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
@@ -56,7 +56,7 @@
56
56
  */
57
57
  import { readFileSync, statSync } from 'node:fs';
58
58
  import { join } from 'node:path';
59
- import { toml } from './projectToolRuntimes.mjs';
59
+ import { isTable, newTable, parseCodexToml, tomlValue } from './codexToml.mjs';
60
60
  import { codexHome } from './codexHome.mjs';
61
61
 
62
62
  /** Where the login lives and which endpoint it signs into — top-level keys,
@@ -104,259 +104,6 @@ export const CODEX_HEADER_ENV_PREFIX = 'FLOWVIANT_CODEX_SECRET_HEADER_';
104
104
 
105
105
  /** Far past any real config.toml; a bigger one is not read. */
106
106
  const CONFIG_MAX_BYTES = 1024 * 1024;
107
- /** Arrays and inline tables nest no deeper than this in a file we read. */
108
- const MAX_DEPTH = 32;
109
-
110
- /** A number, date or time, kept as written so it is carried as written. */
111
- export class TomlRaw {
112
- constructor(text) {
113
- this.text = text;
114
- }
115
- }
116
- const newTable = () => Object.create(null);
117
- const isTable = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !(v instanceof TomlRaw);
118
-
119
- const BARE = /[A-Za-z0-9_-]+/y;
120
- // First match wins, so the forms that begin like an integer come first.
121
- const SCALAR =
122
- /[+-]?(?: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;
123
-
124
- /**
125
- * A BOUNDED TOML READER — the repo carries no TOML library, and the one file
126
- * it reads is Codex's own config. Whole documents: tables, arrays of tables
127
- * (the daemon's own `[[skills.config]]` in an agent's home), dotted and quoted
128
- * keys, the four string forms, arrays, inline tables, booleans, and numbers,
129
- * dates and times kept as written. Anything else throws, and the caller
130
- * carries nothing — a partial read is never a license to guess.
131
- */
132
- export function parseCodexToml(text) {
133
- const src = String(text).replace(/^/, '');
134
- const n = src.length;
135
- let i = 0;
136
- const fail = (why) => {
137
- throw new Error(`config.toml: ${why} at offset ${i}`);
138
- };
139
- const ws = () => {
140
- while (i < n && (src[i] === ' ' || src[i] === '\t')) i++;
141
- };
142
- const comment = () => {
143
- if (src[i] === '#') while (i < n && src[i] !== '\n') i++;
144
- };
145
- const newline = () => {
146
- if (src[i] === '\n') return (i += 1), true;
147
- if (src[i] === '\r' && src[i + 1] === '\n') return (i += 2), true;
148
- return false;
149
- };
150
- /** Blank lines, comments and whitespace, as between array items. */
151
- const gap = () => {
152
- for (;;) {
153
- ws();
154
- comment();
155
- if (!newline()) return;
156
- }
157
- };
158
- const endOfLine = () => {
159
- ws();
160
- comment();
161
- if (i < n && !newline()) fail('expected the end of the line');
162
- };
163
- const hexChar = (len) => {
164
- const h = src.slice(i, i + len);
165
- if (h.length !== len || !/^[0-9A-Fa-f]+$/.test(h)) fail('bad escape');
166
- i += len;
167
- const cp = parseInt(h, 16);
168
- if (cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff)) fail('bad escape');
169
- return String.fromCodePoint(cp);
170
- };
171
- const escape = () => {
172
- const e = src[i + 1];
173
- i += 2;
174
- switch (e) {
175
- case 'b': return '\b';
176
- case 't': return '\t';
177
- case 'n': return '\n';
178
- case 'f': return '\f';
179
- case 'r': return '\r';
180
- case 'e': return '\x1b';
181
- case '"': return '"';
182
- case '\\': return '\\';
183
- case 'x': return hexChar(2);
184
- case 'u': return hexChar(4);
185
- case 'U': return hexChar(8);
186
- default: return fail('bad escape');
187
- }
188
- };
189
- const basicString = () => {
190
- i += 1;
191
- let s = '';
192
- for (;;) {
193
- if (i >= n) fail('unterminated string');
194
- const ch = src[i];
195
- if (ch === '"') return (i += 1), s;
196
- if (ch === '\n' || ch === '\r') fail('newline in a string');
197
- if (ch === '\\') s += escape();
198
- else (s += ch), (i += 1);
199
- }
200
- };
201
- const literalString = () => {
202
- const end = src.indexOf("'", i + 1);
203
- if (end < 0) fail('unterminated string');
204
- const s = src.slice(i + 1, end);
205
- if (/[\r\n]/.test(s)) fail('newline in a string');
206
- i = end + 1;
207
- return s;
208
- };
209
- const LINE_END_BACKSLASH = /\\[ \t]*\r?\n/y;
210
- const multiline = (quote, escapes) => {
211
- i += 3;
212
- newline(); // a newline straight after the opening quotes is not content
213
- let s = '';
214
- for (;;) {
215
- if (i >= n) fail('unterminated string');
216
- if (src.startsWith(quote.repeat(3), i)) {
217
- let q = 3;
218
- while (q < 5 && src[i + q] === quote) q++; // up to two belong to the content
219
- s += quote.repeat(q - 3);
220
- i += q;
221
- return s;
222
- }
223
- if (escapes && src[i] === '\\') {
224
- LINE_END_BACKSLASH.lastIndex = i;
225
- if (LINE_END_BACKSLASH.test(src)) {
226
- i = LINE_END_BACKSLASH.lastIndex;
227
- while (i < n && /[ \t\r\n]/.test(src[i])) i++;
228
- } else s += escape();
229
- } else (s += src[i]), (i += 1);
230
- }
231
- };
232
- const key = () => {
233
- const parts = [];
234
- for (;;) {
235
- ws();
236
- if (src[i] === '"') {
237
- if (src.startsWith('"""', i)) fail('a multi-line key');
238
- parts.push(basicString());
239
- } else if (src[i] === "'") {
240
- if (src.startsWith("'''", i)) fail('a multi-line key');
241
- parts.push(literalString());
242
- } else {
243
- BARE.lastIndex = i;
244
- const m = BARE.exec(src);
245
- if (!m) fail('expected a key');
246
- parts.push(m[0]);
247
- i = BARE.lastIndex;
248
- }
249
- ws();
250
- if (src[i] !== '.') return parts;
251
- i += 1;
252
- }
253
- };
254
- const assign = (target, path, v) => {
255
- let t = target;
256
- for (const k of path.slice(0, -1)) {
257
- if (!Object.hasOwn(t, k)) t[k] = newTable();
258
- else if (!isTable(t[k])) fail(`"${k}" is not a table`);
259
- t = t[k];
260
- }
261
- const last = path.at(-1);
262
- if (Object.hasOwn(t, last)) fail(`"${last}" is defined twice`);
263
- t[last] = v;
264
- };
265
- let depth = 0;
266
- const nested = (open) => {
267
- if (++depth > MAX_DEPTH) fail('nested too deep');
268
- try {
269
- return open();
270
- } finally {
271
- depth -= 1;
272
- }
273
- };
274
- const array = () => {
275
- i += 1;
276
- const out = [];
277
- for (;;) {
278
- gap();
279
- if (src[i] === ']') return (i += 1), out;
280
- out.push(value());
281
- gap();
282
- if (src[i] === ',') i += 1;
283
- else if (src[i] === ']') return (i += 1), out;
284
- else fail('expected , or ]');
285
- }
286
- };
287
- const inlineTable = () => {
288
- i += 1;
289
- const t = newTable();
290
- for (;;) {
291
- gap();
292
- if (src[i] === '}') return (i += 1), t;
293
- keyValue(t);
294
- gap();
295
- if (src[i] === ',') i += 1;
296
- else if (src[i] === '}') return (i += 1), t;
297
- else fail('expected , or }');
298
- }
299
- };
300
- const word = (w) => src.startsWith(w, i) && !/[A-Za-z0-9_-]/.test(src[i + w.length] ?? '');
301
- const value = () => {
302
- const ch = src[i];
303
- if (ch === '"') return src.startsWith('"""', i) ? multiline('"', true) : basicString();
304
- if (ch === "'") return src.startsWith("'''", i) ? multiline("'", false) : literalString();
305
- if (ch === '[') return nested(array);
306
- if (ch === '{') return nested(inlineTable);
307
- if (word('true')) return (i += 4), true;
308
- if (word('false')) return (i += 5), false;
309
- SCALAR.lastIndex = i;
310
- const m = SCALAR.exec(src);
311
- if (!m) fail('a value this reader does not know');
312
- i = SCALAR.lastIndex;
313
- if (/[A-Za-z0-9_.:+-]/.test(src[i] ?? '')) fail('a value this reader does not know');
314
- return new TomlRaw(m[0]);
315
- };
316
- const keyValue = (target) => {
317
- const path = key();
318
- if (src[i] !== '=') fail('expected =');
319
- i += 1;
320
- ws();
321
- assign(target, path, value());
322
- };
323
-
324
- const root = newTable();
325
- let current = root;
326
- for (;;) {
327
- gap();
328
- if (i >= n) return root;
329
- if (src[i] !== '[') {
330
- keyValue(current);
331
- endOfLine();
332
- continue;
333
- }
334
- const many = src[i + 1] === '[';
335
- i += many ? 2 : 1;
336
- const path = key();
337
- if (many ? !src.startsWith(']]', i) : src[i] !== ']') fail('an unclosed table header');
338
- i += many ? 2 : 1;
339
- endOfLine();
340
- let t = root;
341
- for (const k of path.slice(0, -1)) {
342
- if (!Object.hasOwn(t, k)) t[k] = newTable();
343
- const v = Array.isArray(t[k]) ? t[k].at(-1) : t[k];
344
- if (!isTable(v)) fail(`"${k}" is not a table`);
345
- t = v;
346
- }
347
- const last = path.at(-1);
348
- if (many) {
349
- if (!Object.hasOwn(t, last)) t[last] = [];
350
- if (!Array.isArray(t[last])) fail(`"${last}" is not an array of tables`);
351
- current = newTable();
352
- t[last].push(current);
353
- } else {
354
- if (!Object.hasOwn(t, last)) t[last] = newTable();
355
- else if (!isTable(t[last])) fail(`"${last}" is not a table`);
356
- current = t[last];
357
- }
358
- }
359
- }
360
107
 
