@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.
- package/CHANGELOG.md +6 -0
- package/README.md +5 -5
- package/README.tr.md +5 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -7
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +10 -1
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/manifest.json +149 -48
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
- package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
- package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
- package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/contract/CHANGELOG.md +62 -0
- package/pipeline/contract/README.md +52 -6
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/manifest.json +367 -22
- package/pipeline/contract/types/index.d.ts +530 -3
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
- package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
- package/pipeline/multi-agent-refs/help/en.md +3 -0
- package/pipeline/multi-agent-refs/help/tr.md +3 -0
- package/pipeline/office/NOTICE.md +42 -0
- package/pipeline/office/assets/BIN.png +0 -0
- package/pipeline/office/assets/BOOKSHELF.png +0 -0
- package/pipeline/office/assets/CACTUS.png +0 -0
- package/pipeline/office/assets/CLOCK.png +0 -0
- package/pipeline/office/assets/COFFEE.png +0 -0
- package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
- package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/DESK_FRONT.png +0 -0
- package/pipeline/office/assets/DESK_SIDE.png +0 -0
- package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
- package/pipeline/office/assets/HANGING_PLANT.png +0 -0
- package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
- package/pipeline/office/assets/LARGE_PLANT.png +0 -0
- package/pipeline/office/assets/PC_BACK.png +0 -0
- package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
- package/pipeline/office/assets/PC_SIDE.png +0 -0
- package/pipeline/office/assets/PLANT.png +0 -0
- package/pipeline/office/assets/PLANT_2.png +0 -0
- package/pipeline/office/assets/POT.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
- package/pipeline/office/assets/SOFA_BACK.png +0 -0
- package/pipeline/office/assets/SOFA_FRONT.png +0 -0
- package/pipeline/office/assets/SOFA_SIDE.png +0 -0
- package/pipeline/office/assets/TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/WHITEBOARD.png +0 -0
- package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/char_0.png +0 -0
- package/pipeline/office/assets/char_1.png +0 -0
- package/pipeline/office/assets/char_2.png +0 -0
- package/pipeline/office/assets/char_3.png +0 -0
- package/pipeline/office/assets/char_4.png +0 -0
- package/pipeline/office/assets/char_5.png +0 -0
- package/pipeline/office/assets/floor_0.png +0 -0
- package/pipeline/office/assets/floor_1.png +0 -0
- package/pipeline/office/assets/floor_2.png +0 -0
- package/pipeline/office/assets/floor_3.png +0 -0
- package/pipeline/office/assets/floor_4.png +0 -0
- package/pipeline/office/assets/floor_5.png +0 -0
- package/pipeline/office/assets/floor_6.png +0 -0
- package/pipeline/office/assets/floor_7.png +0 -0
- package/pipeline/office/assets/floor_8.png +0 -0
- package/pipeline/office/assets/wall_0.png +0 -0
- package/pipeline/office/index.html +5319 -0
- package/pipeline/schemas/activity-chat.schema.json +78 -0
- package/pipeline/schemas/activity-roles.json +48 -0
- package/pipeline/schemas/activity-roles.schema.json +49 -0
- package/pipeline/schemas/activity.schema.json +429 -0
- package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
- package/pipeline/schemas/autopilot-on.schema.json +111 -0
- package/pipeline/schemas/contract-error.schema.json +33 -3
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/prefs.schema.json +19 -0
- package/pipeline/schemas/prs.schema.json +73 -0
- package/pipeline/schemas/review-verdict-request.schema.json +20 -0
- package/pipeline/schemas/review-verdict.schema.json +62 -0
- package/pipeline/schemas/routine-delete.schema.json +59 -0
- package/pipeline/schemas/routines.schema.json +38 -0
- package/pipeline/schemas/run-evidence.schema.json +29 -0
- package/pipeline/schemas/run-results.schema.json +218 -0
- package/pipeline/schemas/service-read.schema.json +58 -0
- package/pipeline/schemas/stack-change.schema.json +82 -0
- package/pipeline/schemas/stack-request.schema.json +33 -0
- package/pipeline/schemas/stack.schema.json +59 -0
- package/pipeline/schemas/steer-request.schema.json +25 -0
- package/pipeline/schemas/steer.schema.json +59 -0
- package/pipeline/schemas/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +60 -0
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +73 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1027 -0
- package/pipeline/scripts/autopilot-control.mjs +214 -9
- package/pipeline/scripts/contract-server.mjs +1178 -48
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -0
- package/pipeline/scripts/office-build.mjs +89 -0
- package/pipeline/scripts/office-control.mjs +383 -0
- package/pipeline/scripts/office-ledger.mjs +409 -0
- package/pipeline/scripts/routine-registry.mjs +42 -6
- package/pipeline/scripts/run-results.mjs +373 -0
- package/pipeline/scripts/service-read.mjs +244 -0
- package/pipeline/scripts/stack-toggle.mjs +285 -0
- package/pipeline/scripts/steer-run.mjs +97 -0
- package/pipeline/scripts/toolkit-tools.mjs +599 -0
- package/pipeline/scripts/uninstall.mjs +3 -0
- package/pipeline/scripts/work-items.mjs +304 -0
- package/pipeline/skills/.skill-manifest.json +19 -7
- package/pipeline/skills/.skills-index.json +34 -1
- package/pipeline/skills/shared/README.md +6 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
- package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- 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
|
+
}
|