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
@@ -1,336 +1,81 @@
1
1
  /**
2
- * `npx flowviant machines` — every project connected on THIS box, and every box
3
- * that has polled each of them (0.91.0).
4
- *
5
- * ── WHY A THIRD COMMAND EXISTS AT ALL ──
6
- *
7
- * The terminal surface is `npx flowviant` and `npx flowviant login`, full stop.
8
- * That is the owner's own ruling and it is what deleted the terminal flag that
9
- * used to move a project's machine. This amends it BY HIS OWN REQUEST — "the CLI gets a command to list connections" —
10
- * and it shipped in the narrowest way the ruling could survive: VIEW-ONLY,
11
- * starting nothing, stopping nothing, moving nothing, the one write behind an
12
- * explicit `--forget`.
13
- *
14
- * ── AND IT GREW VERBS, ON HIS SECOND REQUEST (2026-09-23, 0.95.0) ──
15
- *
16
- * Verbatim: *"when i run npx flowviant machines, it just exits out of cli, it
17
- * seems to be only a read cmd but I want to be able to interact with it and
18
- * use arrow keys to select the machines or to remove them"* — and, of the
19
- * duplicate it showed him, *"there should be a cli to remove it as well as a
20
- * ui on flowviant to remove it."* So on a terminal that can draw a menu the
21
- * listing is followed by one: ↑/↓ over the projects connected on THIS box,
22
- * enter for a project's two verbs, esc to leave. Piped, or on a terminal with
23
- * no raw mode, it is exactly the listing it was, and `--remove <id>` /
24
- * `--forget <id>` are the same two verbs for a script.
25
- *
26
- * THE VERBS ACT ON THIS BOX'S OWN CONNECTIONS AND NOTHING ELSE. "Disconnect"
27
- * stops the daemon serving that project HERE (one credential's lock, never a
28
- * sweep), tells the app this box has left that project's machines list, and
29
- * forgets the credential here — three things this box is entitled to do about
30
- * itself. It does not stop, remove or move a daemon on another computer: those
31
- * rows are printed under each project so the duplicate is visible, and the
32
- * footer still says where THOSE verbs live. Decisions about other machines
33
- * stay in the app, which is the ruling this amends and not the one it breaks.
34
- *
35
- * The question it answers is the owner's, verbatim: "is there a way to view ALL
36
- * the connected flowviants? because im not sure if i have any duplicate or
37
- * redundant daemons running". Measured the day it was asked: four live daemons
38
- * across two boxes, two credentials last heard three days ago, and two projects
39
- * both named "BRIF AI" bound to the same checkout — which is why `npx
40
- * flowviant` there offered two identical picker rows. THE PROJECT ID IS
41
- * THEREFORE ALWAYS PRINTED: it is the only thing that tells those two apart,
42
- * and the whole listing is worth less without it. **And since 0.95.0 the
43
- * collision is SAID, not left to be noticed**: the id told him the rows were
44
- * different projects, and he still read them as one project connected twice
45
- * ("BRIF AI appears twice meaning I likely have 2 daemon or 'machine
46
- * profiles' on my machine for the same BRIF AI project"). `renderCollisions`
47
- * prints one line per repo that two projects are bound to, naming both.
48
- *
49
- * ── IT IS A RELAY ──
50
- *
51
- * The role words are the SERVER'S — `serving` and `inactive` today — computed in
52
- * one place so this command, project settings and the account's Home block
53
- * cannot disagree about what a box is doing. They are PRINTED AS SENT: there is
54
- * no mapping table here and no default word, because either is this command
55
- * having an opinion about a state it did not measure. Nothing here re-derives a
56
- * state, and a project the server could not answer for says so rather than
57
- * rendering as empty.
58
- *
59
- * ── RENDERING IS PURE, FETCHING IS NOT ──
60
- *
61
- * `renderMachines` takes the entries and the results and returns lines, so
62
- * every case that matters — rejected, unreachable, an older server, no boxes,
63
- * behind, this box — is provable without a network, a credential or a daemon.
64
- * The wiring above it is the thin part on purpose.
2
+ * THE MACHINE'S TWO REQUESTS about its own boxes — `GET /fleet/boxes` (0.91.0)
3
+ * and `POST /fleet/boxes/leave` (0.95.0) — and the one rule that classifies
4
+ * what the server answered.
5
+ *
6
+ * Split out of the `machines` command's single module (SOLID audit 2026-09-26,
7
+ * F056): the listing and its copy live in `machineListing.mjs`, the
8
+ * stop → leave → forget orchestration in `machineDisconnect.mjs`, and this
9
+ * file is only the wire. The daemon's startup line and the tray's
10
+ * `status --remote` read the same boxes call from here.
11
+ *
12
+ * ONE CLASSIFICATION FOR BOTH (F170). The read and the leave each spelled the
13
+ * same five decisions — the app rejected the credential, something in front of
14
+ * the app answered 401/403, a 404 is an older SERVER, any other non-OK is an
15
+ * HTTP error, a thrown fetch is a transport failure — and only their success
16
+ * bodies differ. `machineRequest` states them once; each caller validates its
17
+ * own body. Keeping 404 apart from 401 is the part that matters: one means
18
+ * "older server", the other "a credential this box should probably forget",
19
+ * and collapsing them is how somebody deletes a working credential. The tray's
20
+ * third machine-credential read, `GET /fleet/live-agents` (desktopContract.mjs),
21
+ * classifies through the same exported rule.
65
22
  *
66
23
  * It deliberately imports NOTHING from fleet.mjs: that module pulls the whole
67
- * daemon (the CLIs, the worktree machinery, the preview tunnels) and this
68
- * command runs before the auth gate, like `stop` and `projects`. The small
69
- * duration formatter below is a second copy of a ten-line function, knowingly,
70
- * and it says different words anyway — "heard 12s ago" against "last heard 3d
71
- * ago", where the word LAST is the staleness mark.
24
+ * daemon, and the `machines` command runs before the auth gate.
72
25
  */
