flowviant 0.125.13 → 0.125.16

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.
@@ -120,6 +120,19 @@ export function checkEnv(env = process.env, browserHome = null) {
120
120
  * child whose 'close' never comes, which a real process only shows with a
121
121
  * survivor outside its group holding the pipe.
122
122
  */
123
+ /** The repository's declared check, `.flowviant/check.json`'s `command` —
124
+ * read once here for the check and the batch merge's coordinator alike. */
125
+ export function readConfiguredCheck(root) {
126
+ try {
127
+ const raw = readFileSync(join(root, '.flowviant', 'check.json'), 'utf8');
128
+ const cfg = JSON.parse(raw);
129
+ const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
130
+ return cmd ? cmd.slice(0, 500) : null;
131
+ } catch {
132
+ return null; // absent, unreadable or not JSON — all mean "no check"
133
+ }
134
+ }
135
+
123
136
  export function createWorkAgentCheck({
124
137
  repoRoot,
125
138
  postBestEffort,
@@ -150,16 +163,7 @@ export function createWorkAgentCheck({
150
163
  const CHECK_TIMEOUT_MS = 10 * 60_000;
151
164
  const CHECK_OUTPUT_CAP = 4000;
152
165
 
153
- const readCheckCommand = (root = repoRoot) => {
154
- try {
155
- const raw = readFileSync(join(root, '.flowviant', 'check.json'), 'utf8');
156
- const cfg = JSON.parse(raw);
157
- const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
158
- return cmd ? cmd.slice(0, 500) : null;
159
- } catch {
160
- return null; // absent, unreadable or not JSON — all mean "no check"
161
- }
162
- };
166
+ const readCheckCommand = (root = repoRoot) => readConfiguredCheck(root);
163
167
 
164
168
  const postCheck = async (body) => {
165
169
  // Same bounded retry as postAgentPlan — see postBestEffort. On the
@@ -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);
@@ -754,7 +754,8 @@ export function createAgentTurnExecution({
754
754
  throw error;
755
755
  }
756
756
  }
757
- await artifacts.report({ placeDir: wt, before: intermediateDelivery?.uploadBefore ?? artifactsBefore, agentId, turnId, requireStored: kind.artifact != null });
757
+ await artifacts.report({ placeDir: wt, before: intermediateDelivery?.uploadBefore ?? artifactsBefore, agentId, turnId,
758
+ requireStored: kind.artifact != null, requiredOutputs: intermediateDelivery?.requiredOutputs });
758
759
  }
759
760
 
760
761
 
@@ -784,7 +785,7 @@ export function createAgentTurnExecution({
784
785
  usage,
785
786
  model,
786
787
  commits,
787
- artifactsBefore,
788
+ artifactsBefore: intermediateDelivery?.uploadBefore ?? artifactsBefore,
788
789
  branch,
789
790
  wt,
790
791
  modelExports: intermediateDelivery?.modelExports,
@@ -139,7 +139,7 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
139
139
  const want = kind.artifact.match;
140
140
  const has = (l) => l.some((e) => want.test(e.name)
141
141
  && (kind.posture !== 'image' || usableImageArtifact(e)));
142
- const standing = scanArtifacts(wt);
142
+ const standing = scanArtifacts(wt, { all: true });
143
143
  const wrote = has(changedArtifacts(artifactsBefore, standing));
144
144
  const exported = kind.exportModel && measuredModelExports(wt, modelExports);
145
145
  const present = wrote || exported || ((job.kind !== 'task' || job.redo === true) && has(standing));
@@ -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);
@@ -18,6 +18,7 @@ import { scrub } from './uplinkScrub.mjs';
18
18
  import { relayedText } from './prWorkflow.mjs';
19
19
  import { isSafePathSegment, stripUrlUserinfo } from './git.mjs';
20
20
  import { killGroup } from './processGroup.mjs';
21
+ import { readConfiguredCheck } from './workAgentCheck.mjs';
21
22
 
22
23
  export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPlace, runCheck, workChildren, groupKillChildren, admit, onRepoChanged, landed, runCli = runTurn }) {
23
24
  const request = async (route, body) => {
@@ -48,6 +49,7 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
48
49
  integrated = await integrateMergeBatch({ spec, progress: claim.progress ?? job.batch.progress, previousError: claim.previousError ?? job.batch.previousError, baseDir,
49
50
  rootFor: (id) => repositoryPlaces?.rootFor?.(id) ?? (spec.repositories.length === 1 ? repoRoot : null),
50
51
  setup: trackedRepositorySetup(workChildren, groupKillChildren),
52
+ checkCommand: (r) => readConfiguredCheck(r.wt),
51
53
  entries: (member) => {
52
54
  const rows = repositoryPlaces?.entries(member.placeId) ?? [];
53
55
  if (rows.length) return rows;
@@ -81,6 +83,8 @@ export function createWorkMergeBatch({ repositoryPlaces, repoRoot, baseDir, inPl
81
83
  // Run configured checks too; a coordinator cannot replace them with a weaker command.
82
84
  const configured = await runCheck(leader.agentId, r.wt, r);
83
85
  if (!['passed', 'none'].includes(configured?.status)) throw new Error(`${r.name}: the configured combined check failed or could not run.\n${configured?.text || ''}`);
86
+ // The same command twice is one validation (2026-10-22).
87
+ if (r.command.trim() === readConfiguredCheck(r.wt)) return;
84
88
  const measured = await runCheck(leader.agentId, r.wt, r, r.command);
85
89
  if (measured?.status !== 'passed') throw new Error(`${r.name}: combined validation failed. Retry the batch to resolve it.\n${measured?.text || ''}`);
86
90
  },
@@ -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
- }
@@ -46,6 +46,15 @@ import { sessionTurnKickoff, sessionTurnShape, sessionTurnSystem } from './workT
46
46
  import { createToolLog } from './workToolLog.mjs';
47
47
  import { createCommandAudit } from './workCommandAudit.mjs';
48
48
  import { APPROVAL_REFUSED, codexApprovalSentence, codexLoginFailure, codexLoginSentence, withRelay } from './codexRelay.mjs';
49
+ import { createReadOnlyCoordination, readOnlyCoordinationSafe, watchReadOnlyAttempt } from './readOnlyRun.mjs';
50
+ import { createCaptureAuthority } from './captureAuthority.mjs';
51
+ import { captureOrchestratorContract } from './orchestratorPrompt.mjs';
52
+ import { runCappedTurn } from './workCappedTurn.mjs';
53
+ import { measuredRuntimeVersion } from './runtimeDetection.mjs';
54
+ import { knownCodexModels } from './runtimeOfferings.mjs';
55
+ import { FLEET_URL, DAEMON_INSTANCE, MODEL } from './config.mjs';
56
+ import { fleetEndpoint } from './fleetWire.mjs';
57
+ const defaultWatchCapture = job => watchReadOnlyAttempt(fleetEndpoint('capture-run-state', FLEET_URL), job);
49
58
 
50
59
  /**
51
60
  * HOW LONG, AND HOW MANY, SETTLED TURNS THE LANE REMEMBERS (2026-09-29) — see
@@ -91,6 +100,9 @@ export function createWorkSessionTurns({
91
100
  // The real CLI driver; injectable only so the lane can be driven in a test
92
101
  // without spending a model turn (the wiki runner's shape).
93
102
  runTurn = defaultRunTurn,
103
+ runtimeVersion = id => measuredRuntimeVersion(id)?.version,
104
+ coordinate = createReadOnlyCoordination,
105
+ watchCapture = defaultWatchCapture,
94
106
  // The clock `settledHere` ages by; injectable only for its bound's test.
95
107
  now = Date.now,
96
108
  makeLiveMessages = createLiveMessages,
@@ -140,14 +152,17 @@ export function createWorkSessionTurns({
140
152
  * be delivered by `pendingWorkReports`, and this covers the one that was.
141
153
  */
142
154
  const settledHere = new Map(); // turnId -> when this lane settled it, oldest first
155
+ const settledAttempts = new Map(); // only measured execution generations
143
156
  const forgetStale = (at) => {
144
157
  for (const [id, when] of settledHere) {
145
158
  if (settledHere.size <= SETTLED_HERE_MAX && at - when < SETTLED_HERE_TTL_MS) break;
146
159
  settledHere.delete(id);
160
+ settledAttempts.delete(id);
147
161
  }
148
162
  };
149
- const settledRecently = (turnId) => {
163
+ const settledRecently = (turnId, attempt) => {
150
164
  forgetStale(now());
165
+ if (Number.isInteger(attempt) && settledAttempts.has(turnId) && settledAttempts.get(turnId) !== attempt) return false;
151
166
  return settledHere.has(turnId);
152
167
  };
153
168
  const settleTurn = (turnId, payload) => {
@@ -156,6 +171,7 @@ export function createWorkSessionTurns({
156
171
  const at = now();
157
172
  settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
158
173
  settledHere.set(turnId, at);
174
+ if (Number.isInteger(run?.job?.attempt)) settledAttempts.set(turnId, run.job.attempt);
159
175
  forgetStale(at);
160
176
  const liveMessages = runs.get(turnId)?.live?.receipts();
161
177
  return settleWorkTurn(turnId, { ...payload, ...(liveMessages?.ids.length ? { liveMessages } : {}) });
@@ -185,13 +201,18 @@ export function createWorkSessionTurns({
185
201
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
186
202
  if (rosterInterrupts.has(job.id)) continue;
187
203
  if (workAnswering.has(job.id)) {
188
- const reason = runs.get(job.id)?.waitingReason;
204
+ const previous = runs.get(job.id);
205
+ if (job.captureOrchestrationVersion === 1 && Number.isInteger(job.attempt) && Number.isInteger(previous?.job.attempt) && job.attempt !== previous.job.attempt) {
206
+ previous.interrupted = true; previous.abort.abort();
207
+ for (const child of previous.spawned) interruptThenKill(child);
208
+ }
209
+ const reason = previous?.waitingReason;
189
210
  if (reason) reportDelivery('session', job, 'queued', reason);
190
211
  continue;
191
212
  }
192
213
  // Settled by this lane already: a roster read from before the settle
193
214
  // landed. See `settledHere`.
194
- if (settledRecently(job.id)) continue;
215
+ if (settledRecently(job.id, job.attempt)) continue;
195
216
  // The turn already RAN and its answer sits in the delivery queue — never
196
217
  // run it again while the report is merely undelivered.
197
218
  if (pendingWorkReports.has(job.id)) continue;
@@ -225,9 +246,10 @@ export function createWorkSessionTurns({
225
246
  continue;
226
247
  }
227
248
  const releaseSlot = admit.reserve();
249
+ if (Number.isInteger(job.attempt) && settledAttempts.has(job.id) && settledAttempts.get(job.id) !== job.attempt) workAttempts.delete(job.id);
228
250
  lastDeferSaid.delete(job.sessionId);
229
251
  workAnswering.add(job.id);
230
- const run = { interrupted: false, spawned: [], sessionId: job.sessionId, live: null, job };
252
+ const run = { interrupted: false, spawned: [], sessionId: job.sessionId, live: null, job, abort: new AbortController() };
231
253
  runs.set(job.id, run);
232
254
  /** The execution generation the server handed, echoed on every settle
233
255
  * so the server can tell this execution's answer from an older one's. */
@@ -644,17 +666,29 @@ export function createWorkSessionTurns({
644
666
  // without one — see workTurnShape.mjs). A plan turn's credential is
645
667
  // still minted above: its lease answers (held elsewhere, gone) are
646
668
  // about the SESSION, and a plan turn needs them as much as any other.
647
- const mcp = shape.mountMcp
648
- ? mcpFor(rt.id, mint.token, getMcpUrl())
649
- : { args: [], env: null, dir: null };
669
+ let mcp = { args: [], env: null, dir: null };
650
670
  // The tab's model/effort, if it named any. Spread into turnArgs so
651
671
  // BOTH runTurn calls below carry it — the retry is the same turn on
652
672
  // the same brain, not a quieter second opinion.
653
673
  const brain = brainFor(job, rt.id);
674
+ const childBrain = { ...brain, runtime: rt.id, model: brain.model ?? (rt.id === 'claude' ? MODEL : null),
675
+ ...(rt.id === 'codex' ? { catalog: knownCodexModels() } : {}) };
676
+ const version = shape.capture && job.captureOrchestrationVersion === 1 ? runtimeVersion(rt.id) : null;
677
+ const delegated = shape.capture && job.captureOrchestrationVersion === 1 && Number.isSafeInteger(job.attempt)
678
+ && job.attempt >= 0 && readOnlyCoordinationSafe(rt.id, version);
679
+ let coordination = null, control = null;
680
+ let captureSignal = run.abort.signal;
681
+ const makeCoordination = () => coordinate({ enabled: true, runtime: rt.id, version, admit,
682
+ identity: { scope: 'capture', id: job.id, attempt: job.attempt }, signal: captureSignal,
683
+ children: childBrain,
684
+ rootTools: createCaptureAuthority({ url: getMcpUrl(), token: mint.token, sessionId: job.sessionId,
685
+ turnId: job.id, attempt: job.attempt, signal: captureSignal }),
686
+ });
654
687
  // Attempts count RUNS: the infra refusals above consumed nothing and
655
688
  // settled on their own terms.
656
689
  workAttempts.set(job.id, tries + 1);
657
690
  let out;
691
+ let turnReturned = false;
658
692
  let seenThreadId = null; // codex's conversation id, off thread.started
659
693
  let seenClaudeSession = null; // claude's own conversation id, off system.init
660
694
  // What a Codex turn's own events say went wrong (codexRelay.mjs):
@@ -678,9 +712,8 @@ export function createWorkSessionTurns({
678
712
  const turnLog = createToolLog(dir.wt);
679
713
  toolLog = turnLog.toolLog;
680
714
  const { pushToolEvent } = turnLog;
681
- const narrator = makeNarrator(job.sessionId, job.id, () =>
682
- toolLog
683
- );
715
+ const narrator = makeNarrator(job.sessionId, job.id, () => toolLog,
716
+ delegated ? { attempt: job.attempt, instance: DAEMON_INSTANCE } : {});
684
717
 
685
718
 
686
719
  // THE COMMAND AUDIT — see workCommandAudit.mjs.
@@ -691,6 +724,19 @@ export function createWorkSessionTurns({
691
724
  cwd: dir.wt,
692
725
  });
693
726
  try {
727
+ if (delegated) {
728
+ control = await watchCapture(job);
729
+ captureSignal = AbortSignal.any([run.abort.signal, control.signal]);
730
+ const stopped = () => { run.interrupted = true; run.abort.abort(); };
731
+ if (captureSignal.aborted) stopped();
732
+ else control.signal.addEventListener('abort', stopped, { once: true });
733
+ if (!run.interrupted) coordination = await makeCoordination();
734
+ }
735
+ // A safe native gate replaces the credential mount, keeping the
736
+ // actual capture credential entirely outside the CLI's children.
737
+ // Unsupported/gate-unavailable turns retain the original solo path.
738
+ if (!coordination && shape.mountMcp) mcp = mcpFor(rt.id, mint.token, getMcpUrl());
739
+ const captureContract = coordination ? captureOrchestratorContract(childBrain) : '';
694
740
  // Files first, then the message that references them: the agent
695
741
  // must be able to open what it is being told about. Only the ones
696
742
  // that actually landed are named.
@@ -750,7 +796,7 @@ export function createWorkSessionTurns({
750
796
  knowledgeDir,
751
797
  artifactsAccepted: getArtifactsAccepted(),
752
798
  runtime: rt.id,
753
- }) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : '');
799
+ }) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : '') + (captureContract ? `\n\n${captureContract}` : '');
754
800
  contractSent = !codexContractKnown(contractPath, codexResumeId, turnSystem);
755
801
  return contractSent ? turnSystem : '';
756
802
  })(),
@@ -862,7 +908,43 @@ export function createWorkSessionTurns({
862
908
  }
863
909
  },
864
910
  };
865
- out = await runCli({
911
+ const invoke = async (args, retry = false) => {
912
+ if (run.interrupted) return '';
913
+ if (!captureContract) return runCli(args);
914
+ let releaseRetry = () => {};
915
+ if (retry) {
916
+ // The prior process no longer counts. Reserve its replacement
917
+ // against the SAME pool before awaiting a new local gate.
918
+ while (!run.interrupted) {
919
+ const hold = admit('interactive');
920
+ if (!hold) { releaseRetry = admit.reserve(); break; }
921
+ narrator.line(hold.reason);
922
+ await new Promise(resolve => setTimeout(resolve, 1_000));
923
+ }
924
+ }
925
+ try {
926
+ if (retry && !run.interrupted) coordination = await makeCoordination();
927
+ if (run.interrupted) return '';
928
+ if (!coordination) {
929
+ if (!mcp.env) mcp = mcpFor(rt.id, mint.token, getMcpUrl());
930
+ return await runCli({ ...args, mcpArgs: mcp.args, mcpEnv: mcp.env,
931
+ onSpawn: ch => { releaseRetry(); args.onSpawn?.(ch); } });
932
+ }
933
+ const { onSpawn, onInit, ...turnArgs } = coordination.wrap({ ...args, mcpArgs: [], mcpEnv: null });
934
+ const result = await runCappedTurn({ runTurn: runCli, turnArgs, capMs: 15 * 60_000, workChildren,
935
+ releaseSlot: () => { releaseSlot(); releaseRetry(); }, onSpawn, onInit,
936
+ signal: captureSignal, killChild: coordination.kill,
937
+ // This is the ongoing conversation, never a scratch consult.
938
+ removeTranscript: () => {},
939
+ });
940
+ if (result.wedged && !run.interrupted) { cliFailed = true; return 'The capture turn timed out. Continue in this conversation; read list_staged before changing any proposals.'; }
941
+ return envScrub(result.out);
942
+ } finally {
943
+ releaseRetry();
944
+ await coordination?.close(run.interrupted); coordination = null;
945
+ }
946
+ };
947
+ out = await invoke({
866
948
  ...turnArgs,
867
949
  resume,
868
950
  resumeThreadId: codexResumeId || claudeResumeId || undefined,
@@ -913,9 +995,15 @@ export function createWorkSessionTurns({
913
995
  cliFailed = false;
914
996
  badExit = null;
915
997
  contractSent = true;
916
- out = await runCli({ ...turnArgs, resume: false, system: turnSystem });
998
+ out = await invoke({ ...turnArgs, resume: false, system: turnSystem }, true);
917
999
  }
1000
+ turnReturned = true;
918
1001
  } finally {
1002
+ await coordination?.close(run.interrupted);
1003
+ control?.stop();
1004
+ // The cap can resolve before a wedged provider closes stdout.
1005
+ // Its live-input relay must not keep taking the next prompt.
1006
+ if (delegated) await run.live?.stop();
919
1007
  // The CLI has stopped printing, so stop relaying. The LINE itself
920
1008
  // is cleared server-side at settle — clearing it here would race
921
1009
  // the settle and blank the tab a beat before the reply lands.
@@ -930,15 +1018,13 @@ export function createWorkSessionTurns({
930
1018
  }
931
1019
  }
932
1020
  if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
933
- }
934
- // Persist the codex thread id AFTER the turn ends, so the next turn
935
- // resumes exactly the conversation that just spoke. Shape-guarded
936
- // before it ever touches disk — it later rides in argv as
937
- // `resume <id>` — and best-effort, like the runtime pin: an
938
- // unwritable marker just means the tab runs fresh next turn.
939
- if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
940
- pinId(sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId), CODEX_THREAD_RE, seenThreadId);
941
- recordCodexContract(contractPath, seenThreadId, turnSystem, contractSent);
1021
+ // Keep the exact native conversation on failures too. A later
1022
+ // capture continuation must reconcile its own staged rows, not
1023
+ // silently start a second conversation after a provider throws.
1024
+ if ((shape.capture || turnReturned) && rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
1025
+ pinId(sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId), CODEX_THREAD_RE, seenThreadId);
1026
+ recordCodexContract(contractPath, seenThreadId, turnSystem, contractSent);
1027
+ }
942
1028
  }
943
1029
  /**
944
1030
  * A PERSON INTERRUPTED IT. Whatever the CLI printed was cut off, so
@@ -1116,6 +1202,7 @@ export function createWorkSessionTurns({
1116
1202
  if (run) {
1117
1203
  if (!run.interrupted) note(`${c.cyan('tab')} ${c.dim('— interrupting the running turn')}`);
1118
1204
  run.interrupted = true;
1205
+ run.abort.abort();
1119
1206
  for (const ch of run.spawned) interruptThenKill(ch);
1120
1207
  continue;
1121
1208
  }
@@ -84,6 +84,8 @@ export function createToolLog(cwd) {
84
84
  // past the server's limits (review, 2026-09-01).
85
85
  const e = toolEventOf(name, input, cwd, envScrub);
86
86
  if (!e) return;
87
+ if (typeof meta.agentId === 'string' && meta.agentId) e.agentId = scrubCut(meta.agentId, 160);
88
+ if (e.agentId && typeof meta.agentTitle === 'string' && meta.agentTitle) e.agentTitle = scrubCut(meta.agentTitle, 120);
87
89
  if (e.t !== 'plan') {
88
90
  if (id) { e.id = scrubCut(id, 160); correlated.set(id, e); }
89
91
  if (name === 'Read') for (const key of ['offset', 'limit']) {
@@ -106,6 +108,7 @@ export function createToolLog(cwd) {
106
108
  const last = toolLog.ev[toolLog.ev.length - 1];
107
109
  const sameKey =
108
110
  last &&
111
+ last.agentId === e.agentId &&
109
112
  !id && !last.id &&
110
113
  last.outcome === e.outcome &&
111
114
  last.exitCode === e.exitCode &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.125.13",
3
+ "version": "0.125.16",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant \u2014 Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {