crewforth 3.1.0-rc.5 → 3.1.0-rc.7

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 (57) hide show
  1. package/CHANGELOG.md +76 -12
  2. package/README.md +3 -2
  3. package/kit/AGENT_TEMPLATE.md +41 -21
  4. package/kit/CLAUDE.md +9 -7
  5. package/kit/agents/crew-backend-expert.md +4 -1
  6. package/kit/agents/crew-commit-agent.md +8 -3
  7. package/kit/agents/crew-database-expert.md +4 -1
  8. package/kit/agents/crew-devops-expert.md +4 -1
  9. package/kit/agents/crew-frontend-expert.md +4 -1
  10. package/kit/agents/crew-performance-expert.md +4 -1
  11. package/kit/agents/crew-planner.md +10 -1
  12. package/kit/agents/crew-privacy-agent.md +4 -1
  13. package/kit/agents/crew-review-agent.md +4 -1
  14. package/kit/agents/crew-security-expert.md +4 -1
  15. package/kit/agents/crew-session-manager.md +4 -1
  16. package/kit/agents/crew-test-expert.md +4 -1
  17. package/kit/eval/smoke-test.sh +860 -27
  18. package/kit/hooks/agent-outcome.sh +437 -0
  19. package/kit/hooks/context-usage.sh +1 -1
  20. package/kit/hooks/guard-agent-model.sh +494 -60
  21. package/kit/hooks/guard-bash.sh +69 -8
  22. package/kit/hooks/guard-schedule.sh +8 -2
  23. package/kit/hooks/guard-write.sh +31 -0
  24. package/kit/hooks/prompt-approval.sh +94 -7
  25. package/kit/settings.json +43 -0
  26. package/kit/skills/crew-approve/SKILL.md +4 -1
  27. package/kit/skills/crew-loosen/SKILL.md +28 -0
  28. package/kit/studio/server/hooks/studio-gate.sh +90 -4
  29. package/kit/studio/server/hooks/studio-host.mjs +92 -0
  30. package/kit/studio/server/index.js +85 -6
  31. package/kit/studio/server/lib/agent-origin.js +44 -0
  32. package/kit/studio/server/lib/graph.js +45 -7
  33. package/kit/studio/server/lib/model-outcomes.js +287 -0
  34. package/kit/studio/server/lib/palette.js +5 -18
  35. package/kit/studio/server/lib/permissions.js +260 -17
  36. package/kit/studio/server/lib/session.js +41 -1
  37. package/kit/studio/server/lib/usage.js +8 -1
  38. package/kit/studio/web/app.js +203 -32
  39. package/kit/studio/web/approvals.js +53 -2
  40. package/kit/studio/web/ask.js +137 -0
  41. package/kit/studio/web/canvas.js +91 -42
  42. package/kit/studio/web/chat.js +11 -1
  43. package/kit/studio/web/convo.js +9 -0
  44. package/kit/studio/web/dock.js +30 -9
  45. package/kit/studio/web/graph-plan.js +29 -9
  46. package/kit/studio/web/icons/builtin.svg +1 -1
  47. package/kit/studio/web/index.html +11 -4
  48. package/kit/studio/web/keys.js +29 -0
  49. package/kit/studio/web/list-plan.js +8 -3
  50. package/kit/studio/web/list.js +27 -5
  51. package/kit/studio/web/models-plan.js +167 -0
  52. package/kit/studio/web/models.js +127 -0
  53. package/kit/studio/web/names.js +30 -0
  54. package/kit/studio/web/style.css +85 -9
  55. package/kit/studio/web/timeline-plan.js +8 -0
  56. package/kit/studio/web/timeline.js +26 -7
  57. package/package.json +1 -1
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // The permission host of a session Studio starts: an MCP server over stdio with one tool, `approve`, named to
3
+ // Claude Code with --permission-prompt-tool.
4
+ //
5
+ // WHY IT EXISTS. In a `-p` run Claude Code offers the two tools that need a person, AskUserQuestion and
6
+ // ExitPlanMode, only when the run has a permission host. Measured on Claude Code 2.1.294: with no host neither
7
+ // is in the session's tool list; with this one both are. A session without them asked its question as prose and
8
+ // could not leave plan mode at all.
9
+ //
10
+ // WHAT IT ANSWERS. Nothing, with one exception. Every prompt that reaches it is DENIED: the place a call is
11
+ // allowed in Studio is the dock, through the PreToolUse hook (studio-gate.sh), before the call gets here. A call
12
+ // that reaches this host is one the hook said nothing about, and before there was a host Claude Code refused
13
+ // those itself, because nobody could answer. Denying here keeps that exactly.
14
+ //
15
+ // The exception is a plan the viewer approved WITH a change of mode. The hook cannot change the mode; the
16
+ // permission host can, in its answer (measured: `updatedPermissions: [{ type: "setMode", … }]` moved a session
17
+ // from plan to acceptEdits). So for that one case the hook stays silent, and this host finds what the viewer
18
+ // decided in the spool — `host/<tool_use_id>.json`, written by the server when the answer was given — and
19
+ // answers with it. The mode is one of two words, checked here as well as where it was written.
20
+ //
21
+ // No dependency: the protocol is three JSON-RPC methods on newline-delimited stdio.
22
+ //
23
+ // Usage: studio-host.mjs <spool-dir>
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { pathToFileURL } from 'node:url';
27
+
28
+ const SPOOL = process.argv[2] ?? '';
29
+ const MODES = ['acceptEdits', 'default'];
30
+ const DENIED = 'Crewforth Studio: nobody answers this prompt here. A call is allowed in the Studio dock before it runs, or it is refused.';
31
+
32
+ const send = (o) => process.stdout.write(`${JSON.stringify(o)}\n`);
33
+ const text = (o) => ({ content: [{ type: 'text', text: JSON.stringify(o) }] });
34
+
35
+ /** What the viewer decided for this call, when the decision is one only the host can deliver. Read once. */
36
+ export function decisionFor(spool, toolName, toolUseId) {
37
+ if (toolName !== 'ExitPlanMode' || typeof toolUseId !== 'string' || !/^[A-Za-z0-9_-]+$/.test(toolUseId) || !spool) return null;
38
+ const file = path.join(spool, 'host', `${toolUseId}.json`);
39
+ let said;
40
+ try { said = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
41
+ try { fs.rmSync(file); } catch { /* answered once either way: the hook asks again for a new call */ }
42
+ return MODES.includes(said?.mode) ? said.mode : null;
43
+ }
44
+
45
+ export function answer(spool, args) {
46
+ const mode = decisionFor(spool, args?.tool_name, args?.tool_use_id);
47
+ if (!mode) return { behavior: 'deny', message: DENIED };
48
+ return {
49
+ behavior: 'allow',
50
+ updatedInput: args.input ?? {},
51
+ updatedPermissions: [{ type: 'setMode', mode, destination: 'session' }],
52
+ };
53
+ }
54
+
55
+ function serve() {
56
+ let buf = '';
57
+ process.stdin.on('data', (d) => {
58
+ buf += d;
59
+ let i;
60
+ while ((i = buf.indexOf('\n')) !== -1) {
61
+ const line = buf.slice(0, i).trim();
62
+ buf = buf.slice(i + 1);
63
+ if (!line) continue;
64
+ let m;
65
+ try { m = JSON.parse(line); } catch { continue; }
66
+ if (m.method === 'initialize') {
67
+ send({ jsonrpc: '2.0', id: m.id, result: { protocolVersion: m.params?.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'crew-studio-host', version: '1' } } });
68
+ } else if (m.method === 'tools/list') {
69
+ send({
70
+ jsonrpc: '2.0',
71
+ id: m.id,
72
+ result: { tools: [{
73
+ name: 'approve',
74
+ description: 'Answers a permission prompt of a session Crewforth Studio started.',
75
+ inputSchema: { type: 'object', properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } }, required: ['tool_name', 'input'] },
76
+ }] },
77
+ });
78
+ } else if (m.method === 'tools/call') {
79
+ send({ jsonrpc: '2.0', id: m.id, result: text(answer(SPOOL, m.params?.arguments)) });
80
+ } else if (m.id !== undefined) {
81
+ // Anything else that expects an answer gets an empty one; a notification gets none.
82
+ send({ jsonrpc: '2.0', id: m.id, result: {} });
83
+ }
84
+ }
85
+ });
86
+ }
87
+
88
+ // Imported by the selfcheck for `answer`; run by Claude Code as the server.
89
+ // Node names the main module by its real path, so the path it was started with is resolved the same way.
90
+ let main = false;
91
+ try { main = pathToFileURL(fs.realpathSync(process.argv[1])).href === import.meta.url; } catch { /* not started as a script */ }
92
+ if (main) serve();
@@ -17,18 +17,20 @@ import os from 'node:os';
17
17
  import { fileURLToPath, pathToFileURL } from 'node:url';
