flowviant 0.102.2 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,120 @@
1
+ /**
2
+ * TWO ROSTER JOB LANES THAT ACT ON THIS CHECKOUT AND SETTLE BACK: patch
3
+ * reverts and task-restart cleanups.
4
+ *
5
+ * Split out of fleet.mjs (SOLID audit 2026-09-26, F038). Each lane dedups its
6
+ * in-flight job ids, does its work fire-and-forget, and ALWAYS reports the job
7
+ * done — the roster re-serves a job until it is reported, so a lane that could
8
+ * wedge on a failure would redo it every poll. The loop in fleet.mjs only
9
+ * hands each lane its roster array.
10
+ *
11
+ * Factories rather than module state because each lane is bound to the
12
+ * checkout this daemon serves (and cleanup to the live base ref, read at call
13
+ * time — the roster can move it mid-run).
14
+ */
15
+
16
+ import { execFileSync } from 'node:child_process';
17
+ import { FLEET_URL } from './config.mjs';
18
+ import { gitNetAsync, originSlug, isValidPrUrl, isValidBranch } from './git.mjs';
19
+ import { c, note, ok, warn } from './ui.mjs';
20
+ import { revertPatch, withPatchLock } from './patchRevert.mjs';
21
+ import { fleetEndpoint } from './fleetWire.mjs';
22
+ import { postToFleet } from './fleetPost.mjs';
23
+
24
+ export function createPatchRevertLane({ repoRoot }) {
25
+ // Patch reverts: a patch landed straight in this checkout, and a human took it
26
+ // back. The commits are HERE, not on the server, so the reverse-apply happens
27
+ // here too — a revert, never a reset, because the owner has almost certainly
28
+ // worked on top by now. Serialised through the same lock as applies.
29
+ const PATCH_REVERT_DONE_URL = fleetEndpoint('patch-revert-done', FLEET_URL);
30
+ const reverting = new Set();
31
+ const processPatchRevertJobs = (jobs) => {
32
+ for (const job of jobs ?? []) {
33
+ if (!job || typeof job.id !== 'string' || !Array.isArray(job.shas)) continue;
34
+ if (reverting.has(job.id)) continue;
35
+ reverting.add(job.id);
36
+ (async () => {
37
+ try {
38
+ note(`${c.cyan('revert')} ${c.dim(`— ${job.title}`)}`);
39
+ const res = await withPatchLock(() =>
40
+ Promise.resolve(revertPatch({ repoRoot, shas: job.shas }))
41
+ );
42
+ if (res.ok) ok(`${c.dim('reverted')} ${job.title}`);
43
+ else warn(`revert failed for "${job.title}": ${res.error}`);
44
+ // ALWAYS report, success or not. Without this the flag stays set, the
45
+ // roster re-serves the job every poll, and each pass reverts the
46
+ // revert — the change flapping in and out of the owner's tree forever.
47
+ await postToFleet(PATCH_REVERT_DONE_URL, {
48
+ taskId: job.id,
49
+ ok: res.ok,
50
+ error: res.ok ? undefined : String(res.error ?? 'revert failed'),
51
+ });
52
+ } finally {
53
+ reverting.delete(job.id);
54
+ }
55
+ })();
56
+ }
57
+ };
58
+ return processPatchRevertJobs;
59
+ }
60
+
61
+ export function createCleanupLane({ repoRoot, getBaseRef }) {
62
+ // Cleanup jobs (task restarts): close the abandoned PR + delete its remote
63
+ // branch on the user's own gh, so a restart doesn't litter the repo.
64
+ const CLEANUP_DONE_URL = fleetEndpoint('cleanup-done', FLEET_URL);
65
+ const cleaning = new Set();
66
+ const processCleanupJobs = (jobs) => {
67
+ for (const job of jobs ?? []) {
68
+ if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
69
+ if (cleaning.has(job.id)) continue;
70
+ cleaning.add(job.id);
71
+ (async () => {
72
+ try {
73
+ note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
74
+ // Same guards as merge: only close a PR in THIS repo, only delete a
75
+ // well-formed non-base branch. A bad server must not close a stranger's
76
+ // PR or delete `main` (`--delete` with `main`) via a cleanup job.
77
+ if (job.prUrl && isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
78
+ try {
79
+ execFileSync(
80
+ 'gh',
81
+ [
82
+ 'pr',
83
+ 'close',
84
+ job.prUrl,
85
+ '--comment',
86
+ 'Task restarted in Flowviant — this attempt was discarded.',
87
+ '--delete-branch',
88
+ ],
89
+ { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
90
+ );
91
+ } catch (e) {
92
+ // Already closed/merged/missing = fine; anything else we still
93
+ // report done — a restart must never wedge on stale remotes.
94
+ const err = e.stderr?.toString?.() || e.message || '';
95
+ warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
96
+ }
97
+ } else if (job.branch && isValidBranch(job.branch, repoRoot, getBaseRef())) {
98
+ try {
99
+ // Explicit refspec form so a leading '-' can't be a git flag.
100
+ // NETWORK call, so timed and non-interactive (2026-09-24, the
101
+ // audit): a bare execFileSync here had no timeout and could
102
+ // prompt on /dev/tty, freezing every poll and lease on the
103
+ // machine behind this cleanup's own async loop.
104
+ await gitNetAsync(['push', 'origin', `:refs/heads/${job.branch}`], repoRoot);
105
+ } catch {
106
+ /* branch already gone — fine */
107
+ }
108
+ } else if (job.prUrl || job.branch) {
109
+ warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
110
+ }
111
+ await postToFleet(CLEANUP_DONE_URL, { taskId: job.id });
112
+ ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
113
+ } finally {
114
+ cleaning.delete(job.id);
115
+ }
116
+ })();
117
+ }
118
+ };
119
+ return processCleanupJobs;
120
+ }
@@ -0,0 +1,40 @@
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`).
5
+ *
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.
12
+ *
13
+ * A DAEMON→SERVER REPORT, so no floor: the report's presence is the
14
+ * capability, and a lane that gets `false` back retries on its own beat.
15
+ */
16
+
17
+ import { FLEET_TOKEN, USER_AGENT } from './config.mjs';
18
+
19
+ /** Returns whether the server actually accepted it. Callers that spend a
20
+ * Claude turn per attempt need to know: swallowing the failure silently made
21
+ * 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) {
24
+ 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;
36
+ } catch {
37
+ /* best-effort — the job reappears next poll if this failed */
38
+ return false;
39
+ }
40
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * WHAT THIS MACHINE REPORTS ABOUT ITSELF, off the roster's beat — terminal
3
+ * sessions, the repo's branches and worktrees (with the CLI's credential
4
+ * context), the checkout's env by name, repo tool readiness, and the machine
5
+ * telemetry snapshot.
6
+ *
7
+ * Split out of fleet.mjs (SOLID audit 2026-09-26, F038). Each lane is its own
8
+ * throttle, its own dedup against the last ACCEPTED payload, and its own
9
+ * "the server refused for good — quiet until restart" flag; all of that is
10
+ * module state, and none of it is the reconcile loop's business. The loop
11
+ * fires each one and forgets it (`void`), which is the one contract they all
12
+ * keep: a readout must never throw into, or delay, the poll.
13
+ *
14
+ * Every lane is a DAEMON→SERVER report, so none needs a floor: an older server
15
+ * 404s once and the lane goes quiet; an older daemon simply never posts.
16
+ */
17
+
18
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
19
+ import { fleetEndpoint } from './fleetWire.mjs';
20
+ import { postToFleet } from './fleetPost.mjs';
21
+ import { myPubB64 } from './boxIdentity.mjs';
22
+ import { scanEnvForScrub } from './env.mjs';
23
+ import { machineSnapshot } from './resources.mjs';
24
+ import { detectRuntimes } from './runtimeDetection.mjs';
25
+ import { scanLocalSessions } from './localSessions.mjs';
26
+ import { repoState } from './repoState.mjs';
27
+ import { claudeAuthContext } from './claudeAuth.mjs';
28
+ import { readBaseTools, toolReadout } from './projectTools.mjs';
29
+ import { runnerToolCapabilities } from './projectToolRuntimes.mjs';
30
+
31
+ /**
32
+ * Terminal-session presence: tell the server which Claude sessions exist in
33
+ * this repo (localSessions.mjs reads them off Claude's own on-disk state), so
34
+ * the Workbench can offer "adopt this terminal session as a tab". Best-effort
35
+ * in exactly the way the env/runtimes blocks are — a presence report that can
36
+ * fail a poll is worse than no presence at all — with three quiet economies:
37
+ * the scan runs at most once a minute (the reconcile loop ticks far faster), a
38
+ * report identical to the last DELIVERED one is not re-sent, and a 404 means
39
+ * an older server that has never heard of the endpoint, after which this
40
+ * process stops asking (a deploy that adds it also restarts nothing on this
41
+ * machine, so silence-until-restart costs one daemon restart, not a feature).
42
+ */
43
+ const LOCAL_SESSIONS_URL = fleetEndpoint('local-sessions', FLEET_URL);
44
+ const LOCAL_SESSIONS_SCAN_MS = 60_000;
45
+ // The web hides a report older than 10 minutes (presence must not linger as
46
+ // fact after the machine dies), so an UNCHANGED report is re-sent inside that
47
+ // window anyway — the re-send is the machine's heartbeat on this fact, and
48
+ // suppressing it entirely would blank the strip while everything still holds.
49
+ const LOCAL_SESSIONS_RESEND_MS = 5 * 60_000;
50
+ let localSessionsUnsupported = false; // the server 404'd — quiet until restart
51
+ let localSessionsScanAt = 0;
52
+ let localSessionsSent = null; // last payload the server ACCEPTED, stringified
53
+ let localSessionsSentAt = 0;
54
+ export async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
55
+ if (localSessionsUnsupported) return;
56
+ if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
57
+ localSessionsScanAt = Date.now();
58
+ let payload;
59
+ try {
60
+ // scanLocalSessions orders deterministically, so this string only changes
61
+ // when the facts on disk do — the dedup below compares whole payloads.
62
+ payload = JSON.stringify({
63
+ sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }),
64
+ });
65
+ } catch {
66
+ return; // presence must never throw into the poll loop
67
+ }
68
+ if (payload === localSessionsSent && Date.now() - localSessionsSentAt < LOCAL_SESSIONS_RESEND_MS)
69
+ return;
70
+ try {
71
+ const res = await fetch(LOCAL_SESSIONS_URL, {
72
+ method: 'POST',
73
+ headers: {
74
+ Authorization: `Bearer ${FLEET_TOKEN}`,
75
+ 'User-Agent': USER_AGENT,
76
+ 'Content-Type': 'application/json',
77
+ },
78
+ signal: AbortSignal.timeout(15_000),
79
+ body: payload,
80
+ });
81
+ if (res.status === 404) {
82
+ localSessionsUnsupported = true; // older server — it REPLACED nothing here
83
+ return;
84
+ }
85
+ // Only an accepted report counts as sent; anything else forgets the
86
+ // last-sent payload so the next pass retries instead of dedup-suppressing
87
+ // a report the server never received.
88
+ localSessionsSent = res.ok ? payload : null;
89
+ localSessionsSentAt = res.ok ? Date.now() : 0;
90
+ } catch {
91
+ localSessionsSent = null;
92
+ localSessionsSentAt = 0;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * EVERY BRANCH AND WORKTREE ON THIS MACHINE, pushed on the same beat as
98
+ * presence — the answer to "is my Claude leaving a mess in here?".
99
+ *
100
+ * Same three economies as the presence report above and for the same reasons:
101
+ * scanned at most once a minute, not re-sent while identical (repoState orders
102
+ * deterministically so the string only moves when the repo does), and silent
103
+ * for the rest of the process once an older server 404s.
104
+ *
105
+ * ONE DIFFERENCE, deliberate: there is no re-send heartbeat window. Presence
106
+ * expires in the UI because a session that ENDED must stop reading as live;
107
+ * a branch list is not presence — a branch that existed a minute ago still
108
+ * exists — so re-posting an unchanged list would be a write per machine per
109
+ * five minutes to say nothing at all.
110
+ */
111
+ const REPO_STATE_URL = fleetEndpoint('repo-state', FLEET_URL);
112
+ const REPO_STATE_SCAN_MS = 60_000;
113
+ let repoStateUnsupported = false; // the server 404'd — quiet until restart
114
+ let repoStateScanAt = 0;
115
+ let repoStateSent = null; // last payload the server ACCEPTED, stringified
116
+ export async function maybeReportRepoState({ repoRoot, baseRef }) {
117
+ if (repoStateUnsupported) return;
118
+ if (Date.now() - repoStateScanAt < REPO_STATE_SCAN_MS) return;
119
+ repoStateScanAt = Date.now();
120
+ let payload;
121
+ try {
122
+ // The PARAM, and nothing else: this function sits at MODULE scope, where
123
+ // runFleetDaemon's `getBaseRef` closure does not exist. An earlier version
124
+ // reached for it anyway, the ReferenceError landed in the catch below —
125
+ // written for an unreadable repo, silent by design — and this endpoint was
126
+ // never once posted to. The caller reads the loop's live `baseRef` at call
127
+ // time, so the value here is always current.
128
+ const state = repoState(repoRoot, baseRef);
129
+ if (!state) return; // not readable — say nothing rather than say "none"
130
+ /**
131
+ * WHICH CREDENTIAL THIS MACHINE'S CLI RESOLVES, on the same beat.
132
+ *
133
+ * It rides this report rather than getting an endpoint of its own because
134
+ * it is the same KIND of fact — something only the machine can see, pushed
135
+ * because a pull client can never be asked — and it inherits this
136
+ * function's three economies for free: scanned once a minute, deduped
137
+ * against the last ACCEPTED payload, and silent forever once an older
138
+ * server 404s.
139
+ *
140
+ * The dedup keeps it cheap: `claudeAuthContext` is presence plus two dates,
141
+ * so the string is stable across scans and only moves when something about
142
+ * the credential actually moves. It DOES move when the CLI refreshes its
143
+ * access token — that stamp is carried for diagnostics — which costs a
144
+ * write roughly twice a day and is the whole of the extra traffic. The
145
+ * WARNING is built on the 22-day refresh clock instead, so a note never
146
+ * fires on the ~12h cycle.
147
+ *
148
+ * NO VERSION FLOOR, and none is possible to need: this is a daemon→server
149
+ * report, and an older SERVER strips the unknown key in its zod parse and
150
+ * stores the rest exactly as before.
151
+ */
152
+ payload = JSON.stringify({ ...state, auth: claudeAuthContext() });
153
+ } catch {
154
+ return; // a readout must never throw into the poll loop
155
+ }
156
+ if (payload === repoStateSent) return;
157
+ try {
158
+ const res = await fetch(REPO_STATE_URL, {
159
+ method: 'POST',
160
+ headers: {
161
+ Authorization: `Bearer ${FLEET_TOKEN}`,
162
+ 'User-Agent': USER_AGENT,
163
+ 'Content-Type': 'application/json',
164
+ },
165
+ signal: AbortSignal.timeout(15_000),
166
+ body: payload,
167
+ });
168
+ if (res.status === 404) {
169
+ repoStateUnsupported = true; // older server — nothing was replaced here
170
+ return;
171
+ }
172
+ // Only an ACCEPTED report counts: anything else forgets it so the next
173
+ // pass retries rather than dedup-suppressing a report nobody received.
174
+ repoStateSent = res.ok ? payload : null;
175
+ } catch {
176
+ repoStateSent = null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * WHAT IS IN THIS BOX'S ENVIRONMENT, BY NAME (2026-09-21) — the one readout the
182
+ * deleted secrets vault was genuinely wanted for.
183
+ *
184
+ * The owner, on the vault: *"no i dont want it. unless its needed where i want
185
+ * to show the env of each of the machines (for comparison)."* So the custody
186
+ * went and the comparison stayed, rebuilt as a plain report: for each `.env*`
187
+ * file in the CHECKOUT ROOT, the variable NAMES and an 8-hex VALUE FINGERPRINT.
188
+ * **The value never leaves the box.** Two machines holding the same fingerprint
189
+ * for `DATABASE_URL` agree; two different fingerprints is the whole answer to
190
+ * "why does it work over there", and neither requires Flowviant to hold a
191
+ * secret, seal a key, or own a recovery code.
192
+ *
193
+ * ONE POST PER BOX PER CHANGE. The report is hashed and compared against the
194
+ * last one the server ACCEPTED, so a box whose env is stable posts exactly once
195
+ * per daemon and then never again — the same economy `maybeReportRepoState`
196
+ * keeps next door, and for the same reason: an env file is not PRESENCE, so
197
+ * re-posting an unchanged list would be a write per machine per minute to say
198
+ * nothing.
199
+ *
200
+ * A PERMANENT 4xx IS TREATED AS DELIVERED, which is the `/fleet/agent-trace`
201
+ * precedent stated there: an older SERVER 404s every batch, and a daemon that
202
+ * held them would keep re-posting a body nobody will ever read. Which statuses
203
+ * count as permanent is `envReportIsPermanent`, and it is a SHORT list for the
204
+ * reason stated there; everything else forgets the dedup so the next beat tries
205
+ * again.
206
+ *
207
+ * THE TOTALS RIDE BESIDE THE CAPPED LIST (2026-09-21, the review). The report
208
+ * is bounded at eight files and two hundred variables, and a list silently cut
209
+ * at a cap reads as the whole directory — so `filesTotal` and `varsTotal` (the
210
+ * numbers BEFORE the caps) go on the wire beside it and the app can say "N more
211
+ * not shown". Same rule `repoState` already keeps for branches and worktrees.
212
+ * Both are optional on the server, so an older server parsing only `files` is
213
+ * unaffected and needs no floor.
214
+ *
215
+ * NO VERSION FLOOR, and none is possible to need: it is a daemon→server report
216
+ * on a NEW endpoint, so an older server answers 404 once and is never asked
217
+ * again, and an older daemon simply never posts. The report's presence IS the
218
+ * capability.
219
+ *
220
+ * IT ALSO FEEDS THE SCRUBBER, on every scan and whether or not anything is
221
+ * posted (`scanEnvForScrub`). That is the half that must not be skipped: with
222
+ * the vault gone these files are what `scrub()` redacts out of turn streams,
223
+ * tool events, traces and deploy logs, and the dedup above is about the WIRE,
224
+ * never about what this box knows to hide.
225
+ */
226
+ /**
227
+ * WHICH REFUSAL IS FINAL — pure, exported, and deliberately a SHORT list
228
+ * (2026-09-21, the review).
229
+ *
230
+ * The first cut treated EVERY 4xx as permanent, which quietly turned one bad
231
+ * minute into a dead lane for the life of the process. A 429 is a rate limit
232
+ * and says "later", not "never". A 401 or 403 during a credential blip — a
233
+ * rotation, a clock skew, a roster the box is momentarily not the holder of —
234
+ * is transient by construction. A 408 is a timeout wearing a 4xx. Every one of
235
+ * those stopped env reporting until somebody restarted the daemon, with NO
236
+ * sign anywhere that it had stopped: the report is deduped and silent by
237
+ * design, so "posted once and never again" is indistinguishable from "working
238
+ * normally on a box whose env has not changed". A lane that can die invisibly
239
+ * must not die for a reason that will pass.
240
+ *
241
+ * So permanent means exactly three things, and each of them is a fact about
242
+ * the REQUEST rather than about the moment: 404, the route does not exist (an
243
+ * older server — the `/fleet/agent-trace` precedent this lane is built on);
244
+ * 400 and 422, the server will never accept this shape. Retrying any of those
245
+ * forever is the daemon arguing with a decision already made.
246
+ *
247
+ * Pure and exported so the decision can be proved without a credential, a
248
+ * server or a poll — the same shape `shouldStop` and `createHolderWatch` keep
249
+ * in holder.mjs.
250
+ */
251
+ export function envReportIsPermanent(status) {
252
+ return status === 400 || status === 404 || status === 422;
253
+ }
254
+
255
+ const ENV_REPORT_URL = fleetEndpoint('env-report', FLEET_URL);
256
+ const ENV_REPORT_SCAN_MS = 60_000;
257
+ let envReportUnsupported = false; // the server refused permanently — quiet until restart
258
+ let envReportScanAt = 0;
259
+ let envReportSent = null; // the last report the server ACCEPTED, stringified
260
+ /**
261
+ * Returns a one-word VERDICT — 'throttled' | 'scan-failed' | 'quiet' |
262
+ * 'deduped' | 'accepted' | 'retry' | 'stopped'. The daemon ignores it (the
263
+ * caller is a bare `void`); it exists so the lane's decisions can be driven
264
+ * against a real HTTP server in a test rather than pinned as source text. A
265
+ * readout whose only proof is that its source LOOKS right is the inert-pin
266
+ * class this repo has caught five times.
267
+ */
268
+ export async function maybeReportEnv(repoRoot) {
269
+ if (Date.now() - envReportScanAt < ENV_REPORT_SCAN_MS) return 'throttled';
270
+ envReportScanAt = Date.now();
271
+ let report;
272
+ try {
273
+ // The SCAN happens even when the POST cannot — see the docblock: the
274
+ // scrubber has no dedup and must reflect the newest read every time.
275
+ report = scanEnvForScrub(repoRoot);
276
+ } catch {
277
+ return 'scan-failed'; // a readout must never throw into the poll loop
278
+ }
279
+ if (envReportUnsupported) return 'quiet';
280
+ const payload = JSON.stringify({
281
+ pubkey: myPubB64(),
282
+ files: report.files,
283
+ filesTotal: report.filesTotal,
284
+ varsTotal: report.varsTotal,
285
+ });
286
+ if (payload === envReportSent) return 'deduped';
287
+ try {
288
+ const res = await fetch(ENV_REPORT_URL, {
289
+ method: 'POST',
290
+ headers: {
291
+ Authorization: `Bearer ${FLEET_TOKEN}`,
292
+ 'User-Agent': USER_AGENT,
293
+ 'Content-Type': 'application/json',
294
+ },
295
+ signal: AbortSignal.timeout(15_000),
296
+ body: payload,
297
+ });
298
+ // Only a refusal that cannot change stops the lane — see
299
+ // `envReportIsPermanent`. A 429 or a 401 during a credential blip is a
300
+ // "later", and treating it as a "never" killed env reporting for the life
301
+ // of the process with nothing anywhere saying so.
302
+ if (envReportIsPermanent(res.status)) {
303
+ envReportUnsupported = true;
304
+ return 'stopped';
305
+ }
306
+ // Only an ACCEPTED report counts, the rule `maybeReportRepoState` keeps:
307
+ // anything else forgets it so the next pass retries rather than
308
+ // dedup-suppressing a report nobody received.
309
+ envReportSent = res.ok ? payload : null;
310
+ return res.ok ? 'accepted' : 'retry';
311
+ } catch {
312
+ envReportSent = null;
313
+ return 'retry';
314
+ }
315
+ }
316
+
317
+ const TOOLS_REPORT_URL = fleetEndpoint('tools-report', FLEET_URL);
318
+ let toolsReportSent = null;
319
+ let toolsReportUnsupported = false;
320
+ let toolsReportAt = 0;
321
+ /** Repo tool readiness is a per-box daemon report. Never include env values. */
322
+ export async function maybeReportTools(repoRoot, baseRef) {
323
+ if (Date.now() - toolsReportAt < 60_000) return;
324
+ toolsReportAt = Date.now();
325
+ if (toolsReportUnsupported) return;
326
+ const pubkey = myPubB64();
327
+ if (!pubkey) return;
328
+ let payload;
329
+ try {
330
+ const snapshot = readBaseTools(repoRoot, baseRef);
331
+ const installed = detectRuntimes().filter((r) => r.installed);
332
+ const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
333
+ payload = JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
334
+ } catch { return; }
335
+ if (payload === toolsReportSent) return;
336
+ try {
337
+ const res = await fetch(TOOLS_REPORT_URL, {
338
+ method: 'POST',
339
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json' },
340
+ signal: AbortSignal.timeout(15_000), body: payload,
341
+ });
342
+ if (envReportIsPermanent(res.status)) toolsReportUnsupported = true;
343
+ toolsReportSent = res.ok ? payload : null;
344
+ } catch { toolsReportSent = null; }
345
+ }
346
+
347
+ /**
348
+ * "THE REPO JUST CHANGED — look again." Clears the repo-state throttle so the
349
+ * next reconcile rescans on the beat it already runs; the work manager calls
350
+ * it (through fleet.mjs's `onRepoChanged`) after a ship, a retirement or a cut.
351
+ */
352
+ export function repoStateChanged() {
353
+ repoStateScanAt = 0;
354
+ }
355
+
356
+ const MACHINE_URL = fleetEndpoint('machine', FLEET_URL);
357
+
358
+ /**
359
+ * Machine telemetry — what the box is doing with itself, for the admin view.
360
+ * `liveTurns` is the work manager's live registry, passed in by the loop.
361
+ */
362
+ export function reportMachine({ worktreeDir, liveTurns }) {
363
+ // Tell the app what this machine is doing with itself. Every reconcile,
364
+ // best-effort, and never awaited — telemetry that can delay a dispatch is
365
+ // worse than no telemetry.
366
+ //
367
+ // The one thing Flowviant could never answer about a task was "why is it
368
+ // slow", because the box was somebody's laptop and only they could look at
369
+ // it. Centralising is supposed to make one machine easier to manage than N
370
+ // laptops; that is only true if the machine is visible. Per-task RSS is the
371
+ // load-bearing part — "the box is full" is not actionable, "this task is
372
+ // holding 9GB" is.
373
+ //
374
+ // …AND IT HAD NEVER BEEN POPULATED ONCE (fixed 2026-09-14). This read the
375
+ // dispatch-era `workers` map, which nothing has `.set()` since the lane was
376
+ // deleted on 2026-08-19 — so the list was permanently empty, the server
377
+ // stored an empty array every poll, and the column its own handler calls
378
+ // the load-bearing half of this report was blank on every machine that has
379
+ // ever run. The same shape as the env rotation two blocks up, which
380
+ // iterated the same dead map and reached no worktree at all.
381
+ //
382
+ // `liveTurns()` is the live registry: every CLI child this daemon is
383
+ // holding, with the session or agent id it serves. Bounded inside the
384
+ // snapshot, because each row costs a /proc tree walk.
385
+ void postToFleet(
386
+ MACHINE_URL,
387
+ machineSnapshot({
388
+ worktreeDir,
389
+ tasks: liveTurns().map((t) => ({ intentId: t.id, pid: t.pid })),
390
+ })
391
+ );
392
+ }