flowviant 0.102.2 → 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 (141) 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 +184 -2396
  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 +34 -3
  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/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
package/bin/lib/work.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Work sessions — the Workbench tabs, daemon side.
2
+ * Work sessions — the Workbench tabs and the agent lanes, daemon side: THE
3
+ * MANAGER that builds every lane and hands each one exactly what it uses.
3
4
  *
4
5
  * A tab is a held coding-CLI session (Claude or codex — the server names the
5
6
  * brain per tab, and the pin holds it) with BUILD permissions in a PERSISTENT
@@ -8,272 +9,83 @@
8
9
  * blowing it away would be closing the human's editor mid-thought. (Plan
9
10
  * worktrees are the deliberate opposite: reset at base every turn.)
10
11
  *
11
- * Everything the loop guarantees lives here: per-session turn/ship chains,
12
- * per-session work credentials, the settle-every-turn contract, the ship
13
- * executor, and worktree retirement. Split out of fleet.mjs mechanically —
14
- * the daemon's reconcile loop constructs one manager per run and feeds it
15
- * roster jobs; the only state it borrows from the loop is read through the
16
- * two getters (the MCP URL and the lease TTL can change with any poll).
12
+ * The daemon's reconcile loop (fleet.mjs) constructs one manager per run and
13
+ * feeds it roster jobs; the only state it borrows from the loop is read
14
+ * through the getters (the MCP URL, the lease TTL and the base can change with
15
+ * any poll).
16
+ *
17
+ * THIS FILE RUNS NO LANE OF ITS OWN (2026-09-26, SOLID F037). It was the
18
+ * implementation site of every workflow the loop guarantees — process-group
19
+ * bookkeeping, the settle queue, publishing, the worktree readout,
20
+ * attachments, the tab's whole turn, retirement — at 3.2k lines, so a change
21
+ * to any one of them was a change to all of their file. Each now has a
22
+ * sibling named for what it does, and this file owns only what is genuinely
23
+ * the manager's: the shared maps (named at every factory that reads them,
24
+ * F164), the artifact reporter and the landed observer that several lanes
25
+ * share, the network-git bound, the construction ORDER (a factory is built
26
+ * after everything it is handed), and the two answers the loop asks of the
27
+ * whole — `workBusy` and the returned surface. A pure rule or constant
28
+ * (REPO_PLACE, the resume-id guards, brainFor, the turn lock) is IMPORTED by
29
+ * every lane that reads it, never carried through here.
30
+ *
31
+ * workPlaces.mjs where a session works, its worktree, its markers
32
+ * workSessionGroups.mjs which process groups each tab started
33
+ * workChildren.mjs the live CLI children, teardown, the admission
34
+ * workAgentPublish.mjs pushing and fetching an agent's branch
35
+ * workPlaceReports.mjs the worktree readout: settle, burst, sweep
36
+ * workReportQueue.mjs every settle, held and retried until delivered
37
+ * workSessionTokens.mjs the per-session work credential
38
+ * workNarration.mjs the tab's live line and the deferral sentence
39
+ * workAttachments.mjs files the human attached, brought to disk
40
+ * workSessionRuntime.mjs which CLI a tab speaks, which conversation resumes
41
+ * workAdoptCarry.mjs adoption's dirty carry
42
+ * workSessionTurns.mjs a tab's turn, start to settle
43
+ * workRetire.mjs held sessions, retirement, manual-place freshening
44
+ * workToolLog.mjs one turn's tool cards, folded and capped (pure)
45
+ * workCommandAudit.mjs one turn's `$ …` audit, batched to the server
46
+ * workBrain.mjs the model and effort a turn may put on argv (pure)
47
+ * turnLock.mjs whether a live CLI of ours holds a directory's turn
48
+ * (and, split earlier: workDiffs, workPreviews, workPullRequests, workShip,
49
+ * workAgentPlans, workAgentCheck, workAgentPrecheck, workAgentReview,
50
+ * workAgentTurns, workAgentMerges, workProcesses; and out of workAgentTurns,
51
+ * SOLID F036: workAgentTurnReports, workAgentTurnExecution,
52
+ * workAgentTurnBegun, workAgentTurnOutcome)
17
53
  */
18
54
 
19
55
  import { createWorkProcesses } from './workProcesses.mjs';
20
56
  import { createWorkAgentMerges } from './workAgentMerges.mjs';
21
57
  import { createWorkAgentTurns } from './workAgentTurns.mjs';
22
- export { clearNonArtifacts, checkEnv } from './workAgentReview.mjs';
58
+ import { createWorkAgentCheck } from './workAgentCheck.mjs';
59
+ import { createWorkAgentPrecheck } from './workAgentPrecheck.mjs';
23
60
  import { createWorkAgentReview } from './workAgentReview.mjs';
24
61
  import { createWorkAgentPlans } from './workAgentPlans.mjs';
25
62
  import { createWorkShipper } from './workShip.mjs';
26
63
  import { createWorkPullRequests } from './workPullRequests.mjs';
27
64
  import { createWorkPreviews } from './workPreviews.mjs';
28
65
  import { createWorkDiffs } from './workDiffs.mjs';
29
- import {
30
- existsSync,
31
- rmSync,
32
- readdirSync,
33
- readFileSync,
34
- writeFileSync,
35
- realpathSync,
36
- statSync,
37
- lstatSync,
38
- mkdirSync,
39
- cpSync,
40
- } from 'node:fs';
41
- import { execFileSync } from 'node:child_process';
42
- import { join, dirname } from 'node:path';
43
- import {
44
- FLEET_URL,
45
- FLEET_TOKEN,
46
- USER_AGENT,
47
- REFRESH_BEFORE_SECONDS,
48
- DAEMON_INSTANCE,
49
- MACHINE_HOST,
50
- } from './config.mjs';
51
- import {
52
- git,
53
- gitRaw,
54
- gitNet as gitNetIn,
55
- gitNetAsync,
56
- splitNul,
57
- isSafePathSegment,
58
- excludeInWorktree,
59
- } from './git.mjs';
60
- import {
61
- publishPushArgs,
62
- publishFetchArgs,
63
- publishErrorText,
64
- } from './agentPublish.mjs';
66
+ import { createWorkPlaces } from './workPlaces.mjs';
67
+ import { createWorkSessionGroups } from './workSessionGroups.mjs';
68
+ import { createWorkChildren } from './workChildren.mjs';
69
+ import { createWorkAgentPublish } from './workAgentPublish.mjs';
70
+ import { createWorkPlaceReports } from './workPlaceReports.mjs';
71
+ import { createWorkReportQueue } from './workReportQueue.mjs';
72
+ import { createWorkSessionTokens } from './workSessionTokens.mjs';
73
+ import { createWorkNarration } from './workNarration.mjs';
74
+ import { createWorkAttachments } from './workAttachments.mjs';
75
+ import { createWorkSessionRuntime } from './workSessionRuntime.mjs';
76
+ import { createAdoptCarry } from './workAdoptCarry.mjs';
77
+ import { createWorkSessionTurns } from './workSessionTurns.mjs';
78
+ import { createWorkRetire } from './workRetire.mjs';
79
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
80
+ import { gitNet as gitNetIn, excludeInWorktree } from './git.mjs';
65
81
  import { createLandedObserver } from './landed.mjs';
66
- import { measureListeners, listenersSupported } from './listeners.mjs';
67
- import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
68
- import {
69
- bootMark,
70
- mutateRegistry,
71
- processStartTime,
72
- readRegistry,
73
- sameBoot,
74
- } from './procRegistry.mjs';
75
82
  import { createPlaceLock } from './placeLock.mjs';
76
- import { sweepMergedBranch } from './shipSweep.mjs';
77
- import { c, note, ok, warn } from './ui.mjs';
78
- import { mcpFor, runTurn } from './claude.mjs';
79
- import {
80
- SYSTEM_WORK,
81
- SYSTEM_CAPTURE,
82
- WORK_TURN_KICKOFF,
83
- CAPTURE_TURN_KICKOFF,
84
- SYSTEM_WORK_PLAIN,
85
- WORK_TURN_KICKOFF_PLAIN,
86
- withProjectContext,
87
- } from './prompts.mjs';
88
- import { knowledgeDirFor, FLOWVIANT_OWN_PATHS } from './knowledge.mjs';
89
- import {
90
- createArtifactReporter,
91
- snapshotArtifacts,
92
- } from './artifacts.mjs';
93
- import { myPubB64, scrub as envScrub, secretIn as envSecretIn } from './env.mjs';
94
- import {
95
- detectRuntimes,
96
- canRun,
97
- recordSkills,
98
- recordMcpServers,
99
- toolEventOf,
100
- RUNTIMES,
101
- } from './runtimes.mjs';
102
- import { createAdmission } from './admission.mjs';
103
-
104
- /** The place id meaning "the checkout", not a worktree. Must match the
105
- * server's REPO_PLACE — it is a wire value, not a local convention. */
106
- const REPO_PLACE = 'repo';
107
- import {
108
- isTerminalSessionLive,
109
- isAgyConversationLive,
110
- titleForSession,
111
- } from './localSessions.mjs';
112
- import { worktreeDiff } from './worktreeDiff.mjs';
113
- import { homedir } from 'node:os';
114
- import { createHash } from 'node:crypto';
115
-
116
- /**
117
- * The spawn lock: the pid of the CLI currently live in this worktree. A
118
- * restarted daemon must not put a second Claude into a directory the orphan
119
- * of its previous life is still editing — two CLIs appending to one held
120
- * conversation is exactly the incoherence workChains prevents in-process,
121
- * and the lock extends that guarantee across a restart. A dead pid is a
122
- * stale lock (removed here); a live one means "come back next poll".
123
- *
124
- * …AND A PID IS ONLY THE HOLDER WHILE IT IS STILL THE SAME PROCESS.
125
- *
126
- * The lock outlives a reboot or a daemon killed mid-turn, and pids are
127
- * recycled. Two readings treated a stranger as the holder and wedged every
128
- * turn and ship in the place until somebody deleted the file by hand: EPERM
129
- * was read as "alive, just not ours" — but the lock only ever holds a CLI this
130
- * daemon spawned under its own uid, so a pid we may not signal is by
131
- * definition not ours, i.e. stale — and a recycled pid of our OWN uid
132
- * answered signal 0 like the CLI it replaced. The lock now records the
133
- * process's start time beside the pid (`pid:start`); a live pid whose start
134
- * time differs is a different process. A lock with no start time (written by
135
- * an older daemon) keeps the old signal-0 reading, minus EPERM.
136
- */
137
- export const turnLockedByLivePid = (lockPath) => {
138
- if (!lockPath || !existsSync(lockPath)) return false;
139
- let pid = 0;
140
- let start = null;
141
- try {
142
- const raw = readFileSync(lockPath, 'utf8').trim();
143
- const cut = raw.indexOf(':');
144
- pid = Number(cut < 0 ? raw : raw.slice(0, cut));
145
- start = cut < 0 ? null : raw.slice(cut + 1) || null;
146
- } catch {
147
- /* unreadable — treat as stale */
148
- }
149
- if (Number.isInteger(pid) && pid > 0) {
150
- let alive = false;
151
- try {
152
- process.kill(pid, 0);
153
- alive = true; // signal 0 delivered — a process of OUR uid holds the pid
154
- } catch {
155
- /* ESRCH: gone. EPERM: another uid's process — never our CLI. Stale. */
156
- }
157
- if (alive) {
158
- const now = start ? processStartTime(pid) : null;
159
- // Unmeasurable start time is "cannot tell", which keeps the lock.
160
- if (!start || !now || now === start) return true;
161
- }
162
- }
163
- try {
164
- rmSync(lockPath, { force: true }); // dead holder — clear the stale lock
165
- } catch {
166
- /* best-effort */
167
- }
168
- return false;
169
- };
170
-
171
- /**
172
- * A RESUME THAT FOUND NO CONVERSATION, in the CLI's own words.
173
- *
174
- * "Produced nothing" was the only signal the retry backstop had, and it is not
175
- * the signal these failures give: Claude Code answers a dead `--resume` id with
176
- * a result event carrying `errors: ['No conversation found with session ID: …']`
177
- * AND writes the same line to stderr, so `out` is non-empty, the backstop never
178
- * fired, and the turn SETTLED SUCCESSFULLY with the error as its answer. Every
179
- * later message in that tab replied the same way — the marker file still held
180
- * the dead id, nothing rewrote it (that only happens when an init event is
181
- * seen, and there is none), and no surface offered a way to clear it. A tab
182
- * bricked forever by its own CLI pruning its history, which it does on its own
183
- * schedule.
184
- *
185
- * Matched on the CLI's phrasing rather than a code, because neither runtime
186
- * gives one. Deliberately narrow: it must not swallow a rate limit or a
187
- * permission refusal, both of which are real answers that should stand.
188
- */
189
- const RESUME_LOST = [
190
- /no conversation found/i,
191
- /no session found/i,
192
- /session .{0,80}not found/i,
193
- /conversation .{0,80}not found/i,
194
- /thread .{0,80}not found/i,
195
- /trajectory not found/i,
196
- ];
197
- /**
198
- * …BUT ONLY WHEN THE CLI SAID IT, NOT WHEN THE REPLY DID (2026-09-24).
199
- *
200
- * Under `answerFromResult` a SUCCESSFUL turn's `out` is Claude's reply, and the
201
- * patterns above were tested against all of it — so "the session cookie was not
202
- * found because SameSite dropped it" threw away a real answer and re-ran the
203
- * same message in a fresh, context-free conversation, whose init id then
204
- * re-pinned the tab: the first run's edits and commits had already happened,
205
- * the second repeated or misread them, and the tab's history was gone for good.
206
- * Web debugging talk says "session … not found" all day.
207
- *
208
- * So the text alone is never the evidence. Claude Code emits `system.init` on
209
- * EVERY turn that reached a conversation, and a dead `--resume` id fails before
210
- * one is emitted — so for Claude, a lost conversation is the phrase AND no init
211
- * event. Codex and agy give no such marker to this caller, so for them the
212
- * phrase must BE the reply: a short line, the shape a CLI error takes, never a
213
- * paragraph that happens to contain it.
214
- */
215
- export const RESUME_LOST_MAX_CHARS = 400;
216
- export const resumeConversationLost = (text, { runtime = 'claude', sawInit = false } = {}) => {
217
- const t = String(text || '').trim();
218
- if (!t || !RESUME_LOST.some((re) => re.test(t))) return false;
219
- if (runtime === 'claude') return !sawInit;
220
- return t.length <= RESUME_LOST_MAX_CHARS;
221
- };
222
-
223
- /**
224
- * The shape a per-tab model name must have before it rides argv as
225
- * `--model <name>`. Conservative for the same reason the codex thread id is
226
- * (below): it comes off the wire and lands in a child process's arguments —
227
- * alphanumerics plus dot/dash/underscore, at most 40 characters, and NEVER a
228
- * leading dash, which is an argv that parses as a flag.
229
- */
230
- const WORK_MODEL_RE = /^[a-zA-Z0-9._][a-zA-Z0-9._-]{0,39}$/;
231
-
232
- /** The five efforts the CLIs actually accept. A literal set rather than a
233
- * pattern: there is no such thing as an effort we haven't heard of, and the
234
- * server's own union is exactly this list. */
235
- const WORK_EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']);
236
-
237
- /**
238
- * WHICH BRAIN, AT WHICH EFFORT — the tab's own pick, off the roster.
239
- *
240
- * Absent is the resting state and it must stay genuinely absent: every tab ran
241
- * with no `--model` and no `--effort` until now, so a job that names neither
242
- * has to produce the byte-identical argv it produced yesterday — Claude falling
243
- * back to the machine's MODEL pin, codex and agy to their own defaults. Hence
244
- * an object with the key MISSING rather than one holding null: a null would
245
- * reach the builders as a value and Claude's `model || MODEL` is the only one
246
- * that would survive it.
247
- *
248
- * A value that fails its guard is DROPPED, not passed through and not an error.
249
- * The honest outcome of "the server named a model this machine can't spell" is
250
- * the machine's own default — a turn that runs — rather than a flag no CLI
251
- * understands and a tab that fails every message.
252
- */
253
- /**
254
- * THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
255
- * rather than in prompts.mjs because it is a MODIFIER on whichever contract
256
- * the tab runs, not a contract of its own.
257
- *
258
- * The enforcement is not this sentence — it is `--permission-mode plan`, under
259
- * which the CLI itself refuses every write (claude.mjs, PLAN_MODE_PERM). What
260
- * the sentence buys is the ANSWER: the plain contract above it says "edit
261
- * freely, commit", and a turn told only that would spend itself discovering
262
- * refusals. It also says not to reach for `ExitPlanMode` (measured: disabled
263
- * under `-p`, and the probe turn then asked the person to leave plan mode
264
- * themselves) — leaving plan mode is the person's switch in the tab.
265
- */
266
- export const PLAN_TURN_SENTENCE =
267
- '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.';
268
-
269
- function brainFor(job) {
270
- const out = {};
271
- const model = typeof job?.model === 'string' ? job.model.trim() : '';
272
- if (model && WORK_MODEL_RE.test(model)) out.model = model;
273
- const effort = typeof job?.effort === 'string' ? job.effort.trim() : '';
274
- if (effort && WORK_EFFORTS.has(effort)) out.effort = effort;
275
- return out;
276
- }
83
+ import { warn } from './ui.mjs';
84
+ import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
85
+ import { buildArtifactUpload, changedSince, snapshotArtifacts } from './artifacts.mjs';
86
+ import { createArtifactReporter } from './artifactReporter.mjs';
87
+ import { renderDesignPreview } from './artifactPreview.mjs';
88
+ import { scrub as envScrub, secretIn as envSecretIn } from './uplinkScrub.mjs';
277
89
 
