flowviant 0.125.5 → 0.125.7

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 (101) hide show
  1. package/README.md +4 -5
  2. package/bin/lib/agentInternalWait.mjs +1 -5
  3. package/bin/lib/agentPlan.mjs +13 -163
  4. package/bin/lib/agentRepositories.mjs +3 -1
  5. package/bin/lib/claudeInput.mjs +89 -10
  6. package/bin/lib/claudeMachineMcp.mjs +85 -0
  7. package/bin/lib/claudePersonal.mjs +6 -57
  8. package/bin/lib/claudePosture.mjs +6 -15
  9. package/bin/lib/claudeSettingsArg.mjs +67 -0
  10. package/bin/lib/claudeStream.mjs +30 -1
  11. package/bin/lib/claudeSubagentProgress.mjs +12 -28
  12. package/bin/lib/claudeSubagents.mjs +4 -2
  13. package/bin/lib/codexContract.mjs +52 -0
  14. package/bin/lib/codexFencedMount.mjs +275 -0
  15. package/bin/lib/codexPersonal.mjs +1 -265
  16. package/bin/lib/codexProjectRead.mjs +5 -4
  17. package/bin/lib/codexResearch.mjs +9 -3
  18. package/bin/lib/codexSubagentEvents.mjs +11 -7
  19. package/bin/lib/codexSubagents.mjs +55 -52
  20. package/bin/lib/codexToml.mjs +328 -0
  21. package/bin/lib/config.mjs +22 -1
  22. package/bin/lib/conversationIds.mjs +10 -0
  23. package/bin/lib/fleet.mjs +2 -2
  24. package/bin/lib/git.mjs +32 -0
  25. package/bin/lib/heldTool.mjs +49 -0
  26. package/bin/lib/hooks/mcpAllow.mjs +11 -5
  27. package/bin/lib/hooks/orchestrator.mjs +20 -10
  28. package/bin/lib/intakeDraft.mjs +1 -1
  29. package/bin/lib/jsonlTail.mjs +52 -0
  30. package/bin/lib/mcpInventory.mjs +11 -6
  31. package/bin/lib/mcpReasons.mjs +26 -0
  32. package/bin/lib/mcpReportBounds.mjs +10 -0
  33. package/bin/lib/mcpStdio.mjs +35 -0
  34. package/bin/lib/mergeBatch.mjs +6 -24
  35. package/bin/lib/mergeOrder.mjs +18 -0
  36. package/bin/lib/modelJson.mjs +173 -0
  37. package/bin/lib/nativeAgentTools.mjs +72 -0
  38. package/bin/lib/orchestrator.mjs +221 -133
  39. package/bin/lib/orchestratorClient.mjs +17 -21
  40. package/bin/lib/orchestratorHooks.mjs +21 -4
  41. package/bin/lib/orchestratorNative.mjs +58 -0
  42. package/bin/lib/orchestratorPrompt.mjs +7 -4
  43. package/bin/lib/orchestratorProtocol.mjs +25 -3
  44. package/bin/lib/postureTools.mjs +58 -13
  45. package/bin/lib/prWorkflow.mjs +13 -2
  46. package/bin/lib/processGroup.mjs +41 -0
  47. package/bin/lib/projectRepositories.mjs +2 -2
  48. package/bin/lib/projectToolRuntimes.mjs +92 -245
  49. package/bin/lib/projectTools.mjs +60 -19
  50. package/bin/lib/prompts.mjs +47 -18
  51. package/bin/lib/reportJournal.mjs +61 -0
  52. package/bin/lib/repositoryDelivery.mjs +6 -4
  53. package/bin/lib/repositoryMerge.mjs +8 -12
  54. package/bin/lib/repositoryPlaces.mjs +2 -2
  55. package/bin/lib/repositoryPrompts.mjs +42 -0
  56. package/bin/lib/repositoryTool.mjs +9 -32
  57. package/bin/lib/researchUse.mjs +39 -0
  58. package/bin/lib/resumeLost.mjs +21 -1
  59. package/bin/lib/resumeMarker.mjs +43 -0
  60. package/bin/lib/runTurn.mjs +55 -52
  61. package/bin/lib/runtimeAntigravity.mjs +1 -1
  62. package/bin/lib/runtimeCapabilities.mjs +13 -18
  63. package/bin/lib/runtimeClaude.mjs +68 -24
  64. package/bin/lib/runtimeCodex.mjs +12 -6
  65. package/bin/lib/runtimeEvents.mjs +13 -2
  66. package/bin/lib/runtimeOfferings.mjs +12 -5
  67. package/bin/lib/runtimes.mjs +3 -1
  68. package/bin/lib/startProject.mjs +10 -0
  69. package/bin/lib/subagentBounds.mjs +20 -0
  70. package/bin/lib/trace.mjs +30 -5
  71. package/bin/lib/turnDeclaration.mjs +1 -1
  72. package/bin/lib/turnDiagnostics.mjs +9 -12
  73. package/bin/lib/turnReceipts.mjs +64 -0
  74. package/bin/lib/turnSubagents.mjs +91 -0
  75. package/bin/lib/turnUsage.mjs +9 -6
  76. package/bin/lib/update.mjs +18 -4
  77. package/bin/lib/versionOrder.mjs +11 -1
  78. package/bin/lib/work.mjs +9 -9
  79. package/bin/lib/workAgentMerges.mjs +5 -7
  80. package/bin/lib/workAgentPrecheck.mjs +5 -5
  81. package/bin/lib/workAgentRecovery.mjs +2 -1
  82. package/bin/lib/workAgentResume.mjs +121 -0
  83. package/bin/lib/workAgentTools.mjs +111 -0
  84. package/bin/lib/workAgentTurnExecution.mjs +82 -246
  85. package/bin/lib/workAgentTurnOutcome.mjs +30 -3
  86. package/bin/lib/workAgentTurnReports.mjs +16 -0
  87. package/bin/lib/workAgentTurnShape.mjs +90 -0
  88. package/bin/lib/workAgentTurns.mjs +28 -12
  89. package/bin/lib/workArtifactReconcile.mjs +3 -2
  90. package/bin/lib/workAttachments.mjs +27 -5
  91. package/bin/lib/workMergeBatch.mjs +7 -5
  92. package/bin/lib/workNarration.mjs +32 -0
  93. package/bin/lib/workPlaceReports.mjs +3 -3
  94. package/bin/lib/workPlaces.mjs +14 -0
  95. package/bin/lib/workReportQueue.mjs +18 -1
  96. package/bin/lib/workSessionRuntime.mjs +2 -5
  97. package/bin/lib/workSessionTurns.mjs +68 -80
  98. package/bin/lib/workShip.mjs +8 -18
  99. package/bin/lib/workTurnShape.mjs +6 -4
  100. package/bin/lib/worktreeDiff.mjs +29 -5
  101. package/package.json +1 -1
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A TURN'S ONE `--settings`, SPLICED AND READ IN ONE PLACE (2026-10-18, the
3
+ * SOLID pass). Three functions in two modules spliced the flag — the machine's
4
+ * MCP settings and the orchestrator's hooks (claudePosture.mjs), the person's
5
+ * kept keys (claudePersonal.mjs) — each repeating find, parse, merge, then
6
+ * splice or prepend, and the turn diagnostics read it a fourth way. The
7
+ * 2026-10-17 bug (the orchestrator's hooks on a second `--settings`, never
8
+ * loaded) is that class: the next thing a turn's settings carry merges here,
9
+ * into the one flag, and claudeSettingsArg.test.mjs keeps every other module
10
+ * from finding it for itself.
11
+ *
12
+ * THE PERSONAL STEP RUNS LAST. claudePersonal.mjs `withPersonalSettings` turns
13
+ * the value into the path of a private file (the person's keys never ride
14
+ * argv), so nothing merges after it: `replaceSettings` reads inline JSON only
15
+ * and refuses a path in words — merging into that file would put the person's
16
+ * secrets back on the command line. `settingsAt`, the diagnostics' reader,
17
+ * reads either.
18
+ */
19
+ import { readFileSync } from 'node:fs';
20
+
21
+ /** The turn's `--settings`: where it stands (-1 for none) and what it holds —
22
+ * null with no flag, else the inline JSON or the file it names, parsed. May
23
+ * throw (a file gone, a value that does not parse); the caller decides. */
24
+ export function settingsAt(args) {
25
+ const at = args.indexOf('--settings');
26
+ if (at < 0) return { at, settings: null };
27
+ const input = args[at + 1];
28
+ return { at, settings: JSON.parse(input.startsWith('{') ? input : readFileSync(input, 'utf8')) };
29
+ }
30
+
31
+ /** An inline value, parsed — never a path (see the header). */
32
+ function inline(value) {
33
+ if (typeof value !== 'string' || !value.startsWith('{')) {
34
+ throw new Error("this turn's --settings already names a file; nothing merges after the personal settings");
35
+ }
36
+ return JSON.parse(value);
37
+ }
38
+
39
+ /**
40
+ * `perm` with its one `--settings` replaced by `fn(posture)` — `posture` the
41
+ * flag's own object, `{}` where there is none — standing where the flag stood,
42
+ * or before the lists when there was none (`--allowedTools` is variadic).
43
+ */
44
+ export function replaceSettings(perm, fn) {
45
+ const at = perm.indexOf('--settings');
46
+ const value = at < 0 ? fn({}) : fn(inline(perm[at + 1]));
47
+ return at < 0 ? ['--settings', value, ...perm] : [...perm.slice(0, at), '--settings', value, ...perm.slice(at + 2)];
48
+ }
49
+
50
+ /**
51
+ * ONE `--settings` PER TURN (2026-10-17). Claude Code applies only the LAST
52
+ * `--settings` it is handed (measured, 2.1.302: two flags each holding a
53
+ * PreToolUse hook ran the second hook alone). The card orchestrator's hooks
54
+ * rode a flag of their own ahead of the machine-MCP settings (claudePosture.mjs
55
+ * `withMachineMcp`), so on every code card that mounts the machine's servers
56
+ * the spawn gate, child registration and checkpoints never loaded
57
+ * (claudeOrchestratorHooks.test.mjs
58
+ * re-measures it on the real CLI). `extra` hooks join the posture's per event,
59
+ * after its own; its other keys win over the posture's.
60
+ */
61
+ export function withSettings(perm, { hooks: extraHooks = {}, ...extra }) {
62
+ return replaceSettings(perm, (posture) => {
63
+ const hooks = { ...(posture.hooks ?? {}) };
64
+ for (const [event, rows] of Object.entries(extraHooks)) hooks[event] = [...(hooks[event] ?? []), ...rows];
65
+ return JSON.stringify({ ...posture, ...extra, hooks });
66
+ });
67
+ }
@@ -79,8 +79,28 @@ export function usageFromResult(ev) {
79
79
  // container that reports no spend, which is indistinguishable from an older
80
80
  // daemon by design. A callback that is silent when it breaks has to be called
81
81
  // directly by something.
82
+ /**
83
+ * One TaskCreate/TaskUpdate result, applied to a turn's task list (`tasks`, a
84
+ * Map of id → `{ content, status }` in creation order). True when the list
85
+ * changed. A task an earlier turn created is not on this turn's list, and an
86
+ * update to it changes nothing here: its subject was never said.
87
+ */
88
+ export function taskPlanChanged(tasks, result) {
89
+ if (!result || typeof result !== 'object') return false;
90
+ const made = result.task;
91
+ if (made && typeof made.id === 'string' && typeof made.subject === 'string' && made.subject.trim()) {
92
+ tasks.set(made.id, { content: made.subject, status: 'pending' });
93
+ return true;
94
+ }
95
+ const to = result.statusChange?.to;
96
+ if (typeof result.taskId !== 'string' || typeof to !== 'string' || !tasks.has(result.taskId)) return false;
97
+ if (to === 'deleted') tasks.delete(result.taskId);
98
+ else tasks.set(result.taskId, { ...tasks.get(result.taskId), status: to });
99
+ return true;
100
+ }
101
+
82
102
  export function handleStreamLine(line, options) {
83
- const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents } = options;
103
+ const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks } = options;
84
104
  let ev;
