flowviant 0.117.0 → 0.124.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ONE DAEMON PER REPO (and per credential), ARBITRATED at startup — a second
2
+ * ONE DAEMON PER PROJECT (and per checkout), ARBITRATED at startup — a second
3
3
  * run in the same repo takes the first one's place rather than being turned
4
4
  * away. See "WHAT A SECOND RUN DOES" below for the whole rule.
5
5
  *
@@ -20,7 +20,7 @@
20
20
  * deploys and every worktree operation the server never sees are still first
21
21
  * come, first served.
22
22
  *
23
- * The per-worktree `flowviant-turn.lock` cannot save it. That lock is written
23
+ * The per-tab `flowviant-turn.lock` cannot save it. That lock is written
24
24
  * AFTER the work token is minted and the attachments are fetched — a window
25
25
  * containing a network round trip — so both daemons clear the check and both
26
26
  * spawn a CLI into one held conversation. It was built for a RESTARTED daemon
@@ -84,6 +84,7 @@
84
84
 
85
85
  import { execFileSync } from 'node:child_process';
86
86
  import {
87
+ linkSync,
87
88
  closeSync,
88
89
  mkdirSync,
89
90
  openSync,
@@ -98,7 +99,7 @@ import { join } from 'node:path';
98
99
  import { VERSION, PROCESS_STARTED_AT } from './config.mjs';
99
100
  import { compareVersions } from './versionOrder.mjs';
100
101
  import { samePath } from './repoPath.mjs';
101
- import { instanceLockPath, listDaemonLocks, readLockFile, readLockHolder } from './daemonLocks.mjs';
102
+ import { legacyInstanceLockPath, instanceLockPath, listDaemonLocks, readLockFile, readLockHolder } from './daemonLocks.mjs';
102
103
  import { processAlive } from './procRegistry.mjs';
103
104
  import { procStat } from './procStat.mjs';
104
105
 
@@ -263,7 +264,7 @@ function neighbourLockPath(holder, fallback) {
263
264
  export function daemonInSameRepo(repoRoot, ownPath) {
264
265
  for (const { path, holder } of listDaemonLocks()) {
265
266
  if (path === ownPath) continue; // our own credential — the lock above owns that question
266
- if (!holder || !processAlive(holder.pid)) continue;
267
+ if (!holder || holder.pid === process.pid || !processAlive(holder.pid)) continue;
267
268
  if (isOurReexecParent(holder.pid)) continue; // ourselves mid self-update re-exec
268
269
  if (samePath(holder.repoRoot, repoRoot)) {
269
270
  NEIGHBOUR_PATHS.set(holder, path);
@@ -747,6 +748,12 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
747
748
  const { takeover: force = false, noTakeover = false, allowDowngrade = false, log } = opts;
748
749
  if (process.env.FLOWVIANT_ALLOW_MULTI === '1') return { ok: true, release: () => {} };
749
750
  const path = instanceLockPath(fleetToken);
751
+ // Atomically adopt the pre-project lock if one survives an upgrade. A hard
752
+ // link cannot replace a new holder and preserves its ownership record.
753
+ const legacy = legacyInstanceLockPath(fleetToken);
754
+ if (legacy !== path) {
755
+ try { linkSync(legacy, path); } catch { /* absent or already adopted */ }
756
+ }
750
757
  try {
751
758
  mkdirSync(join(homedir(), '.flowviant'), { recursive: true });
752
759
  } catch {
@@ -786,7 +793,10 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
786
793
  fd = openSync(path, 'wx');
787
794
  } catch (e) {
788
795
  if (e.code !== 'EEXIST') return { ok: true, release: () => {}, unguarded: true };
789
- const holder = readHolder(path);
796
+ let holder = readHolder(path);
797
+ const legacyHolder = legacy !== path ? readHolder(legacy) : null;
798
+ const holderPath = legacyHolder?.pid === holder?.pid && legacyHolder ? legacy : path;
799
+ if (legacyHolder && holderPath === legacy) holder = legacyHolder;
790
800
  if (!holder || !processAlive(holder.pid)) {
791
801
  // A crashed daemon's leftover. Clear it and take it on the next pass —
792
802
  // ownership-verified, because a concurrent start may have cleared and
@@ -826,7 +836,7 @@ export function acquireInstanceLock(fleetToken, repoRoot, opts = {}) {
826
836
  const here = samePath(holder.repoRoot, repoRoot);
827
837
  const wanted = force || (here && !noTakeover);
828
838
  if (wanted) {
829
- const bad = takeOverFrom(holder, path, log, { allowDowngrade });
839
+ const bad = takeOverFrom(holder, holderPath, log, { allowDowngrade });
830
840
  if (bad?.stale) {
831
841
  // Measured: the lock's writer is gone and its pid was recycled to a
832
842
  // stranger. A corpse is cleared, never "refused" — refusing bricked
@@ -930,7 +940,10 @@ function makeRelease(path) {
930
940
  * rest are what the caller reports.
931
941
  */
932
942
  function stopLock(path, log, tally) {
933
- const holder = readHolder(path);
943
+ let holder = readHolder(path);
944
+ // An upgraded CLI may have adopted an old credential lock. The old
945
+ // daemon still writes its drain mark to that alias by rename.
946
+ if (holder) holder = listDaemonLocks().find((row) => row.holder?.pid === holder.pid && row.holder?.draining)?.holder ?? holder;
934
947
  if (!holder) return; // absent, truncated, half-written — no claim to answer
935
948
  const where = holder.repoRoot ? ` in ${holder.repoRoot}` : '';
936
949
  const what = holder.version ? ` ${holder.version}` : '';
@@ -44,7 +44,7 @@ const MAX_COMMITS = 50;
44
44
  * shape (7–64 hex, either case), the one this module always used. */
45
45
  const SHA_RE = OBJECT_ID_RE;
46
46
 
47
- export function createLandedObserver({ repoRoot, baseRef }) {
47
+ export function createLandedObserver({ repoRoot, baseRef, repo }) {
48
48
  // One state file per checkout, so two repos on one box never share a tip.
49
49
  // Its own 8-hex key — NOT the worktree home's `<basename>-<8 hex>`
50
50
  // (statePaths.mjs) — and it stays as it is, so a persisted tip is never
@@ -175,7 +175,7 @@ export function createLandedObserver({ repoRoot, baseRef }) {
175
175
  try {
176
176
  const res = await postFleetJson(
177
177
  LANDED_URL,
178
- { base: baseBranchName(ref), tip: reportedTip, commits },
178
+ { base: baseBranchName(ref), tip: reportedTip, commits, ...(repo ? { repo } : {}) },
179
179
  { timeoutMs: 20_000 }
180
180
  );
181
181
  if (res.status === 404) {
package/bin/lib/login.mjs CHANGED
@@ -7,23 +7,28 @@
7
7
  */
8
8
 
9
9
  import { APP_URL, FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
10
- import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects, boundElsewhere, directoryTakenRefusal } from './credentials.mjs';
10
+ import { saveLogin, detectRepoRoot, projectLabel, safeName, listStoredProjects, boundElsewhere, directoryTakenRefusal, isStoredRepo } from './credentials.mjs';
11
11
  import { c, info, ok, warn, fail } from './ui.mjs';
12
12
  import { sleep } from './sleep.mjs';
13
13
  import { launchCommand, terminalCommand } from './launchCommand.mjs';
14
14
  import { fleetEndpoint } from './fleetWire.mjs';
15
+ import { checkoutIdentity } from './checkoutIdentity.mjs';
16
+ import { printableLine } from './printable.mjs';
15
17
 
16
18
  const DEVICE_START = fleetEndpoint('device/start', FLEET_URL);
17
19
  const DEVICE_POLL = fleetEndpoint('device/poll', FLEET_URL);
18
20
 
19
- async function postJson(url, body) {
21
+ async function postJson(url, body, token) {
20
22
  const res = await fetch(url, {
21
23
  method: 'POST',
22
- headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
24
+ headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
23
25
  signal: AbortSignal.timeout(30_000),
24
26
  body: JSON.stringify(body),
25
27
  });
26
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
28
+ if (!res.ok) {
29
+ const body = await res.json().catch(() => null);
30
+ throw new Error(printableLine(body?.error?.message, 2000) || `HTTP ${res.status}`);
31
+ }
27
32
  const j = await res.json();
28
33
  return j.data ?? j;
29
34
  }
@@ -69,6 +74,7 @@ export async function runLogin({ thenStart = false, json = false, dir = process.
69
74
  else {
70
75
  console.log(` Open ${c.cyan(approveUrl)}`);
71
76
  console.log(` and approve this computer for your project. Check the code there is ${c.bold(c.green(pretty))}.\n`);
77
+ if (listStoredProjects().length) info('Choose an existing project to add this checkout to its board.');
72
78
  info('waiting for you to approve…');
73
79
  }
74
80
 
@@ -123,6 +129,35 @@ export async function runLogin({ thenStart = false, json = false, dir = process.
123
129
  console.log('');
124
130
  return { saved: false };
125
131
  }
132
+ if (repoRoot) {
133
+ const previous = listStoredProjects().find((p) => p.projectId === entry.projectId);
134
+ try {
135
+ if (poll.repositoryRegistration === true) {
136
+ // A pre-multi-repo project may never have reported its remote.
137
+ // Fill its migrated primary from the checkout already bound by a
138
+ // person before registering the newly approved second checkout.
139
+ entry.repositoryBindings = [];
140
+ for (const [name, root] of Object.entries(previous?.repositoriesRegistered ? {} : previous?.repositories ?? {})) {
141
+ if (isStoredRepo({ repoRoot: root }, repoRoot)) continue;
142
+ const old = await post(fleetEndpoint('repositories/register', FLEET_URL), { ...checkoutIdentity(root), name }, entry.fleetToken);
143
+ if (typeof old?.name !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(old.name)) throw new Error('Flowviant returned an invalid repository.');
144
+ entry.repositoryBindings.push({ name: old.name, repoRoot: root, isPrimary: old.isPrimary === true });
145
+ }
146
+ const repo = await post(fleetEndpoint('repositories/register', FLEET_URL), checkoutIdentity(repoRoot), entry.fleetToken);
147
+ if (typeof repo?.name !== 'string' || !/^[a-z0-9][a-z0-9_-]*$/.test(repo.name)) throw new Error('Flowviant returned an invalid repository.');
148
+ entry.repositoryName = repo.name;
149
+ entry.isPrimary = repo.isPrimary === true;
150
+ entry.repositoriesRegistered = true;
151
+ } else if (previous?.repoRoot && !isStoredRepo(previous, repoRoot)) {
152
+ throw new Error('This Flowviant server does not support adding repositories yet.');
153
+ }
154
+ } catch (e) {
155
+ const message = `Could not connect this repository: ${e.message}`;
156
+ if (json) event({ event: 'error', message });
157
+ else warn(message);
158
+ return { saved: false };
159
+ }
160
+ }
126
161
  try { saveLogin(entry); }
127
162
  catch (e) {
128
163
  if (json) { event({ event: 'error', message: `Could not save this connection: ${e.message}` }); return { saved: false }; }
@@ -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
+ }
@@ -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
+ }
@@ -39,12 +39,13 @@ export function createPlaceLock() {
39
39
  * so the two never shared a chain and the guarantee was not held. Both sides
40
40
  * key on the place now.
41
41
  *
42
- * The cross-process pid lock (`flowviant-turn.lock`) is UNCHANGED and stays
43
- * per-place: it is a crash backstop for an orphaned CLI a dead daemon left
44
- * behind, not the parallelism policy. It is more conservative than this lock
45
- * — two sessions sharing one place still serialize across processes — which
46
- * costs nothing in practice, since a place holds one session unless somebody
47
- * deliberately points a second one at it.
42
+ * The cross-process pid lock (`flowviant-turn.lock`) is a crash backstop for
43
+ * an orphaned CLI a dead daemon left behind, not the parallelism policy. It
44
+ * was per-place and so MORE conservative than this lock, on the claim that a
45
+ * place holds one session — but every tab a person opens shares the checkout,
46
+ * so it serialized all of their tabs and undid this lock's readers. It is per
47
+ * TAB since 2026-10-14 (turnLock.mjs): one CLI per conversation, any number
48
+ * per place, and ship still reads every tab's.
48
49
  */
49
50
  const placeLocks = new Map(); // placeId -> { readers, writing, waiters: [] }
50
51
 
@@ -80,12 +81,13 @@ const pumpPlace = (id) => {
80
81
  * contract above is what guarantees a turn is answered, and hiding a throw
81
82
  * here would let a turn fail silently while holding nothing.
82
83
  */
83
- const inPlace = async (placeId, write, fn) => {
84
+ const inPlace = async (placeId, write, fn, onWaiting) => {
84
85
  let st = placeLocks.get(placeId);
85
86
  if (!st) {
86
87
  st = { readers: 0, writing: false, waiters: [] };
87
88
  placeLocks.set(placeId, st);
88
89
  }
90
+ if (st.writing || st.waiters.some(waiter => waiter.write) || (write && st.readers > 0)) onWaiting?.();
89
91
  await new Promise((go) => {
90
92
  st.waiters.push({ write, go });
91
93
  pumpPlace(placeId);