flowviant 0.117.0 → 0.124.1

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 (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -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 +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
@@ -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,9 @@ 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 { createDeliveryReporter } from './workDelivery.mjs';
36
+ import { createLiveMessages } from './workLiveMessages.mjs';
37
+ import { killGroup } from './processGroup.mjs';
33
38
  import { printable } from './printable.mjs';
34
39
  import { brainFor } from './workBrain.mjs';
35
40
  import { AGY_CONV_RE, CLAUDE_SESSION_RE, CODEX_THREAD_RE, RESUME_MARKERS } from './conversationIds.mjs';
@@ -48,6 +53,7 @@ export const SETTLED_HERE_MAX = 500;
48
53
 
49
54
  export function createWorkSessionTurns({
50
55
  repoRoot,
56
+ baseRef = () => null,
51
57
  baseDir,
52
58
  getMcpUrl,
53
59
  getArtifactsAccepted,
@@ -80,10 +86,37 @@ export function createWorkSessionTurns({
80
86
  runTurn = defaultRunTurn,
81
87
  // The clock `settledHere` ages by; injectable only for its bound's test.
82
88
  now = Date.now,
89
+ makeLiveMessages = createLiveMessages,
90
+ reportDelivery = createDeliveryReporter(),
83
91
  }) {
84
92
  const workAnswering = new Set(); // turn ids currently queued/running here
85
93
  const MAX_WORK_TRIES = 3;
86
94
 
95
+ /**
96
+ * INTERRUPT NOW (2026-10-13) — each turn this lane has taken, with the CLI
97
+ * children it spawned and whether a person asked to stop it. A stop kills
98
+ * each child's PROCESS GROUP: the CLI is spawned detached, so everything it
99
+ * started (a test run, a dev server) is in that group and goes with it —
100
+ * which is what interrupting a prompt means, and what Pause does too. A
101
+ * turn asked to stop before it spawned never spawns.
102
+ */
103
+ const runs = new Map(); // turnId -> { interrupted, spawned: [] }
104
+ /** Turns this process answered `ran: false` for, so the roster's next read
105
+ * (made before that settle landed) is not answered again at once. */
106
+ const notRunningSaid = new Map(); // turnId -> when
107
+ /**
108
+ * Sessions whose run this lane just stopped. The next turn there is the one
109
+ * sent to interrupt it, and the roster that hands it over can have been read
110
+ * before the stop's settle landed — so the note does not wait on the
111
+ * server's `interrupted` alone. Taken once, and only for a little while.
112
+ */
113
+ const stoppedIn = new Map(); // sessionId -> when
114
+ const tookStop = (sessionId) => {
115
+ const when = stoppedIn.get(sessionId);
116
+ stoppedIn.delete(sessionId);
117
+ return when != null && now() - when < 120_000;
118
+ };
119
+
87
120
  /**
88
121
  * A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
89
122
  *
@@ -115,12 +148,14 @@ export function createWorkSessionTurns({
115
148
  settledHere.delete(turnId); // re-inserted at the end: the map stays in time order
116
149
  settledHere.set(turnId, at);
117
150
  forgetStale(at);
118
- return settleWorkTurn(turnId, payload);
151
+ const liveMessages = runs.get(turnId)?.live?.receipts();
152
+ return settleWorkTurn(turnId, { ...payload, ...(liveMessages?.ids.length ? { liveMessages } : {}) });
119
153
  };
120
154
 
121
155
  /**
122
- * A PLACE A TURN OF THIS PROCESS IS STARTING IN (2026-09-29) — the turn
123
- * lock's in-process half, keyed by the lock's own path.
156
+ * A TAB A TURN OF THIS PROCESS IS STARTING IN (2026-09-29; per tab since
157
+ * 2026-10-14) — the turn lock's in-process half, keyed by the lock's own
158
+ * path.
124
159
  *
125
160
  * `flowviant-turn.lock` is written at SPAWN, several awaits after it is
126
161
  * read (a credential mint, the attachment fetch), so two turns of one
@@ -130,18 +165,33 @@ export function createWorkSessionTurns({
130
165
  * check, so the second turn waits for a later poll like any locked one,
131
166
  * and it is released in the turn's outer `finally`, after its settle.
132
167
  */
133
- const startingIn = new Set();
168
+ const startingIn = new Map(); // lock path -> the holding tab's measured name
169
+
170
+ // A roster can contain a turn read before its stop was requested. Never
171
+ // start that stale job after acknowledging the same roster's interrupt.
172
+ let rosterInterrupts = new Set();
134
173
 
135
174
  const processWorkTurns = (jobs) => {
136
175
  for (const job of jobs ?? []) {
137
176
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
138
- if (workAnswering.has(job.id)) continue;
177
+ if (rosterInterrupts.has(job.id)) continue;
178
+ if (workAnswering.has(job.id)) {
179
+ const reason = runs.get(job.id)?.waitingReason;
180
+ if (reason) reportDelivery('session', job, 'queued', reason);
181
+ continue;
182
+ }
139
183
  // Settled by this lane already: a roster read from before the settle
140
184
  // landed. See `settledHere`.
141
185
  if (settledRecently(job.id)) continue;
142
186
  // The turn already RAN and its answer sits in the delivery queue — never
143
187
  // run it again while the report is merely undelivered.
144
188
  if (pendingWorkReports.has(job.id)) continue;
189
+ // This conversation already has a streaming Claude. Its input relay
190
+ // reads the durable queue; a second process must never read these words.
191
+ if ([...runs.values()].some((r) => r.sessionId === job.sessionId && r.live?.connected() && !r.interrupted)) {
192
+ reportDelivery('session', job, 'queued', 'waiting for this turn to take in the message');
193
+ continue;
194
+ }
145
195
  /**
146
196
  * THE BOX IS ABOUT TO FALL OVER, OR THIS MACHINE IS ALREADY AT ITS
147
197
  * CEILING. Defer: return without settling and without consuming an
@@ -162,11 +212,20 @@ export function createWorkSessionTurns({
162
212
  const hold = admit('interactive');
163
213
  if (hold) {
164
214
  sayTurnDeferred(job.sessionId, job.id, hold.reason);
215
+ reportDelivery('session', job, 'queued', hold.reason);
165
216
  continue;
166
217
  }
167
218
  const releaseSlot = admit.reserve();
168
219
  lastDeferSaid.delete(job.sessionId);
169
220
  workAnswering.add(job.id);
221
+ const run = { interrupted: false, spawned: [], sessionId: job.sessionId, live: null };
222
+ runs.set(job.id, run);
223
+ /** The execution generation the server handed, echoed on every settle
224
+ * so the server can tell this execution's answer from an older one's. */
225
+ const attempt = Number.isInteger(job.attempt) ? { attempt: job.attempt } : {};
226
+ /** The CLI, unless a person already asked to stop this turn — then no
227
+ * run starts, the fresh retry after an empty resume included. */
228
+ const runCli = (args) => (run.interrupted ? Promise.resolve('') : runTurn(args));
170
229
  const place = job.place || job.sessionId;
171
230
  /**
172
231
  * VALIDATED AT THE TRUST BOUNDARY, through the same door `learnPlaces`
@@ -185,6 +244,7 @@ export function createWorkSessionTurns({
185
244
  */
186
245
  if (!notePlace(job.sessionId, place)) {
187
246
  void settleTurn(job.id, {
247
+ ...attempt,
188
248
  ok: false,
189
249
  answer:
190
250
  'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
@@ -194,6 +254,7 @@ export function createWorkSessionTurns({
194
254
  }
195
255
  // A READER: other turns in this place run alongside it. See `inPlace`.
196
256
  inPlace(place, false, async () => {
257
+ run.waitingReason = null;
197
258
  /** This turn's artifact snapshot — set once the place is resolved and
198
259
  * a CLI is about to spawn there; read in the `finally`. Null on every
199
260
  * path that never ran a CLI, which is exactly when nothing is new. */
@@ -229,7 +290,7 @@ export function createWorkSessionTurns({
229
290
  * a turn fails.
230
291
  */
231
292
  const spent = () => ({
232
- ...(toolLog?.ev?.length > 0 ? { tools: toolLog } : {}),
293
+ ...(toolLog != null ? { tools: toolLog } : {}),
233
294
  ...(usage ? { usage } : {}),
234
295
  ...(model ? { model } : {}),
235
296
  });
@@ -242,6 +303,7 @@ export function createWorkSessionTurns({
242
303
  // Out of local tries: SETTLE, don't skip — a silently skipped turn
243
304
  // strands the tab for the server's whole 24h expiry window.
244
305
  await settleTurn(job.id, {
306
+ ...attempt,
245
307
  ok: false,
246
308
  answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
247
309
  });
@@ -271,6 +333,7 @@ export function createWorkSessionTurns({
271
333
  !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
272
334
  ) {
273
335
  await settleTurn(job.id, {
336
+ ...attempt,
274
337
  ok: false,
275
338
  answer: 'that terminal session id is not one this machine can resume',
276
339
  });
@@ -285,6 +348,7 @@ export function createWorkSessionTurns({
285
348
  }
286
349
  if (!srcCwd) {
287
350
  await settleTurn(job.id, {
351
+ ...attempt,
288
352
  ok: false,
289
353
  answer: "the terminal session's directory no longer exists on the machine",
290
354
  });
@@ -298,6 +362,7 @@ export function createWorkSessionTurns({
298
362
  const fenced = adoptFence({ repoRoot, excludeDirs: [baseDir] }).check(srcCwd);
299
363
  if (fenced === 'outside') {
300
364
  await settleTurn(job.id, {
365
+ ...attempt,
301
366
  ok: false,
302
367
  answer: "the terminal session's directory is outside this project's repository",
303
368
  });
@@ -305,6 +370,7 @@ export function createWorkSessionTurns({
305
370
  }
306
371
  if (fenced === 'ours') {
307
372
  await settleTurn(job.id, {
373
+ ...attempt,
308
374
  ok: false,
309
375
  answer:
310
376
  "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
@@ -315,6 +381,7 @@ export function createWorkSessionTurns({
315
381
  srcHead = git(['rev-parse', 'HEAD'], srcCwd);
316
382
  } catch {
317
383
  await settleTurn(job.id, {
384
+ ...attempt,
318
385
  ok: false,
319
386
  answer:
320
387
  "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
@@ -332,6 +399,7 @@ export function createWorkSessionTurns({
332
399
  : isTerminalSessionLive(job.adopt.id);
333
400
  if (adoptLive) {
334
401
  await settleTurn(job.id, {
402
+ ...attempt,
335
403
  ok: false,
336
404
  answer:
337
405
  'That terminal session is still open on the machine — close it there first, then adopt.',
@@ -348,29 +416,44 @@ export function createWorkSessionTurns({
348
416
  const dir = placeWtFor(place, adopting ? srcHead : undefined);
349
417
  if (!dir) {
350
418
  await settleTurn(job.id, {
419
+ ...attempt,
351
420
  ok: false,
352
421
  answer:
353
422
  'the session worktree could not be opened on the machine — check the daemon log',
354
423
  });
355
424
  return;
356
425
  }
357
- // A live CLI is ALREADY in this worktree — this daemon's previous
358
- // life, most likely; the lock outlives a restart. Leave the job
359
- // pending and look again next poll; spawning a second CLI would put
360
- // two Claudes in one held context. Costs no attempt: nothing ran.
426
+ // A live CLI is ALREADY in this tab's conversation — this daemon's
427
+ // previous life, most likely; the lock outlives a restart. Leave the
428
+ // job pending and look again next poll; spawning a second CLI would
429
+ // put two Claudes in one held context. Costs no attempt: nothing ran.
361
430
  // …AND ONE OF OURS MAY BE ABOUT TO: `startingIn`, checked and
362
431
  // claimed with no await between, because the file is only written
363
- // at spawn. Keyed by the lock's path (the directory, when the git
364
- // dir cannot be named) — the file lock's own scope.
365
- const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
366
- const startKey = lockPath || dir.wt;
432
+ // at spawn. Keyed by the lock's path (the directory and the tab,
433
+ // when the git dir cannot be named) — the file lock's own scope.
434
+ // PER TAB (2026-10-14, turnLock.mjs): other tabs in this directory
435
+ // run beside it. The unscoped file is an older daemon's CLI, whose
436
+ // tab is unknown, so it holds every tab here until it exits.
437
+ const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock', job.sessionId);
438
+ const legacyLock = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
439
+ const startKey = lockPath || `${dir.wt}\0${job.sessionId}`;
440
+ const tabId = printable(job.sessionId);
441
+ const tabLabel = `tab "${printable(job.sessionName) || tabId}" (${tabId})`;
442
+ if (turnLockedByLivePid(legacyLock)) {
443
+ reportDelivery('session', job, 'queued', 'waiting for another tab’s turn to finish (legacy lock; tab unknown)');
444
+ warn(
445
+ `a turn from an earlier flowviant holds the place; tab unknown (legacy lock) — ${tabLabel} waits for it to finish`
446
+ );
447
+ return;
448
+ }
367
449
  if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
450
+ reportDelivery('session', job, 'queued', 'waiting for this turn to finish');
368
451
  warn(
369
- `a turn is already running in "${printable(job.sessionName) || job.sessionId}" — waiting for it to finish`
452
+ `${startingIn.get(startKey) || tabLabel} holds the turn lock — waiting for it to finish`
370
453
  );
371
454
  return;
372
455
  }
373
- startingIn.add(startKey);
456
+ startingIn.set(startKey, tabLabel);
374
457
  startClaim = startKey;
375
458
  // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
376
459
  // which is what every tab ran on until now) — honored by
@@ -379,12 +462,17 @@ export function createWorkSessionTurns({
379
462
  // What the artifact directory held BEFORE this turn — the capture chat
380
463
  // excepted: it runs read-only, is never told about artifacts, and
381
464
  // whatever sibling tabs wrote in this shared place is theirs.
382
- if (job.capture !== true) artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt) };
465
+ if (job.capture !== true) {
466
+ artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt), reported: false };
467
+ artifactScan.instructions = getArtifactsAccepted()
468
+ ? await artifacts.prepare?.({ placeDir: dir.wt, before: artifactScan.before, sessionId: job.sessionId }) ?? '' : '';
469
+ }
383
470
  const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
384
471
  if (rt.mismatch) {
385
472
  // Something upstream changed this tab's identity mid-life. A held
386
473
  // context must never be answered by a different brain — say so.
387
474
  await settleTurn(job.id, {
475
+ ...attempt,
388
476
  ok: false,
389
477
  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
478
  });
@@ -392,6 +480,7 @@ export function createWorkSessionTurns({
392
480
  }
393
481
  if (rt.missing) {
394
482
  await settleTurn(job.id, {
483
+ ...attempt,
395
484
  ok: false,
396
485
  answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
397
486
  });
@@ -404,6 +493,7 @@ export function createWorkSessionTurns({
404
493
  // whole control plane rides one). An honest sentence beats the
405
494
  // mcpFor throw this used to crash into every turn.
406
495
  await settleTurn(job.id, {
496
+ ...attempt,
407
497
  ok: false,
408
498
  answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
409
499
  });
@@ -411,6 +501,7 @@ export function createWorkSessionTurns({
411
501
  }
412
502
  if (!rt.id) {
413
503
  await settleTurn(job.id, {
504
+ ...attempt,
414
505
  ok: false,
415
506
  answer:
416
507
  'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
@@ -428,7 +519,7 @@ export function createWorkSessionTurns({
428
519
  */
429
520
  const shape = sessionTurnShape({ runtime: rt.id, adopting, job });
430
521
  if (shape.refusal) {
431
- await settleTurn(job.id, { ok: false, answer: shape.refusal });
522
+ await settleTurn(job.id, { ...attempt, ok: false, answer: shape.refusal });
432
523
  return;
433
524
  }
434
525
  // A PLAIN tab (agy) mounts no MCP: no credential to mint.
@@ -447,6 +538,7 @@ export function createWorkSessionTurns({
447
538
  }
448
539
  if (mint?.gone) {
449
540
  await settleTurn(job.id, {
541
+ ...attempt,
450
542
  ok: false,
451
543
  answer:
452
544
  'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
@@ -455,6 +547,7 @@ export function createWorkSessionTurns({
455
547
  }
456
548
  if (!mint?.token) {
457
549
  await settleTurn(job.id, {
550
+ ...attempt,
458
551
  ok: false,
459
552
  answer:
460
553
  'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
@@ -598,7 +691,7 @@ export function createWorkSessionTurns({
598
691
  toolLog = turnLog.toolLog;
599
692
  const { pushToolEvent } = turnLog;
600
693
  const narrator = makeNarrator(job.sessionId, job.id, () =>
601
- toolLog.ev.length > 0 ? toolLog : undefined
694
+ toolLog
602
695
  );
603
696
 
604
697
 
@@ -623,6 +716,17 @@ export function createWorkSessionTurns({
623
716
  .filter(Boolean)
624
717
  .join('\n\n');
625
718
  const turnArgs = {
719
+ ...(rt.id === 'claude' ? { liveMessages: (run.live = makeLiveMessages({
720
+ scope: 'session', turnId: job.id, attempt: job.attempt, cwd: dir.wt, sessionMetaPath,
721
+ interrupted: () => run.interrupted,
722
+ format: async (followup) => {
723
+ const paths = await fetchAttachments(dir.wt, followup.attachments);
724
+ const note = paths.length ? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${paths.map((f) => `- ${f}`).join('\n')}` : '';
725
+ const words = shape.capture ? (followup.libraryPrompt ?? '') + followup.body : followup.body + (followup.libraryPrompt ?? '');
726
+ return sessionTurnKickoff(shape, { sessionId: job.sessionId, sessionName: job.sessionName,
727
+ askedByName: followup.askedByName, message: [words, note].filter(Boolean).join('\n\n') });
728
+ },
729
+ })) } : {}),
626
730
  // The kickoff under the turn's contract: a plain tab (or a plan
627
731
  // turn) has no tools to name and no session id to pass, and asks
628
732
  // for one complete report instead of a stream; a capture chat
@@ -632,6 +736,8 @@ export function createWorkSessionTurns({
632
736
  sessionName: job.sessionName,
633
737
  message,
634
738
  askedByName: job.askedByName,
739
+ // INTERRUPT NOW: the previous run was stopped to send this.
740
+ interrupted: tookStop(job.sessionId) || job.interrupted === true,
635
741
  }),
636
742
  // ONE PROFILE (turnProfile.mjs), the shape's: `plan` for the
637
743
  // capture chat, `plan-mode` for a plan turn, else `build`.
@@ -647,6 +753,7 @@ export function createWorkSessionTurns({
647
753
  // library that synced a second ago is in THIS turn.
648
754
  ...(() => {
649
755
  const knowledgeDir = knowledgeDirFor(repoRoot);
756
+ if (job.capture === true) toolLog.baseline = measureRepositoryBaseline({ cwd: dir.wt, baseRef, knowledgeDir });
650
757
  return {
651
758
  // The shape's contract (a plan turn: the PLAIN one, no
652
759
  // artifacts paragraph, the planning sentence appended). The
@@ -655,7 +762,8 @@ export function createWorkSessionTurns({
655
762
  system: sessionTurnSystem(shape, {
656
763
  knowledgeDir,
657
764
  artifactsAccepted: getArtifactsAccepted(),
658
- }),
765
+ runtime: rt.id,
766
+ }) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : ''),
659
767
  // The directory is OUTSIDE a worktree's cwd; Claude Code is
660
768
  // told it may read there (`--add-dir`) rather than left to
661
769
  // refuse a read-only capture turn a path it was just handed.
@@ -672,7 +780,8 @@ export function createWorkSessionTurns({
672
780
  answerFromResult: true,
673
781
  onActivity: (a) => {
674
782
  narrator.line(a?.label);
675
- auditCommand(a);
783
+ // Claude/Codex commands are audited below with their correlated outcome.
784
+ if (rt.id !== 'claude' && rt.id !== 'codex') auditCommand(a);
676
785
  if (a?.kind === 'tool' && APPROVAL_REFUSED.test(a.error ?? '')) approvalRefused.push(a.label);
677
786
  },
678
787
  // One Codex agent MESSAGE (runtimeEvents.mjs `answer`): the turn
@@ -681,7 +790,13 @@ export function createWorkSessionTurns({
681
790
  if (String(a ?? '').trim()) codexSpoke = true;
682
791
  },
683
792
  // The structured twin of the line above — see toolLog.
684
- onToolEvent: pushToolEvent,
793
+ onToolEvent: (name, input, meta) => {
794
+ const e = pushToolEvent(name, input, meta);
795
+ if (e?.t === 'bash') auditCommand({
796
+ kind: 'bash', command: input?.command ?? e.c,
797
+ observation: { id: e.id, outcome: e.outcome, exitCode: e.exitCode, errorCategory: e.errorCategory },
798
+ });
799
+ },
685
800
  // The CLI's own token counts, tagged with which CLI counted them
686
801
  // — ADDED, not set: both runTurn calls below share these args.
687
802
  onUsage: (u) => {
@@ -696,13 +811,15 @@ export function createWorkSessionTurns({
696
811
  // a tab's build turn is the machine's measurement; its fenced
697
812
  // capture chat is not.
698
813
  onInit: (i) => {
699
- // The conversation this turn is actually speaking under. Held
700
- // and persisted after the turn ends, so the NEXT one resumes
701
- // this exact thread rather than whatever the directory saw
702
- // last. Last write wins on purpose: a resume that fell back to
703
- // fresh reports the fresh id, healing the marker.
704
- if (typeof i.sessionId === 'string' && i.sessionId.trim())
814
+ // Pin at init so a restart during a long tool can resume the
815
+ // same conversation and recover its native message UUIDs.
816
+ if (typeof i.sessionId === 'string' && i.sessionId.trim()) {
705
817
  seenClaudeSession = i.sessionId.trim();
818
+ if (CLAUDE_SESSION_RE.test(seenClaudeSession)) {
819
+ const marker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
820
+ if (marker) try { writeFileSync(marker, seenClaudeSession); } catch { /* best effort */ }
821
+ }
822
+ }
706
823
  },
707
824
  cwd: dir.wt,
708
825
  mcpArgs: mcp.args,
@@ -721,7 +838,11 @@ export function createWorkSessionTurns({
721
838
  },
722
839
  onSpawn: (ch) => {
723
840
  if (!ch) return;
841
+ if (rt.id !== 'claude') reportDelivery('session', job, 'taken');
724
842
  spawned.push(ch);
843
+ run.spawned.push(ch);
844
+ // Asked to stop between the check and the spawn.
845
+ if (run.interrupted) killGroup(ch);
725
846
  // Keyed to the SESSION it serves — that id is what the machine
726
847
  // snapshot charges this child's memory to.
727
848
  workChildren.set(ch, job.sessionId);
@@ -745,7 +866,7 @@ export function createWorkSessionTurns({
745
866
  }
746
867
  },
747
868
  };
748
- out = await runTurn({
869
+ out = await runCli({
749
870
  ...turnArgs,
750
871
  resume,
751
872
  resumeThreadId: codexResumeId || claudeResumeId || undefined,
@@ -786,7 +907,7 @@ export function createWorkSessionTurns({
786
907
  !adopting &&
787
908
  resumeRetriesFresh({ resume, out, runtime: rt.id, sawInit: Boolean(seenClaudeSession) })
788
909
  ) {
789
- out = await runTurn({ ...turnArgs, resume: false });
910
+ out = await runCli({ ...turnArgs, resume: false });
790
911
  }
791
912
  } finally {
792
913
  // The CLI has stopped printing, so stop relaying. The LINE itself
@@ -809,24 +930,6 @@ export function createWorkSessionTurns({
809
930
  // before it ever touches disk — it later rides in argv as
810
931
  // `resume <id>` — and best-effort, like the runtime pin: an
811
932
  // unwritable marker just means the tab runs fresh next turn.
812
- // The conversation THIS TAB just spoke under, pinned so the next
813
- // turn resumes it by id rather than asking the directory. Written
814
- // after the turn for the same reason codex's is: an id learned
815
- // mid-turn is only true once the turn that learned it finished.
816
- if (
817
- rt.id === 'claude' &&
818
- seenClaudeSession &&
819
- CLAUDE_SESSION_RE.test(seenClaudeSession)
820
- ) {
821
- const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
822
- if (convMarker) {
823
- try {
824
- writeFileSync(convMarker, seenClaudeSession);
825
- } catch {
826
- /* best-effort — the next turn re-learns it */
827
- }
828
- }
829
- }
830
933
  if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
831
934
  const threadMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId);
832
935
  if (threadMarker) {
@@ -837,6 +940,21 @@ export function createWorkSessionTurns({
837
940
  }
838
941
  }
839
942
  }
943
+ /**
944
+ * A PERSON INTERRUPTED IT. Whatever the CLI printed was cut off, so
945
+ * it is not an answer: the turn settles `interrupted`, with what it
946
+ * spent and did, and the message sent to interrupt it runs next in
947
+ * this same conversation — the markers above already hold its id.
948
+ * Never spawned, it settles `ran: false`, unremembered, so the
949
+ * server's re-offer runs it as it was.
950
+ */
951
+ if (run.interrupted) {
952
+ const body = { ...attempt, ok: false, interrupted: true, ran: run.spawned.length > 0, answer: 'the turn was interrupted', ...spent() };
953
+ if (body.ran) stoppedIn.set(job.sessionId, now());
954
+ await (body.ran ? settleTurn(job.id, body) : settleWorkTurn(job.id, body));
955
+ ok(`${c.cyan('tab')} ${c.dim('— interrupted')}`);
956
+ return;
957
+ }
840
958
  const answer = (out || '').trim();
841
959
  // No output at all smells like a dead MCP credential (the lane
842
960
  // workers' no-sentinel case) — drop the cached token so the next
@@ -848,6 +966,7 @@ export function createWorkSessionTurns({
848
966
  // exactly that; no sessionRef is recorded, so the server keeps
849
967
  // offering the adoption and a retry after the user checks is cheap.
850
968
  await settleTurn(job.id, {
969
+ ...attempt,
851
970
  ok: false,
852
971
  answer: "Couldn't resume the terminal session — it may have been removed.",
853
972
  // Whatever it DID before coming back empty is exactly the
@@ -897,10 +1016,20 @@ export function createWorkSessionTurns({
897
1016
  */
898
1017
  const loginFailure = rt.id === 'codex' && !codexSpoke ? codexLoginFailure(answer) : null;
899
1018
  const refusedLine = rt.id === 'codex' ? codexApprovalSentence(approvalRefused) : null;
1019
+ let artifactFailure = null;
1020
+ if (artifactScan) {
1021
+ artifactScan.reported = true;
1022
+ try {
1023
+ await artifacts.report({ placeDir: artifactScan.dir, before: artifactScan.before, sessionId: job.sessionId, turnId: job.id });
1024
+ } catch (error) {
1025
+ artifactFailure = `Artifact delivery is incomplete: ${error.message}`;
1026
+ }
1027
+ }
900
1028
  const spoke = answer.length > 0 && !loginFailure;
901
1029
  await settleTurn(job.id, {
902
- ok: spoke,
903
- answer: loginFailure
1030
+ ...attempt,
1031
+ ok: spoke && !artifactFailure,
1032
+ answer: artifactFailure ? withRelay(artifactFailure, answer) : loginFailure
904
1033
  ? withRelay(codexLoginSentence(loginFailure), answer)
905
1034
  : answer.length > 0
906
1035
  ? refusedLine
@@ -921,7 +1050,14 @@ export function createWorkSessionTurns({
921
1050
  else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
922
1051
  else warn('session turn produced no output — settled as failed');
923
1052
  } catch (e) {
1053
+ if (toolLog) toolLog.state = 'partial';
1054
+ if (run.interrupted) {
1055
+ const body = { ...attempt, ok: false, interrupted: true, ran: run.spawned.length > 0, answer: 'the turn was interrupted', ...spent() };
1056
+ await (body.ran ? settleTurn(job.id, body) : settleWorkTurn(job.id, body));
1057
+ return;
1058
+ }
924
1059
  await settleTurn(job.id, {
1060
+ ...attempt,
925
1061
  ok: false,
926
1062
  // Scrub, like every string that leaves this machine: an exception
927
1063
  // routinely quotes command output, and command output can quote a
@@ -935,6 +1071,7 @@ export function createWorkSessionTurns({
935
1071
  warn(`session turn failed: ${e?.message ?? e}`);
936
1072
  } finally {
937
1073
  workAnswering.delete(job.id);
1074
+ runs.delete(job.id);
938
1075
  if (startClaim) startingIn.delete(startClaim);
939
1076
  // Nothing spawned, or everything already has: releasing twice is the
940
1077
  // normal case and costs nothing. A reservation that leaked would
@@ -950,7 +1087,7 @@ export function createWorkSessionTurns({
950
1087
  // …and relay what it wrote to SHOW its owner (2026-09-22) — the same
951
1088
  // beat, the same reason it is not awaited. A failed turn's half-drawn
952
1089
  // page is still what the turn left, and the tab should see it.
953
- if (artifactScan) {
1090
+ if (artifactScan && !artifactScan.reported) {
954
1091
  void artifacts
955
1092
  .report({
956
1093
  placeDir: artifactScan.dir,
@@ -961,9 +1098,39 @@ export function createWorkSessionTurns({
961
1098
  .catch(() => {});
962
1099
  }
963
1100
  }
1101
+ }, () => {
1102
+ run.waitingReason = 'waiting for a ship to finish';
1103
+ reportDelivery('session', job, 'queued', run.waitingReason);
964
1104
  });
965
1105
  }
966
1106
  };
967
1107
 
968
- return { processWorkTurns, workAnswering, settledHere };
1108
+ /**
1109
+ * THE RUNS A PERSON ASKED TO STOP (`roster.interruptTurns`, read before the
1110
+ * jobs). One this lane is running is stopped — its process groups killed —
1111
+ * and settles `interrupted` when its CLI is gone. One it finished already
1112
+ * keeps its own settle. One it is not running at all is answered `ran:
1113
+ * false`: nothing was running here to stop, and the server puts it back in
1114
+ * the queue.
1115
+ */
1116
+ const interruptWorkTurns = (ids) => {
1117
+ rosterInterrupts = new Set(Array.isArray(ids) ? ids : []);
1118
+ const at = now();
1119
+ for (const [id, when] of notRunningSaid) if (at - when > 30_000) notRunningSaid.delete(id);
1120
+ for (const id of Array.isArray(ids) ? ids : []) {
1121
+ if (typeof id !== 'string' || !id) continue;
1122
+ const run = runs.get(id);
1123
+ if (run) {
1124
+ if (!run.interrupted) note(`${c.cyan('tab')} ${c.dim('— interrupting the running turn')}`);
1125
+ run.interrupted = true;
1126
+ for (const ch of run.spawned) killGroup(ch);
1127
+ continue;
1128
+ }
1129
+ if (pendingWorkReports.has(id) || settledRecently(id) || notRunningSaid.has(id)) continue;
1130
+ notRunningSaid.set(id, at);
1131
+ void Promise.resolve(settleWorkTurn(id, { ok: false, interrupted: true, ran: false, answer: '' })).catch(() => {});
1132
+ }
1133
+ };
1134
+
1135
+ return { processWorkTurns, interruptWorkTurns, workAnswering, settledHere };
969
1136
  }
@@ -7,7 +7,7 @@ import { gitMergeIn } from './gitMergeIdentity.mjs';
7
7
  import { c, note, ok, warn } from './ui.mjs';
8
8
  import { scrub as envScrub } from './uplinkScrub.mjs';
9
9
  import { REPO_PLACE } from './workPlaces.mjs';
10
- import { turnLockedByLivePid } from './turnLock.mjs';
10
+ import { placeTurnLockHolder } from './turnLock.mjs';
11
11
  import { printable } from './printable.mjs';
12
12
 
13
13
  export function createWorkShipper({
@@ -227,11 +227,24 @@ export function createWorkShipper({
227
227
  ok(`${c.cyan('ship')} ${c.dim('— already on main; nothing new to merge')}`);
228
228
  return;
229
229
  }
230
+ // Any tab's orphaned CLI holds this shared HEAD across a restart.
231
+ // This also applies when the SHIPPING tab is closed: another tab
232
+ // can still be editing its place. Query the directory without
233
+ // re-cutting a retired worktree, after the idempotent receipt path.
234
+ const holder = placeTurnLockHolder(sessionMetaPath(wt, 'flowviant-turn.lock'));
235
+ if (holder) {
236
+ const holderTab = holder.sessionId === null ? 'tab unknown (legacy lock)' : `tab "${printable(holder.sessionId)}"`;
237
+ warn(
238
+ `${holderTab} holds the turn lock — ship of "${sessionName || job.sessionId}" waits for it`
239
+ );
240
+ deferred = true;
241
+ return;
242
+ }
230
243
  const ended = liveIds ? !liveIds.has(job.sessionId) : false;
231
244
  if (ended) {
232
- // The tab is closed: no turn can commit, discard, or resolve
233
- // anything in the directory, so a dirty worktree must not strand
234
- // the branch's committed work in review forever. Ship the TIP.
245
+ // The tab is closed: it cannot commit, discard, or resolve, so
246
+ // a dirty worktree must not strand its committed work in review
247
+ // forever. With every other tab's CLI quiet, ship the TIP.
235
248
  if (!branchExists) {
236
249
  await done({
237
250
  ok: false,
@@ -284,18 +297,6 @@ export function createWorkShipper({
284
297
  });
285
298
  return;
286
299
  }
287
- // A live CLI is in this worktree — a restarted daemon's orphan
288
- // mid-turn (in-process the chain serializes, but the lock is the
289
- // only guarantee that survives a crash). Folding under it would
290
- // rewrite HEAD inside a held conversation; defer like the turn
291
- // path, and the job re-offers next poll.
292
- if (turnLockedByLivePid(sessionMetaPath(dir.wt, 'flowviant-turn.lock'))) {
293
- warn(
294
- `a turn is still running in "${sessionName || job.sessionId}" — ship waits for it`
295
- );
296
- deferred = true;
297
- return;
298
- }
299
300
  if (git(['status', '--porcelain'], dir.wt) !== '') {
300
301
  await done({
301
302
  ok: false,