flowviant 0.116.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -11,17 +11,27 @@
11
11
  * measured "not installed" under the tray (2026-09-25).
12
12
  *
13
13
  * So the daemon asks the person's login shell for its PATH once, and keeps
14
- * every entry it already had FIRST (a terminal start is unchanged) with the
14
+ * every entry it already had FIRST (a terminal start keeps its order) with the
15
15
  * shell's additions after it; then the usual per-user bin directories that
16
16
  * exist. Best-effort and bounded: a shell that hangs or prints nothing costs a
17
- * few seconds once, and nothing is ever removed from PATH.
17
+ * few seconds once.
18
+ *
19
+ * ONLY ABSOLUTE ENTRIES ARE KEPT, from every input — the current PATH too
20
+ * (2026-10-10, F118). A relative entry (`node_modules/.bin`, `.`, and the empty
21
+ * entry, which also means "here") resolves against whatever directory a child
22
+ * is spawned in, and the daemon spawns `git`, `gh`, `cloudflared` and the CLIs
23
+ * by bare name with `cwd` set to an agent's worktree: a binary an agent plants
24
+ * there would run as the daemon, outside any sandbox, with its credentials. A
25
+ * common ~/.bashrc idiom is enough to open that. `FLOWVIANT_KEEP_PATH=1` is the
26
+ * operator's explicit opt-out and leaves PATH exactly as they set it.
18
27
  */
19
28
 
20
- import { execFileSync, spawn } from 'node:child_process';
29
+ import { execFileSync } from 'node:child_process';
21
30
  import { existsSync, readdirSync } from 'node:fs';
22
31
  import { homedir, platform } from 'node:os';
23
32
  import { join } from 'node:path';
24
33
  import { compareVersions } from './versionOrder.mjs';
34
+ import { relaunchSelf, selfArgv } from './launchCommand.mjs';
25
35
 
26
36
  const MARK = '__FLOWVIANT_PATH__=';
27
37
 
