Skip to content
1,085 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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