361
108
  /**
362
109
  * The person's config.toml, where the turn's Codex would look —
@@ -374,17 +121,6 @@ export function readPersonalCodexConfig(env = process.env) {
374
121
  }
375
122
  }
376
123
 
377
- /** One value as TOML, as a `-c` override's value is parsed. */
378
- const bareKeyOr = (k) => (/^[A-Za-z0-9_-]+$/.test(k) ? k : toml(k));
379
- export function tomlValue(v) {
380
- if (typeof v === 'string') return toml(v);
381
- if (typeof v === 'boolean') return String(v);
382
- if (v instanceof TomlRaw) return v.text;
383
- if (Array.isArray(v)) return `[${v.map(tomlValue).join(',')}]`;
384
- if (isTable(v)) return `{${Object.keys(v).map((k) => `${bareKeyOr(k)}=${tomlValue(v[k])}`).join(',')}}`;
385
- throw new Error('not a TOML value');
386
- }
387
-
388
124
  const isString = (v) => typeof v === 'string';
389
125
  const isStringList = (v) => Array.isArray(v) && v.every(isString);
390
126
  const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
@@ -6,16 +6,17 @@ import { execFileSync } from 'node:child_process';
6
6
  import { lstatSync, readdirSync, realpathSync } from 'node:fs';
