flowviant 0.117.0 → 0.124.1

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 (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
package/bin/lib/fleet.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  * that order. Moving the order out would move the reconciliation itself.
19
19
  */
20
20
 
21
+ import { existsSync } from 'node:fs';
21
22
  import {
22
23
  VERSION,
23
24
  FLEET_URL,
@@ -36,6 +37,7 @@ import { handleVersionSignal } from './update.mjs';
36
37
  import { compareVersions } from './versionOrder.mjs';
37
38
  import { emitMachineEvent } from './daemonLogging.mjs';
38
39
  import { writeDaemonState } from './daemonState.mjs';
40
+ import { maintenanceTick, readMaintenance } from './maintenance.mjs';
39
41
  import { repoRootOrDie, detectBaseRef, baseBranchName, excludeInWorktree, usableBaseRef } from './git.mjs';
40
42
  import { c, info, note, ok, warn, fail } from './ui.mjs';
41
43
  import { sleep } from './sleep.mjs';
@@ -56,7 +58,9 @@ import { createLeave } from './standDownExit.mjs';
56
58
  import { stopWorkLanes } from './standDownGate.mjs';
57
59
  import { markLockDraining } from './instance.mjs';
58
60
  import { reportDeployConfig } from './deployConfig.mjs';
61
+ import { mcpInstallsInFlight, processMcpInstallJobs } from './mcpInstall.mjs';
59
62
  import { probeSkillsOnce } from './runtimeCapabilities.mjs';
63
+ import { createProjectRepositories, storedProject } from './projectRepositories.mjs';
60
64
  import { createWorkManager } from './work.mjs';
61
65
  import { INTAKE_ROSTER_KEY } from './workIntake.mjs';
62
66
  import { createKnowledgeSync } from './knowledge.mjs';
@@ -70,6 +74,7 @@ import {
70
74
  maybeReportLocalSessions,
71
75
  maybeReportRepoState,
72
76
  maybeReportTools,
77
+ recheckProjectMcp,
73
78
  repoStateChanged,
74
79
  reportMachine,
75
80
  } from './fleetReports.mjs';
@@ -87,7 +92,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
87
92
  console.log('');
88
93
  console.log(` ${c.bold(c.cyan('◣ flowviant'))} ${c.dim(`machine daemon · v${VERSION}`)}`);
89
94
  console.log(` ${c.dim('──────────────────────────────────────────────')}`);
90
- const repoRoot = repoRootOrDie();
95
+ const launchRoot = repoRootOrDie();
96
+ const projectEntry = storedProject(FLEET_TOKEN);
97
+ const repoRoot = projectEntry?.repoRoot && existsSync(projectEntry.repoRoot) ? projectEntry.repoRoot : launchRoot;
98
+ const repositories = createProjectRepositories({ entry: projectEntry, repoRoot, getEntry: () => storedProject(FLEET_TOKEN) });
91
99
  /**
92
100
  * WHERE SHIP LANDS. Detected at startup, then OVERRIDDEN by the roster when a
93
101
  * human has chosen one (`projects.baseBranch`).
@@ -132,7 +140,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
132
140
  info(`server · ${FLEET_URL}`);
133
141
  console.log('');
134
142
 
135
- takeInstanceLockOrExit(repoRoot);
143
+ takeInstanceLockOrExit(launchRoot, projectEntry?.projectId);
136
144
  // The repo binding the start path's picker or confirm was answered with.
137
145
  // Persisted HERE, after the lock, so a refused start moves nothing: moving it
138
146
  // first stranded the daemon already serving that project's own checkout at
@@ -173,7 +181,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
173
181
  // running (detached children survive an ungraceful exit) before we start fresh.
174
182
  reapOrphanPreviews((m) => info(m));
175
183
 
176
- const { repoKey, baseDir } = worktreeHome(repoRoot);
184
+ const { repoKey, baseDir } = worktreeHome(projectEntry?.repoRoot ?? repoRoot, projectEntry?.projectId, Object.values(projectEntry?.repositories ?? {}));
177
185
 
178
186
  // WHAT THE DELETED VAULT LEFT ON THIS DISK — once, here, because this is the
179
187
  // first point at which both directories it wrote into are known. Deleting the
@@ -249,6 +257,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
249
257
  flushWorkReports,
250
258
  learnPlaces,
251
259
  processWorkTurns,
260
+ interruptWorkTurns,
252
261
  processShipJobs,
253
262
  processDiffJobs,
254
263
  processKillJobs,
@@ -256,6 +265,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
256
265
  processAgentPlanJobs,
257
266
  processIntakeJobs,
258
267
  processAgentTurnJobs,
268
+ processAgentRecoveryJobs,
269
+ interruptAgentTurns,
259
270
  processAgentMergeJobs,
260
271
  freshenManualPlaces,
261
272
  heldSessionIds,
@@ -265,13 +276,16 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
265
276
  shutdownPreviews,
266
277
  retireWorkSessions,
267
278
  reportWorktrees,
279
+ reconcileIdleArtifacts,
268
280
  shutdownWork,
269
281
  settleAgentTurns,
270
282
  workBusy,
283
+ runningAgentCount,
271
284
  admit,
272
285
  liveTurns,
273
286
  } = createWorkManager({
274
287
  repoRoot,
288
+ repositories,
275
289
  baseDir,
276
290
  getBaseRef,
277
291
  getMcpUrl: () => mcpUrl,
@@ -331,9 +345,9 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
331
345
  * from the server's list of WORKING AGENTS, which cannot see a Workbench
332
346
  * turn, a ship or a deploy.
333
347
  */
334
- const machineBusy = () => wiki.busy() || workBusy() || deploysInFlight() > 0;
348
+ const machineBusy = () => wiki.busy() || workBusy() || deploysInFlight() > 0 || mcpInstallsInFlight() > 0;
335
349
  const reportBusy = () =>
336
- writeDaemonState(desktopProjectId, { busy: machineBusy(), busyAt: new Date().toISOString() });
350
+ writeDaemonState(desktopProjectId, { busy: machineBusy(), liveAgents: runningAgentCount(), maintenance: null, busyAt: new Date().toISOString() });
337
351
  /**
338
352
  * HOW EVERY STAND-DOWN LEAVES — after the teardown, and after any deploy in
339
353
  * flight has finished and reported (standDownExit.mjs, ruling 2026-09-26).
@@ -379,6 +393,16 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
379
393
  // share, no new deploy — only the deploys already claimed run on to their
380
394
  // report (standDownExit.mjs).
381
395
  const standingDown = () => leave.leaving();
396
+ let lastAgentJobs = [];
397
+ const reportMaintenance = (jobs = lastAgentJobs) => maintenanceTick({
398
+ busy: machineBusy,
399
+ agents: runningAgentCount,
400
+ flush: async () => {
401
+ await flushWorkReports();
402
+ processAgentTurnJobs(jobs, { reportsOnly: true });
403
+ },
404
+ report: (state) => writeDaemonState(desktopProjectId, state),
405
+ });
382
406
  for (;;) {
383
407
  if (standingDown()) return leave.drained();
384
408
  let roster;
@@ -412,7 +436,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
412
436
  }
413
437
  warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
414
438
  // Work already running keeps running offline; the measurement keeps up.
415
- reportBusy();
439
+ if (!await reportMaintenance()) reportBusy();
416
440
  await sleep(RECONCILE_SECONDS);
417
441
  continue;
418
442
  }
@@ -490,7 +514,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
490
514
  // same one the desktop app's auto-install reads — which is how a deploy
491
515
  // in flight joined this gate (2026-09-26): re-exec'ing under one would
492
516
  // stop its heartbeat mid-run.
493
- const safeToUpdate = !machineBusy();
517
+ const safeToUpdate = !machineBusy() && !readMaintenance();
494
518
  const updating = handleVersionSignal({
495
519
  latest: roster.daemon.latest,
496
520
  min: roster.daemon.min,
@@ -501,6 +525,19 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
501
525
  if (await updating) return;
502
526
  if (standingDown()) return leave.drained();
503
527
  }
528
+ // Keep polling to renew the running agents' leases and honour stops and
529
+ // interrupts. A paused tick admits no roster jobs, even after becoming
530
+ // idle; only held answers are retried until the tray releases its lease.
531
+ // Interrupts precede both paths so a stop arriving while paused still
532
+ // reaches its running CLI, and a successor sees the stop before intake.
533
+ interruptWorkTurns(roster.interruptTurns);
534
+ interruptAgentTurns(roster.interruptAgentTurns);
535
+ lastAgentJobs = roster.agentTurnJobs;
536
+ if (await reportMaintenance()) {
537
+ reportMachine({ worktreeDir: baseDir, liveTurns });
538
+ await sleep(RECONCILE_SECONDS);
539
+ continue;
540
+ }
504
541
  // Settle any turn/ship answers whose earlier report POST failed BEFORE
505
542
  // taking new work — the skip-if-pending guards make the ordering safe, but
506
543
  // delivering first keeps the tab honest a poll sooner.
@@ -508,6 +545,8 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
508
545
  // WHERE each live tab works, BEFORE anything measures one: the sweep below
509
546
  // and every preview check ask `placeOf`, and a tab nobody has typed into
510
547
  // yet has taught it nothing.
548
+ repositories.learn(roster.repositories);
549
+ for (const repository of repositories.list()) if (repository.repoRoot) scanEnvForScrub(repository.repoRoot);
511
550
  learnPlaces(roster.sessionPlaces);
512
551
  processWorkTurns(roster.workTurnJobs);
513
552
  // The roster's live-session list rides along: an ENDED session's ship
@@ -534,8 +573,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
534
573
  * Announced on change, because a silent switch of merge target is the one
535
574
  * thing worse than not offering the choice at all.
536
575
  */
537
- if (typeof roster.baseBranch === 'string' && roster.baseBranch.trim()) {
538
- const want = usableBaseRef(repoRoot, `origin/${baseBranchName(roster.baseBranch.trim())}`);
576
+ const servedBaseBranch = Array.isArray(roster.repositories)
577
+ ? repositories.list().find((r) => r.repoRoot === repoRoot)?.baseBranch : roster.baseBranch;
578
+ if (typeof servedBaseBranch === 'string' && servedBaseBranch.trim()) {
579
+ const want = usableBaseRef(repoRoot, `origin/${baseBranchName(servedBaseBranch.trim())}`);
539
580
  if (want !== baseRef) {
540
581
  note(`base · ${want} ${c.dim('(set for this project)')}`);
541
582
  baseRef = want;
@@ -581,7 +622,9 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
581
622
  processIntakeJobs(roster[INTAKE_ROSTER_KEY]);
582
623
  // …and an agent's next card. After the plan jobs because a press becoming
583
624
  // agents is the thing that produces these.
584
- processAgentTurnJobs(roster.agentTurnJobs);
625
+ const recovering = await processAgentRecoveryJobs(roster.agentRecoveryJobs);
626
+ processAgentTurnJobs(Array.isArray(roster.agentTurnJobs)
627
+ ? roster.agentTurnJobs.filter((job) => !recovering.has(job.agentId)) : roster.agentTurnJobs);
585
628
  // …and a branch somebody approved. After the turns: a merge takes the
586
629
  // place's WRITER lock, and writer preference means it goes ahead of any
587
630
  // reader queued behind it anyway.
@@ -594,6 +637,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
594
637
  // themselves from a browser, relayed. After retirement so a directory that
595
638
  // just went away is not reported as a place.
596
639
  reportWorktrees(roster.activeWorkSessions);
640
+ // …and an idle agent's delivery against what its worktree still holds:
641
+ // once per process, beside no turn (workArtifactReconcile.mjs). After the
642
+ // turn intake above, so a card about to run in a place is not raced.
643
+ void reconcileIdleArtifacts(roster.activeWorkSessions)?.catch(() => {});
597
644
  // Terminal-session presence, throttled + dedup'd inside; never awaited —
598
645
  // the daemon's own worktrees are carved out (a session the daemon spawned
599
646
  // is already a tab, not something to offer adopting).
@@ -657,6 +704,14 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
657
704
  // This is what replaced the vault's sync tick — see `maybeReportEnv`.
658
705
  void maybeReportEnv(repoRoot);
659
706
  void maybeReportTools(repoRoot, getBaseRef());
707
+ // INSTALLS AN OWNER CONFIRMED FOR THIS BOX (mcpInstall.mjs) — handed to
708
+ // the picked box whether it holds the machine or stands by; each is
709
+ // claimed, run exactly as confirmed, and followed by a re-check.
710
+ processMcpInstallJobs(roster.mcpInstallJobs, {
711
+ repoRoot,
712
+ myPubB64,
713
+ recheck: () => recheckProjectMcp(repoRoot, getBaseRef()),
714
+ });
660
715
 
661
716
  // Tell the app what this machine is doing with itself (fleetReports.mjs).
662
717
  reportMachine({ worktreeDir: baseDir, liveTurns });
@@ -27,8 +27,11 @@ import { detectRuntimes } from './runtimeDetection.mjs';
27
27
  import { scanLocalSessions } from './localSessions.mjs';
28
28
  import { repoState } from './repoState.mjs';
29
29
  import { claudeAuthContext } from './claudeAuth.mjs';
30
- import { readBaseTools, toolReadout } from './projectTools.mjs';
30
+ import { prepareAgentTools, readBaseTools, toolReadout } from './projectTools.mjs';
31
31
  import { runnerToolCapabilities } from './projectToolRuntimes.mjs';
32
+ import { buildMcpInventory } from './mcpInventory.mjs';
33
+ import { knownClaudeMcpInventory, probeMcpNow } from './runtimeCapabilities.mjs';
34
+ import { codexMcpServers } from './runtimeOfferings.mjs';
32
35
 
33
36
  /**
34
37
  * ONE READOUT LANE (audit 2026-10-10, F087/F752). The four readouts below —
@@ -292,7 +295,15 @@ export function maybeReportEnv(repoRoot) {
292
295
  // Latches on the env lane's short list for the same reason: a 401 or a 429 is
293
296
  // a "later", and a lane that dies invisibly must not die for one.
294
297
  const toolsLane = createReportLane({ endpoint: 'tools-report', isPermanent: envReportIsPermanent });
295
- /** Repo tool readiness is a per-box daemon report. Never include env values. */
298
+ /**
299
+ * Repo tool readiness is a per-box daemon report. Never include env values.
300
+ *
301
+ * `mcp` (2026-10-13) is this box's MCP inventory — every server each installed
302
+ * CLI has, by name, source and status, with a connected server's tool names
303
+ * (mcpInventory.mjs). It rides here rather than on the poll because tool names
304
+ * do not fit a URL, and this lane already posts only on change. An older
305
+ * server's schema strips the key.
306
+ */
296
307
  export function maybeReportTools(repoRoot, baseRef) {
297
308
  return toolsLane(() => {
298
309
  const pubkey = myPubB64();
@@ -300,10 +311,40 @@ export function maybeReportTools(repoRoot, baseRef) {
300
311
  const snapshot = readBaseTools(repoRoot, baseRef);
301
312
  const installed = detectRuntimes().filter((r) => r.installed);
302
313
  const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
303
- return JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
314
+ const ids = installed.map((r) => r.id);
315
+ let codex = null;
316
+ if (ids.includes('codex')) {
317
+ try { codex = codexMcpServers(); } catch { /* a failed local read is unknown */ }
318
+ }
319
+ const mcp = buildMcpInventory({ installed: ids, snapshot, claude: knownClaudeMcpInventory(), codex });
320
+ return JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner), mcp });
304
321
  });
305
322
  }
306
323
 
324
+ /**
325
+ * LOOK AGAIN AT THIS BOX'S MCP SERVERS, NOW (2026-10-13) — after a confirmed
326
+ * install (mcpInstall.mjs). Codex's list is re-read, Claude Code is probed
327
+ * with the base branch's servers mounted (`probeMcpNow`), and the tool report
328
+ * — whose "command not on PATH" was measured before the install — is rescanned
329
+ * and posted without waiting for its beat. Every step is best-effort.
330
+ */
331
+ export async function recheckProjectMcp(repoRoot, baseRef) {
332
+ try {
333
+ if (detectRuntimes().some((r) => r.id === 'codex' && r.installed)) codexMcpServers({ refresh: true });
334
+ } catch { /* unknown stays unknown */ }
335
+ if (detectRuntimes().some((r) => r.id === 'claude' && r.installed)) {
336
+ let tools = null;
337
+ try {
338
+ tools = prepareAgentTools(readBaseTools(repoRoot, baseRef), 'claude');
339
+ await probeMcpNow(repoRoot, tools.mcpPath);
340
+ } catch { /* no base read, or no claude: the next turn teaches it */ } finally {
341
+ tools?.cleanup();
342
+ }
343
+ }
344
+ toolsLane.rescan();
345
+ await maybeReportTools(repoRoot, baseRef);
346
+ }
347
+
307
348
  /**
308
349
  * "THE REPO JUST CHANGED — look again." Clears the repo-state throttle so the
309
350
  * next reconcile rescans on the beat it already runs; the work manager calls
@@ -45,65 +45,6 @@ let warnedEnvIdentity = false;
45
45
  */
46
46
  const POLL_URL_BUDGET = 14_000;
47
47
 
48
- /**
49
- * HAS THIS PROCESS ALREADY ASKED FOR THE MACHINE? (0.91.0)
50
- *
51
- * The owner's ruling on two boxes running one project: "it should kill the
52
- * first one and take over". So a daemon asks on the FIRST poll of its life and
53
- * never again, and this pair is the whole of that "never again".
54
- *
55
- * ONE ASK PER PROCESS IS THE SAFETY, NOT A POLITENESS. A param that kept asking
56
- * would be two boxes trading a machine back and forth every ten seconds. Asked
57
- * once, the story ends at the first exchange, because the box that loses
58
- * SETTLES ITS TURNS AND EXITS rather than restarting — and a process that has
59
- * exited sends no more first polls.
60
- *
61
- * THAT LAST SENTENCE IS TRUE OF THE PROCESS AND NOT OF THE UNIT, which is why
62
- * the server stopped relying on it (2026-09-19). Under `Restart=always`, pm2 or
63
- * docker, exit 0 is a relaunch and the relaunch's first poll is a genuine one.
64
- * `takeMachineHolderNow` refuses a take by the box it just displaced for the
65
- * length of the displaced window, so the ping-pong is bounded on the side that
66
- * can see both boxes. This module still keeps its half of the bargain.
67
- *
68
- * SPENT ON A DELIVERED POLL, NOT ON AN ATTEMPTED ONE, which is why this is two
69
- * functions instead of one consuming read. A daemon that starts while the wire
70
- * is down retries every ten seconds; spending the ask on the failed attempt
71
- * would mean the box you walked over to and started never takes the machine,
72
- * and nothing anywhere would say why.
73
- *
74
- * IT IS NOT THE DELETED FLAG COMING BACK. There is still nothing to type: the
75
- * terminal surface is `npx flowviant` and `npx flowviant login`, and what
76
- * claims here is starting the daemon, which is already one of those two.
77
- *
78
- * Exported so both properties can be proved without a server, a credential or a
79
- * second box — they are the contract, and nothing else in the file states them.
80
- */
81
- /**
82
- * AND A RESTART IS NOT A PERSON (2026-09-19, the review).
83
- *
84
- * `update.mjs` re-execs the daemon with `FLOWVIANT_REEXEC='1'` after an
85
- * UNATTENDED auto-update, which is on by default. Born `false`, the new process
86
- * would then spend a fresh ask and TAKE THE PROJECT'S MACHINE — so a standby
87
- * sitting quietly on somebody's second computer would seize the machine off the
88
- * live holder in the middle of the night, settling its running turns as moved,
89
- * because npm published a patch. Nobody was at either keyboard, and nothing on
90
- * any surface would say why.
91
- *
92
- * The ask belongs to the GESTURE, not to the process: what claims a machine is
93
- * a person typing `npx flowviant`, and a re-exec is the same start continuing.
94
- * So a re-executed daemon is born with the ask already spent and behaves
95
- * exactly as it did before the update — it keeps the machine if it had it (arm
96
- * (a) stamps it), and stands by if it did not.
97
- */
98
- let machineAskSpent = process.env.FLOWVIANT_REEXEC === '1';
99
- export function machineAskPending() {
100
- return !machineAskSpent;
101
- }
102
- export function spendMachineAsk() {
103
- machineAskSpent = true;
104
- }
105
-
106
-
107
48
  export async function fetchRoster(
108
49
  livePreviewSessionIds = [],
109
50
  heldSessionIds = [],
@@ -170,30 +111,7 @@ export async function fetchRoster(
170
111
  if (repoRoot) url.searchParams.set('cp', String(repoRoot).slice(0, 256));
171
112
  url.searchParams.set('pid', String(process.pid));
172
113
  url.searchParams.set('st', PROCESS_STARTED_AT);
173
- /**
174
- * THE FIRST POLL OF THIS PROCESS ASKS FOR THE MACHINE (`claim`, 0.91.0).
175
- *
176
- * The owner, on two boxes running one project: "it should kill the first one
177
- * and take over." Starting the daemon on the computer in front of you IS the
178
- * gesture — there is no flag, no env var and no third command, so the ruling
179
- * that deleted the old machine-moving flag ("i dont intend to run or do
180
- * anything in the terminal besides npx flowviant or npx flowviant login")
181
- * stands untouched.
182
- *
183
- * SENT ONCE PER PROCESS, and that is the safety rather than a politeness: see
184
- * `askForMachineOnce`. The server takes holdership there and then, the box it
185
- * displaces learns through the existing named-and-windowed `displaced` signal
186
- * and exits 0, and nothing restarts to ask again.
187
- *
188
- * AN OLDER SERVER IGNORES IT and the old standby behaviour stands, which is
189
- * why this needs no floor of its own — the server applies one (it will not
190
- * take a machine from a box too old to be told it lost it).
191
- */
192
- if (machineAskPending()) url.searchParams.set('claim', '1');
193
- // A poll reports what this box IS. Moving the project's machine onto it, when
194
- // this process has already spent its one ask above, is a gesture a person
195
- // makes in the app — and the daemon learns that outcome on its next poll like
196
- // every other holder fact.
114
+ // Only Make it the machine in the app moves an existing holder.
197
115
  // Which shares this machine is still serving. It rides the poll rather than
198
116
  // taking an endpoint of its own: one beat, no floor, and the stale window is
199
117
  // the reconcile interval instead of minutes — which matters, because a share
@@ -430,12 +348,6 @@ export async function fetchRoster(
430
348
  throw new Error(`fleet poll refused by something in front of the API (${res.status})`);
431
349
  }
432
350
  if (!res.ok) throw new Error(`fleet poll failed (${res.status})`);
433
- // THE ASK IS SPENT HERE AND NOWHERE ELSE — on a poll the server actually
434
- // answered. Spending it where the param is SET would lose the claim of a
435
- // daemon that started while the wire was down: it would retry, silently
436
- // without `claim`, and the box somebody walked over to and started would
437
- // never take the machine.
438
- spendMachineAsk();
439
351
  const body = await res.json();
440
352
  // Validate the shape here so a malformed 200 (deploy hiccup, error envelope)
441
353
  // throws a NORMAL retryable error inside the loop's try/catch, instead of a
@@ -18,16 +18,13 @@ import { info, note, warn, fail } from './ui.mjs';
18
18
  import { acquireInstanceLock } from './instance.mjs';
19
19
  import { emitMachineEvent } from './daemonLogging.mjs';
20
20
  import { worktreeHomeDir, worktreeHomeKey } from './statePaths.mjs';
21
+ import { projectWorktreeHome } from './projectWorktreeHome.mjs';
21
22
 
22
23
  /** Takes the lock, or says why not and exits 1. Returns only on success. */
23
- export function takeInstanceLockOrExit(repoRoot) {
24
- // ONE DAEMON PER CREDENTIAL. Before preflight, before the preview reap,
25
- // before anything with a side effect — a second daemon must not so much as
26
- // install a CLI or clear a registry on its way to being refused. Keyed on the
27
- // credential rather than the repo, because two checkouts on one credential is
28
- // the SAME project served twice, and the worst version of this: their session
29
- // worktrees are in different directories, so the per-turn lock cannot even see
30
- // across them. See instance.mjs for why that lock is not enough on its own.
24
+ export function takeInstanceLockOrExit(repoRoot, projectId) {
25
+ // One project lock before any side effect. daemonLocks resolves stored
26
+ // credentials to the project id; legacy explicit credentials retain their
27
+ // credential key. Different checkouts therefore find the same holder.
31
28
  // Same repo -> this run replaces whatever was serving it. Different repo ->
32
29
  // refused, and nothing is signalled. See instance.mjs's header for the rule.
33
30
  //
@@ -55,18 +52,18 @@ export function takeInstanceLockOrExit(repoRoot) {
55
52
  } else if (instance.sameRepo) {
56
53
  fail('a flowviant daemon is already running in this repo.');
57
54
  } else {
58
- fail('a flowviant daemon is already running for this credential.');
55
+ fail(projectId ? 'a flowviant daemon is already running for this project.' : 'a flowviant daemon is already running for this credential.');
59
56
  }
60
57
  if (h?.pid) info(`holder · pid ${h.pid}${h.repoRoot ? ` in ${h.repoRoot}` : ''}`);
61
58
  // The two-checkouts case is the one nobody spots on their own: both tabs
62
59
  // look healthy, and the damage is doubled cards and doubled edits in a repo
63
60
  // you are not looking at. Name the other repo when it is a different one.
64
61
  if (!instance.sameRepo && h?.repoRoot && h.repoRoot !== repoRoot) {
65
- warn('that is a DIFFERENT checkout — one credential serves one project, so both would answer the same tabs.');
62
+ info('the running daemon serves this project’s connected checkouts.');
66
63
  // Not offered lightly: that daemon is serving other work, and this
67
64
  // command was run somewhere else. Replacing it is a decision, not a
68
65
  // restart, so it takes a word.
69
- note('run with --takeover to stop it and serve this repo instead.');
66
+ note('run with --takeover to replace the running daemon.');
70
67
  }
71
68
  // WITHHELD when we could not identify the holder. ALLOW_MULTI runs this
72
69
  // daemon unguarded beside one we just admitted we cannot see, and in the
@@ -87,12 +84,12 @@ export function takeInstanceLockOrExit(repoRoot) {
87
84
  /**
88
85
  * The persistent worktree home for this checkout, created if missing.
89
86
  */
90
- export function worktreeHome(repoRoot) {
87
+ export function worktreeHome(repoRoot, projectId, roots) {
91
88
  // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
92
89
  // so Ctrl+C mid-task never loses local work. Keyed per repo path, by the
93
90
  // one derivation deploy checkouts and `flowviant clean` share (statePaths.mjs).
94
- const repoKey = worktreeHomeKey(repoRoot);
95
- const baseDir = worktreeHomeDir(repoRoot);
91
+ const { repoKey, baseDir } = projectId ? projectWorktreeHome(repoRoot, projectId, roots)
92
+ : { repoKey: worktreeHomeKey(repoRoot), baseDir: worktreeHomeDir(repoRoot) };
96
93
  mkdirSync(baseDir, { recursive: true });
97
94
  return { repoKey, baseDir };
98
95
  }
@@ -117,10 +117,9 @@ export function agoLabel(ms) {
117
117
  * · mine: true -> we are the machine. Announce it only if we have been
118
118
  * inactive, so an ordinary daemon prints nothing new.
119
119
  * · mine: false -> GO INACTIVE. Keep polling quietly; the restricted roster
120
- * serves us nothing, the auto-handover makes this box the
121
- * machine when the holder dies, and the app is where a
122
- * person moves it sooner. Never exit: an inactive box that
123
- * quits is one somebody has to go and restart by hand.
120
+ * serves us no work. A person moves it with Make it the
121
+ * machine in the app; silence never moves it. Never exit:
122
+ * an inactive box that quits is one somebody has to go and restart by hand.
124
123
  *
125
124
  * THE WORD A PERSON READS IS `inactive` (2026-09-21) — the owner replaced
126
125
  * "standing by" outright: *"no, it can [be] inactive instead"*. The server's
@@ -207,41 +206,9 @@ export function createHolderWatch({ say = () => {} } = {}) {
207
206
  // took, printed at the one moment the person is deciding whether to
208
207
  // wait.
209
208
  //
210
- // THE PROMISE IS GONE (0.91.0), and its absence is the honest half.
211
- // This sentence used to end "It moves here automatically once that
212
- // machine has been quiet 10 minutes", which was true while starting a
213
- // daemon did nothing but wait. Since 0.91.0 a process START asks for
214
- // the machine on its first poll — the owner's ruling, "it should kill
215
- // the first one and take over" — so reaching this branch at all means
216
- // the ask was REFUSED or never spent. Four ways: an older server that
217
- // does not honour it; a daemon this server will not hand a machine to;
218
- // an UNATTENDED RE-EXEC, which is born with the ask spent because a
219
- // restart is not a person; and — the one worth naming — a box this
220
- // credential DISPLACED inside the last ten minutes, which the server
221
- // refuses so that two supervised daemons cannot trade a machine back
222
- // and forth forever (see `takeMachineHolderNow`). In that last case the
223
- // right next move is a decision, not a wait, which is exactly what this
224
- // sentence already points at. Restating the staleness clock instead
225
- // would promise a handover on the one path where it is least likely to
226
- // be what happens. So the line relays the fact and points at the door,
227
- // which is the same door it always pointed at.
228
- //
229
- // THE WORD IS `inactive` (2026-09-21). It was "standing by" until the
230
- // owner replaced it — asked whether a box should keep saying that, he
231
- // answered *"no, it can [be] inactive instead"* — and the server's role
232
- // enum moved in the same pass (`serving` / `inactive`, which is why
233
- // `machineListing.mjs` no longer respells anything). A terminal saying one
234
- // word about this box while the app says another about the same box is
235
- // the confusion these readouts exist to end, so the two ends say the
236
- // same thing.
237
- //
238
- // THE POINTER SURVIVES THE REWORD. The sentence still ends at the app's
239
- // project settings rather than at "another machine is serving this
240
- // project", which the line's first half has already said — restating it
241
- // would spend the only clause the person can act on repeating a fact.
242
209
  say(
243
210
  `This project's machine is ${name ?? 'another machine'}${ago ? ` (heard ${ago} ago)` : ''}. ` +
244
- "This one is inactive — move it here from the app's project settings."
211
+ "This one is inactive — choose Make it the machine in Project settings › Machines in the app."
245
212
  );
246
213
  }
247
214
  return 'standby';
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * THE MACHINE'S MCP SERVERS, ALLOWED ON A FENCED AGENT TURN (2026-10-11, the
4
+ * owner: "the server that is running flowviant's claude HAS these MCPs yet the
5
+ * agents are not using them").
6
+ *
7
+ * A write-up or mockup/3D-model/deck turn runs under an `--allowedTools` list,
8
+ * and an MCP tool the list does not name is refused ("Claude requested
9
+ * permissions to use mcp__…, but you haven't granted it yet"). The project's
10
+ * base servers are named by the lane; the machine's own servers and claude.ai
11
+ * connectors are not knowable before the CLI connects them (a connector's tools
12
+ * are `mcp__claude_ai_<Name>__<tool>`, from the account), and `mcp__*` is not a
13
+ * rule this CLI admits (measured, Claude Code 2.1.290: refused). So this
14
+ * `PreToolUse` hook, matched on `mcp__.*`, answers `allow` for an MCP tool call.
15
+ * MEASURED on 2.1.290 under the research and design lists: the project server
16
+ * and a claude.ai connector each answered with no per-name rule, and Bash
17
+ * `touch`, an outside Write and WebFetch were refused exactly as without the
18
+ * hook.
19
+ *
20
+ * It decides MCP calls and nothing else: any other tool, and Flowviant's own
21
+ * control plane (`mcp__flowviant`, never mounted on an agent turn), get no
22
+ * decision, and the posture's list decides. A hook that fails gives no
23
+ * decision either, so a broken hook leaves MCP calls refused — closed.
24
+ */
25
+ import { pathToFileURL } from 'node:url';
26
+
27
+ /** The decision for one PreToolUse payload, or null for "not ours". */
28
+ export function mcpAllowDecision(payload) {
29
+ const name = payload?.tool_name;
30
+ if (typeof name !== 'string' || !name.startsWith('mcp__')) return null;
31
+ if (name === 'mcp__flowviant' || name.startsWith('mcp__flowviant__')) return null;
32
+ return {
33
+ hookSpecificOutput: {
34
+ hookEventName: 'PreToolUse',
35
+ permissionDecision: 'allow',
36
+ permissionDecisionReason: 'an MCP server this machine mounted for the card',
37
+ },
38
+ };
39
+ }
40
+
41
+ let started = false;
42
+ export function main() {
43
+ if (started) return;
44
+ started = true;
45
+ let buf = '';
46
+ process.stdin.setEncoding('utf8');
47
+ process.stdin.on('data', (d) => (buf += d));
48
+ process.stdin.on('end', () => {
49
+ let decision = null;
50
+ try {
51
+ decision = mcpAllowDecision(JSON.parse(buf));
52
+ } catch {
53
+ decision = null;
54
+ }
55
+ if (decision) process.stdout.write(JSON.stringify(decision));
56
+ process.exit(0);
57
+ });
58
+ }
59
+
60
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
@@ -77,8 +77,11 @@
77
77
  * way they could turn off anything on their own box; the prefix list is still
78
78
  * the first fence and this is the second.
79
79
  *
80
- * Run by the CLI as `node readGuard.mjs`, the hook JSON on stdin. Imported by
81
- * the tests for `readGuardRefusal`, which is the whole decision.
80
+ * Run by the CLI as `<daemon's node> readGuard.mjs` from npm, and as
81
+ * `flowviant __hook read-guard` under the compiled binary (bin/cli.mjs calls
82
+ * `main`), the hook JSON on stdin either way (claudePosture.mjs
83
+ * `hookCommand`). Imported by the tests for `readGuardRefusal`, which is the
84
+ * whole decision.
82
85
  */
83
86
 
84
87
  import { pathToFileURL } from 'node:url';
@@ -161,9 +164,15 @@ export function readGuardRefusal(command) {
161
164
  /** The sentence the model reads when a call is refused. */
162
165
  export const readGuardSentence = (why) =>
163
166
  `flowviant: this turn is read-only, so ${why} is refused here — it can write a file, run a program or read past this turn's fence. ` +
164
- 'Run one plain read command at a time with no redirects, pipes or expansions, or use the Read, Grep and Glob tools.';
167
+ 'Run one plain read command at a time with no redirects, pipes or expansions, or use the Read, Grep and Glob tools. ' +
168
+ 'Quoted pipe/bracket syntax is still refused: try separate literal git log -S searches, native Read for the working-tree path, or a plain scoped parent-path history read. Keep the guard enabled; do not bypass secret/path denials.';
165
169
 
166
- function main() {
170
+ /** Run once per process: the CLI's `__hook read-guard` (the compiled binary)
171
+ * calls it, and so may the direct-run check below. */
172
+ let started = false;
173
+ export function main() {
174
+ if (started) return;
175
+ started = true;
167
176
  // A hook that CRASHES is not a hook that refused: Claude Code treats any
168
177
  // exit other than 2 as a non-blocking error and runs the command anyway. So
169
178
  // every way out of this process that is not a verdict is a refusal.