flowviant 0.116.0 → 0.117.0

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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +71 -139
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -31,29 +31,26 @@
31
31
  * repo mid-rebase or an unborn HEAD is a field to omit, not an error to raise.
32
32
  */
33
33
 
34
- import { execFileSync } from 'node:child_process';
35
34
  import { measureListeners, listenersSupported } from './listeners.mjs';
35
+ import { git, GIT_WIDE_BUFFER, isSessionBranch } from './git.mjs';
36
36
 
37
37
  /** Same cap the session diffstat uses: enough to see the shape, small enough
38
38
  * that one machine cannot flood a row. */
39
39
  const MAX_BRANCHES = 60;
40
40
  const MAX_WORKTREES = 40;
41
41
 
42
- function git(args, cwd) {
43
- return execFileSync('git', args, {
44
- cwd,
45
- encoding: 'utf8',
46
- stdio: ['ignore', 'pipe', 'ignore'],
47
- timeout: 10_000,
48
- maxBuffer: 8 * 1024 * 1024,
49
- }).trim();
50
- }
42
+ /** Every read here is BOUNDED and QUIET: a best-effort report in the poll
43
+ * loop must neither hold the event loop on a wedged git nor print git's
44
+ * complaints about a repo mid-rebase onto the daemon's terminal. git.mjs's
45
+ * own `git()`, with those options (audit 2026-10-10, F107) — this module
46
+ * used to declare a private `function git` of the same name with different
47
+ * semantics, so a reader could not tell which one a call meant. */
48
+ const READ = Object.freeze({ timeout: 10_000, maxBuffer: GIT_WIDE_BUFFER, stdio: ['ignore', 'pipe', 'ignore'] });
51
49
 
52
- /** A ref Flowviant itself cut for a tab. The ONLY thing that makes a branch
53
- * "ours", and the distinction the whole report exists to draw. */
54
- export function isSessionBranch(name) {
55
- return /^session\//.test(name);
56
- }
50
+ /** A ref Flowviant itself cut for a tab — the distinction the whole report
51
+ * exists to draw. Defined beside the branch-name writer in git.mjs (audit
52
+ * 2026-10-10, F122) and re-exported here for this report's readers. */
53
+ export { isSessionBranch };
57
54
 
58
55
  /**
59
56
  * `git worktree list --porcelain` → rows. The porcelain form is parsed rather
@@ -63,7 +60,7 @@ export function isSessionBranch(name) {
63
60
  function readWorktrees(repoRoot) {
64
61
  let out;
65
62
  try {
66
- out = git(['worktree', 'list', '--porcelain'], repoRoot);
63
+ out = git(['worktree', 'list', '--porcelain'], repoRoot, READ);
67
64
  } catch {
68
65
  return null; // not a git repo, or git is unhappy — say nothing
69
66
  }
@@ -103,13 +100,14 @@ function readBranches(repoRoot, baseRef) {
103
100
  const fmt = '%(refname:short)%09%(committerdate:unix)%09%(ahead-behind:' + baseRef + ')';
104
101
  let out;
105
102
  try {
106
- out = git(['for-each-ref', '--sort=-committerdate', `--format=${fmt}`, 'refs/heads'], repoRoot);
103
+ out = git(['for-each-ref', '--sort=-committerdate', `--format=${fmt}`, 'refs/heads'], repoRoot, READ);
107
104
  } catch {
108
105
  // No ahead-behind on this git. Names and dates still answer most of it.
109
106
  try {
110
107
  out = git(
111
108
  ['for-each-ref', '--sort=-committerdate', '--format=%(refname:short)%09%(committerdate:unix)', 'refs/heads'],
112
- repoRoot
109
+ repoRoot,
110
+ READ
113
111
  );
114
112
  } catch {
115
113
  return null;
@@ -146,7 +144,7 @@ export function repoState(repoRoot, baseRef) {
146
144
  /**
147
145
  * WHAT IS LISTENING IN THE CHECKOUT ITSELF.
148
146
  *
149
- * `listenersIn` has always taken any directory, and had only ever been asked
147
+ * `measureListeners` has always taken any directory, and had only ever been asked
150
148
  * about SESSION WORKTREES — so somebody running `npm run dev` in their normal
151
149
  * checkout, which is what "just testing or playing around in dev" actually
152
150
  * looks like, was invisible to every surface in the product. The measurement
@@ -10,16 +10,18 @@
10
10
  * (runtimeEvents.mjs).
11
11
  */