18
18
 
19
19
  import { getFleet, measureSpawnCost } from './lib/fleet.js';
20
- import { projectDir, listSessions, findSession, listProjects, sessionCwd } from './lib/projects.js';
20
+ import { projectDir, listSessions, findSession, listProjects, sessionCwd, agentMetaFiles } from './lib/projects.js';
21
+ import { agentOrigins } from './lib/agent-origin.js';
21
22
  import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
22
23
  import { cachedUsage } from './lib/usage.js';
24
+ import { readModels, readRecord, readCosts } from './lib/model-outcomes.js';
23
25
 
24
26
  const USAGE_BATCH = 12;
25
27
  import { palette } from './lib/palette.js';
26
28
  import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
27
29
  import { parsePeers, askAll, ask } from './lib/peers.js';
28
30
  import {
29
- createSession, getSession, listSessionsOwned, reap, stopAll, ALLOWED_MODES,
31
+ createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES,
30
32
  } from './lib/session.js';
31
- import { decide, pending, alwaysList, revoke } from './lib/permissions.js';
33
+ import { decide, pending, alwaysList, revoke, sweepLeft } from './lib/permissions.js';
32
34
  import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
33
35
  import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
34
36
  import { writeState, clearStateSync, findRunning } from './lib/instance.js';
