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.
- package/README.md +2 -1
- package/bin/cli.mjs +5 -0
- package/bin/lib/captureAuthority.mjs +82 -0
- package/bin/lib/claudePosture.mjs +9 -0
- package/bin/lib/hooks/orchestrator.mjs +1 -1
- package/bin/lib/mcpStdio.mjs +11 -3
- package/bin/lib/nativeAgentTools.mjs +4 -1
- package/bin/lib/orchestrator.mjs +46 -20
- package/bin/lib/orchestratorClient.mjs +2 -2
- package/bin/lib/orchestratorPrompt.mjs +70 -0
- package/bin/lib/readOnlyRun.mjs +172 -0
- package/bin/lib/runTurn.mjs +1 -0
- package/bin/lib/runtimeClaude.mjs +6 -5
- package/bin/lib/runtimeCodex.mjs +4 -4
- package/bin/lib/turnProfile.mjs +1 -0
- package/bin/lib/workAgentPlans.mjs +69 -43
- package/bin/lib/workCappedTurn.mjs +4 -0
- package/bin/lib/workNarration.mjs +3 -2
- package/bin/lib/workPlanRecovery.mjs +2 -21
- package/bin/lib/workSessionTurns.mjs +109 -22
- package/bin/lib/workToolLog.mjs +3 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -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
|
|
197
|
-
const
|
|
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
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -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
|
package/bin/lib/turnProfile.mjs
CHANGED
|
@@ -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 {
|
|
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)
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// than through a tool, which
|
|
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
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|