flowviant 0.116.0 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -12,14 +12,15 @@
12
12
  */
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT, MACHINE_HOST } from './config.mjs';
15
- import { gitNetAsync, isSafePathSegment } from './git.mjs';
15
+ import { gitNetAsync } from './git.mjs';
16
16
  import { measureListeners, listenersSupported } from './listeners.mjs';
17
17
  import { processesSupported } from './processes.mjs';
18
18
  import { titleForSession } from './claudeSessions.mjs';
19
+ import { RESUME_MARKERS } from './conversationIds.mjs';
19
20
  import { worktreeDiff } from './worktreeDiff.mjs';
20
21
  import { myPubB64 } from './boxIdentity.mjs';
21
22
  import { fleetEndpoint } from './fleetWire.mjs';
22
- import { REPO_PLACE } from './workPlaces.mjs';
23
+ import { isValidPlace } from './workPlaces.mjs';
23
24
 
24
25
  export function createWorkPlaceReports({
25
26
  repoRoot,
@@ -94,7 +95,9 @@ export function createWorkPlaceReports({
94
95
  // The session's PLACE, not its name: a tab in the checkout is measured in
95
96
  // the checkout, and a tab sharing another tab's worktree is measured there.
96
97
  const place = placeOf(sessionId);
97
- if (place !== REPO_PLACE && !isSafePathSegment(place)) return null;
98
+ // The one validity rule (`isValidPlace`, workPlaces.mjs, audit 2026-10-10
99
+ // F141) — not a third spelling of it.
100
+ if (!isValidPlace(place)) return null;
98
101
  const wt = placeDir(sessionId);
99
102
  const d = worktreeDiff(wt, baseRef());
100
103
  if (!d) return null;
@@ -138,7 +141,7 @@ export function createWorkPlaceReports({
138
141
  // exactly as it found it.
139
142
  let title = null;
140
143
  try {
141
- const marker = sessionMetaPath(wt, 'flowviant-claude-session', sessionId);
144
+ const marker = sessionMetaPath(wt, RESUME_MARKERS.claude, sessionId);
142
145
  if (marker) title = titleForSession(wt, readFileSync(marker, 'utf8').trim());
143
146
  } catch {
144
147
  /* no marker yet — this tab has not spoken, or is not Claude */
@@ -7,18 +7,62 @@
7
7
  * one of these four questions, and they have to agree: a tab in the checkout
8
8
  * measured against `sessions/<id>` reports nothing, and a marker written
9
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).
10
+ * answer. The trust boundary (`isValidPlace`) lives here with the map it
11
+ * guards, and `notePlace` is the map's ONLY writer: the roster's intake and
12
+ * the session lane's turn intake both go through it (audit 2026-10-10, F141),
13
+ * so a lane that learns a place cannot store one unvalidated.
13
14
  */
14
- import { existsSync } from 'node:fs';
15
+ import { existsSync, statSync } from 'node:fs';
15
16
  import { join } from 'node:path';
16
- import { git, isSafePathSegment, hasCommits, gitFailure } from './git.mjs';
17
+ import { git, isSafePathSegment, hasCommits, gitFailure, sessionBranch } from './git.mjs';
17
18
 
18
19
  /** 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
+ * server's REPO_PLACE — it is a wire value, not a local convention. The
21
+ * release parity gate holds it there (2026-10-10, audit F163). */
20
22
  export const REPO_PLACE = 'repo';
21
23
 
24
+ /** A PERSON's place starts with this — the server's `userPlace(userId)` is
25
+ * `u-<userId>` — which is how the worktree sweep tells a person's manual copy
26
+ * (fast-forwarded) from an agent's or a session's (never touched). A wire
27
+ * value too, held to `userPlace` by the release parity gate (2026-10-10,
28
+ * audit F163). */
29
+ export const PERSON_PLACE_PREFIX = 'u-';
30
+
31
+ /**
32
+ * A PLACE THIS MACHINE WILL RESOLVE: the checkout, or a safe path segment.
33
+ *
34
+ * The value becomes the directory a turn is SPAWNED in and a preview port is
35
+ * measured against — the security boundary for the whole preview feature. The
36
+ * server resolves place to an enum (never a path), but a server bug or
37
+ * compromise sending `../../etc` would otherwise point a session's measured
38
+ * directory anywhere on the box. REPO_PLACE resolves to repoRoot, so it is
39
+ * allowed through despite not being a path segment.
40
+ */
41
+ export function isValidPlace(place) {
42
+ return typeof place === 'string' && (place === REPO_PLACE || isSafePathSegment(place));
43
+ }
44
+
45
+ /**
46
+ * A PLACE'S OWN WORKTREE DIRECTORY — `sessions/<place>` under the worktrees
47
+ * root — or null for an id that is not a safe path segment. Pure.
48
+ *
49
+ * No REPO_PLACE arm, on purpose: the agent lanes hold an AGENT's place, which
50
+ * is never the checkout, so a stray `'repo'` there keeps naming a directory
51
+ * that does not exist rather than becoming the operator's checkout.
52
+ */
53
+ export function worktreePath(baseDir, place) {
54
+ return isSafePathSegment(place) ? join(baseDir, 'sessions', place) : null;
55
+ }
56
+
57
+ /**
58
+ * THE DIRECTORY A PLACE RESOLVES TO: the checkout for REPO_PLACE, its own
59
+ * worktree otherwise, null for an unsafe id. Pure — `placeDir` is this over
60
+ * the session map, and nothing else spells the rule.
61
+ */
62
+ export function placePath({ repoRoot, baseDir }, place) {
63
+ return place === REPO_PLACE ? repoRoot : worktreePath(baseDir, place);
64
+ }
65
+
22
66
  export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged }) {
23
67
  /**
24
68
  * WHERE EACH SESSION WORKS, learned from the turns we are handed.
@@ -66,36 +110,59 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
66
110
  * Absence of the whole map still means "an older server said nothing", which
67
111
  * is the only thing absence can safely mean.
68
112
  */
113
+ /**
114
+ * THE MAP'S ONE WRITER — validate, then store; false (and nothing stored)
115
+ * for a place this machine refuses.
116
+ *
117
+ * VALIDATED AT THE TRUST BOUNDARY (`isValidPlace` says why). There were two
118
+ * writers — the roster's intake below and the session lane's turn intake —
119
+ * each spelling the check, with a comment keeping them in step by hand:
120
+ * validating one intake and not the other is one deploy away from
121
+ * validating neither. Now there is one door, and the map is not handed out.
122
+ */
123
+ const notePlace = (sessionId, place) => {
124
+ if (!isValidPlace(place)) return false;
125
+ sessionPlaces.set(sessionId, place);
126
+ return true;
127
+ };
69
128
  const learnPlaces = (map) => {
70
129
  if (!map || typeof map !== 'object') return;
71
130
  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);
131
+ // `sessionWorktreeReport` and `placeWtFor` already reject an unsafe
132
+ // segment; checking at the intake covers the consumers that do not (the
133
+ // preview-claim `placeDir` did not re-check).
87
134
  // null / '' / an unsafe value: the server is telling us this tab is in its
88
135
  // OWN worktree (or is malformed). Falling back to the default requires
89
136
  // forgetting, not ignoring.
90
- else sessionPlaces.delete(sid);
137
+ if (!notePlace(sid, place)) sessionPlaces.delete(sid);
91
138
  }
92
139
  };
140
+ /** The directory a PLACE resolves to, on this machine's roots. */
141
+ const dirOfPlace = (place) => placePath({ repoRoot, baseDir }, place);
93
142
  /** The DIRECTORY a session works in. Every path that used to build
94
143
  * `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);
144
+ * measured against a directory that does not exist. Null for a session id
145
+ * that is not a safe segment and has learnt no place. */
146
+ const placeDir = (sessionId) => dirOfPlace(placeOf(sessionId));
147
+
148
+ /**
149
+ * THE BRANCH STANDING IN `wt` — what is actually checked out, not what we
150
+ * named it at birth — as `{ branch }`, or `{ detached: true }` when HEAD
151
+ * names no branch. A directory that is gone (a retired or closed tab) cannot
152
+ * be asked, so `fallback` is the answer: the one case where the recorded
153
+ * name is the only thing there is.
154
+ *
155
+ * Moved verbatim out of ship and the PR lane (audit 2026-10-10, F141), which
156
+ * spelled it twice so that "which branch would ship" and "which branch would
157
+ * open a PR" agreed only by hand.
158
+ */
159
+ const branchAt = (wt, fallback) => {
160
+ if (!existsSync(wt)) return { branch: fallback };
161
+ try {
162
+ return { branch: git(['symbolic-ref', '--short', 'HEAD'], wt) };
163
+ } catch {
164
+ return { detached: true };
165
+ }
99
166
  };
100
167
 
101
168
  /**
@@ -132,12 +199,11 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
132
199
  // caller may treat this as a newly-cut branch.
133
200
  return { wt: repoRoot, fresh: false };
134
201
  }
135
- if (!isSafePathSegment(placeId)) return null;
136
- const sessionId = placeId;
137
- const wt = join(baseDir, 'sessions', sessionId);
202
+ const wt = worktreePath(baseDir, placeId);
203
+ if (!wt) return null;
138
204
  const fresh = !existsSync(wt);
139
205
  if (fresh) {
140
- const branch = `session/${sessionId}`;
206
+ const branch = sessionBranch(placeId);
141
207
  // `baseAt` is the adoption override: a tab born from a terminal session
142
208
  // branches from THAT checkout's HEAD, because the conversation being
143
209
  // resumed was had against those commits — putting it on the project base
@@ -224,5 +290,21 @@ export function createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged })
224
290
  }
225
291
  };
226
292
 
227
- return { sessionPlaces, placeOf, learnPlaces, placeDir, placeWtFor, sessionMetaPath };
293
+ /**
294
+ * WHEN `git worktree add` CUT THIS WORKTREE: its private git dir's
295
+ * `commondir`, written once at creation and left alone by commits, lock,
296
+ * repair and move. A recreated worktree is a new birth. Null for the
297
+ * checkout (no such file) and for anything unreadable — read by the
298
+ * artifact reporter, which then has no evidence a place held older rows.
299
+ */
300
+ const worktreeBornAt = (wt) => {
301
+ const path = sessionMetaPath(wt, 'commondir');
302
+ try {
303
+ return path ? statSync(path).mtimeMs : null;
304
+ } catch {
305
+ return null;
306
+ }
307
+ };
308
+
309
+ return { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt };
228
310
  }
@@ -1,10 +1,11 @@
1
1
  /** Session preview claims, tunnels, and retirement. */
2
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
2
+ import { FLEET_URL } from './config.mjs';
3
3
  import { isSafePathSegment } from './git.mjs';
4
4
  import { originFor as measuredOrigin } from './listeners.mjs';
5
5
  import { openTunnel as openLiveTunnel } from './preview.mjs';
6
6
  import { note } from './ui.mjs';
7
7
  import { fleetEndpoint } from './fleetWire.mjs';
8
+ import { claimFleetLease, settleLease } from './fleetPost.mjs';
8
9
 
9
10
  /** `originFor` and `openTunnel` are injectable only so the lane can be driven
10
11
  * without a dev server or cloudflared (workPreviews.test.mjs); work.mjs passes
@@ -19,7 +20,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
19
20
  // generated password, on a quick tunnel. This daemon never starts an app: the
20
21
  // deleted live-preview feature ran a repo-declared command through a shell,
21
22
  // and that is the reason it is deleted. Here the human runs their own server,
22
- // `listenersIn` notices it, and this only ever wraps a port that measurement
23
+ // `measureListeners` notices it, and this only ever wraps a port that measurement
23
24
  // already named for that session.
24
25
  //
25
26
  // CLAIM BEFORE ACTING. Two daemons legitimately share one fleet credential —
@@ -35,47 +36,26 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
35
36
  // Returns the parsed JSON (or null on any failure) rather than discarding
36
37
  // it — an OPEN settle's caller needs to see `data.settled === false`, the
37
38
  // sharper share-id check on the server's side of a re-share racing the
38
- // open's own round trip (see the openTunnel call site below).
39
- const postPreview = async (body) => {
40
- try {
41
- const res = await fetch(PREVIEW_DONE_URL, {
42
- method: 'POST',
43
- headers: {
44
- Authorization: `Bearer ${FLEET_TOKEN}`,
45
- 'User-Agent': USER_AGENT,
46
- 'Content-Type': 'application/json',
47
- },
48
- signal: AbortSignal.timeout(30_000),
49
- body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
50
- });
51
- return await res.json().catch(() => null);
52
- } catch {
53
- /* the row stops being confirmed and reads as ended — which is true */
54
- return null;
55
- }
56
- };
39
+ // open's own round trip (see the openTunnel call site below). On a failure
40
+ // the row stops being confirmed and reads as ended — which is true.
41
+ const postPreview = (body) => settleLease(PREVIEW_DONE_URL, body, { timeoutMs: 30_000 });
57
42
 