@@ -138,8 +140,45 @@ export function writeAllowed(req) {
138
140
  return { ok: true };
139
141
  }
140
142
 
143
+ /**
144
+ * The record of outcomes for a project, with each agent's cost where it can be read. Only the sessions written
145
+ * since the record began are opened, and each is read once and kept until it grows (usage.js).
146
+ */
147
+ /** The ids of the agents of one session, from the files it left: the only lines of the record a page of it needs. */
148
+ async function agentIdsOf(session) {
149
+ if (!session?.subagentsDir) return [];
150
+ return (await agentMetaFiles(session.subagentsDir)).map((m) => path.basename(m).replace(/^agent-/, '').replace(/\.meta\.json$/, ''));
151
+ }
152
+
153
+ /**
154
+ * What the classes of a project cost: each agent's tokens at list price, from the transcripts of the project's
155
+ * sessions that are on this machine. Reading those is the expensive part of the Models view, so it is asked for
156
+ * apart from the record and less often, and a session whose transcript has not changed is not read again
157
+ * (`cachedUsage`).
158
+ */
159
+ async function modelCosts(cwd) {
160
+ const rec = readRecord(cwd);
161
+ if (!rec.measured) return { measured: false, reason: rec.reason, costs: {}, sessionsRead: 0 };
162
+ const per = new Map();
163
+ const dir = await projectDir(cwd);
164
+ let read = 0;
165
+ if (dir) {
166
+ const since = (rec.firstAt ?? 0) - 3600_000;
167
+ for (const s of (await listSessions(dir)).filter((x) => x.modifiedAt >= since).slice(0, MODELS_SESSIONS_MAX)) {
168
+ const u = await cachedUsage(s);
169
+ read += 1;
170
+ for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a.cost);
171
+ }
172
+ }
173
+ return { measured: true, costs: readCosts(cwd, (id) => (per.has(id) ? per.get(id) : null)), sessionsRead: read };
174
+ }
175
+ const MODELS_SESSIONS_MAX = 60;
176
+
141
177
  const MAX_BODY_BYTES = 1024 * 1024;
142
178
 
