flowviant 0.125.5 → 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 -57
  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 +34 -52
  53. package/bin/lib/runtimeAntigravity.mjs +1 -1
  54. package/bin/lib/runtimeCapabilities.mjs +6 -16
  55. package/bin/lib/runtimeClaude.mjs +47 -20
  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
@@ -14,14 +14,12 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
- import { NO_SETTING_SOURCES, machineMcpServers, personalClaudeSettings, releaseTurnSettings, withPersonalSettings, writeTurnSettings } from './claudePersonal.mjs';
18
- import { withMachineMcp } from './claudePosture.mjs';
17
+ import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings, writeTurnSettings } from './claudePersonal.mjs';
18
+ import { machineMcpServers } from './claudeMachineMcp.mjs';
19
+ import { claudePermFor, withMachineMcp } from './claudePosture.mjs';
20
+ import { withSettings } from './claudeSettingsArg.mjs';
19
21
  import { orchestratorHooks } from './orchestratorHooks.mjs';
20
22
 
21
- /** The profiles an agent lane mounts the machine's MCP servers on (2026-10-11):
22
- * the ones whose turn profile says `projectMcp` and that Claude declares. */
23
- const MACHINE_MCP_PROFILES = Object.freeze(['build', 'design', 'research']);
24
-
25
23
  function claudeMcp(token, mcpUrl) {
26
24
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
27
25
  const path = join(dir, 'mcp.json');
@@ -48,10 +46,10 @@ export const CLAUDE_RUNTIME = {
48
46
  bin: 'claude',
49
47
  install: 'npm i -g @anthropic-ai/claude-code',
50
48
  login: 'claude',
51
- /** The Agent-SDK live session (persistent, injectable mid-task) is Claude
52
- * only — it is an Anthropic SDK, not a CLI contract. Everything else runs
53
- * the subprocess path. */
54
- live: true,
49
+ /** Takes follow-up messages on stdin mid-turn (claudeInput.mjs,
50
+ * workLiveMessages.mjs); a runtime without it gets its follow-ups as the
51
+ * next turn, and its delivery is reported taken at spawn. */
52
+ liveInput: true,
55
53
  /**
56
54
  * Every profile, because every profile is DEFINED in its vocabulary: the
57
55
  * four `--allowedTools` lists in claudePosture.mjs are what "build", "wiki",
@@ -92,11 +90,24 @@ export const CLAUDE_RUNTIME = {
92
90
  * RETIRED_POSTURE_KEYS), so no spelling of it reads as one.
93
91
  * adopt: 'fork' — a terminal conversation is forked into the tab's
94
92
  * worktree (`--resume <id> --fork-session`).
93
+ * liveInput (above) — a follow-up reaches the running CLI on stdin
94
+ * (2026-10-18, the SOLID pass: the tab, agent and runTurn lanes spelled
95
+ * it `rt.id === 'claude'`, and the row's old `live` flag was read by
96
+ * nothing).
95
97
  */
96
98
  tab: 'mcp',
97
99
  planTurns: true,
98
100
  adopt: 'fork',
99
101
  mcp: claudeMcp,
102
+ /**
103
+ * THE POSTURE AS CLAUDE'S `--allowedTools` LIST (2026-10-18, the SOLID
104
+ * pass), looked up by the turn profile's name (claudePosture.mjs
105
+ * `claudePermFor`). runTurn asks the row for it, so it knows no runtime's
106
+ * list: a runtime that expresses postures another way — Codex's sandbox
107
+ * modes, agy's flags — has no hook, and Claude's table needs no stub for a
108
+ * posture only another CLI runs.
109
+ */
110
+ perm: claudePermFor,
100
111
  /**
101
112
  * Claude takes the operating contract as a real system prompt, which is the
102
113
  * strongest form of it available anywhere: `--append-system-prompt` sits
@@ -170,7 +181,6 @@ export const CLAUDE_RUNTIME = {
170
181
  * agent renders the CURRENT base snapshot, not the first turn's cache.
171
182
  */
172
183
  const fenced = profile !== 'build';
173
- if (orchestration && !fenced) a.push('--settings', JSON.stringify({ hooks: orchestratorHooks(), disableAllHooks: false }));
174
184
  if (fenced) a.push(...NO_SETTING_SOURCES);
175
185
  // A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
176
186
  // under the CLI's `default` mode, where a tool the list does not name is
@@ -191,12 +201,17 @@ export const CLAUDE_RUNTIME = {
191
201
  // beside the base branch's `.mcp.json` copy the caller passes, so they are
192
202
  // not strict; `machineMcp` names every `.mcp.json` server from the
193
203
  // worktree up, which `withMachineMcp` disables so a worktree edit never
194
- // mounts before review (claudePosture.mjs). Every other fenced posture —
195
- // consult, wiki, and a design/research turn no lane mounted for — stays
196
- // strict: it never discovers a .mcp.json, nor loads the machine's servers.
204
+ // mounts before review (claudePosture.mjs). Every fenced posture but plan
205
+ // stays strict unless the lane mounted the machine's servers — consult,
206
+ // wiki, and a design/research turn no lane mounted for: it never discovers
207
+ // a .mcp.json, nor loads the machine's servers. Keyed on the rule, not a
208
+ // list of names, so a posture added later is strict unless it mounts.
197
209
  // The plan profile keeps its own principal.
198
- const machine = Array.isArray(machineMcp) && MACHINE_MCP_PROFILES.includes(profile);
199
- if (['consult', 'wiki', 'design', 'research'].includes(profile) && !machine && !mcp.includes('--strict-mcp-config')) a.push('--strict-mcp-config');
210
+ //
211
+ // runTurn hands machineMcp only on a profile whose turn profile says
212
+ // projectMcp (runTurn.mjs), the one rule.
213
+ const machine = Array.isArray(machineMcp);
214
+ if (fenced && profile !== 'plan' && !machine && !mcp.includes('--strict-mcp-config')) a.push('--strict-mcp-config');
200
215
  a.push(...mcp, ...resultSchemaArgs);
201
216
  a.push('--model', model || MODEL);
202
217
  if (effort) a.push('--effort', effort);
@@ -223,14 +238,26 @@ export const CLAUDE_RUNTIME = {
223
238
  // leave the file with no turn to release it.
224
239
  //
225
240
  // …AND THE MACHINE'S SERVERS NO SOURCE OF THIS TURN READS (2026-10-17,
226
- // claudePersonal.mjs `machineMcpServers`): the user scope a fenced turn's
241
+ // claudeMachineMcp.mjs `machineMcpServers`): the user scope a fenced turn's
227
242
  // empty source list drops, and the checkout's local scope every card turn
228
243
  // missed. In a private file too — a server's env and headers are secrets —
229
244
  // minus every name the base copy mounts, which speaks for the project.
230
- const own = machine ? machineMcpServers({ user: fenced, checkout: mcpCheckout }) : {};
245
+ const own = (machine ? machineMcpServers({ user: fenced, checkout: mcpCheckout }) : null) ?? {};
231
246
  for (const name of baseMcp) delete own[name];
232
- const ownArgs = Object.keys(own).length ? ['--mcp-config', writeTurnSettings({ mcpServers: own })] : [];
233
- const postured = machine ? withMachineMcp(perm, machineMcp) : perm;
247
+ // A file that cannot be written costs the card these servers, never the
248
+ // card itself: the turn runs with what its own sources load, and says so.
249
+ let ownArgs = [];
250
+ if (Object.keys(own).length) {
251
+ try {
252
+ ownArgs = ['--mcp-config', writeTurnSettings({ mcpServers: own })];
253
+ } catch (error) {
254
+ console.warn(`[agent] this machine's MCP servers are not mounted on this turn: ${error.message}`);
255
+ }
256
+ }
257
+ const mounted = machine ? withMachineMcp(perm, machineMcp) : perm;
258
+ // The orchestrator's hooks join that same `--settings`: the CLI applies
259
+ // only the last one it is handed (claudeSettingsArg.mjs `withSettings`).
260
+ const postured = orchestration && !fenced ? withSettings(mounted, { hooks: orchestratorHooks(), disableAllHooks: false }) : mounted;
234
261
  let tail;
235
262
  try {
236
263
  tail = fenced ? withPersonalSettings(postured, personalSettings ?? personalClaudeSettings()) : postured;
@@ -16,7 +16,7 @@ import { ARTIFACT_DIR, FENCE_TMP_DIR } from './artifacts.mjs';
16
16
  import { codexPersonalTurn } from './codexPersonal.mjs';
17
17
  import { researchFence } from './codexResearch.mjs';
18
18
  import { projectReadBoundary, projectReadFence } from './codexProjectRead.mjs';
19
- import { compareVersions } from './versionOrder.mjs';
19
+ import { versionAtLeast } from './versionOrder.mjs';
20
20
  import { codexOrchestratorArgs } from './orchestratorHooks.mjs';
21
21
 
22
22
  /**
@@ -27,7 +27,11 @@ import { codexOrchestratorArgs } from './orchestratorHooks.mjs';
27
27
  * whose one tool carries no annotations (Flowviant's carry none): under the
28
28
  * capture chat's read-only argv the call failed "MCP tool call requires
29
29
  * approval, but approval policy is never" and never reached the server; under
30
- * the build posture (workspace-write) the same call ran unasked. With
30
+ * the build posture (workspace-write) the same call ran unasked — no longer
31
+ * so on 0.163.0, where a workspace-write card turn refuses it the same way
32
+ * and only danger-full-access runs it (2026-10-17; a card approves the
33
+ * person's own servers by name, projectToolRuntimes.mjs `personalApprovals`).
34
+ * With
31
35
  * `mcp_servers.flowviant.default_tools_approval_mode="approve"` beside the
32
36
  * read-only argv it ran — and in the same turn a shell write still failed
33
37
  * "Read-only file system" and an escalation still failed "approval policy is
@@ -88,8 +92,7 @@ export const NO_SUBAGENTS = Object.freeze(['-c', 'features.multi_agent=false', '
88
92
  * and V2. Older/unmeasured binaries have no inheritance evidence here. */
89
93
  export const CODEX_SUBAGENTS_MIN = '0.160.0';
90
94
  export function codexSubagentsSafe(version) {
91
- const order = compareVersions(String(version ?? '').replace(/^codex-cli\s+/, ''), CODEX_SUBAGENTS_MIN);
92
- return order === 0 || order === 1;
95
+ return versionAtLeast(String(version ?? '').replace(/^codex-cli\s+/, ''), CODEX_SUBAGENTS_MIN);
93
96
  }
94
97
 
95
98
  /**
@@ -183,7 +186,7 @@ export const CODEX_RUNTIME = {
183
186
  bin: 'codex',
184
187
  install: 'npm i -g @openai/codex',
185
188
  login: 'codex login',
186
- live: false,
189
+ liveInput: false,
187
190
  /**
188
191
  * BUILD AND CONSULT. See `args()` below for how consult is expressed — the
189
192
  * short version is that Codex keeps the promise at the kernel rather than in
@@ -296,7 +299,7 @@ export const CODEX_RUNTIME = {
296
299
  // before it started. `-c sandbox_mode=` means the same on both.
297
300
  if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
298
301
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
299
- if (cardTurn) a.push('-c', 'approval_policy="never"');
302
+ if (cardTurn) a.push('-c', 'approval_policy="never"', ...(agentTools?.codexApprovals ?? []));
300
303
  a.push(...(cardTurn && profile === 'build' && codexSubagentsSafe(codexVersion)
301
304
  ? ['-c', 'features.multi_agent=true', '-c', 'features.goals=false']
302
305
  : NO_SUBAGENTS));
@@ -14,7 +14,7 @@
14
14
  * own stream loop and calls `humanizeClaudeTool`/`toolEventOf` from it.
15
15
  */
16
16
 
17
- import { codexCollabUpdates } from './codexSubagentEvents.mjs';
17
+ import { codexCollabUpdates, codexSpawnResult } from './codexSubagentEvents.mjs';
18
18
 
19
19
  /** Truncate for a one-line activity label. */
20
20
  const oneLine = (s, n = 140) =>
@@ -312,7 +312,10 @@ function humanizeCodexItem(item = {}, cwd = '') {
312
312
  * threadId }` — `text` accumulates the agent's own words, because the wiki
313
313
  * lane reads its sentinels (WIKI_DONE / REGROUND_DONE, claude.mjs
314
314
  * `sawSentinel`) out of exactly that; `threadId` surfaces once, off the lifecycle event, for callers that
315
- * need to resume THIS conversation later (runTurn's onThreadId).
315
+ * need to resume THIS conversation later (runTurn's onThreadId). `spawnResult`
316
+ * is a native spawn's receipt (codexSubagentEvents.mjs), relayed to the
317
+ * orchestrator; it rides any event this parser keeps, read off the same parse
318
+ * (2026-10-18, the SOLID pass: the spawn lane parsed every line a second time).
316
319
  */
317
320
  export function parseCodexLine(line, cwd) {
318
321
  let ev;
@@ -321,6 +324,14 @@ export function parseCodexLine(line, cwd) {
321
324
  } catch {
322
325
  return null; // not every line is JSON (warnings go to stderr, but be safe)
323
326
  }
327
+ const parsed = codexEventOf(ev, cwd);
328
+ if (!parsed) return parsed;
329
+ const spawnResult = codexSpawnResult(ev.item);
330
+ return spawnResult ? { ...parsed, spawnResult } : parsed;
331
+ }
332
+
333
+ /** One ThreadEvent, already parsed, in the daemon's vocabulary. */
334
+ function codexEventOf(ev, cwd) {
324
335
  if (['item.started', 'item.updated', 'item.completed'].includes(ev.type) &&
325
336
  (ev.item?.type ?? ev.item?.item_type) === 'collab_tool_call')
326
337
  return { subagents: codexCollabUpdates(ev.item), activity: null, text: '' };
@@ -1,10 +1,12 @@
1
1
  /** Local, bounded Codex readouts. No agent turn is started for these reports. */
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { accessSync, constants, readFileSync, readdirSync, statSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
4
5
  import { basename, delimiter, isAbsolute, join } from 'node:path';
5
6
  import { readBaseTools } from './projectTools.mjs';
6
7
  import { mcpRefusal } from './projectToolRuntimes.mjs';
7
8
  import { CODEX_MCP_NAME, normalizeMcpReport } from './runtimeCapabilities.mjs';
9
+ import { mcpCommandNotFoundReason, mcpEnvUnsetReason } from './mcpReasons.mjs';
8
10
  import { unattendedEnv } from './noWindowEnv.mjs';
9
11
  import { codexHome } from './codexHome.mjs';
10
12
 
@@ -15,9 +17,12 @@ let repoCached = null;
15
17
  const nameOk = (name) => typeof name === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(name) && name !== 'flowviant';
16
18
  // An unattended CLI child like any other (noWindowEnv.mjs, 2026-10-10): `codex
17
19
  // mcp list` may start the person's configured servers, and none of them is
18
- // handed the machine credential or a display.
20
+ // handed the machine credential or a display. From the home directory
21
+ // (2026-10-17): in a trusted checkout — the daemon's own cwd — Codex lists
22
+ // that checkout's `.codex/config.toml` servers as well (measured, 0.163.0),
23
+ // which no card mounts.
19
24
  const run = (args, env) => execFileSync('codex', args, {
20
- encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024,
25
+ encoding: 'utf8', timeout: 5000, maxBuffer: 128 * 1024, cwd: homedir(),
21
26
  stdio: ['ignore', 'pipe', 'ignore'], env: unattendedEnv(env),
22
27
  });
23
28
  /**
@@ -105,10 +110,10 @@ export function codexServerRows(servers, env = process.env) {
105
110
  reason = typeof s.disabled_reason === 'string' ? s.disabled_reason.slice(0, 200) : null;
106
111
  } else if (typeof t.command === 'string' && commandFound(t.command, env) === false) {
107
112
  status = 'failed';
108
- reason = `command ${basename(t.command).slice(0, 60)} not found`;
113
+ reason = mcpCommandNotFoundReason(basename(t.command).slice(0, 60));
109
114
  } else if (unset.length) {
110
115
  status = 'failed';
111
- reason = `env ${[...new Set(unset)].sort().join(', ')} unset`;
116
+ reason = mcpEnvUnsetReason([...new Set(unset)].sort());
112
117
  } else if (['needs_auth', 'not_logged_in', 'notLoggedIn'].includes(auth)) {
113
118
  status = 'needs-auth';
114
119
  } else if (auth === 'failed') {
@@ -131,7 +131,9 @@ export const runtimeLabel = (id) => runtimeById(id)?.label ?? String(id);
131
131
  * So the restriction is gone and this is back to "installed, and this module
132
132
  * knows how to spawn it" — which is what the registry's `live` flag always
133
133
  * described: not which runtimes can run, but which get a session instead of a
134
- * subprocess.
134
+ * subprocess. (That flag is gone: since 2026-10-18 the rows carry
135
+ * `liveInput`, a different fact — whether follow-ups reach the running CLI
136
+ * on stdin.)
135
137
  *
136
138
  * LIVE is no longer read here. That is deliberate and load-bearing: this
137
139
  * predicate feeds both the roster report AND the claim, and if the two ever
@@ -0,0 +1,20 @@
1
+ /** HOW MANY NATIVE CHILDREN ONE CARD RUN RECORDS, THE FIELD CAPS EACH
2
+ * CARRIES, AND THE STATUSES A CHILD HAS (2026-10-18, the SOLID pass). The
3
+ * server folds at most `TRACE_AGENT_CAP` children per run, clamps each field
4
+ * (`agentTraceAgents.ts`) and refuses a handed route to a child it did not
5
+ * keep, so every daemon reader that tracks a child keeps the same bound: a
6
+ * 33rd is never half-reported, admitted by the controller while the trace
7
+ * sheds its descriptor. The cap was spelled eight times across the
8
+ * controller, the trace relay, runTurn and both Codex readers (the
9
+ * codexSubagentEvents split had copied it), and nothing held it to the
10
+ * server. A leaf of its own because trace.mjs, which the web's tests import,
11
+ * reads it (mcpReportBounds.mjs argues the same); scripts/check-app-parity.mjs
12
+ * holds it to the API's `TRACE_AGENT_CAP`/`TRACE_AGENT_FIELD_CAPS` and the
13
+ * shared `SUBAGENT_STATUSES`. */
14
+
15
+ export const SUBAGENT_CAP = 32;
16
+ /** In the order the trace relay sends them. */
17
+ export const SUBAGENT_FIELD_CAPS = Object.freeze({ kind: 80, title: 200, now: 400, brief: 4_000 });
18
+ export const SUBAGENT_STATUSES = Object.freeze(['running', 'waiting', 'asked', 'stopped', 'done']);
19
+ /** A child that has finished or been stopped: the controller revives neither. */
20
+ export const SETTLED_SUBAGENT_STATUSES = Object.freeze(['done', 'stopped']);
package/bin/lib/trace.mjs CHANGED
@@ -61,12 +61,16 @@
61
61
 
62
62
  import { THINK_MARKER, scrubNativeText } from './runtimeEvents.mjs';
63
63
  import { withoutTurnDeclaration } from './turnDeclaration.mjs';
64
+ import { MAX_MCP_SERVERS, MCP_NAME_MAX, MCP_STATUSES } from './mcpReportBounds.mjs';
65
+ import { SUBAGENT_CAP, SUBAGENT_FIELD_CAPS, SUBAGENT_STATUSES } from './subagentBounds.mjs';
64
66
 
65
67
  /** One batch every two seconds — the discipline the tab's narrator keeps, for
66
68
  * the same reason: a turn emits hundreds of entries and nobody is reading them
67
69
  * faster than that. */
68
70
  export const TRACE_FLUSH_MS = 2_000;
69
- /** Entries per POST. Matches the server's own per-batch cap. */
71
+ /** Entries per POST. Matches the server's own per-batch cap
72
+ * (`agentTrace.ts.TRACE_BATCH_CAP`), which refuses a larger batch whole;
73
+ * scripts/check-app-parity.mjs rule 38 holds the two equal. */
70
74
  export const TRACE_BATCH = 40;
71
75
  /** Entries held while the uplink is down. Past this the OLDEST go: a trace is
72
76
  * scrollback, and the newest steps are the ones somebody watching wants. The
@@ -77,7 +81,7 @@ export const TRACE_BUFFER = 120;
77
81
  * (`agentTrace.ts.TRACE_PROSE_CAP`); doing it here too means a pathological line
78
82
  * never becomes the POST. The two must move together — daemon at or below the
79
83
  * server's, or the server silently does the cutting and this file's caps stop
80
- * describing what ships.
84
+ * describing what ships. scripts/check-app-parity.mjs rule 38 holds them equal.
81
85
  *
82
86
  * 300 UNTIL 2026-09-16, when it was the thing clipping sentences. A trace entry
83
87
  * used to be the humanized 160-char label the console prints, so a 300 cap could
@@ -120,6 +124,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
120
124
  * by the bare-marker collapse in `prose()` and nothing else. */
121
125
  let lastEntry = null;
122
126
  const agentReadings = new Map();
127
+ const mcpSaid = new Set();
123
128
  let dirty = orchestrationVersion === 2;
124
129
  let markerPending = dirty;
125
130
  let timer = null;
@@ -266,13 +271,13 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
266
271
  if (!update?.id) return;
267
272
  const clean = (v, n) => scrubNativeText(v, n, scrub);
268
273
  const fields = {};
269
- for (const [key, cap] of Object.entries({ kind: 80, title: 200, now: 400, brief: 4_000 }))
274
+ for (const [key, cap] of Object.entries(SUBAGENT_FIELD_CAPS))
270
275
  if (typeof update[key] === 'string') fields[key] = clean(update[key], cap);
271
276
  if (typeof fields.now === 'string') fields.now = fields.now.replace(/\s+/g, ' ').trim();
272
- if (['running', 'waiting', 'asked', 'stopped', 'done'].includes(update.status)) fields.status = update.status;
277
+ if (SUBAGENT_STATUSES.includes(update.status)) fields.status = update.status;
273
278
  const previous = agentReadings.get(update.id) ?? {};
274
279
  if (agentReadings.has(update.id) && Object.entries(fields).every(([k, v]) => previous[k] === v)) return;
275
- if (!agentReadings.has(update.id) && agentReadings.size >= 32) return;
280
+ if (!agentReadings.has(update.id) && agentReadings.size >= SUBAGENT_CAP) return;
276
281
  const reading = { ...previous, ...fields };
277
282
  agentReadings.set(update.id, reading);
278
283
  // A later update repairs metadata whose first report was shed while
@@ -286,6 +291,26 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
286
291
  doing(agentId, text) {
287
292
  push({ k: 'doing', agentId: scoped(agentId), t: scrub(String(text)).slice(0, TRACE_PROSE_CAP) });
288
293
  },
294
+ /**
295
+ * WHAT THE CLI SAID OF ITS MCP SERVERS AT START (2026-10-17): Claude's
296
+ * init lists each with its status. A server that failed to start read to
297
+ * the agent and the person as "no MCP access", so each one not connected
298
+ * is said on the root stream, once per run. No list is unknown and says
299
+ * nothing; connected is good news and says nothing either.
300
+ */
301
+ mcpStatus(servers) {
302
+ if (!Array.isArray(servers)) return;
303
+ const fresh = [];
304
+ for (const s of servers.slice(0, MAX_MCP_SERVERS)) {
305
+ if (!s || typeof s.name !== 'string' || s.status === 'connected') continue;
306
+ const name = scrubNativeText(s.name, MCP_NAME_MAX, scrub).replace(/\s+/g, ' ').trim();
307
+ const status = MCP_STATUSES.has(s.status) ? s.status : 'other';
308
+ if (!name || mcpSaid.has(`${name}\0${status}`) || mcpSaid.size >= 4 * MAX_MCP_SERVERS) continue;
309
+ mcpSaid.add(`${name}\0${status}`);
310
+ fresh.push(`${name} (${status})`);
311
+ }
312
+ if (fresh.length) push({ k: 'note', t: scrub(`The CLI reported MCP servers that are not connected: ${fresh.join(', ')}.`).slice(0, TRACE_PROSE_CAP) });
313
+ },
289
314
  flush,
290
315
  /** No more entries, no more timers. The queue survives so a final flush can
291
316
  * still deliver it. */
@@ -2,7 +2,7 @@
2
2
  * normalization/caps; the unmodified answer still goes to parseTurnResult.
3
3
  * Only standalone objects and JSON/unlabelled fences count. Code examples
4
4
  * inside prose or another language's fence stay as the CLI wrote them. */
5
- import { jsonObjectClosers } from './agentPlan.mjs';
5
+ import { jsonObjectClosers } from './modelJson.mjs';
6
6
 
7
7
  const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'nothing']);
8
8
  function declaration(body) {
@@ -4,9 +4,10 @@
4
4
  * Logging is best effort and rides the daemon's existing bounded private log.
5
5
  */
6
6
  import { randomUUID } from 'node:crypto';
7
- import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
7
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs';
8
8
  import { delimiter, isAbsolute, resolve } from 'node:path';
9
9
  import { AUTH_ENV_VARS, claudeAuthContext, claudeCredentialPath } from './claudeAuth.mjs';
10
+ import { settingsAt } from './claudeSettingsArg.mjs';
10
11
  import { scrub } from './uplinkScrub.mjs';
11
12
  import { printableText } from './printable.mjs';
12
13
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
@@ -47,17 +48,13 @@ function createTurnDiagnostics({ runtime, bin, env, cwd = process.cwd(), profile
47
48
  const executable = executableFor(bin, env, cwd);
48
49
  let settingsEnv = {};
49
50
  if (runtime === 'claude') {
50
- const at = args.indexOf('--settings');
51
- if (at >= 0) {
52
- try {
53
- const input = args[at + 1];
54
- const settings = JSON.parse(input.startsWith('{') ? input : readFileSync(input, 'utf8'));
55
- if (settings.env && typeof settings.env === 'object' && !Array.isArray(settings.env)) {
56
- for (const [key, value] of Object.entries(settings.env))
57
- if (typeof value === 'string') settingsEnv[key] = value;
58
- }
59
- } catch { /* CLI settings unavailable */ }
60
- }
51
+ try {
52
+ const { settings } = settingsAt(args);
53
+ if (settings?.env && typeof settings.env === 'object' && !Array.isArray(settings.env)) {
54
+ for (const [key, value] of Object.entries(settings.env))
55
+ if (typeof value === 'string') settingsEnv[key] = value;
56
+ }
57
+ } catch { /* CLI settings unavailable */ }
61
58
  }
62
59
  // Only byte counts, including each terminating NUL. No values or hashes.
63
60
  const argSizes = [bin, ...args].map(value => Buffer.byteLength(value) + 1);
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A TURN'S COMMIT RECEIPTS — every sha that landed on its branch while it ran
3
+ * (2026-10-18, the SOLID pass).
4
+ *
5
+ * The rule was spelled three times: the agent run's `commitsBetween`, the
6
+ * lane's catch (which settles a turn whose run threw, and computed the same
7
+ * receipts over what the run had measured), and a multi-repository place's
8
+ * per-repository log (repositoryPlaces.mjs). Two incidents live in it — the
9
+ * base commits reported as a turn's own, and the six-hour re-run below — so a
10
+ * fix to one copy that missed another was the risk. A leaf: it imports only
11
+ * git.mjs's guarded read.
12
+ *
13
+ * Every sha that landed on this branch between two points. Measured, never
14
+ * asserted — this is the whole reason an agent is not asked for its own.
15
+ *
16
+ * `--not <base>` and `--no-merges`, because `from..HEAD` alone reports
17
+ * BASE'S commits as this turn's receipts the moment anything folds base in
18
+ * — which the stale path does on purpose before a merge. A receipt naming
19
+ * somebody else's commit is worse than a missing one.
20
+ *
21
+ * IT CANNOT THROW, and that guard is the whole point of it being here.
22
+ *
23
+ * `git()` throws on a non-zero exit, and `baseRef()` is built from the
24
+ * project's Base branch setting — free text an owner types, never verified
25
+ * against the remote. Point it at a branch with no tracking ref (`develop`
26
+ * on a repo whose remote branch is `dev`) and this exits "fatal: ambiguous
27
+ * argument". The throw escaped `runAgentTurn` AFTER the CLI had already run
28
+ * the card, so `postAgentTurn` was never reached, the server never settled
29
+ * the turn, and the next poll handed back the identical turn — the same
30
+ * card re-run every poll for six hours, on the operator's shared account,
31
+ * piling commits onto the review branch, silently.
32
+ *
33
+ * An unreadable range means we cannot MEASURE the receipts, which is a
34
+ * smaller failure than not settling: the turn still reports, and ship-time
35
+ * reconciliation books whatever no card claimed. Missing beats fabricated
36
+ * and both beat a stall.
37
+ *
38
+ * THE CAP IS THE WIRE'S: agent-turn-done takes `commits` up to 50
39
+ * (machineAgentTurns.routes.ts), so a longer list is a settle refused.
40
+ */
41
+ import { gitOrNull } from './git.mjs';
42
+
43
+ /** The most receipts one settle carries — the API's `commits` max. */
44
+ export const TURN_COMMITS_MAX = 50;
45
+
46
+ /** The shas on `wt`'s HEAD since `from` and not on `base`, merges left out,
47
+ * newest first and capped; `[]` for a range git cannot read. */
48
+ export function commitsSince(wt, from, base) {
49
+ const out = gitOrNull(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', base], wt);
50
+ return out?.split('\n').map((x) => x.trim()).filter(Boolean).slice(0, TURN_COMMITS_MAX) ?? [];
51
+ }
52
+
53
+ /**
54
+ * The receipts one agent turn settles with: a multi-repository card's own
55
+ * (`repositoryReceipts()`, every writable repository's, a sha once), else
56
+ * this worktree's since `before`. `baseRef` is the lane's function, read only
57
+ * when there is a range to read; no `before` (no worktree, or a branch with
58
+ * no commits yet) is none.
59
+ */
60
+ export function turnCommits({ repositoryReceipts, wt, before, baseRef }) {
61
+ return repositoryReceipts
62
+ ? [...new Set(repositoryReceipts().map((r) => r.sha))].slice(0, TURN_COMMITS_MAX)
63
+ : before ? commitsSince(wt, before, baseRef()) : [];
64
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * THE NATIVE CHILDREN ONE CLI TURN STARTS, OBSERVED — the readers that follow
3
+ * them, the bound a turn with no orchestrator keeps on them, and its exit
4
+ * sweep (2026-10-18, the SOLID pass).
5
+ *
6
+ * Split out of runTurn.mjs, the one spawn point every lane uses, because
7
+ * watching a CLI's own subagents is a second vocabulary with its own reasons
8
+ * to change: Claude's background agents (its stream, and the agent_progress
9
+ * rows only its transcript keeps) and Codex's collab children (their
10
+ * rollouts) arrived in four commits in three days, each another runtime-id
11
+ * arm inside the spawn function. runTurn keeps the process and its parent
12
+ * stream; this keeps the children, and a third CLI with native subagents is
13
+ * an arm here. The Codex thread's close-time reads (its plan windows, its
14
+ * model) stay in runTurn: they are the turn's, not a child's.
15
+ *
16
+ * Two rules ride with it, each argued where it lives below: an orchestrator
17
+ * bounds and settles its own records, so only an unowned turn is counted
18
+ * here; and native transcript reads are readouts, so the drain at close is
19
+ * bounded and never holds the card's answer.
20
+ */
21
+
22
+ import { handleStreamLine } from './claudeStream.mjs';
23
+ import { createClaudeSubagents } from './claudeSubagents.mjs';
24
+ import { createClaudeSubagentProgress } from './claudeSubagentProgress.mjs';
25
+ import { createCodexSubagentReader } from './codexSubagents.mjs';
26
+ import { SUBAGENT_CAP } from './subagentBounds.mjs';
27
+
28
+ /**
29
+ * `sinks` are the turn's own: its console, activity, tool-event and text
30
+ * sinks, which a Claude child's transcript rows feed as its stream does.
31
+ */
32
+ export function createTurnSubagents({ runtime, cwd, since, env, owned, onSubagent, onSpawnResult, resumeThreadId,
33
+ sinks: { emit, onActivity, onToolEvent, appendText } }) {
34
+ // An orchestrator bounds its own records and settles them at close
35
+ // (orchestrator.mjs). Counted here by stream id, refused retries used up
36
+ // the 32 and later children's updates were dropped; the exit sweep
37
+ // reported 'stopped' off stream state the controller had already settled.
38
+ const agentStates = new Map();
39
+ const reportSubagent = (update) => {
40
+ if (!update?.id) return;
41
+ if (!owned) {
42
+ if (!agentStates.has(update.id) && agentStates.size >= SUBAGENT_CAP) return;
43
+ agentStates.set(update.id, update.status ?? agentStates.get(update.id) ?? 'running');
44
+ }
45
+ onSubagent?.(update);
46
+ };
47
+ const subagents = createClaudeSubagents(reportSubagent);
48
+ const claudeProgress = runtime === 'claude' && onSubagent ? createClaudeSubagentProgress({
49
+ cwd, since, env,
50
+ onLine: line => handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText,
51
+ answerFromResult: true, subagents }),
52
+ }) : null;
53
+ const codexAgents = runtime === 'codex' && onSubagent ? createCodexSubagentReader({
54
+ since, env,
55
+ onSubagent: reportSubagent, onActivity, onToolEvent,
56
+ onSpawnResult,
57
+ }) : null;
58
+ const resumed = runtime === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
59
+ if (resumed) codexAgents?.thread(resumed);
60
+ return {
61
+ /** Claude's child bookkeeping, handed to the parent stream's reader. */
62
+ collector: subagents,
63
+ /** Claude's session, off its init: whose transcript to follow. */
64
+ session: (id) => claudeProgress?.session(id),
65
+ /** Codex's thread, off `thread.started`: whose rollouts to follow. */
66
+ thread: (id) => codexAgents?.thread(id),
67
+ /** A child update the Codex exec stream carried. */
68
+ update: (update) => codexAgents?.update(update),
69
+ /** The spawn failed: the readers stop, unawaited. */
70
+ abandon() {
71
+ void codexAgents?.stop();
72
+ void claudeProgress?.stop();
73
+ },
74
+ async drain(capMs) {
75
+ // Native transcript reads are readouts too: a stuck filesystem must
76
+ // not hold the card's answer. Readers stop scheduling before draining.
77
+ let progressTimer;
78
+ try {
79
+ await Promise.race([
80
+ Promise.all([codexAgents?.stop(), claudeProgress?.stop()]),
81
+ new Promise(resolve => { progressTimer = setTimeout(resolve, capMs); }),
82
+ ]);
83
+ } finally { clearTimeout(progressTimer); }
84
+ },
85
+ /** After the drain: a child an unowned turn still reads as live ended with it. */
86
+ sweep() {
87
+ for (const [id, status] of agentStates)
88
+ if (status === 'running' || status === 'waiting') reportSubagent({ id, status: 'stopped' });
89
+ },
90
+ };
91
+ }
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * ONE TURN'S TOKENS, ADDED UP ACROSS THE SPAWNS THAT MADE IT (2026-09-28).
3
3
  *
4
- * The agent lane SETS its usage — one spawn, one `result` — and leaves the
5
- * adding-up to the server. The Terminal tab, the capture chat and the wiki
6
- * are different: a tab turn whose resume came back empty runs a fresh retry
7
- * in the same turn, and BOTH spawns spent real tokens; reporting only the
8
- * second would under-count exactly the turns that went wrong. So those lanes
9
- * sum what each spawn reported before the one settle carries it.
4
+ * A turn can be more than one spawn: a tab turn whose resume came back empty
5
+ * runs a fresh retry in the same turn, and an agent turn resumes its CLI
6
+ * after an internal wait (agentInternalWait.mjs) or to commit a repository's
7
+ * work (repositoryDelivery.mjs). Every spawn spent real tokens; reporting
8
+ * only the last would under-count exactly the turns that went wrong. So the
9
+ * Terminal tab, the capture chat, the planner, the wiki and the agent lane
10
+ * (since 2026-10-18, the SOLID pass) sum what each spawn reported through
11
+ * this, and the server still charges once, behind the one settle that
12
+ * carries it.
10
13
  *
11
14
  * The wire shape is the agent lane's, unchanged: `{input, output,
12
15
  * cacheCreate, cacheRead, runtime}`. Null-safe on both sides — a spawn that
@@ -2,7 +2,11 @@
2
2
  * VERSION ORDER — the one parser and comparator every version decision on this
3
3
  * box uses: the takeover downgrade guard (instance.mjs), the self-update
4
4
  * signals (update.mjs, fleet.mjs), the behind marks in the read-only views
5
- * (views.mjs) and the newest nvm Node (loginPath.mjs).
5
+ * (views.mjs), the newest nvm Node (loginPath.mjs), and the measured CLI
6
+ * floors — Claude's and Codex's orchestration (orchestratorHooks.mjs,
7
+ * runtimeCodex.mjs) and the Codex sandbox helper's (codexResearch.mjs
8
+ * `CODEX_HELPER_MIN`, read by codexProjectRead.mjs too; 2026-10-18, the SOLID
9
+ * pass: both kept a hand-rolled reducer and the same spelled-out floor).
6
10
  *
7
11
  * Split out (SOLID F005) because four hand-written rules disagreed on a
8
12
  * version they could not read: one called it EQUAL, one called each
@@ -40,3 +44,9 @@ export function compareVersions(a, b) {
40
44
 
41
45
  /** True only when BOTH parse and `a` is strictly older. Unknown is not older. */
42
46
  export const versionBelow = (a, b) => compareVersions(a, b) === -1;
47
+
48
+ /** True only when BOTH parse and `a` is at or above `b`. Unknown is not at least. */
49
+ export const versionAtLeast = (a, b) => {
50
+ const o = compareVersions(a, b);
51
+ return o === 0 || o === 1;
52
+ };