7
7
  import { isAbsolute, join, dirname } from 'node:path';
8
8
  import { git } from './git.mjs';
9
- import { codexResearchHelper } from './codexResearch.mjs';
9
+ import { CODEX_HELPER_MIN, codexResearchHelper } from './codexResearch.mjs';
10
+ import { versionAtLeast } from './versionOrder.mjs';
10
11
  import { unattendedEnv } from './noWindowEnv.mjs';
11
12
 
12
13
  export function projectReadBoundary({ cwd, knowledgeDir, env = process.env }) {
13
14
  if (process.platform !== 'linux') throw new Error('Project-scoped Codex reads require Linux or WSL.');
14
15
  if (!isAbsolute(cwd ?? '')) throw new Error('Project-scoped Codex reads require an absolute project directory.');
15
16
  const helper = codexResearchHelper(env);
16
- const version = execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10000, env: unattendedEnv(env) }).match(/^codex-cli (\d+)\.(\d+)\.(\d+)/);
17
- if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
18
- throw new Error('Project-scoped reads need Codex CLI 0.159.2 or newer. Update Codex before retrying.');
17
+ const version = execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10000, env: unattendedEnv(env) }).match(/^codex-cli (\d+\.\d+\.\d+)/);
18
+ if (!version || !versionAtLeast(version[1], CODEX_HELPER_MIN))
19
+ throw new Error(`Project-scoped reads need Codex CLI ${CODEX_HELPER_MIN} or newer. Update Codex before retrying.`);
19
20
  const project = realpathSync(cwd);