179
+ /** The session events after which a subscriber is sent the session's summary again. */
180
+ export const STATE_AFTER = new Set(['result', 'exit', 'mode']);
181
+
143
182
  function readBody(req) {
144
183
  return new Promise((resolve) => {
145
184
  let size = 0;
@@ -294,6 +333,24 @@ async function handle(req, res) {
294
333
  }
295
334
 
296
335
  // What Crewforth already measures about itself, reported as Crewforth reports it.
336
+ // Which model did which class of work in this project, and how it went: Crewforth's own record of outcomes,
337
+ // and the lines of it that belong to the session on screen. What the classes cost is the next route.
338
+ if (url.pathname === '/api/models') {
339
+ const sid = url.searchParams.get('session');
340
+ const session = sid ? await findSession(sid) : null;
341
+ const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
342
+ if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], byAgent: {} });
343
+ return sendJson(res, 200, readModels(cwd, { agents: await agentIdsOf(session) }));
344
+ }
345
+
346
+ if (url.pathname === '/api/models/cost') {
347
+ const sid = url.searchParams.get('session');
348
+ const session = sid ? await findSession(sid) : null;
349
+ const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
350
+ if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, sessionsRead: 0 });
351
+ return sendJson(res, 200, await modelCosts(cwd));
352
+ }
353
+
297
354
  if (url.pathname === '/api/kit') {
298
355
  const cwd = url.searchParams.get('cwd') || process.cwd();
299
356
  const sid = url.searchParams.get('session');
@@ -310,6 +367,14 @@ async function handle(req, res) {
310
367
  return sendJson(res, 200, palette());
311
368
  }
312
369
 
370
+ // Where the agents of a session's project are defined: the project's own files and the user's.
371
+ if (url.pathname === '/api/agents') {
372
+ const sid = url.searchParams.get('session');
373
+ const session = sid ? await findSession(sid) : null;
374
+ const cwd = session ? await sessionCwd(session.file, session.bytes) : null;
375
+ return sendJson(res, 200, { measured: true, ...agentOrigins(cwd) });
376
+ }
377
+
313
378
  if (url.pathname === '/api/sessions') {
314
379
  const cwd = url.searchParams.get('cwd') || process.cwd();
315
380
  const dir = await projectDir(cwd);
@@ -455,12 +520,12 @@ async function handle(req, res) {
455
520
  const body = await readBody(req);
456
521
  if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
457
522
  const toolUseId = decodeURIComponent(permMatch[2]);
458
- const out = decide(s.id, toolUseId, body.verdict);
523
+ const out = decide(s.id, toolUseId, body.verdict, { answers: body.answers, mode: body.mode });
459
524
  if (out.ok) s.noteDecision(toolUseId, body.verdict);
460
525
  return sendJson(res, out.ok ? 200 : 400, out);
461
526
  }
462
527
 
463
- const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events))?$/);
528
+ const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events|mode))?$/);
464
529
  if (ownedMatch) {
465
530
  const s = getSession(decodeURIComponent(ownedMatch[1]));
466
531
  if (!s) return sendJson(res, 404, { ok: false, reason: 'no such owned session' });
@@ -476,6 +541,13 @@ async function handle(req, res) {
476
541
 
477
542
  if (verb === 'stop') return sendJson(res, 200, { ...s.stop(), session: s.summary() });
478
543
 
544
+ // The viewer chose another mode for the session, from the three a session may be started in.
545
+ if (verb === 'mode') {
546
+ const asked = await readBody(req);
547
+ const out = s.requestMode(asked?.mode);
548
+ return sendJson(res, out.ok ? 200 : 400, { ...out, session: s.summary() });
549
+ }
550
+
479
551
  const body = await readBody(req);
480
552
  if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
481
553
  const sent = s.send(body.text);
@@ -728,7 +800,9 @@ function ownedStream(req, res, url, session) {
728
800
 
729
801
  const unsubscribe = session.subscribe((ev) => {
730
802
  write('event', ev);
731
- if (ev.rec?.type === 'result' || ev.rec?.type === 'exit') write('state', session.summary());
803
+ // The summary is what a pane paints its strip from. It follows what changes it: a turn ending, the session
804
+ // going, and the mode Claude Code reports. Without the last, the strip waited for the beat below.
805
+ if (STATE_AFTER.has(ev.rec?.type)) write('state', session.summary());
732
806
  }, after);
733
807
 
734
808
  const beat = setInterval(() => write('state', session.summary()), 15000);
@@ -815,6 +889,10 @@ async function main() {
815
889
  process.exit(await selftest());
816
890
  }
817
891
 
892
+ // What an earlier server left under the temp directory when it was killed: gone before this one makes its own.
893
+ // Only a server that is about to run does this; asking for --help or --selftest removes nothing.
894
+ sweepLeft();
895
+
818
896
  const server = http.createServer((req, res) => {
819
897
  handle(req, res).catch((e) => {
820
898
  process.stderr.write(`crewforth-studio: unhandled: ${e?.stack ?? e}\n`);
@@ -900,6 +978,7 @@ async function main() {
900
978
  // Children outlive their parent unless told otherwise, and a panel that
901
979
  // leaks running sessions is worse than one that never started them.
902
980
  stopAll();
981
+ dropSpools();
903
982
  server.close(() => process.exit(0));
904
983
  setTimeout(() => process.exit(0), 2000).unref();
905
984
  });
@@ -0,0 +1,44 @@
1
+ // Where a Claude Code agent is defined, as far as that can be read from disk.
2
+ //
3
+ // Claude Code takes an agent that is not built in from a Markdown file whose frontmatter names it: in the project
4
+ // (`.claude/agents/`), in the user's own directory (`~/.claude/agents/`), or in a plugin. This file reads the first
5
+ // two and nothing else. It keeps no list of built-in types: an agent found in neither place is reported as not
6
+ // found, which is all that is known about it.
7
+ //
8
+ // Not read: a plugin's files (a plugin agent is told by its name, `plugin:agent`, in web/names.js), agents given
9
+ // on the command line, managed settings, and files in a directory under `agents/`.
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { PROJECTS_ROOT } from './projects.js';
14
+
15
+ /** The agents one directory defines: { name: file name }. Null when the directory is not there or cannot be read. */
16
+ export function agentsIn(dir) {
17
+ let files;
18
+ try { files = fs.readdirSync(dir); } catch { return null; }
19
+ const out = {};
20
+ for (const f of files.sort()) {
21
+ if (!f.endsWith('.md')) continue;
22
+ let src;
23
+ try { src = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
24
+ // Frontmatter only, as the palette reads it: a "name:" in the body is not the agent's name.
25
+ const fm = src.replace(/\r\n/g, '\n').split(/^---$/m)[1] ?? '';
26
+ const name = fm.match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
27
+ if (name && out[name] === undefined) out[name] = f;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** Claude Code's own directory is the parent of its projects directory; the user's agents sit beside the projects. */
33
+ export const USER_AGENTS_DIR = path.join(path.dirname(PROJECTS_ROOT), 'agents');
34
+
35
+ /**
36
+ * @returns { project, user } — each { name: file name }, or null where that directory was not read. A project's
37
+ * definition is the one Claude Code uses when both name the same agent, and it is listed in both here.
38
+ */
39
+ export function agentOrigins(cwd, userDir = USER_AGENTS_DIR) {
40
+ return {
41
+ project: cwd ? agentsIn(path.join(cwd, '.claude', 'agents')) : null,
42
+ user: agentsIn(userDir),
43
+ };
44
+ }
@@ -85,6 +85,41 @@ export function modelRank(model) {
85
85
  return i === -1 ? null : i;
86
86
  }
87
87
 
88
+ // The tool a subagent hands its result back with. The report is the call's `message`; the agent often says
89
+ // nothing after it, so a transcript with a handback and no closing text still holds a report.
90
+ const HANDBACK = 'SubagentHandback';
91
+
92
+ /**
93
+ * What an agent reported, from its own transcript, and where that was read from.
94
+ *
95
+ * `closing` — text the agent wrote after its last handback: its own last word, and it wins;
96
+ * `handback` — the message of its last handback call, when nothing was written after it;
97
+ * `text` — with no handback at all, the last text it wrote (what came before is narration).
98
+ *
99
+ * Text written BEFORE a handback is narration on the way to it and is never the report.
100
+ * @returns { text, from } — text is null when the transcript holds none of the three
101
+ */
102
+ export function reportIn(records) {
103
+ let lastText = null; // the last text block of all
104
+ let afterHandback = null; // the last text block since the last handback
105
+ let handback = null;
106
+ for (const r of records ?? []) {
107
+ if (r?.type !== 'assistant' || !Array.isArray(r.message?.content)) continue;
108
+ for (const c of r.message.content) {
109
+ if (c?.type === 'text' && typeof c.text === 'string' && c.text.trim()) {
110
+ lastText = c.text;
111
+ if (handback !== null) afterHandback = c.text;
112
+ } else if (c?.type === 'tool_use' && c.name === HANDBACK && typeof c.input?.message === 'string' && c.input.message.trim()) {
113
+ handback = c.input.message;
114
+ afterHandback = null;
115
+ }
116
+ }
117
+ }
118
+ if (afterHandback !== null) return { text: afterHandback, from: 'closing' };
119
+ if (handback !== null) return { text: handback, from: 'handback' };
120
+ return { text: lastText, from: lastText === null ? null : 'text' };
121
+ }
122
+
88
123
  /** How a report says it ended: the `confidence: high|low` line a crew agent closes with, or null. */
89
124
  export function confidenceOf(text) {
90
125
  const m = typeof text === 'string' ? text.trimEnd().match(/(?:^|\n)\W*confidence:\s*(high|low)\W*$/i) : null;
@@ -287,7 +322,7 @@ async function scanAgent(file, usage = null, agentId = null) {
287
322
  const stats = {
288
323
  tools: {}, toolCount: 0, lastTool: null, errors: 0,
289
324
  model: null, // the model its own transcript names: what it actually ran on
290
- lastText: null, // the end of the last thing it said, for the line a report closes with
325
+ lastText: null, // the end of its report, for the line a report closes with
291
326
  tokens: null, startedAt: null, endedAt: null, turns: 0,
292
327
  owns: [], // tool_use ids this agent emitted — how nesting is resolved
293
328
  };
@@ -304,8 +339,6 @@ async function scanAgent(file, usage = null, agentId = null) {
304
339
  stats.turns += 1;
305
340
  const mdl = r.message?.model;
306
341
  if (typeof mdl === 'string' && mdl && mdl !== '<synthetic>') stats.model = mdl;
307
- const said = textOf(r.message?.content);
308
- if (said.trim()) stats.lastText = said.slice(-400);
309
342
  const u = r.message?.usage;
310
343
  if (u && u.cache_read_input_tokens != null) {
311
344
  stats.tokens = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
@@ -333,6 +366,8 @@ async function scanAgent(file, usage = null, agentId = null) {
333
366
  stats.durationMs = stats.startedAt !== null && stats.endedAt !== null
334
367
  ? stats.endedAt - stats.startedAt
335
368
  : null;
369
+ // The report, wherever the agent put it: the confidence line is its last line, handback included.
370
+ stats.lastText = reportIn(records).text?.slice(-400) ?? null;
336
371
  stats.midTurn = midTurn(records);
337
372
  stats.open = openCalls(records);
338
373
  return stats;
@@ -692,9 +727,10 @@ export async function agentDetail(session, agentId) {
692
727
  }
693
728
  }
694
729
 
695
- // The report is the last text block the agent produced. Earlier blocks are
696
- // narration between tool calls, which is progress rather than conclusion.
697
- const report = texts.length ? texts[texts.length - 1].text : null;
730
+ // The report: what the agent wrote last, or what it handed back when it wrote nothing after that. Earlier
731
+ // blocks are narration between tool calls, which is progress rather than conclusion.
732
+ const reported = reportIn(records);
733
+ const report = reported.text;
698
734
 
699
735
  return {
700
736
  agentId,
@@ -705,9 +741,11 @@ export async function agentDetail(session, agentId) {
705
741
  spawnDepth: meta.spawnDepth ?? null,
706
742
  prompt,
707
743
  report,
744
+ // Where the report was read from: 'closing', 'handback' or 'text' (see reportIn); null when there is none.
745
+ reportFrom: reported.from,
708
746
  // Kept apart so the UI can show progress for an agent that has not
709
747
  // reported yet, without pretending the narration is a conclusion.
710
- narration: texts.slice(0, -1).map((t) => t.text),
748
+ narration: texts.filter((t) => t.text !== report).map((t) => t.text),
711
749
  thinking: lastThinking,
712
750
  timeline,
713
751
  lastError,