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.
@@ -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.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": {