crewforth 0.0.1 → 3.0.0-rc.2

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 (169) hide show
  1. package/CHANGELOG.md +3805 -0
  2. package/LICENSE +21 -0
  3. package/README.md +37 -7
  4. package/VERSION +1 -0
  5. package/adopt.sh +1739 -0
  6. package/bin/cli.js +497 -0
  7. package/kit/AGENT_TEMPLATE.md +92 -0
  8. package/kit/CLAUDE.md +228 -0
  9. package/kit/README.md +107 -0
  10. package/kit/agents/crew-backend-expert.md +95 -0
  11. package/kit/agents/crew-commit-agent.md +96 -0
  12. package/kit/agents/crew-database-expert.md +84 -0
  13. package/kit/agents/crew-devops-expert.md +82 -0
  14. package/kit/agents/crew-frontend-expert.md +85 -0
  15. package/kit/agents/crew-performance-expert.md +78 -0
  16. package/kit/agents/crew-planner.md +60 -0
  17. package/kit/agents/crew-privacy-agent.md +71 -0
  18. package/kit/agents/crew-review-agent.md +95 -0
  19. package/kit/agents/crew-security-expert.md +73 -0
  20. package/kit/agents/crew-session-manager.md +77 -0
  21. package/kit/agents/crew-test-expert.md +64 -0
  22. package/kit/eval/doctor.sh +639 -0
  23. package/kit/eval/gate-report.sh +137 -0
  24. package/kit/eval/golden-holdout.txt +53 -0
  25. package/kit/eval/golden-routing.txt +199 -0
  26. package/kit/eval/lib/crew-env.sh +14 -0
  27. package/kit/eval/lib/settings-json.awk +154 -0
  28. package/kit/eval/lib/skill-listing.awk +43 -0
  29. package/kit/eval/lib/star.sh +70 -0
  30. package/kit/eval/parser-conformance.sh +612 -0
  31. package/kit/eval/preflight.sh +186 -0
  32. package/kit/eval/routing-eval.sh +209 -0
  33. package/kit/eval/scan-skill.sh +162 -0
  34. package/kit/eval/smoke-test.sh +6736 -0
  35. package/kit/eval/update-guard.sh +81 -0
  36. package/kit/eval/utilization.sh +130 -0
  37. package/kit/hooks/board-sync.sh +106 -0
  38. package/kit/hooks/board.sh +962 -0
  39. package/kit/hooks/commit-msg +49 -0
  40. package/kit/hooks/context-usage.sh +353 -0
  41. package/kit/hooks/floor-blocklist.txt +124 -0
  42. package/kit/hooks/guard-bash.sh +1433 -0
  43. package/kit/hooks/guard-commit-scan.sh +511 -0
  44. package/kit/hooks/guard-write.sh +513 -0
  45. package/kit/hooks/pre-commit +577 -0
  46. package/kit/hooks/route-hint.sh +185 -0
  47. package/kit/hooks/secret-blocklist.txt +82 -0
  48. package/kit/hooks/session-guard.sh +149 -0
  49. package/kit/hooks/session-rehydrate.sh +41 -0
  50. package/kit/hooks/session-stats.sh +252 -0
  51. package/kit/hooks/session-update-check.sh +236 -0
  52. package/kit/hooks/skill-trust.sh +163 -0
  53. package/kit/hooks/trace-blocklist.txt +53 -0
  54. package/kit/settings.json +132 -0
  55. package/kit/skills/a11y/SKILL.md +44 -0
  56. package/kit/skills/adr/SKILL.md +74 -0
  57. package/kit/skills/api-design/SKILL.md +50 -0
  58. package/kit/skills/automode-policy/SKILL.md +91 -0
  59. package/kit/skills/automode-policy/references/policy.json +13 -0
  60. package/kit/skills/automode-policy/scripts/apply.sh +84 -0
  61. package/kit/skills/automode-policy/scripts/check.sh +102 -0
  62. package/kit/skills/backend-architecture/SKILL.md +103 -0
  63. package/kit/skills/brainstorm/SKILL.md +47 -0
  64. package/kit/skills/ci-pipeline/SKILL.md +38 -0
  65. package/kit/skills/commit-message/SKILL.md +82 -0
  66. package/kit/skills/commit-message/references/examples.md +8 -0
  67. package/kit/skills/confidence-check/SKILL.md +86 -0
  68. package/kit/skills/crew-board/SKILL.md +55 -0
  69. package/kit/skills/crew-brainstorm/SKILL.md +19 -0
  70. package/kit/skills/crew-code-review/SKILL.md +151 -0
  71. package/kit/skills/crew-code-review/references/history-search.md +19 -0
  72. package/kit/skills/crew-code-review/references/panel-mode.md +32 -0
  73. package/kit/skills/crew-code-review/references/receiving.md +23 -0
  74. package/kit/skills/crew-doctor/SKILL.md +46 -0
  75. package/kit/skills/crew-gates/SKILL.md +25 -0
  76. package/kit/skills/crew-handoff/SKILL.md +14 -0
  77. package/kit/skills/crew-plan/SKILL.md +18 -0
  78. package/kit/skills/crew-review/SKILL.md +26 -0
  79. package/kit/skills/crew-ship/SKILL.md +19 -0
  80. package/kit/skills/crew-skill/SKILL.md +62 -0
  81. package/kit/skills/crew-studio/SKILL.md +129 -0
  82. package/kit/skills/crew-update/SKILL.md +43 -0
  83. package/kit/skills/db-migration/SKILL.md +120 -0
  84. package/kit/skills/db-migration/references/backup-restore.md +33 -0
  85. package/kit/skills/db-migration/references/raw-sql.md +30 -0
  86. package/kit/skills/db-migration/references/tool-matrix.md +35 -0
  87. package/kit/skills/dependency-audit/SKILL.md +61 -0
  88. package/kit/skills/dependency-upgrade/SKILL.md +82 -0
  89. package/kit/skills/deploy/SKILL.md +100 -0
  90. package/kit/skills/deploy/references/proxy-ssl.md +56 -0
  91. package/kit/skills/deploy/references/rollback.md +18 -0
  92. package/kit/skills/deploy/references/scripts.md +9 -0
  93. package/kit/skills/deploy/references/topology-a.md +128 -0
  94. package/kit/skills/deploy/references/topology-b.md +44 -0
  95. package/kit/skills/docs-writer/SKILL.md +53 -0
  96. package/kit/skills/docs-writer/references/coauthoring.md +34 -0
  97. package/kit/skills/eval-grader/SKILL.md +75 -0
  98. package/kit/skills/eval-grader/references/method.md +51 -0
  99. package/kit/skills/frontend/SKILL.md +56 -0
  100. package/kit/skills/frontend/references/verify-contract.md +41 -0
  101. package/kit/skills/frontend-design/SKILL.md +71 -0
  102. package/kit/skills/frontend-design/references/design-review.md +34 -0
  103. package/kit/skills/frontend-rn-expo/SKILL.md +25 -0
  104. package/kit/skills/handoff/SKILL.md +71 -0
  105. package/kit/skills/handoff/references/cold-reader.md +51 -0
  106. package/kit/skills/i18n-integrity/SKILL.md +31 -0
  107. package/kit/skills/incident-runbook/SKILL.md +40 -0
  108. package/kit/skills/incident-runbook/references/postmortem.md +16 -0
  109. package/kit/skills/iterate/SKILL.md +40 -0
  110. package/kit/skills/mcp-builder/SKILL.md +54 -0
  111. package/kit/skills/mcp-builder/references/building.md +71 -0
  112. package/kit/skills/observability/SKILL.md +50 -0
  113. package/kit/skills/performance/SKILL.md +38 -0
  114. package/kit/skills/performance/references/bottlenecks.md +10 -0
  115. package/kit/skills/privacy-compliance/SKILL.md +75 -0
  116. package/kit/skills/red-team/SKILL.md +48 -0
  117. package/kit/skills/reflect/SKILL.md +57 -0
  118. package/kit/skills/release/SKILL.md +44 -0
  119. package/kit/skills/security-scan/SKILL.md +99 -0
  120. package/kit/skills/security-scan/references/ai-agents.md +107 -0
  121. package/kit/skills/security-scan/references/fronts.md +99 -0
  122. package/kit/skills/security-scan/references/prompting.md +23 -0
  123. package/kit/skills/security-scan/references/reporting.md +75 -0
  124. package/kit/skills/security-scan/references/verify.md +131 -0
  125. package/kit/skills/sonarqube-check/SKILL.md +108 -0
  126. package/kit/skills/sonarqube-check/references/local-engines.md +32 -0
  127. package/kit/skills/sonarqube-check/references/stand-up-server.md +25 -0
  128. package/kit/skills/spec-planning/SKILL.md +92 -0
  129. package/kit/skills/systematic-debugging/SKILL.md +57 -0
  130. package/kit/skills/systematic-debugging/references/techniques.md +44 -0
  131. package/kit/skills/teamboard/SKILL.md +88 -0
  132. package/kit/skills/teamboard/references/setup.md +37 -0
  133. package/kit/skills/testing/SKILL.md +49 -0
  134. package/kit/skills/testing/references/flaky-triage.md +51 -0
  135. package/kit/skills/threat-model/SKILL.md +57 -0
  136. package/kit/skills/threat-model/references/interview.md +51 -0
  137. package/kit/skills/threat-model/references/schema.md +42 -0
  138. package/kit/skills/token-budget/SKILL.md +51 -0
  139. package/kit/skills/trace-scan/SKILL.md +43 -0
  140. package/kit/skills/worktree/SKILL.md +40 -0
  141. package/kit/studio/README.md +266 -0
  142. package/kit/studio/ensure-node.sh +471 -0
  143. package/kit/studio/package.json +14 -0
  144. package/kit/studio/server/hooks/studio-gate.sh +169 -0
  145. package/kit/studio/server/index.js +902 -0
  146. package/kit/studio/server/lib/crew-env.js +11 -0
  147. package/kit/studio/server/lib/fleet.js +146 -0
  148. package/kit/studio/server/lib/graph.js +533 -0
  149. package/kit/studio/server/lib/instance.js +135 -0
  150. package/kit/studio/server/lib/kit-telemetry.js +182 -0
  151. package/kit/studio/server/lib/kit.js +158 -0
  152. package/kit/studio/server/lib/palette.js +118 -0
  153. package/kit/studio/server/lib/peers.js +72 -0
  154. package/kit/studio/server/lib/permissions.js +150 -0
  155. package/kit/studio/server/lib/projects.js +294 -0
  156. package/kit/studio/server/lib/roster.js +191 -0
  157. package/kit/studio/server/lib/session.js +309 -0
  158. package/kit/studio/server/lib/terminal.js +91 -0
  159. package/kit/studio/server/lib/transcript.js +100 -0
  160. package/kit/studio/web/app.js +1098 -0
  161. package/kit/studio/web/canvas.js +1179 -0
  162. package/kit/studio/web/chat.js +543 -0
  163. package/kit/studio/web/index.html +86 -0
  164. package/kit/studio/web/md.js +110 -0
  165. package/kit/studio/web/storage-migrate.js +23 -0
  166. package/kit/studio/web/style.css +1453 -0
  167. package/package.json +46 -8
  168. package/start.sh +764 -0
  169. package/bin/crewforth.js +0 -10
