@mmerterden/multi-agent-pipeline 20.29.0 → 20.33.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 (227) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +50 -5
  3. package/README.tr.md +64 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +9 -9
  7. package/install/_unattended-profile.mjs +61 -0
  8. package/install/catalog-history.json +1 -1
  9. package/install/claude.mjs +19 -3
  10. package/install/codex.mjs +2 -0
  11. package/install/copilot.mjs +13 -0
  12. package/install/index.mjs +10 -1
  13. package/install/templates/claude-hooks.json +12 -1
  14. package/manifest.json +230 -88
  15. package/package.json +3 -1
  16. package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
  17. package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
  18. package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
  19. package/pipeline/commands/multi-agent/office-on/SKILL.md +51 -0
  20. package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
  21. package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
  22. package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
  23. package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
  24. package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
  25. package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
  26. package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
  27. package/pipeline/commands/multi-agent/sync/SKILL.md +15 -3
  28. package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
  29. package/pipeline/contract/CHANGELOG.md +108 -0
  30. package/pipeline/contract/README.md +72 -12
  31. package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
  32. package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
  33. package/pipeline/contract/fixtures/error-unsigned.json +1 -1
  34. package/pipeline/contract/fixtures/launch-plan.json +1 -1
  35. package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
  36. package/pipeline/contract/fixtures/runs-empty.json +1 -1
  37. package/pipeline/contract/fixtures/runs-failed.json +6 -1
  38. package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
  39. package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
  40. package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
  41. package/pipeline/contract/fixtures/runs-running.json +6 -1
  42. package/pipeline/contract/manifest.json +506 -27
  43. package/pipeline/contract/types/index.d.ts +829 -4
  44. package/pipeline/lib/bg-launch.mjs +106 -0
  45. package/pipeline/lib/code-digest.mjs +49 -0
  46. package/pipeline/lib/credential-resolve.mjs +42 -2
  47. package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
  48. package/pipeline/multi-agent-refs/features/office-threat-model.md +109 -0
  49. package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
  50. package/pipeline/multi-agent-refs/help/en.md +3 -0
  51. package/pipeline/multi-agent-refs/help/tr.md +3 -0
  52. package/pipeline/multi-agent-refs/phases/modes.md +7 -7
  53. package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
  54. package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
  55. package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
  56. package/pipeline/office/NOTICE.md +45 -0
  57. package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
  58. package/pipeline/office/assets/BIN.png +0 -0
  59. package/pipeline/office/assets/BOOKSHELF.png +0 -0
  60. package/pipeline/office/assets/CACTUS.png +0 -0
  61. package/pipeline/office/assets/CLOCK.png +0 -0
  62. package/pipeline/office/assets/COFFEE.png +0 -0
  63. package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
  64. package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
  65. package/pipeline/office/assets/COUNTER_SINK.png +0 -0
  66. package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
  67. package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
  68. package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
  69. package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
  70. package/pipeline/office/assets/DESK_FRONT.png +0 -0
  71. package/pipeline/office/assets/DESK_SIDE.png +0 -0
  72. package/pipeline/office/assets/DEVICE_STAND.png +0 -0
  73. package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
  74. package/pipeline/office/assets/FILING_CABINET.png +0 -0
  75. package/pipeline/office/assets/FRIDGE.png +0 -0
  76. package/pipeline/office/assets/HANGING_PLANT.png +0 -0
  77. package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
  78. package/pipeline/office/assets/LARGE_PLANT.png +0 -0
  79. package/pipeline/office/assets/MAILBOX.png +0 -0
  80. package/pipeline/office/assets/MOP_BUCKET.png +0 -0
  81. package/pipeline/office/assets/PC_BACK.png +0 -0
  82. package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
  83. package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
  84. package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
  85. package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
  86. package/pipeline/office/assets/PC_SIDE.png +0 -0
  87. package/pipeline/office/assets/PLANT.png +0 -0
  88. package/pipeline/office/assets/PLANT_2.png +0 -0
  89. package/pipeline/office/assets/POT.png +0 -0
  90. package/pipeline/office/assets/PRINTER.png +0 -0
  91. package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
  92. package/pipeline/office/assets/SERVER_RACK.png +0 -0
  93. package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
  94. package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
  95. package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
  96. package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
  97. package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
  98. package/pipeline/office/assets/SOFA_BACK.png +0 -0
  99. package/pipeline/office/assets/SOFA_FRONT.png +0 -0
  100. package/pipeline/office/assets/SOFA_SIDE.png +0 -0
  101. package/pipeline/office/assets/TABLE_FRONT.png +0 -0
  102. package/pipeline/office/assets/TOILET_STALL.png +0 -0
  103. package/pipeline/office/assets/VENDING.png +0 -0
  104. package/pipeline/office/assets/WASHBASIN.png +0 -0
  105. package/pipeline/office/assets/WATER_COOLER.png +0 -0
  106. package/pipeline/office/assets/WHITEBOARD.png +0 -0
  107. package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
  108. package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
  109. package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
  110. package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
  111. package/pipeline/office/assets/char_0.png +0 -0
  112. package/pipeline/office/assets/char_1.png +0 -0
  113. package/pipeline/office/assets/char_2.png +0 -0
  114. package/pipeline/office/assets/char_3.png +0 -0
  115. package/pipeline/office/assets/char_4.png +0 -0
  116. package/pipeline/office/assets/char_5.png +0 -0
  117. package/pipeline/office/assets/floor_0.png +0 -0
  118. package/pipeline/office/assets/floor_1.png +0 -0
  119. package/pipeline/office/assets/floor_2.png +0 -0
  120. package/pipeline/office/assets/floor_3.png +0 -0
  121. package/pipeline/office/assets/floor_4.png +0 -0
  122. package/pipeline/office/assets/floor_5.png +0 -0
  123. package/pipeline/office/assets/floor_6.png +0 -0
  124. package/pipeline/office/assets/floor_7.png +0 -0
  125. package/pipeline/office/assets/floor_8.png +0 -0
  126. package/pipeline/office/assets/wall_0.png +0 -0
  127. package/pipeline/office/index.html +8431 -0
  128. package/pipeline/office/toolkit.json +3 -0
  129. package/pipeline/schemas/activity-chat.schema.json +78 -0
  130. package/pipeline/schemas/activity-roles.json +48 -0
  131. package/pipeline/schemas/activity-roles.schema.json +49 -0
  132. package/pipeline/schemas/activity.schema.json +613 -0
  133. package/pipeline/schemas/agent-state.schema.json +493 -115
  134. package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
  135. package/pipeline/schemas/autopilot-on.schema.json +111 -0
  136. package/pipeline/schemas/contract-error.schema.json +45 -4
  137. package/pipeline/schemas/issue-maturity.schema.json +68 -0
  138. package/pipeline/schemas/job-change.schema.json +72 -0
  139. package/pipeline/schemas/job-request.schema.json +38 -0
  140. package/pipeline/schemas/launch.json +4 -4
  141. package/pipeline/schemas/ledger.schema.json +202 -0
  142. package/pipeline/schemas/office-credentials.schema.json +71 -0
  143. package/pipeline/schemas/office-jobs.json +253 -0
  144. package/pipeline/schemas/office-jobs.schema.json +62 -0
  145. package/pipeline/schemas/office-memory.schema.json +105 -0
  146. package/pipeline/schemas/office-settings-request.schema.json +30 -0
  147. package/pipeline/schemas/office-settings.schema.json +67 -0
  148. package/pipeline/schemas/permissions.schema.json +47 -0
  149. package/pipeline/schemas/phases.json +10 -2
  150. package/pipeline/schemas/phases.schema.json +86 -19
  151. package/pipeline/schemas/prefs.schema.json +19 -0
  152. package/pipeline/schemas/prs.schema.json +78 -0
  153. package/pipeline/schemas/prune-logs-request.schema.json +26 -0
  154. package/pipeline/schemas/prune-logs.schema.json +52 -0
  155. package/pipeline/schemas/repo-change.schema.json +54 -0
  156. package/pipeline/schemas/repo-request.schema.json +29 -0
  157. package/pipeline/schemas/repos.schema.json +49 -1
  158. package/pipeline/schemas/review-verdict-request.schema.json +20 -0
  159. package/pipeline/schemas/review-verdict.schema.json +62 -0
  160. package/pipeline/schemas/routine-delete.schema.json +59 -0
  161. package/pipeline/schemas/routines.schema.json +38 -0
  162. package/pipeline/schemas/run-evidence.schema.json +29 -0
  163. package/pipeline/schemas/run-results.schema.json +218 -0
  164. package/pipeline/schemas/runs-index.schema.json +257 -46
  165. package/pipeline/schemas/service-read.schema.json +58 -0
  166. package/pipeline/schemas/stack-change.schema.json +82 -0
  167. package/pipeline/schemas/stack-request.schema.json +33 -0
  168. package/pipeline/schemas/stack.schema.json +59 -0
  169. package/pipeline/schemas/steer-request.schema.json +25 -0
  170. package/pipeline/schemas/steer.schema.json +59 -0
  171. package/pipeline/schemas/system-status.schema.json +111 -0
  172. package/pipeline/schemas/tool-call-request.schema.json +19 -0
  173. package/pipeline/schemas/tool-call.schema.json +47 -0
  174. package/pipeline/schemas/tools.schema.json +65 -0
  175. package/pipeline/schemas/unattended-profile.json +7 -2
  176. package/pipeline/scripts/_active-run.mjs +2 -1
  177. package/pipeline/scripts/_cost.mjs +66 -1
  178. package/pipeline/scripts/_office-smoke-lib.sh +115 -0
  179. package/pipeline/scripts/_spawn-async.mjs +77 -0
  180. package/pipeline/scripts/_transcript-tail.mjs +274 -0
  181. package/pipeline/scripts/activity-snapshot.mjs +1223 -0
  182. package/pipeline/scripts/autopilot-control.mjs +214 -9
  183. package/pipeline/scripts/autopilot-runner.mjs +7 -5
  184. package/pipeline/scripts/contract-server.mjs +1853 -197
  185. package/pipeline/scripts/cost-analyze.mjs +2 -53
  186. package/pipeline/scripts/cost-table.json +1 -0
  187. package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
  188. package/pipeline/scripts/job-launch.mjs +477 -0
  189. package/pipeline/scripts/launch-request.mjs +94 -9
  190. package/pipeline/scripts/office-build.mjs +226 -0
  191. package/pipeline/scripts/office-control.mjs +410 -0
  192. package/pipeline/scripts/office-credentials.mjs +106 -0
  193. package/pipeline/scripts/office-ledger.mjs +409 -0
  194. package/pipeline/scripts/office-memory.mjs +200 -0
  195. package/pipeline/scripts/office-prune.mjs +125 -0
  196. package/pipeline/scripts/office-settings.mjs +145 -0
  197. package/pipeline/scripts/phase-tracker.sh +12 -5
  198. package/pipeline/scripts/prune-logs.sh +53 -2
  199. package/pipeline/scripts/routine-registry.mjs +42 -6
  200. package/pipeline/scripts/run-results.mjs +373 -0
  201. package/pipeline/scripts/runs-index.mjs +62 -2
  202. package/pipeline/scripts/service-read.mjs +244 -0
  203. package/pipeline/scripts/stack-toggle.mjs +285 -0
  204. package/pipeline/scripts/steer-run.mjs +97 -0
  205. package/pipeline/scripts/system-status.mjs +196 -0
  206. package/pipeline/scripts/task-mirror.mjs +150 -0
  207. package/pipeline/scripts/task-mirror.sh +20 -0
  208. package/pipeline/scripts/toolkit-tools.mjs +625 -0
  209. package/pipeline/scripts/transcript-index.mjs +4 -1
  210. package/pipeline/scripts/uninstall.mjs +3 -0
  211. package/pipeline/scripts/work-items.mjs +473 -0
  212. package/pipeline/skills/.skill-manifest.json +23 -11
  213. package/pipeline/skills/.skills-index.json +34 -1
  214. package/pipeline/skills/shared/README.md +6 -3
  215. package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
  216. package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
  217. package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +21 -0
  218. package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
  219. package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
  220. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
  221. package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
  222. package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
  223. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
  224. package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
  225. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  226. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
  227. package/pipeline/skills/skills-index.md +4 -1