20
21
  // Resolve both: linked worktrees have their own HEAD/index and shared
21
22
  // objects/refs in the common directory. Never grant the parent checkout.
@@ -10,6 +10,7 @@ import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
10
10
  import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
11
11
  import { dirname, isAbsolute, join, resolve } from 'node:path';
12
12
  import { createRequire } from 'node:module';
13
+ import { versionAtLeast } from './versionOrder.mjs';
13
14
 
14
15
  const MAX_FILES = 20_000;
15
16
  const MAX_BYTES = 128 * 1024 * 1024;
@@ -51,6 +52,11 @@ export function copyResearchView(source, destination, budget = { files: 0, bytes
51
52
  }
52
53
  }
53
54
 
55
+ /** The helper both the research fence and project-scoped reads need
56
+ * (codexProjectRead.mjs): one floor, so neither runs on a Codex the other
57
+ * refuses. */
58
+ export const CODEX_HELPER_MIN = '0.159.2';
59
+
54
60
  /** Resolve the actual sandbox helper without granting the person's bin/home.
55
61
  * Support native installs and the npm wrapper's platform package. Unknown
56
62
  * installations refuse; granting the whole home to find a helper is no fence.
@@ -147,9 +153,9 @@ export async function runIsolatedResearch(opts, run, {
147
153
  if (process.platform !== 'linux') throw new Error('Isolated Codex research requires Linux or WSL.');
148
154
  const original = realpathSync(opts.cwd);
149
155
  const helper = resolveHelper();
150
- const version = readVersion(helper).match(/codex-cli (\d+)\.(\d+)\.(\d+)/);
151
- if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
152
- throw new Error('Isolated research needs Codex CLI 0.159.2 or newer. Restart after updating Codex.');
156
+ const version = readVersion(helper).match(/codex-cli (\d+\.\d+\.\d+)/);
157
+ if (!version || !versionAtLeast(version[1], CODEX_HELPER_MIN))
158
+ throw new Error(`Isolated research needs Codex CLI ${CODEX_HELPER_MIN} or newer. Restart after updating Codex.`);
153
159
  // Both publication parents must be real directories in this checkout, or
154
160
  // absent — never a link, dangling or not (ownedDirs.mjs).
155
161
  for (const dir of [join(original, '.flowviant'), join(original, '.flowviant/artifacts')])
@@ -1,24 +1,28 @@
1
- const SUBAGENT_LIMIT = 32;
2
- const THREAD_ID = /^[a-zA-Z0-9-]{16,64}$/;
1
+ import { CODEX_CHILD_THREAD_RE } from './conversationIds.mjs';
2
+ import { NATIVE_AGENT_TOOLS } from './nativeAgentTools.mjs';
3
+ import { SUBAGENT_CAP } from './subagentBounds.mjs';
4
+
3
5
  const snake = s => String(s ?? '').replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
6
+ // A collab item as exec (`collab_tool_call`) or a rollout (`CollabAgentToolCall`) spells it.
7
+ const isCollabItem = item => ['collab_tool_call', 'CollabAgentToolCall'].includes(item?.type ?? item?.item_type);
4
8
  export const statusOf = (s) => ({ pending_init: 'waiting', running: 'running', completed: 'done',
5
9
  interrupted: 'stopped', errored: 'stopped', shutdown: 'stopped', not_found: 'stopped',
6
10
  waiting: 'waiting', asked: 'asked' })[snake(typeof s === 'string' ? s : s && Object.keys(s)[0])];
7
11
 
8
12
  export function codexSpawnResult(item) {
9
- if (!['collab_tool_call', 'CollabAgentToolCall'].includes(item?.type ?? item?.item_type) || snake(item.tool) !== 'spawn_agent' || !item.id) return null;
13
+ if (!isCollabItem(item) || snake(item.tool) !== 'spawn_agent' || !item.id) return null;
10
14
  if (['failed', 'errored'].includes(snake(item.status))) return { callId: item.id, success: false };
11
15
  const nativeId = item.receiver_thread_ids?.[0];
12
16
  return nativeId ? { callId: item.id, nativeId, success: true } : null;
13
17
  }
14
18
 
15
19
  export function codexCollabUpdates(item) {
16
- if (!['collab_tool_call', 'CollabAgentToolCall'].includes(item?.type ?? item?.item_type)) return [];
20
+ if (!isCollabItem(item)) return [];
17
21
  const tool = snake(item.tool);
18
22
  const states = item.agents_states ?? {};
19
- const ids = [...new Set([...(item.receiver_thread_ids ?? []), ...Object.keys(states)])].slice(0, SUBAGENT_LIMIT);
23
+ const ids = [...new Set([...(item.receiver_thread_ids ?? []), ...Object.keys(states)])].slice(0, SUBAGENT_CAP);
20
24
  return ids.flatMap(id => {
21
- if (typeof id !== 'string' || !THREAD_ID.test(id)) return [];
25
+ if (typeof id !== 'string' || !CODEX_CHILD_THREAD_RE.test(id)) return [];
22
26
  const state = states[id];
23
27
  const receiver = item.receiver_agents?.find(a => a.thread_id === id);
24
28
  const status = statusOf(state?.status ?? state);
@@ -27,7 +31,7 @@ export function codexCollabUpdates(item) {
27
31
  ...(receiver?.agent_path || receiver?.agent_nickname || item.title ? { title: receiver?.agent_path ?? receiver?.agent_nickname ?? item.title } : {}),
28
32
  ...(receiver?.agent_role || item.agent_type ? { kind: receiver?.agent_role ?? item.agent_type } : {}),
29
33
  } : {}),
30
- ...(status ? { status } : ['close_agent', 'interrupt_agent'].includes(tool) ? { status: 'stopped' } : {}),
34
+ ...(status ? { status } : NATIVE_AGENT_TOOLS.codex.stop.includes(tool) ? { status: 'stopped' } : {}),
31
35
  ...(typeof message === 'string' ? { now: message, message } : {}),
32
36
  }];
33
37
  });
@@ -3,15 +3,22 @@
3
3
  * rollouts named by this turn's native spawn receipts, never a global latest.
4
4
  * rust-v0.160.0: exec_events.rs CollabToolCallItem; core/agent/child_config.rs
5
5
  * reapplies the live turn's approval and permission profile after role config.
6
+ * V2 rollout shapes measured on codex-cli 0.163.0 (codexSubagents.test.mjs).
6
7
  */
