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
@@ -8,12 +8,11 @@ import { FLEET_URL, MACHINE_HOST, USER_AGENT, VERSION } from './config.mjs';
8
8
  import { listStoredProjects } from './credentials.mjs';
9
9
  import { readStoredPubB64 } from './boxIdentity.mjs';
10
10
  import { daemonRunningFor, drainingFor } from './instance.mjs';
11
- import { runningViaNpx } from './launchCommand.mjs';
11
+ import { installChannel } from './launchCommand.mjs';
12
12
  import { boxesUrlFrom, classifyMachineResponse, fetchBoxesFor } from './machines.mjs';
13
13
  import { daemonLockPid, daemonLogPath, readDaemonState } from './daemonState.mjs';
14
14
  import { detectRuntimes } from './runtimeDetection.mjs';
15
15
  import { readClaudeAuthStatus } from './runtimeAuth.mjs';
16
- import { runningCompiledBinary } from './update.mjs';
17
16
  import { fleetEndpoint } from './fleetWire.mjs';
18
17
 
19
18
  /**
@@ -40,9 +39,8 @@ export function busyOf(liveState, now = Date.now()) {
40
39
  return liveState.busy;
41
40
  }
42
41
 
43
- export function installChannel() {
44
- return runningCompiledBinary() ? 'binary' : runningViaNpx() ? 'npx' : 'npm-g';
45
- }
42
+ // One home for the channel (launchCommand.mjs); re-exported for views.mjs.
43
+ export { installChannel };
46
44
 
47
45
  export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now(), authFor = readClaudeAuthStatus, refreshAuth = false } = {}) {
48
46
  const runtimeRows = runtimes.map((rt) => ({
package/bin/lib/env.mjs CHANGED
@@ -92,7 +92,18 @@ import { learnSecrets } from './uplinkScrub.mjs';
92
92
  * monorepo's shared types. The daemon holds its own line for the same reason
93
93
  * every other boundary in this file does — a report that the server will
94
94
  * reject is a report nobody sees, and clamping here is what makes the
95
- * difference visible as a smaller list rather than a 400.
95
+ * difference visible as a smaller list.
96
+ *
97
+ * ── THE RELEASE GATE HOLDS THEM (2026-10-10, audit F093) ──
98
+ *
99
+ * `ENV_REPORT_BOUNDS` and `ENV_NAME_RE` are exported for
100
+ * `scripts/check-app-parity.mjs`, which compares them with the shared
101
+ * `MACHINE_ENV_MAX_FILES` / `_MAX_VARS` / `_MAX_NAME` / `_FP_LENGTH` and
102
+ * `machineEnvVarSchema`'s name pattern. Drift would be silent: POST
103
+ * /fleet/env-report answers a body its schema refuses with 200 `stored:false`
104
+ * (and its reader drops a bad file alone), so this lane would see `ok`, dedup,
105
+ * and the comparison sheet would simply stop hearing from this box. `vars` is
106
+ * a TOTAL on both ends (the server's reader spends one budget across files).
96
107
  *
97
108
  * ── THE REPORT'S CAPS ARE NOT THE SCRUBBER'S, AND THAT ASYMMETRY IS
98
109
  * DELIBERATE (2026-09-21, the review) ──
@@ -112,23 +123,41 @@ import { learnSecrets } from './uplinkScrub.mjs';
112
123
  * actually read, so the totals are honest about a directory somebody has let
113
124
  * grow without this scan walking an unbounded list of files on the poll's beat.
114
125
  */
115
- const MAX_ENV_FILES = 8;
116
- const MAX_ENV_VARS = 200; // TOTAL across every file, not per file — REPORT only
117
- const MAX_ENV_NAME_CHARS = 64;
126
+ export const ENV_REPORT_BOUNDS = Object.freeze({ files: 8, vars: 200, name: 64, fp: 8 });
127
+ const MAX_ENV_FILES = ENV_REPORT_BOUNDS.files;
128
+ const MAX_ENV_VARS = ENV_REPORT_BOUNDS.vars; // TOTAL across every file, not per file — REPORT only
129
+ const MAX_ENV_NAME_CHARS = ENV_REPORT_BOUNDS.name;
118
130
  const MAX_ENV_FILE_BYTES = 256 * 1024;