12
12
 
13
+ import { runIsolatedResearch } from './codexResearch.mjs';
13
14
  import { spawn } from 'node:child_process';
14
15
  import { runtimeById } from './runtimes.mjs';
15
- import { withoutMachineCredentials } from './machineEnv.mjs';
16
- import { openBrowserHome, withoutWindows } from './noWindowEnv.mjs';
16
+ import { openBrowserHome, unattendedEnv } from './noWindowEnv.mjs';
17
17
  import { resolveTurnProfile } from './turnProfile.mjs';
18
18
  import { claudePermFor } from './claudePosture.mjs';
19
19
  import { handleStreamLine } from './claudeStream.mjs';
20
20
  import { never, workLanesStopped } from './standDownGate.mjs';
21
21
  import { learnCodexLimits, recordClaudeRateLimit } from './runtimeLimits.mjs';
22
+ import { initHarvestFor, recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
22
23
  import { codexTurnModel } from './turnModel.mjs';
24
+ import { printableText } from './printable.mjs';
23
25
 
24
26
  /** How long a Codex turn's answer may wait on its rollout's model line. */
25
27
  const CODEX_MODEL_READ_MS = 1_500;
@@ -37,7 +39,9 @@ const CODEX_MODEL_READ_MS = 1_500;
37
39
  * needs it: the daemon authenticates to the server itself, and a turn that
38
40
  * needs the project's tools gets its own per-turn token through `mcpEnv` or
39
41
  * the MCP config file. So those two names, and only those, are removed —
40
- * by the one rule in machineEnv.mjs, which the project check obeys too.
42
+ * by the one rule in machineEnv.mjs, composed for every unattended CLI child
43
+ * by `unattendedEnv` (noWindowEnv.mjs), which the project check, the skills
44
+ * probe and the Codex offerings reads obey too.
41
45
  */
42
46
  /**
43
47
  * …AND, UNLESS A PERSON IS AT THE KEYBOARD, NO WAY TO A WINDOW (2026-09-29).
@@ -49,21 +53,27 @@ const CODEX_MODEL_READ_MS = 1_500;
49
53
  */
50
54
  export function cliEnv(mcpEnv, { display = false, browserHome = null } = {}) {
51
55
  // After the merge, so an mcpEnv can never smuggle one back in.
52
- const env = withoutMachineCredentials({ ...process.env, ...(mcpEnv ?? {}) });
53
- return display === true ? env : withoutWindows(env, browserHome);
56
+ return unattendedEnv({ ...process.env, ...(mcpEnv ?? {}) }, { display, browserHome });
54
57
  }
55
58
 
56
- // One Claude Code turn. Output is captured (for sentinel detection) and streamed
59
+ // One CLI turn. Output is captured (for sentinel detection) and streamed
57
60
  // through, line-prefixed with the worker label so a fleet stays legible.
58
61
  //
59
- // `streamJson` switches to `--output-format stream-json` and parses the event
60
- // stream: only the humanized tool activity reaches the console (a legible
61
- // stream of `read …`, `grep …`, `+ node …`), assistant text is folded into the
62
- // returned string for sentinel detection, and each activity is handed to
63
- // `onActivity` so the caller can forward progress. Build-agent turns leave it
64
- // off and keep the raw text passthrough + line sentinels.
62
+ // The event stream is parsed, always: only the humanized tool activity reaches
63
+ // the console (a legible stream of `read …`, `grep …`, `+ node …`), assistant
64
+ // text is folded into the returned string for sentinel detection, and each
65
+ // activity is handed to `onActivity` so the caller can forward progress.
65
66
  export function runTurn(opts) {
66
- const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onRateLimit, onModel, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId, display, browserHome } = opts;
67
+ // A Codex write-up runs over an isolated copy of its worktree
68
+ // (codexResearch.mjs). After the teardown it copies nothing either: the
69
+ // stand-down gate below is asked before the copy, not only before the spawn.
70
+ if (opts.runtime === 'codex' && opts.profile === 'research')
71
+ return workLanesStopped() ? never() : runIsolatedResearch(opts, runPlainTurn);
72
+ return runPlainTurn(opts);
73
+ }
74
+
75
+ function runPlainTurn(opts) {
76
+ const { prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onRateLimit, onModel, onThreadId, onAnswer, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId, display, browserHome } = opts;
67
77
  /**
68
78
  * AFTER THE TEARDOWN, NO TURN STARTS AND NO TURN ENDS (standDownGate.mjs).
69
79
  * A stand-down that drains a deploy keeps this process alive; without this
@@ -157,12 +167,12 @@ export function runTurn(opts) {
157
167
  // to get wrong: the caller named one profile (turnProfile.mjs).
158
168
  const args = rt.args({
159
169
  personal,
170
+ researchIsolation: opts.researchIsolation,
160
171
  prompt,
161
172
  system,
162
173
  model,
163
174
  effort,
164
175
  resume,
165
- streamJson,
166
176
  profile: turnProfile.adapterProfile,
167
177
  agentTools,
168
178
  // Adopting a terminal session (workSessionTurns.mjs): Claude turns it into
@@ -172,9 +182,9 @@ export function runTurn(opts) {
172
182
  // so a mis-wired adoption fails as a loud turn error rather than a
173
183
  // silent fresh conversation wearing an adopted session's name.
174
184
  adoptResumeId,
175
- // Only the wiki profile uses it, but it is passed unconditionally: a
176
- // runtime that can path-scope its writes needs to know WHERE the vault is,
177
- // and Claude — which cannot — simply ignores it.
185
+ // Only the wiki profile uses it, but it is passed unconditionally: every
186
+ // runtime that runs a cartographer path-scopes its writes to the vault
187
+ // and needs to know WHERE it is — Claude's list names it too (2026-10-10).
178
188
  vaultDir,
179
189
  // The turn's own directory, for a runtime that fences its writes to a
180
190
  // path under it: Codex's image profile makes `<cwd>/.flowviant/artifacts`
@@ -196,7 +206,7 @@ export function runTurn(opts) {
196
206
  // PLAN MODE REPLACES THE POSTURE, never joins it: beside
197
207
  // `--dangerously-skip-permissions` the bypass wins silently (measured —
198
208
  // see PLAN_MODE_PERM).
199
- perm: claudePermFor(turnProfile.name, knowledgeDir),
209
+ perm: claudePermFor(turnProfile.name, knowledgeDir, vaultDir),
200
210
  // Handed to the adapter rather than appended here, because WHERE these go
201
211
  // is a property of the CLI: Codex reads its prompt as a trailing
202
212
  // positional, so a flag after it is a flag in the wrong place.
@@ -223,45 +233,73 @@ export function runTurn(opts) {
223
233
  // Which credential is correct, and whether an account may be shared, is
224
234
  // between the operator and the vendor. Flowviant does not detect it and does
225
235
  // not enforce it; it runs the CLI the ordinary way and relays what happens.
226
- //
227
- // A turn nobody is sitting at keeps its browsers in a home of its own —
228
- // the lane's kept one (an agent's), else this spawn's, removed when the
229
- // CLI closes. See `cliEnv`.
230
- const browser = display === true ? null : openBrowserHome(browserHome);
231
- const child = spawn(rt.bin, args, {
232
- cwd,
233
- stdio: ['ignore', 'pipe', 'pipe'],
234
- /**
235
- * ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
236
- *
237
- * Everything the CLI spawns inherits this pgid and KEEPS it through
238
- * `nohup` and `setsid` — which is exactly when attribution by descendancy
239
- * fails, because reparenting to init breaks the ppid chain the moment a
240
- * process becomes long-running. `processes.mjs` reads the group; the
241
- * Workbench renders it.
242
- *
243
- * TEARDOWN IS DELIBERATELY UNCHANGED: `shutdownWork` still SIGTERMs this
244
- * CHILD and never the group. Signalling the group would kill the dev
245
- * server the driver started every time the daemon restarts — including
246
- * on an ordinary auto-update, unattended — which is the outcome the
247
- * deleted dev-run supervisor spent a whole registry avoiding. Flowviant
248
- * does not manage those processes; it reports them.
249
- *
250
- * Not `unref`'d: the daemon must still wait on this turn.
251
- */
252
- detached: true,
253
- // Only ADDS to the environment (the worker token, for runtimes that read
254
- // it from there). Never replaces it: the CLI's own credentials live in
255
- // this environment, and handing it a curated one signs it out. What it
256
- // REMOVES is this daemon's machine credential and, for a turn nobody is
257
- // sitting at, every way to a window — see `cliEnv`.
258
- // The login's literal secrets first, so nothing of the lane's (its MCP
259
- // token, its CODEX_HOME) can be overwritten by one.
260
- env: cliEnv(personal?.env ? { ...personal.env, ...(mcpEnv ?? {}) } : mcpEnv, {
261
- display: display === true,
262
- browserHome: browser?.dir,
263
- }),
264
- });
236
+ /**
237
+ * WHAT THE ADAPTER WROTE FOR THIS ARGV goes when the turn settles — once,
238
+ * on close or on a spawn error: Claude's fenced `--settings` file, which
239
+ * holds the person's login settings (claudePersonal.mjs, 2026-10-10).
240
+ * Everything between the argv and the spawn sits inside the `try` below,
241
+ * so no throw on the way leaves that file behind: the sweep takes only a
242
+ * dead process's leftovers, and this one names the daemon's own pid.
243
+ */
244
+ let argsReleased = false;
245
+ const releaseArgs = () => {
246
+ if (argsReleased) return;
247
+ argsReleased = true;
248
+ try {
249
+ rt.releaseArgs?.(args);
250
+ } catch {
251
+ /* best effort — a later write sweeps it */
252
+ }
253
+ };
254
+ let browser = null;
255
+ let child;
256
+ try {
257
+ // A turn nobody is sitting at keeps its browsers in a home of its own —
258
+ // the lane's kept one (an agent's), else this spawn's, removed when the
259
+ // CLI closes. See `cliEnv`.
260
+ browser = display === true ? null : openBrowserHome(browserHome);
261
+ child = spawn(rt.bin, args, {
262
+ cwd,
263
+ stdio: ['ignore', 'pipe', 'pipe'],
264
+ /**
265
+ * ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
266
+ *
267
+ * Everything the CLI spawns inherits this pgid and KEEPS it through
268
+ * `nohup` and `setsid` — which is exactly when attribution by descendancy
269
+ * fails, because reparenting to init breaks the ppid chain the moment a
270
+ * process becomes long-running. `processes.mjs` reads the group; the
271
+ * Workbench renders it.
272
+ *
273
+ * TEARDOWN IS DELIBERATELY UNCHANGED: `shutdownWork` still SIGTERMs this
274
+ * CHILD and never the group. Signalling the group would kill the dev
275
+ * server the driver started every time the daemon restarts — including
276
+ * on an ordinary auto-update, unattended — which is the outcome the
277
+ * deleted dev-run supervisor spent a whole registry avoiding. Flowviant
278
+ * does not manage those processes; it reports them.
279
+ *
280
+ * Not `unref`'d: the daemon must still wait on this turn.
281
+ */
282
+ detached: true,
283
+ // Only ADDS to the environment (the worker token, for runtimes that read
284
+ // it from there). Never replaces it: the CLI's own credentials live in
285
+ // this environment, and handing it a curated one signs it out. What it
286
+ // REMOVES is this daemon's machine credential and, for a turn nobody is
287
+ // sitting at, every way to a window — see `cliEnv`.
288
+ // The login's literal secrets first, so nothing of the lane's (its MCP
289
+ // token, its CODEX_HOME) can be overwritten by one.
290
+ env: cliEnv(personal?.env ? { ...personal.env, ...(mcpEnv ?? {}) } : mcpEnv, {
291
+ display: display === true,
292
+ browserHome: browser?.dir,
293
+ }),
294
+ });
295
+ } catch (error) {
296
+ // A spawn that throws (an argument it refuses), or anything on the way
297
+ // to it, still takes what the adapter wrote with it — and this spawn's
298
+ // own browser home.
299
+ releaseArgs();
300
+ browser?.cleanup();
301
+ throw error;
302
+ }
265
303
  if (browser) {
266
304
  child.once('close', browser.cleanup);
267
305
  child.once('error', browser.cleanup);
@@ -272,170 +310,190 @@ export function runTurn(opts) {
272
310
  const spawnedAt = Date.now();
273
311
  let out = '';
274
312
  const pfx = label ? `${label} ` : '';
275
- const emit = (s) => process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
276
-
277
- // A runtime with its own parser is ALWAYS line-parsed — for Codex the JSONL
278
- // stream is the only output there is, so treating it as raw text would print
279
- // event objects at the operator and, worse, hand the sentinel matcher a
280
- // string containing every word the model reasoned about.
281
- const lineParsed = streamJson || Boolean(rt.parse);
282
- if (lineParsed) {
283
- let buf = '';
284
- const appendText = (t) => {
285
- out += t;
286
- };
287
- /**
288
- * THE PLAN'S WINDOWS, LEARNED OFF EVERY TURN THIS DAEMON RUNS (0.109.0).
289
- *
290
- * This is the one spawn point — agent turns, Terminal tabs, capture
291
- * chats, the planner, the pre-review and the wiki all come through here
292
- * — so it is the one place that sees every CLI's word on its plan.
293
- * Claude says it on the stream (`rate_limit_event`), recorded as it
294
- * arrives; a caller's own `onRateLimit` still hears it. Codex writes it
295
- * into the thread's rollout file instead, so the thread this turn spoke
296
- * under (announced on `thread.started`, else the one it resumed) is read
297
- * AFTER the child closes, on the next tick — the turn's answer never
298
- * waits on a file read. See runtimeLimits.mjs.
299
- */
300
- const onRate = (info) => {
301
- recordClaudeRateLimit(info);
302
- onRateLimit?.(info);
303
- };
304
- let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
305
- /**
306
- * THE MODEL THIS SPAWN RAN ON, ONCE (2026-09-29, turnModel.mjs). Claude
307
- * names it in its init event and again on every reply; the first valid
308
- * one is the answer, so the init wins and the first reply is the
309
- * fallback. Codex names it only in its rollout, read at close below.
310
- */
311
- let ranOn = null;
312
- const onRanOn = (m) => {
313
- if (ranOn || !m) return;
314
- ranOn = m;
315
- onModel?.(m);
316
- };
317
- /** One line of the child's stdout, in whichever dialect it speaks. */
318
- const onLine = (line) => {
319
- if (!rt.parse)
320
- return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit: onRate, onModel: onRanOn });
321
- const ev = rt.parse(line, cwd);
322
- if (!ev) return;
323
- // The conversation id, when the runtime announces one (codex's
324
- // thread.started). Purely additive: callers that pass no onThreadId —
325
- // every dispatch path — see zero behavior change.
326
- if (ev.threadId) {
327
- if (rt.id === 'codex') codexThread = ev.threadId;
328
- onThreadId?.(ev.threadId);
329
- }
330
- // The runtime's own token count (codex's `turn.completed`) — the same
331
- // `onUsage` Claude's result event feeds, so a caller charges a turn
332
- // identically whichever CLI ran it.
333
- if (ev.usage) onUsage?.(ev.usage);
334
- // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
335
- // wins at the caller, which is what "the final answer" means.
336
- if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
337
- if (ev.text) appendText(ev.text);
338
- if (ev.activity) {
339
- emit(`${ev.activity.label}\n`);
340
- onActivity?.(ev.activity);
341
- }
342
- };
343
- // DECODED AS A STREAM, never chunk by chunk (2026-09-24, the audit): a
344
- // pipe read can end inside a multi-byte character — the em dash models
345
- // write constantly — and `d.toString()` per chunk turned each half into
346
- // U+FFFD, which JSON.parse accepts and the reply then carried verbatim.
347
- // `setEncoding` holds the partial bytes over to the next chunk.
348
- child.stdout.setEncoding('utf8');
349
- child.stderr.setEncoding('utf8');
350
- child.stdout.on('data', (d) => {
351
- buf += d;
352
- let nl;
353
- while ((nl = buf.indexOf('\n')) >= 0) {
354
- const line = buf.slice(0, nl);
355
- buf = buf.slice(nl + 1);
356
- if (line.trim()) onLine(line);
357
- }
358
- });
359
- // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
360
- child.stderr.on('data', (s) => {
361
- out += s;
362
- emit(s);
363
- });
364
- child.on('error', (e) => {
365
- if (e.code === 'ENOENT') {
366
- // A MISSING CLI FAILS THE TURN, NOT THE DAEMON. This called
367
- // process.exit(1), which was defensible while `claude` was the only
368
- // runtime and preflight refused to start without it — the process
369
- // could not reach here. Both halves of that are gone: preflight is now
370
- // fatal only when NOTHING is drivable, so a Codex-only machine starts
371
- // legitimately, and the wiki/plan-check/consult turns still ask for
372
- // Claude by default. On such a machine the first wiki sweep would have
373
- // killed the whole daemon, taking every in-flight build with it,
374
- // because one background job could not find one binary.
375
- console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
376
- resolve('');
377
- return;
378
- }
379
- console.error(e);
380
- resolve(out);
381
- });
382
- child.on('close', () => {
383
- if (buf.trim()) onLine(buf);
384
- const handBack = () => {
385
- resolve(out);
386
- // After the answer is handed back, never before it: see `onRate`.
387
- if (codexThread) {
388
- const thread = codexThread;
389
- setImmediate(() => void learnCodexLimits(thread));
390
- }
391
- };
392
- /**
393
- * A CODEX TURN'S MODEL IS IN ITS ROLLOUT, so a caller that asked for
394
- * it waits for one bounded read before the answer goes back — the
395
- * settle it rides is built from that answer. Only then: a caller that
396
- * passes no `onModel` hands back exactly as before. A read that fails
397
- * or overruns reports nothing (never a guess).
398
- */
399
- if (codexThread && onModel && !ranOn) {
400
- const thread = codexThread;
401
- let timer = null;
402
- const cap = new Promise((res) => {
403
- timer = setTimeout(() => res(null), CODEX_MODEL_READ_MS);
404
- });
405
- void Promise.race([codexTurnModel(thread, { since: spawnedAt }), cap])
406
- .then(onRanOn, () => {})
407
- .finally(() => {
408
- clearTimeout(timer);
409
- handBack();
410
- });
411
- return;
412
- }
413
- handBack();
414
- });
415
- return;
416
- }
313
+ /**
314
+ * THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
315
+ * F133): activity labels — a Bash command, a Codex `say`, a file path —
316
+ * and the CLI's raw stderr. A prompt-injected agent can put an OSC 52 in
317
+ * a tool command as easily as in prose, and this wrote it to the
318
+ * operator's terminal as-is. Scrubbed HERE, before the daemon's own
319
+ * coloured `label` is added, so the prefix keeps its colour and nothing
320
+ * the model wrote keeps an escape sequence (printable.mjs, `printableText`).
321
+ */
322
+ const emit = (s) => {
323
+ const text = printableText(String(s));
324
+ process.stdout.write(pfx ? text.replace(/\n/g, `\n${pfx}`) : text);
325
+ };
417
326
 