58
43
  // `shareId` is the share the daemon was OFFERED, echoed off the job. A
59
44
  // re-share inside the claim's round trip rotates the id server-side, so a
60
45
  // claim naming the old one matches nothing and this daemon opens no tunnel
61
46
  // for a request that no longer exists. Absent-safe: an older server that
62
47
  // does not read the field just claims on the place alone, the old rule.
63
- const claimPreview = async (sessionId, shareId) => {
48
+ // Could not claim → do nothing at all. The other daemon may have.
49
+ const claimPreview = (sessionId, shareId) =>
50
+ claimFleetLease(PREVIEW_CLAIM_URL, { sessionId, ...(shareId ? { shareId } : {}) });
51
+
52
+ /** Stop a tunnel (a live entry or an open's result) without letting its
53
+ * teardown throw into the lane: the stop is best-effort everywhere. */
54
+ const stopQuietly = (live) => {
64
55
  try {
65
- const res = await fetch(PREVIEW_CLAIM_URL, {
66
- method: 'POST',
67
- headers: {
68
- Authorization: `Bearer ${FLEET_TOKEN}`,
69
- 'User-Agent': USER_AGENT,
70
- 'Content-Type': 'application/json',
71
- },
72
- signal: AbortSignal.timeout(15_000),
73
- body: JSON.stringify({ sessionId, instance: DAEMON_INSTANCE, ...(shareId ? { shareId } : {}) }),
74
- });
75
- const j = await res.json().catch(() => null);
76
- return j?.data?.claimed === true;
56
+ live?.stop();
77
57
  } catch {
78
- return false; // could not claim → do nothing at all. The other daemon may have.
58
+ /* best-effort */
79
59
  }
80
60
  };
81
61
 
@@ -85,13 +65,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
85
65
  const stopPreview = async (sessionId, reason) => {
86
66
  const live = livePreviews.get(sessionId);
87
67
  livePreviews.delete(sessionId);
88
- if (live) {
89
- try {
90
- live.stop();
91
- } catch {
92
- /* best-effort */
93
- }
94
- }
68
+ stopQuietly(live);
95
69
  // Confirm only a teardown we actually PERFORMED. The stop job is a
96
70
  // broadcast — every daemon on the credential gets it — and the one holding
97
71
  // nothing used to answer instantly, flipping the row to 'ended' so the
@@ -115,6 +89,15 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
115
89
  });
