flowviant 0.125.0 → 0.125.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.
package/bin/cli.mjs CHANGED
@@ -80,6 +80,11 @@
80
80
  // answered here before anything else loads: no config, no update check, no
81
81
  // output but the hook's. An unknown hook exits 2, which Claude Code reads as a
82
82
  // refusal — the read guard's own rule for a hook that cannot judge.
83
+ if (process.argv[2] === '__orchestrator-mcp') {
84
+ await (await import('./lib/orchestratorClient.mjs')).orchestratorMcpMain();
85
+ process.exit(process.exitCode ?? 0);
86
+ }
87
+
83
88
  if (['__repository', '__repository-mcp'].includes(process.argv[2])) {
84
89
  await (await import('./lib/repositoryTool.mjs')).repositoryToolMain(process.argv[2] === '__repository-mcp');
85
90
  process.exit(process.exitCode ?? 0);
@@ -89,12 +94,17 @@ if (process.argv[2] === '__hook') {
89
94
  const HOOKS = {
90
95
  'read-guard': () => import('./lib/hooks/readGuard.mjs'),
91
96
  'mcp-allow': () => import('./lib/hooks/mcpAllow.mjs'),
97
+ 'orchestrator': () => import('./lib/hooks/orchestrator.mjs'),
92
98
  };
93
99
  const load = Object.hasOwn(HOOKS, process.argv[3] ?? '') ? HOOKS[process.argv[3]] : null;
94
100
  if (!load) {
95
101
  process.stderr.write(`flowviant: no hook named '${String(process.argv[3] ?? '').slice(0, 40)}'\n`);
96
102
  process.exit(2);
97
103
  }
104
+ if (process.argv[3] === 'orchestrator') {
105
+ await (await load()).main();
106
+ process.exit(process.exitCode ?? 0);
107
+ }
98
108
  (await load()).main();
99
109
  // The hook ends the process itself, on its verdict.
100
110
  await new Promise(() => {});
@@ -21,7 +21,7 @@
21
21
  * would alter what the agent declared, the fence's own rule), and the
22
22
  * question goes through as plain text, exactly as it did before options
23
23
  * existed. So does a question too long for a fence, one that already carries
24
- * a fence of its own, or an answer the fence would push past the wire's cut.
24
+ * a fence of its own. Chat prose itself has no character allowance.
25
25
  *
26
26
  * STRICTER THAN THE PAGE, NEVER LOOSER. `ASK_BOUNDS` is the shared one (the
27
27
  * release gate, check-app-parity.mjs, compares them). `ASK_HOSTILE` is a
@@ -30,7 +30,6 @@
30
30
  * Refusing a label the page would have taken costs only the tiles.
31
31
  */
32
32
 
33
- import { scrubCut } from './uplinkScrub.mjs';
34
33
 
35
34
  /** The fence's bounds — the shared `ASK_BOUNDS`, key for key. */
36
35
  export const ASK_BOUNDS = Object.freeze({
@@ -52,10 +51,6 @@ const QUESTION_HOSTILE = new RegExp(`[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u00
52
51
  /** A fence already in the question, anchored as the page's reader anchors. */
53
52
  const HAS_FENCE = /^```[ \t]*flowviant-ask/m;
54
53
 
55
- /** The cut the turn's answer gets on its way to the wire
56
- * (`workAgentTurnOutcome.mjs`): a fence past it would arrive broken. */
57
- export const ASK_ANSWER_MAX = 8000;
58
-
59
54
  /** One field, trimmed — or null when it is empty, oversize or hostile. */
60
55
  const line = (v, max) => {
61
56
  if (typeof v !== 'string') return null;
@@ -96,17 +91,16 @@ export function declaredOptions(raw, scrub = (s) => s) {
96
91
  * "a short heading over body-size context"). The blocked shape's optional
97
92
  * `context` — what the decision settles, findings, evidence — is written as
98
93
  * prose before the question, so the page draws the fence's one-line
99
- * `question` as the heading and this beneath it at body size. Scrubbed, then
100
- * cut at the wire's cap; one that carries a fence of its own declares nothing
101
- * and is written as the plain text it is.
94
+ * `question` as the heading and this beneath it at body size. Scrubbed whole;
95
+ * a context carrying its own fence keeps its prose without new choice tiles.
102
96
  */
103
97
  const contextOf = (v, scrub) => {
104
98
  if (typeof v?.context !== 'string') return '';
105
- return scrubCut(v.context.trim(), ASK_ANSWER_MAX, scrub).trim();
99
+ return scrub(v.context.trim()).trim();
106
100
  };
107
101
 
108
102
  /**
109
- * THE ANSWER A BLOCKED TURN HANDS ON: `question` (already scrubbed and cut),
103
+ * THE ANSWER A BLOCKED TURN HANDS ON: `question` (already scrubbed),
110
104
  * after its `context` when there is one, then the fence built from
111
105
  * `v.options` and `v.multiSelect` when every rule above holds.
112
106
  */
@@ -119,5 +113,5 @@ export function blockedAnswer(question, v, scrub = (s) => s) {
119
113
  if (!q || q.length > ASK_BOUNDS.question || QUESTION_HOSTILE.test(q) || HAS_FENCE.test(prose)) return prose;
120
114
  const ask = { question: q, options, ...(v.multiSelect === true ? { multiSelect: true } : {}) };
121
115
  const answer = `${prose}\n\n\`\`\`flowviant-ask\n${JSON.stringify(ask)}\n\`\`\``;
122
- return answer.length <= ASK_ANSWER_MAX ? answer : prose;
116
+ return answer;
123
117
  }
@@ -37,31 +37,8 @@
37
37
  */
38
38
 
39
39
  import { appendFileSync, readFileSync } from 'node:fs';
40
- import { CARD_THREAD_RENDER_MAX } from './cardThread.mjs';
41
- import { CARD_FILES_RENDER_MAX } from './agentFiles.mjs';
42
-
43
- /**
44
- * How many card specs one agent may accumulate.
45
- *
46
- * A bound on a MACHINE — an agent's queue is a handful of cards, and this exists
47
- * so a pathological agent (a card re-delivered fifty times, an agent grown past
48
- * its budget) cannot turn a prompt into a file read. The NEWEST are kept, which
49
- * is the same tail-is-what-matters rule the trace keeps.
50
- */
51
- export const MAX_STASHED_CARDS = 40;
52
- /** The most one spec may contribute. A brief is written by whoever filed the
53
- * card and the server caps it, but this file is composed into a prompt and a
54
- * bound it owns is a bound that cannot be argued away upstream.
55
- *
56
- * 8,000 is the record's brief ceiling; since 0.106.0 the spec also carries
57
- * the card's discussion, so the bound grows by the most a turn's discussion
58
- * can render to (`CARD_THREAD_RENDER_MAX`). The discussion is printed LAST,
59
- * and a stash that cut it would hand the pre-review a thread missing its
60
- * newest words — the ones the agent read most recently. Since 0.112.0 the
61
- * files on that discussion follow it (`CARD_FILES_RENDER_MAX`), and a cut
62
- * there would hand the pre-review a card missing the screenshot its agent
63
- * was shown. */
64
- export const MAX_SPEC_CHARS = 8_000 + CARD_THREAD_RENDER_MAX + CARD_FILES_RENDER_MAX;
40
+ // Specs have no character or card-count allowance. Filesystem read/write
41
+ // failures remain explicit missing-spec evidence for the optional precheck.
65
42
 
66
43
  /**
67
44
  * Write down the spec this turn is about to hand the agent.
@@ -83,7 +60,7 @@ export function stashCard(path, taskId, spec) {
83
60
  try {
84
61
  appendFileSync(
85
62
  path,
86
- JSON.stringify({ taskId: id.slice(0, 64), prompt: text.slice(0, MAX_SPEC_CHARS) }) + '\n'
63
+ JSON.stringify({ taskId: id.slice(0, 64), prompt: text }) + '\n'
87
64
  );
88
65
  return true;
89
66
  } catch {
@@ -124,10 +101,8 @@ export function readStash(path) {
124
101
  const taskId = typeof v.taskId === 'string' ? v.taskId.trim() : '';
125
102
  const prompt = typeof v.prompt === 'string' ? v.prompt : '';
126
103
  if (!taskId || !prompt.trim()) continue;
127
- byId.set(taskId, prompt.slice(0, MAX_SPEC_CHARS));
104
+ byId.set(taskId, prompt);
128
105
  }
129
106
  const all = [...byId].map(([taskId, prompt]) => ({ taskId, prompt }));
130
- // The NEWEST cards when there are too many — a reviewer reading a grown
131
- // agent's branch is reading the work at its end.
132
- return all.slice(-MAX_STASHED_CARDS);
107
+ return all;
133
108
  }
@@ -38,7 +38,7 @@ export const AGENT_FILE_LINE_MAX = 160;
38
38
 
39
39
  /**
40
40
  * THE MOST THE CARD'S FILES CAN ADD TO A CARD SPEC, rendered — so the spec
41
- * stash (agentCards.mjs `MAX_SPEC_CHARS`) holds the note whole beside the
41
+ * stash (agentCards.mjs) holds the note whole beside the
42
42
  * discussion, and the pre-review is handed the files the agent was. A header
43
43
  * line and its blank line, then one line per file. `agentFiles.test.mjs`
44
44
  * renders the worst case against it.
@@ -52,20 +52,20 @@ export function measureAgentFootprint({ placeId, baseDir, baseRef, readGit = git
52
52
  return result;
53
53
  }
54
54
 
55
- /** Card prose stays bounded and inside the kickoff's untrusted fence. */
55
+ /** Complete card requirements stay scrubbed inside the kickoff's untrusted fence. */
56
56
  export function liveAgentContext(agent, footprint) {
57
57
  const text = (value, cap) => scrubCut(typeof value === 'string' ? value : '', cap);
58
58
  const count = value => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1_000_000, Math.floor(value))) : 0;
59
- const ids = value => (Array.isArray(value) ? value : []).filter(id => typeof id === 'string' && id.length > 0 && id.length <= 64).slice(0, 100);
59
+ const ids = value => (Array.isArray(value) ? value : []).filter(id => typeof id === 'string' && id.length > 0 && id.length <= 64);
60
60
  const intended = agent?.intendedWork;
61
61
  return {
62
62
  id: text(agent?.id, 64), name: text(agent?.name, 80), status: text(agent?.status, 40),
63
63
  taskIds: ids(agent?.taskIds), changedFiles: footprint,
64
64
  intendedWork: intended && Array.isArray(intended.tasks) ? {
65
65
  taskIds: ids(intended.taskIds),
66
- tasks: intended.tasks.slice(0, 20).filter(t => t && typeof t.id === 'string' && t.id.length <= 64 && ['queued', 'running'].includes(t.state)).map(t => ({
67
- id: t.id, state: t.state, taskKind: agentTaskKindOf(t.taskKind), ...(agentTaskTypeFor(t) ? { taskType: agentTaskTypeFor(t) } : {}), truncated: t.truncated === true || (typeof t.brief === 'string' && t.brief.length > 1000) || (Array.isArray(t.criteria) && t.criteria.length > 8) || (Array.isArray(t.anchors) && t.anchors.length > 20), title: text(t.title, 300), brief: text(t.brief, 1000),
68
- criteria: (Array.isArray(t.criteria) ? t.criteria : []).slice(0, 8).map(x => text(x, 300)),
66
+ tasks: intended.tasks.filter(t => t && typeof t.id === 'string' && t.id.length <= 64 && ['queued', 'running'].includes(t.state)).map(t => ({
67
+ id: t.id, state: t.state, taskKind: agentTaskKindOf(t.taskKind), ...(agentTaskTypeFor(t) ? { taskType: agentTaskTypeFor(t) } : {}), truncated: t.truncated === true, title: text(t.title, 300), brief: typeof t.brief === 'string' ? scrub(t.brief) : '',
68
+ criteria: (Array.isArray(t.criteria) ? t.criteria : []).filter(x => typeof x === 'string').map(x => scrub(x)),
69
69
  anchors: (Array.isArray(t.anchors) ? t.anchors : []).slice(0, 20).map(x => text(x, 200)),
70
70
  })),
71
71
  omitted: count(intended.omitted),
@@ -19,8 +19,8 @@
19
19
  * nobody can accept, and `null` — which the caller reports in the machine's own
20
20
  * words — beats rendering an empty board with an Accept button on it.
21
21
  *
22
- * Every string is CAPPED here rather than downstream. This text is model
23
- * output about untrusted card content, and it becomes a row.
22
+ * Structured proposal fields keep their existing shape bounds. Chat prose
23
+ * stays complete; native transport/storage refusals never shorten its tail.
24
24
  *
25
25
  * Run: node --test bin/lib/agentPlan.test.mjs
26
26
  */
@@ -71,9 +71,8 @@ const MAX_NOTE = 1000;
71
71
  * the machine moved: no poll, no lease renewal, no other lane. Three bounds,
72
72
  * none of which a real answer comes near:
73
73
  *
74
- * · only the LAST `MAX_SCAN_CHARS` are scanned — every contract here puts the
75
- * object LAST, and the fenced block is still looked for in the whole text,
76
- * by a LINEAR scan (`fencedBody`) since 2026-10-10;
74
+ * · top-level objects are scanned whole, including long un-fenced replies;
75
+ * `fencedBody` and the brace scan are linear, with no prose-length slice;
77
76
  * · each `{`'s closing brace is found ONCE (see `closers`), so the scan is a
78
77
  * single right-to-left pass instead of one walk per brace;
79
78
  * · only spans that can open an object (`{` then a key or `}`) are handed to
@@ -90,13 +89,12 @@ const MAX_NOTE = 1000;
90
89
  * delivered agent went to Stuck. The contract puts the answer LAST and at the
91
90
  * top level, which is the one span a budget must never cost.
92
91
  */
93
- const MAX_SCAN_CHARS = 256 * 1024;
94
92
  const MAX_NESTED_PARSES = 200;
95
93
  const MAX_NESTED_CHARS = 4 * 1024 * 1024;
96
94
 
97
95
  export function candidateObjects(text) {
98
96
  const fenced = fencedBody(text);
99
- const raw = text.length > MAX_SCAN_CHARS ? text.slice(text.length - MAX_SCAN_CHARS) : text;
97
+ const raw = text;
100
98
  const bodies = [];
101
99
  if (fenced !== null) bodies.push(fenced);
102
100
  const close = closers(raw);
@@ -135,8 +133,7 @@ export function candidateObjects(text) {
135
133
  * `text.match(/```(?:json)?\s*([\s\S]*?)```/)[1]` answered, found in ONE
136
134
  * forward pass (2026-10-10, the audit's F079).
137
135
  *
138
- * That regex ran over the WHOLE text — the only search the `MAX_SCAN_CHARS`
139
- * bound above skipped — and it backtracks quadratically on an opening fence
136
+ * That regex ran over the WHOLE text and backtracks quadratically on an opening fence
140
137
  * followed by a long whitespace run with no closing fence: the greedy `\s*` and
141
138
  * the lazy body re-scan the run from every split. Measured: 80 KB took 0.6 s,
142
139
  * 320 KB nine seconds, of synchronous CPU in which no poll, lease or other
@@ -144,7 +141,7 @@ export function candidateObjects(text) {
144
141
  * answer, by `indexOf`: the first ``` anywhere, an optional `json`, the
145
142
  * whitespace after it skipped, and the body runs to the next ```. If that
146
143
  * opening has no closer, no later opening can have one either, so there is no
147
- * fence. The search stays over the whole text, as the bound above promises.
144
+ * fence. The search stays over the whole text.
148
145
  */
149
146
  const FENCE_SPACE = /\s*/y;
150
147
  function fencedBody(text) {
@@ -323,18 +320,10 @@ function uniqueTempIds(list) {
323
320
  * sent instead would be indistinguishable from an agent saying the branch now
324
321
  * amounts to nothing.
325
322
  *
326
- * AN ABSURDITY BOUND HERE, NOT THE DISPLAY CAP (corrected 2026-09-22, in
327
- * review). This sliced to the wire's own 1000 first, which quietly INVERTED the
328
- * order the caller's own comment claims to keep: `envScrub` replaces EXACT
329
- * values, so handing it a paragraph already cut at 1000 hands it a credential
330
- * cut in half — it matches nothing and the surviving prefix ships. The scrub is
331
- * the only thing standing between a model that pasted a secret into its own
332
- * summary and the wire, and a cap must never run ahead of it. So this is the
333
- * same absurdity bound `summary` takes one line up (8000): enough that nothing
334
- * a model writes reaches the caller unbounded, far enough above the 1000 the
335
- * wire cuts to that the scrub always sees whole values. `workAgentTurns.mjs` scrubs and
336
- * THEN cuts to 1000, and the server clamps again because the wire never trusts
337
- * a machine to have clamped.
323
+ * The running branch account is a separate, short display readout, not the
324
+ * chat answer. Its existing display policy stays below; the reply parser
325
+ * preserves summaries and blocked-question prose whole. Scrub this entire
326
+ * account before its display slice so a credential cannot straddle the cut.
338
327
  */
339
328
  const MAX_PROGRESS = 8000;
340
329
  const progressOf = (v, scrub) => {
@@ -405,8 +394,8 @@ export function parseTurnResult(text, scrub = envScrub) {
405
394
  const r = v.prerequisite;
406
395
  if (!r || typeof r.title !== 'string' || !r.title.trim() || typeof r.brief !== 'string' || !r.brief.trim()
407
396
  || typeof r.taskKind !== 'string' || !Object.hasOwn(AGENT_TASK_KINDS, r.taskKind)) continue;
408
- return { outcome: 'question', answer: typeof v.summary === 'string' ? clean(v.summary, 8000) : 'Requested a prerequisite card.',
409
- ...progressOf(v, scrub), prerequisite: { title: clean(r.title.trim(), 300), brief: clean(r.brief.trim(), 2000), taskKind: r.taskKind } };
397
+ return { outcome: 'question', answer: typeof v.summary === 'string' ? scrub(v.summary) : 'Requested a prerequisite card.',
398
+ ...progressOf(v, scrub), prerequisite: { title: clean(r.title.trim(), 300), brief: scrub(r.brief.trim()), taskKind: r.taskKind } };
410
399
  }
411
400
  if (v.status === 'blocked') {
412
401
  const question = typeof v.question === 'string' ? v.question.trim() : '';
@@ -417,7 +406,7 @@ export function parseTurnResult(text, scrub = envScrub) {
417
406
  // Its declared choices (2026-10-13) ride on as the one fence the page
418
407
  // reads, built here (agentAsk.mjs); malformed ones leave the question
419
408
  // as plain text.
420
- return { outcome: 'question', answer: blockedAnswer(clean(question, 8000), v, scrub), ...progressOf(v, scrub) };
409
+ return { outcome: 'question', answer: blockedAnswer(scrub(question), v, scrub), ...progressOf(v, scrub) };
421
410
  }
422
411
  if (v.status === 'delivered') {
423
412
  return {
@@ -426,7 +415,7 @@ export function parseTurnResult(text, scrub = envScrub) {
426
415
  ...(Array.isArray(v.mergeOrder) ? { mergeOrder: v.mergeOrder.filter((id) => typeof id === 'string').slice(0, 64) } : {}),
427
416
  ...(typeof v.mergeOrderReason === 'string' ? { mergeOrderReason: clean(v.mergeOrderReason, 1000) } : {}),
428
417
  ...(Array.isArray(v.mergeOrders) ? { mergeOrders: v.mergeOrders.slice(0, 60) } : {}),
429
- answer: typeof v.summary === 'string' ? clean(v.summary, 8000) : '',
418
+ answer: typeof v.summary === 'string' ? scrub(v.summary) : '',
430
419
  ...progressOf(v, scrub),
431
420
  raised: Array.isArray(v.raised)
432
421
  ? v.raised
@@ -435,7 +424,7 @@ export function parseTurnResult(text, scrub = envScrub) {
435
424
  .map((r) => ({
436
425
  title: clean(r.title.trim(), 300),
437
426
  ...(typeof r.brief === 'string' && r.brief.trim()
438
- ? { brief: clean(r.brief.trim(), 2000) }
427
+ ? { brief: scrub(r.brief.trim()) }
439
428
  : {}),
440
429
  }))
441
430
  : [],
@@ -42,7 +42,7 @@ const OMITTED_MAX = 1_000_000;
42
42
 
43
43
  /**
44
44
  * THE MOST ONE TURN'S DISCUSSION CAN ADD TO A CARD SPEC, rendered — so the
45
- * spec stash (agentCards.mjs `MAX_SPEC_CHARS`) can hold a card with its
45
+ * complete spec stash (agentCards.mjs) can hold a card with its
46
46
  * discussion whole, and the pre-review reads what the agent read. The text is
47
47
  * at most `chars`; the renderer adds at most two characters and a newline per
48
48
  * text line (<= 3x the text), a header line per entry, and two lines of its
@@ -79,7 +79,8 @@ export function usageFromResult(ev) {
79
79
  // container that reports no spend, which is indistinguishable from an older
80
80
  // daemon by design. A callback that is silent when it breaks has to be called
81
81
  // directly by something.
82
- export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel }) {
82
+ export function handleStreamLine(line, options) {
83
+ const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents } = options;
83
84
  let ev;
84
85
  try {
85
86
  ev = JSON.parse(line);
@@ -88,10 +89,19 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
88
89
  emit(line + '\n');
89
90
  return;
90
91
  }
92
+ if (ev.type === 'progress' && ev.data?.type === 'agent_progress' && ev.parentToolUseID && ev.data.message) {
93
+ subagents?.link(ev.data.agentId, ev.parentToolUseID);
94
+ return handleStreamLine(JSON.stringify({ ...ev.data.message, parent_tool_use_id: ev.parentToolUseID }), options);
95
+ }
96
+ const agentId = subagents ? subagents.identify(ev) : ev.parent_tool_use_id ?? (ev.isSidechain ? ev.agentId ?? ev.agent_id ?? ev.sessionId : undefined);
97
+ if (subagents && !subagents.accept(ev, agentId)) return;
98
+ // A sidechain with no identity must never be attributed to the parent.
99
+ if (ev.isSidechain && !agentId) return;
100
+ subagents?.observe(ev);
91
101
  const push = (a) => {
92
102
  if (!a || !a.label) return;
93
103
  emit(a.label + '\n');
94
- onActivity?.(a);
104
+ onActivity?.({ ...a, ...(agentId ? { agentId } : {}) });
95
105
  };
96
106
  if (ev.type === 'stream_event') {
97
107
  // Partial messages announce reasoning before the completed assistant
@@ -106,7 +116,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
106
116
  // THE MODEL THAT ANSWERED (2026-09-29, turnModel.mjs) — the fallback for an
107
117
  // init that named none. The turn's own messages only: a subagent's carry
108
118
  // `parent_tool_use_id`, and its model is not the turn's.
109
- if (ev.parent_tool_use_id == null) {
119
+ if (!agentId) {
110
120
  const answered = turnModelOf(ev.message.model);
111
121
  if (answered) onModel?.(answered);
112
122
  }
@@ -138,7 +148,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
138
148
  : { kind: 'think', label: THINK_MARKER }
139
149
  );
140
150
  } else if (b.type === 'text' && b.text?.trim()) {
141
- if (!answerFromResult) appendText(b.text + '\n');
151
+ if (!answerFromResult && !agentId) appendText(b.text + '\n');
142
152
  // `label` for the console and the pulse — one collapsed 160-char line,
143
153
  // byte-identical to what it has always printed. `full` for the trace:
144
154
  // a `say` is the agent NARRATING, several sentences at a time, and the
@@ -150,18 +160,19 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
150
160
  // The STRUCTURED form of the same event, for the transcript's tool
151
161
  // cards — raw name + input, so the collector can keep what the
152
162
  // one-line humanizer drops (an Edit's counts, the plan's items).
153
- onToolEvent?.(b.name, b.input || {}, { id: b.id });
163
+ onToolEvent?.(b.name, b.input || {}, { id: b.id, ...(agentId ? { agentId } : {}) });
154
164
  }
155
165
  }
156
166
  } else if (ev.type === 'user' && Array.isArray(ev.message?.content)) {
157
167
  for (const b of ev.message.content) {
158
168
  if (b.type === 'tool_result') onToolEvent?.(null, {}, {
159
169
  id: b.tool_use_id, result: true, isError: b.is_error === true,
170
+ ...(agentId ? { agentId } : {}),
160
171
  output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
161
172
  exitCode: ev.tool_use_result?.exit_code ?? ev.tool_use_result?.exitCode,
162
173
  });
163
174
  }
164
- } else if (ev.type === 'system' && ev.subtype === 'init') {
175
+ } else if (ev.type === 'system' && ev.subtype === 'init' && !agentId) {
165
176
  // WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
166
177
  // CLI's OWN answer — it has already resolved personal skills, this repo's
167
178
  // skills, plugins and whatever the project settings enable or disable — so
@@ -197,7 +208,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
197
208
  tools: Array.isArray(ev.tools) ? ev.tools : undefined,
198
209
  });
199
210
  }
200
- } else if (ev.type === 'result') {
211
+ } else if (ev.type === 'result' && !agentId) {
201
212
  /**
202
213
  * WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
203
214
  *
@@ -0,0 +1,62 @@
1
+ /** Claude persists agent_progress with the spawning parentToolUseID even
2
+ * when print-mode stdout carries only the parent. Follow this session alone. */
3
+ import { open } from 'node:fs/promises';
4
+ import { realpathSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { claudeProjectDir } from './claudeSessions.mjs';
8
+ import { CLAUDE_SESSION_RE } from './conversationIds.mjs';
9
+
10
+ const MAX_READ = 4 * 1024 * 1024;
11
+ const LINE_MAX = 256 * 1024;
12
+ export function createClaudeSubagentProgress({ cwd, since = Date.now(), env = process.env, onLine }) {
13
+ let file, offset = null, rest = Buffer.alloc(0), skip = false, timer;
14
+ let chain = Promise.resolve();
15
+ const flush = () => {
16
+ chain = chain.then(async () => {
17
+ if (!file) return;
18
+ let fh;
19
+ try {
20
+ fh = await open(file, 'r');
21
+ const { size } = await fh.stat();
22
+ if (offset === null) { offset = Math.max(0, size - MAX_READ); skip = offset > 0; }
23
+ if (size < offset) { offset = 0; rest = Buffer.alloc(0); }
24
+ const end = Math.min(size, offset + MAX_READ);
25
+ while (offset < end) {
26
+ const buf = Buffer.alloc(Math.min(LINE_MAX, end - offset));
27
+ const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
28
+ if (!bytesRead) break;
29
+ offset += bytesRead;
30
+ const data = Buffer.concat([rest, buf.subarray(0, bytesRead)]);
31
+ let start = 0, nl;
32
+ while ((nl = data.indexOf(10, start)) >= 0) {
33
+ const line = data.subarray(start, nl).toString('utf8'); start = nl + 1;
34
+ if (skip) { skip = false; continue; }
35
+ try {
36
+ const ev = JSON.parse(line);
37
+ if (ev.type !== 'progress' || ev.data?.type !== 'agent_progress' || !ev.parentToolUseID) continue;
38
+ const at = Date.parse(ev.timestamp ?? '');
39
+ if (!Number.isFinite(at) || at < since) continue;
40
+ onLine(line);
41
+ } catch { /* torn or unknown native row */ }
42
+ }
43
+ rest = data.subarray(start);
44
+ if (rest.length > LINE_MAX) { rest = Buffer.alloc(0); skip = true; }
45
+ }
46
+ } catch { /* the transcript can appear after init */ }
47
+ finally { await fh?.close().catch(() => {}); }
48
+ }).catch(() => {});
49
+ return chain;
50
+ };
51
+ return {
52
+ session(id) {
53
+ if (file || !CLAUDE_SESSION_RE.test(id ?? '')) return;
54
+ let actualCwd = cwd;
55
+ try { actualCwd = realpathSync(cwd); } catch { /* missing directory */ }
56
+ file = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects', claudeProjectDir(actualCwd), `${id}.jsonl`);
57
+ timer = setInterval(() => void flush(), 1_000); timer.unref?.();
58
+ },
59
+ flush,
60
+ async stop() { clearInterval(timer); await flush(); },
61
+ };
62
+ }
@@ -0,0 +1,77 @@
1
+ /** Native tool-use ids are stable for the lifetime of an Agent/Task call. */
2
+ export function createClaudeSubagents(report = () => {}) {
3
+ const calls = new Map();
4
+ const aliases = new Map();
5
+ const questions = new Map();
6
+ const seen = new Set();
7
+ const identify = ev => ev.parent_tool_use_id != null
8
+ ? aliases.get(ev.parent_tool_use_id) ?? ev.parent_tool_use_id
9
+ : ev.isSidechain ? aliases.get(ev.agentId ?? ev.agent_id ?? ev.sessionId) : undefined;
10
+ return {
11
+ identify,
12
+ link(nativeId, callId) {
13
+ if (nativeId && callId) {
14
+ const id = aliases.get(callId) ?? callId;
15
+ aliases.set(nativeId, id);
16
+ report({ id, nativeId });
17
+ }
18
+ if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
19
+ },
20
+ accept(ev, id) {
21
+ if (!id || !ev.uuid) return true;
22
+ const key = `${id}:${ev.uuid}`;
23
+ if (seen.has(key)) return false;
24
+ seen.add(key);
25
+ if (seen.size > 800) seen.delete(seen.values().next().value);
26
+ return true;
27
+ },
28
+ observe(ev) {
29
+ const agentId = identify(ev);
30
+ if (ev.type === 'assistant') {
31
+ for (const b of ev.message?.content ?? []) {
32
+ if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && agentId) {
33
+ questions.set(b.id, agentId);
34
+ if (questions.size > 128) questions.delete(questions.keys().next().value);
35
+ report({ id: agentId, status: 'asked' });
36
+ }
37
+ if (b.type !== 'tool_use' || !['Agent', 'Task'].includes(b.name) || !b.id) continue;
38
+ const input = b.input ?? {};
39
+ const id = aliases.get(input.resume) ?? b.id;
40
+ calls.set(b.id, { id, input });
41
+ aliases.set(b.id, id);
42
+ if (calls.size > 128) calls.delete(calls.keys().next().value);
43
+ if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
44
+ report({ id, kind: input.subagent_type ?? '', title: input.description ?? input.name ?? '',
45
+ brief: input.prompt ?? '', status: 'running', now: input.description ?? '' });
46
+ }
47
+ }
48
+ if (ev.type === 'user') {
49
+ for (const b of ev.message?.content ?? []) {
50
+ if (questions.has(b.tool_use_id)) {
51
+ report({ id: questions.get(b.tool_use_id), status: 'running' });
52
+ questions.delete(b.tool_use_id);
53
+ }
54
+ if (b.type !== 'tool_result' || !calls.has(b.tool_use_id)) continue;
55
+ const id = calls.get(b.tool_use_id).id;
56
+ const result = ev.tool_use_result ?? {};
57
+ const nativeId = result.agentId ?? result.agent_id;
58
+ if (nativeId) aliases.set(nativeId, id);
59
+ // Background launch is not completion; its notification closes it.
60
+ const background = result.status === 'async_launched' || result.isAsync === true || calls.get(b.tool_use_id).input.run_in_background === true;
61
+ report({ id, ...(nativeId ? { nativeId } : {}), status: b.is_error ? 'stopped' : background ? 'running' : 'done',
62
+ ...(typeof result.description === 'string' ? { now: result.description } : {}) });
63
+ }
64
+ }
65
+ if (ev.type === 'system' && ['task_started', 'task_progress', 'task_notification'].includes(ev.subtype)) {
66
+ // Other native background tasks (notably Bash) are not subagents.
67
+ const id = aliases.get(ev.tool_use_id) ?? aliases.get(ev.task_id);
68
+ if (id) {
69
+ if (ev.task_id) aliases.set(ev.task_id, id);
70
+ const status = ev.status === 'completed' ? 'done' : ['failed', 'stopped'].includes(ev.status) ? 'stopped' : 'running';
71
+ report({ id, status, ...(ev.description || ev.summary ? { now: ev.description ?? ev.summary } : {}) });
72
+ }
73
+ }
74
+ return agentId;
75
+ },
76
+ };
77
+ }
@@ -20,7 +20,7 @@
20
20
  * classifier. The sentence names what was measured and the one remedy the
21
21
  * machine's own CLI offers; Codex's own words stay beside it.
22
22
  */
23
- import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
23
+ import { scrub as envScrub } from './uplinkScrub.mjs';
24
24
  import { CODEX_RUNTIME } from './runtimeCodex.mjs';
25
25
 
26
26
  const NO_LOGIN = /unexpected status 401 Unauthorized: Missing bearer or basic authentication/;
@@ -55,25 +55,9 @@ export function codexApprovalSentence(refused) {
55
55
  return `Codex on the machine refused ${n} tool call${n === 1 ? '' : 's'} in this turn${which} — "MCP tool call requires approval, but approval policy is never" — so nothing ${n === 1 ? 'it' : 'they'} would have done was done.`;
56
56
  }
57
57
 
58
- /**
59
- * WHAT A TERMINAL OR NEW TASK REPLY MAY CARRY BACK (2026-10-10, the audit's
60
- * final cleanup; F042's daemon half). The server's `SESSION_ANSWER_MAX`
61
- * (shared `fieldCaps.ts`): the settle route (`/fleet/work-turn-done`) cuts an
62
- * answer there and the session writer clamps every message to it. The daemon
63
- * cuts there too before it sends — `withRelay` below and the plain reply in
64
- * `workSessionTurns.mjs` — so nothing past it crosses the wire. It was a bare
65
- * `16000` in both; the release gate (`scripts/check-app-parity.mjs`, rule 29)
66
- * holds this copy equal to the shared one.
67
- */
68
- export const SESSION_ANSWER_MAX = 16_000;
69
-
70
- /** A reply with the sentence first and Codex's own words kept under it,
71
- * scrubbed like every reply and cut to the lane's cap — the words scrubbed
72
- * whole before they are trimmed, and the reply cut only after (`scrubCut`). */
73
- export function withRelay(sentence, raw, cap = SESSION_ANSWER_MAX) {
74
- const reply = (s) => {
75
- const words = envScrub(s).trim();
76
- return words ? `${sentence}\n\n${words}` : sentence;
77
- };
78
- return scrubCut(raw, cap, reply);
58
+ /** A refusal sentence with the CLI's complete scrubbed reply beneath it.
59
+ * No prose cap: a real transport/storage refusal must retain the reply. */
60
+ export function withRelay(sentence, raw) {
61
+ const words = envScrub(String(raw ?? '')).trim();
62
+ return words ? `${sentence}\n\n${words}` : sentence;
79
63
  }
@@ -0,0 +1,34 @@
1
+ const SUBAGENT_LIMIT = 32;
2
+ const THREAD_ID = /^[a-zA-Z0-9-]{16,64}$/;
3
+ const snake = s => String(s ?? '').replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
4
+ export const statusOf = (s) => ({ pending_init: 'waiting', running: 'running', completed: 'done',
5
+ interrupted: 'stopped', errored: 'stopped', shutdown: 'stopped', not_found: 'stopped',
6
+ waiting: 'waiting', asked: 'asked' })[snake(typeof s === 'string' ? s : s && Object.keys(s)[0])];
7
+
8
+ export function codexSpawnResult(item) {
9
+ if (!['collab_tool_call', 'CollabAgentToolCall'].includes(item?.type ?? item?.item_type) || snake(item.tool) !== 'spawn_agent' || !item.id) return null;
10
+ if (['failed', 'errored'].includes(snake(item.status))) return { callId: item.id, success: false };
11
+ const nativeId = item.receiver_thread_ids?.[0];
12
+ return nativeId ? { callId: item.id, nativeId, success: true } : null;
13
+ }
14
+
15
+ export function codexCollabUpdates(item) {
16
+ if (!['collab_tool_call', 'CollabAgentToolCall'].includes(item?.type ?? item?.item_type)) return [];
17
+ const tool = snake(item.tool);
18
+ const states = item.agents_states ?? {};
19
+ const ids = [...new Set([...(item.receiver_thread_ids ?? []), ...Object.keys(states)])].slice(0, SUBAGENT_LIMIT);
20
+ return ids.flatMap(id => {
21
+ if (typeof id !== 'string' || !THREAD_ID.test(id)) return [];
22
+ const state = states[id];
23
+ const receiver = item.receiver_agents?.find(a => a.thread_id === id);
24
+ const status = statusOf(state?.status ?? state);
25
+ const message = state?.message ?? (typeof state?.completed === 'string' ? state.completed : null);
26
+ return [{ id, ...(tool === 'spawn_agent' ? { ...(item.id ? { callId: item.id } : {}), ...(typeof item.prompt === 'string' ? { brief: item.prompt } : {}),
27
+ ...(receiver?.agent_path || receiver?.agent_nickname || item.title ? { title: receiver?.agent_path ?? receiver?.agent_nickname ?? item.title } : {}),
28
+ ...(receiver?.agent_role || item.agent_type ? { kind: receiver?.agent_role ?? item.agent_type } : {}),
29
+ } : {}),
30
+ ...(status ? { status } : ['close_agent', 'interrupt_agent'].includes(tool) ? { status: 'stopped' } : {}),
31
+ ...(typeof message === 'string' ? { now: message, message } : {}),
32
+ }];
33
+ });
34
+ }