flowviant 0.125.30 → 0.125.31

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 (43) hide show
  1. package/bin/lib/agentPlan.mjs +8 -0
  2. package/bin/lib/agentQueueContext.mjs +6 -0
  3. package/bin/lib/agentSplit.mjs +90 -0
  4. package/bin/lib/architectureContract.mjs +57 -0
  5. package/bin/lib/architectureReport.mjs +38 -0
  6. package/bin/lib/artifactContracts.mjs +21 -2
  7. package/bin/lib/claudeInput.mjs +1 -1
  8. package/bin/lib/claudeSubagents.mjs +46 -6
  9. package/bin/lib/claudeWorkflows.mjs +94 -0
  10. package/bin/lib/cliLimit.mjs +5 -0
  11. package/bin/lib/codexInput.mjs +191 -0
  12. package/bin/lib/codexSubagentEvents.mjs +3 -1
  13. package/bin/lib/codexSubagents.mjs +34 -9
  14. package/bin/lib/conversationOutput.mjs +14 -0
  15. package/bin/lib/crossCliChild.mjs +327 -0
  16. package/bin/lib/crossCliLaunch.mjs +92 -0
  17. package/bin/lib/crossCliPosture.mjs +55 -0
  18. package/bin/lib/crossCliResearch.mjs +19 -0
  19. package/bin/lib/fixtures/workflow-ultracode.jsonl +22 -0
  20. package/bin/lib/nativeAgentTools.mjs +12 -5
  21. package/bin/lib/orchestrator.mjs +24 -7
  22. package/bin/lib/orchestratorClient.mjs +8 -2
  23. package/bin/lib/orchestratorPrompt.mjs +13 -5
  24. package/bin/lib/orchestratorProtocol.mjs +18 -0
  25. package/bin/lib/outsideCli.mjs +124 -0
  26. package/bin/lib/prompts.mjs +7 -5
  27. package/bin/lib/runTurn.mjs +30 -8
  28. package/bin/lib/runtimeClaude.mjs +7 -3
  29. package/bin/lib/runtimeCodex.mjs +12 -6
  30. package/bin/lib/runtimeLimits.mjs +59 -1
  31. package/bin/lib/splitGate.mjs +115 -0
  32. package/bin/lib/subagentBounds.mjs +26 -0
  33. package/bin/lib/subagentLedger.mjs +110 -0
  34. package/bin/lib/trace.mjs +134 -6
  35. package/bin/lib/turnDeclaration.mjs +4 -4
  36. package/bin/lib/turnGaps.mjs +6 -2
  37. package/bin/lib/turnSubagents.mjs +23 -9
  38. package/bin/lib/wikiDraft.mjs +9 -0
  39. package/bin/lib/workAgentResume.mjs +11 -2
  40. package/bin/lib/workAgentTurnExecution.mjs +31 -7
  41. package/bin/lib/workAgentTurnOutcome.mjs +11 -0
  42. package/bin/lib/workAgentTurnReports.mjs +5 -2
  43. package/package.json +1 -1
@@ -8,7 +8,9 @@
8
8
  * plan window is not Flowviant's capacity — it is the VENDOR measuring its own
9
9
  * plan, and this file only carries that measurement to Project settings ›
10
10
  * Machines, where it reads as "used", never "left" or "free". Nothing here
11
- * decides, gates, parks or schedules anything off these numbers.
11
+ * decides, gates or parks anything off these numbers; the one other relay is
12
+ * a hit limit's own reset, carried on the park (`limitResetAt`, 2026-11-06)
13
+ * so the server retries when the CLI said, not on a guess.
12
14
  *
13
15
  * TWO SOURCES, ONE SHAPE:
14
16
  *
@@ -135,6 +137,8 @@ export function recordClaudeRateLimit(info, now = Date.now()) {
135
137
  capWindows(state.windows);
136
138
  state.status = CLAUDE_STATUSES.has(info.status) ? info.status : 'other';
137
139
  state.at = at;
140
+ // The binding window's reset rides the event itself beside a rejection.
141
+ state.rejectedResetsAt = state.status === 'rejected' ? isoFromStamp(info.resetsAt) : null;
138
142
  }
139
143
 
140
144
  /**
@@ -370,6 +374,60 @@ export function runtimeLimitsReport({ detected, plan, now = Date.now() } = {}) {
370
374
  return Object.keys(out).length ? fitRuntimeLimits(out) : null;
371
375
  }
372
376
 
377
+ /** A park's reset is relayed only past now and within this (the longest plan
378
+ * window is a week); the server applies the same bound (`PARK_RESET_MAX_MS`). */
379
+ export const PARK_RESET_MAX_MS = 8 * 24 * 60 * 60_000;
380
+ /** A rejection older than this is not the one this turn hit. */
381
+ const LIMIT_READING_FRESH_MS = 30 * 60_000;
382
+
383
+ /** An ISO reset after `now` and within the bound, or null. */
384
+ export function boundedResetAt(iso, now = Date.now()) {
385
+ const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
386
+ return Number.isFinite(t) && t > now && t - now <= PARK_RESET_MAX_MS ? new Date(t).toISOString() : null;
387
+ }
388
+
389
+ /** The latest reset among windows the CLI measured as full (≥ 100% used):
390
+ * the account carries on only once every full window has reset. */
391
+ const fullWindowsReset = (windows) => {
392
+ let latest = null;
393
+ for (const w of windows?.values?.() ?? []) {
394
+ if (w.usedPct != null && w.usedPct >= 100 && w.resetsAt && (!latest || w.resetsAt > latest)) latest = w.resetsAt;
395
+ }
396
+ return latest;
397
+ };
398
+
399
+ /**
400
+ * WHEN THE LIMIT THIS TURN HIT RESETS, IN THE CLI'S OWN MEASURE (2026-11-06) —
401
+ * ISO, or null. Relayed on the park (`/fleet/agent-parked` `resetsAt`) so the
402
+ * server retries then instead of after its fixed cooldown.
403
+ *
404
+ * Only a REJECTION read fresh: Claude's `rate_limit_event` with status
405
+ * `rejected` (its own `resetsAt`, else a full window's), or a Codex rollout
406
+ * naming the limit reached — read now, off this turn's thread under the
407
+ * agent's CODEX_HOME (`env`), because the after-close read in runTurn.mjs is
408
+ * not awaited — with a full window's `resets_at`. Anything unmeasured, stale,
409
+ * past or past the bound is null, and the server's cooldown stands. Never
410
+ * throws.
411
+ */
412
+ export async function limitResetAt(runtime, { threadId, env, now = Date.now() } = {}) {
413
+ try {
414
+ const fresh = (st) => st?.status === 'rejected' && now - Date.parse(st.at) <= LIMIT_READING_FRESH_MS;
415
+ if (runtime === 'claude' || !runtime) {
416
+ if (!fresh(claudeState)) return null;
417
+ return boundedResetAt(claudeState.rejectedResetsAt ?? fullWindowsReset(claudeState.windows), now);
418
+ }
419
+ if (runtime === 'codex') {
420
+ // The agent's own home first, then the person's (its sessions link there).
421
+ if (threadId && !(await learnCodexLimits(threadId, { env, now })) && env) await learnCodexLimits(threadId, { now });
422
+ if (!fresh(codexState)) return null;
423
+ return boundedResetAt(fullWindowsReset(codexState.windows), now);
424
+ }
425
+ return null;
426
+ } catch {
427
+ return null;
428
+ }
429
+ }
430
+
373
431
  /** Test-only: forget everything learned, the plan memo and the path cache. */