7
8
  import { open } from 'node:fs/promises';
8
9
  import { codexRolloutFile } from './runtimeLimits.mjs';
10
+ import { CODEX_CHILD_THREAD_RE } from './conversationIds.mjs';
11
+ import { followJsonl } from './jsonlTail.mjs';
9
12
  import { codexCollabUpdates, codexSpawnResult, statusOf } from './codexSubagentEvents.mjs';
13
+ import { NATIVE_AGENT_TOOLS } from './nativeAgentTools.mjs';
14
+ import { SUBAGENT_CAP } from './subagentBounds.mjs';
10
15
 
11
- export const SUBAGENT_LIMIT = 32;
12
- const READ_BYTES = 256 * 1024;
13
- const INITIAL_SCAN = 4 * 1024 * 1024;
14
- const THREAD_ID = /^[a-zA-Z0-9-]{16,64}$/;
16
+ const CODEX = NATIVE_AGENT_TOOLS.codex;
17
+ // A rollout shows a superset of what the gate sees: send_message and the
18
+ // stop tools too.
19
+ const COLLAB_CALLS = [...CODEX.spawn, ...CODEX.followup, ...CODEX.message, ...CODEX.stop];
20
+ // The calls whose output names a child's receipt.
21
+ const RECEIPT_CALLS = [...CODEX.spawn, ...CODEX.followup];
15
22
 
