flowviant 0.116.0 → 0.124.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -16,6 +16,9 @@ import { existsSync, readdirSync, rmSync } from 'node:fs';
16
16
  import { homedir } from 'node:os';
17
17
  import { join } from 'node:path';
18
18
  import { RUNTIMES } from './runtimes.mjs';
19
+ import { unattendedEnv } from './noWindowEnv.mjs';
20
+ import { claudeProjectDir } from './claudeSessions.mjs';
21
+ import { CLAUDE_SESSION_RE } from './conversationIds.mjs';
19
22
 
20
23
  /**
21
24
  * WHAT THE CLI SAID IT CAN BE ASKED FOR BY NAME — the machine's skills.
@@ -103,29 +106,134 @@ export function knownSkills() {
103
106
  let mcpServersCache = null;
104
107
 
105
108
  export const MCP_STATUSES = new Set(['connected', 'failed', 'needs-auth', 'pending']);
109
+ /** The server's own bounds (shared `MAX_MACHINE_MCP_SERVERS` and
110
+ * `MCP_SERVER_NAME_MAX`); scripts/check-app-parity.mjs holds them equal. */
106
111
  export const MAX_MCP_SERVERS = 40;
107
112
  export const MCP_NAME_MAX = 60;
113
+ /** The name charset the server's Codex report parser admits
114
+ * (runtimeOfferings.ts `name`) — Claude's report has none, and its connector
115
+ * names carry a space (`claude.ai Gmail`). */
116
+ export const CODEX_MCP_NAME = /^[A-Za-z0-9_.:-]+$/;
108
117
 
