flowviant 0.125.4 → 0.125.6

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 (86) hide show
  1. package/bin/lib/agentInternalWait.mjs +1 -5
  2. package/bin/lib/agentPlan.mjs +13 -163
  3. package/bin/lib/agentRepositories.mjs +3 -1
  4. package/bin/lib/claudeInput.mjs +28 -9
  5. package/bin/lib/claudeMachineMcp.mjs +85 -0
  6. package/bin/lib/claudePersonal.mjs +6 -6
  7. package/bin/lib/claudePosture.mjs +6 -15
  8. package/bin/lib/claudeSettingsArg.mjs +67 -0
  9. package/bin/lib/claudeSubagentProgress.mjs +12 -28
  10. package/bin/lib/claudeSubagents.mjs +4 -2
  11. package/bin/lib/codexFencedMount.mjs +275 -0
  12. package/bin/lib/codexPersonal.mjs +1 -265
  13. package/bin/lib/codexProjectRead.mjs +5 -4
  14. package/bin/lib/codexResearch.mjs +9 -3
  15. package/bin/lib/codexSubagentEvents.mjs +11 -7
  16. package/bin/lib/codexSubagents.mjs +55 -52
  17. package/bin/lib/codexToml.mjs +328 -0
  18. package/bin/lib/conversationIds.mjs +10 -0
  19. package/bin/lib/fleet.mjs +2 -2
  20. package/bin/lib/git.mjs +32 -0
  21. package/bin/lib/heldTool.mjs +49 -0
  22. package/bin/lib/hooks/mcpAllow.mjs +11 -5
  23. package/bin/lib/hooks/orchestrator.mjs +20 -10
  24. package/bin/lib/intakeDraft.mjs +1 -1
  25. package/bin/lib/jsonlTail.mjs +52 -0
  26. package/bin/lib/mcpInventory.mjs +9 -5
  27. package/bin/lib/mcpReasons.mjs +20 -0
  28. package/bin/lib/mcpReportBounds.mjs +10 -0
  29. package/bin/lib/mcpStdio.mjs +35 -0
  30. package/bin/lib/mergeBatch.mjs +6 -24
  31. package/bin/lib/mergeOrder.mjs +18 -0
  32. package/bin/lib/modelJson.mjs +173 -0
  33. package/bin/lib/nativeAgentTools.mjs +72 -0
  34. package/bin/lib/orchestrator.mjs +212 -133
  35. package/bin/lib/orchestratorClient.mjs +17 -21
  36. package/bin/lib/orchestratorHooks.mjs +21 -4
  37. package/bin/lib/orchestratorNative.mjs +58 -0
  38. package/bin/lib/orchestratorPrompt.mjs +7 -4
  39. package/bin/lib/orchestratorProtocol.mjs +25 -3
  40. package/bin/lib/postureTools.mjs +58 -13
  41. package/bin/lib/prWorkflow.mjs +13 -2
  42. package/bin/lib/projectRepositories.mjs +2 -2
  43. package/bin/lib/projectToolRuntimes.mjs +92 -245
  44. package/bin/lib/projectTools.mjs +59 -19
  45. package/bin/lib/repositoryDelivery.mjs +6 -4
  46. package/bin/lib/repositoryMerge.mjs +8 -12
  47. package/bin/lib/repositoryPlaces.mjs +2 -2
  48. package/bin/lib/repositoryPrompts.mjs +42 -0
  49. package/bin/lib/repositoryTool.mjs +9 -32
  50. package/bin/lib/researchUse.mjs +39 -0
  51. package/bin/lib/resumeMarker.mjs +43 -0
  52. package/bin/lib/runTurn.mjs +38 -52
  53. package/bin/lib/runtimeAntigravity.mjs +1 -1
  54. package/bin/lib/runtimeCapabilities.mjs +6 -16
  55. package/bin/lib/runtimeClaude.mjs +62 -19
  56. package/bin/lib/runtimeCodex.mjs +9 -6
  57. package/bin/lib/runtimeEvents.mjs +13 -2
  58. package/bin/lib/runtimeOfferings.mjs +9 -4
  59. package/bin/lib/runtimes.mjs +3 -1
  60. package/bin/lib/subagentBounds.mjs +20 -0
  61. package/bin/lib/trace.mjs +30 -5
  62. package/bin/lib/turnDeclaration.mjs +1 -1
  63. package/bin/lib/turnDiagnostics.mjs +9 -12
  64. package/bin/lib/turnReceipts.mjs +64 -0
  65. package/bin/lib/turnSubagents.mjs +91 -0
  66. package/bin/lib/turnUsage.mjs +9 -6
  67. package/bin/lib/versionOrder.mjs +11 -1
  68. package/bin/lib/work.mjs +6 -6
  69. package/bin/lib/workAgentMerges.mjs +5 -7
  70. package/bin/lib/workAgentPrecheck.mjs +5 -5
  71. package/bin/lib/workAgentRecovery.mjs +2 -1
  72. package/bin/lib/workAgentResume.mjs +121 -0
  73. package/bin/lib/workAgentTools.mjs +111 -0
  74. package/bin/lib/workAgentTurnExecution.mjs +62 -246
  75. package/bin/lib/workAgentTurnOutcome.mjs +30 -3
  76. package/bin/lib/workAgentTurnShape.mjs +90 -0
  77. package/bin/lib/workAgentTurns.mjs +24 -10
  78. package/bin/lib/workArtifactReconcile.mjs +3 -2
  79. package/bin/lib/workMergeBatch.mjs +7 -5
  80. package/bin/lib/workPlaceReports.mjs +3 -3
  81. package/bin/lib/workPlaces.mjs +14 -0
  82. package/bin/lib/workSessionRuntime.mjs +2 -5
  83. package/bin/lib/workSessionTurns.mjs +12 -63
  84. package/bin/lib/workShip.mjs +8 -18
  85. package/bin/lib/workTurnShape.mjs +6 -4
  86. package/package.json +1 -1
