flowviant 0.125.12 → 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.
Files changed (42) hide show
  1. package/README.md +24 -1
  2. package/bin/cli.mjs +5 -0
  3. package/bin/lib/agentFiles.mjs +1 -1
  4. package/bin/lib/agentPrerequisiteContract.mjs +8 -0
  5. package/bin/lib/agentTaskKinds.mjs +4 -4
  6. package/bin/lib/artifactContracts.mjs +15 -11
  7. package/bin/lib/artifactPreview.mjs +2 -2
  8. package/bin/lib/captureAuthority.mjs +82 -0
  9. package/bin/lib/claudePosture.mjs +9 -0
  10. package/bin/lib/codexResearch.mjs +12 -1
  11. package/bin/lib/hooks/orchestrator.mjs +1 -1
  12. package/bin/lib/intermediateArtifacts.mjs +228 -0
  13. package/bin/lib/mcpStdio.mjs +11 -3
  14. package/bin/lib/modelExport.mjs +19 -0
  15. package/bin/lib/modelExportProtocol.mjs +80 -0
  16. package/bin/lib/nativeAgentTools.mjs +4 -1
  17. package/bin/lib/orchestrator.mjs +46 -20
  18. package/bin/lib/orchestratorClient.mjs +2 -2
  19. package/bin/lib/orchestratorPrompt.mjs +70 -0
  20. package/bin/lib/pinnedCapture.mjs +35 -7
  21. package/bin/lib/prompts.mjs +10 -3
  22. package/bin/lib/readOnlyRun.mjs +172 -0
  23. package/bin/lib/runTurn.mjs +1 -0
  24. package/bin/lib/runtimeClaude.mjs +6 -5
  25. package/bin/lib/runtimeCodex.mjs +4 -4
  26. package/bin/lib/turnProfile.mjs +1 -0
  27. package/bin/lib/work.mjs +5 -5
  28. package/bin/lib/workAgentMerges.mjs +8 -8
  29. package/bin/lib/workAgentPlans.mjs +69 -43
  30. package/bin/lib/workAgentReview.mjs +24 -1
  31. package/bin/lib/workAgentTurnExecution.mjs +29 -6
  32. package/bin/lib/workAgentTurnOutcome.mjs +15 -3
  33. package/bin/lib/workAgentTurnShape.mjs +6 -5
  34. package/bin/lib/workAgentTurns.mjs +4 -0
  35. package/bin/lib/workAttachments.mjs +4 -2
  36. package/bin/lib/workCappedTurn.mjs +4 -0
  37. package/bin/lib/workLiveMessages.mjs +2 -2
  38. package/bin/lib/workNarration.mjs +3 -2
  39. package/bin/lib/workPlanRecovery.mjs +2 -21
  40. package/bin/lib/workSessionTurns.mjs +109 -22
  41. package/bin/lib/workToolLog.mjs +3 -0
  42. package/package.json +1 -1
@@ -47,7 +47,7 @@ import { runAgentWithInternalWaits } from './agentInternalWait.mjs';
47
47
  import { brainFor } from './workBrain.mjs';
48
48
  import { refreshRestartBase } from './workAgentRecovery.mjs';
49
49
  import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
50
- import { agentTurnSettlement, interruptedTurnSettlement } from './workAgentTurnOutcome.mjs';
50
+ import { agentDeclaredDelivery, agentTurnSettlement, interruptedTurnSettlement } from './workAgentTurnOutcome.mjs';
51
51
  import { ensureArtifactDir, ensureFenceScratch } from './artifacts.mjs';
52
52
  import { agentBrowserHome } from './noWindowEnv.mjs';
53
53
  import { agentTurnResume } from './workAgentResume.mjs';
@@ -59,6 +59,7 @@ import { ORCHESTRATED_POSTURES, orchestratesPosture } from './orchestratorPrompt
59
59
  import { knownCodexModels } from './runtimeOfferings.mjs';
60
60
  import { codexSubagentsSafe } from './runtimeCodex.mjs';
61
61
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
62
+ import { createIntermediateArtifacts } from './intermediateArtifacts.mjs';
62
63
 