@@ -0,0 +1,1223 @@
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
+ import { startedJobs } from "./job-launch.mjs";
79
+
80
+ export const CONTRACT_VERSION = "1.1.0";
81
+ export const RECENT_MS = 10 * 60_000;
82
+
83
+ export const ROLES = JSON.parse(
84
+ readFileSync(new URL("../schemas/activity-roles.json", import.meta.url), "utf8"),
85
+ );
86
+ const PHASES = JSON.parse(readFileSync(new URL("../schemas/phases.json", import.meta.url), "utf8"));
87
+ const PHASE_NAMES = Object.fromEntries(PHASES.phases.map((p) => [String(p.id), p.name]));
88
+ /** A phase's name in the run's own mode: an analysis names its phases its way. */
89
+ const phaseName = (runType, id) =>
90
+ (runType === "analysis" ? PHASES.modes?.analysis?.names?.[String(id)] : null) ??
91
+ PHASE_NAMES[String(id)] ??
92
+ String(id);
93
+
94
+ const TERMINAL = new Set(["complete", "completed", "done", "abandoned", "cancelled", "failed"]);
95
+ /**
96
+ * A run that finished a moment ago stays on the floor for its result: the team
97
+ * sees it through, then leaves. Only a finished run counts, not one that was
98
+ * abandoned or failed.
99
+ */
100
+ export const FINISH_MS = 2 * 60_000;
101
+ const FINISHED = new Set(["complete", "completed", "done"]);
102
+ function justFinished(run, nowMs) {
103
+ if (!FINISHED.has(run.status)) return false;
104
+ const ends = (run.phases ?? [])
105
+ .map((p) => Date.parse(p.completedAt ?? ""))
106
+ .filter(Number.isFinite);
107
+ return ends.length > 0 && nowMs - Math.max(...ends) < FINISH_MS;
108
+ }
109
+ const READ_TOOLS = new Set([
110
+ "Read",
111
+ "Grep",
112
+ "Glob",
113
+ "LS",
114
+ "WebFetch",
115
+ "WebSearch",
116
+ "NotebookRead",
117
+ "ToolSearch",
118
+ "view",
119
+ "read_file",
120
+ ]);
121
+ const READ_MCP = /(?:^|_)(get|list|read|search|fetch|query|find|status|describe)(?:_|$)/i;
122
+ const SPAWN_TOOLS = new Set(["Agent", "Task", "task", "spawn_agent"]);
123
+ const WAIT_TOOLS = new Set(["AskUserQuestion"]);
124
+ const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
125
+ const SAFE_AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
126
+ const DESCRIPTION_LIMIT = 56;
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Roles, models, tools
130
+
131
+ /**
132
+ * The role label for an agent type. `phaseId` is the run phase the agent was
133
+ * spawned in; it decides the role of an untyped agent and nothing else.
134
+ *
135
+ * @param {string|null} agentType null for a session's own desk
136
+ * @param {number|string|null} phaseId
137
+ * @returns {{role: string, known: boolean}}
138
+ */
139
+ export function roleFor(agentType, phaseId) {
140
+ if (!agentType) return { role: ROLES.orchestrator, known: true };
141
+ const named = ROLES.agentTypes[agentType];
142
+ if (named) return { role: named, known: true };
143
+ if (ROLES.untyped.includes(agentType)) {
144
+ const byPhase =
145
+ phaseId === null || phaseId === undefined ? null : ROLES.phaseRoles[String(phaseId)];
146
+ if (byPhase) return { role: byPhase, known: true };
147
+ }
148
+ return { role: agentType, known: false };
149
+ }
150
+
151
+ /** The fixed sprite and color of a role; an unknown role gets the neutral one. */
152
+ export function styleFor(role) {
153
+ return ROLES.styles[role] ?? ROLES.unknownStyle;
154
+ }
155
+
156
+ /** "Opus" for claude-opus-5-5; any other id as it is. */
157
+ export function modelLabel(id) {
158
+ if (typeof id !== "string" || !id) return null;
159
+ const m = /(fable|opus|sonnet|haiku)/i.exec(id);
160
+ return m ? m[1][0].toUpperCase() + m[1].slice(1).toLowerCase() : id;
161
+ }
162
+
163
+ function toolState(name) {
164
+ if (SPAWN_TOOLS.has(name)) return "spawning";
165
+ if (WAIT_TOOLS.has(name)) return "waiting";
166
+ if (READ_TOOLS.has(name)) return "reading";
167
+ if (name.startsWith("mcp__") && READ_MCP.test(name.split("__").pop())) return "reading";
168
+ return "writing";
169
+ }
170
+
171
+ /** The one allow-listed detail of a tool call: a file's basename, or the spawned type. */
172
+ function toolDetail(name, input) {
173
+ if (!input || typeof input !== "object") return null;
174
+ if (SPAWN_TOOLS.has(name)) {
175
+ const t = input.subagent_type ?? input.agent_type;
176
+ return typeof t === "string" && t ? t : null;
177
+ }
178
+ for (const k of ["file_path", "notebook_path", "path"]) {
179
+ if (typeof input[k] === "string" && input[k]) return basename(input[k]);
180
+ }
181
+ return null;
182
+ }
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // One transcript, reduced to what a desk shows
186
+
187
+ export function emptyState() {
188
+ return {
189
+ firstAt: null,
190
+ lastAt: null,
191
+ model: null,
192
+ cwd: null,
193
+ gitBranch: null,
194
+ title: null,
195
+ last: null,
196
+ pending: new Map(),
197
+ mcp: [],
198
+ tasks: [],
199
+ taskCalls: new Map(),
200
+ };
201
+ }
202
+ // The last MCP calls a desk made, newest last: what a session did with the
203
+ // toolkit and other servers when it used them directly, outside any run step.
204
+ const MCP_KEEP = 8;
205
+
206
+ const WAIT_KINDS = new Set([
207
+ "maturity",
208
+ "user-channels-choice",
209
+ "local-test",
210
+ "attended-reply",
211
+ "question",
212
+ ]);
213
+ /** A live seat holding a question open in its own window (AskUserQuestion). */
214
+ const asking = (seats) => seats.some((x) => x.live === true && x.state === "waiting");
215
+
216
+ const tsOf = (rec) => {
217
+ const t = Date.parse(rec?.timestamp ?? "");
218
+ return Number.isFinite(t) ? t : null;
219
+ };
220
+
221
+ /** Fold one Claude-shaped record into a desk state. */
222
+ // A turn that ended or was interrupted leaves no call running: an MCP call
223
+ // it never answered is closed as failed, so no desk waits on it forever.
224
+ function endTurn(s, t) {
225
+ s.pending.clear();
226
+ for (const c of s.mcp)
227
+ if (c.end === null) {
228
+ c.end = t ?? c.at ?? 0;
229
+ c.error = true;
230
+ }
231
+ }
232
+
233
+ export function reduceRecord(s, rec) {
234
+ const t = tsOf(rec);
235
+ if (t !== null) {
236
+ s.firstAt = s.firstAt === null ? t : Math.min(s.firstAt, t);
237
+ s.lastAt = s.lastAt === null ? t : Math.max(s.lastAt, t);
238
+ }
239
+ if (typeof rec.cwd === "string" && rec.cwd) s.cwd = rec.cwd;
240
+ if (typeof rec.gitBranch === "string" && rec.gitBranch) s.gitBranch = rec.gitBranch;
241
+ if (rec.type === "ai-title" && typeof rec.aiTitle === "string") s.title = rec.aiTitle;
242
+ if (rec.type === "system" && rec.subtype === "turn_duration") endTurn(s, t);
243
+ const msg = rec.message;
244
+ if (!msg || typeof msg !== "object") return s;
245
+ const content = Array.isArray(msg.content) ? msg.content : null;
246
+ if (rec.type === "assistant") {
247
+ if (typeof msg.model === "string" && msg.model && !msg.model.startsWith("<")) {
248
+ s.model = msg.model;
249
+ }
250
+ for (const b of content ?? []) {
251
+ if (b?.type === "tool_use" && typeof b.name === "string") {
252
+ const entry = { tool: b.name, detail: toolDetail(b.name, b.input) };
253
+ s.pending.set(String(b.id ?? s.pending.size), entry);
254
+ s.last = entry;
255
+ if (b.name === "TodoWrite" && Array.isArray(b.input?.todos)) {
256
+ s.tasks = b.input.todos.slice(0, MAX_TASKS).map((x, i) => ({
257
+ id: String(i),
258
+ subject: String(x?.content ?? x?.subject ?? ""),
259
+ status: TASK_STATES.has(x?.status) ? x.status : "pending",
260
+ }));
261
+ } else if (b.name === "TaskCreate" && typeof b.input?.subject === "string") {
262
+ s.taskCalls.set(String(b.id ?? ""), b.input.subject);
263
+ } else if (b.name === "TaskUpdate" && b.input?.taskId !== undefined) {
264
+ if (b.input.status === "deleted")
265
+ s.tasks = s.tasks.filter((x) => x.id !== String(b.input.taskId));
266
+ const tk = s.tasks.find((x) => x.id === String(b.input.taskId));
267
+ if (tk && TASK_STATES.has(b.input.status)) tk.status = b.input.status;
268
+ if (tk && typeof b.input.subject === "string") tk.subject = b.input.subject;
269
+ }
270
+ if (b.name.startsWith("mcp__")) {
271
+ s.mcp.push({ id: String(b.id ?? ""), tool: b.name, at: t, end: null, error: false });
272
+ if (s.mcp.length > MCP_KEEP) s.mcp.shift();
273
+ }
274
+ }
275
+ }
276
+ if (msg.stop_reason === "end_turn" || msg.stop_reason === "stop_sequence") endTurn(s, t);
277
+ } else if (rec.type === "user") {
278
+ const results = (content ?? []).filter((b) => b?.type === "tool_result");
279
+ for (const b of results) {
280
+ s.pending.delete(String(b.tool_use_id));
281
+ const subject = s.taskCalls.get(String(b.tool_use_id));
282
+ if (subject !== undefined) {
283
+ s.taskCalls.delete(String(b.tool_use_id));
284
+ const text = typeof b.content === "string" ? b.content : JSON.stringify(b.content ?? "");
285
+ const m = /Task #(\w+)/.exec(text);
286
+ if (m && !/^Phase \d+\b/.test(subject) && s.tasks.length < MAX_TASKS)
287
+ s.tasks.push({ id: m[1], subject, status: "pending" });
288
+ }
289
+ const call = s.mcp.find((c) => c.id === String(b.tool_use_id));
290
+ if (call) {
291
+ call.end = t;
292
+ call.error = b.is_error === true;
293
+ }
294
+ }
295
+ if (!results.length && !rec.isMeta) endTurn(s, t);
296
+ }
297
+ return s;
298
+ }
299
+
300
+ /** The state a desk shows for a reduced transcript. */
301
+ function deskActivity(s, finished) {
302
+ if (finished)
303
+ return { state: "done", tool: s.last?.tool ?? null, detail: s.last?.detail ?? null };
304
+ const running = [...s.pending.values()].at(-1);
305
+ if (running)
306
+ return { state: toolState(running.tool), tool: running.tool, detail: running.detail };
307
+ return { state: "idle", tool: s.last?.tool ?? null, detail: s.last?.detail ?? null };
308
+ }
309
+
310
+ /**
311
+ * Rebuildable per-file state between snapshots: a follower (offsets) and the
312
+ * reduced state of each transcript it has read.
313
+ */
314
+ export function createActivityCache() {
315
+ return { follower: createFollower({ allow: null }), states: new Map(), heads: new Map() };
316
+ }
317
+ const defaultCache = createActivityCache();
318
+
319
+ /**
320
+ * The reduced state of one transcript, advanced by whatever was appended since
321
+ * the previous call. `format` is detected from the first record seen.
322
+ */
323
+ function stateOf(cache, path) {
324
+ const got = cache.follower.read(path);
325
+ let entry = cache.states.get(path);
326
+ if (!entry || got.reset) {
327
+ entry = { state: emptyState(), format: null, adapter: null };
328
+ cache.states.set(path, entry);
329
+ }
330
+ for (const { record } of got.records) {
331
+ if (!entry.format) {
332
+ entry.format = detectFormat(record);
333
+ entry.adapter = createAdapter(entry.format);
334
+ }
335
+ for (const rec of entry.adapter(record)) {
336
+ if (entry.format === "claude" && !ACTIVITY_TYPES.has(rec.type)) continue;
337
+ reduceRecord(entry.state, rec);
338
+ }
339
+ }
340
+ entry.touched = true;
341
+ return entry;
342
+ }
343
+
344
+ /** The working directory a Copilot CLI or Codex session started in (its opening record). */
345
+ function hostHead(cache, path) {
346
+ if (cache.heads.has(path)) return cache.heads.get(path);
347
+ let cwd = null;
348
+ try {
349
+ const text = readFileSync(path, { encoding: "utf8", flag: "r" }).slice(0, 256 * 1024);
350
+ for (const line of text.split("\n").slice(0, 16)) {
351
+ try {
352
+ cwd = sessionCwd(JSON.parse(line)) ?? cwd;
353
+ } catch {
354
+ // A partial first line says nothing; the next one may.
355
+ }
356
+ if (cwd) break;
357
+ }
358
+ } catch {
359
+ cwd = null;
360
+ }
361
+ cache.heads.set(path, cwd);
362
+ return cwd;
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Disk walking
367
+
368
+ const mtimeOf = (p) => {
369
+ try {
370
+ return statSync(p).mtimeMs;
371
+ } catch {
372
+ return null;
373
+ }
374
+ };
375
+
376
+ const listDir = (dir) => {
377
+ try {
378
+ return readdirSync(dir);
379
+ } catch {
380
+ return [];
381
+ }
382
+ };
383
+
384
+ /** Claude Code session files changed since `since`, plus the ones runs name. */
385
+ function claudeSessions(projectsRoot, since, wanted) {
386
+ const out = new Map();
387
+ for (const slug of listDir(projectsRoot)) {
388
+ const dir = join(projectsRoot, slug);
389
+ for (const f of listDir(dir)) {
390
+ if (!f.endsWith(".jsonl")) continue;
391
+ const sessionId = f.slice(0, -6);
392
+ if (!SAFE_SESSION_ID.test(sessionId)) continue;
393
+ const path = join(dir, f);
394
+ const m = mtimeOf(path);
395
+ if (m === null) continue;
396
+ if (m >= since || wanted.has(sessionId)) out.set(sessionId, { path, dir, mtime: m });
397
+ }
398
+ }
399
+ return out;
400
+ }
401
+
402
+ function subagentsOf(sessionDir, sessionId, since) {
403
+ const dir = join(sessionDir, sessionId, "subagents");
404
+ const out = [];
405
+ for (const f of listDir(dir)) {
406
+ const m = /^agent-([A-Za-z0-9_-]{1,64})\.jsonl$/.exec(f);
407
+ if (!m) continue;
408
+ const path = join(dir, f);
409
+ const mtime = mtimeOf(path);
410
+ if (mtime === null || mtime < since) continue;
411
+ const meta = readRegularJson(join(dir, `agent-${m[1]}.meta.json`), null) ?? {};
412
+ out.push({ agentId: m[1], path, meta });
413
+ }
414
+ return out.sort((a, b) => (a.agentId < b.agentId ? -1 : 1));
415
+ }
416
+
417
+ function copilotSessions(root, since) {
418
+ const out = [];
419
+ for (const d of listDir(root)) {
420
+ const path = join(root, d, "events.jsonl");
421
+ const m = mtimeOf(path);
422
+ if (m !== null && m >= since && SAFE_SESSION_ID.test(d))
423
+ out.push({ path, sessionId: d, host: "copilot" });
424
+ }
425
+ return out;
426
+ }
427
+
428
+ function codexSessions(root, since, depth = 0) {
429
+ const out = [];
430
+ for (const name of listDir(root)) {
431
+ const p = join(root, name);
432
+ if (depth < 3 && !name.endsWith(".jsonl")) {
433
+ out.push(...codexSessions(p, since, depth + 1));
434
+ continue;
435
+ }
436
+ const m = /^rollout-.*-([0-9a-f-]{36})\.jsonl$/i.exec(name);
437
+ const mt = m ? mtimeOf(p) : null;
438
+ if (m && mt !== null && mt >= since) out.push({ path: p, sessionId: m[1], host: "codex" });
439
+ }
440
+ return out;
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Runs
445
+
446
+ const samePath = (p) => String(p ?? "").replace(/\/+$/, "");
447
+ const inside = (path, root) =>
448
+ Boolean(root) &&
449
+ (samePath(path) === samePath(root) || samePath(path).startsWith(samePath(root) + "/"));
450
+
451
+ /** The repository a working directory belongs to: the checkout, not the worktree. */
452
+ export function repoName(path) {
453
+ if (typeof path !== "string" || !path) return null;
454
+ const root = path.replace(/\/\.(?:claude\/)?worktrees\/.*$/, "").replace(/\/+$/, "");
455
+ return basename(root) || null;
456
+ }
457
+
458
+ /** The fields of agent-state.json the index does not carry. */
459
+ export function runExtras(run) {
460
+ if (!run.dir) return {};
461
+ for (const p of [
462
+ join(run.dir, "agent-state.json"),
463
+ join(run.dir, "artifacts", "agent-state.json"),
464
+ ]) {
465
+ if (!existsSync(p)) continue;
466
+ const doc = readRegularJson(p, null);
467
+ if (doc && typeof doc === "object") {
468
+ return {
469
+ projectRoot: typeof doc.projectRoot === "string" ? doc.projectRoot : null,
470
+ projects: Array.isArray(doc.projects) ? doc.projects : [],
471
+ };
472
+ }
473
+ }
474
+ return {};
475
+ }
476
+
477
+ /**
478
+ * The run a session belongs to: the one that recorded its id, else the one
479
+ * whose worktree holds its working directory, or whose checkout and branch it
480
+ * shares. More than one such run is `ambiguous`.
481
+ *
482
+ * @returns {{run: object|null, matchedBy: "session"|"cwd-branch"|null, ambiguous: boolean}}
483
+ */
484
+ export function matchRun(sessionId, cwd, branch, runs) {
485
+ // A run that just finished keeps only the session that ran it, and only
486
+ // when no live run claims that session: a session that finished one run and
487
+ // started the next belongs to the next.
488
+ const live = runs.filter((r) => !TERMINAL.has(r.status));
489
+ const mine = (r) => r.sessionId && r.sessionId === sessionId;
490
+ const byId = live.find(mine) ?? runs.find(mine);
491
+ if (byId) return { run: byId, matchedBy: "session", ambiguous: false };
492
+ if (!cwd) return { run: null, matchedBy: null, ambiguous: false };
493
+ const hits = live.filter((r) => {
494
+ const roots = [{ wt: r.worktreePath, root: r.extras?.projectRoot, branch: r.branch }];
495
+ for (const p of r.extras?.projects ?? []) {
496
+ roots.push({ wt: p?.worktreePath, root: p?.projectRoot, branch: p?.branch ?? r.branch });
497
+ }
498
+ return roots.some(
499
+ (x) =>
500
+ (x.wt && inside(cwd, x.wt)) ||
501
+ (x.root && samePath(cwd) === samePath(x.root) && branch && x.branch === branch),
502
+ );
503
+ });
504
+ if (hits.length === 1) return { run: hits[0], matchedBy: "cwd-branch", ambiguous: false };
505
+ return { run: null, matchedBy: null, ambiguous: hits.length > 1 };
506
+ }
507
+
508
+ /** The phase a run was in at time `t`: the latest one started before it. */
509
+ export function phaseAt(run, t) {
510
+ if (!run) return null;
511
+ if (t !== null) {
512
+ let best = null;
513
+ for (const p of run.phases ?? []) {
514
+ const s = Date.parse(p.startedAt ?? "");
515
+ const e = Date.parse(p.completedAt ?? "");
516
+ if (!Number.isFinite(s) || s > t) continue;
517
+ if (Number.isFinite(e) && t >= e) continue;
518
+ if (!best || s > best.s) best = { s, id: p.id };
519
+ }
520
+ if (best) return Number(best.id);
521
+ }
522
+ return Number.isFinite(run.currentPhase) ? run.currentPhase : null;
523
+ }
524
+
525
+ const iso = (ms) => (ms === null || ms === undefined ? null : new Date(ms).toISOString());
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // The snapshot
529
+
530
+ /**
531
+ * The office document.
532
+ *
533
+ * @param {{
534
+ * runs?: object[], projectsRoot?: string, copilotRoot?: string, codexRoot?: string,
535
+ * sessions?: object[]|undefined|null, now?: number, recentMs?: number,
536
+ * cache?: ReturnType<typeof createActivityCache>
537
+ * }} [opts] `sessions` is the `claude agents --json` list; null reads it,
538
+ * undefined leaves liveness unknown.
539
+ */
540
+ export async function activitySnapshot(opts = {}) {
541
+ const now = opts.now ?? Date.now();
542
+ const recentMs = opts.recentMs ?? RECENT_MS;
543
+ const since = now - recentMs;
544
+ const cache = opts.cache ?? defaultCache;
545
+ const sessions = opts.sessions === null ? listSessions() : opts.sessions;
546
+ const R = createRedactor();
547
+ const clean = (v, limit = 160) =>
548
+ v === null || v === undefined || v === "" ? null : R.excerpt(v, limit);
549
+
550
+ const jobs = opts.jobs ?? startedJobs();
551
+ const allRuns = opts.runs ?? buildIndex();
552
+ const runs = allRuns
553
+ .filter((r) => !TERMINAL.has(r.status) || justFinished(r, now))
554
+ .map((r) => ({ ...r, extras: runExtras(r) }));
555
+ const wanted = new Set(runs.map((r) => r.sessionId).filter(Boolean));
556
+
557
+ for (const e of cache.states.values()) e.touched = false;
558
+
559
+ const teams = new Map();
560
+ const teamForRun = (run) => {
561
+ const id = `run:${run.taskId}`;
562
+ if (!teams.has(id)) teams.set(id, { id, run, seats: [], sessions: [], hosts: new Set() });
563
+ return teams.get(id);
564
+ };
565
+ const liveOf = (sessionId) => {
566
+ if (sessions === undefined) return null;
567
+ return sessionLiveness(sessionId, sessions ?? undefined);
568
+ };
569
+
570
+ const addSession = ({ sessionId, path, dir, host, cwd: knownCwd }) => {
571
+ const main = stateOf(cache, path);
572
+ const s = main.state;
573
+ const cwd = knownCwd ?? s.cwd;
574
+ const hit = matchRun(sessionId, cwd, s.gitBranch, runs);
575
+ let team;
576
+ if (hit.run) {
577
+ team = teamForRun(hit.run);
578
+ team.matchedBy ??= hit.matchedBy;
579
+ } else {
580
+ const id = `session:${sessionId}`;
581
+ // A session the office started for a job carries the job's role, and a
582
+ // solo job is seated at a desk rather than around a table.
583
+ const job = jobs.get(sessionId) ?? null;
584
+ team = {
585
+ id,
586
+ run: null,
587
+ kind: hit.ambiguous ? "unmatched" : job && !job.team ? "solo" : "session",
588
+ job,
589
+ seats: [],
590
+ sessions: [],
591
+ hosts: new Set(),
592
+ };
593
+ teams.set(id, team);
594
+ }
595
+ team.sessions.push({ sessionId, cwd, branch: s.gitBranch, title: s.title, lastAt: s.lastAt });
596
+ team.hosts.add(host);
597
+ const live = liveOf(sessionId);
598
+ team.seats.push(
599
+ seat({
600
+ sessionId,
601
+ agentId: null,
602
+ agentType: null,
603
+ s,
604
+ finished: false,
605
+ live,
606
+ phase: null,
607
+ as: team.job?.role,
608
+ }),
609
+ );
610
+ if (host !== "claude" || !dir) return;
611
+ for (const sub of subagentsOf(dir, sessionId, since)) {
612
+ const st = stateOf(cache, sub.path).state;
613
+ const agentType = typeof sub.meta.agentType === "string" ? sub.meta.agentType : null;
614
+ const finished = sub.meta.finished === true;
615
+ team.seats.push(
616
+ seat({
617
+ sessionId,
618
+ agentId: sub.agentId,
619
+ agentType,
620
+ description: sub.meta.description,
621
+ s: st,
622
+ finished,
623
+ live: finished ? false : live,
624
+ phase: phaseAt(hit.run, st.firstAt),
625
+ }),
626
+ );
627
+ }
628
+ };
629
+
630
+ const seat = ({ sessionId, agentId, agentType, description, s, finished, live, phase, as }) => {
631
+ const { role, known } =
632
+ as && agentId === null
633
+ ? { role: as, known: true }
634
+ : roleFor(agentType ?? (agentId ? "unknown" : null), phase);
635
+ const style = styleFor(role);
636
+ const act = deskActivity(s, finished);
637
+ const detail = clean(act.detail, 80);
638
+ return {
639
+ id: `${sessionId}:${agentId ?? "main"}`,
640
+ sessionId,
641
+ agentId,
642
+ agentType,
643
+ role,
644
+ label: role,
645
+ known,
646
+ phase: agentId === null ? null : phase,
647
+ sprite: style.sprite,
648
+ color: style.color,
649
+ model: s.model,
650
+ modelLabel: modelLabel(s.model),
651
+ description: clean(description, DESCRIPTION_LIMIT),
652
+ state: act.state,
653
+ status: act.state === "done" ? "done" : act.state === "idle" ? "idle" : "working",
654
+ tool: act.tool,
655
+ detail,
656
+ doing: doingText(act.state, act.tool, detail),
657
+ live,
658
+ startedAt: iso(s.firstAt),
659
+ lastAt: iso(s.lastAt),
660
+ tasks: (s.tasks ?? []).map((x) => ({
661
+ subject: clean(x.subject, 80) ?? "",
662
+ status: x.status,
663
+ })),
664
+ mcp: (s.mcp ?? []).map((c) => {
665
+ const [, server = "", name = c.tool] = /^mcp__(.+?)__(.+)$/.exec(c.tool) ?? [];
666
+ return {
667
+ server: clean(server, 40) ?? "",
668
+ tool: clean(name, 60) ?? "",
669
+ state: c.end === null ? "running" : c.error ? "error" : "ok",
670
+ ms: c.end !== null && c.at !== null ? Math.max(0, c.end - c.at) : null,
671
+ at: iso(c.at),
672
+ };
673
+ }),
674
+ };
675
+ };
676
+
677
+ const projectsRoot = opts.projectsRoot ?? defaultProjectsRoot();
678
+ for (const [sessionId, f] of claudeSessions(projectsRoot, since, wanted)) {
679
+ addSession({ sessionId, path: f.path, dir: f.dir, host: "claude" });
680
+ }
681
+ for (const f of [
682
+ ...copilotSessions(opts.copilotRoot ?? defaultCopilotRoot(), since),
683
+ ...codexSessions(opts.codexRoot ?? defaultCodexRoot(), since),
684
+ ]) {
685
+ addSession({ ...f, dir: null, cwd: hostHead(cache, f.path) });
686
+ }
687
+
688
+ // A run waiting on you, or whose session is alive, has a table even when no
689
+ // transcript of it changed in the window.
690
+ for (const run of runs) {
691
+ const waiting =
692
+ Boolean(run.pendingQuestion) || run.status === "awaiting_input" || Boolean(run.waitingFor);
693
+ if (waiting || run.processAlive === true) teamForRun(run);
694
+ }
695
+
696
+ for (const [path, e] of cache.states) {
697
+ if (!e.touched) {
698
+ cache.states.delete(path);
699
+ cache.follower.forget(path);
700
+ cache.heads.delete(path);
701
+ }
702
+ }
703
+
704
+ const rooms = new Map();
705
+ const corridors = [];
706
+ for (const team of teams.values()) {
707
+ const table = renderTable(team, clean, now);
708
+ if (!rooms.has(table.repo)) rooms.set(table.repo, []);
709
+ rooms.get(table.repo).push(table);
710
+ if (table.repos.length > 1) corridors.push({ tableId: table.id, repos: table.repos });
711
+ }
712
+ const doc = {
713
+ contractVersion: CONTRACT_VERSION,
714
+ generatedAt: new Date(now).toISOString(),
715
+ recentMinutes: recentMs / 60_000,
716
+ rooms: [...rooms.entries()]
717
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
718
+ .map(([repo, list]) => ({ repo, tables: list.sort(tableOrder) })),
719
+ corridors,
720
+ counts: { rooms: 0, tables: 0, seats: 0, live: 0, waiting: 0 },
721
+ };
722
+ for (const room of doc.rooms) {
723
+ doc.counts.rooms++;
724
+ for (const t of room.tables) {
725
+ doc.counts.tables++;
726
+ if (t.waitingForYou) doc.counts.waiting++;
727
+ for (const s of t.seats) {
728
+ doc.counts.seats++;
729
+ if (s.live === true && s.state !== "done") doc.counts.live++;
730
+ }
731
+ }
732
+ }
733
+ assertClean(JSON.stringify(doc));
734
+ return doc;
735
+ }
736
+
737
+ /** What a seat is doing, in a few plain words; the raw tool name when there is no phrase. */
738
+ export function doingText(state, tool, detail) {
739
+ if (state === "done") return "Done";
740
+ if (!tool || state === "idle") return state === "idle" ? "Idle" : "Working";
741
+ const on = detail ? ` ${detail}` : "";
742
+ const phrase = TOOL_PHRASES[tool];
743
+ if (phrase) return `${phrase}${on}`;
744
+ if (tool.startsWith("mcp__")) {
745
+ const [, server, name] = tool.split("__");
746
+ return `Using ${name ?? tool} (${server})`;
747
+ }
748
+ return `${tool}${on}`;
749
+ }
750
+
751
+ const TOOL_PHRASES = {
752
+ Read: "Reading",
753
+ Edit: "Editing",
754
+ MultiEdit: "Editing",
755
+ Write: "Writing",
756
+ NotebookEdit: "Editing",
757
+ Grep: "Searching the code",
758
+ Glob: "Listing files",
759
+ LS: "Listing files",
760
+ Bash: "Running a command",
761
+ WebFetch: "Reading a web page",
762
+ WebSearch: "Searching the web",
763
+ Agent: "Spawning",
764
+ Task: "Spawning",
765
+ AskUserQuestion: "Asking you",
766
+ TodoWrite: "Updating the task list",
767
+ TaskCreate: "Updating the task list",
768
+ TaskUpdate: "Updating the task list",
769
+ Skill: "Running a skill",
770
+ ToolSearch: "Looking up a tool",
771
+ };
772
+
773
+ const tableOrder = (a, b) => {
774
+ const ka = a.kind === "run" ? `0${a.taskId}` : `1${a.seats[0]?.lastAt ?? ""}`;
775
+ const kb = b.kind === "run" ? `0${b.taskId}` : `1${b.seats[0]?.lastAt ?? ""}`;
776
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
777
+ };
778
+
779
+ /** Number the seats of a role that appears more than once, in spawn order. */
780
+ function numberParallel(seats) {
781
+ const byRole = new Map();
782
+ for (const d of seats) {
783
+ if (d.agentId === null) continue;
784
+ if (!byRole.has(d.role)) byRole.set(d.role, []);
785
+ byRole.get(d.role).push(d);
786
+ }
787
+ for (const group of byRole.values()) {
788
+ if (group.length < 2) continue;
789
+ group.sort(bySpawn).forEach((d, i) => (d.label = `${d.role} ${i + 1}`));
790
+ }
791
+ }
792
+
793
+ const bySpawn = (a, b) =>
794
+ (a.startedAt ?? "") < (b.startedAt ?? "")
795
+ ? -1
796
+ : (a.startedAt ?? "") > (b.startedAt ?? "")
797
+ ? 1
798
+ : (a.agentId ?? "") < (b.agentId ?? "")
799
+ ? -1
800
+ : 1;
801
+
802
+ /**
803
+ * Orchestrator at the head, then the seats in the order of the phase each
804
+ * was spawned in, parallel agents of one phase side by side in spawn order.
805
+ */
806
+ function seatOrder(a, b) {
807
+ if (a.agentId === null && b.agentId !== null) return -1;
808
+ if (b.agentId === null && a.agentId !== null) return 1;
809
+ const pa = a.phase ?? Infinity;
810
+ const pb = b.phase ?? Infinity;
811
+ return pa !== pb ? pa - pb : bySpawn(a, b);
812
+ }
813
+
814
+ /**
815
+ * Who holds the work now: the latest-started seat still working, else the
816
+ * orchestrator. `previousHolder` is the latest seat of an earlier phase, so a
817
+ * client can draw the handoff from it.
818
+ */
819
+ function holders(seats, phaseId) {
820
+ const working = seats.filter((s) => s.agentId !== null && s.status === "working");
821
+ const holder = working.sort(bySpawn).at(-1) ?? seats.find((s) => s.agentId === null) ?? null;
822
+ const earlier = seats
823
+ .filter((s) => s.agentId !== null && s.phase !== null && phaseId !== null && s.phase < phaseId)
824
+ .sort(bySpawn)
825
+ .at(-1);
826
+ return { holder: holder?.id ?? null, previousHolder: earlier?.id ?? null };
827
+ }
828
+
829
+ // The host task widget's list, as far as a table sign and a run window show it.
830
+ const MAX_TASKS = 20;
831
+ const TASK_STATES = new Set(["pending", "in_progress", "completed"]);
832
+ const fraction = (v) =>
833
+ typeof v === "number" && v >= 0 && v <= 1 ? Math.round(v * 100) / 100 : null;
834
+ // The command a table runs, named the way a user types it.
835
+ // The commands installed beside this script; a mode with no command of its
836
+ // own (dev, short, dev-local) is a /multi-agent run.
837
+ const COMMANDS = (() => {
838
+ try {
839
+ return new Set(
840
+ readdirSync(new URL("../commands/multi-agent/", import.meta.url), { withFileTypes: true })
841
+ .filter((d) => d.isDirectory())
842
+ .map((d) => d.name),
843
+ );
844
+ } catch {
845
+ return new Set();
846
+ }
847
+ })();
848
+ // Where no command tree sits beside the scripts (another host's layout), the
849
+ // modes known to be plain /multi-agent runs are the ones left unprefixed.
850
+ const PLAIN_MODES = new Set(["pipeline", "full", "dev", "short", "dev-local"]);
851
+ const hasCommand = (mode) => (COMMANDS.size ? COMMANDS.has(mode) : !PLAIN_MODES.has(mode));
852
+ export const commandOf = (mode) =>
853
+ mode === "autopilot-run" || mode === "autopilot"
854
+ ? "/multi-agent autopilot"
855
+ : mode && hasCommand(mode)
856
+ ? `/multi-agent:${mode}`
857
+ : "/multi-agent";
858
+ const RUN_TYPES = new Set(["development", "analysis", "autopilot"]);
859
+ // A session the office started for a team job, before its run record exists.
860
+ const JOB_RUN_TYPES = {
861
+ pipeline: "development",
862
+ "autopilot-run": "autopilot",
863
+ analysis: "analysis",
864
+ };
865
+ const LAST_PHASE = Math.max(...Object.keys(PHASE_NAMES).map(Number));
866
+
867
+ function renderTable(team, clean, now) {
868
+ const run = team.run;
869
+ const seats = team.seats.sort(seatOrder);
870
+ numberParallel(seats);
871
+ const hosts = [...team.hosts];
872
+ const host = hosts.find((h) => h !== "claude") ?? "claude";
873
+ const first = team.sessions[0] ?? null;
874
+ if (!run) {
875
+ const repo = repoName(first?.cwd) ?? "unknown";
876
+ return {
877
+ id: team.id,
878
+ kind: team.kind,
879
+ runType: JOB_RUN_TYPES[team.job?.jobId] ?? null,
880
+ command: team.job?.jobId ? commandOf(team.job.jobId) : null,
881
+ taskId: null,
882
+ title: clean(first?.title ?? team.job?.prompt, 120),
883
+ job: team.job ? { id: team.job.jobId, role: team.job.role, team: team.job.team } : null,
884
+ host,
885
+ limited: host !== "claude",
886
+ sessionId: first?.sessionId ?? null,
887
+ matchedBy: null,
888
+ repo,
889
+ repos: [repo],
890
+ location: first?.cwd
891
+ ? /\/\.(?:claude\/)?worktrees\//.test(first.cwd)
892
+ ? "worktree"
893
+ : "local"
894
+ : "unknown",
895
+ branch: clean(first?.branch, 120),
896
+ phase: null,
897
+ phaseHistory: [],
898
+ currentTask: null,
899
+ progress: null,
900
+ startedAt: seats[0]?.startedAt ?? null,
901
+ elapsedSeconds: null,
902
+ now: null,
903
+ status: null,
904
+ source: null,
905
+ pr: null,
906
+ channels: [],
907
+ waitingFor: asking(seats) ? "terminal" : null,
908
+ waitingForYou: asking(seats),
909
+ pendingQuestion: null,
910
+ tokens: null,
911
+ estUsd: null,
912
+ costSource: "not-measured",
913
+ ...holders(seats, null),
914
+ seats,
915
+ };
916
+ }
917
+ const repo =
918
+ run.project ?? repoName(run.extras?.projectRoot) ?? repoName(first?.cwd) ?? "unknown";
919
+ const repos = [repo];
920
+ for (const p of run.extras?.projects ?? []) {
921
+ const name = repoName(p?.projectRoot ?? p?.worktreePath);
922
+ if (name && !repos.includes(name)) repos.push(name);
923
+ }
924
+ const phaseId = Number.isFinite(run.currentPhase) ? run.currentPhase : null;
925
+ const phaseRow =
926
+ (run.phases ?? []).find((p) => p.status === "in_progress") ??
927
+ (phaseId === null ? null : (run.phases ?? []).find((p) => p.id === String(phaseId)));
928
+ const shownPhase = phaseRow ? Number(phaseRow.id) : phaseId;
929
+ const runType = RUN_TYPES.has(run.runType) ? run.runType : "development";
930
+ // A run with no tracker still shows every phase of its mode, the ones before
931
+ // the current one done, so the table and its sign tell the same story.
932
+ const phases =
933
+ (run.phases ?? []).length || shownPhase === null || !Number.isFinite(shownPhase)
934
+ ? (run.phases ?? [])
935
+ : Array.from({ length: LAST_PHASE + 1 }, (_, i) => ({
936
+ id: String(i),
937
+ name: phaseName(runType, i),
938
+ status: i < shownPhase ? "completed" : i === shownPhase ? "in_progress" : "pending",
939
+ }));
940
+ // Which wait it is, whenever the run says it waits: a question for the panel,
941
+ // a named pause, else whatever the terminal is asking.
942
+ const waitingFor = run.pendingQuestion
943
+ ? "question"
944
+ : WAIT_KINDS.has(run.waitingFor)
945
+ ? run.waitingFor
946
+ : run.status === "awaiting_input" || run.waitingFor || asking(seats)
947
+ ? "terminal"
948
+ : null;
949
+ const measured = run.tokens && run.tokens.in + run.tokens.out + run.tokens.cached > 0;
950
+ const q = run.pendingQuestion;
951
+ const started = Date.parse(run.startedAt ?? "");
952
+ const location = run.worktreePath ? "worktree" : run.extras?.projectRoot ? "local" : "unknown";
953
+ return {
954
+ id: team.id,
955
+ kind: "run",
956
+ runType,
957
+ command: run.autopilot ? "/multi-agent autopilot" : commandOf(run.mode),
958
+ taskId: run.taskId,
959
+ title: clean(run.title, 120),
960
+ source: run.source ?? null,
961
+ pr: Number.isInteger(run.prNumber) && run.prNumber > 0 ? { number: run.prNumber } : null,
962
+ channels: Array.isArray(run.channels) ? run.channels.slice(0, 5) : [],
963
+ waitingFor,
964
+ host,
965
+ limited: host !== "claude",
966
+ sessionId: run.sessionId ?? first?.sessionId ?? null,
967
+ matchedBy: team.matchedBy ?? null,
968
+ repo,
969
+ repos,
970
+ location,
971
+ branch: clean(run.branch, 120),
972
+ phase:
973
+ shownPhase === null || !Number.isFinite(shownPhase)
974
+ ? null
975
+ : {
976
+ id: shownPhase,
977
+ name: phaseRow?.name || phaseName(runType, shownPhase),
978
+ last: LAST_PHASE,
979
+ },
980
+ phaseHistory: phases.map((p) => ({
981
+ id: Number.isFinite(Number(p.id)) ? Number(p.id) : null,
982
+ name: clean(p.name, 60) ?? "",
983
+ status: String(p.status ?? "pending"),
984
+ startedAt: p.startedAt ?? null,
985
+ completedAt: p.completedAt ?? null,
986
+ progress: fraction(p.progress),
987
+ tasks: (Array.isArray(p.tasks) ? p.tasks : []).slice(0, MAX_TASKS).map((tk) => ({
988
+ subject:
989
+ clean(tk.activeForm && tk.status === "in_progress" ? tk.activeForm : tk.subject, 80) ??
990
+ "",
991
+ status: TASK_STATES.has(tk.status) ? tk.status : "pending",
992
+ })),
993
+ })),
994
+ currentTask: clean(run.currentTask?.activeForm ?? run.currentTask?.subject, 80),
995
+ progress: fraction(run.progress),
996
+ startedAt: Number.isFinite(started) ? new Date(started).toISOString() : null,
997
+ elapsedSeconds: Number.isFinite(started)
998
+ ? Math.max(0, Math.round((now - started) / 1000))
999
+ : null,
1000
+ now: clean(phaseRow?.now, 160),
1001
+ status: run.status ?? null,
1002
+ // Waiting for you: a question the run wrote down for the panel to answer,
1003
+ // or one its session is asking in the terminal right now.
1004
+ waitingForYou: waitingFor !== null,
1005
+ pendingQuestion: q
1006
+ ? {
1007
+ id: q.id,
1008
+ text: clean(q.text, 400) ?? "",
1009
+ kind: q.kind === "multi-select" ? "multi-select" : "single-select",
1010
+ options: q.options.map((o) => ({ id: o.id, label: clean(o.label, 120) ?? "" })),
1011
+ }
1012
+ : null,
1013
+ tokens: measured ? { ...run.tokens } : null,
1014
+ estUsd: measured ? run.estUsd : null,
1015
+ costSource: measured ? "tracker-state" : "not-measured",
1016
+ ...holders(seats, shownPhase),
1017
+ seats,
1018
+ };
1019
+ }
1020
+
1021
+ // ---------------------------------------------------------------------------
1022
+ // Chat
1023
+
1024
+ /** The transcript of one desk on disk, or null. `agentId` null is the session itself. */
1025
+ export function transcriptPath(projectsRoot, sessionId, agentId) {
1026
+ if (typeof sessionId !== "string" || !SAFE_SESSION_ID.test(sessionId)) return null;
1027
+ if (agentId !== null && (typeof agentId !== "string" || !SAFE_AGENT_ID.test(agentId)))
1028
+ return null;
1029
+ for (const slug of listDir(projectsRoot)) {
1030
+ const dir = join(projectsRoot, slug);
1031
+ const p = agentId
1032
+ ? join(dir, sessionId, "subagents", `agent-${agentId}.jsonl`)
1033
+ : join(dir, `${sessionId}.jsonl`);
1034
+ if (existsSync(p)) return p;
1035
+ }
1036
+ return null;
1037
+ }
1038
+
1039
+ const MAX_TEXT = 20_000;
1040
+ const MAX_RESULT = 2_000;
1041
+ const WITHHELD = "[withheld: the outbound gate still found a credential here]";
1042
+
1043
+ const contentText = (c) => {
1044
+ if (typeof c === "string") return c;
1045
+ if (!Array.isArray(c)) return "";
1046
+ return c
1047
+ .map((b) =>
1048
+ typeof b === "string"
1049
+ ? b
1050
+ : b?.type === "text"
1051
+ ? (b.text ?? "")
1052
+ : b?.type
1053
+ ? `[${b.type}]`
1054
+ : "",
1055
+ )
1056
+ .filter(Boolean)
1057
+ .join("\n");
1058
+ };
1059
+
1060
+ function toolSummary(input) {
1061
+ if (!input || typeof input !== "object") return "";
1062
+ for (const k of [
1063
+ "command",
1064
+ "file_path",
1065
+ "path",
1066
+ "pattern",
1067
+ "url",
1068
+ "query",
1069
+ "description",
1070
+ "skill",
1071
+ ]) {
1072
+ if (typeof input[k] === "string" && input[k]) return input[k];
1073
+ }
1074
+ return JSON.stringify(input);
1075
+ }
1076
+
1077
+ /**
1078
+ * One page of a desk's transcript, newest page first, messages oldest first
1079
+ * within it.
1080
+ *
1081
+ * @param {{projectsRoot?: string, sessionId: string, agentId: string|null,
1082
+ * before?: number|null, limit?: number}} opts
1083
+ * @returns {Promise<object|null>} null when the transcript is not on disk
1084
+ */
1085
+ export async function agentChat({
1086
+ projectsRoot = defaultProjectsRoot(),
1087
+ sessionId,
1088
+ agentId = null,
1089
+ before = null,
1090
+ limit = 200,
1091
+ }) {
1092
+ const path = transcriptPath(projectsRoot, sessionId, agentId);
1093
+ if (!path) return null;
1094
+ const page = readTailPage(path, { before, limit, allow: CHAT_TYPES });
1095
+ const R = createRedactor();
1096
+ const guard = (text) => (scan(text).some((f) => f.certain) ? WITHHELD : text);
1097
+ const text = (v, limit = MAX_TEXT) =>
1098
+ guard(R.clean(String(v ?? "").slice(0, limit * 2)).slice(0, limit));
1099
+ const messages = [];
1100
+ for (const { offset, record } of page.records) {
1101
+ if (record.isMeta) continue;
1102
+ const c = record.message?.content;
1103
+ const blocks = [];
1104
+ if (typeof c === "string") {
1105
+ if (c.trim()) blocks.push({ kind: "text", text: text(c) });
1106
+ } else if (Array.isArray(c)) {
1107
+ for (const b of c) {
1108
+ if (b?.type === "text" && b.text?.trim()) blocks.push({ kind: "text", text: text(b.text) });
1109
+ else if (b?.type === "tool_use") {
1110
+ blocks.push({
1111
+ kind: "tool",
1112
+ name: String(b.name ?? "tool"),
1113
+ summary: guard(R.excerpt(toolSummary(b.input), 240)),
1114
+ toolUseId: typeof b.id === "string" ? b.id : null,
1115
+ });
1116
+ } else if (b?.type === "tool_result") {
1117
+ blocks.push({
1118
+ kind: "result",
1119
+ toolUseId: typeof b.tool_use_id === "string" ? b.tool_use_id : null,
1120
+ text: text(contentText(b.content), MAX_RESULT),
1121
+ isError: b.is_error === true,
1122
+ });
1123
+ } else if (b?.type === "image") blocks.push({ kind: "text", text: "[image]" });
1124
+ }
1125
+ }
1126
+ if (!blocks.length) continue;
1127
+ messages.push({
1128
+ offset,
1129
+ role: record.type === "assistant" ? "assistant" : "user",
1130
+ at: typeof record.timestamp === "string" ? record.timestamp : null,
1131
+ blocks,
1132
+ });
1133
+ }
1134
+ const out = {
1135
+ contractVersion: CONTRACT_VERSION,
1136
+ sessionId,
1137
+ agentId,
1138
+ messages,
1139
+ before: page.before,
1140
+ redacted: R.total(),
1141
+ };
1142
+ assertClean(JSON.stringify(out));
1143
+ return out;
1144
+ }
1145
+
1146
+ // ---------------------------------------------------------------------------
1147
+ // CLI
1148
+
1149
+ function renderHuman(doc) {
1150
+ const L = [];
1151
+ for (const room of doc.rooms) {
1152
+ L.push(room.repo);
1153
+ for (const t of room.tables) {
1154
+ const head = t.taskId ?? t.sessionId ?? t.id;
1155
+ const phase = t.phase ? ` Phase ${t.phase.id}/${t.phase.last} ${t.phase.name}` : "";
1156
+ L.push(` ${head}${phase}${t.waitingForYou ? " (waiting for you)" : ""}`);
1157
+ for (const s of t.seats) {
1158
+ L.push(` ${s.label}${s.modelLabel ? ` - ${s.modelLabel}` : ""}: ${s.doing}`);
1159
+ }
1160
+ }
1161
+ }
1162
+ if (!L.length) L.push("No agent activity in the recent window.");
1163
+ return L.join("\n") + "\n";
1164
+ }
1165
+
1166
+ function intArg(v, name) {
1167
+ if (!/^[0-9]{1,12}$/.test(String(v ?? ""))) throw new Error(`${name} must be a whole number`);
1168
+ return Number(v);
1169
+ }
1170
+
1171
+ async function main(argv) {
1172
+ if (argv[0] === "chat") {
1173
+ const [, sessionId, maybeAgent, ...rest] = argv;
1174
+ let agentId = null;
1175
+ const flags = [...rest];
1176
+ if (maybeAgent && !maybeAgent.startsWith("--")) agentId = maybeAgent;
1177
+ else if (maybeAgent) flags.unshift(maybeAgent);
1178
+ let before = null;
1179
+ let limit = 200;
1180
+ for (let i = 0; i < flags.length; i++) {
1181
+ if (flags[i] === "--before") before = intArg(flags[++i], "--before");
1182
+ else if (flags[i] === "--limit") limit = intArg(flags[++i], "--limit");
1183
+ else throw new Error(`unknown argument ${flags[i]}`);
1184
+ }
1185
+ const page = await agentChat({ sessionId, agentId, before, limit });
1186
+ if (!page) {
1187
+ process.stderr.write("activity-snapshot: no such session or agent\n");
1188
+ return 3;
1189
+ }
1190
+ process.stdout.write(JSON.stringify(page, null, 2) + "\n");
1191
+ return 0;
1192
+ }
1193
+ let json = false;
1194
+ let recentMs = RECENT_MS;
1195
+ for (let i = 0; i < argv.length; i++) {
1196
+ if (argv[i] === "--json") json = true;
1197
+ else if (argv[i] === "--recent-minutes")
1198
+ recentMs = intArg(argv[++i], "--recent-minutes") * 60_000;
1199
+ else throw new Error(`unknown argument ${argv[i]}`);
1200
+ }
1201
+ const doc = await activitySnapshot({ recentMs, sessions: null });
1202
+ process.stdout.write(json ? JSON.stringify(doc, null, 2) + "\n" : renderHuman(doc));
1203
+ return 0;
1204
+ }
1205
+
1206
+ if (invokedDirectly(import.meta.url)) {
1207
+ runMain(
1208
+ "activity-snapshot",
1209
+ async () => {
1210
+ try {
1211
+ process.exitCode = await main(process.argv.slice(2));
1212
+ } catch (err) {
1213
+ if (err?.code === "REDACTION") {
1214
+ process.stderr.write(`activity-snapshot: ${err.message}; nothing was printed\n`);
1215
+ process.exitCode = 4;
1216
+ return;
1217
+ }
1218
+ throw err;
1219
+ }
1220
+ },
1221
+ { code: 2 },
1222
+ );
1223
+ }