119
131
  const MAX_SCAN_FILES = 64;
120
132
  const MAX_SCRUB_VALUES = 2000;
121
- const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
133
+ export const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
122
134
 
123
135
  /** `.env`, `.env.local`, `.env.production` … and never a template. A file whose
124
136
  * name ends `.example` / `.sample` / `.template` is somebody's DOCUMENTATION
125
137
  * of the shape, committed on purpose, and its "values" are placeholders — so
126
138
  * it would fingerprint `your-key-here` on every box and report them as
127
139
  * agreeing, which is the exact opposite of what a comparison is for. */
128
- const isEnvFileName = (name) =>
140
+ export const isEnvFileName = (name) =>
129
141
  (name === '.env' || name.startsWith('.env.')) &&
130
142
  !/\.(example|sample|template)$/i.test(name);
131
143
 
144
+ /**
145
+ * THE OTHER RULE FOR "AN ENV FILE", AND WHY THE TWO DIFFER (2026-10-10, the
146
+ * audit's F085; it moved here from deployCheckout.mjs so both live in one
147
+ * place). `isEnvFileName` decides what the COMPARISON REPORT reads at the
148
+ * checkout root: `.env*` minus templates, because a template's placeholders
149
+ * would fingerprint as agreement. This one decides what a DEPLOY COPIES into
150
+ * its throwaway worktree, at every tracked directory three levels deep: every
151
+ * `.env*` and `.dev.vars` a build might load, templates included, because a
152
+ * build that reads one must find it. Copying is about making the build work;
153
+ * reporting is about a screen a person reads. The deploy lane teaches the
154
+ * scrubber the files it actually copied (deployRunner.mjs), so the wider rule
155
+ * never widens what goes unredacted. (vaultArtefacts.mjs keeps a third, wider
156
+ * net on purpose: it looks only for files the deleted vault wrote, which it
157
+ * recognises by their header, never by their name alone.)
158
+ */
159
+ export const isCheckoutEnvFile = (name) => /^(?:\.env(?:\..+)?|\.dev\.vars)$/.test(name);
160
+
132
161
  /**
133
162
  * ONE LINE OF DOTENV. Blank lines and `#` comments are skipped, an optional
134
163
  * `export ` prefix is allowed, the line is split on the FIRST `=` (a value may
@@ -230,6 +259,55 @@ function closingQuoteLine(lines, start, quote) {
230
259
  return lines.length;
231
260
  }
232
261
 
262
+ /**
263
+ * ONE DOTENV FILE'S TEXT → `[{ name, value }]`, the variables in force — pure
264
+ * (2026-10-10, extracted from `scanEnvFiles` so the deploy lane parses the
265
+ * nested files it copies with the SAME rules the report uses).
266
+ *
267
+ * A MAP, because LAST WRITE WINS inside one file — which is how a dotenv
268
+ * loader resolves a repeated key, so the fingerprint reported is the one
269
+ * actually in force. It also makes the totals honest: a file that sets the
270
+ * same key forty times counts once, not forty times.
271
+ */
272
+ export function parseEnvText(text) {
273
+ const byName = new Map();
274
+ const lines = String(text ?? '').split('\n');
275
+ for (let i = 0; i < lines.length; i++) {
276
+ const parsed = parseEnvLine(lines[i]);
277
+ if (!parsed) continue;
278
+ // A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
279
+ // dropped from BOTH halves and the scan steps over its continuation
280
+ // lines, so a PEM body can never be read as variables of its own.
281
+ if (parsed.unterminated) {
282
+ i = closingQuoteLine(lines, i, parsed.unterminated);
283
+ continue;
284
+ }
285
+ // A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
286
+ // is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
287
+ // truncated names as the same row is worse than rendering neither.
288
+ if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
289
+ if (!ENV_NAME_RE.test(parsed.name)) continue;
290
+ if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
291
+ byName.set(parsed.name, parsed.value);
292
+ }
293
+ return [...byName].map(([name, value]) => ({ name, value }));
294
+ }
295
+
296
+ /**
297
+ * The values in ONE env file, for the scrubber — `[]` for anything that is not
298
+ * a regular file under the scan's size cap (the same `lstat` refusal of a
299
+ * symlink, for the same reason; see `scanEnvFiles`). Never throws.
300
+ */
301
+ export function readEnvFileValues(path) {
302
+ try {
303
+ const st = lstatSync(path);
304
+ if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) return [];
305
+ return parseEnvText(readFileSync(path, 'utf8'));
306
+ } catch {
307
+ return [];
308
+ }
309
+ }
310
+
233
311
  /**
234
312
  * A VALUE FINGERPRINT — sha256 over `<projectId>\n<value>`, first 8 hex
235
313
  * characters.
@@ -249,8 +327,22 @@ function closingQuoteLine(lines, start, quote) {
249
327
  * `development`, `production`, `postgres`, `localhost`, `info` — so anybody who
250
328
  * could read a stored report could recover those values with one precomputed
251
329
  * table, and the eight characters that were chosen to be uninformative became
252
- * a lookup key instead. Salting with the project id makes the table worthless
253
- * without knowing which project it is for, and makes it per-project even then.
330
+ * a lookup key instead. Salting with the project id stops ONE table serving
331
+ * every project, and that is all it stops.
332
+ *
333
+ * ── WHAT THE SALT DOES NOT STOP (2026-10-10, audit F094) ──
334
+ *
335
+ * The project id is no secret: every member of the project has it in the URL,
336
+ * and it is stored beside the report. To anyone who knows it, this digest
337
+ * still CONFIRMS A GUESS — hash `postgres`, `changeme` or every six-digit PIN
338
+ * with the id and compare eight hex — so for a weak value it is a per-project
339
+ * dictionary oracle, not "useless for recovering it". The server therefore
340
+ * never serves it back: the app's comparison receives an opaque equality group
341
+ * per (file, name) instead (`envComparisonView` in the app's
342
+ * `machineEnvReports.ts`). Keying this digest with a secret no member holds —
343
+ * an HMAC under the machine credential, with a scheme marker so mixed daemons
344
+ * read as "not comparable" rather than `differs` — is the at-rest half, and it
345
+ * is still an open item, not done here.
254
346
  *
255
347
  * IT COSTS THE FEATURE NOTHING, and that is what makes the trade obvious: the
256
348
  * screen compares the BOXES OF ONE PROJECT. Two projects agreeing on a value is
@@ -263,7 +355,7 @@ function closingQuoteLine(lines, start, quote) {
263
355
  * deliberately SHORT so nobody mistakes it for a commitment to the value.
264
356
  */
