flowviant 0.103.0 → 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 (140) 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 +183 -2383
  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 +2 -2
  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/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
package/bin/cli.mjs CHANGED
@@ -64,9 +64,12 @@
64
64
  * inside the git repo you want worked. `gh` is optional.
65
65
  *
66
66
  * Implementation lives in ./lib/: config, ui, preflight, install, update,
67
- * instance, login, mcp-cli; fleet (the roster loop) and work (session turns);
67
+ * instance, login, mcp-cli; startProject (which project a start serves) and
68
+ * machinesCommand (the `machines` listing and menu); fleet (the roster loop)
69
+ * and work (session turns);
68
70
  * claude + runtimes + prompts + stream (spawning a CLI and reading its events);
69
- * git + worktreeDiff + patch; localSessions, listeners, preview + authproxy;
71
+ * git + worktreeDiff + patch; localSessions (+ claudeSessions, agySessions), listeners, preview + authproxy
72
+ * (+ previewGatePolicy, previewFramePolicy);
70
73
  * env (the box keypair, the uplink scrubber, the env-comparison scan) + vault
71
74
  * (the knowledge wiki, not secrets), resources, deploy, shot.
72
75
  */
@@ -81,9 +84,11 @@ if (process.argv[2] === 'start' && !process.argv.slice(3).some((arg) => arg ===
81
84
 
82
85
  // Resolve --dir before config reads the project bound to cwd. A tray process
83
86
  // starts outside the checkout; its explicit folder is the daemon's checkout.
87
+ // Which words work on the checkout is the command table's (commandSpecs.mjs,
88
+ // import-free, so it is safe to read before config.mjs loads).
89
+ const { commandUsesCheckout, findCommand } = await import('./lib/commandSpecs.mjs');
84
90
  const dirAt = process.argv.indexOf('--dir');
85
- const noCheckoutCommand = new Set(['--version', '-v', 'version', 'status', 'stop', 'uninstall', 'machines', 'projects', 'update', 'shot', 'clean', 'gh-auth', 'mcp', 'help', '--help', '-h']);
86
- if (dirAt >= 0 && !noCheckoutCommand.has(process.argv[2])) {
91
+ if (dirAt >= 0 && commandUsesCheckout(process.argv[2])) {
87
92
  const dir = process.argv[dirAt + 1];
88
93
  try {
89
94
  if (!dir || dir.startsWith('--')) throw new Error('missing directory');
@@ -96,7 +101,7 @@ if (dirAt >= 0 && !noCheckoutCommand.has(process.argv[2])) {
96
101
  process.exit(1);
97
102
  }
98
103
  }
99
- const { FLEET_TOKEN, CREDENTIAL, VERSION, adoptStoredCredential } = await import('./lib/config.mjs');
104
+ const { CREDENTIAL, VERSION } = await import('./lib/config.mjs');
100
105
  const { runFleetDaemon } = await import('./lib/fleet.mjs');
101
106
  const { runLogin } = await import('./lib/login.mjs');
102
107
  const { launchCommand, terminalCommand } = await import('./lib/launchCommand.mjs');
@@ -120,18 +125,18 @@ if (process.argv.includes('--remote') && !(process.argv[2] === 'status' && proce
120
125
  // long-running process.
121
126
  // `flowviant --version` prints and exits — the first thing a person runs after
122
127
  // a curl install, and it must never start a daemon.
123
- if (process.argv[2] === '--version' || process.argv[2] === '-v' || process.argv[2] === 'version') {
128
+ if (findCommand(process.argv[2])?.name === 'version') {
124
129
  console.log(VERSION);
125
130
  process.exit(0);
126
131
  }
127
132
 
128
133
  // `flowviant help`, `--help`/`-h`, and `<command> --help`. Before any command
129
- // runs, so asking about one never runs it (bin/lib/help.mjs has the table).
134
+ // runs, so asking about one never runs it (bin/lib/commandSpecs.mjs has the table).
130
135
  {
131
136
  const asksHelp = (arg) => arg === '--help' || arg === '-h';
132
137
  const first = process.argv[2];
133
138
  if (first === 'help' || asksHelp(first) || process.argv.slice(3).some(asksHelp)) {
134
- const { findCommand, renderCommandHelp, renderHelp, unknownCommandMessage } = await import('./lib/help.mjs');
139
+ const { renderCommandHelp, renderHelp, unknownCommandMessage } = await import('./lib/help.mjs');
135
140
  const topic = first === 'help' ? process.argv[3] : asksHelp(first) ? undefined : first?.startsWith('-') ? 'start' : first;
136
141
  if (!topic) { console.log(renderHelp(VERSION)); process.exit(0); }
137
142
  const cmd = findCommand(topic);
@@ -141,7 +146,7 @@ if (process.argv[2] === '--version' || process.argv[2] === '-v' || process.argv[
141
146
  }
142
147
  }
143
148
 
144
- if (process.argv[2] === 'login') {
149
+ async function loginCommand() {
145
150
  const noStart = process.argv.includes('--no-start');
146
151
  const json = process.argv.includes('--json');
147
152
  const login = await runLogin({ thenStart: !noStart, json, dir: process.cwd() });
@@ -169,17 +174,18 @@ if (process.argv[2] === 'login') {
169
174
  process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
170
175
  }
171
176
 
172
- if (process.argv[2] === 'status' && process.argv.includes('--json')) {
173
- // The tray polls this every 30 s: the fixed per-user bin dirs only, no shell.
174
- await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess({ shell: false });
175
- const { desktopStatus, desktopStatusRemote } = await import('./lib/desktopContract.mjs');
176
- const status = process.argv.includes('--remote') ? await desktopStatusRemote() : desktopStatus();
177
- process.stdout.write(`${JSON.stringify(status)}\n`);
178
- process.exit(0);
179
- }
180
-
181
- // `flowviant status` — the same facts, for a person.
182
- if (process.argv[2] === 'status') {
177
+ // `flowviant status` — each project connected here. `--json` is the tray's
178
+ // form (and `--remote` adds the server's answer); plain is the same facts, for
179
+ // a person.
180
+ async function statusCommand() {
181
+ if (process.argv.includes('--json')) {
182
+ // The tray polls this every 30 s: the fixed per-user bin dirs only, no shell.
183
+ await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess({ shell: false });
184
+ const { desktopStatus, desktopStatusRemote } = await import('./lib/desktopContract.mjs');
185
+ const status = process.argv.includes('--remote') ? await desktopStatusRemote() : desktopStatus();
186
+ process.stdout.write(`${JSON.stringify(status)}\n`);
187
+ process.exit(0);
188
+ }
183
189
  await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
184
190
  const { runStatus } = await import('./lib/views.mjs');
185
191
  await runStatus();
@@ -187,21 +193,21 @@ if (process.argv[2] === 'status') {
187
193
  }
188
194
 
189
195
  // `flowviant logs [-f]` — this repo's daemon log.
190
- if (process.argv[2] === 'logs') {
196
+ async function logsCommand() {
191
197
  const { runLogs } = await import('./lib/views.mjs');
192
198
  await runLogs(process.argv);
193
199
  process.exit(0);
194
200
  }
195
201
 
196
202
  // `flowviant open` — this repo's board, in the browser.
197
- if (process.argv[2] === 'open') {
203
+ async function openCommand() {
198
204
  const { runOpen } = await import('./lib/views.mjs');
199
205
  runOpen(process.argv);
200
206
  process.exit(0);
201
207
  }
202
208
 
203
209
  // `flowviant doctor` — what this computer needs, checked.
204
- if (process.argv[2] === 'doctor') {
210
+ async function doctorCommand() {
205
211
  await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
206
212
  const { runDoctor } = await import('./lib/views.mjs');
207
213
  process.exit((await runDoctor()) > 0 ? 1 : 0);
@@ -209,7 +215,7 @@ if (process.argv[2] === 'doctor') {
209
215
 
210
216
  // `flowviant update` — install the latest published version now. The daemon also
211
217
  // self-updates on its own (at startup + when idle); this is the manual path.
212
- if (process.argv[2] === 'update') {
218
+ async function updateCommand() {
213
219
  const { runUpdateCommand } = await import('./lib/update.mjs');
214
220
  await runUpdateCommand();
215
221
  process.exit(0);
@@ -217,7 +223,7 @@ if (process.argv[2] === 'update') {
217
223
 
218
224
  // `flowviant gh-auth` — sign in the gh CLI (incl. a copy we bundled into
219
225
  // ~/.flowviant/bin), so the isolated install doesn't need gh on your global PATH.
220
- if (process.argv[2] === 'gh-auth') {
226
+ async function ghAuthCommand() {
221
227
  const { addLocalBinToPath } = await import('./lib/install.mjs');
222
228
  const { execFileSync } = await import('node:child_process');
223
229
  addLocalBinToPath();
@@ -233,13 +239,13 @@ if (process.argv[2] === 'gh-auth') {
233
239
  // They're kept across runs so in-flight work survives Ctrl+C; this is the drain.
234
240
  // Repos self-heal: the daemon runs `git worktree prune` if a stale registration
235
241
  // blocks re-adding a path.
236
- if (process.argv[2] === 'clean') {
242
+ async function cleanCommand() {
237
243
  const { rmSync, existsSync } = await import('node:fs');
238
244
  const { join } = await import('node:path');
239
245
  const { homedir } = await import('node:os');
240
246
  const { execFileSync } = await import('node:child_process');
241
247
  // Also reap any preview dev-server/tunnel groups a crashed daemon left running.
242
- const { reapOrphanPreviews } = await import('./lib/preview.mjs');
248
+ const { reapOrphanPreviews } = await import('./lib/previewRegistry.mjs');
243
249
  reapOrphanPreviews((m) => console.log(m));
244
250
  const dir = join(homedir(), '.flowviant', 'worktrees');
245
251
  if (!existsSync(dir)) {
@@ -263,7 +269,7 @@ if (process.argv[2] === 'clean') {
263
269
  // page. A session's agent shells out to this to SEE the change it just made.
264
270
  // Self-contained + graceful (no browser → exit 1, and the agent carries on in
265
271
  // text); needs no credential, so it runs before the auth gate.
266
- if (process.argv[2] === 'shot') {
272
+ async function shotCommand() {
267
273
  const { runShot } = await import('./lib/shot.mjs');
268
274
  await runShot(process.argv.slice(3));
269
275
  process.exit(0);
@@ -287,7 +293,7 @@ if (process.argv[2] === 'shot') {
287
293
  // EXIT 0 when it stopped something AND when it found nothing: "no flowviant
288
294
  // daemon is running on this machine." is the answer the asker came for, not an
289
295
  // error. Non-zero only when something was alive and could not be stopped.
290
- if (process.argv[2] === 'stop') {
296
+ async function stopCommand() {
291
297
  const { stopAllDaemons, stopDaemonFor } = await import('./lib/instance.mjs');
292
298
  let result;
293
299
  if (process.argv.includes('--project')) {
@@ -301,7 +307,7 @@ if (process.argv[2] === 'stop') {
301
307
  process.exit(failed > 0 ? 1 : 0);
302
308
  }
303
309
 
304
- if (process.argv[2] === 'uninstall') {
310
+ async function uninstallCommand() {
305
311
  const { runUninstall } = await import('./lib/uninstall.mjs');
306
312
  const { askWithTimeout } = await import('./lib/tty.mjs');
307
313
  const result = await runUninstall({
@@ -319,8 +325,9 @@ if (process.argv[2] === 'uninstall') {
319
325
  // network: it reads the store, which is the exact thing a confused person is
320
326
  // trying to see. The remedies are named because this listing IS the moment of
321
327
  // confusion ("why did it say skadooble?"), not documentation.
322
- if (process.argv[2] === 'projects') {
328
+ async function projectsCommand() {
323
329
  const { listStoredProjects, projectLabel } = await import('./lib/credentials.mjs');
330
+ const { connectedOn } = await import('./lib/credentialDate.mjs');
324
331
  const entries = listStoredProjects();
325
332
  if (entries.length === 0) {
326
333
  console.log(`no projects connected on this machine yet — run \`${terminalCommand('login')}\` inside a repo.`);
@@ -332,11 +339,8 @@ if (process.argv[2] === 'projects') {
332
339
  // did I set up last month" is the same question one step on. Absent when
333
340
  // nothing recorded one (a credential stored before the field existed);
334
341
  // nothing is invented.
335
- const savedOn = e.savedAt ? new Date(e.savedAt) : null;
336
- const connected =
337
- savedOn && !Number.isNaN(savedOn.getTime())
338
- ? `, connected ${savedOn.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
339
- : '';
342
+ const on = connectedOn(e.savedAt);
343
+ const connected = on ? `, connected ${on}` : '';
340
344
  console.log(
341
345
  ` ${projectLabel(e)} (${e.projectId.slice(0, 8)}…${connected})` +
342
346
  `${e.repoRoot ? `\n repo · ${e.repoRoot}` : '\n repo · not bound yet — first start or login in its repo binds it'}` +
@@ -350,192 +354,12 @@ if (process.argv[2] === 'projects') {
350
354
  process.exit(0);
351
355
  }
352
356
 
353
- // ── `flowviant machines` — every project connected on THIS box, and every box
354
- // that has polled each of them; and, on a terminal, a menu over them.
355
- //
356
- // A THIRD COMMAND, and the owner's ruling it amends is his own: the terminal
357
- // surface is `npx flowviant` and `npx flowviant login`, full stop — which is
358
- // what deleted the old terminal flag for moving a machine — and he asked for
359
- // this one directly ("the CLI gets a command to list connections"). It shipped
360
- // VIEW-ONLY, and on 2026-09-23 he asked for the rest: "it seems to be only a
361
- // read cmd but I want to be able to interact with it and use arrow keys to
362
- // select the machines or to remove them." So: the listing, then — only where a
363
- // person can drive one — a menu. Its verbs act on THIS BOX'S OWN CONNECTIONS
364
- // (stop the daemon here, leave the project's list in the app, forget the
365
- // credential here) and never on another computer's daemon; machines.mjs
366
- // carries the argument.
367
- //
368
- // The question is the owner's, verbatim: "is there a way to view ALL the
369
- // connected flowviants? because im not sure if i have any duplicate or
370
- // redundant daemons running". It needs the NETWORK, unlike `projects` next
371
- // door, because the boxes are the server's answer — this box cannot see a
372
- // daemon running on somebody else's computer, and guessing would be worse than
373
- // asking.
374
- //
375
- // ONE CALL PER STORED CREDENTIAL, because the CLI has NO PERSON IDENTITY: every
376
- // request it can make is `Bearer <machine token>`, so "all my machines" has to
377
- // be assembled from the credentials this box holds. That is also the limit the
378
- // footer states out loud — the app's Home is where the whole account's answer
379
- // lives.
380
- //
381
- // EXIT 0 whatever it finds, including nothing: the listing is the answer the
382
- // asker came for, and a non-zero code over "you have no projects connected"
383
- // would make this command unusable in anything that checks one. `--remove`
384
- // is the one exit that can be 1, when the daemon it had to stop would not.
385
- if (process.argv[2] === 'machines') {
386
- const creds = await import('./lib/credentials.mjs');
387
- const machineJson = process.argv.includes('--json');
388
- const forgetAt = process.argv.indexOf('--forget');
389
- if (forgetAt >= 0) {
390
- if (machineJson && !process.argv.includes('--yes')) {
391
- process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
392
- process.exit(1);
393
- }
394
- // THE LOCAL-ONLY WRITE. Named explicitly, matched by full id or a ≥6
395
- // prefix, and AMBIGUITY REFUSES rather than guessing — this deletes a
396
- // credential, and two projects that look alike is the case that produced
397
- // the command.
398
- const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
399
- if (res.error) {
400
- if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: res.error })}\n`); process.exit(1); }
401
- console.error(`error: ${res.error}`);
402
- process.exit(1);
403
- }
404
- if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: true, action: 'forget', projectId: res.entry.projectId })}\n`); process.exit(0); }
405
- console.log(
406
- `forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box.\n` +
407
- ` Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
408
- );
409
- process.exit(0);
410
- }
411
- const { boxesUrlFrom, leaveUrlFrom, fetchBoxesFor, renderMachines, disconnectHere, realDisconnectDeps, MACHINES_FOOTER, MACHINES_FLAGS_FOOTER } =
412
- await import('./lib/machines.mjs');
413
- const { FLEET_URL } = await import('./lib/config.mjs');
414
- const removeAt = process.argv.indexOf('--remove');
415
- if (removeAt >= 0) {
416
- if (machineJson && !process.argv.includes('--yes')) {
417
- process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
418
- process.exit(1);
419
- }
420
- // THE SCRIPTED DISCONNECT — the menu's first verb, named by id for a
421
- // terminal nobody is sitting at. Same matcher and the same refusal of an
422
- // ambiguous prefix as --forget: a name two projects share is exactly the
423
- // input this command exists to untangle, and it must not pick one.
424
- const m = creds.matchStoredProject(process.argv[removeAt + 1]);
425
- if (m.error) {
426
- if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: m.error })}\n`); process.exit(1); }
427
- console.error(`error: ${m.error}. \`${terminalCommand('machines')}\` lists what is stored.`);
428
- process.exit(1);
429
- }
430
- const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log: machineJson ? () => {} : undefined }), { log: machineJson ? () => {} : undefined });
431
- if (machineJson) process.stdout.write(`${JSON.stringify({ ok: res.ok, action: 'disconnect', projectId: m.entry.projectId })}\n`);
432
- process.exit(res.ok ? 0 : 1);
433
- }
434
-
435
- let entries = creds.listStoredProjects();
436
- if (entries.length === 0) {
437
- console.log(`no projects connected on this machine yet — run \`${terminalCommand('login')}\` inside a repo.`);
438
- process.exit(0);
439
- }
440
- // OUR OWN BOX ID, so the listing can mark "← this box".
441
- //
442
- // READ, NEVER CREATED. This called `ensureKeypair()` until 2026-09-19, which
443
- // MINTS a keypair when there is no file — a 0600 write establishing this
444
- // machine's durable identity, performed by a command whose entire job is to
445
- // print a list. A view-only command is what let a third terminal command
446
- // exist at all, and enrolling a box as a side effect of looking at one is not
447
- // that. `readStoredPubB64` reads or answers null.
448
- //
449
- // NULL COSTS ONLY THE MARK, and that is the honest outcome: a box that has
450
- // never run a daemon has never polled, so it is not in this listing to be
451
- // marked in the first place.
452
- let me;
453
- try {
454
- const env = await import('./lib/env.mjs');
455
- me = env.readStoredPubB64() ?? undefined;
456
- } catch {
457
- /* unreadable keypair — nothing is marked, and nothing is claimed */
458
- }
459
- const url = boxesUrlFrom(FLEET_URL);
460
- const listing = async () => {
461
- const results = {};
462
- // SEQUENTIAL, not a fan-out: this is a handful of credentials on somebody's
463
- // laptop, and a parallel burst against the API buys nothing a person
464
- // waiting two seconds can perceive.
465
- for (const e of entries) {
466
- results[e.projectId] = await fetchBoxesFor(e, { url, envpub: me });
467
- }
468
- console.log('');
469
- for (const line of renderMachines(entries, results)) console.log(line);
470
- console.log('');
471
- };
472
- await listing();
473
-
474
- // THE MENU, only where a person can drive one. `canPrompt()` is the same
475
- // gate the start path keeps — a backgrounded job or a pipe gets the listing
476
- // and the flags, never a prompt that stops the process — and `menuSupported`
477
- // is whether ↑/↓ can be read at all. Without both this is the command it was.
478
- const { canPrompt, menuSupported, selectMenu, askWithTimeout } = await import('./lib/tty.mjs');
479
- if (!(canPrompt() && menuSupported())) {
480
- for (const line of MACHINES_FOOTER) console.log(line);
481
- for (const line of MACHINES_FLAGS_FOOTER) console.log(line);
482
- process.exit(0);
483
- }
484
- const deps = await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) });
485
- for (;;) {
486
- // A ROW PER PROJECT connected on this box — the things this box can act on.
487
- // The other computers under each project are printed above for the
488
- // duplicate to be visible, and are not rows here: stopping or removing
489
- // THEM is the app's verb, and a menu row that answers "not from here" is
490
- // a control wired to a refusal.
491
- console.log(' projects connected on this box — enter one for what you can do about it:');
492
- const rowLabel = (e) => creds.projectRowLabel(e, entries) + (e.repoRoot ? ` — ${e.repoRoot}` : ' — not tied to a repo');
493
- const pick = await selectMenu({ options: [...entries.map(rowLabel), 'done'] });
494
- if (pick.cancelled || pick.unsupported || pick.index === entries.length) break;
495
- const picked = entries[pick.index];
496
- const who = creds.projectRowLabel(picked, entries);
497
- // TWO VERBS, each row saying its whole consequence — the app's ⋯ menu on a
498
- // machine row says the same two things, and a menu whose rows are verbs
499
- // with the outcome in a manual somewhere is one you have to be sure about
500
- // before you can use it.
501
- const action = await selectMenu({
502
- options: [
503
- `disconnect this box from ${who} — stops its daemon here, removes this box from its machines list in the app, forgets its credential here`,
504
- `forget ${who} here only — nothing is stopped; the app keeps listing this box until it goes quiet`,
505
- 'back',
506
- ],
507
- });
508
- if (action.cancelled || action.unsupported || action.index === 2) continue;
509
- // ONE CONFIRM, in words, because both verbs delete a credential and the
510
- // arrow keys make a slip cheap. A read that failed (stdin gone) is "no".
511
- const verb = action.index === 0 ? 'Disconnect this box from' : 'Forget';
512
- const answer = await askWithTimeout(` ${verb} ${who}? [y/N] `);
513
- if (!/^y(es)?$/i.test(answer ?? '')) {
514
- console.log(' left as it was.\n');
515
- continue;
516
- }
517
- if (action.index === 0) {
518
- await disconnectHere(picked, deps, { log: (m) => console.log(` ${m}`) });
519
- } else {
520
- const res = creds.forgetStoredProject(picked.projectId);
521
- console.log(
522
- res.error
523
- ? ` ${res.error}`
524
- : ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
525
- );
526
- }
527
- entries = creds.listStoredProjects();
528
- if (entries.length === 0) {
529
- console.log('\n no projects are connected on this box now.\n');
530
- break;
531
- }
532
- // THE LISTING AGAIN, re-asked: the server's rows are the only true account
533
- // of what the leave did, and a menu redrawn over a stale listing would be
534
- // this command asserting the outcome it hoped for.
535
- await listing();
536
- }
537
- for (const line of MACHINES_FOOTER) console.log(line);
538
- process.exit(0);
357
+ // `flowviant machines` — every project connected on THIS box, every box that
358
+ // has polled each of them, and on a terminal a menu over them. The command's
359
+ // body and its argument live in machinesCommand.mjs.
360
+ async function machinesCommand() {
361
+ const { runMachinesCommand } = await import('./lib/machinesCommand.mjs');
362
+ await runMachinesCommand();
539
363
  }
540
364
 
541
365
  // `flowviant env <import|set|show>` IS DELETED (2026-09-21), with the
@@ -550,12 +374,41 @@ if (process.argv[2] === 'machines') {
550
374
  // the terminal. Mints a `cli` credential: a separate principal from the
551
375
  // per-session tokens, with only the management tools and no way to work or ship
552
376
  // a card.
553
- if (process.argv[2] === 'mcp') {
377
+ async function mcpCommand() {
554
378
  const { runMcpCommand } = await import('./lib/mcp-cli.mjs');
555
379
  await runMcpCommand(process.argv.slice(3));
556
380
  process.exit(0);
557
381
  }
558
382
 
383
+ // ── DISPATCH: one handler per row of the command table (commandSpecs.mjs).
384
+ // `version` and `help` were answered above and `start` is everything below;
385
+ // help.test.mjs pins that every other row has exactly one handler here.
386
+ const HANDLERS = {
387
+ login: loginCommand,
388
+ status: statusCommand,
389
+ logs: logsCommand,
390
+ open: openCommand,
391
+ doctor: doctorCommand,
392
+ update: updateCommand,
393
+ 'gh-auth': ghAuthCommand,
394
+ clean: cleanCommand,
395
+ shot: shotCommand,
396
+ stop: stopCommand,
397
+ uninstall: uninstallCommand,
398
+ projects: projectsCommand,
399
+ machines: machinesCommand,
400
+ mcp: mcpCommand,
401
+ };
402
+ {
403
+ const handler = HANDLERS[findCommand(process.argv[2])?.name];
404
+ if (handler) {
405
+ await handler();
406
+ // Every handler exits on its own; a command never falls through into
407
+ // starting the daemon.
408
+ process.exit(0);
409
+ }
410
+ }
411
+
559
412
  // A WORD THAT IS NOT A COMMAND IS REFUSED, not served. Every word used to fall
560
413
  // through to here, so `flowviant hlep` started the daemon. Flags still reach
561
414
  // the start path (`--project`, `--dir`, `--json-events`, …).
@@ -569,255 +422,15 @@ if (process.argv[2] && !process.argv[2].startsWith('-')) {
569
422
  // this through `wsl.exe --`, which reads no shell startup file (loginPath.mjs).
570
423
  await (await import('./lib/loginPath.mjs')).adoptLoginPathForProcess();
571
424
 
572
- // ── WHICH PROJECT THIS START SERVES — said, asked, or refused; never guessed.
573
- //
574
- // The store holds many projects since 0.55.0 and resolution is BY REPO
575
- // (credentials.mjs). What is left here is the human half: an ambiguous store
576
- // on a TTY becomes a PICKER, a single unbound credential gets ONE confirm that
577
- // binds it, and a headless start with no unambiguous answer refuses in words
578
- // that name every stored project and every way out. The one thing this block
579
- // must never do is serve a project the resolution did not name — "it said
580
- // skadooble in my calendar repo" is the confusion this exists to end.
581
- // A RESTART IS NOT A PERSON. `reexec` (update.mjs) inherits stdio, so an
582
- // auto-updated daemon's child sees two TTYs; without this it would stop on the
583
- // binding confirm below and the machine would stay dark until somebody typed a
584
- // key. Same reasoning as the headless case, and the same answer.
585
- // `canPrompt()`, not a bare isTTY pair: a BACKGROUNDED job (`flowviant &`) has
586
- // two TTYs and cannot be asked anything — the first read raises SIGTTIN and the
587
- // kernel STOPS the process, which is why 0.55.2's timeout did not save it (a
588
- // stopped process runs no timers). See tty.mjs.
589
- const { canPrompt, askWithTimeout, selectMenu, menuSupported } = await import('./lib/tty.mjs');
590
- const interactive = canPrompt() && process.env.FLOWVIANT_REEXEC !== '1';
591
-
592
- /** NO ANSWER TIME LIMIT on the start path (2026-09-20, the owner: "why is
593
- * there an answer time limit, remove that"). The confirm below carried 20s
594
- * and the picker 60s, each arguing that a restart nobody is watching must
595
- * not sit dark on a prompt — but `interactive` already excludes exactly that
596
- * case (no foreground terminal, or a self-update re-exec, and nothing is
597
- * asked). What was left was a person reading a list being told "no answer
598
- * in 60s — nothing started". A prompt a person can see waits for the
599
- * person; tty.mjs's header carries the argument. */
600
- const externalToken =
601
- process.argv.includes('--fleet') ||
602
- Boolean(process.env.FLOWVIANT_MACHINE_TOKEN) ||
603
- Boolean(process.env.FLOWVIANT_FLEET);
604
-
605
- /** Re-exec a plain `flowviant` after an inline login — the login command's own
606
- * pattern: config.mjs read the store at IMPORT time, before the credential
607
- * existed, so this process cannot serve; the child can. */
608
- async function reexecAfterLogin() {
609
- const login = await runLogin({ thenStart: false });
610
- if (!login?.saved) process.exit(1); // refused: the directory already serves another project
611
- const { spawn } = await import('node:child_process');
612
- const child = spawn(process.execPath, process.argv[1]?.startsWith('/$bunfs/') ? [] : [process.argv[1]], { stdio: 'inherit', env: process.env });
613
- process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
614
- }
615
-
616
- // TWO ROWS THAT READ ALIKE ARE NOT A CHOICE. `projectRowLabel` adds the id and
617
- // the connected date to the COLLIDING rows only — production held two projects
618
- // both called "BRIF AI", both bound to the same checkout, so this picker
619
- // offered two identical lines and the only way to answer was to guess. See
620
- // credentials.mjs for why the suffix is not on every row. (That exact case is
621
- // a refusal now rather than a picker — a directory serves one project — but
622
- // two same-named projects can still meet in the no-match picker.)
623
- function listLines(entries, { projectRowLabel }) {
624
- return entries
625
- .map(
626
- (e, i) =>
627
- ` ${i + 1}. ${projectRowLabel(e, entries)}` +
628
- (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet')
629
- )
630
- .join('\n');
631
- }
632
-
633
- /** A repo binding the person just consented to, persisted by runFleetDaemon
634
- * only after the instance lock is taken — never before a refusal. */
635
- let afterLock = null;
636
-
637
- if (!FLEET_TOKEN) {
638
- if (CREDENTIAL.error) {
639
- console.error(`error: ${CREDENTIAL.error}. \`${terminalCommand('projects')}\` lists what is stored.`);
640
- process.exit(1);
641
- }
642
- // A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
643
- // directory cannot have more than one project on flowviant. if one already
644
- // exists, it would warn the user and ask them to delete the project on
645
- // flowviant first. because 2 projects shouldnt be able to edit a directory
646
- // at the same time."). This used to be a PICKER — two projects bound to one
647
- // checkout, choose which this daemon serves — which is a control offering to
648
- // do the forbidden thing politely. It is a refusal now, on a TTY and
649
- // headless alike, naming both projects and the remedy. `credentialRefusal`
650
- // is the same sentence login prints when it declines to bind a second one.
651
- if (CREDENTIAL.reason === 'multiple-bound') {
652
- const { directoryTakenRefusal } = await import('./lib/credentials.mjs');
653
- console.error(directoryTakenRefusal(CREDENTIAL.choices, CREDENTIAL.repoRoot));
654
- process.exit(1);
655
- }
656
- if (CREDENTIAL.choices?.length && interactive) {
657
- const creds = await import('./lib/credentials.mjs');
658
- const { originSlug } = await import('./lib/git.mjs');
659
- const { basename } = await import('node:path');
660
- const { choices, repoRoot } = CREDENTIAL;
661
- console.log(
662
- CREDENTIAL.reason === 'outside-repo'
663
- ? 'flowviant is not inside a git repo, and more than one project is connected here.'
664
- : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
665
- );
666
-
667
- const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (${terminalCommand('login')})`;
668
- // WHICH ONE LOOKS RIGHT — a pre-selection, never an auto-serve. The resolver
669
- // refuses to serve a project the repo PATH did not name (the skadooble law);
670
- // this only decides which row the cursor starts on, using the repo's folder
671
- // name and its github repo-name against the stored project names. A unique
672
- // match becomes ONE keypress; a wrong guess costs nothing, because the human
673
- // still confirms. (Two projects bound to THIS repo never reach here: that
674
- // is a refusal above, since 2026-09-23.)
675
- const slug = repoRoot ? originSlug(repoRoot) : null;
676
- const likely = creds.likelyChoiceIndex(choices, {
677
- repoBasename: repoRoot ? basename(repoRoot) : null,
678
- repoSlugName: slug ? slug.split('/')[1] : null,
679
- });
680
-
681
- // Bounded like the confirm below, and for the same reason — but silence
682
- // means something DIFFERENT here and the difference is load-bearing. There
683
- // is a real ambiguity to resolve; serving a guess is the skadooble bug.
684
- // So no answer REFUSES, which is exactly what this branch already does
685
- // headless, and the message says how to answer without being present.
686
- let chosen = null; // 0-based into [...choices, login]
687
- if (menuSupported()) {
688
- const rowLabel = (e) =>
689
- creds.projectRowLabel(e, choices) +
690
- (e.repoRoot ? ` — connected for ${e.repoRoot}` : ' — not tied to a repo yet');
691
- const options = [...choices.map(rowLabel), loginLabel];
692
- // Say WHY the cursor starts where it does — "intuitive" made visible.
693
- if (likely >= 0) options[likely] += ' ← looks like this repo';
694
- const res = await selectMenu({
695
- options,
696
- defaultIndex: likely >= 0 ? likely : 0,
697
- });
698
- if (res.cancelled) {
699
- console.error('nothing chosen — nothing started.');
700
- process.exit(1);
701
- }
702
- if (!res.unsupported) chosen = res.index;
703
- }
704
- if (chosen === null) {
705
- // Numeric fallback — a pipe, or a terminal without raw mode. Empty Enter
706
- // takes the likely default when there is one, so it is one keystroke here
707
- // too.
708
- console.log(listLines(choices, creds));
709
- console.log(` ${choices.length + 1}. ${loginLabel}`);
710
- const hint = likely >= 0 ? ` (enter for ${creds.projectLabel(choices[likely])})` : '';
711
- const raw = await askWithTimeout(
712
- `Which project should this daemon serve? [1-${choices.length + 1}]${hint} `
713
- );
714
- if (raw === null) {
715
- // The read itself failed — stdin closed, or the terminal went away
716
- // mid-question. Not a timeout: there is none.
717
- console.error(
718
- `\ncould not read an answer — nothing started. ` +
719
- `Name one with \`--project <name|id>\`, or run \`flowviant\` here in the foreground and pick.`
720
- );
721
- process.exit(1);
722
- }
723
- const n = raw === '' && likely >= 0 ? likely + 1 : Number.parseInt(raw, 10);
724
- chosen = Number.isInteger(n) ? n - 1 : -1;
725
- }
726
-
727
- if (chosen === choices.length) await reexecAfterLogin();
728
- const picked = chosen >= 0 ? choices[chosen] : undefined;
729
- if (!picked) {
730
- console.error('nothing chosen — nothing started.');
731
- process.exit(1);
732
- }
733
- // An answered question is consent: adopt it, and BIND it to this repo so
734
- // the next start needs no prompt. Repointing is legitimate and said aloud.
735
- if (repoRoot && picked.repoRoot && picked.repoRoot !== repoRoot) {
736
- console.log(`note: ${creds.projectLabel(picked)} was connected for ${picked.repoRoot} — now serving ${repoRoot} instead.`);
737
- }
738
- adoptStoredCredential(picked);
739
- // BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
740
- // the binding even when the instance lock then refused the start — a live
741
- // daemon for the same project in its own checkout — and that daemon's next
742
- // unattended self-update re-exec'd into a store that no longer bound its
743
- // repo, found no match headless, and exited with the machine dark.
744
- afterLock = () => creds.selectStoredProject(picked.projectId, { bindRepoRoot: repoRoot ?? undefined });
745
- console.log(`serving ${creds.projectLabel(picked)}${repoRoot ? ` from ${repoRoot}` : ''}.`);
746
- } else if (CREDENTIAL.choices?.length) {
747
- const creds = await import('./lib/credentials.mjs');
748
- console.error(
749
- 'error: more than one project is connected on this machine and this repo is not bound to any of them:\n' +
750
- listLines(CREDENTIAL.choices, creds) +
751
- `\nPick one with \`--project <name|id>\`, bind this repo by running \`${terminalCommand()}\` here in a terminal once,\n` +
752
- `or connect this repo to its own project with \`${terminalCommand('login')}\`.`
753
- );
754
- process.exit(1);
755
- } else {
756
- console.error(
757
- 'error: no credential found. Easiest:\n' +
758
- ` ${terminalCommand('login')} (approve in the app — recommended)\n` +
759
- 'Or set:\n' +
760
- ' FLOWVIANT_FLEET=fva_… (machine token, from the app)'
761
- );
762
- process.exit(1);
763
- }
764
- } else if (!externalToken && CREDENTIAL.needsConfirm && interactive) {
765
- // ONE stored project, never tied to a repo — the pre-0.55.0 world. Ask once;
766
- // yes binds and every later start is silent. This is the exact question
767
- // whose absence had a calendar checkout serving skadooble.
768
- //
769
- // AND IT TIMES OUT, because a prompt on a start path is a way for a machine
770
- // to go dark. `FLOWVIANT_REEXEC` above covers the restart THIS version
771
- // performs, but it cannot cover the one that matters most: the hop that
772
- // installs a fixed daemon is spawned by the OLD one, which never sets it.
773
- // 0.55.0 → 0.55.1 was exactly that — an auto-update landing unattended would
774
- // stop here with the machine serving nothing. A guard that only works once
775
- // everyone already has it is not a guard.
776
- //
777
- // IF THE READ FAILS WE SERVE, AND WE DO NOT BIND. (This used to say "on
778
- // timeout"; there is no timeout since 2026-09-20 — a person at the prompt
779
- // is waited for. `null` now means stdin closed under the question.) Those
780
- // are two decisions:
781
- // · SERVE, because it is what every version before 0.55.0 did with this
782
- // exact store, so the silent path is the status quo rather than a new
783
- // risk — and a daemon that answers is strictly better than one that does
784
- // not, which is the whole reason this product has exactly one refusal.
785
- // · DO NOT BIND, because binding is the thing the question was FOR. Nobody
786
- // answered, so nothing is cemented; the next human start asks again. That
787
- // keeps the skadooble case fixed for the person who is actually looking,
788
- // which is the only person it could ever have been fixed for.
789
- const creds = await import('./lib/credentials.mjs');
790
- const label = creds.projectLabel(CREDENTIAL.entry);
791
- const answered = await askWithTimeout(
792
- `This machine's one connected project is ${label}. Serve this repo (${CREDENTIAL.repoRoot}) as ${label}? [Y/n] `
793
- );
794
- const raw = answered === null ? null : answered.toLowerCase(); // null = the read failed
795
- if (raw === null) {
796
- console.log(
797
- `\n could not read an answer — serving ${label} for this run ` +
798
- `without tying it to this repo. Run \`${terminalCommand()}\` here and answer to make it stick.`
799
- );
800
- } else if (raw === '' || raw === 'y' || raw === 'yes') {
801
- // After the lock, for the reason the picker's bind gives above.
802
- afterLock = () => creds.bindStoredRepo(CREDENTIAL.entry.projectId, CREDENTIAL.repoRoot);
803
- } else {
804
- console.error(
805
- `nothing started. Connect this repo to its own project with \`${terminalCommand('login')}\`, ` +
806
- `or see what is stored with \`${terminalCommand('projects')}\`.`
807
- );
808
- process.exit(1);
809
- }
810
- }
811
-
812
- if (process.argv.includes('--json-events') && dirAt >= 0 && CREDENTIAL?.entry) {
813
- const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./lib/credentials.mjs');
814
- const repo = detectRepoRoot();
815
- const entry = CREDENTIAL.entry;
816
- if (!repo || entry.repoRoot !== repo || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
817
- console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
818
- process.exit(1);
819
- }
820
- }
821
- const { installDaemonLogging } = await import('./lib/desktopContract.mjs');
822
- installDaemonLogging(CREDENTIAL?.entry?.projectId, { jsonEvents: process.argv.includes('--json-events') });
425
+ // WHICH PROJECT THIS START SERVES — said, asked, or refused; never guessed.
426
+ // startProject.mjs owns the picker, the one binding confirm and the refusals;
427
+ // what it hands back is the binding the person consented to, deferred until
428
+ // runFleetDaemon holds the instance lock (a refused start moves nothing).
429
+ const { resolveStartProject } = await import('./lib/startProject.mjs');
430
+ const afterLock = await resolveStartProject({ dirGiven: dirAt >= 0 });
431
+
432
+ const { installDaemonLogging } = await import('./lib/daemonLogging.mjs');
433
+ const { daemonLogPath } = await import('./lib/daemonState.mjs');
434
+ const logProjectId = CREDENTIAL?.entry?.projectId;
435
+ installDaemonLogging(logProjectId ? daemonLogPath(logProjectId) : null, { jsonEvents: process.argv.includes('--json-events') });
823
436
  await runFleetDaemon({ afterLock });