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
@@ -0,0 +1,426 @@
1
+ /**
2
+ * THE WORKTREE READOUT — what each place looks like (diff, listeners,
3
+ * processes, title, box, publication), reported after a settle, in a decaying
4
+ * burst while a dev server binds, and in a throttled rotating sweep over every
5
+ * live session.
6
+ *
7
+ * Split out of work.mjs (2026-09-26, SOLID F037). Every trigger (a settle, a
8
+ * ship, a kill, the poll) asks for a measurement and nothing else; this
9
+ * module owns what a measurement IS and when the sweep runs, so a new field
10
+ * on `/fleet/session-worktrees` is one edit here. A daemon→server report, so
11
+ * none of it needs a version floor.
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, MACHINE_HOST } from './config.mjs';
15
+ import { gitNetAsync, isSafePathSegment } from './git.mjs';
16
+ import { measureListeners, listenersSupported } from './listeners.mjs';
17
+ import { processesSupported } from './processes.mjs';
18
+ import { titleForSession } from './claudeSessions.mjs';
19
+ import { worktreeDiff } from './worktreeDiff.mjs';
20
+ import { myPubB64 } from './boxIdentity.mjs';
21
+ import { fleetEndpoint } from './fleetWire.mjs';
22
+ import { REPO_PLACE } from './workPlaces.mjs';
23
+
24
+ export function createWorkPlaceReports({
25
+ repoRoot,
26
+ baseRef,
27
+ placeOf,
28
+ placeDir,
29
+ sessionMetaPath,
30
+ sessionProcesses,
31
+ pruneSessionGroups,
32
+ agentPublished,
33
+ agentRemoteAt,
34
+ publishAgentBranch,
35
+ landed,
36
+ }) {
37
+ const WORKTREES_URL = fleetEndpoint('session-worktrees', FLEET_URL);
38
+ /**
39
+ * WHERE EACH TAB IS STANDING, and what it holds — the readout a human would
40
+ * get by running `git status` in the session's directory, which is the one
41
+ * thing they cannot do from a browser.
42
+ *
43
+ * Two triggers, both cheap: right after a turn settles (the moment the diff
44
+ * changed) and a throttled sweep over every live session (a human editing in
45
+ * the worktree, a build writing files, a ship landing). Best-effort like the
46
+ * narrator: never awaited by a turn, every failure swallowed.
47
+ */
48
+ const WORKTREE_SWEEP_MS = 60_000;
49
+ /** How often the sweep refreshes `origin/<base>` before measuring. The
50
+ * behind-count is the whole point of the readout — "someone pushed while you
51
+ * were working" — and without a fetch it would only ever count what this
52
+ * machine already happened to have. Rarer than the sweep because a fetch is
53
+ * network, and a teammate's push being visible within three minutes is the
54
+ * same promise the rest of the product makes. */
55
+ const WORKTREE_FETCH_MS = 3 * 60_000;
56
+ let lastWorktreeSweep = 0;
57
+ /** Sessions this process has already tried to measure. See `reportWorktrees`. */
58
+ const worktreeSeen = new Set();
59
+ let lastWorktreeFetch = 0;
60
+ let sweepingWorktrees = false;
61
+ /**
62
+ * WHERE THE NEXT SWEEP STARTS.
63
+ *
64
+ * A safety valve with a rotation, and the rotation is the load-bearing half.
65
+ * The sweep used to take `activeIds.slice(0, 20)` — a silent truncation of a
66
+ * list the server builds tabs-first and agent places LAST, so on a project
67
+ * with twenty live tabs no agent was EVER measured: no branch diff, no head
68
+ * sha, and none of their trailered commits ever reached a card. Permanently,
69
+ * because the same twenty won every pass.
70
+ *
71
+ * Chunking removes the cut for any realistic project (see below). The cursor
72
+ * is what makes the residual cap fair rather than arbitrary: past it, the
73
+ * places that missed one sweep are the ones that lead the next.
74
+ */
75
+ let worktreeCursor = 0;
76
+ const postWorktrees = async (reports) => {
77
+ if (!reports.length) return;
78
+ try {
79
+ await fetch(WORKTREES_URL, {
80
+ method: 'POST',
81
+ headers: {
82
+ Authorization: `Bearer ${FLEET_TOKEN}`,
83
+ 'User-Agent': USER_AGENT,
84
+ 'Content-Type': 'application/json',
85
+ },
86
+ signal: AbortSignal.timeout(20_000),
87
+ body: JSON.stringify({ reports }),
88
+ });
89
+ } catch {
90
+ /* a readout — the next sweep carries it */
91
+ }
92
+ };
93
+ const sessionWorktreeReport = (sessionId) => {
94
+ // The session's PLACE, not its name: a tab in the checkout is measured in
95
+ // the checkout, and a tab sharing another tab's worktree is measured there.
96
+ const place = placeOf(sessionId);
97
+ if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
98
+ const wt = placeDir(sessionId);
99
+ const d = worktreeDiff(wt, baseRef());
100
+ if (!d) return null;
101
+ // WHAT IS LISTENING in this worktree, attributed by the CWD of the process
102
+ // holding the socket. It rides the sweep the daemon already makes rather
103
+ // than taking a beat of its own, exactly as the commit trailers do — and
104
+ // like them it needs no version floor, because it is a daemon→server report
105
+ // on an endpoint that already exists. An older server ignores the key.
106
+ //
107
+ // The browser NEVER names a directory and never names a port this did not
108
+ // report: ports are global to a box and a worktree is not, so this
109
+ // measurement is the security boundary for the whole preview feature.
110
+ // `listeningSupported` says whether this machine can measure AT ALL, which
111
+ // is a different fact from finding nothing. Windows reports nothing and a
112
+ // failed scan reports nothing, and both were indistinguishable from an idle
113
+ // worktree — harmless while the only consumer needed a NON-empty array, and
114
+ // a permanent `Starting…` the moment a Run dev offer hangs off an empty one.
115
+ // …AND WHAT IT IS RUNNING, attributed by PROCESS GROUP rather than by cwd.
116
+ // A watcher (`rbxtsc -w`, `tsc --watch`) holds no socket and touches no
117
+ // file for minutes, so it was invisible from a browser in a way it never is
118
+ // in a terminal. Same rules as `listening` beside it: a daemon→server
119
+ // report on an endpoint that already exists, so NO version floor, and
120
+ // `processesSupported` keeps "cannot look" (Windows) apart from "looked and
121
+ // found none", which renders differently.
122
+ const proc = sessionProcesses(sessionId);
123
+ // THE NAME CLAUDE ALREADY GAVE THIS CONVERSATION, relayed.
124
+ //
125
+ // Claude Code titles its own sessions; a Flowviant tab was born "session 3"
126
+ // and stayed that way unless somebody renamed it by hand, so a strip of
127
+ // eight tabs said nothing about any of them. The title is not ours to
128
+ // invent — reading it is the same relay this whole file does, and asking
129
+ // a model for one would be a second brain, which the product forbids.
130
+ //
131
+ // Only CLAUDE tabs have one here: the id is the one the CLI reported at
132
+ // `system.init` and pinned per tab, so a codex or agy tab simply has no
133
+ // marker and reports no title. No runtime check is needed for that — the
134
+ // absent marker IS the check.
135
+ //
136
+ // No version floor: a daemon→server report on an endpoint that already
137
+ // exists, so an older daemon sends no key and the server leaves the name
138
+ // exactly as it found it.
139
+ let title = null;
140
+ try {
141
+ const marker = sessionMetaPath(wt, 'flowviant-claude-session', sessionId);
142
+ if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
143
+ } catch {
144
+ /* no marker yet — this tab has not spoken, or is not Claude */
145
+ }
146
+ // The TOTAL rides beside the capped rows, because a list silently cut at
147
+ // twelve answers "what is running in here" with a number that is not true.
148
+ // `wrangler dev` alone opens nine, so the old cap of eight was already
149
+ // dropping a row on an ordinary stack with nothing on the wire to say so.
150
+ const lis = measureListeners(wt);
151
+ /**
152
+ * WHICH BOX MEASURED THIS — on an agent's report only.
153
+ *
154
+ * The server stores it on the agent row so a LATER turn can be checked
155
+ * against the box that actually holds the work: an agent's branch and its
156
+ * conversation exist on one machine's disk until somebody approves it, and
157
+ * two boxes on one credential can both be offered its turns. The daemon
158
+ * reports what it is; the server does the comparing.
159
+ *
160
+ * A daemon→server report on an endpoint that already exists, so no floor —
161
+ * an older daemon sends no key and the agent is left UNATTRIBUTED, which is
162
+ * a third state the server reads as "nobody said" rather than as "not this
163
+ * box". `envpub` is the identity for the same reason the poll uses it: it is
164
+ * durable per box, and the hostname beside it is only a label for a person
165
+ * to read. Absent when the keypair is unreadable — that machine is exempt
166
+ * from arbitration entirely, which is the fail-open direction.
167
+ *
168
+ * TABS GET NOTHING. A tab's place is shared by design and its work is a
169
+ * human's own directory; attributing one would be a fact with no reader.
170
+ */
171
+ const pub = sessionId.startsWith('a-') && sessionId.length > 2 ? myPubB64() : null;
172
+ /**
173
+ * …AND WHAT THIS MACHINE PUSHED OF IT (0.86.0).
174
+ *
175
+ * The ONE road by which the server learns a push happened: it composes the
176
+ * target name and sends it, and stores nothing until a machine reports
177
+ * back. So an unreported push renders nothing and no surface can name a ref
178
+ * nobody can pull — the same "never assert what you did not observe" rule
179
+ * the box id beside it keeps.
180
+ *
181
+ * NO KEY AT ALL until a target arrives, which is what lets absence keep its
182
+ * one meaning: an older server, or a project with publishing off, leaves
183
+ * the agent reading as never published rather than as failed.
184
+ *
185
+ * Mutually exclusive without a rule of its own, because the state it reads
186
+ * holds a sha or an error and never both.
187
+ */
188
+ const pushed = sessionId.startsWith('a-') ? agentPublished.get(sessionId) : null;
189
+ return {
190
+ sessionId,
191
+ ...d,
192
+ ...(pub ? { box: { id: pub, name: MACHINE_HOST } } : {}),
193
+ ...(pushed?.sha ? { published: { ref: pushed.ref, sha: pushed.sha } } : {}),
194
+ ...(pushed?.error ? { publishError: pushed.error } : {}),
195
+ listening: lis.rows,
196
+ listeningTotal: lis.total,
197
+ listeningSupported: listenersSupported(),
198
+ ...(proc === null ? {} : { processes: proc.rows, processesTotal: proc.total }),
199
+ processesSupported: processesSupported(),
200
+ ...(title ? { title } : {}),
201
+ };
202
+ };
203
+ /** One session, now. */
204
+ const reportSessionWorktree = async (sessionId) => {
205
+ const r = sessionWorktreeReport(sessionId);
206
+ if (r) await postWorktrees([r]);
207
+ };
208
+
209
+ /**
210
+ * …AND EVERY OTHER TAB STANDING IN THE SAME DIRECTORY.
211
+ *
212
+ * One tab is not one directory any more. Since tabs moved into the driver's
213
+ * own folder, every tab a person owns resolves to the SAME place — so a turn
214
+ * in tab A changed the directory tab B is also describing, and only tab A was
215
+ * re-measured. Tab B went on rendering its pre-turn `+A −D` for up to a
216
+ * minute, which makes the tab strip visibly disagree with itself about one
217
+ * directory. That is the "why are two tabs showing one dev server" confusion
218
+ * the places readout exists to END, arriving through the diffstat instead.
219
+ *
220
+ * It fires on EVERY turn, every ship and every stop, which is what made this
221
+ * the most-hit instance of the rule and the least visible: nothing is wrong
222
+ * on the tab you are looking at.
223
+ *
224
+ * Bounded by the live set the roster last handed us, and the reports go in
225
+ * ONE post — the endpoint is already batched, and a tab per request would
226
+ * turn a five-tab place into five round trips on every settle.
227
+ */
228
+ const reportPlaceWorktrees = async (sessionId) => {
229
+ const place = placeOf(sessionId);
230
+ const ids = [sessionId];
231
+ // `worktreeSeen` is the roster's own live set, pruned to `activeWorkSessions`
232
+ // on every sweep — so this can never report a tab that has closed, and it
233
+ // needs no second source of truth about which tabs exist.
234
+ for (const id of worktreeSeen) {
235
+ if (id !== sessionId && placeOf(id) === place) ids.push(id);
236
+ }
237
+ const reports = ids.map(sessionWorktreeReport).filter(Boolean);
238
+ if (reports.length) await postWorktrees(reports);
239
+ };
240
+
241
+ /**
242
+ * THE FIRST MINUTE AFTER A SETTLE — when "run the dev server" actually binds.
243
+ *
244
+ * The settle-time report fires the moment the reply lands, but a dev server
245
+ * the agent just started usually takes a few more seconds to open its socket
246
+ * (vite boots, next compiles). It therefore missed the settle measurement and
247
+ * waited the full 60s sweep — up to a minute of "nothing is running here"
248
+ * over a server that was already up, which is the slowest link in the whole
249
+ * "ask for dev → see the preview" chain. Asked directly: "how do we make it
250
+ * more responsive when the user prompts claude to run dev to waiting for it
251
+ * to appear on the preview?"
252
+ *
253
+ * A DECAYING BURST, and it re-CHECKS before it re-REPORTS: each beat walks
254
+ * /proc for the place's listeners (purely local, no git, no network) and only
255
+ * when the PORT SET actually changed does the full place report run and post.
256
+ * A settle where nothing ever binds costs five /proc walks and zero posts; a
257
+ * dev server that binds at +7s is on the wire at +9 instead of +60. The burst
258
+ * for a place restarts on its next settle, so overlapping turns cannot stack
259
+ * timers, and every timer is unref'd — a readout must never hold the process
260
+ * open.
261
+ *
262
+ * This also serves the OPPOSITE transition for free: a stopped dev server
263
+ * (the panel's Stop, a ctrl-C in a terminal) vanishes from the port set the
264
+ * same way it appeared, so the preview's "origin gone" story starts in
265
+ * seconds too.
266
+ */
267
+ const LISTEN_BURST_DELAYS_MS = [4_000, 9_000, 16_000, 30_000, 55_000];
268
+ const listenBursts = new Map(); // place -> timers[]
269
+ const listenSignature = (wt) => {
270
+ try {
271
+ const l = measureListeners(wt);
272
+ return l.rows.map((r) => r.port).sort((a, b) => a - b).join(',');
273
+ } catch {
274
+ return '';
275
+ }
276
+ };
277
+ const burstListeners = (sessionId) => {
278
+ try {
279
+ const place = placeOf(sessionId);
280
+ for (const t of listenBursts.get(place) ?? []) clearTimeout(t);
281
+ const wt = placeDir(sessionId);
282
+ // Captured alongside the settle report, so only a CHANGE after this
283
+ // moment triggers a post — the settle report already said the rest.
284
+ let last = listenSignature(wt);
285
+ const timers = LISTEN_BURST_DELAYS_MS.map((d) =>
286
+ setTimeout(() => {
287
+ try {
288
+ const sig = listenSignature(wt);
289
+ if (sig === last) return;
290
+ last = sig;
291
+ void reportPlaceWorktrees(sessionId).catch(() => {});
292
+ } catch {
293
+ /* a readout — the sweep still carries it */
294
+ }
295
+ }, d)
296
+ );
297
+ for (const t of timers) t.unref?.();
298
+ listenBursts.set(place, timers);
299
+ } catch {
300
+ /* never let the burst break a settle */
301
+ }
302
+ };
303
+ /** Every live session, throttled — called from the reconcile loop. */
304
+ /**
305
+ * A SESSION NOBODY HAS MEASURED YET JUMPS THE SWEEP (2026-08-26).
306
+ *
307
+ * The throttle is GLOBAL, not per-session, so a tab opened one second after a
308
+ * sweep waited the remaining fifty-nine for its first measurement — and until
309
+ * it lands there is no branch, no directory and no listeners anywhere in the
310
+ * product, because every one of those readouts is gated on a measurement and
311
+ * renders nothing rather than inventing a state. Asked directly: "how come it
312
+ * takes a while for a new session to show branch and worktree and listeners
313
+ * after i create a new tab."
314
+ *
315
+ * ATTEMPTED, never MEASURED, is what is remembered. A session whose directory
316
+ * cannot be read yet — a pre-places daemon that has not cut one, a worktree
317
+ * mid-creation — would otherwise be "unmeasured" on every poll and force a
318
+ * full sweep each time. Recording the attempt bounds it at exactly one extra
319
+ * sweep per session, ever, after which the normal cadence carries it.
320
+ */
321
+ const reportWorktrees = (activeIds) => {
322
+ if (!Array.isArray(activeIds) || activeIds.length === 0) return;
323
+ if (sweepingWorktrees) return;
324
+ const firstSight = activeIds.some((id) => !worktreeSeen.has(id));
325
+ if (!firstSight && Date.now() - lastWorktreeSweep < WORKTREE_SWEEP_MS) return;
326
+ // Bounded to LIVE sessions: a long-running daemon must not accumulate a
327
+ // uuid per tab anyone has ever opened. Pruning also means a reopened tab is
328
+ // measured immediately again, which is the same answer for the same reason.
329
+ const live = new Set(activeIds);
330
+ for (const id of worktreeSeen) if (!live.has(id)) worktreeSeen.delete(id);
331
+ for (const id of activeIds) worktreeSeen.add(id);
332
+ // The publish record is bounded the same way and for the same reason: an
333
+ // agent the roster has stopped naming is done, its ref is the server's
334
+ // business now (the merge lane deletes it when the work lands), and a
335
+ // long-running daemon must not accumulate a row per agent that ever ran.
336
+ for (const id of agentPublished.keys()) if (!live.has(id)) agentPublished.delete(id);
337
+ for (const id of agentRemoteAt.keys()) if (!live.has(id)) agentRemoteAt.delete(id);
338
+ pruneSessionGroups(activeIds);
339
+ sweepingWorktrees = true;
340
+ lastWorktreeSweep = Date.now();
341
+ void (async () => {
342
+ try {
343
+ // Refresh the base before measuring, so "3 new on main" means what a
344
+ // person thinks it means. Throttled, best-effort, and never fatal: an
345
+ // offline machine reports the counts it can still compute.
346
+ if (Date.now() - lastWorktreeFetch >= WORKTREE_FETCH_MS) {
347
+ lastWorktreeFetch = Date.now();
348
+ try {
349
+ // ASYNC and TIMED: this runs every three minutes with nobody
350
+ // watching, and a synchronous fetch against a remote that prompts
351
+ // on /dev/tty or a connection gone half-open froze the whole
352
+ // daemon — no polls, no settles, no lease renewals — until it
353
+ // returned. See `gitNetAsync`.
354
+ await gitNetAsync(['fetch', 'origin', '--quiet'], repoRoot);
355
+ } catch {
356
+ /* offline, no remote, or timed out — the numbers just age */
357
+ }
358
+ // The fetch may have moved the base tip — walk and report what
359
+ // landed. Best-effort like everything in this sweep.
360
+ void landed.observe().catch(() => {});
361
+ }
362
+ /**
363
+ * MEASURED IN CHUNKS, not truncated to one.
364
+ *
365
+ * The server's endpoint takes twenty entries per request, and this read
366
+ * that bound as "measure twenty places" — so everything past the
367
+ * twentieth was silently never measured, and the server builds that
368
+ * list with agent places at the END. A project with twenty live tabs
369
+ * therefore measured no agent at all: their review pane showed no
370
+ * branch diff, `checkIsStale` had no head to compare against, and their
371
+ * commits never reached the cards they name.
372
+ *
373
+ * The cap on a REQUEST is not a cap on the WORK. Several requests of
374
+ * twenty cost several round trips and each is validated per entry
375
+ * exactly as before, so no contract changes and no floor is needed.
376
+ *
377
+ * `SWEEP_MAX_PLACES` is a bound on the MACHINE — each place costs a
378
+ * `git diff`, a listener scan and a process scan — and the cursor
379
+ * rotates so a project past it still measures everything, just across
380
+ * successive sweeps instead of one.
381
+ */
382
+ const SWEEP_MAX_PLACES = 60;
383
+ const CHUNK = 20;
384
+ const total = activeIds.length;
385
+ const start = total > SWEEP_MAX_PLACES ? worktreeCursor % total : 0;
386
+ const take = Math.min(total, SWEEP_MAX_PLACES);
387
+ // Rotated slice, so the tail of a long list leads the next sweep rather
388
+ // than never being reached.
389
+ const order = Array.from({ length: take }, (_, i) => activeIds[(start + i) % total]);
390
+ worktreeCursor = total > SWEEP_MAX_PLACES ? (start + take) % total : 0;
391
+ const reports = [];
392
+ for (const id of order) {
393
+ /**
394
+ * KEEP A PUBLISHED BRANCH CURRENT, not merely born.
395
+ *
396
+ * A settle publishes what the turn just wrote, which covers almost
397
+ * everything — but a branch also moves without a turn: the stale
398
+ * path folds base in before a merge, and an operator can commit in
399
+ * the agent's worktree by hand. Without this the remote ref would sit
400
+ * at whatever the last turn left and the durability claim would be
401
+ * quietly false for exactly the branches somebody is working on.
402
+ *
403
+ * ONLY where a target is already known. Nothing here composes a name,
404
+ * so an agent this process has never been told to publish is
405
+ * untouched — and the sha compare inside makes the resting cost of
406
+ * this loop one `rev-parse` per agent.
407
+ */
408
+ const known = agentPublished.get(id);
409
+ if (known?.ref) await publishAgentBranch(id, known.ref);
410
+ const r = sessionWorktreeReport(id);
411
+ if (r) reports.push(r);
412
+ }
413
+ // One POST per chunk. Awaited in sequence rather than fired together:
414
+ // this runs on the machine's own poll beat and a burst of parallel
415
+ // writes to the same rows buys nothing.
416
+ for (let i = 0; i < reports.length; i += CHUNK) {
417
+ await postWorktrees(reports.slice(i, i + CHUNK));
418
+ }
419
+ } finally {
420
+ sweepingWorktrees = false;
421
+ }
422
+ })();
423
+ };
424
+
425
+ return { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees };
426
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * WHERE A SESSION WORKS — the place map, the directory it resolves to, the
3
+ * worktree cut on first use, and the per-tab marker files beside its git dir.
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). Every other beat — a turn's
6
+ * spawn, the worktree sweep, ship, a preview re-check, the agent lane — asks
7
+ * one of these four questions, and they have to agree: a tab in the checkout
8
+ * measured against `sessions/<id>` reports nothing, and a marker written
9
+ * unscoped hands one tab another's conversation. One home keeps them one
10
+ * answer. The trust boundary (`isSafePathSegment` / `REPO_PLACE`) lives here
11
+ * with the map it guards; the session lane is the only other writer and keeps
12
+ * the same check (its comment says why).
13
+ */
14
+ import { existsSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { git, isSafePathSegment, hasCommits, gitFailure } from './git.mjs';
17
+
18
+ /** The place id meaning "the checkout", not a worktree. Must match the
19
+ * server's REPO_PLACE — it is a wire value, not a local convention. */
20
+ export const REPO_PLACE = 'repo';
21
+
22
+ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged }) {
23
+ /**
24
+ * WHERE EACH SESSION WORKS, learned from the turns we are handed.
25
+ *
26
+ * Every other beat — the worktree sweep, ship, the preview re-check — has to
27
+ * ask the SAME directory the turn ran in, and only the turn job carries
28
+ * `place`. Caching it here is what keeps them agreeing without a second
29
+ * server→daemon field: a session absent from this map has never run a turn,
30
+ * and its own id is the right answer for that case anyway (it is the default
31
+ * place, and a session with no turn has no worktree either).
32
+ *
33
+ * A tab standing in the CHECKOUT is the case this exists for: its directory
34
+ * is not `sessions/<id>` and never will be, so a sweep that assumed the
35
+ * default would measure a directory that does not exist and report nothing —
36
+ * which is exactly why "I still do not see a preview URL" was true of a tab
37
+ * opened in the checkout.
38
+ */
39
+ const sessionPlaces = new Map();
40
+ const placeOf = (sessionId) => sessionPlaces.get(sessionId) ?? sessionId;
41
+ /**
42
+ * Places straight off the roster, so a tab is measured the moment it EXISTS
43
+ * rather than after somebody types into it.
44
+ *
45
+ * Learning only from turn jobs meant a tab nobody had spoken to yet was
46
+ * measured at `sessions/<id>` — a directory that does not exist for a tab
47
+ * working in the checkout — so it reported no branch, no diffstat and no
48
+ * ports, and every control reading those had nothing to render.
49
+ *
50
+ * The roster is authoritative and turn jobs still agree with it; a session
51
+ * the server does not name keeps whatever a turn taught, and failing that its
52
+ * own id, which is the pre-places default.
53
+ */
54
+ /**
55
+ * WHERE EACH TAB WORKS — and it has to be able to UNLEARN.
56
+ *
57
+ * This only ever set. Combined with a server that omitted null places, a tab
58
+ * moved from the checkout back to its own worktree simply stopped being
59
+ * mentioned, and this map kept the old value forever. `placeDir` decides
60
+ * where a turn is SPAWNED and where the worktree is measured, so the browser
61
+ * said "its own worktree" while the CLI went on working in the checkout, and
62
+ * the tab reported the checkout's listeners as its own. That is the exact
63
+ * confusion the whole places feature exists to prevent.
64
+ *
65
+ * An explicit `null` now means "its own worktree" and DELETES the entry.
66
+ * Absence of the whole map still means "an older server said nothing", which
67
+ * is the only thing absence can safely mean.
68
+ */
69
+ const learnPlaces = (map) => {
70
+ if (!map || typeof map !== 'object') return;
71
+ for (const [sid, place] of Object.entries(map)) {
72
+ // VALIDATE AT THE TRUST BOUNDARY. `placeDir` joins this value straight
73
+ // into `sessions/<place>` and it is the directory a turn is SPAWNED in
74
+ // and a preview port is measured against — the security boundary for the
75
+ // whole preview feature. The server resolves place to an enum (never a
76
+ // path), but a server bug or compromise sending `../../etc` would
77
+ // otherwise point a session's measured directory anywhere on the box and
78
+ // defeat the port attribution. `sessionWorktreeReport` and `placeWtFor`
79
+ // already reject an unsafe segment; checking at the intake covers the
80
+ // consumers that do not (the preview-claim `placeDir` did not re-check).
81
+ // NOT the only writer: `processWorkTurns` stores a turn job's place too,
82
+ // and keeps the same check — validating one intake and not the other is
83
+ // one deploy away from validating neither. REPO_PLACE resolves to
84
+ // repoRoot, so it is allowed through despite not being a path segment.
85
+ if (typeof place === 'string' && place && (place === REPO_PLACE || isSafePathSegment(place)))
86
+ sessionPlaces.set(sid, place);
87
+ // null / '' / an unsafe value: the server is telling us this tab is in its
88
+ // OWN worktree (or is malformed). Falling back to the default requires
89
+ // forgetting, not ignoring.
90
+ else sessionPlaces.delete(sid);
91
+ }
92
+ };
93
+ /** The DIRECTORY a session works in. Every path that used to build
94
+ * `sessions/<id>` by hand goes through here, or a tab in the checkout gets
95
+ * measured against a directory that does not exist. */
96
+ const placeDir = (sessionId) => {
97
+ const place = placeOf(sessionId);
98
+ return place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
99
+ };
100
+
101
+ /**
102
+ * This tab's worktree — its held context, expressed as a place, ON A BRANCH.
103
+ *
104
+ * Fresh: branch `session/<id>` off the current base. Existing: touched not at
105
+ * all — no fetch-reset-clean like a plan directory, because the dirty state
106
+ * is the point. If the directory was retired but the branch survives, the
107
+ * worktree re-attaches to the branch and the committed work is still there.
108
+ */
109
+ /**
110
+ * WHERE A SESSION WORKS — its PLACE, which is a directory on a branch.
111
+ *
112
+ * A session used to BE a worktree: one tab, one directory, cut at birth and
113
+ * retired at close. That binding was never an isolation guarantee — a turn
114
+ * runs with permissions skipped, so the worktree is a starting directory and
115
+ * not a fence, and any agent could always `cd` into another one. The product
116
+ * was asserting an invariant it did not have.
117
+ *
118
+ * So a session now REFERENCES a place rather than being one. Many sessions
119
+ * may name the same place; a session may name the repo checkout itself; and
120
+ * `session/<own-id>` is simply the DEFAULT place, cut fresh at first turn,
121
+ * which is why an absent `place` behaves exactly as every existing tab does.
122
+ *
123
+ * `'repo'` IS NOT A DIRECTORY NAME AND MUST NOT BECOME ONE. It resolves to
124
+ * the checkout the daemon already serves — never created, never retired,
125
+ * because it is not ours to remove. The value reaching here is a server-side
126
+ * enum, never a browser-supplied path: `sessions.routes.ts` resolves it the
127
+ * same way adoption resolves a cwd, and for the same reason.
128
+ */
129
+ const placeWtFor = (placeId, baseAt) => {
130
+ if (placeId === REPO_PLACE) {
131
+ // The checkout. `fresh: false` on purpose — nothing was opened, so no
132
+ // caller may treat this as a newly-cut branch.
133
+ return { wt: repoRoot, fresh: false };
134
+ }
135
+ if (!isSafePathSegment(placeId)) return null;
136
+ const sessionId = placeId;
137
+ const wt = join(baseDir, 'sessions', sessionId);
138
+ const fresh = !existsSync(wt);
139
+ if (fresh) {
140
+ const branch = `session/${sessionId}`;
141
+ // `baseAt` is the adoption override: a tab born from a terminal session
142
+ // branches from THAT checkout's HEAD, because the conversation being
143
+ // resumed was had against those commits — putting it on the project base
144
+ // would hand it a repo state it has never seen. Everything else is
145
+ // unchanged, the attach fallback included: a surviving branch already
146
+ // chose its base, and re-basing it here would move committed work.
147
+ const at = baseAt || baseRef();
148
+ try {
149
+ git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
150
+ } catch {
151
+ git(['worktree', 'prune'], repoRoot);
152
+ // A directory and a branch just stopped existing. The Repository block
153
+ // would otherwise keep listing both until its own 60s scan came round.
154
+ onRepoChanged();
155
+ try {
156
+ // The branch may already exist (a retired directory's work) — attach.
157
+ git(['worktree', 'add', wt, branch], repoRoot);
158
+ } catch {
159
+ try {
160
+ git(['worktree', 'add', '-b', branch, wt, at], repoRoot);
161
+ } catch (e) {
162
+ // Kept for the caller to SAY: an agent turn that cannot get a
163
+ // worktree used to end "nothing" with no words at all.
164
+ placeWtFor.lastError = hasCommits(repoRoot)
165
+ ? `git could not create the worktree from ${at}: ${gitFailure(e)}`
166
+ : `the repository ${repoRoot} has no commits yet, so there is nothing to branch from. Make a first commit there, then send the agent any message to try again.`;
167
+ return null;
168
+ }
169
+ }
170
+ }
171
+ // NOTHING IS MATERIALIZED INTO A FRESH WORKTREE ANY MORE (2026-09-21),
172
+ // and nothing replaced it.
173
+ //
174
+ // Two blocks stood here: one that wrote the decrypted vault bundle into a
175
+ // newly-created worktree, and a second that retried on the next turn for
176
+ // the restart window where the bundle had not warmed yet. The vault is
177
+ // deleted — the owner: "no i dont want it" — so Flowviant holds no secret
178
+ // for this project and has nothing to write.
179
+ //
180
+ // WHAT THE SOURCE OF A WORKTREE'S ENV IS NOW: the repo's own `.env`
181
+ // files, wherever the repo puts them. A worktree branches from the
182
+ // checkout, and a gitignored `.env` is by definition not in a fresh one —
183
+ // which is the ordinary behaviour of `git worktree add`, the same thing a
184
+ // person gets typing it themselves, and the operator's problem to solve
185
+ // the way they already solve it (a symlink, a `direnv`, a copy in a
186
+ // setup script). This product no longer claims otherwise, and the claim
187
+ // is what was worth deleting: a promise that every session tab comes up
188
+ // with the team's secrets is only kept while a vault exists to keep it.
189
+ //
190
+ // The CLI child still inherits the daemon's own environment exactly as it
191
+ // always did (runTurn.mjs spawns with `{...process.env}`), so anything
192
+ // exported in the shell the daemon was started from is present here.
193
+ }
194
+ return { wt, fresh };
195
+ };
196
+
197
+ /**
198
+ * A file in the worktree's PRIVATE git dir (…/.git/worktrees/<name>). It
199
+ * travels with the worktree, dies with `git worktree remove`, and is
200
+ * invisible to `git status` — so nothing stored here can ever make the
201
+ * session look dirty (a dirty tree refuses ships). A marker file in the
202
+ * working tree itself would show up as an untracked path and block every
203
+ * ship of an otherwise-clean session.
204
+ */
205
+ /**
206
+ * A file beside the worktree's git dir, holding something about ONE TAB.
207
+ *
208
+ * `scope` is the session id and is NOT optional for anything per-tab. These
209
+ * markers were named bare — `flowviant-codex-thread`, `flowviant-agy-
210
+ * conversation` — which was unambiguous while one directory meant one tab.
211
+ * The day tabs moved into their driver's project folder, every tab there
212
+ * started reading and writing ONE marker: tab B would resume tab A's codex
213
+ * thread, and the last turn to finish would overwrite the id for both.
214
+ *
215
+ * The turn LOCK is deliberately still un-scoped — it guards the directory
216
+ * against a second CLI, which is a property of the place and not of a tab.
217
+ */
218
+ const sessionMetaPath = (wt, name, scope) => {
219
+ try {
220
+ const safe = scope && /^[A-Za-z0-9_-]{1,64}$/.test(String(scope)) ? `-${scope}` : '';
221
+ return join(git(['rev-parse', '--absolute-git-dir'], wt), `${name}${safe}`);
222
+ } catch {
223
+ return null;
224
+ }
225
+ };
226
+
227
+ return { sessionPlaces, placeOf, learnPlaces, placeDir, placeWtFor, sessionMetaPath };
228
+ }