265
357
  const fingerprint = (salt, value) =>
266
- createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0, 8);
358
+ createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0, ENV_REPORT_BOUNDS.fp);
267
359
 
268
360
  /**
269
361
  * READ THE CHECKOUT'S `.env*` FILES AND REPORT WHAT IS IN THEM BY NAME.
@@ -353,36 +445,13 @@ export function scanEnvFiles(repoRoot, salt) {
353
445
  // file and contributes nothing, and neither is a SYMLINK: see above.
354
446
  const st = lstatSync(abs);
355
447
  if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) continue;
356
- // A MAP, because LAST WRITE WINS inside one file — which is how a dotenv
357
- // loader resolves a repeated key, so the fingerprint reported is the one
358
- // actually in force. It also makes the totals honest: a file that sets
359
- // the same key forty times counts once, not forty times.
360
- const byName = new Map();
361
- const lines = readFileSync(abs, 'utf8').split('\n');
362
- for (let i = 0; i < lines.length; i++) {
363
- const parsed = parseEnvLine(lines[i]);
364
- if (!parsed) continue;
365
- // A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
366
- // dropped from BOTH halves and the scan steps over its continuation
367
- // lines, so a PEM body can never be read as variables of its own.
368
- if (parsed.unterminated) {
369
- i = closingQuoteLine(lines, i, parsed.unterminated);
370
- continue;
371
- }
372
- // A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
373
- // is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
374
- // truncated names as the same row is worse than rendering neither.
375
- if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
376
- if (!ENV_NAME_RE.test(parsed.name)) continue;
377
- if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
378
- byName.set(parsed.name, parsed.value);
379
- }
380
- out.varsTotal += byName.size;
448
+ const parsed = parseEnvText(readFileSync(abs, 'utf8'));
449
+ out.varsTotal += parsed.length;
381
450
  // Decided BEFORE this file spends any budget, so `budget` reaching zero
382
451
  // inside a file still lets that file's own entry carry what it got.
383
452
  const reporting = canReport && budget > 0 && out.files.length < MAX_ENV_FILES;
384
453
  const vars = [];
385
- for (const [n, value] of byName) {
454
+ for (const { name: n, value } of parsed) {
386
455
  // THE SCRUB LIST IS NOT BOUNDED BY THE REPORT'S CAPS — see the bounds
387
456
  // block above. A secret dropped for being the 201st is a secret in the
388
457
  // server's database.
package/bin/lib/fleet.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  * what order. What a lane DOES lives beside it — the poll's query string
11
11
  * (fleetRoster.mjs), the machine's own reports (fleetReports.mjs), holdership
12
12
  * and the terminal signals (holder.mjs), the wiki runner (wikiRunner.mjs), the
13
- * checkout job lanes (fleetJobs.mjs), the start steps (fleetStartup.mjs), the
14
- * reconcile wait (fleetWake.mjs), and session/agent work (work.mjs).
13
+ * start steps (fleetStartup.mjs), the reconcile wait (fleetWake.mjs), and
14
+ * session/agent work (work.mjs).
15
15
  *
16
16
  * Over the 500-line mark as ONE LIFECYCLE, not as debt: what is left is the
17
17
  * intake order over roster keys, and most of its length is the argument for
@@ -38,7 +38,7 @@ import { emitMachineEvent } from './daemonLogging.mjs';
38
38
  import { writeDaemonState } from './daemonState.mjs';
39
39
  import { repoRootOrDie, detectBaseRef, baseBranchName, excludeInWorktree, usableBaseRef } from './git.mjs';
40
40
  import { c, info, note, ok, warn, fail } from './ui.mjs';
41
- import { sleep } from './claude.mjs';
41
+ import { sleep } from './sleep.mjs';
42
42
  import { reapOrphanPreviews } from './previewRegistry.mjs';
43
43
  import { preflight } from './preflight.mjs';
44
44
  import { connectStream } from './stream.mjs';
@@ -75,7 +75,6 @@ import {
75
75
  } from './fleetReports.mjs';
76
76
  import { createHolderWatch, obeyRosterCommands } from './holder.mjs';
77
77
  import { createWikiRunner } from './wikiRunner.mjs';
78
- import { createCleanupLane, createPatchRevertLane } from './fleetJobs.mjs';
79
78
  import { installSignalHandlers, takeInstanceLockOrExit, tidyWorktreeHome, worktreeHome } from './fleetStartup.mjs';
80
79
  import { createReconcileWait } from './fleetWake.mjs';
81
80
 
@@ -235,7 +234,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
235
234
  // never-squash law — and every server since the pivot sends `mergeJobs: []`
236
235
  // (a wire fossil, `rosterResponse.ts`). Merges run through `prWorkflow.mjs`
237
236
  // (session PR job, agent approve) and `shipMerge.mjs`, and nowhere else.
238
- const processPatchRevertJobs = createPatchRevertLane({ repoRoot });
237
+ // Its two checkout siblings went the same way (audit 2026-10-10, F083): no
238
+ // server sends `patchRevertJobs` (a `git revert` in this checkout) or
239
+ // `cleanupJobs` (`gh pr close --delete-branch`, a remote branch delete), and
240
+ // their settle routes died with dispatch, so nothing here reads either key.
239
241
 
240
242
  // ── Work sessions — the Workbench tabs ─────────────────────────────────────
241
243
  //
@@ -314,8 +316,6 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
314
316
  });
315
317
  workShutdown = shutdownWork; // teardown can now reach the live session CLIs
316
318
 
317
- const processCleanupJobs = createCleanupLane({ repoRoot, getBaseRef });
318
-
319
319
  // Living-wiki work: one cartographer turn at a time, off the agents'
320
320
  // checkouts (wikiRunner.mjs). Wiki work needs no agent online.
321
321
  wiki = createWikiRunner({ repoRoot, baseDir, repoKey, getBaseRef, admit });
@@ -505,7 +505,6 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
505
505
  // taking new work — the skip-if-pending guards make the ordering safe, but
506
506
  // delivering first keeps the tab honest a poll sooner.
507
507
  void flushWorkReports();
508
- processPatchRevertJobs(roster.patchRevertJobs);
509
508
  // WHERE each live tab works, BEFORE anything measures one: the sweep below
510
509
  // and every preview check ask `placeOf`, and a tab nobody has typed into
511
510
  // yet has taught it nothing.
@@ -617,7 +616,6 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
617
616
  // startup on purpose: a daemon that has been up since before this release
618
617
  // gets measured too, without needing a restart to earn its own menu.
619
618
  probeSkillsOnce(repoRoot);
620
- processCleanupJobs(roster.cleanupJobs);
621
619
  // Announce roster membership only when it changes (not every poll).
622
620
  const sig = [...new Set(roster.agents.map((a) => a.agentId))].sort().join(',');
623
621
  if (sig !== rosterSig) {
@@ -1,40 +1,187 @@
1
1
  /**
2
- * ONE DAEMON→SERVER POST THAT ANSWERS ONLY "WAS IT ACCEPTED" — the settle a
3
- * job lane sends when it is done (`patch-revert-done`, `cleanup-done`,
4
- * `reground-done`) and the machine telemetry beat (`/fleet/machine`).
2
+ * THE DAEMON'S MACHINE-CREDENTIAL REQUEST — the headers, the timeout and the
3
+ * body every `/fleet/*` call shares, and the few answers built on it: a settle
4
+ * that says only "was it accepted" (`postToFleet`), the lease claim that stops
5
+ * two daemons acting on one job (`claimFleetLease`), the lease settle that
6
+ * follows it (`settleLease`), and the bounded best-effort settle
7
+ * (`postBestEffort`).
5
8
  *
6
- * Split out of fleet.mjs (SOLID audit 2026-09-26, F038) because four lanes in
7
- * three modules share it — the job lanes (fleetJobs.mjs), the wiki runner
8
- * (wikiRunner.mjs) and the machine report (fleetReports.mjs) — and it was a
9
- * closure inside `runFleetDaemon` that only one of them could reach. It was
10
- * called `reportMergeOutcome` there, a name left over from the deleted
11
- * dispatch-era merge lane; the body is unchanged.
9
+ * Split out of fleet.mjs (SOLID audit 2026-09-26, F038) because lanes in
10
+ * other modules share `postToFleet` — today the wiki runner (wikiRunner.mjs)
11
+ * and the machine report (fleetReports.mjs) — and it was a closure inside
12
+ * `runFleetDaemon` that only one of them could reach. It was called
13
+ * `reportMergeOutcome` there, a name left over from the deleted dispatch-era
14
+ * merge lane. The patch-revert and cleanup lanes that also settled through it
15
+ * were deleted with their dead roster keys (audit 2026-10-10, F083).
12
16
  *
13
- * A DAEMON→SERVER REPORT, so no floor: the report's presence is the
17
+ * ONE REQUEST SKELETON (audit 2026-10-10, F071/F082/F102/F136/F138/F154/F744).
18
+ * The `Authorization: Bearer …` / `User-Agent` / `Content-Type` block and its
19
+ * `AbortSignal.timeout` were hand-built at ~37 sites in 23 files, and the
20
+ * copies had drifted (mcp-cli's had no timeout at all; wikiRunner's abandon
21
+ * post was a near copy of `postToFleet`). The lease claim — the double-run
22
+ * guard — was five byte-identical copies. Each caller still passes its OWN
23
+ * timeout and keeps its OWN status policy; only the request and the claim's
24
+ * winner test moved here.
25
+ *
26
+ * `fetch` is the global, resolved at CALL time inside each function — tests
27
+ * swap `globalThis.fetch` after import, and that must keep working.
28
+ *
29
+ * A DAEMON→SERVER REPORT needs no floor: the report's presence is the
14
30
  * capability, and a lane that gets `false` back retries on its own beat.
15
31
  */
