Skip to content
1,566 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 {
Four small things seen today. An agent can attach a file it made to any doc the person who asked can edit, whatever the space's agent mode: attaching changes nothing in the doc, so the suggest mode that stopped every PDF and spreadsheet no longer does; the ability is its own, attach, beside read, suggest and edit. Home no longer counts robots among the people: events that name an agent by its own id, or g1t's upkeep as the workspace or as g1t, read as the agent and as g1t, in the digest and on the page, so the faces say who instead of someone. Tooltips are the inverted bubble, the page's foreground behind the page's background, with no border. The Spend page's slice tabs keep their ring inside the row instead of losing its top to the scroll edge, and the Apps launcher keeps its search box and footer in place while it reads which apps there are, with skeleton tiles between.384 return key.startsWith("agent:") || key === G1T_ACTOR || isRobotKey(key) || agents?.has(key) ? "agent" : "person";
385}
386
387/**
388 * A member key that names a robot under `user:`: an agent by its own id,
389 * or g1t's upkeep as the workspace or as `g1t`. Events normalise these
390 * (services/events digest), so this is the safety net for older ones.
391 */
392export function isRobotKey(key: string): boolean {
393 return key.startsWith("user:agt_") || key.startsWith("user:wsp_") || key === "user:g1t";
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.394}
395
396/** Whether a username is an agent's: g1t, or an agent account. */
397export function isAgentName(name: string | null | undefined): boolean {
398 return name === "g1t" || (name != null && name.endsWith("-agent"));
399}
400
401/** A repository as the digest's links and lines need it. */
402export type RepoRef = RepoPath & { defaultBranch: string };
403
404/** Someone who did something in the span: for the faces beside a column. */
405export type DigestActor = {
406 key: string;
407 kind: ActorKind;
408 /** Their username or handle; `someone` when it could not be read. */
409 name: string;
410 /** How many things they did, over every line: the order of the faces. */
411 count: number;
412};
413
414/** One line of a column: what was done, with its count, and where that is listed. */
415export type DigestLine = {
416 key: string;
417 text: string;
418 to: string | null;
419};
420
421export type DigestColumn = {
422 kind: ActorKind;
423 lines: DigestLine[];
424 /** Most active first. */
425 actors: DigestActor[];
426};
427
428/** What the sentence under the heading needs. */
429export type DigestSummary = {
430 /** Distinct people who did anything. */
431 people: number;
432 /** Commits people pushed. */
433 commits: number;
434 /** Pull requests people merged. */
435 merged: number;
436 /** Agent tasks that settled well, and that did not. */
437 finished: number;
438 dropped: number;
439 /** Messages people sent. */
440 said: number;
441 /** Whether anything at all was counted, in either column. */
442 anything: boolean;
443 /** Whether what people did could be read at all (events and chat both silent: no). */
444 peopleRead: boolean;
445};
446
447export type Digest = {
448 people: DigestColumn;
449 agents: DigestColumn;
450 summary: DigestSummary;
451 /** Sources that did not answer, by name, for the card to say so: Code, Chat, Agents. */
452 missing: string[];
453 /** True when a source was cut short, so some counts are low. */
454 partial: boolean;
455};
456
457export type DigestInput = {
458 slug: string;
459 /** The workspace's repositories by id: for links, and to count projects. */
460 repos: Record<string, RepoRef>;
461 /** Events' digest; null when events did not answer. Without Code access it has no repositories. */
462 activity: ActivityDigest | null;
463 /** Chat's; null when chat did not answer. */
464 chat: ChatActivity | null;
465 /** Names for member keys: a username, or an agent's handle. */
466 names: Record<string, string>;
467 /** Keys of accounts that are agents, beyond g1t and the workspace's agents. */
468 agentKeys?: ReadonlySet<string>;
469 /** The agents' tasks, for their column; null when neither Code nor Agents answered. */
470 work: SpanWork | null;
471 code: boolean;
472};
473
474const people = (n: number) => plural(n, "person", "people");
475
476/** `a, b and c`. */
477export const listOf = (names: string[]) => (names.length <= 1 ? (names[0] ?? "") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
478
479/** What one kind did, from a tally: how many, and by whom (non-empty keys). */
480function part(counts: ActorCount[], kind: ActorKind, agents?: ReadonlySet<string>): { count: number; actors: Map<string, number> } {
481 const actors = new Map<string, number>();
482 let count = 0;
483 for (const entry of counts) {
484 if (actorKind(entry.actor, agents) !== kind) continue;
485 count += entry.count;
486 if (entry.actor) actors.set(entry.actor, (actors.get(entry.actor) ?? 0) + entry.count);
487 }
488 return { count, actors };
489}
490
491/** Where a line points: the one project's page, or the workspace's projects when several. */
492function placeFor(slug: string, repos: Record<string, RepoRef>, repoIds: Iterable<string>, page: string): string {
493 const ids = [...new Set(repoIds)];
494 const only = ids.length === 1 ? repos[ids[0]!] : null;
495 return only ? `/${only.namespace}/${only.name}/${page}` : `/${slug}/-/repositories`;
496}
497
498/**
499 * One column: what people, or agents, did in the span, as lines with
500 * counts and links, and who did it. Pure: everything comes from the
501 * services' counts and the names looked up for them.
502 */
503export function digestColumn(kind: ActorKind, input: DigestInput): DigestColumn {
504 const { slug, repos, activity, chat, names, agentKeys, work } = input;
505 const lines: DigestLine[] = [];
506 const did = new Map<string, number>();
507 const credit = (actors: Map<string, number>) => {
508 for (const [key, n] of actors) did.set(key, (did.get(key) ?? 0) + n);
509 };
510
511 // The agents' tasks come first in their column: what Home has always counted.
512 if (kind === "agent" && work) {
513 const { accepted, fixed, finished, dropped } = work.counts;
514 const done = accepted + fixed + finished;
515 if (done > 0 || dropped > 0) {
516 const text = done > 0 ? `finished ${plural(done, "task")}${dropped > 0 ? `, ${dropped.toLocaleString("en-US")} didn't finish` : ""}` : `finished none of ${plural(dropped, "task")}`;
517 lines.push({ key: "tasks", text, to: `/${slug}/-/agents` });
518 }
519 const sessions = work.tasks.filter((task) => task.source !== "code").length;
520 if (sessions > 0) lines.push({ key: "sessions", text: `ran ${plural(sessions, "session")}`, to: `/${slug}/-/agents` });
521 }
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.522
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.523 if (activity) {
524 // Pushes: commits, projects, and who.
525 const pushers = new Map<string, number>();
526 const pushed = new Set<string>();
527 let commits = 0;
528 let pushes = 0;
529 for (const repo of activity.repos) {
530 let any = false;
531 for (const by of repo.pushes.by) {
532 if (actorKind(by.actor, agentKeys) !== kind) continue;
533 any = true;
534 commits += by.commits;
535 pushes += by.count;
536 if (by.actor) pushers.set(by.actor, (pushers.get(by.actor) ?? 0) + by.count);
537 }
538 if (any) pushed.add(repo.repo_id);
539 }
540 if (pushes > 0) {
541 const where = `to ${plural(pushed.size, "project")}`;
542 const text =
543 kind === "person"
544 ? pushers.size > 0
545 ? `${people(pushers.size)} pushed ${plural(commits, "commit")} ${where}`
546 : `${plural(commits, "commit")} pushed ${where}`
547 : `pushed ${plural(commits, "commit")} ${where}`;
548 lines.push({ key: "pushes", text, to: placeFor(slug, repos, pushed, "activity") });
549 credit(pushers);
550 }
551
552 // Pull requests, reviews, issues, comments, deploys, releases, packages: summed over projects.
553 const sum = (pick: (repo: RepoDigest) => ActorCount[]) => {
554 const total = { count: 0, actors: new Map<string, number>(), repoIds: [] as string[] };
555 for (const repo of activity.repos) {
556 const { count, actors } = part(pick(repo), kind, agentKeys);
557 if (count === 0) continue;
558 total.count += count;
559 total.repoIds.push(repo.repo_id);
560 for (const [key, n] of actors) total.actors.set(key, (total.actors.get(key) ?? 0) + n);
561 }
562 return total;
563 };
564 const opened = sum((repo) => repo.pulls.opened);
565 const merged = sum((repo) => repo.pulls.merged);
566 const closed = sum((repo) => repo.pulls.closed);
567 if (opened.count + merged.count + closed.count > 0) {
568 const parts: string[] = [];
569 if (opened.count > 0) parts.push(`opened ${plural(opened.count, "pull request")}`);
570 if (merged.count > 0) parts.push(`merged ${opened.count > 0 ? merged.count.toLocaleString("en-US") : plural(merged.count, "pull request")}`);
571 if (closed.count > 0) parts.push(`closed ${closed.count.toLocaleString("en-US")} without merging`);
572 lines.push({ key: "pulls", text: parts.join(", "), to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds], "pulls") });
573 credit(opened.actors);
574 credit(merged.actors);
575 credit(closed.actors);
576 }
577 const reviews = sum((repo) => repo.reviews);
578 if (reviews.count > 0) {
579 lines.push({ key: "reviews", text: `reviewed ${plural(reviews.count, "pull request")}`, to: placeFor(slug, repos, reviews.repoIds, "pulls") });
580 credit(reviews.actors);
581 }
582 const issuesOpened = sum((repo) => repo.issues.opened);
583 const issuesClosed = sum((repo) => repo.issues.closed);
584 if (issuesOpened.count + issuesClosed.count > 0) {
585 const parts: string[] = [];
586 if (issuesOpened.count > 0) parts.push(`opened ${plural(issuesOpened.count, "issue")}`);
587 if (issuesClosed.count > 0) parts.push(`closed ${issuesOpened.count > 0 ? issuesClosed.count.toLocaleString("en-US") : plural(issuesClosed.count, "issue")}`);
588 lines.push({ key: "issues", text: parts.join(", "), to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds], "issues") });
589 credit(issuesOpened.actors);
590 credit(issuesClosed.actors);
591 }
592 const comments = sum((repo) => repo.comments);
593 if (comments.count > 0) {
594 lines.push({ key: "comments", text: `left ${plural(comments.count, "comment")}`, to: placeFor(slug, repos, comments.repoIds, "issues") });
595 credit(comments.actors);
596 }
597 const succeeded = sum((repo) => repo.deployments.succeeded);
598 const failed = sum((repo) => repo.deployments.failed);
599 if (succeeded.count + failed.count > 0) {
600 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`;
601 lines.push({ key: "deploys", text, to: placeFor(slug, repos, [...succeeded.repoIds, ...failed.repoIds], "deployments") });
602 credit(succeeded.actors);
603 credit(failed.actors);
604 }
605 const releases = sum((repo) => tallyOf(repo.releases.map((release) => release.actor)));
606 if (releases.count > 0) {
607 lines.push({ key: "releases", text: `published ${plural(releases.count, "release")}`, to: placeFor(slug, repos, releases.repoIds, "releases") });
608 credit(releases.actors);
609 }
610 const packages = sum((repo) => tallyOf(repo.packages.map((pkg) => pkg.actor)));
611 if (packages.count > 0) {
612 lines.push({ key: "packages", text: `published ${plural(packages.count, "package version")}`, to: `/${slug}/-/packages` });
613 credit(packages.actors);
614 }
615
616 // Artifacts: made, and whose content changed.
617 if (activity.folios) {
618 const made = part(activity.folios.created, kind, agentKeys);
619 const edited = new Set<string>();
620 const editors = new Map<string, number>();
621 let allDocs = true;
622 for (const folio of activity.folios.edited) {
623 const mine = folio.authors.filter((author) => actorKind(author, agentKeys) === kind);
624 if (mine.length === 0) continue;
625 edited.add(folio.folio_id);
626 if (folio.kind !== "doc") allDocs = false;
627 for (const author of mine) editors.set(author, (editors.get(author) ?? 0) + 1);
628 }
629 const what = (n: number) => plural(n, allDocs ? "doc" : "artifact");
630 if (made.count + edited.size > 0) {
631 const parts: string[] = [];
632 if (made.count > 0) parts.push(`created ${what(made.count)}`);
633 if (edited.size > 0) parts.push(`edited ${what(edited.size)}`);
634 lines.push({ key: "docs", text: parts.join(", "), to: `/${slug}/-/artifacts` });
635 credit(made.actors);
636 credit(editors);
637 }
638 }
639 }
640
641 // Chat: what each kind said.
642 if (chat) {
643 const said = part(
644 chat.authors.map((author) => ({ actor: author.key, count: author.messages })),
645 kind,
646 agentKeys,
647 );
648 if (said.count > 0) {
649 const text = kind === "person" ? `sent ${plural(said.count, "message")} in ${plural(chat.channels, "conversation")}` : `replied ${plural(said.count, "time")} in chat`;
650 lines.push({ key: "chat", text, to: `/${slug}/-/chat` });
651 credit(said.actors);
652 }
653 }
654
655 const actors: DigestActor[] = [...did]
656 .map(([key, count]) => ({ key, kind, name: names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone"), count }))
657 .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
658 return { kind, lines, actors };
659}
660
661/** A tally with one of each actor, for lists that name an actor per item. */
662function tallyOf(actors: string[]): ActorCount[] {
663 const counts = new Map<string, number>();
664 for (const actor of actors) counts.set(actor, (counts.get(actor) ?? 0) + 1);
665 return [...counts].map(([actor, count]) => ({ actor, count }));
666}
667
668/** Both columns, what the sentence needs, and which sources were silent. */
669export function digestOf(input: DigestInput): Digest {
670 const people = digestColumn("person", input);
671 const agents = digestColumn("agent", input);
672 const missing: string[] = [];
673 if (input.code && !input.activity) missing.push("Code");
674 if (!input.chat) missing.push("Chat");
675 if (!input.work) missing.push("Agents");
676 let commits = 0;
677 let merged = 0;
678 for (const repo of input.activity?.repos ?? []) {
679 for (const by of repo.pushes.by) if (actorKind(by.actor, input.agentKeys) === "person") commits += by.commits;
680 merged += part(repo.pulls.merged, "person", input.agentKeys).count;
681 }
682 const said = part(
683 (input.chat?.authors ?? []).map((author) => ({ actor: author.key, count: author.messages })),
684 "person",
685 input.agentKeys,
686 ).count;
687 const counts = input.work?.counts;
688 return {
689 people,
690 agents,
691 summary: {
692 people: people.actors.length,
693 commits,
694 merged,
695 finished: counts ? counts.accepted + counts.fixed + counts.finished : 0,
696 dropped: counts?.dropped ?? 0,
697 said,
698 anything: people.lines.length > 0 || agents.lines.length > 0,
699 peopleRead: input.activity != null || input.chat != null,
700 },
701 missing,
702 partial: (input.activity != null && !input.activity.complete) || (input.work?.partial ?? false),
703 };
704}
705
706// --- What landed in the span -----------------------------------------------------
707
708/**
709 * A change that landed: a pull request merged; commits pushed straight to
710 * a default branch; a production build that went live; a release or a
711 * package version published. By a person or an agent.
712 */
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.713export type LandedChange = {
714 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.715 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.716 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.717 /** One line under it: where, and by whom. */
718 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.719 to: string;
720 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.721 /** 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.722 agent: boolean;
723};
724
725/** Every pull request that merged in the span, newest first. */
726export function landedIn(pulls: CodePull[], span: Pick<Span, "from" | "now">): LandedChange[] {
727 return pulls
728 .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.729 .map((pull) => {
730 const agent = isAgentPull(pull);
731 const mergedBy = pull.mergedBy && pull.mergedBy !== pull.author.username ? `, merged by ${pull.mergedBy}` : "";
732 return {
733 key: `landed:${pullKey(pull.repo, pull.number)}`,
734 kind: "merge" as const,
735 title: pull.title,
736 detail: `${pull.repo.name}#${pull.number} · by ${agent ? `@${pull.author.username}` : pull.author.username}${mergedBy}`,
737 to: `/${pull.repo.namespace}/${pull.repo.name}/pull/${pull.number}`,
738 at: Date.parse(pull.mergedAt!),
739 agent,
740 };
741 })
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.742 .sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
743}
744
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.745/** The names behind member keys, for a line: `ana and bo`, `@margo`, `a mirror` for nobody. */
746function namesOf(keys: string[], names: Record<string, string>, agentKeys?: ReadonlySet<string>): string {
747 const shown = keys.map((key) => {
748 if (!key) return "a mirror";
749 const name = names[key] ?? (key === G1T_ACTOR ? "g1t" : "someone");
750 return actorKind(key, agentKeys) === "agent" ? `@${name}` : name;
751 });
752 return listOf([...new Set(shown)]);
753}
754
755/**
756 * Commits pushed straight to a default branch, one row per project with
757 * any: what landed without a pull request. Releases and package versions
758 * published, one row each. Pushes within the span only: the digest was
759 * asked for the span.
760 */
761export function landedFromActivity(activity: ActivityDigest | null, repos: Record<string, RepoRef>, names: Record<string, string>, slug: string, agentKeys?: ReadonlySet<string>): LandedChange[] {
762 const rows: LandedChange[] = [];
763 for (const repo of activity?.repos ?? []) {
764 const path = repos[repo.repo_id];
765 if (!path) continue;
766 const direct = repo.pushes.default_branch;
767 if (direct.count > 0 && direct.last_at) {
768 const by = direct.by.map((entry) => entry.actor);
769 rows.push({
770 key: `push:${repo.repo_id}`,
771 kind: "push",
772 title: `${plural(direct.commits, "commit")} pushed to ${path.defaultBranch}`,
773 detail: `${path.name} · ${plural(direct.count, "push", "pushes")} by ${namesOf(by, names, agentKeys)}`,
774 to: `/${path.namespace}/${path.name}/activity`,
775 at: Date.parse(direct.last_at),
776 agent: by.length > 0 && by.every((key) => key && actorKind(key, agentKeys) === "agent"),
777 });
778 }
779 for (const release of repo.releases) {
780 rows.push({
781 key: `release:${repo.repo_id}:${release.tag}`,
782 kind: "release",
783 title: release.name ? `${release.tag} · ${release.name}` : release.tag,
784 detail: `${path.name} · released by ${namesOf([release.actor], names, agentKeys)}`,
785 to: `/${path.namespace}/${path.name}/releases/tag/${encodeURIComponent(release.tag)}`,
786 at: Date.parse(release.at),
787 agent: actorKind(release.actor, agentKeys) === "agent",
788 });
789 }
790 for (const pkg of repo.packages) {
791 rows.push({
792 key: `package:${repo.repo_id}:${pkg.ecosystem}:${pkg.name}:${pkg.version}`,
793 kind: "package",
794 title: `${pkg.name} ${pkg.version}`,
795 detail: `${pkg.ecosystem} package · published by ${namesOf([pkg.actor], names, agentKeys)}`,
796 to: `/${slug}/-/packages`,
797 at: Date.parse(pkg.at),
798 agent: actorKind(pkg.actor, agentKeys) === "agent",
799 });
800 }
801 }
802 return rows;
803}
804
805/** Production builds that went live in the span, as landed changes; previews stay in Deploys. */
806export function landedDeploys(deploys: DeployRow[]): LandedChange[] {
807 return deploys
808 .filter((row) => row.kind === "production" && row.outcome === "live")
809 .map((row) => ({
810 key: `landed:${row.key}`,
811 kind: "deploy" as const,
812 title: `Production of ${row.project} went live`,
Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.813 detail: `${row.commit} · by ${row.by ?? "someone"}`,
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.814 to: row.to,
815 at: row.at,
Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.816 agent: row.agent,
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.817 }));
818}
819
820/** Everything that landed, newest first. */
821export function landed(parts: LandedChange[][]): LandedChange[] {
822 return parts.flat().sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
823}
824
Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.825/** A deployment's maker as a member key: an id becomes `user:<id>`, a key stays as it is. */
826export function deployActorKey(createdBy: string | null | undefined): string | null {
827 const by = (createdBy ?? "").trim();
828 if (!by) return null;
829 return /^(user|agent):/.test(by) ? by : `user:${by}`;
830}
831
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.832/** A build that finished in the span. */
833export type DeployRow = {
834 key: string;
835 project: string;
836 kind: Deployment["kind"];
837 /** `live`: built and served (it may have been replaced since); `failed`: it did not build. */
838 outcome: "live" | "failed";
839 commit: string;
840 branch: string | null;
841 at: number;
842 to: string;
Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.843 /** Who made it, as a name (`@handle` for an agent); null when the deployment does not say. */
844 by: string | null;
845 /** Whether an agent made it. */
846 agent: boolean;
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.847};
848
849/**
850 * Builds that finished in the span, newest first: production builds each,
851 * and previews too. A build that went live and was replaced since still
852 * went live. Skipped builds are left out.
853 */
Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.854export function deploysIn(projects: { slug: string; deployments: Deployment[] }[], workspace: string, span: Pick<Span, "from" | "now">, names: Record<string, string> = {}, agentKeys?: ReadonlySet<string>): DeployRow[] {
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.855 const rows: DeployRow[] = [];
856 for (const project of projects) {
857 for (const deploy of project.deployments) {
Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.858 // Who made it: the deployment names them by id, so it reads as a name, or as someone when identity doesn't know it.
859 const key = deployActorKey(deploy.createdBy);
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.860 const ended = deploy.finishedAt ?? null;
861 if (!within(ended, span)) continue;
862 const outcome = deploy.status === "failed" ? "failed" : deploy.status === "ready" || deploy.status === "replaced" || deploy.status === "down" ? "live" : null;
863 if (!outcome) continue;
864 rows.push({
865 key: `deploy:${project.slug}:${deploy.id}`,
866 project: project.slug,
867 kind: deploy.kind,
868 outcome,
869 commit: deploy.commit.slice(0, 7),
870 branch: deploy.branch,
871 at: Date.parse(ended!),
872 to: `/${workspace}/${project.slug}/deployments/${deploy.id}`,
Notifications is the bell in the top bar alone: the rail no longer carries it, and the notifications guide points at the bell; a phone keeps it in the bottom bar, which has no bell. Home names who made a deployment, by username or by an agent's handle, instead of showing the account's id, and says someone when identity doesn't know the id. The Apps launcher's skeleton tiles are drawn in the line colour so they show on its raised surface.873 by: key ? namesOf([key], names, agentKeys) : null,
874 agent: !!key && actorKind(key, agentKeys) === "agent",
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.875 });
876 }
877 }
878 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
879}
880
881/** A decision made in the span: one an agent or a person recorded in the workspace's memory, or a request an owner answered. */
882export type DecisionRow = {
883 key: string;
884 text: string;
885 by: string | null;
886 at: number;
887 to: string;
888 kind: "memory" | "request";
889};
890
891export function decisionsIn(
892 input: { memories: Memory[] | null; requests: InstallRequest[] | null },
893 slug: string,
894 span: Pick<Span, "from" | "now">,
895): DecisionRow[] {
896 const rows: DecisionRow[] = [];
897 for (const memory of input.memories ?? []) {
898 if (memory.kind !== "decision" || (memory.status != null && memory.status !== "kept") || !within(memory.createdAt, span)) continue;
899 rows.push({ key: `memory:${memory.id}`, text: memory.text, by: memory.createdBy, at: Date.parse(memory.createdAt), to: `/${slug}/-/memory`, kind: "memory" });
900 }
901 for (const request of input.requests ?? []) {
902 if (request.status === "open" || !within(request.resolved_at, span)) continue;
903 rows.push({
904 key: `request:${request.id}`,
905 text: `${request.status === "done" ? "Added" : "Turned down"} ${request.name}, which ${request.requested_by} asked for`,
906 by: request.resolved_by,
907 at: Date.parse(request.resolved_at!),
908 to: `/${slug}/-/marketplace/requests`,
909 kind: "request",
910 });
911 }
912 return rows.sort((a, b) => b.at - a.at || a.key.localeCompare(b.key));
913}
914
915// --- Running now ----------------------------------------------------------------
916
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.917/** 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.918export type RunningRow = {
919 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.920 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.921 title: string;
922 /** One line: who and where. */
923 detail: string;
924 /** "Working", "Queued", "Building". */
925 status: string;
926 /** Epoch ms: when it started. */
927 at: number;
928 to: string;
929};
930
931const SESSION_STATUS: Record<string, string> = { queued: "Queued", working: "Working", waiting: "Waiting on its helpers" };
932
933/** Live sessions at the root of their tree; one stopped at its cap waits on a person and is under Needs you instead. */
934export function runningSessions(sessions: AgentSession[], slug: string): RunningRow[] {
935 return sessions
936 .filter((session) => !session.parent_id && SESSION_STATUS[session.status])
937 .map((session) => ({
938 key: `session:${session.id}`,
939 kind: "session" as const,
940 title: session.visible ? session.title : "A session in a conversation you're not in",
941 detail: `@${session.agent_handle}${session.channel_name ? ` in #${session.channel_name}` : ""}`,
942 status: SESSION_STATUS[session.status]!,
943 at: Date.parse(session.created_at),
944 to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}`,
945 }));
946}
947
948/** Mission control's "waiting on agents" row, as Home reads it (lib/mission-control.ts `WaitingRow`). */
949export type AgentChange = { key: string; repo: RepoPath; ref: string | null; title: string; chip: string; by: { name: string } | null; at: number; to: string };
950
951export function runningChanges(changes: AgentChange[]): RunningRow[] {
952 return changes.map((change) => ({
953 key: `change:${change.key}`,
954 kind: "change" as const,
955 title: change.title,
956 detail: `${change.by ? `@${change.by.name} · ` : ""}${change.repo.namespace}/${change.repo.name}${change.ref ? ` ${change.ref}` : ""}`,
957 status: change.chip,
958 at: change.at,
959 to: change.to,
960 }));
961}
962
963/** Builds queued or going now, one per project (its newest). */
964export function runningDeploys(overview: ProjectDeploys[], slug: string): RunningRow[] {
965 return overview.flatMap((project) => {
966 const latest = project.latest;
967 if (!latest || (latest.status !== "queued" && latest.status !== "building")) return [];
968 return [
969 {
970 key: `deploy:${project.slug}:${latest.id}`,
971 kind: "deploy" as const,
972 title: `${latest.kind === "production" ? "Production" : `Preview of ${latest.branch ?? "a branch"}`} of ${project.slug}`,
973 detail: `${latest.commit.slice(0, 7)} · by ${latest.createdBy}`,
974 status: latest.status === "queued" ? "Queued" : "Building",
975 at: Date.parse(latest.createdAt),
976 to: `/${slug}/${project.slug}/deployments/${latest.id}`,
977 },
978 ];
979 });
980}
981
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.982const RUN_STATUS: Partial<Record<WorkflowRun["status"], string>> = {
983 pending: "Queued",
984 queued: "Queued",
985 action_required: "Needs approval",
986 in_progress: "Running",
987 waiting: "Waiting",
988};
989
990/** Workflow runs not finished, in the projects read: queued, waiting, running, or waiting for approval. */
991export function runningWorkflows(projects: { repo: RepoPath; runs: WorkflowRun[] }[]): RunningRow[] {
992 return projects.flatMap(({ repo, runs }) =>
993 runs.flatMap((run) => {
994 const status = RUN_STATUS[run.status];
995 if (!status) return [];
996 return [
997 {
998 key: `workflow:${repo.namespace}/${repo.name}:${run.id}`.toLowerCase(),
999 kind: "workflow" as const,
1000 title: run.title ? `${run.name}: ${run.title}` : run.name,
1001 detail: `${repo.namespace}/${repo.name} ${run.ref.replace(/^refs\/heads\//, "")}${run.actor ? ` · by ${run.actor}` : ""}`,
1002 status,
1003 at: Date.parse(run.startedAt ?? run.createdAt),
1004 to: `/${repo.namespace}/${repo.name}/actions/runs/${run.id}`,
1005 },
1006 ];
1007 }),
1008 );
1009}
1010
1011/** 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.1012export 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.1013 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.1014 return parts.flat().sort((a, b) => order[a.kind] - order[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
1015}
1016
1017// --- Needs you ------------------------------------------------------------------
1018
1019/**
1020 * What a row is. Tiers, most pressing first:
1021 * 0. production is down to an older build (`deploy`), or the workspace's agents used up its agent budget (`limit`);
1022 * 1. agent work that has stopped and can't go on without you;
1023 * 2. finished work waiting only for you (merge, review, an invitation, an agent asking, a request to add something);
1024 * 3. something stuck that may need a look;
1025 * 4. an unread notification that is a warning or a failure;
1026 * 5. a mention or a direct message.
1027 */
1028export type AttentionKind =
1029 | "limit"
1030 | "deploy"
1031 | "session_cap"
1032 | "agent_budget"
1033 | "pull_blocked"
1034 | "ready"
1035 | "review"
1036 | "invitation"
1037 | "install_request"
1038 | "agent_waiting"
1039 | "stuck"
1040 | "runner"
1041 | "notification"
1042 | "chat";
1043
1044export const TIER: Record<AttentionKind, number> = {
1045 limit: 0,
1046 deploy: 0,
1047 session_cap: 1,
1048 agent_budget: 1,
1049 pull_blocked: 1,
1050 ready: 2,
1051 review: 2,
1052 invitation: 2,
1053 install_request: 2,
1054 agent_waiting: 2,
1055 stuck: 3,
1056 runner: 3,
1057 notification: 4,
1058 chat: 5,
1059};
1060
1061export type Stake = { text: string; tone: "danger" | "warn" | "accent" | null };
1062
1063/** One row of Needs you. */
1064export type AttentionRow = {
1065 key: string;
1066 kind: AttentionKind;
1067 title: string;
1068 /** One line: what it is and where. */
1069 detail: string;
1070 /** What is at stake, in a few words. */
1071 stake: Stake | null;
1072 /** The action, right there. */
1073 action: { label: string; to: string };
1074 /** Who is on it: the agent, or the person who asked. */
1075 owner: { name: string; agent: boolean } | null;
1076 /** Epoch ms: when it started waiting. */
1077 at: number;
1078 /** Where it came from, for a member without Code access never `code`. */
1079 from: "code" | "agents" | "notifications" | "chat" | "marketplace";
1080};
1081
1082/**
1083 * Most pressing first: by tier, then what has waited longest, then by key
1084 * so the order never depends on the order rows were read. One of each key,
1085 * and one of each place: a notification about a pull request already on
1086 * the list is left out.
1087 */
1088export function rankAttention(rows: AttentionRow[]): AttentionRow[] {
1089 const sorted = [...rows].sort((a, b) => TIER[a.kind] - TIER[b.kind] || a.at - b.at || a.key.localeCompare(b.key));
1090 const keys = new Set<string>();
1091 const places = new Set<string>();
1092 return sorted.filter((row) => {
1093 const place = row.action.to.split(/[?#]/)[0];
1094 if (keys.has(row.key) || (row.kind === "notification" && places.has(place))) return false;
1095 keys.add(row.key);
1096 places.add(place);
1097 return true;
1098 });
1099}
1100
1101/** Start here: the first row by `rankAttention`, with why it was picked. */
1102export function startHere(ranked: AttentionRow[]): { row: AttentionRow; why: string } | null {
1103 const row = ranked[0];
1104 if (!row) return null;
1105 const sameTier = ranked.filter((other) => TIER[other.kind] === TIER[row.kind]).length;
1106 const tail = sameTier > 1 ? ` Of the ${sameTier} like it, it has waited longest.` : "";
1107 return { row, why: `${WHY[row.kind]}${tail}` };
1108}
1109
1110const WHY: Record<AttentionKind, string> = {
1111 limit: "Agents start nothing new past the workspace's agent budget, so everything else waits on it.",
1112 install_request: "A member asked for it, and only an owner can add it or turn it down.",
1113 deploy: "Production is serving an older build until this is fixed, so it comes before anything else.",
1114 session_cap: "An agent stopped at its spend cap and does nothing more until someone approves more.",
1115 agent_budget: "An agent used up its budget and takes no new work until it is raised.",
1116 pull_blocked: "An agent stopped on this change and won't go on until a person decides.",
1117 ready: "The work is done and only waits for you to land it.",
1118 review: "You were asked by name, so it waits for your verdict.",
1119 invitation: "It is addressed to you; no one else can answer it.",
1120 agent_waiting: "An agent is waiting on you to go on.",
1121 stuck: "A running agent has stopped reporting and may be stuck.",
1122 runner: "A job is waiting for a runner that is not there.",
1123 notification: "It is the most pressing unread notification.",
1124 chat: "Nothing else is waiting, and this is the oldest message for you.",
1125};
1126
1127// --- Rows from each source ----------------------------------------------------
1128
1129/** Mission control's need row, as Home reads it (lib/mission-control.ts `NeedRow`). */
1130export type CodeNeed = {
1131 key: string;
1132 reason: string;
1133 repo: RepoPath | null;
1134 ref: string | null;
1135 title: string;
1136 ask: string;
1137 by: { name: string; agent: boolean } | null;
1138 for: string | null;
1139 at: number;
1140 to: string;
1141 facts: { label: string; value: string; tone: string | null }[];
1142};
1143
1144const fact = (need: CodeNeed, label: string) => need.facts.find((f) => f.label === label)?.value ?? null;
1145
1146const REASON_STAKE: Record<string, Stake> = {
1147 blocking: { text: "Blocking", tone: "danger" },
1148 checks_failing: { text: "Checks failing", tone: "danger" },
1149 outside_guardrails: { text: "Over a guardrail", tone: "warn" },
1150 low_confidence: { text: "Low confidence", tone: "warn" },
1151 needs_review: { text: "Needs a verdict", tone: "warn" },
1152 stalled: { text: "Stalled", tone: "warn" },
1153};
1154
1155/** A Code need (failed deploy, pull request, review, invitation, stuck run, runner) as a row. */
1156export function codeRow(need: CodeNeed): AttentionRow {
1157 const prefix = need.key.split(":")[0];
1158 const where = need.repo ? `${need.repo.namespace}/${need.repo.name}${need.ref ? ` ${need.ref}` : ""}` : null;
1159 const detail = where ? `${where} · ${need.ask}` : need.ask;
1160 const owner = need.by;
1161 const base = { key: need.key, title: need.title, detail, owner, at: need.at, from: "code" as const };
1162 switch (prefix) {
1163 case "deploy":
1164 return {
1165 ...base,
1166 kind: "deploy",
1167 stake: { text: fact(need, "Production") === "Not live yet" ? "Not live yet" : "Production on an older build", tone: "danger" },
1168 action: { label: "See the build", to: need.to },
1169 };
1170 case "invitation":
1171 return { ...base, kind: "invitation", stake: { text: `${fact(need, "Role") ?? "A"} role`, tone: null }, action: { label: "Respond", to: need.to } };
1172 case "review": {
1173 const lines = fact(need, "Lines");
1174 return { ...base, kind: "review", stake: { text: lines ?? "Your review", tone: null }, action: { label: "Review", to: need.to } };
1175 }
1176 case "runner":
1177 return { ...base, kind: "runner", stake: { text: `Waiting ${fact(need, "Waiting") ?? ""}`.trim(), tone: "warn" }, action: { label: "Look", to: need.to } };
1178 case "run": {
1179 const cost = fact(need, "Cost so far");
1180 return {
1181 ...base,
1182 kind: "stuck",
1183 stake: { text: cost ? `${cost} so far` : `Quiet ${fact(need, "Quiet for") ?? ""}`.trim(), tone: "warn" },
1184 action: { label: "Look", to: need.to },
1185 };
1186 }
1187 default: {
1188 if (need.reason === "ready_to_merge") {
1189 const files = fact(need, "Files changed");
1190 return {
1191 ...base,
1192 kind: "ready",
1193 stake: { text: files ? `${files} ${files === "1" ? "file" : "files"} to land` : "Ready to land", tone: "accent" },
1194 action: { label: "Merge", to: need.to },
1195 };
1196 }
1197 return {
1198 ...base,
1199 kind: "pull_blocked",
1200 stake: REASON_STAKE[need.reason] ?? { text: "Waiting on you", tone: "warn" },
1201 action: { label: need.reason === "low_confidence" || need.reason === "needs_review" ? "Review" : "Decide", to: need.to },
1202 };
1203 }
1204 }
1205}
1206
1207/** A session stopped at its spend cap that the viewer may approve more for. */
1208export function sessionCapRow(session: AgentSession, slug: string): AttentionRow {
1209 const cap = session.cap_micros;
1210 return {
1211 key: `session:${session.id}`,
1212 kind: "session_cap",
1213 title: session.visible ? session.title : `A session of @${session.agent_handle}`,
1214 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.1215 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.1216 action: { label: "Approve more", to: `/${slug}/-/agents/${session.agent_handle}/sessions/${session.id}` },
1217 owner: { name: session.agent_handle, agent: true },
1218 at: Date.parse(session.updated_at),
1219 from: "agents",
1220 };
1221}
1222
1223export type AgentLike = {
1224 id: string;
1225 handle: string;
1226 display_name: string;
1227 status: string;
1228 spent_month_micros: number;
1229 budget: { monthly_micros: number | null };
1230 updated_at: string;
1231};
1232
1233/** An agent out of budget (for those who may raise it), or waiting on someone. */
1234export function agentRow(agent: AgentLike, slug: string): AttentionRow | null {
1235 const base = {
1236 key: `agent:${agent.id}`,
1237 owner: { name: agent.handle, agent: true },
1238 at: Date.parse(agent.updated_at),
1239 from: "agents" as const,
1240 };
1241 if (agent.status === "out_of_budget") {
1242 const budget = agent.budget.monthly_micros;
1243 return {
1244 ...base,
1245 kind: "agent_budget",
1246 title: `${agent.display_name} is out of budget`,
1247 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.1248 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.1249 action: { label: "Raise the budget", to: `/${slug}/-/agents/${agent.handle}/spend` },
1250 };
1251 }
1252 if (agent.status === "waiting") {
1253 return {
1254 ...base,
1255 kind: "agent_waiting",
1256 title: `${agent.display_name} is waiting`,
1257 detail: `@${agent.handle} is waiting before it goes on.`,
1258 stake: null,
1259 action: { label: "Open", to: `/${slug}/-/agents/${agent.handle}` },
1260 };
1261 }
1262 return null;
1263}
1264
1265const NOTIFICATION_STAKE: Record<string, string> = {
1266 agent: "Agent waiting",
1267 review_requested: "Review requested",
1268 assign: "Assigned to you",
1269 mention: "Mentioned",
1270 team_mention: "Team mentioned",
1271 ci_activity: "CI activity",
1272 security_alert: "Security alert",
1273 state_change: "State changed",
1274 author: "Your work",
1275 comment: "Comment",
1276 manual: "Subscribed",
1277 subscribed: "Watching",
1278};
1279
1280/**
1281 * An unread notification that is a warning (someone or something is
1282 * waiting) or a failure, in this workspace. A member without Code access
1283 * gets none about a repository.
1284 */
1285export function notificationRow(item: InboxItem, slug: string, code: boolean): AttentionRow | null {
1286 if (item.readAt != null || item.doneAt != null) return null;
1287 if (item.severity !== "warning" && item.severity !== "error") return null;
1288 const inWorkspace = (item.workspace ?? item.repo?.split("/")[0] ?? "").toLowerCase() === slug;
1289 if (!inWorkspace) return null;
1290 if (!code && item.repo) return null;
1291 return {
1292 key: `notification:${item.id}`,
1293 kind: "notification",
1294 title: item.body || item.title,
1295 detail: item.body ? item.title : (item.repo ?? "A notification"),
1296 stake: { text: NOTIFICATION_STAKE[item.reason] ?? "Notification", tone: item.severity === "error" ? "danger" : "warn" },
1297 action: { label: "Open", to: item.url },
1298 owner: item.actor ? { name: item.actor, agent: item.actor === "g1t" || item.actor.endsWith("-agent") } : null,
1299 at: Date.parse(item.updatedAt),
1300 from: "notifications",
1301 };
1302}
1303
1304export type ChatEntryLike = {
1305 channel: { id: string; kind: "channel" | "dm"; name: string | null; last_message_at?: string | null };
1306 title: string;
1307 muted: boolean;
1308 unread: number;
1309 mentions: number;
1310};
1311
1312/** A conversation with a mention of you, or a direct message you haven't read. */
1313export function chatRow(entry: ChatEntryLike, slug: string): AttentionRow | null {
1314 if (entry.muted) return null;
1315 if (!(entry.mentions > 0 || (entry.channel.kind === "dm" && entry.unread > 0))) return null;
1316 const to = entry.channel.kind === "dm" || !entry.channel.name ? `/${slug}/-/chat/dm/${entry.channel.id}` : `/${slug}/-/chat/${entry.channel.name}`;
1317 const dm = entry.channel.kind === "dm";
1318 return {
1319 key: `chat:${entry.channel.id}`,
1320 kind: "chat",
1321 title: dm ? entry.title : `#${entry.title}`,
1322 detail: dm ? "A direct message you haven't read." : "You were mentioned.",
1323 stake: {
1324 text: entry.mentions > 0 ? `${entry.mentions} ${entry.mentions === 1 ? "mention" : "mentions"}` : `${entry.unread} unread`,
1325 tone: entry.mentions > 0 ? "accent" : null,
1326 },
1327 action: { label: "Reply", to },
1328 owner: null,
1329 at: entry.channel.last_message_at ? Date.parse(entry.channel.last_message_at) : 0,
1330 from: "chat",
1331 };
1332}
1333
1334/** A member's open request to add something from the Marketplace, for an owner. */
1335export function installRequestRow(request: InstallRequest, slug: string): AttentionRow | null {
1336 if (request.status !== "open") return null;
1337 return {
1338 key: `request:${request.id}`,
1339 kind: "install_request",
1340 title: `${request.requested_by} asked to add ${request.name}`,
1341 detail: request.note ? `“${request.note}”` : "A request to add it from the Marketplace.",
1342 stake: { text: "Only owners can add it", tone: null },
1343 action: { label: "Review the request", to: `/${slug}/-/marketplace/requests` },
1344 owner: { name: request.requested_by, agent: false },
1345 at: Date.parse(request.requested_at),
1346 from: "marketplace",
1347 };
1348}
1349
1350/**
1351 * The workspace's agents at 100% of its monthly agent budget, for those who
1352 * may raise it. `since` is when it was reached as far as is known: the
1353 * newest session that stopped at its cap, else the start of the month.
1354 */
1355export function limitRow(input: { alert: number | null; spentMicros: number; since: number }, slug: string): AttentionRow | null {
1356 if (input.alert == null || input.alert < 100) return null;
1357 return {
1358 key: "limit:agents",
1359 kind: "limit",
1360 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.1361 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.1362 stake: { text: "No new agent work", tone: "danger" },
1363 action: { label: "Raise the budget", to: `/${slug}/-/spend` },
1364 owner: null,
1365 at: input.since,
1366 from: "agents",
1367 };
1368}
1369
1370/** What Needs you and Start here show, and which sources did not answer. */
1371export type Attention = {
1372 rows: AttentionRow[];
1373 start: { row: AttentionRow; why: string } | null;
1374 /** Sources that did not answer, by name, for the card to say so. */
1375 missing: string[];
1376};
1377
1378export function attention(input: {
1379 slug: string;
1380 code: boolean;
1381 codeNeeds: CodeNeed[] | null;
1382 capped: AgentSession[] | null;
1383 agents: AgentLike[] | null;
1384 canManage: boolean;
1385 /** The workspace's agent budget, for those who may manage agents; null for anyone else. */
1386 limit?: { alert: number | null; spentMicros: number; since: number } | null;
1387 /** Install requests, when the viewer may answer them (an owner); null otherwise or when they couldn't be read. */
1388 requests?: InstallRequest[] | null;
1389 notifications: InboxItem[] | null;
1390 chat: ChatEntryLike[] | null;
1391}): Attention {
1392 const rows: AttentionRow[] = [];
1393 const missing: string[] = [];
1394 if (input.code) {
1395 if (input.codeNeeds) rows.push(...input.codeNeeds.map(codeRow));
1396 else missing.push("Code");
1397 }
1398 if (input.limit && input.canManage) {
1399 const row = limitRow(input.limit, input.slug);
1400 if (row) rows.push(row);
1401 }
1402 for (const request of input.requests ?? []) {
1403 const row = installRequestRow(request, input.slug);
1404 if (row) rows.push(row);
1405 }
1406 if (input.capped) rows.push(...input.capped.map((session) => sessionCapRow(session, input.slug)));
1407 if (input.agents) {
1408 const cappedAgents = new Set((input.capped ?? []).map((s) => s.agent_handle));
1409 for (const agent of input.agents) {
1410 if (agent.status === "out_of_budget" && !input.canManage) continue;
1411 if (agent.status === "waiting" && cappedAgents.has(agent.handle)) continue;
1412 const row = agentRow(agent, input.slug);
1413 if (row) rows.push(row);
1414 }
1415 }
1416 if (input.capped == null || input.agents == null) missing.push("Agents");
1417 if (input.notifications) {
1418 for (const item of input.notifications) {
1419 const row = notificationRow(item, input.slug, input.code);
1420 if (row) rows.push(row);
1421 }
1422 } else missing.push("Notifications");
1423 if (input.chat) {
1424 for (const entry of input.chat) {
1425 const row = chatRow(entry, input.slug);
1426 if (row) rows.push(row);
1427 }
1428 } else missing.push("Chat");
1429 const ranked = rankAttention(rows);
1430 return { rows: ranked, start: startHere(ranked), missing };
1431}
1432
1433// --- Spend in the span ----------------------------------------------------------
1434
1435/** Money in, which the statement lists but which is not spend. */
1436const MONEY_IN: ReadonlySet<string> = new Set(["Payments", "AI credit", "Credits from g1t", "Refunds", "Tax", "Card processing fees"]);
1437
1438export type Spend = {
1439 /** The statement days counted, `YYYY-MM-DD` in UTC, first and last. */
1440 from: string;
1441 to: string;
1442 /** Usage at price over those days, in micros. */
1443 totalMicros: number;
1444 /** A line per kind of charge, as the statement names it, most first. */
1445 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.1446 /** Usage at price this month so far, the top bar's and Usage's figure; null when it could not be read. */
1447 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.1448};
1449
1450/** The UTC months (`YYYY-MM`) the span touches, oldest first: the statements Home reads. */
1451export function spanMonths(span: Pick<Span, "from" | "now">): string[] {
1452 const months: string[] = [];
1453 const first = new Date(span.from);
1454 let year = first.getUTCFullYear();
1455 let month = first.getUTCMonth();
1456 const last = new Date(span.now).toISOString().slice(0, 7);
1457 for (let i = 0; i < 3; i++) {
1458 const key = `${year}-${String(month + 1).padStart(2, "0")}`;
1459 months.push(key);
1460 if (key >= last) break;
1461 month++;
1462 if (month === 12) {
1463 month = 0;
1464 year++;
1465 }
1466 }
1467 return months;
1468}
1469
1470/**
1471 * The span's spend from the statements grouped by day: each usage line at
1472 * its price (what was charged, plus what the plan, a trial, a pool or a
1473 * discount paid of it), leaving out money in. The statement keeps whole
1474 * UTC days, so the span is counted from the start of the UTC day it began.
1475 * `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.1476 * `monthMicros` is this month so far as billing's usage report counts it,
1477 * the month's usage not yet closed included: the figure the top bar's
1478 * 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.1479 */
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.1480export 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.1481 const from = new Date(span.from).toISOString().slice(0, 10);
1482 const to = new Date(span.now).toISOString().slice(0, 10);
1483 const byKind = new Map<string, { micros: number; count: number }>();
1484 for (const statement of statements) {
1485 for (const group of statement.groups) {
1486 if (group.key < from || group.key > to) continue;
1487 for (const line of group.lines) {
1488 if (MONEY_IN.has(line.kind)) continue;
1489 const micros = line.priceMicros ?? line.chargedMicros;
1490 const kept = byKind.get(line.kind) ?? { micros: 0, count: 0 };
1491 byKind.set(line.kind, { micros: kept.micros + micros, count: kept.count + line.count });
1492 }
1493 }
1494 }
1495 const lines = [...byKind]
1496 .map(([kind, value]) => ({ kind, ...value }))
1497 .filter((line) => line.micros > 0)
1498 .sort((a, b) => b.micros - a.micros || a.kind.localeCompare(b.kind));
1499 return {
1500 from,
1501 to,
1502 totalMicros: lines.reduce((sum, line) => sum + line.micros, 0),
1503 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.1504 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.1505 };
1506}
1507
1508// --- The sentence -----------------------------------------------------------
1509
1510/**
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.1511 * The sentence under the heading, honest about what happened: "Since
1512 * Tuesday evening, 3 people pushed 41 commits and merged 5 changes, agents
1513 * finished 7 tasks, and 12 changes landed." What people did comes first
1514 * (commits and merges; else what they said), then the agents' tasks that
1515 * settled well (accepted, fixed, sessions finished), then everything that
1516 * landed. When nothing at all was counted: "Quiet since Tuesday evening:
1517 * nothing pushed, merged, deployed or said." When what people did could
1518 * 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.1519 */
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.1520export 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.1521 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.1522 if (!digest) return `${lead}, what happened couldn't be read.`;
1523 const { people: count, commits, merged, finished, dropped, said, anything, peopleRead } = digest.summary;
1524 const landedCount = landed ?? 0;
1525 if (!anything && landedCount === 0) {
1526 if (!peopleRead) return `${lead}, what people did couldn't be read, and no agent task settled.`;
1527 return `Quiet ${lead.charAt(0).toLowerCase()}${lead.slice(1)}: nothing pushed, merged, deployed or said.`;
1528 }
1529 const parts: string[] = [];
1530 if (count > 0) {
1531 if (commits > 0 || merged > 0) {
1532 const did: string[] = [];
1533 if (commits > 0) did.push(`pushed ${plural(commits, "commit")}`);
1534 if (merged > 0) did.push(`merged ${plural(merged, "change")}`);
1535 parts.push(`${people(count)} ${did.join(" and ")}`);
1536 } else if (said > 0) parts.push(`${people(count)} sent ${plural(said, "message")}`);
1537 else parts.push(`${people(count)} ${count === 1 ? "was" : "were"} active`);
1538 }
1539 if (finished > 0) parts.push(`agents finished ${plural(finished, "task")}`);
1540 else if (dropped > 0) parts.push(`agents finished none of ${plural(dropped, "task")}`);
1541 if (landedCount > 0) parts.push(`${plural(landedCount, "change")} landed`);
1542 const tail = peopleRead ? "" : "; what people did couldn't be read";
1543 if (parts.length === 0) return `${lead}, work went on, but nothing landed and no agent task finished${tail}.`;
1544 const joined = parts.length > 2 ? `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}` : parts.join(" and ");
1545 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.1546}
1547
1548/** "8 things need you.", or that nothing does. */
1549export function waitingSentence(total: number): string {
1550 if (total === 0) return "Nothing needs you right now.";
1551 return `${plural(total, "thing")} ${total === 1 ? "needs" : "need"} you.`;
1552}
1553
1554/** How long something has waited: "45 min", "17 h", "2 d". */
1555export function waited(at: number, now: number): string {
1556 const minutes = Math.max(0, Math.floor((now - at) / MINUTE));
1557 if (minutes < 60) return `${minutes} min`;
1558 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)} h`;
1559 return `${Math.floor(minutes / (24 * 60))} d`;
1560}
1561
1562/** "3:40 PM" when it is the same day as `now`, else "Oct 7". */
1563export function whenShort(at: number, now: number, timeZone: string | null): string {
1564 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
1565 return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone);
1566}