@@ -60,12 +70,13 @@ export function userBinDirs({ home = homedir(), exists = existsSync, list = read
60
70
  return dirs.filter((d) => exists(d));
61
71
  }
62
72
 
63
- /** Current entries first, then any the others add. Order-preserving, deduped. */
73
+ /** Current entries first, then any the others add. Order-preserving, deduped,
74
+ * absolute only (see the header): an empty or relative entry is dropped. */
64
75
  export function mergePath(current, ...more) {
65
76
  const seen = new Set();
66
77
  const out = [];
67
78
  for (const part of [current, ...more].flatMap((p) => (Array.isArray(p) ? p : String(p ?? '').split(':')))) {
68
- if (!part || seen.has(part)) continue;
79
+ if (!part || !part.startsWith('/') || seen.has(part)) continue;
69
80
  seen.add(part);
70
81
  out.push(part);
71
82
  }
@@ -91,24 +102,18 @@ export function adoptLoginPath({ shell = true, env = process.env, ...deps } = {}
91
102
  * processes are looked up and started with the environment the process
92
103
  * STARTED with unless a spawn passes `env` — a later `process.env.PATH = …` is
93
104
  * invisible to them (measured 2026-09-25: node found ~/.local/bin/claude after
94
- * the change, the compiled binary did not). So when the PATH grew, the
95
- * binary restarts itself once with it: a thin proxy, the same shape as the
96
- * self-update re-exec, stdio inherited, exit code and signals passed through.
97
- * Node needs none of that and adopts in place.
105
+ * the change, the compiled binary did not). So when the PATH changed, the
106
+ * binary restarts itself once with it through the one relaunch proxy
107
+ * (launchCommand.mjs `relaunchSelf`): stdio inherited, exit code and signals
108
+ * passed through. Node needs none of that and adopts in place.
98
109
  */
99
110
  export async function adoptLoginPathForProcess(opts = {}) {
100
111
  if (process.env.FLOWVIANT_PATH_ADOPTED === '1') return;
101
112
  const before = process.env.PATH;
102
113
  const after = adoptLoginPath(opts);
103
114
  if (!process.versions.bun || after === before) return;
104
- const compiled = process.argv[1]?.startsWith('/$bunfs/');
105
- const child = spawn(process.execPath, process.argv.slice(compiled ? 2 : 1), {
106
- stdio: 'inherit',
115
+ await relaunchSelf({
116
+ argv: selfArgv(process.argv.slice(2)),
107
117
  env: { ...process.env, FLOWVIANT_PATH_ADOPTED: '1' },
108
118
  });
109
- for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
110
- process.on(signal, () => { try { child.kill(signal); } catch { /* gone */ } });
111
- }
112
- const code = await new Promise((resolve) => child.on('exit', (status, signal) => resolve(status ?? (signal ? 1 : 0))));
113
- process.exit(code);
114
119
  }
@@ -92,11 +92,14 @@ const JUST_NOW = 'just now';
92
92
  /**
93
93
  * A coarse age. Never "0s": a box heard a moment ago reads as "just now",
94
94
  * because a daemon that polled four hundred milliseconds ago has not been
95
- * silent for no time at all — the same rule `agoLabel` keeps in holder.mjs and
96
- * `elapsed` keeps in the web.
95
+ * silent for no time at all — the same rule `elapsed` keeps in the web.
97
96
  *
98
97
  * NULL for anything unmeasurable, so the caller DROPS the clause rather than
99
- * printing a duration nobody took.
98
+ * printing a duration nobody took — the one rule this shares with holder.mjs's
99
+ * `agoLabel`. That one is a different formatter on purpose (2026-10-10, F084:
100
+ * this comment used to claim it kept the "never 0s" rule too): it prints a
101
+ * measured zero as "0s" and steps at 60s/60m/48h, and holder.test.mjs pins
102
+ * both.
100
103
  */
101
104
  export function agoFrom(iso, now = Date.now()) {
102
105
  const t = Date.parse(iso ?? '');
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ /** The restart must also see live locks not backed by a stored project, and
2
+ * unguarded processes recorded in state. Neither is an empty machine. */
3
+ import { instanceLockPath, listDaemonLocks } from './daemonLocks.mjs';
4
+ import { daemonLockPid, readDaemonState } from './daemonState.mjs';
5
+ import { listStoredProjects } from './credentials.mjs';
6
+ import { processAlive } from './procRegistry.mjs';
7
+
8
+ export function unknownMaintenanceDaemons({ entries = listStoredProjects(), locks = listDaemonLocks(),
9
+ stateFor = readDaemonState, pidFor = daemonLockPid, alive = processAlive } = {}) {
10
+ const known = new Set();
11
+ const unknown = new Set();
12
+ for (const entry of entries) {
13
+ const state = stateFor(entry.projectId);
14
+ const pid = pidFor(entry.fleetToken);
15
+ if (pid != null && pid === state.pid) known.add(pid);
16
+ if (Number.isInteger(state.pid) && state.pid > 0 && state.pid !== pid && alive(state.pid)) unknown.add(state.pid);
17
+ }
18
+ for (const { path, holder } of locks) {
19
+ if (!holder) unknown.add(path); // unreadable is not absent
20
+ else if (alive(holder.pid) && !known.has(holder.pid)) unknown.add(holder.pid);
21
+ }
22
+ return unknown.size;
23
+ }
24
+
25
+ /** The ordinary tray status reserves null for an absent lock. For a global
26
+ * restart we additionally measure the whole lock directory: no lock and no
27
+ * live state PID means this stored project's daemon is stopped. Unreadable
28
+ * directories throw rather than becoming a fabricated empty machine. */
29
+ export function maintenanceInventory(status, { entries = listStoredProjects(), locks = listDaemonLocks({ strict: true }),
30
+ stateFor = readDaemonState, pidFor = daemonLockPid, alive = processAlive } = {}) {
31
+ const paths = new Set(locks.map(lock => lock.path));
32
+ const projects = status.projects.map(row => {
33
+ const entry = entries.find(entry => entry.projectId === row.id);
34
+ if (row.running !== null || !entry || paths.has(instanceLockPath(entry.fleetToken))) return row;
35
+ const pid = stateFor(row.id).pid;
36
+ return Number.isInteger(pid) && pid > 0 && alive(pid) ? row : { ...row, running: false };
37
+ });
38
+ return { projects, unknownDaemons: unknownMaintenanceDaemons({ entries, locks, stateFor, pidFor, alive }) };
39
+ }
@@ -17,13 +17,16 @@
17
17
  * never from a CLI credential sitting on a shared machine.
18
18
  */
19
19
 
20
- import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
20
+ import { FLEET_TOKEN, MCP_URL, FLEET_URL } from './config.mjs';
21
21
  import { terminalCommand } from './launchCommand.mjs';
22
22
  import { fleetEndpoint } from './fleetWire.mjs';
23
+ import { postFleetJson } from './fleetPost.mjs';
23
24
 
24
25
  const CLI_TOKEN_URL = fleetEndpoint('cli-token', FLEET_URL);
25
26
 
26
- export async function runMcpCommand(args = []) {
27
+ /** `timeoutMs` is the request's ceiling — injectable only so a test can prove
28
+ * a hung server is abandoned without waiting thirty seconds. */
29
+ export async function runMcpCommand(args = [], { timeoutMs = 30_000 } = {}) {
27
30
  if (!FLEET_TOKEN) {
28
31
  console.error(
29
32
  `error: no credential resolves here. Run \`${terminalCommand('login')}\` first — or, with\n` +
@@ -33,16 +36,12 @@ export async function runMcpCommand(args = []) {
33
36
  process.exit(1);
34
37
  }
35
38
 
39
+ // BOUNDED (audit 2026-10-10, F102): this was the one fleet request with no
40
+ // timeout, so a hung server held the command forever. A timeout lands in
41
+ // the catch below and says so like any other unreachable server.
36
42
  let res;
37
43
  try {
38
- res = await fetch(CLI_TOKEN_URL, {
39
- method: 'POST',
40
- headers: {
41
- authorization: `Bearer ${FLEET_TOKEN}`,
42
- 'content-type': 'application/json',
43
- 'user-agent': USER_AGENT,
44
- },
45
- });
44
+ res = await postFleetJson(CLI_TOKEN_URL, undefined, { timeoutMs });
46
45
  } catch (err) {
47
46
  console.error(`error: could not reach Flowviant (${err?.message || err})`);
48
47
  process.exit(1);
@@ -0,0 +1,163 @@
1
+ /**
2
+ * A CONFIRMED MCP INSTALL (2026-10-13, the owner; the app's half is
3
+ * packages/shared `mcpSetup.ts`). A project owner pressed "Install on <this
4
+ * machine>" on the Machines page and confirmed the exact command; the roster
5
+ * hands it to THIS box as `mcpInstallJobs`.
6
+ *
7
+ * WHAT THIS DOES, AND ALL IT DOES:
8
+ * · claims the job (single-flight across processes, bound to this box);
9
+ * · refuses, without running anything, a command its own copy of the rule
10
+ * refuses (`mcpInstallCommandRefusal` — a privilege word, more than one
11
+ * line, over the cap; parity rule 36 holds it to the server's);
12
+ * · runs EXACTLY the text it was handed through `/bin/sh -c`, in the
13
+ * project's checkout, as this daemon's own user, never with sudo, stdin
14
+ * closed, as an unattended child (no machine credential, no display), in
15
+ * its own process group killed whole at the timeout;
16
+ * · streams the output's tail back about once a second, and at the end the
17
+ * exit code;
18
+ * · then looks again (`recheck`): the tool report is rescanned and posted,
19
+ * and Claude Code is probed with the base branch's servers mounted, so the
20
+ * page's checklist clears when the server starts.
21
+ *
22
+ * The command is never rewritten, prefixed or appended to.
23
+ */
24
+ import { spawn } from 'node:child_process';
25
+ import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
26
+ import { fleetEndpoint } from './fleetWire.mjs';
27
+ import { claimFleetLease, postBestEffort, settleLease } from './fleetPost.mjs';
28
+ import { killGroup } from './processGroup.mjs';
29
+ import { unattendedEnv } from './noWindowEnv.mjs';
30
+ import { scrubTail } from './uplinkScrub.mjs';
31
+ import { c, note, ok, warn } from './ui.mjs';
32
+ import { printableLine } from './printable.mjs';
33
+
34
+ /** The server's bounds (shared `mcpSetup.ts`; parity rule 36). */
35
+ export const MCP_INSTALL_COMMAND_MAX = 500;
36
+ export const MCP_INSTALL_OUTPUT_MAX = 16_000;
37
+ export const MCP_INSTALL_TIMEOUT_MS = 10 * 60_000;
38
+ export const MCP_INSTALL_PRIVILEGE_WORDS = Object.freeze(['sudo', 'doas', 'su', 'pkexec', 'run0']);
39
+
40
+ /** Why this command cannot run, or null — the shared rule, word for word. */
41
+ export function mcpInstallCommandRefusal(command) {
42
+ if (typeof command !== 'string' || !command.trim()) return 'The install command is empty.';
43
+ if (command.length > MCP_INSTALL_COMMAND_MAX) return `The install command is over ${MCP_INSTALL_COMMAND_MAX} characters.`;
44
+ if (/[\r\n\0]/.test(command)) return 'The install command must be one line.';
45
+ const words = command.split(/[^A-Za-z0-9_-]+/);
46
+ const word = MCP_INSTALL_PRIVILEGE_WORDS.find((w) => words.includes(w));
47
+ if (word) return `Flowviant never installs with ${word}. Use a command that installs as the machine's own user.`;
48
+ return null;
49
+ }
50
+
51
+ const url = (tail) => fleetEndpoint(tail, FLEET_URL);
52
+ const tail = (text) => (text.length > MCP_INSTALL_OUTPUT_MAX ? text.slice(text.length - MCP_INSTALL_OUTPUT_MAX) : text);
53
+
54
+ /**
55
+ * Run one command and hand back `{ code, out }`. `onOutput(out)` hears the
56
+ * tail as it grows. `spawnChild` is a test's.
57
+ */
58
+ export function runInstallCommand(command, { cwd, timeoutMs = MCP_INSTALL_TIMEOUT_MS, env = unattendedEnv(), onOutput = () => {}, spawnChild = spawn } = {}) {
59
+ return new Promise((resolve) => {
60
+ let child;
61
+ try {
62
+ child = spawnChild('/bin/sh', ['-c', command], { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
63
+ } catch (error) {
64
+ resolve({ code: -1, out: `[flowviant] could not start the command: ${error?.message ?? error}` });
65
+ return;
66
+ }
67
+ let buf = '';
68
+ let settled = false;
69
+ const settle = (v) => {
70
+ if (settled) return;
71
+ settled = true;
72
+ clearTimeout(timer);
73
+ resolve(v);
74
+ };
75
+ const take = (d) => {
76
+ buf += d;
77
+ if (buf.length > 2 * MCP_INSTALL_OUTPUT_MAX) buf = scrubTail(buf, MCP_INSTALL_OUTPUT_MAX);
78
+ onOutput(scrubTail(buf, MCP_INSTALL_OUTPUT_MAX));
79
+ };
80
+ child.stdout?.setEncoding('utf8');
81
+ child.stderr?.setEncoding('utf8');
82
+ child.stdout?.on('data', take);
83
+ child.stderr?.on('data', take);
84
+ const timer = setTimeout(() => {
85
+ killGroup(child, 'SIGKILL');
86
+ const minutes = Math.round(timeoutMs / 60_000);
87
+ settle({ code: -1, out: scrubTail(`${buf}\n[flowviant] the command ran past ${minutes >= 1 ? `${minutes} minutes` : `${timeoutMs}ms`} and was stopped`, MCP_INSTALL_OUTPUT_MAX) });
88
+ }, timeoutMs);
89
+ timer.unref?.();
90
+ child.on('error', (error) => settle({ code: -1, out: scrubTail(`${buf}\n[flowviant] ${error?.message ?? error}`, MCP_INSTALL_OUTPUT_MAX) }));
91
+ child.on('close', (code) => settle({ code: code ?? -1, out: scrubTail(buf, MCP_INSTALL_OUTPUT_MAX) }));
92
+ });
93
+ }
94
+
95
+ /** Job ids this process has taken — never run twice in one process. */
96
+ const seen = new Set();
97
+ let running = 0;
98
+ /** Installs this process is running — the machine is busy while one is. */
99
+ export const mcpInstallsInFlight = () => running;
100
+
101
+ /**
102
+ * The roster's `mcpInstallJobs` for this box. `ctx` = { repoRoot, myPubB64,
103
+ * recheck }, and for tests `run`, `claim`, `report` and `final`.
104
+ */
105
+ export function processMcpInstallJobs(jobs, ctx) {
106
+ if (!Array.isArray(jobs)) return;
107
+ for (const job of jobs) {
108
+ if (!job || typeof job.id !== 'string' || typeof job.command !== 'string' || seen.has(job.id)) continue;
109
+ seen.add(job.id);
110
+ void runJob(job, ctx);
111
+ }
112
+ }
113
+
114
+ async function runJob(job, ctx) {
115
+ const pubkey = ctx.myPubB64?.();
116
+ if (!pubkey) return;
117
+ const claim = ctx.claim ?? ((fields) => claimFleetLease(url('mcp-install-claim'), fields));
118
+ const report = ctx.report ?? ((body) => settleLease(url('mcp-install-report'), body, { timeoutMs: 15_000 }));
119
+ const final = ctx.final ?? ((body) => postBestEffort(url('mcp-install-report'), { ...body, instance: DAEMON_INSTANCE }));
120
+ const run = ctx.run ?? runInstallCommand;
121
+ if (!(await claim({ jobId: job.id, pubkey }))) return;
122
+ const name = printableLine(String(job.serverName ?? 'MCP server'), 60);
123
+ const refusal = mcpInstallCommandRefusal(job.command);
124
+ if (refusal) {
125
+ warn(`${c.cyan('mcp')} ${c.dim(`— not installing ${name}: ${refusal}`)}`);
126
+ await final({ jobId: job.id, pubkey, output: `[flowviant] ${refusal}`, done: true, exitCode: -1 });
127
+ return;
128
+ }
129
+ running += 1;
130
+ note(`${c.cyan('mcp')} ${c.dim(`— installing ${name}, as confirmed in the app: ${printableLine(job.command, 200)}`)}`);
131
+ let latest = '';
132
+ let sentAt = 0;
133
+ let sending = false;
134
+ const beat = () => {
135
+ if (sending || latest === '' || Date.now() - sentAt < 1000) return;
136
+ sending = true;
137
+ sentAt = Date.now();
138
+ const output = latest;
139
+ void Promise.resolve(report({ jobId: job.id, pubkey, output, done: false, exitCode: null })).catch(() => {}).finally(() => { sending = false; });
140
+ };
141
+ const timer = setInterval(beat, 1000);
142
+ timer.unref?.();
143
+ try {
144
+ const timeoutMs = Number.isInteger(job.timeoutMs) && job.timeoutMs > 0 ? Math.min(job.timeoutMs, MCP_INSTALL_TIMEOUT_MS) : MCP_INSTALL_TIMEOUT_MS;
145
+ const { code, out } = await run(job.command, { cwd: ctx.repoRoot, timeoutMs, onOutput: (o) => { latest = tail(o); } });
146
+ clearInterval(timer);
147
+ await final({ jobId: job.id, pubkey, output: tail(out), done: true, exitCode: code });
148
+ if (code === 0) ok(`${c.cyan('mcp')} ${c.dim(`— installed ${name}; checking it again`)}`);
149
+ else warn(`${c.cyan('mcp')} ${c.dim(`— installing ${name} exited ${code}`)}`);
150
+ } finally {
151
+ clearInterval(timer);
152
+ running -= 1;
153
+ try {
154
+ await ctx.recheck?.();
155
+ } catch { /* a re-check is best-effort; the next beat looks anyway */ }
156
+ }
157
+ }
158
+
159
+ /** Tests only. */
160
+ export function resetMcpInstallsForTest() {
161
+ seen.clear();
162
+ running = 0;
163
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * EVERY MCP SERVER EACH CLI ON THIS BOX HAS, FOR THE MACHINES PAGE (2026-10-13,
3
+ * the owner: "see at a glance what Flowviant can do on this project, from what
4
+ * the serving machine's CLIs actually have").
5
+ *
6
+ * Per installed CLI, one list: the servers the CLI itself reported (Claude
7
+ * Code's init, harvested in runtimeCapabilities.mjs; Codex's `mcp list`, read
8
+ * in runtimeOfferings.mjs) and every server the base branch's `.mcp.json`
9
+ * declares (projectTools.mjs). Rides this box's own `tools-report`
10
+ * (fleetReports.mjs), so its 60-second beat and write-on-change dedup are the
11
+ * lane's; the server stores it per box (packages/shared `mcpInventory.ts`,
12
+ * the shape and the bounds, held equal by parity rule 35).
13
+ *
14
+ * DECLARED IS NOT PRESENT. A base `.mcp.json` server the CLI did not report
15
+ * running is `declared`, with the reason this daemon would leave it unmounted
16
+ * (`env X unset`, `command y not on PATH`, a transport the CLI refuses) when
17
+ * there is one — never shown as available because the file lists it.
18
+ *
19
+ * Names, statuses, sources, reasons and tool names only: no env value, header,
20
+ * token, URL or path leaves the box.
21
+ */
22
+ import { toolReadout } from './projectTools.mjs';
23
+ import { MCP_NAME_MAX } from './runtimeCapabilities.mjs';
24
+
25
+ /** The server's bounds (shared `mcpInventory.ts`; parity rule 35). */
26
+ export const MCP_INVENTORY_MAX_SERVERS = 80;
27
+ export const MCP_INVENTORY_NAME_MAX = MCP_NAME_MAX;
28
+ export const MCP_INVENTORY_TOOLS_PER_SERVER = 100;
29
+ export const MCP_INVENTORY_TOOLS_PER_CLI = 1000;
30
+ export const MCP_INVENTORY_TOOL_NAME_MAX = 64;
31
+ export const MCP_INVENTORY_REASON_MAX = 200;
32
+
33
+ const SOURCE_ORDER = { project: 0, personal: 1, claudeai: 2 };
34
+ const TOOL_NAME = /^[A-Za-z0-9_.:-]+$/;
35
+
36
+ /**
37
+ * Claude Code's scope word → the page's source. `project` is a checkout's
38
+ * `.mcp.json`; `claudeai` an account connector; `dynamic` is a server named on
39
+ * the command line, which on a turn the harvest records is the base copy when
40
+ * the name is declared and otherwise this daemon's own — not the person's.
41
+ * Every other scope (user, local, plugin, enterprise, managed, agent) is the
42
+ * CLI's own config on this box.
43
+ */
44
+ function claudeSource(scope, name, declared) {
45
+ if (scope === 'claudeai' || name.startsWith('claude.ai ')) return 'claudeai';
46
+ if (scope === 'project') return 'project';
47
+ if (scope === 'dynamic') return declared.has(name) ? 'project' : null;
48
+ return 'personal';
49
+ }
50
+
51
+ const declaredRows = (snapshot, runner) =>
52
+ snapshot ? toolReadout(snapshot, runner).filter((t) => t.kind === 'mcp') : [];
53
+
54
+ /** One CLI's list, bounded the way the server re-applies it. */
55
+ function bound(servers) {
56
+ let budget = MCP_INVENTORY_TOOLS_PER_CLI;
57
+ const seen = new Set();
58
+ return servers
59
+ .map((s) => ({ ...s, name: s.name.trim().slice(0, MCP_INVENTORY_NAME_MAX).trim() }))
60
+ .filter((s) => s.name && s.name !== 'flowviant' && !seen.has(s.name) && seen.add(s.name))
61
+ .sort((a, b) => SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
62
+ .slice(0, MCP_INVENTORY_MAX_SERVERS)
63
+ .map((s) => {
64
+ let tools = null;
65
+ if (Array.isArray(s.tools)) {
66
+ tools = [...new Set(s.tools.filter((t) => typeof t === 'string' && t.length <= MCP_INVENTORY_TOOL_NAME_MAX && TOOL_NAME.test(t)))]
67
+ .sort()
68
+ .slice(0, Math.min(MCP_INVENTORY_TOOLS_PER_SERVER, budget));
69
+ budget -= tools.length;
70
+ }
71
+ return { name: s.name, source: s.source, status: s.status, reason: s.reason == null ? null : String(s.reason).slice(0, MCP_INVENTORY_REASON_MAX), tools };
72
+ });
73
+ }
74
+
75
+ /** Claude Code: what its init reported, then each declaration it did not. */
76
+ export function claudeInventory(harvested, snapshot) {
77
+ const declared = new Map(declaredRows(snapshot, 'claude').map((t) => [t.name, t]));
78
+ const servers = [];
79
+ for (const h of harvested ?? []) {
80
+ const source = claudeSource(h.scope, h.name, declared);
81
+ if (!source) continue;
82
+ const why = source === 'project' && h.status !== 'connected' ? declared.get(h.name)?.reason ?? null : null;
83
+ servers.push({ name: h.name, source, status: h.status, reason: why, tools: h.status === 'connected' ? h.tools ?? [] : null });
84
+ }
85
+ const running = new Set(servers.filter((s) => s.source === 'project').map((s) => s.name));
86
+ for (const [name, d] of declared) {
87
+ if (!running.has(name)) servers.push({ name, source: 'project', status: 'declared', reason: d.reason, tools: null });
88
+ }
89
+ return { reported: harvested != null, servers: bound(servers) };
90
+ }
91
+
92
+ /**
93
+ * Codex: its own config's servers, then each declaration. A declared server
94
+ * replaces a personal one of the same name on a card's turn
95
+ * (projectToolRuntimes.mjs `isolatedCodexHome`), so the declaration is the
96
+ * row. Codex names no tools without starting a server, so `tools` is null.
97
+ */
98
+ export function codexInventory(rows, snapshot) {
99
+ const declared = declaredRows(snapshot, 'codex');
100
+ const names = new Set(declared.map((d) => d.name));
101
+ const servers = [
102
+ ...declared.map((d) => ({ name: d.name, source: 'project', status: 'declared', reason: d.reason, tools: null })),
103
+ ...(rows ?? []).filter((r) => !names.has(r.name)).map((r) => ({ name: r.name, source: 'personal', status: r.status, reason: r.reason, tools: null })),
104
+ ];
105
+ return { reported: rows != null, servers: bound(servers) };
106
+ }
107
+
108
+ /**
109
+ * The `mcp` key of the tools report: `{ claude?, codex? }`, a CLI only when it
110
+ * is installed here. `snapshot` is the base-branch read (null when it could
111
+ * not be read — the declarations are then unknown, and only the CLI's own
112
+ * list is said).
113
+ */
114
+ export function buildMcpInventory({ installed, snapshot, claude, codex }) {
115
+ const out = {};
116
+ if (installed.includes('claude')) out.claude = claudeInventory(claude, snapshot);
117
+ if (installed.includes('codex')) out.codex = codexInventory(codex, snapshot);
118
+ return out;
119
+ }
@@ -41,9 +41,11 @@
41
41
  * purpose through WSL interop (`explorer.exe`, `cmd.exe /c start`); the kind
42
42
  * contracts ask it not to (artifactContracts.mjs, `NO_WINDOW`).
43
43
  */
44
- import { lstatSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
44
+ import { mkdtempSync, rmSync } from 'node:fs';
45
45
  import { tmpdir } from 'node:os';
46
46
  import { join } from 'node:path';
47
+ import { withoutMachineCredentials } from './machineEnv.mjs';
48
+ import { ensureOwnDir } from './ownedDirs.mjs';
47
49
 
48
50
  /** Removed outright; WAYLAND_DISPLAY is kept but emptied (see above). */
49
51
  export const WINDOW_ENV_REMOVED = Object.freeze(['DISPLAY', 'WAYLAND_SOCKET']);
@@ -62,6 +64,24 @@ export function withoutWindows(env, browserHome = null) {
62
64
  return out;
63
65
  }
64
66
 
67
+ /**
68
+ * EVERY CLI CHILD THE DAEMON SPAWNS UNATTENDED, ONE RULE (2026-10-10, the
69
+ * audit): no machine credential (machineEnv.mjs), and no way to a window
70
+ * unless a person at the keyboard asked for one (`display: true`, a Terminal
71
+ * tab's alone). It was composed by hand twice — a turn's `cliEnv`
72
+ * (runTurn.mjs) and the project check's `checkEnv` (workAgentCheck.mjs) — and
73
+ * skipped outright by the two spawns the daemon makes for its own purposes:
74
+ * the startup skills probe (runtimeCapabilities.mjs), whose `claude` started
75
+ * the person's stdio MCP servers and the checkout's SessionStart hooks with
76
+ * the machine credential in their environment, and the Codex offerings reads
77
+ * (runtimeOfferings.mjs), whose `codex mcp list` may start configured servers.
78
+ * Every one of them now asks here. Never mutates its input.
79
+ */
80
+ export function unattendedEnv(env = process.env, { display = false, browserHome = null } = {}) {
81
+ const out = withoutMachineCredentials(env);
82
+ return display === true ? out : withoutWindows(out, browserHome);
83
+ }
84
+
65
85
  /**
66
86
  * AN AGENT'S BROWSER HOME is kept beside its Codex home, in its worktree's
67
87
  * private git dir: never committed, the same profile turn after turn, and
@@ -82,8 +102,8 @@ export const agentBrowserHome = (sessionMetaPath, wt, agentId) =>
82
102
  export function openBrowserHome(kept) {
83
103
  if (kept) {
84
104
  try {
85
- try { if (!lstatSync(kept).isDirectory()) rmSync(kept, { force: true }); } catch { /* absent */ }
86
- mkdirSync(kept, { recursive: true, mode: 0o700 });
105
+ // ownedDirs.mjs (2026-10-10): the link goes, never its target.
106
+ ensureOwnDir(kept, { mode: 0o700 });
87
107
  return { dir: kept, cleanup: () => {} };
88
108
  } catch { /* fall through to a turn's own */ }
89
109
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DIRECTORIES THE DAEMON WRITES INTO THAT A REPOSITORY COULD HAVE PLANTED —
3
+ * one home for the rule "never follow a link a checkout carries under
4
+ * `.flowviant/`" (2026-10-10, the audit's F100 / F080).
5
+ *
6
+ * git commits symlinks. A checkout can arrive carrying `.flowviant -> ~/.ssh`,
7
+ * or `.flowviant/uploads -> ../../../../.ssh` (a worktree's depth under
8
+ * `~/.flowviant/worktrees` is deterministic), and any daemon write through
9
+ * either lands outside the place it was meant for, as the operator. The rule
10
+ * that stops it was hand-written in ten places in four shapes — a boolean, a
11
+ * true/null/false, a 'dir'/'none'/'other', a replace-then-mkdir — and the
12
+ * attachment lane, the one that writes a MEMBER'S bytes under a member's name,
13
+ * had none of them. Each new `.flowviant/<x>` writer had to rediscover it.
14
+ *
15
+ * TWO POLICIES, deliberately two doors:
16
+ *
17
+ * - REFUSED, NEVER REPLACED (`ensureRealSubdir`): `.flowviant/` and the
18
+ * directories under it a repository could hold — the artifacts directory,
19
+ * the uploads directory. `.flowviant/` is not only ours (a repo declares
20
+ * `check.json` and `deploy.json` there), so a link or a file at one of
21
+ * these names is the repository's content: Flowviant deleting it would be
22
+ * destroying what it did not create, and following it is the write outside
23
+ * the worktree. Either answer is "no", and the caller says so in words.
24
+ * - REPLACED, NEVER FOLLOWED (`ensureOwnDir`): a path that is wholly the
25
+ * daemon's — the knowledge library under a parent its caller has already
26
+ * checked, a kept browser home. A link or a file planted there is removed
27
+ * (the link, never its target) and a real directory made in its place.
28
+ *
29
+ * Leaf: node:fs and node:path only.
30
+ */
31
+ import { lstatSync, mkdirSync, rmSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+
34
+ /**
35
+ * What stands at `p`, by `lstat` and never followed: `'dir'` for a real
36
+ * directory, `'none'` for nothing at all, `'other'` for anything else — a
37
+ * symlink (to a directory or dangling), a regular file, or a path this cannot
38
+ * look at (a component that is a file, no permission). The unknown case is
39
+ * `'other'`, never `'none'`: an answer of "absent" invites a create.
40
+ */
41
+ export function realDirState(p) {
42
+ try {
43
+ const st = lstatSync(p);
44
+ return st.isDirectory() && !st.isSymbolicLink() ? 'dir' : 'other';
45
+ } catch (e) {
46
+ return e?.code === 'ENOENT' ? 'none' : 'other';
47
+ }
48
+ }
49
+
50
+ /** `<placeDir>/.flowviant`, by the same three states. */
51
+ export const flowviantParentState = (placeDir) => realDirState(join(placeDir, '.flowviant'));
52
+
53
+ /**
54
+ * `<placeDir>/<rel>` as a real directory, every component of `rel` a real
55
+ * directory too — or FALSE, with nothing replaced.
56
+ *
57
+ * Each component must already be a real directory or be absent; any one that
58
+ * is a link or a non-directory refuses before anything is made. The missing
59
+ * ones are made, and then EVERY component is checked again: a link swapped in
60
+ * between the look and the make is caught here, and the caller writes
61
+ * nothing. `rel` is a relative path of plain names (`.flowviant/uploads`); a
62
+ * `..`, an empty or an absolute component refuses.
63
+ */
64
+ export function ensureRealSubdir(placeDir, rel) {
65
+ const parts = String(rel).split('/');
66
+ if (parts.some((p) => p === '' || p === '.' || p === '..')) return false;
67
+ const paths = parts.map((_, i) => join(placeDir, ...parts.slice(0, i + 1)));
68
+ if (paths.some((p) => realDirState(p) === 'other')) return false;
69
+ try {
70
+ mkdirSync(paths[paths.length - 1], { recursive: true });
71
+ } catch {
72
+ return false;
73
+ }
74
+ return paths.every((p) => realDirState(p) === 'dir');
75
+ }
76
+
77
+ /**
78
+ * A directory that is wholly the daemon's, made real: anything at `path` that
79
+ * is not a real directory is removed — non-recursively, so a link goes and its
80
+ * target never does — and the directory made (`mode` for each directory this
81
+ * creates). Returns `path`; THROWS when a real directory cannot be had there,
82
+ * a link reappearing between the remove and the make included, so a caller
83
+ * never writes through one.
84
+ *
85
+ * Only for paths that are OURS. A path a repository may legitimately hold is
86
+ * `ensureRealSubdir`'s, which refuses rather than removes.
87
+ */
88
+ export function ensureOwnDir(path, { mode } = {}) {
89
+ if (realDirState(path) !== 'dir') {
90
+ try {
91
+ rmSync(path, { force: true });
92
+ } catch {
93
+ /* the make below says why */
94
+ }
95
+ }
96
+ mkdirSync(path, mode === undefined ? { recursive: true } : { recursive: true, mode });
97
+ if (realDirState(path) !== 'dir') throw new Error(`${path} is not a real directory`);
98
+ return path;
99
+ }