418
- const onChunk = (s) => {
419
- out += s;
420
- emit(s);
327
+ // EVERY TURN IS LINE-PARSED (2026-10-10, the audit). Claude's adapter
328
+ // always asks for `--output-format stream-json` and every other runtime
329
+ // brings its own `parse` — for Codex the JSONL stream is the only output
330
+ // there is. A raw-text path survived for a `streamJson: false` no caller
331
+ // passed any more, with a second copy of the spawn-error rule that had
332
+ // already drifted once (it called process.exit(1)); both are gone. A line
333
+ // that is not JSON is still kept as text (claudeStream.mjs), and stderr
334
+ // still lands in the haystack the sentinels read.
335
+ let buf = '';
336
+ const appendText = (t) => {
337
+ out += t;
338
+ };
339
+ /**
340
+ * THE PLAN'S WINDOWS, LEARNED OFF EVERY TURN THIS DAEMON RUNS (0.109.0).
341
+ *
342
+ * This is the one spawn point — agent turns, Terminal tabs, capture
343
+ * chats, the planner, the pre-review and the wiki all come through here
344
+ * — so it is the one place that sees every CLI's word on its plan.
345
+ * Claude says it on the stream (`rate_limit_event`), recorded as it
346
+ * arrives; a caller's own `onRateLimit` still hears it. Codex writes it
347
+ * into the thread's rollout file instead, so the thread this turn spoke
348
+ * under (announced on `thread.started`, else the one it resumed) is read
349
+ * AFTER the child closes, on the next tick — the turn's answer never
350
+ * waits on a file read. See runtimeLimits.mjs.
351
+ */
352
+ const onRate = (info) => {
353
+ recordClaudeRateLimit(info);
354
+ onRateLimit?.(info);
421
355
  };
422
- // Stream-decoded for the same reason as the line-parsed path above.
356
+ let codexThread = rt.id === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
357
+ /**
358
+ * THE MODEL THIS SPAWN RAN ON, ONCE (2026-09-29, turnModel.mjs). Claude
359
+ * names it in its init event and again on every reply; the first valid
360
+ * one is the answer, so the init wins and the first reply is the
361
+ * fallback. Codex names it only in its rollout, read at close below.
362
+ */
363
+ let ranOn = null;
364
+ const onRanOn = (m) => {
365
+ if (ranOn || !m) return;
366
+ ranOn = m;
367
+ onModel?.(m);
368
+ };
369
+ /**
370
+ * WHAT THE CLI SAID IT HAS — its skills and MCP servers — recorded HERE
371
+ * for every lane (2026-10-10, the audit), beside the plan's windows: four
372
+ * lanes each kept a copy of the harvest, and they drifted until a fenced
373
+ * turn's narrowed list overwrote the machine's report. Only a turn whose
374
+ * argv narrows neither is recorded (`initHarvestFor`,
375
+ * runtimeCapabilities.mjs); a caller's own `onInit` still hears every
376
+ * init.
377
+ */
378
+ const harvest = initHarvestFor(rt, args);
379
+ const onInitAll = (i) => {
380
+ if (harvest.skills) recordSkills(i?.skills);
381
+ if (harvest.mcp) recordMcpServers(i?.mcpServers);
382
+ onInit?.(i);
383
+ };
384
+ /** One line of the child's stdout, in whichever dialect it speaks. */
385
+ const onLine = (line) => {
386
+ if (opts.onCliFailure) {
387
+ try {
388
+ const event = JSON.parse(line);
389
+ if ((event.type === 'result' && event.is_error === true) ||
390
+ event.type === 'error' || event.type === 'turn.failed')
391
+ opts.onCliFailure();
392
+ } catch { /* ordinary non-JSON CLI output */ }
393
+ }
394
+ if (!rt.parse)
395
+ return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn });
396
+ const ev = rt.parse(line, cwd);
397
+ if (!ev) return;
398
+ // The conversation id, when the runtime announces one (codex's
399
+ // thread.started). Purely additive: callers that pass no onThreadId —
400
+ // every dispatch path — see zero behavior change.
401
+ if (ev.threadId) {
402
+ if (rt.id === 'codex') codexThread = ev.threadId;
403
+ onThreadId?.(ev.threadId);
404
+ }
405
+ // The runtime's own token count (codex's `turn.completed`) — the same
406
+ // `onUsage` Claude's result event feeds, so a caller charges a turn
407
+ // identically whichever CLI ran it.
408
+ if (ev.usage) onUsage?.(ev.usage);
409
+ // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
410
+ // wins at the caller, which is what "the final answer" means.
411
+ if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
412
+ if (ev.text) appendText(ev.text);
413
+ if (ev.activity) {
414
+ emit(`${ev.activity.label}\n`);
415
+ onActivity?.(ev.activity);
416
+ }
417
+ };
418
+ // DECODED AS A STREAM, never chunk by chunk (2026-09-24, the audit): a
419
+ // pipe read can end inside a multi-byte character — the em dash models
420
+ // write constantly — and `d.toString()` per chunk turned each half into
421
+ // U+FFFD, which JSON.parse accepts and the reply then carried verbatim.
422
+ // `setEncoding` holds the partial bytes over to the next chunk.
423
423
  child.stdout.setEncoding('utf8');