63
64
  export function createAgentTurnExecution({
64
65
  admit,
@@ -76,6 +77,7 @@ export function createAgentTurnExecution({
76
77
  reportSessionWorktree,
77
78
  artifacts,
78
79
  runReviewEntry,
80
+ runMergeCheck,
79
81
  workChildren,
80
82
  agentChildren,
81
83
  postAgentTrace,
@@ -84,6 +86,8 @@ export function createAgentTurnExecution({
84
86
  postAgentParked,
85
87
  fetchAgentFiles,
86
88
  makeLiveMessages = createLiveMessages,
89
+ runTurnImpl = runTurn,
90
+ exportModel,
87
91
  }) {
88
92
  const reportDelivery = createDeliveryReporter();
89
93
  /** How long the final flush may hold the settle. Bounded because the settle
@@ -276,6 +280,10 @@ export function createAgentTurnExecution({
276
280
  * prompt is byte-for-byte what it was.
277
281
  */
278
282
  await fetchResultInputs(wt, job.resultInputs);
283
+ const intermediate = createIntermediateArtifacts({ wt, agentId,
284
+ path: sessionMetaPath(wt, 'flowviant-agent-artifacts', agentId),
285
+ ...(exportModel ? { exportPage: exportModel } : {}) });
286
+ const intermediateFiles = intermediate.inputs();
279
287
  const files =
280
288
  Array.isArray(job.attachments) && job.attachments.length > 0
281
289
  ? await fetchAgentFiles(wt, job.attachments)
@@ -380,7 +388,7 @@ export function createAgentTurnExecution({
380
388
  const spawnedChildren = new Set();
381
389
  /** The CLI, unless a person already asked to stop this turn (Interrupt
382
390
  * now): then nothing starts — no fresh retry, no continuation. */
383
- const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurn(args));
391
+ const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurnImpl(args));
384
392
  /**
385
393
  * WHAT THIS TURN SPENT, AS THE CLI COUNTED IT (2026-09-19).
386
394
  *
@@ -420,6 +428,7 @@ export function createAgentTurnExecution({
420
428
  }
421
429
  let projectTools = null;
422
430
  let orchestrator = null;
431
+ let intermediateDelivery = null;
423
432
  try {
424
433
  if (orchestrating) {
425
434
  orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
@@ -441,15 +450,16 @@ export function createAgentTurnExecution({
441
450
  format: async (followup) => {
442
451
  orchestrator?.note(followup);
443
452
  const attached = followup.attachments?.length ? await fetchAgentFiles(wt, followup.attachments.map((file) => ({ ...file, from: 'message' }))) : { message: [], card: [] };
444
- return agentFollowupKickoff(job, followup, { messageFiles: attached.message, cardFiles: files.card, orchestrated: Boolean(orchestrator) });
453
+ return agentFollowupKickoff(job, followup, { messageFiles: attached.message, cardFiles: files.card, intermediateFiles, orchestrated: Boolean(orchestrator) });
445
454
  },
446
455
  })) } : {}),
447
456
  // The kickoff and the system prompt, composed by the agent lane's
448
457
  // shape (workAgentTurnShape.mjs) from what this run measured.
449
458
  researchHandoff: job.researchHandoff,
450
459
  resultInputs: job.resultInputs,
460
+ intermediateFiles,
451
461
  researchIdentity: { projectId: PROJECT_ID, agentId, turnId, taskId: job.task?.id ?? job.repositoryTaskId },
452
- prompt: agentKickoff(job, { cardFiles: files.card, messageFiles: files.message }),
462
+ prompt: agentKickoff(job, { cardFiles: files.card, messageFiles: files.message, intermediateFiles }),
453
463
  system: agentTurnSystem({ taskKind, taskType: job.task?.taskType, runtimeLabel: runtimeById(rt)?.label, prerequisitesAccepted: job.prerequisitesAccepted === true, knowledgeDir: knowledgeDirFor(repoRoot), artifactsAccepted: getArtifactsAccepted(), toolsNote, artifactInstructions, toolInstructions: projectTools.instructions, repositoryRun, orchestration: orchestrator ? { posture, runtime: rt, ...cardBrain } : null }),
454
464
  agentTools: projectTools,
455
465
  writableDirs: repositoryRun?.writableDirs,
@@ -733,7 +743,18 @@ export function createAgentTurnExecution({
733
743
  void reportSessionWorktree(place).catch(() => {});
734
744
  // A file card delivers uploaded bytes, not an in-memory retry. Otherwise
735
745
  // its dependent card can start while Review has nothing to show.
736
- await artifacts.report({ placeDir: wt, before: artifactsBefore, agentId, turnId, requireStored: kind.artifact != null });
746
+ if (!run.interrupted && agentDeclaredDelivery(out, lastAnswer)) {
747
+ try { intermediateDelivery = await intermediate.prepare({ job, turnId, taskKind, before: artifactsBefore, signal: setupController.signal }); }
748
+ catch (error) {
749
+ if (run.interrupted) throw error;
750
+ // The failed page still has its preview/files for repair. Reporting
751
+ // those bytes cannot turn a failed export into a delivered card.
752
+ try { await artifacts.report({ placeDir: wt, before: artifactsBefore, agentId, turnId, requireStored: kind.artifact != null }); }
753
+ catch (uploadError) { throw new Error(`${error.message}; artifact reporting also failed: ${uploadError.message}`); }
754
+ throw error;
755
+ }
756
+ }
757
+ await artifacts.report({ placeDir: wt, before: intermediateDelivery?.uploadBefore ?? artifactsBefore, agentId, turnId, requireStored: kind.artifact != null });
737
758
  }
738
759
 
739
760
 
@@ -766,7 +787,9 @@ export function createAgentTurnExecution({
766
787
  artifactsBefore,
767
788
  branch,
768
789
  wt,
790
+ modelExports: intermediateDelivery?.modelExports,
769
791
  });
792
+ if (body.outcome === 'delivered') intermediateDelivery?.commit();
770
793
  if (res) setRuntimeLimit(PROJECT_ID, rt, null);
771
794
  if (body.outcome === 'delivered' && job.task?.taskType === 'tests' && res?.testFindings) {
772
795
  body.testFindings = measureTestFindings(res.testFindings, repositoryRun?.sourcesNow() ?? [{ wt, baseRef: baseRef(), id: job.repos?.length === 1 ? job.repos[0] : null }], envScrub);
@@ -789,7 +812,7 @@ export function createAgentTurnExecution({
789
812
  const rows = repositoryRun?.sourcesNow() ?? [{ wt }];
790
813
  for (const r of rows) {
791
814
  const head = gitOrNull(['rev-parse', 'HEAD'], r.wt);
792
- const check = await runReviewEntry(agentId, r.wt, job.agentName, repositoryRun ? r : undefined);
815
+ const check = await runMergeCheck(agentId, r.wt, repositoryRun ? r : undefined);
793
816
  if (check?.status === 'failed') {
794
817
  body.mergeResolved = false;
795
818
  body.mergeDetail = relayedText(check.text || check.output || 'The repository check failed during merge recovery.').slice(0, 2000);
@@ -33,8 +33,14 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
33
33
  import { usableImageArtifact } from './imageArtifact.mjs';
34
34
  import { withoutTurnDeclaration } from './turnDeclaration.mjs';
35
35
  import { ANSWER_DELIVERED_USES, researchUseOf, withResearchHeader } from './researchUse.mjs';
36
+ import { measuredModelExports } from './intermediateArtifacts.mjs';
36
37
 
37
- export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage, model = null, commits, artifactsBefore, branch, wt }) {
38
+ /** Materialization runs only for a declared delivery; settlement remains the
39
+ * sole parser and the sole authority deciding whether it really delivered. */
40
+ export const agentDeclaredDelivery = (out, lastAnswer) =>
41
+ ((lastAnswer && parseTurnResult(lastAnswer)) || parseTurnResult(out))?.outcome === 'delivered';
42
+
43
+ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage, model = null, commits, artifactsBefore, branch, wt, modelExports = [] }) {
38
44
  const res = (lastAnswer && parseTurnResult(lastAnswer)) || parseTurnResult(out);
39
45
  /**
40
46
  * A LIMIT IS ONLY A LIMIT WHEN THE TURN PRODUCED NOTHING.
@@ -94,7 +100,7 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
94
100
  * Its product is under `.flowviant/artifacts/`, and the kind's table
95
101
  * (agentTaskKinds.mjs) says which file proves it: a `.html` mockup for
96
102
  * design, a `.html` deck, a `.md` write-up for research, and a `.html`
97
- * page for a 3D model (the model is built in it, 0.107.0). A turn that
103
+ * page for a 3D model plus its measured page-bound local GLB export. A turn that
98
104
  * says "delivered" without having written one would land the agent in Review
99
105
  * with nothing to look at and a summary describing a page that does not
100
106
  * exist — so it settles `nothing` with the measured sentence, and the
@@ -135,7 +141,8 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
135
141
  && (kind.posture !== 'image' || usableImageArtifact(e)));
136
142
  const standing = scanArtifacts(wt);
137
143
  const wrote = has(changedArtifacts(artifactsBefore, standing));
138
- const present = wrote || ((job.kind !== 'task' || job.redo === true) && has(standing));
144
+ const exported = kind.exportModel && measuredModelExports(wt, modelExports);
145
+ const present = wrote || exported || ((job.kind !== 'task' || job.redo === true) && has(standing));
139
146
  if (!present) {
140
147
  return report({
141
148
  turnId,
@@ -147,6 +154,11 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
147
154
  worktree: wt,
148
155
  });
149
156
  }
157
+ if (kind.exportModel && !exported) {
158
+ return report({ turnId, outcome: 'nothing',
159
+ answer: 'The model page has no verified local .glb export. Retry this producer card to export its exact page before dependent work starts; no manual download or Library keeping is required.',
160
+ ...(usage ? { usage } : {}), branch, worktree: wt });
161
+ }
150
162
  }
151
163
  const prerequisite = res.prerequisite;
152
164
  const cannotRequest = prerequisite && job.prerequisitesAccepted !== true
@@ -25,6 +25,7 @@
25
25
  import { AGENT_TASK_KICKOFF, AGENT_HUMAN_KICKOFF, SYSTEM_AGENT_FOR, withProjectContext } from './prompts.mjs';
26
26
  import { INTERNAL_WAIT_CONTRACT } from './agentInternalWait.mjs';
27
27
  import { orchestratorContract } from './orchestratorPrompt.mjs';
28
+ import { intermediateArtifactContext } from './intermediateArtifacts.mjs';
28
29
 
29
30
  /** What every kickoff of this job is handed: the agent, the card and its
30
31
  * discussion, the job's context handoffs, the card's files, the place in
@@ -43,8 +44,8 @@ const shared = (job, cardFiles) => ({
43
44
 
44
45
  /** The turn's first prompt: the card, on a task turn; the person's words
45
46
  * otherwise. `cardFiles` and `messageFiles` are the files already on disk. */
46
- export const agentKickoff = (job, { cardFiles, messageFiles }) =>
47
- job.kind === 'task' && job.task
47
+ export const agentKickoff = (job, { cardFiles, messageFiles, intermediateFiles }) =>
48
+ (job.kind === 'task' && job.task
48
49
  ? AGENT_TASK_KICKOFF({
49
50
  // The card's discussion (0.106.0), printed inside THE CARD by
50
51
  // the same builder the run's stash wrote down — and the files
@@ -63,17 +64,17 @@ export const agentKickoff = (job, { cardFiles, messageFiles }) =>
63
64
  // The files (0.112.0): the person's inside their words, the
64
65
  // card's at the foot of its discussion.
65
66
  messageFiles,
66
- });
67
+ })) + intermediateArtifactContext(intermediateFiles);
67
68
 
68
69
  /** A live message sent while the turn runs, as the CLI is handed it — on an
69
70
  * orchestrated card with its id, so the route it takes is reported. */
70
- export const agentFollowupKickoff = (job, followup, { cardFiles, messageFiles, orchestrated }) =>
71
+ export const agentFollowupKickoff = (job, followup, { cardFiles, messageFiles, intermediateFiles, orchestrated }) =>
71
72
  AGENT_HUMAN_KICKOFF({
72
73
  ...shared(job, cardFiles),
73
74
  message: followup.body + (followup.libraryPrompt ?? '') + (orchestrated ? `\nMessage id: ${followup.id}. Choose and report its route with card_orchestrator.route_message.` : ''),
74
75
  askedByName: followup.askedByName,
75
76
  messageFiles,
76
- });
77
+ }) + intermediateArtifactContext(intermediateFiles);
77
78
 
