Skip to content
1,544 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 {
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.11 ActivityDigest,
12 ActorCount,
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.13 AgentSession,
14 AgentSessionKind,
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.15 ChatActivity,
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.16 Deployment,
17 InboxItem,
18 InstallRequest,
19 Memory,
20 ProjectDeploys,
21 Pull,
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.22 RepoDigest,
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.23 RepoPath,
24 Statement,
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.25 WorkflowRun,
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.26} from "@g1t/contracts";
27
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.28import { money } from "./money.ts";
29
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.30const MINUTE = 60_000;
31const HOUR = 60 * MINUTE;
32const DAY = 24 * HOUR;
33
34/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown or invalid. */
35export function dayIn(at: number, timeZone: string | null): string {
36 try {
37 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
38 } catch {
39 return new Date(at).toISOString().slice(0, 10);
40 }
41}
42
43/** The hour of the day (0–23) in a time zone; UTC when it is unknown. */
44export function hourIn(at: number, timeZone: string | null): number {
45 const read = (zone: string) => Number(new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: zone }).format(at)) % 24;
46 try {
47 return read(timeZone || "UTC");
48 } catch {
49 return read("UTC");
50 }
51}
52
53function formatIn(at: number, timeZone: string | null, options: Intl.DateTimeFormatOptions): string {
54 try {
55 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(at);
56 } catch {
57 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(at);
58 }
59}
60
61/** "Oct 9", in the reader's time zone. */
62export function shortDate(at: number, timeZone: string | null): string {
63 return formatIn(at, timeZone, { month: "short", day: "numeric" });
64}
65
66// --- The span -----------------------------------------------------------------
67
68/** Which span "what happened" covers: since your last visit, or a fixed one. */
69export type WindowKey = "last" | "24h" | "7d";
70
71export const WINDOWS: { key: WindowKey; label: string }[] = [
72 { key: "last", label: "Since last visit" },
73 { key: "24h", label: "Last 24 hours" },
74 { key: "7d", label: "Last 7 days" },
75];
76
77/** The furthest back "since your last visit" looks. */
78export const MAX_AWAY_DAYS = 14;
79
80/** A `?window=` value, or the default: since your last visit. */
81export function parseWindow(value: string | null | undefined): WindowKey {
82 return value === "24h" || value === "7d" ? value : "last";
83}
84
85export type Span = {
86 key: WindowKey;
87 /** Epoch ms: the start of the span. */
88 from: number;
89 now: number;
90 /** "Since Tuesday evening", "In the last 24 hours". */
91 words: string;
92 /** How long it covers: "3 days", "45 minutes". */
93 length: string;
94 /**
95 * Why "since your last visit" covers something else: `first` when there
96 * is no visit on record (it shows the last 24 hours), `capped` when the
97 * last one was longer ago than MAX_AWAY_DAYS, `unknown` when it could
98 * not be read (it shows the last 24 hours).
99 */
100 note: "first" | "capped" | "unknown" | null;
101};
102
103const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
104
105/** How long a stretch of time is, roughly: "a moment", "40 minutes", "3 hours", "3 days", "2 weeks". */
106export function duration(ms: number): string {
107 const minutes = Math.floor(Math.max(0, ms) / MINUTE);
108 if (minutes < 1) return "a moment";
109 if (minutes < 60) return plural(minutes, "minute");
110 const hours = Math.floor(minutes / 60);
111 if (hours < 24) return plural(hours, "hour");
112 const days = Math.floor(hours / 24);
113 if (days < 14) return plural(days, "day");
114 return plural(Math.floor(days / 7), "week");
115}
116
117type Part = "night" | "morning" | "afternoon" | "evening";
118
119/**
120 * When `from` was, in words, from `now`: "a moment ago", "40 minutes ago",
121 * "2 hours ago", "this morning", "last night", "yesterday evening",
122 * "Tuesday evening", "Oct 2". The small hours belong to the night before:
123 * 2 a.m. on Wednesday is Tuesday night.
124 */
125export function sinceWords(from: number, now: number, timeZone: string | null): string {
126 const ago = now - from;
127 if (ago < MINUTE) return "a moment ago";
128 if (ago < HOUR) return `${plural(Math.floor(ago / MINUTE), "minute")} ago`;
129 if (ago < 3 * HOUR) return Math.floor(ago / HOUR) === 1 ? "an hour ago" : `${Math.floor(ago / HOUR)} hours ago`;
130 const hour = hourIn(from, timeZone);
131 const part: Part = hour < 5 ? "night" : hour < 12 ? "morning" : hour < 17 ? "afternoon" : "evening";
132 // The night is the evening's day's.
133 const day = dayIn(part === "night" ? from - 6 * HOUR : from, timeZone);
134 const today = dayIn(now, timeZone);
135 const yesterday = dayIn(now - DAY, timeZone);
136 const twoDaysAgo = dayIn(now - 2 * DAY, timeZone);
137 if (day === today) return `this ${part === "night" ? "evening" : part}`;
138 if (day === yesterday) return part === "night" ? "last night" : `yesterday ${part}`;
139 if (part === "night" && day === twoDaysAgo) return "the night before last";
140 if (ago < 6 * DAY) return `${formatIn(part === "night" ? from - 6 * HOUR : from, timeZone, { weekday: "long" })} ${part}`;
141 return shortDate(from, timeZone);
142}
143
144/**
145 * The span for `key`: since `lastSeen` (your last visit, kept by notify),
146 * or the last 24 hours or 7 days. Since your last visit falls back to the
147 * last 24 hours when there is none (`null`) or it could not be read
148 * (`undefined`), and to MAX_AWAY_DAYS when it was longer ago.
149 */
150export function spanFor(key: WindowKey, now: number, lastSeen: number | null | undefined, timeZone: string | null): Span {
151 if (key === "7d") return { key, from: now - 7 * DAY, now, words: "In the last 7 days", length: "7 days", note: null };
152 if (key === "24h" || lastSeen == null || lastSeen > now) {
153 const note = key !== "last" ? null : lastSeen === undefined ? "unknown" : "first";
154 return { key, from: now - DAY, now, words: "In the last 24 hours", length: "24 hours", note };
155 }
156 const furthest = now - MAX_AWAY_DAYS * DAY;
157 if (lastSeen < furthest) return { key, from: furthest, now, words: `In the last ${MAX_AWAY_DAYS} days`, length: `${MAX_AWAY_DAYS} days`, note: "capped" };
158 return { key, from: lastSeen, now, words: `Since ${sinceWords(lastSeen, now, timeZone)}`, length: duration(now - lastSeen), note: null };
159}
160
161/** Whether `at` (epoch ms or RFC 3339) falls in the span. */
162export function within(at: number | string | null | undefined, span: Pick<Span, "from" | "now">): boolean {
163 if (at == null) return false;
164 const ms = typeof at === "number" ? at : Date.parse(at);
165 return Number.isFinite(ms) && ms >= span.from && ms <= span.now;
166}
167
168// --- Agent work in the span ---------------------------------------------------
169
170/**
171 * How one piece of agent work that settled in the span went:
172 * - `accepted`: an agent's pull request merged without the agent being sent back to revise it.
173 * - `fixed`: an agent's pull request merged after one or more revisions.
174 * - `finished`: an agent session that finished. Sessions are not reviewed, so they are never `accepted`.
175 * - `dropped`: an agent's pull request closed without merging, or a session that failed or was stopped.
176 * Work still going is not here: it is under Running now.
177 */
178export type TaskOutcome = "accepted" | "fixed" | "finished" | "dropped";
179
180/** Where a task came from. */
181export type TaskSource = "chat" | "schedule" | "colleague" | "code";
182
183export const OUTCOME_LABEL: Record<TaskOutcome, string> = {
184 accepted: "Accepted first time",
185 fixed: "Fixed after review",
186 finished: "Sessions finished",
187 dropped: "Didn't finish",
188};
189
190export const SOURCE_LABEL: Record<TaskSource, string> = {
191 chat: "Chat",
192 schedule: "Schedules",
193 colleague: "Another agent",
194 code: "Code",
195};
196
197/** One mark on the strip. */
198export type WorkTask = {
199 key: string;
200 outcome: TaskOutcome;
201 source: TaskSource;
202 /** Null for a session in a conversation the viewer is not in. */
203 title: string | null;
204 to: string | null;
205 /** When it settled: the strip's order. */
206 at: number;
207};
208
209/** A pull request with where it is: what Home reads from Code. */
210export type CodePull = Pick<Pull, "number" | "title" | "status" | "mergedAt" | "createdAt" | "updatedAt"> & {
211 repo: RepoPath;
212 author: { username: string; kind?: string };
213 /** Who merged it, when it merged. */
214 mergedBy?: string | null;
215};
216
217/** Whether a pull request is an agent's change: g1t made it, or an agent account opened it. */
218export function isAgentPull(pull: { author: { username: string; kind?: string } }): boolean {
219 const name = pull.author.username;
220 return pull.author.kind === "agent" || name === "g1t" || name.endsWith("-agent");
221}
222
223/** `acme/web#12`, lowercase: how a pull request and its runs are matched. */
224export function pullKey(repo: RepoPath, number: number): string {
225 return `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
226}
227
228/**
229 * How an agent's pull request settled in the span, or null when it did not
230 * settle in it. `revisions` counts its `revise` runs: each time g1t sent
231 * the agent back for failed checks or a review.
232 */
233export function pullOutcome(pull: CodePull, revisions: number, span: Pick<Span, "from" | "now">): TaskOutcome | null {
234 if (pull.status === "merged") return within(pull.mergedAt, span) ? (revisions > 0 ? "fixed" : "accepted") : null;
235 if (pull.status === "closed") return within(pull.updatedAt, span) ? "dropped" : null;
236 return null;
237}
238
239const LIVE: ReadonlySet<string> = new Set(["queued", "working", "waiting", "needs_approval"]);
240
241/**
242 * How a session settled in the span, or null. Only a session at the root
243 * of its tree counts: the subagents and colleagues it brought in are part
244 * of its task.
245 */
246export function sessionOutcome(
247 session: Pick<AgentSession, "parent_id" | "status" | "updated_at" | "finished_at">,
248 span: Pick<Span, "from" | "now">,
249): TaskOutcome | null {
250 if (session.parent_id || LIVE.has(session.status)) return null;
251 if (!within(session.finished_at ?? session.updated_at, span)) return null;
252 return session.status === "done" ? "finished" : "dropped";
253}
254
255export function sessionSource(kind: AgentSessionKind): TaskSource {
256 return kind === "routine" ? "schedule" : kind === "chat" ? "chat" : "colleague";
257}
258
259/** The agents' work that settled in the span, and how the 7 days before it compare. */
260export type SpanWork = {
261 tasks: WorkTask[];
262 counts: Record<TaskOutcome, number>;
263 /** Tasks by where they came from, most first; only sources with any. */
264 sources: { source: TaskSource; count: number }[];
265 /**
266 * Accepted first time: of the agents' pull requests that merged or closed
267 * in the span, the share that merged without a revision. Null with none.
268 */
269 rate: { value: number; of: number } | null;
270 /** The same share over the 7 days before the span; null when there were none, or they could not all be read. */
271 before: { value: number; of: number } | null;
272 /** Whether Code was read: false for a member without Code access, or when Code did not answer. */
273 code: "read" | "no_access" | "unavailable";
274 /** Whether sessions were read. */
275 sessions: "read" | "unavailable";
276 /** True when a list was cut short before the start of the span, so some work may be missing. */
277 partial: boolean;
278};
279
280export type CodeWork = {
281 pulls: CodePull[];
282 /** `revise` runs per pull request (`pullKey`). */
283 revisions: Record<string, number>;
284 /** Whether every list read reached back to 7 days before the span. */
285 complete: boolean;
286};
287
288export type SessionWork = { sessions: AgentSession[]; complete: boolean };
289
290/** Accepted first time over a set of settled outcomes: null when no pull request settled. */
291export function acceptance(outcomes: TaskOutcome[]): { value: number; of: number } | null {
292 const settled = outcomes.filter((o) => o === "accepted" || o === "fixed" || o === "dropped");
293 if (settled.length === 0) return null;
294 return { value: settled.filter((o) => o === "accepted").length / settled.length, of: settled.length };
295}
296
297export function spanWork(input: {
298 span: Pick<Span, "from" | "now">;
299 slug: string;
300 code: CodeWork | null | "no_access";
301 sessions: SessionWork | null;
302}): SpanWork {
303 const { span, slug } = input;
304 const tasks: WorkTask[] = [];
305 let before: SpanWork["before"] = null;
306 const code = input.code;
307 if (code && code !== "no_access") {
308 const prior = { from: span.from - 7 * DAY, now: span.from - 1 };
309 const earlier: TaskOutcome[] = [];
310 for (const pull of code.pulls) {
311 if (!isAgentPull(pull)) continue;
312 const revisions = code.revisions[pullKey(pull.repo, pull.number)] ?? 0;
313 const to = `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`;
314 const outcome = pullOutcome(pull, revisions, span);
315 if (outcome) {
316 const at = Date.parse(pull.status === "merged" && pull.mergedAt ? pull.mergedAt : pull.updatedAt);
317 tasks.push({ key: `pull:${pullKey(pull.repo, pull.number)}`, outcome, source: "code", title: pull.title, to, at });
318 continue;
319 }
320 const then = pullOutcome(pull, revisions, prior);
321 if (then) earlier.push(then);
322 }
323 before = code.complete ? acceptance(earlier) : null;
324 }
325 for (const session of input.sessions?.sessions ?? []) {
326 const outcome = sessionOutcome(session, span);
327 if (!outcome) continue;
328 tasks.push({
329 key: `session:${session.id}`,
330 outcome,
331 source: sessionSource(session.kind),
332 title: session.visible ? session.title : null,
333 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
334 at: Date.parse(session.finished_at ?? session.updated_at),
335 });
336 }
337 // In the order they settled.
338 tasks.sort((a, b) => a.at - b.at || a.key.localeCompare(b.key));
339 const counts: Record<TaskOutcome, number> = { accepted: 0, fixed: 0, finished: 0, dropped: 0 };
340 const bySource = new Map<TaskSource, number>();
341 for (const task of tasks) {
342 counts[task.outcome]++;
343 bySource.set(task.source, (bySource.get(task.source) ?? 0) + 1);
344 }
345 const sourceOrder: TaskSource[] = ["chat", "code", "schedule", "colleague"];
346 return {
347 tasks,
348 counts,
349 sources: [...bySource]
350 .map(([source, count]) => ({ source, count }))
351 .sort((a, b) => b.count - a.count || sourceOrder.indexOf(a.source) - sourceOrder.indexOf(b.source)),
352 rate: acceptance(tasks.filter((t) => t.source === "code").map((t) => t.outcome)),
353 before,
354 code: code === "no_access" ? "no_access" : code ? "read" : "unavailable",
355 sessions: input.sessions ? "read" : "unavailable",
356 partial: (code != null && code !== "no_access" && !code.complete) || (input.sessions != null && !input.sessions.complete),
357 };
358}
359
360/** The change in percentage points from the 7 days before, rounded: "+6 pts vs the 7 days before", "Same as the 7 days before". */
361export function trendLabel(rate: { value: number } | null, before: { value: number } | null): string | null {
362 if (!rate || !before) return null;
363 const points = Math.round(rate.value * 100) - Math.round(before.value * 100);
364 if (points === 0) return "Same as the 7 days before";
365 return `${points > 0 ? "+" : "−"}${Math.abs(points)} pts vs the 7 days before`;
366}
367
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.368// --- People and agents in the span -----------------------------------------------
369
370/**
371 * Who an actor is. Actors are member keys, as the events and chat services
372 * return them: `user:<id>` for an account, `agent:<id>` for one of the
373 * workspace's agents acting as itself, `""` for nobody (a push a mirror
374 * copied in). An agent is one of the workspace's agents, or g1t itself;
375 * everyone else is a person. `agents` names more keys that are agents'
376 * (accounts of kind `agent`), when known.
377 */
378export type ActorKind = "person" | "agent";
379
380/** g1t's own account, as events name it (identity's `AGENT_ID`). */
381export const G1T_ACTOR = "user:usr_g1t_agent";
382
383export function actorKind(key: string, agents?: ReadonlySet<string>): ActorKind {
384 return key.startsWith("agent:") || key === G1T_ACTOR || agents?.has(key) ? "agent" : "person";
385}
386
387/** Whether a username is an agent's: g1t, or an agent account. */
388export function isAgentName(name: string | null | undefined): boolean {
389 return name === "g1t" || (name != null && name.endsWith("-agent"));
390}
391
392/** A repository as the digest's links and lines need it. */
393export type RepoRef = RepoPath & { defaultBranch: string };
394
395/** Someone who did something in the span: for the faces beside a column. */
396export type DigestActor = {
397 key: string;
398 kind: ActorKind;
399 /** Their username or handle; `someone` when it could not be read. */
400 name: string;
401 /** How many things they did, over every line: the order of the faces. */
402 count: number;
403};
404
405/** One line of a column: what was done, with its count, and where that is listed. */
406export type DigestLine = {
407 key: string;
408 text: string;
409 to: string | null;
410};
411
412export type DigestColumn = {
413 kind: ActorKind;
414 lines: DigestLine[];
415 /** Most active first. */
416 actors: DigestActor[];
417};
418
419/** What the sentence under the heading needs. */
420export type DigestSummary = {
421 /** Distinct people who did anything. */
422 people: number;
423 /** Commits people pushed. */
424 commits: number;
425 /** Pull requests people merged. */
426 merged: number;
427 /** Agent tasks that settled well, and that did not. */
428 finished: number;
429 dropped: number;
430 /** Messages people sent. */
431 said: number;
432 /** Whether anything at all was counted, in either column. */
433 anything: boolean;
434 /** Whether what people did could be read at all (events and chat both silent: no). */
435 peopleRead: boolean;
436};
437
438export type Digest = {
439 people: DigestColumn;
440 agents: DigestColumn;
441 summary: DigestSummary;
442 /** Sources that did not answer, by name, for the card to say so: Code, Chat, Agents. */
443 missing: string[];
444 /** True when a source was cut short, so some counts are low. */
445 partial: boolean;
446};
447
448export type DigestInput = {
449 slug: string;
450 /** The workspace's repositories by id: for links, and to count projects. */
451 repos: Record<string, RepoRef>;
452 /** Events' digest; null when events did not answer. Without Code access it has no repositories. */
453 activity: ActivityDigest | null;
454 /** Chat's; null when chat did not answer. */
455 chat: ChatActivity | null;
456 /** Names for member keys: a username, or an agent's handle. */
457 names: Record<string, string>;
458 /** Keys of accounts that are agents, beyond g1t and the workspace's agents. */
459 agentKeys?: ReadonlySet<string>;
460 /** The agents' tasks, for their column; null when neither Code nor Agents answered. */
461 work: SpanWork | null;
462 code: boolean;
463};
464
465const people = (n: number) => plural(n, "person", "people");
466
467/** `a, b and c`. */
468export const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
469
470/** What one kind did, from a tally: how many, and by whom (non-empty keys). */
471function part(counts: ActorCount[], kind: ActorKind, agents?: ReadonlySet<string>): { count: number; actors: Map<string, number> } {
472 const actors = new Map<string, number>();
473 let count = 0;
474 for (const entry of counts) {
475 if (actorKind(entry.actor, agents) !== kind) continue;
476 count += entry.count;
477 if (entry.actor) actors.set(entry.actor, (actors.get(entry.actor) ?? 0) + entry.count);
478 }
479 return { count, actors };
480}
481
482/** Where a line points: the one project's page, or the workspace's projects when several. */
483function placeFor(slug: string, repos: Record<string, RepoRef>, repoIds: Iterable<string>, page: string): string {
484 const ids = [...new Set(repoIds)];
485 const only = ids.length === 1 ? repos[ids[0]!] : null;
486 return only ? `/${only.namespace}/${only.name}/${page}` : `/${slug}/-/repositories`;
487}
488
489/**
490 * One column: what people, or agents, did in the span, as lines with
491 * counts and links, and who did it. Pure: everything comes from the
492 * services' counts and the names looked up for them.
493 */
494export function digestColumn(kind: ActorKind, input: DigestInput): DigestColumn {
495 const { slug, repos, activity, chat, names, agentKeys, work } = input;
496 const lines: DigestLine[] = [];
497 const did = new Map<string, number>();
498 const credit = (actors: Map<string, number>) => {
499 for (const [key, n] of actors) did.set(key, (did.get(key) ?? 0) + n);
500 };
501
502 // The agents' tasks come first in their column: what Home has always counted.
503 if (kind === "agent" && work) {
504 const { accepted, fixed, finished, dropped } = work.counts;
505 const done = accepted + fixed + finished;
506 if (done > 0 || dropped > 0) {
507 const text = done > 0 ? `finished ${plural(done, "task")}${dropped > 0 ? `, ${dropped.toLocaleString("en-US")} didn't finish` : ""}` : `finished none of ${plural(dropped, "task")}`;
508 lines.push({ key: "tasks", text, to: `/${slug}/-/agents` });
509 }
510 const sessions = work.tasks.filter((task) => task.source !== "code").length;
511 if (sessions > 0) lines.push({ key: "sessions", text: `ran ${plural(sessions, "session")}`, to: `/${slug}/-/agents` });
512 }
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.513
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.514 if (activity) {
515 // Pushes: commits, projects, and who.
516 const pushers = new Map<string, number>();
517 const pushed = new Set<string>();
518 let commits = 0;
519 let pushes = 0;
520 for (const repo of activity.repos) {
521 let any = false;
522 for (const by of repo.pushes.by) {
523 if (actorKind(by.actor, agentKeys) !== kind) continue;
524 any = true;
525 commits += by.commits;
526 pushes += by.count;
527 if (by.actor) pushers.set(by.actor, (pushers.get(by.actor) ?? 0) + by.count);
528 }
529 if (any) pushed.add(repo.repo_id);
530 }
531 if (pushes > 0) {
532 const where = `to ${plural(pushed.size, "project")}`;
533 const text =
534 kind === "person"
535 ? pushers.size > 0
536 ? `${people(pushers.size)} pushed ${plural(commits, "commit")} ${where}`
537 : `${plural(commits, "commit")} pushed ${where}`
538 : `pushed ${plural(commits, "commit")} ${where}`;
539 lines.push({ key: "pushes", text, to: placeFor(slug, repos, pushed, "activity") });
540 credit(pushers);
541 }
542
543 // Pull requests, reviews, issues, comments, deploys, releases, packages: summed over projects.
544 const sum = (pick: (repo: RepoDigest) => ActorCount[]) => {
545 const total = { count: 0, actors: new Map<string, number>(), repoIds: [] as string[] };
546 for (const repo of activity.repos) {
547 const { count, actors } = part(pick(repo), kind, agentKeys);
548 if (count === 0) continue;
549 total.count += count;
550 total.repoIds.push(repo.repo_id);
551 for (const [key, n] of actors) total.actors.set(key, (total.actors.get(key) ?? 0) + n);
552 }
553 return total;
554 };
555 const opened = sum((repo) => repo.pulls.opened);
556 const merged = sum((repo) => repo.pulls.merged);
557 const closed = sum((repo) => repo.pulls.closed);
558 if (opened.count + merged.count + closed.count > 0) {
559 const parts: string[] = [];
560 if (opened.count > 0) parts.push(`opened ${plural(opened.count, "pull request")}`);
561 if (merged.count > 0) parts.push(`merged ${opened.count > 0 ? merged.count.toLocaleString("en-US") : plural(merged.count, "pull request")}`);
562 if (closed.count > 0) parts.push(`closed ${closed.count.toLocaleString("en-US")} without merging`);
563 lines.push({ key: "pulls", text: parts.join(", "), to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds], "pulls") });
564 credit(opened.actors);
565 credit(merged.actors);
566 credit(closed.actors);
567 }
568 const reviews = sum((repo) => repo.reviews);
569 if (reviews.count > 0) {
570 lines.push({ key: "reviews", text: `reviewed ${plural(reviews.count, "pull request")}`, to: placeFor(slug, repos, reviews.repoIds, "pulls") });
571 credit(reviews.actors);
572 }
573 const issuesOpened = sum((repo) => repo.issues.opened);
574 const issuesClosed = sum((repo) => repo.issues.closed);
575 if (issuesOpened.count + issuesClosed.count > 0) {
576 const parts: string[] = [];
577 if (issuesOpened.count > 0) parts.push(`opened ${plural(issuesOpened.count, "issue")}`);
578 if (issuesClosed.count > 0) parts.push(`closed ${issuesOpened.count > 0 ? issuesClosed.count.toLocaleString("en-US") : plural(issuesClosed.count, "issue")}`);
579 lines.push({ key: "issues", text: parts.join(", "), to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds], "issues") });
580 credit(issuesOpened.actors);
581 credit(issuesClosed.actors);
582 }
583 const comments = sum((repo) => repo.comments);
584 if (comments.count > 0) {
585 lines.push({ key: "comments", text: `left ${plural(comments.count, "comment")}`, to: placeFor(slug, repos, comments.repoIds, "issues") });
586 credit(comments.actors);
587 }
588 const succeeded = sum((repo) => repo.deployments.succeeded);
589 const failed = sum((repo) => repo.deployments.failed);
590 if (succeeded.count + failed.count > 0) {
591 const text = succeeded.count > 0 ? `${plural(succeeded.count, "deploy")} went out${failed.count > 0 ? `, ${failed.count.toLocaleString("en-US")} failed` : ""}` : `${plural(failed.count, "deploy")} failed`;
592 lines.push({ key: "deploys", text, to: placeFor(slug, repos, [...succeeded.repoIds, ...failed.repoIds], "deployments") });
593 credit(succeeded.actors);
594 credit(failed.actors);
595 }
596 const releases = sum((repo) => tallyOf(repo.releases.map((release) => release.actor)));
597 if (releases.count > 0) {
598 lines.push({ key: "releases", text: `published ${plural(releases.count, "release")}`, to: placeFor(slug, repos, releases.repoIds, "releases") });
599 credit(releases.actors);
600 }
601 const packages = sum((repo) => tallyOf(repo.packages.map((pkg) => pkg.actor)));
602 if (packages.count > 0) {
603 lines.push({ key: "packages", text: `published ${plural(packages.count, "package version")}`, to: `/${slug}/-/packages` });
604 credit(packages.actors);
605 }
606
607 // Artifacts: made, and whose content changed.
608 if (activity.folios) {
609 const made = part(activity.folios.created, kind, agentKeys);
610 const edited = new Set<string>();
611 const editors = new Map<string, number>();
612 let allDocs = true;
613 for (const folio of activity.folios.edited) {
614 const mine = folio.authors.filter((author) => actorKind(author, agentKeys) === kind);
615 if (mine.length === 0) continue;
616 edited.add(folio.folio_id);
617 if (folio.kind !== "doc") allDocs = false;
618 for (const author of mine) editors.set(author, (editors.get(author) ?? 0) + 1);
619 }
620 const what = (n: number) => plural(n, allDocs ? "doc" : "artifact");
621 if (made.count + edited.size > 0) {
622 const parts: string[] = [];
623 if (made.count > 0) parts.push(`created ${what(made.count)}`);
624 if (edited.size > 0) parts.push(`edited ${what(edited.size)}`);
625 lines.push({ key: "docs", text: parts.join(", "), to: `/${slug}/-/artifacts` });
626 credit(made.actors);
627 credit(editors);
628 }
629 }
630 }
631
632 // Chat: what each kind said.
633 if (chat) {
634 const said = part(
635 chat.authors.map((author) => ({ actor: author.key, count: author.messages })),
636 kind,
637 agentKeys,
638 );
639 if (said.count > 0) {
640 const text = kind === "person" ? `sent ${plural(said.count, "message")} in ${plural(chat.channels, "conversation")}` : `replied ${plural(said.count, "time")} in chat`;
641 lines.push({ key: "chat", text, to: `/${slug}/-/chat` });
642 credit(said.actors);
643 }
644 }
645
646 const actors: DigestActor[] = [...did]
647 .map(([key, count]) => ({ key, kind, name: names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"), count }))
648 .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
649 return { kind, lines, actors };
650}
651
652/** A tally with one of each actor, for lists that name an actor per item. */
653function tallyOf(actors: string[]): ActorCount[] {
654 const counts = new Map<string, number>();
655 for (const actor of actors) counts.set(actor, (counts.get(actor) ?? 0) + 1);
656 return [...counts].map(([actor, count]) => ({ actor, count }));
657}
658
659/** Both columns, what the sentence needs, and which sources were silent. */
660export function digestOf(input: DigestInput): Digest {
661 const people = digestColumn("person", input);
662 const agents = digestColumn("agent", input);
663 const missing: string[] = [];
664 if (input.code && !input.activity) missing.push("Code");
665 if (!input.chat) missing.push("Chat");
666 if (!input.work) missing.push("Agents");
667 let commits = 0;
668 let merged = 0;
669 for (const repo of input.activity?.repos ?? []) {
670 for (const by of repo.pushes.by) if (actorKind(by.actor, input.agentKeys) === "person") commits += by.commits;
671 merged += part(repo.pulls.merged, "person", input.agentKeys).count;
672 }
673 const said = part(
674 (input.chat?.authors ?? []).map((author) => ({ actor: author.key, count: author.messages })),
675 "person",
676 input.agentKeys,
677 ).count;
678 const counts = input.work?.counts;
679 return {
680 people,
681 agents,
682 summary: {
683 people: people.actors.length,
684 commits,
685 merged,
686 finished: counts ? counts.accepted + counts.fixed + counts.finished : 0,
687 dropped: counts?.dropped ?? 0,
688 said,
689 anything: people.lines.length > 0 || agents.lines.length > 0,
690 peopleRead: input.activity != null || input.chat != null,
691 },
692 missing,
693 partial: (input.activity != null && !input.activity.complete) || (input.work?.partial ?? false),
694 };
695}
696
697// --- What landed in the span -----------------------------------------------------
698
699/**
700 * A change that landed: a pull request merged; commits pushed straight to
701 * a default branch; a production build that went live; a release or a
702 * package version published. By a person or an agent.
703 */
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.704export type LandedChange = {
705 key: string;
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.706 kind: "merge" | "push" | "deploy" | "release" | "package";
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.707 title: string;
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.708 /** One line under it: where, and by whom. */
709 detail: string;
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.710 to: string;
711 at: number;
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.712 /** An agent's doing, or a person's. */
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.713 agent: boolean;
714};
715
716/** Every pull request that merged in the span, newest first. */
717export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
718 return pulls
719 .filter((pull) => pull.status === "merged" && within(pull.mergedAt, span))
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.720 .map((pull) => {
721 const agent = isAgentPull(pull);
722 const mergedBy = pull.mergedBy && pull.mergedBy !== pull.author.username ? `, merged by ${pull.mergedBy}` : "";
723 return {
724 key: `landed:${pullKey(pull.repo, pull.number)}`,
725 kind: "merge" as const,
726 title: pull.title,
727 detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`,
728 to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
729 at: Date.parse(pull.mergedAt!),
730 agent,
731 };
732 })
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.733 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
734}
735
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.736/** The names behind member keys, for a line: `ana and bo`, `@margo`, `a mirror` for nobody. */
737function namesOf(keys: string[], names: Record<string, string>, agentKeys?: ReadonlySet<string>): string {
738 const shown = keys.map((key) => {
739 if (!key) return "a mirror";
740 const name = names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone");
741 return actorKind(key, agentKeys) === "agent" ? `@${name}` : name;
742 });
743 return listOf([...new Set(shown)]);
744}
745
746/**
747 * Commits pushed straight to a default branch, one row per project with
748 * any: what landed without a pull request. Releases and package versions
749 * published, one row each. Pushes within the span only: the digest was
750 * asked for the span.
751 */
752export function landedFromActivity(activity: ActivityDigest | null, repos: Record<string, RepoRef>, names: Record<string, string>, slug: string, agentKeys?: ReadonlySet<string>): LandedChange[] {
753 const rows: LandedChange[] = [];
754 for (const repo of activity?.repos ?? []) {
755 const path = repos[repo.repo_id];
756 if (!path) continue;
757 const direct = repo.pushes.default_branch;
758 if (direct.count > 0 && direct.last_at) {
759 const by = direct.by.map((entry) => entry.actor);
760 rows.push({
761 key: `push:${repo.repo_id}`,
762 kind: "push",
763 title: `${plural(direct.commits, "commit")} pushed to ${path.defaultBranch}`,
764 detail: `${path.name} · ${plural(direct.count, "push", "pushes")} by ${namesOf(by, names, agentKeys)}`,
765 to: `/${path.namespace}/${path.name}/activity`,
766 at: Date.parse(direct.last_at),
767 agent: by.length > 0 && by.every((key) => key && actorKind(key, agentKeys) === "agent"),
768 });
769 }
770 for (const release of repo.releases) {
771 rows.push({
772 key: `release:${repo.repo_id}:${release.tag}`,
773 kind: "release",
774 title: release.name ? `${release.tag} · ${release.name}` : release.tag,
775 detail: `${path.name} · released by ${namesOf([release.actor], names, agentKeys)}`,
776 to: `/${path.namespace}/${path.name}/releases/tag/${encodeURIComponent(release.tag)}`,
777 at: Date.parse(release.at),
778 agent: actorKind(release.actor, agentKeys) === "agent",
779 });
780 }
781 for (const pkg of repo.packages) {
782 rows.push({
783 key: `package:${repo.repo_id}:${pkg.ecosystem}:${pkg.name}:${pkg.version}`,
784 kind: "package",
785 title: `${pkg.name} ${pkg.version}`,
786 detail: `${pkg.ecosystem} package · published by ${namesOf([pkg.actor], names, agentKeys)}`,
787 to: `/${slug}/-/packages`,
788 at: Date.parse(pkg.at),
789 agent: actorKind(pkg.actor, agentKeys) === "agent",
790 });
791 }
792 }
793 return rows;
794}
795
796/** Production builds that went live in the span, as landed changes; previews stay in Deploys. */
797export function landedDeploys(deploys: DeployRow[]): LandedChange[] {
798 return deploys
799 .filter((row) => row.kind === "production" && row.outcome === "live")
800 .map((row) => ({
801 key: `landed:${row.key}`,
802 kind: "deploy" as const,
803 title: `Production of ${row.project} went live`,
804 detail: `${row.commit} · by ${row.by}`,
805 to: row.to,
806 at: row.at,
807 agent: isAgentName(row.by),
808 }));
809}
810
811/** Everything that landed, newest first. */
812export function landed(parts: LandedChange[][]): LandedChange[] {
813 return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
814}
815
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.816/** A build that finished in the span. */
817export type DeployRow = {
818 key: string;
819 project: string;
820 kind: Deployment["kind"];
821 /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */
822 outcome: "live" | "failed";
823 commit: string;
824 branch: string | null;
825 at: number;
826 to: string;
827 by: string;
828};
829
830/**
831 * Builds that finished in the span, newest first: production builds each,
832 * and previews too. A build that went live and was replaced since still
833 * went live. Skipped builds are left out.
834 */
835export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">): DeployRow[] {
836 const rows: DeployRow[] = [];
837 for (const project of projects) {
838 for (const deploy of project.deployments) {
839 const ended = deploy.finishedAt ?? null;
840 if (!within(ended, span)) continue;
841 const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null;
842 if (!outcome) continue;
843 rows.push({
844 key: `deploy:${project.slug}:${deploy.id}`,
845 project: project.slug,
846 kind: deploy.kind,
847 outcome,
848 commit: deploy.commit.slice(0, 7),
849 branch: deploy.branch,
850 at: Date.parse(ended!),
851 to: `/${workspace}/${project.slug}/deployments/${deploy.id}`,
852 by: deploy.createdBy,
853 });
854 }
855 }
856 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
857}
858
859/** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */
860export type DecisionRow = {
861 key: string;
862 text: string;
863 by: string | null;
864 at: number;
865 to: string;
866 kind: "memory" | "request";
867};
868
869export function decisionsIn(
870 input: { memories: Memory[] | null; requests: InstallRequest[] | null },
871 slug: string,
872 span: Pick<Span, "from" | "now">,
873): DecisionRow[] {
874 const rows: DecisionRow[] = [];
875 for (const memory of input.memories ?? []) {
876 if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue;
877 rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" });
878 }
879 for (const request of input.requests ?? []) {
880 if (request.status === "open" || !within(request.resolved_at, span)) continue;
881 rows.push({
882 key: `request:${request.id}`,
883 text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`,
884 by: request.resolved_by,
885 at: Date.parse(request.resolved_at!),
886 to: `/${slug}/-/marketplace/requests`,
887 kind: "request",
888 });
889 }
890 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
891}
892
893// --- Running now ----------------------------------------------------------------
894
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.895/** Something going right now: a session, an agent's change, a workflow run, a build. */
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.896export type RunningRow = {
897 key: string;
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.898 kind: "session" | "change" | "workflow" | "deploy";
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.899 title: string;
900 /** One line: who and where. */
901 detail: string;
902 /** "Working", "Queued", "Building". */
903 status: string;
904 /** Epoch ms: when it started. */
905 at: number;
906 to: string;
907};
908
909const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" };
910
911/** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */
912export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] {
913 return sessions
914 .filter((session) => !session.parent_id && SESSION_STATUS[session.status])
915 .map((session) => ({
916 key: `session:${session.id}`,
917 kind: "session" as const,
918 title: session.visible ? session.title : "A session in a conversation you're not in",
919 detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`,
920 status: SESSION_STATUS[session.status]!,
921 at: Date.parse(session.created_at),
922 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
923 }));
924}
925
926/** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */
927export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string };
928
929export function runningChanges(changes: AgentChange[]): RunningRow[] {
930 return changes.map((change) => ({
931 key: `change:${change.key}`,
932 kind: "change" as const,
933 title: change.title,
934 detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`,
935 status: change.chip,
936 at: change.at,
937 to: change.to,
938 }));
939}
940
941/** Builds queued or going now, one per project (its newest). */
942export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] {
943 return overview.flatMap((project) => {
944 const latest = project.latest;
945 if (!latest || (latest.status !== "queued" && latest.status !== "building")) return [];
946 return [
947 {
948 key: `deploy:${project.slug}:${latest.id}`,
949 kind: "deploy" as const,
950 title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`,
951 detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`,
952 status: latest.status === "queued" ? "Queued" : "Building",
953 at: Date.parse(latest.createdAt),
954 to: `/${slug}/${project.slug}/deployments/${latest.id}`,
955 },
956 ];
957 });
958}
959
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.960const RUN_STATUS: Partial<Record<WorkflowRun["status"], string>> = {
961 pending: "Queued",
962 queued: "Queued",
963 action_required: "Needs approval",
964 in_progress: "Running",
965 waiting: "Waiting",
966};
967
968/** Workflow runs not finished, in the projects read: queued, waiting, running, or waiting for approval. */
969export function runningWorkflows(projects: { repo: RepoPath; runs: WorkflowRun[] }[]): RunningRow[] {
970 return projects.flatMap(({ repo, runs }) =>
971 runs.flatMap((run) => {
972 const status = RUN_STATUS[run.status];
973 if (!status) return [];
974 return [
975 {
976 key: `workflow:${repo.namespace}/${repo.name}:${run.id}`.toLowerCase(),
977 kind: "workflow" as const,
978 title: run.title ? `${run.name}: ${run.title}` : run.name,
979 detail: `${repo.namespace}/${repo.name} ${run.ref.replace(/^refs\/heads\//, "")}${run.actor ? ` · by ${run.actor}` : ""}`,
980 status,
981 at: Date.parse(run.startedAt ?? run.createdAt),
982 to: `/${repo.namespace}/${repo.name}/actions/runs/${run.id}`,
983 },
984 ];
985 }),
986 );
987}
988
989/** Everything running, longest-going first within each kind: sessions, then changes, then workflow runs, then builds. */
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.990export function running(parts: RunningRow[][]): RunningRow[] {
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.991 const order = { session: 0, change: 1, workflow: 2, deploy: 3 } as const;
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.992 return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
993}
994
995// --- Needs you ------------------------------------------------------------------
996
997/**
998 * What a row is. Tiers, most pressing first:
999 * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`);
1000 * 1. agent work that has stopped and can't go on without you;
1001 * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something);
1002 * 3. something stuck that may need a look;
1003 * 4. an unread notification that is a warning or a failure;
1004 * 5. a mention or a direct message.
1005 */
1006export type AttentionKind =
1007 | "limit"
1008 | "deploy"
1009 | "session_cap"
1010 | "agent_budget"
1011 | "pull_blocked"
1012 | "ready"
1013 | "review"
1014 | "invitation"
1015 | "install_request"
1016 | "agent_waiting"
1017 | "stuck"
1018 | "runner"
1019 | "notification"
1020 | "chat";
1021
1022export const TIER: Record<AttentionKind, number> = {
1023 limit: 0,
1024 deploy: 0,
1025 session_cap: 1,
1026 agent_budget: 1,
1027 pull_blocked: 1,
1028 ready: 2,
1029 review: 2,
1030 invitation: 2,
1031 install_request: 2,
1032 agent_waiting: 2,
1033 stuck: 3,
1034 runner: 3,
1035 notification: 4,
1036 chat: 5,
1037};
1038
1039export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
1040
1041/** One row of Needs you. */
1042export type AttentionRow = {
1043 key: string;
1044 kind: AttentionKind;
1045 title: string;
1046 /** One line: what it is and where. */
1047 detail: string;
1048 /** What is at stake, in a few words. */
1049 stake: Stake | null;
1050 /** The action, right there. */
1051 action: { label: string; to: string };
1052 /** Who is on it: the agent, or the person who asked. */
1053 owner: { name: string; agent: boolean } | null;
1054 /** Epoch ms: when it started waiting. */
1055 at: number;
1056 /** Where it came from, for a member without Code access never `code`. */
1057 from: "code" | "agents" | "notifications" | "chat" | "marketplace";
1058};
1059
1060/**
1061 * Most pressing first: by tier, then what has waited longest, then by key
1062 * so the order never depends on the order rows were read. One of each key,
1063 * and one of each place: a notification about a pull request already on
1064 * the list is left out.
1065 */
1066export function rankAttention(rows: AttentionRow[]): AttentionRow[] {
1067 const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
1068 const keys = new Set<string>();
1069 const places = new Set<string>();
1070 return sorted.filter((row) => {
1071 const place = row.action.to.split(/[?#]/)[0];
1072 if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false;
1073 keys.add(row.key);
1074 places.add(place);
1075 return true;
1076 });
1077}
1078
1079/** Start here: the first row by `rankAttention`, with why it was picked. */
1080export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null {
1081 const row = ranked[0];
1082 if (!row) return null;
1083 const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length;
1084 const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : "";
1085 return { row, why: `${WHY[row.kind]}${tail}` };
1086}
1087
1088const WHY: Record<AttentionKind, string> = {
1089 limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.",
1090 install_request: "A member asked for it, and only an owner can add it or turn it down.",
1091 deploy: "Production is serving an older build until this is fixed, so it comes before anything else.",
1092 session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.",
1093 agent_budget: "An agent used up its budget and takes no new work until it is raised.",
1094 pull_blocked: "An agent stopped on this change and won't go on until a person decides.",
1095 ready: "The work is done and only waits for you to land it.",
1096 review: "You were asked by name, so it waits for your verdict.",
1097 invitation: "It is addressed to you; no one else can answer it.",
1098 agent_waiting: "An agent is waiting on you to go on.",
1099 stuck: "A running agent has stopped reporting and may be stuck.",
1100 runner: "A job is waiting for a runner that is not there.",
1101 notification: "It is the most pressing unread notification.",
1102 chat: "Nothing else is waiting, and this is the oldest message for you.",
1103};
1104
1105// --- Rows from each source ----------------------------------------------------
1106
1107/** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */
1108export type CodeNeed = {
1109 key: string;
1110 reason: string;
1111 repo: RepoPath | null;
1112 ref: string | null;
1113 title: string;
1114 ask: string;
1115 by: { name: string; agent: boolean } | null;
1116 for: string | null;
1117 at: number;
1118 to: string;
1119 facts: { label: string; value: string; tone: string | null }[];
1120};
1121
1122const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null;
1123
1124const REASON_STAKE: Record<string, Stake> = {
1125 blocking: { text: "Blocking", tone: "danger" },
1126 checks_failing: { text: "Checks failing", tone: "danger" },
1127 outside_guardrails: { text: "Over a guardrail", tone: "warn" },
1128 low_confidence: { text: "Low confidence", tone: "warn" },
1129 needs_review: { text: "Needs a verdict", tone: "warn" },
1130 stalled: { text: "Stalled", tone: "warn" },
1131};
1132
1133/** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */
1134export function codeRow(need: CodeNeed): AttentionRow {
1135 const prefix = need.key.split(":")[0];
1136 const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null;
1137 const detail = where ? `${where} · ${need.ask}` : need.ask;
1138 const owner = need.by;
1139 const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const };
1140 switch (prefix) {
1141 case "deploy":
1142 return {
1143 ...base,
1144 kind: "deploy",
1145 stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" },
1146 action: { label: "See the build", to: need.to },
1147 };
1148 case "invitation":
1149 return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } };
1150 case "review": {
1151 const lines = fact(need, "Lines");
1152 return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } };
1153 }
1154 case "runner":
1155 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
1156 case "run": {
1157 const cost = fact(need, "Cost so far");
1158 return {
1159 ...base,
1160 kind: "stuck",
1161 stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" },
1162 action: { label: "Look", to: need.to },
1163 };
1164 }
1165 default: {
1166 if (need.reason === "ready_to_merge") {
1167 const files = fact(need, "Files changed");
1168 return {
1169 ...base,
1170 kind: "ready",
1171 stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" },
1172 action: { label: "Merge", to: need.to },
1173 };
1174 }
1175 return {
1176 ...base,
1177 kind: "pull_blocked",
1178 stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" },
1179 action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to },
1180 };
1181 }
1182 }
1183}
1184
1185/** A session stopped at its spend cap that the viewer may approve more for. */
1186export function sessionCapRow(session: AgentSession, slug: string): AttentionRow {
1187 const cap = session.cap_micros;
1188 return {
1189 key: `session:${session.id}`,
1190 kind: "session_cap",
1191 title: session.visible ? session.title : `A session of @${session.agent_handle}`,
1192 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.1193 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.1194 action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` },
1195 owner: { name: session.agent_handle, agent: true },
1196 at: Date.parse(session.updated_at),
1197 from: "agents",
1198 };
1199}
1200
1201export type AgentLike = {
1202 id: string;
1203 handle: string;
1204 display_name: string;
1205 status: string;
1206 spent_month_micros: number;
1207 budget: { monthly_micros: number | null };
1208 updated_at: string;
1209};
1210
1211/** An agent out of budget (for those who may raise it), or waiting on someone. */
1212export function agentRow(agent: AgentLike, slug: string): AttentionRow | null {
1213 const base = {
1214 key: `agent:${agent.id}`,
1215 owner: { name: agent.handle, agent: true },
1216 at: Date.parse(agent.updated_at),
1217 from: "agents" as const,
1218 };
1219 if (agent.status === "out_of_budget") {
1220 const budget = agent.budget.monthly_micros;
1221 return {
1222 ...base,
1223 kind: "agent_budget",
1224 title: `${agent.display_name} is out of budget`,
1225 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.1226 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.1227 action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` },
1228 };
1229 }
1230 if (agent.status === "waiting") {
1231 return {
1232 ...base,
1233 kind: "agent_waiting",
1234 title: `${agent.display_name} is waiting`,
1235 detail: `@${agent.handle} is waiting before it goes on.`,
1236 stake: null,
1237 action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` },
1238 };
1239 }
1240 return null;
1241}
1242
1243const NOTIFICATION_STAKE: Record<string, string> = {
1244 agent: "Agent waiting",
1245 review_requested: "Review requested",
1246 assign: "Assigned to you",
1247 mention: "Mentioned",
1248 team_mention: "Team mentioned",
1249 ci_activity: "CI activity",
1250 security_alert: "Security alert",
1251 state_change: "State changed",
1252 author: "Your work",
1253 comment: "Comment",
1254 manual: "Subscribed",
1255 subscribed: "Watching",
1256};
1257
1258/**
1259 * An unread notification that is a warning (someone or something is
1260 * waiting) or a failure, in this workspace. A member without Code access
1261 * gets none about a repository.
1262 */
1263export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null {
1264 if (item.readAt != null || item.doneAt != null) return null;
1265 if (item.severity !== "warning" && item.severity !== "error") return null;
1266 const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug;
1267 if (!inWorkspace) return null;
1268 if (!code && item.repo) return null;
1269 return {
1270 key: `notification:${item.id}`,
1271 kind: "notification",
1272 title: item.body || item.title,
1273 detail: item.body ? item.title : (item.repo ?? "A notification"),
1274 stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" },
1275 action: { label: "Open", to: item.url },
1276 owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null,
1277 at: Date.parse(item.updatedAt),
1278 from: "notifications",
1279 };
1280}
1281
1282export type ChatEntryLike = {
1283 channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null };
1284 title: string;
1285 muted: boolean;
1286 unread: number;
1287 mentions: number;
1288};
1289
1290/** A conversation with a mention of you, or a direct message you haven't read. */
1291export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null {
1292 if (entry.muted) return null;
1293 if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null;
1294 const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`;
1295 const dm = entry.channel.kind === "dm";
1296 return {
1297 key: `chat:${entry.channel.id}`,
1298 kind: "chat",
1299 title: dm ? entry.title : `#${entry.title}`,
1300 detail: dm ? "A direct message you haven't read." : "You were mentioned.",
1301 stake: {
1302 text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`,
1303 tone: entry.mentions > 0 ? "accent" : null,
1304 },
1305 action: { label: "Reply", to },
1306 owner: null,
1307 at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0,
1308 from: "chat",
1309 };
1310}
1311
1312/** A member's open request to add something from the Marketplace, for an owner. */
1313export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null {
1314 if (request.status !== "open") return null;
1315 return {
1316 key: `request:${request.id}`,
1317 kind: "install_request",
1318 title: `${request.requested_by} asked to add ${request.name}`,
1319 detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.",
1320 stake: { text: "Only owners can add it", tone: null },
1321 action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` },
1322 owner: { name: request.requested_by, agent: false },
1323 at: Date.parse(request.requested_at),
1324 from: "marketplace",
1325 };
1326}
1327
1328/**
1329 * The workspace's agents at 100% of its monthly agent budget, for those who
1330 * may raise it. `since` is when it was reached as far as is known: the
1331 * newest session that stopped at its cap, else the start of the month.
1332 */
1333export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null {
1334 if (input.alert == null || input.alert < 100) return null;
1335 return {
1336 key: "limit:agents",
1337 kind: "limit",
1338 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.1339 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.1340 stake: { text: "No new agent work", tone: "danger" },
1341 action: { label: "Raise the budget", to: `/${slug}/-/spend` },
1342 owner: null,
1343 at: input.since,
1344 from: "agents",
1345 };
1346}
1347
1348/** What Needs you and Start here show, and which sources did not answer. */
1349export type Attention = {
1350 rows: AttentionRow[];
1351 start: { row: AttentionRow; why: string } | null;
1352 /** Sources that did not answer, by name, for the card to say so. */
1353 missing: string[];
1354};
1355
1356export function attention(input: {
1357 slug: string;
1358 code: boolean;
1359 codeNeeds: CodeNeed[] | null;
1360 capped: AgentSession[] | null;
1361 agents: AgentLike[] | null;
1362 canManage: boolean;
1363 /** The workspace's agent budget, for those who may manage agents; null for anyone else. */
1364 limit?: { alert: number | null; spentMicros: number; since: number } | null;
1365 /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */
1366 requests?: InstallRequest[] | null;
1367 notifications: InboxItem[] | null;
1368 chat: ChatEntryLike[] | null;
1369}): Attention {
1370 const rows: AttentionRow[] = [];
1371 const missing: string[] = [];
1372 if (input.code) {
1373 if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow));
1374 else missing.push("Code");
1375 }
1376 if (input.limit && input.canManage) {
1377 const row = limitRow(input.limit, input.slug);
1378 if (row) rows.push(row);
1379 }
1380 for (const request of input.requests ?? []) {
1381 const row = installRequestRow(request, input.slug);
1382 if (row) rows.push(row);
1383 }
1384 if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug)));
1385 if (input.agents) {
1386 const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle));
1387 for (const agent of input.agents) {
1388 if (agent.status === "out_of_budget" && !input.canManage) continue;
1389 if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue;
1390 const row = agentRow(agent, input.slug);
1391 if (row) rows.push(row);
1392 }
1393 }
1394 if (input.capped == null || input.agents == null) missing.push("Agents");
1395 if (input.notifications) {
1396 for (const item of input.notifications) {
1397 const row = notificationRow(item, input.slug, input.code);
1398 if (row) rows.push(row);
1399 }
1400 } else missing.push("Notifications");
1401 if (input.chat) {
1402 for (const entry of input.chat) {
1403 const row = chatRow(entry, input.slug);
1404 if (row) rows.push(row);
1405 }
1406 } else missing.push("Chat");
1407 const ranked = rankAttention(rows);
1408 return { rows: ranked, start: startHere(ranked), missing };
1409}
1410
1411// --- Spend in the span ----------------------------------------------------------
1412
1413/** Money in, which the statement lists but which is not spend. */
1414const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]);
1415
1416export type Spend = {
1417 /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */
1418 from: string;
1419 to: string;
1420 /** Usage at price over those days, in micros. */
1421 totalMicros: number;
1422 /** A line per kind of charge, as the statement names it, most first. */
1423 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.1424 /** Usage at price this month so far, the top bar's and Usage's figure; null when it could not be read. */
1425 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.1426};
1427
1428/** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */
1429export function spanMonths(span: Pick<Span, "from" | "now">): string[] {
1430 const months: string[] = [];
1431 const first = new Date(span.from);
1432 let year = first.getUTCFullYear();
1433 let month = first.getUTCMonth();
1434 const last = new Date(span.now).toISOString().slice(0, 7);
1435 for (let i = 0; i < 3; i++) {
1436 const key = `${year}-${String(month + 1).padStart(2, "0")}`;
1437 months.push(key);
1438 if (key >= last) break;
1439 month++;
1440 if (month === 12) {
1441 month = 0;
1442 year++;
1443 }
1444 }
1445 return months;
1446}
1447
1448/**
1449 * The span's spend from the statements grouped by day: each usage line at
1450 * its price (what was charged, plus what the plan, a trial, a pool or a
1451 * discount paid of it), leaving out money in. The statement keeps whole
1452 * UTC days, so the span is counted from the start of the UTC day it began.
1453 * `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.1454 * `monthMicros` is this month so far as billing's usage report counts it,
1455 * the month's usage not yet closed included: the figure the top bar's
1456 * 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.1457 */
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.1458export 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.1459 const from = new Date(span.from).toISOString().slice(0, 10);
1460 const to = new Date(span.now).toISOString().slice(0, 10);
1461 const byKind = new Map<string, { micros: number; count: number }>();
1462 for (const statement of statements) {
1463 for (const group of statement.groups) {
1464 if (group.key < from || group.key > to) continue;
1465 for (const line of group.lines) {
1466 if (MONEY_IN.has(line.kind)) continue;
1467 const micros = line.priceMicros ?? line.chargedMicros;
1468 const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 };
1469 byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count });
1470 }
1471 }
1472 }
1473 const lines = [...byKind]
1474 .map(([kind, value]) => ({ kind, ...value }))
1475 .filter((line) => line.micros > 0)
1476 .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind));
1477 return {
1478 from,
1479 to,
1480 totalMicros: lines.reduce((sum, line) => sum + line.micros, 0),
1481 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.1482 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.1483 };
1484}
1485
1486// --- The sentence -----------------------------------------------------------
1487
1488/**
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.1489 * The sentence under the heading, honest about what happened: "Since
1490 * Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents
1491 * finished 7 tasks, and 12 changes landed." What people did comes first
1492 * (commits and merges; else what they said), then the agents' tasks that
1493 * settled well (accepted, fixed, sessions finished), then everything that
1494 * landed. When nothing at all was counted: "Quiet since Tuesday evening:
1495 * nothing pushed, merged, deployed or said." When what people did could
1496 * not be read, it says so.
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.1497 */
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.1498export function spanSentence(span: Pick<Span, "words">, digest: Pick<Digest, "summary"> | null, landed: number | null): string {
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.1499 const lead = span.words;
Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales.1500 if (!digest) return `${lead}, what happened couldn't be read.`;
1501 const { people: count, commits, merged, finished, dropped, said, anything, peopleRead } = digest.summary;
1502 const landedCount = landed ?? 0;
1503 if (!anything && landedCount === 0) {
1504 if (!peopleRead) return `${lead}, what people did couldn't be read, and no agent task settled.`;
1505 return `Quiet ${lead.charAt(0).toLowerCase()}${lead.slice(1)}: nothing pushed, merged, deployed or said.`;
1506 }
1507 const parts: string[] = [];
1508 if (count > 0) {
1509 if (commits > 0 || merged > 0) {
1510 const did: string[] = [];
1511 if (commits > 0) did.push(`pushed ${plural(commits, "commit")}`);
1512 if (merged > 0) did.push(`merged ${plural(merged, "change")}`);
1513 parts.push(`${people(count)} ${did.join(" and ")}`);
1514 } else if (said > 0) parts.push(`${people(count)} sent ${plural(said, "message")}`);
1515 else parts.push(`${people(count)} ${count === 1 ? "was" : "were"} active`);
1516 }
1517 if (finished > 0) parts.push(`agents finished ${plural(finished, "task")}`);
1518 else if (dropped > 0) parts.push(`agents finished none of ${plural(dropped, "task")}`);
1519 if (landedCount > 0) parts.push(`${plural(landedCount, "change")} landed`);
1520 const tail = peopleRead ? "" : "; what people did couldn't be read";
1521 if (parts.length === 0) return `${lead}, work went on, but nothing landed and no agent task finished${tail}.`;
1522 const joined = parts.length > 2 ? `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}` : parts.join(" and ");
1523 return `${lead}, ${joined}${tail}.`;
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.1524}
1525
1526/** "8 things need you.", or that nothing does. */
1527export function waitingSentence(total: number): string {
1528 if (total === 0) return "Nothing needs you right now.";
1529 return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`;
1530}
1531
1532/** How long something has waited: "45 min", "17 h", "2 d". */
1533export function waited(at: number, now: number): string {
1534 const minutes = Math.max(0, Math.floor((now - at) / MINUTE));
1535 if (minutes < 60) return `${minutes} min`;
1536 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`;
1537 return `${Math.floor(minutes / (24 * 60))} d`;
1538}
1539
1540/** "3:40 PM" when it is the same day as `now`, else "Oct 7". */
1541export function whenShort(at: number, now: number, timeZone: string | null): string {
1542 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
1543 return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone);
1544}