109
- export function recordMcpServers(list) {
110
- if (!Array.isArray(list)) return;
118
+ /**
119
+ * ONE SHAPE FOR EVERY MCP REPORT THAT LEAVES THE BOX (2026-10-10, the audit)
120
+ * — Claude's `mcp` off a turn's init, and Codex's `rtp.codex`
121
+ * (runtimeOfferings.mjs) — bounded exactly as the server re-applies it, so
122
+ * nothing is cut silently at the boundary: names trimmed and CLIPPED at
123
+ * MCP_NAME_MAX (never dropped for length), `charset` when the server's parser
124
+ * has one, this daemon's own `flowviant` and every `connected` server dropped
125
+ * (see the header), the first of a name kept, sorted, at most
126
+ * MAX_MCP_SERVERS. A status off the closed list is `other`. `entries` are
127
+ * `{ name, status }`.
128
+ */
129
+ export function normalizeMcpReport(entries, { charset = null } = {}) {
111
130
  const seen = new Map();
112
- for (const e of list) {
131
+ for (const e of Array.isArray(entries) ? entries : []) {
113
132
  if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
114
133
  const n = e.name.trim().slice(0, MCP_NAME_MAX).trim();
115
134
  if (!n || n === 'flowviant' || seen.has(n)) continue;
135
+ if (charset && !charset.test(n)) continue;
116
136
  if (e.status === 'connected') continue; // see the header: never relayed
117
137
  seen.set(n, { n, s: MCP_STATUSES.has(e.status) ? e.status : 'other' });
118
138
  }
119
- mcpServersCache = [...seen.values()]
139
+ return [...seen.values()]
120
140
  .sort((a, b) => (a.n < b.n ? -1 : a.n > b.n ? 1 : 0))
121
141
  .slice(0, MAX_MCP_SERVERS);
122
142
  }
123
143
 
144
+ /**
145
+ * EVERY SERVER CLAUDE CODE REPORTED, CONNECTED ONES INCLUDED, WITH ITS SOURCE
146
+ * AND — WHEN CONNECTED — ITS TOOLS' NAMES (2026-10-13, the owner: name every
147
+ * MCP server and capability each CLI gives the project). Learned off the same
148
+ * init as the list above, under the same harvest rule; it leaves the box only
149
+ * on this box's own `tools-report` (mcpInventory.mjs), never on the poll, and
150
+ * carries names, statuses and sources alone. The header's "only what is not
151
+ * connected leaves the box" still holds for the poll's `mcp`, which an older
152
+ * app reads.
153
+ *
154
+ * `scope` is the CLI's own word (measured on Claude Code 2.1.296's scope list:
155
+ * local, user, project, dynamic, enterprise, claudeai, managed, agent);
156
+ * mcpInventory.mjs maps it to a source. Tool names come from the init's
157
+ * `tools`, where a server's tools are `mcp__<server>__<tool>` and the server
158
+ * part is its name with every character outside `[A-Za-z0-9_-]` made `_`
159
+ * (`claude.ai Gmail` → `claude_ai_Gmail`, measured 2026-10-11).
160
+ */
161
+ let mcpInventoryCache = null;
162
+ const MAX_INVENTORY_SERVERS = 80;
163
+ const mcpToolPrefix = (name) => `mcp__${name.replace(/[^A-Za-z0-9_-]/g, '_')}__`;
164
+
165
+ /** The init's servers and tools → `[{ name, status, scope, tools }]`. */
166
+ export function claudeMcpInventoryOf(list, tools) {
167
+ const names = Array.isArray(tools) ? tools.filter((t) => typeof t === 'string' && t.startsWith('mcp__')) : [];
168
+ const seen = new Map();
169
+ for (const e of list) {
170
+ if (!e || typeof e !== 'object' || typeof e.name !== 'string') continue;
171
+ const name = e.name.trim().slice(0, MCP_NAME_MAX).trim();
172
+ if (!name || name === 'flowviant' || seen.has(name)) continue;
173
+ const status = MCP_STATUSES.has(e.status) ? e.status : 'other';
174
+ seen.set(name, { name, status, scope: typeof e.scope === 'string' ? e.scope : typeof e.source === 'string' ? e.source : null, prefix: mcpToolPrefix(e.name.trim()), tools: status === 'connected' ? [] : null });
175
+ }
176
+ // The longest prefix wins, so `a_b` never claims `a`'s tools or the reverse.
177
+ const byPrefix = [...seen.values()].sort((a, b) => b.prefix.length - a.prefix.length);
178
+ for (const tool of names) {
179
+ const owner = byPrefix.find((s) => tool.startsWith(s.prefix));
180
+ if (owner?.tools) owner.tools.push(tool.slice(owner.prefix.length));
181
+ }
182
+ return [...seen.values()]
183
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
184
+ .slice(0, MAX_INVENTORY_SERVERS)
185
+ .map(({ prefix, ...server }) => server);
186
+ }
187
+
188
+ export function recordMcpServers(list, tools) {
189
+ if (!Array.isArray(list)) return;
190
+ mcpServersCache = normalizeMcpReport(list);
191
+ mcpInventoryCache = claudeMcpInventoryOf(list, tools);
192
+ }
193
+
124
194
  /** What to send on the roster poll as `mcp` — null until a turn has taught us. */
125
195
  export function knownMcpServers() {
126
196
  return mcpServersCache;
127
197
  }
128
198
 
199
+ /** Claude Code's whole server list for the box's inventory — null until a
200
+ * turn (or the probe) has taught us. */
201
+ export function knownClaudeMcpInventory() {
202
+ return mcpInventoryCache;
203
+ }
204
+
205
+ /**
206
+ * WHICH TURNS ARE A MEASUREMENT OF THE MACHINE (2026-10-10, the audit) —
207
+ * asked by runTurn.mjs, the one spawn point, of the argv the adapter built.
208
+ *
209
+ * Every report above is "last turn wins", so a turn whose CLI was told to see
210
+ * less than the machine has must not be recorded: a fenced turn
211
+ * (`--setting-sources ''` — the planner, the wiki, the capture chat, a design
212
+ * card) reads no personal or project skills, and an agent's code turn reads
213
+ * `--setting-sources user` plus the reviewed `--plugin-dir`; a
214
+ * `--strict-mcp-config` turn mounts only what its argv names. Recorded, each
215
+ * wrote its narrowed list over the machine's — a claude.ai connector that
216
+ * needs a sign-in vanished from the Machines page until the next tab turn put
217
+ * it back. ANY NAMED SOURCE LIST NARROWS MCP TOO, strict or not: the person's
218
+ * plugins ride the user file (claudePersonal.mjs), so an empty list drops
219
+ * their servers, and a project `.mcp.json` is admitted by the project and
220
+ * local files. The capture chat is fenced but not strict — gated on strict
221
+ * alone, its init still wrote over the machine's report. So skills are
222
+ * harvested only off a turn that names no setting source and no plugin, MCP
223
+ * only off one that names no setting source and is not strict, and both only
224
+ * off Claude Code, whose init carries them. A box that runs only agents
225
+ * learns from the startup probe and its Terminal tabs.
226
+ */
227
+ export function initHarvestFor(rt, args) {
228
+ const argv = Array.isArray(args) ? args : [];
229
+ const claude = rt?.id === 'claude';
230
+ const sourcesNamed = argv.includes('--setting-sources');
231
+ return {
232
+ skills: claude && !sourcesNamed && !argv.includes('--plugin-dir'),
233
+ mcp: claude && !sourcesNamed && !argv.includes('--strict-mcp-config'),
234
+ };
235
+ }
236
+
129
237
  /**
130
238
  * LEARN WHAT `/` CAN OFFER, ON A MACHINE NO TURN HAS TAUGHT.
131
239
  *
@@ -189,10 +297,20 @@ const SKILL_PROBE_MAX_LINES = 20;
189
297
 
190
298
  let skillProbeStarted = false;
191
299
 
192
- /** Where Claude Code keeps a transcript for `cwd`: `/` and `.` both become `-`. */
300
+ /**
301
+ * Where Claude Code keeps a transcript for `cwd`: its own directory rule,
302
+ * `claudeProjectDir` (claudeSessions.mjs), first.
303
+ *
304
+ * ONE RULE, NOT TWO (2026-10-10, the audit). This spelled the munge itself as
305
+ * `replace(/[/.]/g, '-')` — the rule claudeSessions.mjs retired on 2026-09-24
306
+ * because it disagrees with Claude Code for any path holding a character other
307
+ * than a letter, digit, `/` or `.` — so a checkout at `~/code/my_app` built the
308
+ * wrong primary path and every removal fell through to the scan below. The
309
+ * store's root is unchanged: `~/.claude`, as it was.
310
+ */
193
311
  function transcriptCandidates(cwd, sessionId) {
194
312
  const base = join(homedir(), '.claude', 'projects');
195
- const out = [join(base, cwd.replace(/[/.]/g, '-'), `${sessionId}.jsonl`)];
313
+ const out = [join(base, claudeProjectDir(cwd), `${sessionId}.jsonl`)];
196
314
  // The munge is Claude Code's, not ours, so a version that changes it must not
197
315
  // leave the file behind: fall back to finding our own session id by name.
198
316
  try {
@@ -207,15 +325,15 @@ function transcriptCandidates(cwd, sessionId) {
207
325
  * Delete the transcript a headless `-p` turn left behind.
208
326
  *
209
327
  * EXPORTED because the skills probe is no longer the only thing that runs one:
210
- * resolving a project's dev command is a background Claude turn too, and every
211
- * such turn has the same footprint. `claude -p` writes
212
- * `~/.claude/projects/<munged-cwd>/<id>.jsonl` at startup, and
328
+ * the planner, the pre-review and intake each run a read-only consult turn
329
+ * (workCappedTurn.mjs), and every such turn has the same footprint.
330
+ * `claude -p` writes `~/.claude/projects/<munged-cwd>/<id>.jsonl` at startup, and
213
331
  * `claudeSessions.mjs` offers the newest ENDED session per directory as
214
332
  * ADOPTABLE — so anything we run for our own purposes would put a phantom
215
333
  * untitled session in somebody's `+` menu.
216
334
  */
217
335
  export function removeProbeTranscript(cwd, sessionId) {
218
- if (!sessionId || !/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) return;
336
+ if (!sessionId || !CLAUDE_SESSION_RE.test(sessionId)) return;
219
337
  for (const f of transcriptCandidates(cwd, sessionId)) {
220
338
  try {
221
339
  if (existsSync(f)) {
@@ -228,6 +346,21 @@ export function removeProbeTranscript(cwd, sessionId) {
228
346
  }
229
347
  }
230
348
 
349
+ /**
350
+ * REMOVE IT SOON, NOT NOW — the one spelling of the delayed removal every
351
+ * headless turn of ours keeps (the skills probe here, and the capped consult
352
+ * turn of workCappedTurn.mjs that the planner, the pre-review and intake run).
353
+ *
354
+ * AFTER the kill and ON A DELAY: the transcript is the CHILD's file, so
355
+ * deleting it while the child is still dying races a recreate. Unref'd — a
356
+ * cleanup must never hold the process open. A turn that never named its
357
+ * conversation has nothing of ours to remove.
358
+ */
359
+ export function removeTranscriptSoon(cwd, sessionId, ms = 750) {
360
+ if (!sessionId) return;
361
+ setTimeout(() => removeProbeTranscript(cwd, sessionId), ms).unref?.();
362
+ }
363
+
231
364
  /**
232
365
  * One line of the probe's stdout → the init event's payload, or null for "not
233
366
  * it, keep reading".
@@ -257,30 +390,76 @@ export function parseInitLine(line) {
257
390
  skills: Array.isArray(ev.skills) ? ev.skills : null,
258
391
  // The CLI's mounted MCP servers (0.97.0) — `recordMcpServers` normalises.
259
392
  mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : null,
393
+ // Its tool names, for the inventory's per-server lists (2026-10-13).
394
+ tools: Array.isArray(ev.tools) ? ev.tools : null,
260
395
  sessionId: typeof ev.session_id === 'string' ? ev.session_id : null,
261
396
  };
262
397
  }
263
398
 
399
+ /**
400
+ * THE PROBE IS AN UNATTENDED CLAUDE SESSION, AND RUNS AS ONE (2026-10-10, the
401
+ * audit). It runs in the operator's checkout at every daemon start, and its
402
+ * `claude` started the person's stdio MCP servers and fired SessionStart hooks
403
+ * — the checkout's `.claude/settings.json` ones included, which fire even in
404
+ * an untrusted workspace (decisions.md, 2026-09-29) — with the daemon's whole
405
+ * environment, the machine credential in it. So:
406
+ * · its environment is every unattended child's (`unattendedEnv`): no
407
+ * machine credential, no display;
408
+ * · `disableAllHooks` rides a FLAG setting, which outranks the user and
409
+ * project files — and only hooks go: the probe mirrors what a Terminal tab
410
+ * sees, so no `--setting-sources` narrowing. MEASURED on Claude Code
411
+ * 2.1.296, offline: with the flag, neither a personal nor a project
412
+ * SessionStart hook started (no `hook_started`), and the init still named
413
+ * the personal and the project skill;
414
+ * · its own process group, killed whole when it finishes, so nothing it
415
+ * started — an MCP server, a backgrounded hook — outlives the probe.
416
+ */
417
+ export const SKILL_PROBE_ARGS = Object.freeze([
418
+ '-p', 'x', '--model', 'haiku', '--output-format', 'stream-json', '--verbose',
419
+ '--settings', JSON.stringify({ disableAllHooks: true }),
420
+ ]);
421
+
264
422
  /**
265
423
  * Kick off the one-shot probe. Returns immediately; the result lands in
266
424
  * `skillsCache` and rides the NEXT poll, so nothing waits on it.
267
425
  *
268
426
  * A no-op once a turn has taught us (`skillsCache !== null`) — a turn's init
269
- * event and this one say the same thing, and the turn is free.
427
+ * event and this one say the same thing, and the turn is free. `spawn` is a
428
+ * test's.
270
429
  */
271
- export function probeSkillsOnce(cwd) {
430
+ export function probeSkillsOnce(cwd, { spawn: spawnChild = spawn } = {}) {
272
431
  if (skillProbeStarted || skillsCache !== null) return;
273
432
  skillProbeStarted = true;
433
+ runInitProbe(cwd, [], spawnChild);
434
+ }
435
+
436
+ /**
437
+ * LOOK AGAIN, NOW, WITH THE BASE BRANCH'S SERVERS MOUNTED (2026-10-13) — the
438
+ * re-check after a confirmed MCP install (mcpInstall.mjs). The same cheap
439
+ * probe, same rules (hooks off, unattended, its group killed), with the base
440
+ * copy added through `--mcp-config` so Claude Code says whether each project
441
+ * server now starts; that is what clears a machine's checklist on the Machines
442
+ * page. A person pressed Install for this, so it is not the once-per-process
443
+ * probe: it runs every time it is asked, which is once per finished install.
444
+ * Resolves when the probe has finished (or given up).
445
+ */
446
+ export function probeMcpNow(cwd, mcpConfigPath, { spawn: spawnChild = spawn } = {}) {
447
+ return new Promise((resolve) => runInitProbe(cwd, mcpConfigPath ? ['--mcp-config', mcpConfigPath] : [], spawnChild, resolve));
448
+ }
449
+
450
+ function runInitProbe(cwd, extraArgs, spawnChild, done = () => {}) {
274
451
  let child;
275
452
  try {
276
453
  // Claude Code specifically, not the `wiki` profile's runtime pick: `skills`
277
454
  // is Claude Code's own field and the `/` tray only renders for claude tabs.
278
- child = spawn(
279
- RUNTIMES.claude.bin,
280
- ['-p', 'x', '--model', 'haiku', '--output-format', 'stream-json', '--verbose'],
281
- { cwd, stdio: ['ignore', 'pipe', 'ignore'] }
282
- );
455
+ child = spawnChild(RUNTIMES.claude.bin, [...SKILL_PROBE_ARGS, ...extraArgs], {
456
+ cwd,
457
+ stdio: ['ignore', 'pipe', 'ignore'],
458
+ detached: true,
459
+ env: unattendedEnv(),
460
+ });
283
461
  } catch {
462
+ done();
284
463
  return; // no claude on PATH — the machine simply stays unmeasured
285
464
  }
286
465
  let buf = '';
@@ -290,14 +469,21 @@ export function probeSkillsOnce(cwd) {
290
469
  if (settled) return;
291
470
  settled = true;
292
471
  clearTimeout(timer);
472
+ // The whole group: what the CLI started goes with it.
293
473
  try {
294
- child.kill('SIGKILL');
474
+ if (child.pid) process.kill(-child.pid, 'SIGKILL');
475
+ else child.kill('SIGKILL');
295
476
  } catch {
296
- /* already gone */
477
+ try {
478
+ child.kill('SIGKILL');
479
+ } catch {
480
+ /* already gone */
481
+ }
297
482
  }
298
483
  // AFTER the kill, and on a delay: the transcript is the CHILD's file, so
299
484
  // deleting it while the child still lives races a recreate.
300
- if (sessionId) setTimeout(() => removeProbeTranscript(cwd, sessionId), 750).unref?.();
485
+ removeTranscriptSoon(cwd, sessionId);
486
+ done();
301
487
  };
302
488
  const timer = setTimeout(() => finish(null), SKILL_PROBE_TIMEOUT_MS);
303
489
  timer.unref?.();
@@ -323,7 +509,7 @@ export function probeSkillsOnce(cwd) {
323
509
  const init = parseInitLine(line);
324
510
  if (!init) continue;
325
511
  if (init.skills) recordSkills(init.skills);
326
- if (init.mcpServers) recordMcpServers(init.mcpServers);
512
+ if (init.mcpServers) recordMcpServers(init.mcpServers, init.tools);
327
513
  finish(init.sessionId);
328
514
  return;
329
515
  }
@@ -332,3 +518,11 @@ export function probeSkillsOnce(cwd) {
332
518
  if (!settled && buf.length > 1_000_000) finish(null);
333
519
  });
334
520
  }
521
+
522
+ /** Tests only: forget what was learned, and that the probe ran. */
523
+ export function resetCapabilitiesForTest() {
524
+ skillsCache = null;
525
+ mcpServersCache = null;
526
+ mcpInventoryCache = null;
527
+ skillProbeStarted = false;
528
+ }
@@ -14,7 +14,12 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
- import { NO_SETTING_SOURCES, personalClaudeSettings, withPersonalSettings } from './claudePersonal.mjs';
17
+ import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings } from './claudePersonal.mjs';
18
+ import { withMachineMcp } from './claudePosture.mjs';
19
+
20
+ /** The profiles an agent lane mounts the machine's MCP servers on (2026-10-11):
21
+ * the ones whose turn profile says `projectMcp` and that Claude declares. */
22
+ const MACHINE_MCP_PROFILES = Object.freeze(['build', 'design', 'research']);
18
23
 
19
24
  function claudeMcp(token, mcpUrl) {
20
25
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
@@ -53,7 +58,10 @@ export const CLAUDE_RUNTIME = {
53
58
  * contract was written, not a claim that only Claude could ever satisfy it.
54
59
  */
55
60
  // `design` and `research` (0.97.0): each is an `--allowedTools` list with a
56
- // PATH-SCOPED write (claudePosture.mjs). `research` is Claude's alone;
61
+ // PATH-SCOPED write (claudePosture.mjs); an agent's turn mounts the
62
+ // project's base MCP servers (2026-10-10) and this machine's own
63
+ // (2026-10-11). `research` is Codex's too since 0.117.0,
64
+ // over an isolated copy (codexResearch.mjs);
57
65
  // `design` is Codex's too since 0.115.0 (2026-09-29), whose permission
58
66
  // profile was measured to write only the artifacts directory
59
67
  // (runtimeCodex.mjs `codexDesignFence`). agy's flags cannot express "write
@@ -70,13 +78,41 @@ export const CLAUDE_RUNTIME = {
70
78
  * fails when any runtime's row differs from the server's.
71
79
  */
72
80
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
81
+ /**
82
+ * HOW A TERMINAL TAB SPEAKS ON THIS CLI (audit 2026-10-10, F159) — the
83
+ * facts the session turn's shape reads (workTurnShape.mjs), declared on the
84
+ * row instead of spelled as an id check in the lane. Daemon-internal:
85
+ * runtimeDetection maps rows to fields of its own, so none of this is on
86
+ * the wire.
87
+ * tab: 'mcp' — the session tools ride a per-invocation MCP config.
88
+ * planTurns — a tab's plan turn can run here: `--permission-mode plan`
89
+ * is Claude Code's own switch (claudePosture.mjs, PLAN_MODE_PERM). Not
90
+ * named `planMode`, the retired runTurn switch (turnProfile.mjs,
91
+ * RETIRED_POSTURE_KEYS), so no spelling of it reads as one.
92
+ * adopt: 'fork' — a terminal conversation is forked into the tab's
93
+ * worktree (`--resume <id> --fork-session`).
94
+ */
95
+ tab: 'mcp',
96
+ planTurns: true,
97
+ adopt: 'fork',
73
98
  mcp: claudeMcp,
74
99
  /**
75
100
  * Claude takes the operating contract as a real system prompt, which is the
76
101
  * strongest form of it available anywhere: `--append-system-prompt` sits
77
102
  * above the conversation rather than inside it.
78
103
  */
79
- args({ prompt, system, model, effort, resume, streamJson, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, agentTools, personalSettings }) {
104
+ args({ prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp }) {
105
+ // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
106
+ // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
107
+ // with no vault, or one no rule can name, has no list — refused here in
108
+ // words, as Codex's builder refuses an image turn with no worktree, and
109
+ // never run on a list it did not build.
110
+ if (profile === 'wiki' && !vaultDir) {
111
+ throw new Error("a wiki turn needs its vault to fence — none reached claude's builder");
112
+ }
113
+ if (profile === 'wiki' && !Array.isArray(perm)) {
114
+ throw new Error(`a wiki turn's vault must be a path a Claude Code permission rule can name — ${JSON.stringify(vaultDir)} is not`);
115
+ }
80
116
  const a = [];
81
117
  // THREE ANSWERS TO ONE QUESTION — "what conversation is this?" — and they
82
118
  // are mutually exclusive, never combined.
@@ -132,28 +168,60 @@ export const CLAUDE_RUNTIME = {
132
168
  */
133
169
  const fenced = profile !== 'build';
134
170
  if (fenced) a.push(...NO_SETTING_SOURCES);
171
+ // A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
172
+ // under the CLI's `default` mode, where a tool the list does not name is
173
+ // refused because nobody is there to approve it. With no settings read,
174
+ // Claude Code 2.1.296 measured its built-in mode as `auto` (init
175
+ // `permissionMode: "auto"`, claudePersonal.test.mjs's real-CLI case) — a
176
+ // classifier that may approve what the list never named. So the posture
177
+ // states `default` itself, unless its own list names a mode (plan-mode).
178
+ if (fenced && !perm.includes('--permission-mode')) a.push('--permission-mode', 'default');
135
179
  else if (agentTools) a.push('--setting-sources', 'user');
136
180
  if (agentTools) {
137
181
  a.push('--system-prompt-snapshot', 'off');
138
182
  if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
139
183
  }
140
- // These fenced postures have no project MCP, even if Claude discovers a
141
- // .mcp.json in the agent's worktree. Build gets its reviewed base config
142
- // explicitly from the caller; the plan profile keeps its own principal.
143
- if (['consult', 'wiki', 'design', 'research'].includes(profile)) a.push('--strict-mcp-config');
184
+ // WHICH MCP SERVERS A TURN MOUNTS. An agent's code, write-up and
185
+ // mockup/3D-model/deck turns (2026-10-11, the owner) mount this machine's
186
+ // own — its user and local servers and the account's claude.ai connectors —
187
+ // beside the base branch's `.mcp.json` copy the caller passes, so they are
188
+ // not strict; `machineMcp` names every `.mcp.json` server from the
189
+ // worktree up, which `withMachineMcp` disables so a worktree edit never
190
+ // mounts before review (claudePosture.mjs). Every other fenced posture —
191
+ // consult, wiki, and a design/research turn no lane mounted for — stays
192
+ // strict: it never discovers a .mcp.json, nor loads the machine's servers.
193
+ // The plan profile keeps its own principal.
194
+ const machine = Array.isArray(machineMcp) && MACHINE_MCP_PROFILES.includes(profile);
195
+ if (['consult', 'wiki', 'design', 'research'].includes(profile) && !machine && !mcp.includes('--strict-mcp-config')) a.push('--strict-mcp-config');
144
196
  a.push(...mcp, ...resultSchemaArgs);
145
197
  a.push('--model', model || MODEL);
146
198
  if (effort) a.push('--effort', effort);
147
- if (streamJson) a.push('--output-format', 'stream-json', '--verbose');
199
+ // ALWAYS THE EVENT STREAM (2026-10-10): runTurn reads every turn line by
200
+ // line — the activity, the init's skills and servers, the result.
201
+ // A completed thinking block can arrive minutes after reasoning began.
202
+ // Ask for its start too, so both live surfaces can relay that fact.
203
+ a.push('--output-format', 'stream-json', '--verbose', '--include-partial-messages');
148
204
  // The knowledge library (0.94.0) sits in the checkout, outside a
149
205
  // worktree's cwd: named as a readable directory so a curated profile
150
206
  // does not refuse the path the prompt just handed it. Before `perm`,
151
207
  // because `--allowedTools` is variadic and would swallow the flag.
152
208
  if (knowledgeDir) a.push('--add-dir', knowledgeDir);
209
+ // THE VAULT, likewise (2026-10-10): it lives under ~/.flowviant/vaults,
210
+ // outside the wiki worktree, and its `mkdir`/`rm` are path-validated to the
211
+ // working directories — the list's `Edit(//<vault>/**)` names the write,
212
+ // this names the directory. Antigravity's adapter hands it over the same way.
213
+ if (profile === 'wiki') a.push('--add-dir', vaultDir);
153
214
  // `personalSettings` is the caller's copy when it has one (a test); a
154
- // turn reads the person's file here, at spawn, as the CLI would.
155
- a.push(...(fenced ? withPersonalSettings(perm, personalSettings ?? personalClaudeSettings()) : perm));
215
+ // turn reads the person's file here, at spawn, as the CLI would. What is
216
+ // kept is written to a private file, never onto argv (claudePersonal.mjs)
217
+ // — so this is the LAST thing built: nothing after it may throw and
218
+ // leave the file with no turn to release it.
219
+ const postured = machine ? withMachineMcp(perm, machineMcp) : perm;
220
+ a.push(...(fenced ? withPersonalSettings(postured, personalSettings ?? personalClaudeSettings()) : postured));
156
221
  return a;
157
222
  },
223
+ /** What `args` wrote for a turn — the fenced posture's settings file —
224
+ * removed when runTurn's child settles. */
225
+ releaseArgs: releaseTurnSettings,
158
226
  parse: null, // claudeStream.mjs owns its own stream parser (unchanged)
159
227
  };