flowviant 0.125.13 → 0.125.15

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.
@@ -0,0 +1,172 @@
1
+ /** Native investigation coordination for a held read-only attempt. No card,
2
+ * board token, project config or personal MCP server enters this capability.
3
+ * Capture can reuse the same lifecycle with its own attempt identity. */
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { createOrchestrator } from './orchestrator.mjs';
9
+ import { claudeOrchestrationSafe } from './orchestratorHooks.mjs';
10
+ import { codexSubagentsSafe } from './runtimeCodex.mjs';
11
+ import { ORCHESTRATOR_SERVER } from './orchestratorProtocol.mjs';
12
+ import { SUBAGENT_CAP, SUBAGENT_FIELD_CAPS } from './subagentBounds.mjs';
13
+ import { runCappedTurn } from './workCappedTurn.mjs';
14
+ import { killGroup } from './processGroup.mjs';
15
+ import { scrub, scrubCut } from './uplinkScrub.mjs';
16
+ import { toml } from './codexToml.mjs';
17
+ import { heldToolArgv } from './heldTool.mjs';
18
+ import { fleetRequest } from './fleetPost.mjs';
19
+ import { DAEMON_INSTANCE } from './config.mjs';
20
+
21
+ /** Transport failure is unknown, never a fabricated Stop. Only a measured
22
+ * holder/attempt mismatch cancels a root and its read-only descendants. */
23
+ export async function watchReadOnlyAttempt(url, job, request = fleetRequest) {
24
+ const controller = new AbortController();
25
+ let busy = false, closed = false;
26
+ const check = async () => {
27
+ if (busy || closed) return;
28
+ busy = true;
29
+ try {
30
+ const r = await request(url, { body: { id: job.id, attempt: job.attempt, instance: DAEMON_INSTANCE }, timeoutMs: 15_000 });
31
+ if (!closed && r.ok && r.json?.data?.active === false) controller.abort();
32
+ } catch { /* an unanswered holder read is unknown */
33
+ } finally { busy = false; }
34
+ };
35
+ await check();
36
+ const timer = setInterval(check, 5_000);
37
+ timer.unref?.();
38
+ return { signal: controller.signal, check, stop: () => { closed = true; clearInterval(timer); } };
39
+ }
40
+
41
+ export const readOnlyCoordinationSafe = (runtime, version) => runtime === 'claude'
42
+ ? claudeOrchestrationSafe(version) : runtime === 'codex' && codexSubagentsSafe(version);
43
+
44
+ function coordinationMount(runtime, controller, rootTools) {
45
+ const servers = { [ORCHESTRATOR_SERVER]: controller.config,
46
+ ...(rootTools ? { flowviant: { ...controller.config, args: heldToolArgv('__capture-mcp') } } : {}) };
47
+ if (runtime === 'codex') {
48
+ return { args: Object.entries(servers).flatMap(([name, config]) => {
49
+ const key = `mcp_servers.${name}`;
50
+ return ['-c', `${key}.command=${toml(config.command)}`,
51
+ '-c', `${key}.args=${JSON.stringify(config.args)}`,
52
+ '-c', `${key}.env_vars=${JSON.stringify(Object.keys(controller.env))}`,
53
+ '-c', `${key}.default_tools_approval_mode="approve"`];
54
+ }), cleanup() {} };
55
+ }
56
+ const dir = mkdtempSync(join(tmpdir(), 'flowviant-read-coordination-'));
57
+ try {
58
+ const path = join(dir, 'mcp.json');
59
+ writeFileSync(path, JSON.stringify({ mcpServers: servers }), { mode: 0o600 });
60
+ return { args: ['--strict-mcp-config', '--mcp-config', path], cleanup: () => rmSync(dir, { recursive: true, force: true }) };
61
+ } catch (error) { rmSync(dir, { recursive: true, force: true }); throw error; }
62
+ }
63
+
64
+ /** The root is counted by runCappedTurn's workChildren registration; native
65
+ * children retain reservations in the SAME admit pool until measured stopped.
66
+ * Each invocation mints a fresh capability, including diagnosis → retry.
67
+ * Late callbacks cannot repaint a replacement consult's activity/evidence. */
68
+ export async function createReadOnlyCoordination({ enabled = false, identity, runtime, version, admit, children = {}, signal,
69
+ contract = '', createController = createOrchestrator, onUnavailable = () => {}, rootTools = null }) {
70
+ if (!enabled || signal?.aborted || !readOnlyCoordinationSafe(runtime, version)) return null;
71
+ if (!identity || !['plan', 'capture'].includes(identity.scope) || !identity.id ||
72
+ !Number.isSafeInteger(identity.attempt) || identity.attempt < 0) {
73
+ onUnavailable(new Error('Read-only coordination needs a held attempt identity.'));
74
+ return null;
75
+ }
76
+ let controller = null, mount = null, active = true;
77
+ let root = null, killed = false;
78
+ const kill = child => { if (killed || !child) return; killed = true; killGroup(child); };
79
+ const agents = new Map();
80
+ const nonce = randomUUID();
81
+ const label = native => {
82
+ const id = controller?.resolve(native) ?? native;
83
+ const a = agents.get(id);
84
+ return scrubCut(a?.title || a?.kind || id, 120);
85
+ };
86
+ const trace = {
87
+ agent(update) {
88
+ if (!active || signal?.aborted || !agents.has(update.id) && agents.size >= SUBAGENT_CAP) return;
89
+ const prior = agents.get(update.id) ?? {};
90
+ const row = { ...prior, id: update.id };
91
+ for (const [key, cap] of Object.entries(SUBAGENT_FIELD_CAPS)) if (typeof update[key] === 'string') row[key] = scrubCut(update[key], cap);
92
+ if (update.status) row.status = update.status;
93
+ agents.set(update.id, row);
94
+ },
95
+ // Questions and native choice notes remain inside the CLI coordination;
96
+ // only observed CLI activity reaches the existing progress line below.
97
+ prose() {}, handoff() {}, flush: async () => {},
98
+ };
99
+ try {
100
+ controller = await createController({ runtime, readOnlyRun: identity, signal, trace, admit, rootTools,
101
+ children: { ...children, access: 'read' } });
102
+ mount = coordinationMount(runtime, controller, rootTools);
103
+ } catch (error) {
104
+ await controller?.close(true); rootTools?.close();
105
+ onUnavailable(error);
106
+ return null;
107
+ }
108
+ return {
109
+ kill,
110
+ wrap(turnArgs) {
111
+ if (turnArgs.profile !== (rootTools ? 'plan' : 'consult') || turnArgs.mcpConfig || turnArgs.mcpArgs?.length ||
112
+ turnArgs.projectMcpServers?.length || turnArgs.machineMcp || turnArgs.mcpEnv?.FLOWVIANT_MCP_TOKEN)
113
+ throw new TypeError('Read-only coordination requires a fenced turn without external MCP credentials.');
114
+ return { ...turnArgs, orchestration: true, readOnlyCoordination: true,
115
+ system: [turnArgs.system, contract].filter(Boolean).join('\n\n'),
116
+ ...(runtime === 'claude' ? { claudeVersion: version } : { codexVersion: version }),
117
+ mcpArgs: mount.args, mcpEnv: controller.env,
118
+ onSpawn: child => { root = child; turnArgs.onSpawn?.(child); },
119
+ onInit: init => { if (active && !signal?.aborted) { controller.root(init.sessionId); turnArgs.onInit?.(init); } },
120
+ onThreadId: id => { if (active && !signal?.aborted) { controller.root(id); turnArgs.onThreadId?.(id); } },
121
+ ...Object.fromEntries(['onAnswer', 'onUsage', 'onModel', 'onContext', 'onCliFailure', 'onExit'].map(key =>
122
+ [key, (...args) => { if (active && !signal?.aborted) turnArgs[key]?.(...args); }])),
123
+ onSubagent: update => { if (active && !signal?.aborted) controller.observe(update); },
124
+ onSpawnResult: result => { if (active && !signal?.aborted) controller.spawnResult(result); },
125
+ onActivity: activity => {
126
+ if (!active || signal?.aborted) return;
127
+ if (!activity?.agentId) { turnArgs.onActivity?.(activity); return; }
128
+ controller.attributed(activity.agentId, id => {
129
+ if (active && !signal?.aborted && activity.label) turnArgs.onActivity?.({ ...activity, label: `${label(id)}: ${activity.label}` });
130
+ });
131
+ },
132
+ onToolEvent: (name, input, meta = {}) => {
133
+ if (!active || signal?.aborted) return;
134
+ const push = id => turnArgs.onToolEvent?.(name, input, { ...meta,
135
+ // Runtime call ids can repeat across children and fresh consults.
136
+ // A citation in final prose never creates a reader observation.
137
+ ...(meta.id ? { id: createHash('sha256').update(`${nonce}\0${id ?? 'root'}\0${meta.id}`).digest('hex') } : {}),
138
+ ...(id ? { agentId: id, agentTitle: label(id) } : {}),
139
+ });
140
+ if (meta.agentId) controller.attributed(meta.agentId, push); else push(null);
141
+ },
142
+ };
143
+ },
144
+ async close(stopped = false) {
145
+ active = false; kill(root);
146
+ try { await controller.close(stopped || Boolean(signal?.aborted)); }
147
+ finally { mount.cleanup(); }
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Scratch planning retains its capped, transcript-removing lifecycle.
153
+ * Capture consumes the same coordination factory around its own resumable
154
+ * conversation lifecycle and keeps its transcript. */
155
+ export async function runReadOnlyConsult({ ...options }) {
156
+ const { turnArgs } = options;
157
+ if (turnArgs.profile !== 'consult' || turnArgs.mcpConfig || turnArgs.mcpArgs?.length ||
158
+ turnArgs.projectMcpServers?.length || turnArgs.machineMcp || turnArgs.mcpEnv?.FLOWVIANT_MCP_TOKEN) {
159
+ options.releaseSlot?.();
160
+ throw new TypeError('Read-only coordination requires a consult without board, staging or external MCP credentials.');
161
+ }
162
+ const coordination = await createReadOnlyCoordination({ ...options, runtime: turnArgs.runtime });
163
+ if (!coordination) return runCappedTurn(options);
164
+ try {
165
+ // The cap composes onSpawn/onInit itself; adapt its wrapper callbacks
166
+ // beside turnArgs so transcript and root-count bookkeeping stay there.
167
+ const wrapped = coordination.wrap({ ...turnArgs, onSpawn: options.onSpawn, onInit: options.onInit });
168
+ const { onSpawn, onInit, ...args } = wrapped;
169
+ const result = await runCappedTurn({ ...options, turnArgs: args, onSpawn, onInit, killChild: coordination.kill });
170
+ return { ...result, out: scrub(result.out) };
171
+ } finally { await coordination.close(); }
172
+ }
@@ -197,6 +197,7 @@ function runPlainTurn(opts) {
197
197
  const args = rt.args({
198
198
  cardTurn: opts.cardTurn === true,
199
199
  orchestration: opts.orchestration === true,
200
+ readOnlyCoordination: opts.readOnlyCoordination === true,
200
201
  // The lane's own measurement when it decided to orchestrate, so the
201
202
  // hooks and the controller cannot disagree (workAgentTurnExecution.mjs).
202
203
  codexVersion: opts.codexVersion ?? measuredRuntimeVersion('codex')?.version,
@@ -16,9 +16,9 @@ import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
17
  import { NO_SETTING_SOURCES, personalClaudeSettings, releaseTurnSettings, withPersonalSettings, writeTurnSettings } from './claudePersonal.mjs';
18
18
  import { machineMcpServers } from './claudeMachineMcp.mjs';
19
- import { claudePermFor, withMachineMcp, withNativeChildren } from './claudePosture.mjs';
19
+ import { claudePermFor, withMachineMcp, withNativeChildren, withReadOnlyCoordination } from './claudePosture.mjs';
20
20
  import { withSettings } from './claudeSettingsArg.mjs';
21
- import { orchestratorHooks } from './orchestratorHooks.mjs';
21
+ import { orchestratorHooks, claudeOrchestrationSafe } from './orchestratorHooks.mjs';
22
22
  import { orchestratesPosture } from './orchestratorPrompt.mjs';
23
23
  import { versionAtLeast } from './versionOrder.mjs';
24
24
 
@@ -123,7 +123,7 @@ export const CLAUDE_RUNTIME = {
123
123
  * strongest form of it available anywhere: `--append-system-prompt` sits
124
124
  * above the conversation rather than inside it.
125
125
  */
126
- args({ writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, claudeVersion = null }) {
126
+ args({ writableDirs = [], prompt, system, model, effort, resume, perm: postureList, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, baseMcp = [], mcpCheckout = null, streamInput = false, orchestration = false, readOnlyCoordination = false, claudeVersion = null }) {
127
127
  // THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
128
128
  // is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
129
129
  // with no vault, or one no rule can name, has no list — refused here in
@@ -193,8 +193,9 @@ export const CLAUDE_RUNTIME = {
193
193
  const fenced = profile !== 'build';
194
194
  // THE GATE RIDES A CODE OR RESEARCH CARD (2026-10-19, orchestratorPrompt.mjs);
195
195
  // a research card's list then admits the children its fence denied.
196
- const gated = orchestration && orchestratesPosture(profile);
197
- const perm = gated && fenced ? withNativeChildren(postureList) : postureList;
196
+ const readCoordinator = orchestration && readOnlyCoordination && ['consult', 'plan'].includes(profile) && claudeOrchestrationSafe(claudeVersion);
197
+ const gated = readCoordinator || orchestration && orchestratesPosture(profile);
198
+ const perm = readCoordinator ? withReadOnlyCoordination(postureList) : gated && fenced ? withNativeChildren(postureList) : postureList;
198
199
  if (fenced) a.push(...NO_SETTING_SOURCES);
199
200
  // A FENCED POSTURE NAMES ITS OWN MODE (2026-10-10). Its lists fence only
200
201
  // under the CLI's `default` mode, where a tool the list does not name is
@@ -281,7 +281,7 @@ export const CODEX_RUNTIME = {
281
281
  * limit even when its argv plus environment is far below ARG_MAX. Both exec
282
282
  * and exec resume support `-` (verified on Codex 0.160.0, 2026-10-16).
283
283
  */
284
- args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, codexVersion, orchestration = false }) {
284
+ args({ streamInput = false, writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads, cardTurn = false, codexVersion, orchestration = false, readOnlyCoordination = false }) {
285
285
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
286
286
  // agy moves) — codex has no adoptable store wired yet. Reaching here
287
287
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -314,7 +314,7 @@ export const CODEX_RUNTIME = {
314
314
  const fenced = codexIgnoresUserConfig({ profile, vaultDir });
315
315
  // THE GATE RIDES A CODE OR RESEARCH CARD on a CLI whose children inherit
316
316
  // the parent's sandbox (2026-10-19, orchestratorPrompt.mjs).
317
- const gated = orchestration && orchestratesPosture(fenced ? profile : 'build') && codexSubagentsSafe(codexVersion);
317
+ const gated = orchestration && (orchestratesPosture(fenced ? profile : 'build') || readOnlyCoordination && ['consult', 'plan'].includes(profile)) && codexSubagentsSafe(codexVersion);
318
318
  if (fenced) {
319
319
  a.push(...CODEX_FENCES[profile]({ cwd, vaultDir, researchIsolation, knowledgeDir, projectReads, subagents: gated, codexVersion }));
320
320
  } else {
@@ -466,7 +466,7 @@ const CODEX_FENCES = {
466
466
  // A CONSULT, EXPRESSED THE ONLY WAY CODEX CAN EXPRESS IT — a different
467
467
  // shape from Claude's verb allowlist, which is the whole reason `profile` is
468
468
  // a promise rather than a flag list. See codexProjectRead.mjs.
469
- consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ codexVersion: opts.codexVersion })],
469
+ consult: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ subagents: opts.subagents, codexVersion: opts.codexVersion })],
470
470
  // A PLANNING SESSION. Read-only on the filesystem, exactly like a consult —
471
471
  // the writes it makes go through the control plane, not through this box —
472
472
  // and for the same reason: its prompt is steered by anything a project
@@ -475,7 +475,7 @@ const CODEX_FENCES = {
475
475
  // turn's scoped token. The plan token has the planning tools; a capture
476
476
  // token has only staging and transcript tools. The server refuses
477
477
  // everything outside that scope.
478
- plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ codexVersion: opts.codexVersion })],
478
+ plan: (opts) => [...projectReadFence(opts.projectReads ?? projectReadBoundary(opts)), ...fencedTail({ subagents: opts.subagents, codexVersion: opts.codexVersion })],
479
479
  wiki: ({ vaultDir, codexVersion }) => codexWikiFence(vaultDir, codexVersion),
480
480
  // AN IMAGE CARD (0.114.0): read the repo, write ONLY the worktree's
481
481
  // `.flowviant/artifacts/`, generate pictures, and nothing else. MEASURED on
@@ -105,5 +105,6 @@ export function resolveTurnProfile(opts = {}) {
105
105
  if (typeof name !== 'string' || !Object.hasOwn(TURN_PROFILES, name)) {
106
106
  return { error: `unknown turn profile '${String(name).slice(0, 40)}'` };
107
107
  }
108
+ if (opts.readOnlyCoordination === true && !['consult', 'plan'].includes(name)) return { error: 'read-only coordination requires a consult or capture profile' };
108
109
  return { name, ...TURN_PROFILES[name] };
109
110
  }
@@ -6,7 +6,7 @@ import { discoveryGuide } from './discoveryContract.mjs';
6
6
  import { compareRepositoryBaselines, measureRepositoryBaseline, repositoryBaselineOf } from './repositoryBaseline.mjs';
7
7
  import { knowledgeDirFor } from './knowledgeFiles.mjs';
8
8
  import { createToolLog } from './workToolLog.mjs';
9
- import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
9
+ import { FLEET_URL, DAEMON_INSTANCE, MODEL } from './config.mjs';
10
10
  import { measureAgentFootprint, liveAgentContext } from './agentFootprint.mjs';
11
11
  import { parseProposal } from './agentPlan.mjs';
12
12
  import { c, note } from './ui.mjs';
@@ -14,9 +14,11 @@ import { runTurn as defaultRunTurn } from './runTurn.mjs';
14
14
  import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF, withProjectContext, REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
15
15
  import { scrub as envScrub } from './uplinkScrub.mjs';
16
16
  import { canRun, runtimeById, runtimeLabel } from './runtimes.mjs';
17
- import { detectRuntimes as defaultDetectRuntimes, pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
17
+ import { detectRuntimes as defaultDetectRuntimes, pickRuntimeFor as defaultPickRuntimeFor, measuredRuntimeVersion } from './runtimeDetection.mjs';
18
18
  import { brainFor } from './workBrain.mjs';
19
- import { runCappedTurn } from './workCappedTurn.mjs';
19
+ import { runReadOnlyConsult } from './readOnlyRun.mjs';
20
+ import { planningOrchestratorContract } from './orchestratorPrompt.mjs';
21
+ import { knownCodexModels } from './runtimeOfferings.mjs';
20
22
  import { fleetEndpoint } from './fleetWire.mjs';
21
23
  import { claimFleetLease, fleetHeaders } from './fleetPost.mjs';
22
24
  import { REPO_PLACE } from './workPlaces.mjs';
@@ -37,6 +39,8 @@ export function createWorkAgentPlans({
37
39
  pickRuntimeFor = defaultPickRuntimeFor,
38
40
  detectRuntimes = defaultDetectRuntimes,
39
41
  authStatus = readClaudeAuthStatus,
42
+ runtimeVersion = id => measuredRuntimeVersion(id)?.version,
43
+ runConsult = runReadOnlyConsult,
40
44
  }) {
41
45
  const AGENT_PLAN_DONE_URL = fleetEndpoint('agent-plan-done', FLEET_URL);
42
46
  const AGENT_PLAN_ACTIVITY_URL = fleetEndpoint('agent-plan-activity', FLEET_URL);
@@ -55,10 +59,10 @@ export function createWorkAgentPlans({
55
59
  // the CLI path-validates itself, and the `git` readers, which it does not —
56
60
  // their path fence is the read guard's refusal (hooks/readGuard.mjs), of
57
61
  // `--no-index` and of any git argument aimed off the checkout — and no
58
- // Write, no Edit, no mkdir, no rm) and NO MCP is passed at all, so this
59
- // turn has no control plane to reach even if the repository it reads tries
60
- // to steer it. The proposal comes back as the turn's final message rather
61
- // than through a tool, which is exactly what lets that permission set be
62
+ // Write, no Edit, no mkdir, no rm). Compatible held attempts may receive
63
+ // only a local read-only coordination capability. No board/staging token,
64
+ // project/personal server or extra network access is supplied. The proposal
65
+ // comes back as the turn's final message rather than through a tool, which lets that permission set be
62
66
  // this narrow.
63
67
  //
64
68
  // It takes the checkout's place lock as a READER, beside the operator's own
@@ -287,49 +291,71 @@ export function createWorkAgentPlans({
287
291
  discovery.toolLog.baseline = measureRepositoryBaseline({ cwd: repoRoot, baseRef, knowledgeDir });
288
292
  discovery.toolLog.captureBaseline = repositoryBaselineOf(job.captureBaseline);
289
293
  discovery.toolLog.reconciliation = compareRepositoryBaselines(discovery.toolLog.baseline, discovery.toolLog.captureBaseline);
290
- const consult = async (prompt, system) => await runCappedTurn({
291
- runTurn,
292
- signal: control?.signal,
293
- capMs: PLAN_TURN_TIMEOUT_MS,
294
- workChildren,
295
- releaseSlot,
296
- transcriptCwd: repoRoot,
297
- turnArgs: {
298
- diagnosticPlanId: id,
299
- prompt,
300
- system,
301
- knowledgeDir,
302
- // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
303
- // passed empty, so there is no control plane on this turn at all.
304
- profile: 'consult',
305
- cwd: repoRoot,
306
- runtime: rt,
307
- ...brainFor(job, rt),
308
- answerFromResult: true,
309
- work: planWork(job),
310
- // The CLI's own tail, forwarded to the press. Same rule as an agent
311
- // turn's: throttled, overwritten rather than appended, and never
312
- // awaited by the turn.
313
- onActivity: (a) => {
314
- if (a?.label) narrate(String(a.label));
294
+ const brain = brainFor(job, rt);
295
+ let consults = 0;
296
+ const consult = async (prompt, system, diagnosing = false) => {
297
+ let rootSlot = releaseSlot;
298
+ if (consults++ > 0) {
299
+ // Diagnosis ended its CLI and released its children. A fresh
300
+ // paid consult must ask the pool again, like every other root.
301
+ while (admit('churn')) {
302
+ if (control?.signal.aborted) return { out: '', wedged: false };
303
+ await new Promise(resolve => setTimeout(resolve, 1_000));
304
+ }
305
+ rootSlot = admit.reserve();
306
+ }
307
+ return await runConsult({
308
+ enabled: job.readOnlyOrchestrationVersion === 1 && job.recoveryEnabled === true,
309
+ identity: { scope: 'plan', id, attempt: job.attempt },
310
+ version: runtimeVersion(rt),
311
+ admit,
312
+ children: { ...brain, model: brain.model ?? (rt === 'claude' ? MODEL : null),
313
+ ...(rt === 'codex' ? { catalog: knownCodexModels() } : {}) },
314
+ contract: planningOrchestratorContract({ diagnosis: diagnosing, runtime: rt, ...brain, model: brain.model ?? (rt === 'claude' ? MODEL : null) }),
315
+ onUnavailable: error => say(`Native investigation coordination unavailable: ${error.message}`),
316
+ runTurn,
317
+ signal: control?.signal,
318
+ capMs: PLAN_TURN_TIMEOUT_MS,
319
+ workChildren,
320
+ releaseSlot: rootSlot,
321
+ transcriptCwd: repoRoot,
322
+ turnArgs: {
323
+ diagnosticPlanId: id,
324
+ prompt,
325
+ system,
326
+ knowledgeDir,
327
+ // The consult fence stays intact. runReadOnlyConsult may mount
328
+ // only the attempt's local coordination tool, never a board token.
329
+ profile: 'consult',
330
+ cwd: repoRoot,
331
+ runtime: rt,
332
+ ...brain,
333
+ answerFromResult: true,
334
+ work: planWork(job),
335
+ // The CLI's own tail, forwarded to the press. Same rule as an agent
336
+ // turn's: throttled, overwritten rather than appended, and never
337
+ // awaited by the turn.
338
+ onActivity: (a) => {
339
+ if (a?.label) narrate(String(a.label));
340
+ },
341
+ onToolEvent: discovery.pushToolEvent,
342
+ // Tagged with the CLI that counted it; one turn, so the sum is the
343
+ // one report (turnUsage.mjs keeps it null-safe).
344
+ onUsage: (u) => {
345
+ usage = addUsage(usage, { ...u, runtime: rt });
346
+ },
315
347
  },
316
- onToolEvent: discovery.pushToolEvent,
317
- // Tagged with the CLI that counted it; one turn, so the sum is the
318
- // one report (turnUsage.mjs keeps it null-safe).
319
- onUsage: (u) => {
320
- usage = addUsage(usage, { ...u, runtime: rt });
348
+ onSpawn: () => {
349
+ say(`${runtimeLabel(rt)} started on this machine`);
321
350
  },
322
- },
323
- onSpawn: () => {
324
- say(`${runtimeLabel(rt)} started on this machine`);
325
- },
326
- });
351
+ });
352
+ };
327
353
  let context = AGENT_PLAN_KICKOFF({ tasks, liveAgents, agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3 });
328
354
  if (job.recovery?.start) context += '\nSTART REPAIR: This is a supporting diagnosis for an already approved Start. Return exactly ONE group with the same ordered taskIds, attachment target (or absence) and CLI. Preserve the approval; do not start an unapproved sibling. Diagnose admission/pickup errors and repair planning evidence/context. Ask only a genuine person-only question in the diagnostic JSON. Approval and source revision (data): ' + JSON.stringify(job.recovery.start);
329
355
  const system = withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation);
330
356
  if (job.recovery) {
331
357
  say(`Diagnosing the failed Plan: ${job.recovery.reason}`);
332
- const diagnostic = await consult(context + '\nRetained recovery context (data): ' + JSON.stringify(job.recovery), withProjectContext(PLAN_RECOVERY_SYSTEM, { knowledgeDir }) + '\n' + discoveryGuide(rt, 'planning'));
358
+ const diagnostic = await consult(context + '\nRetained recovery context (data): ' + JSON.stringify(job.recovery), withProjectContext(PLAN_RECOVERY_SYSTEM, { knowledgeDir }) + '\n' + discoveryGuide(rt, 'planning'), true);
333
359
  if (control?.signal.aborted) return;
334
360
  if (diagnostic.wedged) { wedged = true; return; }
335
361
  const result = parsePlanDiagnosis(diagnostic.out);
@@ -73,6 +73,7 @@ export async function runCappedTurn({
73
73
  let timer = null;
74
74
  let wedged = false;
75
75
  let sessionId = null;
76
+ let finished = false;
76
77
  let stopWaiting = () => {};
77
78
  const capped = new Promise((resolve) => {
78
79
  stopWaiting = resolve;
@@ -88,10 +89,12 @@ export async function runCappedTurn({
88
89
  const turn = runTurn({
89
90
  ...turnArgs,
90
91
  onInit: (i) => {
92
+ if (finished) return;
91
93
  if (typeof i?.sessionId === 'string' && i.sessionId.trim()) sessionId = i.sessionId.trim();
92
94
  onInit(i);
93
95
  },
94
96
  onSpawn: (ch) => {
97
+ if (finished) { try { if (killChild) killChild(ch); else ch?.kill('SIGKILL'); } catch { /* already gone */ } return; }
95
98
  child = ch;
96
99
  workChildren.set(ch, null);
97
100
  releaseSlot();
@@ -112,6 +115,7 @@ export async function runCappedTurn({
112
115
  const out = await Promise.race([turn, capped]);
113
116
  return { out, wedged, spawned: child !== null, sessionId };
114
117
  } finally {
118
+ finished = true;
115
119
  signal?.removeEventListener('abort', cancel);
116
120
  if (timer) clearTimeout(timer);
117
121
  if (child) workChildren.delete(child);
@@ -35,7 +35,7 @@ export function createWorkNarration() {
35
35
  * line over the finished reply — the server drops narration for a turn
36
36
  * that is no longer pending. (A session-level pending count can't tell the
37
37
  * settled turn's stale line from the queued NEXT turn's fresh one.) */
38
- const makeNarrator = (sessionId, turnId, getTools) => {
38
+ const makeNarrator = (sessionId, turnId, getTools, identity = {}) => {
39
39
  const recent = [];
40
40
  let lastSent = 0;
41
41
  let dirty = false;
@@ -60,6 +60,7 @@ export function createWorkNarration() {
60
60
  body: JSON.stringify({
61
61
  sessionId,
62
62
  turnId,
63
+ ...identity,
63
64
  lines,
64
65
  // The structured tool log so far, riding the same throttled beat.
65
66
  // Same lifecycle as the lines: overwritten as the turn moves,
@@ -101,7 +102,7 @@ export function createWorkNarration() {
101
102
  'Content-Type': 'application/json',
102
103
  },
103
104
  signal: AbortSignal.timeout(10_000),
104
- body: JSON.stringify({ sessionId, turnId, lines: [], context }),
105
+ body: JSON.stringify({ sessionId, turnId, ...identity, lines: [], context }),
105
106
  });
106
107
  } catch {
107
108
  /* a readout is decoration, like the line */
@@ -1,14 +1,13 @@
1
1
  /** The person's own CLI diagnoses planning, under the ordinary consult fence. */
2
2
  import { candidateObjects } from './modelJson.mjs';
3
3
  import { scrub } from './uplinkScrub.mjs';
4
- import { fleetRequest } from './fleetPost.mjs';
5
- import { DAEMON_INSTANCE } from './config.mjs';
4
+ export { watchReadOnlyAttempt as watchPlanAttempt } from './readOnlyRun.mjs';
6
5
 
7
6
  export const PLAN_RECOVERY_SYSTEM = `You are recovering the person's retained Plan press.
8
7
  Read the original selection, failure, retained evidence, answers and repository.
9
8
  Establish the cause before claiming a repair. Diagnose/debug the planning failure
10
9
  with the available read-only tools. Repair the planning approach, context or output
11
- instructions. Do not change repository files, start agents or approve work.
10
+ instructions. Do not change repository files, start Board work or approve work.
12
11
  Retry count is never a reason to ask the person to click Plan again.
13
12
  Only ask for an answer, credential action or changed-scope decision that reading
14
13
  and diagnosis cannot supply. Ask one specific question and explain what it settles.
@@ -30,21 +29,3 @@ export function parsePlanDiagnosis(out) {
30
29
  }
31
30
  return null;
32
31
  }
33
-
34
- /** Unknown transport state never means Stop. Only an answered holder read does. */
35
- export async function watchPlanAttempt(url, job) {
36
- const controller = new AbortController();
37
- let busy = false;
38
- const check = async () => {
39
- if (busy) return;
40
- busy = true;
41
- try {
42
- const r = await fleetRequest(url, { body: { id: job.id, attempt: job.attempt, instance: DAEMON_INSTANCE }, timeoutMs: 15_000 });
43
- if (r.ok && r.json?.data?.active === false) controller.abort();
44
- } finally { busy = false; }
45
- };
46
- await check();
47
- const timer = setInterval(check, 5_000);
48
- timer.unref?.();
49
- return { signal: controller.signal, check, stop: () => clearInterval(timer) };
50
- }