@mmerterden/multi-agent-pipeline 20.29.0 → 20.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -5
  3. package/README.tr.md +5 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +7 -7
  7. package/install/catalog-history.json +1 -1
  8. package/install/claude.mjs +10 -1
  9. package/install/codex.mjs +2 -0
  10. package/install/copilot.mjs +13 -0
  11. package/manifest.json +149 -48
  12. package/package.json +2 -1
  13. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  14. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  15. package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
  16. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  17. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  18. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  19. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  20. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  21. package/pipeline/contract/CHANGELOG.md +62 -0
  22. package/pipeline/contract/README.md +52 -6
  23. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  24. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  25. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  26. package/pipeline/contract/manifest.json +367 -22
  27. package/pipeline/contract/types/index.d.ts +530 -3
  28. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  29. package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
  30. package/pipeline/multi-agent-refs/help/en.md +3 -0
  31. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  32. package/pipeline/office/NOTICE.md +42 -0
  33. package/pipeline/office/assets/BIN.png +0 -0
  34. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  35. package/pipeline/office/assets/CACTUS.png +0 -0
  36. package/pipeline/office/assets/CLOCK.png +0 -0
  37. package/pipeline/office/assets/COFFEE.png +0 -0
  38. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  39. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  40. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  41. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  42. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  43. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  44. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  45. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  46. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  47. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  48. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  49. package/pipeline/office/assets/PC_BACK.png +0 -0
  50. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  51. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  52. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  53. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  54. package/pipeline/office/assets/PC_SIDE.png +0 -0
  55. package/pipeline/office/assets/PLANT.png +0 -0
  56. package/pipeline/office/assets/PLANT_2.png +0 -0
  57. package/pipeline/office/assets/POT.png +0 -0
  58. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  59. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  60. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  61. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  62. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  63. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  64. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  65. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  66. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  67. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  68. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  69. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  70. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  71. package/pipeline/office/assets/char_0.png +0 -0
  72. package/pipeline/office/assets/char_1.png +0 -0
  73. package/pipeline/office/assets/char_2.png +0 -0
  74. package/pipeline/office/assets/char_3.png +0 -0
  75. package/pipeline/office/assets/char_4.png +0 -0
  76. package/pipeline/office/assets/char_5.png +0 -0
  77. package/pipeline/office/assets/floor_0.png +0 -0
  78. package/pipeline/office/assets/floor_1.png +0 -0
  79. package/pipeline/office/assets/floor_2.png +0 -0
  80. package/pipeline/office/assets/floor_3.png +0 -0
  81. package/pipeline/office/assets/floor_4.png +0 -0
  82. package/pipeline/office/assets/floor_5.png +0 -0
  83. package/pipeline/office/assets/floor_6.png +0 -0
  84. package/pipeline/office/assets/floor_7.png +0 -0
  85. package/pipeline/office/assets/floor_8.png +0 -0
  86. package/pipeline/office/assets/wall_0.png +0 -0
  87. package/pipeline/office/index.html +5319 -0
  88. package/pipeline/schemas/activity-chat.schema.json +78 -0
  89. package/pipeline/schemas/activity-roles.json +48 -0
  90. package/pipeline/schemas/activity-roles.schema.json +49 -0
  91. package/pipeline/schemas/activity.schema.json +429 -0
  92. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  93. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  94. package/pipeline/schemas/contract-error.schema.json +33 -3
  95. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  96. package/pipeline/schemas/ledger.schema.json +202 -0
  97. package/pipeline/schemas/permissions.schema.json +47 -0
  98. package/pipeline/schemas/prefs.schema.json +19 -0
  99. package/pipeline/schemas/prs.schema.json +73 -0
  100. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  101. package/pipeline/schemas/review-verdict.schema.json +62 -0
  102. package/pipeline/schemas/routine-delete.schema.json +59 -0
  103. package/pipeline/schemas/routines.schema.json +38 -0
  104. package/pipeline/schemas/run-evidence.schema.json +29 -0
  105. package/pipeline/schemas/run-results.schema.json +218 -0
  106. package/pipeline/schemas/service-read.schema.json +58 -0
  107. package/pipeline/schemas/stack-change.schema.json +82 -0
  108. package/pipeline/schemas/stack-request.schema.json +33 -0
  109. package/pipeline/schemas/stack.schema.json +59 -0
  110. package/pipeline/schemas/steer-request.schema.json +25 -0
  111. package/pipeline/schemas/steer.schema.json +59 -0
  112. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  113. package/pipeline/schemas/tool-call.schema.json +47 -0
  114. package/pipeline/schemas/tools.schema.json +60 -0
  115. package/pipeline/scripts/_cost.mjs +66 -1
  116. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  117. package/pipeline/scripts/_spawn-async.mjs +73 -0
  118. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  119. package/pipeline/scripts/activity-snapshot.mjs +1027 -0
  120. package/pipeline/scripts/autopilot-control.mjs +214 -9
  121. package/pipeline/scripts/contract-server.mjs +1178 -48
  122. package/pipeline/scripts/cost-analyze.mjs +2 -53
  123. package/pipeline/scripts/cost-table.json +1 -0
  124. package/pipeline/scripts/office-build.mjs +89 -0
  125. package/pipeline/scripts/office-control.mjs +383 -0
  126. package/pipeline/scripts/office-ledger.mjs +409 -0
  127. package/pipeline/scripts/routine-registry.mjs +42 -6
  128. package/pipeline/scripts/run-results.mjs +373 -0
  129. package/pipeline/scripts/service-read.mjs +244 -0
  130. package/pipeline/scripts/stack-toggle.mjs +285 -0
  131. package/pipeline/scripts/steer-run.mjs +97 -0
  132. package/pipeline/scripts/toolkit-tools.mjs +599 -0
  133. package/pipeline/scripts/uninstall.mjs +3 -0
  134. package/pipeline/scripts/work-items.mjs +304 -0
  135. package/pipeline/skills/.skill-manifest.json +19 -7
  136. package/pipeline/skills/.skills-index.json +34 -1
  137. package/pipeline/skills/shared/README.md +6 -3
  138. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  139. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  140. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
  141. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  142. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  143. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  144. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  145. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  146. package/pipeline/skills/skills-index.md +4 -1
