flowviant 0.102.2 → 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 (141) 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 +184 -2396
  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 +34 -3
  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/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -7,8 +7,10 @@
7
7
  * and a set of sentinel words the turn loop reads out of stdout. A second CLI is
8
8
  * not a different binary name — it is a different answer to each of those.
9
9
  *
10
- * So each runtime declares its own answers here, and everything else in the
11
- * daemon asks this module rather than knowing them.
10
+ * So each runtime declares its own answers — one row per vendor, in
11
+ * runtimeClaude.mjs, runtimeCodex.mjs and runtimeAntigravity.mjs, assembled
12
+ * into RUNTIMES here — and everything else in the daemon asks this module
13
+ * rather than knowing them.
12
14
  *
13
15
  * WHAT A RUNTIME MUST BE ABLE TO DO to build a Flowviant task at all:
14
16
  * 1. run headless from one prompt and exit,
@@ -19,7 +21,7 @@
19
21
  * 4. emit machine-readable progress, or the thread goes silent for the length
20
22
  * of a build.
21
23
  * Claude Code and Codex both do all four. Antigravity does 1, 3 and 4 and is
22
- * declared below with the exact reason it cannot yet do 2.
24
+ * declared in runtimeAntigravity.mjs with the exact reason it cannot yet do 2.
23
25
  *
24
26
  * BUT ONLY A BUILD NEEDS ALL FOUR, and that qualifier was missing long enough to
25
27
  * cost Antigravity every capability it has. Requirement 2 is the CONTROL PLANE —
@@ -32,482 +34,25 @@
32
34
  *
33
35
  * WHAT THIS MODULE DELIBERATELY DOES NOT DO: pick. Which runtime runs a task is