73
26
 
74
27
  import { USER_AGENT } from './config.mjs';
75
- import { projectLabel, repoCollisions, safeName } from './credentials.mjs';
76
28
  import { credentialRejected } from './authReject.mjs';
77
- import { launchCommand, terminalCommand } from './launchCommand.mjs';
29
+ import { fleetEndpoint } from './fleetWire.mjs';
78
30
 
79
31
  /** The boxes read, derived from the roster URL the way the diffstat post is —
80
32
  * one place configures the API base and everything else is a suffix swap. */
81
33
  export function boxesUrlFrom(fleetUrl) {
82
- return String(fleetUrl).replace(/\/agents\/?$/, '/boxes');
34
+ return fleetEndpoint('boxes', fleetUrl);
83
35
  }
84
36
 
85
37
  /** Where this box tells the server it has left a project — the boxes read's
86
38
  * own path with a verb on the end, so a server that has the read and not the
87
39
  * verb answers 404 and the caller says "older server" rather than guessing. */
88
40
  export function leaveUrlFrom(fleetUrl) {
89
- return `${boxesUrlFrom(fleetUrl)}/leave`;
41
+ return fleetEndpoint('boxes/leave', fleetUrl);
90
42
  }
91
43
 
92
- /** The one answer `agoFrom` gives that is a PHRASE rather than a duration, so
93
- * it is the one answer that must not take an " ago" after it. Named because
94
- * two callers have to agree about it and a bare string literal is how they
95
- * stopped agreeing. */
96
- const JUST_NOW = 'just now';
97
-
98
44
  /**
99
- * A coarse age. Never "0s": a box heard a moment ago reads as "just now",
100
- * because a daemon that polled four hundred milliseconds ago has not been
101
- * silent for no time at all — the same rule `agoLabel` keeps in fleet.mjs and
102
- * `elapsed` keeps in the web.
103
- *
104
- * NULL for anything unmeasurable, so the caller DROPS the clause rather than
105
- * printing a duration nobody took.
45
+ * WHAT A NON-SUCCESS ANSWER MEANS, as a shape both callers and the renderer
46
+ * know — or null when the response is an OK the caller should read.
47
+ *
48
+ * { rejected: true } the APP refused the credential (its JSON envelope)
49
+ * { error: 'HTTP 403 from something in front of the app' }
50
+ * an edge 401/403 (a challenge page) — not the app
51
+ * { unsupported: true } 404: an older server without this route
52
+ * { error: 'HTTP <n>' } any other non-OK
106
53
  */
