flowviant 0.117.0 → 0.124.0

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 (64) hide show
  1. package/README.md +35 -0
  2. package/bin/cli.mjs +37 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +2 -2
  7. package/bin/lib/agentPlan.mjs +78 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentTaskKinds.mjs +1 -1
  10. package/bin/lib/artifactContracts.mjs +91 -20
  11. package/bin/lib/artifactIteration.mjs +182 -0
  12. package/bin/lib/artifactRecovery.mjs +36 -0
  13. package/bin/lib/artifactReporter.mjs +183 -19
  14. package/bin/lib/artifacts.mjs +6 -3
  15. package/bin/lib/claudeAuth.mjs +15 -5
  16. package/bin/lib/claudePosture.mjs +110 -14
  17. package/bin/lib/claudeStream.mjs +20 -2
  18. package/bin/lib/codexProjectRead.mjs +63 -0
  19. package/bin/lib/codexResearch.mjs +16 -3
  20. package/bin/lib/commandSpecs.mjs +6 -0
  21. package/bin/lib/daemonLocks.mjs +5 -3
  22. package/bin/lib/desktopContract.mjs +3 -0
  23. package/bin/lib/discoveryContract.mjs +19 -0
  24. package/bin/lib/fleet.mjs +46 -4
  25. package/bin/lib/fleetReports.mjs +44 -3
  26. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  27. package/bin/lib/hooks/readGuard.mjs +13 -4
  28. package/bin/lib/imageArtifact.mjs +74 -0
  29. package/bin/lib/maintenance.mjs +47 -0
  30. package/bin/lib/maintenanceInventory.mjs +39 -0
  31. package/bin/lib/mcpInstall.mjs +163 -0
  32. package/bin/lib/mcpInventory.mjs +119 -0
  33. package/bin/lib/planDependencies.mjs +70 -0
  34. package/bin/lib/postureTools.mjs +85 -0
  35. package/bin/lib/projectToolRuntimes.mjs +227 -3
  36. package/bin/lib/projectTools.mjs +61 -4
  37. package/bin/lib/prompts.mjs +164 -41
  38. package/bin/lib/repositoryBaseline.mjs +68 -0
  39. package/bin/lib/runTurn.mjs +35 -16
  40. package/bin/lib/runtimeCapabilities.mjs +77 -3
  41. package/bin/lib/runtimeClaude.mjs +27 -9
  42. package/bin/lib/runtimeCodex.mjs +27 -59
  43. package/bin/lib/runtimeDetection.mjs +6 -0
  44. package/bin/lib/runtimeEvents.mjs +12 -1
  45. package/bin/lib/runtimeOfferings.mjs +75 -3
  46. package/bin/lib/turnDiagnostics.mjs +93 -0
  47. package/bin/lib/turnProfile.mjs +15 -3
  48. package/bin/lib/work.mjs +28 -7
  49. package/bin/lib/workAgentBrief.mjs +92 -0
  50. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  51. package/bin/lib/workAgentPlans.mjs +21 -70
  52. package/bin/lib/workAgentPrecheck.mjs +5 -0
  53. package/bin/lib/workAgentTurnExecution.mjs +93 -22
  54. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  55. package/bin/lib/workAgentTurns.mjs +60 -4
  56. package/bin/lib/workArtifactReconcile.mjs +79 -0
  57. package/bin/lib/workCappedTurn.mjs +10 -0
  58. package/bin/lib/workCommandAudit.mjs +8 -1
  59. package/bin/lib/workPlaces.mjs +18 -2
  60. package/bin/lib/workSessionTurns.mjs +148 -13
  61. package/bin/lib/workToolLog.mjs +70 -3
  62. package/bin/lib/workTurnShape.mjs +6 -4
  63. package/npm-shrinkwrap.json +12 -2
  64. package/package.json +7 -3
@@ -18,6 +18,8 @@
18
18
  */
19
19
  import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
20
  import { git } from './git.mjs';
