flowviant 0.103.0 → 0.104.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 (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,892 @@
1
+ /**
2
+ * A WORKBENCH TAB'S TURN — offered, admitted, placed, adopted, pinned to its
3
+ * CLI, credentialed, resumed, run, persisted and settled.
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). This is ONE lifecycle and it
6
+ * stays one function on purpose — the order of its refusals IS the contract
7
+ * (no place, no worktree, a live lock, the wrong brain, no credential: each
8
+ * settles in words or waits, before anything is spawned), and pulling its
9
+ * steps apart would scatter that order across files. What it USES and does not
10
+ * own went to siblings, each named at the factory call in work.mjs: the place
11
+ * and its markers (workPlaces.mjs), the settle queue (workReportQueue.mjs),
12
+ * the credential (workSessionTokens.mjs), the runtime pin (workSessionRuntime
13
+ * .mjs), the adopt carry (workAdoptCarry.mjs), attachments
14
+ * (workAttachments.mjs), the live line (workNarration.mjs), the tool log
15
+ * (workToolLog.mjs) and the command audit (workCommandAudit.mjs).
16
+ */
17
+ import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
+ import { git, isSafePathSegment } from './git.mjs';
19
+ import { processStartTime } from './procRegistry.mjs';
20
+ import { c, note, ok, warn } from './ui.mjs';
21
+ import { mcpFor } from './claude.mjs';
22
+ import { runTurn } from './runTurn.mjs';
23
+ import {
24
+ SYSTEM_WORK,
25
+ SYSTEM_CAPTURE,
26
+ WORK_TURN_KICKOFF,
27
+ CAPTURE_TURN_KICKOFF,
28
+ SYSTEM_WORK_PLAIN,
29
+ WORK_TURN_KICKOFF_PLAIN,
30
+ withProjectContext,
31
+ } from './prompts.mjs';
32
+ import { knowledgeDirFor } from './knowledgeFiles.mjs';
33
+ import { scrub as envScrub } from './uplinkScrub.mjs';
34
+ import { recordSkills, recordMcpServers } from './runtimeCapabilities.mjs';
35
+ import { isTerminalSessionLive } from './claudeSessions.mjs';
36
+ import { isAgyConversationLive } from './agySessions.mjs';
37
+ import { resumeRetriesFresh } from './resumeLost.mjs';
38
+ import { turnLockedByLivePid } from './turnLock.mjs';
39
+ import { brainFor } from './workBrain.mjs';
40
+ import { REPO_PLACE } from './workPlaces.mjs';
41
+ import { CODEX_THREAD_RE, AGY_CONV_RE } from './workSessionRuntime.mjs';
42
+ import { createToolLog } from './workToolLog.mjs';
43
+ import { createCommandAudit } from './workCommandAudit.mjs';
44
+
45
+ /**
46
+ * THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
47
+ * rather than in prompts.mjs because it is a MODIFIER on whichever contract
48
+ * the tab runs, not a contract of its own.
49
+ *
50
+ * The enforcement is not this sentence — it is `--permission-mode plan`, under
51
+ * which the CLI itself refuses every write (claudePosture.mjs, PLAN_MODE_PERM). What
52
+ * the sentence buys is the ANSWER: the plain contract above it says "edit
53
+ * freely, commit", and a turn told only that would spend itself discovering
54
+ * refusals. It also says not to reach for `ExitPlanMode` (measured: disabled
55
+ * under `-p`, and the probe turn then asked the person to leave plan mode
56
+ * themselves) — leaving plan mode is the person's switch in the tab.
57
+ */
58
+ export const PLAN_TURN_SENTENCE =
59
+ '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.';
60
+
61
+ export function createWorkSessionTurns({
62
+ repoRoot,
63
+ baseDir,
64
+ getMcpUrl,
65
+ getArtifactsAccepted,
66
+ admit,
67
+ inPlace,
68
+ workChildren,
69
+ workAttempts,
70
+ sessionPlaces,
71
+ placeOf,
72
+ placeWtFor,
73
+ sessionMetaPath,
74
+ beforeArtifacts,
75
+ artifacts,
76
+ settleWorkTurn,
77
+ pendingWorkReports,
78
+ workTokens,
79
+ mintWorkToken,
80
+ sessionRuntime,
81
+ agyRegistryLookup,
82
+ carryDirtyState,
83
+ fetchAttachments,
84
+ makeNarrator,
85
+ sayTurnDeferred,
86
+ lastDeferSaid,
87
+ noteSessionGroup,
88
+ reportPlaceWorktrees,
89
+ burstListeners,
90
+ }) {
91
+ const workAnswering = new Set(); // turn ids currently queued/running here
92
+ const MAX_WORK_TRIES = 3;
93
+
94
+ const processWorkTurns = (jobs) => {
95
+ for (const job of jobs ?? []) {
96
+ if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
97
+ if (workAnswering.has(job.id)) continue;
98
+ // The turn already RAN and its answer sits in the delivery queue — never
99
+ // run it again while the report is merely undelivered.
100
+ if (pendingWorkReports.has(job.id)) continue;
101
+ /**
102
+ * THE BOX IS ABOUT TO FALL OVER, OR THIS MACHINE IS ALREADY AT ITS
103
+ * CEILING. Defer: return without settling and without consuming an
104
+ * attempt — the shape the live-CLI lock below uses, for the same reason.
105
+ * The job stays pending and the server re-offers it next poll; settling
106
+ * it would tell the human their message failed when nothing ran.
107
+ *
108
+ * `interactive`, not `churn`: somebody is watching a composer they just
109
+ * pressed enter in, so this holds out until the box is genuinely about to
110
+ * die rather than yielding early the way the unattended lanes do.
111
+ *
112
+ * AND THE SLOT IS RESERVED BEFORE THE NEXT ITERATION ASKS. This loop is
113
+ * synchronous and every spawn under it is not — `inPlace` resolves its
114
+ * callback in a later microtask — so `liveTurnCount()` could not move
115
+ * between jobs and a roster offering eight turns admitted all eight
116
+ * against a ceiling of one. See admission.mjs.
117
+ */
118
+ const hold = admit('interactive');
119
+ if (hold) {
120
+ sayTurnDeferred(job.sessionId, job.id, hold.reason);
121
+ continue;
122
+ }
123
+ const releaseSlot = admit.reserve();
124
+ lastDeferSaid.delete(job.sessionId);
125
+ workAnswering.add(job.id);
126
+ const place = job.place || job.sessionId;
127
+ /**
128
+ * VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
129
+ * the roster's map — this is the OTHER writer of `sessionPlaces`, in the
130
+ * same reconcile tick, BEFORE the preview jobs and the sweep read it. An
131
+ * unchecked value stored here reaches every `placeDir` consumer: the
132
+ * turn is spawned in it, `burstListeners` measures it, and a share would
133
+ * publicly tunnel a directory OUTSIDE the checkout — the exact traversal
134
+ * the preview feature's port attribution exists to prevent. Settled out
135
+ * loud rather than skipped, because a silently dropped turn strands the
136
+ * tab for the server's whole expiry window.
137
+ */
138
+ if (place !== REPO_PLACE && !isSafePathSegment(place)) {
139
+ void settleWorkTurn(job.id, {
140
+ ok: false,
141
+ answer:
142
+ 'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
143
+ }).finally(() => workAnswering.delete(job.id));
144
+ releaseSlot();
145
+ continue;
146
+ }
147
+ // Remembered for every other beat — the sweep, ship, the preview
148
+ // re-check — so they all ask the same directory this turn runs in.
149
+ sessionPlaces.set(job.sessionId, place);
150
+ // A READER: other turns in this place run alongside it. See `inPlace`.
151
+ inPlace(place, false, async () => {
152
+ /** This turn's artifact snapshot — set once the place is resolved and
153
+ * a CLI is about to spawn there; read in the `finally`. Null on every
154
+ * path that never ran a CLI, which is exactly when nothing is new. */
155
+ let artifactScan = null;
156
+ /** This turn's tool log — declared HERE, outside the `try`, because
157
+ * the `catch` below spreads it into the failed settle. Declared inside,
158
+ * the catch threw a ReferenceError instead of settling: the error's
159
+ * words were lost and the turn was re-offered and re-run. Null until
160
+ * the turn gets as far as building one. */
161
+ let toolLog = null;
162
+ try {
163
+ const tries = workAttempts.get(job.id) ?? 0;
164
+ if (tries >= MAX_WORK_TRIES) {
165
+ // Out of local tries: SETTLE, don't skip — a silently skipped turn
166
+ // strands the tab for the server's whole 24h expiry window.
167
+ await settleWorkTurn(job.id, {
168
+ ok: false,
169
+ answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
170
+ });
171
+ return;
172
+ }
173
+ note(
174
+ `${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
175
+ );
176
+ // ── ADOPTION: a tab born from a TERMINAL session ────────────────
177
+ // The server sends `adopt {id, cwd}` only while the session has no
178
+ // sessionRef — no turn has ever spoken from a worktree here — and
179
+ // the first turn resumes the terminal conversation by forking it
180
+ // into the tab's own worktree. Everything the server asserts is
181
+ // re-validated MACHINE-side: the id shape, the source directory,
182
+ // and — decisive — that the terminal is actually closed, because
183
+ // forking a session someone is still typing into puts two Claudes
184
+ // on one conversation.
185
+ const adopting = Boolean(job.adopt) && !job.sessionRef;
186
+ let srcHead = null;
187
+ let adoptSrc = null; // the validated, realpath'd source checkout
188
+ if (adopting) {
189
+ if (
190
+ typeof job.adopt.id !== 'string' ||
191
+ !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
192
+ ) {
193
+ await settleWorkTurn(job.id, {
194
+ ok: false,
195
+ answer: 'that terminal session id is not one this machine can resume',
196
+ });
197
+ return;
198
+ }
199
+ let srcCwd = null;
200
+ try {
201
+ srcCwd = realpathSync(String(job.adopt.cwd ?? ''));
202
+ if (!statSync(srcCwd).isDirectory()) srcCwd = null;
203
+ } catch {
204
+ srcCwd = null;
205
+ }
206
+ if (!srcCwd) {
207
+ await settleWorkTurn(job.id, {
208
+ ok: false,
209
+ answer: "the terminal session's directory no longer exists on the machine",
210
+ });
211
+ return;
212
+ }
213
+ // Inside the repo, outside the daemon's own worktrees: an adopt
214
+ // source is a HUMAN's checkout, and one of our directories showing
215
+ // up here means a stale or confused offer, not a session to fork.
216
+ const under = (p, root) =>
217
+ p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
218
+ let realRoot = repoRoot;
219
+ let realBase = baseDir;
220
+ try {
221
+ realRoot = realpathSync(repoRoot);
222
+ } catch {
223
+ /* keep the literal path */
224
+ }
225
+ try {
226
+ realBase = realpathSync(baseDir);
227
+ } catch {
228
+ /* keep the literal path */
229
+ }
230
+ if (!under(srcCwd, realRoot)) {
231
+ await settleWorkTurn(job.id, {
232
+ ok: false,
233
+ answer: "the terminal session's directory is outside this project's repository",
234
+ });
235
+ return;
236
+ }
237
+ if (under(srcCwd, realBase)) {
238
+ await settleWorkTurn(job.id, {
239
+ ok: false,
240
+ answer:
241
+ "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
242
+ });
243
+ return;
244
+ }
245
+ try {
246
+ srcHead = git(['rev-parse', 'HEAD'], srcCwd);
247
+ } catch {
248
+ await settleWorkTurn(job.id, {
249
+ ok: false,
250
+ answer:
251
+ "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
252
+ });
253
+ return;
254
+ }
255
+ // Liveness by the SESSION's own runtime: Claude has a real pid
256
+ // registry; agy only leaves store-write recency + a process check,
257
+ // and adoption there is a MOVE (no fork exists), so the composite
258
+ // errs toward refusing — a false "live" costs a retry in minutes,
259
+ // a false "ended" puts two drivers on one conversation store.
260
+ const adoptLive =
261
+ job.runtime === 'antigravity'
262
+ ? isAgyConversationLive(job.adopt.id)
263
+ : isTerminalSessionLive(job.adopt.id);
264
+ if (adoptLive) {
265
+ await settleWorkTurn(job.id, {
266
+ ok: false,
267
+ answer:
268
+ 'That terminal session is still open on the machine — close it there first, then adopt.',
269
+ });
270
+ return;
271
+ }
272
+ adoptSrc = srcCwd;
273
+ }
274
+ // Based at the SOURCE's HEAD when adopting — the resumed
275
+ // conversation was had against those commits, not the project base.
276
+ // The PLACE this tab works in — its own worktree unless the server
277
+ // named another. An older server sends no `place` and the default is
278
+ // the session's own id, which is what every tab has always done.
279
+ const dir = placeWtFor(place, adopting ? srcHead : undefined);
280
+ if (!dir) {
281
+ await settleWorkTurn(job.id, {
282
+ ok: false,
283
+ answer:
284
+ 'the session worktree could not be opened on the machine — check the daemon log',
285
+ });
286
+ return;
287
+ }
288
+ // A live CLI is ALREADY in this worktree — this daemon's previous
289
+ // life, most likely; the lock outlives a restart. Leave the job
290
+ // pending and look again next poll; spawning a second CLI would put
291
+ // two Claudes in one held context. Costs no attempt: nothing ran.
292
+ const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
293
+ if (turnLockedByLivePid(lockPath)) {
294
+ warn(
295
+ `a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
296
+ );
297
+ return;
298
+ }
299
+ // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
300
+ // which is what every tab ran on until now) — honored by
301
+ // sessionRuntime: on a first turn a named runtime IS the pick, and a
302
+ // named runtime that disagrees with the pin settles below.
303
+ // What the artifact directory held BEFORE this turn — the capture chat
304
+ // excepted: it runs read-only, is never told about artifacts, and
305
+ // whatever sibling tabs wrote in this shared place is theirs.
306
+ if (job.capture !== true) artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt) };
307
+ const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
308
+ if (rt.mismatch) {
309
+ // Something upstream changed this tab's identity mid-life. A held
310
+ // context must never be answered by a different brain — say so.
311
+ await settleWorkTurn(job.id, {
312
+ ok: false,
313
+ answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
314
+ });
315
+ return;
316
+ }
317
+ if (rt.missing) {
318
+ await settleWorkTurn(job.id, {
319
+ ok: false,
320
+ answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
321
+ });
322
+ return;
323
+ }
324
+ if (rt.unsupported) {
325
+ // A pin from before the session-capable gate existed — or a
326
+ // first-turn tab the server named for one — can carry a runtime no
327
+ // tab can run on (Antigravity has no MCP config, and the session's
328
+ // whole control plane rides one). An honest sentence beats the
329
+ // mcpFor throw this used to crash into every turn.
330
+ await settleWorkTurn(job.id, {
331
+ ok: false,
332
+ answer: `this session runs on ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
333
+ });
334
+ return;
335
+ }
336
+ if (!rt.id) {
337
+ await settleWorkTurn(job.id, {
338
+ ok: false,
339
+ answer:
340
+ 'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
341
+ });
342
+ return;
343
+ }
344
+ if (adopting && rt.id !== 'claude' && rt.id !== 'antigravity') {
345
+ // An adopt id names a conversation in ITS OWN CLI's store: claude
346
+ // forks it (--resume --fork-session), agy moves it
347
+ // (--conversation). Codex has no adoptable store yet, and its
348
+ // args builder backstops this with a loud throw — but a sentence
349
+ // here beats a stack there.
350
+ await settleWorkTurn(job.id, {
351
+ ok: false,
352
+ answer:
353
+ 'adopting this terminal session needs its own CLI on the machine — install it, then try again',
354
+ });
355
+ return;
356
+ }
357
+ /**
358
+ * A PLAN TURN (0.97.0): the tab's switch, off the job. CLAUDE ONLY —
359
+ * `--permission-mode plan` is Claude Code's, and a codex or agy turn
360
+ * has no way to spell it, so it is refused HERE in words rather than
361
+ * run as a build turn wearing the word. The server never sends the
362
+ * key for a non-Claude tab; this is the machine's own half. Never on
363
+ * a capture chat, which is read-only by its own profile already.
364
+ */
365
+ const planTurn = job.planMode === true && job.capture !== true;
366
+ if (planTurn && rt.id !== 'claude') {
367
+ await settleWorkTurn(job.id, {
368
+ ok: false,
369
+ 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`,
370
+ });
371
+ return;
372
+ }
373
+ // A PLAIN tab (agy) mounts no MCP: no credential to mint, no config
374
+ // to write. The trade is stated in SYSTEM_WORK_PLAIN — no cards, no
375
+ // streaming — and the honesty survives on the existing rails: the
376
+ // answer lands via work-turn-done, the rail says "no card yet", and
377
+ // ship-time reconciliation books every branch commit.
378
+ const plainTab = rt.id === 'antigravity';
379
+ let mint = null;
380
+ if (!plainTab) {
381
+ mint = await mintWorkToken(job.sessionId);
382
+ if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
383
+ // Another daemon on this credential holds the session. Return
384
+ // WITHOUT settling: the holder is answering this same turn, and
385
+ // settling it here — even as a failure — would race the real
386
+ // answer and could win. Dropping it means the turn stays pending
387
+ // and the holder's answer lands, which is the whole point.
388
+ if (mint?.heldElsewhere) {
389
+ workAnswering.delete(job.id);
390
+ return;
391
+ }
392
+ if (mint?.gone) {
393
+ await settleWorkTurn(job.id, {
394
+ ok: false,
395
+ answer:
396
+ 'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
397
+ });
398
+ return;
399
+ }
400
+ if (!mint?.token) {
401
+ await settleWorkTurn(job.id, {
402
+ ok: false,
403
+ answer:
404
+ 'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
405
+ });
406
+ return;
407
+ }
408
+ }
409
+ // CODEX RESUMES BY THREAD ID, never by `--last`: `resume --last` is
410
+ // the MACHINE's most recent codex conversation, and two codex tabs —
411
+ // or a tab plus a codex dispatch — would cross-resume each other's
412
+ // context. The id was captured off thread.started (runtimeEvents.mjs) and
413
+ // persisted below, beside the runtime pin; absent, the turn runs
414
+ // FRESH in the same worktree — the dirty state is most of the held
415
+ // context, and a machine-global guess is someone else's conversation.
416
+ /**
417
+ * CLAUDE RESUMES BY THE CONVERSATION ID THIS TAB SPOKE UNDER.
418
+ *
419
+ * `--continue` is CWD-KEYED. That was unambiguous while one directory
420
+ * meant one tab, and it stopped being true the day tabs moved into
421
+ * their driver's project folder: every tab there said `--continue`
422
+ * and every one of them resumed whichever conversation had spoken
423
+ * most recently in that directory. Tab B inherited tab A's entire
424
+ * context, and each turn afterwards ping-ponged between them — the
425
+ * exact failure the codex note two blocks down warns about for
426
+ * `resume --last`, arriving for Claude by a different route.
427
+ *
428
+ * The id comes from the CLI's own `system.init` event, which the
429
+ * stream parser already surfaces, and is pinned per session so it is
430
+ * unambiguous wherever the tab is standing.
431
+ *
432
+ * NO ID, NO `--continue`: a tab whose place is shared starts FRESH
433
+ * rather than guessing, because in a shared directory the guess is
434
+ * someone else's conversation. `--continue` survives only where the
435
+ * directory belongs to this tab alone, which is the one case it was
436
+ * ever right for.
437
+ */
438
+ let claudeResumeId = null;
439
+ if (rt.id === 'claude') {
440
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
441
+ if (convMarker && existsSync(convMarker)) {
442
+ try {
443
+ const v = readFileSync(convMarker, 'utf8').trim();
444
+ if (/^[A-Za-z0-9_-]{8,64}$/.test(v)) claudeResumeId = v;
445
+ } catch {
446
+ /* unreadable marker — run fresh */
447
+ }
448
+ }
449
+ }
450
+ let codexResumeId = null;
451
+ if (rt.id === 'codex') {
452
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
453
+ if (threadMarker && existsSync(threadMarker)) {
454
+ try {
455
+ const v = readFileSync(threadMarker, 'utf8').trim();
456
+ if (CODEX_THREAD_RE.test(v)) codexResumeId = v;
457
+ } catch {
458
+ /* unreadable marker — run fresh */
459
+ }
460
+ }
461
+ }
462
+ // AGY RESUMES BY CONVERSATION ID, learned once and pinned beside the
463
+ // runtime marker: an adopted tab knows it from the adopt hint; a new
464
+ // tab learns it from agy's own cwd registry after its first turn.
465
+ // The marker beats `--continue` because it is the tab's OWN identity
466
+ // — the registry maps a cwd to whatever ran there LAST, and a
467
+ // dispatch sharing the machine could overwrite that between turns.
468
+ let agyConvId = null;
469
+ if (rt.id === 'antigravity' && !adopting) {
470
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
471
+ if (convMarker && existsSync(convMarker)) {
472
+ try {
473
+ const v = readFileSync(convMarker, 'utf8').trim();
474
+ if (AGY_CONV_RE.test(v)) agyConvId = v;
475
+ } catch {
476
+ /* unreadable marker — run fresh */
477
+ }
478
+ }
479
+ }
480
+ // Resume iff a conversation is known to live in THIS directory. For
481
+ // Claude that proof is the server's sessionRef — only ever a path
482
+ // some turn actually SPOKE from (see the settle below), and it must
483
+ // match the directory we just opened. For codex it is the stored
484
+ // thread id, which lives IN the directory and is stronger. Anything
485
+ // else starts fresh IN the existing worktree — never a reset; the
486
+ // dirty state is the session.
487
+ // agy layers its two resumes: the pinned conversation id when the
488
+ // marker exists (deterministic, registry-proof), else the Claude
489
+ // rule — a tab that has SPOKEN from this directory may `--continue`
490
+ // it (cwd-keyed; measured safe), so a lost marker degrades to the
491
+ // weaker resume instead of silently starting over.
492
+ const spokeHere = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
493
+ // A place this tab does NOT have to itself: `--continue` there is a
494
+ // guess at somebody else's conversation, so it is withheld and only
495
+ // a pinned id may resume.
496
+ const placeIsMine = !placeOf(job.sessionId) || placeOf(job.sessionId) === job.sessionId;
497
+ const resume =
498
+ rt.id === 'codex'
499
+ ? Boolean(codexResumeId)
500
+ : rt.id === 'antigravity'
501
+ ? Boolean(agyConvId) || (placeIsMine && spokeHere)
502
+ : Boolean(claudeResumeId) || (placeIsMine && spokeHere);
503
+ // The dirty carry, on the adopt worktree's FIRST life only: a
504
+ // re-attempted adoption (the directory already exists) carried what
505
+ // it could the first time, and re-applying would double it. A carry
506
+ // problem never fails the adoption — it becomes one bracketed line
507
+ // in the prompt, so the AGENT tells the user what stayed behind.
508
+ let carryNote = '';
509
+ if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt, job.sessionId);
510
+ // The tab's transcript starts EMPTY on adoption (scrollback is
511
+ // disposable, the held context is the brain — never import an
512
+ // archive), so the first reply opens with a recap: the human sees
513
+ // the thread they are picking up without asking for it.
514
+ const adoptNote = adopting
515
+ ? '[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.]'
516
+ : '';
517
+ // A PLAN TURN RUNS PLAIN TOO (0.97.0): measured on 2.1.281, plan mode
518
+ // refuses every `mcp__flowviant` call ("Cannot call … while in plan
519
+ // mode") unless the tool is annotated read-only, and none of the
520
+ // session tools are — so mounting the control plane would hand the
521
+ // turn a list of tools it can only fail at. The credential is still
522
+ // minted above: its lease answers (held elsewhere, gone) are about
523
+ // the SESSION, and a plan turn needs them as much as any other.
524
+ const mcp =
525
+ plainTab || planTurn
526
+ ? { args: [], env: null, dir: null }
527
+ : mcpFor(rt.id, mint.token, getMcpUrl());
528
+ // The tab's model/effort, if it named any. Spread into turnArgs so
529
+ // BOTH runTurn calls below carry it — the retry is the same turn on
530
+ // the same brain, not a quieter second opinion.
531
+ const brain = brainFor(job, rt.id);
532
+ // Attempts count RUNS: the infra refusals above consumed nothing and
533
+ // settled on their own terms.
534
+ workAttempts.set(job.id, tries + 1);
535
+ let out;
536
+ let seenThreadId = null; // codex's conversation id, off thread.started
537
+ let seenClaudeSession = null; // claude's own conversation id, off system.init
538
+ const spawned = []; // this turn's children, for the teardown registry
539
+ // THE TURN'S TOOL LOG — see workToolLog.mjs for its shape rules.
540
+ const turnLog = createToolLog(dir.wt);
541
+ toolLog = turnLog.toolLog;
542
+ const { pushToolEvent } = turnLog;
543
+ const narrator = makeNarrator(job.sessionId, job.id, () =>
544
+ toolLog.ev.length > 0 ? toolLog : undefined
545
+ );
546
+
547
+
548
+ // THE COMMAND AUDIT — see workCommandAudit.mjs.
549
+ const { flushAudit, auditCommand } = createCommandAudit({
550
+ sessionId: job.sessionId,
551
+ turnId: job.id,
552
+ runtime: rt.id,
553
+ cwd: dir.wt,
554
+ });
555
+ try {
556
+ // Files first, then the message that references them: the agent
557
+ // must be able to open what it is being told about. Only the ones
558
+ // that actually landed are named.
559
+ const files = await fetchAttachments(dir.wt, job.attachments);
560
+ const filesNote = files.length
561
+ ? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${files
562
+ .map((f) => `- ${f}`)
563
+ .join('\n')}`
564
+ : '';
565
+ const message = [job.body, filesNote, adoptNote, carryNote]
566
+ .filter(Boolean)
567
+ .join('\n\n');
568
+ // A CAPTURE chat (the board's New task conversation): its own
569
+ // system prompt, its own kickoff, and the scratch planner's
570
+ // READ-ONLY permission profile — the prompt says stage-never-file
571
+ // and the profile is what makes "read-only" true rather than
572
+ // asserted. Server-flagged per job; a server too old to flag it
573
+ // simply runs an ordinary tab, which the web's version floor
574
+ // prevents ever being offered.
575
+ const captureTab = job.capture === true;
576
+ const turnArgs = {
577
+ // A plain tab has no tools to name and no session id to pass —
578
+ // its kickoff asks for one complete report instead of a stream.
579
+ prompt: plainTab || planTurn
580
+ ? WORK_TURN_KICKOFF_PLAIN({
581
+ sessionName: job.sessionName,
582
+ message,
583
+ askedByName: job.askedByName,
584
+ })
585
+ : captureTab
586
+ ? CAPTURE_TURN_KICKOFF({
587
+ sessionId: job.sessionId,
588
+ sessionName: job.sessionName,
589
+ message,
590
+ askedByName: job.askedByName,
591
+ })
592
+ : WORK_TURN_KICKOFF({
593
+ sessionId: job.sessionId,
594
+ sessionName: job.sessionName,
595
+ message,
596
+ askedByName: job.askedByName,
597
+ }),
598
+ // ONE PROFILE (turnProfile.mjs): the capture chat's read-only
599
+ // `plan` list; or a plan turn's `plan-mode` — `--permission-mode
600
+ // plan` INSTEAD of the build posture, never beside
601
+ // `--dangerously-skip-permissions`, which silently wins
602
+ // (claudePosture.mjs, PLAN_MODE_PERM); else the build posture.
603
+ profile: captureTab ? 'plan' : planTurn ? 'plan-mode' : 'build',
604
+ // The adopt turn resumes the TERMINAL conversation by forking it
605
+ // into this cwd (claude: --resume <id> --fork-session). After it
606
+ // speaks once, the fork lives natively here and turn 2+ is the
607
+ // ordinary --continue resume path, unchanged.
608
+ ...(adopting ? { adoptResumeId: job.adopt.id } : {}),
609
+ // THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0) rides as one appended
610
+ // paragraph, and only while this box holds one — see
611
+ // `withProjectContext`. Resolved at spawn, not at startup, so a
612
+ // library that synced a second ago is in THIS turn.
613
+ ...(() => {
614
+ const knowledgeDir = knowledgeDirFor(repoRoot);
615
+ return {
616
+ // A PLAN TURN (0.97.0) runs the PLAIN contract — it has no
617
+ // Flowviant tools, see `mcp` above — with no artifacts
618
+ // paragraph (plan mode cannot write one) and the planning
619
+ // sentence appended. Every other tab is untouched.
620
+ system: planTurn
621
+ ? `${withProjectContext(SYSTEM_WORK_PLAIN, { knowledgeDir, artifacts: false })}\n\n${PLAN_TURN_SENTENCE}`
622
+ : withProjectContext(
623
+ plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
624
+ // ARTIFACTS (0.94.0): every tab but the read-only capture
625
+ // chat, and only while the server can show one.
626
+ { knowledgeDir, artifacts: !captureTab && getArtifactsAccepted() }
627
+ ),
628
+ // The directory is OUTSIDE a worktree's cwd; Claude Code is
629
+ // told it may read there (`--add-dir`) rather than left to
630
+ // refuse a read-only capture turn a path it was just handed.
631
+ knowledgeDir,
632
+ };
633
+ })(),
634
+ // Present only when the tab named one — see brainFor.
635
+ ...brain,
636
+ // The tab watches the CLI work. Claude needs the flag to speak
637
+ // events at all (codex and agy always do); `answerFromResult`
638
+ // keeps `out` — which IS the reply posted to the transcript — to
639
+ // the final result, so streamed prose is narrated once and
640
+ // posted once. Every line goes to the narrator above, throttled.
641
+ streamJson: true,
642
+ answerFromResult: true,
643
+ onActivity: (a) => {
644
+ narrator.line(a?.label);
645
+ auditCommand(a);
646
+ },
647
+ // The structured twin of the line above — see toolLog.
648
+ onToolEvent: pushToolEvent,
649
+ // What this CLI says it can be asked for by name. Harvested off
650
+ // the init event the stream already carries — no probe, no scan,
651
+ // no extra spawn — and reported on the next roster poll so the
652
+ // composer can autocomplete a `/`. See runtimeCapabilities.mjs for why it is
653
+ // learned from a turn rather than looked up.
654
+ onInit: (i) => {
655
+ recordSkills(i.skills);
656
+ // The CLI's mounted MCP servers and connectors (0.97.0) — the
657
+ // same free fact off the same event; see recordMcpServers.
658
+ recordMcpServers(i.mcpServers);
659
+ // The conversation this turn is actually speaking under. Held
660
+ // and persisted after the turn ends, so the NEXT one resumes
661
+ // this exact thread rather than whatever the directory saw
662
+ // last. Last write wins on purpose: a resume that fell back to
663
+ // fresh reports the fresh id, healing the marker.
664
+ if (typeof i.sessionId === 'string' && i.sessionId.trim())
665
+ seenClaudeSession = i.sessionId.trim();
666
+ },
667
+ cwd: dir.wt,
668
+ mcpArgs: mcp.args,
669
+ mcpEnv: mcp.env,
670
+ runtime: rt.id,
671
+ label: c.cyan('[tab]'),
672
+ // Only codex announces one (thread.started); held here so the id
673
+ // this turn actually SPOKE under is what gets persisted after it
674
+ // ends. Last write wins on purpose: a failed resume that fell
675
+ // back to fresh reports the fresh run's id, healing the marker.
676
+ onThreadId: (id) => {
677
+ seenThreadId = String(id ?? '').trim() || seenThreadId;
678
+ },
679
+ onSpawn: (ch) => {
680
+ if (!ch) return;
681
+ spawned.push(ch);
682
+ // Keyed to the SESSION it serves — that id is what the machine
683
+ // snapshot charges this child's memory to.
684
+ workChildren.set(ch, job.sessionId);
685
+ // The process exists, so the reserved slot is now counted by
686
+ // the registry itself. Idempotent — the `finally` below releases
687
+ // it again for every path that never got here.
688
+ releaseSlot();
689
+ // The CLI is spawned `detached`, so its pid IS its process
690
+ // group id — and every process it starts inherits that, through
691
+ // `nohup` and `setsid` alike. Remembered per SESSION rather
692
+ // than per turn, because the whole point is the watcher that
693
+ // outlives the turn that started it.
694
+ if (ch.pid) noteSessionGroup(job.sessionId, ch.pid);
695
+ if (lockPath && ch.pid) {
696
+ try {
697
+ const start = processStartTime(ch.pid);
698
+ writeFileSync(lockPath, start ? `${ch.pid}:${start}` : String(ch.pid));
699
+ } catch {
700
+ /* best-effort */
701
+ }
702
+ }
703
+ },
704
+ };
705
+ out = await runTurn({
706
+ ...turnArgs,
707
+ resume,
708
+ resumeThreadId: codexResumeId || claudeResumeId || undefined,
709
+ resumeConversationId: agyConvId || undefined,
710
+ });
711
+ // A resume that produced NOTHING usually means the held
712
+ // conversation is gone (a first turn that crashed before writing
713
+ // state, a wiped CLI dir — or, on codex, a deleted thread). Retry
714
+ // once fresh in the SAME worktree — never reset — instead of
715
+ // bricking the tab forever; the retry carries no resumeThreadId,
716
+ // so codex genuinely starts over rather than re-asking for the
717
+ // thread that just came back empty. NEVER on an adopt turn
718
+ // (`resume` is structurally false there, and the guard says so out
719
+ // loud): a fresh conversation would silently discard the adoption
720
+ // and answer as a new session wearing its name — the empty adopt
721
+ // turn settles failed below instead.
722
+ /**
723
+ * …OR PRODUCED ONLY THE CLI SAYING THE CONVERSATION IS GONE.
724
+ *
725
+ * "Produced nothing" was the whole test, and it is not the shape
726
+ * these failures take: Claude Code answers a dead `--resume` id
727
+ * with a result event carrying `errors: ['No conversation found
728
+ * with session ID: …']` and writes the same line to stderr, so
729
+ * `out` is non-empty. The backstop never fired, the turn settled
730
+ * SUCCESSFULLY with that error as its answer, and — because the
731
+ * marker is only rewritten when an init event is seen, and there
732
+ * was none — the dead id stayed pinned. Every later message in
733
+ * that tab replied identically, with nothing on any surface able
734
+ * to clear it. A tab bricked forever by its own CLI pruning its
735
+ * history, which it does on its own schedule.
736
+ */
737
+ if (
738
+ !adopting &&
739
+ resumeRetriesFresh({ resume, out, runtime: rt.id, sawInit: Boolean(seenClaudeSession) })
740
+ ) {
741
+ out = await runTurn({ ...turnArgs, resume: false });
742
+ }
743
+ } finally {
744
+ // The CLI has stopped printing, so stop relaying. The LINE itself
745
+ // is cleared server-side at settle — clearing it here would race
746
+ // the settle and blank the tab a beat before the reply lands.
747
+ narrator.stop();
748
+ flushAudit();
749
+ for (const ch of spawned) workChildren.delete(ch);
750
+ if (lockPath) {
751
+ try {
752
+ rmSync(lockPath, { force: true });
753
+ } catch {
754
+ /* best-effort */
755
+ }
756
+ }
757
+ if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
758
+ }
759
+ // Persist the codex thread id AFTER the turn ends, so the next turn
760
+ // resumes exactly the conversation that just spoke. Shape-guarded
761
+ // before it ever touches disk — it later rides in argv as
762
+ // `resume <id>` — and best-effort, like the runtime pin: an
763
+ // unwritable marker just means the tab runs fresh next turn.
764
+ // The conversation THIS TAB just spoke under, pinned so the next
765
+ // turn resumes it by id rather than asking the directory. Written
766
+ // after the turn for the same reason codex's is: an id learned
767
+ // mid-turn is only true once the turn that learned it finished.
768
+ if (
769
+ rt.id === 'claude' &&
770
+ seenClaudeSession &&
771
+ /^[A-Za-z0-9_-]{8,64}$/.test(seenClaudeSession)
772
+ ) {
773
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
774
+ if (convMarker) {
775
+ try {
776
+ writeFileSync(convMarker, seenClaudeSession);
777
+ } catch {
778
+ /* best-effort — the next turn re-learns it */
779
+ }
780
+ }
781
+ }
782
+ if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
783
+ const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
784
+ if (threadMarker) {
785
+ try {
786
+ writeFileSync(threadMarker, seenThreadId);
787
+ } catch {
788
+ /* best-effort */
789
+ }
790
+ }
791
+ }
792
+ const answer = (out || '').trim();
793
+ // No output at all smells like a dead MCP credential (the lane
794
+ // workers' no-sentinel case) — drop the cached token so the next
795
+ // turn re-mints instead of failing the same way forever.
796
+ if (!answer) workTokens.delete(job.sessionId);
797
+ if (adopting && !answer) {
798
+ // The fork came back with nothing — the terminal session's
799
+ // transcript is most likely gone (cleaned, expired, deleted). Say
800
+ // exactly that; no sessionRef is recorded, so the server keeps
801
+ // offering the adoption and a retry after the user checks is cheap.
802
+ await settleWorkTurn(job.id, {
803
+ ok: false,
804
+ answer: "Couldn't resume the terminal session — it may have been removed.",
805
+ // Whatever it DID before coming back empty is exactly the
806
+ // question a failed turn's log answers.
807
+ ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
808
+ });
809
+ warn('adopt turn produced no output — settled as failed');
810
+ return;
811
+ }
812
+ // Persist the agy conversation id once the turn actually SPOKE — an
813
+ // adopted tab pins the id it moved in (the adopt hint); a new tab
814
+ // learns the one its first fresh turn just created, from agy's own
815
+ // cwd registry. From here on the marker is the tab's identity and
816
+ // the registry is never trusted again.
817
+ if (rt.id === 'antigravity' && answer.length > 0) {
818
+ const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
819
+ if (convMarker && !existsSync(convMarker)) {
820
+ const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
821
+ if (learned && AGY_CONV_RE.test(learned)) {
822
+ try {
823
+ writeFileSync(convMarker, learned);
824
+ } catch {
825
+ /* best-effort — an unpinned tab resumes via --continue's cwd key */
826
+ }
827
+ }
828
+ }
829
+ }
830
+ await settleWorkTurn(job.id, {
831
+ ok: answer.length > 0,
832
+ answer:
833
+ answer.length > 0
834
+ ? // Scrub: a reply can quote config or env-adjacent code.
835
+ envScrub(answer).slice(0, 16000)
836
+ : 'the turn produced no output on the machine — its CLI may be signed out; try again',
837
+ // Only a turn that actually SPOKE proves a conversation lives
838
+ // here. Recording the path unconditionally is how a crashed first
839
+ // turn used to brick resume for the session's whole life.
840
+ ...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
841
+ // The turn's tool log, in final form — the durable copy that lands
842
+ // on the settled message (the live copy on the record is cleared
843
+ // at settle). Already scrubbed at collection.
844
+ ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
845
+ });
846
+ if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
847
+ else warn('session turn produced no output — settled as failed');
848
+ } catch (e) {
849
+ await settleWorkTurn(job.id, {
850
+ ok: false,
851
+ // Scrub, like every string that leaves this machine: an exception
852
+ // routinely quotes command output, and command output can quote a
853
+ // synced secret.
854
+ answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
855
+ // "What did it do before it failed" is exactly the question a
856
+ // crashed turn's log answers — same spread as the main settle.
857
+ ...(toolLog?.ev.length > 0 ? { tools: toolLog } : {}),
858
+ });
859
+ warn(`session turn failed: ${e?.message ?? e}`);
860
+ } finally {
861
+ workAnswering.delete(job.id);
862
+ // Nothing spawned, or everything already has: releasing twice is the
863
+ // normal case and costs nothing. A reservation that leaked would
864
+ // shrink this machine's ceiling for the life of the process.
865
+ releaseSlot();
866
+ // The turn just changed the directory — say what it looks like now,
867
+ // whether it succeeded or blew up (a failed turn can still have
868
+ // written half a file, and the tab should show that honestly). NOT
869
+ // awaited: this runs inside the session's chain, and a slow POST
870
+ // would delay the next turn of that tab behind a readout.
871
+ void reportPlaceWorktrees(job.sessionId).catch(() => {});
872
+ burstListeners(job.sessionId);
873
+ // …and relay what it wrote to SHOW its owner (2026-09-22) — the same
874
+ // beat, the same reason it is not awaited. A failed turn's half-drawn
875
+ // page is still what the turn left, and the tab should see it.
876
+ if (artifactScan) {
877
+ void artifacts
878
+ .report({
879
+ placeDir: artifactScan.dir,
880
+ before: artifactScan.before,
881
+ sessionId: job.sessionId,
882
+ turnId: job.id,
883
+ })
884
+ .catch(() => {});
885
+ }
886
+ }
887
+ });
888
+ }
889
+ };
890
+
891
+ return { processWorkTurns, workAnswering };
892
+ }