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,482 @@
1
+ /**
2
+ * WHETHER THIS BOX IS (STILL) THE PROJECT'S MACHINE — the holder watch, the
3
+ * three roster signals that end a daemon (a commanded stop, a displacement, a
4
+ * removal), and the stand-down choreography they share.
5
+ *
6
+ * Split out of fleet.mjs (SOLID audit 2026-09-26, F038). Everything here is a
7
+ * decision the SERVER made and the daemon relays: the server arbitrates
8
+ * holdership because it is the only party that can see both boxes, and every
9
+ * signal's PRESENCE is the command. What the loop keeps is the ordering —
10
+ * `obeyRosterCommands` runs before the holder observation and before the
11
+ * version signal, for the reason each block below states.
12
+ *
13
+ * Pure or dependency-injected throughout, so every property can be proved
14
+ * without a credential, a server or a second box (holder.test.mjs).
15
+ */
16
+
17
+ import { safeName } from './credentials.mjs';
18
+ import { emitMachineEvent } from './daemonLogging.mjs';
19
+ import { note, warn } from './ui.mjs';
20
+ import { sleep } from './claude.mjs';
21
+
22
+ /**
23
+ * A STOP COMMANDED BY FLOWVIANT, read off the roster poll.
24
+ *
25
+ * The daemon is a PULL client — the /fleet/stream socket is a one-way wake
26
+ * nudge with no server→daemon request path — so "stop this machine" can never
27
+ * be a request the server makes of us. It rides the roster RESPONSE instead, on
28
+ * the same `daemon` object the version signal already travels on, which is why
29
+ * it needs no new endpoint and no version floor: an older daemon reads an
30
+ * unknown key as nothing and keeps running, and fail-open is the safe direction
31
+ * for a switch whose failure mode is "your machine went dark".
32
+ *
33
+ * The server decides whether a stop is LIVE — it stamps the credential and only
34
+ * sends the key inside a short honor window — and the daemon does NOT re-derive
35
+ * that. The key's PRESENCE is the command. Evaluating the same TTL on both
36
+ * sides would make clock skew the arbiter of whether a machine may run, and get
37
+ * it wrong in the direction that bricks the box: a relaunch that re-reads an
38
+ * old timestamp and stops itself again, forever.
39
+ *
40
+ * Pure and exported so the decision can be proved without a credential or a
41
+ * live server. `null` means keep running.
42
+ */
43
+ export function shouldStop(rosterDaemon) {
44
+ const stop = rosterDaemon?.stop;
45
+ // An OBJECT, and not an array: `typeof [] === 'object'`, so the plain typeof
46
+ // guard let `stop: []` — an empty list, which is how this codebase spells "no
47
+ // jobs" on every other roster key — read as a live stop with no reason. A
48
+ // switch that kills a machine gets the narrow test.
49
+ if (!stop || typeof stop !== 'object' || Array.isArray(stop)) return null;
50
+ // Re-sanitized HERE even though the server wrote it: this string is operator
51
+ // prose typed into a SQL UPDATE and then printed straight to a terminal, so
52
+ // control bytes would let a stop reason repaint the console it is being read
53
+ // on, and an unbounded one would bury the line that matters. The WORDING is
54
+ // untouched — the operator's own sentence is the whole point of the field,
55
+ // and paraphrasing it would leave the person at the keyboard guessing.
56
+ const reason = String(stop.reason ?? '')
57
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
58
+ .replace(/\s+/g, ' ')
59
+ .trim()
60
+ .slice(0, 300);
61
+ return { stop: true, reason };
62
+ }
63
+
64
+ /**
65
+ * HOW LONG AGO, in the words the standby sentence needs. Milliseconds in, one
66
+ * short label out; anything that is not a finite, non-negative number renders
67
+ * NOTHING and the caller drops the clause rather than printing "heard NaN ago".
68
+ * The three-state rule applied to a duration: measured, or say nothing.
69
+ */
70
+ export function agoLabel(ms) {
71
+ // `typeof`, not `Number()`: `Number(null)` is 0, so a coercing guard turns
72
+ // "the server said nothing" into "heard 0s ago" — a measurement nobody took,
73
+ // printed at the one moment the person is deciding whether to wait.
74
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return null;
75
+ const s = Math.round(ms / 1000);
76
+ if (s < 60) return `${s}s`;
77
+ const m = Math.round(s / 60);
78
+ if (m < 60) return `${m}m`;
79
+ const h = Math.round(m / 60);
80
+ if (h < 48) return `${h}h`;
81
+ return `${Math.round(h / 24)}d`;
82
+ }
83
+
84
+ /**
85
+ * WHOSE MACHINE THIS IS — read off the roster, said once, and never a refusal.
86
+ *
87
+ * A project has ONE machine credential and `device/approve` hands every device
88
+ * the same raw token, so two boxes running `npx flowviant` are two daemons that
89
+ * both believe they are the machine. The old behaviour was a silent race: the
90
+ * loser could not see the winner at all, and the moment the winner went quiet
91
+ * long enough for a lease to lapse it cut a FRESH branch for an agent whose work
92
+ * exists only on the other box's disk, then ran a CLI with no conversation
93
+ * behind it. A confident, context-free redo of work somebody was mid-way
94
+ * through.
95
+ *
96
+ * The server arbitrates (it is the only party that can see both boxes) and the
97
+ * answer rides the poll RESPONSE as `holder`. This turns that answer into what
98
+ * the person at the keyboard needs to know, and nothing more:
99
+ *
100
+ * · ABSENT -> this server does not arbitrate machines. Behave exactly
101
+ * as every daemon before 0.84.0 did — zero new paths.
102
+ * · mine: true -> we are the machine. Announce it only if we have been
103
+ * inactive, so an ordinary daemon prints nothing new.
104
+ * · mine: false -> GO INACTIVE. Keep polling quietly; the restricted roster
105
+ * serves us nothing, the auto-handover makes this box the
106
+ * machine when the holder dies, and the app is where a
107
+ * person moves it sooner. Never exit: an inactive box that
108
+ * quits is one somebody has to go and restart by hand.
109
+ *
110
+ * THE WORD A PERSON READS IS `inactive` (2026-09-21) — the owner replaced
111
+ * "standing by" outright: *"no, it can [be] inactive instead"*. The server's
112
+ * role enum says `inactive`, `flowviant machines` prints it, and so do the two
113
+ * sentences below. The STATE NAME returned here is still `'standby'`, and that
114
+ * is deliberate: it names the server's arbitration arm, it is what every gate
115
+ * in this file branches on, and renaming an identifier to match a copy change
116
+ * is how a rename becomes a behaviour change nobody reviewed.
117
+ *
118
+ * Printed ONCE PER DISTINCT HOLDER rather than per poll — a true sentence
119
+ * restated every ten seconds is a scrolling console nobody reads, and the fact
120
+ * only CHANGES when the holder does.
121
+ *
122
+ * Pure except for the injected `say`, so the whole decision can be proved
123
+ * without a credential, a server or a second box.
124
+ */
125
+ export function createHolderWatch({ say = () => {} } = {}) {
126
+ let standbyKey = null; // the holder we last announced, or null while we serve
127
+ return {
128
+ /** Returns 'absent' | 'mine' | 'standby' — the state, for the caller's
129
+ * own gating and for tests that must not read the console. */
130
+ observe(holder) {
131
+ if (!holder || typeof holder !== 'object' || Array.isArray(holder)) {
132
+ // An older server, or a poll it did not arbitrate. Silence, and not one
133
+ // new path: this is the 0.83.0 daemon.
134
+ return 'absent';
135
+ }
136
+ if (holder.mine === true) {
137
+ /**
138
+ * WE TOOK IT, AND THE SERVER SAID SO (0.91.0).
139
+ *
140
+ * Sent on the one poll that moved holdership and never again, so this
141
+ * is news by construction — no key to remember, no window to judge.
142
+ * RELAYED AND NEVER DERIVED: the box we displaced is named because the
143
+ * server named it, and an unnamed one gets the nameless fallback the
144
+ * rest of this file uses rather than a guess. The turns clause DROPS
145
+ * WHOLE when the count is absent — "0 turns were running" is a
146
+ * measurement nobody took, printed at the one moment somebody is
147
+ * deciding whether they have just interrupted their own work.
148
+ */
149
+ const took = holder.took;
150
+ if (took && typeof took === 'object' && !Array.isArray(took)) {
151
+ standbyKey = null;
152
+ // scrubbed before it reaches a terminal (2026-09-24, the audit) —
153
+ // see printable.mjs.
154
+ const from = safeName(took.from)?.slice(0, 64) ?? 'another machine';
155
+ const turns = Number.isInteger(took.turns) && took.turns > 0 ? took.turns : null;
156
+ say(
157
+ `took this project's machine from ${from}` +
158
+ (turns
159
+ ? ` — ${turns} turn${turns === 1 ? '' : 's'} ${turns === 1 ? 'was' : 'were'} running there and ${turns === 1 ? 'was' : 'were'} settled as moved.`
160
+ : '.')
161
+ );
162
+ return 'mine';
163
+ }
164
+ if (standbyKey !== null) {
165
+ standbyKey = null;
166
+ say('this machine now serves the project.');
167
+ }
168
+ return 'mine';
169
+ }
170
+ // The NAME is all the response carries about the other box, so it is also
171
+ // the only thing "a distinct holder" can be keyed on. An unnamed holder
172
+ // keys on the empty string, which is stable — one announcement, not one
173
+ // per poll. Scrubbed before it reaches a terminal (2026-09-24, the
174
+ // audit) — see printable.mjs.
175
+ const name = safeName(holder.name)?.slice(0, 64) ?? null;
176
+ const key = name ?? '';
177
+ if (key !== standbyKey) {
178
+ standbyKey = key;
179
+ const ago = agoLabel(holder.heardAgo);
180
+ // THE SENTENCE POINTS AT THE APP, NEVER AT A COMMAND. The owner's
181
+ // ruling, verbatim: "i dont intend to run or do anything in the
182
+ // terminal besides npx flowviant or npx flowviant login." So the
183
+ // terminal surface is those two commands, full stop — this box waits
184
+ // or a person moves the machine from project settings, and there is no
185
+ // third thing to type here. An earlier cut of this sentence ended by
186
+ // telling the person to re-run this daemon with a claim flag; that flag
187
+ // is deleted, and the gesture is the app's — where the server can see
188
+ // both boxes and every daemon learns the outcome on its next poll.
189
+ //
190
+ // The ago clause DROPS whole when unmeasured — agoLabel returns null
191
+ // rather than a zero — because "heard 0s ago" is a measurement nobody
192
+ // took, printed at the one moment the person is deciding whether to
193
+ // wait.
194
+ //
195
+ // THE PROMISE IS GONE (0.91.0), and its absence is the honest half.
196
+ // This sentence used to end "It moves here automatically once that
197
+ // machine has been quiet 10 minutes", which was true while starting a
198
+ // daemon did nothing but wait. Since 0.91.0 a process START asks for
199
+ // the machine on its first poll — the owner's ruling, "it should kill
200
+ // the first one and take over" — so reaching this branch at all means
201
+ // the ask was REFUSED or never spent. Four ways: an older server that
202
+ // does not honour it; a daemon this server will not hand a machine to;
203
+ // an UNATTENDED RE-EXEC, which is born with the ask spent because a
204
+ // restart is not a person; and — the one worth naming — a box this
205
+ // credential DISPLACED inside the last ten minutes, which the server
206
+ // refuses so that two supervised daemons cannot trade a machine back
207
+ // and forth forever (see `takeMachineHolderNow`). In that last case the
208
+ // right next move is a decision, not a wait, which is exactly what this
209
+ // sentence already points at. Restating the staleness clock instead
210
+ // would promise a handover on the one path where it is least likely to
211
+ // be what happens. So the line relays the fact and points at the door,
212
+ // which is the same door it always pointed at.
213
+ //
214
+ // THE WORD IS `inactive` (2026-09-21). It was "standing by" until the
215
+ // owner replaced it — asked whether a box should keep saying that, he
216
+ // answered *"no, it can [be] inactive instead"* — and the server's role
217
+ // enum moved in the same pass (`serving` / `inactive`, which is why
218
+ // `machineListing.mjs` no longer respells anything). A terminal saying one
219
+ // word about this box while the app says another about the same box is
220
+ // the confusion these readouts exist to end, so the two ends say the
221
+ // same thing.
222
+ //
223
+ // THE POINTER SURVIVES THE REWORD. The sentence still ends at the app's
224
+ // project settings rather than at "another machine is serving this
225
+ // project", which the line's first half has already said — restating it
226
+ // would spend the only clause the person can act on repeating a fact.
227
+ say(
228
+ `This project's machine is ${name ?? 'another machine'}${ago ? ` (heard ${ago} ago)` : ''}. ` +
229
+ "This one is inactive — move it here from the app's project settings."
230
+ );
231
+ }
232
+ return 'standby';
233
+ },
234
+ };
235
+ }
236
+
237
+ /** The sentence an in-flight agent turn is settled with when the machine moves
238
+ * out from under it. MEASURED, not inferred: the server named the box that
239
+ * took over, and an unnamed one says so rather than guessing. Scrubbed
240
+ * before it is relayed anywhere (2026-09-24, the audit) — see printable.mjs. */
241
+ export function displacedTurnSentence(by) {
242
+ const name = safeName(by)?.slice(0, 64) ?? 'another machine';
243
+ return `The project's machine moved to ${name} while this turn was running.`;
244
+ }
245
+
246
+ /**
247
+ * …AND THE SENTENCE FOR THE OTHER WAY A BOX STOPS BEING THIS PROJECT'S
248
+ * MACHINE (2026-09-21): it was REMOVED in the app.
249
+ *
250
+ * Kept apart from the displaced one rather than generalised into "the machine
251
+ * is no longer this box", because the two are different facts and the person
252
+ * reading a stuck turn needs the one that happened. A move names where the work
253
+ * went and implies somebody will pick it up there; a removal names nothing,
254
+ * because there is nowhere for it to have gone. Guessing between them — or
255
+ * blurring them into one sentence that is true of both — is the product
256
+ * inventing a state.
257
+ *
258
+ * It takes NO name: there is no box that took over. The PROJECT is named on the
259
+ * console line instead, where it answers "removed from what".
260
+ */
261
+ export function removedTurnSentence() {
262
+ return 'This machine was removed from the project in the app while this turn was running.';
263
+ }
264
+
265
+ /**
266
+ * THE MACHINE MOVED. STAND DOWN.
267
+ *
268
+ * The server sends `displaced` only when it is aimed at THIS box (it matches the
269
+ * poll's own `envpub`) and only inside a short window, so there is nothing to
270
+ * re-derive here and nothing to honour from last week — the same shape, and the
271
+ * same reasoning, as the commanded stop above.
272
+ *
273
+ * Unlike the signal handlers this path is poll-response-driven, so it CAN await:
274
+ * every in-flight agent turn is settled first, because a turn this daemon
275
+ * abandons silently sits pending until the server's six-hour expiry while the
276
+ * board shows an agent working on a machine that has gone. `nothing` is the
277
+ * honest outcome and the sentence says what happened.
278
+ *
279
+ * EXIT 0, for the reason the two existing terminal paths (the commanded stop,
280
+ * and the revoked credential above it) both document: under `Restart=on-failure`
281
+ * a nonzero code has systemd relaunch this daemon immediately, where it would
282
+ * poll, be told again that it is not the machine, and stand down again — a
283
+ * restart loop fighting a decision somebody made on purpose.
284
+ *
285
+ * Every dependency is injected so the whole stand-down can be proved without a
286
+ * server, a repo or a process to kill.
287
+ */
288
+ export async function standDownDisplaced({
289
+ by,
290
+ kind = 'moved',
291
+ project,
292
+ settleAgentTurns,
293
+ flushReports,
294
+ teardown,
295
+ exit,
296
+ log = { warn: () => {}, note: () => {} },
297
+ }) {
298
+ /** A bound on a wedged uplink, and never a reason to hang: the timer is
299
+ * unref'd, so the only thing that keeps this process alive is the work. */
300
+ const bounded = (p, seconds) =>
301
+ Promise.race([
302
+ p,
303
+ new Promise((resolve) => {
304
+ const t = setTimeout(resolve, seconds * 1000);
305
+ t.unref?.();
306
+ }),
307
+ ]);
308
+ // Scrubbed before it reaches this box's own console (2026-09-24, the
309
+ // audit) — see printable.mjs.
310
+ const name = safeName(by)?.slice(0, 64) ?? null;
311
+ /**
312
+ * TWO KINDS, ONE CHOREOGRAPHY (2026-09-21).
313
+ *
314
+ * `moved` is the original: another box took the machine, and it is the
315
+ * DEFAULT so the displaced call site reads exactly as it always did.
316
+ * `removed` is the project saying this box is not its machine at all any
317
+ * more — somebody pressed a button in the app — and the only thing that
318
+ * differs is the WORDS. Everything below this point is the same, and it must
319
+ * be: settle the turns first, flush the reports, tear down the detached
320
+ * children, keep the worktrees, exit 0.
321
+ *
322
+ * The sentences are picked HERE rather than passed in, so the two cannot
323
+ * drift and neither call site can invent a third.
324
+ */
325
+ const removed = kind === 'removed';
326
+ const projectName = safeName(project)?.slice(0, 64) ?? 'this project';
327
+ log.warn(
328
+ removed
329
+ ? `removed from ${projectName} in the app — stopping.`
330
+ : name
331
+ ? `this project's machine moved to ${name} — standing down.`
332
+ : "this project's machine moved to another box — standing down."
333
+ );
334
+ if (removed) emitMachineEvent({ event: 'stopped', reason: 'removed' });
335
+ else emitMachineEvent({ event: 'displaced', message: displacedTurnSentence(by) });
336
+ // FIRST, and awaited: an unsettled turn is the one thing here that no later
337
+ // poll from anybody can fix — this process holds the only copy of the fact
338
+ // that it was running.
339
+ try {
340
+ await bounded(
341
+ settleAgentTurns(removed ? removedTurnSentence() : displacedTurnSentence(by)),
342
+ 10
343
+ );
344
+ } catch {
345
+ /* an unsettled turn expires server-side with words of its own */
346
+ }
347
+ // THEN the queued settles, bounded exactly as the commanded stop bounds them:
348
+ // a queued report is a COMPLETED turn whose side effects already happened, and
349
+ // dropping it re-runs the whole turn somewhere else.
350
+ try {
351
+ await bounded(flushReports(), 5);
352
+ } catch {
353
+ /* undelivered reports re-run; delivering them was best-effort */
354
+ }
355
+ // NOT optional, for the reason the commanded stop states: detached preview
356
+ // tunnels survive this process by design, and a public hostname pointed into a
357
+ // worktree on a box that no longer serves the project is the worst thing this
358
+ // path can leave behind.
359
+ teardown();
360
+ log.note('worktrees are kept — the branches here are the only copy of this box\'s work.');
361
+ exit(0);
362
+ }
363
+
364
+ /**
365
+ * THE THREE ROSTER SIGNALS THAT END THIS DAEMON, in their one order: a
366
+ * commanded stop, then a displacement, then a removal. Returns true when the
367
+ * daemon is going away (the loop returns), false when none was sent.
368
+ *
369
+ * Every dependency is injected so the choreography can be driven without a
370
+ * process to kill; the loop passes standDownExit.mjs's `leave` as `exit`, so a
371
+ * deploy in flight finishes and reports before the process goes.
372
+ */
373
+ export async function obeyRosterCommands(
374
+ roster,
375
+ { settleAgentTurns, flushWorkReports, teardown, exit, log = { warn, note } }
376
+ ) {
377
+ // A COMMANDED STOP OUTRANKS AN UPDATE, and that ordering is the whole reason
378
+ // this sits ABOVE the version signal rather than inside it. Both read the
379
+ // same `roster.daemon` object, but `handleVersionSignal` can re-exec this
380
+ // process into a newer build — so checked second, a machine somebody just
381
+ // told to stop would come back up wearing a different version instead of
382
+ // going away.
383
+ const stopSignal = shouldStop(roster.daemon);
384
+ if (stopSignal) {
385
+ emitMachineEvent({ event: 'stopped', reason: stopSignal.reason ?? null });
386
+ log.warn(
387
+ stopSignal.reason
388
+ ? `stopped by Flowviant — ${stopSignal.reason}`
389
+ : 'stopped by Flowviant — no reason given.'
390
+ );
391
+ log.note('shutting down — stopping workers. Worktrees are kept: in-flight work resumes next run.');
392
+ // FLUSH the settle queue first, bounded: a queued-but-undelivered report
393
+ // is a COMPLETED turn whose side effects already happened, and dropping
394
+ // it re-runs the whole turn on the next start — quota spent twice and
395
+ // every card write doubled. This path is async (unlike the signal
396
+ // handlers, which cannot await), so the stop can afford five seconds of
397
+ // delivery before it obeys.
398
+ try {
399
+ await Promise.race([flushWorkReports(), sleep(5)]);
400
+ } catch {
401
+ /* undelivered reports re-run; delivering them was best-effort */
402
+ }
403
+ // teardown() is NOT optional on this path. Detached preview tunnels
404
+ // survive this process BY DESIGN, so exiting without it strands a public
405
+ // hostname pointed into a worktree until somebody reboots the box — which
406
+ // is precisely the state a remote stop is usually being used to end. It
407
+ // also kills the session CLIs and the wiki Claude, which would otherwise
408
+ // keep editing worktrees and burning quota for a machine nobody is
409
+ // watching any more.
410
+ teardown();
411
+ // EXIT 0, and this is load-bearing: the stop was ASKED FOR, so it is not
412
+ // a failure. Under `Restart=on-failure` a nonzero code has systemd
413
+ // relaunch the daemon immediately, fighting the very command that stopped
414
+ // it; exit 0 reads as "the job is done" and leaves it down. The server's
415
+ // honor window is what makes the other half work — a deliberate relaunch
416
+ // minutes later comes up clean instead of stopping itself forever.
417
+ exit(0);
418
+ return true;
419
+ }
420
+ /**
421
+ * THE MACHINE MOVED OUT FROM UNDER US — checked BEFORE the version signal
422
+ * for the reason the stop above is: `handleVersionSignal` can re-exec this
423
+ * process, and a box that has just been displaced coming back up wearing a
424
+ * newer version is the one outcome nobody asked for.
425
+ *
426
+ * The key's PRESENCE is the command, exactly as it is for a stop: the server
427
+ * sends it only when it names THIS box's `envpub` and only inside its own
428
+ * window, so there is no TTL to re-evaluate here and no way for a relaunch
429
+ * to obey a displacement aimed at somebody else.
430
+ */
431
+ if (roster.displaced && typeof roster.displaced === 'object' && !Array.isArray(roster.displaced)) {
432
+ await standDownDisplaced({
433
+ by: roster.displaced.by,
434
+ settleAgentTurns,
435
+ flushReports: flushWorkReports,
436
+ teardown,
437
+ exit,
438
+ log,
439
+ });
440
+ return true;
441
+ }
442
+ /**
443
+ * REMOVED FROM THE PROJECT IN THE APP — the second way this box stops being
444
+ * this project's machine, and it goes through the same door for the same
445
+ * reason (2026-09-21).
446
+ *
447
+ * IMMEDIATELY AFTER THE DISPLACEMENT, AND BEFORE THE VERSION SIGNAL, which
448
+ * is the ordering the stop and the displacement above both document:
449
+ * `handleVersionSignal` can RE-EXEC this process, and a box that was just
450
+ * removed coming back up wearing a newer version is the one outcome nobody
451
+ * asked for. The two removal-shaped signals sit together so a reader cannot
452
+ * find one without the other.
453
+ *
454
+ * THE KEY'S PRESENCE IS THE COMMAND, exactly as it is for a stop and a
455
+ * displacement: the server sends it only when it names THIS box and only
456
+ * inside its own window, so there is no TTL to re-evaluate here and no way
457
+ * for a relaunch to obey a removal aimed at somebody else.
458
+ *
459
+ * The choreography is identical — settle every in-flight turn, flush the
460
+ * queued reports, tear down the detached children, keep the worktrees,
461
+ * exit 0 — and only the sentences differ. Exit 0 for the reason every
462
+ * terminal path here states: this was ASKED FOR, so under
463
+ * `Restart=on-failure` a nonzero code would relaunch the daemon straight
464
+ * into being removed again.
465
+ */
466
+ if (roster.standDown && typeof roster.standDown === 'object' && !Array.isArray(roster.standDown)) {
467
+ await standDownDisplaced({
468
+ kind: 'removed',
469
+ // The project's NAME rather than the box that took over, because no box
470
+ // took over. Falls back to "this project" inside — an unnamed project
471
+ // says so rather than being guessed at.
472
+ project: safeName(roster.standDown.project) ?? safeName(roster.project?.name) ?? undefined,
473
+ settleAgentTurns,
474
+ flushReports: flushWorkReports,
475
+ teardown,
476
+ exit,
477
+ log,
478
+ });
479
+ return true;
480
+ }
481
+ return false;
482
+ }