85
105
  try {
86
106
  ev = JSON.parse(line);
@@ -164,6 +184,15 @@ export function handleStreamLine(line, options) {
164
184
  }
165
185
  }
166
186
  } else if (ev.type === 'user' && Array.isArray(ev.message?.content)) {
187
+ // THE TASK LIST IS THE PLAN NOW (2026-10-19). Claude Code 2.1.303 offers
188
+ // TaskCreate/TaskUpdate and no TodoWrite (measured: the tools a `-p` turn
189
+ // is handed), so the plan card — built from TodoWrite — never drew. Each
190
+ // result says what it did (`tool_use_result`: the created task's id and
191
+ // subject, or a task's status change), so the turn's own list is kept here
192
+ // and handed on in TodoWrite's shape, which the card already reads.
193
+ if (!agentId && tasks && taskPlanChanged(tasks, ev.tool_use_result)) {
194
+ onToolEvent?.('TodoWrite', { todos: [...tasks.values()] }, { id: 'claude-task-list' });
195
+ }
167
196
  for (const b of ev.message.content) {
168
197
  if (b.type === 'tool_result') onToolEvent?.(null, {}, {
169
198
  id: b.tool_use_id, result: true, isError: b.is_error === true,
@@ -6,11 +6,11 @@ import { homedir } from 'node:os';
6
6
  import { join } from 'node:path';
7
7
  import { claudeProjectDir } from './claudeSessions.mjs';
8
8
  import { CLAUDE_SESSION_RE } from './conversationIds.mjs';
9
+ import { followJsonl, newTail } from './jsonlTail.mjs';
9
10
 
10
- const MAX_READ = 4 * 1024 * 1024;
11
- const LINE_MAX = 256 * 1024;
12
11
  export function createClaudeSubagentProgress({ cwd, since = Date.now(), env = process.env, onLine }) {
13
- let file, offset = null, rest = Buffer.alloc(0), skip = false, timer;
12
+ const tail = newTail();
13
+ let file, timer;
14
14
  let chain = Promise.resolve();
15
15
  const flush = () => {
16
16
  chain = chain.then(async () => {
@@ -18,31 +18,15 @@ export function createClaudeSubagentProgress({ cwd, since = Date.now(), env = pr
18
18
  let fh;
19
19
  try {
20
20
  fh = await open(file, 'r');
21
- const { size } = await fh.stat();
22
- if (offset === null) { offset = Math.max(0, size - MAX_READ); skip = offset > 0; }
23
- if (size < offset) { offset = 0; rest = Buffer.alloc(0); }
24
- const end = Math.min(size, offset + MAX_READ);
25
- while (offset < end) {
26
- const buf = Buffer.alloc(Math.min(LINE_MAX, end - offset));
27
- const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
28
- if (!bytesRead) break;
29
- offset += bytesRead;
30
- const data = Buffer.concat([rest, buf.subarray(0, bytesRead)]);
31
- let start = 0, nl;
32
- while ((nl = data.indexOf(10, start)) >= 0) {
33
- const line = data.subarray(start, nl).toString('utf8'); start = nl + 1;
34
- if (skip) { skip = false; continue; }
35
- try {
36
- const ev = JSON.parse(line);
37
- if (ev.type !== 'progress' || ev.data?.type !== 'agent_progress' || !ev.parentToolUseID) continue;
38
- const at = Date.parse(ev.timestamp ?? '');
39
- if (!Number.isFinite(at) || at < since) continue;
40
- onLine(line);
41
- } catch { /* torn or unknown native row */ }
42
- }
43
- rest = data.subarray(start);
44
- if (rest.length > LINE_MAX) { rest = Buffer.alloc(0); skip = true; }
45
- }
21
+ await followJsonl(fh, tail, line => {
22
+ try {
23
+ const ev = JSON.parse(line);
24
+ if (ev.type !== 'progress' || ev.data?.type !== 'agent_progress' || !ev.parentToolUseID) return;
25
+ const at = Date.parse(ev.timestamp ?? '');
26
+ if (!Number.isFinite(at) || at < since) return;
27
+ onLine(line);
28
+ } catch { /* torn or unknown native row */ }
29
+ });
46
30
  } catch { /* the transcript can appear after init */ }
47
31
  finally { await fh?.close().catch(() => {}); }
48
32
  }).catch(() => {});
@@ -1,3 +1,5 @@
1
+ import { NATIVE_AGENT_TOOLS } from './nativeAgentTools.mjs';
2
+
1
3
  /** Native tool-use ids are stable for the lifetime of an Agent/Task call. */
2
4
  export function createClaudeSubagents(report = () => {}) {
3
5
  const calls = new Map();
@@ -29,12 +31,12 @@ export function createClaudeSubagents(report = () => {}) {
29
31
  const agentId = identify(ev);
30
32
  if (ev.type === 'assistant') {
31
33
  for (const b of ev.message?.content ?? []) {
32
- if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && agentId) {
34
+ if (b.type === 'tool_use' && NATIVE_AGENT_TOOLS.claude.ask.includes(b.name) && agentId) {
33
35
  questions.set(b.id, agentId);
34
36
  if (questions.size > 128) questions.delete(questions.keys().next().value);
35
37
  report({ id: agentId, status: 'asked' });
36
38
  }
37
- if (b.type !== 'tool_use' || !['Agent', 'Task'].includes(b.name) || !b.id) continue;
39
+ if (b.type !== 'tool_use' || !NATIVE_AGENT_TOOLS.claude.spawn.includes(b.name) || !b.id) continue;
38
40
  const input = b.input ?? {};
39
41
  const id = aliases.get(input.resume) ?? b.id;
40
42
  calls.set(b.id, { id, input });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A CODEX TAB'S CONTRACT, SENT WHEN IT IS NEWS (2026-10-19).
3
+ *
4
+ * Codex has no system prompt for a resumed `exec`: the tab's contract rode at
5
+ * the head of EVERY turn's message, so each turn added its ~6 KB to the thread
6
+ * again — measured offline on 0.163, the third turn's request carried it three
7
+ * times — and a long tab compacted early, on copies of the same instructions.
8
+ * `developer_instructions` is no way out: a resumed thread keeps the ones it
9
+ * started with and ignores a changed value (measured the same way), and the
10
+ * contract does change (the knowledge and artifact paragraphs).
11
+ *
12
+ * So a resumed thread is sent the contract only when it is news: a thread this
13
+ * marker has not seen, a contract whose text changed, or every
14
+ * `CONTRACT_RESEND_EVERY`th turn — a bound on what a compaction may have
15
+ * summarised away. A fresh thread is always sent it. The marker sits beside
16
+ * the tab's thread marker, scoped by session, owner-only, best-effort: an
17
+ * unreadable one sends the contract, as before.
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { readFileSync, writeFileSync } from 'node:fs';
21
+
22
+ export const CODEX_CONTRACT_MARKER = 'flowviant-codex-contract';
23
+ export const CONTRACT_RESEND_EVERY = 8;
24
+
25
+ const digest = (text) => createHash('sha256').update(String(text ?? '')).digest('hex');
26
+ const read = (path) => {
27
+ try {
28
+ return JSON.parse(readFileSync(path, 'utf8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ /** Does the resumed `thread` already hold this `system` text, recently? */
35
+ export function codexContractKnown(path, thread, system) {
36
+ if (!path || !thread) return false;
37
+ const m = read(path);
38
+ return m?.thread === thread && m.hash === digest(system) && Number.isInteger(m.since) && m.since < CONTRACT_RESEND_EVERY;
39
+ }
40
+
41
+ /** After a turn: what `thread` now holds, and how many turns since it was
42
+ * last sent the contract (`sent`: whether this turn carried it). */
43
+ export function recordCodexContract(path, thread, system, sent) {
44
+ if (!path || !thread) return;
45
+ const m = sent ? null : read(path);
46
+ const since = !sent && m?.thread === thread && Number.isInteger(m.since) ? m.since + 1 : 0;
47
+ try {
48
+ writeFileSync(path, JSON.stringify({ thread, hash: digest(system), since }), { mode: 0o600 });
49
+ } catch {
50
+ /* best-effort: the next turn sends the contract */
51
+ }
52
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * THE MCP SERVERS ON A FENCED CODEX TURN, AS `-c` PAIRS — the project's and
3
+ * the machine's own (`codexFencedMount`, below). Split out of
4
+ * projectToolRuntimes.mjs (2026-10-17), whose table row hands it out as
5
+ * `fencedMount`; the build's config.toml stays there.
6
+ */
7
+ import { readFileSync, statSync } from 'node:fs';
8
+ import { join, resolve } from 'node:path';
9
+ import { codexHome } from './codexHome.mjs';
10
+ import { parseCodexToml, TomlRaw, toml, tomlValue } from './codexToml.mjs';
11
+
12
+ /** The environment names a fenced Codex turn's MCP header values ride under,
13
+ * one per header (`codexFencedMount`). */
14
+ export const MCP_HEADER_ENV_PREFIX = 'FLOWVIANT_MCP_HEADER_';
15
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
16
+ export const SERVER_NAME = /^[A-Za-z0-9_-]{1,80}$/;
17
+ const expands = (value) => /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(String(value));
18
+ /**
19
+ * Names this machine's Codex, or the shell and tools under it, read for
20
+ * themselves. A server's env value rides in the CLI's own environment
21
+ * (`env_vars` forwards it), so a name the machine does not already set to that
22
+ * value would change how the CLI itself runs — a proxy, a CA bundle, a
23
+ * provider key. Such a server is left unmounted, in words.
24
+ */
25
+ const CLI_ENV =
26
+ /^(PATH|HOME|SHELL|USER|LOGNAME|PWD|TMPDIR|TMP|TEMP|TERM|LANG|LANGUAGE|DISPLAY|WAYLAND_DISPLAY|BROWSER|NODE_OPTIONS|NODE_PATH|NODE_EXTRA_CA_CERTS|SSL_CERT_FILE|SSL_CERT_DIR|REQUESTS_CA_BUNDLE|RUST_LOG|RUST_BACKTRACE|HTTPS?_PROXY|ALL_PROXY|NO_PROXY|https?_proxy|all_proxy|no_proxy)$|^(LC|XDG|LD|DYLD|OPENAI|CODEX|AZURE_OPENAI|FLOWVIANT)_/;
27
+
28
+ /** This machine's own Codex servers: its config.toml's `[mcp_servers.*]`,
29
+ * read the way codexPersonal.mjs reads the login (bounded; a file that does
30
+ * not parse carries nothing). `[]` when there is no file, NULL when there is
31
+ * one this cannot read (2026-10-17: unknown, never "none"). */
32
+ function machineCodexServers(machineEnv) {
33
+ let text;
34
+ try {
35
+ const path = join(resolve(codexHome(machineEnv)), 'config.toml');
36
+ const st = statSync(path);
37
+ if (!st.isFile() || st.size > 1024 * 1024) return null;
38
+ text = readFileSync(path, 'utf8');
39
+ } catch (error) {
40
+ return error?.code === 'ENOENT' ? [] : null;
41
+ }
42
+ try {
43
+ const servers = parseCodexToml(text)?.mcp_servers;
44
+ if (servers === undefined) return [];
45
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return null;
46
+ return Object.entries(servers).filter(([, v]) => v && typeof v === 'object' && !Array.isArray(v));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+ const APPROVAL_MODES = new Set(['auto', 'prompt', 'writes', 'approve']);
52
+ const tomlScalar = (v) => (v instanceof TomlRaw ? v.text : typeof v === 'number' && Number.isFinite(v) ? String(v) : null);
53
+ const words = (v) => (Array.isArray(v) && v.every((x) => typeof x === 'string') ? v : null);
54
+ const table = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
55
+
56
+ /**
57
+ * THE MCP SERVERS ON A FENCED CODEX TURN — the project's and, since 2026-10-11,
58
+ * the machine's own (the owner: "the server that is running flowviant's claude
59
+ * HAS these MCPs yet the agents are not using them"). A Codex build reads both
60
+ * from the agent's config.toml (`isolatedCodexHome`: the person's own tables
61
+ * kept, the repo's beside them). Every fenced posture — write-up, mockup,
62
+ * 3D model, deck, image — passes `--ignore-user-config`, which drops that
63
+ * file, and a `-p` profile layered beside the flag was measured to load no
64
+ * server either (codex-cli 0.159.2, artifactProjectMcp.test.mjs). So these
65
+ * turns take the servers as `-c` pairs, the way codexPersonal.mjs carries a
66
+ * login back, under the same rule: A LITERAL SECRET NEVER RIDES ARGV.
67
+ *
68
+ * · A stdio server's command and arguments ride as written: they are the
69
+ * server process's own command line once Codex starts it, so `-c` adds no
70
+ * reader. A project server whose command or arguments expand a `${VAR}`
71
+ * is still left unmounted (the 2026-10-10 audit took expanded values off
72
+ * argv, and this keeps that line).
73
+ * · Every env value rides in the turn's ENVIRONMENT under its own name,
74
+ * forwarded by Codex's `env_vars` — except a project value with no `${VAR}`,
75
+ * which is text of the reviewed file and rides as written. Refused (the
76
+ * server is left unmounted, with the reason) when the name is the
77
+ * daemon's own (`FLOWVIANT_*`, `CODEX_HOME`), when this machine already
78
+ * sets it to something else, or when it is one the CLI reads itself
79
+ * (`CLI_ENV`) and the machine does not set it.
80
+ * · Every header value rides in the environment, named by
81
+ * `env_http_headers`. A machine server's own `env_http_headers`,
82
+ * `bearer_token_env_var` and `env_vars` are names, and ride as written.
83
+ * · A URL that expands a `${VAR}`, or carries a query, fragment or
84
+ * credentials, has no indirection in Codex's config: left unmounted.
85
+ * · A machine server keeps its own `cwd`, timeouts, tool lists, header
86
+ * helper (a command, riding as written, as a stdio command does), OAuth
87
+ * scopes, resource and settings — all but `oauth.client_secret`, which
88
+ * stays off argv and is said; it is skipped where the person turned it
89
+ * off (`enabled = false`), and a project server of the same name replaces
90
+ * it, as on the build. The person's own `flowviant` rides too: a card
91
+ * mints no Flowviant server of its own (hooks/mcpAllow.mjs, 2026-10-17).
92
+ * · Each mounted server's tools are approved by name: an unannotated tool
93
+ * under a fenced posture failed "MCP tool call requires approval, but
94
+ * approval policy is never" (measured, 0.159.2) — never a blanket
95
+ * approval policy. A machine server keeps the person's own modes, its
96
+ * default and each tool's (2026-10-17: an unconditional approval
97
+ * overrode a person's "prompt"), and is approved only where they set
98
+ * none; one that asks before every call is left unmounted, and one with
99
+ * tools that ask is mounted and said (`partial`), since nobody can
100
+ * approve on this turn (measured, 0.163.0: a tool's mode beats its
101
+ * server's, and "auto", "writes" and "prompt" each refuse an unannotated
102
+ * tool there).
103
+ *
104
+ * `usable` is the base snapshot's ready entries, `turnServers` the card's
105
+ * own (prepareAgentTools); `expand` reads a value the
106
+ * way projectTools.mjs does; `machineEnv` is the caller's own (what the child
107
+ * would inherit). Returns `{ args, env, mounted, machine, unmounted }`:
108
+ * `mounted` the project servers, `machine` the machine's own (null when its
109
+ * config.toml could not be read), and
110
+ * `unmounted` `[{ name, reason, source: 'project' | 'machine', partial? }]`.
111
+ */
112
+ export function codexFencedMount(usable, expand, machineEnv, turnServers = {}) {
113
+ const args = [];
114
+ const childEnv = {};
115
+ const mounted = [];
116
+ const machine = [];
117
+ const unmounted = [];
118
+ let header = 0;
119
+ /** A refusal sentence, or null with the value staged in `extra`. */
120
+ const forward = (key, value, extra) => {
121
+ if (key.startsWith('FLOWVIANT_') || key === 'CODEX_HOME') return `its env ${key} is a name this daemon keeps for itself`;
122
+ const has = machineEnv[key];
123
+ if (has !== undefined) return has === value ? null : `its env ${key} would replace this machine's own ${key}, which a fenced Codex turn cannot do`;
124
+ if (CLI_ENV.test(key)) return `its env ${key} is one this machine's Codex reads itself, which a fenced Codex turn cannot set`;
125
+ const staged = childEnv[key] ?? extra[key];
126
+ if (staged !== undefined && staged !== value) return `its env ${key} would replace another server's ${key}`;
127
+ extra[key] = value;
128
+ return null;
129
+ };
130
+ const headerEnv = (headers, extra) =>
131
+ Object.entries(headers).map(([key, value]) => {
132
+ const envName = `${MCP_HEADER_ENV_PREFIX}${header++}`;
133
+ extra[envName] = value;
134
+ return `${toml(key)}=${toml(envName)}`;
135
+ });
136
+ const approve = (name) => ['-c', `mcp_servers.${name}.default_tools_approval_mode="approve"`];
137
+ const accept = (name, pairs, extra, list, approval = approve(name)) => {
138
+ args.push(...pairs, ...approval);
139
+ Object.assign(childEnv, extra);
140
+ list.push(name);
141
+ };
142
+
143
+ // THE CARD'S OWN SERVERS (2026-10-17) — `card_repositories` on a
144
+ // multi-repository card — which rode only the home's config.toml, ignored
145
+ // here. The daemon's words; their env by name (the values are the turn's
146
+ // environment already), and no project or machine server of their name.
147
+ const owned = Object.entries(turnServers ?? {}).filter(([name, c]) => SERVER_NAME.test(name) && typeof c?.command === 'string');
148
+ for (const [name, config] of owned) {
149
+ const keys = Object.keys(config.env ?? {}).filter((k) => ENV_NAME.test(k));
150
+ const pairs = ['-c', `mcp_servers.${name}.command=${toml(config.command)}`, '-c', `mcp_servers.${name}.args=[${(Array.isArray(config.args) ? config.args : []).map((v) => toml(String(v))).join(',')}]`];
151
+ if (keys.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${keys.map(toml).join(',')}]`);
152
+ accept(name, pairs, Object.fromEntries(keys.map((k) => [k, String(config.env[k])])), []);
153
+ }
154
+ const ownNames = new Set(owned.map(([name]) => name));
155
+
156
+ for (const { name, config } of usable) {
157
+ if (ownNames.has(name)) continue;
158
+ const pairs = [];
159
+ const extra = {};
160
+ let refused = null;
161
+ if (typeof config.command === 'string') {
162
+ const rawArgs = Array.isArray(config.args) ? config.args : [];
163
+ if (expands(config.command) || rawArgs.some(expands)) {
164
+ refused = 'its command or arguments name a ${VAR}, which a fenced Codex turn would put on the command line';
165
+ } else {
166
+ pairs.push('-c', `mcp_servers.${name}.command=${toml(String(config.command))}`);
167
+ pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map((v) => toml(String(v))).join(',')}]`);
168
+ const forwarded = [];
169
+ for (const [key, value] of Object.entries(config.env ?? {})) {
170
+ if (!ENV_NAME.test(key)) continue;
171
+ if (!expands(value)) {
172
+ pairs.push('-c', `mcp_servers.${name}.env.${key}=${toml(String(value))}`);
173
+ continue;
174
+ }
175
+ refused = forward(key, expand(value), extra);
176
+ if (refused) break;
177
+ forwarded.push(key);
178
+ }
179
+ if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
180
+ }
181
+ } else if (typeof config.url === 'string') {
182
+ if (expands(config.url)) {
183
+ refused = 'its URL names a ${VAR}, which a fenced Codex turn would put on the command line';
184
+ } else {
185
+ pairs.push('-c', `mcp_servers.${name}.url=${toml(String(config.url))}`);
186
+ const named = headerEnv(Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v)])), extra);
187
+ if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
188
+ }
189
+ } else continue;
190
+ if (refused) unmounted.push({ name, reason: refused, source: 'project' });
191
+ else accept(name, pairs, extra, mounted);
192
+ }
193
+
194
+ const projectNames = new Set([...usable.map((t) => t.name), ...ownNames]);
195
+ const personal = machineCodexServers(machineEnv);
196
+ for (const [name, server] of personal ?? []) {
197
+ if (projectNames.has(name) || server.enabled === false) continue;
198
+ if (typeof server.command !== 'string' && typeof server.url !== 'string') continue;
199
+ const pairs = [];
200
+ const extra = {};
201
+ const partial = [];
202
+ let refused = SERVER_NAME.test(name) ? null : 'its name cannot be spelled as a command-line setting';
203
+ if (!refused && typeof server.command === 'string') {
204
+ const rawArgs = server.args === undefined ? [] : words(server.args);
205
+ if (!rawArgs) refused = 'its args are not a list of words';
206
+ else {
207
+ pairs.push('-c', `mcp_servers.${name}.command=${toml(server.command)}`);
208
+ pairs.push('-c', `mcp_servers.${name}.args=[${rawArgs.map(toml).join(',')}]`);
209
+ const forwarded = [...(words(server.env_vars) ?? []).filter((k) => ENV_NAME.test(k))];
210
+ for (const [key, value] of Object.entries(table(server.env))) {
211
+ if (!ENV_NAME.test(key) || typeof value !== 'string') {
212
+ refused = `its env ${String(key).slice(0, 40)} is not a name and text`;
213
+ break;
214
+ }
215
+ refused = forward(key, value, extra);
216
+ if (refused) break;
217
+ if (!forwarded.includes(key)) forwarded.push(key);
218
+ }
219
+ if (forwarded.length) pairs.push('-c', `mcp_servers.${name}.env_vars=[${forwarded.map(toml).join(',')}]`);
220
+ if (typeof server.cwd === 'string') pairs.push('-c', `mcp_servers.${name}.cwd=${toml(server.cwd)}`);
221
+ }
222
+ } else if (!refused) {
223
+ let url = null;
224
+ try { url = new URL(server.url); } catch { /* refused below */ }
225
+ if (!url || url.search || url.hash || url.username || url.password) {
226
+ refused = 'its URL carries a query, fragment or credentials, which a fenced Codex turn would put on the command line';
227
+ } else {
228
+ pairs.push('-c', `mcp_servers.${name}.url=${toml(server.url)}`);
229
+ if (typeof server.bearer_token_env_var === 'string' && ENV_NAME.test(server.bearer_token_env_var))
230
+ pairs.push('-c', `mcp_servers.${name}.bearer_token_env_var=${toml(server.bearer_token_env_var)}`);
231
+ const literal = table(server.http_headers);
232
+ const byName = table(server.env_http_headers);
233
+ if (Object.values(literal).some((v) => typeof v !== 'string') || Object.values(byName).some((v) => typeof v !== 'string' || !ENV_NAME.test(v))) {
234
+ refused = 'its headers are not text';
235
+ } else {
236
+ const named = [...headerEnv(literal, extra), ...Object.entries(byName).map(([key, envName]) => `${toml(key)}=${toml(envName)}`)];
237
+ if (named.length) pairs.push('-c', `mcp_servers.${name}.env_http_headers={${named.join(',')}}`);
238
+ if (typeof server.http_headers_helper === 'string') pairs.push('-c', `mcp_servers.${name}.http_headers_helper=${toml(server.http_headers_helper)}`);
239
+ if (words(server.scopes)) pairs.push('-c', `mcp_servers.${name}.scopes=[${server.scopes.map(toml).join(',')}]`);
240
+ if (typeof server.oauth_resource === 'string') pairs.push('-c', `mcp_servers.${name}.oauth_resource=${toml(server.oauth_resource)}`);
241
+ const oauth = Object.entries(table(server.oauth)).filter(([key, v]) => key !== 'client_secret' && (typeof v === 'string' || typeof v === 'boolean' || tomlScalar(v) != null));
242
+ if (oauth.length) pairs.push('-c', `mcp_servers.${name}.oauth=${tomlValue(Object.fromEntries(oauth))}`);
243
+ if (Object.hasOwn(table(server.oauth), 'client_secret')) partial.push('its OAuth client secret stays off this turn’s command line, so a sign-in or refresh that needs it fails');
244
+ }
245
+ }
246
+ }
247
+ // The person's approval modes, as written; approved where they set none.
248
+ const mode = APPROVAL_MODES.has(server.default_tools_approval_mode) ? server.default_tools_approval_mode : null;
249
+ const tools = Object.entries(table(server.tools)).filter(([, v]) => APPROVAL_MODES.has(table(v).approval_mode));
250
+ const approval = ['-c', `mcp_servers.${name}.default_tools_approval_mode=${toml(mode ?? 'approve')}`];
251
+ if (tools.length) approval.push('-c', `mcp_servers.${name}.tools=${tomlValue(Object.fromEntries(tools.map(([tool, v]) => [tool, { approval_mode: v.approval_mode }])))}`);
252
+ if (!refused && mode === 'prompt' && !tools.some(([, v]) => v.approval_mode === 'approve')) {
253
+ refused = 'it asks for approval before every tool call (default_tools_approval_mode = "prompt"), and nobody can approve one on this turn';
254
+ }
255
+ const asking = tools.filter(([, v]) => v.approval_mode !== 'approve').map(([tool]) => tool);
256
+ if (mode && mode !== 'approve') partial.unshift(`its tools ask for approval (default_tools_approval_mode = "${mode}"), and a call that asks fails here, where nobody can approve one`);
257
+ else if (asking.length) partial.unshift(`its tools ${asking.join(', ')} ask for approval, and a call to one fails here, where nobody can approve one`);
258
+ if (!refused) {
259
+ for (const key of ['startup_timeout_sec', 'startup_timeout_ms', 'tool_timeout_sec']) {
260
+ const v = tomlScalar(server[key]);
261
+ if (v != null && /^[0-9_.]+$/.test(v)) pairs.push('-c', `mcp_servers.${name}.${key}=${v}`);
262
+ }
263
+ for (const key of ['enabled_tools', 'disabled_tools']) {
264
+ const list = words(server[key]);
265
+ if (list) pairs.push('-c', `mcp_servers.${name}.${key}=[${list.map(toml).join(',')}]`);
266
+ }
267
+ }
268
+ if (refused) unmounted.push({ name, reason: refused, source: 'machine' });
269
+ else {
270
+ accept(name, pairs, extra, machine, approval);
271
+ if (partial.length) unmounted.push({ name, reason: partial.join('; '), source: 'machine', partial: true });
272
+ }
273
+ }
274
+ return { args, env: childEnv, mounted, machine: personal == null ? null : machine, unmounted };
275
+ }