21
+ import { measureRepositoryBaseline } from './repositoryBaseline.mjs';
22
+ import { REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
21
23
  import { processStartTime } from './procRegistry.mjs';
22
24
  import { c, note, ok, warn } from './ui.mjs';
23
25
  import { mcpFor } from './claude.mjs';
@@ -30,6 +32,7 @@ import { isAgyConversationLive } from './agySessions.mjs';
30
32
  import { adoptFence } from './localSessions.mjs';
31
33
  import { resumeRetriesFresh } from './resumeLost.mjs';
32
34
  import { turnLockedByLivePid } from './turnLock.mjs';
35
+ import { killGroup } from './processGroup.mjs';
33
36
  import { printable } from './printable.mjs';
34
37
  import { brainFor } from './workBrain.mjs';
35
38
  import { AGY_CONV_RE, CLAUDE_SESSION_RE, CODEX_THREAD_RE, RESUME_MARKERS } from './conversationIds.mjs';
@@ -48,6 +51,7 @@ export const SETTLED_HERE_MAX = 500;
48
51
 
49
52
  export function createWorkSessionTurns({
50
53
  repoRoot,
54
+ baseRef = () => null,
51
55
  baseDir,
52
56
  getMcpUrl,
53
57
  getArtifactsAccepted,
@@ -84,6 +88,31 @@ export function createWorkSessionTurns({
84
88
  const workAnswering = new Set(); // turn ids currently queued/running here
85
89
  const MAX_WORK_TRIES = 3;
86
90
 
91
+ /**
92
+ * INTERRUPT NOW (2026-10-13) — each turn this lane has taken, with the CLI
93
+ * children it spawned and whether a person asked to stop it. A stop kills
94
+ * each child's PROCESS GROUP: the CLI is spawned detached, so everything it
95
+ * started (a test run, a dev server) is in that group and goes with it —
96
+ * which is what interrupting a prompt means, and what Pause does too. A
97
+ * turn asked to stop before it spawned never spawns.
98
+ */
99
+ const runs = new Map(); // turnId -> { interrupted, spawned: [] }
100
+ /** Turns this process answered `ran: false` for, so the roster's next read
101
+ * (made before that settle landed) is not answered again at once. */
102
+ const notRunningSaid = new Map(); // turnId -> when
103
+ /**
104
+ * Sessions whose run this lane just stopped. The next turn there is the one
105
+ * sent to interrupt it, and the roster that hands it over can have been read
106
+ * before the stop's settle landed — so the note does not wait on the
107
+ * server's `interrupted` alone. Taken once, and only for a little while.
108
+ */
109
+ const stoppedIn = new Map(); // sessionId -> when
110
+ const tookStop = (sessionId) => {
111
+ const when = stoppedIn.get(sessionId);
112
+ stoppedIn.delete(sessionId);
113
+ return when != null && now() - when < 120_000;
114
+ };
115
+
87
116
  /**
88
117
  * A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
89
118
  *
@@ -131,10 +160,14 @@ export function createWorkSessionTurns({
131
160
  * and it is released in the turn's outer `finally`, after its settle.
132
161
  */
133
162
  const startingIn = new Set();
163
+ // A roster can contain a turn read before its stop was requested. Never
164
+ // start that stale job after acknowledging the same roster's interrupt.
165
+ let rosterInterrupts = new Set();
134
166
 
135
167
  const processWorkTurns = (jobs) => {
136
168
  for (const job of jobs ?? []) {
137
169
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
170
+ if (rosterInterrupts.has(job.id)) continue;
138
171
  if (workAnswering.has(job.id)) continue;
139
172
  // Settled by this lane already: a roster read from before the settle
140
173
  // landed. See `settledHere`.
@@ -167,6 +200,14 @@ export function createWorkSessionTurns({
167
200
  const releaseSlot = admit.reserve();
168
201
  lastDeferSaid.delete(job.sessionId);
169
202
  workAnswering.add(job.id);
203
+ const run = { interrupted: false, spawned: [] };
204
+ runs.set(job.id, run);
205
+ /** The execution generation the server handed, echoed on every settle
206
+ * so the server can tell this execution's answer from an older one's. */
207
+ const attempt = Number.isInteger(job.attempt) ? { attempt: job.attempt } : {};
208
+ /** The CLI, unless a person already asked to stop this turn — then no
209
+ * run starts, the fresh retry after an empty resume included. */
210
+ const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurn(args));
170
211
  const place = job.place || job.sessionId;
171
212
  /**
172
213
  * VALIDATED AT THE TRUST BOUNDARY, through the same door `learnPlaces`
@@ -185,6 +226,7 @@ export function createWorkSessionTurns({
185
226
  */
186
227
  if (!notePlace(job.sessionId, place)) {
187
228
  void settleTurn(job.id, {
229
+ ...attempt,
188
230
  ok: false,
189
231
  answer:
190
232
  'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
@@ -229,7 +271,7 @@ export function createWorkSessionTurns({
229
271
  * a turn fails.
230
272
  */
231
273
  const spent = () => ({
232
- ...(toolLog?.ev?.length > 0 ? { tools: toolLog } : {}),
274
+ ...(toolLog != null ? { tools: toolLog } : {}),
233
275
  ...(usage ? { usage } : {}),
234
276
  ...(model ? { model } : {}),
235
277
  });
@@ -242,6 +284,7 @@ export function createWorkSessionTurns({
242
284
  // Out of local tries: SETTLE, don't skip — a silently skipped turn
243
285
  // strands the tab for the server's whole 24h expiry window.
244
286
  await settleTurn(job.id, {
287
+ ...attempt,
245
288
  ok: false,
246
289
  answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
247
290
  });
@@ -271,6 +314,7 @@ export function createWorkSessionTurns({
271
314
  !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
272
315
  ) {
273
316
  await settleTurn(job.id, {
317
+ ...attempt,
274
318
  ok: false,
275
319
  answer: 'that terminal session id is not one this machine can resume',
276
320
  });
@@ -285,6 +329,7 @@ export function createWorkSessionTurns({
285
329
  }
286
330
  if (!srcCwd) {
287
331
  await settleTurn(job.id, {
332
+ ...attempt,
288
333
  ok: false,
289
334
  answer: "the terminal session's directory no longer exists on the machine",
290
335
  });
@@ -298,6 +343,7 @@ export function createWorkSessionTurns({
298
343
  const fenced = adoptFence({ repoRoot, excludeDirs: [baseDir] }).check(srcCwd);
299
344
  if (fenced === 'outside') {
300
345
  await settleTurn(job.id, {
346
+ ...attempt,
301
347
  ok: false,
302
348
  answer: "the terminal session's directory is outside this project's repository",
303
349
  });
@@ -305,6 +351,7 @@ export function createWorkSessionTurns({
305
351
  }
306
352
  if (fenced === 'ours') {
307
353
  await settleTurn(job.id, {
354
+ ...attempt,
308
355
  ok: false,
309
356
  answer:
310
357
  "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
@@ -315,6 +362,7 @@ export function createWorkSessionTurns({
315
362
  srcHead = git(['rev-parse', 'HEAD'], srcCwd);
316
363
  } catch {
317
364
  await settleTurn(job.id, {
365
+ ...attempt,
318
366
  ok: false,
319
367
  answer:
320
368
  "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
@@ -332,6 +380,7 @@ export function createWorkSessionTurns({
332
380
  : isTerminalSessionLive(job.adopt.id);
333
381
  if (adoptLive) {
334
382
  await settleTurn(job.id, {
383
+ ...attempt,
335
384
  ok: false,
336
385
  answer:
337
386
  'That terminal session is still open on the machine — close it there first, then adopt.',
@@ -348,6 +397,7 @@ export function createWorkSessionTurns({
348
397
  const dir = placeWtFor(place, adopting ? srcHead : undefined);
349
398
  if (!dir) {
350
399
  await settleTurn(job.id, {
400
+ ...attempt,
351
401
  ok: false,
352
402
  answer:
353
403
  'the session worktree could not be opened on the machine — check the daemon log',
@@ -379,12 +429,17 @@ export function createWorkSessionTurns({
379
429
  // What the artifact directory held BEFORE this turn — the capture chat
380
430
  // excepted: it runs read-only, is never told about artifacts, and
381
431
  // whatever sibling tabs wrote in this shared place is theirs.
382
- if (job.capture !== true) artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt) };
432
+ if (job.capture !== true) {
433
+ artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt), reported: false };
434
+ artifactScan.instructions = getArtifactsAccepted()
435
+ ? await artifacts.prepare?.({ placeDir: dir.wt, before: artifactScan.before, sessionId: job.sessionId }) ?? '' : '';
436
+ }
383
437
  const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
384
438
  if (rt.mismatch) {
385
439
  // Something upstream changed this tab's identity mid-life. A held
386
440
  // context must never be answered by a different brain — say so.
387
441
  await settleTurn(job.id, {
442
+ ...attempt,
388
443
  ok: false,
389
444
  answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
390
445
  });
@@ -392,6 +447,7 @@ export function createWorkSessionTurns({
392
447
  }
393
448
  if (rt.missing) {
394
449
  await settleTurn(job.id, {
450
+ ...attempt,
395
451
  ok: false,
396
452
  answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
397
453
  });
@@ -404,6 +460,7 @@ export function createWorkSessionTurns({
404
460
  // whole control plane rides one). An honest sentence beats the
405
461
  // mcpFor throw this used to crash into every turn.
406
462
  await settleTurn(job.id, {
463
+ ...attempt,
407
464
  ok: false,
408
465
  answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
409
466
  });
@@ -411,6 +468,7 @@ export function createWorkSessionTurns({
411
468
  }
412
469
  if (!rt.id) {
413
470
  await settleTurn(job.id, {
471
+ ...attempt,
414
472
  ok: false,
415
473
  answer:
416
474
  'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
@@ -428,7 +486,7 @@ export function createWorkSessionTurns({
428
486
  */
429
487
  const shape = sessionTurnShape({ runtime: rt.id, adopting, job });
430
488
  if (shape.refusal) {
431
- await settleTurn(job.id, { ok: false, answer: shape.refusal });
489
+ await settleTurn(job.id, { ...attempt, ok: false, answer: shape.refusal });
432
490
  return;
433
491
  }
434
492
  // A PLAIN tab (agy) mounts no MCP: no credential to mint.
@@ -447,6 +505,7 @@ export function createWorkSessionTurns({
447
505
  }
448
506
  if (mint?.gone) {
449
507
  await settleTurn(job.id, {
508
+ ...attempt,
450
509
  ok: false,
451
510
  answer:
452
511
  'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
@@ -455,6 +514,7 @@ export function createWorkSessionTurns({
455
514
  }
456
515
  if (!mint?.token) {
457
516
  await settleTurn(job.id, {
517
+ ...attempt,
458
518
  ok: false,
459
519
  answer:
460
520
  'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
@@ -598,7 +658,7 @@ export function createWorkSessionTurns({
598
658
  toolLog = turnLog.toolLog;
599
659
  const { pushToolEvent } = turnLog;
600
660
  const narrator = makeNarrator(job.sessionId, job.id, () =>
601
- toolLog.ev.length > 0 ? toolLog : undefined
661
+ toolLog
602
662
  );
603
663
 
604
664
 
@@ -632,6 +692,8 @@ export function createWorkSessionTurns({
632
692
  sessionName: job.sessionName,
633
693
  message,
634
694
  askedByName: job.askedByName,
695
+ // INTERRUPT NOW: the previous run was stopped to send this.
696
+ interrupted: tookStop(job.sessionId) || job.interrupted === true,
635
697
  }),
636
698
  // ONE PROFILE (turnProfile.mjs), the shape's: `plan` for the
637
699
  // capture chat, `plan-mode` for a plan turn, else `build`.
@@ -647,6 +709,7 @@ export function createWorkSessionTurns({
647
709
  // library that synced a second ago is in THIS turn.
648
710
  ...(() => {
649
711
  const knowledgeDir = knowledgeDirFor(repoRoot);
712
+ if (job.capture === true) toolLog.baseline = measureRepositoryBaseline({ cwd: dir.wt, baseRef, knowledgeDir });
650
713
  return {
651
714
  // The shape's contract (a plan turn: the PLAIN one, no
652
715
  // artifacts paragraph, the planning sentence appended). The
@@ -655,7 +718,8 @@ export function createWorkSessionTurns({
655
718
  system: sessionTurnSystem(shape, {
656
719
  knowledgeDir,
657
720
  artifactsAccepted: getArtifactsAccepted(),
658
- }),
721
+ runtime: rt.id,
722
+ }) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : ''),
659
723
  // The directory is OUTSIDE a worktree's cwd; Claude Code is
660
724
  // told it may read there (`--add-dir`) rather than left to
661
725
  // refuse a read-only capture turn a path it was just handed.
@@ -672,7 +736,8 @@ export function createWorkSessionTurns({
672
736
  answerFromResult: true,
673
737
  onActivity: (a) => {
674
738
  narrator.line(a?.label);
675
- auditCommand(a);
739
+ // Claude/Codex commands are audited below with their correlated outcome.
740
+ if (rt.id !== 'claude' && rt.id !== 'codex') auditCommand(a);
676
741
  if (a?.kind === 'tool' && APPROVAL_REFUSED.test(a.error ?? '')) approvalRefused.push(a.label);
677
742
  },
678
743
  // One Codex agent MESSAGE (runtimeEvents.mjs `answer`): the turn
@@ -681,7 +746,13 @@ export function createWorkSessionTurns({
681
746
  if (String(a ?? '').trim()) codexSpoke = true;
682
747
  },
683
748
  // The structured twin of the line above — see toolLog.
684
- onToolEvent: pushToolEvent,
749
+ onToolEvent: (name, input, meta) => {
750
+ const e = pushToolEvent(name, input, meta);
751
+ if (e?.t === 'bash') auditCommand({
752
+ kind: 'bash', command: input?.command ?? e.c,
753
+ observation: { id: e.id, outcome: e.outcome, exitCode: e.exitCode, errorCategory: e.errorCategory },
754
+ });
755
+ },
685
756
  // The CLI's own token counts, tagged with which CLI counted them
686
757
  // — ADDED, not set: both runTurn calls below share these args.
687
758
  onUsage: (u) => {
@@ -722,6 +793,9 @@ export function createWorkSessionTurns({
722
793
  onSpawn: (ch) => {
723
794
  if (!ch) return;
724
795
  spawned.push(ch);
796
+ run.spawned.push(ch);
797
+ // Asked to stop between the check and the spawn.
798
+ if (run.interrupted) killGroup(ch);
725
799
  // Keyed to the SESSION it serves — that id is what the machine
726
800
  // snapshot charges this child's memory to.
727
801
  workChildren.set(ch, job.sessionId);
@@ -745,7 +819,7 @@ export function createWorkSessionTurns({
745
819
  }
746
820
  },
747
821
  };
748
- out = await runTurn({
822
+ out = await runCli({
749
823
  ...turnArgs,
750
824
  resume,
751
825
  resumeThreadId: codexResumeId || claudeResumeId || undefined,
@@ -786,7 +860,7 @@ export function createWorkSessionTurns({
786
860
  !adopting &&
787
861
  resumeRetriesFresh({ resume, out, runtime: rt.id, sawInit: Boolean(seenClaudeSession) })
788
862
  ) {
789
- out = await runTurn({ ...turnArgs, resume: false });
863
+ out = await runCli({ ...turnArgs, resume: false });
790
864
  }
791
865
  } finally {
792
866
  // The CLI has stopped printing, so stop relaying. The LINE itself
@@ -837,6 +911,21 @@ export function createWorkSessionTurns({
837
911
  }
838
912
  }
839
913
  }
914
+ /**
915
+ * A PERSON INTERRUPTED IT. Whatever the CLI printed was cut off, so
916
+ * it is not an answer: the turn settles `interrupted`, with what it
917
+ * spent and did, and the message sent to interrupt it runs next in
918
+ * this same conversation — the markers above already hold its id.
919
+ * Never spawned, it settles `ran: false`, unremembered, so the
920
+ * server's re-offer runs it as it was.
921
+ */
922
+ if (run.interrupted) {
923
+ const body = { ...attempt, ok: false, interrupted: true, ran: run.spawned.length > 0, answer: 'the turn was interrupted', ...spent() };
924
+ if (body.ran) stoppedIn.set(job.sessionId, now());
925
+ await (body.ran ? settleTurn(job.id, body) : settleWorkTurn(job.id, body));
926
+ ok(`${c.cyan('tab')} ${c.dim('— interrupted')}`);
927
+ return;
928
+ }
840
929
  const answer = (out || '').trim();
841
930
  // No output at all smells like a dead MCP credential (the lane
842
931
  // workers' no-sentinel case) — drop the cached token so the next
@@ -848,6 +937,7 @@ export function createWorkSessionTurns({
848
937
  // exactly that; no sessionRef is recorded, so the server keeps
849
938
  // offering the adoption and a retry after the user checks is cheap.
850
939
  await settleTurn(job.id, {
940
+ ...attempt,
851
941
  ok: false,
852
942
  answer: "Couldn't resume the terminal session — it may have been removed.",
853
943
  // Whatever it DID before coming back empty is exactly the
@@ -897,10 +987,20 @@ export function createWorkSessionTurns({
897
987
  */
898
988
  const loginFailure = rt.id === 'codex' && !codexSpoke ? codexLoginFailure(answer) : null;
899
989
  const refusedLine = rt.id === 'codex' ? codexApprovalSentence(approvalRefused) : null;
990
+ let artifactFailure = null;
991
+ if (artifactScan) {
992
+ artifactScan.reported = true;
993
+ try {
994
+ await artifacts.report({ placeDir: artifactScan.dir, before: artifactScan.before, sessionId: job.sessionId, turnId: job.id });
995
+ } catch (error) {
996
+ artifactFailure = `Artifact delivery is incomplete: ${error.message}`;
997
+ }
998
+ }
900
999
  const spoke = answer.length > 0 && !loginFailure;
901
1000
  await settleTurn(job.id, {
902
- ok: spoke,
903
- answer: loginFailure
1001
+ ...attempt,
1002
+ ok: spoke && !artifactFailure,
1003
+ answer: artifactFailure ? withRelay(artifactFailure, answer) : loginFailure
904
1004
  ? withRelay(codexLoginSentence(loginFailure), answer)
905
1005
  : answer.length > 0
906
1006
  ? refusedLine
@@ -921,7 +1021,14 @@ export function createWorkSessionTurns({
921
1021
  else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
922
1022
  else warn('session turn produced no output — settled as failed');
923
1023
  } catch (e) {
1024
+ if (toolLog) toolLog.state = 'partial';
1025
+ if (run.interrupted) {
1026
+ const body = { ...attempt, ok: false, interrupted: true, ran: run.spawned.length > 0, answer: 'the turn was interrupted', ...spent() };
1027
+ await (body.ran ? settleTurn(job.id, body) : settleWorkTurn(job.id, body));
1028
+ return;
1029
+ }
924
1030
  await settleTurn(job.id, {
1031
+ ...attempt,
925
1032
  ok: false,
926
1033
  // Scrub, like every string that leaves this machine: an exception
927
1034
  // routinely quotes command output, and command output can quote a
@@ -935,6 +1042,7 @@ export function createWorkSessionTurns({
935
1042
  warn(`session turn failed: ${e?.message ?? e}`);
936
1043
  } finally {
937
1044
  workAnswering.delete(job.id);
1045
+ runs.delete(job.id);
938
1046
  if (startClaim) startingIn.delete(startClaim);
939
1047
  // Nothing spawned, or everything already has: releasing twice is the
940
1048
  // normal case and costs nothing. A reservation that leaked would
@@ -950,7 +1058,7 @@ export function createWorkSessionTurns({
950
1058
  // …and relay what it wrote to SHOW its owner (2026-09-22) — the same
951
1059
  // beat, the same reason it is not awaited. A failed turn's half-drawn
952
1060
  // page is still what the turn left, and the tab should see it.
953
- if (artifactScan) {
1061
+ if (artifactScan && !artifactScan.reported) {
954
1062
  void artifacts
955
1063
  .report({
956
1064
  placeDir: artifactScan.dir,
@@ -965,5 +1073,32 @@ export function createWorkSessionTurns({
965
1073
  }
966
1074
  };
967
1075
 
968
- return { processWorkTurns, workAnswering, settledHere };
1076
+ /**
1077
+ * THE RUNS A PERSON ASKED TO STOP (`roster.interruptTurns`, read before the
1078
+ * jobs). One this lane is running is stopped — its process groups killed —
1079
+ * and settles `interrupted` when its CLI is gone. One it finished already
1080
+ * keeps its own settle. One it is not running at all is answered `ran:
1081
+ * false`: nothing was running here to stop, and the server puts it back in
1082
+ * the queue.
1083
+ */
1084
+ const interruptWorkTurns = (ids) => {
1085
+ rosterInterrupts = new Set(Array.isArray(ids) ? ids : []);
1086
+ const at = now();
1087
+ for (const [id, when] of notRunningSaid) if (at - when > 30_000) notRunningSaid.delete(id);
1088
+ for (const id of Array.isArray(ids) ? ids : []) {
1089
+ if (typeof id !== 'string' || !id) continue;
1090
+ const run = runs.get(id);
1091
+ if (run) {
1092
+ if (!run.interrupted) note(`${c.cyan('tab')} ${c.dim('— interrupting the running turn')}`);
1093
+ run.interrupted = true;
1094
+ for (const ch of run.spawned) killGroup(ch);
1095
+ continue;
1096
+ }
1097
+ if (pendingWorkReports.has(id) || settledRecently(id) || notRunningSaid.has(id)) continue;
1098
+ notRunningSaid.set(id, at);
1099
+ void Promise.resolve(settleWorkTurn(id, { ok: false, interrupted: true, ran: false, answer: '' })).catch(() => {});
1100
+ }
1101
+ };
1102
+
1103
+ return { processWorkTurns, interruptWorkTurns, workAnswering, settledHere };
969
1104
  }
@@ -8,7 +8,7 @@
8
8
  * the stream. Tested directly (workToolLog.test.mjs).
9
9
  */
10
10
  import { toolEventOf } from './runtimeEvents.mjs';
11
- import { scrub as envScrub } from './uplinkScrub.mjs';
11
+ import { scrubCut, scrub as envScrub } from './uplinkScrub.mjs';
12
12
 
13
13
  /**
14
14
  * THE TURN'S TOOL LOG — the structured relay behind the transcript's
@@ -30,14 +30,74 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
30
30
  * because it is current state rather than scrollback.
31
31
  */
32
32
  export function createToolLog(cwd) {
33
- const toolLog = { ev: [], dropped: 0 };
34
- const pushToolEvent = (name, input) => {
33
+ const toolLog = { ev: [], dropped: 0, state: 'unknown' };
34
+ // IDs are correlation keys, never model-authored citations. Keep only bounded
35
+ // metadata; output supplies a fixed category and allowlisted reader diagnostic.
36
+ // Arbitrary output and file contents are discarded.
37
+ const correlated = new Map();
38
+ const refresh = () => {
39
+ toolLog.state = toolLog.state === 'partial' || toolLog.dropped || toolLog.recoveryDropped ? 'partial' :
40
+ toolLog.ev.length && toolLog.ev.every(e => e.outcome && e.outcome !== 'unknown') ? 'complete' : 'unknown';
41
+ const retained = new Set([...toolLog.ev, toolLog.firstFailure, ...(toolLog.recovery ?? [])]);
42
+ for (const [id, row] of correlated) if (!retained.has(row)) correlated.delete(id);
43
+ };
44
+ const observe = (e, meta) => {
45
+ const code = Number.isSafeInteger(meta.exitCode) ? meta.exitCode : undefined;
46
+ const failed = meta.isError === true || (code !== undefined && code !== 0);
47
+ e.outcome = !meta.result ? 'unknown' : failed ? 'failed' :
48
+ meta.runtime === 'codex' && code === undefined ? 'unknown' : 'success';
49
+ if (code !== undefined) e.exitCode = code;
50
+ if (failed) {
51
+ const output = scrubCut(meta.output ?? '', 16_384);
52
+ const guardAt = output.indexOf('flowviant: this turn is read-only, so ');
53
+ const guard = guardAt >= 0 ? output.slice(guardAt).split('\n', 1)[0] : undefined;
54
+ const diagnostic = guard ?? output.split('\n', 80).find(line => /^(?:[\w./ -]{1,160}: ){1,2}(?:No such file or directory|Permission denied|command not found)\.?$/.test(line) || (e.t === 'read' && /^(?:Error: )?(?:File does not exist|File not found|Permission denied)\.?(?: Note: your current working directory is [^\n]{1,400}\.)?$/.test(line)));
55
+ if (diagnostic) {
56
+ e.errorMessage = diagnostic.slice(0, 500);
57
+ if (diagnostic.length > 500) { e.errorTruncated = true; toolLog.state = 'partial'; }
58
+ }
59
+ e.errorCategory = guard && /a pipe|a glob|a redirect|a second command|an expansion|command substitution|brace expansion|a subshell/.test(guard) ? 'syntax_refusal' : /permission|denied|approval|not allowed|refus/i.test(output) ? 'denied' :
60
+ /command not found|not recognized|executable.*not found/i.test(output) ? 'unavailable_tool' :
61
+ /no such file|not found|does not exist|cannot find/i.test(output) ? 'missing_path' : 'tool_error';
62
+ if (!toolLog.firstFailure) {
63
+ toolLog.firstFailure = { ...e };
64
+ toolLog.recovery = [];
65
+ }
66
+ }
67
+ };
68
+ const pushToolEvent = (name, input, meta = {}) => {
69
+ const id = typeof meta.id === 'string' && meta.id && meta.id.length <= 160 ? meta.id : undefined;
70
+ const existing = id && correlated.get(id);
71
+ if (existing) {
72
+ if (meta.result) observe(existing, meta);
73
+ refresh();
74
+ return existing;
75
+ }
76
+ if (!name) {
77
+ toolLog.dropped = Math.min(1_000_000, toolLog.dropped + 1);
78
+ refresh();
79
+ return;
80
+ }
35
81
  // envScrub rides INTO the builder, which scrubs over a bounded
36
82
  // window BEFORE its caps — scrubbing after the cut both leaked a
37
83
  // boundary-straddling secret's prefix and grew a capped field
38
84
  // past the server's limits (review, 2026-09-01).
39
85
  const e = toolEventOf(name, input, cwd, envScrub);
40
86
  if (!e) return;
87
+ if (e.t !== 'plan') {
88
+ if (id) { e.id = scrubCut(id, 160); correlated.set(id, e); }
89
+ if (name === 'Read') for (const key of ['offset', 'limit']) {
90
+ if (Number.isSafeInteger(input?.[key]) && input[key] >= 0) e[key] = input[key];
91
+ }
92
+ if (toolLog.firstFailure) {
93
+ toolLog.recovery.push(e);
94
+ if (toolLog.recovery.length > 12) {
95
+ toolLog.recovery.shift();
96
+ toolLog.recoveryDropped = Math.min(1_000_000, (toolLog.recoveryDropped ?? 0) + 1);
97
+ }
98
+ }
99
+ observe(e, meta);
100
+ }
41
101
  if (e.t === 'plan') {
42
102
  const i = toolLog.ev.findIndex((x) => x.t === 'plan');
43
103
  if (i >= 0) toolLog.ev.splice(i, 1);
@@ -46,6 +106,11 @@ export function createToolLog(cwd) {
46
106
  const last = toolLog.ev[toolLog.ev.length - 1];
47
107
  const sameKey =
48
108
  last &&
109
+ !id && !last.id &&
110
+ last.outcome === e.outcome &&
111
+ last.exitCode === e.exitCode &&
112
+ last.errorCategory === e.errorCategory &&
113
+ last.offset === e.offset && last.limit === e.limit &&
49
114
  last.t === e.t &&
50
115
  last.p === e.p &&
51
116
  last.q === e.q &&
@@ -71,6 +136,8 @@ export function createToolLog(cwd) {
71
136
  const [shed] = toolLog.ev.splice(i, 1);
72
137
  toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
73
138
  }
139
+ refresh();
140
+ return e;
74
141
  };
75
142
  return { toolLog, pushToolEvent };
76
143
  }
@@ -1,3 +1,4 @@
1
+ import { discoveryGuide } from './discoveryContract.mjs';
1
2
  /**
2
3
  * WHAT A TERMINAL TAB'S TURN IS — plain, plan, capture or build — decided
3
4
  * once, as data (audit 2026-10-10, F159). Pure: no I/O, no state.
@@ -145,8 +146,8 @@ const SYSTEMS = { plain: SYSTEM_WORK_PLAIN, capture: SYSTEM_CAPTURE, work: SYSTE
145
146
  /** The turn's opening message under its contract. The plain kickoff names no
146
147
  * session id (WORK_TURN_KICKOFF_PLAIN takes none) — it has no tools to pass
147
148
  * one to. */
148
- export const sessionTurnKickoff = (shape, { sessionId, sessionName, message, askedByName }) =>
149
- KICKOFFS[shape.kickoff]({ sessionId, sessionName, message, askedByName });
149
+ export const sessionTurnKickoff = (shape, { sessionId, sessionName, message, askedByName, interrupted = false }) =>
150
+ KICKOFFS[shape.kickoff]({ sessionId, sessionName, message, askedByName, interrupted });
150
151
 
151
152
  /**
152
153
  * The turn's system prompt: its contract with the project's knowledge
@@ -154,10 +155,11 @@ export const sessionTurnKickoff = (shape, { sessionId, sessionName, message, ask
154
155
  * server can show one (`artifactsAccepted`, read by the lane at spawn), and a
155
156
  * plan turn's sentence appended.
156
157
  */
157
- export const sessionTurnSystem = (shape, { knowledgeDir, artifactsAccepted }) => {
158
+ export const sessionTurnSystem = (shape, { knowledgeDir, artifactsAccepted, runtime }) => {
158
159
  const contract = withProjectContext(SYSTEMS[shape.systemBase], {
159
160
  knowledgeDir,
160
161
  artifacts: shape.artifacts && artifactsAccepted,
161
162
  });
162
- return shape.planSentence ? `${contract}\n\n${PLAN_TURN_SENTENCE}` : contract;
163
+ const discovery = shape.systemBase === 'capture' && runtime ? `\n\n${discoveryGuide(runtime, 'capture')}` : '';
164
+ return shape.planSentence ? `${contract}\n\n${PLAN_TURN_SENTENCE}` : contract + discovery;
163
165
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.117.0",
3
+ "version": "0.124.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "flowviant",
9
- "version": "0.117.0",
9
+ "version": "0.124.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "libsodium-wrappers-sumo": "^0.8.4",
@@ -15,6 +15,9 @@
15
15
  "bin": {
16
16
  "flowviant": "bin/cli.mjs"
17
17
  },
18
+ "devDependencies": {
19
+ "three": "0.180.0"
20
+ },
18
21
  "engines": {
19
22
  "node": ">=20"
20
23
  }
@@ -34,6 +37,13 @@
34
37
  "libsodium-sumo": "^0.8.0"
35
38
  }
36
39
  },
40
+ "node_modules/three": {
41
+ "version": "0.180.0",
42
+ "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
43
+ "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
44
+ "dev": true,
45
+ "license": "MIT"
46
+ },
37
47
  "node_modules/ws": {
38
48
  "version": "8.21.1",
39
49
  "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.117.0",
3
+ "version": "0.124.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — 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": {
@@ -35,10 +35,14 @@
35
35
  "homepage": "https://flowviant.com",
36
36
  "repository": {
37
37
  "type": "git",
38
- "url": "git+https://github.com/flowviant/cli.git"
38
+ "url": "git+https://github.com/Flowviant/flowviant.git",
39
+ "directory": "cli"
39
40
  },
40
41
  "license": "MIT",
41
42
  "bugs": {
42
- "url": "https://github.com/flowviant/cli/issues"
43
+ "url": "https://github.com/Flowviant/flowviant/issues"
44
+ },
45
+ "devDependencies": {
46
+ "three": "0.180.0"
43
47
  }
44
48
  }