flowviant 0.125.1 → 0.125.3
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.
- package/README.md +4 -1
- package/bin/cli.mjs +10 -0
- package/bin/lib/agentAsk.mjs +6 -12
- package/bin/lib/agentCards.mjs +5 -30
- package/bin/lib/agentFiles.mjs +1 -1
- package/bin/lib/agentFootprint.mjs +5 -5
- package/bin/lib/agentPlan.mjs +16 -27
- package/bin/lib/cardThread.mjs +1 -1
- package/bin/lib/claudeStream.mjs +18 -7
- package/bin/lib/claudeSubagentProgress.mjs +62 -0
- package/bin/lib/claudeSubagents.mjs +77 -0
- package/bin/lib/codexRelay.mjs +6 -22
- package/bin/lib/codexSubagentEvents.mjs +34 -0
- package/bin/lib/codexSubagents.mjs +232 -0
- package/bin/lib/hooks/orchestrator.mjs +28 -0
- package/bin/lib/orchestrator.mjs +298 -0
- package/bin/lib/orchestratorClient.mjs +34 -0
- package/bin/lib/orchestratorHooks.mjs +15 -0
- package/bin/lib/orchestratorInput.mjs +47 -0
- package/bin/lib/orchestratorPrompt.mjs +19 -0
- package/bin/lib/orchestratorProtocol.mjs +15 -0
- package/bin/lib/promptTransport.mjs +34 -0
- package/bin/lib/runTurn.mjs +68 -13
- package/bin/lib/runtimeClaude.mjs +3 -1
- package/bin/lib/runtimeCodex.mjs +18 -2
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/trace.mjs +48 -10
- package/bin/lib/workAgentTurnExecution.mjs +84 -7
- package/bin/lib/workAgentTurnOutcome.mjs +8 -8
- package/bin/lib/workAgentTurns.mjs +2 -0
- package/bin/lib/workChildren.mjs +4 -0
- package/bin/lib/workLiveMessages.mjs +7 -1
- package/bin/lib/workSessionTurns.mjs +3 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -223,5 +223,8 @@ this package version, builds, attests and attaches the binaries and manifest.
|
|
|
223
223
|
R2 publication remains opt-in through `PUBLISH_R2` and the `r2` environment.
|
|
224
224
|
For a manual upload run `sh cli/scripts/upload-binaries.sh` from the root; it
|
|
225
225
|
checks all four binaries against the manifest and publishes `latest.json` last.
|
|
226
|
-
`(cd cli && npm
|
|
226
|
+
`(cd cli && npm exec --yes --package=npm@11.17.0 -- npm publish)` retains
|
|
227
|
+
the prepublish parity gate and includes the npm shrinkwrap. The publisher is
|
|
228
|
+
pinned because [npm 12 removed shrinkwrap support](https://github.com/npm/cli/blob/latest/docs/lib/content/configuring-npm/package-lock-json.md#npm-shrinkwrapjson)
|
|
229
|
+
and omits it from tarballs; npm 12 consumers also ignore that lockfile.
|
|
227
230
|
No sibling repository or separate app deploy key is required.
|
package/bin/cli.mjs
CHANGED
|
@@ -80,6 +80,11 @@
|
|
|
80
80
|
// answered here before anything else loads: no config, no update check, no
|
|
81
81
|
// output but the hook's. An unknown hook exits 2, which Claude Code reads as a
|
|
82
82
|
// refusal — the read guard's own rule for a hook that cannot judge.
|
|
83
|
+
if (process.argv[2] === '__orchestrator-mcp') {
|
|
84
|
+
await (await import('./lib/orchestratorClient.mjs')).orchestratorMcpMain();
|
|
85
|
+
process.exit(process.exitCode ?? 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
83
88
|
if (['__repository', '__repository-mcp'].includes(process.argv[2])) {
|
|
84
89
|
await (await import('./lib/repositoryTool.mjs')).repositoryToolMain(process.argv[2] === '__repository-mcp');
|
|
85
90
|
process.exit(process.exitCode ?? 0);
|
|
@@ -89,12 +94,17 @@ if (process.argv[2] === '__hook') {
|
|
|
89
94
|
const HOOKS = {
|
|
90
95
|
'read-guard': () => import('./lib/hooks/readGuard.mjs'),
|
|
91
96
|
'mcp-allow': () => import('./lib/hooks/mcpAllow.mjs'),
|
|
97
|
+
'orchestrator': () => import('./lib/hooks/orchestrator.mjs'),
|
|
92
98
|
};
|
|
93
99
|
const load = Object.hasOwn(HOOKS, process.argv[3] ?? '') ? HOOKS[process.argv[3]] : null;
|
|
94
100
|
if (!load) {
|
|
95
101
|
process.stderr.write(`flowviant: no hook named '${String(process.argv[3] ?? '').slice(0, 40)}'\n`);
|
|
96
102
|
process.exit(2);
|
|
97
103
|
}
|
|
104
|
+
if (process.argv[3] === 'orchestrator') {
|
|
105
|
+
await (await load()).main();
|
|
106
|
+
process.exit(process.exitCode ?? 0);
|
|
107
|
+
}
|
|
98
108
|
(await load()).main();
|
|
99
109
|
// The hook ends the process itself, on its verdict.
|
|
100
110
|
await new Promise(() => {});
|
package/bin/lib/agentAsk.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* would alter what the agent declared, the fence's own rule), and the
|
|
22
22
|
* question goes through as plain text, exactly as it did before options
|
|
23
23
|
* existed. So does a question too long for a fence, one that already carries
|
|
24
|
-
* a fence of its own
|
|
24
|
+
* a fence of its own. Chat prose itself has no character allowance.
|
|
25
25
|
*
|
|
26
26
|
* STRICTER THAN THE PAGE, NEVER LOOSER. `ASK_BOUNDS` is the shared one (the
|
|
27
27
|
* release gate, check-app-parity.mjs, compares them). `ASK_HOSTILE` is a
|
|
@@ -30,7 +30,6 @@
|
|
|
30
30
|
* Refusing a label the page would have taken costs only the tiles.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import { scrubCut } from './uplinkScrub.mjs';
|
|
34
33
|
|
|
35
34
|
/** The fence's bounds — the shared `ASK_BOUNDS`, key for key. */
|
|
36
35
|
export const ASK_BOUNDS = Object.freeze({
|
|
@@ -52,10 +51,6 @@ const QUESTION_HOSTILE = new RegExp(`[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u00
|
|
|
52
51
|
/** A fence already in the question, anchored as the page's reader anchors. */
|
|
53
52
|
const HAS_FENCE = /^```[ \t]*flowviant-ask/m;
|
|
54
53
|
|
|
55
|
-
/** The cut the turn's answer gets on its way to the wire
|
|
56
|
-
* (`workAgentTurnOutcome.mjs`): a fence past it would arrive broken. */
|
|
57
|
-
export const ASK_ANSWER_MAX = 8000;
|
|
58
|
-
|
|
59
54
|
/** One field, trimmed — or null when it is empty, oversize or hostile. */
|
|
60
55
|
const line = (v, max) => {
|
|
61
56
|
if (typeof v !== 'string') return null;
|
|
@@ -96,17 +91,16 @@ export function declaredOptions(raw, scrub = (s) => s) {
|
|
|
96
91
|
* "a short heading over body-size context"). The blocked shape's optional
|
|
97
92
|
* `context` — what the decision settles, findings, evidence — is written as
|
|
98
93
|
* prose before the question, so the page draws the fence's one-line
|
|
99
|
-
* `question` as the heading and this beneath it at body size. Scrubbed
|
|
100
|
-
*
|
|
101
|
-
* and is written as the plain text it is.
|
|
94
|
+
* `question` as the heading and this beneath it at body size. Scrubbed whole;
|
|
95
|
+
* a context carrying its own fence keeps its prose without new choice tiles.
|
|
102
96
|
*/
|
|
103
97
|
const contextOf = (v, scrub) => {
|
|
104
98
|
if (typeof v?.context !== 'string') return '';
|
|
105
|
-
return
|
|
99
|
+
return scrub(v.context.trim()).trim();
|
|
106
100
|
};
|
|
107
101
|
|
|
108
102
|
/**
|
|
109
|
-
* THE ANSWER A BLOCKED TURN HANDS ON: `question` (already scrubbed
|
|
103
|
+
* THE ANSWER A BLOCKED TURN HANDS ON: `question` (already scrubbed),
|
|
110
104
|
* after its `context` when there is one, then the fence built from
|
|
111
105
|
* `v.options` and `v.multiSelect` when every rule above holds.
|
|
112
106
|
*/
|
|
@@ -119,5 +113,5 @@ export function blockedAnswer(question, v, scrub = (s) => s) {
|
|
|
119
113
|
if (!q || q.length > ASK_BOUNDS.question || QUESTION_HOSTILE.test(q) || HAS_FENCE.test(prose)) return prose;
|
|
120
114
|
const ask = { question: q, options, ...(v.multiSelect === true ? { multiSelect: true } : {}) };
|
|
121
115
|
const answer = `${prose}\n\n\`\`\`flowviant-ask\n${JSON.stringify(ask)}\n\`\`\``;
|
|
122
|
-
return answer
|
|
116
|
+
return answer;
|
|
123
117
|
}
|
package/bin/lib/agentCards.mjs
CHANGED
|
@@ -37,31 +37,8 @@
|
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
39
|
import { appendFileSync, readFileSync } from 'node:fs';
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* How many card specs one agent may accumulate.
|
|
45
|
-
*
|
|
46
|
-
* A bound on a MACHINE — an agent's queue is a handful of cards, and this exists
|
|
47
|
-
* so a pathological agent (a card re-delivered fifty times, an agent grown past
|
|
48
|
-
* its budget) cannot turn a prompt into a file read. The NEWEST are kept, which
|
|
49
|
-
* is the same tail-is-what-matters rule the trace keeps.
|
|
50
|
-
*/
|
|
51
|
-
export const MAX_STASHED_CARDS = 40;
|
|
52
|
-
/** The most one spec may contribute. A brief is written by whoever filed the
|
|
53
|
-
* card and the server caps it, but this file is composed into a prompt and a
|
|
54
|
-
* bound it owns is a bound that cannot be argued away upstream.
|
|
55
|
-
*
|
|
56
|
-
* 8,000 is the record's brief ceiling; since 0.106.0 the spec also carries
|
|
57
|
-
* the card's discussion, so the bound grows by the most a turn's discussion
|
|
58
|
-
* can render to (`CARD_THREAD_RENDER_MAX`). The discussion is printed LAST,
|
|
59
|
-
* and a stash that cut it would hand the pre-review a thread missing its
|
|
60
|
-
* newest words — the ones the agent read most recently. Since 0.112.0 the
|
|
61
|
-
* files on that discussion follow it (`CARD_FILES_RENDER_MAX`), and a cut
|
|
62
|
-
* there would hand the pre-review a card missing the screenshot its agent
|
|
63
|
-
* was shown. */
|
|
64
|
-
export const MAX_SPEC_CHARS = 8_000 + CARD_THREAD_RENDER_MAX + CARD_FILES_RENDER_MAX;
|
|
40
|
+
// Specs have no character or card-count allowance. Filesystem read/write
|
|
41
|
+
// failures remain explicit missing-spec evidence for the optional precheck.
|
|
65
42
|
|
|
66
43
|
/**
|
|
67
44
|
* Write down the spec this turn is about to hand the agent.
|
|
@@ -83,7 +60,7 @@ export function stashCard(path, taskId, spec) {
|
|
|
83
60
|
try {
|
|
84
61
|
appendFileSync(
|
|
85
62
|
path,
|
|
86
|
-
JSON.stringify({ taskId: id.slice(0, 64), prompt: text
|
|
63
|
+
JSON.stringify({ taskId: id.slice(0, 64), prompt: text }) + '\n'
|
|
87
64
|
);
|
|
88
65
|
return true;
|
|
89
66
|
} catch {
|
|
@@ -124,10 +101,8 @@ export function readStash(path) {
|
|
|
124
101
|
const taskId = typeof v.taskId === 'string' ? v.taskId.trim() : '';
|
|
125
102
|
const prompt = typeof v.prompt === 'string' ? v.prompt : '';
|
|
126
103
|
if (!taskId || !prompt.trim()) continue;
|
|
127
|
-
byId.set(taskId, prompt
|
|
104
|
+
byId.set(taskId, prompt);
|
|
128
105
|
}
|
|
129
106
|
const all = [...byId].map(([taskId, prompt]) => ({ taskId, prompt }));
|
|
130
|
-
|
|
131
|
-
// agent's branch is reading the work at its end.
|
|
132
|
-
return all.slice(-MAX_STASHED_CARDS);
|
|
107
|
+
return all;
|
|
133
108
|
}
|
package/bin/lib/agentFiles.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export const AGENT_FILE_LINE_MAX = 160;
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* THE MOST THE CARD'S FILES CAN ADD TO A CARD SPEC, rendered — so the spec
|
|
41
|
-
* stash (agentCards.mjs
|
|
41
|
+
* stash (agentCards.mjs) holds the note whole beside the
|
|
42
42
|
* discussion, and the pre-review is handed the files the agent was. A header
|
|
43
43
|
* line and its blank line, then one line per file. `agentFiles.test.mjs`
|
|
44
44
|
* renders the worst case against it.
|
|
@@ -52,20 +52,20 @@ export function measureAgentFootprint({ placeId, baseDir, baseRef, readGit = git
|
|
|
52
52
|
return result;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
55
|
+
/** Complete card requirements stay scrubbed inside the kickoff's untrusted fence. */
|
|
56
56
|
export function liveAgentContext(agent, footprint) {
|
|
57
57
|
const text = (value, cap) => scrubCut(typeof value === 'string' ? value : '', cap);
|
|
58
58
|
const count = value => typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1_000_000, Math.floor(value))) : 0;
|
|
59
|
-
const ids = value => (Array.isArray(value) ? value : []).filter(id => typeof id === 'string' && id.length > 0 && id.length <= 64)
|
|
59
|
+
const ids = value => (Array.isArray(value) ? value : []).filter(id => typeof id === 'string' && id.length > 0 && id.length <= 64);
|
|
60
60
|
const intended = agent?.intendedWork;
|
|
61
61
|
return {
|
|
62
62
|
id: text(agent?.id, 64), name: text(agent?.name, 80), status: text(agent?.status, 40),
|
|
63
63
|
taskIds: ids(agent?.taskIds), changedFiles: footprint,
|
|
64
64
|
intendedWork: intended && Array.isArray(intended.tasks) ? {
|
|
65
65
|
taskIds: ids(intended.taskIds),
|
|
66
|
-
tasks: intended.tasks.
|
|
67
|
-
id: t.id, state: t.state, taskKind: agentTaskKindOf(t.taskKind), ...(agentTaskTypeFor(t) ? { taskType: agentTaskTypeFor(t) } : {}), truncated: t.truncated === true
|
|
68
|
-
criteria: (Array.isArray(t.criteria) ? t.criteria : []).
|
|
66
|
+
tasks: intended.tasks.filter(t => t && typeof t.id === 'string' && t.id.length <= 64 && ['queued', 'running'].includes(t.state)).map(t => ({
|
|
67
|
+
id: t.id, state: t.state, taskKind: agentTaskKindOf(t.taskKind), ...(agentTaskTypeFor(t) ? { taskType: agentTaskTypeFor(t) } : {}), truncated: t.truncated === true, title: text(t.title, 300), brief: typeof t.brief === 'string' ? scrub(t.brief) : '',
|
|
68
|
+
criteria: (Array.isArray(t.criteria) ? t.criteria : []).filter(x => typeof x === 'string').map(x => scrub(x)),
|
|
69
69
|
anchors: (Array.isArray(t.anchors) ? t.anchors : []).slice(0, 20).map(x => text(x, 200)),
|
|
70
70
|
})),
|
|
71
71
|
omitted: count(intended.omitted),
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
* nobody can accept, and `null` — which the caller reports in the machine's own
|
|
20
20
|
* words — beats rendering an empty board with an Accept button on it.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* Structured proposal fields keep their existing shape bounds. Chat prose
|
|
23
|
+
* stays complete; native transport/storage refusals never shorten its tail.
|
|
24
24
|
*
|
|
25
25
|
* Run: node --test bin/lib/agentPlan.test.mjs
|
|
26
26
|
*/
|
|
@@ -71,9 +71,8 @@ const MAX_NOTE = 1000;
|
|
|
71
71
|
* the machine moved: no poll, no lease renewal, no other lane. Three bounds,
|
|
72
72
|
* none of which a real answer comes near:
|
|
73
73
|
*
|
|
74
|
-
* ·
|
|
75
|
-
*
|
|
76
|
-
* by a LINEAR scan (`fencedBody`) since 2026-10-10;
|
|
74
|
+
* · top-level objects are scanned whole, including long un-fenced replies;
|
|
75
|
+
* `fencedBody` and the brace scan are linear, with no prose-length slice;
|
|
77
76
|
* · each `{`'s closing brace is found ONCE (see `closers`), so the scan is a
|
|
78
77
|
* single right-to-left pass instead of one walk per brace;
|
|
79
78
|
* · only spans that can open an object (`{` then a key or `}`) are handed to
|
|
@@ -90,13 +89,12 @@ const MAX_NOTE = 1000;
|
|
|
90
89
|
* delivered agent went to Stuck. The contract puts the answer LAST and at the
|
|
91
90
|
* top level, which is the one span a budget must never cost.
|
|
92
91
|
*/
|
|
93
|
-
const MAX_SCAN_CHARS = 256 * 1024;
|
|
94
92
|
const MAX_NESTED_PARSES = 200;
|
|
95
93
|
const MAX_NESTED_CHARS = 4 * 1024 * 1024;
|
|
96
94
|
|
|
97
95
|
export function candidateObjects(text) {
|
|
98
96
|
const fenced = fencedBody(text);
|
|
99
|
-
const raw = text
|
|
97
|
+
const raw = text;
|
|
100
98
|
const bodies = [];
|
|
101
99
|
if (fenced !== null) bodies.push(fenced);
|
|
102
100
|
const close = closers(raw);
|
|
@@ -135,8 +133,7 @@ export function candidateObjects(text) {
|
|
|
135
133
|
* `text.match(/```(?:json)?\s*([\s\S]*?)```/)[1]` answered, found in ONE
|
|
136
134
|
* forward pass (2026-10-10, the audit's F079).
|
|
137
135
|
*
|
|
138
|
-
* That regex ran over the WHOLE text
|
|
139
|
-
* bound above skipped — and it backtracks quadratically on an opening fence
|
|
136
|
+
* That regex ran over the WHOLE text and backtracks quadratically on an opening fence
|
|
140
137
|
* followed by a long whitespace run with no closing fence: the greedy `\s*` and
|
|
141
138
|
* the lazy body re-scan the run from every split. Measured: 80 KB took 0.6 s,
|
|
142
139
|
* 320 KB nine seconds, of synchronous CPU in which no poll, lease or other
|
|
@@ -144,7 +141,7 @@ export function candidateObjects(text) {
|
|
|
144
141
|
* answer, by `indexOf`: the first ``` anywhere, an optional `json`, the
|
|
145
142
|
* whitespace after it skipped, and the body runs to the next ```. If that
|
|
146
143
|
* opening has no closer, no later opening can have one either, so there is no
|
|
147
|
-
* fence. The search stays over the whole text
|
|
144
|
+
* fence. The search stays over the whole text.
|
|
148
145
|
*/
|
|
149
146
|
const FENCE_SPACE = /\s*/y;
|
|
150
147
|
function fencedBody(text) {
|
|
@@ -323,18 +320,10 @@ function uniqueTempIds(list) {
|
|
|
323
320
|
* sent instead would be indistinguishable from an agent saying the branch now
|
|
324
321
|
* amounts to nothing.
|
|
325
322
|
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* cut in half — it matches nothing and the surviving prefix ships. The scrub is
|
|
331
|
-
* the only thing standing between a model that pasted a secret into its own
|
|
332
|
-
* summary and the wire, and a cap must never run ahead of it. So this is the
|
|
333
|
-
* same absurdity bound `summary` takes one line up (8000): enough that nothing
|
|
334
|
-
* a model writes reaches the caller unbounded, far enough above the 1000 the
|
|
335
|
-
* wire cuts to that the scrub always sees whole values. `workAgentTurns.mjs` scrubs and
|
|
336
|
-
* THEN cuts to 1000, and the server clamps again because the wire never trusts
|
|
337
|
-
* a machine to have clamped.
|
|
323
|
+
* The running branch account is a separate, short display readout, not the
|
|
324
|
+
* chat answer. Its existing display policy stays below; the reply parser
|
|
325
|
+
* preserves summaries and blocked-question prose whole. Scrub this entire
|
|
326
|
+
* account before its display slice so a credential cannot straddle the cut.
|
|
338
327
|
*/
|
|
339
328
|
const MAX_PROGRESS = 8000;
|
|
340
329
|
const progressOf = (v, scrub) => {
|
|
@@ -405,8 +394,8 @@ export function parseTurnResult(text, scrub = envScrub) {
|
|
|
405
394
|
const r = v.prerequisite;
|
|
406
395
|
if (!r || typeof r.title !== 'string' || !r.title.trim() || typeof r.brief !== 'string' || !r.brief.trim()
|
|
407
396
|
|| typeof r.taskKind !== 'string' || !Object.hasOwn(AGENT_TASK_KINDS, r.taskKind)) continue;
|
|
408
|
-
return { outcome: 'question', answer: typeof v.summary === 'string' ?
|
|
409
|
-
...progressOf(v, scrub), prerequisite: { title: clean(r.title.trim(), 300), brief:
|
|
397
|
+
return { outcome: 'question', answer: typeof v.summary === 'string' ? scrub(v.summary) : 'Requested a prerequisite card.',
|
|
398
|
+
...progressOf(v, scrub), prerequisite: { title: clean(r.title.trim(), 300), brief: scrub(r.brief.trim()), taskKind: r.taskKind } };
|
|
410
399
|
}
|
|
411
400
|
if (v.status === 'blocked') {
|
|
412
401
|
const question = typeof v.question === 'string' ? v.question.trim() : '';
|
|
@@ -417,7 +406,7 @@ export function parseTurnResult(text, scrub = envScrub) {
|
|
|
417
406
|
// Its declared choices (2026-10-13) ride on as the one fence the page
|
|
418
407
|
// reads, built here (agentAsk.mjs); malformed ones leave the question
|
|
419
408
|
// as plain text.
|
|
420
|
-
return { outcome: 'question', answer: blockedAnswer(
|
|
409
|
+
return { outcome: 'question', answer: blockedAnswer(scrub(question), v, scrub), ...progressOf(v, scrub) };
|
|
421
410
|
}
|
|
422
411
|
if (v.status === 'delivered') {
|
|
423
412
|
return {
|
|
@@ -426,7 +415,7 @@ export function parseTurnResult(text, scrub = envScrub) {
|
|
|
426
415
|
...(Array.isArray(v.mergeOrder) ? { mergeOrder: v.mergeOrder.filter((id) => typeof id === 'string').slice(0, 64) } : {}),
|
|
427
416
|
...(typeof v.mergeOrderReason === 'string' ? { mergeOrderReason: clean(v.mergeOrderReason, 1000) } : {}),
|
|
428
417
|
...(Array.isArray(v.mergeOrders) ? { mergeOrders: v.mergeOrders.slice(0, 60) } : {}),
|
|
429
|
-
answer: typeof v.summary === 'string' ?
|
|
418
|
+
answer: typeof v.summary === 'string' ? scrub(v.summary) : '',
|
|
430
419
|
...progressOf(v, scrub),
|
|
431
420
|
raised: Array.isArray(v.raised)
|
|
432
421
|
? v.raised
|
|
@@ -435,7 +424,7 @@ export function parseTurnResult(text, scrub = envScrub) {
|
|
|
435
424
|
.map((r) => ({
|
|
436
425
|
title: clean(r.title.trim(), 300),
|
|
437
426
|
...(typeof r.brief === 'string' && r.brief.trim()
|
|
438
|
-
? { brief:
|
|
427
|
+
? { brief: scrub(r.brief.trim()) }
|
|
439
428
|
: {}),
|
|
440
429
|
}))
|
|
441
430
|
: [],
|
package/bin/lib/cardThread.mjs
CHANGED
|
@@ -42,7 +42,7 @@ const OMITTED_MAX = 1_000_000;
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* THE MOST ONE TURN'S DISCUSSION CAN ADD TO A CARD SPEC, rendered — so the
|
|
45
|
-
* spec stash (agentCards.mjs
|
|
45
|
+
* complete spec stash (agentCards.mjs) can hold a card with its
|
|
46
46
|
* discussion whole, and the pre-review reads what the agent read. The text is
|
|
47
47
|
* at most `chars`; the renderer adds at most two characters and a newline per
|
|
48
48
|
* text line (<= 3x the text), a header line per entry, and two lines of its
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -79,7 +79,8 @@ export function usageFromResult(ev) {
|
|
|
79
79
|
// container that reports no spend, which is indistinguishable from an older
|
|
80
80
|
// daemon by design. A callback that is silent when it breaks has to be called
|
|
81
81
|
// directly by something.
|
|
82
|
-
export function handleStreamLine(line,
|
|
82
|
+
export function handleStreamLine(line, options) {
|
|
83
|
+
const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents } = options;
|
|
83
84
|
let ev;
|
|
84
85
|
try {
|
|
85
86
|
ev = JSON.parse(line);
|
|
@@ -88,10 +89,19 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
88
89
|
emit(line + '\n');
|
|
89
90
|
return;
|
|
90
91
|
}
|
|
92
|
+
if (ev.type === 'progress' && ev.data?.type === 'agent_progress' && ev.parentToolUseID && ev.data.message) {
|
|
93
|
+
subagents?.link(ev.data.agentId, ev.parentToolUseID);
|
|
94
|
+
return handleStreamLine(JSON.stringify({ ...ev.data.message, parent_tool_use_id: ev.parentToolUseID }), options);
|
|
95
|
+
}
|
|
96
|
+
const agentId = subagents ? subagents.identify(ev) : ev.parent_tool_use_id ?? (ev.isSidechain ? ev.agentId ?? ev.agent_id ?? ev.sessionId : undefined);
|
|
97
|
+
if (subagents && !subagents.accept(ev, agentId)) return;
|
|
98
|
+
// A sidechain with no identity must never be attributed to the parent.
|
|
99
|
+
if (ev.isSidechain && !agentId) return;
|
|
100
|
+
subagents?.observe(ev);
|
|
91
101
|
const push = (a) => {
|
|
92
102
|
if (!a || !a.label) return;
|
|
93
103
|
emit(a.label + '\n');
|
|
94
|
-
onActivity?.(a);
|
|
104
|
+
onActivity?.({ ...a, ...(agentId ? { agentId } : {}) });
|
|
95
105
|
};
|
|
96
106
|
if (ev.type === 'stream_event') {
|
|
97
107
|
// Partial messages announce reasoning before the completed assistant
|
|
@@ -106,7 +116,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
106
116
|
// THE MODEL THAT ANSWERED (2026-09-29, turnModel.mjs) — the fallback for an
|
|
107
117
|
// init that named none. The turn's own messages only: a subagent's carry
|
|
108
118
|
// `parent_tool_use_id`, and its model is not the turn's.
|
|
109
|
-
if (
|
|
119
|
+
if (!agentId) {
|
|
110
120
|
const answered = turnModelOf(ev.message.model);
|
|
111
121
|
if (answered) onModel?.(answered);
|
|
112
122
|
}
|
|
@@ -138,7 +148,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
138
148
|
: { kind: 'think', label: THINK_MARKER }
|
|
139
149
|
);
|
|
140
150
|
} else if (b.type === 'text' && b.text?.trim()) {
|
|
141
|
-
if (!answerFromResult) appendText(b.text + '\n');
|
|
151
|
+
if (!answerFromResult && !agentId) appendText(b.text + '\n');
|
|
142
152
|
// `label` for the console and the pulse — one collapsed 160-char line,
|
|
143
153
|
// byte-identical to what it has always printed. `full` for the trace:
|
|
144
154
|
// a `say` is the agent NARRATING, several sentences at a time, and the
|
|
@@ -150,18 +160,19 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
150
160
|
// The STRUCTURED form of the same event, for the transcript's tool
|
|
151
161
|
// cards — raw name + input, so the collector can keep what the
|
|
152
162
|
// one-line humanizer drops (an Edit's counts, the plan's items).
|
|
153
|
-
onToolEvent?.(b.name, b.input || {}, { id: b.id });
|
|
163
|
+
onToolEvent?.(b.name, b.input || {}, { id: b.id, ...(agentId ? { agentId } : {}) });
|
|
154
164
|
}
|
|
155
165
|
}
|
|
156
166
|
} else if (ev.type === 'user' && Array.isArray(ev.message?.content)) {
|
|
157
167
|
for (const b of ev.message.content) {
|
|
158
168
|
if (b.type === 'tool_result') onToolEvent?.(null, {}, {
|
|
159
169
|
id: b.tool_use_id, result: true, isError: b.is_error === true,
|
|
170
|
+
...(agentId ? { agentId } : {}),
|
|
160
171
|
output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
|
|
161
172
|
exitCode: ev.tool_use_result?.exit_code ?? ev.tool_use_result?.exitCode,
|
|
162
173
|
});
|
|
163
174
|
}
|
|
164
|
-
} else if (ev.type === 'system' && ev.subtype === 'init') {
|
|
175
|
+
} else if (ev.type === 'system' && ev.subtype === 'init' && !agentId) {
|
|
165
176
|
// WHAT THIS MACHINE'S CLI CAN BE ASKED FOR BY NAME. The init event is the
|
|
166
177
|
// CLI's OWN answer — it has already resolved personal skills, this repo's
|
|
167
178
|
// skills, plugins and whatever the project settings enable or disable — so
|
|
@@ -197,7 +208,7 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
|
|
|
197
208
|
tools: Array.isArray(ev.tools) ? ev.tools : undefined,
|
|
198
209
|
});
|
|
199
210
|
}
|
|
200
|
-
} else if (ev.type === 'result') {
|
|
211
|
+
} else if (ev.type === 'result' && !agentId) {
|
|
201
212
|
/**
|
|
202
213
|
* WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
|
|
203
214
|
*
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Claude persists agent_progress with the spawning parentToolUseID even
|
|
2
|
+
* when print-mode stdout carries only the parent. Follow this session alone. */
|
|
3
|
+
import { open } from 'node:fs/promises';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { claudeProjectDir } from './claudeSessions.mjs';
|
|
8
|
+
import { CLAUDE_SESSION_RE } from './conversationIds.mjs';
|
|
9
|
+
|
|
10
|
+
const MAX_READ = 4 * 1024 * 1024;
|
|
11
|
+
const LINE_MAX = 256 * 1024;
|
|
12
|
+
export function createClaudeSubagentProgress({ cwd, since = Date.now(), env = process.env, onLine }) {
|
|
13
|
+
let file, offset = null, rest = Buffer.alloc(0), skip = false, timer;
|
|
14
|
+
let chain = Promise.resolve();
|
|
15
|
+
const flush = () => {
|
|
16
|
+
chain = chain.then(async () => {
|
|
17
|
+
if (!file) return;
|
|
18
|
+
let fh;
|
|
19
|
+
try {
|
|
20
|
+
fh = await open(file, 'r');
|
|
21
|
+
const { size } = await fh.stat();
|
|
22
|
+
if (offset === null) { offset = Math.max(0, size - MAX_READ); skip = offset > 0; }
|
|
23
|
+
if (size < offset) { offset = 0; rest = Buffer.alloc(0); }
|
|
24
|
+
const end = Math.min(size, offset + MAX_READ);
|
|
25
|
+
while (offset < end) {
|
|
26
|
+
const buf = Buffer.alloc(Math.min(LINE_MAX, end - offset));
|
|
27
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
|
|
28
|
+
if (!bytesRead) break;
|
|
29
|
+
offset += bytesRead;
|
|
30
|
+
const data = Buffer.concat([rest, buf.subarray(0, bytesRead)]);
|
|
31
|
+
let start = 0, nl;
|
|
32
|
+
while ((nl = data.indexOf(10, start)) >= 0) {
|
|
33
|
+
const line = data.subarray(start, nl).toString('utf8'); start = nl + 1;
|
|
34
|
+
if (skip) { skip = false; continue; }
|
|
35
|
+
try {
|
|
36
|
+
const ev = JSON.parse(line);
|
|
37
|
+
if (ev.type !== 'progress' || ev.data?.type !== 'agent_progress' || !ev.parentToolUseID) continue;
|
|
38
|
+
const at = Date.parse(ev.timestamp ?? '');
|
|
39
|
+
if (!Number.isFinite(at) || at < since) continue;
|
|
40
|
+
onLine(line);
|
|
41
|
+
} catch { /* torn or unknown native row */ }
|
|
42
|
+
}
|
|
43
|
+
rest = data.subarray(start);
|
|
44
|
+
if (rest.length > LINE_MAX) { rest = Buffer.alloc(0); skip = true; }
|
|
45
|
+
}
|
|
46
|
+
} catch { /* the transcript can appear after init */ }
|
|
47
|
+
finally { await fh?.close().catch(() => {}); }
|
|
48
|
+
}).catch(() => {});
|
|
49
|
+
return chain;
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
session(id) {
|
|
53
|
+
if (file || !CLAUDE_SESSION_RE.test(id ?? '')) return;
|
|
54
|
+
let actualCwd = cwd;
|
|
55
|
+
try { actualCwd = realpathSync(cwd); } catch { /* missing directory */ }
|
|
56
|
+
file = join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects', claudeProjectDir(actualCwd), `${id}.jsonl`);
|
|
57
|
+
timer = setInterval(() => void flush(), 1_000); timer.unref?.();
|
|
58
|
+
},
|
|
59
|
+
flush,
|
|
60
|
+
async stop() { clearInterval(timer); await flush(); },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Native tool-use ids are stable for the lifetime of an Agent/Task call. */
|
|
2
|
+
export function createClaudeSubagents(report = () => {}) {
|
|
3
|
+
const calls = new Map();
|
|
4
|
+
const aliases = new Map();
|
|
5
|
+
const questions = new Map();
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
const identify = ev => ev.parent_tool_use_id != null
|
|
8
|
+
? aliases.get(ev.parent_tool_use_id) ?? ev.parent_tool_use_id
|
|
9
|
+
: ev.isSidechain ? aliases.get(ev.agentId ?? ev.agent_id ?? ev.sessionId) : undefined;
|
|
10
|
+
return {
|
|
11
|
+
identify,
|
|
12
|
+
link(nativeId, callId) {
|
|
13
|
+
if (nativeId && callId) {
|
|
14
|
+
const id = aliases.get(callId) ?? callId;
|
|
15
|
+
aliases.set(nativeId, id);
|
|
16
|
+
report({ id, nativeId });
|
|
17
|
+
}
|
|
18
|
+
if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
|
|
19
|
+
},
|
|
20
|
+
accept(ev, id) {
|
|
21
|
+
if (!id || !ev.uuid) return true;
|
|
22
|
+
const key = `${id}:${ev.uuid}`;
|
|
23
|
+
if (seen.has(key)) return false;
|
|
24
|
+
seen.add(key);
|
|
25
|
+
if (seen.size > 800) seen.delete(seen.values().next().value);
|
|
26
|
+
return true;
|
|
27
|
+
},
|
|
28
|
+
observe(ev) {
|
|
29
|
+
const agentId = identify(ev);
|
|
30
|
+
if (ev.type === 'assistant') {
|
|
31
|
+
for (const b of ev.message?.content ?? []) {
|
|
32
|
+
if (b.type === 'tool_use' && b.name === 'AskUserQuestion' && agentId) {
|
|
33
|
+
questions.set(b.id, agentId);
|
|
34
|
+
if (questions.size > 128) questions.delete(questions.keys().next().value);
|
|
35
|
+
report({ id: agentId, status: 'asked' });
|
|
36
|
+
}
|
|
37
|
+
if (b.type !== 'tool_use' || !['Agent', 'Task'].includes(b.name) || !b.id) continue;
|
|
38
|
+
const input = b.input ?? {};
|
|
39
|
+
const id = aliases.get(input.resume) ?? b.id;
|
|
40
|
+
calls.set(b.id, { id, input });
|
|
41
|
+
aliases.set(b.id, id);
|
|
42
|
+
if (calls.size > 128) calls.delete(calls.keys().next().value);
|
|
43
|
+
if (aliases.size > 256) aliases.delete(aliases.keys().next().value);
|
|
44
|
+
report({ id, kind: input.subagent_type ?? '', title: input.description ?? input.name ?? '',
|
|
45
|
+
brief: input.prompt ?? '', status: 'running', now: input.description ?? '' });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (ev.type === 'user') {
|
|
49
|
+
for (const b of ev.message?.content ?? []) {
|
|
50
|
+
if (questions.has(b.tool_use_id)) {
|
|
51
|
+
report({ id: questions.get(b.tool_use_id), status: 'running' });
|
|
52
|
+
questions.delete(b.tool_use_id);
|
|
53
|
+
}
|
|
54
|
+
if (b.type !== 'tool_result' || !calls.has(b.tool_use_id)) continue;
|
|
55
|
+
const id = calls.get(b.tool_use_id).id;
|
|
56
|
+
const result = ev.tool_use_result ?? {};
|
|
57
|
+
const nativeId = result.agentId ?? result.agent_id;
|
|
58
|
+
if (nativeId) aliases.set(nativeId, id);
|
|
59
|
+
// Background launch is not completion; its notification closes it.
|
|
60
|
+
const background = result.status === 'async_launched' || result.isAsync === true || calls.get(b.tool_use_id).input.run_in_background === true;
|
|
61
|
+
report({ id, ...(nativeId ? { nativeId } : {}), status: b.is_error ? 'stopped' : background ? 'running' : 'done',
|
|
62
|
+
...(typeof result.description === 'string' ? { now: result.description } : {}) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (ev.type === 'system' && ['task_started', 'task_progress', 'task_notification'].includes(ev.subtype)) {
|
|
66
|
+
// Other native background tasks (notably Bash) are not subagents.
|
|
67
|
+
const id = aliases.get(ev.tool_use_id) ?? aliases.get(ev.task_id);
|
|
68
|
+
if (id) {
|
|
69
|
+
if (ev.task_id) aliases.set(ev.task_id, id);
|
|
70
|
+
const status = ev.status === 'completed' ? 'done' : ['failed', 'stopped'].includes(ev.status) ? 'stopped' : 'running';
|
|
71
|
+
report({ id, status, ...(ev.description || ev.summary ? { now: ev.description ?? ev.summary } : {}) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return agentId;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/bin/lib/codexRelay.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* classifier. The sentence names what was measured and the one remedy the
|
|
21
21
|
* machine's own CLI offers; Codex's own words stay beside it.
|
|
22
22
|
*/
|
|
23
|
-
import { scrub as envScrub
|
|
23
|
+
import { scrub as envScrub } from './uplinkScrub.mjs';
|
|
24
24
|
import { CODEX_RUNTIME } from './runtimeCodex.mjs';
|
|
25
25
|
|
|
26
26
|
const NO_LOGIN = /unexpected status 401 Unauthorized: Missing bearer or basic authentication/;
|
|
@@ -55,25 +55,9 @@ export function codexApprovalSentence(refused) {
|
|
|
55
55
|
return `Codex on the machine refused ${n} tool call${n === 1 ? '' : 's'} in this turn${which} — "MCP tool call requires approval, but approval policy is never" — so nothing ${n === 1 ? 'it' : 'they'} would have done was done.`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* cuts there too before it sends — `withRelay` below and the plain reply in
|
|
64
|
-
* `workSessionTurns.mjs` — so nothing past it crosses the wire. It was a bare
|
|
65
|
-
* `16000` in both; the release gate (`scripts/check-app-parity.mjs`, rule 29)
|
|
66
|
-
* holds this copy equal to the shared one.
|
|
67
|
-
*/
|
|
68
|
-
export const SESSION_ANSWER_MAX = 16_000;
|
|
69
|
-
|
|
70
|
-
/** A reply with the sentence first and Codex's own words kept under it,
|
|
71
|
-
* scrubbed like every reply and cut to the lane's cap — the words scrubbed
|
|
72
|
-
* whole before they are trimmed, and the reply cut only after (`scrubCut`). */
|
|
73
|
-
export function withRelay(sentence, raw, cap = SESSION_ANSWER_MAX) {
|
|
74
|
-
const reply = (s) => {
|
|
75
|
-
const words = envScrub(s).trim();
|
|
76
|
-
return words ? `${sentence}\n\n${words}` : sentence;
|
|
77
|
-
};
|
|
78
|
-
return scrubCut(raw, cap, reply);
|
|
58
|
+
/** A refusal sentence with the CLI's complete scrubbed reply beneath it.
|
|
59
|
+
* No prose cap: a real transport/storage refusal must retain the reply. */
|
|
60
|
+
export function withRelay(sentence, raw) {
|
|
61
|
+
const words = envScrub(String(raw ?? '')).trim();
|
|
62
|
+
return words ? `${sentence}\n\n${words}` : sentence;
|
|
79
63
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const SUBAGENT_LIMIT = 32;
|
|
2
|
+
const THREAD_ID = /^[a-zA-Z0-9-]{16,64}$/;
|
|
3
|
+
const snake = s => String(s ?? '').replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
|
|
4
|
+
export const statusOf = (s) => ({ pending_init: 'waiting', running: 'running', completed: 'done',
|
|
5
|
+
interrupted: 'stopped', errored: 'stopped', shutdown: 'stopped', not_found: 'stopped',
|
|
6
|
+
waiting: 'waiting', asked: 'asked' })[snake(typeof s === 'string' ? s : s && Object.keys(s)[0])];
|
|
7
|
+
|
|
8
|
+
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;
|
|
10
|
+
if (['failed', 'errored'].includes(snake(item.status))) return { callId: item.id, success: false };
|
|
11
|
+
const nativeId = item.receiver_thread_ids?.[0];
|
|
12
|
+
return nativeId ? { callId: item.id, nativeId, success: true } : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function codexCollabUpdates(item) {
|
|
16
|
+
if (!['collab_tool_call', 'CollabAgentToolCall'].includes(item?.type ?? item?.item_type)) return [];
|
|
17
|
+
const tool = snake(item.tool);
|
|
18
|
+
const states = item.agents_states ?? {};
|
|
19
|
+
const ids = [...new Set([...(item.receiver_thread_ids ?? []), ...Object.keys(states)])].slice(0, SUBAGENT_LIMIT);
|
|
20
|
+
return ids.flatMap(id => {
|
|
21
|
+
if (typeof id !== 'string' || !THREAD_ID.test(id)) return [];
|
|
22
|
+
const state = states[id];
|
|
23
|
+
const receiver = item.receiver_agents?.find(a => a.thread_id === id);
|
|
24
|
+
const status = statusOf(state?.status ?? state);
|
|
25
|
+
const message = state?.message ?? (typeof state?.completed === 'string' ? state.completed : null);
|
|
26
|
+
return [{ id, ...(tool === 'spawn_agent' ? { ...(item.id ? { callId: item.id } : {}), ...(typeof item.prompt === 'string' ? { brief: item.prompt } : {}),
|
|
27
|
+
...(receiver?.agent_path || receiver?.agent_nickname || item.title ? { title: receiver?.agent_path ?? receiver?.agent_nickname ?? item.title } : {}),
|
|
28
|
+
...(receiver?.agent_role || item.agent_type ? { kind: receiver?.agent_role ?? item.agent_type } : {}),
|
|
29
|
+
} : {}),
|
|
30
|
+
...(status ? { status } : ['close_agent', 'interrupt_agent'].includes(tool) ? { status: 'stopped' } : {}),
|
|
31
|
+
...(typeof message === 'string' ? { now: message, message } : {}),
|
|
32
|
+
}];
|
|
33
|
+
});
|
|
34
|
+
}
|