@@ -0,0 +1,11 @@
1
+ // The ONE Node place that still reads the pre-3.0 CSK_* names — the twin of kit/eval/lib/crew-env.sh, same list,
2
+ // same rule: when CREW_<X> is not set at all and CSK_<X> is, CREW_<X> takes its value; a CREW_<X> that is set wins.
3
+ // Imported first by index.js, so every module after it only ever reads CREW_*. The old names go in 4.0.
4
+ const LEGACY = ['LANG', 'NO_STAR', 'NO_UPDATE_CHECK', 'NO_BOARD', 'GATE_LOG', 'GATE_LOG_CMD', 'STUDIO_TOKEN',
5
+ 'STUDIO_PEERS', 'STUDIO_RUNTIME', 'MAX_FILE_BYTES', 'ALLOW_SOURCE_INSTALL'];
6
+ for (const name of LEGACY) {
7
+ if (process.env[`CREW_${name}`] === undefined && process.env[`CSK_${name}`] !== undefined) {
8
+ process.env[`CREW_${name}`] = process.env[`CSK_${name}`];
9
+ }
10
+ }
11
+ export const LEGACY_ENV = LEGACY;
@@ -0,0 +1,146 @@
1
+ // Fleet: the list of Claude Code sessions alive on this machine.
2
+ //
3
+ // `claude agents --json` is the only surface that reports interactive sessions
4
+ // without a TTY. It spawns a process per call, so every read is cached; the
5
+ // cost of that spawn is measured at startup rather than assumed (a 1 s poll is
6
+ // free on a fast machine and a tax on a slow one).
7
+ //
8
+ // Nothing here throws. A missing CLI is a legitimate answer — "not measured" —
9
+ // and it is reported as such, never as an empty fleet. Those are different
10
+ // facts and the UI draws them differently.
11
+
12
+ import { execFile } from 'node:child_process';
13
+
14
+ const CACHE_MS = 1000;
15
+ const EXEC_TIMEOUT_MS = 10000;
16
+ const MAX_BUFFER = 4 * 1024 * 1024;
17
+
18
+ let cache = null; // { at: epochMs, value: FleetResult }
19
+ let inflight = null; // Promise, so N concurrent readers cause 1 spawn
20
+
21
+ /**
22
+ * @typedef {Object} FleetResult
23
+ * @property {boolean} measured false means nothing was read, NOT "nothing is running"
24
+ * @property {string} [reason] why it could not be measured
25
+ * @property {Array} sessions normalised session rows (empty when !measured)
26
+ * @property {number} at epoch ms of the underlying read
27
+ * @property {number} [spawnMs] how long the spawn actually took
28
+ */
29
+
30
+ function run(cmd, args) {
31
+ return new Promise((resolve) => {
32
+ const started = Date.now();
33
+ execFile(
34
+ cmd,
35
+ args,
36
+ { timeout: EXEC_TIMEOUT_MS, maxBuffer: MAX_BUFFER, windowsHide: true },
37
+ (err, stdout, stderr) => {
38
+ resolve({
39
+ err,
40
+ stdout: stdout ?? '',
41
+ stderr: stderr ?? '',
42
+ ms: Date.now() - started,
43
+ });
44
+ },
45
+ );
46
+ });
47
+ }
48
+
49
+ // A row we do not recognise is still a row. Unknown fields are carried through
50
+ // untouched so a CLI upgrade adds information rather than silently dropping it.
51
+ function normalise(row) {
52
+ if (!row || typeof row !== 'object') return null;
53
+ const id = row.sessionId ?? row.session_id ?? null;
54
+ if (!id) return null;
55
+ return {
56
+ sessionId: id,
57
+ pid: typeof row.pid === 'number' ? row.pid : null,
58
+ cwd: typeof row.cwd === 'string' ? row.cwd : null,
59
+ kind: typeof row.kind === 'string' ? row.kind : 'unknown',
60
+ name: typeof row.name === 'string' ? row.name : null,
61
+ status: typeof row.status === 'string' ? row.status : 'unknown',
62
+ // Present when the session is blocked on a human. Shown verbatim — the CLI
63
+ // phrases it better than a re-worded guess would.
64
+ waitingFor: typeof row.waitingFor === 'string' ? row.waitingFor : null,
65
+ startedAt: typeof row.startedAt === 'number' ? row.startedAt : null,
66
+ raw: row,
67
+ };
68
+ }
69
+
70
+ async function read() {
71
+ const { err, stdout, stderr, ms } = await run('claude', ['agents', '--json']);
72
+
73
+ if (err) {
74
+ // ENOENT is the honest "no CLI here" case; anything else is a broken read.
75
+ const reason =
76
+ err.code === 'ENOENT'
77
+ ? 'claude CLI not found on PATH'
78
+ : `claude agents --json failed (${err.code ?? err.message})${
79
+ stderr.trim() ? `: ${stderr.trim().slice(0, 200)}` : ''
80
+ }`;
81
+ return { measured: false, reason, sessions: [], at: Date.now(), spawnMs: ms };
82
+ }
83
+
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(stdout);
87
+ } catch {
88
+ return {
89
+ measured: false,
90
+ reason: 'claude agents --json returned output that is not JSON',
91
+ sessions: [],
92
+ at: Date.now(),
93
+ spawnMs: ms,
94
+ };
95
+ }
96
+
97
+ if (!Array.isArray(parsed)) {
98
+ return {
99
+ measured: false,
100
+ reason: `expected a JSON array, got ${typeof parsed}`,
101
+ sessions: [],
102
+ at: Date.now(),
103
+ spawnMs: ms,
104
+ };
105
+ }
106
+
107
+ return {
108
+ measured: true,
109
+ sessions: parsed.map(normalise).filter(Boolean),
110
+ at: Date.now(),
111
+ spawnMs: ms,
112
+ };
113
+ }
114
+
115
+ /** Cached fleet read. Concurrent callers share one spawn. */
116
+ export async function getFleet({ force = false } = {}) {
117
+ if (!force && cache && Date.now() - cache.at < CACHE_MS) return cache.value;
118
+ if (inflight) return inflight;
119
+
120
+ inflight = read()
121
+ .then((value) => {
122
+ cache = { at: Date.now(), value };
123
+ return value;
124
+ })
125
+ .finally(() => {
126
+ inflight = null;
127
+ });
128
+
129
+ return inflight;
130
+ }
131
+
132
+ /** Measure what one spawn actually costs here, so the poll interval is a
133
+ * measurement rather than a guess. Returns null when the CLI is absent. */
134
+ export async function measureSpawnCost(samples = 3) {
135
+ const runs = [];
136
+ for (let i = 0; i < samples; i += 1) {
137
+ const r = await getFleet({ force: true });
138
+ if (!r.measured && r.reason?.includes('not found')) return null;
139
+ if (typeof r.spawnMs === 'number') runs.push(r.spawnMs);
140
+ }
141
+ if (!runs.length) return null;
142
+ runs.sort((a, b) => a - b);
143
+ return { samples: runs.length, minMs: runs[0], medianMs: runs[Math.floor(runs.length / 2)], maxMs: runs[runs.length - 1] };
144
+ }
145
+
146
+ export const _internals = { normalise, CACHE_MS };
@@ -0,0 +1,533 @@
1
+ // The orchestration graph: one node per agent, one edge per spawn.
2
+ //
3
+ // Nothing here is documented by Claude Code, so every field was read off real
4
+ // transcripts and every one of them is optional. A record shape that changes
5
+ // should cost this file a field, never a crash — unknown values are carried,
6
+ // not coerced.
7
+ //
8
+ // The chain that makes the graph possible, measured:
9
+ // main transcript assistant → content[].tool_use{ name:"Agent", id, input }
10
+ // main transcript user → toolUseResult{ agentId, status, resolvedModel }
11
+ // on disk subagents/agent-<agentId>.meta.json{ agentType, toolUseId, spawnDepth }
12
+ // main transcript text → <task-id>…</task-id> + <status>completed</status>
13
+ //
14
+ // `toolUseId` is the join. Whoever emitted that tool_use is the parent: the
15
+ // session itself for depth 1, another agent for anything deeper.
16
+
17
+ import fsp from 'node:fs/promises';
18
+ import path from 'node:path';
19
+
20
+ import { readAll, contextFill } from './transcript.js';
21
+ import { agentMetaFiles } from './projects.js';
22
+
23
+ const SESSION_NODE = 'session';
24
+
25
+ function textOf(content) {
26
+ if (typeof content === 'string') return content;
27
+ if (!Array.isArray(content)) return '';
28
+ let s = '';
29
+ for (const c of content) if (c?.type === 'text' && typeof c.text === 'string') s += c.text;
30
+ return s;
31
+ }
32
+
33
+ /**
34
+ * Completion notices arrive as prose, so they are read as prose.
35
+ *
36
+ * Claude Code sometimes batches several ids under one status. A regex that
37
+ * pairs the first id with the status swallows the rest; measured on this
38
+ * machine, one notice carried six ids and five were being dropped. Every id
39
+ * seen since the previous status takes that status.
40
+ */
41
+ function harvestCompletions(text, into) {
42
+ if (!text || !text.includes('<task-id>')) return;
43
+ const token = /<task-id>([^<]+)<\/task-id>|<status>([^<]+)<\/status>/g;
44
+ let pending = [];
45
+ let m;
46
+ while ((m = token.exec(text)) !== null) {
47
+ if (m[1] !== undefined) {
48
+ pending.push(m[1].trim());
49
+ } else {
50
+ const status = m[2].trim();
51
+ for (const id of pending) into.set(id, status);
52
+ pending = [];
53
+ }
54
+ }
55
+ }
56
+
57
+ function scanMain(records) {
58
+ const out = {
59
+ cwd: null, gitBranch: null, version: null, model: null,
60
+ startedAt: null, updatedAt: null,
61
+ agentCalls: new Map(), // toolUseId -> { subagentType, description }
62
+ links: new Map(), // toolUseId -> { agentId, status, model }
63
+ completions: new Map(), // agentId -> status
64
+ owners: new Map(), // toolUseId -> owner node id
65
+ userTurns: 0,
66
+ };
67
+
68
+ for (const r of records) {
69
+ if (r?.isSidechain === true) continue;
70
+
71
+ if (r?.cwd && !out.cwd) out.cwd = r.cwd;
72
+ if (r?.gitBranch && !out.gitBranch) out.gitBranch = r.gitBranch;
73
+ if (r?.version && !out.version) out.version = r.version;
74
+ if (r?.timestamp) {
75
+ const t = Date.parse(r.timestamp);
76
+ if (Number.isFinite(t)) {
77
+ if (out.startedAt === null || t < out.startedAt) out.startedAt = t;
78
+ if (out.updatedAt === null || t > out.updatedAt) out.updatedAt = t;
79
+ }
80
+ }
81
+
82
+ if (r?.type === 'assistant') {
83
+ if (r.message?.model && !out.model) out.model = r.message.model;
84
+ const content = r.message?.content;
85
+ if (Array.isArray(content)) {
86
+ for (const c of content) {
87
+ if (c?.type === 'tool_use' && c.id) {
88
+ out.owners.set(c.id, SESSION_NODE);
89
+ if (c.name === 'Agent') {
90
+ out.agentCalls.set(c.id, {
91
+ subagentType: c.input?.subagent_type ?? null,
92
+ description: c.input?.description ?? null,
93
+ });
94
+ }
95
+ }
96
+ }
97
+ }
98
+ harvestCompletions(textOf(content), out.completions);
99
+ }
100
+
101
+ if (r?.type === 'user') {
102
+ out.userTurns += 1;
103
+ harvestCompletions(textOf(r.message?.content), out.completions);
104
+ }
105
+
106
+ // Present on the record that carries a subagent's result, whatever its type.
107
+ const tur = r?.toolUseResult;
108
+ if (tur && typeof tur === 'object' && tur.agentId) {
109
+ const id = r.message?.content?.find?.((c) => c?.type === 'tool_result')?.tool_use_id
110
+ ?? r.toolUseID ?? null;
111
+ const entry = { agentId: tur.agentId, status: tur.status ?? null, model: tur.resolvedModel ?? null };
112
+ if (id) out.links.set(id, entry);
113
+ else out.links.set(`agent:${tur.agentId}`, entry);
114
+ }
115
+ if (r?.type === 'attachment') harvestCompletions(JSON.stringify(r.attachment ?? ''), out.completions);
116
+ }
117
+
118
+ return out;
119
+ }
120
+
121
+ /** Roll one agent's own transcript into the numbers its node shows. */
122
+ async function scanAgent(file) {
123
+ const { records } = await readAll(file);
124
+ const stats = {
125
+ tools: {}, toolCount: 0, lastTool: null, errors: 0,
126
+ tokens: null, startedAt: null, endedAt: null, turns: 0,
127
+ owns: [], // tool_use ids this agent emitted — how nesting is resolved
128
+ };
129
+
130
+ for (const r of records) {
131
+ if (r?.timestamp) {
132
+ const t = Date.parse(r.timestamp);
133
+ if (Number.isFinite(t)) {
134
+ if (stats.startedAt === null || t < stats.startedAt) stats.startedAt = t;
135
+ if (stats.endedAt === null || t > stats.endedAt) stats.endedAt = t;
136
+ }
137
+ }
138
+ if (r?.type === 'assistant') {
139
+ stats.turns += 1;
140
+ const u = r.message?.usage;
141
+ if (u && u.cache_read_input_tokens != null) {
142
+ stats.tokens = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
143
+ }
144
+ const content = r.message?.content;
145
+ if (Array.isArray(content)) {
146
+ for (const c of content) {
147
+ if (c?.type !== 'tool_use') continue;
148
+ const n = c.name ?? 'unknown';
149
+ stats.tools[n] = (stats.tools[n] ?? 0) + 1;
150
+ stats.toolCount += 1;
151
+ stats.lastTool = n;
152
+ if (c.id) stats.owns.push(c.id);
153
+ }
154
+ }
155
+ }
156
+ if (r?.type === 'user') {
157
+ const content = r.message?.content;
158
+ if (Array.isArray(content)) {
159
+ for (const c of content) if (c?.type === 'tool_result' && c.is_error === true) stats.errors += 1;
160
+ }
161
+ }
162
+ }
163
+
164
+ stats.durationMs = stats.startedAt !== null && stats.endedAt !== null
165
+ ? stats.endedAt - stats.startedAt
166
+ : null;
167
+ return stats;
168
+ }
169
+
170
+ async function readAgentDir(subagentsDir) {
171
+ const out = [];
172
+ // Nested too: a workflow puts its agents under subagents/workflows/<id>/.
173
+ for (const metaPath of await agentMetaFiles(subagentsDir)) {
174
+ const name = path.basename(metaPath);
175
+ const dir = path.dirname(metaPath);
176
+ if (!name.startsWith('agent-')) continue;
177
+ const agentId = name.slice('agent-'.length, -'.meta.json'.length);
178
+ let meta = {};
179
+ try { meta = JSON.parse(await fsp.readFile(metaPath, 'utf8')); } catch { /* keep going */ }
180
+
181
+ const jsonl = path.join(dir, `agent-${agentId}.jsonl`);
182
+ let mtime = null;
183
+ try { mtime = (await fsp.stat(jsonl)).mtimeMs; } catch { /* not written yet */ }
184
+
185
+ // `stats` needs the transcript read; a missing file is a normal state (the
186
+ // agent has not written yet), so absence is a null rather than a throw.
187
+ let stats = null;
188
+ if (mtime !== null) stats = await scanAgent(jsonl);
189
+
190
+ out.push({
191
+ agentId,
192
+ agentType: meta.agentType ?? null,
193
+ description: meta.description ?? null,
194
+ toolUseId: meta.toolUseId ?? null,
195
+ spawnDepth: typeof meta.spawnDepth === 'number' ? meta.spawnDepth : 1,
196
+ jsonl,
197
+ mtime,
198
+ // A workflow agent belongs to the run that spawned it, not the session
199
+ // root; without this they all hang off the root as one flat fan.
200
+ workflow: dir === subagentsDir ? null : path.basename(dir),
201
+ stats,
202
+ });
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /**
208
+ * Build the graph for one session.
209
+ * `staleMs` decides when an agent with no completion notice is called stale
210
+ * rather than running — an unfinished agent whose file stopped growing is a
211
+ * different fact from one that is working.
212
+ */
213
+ /** How long an unfinished agent may stay quiet before it is called stale
214
+ * rather than running. Exported because index.js's stream signature has to
215
+ * know the same number: a status that changes on the clock alone has to be
216
+ * re-asked for while it can still change, and two copies of this window
217
+ * would drift into a panel that never updates or one that never settles. */
218
+ export const STALE_MS = 120000;
219
+
220
+ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
221
+ const { records, malformed } = await readAll(session.file);
222
+ const main = scanMain(records);
223
+ const agents = await readAgentDir(session.subagentsDir);
224
+ const now = Date.now();
225
+
226
+ // Ownership: session first, then every agent's own tool_use ids, so a nested
227
+ // agent attaches to its real parent instead of the root.
228
+ const owners = new Map(main.owners);
229
+ for (const a of agents) {
230
+ for (const id of a.stats?.owns ?? []) owners.set(id, a.agentId);
231
+ }
232
+
233
+ // toolUseId -> agentId, from the returning result record.
234
+ const byToolUse = new Map();
235
+ for (const [k, v] of main.links) if (!k.startsWith('agent:')) byToolUse.set(k, v);
236
+
237
+ const nodes = [{
238
+ id: SESSION_NODE,
239
+ kind: 'session',
240
+ label: session.sessionId.slice(0, 8),
241
+ sessionId: session.sessionId,
242
+ cwd: main.cwd,
243
+ gitBranch: main.gitBranch,
244
+ model: main.model,
245
+ version: main.version,
246
+ status: 'session',
247
+ turns: main.userTurns,
248
+ tokens: contextFill(records),
249
+ startedAt: main.startedAt,
250
+ endedAt: main.updatedAt,
251
+ }];
252
+ const edges = [];
253
+
254
+ for (const a of agents) {
255
+ const call = a.toolUseId ? main.agentCalls.get(a.toolUseId) : null;
256
+ const link = a.toolUseId ? byToolUse.get(a.toolUseId) : null;
257
+ const completion = main.completions.get(a.agentId) ?? null;
258
+
259
+ // Order matters. A synchronous Agent call never produces a task
260
+ // notification — it reports through toolUseResult.status instead. Reading
261
+ // the notice first and falling back to file freshness labelled 87 of 272
262
+ // finished agents "stale" on this machine; the transcript had said
263
+ // "completed" all along.
264
+ let status;
265
+ if (completion === 'completed') status = 'done';
266
+ else if (completion) status = completion; // failed / killed / stopped, carried as-is
267
+ else if (link?.status === 'completed') status = 'done';
268
+ else if (a.mtime === null) status = 'starting';
269
+ else if (now - a.mtime < staleMs) status = 'running';
270
+ // "Stale" only means something while the session is still being written:
271
+ // an agent that went quiet under a live session may be stuck. In a session
272
+ // that itself finished long ago, the agent simply ended and no notice was
273
+ // recorded — calling that stale would raise an alarm about history.
274
+ else if (main.updatedAt !== null && main.updatedAt - a.mtime > staleMs) status = 'ended';
275
+ else status = 'stale';
276
+
277
+ const parentId = a.toolUseId ? (owners.get(a.toolUseId) ?? SESSION_NODE) : SESSION_NODE;
278
+
279
+ nodes.push({
280
+ id: a.agentId,
281
+ kind: 'agent',
282
+ label: a.agentType ?? call?.subagentType ?? 'agent',
283
+ agentType: a.agentType ?? call?.subagentType ?? null,
284
+ description: a.description ?? call?.description ?? null,
285
+ status,
286
+ spawnDepth: a.spawnDepth,
287
+ workflow: a.workflow,
288
+ toolUseId: a.toolUseId,
289
+ model: link?.model ?? null,
290
+ launchStatus: link?.status ?? null,
291
+ parentId,
292
+ tools: a.stats?.tools ?? {},
293
+ toolCount: a.stats?.toolCount ?? 0,
294
+ lastTool: a.stats?.lastTool ?? null,
295
+ errors: a.stats?.errors ?? 0,
296
+ turns: a.stats?.turns ?? 0,
297
+ tokens: a.stats?.tokens ?? null,
298
+ startedAt: a.stats?.startedAt ?? null,
299
+ endedAt: a.stats?.endedAt ?? null,
300
+ durationMs: a.stats?.durationMs ?? null,
301
+ updatedAt: a.mtime,
302
+ });
303
+
304
+ edges.push({ id: `${parentId}->${a.agentId}`, source: parentId, target: a.agentId, kind: 'spawn' });
305
+ }
306
+
307
+ // A workflow run is a real container, not a rendering trick: its agents were
308
+ // spawned by one orchestration script, not by the session directly. Hanging
309
+ // 243 of them straight off the root produced a graph nobody could read, and
310
+ // it was also the wrong shape.
311
+ const wfGroups = new Map();
312
+ for (const n of nodes) {
313
+ if (n.kind !== 'agent' || !n.workflow) continue;
314
+ if (!wfGroups.has(n.workflow)) wfGroups.set(n.workflow, []);
315
+ wfGroups.get(n.workflow).push(n);
316
+ }
317
+
318
+ for (const [wfId, members] of wfGroups) {
319
+ const id = `wf:${wfId}`;
320
+ const byStatusIn = {};
321
+ let tokens = 0;
322
+ let tools = 0;
323
+ let startedAt = null;
324
+ let endedAt = null;
325
+ for (const m of members) {
326
+ byStatusIn[m.status] = (byStatusIn[m.status] ?? 0) + 1;
327
+ tokens += m.tokens ?? 0;
328
+ tools += m.toolCount ?? 0;
329
+ if (m.startedAt != null) startedAt = startedAt === null ? m.startedAt : Math.min(startedAt, m.startedAt);
330
+ if (m.endedAt != null) endedAt = endedAt === null ? m.endedAt : Math.max(endedAt, m.endedAt);
331
+ m.parentId = id;
332
+ m.spawnDepth = (m.spawnDepth ?? 1) + 1;
333
+ }
334
+ const running = byStatusIn.running ?? 0;
335
+
336
+ nodes.push({
337
+ id,
338
+ kind: 'workflow',
339
+ label: 'workflow',
340
+ workflowId: wfId,
341
+ description: `${members.length} agents`,
342
+ status: running ? 'running' : 'done',
343
+ members: members.length,
344
+ byStatus: byStatusIn,
345
+ tokens,
346
+ toolCount: tools,
347
+ startedAt,
348
+ endedAt,
349
+ durationMs: startedAt !== null && endedAt !== null ? endedAt - startedAt : null,
350
+ spawnDepth: 1,
351
+ parentId: SESSION_NODE,
352
+ });
353
+ edges.push({ id: `${SESSION_NODE}->${id}`, source: SESSION_NODE, target: id, kind: 'spawn' });
354
+ }
355
+
356
+ // Edges follow the reparenting.
357
+ for (const e of edges) {
358
+ const target = nodes.find((n) => n.id === e.target);
359
+ if (target && target.kind === 'agent' && target.parentId !== e.source) {
360
+ e.source = target.parentId;
361
+ e.id = `${e.source}->${e.target}`;
362
+ }
363
+ }
364
+
365
+ const count = (s) => nodes.filter((n) => n.kind === 'agent' && n.status === s).length;
366
+ // Every status is counted, so the parts always add up to the whole. A
367
+ // summary that quietly drops "failed" reads as a clean run.
368
+ const byStatus = {};
369
+ for (const n of nodes) if (n.kind === 'agent') byStatus[n.status] = (byStatus[n.status] ?? 0) + 1;
370
+
371
+ return {
372
+ sessionId: session.sessionId,
373
+ cwd: main.cwd,
374
+ gitBranch: main.gitBranch,
375
+ model: main.model,
376
+ version: main.version,
377
+ updatedAt: main.updatedAt,
378
+ contextTokens: contextFill(records),
379
+ nodes,
380
+ edges,
381
+ stats: {
382
+ agents: agents.length,
383
+ workflows: wfGroups.size,
384
+ running: count('running'),
385
+ done: count('done'),
386
+ stale: count('stale'),
387
+ ended: count('ended'),
388
+ failed: count('failed') + count('killed') + count('stopped'),
389
+ byStatus,
390
+ records: records.length,
391
+ malformed, // surfaced, never swallowed
392
+ },
393
+ };
394
+ }
395
+
396
+ export const _internals = { scanMain, harvestCompletions, SESSION_NODE };
397
+
398
+ /**
399
+ * One agent's work in full: what it was asked, what it did, what it reported.
400
+ *
401
+ * Deliberately not part of buildGraph — a finished report runs to tens of
402
+ * thousands of characters and the graph is pushed down an SSE stream every
403
+ * time a file changes. This is fetched when someone opens a node.
404
+ */
405
+ export async function agentDetail(session, agentId) {
406
+ if (!/^[A-Za-z0-9_-]+$/.test(agentId)) return null;
407
+
408
+ const file = path.join(session.subagentsDir, `agent-${agentId}.jsonl`);
409
+ try { await fsp.stat(file); } catch { return null; }
410
+
411
+ const { records, malformed } = await readAll(file);
412
+
413
+ let meta = {};
414
+ try {
415
+ meta = JSON.parse(await fsp.readFile(path.join(session.subagentsDir, `agent-${agentId}.meta.json`), 'utf8'));
416
+ } catch { /* the transcript is still the source of truth */ }
417
+
418
+ const timeline = [];
419
+ const texts = []; // every text block, in order
420
+ let prompt = null;
421
+ let lastThinking = null;
422
+
423
+ for (const r of records) {
424
+ const at = r?.timestamp ? Date.parse(r.timestamp) : null;
425
+
426
+ if (r?.type === 'user' && prompt === null) {
427
+ const c = r.message?.content;
428
+ const t = typeof c === 'string' ? c : (Array.isArray(c) ? c.find((x) => x?.type === 'text')?.text : null);
429
+ if (t) prompt = t;
430
+ }
431
+
432
+ if (r?.type !== 'assistant') continue;
433
+ const content = r.message?.content;
434
+ if (!Array.isArray(content)) continue;
435
+
436
+ for (const c of content) {
437
+ if (c?.type === 'text' && typeof c.text === 'string' && c.text.trim()) {
438
+ texts.push({ at, text: c.text });
439
+ } else if (c?.type === 'thinking' && typeof c.thinking === 'string') {
440
+ lastThinking = c.thinking;
441
+ } else if (c?.type === 'tool_use') {
442
+ // Bash carries a written description; other tools describe themselves
443
+ // through their most identifying input.
444
+ const i = c.input ?? {};
445
+ const label = i.description ?? i.file_path ?? i.pattern ?? i.query ?? i.command ?? i.skill ?? null;
446
+ timeline.push({
447
+ at,
448
+ name: c.name ?? 'unknown',
449
+ label: label ? String(label).slice(0, 160) : null,
450
+ });
451
+ }
452
+ }
453
+ }
454
+
455
+ // The report is the last text block the agent produced. Earlier blocks are
456
+ // narration between tool calls, which is progress rather than conclusion.
457
+ const report = texts.length ? texts[texts.length - 1].text : null;
458
+
459
+ return {
460
+ agentId,
461
+ agentType: meta.agentType ?? null,
462
+ description: meta.description ?? null,
463
+ spawnDepth: meta.spawnDepth ?? null,
464
+ prompt,
465
+ report,
466
+ // Kept apart so the UI can show progress for an agent that has not
467
+ // reported yet, without pretending the narration is a conclusion.
468
+ narration: texts.slice(0, -1).map((t) => t.text),
469
+ thinking: lastThinking,
470
+ timeline,
471
+ records: records.length,
472
+ malformed,
473
+ };
474
+ }
475
+
476
+
477
+ /**
478
+ * The conversation a transcript holds, as the panel renders conversations.
479
+ *
480
+ * Needed because a resumed session starts a fresh stream: it remembers what was
481
+ * said — it will answer questions about it — but the panel had nothing to draw,
482
+ * so continuing a conversation looked exactly like starting one.
483
+ *
484
+ * Sidechains are skipped: a subagent's exchange belongs to the graph, not to
485
+ * the conversation the person had.
486
+ */
487
+ export async function conversation(session, { limit = 400 } = {}) {
488
+ const { records } = await readAll(session.file);
489
+ const out = [];
490
+
491
+ for (const r of records) {
492
+ if (r?.isSidechain === true) continue;
493
+ if (r?.parent_tool_use_id) continue;
494
+ const at = r?.timestamp ? Date.parse(r.timestamp) : null;
495
+
496
+ if (r?.type === 'user') {
497
+ const c = r.message?.content;
498
+ const text = typeof c === 'string'
499
+ ? c
500
+ : Array.isArray(c) ? c.filter((x) => x?.type === 'text').map((x) => x.text).join('') : '';
501
+ // Command envelopes and tool results are machinery, not what was said.
502
+ if (!text.trim()) continue;
503
+ if (/^<(command-name|command-message|local-command|task-notification)/.test(text.trim())) continue;
504
+ out.push({ role: 'user', at, text });
505
+ continue;
506
+ }
507
+
508
+ if (r?.type === 'assistant' && Array.isArray(r.message?.content)) {
509
+ const blocks = [];
510
+ for (const c of r.message.content) {
511
+ if (c?.type === 'text' && c.text?.trim()) blocks.push({ kind: 'text', text: c.text });
512
+ else if (c?.type === 'tool_use') {
513
+ const i = c.input ?? {};
514
+ blocks.push({
515
+ kind: 'tool',
516
+ name: c.name ?? 'tool',
517
+ label: i.description ?? i.file_path ?? i.command ?? i.pattern ?? i.query ?? null,
518
+ });
519
+ }
520
+ }
521
+ if (blocks.length) out.push({ role: 'assistant', at, blocks });
522
+ }
523
+ }
524
+
525
+ // The tail: a long history is the part nearest the continuation that matters,
526
+ // and the count says what was left out rather than hiding it.
527
+ return {
528
+ measured: true,
529
+ total: out.length,
530
+ truncated: out.length > limit,
531
+ messages: out.slice(-limit),
532
+ };
533
+ }