16
32
 
17
- import { FLEET_TOKEN, USER_AGENT } from './config.mjs';
33
+ import { FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
34
+ import { isCredentialRejection } from './authReject.mjs';
35
+ import { isConsideredRefusal, isNotNow, retryAfterMs } from './fleetWire.mjs';
36
+
37
+ /** The machine credential's headers. `token` defaults to the LIVE binding
38
+ * (config.mjs's `let FLEET_TOKEN`, which a project switch re-points), read
39
+ * at call time. `json: false` for a GET or a multipart body. */
40
+ export function fleetHeaders({ json = true, token = FLEET_TOKEN } = {}) {
41
+ return {
42
+ Authorization: `Bearer ${token}`,
43
+ 'User-Agent': USER_AGENT,
44
+ ...(json ? { 'Content-Type': 'application/json' } : {}),
45
+ };
46
+ }
47
+
48
+ /** A body as it goes on the wire: a string is already serialised (the
49
+ * readout lanes dedup on the exact string they post), anything else is
50
+ * JSON-encoded, and `undefined` sends no body. */
51
+ const wireBody = (body) => (body === undefined || typeof body === 'string' ? body : JSON.stringify(body));
52
+
53
+ /**
54
+ * A JSON POST with the machine credential, returning the RESPONSE — for a
55
+ * caller that reads it itself. Rejects on a network error or a timeout
56
+ * exactly as `fetch` does.
57
+ */
58
+ export async function postFleetJson(url, body, { timeoutMs = 30_000, token, signal } = {}) {
59
+ return fetch(url, {
60
+ method: 'POST',
61
+ headers: fleetHeaders({ token }),
62
+ signal: signal ?? AbortSignal.timeout(timeoutMs),
63
+ body: wireBody(body),
64
+ });
65
+ }
66
+
67
+ /**
68
+ * ONE REQUEST, ANSWERED AS A SHAPE — never a throw.
69
+ *
70
+ * { ok, status, json, credentialRejected, retryAfterMs, error }
71
+ *
72
+ * `status` is null (and `error` set) when nothing answered: a network error
73
+ * or the timeout. `json` is the parsed body, or null when it was not JSON.
74
+ * `credentialRejected` is authReject.mjs's one rule — a 401/403 wearing the
75
+ * API's own `{ success: false }` envelope, never the bare status (an edge
76
+ * 403 is somebody else's answer). `retryAfterMs` is the server's Retry-After,
77
+ * capped, or null when it sent none.
78
+ */
79
+ export async function fleetRequest(url, { method = 'POST', body, timeoutMs = 30_000, token, signal } = {}) {
80
+ let res;
81
+ try {
82
+ const sent = wireBody(body);
83
+ res = await fetch(url, {
84
+ method,
85
+ headers: fleetHeaders({ json: sent !== undefined, token }),
86
+ signal: signal ?? AbortSignal.timeout(timeoutMs),
87
+ ...(sent !== undefined ? { body: sent } : {}),
88
+ });
89
+ } catch (error) {
90
+ return { ok: false, status: null, json: null, credentialRejected: false, retryAfterMs: null, error };
91
+ }
92
+ let json = null;
93
+ try {
94
+ json = typeof res.json === 'function' ? await res.json() : null;
95
+ } catch {
96
+ json = null; // not JSON — an edge page, an empty body
97
+ }
98
+ return {
99
+ ok: Boolean(res.ok),
100
+ status: res.status,
101
+ json,
102
+ credentialRejected: isCredentialRejection(res.status, res.headers?.get?.('content-type'), json),
103
+ retryAfterMs: retryAfterMs(res, null),
104
+ error: null,
105
+ };
106
+ }
18
107
 
19
108
  /** Returns whether the server actually accepted it. Callers that spend a
20
109
  * Claude turn per attempt need to know: swallowing the failure silently made
21
110
  * an unreachable endpoint look identical to a settled job, so the turn
22
- * re-ran on every poll. */
23
- export async function postToFleet(url, body) {
111
+ * re-ran on every poll. Never throws — the job reappears next poll if this
112
+ * failed. */
113
+ export async function postToFleet(url, body, { timeoutMs = 30_000 } = {}) {
114
+ return (await fleetRequest(url, { body, timeoutMs })).ok;
115
+ }
116
+
117
+ /**
118
+ * CLAIM A LEASE BEFORE ACTING — the double-run guard (audit 2026-10-10,
119
+ * F138). Two daemons legitimately share one credential and are handed the
120
+ * same job array, so a lane that opens a tunnel, signals a pid, pushes a
121
+ * branch or runs a deploy claims the job first and does NOTHING unless it
122
+ * won. The rules, which were five hand copies:
123
+ * - the body names this PROCESS (`instance: DAEMON_INSTANCE`), never only
124
+ * the credential — a token cannot tell two holders apart;
125
+ * - the winner is read from the BODY (`data.claimed === true`), never
126
+ * inferred from the status;
127
+ * - any failure — a network error, a timeout, a non-JSON answer — is "not
128
+ * claimed": the peer may hold it, and doing nothing is the safe answer.
129
+ * `won` is for a route that answers in another shape (intake's `{ ok: true }`
130
+ * on a 200); it is handed `fleetRequest`'s result.
131
+ */
132
+ export async function claimFleetLease(
133
+ url,
134
+ fields,
135
+ { timeoutMs = 15_000, won = (r) => r.json?.data?.claimed === true } = {}
136
+ ) {
24
137
  try {
25
- const res = await fetch(url, {
26
- method: 'POST',
27
- headers: {
28
- Authorization: `Bearer ${FLEET_TOKEN}`,
29
- 'User-Agent': USER_AGENT,
30
- 'Content-Type': 'application/json',
31
- },
32
- signal: AbortSignal.timeout(30_000),
33
- body: JSON.stringify(body),
34
- });
35
- return res.ok;
138
+ const r = await fleetRequest(url, { body: { ...fields, instance: DAEMON_INSTANCE }, timeoutMs });
139
+ if (r.error) return false;
140
+ return won(r) === true;
36
141
  } catch {
37
- /* best-effort — the job reappears next poll if this failed */
38
142
  return false;
39
143
  }
40
144
  }
145
+
146
+ /**
147
+ * SETTLE A HELD LEASE — the outcome POST that follows a claim, stamped with
148
+ * the same `instance` (only the holder may settle). Returns the parsed reply
149
+ * (a preview open reads `data.settled`), or null when nothing usable came
150
+ * back. Never throws: an unsettled lease expires server-side and the asker
151
+ * is told, never spun.
152
+ */
153
+ export async function settleLease(url, body, { timeoutMs = 30_000 } = {}) {
154
+ const r = await fleetRequest(url, { body: { ...body, instance: DAEMON_INSTANCE }, timeoutMs });
155
+ return r.json ?? null;
156
+ }
157
+
158
+ /**
159
+ * A best-effort settle POST, AWAITED INLINE by a caller with no per-poll
160
+ * queue of its own to retry from — unlike workReportQueue's `postSettle`,
161
+ * whose callers requeue a 'retry'/'reject' outcome themselves. Bounded
162
+ * attempts: 408/429 retry honouring Retry-After, 5xx retries on a short fixed
163
+ * backoff, and any OTHER 4xx is the server's considered answer — retrying it
164
+ * would just spend the same refusal again, so it counts as delivered, the
165
+ * existing trace convention. A network error retries the same way and is
166
+ * then swallowed: unsettled, and the server expires the job so the asker is
167
+ * told, never spun.
168
+ *
169
+ * Moved here from inside `createWorkReportQueue` (audit 2026-10-10, F136): it
170
+ * closed over nothing of the queue's. work.mjs still INJECTS it into the
171
+ * plan, check and intake lanes, so their tests hand in fakes.
172
+ */
173
+ export async function postBestEffort(url, body, { attempts = 4, timeoutMs = 30_000 } = {}) {
174
+ for (let i = 0; i < attempts; i += 1) {
175
+ const r = await fleetRequest(url, { body, timeoutMs });
176
+ if (r.ok) return true;
177
+ if (isConsideredRefusal(r.status)) return true; // the server's considered answer
178
+ const notNow = isNotNow(r.status);
179
+ // Only a network error, 408/429 or a 5xx retries; anything else (a stray
180
+ // 1xx/3xx) was never retried and still is not.
181
+ if (!r.error && !notNow && r.status < 500) return true;
182
+ if (i === attempts - 1) return false;
183
+ const backoff = 2_000 * (i + 1);
184
+ await new Promise((done) => setTimeout(done, notNow ? (r.retryAfterMs ?? backoff) : backoff));
185
+ }
186
+ return false;
187
+ }