flowviant 0.117.0 → 0.124.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 (64) hide show
  1. package/README.md +35 -0
  2. package/bin/cli.mjs +37 -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 +2 -2
  7. package/bin/lib/agentPlan.mjs +78 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentTaskKinds.mjs +1 -1
  10. package/bin/lib/artifactContracts.mjs +91 -20
  11. package/bin/lib/artifactIteration.mjs +182 -0
  12. package/bin/lib/artifactRecovery.mjs +36 -0
  13. package/bin/lib/artifactReporter.mjs +183 -19
  14. package/bin/lib/artifacts.mjs +6 -3
  15. package/bin/lib/claudeAuth.mjs +15 -5
  16. package/bin/lib/claudePosture.mjs +110 -14
  17. package/bin/lib/claudeStream.mjs +20 -2
  18. package/bin/lib/codexProjectRead.mjs +63 -0
  19. package/bin/lib/codexResearch.mjs +16 -3
  20. package/bin/lib/commandSpecs.mjs +6 -0
  21. package/bin/lib/daemonLocks.mjs +5 -3
  22. package/bin/lib/desktopContract.mjs +3 -0
  23. package/bin/lib/discoveryContract.mjs +19 -0
  24. package/bin/lib/fleet.mjs +46 -4
  25. package/bin/lib/fleetReports.mjs +44 -3
  26. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  27. package/bin/lib/hooks/readGuard.mjs +13 -4
  28. package/bin/lib/imageArtifact.mjs +74 -0
  29. package/bin/lib/maintenance.mjs +47 -0
  30. package/bin/lib/maintenanceInventory.mjs +39 -0
  31. package/bin/lib/mcpInstall.mjs +163 -0
  32. package/bin/lib/mcpInventory.mjs +119 -0
  33. package/bin/lib/planDependencies.mjs +70 -0
  34. package/bin/lib/postureTools.mjs +85 -0
  35. package/bin/lib/projectToolRuntimes.mjs +227 -3
  36. package/bin/lib/projectTools.mjs +61 -4
  37. package/bin/lib/prompts.mjs +164 -41
  38. package/bin/lib/repositoryBaseline.mjs +68 -0
  39. package/bin/lib/runTurn.mjs +35 -16
  40. package/bin/lib/runtimeCapabilities.mjs +77 -3
  41. package/bin/lib/runtimeClaude.mjs +27 -9
  42. package/bin/lib/runtimeCodex.mjs +27 -59
  43. package/bin/lib/runtimeDetection.mjs +6 -0
  44. package/bin/lib/runtimeEvents.mjs +12 -1
  45. package/bin/lib/runtimeOfferings.mjs +75 -3
  46. package/bin/lib/turnDiagnostics.mjs +93 -0
  47. package/bin/lib/turnProfile.mjs +15 -3
  48. package/bin/lib/work.mjs +28 -7
  49. package/bin/lib/workAgentBrief.mjs +92 -0
  50. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  51. package/bin/lib/workAgentPlans.mjs +21 -70
  52. package/bin/lib/workAgentPrecheck.mjs +5 -0
  53. package/bin/lib/workAgentTurnExecution.mjs +93 -22
  54. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  55. package/bin/lib/workAgentTurns.mjs +60 -4
  56. package/bin/lib/workArtifactReconcile.mjs +79 -0
  57. package/bin/lib/workCappedTurn.mjs +10 -0
  58. package/bin/lib/workCommandAudit.mjs +8 -1
  59. package/bin/lib/workPlaces.mjs +18 -2
  60. package/bin/lib/workSessionTurns.mjs +148 -13
  61. package/bin/lib/workToolLog.mjs +70 -3
  62. package/bin/lib/workTurnShape.mjs +6 -4
  63. package/npm-shrinkwrap.json +12 -2
  64. package/package.json +7 -3
