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,203 @@
1
+ /**
2
+ * `flowviant machines --remove` and the menu's first verb: DISCONNECT THIS BOX
3
+ * FROM ONE PROJECT — stop the daemon here, leave the app's machines list,
4
+ * forget the credential here.
5
+ *
6
+ * THE VERBS ACT ON THIS BOX'S OWN CONNECTIONS AND NOTHING ELSE. It does not
7
+ * stop, remove or move a daemon on another computer; decisions about other
8
+ * machines stay in the app (the ruling the `machines` command amends, argued
9
+ * in `machineListing.mjs`'s header).
10
+ *
11
+ * Split out of the `machines` command's single module (SOLID audit 2026-09-26,
12
+ * F056) because it is the one DESTRUCTIVE workflow there, with its own callers
13
+ * (machinesCommand.mjs's `--remove` and menu, `uninstall`) and its own test needs (the
14
+ * order and the sentences, under injected deps). It reads the listing's
15
+ * `projectPhrase` so a project is named the same way in both, and the leave
16
+ * request from `machines.mjs`.
17
+ *
18
+ * IT ALSO OWNS WHAT A DISCONNECT ACHIEVED — the outcome contract
19
+ * `disconnectHere` returns beside the sentences it prints, and the one reading
20
+ * of it every caller that must ACT on the result uses (SOLID F004), because
21
+ * `flowviant uninstall --purge` decided whether the app had been told by
22
+ * matching four phrases in the log lines, and then deleted `~/.flowviant`
23
+ * even after a failure it had just recorded. The sentences are for a person
24
+ * at a terminal; a decision reads this.
25
+ *
26
+ * The shape: `{ ok, stop, leave, forget }` —
27
+ * stop: 'stopped' | 'none' | 'failed' | 'draining'
28
+ * leave: 'left' | 'not-listed' | 'skipped' | 'rejected' | 'unsupported' | 'failed' | 'not-run'
29
+ * (+ `leaveError` when 'failed')
30
+ * forget: 'forgotten' | 'kept' | 'failed' | 'not-run'
31
+ * (+ `forgetError` when 'failed')
32
+ * `ok` keeps its meaning for the `machines` verbs (false when the stop
33
+ * refused, the forget failed, or the caller asked to keep the credential
34
+ * after a failed leave and it was kept).
35
+ */
36
+
37
+ import { projectPhrase } from './machineListing.mjs';
38
+ import { leaveBoxFor } from './machines.mjs';
39
+ import { terminalCommand } from './launchCommand.mjs';
40
+
41
+ /**
42
+ * Where a person removes a box's row by hand when this box could not: the
43
+ * project's Machines page (it took over Settings › Machines, 2026-09-25).
44
+ * One spelling for every terminal sentence that points there.
45
+ */
46
+ export const removeOnMachinesPage = (what = 'it') => `remove ${what} on the project's Machines page`;
47
+
48
+ /** The leave's answer (see `leaveBoxFor`) as one word. */
49
+ export function leaveOutcomeOf(left) {
50
+ if (left?.skipped) return 'skipped';
51
+ if (left?.rejected) return 'rejected';
52
+ if (left?.unsupported) return 'unsupported';
53
+ if (left?.error) return 'failed';
54
+ if (!left?.removed) return 'not-listed';
55
+ return 'left';
56
+ }
57
+
58
+ /**
59
+ * What the disconnect did NOT achieve, or null when it achieved everything it
60
+ * can. `retryable` says whether running it again could change the answer:
61
+ * a daemon that would not stop, a leave the network or the app refused, a
62
+ * credential that could not be forgotten. An older server with no leave
63
+ * route is reported but not retryable — asking it again changes nothing.
64
+ *
65
+ * An outcome with none of the fields (a caller's own stub, an older shape) is
66
+ * read by `ok` alone.
67
+ */
68
+ export function disconnectShortfall(outcome) {
69
+ if (!outcome || typeof outcome !== 'object') return { reason: 'the disconnect reported nothing', retryable: true };
70
+ if (outcome.stop === 'failed')
71
+ return { reason: 'a daemon for it is still running here and was not stopped', retryable: true };
72
+ if (outcome.stop === 'draining')
73
+ return { reason: 'a daemon for it is finishing a deploy here; wait for it to finish', retryable: true };
74
+ if (outcome.leave === 'failed')
75
+ return { reason: `could not tell the app this box has left (${outcome.leaveError ?? 'failed'})`, retryable: true };
76
+ if (outcome.forget === 'failed')
77
+ return { reason: `could not forget the credential here: ${outcome.forgetError ?? 'failed'}`, retryable: true };
78
+ if (outcome.leave === 'unsupported')
79
+ return { reason: 'the app does not take a leave from a machine yet (older server)', retryable: false };
80
+ if (outcome.ok === false) return { reason: 'failed', retryable: true };
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * DISCONNECT THIS BOX FROM ONE PROJECT — the three steps, in the only order
86
+ * that is safe, with every outcome said as it happens (0.95.0).
87
+ *
88
+ * 1. STOP the daemon serving that credential HERE. One lock, identified
89
+ * before it is signalled, through the same stand-down `flowviant stop`
90
+ * uses — never a sweep, because the person named ONE project. A daemon
91
+ * that is alive and could not be stopped ABORTS the disconnect: forgetting
92
+ * a credential under a running daemon changes nothing about the daemon
93
+ * (it holds the token in memory) and telling the server we have left while
94
+ * it keeps polling is a row that comes back in ten seconds wearing the
95
+ * name we just said was gone.
96
+ * A daemon FINISHING A DEPLOY aborts it too (ruling 2026-09-26: a
97
+ * stand-down lets a deploy finish and report). The stop was obeyed and is
98
+ * never forced, but the daemon is still alive: its report needs the
99
+ * credential and the app's row of this box, and the leave would delete
100
+ * that row, which is how the app knows to refuse its own Disconnect while
101
+ * the deploy is in flight. So nothing is left or forgotten, and it says to
102
+ * wait, in the app's own words.
103
+ * 2. LEAVE in the app — see `leaveBoxFor`. Skipped, and said, when this box
104
+ * has no identity file: a box that never ran a daemon never polled, so
105
+ * the app has no row to remove.
106
+ * 3. FORGET the credential here. Last, so a failure in 1 or 2 leaves the
107
+ * store able to try again.
108
+ *
109
+ * `deps` is injected so the ORDER and the SENTENCES are provable without a
110
+ * daemon, a socket or a credential file; `realDisconnectDeps` builds the live
111
+ * set. Returns the OUTCOME (above) — `{ ok, stop, leave, forget }` — so a
112
+ * caller that must act on what happened reads fields, never the sentences.
113
+ * `ok` is false only when step 1 refused, step 3 failed, or the credential
114
+ * was kept (below).
115
+ *
116
+ * `forgetAfterFailedLeave` (default true, the `machines` verbs' rule): after
117
+ * a leave the app could not be told, the stopped box's store is still the
118
+ * last thing to clean. `uninstall --purge` passes false — it is about to
119
+ * delete the store whole, and a credential forgotten before a failed leave is
120
+ * one it can never use to try again.
121
+ */
122
+ export async function disconnectHere(entry, deps, { log = (m) => console.log(m), forgetAfterFailedLeave = true } = {}) {
123
+ const who = projectPhrase(entry);
124
+ const tally = deps.stopDaemon(entry.fleetToken);
125
+ if (tally.failed > 0) {
126
+ log(
127
+ `not disconnected from ${who}: a daemon for it is still running here and was not stopped. ` +
128
+ 'Stop it first (the line above says how), then run this again.'
129
+ );
130
+ return { ok: false, stop: 'failed', leave: 'not-run', forget: 'not-run' };
131
+ }
132
+ if (tally.draining > 0) {
133
+ log(
134
+ `not disconnected from ${who}: a deploy is in flight on this box; wait for it to finish, ` +
135
+ 'then run this again. Its daemon stops by itself once the outcome is reported.'
136
+ );
137
+ return { ok: false, stop: 'draining', leave: 'not-run', forget: 'not-run' };
138
+ }
139
+ const stop = tally.running === 0 ? 'none' : 'stopped';
140
+ if (tally.running === 0) log(`no daemon for ${who} was running here.`);
141
+
142
+ const left = await deps.leave(entry);
143
+ const leave = leaveOutcomeOf(left);
144
+ const leaveFacts = leave === 'failed' ? { leave, leaveError: String(left.error) } : { leave };
145
+ if (left.skipped) {
146
+ log('this box has never run a daemon, so the app has no row of it to remove.');
147
+ } else if (left.rejected) {
148
+ log(`the app had already disconnected or deleted ${who} — nothing to leave.`);
149
+ } else if (left.unsupported) {
150
+ log(
151
+ 'the app does not take a leave from a machine yet (older server) — its row goes quiet on its own, ' +
152
+ `or ${removeOnMachinesPage()}.`
153
+ );
154
+ } else if (left.error) {
155
+ log(
156
+ `could not tell the app this box has left ${who} (${left.error}) — its row goes quiet on its own, ` +
157
+ `or ${removeOnMachinesPage()}.`
158
+ );
159
+ } else if (!left.removed) {
160
+ log(`the app was not listing this box on ${who}.`);
161
+ } else {
162
+ log(
163
+ `removed this box from ${who}’s machines list in the app.` +
164
+ (left.wasHolder ? ' It was the machine serving that project, which has none until another polls.' : '')
165
+ );
166
+ }
167
+
168
+ if (leave === 'failed' && !forgetAfterFailedLeave) {
169
+ log(`kept ${who}’s credential on this box, so the leave can be tried again.`);
170
+ return { ok: false, stop, ...leaveFacts, forget: 'kept' };
171
+ }
172
+ const gone = deps.forget(entry.projectId);
173
+ if (gone.error) {
174
+ log(`could not forget the credential here: ${gone.error}`);
175
+ return { ok: false, stop, ...leaveFacts, forget: 'failed', forgetError: String(gone.error) };
176
+ }
177
+ log(`forgot ${who}’s credential on this box. \`${terminalCommand('login')}\` in its repo connects it again.`);
178
+ return { ok: true, stop, ...leaveFacts, forget: 'forgotten' };
179
+ }
180
+
181
+ /**
182
+ * THE LIVE DEPENDENCIES for `disconnectHere`, built lazily: `instance.mjs`
183
+ * and `boxIdentity.mjs` are imported here and not at the top so that rendering the
184
+ * listing — the piped, scripted, most common use — loads nothing that reads a
185
+ * lock directory or a keypair. `readStoredPubB64` READS the key and never
186
+ * mints one, the rule machinesCommand.mjs is pinned to.
187
+ */
188
+ export async function realDisconnectDeps({ url, log = (m) => console.log(m) } = {}) {
189
+ const { stopDaemonFor } = await import('./instance.mjs');
190
+ const { forgetStoredProject } = await import('./credentials.mjs');
191
+ let envpub = null;
192
+ try {
193
+ const env = await import('./boxIdentity.mjs');
194
+ envpub = env.readStoredPubB64();
195
+ } catch {
196
+ /* unreadable keypair — the leave is skipped and said */
197
+ }
198
+ return {
199
+ stopDaemon: (fleetToken) => stopDaemonFor(fleetToken, { log: (m) => log(` ${m}`) }),
200
+ leave: (entry) => leaveBoxFor(entry, { url, envpub }),
201
+ forget: (projectId) => forgetStoredProject(projectId),
202
+ };
203
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * THE MACHINE CREDENTIAL NEVER RIDES AN INHERITED ENVIRONMENT — one home for
3
+ * the removal rule every child built from the daemon's own environment obeys
4
+ * (a CLI turn's `cliEnv` in runTurn.mjs, the project check's `checkEnv` in
5
+ * workAgentCheck.mjs). Split out (SOLID F042) because the two lists were
6
+ * hand-copied: a third credential name added to one would have left the other
7
+ * lane handing it to a repo-controlled command.
8
+ *
9
+ * A DENYLIST ON PURPOSE, unlike `childEnv`'s allowlist: these children need
10
+ * the operator's shell whole (the CLI's own sign-in, the toolchain a test run
11
+ * needs), so stripping everything else would manufacture failures. Nothing
12
+ * they run needs the credential the daemon authenticates with, so exactly
13
+ * these names go. Imports nothing.
14
+ */
15
+ export const MACHINE_CREDENTIAL_ENV = Object.freeze(['FLOWVIANT_MACHINE_TOKEN', 'FLOWVIANT_FLEET']);
16
+
17
+ /** A copy of `env` without the machine credential. Never mutates its input. */
18
+ export function withoutMachineCredentials(env) {
19
+ const out = { ...(env ?? {}) };
20
+ for (const k of MACHINE_CREDENTIAL_ENV) delete out[k];
21
+ return out;
22
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * WHAT THIS MACHINE CAN ACTUALLY BE GIVEN, read from the machine (2026-09-26,
3
+ * SOLID F059).
4
+ *
5
+ * Split out of config.mjs, which computed this inline at import from the real
6
+ * `/sys/fs/cgroup` files and the os module — so the rule could not be driven
7
+ * under any cgroup but the one the suite happened to run in. The RULE is now
8
+ * `deriveMachineLimits`, pure over supplied cgroup contents and host totals;
9
+ * `measureMachineLimits` is the thin reader that hands it this box's.
10
+ * config.mjs still exports the measured `MACHINE`, and `deriveMaxConcurrent`
11
+ * keeps its (memBytes, cores) contract.
12
+ *
13
+ * `os.totalmem()` and `cpus().length` report the HOST inside a container: a
14
+ * 4GB container on a 256GB box reads 256GB and cheerfully oversubscribes until
15
+ * the OOM killer picks a victim — which, because it picks by resident size, is
16
+ * frequently not the task that caused it. cgroup v2 publishes the real limits,
17
+ * so read those first and treat the os module as the fallback it is.
18
+ */
19
+
20
+ import { readFileSync } from 'node:fs';
21
+ import { cpus, totalmem } from 'node:os';
22
+
23
+ /**
24
+ * The limits from what was read. `memMax` / `cpuMax` are the raw cgroup v2
25
+ * file contents (trimmed), or null when there is no such file; anything
26
+ * unparseable is ignored and the host figure stands.
27
+ */
28
+ export function deriveMachineLimits({ memMax = null, cpuMax = null, hostMemBytes, hostCores } = {}) {
29
+ let memBytes = hostMemBytes;
30
+ if (memMax && memMax !== 'max') {
31
+ const n = Number(memMax);
32
+ if (Number.isFinite(n) && n > 0) memBytes = Math.min(memBytes, n);
33
+ }
34
+ let cores = hostCores || 2;
35
+ // "<quota> <period>" in microseconds, or "max <period>" for unlimited.
36
+ if (cpuMax && !cpuMax.startsWith('max')) {
37
+ const [q, p] = cpuMax.split(/\s+/).map(Number);
38
+ if (Number.isFinite(q) && Number.isFinite(p) && p > 0) {
39
+ cores = Math.max(1, Math.min(cores, Math.floor(q / p)));
40
+ }
41
+ }
42
+ return { memBytes, cores };
43
+ }
44
+
45
+ const readCgroup = (f) => {
46
+ try {
47
+ return readFileSync(`/sys/fs/cgroup/${f}`, 'utf8').trim();
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /** This box's limits: its cgroup files and its os module, handed to the rule. */
54
+ export function measureMachineLimits(read = readCgroup) {
55
+ return deriveMachineLimits({
56
+ memMax: read('memory.max'),
57
+ cpuMax: read('cpu.max'),
58
+ hostMemBytes: totalmem(),
59
+ hostCores: cpus().length,
60
+ });
61
+ }
@@ -0,0 +1,338 @@
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, AND IT IS THIS FILE ──
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
+ *
65
+ * THREE FILES, ONE COMMAND (SOLID audit 2026-09-26, F056). `machines.mjs` held
66
+ * the listing, the two fleet requests and the destructive disconnect in one
67
+ * 517-line module with three sets of callers and three kinds of test. The
68
+ * split follows those callers: this file is the PURE listing and its copy
69
+ * (the daemon's startup line and `views.mjs` import from here too);
70
+ * `machines.mjs` is the two requests to `/fleet/boxes` and the one rule that
71
+ * classifies their answers; `machineDisconnect.mjs` is the stop → leave →
72
+ * forget orchestration. Each importer names the file it means.
73
+ *
74
+ * It deliberately imports NOTHING from fleet.mjs: that module pulls the whole
75
+ * daemon (the CLIs, the worktree machinery, the preview tunnels) and this
76
+ * command runs before the auth gate, like `stop` and `projects`. The small
77
+ * duration formatter below is a second copy of a ten-line function, knowingly,
78
+ * and it says different words anyway — "heard 12s ago" against "last heard 3d
79
+ * ago", where the word LAST is the staleness mark.
80
+ */
81
+
82
+ import { projectLabel, repoCollisions, safeName } from './credentials.mjs';
83
+ import { connectedOn } from './credentialDate.mjs';
84
+ import { launchCommand, terminalCommand } from './launchCommand.mjs';
85
+
86
+ /** The one answer `agoFrom` gives that is a PHRASE rather than a duration, so
87
+ * it is the one answer that must not take an " ago" after it. Named because
88
+ * two callers have to agree about it and a bare string literal is how they
89
+ * stopped agreeing. */
90
+ const JUST_NOW = 'just now';
91
+
92
+ /**
93
+ * A coarse age. Never "0s": a box heard a moment ago reads as "just now",
94
+ * because a daemon that polled four hundred milliseconds ago has not been
95
+ * silent for no time at all — the same rule `agoLabel` keeps in holder.mjs and
96
+ * `elapsed` keeps in the web.
97
+ *
98
+ * NULL for anything unmeasurable, so the caller DROPS the clause rather than
99
+ * printing a duration nobody took.
100
+ */
101
+ export function agoFrom(iso, now = Date.now()) {
102
+ const t = Date.parse(iso ?? '');
103
+ if (Number.isNaN(t)) return null;
104
+ const ms = Math.max(0, now - t);
105
+ const s = Math.round(ms / 1000);
106
+ if (s < 5) return JUST_NOW;
107
+ if (s < 90) return `${s}s`;
108
+ const m = Math.round(s / 60);
109
+ if (m < 90) return `${m}m`;
110
+ const h = Math.round(m / 60);
111
+ if (h < 36) return `${h}h`;
112
+ return `${Math.round(h / 24)}d`;
113
+ }
114
+
115
+ /**
116
+ * WHEN THIS BOX WAS LAST HEARD FROM, as a whole phrase — and the phrase is one
117
+ * function because the two callers got it WRONG THE SAME WAY (2026-09-19, the
118
+ * review).
119
+ *
120
+ * `agoFrom` answers with a DURATION for everything except the newest case,
121
+ * where it answers with the words "just now" — and both callers appended
122
+ * " ago", so the most common row in the listing, a daemon that polled four
123
+ * hundred milliseconds ago, read "heard just now ago". On the serving box. On
124
+ * the first line anybody looks at.
125
+ *
126
+ * Stated once here rather than fixed twice: a duration takes the suffix, a
127
+ * phrase does not, and "LAST heard" is the staleness mark the whole listing
128
+ * turns on. An unmeasurable stamp says "never heard" rather than printing a
129
+ * duration nobody took.
130
+ */
131
+ export function heardPhrase(box, now = Date.now()) {
132
+ const ago = agoFrom(box?.lastHeardAt, now);
133
+ if (!ago) return 'never heard';
134
+ const lead = box?.fresh ? 'heard' : 'last heard';
135
+ return ago === JUST_NOW ? `${lead} ${ago}` : `${lead} ${ago} ago`;
136
+ }
137
+
138
+ /**
139
+ * THE SERVER'S OWN ROLE WORD, PRINTED. A pure relay and nothing else.
140
+ *
141
+ * It carried one special case until 2026-09-21: `'standing-by'` was respelled
142
+ * as `standing by`, because the server sent a hyphenated enum value and a
143
+ * terminal should not print an identifier. The owner replaced that word —
144
+ * asked whether a box should keep saying "standing by", he answered *"no, it
145
+ * can [be] inactive instead"* — so the server sends `serving` and `inactive`,
146
+ * both of which are already words, and the special case had nothing left to
147
+ * translate.
148
+ *
149
+ * IT IS NOT KEPT "IN CASE": a mapping table in a relay is a place for the two
150
+ * ends to disagree, and a stale entry would print one thing while the app
151
+ * printed another about the same box — which is precisely the confusion this
152
+ * command exists to end. A role this build has never heard of passes through
153
+ * untouched for the same reason.
154
+ *
155
+ * An ABSENT role prints nothing rather than a guess. It used to default to
156
+ * `idle`, which is a CLAIM — "the server told us this box is doing nothing" —
157
+ * about an answer the server did not give.
158
+ */
159
+ function roleWord(role) {
160
+ return typeof role === 'string' && role.trim() ? role.trim() : '';
161
+ }
162
+
163
+ /** One box, as a line under its project. The MARK is the only derived thing on
164
+ * it — a filled dot for a box the server still calls fresh, hollow for one it
165
+ * does not — and the words beside it are all the server's or the box's own. */
166
+ export function renderBox(box, { me = null, latest = null } = {}, now = Date.now()) {
167
+ const mark = box.fresh ? '●' : '○';
168
+ // SCRUBBED HERE TOO (2026-09-24, the audit) — a belt against the server's
169
+ // own display scrub, in defence of a stale stored row or a server this
170
+ // fix has not reached: the terminal is where an escape sequence in a
171
+ // box's name or its checkout path actually does harm, so this is the one
172
+ // place it must never be trusted to have already been cleaned. See
173
+ // printable.mjs.
174
+ const name = safeName(box.boxName) ?? 'an unnamed machine';
175
+ // "LAST heard" is the staleness mark, and the row is never hidden for it —
176
+ // the owner's answer to "what about a box that stopped" was exactly this.
177
+ const heard = heardPhrase(box, now);
178
+ // An unreported role DROPS ITS CELL. Every other cell on this row states its
179
+ // own absence ("(checkout not reported)") because a reader who cannot see a
180
+ // path needs to know nobody measured one; a role is different — the row is
181
+ // already carrying the mark, the version and the last-heard, and a
182
+ // placeholder there would be four words of nothing in the widest column.
183
+ const cells = [
184
+ `${mark} ${name}`,
185
+ roleWord(box.role),
186
+ safeName(box.checkoutPath) ?? '(checkout not reported)',
187
+ box.daemonVersion ? `v${box.daemonVersion}` : 'version not reported',
188
+ heard,
189
+ ].filter(Boolean);
190
+ let line = ` ${cells.join(' ')}`;
191
+ if (me && box.boxId === me) line += ' ← this box';
192
+ // Said only when the server said it, and it names the version that is
193
+ // actually published rather than a feature floor — a floor may sit above
194
+ // LATEST on purpose, and an instruction to install something npm does not
195
+ // serve is worse than silence.
196
+ if (box.behind && latest) line += ` · behind, ${latest} is latest`;
197
+ return line;
198
+ }
199
+
200
+ /**
201
+ * THE WHOLE LISTING, as lines. Pure over (entries, results, now).
202
+ *
203
+ * `results` is keyed by projectId and each value is one of:
204
+ * { boxes, latest, me } the server answered
205
+ * { rejected: true } 401/403 — the credential is dead here
206
+ * { unsupported: true } 404 — an older server with no boxes route
207
+ * { error: '<words>' } anything else, relayed rather than summarised
208
+ * A project with no entry in `results` is treated as unreachable, because that
209
+ * is the only thing an absent answer can honestly mean.
210
+ */
211
+ export function renderMachines(entries, results, { now = Date.now() } = {}) {
212
+ const lines = [];
213
+ // WHICH LABELS COLLIDE. Two projects genuinely called the same thing is the
214
+ // case that produced this command, so the id carries it — and it is printed
215
+ // on EVERY row rather than only the colliding ones, because a reader who has
216
+ // to notice a suffix to know whether it matters has already been asked to do
217
+ // the work this listing is for.
218
+ for (const e of entries) {
219
+ const label = projectLabel(e);
220
+ const connected = connectedOn(e.savedAt);
221
+ const id = `(id ${e.projectId.slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
222
+ lines.push(
223
+ ` ${label} · ${e.repoRoot || 'not bound to a repo yet'} ${id}`
224
+ );
225
+ const r = results?.[e.projectId];
226
+ if (!r || r.error) {
227
+ // The failure is RELAYED, not summarised, and the loop carries on: one
228
+ // unreachable project must not cost you the listing for the others.
229
+ lines.push(` could not ask the server — ${r?.error ?? 'no answer'}`);
230
+ continue;
231
+ }
232
+ if (r.rejected) {
233
+ lines.push(
234
+ ' credential rejected — this project was disconnected or deleted. ' +
235
+ `\`${terminalCommand(`machines --forget ${e.projectId.slice(0, 8)}`)}\` removes it here.`
236
+ );
237
+ continue;
238
+ }
239
+ if (r.unsupported) {
240
+ lines.push(' server does not report machines yet');
241
+ continue;
242
+ }
243
+ const boxes = Array.isArray(r.boxes) ? r.boxes : [];
244
+ if (boxes.length === 0) {
245
+ // NOT "no machines". Nothing has polled this credential, which is a
246
+ // different fact from a project whose machines have all gone quiet — and
247
+ // a quiet one still has rows, wearing its last-heard.
248
+ lines.push(' (no machine has polled this project)');
249
+ continue;
250
+ }
251
+ for (const b of boxes) lines.push(renderBox(b, { me: r.me, latest: r.latest }, now));
252
+ }
253
+ const collisions = renderCollisions(entries);
254
+ if (collisions.length) lines.push('', ...collisions);
255
+ return lines;
256
+ }
257
+
258
+ /** A project as ONE phrase for a sentence that names two of them — the name,
259
+ * the id, and the connected date, which is the fact a person can match
260
+ * against their own memory ("I set that one up last month"). */
261
+ export function projectPhrase(e) {
262
+ const connected = connectedOn(e?.savedAt);
263
+ return `${projectLabel(e)} (${String(e?.projectId ?? '').slice(0, 8)}…${connected ? `, connected ${connected}` : ''})`;
264
+ }
265
+
266
+ /**
267
+ * TWO PROJECTS ON ONE REPO, SAID OUT LOUD (2026-09-23).
268
+ *
269
+ * The owner ran this command, saw "BRIF AI" twice, and concluded he had two
270
+ * daemons for one project. The id on each row said they were different
271
+ * projects; nothing said they were bound to the SAME checkout, which is the
272
+ * fact that makes it a duplicate rather than two rows. So the listing ends
273
+ * with one line per such repo, naming every project on it in full, the rule
274
+ * (a directory serves one project — the owner's 2026-09-23 ruling, which made
275
+ * the start there a refusal), and the remedy in his order: delete the project
276
+ * in Flowviant, or disconnect this box from it (the menu below, or the flags).
277
+ *
278
+ * Pure over the entries. An unbound project collides with nothing.
279
+ */
280
+ export function renderCollisions(entries) {
281
+ return repoCollisions(entries).map((g) => {
282
+ const names = g.entries.map(projectPhrase);
283
+ const list = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
284
+ return (
285
+ ` ! ${g.entries.length} projects are connected for ${g.repoRoot}: ${list} — ` +
286
+ `a directory serves one project, and \`${launchCommand()}\` there refuses to start until it does. ` +
287
+ 'Delete the one you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it.'
288
+ );
289
+ });
290
+ }
291
+
292
+ /**
293
+ * THE FOOTER. Two sentences, both of which exist because this listing is the
294
+ * moment somebody is confused — the same reason `flowviant projects` names its
295
+ * remedies inline rather than in a manual.
296
+ *
297
+ * The first says where the verbs are, because there are none here. The second
298
+ * states this command's own SCOPE, which is the one thing a reader cannot see
299
+ * from the output: it lists what is connected on THIS box, and a daemon running
300
+ * on a computer that has never met this one is invisible to it. The app's Home
301
+ * is where "all of them" lives, and pretending otherwise would be this listing
302
+ * quietly answering a narrower question than the one asked.
303
+ */
304
+ export const MACHINES_FOOTER = [
305
+ ' stop a machine from the app: project settings → Machine → Disconnect;',
306
+ ` a new \`${launchCommand()}\` on another box takes the project over.`,
307
+ ' this lists the projects connected on THIS box; the app’s Home lists every machine on your account.',
308
+ ];
309
+
310
+ /** THE SCRIPT'S VERBS, said only when there was no menu to offer them in — a
311
+ * terminal that could draw the menu was offered the same two things by name,
312
+ * and repeating the flags under it would be a manual under a control. */
313
+ export const MACHINES_FLAGS_FOOTER = [
314
+ ` \`${terminalCommand('machines --remove <id>')}\` disconnects this box from a project: stops its daemon here,`,
315
+ ' removes this box from that project’s machines list in the app, and forgets its credential here.',
316
+ ' `--forget <id>` forgets the credential here only.',
317
+ ];
318
+
319
+ /**
320
+ * THE OTHER BOXES ON THIS PROJECT, as ONE line for the daemon's startup — or
321
+ * null when there are none, which is the common case and must print nothing.
322
+ *
323
+ * Said ONCE per process, after the first successful poll, and never per poll:
324
+ * the fact only changes when somebody starts a daemon somewhere else, and a
325
+ * true sentence restated every ten seconds is a scrolling console nobody reads.
326
+ * THE CALLING BOX IS EXCLUDED — it is the one box the person at that terminal
327
+ * does not need to be told about.
328
+ */
329
+ export function otherBoxesLine(boxes, me, now = Date.now()) {
330
+ const others = (Array.isArray(boxes) ? boxes : []).filter((b) => b && b.boxId !== me);
331
+ if (others.length === 0) return null;
332
+ const parts = others.slice(0, 6).map((b) => {
333
+ const role = roleWord(b.role);
334
+ return `${safeName(b.boxName) ?? 'an unnamed machine'} (${role ? `${role} · ` : ''}${heardPhrase(b, now)})`;
335
+ });
336
+ const more = others.length - parts.length;
337
+ return `other machines on this project: ${parts.join(', ')}${more > 0 ? `, and ${more} more` : ''}`;
338
+ }