flowviant 0.116.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +71 -139
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ONE READER OF `/proc/<pid>/stat` (audit 2026-10-10, F050/F089).
3
+ *
4
+ * Six modules used to parse this line by hand, each counting its own field
5
+ * index from "rest[0] is field 3" in a comment. The parse is the whole trap:
6
+ * field 2 is `comm`, which the kernel wraps in parens and which may contain
7
+ * BOTH spaces and parens — it is the first 15 bytes of the executable's name,
8
+ * and a name is not a token. A naive whitespace split taking $22 lands on
9
+ * `nice` for any such process, and `nice` is a small integer, so the answer
10
+ * is not a parse error but a plausible-looking "started at boot" that sails
11
+ * past any isFinite guard. Measured: a binary named `ev (i l) x` read 28308s
12
+ * too old that way (instance.mjs, which gates a SIGTERM and then a SIGKILL on
13
+ * this read).
14
+ *
15
+ * Every field after `comm` is a number or a single-character state, so no ')'
16
+ * can appear later: the LAST ')' in the line is always the kernel's own
17
+ * closing paren, even when comm itself ends in one. Fields are NAMED here so
18
+ * no caller counts again.
19
+ *
20
+ * Linux only, and a leaf: node builtins, nothing of ours. Null on any read or
21
+ * shape failure — which every caller already read as "cannot tell".
22
+ */
23
+
24
+ import { readFileSync } from 'node:fs';
25
+
26
+ /** Index of each named field in the list AFTER `comm` (index 0 is field 3). */
27
+ const FIELD = Object.freeze({ state: 0, ppid: 1, pgrp: 2, session: 3, tpgid: 5, starttime: 19 });
28
+
29
+ /** A kernel integer token: digits, with tpgid's `-1` for "no terminal". */
30
+ const int = (token) => (/^-?\d+$/.test(token ?? '') ? Number(token) : null);
31
+
32
+ /**
33
+ * The named fields of one `/proc/<pid>/stat` line, or null when the line has
34
+ * no `comm` close or is too short to carry `starttime` (field 22).
35
+ */
36
+ export function parseProcStat(text) {
37
+ if (typeof text !== 'string') return null;
38
+ const close = text.lastIndexOf(')');
39
+ if (close < 0) return null;
40
+ const rest = text.slice(close + 1).trim().split(/\s+/);
41
+ if (rest.length <= FIELD.starttime) return null;
42
+ const out = { state: rest[FIELD.state] };
43
+ for (const name of ['ppid', 'pgrp', 'session', 'tpgid', 'starttime']) {
44
+ const value = int(rest[FIELD[name]]);
45
+ if (value === null) return null;
46
+ out[name] = value;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** A pid this reader will put in a path: a positive integer, its digit
52
+ * string (what a /proc listing hands back), or 'self'. */
53
+ function procName(pid) {
54
+ if (pid === 'self') return 'self';
55
+ if (Number.isSafeInteger(pid) && pid > 0) return String(pid);
56
+ if (typeof pid === 'string' && /^\d+$/.test(pid) && Number(pid) > 0) return pid;
57
+ return null;
58
+ }
59
+
60
+ /**
61
+ * `{ state, ppid, pgrp, session, tpgid, starttime }` for `pid` (or 'self'),
62
+ * or null: no such process, no /proc (macOS, hidepid=2, a pid namespace), or
63
+ * a line this parser does not recognise. `starttime` is in clock ticks since
64
+ * boot (USER_HZ); `tpgid` is -1 without a controlling terminal.
65
+ */
66
+ export function procStat(pid, { read = readFileSync } = {}) {
67
+ const name = procName(pid);
68
+ if (!name) return null;
69
+ let text;
70
+ try {
71
+ text = read(`/proc/${name}/stat`, 'utf8');
72
+ } catch {
73
+ return null;
74
+ }
75
+ return parseProcStat(String(text));
76
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * SIGNAL A DETACHED CHILD'S WHOLE PROCESS GROUP, FALLING BACK TO THE CHILD
3
+ * (2026-10-10, the audit's F086 / F145) — the one home of a block that was
4
+ * hand-written six times: the project check's timeout, the teardown of a
5
+ * check, the deploy command's timeout, the async network git call's timeout,
6
+ * a preview tunnel's stop and the reap of an orphaned tunnel.
7
+ *
8
+ * Why the GROUP: the daemon spawns these `detached`, so the child's pid is its
9
+ * process group, and the child is usually a shell (`shell: true`) or a tool
10
+ * that starts helpers (git's ssh, cloudflared). Signalling the child alone
11
+ * leaves what it started running — holding the worktree, its writer lock, or
12
+ * a pipe the caller is waiting on (workChildren.mjs tells the incident).
13
+ *
14
+ * Why the FALLBACK: a group that has already gone, a pid that is not a group
15
+ * leader, and a platform with no process groups (Windows throws on a negative
16
+ * pid) all throw on the group signal; the child itself is then signalled.
17
+ *
18
+ * NEVER A GROUP THE DAEMON DID NOT START: a pid that is not an integer above 1
19
+ * signals nothing by number — `kill(-1)` is every process the user owns, and
20
+ * `kill(0)` is the daemon's own group. Import-free, so git.mjs can use it.
21
+ *
22
+ * @param {import('node:child_process').ChildProcess | number} target
23
+ * @param {NodeJS.Signals} [signal]
24
+ * @returns {boolean} whether a signal was delivered
25
+ */
26
+ export function killGroup(target, signal = 'SIGKILL') {
27
+ const child = target !== null && typeof target === 'object' ? target : null;
28
+ const pid = child ? child.pid : target;
29
+ const leader = Number.isInteger(pid) && pid > 1;
30
+ if (leader) {
31
+ try {
32
+ process.kill(-pid, signal);
33
+ return true;
34
+ } catch {
35
+ /* gone, not a leader, or no groups here — the process itself next */
36
+ }
37
+ }
38
+ try {
39
+ if (child) return child.kill(signal) === true;
40
+ if (leader) {
41
+ process.kill(pid, signal);
42
+ return true;
43
+ }
44
+ } catch {
45
+ /* already gone */
46
+ }
47
+ return false;
48
+ }
@@ -47,6 +47,7 @@ import { execFileSync } from 'node:child_process';
47
47
  import { readFileSync } from 'node:fs';
48
48
  import { platform } from 'node:os';
49
49
  import { procPids, scanProcs } from './procScan.mjs';
50
+ import { procStat } from './procStat.mjs';
50
51
 
51
52
  // THE WALK'S BOUND is `procScan.mjs`'s: matched rows collected before the walk
52
53
  // stops early — a bound on the WORK done for one pathological group, never on
@@ -69,21 +70,13 @@ export function processesSupported() {
69
70
 
70
71
  /**
71
72
  * `pid (comm) state ppid pgrp …` — comm is arbitrary text and CAN contain a
72
- * `)`, so the fields are read after the LAST one rather than by splitting the
73
- * whole line. A process named `foo) bar` is not hypothetical; it is what
74
- * anything that sets its own title can produce.
73
+ * `)`, which is why the line is read by procStat.mjs, the one parser that
74
+ * knows where comm ends. A process named `foo) bar` is not hypothetical; it is
75
+ * what anything that sets its own title can produce. Null when the pid is gone
76
+ * between readdir and read — ordinary.
75
77
  */
76
78
  function pgrpOf(pid) {
77
- let stat;
78
- try {
79
- stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
80
- } catch {
81
- return null; // gone between readdir and read — ordinary
82
- }
83
- const close = stat.lastIndexOf(')');
84
- if (close < 0) return null;
85
- const rest = stat.slice(close + 1).trim().split(/\s+/);
86
- const pgrp = Number(rest[2]);
79
+ const pgrp = procStat(pid)?.pgrp;
87
80
  return Number.isInteger(pgrp) && pgrp > 0 ? pgrp : null;
88
81
  }
89
82
 
@@ -172,18 +165,11 @@ function scanDarwin(pgids) {
172
165
  }
173
166
 
174
167
  /**
175
- * Live processes in any of `pgids`.
168
+ * Live processes in any of `pgids`, and the TOTAL the cap was applied to.
176
169
  *
177
- * Returns null where the platform cannot answer — never `[]`, which means
178
- * "looked, found none" and is a different fact a surface renders differently.
179
- */
180
- export function processesInGroups(pgids, { scrub } = {}) {
181
- const m = measureProcesses(pgids, { scrub });
182
- return m === null ? null : m.rows;
183
- }
184
-
185
- /**
186
- * …and the TOTAL the cap was applied to.
170
+ * Returns null where the platform cannot answer — never an empty measurement,
171
+ * which means "looked, found none" and is a different fact a surface renders
172
+ * differently.
187
173
  *
188
174
  * The rows alone are a lie about scale the moment a group has more than twelve
189
175
  * members: a dashboard summing them states an incomplete figure as a complete
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
19
19
  import { join, resolve } from 'node:path';
20
- import { homedir } from 'node:os';
20
+ import { codexHome } from './codexHome.mjs';
21
21
 
22
22
  export const toml = (value) => JSON.stringify(String(value));
23
23
 
@@ -43,6 +43,72 @@ function linkPersonalEntry(target, link) {
43
43
  }
44
44
  }
45
45
 
46
+ /** A table header's dotted keys, unquoted — null for one this reader cannot
47
+ * split, which is then nobody's to strip. */
48
+ function headerKeys(section) {
49
+ const key = /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*(?:\.|$)/y;
50
+ const keys = [];
51
+ for (let i = 0; i < section.length; ) {
52
+ key.lastIndex = i;
53
+ const m = key.exec(section);
54
+ if (!m || key.lastIndex === i) return null;
55
+ try {
56
+ keys.push(m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : m[2] ?? m[3]);
57
+ } catch {
58
+ return null;
59
+ }
60
+ i = key.lastIndex;
61
+ }
62
+ return keys;
63
+ }
64
+
65
+ /** The key a `key = …` / `key.sub = …` line defines, unquoted, or null. */
66
+ const lineKey = (line) => {
67
+ const m = /^\s*(?:"([^"\\]*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*[.=]/.exec(line);
68
+ return m ? (m[1] ?? m[2] ?? m[3]) : null;
69
+ };
70
+
71
+ /**
72
+ * THE REPO'S MCP SERVERS, AS THE AGENT'S OWN CONFIG (2026-10-10, the audit).
73
+ * A Codex build turn was handed the base branch's servers as `-c` pairs —
74
+ * `mcp_servers.<n>.env.<K>=<value>`, `…http_headers={…}`, `…url=…` — with
75
+ * every `${VAR}` already expanded, so a token the repo's `.mcp.json` names
76
+ * sat in /proc/<pid>/cmdline, every local user's to read, against this
77
+ * daemon's own rule (codexPersonal.mjs: "A LITERAL SECRET NEVER RIDES
78
+ * ARGV"). They are written here instead, as tables of the agent's private
79
+ * CODEX_HOME config.toml (0600, in a 0700 home), which a build turn reads and
80
+ * every fenced posture ignores (`--ignore-user-config`) — the same turns the
81
+ * `-c` pairs reached. Written in the shape codexPersonal.mjs's bounded reader
82
+ * parses, since a fenced turn reads this file for its login.
83
+ *
84
+ * SO THE EXPANDED VALUES NOW SIT ON DISK, AND THAT IS STATED (2026-10-10). The
85
+ * `-c` pairs died with the turn; these tables live as long as the home does.
86
+ * An agent's home is kept between its turns in the worktree's private git dir
87
+ * (workAgentTurnExecution.mjs `codexAgentHome`) and goes only with the
88
+ * worktree, when the agent retires — until then this 0600 file holds every
89
+ * secret the repo's `.mcp.json` named. Each turn rewrites it whole, so a
90
+ * server the base branch dropped, or a variable since unset, leaves with the
91
+ * next turn; a home with no git dir to hold it dies with its turn. NOT DONE:
92
+ * a URL that would carry an expanded secret is written as any other — refusing
93
+ * it ('missing') is an open follow-up from the same audit finding (F660).
94
+ */
95
+ function codexServerTables(servers) {
96
+ let out = '';
97
+ for (const [name, server] of Object.entries(servers ?? {})) {
98
+ out += `\n[mcp_servers.${name}]\n`;
99
+ if (typeof server.command === 'string') {
100
+ out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n`;
101
+ const env = Object.entries(server.env ?? {}).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
102
+ if (env.length) out += `[mcp_servers.${name}.env]\n${env.map(([key, value]) => `${key} = ${toml(value)}`).join('\n')}\n`;
103
+ } else {
104
+ out += `url = ${toml(server.url)}\n`;
105
+ const headers = Object.entries(server.headers ?? {});
106
+ if (headers.length) out += `[mcp_servers.${name}.http_headers]\n${headers.map(([key, value]) => `${toml(key)} = ${toml(value)}`).join('\n')}\n`;
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
46
112
  /**
47
113
  * Keep personal Codex files, but do not copy its project trust grants. An
48
114
  * untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1.
@@ -58,23 +124,39 @@ function linkPersonalEntry(target, link) {
58
124
  * worktree's skills can change between turns), and what Codex made here
59
125
  * itself under a name the person has not got is kept.
60
126
  */
61
- function isolatedCodexHome(dest, env, worktree) {
62
- const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
127
+ function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
128
+ const source = resolve(codexHome(env));
63
129
  const configPath = join(source, 'config.toml');
64
130
  const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
65
131
  const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
66
132
  const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
67
- // These sections alone grant a checkout permission to supply project
68
- // settings. Keep every other personal setting, skill and session store.
133
+ // The repo's servers replace a personal server of the same name whole —
134
+ // its `[mcp_servers.<n>…]` tables are dropped below. A personal
135
+ // `mcp_servers` written inline cannot be told apart line by line, so it
136
+ // fails closed, as an inline `skills.config` does below.
137
+ const repoServers = new Set(Object.keys(mcpServers ?? {}));
138
+ const inline = () => new Error('Cannot isolate Codex project MCP servers beside an inline mcp_servers entry in config.toml');
139
+ if (repoServers.size && /^\s*["']?mcp_servers["']?\s*[.=]/m.test(topLevel)) throw inline();
140
+ // The `projects` sections alone grant a checkout permission to supply
141
+ // project settings. Keep every other personal setting, skill and session
142
+ // store.
69
143
  const lines = sourceConfig.split('\n');
70
- let inProject = false;
144
+ let dropping = false;
145
+ let inServers = false;
71
146
  let config = lines.filter((line) => {
72
147
  const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
73
148
  if (header) {
74
149
  const section = header[1] ?? header[2];
75
- inProject = section === 'projects' || section.startsWith('projects.');
150
+ const keys = headerKeys(section);
151
+ inServers = keys?.length === 1 && keys[0] === 'mcp_servers';
152
+ dropping =
153
+ section === 'projects' ||
154
+ section.startsWith('projects.') ||
155
+ (keys?.[0] === 'mcp_servers' && keys.length > 1 && repoServers.has(keys[1]));
156
+ } else if (inServers && repoServers.has(lineKey(line))) {
157
+ throw inline();
76
158
  }
77
- return !inProject;
159
+ return !dropping;
78
160
  }).join('\n');
79
161
  if (worktree) {
80
162
  // Codex discovers these even in an untrusted project. Disable exactly the
@@ -92,6 +174,7 @@ function isolatedCodexHome(dest, env, worktree) {
92
174
  throw new Error('Cannot isolate Codex project skills with inline skills.config');
93
175
  for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
94
176
  }
177
+ config += codexServerTables(mcpServers);
95
178
 
96
179
  // The home itself, only once the config is decided: a refused turn touches
97
180
  // nothing here. A home that is not a real directory is not ours to write
@@ -139,8 +222,10 @@ function reviewedClaudePlugin(dir, snapshot) {
139
222
  /**
140
223
  * The table. `refuseMcp(config)` names why this runtime cannot take one MCP
141
224
  * configuration it otherwise could; `prepare(dir, snapshot, env, worktree,
142
- * kept)` returns the runner-specific part of prepareAgentTools' result — under
143
- * `dir`, which dies with the turn, except what `kept` names a lasting place for.
225
+ * turn)` returns the runner-specific part of prepareAgentTools' result — under
226
+ * `dir`, which dies with the turn, except what `turn.codexHome` names a
227
+ * lasting place for. `turn.mcpServers` is the base branch's servers this
228
+ * runtime takes, expanded.
144
229
  */
145
230
  const TOOL_RUNTIMES = {
146
231
  claude: {
@@ -156,8 +241,8 @@ const TOOL_RUNTIMES = {
156
241
  // The agent's own lasting home when the caller has one (2026-09-29: its
157
242
  // threads are indexed under it — see isolatedCodexHome), else one that
158
243
  // dies with the turn, as every home did before.
159
- prepare: (dir, _snapshot, env, worktree, kept) => {
160
- const isolated = isolatedCodexHome(kept?.codexHome ?? join(dir, 'codex-home'), env, worktree);
244
+ prepare: (dir, _snapshot, env, worktree, turn) => {
245
+ const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.mcpServers);
161
246
  return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
162
247
  },
163
248
  },
@@ -189,8 +274,8 @@ export function skillsRefusal(runner) {
189
274
  return runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`;
190
275
  }
191
276
 
192
- /** The runner-specific files a turn needs, written under `dir` — or, for what
193
- * `kept` names, where the caller keeps it between turns. */
194
- export function prepareForRunner(runner, dir, snapshot, env, worktree, kept = {}) {
195
- return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, kept) ?? {}) };
277
+ /** The runner-specific files a turn needs, written under `dir` — or, for the
278
+ * `codexHome` it names, where the caller keeps it between turns. */
279
+ export function prepareForRunner(runner, dir, snapshot, env, worktree, turn = {}) {
280
+ return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, turn) ?? {}) };
196
281
  }
@@ -1,27 +1,28 @@
1
1
  /** Repo-owned tool configuration, read from the base ref for every agent turn. */
2
- import { execFileSync } from 'node:child_process';
3
2
  import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
3
  import { delimiter, isAbsolute, join } from 'node:path';
5
4
  import { tmpdir } from 'node:os';
6
- import { mcpRefusal, prepareForRunner, skillsRefusal, toml } from './projectToolRuntimes.mjs';
5
+ import { git, gitRaw, splitNul } from './git.mjs';
6
+ import { withoutMachineCredentials } from './machineEnv.mjs';
7
+ import { mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
7
8
 
8
9
  const MAX_TOOLS = 40;
9
10
  const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
10
11
  const MAX_INSTRUCTION_FILES = 80;
11
12
  const MAX_INSTRUCTION_BYTES = 512 * 1024;
12
13
  const nameOk = (name) => /^[A-Za-z0-9_-]{1,80}$/.test(name) && name !== 'flowviant';
14
+ /** Base-ref reads are QUIET (git's "does not exist in" for an absent file is
15
+ * an answer here, not a message for the daemon's terminal) and keep a 1 MiB
16
+ * cap per file. */
17
+ const SHOW = Object.freeze({ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 });
13
18
  const show = (root, ref, path) => {
14
19
  try {
15
- return execFileSync('git', ['show', `${ref}:${path}`], {
16
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
17
- });
20
+ return gitRaw(['show', `${ref}:${path}`], root, SHOW);
18
21
  } catch { return null; }
19
22
  };
20
23
  const showBytes = (root, ref, path) => {
21
24
  try {
22
- return execFileSync('git', ['show', `${ref}:${path}`], {
23
- cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
24
- });
25
+ return gitRaw(['show', `${ref}:${path}`], root, { ...SHOW, encoding: 'buffer' });
25
26
  } catch { return null; }
26
27
  };
27
28
 
@@ -39,11 +40,39 @@ function referenced(value) {
39
40
  return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
40
41
  }
41
42
 
42
- export function readBaseTools(root, ref, env = process.env) {
43
+ /**
44
+ * THE ENVIRONMENT A `${VAR}` IN THE REPO'S `.mcp.json` IS READ FROM: the
45
+ * caller's, MINUS THE MACHINE CREDENTIAL AND EVERY OTHER `FLOWVIANT_*` NAME,
46
+ * whatever the caller passed (2026-10-10, the audit). A base-branch
47
+ * `"url": "https://x/mcp?k=${FLOWVIANT_FLEET}"` delivered the credential this
48
+ * daemon authenticates with to that host on every agent turn — the
49
+ * inherited-credential leak machineEnv.mjs exists to close. The daemon's own
50
+ * names are nobody's repo config to read, so such a server reads as
51
+ * `missing` ("env FLOWVIANT_FLEET unset").
52
+ */
53
+ const expansionEnv = (env) =>
54
+ Object.fromEntries(Object.entries(withoutMachineCredentials(env ?? process.env)).filter(([key]) => !key.startsWith('FLOWVIANT_')));
55
+
56
+ /**
57
+ * A TREE LISTING IS NUL-SEPARATED AND SIZED FOR A REAL REPOSITORY (2026-10-10,
58
+ * the audit). The whole-tree listing that finds nested CLAUDE.md/AGENTS.md ran
59
+ * under a 1 MiB buffer — about 20k paths — and nothing caught the ENOBUFS, so
60
+ * EVERY agent turn on a larger repository settled `nothing` before its CLI
61
+ * started. 64 MiB is workAdoptCarry.mjs's precedent; MAX_INSTRUCTION_FILES and
62
+ * MAX_INSTRUCTION_BYTES stay the real bounds. `-z` because git C-quotes a path
63
+ * that is not plain ASCII (`"d\303\251j\303\240/CLAUDE.md"`), which the name
64
+ * filter never matched (git.mjs's `-z` rule).
65
+ */
66
+ const LIST_MAX_BUFFER = 64 * 1024 * 1024;
67
+ const listTree = (root, args) =>
68
+ splitNul(gitRaw(['ls-tree', '-r', '-z', '--name-only', ...args], root, {
69
+ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: LIST_MAX_BUFFER,
70
+ }));
71
+
72
+ export function readBaseTools(root, ref, callerEnv = process.env) {
73
+ const env = expansionEnv(callerEnv);
43
74
  // Resolve once: a base branch may advance while the turn is being prepared.
44
- const commit = execFileSync('git', ['rev-parse', '--verify', ref], {
45
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
46
- }).trim();
75
+ const commit = git(['rev-parse', '--verify', ref], root, { stdio: ['ignore', 'pipe', 'ignore'] });
47
76
  const raw = show(root, commit, '.mcp.json');
48
77
  let servers = {};
49
78
  if (raw != null) {
@@ -53,9 +82,7 @@ export function readBaseTools(root, ref, env = process.env) {
53
82
  const skills = [];
54
83
  const skillFiles = [];
55
84
  try {
56
- const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', commit, '--', '.claude/skills'], {
57
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
58
- }).split('\n');
85
+ const paths = listTree(root, [commit, '--', '.claude/skills']);
59
86
  let bytes = 0;
60
87
  for (const path of paths) {
61
88
  if (!/^\.claude\/skills\/[A-Za-z0-9_-]+\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(path)) continue;
@@ -82,9 +109,7 @@ export function readBaseTools(root, ref, env = process.env) {
82
109
  // Nested instructions are normally discovered when a CLI enters that path.
83
110
  // Their worktree copies must not regain authority through that discovery.
84
111
  const instructions = [];
85
- const paths = execFileSync('git', ['ls-tree', '-r', '--name-only', commit], {
86
- cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
87
- }).split('\n').filter((path) => /(^|\/)(CLAUDE|AGENTS)\.md$/.test(path));
112
+ const paths = listTree(root, [commit]).filter((path) => /(^|\/)(CLAUDE|AGENTS)\.md$/.test(path));
88
113
  if (paths.length > MAX_INSTRUCTION_FILES) throw new Error('Base branch has too many agent instruction files');
89
114
  let instructionBytes = 0;
90
115
  for (const path of paths) {
@@ -107,32 +132,26 @@ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]
107
132
  * removed by `cleanup`, because Codex indexes a thread under the home it ran
108
133
  * in and the next card resumes that thread. Absent, a Codex turn gets a home
109
134
  * that dies with it.
135
+ *
136
+ * NO EXPANDED VALUE RIDES ARGV (2026-10-10, the audit): Claude's servers go
137
+ * to this turn's 0600 mcp.json, Codex's into the isolated home's config.toml
138
+ * (projectToolRuntimes.mjs) — never `-c` pairs on the command line.
110
139
  */
111
- export function prepareAgentTools(snapshot, runner, env = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
140
+ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
141
+ const env = expansionEnv(callerEnv);
112
142
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
113
143
  const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
114
144
  const mcpServers = {};
115
- const codexArgs = [];
116
145
  for (const { name, config } of usable) {
117
146
  if (typeof config.command === 'string') {
118
147
  const command = expand(config.command, env);
119
148
  const args = Array.isArray(config.args) ? config.args.map((v) => expand(v, env)) : [];
120
149
  const vars = Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v, env)]));
121
150
  mcpServers[name] = { command, args, env: vars };
122
- codexArgs.push('-c', `mcp_servers.${name}.command=${toml(command)}`);
123
- codexArgs.push('-c', `mcp_servers.${name}.args=${JSON.stringify(args)}`);
124
- for (const [key, value] of Object.entries(vars)) {
125
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) codexArgs.push('-c', `mcp_servers.${name}.env.${key}=${toml(value)}`);
126
- }
127
151
  } else if (typeof config.url === 'string') {
128
152
  const url = expand(config.url, env);
129
153
  const headers = Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v, env)]));
130
154
  mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
131
- codexArgs.push('-c', `mcp_servers.${name}.url=${toml(url)}`);
132
- if (Object.keys(headers).length) {
133
- const inline = Object.entries(headers).map(([key, value]) => `${toml(key)}=${toml(value)}`).join(',');
134
- codexArgs.push('-c', `mcp_servers.${name}.http_headers={${inline}}`);
135
- }
136
155
  }
137
156
  }
138
157
  const mcpPath = join(dir, 'mcp.json');
@@ -150,7 +169,7 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
150
169
  ].filter(Boolean).join('\n\n');
151
170
  let prepared;
152
171
  try {
153
- prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome });
172
+ prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers });
154
173
  } catch (error) {
155
174
  rmSync(dir, { recursive: true, force: true });
156
175
  throw error;
@@ -158,7 +177,6 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
158
177
  const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
159
178
  return {
160
179
  mcpPath,
161
- codexArgs,
162
180
  instructions,
163
181
  pluginDir,
164
182
  codexHome,
@@ -209,6 +209,23 @@ const fence = (label, content) =>
209
209
  `${String(content ?? '').replace(/<<<|>>>/g, (m) => m.split('').join('\u200b'))}\n` +
210
210
  `<<<END ${label}>>>`;
211
211
 
212
+ /** One line, capped — a title or name is a server string headed into a
213
+ * prompt, and a line break would forge a second entry (a reference, a
214
+ * `- id:` row, a `brief:` line). Above every use (2026-10-10): it was declared
215
+ * far below the builders that call it. Every line break Unicode names, not
216
+ * only `\r` and `\n`: a U+2028 starts a line as surely as `\n` does. */
217
+ const oneLine = (v, max) => String(v ?? '').replace(/[\r\n\v\f\u0085\u2028\u2029]+/g, ' ').trim().slice(0, max);
218
+
219
+ /**
220
+ * A CARD ID FIT TO PRINT OUTSIDE THE FENCE (2026-10-10, the audit). The commit
221
+ * trailer is OUR instruction, after the card's fence closes, and a card id is
222
+ * a doc key a member can write — the server checks only its length, so a
223
+ * newline and a sentence ride in it. A plain token (what the server mints is
224
+ * a UUID) prints as it is; anything else prints nothing, and the trailer
225
+ * paragraph is not said, as it is not on a card with nothing to commit.
226
+ */
227
+ const trailerId = (id) => (typeof id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : '');
228
+
212
229
 
213
230
 
214
231
 
@@ -373,11 +390,14 @@ MECHANICS OF THIS TAB:
373
390
  \`\`\`
374
391
 
375
392
  ONE block per reply, and always the LAST thing in it. Two to eight options,
376
- each label short enough to sit on a button. multiSelect true only for a
377
- genuine check-several-of-these case. NEVER for an open question — ask those
378
- in prose, like anyone would. And ask the question in prose above the block
379
- as well: a client that doesn't render the fence shows it as plain text, so
380
- the reply has to read as a question with its options either way.
393
+ each label short enough to sit on a button — a few words, never a comma
394
+ (multi-select answers arrive as the chosen labels comma-joined, in the order
395
+ listed). Do NOT add an "Other" option — the card offers a free-text path
396
+ itself. multiSelect true only for a genuine check-several-of-these case.
397
+ NEVER for an open question — ask those in prose, like anyone would. And ask
398
+ the question in prose above the block as well: a client that doesn't render
399
+ the fence shows it as plain text, so the reply has to read as a question
400
+ with its options either way.
381
401
 
382
402
  THERE IS NO LATER. Your turn ends when you stop writing, and nothing of yours
383
403
  runs after that — so never promise to report back, keep watching, or tell them
@@ -651,11 +671,12 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
651
671
  * REPORTER's ticket title verbatim, so a stranger can put words in it. That
652
672
  * string rides the ship report into `code_map_reground_jobs`, comes back on the
653
673
  * roster, and landed here as a bare `Feature: <text>` line — no delimiter, no
654
- * instruction not to obey it. This turn runs UNATTENDED under `WIKI_PERM`,
655
- * which grants Write, Edit, `Bash(mkdir:*)` and `Bash(rm:*)`, and whose own
656
- * comment concedes that Write/Edit cannot be path-scoped here — the worktree
657
- * reset is the backstop, and it only cleans the wiki worktree. Anything written
658
- * outside it survives.
674
+ * instruction not to obey it. This turn runs UNATTENDED, with `Bash(mkdir:*)`
675
+ * and `Bash(rm:*)` and a write to the vault — every page of which is uploaded
676
+ * to the project's wiki. Since 2026-10-10 Claude's list scopes that write to
677
+ * the vault and fences its reads (claudePosture.mjs `wikiPermFor`), but the
678
+ * fence here is still the prompt's half: what the turn WRITES into the vault
679
+ * is what members read.
659
680
  *
660
681
  * The predicted pages come off the roster exactly as the title does — a
661
682
  * planner wrote them from card text, and card text is member-authored — so an
@@ -886,7 +907,9 @@ export const AGENT_PLAN_KICKOFF = ({ tasks, liveAgents, agentCap }) => {
886
907
  const cards = tasks
887
908
  .map(
888
909
  (t) =>
889
- `- id: ${t.id}\n title: ${t.title}\n` +
910
+ // THE TITLE ON ONE LINE (2026-10-10): a member writes it, and a line
911
+ // break in it forged a second `- id:` row inside the fence.
912
+ `- id: ${t.id}\n title: ${oneLine(t.title, 300)}\n` +
890
913
  // EVERY card says its kind, code included — the planner is splitting
891
914
  // by it (SYSTEM_PLAN rule 8), and an absent line would read as "not
892
915
  // stated" rather than as the default. Absent on the wire IS code.
@@ -1170,9 +1193,11 @@ export const AGENT_TASK_KICKOFF = ({ agentName, task, cardThread, cardFiles, pos
1170
1193
  `on this branch. Everything you commit here is reviewed and merged TOGETHER with ` +
1171
1194
  `the other cards in this run.\n\n` +
1172
1195
  `${fence('THE CARD', taskBlock(task, cardThread, cardFiles))}\n\n` +
1173
- `When you commit, put this trailer on the LAST line of each commit message so ` +
1174
- `the card can find its own commits:\n` +
1175
- `Flowviant-Task: ${task?.id ?? ''}\n\n` +
1196
+ (trailerId(task?.id)
1197
+ ? `When you commit, put this trailer on the LAST line of each commit message so ` +
1198
+ `the card can find its own commits:\n` +
1199
+ `Flowviant-Task: ${trailerId(task?.id)}\n\n`
1200
+ : '') +
1176
1201
  `Do it, commit it, and end with the JSON object.`
1177
1202
  : // A NON-CODE CARD (0.97.0; 3D model and presentation 0.105.0; image 0.114.0) has nothing to commit, so the trailer
1178
1203
  // paragraph — an instruction to do the one thing its contract forbids —
@@ -1211,7 +1236,8 @@ const safeName = (n) =>
1211
1236
  */
1212
1237
  export const AGENT_TASK_SPEC = (task, cardThread, cardFiles) =>
1213
1238
  `id: ${task?.id ?? ''}\n` +
1214
- `title: ${task?.title ?? ''}\n` +
1239
+ // On one line (2026-10-10): a line break forged a `brief:` or `done when:`.
1240
+ `title: ${oneLine(task?.title, 300)}\n` +
1215
1241
  // THE KIND, when it is news (0.97.0). A code card's spec stays byte-for-byte
1216
1242
  // what it was — the reviewer's stash and the agent's prompt both read this,
1217
1243
  // and a line saying "kind: code" on every card is a change to every prompt
@@ -1324,10 +1350,6 @@ const cardFilesText = (files) => {
1324
1350
  * The server has sent `designs/<slug>-vN/` since 0.99.0, and the folder list
1325
1351
  * this file used to spell for itself dropped every one until 0.105.0. */
1326
1352
  const isReference = (r) => !!r && isLibraryReference(r.name) && typeof r.title === 'string';
1327
- /** One line, capped — a reference title is a server string headed into a
1328
- * prompt, and a line break would forge a second entry. */
1329
- const oneLine = (v, max) => String(v ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, max);
1330
-
1331
1353
  const taskBlock = AGENT_TASK_SPEC;
1332
1354
 
1333
1355
  /**
@@ -11,6 +11,7 @@
11
11
  * deleted checkout still matches the lock or entry that names it.
12
12
  */
13
13
  import { realpathSync } from 'node:fs';
14
+ import { sep } from 'node:path';
14
15
 
15
16
  /** Same directory, whatever it is spelled as. A blank side matches nothing. */
16
17
  export function samePath(a, b) {
@@ -24,3 +25,21 @@ export function samePath(a, b) {
24
25
  };
25
26
  return norm(a) === norm(b);
26
27
  }
28
+
29
+ /**
30
+ * `p` IS `root` OR INSIDE IT — a pure prefix test on already-RESOLVED absolute
31
+ * paths, never a sibling that merely shares the prefix (`/repo-other` is not
32
+ * inside `/repo`). No filesystem: each caller resolves (or deliberately keeps
33
+ * a literal) before asking.
34
+ *
35
+ * One predicate for the fences that decide on it (audit 2026-10-10, F106 /
36
+ * F161): the adopt fence offered and accepted on both sides (localSessions.mjs)
37
+ * and the listener attribution behind a public preview tunnel (procScan.mjs's
38
+ * insideDir). Three hand copies meant a containment fix — a root of `/`, a
39
+ * case-insensitive filesystem — had to land three times or the fences would
40
+ * disagree about the same path.
41
+ */
42
+ export function isPathInside(p, root) {
43
+ if (typeof p !== 'string' || typeof root !== 'string') return false;
44
+ return p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
45
+ }