374
432
  export function resetRuntimeLimitsForTest() {
375
433
  claudeState = null;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * THE PERSON'S YES BEFORE CHILDREN EDIT FILES (2026-11-06, D13/D28–D31, E5).
3
+ *
4
+ * A card turn's job carries `subagents` (`SubagentSplitJob`, shared
5
+ * subagentSplit.ts, `DAEMON_SUBAGENT_SPLIT_MIN`) from a server that asks:
6
+ * - `ask`: a child that edits files waits for an approved split. The gate
7
+ * refuses its spawn in words that tell the model to propose the split
8
+ * (agentSplit.mjs) and stop. A read-only lookup on the agent's own CLI
9
+ * starts without asking (D13).
10
+ * - `approved`: this turn is the person's "Use N subagents".
11
+ * - `always`: the agent's Always switch (D29). It only skips this question;
12
+ * Claude's own workflow/ultracode modes stay separate (D14).
13
+ * ABSENT IS THE OLD SERVER, and the old behaviour stands: nothing is held and
14
+ * nothing is proposed. A research card's children only read already (the
15
+ * gate fences them), so nothing there waits either.
16
+ *
17
+ * WHETHER A CHILD EDITS, per CLI, conservatively — unknown is editing:
18
+ * - Claude's Agent/Task: by `subagent_type`. Only the built-in read-only
19
+ * types (Explore, Plan: no Edit/Write/NotebookEdit tools) read; general-
20
+ * purpose, a project's own type, or none named, edit. The gate also refuses
21
+ * such a child's file-write tools (orchestrator.mjs), since a type is the
22
+ * model's word; its shell is not fenced.
23
+ * - Codex's spawn_agent: by a declared read-only sandbox on the spawn, which
24
+ * no measured Codex input carries today, so every Codex spawn waits.
25
+ * Pure: no record, slot or request is read here.
26
+ */
27
+ import { isBackgroundSpawnTool, isSpawnTool } from './nativeAgentTools.mjs';
28
+ import { CLAUDE_CHILD_MODELS, claudeFamily } from './orchestratorNative.mjs';
29
+ import { SPLIT_SOLO_LABEL } from './agentSplit.mjs';
30
+
31
+ export const SPLIT_MODES = Object.freeze(['ask', 'approved', 'always']);
32
+ /** Claude Code's built-in subagent types whose tools exclude file writes. */
33
+ export const CLAUDE_READ_ONLY_TYPES = Object.freeze(['Explore', 'Plan']);
34
+ const READ_ONLY_SANDBOX = /^read[-_ ]?only$/i;
35
+
36
+ /** The job's split field, or null when absent or unreadable (the old path). */
37
+ export function splitPolicy(job) {
38
+ const s = job?.subagents;
39
+ if (!s || typeof s !== 'object' || !SPLIT_MODES.includes(s.mode)) return null;
40
+ const str = v => typeof v === 'string' && v.trim() ? v.trim() : null;
41
+ return { mode: s.mode, runtime: str(s.runtime), model: str(s.model), effort: str(s.effort) };
42
+ }
43
+
44
+ /** Whether this spawn's child may edit files; true whenever it cannot tell. */
45
+ export function childEdits(event, input = event?.tool_input ?? {}) {
46
+ if (isBackgroundSpawnTool(event?.tool_name)) return !CLAUDE_READ_ONLY_TYPES.includes(input.subagent_type);
47
+ if (isSpawnTool(event?.tool_name)) return ![input.sandbox, input.sandbox_mode, input.sandbox_policy].some(v => typeof v === 'string' && READ_ONLY_SANDBOX.test(v));
48
+ return true;
49
+ }
50
+
51
+ export const splitRefusal = runtime => `This child may edit files, and the person has not approved a split, so it was not started. Propose the split instead: end your turn with the split JSON (status "split") naming each part, the paths it touches and whether it edits files, then stop and wait for the person's answer. ${runtime === 'codex' ? 'On Codex every spawn waits for that answer; do read-only lookups yourself.' : 'A read-only lookup starts without asking: spawn it with the Explore or Plan agent type.'} Or do this part yourself.`;
52
+
53
+ /** The deny words for a spawn the split holds, or null when it may start. */
54
+ export function splitHold(policy, event, spawn, { runtime, access }) {
55
+ if (!policy || policy.mode !== 'ask' || access === 'read') return null;
56
+ return childEdits(event, spawn?.input) ? splitRefusal(runtime) : null;
57
+ }
58
+
59
+ /** Whether the gate admitted this spawn as a read-only lookup (ask mode). */
60
+ export const readOnlyLookup = (policy, event, spawn, access) => Boolean(policy && policy.mode === 'ask' && access !== 'read' && !childEdits(event, spawn?.input));
61
+
62
+ /**
63
+ * The children's model and effort under an approved or Always split: the
64
+ * person's batch choice, where they named one, over the card's. On Claude the
65
+ * Agent tool takes only a family alias; a choice outside the known families
66
+ * is not honourable there, and the card's model stands (`unhonoured`).
67
+ */
68
+ export function splitBrain(policy, runtime, card = {}) {
69
+ if (!policy || policy.mode === 'ask') return { brain: {}, unhonoured: [] };
70
+ // A batch on another CLI (E7) chose that CLI's model: native children keep the card's.
71
+ if (policy.runtime && policy.runtime !== runtime) return { brain: {}, unhonoured: [] };
72
+ const brain = {}, unhonoured = [];
73
+ if (policy.model) {
74
+ if (runtime === 'claude' && !CLAUDE_CHILD_MODELS.includes(claudeFamily(policy.model))) unhonoured.push(`Claude's Agent tool names only ${CLAUDE_CHILD_MODELS.join(', ')}, so children run on ${card.model ?? 'the card’s model'}, not ${policy.model}`);
75
+ else brain.model = policy.model;
76
+ }
77
+ if (policy.effort) brain.effort = policy.effort;
78
+ return { brain, unhonoured };
79
+ }
80
+
81
+ const choiceWords = (policy, runtime, card) => {
82
+ const { brain, unhonoured } = splitBrain(policy, runtime, card);
83
+ const model = brain.model ?? card.model, effort = brain.effort ?? card.effort;
84
+ const at = `${model ?? 'the card’s model'}${effort ? ` at ${effort} effort` : ''}`;
85
+ const codex = runtime === 'codex' && brain.model ? ' On Codex a model outside this machine’s catalog cannot be named for a child; it then inherits the card’s.' : '';
86
+ return `Run every child on ${at}: the person chose it for the whole batch, so name no other model or effort for a child.${unhonoured.length ? ` ${unhonoured.join('. ')}.` : ''}${codex}`;
87
+ };
88
+
89
+ const CLI_LABEL = { claude: 'Claude Code', codex: 'Codex' };
90
+
91
+ /**
92
+ * A BATCH ON ANOTHER CLI (2026-11-06, D21/D30, E7): the person chose the other
93
+ * CLI for every child, so each part starts with card_orchestrator's
94
+ * start_cli_agent instead of a native spawn — when this machine has that CLI
95
+ * ready (`crossCli`, the CLIs the controller offers); otherwise the parts are
96
+ * the lead's own, and it says why.
97
+ */
98
+ const otherCliWords = (policy, crossCli) => {
99
+ const cli = policy.runtime, name = CLI_LABEL[cli] ?? cli;
100
+ if (!crossCli.includes(cli)) return `The person chose ${name} for the children, and ${name} is not ready on this machine, so no child can start on it: do the parts yourself, and say so in your update.`;
101
+ const at = [policy.model, policy.effort && `${policy.effort} effort`].filter(Boolean).join(' at ');
102
+ return `Run every child on ${name}${at ? `, ${at}` : ''}: the person chose it for the whole batch. Start each part with card_orchestrator.start_cli_agent (cli "${cli}", a title, its kind and the whole brief, naming no other model or effort), never with a native spawn, then read each with cli_agent_result until it is done, stopped or limited. A limited child hit ${name}'s own plan limit: its words say so; retry it later with followup_cli_agent or do its part yourself.`;
103
+ };
104
+
105
+ /** The words this run's split policy adds to the orchestrator's contract. */
106
+ export function splitContract(policy, { runtime = 'claude', model = null, effort = null, crossCli = [] } = {}) {
107
+ if (!policy) return '';
108
+ const card = { model, effort };
109
+ const batch = () => policy.runtime && policy.runtime !== runtime ? otherCliWords(policy, crossCli) : choiceWords(policy, runtime, card);
110
+ if (policy.mode === 'approved') return `THE PERSON APPROVED YOUR SPLIT for this turn: start the parts you proposed ${policy.runtime && policy.runtime !== runtime ? 'now' : 'as native subagents now, as above'}. ${batch()}`;
111
+ if (policy.mode === 'always') return `SPLITS ARE PRE-APPROVED ON THIS AGENT (its Always use subagents switch): split independent parts as above without proposing first. ${batch()} This switch starts no workflow or other mode of the CLI; it only skips the person's question.`;
112
+ return `SUBAGENTS THAT EDIT FILES NEED THE PERSON'S YES. For children that edit files this replaces "this contract is the authorization" above. When parts of the card's work can run side by side and would edit files, do not spawn them: write any code they share first, as above, then propose the split and stop. End your turn with this JSON as your final answer, in place of delivered or blocked:
113
+ {"status":"split","split":{"reason":"one or two sentences on why these parts can run side by side","parts":[{"title":"Port gizmo shaders","touches":["src/gizmo.frag","src/gizmo.ts"],"edits":true},{"title":"Audit textures","touches":["assets/textures/**"],"edits":false}]},"progress":"…"}
114
+ Name every part you would give a child (at most 12), the paths or areas it touches (at most 8), and whether it edits files or only reads. The person answers "Use N subagents" — your next turn then says the split is approved and which model and effort the children run on — or "${SPLIT_SOLO_LABEL}", which means: do every part yourself with no child that edits files, and do not propose this split again. ${runtime === 'codex' ? 'On Codex the gate cannot tell a reading child from an editing one, so every spawn waits for that answer: do lookups yourself.' : 'A read-only lookup needs no approval: spawn it with the Explore or Plan agent type; the gate holds every other spawn.'} Children already running keep going: write the proposal as your final answer once they finish. A split is not a question for blocked JSON.`;
115
+ }
@@ -13,8 +13,34 @@
13
13
  * shared `SUBAGENT_STATUSES`. */