@@ -25,11 +25,7 @@ export async function runAgentWithInternalWaits(run, options) {
25
25
  let cliFailed = false;
26
26
  let lastAnswer = null;
27
27
  let currentUsage = null;
28
- const combine = () => {
29
- if (!currentUsage) return total;
30
- return Object.fromEntries(['input', 'output', 'cacheCreate', 'cacheRead'].map((k) =>
31
- [k, (total?.[k] ?? 0) + (currentUsage[k] ?? 0)]));
32
- };
28
+ const combine = () => currentUsage ? addUsage(total, currentUsage) : total;
33
29
  const out = await run({
34
30
  ...options,
35
31
  ...(attempt ? {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * READING A MODEL'S ANSWER, in the agent lane's shapes: a planner's
3
- * PROPOSAL, an agent's own TURN RESULT, the AI pre-review's TRIAGE, and an
4
- * incoming ticket's one-card DRAFT (2026-09-28).
3
+ * PROPOSAL, an agent's own TURN RESULT and the AI pre-review's TRIAGE. The
4
+ * objects themselves are found by modelJson.mjs (2026-10-18).
5
5
  *
6
6
  * The scratch agent behind a Deploy press is asked for one JSON object and
7
7
  * nothing else. This is what turns its final message into a proposal, and it is
@@ -32,6 +32,8 @@ import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
32
32
  import { groupDependencies } from './planDependencies.mjs';
33
33
  import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
34
34
  import { blockedAnswer } from './agentAsk.mjs';
35
+ import { isResearchUse } from './researchUse.mjs';
36
+ import { candidateObjects } from './modelJson.mjs';
35
37
 
36
38
  /** The biggest a single proposal may be. Bounds on a machine, not a policy:
37
39
  * the server caps these again at its own boundary. */
@@ -40,164 +42,6 @@ const MAX_TASKS_PER_AGENT = 60;
40
42
  const MAX_NAME = 80;
41
43
  const MAX_NOTE = 1000;
42
44
 
43
- /**
44
- * Find the objects.
45
- *
46
- * A fenced block first, because that is what was asked for. Otherwise every `{`
47
- * in the text is tried as a start, and its BALANCED end is found by counting
48
- * braces while skipping string literals — each caller then takes the first
49
- * candidate whose SHAPE is the one it asked for.
50
- *
51
- * The obvious cheap version — first `{` to last `}` — is wrong in a way a test
52
- * caught: a planner that writes "I looked at {the auth module} first" before its
53
- * JSON produces a span starting at the wrong brace, and the whole plan is lost
54
- * to a sentence. Scanning candidates costs nothing at this size and cannot be
55
- * defeated by prose.
56
- *
57
- * ONE SCANNER FOR EVERY READER (2026-09-16). It was written
58
- * twice — once here and once inline in `parseTurnResult` — and a third copy was
59
- * about to be written for the precheck. A brace scanner that skips string
60
- * literals is exactly the kind of thing where two copies quietly stop agreeing
61
- * about escapes and nobody notices, because the disagreement only shows up on a
62
- * card title with a quote in it.
63
- */
64
- /**
65
- * BOUNDED, BECAUSE THE TEXT IS A MODEL'S (2026-09-24, the audit).
66
- *
67
- * The scan used to walk from EVERY `{` to the end of the text, and parse every
68
- * balanced span it found — quadratic on both counts. A final message of eighty
69
- * thousand `{` (a quoted fixture, brace padding, a turn steered into writing
70
- * one) took nine seconds of synchronous CPU, and while it ran nothing else on
71
- * the machine moved: no poll, no lease renewal, no other lane. Three bounds,
72
- * none of which a real answer comes near:
73
- *
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;
76
- * · each `{`'s closing brace is found ONCE (see `closers`), so the scan is a
77
- * single right-to-left pass instead of one walk per brace;
78
- * · only spans that can open an object (`{` then a key or `}`) are handed to
79
- * JSON.parse. Every TOP-LEVEL span is parsed — top-level spans are
80
- * disjoint, so together they are at most the scanned window — and the
81
- * NESTED ones (a span inside an earlier candidate) share a budget of
82
- * `MAX_NESTED_PARSES` spans and `MAX_NESTED_CHARS` characters, so a run of
83
- * nested braces that parse is not re-parsed thousands of times.
84
- *
85
- * THE BUDGET NEVER REACHES A TOP-LEVEL SPAN, and the review found why it must
86
- * not: the first cut counted every span from the left against one budget, so
87
- * an answer that followed 250 JSON lines of a quoted fixture (or one large
88
- * nested document) was never parsed at all — the turn read as `nothing` and a
89
- * delivered agent went to Stuck. The contract puts the answer LAST and at the
90
- * top level, which is the one span a budget must never cost.
91
- */
92
- const MAX_NESTED_PARSES = 200;
93
- const MAX_NESTED_CHARS = 4 * 1024 * 1024;
94
-
95
- export function candidateObjects(text) {
96
- const fenced = fencedBody(text);
97
- const raw = text;
98
- const bodies = [];
99
- if (fenced !== null) bodies.push(fenced);
100
- const close = closers(raw);
101
- let topEnd = -1;
102
- let nestedParses = 0;
103
- let nestedChars = 0;
104
- for (let i = 0; i < raw.length; i++) {
105
- if (raw[i] !== '{') continue;
106
- const end = close.get(i);
107
- if (!(end > i) || !/^\{\s*["}]/.test(raw.slice(i, i + 64))) continue;
108
- if (i > topEnd) {
109
- topEnd = end;
110
- } else {
111
- const len = end - i + 1;
112
- if (nestedParses >= MAX_NESTED_PARSES || nestedChars + len > MAX_NESTED_CHARS) continue;
113
- nestedParses++;
114
- nestedChars += len;
115
- }
116
- bodies.push(raw.slice(i, end + 1));
117
- }
118
- const out = [];
119
- for (const body of bodies) {
120
- if (!body.trim()) continue;
121
- try {
122
- const v = JSON.parse(body);
123
- if (v && typeof v === 'object') out.push(v);
124
- } catch {
125
- /* the next candidate may be the object */
126
- }
127
- }
128
- return out;
129
- }
130
-
131
- /**
132
- * THE FIRST FENCED BLOCK'S BODY, or null — what
133
- * `text.match(/```(?:json)?\s*([\s\S]*?)```/)[1]` answered, found in ONE
134
- * forward pass (2026-10-10, the audit's F079).
135
- *
136
- * That regex ran over the WHOLE text and backtracks quadratically on an opening fence
137
- * followed by a long whitespace run with no closing fence: the greedy `\s*` and
138
- * the lazy body re-scan the run from every split. Measured: 80 KB took 0.6 s,
139
- * 320 KB nine seconds, of synchronous CPU in which no poll, lease or other
140
- * lane moved — and a turn's stdout is model-written and uncapped. Same
141
- * answer, by `indexOf`: the first ``` anywhere, an optional `json`, the
142
- * whitespace after it skipped, and the body runs to the next ```. If that
143
- * opening has no closer, no later opening can have one either, so there is no
144
- * fence. The search stays over the whole text.
145
- */
146
- const FENCE_SPACE = /\s*/y;
147
- function fencedBody(text) {
148
- const open = text.indexOf('```');
149
- if (open < 0) return null;
150
- let at = open + 3;
151
- if (text.startsWith('json', at)) at += 4;
152
- FENCE_SPACE.lastIndex = at;
153
- FENCE_SPACE.exec(text);
154
- at = FENCE_SPACE.lastIndex;
155
- const close = text.indexOf('```', at);
156
- return close < 0 ? null : text.slice(at, close);
157
- }
158
-
159
- /**
160
- * For every `{`, the index of the `}` that closes it when read as the START of
161
- * an object (outside any string), or -1 — the answer `balanced` gives, for all
162
- * of them in one pass.
163
- *
164
- * Right to left, so every `{` AFTER the one being closed already has its
165
- * answer. A walk that meets such a `{` outside a string jumps straight to that
166
- * brace's closer — the string state from there on is the same for both walks,
167
- * so the nested object ends where it ends — and if that brace never closes,
168
- * neither does this one. What is left for each walk is its own top level.
169
- */
170
- // The conversation-prose filter uses the same brace/string boundaries.
171
- export { closers as jsonObjectClosers };
172
- function closers(s) {
173
- const close = new Map();
174
- for (let i = s.lastIndexOf('{'); i >= 0; i = i > 0 ? s.lastIndexOf('{', i - 1) : -1) {
175
- let inStr = false;
176
- let esc = false;
177
- let end = -1;
178
- for (let k = i + 1; k < s.length; k++) {
179
- const ch = s[k];
180
- if (inStr) {
181
- if (esc) esc = false;
182
- else if (ch === '\\') esc = true;
183
- else if (ch === '"') inStr = false;
184
- continue;
185
- }
186
- if (ch === '"') inStr = true;
187
- else if (ch === '{') {
188
- const inner = close.get(k);
189
- if (inner === undefined || inner < 0) break;
190
- k = inner;
191
- } else if (ch === '}') {
192
- end = k;
193
- break;
194
- }
195
- }
196
- close.set(i, end);
197
- }
198
- return close;
199
- }
200
-
201
45
  function extract(raw) {
202
46
  return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
203
47
  }
@@ -411,7 +255,7 @@ export function parseTurnResult(text, scrub = envScrub) {
411
255
  if (v.status === 'delivered') {
412
256
  return {
413
257
  outcome: 'delivered',
414
- ...(['tasks', 'context', 'document'].includes(v.researchUse) ? { researchUse: v.researchUse } : {}),
258
+ ...(isResearchUse(v.researchUse) ? { researchUse: v.researchUse } : {}),
415
259
  ...(Array.isArray(v.mergeOrder) ? { mergeOrder: v.mergeOrder.filter((id) => typeof id === 'string').slice(0, 64) } : {}),
416
260
  ...(typeof v.mergeOrderReason === 'string' ? { mergeOrderReason: clean(v.mergeOrderReason, 1000) } : {}),
417
261
  ...(Array.isArray(v.mergeOrders) ? { mergeOrders: v.mergeOrders.slice(0, 60) } : {}),
@@ -447,6 +291,12 @@ export const MAX_PRECHECK_OVERALL = 1200;
447
291
  export const MAX_PRECHECK_CRITERIA = 12;
448
292
  export const MAX_PRECHECK_CRITERION = 200;
449
293
  export const MAX_PRECHECK_LINE = 160;
294
+ /** The verdict words the server's boundary admits (shared `agents.schema.ts`
295
+ * PRECHECK_CARD_VERDICTS / PRECHECK_CRITERION_VERDICTS, 2026-10-18), held
296
+ * equal by the release gate (rule 31): a word kept here and refused there
297
+ * is a reading the panel never draws. */
298
+ export const PRECHECK_CARD_VERDICTS = ['ok', 'concerns'];
299
+ export const PRECHECK_CRITERION_VERDICTS = ['met', 'concern', 'unclear'];
450
300
 
451
301
  /**
452
302
  * ONE CARD'S CRITERIA, as the reader judged them (2026-10-10).
@@ -464,7 +314,7 @@ function precheckCriteria(raw, scrub) {
464
314
  if (out.length >= MAX_PRECHECK_CRITERIA) break;
465
315
  if (!k || typeof k !== 'object') continue;
466
316
  if (typeof k.criterion !== 'string' || !k.criterion.trim()) continue;
467
- if (k.verdict !== 'met' && k.verdict !== 'concern' && k.verdict !== 'unclear') continue;
317
+ if (!PRECHECK_CRITERION_VERDICTS.includes(k.verdict)) continue;
468
318
  if (k.line != null && typeof k.line !== 'string') continue;
469
319
  const criterion = scrubCut(k.criterion.trim(), MAX_PRECHECK_CRITERION, scrub);
470
320
  const line = typeof k.line === 'string' ? scrubCut(k.line.trim(), MAX_PRECHECK_LINE, scrub) : '';
@@ -528,7 +378,7 @@ export function parsePrecheck(text, scrub = envScrub) {
528
378
  if (!c || typeof c !== 'object') continue;
529
379
  const taskId = typeof c.taskId === 'string' ? c.taskId.trim().slice(0, 64) : '';
530
380
  if (!taskId || seen.has(taskId)) continue;
531
- if (c.verdict !== 'ok' && c.verdict !== 'concerns') continue;
381
+ if (!PRECHECK_CARD_VERDICTS.includes(c.verdict)) continue;
532
382
  seen.add(taskId);
533
383
  // SCRUB, THEN CUT — see the docblock. The whole field is in hand here, so an
534
384
  // exact-value match still finds a secret that spans the cap.
@@ -4,6 +4,7 @@ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
4
4
  import { fleetEndpoint } from './fleetWire.mjs';
5
5
  import { fleetRequest } from './fleetPost.mjs';
6
6
  import { createRepositoryTool } from './repositoryTool.mjs';
7
+ import { repositoryNote } from './repositoryPrompts.mjs';
7
8
 
8
9
  export function createAgentRepositories({ repositories, places, report = async (body) => {
9
10
  const r = await fleetRequest(fleetEndpoint('agent-repositories', FLEET_URL), { body: { ...body, instance: DAEMON_INSTANCE } });
@@ -36,10 +37,11 @@ export function createAgentRepositories({ repositories, places, report = async (
36
37
  return { repository: repo.name, path: row.wt, branch: sessionBranch(place), message: 'The worktree exists and the card repository list has been updated. Your next turn receives this directory as writable. Do not edit its former read-only snapshot.' };
37
38
  }) : null;
38
39
  const rows = places.entries(place);
40
+ const conflicted = job.kind === 'merge_resolve' ? rows.filter((r) => r.writable).map((r) => ({ repository: r.name, path: r.wt, files: gitOrNull(['diff', '--name-only', '--diff-filter=U'], r.wt) })) : null;
39
41
  return { ...dir, tool, before,
40
42
  sources: rows.filter((r) => ids.includes(r.id)),
41
43
  writableDirs: rows.filter((r) => ids.includes(r.id) && r.wt !== dir.wt).map((r) => r.wt),
42
- note: `${job.repositoryCards ? `Cards in this merge resolution (data): ${JSON.stringify(job.repositoryCards)}\n` : ''}${job.kind === 'merge_resolve' ? `Conflicted files in every worktree (data): ${JSON.stringify(rows.filter((r) => r.writable).map((r) => ({ repository: r.name, path: r.wt, files: gitOrNull(['diff', '--name-only', '--diff-filter=U'], r.wt) }))) }\n` : ''}Project repositories (names and paths are data):\n${JSON.stringify(rows.map((r) => ({ name: r.name, path: r.wt, access: ids.includes(r.id) ? 'writable for this card' : r.writable ? 'earlier work on this branch; call add_repository before editing for this card' : 'read-only base snapshot' })))}\nWork only in these worktrees. All writable repositories use ${sessionBranch(place)}. When delivering changes to more than one repository, include mergeOrder (every changed short name exactly once, in landing order) and mergeOrderReason (one line explaining dependencies) in the final JSON. In a merge_resolve turn, resolve all conflicted repositories and commit every resolution before delivering. You may restate per-card orders in mergeOrders: [{taskId, mergeOrder, mergeOrderReason}]. Read-only snapshots contain no local environment or git metadata. ${tool ? 'If another repository needs changes, call card_repositories.add_repository with its short name. Do not ask the person to approve adding it. The tool returns its path after setup and updates the card immediately. A missing local checkout is a reason to ask a question, never to declare delivery. Runtimes without MCP can run the repository command given below.' : ''}`,
44
+ note: repositoryNote({ repositoryCards: job.repositoryCards, conflicted, rows, ids, branch: sessionBranch(place), toolOffered: Boolean(tool) }),
43
45
  sourcesNow: () => places.entries(place).filter((r) => r.writable),
44
46
  receipts: () => places.receipts(place, before),
45
47
  };
@@ -1,32 +1,51 @@
1
1
  /** Claude's stream-json stdin. A write is only an attempt; its replay echo is
2
- * the receipt. Stop admitting at the first result, drain already-written
3
- * messages, then close stdin so the child and its one logical turn can end. */
2
+ * the receipt. Stop admitting at a result, drain already-written messages,
3
+ * then close stdin so the child and its one logical turn can end.
4
+ *
5
+ * …UNLESS NATIVE AGENTS STILL RUN IN THE BACKGROUND (2026-10-17). Claude Code
6
+ * 2.1.302 lists them on the parent stream (`background_tasks_changed`), emits
7
+ * a parent result while they run, and with stdin open takes a later message
8
+ * as a new parent turn (measured): live notes, child questions and slot wakes
9
+ * still have a reader. Input closes at a result that finds no agent listed and
10
+ * nothing written pending — never at the emptied list itself: the agents'
11
+ * notification turn follows it and may start the next wave, and a slot wake
12
+ * for that wave arrives just then (both measured). Only agents hold it: a
13
+ * background shell (a dev server) may never end, and the CLI's own wind-down
14
+ * waits on those. A CLI that never lists tasks closes at its first result. */
4
15
  export function createClaudeInput(child, { onTaken = () => {} } = {}) {
5
16
  const sent = new Set();
6
17
  const pending = new Set();
7
- let accepting = true;
18
+ let draining = false; // a result found no live agent: admit nothing, close once pending is read
19
+ let broken = false;
8
20
  let ended = false;
9
21
  let resultSeen = false;
10
- child.stdin.on('error', () => { accepting = false; }); // EPIPE leaves the message queued
22
+ let agents = 0; // live background agents, as the parent stream last listed them
23
+ child.stdin.on('error', () => { broken = true; }); // EPIPE leaves the message queued
24
+ const end = () => { if (!ended) { ended = true; child.stdin.end(); } };
11
25
  const send = (id, prompt) => {
12
- if (!accepting || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
26
+ if (draining || broken || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
13
27
  const message = { type: 'user', uuid: id, session_id: '', message: { role: 'user', content: prompt }, parent_tool_use_id: null };
14
28
  sent.add(id);
15
29
  pending.add(id);
16
30
  try { child.stdin.write(JSON.stringify(message) + '\n'); return true; }
17
- catch { pending.delete(id); accepting = false; return false; }
31
+ catch { pending.delete(id); broken = true; return false; }
18
32
  };
19
33
  return {
20
34
  send,
21
35
  event(event) {
22
36
  if (event.type === 'user' && event.isReplay === true && pending.delete(event.uuid)) onTaken(event.uuid);
37
+ if (event.type === 'system' && event.subtype === 'background_tasks_changed' && Array.isArray(event.tasks)) {
38
+ agents = event.tasks.filter(task => task?.task_type === 'local_agent').length;
39
+ if (agents > 0) draining = false; // a drained message's turn started agents
40
+ }
23
41
  if (event.type === 'result') {
24
42
  resultSeen = true;
25
- accepting = false;
26
- if ((pending.size === 0 || event.is_error === true) && !ended) { ended = true; child.stdin.end(); }
43
+ if (event.is_error === true) end();
44
+ else if (agents > 0) draining = false;
45
+ else { draining = true; if (pending.size === 0) end(); }
27
46
  }
28
47
  },
29
- close() { accepting = false; ended = true; },
48
+ close() { ended = true; },
30
49
  get resultSeen() { return resultSeen; },
31
50
  };
32
51
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * THE MACHINE'S OWN MCP SERVERS THAT A CARD TURN'S SETTINGS SOURCES NEVER
3
+ * REACH (2026-10-17). The 2026-10-11 ruling mounts "the user and local
4
+ * servers" on every card turn, and neither arrived where the person keeps
5
+ * them. MEASURED on Claude Code 2.1.302, a scratch CLAUDE_CONFIG_DIR whose
6
+ * `.claude.json` held one user and one local server:
7
+ * · `--setting-sources ''` (every fenced posture: write-up, mockup, 3D model,
8
+ * deck) loaded NEITHER — a research card on the owner's box saw the
9
+ * claude.ai connectors and no Roblox_Studio, a user-scope server;
10
+ * · `--setting-sources user` (a code card) loaded the user server only;
11
+ * · a local server (`claude mcp add`'s default scope) is keyed on the
12
+ * CANONICAL git root — the main checkout, which the CLI also resolves a
13
+ * linked worktree to (re-measured 2026-10-17: a worktree cwd with no
14
+ * `--setting-sources` loaded it). What drops it on a card is the `local`
15
+ * source the card's list leaves out, not the worktree's path.
16
+ * So the lane names what is missing and the adapter hands it over through a
17
+ * private `--mcp-config` file (runtimeClaude.mjs): `user` when the turn's
18
+ * sources drop the user scope, and the checkout's local servers always. A
19
+ * local name shadows a user one, the CLI's own precedence; an `--mcp-config`
20
+ * server beat a user server of the same name (measured), so a code card keeps
21
+ * that order too.
22
+ *
23
+ * ITS OWN MODULE (2026-10-18, the SOLID pass). It sat in claudePersonal.mjs,
24
+ * whose ruling is the person's settings allowlist and private settings file
25
+ * (2026-09-29, 2026-10-10), and was the only reason that module imported git;
26
+ * the agent lane imported claudePersonal.mjs for this function alone. Where
27
+ * Claude Code keeps user and local servers in `.claude.json` is a different
28
+ * fact, which moves on its own: a change to how the CLI keys them, or a new
29
+ * scope, opens this file and no other (claudeMachineMcp.test.mjs pins the
30
+ * file name to here).
31
+ */
32
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { basename, dirname, join, resolve } from 'node:path';
35
+ import { gitOrNull } from './git.mjs';
36
+
37
+ /** Far past any real CLI config — `.claude.json` also holds every project's
38
+ * history; a bigger one is not read. */
39
+ const CLI_CONFIG_MAX_BYTES = 32 * 1024 * 1024;
40
+ const isRecord = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
41
+
42
+ /**
43
+ * THREE STATES (2026-10-17): `{}` when the file is absent (measured: the
44
+ * machine has none), `null` when it exists but could not be read, is too big
45
+ * or is not an object (unknown), so a caller never says a server is mounted
46
+ * that was never read.
47
+ */
48
+ export function machineMcpServers({ user = false, checkout = null, env = process.env } = {}) {
49
+ const path = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, '.claude.json') : join(env.HOME || homedir(), '.claude.json');
50
+ let raw;
51
+ try {
52
+ if (statSync(path).size > CLI_CONFIG_MAX_BYTES) return null;
53
+ raw = JSON.parse(readFileSync(path, 'utf8'));
54
+ } catch (error) {
55
+ return error?.code === 'ENOENT' ? {} : null;
56
+ }
57
+ if (!isRecord(raw)) return null;
58
+ const out = {};
59
+ const take = (servers) => {
60
+ if (!isRecord(servers)) return;
61
+ for (const [name, config] of Object.entries(servers)) if (name !== '__proto__' && isRecord(config)) out[name] = config;
62
+ };
63
+ if (user) take(raw.mcpServers);
64
+ if (typeof checkout === 'string' && checkout && isRecord(raw.projects)) {
65
+ const keys = [resolve(checkout)];
66
+ try { keys.push(realpathSync(checkout)); } catch { /* the resolved path alone */ }
67
+ const root = canonicalGitRoot(checkout);
68
+ if (root) keys.unshift(root);
69
+ const key = keys.find((k) => Object.hasOwn(raw.projects, k));
70
+ if (key) take(raw.projects[key]?.mcpServers);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** The main checkout a directory's repository resolves to — the key the CLI
76
+ * files a local server under when the connected folder is itself a linked
77
+ * worktree. Null when git cannot say. */
78
+ function canonicalGitRoot(dir) {
79
+ const common = gitOrNull(['rev-parse', '--path-format=absolute', '--git-common-dir'], dir);
80
+ try {
81
+ return common && basename(common) === '.git' ? realpathSync(dirname(common)) : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
@@ -49,6 +49,7 @@ import { randomBytes } from 'node:crypto';
49
49
  import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
50
50
  import { homedir } from 'node:os';
51
51
  import { basename, dirname, join } from 'node:path';
52
+ import { replaceSettings } from './claudeSettingsArg.mjs';
52
53
  import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
53
54
  import { WINDOW_ENV_REMOVED, withoutWindows } from './noWindowEnv.mjs';
54
55
 
@@ -182,11 +183,13 @@ export function writeTurnSettings(settings, { dir = turnSettingsDir() } = {}) {
182
183
  }
183
184
  }
184
185
 
185
- /** Remove the settings file a turn's argv names, when it is one of ours. */
186
+ /** Remove the settings file a turn's argv names, when it is one of ours — and
187
+ * the machine-MCP file (claudeMachineMcp.mjs `machineMcpServers`), written
188
+ * the same way. */
186
189
  export function releaseTurnSettings(args, { dir = turnSettingsDir() } = {}) {
187
190
  if (!Array.isArray(args)) return;
188
191
  args.forEach((a, i) => {
189
- if (i === 0 || args[i - 1] !== '--settings' || typeof a !== 'string') return;
192
+ if (i === 0 || !['--settings', '--mcp-config'].includes(args[i - 1]) || typeof a !== 'string') return;
190
193
  if (dirname(a) !== dir || !TURN_SETTINGS_FILE.test(basename(a))) return;
191
194
  rmSync(a, { force: true });
192
195
  });
@@ -203,8 +206,5 @@ export function releaseTurnSettings(args, { dir = turnSettingsDir() } = {}) {
203
206
  export function withPersonalSettings(perm, kept, { write = writeTurnSettings } = {}) {
204
207
  const personal = kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {};
205
208
  if (!Object.keys(personal).length) return [...perm];
206
- const at = perm.indexOf('--settings');
207
- if (at < 0) return ['--settings', write(personal), ...perm];
208
- const posture = JSON.parse(perm[at + 1]);
209
- return [...perm.slice(0, at), '--settings', write({ ...personal, ...posture }), ...perm.slice(at + 2)];
209
+ return replaceSettings(perm, (posture) => write({ ...personal, ...posture }));
210
210
  }
@@ -13,6 +13,7 @@
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { SAFE } from './config.mjs';
15
15
  import { runningCompiledBinary } from './launchCommand.mjs';
16
+ import { withSettings } from './claudeSettingsArg.mjs';
16
17
 
17
18
  /**
18
19
  * THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
@@ -479,8 +480,10 @@ export const RESEARCH_PERM = researchPerm(null);
479
480
 
480
481
  /**
481
482
  * CLAUDE'S PERMISSION LIST FOR A TURN PROFILE, by the profile's name — one
482
- * entry per name in turnProfile.mjs (a test holds the two tables equal). The
483
- * builders that take the knowledge directory get it; the fixed lists ignore it.
483
+ * entry per profile Claude declares, plus plan-mode (runtimeClaude.mjs
484
+ * `profiles`; a test holds the two equal). runTurn asks for it through the
485
+ * row (`CLAUDE_RUNTIME.perm`), on Claude alone. The builders that take the
486
+ * knowledge directory get it; the fixed lists ignore it.
484
487
  * `projectMcp` is the names of the project servers the lane mounted; only the
485
488
  * design and research lists take them (`projectMcp` in turnProfile.mjs).
486
489
  */
@@ -492,13 +495,6 @@ const CLAUDE_PERM = {
492
495
  'plan-mode': () => PLAN_MODE_PERM,
493
496
  design: (kd, _vault, mcp) => designPermFor(kd, mcp),
494
497
  research: (kd, _vault, mcp) => researchPerm(kd, mcp),
495
- // THE IMAGE POSTURE IS CODEX'S (0.114.0) and Claude does not declare it
496
- // (runtimeClaude.mjs `profiles`), so turnProfile.mjs refuses a Claude turn
497
- // under it before argv is built. The entry exists because `runTurn` looks
498
- // Claude's list up BY NAME for every runtime (the adapter that is not Claude
499
- // ignores it), and a missing name throws. Were it ever reached on Claude it
500
- // is the consult list: read-only, no write at all — failing closed.
501
- image: (kd) => consultPermFor(kd),
502
498
  };
503
499
  export const CLAUDE_PERM_PROFILES = Object.freeze(Object.keys(CLAUDE_PERM));
504
500
  export function claudePermFor(profileName, knowledgeDir, vaultDir, projectMcp = []) {
@@ -541,10 +537,5 @@ export function claudePermFor(profileName, knowledgeDir, vaultDir, projectMcp =
541
537
  * of a fenced posture.
542
538
  */
543
539
  export function withMachineMcp(perm, disabledProjectMcp) {
544
- const extra = { disabledMcpjsonServers: [...disabledProjectMcp] };
545
- const at = perm.indexOf('--settings');
546
- if (at < 0) return ['--settings', JSON.stringify({ ...extra, hooks: { PreToolUse: [MCP_ALLOW_HOOK] } }), ...perm];
547
- const posture = JSON.parse(perm[at + 1]);
548
- const hooks = { ...(posture.hooks ?? {}), PreToolUse: [...(posture.hooks?.PreToolUse ?? []), MCP_ALLOW_HOOK] };
549
- return [...perm.slice(0, at), '--settings', JSON.stringify({ ...posture, ...extra, hooks }), ...perm.slice(at + 2)];
540
+ return withSettings(perm, { disabledMcpjsonServers: [...disabledProjectMcp], hooks: { PreToolUse: [MCP_ALLOW_HOOK] } });
550
541
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A TURN'S ONE `--settings`, SPLICED AND READ IN ONE PLACE (2026-10-18, the
3
+ * SOLID pass). Three functions in two modules spliced the flag — the machine's
4
+ * MCP settings and the orchestrator's hooks (claudePosture.mjs), the person's
5
+ * kept keys (claudePersonal.mjs) — each repeating find, parse, merge, then
6
+ * splice or prepend, and the turn diagnostics read it a fourth way. The
7
+ * 2026-10-17 bug (the orchestrator's hooks on a second `--settings`, never
8
+ * loaded) is that class: the next thing a turn's settings carry merges here,
9
+ * into the one flag, and claudeSettingsArg.test.mjs keeps every other module
10
+ * from finding it for itself.
11
+ *
12
+ * THE PERSONAL STEP RUNS LAST. claudePersonal.mjs `withPersonalSettings` turns
13
+ * the value into the path of a private file (the person's keys never ride
14
+ * argv), so nothing merges after it: `replaceSettings` reads inline JSON only
15
+ * and refuses a path in words — merging into that file would put the person's
16
+ * secrets back on the command line. `settingsAt`, the diagnostics' reader,
17
+ * reads either.
18
+ */
19
+ import { readFileSync } from 'node:fs';
20
+
21
+ /** The turn's `--settings`: where it stands (-1 for none) and what it holds —
22
+ * null with no flag, else the inline JSON or the file it names, parsed. May
23
+ * throw (a file gone, a value that does not parse); the caller decides. */
24
+ export function settingsAt(args) {
25
+ const at = args.indexOf('--settings');
26
+ if (at < 0) return { at, settings: null };
27
+ const input = args[at + 1];
28
+ return { at, settings: JSON.parse(input.startsWith('{') ? input : readFileSync(input, 'utf8')) };
29
+ }
30
+
31
+ /** An inline value, parsed — never a path (see the header). */
32
+ function inline(value) {
33
+ if (typeof value !== 'string' || !value.startsWith('{')) {
34
+ throw new Error("this turn's --settings already names a file; nothing merges after the personal settings");
35
+ }
36
+ return JSON.parse(value);
37
+ }
38
+
39
+ /**
40
+ * `perm` with its one `--settings` replaced by `fn(posture)` — `posture` the
41
+ * flag's own object, `{}` where there is none — standing where the flag stood,
42
+ * or before the lists when there was none (`--allowedTools` is variadic).
43
+ */
44
+ export function replaceSettings(perm, fn) {
45
+ const at = perm.indexOf('--settings');
46
+ const value = at < 0 ? fn({}) : fn(inline(perm[at + 1]));
47
+ return at < 0 ? ['--settings', value, ...perm] : [...perm.slice(0, at), '--settings', value, ...perm.slice(at + 2)];
48
+ }
49
+
50
+ /**
51
+ * ONE `--settings` PER TURN (2026-10-17). Claude Code applies only the LAST
52
+ * `--settings` it is handed (measured, 2.1.302: two flags each holding a
53
+ * PreToolUse hook ran the second hook alone). The card orchestrator's hooks
54
+ * rode a flag of their own ahead of the machine-MCP settings (claudePosture.mjs
55
+ * `withMachineMcp`), so on every code card that mounts the machine's servers
56
+ * the spawn gate, child registration and checkpoints never loaded
57
+ * (claudeOrchestratorHooks.test.mjs
58
+ * re-measures it on the real CLI). `extra` hooks join the posture's per event,
59
+ * after its own; its other keys win over the posture's.
60
+ */
61
+ export function withSettings(perm, { hooks: extraHooks = {}, ...extra }) {
62
+ return replaceSettings(perm, (posture) => {
63
+ const hooks = { ...(posture.hooks ?? {}) };
64
+ for (const [event, rows] of Object.entries(extraHooks)) hooks[event] = [...(hooks[event] ?? []), ...rows];
65
+ return JSON.stringify({ ...posture, ...extra, hooks });
66
+ });
67
+ }
@@ -6,11 +6,11 @@ import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { claudeProjectDir } from './claudeSessions.mjs';
8
8
  import { CLAUDE_SESSION_RE } from './conversationIds.mjs';
9
+ import { followJsonl, newTail } from './jsonlTail.mjs';
9
10
 
10
- const MAX_READ = 4 * 1024 * 1024;
11
- const LINE_MAX = 256 * 1024;
12
11
  export function createClaudeSubagentProgress({ cwd, since = Date.now(), env = process.env, onLine }) {
13
- let file, offset = null, rest = Buffer.alloc(0), skip = false, timer;
12
+ const tail = newTail();
13
+ let file, timer;
14
14
  let chain = Promise.resolve();
15
15
  const flush = () => {
16
16
  chain = chain.then(async () => {
@@ -18,31 +18,15 @@ export function createClaudeSubagentProgress({ cwd, since = Date.now(), env = pr
18
18
  let fh;
19
19
  try {
20
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
- }
21
+ await followJsonl(fh, tail, line => {
22
+ try {
23
+ const ev = JSON.parse(line);
24
+ if (ev.type !== 'progress' || ev.data?.type !== 'agent_progress' || !ev.parentToolUseID) return;
25
+ const at = Date.parse(ev.timestamp ?? '');
26
+ if (!Number.isFinite(at) || at < since) return;
27
+ onLine(line);
28
+ } catch { /* torn or unknown native row */ }
29
+ });
46
30
  } catch { /* the transcript can appear after init */ }
47
31
  finally { await fh?.close().catch(() => {}); }
48
32
  }).catch(() => {});
@@ -1,3 +1,5 @@
1
+ import { NATIVE_AGENT_TOOLS } from './nativeAgentTools.mjs';
2
+
1
3
  /** Native tool-use ids are stable for the lifetime of an Agent/Task call. */
2
4
  export function createClaudeSubagents(report = () => {}) {
3
5
  const calls = new Map();
@@ -29,12 +31,12 @@ export function createClaudeSubagents(report = () => {}) {
29
31
  const agentId = identify(ev);
30
32
  if (ev.type === 'assistant') {
31
33
  for (const b of ev.message?.content ?? []) {
32
- if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && agentId) {
34
+ if (b.type === 'tool_use' && NATIVE_AGENT_TOOLS.claude.ask.includes(b.name) && agentId) {
33
35
  questions.set(b.id, agentId);
34
36
  if (questions.size > 128) questions.delete(questions.keys().next().value);
35
37
  report({ id: agentId, status: 'asked' });
36
38
  }
37
- if (b.type !== 'tool_use' || !['Agent', 'Task'].includes(b.name) || !b.id) continue;
39
+ if (b.type !== 'tool_use' || !NATIVE_AGENT_TOOLS.claude.spawn.includes(b.name) || !b.id) continue;
38
40
  const input = b.input ?? {};
39
41
  const id = aliases.get(input.resume) ?? b.id;
40
42
  calls.set(b.id, { id, input });