16
23
  export function createCodexSubagentReader({ since = Date.now(), env = process.env, onSubagent, onActivity, onToolEvent, onSpawnResult,
17
24
  findFile = codexRolloutFile } = {}) {
@@ -24,8 +31,9 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
24
31
  let stopped = false;
25
32
  let chain = Promise.resolve();
26
33
  const report = update => {
27
- if (!update?.id || !THREAD_ID.test(update.id)) return;
28
- if (!readers.has(update.id) && readers.size >= SUBAGENT_LIMIT + 1) return;
34
+ if (!update?.id || !CODEX_CHILD_THREAD_RE.test(update.id)) return;
35
+ // The root thread plus the run's children.
36
+ if (!readers.has(update.id) && readers.size >= SUBAGENT_CAP + 1) return;
29
37
  if (!readers.has(update.id))
30
38
  readers.set(update.id, { offset: null, rest: Buffer.alloc(0), seen: new Set(), live: false });
31
39
  const { message, ...record } = update;
@@ -35,7 +43,7 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
35
43
  targets.set(path, id);
36
44
  if (targets.size > 128) targets.delete(targets.keys().next().value);
37
45
  };
38
- const targetId = value => targets.get(value) ?? (THREAD_ID.test(value ?? '') ? value : null);
46
+ const targetId = value => targets.get(value) ?? (CODEX_CHILD_THREAD_RE.test(value ?? '') ? value : null);
39
47
 
40
48
  function observe(ev, owner) {
41
49
  const at = Date.parse(ev?.timestamp ?? '');
@@ -68,40 +76,52 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
68
76
  if (command) step(p.call_id, Array.isArray(command) ? command.join(' ') : command);
69
77
  return;
70
78
  }
71
- if (owner !== root && name === 'request_user_input') {
79
+ if (owner !== root && CODEX.ask.includes(name)) {
72
80
  questions.set(p.call_id, owner);
73
81
  if (questions.size > 128) questions.delete(questions.keys().next().value);
74
82
  report({ id: owner, status: 'asked' }); return;
75
83
  }
76
- if (!['spawn_agent', 'send_input', 'send_message', 'followup_task', 'resume_agent', 'close_agent', 'interrupt_agent'].includes(name)) return;
84
+ if (!COLLAB_CALLS.includes(name)) return;
77
85
  let args;
78
86
  try { args = JSON.parse(p.arguments); } catch { return; }
79
87
  calls.set(p.call_id, { name, args });
80
88
  if (calls.size > 128) calls.delete(calls.keys().next().value);
81
89
  const id = targetId(args.target ?? args.id);
82
90
  if (id && name !== 'spawn_agent') {
83
- const stopped = name === 'close_agent' || name === 'interrupt_agent';
91
+ const stopped = CODEX.stop.includes(name);
84
92
  report({ id, ...(stopped ? { status: 'stopped' } : name === 'followup_task' ? { status: 'running' } : {}) });
85
93
  }
86
94
  }
87
95
  if (ev.type === 'response_item' && p.type === 'function_call_output') {
88
96
  if (questions.has(p.call_id)) { report({ id: questions.get(p.call_id), status: 'running' }); questions.delete(p.call_id); }
89
97
  const call = calls.get(p.call_id);
90
- if (!['spawn_agent', 'followup_task', 'resume_agent', 'send_input'].includes(call?.name)) return;
91
- let output;
98
+ if (!RECEIPT_CALLS.includes(call?.name)) return;
99
+ let output = null;
92
100
  try {
93
101
  const body = Array.isArray(p.output) ? p.output.map(b => b.text ?? '').join('') : p.output;
94
102
  output = typeof body === 'string' ? JSON.parse(body) : body;
95
- } catch { onSpawnResult?.({ callId: p.call_id, success: false }); return; }
96
- const id = output?.agent_id ?? output?.thread_id;
97
- const receipt = id ?? output?.agent_path ?? (call.name !== 'spawn_agent' && (output?.submission_id || output?.status) ? call.args.target ?? call.args.id : null);
98
- onSpawnResult?.({ callId: p.call_id, success: Boolean(receipt), ...(receipt ? { nativeId: receipt } : {}) });
103
+ } catch { /* the tool's own words */ }
104
+ // ONLY AN EXPLICIT FAILURE FREES A SLOT (2026-10-17). These tools answer
105
+ // JSON on success (their output schemas), so plain text is the native
106
+ // error ("collab spawn failed: …"); V2 followup_task declares no output,
107
+ // and a JSON answer naming no receipt is unknown. Neither is a failure.
108
+ if (!output || typeof output !== 'object') {
109
+ if (call.name !== 'followup_task') onSpawnResult?.({ callId: p.call_id, success: false });
110
+ return;
111
+ }
112
+ // V2 (measured on 0.163) answers only the canonical task path; the
113
+ // thread arrived first, on the spawn's SubAgentActivity.
114
+ const path = output.task_name ?? output.agent_path;
115
+ const id = output.agent_id ?? output.thread_id ?? (typeof path === 'string' ? targets.get(path) : undefined);
116
+ const receipt = call.name === 'spawn_agent' ? output.agent_id ?? output.thread_id ?? path
117
+ : output.submission_id || output.status ? call.args.target ?? call.args.id : null;
118
+ if (receipt) onSpawnResult?.({ callId: p.call_id, success: true, nativeId: receipt });
99
119
  if (call.name !== 'spawn_agent') return;
100
120
  if (!id) return;
101
121
  const args = call.args;
102
122
  const title = args.task_name ?? args.description ?? output.agent_nickname ?? '';
103
123
  if (args.task_name) rememberTarget(args.task_name, id);
104
- if (output.agent_path) rememberTarget(output.agent_path, id);
124
+ if (typeof path === 'string') rememberTarget(path, id);
105
125
  report({ id, kind: args.agent_type ?? '', title, brief: args.message ?? '', status: 'running', now: '' });
106
126
  return;
107
127
  }