14
14
 
15
15
  export const SUBAGENT_CAP = 32;
16
+ export const WORKFLOW_AGENT_CAP = 200;
17
+ export const WORKFLOW_CAP = 32;
16
18
  /** In the order the trace relay sends them. */
17
19
  export const SUBAGENT_FIELD_CAPS = Object.freeze({ kind: 80, title: 200, now: 400, brief: 24_000 });
18
20
  export const SUBAGENT_STATUSES = Object.freeze(['running', 'waiting', 'asked', 'stopped', 'done']);
19
21
  /** A child that has finished or been stopped: the controller revives neither. */
20
22
  export const SETTLED_SUBAGENT_STATUSES = Object.freeze(['done', 'stopped']);
23
+
24
+ /** WHAT EACH CHILD'S RECORD CARRIES BEYOND ITS DESCRIPTOR (2026-11-06, S4.1),
25
+ * and its own step budget. Before this a child was id/kind/title/brief/now
26
+ * and a status, its steps shared the turn's 400 with the lead and every
27
+ * sibling, and a 33rd child vanished without a count. The server keeps the
28
+ * same numbers (`agentTraceAgents.ts`, check-app-parity rule 38):
29
+ * · `SUBAGENT_STEP_CAP` — the newest steps each child keeps of its own,
30
+ * past which its oldest are said as "N earlier not kept";
31
+ * · `SUBAGENT_RESULT_CAP` — its final message, as its CLI handed it back;
32
+ * · `SUBAGENT_RUNS_CAP` — the turns one resumed child is remembered in;
33
+ * · `SUBAGENT_KEY_MAX` — its stable identity across turns;
34
+ * · `SUBAGENT_CLIS` — the CLIs whose children are recorded (a CLI outside
35
+ * the list is unknown and the field is absent).
36
+ * `OUTSIDE_*` bounds the lead's shell-outs to another CLI ("Outside" rows,
37
+ * D23): shown, never blocked, never counted toward the 32. */
38
+ export const SUBAGENT_STEP_CAP = 120;
39
+ export const SUBAGENT_RESULT_CAP = 8_000;
40
+ export const SUBAGENT_RUNS_CAP = 16;
41
+ export const SUBAGENT_KEY_MAX = 160;
42
+ export const SUBAGENT_CLIS = Object.freeze(['claude', 'codex']);
43
+ export const OUTSIDE_CAP = 32;
44
+ export const OUTSIDE_COMMAND_CAP = 600;
45
+ export const OUTSIDE_CLIS = Object.freeze(['claude', 'codex', 'gemini']);
46
+ export const OUTSIDE_STATUSES = Object.freeze(['running', 'done', 'failed']);
@@ -0,0 +1,110 @@
1
+ /**
2
+ * WHICH TURNS EACH OF AN AGENT'S SUBAGENTS APPEARED IN (2026-11-06, S4.1,
3
+ * D26). A child resumed in a later turn — Claude's `Agent({ resume })` of
4
+ * its native agent id, a Codex follow-up to its child thread — is the same
5
+ * child, and the chat shows it once ("continued in run 3"). Each turn's
6
+ * trace keeps its own bounded record; this ledger is how that record knows
7
+ * the earlier turns, by the stable `key` turnSubagents.mjs gives it.
8
+ *
9
+ * Kept beside the agent's resume markers, in the worktree's private git dir
10
+ * (workAgentResume.mjs): a child can only be resumed where its conversation
11
+ * is, so this is exactly as durable as the resume it describes. Codex
12
+ * children also keep the task paths they answered to, so a follow-up by
13
+ * path reaches its thread in a later turn (codexSubagents.mjs
14
+ * `knownTargets`).
15
+ *
16
+ * BEST EFFORT BOTH WAYS, like the markers: unreadable is unknown (runs
17
+ * carry no field), unwritable costs the next turn its history.
18
+ */
19
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { CLAUDE_SESSION_RE, CODEX_THREAD_RE, CODEX_CHILD_THREAD_RE } from './conversationIds.mjs';
21
+ import { SUBAGENT_KEY_MAX, SUBAGENT_RUNS_CAP } from './subagentBounds.mjs';
22
+
23
+ /** Children remembered per agent; the oldest go first. */
24
+ export const LEDGER_KEYS = 128;
25
+ const LEDGER_BYTES = 256 * 1024;
26
+ const NAMES = 4;
27
+ const TURN_RE = /^[A-Za-z0-9_-]{1,64}$/;
28
+
29
+ function readLedger(path) {
30
+ if (!path || !existsSync(path)) return new Map();
31
+ try {
32
+ const raw = readFileSync(path, 'utf8');
33
+ if (raw.length > LEDGER_BYTES) return null;
34
+ const v = JSON.parse(raw);
35
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
36
+ const out = new Map();
37
+ for (const [key, entry] of Object.entries(v).slice(-LEDGER_KEYS)) {
38
+ if (key.length > SUBAGENT_KEY_MAX || !entry || typeof entry !== 'object') continue;
39
+ const r = Array.isArray(entry.r) ? entry.r.filter(t => typeof t === 'string' && TURN_RE.test(t)).slice(-SUBAGENT_RUNS_CAP) : [];
40
+ const n = Array.isArray(entry.n) ? entry.n.filter(s => typeof s === 'string' && s.length <= 160).slice(-NAMES) : [];
41
+ const c = entry.c;
42
+ const valid = c && ['codex', 'claude'].includes(c.cli) &&
43
+ (c.cli === 'codex' ? CODEX_THREAD_RE : CLAUDE_SESSION_RE).test(c.native ?? '') &&
44
+ key === `${c.cli}:${c.native}` && typeof c.wt === 'string' && c.wt.startsWith('/') && c.wt.length <= 4096;
45
+ out.set(key, { r, n, ...(valid ? { c } : {}) });
46
+ }
47
+ return out;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** One turn's view of the agent's ledger at `path` (null: no git dir). */
54
+ export function subagentLedger(path, turnId) {
55
+ const ledger = path ? readLedger(path) : null;
56
+ let dirty = false;
57
+ return {
58
+ /** Null when the ledger is unknown: the trace then sends no runs. */
59
+ runsOf: ledger && TURN_RE.test(turnId ?? '') ? (key, { title } = {}) => {
60
+ if (typeof key !== 'string' || !key || key.length > SUBAGENT_KEY_MAX) return null;
61
+ const entry = ledger.get(key) ?? { r: [], n: [] };
62
+ if (!entry.r.includes(turnId)) { entry.r = [...entry.r, turnId].slice(-SUBAGENT_RUNS_CAP); dirty = true; }
63
+ if (typeof title === 'string' && title && title.length <= 160 && !entry.n.includes(title)) { entry.n = [...entry.n, title].slice(-NAMES); dirty = true; }
64
+ // Most recently seen last, so the bound sheds the longest unheard.
65
+ ledger.delete(key); ledger.set(key, entry);
66
+ while (ledger.size > LEDGER_KEYS) ledger.delete(ledger.keys().next().value);
67
+ return [...entry.r];
68
+ } : null,
69
+ /** A Codex child's earlier names → its thread, for this turn's reader. */
70
+ codexTargets() {
71
+ const out = new Map();
72
+ for (const [key, { n }] of ledger ?? []) {
73
+ const thread = key.startsWith('codex:') ? key.slice(6) : '';
74
+ if (!CODEX_CHILD_THREAD_RE.test(thread)) continue;
75
+ for (const name of n) out.set(name, thread);
76
+ }
77
+ return out;
78
+ },
79
+ /** Cross-CLI identity is measured at init, never reconstructed from a title. */
80
+ rememberCli(key, child) {
81
+ if (!ledger || !key || key.length > SUBAGENT_KEY_MAX) return;
82
+ const entry = ledger.get(key) ?? { r: [], n: [] };
83
+ entry.c = { ...child, id: entry.c?.id ?? child.id };
84
+ ledger.delete(key); ledger.set(key, entry);
85
+ while (ledger.size > LEDGER_KEYS) ledger.delete(ledger.keys().next().value);
86
+ dirty = true;
87
+ this.persist();
88
+ },
89
+ cliChild(id, wt) {
90
+ for (const [key, entry] of ledger ?? []) {
91
+ if (entry.c?.wt === wt && (key === id || entry.c.id === id)) return { ...entry.c, key };
92
+ }
93
+ return null;
94
+ },
95
+ persist() {
96
+ if (!dirty || !path || !ledger) return;
97
+ try {
98
+ let data = JSON.stringify(Object.fromEntries(ledger));
99
+ while (Buffer.byteLength(data) > LEDGER_BYTES && ledger.size) {
100
+ ledger.delete(ledger.keys().next().value);
101
+ data = JSON.stringify(Object.fromEntries(ledger));
102
+ }
103
+ writeFileSync(path, data);
104
+ dirty = false;
105
+ } catch {
106
+ /* the next turn's children carry no history */
107
+ }
108
+ },
109
+ };
110
+ }
package/bin/lib/trace.mjs CHANGED
@@ -62,7 +62,8 @@
62
62
  import { THINK_MARKER, scrubNativeText } from './runtimeEvents.mjs';
63
63
  import { withoutTurnDeclaration } from './turnDeclaration.mjs';
64
64
  import { MAX_MCP_SERVERS, MCP_NAME_MAX, MCP_STATUSES } from './mcpReportBounds.mjs';
65
- import { SUBAGENT_CAP, SUBAGENT_FIELD_CAPS, SUBAGENT_STATUSES } from './subagentBounds.mjs';
65
+ import { WORKFLOW_AGENT_CAP, WORKFLOW_CAP, OUTSIDE_CAP, OUTSIDE_COMMAND_CAP, OUTSIDE_STATUSES, SETTLED_SUBAGENT_STATUSES, SUBAGENT_CAP, SUBAGENT_CLIS, SUBAGENT_FIELD_CAPS,
66
+ SUBAGENT_KEY_MAX, SUBAGENT_RESULT_CAP, SUBAGENT_RUNS_CAP, SUBAGENT_STATUSES } from './subagentBounds.mjs';
66
67
 
67
68
  /** One batch every two seconds — the discipline the tab's narrator keeps, for
68
69
  * the same reason: a turn emits hundreds of entries and nobody is reading them
@@ -111,7 +112,18 @@ function newRunId() {
111
112
  * with no such route, a body the server will never accept). Resolving falsy
112
113
  * keeps them queued for the next flush.
113
114
  */
114
- export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run = newRunId(), orchestrationVersion }) {
115
+ /** One native window a brief or result is scrubbed over before its cap. */
116
+ const NATIVE_WINDOW = 65_536;
117
+ const EFFORT_RE = /^[a-z]{1,24}$/;
118
+ const RUN_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
119
+
120
+ /**
121
+ * `cli` is the runtime whose children this turn records. `runsOf(key)` is
122
+ * the agent's subagent ledger (workAgentResume.mjs): the turns a child of
123
+ * that stable key appeared in, this one included; absent, a child's runs are
124
+ * unknown and carry no field.
125
+ */
126
+ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run = newRunId(), orchestrationVersion, cli, runsOf }) {
115
127
  /** Entries not yet delivered. `base` is the ABSOLUTE index of queue[0] in
116
128
  * THIS RUN's stream, so `base + queue.length` is everything this relay has
117
129
  * ever accepted — sent, queued or shed. Absolute for the TURN is the
@@ -124,6 +136,19 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
124
136
  * by the bare-marker collapse in `prose()` and nothing else. */
125
137
  let lastEntry = null;
126
138
  const agentReadings = new Map();
139
+ /** Each child's own steps, sent or shed: its record's `steps`, against
140
+ * which the server says "N earlier not kept" past its own budget. */
141
+ const childSteps = new Map();
142
+ /** Children past the 32, counted and never recorded (bounded: a count). */
143
+ const unrecorded = new Set();
144
+ let notRecorded = 0;
145
+ /** The lead's own entries this run, sent or shed: where a child or an
146
+ * Outside row sits in the lead's thread (its `position`). */
147
+ let leadCount = 0;
148
+ /** The lead's shell-outs to another CLI ("Outside", D23), by call id. */
149
+ const outsideRows = new Map();
150
+ const workflowRows = new Map(), workflowPositions = new Map();
151
+ let extrasPending = false;
127
152
  const mcpSaid = new Set();
128
153
  let dirty = orchestrationVersion === 2;
129
154
  let markerPending = dirty;
@@ -145,6 +170,8 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
145
170
  const push = (entry) => {
146
171
  if (stopped || !entry) return;
147
172
  lastEntry = entry;
173
+ if (entry.agentId) childSteps.set(entry.agentId, (childSteps.get(entry.agentId) ?? 0) + 1);
174
+ else if (entry.k !== 'agent') leadCount += 1;
148
175
  queue.push(entry);
149
176
  while (queue.length > TRACE_BUFFER) {
150
177
  queue.shift();
@@ -156,7 +183,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
156
183
 
157
184
  const drain = async (deadlineMs) => {
158
185
  const until = deadlineMs > 0 ? Date.now() + deadlineMs : 0;
159
- while (queue.length || markerPending) {
186
+ while (queue.length || markerPending || extrasPending) {
160
187
  if (until && Date.now() > until) {
161
188
  dirty = true;
162
189
  return;
@@ -164,10 +191,17 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
164
191
  const entries = queue.slice(0, TRACE_BATCH);
165
192
  const seq = base;
166
193
  dirty = false;
194
+ // The turn's counts and Outside rows ride beside the stream, whole: they
195
+ // take no seq, so an older server ignoring them sheds no step.
196
+ const extras = extrasPending;
197
+ extrasPending = false;
167
198
  let ok = false;
168
199
  try {
169
200
  ok = (await post({ agentId, turnId, run, seq, entries,
170
201
  ...(orchestrationVersion === 2 ? { orchestrationVersion } : {}),
202
+ ...(extras && notRecorded > 0 ? { notRecorded } : {}),
203
+ ...(extras && outsideRows.size ? { outside: [...outsideRows.values()] } : {}),
204
+ ...(extras && workflowRows.size ? { workflows: [...workflowRows.values()] } : {}),
171
205
  })) !== false;
172
206
  } catch {
173
207
  ok = false;
@@ -176,6 +210,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
176
210
  // Held, at the SAME seq: a retry the server has already seen is
177
211
  // trimmed against its high-water mark rather than doubled.
178
212
  dirty = true;
213
+ extrasPending ||= extras;
179
214
  return;
180
215
  }
181
216
  markerPending = false;
@@ -201,6 +236,30 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
201
236
  // The first report stamps even a silent or immediately failed CLI invocation.
202
237
  schedule();
203
238
  const scoped = (id) => id ? `${run}:${scrubNativeText(id, 96, scrub)}` : undefined;
239
+ /** A child's S4.1 detail, each field present only when its source said it. */
240
+ const cleanKey = v => scrubNativeText(v, SUBAGENT_KEY_MAX, scrub);
241
+ const detailOf = (update) => {
242
+ const out = {};
243
+ const childCli = update.cli ?? cli;
244
+ if (SUBAGENT_CLIS.includes(childCli)) out.cli = childCli;
245
+ if (typeof update.key === 'string' && update.key && update.key.length <= SUBAGENT_KEY_MAX) out.key = scrubNativeText(update.key, SUBAGENT_KEY_MAX, scrub);
246
+ if (update.parent === 'lead') out.parent = 'lead';
247
+ else if (typeof update.parent === 'string' && update.parent && update.parent !== update.id) out.parent = scoped(update.parent);
248
+ if (typeof update.model === 'string' && update.model) out.model = scrubNativeText(update.model, 80, scrub);
249
+ if (typeof update.effort === 'string' && EFFORT_RE.test(update.effort)) out.effort = update.effort;
250
+ // A subagent on another CLI stopped by its CLI's plan limit (E7 S7.5), cleared when it runs again.
251
+ if (typeof update.limited === 'boolean') out.limited = update.limited;
252
+ if (typeof update.parentKey === 'string') out.parentKey = cleanKey(update.parentKey);
253
+ if (typeof update.phase === 'string') out.phase = scrubNativeText(update.phase, 200, scrub);
254
+ for (const k of ['tokens', 'toolCalls']) if (Number.isSafeInteger(update[k]) && update[k] >= 0) out[k] = update[k];
255
+ for (const k of ['startedAt', 'endedAt']) if (typeof update[k] === 'string' && Number.isFinite(Date.parse(update[k]))) out[k] = update[k];
256
+ if (typeof update.result === 'string' && update.result.trim()) {
257
+ const full = scrubNativeText(update.result, NATIVE_WINDOW, scrub, NATIVE_WINDOW);
258
+ out.result = full.slice(0, SUBAGENT_RESULT_CAP);
259
+ out.resultStatus = update.resultStatus === 'truncated' || update.result.length > NATIVE_WINDOW || full.length > SUBAGENT_RESULT_CAP ? 'truncated' : 'complete';
260
+ }
261
+ return out;
262
+ };
204
263
  return {
205
264
  agentId: scoped,
206
265
  /**
@@ -283,11 +342,44 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
283
342
  }
284
343
  if (typeof fields.now === 'string') fields.now = fields.now.replace(/\s+/g, ' ').trim();
285
344
  if (SUBAGENT_STATUSES.includes(update.status)) fields.status = update.status;
345
+ Object.assign(fields, detailOf(update));
286
346
  const previous = agentReadings.get(update.id) ?? {};
287
- if (agentReadings.has(update.id) && Object.entries(fields).every(([k, v]) => previous[k] === v)) return;
288
- if (!agentReadings.has(update.id) && agentReadings.size >= SUBAGENT_CAP) return;
347
+ if (!agentReadings.has(update.id) && fields.kind !== 'workflow' && [...agentReadings.values()].filter(a => a.kind !== 'workflow').length >= SUBAGENT_CAP) {
348
+ // A 33rd child runs; its record does not (D25): the turn says how many.
349
+ if (!unrecorded.has(update.id)) {
350
+ notRecorded += 1;
351
+ if (unrecorded.size < 4_096) unrecorded.add(update.id);
352
+ extrasPending = true; dirty = true; schedule();
353
+ }
354
+ return;
355
+ }
356
+ if (!agentReadings.has(update.id)) {
357
+ // Where the lead stood when the child was first seen, and when.
358
+ fields.position = leadCount;
359
+ fields.startedAt ??= new Date().toISOString();
360
+ }
361
+ const key = fields.key ?? previous.key, title = fields.title ?? previous.title;
362
+ // The ledger learns a child's key (and the names a Codex follow-up may
363
+ // use for it) and answers the turns it appeared in, this one included.
364
+ if (key && (key !== previous.key || title !== previous.title) && typeof runsOf === 'function') {
365
+ let runs = null;
366
+ try { runs = runsOf(key, { title }); } catch { /* the ledger is a readout */ }
367
+ if (Array.isArray(runs)) fields.runs = runs.filter(r => typeof r === 'string' && RUN_ID_RE.test(r)).slice(-SUBAGENT_RUNS_CAP);
368
+ }
369
+ const status = fields.status ?? previous.status;
370
+ if (SETTLED_SUBAGENT_STATUSES.includes(status)) { if (!previous.endedAt) fields.endedAt ??= new Date().toISOString(); }
371
+ const same = (k, v) => k === 'endedAt' ? Boolean(previous.endedAt) : k === 'runs' ? JSON.stringify(previous.runs) === JSON.stringify(v) : previous[k] === v;
372
+ if (agentReadings.has(update.id) && Object.entries(fields).every(([k, v]) => same(k, v))) return;
289
373
  const reading = { ...previous, ...fields };
374
+ // A child running again (a follow-up) has not ended.
375
+ if (!SETTLED_SUBAGENT_STATUSES.includes(status)) delete reading.endedAt;
290
376
  agentReadings.set(update.id, reading);
377
+ if (reading.kind === 'workflow') {
378
+ const siblings = [...agentReadings].filter(([, a]) => a.kind === 'workflow' && a.parentKey === reading.parentKey)
379
+ .sort((a, b) => (b[1].startedAt ?? '').localeCompare(a[1].startedAt ?? ''));
380
+ for (const [id] of siblings.slice(WORKFLOW_AGENT_CAP)) agentReadings.delete(id);
381
+ if (!agentReadings.has(update.id)) return;
382
+ }
291
383
  // A later update repairs metadata whose first report was shed while
292
384
  // offline; a delta alone cannot recover the child's prompt or title.
293
385
  push({ k: 'agent', id: scoped(update.id), ...reading });
@@ -319,6 +411,38 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
319
411
  }
320
412
  if (fresh.length) push({ k: 'note', t: scrub(`The CLI reported MCP servers that are not connected: ${fresh.join(', ')}.`).slice(0, TRACE_PROSE_CAP) });
321
413
  },
414
+ /**
415
+ * ONE SHELL COMMAND THE LEAD RAN THAT STARTS ANOTHER CLI (D23): its row
416
+ * by call id, updated at its result. Shown and never blocked, never a
417
+ * subagent and never counted toward the 32. `row` is outsideCli.mjs's.
418
+ */
419
+ outside(id, row) {
420
+ if (stopped || typeof id !== 'string' || !id || !row) return;
421
+ const previous = outsideRows.get(id);
422
+ if (!previous && outsideRows.size >= OUTSIDE_CAP) return;
423
+ const next = { ...(previous ?? {
424
+ id: scrubNativeText(id, 96, scrub), position: leadCount, startedAt: new Date().toISOString(),
425
+ }) };
426
+ if (typeof row.cli === 'string') next.cli = row.cli;
427
+ if (typeof row.model === 'string' && row.model) next.model = scrubNativeText(row.model, 80, scrub);
428
+ if (typeof row.command === 'string') next.command = scrubNativeText(row.command, OUTSIDE_COMMAND_CAP, scrub);
429
+ if (OUTSIDE_STATUSES.includes(row.status)) next.status = row.status;
430
+ if (next.status && next.status !== 'running' && !next.endedAt) next.endedAt = new Date().toISOString();
431
+ if (previous && JSON.stringify(previous) === JSON.stringify(next)) return;
432
+ outsideRows.set(id, next);
433
+ extrasPending = true; dirty = true; schedule();
434
+ },
435
+ workflow(row) {
436
+ if (stopped || !row) return;
437
+ if (row.launcherId && workflowPositions.size < WORKFLOW_CAP && !workflowPositions.has(row.launcherId)) workflowPositions.set(row.launcherId, leadCount);
438
+ if (!row.id || (!workflowRows.has(row.id) && workflowRows.size >= WORKFLOW_CAP)) return;
439
+ const { launcherId, ...next } = row;
440
+ next.position = workflowRows.get(row.id)?.position ?? workflowPositions.get(launcherId) ?? leadCount;
441
+ for (const k of ['id', 'key', 'name', 'description']) if (typeof next[k] === 'string') next[k] = scrubNativeText(next[k], k === 'description' ? 600 : 200, scrub);
442
+ next.phases = (next.phases ?? []).slice(0, 64).map(p => ({ ...p, title: scrubNativeText(p.title, 200, scrub) }));
443
+ workflowRows.set(row.id, next);
444
+ extrasPending = true; dirty = true; schedule();
445
+ },
322
446
  flush,
323
447
  /** No more entries, no more timers. The queue survives so a final flush can
324
448
  * still deliver it. */
@@ -326,8 +450,12 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
326
450
  if (!stopped && orchestrationVersion === 2) {
327
451
  // Even a quiet child retains its identity after another child's noisy
328
452
  // stream overflowed the relay. These snapshots remain sequence-safe.
329
- for (const [id, reading] of agentReadings) push({ k: 'agent', id: scoped(id), ...reading });
453
+ for (const [id, reading] of agentReadings) {
454
+ const steps = childSteps.get(scoped(id));
455
+ push({ k: 'agent', id: scoped(id), ...reading, ...(steps ? { steps } : {}) });
456
+ }
330
457
  }
458
+ if (!stopped && (notRecorded > 0 || outsideRows.size || workflowRows.size)) extrasPending = true;
331
459
  stopped = true;
332
460
  if (timer) {
333
461
  clearTimeout(timer);
@@ -4,7 +4,7 @@
4
4
  * inside prose or another language's fence stay as the CLI wrote them. */
5
5
  import { jsonObjectClosers } from './modelJson.mjs';
6
6
 
7
- const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'nothing']);
7
+ const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'split', 'nothing']);
8
8
  function declaration(body) {
9
9
  const raw = body.trim();
10
10
  if (!raw.startsWith('{')) return false;
@@ -13,13 +13,13 @@ function declaration(body) {
13
13
  return value && typeof value === 'object' && !Array.isArray(value)
14
14
  && ((Object.hasOwn(value, 'summary') && Object.hasOwn(value, 'progress'))
15
15
  || (typeof value.status === 'string'
16
- && (statuses.has(value.status) || ['summary', 'progress', 'question'].some(key => Object.hasOwn(value, key)))));
16
+ && (statuses.has(value.status) || ['summary', 'progress', 'question', 'split'].some(key => Object.hasOwn(value, key)))));
17
17
  } catch {
18
18
  // A truncated/invalid declaration must not fall through as an answer.
19
19
  // Require a protocol key at the root's opening and the declared status;
20
20
  // arbitrary malformed JSON remains useful diagnostic output.
21
- return /^\{\s*"(?:status|summary|progress|question)"\s*:/.test(raw)
22
- && /"status"\s*:\s*"(?:delivered|blocked|prerequisite|nothing)"/.test(raw);
21
+ return /^\{\s*"(?:status|summary|progress|question|split)"\s*:/.test(raw)
22
+ && /"status"\s*:\s*"(?:delivered|blocked|prerequisite|split|nothing)"/.test(raw);
23
23
  }
24
24
  }
25
25
 
@@ -16,6 +16,7 @@
16
16
  * listed. A malformed entry costs that entry alone.
17
17
  */
18
18
  export const GAP_STATUSES = ['not built', 'partly', 'unverified', 'unowned'];
19
+ export const GAP_DISPOSITIONS = ['required', 'owned', 'optional', 'decision'];
19
20
  export const MAX_GAPS = 40;
20
21
  export const MAX_GAP_ITEM = 300;
21
22
  export const MAX_GAP_WHY = 800;
@@ -32,7 +33,10 @@ export function parseGaps(raw, scrub) {
32
33
  const status = typeof g.status === 'string' ? g.status.trim().toLowerCase() : '';
33
34
  if (!GAP_STATUSES.includes(status)) continue;
34
35
  const why = typeof g.why === 'string' && g.why.trim() ? cut(scrub(g.why.trim()), MAX_GAP_WHY) : null;
35
- out.push({ item: cut(scrub(g.item.trim()), MAX_GAP_ITEM), status, ...(why ? { why } : {}) });
36
+ const disposition = GAP_DISPOSITIONS.includes(g.disposition) ? g.disposition : null;
37
+ const ownerTaskId = typeof g.ownerTaskId === 'string' && /^[a-zA-Z0-9_-]{1,100}$/.test(g.ownerTaskId) ? g.ownerTaskId : null;
38
+ out.push({ item: cut(scrub(g.item.trim()), MAX_GAP_ITEM), status, ...(why ? { why } : {}),
39
+ ...(disposition ? { disposition } : {}), ...(ownerTaskId ? { ownerTaskId } : {}) });
36
40
  }
37
41
  return out;
38
42
  }
@@ -41,7 +45,7 @@ export function parseGaps(raw, scrub) {
41
45
  export function gapsMarkdown(gaps) {
42
46
  if (!Array.isArray(gaps)) return null;
43
47
  if (!gaps.length) return '**Gaps against the request:** none reported.';
44
- return ['**Gaps against the request**', ...gaps.map((g) => `- **${g.status}** — ${oneLine(g.item)}${g.why ? `: ${oneLine(g.why)}` : ''}`)].join('\n');
48
+ return ['**Gaps against the request**', ...gaps.map((g) => `- **${g.status}** — ${oneLine(g.item)}${g.why ? `: ${oneLine(g.why)}` : ''}${g.disposition ? ` (${g.disposition}${g.ownerTaskId ? `: card ${g.ownerTaskId}` : ''})` : ''}`)].join('\n');
45
49
  }
46
50
 
47
51
  const oneLine = (s) => s.replace(/\s*\n\s*/g, ' ');