78
79
  /** The turn's system prompt, in its fixed section order. `orchestration` is
79
80
  * null on a solo run, else what the contract names: `{ posture, runtime,
@@ -48,6 +48,7 @@ export function createWorkAgentTurns({
48
48
  reportSessionWorktree,
49
49
  artifacts,
50
50
  runReviewEntry,
51
+ runMergeCheck,
51
52
  publishAgentBranch,
52
53
  stopAgentPublishing = () => {},
53
54
  admit,
@@ -59,6 +60,7 @@ export function createWorkAgentTurns({
59
60
  makeBrief = createAgentBriefRunner({ admit, workChildren }),
60
61
  /** The run's live-message relay (workLiveMessages.mjs); a test's stand-in. */
61
62
  makeLiveMessages,
63
+ exportModel,
62
64
  }) {
63
65
  const reportDelivery = createDeliveryReporter();
64
66
  const {
@@ -133,6 +135,8 @@ export function createWorkAgentTurns({
133
135
  reportSessionWorktree,
134
136
  artifacts,
135
137
  runReviewEntry,
138
+ runMergeCheck,
139
+ exportModel,
136
140
  workChildren,
137
141
  agentChildren,
138
142
  postAgentTrace,
@@ -17,7 +17,7 @@ import { excludeInWorktree } from './git.mjs';
17
17
  import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
18
18
  import { safeFileName } from './safeFileName.mjs';
19
19
  import { fleetEndpoint } from './fleetWire.mjs';
20
- import { readAgentFiles } from './agentFiles.mjs';
20
+ import { AGENT_FILES_PER_MESSAGE_MAX, readAgentFiles } from './agentFiles.mjs';
21
21
 
22
22
  /**
23
23
  * ONE FILE'S CEILING, for both lanes (0.113.0, the owner 2026-09-29: "20 MB
@@ -27,6 +27,8 @@ import { readAgentFiles } from './agentFiles.mjs';
27
27
  * is what 0.112.0's 10 MB did to anything bigger, and why the server floors a
28
28
  * file over 10 MB at this release (`DAEMON_LARGE_FILES_MIN`).
29
29
  */
30
+ export const SESSION_ATTACHMENTS_PER_TURN_MAX = AGENT_FILES_PER_MESSAGE_MAX;
31
+
30
32
  export const ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024;
31
33
 
32
34
  export function createWorkAttachments() {
@@ -206,7 +208,7 @@ export function createWorkAttachments() {
206
208
  */
207
209
  const fetchTabFiles = async (wt, attachments) => {
208
210
  if (!Array.isArray(attachments) || attachments.length === 0) return [];
209
- const list = attachments.slice(0, 8);
211
+ const list = attachments.slice(0, SESSION_ATTACHMENTS_PER_TURN_MAX);
210
212
  const landed = await landFiles(wt, list, 'attachment', false);
211
213
  return list.map((a, i) => (landed[i] ? { path: landed[i] } : { missed: safeUploadName(a?.name) }));
212
214
  };
@@ -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);
@@ -6,12 +6,12 @@ import { readFileSync, writeFileSync, renameSync, rmSync } from 'node:fs';
6
6
  import { randomUUID } from 'node:crypto';
7
7
  import { fleetRequest } from './fleetPost.mjs';
8
8
  import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
9
- import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
9
+ import { DAEMON_INSTANCE, FLEET_URL, VERSION } from './config.mjs';
10
10
  import { messageIdsForSession } from './claudeSessions.mjs';
11
11
 
12
12
  export function createLiveMessages({ scope, turnId, attempt, cwd, sessionMetaPath, format, observe = () => {}, interrupted = () => false,
13
13
  request = fleetRequest, nativeIds = messageIdsForSession, setTimer = setInterval, clearTimer = clearInterval, finalFlushMs = 1_000 }) {
14
- const identity = { scope, turnId, instance: DAEMON_INSTANCE, ...(attempt !== undefined ? { attempt } : {}) };
14
+ const identity = { scope, turnId, daemonVersion: VERSION, instance: DAEMON_INSTANCE, ...(attempt !== undefined ? { attempt } : {}) };
15
15
  const file = sessionMetaPath(cwd, 'flowviant-live-messages', turnId);
16
16
  let sessionId = null;
17
17
  let timer = null;
@@ -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.12",
3
+ "version": "0.125.15",
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": {