424
424
  child.stderr.setEncoding('utf8');
425
- child.stdout.on('data', onChunk);
426
- child.stderr.on('data', onChunk);
427
- child.on('error', (e) => {
425
+ child.stdout.on('data', (d) => {
426
+ buf += d;
427
+ let nl;
428
+ while ((nl = buf.indexOf('\n')) >= 0) {
429
+ const line = buf.slice(0, nl);
430
+ buf = buf.slice(nl + 1);
431
+ if (line.trim()) onLine(line);
432
+ }
433
+ });
434
+ // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
435
+ child.stderr.on('data', (s) => {
436
+ out += s;
437
+ emit(s);
438
+ });
439
+ /**
440
+ * A SPAWN THAT FAILS FAILS THE TURN, NOT THE DAEMON — the one copy of the
441
+ * rule. A missing CLI resolves '' with the install line; this called
442
+ * process.exit(1), which was defensible while `claude` was the only
443
+ * runtime and preflight refused to start without it — the process could
444
+ * not reach here. Both halves of that are gone: preflight is now fatal
445
+ * only when NOTHING is drivable, so a Codex-only machine starts
446
+ * legitimately, and the wiki/plan-check/consult turns still ask for Claude
447
+ * by default. On such a machine the first wiki sweep would have killed the
448
+ * whole daemon, taking every in-flight build with it, because one
449
+ * background job could not find one binary. Any other spawn error
450
+ * resolves what was captured so far.
451
+ */
452
+ const onSpawnError = (e) => {
453
+ releaseArgs();
428
454
  if (e.code === 'ENOENT') {
429
- // A MISSING CLI FAILS THE TURN, NOT THE DAEMON — same fix as the
430
- // line-parsed path above; this raw-output duplicate used to
431
- // process.exit(1) and take every in-flight worker down with it.
432
455
  console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
433
456
  resolve('');
434
457
  return;
435
458
  }
436
459
  console.error(e);
437
460
  resolve(out);
461
+ };
462
+ child.on('error', onSpawnError);
463
+ child.on('close', (code, signal) => {
464
+ releaseArgs();
465
+ opts.onExit?.(code, signal);
466
+ if (buf.trim()) onLine(buf);
467
+ const handBack = () => {
468
+ resolve(out);
469
+ // After the answer is handed back, never before it: see `onRate`.
470
+ if (codexThread) {
471
+ const thread = codexThread;
472
+ setImmediate(() => void learnCodexLimits(thread));
473
+ }
474
+ };
475
+ /**
476
+ * A CODEX TURN'S MODEL IS IN ITS ROLLOUT, so a caller that asked for
477
+ * it waits for one bounded read before the answer goes back — the
478
+ * settle it rides is built from that answer. Only then: a caller that
479
+ * passes no `onModel` hands back exactly as before. A read that fails
480
+ * or overruns reports nothing (never a guess).
481
+ */
482
+ if (codexThread && onModel && !ranOn) {
483
+ const thread = codexThread;
484
+ let timer = null;
485
+ const cap = new Promise((res) => {
486
+ timer = setTimeout(() => res(null), CODEX_MODEL_READ_MS);
487
+ });
488
+ void Promise.race([codexTurnModel(thread, { since: spawnedAt }), cap])
489
+ .then(onRanOn, () => {})
490
+ .finally(() => {
491
+ clearTimeout(timer);
492
+ handBack();
493
+ });
494
+ return;
495
+ }
496
+ handBack();
438
497
  });
439
- child.on('close', () => resolve(out));
440
498
  });
441
499
  }