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.
- package/README.md +41 -0
- package/bin/cli.mjs +44 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +4 -3
- package/bin/lib/agentPlan.mjs +86 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +26 -6
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +65 -10
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +63 -5
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +170 -42
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +58 -18
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +31 -10
- package/bin/lib/runtimeCodex.mjs +28 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +48 -29
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +10 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +167 -29
- package/bin/lib/workAgentTurnOutcome.mjs +20 -5
- package/bin/lib/workAgentTurns.mjs +92 -12
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +24 -3
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +219 -52
- package/bin/lib/workShip.mjs +17 -16
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +7 -3
- 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
|
|
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(
|
|
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
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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 });
|
package/bin/lib/fleetReports.mjs
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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
|
package/bin/lib/fleetRoster.mjs
CHANGED
|
@@ -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
|
package/bin/lib/fleetStartup.mjs
CHANGED
|
@@ -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
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
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
|
|
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 =
|
|
95
|
-
|
|
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
|
}
|
package/bin/lib/holder.mjs
CHANGED
|
@@ -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
|
|
121
|
-
* machine
|
|
122
|
-
*
|
|
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 —
|
|
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
|
|
81
|
-
*
|
|
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
|
-
|
|
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.
|