@@ -0,0 +1,63 @@
1
+ /** Actual project files and Git history, with no host-wide read fallback.
2
+ * The CLI retains its provider/MCP login outside the tool sandbox. Shells
3
+ * receive neither that environment nor native image reads/shell snapshots.
4
+ */
5
+ import { execFileSync } from 'node:child_process';
6
+ import { lstatSync, readdirSync, realpathSync } from 'node:fs';
7
+ import { isAbsolute, join, dirname } from 'node:path';
8
+ import { git } from './git.mjs';
9
+ import { codexResearchHelper } from './codexResearch.mjs';
10
+ import { unattendedEnv } from './noWindowEnv.mjs';
11
+
12
+ export function projectReadBoundary({ cwd, knowledgeDir, env = process.env }) {
13
+ if (process.platform !== 'linux') throw new Error('Project-scoped Codex reads require Linux or WSL.');
14
+ if (!isAbsolute(cwd ?? '')) throw new Error('Project-scoped Codex reads require an absolute project directory.');
15
+ const helper = codexResearchHelper(env);
16
+ const version = execFileSync(helper, ['--version'], { encoding: 'utf8', timeout: 10000, env: unattendedEnv(env) }).match(/^codex-cli (\d+)\.(\d+)\.(\d+)/);
17
+ if (!version || version.slice(1).map(Number).reduce((n, v) => n * 1000 + v, 0) < 159002)
18
+ throw new Error('Project-scoped reads need Codex CLI 0.159.2 or newer. Update Codex before retrying.');
19
+ const project = realpathSync(cwd);
20
+ // Resolve both: linked worktrees have their own HEAD/index and shared
21
+ // objects/refs in the common directory. Never grant the parent checkout.
22
+ const gitOpts = { timeout: 10000, env: { PATH: env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' } };
23
+ const local = realpathSync(git(['rev-parse', '--absolute-git-dir'], project, gitOpts));
24
+ const common = realpathSync(git(['rev-parse', '--path-format=absolute', '--git-common-dir'], project, gitOpts));
25
+ const paths = [project, local, common];
26
+ // Git directories also hold daemon login/thread/browser stores. They are
27
+ // not history. Mask non-Git directories, including other worktree stores.
28
+ const metadata = new Set(['HEAD', 'index', 'commondir', 'gitdir', 'config', 'config.worktree', 'objects', 'refs', 'logs', 'packed-refs', 'shallow', 'description', 'info', 'branches']);
29
+ const denied = [];
30
+ for (const dir of new Set([local, common])) {
31
+ for (const name of readdirSync(dir)) {
32
+ if (dir === common && name === 'worktrees' && local !== common) {
33
+ for (const sibling of readdirSync(join(common, name))) {
34
+ const path = join(common, name, sibling);
35
+ if (path !== local && path !== dirname(local)) denied.push(path);
36
+ }
37
+ } else if (!metadata.has(name) && lstatSync(join(dir, name)).isDirectory()) denied.push(join(dir, name));
38
+ }
39
+ }
40
+ if (knowledgeDir) paths.push(realpathSync(knowledgeDir));
41
+ return { paths: [...new Set(paths)], denied, helper };
42
+ }
43
+
44
+ export function projectReadFence({ paths, denied = [], helper }) {
45
+ const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read' };
46
+ for (const path of paths) {
47
+ if (!isAbsolute(path) || path === '/') throw new Error('Invalid project read boundary.');
48
+ rules[path] = 'read';
49
+ }
50
+ for (const path of denied) rules[path] = 'deny';
51
+ const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
52
+ return [
53
+ '-c', `permissions.flowviantproject.filesystem=${table}`,
54
+ '-c', 'permissions.flowviantproject.network.enabled=false',
55
+ '-c', 'default_permissions="flowviantproject"',
56
+ '-c', 'approval_policy="never"',
57
+ '-c', 'project_doc_max_bytes=0',
58
+ '-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
59
+ '-c', 'shell_environment_policy.inherit="none"',
60
+ '-c', 'shell_environment_policy.set={PATH="/usr/bin:/bin",HOME="/nonexistent",GIT_CONFIG_NOSYSTEM="1",GIT_CONFIG_GLOBAL="/dev/null",GIT_PAGER="cat",BROWSER="none"}',
61
+ '--disable', 'view_image',
62
+ ];
63
+ }
@@ -4,6 +4,7 @@
4
4
  * Web search is a CLI tool, not direct shell network access.
5
5
  */
6
6
  import { execFileSync } from 'node:child_process';
7
+ import { codexHome } from './codexHome.mjs';
7
8
  import { git } from './git.mjs';
8
9
  import { ensureRealSubdir, realDirState } from './ownedDirs.mjs';
9
10
  import { closeSync, constants, copyFileSync, existsSync, fstatSync, openSync, readFileSync, readSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
@@ -77,12 +78,16 @@ export function codexResearchHelper(env = process.env) {
77
78
  * in the shared fenced tail (sub-agents, browser, image generation, the
78
79
  * person's config and rules), with the web left on: it is this posture's tool.
79
80
  */
80
- export function researchFence({ cwd, helper }) {
81
+ export function researchFence({ cwd, helper, generatedImages }) {
81
82
  if (process.platform !== 'linux' || !isAbsolute(cwd ?? '') || !isAbsolute(helper ?? ''))
82
83
  throw new Error('Isolated Codex research requires Linux or WSL and absolute sandbox paths.');
83
84
  const artifacts = join(cwd, '.flowviant/artifacts');
84
85
  const scratch = join(cwd, '.flowviant/tmp');
85
86
  const rules = { ':root': 'deny', ':minimal': 'read', [helper]: 'read', [cwd]: 'read', [artifacts]: 'write', [scratch]: 'write' };
87
+ if (generatedImages) {
88
+ if (!isAbsolute(generatedImages)) throw new Error('Generated image directory must be absolute.');
89
+ rules[generatedImages] = 'read';
90
+ }
86
91
  const table = '{' + Object.entries(rules).map(([path, access]) => `${JSON.stringify(path)}=${JSON.stringify(access)}`).join(',') + '}';
87
92
  return [
88
93
  '-c', `permissions.flowviantresearch.filesystem=${table}`,
@@ -93,7 +98,7 @@ export function researchFence({ cwd, helper }) {
93
98
  '-c', 'features.shell_snapshot=false', '-c', 'allow_login_shell=false',
94
99
  '-c', 'shell_environment_policy.inherit="none"',
95
100
  '-c', `shell_environment_policy.set={PATH="/usr/bin:/bin",HOME=${JSON.stringify(scratch)},TMPDIR=${JSON.stringify(scratch)},BROWSER="none"}`,
96
- '--disable', 'view_image',
101
+ '--disable', 'view_image', // native host-file reads bypass the tool fence
97
102
  '--skip-git-repo-check',
98
103
  ];
99
104
  }
@@ -168,9 +173,17 @@ export async function runIsolatedResearch(opts, run, {
168
173
  const rewrite = (text) => typeof text === 'string'
169
174
  ? text.replaceAll(original, snapshot).replaceAll(opts.knowledgeDir || '\0', knowledge || '\0') : text;
170
175
  const intro = 'This fresh research turn uses an isolated read-only copy. Prior reports are available under .flowviant/artifacts/; previous CLI conversations are not resumed because they may contain host secrets. Git metadata, .env files, credential files, symlinks and personal CLI settings are omitted. Write the Markdown report only under .flowviant/artifacts/. Web search is available; shell networking is disabled. Use exec_command with shell=/bin/bash and login=false; personal shells are outside the fence.\n\n';
176
+ // Only native output pixels, never the surrounding login/config home.
177
+ let generatedImages;
178
+ if (opts.mcpEnv?.CODEX_HOME) {
179
+ const imageHome = realpathSync(codexHome({ ...process.env, ...opts.mcpEnv }));
180
+ if (!ensureRealSubdir(imageHome, 'generated_images'))
181
+ throw new Error('The native generated image directory is not a real directory.');
182
+ generatedImages = join(imageHome, 'generated_images');
183
+ }
171
184
  const result = await run({ ...opts, resume: false, resumeThreadId: null, resumeConversationId: null, cwd: snapshot, knowledgeDir: knowledge,
172
185
  prompt: intro + rewrite(opts.prompt), system: rewrite(opts.system),
173
- researchIsolation: { cwd: snapshot, helper } });
186
+ researchIsolation: { cwd: snapshot, helper, generatedImages } });
174
187
  // Made, or found, real at publication too: every component checked again.
175
188
  if (!ensureRealSubdir(original, '.flowviant/artifacts'))
176
189
  throw new Error('The research artifact destination is not a real directory.');
@@ -21,6 +21,12 @@
21
21
  */
22
22
 
23
23
  export const COMMANDS = [
24
+ {
25
+ name: 'maintenance', usesCheckout: false, hidden: true,
26
+ usage: 'flowviant maintenance <request|release> <token>',
27
+ summary: 'Hold new runs for a manual WSL restart; existing work finishes.',
28
+ group: 'manage',
29
+ },
24
30
  {
25
31
  name: 'start',
26
32
  usesCheckout: true,
@@ -68,14 +68,16 @@ export function readLockHolder(fleetToken) {
68
68
  * neighbour in the same repo, `flowviant stop` sweeping the lot, uninstall
69
69
  * protecting the copy a live daemon runs from. An unreadable (or absent)
70
70
  * directory is not an error here, it is an empty machine. `fs` and `home` are
71
- * injectable for uninstall's test seams.
71
+ * injectable for uninstall's test seams. The manual WSL restart asks for
72
+ * `strict`: only a missing directory is empty; other scan failures throw.
72
73
  */
73
- export function listDaemonLocks({ fs = nodeFs, home = homedir() } = {}) {
74
+ export function listDaemonLocks({ fs = nodeFs, home = homedir(), strict = false } = {}) {
74
75
  const dir = join(home, '.flowviant');
75
76
  let names;
76
77
  try {
77
78
  names = fs.readdirSync(dir);
78
- } catch {
79
+ } catch (error) {
80
+ if (strict && error.code !== 'ENOENT') throw error;
79
81
  return [];
80
82
  }
81
83
  return names
@@ -64,6 +64,9 @@ export function desktopStatus({ entries = listStoredProjects(), runtimes = detec
64
64
  holder: liveState?.holder ?? null,
65
65
  // Measured by the daemon; null = unknown, never idle (see `busyOf`).
66
66
  busy: busyOf(liveState, nowMs),
67
+ maintenance: busyOf(liveState, nowMs) !== null && typeof liveState?.maintenance === 'string' ? liveState.maintenance : null,
68
+ maintenanceEpoch: busyOf(liveState, nowMs) !== null && typeof liveState?.maintenanceEpoch === 'string' ? liveState.maintenanceEpoch : null,
69
+ liveAgents: busyOf(liveState, nowMs) !== null && Number.isInteger(liveState?.liveAgents) && liveState.liveAgents >= 0 ? liveState.liveAgents : null,
67
70
  // What a standing-down daemon is finishing (`web → prod`), from its
68
71
  // lock's draining mark; null when it is not draining or unknown. A
69
72
  // daemon→desktop report field: an older tray ignores it.
@@ -0,0 +1,19 @@
1
+ /** A discovery contract for the person's CLI, never a server-side diagnosis. */
2
+ export const REGRESSION_INVESTIGATION = `REGRESSION INVESTIGATION — for "this existed before and now is gone", restoration requests, and bugs that persist after Done:
3
+ 1. Keep the REQUESTED OUTCOME separate from a proposed cause. The runtime baseline names cwd, HEAD, execution base and dirty state; it does not prove you inspected relevant code. Reconcile differing or dirty baselines before relying on them.
4
+ 2. Before naming a cause, formula or restoration location, trace current rendering and data flow: locate the UI label/symbol, its render conditions and formatting, the calculation, its data provider, missing/loading inputs, and relevant tests. Reading tests is not running them; never claim tests passed merely because you read them. Do not run write-producing tests or builds under this read-only profile.
5
+ 3. Inspect available local Git history with supported plain readers: git log --oneline -- <relative-path>, git log -p --follow -- <relative-path>, git log -S delta -- <relative-path>, git show <commit>:<relative-path>, and relevant diffs. Compare the previous implementation with current code. Missing/shallow history or an unavailable deployed version limits the comparison; say exactly which comparison remains unverified.
6
+ 4. Explain only supported evidence. A surviving hidden display needs its render condition investigated, not an invented replacement formula. A missing input needs its provider/comparison data traced, not a claim that the display was deleted. A removed implementation needs a verified historical file/commit and a compatibility check against current code before proposing restoration.
7
+ 5. A board card marked Done is history, not proof that an implementation shipped or that the reported behavior works in the deployed version. In capture, dedupe with list_cards and list_staged; use find_cards/read_card for held or completed matches. Refine a pending/staged match in place. A recurrence after Done is new work with followUpOf, never proof of its cause. Scratch planning instead compares the supplied cards and live-agent context; it has no staging MCP.
8
+ 6. A failed read requires supported recovery before claiming repository discovery is unavailable. Preserve the actual failure and each attempted alternative; distinguish syntax refusal, permission denial, missing path/history and absent tool. Runtime outcomes are observations; model-authored path/line/commit citations and causal explanations remain separate claims. A request alone proves no successful read.
9
+ 7. In capture, still STAGE FIRST: if discovery remains unresolved, stage an investigation-first fix for the requested outcome, requiring reproduction and cause establishment before implementation. State unverified causes in assumed, explicit discovery limits and failures/attempts in the brief, and do not invent a formula or restoration path. Use optional grounding when the served staging tool advertises it; otherwise put requested outcome, observed code/history, discovery limits and recovery attempts in clearly separate description sections. Stage before asking the one remaining person-only question, and edit_staged after its answer rather than creating a twin. In scratch planning, retain that investigation-first ordering in the proposal; do not turn an unverified explanation into implementation instructions.`;
10
+
11
+ export function discoveryGuide(runtime, lane) {
12
+ const readers = runtime === 'claude'
13
+ ? `Claude: scoped Read and Glob, in-workspace Grep, and approved plain shell readers (cat/head/ls/wc and git log/show/diff/rev-parse). The read guard rejects shell composition, expansions/globs and unsafe paths; quoted pipes and bracket Git paths can still be refused. Quotes/escaping do not bypass the guard. Prefer native Read/Grep/Glob after such a refusal. Replace git log -G 'delta|change' with separate plain git log -S delta and git log -S change calls. For a bracket working-tree path use native Read with the literal path; for history use a supported parent-relative Git path or a scoped commit diff and inspect the relevant entry. Never disable the guard or request unrestricted Bash.`
14
+ : runtime === 'codex'
15
+ ? `Codex: this profile uses a read-only sandbox with shell discovery such as cat, head, rg and plain Git readers; Claude's dedicated Read/Grep/Glob and read guard are not guaranteed here. Use only tools advertised this turn. No native web, subagents, browser/apps or unrestricted shell are granted. Read only the repository and explicitly supplied project knowledge.`
16
+ : `This runtime: use the native file/list/search readers actually advertised this turn. Shell availability is unverified and may be denied; do not claim all reading is blocked after one shell refusal. Keep the read-only profile.`;
17
+ return `DISCOVERY CAPABILITIES (${runtime}, ${lane}):\n${readers}\n` +
18
+ `Recovery order: use a cwd-relative path; use one plain reader rather than a composed command; use native Read/Grep/Glob where supported, or shell cat/head/rg where permitted; use git show <commit>:<relative-path> when a working-tree file is missing and history is available. If a utility is absent, switch to an advertised reader. Do not try to evade a secret/path denial. Re-read the actual result after each attempt. Preserve the first failure and subsequent recovery attempts, including an unresolved result; then state the remaining limit rather than asking the person to disable protections. ${lane === 'capture' ? 'Only the scoped Flowviant staging tools write records; never change repository files.' : 'Scratch planning has no Flowviant MCP; return a proposal only.'}`;
19
+ }
package/bin/lib/fleet.mjs CHANGED
@@ -36,6 +36,7 @@ import { handleVersionSignal } from './update.mjs';
36
36
  import { compareVersions } from './versionOrder.mjs';
37
37
  import { emitMachineEvent } from './daemonLogging.mjs';
38
38
  import { writeDaemonState } from './daemonState.mjs';
39
+ import { maintenanceTick, readMaintenance } from './maintenance.mjs';
39
40
  import { repoRootOrDie, detectBaseRef, baseBranchName, excludeInWorktree, usableBaseRef } from './git.mjs';
40
41
  import { c, info, note, ok, warn, fail } from './ui.mjs';
41
42
  import { sleep } from './sleep.mjs';
@@ -56,6 +57,7 @@ import { createLeave } from './standDownExit.mjs';
56
57
  import { stopWorkLanes } from './standDownGate.mjs';
57
58
  import { markLockDraining } from './instance.mjs';
58
59
  import { reportDeployConfig } from './deployConfig.mjs';
60
+ import { mcpInstallsInFlight, processMcpInstallJobs } from './mcpInstall.mjs';
59
61
  import { probeSkillsOnce } from './runtimeCapabilities.mjs';
60
62
  import { createWorkManager } from './work.mjs';
61
63
  import { INTAKE_ROSTER_KEY } from './workIntake.mjs';
@@ -70,6 +72,7 @@ import {
70
72
  maybeReportLocalSessions,
71
73
  maybeReportRepoState,
72
74
  maybeReportTools,
75
+ recheckProjectMcp,
73
76
  repoStateChanged,
74
77
  reportMachine,
75
78
  } from './fleetReports.mjs';
@@ -249,6 +252,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
249
252
  flushWorkReports,
250
253
  learnPlaces,
251
254
  processWorkTurns,
255
+ interruptWorkTurns,
252
256
  processShipJobs,
253
257
  processDiffJobs,
254
258
  processKillJobs,
@@ -256,6 +260,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
256
260
  processAgentPlanJobs,
257
261
  processIntakeJobs,
258
262
  processAgentTurnJobs,
263
+ interruptAgentTurns,
259
264
  processAgentMergeJobs,
260
265
  freshenManualPlaces,
261
266
  heldSessionIds,
@@ -265,9 +270,11 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
265
270
  shutdownPreviews,
266
271
  retireWorkSessions,
267
272
  reportWorktrees,
273
+ reconcileIdleArtifacts,
268
274
  shutdownWork,
269
275
  settleAgentTurns,
270
276
  workBusy,
277
+ runningAgentCount,
271
278
  admit,
272
279
  liveTurns,
273
280
  } = createWorkManager({
@@ -331,9 +338,9 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
331
338
  * from the server's list of WORKING AGENTS, which cannot see a Workbench
332
339
  * turn, a ship or a deploy.
333
340
  */
334
- const machineBusy = () => wiki.busy() || workBusy() || deploysInFlight() > 0;
341
+ const machineBusy = () => wiki.busy() || workBusy() || deploysInFlight() > 0 || mcpInstallsInFlight() > 0;
335
342
  const reportBusy = () =>
336
- writeDaemonState(desktopProjectId, { busy: machineBusy(), busyAt: new Date().toISOString() });
343
+ writeDaemonState(desktopProjectId, { busy: machineBusy(), liveAgents: runningAgentCount(), maintenance: null, busyAt: new Date().toISOString() });
337
344
  /**
338
345
  * HOW EVERY STAND-DOWN LEAVES — after the teardown, and after any deploy in
339
346
  * flight has finished and reported (standDownExit.mjs, ruling 2026-09-26).
@@ -379,6 +386,16 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
379
386
  // share, no new deploy — only the deploys already claimed run on to their
380
387
  // report (standDownExit.mjs).
381
388
  const standingDown = () => leave.leaving();
389
+ let lastAgentJobs = [];
390
+ const reportMaintenance = (jobs = lastAgentJobs) => maintenanceTick({
391
+ busy: machineBusy,
392
+ agents: runningAgentCount,
393
+ flush: async () => {
394
+ await flushWorkReports();
395
+ processAgentTurnJobs(jobs, { reportsOnly: true });
396
+ },
397
+ report: (state) => writeDaemonState(desktopProjectId, state),
398
+ });
382
399
  for (;;) {
383
400
  if (standingDown()) return leave.drained();
384
401
  let roster;
@@ -412,7 +429,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
412
429
  }
413
430
  warn(`roster poll failed: ${e.message} — retrying in ${RECONCILE_SECONDS}s`);
414
431
  // Work already running keeps running offline; the measurement keeps up.
415
- reportBusy();
432
+ if (!await reportMaintenance()) reportBusy();
416
433
  await sleep(RECONCILE_SECONDS);
417
434
  continue;
418
435
  }
@@ -490,7 +507,7 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
490
507
  // same one the desktop app's auto-install reads — which is how a deploy
491
508
  // in flight joined this gate (2026-09-26): re-exec'ing under one would
492
509
  // stop its heartbeat mid-run.
493
- const safeToUpdate = !machineBusy();
510
+ const safeToUpdate = !machineBusy() && !readMaintenance();
494
511
  const updating = handleVersionSignal({
495
512
  latest: roster.daemon.latest,
496
513
  min: roster.daemon.min,
@@ -501,6 +518,19 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
501
518
  if (await updating) return;
502
519
  if (standingDown()) return leave.drained();
503
520
  }
521
+ // Keep polling to renew the running agents' leases and honour stops and
522
+ // interrupts. A paused tick admits no roster jobs, even after becoming
523
+ // idle; only held answers are retried until the tray releases its lease.
524
+ // Interrupts precede both paths so a stop arriving while paused still
525
+ // reaches its running CLI, and a successor sees the stop before intake.
526
+ interruptWorkTurns(roster.interruptTurns);
527
+ interruptAgentTurns(roster.interruptAgentTurns);
528
+ lastAgentJobs = roster.agentTurnJobs;
529
+ if (await reportMaintenance()) {
530
+ reportMachine({ worktreeDir: baseDir, liveTurns });
531
+ await sleep(RECONCILE_SECONDS);
532
+ continue;
533
+ }
504
534
  // Settle any turn/ship answers whose earlier report POST failed BEFORE
505
535
  // taking new work — the skip-if-pending guards make the ordering safe, but
506
536
  // delivering first keeps the tab honest a poll sooner.
@@ -594,6 +624,10 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
594
624
  // themselves from a browser, relayed. After retirement so a directory that
595
625
  // just went away is not reported as a place.
596
626
  reportWorktrees(roster.activeWorkSessions);
627
+ // …and an idle agent's delivery against what its worktree still holds:
628
+ // once per process, beside no turn (workArtifactReconcile.mjs). After the
629
+ // turn intake above, so a card about to run in a place is not raced.
630
+ void reconcileIdleArtifacts(roster.activeWorkSessions)?.catch(() => {});
597
631
  // Terminal-session presence, throttled + dedup'd inside; never awaited —
598
632
  // the daemon's own worktrees are carved out (a session the daemon spawned
599
633
  // is already a tab, not something to offer adopting).
@@ -657,6 +691,14 @@ export async function runFleetDaemon({ afterLock = null } = {}) {
657
691
  // This is what replaced the vault's sync tick — see `maybeReportEnv`.
658
692
  void maybeReportEnv(repoRoot);
659
693
  void maybeReportTools(repoRoot, getBaseRef());
694
+ // INSTALLS AN OWNER CONFIRMED FOR THIS BOX (mcpInstall.mjs) — handed to
695
+ // the picked box whether it holds the machine or stands by; each is
696
+ // claimed, run exactly as confirmed, and followed by a re-check.
697
+ processMcpInstallJobs(roster.mcpInstallJobs, {
698
+ repoRoot,
699
+ myPubB64,
700
+ recheck: () => recheckProjectMcp(repoRoot, getBaseRef()),
701
+ });
660
702
 
661
703
  // Tell the app what this machine is doing with itself (fleetReports.mjs).
662
704
  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
@@ -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.
@@ -0,0 +1,74 @@
1
+ import { inflateSync } from 'node:zlib';
2
+ import { buildArtifactUpload } from './artifacts.mjs';
3
+
4
+ /** Read through the artifact scanner's bounded, no-follow reader. A filename
5
+ * alone cannot prove that generated textures are usable. */
6
+ export function usableImageArtifact(entry) {
7
+ const bytes = buildArtifactUpload(entry, { agentId: 'validation' })?.bytes;
8
+ if (!bytes) return false;
9
+ if (/\.webp$/i.test(entry.name)) {
10
+ return bytes.length >= 20 && bytes.toString('ascii', 0, 4) === 'RIFF'
11
+ && bytes.toString('ascii', 8, 12) === 'WEBP'
12
+ && bytes.readUInt32LE(4) + 8 === bytes.length;
13
+ }
14
+ return usablePng(bytes);
15
+ }
16
+
17
+ const crc32 = (bytes) => {
18
+ let crc = 0xffffffff;
19
+ for (const byte of bytes) {
20
+ crc ^= byte;
21
+ for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
22
+ }
23
+ return (crc ^ 0xffffffff) >>> 0;
24
+ };
25
+
26
+ /** PNG structure, checksums and decoded scanlines, bounded at 64 MiB. Supports
27
+ * all standard colour types/bit depths and Adam7; no canvas or network needed. */
28
+ export function usablePng(bytes) {
29
+ try {
30
+ if (!bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return false;
31
+ let header = null;
32
+ const data = [];
33
+ let ended = false;
34
+ for (let at = 8; at < bytes.length;) {
35
+ if (at + 12 > bytes.length) return false;
36
+ const size = bytes.readUInt32BE(at);
37
+ const end = at + 8 + size;
38
+ if (end + 4 > bytes.length || crc32(bytes.subarray(at + 4, end)) !== bytes.readUInt32BE(end)) return false;
39
+ const type = bytes.toString('ascii', at + 4, at + 8);
40
+ if (!header && (type !== 'IHDR' || size !== 13)) return false;
41
+ if (type === 'IHDR') {
42
+ if (header || size !== 13) return false;
43
+ header = bytes.subarray(at + 8, end);
44
+ }
45
+ if (type === 'IDAT') data.push(bytes.subarray(at + 8, end));
46
+ if (type === 'IEND') {
47
+ if (size !== 0 || end + 4 !== bytes.length) return false;
48
+ ended = true;
49
+ }
50
+ at = end + 4;
51
+ }
52
+ if (!header || !ended || !data.length) return false;
53
+ const width = header.readUInt32BE(0), height = header.readUInt32BE(4);
54
+ const depth = header[8], colour = header[9], interlace = header[12];
55
+ const depths = { 0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16] };
56
+ if (!width || !height || !depths[colour]?.includes(depth) || header[10] || header[11] || interlace > 1) return false;
57
+ const channels = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[colour];
58
+ const passes = interlace ? [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2]] : [[0, 0, 1, 1]];
59
+ const rows = passes.map(([x, y, dx, dy]) => {
60
+ const w = Math.max(0, Math.ceil((width - x) / dx));
61
+ const h = Math.max(0, Math.ceil((height - y) / dy));
62
+ return { h: w ? h : 0, stride: 1 + Math.ceil(w * channels * depth / 8) };
63
+ });
64
+ const expected = rows.reduce((n, { h, stride }) => n + h * stride, 0);
65
+ if (expected > 64 * 1024 * 1024) return false;
66
+ const pixels = inflateSync(Buffer.concat(data), { maxOutputLength: expected });
67
+ if (pixels.length !== expected) return false;
68
+ let at = 0;
69
+ for (const { h, stride } of rows) {
70
+ for (let row = 0; row < h; row++, at += stride) if (pixels[at] > 4) return false;
71
+ }
72
+ return true;
73
+ } catch { return false; }
74
+ }
@@ -0,0 +1,47 @@
1
+ /** A manual tray restart drains intake without signalling any existing run.
2
+ * The lease expires if the tray crashes. A daemon acknowledges only at the
3
+ * reconcile boundary, after all previously admitted work has been counted. */
4
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { randomUUID } from 'node:crypto';
8
+
9
+ export const MAINTENANCE_LEASE_MS = 120_000;
10
+ const pathFor = (home) => join(home, '.flowviant', 'maintenance.json');
11
+ export function readMaintenanceRequest({ home = homedir(), now = Date.now() } = {}) {
12
+ try {
13
+ const value = JSON.parse(readFileSync(pathFor(home), 'utf8'));
14
+ return typeof value.token === 'string' && /^[a-zA-Z0-9-]{1,100}$/.test(value.token) && typeof value.epoch === 'string' &&
15
+ Number.isFinite(value.expires) && value.expires > now && value.expires <= now + MAINTENANCE_LEASE_MS
16
+ ? value : null;
17
+ } catch { return null; }
18
+ }
19
+ export function readMaintenance(options) { return readMaintenanceRequest(options)?.token ?? null; }
20
+ export function requestMaintenance(token, { home = homedir(), now = Date.now() } = {}) {
21
+ if (typeof token !== 'string' || !/^[a-zA-Z0-9-]{1,100}$/.test(token)) throw new Error('Invalid restart request');
22
+ const current = readMaintenanceRequest({ home, now });
23
+ if (current && current.token !== token) throw new Error('Another WSL restart is pending');
24
+ mkdirSync(join(home, '.flowviant'), { recursive: true });
25
+ const path = pathFor(home);
26
+ const tmp = `${path}.${process.pid}.tmp`;
27
+ const epoch = current?.epoch ?? randomUUID();
28
+ writeFileSync(tmp, JSON.stringify({ token, epoch, expires: now + MAINTENANCE_LEASE_MS }), { mode: 0o600 });
29
+ renameSync(tmp, path);
30
+ return epoch;
31
+ }
32
+ export function releaseMaintenance(token, { home = homedir(), now = Date.now() } = {}) {
33
+ if (readMaintenance({ home, now }) === token) rmSync(pathFor(home), { force: true });
34
+ }
35
+
36
+ /** The paused branch does only reporting. In particular, an acknowledgement
37
+ * can never be copied from a pre-pause idle measurement. */
38
+ export async function maintenanceTick({ request = readMaintenanceRequest, busy, agents, flush, report }) {
39
+ const lease = request();
40
+ if (!lease) return false;
41
+ await flush();
42
+ // A slow network flush can outlive the request. Never acknowledge an
43
+ // expired generation whose next reconcile is allowed to admit work again.
44
+ if (request()?.epoch !== lease.epoch) return true;
45
+ report({ maintenance: lease.token, maintenanceEpoch: lease.epoch, busy: busy(), liveAgents: agents(), busyAt: new Date().toISOString() });
46
+ return true;
47
+ }