107
- export function agoFrom(iso, now = Date.now()) {
108
- const t = Date.parse(iso ?? '');
109
- if (Number.isNaN(t)) return null;
110
- const ms = Math.max(0, now - t);
111
- const s = Math.round(ms / 1000);
112
- if (s < 5) return JUST_NOW;
113
- if (s < 90) return `${s}s`;
114
- const m = Math.round(s / 60);
115
- if (m < 90) return `${m}m`;
116
- const h = Math.round(m / 60);
117
- if (h < 36) return `${h}h`;
118
- return `${Math.round(h / 24)}d`;
54
+ export async function classifyMachineResponse(res) {
55
+ if (await credentialRejected(res)) return { rejected: true };
56
+ if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
57
+ if (res.status === 404) return { unsupported: true };
58
+ if (!res.ok) return { error: `HTTP ${res.status}` };
59
+ return null;
119
60
  }
120
61
 
121
62
  /**
122
- * WHEN THIS BOX WAS LAST HEARD FROM, as a whole phrase — and the phrase is one
123
- * function because the two callers got it WRONG THE SAME WAY (2026-09-19, the
124
- * review).
125
- *
126
- * `agoFrom` answers with a DURATION for everything except the newest case,
127
- * where it answers with the words "just now" — and both callers appended
128
- * " ago", so the most common row in the listing, a daemon that polled four
129
- * hundred milliseconds ago, read "heard just now ago". On the serving box. On
130
- * the first line anybody looks at.
131
- *
132
- * Stated once here rather than fixed twice: a duration takes the suffix, a
133
- * phrase does not, and "LAST heard" is the staleness mark the whole listing
134
- * turns on. An unmeasurable stamp says "never heard" rather than printing a
135
- * duration nobody took.
63
+ * Send one request and answer in a shape, never a throw: the classification
64
+ * above for a refusal, `accept(data)` for an OK body's `data`, and the
65
+ * transport's own words for a fetch that threw.
136
66
  */
137
- export function heardPhrase(box, now = Date.now()) {
138
- const ago = agoFrom(box?.lastHeardAt, now);
139
- if (!ago) return 'never heard';
140
- const lead = box?.fresh ? 'heard' : 'last heard';
141
- return ago === JUST_NOW ? `${lead} ${ago}` : `${lead} ${ago} ago`;
142
- }
143
-
144
- /** `savedAt` as a date somebody can match against their own memory of setting
145
- * it up. Absent stays absent — a credential stored before the field existed
146
- * has no date, and inventing one would be the listing asserting a day. */
147
- export function connectedOn(iso) {
148
- const t = Date.parse(iso ?? '');
149
- if (Number.isNaN(t)) return null;
150
- return new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
151
- }
152
-
153
- /**
154
- * THE SERVER'S OWN ROLE WORD, PRINTED. A pure relay and nothing else.
155
- *
156
- * It carried one special case until 2026-09-21: `'standing-by'` was respelled
157
- * as `standing by`, because the server sent a hyphenated enum value and a
158
- * terminal should not print an identifier. The owner replaced that word —
159
- * asked whether a box should keep saying "standing by", he answered *"no, it
160
- * can [be] inactive instead"* — so the server sends `serving` and `inactive`,
161
- * both of which are already words, and the special case had nothing left to
162
- * translate.
163
- *
164
- * IT IS NOT KEPT "IN CASE": a mapping table in a relay is a place for the two
165
- * ends to disagree, and a stale entry would print one thing while the app
166
- * printed another about the same box — which is precisely the confusion this
167
- * command exists to end. A role this build has never heard of passes through
168
- * untouched for the same reason.
169
- *
170
- * An ABSENT role prints nothing rather than a guess. It used to default to
171
- * `idle`, which is a CLAIM — "the server told us this box is doing nothing" —
172
- * about an answer the server did not give.
173
- */
174
- function roleWord(role) {
175
- return typeof role === 'string' && role.trim() ? role.trim() : '';
176
- }
177
-
178
- /** One box, as a line under its project. The MARK is the only derived thing on
179
- * it — a filled dot for a box the server still calls fresh, hollow for one it
180
- * does not — and the words beside it are all the server's or the box's own. */
181
- export function renderBox(box, { me = null, latest = null } = {}, now = Date.now()) {
182
- const mark = box.fresh ? '●' : '○';
183
- // SCRUBBED HERE TOO (2026-09-24, the audit) — a belt against the server's
184
- // own display scrub, in defence of a stale stored row or a server this
185
- // fix has not reached: the terminal is where an escape sequence in a
186
- // box's name or its checkout path actually does harm, so this is the one
187
- // place it must never be trusted to have already been cleaned. See
188
- // printable.mjs.
189
- const name = safeName(box.boxName) ?? 'an unnamed machine';
190
- // "LAST heard" is the staleness mark, and the row is never hidden for it —
191
- // the owner's answer to "what about a box that stopped" was exactly this.
192
- const heard = heardPhrase(box, now);
193
- // An unreported role DROPS ITS CELL. Every other cell on this row states its
194
- // own absence ("(checkout not reported)") because a reader who cannot see a
195
- // path needs to know nobody measured one; a role is different — the row is
196
- // already carrying the mark, the version and the last-heard, and a
197
- // placeholder there would be four words of nothing in the widest column.
198
- const cells = [
199
- `${mark} ${name}`,
200
- roleWord(box.role),
201
- safeName(box.checkoutPath) ?? '(checkout not reported)',
202
- box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
203
- heard,
204
- ].filter(Boolean);
205
- let line = ` ${cells.join(' ')}`;
206
- if (me && box.boxId === me) line += ' ← this box';
207
- // Said only when the server said it, and it names the version that is
208
- // actually published rather than a feature floor — a floor may sit above
209
- // LATEST on purpose, and an instruction to install something npm does not
210
- // serve is worse than silence.
211
- if (box.behind && latest) line += ` · behind, ${latest} is latest`;
212
- return line;
213
- }
214
-
215
- /**
216
- * THE WHOLE LISTING, as lines. Pure over (entries, results, now).
217
- *
218
- * `results` is keyed by projectId and each value is one of:
219
- * { boxes, latest, me } the server answered
220
- * { rejected: true } 401/403 — the credential is dead here
221
- * { unsupported: true } 404 — an older server with no boxes route
222
- * { error: '<words>' } anything else, relayed rather than summarised
223
- * A project with no entry in `results` is treated as unreachable, because that
224
- * is the only thing an absent answer can honestly mean.
225
- */
226
- export function renderMachines(entries, results, { now = Date.now() } = {}) {
227
- const lines = [];
228
- // WHICH LABELS COLLIDE. Two projects genuinely called the same thing is the
229
- // case that produced this command, so the id carries it — and it is printed
230
- // on EVERY row rather than only the colliding ones, because a reader who has
231
- // to notice a suffix to know whether it matters has already been asked to do
232
- // the work this listing is for.
233
- for (const e of entries) {
234
- const label = projectLabel(e);
235
- const connected = connectedOn(e.savedAt);
236
- const id = `(id ${e.projectId.slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
237
- lines.push(
238
- ` ${label} · ${e.repoRoot || 'not bound to a repo yet'} ${id}`
239
- );
240
- const r = results?.[e.projectId];
241
- if (!r || r.error) {
242
- // The failure is RELAYED, not summarised, and the loop carries on: one
243
- // unreachable project must not cost you the listing for the others.
244
- lines.push(` could not ask the server — ${r?.error ?? 'no answer'}`);
245
- continue;
246
- }
247
- if (r.rejected) {
248
- lines.push(
249
- ' credential rejected — this project was disconnected or deleted. ' +
250
- `\`${terminalCommand(`machines --forget ${e.projectId.slice(0, 8)}`)}\` removes it here.`
251
- );
252
- continue;
253
- }
254
- if (r.unsupported) {
255
- lines.push(' server does not report machines yet');
256
- continue;
257
- }
258
- const boxes = Array.isArray(r.boxes) ? r.boxes : [];
259
- if (boxes.length === 0) {
260
- // NOT "no machines". Nothing has polled this credential, which is a
261
- // different fact from a project whose machines have all gone quiet — and
262
- // a quiet one still has rows, wearing its last-heard.
263
- lines.push(' (no machine has polled this project)');
264
- continue;
265
- }
266
- for (const b of boxes) lines.push(renderBox(b, { me: r.me, latest: r.latest }, now));
67
+ async function machineRequest(send, accept) {
68
+ try {
69
+ const res = await send();
70
+ const refused = await classifyMachineResponse(res);
71
+ if (refused) return refused;
72
+ const body = await res.json();
73
+ return accept(body?.data);
74
+ } catch (e) {
75
+ return { error: String(e?.message ?? e) };
267
76
  }
268
- const collisions = renderCollisions(entries);
269
- if (collisions.length) lines.push('', ...collisions);
270
- return lines;
271
- }
272
-
273
- /** A project as ONE phrase for a sentence that names two of them — the name,
274
- * the id, and the connected date, which is the fact a person can match
275
- * against their own memory ("I set that one up last month"). */
276
- export function projectPhrase(e) {
277
- const connected = connectedOn(e?.savedAt);
278
- return `${projectLabel(e)} (${String(e?.projectId ?? '').slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
279
77
  }
280
78
 
281
- /**
282
- * TWO PROJECTS ON ONE REPO, SAID OUT LOUD (2026-09-23).
283
- *
284
- * The owner ran this command, saw "BRIF AI" twice, and concluded he had two
285
- * daemons for one project. The id on each row said they were different
286
- * projects; nothing said they were bound to the SAME checkout, which is the
287
- * fact that makes it a duplicate rather than two rows. So the listing ends
288
- * with one line per such repo, naming every project on it in full, the rule
289
- * (a directory serves one project — the owner's 2026-09-23 ruling, which made
290
- * the start there a refusal), and the remedy in his order: delete the project
291
- * in Flowviant, or disconnect this box from it (the menu below, or the flags).
292
- *
293
- * Pure over the entries. An unbound project collides with nothing.
294
- */
295
- export function renderCollisions(entries) {
296
- return repoCollisions(entries).map((g) => {
297
- const names = g.entries.map(projectPhrase);
298
- const list = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
299
- return (
300
- ` ! ${g.entries.length} projects are connected for ${g.repoRoot}: ${list} — ` +
301
- `a directory serves one project, and \`${launchCommand()}\` there refuses to start until it does. ` +
302
- 'Delete the one you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it.'
303
- );
304
- });
305
- }
306
-
307
- /**
308
- * THE FOOTER. Two sentences, both of which exist because this listing is the
309
- * moment somebody is confused — the same reason `flowviant projects` names its
310
- * remedies inline rather than in a manual.
311
- *
312
- * The first says where the verbs are, because there are none here. The second
313
- * states this command's own SCOPE, which is the one thing a reader cannot see
314
- * from the output: it lists what is connected on THIS box, and a daemon running
315
- * on a computer that has never met this one is invisible to it. The app's Home
316
- * is where "all of them" lives, and pretending otherwise would be this listing
317
- * quietly answering a narrower question than the one asked.
318
- */
319
- export const MACHINES_FOOTER = [
320
- ' stop a machine from the app: project settings → Machine → Disconnect;',
321
- ` a new \`${launchCommand()}\` on another box takes the project over.`,
322
- ' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
323
- ];
324
-
325
- /** THE SCRIPT'S VERBS, said only when there was no menu to offer them in — a
326
- * terminal that could draw the menu was offered the same two things by name,
327
- * and repeating the flags under it would be a manual under a control. */
328
- export const MACHINES_FLAGS_FOOTER = [
329
- ` \`${terminalCommand('machines --remove <id>')}\` disconnects this box from a project: stops its daemon here,`,
330
- ' removes this box from that project’s machines list in the app, and forgets its credential here.',
331
- ' `--forget <id>` forgets the credential here only.',
332
- ];
333
-
334
79
  /**
335
80
  * Ask one credential for its boxes. Never throws: every outcome is a shape the
336
81
  * renderer knows, because one dead project must not end the listing.
@@ -343,43 +88,17 @@ export const MACHINES_FLAGS_FOOTER = [
343
88
  export async function fetchBoxesFor(entry, { url, envpub, fetchImpl = fetch, signal = AbortSignal.timeout(15_000) } = {}) {
344
89
  const target = new URL(url);
345
90
  if (envpub) target.searchParams.set('envpub', envpub);
346
- try {
347
- const res = await fetchImpl(target, {
348
- headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
349
- signal,
350
- });
351
- if (await credentialRejected(res)) return { rejected: true };
352
- if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
353
- if (res.status === 404) return { unsupported: true };
354
- if (!res.ok) return { error: `HTTP ${res.status}` };
355
- const body = await res.json();
356
- const data = body?.data;
357
- if (!data || !Array.isArray(data.boxes)) return { error: 'unexpected answer shape' };
358
- return { boxes: data.boxes, latest: data.latest ?? null, me: data.me ?? null };
359
- } catch (e) {
360
- return { error: String(e?.message ?? e) };
361
- }
362
- }
363
-
364
- /**
365
- * THE OTHER BOXES ON THIS PROJECT, as ONE line for the daemon's startup — or
366
- * null when there are none, which is the common case and must print nothing.
367
- *
368
- * Said ONCE per process, after the first successful poll, and never per poll:
369
- * the fact only changes when somebody starts a daemon somewhere else, and a
370
- * true sentence restated every ten seconds is a scrolling console nobody reads.
371
- * THE CALLING BOX IS EXCLUDED — it is the one box the person at that terminal
372
- * does not need to be told about.
373
- */
374
- export function otherBoxesLine(boxes, me, now = Date.now()) {
375
- const others = (Array.isArray(boxes) ? boxes : []).filter((b) => b && b.boxId !== me);
376
- if (others.length === 0) return null;
377
- const parts = others.slice(0, 6).map((b) => {
378
- const role = roleWord(b.role);
379
- return `${safeName(b.boxName) ?? 'an unnamed machine'} (${role ? `${role} · ` : ''}${heardPhrase(b, now)})`;
380
- });
381
- const more = others.length - parts.length;
382
- return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
91
+ return machineRequest(
92
+ () =>
93
+ fetchImpl(target, {
94
+ headers: { Authorization: `Bearer ${entry.fleetToken}`, 'User-Agent': USER_AGENT },
95
+ signal,
96
+ }),
97
+ (data) => {
98
+ if (!data || !Array.isArray(data.boxes)) return { error: 'unexpected answer shape' };
99
+ return { boxes: data.boxes, latest: data.latest ?? null, me: data.me ?? null };
100
+ }
101
+ );
383
102
  }
