flowviant 0.103.0 → 0.104.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 (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -1,357 +1,20 @@
1
1
  /**
2
- * Driving a coding CLI: the operating-contract system prompts, the permission
3
- * posture, and one headless turn. The hard rule baked into both prompts: there
4
- * is no interactive user — the only channel to a human is the blocker loop.
2
+ * The Flowviant side of driving a coding CLI that is not a posture, a stream
3
+ * or a process: the sentinel protocol (a line the contract prompts require a
4
+ * turn to end on) and the hand-over of the flowviant MCP server to whichever
5
+ * runtime runs the turn. The hard rule baked into the prompts: there is no
6
+ * interactive user — the only channel to a human is the blocker loop.
5
7
  *
6
- * `runTurn` used to BE `claude -p`, argv and all. The argv, the binary, the way
7
- * the MCP server is handed over and the shape of the event stream now come from
8
- * the runtime registry (runtimes.mjs), because those four things are exactly
9
- * what differs between one CLI and the next. What stays here is everything that
10
- * is about FLOWVIANT rather than about a vendor: the contract prompts, the
11
- * permission sets, the sentinel protocol, and the turn plumbing.
8
+ * This file used to hold everything about a turn. It was split by reason to
9
+ * change (SOLID 2026-09-26): the contract prompts live in prompts.mjs, the
10
+ * permission lists in claudePosture.mjs, Claude's event-stream reader in
11
+ * claudeStream.mjs, and the spawn-and-supervise of one turn in runTurn.mjs.
12
+ * The argv, the binary and the event shape of each vendor come from each
13
+ * CLI's registry row (runtime{Claude,Codex,Antigravity}.mjs, assembled in
14
+ * runtimes.mjs; the stream's meaning in runtimeEvents.mjs).
12
15
  */
13
16
 
14
- import { spawn } from 'node:child_process';
15
- import { fileURLToPath } from 'node:url';
16
- import { SAFE } from './config.mjs';
17
- import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
18
-
19
- // Every prompt/kickoff constant lives in prompts.mjs and is re-exported here:
20
- // a dozen call sites import them from claude.mjs, and none of them care where
21
- // the strings live.
22
- export * from './prompts.mjs';
23
-
24
- /**
25
- * THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
26
- * hooks/readGuard.mjs for what it refuses and the probe that proved it fires.
27
- *
28
- * A `Bash(git log:*)` allow is a PREFIX: it names the program and says nothing
29
- * about the arguments, and `git log --output=<path>` writes any file while
30
- * `-c core.fsmonitor=…` / `--ext-diff` run a program. A reviewer landed
31
- * `git log -1 --format='tformat:x' --output=pwned.txt` under RESEARCH_PERM with
32
- * no denial, and it re-measured the same way here. So every list below that
33
- * promises "reads, and nothing else" carries a `PreToolUse` hook through
34
- * `--settings`, measured on Claude Code 2.1.281: it fires under
35
- * `--allowedTools`, exit 2 blocks the call, and its sentence reaches the model.
36
- *
37
- * NEVER ON THE BUILD POSTURE: `--dangerously-skip-permissions` is the
38
- * operator's own choice for a turn that is meant to write, and a guard there
39
- * would be refusing the job. Nor on PLAN_MODE_PERM, which is the CLI's own
40
- * posture on the owner's own tab, not a list this file promises anything about.
41
- *
42
- * The hook runs as `<this node> <readGuard.mjs>` — the daemon's own
43
- * `process.execPath`, never a `node` looked up on a PATH the operator may not
44
- * have, because a hook that fails to START is a non-blocking error and the
45
- * command runs anyway.
46
- */
47
- export const READ_GUARD_PATH = fileURLToPath(new URL('./hooks/readGuard.mjs', import.meta.url));
48
- const hookQuote = (s) =>
49
- process.platform === 'win32' ? `"${s}"` : `'${String(s).replace(/'/g, `'\\''`)}'`;
50
- export const READ_GUARD_SETTINGS = JSON.stringify({
51
- hooks: {
52
- PreToolUse: [
53
- {
54
- matcher: 'Bash',
55
- hooks: [{ type: 'command', command: `${hookQuote(process.execPath)} ${hookQuote(READ_GUARD_PATH)}` }],
56
- },
57
- ],
58
- },
59
- });
60
- /** Spread FIRST into a curated list: `--allowedTools` is variadic, so the
61
- * `--settings` pair must stand before it or be swallowed as a tool name. */
62
- const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
63
-
64
- /** A path a permission rule can name: absolute POSIX, and free of the glob and
65
- * rule-syntax characters that would change what the rule means. Anything else
66
- * gets no rule — `--add-dir` still admits the directory (measured). */
67
- const ruleSafeAbsolute = (p) =>
68
- typeof p === 'string' && p.startsWith('/') && !/[*?[\]{}()\\\n]/.test(p) ? p.replace(/\/+$/, '') : null;
69
-
70
- /**
71
- * THE READ FENCE — the worktree, plus the knowledge library by its absolute
72
- * path, and nothing else on the box (2026-09-24, the audit).
73
- *
74
- * Every curated posture used to allow bare `Read`, `Grep` and `Glob`, and a
75
- * bare allow is not "read the repo": measured on Claude Code 2.1.281, bare
76
- * `Read` read a file outside the working directory and bare `Grep` searched
77
- * one. So a design, consult or capture turn — each steered by words anyone who
78
- * can file a card can write — could read `~/.flowviant/credentials.json`, which
79
- * holds the MACHINE CREDENTIAL of every project connected on this box, and put
80
- * it in an artifact, a plan note or a transcript that leaves the machine. That
81
- * is a cross-project leak from a posture this file calls read-only.
82
- *
83
- * The fence is research's, measured the same day in the design shape:
84
- * `Read(./**)` and `Glob(./**)` read the worktree; the knowledge library reads
85
- * through `--add-dir` plus its own `//` rule; a file outside both was refused
86
- * ("Claude requested permissions to read from …, but you haven't granted it
87
- * yet"). GREP CARRIES NO ALLOW RULE AT ALL, on purpose: with none, Grep in the
88
- * working directory (and in the added knowledge directory) ran, and Grep of a
89
- * path outside them was refused. The Bash readers these postures keep are
90
- * path-validated by the CLI on its own (see CONSULT_BASH).
91
- */
92
- function fencedReads(knowledgeDir) {
93
- const kd = ruleSafeAbsolute(knowledgeDir);
94
- return [
95
- 'Read(./**)',
96
- ...(kd ? [`Read(/${kd}/**)`] : []),
97
- 'Glob(./**)',
98
- ...(kd ? [`Glob(/${kd}/**)`] : []),
99
- ];
100
- }
101
-
102
- /** The shell readers CONSULT and PLAN keep. Each one is PATH-VALIDATED by the
103
- * CLI itself — measured on 2.1.281 (2026-09-24): with `Bash(cat:*)`,
104
- * `Bash(head:*)` and `Bash(ls:*)` allowed, `cat`/`ls`/`head` on a file outside
105
- * the working directories were each refused ("Claude Code may only
106
- * concatenate files from the allowed working directories") — so it is the
107
- * Read/Grep/Glob TOOLS, not these, that needed the fence above. */
108
- const CONSULT_BASH = [
109
- 'Bash(ls:*)',
110
- 'Bash(wc:*)',
111
- 'Bash(head:*)',
112
- 'Bash(cat:*)',
113
- 'Bash(git log:*)',
114
- 'Bash(git show:*)',
115
- 'Bash(git diff:*)',
116
- 'Bash(git rev-parse:*)',
117
- ];
118
-
119
- /**
120
- * PLAN — read the repo, write the plan, never the code.
121
- *
122
- * The read half is `consultPermFor`'s verbatim (the read fence and the
123
- * path-validated shell readers): this turn's prompt is steered by
124
- * anything a project editor can type, so the same threat applies and the same
125
- * allowlist answers it. What is added is the control plane and NOTHING else —
126
- * `mcp__flowviant` is the plan principal's token, whose entire tool set is the
127
- * five plan tools (the server refuses anything else on it). So even a fully
128
- * hijacked turn's most destructive reachable act is dropping a slice from the
129
- * plan it is already in, which a human can see and undo in the thread.
130
- *
131
- * Note what is absent versus WIKI_PERM: Write, Edit, mkdir and rm. The
132
- * cartographer needs those because it authors files; a planner authors records
133
- * through an API, and there is no file on this machine it has any business
134
- * touching.
135
- */
136
- export function planPermFor(knowledgeDir) {
137
- return [
138
- ...READ_GUARD,
139
- '--allowedTools',
140
- 'mcp__flowviant',
141
- ...fencedReads(knowledgeDir),
142
- ...CONSULT_BASH,
143
- ];
144
- }
145
-
146
- // Unattended (default) skips prompts so the agent never stalls with no terminal;
147
- // FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
148
- const PERM = SAFE
149
- ? [
150
- '--allowedTools',
151
- 'mcp__flowviant',
152
- 'Edit',
153
- 'Write',
154
- 'Read',
155
- 'Grep',
156
- 'Glob',
157
- 'Bash(git:*)',
158
- 'Bash(gh:*)',
159
- 'Bash(npm:*)',
160
- 'Bash(bun:*)',
161
- ]
162
- : ['--dangerously-skip-permissions'];
163
-
164
- // Wiki turns are read-the-repo + write-the-vault ONLY — always curated, never
165
- // --dangerously-skip-permissions: no gh, no push-capable git, no package
166
- // managers, and nothing that can EXECUTE arbitrary commands — no `find`
167
- // (-exec/-delete) and no `git grep` (-O<pager> runs a shell; the Grep tool
168
- // covers search). Command execution is the line: it enables network exfil,
169
- // which plain file writes never do. `rm` IS allowed: pruning a stale vault
170
- // page requires a real file deletion (that's how the sync protocol learns of
171
- // it), and the blast radius is bounded — the daemon resets the repo worktree
172
- // after every wiki turn, and the vault has its own git history.
173
- // (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
174
- const WIKI_PERM = [
175
- ...READ_GUARD,
176
- '--allowedTools',
177
- 'Read',
178
- 'Grep',
179
- 'Glob',
180
- 'Edit',
181
- 'Write',
182
- 'Bash(ls:*)',
183
- 'Bash(wc:*)',
184
- 'Bash(head:*)',
185
- 'Bash(cat:*)',
186
- 'Bash(mkdir:*)',
187
- 'Bash(rm:*)',
188
- 'Bash(git status:*)',
189
- 'Bash(git log:*)',
190
- 'Bash(git show:*)',
191
- 'Bash(git diff:*)',
192
- 'Bash(git rev-parse:*)',
193
- ];
194
-
195
- // A CONSULT reads and answers. Nothing else.
196
- //
197
- // It used to run on WIKI_PERM, whose comment two blocks up says the quiet part:
198
- // Write/Edit "can't be path-scoped here; the worktree reset is the backstop".
199
- // That is a fine trade for the cartographer, which exists to author files and
200
- // gets reset after every turn. It is the wrong trade for a consult, whose prompt
201
- // is steered by a question ANY project editor can write and which had no reset
202
- // behind it — so a sentence in a chat box could reach Write, rm and mkdir on
203
- // someone else's machine. The permission list is the enforcement; the prompt's
204
- // "do not change anything" is only an instruction, and instructions are exactly
205
- // what an injected question competes with.
206
- export function consultPermFor(knowledgeDir) {
207
- return [...READ_GUARD, '--allowedTools', ...fencedReads(knowledgeDir), ...CONSULT_BASH];
208
- }
209
-
210
- /**
211
- * THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
212
- * WRITE, and only under `.flowviant/artifacts/`.
213
- *
214
- * The card's words are steered by anyone who can file a card, so "change
215
- * nothing" as prose is only an instruction, and the permission list is the
216
- * enforcement. What each carries is the ONE write its contract needs:
217
- *
218
- * · `Edit(.flowviant/artifacts/**)` — PROBED on Claude Code 2.1.281 before
219
- * relying on it (2026-09-23): with `--allowedTools 'Read'
220
- * 'Edit(.flowviant/artifacts/**)'`, a `-p` turn told to write
221
- * `.flowviant/artifacts/a.html` AND `b.txt` at the root wrote the first and
222
- * was DENIED the second. The Edit rule governs every file-writing tool,
223
- * Write included. Spelled `Write(.flowviant/artifacts/**)` — or `./`- or
224
- * `//abs`-anchored — the same probe was denied BOTH files, so that spelling
225
- * is not a scoped write at all, it is no write. Relative to the turn's cwd,
226
- * which is the agent's own worktree, which is where the artifact scan looks.
227
- * `**` AND NOT `*`, measured the same day: `Edit(.flowviant/artifacts/*)`
228
- * let a Write to `.flowviant/artifacts/sub/b.html` through exactly as `**`
229
- * did, so the single-level spelling is not narrower on this CLI and would
230
- * only read as if it were. The scan keeps top-level files, so the
231
- * contracts say "no subfolders" — the prompt is the only thing that can.
232
- *
233
- * THEY ARE NOT THE SAME READER, and the difference is the network:
234
- *
235
- * · DESIGN draws THIS product, so it reads the repo — the worktree and the
236
- * knowledge library through the read fence (`fencedReads`, 2026-09-24:
237
- * it was bare Read/Grep/Glob, which reach the whole box), Grep with no
238
- * allow rule, `ls`, and `.env*` denied by name — and has no web at all (a mockup that went looking on the
239
- * web would be a mockup of somebody else's product). No git readers and no
240
- * cat/head/wc: Read covers every one of them, and each git reader was an
241
- * `--output` away from a write (see READ_GUARD, which it still carries for
242
- * the `ls` it keeps).
243
- * · RESEARCH has the web — WebSearch and WebFetch — and a turn that can both
244
- * read a secret and fetch a URL can send one. So its reading is FENCED to
245
- * what the card is about: `Read(./**)` (the worktree) plus the project's
246
- * knowledge library by its absolute path, `Glob(./**)` for names, NO Grep
247
- * (an unscoped search of the home directory prints matching lines), NO
248
- * Bash at all, and `.env*` DENIED by name. PROBED on 2.1.281, the whole
249
- * list with `--add-dir <knowledge>`: a file in the cwd read; `/etc/hostname`,
250
- * a sibling directory's file and `~/.flowviant/credentials.json` were each
251
- * refused ("Claude requested permissions to read from …, but you haven't
252
- * granted it yet"); `.env` was refused ("File is in a directory that is
253
- * denied by your permission settings"); the knowledge file read. Two
254
- * controls: WITHOUT the `--disallowedTools` pair, `Read(./**)` read `.env`
255
- * — the deny is load-bearing; and a bare `Glob` listed `~/.flowviant`
256
- * while `Glob(./**)` refused it and still listed the cwd and the added
257
- * directory. The knowledge `Read(//…)` rule is belt: `--add-dir` alone
258
- * admitted the file, and the rule keeps the read allowed should the
259
- * adapter ever stop passing that flag.
260
- * AND `Bash` IS DENIED BY NAME, because leaving it off the allow list is
261
- * not "no Bash": measured the same day, with Bash absent from
262
- * `--allowedTools` the CLI still ran `git log -1 --format=%s` and `ls` on
263
- * its own read-only classifier (it did refuse `git log … --output=…`,
264
- * "This command requires approval" — the reviewer's write needed the
265
- * explicit `Bash(git log:*)` prefix the old list carried). With `Bash` in
266
- * `--disallowedTools` the tool was not there at all.
267
- *
268
- * NO MCP, and no shell that could commit: the agent turn has no MCP anyway
269
- * (see SYSTEM_AGENT's header), and a posture that could run `git commit` could
270
- * commit the mockup it was told to keep out of git.
271
- *
272
- * STATED, not closed: research may still read any file in the worktree and
273
- * name it in a WebFetch URL — the repository is what a research card is about,
274
- * and a turn that could not read it could not answer. Design may read any
275
- * file in its worktree and put it in an artifact the server stores; text
276
- * artifacts are scrubbed of the machine's known secret values — and of any
277
- * Flowviant credential, by shape — on the way out (artifacts.mjs, env.mjs), and
278
- * binary ones are withheld when they carry one.
279
- * Design still runs a plain `git log` on the CLI's own read-only classifier
280
- * though no git reader is allowed (measured) — the guard is what refuses its
281
- * `--output`. And the Agent tool is offered with no allow rule on this CLI:
282
- * a research turn with Bash denied launched a subagent to run one, and the
283
- * subagent had no Bash either (measured) — the rules are inherited. Whether
284
- * the guard's hook sees a subagent's own calls was not measured here.
285
- */
286
- const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
287
- /**
288
- * PLAN MODE (0.97.0) — a Workbench tab's `planMode` switch, and Claude Code's
289
- * own `--permission-mode plan` INSTEAD OF every list above: the CLI reads,
290
- * decides and answers with a plan, and refuses each write itself.
291
- *
292
- * PROBED on Claude Code 2.1.281 before relying on it (2026-09-23), in a scratch
293
- * git repo, `-p … --output-format stream-json --verbose`:
294
- *
295
- * · ALONE, asked to "add a subtract(a, b) function to app.js": the init event
296
- * reads `permissionMode: "plan"`, the turn Read the file, ran a read-only
297
- * `find`, wrote its plan to `~/.claude/plans/<slug>.md` (the CLI's own plan
298
- * file, outside the repo), tried `ExitPlanMode` and was told "Error: No such
299
- * tool available: ExitPlanMode. ExitPlanMode is disabled for this session",
300
- * and ended with the plan as its result text. app.js was untouched.
301
- * · BESIDE `--dangerously-skip-permissions`, same ask: the init event reads
302
- * `permissionMode: "bypassPermissions"` — no error, no warning — and the
303
- * edit LANDED. The bypass silently wins. So this list is a REPLACEMENT for
304
- * `perm`, never an addition, and a plan turn can never carry both.
305
- * · WITH `--mcp-config` and `--allowedTools mcp__flowviant`: the server
306
- * connected and its tools were listed, but a call was refused — "Cannot
307
- * call mcp__flowviant__log_work while in plan mode." — and without the
308
- * `--allowedTools` entry, "Claude requested permissions to use
309
- * mcp__flowviant__log_work, but you haven't granted it yet." Plan mode
310
- * admitted the call ONLY when the server annotated the tool
311
- * `readOnlyHint: true`, and none of the session tools (stream_session_turn,
312
- * log_work, file_card …) are read-only or annotated so. So a plan turn runs
313
- * PLAIN — no MCP, the plain tab's contract — rather than mounting a control
314
- * plane whose every call the CLI refuses. See work.mjs.
315
- */
316
- export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
317
- export function designPermFor(knowledgeDir) {
318
- return [
319
- ...READ_GUARD,
320
- '--allowedTools',
321
- ...fencedReads(knowledgeDir),
322
- 'Bash(ls:*)',
323
- ARTIFACT_WRITE,
324
- // A mockup is uploaded and runs scripts in a frame that may navigate
325
- // itself, so the checkout's own secrets are denied by name too — the same
326
- // pair research carries, measured load-bearing there.
327
- '--disallowedTools',
328
- 'Read(./.env*)',
329
- 'Read(./**/.env*)',
330
- ];
331
- }
332
- export const DESIGN_PERM = designPermFor(null);
333
-
334
- /**
335
- * RESEARCH, built at spawn — the knowledge library is the one directory
336
- * outside the worktree it may read, and only the spawn knows where that is.
337
- * `//` is the CLI's own spelling for an absolute path in a rule.
338
- */
339
- export function researchPerm(knowledgeDir) {
340
- return [
341
- ...READ_GUARD,
342
- '--allowedTools',
343
- ...fencedReads(knowledgeDir),
344
- 'WebSearch',
345
- 'WebFetch',
346
- ARTIFACT_WRITE,
347
- '--disallowedTools',
348
- 'Read(./.env*)',
349
- 'Read(./**/.env*)',
350
- 'Bash',
351
- ];
352
- }
353
- /** The list with no library — what a project that keeps none runs under. */
354
- export const RESEARCH_PERM = researchPerm(null);
17
+ import { runtimeById } from './runtimes.mjs';
355
18
 
356
19
  export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
357
20
 
@@ -393,507 +56,7 @@ export const blockedId = (out) => {
393
56
  */
394
57
  export function mcpFor(runtimeId, token, mcpUrl) {
395
58
  const rt = runtimeById(runtimeId);
59
+ if (!rt) throw new Error(`runtime '${runtimeId}' is not one this daemon knows — update flowviant`);
396
60
  if (!rt.mcp) throw new Error(`runtime '${rt.id}' cannot take an MCP server: ${rt.blocked}`);
397
61
  return rt.mcp(token, mcpUrl);
398
62
  }
399
-
400
- // Turn one Claude tool_use into a compact activity {kind, label}, or null for
401
- // tools not worth surfacing. `kind:'read'` is what the file counter counts; a
402
- // Write/Edit of a vault page is the "writing" signal. Used by wiki turns to
403
- // stream exactly which files Claude is touching (daemon console + app cover).
404
- //
405
- // The body moved to runtimes.mjs, beside Codex's equivalent, because they are
406
- // the same job for two vendors and keeping them apart is how the two activity
407
- // vocabularies drift. Re-exported under its original name: a dozen call sites
408
- // know it, and none of them care where it lives.
409
- export const humanizeToolUse = humanizeClaudeTool;
410
-
411
- // Collapse whitespace + clip so a narration/thinking snippet is one tidy feed line.
412
- const oneLine = (s, n = 160) => String(s).replace(/\s+/g, ' ').trim().slice(0, n);
413
-
414
- /**
415
- * WHAT THIS TURN COST, IN THE CLI'S OWN NUMBERS (2026-09-19).
416
- *
417
- * The `result` event carries a `usage` object — `input_tokens`,
418
- * `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` —
419
- * and this reads exactly those four and nothing else. A RELAY: nothing here
420
- * derives, estimates or prices anything.
421
- *
422
- * DELIBERATELY NOT `total_cost_usd`, which rides the same event. It is a
423
- * notional list price that a subscription operator did not pay, so relaying it
424
- * as "cost" would be the product asserting a figure nobody was charged — the
425
- * refusal the board's fix pass already made once against the mock's "1.4
426
- * spent". Tokens are measured; dollars are not.
427
- *
428
- * COERCED AND FLOORED, never trusted: a non-number, a NaN, an Infinity or a
429
- * negative reads as 0, because these are summed into a counter that only ever
430
- * goes up and one bad field must not poison the other three.
431
- *
432
- * Returns null when there is no usage object at all — the three-state rule
433
- * every readout here keeps: a turn that reported nothing charges nothing, and
434
- * that is not the same as a turn that reported zeros.
435
- */
436
- export function usageFromResult(ev) {
437
- const u = ev?.usage;
438
- if (!u || typeof u !== 'object') return null;
439
- const n = (v) => {
440
- const x = Number(v);
441
- return Number.isFinite(x) && x > 0 ? Math.floor(x) : 0;
442
- };
443
- return {
444
- input: n(u.input_tokens),
445
- output: n(u.output_tokens),
446
- cacheCreate: n(u.cache_creation_input_tokens),
447
- cacheRead: n(u.cache_read_input_tokens),
448
- };
449
- }
450
-
451
- // Parse ONE line of `--output-format stream-json` NDJSON into feed activities.
452
- // Surfaces the WHOLE turn — thinking, narration, AND every tool — so neither the
453
- // daemon console nor the app cover goes dark while Claude reasons (Opus thinks in
454
- // bursts before/between tools; emitting only tools left long silent gaps).
455
- // Assistant text is also folded into `out` so the WIKI_DONE/REGROUND_DONE
456
- // sentinels still match. A non-JSON line (a stray warning) is kept as raw text.
457
- //
458
- // `answerFromResult` narrows that last part for callers whose `out` IS the
459
- // answer rather than a haystack to match sentinels in (a Workbench tab's turn):
460
- // every intermediate text block still NARRATES, but only the final `result`
461
- // event contributes text — otherwise the same sentences arrive twice, once as
462
- // they stream and once in the result, and the tab posts the duplicate.
463
- //
464
- // EXPORTED FOR ITS TEST ONLY (2026-09-19), and the reason is worth stating: the
465
- // `onUsage` threading through this function, `runTurn`'s options and `onLine`
466
- // was four edits and NOT ONE of them was reachable from either suite — delete
467
- // any one and both stay green, while the only symptom in production is a
468
- // container that reports no spend, which is indistinguishable from an older
469
- // daemon by design. A callback that is silent when it breaks has to be called
470
- // directly by something.
471
- export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage }) {
472
- let ev;
473
- try {
474
- ev = JSON.parse(line);
475
- } catch {
476
- appendText(line + '\n');
477
- emit(line + '\n');
478
- return;
479
- }
480
- const push = (a) => {
481
- if (!a || !a.label) return;
482
- emit(a.label + '\n');
483
- onActivity?.(a);
484
- };
485
- if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
486
- for (const b of ev.message.content) {
487
- if (b.type === 'thinking' || b.type === 'redacted_thinking') {
488
- /**
489
- * NO THINKING TEXT ARRIVES TODAY, AND THAT IS MEASURED (2026-09-16).
490
- *
491
- * This used to say the text is "usually redacted", which was a guess
492
- * doing the work of a fact. The fact: across three real transcripts, 93
493
- * thinking blocks, EVERY ONE of them carried an empty `thinking` — and
494
- * a live probe with MAX_THINKING_TOKENS set and
495
- * `--include-partial-messages` on returned an empty `thinking_delta`
496
- * and a complete block of length zero (signature only). So the CLI
497
- * emits the FACT that it reasoned and not a word of the reasoning, and
498
- * "show the full thinking" cannot be conjured from this stream.
499
- *
500
- * THE BRANCH BELOW EXISTS ANYWAY, and deliberately. The shape is the
501
- * whole point: when text is present it rides `full` UNCLIPPED, so the
502
- * day a CLI release starts emitting it, the trace carries the thought
503
- * whole with no daemon change and no version floor — the report's own
504
- * presence is the capability. Until then every block takes the marker
505
- * arm, and `trace.mjs` collapses a run of identical markers so the
506
- * absence reads as one quiet step rather than forty.
507
- */
508
- push(
509
- b.thinking
510
- ? { kind: 'think', label: `thinking: ${oneLine(b.thinking)}`, full: b.thinking }
511
- : { kind: 'think', label: THINK_MARKER }
512
- );
513
- } else if (b.type === 'text' && b.text?.trim()) {
514
- if (!answerFromResult) appendText(b.text + '\n');
515
- // `label` for the console and the pulse — one collapsed 160-char line,
516
- // byte-identical to what it has always printed. `full` for the trace:
517
- // a `say` is the agent NARRATING, several sentences at a time, and the
518
- // clip at 160 was landing mid-sentence on the one thing a person opens
519
- // the page to read.
520
- push({ kind: 'say', label: oneLine(b.text), full: b.text });
521
- } else if (b.type === 'tool_use') {
522
- push(humanizeToolUse(b.name, b.input || {}, cwd));
523
- // The STRUCTURED form of the same event, for the transcript's tool
524
- // cards — raw name + input, so the collector can keep what the
525
- // one-line humanizer drops (an Edit's counts, the plan's items).
526
- onToolEvent?.(b.name, b.input || {});
527
- }
528
- }
529
- } else if (ev.type === 'system' && ev.subtype === 'init') {
530
- // WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
531
- // CLI's OWN answer — it has already resolved personal skills, this repo's
532
- // skills, plugins and whatever the project settings enable or disable — so
533
- // reading it costs nothing and cannot drift the way a `~/.claude/skills`
534
- // scan of our own would. `skills` (rather than `slash_commands`) is the
535
- // deliberate narrowing: the 50-odd commands beside it are the CLI's own
536
- // interactive furniture (/clear, /model, /compact), and offering those in a
537
- // relayed tab would be an offer wired to nothing.
538
- //
539
- // Only ever REPORTED, never enforced. Flowviant does not decide what your
540
- // Claude can do; it relays what your Claude said it has.
541
- // `sessionId` rides along for one reason: a `-p` turn WRITES a transcript,
542
- // and `localSessions.mjs` offers the newest ended session per directory as
543
- // ADOPTABLE — so any headless turn we run for our own purposes would leave
544
- // a phantom untitled session in the `+` menu. The caller that needs to
545
- // clean up after itself cannot do so without this id.
546
- //
547
- // `mcpServers` (0.97.0) is the same kind of fact from the same event: the
548
- // MCP servers and claude.ai connectors the CLI mounted, each with its own
549
- // status (`needs-auth` above all — a sign-in that has to happen at this
550
- // box). Relayed raw; `recordMcpServers` (runtimes.mjs) normalises it and
551
- // drops this daemon's own `flowviant` server.
552
- if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
553
- onInit?.({
554
- skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
555
- sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
556
- mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
557
- });
558
- }
559
- } else if (ev.type === 'result') {
560
- /**
561
- * WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
562
- *
563
- * FIRED ON A FAILED RESULT TOO, and that ordering is the point: a turn that
564
- * hit a limit, ran out of permission or aborted still sent the requests it
565
- * sent, and a spend readout that quietly skipped every unhappy turn would
566
- * under-report exactly the runs somebody is looking at the number to
567
- * understand. `usageFromResult` returns null when the event carries no
568
- * usage at all, and a caller that passes no `onUsage` sees no change.
569
- */
570
- const usage = usageFromResult(ev);
571
- if (usage) onUsage?.(usage);
572
- // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
573
- if (typeof ev.result === 'string') appendText(ev.result + '\n');
574
- else if (ev.is_error || ev.subtype) {
575
- // A result that carries no text is a FAILED turn (a limit, a refused
576
- // permission, an aborted run). Under `answerFromResult` this is the only
577
- // stdout that would have said so, and a caller whose `out` is the answer
578
- // must not report "no output" for a turn that explained itself.
579
- // `errors[]` FIRST, because it is where the real sentence is. Claude
580
- // Code reports a dead `--resume` id as
581
- // `{subtype:'error_during_execution', errors:['No conversation found
582
- // with session ID: …']}` — reading only `error`/`subtype` dropped that
583
- // and appended the literal string `error_during_execution`, which told
584
- // the driver nothing and hid the one phrase the caller needs to
585
- // recognise a lost conversation.
586
- const listed = Array.isArray(ev.errors)
587
- ? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
588
- : '';
589
- const msg = listed || ev.error?.message || ev.error || ev.subtype;
590
- appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
591
- }
592
- }
593
- }
594
-
595
- /**
596
- * THE ENVIRONMENT A CLI TURN IS SPAWNED WITH — the daemon's own, plus the
597
- * turn's MCP token, MINUS the machine credential (2026-09-24, the audit).
598
- *
599
- * A turn cannot be handed a curated environment the way `childEnv` builds one
600
- * for a deploy: the CLI's own sign-in lives in this environment, and stripping
601
- * it signs the CLI out. But on a headless box started as
602
- * `FLOWVIANT_MACHINE_TOKEN=… npx flowviant` the MACHINE CREDENTIAL lives here
603
- * too, and every turn — and every dev server and script a turn starts, which
604
- * inherit it — could read it with one `env`. Nothing a CLI or its children do
605
- * needs it: the daemon authenticates to the server itself, and a turn that
606
- * needs the project's tools gets its own per-turn token through `mcpEnv` or
607
- * the MCP config file. So those two names, and only those, are removed.
608
- */
609
- const MACHINE_CREDENTIAL_ENV = ['FLOWVIANT_MACHINE_TOKEN', 'FLOWVIANT_FLEET'];
610
- export function cliEnv(mcpEnv) {
611
- const env = { ...process.env, ...(mcpEnv ?? {}) };
612
- for (const k of MACHINE_CREDENTIAL_ENV) delete env[k];
613
- return env;
614
- }
615
-
616
- // One Claude Code turn. Output is captured (for sentinel detection) and streamed
617
- // through, line-prefixed with the worker label so a fleet stays legible.
618
- //
619
- // `streamJson` switches to `--output-format stream-json` and parses the event
620
- // stream: only the humanized tool activity reaches the console (a legible
621
- // stream of `read …`, `grep …`, `+ node …`), assistant text is folded into the
622
- // returned string for sentinel detection, and each activity is handed to
623
- // `onActivity` so the caller can forward progress. Build-agent turns leave it
624
- // off and keep the raw text passthrough + line sentinels.
625
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, agentTools, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, onAnswer, wikiPerm, readOnly, planPerm, planMode, posture, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
626
- return new Promise((resolve) => {
627
- const rt = runtimeById(runtime);
628
- // PLAN MODE IS CLAUDE'S (0.97.0). The other adapters build their argv
629
- // from `profile` and never read `perm`, so a codex or agy turn handed
630
- // `planMode` would run a BUILD turn wearing the word "plan". The caller
631
- // refuses first, in words; this is the belt — fail the turn, never
632
- // substitute.
633
- if (planMode && rt.id !== 'claude') {
634
- console.error(`\nerror: plan mode runs on Claude Code only — not '${rt.label}'`);
635
- resolve('');
636
- return;
637
- }
638
- // …AND SO ARE THE TWO NON-CODE POSTURES, for the same reason and in the
639
- // same shape. The agent lane asks `canRun` first and settles `nothing` in
640
- // words; this is the layer no caller can skip. A codex or agy adapter
641
- // handed `posture: 'design'` never reads `perm` — it builds from `profile`
642
- // and runs whatever that falls back to, which for codex is its build
643
- // branch, `--sandbox danger-full-access`: a research card's words with the
644
- // whole machine to act on. Asked against the runtime's own declared profiles,
645
- // so the day an adapter learns to express one this opens by itself.
646
- if ((posture === 'design' || posture === 'research') && !(rt.profiles ?? []).includes(posture)) {
647
- console.error(`\nerror: a ${posture} card runs on Claude Code only — not '${rt.label}'`);
648
- resolve('');
649
- return;
650
- }
651
- if (!rt.args) {
652
- // Reached only if a brief names a runtime this daemon declares but cannot
653
- // drive. Fail as a turn with no sentinel — the loop already treats that as
654
- // "the protocol did not complete" and retries, rather than inventing a
655
- // completion for work that never started.
656
- console.error(`\nerror: cannot run '${rt.label}' — ${rt.blocked}`);
657
- resolve('');
658
- return;
659
- }
660
- // Pin the model — never inherit the user's global default (which for Claude
661
- // may be a 1M/long-context tier their subscription can't bill autonomous
662
- // work on). A per-task override (chosen in the app, validated server-side
663
- // against a fixed list before it ever reaches this argv) wins over the
664
- // machine pin; absent, the pin stands. Effort has no machine-level pin at
665
- // all: unset means the CLI's own default, the honest resting state.
666
- //
667
- // readOnly wins over wikiPerm: a consult must never inherit write tools.
668
- //
669
- // TWO FORMS OF THE SAME DECISION, and the redundancy is deliberate rather
670
- // than sloppy. `profile` is the NAME of the posture — a promise about what
671
- // must be impossible during the turn — and every runtime expresses it in its
672
- // own vocabulary: Claude as an `--allowedTools` verb list, Codex as a kernel
673
- // sandbox mode plus feature toggles. `perm` is Claude's expression, still
674
- // computed here only because those three arrays live in this file; it
675
- // collapses into the registry the day every runtime expresses every profile.
676
- // Both derive from the same branch, so they cannot disagree about which
677
- // posture a turn is running under.
678
- // `plan` is asked FIRST, above readOnly, because it is the narrower promise
679
- // of the two and a planning turn that fell through to 'consult' would lose
680
- // the control plane it exists to use — it would read the repo, decide what
681
- // the slices are, and have no way to write any of them down.
682
- // A DESIGN OR RESEARCH CARD'S POSTURE (0.97.0) is asked before all of
683
- // them, by NAME, because it is the narrowest promise here: write only the
684
- // artifacts directory. It is set only by the agent lane, from the card's
685
- // kind; every other caller passes nothing and gets exactly the branch it
686
- // got before this existed.
687
- const profile =
688
- posture === 'design' || posture === 'research'
689
- ? posture
690
- : planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
691
- const args = rt.args({
692
- prompt,
693
- system,
694
- model,
695
- effort,
696
- resume,
697
- streamJson,
698
- profile,
699
- agentTools,
700
- // Adopting a terminal session (work.mjs): Claude turns it into
701
- // `--resume <id> --fork-session` (a FORK — the original is untouched);
702
- // agy turns it into `--conversation <id>` (a MOVE — agy has no fork, the
703
- // tab continues the terminal conversation itself). Codex THROWS on it,
704
- // so a mis-wired adoption fails as a loud turn error rather than a
705
- // silent fresh conversation wearing an adopted session's name.
706
- adoptResumeId,
707
- // Only the wiki profile uses it, but it is passed unconditionally: a
708
- // runtime that can path-scope its writes needs to know WHERE the vault is,
709
- // and Claude — which cannot — simply ignores it.
710
- vaultDir,
711
- // THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0), when this box holds one. It
712
- // lives in the CHECKOUT and a turn usually runs in a worktree, so the
713
- // prompt hands an absolute path OUTSIDE the cwd — and a curated Claude
714
- // profile (the capture chat's read-only list, `FLOWVIANT_SAFE=1`) may
715
- // refuse a read there. `--add-dir` says the directory is one it may read.
716
- // Only Claude's adapter uses it; codex's sandboxes read the filesystem
717
- // already, and agy's `--add-dir` is spent on the wiki vault.
718
- knowledgeDir,
719
- // Structured-output flags for the MEDIATED path. Handed to the adapter
720
- // rather than appended here for the same reason `mcp` is: Codex takes its
721
- // prompt as a trailing positional, so a flag after it is in the wrong
722
- // place.
723
- resultSchemaArgs,
724
- // PLAN MODE REPLACES THE POSTURE, never joins it: beside
725
- // `--dangerously-skip-permissions` the bypass wins silently (measured —
726
- // see PLAN_MODE_PERM).
727
- perm: planMode
728
- ? PLAN_MODE_PERM
729
- : profile === 'design'
730
- ? designPermFor(knowledgeDir)
731
- : profile === 'research'
732
- ? researchPerm(knowledgeDir)
733
- : planPerm
734
- ? planPermFor(knowledgeDir)
735
- : readOnly
736
- ? consultPermFor(knowledgeDir)
737
- : wikiPerm ? WIKI_PERM : PERM,
738
- // Handed to the adapter rather than appended here, because WHERE these go
739
- // is a property of the CLI: Codex reads its prompt as a trailing
740
- // positional, so a flag after it is a flag in the wrong place.
741
- // Wiki-vault turns are pure file work and pass neither — no MCP at all.
742
- mcp: planMode ? ['--strict-mcp-config'] : mcpConfig ? ['--mcp-config', mcpConfig] : (mcpArgs ?? []),
743
- // Resuming a SPECIFIC held conversation by its own id (work.mjs, codex
744
- // sessions). Runtimes without a by-id resume ignore it and keep their
745
- // `resume` behavior unchanged.
746
- resumeThreadId,
747
- // agy's by-id resume (work.mjs, antigravity sessions): the conversation
748
- // id learned from the adopt hint or the cwd registry after a turn.
749
- resumeConversationId,
750
- });
751
- // Whatever this machine is signed in with, we use. We do NOT pick.
752
- //
753
- // This used to delete ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN to force
754
- // the subscription path, which was right when the daemon ran on a
755
- // developer's laptop: a key left in their shell would silently bill every
756
- // turn as raw API usage instead of the plan they were already paying for.
757
- // On a machine the project leaves running, an inherited org key is the
758
- // POINT — deleting it is the daemon overriding the credential its operator
759
- // deliberately configured.
760
- //
761
- // Which credential is correct, and whether an account may be shared, is
762
- // between the operator and the vendor. Flowviant does not detect it and does
763
- // not enforce it; it runs the CLI the ordinary way and relays what happens.
764
- const child = spawn(rt.bin, args, {
765
- cwd,
766
- stdio: ['ignore', 'pipe', 'pipe'],
767
- /**
768
- * ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
769
- *
770
- * Everything the CLI spawns inherits this pgid and KEEPS it through
771
- * `nohup` and `setsid` — which is exactly when attribution by descendancy
772
- * fails, because reparenting to init breaks the ppid chain the moment a
773
- * process becomes long-running. `processes.mjs` reads the group; the
774
- * Workbench renders it.
775
- *
776
- * TEARDOWN IS DELIBERATELY UNCHANGED: `shutdownWork` still SIGTERMs this
777
- * CHILD and never the group. Signalling the group would kill the dev
778
- * server the driver started every time the daemon restarts — including
779
- * on an ordinary auto-update, unattended — which is the outcome the
780
- * deleted dev-run supervisor spent a whole registry avoiding. Flowviant
781
- * does not manage those processes; it reports them.
782
- *
783
- * Not `unref`'d: the daemon must still wait on this turn.
784
- */
785
- detached: true,
786
- // Only ADDS to the environment (the worker token, for runtimes that read
787
- // it from there). Never replaces it: the CLI's own credentials live in
788
- // this environment, and handing it a curated one signs it out. The one
789
- // thing it REMOVES is this daemon's machine credential — see `cliEnv`.
790
- env: cliEnv(mcpEnv),
791
- });
792
- onSpawn?.(child);
793
- let out = '';
794
- const pfx = label ? `${label} ` : '';
795
- const emit = (s) => process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
796
-
797
- // A runtime with its own parser is ALWAYS line-parsed — for Codex the JSONL
798
- // stream is the only output there is, so treating it as raw text would print
799
- // event objects at the operator and, worse, hand the sentinel matcher a
800
- // string containing every word the model reasoned about.
801
- const lineParsed = streamJson || Boolean(rt.parse);
802
- if (lineParsed) {
803
- let buf = '';
804
- const appendText = (t) => {
805
- out += t;
806
- };
807
- /** One line of the child's stdout, in whichever dialect it speaks. */
808
- const onLine = (line) => {
809
- if (!rt.parse)
810
- return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage });
811
- const ev = rt.parse(line, cwd);
812
- if (!ev) return;
813
- // The conversation id, when the runtime announces one (codex's
814
- // thread.started). Purely additive: callers that pass no onThreadId —
815
- // every dispatch path — see zero behavior change.
816
- if (ev.threadId) onThreadId?.(ev.threadId);
817
- // The runtime's own token count (codex's `turn.completed`) — the same
818
- // `onUsage` Claude's result event feeds, so a caller charges a turn
819
- // identically whichever CLI ran it.
820
- if (ev.usage) onUsage?.(ev.usage);
821
- // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
822
- // wins at the caller, which is what "the final answer" means.
823
- if (typeof ev.answer === 'string') onAnswer?.(ev.answer);
824
- if (ev.text) appendText(ev.text);
825
- if (ev.activity) {
826
- emit(`${ev.activity.label}\n`);
827
- onActivity?.(ev.activity);
828
- }
829
- };
830
- // DECODED AS A STREAM, never chunk by chunk (2026-09-24, the audit): a
831
- // pipe read can end inside a multi-byte character — the em dash models
832
- // write constantly — and `d.toString()` per chunk turned each half into
833
- // U+FFFD, which JSON.parse accepts and the reply then carried verbatim.
834
- // `setEncoding` holds the partial bytes over to the next chunk.
835
- child.stdout.setEncoding('utf8');
836
- child.stderr.setEncoding('utf8');
837
- child.stdout.on('data', (d) => {
838
- buf += d;
839
- let nl;
840
- while ((nl = buf.indexOf('\n')) >= 0) {
841
- const line = buf.slice(0, nl);
842
- buf = buf.slice(nl + 1);
843
- if (line.trim()) onLine(line);
844
- }
845
- });
846
- // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
847
- child.stderr.on('data', (s) => {
848
- out += s;
849
- emit(s);
850
- });
851
- child.on('error', (e) => {
852
- if (e.code === 'ENOENT') {
853
- // A MISSING CLI FAILS THE TURN, NOT THE DAEMON. This called
854
- // process.exit(1), which was defensible while `claude` was the only
855
- // runtime and preflight refused to start without it — the process
856
- // could not reach here. Both halves of that are gone: preflight is now
857
- // fatal only when NOTHING is drivable, so a Codex-only machine starts
858
- // legitimately, and the wiki/plan-check/consult turns still ask for
859
- // Claude by default. On such a machine the first wiki sweep would have
860
- // killed the whole daemon, taking every in-flight build with it,
861
- // because one background job could not find one binary.
862
- console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
863
- resolve('');
864
- return;
865
- }
866
- console.error(e);
867
- resolve(out);
868
- });
869
- child.on('close', () => {
870
- if (buf.trim()) onLine(buf);
871
- resolve(out);
872
- });
873
- return;
874
- }
875
-
876
- const onChunk = (s) => {
877
- out += s;
878
- emit(s);
879
- };
880
- // Stream-decoded for the same reason as the line-parsed path above.
881
- child.stdout.setEncoding('utf8');
882
- child.stderr.setEncoding('utf8');
883
- child.stdout.on('data', onChunk);
884
- child.stderr.on('data', onChunk);
885
- child.on('error', (e) => {
886
- if (e.code === 'ENOENT') {
887
- // A MISSING CLI FAILS THE TURN, NOT THE DAEMON — same fix as the
888
- // line-parsed path above; this raw-output duplicate used to
889
- // process.exit(1) and take every in-flight worker down with it.
890
- console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
891
- resolve('');
892
- return;
893
- }
894
- console.error(e);
895
- resolve(out);
896
- });
897
- child.on('close', () => resolve(out));
898
- });
899
- }