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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Network delivery is its own reason to change, apart from what is on the disk
|
|
6
6
|
* (artifacts.mjs) and how a page is screenshotted (artifactPreview.mjs). The
|
|
7
|
-
*
|
|
7
|
+
* uploader is HANDED the three functions it runs —
|
|
8
8
|
* `listChanged(placeDir, before)` (what the turn wrote), `buildUpload(entry,
|
|
9
9
|
* owner, scrub, secretIn)` (one scrubbed body) and `renderPreview(bytes)` (the
|
|
10
10
|
* screenshot) — so the delivery rules below are tested against a fake server
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
* ── DELIVERY ──
|
|
14
14
|
*
|
|
15
|
-
* A 2xx is
|
|
16
|
-
* other than 408/429)
|
|
15
|
+
* A 2xx is stored. A considered 4xx (fleetWire.mjs's `isConsideredRefusal`:
|
|
16
|
+
* other than 408/429) ENDS THE RETRY — an older SERVER 404s the whole
|
|
17
17
|
* route, and holding bodies it will refuse forever would be the trace relay's
|
|
18
18
|
* wedge wearing a retry's clothes; a refusal of one file (ended session, wrong
|
|
19
19
|
* owner) is equally permanent. EXCEPT 401 AND 403, which are a "later"
|
|
@@ -28,8 +28,21 @@
|
|
|
28
28
|
*
|
|
29
29
|
* A PREVIEW THAT FAILS NEVER STOPS THE ARTIFACT: an HTML body goes out with
|
|
30
30
|
* `renderState: 'unavailable'` and no preview, the renderer's explicit answer.
|
|
31
|
+
*
|
|
32
|
+
* FILE-CARD DELIVERY REQUIRES STORAGE (2026-10-14). `requireStored` makes
|
|
33
|
+
* unsuccessful first uploads fail the report, as replacements already did.
|
|
34
|
+
* A held body is still retried, but cannot advance an image/model queue into
|
|
35
|
+
* a delivery with no uploaded files. Code and Terminal artifacts keep their
|
|
36
|
+
* existing best-effort upload policy. Explicit recovery may narrow the scan
|
|
37
|
+
* with `includeNames`, so it never attributes a sibling card's files.
|
|
31
38
|
*/
|
|
32
39
|
|
|
40
|
+
import { artifactStoredName } from './artifacts.mjs';
|
|
41
|
+
import { fleetHeaders } from './fleetPost.mjs';
|
|
42
|
+
import {
|
|
43
|
+
iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
|
|
44
|
+
presentArtifacts, readSeenArtifacts, writeSeenArtifacts, seenArtifacts, serverBornAt,
|
|
45
|
+
} from './artifactIteration.mjs';
|
|
33
46
|
import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
|
|
34
47
|
|
|
35
48
|
/** A held upload is tried this many times in all, then dropped with a line. */
|
|
@@ -39,8 +52,8 @@ const MAX_PENDING = 40;
|
|
|
39
52
|
|
|
40
53
|
/**
|
|
41
54
|
* The reporter a work manager holds for its life. `report` is called once per
|
|
42
|
-
* finished turn and
|
|
43
|
-
*
|
|
55
|
+
* finished turn and awaited before settlement: a failed iteration cleanup
|
|
56
|
+
* must not become an optimistic delivery. `retryPending` rides the settle beat.
|
|
44
57
|
*/
|
|
45
58
|
export function createArtifactReporter({
|
|
46
59
|
fleetUrl,
|
|
@@ -53,13 +66,68 @@ export function createArtifactReporter({
|
|
|
53
66
|
listChanged,
|
|
54
67
|
buildUpload,
|
|
55
68
|
renderPreview,
|
|
69
|
+
/** `(placeDir, owner) => path | null` — where a place records what it held
|
|
70
|
+
* (artifactIteration.mjs). Null keeps no record: only a turn's own
|
|
71
|
+
* snapshot then measures a deletion. */
|
|
72
|
+
seenPath = () => null,
|
|
73
|
+
/** `(placeDir) => ms | null` — when that worktree was cut. Null (the
|
|
74
|
+
* checkout, anything unreadable) gives a place without a record no
|
|
75
|
+
* legacy evidence at all. */
|
|
76
|
+
placeBornAt = () => null,
|
|
56
77
|
}) {
|
|
57
78
|
const url = fleetEndpoint('artifact', fleetUrl);
|
|
58
79
|
const doFetch = fetchImpl ?? ((...a) => fetch(...a));
|
|
59
80
|
const pending = new Map(); // `${owner}:${name}` -> { body, tries }
|
|
60
81
|
|
|
61
|
-
|
|
82
|
+
const prepare = async ({ placeDir, sessionId, agentId, before }) => {
|
|
83
|
+
if (!before) return '';
|
|
84
|
+
before.iterationSignature = iterationSignature(placeDir);
|
|
85
|
+
delete before.stored;
|
|
86
|
+
delete before.seen;
|
|
87
|
+
before.inventoryFailed = false;
|
|
88
|
+
const query = new URLSearchParams(sessionId ? { sessionId } : { agentId });
|
|
89
|
+
try {
|
|
90
|
+
const sentAt = Date.now();
|
|
91
|
+
const response = await doFetch(`${fleetEndpoint('artifacts', fleetUrl)}?${query}`, {
|
|
92
|
+
method: 'GET', headers: fleetHeaders({ token, json: false }), signal: AbortSignal.timeout(15_000),
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
// Older servers have no iteration door. Other failures are unknown,
|
|
96
|
+
// not an empty inventory and not a successful cleanup.
|
|
97
|
+
before.inventoryFailed = response.status !== 404;
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
const result = await response.json();
|
|
101
|
+
if (!Array.isArray(result.data)) { before.inventoryFailed = true; return ''; }
|
|
102
|
+
before.stored = result.data.filter((a) => a && typeof a.id === 'string' && typeof a.name === 'string');
|
|
103
|
+
const path = seenPath(placeDir, sessionId ?? agentId);
|
|
104
|
+
before.seen = seenArtifacts(readSeenArtifacts(path), before.stored, presentArtifacts(placeDir) ?? new Set(),
|
|
105
|
+
serverBornAt(placeBornAt(placeDir), response.headers?.get?.('date'), sentAt));
|
|
106
|
+
writeSeenArtifacts(path, before.seen);
|
|
107
|
+
return iterationInstructions(before.stored);
|
|
108
|
+
} catch { before.inventoryFailed = true; return ''; }
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** A response distinguishes delivery from a permanent refusal and a retry. */
|
|
62
112
|
const post = async (body) => {
|
|
113
|
+
if (body.removal) {
|
|
114
|
+
try {
|
|
115
|
+
const res = await doFetch(`${url}/remove`, {
|
|
116
|
+
method: 'POST', headers: fleetHeaders({ token }), signal: AbortSignal.timeout(30_000),
|
|
117
|
+
body: JSON.stringify(body.removal),
|
|
118
|
+
});
|
|
119
|
+
const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
|
|
120
|
+
if (res.ok && result?.data?.complete === true) {
|
|
121
|
+
discardLocalArtifact(body.placeDir, body.fields.name, body.local);
|
|
122
|
+
return { settled: true, success: true };
|
|
123
|
+
}
|
|
124
|
+
log(`artifact ${body.fields.name}: ${result?.error ?? `removal failed (HTTP ${res.status})`}`);
|
|
125
|
+
return { settled: res.status !== 401 && res.status !== 403 && isConsideredRefusal(res.status), success: false };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
log(`artifact ${body.fields.name}: removal incomplete — ${error.message}`);
|
|
128
|
+
return { settled: false, success: false };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
63
131
|
const form = new FormData();
|
|
64
132
|
for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
|
|
65
133
|
if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
|
|
@@ -71,13 +139,17 @@ export function createArtifactReporter({
|
|
|
71
139
|
signal: AbortSignal.timeout(30_000),
|
|
72
140
|
body: form,
|
|
73
141
|
});
|
|
74
|
-
if (res.ok)
|
|
142
|
+
if (res.ok) {
|
|
143
|
+
const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
|
|
144
|
+
return { settled: true, success: true, id: result?.data?.id };
|
|
145
|
+
}
|
|
75
146
|
// A credential blip or an edge page is a "later", bounded by MAX_TRIES
|
|
76
147
|
// (2026-10-10 ruling — see the header).
|
|
77
|
-
if (res.status === 401 || res.status === 403) return false;
|
|
78
|
-
|
|
148
|
+
if (res.status === 401 || res.status === 403) return { settled: false, success: false };
|
|
149
|
+
const failure = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
|
|
150
|
+
return { settled: isConsideredRefusal(res.status), success: false, error: typeof failure?.error === 'string' ? failure.error : failure?.error?.message };
|
|
79
151
|
} catch {
|
|
80
|
-
return false;
|
|
152
|
+
return { settled: false, success: false };
|
|
81
153
|
}
|
|
82
154
|
};
|
|
83
155
|
|
|
@@ -110,17 +182,30 @@ export function createArtifactReporter({
|
|
|
110
182
|
const send = (key, body, tries, gen) => {
|
|
111
183
|
const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
|
|
112
184
|
if (genOf.get(key) !== gen) return; // superseded before it went out
|
|
113
|
-
const
|
|
185
|
+
const result = await post(body);
|
|
114
186
|
if (genOf.get(key) !== gen) return; // a newer copy decides for this key
|
|
115
|
-
if (
|
|
187
|
+
if (result.settled) pending.delete(key);
|
|
116
188
|
else hold(key, body, tries + 1);
|
|
189
|
+
if (result.success && body.replacements?.length) {
|
|
190
|
+
result.cleanupFailed = [];
|
|
191
|
+
for (const removal of body.replacements) {
|
|
192
|
+
if (!result.id) { result.cleanupFailed.push(removal.fields.name); continue; }
|
|
193
|
+
removal.removal.replacementId = result.id;
|
|
194
|
+
const removalKey = `${removal.removal.sessionId ?? removal.removal.agentId}:${removal.fields.name}`;
|
|
195
|
+
// Do not let an earlier replacement retire a later turn's retained file.
|
|
196
|
+
if (genOf.get(removalKey) !== removal.generation) continue;
|
|
197
|
+
const removed = await send(removalKey, removal, 0, removal.generation);
|
|
198
|
+
if (!removed?.success) result.cleanupFailed.push(removal.fields.name);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
117
202
|
});
|
|
118
203
|
const tail = run.catch(() => {});
|
|
119
204
|
chains.set(key, tail);
|
|
120
205
|
tail.then(() => {
|
|
121
206
|
if (chains.get(key) !== tail) return;
|
|
122
207
|
chains.delete(key);
|
|
123
|
-
if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
|
|
208
|
+
if (!pending.has(key) && genOf.get(key) === gen) genOf.delete(key); // settled: nothing left to order
|
|
124
209
|
});
|
|
125
210
|
return run;
|
|
126
211
|
};
|
|
@@ -141,15 +226,37 @@ export function createArtifactReporter({
|
|
|
141
226
|
|
|
142
227
|
/** Upload what this turn changed in `placeDir`. Resolves to the number of
|
|
143
228
|
* bodies SENT this call (delivered or held), for the tests and the log. */
|
|
144
|
-
const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
|
|
229
|
+
const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null }) => {
|
|
145
230
|
if (!placeDir || (!sessionId && !agentId)) return 0;
|
|
146
|
-
const
|
|
231
|
+
const changes = iterationChanges(placeDir, before);
|
|
232
|
+
if (!before?.stored && (changes.remove.length || Object.keys(changes.replace).length)) throw new Error('Stored artifact inventory could not be measured; removal was not attempted.');
|
|
233
|
+
const removing = new Set([...changes.remove, ...Object.keys(changes.replace), ...(before?.stored ? deletedArtifacts(placeDir, before) : [])]);
|
|
234
|
+
const stored = new Map((before?.stored ?? []).map((a) => [a.name, a]));
|
|
235
|
+
for (const name of [...changes.remove, ...Object.keys(changes.replace)]) {
|
|
236
|
+
if (!stored.has(name)) throw new Error(`No stored artifact named ${name} belongs to this conversation.`);
|
|
237
|
+
}
|
|
238
|
+
// Cancel held uploads immediately, including a copy currently in flight.
|
|
239
|
+
for (const name of removing) {
|
|
240
|
+
const key = `${sessionId ?? agentId}:${name}`;
|
|
241
|
+
pending.delete(key);
|
|
242
|
+
genOf.set(key, ++generation);
|
|
243
|
+
}
|
|
244
|
+
const changed = idle ? [] : listChanged(placeDir, before).filter((e) =>
|
|
245
|
+
!removing.has(artifactStoredName(e.name)) && (!includeNames || includeNames.has(e.name)));
|
|
246
|
+
const delivered = new Map();
|
|
247
|
+
const failures = before?.inventoryFailed
|
|
248
|
+
? ['Stored artifact inventory could not be measured; cleanup is incomplete. Retry.'] : [];
|
|
147
249
|
let n = 0;
|
|
148
250
|
for (const entry of changed) {
|
|
149
251
|
const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
|
|
150
|
-
if (!body)
|
|
252
|
+
if (!body) {
|
|
253
|
+
if (requireStored) failures.push(`artifact ${entry.name} could not be read; it was not delivered`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
151
256
|
if (body.withheld) {
|
|
152
257
|
log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
|
|
258
|
+
if (stored.has(body.fields.name)) failures.push(`artifact ${body.fields.name} replacement was withheld; its predecessor was retained`);
|
|
259
|
+
else if (requireStored) failures.push(`artifact ${body.fields.name} was withheld; it was not delivered`);
|
|
153
260
|
continue;
|
|
154
261
|
}
|
|
155
262
|
if (body.bytes && /\.html?$/i.test(entry.name)) {
|
|
@@ -157,15 +264,72 @@ export function createArtifactReporter({
|
|
|
157
264
|
body.fields.renderState = measured.renderState;
|
|
158
265
|
if (measured.preview) body.preview = measured.preview;
|
|
159
266
|
}
|
|
160
|
-
|
|
267
|
+
body.replacements = Object.entries(changes.replace).filter(([, to]) => to === body.fields.name).map(([name]) => {
|
|
268
|
+
const key = `${sessionId ?? agentId}:${name}`;
|
|
269
|
+
return { fields: { name }, removal: { ...(sessionId ? { sessionId } : { agentId }), id: stored.get(name).id, name },
|
|
270
|
+
placeDir, local: localArtifact(placeDir, name), generation: genOf.get(key) };
|
|
271
|
+
});
|
|
272
|
+
const key = `${sessionId ?? agentId}:${body.fields.name}`;
|
|
161
273
|
pending.delete(key); // this copy supersedes a held older one
|
|
162
274
|
const gen = ++generation;
|
|
163
275
|
genOf.set(key, gen); // …and one still in flight
|
|
164
|
-
await send(key, body, 0, gen);
|
|
276
|
+
const result = await send(key, body, 0, gen);
|
|
277
|
+
if (result?.success && result.id) delivered.set(body.fields.name, result.id);
|
|
278
|
+
if (result?.cleanupFailed?.length) failures.push(`artifact cleanup incomplete: ${result.cleanupFailed.join(', ')}`);
|
|
279
|
+
if (!result?.success && (requireStored || stored.has(body.fields.name) || result?.error)) {
|
|
280
|
+
failures.push(result?.error ?? `artifact ${body.fields.name}${stored.has(body.fields.name) ? ' replacement' : ''} was not delivered`);
|
|
281
|
+
}
|
|
165
282
|
n++;
|
|
166
283
|
}
|
|
284
|
+
const removed = new Set();
|
|
285
|
+
for (const name of removing) {
|
|
286
|
+
const old = stored.get(name);
|
|
287
|
+
if (!old) continue; // A local file may belong to a sibling Terminal tab.
|
|
288
|
+
const replacementName = changes.replace[name];
|
|
289
|
+
const replacementId = replacementName ? delivered.get(replacementName) : null;
|
|
290
|
+
if (replacementName && !replacementId) {
|
|
291
|
+
failures.push(`replacement ${replacementName} was not delivered; ${name} was retained`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (replacementName) continue; // Its upload carries the removal through retries.
|
|
295
|
+
const body = {
|
|
296
|
+
fields: { name },
|
|
297
|
+
removal: { ...(sessionId ? { sessionId } : { agentId }), id: old.id, name,
|
|
298
|
+
...(replacementId ? { replacementId } : {}) },
|
|
299
|
+
placeDir, local: localArtifact(placeDir, name),
|
|
300
|
+
};
|
|
301
|
+
const key = `${sessionId ?? agentId}:${name}`;
|
|
302
|
+
const gen = ++generation;
|
|
303
|
+
genOf.set(key, gen);
|
|
304
|
+
const result = await send(key, body, 0, gen);
|
|
305
|
+
if (result?.success) removed.add(name);
|
|
306
|
+
else failures.push(`artifact ${name} could not be removed; cleanup is incomplete`);
|
|
307
|
+
}
|
|
308
|
+
if (before?.stored) {
|
|
309
|
+
// What this place holds now joins its record; a failed removal stays
|
|
310
|
+
// in it, so the next measurement retries rather than forgets.
|
|
311
|
+
const present = presentArtifacts(placeDir) ?? new Set();
|
|
312
|
+
const owned = new Set([...before.stored.map((a) => a.name), ...present, ...delivered.keys()]);
|
|
313
|
+
writeSeenArtifacts(seenPath(placeDir, sessionId ?? agentId),
|
|
314
|
+
[...(before.seen ?? []), ...present, ...delivered.keys()]
|
|
315
|
+
.filter((name) => owned.has(name) && !removed.has(name)));
|
|
316
|
+
}
|
|
317
|
+
if (failures.length) throw new Error(failures.join('; '));
|
|
167
318
|
return n;
|
|
168
319
|
};
|
|
169
320
|
|
|
170
|
-
|
|
321
|
+
/**
|
|
322
|
+
* AN IDLE DELIVERY, RECONCILED WITHOUT A TURN (2026-10-11 follow-up). A
|
|
323
|
+
* discard was relayed only when a turn settled, so an agent waiting in
|
|
324
|
+
* Review kept every stored file an earlier daemon never removed. Nothing
|
|
325
|
+
* is uploaded here; the place's measured discards are retired and a
|
|
326
|
+
* failure throws, exactly as a turn's report would.
|
|
327
|
+
*/
|
|
328
|
+
const reconcile = async ({ placeDir, before, sessionId, agentId }) => {
|
|
329
|
+
if (!placeDir || !before || (!sessionId && !agentId)) return 0;
|
|
330
|
+
await prepare({ placeDir, before, sessionId, agentId });
|
|
331
|
+
return report({ placeDir, before, sessionId, agentId, idle: true });
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
return { prepare, report, reconcile, retryPending, pendingCount: () => pending.size };
|
|
171
335
|
}
|
package/bin/lib/artifacts.mjs
CHANGED
|
@@ -243,8 +243,8 @@ export function snapshotArtifacts(placeDir) {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
/** What the turn wrote: every entry whose (name, size, mtime) is not what the
|
|
246
|
-
* snapshot held.
|
|
247
|
-
*
|
|
246
|
+
* snapshot held. Deletions and replacement relationships are reported by
|
|
247
|
+
* artifactIteration.mjs separately from upload bodies. */
|
|
248
248
|
export function changedArtifacts(before, entries) {
|
|
249
249
|
return entries.filter((e) => before?.get(e.name) !== sigOf(e));
|
|
250
250
|
}
|
|
@@ -293,6 +293,9 @@ function safeArtifactName(raw) {
|
|
|
293
293
|
return clean ? cutKeepingExtension(clean) : 'artifact';
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
+
/** The stored name is also the key for iteration and queued upload cancellation. */
|
|
297
|
+
export const artifactStoredName = (name) => name.split('/').map(safeArtifactName).join('/');
|
|
298
|
+
|
|
296
299
|
/**
|
|
297
300
|
* ONE UPLOAD BODY, as data — fields plus optional bytes — so a held body is
|
|
298
301
|
* re-sent exactly as it was first built (scrubbed once, never re-read from a
|
|
@@ -303,7 +306,7 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
|
|
|
303
306
|
const fields = {
|
|
304
307
|
...(sessionId ? { sessionId } : { agentId }),
|
|
305
308
|
...(turnId ? { turnId } : {}),
|
|
306
|
-
name: entry.name
|
|
309
|
+
name: artifactStoredName(entry.name),
|
|
307
310
|
bytes: String(entry.size),
|
|
308
311
|
};
|
|
309
312
|
if (!type) return { fields, bytes: null }; // reported by name only
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { gitOrNull } from './git.mjs';
|
|
4
|
+
|
|
5
|
+
/** Login registers addresses without sending a credential in an HTTPS URL.
|
|
6
|
+
* A checkout with no origin has its local path as its repository address. */
|
|
7
|
+
export function checkoutIdentity(repoRoot) {
|
|
8
|
+
let remote = gitOrNull(['remote', 'get-url', 'origin'], repoRoot) ?? pathToFileURL(repoRoot).href;
|
|
9
|
+
if (remote.includes('://')) {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(remote);
|
|
12
|
+
url.password = '';
|
|
13
|
+
if (url.protocol !== 'ssh:') url.username = '';
|
|
14
|
+
url.search = '';
|
|
15
|
+
url.hash = '';
|
|
16
|
+
remote = url.hostname.toLowerCase() === 'github.com'
|
|
17
|
+
? url.pathname.replace(/^\//, '').replace(/\.git\/?$/, '')
|
|
18
|
+
: url.toString();
|
|
19
|
+
} catch { remote = repoRoot; }
|
|
20
|
+
}
|
|
21
|
+
const ssh = remote.match(/^(?:git@)?github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
22
|
+
if (ssh) remote = ssh[1];
|
|
23
|
+
const name = basename(repoRoot).toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 40) || 'app';
|
|
24
|
+
return { name, remote };
|
|
25
|
+
}
|
package/bin/lib/claudeAuth.mjs
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
* daemon started, so a variable exported weeks ago is still in there long
|
|
27
27
|
* after it left your shell. A stale one fails in a way a fresh `claude`
|
|
28
28
|
* never reproduces.
|
|
29
|
-
* · THE
|
|
30
|
-
* different user, or a container has a different one than
|
|
29
|
+
* · THE STORE. The CLI uses $CLAUDE_CONFIG_DIR, else $HOME/.claude. A daemon
|
|
30
|
+
* under systemd, a different user, or a container has a different one than
|
|
31
|
+
* the shell you
|
|
31
32
|
* tested in, so "I am logged in" was measured on the wrong file.
|
|
32
33
|
*
|
|
33
34
|
* ── WHAT THIS IS NOT ──
|
|
@@ -75,11 +76,19 @@ export const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAU
|
|
|
75
76
|
/** Claude Code's OAuth store, relative to the HOME the CLI will be handed. */
|
|
76
77
|
const CRED_REL = ['.claude', '.credentials.json'];
|
|
77
78
|
|
|
79
|
+
/** The same location Claude's personal settings reader uses. */
|
|
80
|
+
export function claudeCredentialPath(env = process.env, home = null) {
|
|
81
|
+
const root = home ?? env.HOME ?? env.USERPROFILE ?? null;
|
|
82
|
+
return env.CLAUDE_CONFIG_DIR
|
|
83
|
+
? join(env.CLAUDE_CONFIG_DIR, '.credentials.json')
|
|
84
|
+
: root ? join(root, ...CRED_REL) : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
78
87
|
|
|
79
88
|
/**
|
|
80
89
|
* What the CLI on this machine will resolve, as far as it can be seen without
|
|
81
90
|
* spending anything: no process spawned, no request made, no quota touched.
|
|
82
|
-
* Just this process's own environment and
|
|
91
|
+
* Just this process's own environment and the CLI-selected file's metadata.
|
|
83
92
|
*
|
|
84
93
|
* `env` and `home` are parameters rather than reads of `process` so the tests
|
|
85
94
|
* can drive every branch — and so the caller passes the SAME environment the
|
|
@@ -98,9 +107,10 @@ export function claudeAuthContext({ env = process.env, home = null } = {}) {
|
|
|
98
107
|
let refreshExpiresAt = null;
|
|
99
108
|
let accessExpiresAt = null;
|
|
100
109
|
let subscriptionType = null;
|
|
101
|
-
|
|
110
|
+
const credentialPath = claudeCredentialPath(env, HOME);
|
|
111
|
+
if (credentialPath) {
|
|
102
112
|
try {
|
|
103
|
-
const raw = readFileSync(
|
|
113
|
+
const raw = readFileSync(credentialPath, 'utf8');
|
|
104
114
|
const oauth = JSON.parse(raw)?.claudeAiOauth;
|
|
105
115
|
if (oauth && typeof oauth === 'object') {
|
|
106
116
|
hasFile = true;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Claude's stream-json stdin. A write is only an attempt; its replay echo is
|
|
2
|
+
* the receipt. Stop admitting at the first result, drain already-written
|
|
3
|
+
* messages, then close stdin so the child and its one logical turn can end. */
|
|
4
|
+
export function createClaudeInput(child, { onTaken = () => {} } = {}) {
|
|
5
|
+
const sent = new Set();
|
|
6
|
+
const pending = new Set();
|
|
7
|
+
let accepting = true;
|
|
8
|
+
let ended = false;
|
|
9
|
+
let resultSeen = false;
|
|
10
|
+
child.stdin.on('error', () => { accepting = false; }); // EPIPE leaves the message queued
|
|
11
|
+
const send = (id, prompt) => {
|
|
12
|
+
if (!accepting || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
|
|
13
|
+
const message = { type: 'user', uuid: id, session_id: '', message: { role: 'user', content: prompt }, parent_tool_use_id: null };
|
|
14
|
+
sent.add(id);
|
|
15
|
+
pending.add(id);
|
|
16
|
+
try { child.stdin.write(JSON.stringify(message) + '\n'); return true; }
|
|
17
|
+
catch { pending.delete(id); accepting = false; return false; }
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
send,
|
|
21
|
+
event(event) {
|
|
22
|
+
if (event.type === 'user' && event.isReplay === true && pending.delete(event.uuid)) onTaken(event.uuid);
|
|
23
|
+
if (event.type === 'result') {
|
|
24
|
+
resultSeen = true;
|
|
25
|
+
accepting = false;
|
|
26
|
+
if ((pending.size === 0 || event.is_error === true) && !ended) { ended = true; child.stdin.end(); }
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
close() { accepting = false; ended = true; },
|
|
30
|
+
get resultSeen() { return resultSeen; },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { SAFE } from './config.mjs';
|
|
15
|
+
import { runningCompiledBinary } from './launchCommand.mjs';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
|
|
@@ -35,20 +36,40 @@ import { SAFE } from './config.mjs';
|
|
|
35
36
|
* `process.execPath`, never a `node` looked up on a PATH the operator may not
|
|
36
37
|
* have, because a hook that fails to START is a non-blocking error and the
|
|
37
38
|
* command runs anyway.
|
|
39
|
+
*
|
|
40
|
+
* …AND AS `<flowviant> __hook read-guard` UNDER THE COMPILED BINARY
|
|
41
|
+
* (2026-10-11). There `process.execPath` IS flowviant and the script path is
|
|
42
|
+
* Bun's virtual `/$bunfs/…`, so the old command was `flowviant
|
|
43
|
+
* '/$bunfs/root/hooks/readGuard.mjs'`: the CLI answered "is not a command"
|
|
44
|
+
* with exit 1 (measured on the installed 0.116.0), a non-blocking error, and
|
|
45
|
+
* every guarded command ran — the curl install had no read guard. The CLI now
|
|
46
|
+
* runs the hook itself (bin/cli.mjs, `__hook`), before anything else loads.
|
|
38
47
|
*/
|
|
39
48
|
export const READ_GUARD_PATH = fileURLToPath(new URL('./hooks/readGuard.mjs', import.meta.url));
|
|
49
|
+
export const MCP_ALLOW_PATH = fileURLToPath(new URL('./hooks/mcpAllow.mjs', import.meta.url));
|
|
40
50
|
const hookQuote = (s) =>
|
|
41
51
|
process.platform === 'win32' ? `"${s}"` : `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
52
|
+
/** The command a hook runs as: this node and the script, or this binary and
|
|
53
|
+
* its `__hook` word (`compiled` is runningCompiledBinary's answer; a test
|
|
54
|
+
* passes it). */
|
|
55
|
+
export function hookCommand(name, scriptPath, { compiled = runningCompiledBinary() } = {}) {
|
|
56
|
+
return compiled ? `${hookQuote(process.execPath)} __hook ${name}` : `${hookQuote(process.execPath)} ${hookQuote(scriptPath)}`;
|
|
57
|
+
}
|
|
42
58
|
export const READ_GUARD_SETTINGS = JSON.stringify({
|
|
43
59
|
hooks: {
|
|
44
60
|
PreToolUse: [
|
|
45
61
|
{
|
|
46
62
|
matcher: 'Bash',
|
|
47
|
-
hooks: [{ type: 'command', command:
|
|
63
|
+
hooks: [{ type: 'command', command: hookCommand('read-guard', READ_GUARD_PATH) }],
|
|
48
64
|
},
|
|
49
65
|
],
|
|
50
66
|
},
|
|
51
67
|
});
|
|
68
|
+
/** The MCP allow hook's entry (hooks/mcpAllow.mjs), matched on MCP tools only. */
|
|
69
|
+
export const MCP_ALLOW_HOOK = Object.freeze({
|
|
70
|
+
matcher: 'mcp__.*',
|
|
71
|
+
hooks: [{ type: 'command', command: hookCommand('mcp-allow', MCP_ALLOW_PATH) }],
|
|
72
|
+
});
|
|
52
73
|
/** Spread FIRST into a curated list: `--allowedTools` is variadic, so the
|
|
53
74
|
* `--settings` pair must stand before it or be swallowed as a tool name. */
|
|
54
75
|
const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
|
|
@@ -278,7 +299,9 @@ export function consultPermFor(knowledgeDir) {
|
|
|
278
299
|
|
|
279
300
|
/**
|
|
280
301
|
* THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
|
|
281
|
-
*
|
|
302
|
+
* HISTORICAL ARTIFACT PROBES before 2026-10-12. Current artifact lists below
|
|
303
|
+
* add guarded readers, web and scratch; arbitrary programs still cannot
|
|
304
|
+
* bypass the write fence. WRITE was only under `.flowviant/artifacts/`.
|
|
282
305
|
*
|
|
283
306
|
* The card's words are steered by anyone who can file a card, so "change
|
|
284
307
|
* nothing" as prose is only an instruction, and the permission list is the
|
|
@@ -337,11 +360,26 @@ export function consultPermFor(knowledgeDir) {
|
|
|
337
360
|
* explicit `Bash(git log:*)` prefix the old list carried). With `Bash` in
|
|
338
361
|
* `--disallowedTools` the tool was not there at all.
|
|
339
362
|
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
363
|
+
* THE PROJECT'S MCP SERVERS (2026-10-10, the owner): each list allows
|
|
364
|
+
* `mcp__<server>` for every server the agent lane mounted from the base
|
|
365
|
+
* branch's reviewed `.mcp.json` (projectTools.mjs) — the set a code card gets.
|
|
366
|
+
* Under `--allowedTools` an MCP tool with no rule is refused ("Claude requested
|
|
367
|
+
* permissions to use mcp__…, but you haven't granted it yet", measured on the
|
|
368
|
+
* plan posture), so the rule IS the mount. AND THE MACHINE'S OWN (2026-10-11):
|
|
369
|
+
* an agent turn mounts this machine's user/local servers and claude.ai
|
|
370
|
+
* connectors too, whose names only the CLI knows, so the lane adds the MCP
|
|
371
|
+
* allow hook (`withMachineMcp`, below). No shell that could commit: a posture
|
|
372
|
+
* that could run `git commit` could commit the mockup it was told to keep out
|
|
373
|
+
* of git.
|
|
343
374
|
*
|
|
344
|
-
* STATED, not closed:
|
|
375
|
+
* STATED, not closed: an MCP server is a process of its own, outside every
|
|
376
|
+
* list here — it reads, writes and reaches the network as the person who runs
|
|
377
|
+
* it, and a turn steered by a card's words can hand it anything the turn read,
|
|
378
|
+
* or ask it to act: the machine's claude.ai connectors are its owner's
|
|
379
|
+
* accounts. On research that sits beside the web. The base branch's
|
|
380
|
+
* `.mcp.json` is the repository's reviewed choice, and the machine's servers
|
|
381
|
+
* are its owner's (docs/decisions.md, 2026-10-10 and 2026-10-11).
|
|
382
|
+
* Research may still read any file in the worktree and
|
|
345
383
|
* name it in a WebFetch URL — the repository is what a research card is about,
|
|
346
384
|
* and a turn that could not read it could not answer. Design may read any
|
|
347
385
|
* file in its worktree and put it in an artifact the server stores; text
|
|
@@ -356,6 +394,12 @@ export function consultPermFor(knowledgeDir) {
|
|
|
356
394
|
* the guard's hook sees a subagent's own calls was not measured here.
|
|
357
395
|
*/
|
|
358
396
|
const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
|
|
397
|
+
/** `mcp__<server>` for each mounted project server — a name the base snapshot
|
|
398
|
+
* already admitted (projectTools.mjs `nameOk`), checked again here because a
|
|
399
|
+
* rule is a pattern: anything but a plain server name gets no rule, and
|
|
400
|
+
* Flowviant's own control plane is never a project's server. */
|
|
401
|
+
const projectMcpRules = (names = []) =>
|
|
402
|
+
names.filter((n) => typeof n === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(n) && n !== 'flowviant').map((n) => `mcp__${n}`);
|
|
359
403
|
/**
|
|
360
404
|
* PLAN MODE (0.97.0) — a Workbench tab's `planMode` switch, and Claude Code's
|
|
361
405
|
* own `--permission-mode plan` INSTEAD OF every list above: the CLI reads,
|
|
@@ -386,17 +430,22 @@ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
|
|
|
386
430
|
* plane whose every call the CLI refuses. See workTurnShape.mjs.
|
|
387
431
|
*/
|
|
388
432
|
export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
|
|
389
|
-
export function designPermFor(knowledgeDir) {
|
|
433
|
+
export function designPermFor(knowledgeDir, projectMcp = []) {
|
|
390
434
|
return [
|
|
391
435
|
...READ_GUARD,
|
|
392
436
|
'--allowedTools',
|
|
393
437
|
...fencedReads(knowledgeDir),
|
|
394
|
-
|
|
438
|
+
...CONSULT_BASH,
|
|
439
|
+
'WebSearch',
|
|
440
|
+
'WebFetch',
|
|
395
441
|
ARTIFACT_WRITE,
|
|
442
|
+
'Edit(.flowviant/tmp/**)',
|
|
443
|
+
...projectMcpRules(projectMcp),
|
|
396
444
|
// A mockup is uploaded and runs scripts in a frame that may navigate
|
|
397
445
|
// itself, so the checkout's own secrets are denied by name too — the same
|
|
398
446
|
// pair research carries, measured load-bearing there.
|
|
399
447
|
'--disallowedTools',
|
|
448
|
+
'Agent',
|
|
400
449
|
'Read(./.env*)',
|
|
401
450
|
'Read(./**/.env*)',
|
|
402
451
|
];
|
|
@@ -408,18 +457,21 @@ export const DESIGN_PERM = designPermFor(null);
|
|
|
408
457
|
* outside the worktree it may read, and only the spawn knows where that is.
|
|
409
458
|
* `//` is the CLI's own spelling for an absolute path in a rule.
|
|
410
459
|
*/
|
|
411
|
-
export function researchPerm(knowledgeDir) {
|
|
460
|
+
export function researchPerm(knowledgeDir, projectMcp = []) {
|
|
412
461
|
return [
|
|
413
462
|
...READ_GUARD,
|
|
414
463
|
'--allowedTools',
|
|
415
464
|
...fencedReads(knowledgeDir),
|
|
465
|
+
...CONSULT_BASH,
|
|
416
466
|
'WebSearch',
|
|
417
467
|
'WebFetch',
|
|
418
468
|
ARTIFACT_WRITE,
|
|
469
|
+
'Edit(.flowviant/tmp/**)',
|
|
470
|
+
...projectMcpRules(projectMcp),
|
|
419
471
|
'--disallowedTools',
|
|
472
|
+
'Agent',
|
|
420
473
|
'Read(./.env*)',
|
|
421
474
|
'Read(./**/.env*)',
|
|
422
|
-
'Bash',
|
|
423
475
|
];
|
|
424
476
|
}
|
|
425
477
|
/** The list with no library — what a project that keeps none runs under. */
|
|
@@ -429,6 +481,8 @@ export const RESEARCH_PERM = researchPerm(null);
|
|
|
429
481
|
* CLAUDE'S PERMISSION LIST FOR A TURN PROFILE, by the profile's name — one
|
|
430
482
|
* entry per name in turnProfile.mjs (a test holds the two tables equal). The
|
|
431
483
|
* builders that take the knowledge directory get it; the fixed lists ignore it.
|
|
484
|
+
* `projectMcp` is the names of the project servers the lane mounted; only the
|
|
485
|
+
* design and research lists take them (`projectMcp` in turnProfile.mjs).
|
|
432
486
|
*/
|
|
433
487
|
const CLAUDE_PERM = {
|
|
434
488
|
build: () => PERM,
|
|
@@ -436,8 +490,8 @@ const CLAUDE_PERM = {
|
|
|
436
490
|
consult: (kd) => consultPermFor(kd),
|
|
437
491
|
plan: (kd) => planPermFor(kd),
|
|
438
492
|
'plan-mode': () => PLAN_MODE_PERM,
|
|
439
|
-
design: (kd) => designPermFor(kd),
|
|
440
|
-
research: (kd) => researchPerm(kd),
|
|
493
|
+
design: (kd, _vault, mcp) => designPermFor(kd, mcp),
|
|
494
|
+
research: (kd, _vault, mcp) => researchPerm(kd, mcp),
|
|
441
495
|
// THE IMAGE POSTURE IS CODEX'S (0.114.0) and Claude does not declare it
|
|
442
496
|
// (runtimeClaude.mjs `profiles`), so turnProfile.mjs refuses a Claude turn
|
|
443
497
|
// under it before argv is built. The entry exists because `runTurn` looks
|
|
@@ -447,8 +501,50 @@ const CLAUDE_PERM = {
|
|
|
447
501
|
image: (kd) => consultPermFor(kd),
|
|
448
502
|
};
|
|
449
503
|
export const CLAUDE_PERM_PROFILES = Object.freeze(Object.keys(CLAUDE_PERM));
|
|
450
|
-
export function claudePermFor(profileName, knowledgeDir, vaultDir) {
|
|
504
|
+
export function claudePermFor(profileName, knowledgeDir, vaultDir, projectMcp = []) {
|
|
451
505
|
const build = Object.hasOwn(CLAUDE_PERM, profileName) ? CLAUDE_PERM[profileName] : null;
|
|
452
506
|
if (!build) throw new Error(`no Claude permission list for profile '${profileName}'`);
|
|
453
|
-
return build(knowledgeDir, vaultDir);
|
|
507
|
+
return build(knowledgeDir, vaultDir, projectMcp);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* THE MACHINE'S MCP SERVERS ON AN AGENT TURN (2026-10-11, the owner: "the
|
|
512
|
+
* server that is running flowviant's claude HAS these MCPs yet the agents are
|
|
513
|
+
* not using them"). A code, write-up, mockup, 3D-model or deck turn mounts what
|
|
514
|
+
* this machine's Claude Code has — its user and local servers and the account's
|
|
515
|
+
* claude.ai connectors — beside the base branch's `.mcp.json` copy. So the turn
|
|
516
|
+
* drops `--strict-mcp-config` (runtimeClaude.mjs), and its one `--settings`
|
|
517
|
+
* carries two things:
|
|
518
|
+
*
|
|
519
|
+
* · `disabledMcpjsonServers`, every server named in a `.mcp.json` from the
|
|
520
|
+
* worktree up to `/`. Without strict, Claude Code reads `.mcp.json` in the
|
|
521
|
+
* cwd AND EVERY PARENT (measured, 2.1.290: a parent directory's server
|
|
522
|
+
* loaded) wherever a settings scope approves it, so the agent's own edit of
|
|
523
|
+
* its worktree's file would mount before review. A flag
|
|
524
|
+
* `enableAllProjectMcpServers: false` did NOT override a lower scope's
|
|
525
|
+
* `true`; the disabled list did, by name, and left the base copy loaded
|
|
526
|
+
* through `--mcp-config` (a `dynamic` server) untouched — measured with both
|
|
527
|
+
* a lower `enableAllProjectMcpServers` and a lower `enabledMcpjsonServers`.
|
|
528
|
+
* · the MCP allow hook (`MCP_ALLOW_HOOK`), because a fenced list refuses an
|
|
529
|
+
* MCP tool it does not name and a connector's name is the account's. The
|
|
530
|
+
* build's bypass needs no hook; a SAFE build's curated list does, and the
|
|
531
|
+
* hook is harmless under the bypass, so every mount carries it.
|
|
532
|
+
*
|
|
533
|
+
* `perm` is the posture's list; a list with no `--settings` (the build) gets
|
|
534
|
+
* one before its lists (`--allowedTools` is variadic). The fenced turn's
|
|
535
|
+
* personal keys are folded in after (claudePersonal.mjs); the posture's keys
|
|
536
|
+
* win there, and none of the person's kept keys is one of these.
|
|
537
|
+
*
|
|
538
|
+
* STATED, not closed: plugins stay the person's own on the build and out of
|
|
539
|
+
* every fenced turn (`--setting-sources ''`), so a plugin's MCP servers reach
|
|
540
|
+
* code cards only — a plugin also brings hooks, which would be a second author
|
|
541
|
+
* of a fenced posture.
|
|
542
|
+
*/
|
|
543
|
+
export function withMachineMcp(perm, disabledProjectMcp) {
|
|
544
|
+
const extra = { disabledMcpjsonServers: [...disabledProjectMcp] };
|
|
545
|
+
const at = perm.indexOf('--settings');
|
|
546
|
+
if (at < 0) return ['--settings', JSON.stringify({ ...extra, hooks: { PreToolUse: [MCP_ALLOW_HOOK] } }), ...perm];
|
|
547
|
+
const posture = JSON.parse(perm[at + 1]);
|
|
548
|
+
const hooks = { ...(posture.hooks ?? {}), PreToolUse: [...(posture.hooks?.PreToolUse ?? []), MCP_ALLOW_HOOK] };
|
|
549
|
+
return [...perm.slice(0, at), '--settings', JSON.stringify({ ...posture, ...extra, hooks }), ...perm.slice(at + 2)];
|
|
454
550
|
}
|