@@ -0,0 +1,1027 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file activity-snapshot.mjs - what every agent on this machine is doing
4
+ * right now, as one document the office view draws: a room per repository, a
5
+ * table per run (its team), a seat per agent at that table.
6
+ *
7
+ * Two sources, both on disk, read on every call:
8
+ *
9
+ * 1. the run index (runs-index.mjs buildIndex): which runs exist, their
10
+ * phase, `meta.Now`, the question they wait on, their tracker tokens;
11
+ * 2. the host transcripts that changed within the recent window (10 minutes
12
+ * by default): `~/.claude/projects/<slug>/<session>.jsonl` for the
13
+ * session's own desk and `<session>/subagents/agent-<id>.jsonl` with its
14
+ * `.meta.json` sidecar (agentType, description, finished) for each
15
+ * subagent; Copilot CLI and Codex sessions as a single limited desk,
16
+ * since neither host writes subagent detail.
17
+ *
18
+ * A session is tied to a run by the session id the run recorded, else by its
19
+ * working directory and branch. Two runs that both fit leave the session at a
20
+ * table of its own marked `unmatched`; nothing is written into a run on a guess.
21
+ * Parallel runs of one repository sit at tables in the same room; a run over
22
+ * several repositories (`projects[]`) is listed in `corridors`.
23
+ *
24
+ * Seats: the orchestrator (the session itself) at the head, then every
25
+ * subagent in the order of the phase it was spawned in, parallel ones side by
26
+ * side. `holder` names the seat that holds the work now and `previousHolder`
27
+ * the last seat of an earlier phase, so a client can draw the handoff. Table
28
+ * fields come from the run's own state (location, branch, phase n of last,
29
+ * elapsed time, tracker cost); what the state does not say is null, never
30
+ * guessed.
31
+ *
32
+ * The role over each desk comes from schemas/activity-roles.json and only
33
+ * from there: a known agentType has its label, an untyped one (general-purpose,
34
+ * claude, fork) takes the role of the phase the run was in when it spawned, and
35
+ * anything else is shown as its raw type. Agents of one role at one table are
36
+ * numbered (Reviewer 1, Reviewer 2) in spawn order.
37
+ *
38
+ * Privacy. Transcripts are read through _transcript-tail.mjs, which never
39
+ * parses api-request records and drops thinking blocks. A desk's label is
40
+ * built from allow-listed fields only: the tool name, a file's basename and the
41
+ * sidecar description; a Bash command is never shown here. Every string that
42
+ * reaches the document passes the transcript redactor, and the finished
43
+ * document is checked once more by the outbound gate. The chat page (`chat`)
44
+ * shows message text and tool input, redacted the same way.
45
+ *
46
+ * Read-only: no file is written. Between calls only rebuildable state is kept
47
+ * in memory (one byte offset and one reduced state per transcript).
48
+ *
49
+ * Usage:
50
+ * activity-snapshot.mjs [--json] [--recent-minutes N]
51
+ * activity-snapshot.mjs chat <sessionId> [<agentId>] [--before N] [--limit N]
52
+ *
53
+ * TRANSCRIPTS_ROOT, COPILOT_TRANSCRIPTS_ROOT and CODEX_TRANSCRIPTS_ROOT
54
+ * override where transcripts are read, as for transcript-index.mjs.
55
+ *
56
+ * Exit: 0 printed, 2 usage, 3 no such session or agent (chat), 4 redaction guard.
57
+ *
58
+ * @module pipeline/scripts/activity-snapshot
59
+ */
60
+
61
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
62
+ import { basename, join } from "node:path";
63
+ import { scan } from "../lib/outbound-gate.mjs";
64
+ import { listSessions, sessionLiveness } from "../lib/claude-sessions.mjs";
65
+ import { readRegularJson } from "../lib/regular-file.mjs";
66
+ import { runMain } from "../lib/fatal.mjs";
67
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
68
+ import { ACTIVITY_TYPES, CHAT_TYPES, createFollower, readTailPage } from "./_transcript-tail.mjs";
69
+ import { createAdapter, detectFormat, sessionCwd } from "./_transcript-hosts.mjs";
70
+ import {
71
+ assertClean,
72
+ createRedactor,
73
+ defaultCodexRoot,
74
+ defaultCopilotRoot,
75
+ defaultProjectsRoot,
76
+ } from "./transcript-index.mjs";
77
+ import { buildIndex } from "./runs-index.mjs";
78
+
79
+ export const CONTRACT_VERSION = "1.0.0";
80
+ export const RECENT_MS = 10 * 60_000;
81
+
82
+ export const ROLES = JSON.parse(
83
+ readFileSync(new URL("../schemas/activity-roles.json", import.meta.url), "utf8"),
84
+ );
85
+ const PHASE_NAMES = Object.fromEntries(
86
+ JSON.parse(readFileSync(new URL("../schemas/phases.json", import.meta.url), "utf8")).phases.map(
87
+ (p) => [String(p.id), p.name],
88
+ ),
89
+ );
90
+
91
+ const TERMINAL = new Set(["complete", "completed", "done", "abandoned", "cancelled", "failed"]);
92
+ const READ_TOOLS = new Set([
93
+ "Read",
94
+ "Grep",
95
+ "Glob",
96
+ "LS",
97
+ "WebFetch",
98
+ "WebSearch",
99
+ "NotebookRead",
100
+ "ToolSearch",
101
+ "view",
102
+ "read_file",
103
+ ]);
104
+ const READ_MCP = /(?:^|_)(get|list|read|search|fetch|query|find|status|describe)(?:_|$)/i;
105
+ const SPAWN_TOOLS = new Set(["Agent", "Task", "task", "spawn_agent"]);
106
+ const WAIT_TOOLS = new Set(["AskUserQuestion"]);
107
+ const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
108
+ const SAFE_AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
109
+ const DESCRIPTION_LIMIT = 56;
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Roles, models, tools
113
+
114
+ /**
115
+ * The role label for an agent type. `phaseId` is the run phase the agent was
116
+ * spawned in; it decides the role of an untyped agent and nothing else.
117
+ *
118
+ * @param {string|null} agentType null for a session's own desk
119
+ * @param {number|string|null} phaseId
120
+ * @returns {{role: string, known: boolean}}
121
+ */
122
+ export function roleFor(agentType, phaseId) {
123
+ if (!agentType) return { role: ROLES.orchestrator, known: true };
124
+ const named = ROLES.agentTypes[agentType];
125
+ if (named) return { role: named, known: true };
126
+ if (ROLES.untyped.includes(agentType)) {
127
+ const byPhase =
128
+ phaseId === null || phaseId === undefined ? null : ROLES.phaseRoles[String(phaseId)];
129
+ if (byPhase) return { role: byPhase, known: true };
130
+ }
131
+ return { role: agentType, known: false };
132
+ }
133
+
134
+ /** The fixed sprite and color of a role; an unknown role gets the neutral one. */
135
+ export function styleFor(role) {
136
+ return ROLES.styles[role] ?? ROLES.unknownStyle;
137
+ }
138
+
139
+ /** "Opus" for claude-opus-5-5; any other id as it is. */
140
+ export function modelLabel(id) {
141
+ if (typeof id !== "string" || !id) return null;
142
+ const m = /(fable|opus|sonnet|haiku)/i.exec(id);
143
+ return m ? m[1][0].toUpperCase() + m[1].slice(1).toLowerCase() : id;
144
+ }
145
+
146
+ function toolState(name) {
147
+ if (SPAWN_TOOLS.has(name)) return "spawning";
148
+ if (WAIT_TOOLS.has(name)) return "waiting";
149
+ if (READ_TOOLS.has(name)) return "reading";
150
+ if (name.startsWith("mcp__") && READ_MCP.test(name.split("__").pop())) return "reading";
151
+ return "writing";
152
+ }
153
+
154
+ /** The one allow-listed detail of a tool call: a file's basename, or the spawned type. */
155
+ function toolDetail(name, input) {
156
+ if (!input || typeof input !== "object") return null;
157
+ if (SPAWN_TOOLS.has(name)) {
158
+ const t = input.subagent_type ?? input.agent_type;
159
+ return typeof t === "string" && t ? t : null;
160
+ }
161
+ for (const k of ["file_path", "notebook_path", "path"]) {
162
+ if (typeof input[k] === "string" && input[k]) return basename(input[k]);
163
+ }
164
+ return null;
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // One transcript, reduced to what a desk shows
169
+
170
+ function emptyState() {
171
+ return {
172
+ firstAt: null,
173
+ lastAt: null,
174
+ model: null,
175
+ cwd: null,
176
+ gitBranch: null,
177
+ title: null,
178
+ last: null,
179
+ pending: new Map(),
180
+ };
181
+ }
182
+
183
+ const tsOf = (rec) => {
184
+ const t = Date.parse(rec?.timestamp ?? "");
185
+ return Number.isFinite(t) ? t : null;
186
+ };
187
+
188
+ /** Fold one Claude-shaped record into a desk state. */
189
+ export function reduceRecord(s, rec) {
190
+ const t = tsOf(rec);
191
+ if (t !== null) {
192
+ s.firstAt = s.firstAt === null ? t : Math.min(s.firstAt, t);
193
+ s.lastAt = s.lastAt === null ? t : Math.max(s.lastAt, t);
194
+ }
195
+ if (typeof rec.cwd === "string" && rec.cwd) s.cwd = rec.cwd;
196
+ if (typeof rec.gitBranch === "string" && rec.gitBranch) s.gitBranch = rec.gitBranch;
197
+ if (rec.type === "ai-title" && typeof rec.aiTitle === "string") s.title = rec.aiTitle;
198
+ if (rec.type === "system" && rec.subtype === "turn_duration") s.pending.clear();
199
+ const msg = rec.message;
200
+ if (!msg || typeof msg !== "object") return s;
201
+ const content = Array.isArray(msg.content) ? msg.content : null;
202
+ if (rec.type === "assistant") {
203
+ if (typeof msg.model === "string" && msg.model && !msg.model.startsWith("<")) {
204
+ s.model = msg.model;
205
+ }
206
+ for (const b of content ?? []) {
207
+ if (b?.type === "tool_use" && typeof b.name === "string") {
208
+ const entry = { tool: b.name, detail: toolDetail(b.name, b.input) };
209
+ s.pending.set(String(b.id ?? s.pending.size), entry);
210
+ s.last = entry;
211
+ }
212
+ }
213
+ if (msg.stop_reason === "end_turn" || msg.stop_reason === "stop_sequence") s.pending.clear();
214
+ } else if (rec.type === "user") {
215
+ const results = (content ?? []).filter((b) => b?.type === "tool_result");
216
+ for (const b of results) s.pending.delete(String(b.tool_use_id));
217
+ if (!results.length && !rec.isMeta) s.pending.clear();
218
+ }
219
+ return s;
220
+ }
221
+
222
+ /** The state a desk shows for a reduced transcript. */
223
+ function deskActivity(s, finished) {
224
+ if (finished)
225
+ return { state: "done", tool: s.last?.tool ?? null, detail: s.last?.detail ?? null };
226
+ const running = [...s.pending.values()].at(-1);
227
+ if (running)
228
+ return { state: toolState(running.tool), tool: running.tool, detail: running.detail };
229
+ return { state: "idle", tool: s.last?.tool ?? null, detail: s.last?.detail ?? null };
230
+ }
231
+
232
+ /**
233
+ * Rebuildable per-file state between snapshots: a follower (offsets) and the
234
+ * reduced state of each transcript it has read.
235
+ */
236
+ export function createActivityCache() {
237
+ return { follower: createFollower({ allow: null }), states: new Map(), heads: new Map() };
238
+ }
239
+ const defaultCache = createActivityCache();
240
+
241
+ /**
242
+ * The reduced state of one transcript, advanced by whatever was appended since
243
+ * the previous call. `format` is detected from the first record seen.
244
+ */
245
+ function stateOf(cache, path) {
246
+ const got = cache.follower.read(path);
247
+ let entry = cache.states.get(path);
248
+ if (!entry || got.reset) {
249
+ entry = { state: emptyState(), format: null, adapter: null };
250
+ cache.states.set(path, entry);
251
+ }
252
+ for (const { record } of got.records) {
253
+ if (!entry.format) {
254
+ entry.format = detectFormat(record);
255
+ entry.adapter = createAdapter(entry.format);
256
+ }
257
+ for (const rec of entry.adapter(record)) {
258
+ if (entry.format === "claude" && !ACTIVITY_TYPES.has(rec.type)) continue;
259
+ reduceRecord(entry.state, rec);
260
+ }
261
+ }
262
+ entry.touched = true;
263
+ return entry;
264
+ }
265
+
266
+ /** The working directory a Copilot CLI or Codex session started in (its opening record). */
267
+ function hostHead(cache, path) {
268
+ if (cache.heads.has(path)) return cache.heads.get(path);
269
+ let cwd = null;
270
+ try {
271
+ const text = readFileSync(path, { encoding: "utf8", flag: "r" }).slice(0, 256 * 1024);
272
+ for (const line of text.split("\n").slice(0, 16)) {
273
+ try {
274
+ cwd = sessionCwd(JSON.parse(line)) ?? cwd;
275
+ } catch {
276
+ // A partial first line says nothing; the next one may.
277
+ }
278
+ if (cwd) break;
279
+ }
280
+ } catch {
281
+ cwd = null;
282
+ }
283
+ cache.heads.set(path, cwd);
284
+ return cwd;
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Disk walking
289
+
290
+ const mtimeOf = (p) => {
291
+ try {
292
+ return statSync(p).mtimeMs;
293
+ } catch {
294
+ return null;
295
+ }
296
+ };
297
+
298
+ const listDir = (dir) => {
299
+ try {
300
+ return readdirSync(dir);
301
+ } catch {
302
+ return [];
303
+ }
304
+ };
305
+
306
+ /** Claude Code session files changed since `since`, plus the ones runs name. */
307
+ function claudeSessions(projectsRoot, since, wanted) {
308
+ const out = new Map();
309
+ for (const slug of listDir(projectsRoot)) {
310
+ const dir = join(projectsRoot, slug);
311
+ for (const f of listDir(dir)) {
312
+ if (!f.endsWith(".jsonl")) continue;
313
+ const sessionId = f.slice(0, -6);
314
+ if (!SAFE_SESSION_ID.test(sessionId)) continue;
315
+ const path = join(dir, f);
316
+ const m = mtimeOf(path);
317
+ if (m === null) continue;
318
+ if (m >= since || wanted.has(sessionId)) out.set(sessionId, { path, dir, mtime: m });
319
+ }
320
+ }
321
+ return out;
322
+ }
323
+
324
+ function subagentsOf(sessionDir, sessionId, since) {
325
+ const dir = join(sessionDir, sessionId, "subagents");
326
+ const out = [];
327
+ for (const f of listDir(dir)) {
328
+ const m = /^agent-([A-Za-z0-9_-]{1,64})\.jsonl$/.exec(f);
329
+ if (!m) continue;
330
+ const path = join(dir, f);
331
+ const mtime = mtimeOf(path);
332
+ if (mtime === null || mtime < since) continue;
333
+ const meta = readRegularJson(join(dir, `agent-${m[1]}.meta.json`), null) ?? {};
334
+ out.push({ agentId: m[1], path, meta });
335
+ }
336
+ return out.sort((a, b) => (a.agentId < b.agentId ? -1 : 1));
337
+ }
338
+
339
+ function copilotSessions(root, since) {
340
+ const out = [];
341
+ for (const d of listDir(root)) {
342
+ const path = join(root, d, "events.jsonl");
343
+ const m = mtimeOf(path);
344
+ if (m !== null && m >= since && SAFE_SESSION_ID.test(d))
345
+ out.push({ path, sessionId: d, host: "copilot" });
346
+ }
347
+ return out;
348
+ }
349
+
350
+ function codexSessions(root, since, depth = 0) {
351
+ const out = [];
352
+ for (const name of listDir(root)) {
353
+ const p = join(root, name);
354
+ if (depth < 3 && !name.endsWith(".jsonl")) {
355
+ out.push(...codexSessions(p, since, depth + 1));
356
+ continue;
357
+ }
358
+ const m = /^rollout-.*-([0-9a-f-]{36})\.jsonl$/i.exec(name);
359
+ const mt = m ? mtimeOf(p) : null;
360
+ if (m && mt !== null && mt >= since) out.push({ path: p, sessionId: m[1], host: "codex" });
361
+ }
362
+ return out;
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Runs
367
+
368
+ const samePath = (p) => String(p ?? "").replace(/\/+$/, "");
369
+ const inside = (path, root) =>
370
+ Boolean(root) &&
371
+ (samePath(path) === samePath(root) || samePath(path).startsWith(samePath(root) + "/"));
372
+
373
+ /** The repository a working directory belongs to: the checkout, not the worktree. */
374
+ export function repoName(path) {
375
+ if (typeof path !== "string" || !path) return null;
376
+ const root = path.replace(/\/\.(?:claude\/)?worktrees\/.*$/, "").replace(/\/+$/, "");
377
+ return basename(root) || null;
378
+ }
379
+
380
+ /** The fields of agent-state.json the index does not carry. */
381
+ export function runExtras(run) {
382
+ if (!run.dir) return {};
383
+ for (const p of [
384
+ join(run.dir, "agent-state.json"),
385
+ join(run.dir, "artifacts", "agent-state.json"),
386
+ ]) {
387
+ if (!existsSync(p)) continue;
388
+ const doc = readRegularJson(p, null);
389
+ if (doc && typeof doc === "object") {
390
+ return {
391
+ projectRoot: typeof doc.projectRoot === "string" ? doc.projectRoot : null,
392
+ projects: Array.isArray(doc.projects) ? doc.projects : [],
393
+ };
394
+ }
395
+ }
396
+ return {};
397
+ }
398
+
399
+ /**
400
+ * The run a session belongs to: the one that recorded its id, else the one
401
+ * whose worktree holds its working directory, or whose checkout and branch it
402
+ * shares. More than one such run is `ambiguous`.
403
+ *
404
+ * @returns {{run: object|null, matchedBy: "session"|"cwd-branch"|null, ambiguous: boolean}}
405
+ */
406
+ export function matchRun(sessionId, cwd, branch, runs) {
407
+ const byId = runs.find((r) => r.sessionId && r.sessionId === sessionId);
408
+ if (byId) return { run: byId, matchedBy: "session", ambiguous: false };
409
+ if (!cwd) return { run: null, matchedBy: null, ambiguous: false };
410
+ const hits = runs.filter((r) => {
411
+ const roots = [{ wt: r.worktreePath, root: r.extras?.projectRoot, branch: r.branch }];
412
+ for (const p of r.extras?.projects ?? []) {
413
+ roots.push({ wt: p?.worktreePath, root: p?.projectRoot, branch: p?.branch ?? r.branch });
414
+ }
415
+ return roots.some(
416
+ (x) =>
417
+ (x.wt && inside(cwd, x.wt)) ||
418
+ (x.root && samePath(cwd) === samePath(x.root) && branch && x.branch === branch),
419
+ );
420
+ });
421
+ if (hits.length === 1) return { run: hits[0], matchedBy: "cwd-branch", ambiguous: false };
422
+ return { run: null, matchedBy: null, ambiguous: hits.length > 1 };
423
+ }
424
+
425
+ /** The phase a run was in at time `t`: the latest one started before it. */
426
+ export function phaseAt(run, t) {
427
+ if (!run) return null;
428
+ if (t !== null) {
429
+ let best = null;
430
+ for (const p of run.phases ?? []) {
431
+ const s = Date.parse(p.startedAt ?? "");
432
+ const e = Date.parse(p.completedAt ?? "");
433
+ if (!Number.isFinite(s) || s > t) continue;
434
+ if (Number.isFinite(e) && t >= e) continue;
435
+ if (!best || s > best.s) best = { s, id: p.id };
436
+ }
437
+ if (best) return Number(best.id);
438
+ }
439
+ return Number.isFinite(run.currentPhase) ? run.currentPhase : null;
440
+ }
441
+
442
+ const iso = (ms) => (ms === null || ms === undefined ? null : new Date(ms).toISOString());
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // The snapshot
446
+
447
+ /**
448
+ * The office document.
449
+ *
450
+ * @param {{
451
+ * runs?: object[], projectsRoot?: string, copilotRoot?: string, codexRoot?: string,
452
+ * sessions?: object[]|undefined|null, now?: number, recentMs?: number,
453
+ * cache?: ReturnType<typeof createActivityCache>
454
+ * }} [opts] `sessions` is the `claude agents --json` list; null reads it,
455
+ * undefined leaves liveness unknown.
456
+ */
457
+ export async function activitySnapshot(opts = {}) {
458
+ const now = opts.now ?? Date.now();
459
+ const recentMs = opts.recentMs ?? RECENT_MS;
460
+ const since = now - recentMs;
461
+ const cache = opts.cache ?? defaultCache;
462
+ const sessions = opts.sessions === null ? listSessions() : opts.sessions;
463
+ const R = createRedactor();
464
+ const clean = (v, limit = 160) =>
465
+ v === null || v === undefined || v === "" ? null : R.excerpt(v, limit);
466
+
467
+ const allRuns = opts.runs ?? buildIndex();
468
+ const runs = allRuns
469
+ .filter((r) => !TERMINAL.has(r.status))
470
+ .map((r) => ({ ...r, extras: runExtras(r) }));
471
+ const wanted = new Set(runs.map((r) => r.sessionId).filter(Boolean));
472
+
473
+ for (const e of cache.states.values()) e.touched = false;
474
+
475
+ const teams = new Map();
476
+ const teamForRun = (run) => {
477
+ const id = `run:${run.taskId}`;
478
+ if (!teams.has(id)) teams.set(id, { id, run, seats: [], sessions: [], hosts: new Set() });
479
+ return teams.get(id);
480
+ };
481
+ const liveOf = (sessionId) => {
482
+ if (sessions === undefined) return null;
483
+ return sessionLiveness(sessionId, sessions ?? undefined);
484
+ };
485
+
486
+ const addSession = ({ sessionId, path, dir, host, cwd: knownCwd }) => {
487
+ const main = stateOf(cache, path);
488
+ const s = main.state;
489
+ const cwd = knownCwd ?? s.cwd;
490
+ const hit = matchRun(sessionId, cwd, s.gitBranch, runs);
491
+ let team;
492
+ if (hit.run) {
493
+ team = teamForRun(hit.run);
494
+ team.matchedBy ??= hit.matchedBy;
495
+ } else {
496
+ const id = `session:${sessionId}`;
497
+ team = {
498
+ id,
499
+ run: null,
500
+ kind: hit.ambiguous ? "unmatched" : "session",
501
+ seats: [],
502
+ sessions: [],
503
+ hosts: new Set(),
504
+ };
505
+ teams.set(id, team);
506
+ }
507
+ team.sessions.push({ sessionId, cwd, branch: s.gitBranch, title: s.title, lastAt: s.lastAt });
508
+ team.hosts.add(host);
509
+ const live = liveOf(sessionId);
510
+ team.seats.push(
511
+ seat({ sessionId, agentId: null, agentType: null, s, finished: false, live, phase: null }),
512
+ );
513
+ if (host !== "claude" || !dir) return;
514
+ for (const sub of subagentsOf(dir, sessionId, since)) {
515
+ const st = stateOf(cache, sub.path).state;
516
+ const agentType = typeof sub.meta.agentType === "string" ? sub.meta.agentType : null;
517
+ const finished = sub.meta.finished === true;
518
+ team.seats.push(
519
+ seat({
520
+ sessionId,
521
+ agentId: sub.agentId,
522
+ agentType,
523
+ description: sub.meta.description,
524
+ s: st,
525
+ finished,
526
+ live: finished ? false : live,
527
+ phase: phaseAt(hit.run, st.firstAt),
528
+ }),
529
+ );
530
+ }
531
+ };
532
+
533
+ const seat = ({ sessionId, agentId, agentType, description, s, finished, live, phase }) => {
534
+ const { role, known } = roleFor(agentType ?? (agentId ? "unknown" : null), phase);
535
+ const style = styleFor(role);
536
+ const act = deskActivity(s, finished);
537
+ const detail = clean(act.detail, 80);
538
+ return {
539
+ id: `${sessionId}:${agentId ?? "main"}`,
540
+ sessionId,
541
+ agentId,
542
+ agentType,
543
+ role,
544
+ label: role,
545
+ known,
546
+ phase: agentId === null ? null : phase,
547
+ sprite: style.sprite,
548
+ color: style.color,
549
+ model: s.model,
550
+ modelLabel: modelLabel(s.model),
551
+ description: clean(description, DESCRIPTION_LIMIT),
552
+ state: act.state,
553
+ status: act.state === "done" ? "done" : act.state === "idle" ? "idle" : "working",
554
+ tool: act.tool,
555
+ detail,
556
+ doing: doingText(act.state, act.tool, detail),
557
+ live,
558
+ startedAt: iso(s.firstAt),
559
+ lastAt: iso(s.lastAt),
560
+ };
561
+ };
562
+
563
+ const projectsRoot = opts.projectsRoot ?? defaultProjectsRoot();
564
+ for (const [sessionId, f] of claudeSessions(projectsRoot, since, wanted)) {
565
+ addSession({ sessionId, path: f.path, dir: f.dir, host: "claude" });
566
+ }
567
+ for (const f of [
568
+ ...copilotSessions(opts.copilotRoot ?? defaultCopilotRoot(), since),
569
+ ...codexSessions(opts.codexRoot ?? defaultCodexRoot(), since),
570
+ ]) {
571
+ addSession({ ...f, dir: null, cwd: hostHead(cache, f.path) });
572
+ }
573
+
574
+ // A run waiting on you, or whose session is alive, has a table even when no
575
+ // transcript of it changed in the window.
576
+ for (const run of runs) {
577
+ const waiting =
578
+ Boolean(run.pendingQuestion) || run.status === "awaiting_input" || Boolean(run.waitingFor);
579
+ if (waiting || run.processAlive === true) teamForRun(run);
580
+ }
581
+
582
+ for (const [path, e] of cache.states) {
583
+ if (!e.touched) {
584
+ cache.states.delete(path);
585
+ cache.follower.forget(path);
586
+ cache.heads.delete(path);
587
+ }
588
+ }
589
+
590
+ const rooms = new Map();
591
+ const corridors = [];
592
+ for (const team of teams.values()) {
593
+ const table = renderTable(team, clean, now);
594
+ if (!rooms.has(table.repo)) rooms.set(table.repo, []);
595
+ rooms.get(table.repo).push(table);
596
+ if (table.repos.length > 1) corridors.push({ tableId: table.id, repos: table.repos });
597
+ }
598
+ const doc = {
599
+ contractVersion: CONTRACT_VERSION,
600
+ generatedAt: new Date(now).toISOString(),
601
+ recentMinutes: recentMs / 60_000,
602
+ rooms: [...rooms.entries()]
603
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
604
+ .map(([repo, list]) => ({ repo, tables: list.sort(tableOrder) })),
605
+ corridors,
606
+ counts: { rooms: 0, tables: 0, seats: 0, live: 0, waiting: 0 },
607
+ };
608
+ for (const room of doc.rooms) {
609
+ doc.counts.rooms++;
610
+ for (const t of room.tables) {
611
+ doc.counts.tables++;
612
+ if (t.waitingForYou) doc.counts.waiting++;
613
+ for (const s of t.seats) {
614
+ doc.counts.seats++;
615
+ if (s.live === true && s.state !== "done") doc.counts.live++;
616
+ }
617
+ }
618
+ }
619
+ assertClean(JSON.stringify(doc));
620
+ return doc;
621
+ }
622
+
623
+ /** What a seat is doing, in a few plain words; the raw tool name when there is no phrase. */
624
+ export function doingText(state, tool, detail) {
625
+ if (state === "done") return "Done";
626
+ if (!tool || state === "idle") return state === "idle" ? "Idle" : "Working";
627
+ const on = detail ? ` ${detail}` : "";
628
+ const phrase = TOOL_PHRASES[tool];
629
+ if (phrase) return `${phrase}${on}`;
630
+ if (tool.startsWith("mcp__")) {
631
+ const [, server, name] = tool.split("__");
632
+ return `Using ${name ?? tool} (${server})`;
633
+ }
634
+ return `${tool}${on}`;
635
+ }
636
+
637
+ const TOOL_PHRASES = {
638
+ Read: "Reading",
639
+ Edit: "Editing",
640
+ MultiEdit: "Editing",
641
+ Write: "Writing",
642
+ NotebookEdit: "Editing",
643
+ Grep: "Searching the code",
644
+ Glob: "Listing files",
645
+ LS: "Listing files",
646
+ Bash: "Running a command",
647
+ WebFetch: "Reading a web page",
648
+ WebSearch: "Searching the web",
649
+ Agent: "Spawning",
650
+ Task: "Spawning",
651
+ AskUserQuestion: "Asking you",
652
+ TodoWrite: "Updating the task list",
653
+ TaskCreate: "Updating the task list",
654
+ TaskUpdate: "Updating the task list",
655
+ Skill: "Running a skill",
656
+ ToolSearch: "Looking up a tool",
657
+ };
658
+
659
+ const tableOrder = (a, b) => {
660
+ const ka = a.kind === "run" ? `0${a.taskId}` : `1${a.seats[0]?.lastAt ?? ""}`;
661
+ const kb = b.kind === "run" ? `0${b.taskId}` : `1${b.seats[0]?.lastAt ?? ""}`;
662
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
663
+ };
664
+
665
+ /** Number the seats of a role that appears more than once, in spawn order. */
666
+ function numberParallel(seats) {
667
+ const byRole = new Map();
668
+ for (const d of seats) {
669
+ if (d.agentId === null) continue;
670
+ if (!byRole.has(d.role)) byRole.set(d.role, []);
671
+ byRole.get(d.role).push(d);
672
+ }
673
+ for (const group of byRole.values()) {
674
+ if (group.length < 2) continue;
675
+ group.sort(bySpawn).forEach((d, i) => (d.label = `${d.role} ${i + 1}`));
676
+ }
677
+ }
678
+
679
+ const bySpawn = (a, b) =>
680
+ (a.startedAt ?? "") < (b.startedAt ?? "")
681
+ ? -1
682
+ : (a.startedAt ?? "") > (b.startedAt ?? "")
683
+ ? 1
684
+ : (a.agentId ?? "") < (b.agentId ?? "")
685
+ ? -1
686
+ : 1;
687
+
688
+ /**
689
+ * Orchestrator at the head, then the seats in the order of the phase each
690
+ * was spawned in, parallel agents of one phase side by side in spawn order.
691
+ */
692
+ function seatOrder(a, b) {
693
+ if (a.agentId === null && b.agentId !== null) return -1;
694
+ if (b.agentId === null && a.agentId !== null) return 1;
695
+ const pa = a.phase ?? Infinity;
696
+ const pb = b.phase ?? Infinity;
697
+ return pa !== pb ? pa - pb : bySpawn(a, b);
698
+ }
699
+
700
+ /**
701
+ * Who holds the work now: the latest-started seat still working, else the
702
+ * orchestrator. `previousHolder` is the latest seat of an earlier phase, so a
703
+ * client can draw the handoff from it.
704
+ */
705
+ function holders(seats, phaseId) {
706
+ const working = seats.filter((s) => s.agentId !== null && s.status === "working");
707
+ const holder = working.sort(bySpawn).at(-1) ?? seats.find((s) => s.agentId === null) ?? null;
708
+ const earlier = seats
709
+ .filter((s) => s.agentId !== null && s.phase !== null && phaseId !== null && s.phase < phaseId)
710
+ .sort(bySpawn)
711
+ .at(-1);
712
+ return { holder: holder?.id ?? null, previousHolder: earlier?.id ?? null };
713
+ }
714
+
715
+ const LAST_PHASE = Math.max(...Object.keys(PHASE_NAMES).map(Number));
716
+
717
+ function renderTable(team, clean, now) {
718
+ const run = team.run;
719
+ const seats = team.seats.sort(seatOrder);
720
+ numberParallel(seats);
721
+ const hosts = [...team.hosts];
722
+ const host = hosts.find((h) => h !== "claude") ?? "claude";
723
+ const first = team.sessions[0] ?? null;
724
+ if (!run) {
725
+ const repo = repoName(first?.cwd) ?? "unknown";
726
+ return {
727
+ id: team.id,
728
+ kind: team.kind,
729
+ taskId: null,
730
+ title: clean(first?.title, 120),
731
+ host,
732
+ limited: host !== "claude",
733
+ sessionId: first?.sessionId ?? null,
734
+ matchedBy: null,
735
+ repo,
736
+ repos: [repo],
737
+ location: first?.cwd
738
+ ? /\/\.(?:claude\/)?worktrees\//.test(first.cwd)
739
+ ? "worktree"
740
+ : "local"
741
+ : "unknown",
742
+ branch: clean(first?.branch, 120),
743
+ phase: null,
744
+ phaseHistory: [],
745
+ startedAt: seats[0]?.startedAt ?? null,
746
+ elapsedSeconds: null,
747
+ now: null,
748
+ status: null,
749
+ waitingForYou: false,
750
+ pendingQuestion: null,
751
+ tokens: null,
752
+ estUsd: null,
753
+ costSource: "not-measured",
754
+ ...holders(seats, null),
755
+ seats,
756
+ };
757
+ }
758
+ const repo =
759
+ run.project ?? repoName(run.extras?.projectRoot) ?? repoName(first?.cwd) ?? "unknown";
760
+ const repos = [repo];
761
+ for (const p of run.extras?.projects ?? []) {
762
+ const name = repoName(p?.projectRoot ?? p?.worktreePath);
763
+ if (name && !repos.includes(name)) repos.push(name);
764
+ }
765
+ const phaseId = Number.isFinite(run.currentPhase) ? run.currentPhase : null;
766
+ const phaseRow =
767
+ (run.phases ?? []).find((p) => p.status === "in_progress") ??
768
+ (phaseId === null ? null : (run.phases ?? []).find((p) => p.id === String(phaseId)));
769
+ const shownPhase = phaseRow ? Number(phaseRow.id) : phaseId;
770
+ const measured = run.tokens && run.tokens.in + run.tokens.out + run.tokens.cached > 0;
771
+ const q = run.pendingQuestion;
772
+ const started = Date.parse(run.startedAt ?? "");
773
+ const location = run.worktreePath ? "worktree" : run.extras?.projectRoot ? "local" : "unknown";
774
+ return {
775
+ id: team.id,
776
+ kind: "run",
777
+ taskId: run.taskId,
778
+ title: clean(run.title, 120),
779
+ host,
780
+ limited: host !== "claude",
781
+ sessionId: run.sessionId ?? first?.sessionId ?? null,
782
+ matchedBy: team.matchedBy ?? null,
783
+ repo,
784
+ repos,
785
+ location,
786
+ branch: clean(run.branch, 120),
787
+ phase:
788
+ shownPhase === null || !Number.isFinite(shownPhase)
789
+ ? null
790
+ : {
791
+ id: shownPhase,
792
+ name: phaseRow?.name || PHASE_NAMES[String(shownPhase)] || String(shownPhase),
793
+ last: LAST_PHASE,
794
+ },
795
+ phaseHistory: (run.phases ?? []).map((p) => ({
796
+ id: Number.isFinite(Number(p.id)) ? Number(p.id) : null,
797
+ name: clean(p.name, 60) ?? "",
798
+ status: String(p.status ?? "pending"),
799
+ startedAt: p.startedAt ?? null,
800
+ completedAt: p.completedAt ?? null,
801
+ })),
802
+ startedAt: Number.isFinite(started) ? new Date(started).toISOString() : null,
803
+ elapsedSeconds: Number.isFinite(started)
804
+ ? Math.max(0, Math.round((now - started) / 1000))
805
+ : null,
806
+ now: clean(phaseRow?.now, 160),
807
+ status: run.status ?? null,
808
+ waitingForYou: Boolean(q) || run.status === "awaiting_input" || Boolean(run.waitingFor),
809
+ pendingQuestion: q
810
+ ? {
811
+ id: q.id,
812
+ text: clean(q.text, 400) ?? "",
813
+ kind: q.kind === "multi-select" ? "multi-select" : "single-select",
814
+ options: q.options.map((o) => ({ id: o.id, label: clean(o.label, 120) ?? "" })),
815
+ }
816
+ : null,
817
+ tokens: measured ? { ...run.tokens } : null,
818
+ estUsd: measured ? run.estUsd : null,
819
+ costSource: measured ? "tracker-state" : "not-measured",
820
+ ...holders(seats, shownPhase),
821
+ seats,
822
+ };
823
+ }
824
+
825
+ // ---------------------------------------------------------------------------
826
+ // Chat
827
+
828
+ /** The transcript of one desk on disk, or null. `agentId` null is the session itself. */
829
+ export function transcriptPath(projectsRoot, sessionId, agentId) {
830
+ if (typeof sessionId !== "string" || !SAFE_SESSION_ID.test(sessionId)) return null;
831
+ if (agentId !== null && (typeof agentId !== "string" || !SAFE_AGENT_ID.test(agentId)))
832
+ return null;
833
+ for (const slug of listDir(projectsRoot)) {
834
+ const dir = join(projectsRoot, slug);
835
+ const p = agentId
836
+ ? join(dir, sessionId, "subagents", `agent-${agentId}.jsonl`)
837
+ : join(dir, `${sessionId}.jsonl`);
838
+ if (existsSync(p)) return p;
839
+ }
840
+ return null;
841
+ }
842
+
843
+ const MAX_TEXT = 20_000;
844
+ const MAX_RESULT = 2_000;
845
+ const WITHHELD = "[withheld: the outbound gate still found a credential here]";
846
+
847
+ const contentText = (c) => {
848
+ if (typeof c === "string") return c;
849
+ if (!Array.isArray(c)) return "";
850
+ return c
851
+ .map((b) =>
852
+ typeof b === "string"
853
+ ? b
854
+ : b?.type === "text"
855
+ ? (b.text ?? "")
856
+ : b?.type
857
+ ? `[${b.type}]`
858
+ : "",
859
+ )
860
+ .filter(Boolean)
861
+ .join("\n");
862
+ };
863
+
864
+ function toolSummary(input) {
865
+ if (!input || typeof input !== "object") return "";
866
+ for (const k of [
867
+ "command",
868
+ "file_path",
869
+ "path",
870
+ "pattern",
871
+ "url",
872
+ "query",
873
+ "description",
874
+ "skill",
875
+ ]) {
876
+ if (typeof input[k] === "string" && input[k]) return input[k];
877
+ }
878
+ return JSON.stringify(input);
879
+ }
880
+
881
+ /**
882
+ * One page of a desk's transcript, newest page first, messages oldest first
883
+ * within it.
884
+ *
885
+ * @param {{projectsRoot?: string, sessionId: string, agentId: string|null,
886
+ * before?: number|null, limit?: number}} opts
887
+ * @returns {Promise<object|null>} null when the transcript is not on disk
888
+ */
889
+ export async function agentChat({
890
+ projectsRoot = defaultProjectsRoot(),
891
+ sessionId,
892
+ agentId = null,
893
+ before = null,
894
+ limit = 200,
895
+ }) {
896
+ const path = transcriptPath(projectsRoot, sessionId, agentId);
897
+ if (!path) return null;
898
+ const page = readTailPage(path, { before, limit, allow: CHAT_TYPES });
899
+ const R = createRedactor();
900
+ const guard = (text) => (scan(text).some((f) => f.certain) ? WITHHELD : text);
901
+ const text = (v, limit = MAX_TEXT) =>
902
+ guard(R.clean(String(v ?? "").slice(0, limit * 2)).slice(0, limit));
903
+ const messages = [];
904
+ for (const { offset, record } of page.records) {
905
+ if (record.isMeta) continue;
906
+ const c = record.message?.content;
907
+ const blocks = [];
908
+ if (typeof c === "string") {
909
+ if (c.trim()) blocks.push({ kind: "text", text: text(c) });
910
+ } else if (Array.isArray(c)) {
911
+ for (const b of c) {
912
+ if (b?.type === "text" && b.text?.trim()) blocks.push({ kind: "text", text: text(b.text) });
913
+ else if (b?.type === "tool_use") {
914
+ blocks.push({
915
+ kind: "tool",
916
+ name: String(b.name ?? "tool"),
917
+ summary: guard(R.excerpt(toolSummary(b.input), 240)),
918
+ toolUseId: typeof b.id === "string" ? b.id : null,
919
+ });
920
+ } else if (b?.type === "tool_result") {
921
+ blocks.push({
922
+ kind: "result",
923
+ toolUseId: typeof b.tool_use_id === "string" ? b.tool_use_id : null,
924
+ text: text(contentText(b.content), MAX_RESULT),
925
+ isError: b.is_error === true,
926
+ });
927
+ } else if (b?.type === "image") blocks.push({ kind: "text", text: "[image]" });
928
+ }
929
+ }
930
+ if (!blocks.length) continue;
931
+ messages.push({
932
+ offset,
933
+ role: record.type === "assistant" ? "assistant" : "user",
934
+ at: typeof record.timestamp === "string" ? record.timestamp : null,
935
+ blocks,
936
+ });
937
+ }
938
+ const out = {
939
+ contractVersion: CONTRACT_VERSION,
940
+ sessionId,
941
+ agentId,
942
+ messages,
943
+ before: page.before,
944
+ redacted: R.total(),
945
+ };
946
+ assertClean(JSON.stringify(out));
947
+ return out;
948
+ }
949
+
950
+ // ---------------------------------------------------------------------------
951
+ // CLI
952
+
953
+ function renderHuman(doc) {
954
+ const L = [];
955
+ for (const room of doc.rooms) {
956
+ L.push(room.repo);
957
+ for (const t of room.tables) {
958
+ const head = t.taskId ?? t.sessionId ?? t.id;
959
+ const phase = t.phase ? ` Phase ${t.phase.id}/${t.phase.last} ${t.phase.name}` : "";
960
+ L.push(` ${head}${phase}${t.waitingForYou ? " (waiting for you)" : ""}`);
961
+ for (const s of t.seats) {
962
+ L.push(` ${s.label}${s.modelLabel ? ` - ${s.modelLabel}` : ""}: ${s.doing}`);
963
+ }
964
+ }
965
+ }
966
+ if (!L.length) L.push("No agent activity in the recent window.");
967
+ return L.join("\n") + "\n";
968
+ }
969
+
970
+ function intArg(v, name) {
971
+ if (!/^[0-9]{1,12}$/.test(String(v ?? ""))) throw new Error(`${name} must be a whole number`);
972
+ return Number(v);
973
+ }
974
+
975
+ async function main(argv) {
976
+ if (argv[0] === "chat") {
977
+ const [, sessionId, maybeAgent, ...rest] = argv;
978
+ let agentId = null;
979
+ const flags = [...rest];
980
+ if (maybeAgent && !maybeAgent.startsWith("--")) agentId = maybeAgent;
981
+ else if (maybeAgent) flags.unshift(maybeAgent);
982
+ let before = null;
983
+ let limit = 200;
984
+ for (let i = 0; i < flags.length; i++) {
985
+ if (flags[i] === "--before") before = intArg(flags[++i], "--before");
986
+ else if (flags[i] === "--limit") limit = intArg(flags[++i], "--limit");
987
+ else throw new Error(`unknown argument ${flags[i]}`);
988
+ }
989
+ const page = await agentChat({ sessionId, agentId, before, limit });
990
+ if (!page) {
991
+ process.stderr.write("activity-snapshot: no such session or agent\n");
992
+ return 3;
993
+ }
994
+ process.stdout.write(JSON.stringify(page, null, 2) + "\n");
995
+ return 0;
996
+ }
997
+ let json = false;
998
+ let recentMs = RECENT_MS;
999
+ for (let i = 0; i < argv.length; i++) {
1000
+ if (argv[i] === "--json") json = true;
1001
+ else if (argv[i] === "--recent-minutes")
1002
+ recentMs = intArg(argv[++i], "--recent-minutes") * 60_000;
1003
+ else throw new Error(`unknown argument ${argv[i]}`);
1004
+ }
1005
+ const doc = await activitySnapshot({ recentMs, sessions: null });
1006
+ process.stdout.write(json ? JSON.stringify(doc, null, 2) + "\n" : renderHuman(doc));
1007
+ return 0;
1008
+ }
1009
+
1010
+ if (invokedDirectly(import.meta.url)) {
1011
+ runMain(
1012
+ "activity-snapshot",
1013
+ async () => {
1014
+ try {
1015
+ process.exitCode = await main(process.argv.slice(2));
1016
+ } catch (err) {
1017
+ if (err?.code === "REDACTION") {
1018
+ process.stderr.write(`activity-snapshot: ${err.message}; nothing was printed\n`);
1019
+ process.exitCode = 4;
1020
+ return;
1021
+ }
1022
+ throw err;
1023
+ }
1024
+ },
1025
+ { code: 2 },
1026
+ );
1027
+ }