34
36
  * decided in the app, by @mentioning it (CLAUDE.md: the @mention is the only
35
- * dispatch), and arrives on the brief. Detection here answers "what does this
36
- * machine HAVE" — activity, never capacity, and never a default we invented.
37
- */
38
-
39
- import { execFileSync, spawn } from 'node:child_process';
40
- import { existsSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
41
- import { homedir, tmpdir } from 'node:os';
42
- import { join } from 'node:path';
43
- import { SAFE, MODEL, USER_AGENT } from './config.mjs';
44
-
45
- /** Truncate for a one-line activity label. */
46
- const oneLine = (s, n = 140) =>
47
- String(s ?? '').replace(/\s+/g, ' ').trim().slice(0, n);
48
-
49
- /**
50
- * `label` IS THE READOUT; `full` IS THE RELAY (2026-09-16) — the convention
51
- * every prose activity in this daemon follows, stated once here because three
52
- * parsers produce one and three consumers read them.
53
- *
54
- * `label` is one collapsed, clamped line, because its first two consumers are a
55
- * terminal console and a one-line pulse that is overwritten every two seconds,
56
- * and neither can show a paragraph. The turn TRACE is a third consumer with the
57
- * opposite need: it is scrollback, and clipping a sentence at 140 characters
58
- * there is the product summarizing its own agent.
59
- *
60
- * So a prose activity may ALSO carry `full`: the text EXACTLY as the CLI
61
- * emitted it, uncollapsed and unclipped. Nothing derives it and nothing but the
62
- * trace reads it — `label` is untouched, so the console and the pulse are
63
- * byte-identical to before. Absent means there was no fuller text than the
64
- * label (a tool line, a bare thinking marker), and the trace falls back to the
65
- * label — which is what every pre-0.87.0 daemon does for everything.
66
- *
67
- * THE MARKER BELOW IS THE label-ONLY CASE, and one definition with four
68
- * readers: claude.mjs writes it, this file's codex parser falls back to it, and
69
- * TWO collapses key on it — `trace.mjs` for the turn trace, `fleet.mjs` for the
70
- * wiki sweep's feed. A collapse keyed on a string typed out four times would
71
- * silently stop collapsing the first time somebody reworded one of them, and a
72
- * collapse that stops collapsing fails no test — it just fills a feed.
73
- *
74
- * It exists at all because the CLIs do not hand over the thinking itself:
75
- * measured 2026-09-16 across three real transcripts, 93 thinking blocks, every
76
- * one of them empty (signature only), and a live probe with MAX_THINKING_TOKENS
77
- * and `--include-partial-messages` returned an empty `thinking_delta` and a
78
- * zero-length complete block. So the marker is the honest whole of what is
79
- * known — "it is reasoning, not hung" — and the day text does arrive it rides
80
- * `full` and this marker is simply not used.
81
- */
82
- export const THINK_MARKER = 'thinking…';
83
-
84
- const shortPath = (p, cwd) => {
85
- const s = String(p ?? '');
86
- return cwd && s.startsWith(cwd) ? s.slice(cwd.length).replace(/^\//, '') : s;
87
- };
88
-
89
- // ── Claude Code ────────────────────────────────────────────────────────────
90
-
91
- /**
92
- * Tool-call → one line of activity. Claude's tool names, unchanged from when
93
- * this lived in claude.mjs; `kind` is the daemon's own vocabulary and every
94
- * runtime's parser must speak it (`read` is what the wiki file counter counts,
95
- * `write` carries `path` so distinct pages can be counted).
96
- */
97
- export function humanizeClaudeTool(name, input = {}, cwd = '') {
98
- switch (name) {
99
- case 'Read':
100
- return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
101
- case 'Write':
102
- case 'Edit': {
103
- const p = String(input.file_path ?? '');
104
- const tail = p.split('/').slice(-2).join('/');
105
- return { kind: 'write', path: p, label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
106
- }
107
- case 'Grep':
108
- return {
109
- kind: 'search',
110
- label: `grep ${JSON.stringify(input.pattern ?? '')}${input.path ? ` in ${shortPath(input.path, cwd)}` : ''}`,
111
- };
112
- case 'Glob':
113
- return { kind: 'glob', label: `glob ${input.pattern ?? ''}` };
114
- case 'LS':
115
- return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
116
- case 'Bash':
117
- // `command` rides beside the display label, VERBATIM (capped): the label
118
- // is a 60-char readout for a console and the rail, and truncation is
119
- // fine there — but the admin's command audit exists to answer "what
120
- // actually ran on our box", and an ellipsis is exactly where the part
121
- // that matters would hide.
122
- return {
123
- kind: 'bash',
124
- command: String(input.command ?? '').slice(0, 2000),
125
- label: `$ ${oneLine(input.command, 60)}`,
126
- };
127
- case 'TodoWrite': {
128
- // The CLI's own todo list — the plan the transcript's PLAN card renders.
129
- // The label narrates the change ("plan: 2 of 5 — cut over /api/session");
130
- // the structured items ride through toolEventOf below.
131
- const todos = Array.isArray(input.todos) ? input.todos : [];
132
- if (todos.length === 0) return null;
133
- const done = todos.filter((t) => t?.status === 'completed').length;
134
- const active = todos.find((t) => t?.status === 'in_progress');
135
- return {
136
- kind: 'plan',
137
- label: `plan: ${done} of ${todos.length}${active?.content ? ` — ${oneLine(active.content, 60)}` : ''}`,
138
- };
139
- }
140
- default:
141
- return null; // other tools: silent
142
- }
143
- }
144
-
145
- /** Count '\n' without materializing a split — a Write's content can be a
146
- * multi-MB generated file, and `split('\n')` re-allocates all of it as line
147
- * strings on the synchronous stream path the narrator and wake socket share.
148
- * Numbers cannot leak, so counting runs on the RAW string. */
149
- export function countLines(s) {
150
- const str = String(s ?? '');
151
- if (!str) return 0;
152
- let n = 1;
153
- let i = -1;
154
- while ((i = str.indexOf('\n', i + 1)) !== -1) n++;
155
- return n;
156
- }
157
-
158
- /** How much raw string the scrubber sees before any cap is applied. A secret
159
- * can only surface in the first ~capped chars of a field; giving the scrub a
160
- * window this much larger means a secret would have to be longer than the
161
- * window minus the cap to straddle out of it — no real credential is. */
162
- const SCRUB_WINDOW = 8_192;
163
-
164
- /**
165
- * Tool-call → ONE STRUCTURED EVENT for the transcript's tool cards — the
166
- * relay's durable form, where `humanizeClaudeTool` above is its one-line live
167
- * form. Same source (the CLI's own tool_use input), zero inference: every
168
- * field is something the CLI emitted, and a tool this doesn't know renders as
169
- * nothing rather than as a guess.
170
- *
171
- * Wire vocabulary (compact keys — this rides a 1.5s-throttled POST):
172
- * t: read|edit|write|grep|glob|bash|task|plan
173
- * p: path (worktree-relative) q: pattern/description c: command
174
- * a/d: line counts added/deleted (from the input's own strings)
175
- * dl: a few "-/+" prefixed preview lines of an Edit
176
- * items: the plan's todos, x = text, s = done|active|open
177
- *
178
- * SCRUB BEFORE CAP — the order is load-bearing (review, 2026-09-01). The
179
- * caller passes its `envScrub`; every string is scrubbed over a bounded
180
- * window FIRST and capped LAST, because the reverse order had two failures:
181
- * a secret straddling the cap boundary was cut into a prefix the
182
- * exact-substring scrub could no longer match (a partial credential on the
183
- * wire), and a scrub REPLACEMENT that grew a string past the cap tripped the
184
- * server's field limits. `scrub` defaults to identity so this stays testable
185
- * without a vault.
186
- */
187
- export function toolEventOf(name, input = {}, cwd = '', scrub = (s) => s) {
188
- // Window → scrub → cap. The window bounds what a multi-MB input costs; the
189
- // cap is applied AFTER the scrub so a replacement cannot overflow it.
190
- const clean = (v, cap) => scrub(String(v ?? '').slice(0, SCRUB_WINDOW)).slice(0, cap);
191
- const rel = (p) => clean(shortPath(p, cwd), 300);
192
- // The first k lines of a side, from a scrubbed bounded prefix — never a
193
- // full split of the raw string.
194
- const firstLines = (s, k) =>
195
- scrub(String(s ?? '').slice(0, SCRUB_WINDOW)).split('\n', k).slice(0, k);
196
- switch (name) {
197
- case 'Read':
198
- return { t: 'read', p: rel(input.file_path) };
199
- case 'Write':
200
- return { t: 'write', p: rel(input.file_path), a: countLines(input.content) };
201
- case 'Edit': {
202
- const oldS = String(input.old_string ?? '');
203
- const newS = String(input.new_string ?? '');
204
- // A MINI-DIFF, not the diff: the first lines of each side, enough to
205
- // recognise the change at a glance. The real diff lives in git.
206
- const dl = [
207
- ...firstLines(oldS, 2).map((l) => `- ${l}`),
208
- ...firstLines(newS, 3).map((l) => `+ ${l}`),
209
- ].map((l) => l.slice(0, 160));
210
- return { t: 'edit', p: rel(input.file_path), a: countLines(newS), d: countLines(oldS), dl };
211
- }
212
- case 'Grep':
213
- return {
214
- t: 'grep',
215
- q: clean(input.pattern, 200),
216
- ...(input.path ? { p: rel(input.path) } : {}),
217
- };
218
- case 'Glob':
219
- return { t: 'glob', q: clean(input.pattern, 200) };
220
- case 'Bash':
221
- return { t: 'bash', c: clean(input.command, 200) };
222
- case 'Task':
223
- return { t: 'task', q: clean(input.description, 200) };
224
- case 'TodoWrite': {
225
- const todos = Array.isArray(input.todos) ? input.todos.slice(0, 20) : [];
226
- const items = todos
227
- .map((td) => ({
228
- x: clean(td?.content, 120),
229
- s: td?.status === 'completed' ? 'done' : td?.status === 'in_progress' ? 'active' : 'open',
230
- }))
231
- .filter((i) => i.x);
232
- return items.length ? { t: 'plan', items } : null;
233
- }
234
- default:
235
- return null;
236
- }
237
- }
238
-
239
- /**
240
- * THE ACTIVITY KINDS `toolEventOf` ALSO ANSWERS — the dedupe rule, stated once.
241
- *
242
- * A Claude tool call goes down BOTH paths in claude.mjs: `humanizeClaudeTool`
243
- * makes a one-line activity and `toolEventOf` makes a structured event, from
244
- * the same `tool_use`. A consumer taking both — the agent turn's trace — would
245
- * otherwise render every read twice, once as a sentence and once as a card.
246
- *
247
- * It is the KINDS the structured builder answers, not every tool kind: `LS`
248
- * produces a `list` activity and no tool event, so dropping `list` would delete
249
- * it from the trace entirely. And it is only safe to apply on the runtimes
250
- * whose stream reaches `onToolEvent` at all — codex and agy have their own
251
- * parsers, never call it, and would go silent. Both halves are pinned in
252
- * trace.test.mjs, because the two functions are edited independently and the
253
- * failure is invisible: a dropped prose line looks exactly like a quiet turn.
254
- */
255
- export const CLAUDE_TOOL_PROSE_KINDS = new Set([
256
- 'read',
257
- 'write',
258
- 'search',
259
- 'glob',
260
- 'bash',
261
- 'plan',
262
- ]);
263
-
264
- // ── Codex ──────────────────────────────────────────────────────────────────
265
-
266
- /**
267
- * Codex item → activity, in the daemon's vocabulary.
268
- *
269
- * The event names and item types below are not guesses: they were read off the
270
- * shipped 0.147 binary (`ThreadStarted`/`TurnCompleted`/`ItemCompleted`, items
271
- * `agent_message` / `reasoning` / `command_execution` / `file_change` /
272
- * `mcp_tool_call` / `web_search` / `todo_list`). Unknown item types return null
273
- * and stay silent rather than printing a raw JSON blob into someone's console.
274
- */
275
- function humanizeCodexItem(item = {}, cwd = '') {
276
- switch (item.item_type ?? item.type) {
277
- // `full` rides beside the label wherever the item carries more than the
278
- // label can hold — see the `label`/`full` note above. Codex is the runtime
279
- // that actually SENDS reasoning text today, so its `think` is the one place
280
- // in this daemon where a real thought reaches the trace whole.
281
- case 'agent_message': {
282
- const text = String(item.text ?? item.message ?? '');
283
- return { kind: 'say', label: oneLine(text), ...(text ? { full: text } : {}) };
284
- }
285
- case 'reasoning': {
286
- const text = String(item.text ?? '');
287
- return {
288
- kind: 'think',
289
- label: oneLine(text) || THINK_MARKER,
290
- ...(text.trim() ? { full: text } : {}),
291
- };
292
- }
293
- case 'command_execution':
294
- return {
295
- kind: 'bash',
296
- command: String(item.command ?? '').slice(0, 2000),
297
- label: `$ ${oneLine(item.command, 60)}`,
298
- };
299
- case 'file_change': {
300
- // `changes` is a list of touched paths; the daemon counts distinct files,
301
- // so emit one activity per path rather than one for the batch.
302
- const first = (item.changes ?? [])[0] ?? {};
303
- const p = String(first.path ?? '');
304
- const tail = p.split('/').slice(-2).join('/');
305
- const verb = first.kind === 'add' ? '+' : first.kind === 'delete' ? '-' : '~';
306
- return { kind: 'write', path: p, label: `${verb} ${tail || 'file'}` };
307
- }
308
- case 'mcp_tool_call':
309
- return { kind: 'tool', label: `${item.server ?? 'mcp'}.${item.tool ?? ''}` };
310
- case 'web_search':
311
- return { kind: 'search', label: `search ${oneLine(item.query, 60)}` };
312
- default:
313
- return null;
314
- }
315
- }
316
-
317
- /**
318
- * Codex `--json` emits JSONL of ThreadEvents. Returns `{ activity, text,
319
- * threadId }` — `text` accumulates the agent's own words, because the turn
320
- * loop reads its sentinels (NOTHING / BLOCKED:<id> / DONE) out of exactly
321
- * that; `threadId` surfaces once, off the lifecycle event, for callers that
322
- * need to resume THIS conversation later (runTurn's onThreadId).
323
- */
324
- function parseCodexLine(line, cwd) {
325
- let ev;
326
- try {
327
- ev = JSON.parse(line);
328
- } catch {
329
- return null; // not every line is JSON (warnings go to stderr, but be safe)
330
- }
331
- switch (ev.type) {
332
- // The conversation's own id, announced before any item (`ThreadStarted` on
333
- // the shipped 0.147 binary, like the measured events below). Surfaced so a
334
- // SESSION turn can resume this exact thread next time: `resume --last` is
335
- // a machine-global guess, and on a box running two tabs — or a tab plus a
336
- // dispatch — it resumes someone else's conversation. No activity and no
337
- // text: nothing here is the model speaking.
338
- case 'thread.started':
339
- return { activity: null, text: '', threadId: String(ev.thread_id ?? '') || null };
340
- case 'item.completed': {
341
- const item = ev.item ?? {};
342
- const activity = humanizeCodexItem(item, cwd);
343
- // Only the agent's MESSAGES are sentinel-bearing text. Reasoning is not:
344
- // a model that muses "I could output NOTHING here" must not end the turn.
345
- const isMessage = (item.item_type ?? item.type) === 'agent_message';
346
- const text = isMessage ? `${item.text ?? item.message ?? ''}\n` : '';
347
- // `answer` is the SAME message, surfaced alone: `text` accumulates every
348
- // message plus every error and stderr line, so a caller that needs "what
349
- // the agent said LAST" (the agent lane's final JSON object) cannot recover
350
- // it from `out`. Claude's stream has this for free under
351
- // `answerFromResult`; codex needed it said.
352
- return { activity, text, ...(isMessage ? { answer: String(item.text ?? item.message ?? '') } : {}) };
353
- }
354
- // THE TURN'S OWN TOKEN COUNT (2026-09-24). `codex exec --json` closes a
355
- // turn with `turn.completed` carrying `usage` — the codex twin of Claude's
356
- // `result.usage`, and until now dropped on the floor, so a codex agent
357
- // reported no spend at all. Mapped onto the daemon's four counters:
358
- // OpenAI's `input_tokens` INCLUDES the cached part, Claude's excludes it,
359
- // so the cached share is subtracted out rather than counted twice. Codex
360
- // has no cache-creation figure; zero is what it reported, not a guess.
361
- // Output already includes reasoning tokens on OpenAI's side.
362
- case 'turn.completed': {
363
- const u = ev.usage;
364
- if (!u || typeof u !== 'object') return null;
365
- const n = (v) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Math.floor(Number(v)) : 0);
366
- const cached = n(u.cached_input_tokens);
367
- return {
368
- activity: null,
369
- text: '',
370
- // `usageFromResult`'s shape (claude.mjs), so every consumer of
371
- // `onUsage` reads one vocabulary whichever CLI counted it.
372
- usage: {
373
- input: Math.max(0, n(u.input_tokens) - cached),
374
- output: n(u.output_tokens),
375
- cacheCreate: 0,
376
- cacheRead: cached,
377
- },
378
- };
379
- }
380
- case 'turn.failed': {
381
- // An error is prose too, and the sentence that explains a failed turn is
382
- // routinely longer than a 140-char label — a stack-shaped message loses
383
- // its cause exactly where somebody is reading to find it.
384
- //
385
- // THE LABEL KEEPS ITS OWN `??`, not `msg || …`, and the difference is one
386
- // input: an EXPLICITLY EMPTY message. `??` lets `''` through as an empty
387
- // label, which every consumer swallows (`if (!label)`); `||` would print
388
- // "turn failed" and fire a pulse where this daemon printed nothing. That
389
- // may well be the better readout, but it is a change to the console and
390
- // the pulse, and `full` shipped on the promise that neither moved — so it
391
- // is argued for on its own day, not smuggled in beside a relay field.
392
- const msg = String(ev.error?.message ?? '');
393
- return {
394
- activity: {
395
- kind: 'error',
396
- label: oneLine(ev.error?.message ?? 'turn failed'),
397
- ...(msg.trim() ? { full: msg } : {}),
398
- },
399
- // THE FAILURE'S OWN WORDS REACH `text` (2026-09-24), the rule the bare
400
- // `error` arm below already keeps. This returned '' — so a codex turn
401
- // that failed on a usage limit handed the limit matcher nothing, the
402
- // agent landed in Stuck as "produced no output", and every other codex
403
- // agent kept spending into the same wall.
404
- text: msg.trim() ? `${msg}\n` : '',
405
- };
406
- }
407
- // A bare `error` event — the shape an auth failure arrives in ("401
408
- // Unauthorized: Missing bearer…", observed against 0.147.0 with no
409
- // credentials). It used to fall through to `default` and be dropped, which
410
- // meant a signed-out Codex produced an EMPTY turn: no sentinel, so the
411
- // driver nudged twice and reported `stalled`, and the thread said the agent
412
- // gave up rather than that the CLI is not signed in. The message goes into
413
- // `text` so it reaches the operator's console AND the usage-limit
414
- // classifier, which reads exactly this stream.
415
- case 'error': {
416
- // Same split as the arm above: `??` for the label (untouched behaviour),
417
- // `msg` for the relay field only.
418
- const msg = String(ev.message ?? '');
419
- return {
420
- activity: {
421
- kind: 'error',
422
- label: oneLine(ev.message ?? 'error'),
423
- ...(msg.trim() ? { full: msg } : {}),
424
- },
425
- text: `${ev.message ?? ''}\n`,
426
- };
427
- }
428
- default:
429
- return null; // turn.started / item.started / item.updated
430
- }
431
- }
432
-
433
- // ── Antigravity ────────────────────────────────────────────────────────────
434
-
435
- /**
436
- * `agy --output-format stream-json` emits `{event: init|step_update|result}`.
437
- * Tool names read off a live 1.1.12 session rather than guessed.
438
- */
439
- function humanizeAgyTool(name, p = {}, cwd = '') {
440
- const path = p.TargetFile ?? p.AbsolutePath ?? p.DirectoryPath ?? p.File ?? '';
441
- const tail = String(path).split('/').slice(-2).join('/');
442
- switch (name) {
443
- case 'view_file':
444
- case 'read_resource':
445
- return { kind: 'read', label: `read ${shortPath(path, cwd)}` };
446
- case 'write_to_file':
447
- case 'replace_file_content':
448
- case 'multi_replace_file_content':
449
- return {
450
- kind: 'write',
451
- path: String(path),
452
- label: `${name === 'write_to_file' ? '+ page' : '~ page'} ${tail || 'file'}`,
453
- };
454
- case 'grep_search':
455
- return { kind: 'search', label: `grep ${oneLine(p.Query ?? p.SearchTerm ?? '', 60)}` };
456
- case 'find_by_name':
457
- return { kind: 'glob', label: `find ${oneLine(p.Pattern ?? '', 60)}` };
458
- case 'list_dir':
459
- return { kind: 'list', label: `ls ${shortPath(path, cwd)}` };
460
- case 'run_command':
461
- return {
462
- kind: 'bash',
463
- command: String(p.CommandLine ?? '').slice(0, 2000),
464
- label: `$ ${oneLine(p.CommandLine, 60)}`,
465
- };
466
- case 'call_mcp_tool':
467
- return { kind: 'tool', label: `mcp.${p.ToolName ?? ''}` };
468
- default:
469
- return null;
470
- }
471
- }
472
-
473
- function parseAgyLine(line, cwd) {
474
- let ev;
475
- try {
476
- ev = JSON.parse(line);
477
- } catch {
478
- return null;
479
- }
480
- if (ev.event === 'step_update') {
481
- const su = ev.step_update ?? {};
482
- const ti = su.tool_info;
483
- if (!ti) return null;
484
- const err = ti.error?.message;
485
- // Same rule as codex's error lines: the label is the console's, `full` is
486
- // the trace's, and a tool error's message is exactly the kind of sentence a
487
- // 140-char clamp cuts the cause out of.
488
- if (err)
489
- return {
490
- activity: { kind: 'error', label: oneLine(err), full: String(err) },
491
- text: '',
492
- };
493
- // Each tool is reported twice — once ACTIVE, once DONE — so only the
494
- // terminal state emits, otherwise every action appears in the thread twice.
495
- if (su.state && su.state !== 'DONE') return null;
496
- return { activity: humanizeAgyTool(ti.name, ti.parameters ?? {}, cwd), text: '' };
497
- }
498
- if (ev.event === 'result') {
499
- const r = ev.result ?? {};
500
- // The final answer is the ONLY sentinel-bearing text: agy has no incremental
501
- // assistant-message event, so a turn's whole verdict arrives here at once.
502
- return {
503
- activity: r.error
504
- ? { kind: 'error', label: oneLine(r.error), full: String(r.error) }
505
- : null,
506
- text: `${r.response ?? ''}${r.error ? `\n${r.error}` : ''}\n`,
507
- };
508
- }
509
- return null;
510
- }
37
+ * dispatch), and arrives on the brief. Detection (runtimeDetection.mjs) answers
38
+ * "what does this machine HAVE" — activity, never capacity, and never a
39
+ * default we invented.
40
+ *
41
+ * WHAT MOVED OUT (2026-09-26, SOLID F045), each to the module named for its
42
+ * own reason to change: what a CLI's stream MEANT → runtimeEvents.mjs; which
43
+ * CLIs this box has, and which runs an unmentioned job → runtimeDetection.mjs;
44
+ * the skills and MCP servers a CLI reported, and the one-shot probe that
45
+ * learns them → runtimeCapabilities.mjs; and each vendor's own row (argv,
46
+ * profiles, efforts, MCP mint) → runtime{Claude,Codex,Antigravity}.mjs, since
47
+ * a vendor renaming a flag is no reason to open another vendor's builder. This
48
+ * file keeps the registry's assembly, the lookup and the per-profile
49
+ * drivability rule.
50
+ */
51
+
52
+ import { SAFE } from './config.mjs';
53
+ import { CLAUDE_RUNTIME } from './runtimeClaude.mjs';
54
+ import { CODEX_RUNTIME } from './runtimeCodex.mjs';
55
+ import { ANTIGRAVITY_RUNTIME } from './runtimeAntigravity.mjs';
511
56
 