@@ -112,7 +132,13 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
112
132
  if (ev.type !== 'event_msg') return;
113
133
  const activityState = item => {
114
134
  const kind = String(item.kind ?? '').toLowerCase();
115
- if (item.agent_thread_id) report({ id: item.agent_thread_id, title: item.agent_path,
135
+ if (!item.agent_thread_id) return;
136
+ if (item.agent_path) rememberTarget(item.agent_path, item.agent_thread_id);
137
+ // A start carries the spawn's own call id (measured on 0.163): the
138
+ // controller binds the native thread to the record that call reserved.
139
+ const callId = kind === 'started' ? item.call_id ?? item.id : null;
140
+ report({ id: item.agent_thread_id, title: item.agent_path,
141
+ ...(typeof callId === 'string' && callId ? { callId } : {}),
116
142
  ...(['started', 'completed', 'interrupted'].includes(kind) ? {
117
143
  status: kind === 'completed' ? 'done' : kind === 'interrupted' ? 'stopped' : 'running',
118
144
  } : {}),
@@ -163,41 +189,18 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
163
189
  if (!file) return;
164
190
  state.file = file;
165
191
  fh = await open(file, 'r');
166
- const { size } = await fh.stat();
167
192
  const initial = state.offset === null;
168
193
  const initialEvents = [];
169
- if (initial) {
170
- state.offset = Math.max(0, size - INITIAL_SCAN);
171
- state.skip = state.offset > 0;
172
- }
173
- if (size < state.offset) { state.offset = 0; state.rest = Buffer.alloc(0); }
174
- // Bounded work per poll, including an immediately completed child.
175
- const end = Math.min(size, state.offset + INITIAL_SCAN);
176
- while (state.offset < end) {
177
- const buffer = Buffer.alloc(Math.min(READ_BYTES, end - state.offset));
178
- const { bytesRead } = await fh.read(buffer, 0, buffer.length, state.offset);
179
- if (!bytesRead) break;
180
- state.offset += bytesRead;
181
- const data = Buffer.concat([state.rest, buffer.subarray(0, bytesRead)]);
182
- let start = 0, nl;
183
- while ((nl = data.indexOf(10, start)) >= 0) {
184
- const line = data.subarray(start, nl).toString('utf8');
185
- start = nl + 1;
186
- if (state.skip) { state.skip = false; continue; }
187
- try {
188
- const ev = JSON.parse(line);
189
- if (initial && id !== root) initialEvents.push(ev);
190
- else if (id === root || state.live || ev.type === 'session_meta') observe(ev, id);
191
- else if (ev.type === 'event_msg' && ['task_started', 'turn_started'].includes(ev.payload?.type)) {
192
- state.live = true; observe(ev, id);
193
- }
194
- } catch { /* non-JSON/unknown native events */ }
195
- }
196
- // Keep bytes across UTF-8 and partial-line boundaries. Oversized lines
197
- // are discarded through their newline rather than reparsed as a suffix.
198
- state.rest = data.subarray(start);
199
- if (state.rest.length > READ_BYTES) { state.rest = Buffer.alloc(0); state.skip = true; }
200
- }
194
+ await followJsonl(fh, state, line => {
195
+ try {
196
+ const ev = JSON.parse(line);
197
+ if (initial && id !== root) initialEvents.push(ev);
198
+ else if (id === root || state.live || ev.type === 'session_meta') observe(ev, id);
199
+ else if (ev.type === 'event_msg' && ['task_started', 'turn_started'].includes(ev.payload?.type)) {
200
+ state.live = true; observe(ev, id);
201
+ }
202
+ } catch { /* non-JSON/unknown native events */ }
203
+ });
201
204
  if (initial && id !== root) {
202
205
  // A fork copies the parent's old EventMsgs. Only the child's newest
203
206
  // native turn belongs to this card; inherited history is never replayed.
@@ -219,7 +222,7 @@ export function createCodexSubagentReader({ since = Date.now(), env = process.en
219
222
  };
220
223
  return {
221
224
  thread(id) {
222
- if (!THREAD_ID.test(id ?? '') || root) return;
225
+ if (!CODEX_CHILD_THREAD_RE.test(id ?? '') || root) return;
223
226
  root = id;
224
227
  readers.set(id, { offset: null, rest: Buffer.alloc(0), seen: new Set(), live: false });
225
228
  if (!stopped) { timer = setInterval(() => void flush(), 1_000); timer.unref?.(); }