116
90
  };
117
91
 
92
+ /** One teardown per session at a time: a session mid-claim (an open or
93
+ * another stop on its way) is left to that claim, and the next tick asks
94
+ * again. The stop job and a closed tab share this guard. */
95
+ const stopOnce = (sessionId, reason) => {
96
+ if (previewClaiming.has(sessionId)) return;
97
+ previewClaiming.add(sessionId);
98
+ void stopPreview(sessionId, reason).finally(() => previewClaiming.delete(sessionId));
99
+ };
100
+
118
101
  const processPreviewJobs = (jobs) => {
119
102
  if (!Array.isArray(jobs) || jobs.length === 0) return;
120
103
  for (const job of jobs.slice(0, 5)) {
@@ -123,9 +106,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
123
106
  if (!isSafePathSegment(sessionId)) continue;
124
107
 
125
108
  if (job?.action === 'stop') {
126
- if (previewClaiming.has(sessionId)) continue;
127
- previewClaiming.add(sessionId);
128
- void stopPreview(sessionId, 'stopped').finally(() => previewClaiming.delete(sessionId));
109
+ stopOnce(sessionId, 'stopped');
129
110
  continue;
130
111
  }
131
112
 
@@ -171,6 +152,13 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
171
152
  if (previewClaiming.has(sessionId)) continue;
172
153
  previewClaiming.add(sessionId);
173
154
 
155
+ // EVERY report about this job's share names the share (F017,
156
+ // 2026-09-26): an un-scoped `ended` or `error` could end a replacement
157
+ // share opened on the same session in the meantime. One spelling, so a
158
+ // new teardown callback cannot forget it (audit 2026-10-10, F162).
159
+ // Absent when the job carried none (an older server), the old rule.
160
+ const settleShare = (body) => postPreview({ sessionId, ...body, ...(shareId ? { shareId } : {}) });
161
+
174
162
  void (async () => {
175
163
  try {
176
164
  if (!(await claimPreview(sessionId, shareId))) return; // somebody else has it
@@ -188,21 +176,13 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
188
176
  // machine never measured. A wire string, not a field — no floor.
189
177
  const refusal = originFor(wt, port).error;
190
178
  if (refusal) {
191
- await postPreview({
192
- sessionId,
193
- error: refusal,
194
- ...(shareId ? { shareId } : {}),
195
- });
179
+ await settleShare({ error: refusal });
196
180
  return;
197
181
  }
198
182
  // Replace anything this session already had — one tab, one door.
199
183
  const prev = livePreviews.get(sessionId);
200
184
  if (prev) {
201
- try {
202
- prev.stop();
203
- } catch {
204
- /* best-effort */
205
- }
185
+ stopQuietly(prev);
206
186
  livePreviews.delete(sessionId);
207
187
  }
208
188
  const t = await openTunnel({
@@ -218,12 +198,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
218
198
  // without this the app would print "live" over a 502.
219
199
  onDead: () => {
220
200
  livePreviews.delete(sessionId);
221
- void postPreview({
222
- sessionId,
223
- ended: true,
224
- endedReason: 'origin_gone',
225
- ...(shareId ? { shareId } : {}),
226
- });
201
+ void settleShare({ ended: true, endedReason: 'origin_gone' });
227
202
  },
228
203
  // ATTRIBUTION rides the probe, not just the open: a freed default
229
204
  // port (5173…) rebound by any other process on the box would keep
@@ -236,38 +211,27 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
236
211
  // owner can re-share the port without restarting the daemon.
237
212
  onAbuse: () => {
238
213
  livePreviews.delete(sessionId);
239
- void postPreview({
240
- sessionId,
241
- ended: true,
242
- endedReason: 'abuse',
243
- ...(shareId ? { shareId } : {}),
244
- });
214
+ void settleShare({ ended: true, endedReason: 'abuse' });
245
215
  },
246
216
  // cloudflared died AFTER publishing (quick tunnels get dropped).
247
217
  // Without this the daemon kept heartbeating a hostname that 530s.
248
218
  onTunnelGone: () => {
249
219
  livePreviews.delete(sessionId);
250
- void postPreview({
251
- sessionId,
252
- error: 'the tunnel dropped — share it again to reopen.',
253
- ...(shareId ? { shareId } : {}),
254
- });
220
+ void settleShare({ error: 'the tunnel dropped — share it again to reopen.' });
255
221
  },
256
222
  });
257
223
  if (t.error) {
258
- await postPreview({ sessionId, error: t.error, ...(shareId ? { shareId } : {}) });
224
+ await settleShare({ error: t.error });
259
225
  return;
260
226
  }
261
227
  livePreviews.set(sessionId, { port, shareId, url: t.url, stop: t.stop });
262
228
  // The gate we ACTUALLY installed, so the app never asserts a door
263
229
  // nobody observed. An older server ignores the field.
264
- const settled = await postPreview({
265
- sessionId,
230
+ const settled = await settleShare({
266
231
  url: t.url,
267
232
  user: t.user,
268
233
  password: t.password,
269
234
  gate: t.gateMode,
270
- ...(shareId ? { shareId } : {}),
271
235
  });
272
236
  // A re-share (or a Stop) can land inside this open's own round
273
237
  // trip and rotate the share id server-side — the settle is then
@@ -279,11 +243,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
279
243
  // settle for an older one).
280
244
  if (settled?.data?.settled === false) {
281
245
  if (livePreviews.get(sessionId)?.stop === t.stop) livePreviews.delete(sessionId);
282
- try {
283
- t.stop();
284
- } catch {
285
- /* best-effort */
286
- }
246
+ stopQuietly(t);
287
247
  }
288
248
  } finally {
289
249
  previewClaiming.delete(sessionId);
@@ -292,26 +252,6 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
292
252
  }
293
253
  };
294
254
 
295
- /** The sessionIds this machine is still serving — sent on the poll so the
296
- * server can tell a live share from one whose machine went away. Silence
297
- * must never read as "live". */
298
- /**
299
- * The daemon's own shape check on an argv the server parsed.
300
- *
301
- * Deliberately a SHAPE check and not a re-parse: the server owns the policy
302
- * (which argv[0] are allowed, the install refusal, the length caps) and the
303
- * machine owns the refusal to EXECUTE something malformed. It is duplicated
304
- * rather than imported because this package ships standalone and cannot
305
- * depend on the monorepo — the mirror is small, and `devCommand.ts` is where
306
- * the real rules live.
307
- */
308
- const isPlausibleDevArgv = (argv) =>
309
- Array.isArray(argv) &&
310
- argv.length > 0 &&
311
- argv.length <= 8 &&
312
- argv.every((a) => typeof a === 'string' && a.length > 0 && a.length <= 200) &&
313
- !argv.some((a) => /[&|;<>`$(){}*?~\\]/.test(a));
314
-
315
255
  // ── DEV RUNS ARE DELETED (2026-08-26) ─────────────────────────────────
316
256
  //
317
257
  // The machine no longer starts application processes. It never learned to
@@ -321,7 +261,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
321
261
  // per ecosystem forever.
322
262
  //
323
263
  // Nothing downstream is lost, because SHARING NEVER DEPENDED ON US STARTING
324
- // IT. `listenersIn` attributes a listening socket to a place by the cwd of
264
+ // IT. `measureListeners` attributes a listening socket to a place by the cwd of
325
265
  // the process holding it, so a server the driver's own agent started in the
326
266
  // tab is measured exactly like one this file used to spawn. The web renders
327
267
  // that measured list and a person picks which port to share.
@@ -331,6 +271,9 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
331
271
  // and its adopt-across-re-exec dance. If supervision is ever wanted back it
332
272
  // returns as "supervise this process", never as "run dev".
333
273
 
274
+ /** The sessionIds this machine is still serving — sent on the poll so the
275
+ * server can tell a live share from one whose machine went away. Silence
276
+ * must never read as "live". */
334
277
  const livePreviewIds = () => [...livePreviews.keys()];
335
278
 
336
279
  /**
@@ -349,9 +292,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
349
292
  const live = new Set(activeIds);
350
293
  for (const sessionId of [...livePreviews.keys()]) {
351
294
  if (live.has(sessionId)) continue;
352
- if (previewClaiming.has(sessionId)) continue;
353
- previewClaiming.add(sessionId);
354
- void stopPreview(sessionId, 'tab_closed').finally(() => previewClaiming.delete(sessionId));
295
+ stopOnce(sessionId, 'tab_closed');
355
296
  }
356
297
  };
357
298
 
@@ -359,13 +300,7 @@ export function createWorkPreviews({ placeDir, originFor = measuredOrigin, openT
359
300
  * them would strand a public hostname until the box rebooted — the exact
360
301
  * case `reapOrphanPreviews` exists to clean up after an UNgraceful death. */
361
302
  const shutdownPreviews = () => {
362
- for (const [, live] of livePreviews) {
363
- try {
364
- live.stop();
365
- } catch {
366
- /* best-effort */
367
- }
368
- }
303
+ for (const [, live] of livePreviews) stopQuietly(live);
369
304
  livePreviews.clear();
370
305
  };
371
306
 
@@ -1,10 +1,11 @@
1
1
  /** Claims and execution for stopping a measured session process. */
2
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
2
+ import { FLEET_URL } from './config.mjs';
3
3
  import { isSafePathSegment } from './git.mjs';
4
4
  import { measureListeners } from './listeners.mjs';
5
5
  import { measureProcesses, liveGroups, processesSupported } from './processes.mjs';
6
6
  import { processAlive } from './procRegistry.mjs';
7
7
  import { fleetEndpoint } from './fleetWire.mjs';
8
+ import { claimFleetLease, settleLease } from './fleetPost.mjs';
8
9
 
9
10
  export function createWorkProcesses({ placeDir, reportPlaceWorktrees, sessionGroups }) {
10
11
  const KILL_DONE_URL = fleetEndpoint('kill-done', FLEET_URL);
@@ -47,41 +48,13 @@ export function createWorkProcesses({ placeDir, reportPlaceWorktrees, sessionGro
47
48
  // is the failure this whole comment is about.
48
49
  const killing = new Set(); // job ids in flight on this tick
49
50
 
50
- const postKill = async (body) => {
51
- try {
52
- await fetch(KILL_DONE_URL, {
53
- method: 'POST',
54
- headers: {
55
- Authorization: `Bearer ${FLEET_TOKEN}`,
56
- 'User-Agent': USER_AGENT,
57
- 'Content-Type': 'application/json',
58
- },
59
- signal: AbortSignal.timeout(30_000),
60
- body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
61
- });
62
- } catch {
63
- /* unsettled, and the server expires it — the asker is told, never spun */
64
- }
65
- };
51
+ // Unsettled on any failure, and the server expires it — the asker is told,
52
+ // never spun.
53
+ const postKill = (body) => settleLease(KILL_DONE_URL, body, { timeoutMs: 30_000 });
66
54
 
67
- const claimKill = async (id) => {
68
- try {
69
- const res = await fetch(KILL_CLAIM_URL, {
70
- method: 'POST',
71
- headers: {
72
- Authorization: `Bearer ${FLEET_TOKEN}`,
73
- 'User-Agent': USER_AGENT,
74
- 'Content-Type': 'application/json',
75
- },
76
- signal: AbortSignal.timeout(15_000),
77
- body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
78
- });
79
- const j = await res.json().catch(() => null);
80
- return j?.data?.claimed === true;
81
- } catch {
82
- return false; // the peer may hold it; doing nothing is the safe answer
83
- }
84
- };
55
+ // Could not claim → do nothing: the peer may hold it (fleetPost.mjs's
56
+ // `claimFleetLease`, the one double-run guard).
57
+ const claimKill = (id) => claimFleetLease(KILL_CLAIM_URL, { id });
85
58
 
86
59
  /**
87
60
  * Is this pid, RIGHT NOW, one of the things we told the browser about for