512
57
  // ── The registry ───────────────────────────────────────────────────────────
513
58
 
@@ -526,524 +71,14 @@ function parseAgyLine(line, cwd) {
526
71
  * read the bearer token from an ENV VAR (`bearer_token_env_var`), so its token
527
72
  * never touches disk at all — strictly better, and the reason Codex was the
528
73
  * first second-runtime rather than the easiest-looking one.
74
+ *
75
+ * Each row's `mcp` is that mint (`claudeMcp` in runtimeClaude.mjs, `codexMcp`
76
+ * in runtimeCodex.mjs); Antigravity's is null, and its row says why.
529
77
  */
530
- function claudeMcp(token, mcpUrl) {
531
- const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
532
- const path = join(dir, 'mcp.json');
533
- writeFileSync(
534
- path,
535
- JSON.stringify({
536
- mcpServers: {
537
- flowviant: {
538
- type: 'http',
539
- url: mcpUrl,
540
- headers: { Authorization: `Bearer ${token}`, 'User-Agent': USER_AGENT },
541
- },
542
- },
543
- }),
544
- { mode: 0o600 }
545
- );
546
- return { dir, args: ['--mcp-config', path], env: {} };
547
- }
548
-
549
- function codexMcp(token, mcpUrl) {
550
- return {
551
- dir: null, // nothing written — the token rides in the environment
552
- args: [
553
- '-c',
554
- `mcp_servers.flowviant.url="${mcpUrl}"`,
555
- '-c',
556
- 'mcp_servers.flowviant.bearer_token_env_var="FLOWVIANT_MCP_TOKEN"',
557
- ],
558
- env: { FLOWVIANT_MCP_TOKEN: token },
559
- };
560
- }
561
-
562
78
  export const RUNTIMES = {
563
- claude: {
564
- id: 'claude',
565
- label: 'Claude Code',
566
- vendor: 'Anthropic',
567
- bin: 'claude',
568
- install: 'npm i -g @anthropic-ai/claude-code',
569
- login: 'claude',
570
- /** The Agent-SDK live session (persistent, injectable mid-task) is Claude
571
- * only — it is an Anthropic SDK, not a CLI contract. Everything else runs
572
- * the subprocess path. */
573
- live: true,
574
- /**
575
- * Every profile, because every profile is DEFINED in its vocabulary: the
576
- * four `--allowedTools` lists in claude.mjs are what "build", "wiki",
577
- * "consult" and "plan" currently mean. That is a statement about where the
578
- * contract was written, not a claim that only Claude could ever satisfy it.
579
- */
580
- // `design` and `research` (0.97.0) are Claude's alone: each is an
581
- // `--allowedTools` list with a PATH-SCOPED write (claude.mjs), and neither
582
- // codex's sandbox modes nor agy's flags were measured to express "write
583
- // only this directory". A runtime that does not declare them is refused
584
- // such a card before spawn, in words — never handed a build turn instead.
585
- profiles: ['build', 'wiki', 'consult', 'plan', 'design', 'research'],
586
- mcp: claudeMcp,
587
- /**
588
- * Claude takes the operating contract as a real system prompt, which is the
589
- * strongest form of it available anywhere: `--append-system-prompt` sits
590
- * above the conversation rather than inside it.
591
- */
592
- args({ prompt, system, model, effort, resume, streamJson, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, agentTools }) {
593
- const a = [];
594
- // THREE ANSWERS TO ONE QUESTION — "what conversation is this?" — and they
595
- // are mutually exclusive, never combined.
596
- //
597
- // ADOPTION: `--resume <id> --fork-session` finds the session globally
598
- // (any cwd), carries its full context, and writes the FORK natively into
599
- // THIS cwd's own store, leaving the original transcript untouched
600
- // (measured on 2.1.234).
601
- //
602
- // BY ID: the conversation THIS TAB spoke under last time, learned from
603
- // the CLI's own `system.init` event and pinned per session. It exists
604
- // because `--continue` is CWD-KEYED, and a directory stopped being one
605
- // tab the day tabs moved into their driver's project folder — two tabs
606
- // sharing a directory both said `--continue` and both resumed whichever
607
- // conversation spoke most recently there, so tab B inherited tab A's
608
- // entire context and every turn after that ping-ponged between them.
609
- // Exactly the failure codex's own note warns about for `resume --last`,
610
- // arriving for Claude by a different route. An id is unambiguous wherever
611
- // the tab is standing.
612
- //
613
- // `--continue` is the LAST resort, and only where nothing better is
614
- // known.
615
- if (adoptResumeId) a.push('--resume', adoptResumeId, '--fork-session');
616
- else if (resumeThreadId) a.push('--resume', resumeThreadId);
617
- else if (resume) a.push('--continue');
618
- a.push('-p', prompt, '--append-system-prompt', system);
619
- if (agentTools) {
620
- // Keep personal settings, but never load the agent-editable project's
621
- // settings, hooks, skills or CLAUDE.md discovery. A resumed agent must
622
- // render the CURRENT base snapshot, not the first turn's prompt cache.
623
- a.push('--setting-sources', 'user', '--system-prompt-snapshot', 'off');
624
- if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
625
- }
626
- // These fenced postures have no project MCP, even if Claude discovers a
627
- // .mcp.json in the agent's worktree. Build gets its reviewed base config
628
- // explicitly from the caller; the plan profile keeps its own principal.
629
- if (['consult', 'wiki', 'design', 'research'].includes(profile)) a.push('--strict-mcp-config');
630
- a.push(...mcp, ...resultSchemaArgs);
631
- a.push('--model', model || MODEL);
632
- if (effort) a.push('--effort', effort);
633
- if (streamJson) a.push('--output-format', 'stream-json', '--verbose');
634
- // The knowledge library (0.94.0) sits in the checkout, outside a
635
- // worktree's cwd: named as a readable directory so a curated profile
636
- // does not refuse the path the prompt just handed it. Before `perm`,
637
- // because `--allowedTools` is variadic and would swallow the flag.
638
- if (knowledgeDir) a.push('--add-dir', knowledgeDir);
639
- a.push(...perm);
640
- return a;
641
- },
642
- parse: null, // claude.mjs owns its own stream parser (unchanged)
643
- },
644
-
645
- codex: {
646
- id: 'codex',
647
- label: 'Codex',
648
- vendor: 'OpenAI',
649
- bin: 'codex',
650
- install: 'npm i -g @openai/codex',
651
- login: 'codex login',
652
- live: false,
653
- /**
654
- * BUILD AND CONSULT. See `args()` below for how consult is expressed — the
655
- * short version is that Codex keeps the promise at the kernel rather than in
656
- * a verb allowlist, and for a consult's actual threat (exfiltration driven
657
- * by an injected question) that is sufficient and arguably stronger.
658
- *
659
- * ALL THREE, and `wiki` is the one worth pausing on: Codex expresses it
660
- * MORE strictly than Claude does. A cartographer must read the repo and
661
- * write only the vault; Claude's WIKI_PERM cannot path-scope Write and says
662
- * so, leaning on the worktree reset as a backstop. Codex's permission
663
- * profiles enforce the same rule in the kernel — measured: repo readable,
664
- * vault writable, repo writes refused, network off.
665
- *
666
- * Enforcement was verified on Linux (bubblewrap + seccomp). macOS Seatbelt
667
- * and Windows are UNTESTED; if this daemon starts running there, re-verify
668
- * before trusting the consult posture on those platforms.
669
- */
670
- profiles: ['build', 'consult', 'wiki', 'plan'],
671
- mcp: codexMcp,
672
- /**
673
- * Codex has NO system-prompt flag. The contract therefore rides inside the
674
- * prompt, fenced and placed first, and this is a genuine weakening worth
675
- * stating plainly: a system prompt is a rule, and a prompt preamble is a
676
- * strong suggestion the model may drift from over a long turn. It is the
677
- * best available; AGENTS.md was the alternative and is worse, because it is
678
- * a FILE IN THE WORKTREE — one `git add -A` from being committed into the
679
- * user's repository, which is not a risk worth taking for a slightly
680
- * stickier instruction.
681
- *
682
- * Agent turns additionally pass reviewed base instructions as a developer
683
- * config override when the personal config has no developer instruction;
684
- * the prompt always carries them too. Workbench tabs keep their normal
685
- * checkout discovery.
686
- *
687
- * `--skip-git-repo-check` is deliberately NOT passed: a task always builds
688
- * in a git worktree, and if it somehow is not one, failing loudly beats
689
- * silently editing files nobody can diff.
690
- *
691
- * THE PROMPT IS LAST, and that is load-bearing rather than tidy. Codex takes
692
- * it as a trailing POSITIONAL, so every flag — including the two `-c` MCP
693
- * overrides, which the caller hands in rather than appending — has to be
694
- * placed before it. Appending them after the positional is the kind of argv
695
- * that parses today and stops parsing on some future clap upgrade.
696
- */
697
- args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools }) {
698
- // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
699
- // agy moves) — codex has no adoptable store wired yet. Reaching here
700
- // with an adopt id is a wiring mistake upstream, and it fails loudly on
701
- // purpose: quietly dropping the flag would answer that session's held
702
- // context with a different brain.
703
- if (adoptResumeId) throw new Error("codex has no adoptable terminal store — an adopt id can't reach this builder");
704
- const a = ['exec'];
705
- // BY ID when the caller knows WHICH conversation this is — a Workbench
706
- // tab's held context, captured off thread.started and stored with its
707
- // worktree. `--last` resumes the machine's most recent codex conversation,
708
- // which is only safe on the dispatch path (one lane, one turn at a time,
709
- // in its own worktree); for a session it is a machine-global guess that
710
- // two tabs — or a tab plus a dispatch — would cross-resume.
711
- if (resumeThreadId) a.push('resume', resumeThreadId);
712
- else if (resume) a.push('resume', '--last');
713
- a.push('--json');
714
- if (agentTools) {
715
- a.push('-c', 'project_doc_max_bytes=0');
716
- // A person's existing developer instructions keep their authority.
717
- // The base snapshot is also present in the per-turn prompt preamble.
718
- if (!agentTools.personalDeveloperInstructions)
719
- a.push('-c', `developer_instructions=${JSON.stringify(agentTools.instructions)}`);
720
- }
721
- if (model) a.push('--model', model);
722
- // Effort is a config value on Codex rather than a flag.
723
- if (effort) a.push('-c', `model_reasoning_effort="${effort}"`);
724
-
725
- if (profile === 'consult') {
726
- // A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — and it is a
727
- // different shape from Claude's, which is the whole reason `profile` is
728
- // a promise rather than a flag list.
729
- //
730
- // Claude gets a VERB allowlist: Read, Grep, Glob and a handful of
731
- // read-only Bash forms, with nothing that reaches the network. Codex has
732
- // no such thing — it has no file-read tool at all, so reading the repo
733
- // IS command execution (`cat`, `rg`). Removing the shell leaves the
734
- // model with exactly ["update_plan","request_user_input"], which cannot
735
- // answer a question about a codebase. You get both capabilities or
736
- // neither.
737
- //
738
- // So the promise is kept one layer down instead. `read-only` is
739
- // kernel-enforced (bubblewrap + seccomp on Linux): writes fail, and a
740
- // direct-IP connect fails with "Operation not permitted" — socket() is
741
- // denied, not merely DNS. An injected command still RUNS and still
742
- // cannot take the repository anywhere, which is the threat a consult
743
- // actually has: its prompt is steered by a question any project editor
744
- // can type. Arguably a stronger guarantee than the allowlist, being
745
- // below the agent rather than inside it.
746
- a.push('--sandbox', 'read-only');
747
-
748
- // THE HOLE THE SANDBOX DOES NOT COVER. `web_search` ships in `codex
749
- // exec`'s default tool list even without --search, and it executes
750
- // SERVER-SIDE at OpenAI — no local sandbox touches it. An injected turn
751
- // could pack repo contents into a query and egress them straight past
752
- // everything above. Both spellings, because the two config systems
753
- // disagree about which one is live.
754
- a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
755
-
756
- // Sub-agents would be a second turn whose posture nobody here chose.
757
- a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false');
758
-
759
- // HERMETIC. Without these a user's ~/.codex/config.toml, a project
760
- // `.rules` execpolicy file, or their own MCP servers can widen a posture
761
- // we are asserting on their behalf — silently, and on the one turn whose
762
- // prompt comes from someone else's typing.
763
- a.push('--ignore-user-config', '--ignore-rules');
764
- } else if (profile === 'plan') {
765
- // A PLANNING SESSION. Read-only on the filesystem, exactly like a
766
- // consult — the writes it makes go through the control plane, not
767
- // through this box — so the kernel sandbox is the same one, and for the
768
- // same reason: this turn's prompt is steered by anything a project
769
- // editor can type.
770
- //
771
- // Everything the consult branch above closes stays closed, and the
772
- // reasoning is unchanged, so it is not restated: web_search egresses
773
- // server-side at OpenAI where no local sandbox reaches it, sub-agents
774
- // would be a turn whose posture nobody here chose, and a user's own
775
- // config or MCP servers must not widen a posture we are asserting on
776
- // their behalf.
777
- //
778
- // What differs from a consult is the ONE thing this profile exists for:
779
- // an MCP config IS passed, carrying the plan principal's token. That
780
- // token's whole tool set is the five plan tools — the server refuses
781
- // anything else on it — so the control plane being open here does not
782
- // widen what a hijacked turn could reach beyond the plan it is already
783
- // sitting in.
784
- a.push('--sandbox', 'read-only');
785
- a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
786
- a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false');
787
- a.push('--ignore-user-config', '--ignore-rules');
788
- } else if (profile === 'wiki' && vaultDir) {
789
- // THE CARTOGRAPHER, AND THIS ONE IS STRICTER THAN CLAUDE'S.
790
- //
791
- // A wiki turn reads the whole repo and writes ONLY the vault. Claude
792
- // cannot actually express that: WIKI_PERM hands it Write/Edit and its own
793
- // comment admits "Write/Edit can't be path-scoped here; the worktree
794
- // reset is the backstop" — i.e. the cartographer CAN scribble on the
795
- // checkout and we clean up afterwards.
796
- //
797
- // Codex's permission profiles take a per-path filesystem map, so the
798
- // rule is enforced by the kernel instead of apologised for. Measured:
799
- // repo readable, vault writable, repo writes fail "Read-only file
800
- // system", and curl returns 000 — the network is off, which is the half
801
- // WIKI_PERM was really protecting (its comment: "Command execution is
802
- // the line: it enables network exfil").
803
- //
804
- // The table is set WHOLE rather than as a dotted key, and that is not
805
- // style: `-c permissions.wiki.filesystem."<path>"="write"` splits the
806
- // dotted path on the dots INSIDE the path, and every real vault lives
807
- // under `~/.flowviant/vaults/…`. It fails with "filesystem path must be
808
- // absolute", which reads like a path problem and is a parsing one.
809
- a.push('-c', 'permissions.flowviantwiki.extends=":read-only"');
810
- a.push('-c', `permissions.flowviantwiki.filesystem={"${vaultDir}"="write"}`);
811
- a.push('-P', 'flowviantwiki');
812
- a.push('--ignore-user-config', '--ignore-rules');
813
- } else {
814
- // The daemon's build posture, mapped: SAFE keeps writes inside the
815
- // workspace, the default lets the agent run its own tests and git
816
- // commands. Neither asks a human — there is no human on this end of the
817
- // pipe. Deliberately NOT hermetic: a build is work the user asked for by
818
- // @mentioning this CLI, and their own config is theirs to apply.
819
- a.push('--sandbox', SAFE ? 'workspace-write' : 'danger-full-access');
820
- }
821
-
822
- a.push(...mcp, ...resultSchemaArgs);
823
- a.push(`${system}\n\n---\n\n${prompt}`);
824
- return a;
825
- },
826
- parse: parseCodexLine,
827
- /** `codex exec --output-schema <file>` constrains the FINAL message to a
828
- * JSON Schema. Only needed on the mediated path; the direct path reports
829
- * through MCP tool calls, which are already structured. */
830
- resultSchema: (path) => ['--output-schema', path],
831
- },
832
-
833
- /**
834
- * DECLARED, NOT DRIVABLE — and this is now MEASURED rather than argued.
835
- *
836
- * The reason went wrong twice before it went right, so the evidence is written
837
- * down here in full. First it cited upstream antigravity-cli#60, which is about
838
- * `.antigravitycli/mcp_config.json` — the project-DISCOVERY folder — while
839
- * claiming it was about `.agents/`, the workspace-CUSTOMIZATION folder. Then,
840
- * on reading the docs (antigravity.google/docs/mcp describes `.agents/`, and
841
- * Google's own codelab creates it), this comment swung the other way and said
842
- * the blocker looked wrong. Both were reasoning from paperwork.
843
- *
844
- * THE TEST, run against agy 1.1.12, signed in, in a real git repo:
845
- * the SAME minimal stdio MCP server, declared two ways.
846
- * • workspace `<worktree>/.agents/mcp_config.json` → the server process is
847
- * NEVER SPAWNED. Not connected-and-failed: never launched. The model
848
- * answers "NO_MCP".
849
- * • global `~/.gemini/config/mcp_config.json` → spawns immediately and
850
- * handshakes: server/discover, initialize, notifications/initialized,
851
- * tools/list.
852
- * stdio deliberately, to remove every confound — no bearer token to reject,
853
- * no network, no TLS. The difference is the config LOCATION and nothing else.
854
- *
855
- * AND IT SURVIVES THE TRUST VARIABLE, which was the obvious objection: agy
856
- * keeps a `trustedWorkspaces` list in settings.json, a fresh per-task worktree
857
- * is not on it, and an untrusted folder demonstrably changes behaviour (the
858
- * model stops treating cwd as its workspace and works out of its own scratch
859
- * dir). Adding the worktree to `trustedWorkspaces` and re-running changed
860
- * nothing: the server still never spawned, the model still answered NO_MCP.
861
- * So the workspace config is not trust-gated, it is simply not read.
862
- *
863
- * So Antigravity's MCP config is machine-wide IN PRACTICE, and the original
864
- * blocker's conclusion stands even though its cited reason never did: every
865
- * lane on the box would share one worker token, which is exactly the blast
866
- * radius the per-lane token exists to prevent. That is why this stays
867
- * undrivable, and it is a property of the CLI rather than something we can
868
- * work around from here.
869
- *
870
- * A TRAP FOR WHOEVER RE-TESTS THIS: agy exposes MCP through a single generic
871
- * `call_mcp_tool` dispatcher, so per-server tools NEVER appear in the `init`
872
- * event's tools array even when a server is loaded correctly. Reading that
873
- * array tells you nothing. Watch the server process instead, or ask the model.
874
- *
875
- * FOUR MORE FACTS from the same session, each an independent obstacle:
876
- * 1. AUTH IS INTERACTIVE OAUTH (bubbletea TUI; needs a real /dev/tty), with
877
- * no headless credential path. A signed-out `agy -p` prints a consent URL
878
- * and then SITS until `--print-timeout` (default 5m) before erroring — so
879
- * a lane on a signed-out agy burns five minutes per turn looking like a
880
- * hang. Preflight cannot see it: `--version` succeeds while signed out.
881
- * 2. `--sandbox` IS A BOOLEAN ("terminal restrictions enabled"), not a mode
882
- * selector — but a CONSULT POSTURE IS STILL EXPRESSIBLE, and this was
883
- * recorded backwards here for a while. It does not come from `--sandbox`
884
- * at all; it comes from headless mode's default. Any tool needing a
885
- * permission that cannot be prompted for is AUTO-DENIED:
886
- * "User denied permission to run command: <cmd>"
887
- * "a tool required the 'command' permission that headless mode cannot
888
- * prompt for, so it was auto-denied."
889
- * Observed repeatedly, including for an entirely benign `pwd && ls -la`,
890
- * and a local listener confirmed no egress in any run. Reads (list_dir,
891
- * file reads) work throughout. So: NOT passing
892
- * `--dangerously-skip-permissions` IS the consult posture, and passing it
893
- * is the build posture — both per invocation, both harness-enforced
894
- * rather than model-instructed. `--mode plan` is a separate, WEAKER thing:
895
- * it steers behaviour and blocks workspace writes, but it is not what
896
- * stops command execution.
897
- * RESIDUAL RISK, and it has no fix from here: `permissions.allow` in the
898
- * machine-wide settings.json is inherited, so a user who has allowed
899
- * `command(...)` widens every consult on that box. Codex has
900
- * `--ignore-user-config` for exactly this; agy has no equivalent.
901
- * 3. No `mcp` subcommand; `/mcp` is interactive-only.
902
- * 4. It attempts to INSTALL PLAYWRIGHT at startup (observed failing 404
903
- * against playwright.azureedge.net). A daemon runtime that downloads and
904
- * runs a browser driver is worth knowing before it goes on a machine the
905
- * project leaves running.
906
- *
907
- * There is no npm package. `antigravity-cli` (0.0.1, "placeholder") and `agy`
908
- * (0.0.0, empty) on npm are SQUATS by unrelated accounts; the real channel is
909
- * the install script at antigravity.google, which is why `install` below says
910
- * to see the docs rather than naming an npm command.
911
- *
912
- * WHAT WOULD UNBLOCK IT: a per-invocation MCP flag or env var, or workspace
913
- * configs actually being honoured. Re-test with the two-location stdio probe
914
- * above — it is five minutes and it answers the question outright. Pin any
915
- * wiring to >= 1.1.10 (`--model`/`--effort` were ignored in headless before it;
916
- * `--output-format` arrived in 1.1.8).
917
- */
918
- antigravity: {
919
- id: 'antigravity',
920
- label: 'Antigravity',
921
- vendor: 'Google',
922
- bin: 'agy',
923
- install: 'see antigravity.google/docs/cli',
924
- login: 'agy',
925
- live: false,
926
- /** None, because it cannot reach the MCP server at all — see `blocked`. */
927
- /**
928
- * WIKI AND CONSULT, BUT NOT BUILD — and the split is the whole point of
929
- * making drivability per-profile rather than one verdict.
930
- *
931
- * Only a BUILD needs the MCP control plane: claiming, reporting a blocker,
932
- * attaching a PR, completing. A wiki turn writes markdown files that the
933
- * daemon syncs afterwards, and a consult answers a question in prose; BOTH
934
- * already run with no MCP at all on every runtime, Claude included. Gating
935
- * them on an MCP capability they never use was a test of the wrong thing,
936
- * and it is what kept Antigravity at zero for months.
937
- *
938
- * Build returns here when the mediated adapter lands (the daemon holds the
939
- * MCP connection and the CLI just returns schema-enforced JSON via
940
- * `--json-schema`), which needs no per-invocation MCP config from the vendor
941
- * at all.
942
- *
943
- * PLAN IS ABSENT ON PURPOSE, and NOT because a planning turn is beyond it —
944
- * it reads code as well as anything here. A plan session is a conversation
945
- * that makes many control-plane calls as it goes (spawn a slice, re-shape
946
- * it, drop it, rewrite the spec), and mediation turns a turn into ONE
947
- * schema-enforced form the daemon then applies. That shape fits a build,
948
- * whose outcome is a single structured result; it does not yet fit an
949
- * argument. Note that `canRun` would otherwise say yes via `mediated()` and
950
- * hand this runtime a job it cannot finish — declaring the profile is the
951
- * only thing standing between here and that. Mediated planning is a real
952
- * design (the session returns its writes as a batch), just not a built one.
953
- */
954
- profiles: ['build', 'wiki', 'consult'],
955
- mcp: null,
956
- args({ prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
957
- const a = [];
958
- // WHICH CONVERSATION IS THIS — one decision, answered one way (the same
959
- // rule as Claude's --resume/--continue). By id when the caller knows:
960
- // `--conversation <id>` resumes globally, any cwd (measured on 1.1.12).
961
- // Adoption is the SAME argv because agy has no fork — the id in the db
962
- // is the identity (a renamed copy fails "trajectory not found",
963
- // measured), so adopting MOVES the conversation: the tab continues the
964
- // terminal session itself, appending to its one store. `--continue` is
965
- // the cwd-keyed fallback for a session whose id was never learned.
966
- if (adoptResumeId || resumeConversationId) a.push('--conversation', adoptResumeId || resumeConversationId);
967
- else if (resume) a.push('--continue');
968
- // No system-prompt flag, same weakening as Codex: the contract rides in
969
- // the prompt, fenced and first.
970
- a.push('-p', `${system}\n\n---\n\n${prompt}`);
971
- a.push('--output-format', 'stream-json');
972
- if (model) a.push('--model', model);
973
- if (effort) a.push('--effort', effort);
974
- a.push(...resultSchemaArgs);
975
-
976
- if (profile === 'wiki') {
977
- // THE CARTOGRAPHER, at the same bar WIKI_PERM sets for Claude — writes
978
- // allowed, network shut. Both halves measured on 1.1.12:
979
- // • headless cannot prompt, so anything needing approval is
980
- // auto-denied — including the file writes a wiki turn exists to
981
- // make. `--dangerously-skip-permissions` is what grants them.
982
- // • `--sandbox` blocks egress: with it, curl returned 400 and a local
983
- // listener saw nothing; without it, 200 and the request arrived.
984
- // Upstream #36 warns these two cancel out (skip-permissions
985
- // auto-approving the sandbox-bypass prompt). It does NOT reproduce on
986
- // 1.1.12 — tested together, the write landed and the network stayed
987
- // shut. Re-check on upgrade: if it ever does cancel, this posture
988
- // silently becomes "wiki turn with internet", which is the one line
989
- // WIKI_PERM draws ("Command execution is the line: it enables network
990
- // exfil").
991
- if (vaultDir) a.push('--add-dir', vaultDir);
992
- a.push('--sandbox', '--dangerously-skip-permissions');
993
- // A sweep reads a whole repository; the 5m default would guillotine it.
994
- a.push('--print-timeout', '60m');
995
- } else if (profile === 'build') {
996
- // A BUILD WRITES, and headless auto-denies anything needing approval —
997
- // so without this every edit comes back "User denied permission" and the
998
- // turn reports failure having touched nothing. Caught only because an
999
- // end-to-end test had to add the flag by hand to work.
1000
- //
1001
- // NOT `--sandbox` here, unlike wiki: a build has to `git push`, so it
1002
- // needs the network by definition.
1003
- //
1004
- // AND NOTHING ELSE CONTAINS IT EITHER. This comment used to end "the
1005
- // containment is the worktree, as it is for every runtime", which was
1006
- // false and is the kind of false that stops people looking: `cwd` is a
1007
- // starting directory, not a jail. A build turn runs with the operator's
1008
- // full user permissions — it can write outside the worktree, to their
1009
- // home directory, to their other checkouts. Claude gets
1010
- // --dangerously-skip-permissions, codex gets --sandbox
1011
- // danger-full-access, and agy gets no sandbox flag at all. The only
1012
- // real boundary today is that the person driving the tab is trusted.
1013
- //
1014
- // FLOWVIANT_SAFE HAS NO EXPRESSION ON THIS RUNTIME. Claude narrows to an
1015
- // allowlist and Codex to `workspace-write`; agy's only per-invocation
1016
- // control is this boolean, and its allow/deny engine is machine-wide.
1017
- // So a SAFE-mode operator gets an agy build that is not actually
1018
- // narrowed — which is why `mediatedSafeGap` is surfaced rather than
1019
- // quietly ignored.
1020
- a.push('--dangerously-skip-permissions');
1021
- a.push('--print-timeout', '60m');
1022
- } else if (profile === 'consult') {
1023
- // NOTHING GRANTED, deliberately. The headless default IS the consult
1024
- // posture: `run_command` comes back "User denied permission… headless
1025
- // mode cannot prompt for it, so it was auto-denied", observed even for a
1026
- // benign `pwd && ls -la`, with no egress in any run. Reads keep working,
1027
- // which is all a consult needs. Passing --dangerously-skip-permissions
1028
- // here would hand a question ANY project editor can type a shell.
1029
- a.push('--print-timeout', '10m');
1030
- }
1031
- return a;
1032
- },
1033
- parse: parseAgyLine,
1034
- /**
1035
- * `--json-schema` enforces the shape of the final result — verified against
1036
- * 1.1.12, which returned exactly the object asked for. This is what makes a
1037
- * BUILD possible on a runtime that cannot be handed an MCP config: the CLI
1038
- * stops needing to CALL anything and just returns a filled-in form, and the
1039
- * daemon makes the control-plane calls on its behalf with the lane's own
1040
- * per-lane token. Strictly better than parsing markers out of prose, which a
1041
- * model can wrap in a code fence, truncate or hallucinate.
1042
- */
1043
- resultSchema: (path) => ['--json-schema', path],
1044
- blocked:
1045
- 'its MCP config is machine-wide — a workspace .agents/mcp_config.json is never loaded (measured), so every lane would share one token',
1046
- },
79
+ claude: CLAUDE_RUNTIME,
80
+ codex: CODEX_RUNTIME,
81
+ antigravity: ANTIGRAVITY_RUNTIME,
1047
82
  };
