flowviant 0.117.0 → 0.124.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/bin/cli.mjs +44 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +4 -3
- package/bin/lib/agentPlan.mjs +86 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +26 -6
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +65 -10
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +63 -5
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +170 -42
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +58 -18
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +31 -10
- package/bin/lib/runtimeCodex.mjs +28 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +48 -29
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +10 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +167 -29
- package/bin/lib/workAgentTurnOutcome.mjs +20 -5
- package/bin/lib/workAgentTurns.mjs +92 -12
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +24 -3
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +219 -52
- package/bin/lib/workShip.mjs +17 -16
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/package.json +7 -3
- package/npm-shrinkwrap.json +0 -59
|
@@ -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
|
+
}
|
package/bin/lib/instance.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ONE DAEMON PER
|
|
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-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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}` : '';
|
package/bin/lib/landed.mjs
CHANGED
|
@@ -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)
|
|
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
|
+
}
|
package/bin/lib/placeLock.mjs
CHANGED
|
@@ -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
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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);
|