flowviant 0.115.0 → 0.117.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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +99 -145
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -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
@@ -8,25 +8,26 @@
8
8
  * shared with the agent approve path.
9
9
  */
10
10
  import { existsSync } from 'node:fs';
11
- import { join } from 'node:path';
12
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
13
- import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
11
+ import { FLEET_URL } from './config.mjs';
12
+ import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment, sessionBranch } from './git.mjs';
14
13
  import {
15
14
  ensureOpenPr,
16
15
  findOpenPr,
17
- ghFirstLine,
18
16
  ghReady,
19
17
  mergeAndVerifyTip,
20
18
  PR_URL_RE,
21
19
  prTargetsOtherBase,
20
+ relayedFailure,
22
21
  } from './prWorkflow.mjs';
23
22
  import { fleetEndpoint } from './fleetWire.mjs';
23
+ import { claimFleetLease, settleLease } from './fleetPost.mjs';
24
24
  import { REPO_PLACE } from './workPlaces.mjs';
25
25
 
26
26
  export function createWorkPullRequests({
27
27
  placeOf,
28
+ placeDir,
29
+ branchAt,
28
30
  repoRoot,
29
- baseDir,
30
31
  baseRef,
31
32
  gitNet,
32
33
  landed,
@@ -43,40 +44,11 @@ export function createWorkPullRequests({
43
44
  // one branch would open two PRs. NOTHING here closes a card — done is
44
45
  // observed by the landed walk when the merge reaches base.
45
46
  const prWorking = new Set();
46
- const claimPr = async (id) => {
47
- try {
48
- const res = await fetch(PR_CLAIM_URL, {
49
- method: 'POST',
50
- headers: {
51
- Authorization: `Bearer ${FLEET_TOKEN}`,
52
- 'User-Agent': USER_AGENT,
53
- 'Content-Type': 'application/json',
54
- },
55
- signal: AbortSignal.timeout(15_000),
56
- body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
57
- });
58
- const j = await res.json().catch(() => null);
59
- return j?.data?.claimed === true;
60
- } catch {
61
- return false; // could not claim → do nothing. The other daemon may have.
62
- }
63
- };
64
- const settlePr = async (body) => {
65
- try {
66
- await fetch(PR_DONE_URL, {
67
- method: 'POST',
68
- headers: {
69
- Authorization: `Bearer ${FLEET_TOKEN}`,
70
- 'User-Agent': USER_AGENT,
71
- 'Content-Type': 'application/json',
72
- },
73
- signal: AbortSignal.timeout(20_000),
74
- body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
75
- });
76
- } catch {
77
- /* the row expires into an honest no_answer; the re-request is the retry */
78
- }
79
- };
47
+ // Could not claim → do nothing. The other daemon may have.
48
+ const claimPr = (id) => claimFleetLease(PR_CLAIM_URL, { id });
49
+ // Twenty seconds, this lane's own ceiling; on any failure the row expires
50
+ // into an honest no_answer and the re-request is the retry.
51
+ const settlePr = (body) => settleLease(PR_DONE_URL, body, { timeoutMs: 20_000 });
80
52
  const runPrJob = async (job) => {
81
53
  const id = String(job.id);
82
54
  if (!(await claimPr(id))) return;
@@ -97,20 +69,17 @@ export function createWorkPullRequests({
97
69
  return;
98
70
  }
99
71
  // The branch is whatever the session's own worktree HEAD says — the same
100
- // resolution ship uses, for the same reason (the branch is where the
101
- // driver left it, not where we put it).
72
+ // resolution ship uses (`branchAt`, workPlaces.mjs), for the same reason
73
+ // (the branch is where the driver left it, not where we put it). A place
74
+ // whose directory is gone falls back to the branch that place was CUT on,
75
+ // `session/<place>` — a person's `u-` place lives on `session/u-<id>`,
76
+ // never on `session/<this tab's id>` (audit 2026-10-10, F168).
77
+ // (The checkout always exists, so its fallback is never read and keeps
78
+ // the tab's own name.)
102
79
  const sessionId = String(job.sessionId);
103
80
  const place = placeOf(sessionId);
104
- const wt = place === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', place);
105
- let branch = `session/${sessionId}`;
106
- let detached = false;
107
- if (existsSync(wt)) {
108
- try {
109
- branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
110
- } catch {
111
- detached = true;
112
- }
113
- }
81
+ const wt = placeDir(sessionId);
82
+ const { branch, detached } = branchAt(wt, sessionBranch(place === REPO_PLACE ? sessionId : place));
114
83
  if (detached) {
115
84
  await settlePr({
116
85
  id,
@@ -146,7 +115,9 @@ export function createWorkPullRequests({
146
115
  try {
147
116
  gitNetIn(['push', '-u', 'origin', branch], pushCwd, 120_000);
148
117
  } catch (e) {
149
- await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
118
+ // SCRUBBED (relayedFailure): this line lands on every delivered card's
119
+ // record and in the tab chat, and a push names the remote URL.
120
+ await settlePr({ id, outcome: 'failed', detail: relayedFailure(e) });
150
121
  return;
151
122
  }
152
123
  const pr = ensureOpenPr(branch, baseName, { cwd: repoRoot });
@@ -167,9 +138,8 @@ export function createWorkPullRequests({
167
138
  // cards over commits that never reached base.
168
139
  // No --delete-branch: the local branch may be a live worktree's HEAD.
169
140
  // THE BASE IS RE-ASKED BEFORE THE MERGE, never trusted from the open step:
170
- // somebody can retarget a PR between Deliver and Approve, and `gh pr merge
171
- // <branch>` merges whichever open PR the branch has, into whatever it
172
- // targets now.
141
+ // somebody can retarget a PR between Deliver and Approve, and the merge
142
+ // acts on whichever open PR the branch has, into whatever it targets now.
173
143
  const wrongBase = otherBase(prTargetsOtherBase(findOpenPr(branch, { cwd: repoRoot }), baseName));
174
144
  if (wrongBase) {
175
145
  await settlePr({ id, outcome: 'failed', detail: wrongBase });
@@ -178,7 +148,7 @@ export function createWorkPullRequests({
178
148
  try {
179
149
  gitNetIn(['push', 'origin', branch], pushCwd, 120_000);
180
150
  } catch (e) {
181
- await settlePr({ id, outcome: 'failed', detail: ghFirstLine(e) });
151
+ await settlePr({ id, outcome: 'failed', detail: relayedFailure(e) });
182
152
  return;
183
153
  }
184
154
  // The tip is the LOCAL branch — the state the reviewer approved and the
@@ -201,6 +171,17 @@ export function createWorkPullRequests({
201
171
  await settlePr({ id, outcome: 'failed', detail: merged.error });
202
172
  return;
203
173
  }
174
+ // GitHub lists no open pull request FROM this branch in this repository,
175
+ // so nothing was merged — never a PR picked by a selector that only looks
176
+ // like this branch (prWorkflow.mjs).
177
+ if (merged.noPr) {
178
+ await settlePr({
179
+ id,
180
+ outcome: 'failed',
181
+ detail: `there is no open pull request from ${branch} in this repository to merge — deliver again to open one`,
182
+ });
183
+ return;
184
+ }
204
185
  if (merged.notOnBase) {
205
186
  await settlePr({
206
187
  id,