1048
83
 
1049
84
  // DELETED: `DISPATCHABLE`, a `mcp && args` filter last described as "runtimes
@@ -1054,7 +89,17 @@ export const RUNTIMES = {
1054
89
  // constant would have silently undone this release. `canRun(rt, profile)` /
1055
90
  // `drivableHere(rt)` below are the answers.
1056
91
 
1057
- export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
92
+ /**
93
+ * The adapter for a runtime id, or NULL for an id this daemon does not declare.
94
+ *
95
+ * NEVER A SUBSTITUTE (2026-09-26, SOLID F048). This used to fall back to
96
+ * Claude, so a misspelled id — or one a newer server learned ahead of this
97
+ * daemon — minted Claude's MCP config or spawned Claude under another
98
+ * runtime's requested identity. The callers (`mcpFor`, `runTurn`) refuse a
99
+ * null in words, naming the id they were given. A caller that OMITS the
100
+ * runtime still gets Claude, from its own explicit default, never from here.
101
+ */
102
+ export const runtimeById = (id) => (typeof id === 'string' && Object.hasOwn(RUNTIMES, id) ? RUNTIMES[id] : null);
1058
103
 
1059
104
  /**
1060
105
  * Can the worker this daemon is running actually DRIVE this runtime right now?
@@ -1089,7 +134,7 @@ export const runtimeById = (id) => RUNTIMES[id] ?? RUNTIMES.claude;
1089
134
  * is the control plane, and a runtime that cannot reach it cannot participate.
1090
135
  * A WIKI turn writes markdown the daemon syncs afterwards. A CONSULT answers a
1091
136
  * question in prose. Neither passes an MCP config on ANY runtime today, Claude
1092
- * included — check the two call sites in fleet.mjs, they hand `runTurn` no
137
+ * included — check the two call sites in wikiRunner.mjs, they hand `runTurn` no
1093
138
  * `mcpArgs` at all.
1094
139
  *
1095
140
  * A PLAN is the second one, and it is the reason the consult stopped being the
@@ -1143,408 +188,3 @@ export const canRun = (rt, profile) =>
1143
188
  * which is why a runtime can be undispatchable and still run wiki and consult.
1144
189
  */
1145
190
  export const drivableHere = (rt) => canRun(rt, 'build');
1146
-
1147
- /**
1148
- * WHICH RUNTIME RUNS A JOB THAT NOBODY @MENTIONED.
1149
- *
1150
- * Building a task has an author: you @mentioned a CLI, and that is the only
1151
- * dispatch in this product. The other turns have none — the wiki sweep, the
1152
- * re-ground, the plan check, the quick edit and the consult are all started by
1153
- * the daemon or the server, and until now every one of them took `runTurn`'s
1154
- * default parameter value and ran Claude. That was not a decision; it was five
1155
- * call sites omitting an argument, and it only looked correct while Claude was
1156
- * the only runtime and preflight refused to start without it.
1157
- *
1158
- * A PROFILE is a promise about what is IMPOSSIBLE during the turn, not a flag
1159
- * list — flag lists are per-vendor, promises are not, and the goal is that every
1160
- * runtime behaves the same way predictably. A runtime declares the profiles it
1161
- * can actually express; one that cannot express a profile does not get that job,
1162
- * rather than getting it with weaker guarantees nobody wrote down.
1163
- *
1164
- * Claude first when it qualifies — not favouritism, and worth saying plainly:
1165
- * these prompts were written and tuned against it, so it is the known-good
1166
- * answer and anything else is a substitution. When it is absent, any runtime
1167
- * that can express the profile runs the job, which is the whole point.
1168
- */
1169
- export function pickRuntimeFor(profile, { detected } = {}) {
1170
- const rows = detected ?? detectRuntimes();
1171
- const ok = (id) =>
1172
- canRun(RUNTIMES[id], profile) && Boolean(rows.find((d) => d.id === id)?.installed);
1173
- if (ok('claude')) return 'claude';
1174
- return Object.keys(RUNTIMES).find(ok) ?? null;
1175
- }
1176
-
1177
- // ── Detection ──────────────────────────────────────────────────────────────
1178
-
1179
- /**
1180
- * Which of these is on this machine, asked at most once per DETECT_TTL_MS.
1181
- *
1182
- * `--version` rather than `which`: a binary on PATH that cannot execute (a
1183
- * broken install, a wrong-arch download, a shell alias pointing at nothing) is
1184
- * not a runtime you can dispatch to, and reporting it as one sends work into a
1185
- * hole. 5s is generous for a version print and short enough that three missing
1186
- * CLIs cannot stall a roster poll.
1187
- *
1188
- * Reported to the server on the roster poll so the app can stop saying "we have
1189
- * not looked". It is a statement about THIS MACHINE and nothing else — no
1190
- * account, no quota, no entitlement. Flowviant relays; it does not enforce.
1191
- */
1192
- let detectedCache = null;
1193
- let detectedAt = 0;
1194
- // The cache EXPIRES rather than living for the process: pickRuntimeFor's whole
1195
- // premise is "a CLI can be installed while the daemon runs", and both it and
1196
- // the roster poll read this cache — a forever-cache made that comment a lie
1197
- // (nothing after preflight ever passed refresh, so a mid-run install was
1198
- // invisible until restart, to the server included). 5 minutes keeps the probes
1199
- // (3 sync --version execs) off the hot path while an install still surfaces on
1200
- // the next poll or job.
1201
- const DETECT_TTL_MS = 5 * 60 * 1000;
1202
- export function detectRuntimes({ refresh = false } = {}) {
1203
- if (detectedCache && !refresh && Date.now() - detectedAt < DETECT_TTL_MS) return detectedCache;
1204
- detectedAt = Date.now();
1205
- detectedCache = Object.values(RUNTIMES).map((rt) => {
1206
- let version = null;
1207
- try {
1208
- version = String(
1209
- execFileSync(rt.bin, ['--version'], {
1210
- timeout: 5000,
1211
- stdio: ['ignore', 'pipe', 'ignore'],
1212
- })
1213
- )
1214
- .trim()
1215
- .split('\n')[0]
1216
- .slice(0, 40);
1217
- } catch {
1218
- version = null;
1219
- }
1220
- return {
1221
- id: rt.id,
1222
- installed: version !== null,
1223
- version,
1224
- // Installed and drivable are different questions, and conflating them is
1225
- // how a user ends up @mentioning something that silently never starts.
1226
- // THREE questions, in fact — see `drivableHere`: the CLI can be installed,
1227
- // and this module can know how to spawn it, and the worker this daemon is
1228
- // running can still be unable to drive it.
1229
- dispatchable: version !== null && drivableHere(rt),
1230
- blocked: rt.blocked ?? null,
1231
- };
1232
- });
1233
- return detectedCache;
1234
- }
1235
-
1236
- /**
1237
- * WHAT THE CLI SAID IT CAN BE ASKED FOR BY NAME — the machine's skills.
1238
- *
1239
- * Learned, never scanned. Claude Code's `system.init` event names its own
1240
- * resolved skill set on every stream-json turn, and the daemon already parses
1241
- * that stream (claude.mjs), so this costs nothing and is authoritative: it has
1242
- * plugins, this repo's `.claude/skills`, and whatever project settings enabled
1243
- * or disabled already folded in. A `~/.claude/skills` scan of our own would be
1244
- * a second implementation of the CLI's resolution rules, and would drift.
1245
- *
1246
- * THE PRICE OF LEARNING RATHER THAN PROBING is that a machine which has not run
1247
- * a turn yet knows nothing, and says nothing. That is the honest answer: the
1248
- * app renders no menu rather than an empty one, and a slash typed into a tab
1249
- * still reaches the CLI either way — the menu is an autocomplete, never a gate.
1250
- * We do NOT probe for it: a `claude -p` run purely to populate a dropdown would
1251
- * spend the operator's quota on a UI affordance.
1252
- *
1253
- * PER MACHINE, not per session. Every session worktree is a checkout of the one
1254
- * repo this daemon serves, so project skills are identical across tabs and
1255
- * personal skills are machine-wide. Last turn wins, which is what makes a skill
1256
- * added mid-run show up on the next poll.
1257
- */
1258
- let skillsCache = null;
1259
-
1260
- /** Claude Code's own names: letters, digits, dash, underscore, and the colon a
1261
- * plugin skill wears (`plugin:skill`). Anything else is not a name we could
1262
- * put after a `/` anyway, so it is dropped rather than relayed as garbage. */
1263
- const SKILL_NAME = /^[A-Za-z0-9][A-Za-z0-9_:-]{0,63}$/;
1264
-
1265
- /** Record what a turn's init event reported. Bounded and sorted so the poll's
1266
- * query param has a stable length and a stable order — an unstable order would
1267
- * make the server write a "change" on every single poll. */
1268
- export function recordSkills(names) {
1269
- if (!Array.isArray(names)) return;
1270
- const clean = [...new Set(names.map((n) => String(n).trim()).filter((n) => SKILL_NAME.test(n)))]
1271
- .sort()
1272
- .slice(0, 100);
1273
- // An empty report is a FACT (a machine with no skills installed), so it is
1274
- // recorded as []. Never conflated with null, which stays "no turn has run".
1275
- skillsCache = clean;
1276
- }
1277
-
1278
- /** What to send on the roster poll — null until a turn has taught us. */
1279
- export function knownSkills() {
1280
- return skillsCache;
1281
- }
1282
-
1283
- /**
1284
- * WHICH MCP SERVERS AND CLAUDE.AI CONNECTORS THE CLI MOUNTED, AND HOW EACH
1285
- * STANDS (2026-09-23, 0.97.0) — learned off the same `system.init` event the
1286
- * skills are, so it costs nothing and cannot drift from what a turn reaches.
1287
- *
1288
- * MEASURED on Claude Code 2.1.281 (`claude -p hi --model haiku --output-format
1289
- * stream-json --verbose`): the init event carries `mcp_servers: [{ name,
1290
- * status, source }]` — statuses seen `connected`, `failed`, `needs-auth` and
1291
- * `pending`; a claude.ai connector arrives named `claude.ai <Name>` with
1292
- * `source: "claudeai"`, a server a turn mounts with `--mcp-config` with
1293
- * `source: "dynamic"`. A connector that needs a one-time sign-in AT THIS BOX
1294
- * reads `needs-auth` here long before a turn fails on it, and the app's
1295
- * Machines page names it.
1296
- *
1297
- * `flowviant` IS EXCLUDED: it is the server THIS daemon mounts on every tab
1298
- * turn, always present, and not something a person can act on. A status off
1299
- * the closed list is relayed as `other` rather than dropped — the CLI said
1300
- * something about the server, and silence would be the daemon deciding it did
1301
- * not. Bounded (40 entries, names cut at 60) and sorted, the `recordSkills`
1302
- * discipline: a stable param, so an unchanged report writes nothing.
1303
- *
1304
- * ONLY WHAT IS NOT CONNECTED LEAVES THE BOX (2026-09-23). The first cut
1305
- * relayed every server, connected ones included — so the operator's whole set
1306
- * of signed-in services (their mail, their brokerage, their calendar) rode the
1307
- * poll to the server and into every teammate's read of the Machines page,
1308
- * which renders only the servers that are NOT connected anyway. A connected
1309
- * connector is good news and good news is not a line; it is also nobody
1310
- * else's business. So `connected` is dropped HERE, before the cap, and what is
1311
- * sent is exactly what the page can say something about.
1312
- *
1313
- * THREE STATES, the skills split: null = no turn has taught us (the param is
1314
- * not sent), [] = nothing of the person's own needs anything (every server is
1315
- * connected, or none is mounted), a list = the ones that do. Last turn wins,
1316
- * so a connector somebody signs into leaves the list on the next turn's init,
1317
- * and an all-connected turn sends `[]`, which clears a stale list server-side.
1318
- */
1319
- let mcpServersCache = null;
1320
-
1321
- export const MCP_STATUSES = new Set(['connected', 'failed', 'needs-auth', 'pending']);
1322
- export const MAX_MCP_SERVERS = 40;
1323
- export const MCP_NAME_MAX = 60;
1324
-
1325
- export function recordMcpServers(list) {
1326
- if (!Array.isArray(list)) return;
1327
- const seen = new Map();
1328
- for (const e of list) {
1329
- if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
1330
- const n = e.name.trim().slice(0, MCP_NAME_MAX).trim();
1331
- if (!n || n === 'flowviant' || seen.has(n)) continue;
1332
- if (e.status === 'connected') continue; // see the header: never relayed
1333
- seen.set(n, { n, s: MCP_STATUSES.has(e.status) ? e.status : 'other' });
1334
- }
1335
- mcpServersCache = [...seen.values()]
1336
- .sort((a, b) => (a.n < b.n ? -1 : a.n > b.n ? 1 : 0))
1337
- .slice(0, MAX_MCP_SERVERS);
1338
- }
1339
-
1340
- /** What to send on the roster poll as `mcp` — null until a turn has taught us. */
1341
- export function knownMcpServers() {
1342
- return mcpServersCache;
1343
- }
1344
-
1345
- /**
1346
- * LEARN WHAT `/` CAN OFFER, ON A MACHINE NO TURN HAS TAUGHT.
1347
- *
1348
- * WHY THIS EXISTS. `recordSkills` above is fed from the init event of a tab
1349
- * turn — authoritative and free, but with a hole nobody priced: THE FIRST THING
1350
- * ANYONE DOES IN A NEW TAB IS TYPE `/`, and that is by definition before that
1351
- * machine has run a turn. The menu was guaranteed empty exactly where it is
1352
- * first reached. Not a theoretical hole: checked against production on
1353
- * 2026-08-25, `agent_tokens.skills` was NULL for EVERY machine credential that
1354
- * has ever existed, because the only tab turns ever run predated the release
1355
- * that reports. The feature had never worked for anyone, once.
1356
- *
1357
- * IT COSTS ONE SMALL REQUEST, AND THAT IS THE HONEST NUMBER. This file used to
1358
- * forbid probing outright — "a `claude -p` run purely to populate a dropdown
1359
- * would spend the operator's quota on an affordance" — and that rule was
1360
- * written picturing a COMPLETED TURN. This is not one: Claude Code emits
1361
- * `system.init`, carrying its own fully resolved skill set, within ~0.5s and
1362
- * long before it finishes answering, so the child is killed the moment that
1363
- * event is read. But the request HAS gone out by then — measured on 2.1.245 by
1364
- * reading the transcript a killed probe left behind: 2 input tokens, 4 output,
1365
- * ~6k cache-creation. Two zero-request routes were tried and both failed: an
1366
- * empty prompt errors before init is emitted, and `--input-format stream-json`
1367
- * emits nothing at all until a message arrives. So the cost is one cheap turn,
1368
- * ONCE per daemon process, on the cheapest model, and only on a machine no turn
1369
- * has taught. Do not let this grow into a per-tab or per-poll probe.
1370
- *
1371
- * `--model haiku` for that reason, and it is safe by this repo's own rule: a
1372
- * name lives in AGENT_MODELS only once `claude --model <name>` is known to be
1373
- * accepted on a real install. If it were ever refused the probe simply learns
1374
- * nothing and the machine stays unmeasured — which is exactly today's state, so
1375
- * the failure mode is the status quo rather than a regression.
1376
- *
1377
- * IT SCANS FOR THE INIT EVENT, never assuming it is line 1 — and that is not
1378
- * defensive padding, it is measured. With `--model haiku` the CLI prints a
1379
- * `system/status` line FIRST and init lands on line 2; with the default model
1380
- * init is line 1. A first-line-only reader (the version this replaced) silently
1381
- * learned nothing the moment a model flag was added.
1382
- *
1383
- * IT CLEANS UP AFTER ITSELF, and this is not optional. `claude -p` writes a
1384
- * transcript to `~/.claude/projects/<munged-cwd>/<session-id>.jsonl` the moment
1385
- * it starts, and `localSessions.mjs` reports the newest ENDED session per
1386
- * directory to the Workbench as an ADOPTABLE row. Left behind, every daemon
1387
- * start would put a phantom untitled session in the `+` menu offering to adopt
1388
- * a conversation that never happened. The init event names its own session id,
1389
- * so the file is ours by name and is removed by it.
1390
- *
1391
- * IT IS BEST-EFFORT IN EVERY DIRECTION. No claude, no PATH, a CLI that changed
1392
- * its event shape, an unwritable home — all leave `skillsCache` exactly as it
1393
- * was (null, "nobody looked"), which is the honest answer and the one the app
1394
- * already renders correctly. Nothing here may throw, and nothing may block the
1395
- * poll it is called from.
1396
- */
1397
-
1398
- /** Long enough for a cold CLI start on a slow box, short enough that a hung
1399
- * child is not left holding a session for the life of the daemon. */
1400
- const SKILL_PROBE_TIMEOUT_MS = 30_000;
1401
-
1402
- /** Init arrives within the first couple of events or not at all; this is the
1403
- * guard against parsing a whole turn's output looking for it. */
1404
- const SKILL_PROBE_MAX_LINES = 20;
1405
-
1406
- let skillProbeStarted = false;
1407
-
1408
- /** Where Claude Code keeps a transcript for `cwd`: `/` and `.` both become `-`. */
1409
- function transcriptCandidates(cwd, sessionId) {
1410
- const base = join(homedir(), '.claude', 'projects');
1411
- const out = [join(base, cwd.replace(/[/.]/g, '-'), `${sessionId}.jsonl`)];
1412
- // The munge is Claude Code's, not ours, so a version that changes it must not
1413
- // leave the file behind: fall back to finding our own session id by name.
1414
- try {
1415
- for (const d of readdirSync(base)) out.push(join(base, d, `${sessionId}.jsonl`));
1416
- } catch {
1417
- /* no project store — nothing was written either */
1418
- }
1419
- return out;
1420
- }
1421
-
1422
- /**
1423
- * Delete the transcript a headless `-p` turn left behind.
1424
- *
1425
- * EXPORTED because the skills probe is no longer the only thing that runs one:
1426
- * resolving a project's dev command is a background Claude turn too, and every
1427
- * such turn has the same footprint. `claude -p` writes
1428
- * `~/.claude/projects/<munged-cwd>/<id>.jsonl` at startup, and
1429
- * `localSessions.mjs` offers the newest ENDED session per directory as
1430
- * ADOPTABLE — so anything we run for our own purposes would put a phantom
1431
- * untitled session in somebody's `+` menu.
1432
- */
1433
- export function removeProbeTranscript(cwd, sessionId) {
1434
- if (!sessionId || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) return;
1435
- for (const f of transcriptCandidates(cwd, sessionId)) {
1436
- try {
1437
- if (existsSync(f)) {
1438
- rmSync(f, { force: true });
1439
- return;
1440
- }
1441
- } catch {
1442
- /* best-effort */
1443
- }
1444
- }
1445
- }
1446
-
1447
- /**
1448
- * One line of the probe's stdout → the init event's payload, or null for "not
1449
- * it, keep reading".
1450
- *
1451
- * SEPARATE FROM THE SCAN LOOP so the thing that actually broke can be tested
1452
- * without spawning a CLI. The first version of this probe read line 1 and
1453
- * stopped, which was measured-correct with the default model and silently
1454
- * WRONG with `--model haiku`: that path prints a `system/status` line first and
1455
- * puts init on line 2, so the probe learned nothing and reported nothing, which
1456
- * is indistinguishable from the bug it was written to fix.
1457
- *
1458
- * A line that is not JSON is not a failure — a CLI warning on stdout is a line
1459
- * to skip, not a reason to abandon the probe.
1460
- */
1461
- export function parseInitLine(line) {
1462
- let ev;
1463
- try {
1464
- ev = JSON.parse(line);
1465
- } catch {
1466
- return null;
1467
- }
1468
- if (!ev || ev.type !== 'system' || ev.subtype !== 'init') return null;
1469
- return {
1470
- // An init event WITHOUT skills is still the init event: stop reading, but
1471
- // record nothing. Conflating the two would keep the probe scanning a whole
1472
- // turn's output on a CLI that does not report them.
1473
- skills: Array.isArray(ev.skills) ? ev.skills : null,
1474
- // The CLI's mounted MCP servers (0.97.0) — `recordMcpServers` normalises.
1475
- mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : null,
1476
- sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
1477
- };
1478
- }
1479
-
1480
- /**
1481
- * Kick off the one-shot probe. Returns immediately; the result lands in
1482
- * `skillsCache` and rides the NEXT poll, so nothing waits on it.
1483
- *
1484
- * A no-op once a turn has taught us (`skillsCache !== null`) — a turn's init
1485
- * event and this one say the same thing, and the turn is free.
1486
- */
1487
- export function probeSkillsOnce(cwd) {
1488
- if (skillProbeStarted || skillsCache !== null) return;
1489
- skillProbeStarted = true;
1490
- let child;
1491
- try {
1492
- // Claude Code specifically, not the `wiki` profile's runtime pick: `skills`
1493
- // is Claude Code's own field and the `/` tray only renders for claude tabs.
1494
- child = spawn(
1495
- RUNTIMES.claude.bin,
1496
- ['-p', 'x', '--model', 'haiku', '--output-format', 'stream-json', '--verbose'],
1497
- { cwd, stdio: ['ignore', 'pipe', 'ignore'] }
1498
- );
1499
- } catch {
1500
- return; // no claude on PATH — the machine simply stays unmeasured
1501
- }
1502
- let buf = '';
1503
- let lines = 0;
1504
- let settled = false;
1505
- const finish = (sessionId) => {
1506
- if (settled) return;
1507
- settled = true;
1508
- clearTimeout(timer);
1509
- try {
1510
- child.kill('SIGKILL');
1511
- } catch {
1512
- /* already gone */
1513
- }
1514
- // AFTER the kill, and on a delay: the transcript is the CHILD's file, so
1515
- // deleting it while the child still lives races a recreate.
1516
- if (sessionId) setTimeout(() => removeProbeTranscript(cwd, sessionId), 750).unref?.();
1517
- };
1518
- const timer = setTimeout(() => finish(null), SKILL_PROBE_TIMEOUT_MS);
1519
- timer.unref?.();
1520
- child.on('error', () => finish(null));
1521
- child.on('exit', () => finish(null));
1522
- // UTF-8 mode on the STREAM, not a per-chunk `.toString()` — a multi-byte
1523
- // character straddling a chunk boundary decodes to mojibake (or a stray
1524
- // replacement byte) when each chunk is decoded on its own; the stream's
1525
- // own decoder holds the partial sequence over to the next chunk instead.
1526
- child.stdout.setEncoding('utf8');
1527
- child.stdout.on('data', (d) => {
1528
- if (settled) return;
1529
- buf += d;
1530
- let nl;
1531
- while (!settled && (nl = buf.indexOf('\n')) >= 0) {
1532
- const line = buf.slice(0, nl);
1533
- buf = buf.slice(nl + 1);
1534
- if (!line.trim()) continue;
1535
- if (++lines > SKILL_PROBE_MAX_LINES) {
1536
- finish(null);
1537
- return;
1538
- }
1539
- const init = parseInitLine(line);
1540
- if (!init) continue;
1541
- if (init.skills) recordSkills(init.skills);
1542
- if (init.mcpServers) recordMcpServers(init.mcpServers);
1543
- finish(init.sessionId);
1544
- return;
1545
- }
1546
- // A single line this long is not an init event; stop buffering a whole
1547
- // turn's output into memory waiting for one.
1548
- if (!settled && buf.length > 1_000_000) finish(null);
1549
- });
1550
- }