Skip to content
1,088 linesCodeBlameRaw
1/**
2 * Home, the workspace's front page: where you're needed now, what happened
3 * since you were last here, and what is running. It never depends on the
4 * calendar: what needs you has no time window, and what happened is
5 * counted over a span you choose, since your last visit by default. Every
6 * number is worked out here from what the services return, so each
7 * definition is tested on its own (home.test.ts) and written down in the
8 * Home guide (apps/docs/.../guides/home.md). Imports only types.
9 */
10import type {
11 AgentSession,
12 AgentSessionKind,
13 Deployment,
14 InboxItem,
15 InstallRequest,
16 Memory,
17 ProjectDeploys,
18 Pull,
19 RepoPath,
20 Statement,
21} from "@g1t/contracts";
22
23const MINUTE = 60_000;
24const HOUR = 60 * MINUTE;
25const DAY = 24 * HOUR;
26
27/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */
28export function dayIn(at: number, timeZone: string | null): string {
29 try {
30 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
31 } catch {
32 return new Date(at).toISOString().slice(0, 10);
33 }
34}
35
36/** The hour of the day (0–23) in a time zone; UTC when it is unknown. */
37export function hourIn(at: number, timeZone: string | null): number {
38 const read = (zone: string) => Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: zone }).format(at)) % 24;
39 try {
40 return read(timeZone || "UTC");
41 } catch {
42 return read("UTC");
43 }
44}
45
46function formatIn(at: number, timeZone: string | null, options: Intl.DateTimeFormatOptions): string {
47 try {
48 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(at);
49 } catch {
50 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(at);
51 }
52}
53
54/** "Oct 9", in the reader's time zone. */
55export function shortDate(at: number, timeZone: string | null): string {
56 return formatIn(at, timeZone, { month: "short", day: "numeric" });
57}
58
59// --- The span -----------------------------------------------------------------
60
61/** Which span "what happened" covers: since your last visit, or a fixed one. */
62export type WindowKey = "last" | "24h" | "7d";
63
64export const WINDOWS: { key: WindowKey; label: string }[] = [
65 { key: "last", label: "Since last visit" },
66 { key: "24h", label: "Last 24 hours" },
67 { key: "7d", label: "Last 7 days" },
68];
69
70/** The furthest back "since your last visit" looks. */
71export const MAX_AWAY_DAYS = 14;
72
73/** A `?window=` value, or the default: since your last visit. */
74export function parseWindow(value: string | null | undefined): WindowKey {
75 return value === "24h" || value === "7d" ? value : "last";
76}
77
78export type Span = {
79 key: WindowKey;
80 /** Epoch ms: the start of the span. */
81 from: number;
82 now: number;
83 /** "Since Tuesday evening", "In the last 24 hours". */
84 words: string;
85 /** How long it covers: "3 days", "45 minutes". */
86 length: string;
87 /**
88 * Why "since your last visit" covers something else: `first` when there
89 * is no visit on record (it shows the last 24 hours), `capped` when the
90 * last one was longer ago than MAX_AWAY_DAYS, `unknown` when it could
91 * not be read (it shows the last 24 hours).
92 */
93 note: "first" | "capped" | "unknown" | null;
94};
95
96const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
97
98/** How long a stretch of time is, roughly: "a moment", "40 minutes", "3 hours", "3 days", "2 weeks". */
99export function duration(ms: number): string {
100 const minutes = Math.floor(Math.max(0, ms) / MINUTE);
101 if (minutes < 1) return "a moment";
102 if (minutes < 60) return plural(minutes, "minute");
103 const hours = Math.floor(minutes / 60);
104 if (hours < 24) return plural(hours, "hour");
105 const days = Math.floor(hours / 24);
106 if (days < 14) return plural(days, "day");
107 return plural(Math.floor(days / 7), "week");
108}
109
110type Part = "night" | "morning" | "afternoon" | "evening";
111
112/**
113 * When `from` was, in words, from `now`: "a moment ago", "40 minutes ago",
114 * "2 hours ago", "this morning", "last night", "yesterday evening",
115 * "Tuesday evening", "Oct 2". The small hours belong to the night before:
116 * 2 a.m. on Wednesday is Tuesday night.
117 */
118export function sinceWords(from: number, now: number, timeZone: string | null): string {
119 const ago = now - from;
120 if (ago < MINUTE) return "a moment ago";
121 if (ago < HOUR) return `${plural(Math.floor(ago / MINUTE), "minute")} ago`;
122 if (ago < 3 * HOUR) return Math.floor(ago / HOUR) === 1 ? "an hour ago" : `${Math.floor(ago / HOUR)} hours ago`;
123 const hour = hourIn(from, timeZone);
124 const part: Part = hour < 5 ? "night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening";
125 // The night is the evening's day's.
126 const day = dayIn(part === "night" ? from - 6 * HOUR : from, timeZone);
127 const today = dayIn(now, timeZone);
128 const yesterday = dayIn(now - DAY, timeZone);
129 const twoDaysAgo = dayIn(now - 2 * DAY, timeZone);
130 if (day === today) return `this ${part === "night" ? "evening" : part}`;
131 if (day === yesterday) return part === "night" ? "last night" : `yesterday ${part}`;
132 if (part === "night" && day === twoDaysAgo) return "the night before last";
133 if (ago < 6 * DAY) return `${formatIn(part === "night" ? from - 6 * HOUR : from, timeZone, { weekday: "long" })} ${part}`;
134 return shortDate(from, timeZone);
135}
136
137/**
138 * The span for `key`: since `lastSeen` (your last visit, kept by notify),
139 * or the last 24 hours or 7 days. Since your last visit falls back to the
140 * last 24 hours when there is none (`null`) or it could not be read
141 * (`undefined`), and to MAX_AWAY_DAYS when it was longer ago.
142 */
143export function spanFor(key: WindowKey, now: number, lastSeen: number | null | undefined, timeZone: string | null): Span {
144 if (key === "7d") return { key, from: now - 7 * DAY, now, words: "In the last 7 days", length: "7 days", note: null };
145 if (key === "24h" || lastSeen == null || lastSeen > now) {
146 const note = key !== "last" ? null : lastSeen === undefined ? "unknown" : "first";
147 return { key, from: now - DAY, now, words: "In the last 24 hours", length: "24 hours", note };
148 }
149 const furthest = now - MAX_AWAY_DAYS * DAY;
150 if (lastSeen < furthest) return { key, from: furthest, now, words: `In the last ${MAX_AWAY_DAYS} days`, length: `${MAX_AWAY_DAYS} days`, note: "capped" };
151 return { key, from: lastSeen, now, words: `Since ${sinceWords(lastSeen, now, timeZone)}`, length: duration(now - lastSeen), note: null };
152}
153
154/** Whether `at` (epoch ms or RFC 3339) falls in the span. */
155export function within(at: number | string | null | undefined, span: Pick<Span, "from" | "now">): boolean {
156 if (at == null) return false;
157 const ms = typeof at === "number" ? at : Date.parse(at);
158 return Number.isFinite(ms) && ms >= span.from && ms <= span.now;
159}
160
161// --- Agent work in the span ---------------------------------------------------
162
163/**
164 * How one piece of agent work that settled in the span went:
165 * - `accepted`: an agent's pull request merged without the agent being sent back to revise it.
166 * - `fixed`: an agent's pull request merged after one or more revisions.
167 * - `finished`: an agent session that finished. Sessions are not reviewed, so they are never `accepted`.
168 * - `dropped`: an agent's pull request closed without merging, or a session that failed or was stopped.
169 * Work still going is not here: it is under Running now.
170 */
171export type TaskOutcome = "accepted" | "fixed" | "finished" | "dropped";
172
173/** Where a task came from. */
174export type TaskSource = "chat" | "schedule" | "colleague" | "code";
175
176export const OUTCOME_LABEL: Record<TaskOutcome, string> = {
177 accepted: "Accepted first time",
178 fixed: "Fixed after review",
179 finished: "Sessions finished",
180 dropped: "Didn't finish",
181};
182
183export const SOURCE_LABEL: Record<TaskSource, string> = {
184 chat: "Chat",
185 schedule: "Schedules",
186 colleague: "Another agent",
187 code: "Code",
188};
189
190/** One mark on the strip. */
191export type WorkTask = {
192 key: string;
193 outcome: TaskOutcome;
194 source: TaskSource;
195 /** Null for a session in a conversation the viewer is not in. */
196 title: string | null;
197 to: string | null;
198 /** When it settled: the strip's order. */
199 at: number;
200};
201
202/** A pull request with where it is: what Home reads from Code. */
203export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & {
204 repo: RepoPath;
205 author: { username: string; kind?: string };
206 /** Who merged it, when it merged. */
207 mergedBy?: string | null;
208};
209
210/** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */
211export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean {
212 const name = pull.author.username;
213 return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent");
214}
215
216/** `acme/web#12`, lowercase: how a pull request and its runs are matched. */
217export function pullKey(repo: RepoPath, number: number): string {
218 return `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
219}
220
221/**
222 * How an agent's pull request settled in the span, or null when it did not
223 * settle in it. `revisions` counts its `revise` runs: each time g1t sent
224 * the agent back for failed checks or a review.
225 */
226export function pullOutcome(pull: CodePull, revisions: number, span: Pick<Span, "from" | "now">): TaskOutcome | null {
227 if (pull.status === "merged") return within(pull.mergedAt, span) ? (revisions > 0 ? "fixed" : "accepted") : null;
228 if (pull.status === "closed") return within(pull.updatedAt, span) ? "dropped" : null;
229 return null;
230}
231
232const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]);
233
234/**
235 * How a session settled in the span, or null. Only a session at the root
236 * of its tree counts: the subagents and colleagues it brought in are part
237 * of its task.
238 */
239export function sessionOutcome(
240 session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">,
241 span: Pick<Span, "from" | "now">,
242): TaskOutcome | null {
243 if (session.parent_id || LIVE.has(session.status)) return null;
244 if (!within(session.finished_at ?? session.updated_at, span)) return null;
245 return session.status === "done" ? "finished" : "dropped";
246}
247
248export function sessionSource(kind: AgentSessionKind): TaskSource {
249 return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague";
250}
251
252/** The agents' work that settled in the span, and how the 7 days before it compare. */
253export type SpanWork = {
254 tasks: WorkTask[];
255 counts: Record<TaskOutcome, number>;
256 /** Tasks by where they came from, most first; only sources with any. */
257 sources: { source: TaskSource; count: number }[];
258 /**
259 * Accepted first time: of the agents' pull requests that merged or closed
260 * in the span, the share that merged without a revision. Null with none.
261 */
262 rate: { value: number; of: number } | null;
263 /** The same share over the 7 days before the span; null when there were none, or they could not all be read. */
264 before: { value: number; of: number } | null;
265 /** Whether Code was read: false for a member without Code access, or when Code did not answer. */
266 code: "read" | "no_access" | "unavailable";
267 /** Whether sessions were read. */
268 sessions: "read" | "unavailable";
269 /** True when a list was cut short before the start of the span, so some work may be missing. */
270 partial: boolean;
271};
272
273export type CodeWork = {
274 pulls: CodePull[];
275 /** `revise` runs per pull request (`pullKey`). */
276 revisions: Record<string, number>;
277 /** Whether every list read reached back to 7 days before the span. */
278 complete: boolean;
279};
280
281export type SessionWork = { sessions: AgentSession[]; complete: boolean };
282
283/** Accepted first time over a set of settled outcomes: null when no pull request settled. */
284export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null {
285 const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped");
286 if (settled.length === 0) return null;
287 return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length };
288}
289
290export function spanWork(input: {
291 span: Pick<Span, "from" | "now">;
292 slug: string;
293 code: CodeWork | null | "no_access";
294 sessions: SessionWork | null;
295}): SpanWork {
296 const { span, slug } = input;
297 const tasks: WorkTask[] = [];
298 let before: SpanWork["before"] = null;
299 const code = input.code;
300 if (code && code !== "no_access") {
301 const prior = { from: span.from - 7 * DAY, now: span.from - 1 };
302 const earlier: TaskOutcome[] = [];
303 for (const pull of code.pulls) {
304 if (!isAgentPull(pull)) continue;
305 const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0;
306 const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`;
307 const outcome = pullOutcome(pull, revisions, span);
308 if (outcome) {
309 const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt);
310 tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at });
311 continue;
312 }
313 const then = pullOutcome(pull, revisions, prior);
314 if (then) earlier.push(then);
315 }
316 before = code.complete ? acceptance(earlier) : null;
317 }
318 for (const session of input.sessions?.sessions ?? []) {
319 const outcome = sessionOutcome(session, span);
320 if (!outcome) continue;
321 tasks.push({
322 key: `session:${session.id}`,
323 outcome,
324 source: sessionSource(session.kind),
325 title: session.visible ? session.title : null,
326 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
327 at: Date.parse(session.finished_at ?? session.updated_at),
328 });
329 }
330 // In the order they settled.
331 tasks.sort((a, b) => a.at - b.at || a.key.localeCompare(b.key));
332 const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0 };
333 const bySource = new Map<TaskSource, number>();
334 for (const task of tasks) {
335 counts[task.outcome]++;
336 bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1);
337 }
338 const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"];
339 return {
340 tasks,
341 counts,
342 sources: [...bySource]
343 .map(([source, count]) => ({ source, count }))
344 .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)),
345 rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)),
346 before,
347 code: code === "no_access" ? "no_access" : code ? "read" : "unavailable",
348 sessions: input.sessions ? "read" : "unavailable",
349 partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete),
350 };
351}
352
353/** The change in percentage points from the 7 days before, rounded: "+6 pts vs the 7 days before", "Same as the 7 days before". */
354export function trendLabel(rate: { value: number } | null, before: { value: number } | null): string | null {
355 if (!rate || !before) return null;
356 const points = Math.round(rate.value * 100) - Math.round(before.value * 100);
357 if (points === 0) return "Same as the 7 days before";
358 return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`;
359}
360
361// --- What else happened in the span -------------------------------------------
362
363/** A change that landed: a pull request merged, by a person or an agent. */
364export type LandedChange = {
365 key: string;
366 title: string;
367 repo: RepoPath;
368 number: number;
369 to: string;
370 at: number;
371 /** An agent's change, or a person's. */
372 agent: boolean;
373 author: string;
374 mergedBy: string | null;
375};
376
377/** Every pull request that merged in the span, newest first. */
378export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
379 return pulls
380 .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))
381 .map((pull) => ({
382 key: `landed:${pullKey(pull.repo, pull.number)}`,
383 title: pull.title,
384 repo: pull.repo,
385 number: pull.number,
386 to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
387 at: Date.parse(pull.mergedAt!),
388 agent: isAgentPull(pull),
389 author: pull.author.username,
390 mergedBy: pull.mergedBy ?? null,
391 }))
392 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
393}
394
395/** A build that finished in the span. */
396export type DeployRow = {
397 key: string;
398 project: string;
399 kind: Deployment["kind"];
400 /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */
401 outcome: "live" | "failed";
402 commit: string;
403 branch: string | null;
404 at: number;
405 to: string;
406 by: string;
407};
408
409/**
410 * Builds that finished in the span, newest first: production builds each,
411 * and previews too. A build that went live and was replaced since still
412 * went live. Skipped builds are left out.
413 */
414export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">): DeployRow[] {
415 const rows: DeployRow[] = [];
416 for (const project of projects) {
417 for (const deploy of project.deployments) {
418 const ended = deploy.finishedAt ?? null;
419 if (!within(ended, span)) continue;
420 const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null;
421 if (!outcome) continue;
422 rows.push({
423 key: `deploy:${project.slug}:${deploy.id}`,
424 project: project.slug,
425 kind: deploy.kind,
426 outcome,
427 commit: deploy.commit.slice(0, 7),
428 branch: deploy.branch,
429 at: Date.parse(ended!),
430 to: `/${workspace}/${project.slug}/deployments/${deploy.id}`,
431 by: deploy.createdBy,
432 });
433 }
434 }
435 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
436}
437
438/** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */
439export type DecisionRow = {
440 key: string;
441 text: string;
442 by: string | null;
443 at: number;
444 to: string;
445 kind: "memory" | "request";
446};
447
448export function decisionsIn(
449 input: { memories: Memory[] | null; requests: InstallRequest[] | null },
450 slug: string,
451 span: Pick<Span, "from" | "now">,
452): DecisionRow[] {
453 const rows: DecisionRow[] = [];
454 for (const memory of input.memories ?? []) {
455 if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue;
456 rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" });
457 }
458 for (const request of input.requests ?? []) {
459 if (request.status === "open" || !within(request.resolved_at, span)) continue;
460 rows.push({
461 key: `request:${request.id}`,
462 text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`,
463 by: request.resolved_by,
464 at: Date.parse(request.resolved_at!),
465 to: `/${slug}/-/marketplace/requests`,
466 kind: "request",
467 });
468 }
469 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
470}
471
472// --- Running now ----------------------------------------------------------------
473
474/** Something going right now: a session, an agent's change, a build. */
475export type RunningRow = {
476 key: string;
477 kind: "session" | "change" | "deploy";
478 title: string;
479 /** One line: who and where. */
480 detail: string;
481 /** "Working", "Queued", "Building". */
482 status: string;
483 /** Epoch ms: when it started. */
484 at: number;
485 to: string;
486};
487
488const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" };
489
490/** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */
491export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] {
492 return sessions
493 .filter((session) => !session.parent_id && SESSION_STATUS[session.status])
494 .map((session) => ({
495 key: `session:${session.id}`,
496 kind: "session" as const,
497 title: session.visible ? session.title : "A session in a conversation you're not in",
498 detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`,
499 status: SESSION_STATUS[session.status]!,
500 at: Date.parse(session.created_at),
501 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
502 }));
503}
504
505/** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */
506export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string };
507
508export function runningChanges(changes: AgentChange[]): RunningRow[] {
509 return changes.map((change) => ({
510 key: `change:${change.key}`,
511 kind: "change" as const,
512 title: change.title,
513 detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`,
514 status: change.chip,
515 at: change.at,
516 to: change.to,
517 }));
518}
519
520/** Builds queued or going now, one per project (its newest). */
521export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] {
522 return overview.flatMap((project) => {
523 const latest = project.latest;
524 if (!latest || (latest.status !== "queued" && latest.status !== "building")) return [];
525 return [
526 {
527 key: `deploy:${project.slug}:${latest.id}`,
528 kind: "deploy" as const,
529 title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`,
530 detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`,
531 status: latest.status === "queued" ? "Queued" : "Building",
532 at: Date.parse(latest.createdAt),
533 to: `/${slug}/${project.slug}/deployments/${latest.id}`,
534 },
535 ];
536 });
537}
538
539/** Everything running, longest-going first within each kind: sessions, then changes, then builds. */
540export function running(parts: RunningRow[][]): RunningRow[] {
541 const order = { session: 0, change: 1, deploy: 2 } as const;
542 return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
543}
544
545// --- Needs you ------------------------------------------------------------------
546
547/**
548 * What a row is. Tiers, most pressing first:
549 * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`);
550 * 1. agent work that has stopped and can't go on without you;
551 * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something);
552 * 3. something stuck that may need a look;
553 * 4. an unread notification that is a warning or a failure;
554 * 5. a mention or a direct message.
555 */
556export type AttentionKind =
557 | "limit"
558 | "deploy"
559 | "session_cap"
560 | "agent_budget"
561 | "pull_blocked"
562 | "ready"
563 | "review"
564 | "invitation"
565 | "install_request"
566 | "agent_waiting"
567 | "stuck"
568 | "runner"
569 | "notification"
570 | "chat";
571
572export const TIER: Record<AttentionKind, number> = {
573 limit: 0,
574 deploy: 0,
575 session_cap: 1,
576 agent_budget: 1,
577 pull_blocked: 1,
578 ready: 2,
579 review: 2,
580 invitation: 2,
581 install_request: 2,
582 agent_waiting: 2,
583 stuck: 3,
584 runner: 3,
585 notification: 4,
586 chat: 5,
587};
588
589export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
590
591/** One row of Needs you. */
592export type AttentionRow = {
593 key: string;
594 kind: AttentionKind;
595 title: string;
596 /** One line: what it is and where. */
597 detail: string;
598 /** What is at stake, in a few words. */
599 stake: Stake | null;
600 /** The action, right there. */
601 action: { label: string; to: string };
602 /** Who is on it: the agent, or the person who asked. */
603 owner: { name: string; agent: boolean } | null;
604 /** Epoch ms: when it started waiting. */
605 at: number;
606 /** Where it came from, for a member without Code access never `code`. */
607 from: "code" | "agents" | "notifications" | "chat" | "marketplace";
608};
609
610/**
611 * Most pressing first: by tier, then what has waited longest, then by key
612 * so the order never depends on the order rows were read. One of each key,
613 * and one of each place: a notification about a pull request already on
614 * the list is left out.
615 */
616export function rankAttention(rows: AttentionRow[]): AttentionRow[] {
617 const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
618 const keys = new Set<string>();
619 const places = new Set<string>();
620 return sorted.filter((row) => {
621 const place = row.action.to.split(/[?#]/)[0];
622 if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false;
623 keys.add(row.key);
624 places.add(place);
625 return true;
626 });
627}
628
629/** Start here: the first row by `rankAttention`, with why it was picked. */
630export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null {
631 const row = ranked[0];
632 if (!row) return null;
633 const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length;
634 const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : "";
635 return { row, why: `${WHY[row.kind]}${tail}` };
636}
637
638const WHY: Record<AttentionKind, string> = {
639 limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.",
640 install_request: "A member asked for it, and only an owner can add it or turn it down.",
641 deploy: "Production is serving an older build until this is fixed, so it comes before anything else.",
642 session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.",
643 agent_budget: "An agent used up its budget and takes no new work until it is raised.",
644 pull_blocked: "An agent stopped on this change and won't go on until a person decides.",
645 ready: "The work is done and only waits for you to land it.",
646 review: "You were asked by name, so it waits for your verdict.",
647 invitation: "It is addressed to you; no one else can answer it.",
648 agent_waiting: "An agent is waiting on you to go on.",
649 stuck: "A running agent has stopped reporting and may be stuck.",
650 runner: "A job is waiting for a runner that is not there.",
651 notification: "It is the most pressing unread notification.",
652 chat: "Nothing else is waiting, and this is the oldest message for you.",
653};
654
655// --- Rows from each source ----------------------------------------------------
656
657/** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */
658export type CodeNeed = {
659 key: string;
660 reason: string;
661 repo: RepoPath | null;
662 ref: string | null;
663 title: string;
664 ask: string;
665 by: { name: string; agent: boolean } | null;
666 for: string | null;
667 at: number;
668 to: string;
669 facts: { label: string; value: string; tone: string | null }[];
670};
671
672const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null;
673
674const REASON_STAKE: Record<string, Stake> = {
675 blocking: { text: "Blocking", tone: "danger" },
676 checks_failing: { text: "Checks failing", tone: "danger" },
677 outside_guardrails: { text: "Over a guardrail", tone: "warn" },
678 low_confidence: { text: "Low confidence", tone: "warn" },
679 needs_review: { text: "Needs a verdict", tone: "warn" },
680 stalled: { text: "Stalled", tone: "warn" },
681};
682
683/** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */
684export function codeRow(need: CodeNeed): AttentionRow {
685 const prefix = need.key.split(":")[0];
686 const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null;
687 const detail = where ? `${where} · ${need.ask}` : need.ask;
688 const owner = need.by;
689 const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const };
690 switch (prefix) {
691 case "deploy":
692 return {
693 ...base,
694 kind: "deploy",
695 stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" },
696 action: { label: "See the build", to: need.to },
697 };
698 case "invitation":
699 return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } };
700 case "review": {
701 const lines = fact(need, "Lines");
702 return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } };
703 }
704 case "runner":
705 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
706 case "run": {
707 const cost = fact(need, "Cost so far");
708 return {
709 ...base,
710 kind: "stuck",
711 stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" },
712 action: { label: "Look", to: need.to },
713 };
714 }
715 default: {
716 if (need.reason === "ready_to_merge") {
717 const files = fact(need, "Files changed");
718 return {
719 ...base,
720 kind: "ready",
721 stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" },
722 action: { label: "Merge", to: need.to },
723 };
724 }
725 return {
726 ...base,
727 kind: "pull_blocked",
728 stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" },
729 action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to },
730 };
731 }
732 }
733}
734
735/** "$4.20", or "<$0.01" for a sliver. */
736export function dollars(micros: number): string {
737 const value = micros / 1_000_000;
738 if (value > 0 && value < 0.01) return "<$0.01";
739 return `$${value.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
740}
741
742/** A session stopped at its spend cap that the viewer may approve more for. */
743export function sessionCapRow(session: AgentSession, slug: string): AttentionRow {
744 const cap = session.cap_micros;
745 return {
746 key: `session:${session.id}`,
747 kind: "session_cap",
748 title: session.visible ? session.title : `A session of @${session.agent_handle}`,
749 detail: `@${session.agent_handle} stopped at its spend cap${session.channel_name ? ` in #${session.channel_name}` : ""}.`,
750 stake: { text: cap != null ? `${dollars(session.charged_micros)} of ${dollars(cap)} cap` : `${dollars(session.charged_micros)} spent`, tone: "warn" },
751 action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` },
752 owner: { name: session.agent_handle, agent: true },
753 at: Date.parse(session.updated_at),
754 from: "agents",
755 };
756}
757
758export type AgentLike = {
759 id: string;
760 handle: string;
761 display_name: string;
762 status: string;
763 spent_month_micros: number;
764 budget: { monthly_micros: number | null };
765 updated_at: string;
766};
767
768/** An agent out of budget (for those who may raise it), or waiting on someone. */
769export function agentRow(agent: AgentLike, slug: string): AttentionRow | null {
770 const base = {
771 key: `agent:${agent.id}`,
772 owner: { name: agent.handle, agent: true },
773 at: Date.parse(agent.updated_at),
774 from: "agents" as const,
775 };
776 if (agent.status === "out_of_budget") {
777 const budget = agent.budget.monthly_micros;
778 return {
779 ...base,
780 kind: "agent_budget",
781 title: `${agent.display_name} is out of budget`,
782 detail: `@${agent.handle} takes no new work this month until its budget is raised.`,
783 stake: { text: budget != null ? `${dollars(Math.max(0, budget - agent.spent_month_micros))} left` : "Budget used", tone: "danger" },
784 action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` },
785 };
786 }
787 if (agent.status === "waiting") {
788 return {
789 ...base,
790 kind: "agent_waiting",
791 title: `${agent.display_name} is waiting`,
792 detail: `@${agent.handle} is waiting before it goes on.`,
793 stake: null,
794 action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` },
795 };
796 }
797 return null;
798}
799
800const NOTIFICATION_STAKE: Record<string, string> = {
801 agent: "Agent waiting",
802 review_requested: "Review requested",
803 assign: "Assigned to you",
804 mention: "Mentioned",
805 team_mention: "Team mentioned",
806 ci_activity: "CI activity",
807 security_alert: "Security alert",
808 state_change: "State changed",
809 author: "Your work",
810 comment: "Comment",
811 manual: "Subscribed",
812 subscribed: "Watching",
813};
814
815/**
816 * An unread notification that is a warning (someone or something is
817 * waiting) or a failure, in this workspace. A member without Code access
818 * gets none about a repository.
819 */
820export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null {
821 if (item.readAt != null || item.doneAt != null) return null;
822 if (item.severity !== "warning" && item.severity !== "error") return null;
823 const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug;
824 if (!inWorkspace) return null;
825 if (!code && item.repo) return null;
826 return {
827 key: `notification:${item.id}`,
828 kind: "notification",
829 title: item.body || item.title,
830 detail: item.body ? item.title : (item.repo ?? "A notification"),
831 stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" },
832 action: { label: "Open", to: item.url },
833 owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null,
834 at: Date.parse(item.updatedAt),
835 from: "notifications",
836 };
837}
838
839export type ChatEntryLike = {
840 channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null };
841 title: string;
842 muted: boolean;
843 unread: number;
844 mentions: number;
845};
846
847/** A conversation with a mention of you, or a direct message you haven't read. */
848export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null {
849 if (entry.muted) return null;
850 if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null;
851 const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`;
852 const dm = entry.channel.kind === "dm";
853 return {
854 key: `chat:${entry.channel.id}`,
855 kind: "chat",
856 title: dm ? entry.title : `#${entry.title}`,
857 detail: dm ? "A direct message you haven't read." : "You were mentioned.",
858 stake: {
859 text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`,
860 tone: entry.mentions > 0 ? "accent" : null,
861 },
862 action: { label: "Reply", to },
863 owner: null,
864 at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0,
865 from: "chat",
866 };
867}
868
869/** A member's open request to add something from the Marketplace, for an owner. */
870export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null {
871 if (request.status !== "open") return null;
872 return {
873 key: `request:${request.id}`,
874 kind: "install_request",
875 title: `${request.requested_by} asked to add ${request.name}`,
876 detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.",
877 stake: { text: "Only owners can add it", tone: null },
878 action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` },
879 owner: { name: request.requested_by, agent: false },
880 at: Date.parse(request.requested_at),
881 from: "marketplace",
882 };
883}
884
885/**
886 * The workspace's agents at 100% of its monthly agent budget, for those who
887 * may raise it. `since` is when it was reached as far as is known: the
888 * newest session that stopped at its cap, else the start of the month.
889 */
890export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null {
891 if (input.alert == null || input.alert < 100) return null;
892 return {
893 key: "limit:agents",
894 kind: "limit",
895 title: "Agents used up the workspace's budget",
896 detail: `${dollars(input.spentMicros)} spent this month. Agents start nothing new until the budget is raised or the month turns.`,
897 stake: { text: "No new agent work", tone: "danger" },
898 action: { label: "Raise the budget", to: `/${slug}/-/spend` },
899 owner: null,
900 at: input.since,
901 from: "agents",
902 };
903}
904
905/** What Needs you and Start here show, and which sources did not answer. */
906export type Attention = {
907 rows: AttentionRow[];
908 start: { row: AttentionRow; why: string } | null;
909 /** Sources that did not answer, by name, for the card to say so. */
910 missing: string[];
911};
912
913export function attention(input: {
914 slug: string;
915 code: boolean;
916 codeNeeds: CodeNeed[] | null;
917 capped: AgentSession[] | null;
918 agents: AgentLike[] | null;
919 canManage: boolean;
920 /** The workspace's agent budget, for those who may manage agents; null for anyone else. */
921 limit?: { alert: number | null; spentMicros: number; since: number } | null;
922 /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */
923 requests?: InstallRequest[] | null;
924 notifications: InboxItem[] | null;
925 chat: ChatEntryLike[] | null;
926}): Attention {
927 const rows: AttentionRow[] = [];
928 const missing: string[] = [];
929 if (input.code) {
930 if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow));
931 else missing.push("Code");
932 }
933 if (input.limit && input.canManage) {
934 const row = limitRow(input.limit, input.slug);
935 if (row) rows.push(row);
936 }
937 for (const request of input.requests ?? []) {
938 const row = installRequestRow(request, input.slug);
939 if (row) rows.push(row);
940 }
941 if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug)));
942 if (input.agents) {
943 const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle));
944 for (const agent of input.agents) {
945 if (agent.status === "out_of_budget" && !input.canManage) continue;
946 if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue;
947 const row = agentRow(agent, input.slug);
948 if (row) rows.push(row);
949 }
950 }
951 if (input.capped == null || input.agents == null) missing.push("Agents");
952 if (input.notifications) {
953 for (const item of input.notifications) {
954 const row = notificationRow(item, input.slug, input.code);
955 if (row) rows.push(row);
956 }
957 } else missing.push("Notifications");
958 if (input.chat) {
959 for (const entry of input.chat) {
960 const row = chatRow(entry, input.slug);
961 if (row) rows.push(row);
962 }
963 } else missing.push("Chat");
964 const ranked = rankAttention(rows);
965 return { rows: ranked, start: startHere(ranked), missing };
966}
967
968// --- Spend in the span ----------------------------------------------------------
969
970/** Money in, which the statement lists but which is not spend. */
971const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]);
972
973export type Spend = {
974 /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */
975 from: string;
976 to: string;
977 /** Usage at price over those days, in micros. */
978 totalMicros: number;
979 /** A line per kind of charge, as the statement names it, most first. */
980 lines: { kind: string; micros: number; count: number }[];
981 /** Usage at price this month so far. */
982 monthMicros: number;
983};
984
985/** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */
986export function spanMonths(span: Pick<Span, "from" | "now">): string[] {
987 const months: string[] = [];
988 const first = new Date(span.from);
989 let year = first.getUTCFullYear();
990 let month = first.getUTCMonth();
991 const last = new Date(span.now).toISOString().slice(0, 7);
992 for (let i = 0; i < 3; i++) {
993 const key = `${year}-${String(month + 1).padStart(2, "0")}`;
994 months.push(key);
995 if (key >= last) break;
996 month++;
997 if (month === 12) {
998 month = 0;
999 year++;
1000 }
1001 }
1002 return months;
1003}
1004
1005/**
1006 * The span's spend from the statements grouped by day: each usage line at
1007 * its price (what was charged, plus what the plan, a trial, a pool or a
1008 * discount paid of it), leaving out money in. The statement keeps whole
1009 * UTC days, so the span is counted from the start of the UTC day it began.
1010 * `statements` is the months of `spanMonths`, the current one last.
1011 */
1012export function spendIn(statements: Pick<Statement, "groups" | "totals">[], span: Pick<Span, "from" | "now">): Spend {
1013 const from = new Date(span.from).toISOString().slice(0, 10);
1014 const to = new Date(span.now).toISOString().slice(0, 10);
1015 const byKind = new Map<string, { micros: number; count: number }>();
1016 for (const statement of statements) {
1017 for (const group of statement.groups) {
1018 if (group.key < from || group.key > to) continue;
1019 for (const line of group.lines) {
1020 if (MONEY_IN.has(line.kind)) continue;
1021 const micros = line.priceMicros ?? line.chargedMicros;
1022 const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 };
1023 byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count });
1024 }
1025 }
1026 }
1027 const lines = [...byKind]
1028 .map(([kind, value]) => ({ kind, ...value }))
1029 .filter((line) => line.micros > 0)
1030 .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind));
1031 const current = statements.at(-1);
1032 return {
1033 from,
1034 to,
1035 totalMicros: lines.reduce((sum, line) => sum + line.micros, 0),
1036 lines,
1037 monthMicros: current ? (current.totals.priceMicros ?? current.totals.chargedMicros) : 0,
1038 };
1039}
1040
1041// --- The sentence -----------------------------------------------------------
1042
1043/**
1044 * The sentence under the heading: "Since Tuesday evening, agents finished
1045 * 35 tasks and 12 changes landed. 4 needed a fix after review." Finished
1046 * counts every task that settled well: accepted, fixed, and sessions that
1047 * finished.
1048 */
1049export function spanSentence(span: Pick<Span, "words">, work: Pick<SpanWork, "counts" | "tasks"> | null, landed: number | null): string {
1050 const lead = span.words;
1051 if (!work) return `${lead}, agent work couldn't be read.`;
1052 const { accepted, fixed, finished, dropped } = work.counts;
1053 const done = accepted + fixed + finished;
1054 const changes = landed && landed > 0 ? `${plural(landed, "change")} landed` : null;
1055 if (work.tasks.length === 0 && !changes) return `${lead}, nothing new has settled.`;
1056 const first =
1057 done > 0
1058 ? `${lead}, agents finished ${plural(done, "task")}${changes ? ` and ${changes}` : ""}.`
1059 : changes
1060 ? `${lead}, ${changes}${work.tasks.length > 0 ? "; agents finished none of their tasks" : ""}.`
1061 : `${lead}, agents finished no tasks.`;
1062 const after: string[] = [];
1063 if (fixed > 0) after.push(`${fixed.toLocaleString("en-US")} needed a fix after review`);
1064 if (dropped > 0) after.push(`${dropped.toLocaleString("en-US")} didn't finish`);
1065 if (after.length === 0) return first;
1066 const text = after.length > 1 ? `${after[0]}, and ${after[1]}` : after[0]!;
1067 return `${first} ${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
1068}
1069
1070/** "8 things need you.", or that nothing does. */
1071export function waitingSentence(total: number): string {
1072 if (total === 0) return "Nothing needs you right now.";
1073 return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`;
1074}
1075
1076/** How long something has waited: "45 min", "17 h", "2 d". */
1077export function waited(at: number, now: number): string {
1078 const minutes = Math.max(0, Math.floor((now - at) / MINUTE));
1079 if (minutes < 60) return `${minutes} min`;
1080 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`;
1081 return `${Math.floor(minutes / (24 * 60))} d`;
1082}
1083
1084/** "3:40 PM" when it is the same day as `now`, else "Oct 7". */
1085export function whenShort(at: number, now: number, timeZone: string | null): string {
1086 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
1087 return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone);
1088}