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,470 @@
1
+ /**
2
+ * THE ROSTER POLL — what this box tells the server on every beat, and what the
3
+ * first answered poll tells the person at the keyboard.
4
+ *
5
+ * Split out of fleet.mjs (SOLID audit 2026-09-26, F038). `fetchRoster` builds
6
+ * the poll's query string (the box's identity, its CLIs, the admission verdict,
7
+ * the one machine ask) and validates the answer's shape; the loop in fleet.mjs
8
+ * decides what to DO with the answer. They change for different reasons — a
9
+ * new daemon→server param lands here and nowhere else, a new roster key lands
10
+ * in the loop — so they live apart.
11
+ *
12
+ * Every param here is a DAEMON→SERVER report and needs no floor (the report's
13
+ * presence is the capability); absence keeps meaning "an older daemon".
14
+ */
15
+
16
+ import {
17
+ VERSION,
18
+ FLEET_URL,
19
+ FLEET_TOKEN,
20
+ USER_AGENT,
21
+ SAFE,
22
+ DAEMON_INSTANCE,
23
+ MACHINE_HOST,
24
+ PROCESS_STARTED_AT,
25
+ MAX_CONCURRENT,
26
+ } from './config.mjs';
27
+ import { projectLabel, safeName, setStoredProjectName } from './credentials.mjs';
28
+ import { credentialRejected } from './authReject.mjs';
29
+ import { isSafePathSegment } from './git.mjs';
30
+ import { c, note, warn } from './ui.mjs';
31
+ import { envQueryParams, myPubB64 } from './boxIdentity.mjs';
32
+ import { detectRuntimes, runtimesReport } from './runtimeDetection.mjs';
33
+ import { knownMcpServers, knownSkills } from './runtimeCapabilities.mjs';
34
+ import { effectiveMaxTurns } from './admission.mjs';
35
+ import { runtimeOfferings } from './runtimeOfferings.mjs';
36
+
37
+ let warnedEnvIdentity = false;
38
+
39
+ /**
40
+ * HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
41
+ *
42
+ * The owner's ruling on two boxes running one project: "it should kill the
43
+ * first one and take over". So a daemon asks on the FIRST poll of its life and
44
+ * never again, and this pair is the whole of that "never again".
45
+ *
46
+ * ONE ASK PER PROCESS IS THE SAFETY, NOT A POLITENESS. A param that kept asking
47
+ * would be two boxes trading a machine back and forth every ten seconds. Asked
48
+ * once, the story ends at the first exchange, because the box that loses
49
+ * SETTLES ITS TURNS AND EXITS rather than restarting — and a process that has
50
+ * exited sends no more first polls.
51
+ *
52
+ * THAT LAST SENTENCE IS TRUE OF THE PROCESS AND NOT OF THE UNIT, which is why
53
+ * the server stopped relying on it (2026-09-19). Under `Restart=always`, pm2 or
54
+ * docker, exit 0 is a relaunch and the relaunch's first poll is a genuine one.
55
+ * `takeMachineHolderNow` refuses a take by the box it just displaced for the
56
+ * length of the displaced window, so the ping-pong is bounded on the side that
57
+ * can see both boxes. This module still keeps its half of the bargain.
58
+ *
59
+ * SPENT ON A DELIVERED POLL, NOT ON AN ATTEMPTED ONE, which is why this is two
60
+ * functions instead of one consuming read. A daemon that starts while the wire
61
+ * is down retries every ten seconds; spending the ask on the failed attempt
62
+ * would mean the box you walked over to and started never takes the machine,
63
+ * and nothing anywhere would say why.
64
+ *
65
+ * IT IS NOT THE DELETED FLAG COMING BACK. There is still nothing to type: the
66
+ * terminal surface is `npx flowviant` and `npx flowviant login`, and what
67
+ * claims here is starting the daemon, which is already one of those two.
68
+ *
69
+ * Exported so both properties can be proved without a server, a credential or a
70
+ * second box — they are the contract, and nothing else in the file states them.
71
+ */
72
+ /**
73
+ * AND A RESTART IS NOT A PERSON (2026-09-19, the review).
74
+ *
75
+ * `update.mjs` re-execs the daemon with `FLOWVIANT_REEXEC='1'` after an
76
+ * UNATTENDED auto-update, which is on by default. Born `false`, the new process
77
+ * would then spend a fresh ask and TAKE THE PROJECT'S MACHINE — so a standby
78
+ * sitting quietly on somebody's second computer would seize the machine off the
79
+ * live holder in the middle of the night, settling its running turns as moved,
80
+ * because npm published a patch. Nobody was at either keyboard, and nothing on
81
+ * any surface would say why.
82
+ *
83
+ * The ask belongs to the GESTURE, not to the process: what claims a machine is
84
+ * a person typing `npx flowviant`, and a re-exec is the same start continuing.
85
+ * So a re-executed daemon is born with the ask already spent and behaves
86
+ * exactly as it did before the update — it keeps the machine if it had it (arm
87
+ * (a) stamps it), and stands by if it did not.
88
+ */
89
+ let machineAskSpent = process.env.FLOWVIANT_REEXEC === '1';
90
+ export function machineAskPending() {
91
+ return !machineAskSpent;
92
+ }
93
+ export function spendMachineAsk() {
94
+ machineAskSpent = true;
95
+ }
96
+
97
+
98
+ export async function fetchRoster(
99
+ livePreviewSessionIds = [],
100
+ heldSessionIds = [],
101
+ /** The churn ADMISSION verdict, taken by the caller from the same `admit`
102
+ * every unattended lane asks — see the `pr` param below for why it is the
103
+ * admission and not the pressure reading alone. Undefined where the caller
104
+ * has no admission to offer, which reads exactly like an older daemon. */
105
+ churnHold = undefined,
106
+ /** The checkout this daemon serves, for the `cp` report below. Passed in
107
+ * rather than re-derived: `repoRootOrDie` is resolved once at startup and
108
+ * running git on every poll to re-learn a constant would be a syscall for
109
+ * a readout. */
110
+ repoRoot = null,
111
+ baseRef = null
112
+ ) {
113
+ const url = new URL(FLEET_URL);
114
+ // No `have`: it named the dispatch-era lanes' held tokens, and nothing has
115
+ // held one since 2026-08-19 — the param was built empty, so never sent.
116
+ // What this machine will run at once. The server grows lanes to meet waiting
117
+ // work beneath this, instead of the user pre-sizing a pool by hand — only the
118
+ // machine knows its cores, its RAM and whose Claude quota is being spent.
119
+ // Older servers ignore the param, so sending it is always safe.
120
+ url.searchParams.set('capacity', String(MAX_CONCURRENT));
121
+ // WHICH DAEMON this machine runs, so the server can gate version-dependent
122
+ // work — codex Workbench tabs are only created for machines whose daemon can
123
+ // actually serve them (dv >= 0.46.0). The same source the self-update check
124
+ // compares against the roster's daemon.latest (config.mjs VERSION, read off
125
+ // our own package.json). Older servers ignore unknown params, so sending it
126
+ // unconditionally is always safe.
127
+ url.searchParams.set('dv', VERSION);
128
+ // The permission posture this machine runs turns under — '1' when
129
+ // FLOWVIANT_SAFE narrows the toolset, '0' when everything is granted. A
130
+ // statement of configuration, not a request: the app SHOWS it in Settings
131
+ // so a team can see whether the shared box runs wide open, and enforces
132
+ // nothing (membership is the consent boundary). Older servers ignore it.
133
+ url.searchParams.set('safe', SAFE ? '1' : '0');
134
+ // WHICH PROCESS, so the server can lease preview work to exactly one of two
135
+ // daemons on one credential. Older servers ignore unknown params.
136
+ url.searchParams.set('di', DAEMON_INSTANCE);
137
+ // WHICH BOX, by name, so the app can say "your machine is mac-mini" instead
138
+ // of naming a public key. Display only: arbitration is keyed on `envpub`,
139
+ // which is durable per box, while a hostname is neither unique nor stable.
140
+ // Absent when the host has no readable name — an older daemon looks the same,
141
+ // and both mean "nobody said", which is what the nameless fallback renders.
142
+ if (MACHINE_HOST) url.searchParams.set('mh', MACHINE_HOST);
143
+ /**
144
+ * WHICH CHECKOUT, WHICH PROCESS, AND SINCE WHEN (0.91.0) — the three facts
145
+ * that turn "a box polled" into a row somebody can act on.
146
+ *
147
+ * The owner could not answer a plain question about his own machines: "im not
148
+ * sure if i have any duplicate or redundant daemons running". The hostname
149
+ * alone cannot answer it either, because ONE box legitimately runs several
150
+ * daemons — "i can have 1 daemon in one directory, 1 in another as a
151
+ * differentiator of projects" — so `cp` is what makes two rows on one box
152
+ * legible, and `pid` + `st` are what let somebody standing at that box find
153
+ * the process and tell a long-lived daemon from one that has restarted nine
154
+ * times since they last looked.
155
+ *
156
+ * DAEMON→SERVER REPORTS, so no floor: an older daemon sends none of these and
157
+ * the server stores null, which reads as "it did not say" rather than as a
158
+ * box with no checkout. Bounded here as well as at the boundary — a query
159
+ * string is not a log, and this one is rendered into a terminal.
160
+ */
161
+ if (repoRoot) url.searchParams.set('cp', String(repoRoot).slice(0, 256));
162
+ url.searchParams.set('pid', String(process.pid));
163
+ url.searchParams.set('st', PROCESS_STARTED_AT);
164
+ /**
165
+ * THE FIRST POLL OF THIS PROCESS ASKS FOR THE MACHINE (`claim`, 0.91.0).
166
+ *
167
+ * The owner, on two boxes running one project: "it should kill the first one
168
+ * and take over." Starting the daemon on the computer in front of you IS the
169
+ * gesture — there is no flag, no env var and no third command, so the ruling
170
+ * that deleted the old machine-moving flag ("i dont intend to run or do
171
+ * anything in the terminal besides npx flowviant or npx flowviant login")
172
+ * stands untouched.
173
+ *
174
+ * SENT ONCE PER PROCESS, and that is the safety rather than a politeness: see
175
+ * `askForMachineOnce`. The server takes holdership there and then, the box it
176
+ * displaces learns through the existing named-and-windowed `displaced` signal
177
+ * and exits 0, and nothing restarts to ask again.
178
+ *
179
+ * AN OLDER SERVER IGNORES IT and the old standby behaviour stands, which is
180
+ * why this needs no floor of its own — the server applies one (it will not
181
+ * take a machine from a box too old to be told it lost it).
182
+ */
183
+ if (machineAskPending()) url.searchParams.set('claim', '1');
184
+ // A poll reports what this box IS. Moving the project's machine onto it, when
185
+ // this process has already spent its one ask above, is a gesture a person
186
+ // makes in the app — and the daemon learns that outcome on its next poll like
187
+ // every other holder fact.
188
+ // Which shares this machine is still serving. It rides the poll rather than
189
+ // taking an endpoint of its own: one beat, no floor, and the stale window is
190
+ // the reconcile interval instead of minutes — which matters, because a share
191
+ // the server still calls live is a 530 on somebody's phone. Always set, even
192
+ // empty: '' means "serving none", absent would mean "an older daemon".
193
+ url.searchParams.set('pv', livePreviewSessionIds.join(','));
194
+ // The sessions this daemon holds a worktree for. Its LEASE on each renews
195
+ // here — one beat, no extra endpoint, and the server can tell "this daemon is
196
+ // still serving that tab" from "it went away" within a reconcile interval
197
+ // instead of minutes. Always set, even empty: '' means "holding none".
198
+ url.searchParams.set('ws', heldSessionIds.join(','));
199
+ // WHICH CLIs this machine actually has, so the app can stop guessing.
200
+ //
201
+ // Until now every surface that listed Gemini or Codex said "not wired up yet"
202
+ // and meant it literally: nothing had ever looked. That was the honest answer
203
+ // while it was true, and it stops being honest the moment a second runtime can
204
+ // run — an app that cannot tell "Codex is not installed" from "we never
205
+ // checked" will confidently tell you the wrong one.
206
+ //
207
+ // A statement about this MACHINE and nothing else: no account, no quota, no
208
+ // entitlement. Detection is cached after the first poll (one version probe per
209
+ // CLI), so this costs a query param thereafter. Older servers ignore an
210
+ // unknown param, so sending it is always safe.
211
+ //
212
+ // ALWAYS SENT once detection ran, even EMPTY: '' is "looked and can drive
213
+ // none", which is how an uninstalled last CLI stops being advertised; only a
214
+ // detection that threw leaves the param absent (see `runtimesReport`).
215
+ try {
216
+ url.searchParams.set('runtimes', runtimesReport(detectRuntimes()));
217
+ } catch {
218
+ /* detection is best-effort — a probe must never fail the poll */
219
+ }
220
+ // WHAT THE CLI CAN BE ASKED FOR BY NAME, so the composer can autocomplete a
221
+ // `/` the way the terminal does. Learned from the init event of a turn we
222
+ // already ran (runtimeCapabilities.mjs) — never probed, because spawning a CLI to fill a
223
+ // dropdown would spend the operator's quota on an affordance.
224
+ //
225
+ // NOT SENT until a turn has taught us: absent means "no turn has run here
226
+ // yet", and the app renders no menu rather than asserting this machine has no
227
+ // skills. An empty report, though, IS a fact and is sent as such — hence the
228
+ // null check rather than a truthiness check on the array.
229
+ try {
230
+ const skills = knownSkills();
231
+ if (skills !== null) url.searchParams.set('skills', skills.join(','));
232
+ } catch {
233
+ /* best-effort — the poll must never fail on a readout */
234
+ }
235
+ // WHICH MCP SERVERS AND CONNECTORS THE CLI MOUNTED, AND HOW EACH STANDS
236
+ // (0.97.0) — `[{n, s}]`, learned off the same init event as the skills, this
237
+ // daemon's own `flowviant` server excluded AND every `connected` one with it:
238
+ // only what needs something leaves the box (runtimeCapabilities.mjs, recordMcpServers —
239
+ // a teammate has no business reading which services the operator signed
240
+ // into). The same three states: NOT SENT
241
+ // until a turn (or the one probe) has taught us, `[]` sent as the fact it is.
242
+ // The app names every server that is not connected — a connector that needs
243
+ // a sign-in AT THIS BOX above all — under the serving machine's row. A
244
+ // daemon→server report: an older server ignores the unknown param.
245
+ try {
246
+ const mcp = knownMcpServers();
247
+ if (mcp !== null) url.searchParams.set('mcp', JSON.stringify(mcp));
248
+ } catch {
249
+ /* best-effort — the poll must never fail on a readout */
250
+ }
251
+ if (repoRoot && baseRef) {
252
+ try {
253
+ const offerings = detectRuntimes().some((rt) => rt.id === 'codex' && rt.installed)
254
+ ? runtimeOfferings(repoRoot, baseRef) : {};
255
+ for (const [param, report] of Object.entries(offerings)) {
256
+ if (Object.keys(report).length) {
257
+ const payload = JSON.stringify(report);
258
+ if (payload.length <= 8_000 && url.toString().length + encodeURIComponent(payload).length <= 14_000) url.searchParams.set(param, payload);
259
+ }
260
+ }
261
+ } catch { /* local readouts never fail the poll */ }
262
+ }
263
+ // THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
264
+ //
265
+ // It carried two more params while the secrets vault existed: `envv` (the
266
+ // materialized bundle version) and `envskip` (the target files the
267
+ // materializer refused to write, where the EMPTY STRING was a real report and
268
+ // a truthiness filter here silently dropped it). Both died with the vault and
269
+ // the server no longer reads either, so the loop's `!= null` has nothing left
270
+ // to defend — it stays anyway, because it is the correct general shape for
271
+ // forwarding a param map and re-deriving that lesson is how it was lost the
272
+ // first time.
273
+ //
274
+ // `envpub` ITSELF IS UNCHANGED: same key, same base64, same keypair file. A
275
+ // box upgrading into this release must stay the SAME box to holdership
276
+ // arbitration and to the machine registry.
277
+ try {
278
+ for (const [k, v] of Object.entries(await envQueryParams())) {
279
+ if (v != null) url.searchParams.set(k, v);
280
+ }
281
+ } catch (e) {
282
+ /* env identity is best-effort — the poll must never fail on it */
283
+ /**
284
+ * …BUT IT IS NOT SILENT, because since 2026-09-14 losing it costs
285
+ * something visible. `envpub` is how the server tells two computers apart,
286
+ * so a poll without one is EXEMPT from arbitration — it is served in full,
287
+ * which is right — and nothing stamps `holder_heard_at`, which is the
288
+ * column presence is now read from. The app therefore says the machine is
289
+ * offline while this daemon is sitting here answering turns, and before
290
+ * this line the only evidence anywhere was a keypair file nobody looks at.
291
+ *
292
+ * Once per process: it is the same failure on every poll, and a reason
293
+ * repeated every few seconds is a reason nobody reads.
294
+ */
295
+ if (!warnedEnvIdentity) {
296
+ warnedEnvIdentity = true;
297
+ console.warn(
298
+ `[flowviant] could not read ${'~/.flowviant/env-keypair.json'} (${e?.message ?? e}).\n` +
299
+ ' This machine cannot identify itself, so the app may show it as offline while it works.\n' +
300
+ ' It has NOT been replaced — that file is what the project secrets are sealed to.'
301
+ );
302
+ }
303
+ }
304
+ /**
305
+ * WHY THIS MACHINE IS NOT TAKING NEW WORK, in its own measured words.
306
+ *
307
+ * Three states, and the third is why this is a param and not a header:
308
+ * · a reason — the churn ADMISSION refused; new unattended work is being
309
+ * deferred, and the board can say so AT the agent that is
310
+ * waiting instead of leaving it looking like a slow model.
311
+ * · `-` — asked, and nothing is holding anything back. A POSITIVE
312
+ * fact, which is what lets the server clear a stale reason
313
+ * rather than letting one sit there being true-looking
314
+ * forever.
315
+ * · absent — an older daemon, or a poll with no admission to ask. The
316
+ * reserved meaning, and the reason nothing is sent in that
317
+ * case: silence must not be readable as "fine".
318
+ *
319
+ * `FLOWVIANT_NO_PRESSURE_GUARD` no longer silences the param, and should not:
320
+ * it turns off the MEMORY AND LOAD half, and the concurrency half it does not
321
+ * touch is still a true account of why nothing is starting. What the operator
322
+ * asked for is a box that is not second-guessed about its own memory, not an
323
+ * agent that sits still with no explanation.
324
+ *
325
+ * IT IS THE WHOLE ADMISSION, AND IT USED TO BE THE PRESSURE HALF ONLY. The
326
+ * argument for narrowing it was that "four turns are running" is a capacity
327
+ * statement — but the effect was worse than the thing it avoided: a machine
328
+ * refusing every agent turn at its ceiling sent `pr=-`, which says MEASURED
329
+ * AND FINE, so the server cleared any stored reason and the board fell
330
+ * through to "nothing has polled this turn for 12m" over a daemon that was
331
+ * polling every ten seconds and declining on purpose. The machine positively
332
+ * asserted health at the one moment it was refusing.
333
+ *
334
+ * And CLAUDE.md already carves this exact shape out: queueing is said "AT THE
335
+ * THING THAT IS WAITING, in the moment, never budgeted for in advance on a
336
+ * global chip". The relayed sentence is `admission.mjs`'s, and it names
337
+ * ACTIVITY — "the machine is already running 3 CLI turns" — never the
338
+ * ceiling, never headroom, and never anywhere but on the row that is stalled.
339
+ * That is the same shape as a CLI relaying that it hit its own limit.
340
+ *
341
+ * `URLSearchParams` does its own encoding; the slice is the belt against a
342
+ * pathological reason.
343
+ */
344
+ try {
345
+ if (churnHold !== undefined) {
346
+ url.searchParams.set('pr', churnHold ? String(churnHold.reason).slice(0, 160) : '-');
347
+ /**
348
+ * …AND THE BOUND THE REFUSAL IS ABOUT (2026-09-17, `mt`).
349
+ *
350
+ * THIS AMENDS THE PARAGRAPH DIRECTLY ABOVE, which says the relayed
351
+ * sentence "names ACTIVITY … never the ceiling, never headroom". That
352
+ * rule was written to stop a capacity meter, and it succeeded so
353
+ * completely that a machine which could only ever run ONE turn had no way
354
+ * to say so — the owner, verbatim: "we should have ui indicating this on
355
+ * the board view and a ui indicating or showing the agent as waiting or
356
+ * queued as a result of that resource constraint. thats why i was
357
+ * immediately confused. theres nothing telling me that i could only have
358
+ * one agent on the board."
359
+ *
360
+ * So the ceiling crosses the wire, and the narrow shape that survives is
361
+ * the same carve-out the sentence beside it already lives under: it is
362
+ * shown in SETTINGS (beside the dial that sets it — a control with no
363
+ * readout is not a control) and on the BOARD only while a real agent is
364
+ * actually being deferred. Never a resting chip, and never headroom:
365
+ * nothing anywhere subtracts this from the live count to advertise room.
366
+ *
367
+ * It rides the `pr` gate deliberately — this is the bound that admission
368
+ * measured against, so a poll with no admission to ask has no effective
369
+ * ceiling to report either, and absence keeps meaning "an older daemon".
370
+ * `capacity` above is the DERIVED number and predates the dial; it is a
371
+ * dispatch-era fossil the server ignores, and the two are not the same
372
+ * fact.
373
+ */
374
+ url.searchParams.set('mt', String(effectiveMaxTurns()));
375
+ }
376
+ } catch {
377
+ /* a readout — the poll must never fail on one */
378
+ }
379
+ // An explicit User-Agent is required: Node's default ("node"/empty) trips
380
+ // Cloudflare Bot Fight Mode (403). A descriptive product UA passes.
381
+ const res = await fetch(url, {
382
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
383
+ signal: AbortSignal.timeout(30_000), // a black-holed poll must not stall the loop
384
+ });
385
+ if (res.status === 401 || res.status === 403) {
386
+ // Revoked or expired — retrying can't recover, so signal exit. But ONLY on
387
+ // the API's own refusal: an edge 403 (Cloudflare's bot checks, which this
388
+ // very client class trips) is a retryable failure, and exiting on one took
389
+ // a machine offline for good over a credential that was still valid.
390
+ if (await credentialRejected(res)) {
391
+ const e = new Error(`fleet credential rejected (${res.status})`);
392
+ e.auth = true;
393
+ throw e;
394
+ }
395
+ throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
396
+ }
397
+ if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
398
+ // THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
399
+ // answered. Spending it where the param is SET would lose the claim of a
400
+ // daemon that started while the wire was down: it would retry, silently
401
+ // without `claim`, and the box somebody walked over to and started would
402
+ // never take the machine.
403
+ spendMachineAsk();
404
+ const body = await res.json();
405
+ // Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
406
+ // throws a NORMAL retryable error inside the loop's try/catch, instead of a
407
+ // `roster.agents.map` TypeError escaping to top-level and killing the daemon.
408
+ const data = body?.data;
409
+ if (!data || !Array.isArray(data.agents)) {
410
+ throw new Error('fleet poll returned an unexpected shape');
411
+ }
412
+ // Drop roster agents with an unsafe id BEFORE they're used as a path segment.
413
+ data.agents = data.agents.filter((a) => {
414
+ if (isSafePathSegment(a?.agentId)) return true;
415
+ warn(`ignoring roster agent with an invalid id: ${JSON.stringify(a?.agentId)}`);
416
+ return false;
417
+ });
418
+ return data; // { mcpUrl, leaseTtlSeconds, agents: [] (a wire fossil), …the lanes' keys }
419
+ }
420
+
421
+ /**
422
+ * THE FIRST ANSWERED POLL, said to the terminal once — which project this
423
+ * daemon serves, and what else is serving it. The loop calls this exactly once
424
+ * per process, on the first roster that lands.
425
+ */
426
+ export function announceFirstPoll(roster) {
427
+ // Name the scoped project so a mismatch (this daemon serves project A, but
428
+ // you're viewing project B's wiki) is obvious instead of a silent no-op.
429
+ if (roster.project) {
430
+ note(
431
+ `${c.cyan('project')} · ${c.bold(projectLabel({ name: roster.project.name, projectId: roster.project.id }))} ${c.dim(`(${safeName(roster.project.id) ?? ''})`)}`
432
+ );
433
+ note(c.dim(' wiki + agents stream to THIS project — view its Code canvas in Flowviant.'));
434
+ // Remember the NAME beside the stored credential, so the picker and
435
+ // `flowviant projects` can say "skadooble" instead of an id — a
436
+ // credential saved before the server sent names backfills here. No-op
437
+ // for a --fleet/env token (nothing stored to annotate).
438
+ setStoredProjectName(roster.project.id, roster.project.name);
439
+ }
440
+ /**
441
+ * WHAT ELSE IS SERVING THIS PROJECT — said ONCE, right after the first
442
+ * poll lands, and never again (0.91.0).
443
+ *
444
+ * The owner could not answer a plain question about his own setup: "im
445
+ * not sure if i have any duplicate or redundant daemons running". This is
446
+ * the half of the answer that belongs in the terminal you just typed
447
+ * into, because that is where you are standing when the question occurs
448
+ * to you — the app's Home is where the whole account's answer lives.
449
+ *
450
+ * A RELAY, and it withholds nothing on failure: an older server has no
451
+ * boxes route and 404s, which prints NOTHING rather than a complaint
452
+ * about a feature nobody asked for. Fire-and-forget and never awaited on
453
+ * the poll path — a listing must never delay a roster tick.
454
+ */
455
+ void (async () => {
456
+ try {
457
+ const { boxesUrlFrom, fetchBoxesFor } = await import('./machines.mjs');
458
+ const { otherBoxesLine } = await import('./machineListing.mjs');
459
+ const res = await fetchBoxesFor(
460
+ { fleetToken: FLEET_TOKEN },
461
+ { url: boxesUrlFrom(FLEET_URL), envpub: myPubB64() ?? undefined }
462
+ );
463
+ if (!res.boxes) return;
464
+ const line = otherBoxesLine(res.boxes, res.me);
465
+ if (line) note(line);
466
+ } catch {
467
+ /* a readout — it must never disturb the daemon that produced it */
468
+ }
469
+ })();
470
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * THE DAEMON'S START, BEFORE THE FIRST POLL — the single-instance lock (and
3
+ * its refusal in words), the per-repo worktree home with its disk line and its
4
+ * fortnight reap, and the process signal handlers that run the teardown.
5
+ *
6
+ * Split out of fleet.mjs (SOLID audit 2026-09-26, F038). These run once per
7
+ * process, in the order `runFleetDaemon` calls them, and change for local
8
+ * reasons (how two daemons on one box are told apart, where worktrees live,
9
+ * how long an idle one is kept) that are nothing to do with the roster loop.
10
+ */
11
+
12
+ import { mkdirSync, readdirSync, statSync } from 'node:fs';
13
+ import { execFileSync } from 'node:child_process';
14
+ import { createHash } from 'node:crypto';
15
+ import { homedir } from 'node:os';
16
+ import { join, basename } from 'node:path';
17
+ import { FLEET_TOKEN } from './config.mjs';
18
+ import { git } from './git.mjs';
19
+ import { info, note, warn, fail } from './ui.mjs';
20
+ import { acquireInstanceLock } from './instance.mjs';
21
+ import { emitMachineEvent } from './daemonLogging.mjs';
22
+
23
+ /** Takes the lock, or says why not and exits 1. Returns only on success. */
24
+ export function takeInstanceLockOrExit(repoRoot) {
25
+ // ONE DAEMON PER CREDENTIAL. Before preflight, before the preview reap,
26
+ // before anything with a side effect — a second daemon must not so much as
27
+ // install a CLI or clear a registry on its way to being refused. Keyed on the
28
+ // credential rather than the repo, because two checkouts on one credential is
29
+ // the SAME project served twice, and the worst version of this: their session
30
+ // worktrees are in different directories, so the per-turn lock cannot even see
31
+ // across them. See instance.mjs for why that lock is not enough on its own.
32
+ // Same repo -> this run replaces whatever was serving it. Different repo ->
33
+ // refused, and nothing is signalled. See instance.mjs's header for the rule.
34
+ //
35
+ // `--takeover` ARBITRATES PROCESSES ON THIS BOX and nothing more — which
36
+ // daemon serves this repo. It says nothing about which BOX serves the
37
+ // project: that is holdership, the server decides it because only the server
38
+ // can see both boxes, and a person moves it from the app.
39
+ const instance = acquireInstanceLock(FLEET_TOKEN, repoRoot, {
40
+ takeover:
41
+ process.argv.includes('--takeover') || process.argv.includes('--takeover-downgrade'),
42
+ noTakeover:
43
+ process.argv.includes('--no-takeover') || process.env.FLOWVIANT_NO_TAKEOVER === '1',
44
+ allowDowngrade: process.argv.includes('--takeover-downgrade'),
45
+ log: (m) => info(m),
46
+ });
47
+ if (!instance.ok) {
48
+ const h = instance.holder;
49
+ console.log('');
50
+ // Two different refusals, because they are two different mistakes and the
51
+ // fix is not the same. Same CREDENTIAL: one project is being served twice.
52
+ // Same REPO under another credential: two daemons in one working tree,
53
+ // which the credential-keyed lock cannot see on its own.
54
+ if (instance.takeoverFailed) {
55
+ fail(`could not replace the running daemon: ${instance.takeoverFailed}`);
56
+ } else if (instance.sameRepo) {
57
+ fail('a flowviant daemon is already running in this repo.');
58
+ } else {
59
+ fail('a flowviant daemon is already running for this credential.');
60
+ }
61
+ if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
62
+ // The two-checkouts case is the one nobody spots on their own: both tabs
63
+ // look healthy, and the damage is doubled cards and doubled edits in a repo
64
+ // you are not looking at. Name the other repo when it is a different one.
65
+ if (!instance.sameRepo && h?.repoRoot && h.repoRoot !== repoRoot) {
66
+ warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
67
+ // Not offered lightly: that daemon is serving other work, and this
68
+ // command was run somewhere else. Replacing it is a decision, not a
69
+ // restart, so it takes a word.
70
+ note('run with --takeover to stop it and serve this repo instead.');
71
+ }
72
+ // WITHHELD when we could not identify the holder. ALLOW_MULTI runs this
73
+ // daemon unguarded beside one we just admitted we cannot see, and in the
74
+ // same repo that is two `git fetch`, two worktree sweeps, and one
75
+ // `retireWorkSessions` deleting directories the other is serving. Offering
76
+ // it as the way out of "I don't know what that process is" would be handing
77
+ // someone the worst option at the moment they have the least information.
78
+ if (!instance.unidentified) {
79
+ note('or run this one with FLOWVIANT_ALLOW_MULTI=1 if you know what you are doing.');
80
+ }
81
+ console.log('');
82
+ process.exit(1);
83
+ }
84
+ if (instance.unguarded)
85
+ warn('could not take the single-instance lock (unwritable ~/.flowviant) — running unguarded');
86
+ }
87
+
88
+ /**
89
+ * The persistent worktree home for this checkout, created if missing.
90
+ */
91
+ export function worktreeHome(repoRoot) {
92
+ // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
93
+ // so Ctrl+C mid-task never loses local work. Keyed per repo path.
94
+ const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
95
+ const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
96
+ mkdirSync(baseDir, { recursive: true });
97
+ return { repoKey, baseDir };
98
+ }
99
+
100
+ /** The disk line and the startup reap of long-idle task checkouts. */
101
+ export function tidyWorktreeHome(baseDir, repoRoot) {
102
+ // ONE CHECKOUT PER TASK, named after the task. Worktrees used to be
103
+ // `agent-<agentId>` — a long-lived tree per lane, reset to base between
104
+ // tasks — and that was the last thing a lane owned. Now a lane is a
105
+ // credential and nothing more, which is what makes it disposable: the server
106
+ // can hand any lane any task, and two tasks can never be in each other's
107
+ // files even when one is mid-edit.
108
+ try {
109
+ const kb = Number(execFileSync('du', ['-sk', baseDir], { encoding: 'utf8' }).split('\t')[0]);
110
+ if (kb > 1024)
111
+ info(
112
+ `disk · worktrees ${(kb / 1024 / 1024).toFixed(1)} GB at ~/.flowviant/worktrees — \`flowviant clean\` reclaims`
113
+ );
114
+ } catch {
115
+ /* du unavailable (Windows) — skip the disk line */
116
+ }
117
+
118
+ // Reap long-dead task checkouts. Per-lane trees were self-limiting — N lanes,
119
+ // N directories, reused forever. Per-task trees are not: every task ever
120
+ // built leaves one behind, so without this the disk grows without bound and
121
+ // `flowviant clean` becomes a chore rather than a convenience.
122
+ //
123
+ // Age, not state, is the test. The daemon has no list of which intents are
124
+ // still open, and asking the server for one would put a delete behind a
125
+ // network call that can fail — so anything untouched for a fortnight goes,
126
+ // which is far beyond how long a task stays reviewable and far beyond any
127
+ // pause a human takes mid-build. Runs at startup only: mid-run this would
128
+ // race a worker that is quietly parked on a blocker.
129
+ try {
130
+ const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
131
+ let reaped = 0;
132
+ for (const name of readdirSync(baseDir)) {
133
+ if (!name.startsWith('task-')) continue;
134
+ const p = join(baseDir, name);
135
+ try {
136
+ if (statSync(p).mtimeMs > cutoff) continue;
137
+ // Through git, so the worktree REGISTRATION goes too — an rm -rf leaves
138
+ // a stale entry that blocks re-adding the same path later.
139
+ git(['worktree', 'remove', '--force', p], repoRoot);
140
+ reaped++;
141
+ } catch {
142
+ /* held, gone, or not ours — leave it for `flowviant clean` */
143
+ }
144
+ }
145
+ if (reaped) info(`disk · reclaimed ${reaped} task worktree${reaped === 1 ? '' : 's'} idle > 14d`);
146
+ } catch {
147
+ /* the worktree home may not exist yet on a first run */
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Ctrl+C, a service manager's SIGTERM, and a stray rejection.
153
+ *
154
+ * `leave` is standDownExit.mjs's: it exits at once when no deploy is in
155
+ * flight, and otherwise waits for the deploys to finish and report (ruling
156
+ * 2026-09-26). The teardown runs once. A signal arriving while it waits goes
157
+ * to `leave` with its NAME, because only a person at the terminal can insist:
158
+ * a second Ctrl+C leaves now, a SIGTERM asking again changes nothing.
159
+ */
160
+ export function installSignalHandlers(teardown, leave) {
161
+ // REQUIRED, with no `process.exit` fallback: a caller that forgot it would
162
+ // get the exit that skips the drain, and nothing would say so.
163
+ if (typeof leave !== 'function') throw new TypeError('installSignalHandlers needs standDownExit.mjs\'s `leave`');
164
+ const onSignal = (code, words, signal) => {
165
+ if (leave.leaving?.()) {
166
+ void leave(code, { signal });
167
+ return;
168
+ }
169
+ emitMachineEvent({ event: 'stopped', reason: 'signal' });
170
+ console.log('');
171
+ note(words);
172
+ teardown();
173
+ void leave(code, { signal });
174
+ };
175
+ process.on('SIGINT', () =>
176
+ onSignal(130, 'shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.', 'SIGINT')
177
+ );
178
+ // A service manager stops the daemon with SIGTERM, not Ctrl+C. Without this
179
+ // handler every child survived a `systemctl stop` — the exact orphaning the
180
+ // teardown exists to prevent.
181
+ process.on('SIGTERM', () =>
182
+ onSignal(
183
+ 143,
184
+ 'shutting down (SIGTERM) — stopping workers. Worktrees are kept: in-flight work resumes next run.',
185
+ 'SIGTERM'
186
+ )
187
+ );
188
+ // Keep the daemon alive on a stray rejection. Many loops here are fire-and-
189
+ // forget (`void drainWiki()`, dispatch, sync) and rely on their callees never
190
+ // rejecting; Node ≥15 terminates the process on an unhandled rejection, which
191
+ // would kill every in-flight agent worker over one transient error. Log and
192
+ // survive instead — a wedged sub-task self-heals on the next poll.
193
+ process.on('unhandledRejection', (reason) => {
194
+ warn(`unhandled rejection (daemon kept alive): ${reason?.stack || reason}`);
195
+ });
196
+ }