384
103
 
385
104
  /**
@@ -401,117 +120,21 @@ export function otherBoxesLine(boxes, me, now = Date.now()) {
401
120
  */
402
121
  export async function leaveBoxFor(entry, { url, envpub, fetchImpl = fetch } = {}) {
403
122
  if (!envpub) return { skipped: true };
404
- try {
405
- const res = await fetchImpl(url, {
406
- method: 'POST',
407
- headers: {
408
- Authorization: `Bearer ${entry.fleetToken}`,
409
- 'User-Agent': USER_AGENT,
410
- 'Content-Type': 'application/json',
411
- },
412
- body: JSON.stringify({ envpub }),
413
- signal: AbortSignal.timeout(15_000),
414
- });
415
- if (await credentialRejected(res)) return { rejected: true };
416
- if (res.status === 401 || res.status === 403) return { error: `HTTP ${res.status} from something in front of the app` };
417
- if (res.status === 404) return { unsupported: true };
418
- if (!res.ok) return { error: `HTTP ${res.status}` };
419
- const body = await res.json();
420
- const data = body?.data;
421
- if (!data || typeof data.removed !== 'boolean') return { error: 'unexpected answer shape' };
422
- return { removed: data.removed, wasHolder: Boolean(data.wasHolder) };
423
- } catch (e) {
424
- return { error: String(e?.message ?? e) };
425
- }
426
- }
427
-
428
- /**
429
- * DISCONNECT THIS BOX FROM ONE PROJECT — the three steps, in the only order
430
- * that is safe, with every outcome said as it happens (0.95.0).
431
- *
432
- * 1. STOP the daemon serving that credential HERE. One lock, identified
433
- * before it is signalled, through the same stand-down `flowviant stop`
434
- * uses — never a sweep, because the person named ONE project. A daemon
435
- * that is alive and could not be stopped ABORTS the disconnect: forgetting
436
- * a credential under a running daemon changes nothing about the daemon
437
- * (it holds the token in memory) and telling the server we have left while
438
- * it keeps polling is a row that comes back in ten seconds wearing the
439
- * name we just said was gone.
440
- * 2. LEAVE in the app — see `leaveBoxFor`. Skipped, and said, when this box
441
- * has no identity file: a box that never ran a daemon never polled, so
442
- * the app has no row to remove.
443
- * 3. FORGET the credential here. Last, so a failure in 1 or 2 leaves the
444
- * store able to try again.
445
- *
446
- * `deps` is injected so the ORDER and the SENTENCES are provable without a
447
- * daemon, a socket or a credential file; `realDisconnectDeps` builds the live
448
- * set. Returns `{ ok }`, false only when step 1 refused.
449
- */
450
- export async function disconnectHere(entry, deps, { log = (m) => console.log(m) } = {}) {
451
- const who = projectPhrase(entry);
452
- const tally = deps.stopDaemon(entry.fleetToken);
453
- if (tally.failed > 0) {
454
- log(
455
- `not disconnected from ${who}: a daemon for it is still running here and was not stopped. ` +
456
- 'Stop it first (the line above says how), then run this again.'
457
- );
458
- return { ok: false };
459
- }
460
- if (tally.running === 0) log(`no daemon for ${who} was running here.`);
461
-
462
- const left = await deps.leave(entry);
463
- if (left.skipped) {
464
- log('this box has never run a daemon, so the app has no row of it to remove.');
465
- } else if (left.rejected) {
466
- log(`the app had already disconnected or deleted ${who} — nothing to leave.`);
467
- } else if (left.unsupported) {
468
- log(
469
- 'the app does not take a leave from a machine yet (older server) — its row goes quiet on its own, ' +
470
- 'or remove it in project settings → Machines.'
471
- );
472
- } else if (left.error) {
473
- log(
474
- `could not tell the app this box has left ${who} (${left.error}) — its row goes quiet on its own, ` +
475
- 'or remove it in project settings → Machines.'
476
- );
477
- } else if (!left.removed) {
478
- log(`the app was not listing this box on ${who}.`);
479
- } else {
480
- log(
481
- `removed this box from ${who}’s machines list in the app.` +
482
- (left.wasHolder ? ' It was the machine serving that project, which has none until another polls.' : '')
483
- );
484
- }
485
-
486
- const gone = deps.forget(entry.projectId);
487
- if (gone.error) {
488
- log(`could not forget the credential here: ${gone.error}`);
489
- return { ok: false };
490
- }
491
- log(`forgot ${who}’s credential on this box. \`${terminalCommand('login')}\` in its repo connects it again.`);
492
- return { ok: true };
493
- }
494
-
495
- /**
496
- * THE LIVE DEPENDENCIES for `disconnectHere`, built lazily: `instance.mjs`
497
- * and `env.mjs` are imported here and not at the top so that rendering the
498
- * listing — the piped, scripted, most common use — loads nothing that reads a
499
- * lock directory or a keypair. `readStoredPubB64` READS the key and never
500
- * mints one, the rule the `machines` branch in cli.mjs is pinned to.
501
- */
502
- export async function realDisconnectDeps({ url, log = (m) => console.log(m) } = {}) {
503
- const { stopDaemonFor } = await import('./instance.mjs');
504
- const { forgetStoredProject } = await import('./credentials.mjs');
505
- let envpub = null;
506
- try {
507
- const env = await import('./env.mjs');
508
- envpub = env.readStoredPubB64();
509
- } catch {
510
- /* unreadable keypair — the leave is skipped and said */
511
- }
512
- return {
513
- stopDaemon: (fleetToken) => stopDaemonFor(fleetToken, { log: (m) => log(` ${m}`) }),
514
- leave: (entry) => leaveBoxFor(entry, { url, envpub }),
515
- forget: (projectId) => forgetStoredProject(projectId),
516
- };
123
+ return machineRequest(
124
+ () =>
125
+ fetchImpl(url, {
126
+ method: 'POST',
127
+ headers: {
128
+ Authorization: `Bearer ${entry.fleetToken}`,
129
+ 'User-Agent': USER_AGENT,
130
+ 'Content-Type': 'application/json',
131
+ },
132
+ body: JSON.stringify({ envpub }),
133
+ signal: AbortSignal.timeout(15_000),
134
+ }),
135
+ (data) => {
136
+ if (!data || typeof data.removed !== 'boolean') return { error: 'unexpected answer shape' };
137
+ return { removed: data.removed, wasHolder: Boolean(data.wasHolder) };
138
+ }
139
+ );
517
140
  }