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,328 @@
1
+ /**
2
+ * CODEX'S config.toml SYNTAX, IN ONE IMPORT-FREE HOME (2026-10-18, the SOLID
3
+ * pass): the bounded reader, the value writer and the key quoting every Codex
4
+ * module writes with. Split out because codexPersonal.mjs (the login carry)
5
+ * took its string quoter `toml` from projectToolRuntimes.mjs (the runtime
6
+ * table), which imported codexPersonal.mjs back for the reader, and
7
+ * codexFencedMount.mjs imported both — a three-module cycle that loaded only
8
+ * because every use across it sat inside a function. MEASURED on scratch
9
+ * copies: one top-level `toml(...)` in codexPersonal.mjs made importing
10
+ * projectToolRuntimes.mjs throw "Cannot access 'toml' before
11
+ * initialization", so which module a caller imported first decided whether
12
+ * the daemon started; and the uplink scrubber, which wants only the provider
13
+ * secrets, loaded the runtime tool table and the fenced mount through it. The
14
+ * one bare-key quoter had two copies (`bareOr`, `bareKeyOr`); it is `bareKey`.
15
+ * The string quoter had a third, orchestratorHooks.mjs's `quote` for the
16
+ * Codex hook rows' `-c` values; it is `toml`.
17
+ *
18
+ * A LEAF: no imports. codexPersonal.mjs, codexFencedMount.mjs and
19
+ * projectToolRuntimes.mjs import it, never each other for it
20
+ * (codexToml.test.mjs).
21
+ */
22
+
23
+ export const toml = (value) => JSON.stringify(String(value));
24
+
25
+ /** A table header's dotted keys, unquoted — null for one this reader cannot
26
+ * split, which is then nobody's to strip. */
27
+ export function headerKeys(section) {
28
+ const key = /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*(?:\.|$)/y;
29
+ const keys = [];
30
+ for (let i = 0; i < section.length; ) {
31
+ key.lastIndex = i;
32
+ const m = key.exec(section);
33
+ if (!m || key.lastIndex === i) return null;
34
+ try {
35
+ keys.push(m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : m[2] ?? m[3]);
36
+ } catch {
37
+ return null;
38
+ }
39
+ i = key.lastIndex;
40
+ }
41
+ return keys;
42
+ }
43
+
44
+ /** The dotted keys a `key = …` / `key.sub = …` line defines, unquoted, or
45
+ * null for a line that defines none this reader can split. */
46
+ export function lineKeys(line) {
47
+ const key = /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*([.=])/y;
48
+ const keys = [];
49
+ for (let i = 0; ; ) {
50
+ key.lastIndex = i;
51
+ const m = key.exec(line);
52
+ if (!m) return null;
53
+ try {
54
+ keys.push(m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : m[2] ?? m[3]);
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (m[4] === '=') return keys;
59
+ i = key.lastIndex;
60
+ }
61
+ }
62
+
63
+ /** Arrays and inline tables nest no deeper than this in a file we read. */
64
+ const MAX_DEPTH = 32;
65
+
66
+ /** A number, date or time, kept as written so it is carried as written. */
67
+ export class TomlRaw {
68
+ constructor(text) {
69
+ this.text = text;
70
+ }
71
+ }
72
+ export const newTable = () => Object.create(null);
73
+ export const isTable = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof TomlRaw);
74
+
75
+ const BARE = /[A-Za-z0-9_-]+/y;
76
+ // First match wins, so the forms that begin like an integer come first.
77
+ const SCALAR =
78
+ /[+-]?(?:inf|nan)|0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*|\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:[Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?|[+-]?\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?/y;
79
+
80
+ /**
81
+ * A BOUNDED TOML READER — the repo carries no TOML library, and the one file
82
+ * it reads is Codex's own config. Whole documents: tables, arrays of tables
83
+ * (the daemon's own `[[skills.config]]` in an agent's home), dotted and quoted
84
+ * keys, the four string forms, arrays, inline tables, booleans, and numbers,
85
+ * dates and times kept as written. Anything else throws, and the caller
86
+ * carries nothing — a partial read is never a license to guess.
87
+ */
88
+ export function parseCodexToml(text) {
89
+ const src = String(text).replace(/^/, '');
90
+ const n = src.length;
91
+ let i = 0;
92
+ const fail = (why) => {
93
+ throw new Error(`config.toml: ${why} at offset ${i}`);
94
+ };
95
+ const ws = () => {
96
+ while (i < n && (src[i] === ' ' || src[i] === '\t')) i++;
97
+ };
98
+ const comment = () => {
99
+ if (src[i] === '#') while (i < n && src[i] !== '\n') i++;
100
+ };
101
+ const newline = () => {
102
+ if (src[i] === '\n') return (i += 1), true;
103
+ if (src[i] === '\r' && src[i + 1] === '\n') return (i += 2), true;
104
+ return false;
105
+ };
106
+ /** Blank lines, comments and whitespace, as between array items. */
107
+ const gap = () => {
108
+ for (;;) {
109
+ ws();
110
+ comment();
111
+ if (!newline()) return;
112
+ }
113
+ };
114
+ const endOfLine = () => {
115
+ ws();
116
+ comment();
117
+ if (i < n && !newline()) fail('expected the end of the line');
118
+ };
119
+ const hexChar = (len) => {
120
+ const h = src.slice(i, i + len);
121
+ if (h.length !== len || !/^[0-9A-Fa-f]+$/.test(h)) fail('bad escape');
122
+ i += len;
123
+ const cp = parseInt(h, 16);
124
+ if (cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) fail('bad escape');
125
+ return String.fromCodePoint(cp);
126
+ };
127
+ const escape = () => {
128
+ const e = src[i + 1];
129
+ i += 2;
130
+ switch (e) {
131
+ case 'b': return '\b';
132
+ case 't': return '\t';
133
+ case 'n': return '\n';
134
+ case 'f': return '\f';
135
+ case 'r': return '\r';
136
+ case 'e': return '\x1b';
137
+ case '"': return '"';
138
+ case '\\': return '\\';
139
+ case 'x': return hexChar(2);
140
+ case 'u': return hexChar(4);
141
+ case 'U': return hexChar(8);
142
+ default: return fail('bad escape');
143
+ }
144
+ };
145
+ const basicString = () => {
146
+ i += 1;
147
+ let s = '';
148
+ for (;;) {
149
+ if (i >= n) fail('unterminated string');
150
+ const ch = src[i];
151
+ if (ch === '"') return (i += 1), s;
152
+ if (ch === '\n' || ch === '\r') fail('newline in a string');
153
+ if (ch === '\\') s += escape();
154
+ else (s += ch), (i += 1);
155
+ }
156
+ };
157
+ const literalString = () => {
158
+ const end = src.indexOf("'", i + 1);
159
+ if (end < 0) fail('unterminated string');
160
+ const s = src.slice(i + 1, end);
161
+ if (/[\r\n]/.test(s)) fail('newline in a string');
162
+ i = end + 1;
163
+ return s;
164
+ };
165
+ const LINE_END_BACKSLASH = /\\[ \t]*\r?\n/y;
166
+ const multiline = (quote, escapes) => {
167
+ i += 3;
168
+ newline(); // a newline straight after the opening quotes is not content
169
+ let s = '';
170
+ for (;;) {
171
+ if (i >= n) fail('unterminated string');
172
+ if (src.startsWith(quote.repeat(3), i)) {
173
+ let q = 3;
174
+ while (q < 5 && src[i + q] === quote) q++; // up to two belong to the content
175
+ s += quote.repeat(q - 3);
176
+ i += q;
177
+ return s;
178
+ }
179
+ if (escapes && src[i] === '\\') {
180
+ LINE_END_BACKSLASH.lastIndex = i;
181
+ if (LINE_END_BACKSLASH.test(src)) {
182
+ i = LINE_END_BACKSLASH.lastIndex;
183
+ while (i < n && /[ \t\r\n]/.test(src[i])) i++;
184
+ } else s += escape();
185
+ } else (s += src[i]), (i += 1);
186
+ }
187
+ };
188
+ const key = () => {
189
+ const parts = [];
190
+ for (;;) {
191
+ ws();
192
+ if (src[i] === '"') {
193
+ if (src.startsWith('"""', i)) fail('a multi-line key');
194
+ parts.push(basicString());
195
+ } else if (src[i] === "'") {
196
+ if (src.startsWith("'''", i)) fail('a multi-line key');
197
+ parts.push(literalString());
198
+ } else {
199
+ BARE.lastIndex = i;
200
+ const m = BARE.exec(src);
201
+ if (!m) fail('expected a key');
202
+ parts.push(m[0]);
203
+ i = BARE.lastIndex;
204
+ }
205
+ ws();
206
+ if (src[i] !== '.') return parts;
207
+ i += 1;
208
+ }
209
+ };
210
+ const assign = (target, path, v) => {
211
+ let t = target;
212
+ for (const k of path.slice(0, -1)) {
213
+ if (!Object.hasOwn(t, k)) t[k] = newTable();
214
+ else if (!isTable(t[k])) fail(`"${k}" is not a table`);
215
+ t = t[k];
216
+ }
217
+ const last = path.at(-1);
218
+ if (Object.hasOwn(t, last)) fail(`"${last}" is defined twice`);
219
+ t[last] = v;
220
+ };
221
+ let depth = 0;
222
+ const nested = (open) => {
223
+ if (++depth > MAX_DEPTH) fail('nested too deep');
224
+ try {
225
+ return open();
226
+ } finally {
227
+ depth -= 1;
228
+ }
229
+ };
230
+ const array = () => {
231
+ i += 1;
232
+ const out = [];
233
+ for (;;) {
234
+ gap();
235
+ if (src[i] === ']') return (i += 1), out;
236
+ out.push(value());
237
+ gap();
238
+ if (src[i] === ',') i += 1;
239
+ else if (src[i] === ']') return (i += 1), out;
240
+ else fail('expected , or ]');
241
+ }
242
+ };
243
+ const inlineTable = () => {
244
+ i += 1;
245
+ const t = newTable();
246
+ for (;;) {
247
+ gap();
248
+ if (src[i] === '}') return (i += 1), t;
249
+ keyValue(t);
250
+ gap();
251
+ if (src[i] === ',') i += 1;
252
+ else if (src[i] === '}') return (i += 1), t;
253
+ else fail('expected , or }');
254
+ }
255
+ };
256
+ const word = (w) => src.startsWith(w, i) && !/[A-Za-z0-9_-]/.test(src[i + w.length] ?? '');
257
+ const value = () => {
258
+ const ch = src[i];
259
+ if (ch === '"') return src.startsWith('"""', i) ? multiline('"', true) : basicString();
260
+ if (ch === "'") return src.startsWith("'''", i) ? multiline("'", false) : literalString();
261
+ if (ch === '[') return nested(array);
262
+ if (ch === '{') return nested(inlineTable);
263
+ if (word('true')) return (i += 4), true;
264
+ if (word('false')) return (i += 5), false;
265
+ SCALAR.lastIndex = i;
266
+ const m = SCALAR.exec(src);
267
+ if (!m) fail('a value this reader does not know');
268
+ i = SCALAR.lastIndex;
269
+ if (/[A-Za-z0-9_.:+-]/.test(src[i] ?? '')) fail('a value this reader does not know');
270
+ return new TomlRaw(m[0]);
271
+ };
272
+ const keyValue = (target) => {
273
+ const path = key();
274
+ if (src[i] !== '=') fail('expected =');
275
+ i += 1;
276
+ ws();
277
+ assign(target, path, value());
278
+ };
279
+
280
+ const root = newTable();
281
+ let current = root;
282
+ for (;;) {
283
+ gap();
284
+ if (i >= n) return root;
285
+ if (src[i] !== '[') {
286
+ keyValue(current);
287
+ endOfLine();
288
+ continue;
289
+ }
290
+ const many = src[i + 1] === '[';
291
+ i += many ? 2 : 1;
292
+ const path = key();
293
+ if (many ? !src.startsWith(']]', i) : src[i] !== ']') fail('an unclosed table header');
294
+ i += many ? 2 : 1;
295
+ endOfLine();
296
+ let t = root;
297
+ for (const k of path.slice(0, -1)) {
298
+ if (!Object.hasOwn(t, k)) t[k] = newTable();
299
+ const v = Array.isArray(t[k]) ? t[k].at(-1) : t[k];
300
+ if (!isTable(v)) fail(`"${k}" is not a table`);
301
+ t = v;
302
+ }
303
+ const last = path.at(-1);
304
+ if (many) {
305
+ if (!Object.hasOwn(t, last)) t[last] = [];
306
+ if (!Array.isArray(t[last])) fail(`"${last}" is not an array of tables`);
307
+ current = newTable();
308
+ t[last].push(current);
309
+ } else {
310
+ if (!Object.hasOwn(t, last)) t[last] = newTable();
311
+ else if (!isTable(t[last])) fail(`"${last}" is not a table`);
312
+ current = t[last];
313
+ }
314
+ }
315
+ }
316
+
317
+ /** A key as TOML writes it: bare where it can be, else quoted. */
318
+ export const bareKey = (k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : toml(k));
319
+
320
+ /** One value as TOML, as a `-c` override's value is parsed. */
321
+ export function tomlValue(v) {
322
+ if (typeof v === 'string') return toml(v);
323
+ if (typeof v === 'boolean') return String(v);
324
+ if (v instanceof TomlRaw) return v.text;
325
+ if (Array.isArray(v)) return `[${v.map(tomlValue).join(',')}]`;
326
+ if (isTable(v)) return `{${Object.keys(v).map((k) => `${bareKey(k)}=${tomlValue(v[k])}`).join(',')}}`;
327
+ throw new Error('not a TOML value');
328
+ }
@@ -226,7 +226,28 @@ export const USER_AGENT = `flowviant/${VERSION}`;
226
226
  * back. Sent on the poll as `di`; its ABSENCE is what an older daemon looks
227
227
  * like, and the server hands preview work to nobody who cannot name themselves.
228
228
  */
229
- export const DAEMON_INSTANCE = randomBytes(12).toString('hex');
229
+ export const DAEMON_INSTANCE = inheritedInstance() ?? randomBytes(12).toString('hex');
230
+
231
+ /**
232
+ * …EXCEPT ACROSS ITS OWN SELF-UPDATE (2026-10-19). A self-update re-execs
233
+ * only when the machine is idle (update.mjs), so the process it replaces
234
+ * holds nothing in flight — only its tabs' session leases, which no other
235
+ * daemon may take for 45 seconds. A successor with a new id waited out every
236
+ * one of them, so each unattended update left every tab unanswered for most
237
+ * of a minute. The successor named by `FLOWVIANT_REEXEC` takes the id its
238
+ * predecessor handed it; any other start still regenerates it.
239
+ */
240
+ function inheritedInstance() {
241
+ const handed = process.env.FLOWVIANT_DAEMON_INSTANCE;
242
+ // TAKEN ONCE, THEN GONE FROM THIS PROCESS'S ENV. Every CLI turn spawns with
243
+ // `{ ...process.env }`, so a variable left here reached any `flowviant`
244
+ // started inside one — an agent running the daemon from a worktree — and
245
+ // two live processes would answer to one instance (the trap instance.mjs
246
+ // names for FLOWVIANT_REEXEC_FROM). `reexec` sets it afresh for its own
247
+ // successor, so the next update still hands it on.
248
+ delete process.env.FLOWVIANT_DAEMON_INSTANCE;
249
+ return process.env.FLOWVIANT_REEXEC === '1' && typeof handed === 'string' && /^[0-9a-f]{24}$/.test(handed) ? handed : null;
250
+ }
230
251
 
231
252
  /**
232
253
  * WHICH BOX, in words a person recognises — and nothing else.
@@ -35,6 +35,16 @@ export const CLAUDE_SESSION_RE = /^[A-Za-z0-9_-]{8,64}$/;
35
35
  */
36
36
  export const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
37
37
 
38
+ /**
39
+ * A Codex native CHILD's thread id as the subagent lane follows it
40
+ * (codexSubagentEvents.mjs, codexSubagents.mjs). Its measured value,
41
+ * unchanged when its two copies moved here (2026-10-18). The rollout lookup
42
+ * it then reaches still guards with CODEX_THREAD_RE (runtimeLimits.mjs
43
+ * codexRolloutFile). Making the two one shape is a behaviour change for the
44
+ * owner to rule on.
45
+ */
46
+ export const CODEX_CHILD_THREAD_RE = /^[a-zA-Z0-9-]{16,64}$/;
47
+
38
48
  /** agy conversation ids are plain UUIDs (the db filename IS the identity —
39
49
  * measured: a renamed copy fails "trajectory not found"). Guarded the same
40
50
  * way as the codex id: it rides in argv as `--conversation <id>`. */
package/bin/lib/fleet.mjs CHANGED
@@ -38,7 +38,7 @@ import { compareVersions } from './versionOrder.mjs';
38
38
  import { emitMachineEvent } from './daemonLogging.mjs';
39
39
  import { writeDaemonState } from './daemonState.mjs';
40
40
  import { maintenanceTick, readMaintenance } from './maintenance.mjs';
41
- import { repoRootOrDie, detectBaseRef, baseBranchName, excludeInWorktree, usableBaseRef } from './git.mjs';
41
+ import { repoRootOrDie, detectBaseRef, excludeInWorktree, baseRefFor } from './git.mjs';
42
42
  import { c, info, note, ok, warn, fail } from './ui.mjs';
43
43
  import { sleep } from './sleep.mjs';
44
44
  import { reapOrphanPreviews } from './previewRegistry.mjs';
@@ -576,7 +576,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
576
576
  const servedBaseBranch = Array.isArray(roster.repositories)
577
577
  ? repositories.list().find((r) => r.repoRoot === repoRoot)?.baseBranch : roster.baseBranch;
578
578
  if (typeof servedBaseBranch === 'string' && servedBaseBranch.trim()) {
579
- const want = usableBaseRef(repoRoot, `origin/${baseBranchName(servedBaseBranch.trim())}`);
579
+ const want = baseRefFor(repoRoot, servedBaseBranch.trim());
580
580
  if (want !== baseRef) {
581
581
  note(`base · ${want} ${c.dim('(set for this project)')}`);
582
582
  baseRef = want;
package/bin/lib/git.mjs CHANGED
@@ -333,6 +333,38 @@ export function usableBaseRef(repoRoot, ref) {
333
333
  return ref;
334
334
  }
335
335
 
336
+ /**
337
+ * The BASE REF a repository lands on: its configured branch when a human set
338
+ * one (`origin/<branch>`, or the local branch where there is no origin), else
339
+ * the detected one. One home (2026-10-18, the SOLID pass); was written in
340
+ * repositoryMerge, mergeBatch, projectRepositories and fleet, so a change to
341
+ * how a configured base resolves needed four edits that had to agree.
342
+ */
343
+ export function baseRefFor(repoRoot, baseBranch) {
344
+ return baseBranch ? usableBaseRef(repoRoot, `origin/${baseBranchName(baseBranch)}`) : detectBaseRef(repoRoot);
345
+ }
346
+
347
+ /**
348
+ * Is `a` an ancestor of `b`? A failed probe (an unknown commit included) is
349
+ * no. One home (2026-10-18, the SOLID pass); was a closure in repositoryMerge,
350
+ * mergeBatch and repositoryDelivery.
351
+ */
352
+ export function isAncestor(a, b, cwd) {
353
+ try { git(['merge-base', '--is-ancestor', a, b], cwd); return true; } catch { return false; }
354
+ }
355
+
356
+ /**
357
+ * A repository's base, MEASURED NOW: fetch origin when there is one, then the
358
+ * base ref and the commit it names. One home (2026-10-18, the SOLID pass);
359
+ * was a `fetch` closure in repositoryMerge and mergeBatch. `net` is the
360
+ * caller's (its tests inject one).
361
+ */
362
+ export function measureBase(repoRoot, baseBranch, net = gitNet) {
363
+ if (gitOrNull(['remote', 'get-url', 'origin'], repoRoot)) net(['fetch', 'origin', '--quiet'], repoRoot, 60_000);
364
+ const baseRef = baseRefFor(repoRoot, baseBranch);
365
+ return { baseRef, baseTip: git(['rev-parse', baseRef], repoRoot) };
366
+ }
367
+
336
368
  /** True when the repository has at least one commit (HEAD resolves). */
337
369
  export function hasCommits(repoRoot) {
338
370
  try { git(['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], repoRoot); return true; } catch { return false; }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * A HELD TURN'S LOOPBACK TOOL, DAEMON SIDE (2026-10-18, the SOLID pass): the
3
+ * run-scoped capability, its one gate, the loopback listen, the env pair and
4
+ * the argv that starts the tool's child.
5
+ *
6
+ * A held turn's CLI never receives the machine token. A card tool it may
7
+ * call is a server on 127.0.0.1 that answers only a POST to `/` bearing a
8
+ * capability minted for this one run, and a bare 403 to anything else. That
9
+ * gate is a security boundary, and it was written out twice, two days apart
10
+ * (repositoryTool.mjs 2026-10-14, orchestrator.mjs 2026-10-15), with the
11
+ * listen, the env pair and the self-argv beside it. Each tool keeps its own
12
+ * body reader, limits, refusals and close order; the child half is
13
+ * mcpStdio.mjs plus each tool's own client.
14
+ *
15
+ * `heldToolArgv` keeps the `import.meta.url` form: launchCommand.mjs's
16
+ * `selfArgv` reads process.argv[1], which under the suite is the test file.
17
+ */
18
+ import { createServer } from 'node:http';
19
+ import { randomBytes } from 'node:crypto';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { sameSecret } from './grant.mjs';
22
+ import { runningCompiledBinary } from './launchCommand.mjs';
23
+
24
+ /** One run's capability: the only credential the tool's child ever holds. */
25
+ export const mintHeldCapability = () => randomBytes(32).toString('hex');
26
+
27
+ /** The argv that starts this flowviant again as `verb`, under node or the compiled binary. */
28
+ export function heldToolArgv(verb) {
29
+ return runningCompiledBinary() ? [verb] : [fileURLToPath(new URL('../cli.mjs', import.meta.url)), verb];
30
+ }
31
+
32
+ /**
33
+ * Listen on a loopback port for one held tool. `respond(req, res)` answers
34
+ * only a request that passed the gate; `isClosed()` shuts the gate before the
35
+ * server stops. Returns the server (the caller closes it, in its own order),
36
+ * the capability, `<envPrefix>_URL`/`_CAPABILITY`, and the child's argv.
37
+ */
38
+ export async function listenHeldTool({ envPrefix, verb, requestTimeoutMs, capability = mintHeldCapability(), isClosed, respond }) {
39
+ const server = createServer(async (req, res) => {
40
+ if (isClosed() || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) {
41
+ res.writeHead(403).end(); return;
42
+ }
43
+ await respond(req, res);
44
+ });
45
+ server.requestTimeout = requestTimeoutMs;
46
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
47
+ const env = { [`${envPrefix}_URL`]: `http://127.0.0.1:${server.address().port}/`, [`${envPrefix}_CAPABILITY`]: capability };
48
+ return { server, capability, env, args: heldToolArgv(verb) };
49
+ }
@@ -17,10 +17,17 @@
17
17
  * `touch`, an outside Write and WebFetch were refused exactly as without the
18
18
  * hook.
19
19
  *
20
- * It decides MCP calls and nothing else: any other tool, and Flowviant's own
21
- * control plane (`mcp__flowviant`, never mounted on an agent turn), get no
22
- * decision, and the posture's list decides. A hook that fails gives no
23
- * decision either, so a broken hook leaves MCP calls refused — closed.
20
+ * It decides MCP calls and nothing else: any other tool gets no decision, and
21
+ * the posture's list decides. A hook that fails gives no decision either, so a
22
+ * broken hook leaves MCP calls refused — closed.
23
+ *
24
+ * `mcp__flowviant` IS ONE OF THEM (2026-10-17). Only a card turn carries this
25
+ * hook, and a card turn mints no Flowviant server of its own, so a server of
26
+ * that name there is the person's own: since 0.125.5 their user and local
27
+ * servers ride the card (claudeMachineMcp.mjs `machineMcpServers`), and the
28
+ * owner keeps a local `flowviant` server on purpose — "its my way of having
29
+ * the board have a set of tools to manage the flowviant web page and board".
30
+ * A code card already admitted it under its bypass; a write-up card refused it.
24
31
  */
25
32
  import { pathToFileURL } from 'node:url';
26
33
 
@@ -28,7 +35,6 @@ import { pathToFileURL } from 'node:url';
28
35
  export function mcpAllowDecision(payload) {
29
36
  const name = payload?.tool_name;
30
37
  if (typeof name !== 'string' || !name.startsWith('mcp__')) return null;
31
- if (name === 'mcp__flowviant' || name.startsWith('mcp__flowviant__')) return null;
32
38
  return {
33
39
  hookSpecificOutput: {
34
40
  hookEventName: 'PreToolUse',
@@ -1,26 +1,36 @@
1
1
  /** Native gate; a full pool refuses only this spawn, preserving the turn. */
2
2
  import { fileURLToPath } from 'node:url';
3
- import { callOrchestrator } from '../orchestratorClient.mjs';
3
+ import { callOrchestrator, OrchestratorRefusal } from '../orchestratorClient.mjs';
4
4
  import { OrchestratorInputTooLarge, readOrchestratorInput } from '../orchestratorInput.mjs';
5
5
  import { runningCompiledBinary } from '../launchCommand.mjs';
6
+ import { gatedSpawnEvent } from '../nativeAgentTools.mjs';
7
+
8
+ // __hook exits as soon as main returns. A large rewritten child prompt must
9
+ // reach the pipe before that exit, not stop partway through JSON.
10
+ const answer = output => new Promise((resolve, reject) => process.stdout.write(`${JSON.stringify(output)}\n`, error => error ? reject(error) : resolve()));
6
11
 
7
12
  export async function main() {
13
+ let event = null;
8
14
  try {
9
- const event = await readOrchestratorInput(process.stdin);
15
+ event = await readOrchestratorInput(process.stdin);
10
16
  const result = await callOrchestrator('hook', event);
11
- const output = result.deny ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: result.deny } }
17
+ // A stop is recorded, never refused or continued (stop_hook_active or not).
18
+ const output = event.hook_event_name === 'SubagentStop' ? {}
19
+ : result.deny ? { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: result.deny } }
12
20
  : result.updatedInput || result.context ? { hookSpecificOutput: { hookEventName: event.hook_event_name,
13
21
  ...(result.updatedInput ? { permissionDecision: 'allow', updatedInput: result.updatedInput } : {}),
14
22
  ...(result.context ? { additionalContext: result.context } : {}) } } : {};
15
- // __hook exits as soon as main returns. A large rewritten child prompt
16
- // must reach the pipe before that exit, not stop partway through JSON.
17
- await new Promise((resolve, reject) => process.stdout.write(`${JSON.stringify(output)}\n`, error => error ? reject(error) : resolve()));
23
+ await answer(output);
18
24
  } catch (error) {
19
- // Both CLIs interpret exit 2 as a tool refusal. A failed capability must
20
- // never let a native spawn escape the shared admission pool.
21
- process.stderr.write(error instanceof OrchestratorInputTooLarge ? `${error.message}\n`
25
+ process.stderr.write(error instanceof OrchestratorInputTooLarge || error instanceof OrchestratorRefusal ? `${error.message}\n`
22
26
  : 'The run’s orchestration gate is unavailable. Keep the work in the orchestrator until it reconnects.\n');
23
- process.exitCode = 2;
27
+ // FAIL CLOSED ONLY WHERE A SPAWN COULD ESCAPE THE SHARED POOL (2026-10-17).
28
+ // Both CLIs read exit 2 as a refusal; after a tool ran (Codex replaces its
29
+ // output) or at a stop (the child keeps going) that refusal is false. An
30
+ // event too large to read is never a spawn: no model writes 24 MiB of input.
31
+ // The names are the controller's own list (nativeAgentTools.mjs).
32
+ if (gatedSpawnEvent(event)) { process.exitCode = 2; return; }
33
+ try { await answer({}); } catch { /* the CLI already stopped reading */ }
24
34
  }
25
35
  }
26
36
  // Bun bundles this module at the virtual entrypoint too. Only cli.mjs may
@@ -1,7 +1,7 @@
1
1
  /** A ticket becomes one card only when the CLI supplied all four fields.
2
2
  * Forgive a JSON fence or a sentence around it, as the plan reader does;
3
3
  * refuse a half-card rather than landing plausible but unactionable work. */
4
- import { candidateObjects } from './agentPlan.mjs';
4
+ import { candidateObjects } from './modelJson.mjs';
5
5
  import { agentTaskTypeOf } from './agentTaskTypes.mjs';
6
6
  import { scrub as envScrub } from './uplinkScrub.mjs';
7
7
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * FOLLOWING A GROWING JSONL FILE, BOUNDED PER POLL (2026-10-18 SOLID pass).
3
+ *
4
+ * claudeSubagentProgress.mjs (Claude's agent_progress rows) and
5
+ * codexSubagents.mjs (each child's rollout) wrote this out by hand with the
6
+ * same bounds, so a tail-reading fix (truncation, a torn or an oversized
7
+ * line) would land in one reader and miss the other. A first read starts
8
+ * within the last `scan` bytes and skips its torn first line. A file that
9
+ * shrank restarts at 0. One poll reads at most `scan` bytes in `chunk`-sized
10
+ * reads. Bytes after the last newline carry to the next poll, so a UTF-8
11
+ * character or a line split across reads survives. A line longer than
12
+ * `chunk` is discarded through its newline rather than reparsed as a suffix.
13
+ *
14
+ * Each line goes to `onLine` as it is read, so the lines before a read that
15
+ * fails mid-poll are still handled: the offset has already moved past them.
16
+ * A leaf: it reads through the caller's open handle.
17
+ */
18
+
19
+ export const TAIL_SCAN_BYTES = 4 * 1024 * 1024;
20
+ export const TAIL_CHUNK_BYTES = 256 * 1024;
21
+
22
+ /** A tail that has read nothing yet. */
23
+ export const newTail = () => ({ offset: null, rest: Buffer.alloc(0), skip: false });
24
+
25
+ export async function followJsonl(fh, tail, onLine, { scan = TAIL_SCAN_BYTES, chunk = TAIL_CHUNK_BYTES } = {}) {
26
+ const { size } = await fh.stat();
27
+ if (tail.offset === null) {
28
+ tail.offset = Math.max(0, size - scan);
29
+ tail.skip = tail.offset > 0;
30
+ }
31
+ if (size < tail.offset) { tail.offset = 0; tail.rest = Buffer.alloc(0); }
32
+ // Bounded work per poll, including an immediately completed child.
33
+ const end = Math.min(size, tail.offset + scan);
34
+ while (tail.offset < end) {
35
+ const buffer = Buffer.alloc(Math.min(chunk, end - tail.offset));
36
+ const { bytesRead } = await fh.read(buffer, 0, buffer.length, tail.offset);
37
+ if (!bytesRead) break;
38
+ tail.offset += bytesRead;
39
+ const data = Buffer.concat([tail.rest, buffer.subarray(0, bytesRead)]);
40
+ let start = 0, nl;
41
+ while ((nl = data.indexOf(10, start)) >= 0) {
42
+ const line = data.subarray(start, nl).toString('utf8');
43
+ start = nl + 1;
44
+ if (tail.skip) { tail.skip = false; continue; }
45
+ onLine(line);
46
+ }
47
+ // Keep bytes across UTF-8 and partial-line boundaries. Oversized lines
48
+ // are discarded through their newline rather than reparsed as a suffix.
49
+ tail.rest = data.subarray(start);
50
+ if (tail.rest.length > chunk) { tail.rest = Buffer.alloc(0); tail.skip = true; }
51
+ }
52
+ }
@@ -57,7 +57,8 @@ function bound(servers) {
57
57
  const seen = new Set();
58
58
  return servers
59
59
  .map((s) => ({ ...s, name: s.name.trim().slice(0, MCP_INVENTORY_NAME_MAX).trim() }))
60
- .filter((s) => s.name && s.name !== 'flowviant' && !seen.has(s.name) && seen.add(s.name))
60
+ // Only the person's own `flowviant` reaches here as theirs (2026-10-19).
61
+ .filter((s) => s.name && (s.name !== 'flowviant' || s.source === 'personal') && !seen.has(s.name) && seen.add(s.name))
61
62
  .sort((a, b) => SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
62
63
  .slice(0, MCP_INVENTORY_MAX_SERVERS)
63
64
  .map((s) => {
@@ -91,16 +92,20 @@ export function claudeInventory(harvested, snapshot) {
91
92
 
92
93
  /**
93
94
  * Codex: its own config's servers, then each declaration. A declared server
94
- * replaces a personal one of the same name on a card's turn
95
+ * that mounts replaces a personal one of the same name on a card's turn
95
96
  * (projectToolRuntimes.mjs `isolatedCodexHome`), so the declaration is the
96
- * row. Codex names no tools without starting a server, so `tools` is null.
97
+ * row; one that cannot mount (2026-10-17) leaves the person's server on the
98
+ * card, and that is the row. Codex names no tools without starting a server,
99
+ * so `tools` is null.
97
100
  */
98
101
  export function codexInventory(rows, snapshot) {
99
102
  const declared = declaredRows(snapshot, 'codex');
100
- const names = new Set(declared.map((d) => d.name));
103
+ const mounts = new Set(declared.filter((d) => d.state === 'ready').map((d) => d.name));
104
+ const personal = (rows ?? []).filter((r) => !mounts.has(r.name));
105
+ const theirs = new Set(personal.map((r) => r.name));
101
106
  const servers = [
102
- ...declared.map((d) => ({ name: d.name, source: 'project', status: 'declared', reason: d.reason, tools: null })),
103
- ...(rows ?? []).filter((r) => !names.has(r.name)).map((r) => ({ name: r.name, source: 'personal', status: r.status, reason: r.reason, tools: null })),
107
+ ...declared.filter((d) => !theirs.has(d.name)).map((d) => ({ name: d.name, source: 'project', status: 'declared', reason: d.reason, tools: null })),
108
+ ...personal.map((r) => ({ name: r.name, source: 'personal', status: r.status, reason: r.reason, tools: null })),
104
109
  ];
105
110
  return { reported: rows != null, servers: bound(servers) };
106
111
  }