flowviant 0.116.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -12,53 +12,34 @@
12
12
  * the credential (workSessionTokens.mjs), the runtime pin (workSessionRuntime
13
13
  * .mjs), the adopt carry (workAdoptCarry.mjs), attachments
14
14
  * (workAttachments.mjs), the live line (workNarration.mjs), the tool log
15
- * (workToolLog.mjs) and the command audit (workCommandAudit.mjs).
15
+ * (workToolLog.mjs), the command audit (workCommandAudit.mjs) and — pure —
16
+ * what the turn IS, plain, plan, capture or build, with its prompt pair
17
+ * (workTurnShape.mjs, audit 2026-10-10 F159).
16
18
  */
17
19
  import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
- import { git, isSafePathSegment } from './git.mjs';
20
+ import { git } from './git.mjs';
21
+ import { measureRepositoryBaseline } from './repositoryBaseline.mjs';
22
+ import { REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
19
23
  import { processStartTime } from './procRegistry.mjs';
20
24
  import { c, note, ok, warn } from './ui.mjs';
21
25
  import { mcpFor } from './claude.mjs';
22
26
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
23
27
  import { addUsage } from './turnUsage.mjs';
24
- import {
25
- SYSTEM_WORK,
26
- SYSTEM_CAPTURE,
27
- WORK_TURN_KICKOFF,
28
- CAPTURE_TURN_KICKOFF,
29
- SYSTEM_WORK_PLAIN,
30
- WORK_TURN_KICKOFF_PLAIN,
31
- withProjectContext,
32
- } from './prompts.mjs';
33
28
  import { knowledgeDirFor } from './knowledgeFiles.mjs';
34
29
  import { scrub as envScrub } from './uplinkScrub.mjs';
35
- import { recordSkills, recordMcpServers } from './runtimeCapabilities.mjs';
36
30
  import { isTerminalSessionLive } from './claudeSessions.mjs';
37
31
  import { isAgyConversationLive } from './agySessions.mjs';
32
+ import { adoptFence } from './localSessions.mjs';
38
33
  import { resumeRetriesFresh } from './resumeLost.mjs';
39
34
  import { turnLockedByLivePid } from './turnLock.mjs';
35
+ import { killGroup } from './processGroup.mjs';
36
+ import { printable } from './printable.mjs';
40
37
  import { brainFor } from './workBrain.mjs';
41
- import { REPO_PLACE } from './workPlaces.mjs';
42
- import { CODEX_THREAD_RE, AGY_CONV_RE } from './workSessionRuntime.mjs';
38
+ import { AGY_CONV_RE, CLAUDE_SESSION_RE, CODEX_THREAD_RE, RESUME_MARKERS } from './conversationIds.mjs';
39
+ import { sessionTurnKickoff, sessionTurnShape, sessionTurnSystem } from './workTurnShape.mjs';
43
40
  import { createToolLog } from './workToolLog.mjs';
44
41
  import { createCommandAudit } from './workCommandAudit.mjs';
45
- import { APPROVAL_REFUSED, codexApprovalSentence, codexLoginFailure, codexLoginSentence, withRelay } from './codexRelay.mjs';
46
-
47
- /**
48
- * THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
49
- * rather than in prompts.mjs because it is a MODIFIER on whichever contract
50
- * the tab runs, not a contract of its own.
51
- *
52
- * The enforcement is not this sentence — it is `--permission-mode plan`, under
53
- * which the CLI itself refuses every write (claudePosture.mjs, PLAN_MODE_PERM). What
54
- * the sentence buys is the ANSWER: the plain contract above it says "edit
55
- * freely, commit", and a turn told only that would spend itself discovering
56
- * refusals. It also says not to reach for `ExitPlanMode` (measured: disabled
57
- * under `-p`, and the probe turn then asked the person to leave plan mode
58
- * themselves) — leaving plan mode is the person's switch in the tab.
59
- */
60
- export const PLAN_TURN_SENTENCE =
61
- 'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
42
+ import { APPROVAL_REFUSED, codexApprovalSentence, codexLoginFailure, codexLoginSentence, SESSION_ANSWER_MAX, withRelay } from './codexRelay.mjs';
62
43
 
63
44
  /**
64
45
  * HOW LONG, AND HOW MANY, SETTLED TURNS THE LANE REMEMBERS (2026-09-29) — see
@@ -70,6 +51,7 @@ export const SETTLED_HERE_MAX = 500;
70
51
 
71
52
  export function createWorkSessionTurns({
72
53
  repoRoot,
54
+ baseRef = () => null,
73
55
  baseDir,
74
56
  getMcpUrl,
75
57
  getArtifactsAccepted,
@@ -77,7 +59,7 @@ export function createWorkSessionTurns({
77
59
  inPlace,
78
60
  workChildren,
79
61
  workAttempts,
80
- sessionPlaces,
62
+ notePlace,
81
63
  placeOf,
82
64
  placeWtFor,
83
65
  sessionMetaPath,
@@ -106,6 +88,31 @@ export function createWorkSessionTurns({
106
88
  const workAnswering = new Set(); // turn ids currently queued/running here
107
89
  const MAX_WORK_TRIES = 3;
108
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
+
109
116
  /**
110
117
  * A TURN THIS LANE SETTLED IS NEVER RUN AGAIN (2026-09-29).
111
118
  *
@@ -153,10 +160,14 @@ export function createWorkSessionTurns({
153
160
  * and it is released in the turn's outer `finally`, after its settle.
154
161
  */
155
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();
156
166
 
157
167
  const processWorkTurns = (jobs) => {
158
168
  for (const job of jobs ?? []) {
159
169
  if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
170
+ if (rosterInterrupts.has(job.id)) continue;
160
171
  if (workAnswering.has(job.id)) continue;
161
172
  // Settled by this lane already: a roster read from before the settle
162
173
  // landed. See `settledHere`.
@@ -189,20 +200,33 @@ export function createWorkSessionTurns({
189
200
  const releaseSlot = admit.reserve();
190
201
  lastDeferSaid.delete(job.sessionId);
191
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));
192
211
  const place = job.place || job.sessionId;
193
212
  /**
194
- * VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
195
- * the roster's map — this is the OTHER writer of `sessionPlaces`, in the
196
- * same reconcile tick, BEFORE the preview jobs and the sweep read it. An
213
+ * VALIDATED AT THE TRUST BOUNDARY, through the same door `learnPlaces`
214
+ * uses for the roster's map (`notePlace`, workPlaces.mjs) — in the same
215
+ * reconcile tick, BEFORE the preview jobs and the sweep read it. An
197
216
  * unchecked value stored here reaches every `placeDir` consumer: the
198
217
  * turn is spawned in it, `burstListeners` measures it, and a share would
199
218
  * publicly tunnel a directory OUTSIDE the checkout — the exact traversal
200
219
  * the preview feature's port attribution exists to prevent. Settled out
201
220
  * loud rather than skipped, because a silently dropped turn strands the
202
221
  * tab for the server's whole expiry window.
222
+ *
223
+ * Stored on success: remembered for every other beat — the sweep, ship,
224
+ * the preview re-check — so they all ask the same directory this turn
225
+ * runs in.
203
226
  */
204
- if (place !== REPO_PLACE && !isSafePathSegment(place)) {
227
+ if (!notePlace(job.sessionId, place)) {
205
228
  void settleTurn(job.id, {
229
+ ...attempt,
206
230
  ok: false,
207
231
  answer:
208
232
  'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
@@ -210,9 +234,6 @@ export function createWorkSessionTurns({
210
234
  releaseSlot();
211
235
  continue;
212
236
  }
213
- // Remembered for every other beat — the sweep, ship, the preview
214
- // re-check — so they all ask the same directory this turn runs in.
215
- sessionPlaces.set(job.sessionId, place);
216
237
  // A READER: other turns in this place run alongside it. See `inPlace`.
217
238
  inPlace(place, false, async () => {
218
239
  /** This turn's artifact snapshot — set once the place is resolved and
@@ -240,6 +261,20 @@ export function createWorkSessionTurns({
240
261
  * out here beside `usage`, and spread wherever it is. Last write wins
241
262
  * across a fresh retry: that spawn is the one that answered. */
242
263
  let model = null;
264
+ /**
265
+ * WHAT THIS TURN SPENT AND DID, as every settle after a CLI may have
266
+ * run reports it (audit 2026-10-10, F164): its tool log when it has
267
+ * rows, its usage and its model, each only when measured. ONE
268
+ * null-safe spread for the empty adoption, the answer and the catch —
269
+ * the catch's own copy is where the 2026-09-26 ReferenceError lived,
270
+ * and a field added to one copy and not the others drops exactly when
271
+ * a turn fails.
272
+ */
273
+ const spent = () => ({
274
+ ...(toolLog != null ? { tools: toolLog } : {}),
275
+ ...(usage ? { usage } : {}),
276
+ ...(model ? { model } : {}),
277
+ });
243
278
  /** The `startingIn` key this turn claimed — released in the outer
244
279
  * `finally`, and only by the turn that took it. */
245
280
  let startClaim = null;
@@ -249,13 +284,17 @@ export function createWorkSessionTurns({
249
284
  // Out of local tries: SETTLE, don't skip — a silently skipped turn
250
285
  // strands the tab for the server's whole 24h expiry window.
251
286
  await settleTurn(job.id, {
287
+ ...attempt,
252
288
  ok: false,
253
289
  answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
254
290
  });
255
291
  return;
256
292
  }
293
+ // A person's name and their tab's, both member-written, printed on
294
+ // the operator's console: scrubbed (2026-10-10, the audit's F097;
295
+ // see printable.mjs).
257
296
  note(
258
- `${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
297
+ `${c.cyan('tab')} ${c.dim(`— ${printable(job.askedByName) || 'the owner'} in "${printable(job.sessionName) || 'a session'}"`)}`
259
298
  );
260
299
  // ── ADOPTION: a tab born from a TERMINAL session ────────────────
261
300
  // The server sends `adopt {id, cwd}` only while the session has no
@@ -275,6 +314,7 @@ export function createWorkSessionTurns({
275
314
  !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
276
315
  ) {
277
316
  await settleTurn(job.id, {
317
+ ...attempt,
278
318
  ok: false,
279
319
  answer: 'that terminal session id is not one this machine can resume',
280
320
  });
@@ -289,6 +329,7 @@ export function createWorkSessionTurns({
289
329
  }
290
330
  if (!srcCwd) {
291
331
  await settleTurn(job.id, {
332
+ ...attempt,
292
333
  ok: false,
293
334
  answer: "the terminal session's directory no longer exists on the machine",
294
335
  });
@@ -297,29 +338,20 @@ export function createWorkSessionTurns({
297
338
  // Inside the repo, outside the daemon's own worktrees: an adopt
298
339
  // source is a HUMAN's checkout, and one of our directories showing
299
340
  // up here means a stale or confused offer, not a session to fork.
300
- const under = (p, root) =>
301
- p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
302
- let realRoot = repoRoot;
303
- let realBase = baseDir;
304
- try {
305
- realRoot = realpathSync(repoRoot);
306
- } catch {
307
- /* keep the literal path */
308
- }
309
- try {
310
- realBase = realpathSync(baseDir);
311
- } catch {
312
- /* keep the literal path */
313
- }
314
- if (!under(srcCwd, realRoot)) {
341
+ // The SAME fence the offer was made through (`adoptFence`,
342
+ // localSessions.mjs), so the two doors cannot disagree.
343
+ const fenced = adoptFence({ repoRoot, excludeDirs: [baseDir] }).check(srcCwd);
344
+ if (fenced === 'outside') {
315
345
  await settleTurn(job.id, {
346
+ ...attempt,
316
347
  ok: false,
317
348
  answer: "the terminal session's directory is outside this project's repository",
318
349
  });
319
350
  return;
320
351
  }
321
- if (under(srcCwd, realBase)) {
352
+ if (fenced === 'ours') {
322
353
  await settleTurn(job.id, {
354
+ ...attempt,
323
355
  ok: false,
324
356
  answer:
325
357
  "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
@@ -330,6 +362,7 @@ export function createWorkSessionTurns({
330
362
  srcHead = git(['rev-parse', 'HEAD'], srcCwd);
331
363
  } catch {
332
364
  await settleTurn(job.id, {
365
+ ...attempt,
333
366
  ok: false,
334
367
  answer:
335
368
  "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
@@ -347,6 +380,7 @@ export function createWorkSessionTurns({
347
380
  : isTerminalSessionLive(job.adopt.id);
348
381
  if (adoptLive) {
349
382
  await settleTurn(job.id, {
383
+ ...attempt,
350
384
  ok: false,
351
385
  answer:
352
386
  'That terminal session is still open on the machine — close it there first, then adopt.',
@@ -363,6 +397,7 @@ export function createWorkSessionTurns({
363
397
  const dir = placeWtFor(place, adopting ? srcHead : undefined);
364
398
  if (!dir) {
365
399
  await settleTurn(job.id, {
400
+ ...attempt,
366
401
  ok: false,
367
402
  answer:
368
403
  'the session worktree could not be opened on the machine — check the daemon log',
@@ -381,7 +416,7 @@ export function createWorkSessionTurns({
381
416
  const startKey = lockPath || dir.wt;
382
417
  if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
383
418
  warn(
384
- `a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
419
+ `a turn is already running in "${printable(job.sessionName) || job.sessionId}" — waiting for it to finish`
385
420
  );
386
421
  return;
387
422
  }
@@ -394,12 +429,17 @@ export function createWorkSessionTurns({
394
429
  // What the artifact directory held BEFORE this turn — the capture chat
395
430
  // excepted: it runs read-only, is never told about artifacts, and
396
431
  // whatever sibling tabs wrote in this shared place is theirs.
397
- 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
+ }
398
437
  const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
399
438
  if (rt.mismatch) {
400
439
  // Something upstream changed this tab's identity mid-life. A held
401
440
  // context must never be answered by a different brain — say so.
402
441
  await settleTurn(job.id, {
442
+ ...attempt,
403
443
  ok: false,
404
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`,
405
445
  });
@@ -407,6 +447,7 @@ export function createWorkSessionTurns({
407
447
  }
408
448
  if (rt.missing) {
409
449
  await settleTurn(job.id, {
450
+ ...attempt,
410
451
  ok: false,
411
452
  answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
412
453
  });
@@ -419,6 +460,7 @@ export function createWorkSessionTurns({
419
460
  // whole control plane rides one). An honest sentence beats the
420
461
  // mcpFor throw this used to crash into every turn.
421
462
  await settleTurn(job.id, {
463
+ ...attempt,
422
464
  ok: false,
423
465
  answer: `this session runs on ${rt.unsupported}, which cannot drive a Terminal tab on this machine — open a new tab`,
424
466
  });
@@ -426,49 +468,30 @@ export function createWorkSessionTurns({
426
468
  }
427
469
  if (!rt.id) {
428
470
  await settleTurn(job.id, {
471
+ ...attempt,
429
472
  ok: false,
430
473
  answer:
431
474
  'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
432
475
  });
433
476
  return;
434
477
  }
435
- if (adopting && rt.id !== 'claude' && rt.id !== 'antigravity') {
436
- // An adopt id names a conversation in ITS OWN CLI's store: claude
437
- // forks it (--resume --fork-session), agy moves it
438
- // (--conversation). Codex has no adoptable store yet, and its
439
- // args builder backstops this with a loud throw — but a sentence
440
- // here beats a stack there.
441
- await settleTurn(job.id, {
442
- ok: false,
443
- answer:
444
- 'adopting this terminal session needs its own CLI on the machine — install it, then try again',
445
- });
446
- return;
447
- }
448
478
  /**
449
- * A PLAN TURN (0.97.0): the tab's switch, off the job. CLAUDE ONLY —
450
- * `--permission-mode plan` is Claude Code's, and a codex or agy turn
451
- * has no way to spell it, so it is refused HERE in words rather than
452
- * run as a build turn wearing the word. The server never sends the
453
- * key for a non-Claude tab; this is the machine's own half. Never on
454
- * a capture chat, which is read-only by its own profile already.
479
+ * WHAT THIS TURN IS — plain, plan, capture or build — decided ONCE,
480
+ * off the runtime's row and the job's own switches
481
+ * (workTurnShape.mjs, audit 2026-10-10 F159); everything below
482
+ * reads it instead of re-deciding. Its refusal (an adoption this
483
+ * CLI cannot take, then plan mode on a CLI that cannot spell it)
484
+ * settles here, in its words, in the order the lane always said
485
+ * them.
455
486
  */
456
- const planTurn = job.planMode === true && job.capture !== true;
457
- if (planTurn && rt.id !== 'claude') {
458
- await settleTurn(job.id, {
459
- ok: false,
460
- answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
461
- });
487
+ const shape = sessionTurnShape({ runtime: rt.id, adopting, job });
488
+ if (shape.refusal) {
489
+ await settleTurn(job.id, { ...attempt, ok: false, answer: shape.refusal });
462
490
  return;
463
491
  }
464
- // A PLAIN tab (agy) mounts no MCP: no credential to mint, no config
465
- // to write. The trade is stated in SYSTEM_WORK_PLAIN — no cards, no
466
- // streaming — and the honesty survives on the existing rails: the
467
- // answer lands via work-turn-done, the rail says "no card yet", and
468
- // ship-time reconciliation books every branch commit.
469
- const plainTab = rt.id === 'antigravity';
492
+ // A PLAIN tab (agy) mounts no MCP: no credential to mint.
470
493
  let mint = null;
471
- if (!plainTab) {
494
+ if (!shape.plain) {
472
495
  mint = await mintWorkToken(job.sessionId);
473
496
  if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
474
497
  // Another daemon on this credential holds the session. Return
@@ -482,6 +505,7 @@ export function createWorkSessionTurns({
482
505
  }
483
506
  if (mint?.gone) {
484
507
  await settleTurn(job.id, {
508
+ ...attempt,
485
509
  ok: false,
486
510
  answer:
487
511
  'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
@@ -490,6 +514,7 @@ export function createWorkSessionTurns({
490
514
  }
491
515
  if (!mint?.token) {
492
516
  await settleTurn(job.id, {
517
+ ...attempt,
493
518
  ok: false,
494
519
  answer:
495
520
  'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
@@ -528,11 +553,11 @@ export function createWorkSessionTurns({
528
553
  */
529
554
  let claudeResumeId = null;
530
555
  if (rt.id === 'claude') {
531
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
556
+ const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
532
557
  if (convMarker && existsSync(convMarker)) {
533
558
  try {
534
559
  const v = readFileSync(convMarker, 'utf8').trim();
535
- if (/^[A-Za-z0-9_-]{8,64}$/.test(v)) claudeResumeId = v;
560
+ if (CLAUDE_SESSION_RE.test(v)) claudeResumeId = v;
536
561
  } catch {
537
562
  /* unreadable marker — run fresh */
538
563
  }
@@ -540,7 +565,7 @@ export function createWorkSessionTurns({
540
565
  }
541
566
  let codexResumeId = null;
542
567
  if (rt.id === 'codex') {
543
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
568
+ const threadMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId);
544
569
  if (threadMarker && existsSync(threadMarker)) {
545
570
  try {
546
571
  const v = readFileSync(threadMarker, 'utf8').trim();
@@ -558,7 +583,7 @@ export function createWorkSessionTurns({
558
583
  // dispatch sharing the machine could overwrite that between turns.
559
584
  let agyConvId = null;
560
585
  if (rt.id === 'antigravity' && !adopting) {
561
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
586
+ const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.antigravity, job.sessionId);
562
587
  if (convMarker && existsSync(convMarker)) {
563
588
  try {
564
589
  const v = readFileSync(convMarker, 'utf8').trim();
@@ -605,17 +630,13 @@ export function createWorkSessionTurns({
605
630
  const adoptNote = adopting
606
631
  ? '[ADOPTED SESSION — this conversation was brought in from a terminal. Begin your reply with a 2-3 sentence recap of where it left off and what state carried over, then answer the message.]'
607
632
  : '';
608
- // A PLAN TURN RUNS PLAIN TOO (0.97.0): measured on 2.1.281, plan mode
609
- // refuses every `mcp__flowviant` call ("Cannot call … while in plan
610
- // mode") unless the tool is annotated read-only, and none of the
611
- // session tools are — so mounting the control plane would hand the
612
- // turn a list of tools it can only fail at. The credential is still
613
- // minted above: its lease answers (held elsewhere, gone) are about
614
- // the SESSION, and a plan turn needs them as much as any other.
615
- const mcp =
616
- plainTab || planTurn
617
- ? { args: [], env: null, dir: null }
618
- : mcpFor(rt.id, mint.token, getMcpUrl());
633
+ // Mounted only where the shape says (a plain tab and a plan turn run
634
+ // without one — see workTurnShape.mjs). A plan turn's credential is
635
+ // still minted above: its lease answers (held elsewhere, gone) are
636
+ // about the SESSION, and a plan turn needs them as much as any other.
637
+ const mcp = shape.mountMcp
638
+ ? mcpFor(rt.id, mint.token, getMcpUrl())
639
+ : { args: [], env: null, dir: null };
619
640
  // The tab's model/effort, if it named any. Spread into turnArgs so
620
641
  // BOTH runTurn calls below carry it — the retry is the same turn on
621
642
  // the same brain, not a quieter second opinion.
@@ -637,7 +658,7 @@ export function createWorkSessionTurns({
637
658
  toolLog = turnLog.toolLog;
638
659
  const { pushToolEvent } = turnLog;
639
660
  const narrator = makeNarrator(job.sessionId, job.id, () =>
640
- toolLog.ev.length > 0 ? toolLog : undefined
661
+ toolLog
641
662
  );
642
663
 
643
664
 
@@ -661,42 +682,22 @@ export function createWorkSessionTurns({
661
682
  const message = [job.body, filesNote, adoptNote, carryNote]
662
683
  .filter(Boolean)
663
684
  .join('\n\n');
664
- // A CAPTURE chat (the board's New task conversation): its own
665
- // system prompt, its own kickoff, and the scratch planner's
666
- // READ-ONLY permission profile — the prompt says stage-never-file
667
- // and the profile is what makes "read-only" true rather than
668
- // asserted. Server-flagged per job; a server too old to flag it
669
- // simply runs an ordinary tab, which the web's version floor
670
- // prevents ever being offered.
671
- const captureTab = job.capture === true;
672
685
  const turnArgs = {
673
- // A plain tab has no tools to name and no session id to pass —
674
- // its kickoff asks for one complete report instead of a stream.
675
- prompt: plainTab || planTurn
676
- ? WORK_TURN_KICKOFF_PLAIN({
677
- sessionName: job.sessionName,
678
- message,
679
- askedByName: job.askedByName,
680
- })
681
- : captureTab
682
- ? CAPTURE_TURN_KICKOFF({
683
- sessionId: job.sessionId,
684
- sessionName: job.sessionName,
685
- message,
686
- askedByName: job.askedByName,
687
- })
688
- : WORK_TURN_KICKOFF({
689
- sessionId: job.sessionId,
690
- sessionName: job.sessionName,
691
- message,
692
- askedByName: job.askedByName,
693
- }),
694
- // ONE PROFILE (turnProfile.mjs): the capture chat's read-only
695
- // `plan` list; or a plan turn's `plan-mode` — `--permission-mode
696
- // plan` INSTEAD of the build posture, never beside
697
- // `--dangerously-skip-permissions`, which silently wins
698
- // (claudePosture.mjs, PLAN_MODE_PERM); else the build posture.
699
- profile: captureTab ? 'plan' : planTurn ? 'plan-mode' : 'build',
686
+ // The kickoff under the turn's contract: a plain tab (or a plan
687
+ // turn) has no tools to name and no session id to pass, and asks
688
+ // for one complete report instead of a stream; a capture chat
689
+ // restates its posture.
690
+ prompt: sessionTurnKickoff(shape, {
691
+ sessionId: job.sessionId,
692
+ sessionName: job.sessionName,
693
+ message,
694
+ askedByName: job.askedByName,
695
+ // INTERRUPT NOW: the previous run was stopped to send this.
696
+ interrupted: tookStop(job.sessionId) || job.interrupted === true,
697
+ }),
698
+ // ONE PROFILE (turnProfile.mjs), the shape's: `plan` for the
699
+ // capture chat, `plan-mode` for a plan turn, else `build`.
700
+ profile: shape.profile,
700
701
  // The adopt turn resumes the TERMINAL conversation by forking it
701
702
  // into this cwd (claude: --resume <id> --fork-session). After it
702
703
  // speaks once, the fork lives natively here and turn 2+ is the
@@ -708,19 +709,17 @@ export function createWorkSessionTurns({
708
709
  // library that synced a second ago is in THIS turn.
709
710
  ...(() => {
710
711
  const knowledgeDir = knowledgeDirFor(repoRoot);
712
+ if (job.capture === true) toolLog.baseline = measureRepositoryBaseline({ cwd: dir.wt, baseRef, knowledgeDir });
711
713
  return {
712
- // A PLAN TURN (0.97.0) runs the PLAIN contract — it has no
713
- // Flowviant tools, see `mcp` above — with no artifacts
714
- // paragraph (plan mode cannot write one) and the planning
715
- // sentence appended. Every other tab is untouched.
716
- system: planTurn
717
- ? `${withProjectContext(SYSTEM_WORK_PLAIN, { knowledgeDir, artifacts: false })}\n\n${PLAN_TURN_SENTENCE}`
718
- : withProjectContext(
719
- plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
720
- // ARTIFACTS (0.94.0): every tab but the read-only capture
721
- // chat, and only while the server can show one.
722
- { knowledgeDir, artifacts: !captureTab && getArtifactsAccepted() }
723
- ),
714
+ // The shape's contract (a plan turn: the PLAIN one, no
715
+ // artifacts paragraph, the planning sentence appended). The
716
+ // artifacts paragraph also waits on the server — ARTIFACTS
717
+ // (0.94.0): only while it can show one, read here at spawn.
718
+ system: sessionTurnSystem(shape, {
719
+ knowledgeDir,
720
+ artifactsAccepted: getArtifactsAccepted(),
721
+ runtime: rt.id,
722
+ }) + (toolLog.baseline ? '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(toolLog.baseline) : '') + (artifactScan?.instructions ? `\n\n${artifactScan.instructions}` : ''),
724
723
  // The directory is OUTSIDE a worktree's cwd; Claude Code is
725
724
  // told it may read there (`--add-dir`) rather than left to
726
725
  // refuse a read-only capture turn a path it was just handed.
@@ -729,16 +728,16 @@ export function createWorkSessionTurns({
729
728
  })(),
730
729
  // Present only when the tab named one — see brainFor.
731
730
  ...brain,
732
- // The tab watches the CLI work. Claude needs the flag to speak
733
- // events at all (codex and agy always do); `answerFromResult`
734
- // keeps `out` — which IS the reply posted to the transcript — to
735
- // the final result, so streamed prose is narrated once and
736
- // posted once. Every line goes to the narrator above, throttled.
737
- streamJson: true,
731
+ // The tab watches the CLI work (every CLI speaks events);
732
+ // `answerFromResult` keeps `out` — which IS the reply posted to
733
+ // the transcript — to the final result, so streamed prose is
734
+ // narrated once and posted once. Every line goes to the narrator
735
+ // above, throttled.
738
736
  answerFromResult: true,
739
737
  onActivity: (a) => {
740
738
  narrator.line(a?.label);
741
- auditCommand(a);
739
+ // Claude/Codex commands are audited below with their correlated outcome.
740
+ if (rt.id !== 'claude' && rt.id !== 'codex') auditCommand(a);
742
741
  if (a?.kind === 'tool' && APPROVAL_REFUSED.test(a.error ?? '')) approvalRefused.push(a.label);
743
742
  },
744
743
  // One Codex agent MESSAGE (runtimeEvents.mjs `answer`): the turn
@@ -747,7 +746,13 @@ export function createWorkSessionTurns({
747
746
  if (String(a ?? '').trim()) codexSpoke = true;
748
747
  },
749
748
  // The structured twin of the line above — see toolLog.
750
- 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
+ },
751
756
  // The CLI's own token counts, tagged with which CLI counted them
752
757
  // — ADDED, not set: both runTurn calls below share these args.
753
758
  onUsage: (u) => {
@@ -756,16 +761,12 @@ export function createWorkSessionTurns({
756
761
  onModel: (m) => {
757
762
  model = m;
758
763
  },
759
- // What this CLI says it can be asked for by name. Harvested off
760
- // the init event the stream already carries — no probe, no scan,
761
- // no extra spawn — and reported on the next roster poll so the
762
- // composer can autocomplete a `/`. See runtimeCapabilities.mjs for why it is
763
- // learned from a turn rather than looked up.
764
+ // What this CLI says it can be asked for by name, and which of
765
+ // its connectors need a sign-in, are harvested off this init by
766
+ // runTurn itself (`initHarvestFor`, runtimeCapabilities.mjs) —
767
+ // a tab's build turn is the machine's measurement; its fenced
768
+ // capture chat is not.
764
769
  onInit: (i) => {
765
- recordSkills(i.skills);
766
- // The CLI's mounted MCP servers and connectors (0.97.0) — the
767
- // same free fact off the same event; see recordMcpServers.
768
- recordMcpServers(i.mcpServers);
769
770
  // The conversation this turn is actually speaking under. Held
770
771
  // and persisted after the turn ends, so the NEXT one resumes
771
772
  // this exact thread rather than whatever the directory saw
@@ -777,11 +778,9 @@ export function createWorkSessionTurns({
777
778
  cwd: dir.wt,
778
779
  mcpArgs: mcp.args,
779
780
  mcpEnv: mcp.env,
780
- // A PERSON AT THE KEYBOARD MAY ASK FOR A WINDOW (2026-09-29): a
781
- // Terminal tab keeps its environment's display. The capture chat
782
- // does not — it only reads and stages cards, and the person
783
- // talking to it may be on a phone (noWindowEnv.mjs).
784
- display: !captureTab,
781
+ // A Terminal tab keeps its environment's display; the capture
782
+ // chat does not (workTurnShape.mjs).
783
+ display: shape.display,
785
784
  runtime: rt.id,
786
785
  label: c.cyan('[tab]'),
787
786
  // Only codex announces one (thread.started); held here so the id
@@ -794,6 +793,9 @@ export function createWorkSessionTurns({
794
793
  onSpawn: (ch) => {
795
794
  if (!ch) return;
796
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);
797
799
  // Keyed to the SESSION it serves — that id is what the machine
798
800
  // snapshot charges this child's memory to.
799
801
  workChildren.set(ch, job.sessionId);
@@ -817,7 +819,7 @@ export function createWorkSessionTurns({
817
819
  }
818
820
  },
819
821
  };
820
- out = await runTurn({
822
+ out = await runCli({
821
823
  ...turnArgs,
822
824
  resume,
823
825
  resumeThreadId: codexResumeId || claudeResumeId || undefined,
@@ -858,7 +860,7 @@ export function createWorkSessionTurns({
858
860
  !adopting &&
859
861
  resumeRetriesFresh({ resume, out, runtime: rt.id, sawInit: Boolean(seenClaudeSession) })
860
862
  ) {
861
- out = await runTurn({ ...turnArgs, resume: false });
863
+ out = await runCli({ ...turnArgs, resume: false });
862
864
  }
863
865
  } finally {
864
866
  // The CLI has stopped printing, so stop relaying. The LINE itself
@@ -888,9 +890,9 @@ export function createWorkSessionTurns({
888
890
  if (
889
891
  rt.id === 'claude' &&
890
892
  seenClaudeSession &&
891
- /^[A-Za-z0-9_-]{8,64}$/.test(seenClaudeSession)
893
+ CLAUDE_SESSION_RE.test(seenClaudeSession)
892
894
  ) {
893
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
895
+ const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
894
896
  if (convMarker) {
895
897
  try {
896
898
  writeFileSync(convMarker, seenClaudeSession);
@@ -900,7 +902,7 @@ export function createWorkSessionTurns({
900
902
  }
901
903
  }
902
904
  if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
903
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
905
+ const threadMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId);
904
906
  if (threadMarker) {
905
907
  try {
906
908
  writeFileSync(threadMarker, seenThreadId);
@@ -909,6 +911,21 @@ export function createWorkSessionTurns({
909
911
  }
910
912
  }
911
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
+ }
912
929
  const answer = (out || '').trim();
913
930
  // No output at all smells like a dead MCP credential (the lane
914
931
  // workers' no-sentinel case) — drop the cached token so the next
@@ -920,14 +937,13 @@ export function createWorkSessionTurns({
920
937
  // exactly that; no sessionRef is recorded, so the server keeps
921
938
  // offering the adoption and a retry after the user checks is cheap.
922
939
  await settleTurn(job.id, {
940
+ ...attempt,
923
941
  ok: false,
924
942
  answer: "Couldn't resume the terminal session — it may have been removed.",
925
943
  // Whatever it DID before coming back empty is exactly the
926
- // question a failed turn's log answers.
927
- ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
928
- // …and an empty fork still spent what it spent.
929
- ...(usage ? { usage } : {}),
930
- ...(model ? { model } : {}),
944
+ // question a failed turn's log answers — and an empty fork
945
+ // still spent what it spent.
946
+ ...spent(),
931
947
  });
932
948
  warn('adopt turn produced no output — settled as failed');
933
949
  return;
@@ -938,7 +954,7 @@ export function createWorkSessionTurns({
938
954
  // cwd registry. From here on the marker is the tab's identity and
939
955
  // the registry is never trusted again.
940
956
  if (rt.id === 'antigravity' && answer.length > 0) {
941
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
957
+ const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.antigravity, job.sessionId);
942
958
  if (convMarker && !existsSync(convMarker)) {
943
959
  const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
944
960
  if (learned && AGY_CONV_RE.test(learned)) {
@@ -971,16 +987,26 @@ export function createWorkSessionTurns({
971
987
  */
972
988
  const loginFailure = rt.id === 'codex' && !codexSpoke ? codexLoginFailure(answer) : null;
973
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
+ }
974
999
  const spoke = answer.length > 0 && !loginFailure;
975
1000
  await settleTurn(job.id, {
976
- ok: spoke,
977
- answer: loginFailure
1001
+ ...attempt,
1002
+ ok: spoke && !artifactFailure,
1003
+ answer: artifactFailure ? withRelay(artifactFailure, answer) : loginFailure
978
1004
  ? withRelay(codexLoginSentence(loginFailure), answer)
979
1005
  : answer.length > 0
980
1006
  ? refusedLine
981
1007
  ? withRelay(refusedLine, answer)
982
1008
  : // Scrub: a reply can quote config or env-adjacent code.
983
- envScrub(answer).slice(0, 16000)
1009
+ envScrub(answer).slice(0, SESSION_ANSWER_MAX)
984
1010
  : (refusedLine ?? noWords),
985
1011
  // Only a turn that actually SPOKE proves a conversation lives
986
1012
  // here. Recording the path unconditionally is how a crashed first
@@ -989,30 +1015,34 @@ export function createWorkSessionTurns({
989
1015
  // The turn's tool log, in final form — the durable copy that lands
990
1016
  // on the settled message (the live copy on the record is cleared
991
1017
  // at settle). Already scrubbed at collection.
992
- ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
993
- ...(usage ? { usage } : {}),
994
- ...(model ? { model } : {}),
1018
+ ...spent(),
995
1019
  });
996
1020
  if (spoke) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
997
1021
  else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
998
1022
  else warn('session turn produced no output — settled as failed');
999
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
+ }
1000
1030
  await settleTurn(job.id, {
1031
+ ...attempt,
1001
1032
  ok: false,
1002
1033
  // Scrub, like every string that leaves this machine: an exception
1003
1034
  // routinely quotes command output, and command output can quote a
1004
1035
  // synced secret.
1005
1036
  answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
1006
1037
  // "What did it do before it failed" is exactly the question a
1007
- // crashed turn's log answers — same spread as the main settle.
1008
- ...(toolLog?.ev.length > 0 ? { tools: toolLog } : {}),
1009
- // A turn that threw after its CLI ran still spent what it spent.
1010
- ...(usage ? { usage } : {}),
1011
- ...(model ? { model } : {}),
1038
+ // crashed turn's log answers, and a turn that threw after its CLI
1039
+ // ran still spent what it spent — the main settle's own spread.
1040
+ ...spent(),
1012
1041
  });
1013
1042
  warn(`session turn failed: ${e?.message ?? e}`);
1014
1043
  } finally {
1015
1044
  workAnswering.delete(job.id);
1045
+ runs.delete(job.id);
1016
1046
  if (startClaim) startingIn.delete(startClaim);
1017
1047
  // Nothing spawned, or everything already has: releasing twice is the
1018
1048
  // normal case and costs nothing. A reservation that leaked would
@@ -1028,7 +1058,7 @@ export function createWorkSessionTurns({
1028
1058
  // …and relay what it wrote to SHOW its owner (2026-09-22) — the same
1029
1059
  // beat, the same reason it is not awaited. A failed turn's half-drawn
1030
1060
  // page is still what the turn left, and the tab should see it.
1031
- if (artifactScan) {
1061
+ if (artifactScan && !artifactScan.reported) {
1032
1062
  void artifacts
1033
1063
  .report({
1034
1064
  placeDir: artifactScan.dir,
@@ -1043,5 +1073,32 @@ export function createWorkSessionTurns({
1043
1073
  }
1044
1074
  };
1045
1075
 
1046
- 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 };
1047
1104
  }