flowviant 0.125.30 → 0.125.33

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
@@ -33,6 +33,7 @@ import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
33
33
  import { groupDependencies } from './planDependencies.mjs';
34
34
  import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
35
35
  import { blockedAnswer } from './agentAsk.mjs';
36
+ import { splitAnswer } from './agentSplit.mjs';
36
37
  import { isResearchUse } from './researchUse.mjs';
37
38
  import { candidateObjects } from './modelJson.mjs';
38
39
  import { parseGaps } from './turnGaps.mjs';
@@ -258,6 +259,13 @@ export function parseTurnResult(text, scrub = envScrub) {
258
259
  // as plain text.
259
260
  return { outcome: 'question', answer: blockedAnswer(scrub(question), v, scrub), ...progressOf(v, scrub) };
260
261
  }
262
+ if (v.status === 'split') {
263
+ // A split proposal (2026-11-06, agentSplit.mjs) is a question: the agent
264
+ // freezes until the person answers Use N subagents or Do it yourself.
265
+ const answer = splitAnswer(v, scrub);
266
+ if (!answer) continue;
267
+ return { outcome: 'question', answer, ...progressOf(v, scrub) };
268
+ }
261
269
  if (v.status === 'delivered') {
262
270
  return {
263
271
  outcome: 'delivered',
@@ -17,6 +17,12 @@ requirements. The brief and criteria marked full are supplied without clipping.
17
17
  sourceChats are the person's exact words in the New task chat these cards were
18
18
  planned in, up to each card's landing; a brief is the planner's paraphrase of
19
19
  them, and links, references and file lists there are part of the request.
20
+ ${job.gapContinuationAccepted === true ? 'This server supports disposition=required: unfinished required work continues in this card before Review.' : 'This server has not advertised automatic gap continuation. Finish required work within this turn; if it remains unfinished, ask through a blocked reply instead of declaring delivery.'}
21
+ relatedWork names sibling cards from the same request across agents, including
22
+ completed cards. Outside this queue does not mean unowned. Link the actual
23
+ ownerTaskId instead of proposing or implementing a sibling's work again.
24
+ Only a card's observed completion is recorded; verify a reported recurrence
25
+ instead of assuming an earlier Done card solved the current failure.
20
26
  Human messages are exact recorded words across this agent, with the preceding
21
27
  question when present. taskId on a message is where it was recorded, not an
22
28
  implicit scope restriction. target.kind=agent explicitly addresses the whole
@@ -0,0 +1,90 @@
1
+ /**
2
+ * CLAUDE SUGGESTS SUBAGENTS (2026-11-06, D28–D31, epic E5): the split a card
3
+ * agent proposes instead of starting children that edit files, read off its
4
+ * final JSON and handed on as a QUESTION.
5
+ *
6
+ * {"status": "split", "split": {"reason": "…", "parts": [
7
+ * {"title": "Port gizmo shaders", "touches": ["src/gizmo.ts"], "edits": true},
8
+ * {"title": "Audit textures", "touches": ["assets/**"], "edits": false}]},
9
+ * "progress": "…"}
10
+ *
11
+ * The agent freezes on it as a native CLI freezes on its ask tool (D31): the
12
+ * turn settles `question`, the board says Needs you, and the person answers
13
+ * through the existing answer lane. So the answer is built as every blocked
14
+ * question's is (agentAsk.mjs): prose, then ONE ```flowviant-ask fence — here
15
+ * with the two options the server and the page key on, `Use N subagents` and
16
+ * `Do it yourself` (the shared `splitUseLabel` / `SPLIT_SOLO_LABEL`), and the
17
+ * parts under an extra `split` key the ask reader ignores. A screen that
18
+ * knows nothing of splits still draws an answerable question.
19
+ *
20
+ * BOUNDED, AND NEVER LOST. `SPLIT_BOUNDS` is the shared one, key for key. A
21
+ * proposal past any bound, or with a hostile character, loses its fence whole
22
+ * and goes on as a plain-text question listing what could be read, exactly as
23
+ * malformed options do: the person can still answer it in words.
24
+ */
25
+ import { ASK_HOSTILE } from './agentAsk.mjs';
26
+
27
+ /** The shared `SPLIT_BOUNDS` (packages/shared/src/schemas/subagentSplit.ts). */
28
+ export const SPLIT_BOUNDS = Object.freeze({ parts: 12, title: 120, touches: 8, touch: 200, reason: 600 });
29
+ export const SPLIT_SOLO_LABEL = 'Do it yourself';
30
+ export const splitUseLabel = n => `Use ${n} subagent${n === 1 ? '' : 's'}`;
31
+ export const SPLIT_QUESTION = 'Use subagents?';
32
+
33
+ const one = (v, max, scrub) => {
34
+ if (typeof v !== 'string') return null;
35
+ const t = scrub(v).trim();
36
+ return t && t.length <= max && !ASK_HOSTILE.test(t) ? t : null;
37
+ };
38
+
39
+ /** The declared split → `{ parts, reason }`, or null when any part breaks a
40
+ * bound. `scrub` runs before each field is measured. */
41
+ export function declaredSplit(raw, scrub = s => s) {
42
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
43
+ if (!Array.isArray(raw.parts) || !raw.parts.length || raw.parts.length > SPLIT_BOUNDS.parts) return null;
44
+ const parts = [];
45
+ for (const p of raw.parts) {
46
+ if (!p || typeof p !== 'object' || Array.isArray(p) || typeof p.edits !== 'boolean') return null;
47
+ const title = one(p.title, SPLIT_BOUNDS.title, scrub);
48
+ const touches = p.touches === undefined ? [] : p.touches;
49
+ if (!title || !Array.isArray(touches) || touches.length > SPLIT_BOUNDS.touches) return null;
50
+ const kept = [];
51
+ for (const t of touches) { const k = one(t, SPLIT_BOUNDS.touch, scrub); if (!k) return null; kept.push(k); }
52
+ parts.push({ title, touches: kept, edits: p.edits });
53
+ }
54
+ let reason = null;
55
+ if (raw.reason !== undefined && raw.reason !== null) {
56
+ if (typeof raw.reason !== 'string') return null;
57
+ const r = scrub(raw.reason).trim();
58
+ if (r.length > SPLIT_BOUNDS.reason || ASK_HOSTILE.test(r.replace(/\n/g, ' '))) return null;
59
+ reason = r || null;
60
+ }
61
+ return { parts, reason };
62
+ }
63
+
64
+ const partLine = (p, i) => `${i + 1}. ${p.title}${p.touches.length ? ` — ${p.touches.join(', ')}` : ''} · ${p.edits ? 'edits' : 'read-only'}`;
65
+
66
+ /**
67
+ * THE ANSWER A SPLIT TURN HANDS ON, or null when it declared no parts at all
68
+ * (then it is no answer, as a blocked turn with no question is not). The
69
+ * prose names the parts, so a surface that draws no fence still shows them.
70
+ */
71
+ export function splitAnswer(v, scrub = s => s) {
72
+ const split = declaredSplit(v?.split, scrub);
73
+ if (!split) {
74
+ // Readable parts, past a bound: a plain question, answerable in words.
75
+ const titles = Array.isArray(v?.split?.parts) ? v.split.parts.map(p => typeof p?.title === 'string' ? scrub(p.title).trim().slice(0, SPLIT_BOUNDS.title) : '').filter(Boolean) : [];
76
+ if (!titles.length) return null;
77
+ return `${SPLIT_QUESTION} I would split this card into ${titles.length} parts:\n\n${titles.map((t, i) => `${i + 1}. ${t}`).join('\n')}`;
78
+ }
79
+ const n = split.parts.length;
80
+ const prose = `${split.reason ? `${split.reason}\n\n` : ''}${split.parts.map(partLine).join('\n')}\n\n${SPLIT_QUESTION}`;
81
+ const ask = {
82
+ question: SPLIT_QUESTION, header: 'Subagents',
83
+ options: [
84
+ { label: splitUseLabel(n), description: n === 1 ? 'Run this part as a subagent.' : 'Run the parts side by side.' },
85
+ { label: SPLIT_SOLO_LABEL, description: 'Claude does every part itself, one after another.' },
86
+ ],
87
+ split,
88
+ };
89
+ return `${prose}\n\n\`\`\`flowviant-ask\n${JSON.stringify(ask)}\n\`\`\``;
90
+ }
@@ -0,0 +1,57 @@
1
+ /** Generated by the existing vault-scoped reader; no new infrastructure access. */
2
+ export const ARCHITECTURE_EXAMPLE = {
3
+ version: 1,
4
+ summary: 'A browser application calls an API. Production deployment is unknown.',
5
+ components: [
6
+ { id: 'web', name: 'Web app', kind: 'application', description: 'The browser interface.', evidence: 'confirmed', sources: [{ path: 'web/package.json', reason: 'Declares the browser application.' }] },
7
+ { id: 'api', name: 'API', kind: 'service', description: 'Handles application requests.', evidence: 'confirmed', sources: [{ path: 'api/server.ts', line: 1, reason: 'Defines the HTTP entry point.' }] },
8
+ ],
9
+ connections: [{ from: 'web', to: 'api', label: 'HTTP requests', evidence: 'confirmed', sources: [{ path: 'web/client.ts', reason: 'Calls the API base URL.' }] }],
10
+ unknowns: ['The repository does not establish which services are deployed.'],
11
+ };
12
+
13
+ export const SYSTEM_ARCHITECTURE_CONTRACT = `
14
+ UNDERSTAND MY SYSTEM — docs/01-architecture.md also supplies the Architecture
15
+ view. Full sweeps generate the report. Incremental re-grounds maintain it only
16
+ when architecture-affecting changes are in scope; never expand an unrelated
17
+ incremental pass into a full scan just to add this report. If no report exists
18
+ and this pass cannot inspect the system, leave it absent. Inspect real entry
19
+ points, workspace/package manifests, API clients,
20
+ database/storage/queue adapters and deployment configuration before writing it.
21
+ Map major applications, services, data stores, queues and external systems;
22
+ do not turn every file, dependency or function into a node.
23
+
24
+ Include exactly ONE fenced block tagged flowviant-architecture containing JSON
25
+ in this version-1 format (this example is FORMAT ONLY, never project facts):
26
+ \n\`\`\`flowviant-architecture
27
+ ${JSON.stringify(ARCHITECTURE_EXAMPLE, null, 2)}
28
+ \`\`\`
29
+
30
+ Kinds: application, service, database, queue, storage, external. Component ids
31
+ are unique ASCII letters/digits/underscore/hyphen, starting with a letter or
32
+ digit, at most 80 characters. Connections reference those ids. Every component
33
+ and connection needs at least one source: a real repository-relative path you
34
+ opened, an optional positive line number and a reason explaining the evidence.
35
+ Never emit absolute paths, traversal or URLs as source paths. Confirmed means
36
+ source explicitly establishes the claim; inferred means an interpretation, and
37
+ the source reason must explain it. Neither means running, healthy or deployed.
38
+
39
+ State scope in the summary: only this checked-out repository is inspected;
40
+ other repositories, deployment state and external settings remain unknown unless
41
+ actually inspected. Never imply project-wide or production completeness. Put
42
+ unanswered questions and uninspected areas in unknowns. An empty/new repository
43
+ has components: [], connections: [] and an explanation in unknowns; do not invent
44
+ a proposed architecture. Never expose credentials, environment values, private
45
+ hosts or secret material. Do not contact infrastructure, provision anything,
46
+ install dependencies or run the application to construct this map.
47
+
48
+ At most 80 components, 200 connections, 40 sources per finding and 80 unknowns.
49
+ Names at most 160 characters, connection labels 200, source paths 1000 and
50
+ summary/descriptions/reasons/questions 4000. Keep the chapter's readable prose,
51
+ Mermaid overview, files: frontmatter and page-local provenance beside the report.
52
+ This is source analysis by the coding CLI, not independent verification by
53
+ Flowviant. Re-ground it when changed files affect system boundaries, config,
54
+ entry points or connections, including additions/removals outside its old files:
55
+ list. Re-read affected sources; retain unrelated findings without asserting a
56
+ new verified commit for unchecked claims. Respect human/reference ownership.
57
+ `;
@@ -0,0 +1,38 @@
1
+ /** Standalone daemon counterpart of shared/systemArchitecture.ts. The release
2
+ * parity gate checks the same accepted/rejected reports on both readers. */
3
+ export const SYSTEM_ARCHITECTURE_PATH = 'docs/01-architecture.md';
4
+ export const SYSTEM_ARCHITECTURE_FENCE = 'flowviant-architecture';
5
+ export const ARCHITECTURE_REPORT_DAEMON_VERSION = '0.125.31';
6
+
7
+ const text = (value, max = 4000) => typeof value === 'string' && value.trim().length > 0 && value.trim().length <= max;
8
+ const id = (value) => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/.test(value);
9
+ const source = (value) => value && typeof value.path === 'string' && value.path.length > 0 && value.path.length <= 1000 &&
10
+ !/^[\/\\]|[\\\x00-\x1f\x7f:#?]/.test(value.path) && value.path.split('/').every((part) => part && part !== '.' && part !== '..') &&
11
+ (value.line === undefined || (Number.isInteger(value.line) && value.line > 0)) && text(value.reason);
12
+ const finding = (value) => value && ['confirmed', 'inferred'].includes(value.evidence) &&
13
+ Array.isArray(value.sources) && value.sources.length > 0 && value.sources.length <= 40 && value.sources.every(source);
14
+
15
+ export function validArchitectureReport(value) {
16
+ if (!value || value.version !== 1 || !text(value.summary) || !Array.isArray(value.components) || value.components.length > 80 ||
17
+ !Array.isArray(value.connections) || value.connections.length > 200 || !Array.isArray(value.unknowns) || value.unknowns.length > 80 || !value.unknowns.every((question) => text(question))) return false;
18
+ const ids = new Set();
19
+ for (const component of value.components) {
20
+ if (!component || !id(component.id) || ids.has(component.id) || !text(component.name, 160) || !text(component.description) ||
21
+ !['application', 'service', 'database', 'queue', 'storage', 'external'].includes(component.kind) || !finding(component)) return false;
22
+ ids.add(component.id);
23
+ }
24
+ return (ids.size > 0 || value.unknowns.length > 0) && value.connections.every((connection) =>
25
+ connection && ids.has(connection.from) && ids.has(connection.to) && text(connection.label, 200) && finding(connection));
26
+ }
27
+
28
+ export function readSystemArchitecture(markdown) {
29
+ const blocks = [...markdown.matchAll(/^```flowviant-architecture[^\S\r\n]*\r?\n([\s\S]*?)^```[^\S\r\n]*\r?$/gm)];
30
+ if (blocks.length === 0) return { status: markdown.includes('```flowviant-architecture') ? 'invalid' : 'missing' };
31
+ if (blocks.length !== 1) return { status: 'invalid' };
32
+ try {
33
+ const report = JSON.parse(blocks[0][1]);
34
+ return validArchitectureReport(report) ? { status: 'available', report } : { status: 'invalid' };
35
+ } catch {
36
+ return { status: 'invalid' };
37
+ }
38
+ }
@@ -116,8 +116,27 @@ export const WHOLE_REQUEST = `THE WHOLE REQUEST, NOT ONLY THE BRIEF:
116
116
  delivered JSON object, one entry per item this card was meant to deliver
117
117
  that you did not build ("not built"), built in part ("partly") or did not
118
118
  check against the real thing ("unverified"), and per part of the request
119
- that NO card in this run owns ("unowned"), so the person can file it:
120
- "gaps": [{ "item": "what is missing", "status": "not built | partly | unverified | unowned", "why": "one sentence" }]
119
+ that NO card in this run owns ("unowned"). Outside this run does not mean
120
+ unowned in the project: check relatedWork and the current queue first.
121
+ "gaps": [{ "item": "what is missing", "status": "not built | partly | unverified | unowned", "why": "one sentence", "disposition": "required | owned | optional | decision", "ownerTaskId": "existing card id only when owned" }]
122
+ ROUTE FINDINGS BEFORE DELIVERY:
123
+ - Already assigned, queued, running or completed on another card: use
124
+ disposition "owned" and its actual ownerTaskId. Link it; do not repeat it
125
+ or stage another task. An older Done receipt is not proof a recurrence is fixed.
126
+ - Necessary to meet this card's approved outcome: finish it now in this same
127
+ worktree and verify the combined result. Record completed incidental repairs
128
+ through raised. If another work step is needed, report disposition "required"
129
+ with why it is necessary; the server queues the same card's next step before
130
+ Review. This does not authorize another card's work, a product-kind change,
131
+ optional improvements, merging or deployment. Keep commit attribution.
132
+ - Optional improvement: use disposition "optional". Describe it as an offer in
133
+ the agent's summary; the person may reply here to include it. It creates no
134
+ New task tab and does not block delivery of the approved scope.
135
+ - A requirement, ownership or scope decision only the person can settle:
136
+ use the existing blocked reply with a specific question and options. Stay in
137
+ this agent conversation. Never claim delivery while that decision is required.
138
+ - After a person says "Finish the current scope", retain declined additions as
139
+ optional and finish the approved request; do not ask the same scope question again.
121
140
  "gaps": [] says you walked the request and nothing is missing. Never recast
122
141
  a gap as a decision: a choice you made in place of the request is a gap
123
142
  ("partly") with its reason.
@@ -95,7 +95,7 @@ export function createClaudeInput(child, { onTaken = () => {}, onContext = null
95
95
  }
96
96
  if (event.type === 'system' && event.subtype === 'compact_boundary') { compacting = false; askContext(true); }
97
97
  if (event.type === 'system' && event.subtype === 'background_tasks_changed' && Array.isArray(event.tasks)) {
98
- agents = event.tasks.filter(task => task?.task_type === 'local_agent').length;
98
+ agents = event.tasks.filter(task => ['local_agent', 'local_workflow'].includes(task?.task_type)).length;
99
99
  if (agents > 0) draining = false; // a drained message's turn started agents
100
100
  }
101
101
  if (event.type === 'result') {
@@ -1,11 +1,34 @@
1
1
  import { NATIVE_AGENT_TOOLS } from './nativeAgentTools.mjs';
2
+ import { createClaudeWorkflows } from './claudeWorkflows.mjs';
3
+ import { turnModelOf } from './turnModel.mjs';
2
4
 
3
- /** Native tool-use ids are stable for the lifetime of an Agent/Task call. */
4
- export function createClaudeSubagents(report = () => {}) {
5
+ const textOf = content => typeof content === 'string' ? content
6
+ : Array.isArray(content) ? content.filter(b => b?.type === 'text' && typeof b.text === 'string').map(b => b.text).join('\n') : '';
7
+ const bounded = (map, n) => { if (map.size > n) map.delete(map.keys().next().value); };
8
+
9
+ /**
10
+ * Native tool-use ids are stable for the lifetime of an Agent/Task call.
11
+ *
12
+ * WHAT A CHILD SAYS OF ITSELF (2026-11-06, S4.1): its model off its own
13
+ * `message.model` (never the spawn's `model` input: what was asked is not
14
+ * what ran), its final words off its own sidechain `result`, else the
15
+ * foreground call's tool_result, else its last message once it is done; its
16
+ * parent is the stream the spawn rode (the lead, or a child that spawned
17
+ * one). Claude states no per-child effort, so none is reported.
18
+ */
19
+ export function createClaudeSubagents(report = () => {}, onWorkflow = () => {}) {
20
+ const workflows = createClaudeWorkflows(report, onWorkflow);
5
21
  const calls = new Map();
6
22
  const aliases = new Map();
7
23
  const questions = new Map();
8
24
  const seen = new Set();
25
+ const models = new Map(), said = new Map(), resulted = new Set();
26
+ const finish = (id, result) => {
27
+ const text = typeof result === 'string' && result.trim() ? result : said.get(id);
28
+ if (!text || resulted.has(id)) return {};
29
+ resulted.add(id); bounded(resulted, 128);
30
+ return { result: text };
31
+ };
9
32
  const identify = ev => ev.parent_tool_use_id != null
10
33
  ? aliases.get(ev.parent_tool_use_id) ?? ev.parent_tool_use_id
11
34
  : ev.isSidechain ? aliases.get(ev.agentId ?? ev.agent_id ?? ev.sessionId) : undefined;
@@ -28,7 +51,18 @@ export function createClaudeSubagents(report = () => {}) {
28
51
  return true;
29
52
  },
30
53
  observe(ev) {
54
+ workflows.observe(ev);
31
55
  const agentId = identify(ev);
56
+ if (ev.type === 'assistant' && agentId) {
57
+ const model = turnModelOf(ev.message?.model);
58
+ if (model && models.get(agentId) !== model) { models.set(agentId, model); bounded(models, 128); report({ id: agentId, model }); }
59
+ const words = textOf(ev.message?.content);
60
+ if (words.trim()) { said.set(agentId, words); bounded(said, 128); }
61
+ }
62
+ if (ev.type === 'result' && agentId && !ev.is_error && typeof ev.result === 'string' && ev.result.trim()) {
63
+ resulted.delete(agentId);
64
+ report({ id: agentId, ...finish(agentId, ev.result) });
65
+ }
32
66
  if (ev.type === 'assistant') {
33
67
  for (const b of ev.message?.content ?? []) {
34
68
  if (b.type === 'tool_use' && NATIVE_AGENT_TOOLS.claude.ask.includes(b.name) && agentId) {
@@ -43,8 +77,11 @@ export function createClaudeSubagents(report = () => {}) {
43
77
  aliases.set(b.id, id);
44
78
  if (calls.size > 128) calls.delete(calls.keys().next().value);
45
79
  if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
80
+ // A resume names the child's native id: the identity it keeps across turns.
81
+ const resumed = typeof input.resume === 'string' && input.resume ? { nativeId: input.resume } : {};
82
+ resulted.delete(id);
46
83
  report({ id, kind: input.subagent_type ?? '', title: input.description ?? input.name ?? '',
47
- brief: input.prompt ?? '', status: 'running', now: input.description ?? '' });
84
+ brief: input.prompt ?? '', status: 'running', now: input.description ?? '', parent: agentId ?? 'lead', ...resumed });
48
85
  }
49
86
  }
50
87
  if (ev.type === 'user') {
@@ -60,8 +97,10 @@ export function createClaudeSubagents(report = () => {}) {
60
97
  if (nativeId) aliases.set(nativeId, id);
61
98
  // Background launch is not completion; its notification closes it.
62
99
  const background = result.status === 'async_launched' || result.isAsync === true || calls.get(b.tool_use_id).input.run_in_background === true;
63
- report({ id, ...(nativeId ? { nativeId } : {}), status: b.is_error ? 'stopped' : background ? 'running' : 'done',
64
- ...(typeof result.description === 'string' ? { now: result.description } : {}) });
100
+ const status = b.is_error ? 'stopped' : background ? 'running' : 'done';
101
+ report({ id, ...(nativeId ? { nativeId } : {}), status,
102
+ ...(typeof result.description === 'string' ? { now: result.description } : {}),
103
+ ...(status === 'done' ? finish(id, textOf(b.content)) : {}) });
65
104
  }
66
105
  }
67
106
  if (ev.type === 'system' && ['task_started', 'task_progress', 'task_notification'].includes(ev.subtype)) {
@@ -70,7 +109,8 @@ export function createClaudeSubagents(report = () => {}) {
70
109
  if (id) {
71
110
  if (ev.task_id) aliases.set(ev.task_id, id);
72
111
  const status = ev.status === 'completed' ? 'done' : ['failed', 'stopped'].includes(ev.status) ? 'stopped' : 'running';
73
- report({ id, status, ...(ev.description || ev.summary ? { now: ev.description ?? ev.summary } : {}) });
112
+ report({ id, status, ...(ev.description || ev.summary ? { now: ev.description ?? ev.summary } : {}),
113
+ ...(status === 'done' ? finish(id) : {}) });
74
114
  }
75
115
  }
76
116
  return agentId;
@@ -0,0 +1,94 @@
1
+ /** Workflow children have no stdout of their own: task_progress carries snapshots. */
2
+ import { turnModelOf } from './turnModel.mjs';
3
+ import { WORKFLOW_AGENT_CAP, WORKFLOW_CAP } from './subagentBounds.mjs';
4
+
5
+ const text = (s, n) => typeof s === 'string' ? s.slice(0, n) : '';
6
+ const idOf = s => typeof s === 'string' && s.length > 0 && s.length <= 96 ? s : null;
7
+ const instant = v => {
8
+ const n = typeof v === 'number' ? v : Date.parse(v);
9
+ return Number.isFinite(n) && Math.abs(n) < 8.64e15 ? new Date(n).toISOString() : undefined;
10
+ };
11
+ const count = v => Number.isSafeInteger(v) && v >= 0 ? v : undefined;
12
+ const statusOf = s => s === 'done' || s === 'completed' ? 'done'
13
+ : s === 'failed' || s === 'stopped' || s === 'cancelled' ? 'stopped' : 'running';
14
+
15
+ export function createClaudeWorkflows(reportAgent = () => {}, report = () => {}) {
16
+ const runs = new Map(), aliases = new Map(), launches = new Map();
17
+ return {
18
+ observe(ev) {
19
+ if (ev.type === 'assistant' && !ev.parent_tool_use_id && !ev.isSidechain) {
20
+ for (const b of ev.message?.content ?? []) if (b?.type === 'tool_use' && b.name === 'Workflow' && idOf(b.id)) {
21
+ launches.set(b.id, text(b.input?.description, 600));
22
+ if (launches.size > WORKFLOW_CAP) launches.delete(launches.keys().next().value);
23
+ report({ launcherId: b.id });
24
+ }
25
+ }
26
+ if (ev.type !== 'system' || !['task_started', 'task_progress', 'task_updated', 'task_notification'].includes(ev.subtype)) return;
27
+ const id = idOf(ev.run_id) ?? aliases.get(ev.task_id);
28
+ if (!id) return;
29
+ if (ev.subtype === 'task_started' && ev.task_type === 'local_workflow' && !runs.has(id)) {
30
+ if (runs.size >= WORKFLOW_CAP) return;
31
+ runs.set(id, { row: { id, key: `claude-workflow:${id}`, name: text(ev.workflow_name, 200),
32
+ description: text(ev.description ?? launches.get(ev.tool_use_id), 600), status: 'running',
33
+ startedAt: instant(ev.startedAt) ?? new Date().toISOString(), phases: [], notRecorded: 0 },
34
+ agents: new Map(), seen: new Set(), tool: ev.tool_use_id });
35
+ if (idOf(ev.task_id)) aliases.set(ev.task_id, id);
36
+ }
37
+ const run = runs.get(id);
38
+ if (!run) return;
39
+ const { row, agents, seen } = run;
40
+ const before = JSON.stringify(row);
41
+ const progress = Array.isArray(ev.workflow_progress) ? ev.workflow_progress.slice(0, 2000) : [];
42
+ for (const a of progress) {
43
+ if (a?.type !== 'workflow_agent' || !idOf(a.agentId)) continue;
44
+ seen.add(a.agentId);
45
+ if (seen.size > 4096) seen.delete(seen.values().next().value);
46
+ const previous = agents.get(a.agentId);
47
+ const status = statusOf(a.state);
48
+ const startedAt = instant(a.startedAt) ?? previous?.startedAt;
49
+ const endedAt = status !== 'running' ? instant(a.endedAt) ?? instant(
50
+ Number.isFinite(a.startedAt) && Number.isFinite(a.durationMs) ? a.startedAt + a.durationMs : a.lastProgressAt
51
+ ) ?? previous?.endedAt ?? new Date().toISOString() : undefined;
52
+ const next = { ...previous, id: a.agentId, nativeId: a.agentId, key: `claude:${a.agentId}`, cli: 'claude',
53
+ kind: 'workflow', parentKey: row.key, title: text(a.label, 200), status,
54
+ ...(typeof a.phaseTitle === 'string' ? { phase: text(a.phaseTitle, 200) } : {}),
55
+ ...(startedAt ? { startedAt } : {}), ...(endedAt ? { endedAt } : {}),
56
+ ...(turnModelOf(a.model) ? { model: turnModelOf(a.model) } : {}),
57
+ ...(a.lastToolName || a.lastToolSummary ? { now: [text(a.lastToolName, 80), text(a.lastToolSummary, 400)].filter(Boolean).join(' ').slice(0, 400) } : {}),
58
+ ...(typeof a.promptPreview === 'string' ? { brief: text(a.promptPreview, 24000), briefStatus: 'truncated' } : {}),
59
+ ...(typeof a.resultPreview === 'string' ? { result: text(a.resultPreview, 8000), resultStatus: 'truncated' } : {}),
60
+ ...(count(a.tokens) !== undefined ? { tokens: a.tokens } : {}),
61
+ ...(count(a.toolCalls) !== undefined ? { toolCalls: a.toolCalls } : {}) };
62
+ if (status === 'running') delete next.endedAt;
63
+ agents.set(a.agentId, next);
64
+ }
65
+ const newest = [...agents.values()].sort((a, b) => (b.startedAt ?? '').localeCompare(a.startedAt ?? ''));
66
+ for (const a of newest.slice(WORKFLOW_AGENT_CAP)) agents.delete(a.id);
67
+ row.notRecorded = Math.max(row.notRecorded, seen.size - agents.size);
68
+ if (progress.length) row.phases = progress.filter(p => p?.type === 'workflow_phase').slice(0, 64).map(p => {
69
+ const children = progress.filter(a => a?.type === 'workflow_agent' && a.phaseTitle === p.title);
70
+ return { title: text(p.title, 200), status: p.state ? statusOf(p.state)
71
+ : children.length && children.every(a => statusOf(a.state) === 'done') ? 'done' : 'running' };
72
+ });
73
+ if (count(ev.usage?.total_tokens) !== undefined) row.totalTokens = ev.usage.total_tokens;
74
+ if (count(ev.usage?.tool_uses) !== undefined) row.toolCalls = ev.usage.tool_uses;
75
+ if (ev.subtype === 'task_updated' && instant(ev.patch?.end_time)) run.endedAt = instant(ev.patch.end_time);
76
+ if (ev.subtype === 'task_notification') {
77
+ row.status = statusOf(ev.status);
78
+ row.endedAt = instant(ev.endedAt) ?? run.endedAt ?? new Date().toISOString();
79
+ row.phases = row.phases.map(p => ({ ...p, status: p.status === 'done' ? 'done' : row.status }));
80
+ for (const a of agents.values()) if (a.status === 'running') Object.assign(a, { status: row.status, endedAt: row.endedAt });
81
+ }
82
+ // Report only changed children, even though the CLI resends the whole snapshot.
83
+ for (const a of agents.values()) {
84
+ const signature = JSON.stringify(a);
85
+ if (run.sent?.get(a.id) !== signature) reportAgent(a);
86
+ }
87
+ // Signatures stay outside the record that travels on the wire.
88
+ run.sent ??= new Map();
89
+ for (const key of run.sent.keys()) if (!agents.has(key)) run.sent.delete(key);
90
+ for (const a of agents.values()) run.sent.set(a.id, JSON.stringify(a));
91
+ if (before !== JSON.stringify(row) || ev.subtype === 'task_started') report({ ...row, launcherId: run.tool });
92
+ },
93
+ };
94
+ }
@@ -19,6 +19,11 @@ import { scrubCut } from './uplinkScrub.mjs';
19
19
  * attached, and is a perfectly survivable second-best.
20
20
  */
21
21
  const LIMIT_PHRASES = [
22
+ // Installed codex-cli 0.166.0 native binary, strings (2026-11-06):
23
+ // ~/.codex/packages/standalone/releases/0.166.0-x86_64-unknown-linux-musl/bin/codex
24
+ // Core error display: plain, model-specific ("for ..."), Plus, credits,
25
+ // and admin-request variants all share this exact literal prefix.
26
+ /you've hit your usage limit/i,
22
27
  /usage limit reached/i,
23
28
  /rate limit/i,
24
29
  /you've reached your .* limit/i,