278
90
  export function createWorkManager({
279
91
  repoRoot,
@@ -285,7 +97,7 @@ export function createWorkManager({
285
97
  onRepoChanged = () => {},
286
98
  /**
287
99
  * CLI turns this manager did not spawn — today exactly one, the wiki
288
- * cartographer, which lives in fleet.mjs's own closure. A callback for the
100
+ * cartographer, which lives in wikiRunner.mjs and is wired by fleet.mjs. A callback for the
289
101
  * same reason `onRepoChanged` is one: work.mjs is imported BY fleet.mjs and
290
102
  * cannot import back. It exists because the concurrency bound is a bound on
291
103
  * the MACHINE: a count that can see three lanes out of four is a ceiling with
@@ -313,13 +125,6 @@ export function createWorkManager({
313
125
  * `origin/<branch you happened to be on>` for the life of the daemon.
314
126
  */
315
127
  const baseRef = () => getBaseRef();
316
- const WORK_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-token');
317
- const WORK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/work-turn-done');
318
- const SHIP_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/ship-done');
319
- const ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-activity');
320
- const WORKTREES_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-worktrees');
321
- const SESSION_COMMANDS_URL = FLEET_URL.replace(/\/agents\/?$/, '/session-commands');
322
- const ATTACHMENT_URL = FLEET_URL.replace(/\/agents\/?$/, '/attachment');
323
128
  // What arrived on base, whichever road it took — observed after every beat
324
129
  // that can move origin/<base>: the sweep's fetch, a ship's push, a PR merge
325
130
  // this daemon performed. See landed.mjs for the seeding and delivery rules.
@@ -328,7 +133,7 @@ export function createWorkManager({
328
133
  * THE ARTIFACT RELAY (2026-09-22, 0.94.0) — what a turn wrote under
329
134
  * `.flowviant/artifacts/`, uploaded after it. One reporter for the life of
330
135
  * the manager so its held bodies survive from one beat to the next; see
331
- * artifacts.mjs for the snapshot rule, the bounds and the delivery shape.
136
+ * artifacts.mjs for the snapshot rule and the bounds, artifactReporter.mjs for the delivery shape.
332
137
  */
333
138
  const artifacts = createArtifactReporter({
334
139
  fleetUrl: FLEET_URL,
@@ -339,6 +144,10 @@ export function createWorkManager({
339
144
  // a hit is withheld, never uploaded rewritten (artifacts.mjs).
340
145
  secretIn: envSecretIn,
341
146
  log: (line) => warn(line),
147
+ // The disk half and the browser half, handed in (artifactReporter.mjs).
148
+ listChanged: changedSince,
149
+ buildUpload: buildArtifactUpload,
150
+ renderPreview: renderDesignPreview,
342
151
  });
343
152
  /**
344
153
  * Snapshot a place's artifact directory before a CLI spawns in it — and make
@@ -358,561 +167,10 @@ export function createWorkManager({
358
167
  }
359
168
  return snapshotArtifacts(placeDir);
360
169
  };
361
- const workAnswering = new Set(); // turn ids currently queued/running here
362
170
  const workAttempts = new Map(); // turn id -> completed runTurn attempts
363
- const MAX_WORK_TRIES = 3;
364
171
  const shipping = new Set(); // sessionIds with a ship queued/running here
365
172
  const { placeLocks, inPlace } = createPlaceLock();
366
173
 
367
- /**
368
- * WHICH PROCESS GROUPS EACH TAB HAS STARTED.
369
- *
370
- * A turn's CLI is spawned `detached`, so its pid is a process-group id and
371
- * everything the agent starts inherits it — through `nohup` and `setsid`,
372
- * which is precisely where attribution by ppid falls apart. Kept per SESSION
373
- * and not per turn: the point of the feature is the watcher that outlives the
374
- * turn that started it.
375
- *
376
- * PRUNED ON EVERY READ against the kernel, which is not housekeeping. A pgid
377
- * is a pid and pids are recycled, so an un-pruned set would eventually
378
- * attribute a stranger's process to a tab that has been closed for a week.
379
- */
380
- const sessionGroups = new Map(); // sessionId -> Set<pgid>
381
-
382
- /**
383
- * …AND IT SURVIVES A RESTART, which it did not until 2026-08-27.
384
- *
385
- * This map used to live only in this closure. The processes it tracks
386
- * OUTLIVE the daemon on purpose — `shutdownWork` SIGTERMs the CLI child and
387
- * never the group, precisely so an unattended auto-update does not kill the
388
- * driver's dev server — so every daemon restart left a live watcher running
389
- * with nothing left that knew whose it was. The tab reported `[]`, the web
390
- * read that as "looked, found none", and the Running section went dark until
391
- * some later turn happened to open a new group.
392
- *
393
- * That is not a small bug: AUTO_UPDATE is on by default, so it fired on every
394
- * release, on every machine. And it broke the three-state rule this file
395
- * states in its own header — the honest answer after a restart was "we have
396
- * forgotten", and `[]` is not that. Persisting is what makes `[]` true again,
397
- * which is why the fix is a disk write and not a fourth state.
398
- *
399
- * `procRegistry` is the right home and was sitting unused: it was built for
400
- * exactly this ("the daemon spawns things that outlive it… the successor has
401
- * to find them"), was orphaned when the dev-run system was deleted, and
402
- * already does the atomic write, the stale-lock recovery, the entry cap and
403
- * the dead-pid TTL. Its prune is deliberately LOOSE here — it keeps an entry
404
- * whose leader is gone, because the leader is the CLI and it exits at the end
405
- * of every turn while the watcher it started keeps running. `liveGroups` is
406
- * the real prune, on every read, against the kernel.
407
- */
408
- /*
409
- * THREE RULES THE FIRST CUT BROKE (2026-09-24), each a way the file named a
410
- * group that was not this tab's, or forgot one that was:
411
- *
412
- * - ONE FILE PER REPO, not per OS user. Each daemon rewrote the shared file
413
- * with only its own groups, so two daemons on one box (project A in one
414
- * checkout, B in another) erased each other's entries and A's watcher
415
- * was forgotten at A's next restart. The instance lock already makes a
416
- * repo one daemon, so the repo is the right owner. The pre-2026-09-24
417
- * shared file is not read: its entries carry no boot, so no rule below
418
- * could believe them anyway.
419
- * - AN ENTRY IS BELIEVED ONLY IN THE BOOT THAT WROTE IT. After a reboot the
420
- * remembered pgid belongs to whatever the kernel handed it to next — the
421
- * operator's shell, another project's children — and the Running list
422
- * relayed that group's command lines as this tab's and `killTargetOk`
423
- * accepted a Stop on them.
424
- * - ONLY LIVE GROUPS ARE WRITTEN, each with the time it was FIRST seen.
425
- * Every persist used to restamp every entry with `Date.now()`, so the
426
- * registry's 7-day TTL never applied to anything, and dead entries (one per
427
- * agent turn, recorded under a key nothing read) filled the 32-entry cap
428
- * and pushed a tab's live watcher off the end.
429
- */
430
- const GROUPS_DIR = join(homedir(), '.flowviant');
431
- const GROUPS_KEY = createHash('sha256').update(String(repoRoot)).digest('hex').slice(0, 16);
432
- const GROUPS_FILE = join(GROUPS_DIR, `session-groups-${GROUPS_KEY}.json`);
433
- const GROUPS_LOCK = join(GROUPS_DIR, `session-groups-${GROUPS_KEY}.lock`);
434
- const groupFirstSeen = new Map(); // pgid -> ms first recorded
435
- const BOOT = bootMark();
436
-
437
- const persistGroups = () => {
438
- const flat = [];
439
- for (const [sid, set] of sessionGroups) {
440
- for (const pgid of liveGroups(set)) {
441
- if (!groupFirstSeen.has(pgid)) groupFirstSeen.set(pgid, Date.now());
442
- flat.push({ sessionId: sid, pid: pgid, startedAt: groupFirstSeen.get(pgid), boot: BOOT });
443
- }
444
- }
445
- const kept = new Set(flat.map((e) => e.pid));
446
- for (const g of groupFirstSeen.keys()) if (!kept.has(g)) groupFirstSeen.delete(g);
447
- try {
448
- mutateRegistry(GROUPS_DIR, GROUPS_FILE, GROUPS_LOCK, () => flat);
449
- } catch {
450
- /* best-effort: losing the file costs a restart's visibility, never a turn */
451
- }
452
- };
453
-
454
- try {
455
- for (const e of readRegistry(GROUPS_FILE)) {
456
- if (!e?.sessionId || !Number.isInteger(e?.pid)) continue;
457
- if (!sameBoot(e.boot, BOOT)) continue; // another boot's pgid names a stranger now
458
- const set = sessionGroups.get(e.sessionId) ?? new Set();
459
- set.add(e.pid);
460
- sessionGroups.set(e.sessionId, set);
461
- if (Number(e.startedAt) > 0) groupFirstSeen.set(e.pid, Number(e.startedAt));
462
- }
463
- } catch {
464
- /* no registry yet — the ordinary first run */
465
- }
466
-
467
- /** Forget the groups of every id the roster no longer names — a closed tab,
468
- * a finished agent. Nothing reports or stops a group for an id that is not
469
- * live, so holding it only grows the map for the life of the process. */
470
- const pruneSessionGroups = (activeIds) => {
471
- const live = new Set(activeIds);
472
- let changed = false;
473
- for (const id of [...sessionGroups.keys()]) {
474
- if (live.has(id)) continue;
475
- sessionGroups.delete(id);
476
- changed = true;
477
- }
478
- if (changed) persistGroups();
479
- };
480
-
481
- const noteSessionGroup = (sessionId, pgid) => {
482
- if (!sessionId || !pgid) return;
483
- const set = sessionGroups.get(sessionId) ?? new Set();
484
- if (set.has(pgid)) return;
485
- set.add(pgid);
486
- sessionGroups.set(sessionId, set);
487
- persistGroups();
488
- };
489
-
490
- /** This tab's live processes, or null where the machine cannot look. */
491
- const sessionProcesses = (sessionId) => {
492
- if (!processesSupported()) return null;
493
- const known = sessionGroups.get(sessionId);
494
- // The SHAPE `measureProcesses` returns — a bare `[]` here has no `.rows`,
495
- // so the report's `processes` key came out undefined and was dropped from
496
- // the JSON: "looked and found none" arrived as "never looked".
497
- if (!known || known.size === 0) return { rows: [], total: 0 };
498
- const alive = liveGroups(known);
499
- // Only touch the disk when the set actually MOVED. This runs on every
500
- // sweep, for every live tab, forever; an unconditional write would be a
501
- // file rewrite a minute for the life of the daemon to restate what is
502
- // already there — the same reasoning the auto-name relay uses for its
503
- // unchanged-title check.
504
- const changed = alive.size !== known.size;
505
- if (alive.size === 0) sessionGroups.delete(sessionId);
506
- else sessionGroups.set(sessionId, alive);
507
- if (changed) persistGroups();
508
- return measureProcesses(alive, { scrub: envScrub });
509
- };
510
-
511
- /** Every process group this machine is tracking, across every tab — what a
512
- * kill request is checked against before anything is signalled. */
513
- const allKnownGroups = () => {
514
- const all = new Set();
515
- for (const set of sessionGroups.values()) for (const g of set) all.add(g);
516
- return all;
517
- };
518
-
519
-
520
- /**
521
- * EVERY turn settles — the work loop's prime contract. A pending turn nobody
522
- * answers holds one of the tab's slots until the server expires it (24h);
523
- * silence is the worst outcome. So a report that cannot be DELIVERED right
524
- * now is queued in memory and retried at the top of every poll, and a turn
525
- * whose finished answer sits in that queue is never re-run — a session turn
526
- * has side effects (edits, commits, cards), and a dropped 200 must not apply
527
- * them twice.
528
- */
529
- const pendingWorkReports = new Map(); // turnId -> work-turn-done body
530
- const pendingShipReports = new Map(); // sessionId -> ship-done body
531
- /** POST a settle body. Four outcomes, and the split between the last two is
532
- * load-bearing:
533
- * - 'ok' — delivered.
534
- * - 'terminal' — the EXPLICIT per-endpoint statuses under which the server
535
- * will never re-offer the job (403 not this fleet's session, 404 unknown
536
- * turn, 409 ship already settled). Only these may drop the report AND
537
- * the attempts counter: they are the statuses where forgetting is safe
538
- * because the job is gone server-side too.
539
- * - 'reject' — any OTHER 4xx (a 400 from deploy skew, an edge/WAF rule):
540
- * the server refused this BODY, but the job row may still be pending and
541
- * riding every poll. The report must stay QUEUED — it is the skip-guard
542
- * that stops the turn being re-run with all its side effects — but
543
- * re-POSTing a body the server just refused every poll is spam, so
544
- * delivery backs off. Treating this as terminal once re-ran whole
545
- * non-idempotent CLI turns in a loop; treating it as plain retry
546
- * hammered a refused body forever.
547
- * - 'retry' — network errors, 408, 429 and 5xx: nothing was decided. */
548
- const postSettle = async (url, body, terminalStatuses) => {
549
- try {
550
- const res = await fetch(url, {
551
- method: 'POST',
552
- headers: {
553
- Authorization: `Bearer ${FLEET_TOKEN}`,
554
- 'User-Agent': USER_AGENT,
555
- 'Content-Type': 'application/json',
556
- },
557
- signal: AbortSignal.timeout(30_000),
558
- body: JSON.stringify(body),
559
- });
560
- if (res.ok) return 'ok';
561
- if (terminalStatuses.includes(res.status)) return 'terminal';
562
- if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
563
- return 'reject';
564
- return 'retry';
565
- } catch {
566
- return 'retry';
567
- }
568
- };
569
-
570
- /** Retry-After, in ms — seconds or an HTTP-date — capped so one bad or
571
- * hostile header cannot stall a caller for an hour. Falls back to the
572
- * caller's own backoff when the header is absent or unparseable. */
573
- const retryAfterMs = (res, fallbackMs) => {
574
- const h = res?.headers?.get?.('retry-after');
575
- if (h == null) return fallbackMs;
576
- const secs = Number(h);
577
- if (Number.isFinite(secs)) return Math.max(0, Math.min(secs * 1000, 60_000));
578
- const at = Date.parse(h);
579
- return Number.isFinite(at) ? Math.max(0, Math.min(at - Date.now(), 60_000)) : fallbackMs;
580
- };
581
-
582
- /**
583
- * A best-effort settle POST, AWAITED INLINE by a caller with no per-poll
584
- * queue of its own to retry from — unlike `postSettle` above, whose callers
585
- * requeue a 'retry'/'reject' outcome themselves. Bounded attempts: 408/429
586
- * retry honouring Retry-After, 5xx retries on a short fixed backoff, and
587
- * any OTHER 4xx is the server's considered answer — retrying it would just
588
- * spend the same refusal again, so it counts as delivered, the existing
589
- * trace convention. A network error retries the same way and is then
590
- * swallowed: unsettled, and the server expires the job so the asker is
591
- * told, never spun.
592
- */
593
- const postBestEffort = async (url, body, { attempts = 4, timeoutMs = 30_000 } = {}) => {
594
- for (let i = 0; i < attempts; i += 1) {
595
- let res;
596
- try {
597
- res = await fetch(url, {
598
- method: 'POST',
599
- headers: {
600
- Authorization: `Bearer ${FLEET_TOKEN}`,
601
- 'User-Agent': USER_AGENT,
602
- 'Content-Type': 'application/json',
603
- },
604
- signal: AbortSignal.timeout(timeoutMs),
605
- body: JSON.stringify(body),
606
- });
607
- } catch {
608
- if (i === attempts - 1) return false;
609
- await new Promise((r) => setTimeout(r, 2_000 * (i + 1)));
610
- continue;
611
- }
612
- if (res.ok) return true;
613
- if (res.status === 408 || res.status === 429) {
614
- if (i === attempts - 1) return false;
615
- await new Promise((r) => setTimeout(r, retryAfterMs(res, 2_000 * (i + 1))));
616
- continue;
617
- }
618
- if (res.status >= 500) {
619
- if (i === attempts - 1) return false;
620
- await new Promise((r) => setTimeout(r, 2_000 * (i + 1)));
621
- continue;
622
- }
623
- return true; // any other 4xx — the server's considered answer
624
- }
625
- return false;
626
- };
627
-
628
- /** How long a REJECTED report sits out before re-offering its body — long
629
- * enough that a deploy-skew 400 costs a handful of POSTs a day, short
630
- * enough that a server fix picks the report up the same morning. */
631
- const REJECT_RETRY_MS = 10 * 60 * 1000;
632
- const reportBackoff = new Map(); // turnId|sessionId -> earliest next attempt
633
- const settleWorkTurn = async (turnId, payload) => {
634
- const body = { turnId, ...payload };
635
- const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
636
- if (r === 'retry' || r === 'reject') {
637
- pendingWorkReports.set(turnId, body);
638
- if (r === 'reject') reportBackoff.set(turnId, Date.now() + REJECT_RETRY_MS);
639
- } else {
640
- pendingWorkReports.delete(turnId);
641
- workAttempts.delete(turnId);
642
- reportBackoff.delete(turnId);
643
- }
644
- return r;
645
- };
646
- const settleShip = async (sessionId, payload) => {
647
- const body = { sessionId, ...payload };
648
- const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
649
- if (r === 'retry' || r === 'reject') {
650
- pendingShipReports.set(sessionId, body);
651
- if (r === 'reject') reportBackoff.set(sessionId, Date.now() + REJECT_RETRY_MS);
652
- } else {
653
- pendingShipReports.delete(sessionId);
654
- reportBackoff.delete(sessionId);
655
- // The report has landed, so the idempotency path no longer needs the
656
- // branch to exist. See `sweepMergedSessionBranch`.
657
- sweepMergedSessionBranch(sessionId);
658
- // AND THE DIFFSTAT IS NOW WRONG BY DEFINITION. A ship folds base in,
659
- // merges the tip out and deletes the branch, so a fresh measurement reads
660
- // `ahead: 0` with an empty diffstat — and without this the rail keeps
661
- // rendering the ENTIRE pre-ship diff while the transcript two hundred
662
- // pixels away says "Shipped to main". The ship button re-arms over a
663
- // branch that is already merged. Same rule the kill path just learned:
664
- // an action that changes what the machine would measure must cause a new
665
- // measurement, and the 60s sweep is not that.
666
- void reportPlaceWorktrees(sessionId).catch(() => {});
667
- burstListeners(sessionId);
668
- // …and the REPO picture changed too: the session branch is gone and base
669
- // moved. Without this the Repository block keeps counting a branch the
670
- // ship just deleted.
671
- onRepoChanged();
672
- // The ship's push moved the local origin/<base> ref — observe now, so
673
- // the landed report (and anything trailered a ship carried) lands on
674
- // this beat rather than the next 3-minute fetch.
675
- void landed.observe().catch(() => {});
676
- }
677
- return r;
678
- };
679
- /**
680
- * THE TAB'S LIVE NARRATION — the terminal's own stdout, relayed.
681
- *
682
- * A turn used to be a spinner: the tab said "working…" for minutes and the
683
- * only thing that ever appeared was the finished reply. The CLI is printing
684
- * the whole time (thinking, reads, greps, commands), so the honest fix is to
685
- * FORWARD that, not to invent a progress model on the server. Flowviant
686
- * relays; it does not narrate on its own behalf.
687
- *
688
- * Best-effort by construction: throttled to one POST per window (a turn can
689
- * emit hundreds of lines), never awaited by the turn, and every failure is
690
- * swallowed. A spinner must never be able to fail a build. The server clears
691
- * the line at settle, so a daemon killed mid-turn cannot leave one stuck.
692
- */
693
- const ACTIVITY_MIN_MS = 1_500;
694
- const ACTIVITY_KEEP = 4; // the last few lines — a tail, not a log
695
- /** `turnId` scopes the narration to the turn that produced it: a POST
696
- * already on the wire when the turn settles must not re-stamp a "working…"
697
- * line over the finished reply — the server drops narration for a turn
698
- * that is no longer pending. (A session-level pending count can't tell the
699
- * settled turn's stale line from the queued NEXT turn's fresh one.) */
700
- const makeNarrator = (sessionId, turnId, getTools) => {
701
- const recent = [];
702
- let lastSent = 0;
703
- let dirty = false;
704
- let timer = null;
705
- let sending = false;
706
- let stopped = false;
707
- const send = async () => {
708
- if (sending || stopped) return;
709
- sending = true;
710
- dirty = false;
711
- lastSent = Date.now();
712
- const lines = recent.slice(-ACTIVITY_KEEP);
713
- try {
714
- await fetch(ACTIVITY_URL, {
715
- method: 'POST',
716
- headers: {
717
- Authorization: `Bearer ${FLEET_TOKEN}`,
718
- 'User-Agent': USER_AGENT,
719
- 'Content-Type': 'application/json',
720
- },
721
- signal: AbortSignal.timeout(10_000),
722
- body: JSON.stringify({
723
- sessionId,
724
- turnId,
725
- lines,
726
- // The structured tool log so far, riding the same throttled beat.
727
- // Same lifecycle as the lines: overwritten as the turn moves,
728
- // cleared server-side at settle. Absent until something ran.
729
- ...(getTools ? { tools: getTools() } : {}),
730
- }),
731
- });
732
- } catch {
733
- /* narration is decoration — a dropped line is not an incident */
734
- }
735
- sending = false;
736
- if (dirty && !stopped) schedule();
737
- };
738
- const schedule = () => {
739
- if (timer || stopped) return;
740
- const wait = Math.max(0, ACTIVITY_MIN_MS - (Date.now() - lastSent));
741
- timer = setTimeout(() => {
742
- timer = null;
743
- void send();
744
- }, wait);
745
- timer.unref?.(); // never hold the process open for a spinner
746
- };
747
- return {
748
- line(label) {
749
- // Scrub, like every string that leaves this machine: a narration line
750
- // is the CLI's own stdout — a command echoing an env var, a read of a
751
- // config file — and it rides the same uplink the final answer does.
752
- const s = envScrub(String(label ?? ''))
753
- .replace(/\s+/g, ' ')
754
- .trim()
755
- .slice(0, 200);
756
- if (!s || stopped) return;
757
- recent.push(s);
758
- if (recent.length > ACTIVITY_KEEP * 2) recent.shift();
759
- dirty = true;
760
- schedule();
761
- },
762
- stop() {
763
- stopped = true;
764
- if (timer) {
765
- clearTimeout(timer);
766
- timer = null;
767
- }
768
- },
769
- };
770
- };
771
-
772
- /**
773
- * WHERE EACH TAB IS STANDING, and what it holds — the readout a human would
774
- * get by running `git status` in the session's directory, which is the one
775
- * thing they cannot do from a browser.
776
- *
777
- * Two triggers, both cheap: right after a turn settles (the moment the diff
778
- * changed) and a throttled sweep over every live session (a human editing in
779
- * the worktree, a build writing files, a ship landing). Best-effort like the
780
- * narrator: never awaited by a turn, every failure swallowed.
781
- */
782
- const WORKTREE_SWEEP_MS = 60_000;
783
- /** How often the sweep refreshes `origin/<base>` before measuring. The
784
- * behind-count is the whole point of the readout — "someone pushed while you
785
- * were working" — and without a fetch it would only ever count what this
786
- * machine already happened to have. Rarer than the sweep because a fetch is
787
- * network, and a teammate's push being visible within three minutes is the
788
- * same promise the rest of the product makes. */
789
- const WORKTREE_FETCH_MS = 3 * 60_000;
790
- /**
791
- * WHERE EACH SESSION WORKS, learned from the turns we are handed.
792
- *
793
- * Every other beat — the worktree sweep, ship, the preview re-check — has to
794
- * ask the SAME directory the turn ran in, and only the turn job carries
795
- * `place`. Caching it here is what keeps them agreeing without a second
796
- * server→daemon field: a session absent from this map has never run a turn,
797
- * and its own id is the right answer for that case anyway (it is the default
798
- * place, and a session with no turn has no worktree either).
799
- *
800
- * A tab standing in the CHECKOUT is the case this exists for: its directory
801
- * is not `sessions/<id>` and never will be, so a sweep that assumed the
802
- * default would measure a directory that does not exist and report nothing —
803
- * which is exactly why "I still do not see a preview URL" was true of a tab
804
- * opened in the checkout.
805
- */
806
- const sessionPlaces = new Map();
807
- const placeOf = (sessionId) => sessionPlaces.get(sessionId) ?? sessionId;
808
- /**
809
- * Places straight off the roster, so a tab is measured the moment it EXISTS
810
- * rather than after somebody types into it.
811
- *
812
- * Learning only from turn jobs meant a tab nobody had spoken to yet was
813
- * measured at `sessions/<id>` — a directory that does not exist for a tab
814
- * working in the checkout — so it reported no branch, no diffstat and no
815
- * ports, and every control reading those had nothing to render.
816
- *
817
- * The roster is authoritative and turn jobs still agree with it; a session
818
- * the server does not name keeps whatever a turn taught, and failing that its
819
- * own id, which is the pre-places default.
820
- */
821
- /**
822
- * WHERE EACH TAB WORKS — and it has to be able to UNLEARN.
823
- *
824
- * This only ever set. Combined with a server that omitted null places, a tab
825
- * moved from the checkout back to its own worktree simply stopped being
826
- * mentioned, and this map kept the old value forever. `placeDir` decides
827
- * where a turn is SPAWNED and where the worktree is measured, so the browser
828
- * said "its own worktree" while the CLI went on working in the checkout, and
829
- * the tab reported the checkout's listeners as its own. That is the exact
830
- * confusion the whole places feature exists to prevent.
831
- *
832
- * An explicit `null` now means "its own worktree" and DELETES the entry.
833
- * Absence of the whole map still means "an older server said nothing", which
834
- * is the only thing absence can safely mean.
835
- */
836
- const learnPlaces = (map) => {
837
- if (!map || typeof map !== 'object') return;
838
- for (const [sid, place] of Object.entries(map)) {
839
- // VALIDATE AT THE TRUST BOUNDARY. `placeDir` joins this value straight
840
- // into `sessions/<place>` and it is the directory a turn is SPAWNED in
841
- // and a preview port is measured against — the security boundary for the
842
- // whole preview feature. The server resolves place to an enum (never a
843
- // path), but a server bug or compromise sending `../../etc` would
844
- // otherwise point a session's measured directory anywhere on the box and
845
- // defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
846
- // already reject an unsafe segment; checking at the intake covers the
847
- // consumers that do not (the preview-claim `placeDir` did not re-check).
848
- // NOT the only writer: `processWorkTurns` stores a turn job's place too,
849
- // and keeps the same check — validating one intake and not the other is
850
- // one deploy away from validating neither. REPO_PLACE resolves to
851
- // repoRoot, so it is allowed through despite not being a path segment.
852
- if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
853
- sessionPlaces.set(sid, place);
854
- // null / '' / an unsafe value: the server is telling us this tab is in its
855
- // OWN worktree (or is malformed). Falling back to the default requires
856
- // forgetting, not ignoring.
857
- else sessionPlaces.delete(sid);
858
- }
859
- };
860
- /** The DIRECTORY a session works in. Every path that used to build
861
- * `sessions/<id>` by hand goes through here, or a tab in the checkout gets
862
- * measured against a directory that does not exist. */
863
- const placeDir = (sessionId) => {
864
- const place = placeOf(sessionId);
865
- return place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
866
- };
867
-
868
- // ── PUBLISHING AN AGENT'S BRANCH ───────────────────────────────────────────
869
- //
870
- // The server names the target (`agentTurnJobs[].publishTo`) and this machine
871
- // pushes to it. Two laws hold the whole lane up:
872
- //
873
- // A PUSH NEVER BLOCKS OR FAILS A TURN. It is tail work after the settle, it
874
- // is try/catch'd like every sweep, and a failure is REPORTED in git's own
875
- // words rather than thrown. An agent whose remote push fails has still done
876
- // its work, still committed it, and still settled.
877
- //
878
- // THE SERVER LEARNS OF A PUSH ONLY BY BEING TOLD. Nothing here composes a
879
- // name: an absent `publishTo` is a project that has publishing off, or a
880
- // server older than this daemon, and in both the honest behaviour is to push
881
- // nothing and report nothing. Absence keeps one meaning.
882
- /**
883
- * WHAT THIS MACHINE LAST DID WITH EACH AGENT'S BRANCH, by place —
884
- * `{ ref, sha }` for a push that landed, `{ ref, error, at, tried }` for one
885
- * that did not. Mutually exclusive by construction, which is what makes the
886
- * report's two keys mutually exclusive without a second rule.
887
- *
888
- * Process-local on purpose: it is a record of what THIS daemon pushed, so a
889
- * restart re-pushes once and re-reports — a push of the same sha to the same
890
- * ref is a no-op at the remote, and re-learning beats trusting a file about
891
- * something a rebase can invalidate.
892
- */
893
- const agentPublished = new Map();
894
- /**
895
- * WHAT THIS PROCESS HAS SEEN AT EACH AGENT'S REMOTE REF — `{ ref, sha }`, and
896
- * the ONLY input to the push's lease.
897
- *
898
- * It is deliberately NOT `agentPublished`: that map is the REPORT record (what
899
- * this machine pushed, and what the server may be told), while this one is an
900
- * observation of the REMOTE's own position, which a box also gets by FETCHING
901
- * a ref it never pushed. A box that fetch-continues an agent has seen the ref
902
- * and must be able to lease against it; a box that has seen nothing pushes
903
- * with no force flag at all.
904
- *
905
- * Process-local for the same reason the record beside it is: a restart has
906
- * observed nothing, and an unforced push is the honest thing to do about that
907
- * — it lands, or it refuses and says so.
908
- */
909
- const agentRemoteAt = new Map();
910
- /** A failed push retries on the next sweep, but not FOREVER at sweep cadence:
911
- * a remote that refuses (no credentials on this box, a protected prefix)
912
- * would otherwise cost a blocking network call per agent per minute for the
913
- * life of the daemon. A moved branch always retries immediately — the
914
- * throttle is on repeating the SAME attempt, never on new work. */
915
- const PUBLISH_RETRY_MS = 5 * 60_000;
916
174
  /**
917
175
  * A NETWORK GIT CALL, TIMED AND NON-INTERACTIVE.
918
176
  *
@@ -924,2219 +182,93 @@ export function createWorkManager({
924
182
  * turns a prompt into an immediate, reportable failure.
925
183
  */
926
184
  const gitNet = (args, ms) => gitNetIn(args, repoRoot, ms);
927
- /** The tip of an agent's local branch, or null when this box does not hold it
928
- * — which is a perfectly ordinary state (the begun-guard's whole subject) and
929
- * means there is nothing to publish, never that a push failed. */
930
- const agentBranchSha = (place) => {
931
- try {
932
- return (
933
- git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot) || null
934
- );
935
- } catch {
936
- return null;
937
- }
938
- };
939
- /**
940
- * PUSH ONE AGENT'S BRANCH TO THE NAME THE SERVER GAVE IT.
941
- *
942
- * Returns TRUE when the recorded state CHANGED, because the caller's next act
943
- * is a worktree report and a report that says what the last one said is a
944
- * write per minute restating a fact. The rule this serves is the one every
945
- * settle keeps: an action that changes what the machine would measure must
946
- * cause a new measurement — and only then.
947
- *
948
- * Never throws.
949
- */
950
- const publishAgentBranch = async (place, target) => {
951
- const sha = agentBranchSha(place);
952
- if (!sha) return false;
953
- const prev = agentPublished.get(place);
954
- if (prev?.ref === target && prev.sha === sha) return false; // already there
955
- if (
956
- prev?.ref === target &&
957
- prev.error &&
958
- prev.tried === sha &&
959
- Date.now() - (prev.at ?? 0) < PUBLISH_RETRY_MS
960
- )
961
- return false; // the same attempt failed moments ago
962
- // THE LEASE IS THIS PROCESS'S OWN LAST SIGHTING of that ref, and nothing
963
- // else — never git's remote-tracking ref, which this daemon's own sweep
964
- // fetch refreshes (the argument is in `publishPushArgs`). An expectation
965
- // recorded against a DIFFERENT ref is no expectation for this one.
966
- const seen = agentRemoteAt.get(place);
967
- // A ref that is not the server's shape never reaches argv. Silent, because
968
- // a refusal here is a feature not happening on this turn, not a failure of
969
- // it — and a `publishError` about a value we declined to use would be this
970
- // machine reporting on a push it never attempted.
971
- const args = publishPushArgs(place, target, seen?.ref === target ? seen.sha : null);
972
- if (!args) return false;
973
- try {
974
- gitNet(args, 60_000);
975
- agentPublished.set(place, { ref: target, sha });
976
- agentRemoteAt.set(place, { ref: target, sha });
977
- return true;
978
- } catch (e) {
979
- const error = publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''));
980
- agentPublished.set(place, { ref: target, error, at: Date.now(), tried: sha });
981
- // A repeat of a failure already reported is not news; the server's stored
982
- // sentence is already this one.
983
- return !(prev?.error === error && prev.ref === target);
984
- }
985
- };
986
- /**
987
- * BRING A PUBLISHED BRANCH BACK DOWN — the other half of the durability
988
- * promise, and the only thing that lets an agent's work outlive its box.
989
- *
990
- * Reached from ONE place: the begun-guard's refusal arm, where this machine
991
- * has just MEASURED that it holds neither the agent's worktree nor its branch.
992
- * The server only sends `publishedRef` when it heard about a real push, so
993
- * this is not a guess at a remote branch — it is a fetch of one a machine
994
- * reported writing.
995
- *
996
- * ── WHAT IT RECOVERS, AND WHAT IT CANNOT ──
997
- *
998
- * COMMITS COME BACK. The CONVERSATION DOES NOT: the CLI's held context lives
999
- * in the box that ran it and nothing here transports it. The turn kickoff
1000
- * re-prompts from the card, which is the honest continuation — an agent that
1001
- * picks up its own commits and re-reads its own card, never one that remembers
1002
- * the argument. Nothing this returns may be phrased as if it did.
1003
- *
1004
- * THREE ANSWERS, because two would lie. `null` is "there was nothing to try"
1005
- * (no ref, or one whose shape this machine will not put in argv), and it must
1006
- * leave the existing refusal EXACTLY as it was — a sentence about a fetch
1007
- * nobody attempted is worse than the plain refusal. `{ ok: false, why }` is a
1008
- * measured failure, relayed in git's own words. `{ ok: true }` is only ever
1009
- * returned after re-reading the ref: a fetch that exits 0 having created
1010
- * nothing would otherwise walk straight into `placeWtFor` cutting a fresh
1011
- * branch off base — the context-free redo the guard above exists to prevent,
1012
- * wearing this feature's name.
1013
- */
1014
- const fetchPublishedBranch = (place, ref) => {
1015
- const args = publishFetchArgs(ref, place);
1016
- if (!args) return null;
1017
- try {
1018
- gitNet(args, 120_000);
1019
- } catch (e) {
1020
- return {
1021
- ok: false,
1022
- why: publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || '')),
1023
- };
1024
- }
1025
- const sha = agentBranchSha(place);
1026
- if (!sha) return { ok: false, why: 'the fetch reported nothing and left no local branch' };
1027
- // WHAT THIS PROCESS HAS NOW SEEN AT THAT REMOTE REF. A fetch is an
1028
- // observation of the remote's own position, and for a box that continues an
1029
- // agent it never started it is the ONLY one it will ever have — without it
1030
- // this box's first push would carry no lease and would have to go unforced.
1031
- // It is not a `publishAgentBranch` record: this machine pushed nothing, so
1032
- // the server is told nothing.
1033
- agentRemoteAt.set(place, { ref, sha });
1034
- return { ok: true };
1035
- };
1036
185
 
1037
- let lastWorktreeSweep = 0;
1038
- /** Sessions this process has already tried to measure. See `reportWorktrees`. */
1039
- const worktreeSeen = new Set();
1040
- let lastWorktreeFetch = 0;
1041
- let sweepingWorktrees = false;
1042
- /**
1043
- * WHERE THE NEXT SWEEP STARTS.
1044
- *
1045
- * A safety valve with a rotation, and the rotation is the load-bearing half.
1046
- * The sweep used to take `activeIds.slice(0, 20)` — a silent truncation of a
1047
- * list the server builds tabs-first and agent places LAST, so on a project
1048
- * with twenty live tabs no agent was EVER measured: no branch diff, no head
1049
- * sha, and none of their trailered commits ever reached a card. Permanently,
1050
- * because the same twenty won every pass.
1051
- *
1052
- * Chunking removes the cut for any realistic project (see below). The cursor
1053
- * is what makes the residual cap fair rather than arbitrary: past it, the
1054
- * places that missed one sweep are the ones that lead the next.
1055
- */
1056
- let worktreeCursor = 0;
1057
- const postWorktrees = async (reports) => {
1058
- if (!reports.length) return;
1059
- try {
1060
- await fetch(WORKTREES_URL, {
1061
- method: 'POST',
1062
- headers: {
1063
- Authorization: `Bearer ${FLEET_TOKEN}`,
1064
- 'User-Agent': USER_AGENT,
1065
- 'Content-Type': 'application/json',
1066
- },
1067
- signal: AbortSignal.timeout(20_000),
1068
- body: JSON.stringify({ reports }),
1069
- });
1070
- } catch {
1071
- /* a readout — the next sweep carries it */
1072
- }
1073
- };
1074
- const sessionWorktreeReport = (sessionId) => {
1075
- // The session's PLACE, not its name: a tab in the checkout is measured in
1076
- // the checkout, and a tab sharing another tab's worktree is measured there.
1077
- const place = placeOf(sessionId);
1078
- if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
1079
- const wt = placeDir(sessionId);
1080
- const d = worktreeDiff(wt, baseRef());
1081
- if (!d) return null;
1082
- // WHAT IS LISTENING in this worktree, attributed by the CWD of the process
1083
- // holding the socket. It rides the sweep the daemon already makes rather
1084
- // than taking a beat of its own, exactly as the commit trailers do — and
1085
- // like them it needs no version floor, because it is a daemon→server report
1086
- // on an endpoint that already exists. An older server ignores the key.
1087
- //
1088
- // The browser NEVER names a directory and never names a port this did not
1089
- // report: ports are global to a box and a worktree is not, so this
1090
- // measurement is the security boundary for the whole preview feature.
1091
- // `listeningSupported` says whether this machine can measure AT ALL, which
1092
- // is a different fact from finding nothing. Windows reports nothing and a
1093
- // failed scan reports nothing, and both were indistinguishable from an idle
1094
- // worktree — harmless while the only consumer needed a NON-empty array, and
1095
- // a permanent `Starting…` the moment a Run dev offer hangs off an empty one.
1096
- // …AND WHAT IT IS RUNNING, attributed by PROCESS GROUP rather than by cwd.
1097
- // A watcher (`rbxtsc -w`, `tsc --watch`) holds no socket and touches no
1098
- // file for minutes, so it was invisible from a browser in a way it never is
1099
- // in a terminal. Same rules as `listening` beside it: a daemon→server
1100
- // report on an endpoint that already exists, so NO version floor, and
1101
- // `processesSupported` keeps "cannot look" (Windows) apart from "looked and
1102
- // found none", which renders differently.
1103
- const proc = sessionProcesses(sessionId);
1104
- // THE NAME CLAUDE ALREADY GAVE THIS CONVERSATION, relayed.
1105
- //
1106
- // Claude Code titles its own sessions; a Flowviant tab was born "session 3"
1107
- // and stayed that way unless somebody renamed it by hand, so a strip of
1108
- // eight tabs said nothing about any of them. The title is not ours to
1109
- // invent — reading it is the same relay this whole file does, and asking
1110
- // a model for one would be a second brain, which the product forbids.
1111
- //
1112
- // Only CLAUDE tabs have one here: the id is the one the CLI reported at
1113
- // `system.init` and pinned per tab, so a codex or agy tab simply has no
1114
- // marker and reports no title. No runtime check is needed for that — the
1115
- // absent marker IS the check.
1116
- //
1117
- // No version floor: a daemon→server report on an endpoint that already
1118
- // exists, so an older daemon sends no key and the server leaves the name
1119
- // exactly as it found it.
1120
- let title = null;
1121
- try {
1122
- const marker = sessionMetaPath(wt, 'flowviant-claude-session', sessionId);
1123
- if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
1124
- } catch {
1125
- /* no marker yet — this tab has not spoken, or is not Claude */
1126
- }
1127
- // The TOTAL rides beside the capped rows, because a list silently cut at
1128
- // twelve answers "what is running in here" with a number that is not true.
1129
- // `wrangler dev` alone opens nine, so the old cap of eight was already
1130
- // dropping a row on an ordinary stack with nothing on the wire to say so.
1131
- const lis = measureListeners(wt);
1132
- /**
1133
- * WHICH BOX MEASURED THIS — on an agent's report only.
1134
- *
1135
- * The server stores it on the agent row so a LATER turn can be checked
1136
- * against the box that actually holds the work: an agent's branch and its
1137
- * conversation exist on one machine's disk until somebody approves it, and
1138
- * two boxes on one credential can both be offered its turns. The daemon
1139
- * reports what it is; the server does the comparing.
1140
- *
1141
- * A daemon→server report on an endpoint that already exists, so no floor —
1142
- * an older daemon sends no key and the agent is left UNATTRIBUTED, which is
1143
- * a third state the server reads as "nobody said" rather than as "not this
1144
- * box". `envpub` is the identity for the same reason the poll uses it: it is
1145
- * durable per box, and the hostname beside it is only a label for a person
1146
- * to read. Absent when the keypair is unreadable — that machine is exempt
1147
- * from arbitration entirely, which is the fail-open direction.
1148
- *
1149
- * TABS GET NOTHING. A tab's place is shared by design and its work is a
1150
- * human's own directory; attributing one would be a fact with no reader.
1151
- */
1152
- const pub = sessionId.startsWith('a-') && sessionId.length > 2 ? myPubB64() : null;
1153
- /**
1154
- * …AND WHAT THIS MACHINE PUSHED OF IT (0.86.0).
1155
- *
1156
- * The ONE road by which the server learns a push happened: it composes the
1157
- * target name and sends it, and stores nothing until a machine reports
1158
- * back. So an unreported push renders nothing and no surface can name a ref
1159
- * nobody can pull — the same "never assert what you did not observe" rule
1160
- * the box id beside it keeps.
1161
- *
1162
- * NO KEY AT ALL until a target arrives, which is what lets absence keep its
1163
- * one meaning: an older server, or a project with publishing off, leaves
1164
- * the agent reading as never published rather than as failed.
1165
- *
1166
- * Mutually exclusive without a rule of its own, because the state it reads
1167
- * holds a sha or an error and never both.
1168
- */
1169
- const pushed = sessionId.startsWith('a-') ? agentPublished.get(sessionId) : null;
1170
- return {
1171
- sessionId,
1172
- ...d,
1173
- ...(pub ? { box: { id: pub, name: MACHINE_HOST } } : {}),
1174
- ...(pushed?.sha ? { published: { ref: pushed.ref, sha: pushed.sha } } : {}),
1175
- ...(pushed?.error ? { publishError: pushed.error } : {}),
1176
- listening: lis.rows,
1177
- listeningTotal: lis.total,
1178
- listeningSupported: listenersSupported(),
1179
- ...(proc === null ? {} : { processes: proc.rows, processesTotal: proc.total }),
1180
- processesSupported: processesSupported(),
1181
- ...(title ? { title } : {}),
1182
- };
1183
- };
1184
- /** One session, now. */
1185
- const reportSessionWorktree = async (sessionId) => {
1186
- const r = sessionWorktreeReport(sessionId);
1187
- if (r) await postWorktrees([r]);
1188
- };
1189
-
1190
- /**
1191
- * …AND EVERY OTHER TAB STANDING IN THE SAME DIRECTORY.
1192
- *
1193
- * One tab is not one directory any more. Since tabs moved into the driver's
1194
- * own folder, every tab a person owns resolves to the SAME place — so a turn
1195
- * in tab A changed the directory tab B is also describing, and only tab A was
1196
- * re-measured. Tab B went on rendering its pre-turn `+A −D` for up to a
1197
- * minute, which makes the tab strip visibly disagree with itself about one
1198
- * directory. That is the "why are two tabs showing one dev server" confusion
1199
- * the places readout exists to END, arriving through the diffstat instead.
1200
- *
1201
- * It fires on EVERY turn, every ship and every stop, which is what made this
1202
- * the most-hit instance of the rule and the least visible: nothing is wrong
1203
- * on the tab you are looking at.
1204
- *
1205
- * Bounded by the live set the roster last handed us, and the reports go in
1206
- * ONE post — the endpoint is already batched, and a tab per request would
1207
- * turn a five-tab place into five round trips on every settle.
1208
- */
1209
- const reportPlaceWorktrees = async (sessionId) => {
1210
- const place = placeOf(sessionId);
1211
- const ids = [sessionId];
1212
- // `worktreeSeen` is the roster's own live set, pruned to `activeWorkSessions`
1213
- // on every sweep — so this can never report a tab that has closed, and it
1214
- // needs no second source of truth about which tabs exist.
1215
- for (const id of worktreeSeen) {
1216
- if (id !== sessionId && placeOf(id) === place) ids.push(id);
1217
- }
1218
- const reports = ids.map(sessionWorktreeReport).filter(Boolean);
1219
- if (reports.length) await postWorktrees(reports);
1220
- };
1221
-
1222
- /**
1223
- * THE FIRST MINUTE AFTER A SETTLE — when "run the dev server" actually binds.
1224
- *
1225
- * The settle-time report fires the moment the reply lands, but a dev server
1226
- * the agent just started usually takes a few more seconds to open its socket
1227
- * (vite boots, next compiles). It therefore missed the settle measurement and
1228
- * waited the full 60s sweep — up to a minute of "nothing is running here"
1229
- * over a server that was already up, which is the slowest link in the whole
1230
- * "ask for dev → see the preview" chain. Asked directly: "how do we make it
1231
- * more responsive when the user prompts claude to run dev to waiting for it
1232
- * to appear on the preview?"
1233
- *
1234
- * A DECAYING BURST, and it re-CHECKS before it re-REPORTS: each beat walks
1235
- * /proc for the place's listeners (purely local, no git, no network) and only
1236
- * when the PORT SET actually changed does the full place report run and post.
1237
- * A settle where nothing ever binds costs five /proc walks and zero posts; a
1238
- * dev server that binds at +7s is on the wire at +9 instead of +60. The burst
1239
- * for a place restarts on its next settle, so overlapping turns cannot stack
1240
- * timers, and every timer is unref'd — a readout must never hold the process
1241
- * open.
1242
- *
1243
- * This also serves the OPPOSITE transition for free: a stopped dev server
1244
- * (the panel's Stop, a ctrl-C in a terminal) vanishes from the port set the
1245
- * same way it appeared, so the preview's "origin gone" story starts in
1246
- * seconds too.
1247
- */
1248
- const LISTEN_BURST_DELAYS_MS = [4_000, 9_000, 16_000, 30_000, 55_000];
1249
- const listenBursts = new Map(); // place -> timers[]
1250
- const listenSignature = (wt) => {
1251
- try {
1252
- const l = measureListeners(wt);
1253
- return l.rows.map((r) => r.port).sort((a, b) => a - b).join(',');
1254
- } catch {
1255
- return '';
1256
- }
1257
- };
1258
- const burstListeners = (sessionId) => {
1259
- try {
1260
- const place = placeOf(sessionId);
1261
- for (const t of listenBursts.get(place) ?? []) clearTimeout(t);
1262
- const wt = placeDir(sessionId);
1263
- // Captured alongside the settle report, so only a CHANGE after this
1264
- // moment triggers a post — the settle report already said the rest.
1265
- let last = listenSignature(wt);
1266
- const timers = LISTEN_BURST_DELAYS_MS.map((d) =>
1267
- setTimeout(() => {
1268
- try {
1269
- const sig = listenSignature(wt);
1270
- if (sig === last) return;
1271
- last = sig;
1272
- void reportPlaceWorktrees(sessionId).catch(() => {});
1273
- } catch {
1274
- /* a readout — the sweep still carries it */
1275
- }
1276
- }, d)
1277
- );
1278
- for (const t of timers) t.unref?.();
1279
- listenBursts.set(place, timers);
1280
- } catch {
1281
- /* never let the burst break a settle */
1282
- }
1283
- };
1284
- /** Every live session, throttled — called from the reconcile loop. */
1285
- /**
1286
- * A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
1287
- *
1288
- * The throttle is GLOBAL, not per-session, so a tab opened one second after a
1289
- * sweep waited the remaining fifty-nine for its first measurement — and until
1290
- * it lands there is no branch, no directory and no listeners anywhere in the
1291
- * product, because every one of those readouts is gated on a measurement and
1292
- * renders nothing rather than inventing a state. Asked directly: "how come it
1293
- * takes a while for a new session to show branch and worktree and listeners
1294
- * after i create a new tab."
1295
- *
1296
- * ATTEMPTED, never MEASURED, is what is remembered. A session whose directory
1297
- * cannot be read yet — a pre-places daemon that has not cut one, a worktree
1298
- * mid-creation — would otherwise be "unmeasured" on every poll and force a
1299
- * full sweep each time. Recording the attempt bounds it at exactly one extra
1300
- * sweep per session, ever, after which the normal cadence carries it.
1301
- */
1302
- const reportWorktrees = (activeIds) => {
1303
- if (!Array.isArray(activeIds) || activeIds.length === 0) return;
1304
- if (sweepingWorktrees) return;
1305
- const firstSight = activeIds.some((id) => !worktreeSeen.has(id));
1306
- if (!firstSight && Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
1307
- // Bounded to LIVE sessions: a long-running daemon must not accumulate a
1308
- // uuid per tab anyone has ever opened. Pruning also means a reopened tab is
1309
- // measured immediately again, which is the same answer for the same reason.
1310
- const live = new Set(activeIds);
1311
- for (const id of worktreeSeen) if (!live.has(id)) worktreeSeen.delete(id);
1312
- for (const id of activeIds) worktreeSeen.add(id);
1313
- // The publish record is bounded the same way and for the same reason: an
1314
- // agent the roster has stopped naming is done, its ref is the server's
1315
- // business now (the merge lane deletes it when the work lands), and a
1316
- // long-running daemon must not accumulate a row per agent that ever ran.
1317
- for (const id of agentPublished.keys()) if (!live.has(id)) agentPublished.delete(id);
1318
- for (const id of agentRemoteAt.keys()) if (!live.has(id)) agentRemoteAt.delete(id);
1319
- pruneSessionGroups(activeIds);
1320
- sweepingWorktrees = true;
1321
- lastWorktreeSweep = Date.now();
1322
- void (async () => {
1323
- try {
1324
- // Refresh the base before measuring, so "3 new on main" means what a
1325
- // person thinks it means. Throttled, best-effort, and never fatal: an
1326
- // offline machine reports the counts it can still compute.
1327
- if (Date.now() - lastWorktreeFetch >= WORKTREE_FETCH_MS) {
1328
- lastWorktreeFetch = Date.now();
1329
- try {
1330
- // ASYNC and TIMED: this runs every three minutes with nobody
1331
- // watching, and a synchronous fetch against a remote that prompts
1332
- // on /dev/tty or a connection gone half-open froze the whole
1333
- // daemon — no polls, no settles, no lease renewals — until it
1334
- // returned. See `gitNetAsync`.
1335
- await gitNetAsync(['fetch', 'origin', '--quiet'], repoRoot);
1336
- } catch {
1337
- /* offline, no remote, or timed out — the numbers just age */
1338
- }
1339
- // The fetch may have moved the base tip — walk and report what
1340
- // landed. Best-effort like everything in this sweep.
1341
- void landed.observe().catch(() => {});
1342
- }
1343
- /**
1344
- * MEASURED IN CHUNKS, not truncated to one.
1345
- *
1346
- * The server's endpoint takes twenty entries per request, and this read
1347
- * that bound as "measure twenty places" — so everything past the
1348
- * twentieth was silently never measured, and the server builds that
1349
- * list with agent places at the END. A project with twenty live tabs
1350
- * therefore measured no agent at all: their review pane showed no
1351
- * branch diff, `checkIsStale` had no head to compare against, and their
1352
- * commits never reached the cards they name.
1353
- *
1354
- * The cap on a REQUEST is not a cap on the WORK. Several requests of
1355
- * twenty cost several round trips and each is validated per entry
1356
- * exactly as before, so no contract changes and no floor is needed.
1357
- *
1358
- * `SWEEP_MAX_PLACES` is a bound on the MACHINE — each place costs a
1359
- * `git diff`, a listener scan and a process scan — and the cursor
1360
- * rotates so a project past it still measures everything, just across
1361
- * successive sweeps instead of one.
1362
- */
1363
- const SWEEP_MAX_PLACES = 60;
1364
- const CHUNK = 20;
1365
- const total = activeIds.length;
1366
- const start = total > SWEEP_MAX_PLACES ? worktreeCursor % total : 0;
1367
- const take = Math.min(total, SWEEP_MAX_PLACES);
1368
- // Rotated slice, so the tail of a long list leads the next sweep rather
1369
- // than never being reached.
1370
- const order = Array.from({ length: take }, (_, i) => activeIds[(start + i) % total]);
1371
- worktreeCursor = total > SWEEP_MAX_PLACES ? (start + take) % total : 0;
1372
- const reports = [];
1373
- for (const id of order) {
1374
- /**
1375
- * KEEP A PUBLISHED BRANCH CURRENT, not merely born.
1376
- *
1377
- * A settle publishes what the turn just wrote, which covers almost
1378
- * everything — but a branch also moves without a turn: the stale
1379
- * path folds base in before a merge, and an operator can commit in
1380
- * the agent's worktree by hand. Without this the remote ref would sit
1381
- * at whatever the last turn left and the durability claim would be
1382
- * quietly false for exactly the branches somebody is working on.
1383
- *
1384
- * ONLY where a target is already known. Nothing here composes a name,
1385
- * so an agent this process has never been told to publish is
1386
- * untouched — and the sha compare inside makes the resting cost of
1387
- * this loop one `rev-parse` per agent.
1388
- */
1389
- const known = agentPublished.get(id);
1390
- if (known?.ref) await publishAgentBranch(id, known.ref);
1391
- const r = sessionWorktreeReport(id);
1392
- if (r) reports.push(r);
1393
- }
1394
- // One POST per chunk. Awaited in sequence rather than fired together:
1395
- // this runs on the machine's own poll beat and a burst of parallel
1396
- // writes to the same rows buys nothing.
1397
- for (let i = 0; i < reports.length; i += CHUNK) {
1398
- await postWorktrees(reports.slice(i, i + CHUNK));
1399
- }
1400
- } finally {
1401
- sweepingWorktrees = false;
1402
- }
1403
- })();
1404
- };
1405
-
1406
- /**
1407
- * FILES THE HUMAN ATTACHED, brought to where a CLI can read them.
1408
- *
1409
- * A screenshot in a chat bubble is useless to an agent; a path is not. So the
1410
- * turn's attachments are downloaded into `.flowviant/uploads/` inside the
1411
- * session's own worktree and the prompt is handed the relative paths.
1412
- *
1413
- * `.flowviant/` rather than the repo proper, and gitignored-or-not it is
1414
- * never committed by us: these are the human's inputs to a conversation, not
1415
- * project files. The name is re-sanitized HERE even though the server already
1416
- * did it — this string becomes a path on someone's machine, and one place
1417
- * doing that check is one deploy away from being zero places.
1418
- */
1419
- const UPLOAD_DIR = '.flowviant/uploads';
1420
- const ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
1421
- // `safeFileName`, verbatim (apps/api/src/routes/sessionsAttachments.routes.ts):
1422
- // THE EXTENSION SURVIVES A CUT (2026-09-24). A bare `slice(0, 80)` used to
1423
- // truncate an over-long name mid-extension; an over-long stem is now cut
1424
- // and an 8-hex FNV-1a hash of the WHOLE sanitised name is appended before
1425
- // the extension — stable, at most 80 characters, and idempotent over its
1426
- // own output, so re-applying it to what the server already sanitized is a
1427
- // no-op and the two sides never disagree about the cut.
1428
- const safeUploadFnv1a8 = (s) => {
1429
- let h = 0x811c9dc5;
1430
- for (let i = 0; i < s.length; i++) {
1431
- h ^= s.charCodeAt(i);
1432
- h = Math.imul(h, 0x01000193);
1433
- }
1434
- return (h >>> 0).toString(16).padStart(8, '0');
1435
- };
1436
- const SAFE_UPLOAD_EXT_RE = /^[A-Za-z0-9]{1,10}$/;
1437
- const safeUploadName = (raw) => {
1438
- const clean = String(raw ?? '')
1439
- .split(/[\\/]/)
1440
- .pop()
1441
- .replace(/[^A-Za-z0-9._-]/g, '_')
1442
- .replace(/^[.-]+/, '');
1443
- if (!clean) return 'attachment';
1444
- if (clean.length <= 80) return clean;
1445
- const dot = clean.lastIndexOf('.');
1446
- const ext = dot > 0 && SAFE_UPLOAD_EXT_RE.test(clean.slice(dot + 1)) ? clean.slice(dot + 1) : '';
1447
- const stem = ext ? clean.slice(0, dot) : clean;
1448
- const tag = `-${safeUploadFnv1a8(clean)}`;
1449
- const room = 80 - tag.length - (ext ? ext.length + 1 : 0);
1450
- return `${stem.slice(0, room)}${tag}${ext ? `.${ext}` : ''}`;
1451
- };
1452
- /** @returns relative paths written, in the order the human attached them. */
1453
- const fetchAttachments = async (wt, attachments) => {
1454
- if (!Array.isArray(attachments) || attachments.length === 0) return [];
1455
- const dir = join(wt, UPLOAD_DIR);
1456
- try {
1457
- mkdirSync(dir, { recursive: true });
1458
- } catch {
1459
- return [];
1460
- }
1461
- // "Never committed by us" has to be true for GIT, not just for this code:
1462
- // an untracked `.flowviant/` makes the whole worktree dirty, which refuses
1463
- // every ship, exempts the tree from closed-tab retirement forever, and
1464
- // shows the human's own uploads in the rail as session changes. Same
1465
- // mechanism the materialized env files used until the vault was deleted —
1466
- // the exclude file git actually reads (git.mjs, where the helper moved when
1467
- // env.mjs shrank), which already skips lines it has written before, so
1468
- // calling it per fetch is idempotent. NARROWED 2026-09-23 to the paths
1469
- // this daemon writes (`FLOWVIANT_OWN_PATHS`, knowledge.mjs says why): the
1470
- // whole-directory line also hid a repo's own new `.flowviant/check.json`
1471
- // from its agent's `git add -A`.
1472
- excludeInWorktree(wt, FLOWVIANT_OWN_PATHS);
1473
- const written = [];
1474
- for (const a of attachments.slice(0, 8)) {
1475
- if (!a?.id || typeof a.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(a.id)) continue;
1476
- if (Number(a.size) > ATTACHMENT_MAX_BYTES) continue;
1477
- try {
1478
- const res = await fetch(`${ATTACHMENT_URL}/${a.id}`, {
1479
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
1480
- signal: AbortSignal.timeout(60_000),
1481
- });
1482
- if (!res.ok) continue;
1483
- const buf = Buffer.from(await res.arrayBuffer());
1484
- if (buf.byteLength === 0 || buf.byteLength > ATTACHMENT_MAX_BYTES) continue;
1485
- // Collisions are real (two screenshots both named Screenshot.png), and
1486
- // silently overwriting one with the other loses a file the human sent.
1487
- let name = safeUploadName(a.name);
1488
- if (existsSync(join(dir, name))) {
1489
- const dot = name.lastIndexOf('.');
1490
- const stem = dot > 0 ? name.slice(0, dot) : name;
1491
- const ext = dot > 0 ? name.slice(dot) : '';
1492
- name = `${stem}-${String(a.id).slice(0, 6)}${ext}`;
1493
- }
1494
- writeFileSync(join(dir, name), buf);
1495
- written.push(`${UPLOAD_DIR}/${name}`);
1496
- } catch {
1497
- /* one file failing must not fail the turn — the prompt lists what
1498
- actually arrived, so the agent never chases a path that isn't there */
1499
- }
1500
- }
1501
- return written;
1502
- };
1503
-
1504
- let flushingReports = false;
1505
- const flushWorkReports = async () => {
1506
- // Held artifact uploads ride this beat too — the settle retry's own shape —
1507
- // and are never awaited: a readout's retry must not hold a settle's.
1508
- void artifacts.retryPending().catch(() => {});
1509
- if (flushingReports) return;
1510
- if (pendingWorkReports.size === 0 && pendingShipReports.size === 0) return;
1511
- flushingReports = true;
1512
- try {
1513
- for (const [id, body] of [...pendingWorkReports]) {
1514
- // A rejected body sits out its backoff; the queued entry itself stays
1515
- // — it is the skip-guard against re-running a turn whose side effects
1516
- // already happened.
1517
- if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
1518
- const r = await postSettle(WORK_DONE_URL, body, [403, 404]);
1519
- if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
1520
- else if (r !== 'retry') {
1521
- pendingWorkReports.delete(id);
1522
- workAttempts.delete(id);
1523
- reportBackoff.delete(id);
1524
- }
1525
- }
1526
- for (const [id, body] of [...pendingShipReports]) {
1527
- if ((reportBackoff.get(id) ?? 0) > Date.now()) continue;
1528
- const r = await postSettle(SHIP_DONE_URL, body, [403, 409]);
1529
- if (r === 'reject') reportBackoff.set(id, Date.now() + REJECT_RETRY_MS);
1530
- else if (r !== 'retry') {
1531
- pendingShipReports.delete(id);
1532
- reportBackoff.delete(id);
1533
- // Delivered late is still delivered — same sweep as the immediate
1534
- // path, and it must be here too or a report that needed a retry
1535
- // would leave its branch behind forever.
1536
- sweepMergedSessionBranch(id);
1537
- }
1538
- }
1539
- } finally {
1540
- flushingReports = false;
1541
- }
1542
- };
1543
-
1544
- /**
1545
- * The work credential, ONE PER SESSION. The server binds each minted token
1546
- * to the sessionId in the mint body and the MCP layer refuses it for any
1547
- * other session, so a process-wide token would fail every tab but the one
1548
- * that minted it. Cached per session, re-minted near expiry (the endpoint
1549
- * rotates on every mint; per-session chaining means no turn is in flight
1550
- * for the session when its next turn mints). 404 means the server no longer
1551
- * holds that session for this fleet — a fact for the turn to settle with,
1552
- * not a retry.
1553
- */
1554
- const workTokens = new Map(); // sessionId -> { token, mintedAt }
1555
- const mintWorkToken = async (sessionId, force = false) => {
1556
- const cached = workTokens.get(sessionId);
1557
- const freshEnoughS = getLeaseTtl() - REFRESH_BEFORE_SECONDS;
1558
- if (cached && !force && (Date.now() - cached.mintedAt) / 1000 < freshEnoughS)
1559
- return { token: cached.token };
1560
- try {
1561
- const res = await fetch(WORK_TOKEN_URL, {
1562
- method: 'POST',
1563
- headers: {
1564
- Authorization: `Bearer ${FLEET_TOKEN}`,
1565
- 'User-Agent': USER_AGENT,
1566
- 'Content-Type': 'application/json',
1567
- },
1568
- signal: AbortSignal.timeout(30_000),
1569
- // The instance is what CLAIMS the session lease server-side. Two
1570
- // daemons share one fleet credential, so the token cannot say which of
1571
- // us is serving this tab — and the mint is the moment that matters:
1572
- // there is one work-token row per session and minting ROTATES it, so a
1573
- // second mint revokes the first daemon's live secret mid-turn.
1574
- body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE }),
1575
- });
1576
- if (res.status === 404) return { gone: true };
1577
- // 409 — another daemon on this credential holds the session. Not ours to
1578
- // serve and not a retry: stand down and let the holder answer.
1579
- if (res.status === 409) return { heldElsewhere: true };
1580
- if (!res.ok) return null;
1581
- const token = (await res.json().catch(() => null))?.data?.token ?? null;
1582
- if (!token) return null;
1583
- workTokens.set(sessionId, { token, mintedAt: Date.now() });
1584
- return { token };
1585
- } catch {
1586
- return null;
1587
- }
1588
- };
1589
-
1590
- /**
1591
- * This tab's worktree — its held context, expressed as a place, ON A BRANCH.
1592
- *
1593
- * Fresh: branch `session/<id>` off the current base. Existing: touched not at
1594
- * all — no fetch-reset-clean like a plan directory, because the dirty state
1595
- * is the point. If the directory was retired but the branch survives, the
1596
- * worktree re-attaches to the branch and the committed work is still there.
1597
- */
1598
- /**
1599
- * WHERE A SESSION WORKS — its PLACE, which is a directory on a branch.
1600
- *
1601
- * A session used to BE a worktree: one tab, one directory, cut at birth and
1602
- * retired at close. That binding was never an isolation guarantee — a turn
1603
- * runs with permissions skipped, so the worktree is a starting directory and
1604
- * not a fence, and any agent could always `cd` into another one. The product
1605
- * was asserting an invariant it did not have.
1606
- *
1607
- * So a session now REFERENCES a place rather than being one. Many sessions
1608
- * may name the same place; a session may name the repo checkout itself; and
1609
- * `session/<own-id>` is simply the DEFAULT place, cut fresh at first turn,
1610
- * which is why an absent `place` behaves exactly as every existing tab does.
1611
- *
1612
- * `'repo'` IS NOT A DIRECTORY NAME AND MUST NOT BECOME ONE. It resolves to
1613
- * the checkout the daemon already serves — never created, never retired,
1614
- * because it is not ours to remove. The value reaching here is a server-side
1615
- * enum, never a browser-supplied path: `sessions.routes.ts` resolves it the
1616
- * same way adoption resolves a cwd, and for the same reason.
1617
- */
1618
- const placeWtFor = (placeId, baseAt) => {
1619
- if (placeId === REPO_PLACE) {
1620
- // The checkout. `fresh: false` on purpose — nothing was opened, so no
1621
- // caller may treat this as a newly-cut branch.
1622
- return { wt: repoRoot, fresh: false };
1623
- }
1624
- if (!isSafePathSegment(placeId)) return null;
1625
- const sessionId = placeId;
1626
- const wt = join(baseDir, 'sessions', sessionId);
1627
- const fresh = !existsSync(wt);
1628
- if (fresh) {
1629
- const branch = `session/${sessionId}`;
1630
- // `baseAt` is the adoption override: a tab born from a terminal session
1631
- // branches from THAT checkout's HEAD, because the conversation being
1632
- // resumed was had against those commits — putting it on the project base
1633
- // would hand it a repo state it has never seen. Everything else is
1634
- // unchanged, the attach fallback included: a surviving branch already
1635
- // chose its base, and re-basing it here would move committed work.
1636
- const at = baseAt || baseRef();
1637
- try {
1638
- git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
1639
- } catch {
1640
- git(['worktree', 'prune'], repoRoot);
1641
- // A directory and a branch just stopped existing. The Repository block
1642
- // would otherwise keep listing both until its own 60s scan came round.
1643
- onRepoChanged();
1644
- try {
1645
- // The branch may already exist (a retired directory's work) — attach.
1646
- git(['worktree', 'add', wt, branch], repoRoot);
1647
- } catch {
1648
- try {
1649
- git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
1650
- } catch {
1651
- return null;
1652
- }
1653
- }
1654
- }
1655
- // NOTHING IS MATERIALIZED INTO A FRESH WORKTREE ANY MORE (2026-09-21),
1656
- // and nothing replaced it.
1657
- //
1658
- // Two blocks stood here: one that wrote the decrypted vault bundle into a
1659
- // newly-created worktree, and a second that retried on the next turn for
1660
- // the restart window where the bundle had not warmed yet. The vault is
1661
- // deleted — the owner: "no i dont want it" — so Flowviant holds no secret
1662
- // for this project and has nothing to write.
1663
- //
1664
- // WHAT THE SOURCE OF A WORKTREE'S ENV IS NOW: the repo's own `.env`
1665
- // files, wherever the repo puts them. A worktree branches from the
1666
- // checkout, and a gitignored `.env` is by definition not in a fresh one —
1667
- // which is the ordinary behaviour of `git worktree add`, the same thing a
1668
- // person gets typing it themselves, and the operator's problem to solve
1669
- // the way they already solve it (a symlink, a `direnv`, a copy in a
1670
- // setup script). This product no longer claims otherwise, and the claim
1671
- // is what was worth deleting: a promise that every session tab comes up
1672
- // with the team's secrets is only kept while a vault exists to keep it.
1673
- //
1674
- // The CLI child still inherits the daemon's own environment exactly as it
1675
- // always did (claude.mjs spawns with `{...process.env}`), so anything
1676
- // exported in the shell the daemon was started from is present here.
1677
- }
1678
- return { wt, fresh };
1679
- };
1680
-
1681
- /**
1682
- * A file in the worktree's PRIVATE git dir (…/.git/worktrees/<name>). It
1683
- * travels with the worktree, dies with `git worktree remove`, and is
1684
- * invisible to `git status` — so nothing stored here can ever make the
1685
- * session look dirty (a dirty tree refuses ships). A marker file in the
1686
- * working tree itself would show up as an untracked path and block every
1687
- * ship of an otherwise-clean session.
1688
- */
1689
- /**
1690
- * A file beside the worktree's git dir, holding something about ONE TAB.
1691
- *
1692
- * `scope` is the session id and is NOT optional for anything per-tab. These
1693
- * markers were named bare — `flowviant-codex-thread`, `flowviant-agy-
1694
- * conversation` — which was unambiguous while one directory meant one tab.
1695
- * The day tabs moved into their driver's project folder, every tab there
1696
- * started reading and writing ONE marker: tab B would resume tab A's codex
1697
- * thread, and the last turn to finish would overwrite the id for both.
1698
- *
1699
- * The turn LOCK is deliberately still un-scoped — it guards the directory
1700
- * against a second CLI, which is a property of the place and not of a tab.
1701
- */
1702
- const sessionMetaPath = (wt, name, scope) => {
1703
- try {
1704
- const safe = scope && /^[A-Za-z0-9_-]{1,64}$/.test(String(scope)) ? `-${scope}` : '';
1705
- return join(git(['rev-parse', '--absolute-git-dir'], wt), `${name}${safe}`);
1706
- } catch {
1707
- return null;
1708
- }
1709
- };
1710
-
1711
- /**
1712
- * Carry a terminal checkout's DIRTY state into a fresh adopt worktree. The
1713
- * source is strictly READ-ONLY — nothing here writes to it, because it is
1714
- * the human's own checkout and adoption promises to leave it exactly as the
1715
- * closed terminal did. Tracked changes travel as one binary patch staged
1716
- * through the worktree's PRIVATE git dir (invisible to status, dies with the
1717
- * tree); untracked files are copied one by one, skipping anything over 5MB.
1718
- *
1719
- * Returns '' or ONE bracketed line for the turn's prompt: a carry problem is
1720
- * the AGENT's to explain to the user, never a reason to fail the adoption —
1721
- * the conversation is the thing being adopted, and it resumes either way.
1722
- */
1723
- const carryDirtyState = (srcCwd, wt, sessionId) => {
1724
- const problems = [];
1725
- try {
1726
- // A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
1727
- // text file) must round-trip byte-exact or the apply corrupts what it
1728
- // carries. 64MB of headroom — a dirtier tree than that fails the read
1729
- // here and is SAID, below, rather than half-applied.
1730
- const patch = execFileSync('git', ['diff', 'HEAD', '--binary'], {
1731
- cwd: srcCwd,
1732
- stdio: ['ignore', 'pipe', 'pipe'],
1733
- maxBuffer: 64 * 1024 * 1024,
1734
- });
1735
- if (patch.length) {
1736
- const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch', sessionId);
1737
- if (!patchPath) throw new Error('no private git dir to stage the patch in');
1738
- try {
1739
- writeFileSync(patchPath, patch);
1740
- git(['apply', '--whitespace=nowarn', patchPath], wt);
1741
- } finally {
1742
- try {
1743
- rmSync(patchPath, { force: true });
1744
- } catch {
1745
- /* best-effort — the private git dir dies with the worktree anyway */
1746
- }
1747
- }
1748
- }
1749
- } catch {
1750
- problems.push(
1751
- 'their uncommitted TRACKED changes did not carry over (they are still in the terminal checkout, untouched)'
1752
- );
1753
- }
1754
- try {
1755
- const skipped = [];
1756
- for (const rel of splitNul(
1757
- gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], srcCwd)
1758
- )) {
1759
- try {
1760
- const from = join(srcCwd, rel);
1761
- // lstat, not stat: a symlink is carried as itself, and its own size
1762
- // is what the 5MB budget judges — never the file it points at.
1763
- if (lstatSync(from).size > 5 * 1024 * 1024) {
1764
- skipped.push(rel);
1765
- continue;
1766
- }
1767
- const to = join(wt, rel);
1768
- mkdirSync(dirname(to), { recursive: true });
1769
- cpSync(from, to);
1770
- } catch {
1771
- skipped.push(rel);
1772
- }
1773
- }
1774
- if (skipped.length) {
1775
- problems.push(
1776
- `${skipped.length} untracked file${skipped.length === 1 ? '' : 's'} did not carry (over 5MB or unreadable): ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ', …' : ''}`
1777
- );
1778
- }
1779
- } catch {
1780
- problems.push(
1781
- 'untracked files could not be listed in the terminal checkout, so none were carried'
1782
- );
1783
- }
1784
- return problems.length
1785
- ? `[ADOPTION NOTE from the daemon — tell the user plainly at the start of your reply: ${problems.join('; ')}.]`
1786
- : '';
1787
- };
1788
-
1789
- /**
1790
- * WHICH CLI drives this session — picked ONCE, on the first turn, and pinned
1791
- * in the worktree's meta dir. The held context belongs to the CLI that made
1792
- * it: `--continue` under a different binary is a different brain wearing the
1793
- * session's half-finished state (the dispatch path pins heldRuntime for the
1794
- * same reason). If the pinned CLI has left the machine, the turn settles
1795
- * honestly instead of substituting. A retired-and-reattached directory has
1796
- * no marker and no held context either, so re-picking there is correct.
1797
- *
1798
- * THE SERVER'S WORD COMES FIRST. A tab is created AS a runtime's tab
1799
- * (`job.runtime`; null/absent = Claude, which is what every tab ran on until
1800
- * now), so on the first turn a named runtime IS the pick — never a
1801
- * preference the machine may override. And a named runtime that DISAGREES
1802
- * with an existing pin is an identity change mid-life: something upstream
1803
- * now calls this tab a different brain's, and the only honest move is to
1804
- * settle the turn and say so ({ mismatch }), because a held context must
1805
- * never be answered by a different brain.
1806
- *
1807
- * Returns { id } | { id: null } (nothing installed) | { missing: label } |
1808
- * { unsupported: label } (a runtime no session can run on) |
1809
- * { mismatch: { pin, runtime } } (labels, for the caller's sentence).
1810
- *
1811
- * SESSION-CAPABLE means rt.mcp is truthy — the session tools ride a real
1812
- * per-invocation MCP config — OR the runtime runs tabs PLAIN (Antigravity):
1813
- * no MCP at all, no cards, no streaming; the final answer is delivered by
1814
- * the daemon's own report and ship-time reconciliation keeps the ledger
1815
- * whole. `pickRuntimeFor('build')` is still the WRONG question here — it
1816
- * says yes to the mediated DISPATCH path without saying how a tab would
1817
- * speak, and a session pinned by it once threw in mcpFor on every turn.
1818
- */
1819
- const sessionCapable = (rid) =>
1820
- (Boolean(RUNTIMES[rid]?.mcp) || rid === 'antigravity') && canRun(RUNTIMES[rid], 'build');
1821
- const sessionRuntime = (wt, jobRuntime, sessionId) => {
1822
- // SCOPED: two tabs standing in one directory may run different CLIs, and an
1823
- // unscoped pin would hand the second one the first one's runtime.
1824
- const marker = sessionMetaPath(wt, 'flowviant-runtime', sessionId);
1825
- let pinned = null;
1826
- if (marker && existsSync(marker)) {
1827
- try {
1828
- pinned = readFileSync(marker, 'utf8').trim() || null;
1829
- } catch {
1830
- /* unreadable marker — re-pin below */
1831
- }
1832
- }
1833
- if (pinned && RUNTIMES[pinned]) {
1834
- if (jobRuntime && jobRuntime !== pinned) {
1835
- return {
1836
- mismatch: {
1837
- pin: RUNTIMES[pinned].label || pinned,
1838
- runtime: RUNTIMES[jobRuntime]?.label || jobRuntime,
1839
- },
1840
- };
1841
- }
1842
- // A pin that names a non-session-capable runtime is settled honestly by
1843
- // the caller, not silently re-picked: re-picking would hand the held
1844
- // context to a different brain, which is the exact substitution the pin
1845
- // exists to prevent.
1846
- if (!sessionCapable(pinned)) return { unsupported: RUNTIMES[pinned].label || pinned };
1847
- const installed = detectRuntimes().find((r) => r.id === pinned)?.installed;
1848
- return installed ? { id: pinned } : { missing: RUNTIMES[pinned].label || pinned };
1849
- }
1850
- // First turn, and the server named the brain: that IS the pick, gated the
1851
- // same two ways as a pin — not session-capable and not installed both
1852
- // settle honestly via the caller's existing paths, never substituted.
1853
- if (jobRuntime) {
1854
- if (!sessionCapable(jobRuntime))
1855
- return { unsupported: RUNTIMES[jobRuntime]?.label || jobRuntime };
1856
- const installed = detectRuntimes().find((r) => r.id === jobRuntime)?.installed;
1857
- if (!installed) return { missing: RUNTIMES[jobRuntime]?.label || jobRuntime };
1858
- if (marker) {
1859
- try {
1860
- writeFileSync(marker, jobRuntime);
1861
- } catch {
1862
- /* best-effort — an unpinnable session just re-picks next turn */
1863
- }
1864
- }
1865
- return { id: jobRuntime };
1866
- }
1867
- // The fresh pick — Claude first when it qualifies, for the reason
1868
- // pickRuntimeFor gives: the prompts were tuned against it. DELIBERATELY
1869
- // NARROWER than sessionCapable: a PLAIN tab (Antigravity — no cards, no
1870
- // streaming) is a degraded mode someone CHOOSES, so it is honored only
1871
- // when the server names it, never handed out as a default.
1872
- const rows = detectRuntimes();
1873
- const okFor = (rid) =>
1874
- Boolean(RUNTIMES[rid]?.mcp) &&
1875
- sessionCapable(rid) &&
1876
- Boolean(rows.find((r) => r.id === rid)?.installed);
1877
- const id = okFor('claude') ? 'claude' : (Object.keys(RUNTIMES).find(okFor) ?? null);
1878
- if (!id) return { id: null };
1879
- if (marker) {
1880
- try {
1881
- writeFileSync(marker, id);
1882
- } catch {
1883
- /* best-effort — an unpinnable session just re-picks next turn */
1884
- }
1885
- }
1886
- return { id };
1887
- };
1888
-
1889
- /**
1890
- * The shape a codex thread id must have before it is written to disk or —
1891
- * decisive — pushed into argv as `resume <id>`. Conservative on purpose:
1892
- * alphanumeric plus dash/underscore, never a leading dash (an argv that
1893
- * parses as a flag), never whitespace. Anything else is dropped and the
1894
- * session simply runs fresh in its own worktree.
1895
- */
1896
- const CODEX_THREAD_RE = /^[0-9a-zA-Z][0-9a-zA-Z_-]{7,63}$/;
1897
-
1898
- /** agy conversation ids are plain UUIDs (the db filename IS the identity —
1899
- * measured: a renamed copy fails "trajectory not found"). Guarded the same
1900
- * way as the codex id: it rides in argv as `--conversation <id>`. */
1901
- const AGY_CONV_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1902
-
1903
- /** agy's own cwd registry — {cwd → the conversation that ran there LAST}.
1904
- * Read once, right after a fresh agy turn, to learn the id the turn just
1905
- * created; from then on the tab's marker is the identity and this registry
1906
- * is never consulted again (a dispatch sharing the machine may overwrite
1907
- * the cwd's entry between turns). */
1908
- const agyRegistryLookup = (cwd) => {
1909
- try {
1910
- const raw = readFileSync(
1911
- join(homedir(), '.gemini', 'antigravity-cli', 'cache', 'last_conversations.json'),
1912
- 'utf8'
1913
- );
1914
- const map = JSON.parse(raw);
1915
- if (!map || typeof map !== 'object') return null;
1916
- // agy keys by the cwd as IT resolved it — try our literal path and its
1917
- // realpath, so a symlinked home doesn't orphan the lookup.
1918
- let keys = [cwd];
1919
- try {
1920
- keys.push(realpathSync(cwd));
1921
- } catch {
1922
- /* the literal alone, then */
1923
- }
1924
- for (const k of keys) {
1925
- const id = map[k];
1926
- if (typeof id === 'string' && AGY_CONV_RE.test(id)) return id;
1927
- }
1928
- return null;
1929
- } catch {
1930
- return null;
1931
- }
1932
- };
1933
-
1934
- /**
1935
- * Live session-turn CLI children. The daemon's teardown SIGTERMs them: an
1936
- * orphaned CLI keeps editing the session worktree and burning quota after
1937
- * the daemon is gone. Each child's pid-lock is deliberately LEFT IN PLACE —
1938
- * a CLI can trap SIGTERM to finish an in-flight request and outlive this
1939
- * loop by seconds, and removing the lock in the same tick handed the
1940
- * restarted daemon a green light to spawn a second CLI into the same held
1941
- * context. turnLockedByLivePid already covers both outcomes: it waits while
1942
- * the pid lives and clears the lock once it is dead.
1943
- *
1944
- * THE VALUE IS THE ID THE CHILD SERVES, and it used to be the pid-lock path —
1945
- * which nothing in this file has ever read. A write-only value is not free:
1946
- * the machine snapshot's per-task RSS (`/fleet/machine`) is the one readout
1947
- * that answers "which task is holding nine gigabytes", and it was being built
1948
- * from the dispatch-era `workers` map, which nothing has `.set()` since that
1949
- * lane was deleted — so the column its own server handler calls the
1950
- * load-bearing half of the report had never been populated once. This map is
1951
- * the only place that knows both the pid and whose work it is, so it carries
1952
- * both. `null` where there is no id to name (a Deploy press is not a task).
1953
- */
1954
- const workChildren = new Map(); // child process -> sessionId | agentId | null
1955
- /**
1956
- * Children whose whole PROCESS GROUP must go, not just the child.
1957
- *
1958
- * The standing rule is the opposite — teardown SIGTERMs the CLI child and
1959
- * never its group, precisely so an unattended auto-update does not kill the
1960
- * driver's dev server, which a turn started INTO the CLI's group. That rule
1961
- * is about the CLI's group and stays.
1962
- *
1963
- * A project CHECK is a different group entirely: the daemon spawns it itself,
1964
- * `detached` with `shell: true`, so its group holds the check command and
1965
- * nothing else — no dev server of anybody's. And `shell: true` is exactly
1966
- * what makes signalling only the child useless: a compound command like
1967
- * `npm run lint && npm test` leaves `/bin/sh` as the child, so SIGTERM killed
1968
- * the shell and the test runner underneath it carried on holding the
1969
- * worktree — and that place's WRITER lock — through every stop, takeover and
1970
- * auto-update. The check's own ten-minute timer already kills `-child.pid`
1971
- * for this reason; teardown simply did not.
1972
- */
1973
- const groupKillChildren = new Set();
1974
- const shutdownWork = () => {
1975
- for (const [ch] of workChildren) {
1976
- try {
1977
- if (groupKillChildren.has(ch) && ch.pid) process.kill(-ch.pid, 'SIGTERM');
1978
- else ch.kill('SIGTERM');
1979
- } catch {
1980
- // A group that has already gone, or a pid that is no longer a leader.
1981
- try {
1982
- ch.kill('SIGTERM');
1983
- } catch {
1984
- /* best-effort */
1985
- }
1986
- }
1987
- }
1988
- workChildren.clear();
1989
- groupKillChildren.clear();
1990
- };
1991
-
1992
- /**
1993
- * HOW MANY CLIs THIS MACHINE IS RUNNING RIGHT NOW — the number
1994
- * `MAX_CONCURRENT` is a ceiling on, and the thing that had no counter.
1995
- *
1996
- * Every lane, because the bound is on the BOX and not on a lane: session
1997
- * turns, an agent's turn, a Deploy press's planner, a project check — all of
1998
- * them land in `workChildren` — plus whatever the caller reports on top of it
1999
- * (the wiki cartographer, which fleet.mjs owns).
2000
- *
2001
- * THE PROJECT CHECK COUNTS, deliberately. It is not a model turn, but it is a
2002
- * full test or build run in a worktree, which is exactly the kind of process
2003
- * this ceiling exists to stop stacking. Nothing gates a check, so counting it
2004
- * cannot deadlock: it only ever delays the NEXT spawn.
2005
- */
2006
- const liveTurnCount = () => {
2007
- const extra = Number(extraLiveTurns() ?? 0);
2008
- return workChildren.size + (Number.isFinite(extra) && extra > 0 ? extra : 0);
2009
- };
2010
-
2011
- /** The live turn children with the id each one serves — what the machine
2012
- * snapshot charges its per-task RSS to. Children with no id (the planner)
2013
- * are still counted above; they just have nothing to be charged TO. */
2014
- const liveTurns = () => {
2015
- const out = [];
2016
- for (const [ch, id] of workChildren) if (ch?.pid) out.push({ id: id ?? null, pid: ch.pid });
2017
- return out;
2018
- };
2019
-
2020
- /**
2021
- * WHETHER TO START ONE MORE. See admission.mjs for the whole argument: a
2022
- * runaway bound on a machine, read at the spawn, surfaced only as the
2023
- * machine's own measured sentence at the thing that is waiting, and never a
2024
- * reason to settle a job — a deferred job is re-offered next poll.
2025
- */
2026
- const admit = createAdmission({ liveTurnCount });
2027
-
2028
- /**
2029
- * Retire the worktrees of sessions the server says are CLOSED.
2030
- *
2031
- * `activeWorkSessions` on the roster is the list of this fleet's LIVE
2032
- * sessions; a directory whose id is absent belongs to a tab its owner
2033
- * closed, and the directory — never the branch: committed work survives on
2034
- * `session/<id>`, and ship re-attaches to it — is returned to disk. NEVER
2035
- * by count: the old cap-12 retirement destroyed live sessions on shared
2036
- * machines. When the roster omits the field entirely (older server),
2037
- * absence of signal is not a close — retire nothing.
2038
- */
2039
- /** Every session this daemon currently has a worktree for — what renews our
2040
- * lease on the poll. Read off the directory rather than a map, so it is the
2041
- * same fact retirement acts on. */
2042
- const heldSessionIds = () => {
2043
- const dir = join(baseDir, 'sessions');
2044
- try {
2045
- return readdirSync(dir).filter(isSafePathSegment).slice(0, 50);
2046
- } catch {
2047
- return [];
2048
- }
2049
- };
2050
-
2051
- /** The pre-places name, kept so every existing caller reads unchanged: a
2052
- * session's own id IS its default place. */
2053
- const sessionWtFor = (sessionId, baseAt) => placeWtFor(sessionId, baseAt);
2054
-
2055
- /** See `shipSweep.mjs`. Bound to this manager's repo, base and report queue. */
2056
- const sweepMergedSessionBranch = (sessionId) =>
2057
- sweepMergedBranch(sessionId, {
2058
- git,
186
+ // ── THE SESSION'S OWN HALF — built first, because the lanes below are handed it.
187
+ const { sessionPlaces, placeOf, learnPlaces, placeDir, placeWtFor, sessionMetaPath } =
188
+ createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
189
+ const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
190
+ createWorkSessionGroups({ repoRoot });
191
+ const { workChildren, groupKillChildren, shutdownWork, liveTurnCount, liveTurns, admit } =
192
+ createWorkChildren({ extraLiveTurns });
193
+ const { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch } =
194
+ createWorkAgentPublish({ repoRoot, gitNet });
195
+ const { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees } =
196
+ createWorkPlaceReports({
2059
197
  repoRoot,
2060
- baseRef: baseRef(),
2061
- note,
2062
- // The report queue is consulted at CALL time, never captured — a sweep
2063
- // scheduled while a report was outstanding must still see it land.
2064
- isReportPending: (id) => pendingShipReports.has(id),
2065
- });
2066
-
2067
- const retireWorkSessions = (activeIds, heldElsewhere) => {
2068
- if (!Array.isArray(activeIds)) return;
2069
- // Sessions ANOTHER daemon on this credential is serving. They are absent
2070
- // from activeWorkSessions for us and present for them, and removing their
2071
- // worktree would pull the directory out from under a running turn. Absence
2072
- // means "the tab closed"; this is the one other thing it can mean.
2073
- const peers = new Set(Array.isArray(heldElsewhere) ? heldElsewhere : []);
2074
- // A peer-held session's CACHED work token is a claim-bypass: the mint is
2075
- // the one place the session lease 409s a non-holder, and a token younger
2076
- // than ~23h skips the mint entirely — so a daemon that lost a lease would
2077
- // run the next turn anyway, editing the worktree while every MCP call
2078
- // 401s (the peer's mint rotated the secret). Dropping the cache forces
2079
- // the next turn through the mint, where the 409 stands it down.
2080
- for (const id of peers) workTokens.delete(id);
2081
- const dir = join(baseDir, 'sessions');
2082
- if (!existsSync(dir)) return;
2083
- let ids;
2084
- try {
2085
- ids = readdirSync(dir);
2086
- } catch {
2087
- return;
2088
- }
2089
- const live = new Set(activeIds);
2090
- let removed = 0;
2091
- for (const id of ids) {
2092
- if (live.has(id)) continue;
2093
- if (peers.has(id)) continue; // another daemon's tab — not ours to retire
2094
- /**
2095
- * A HARD STOP REACHES THE CLI HERE, and this is the whole of it.
2096
- *
2097
- * `stopAgent` marked the agent abandoned in D1 and nothing on the wire
2098
- * told the machine, so the CLI went on working, editing the worktree and
2099
- * spending the operator's quota — while the confirm dialog said the agent
2100
- * was stopped. It also never got cleaned up, because the check below
2101
- * skips any place whose lock is held and a running turn holds it: a
2102
- * stopped agent mid-turn kept its directory forever.
2103
- *
2104
- * No new wire field and no version floor: an abandoned agent simply drops
2105
- * out of `activeWorkSessions` (that list is built from LIVE statuses), so
2106
- * the server ALREADY says everything needed. Absence means "nothing here
2107
- * is live any more", and the honest response to that is to stop what we
2108
- * are running in it and then take the directory.
2109
- *
2110
- * SIGTERM the CHILD, never its group — the rule teardown keeps, so an
2111
- * unattended sweep cannot take the driver's dev server with it. The lock
2112
- * is released when the child exits, so the removal happens on the next
2113
- * pass rather than this one.
2114
- */
2115
- const running = agentChildren.get(id);
2116
- if (running) {
2117
- try {
2118
- running.kill('SIGTERM');
2119
- } catch {
2120
- /* already gone */
2121
- }
2122
- agentChildren.delete(id);
2123
- }
2124
- // Asked of the PLACE, not the session: the lock is keyed by directory,
2125
- // and a session sharing one with a busy peer is not ours to retire
2126
- // either — its worktree is the peer's working directory.
2127
- if (placeLocks.has(placeOf(id)) || shipping.has(id)) continue; // still draining here
2128
- const wt = join(dir, id);
2129
- try {
2130
- // Uncommitted work is the human's — a resource sweep does not outrank
2131
- // it, closed tab or not. (The non-force remove would refuse anyway;
2132
- // the explicit check keeps the intent legible.)
2133
- if (git(['status', '--porcelain'], wt) !== '') continue;
2134
- git(['worktree', 'remove', wt], repoRoot); // non-force
2135
- workTokens.delete(id);
2136
- removed++;
2137
- // NOW the branch can be judged. While this worktree existed the branch
2138
- // was checked out in it, so `git branch -d` refused on every earlier
2139
- // attempt — a tab that shipped and then closed would otherwise leave
2140
- // its merged branch behind forever, which is the common case.
2141
- // Unshipped work still refuses here: `-d` is what decides.
2142
- sweepMergedSessionBranch(id);
2143
- } catch {
2144
- /* not cleanly removable — leave it */
2145
- }
2146
- }
2147
- if (removed) {
2148
- try {
2149
- git(['worktree', 'prune'], repoRoot);
2150
- } catch {
2151
- /* best effort */
2152
- }
2153
- }
2154
- };
2155
-
2156
- /**
2157
- * TELL THE TAB IT IS WAITING ON THE BOX, not on its Claude.
2158
- *
2159
- * A deferred turn is invisible from a browser: the composer says "working…"
2160
- * and the machine simply does not spawn, which looks exactly like a slow
2161
- * model. So the deferral rides the narration channel the turn would have used
2162
- * anyway — the turn is still pending, so the server accepts the line — and
2163
- * says the measured reason and what happens next. The machine's own voice,
2164
- * for a moment only this side can see; the same shape the planner's "waiting
2165
- * for the checkout" already keeps.
2166
- *
2167
- * ONCE PER SESSION PER WINDOW, because the roster re-offers the same turn on
2168
- * every poll and restating an unchanged sentence every ten seconds is a POST
2169
- * loop, not a readout. The clock is cleared the moment a turn for that session
2170
- * actually starts, so the next stall speaks immediately rather than inheriting
2171
- * a window from an unrelated one.
2172
- */
2173
- const DEFER_SAY_MS = 30_000;
2174
- const lastDeferSaid = new Map(); // sessionId -> ms
2175
- const sayTurnDeferred = (sessionId, turnId, reason) => {
2176
- const now = Date.now();
2177
- if (now - (lastDeferSaid.get(sessionId) ?? 0) < DEFER_SAY_MS) return;
2178
- lastDeferSaid.set(sessionId, now);
2179
- void fetch(ACTIVITY_URL, {
2180
- method: 'POST',
2181
- headers: {
2182
- Authorization: `Bearer ${FLEET_TOKEN}`,
2183
- 'User-Agent': USER_AGENT,
2184
- 'Content-Type': 'application/json',
2185
- },
2186
- signal: AbortSignal.timeout(10_000),
2187
- body: JSON.stringify({
2188
- sessionId,
2189
- turnId,
2190
- lines: [`Deferred — ${reason}. The machine retries on its next poll.`],
2191
- }),
2192
- }).catch(() => {
2193
- /* a readout — a dropped line is not an incident */
198
+ baseRef,
199
+ placeOf,
200
+ placeDir,
201
+ sessionMetaPath,
202
+ sessionProcesses,
203
+ pruneSessionGroups,
204
+ agentPublished,
205
+ agentRemoteAt,
206
+ publishAgentBranch,
207
+ landed,
2194
208
  });
2195
- };
2196
-
2197
- const processWorkTurns = (jobs) => {
2198
- for (const job of jobs ?? []) {
2199
- if (!job || typeof job.id !== 'string' || !job.body || !job.sessionId) continue;
2200
- if (workAnswering.has(job.id)) continue;
2201
- // The turn already RAN and its answer sits in the delivery queue — never
2202
- // run it again while the report is merely undelivered.
2203
- if (pendingWorkReports.has(job.id)) continue;
2204
- /**
2205
- * THE BOX IS ABOUT TO FALL OVER, OR THIS MACHINE IS ALREADY AT ITS
2206
- * CEILING. Defer: return without settling and without consuming an
2207
- * attempt — the shape the live-CLI lock below uses, for the same reason.
2208
- * The job stays pending and the server re-offers it next poll; settling
2209
- * it would tell the human their message failed when nothing ran.
2210
- *
2211
- * `interactive`, not `churn`: somebody is watching a composer they just
2212
- * pressed enter in, so this holds out until the box is genuinely about to
2213
- * die rather than yielding early the way the unattended lanes do.
2214
- *
2215
- * AND THE SLOT IS RESERVED BEFORE THE NEXT ITERATION ASKS. This loop is
2216
- * synchronous and every spawn under it is not — `inPlace` resolves its
2217
- * callback in a later microtask — so `liveTurnCount()` could not move
2218
- * between jobs and a roster offering eight turns admitted all eight
2219
- * against a ceiling of one. See admission.mjs.
2220
- */
2221
- const hold = admit('interactive');
2222
- if (hold) {
2223
- sayTurnDeferred(job.sessionId, job.id, hold.reason);
2224
- continue;
2225
- }
2226
- const releaseSlot = admit.reserve();
2227
- lastDeferSaid.delete(job.sessionId);
2228
- workAnswering.add(job.id);
2229
- const place = job.place || job.sessionId;
2230
- /**
2231
- * VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
2232
- * the roster's map — this is the OTHER writer of `sessionPlaces`, in the
2233
- * same reconcile tick, BEFORE the preview jobs and the sweep read it. An
2234
- * unchecked value stored here reaches every `placeDir` consumer: the
2235
- * turn is spawned in it, `burstListeners` measures it, and a share would
2236
- * publicly tunnel a directory OUTSIDE the checkout — the exact traversal
2237
- * the preview feature's port attribution exists to prevent. Settled out
2238
- * loud rather than skipped, because a silently dropped turn strands the
2239
- * tab for the server's whole expiry window.
2240
- */
2241
- if (place !== REPO_PLACE && !isSafePathSegment(place)) {
2242
- void settleWorkTurn(job.id, {
2243
- ok: false,
2244
- answer:
2245
- 'the server named a working directory this machine refuses to use — close and reopen the tab, then send the message again',
2246
- }).finally(() => workAnswering.delete(job.id));
2247
- releaseSlot();
2248
- continue;
2249
- }
2250
- // Remembered for every other beat — the sweep, ship, the preview
2251
- // re-check — so they all ask the same directory this turn runs in.
2252
- sessionPlaces.set(job.sessionId, place);
2253
- // A READER: other turns in this place run alongside it. See `inPlace`.
2254
- inPlace(place, false, async () => {
2255
- /** This turn's artifact snapshot — set once the place is resolved and
2256
- * a CLI is about to spawn there; read in the `finally`. Null on every
2257
- * path that never ran a CLI, which is exactly when nothing is new. */
2258
- let artifactScan = null;
2259
- try {
2260
- const tries = workAttempts.get(job.id) ?? 0;
2261
- if (tries >= MAX_WORK_TRIES) {
2262
- // Out of local tries: SETTLE, don't skip — a silently skipped turn
2263
- // strands the tab for the server's whole 24h expiry window.
2264
- await settleWorkTurn(job.id, {
2265
- ok: false,
2266
- answer: `the turn failed ${tries} times on this machine — check the daemon log, then send the message again`,
2267
- });
2268
- return;
2269
- }
2270
- note(
2271
- `${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
2272
- );
2273
- // ── ADOPTION: a tab born from a TERMINAL session ────────────────
2274
- // The server sends `adopt {id, cwd}` only while the session has no
2275
- // sessionRef — no turn has ever spoken from a worktree here — and
2276
- // the first turn resumes the terminal conversation by forking it
2277
- // into the tab's own worktree. Everything the server asserts is
2278
- // re-validated MACHINE-side: the id shape, the source directory,
2279
- // and — decisive — that the terminal is actually closed, because
2280
- // forking a session someone is still typing into puts two Claudes
2281
- // on one conversation.
2282
- const adopting = Boolean(job.adopt) && !job.sessionRef;
2283
- let srcHead = null;
2284
- let adoptSrc = null; // the validated, realpath'd source checkout
2285
- if (adopting) {
2286
- if (
2287
- typeof job.adopt.id !== 'string' ||
2288
- !/^[0-9a-f][0-9a-f-]{6,62}$/i.test(job.adopt.id)
2289
- ) {
2290
- await settleWorkTurn(job.id, {
2291
- ok: false,
2292
- answer: 'that terminal session id is not one this machine can resume',
2293
- });
2294
- return;
2295
- }
2296
- let srcCwd = null;
2297
- try {
2298
- srcCwd = realpathSync(String(job.adopt.cwd ?? ''));
2299
- if (!statSync(srcCwd).isDirectory()) srcCwd = null;
2300
- } catch {
2301
- srcCwd = null;
2302
- }
2303
- if (!srcCwd) {
2304
- await settleWorkTurn(job.id, {
2305
- ok: false,
2306
- answer: "the terminal session's directory no longer exists on the machine",
2307
- });
2308
- return;
2309
- }
2310
- // Inside the repo, outside the daemon's own worktrees: an adopt
2311
- // source is a HUMAN's checkout, and one of our directories showing
2312
- // up here means a stale or confused offer, not a session to fork.
2313
- const under = (p, root) =>
2314
- p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
2315
- let realRoot = repoRoot;
2316
- let realBase = baseDir;
2317
- try {
2318
- realRoot = realpathSync(repoRoot);
2319
- } catch {
2320
- /* keep the literal path */
2321
- }
2322
- try {
2323
- realBase = realpathSync(baseDir);
2324
- } catch {
2325
- /* keep the literal path */
2326
- }
2327
- if (!under(srcCwd, realRoot)) {
2328
- await settleWorkTurn(job.id, {
2329
- ok: false,
2330
- answer: "the terminal session's directory is outside this project's repository",
2331
- });
2332
- return;
2333
- }
2334
- if (under(srcCwd, realBase)) {
2335
- await settleWorkTurn(job.id, {
2336
- ok: false,
2337
- answer:
2338
- "that directory is one of the daemon's own worktrees — its session is already a tab, not something to adopt",
2339
- });
2340
- return;
2341
- }
2342
- try {
2343
- srcHead = git(['rev-parse', 'HEAD'], srcCwd);
2344
- } catch {
2345
- await settleWorkTurn(job.id, {
2346
- ok: false,
2347
- answer:
2348
- "the terminal session's directory is not a usable git checkout (no HEAD to branch from)",
2349
- });
2350
- return;
2351
- }
2352
- // Liveness by the SESSION's own runtime: Claude has a real pid
2353
- // registry; agy only leaves store-write recency + a process check,
2354
- // and adoption there is a MOVE (no fork exists), so the composite
2355
- // errs toward refusing — a false "live" costs a retry in minutes,
2356
- // a false "ended" puts two drivers on one conversation store.
2357
- const adoptLive =
2358
- job.runtime === 'antigravity'
2359
- ? isAgyConversationLive(job.adopt.id)
2360
- : isTerminalSessionLive(job.adopt.id);
2361
- if (adoptLive) {
2362
- await settleWorkTurn(job.id, {
2363
- ok: false,
2364
- answer:
2365
- 'That terminal session is still open on the machine — close it there first, then adopt.',
2366
- });
2367
- return;
2368
- }
2369
- adoptSrc = srcCwd;
2370
- }
2371
- // Based at the SOURCE's HEAD when adopting — the resumed
2372
- // conversation was had against those commits, not the project base.
2373
- // The PLACE this tab works in — its own worktree unless the server
2374
- // named another. An older server sends no `place` and the default is
2375
- // the session's own id, which is what every tab has always done.
2376
- const dir = placeWtFor(place, adopting ? srcHead : undefined);
2377
- if (!dir) {
2378
- await settleWorkTurn(job.id, {
2379
- ok: false,
2380
- answer:
2381
- 'the session worktree could not be opened on the machine — check the daemon log',
2382
- });
2383
- return;
2384
- }
2385
- // A live CLI is ALREADY in this worktree — this daemon's previous
2386
- // life, most likely; the lock outlives a restart. Leave the job
2387
- // pending and look again next poll; spawning a second CLI would put
2388
- // two Claudes in one held context. Costs no attempt: nothing ran.
2389
- const lockPath = sessionMetaPath(dir.wt, 'flowviant-turn.lock');
2390
- if (turnLockedByLivePid(lockPath)) {
2391
- warn(
2392
- `a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
2393
- );
2394
- return;
2395
- }
2396
- // WHICH BRAIN the roster says this tab speaks (null/absent = Claude,
2397
- // which is what every tab ran on until now) — honored by
2398
- // sessionRuntime: on a first turn a named runtime IS the pick, and a
2399
- // named runtime that disagrees with the pin settles below.
2400
- // What the artifact directory held BEFORE this turn — the capture chat
2401
- // excepted: it runs read-only, is never told about artifacts, and
2402
- // whatever sibling tabs wrote in this shared place is theirs.
2403
- if (job.capture !== true) artifactScan = { dir: dir.wt, before: beforeArtifacts(dir.wt) };
2404
- const rt = sessionRuntime(dir.wt, job.runtime || null, job.sessionId);
2405
- if (rt.mismatch) {
2406
- // Something upstream changed this tab's identity mid-life. A held
2407
- // context must never be answered by a different brain — say so.
2408
- await settleWorkTurn(job.id, {
2409
- ok: false,
2410
- answer: `this tab is pinned to ${rt.mismatch.pin} but the server says it is a ${rt.mismatch.runtime} tab — reopen a new tab`,
2411
- });
2412
- return;
2413
- }
2414
- if (rt.missing) {
2415
- await settleWorkTurn(job.id, {
2416
- ok: false,
2417
- answer: `this session runs on ${rt.missing}, which is no longer installed on the machine — reinstall it, or open a new tab`,
2418
- });
2419
- return;
2420
- }
2421
- if (rt.unsupported) {
2422
- // A pin from before the session-capable gate existed — or a
2423
- // first-turn tab the server named for one — can carry a runtime no
2424
- // tab can run on (Antigravity has no MCP config, and the session's
2425
- // whole control plane rides one). An honest sentence beats the
2426
- // mcpFor throw this used to crash into every turn.
2427
- await settleWorkTurn(job.id, {
2428
- ok: false,
2429
- answer: `this session runs on ${rt.unsupported}, which cannot drive a Workbench tab on this machine — open a new tab`,
2430
- });
2431
- return;
2432
- }
2433
- if (!rt.id) {
2434
- await settleWorkTurn(job.id, {
2435
- ok: false,
2436
- answer:
2437
- 'No coding CLI is installed on the machine — install Claude Code (or another supported CLI), then send the message again',
2438
- });
2439
- return;
2440
- }
2441
- if (adopting && rt.id !== 'claude' && rt.id !== 'antigravity') {
2442
- // An adopt id names a conversation in ITS OWN CLI's store: claude
2443
- // forks it (--resume --fork-session), agy moves it
2444
- // (--conversation). Codex has no adoptable store yet, and its
2445
- // args builder backstops this with a loud throw — but a sentence
2446
- // here beats a stack there.
2447
- await settleWorkTurn(job.id, {
2448
- ok: false,
2449
- answer:
2450
- 'adopting this terminal session needs its own CLI on the machine — install it, then try again',
2451
- });
2452
- return;
2453
- }
2454
- /**
2455
- * A PLAN TURN (0.97.0): the tab's switch, off the job. CLAUDE ONLY —
2456
- * `--permission-mode plan` is Claude Code's, and a codex or agy turn
2457
- * has no way to spell it, so it is refused HERE in words rather than
2458
- * run as a build turn wearing the word. The server never sends the
2459
- * key for a non-Claude tab; this is the machine's own half. Never on
2460
- * a capture chat, which is read-only by its own profile already.
2461
- */
2462
- const planTurn = job.planMode === true && job.capture !== true;
2463
- if (planTurn && rt.id !== 'claude') {
2464
- await settleWorkTurn(job.id, {
2465
- ok: false,
2466
- 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`,
2467
- });
2468
- return;
2469
- }
2470
- // A PLAIN tab (agy) mounts no MCP: no credential to mint, no config
2471
- // to write. The trade is stated in SYSTEM_WORK_PLAIN — no cards, no
2472
- // streaming — and the honesty survives on the existing rails: the
2473
- // answer lands via work-turn-done, the rail says "no card yet", and
2474
- // ship-time reconciliation books every branch commit.
2475
- const plainTab = rt.id === 'antigravity';
2476
- let mint = null;
2477
- if (!plainTab) {
2478
- mint = await mintWorkToken(job.sessionId);
2479
- if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
2480
- // Another daemon on this credential holds the session. Return
2481
- // WITHOUT settling: the holder is answering this same turn, and
2482
- // settling it here — even as a failure — would race the real
2483
- // answer and could win. Dropping it means the turn stays pending
2484
- // and the holder's answer lands, which is the whole point.
2485
- if (mint?.heldElsewhere) {
2486
- workAnswering.delete(job.id);
2487
- return;
2488
- }
2489
- if (mint?.gone) {
2490
- await settleWorkTurn(job.id, {
2491
- ok: false,
2492
- answer:
2493
- 'Flowviant no longer offers this session to this machine — the tab may have been closed or moved',
2494
- });
2495
- return;
2496
- }
2497
- if (!mint?.token) {
2498
- await settleWorkTurn(job.id, {
2499
- ok: false,
2500
- answer:
2501
- 'the machine could not mint a session credential from Flowviant — check its connection, then send the message again',
2502
- });
2503
- return;
2504
- }
2505
- }
2506
- // CODEX RESUMES BY THREAD ID, never by `--last`: `resume --last` is
2507
- // the MACHINE's most recent codex conversation, and two codex tabs —
2508
- // or a tab plus a codex dispatch — would cross-resume each other's
2509
- // context. The id was captured off thread.started (runtimes.mjs) and
2510
- // persisted below, beside the runtime pin; absent, the turn runs
2511
- // FRESH in the same worktree — the dirty state is most of the held
2512
- // context, and a machine-global guess is someone else's conversation.
2513
- /**
2514
- * CLAUDE RESUMES BY THE CONVERSATION ID THIS TAB SPOKE UNDER.
2515
- *
2516
- * `--continue` is CWD-KEYED. That was unambiguous while one directory
2517
- * meant one tab, and it stopped being true the day tabs moved into
2518
- * their driver's project folder: every tab there said `--continue`
2519
- * and every one of them resumed whichever conversation had spoken
2520
- * most recently in that directory. Tab B inherited tab A's entire
2521
- * context, and each turn afterwards ping-ponged between them — the
2522
- * exact failure the codex note two blocks down warns about for
2523
- * `resume --last`, arriving for Claude by a different route.
2524
- *
2525
- * The id comes from the CLI's own `system.init` event, which the
2526
- * stream parser already surfaces, and is pinned per session so it is
2527
- * unambiguous wherever the tab is standing.
2528
- *
2529
- * NO ID, NO `--continue`: a tab whose place is shared starts FRESH
2530
- * rather than guessing, because in a shared directory the guess is
2531
- * someone else's conversation. `--continue` survives only where the
2532
- * directory belongs to this tab alone, which is the one case it was
2533
- * ever right for.
2534
- */
2535
- let claudeResumeId = null;
2536
- if (rt.id === 'claude') {
2537
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
2538
- if (convMarker && existsSync(convMarker)) {
2539
- try {
2540
- const v = readFileSync(convMarker, 'utf8').trim();
2541
- if (/^[A-Za-z0-9_-]{8,64}$/.test(v)) claudeResumeId = v;
2542
- } catch {
2543
- /* unreadable marker — run fresh */
2544
- }
2545
- }
2546
- }
2547
- let codexResumeId = null;
2548
- if (rt.id === 'codex') {
2549
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
2550
- if (threadMarker && existsSync(threadMarker)) {
2551
- try {
2552
- const v = readFileSync(threadMarker, 'utf8').trim();
2553
- if (CODEX_THREAD_RE.test(v)) codexResumeId = v;
2554
- } catch {
2555
- /* unreadable marker — run fresh */
2556
- }
2557
- }
2558
- }
2559
- // AGY RESUMES BY CONVERSATION ID, learned once and pinned beside the
2560
- // runtime marker: an adopted tab knows it from the adopt hint; a new
2561
- // tab learns it from agy's own cwd registry after its first turn.
2562
- // The marker beats `--continue` because it is the tab's OWN identity
2563
- // — the registry maps a cwd to whatever ran there LAST, and a
2564
- // dispatch sharing the machine could overwrite that between turns.
2565
- let agyConvId = null;
2566
- if (rt.id === 'antigravity' && !adopting) {
2567
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
2568
- if (convMarker && existsSync(convMarker)) {
2569
- try {
2570
- const v = readFileSync(convMarker, 'utf8').trim();
2571
- if (AGY_CONV_RE.test(v)) agyConvId = v;
2572
- } catch {
2573
- /* unreadable marker — run fresh */
2574
- }
2575
- }
2576
- }
2577
- // Resume iff a conversation is known to live in THIS directory. For
2578
- // Claude that proof is the server's sessionRef — only ever a path
2579
- // some turn actually SPOKE from (see the settle below), and it must
2580
- // match the directory we just opened. For codex it is the stored
2581
- // thread id, which lives IN the directory and is stronger. Anything
2582
- // else starts fresh IN the existing worktree — never a reset; the
2583
- // dirty state is the session.
2584
- // agy layers its two resumes: the pinned conversation id when the
2585
- // marker exists (deterministic, registry-proof), else the Claude
2586
- // rule — a tab that has SPOKEN from this directory may `--continue`
2587
- // it (cwd-keyed; measured safe), so a lost marker degrades to the
2588
- // weaker resume instead of silently starting over.
2589
- const spokeHere = !dir.fresh && Boolean(job.sessionRef) && job.sessionRef === dir.wt;
2590
- // A place this tab does NOT have to itself: `--continue` there is a
2591
- // guess at somebody else's conversation, so it is withheld and only
2592
- // a pinned id may resume.
2593
- const placeIsMine = !placeOf(job.sessionId) || placeOf(job.sessionId) === job.sessionId;
2594
- const resume =
2595
- rt.id === 'codex'
2596
- ? Boolean(codexResumeId)
2597
- : rt.id === 'antigravity'
2598
- ? Boolean(agyConvId) || (placeIsMine && spokeHere)
2599
- : Boolean(claudeResumeId) || (placeIsMine && spokeHere);
2600
- // The dirty carry, on the adopt worktree's FIRST life only: a
2601
- // re-attempted adoption (the directory already exists) carried what
2602
- // it could the first time, and re-applying would double it. A carry
2603
- // problem never fails the adoption — it becomes one bracketed line
2604
- // in the prompt, so the AGENT tells the user what stayed behind.
2605
- let carryNote = '';
2606
- if (adopting && dir.fresh && adoptSrc) carryNote = carryDirtyState(adoptSrc, dir.wt, job.sessionId);
2607
- // The tab's transcript starts EMPTY on adoption (scrollback is
2608
- // disposable, the held context is the brain — never import an
2609
- // archive), so the first reply opens with a recap: the human sees
2610
- // the thread they are picking up without asking for it.
2611
- const adoptNote = adopting
2612
- ? '[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.]'
2613
- : '';
2614
- // A PLAN TURN RUNS PLAIN TOO (0.97.0): measured on 2.1.281, plan mode
2615
- // refuses every `mcp__flowviant` call ("Cannot call … while in plan
2616
- // mode") unless the tool is annotated read-only, and none of the
2617
- // session tools are — so mounting the control plane would hand the
2618
- // turn a list of tools it can only fail at. The credential is still
2619
- // minted above: its lease answers (held elsewhere, gone) are about
2620
- // the SESSION, and a plan turn needs them as much as any other.
2621
- const mcp =
2622
- plainTab || planTurn
2623
- ? { args: [], env: null, dir: null }
2624
- : mcpFor(rt.id, mint.token, getMcpUrl());
2625
- // The tab's model/effort, if it named any. Spread into turnArgs so
2626
- // BOTH runTurn calls below carry it — the retry is the same turn on
2627
- // the same brain, not a quieter second opinion.
2628
- const brain = brainFor(job);
2629
- // Attempts count RUNS: the infra refusals above consumed nothing and
2630
- // settled on their own terms.
2631
- workAttempts.set(job.id, tries + 1);
2632
- let out;
2633
- let seenThreadId = null; // codex's conversation id, off thread.started
2634
- let seenClaudeSession = null; // claude's own conversation id, off system.init
2635
- const spawned = []; // this turn's children, for the teardown registry
2636
- /**
2637
- * THE TURN'S TOOL LOG — the structured relay behind the transcript's
2638
- * tool cards. Same source as the narrator (the CLI's own tool_use
2639
- * events), zero inference; scrubbed AT COLLECTION so every copy that
2640
- * leaves the machine — live beat and settle alike — is already clean.
2641
- *
2642
- * Shape rules, applied here because the collector is the one writer:
2643
- * · consecutive identical read/grep/glob/bash/task events collapse
2644
- * into one row with a count (n);
2645
- * · consecutive edits of ONE file merge, summing counts, keeping
2646
- * the newest preview;
2647
- * · the PLAN is a single event — a new TodoWrite replaces the old
2648
- * plan at the current position, so the log shows the latest plan
2649
- * where it last changed rather than five stale copies;
2650
- * · capped at the newest 60 NON-PLAN rows, with the shed counted
2651
- * call-for-call (`dropped += n`) — scrollback semantics, the
2652
- * same trade the transcript itself makes; the plan is exempt,
2653
- * because it is current state rather than scrollback.
2654
- */
2655
- const toolLog = { ev: [], dropped: 0 };
2656
- const pushToolEvent = (name, input) => {
2657
- // envScrub rides INTO the builder, which scrubs over a bounded
2658
- // window BEFORE its caps — scrubbing after the cut both leaked a
2659
- // boundary-straddling secret's prefix and grew a capped field
2660
- // past the server's limits (review, 2026-09-01).
2661
- const e = toolEventOf(name, input, dir.wt, envScrub);
2662
- if (!e) return;
2663
- if (e.t === 'plan') {
2664
- const i = toolLog.ev.findIndex((x) => x.t === 'plan');
2665
- if (i >= 0) toolLog.ev.splice(i, 1);
2666
- toolLog.ev.push(e);
2667
- } else {
2668
- const last = toolLog.ev[toolLog.ev.length - 1];
2669
- const sameKey =
2670
- last &&
2671
- last.t === e.t &&
2672
- last.p === e.p &&
2673
- last.q === e.q &&
2674
- last.c === e.c;
2675
- if (sameKey && (e.t === 'edit' || e.t === 'write')) {
2676
- last.n = (last.n ?? 1) + 1;
2677
- last.a = (last.a ?? 0) + (e.a ?? 0);
2678
- last.d = (last.d ?? 0) + (e.d ?? 0);
2679
- if (e.dl) last.dl = e.dl;
2680
- } else if (sameKey) {
2681
- last.n = (last.n ?? 1) + 1;
2682
- } else {
2683
- toolLog.ev.push(e);
2684
- }
2685
- }
2686
- // The cap evicts the oldest NON-plan row: the plan is current
2687
- // state, not scrollback — the one card the fold keeps out — and a
2688
- // shed collapsed row counts its repeats, so "N steps" never
2689
- // understates what the cut removed.
2690
- while (toolLog.ev.length > 60) {
2691
- const i = toolLog.ev.findIndex((x) => x.t !== 'plan');
2692
- if (i < 0) break; // only the plan left; it stays
2693
- const [shed] = toolLog.ev.splice(i, 1);
2694
- toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
2695
- }
2696
- };
2697
- const narrator = makeNarrator(job.sessionId, job.id, () =>
2698
- toolLog.ev.length > 0 ? toolLog : undefined
2699
- );
2700
-
2701
-
2702
- // THE COMMAND AUDIT — every `$ …` the CLI's stream reports, batched
2703
- // to the server verbatim so an admin can read what actually ran on
2704
- // this box. Same events the narrator renders and forgets; this is
2705
- // the durable copy, and it carries ONLY commands — no prose, no
2706
- // thinking, no file reads (the session stays private; what executed
2707
- // on the shared machine is the machine's own fact to relay).
2708
- // Flushed mid-turn every 25 so a long turn is not one giant loss on
2709
- // a kill, and again at settle. Best-effort: a failed post drops the
2710
- // batch rather than blocking the turn — the surface says it is the
2711
- // machine's report, not a syscall trace.
2712
- const auditBatch = [];
2713
- const flushAudit = () => {
2714
- if (auditBatch.length === 0) return;
2715
- const commands = auditBatch.splice(0, auditBatch.length);
2716
- void fetch(SESSION_COMMANDS_URL, {
2717
- method: 'POST',
2718
- headers: {
2719
- Authorization: `Bearer ${FLEET_TOKEN}`,
2720
- 'User-Agent': USER_AGENT,
2721
- 'Content-Type': 'application/json',
2722
- },
2723
- signal: AbortSignal.timeout(30_000),
2724
- body: JSON.stringify({
2725
- sessionId: job.sessionId,
2726
- turnId: job.id,
2727
- runtime: rt.id,
2728
- cwd: dir.wt,
2729
- commands,
2730
- }),
2731
- }).catch(() => {
2732
- /* best-effort — the audit records what reached it */
2733
- });
2734
- };
2735
- const auditCommand = (a) => {
2736
- if (a?.kind !== 'bash' || !a.command) return;
2737
- // Scrubbed like every other string that leaves this box (the
2738
- // narrator label, the settle answer, commit subjects, ship/merge
2739
- // lines, the per-tab process report). A command line is exactly
2740
- // where a secret leaks — `curl -H "authorization: <token>"`,
2741
- // `PGPASSWORD=… psql` — and the audit is stored 30 days and rendered
2742
- // in the admin view, so the one uplink that omitted scrub was the
2743
- // one most likely to carry a plaintext secret.
2744
- auditBatch.push({ command: envScrub(a.command), at: new Date().toISOString() });
2745
- if (auditBatch.length >= 25) flushAudit();
2746
- };
2747
- try {
2748
- // Files first, then the message that references them: the agent
2749
- // must be able to open what it is being told about. Only the ones
2750
- // that actually landed are named.
2751
- const files = await fetchAttachments(dir.wt, job.attachments);
2752
- const filesNote = files.length
2753
- ? `[FILES THE HUMAN ATTACHED TO THIS MESSAGE — already on disk in this worktree]\n${files
2754
- .map((f) => `- ${f}`)
2755
- .join('\n')}`
2756
- : '';
2757
- const message = [job.body, filesNote, adoptNote, carryNote]
2758
- .filter(Boolean)
2759
- .join('\n\n');
2760
- // A CAPTURE chat (the board's New task conversation): its own
2761
- // system prompt, its own kickoff, and the scratch planner's
2762
- // READ-ONLY permission profile — the prompt says stage-never-file
2763
- // and the profile is what makes "read-only" true rather than
2764
- // asserted. Server-flagged per job; a server too old to flag it
2765
- // simply runs an ordinary tab, which the web's version floor
2766
- // prevents ever being offered.
2767
- const captureTab = job.capture === true;
2768
- const turnArgs = {
2769
- // A plain tab has no tools to name and no session id to pass —
2770
- // its kickoff asks for one complete report instead of a stream.
2771
- prompt: plainTab || planTurn
2772
- ? WORK_TURN_KICKOFF_PLAIN({
2773
- sessionName: job.sessionName,
2774
- message,
2775
- askedByName: job.askedByName,
2776
- })
2777
- : captureTab
2778
- ? CAPTURE_TURN_KICKOFF({
2779
- sessionId: job.sessionId,
2780
- sessionName: job.sessionName,
2781
- message,
2782
- askedByName: job.askedByName,
2783
- })
2784
- : WORK_TURN_KICKOFF({
2785
- sessionId: job.sessionId,
2786
- sessionName: job.sessionName,
2787
- message,
2788
- askedByName: job.askedByName,
2789
- }),
2790
- planPerm: captureTab,
2791
- // `--permission-mode plan` INSTEAD of the build posture — never
2792
- // beside `--dangerously-skip-permissions`, which silently wins
2793
- // (claude.mjs, PLAN_MODE_PERM). Present only on a plan turn.
2794
- ...(planTurn ? { planMode: true } : {}),
2795
- // The adopt turn resumes the TERMINAL conversation by forking it
2796
- // into this cwd (claude: --resume <id> --fork-session). After it
2797
- // speaks once, the fork lives natively here and turn 2+ is the
2798
- // ordinary --continue resume path, unchanged.
2799
- ...(adopting ? { adoptResumeId: job.adopt.id } : {}),
2800
- // THE PROJECT'S KNOWLEDGE LIBRARY (0.94.0) rides as one appended
2801
- // paragraph, and only while this box holds one — see
2802
- // `withProjectContext`. Resolved at spawn, not at startup, so a
2803
- // library that synced a second ago is in THIS turn.
2804
- ...(() => {
2805
- const knowledgeDir = knowledgeDirFor(repoRoot);
2806
- return {
2807
- // A PLAN TURN (0.97.0) runs the PLAIN contract — it has no
2808
- // Flowviant tools, see `mcp` above — with no artifacts
2809
- // paragraph (plan mode cannot write one) and the planning
2810
- // sentence appended. Every other tab is untouched.
2811
- system: planTurn
2812
- ? `${withProjectContext(SYSTEM_WORK_PLAIN, { knowledgeDir, artifacts: false })}\n\n${PLAN_TURN_SENTENCE}`
2813
- : withProjectContext(
2814
- plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
2815
- // ARTIFACTS (0.94.0): every tab but the read-only capture
2816
- // chat, and only while the server can show one.
2817
- { knowledgeDir, artifacts: !captureTab && getArtifactsAccepted() }
2818
- ),
2819
- // The directory is OUTSIDE a worktree's cwd; Claude Code is
2820
- // told it may read there (`--add-dir`) rather than left to
2821
- // refuse a read-only capture turn a path it was just handed.
2822
- knowledgeDir,
2823
- };
2824
- })(),
2825
- // Present only when the tab named one — see brainFor.
2826
- ...brain,
2827
- // The tab watches the CLI work. Claude needs the flag to speak
2828
- // events at all (codex and agy always do); `answerFromResult`
2829
- // keeps `out` — which IS the reply posted to the transcript — to
2830
- // the final result, so streamed prose is narrated once and
2831
- // posted once. Every line goes to the narrator above, throttled.
2832
- streamJson: true,
2833
- answerFromResult: true,
2834
- onActivity: (a) => {
2835
- narrator.line(a?.label);
2836
- auditCommand(a);
2837
- },
2838
- // The structured twin of the line above — see toolLog.
2839
- onToolEvent: pushToolEvent,
2840
- // What this CLI says it can be asked for by name. Harvested off
2841
- // the init event the stream already carries — no probe, no scan,
2842
- // no extra spawn — and reported on the next roster poll so the
2843
- // composer can autocomplete a `/`. See runtimes.mjs for why it is
2844
- // learned from a turn rather than looked up.
2845
- onInit: (i) => {
2846
- recordSkills(i.skills);
2847
- // The CLI's mounted MCP servers and connectors (0.97.0) — the
2848
- // same free fact off the same event; see recordMcpServers.
2849
- recordMcpServers(i.mcpServers);
2850
- // The conversation this turn is actually speaking under. Held
2851
- // and persisted after the turn ends, so the NEXT one resumes
2852
- // this exact thread rather than whatever the directory saw
2853
- // last. Last write wins on purpose: a resume that fell back to
2854
- // fresh reports the fresh id, healing the marker.
2855
- if (typeof i.sessionId === 'string' && i.sessionId.trim())
2856
- seenClaudeSession = i.sessionId.trim();
2857
- },
2858
- cwd: dir.wt,
2859
- mcpArgs: mcp.args,
2860
- mcpEnv: mcp.env,
2861
- runtime: rt.id,
2862
- label: c.cyan('[tab]'),
2863
- // Only codex announces one (thread.started); held here so the id
2864
- // this turn actually SPOKE under is what gets persisted after it
2865
- // ends. Last write wins on purpose: a failed resume that fell
2866
- // back to fresh reports the fresh run's id, healing the marker.
2867
- onThreadId: (id) => {
2868
- seenThreadId = String(id ?? '').trim() || seenThreadId;
2869
- },
2870
- onSpawn: (ch) => {
2871
- if (!ch) return;
2872
- spawned.push(ch);
2873
- // Keyed to the SESSION it serves — that id is what the machine
2874
- // snapshot charges this child's memory to.
2875
- workChildren.set(ch, job.sessionId);
2876
- // The process exists, so the reserved slot is now counted by
2877
- // the registry itself. Idempotent — the `finally` below releases
2878
- // it again for every path that never got here.
2879
- releaseSlot();
2880
- // The CLI is spawned `detached`, so its pid IS its process
2881
- // group id — and every process it starts inherits that, through
2882
- // `nohup` and `setsid` alike. Remembered per SESSION rather
2883
- // than per turn, because the whole point is the watcher that
2884
- // outlives the turn that started it.
2885
- if (ch.pid) noteSessionGroup(job.sessionId, ch.pid);
2886
- if (lockPath && ch.pid) {
2887
- try {
2888
- const start = processStartTime(ch.pid);
2889
- writeFileSync(lockPath, start ? `${ch.pid}:${start}` : String(ch.pid));
2890
- } catch {
2891
- /* best-effort */
2892
- }
2893
- }
2894
- },
2895
- };
2896
- out = await runTurn({
2897
- ...turnArgs,
2898
- resume,
2899
- resumeThreadId: codexResumeId || claudeResumeId || undefined,
2900
- resumeConversationId: agyConvId || undefined,
2901
- });
2902
- // A resume that produced NOTHING usually means the held
2903
- // conversation is gone (a first turn that crashed before writing
2904
- // state, a wiped CLI dir — or, on codex, a deleted thread). Retry
2905
- // once fresh in the SAME worktree — never reset — instead of
2906
- // bricking the tab forever; the retry carries no resumeThreadId,
2907
- // so codex genuinely starts over rather than re-asking for the
2908
- // thread that just came back empty. NEVER on an adopt turn
2909
- // (`resume` is structurally false there, and the guard says so out
2910
- // loud): a fresh conversation would silently discard the adoption
2911
- // and answer as a new session wearing its name — the empty adopt
2912
- // turn settles failed below instead.
2913
- /**
2914
- * …OR PRODUCED ONLY THE CLI SAYING THE CONVERSATION IS GONE.
2915
- *
2916
- * "Produced nothing" was the whole test, and it is not the shape
2917
- * these failures take: Claude Code answers a dead `--resume` id
2918
- * with a result event carrying `errors: ['No conversation found
2919
- * with session ID: …']` and writes the same line to stderr, so
2920
- * `out` is non-empty. The backstop never fired, the turn settled
2921
- * SUCCESSFULLY with that error as its answer, and — because the
2922
- * marker is only rewritten when an init event is seen, and there
2923
- * was none — the dead id stayed pinned. Every later message in
2924
- * that tab replied identically, with nothing on any surface able
2925
- * to clear it. A tab bricked forever by its own CLI pruning its
2926
- * history, which it does on its own schedule.
2927
- */
2928
- if (
2929
- !adopting &&
2930
- resume &&
2931
- (!(out || '').trim() ||
2932
- resumeConversationLost(out, { runtime: rt.id, sawInit: Boolean(seenClaudeSession) }))
2933
- ) {
2934
- out = await runTurn({ ...turnArgs, resume: false });
2935
- }
2936
- } finally {
2937
- // The CLI has stopped printing, so stop relaying. The LINE itself
2938
- // is cleared server-side at settle — clearing it here would race
2939
- // the settle and blank the tab a beat before the reply lands.
2940
- narrator.stop();
2941
- flushAudit();
2942
- for (const ch of spawned) workChildren.delete(ch);
2943
- if (lockPath) {
2944
- try {
2945
- rmSync(lockPath, { force: true });
2946
- } catch {
2947
- /* best-effort */
2948
- }
2949
- }
2950
- if (mcp.dir) rmSync(mcp.dir, { recursive: true, force: true });
2951
- }
2952
- // Persist the codex thread id AFTER the turn ends, so the next turn
2953
- // resumes exactly the conversation that just spoke. Shape-guarded
2954
- // before it ever touches disk — it later rides in argv as
2955
- // `resume <id>` — and best-effort, like the runtime pin: an
2956
- // unwritable marker just means the tab runs fresh next turn.
2957
- // The conversation THIS TAB just spoke under, pinned so the next
2958
- // turn resumes it by id rather than asking the directory. Written
2959
- // after the turn for the same reason codex's is: an id learned
2960
- // mid-turn is only true once the turn that learned it finished.
2961
- if (
2962
- rt.id === 'claude' &&
2963
- seenClaudeSession &&
2964
- /^[A-Za-z0-9_-]{8,64}$/.test(seenClaudeSession)
2965
- ) {
2966
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
2967
- if (convMarker) {
2968
- try {
2969
- writeFileSync(convMarker, seenClaudeSession);
2970
- } catch {
2971
- /* best-effort — the next turn re-learns it */
2972
- }
2973
- }
2974
- }
2975
- if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
2976
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
2977
- if (threadMarker) {
2978
- try {
2979
- writeFileSync(threadMarker, seenThreadId);
2980
- } catch {
2981
- /* best-effort */
2982
- }
2983
- }
2984
- }
2985
- const answer = (out || '').trim();
2986
- // No output at all smells like a dead MCP credential (the lane
2987
- // workers' no-sentinel case) — drop the cached token so the next
2988
- // turn re-mints instead of failing the same way forever.
2989
- if (!answer) workTokens.delete(job.sessionId);
2990
- if (adopting && !answer) {
2991
- // The fork came back with nothing — the terminal session's
2992
- // transcript is most likely gone (cleaned, expired, deleted). Say
2993
- // exactly that; no sessionRef is recorded, so the server keeps
2994
- // offering the adoption and a retry after the user checks is cheap.
2995
- await settleWorkTurn(job.id, {
2996
- ok: false,
2997
- answer: "Couldn't resume the terminal session — it may have been removed.",
2998
- // Whatever it DID before coming back empty is exactly the
2999
- // question a failed turn's log answers.
3000
- ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
3001
- });
3002
- warn('adopt turn produced no output — settled as failed');
3003
- return;
3004
- }
3005
- // Persist the agy conversation id once the turn actually SPOKE — an
3006
- // adopted tab pins the id it moved in (the adopt hint); a new tab
3007
- // learns the one its first fresh turn just created, from agy's own
3008
- // cwd registry. From here on the marker is the tab's identity and
3009
- // the registry is never trusted again.
3010
- if (rt.id === 'antigravity' && answer.length > 0) {
3011
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
3012
- if (convMarker && !existsSync(convMarker)) {
3013
- const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
3014
- if (learned && AGY_CONV_RE.test(learned)) {
3015
- try {
3016
- writeFileSync(convMarker, learned);
3017
- } catch {
3018
- /* best-effort — an unpinned tab resumes via --continue's cwd key */
3019
- }
3020
- }
3021
- }
3022
- }
3023
- await settleWorkTurn(job.id, {
3024
- ok: answer.length > 0,
3025
- answer:
3026
- answer.length > 0
3027
- ? // Scrub: a reply can quote config or env-adjacent code.
3028
- envScrub(answer).slice(0, 16000)
3029
- : 'the turn produced no output on the machine — its CLI may be signed out; try again',
3030
- // Only a turn that actually SPOKE proves a conversation lives
3031
- // here. Recording the path unconditionally is how a crashed first
3032
- // turn used to brick resume for the session's whole life.
3033
- ...(answer.length > 0 ? { sessionRef: dir.wt } : {}),
3034
- // The turn's tool log, in final form — the durable copy that lands
3035
- // on the settled message (the live copy on the record is cleared
3036
- // at settle). Already scrubbed at collection.
3037
- ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
3038
- });
3039
- if (answer.length > 0) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
3040
- else warn('session turn produced no output — settled as failed');
3041
- } catch (e) {
3042
- await settleWorkTurn(job.id, {
3043
- ok: false,
3044
- // Scrub, like every string that leaves this machine: an exception
3045
- // routinely quotes command output, and command output can quote a
3046
- // synced secret.
3047
- answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
3048
- // "What did it do before it failed" is exactly the question a
3049
- // crashed turn's log answers — same spread as the main settle.
3050
- ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
3051
- });
3052
- warn(`session turn failed: ${e?.message ?? e}`);
3053
- } finally {
3054
- workAnswering.delete(job.id);
3055
- // Nothing spawned, or everything already has: releasing twice is the
3056
- // normal case and costs nothing. A reservation that leaked would
3057
- // shrink this machine's ceiling for the life of the process.
3058
- releaseSlot();
3059
- // The turn just changed the directory — say what it looks like now,
3060
- // whether it succeeded or blew up (a failed turn can still have
3061
- // written half a file, and the tab should show that honestly). NOT
3062
- // awaited: this runs inside the session's chain, and a slow POST
3063
- // would delay the next turn of that tab behind a readout.
3064
- void reportPlaceWorktrees(job.sessionId).catch(() => {});
3065
- burstListeners(job.sessionId);
3066
- // …and relay what it wrote to SHOW its owner (2026-09-22) — the same
3067
- // beat, the same reason it is not awaited. A failed turn's half-drawn
3068
- // page is still what the turn left, and the tab should see it.
3069
- if (artifactScan) {
3070
- void artifacts
3071
- .report({
3072
- placeDir: artifactScan.dir,
3073
- before: artifactScan.before,
3074
- sessionId: job.sessionId,
3075
- turnId: job.id,
3076
- })
3077
- .catch(() => {});
3078
- }
3079
- }
3080
- });
3081
- }
3082
- };
3083
-
3084
- /**
3085
- * KEEP EACH PERSON'S MANUAL WORKTREE FRESH.
3086
- *
3087
- * Every teammate's Workbench tabs share one directory of their own on a
3088
- * branch of their own — which is not a preference, it is what git allows:
3089
- * two worktrees cannot have the same branch checked out, so "everyone works
3090
- * on main" is only true for the machine's OPERATOR, whose place is the
3091
- * checkout itself. Everyone else needs a branch, and a branch left alone
3092
- * drifts behind main until the first thing they do in a new tab is a merge
3093
- * they did not ask for.
3094
- *
3095
- * FAST-FORWARD ONLY, and that is the whole safety argument. If their branch
3096
- * has no commits of its own it simply catches up, which is the ordinary case
3097
- * and the one worth automating. The moment it HAS diverged, this stops and
3098
- * leaves it exactly as it is: their commits are theirs, a rebase would
3099
- * rewrite them under somebody who is not looking, and a merge would put a
3100
- * commit in their history that they did not make. Their own Claude can fold
3101
- * base in whenever they ask it to.
3102
- *
3103
- * Guarded three ways: a DIRTY tree is left alone (uncommitted work outranks
3104
- * freshness), a place with a live lock is skipped (a turn is standing in it),
3105
- * and the whole thing is silent — nothing here reports, warns or blocks.
3106
- */
3107
- const freshenManualPlaces = () => {
3108
- let dir;
3109
- try {
3110
- dir = readdirSync(join(baseDir, 'sessions'));
3111
- } catch {
3112
- return; // no worktrees yet
3113
- }
3114
- for (const place of dir) {
3115
- // Only a PERSON's manual place. An agent's own worktree (`a-<id>`) is
3116
- // deliberately not touched: its branch is the reviewable unit, and
3117
- // moving it under a review would change what somebody is deciding about.
3118
- if (!place.startsWith('u-') || !isSafePathSegment(place)) continue;
3119
- if (placeLocks.has(place)) continue;
3120
- const wt = join(baseDir, 'sessions', place);
3121
- try {
3122
- if (git(['status', '--porcelain'], wt).trim() !== '') continue;
3123
- git(['merge', '--ff-only', baseRef()], wt);
3124
- } catch {
3125
- /* diverged, or something else is going on in there. Leave it. */
3126
- }
3127
- }
3128
- };
3129
-
3130
- const state = {
3131
- shipping,
209
+ const {
210
+ pendingWorkReports,
3132
211
  pendingShipReports,
212
+ postBestEffort,
213
+ REJECT_RETRY_MS,
214
+ settleWorkTurn,
215
+ settleShip,
216
+ flushWorkReports,
217
+ sweepMergedSessionBranch,
218
+ } = createWorkReportQueue({
219
+ repoRoot,
220
+ baseRef,
221
+ workAttempts,
222
+ artifacts,
223
+ landed,
224
+ onRepoChanged,
225
+ reportPlaceWorktrees,
226
+ burstListeners,
227
+ });
228
+ const { workTokens, mintWorkToken } = createWorkSessionTokens({ getLeaseTtl });
229
+ const { makeNarrator, sayTurnDeferred, lastDeferSaid } = createWorkNarration();
230
+ const { fetchAttachments } = createWorkAttachments();
231
+ const { sessionRuntime, agyRegistryLookup } = createWorkSessionRuntime({ sessionMetaPath });
232
+ const { carryDirtyState } = createAdoptCarry({ sessionMetaPath });
233
+ const { processWorkTurns, workAnswering } = createWorkSessionTurns({
234
+ repoRoot,
235
+ baseDir,
236
+ getMcpUrl,
237
+ getArtifactsAccepted,
238
+ admit,
239
+ inPlace,
3133
240
  workChildren,
3134
- placeLocks,
3135
- groupKillChildren,
3136
- agentPublished,
3137
- agentRemoteAt,
3138
- sessionGroups,
3139
- };
241
+ workAttempts,
242
+ sessionPlaces,
243
+ placeOf,
244
+ placeWtFor,
245
+ sessionMetaPath,
246
+ beforeArtifacts,
247
+ artifacts,
248
+ settleWorkTurn,
249
+ pendingWorkReports,
250
+ workTokens,
251
+ mintWorkToken,
252
+ sessionRuntime,
253
+ agyRegistryLookup,
254
+ carryDirtyState,
255
+ fetchAttachments,
256
+ makeNarrator,
257
+ sayTurnDeferred,
258
+ lastDeferSaid,
259
+ noteSessionGroup,
260
+ reportPlaceWorktrees,
261
+ burstListeners,
262
+ });
263
+
264
+ /*
265
+ * THE SHARED MAPS GO BY NAME (2026-09-26, SOLID F164). They were one
266
+ * eight-field `state` bag handed to six factories that each read one to
267
+ * three of it, which hid every worker's real dependencies. Each factory now
268
+ * names exactly the maps it reads or writes, so a reader sees at the call
269
+ * which lanes share which map (workChildren: plans, review, turns; the
270
+ * publication maps: turns and merges).
271
+ */
3140
272
 
3141
273
  const { processDiffJobs } = createWorkDiffs({
3142
274
  repoRoot,
@@ -3148,7 +280,6 @@ export function createWorkManager({
3148
280
 
3149
281
  const { processPrJobs } = createWorkPullRequests({
3150
282
  placeOf,
3151
- REPO_PLACE,
3152
283
  repoRoot,
3153
284
  baseDir,
3154
285
  baseRef,
@@ -3161,41 +292,45 @@ export function createWorkManager({
3161
292
  inPlace,
3162
293
  placeOf,
3163
294
  settleShip,
3164
- REPO_PLACE,
3165
295
  repoRoot,
3166
296
  baseDir,
3167
297
  gitNet,
3168
298
  baseRef,
3169
299
  placeWtFor,
3170
- turnLockedByLivePid,
3171
300
  sessionMetaPath,
3172
- state,
301
+ shipping,
302
+ pendingShipReports,
3173
303
  });
3174
304
 
3175
305
  const { processAgentPlanJobs, planning } = createWorkAgentPlans({
3176
306
  postBestEffort,
3177
307
  baseDir,
3178
308
  baseRef,
3179
- REPO_PLACE,
3180
309
  inPlace,
3181
310
  repoRoot,
3182
311
  admit,
3183
- state,
312
+ placeLocks,
313
+ workChildren,
3184
314
  });
3185
315
 
3186
- const { runReviewEntry } = createWorkAgentReview({
316
+ // The check and the pre-review are built with only what each one uses; the
317
+ // review entry beat is handed the two and owns only their order.
318
+ const { runCheck } = createWorkAgentCheck({
3187
319
  repoRoot,
3188
320
  postBestEffort,
321
+ workChildren,
322
+ groupKillChildren,
323
+ });
324
+ const { runPrecheck } = createWorkAgentPrecheck({
3189
325
  baseRef,
3190
326
  admit,
3191
327
  sessionMetaPath,
3192
- state,
328
+ workChildren,
3193
329
  });
330
+ const { runReviewEntry } = createWorkAgentReview({ runCheck, runPrecheck });
3194
331
 
3195
332
  const { processAgentTurnJobs, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
3196
333
  REJECT_RETRY_MS,
3197
- CODEX_THREAD_RE,
3198
- resumeConversationLost,
3199
334
  baseRef,
3200
335
  inPlace,
3201
336
  baseDir,
@@ -3203,7 +338,6 @@ export function createWorkManager({
3203
338
  fetchPublishedBranch,
3204
339
  placeWtFor,
3205
340
  sessionMetaPath,
3206
- brainFor,
3207
341
  beforeArtifacts,
3208
342
  getArtifactsAccepted,
3209
343
  noteSessionGroup,
@@ -3212,7 +346,9 @@ export function createWorkManager({
3212
346
  runReviewEntry,
3213
347
  publishAgentBranch,
3214
348
  admit,
3215
- state,
349
+ workChildren,
350
+ agentPublished,
351
+ agentRemoteAt,
3216
352
  });
3217
353
 
3218
354
  const { processAgentMergeJobs, agentMerges } = createWorkAgentMerges({
@@ -3224,13 +360,28 @@ export function createWorkManager({
3224
360
  runReviewEntry,
3225
361
  onRepoChanged,
3226
362
  landed,
3227
- state,
363
+ agentRemoteAt,
364
+ agentPublished,
365
+ });
366
+
367
+ // Built after the agent lane: retirement stops a stopped agent's CLI, and
368
+ // only the agent lane knows which child that is.
369
+ const { heldSessionIds, retireWorkSessions, freshenManualPlaces } = createWorkRetire({
370
+ repoRoot,
371
+ baseDir,
372
+ baseRef,
373
+ placeOf,
374
+ placeLocks,
375
+ shipping,
376
+ workTokens,
377
+ agentChildren,
378
+ sweepMergedSessionBranch,
3228
379
  });
3229
380
 
3230
381
  const { processKillJobs } = createWorkProcesses({
3231
382
  placeDir,
3232
383
  reportPlaceWorktrees,
3233
- state,
384
+ sessionGroups,
3234
385
  });
3235
386
 
3236
387
  /**
@@ -3283,7 +434,7 @@ export function createWorkManager({
3283
434
  shutdownWork,
3284
435
  workBusy,
3285
436
  // The machine's own admission answer, and what it is counting. Handed to
3286
- // the loop so the lanes fleet.mjs owns — the wiki cartographer — ask the
437
+ // the loop so the lanes it owns — the wiki cartographer (wikiRunner.mjs) — ask the
3287
438
  // same question, and so the machine snapshot can charge memory to the work
3288
439
  // holding it.
3289
440
  admit,