flowviant 0.116.0 → 0.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -12
- package/bin/cli.mjs +50 -17
- package/bin/lib/admission.mjs +7 -14
- 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 +83 -0
- package/bin/lib/agentPlan.mjs +134 -31
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +5 -8
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +194 -22
- package/bin/lib/artifacts.mjs +70 -63
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +227 -54
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +27 -13
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +193 -0
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +90 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +6 -5
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +53 -13
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +172 -171
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +54 -11
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +328 -19
- package/bin/lib/projectTools.mjs +109 -34
- package/bin/lib/prompts.mjs +205 -60
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +286 -209
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +215 -21
- package/bin/lib/runtimeClaude.mjs +78 -10
- package/bin/lib/runtimeCodex.mjs +217 -209
- package/bin/lib/runtimeDetection.mjs +8 -2
- package/bin/lib/runtimeEvents.mjs +28 -11
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +99 -14
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +16 -4
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +44 -13
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +82 -199
- package/bin/lib/workAgentPrecheck.mjs +76 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +131 -67
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +64 -7
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +119 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +18 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +113 -31
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +256 -199
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +165 -0
- package/npm-shrinkwrap.json +69 -0
- package/package.json +9 -4
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A SCREENSHOT OF ONE PAGE, AND ONLY THAT PAGE (audit 2026-10-10, F657/F756).
|
|
3
|
+
*
|
|
4
|
+
* `captureScreenshot` (shot.mjs) photographs whatever document Chrome ends up
|
|
5
|
+
* on. For a page somebody else wrote that is the wrong question: the design
|
|
6
|
+
* preview used to load the model's HTML as a top-level `file://` document,
|
|
7
|
+
* and a `<meta http-equiv=refresh>` (or `location.replace`) to
|
|
8
|
+
* `file:///home/<you>/.flowviant/credentials.json` made the uploaded PNG a
|
|
9
|
+
* picture of every machine credential on the box — past the read fence the
|
|
10
|
+
* design posture exists for, and past the secret scrub, which reads text, not
|
|
11
|
+
* pixels. CSP has no working control over top-level navigation.
|
|
12
|
+
*
|
|
13
|
+
* This capture drives Chrome over its DevTools pipe (`--remote-debugging-pipe`
|
|
14
|
+
* — no port, nothing another local process can reach) and holds the page to
|
|
15
|
+
* the one URL it was given:
|
|
16
|
+
*
|
|
17
|
+
* - EVERY network request is paused. The first Document request for `url` is
|
|
18
|
+
* the page and goes through; any other Document request — a meta refresh,
|
|
19
|
+
* a script navigation, a frame — is failed AND marks the capture as having
|
|
20
|
+
* left the page. Subresources go through only when `allowSubresource` says
|
|
21
|
+
* so (the page's own CSP hosts); the rest are failed quietly.
|
|
22
|
+
* - A NEW PAGE (window.open, target=_blank) is paused before it runs, closed,
|
|
23
|
+
* and marks the capture as having left the page.
|
|
24
|
+
* - Downloads are denied.
|
|
25
|
+
* - Before the PNG is kept, the committed document must still be `url`
|
|
26
|
+
* (fragment aside) and must not be an error page.
|
|
27
|
+
*
|
|
28
|
+
* Any of those failing is `{ ok: false, reason: 'left-page' }`, and the caller
|
|
29
|
+
* (artifactPreview.mjs) reports `renderState: 'unavailable'` with no preview.
|
|
30
|
+
* Chrome itself refuses a navigation from an http page to `file://`, which is
|
|
31
|
+
* why the caller serves the page over loopback http rather than from disk.
|
|
32
|
+
*
|
|
33
|
+
* A Windows browser reached through WSL interop cannot be driven: interop
|
|
34
|
+
* bridges stdin/stdout/stderr and nothing else, so the DevTools pipe on fds 3
|
|
35
|
+
* and 4 never arrives. That path answers 'unavailable' rather than a
|
|
36
|
+
* screenshot nobody verified.
|
|
37
|
+
*
|
|
38
|
+
* The launch obeys shot.mjs's policy: sandboxed unless `sandboxOff`, one retry
|
|
39
|
+
* without it after a launch that measured a sandbox failure, and the machine
|
|
40
|
+
* credential stripped from Chrome's environment.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { spawn } from 'node:child_process';
|
|
44
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
import { tmpdir, platform } from 'node:os';
|
|
47
|
+
import { SANDBOX_FAILURE_RE, chromeEnv, chromeFlags, resolveBrowser, sandboxOff } from './shot.mjs';
|
|
48
|
+
import { killGroup } from './processGroup.mjs';
|
|
49
|
+
|
|
50
|
+
/** How long a closing browser gets to exit before it is killed. */
|
|
51
|
+
const EXIT_GRACE_MS = 3_000;
|
|
52
|
+
/** How long the page Chrome just made gets to be auto-attached. */
|
|
53
|
+
const ATTACH_WAIT_MS = 2_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A DevTools client over a pipe pair: JSON messages, each ended by a NUL.
|
|
57
|
+
* `send(method, params, sessionId)` resolves the result or rejects with the
|
|
58
|
+
* protocol's error; `on(fn)` receives every event; `close(reason)` rejects
|
|
59
|
+
* whatever is still waiting (the browser is gone).
|
|
60
|
+
*/
|
|
61
|
+
export function createCdpClient(writable, readable) {
|
|
62
|
+
let nextId = 0;
|
|
63
|
+
let closed = null;
|
|
64
|
+
const waiting = new Map();
|
|
65
|
+
const listeners = [];
|
|
66
|
+
let chunks = [];
|
|
67
|
+
readable.on('data', (d) => {
|
|
68
|
+
let start = 0;
|
|
69
|
+
for (let i = 0; i < d.length; i++) {
|
|
70
|
+
if (d[i] !== 0) continue;
|
|
71
|
+
chunks.push(d.subarray(start, i));
|
|
72
|
+
start = i + 1;
|
|
73
|
+
let msg;
|
|
74
|
+
try {
|
|
75
|
+
msg = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
76
|
+
} catch {
|
|
77
|
+
msg = null;
|
|
78
|
+
}
|
|
79
|
+
chunks = [];
|
|
80
|
+
if (!msg) continue;
|
|
81
|
+
if (msg.id != null && waiting.has(msg.id)) {
|
|
82
|
+
const w = waiting.get(msg.id);
|
|
83
|
+
waiting.delete(msg.id);
|
|
84
|
+
if (msg.error) w.reject(new Error(msg.error.message || 'protocol error'));
|
|
85
|
+
else w.resolve(msg.result ?? {});
|
|
86
|
+
} else if (msg.method) {
|
|
87
|
+
for (const fn of listeners) fn(msg);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (start < d.length) chunks.push(d.subarray(start));
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
send(method, params = {}, sessionId) {
|
|
94
|
+
if (closed) return Promise.reject(new Error(closed));
|
|
95
|
+
const id = ++nextId;
|
|
96
|
+
const msg = { id, method, params, ...(sessionId ? { sessionId } : {}) };
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
waiting.set(id, { resolve, reject });
|
|
99
|
+
try {
|
|
100
|
+
writable.write(JSON.stringify(msg) + '\0');
|
|
101
|
+
} catch (e) {
|
|
102
|
+
waiting.delete(id);
|
|
103
|
+
reject(e);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
on(fn) {
|
|
108
|
+
listeners.push(fn);
|
|
109
|
+
},
|
|
110
|
+
close(reason) {
|
|
111
|
+
if (closed) return;
|
|
112
|
+
closed = reason;
|
|
113
|
+
for (const w of waiting.values()) w.reject(new Error(reason));
|
|
114
|
+
waiting.clear();
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `a` and `b` name the same document: equal once the fragment is dropped. */
|
|
120
|
+
export function sameDocument(a, b) {
|
|
121
|
+
try {
|
|
122
|
+
const x = new URL(a);
|
|
123
|
+
const y = new URL(b);
|
|
124
|
+
x.hash = '';
|
|
125
|
+
y.hash = '';
|
|
126
|
+
return x.href === y.href;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One launch, one page, one PNG. Resolves `{ ok: true }` with the PNG written
|
|
134
|
+
* to `out`, or `{ ok: false, reason, message, launchFailed?, sandboxFailed? }`.
|
|
135
|
+
* `launchFailed` means the browser never answered on the pipe — the only
|
|
136
|
+
* failure a different launch (classic headless, or no sandbox) can change.
|
|
137
|
+
*/
|
|
138
|
+
async function captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox }, { spawnImpl, env }) {
|
|
139
|
+
const userDataDir = mkdtempSync(join(tmpdir(), 'flowviant-pin-'));
|
|
140
|
+
const args = [...chromeFlags({ headlessFlag, noSandbox, userDataDir, width, height }), '--remote-debugging-pipe', 'about:blank'];
|
|
141
|
+
let child;
|
|
142
|
+
try {
|
|
143
|
+
child = spawnImpl(browser.bin, args, {
|
|
144
|
+
stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
|
|
145
|
+
env: chromeEnv(env),
|
|
146
|
+
// Its own process group, so the kill below takes Chrome's helpers too.
|
|
147
|
+
detached: platform() !== 'win32',
|
|
148
|
+
});
|
|
149
|
+
} catch (e) {
|
|
150
|
+
rmSync(userDataDir, { recursive: true, force: true });
|
|
151
|
+
return { ok: false, reason: 'spawn', message: e.message, launchFailed: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!child.stdio?.[3] || !child.stdio?.[4]) {
|
|
155
|
+
try { child.kill('SIGKILL'); } catch { /* gone */ }
|
|
156
|
+
rmSync(userDataDir, { recursive: true, force: true });
|
|
157
|
+
return { ok: false, reason: 'spawn', message: 'no DevTools pipe to the browser.', launchFailed: true };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let err = '';
|
|
161
|
+
child.stderr?.on('data', (d) => { err = (err + d.toString()).slice(-4000); });
|
|
162
|
+
const exited = new Promise((resolve) => {
|
|
163
|
+
if (child.exitCode != null) return resolve();
|
|
164
|
+
child.once('exit', () => resolve());
|
|
165
|
+
child.once('error', () => resolve());
|
|
166
|
+
});
|
|
167
|
+
const cdp = createCdpClient(child.stdio[3], child.stdio[4]);
|
|
168
|
+
child.once('exit', (code) => cdp.close(`Chrome exited (code ${code}).`));
|
|
169
|
+
child.once('error', (e) => cdp.close(e.message));
|
|
170
|
+
child.stdio[3]?.on?.('error', () => {});
|
|
171
|
+
child.stdio[4]?.on?.('error', () => {});
|
|
172
|
+
|
|
173
|
+
// The browser's whole group (processGroup.mjs). On Windows it is not
|
|
174
|
+
// detached, the group signal throws there, and the child itself is killed.
|
|
175
|
+
const kill = () => killGroup(child, 'SIGKILL');
|
|
176
|
+
const shutdown = async () => {
|
|
177
|
+
cdp.send('Browser.close').catch(() => {});
|
|
178
|
+
let grace;
|
|
179
|
+
const gone = await Promise.race([exited.then(() => true), new Promise((r) => { grace = setTimeout(() => r(false), EXIT_GRACE_MS); })]);
|
|
180
|
+
clearTimeout(grace);
|
|
181
|
+
// A browser that closed took its helpers with it; one that did not is
|
|
182
|
+
// killed with its whole group.
|
|
183
|
+
if (!gone) kill();
|
|
184
|
+
cdp.close('closed');
|
|
185
|
+
for (const fd of [3, 4]) {
|
|
186
|
+
try { child.stdio[fd]?.destroy?.(); } catch { /* gone */ }
|
|
187
|
+
}
|
|
188
|
+
try { child.stderr?.destroy?.(); } catch { /* gone */ }
|
|
189
|
+
try { rmSync(userDataDir, { recursive: true, force: true }); } catch { /* a dying helper still writing */ }
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
let left = null; // the first sign the page was left, in words
|
|
193
|
+
const leave = (why) => { if (!left) left = why; };
|
|
194
|
+
let deadline;
|
|
195
|
+
const timedOut = new Promise((resolve) => {
|
|
196
|
+
deadline = setTimeout(() => resolve({ ok: false, reason: 'timeout', message: `the page did not finish within ${timeoutMs / 1000}s.` }), timeoutMs);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const run = async () => {
|
|
200
|
+
try {
|
|
201
|
+
await cdp.send('Browser.getVersion');
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// Give a dying browser a moment to say why on stderr.
|
|
204
|
+
await Promise.race([exited, new Promise((r) => setTimeout(r, 200))]);
|
|
205
|
+
const tail = err.trim().split('\n').slice(-2).join(' ');
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: 'render',
|
|
209
|
+
message: `Chrome did not start (${e.message})${tail ? ' ' + tail : ''}`,
|
|
210
|
+
launchFailed: true,
|
|
211
|
+
sandboxFailed: !noSandbox && SANDBOX_FAILURE_RE.test(err),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
let ours = null; // { targetId, sessionId }
|
|
216
|
+
let ourTargetId = null;
|
|
217
|
+
let documentSeen = false;
|
|
218
|
+
const attached = new Map(); // targetId -> { sessionId, waiting, type }
|
|
219
|
+
let ready;
|
|
220
|
+
const ourSession = new Promise((r) => { ready = r; });
|
|
221
|
+
let budgetDone;
|
|
222
|
+
const budget = new Promise((r) => { budgetDone = r; });
|
|
223
|
+
|
|
224
|
+
let navigating = false;
|
|
225
|
+
// Called only for a target that is paused before it runs. Once the page
|
|
226
|
+
// is navigating, a new page is one IT opened (a popup) and never runs;
|
|
227
|
+
// before that, it can only be the window Chrome starts with.
|
|
228
|
+
const settleForeign = (targetId, info) => {
|
|
229
|
+
if (info.type === 'page') {
|
|
230
|
+
if (navigating) leave('the page opened another window');
|
|
231
|
+
cdp.send('Target.closeTarget', { targetId }).catch(() => {});
|
|
232
|
+
} else {
|
|
233
|
+
cdp.send('Runtime.runIfWaitingForDebugger', {}, info.sessionId).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
cdp.on((msg) => {
|
|
238
|
+
const p = msg.params || {};
|
|
239
|
+
if (msg.method === 'Target.attachedToTarget' && !msg.sessionId) {
|
|
240
|
+
const info = { sessionId: p.sessionId, waiting: !!p.waitingForDebugger, type: p.targetInfo?.type };
|
|
241
|
+
const targetId = p.targetInfo?.targetId;
|
|
242
|
+
attached.set(targetId, info);
|
|
243
|
+
if (ourTargetId) {
|
|
244
|
+
if (targetId === ourTargetId) ready(info.sessionId);
|
|
245
|
+
else if (info.waiting) settleForeign(targetId, info);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (!ours || msg.sessionId !== ours.sessionId) return;
|
|
250
|
+
if (msg.method === 'Fetch.requestPaused') {
|
|
251
|
+
const requestId = p.requestId;
|
|
252
|
+
const reqUrl = p.request?.url ?? '';
|
|
253
|
+
if (p.resourceType === 'Document') {
|
|
254
|
+
if (!documentSeen && p.frameId === ours.targetId && reqUrl === url) {
|
|
255
|
+
documentSeen = true;
|
|
256
|
+
cdp.send('Fetch.continueRequest', { requestId }, ours.sessionId).catch(() => {});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
leave(`the page navigated to ${reqUrl.slice(0, 200)}`);
|
|
260
|
+
cdp.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }, ours.sessionId).catch(() => {});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
let allowed = false;
|
|
264
|
+
try {
|
|
265
|
+
allowed = !!allowSubresource(reqUrl);
|
|
266
|
+
} catch {
|
|
267
|
+
allowed = false;
|
|
268
|
+
}
|
|
269
|
+
cdp.send(allowed ? 'Fetch.continueRequest' : 'Fetch.failRequest', allowed ? { requestId } : { requestId, errorReason: 'BlockedByClient' }, ours.sessionId).catch(() => {});
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (msg.method === 'Emulation.virtualTimeBudgetExpired') budgetDone();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
await cdp.send('Browser.setDownloadBehavior', { behavior: 'deny' }).catch(() => {});
|
|
276
|
+
await cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true });
|
|
277
|
+
const created = await cdp.send('Target.createTarget', { url: 'about:blank' });
|
|
278
|
+
ourTargetId = created.targetId;
|
|
279
|
+
for (const [targetId, info] of attached) {
|
|
280
|
+
if (targetId === ourTargetId) ready(info.sessionId);
|
|
281
|
+
else if (info.waiting) settleForeign(targetId, info);
|
|
282
|
+
}
|
|
283
|
+
// A Chrome that does not auto-attach the page it just made is attached
|
|
284
|
+
// to it by hand.
|
|
285
|
+
let attachWait;
|
|
286
|
+
const sessionId = await Promise.race([
|
|
287
|
+
ourSession,
|
|
288
|
+
new Promise((r) => { attachWait = setTimeout(r, ATTACH_WAIT_MS, null); }),
|
|
289
|
+
]).then((id) => id ?? cdp.send('Target.attachToTarget', { targetId: ourTargetId, flatten: true }).then((a) => a.sessionId));
|
|
290
|
+
clearTimeout(attachWait);
|
|
291
|
+
ours = { targetId: ourTargetId, sessionId };
|
|
292
|
+
|
|
293
|
+
await cdp.send('Page.enable', {}, sessionId);
|
|
294
|
+
await cdp.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] }, sessionId);
|
|
295
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId);
|
|
296
|
+
await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pause' }, sessionId);
|
|
297
|
+
await cdp.send('Runtime.runIfWaitingForDebugger', {}, sessionId).catch(() => {});
|
|
298
|
+
navigating = true;
|
|
299
|
+
const nav = await cdp.send('Page.navigate', { url }, sessionId);
|
|
300
|
+
if (nav.errorText) {
|
|
301
|
+
return { ok: false, reason: 'render', message: `the page did not load (${nav.errorText}).` };
|
|
302
|
+
}
|
|
303
|
+
// Let fonts and scripts settle, as `--virtual-time-budget` does.
|
|
304
|
+
await cdp.send('Emulation.setVirtualTimePolicy', { policy: 'pauseIfNetworkFetchesPending', budget: budgetMs }, sessionId);
|
|
305
|
+
// A Chrome that never reports the budget is captured on the wall clock,
|
|
306
|
+
// short of the deadline, rather than never.
|
|
307
|
+
let wall;
|
|
308
|
+
await Promise.race([budget, new Promise((r) => { wall = setTimeout(r, Math.max(budgetMs, timeoutMs - EXIT_GRACE_MS)); })]);
|
|
309
|
+
clearTimeout(wall);
|
|
310
|
+
|
|
311
|
+
const history = await cdp.send('Page.getNavigationHistory', {}, sessionId);
|
|
312
|
+
const current = history.entries?.[history.currentIndex]?.url ?? '';
|
|
313
|
+
const tree = await cdp.send('Page.getFrameTree', {}, sessionId);
|
|
314
|
+
const main = tree.frameTree?.frame ?? {};
|
|
315
|
+
if (!documentSeen) leave('the page was never requested');
|
|
316
|
+
if (!sameDocument(current, url) || !sameDocument(main.url ?? '', url) || main.unreachableUrl) {
|
|
317
|
+
leave(`the page ended on ${String(current || main.url || 'nothing').slice(0, 200)}`);
|
|
318
|
+
}
|
|
319
|
+
if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
|
|
320
|
+
|
|
321
|
+
const shot = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId);
|
|
322
|
+
// One last look: a navigation that landed during the capture.
|
|
323
|
+
if (left) return { ok: false, reason: 'left-page', message: `${left}.` };
|
|
324
|
+
writeFileSync(out, Buffer.from(shot.data ?? '', 'base64'));
|
|
325
|
+
return { ok: true, path: out };
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
return await Promise.race([
|
|
330
|
+
run().catch((e) => ({ ok: false, reason: 'render', message: e?.message ?? String(e) })),
|
|
331
|
+
timedOut,
|
|
332
|
+
]);
|
|
333
|
+
} finally {
|
|
334
|
+
clearTimeout(deadline);
|
|
335
|
+
await shutdown();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Capture `url` and nothing else to a PNG at `out` (see the header). Resolves
|
|
341
|
+
* `{ ok: true, path }` or `{ ok: false, reason, message }`; a reason of
|
|
342
|
+
* 'left-page' means the page tried to become something else.
|
|
343
|
+
*
|
|
344
|
+
* `browser`, `spawn`, `uid` and `env` are injectable for the tests; nothing in
|
|
345
|
+
* the daemon passes them.
|
|
346
|
+
*/
|
|
347
|
+
export async function capturePinnedPage({
|
|
348
|
+
url,
|
|
349
|
+
out,
|
|
350
|
+
width = 1440,
|
|
351
|
+
height = 900,
|
|
352
|
+
timeoutMs = 60_000,
|
|
353
|
+
budgetMs = 5_000,
|
|
354
|
+
allowSubresource = () => false,
|
|
355
|
+
browser = resolveBrowser(),
|
|
356
|
+
spawn: spawnImpl = spawn,
|
|
357
|
+
uid = process.getuid?.(),
|
|
358
|
+
env = process.env,
|
|
359
|
+
}) {
|
|
360
|
+
if (!browser) {
|
|
361
|
+
return { ok: false, reason: 'no-browser', message: 'No Chrome/Chromium/Edge found.' };
|
|
362
|
+
}
|
|
363
|
+
if (browser.viaWindows) {
|
|
364
|
+
return { ok: false, reason: 'wsl', message: 'a Windows browser reached through WSL cannot be held to one page.' };
|
|
365
|
+
}
|
|
366
|
+
let noSandbox = sandboxOff({ uid, env });
|
|
367
|
+
let last = null;
|
|
368
|
+
for (const headlessFlag of ['--headless=new', '--headless']) {
|
|
369
|
+
const once = () =>
|
|
370
|
+
captureOnce(browser, { url, out, width, height, timeoutMs, budgetMs, allowSubresource, headlessFlag, noSandbox }, { spawnImpl, env });
|
|
371
|
+
let r = await once();
|
|
372
|
+
if (!r.ok && r.sandboxFailed) {
|
|
373
|
+
noSandbox = true; // sticky: the one retry this capture gets
|
|
374
|
+
r = await once();
|
|
375
|
+
}
|
|
376
|
+
const { launchFailed, sandboxFailed: _measured, ...result } = r;
|
|
377
|
+
// Only a browser that never answered is worth another launch; a page that
|
|
378
|
+
// left, timed out or failed to load answers the same under any flag.
|
|
379
|
+
if (result.ok || !launchFailed) return result;
|
|
380
|
+
last = result;
|
|
381
|
+
}
|
|
382
|
+
return last;
|
|
383
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The planner declares prerequisites; the daemon enforces them without reading
|
|
2
|
+
* human prose. Declared collisions use the same compatible groups; evidence stays on the wire. */
|
|
3
|
+
export function groupDependencies(agents, dependencies, collisions) {
|
|
4
|
+
if (dependencies === undefined && collisions === undefined) return agents;
|
|
5
|
+
if (dependencies === undefined) dependencies = [];
|
|
6
|
+
if (collisions === undefined) collisions = [];
|
|
7
|
+
if (!Array.isArray(collisions) || collisions.length > 1200) return null;
|
|
8
|
+
if (!Array.isArray(dependencies) || dependencies.length > 1200) return null;
|
|
9
|
+
if (!dependencies.length && !collisions.length) return agents;
|
|
10
|
+
const owner = new Map();
|
|
11
|
+
for (const [i, group] of agents.entries()) {
|
|
12
|
+
for (const id of group.taskIds) {
|
|
13
|
+
if (owner.has(id)) return null;
|
|
14
|
+
owner.set(id, i);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const parents = agents.map((_, i) => i);
|
|
18
|
+
const root = (i) => {
|
|
19
|
+
while (parents[i] !== i) i = parents[i];
|
|
20
|
+
return i;
|
|
21
|
+
};
|
|
22
|
+
const needs = new Map();
|
|
23
|
+
for (const edge of dependencies) {
|
|
24
|
+
if (!edge || !owner.has(edge.taskId) || !Array.isArray(edge.requires) || edge.requires.length > 60) return null;
|
|
25
|
+
for (const id of edge.requires) {
|
|
26
|
+
if (!owner.has(id) || id === edge.taskId) return null;
|
|
27
|
+
const a = root(owner.get(edge.taskId));
|
|
28
|
+
const b = root(owner.get(id));
|
|
29
|
+
parents[Math.max(a, b)] = Math.min(a, b);
|
|
30
|
+
if (!needs.has(edge.taskId)) needs.set(edge.taskId, new Set());
|
|
31
|
+
needs.get(edge.taskId).add(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const collision of collisions) {
|
|
35
|
+
if (!collision || !Array.isArray(collision.taskIds) || new Set(collision.taskIds).size < 2 || collision.taskIds.length > 60) return null;
|
|
36
|
+
for (const id of collision.taskIds) {
|
|
37
|
+
if (!owner.has(id)) return null;
|
|
38
|
+
const a = root(owner.get(collision.taskIds[0]));
|
|
39
|
+
const b = root(owner.get(id));
|
|
40
|
+
parents[Math.max(a, b)] = Math.min(a, b);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const groups = new Map();
|
|
44
|
+
for (const [i, group] of agents.entries()) {
|
|
45
|
+
const key = root(i);
|
|
46
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
47
|
+
groups.get(key).push(group);
|
|
48
|
+
}
|
|
49
|
+
const result = [];
|
|
50
|
+
for (const members of groups.values()) {
|
|
51
|
+
const attached = [...new Set(members.map((g) => g.intoAgentId).filter(Boolean))];
|
|
52
|
+
if (attached.length > 1) return null;
|
|
53
|
+
const pending = members.flatMap((g) => g.taskIds);
|
|
54
|
+
if (pending.length > 60) return null;
|
|
55
|
+
const taskIds = [];
|
|
56
|
+
// Stable topological order: unrelated cards keep their proposed order.
|
|
57
|
+
while (pending.length) {
|
|
58
|
+
const at = pending.findIndex((id) => [...(needs.get(id) ?? [])].every((need) => taskIds.includes(need)));
|
|
59
|
+
if (at < 0) return null; // a cycle cannot become a runnable proposal
|
|
60
|
+
taskIds.push(...pending.splice(at, 1));
|
|
61
|
+
}
|
|
62
|
+
const budget = members.reduce((sum, g) => sum + (g.pointsBudget ?? 0), 0);
|
|
63
|
+
result.push({ ...members[0], taskIds,
|
|
64
|
+
...(members.some(g => g.rationale) ? { rationale: members.map(g => g.rationale).filter(Boolean).join('\n').slice(0, 1000) } : {}),
|
|
65
|
+
...(budget ? { pointsBudget: Math.min(budget, 100_000) } : {}),
|
|
66
|
+
...(attached.length ? { intoAgentId: attached[0] } : {}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Per-turn readout of mounted tools and remaining permission boundaries.
|
|
2
|
+
* Kind selects the delivery contract; CLI/configuration and the filesystem
|
|
3
|
+
* fence determine supporting capabilities. Keep these claims beside the
|
|
4
|
+
* actual builders and the spawned-lane tests in artifactProjectMcp.test.mjs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** What the machine's own MCP servers are, per CLI. */
|
|
8
|
+
const MACHINE = {
|
|
9
|
+
claude: 'the MCP servers and claude.ai connectors this machine’s Claude Code has (its user and local servers)',
|
|
10
|
+
codex: 'the MCP servers in this machine’s Codex config',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Per posture and CLI: what the card has and what it lacks. */
|
|
14
|
+
const POSTURES = {
|
|
15
|
+
research: {
|
|
16
|
+
claude: {
|
|
17
|
+
has: 'the repository’s files (never .env files), the project’s knowledge library, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
18
|
+
lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
19
|
+
},
|
|
20
|
+
codex: {
|
|
21
|
+
has: 'a sandboxed shell over an isolated copy of the repository (no .env, credential or git files, and no network from the shell), web search, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
22
|
+
lacks: 'no host files outside that copy except native generated images; native image generation depends on this CLI and account',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
design: {
|
|
26
|
+
claude: {
|
|
27
|
+
has: 'the repository’s files (never .env files), the project’s knowledge library, guarded read commands, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
28
|
+
lacks: 'no measured native image-generation tool, no delegation, no helper scripts or browsers, and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
29
|
+
},
|
|
30
|
+
codex: {
|
|
31
|
+
has: 'a sandboxed shell that reads the repository and writes only under .flowviant/artifacts/, with no shell network, scratch writes, and native image generation when this CLI and account expose it',
|
|
32
|
+
lacks: 'no native web on this broad-read fence (host credentials are readable); use a configured project web MCP server when available',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
image: {
|
|
36
|
+
codex: {
|
|
37
|
+
has: 'a sandboxed shell that reads the repository and writes only under .flowviant/artifacts/, with no shell network, scratch writes, and native image generation when this CLI and account expose it',
|
|
38
|
+
lacks: 'no web',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Configuration supplies tools; a skill supplies instructions, not a tool. */
|
|
44
|
+
const SUPPORTING_CAPABILITIES = `The kind defines the delivered product, not which supporting capabilities
|
|
45
|
+
may be used. Use available tools needed for the person's request, including
|
|
46
|
+
native image generation for requested textures or illustrations. A listed skill
|
|
47
|
+
is not proof that its tool is installed, mounted or entitled on this account.
|
|
48
|
+
Do not re-file the kind or require a separate image card just to use a tool.
|
|
49
|
+
If a needed tool is absent or fails, relay the measured limitation and ask for
|
|
50
|
+
the missing configuration or a decision. A separately billed CLI/API fallback
|
|
51
|
+
requires the person's explicit choice first; never default to OPENAI_API_KEY.
|
|
52
|
+
Use personal connectors only for the requested work; sending messages or other
|
|
53
|
+
external writes requires explicit authorization. Never open the person's browser
|
|
54
|
+
or desktop; headless inspection must stay within the granted filesystem fence.
|
|
55
|
+
Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.`;
|
|
56
|
+
|
|
57
|
+
const list = (names) => names.join(', ');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The note for a turn, or '' when its posture withholds nothing to say.
|
|
61
|
+
* `mounted` is the names of the base `.mcp.json` servers this turn mounted;
|
|
62
|
+
* `machine` whether the machine's own servers were mounted beside them (a
|
|
63
|
+
* Claude turn whose `.mcp.json` walk could not be bounded mounts the base copy
|
|
64
|
+
* alone); `unmounted` is `[{ name, reason, source }]` for the servers it
|
|
65
|
+
* could not mount — `source` 'project' (the base `.mcp.json`) or 'machine'.
|
|
66
|
+
*/
|
|
67
|
+
export function postureToolsNote({ posture, runtime, mounted = [], machine = true, unmounted = [] }) {
|
|
68
|
+
const row = POSTURES[posture]?.[runtime];
|
|
69
|
+
if (!row) return posture === 'build' ? `TOOLS ON THIS CARD. Use the tools actually exposed by this CLI and its configuration. ${SUPPORTING_CAPABILITIES.replace('Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.', 'Delegate only when the person explicitly requests it.')} ` : '';
|
|
70
|
+
const fromProject = unmounted.filter((u) => u.source !== 'machine');
|
|
71
|
+
const fromMachine = unmounted.filter((u) => u.source === 'machine');
|
|
72
|
+
const project = mounted.length
|
|
73
|
+
? `The project’s MCP servers from the base branch’s .mcp.json are mounted: ${list(mounted)}.`
|
|
74
|
+
: fromProject.length
|
|
75
|
+
? 'None of the project’s MCP servers is mounted on this machine.'
|
|
76
|
+
: 'The base branch’s .mcp.json lists no MCP server.';
|
|
77
|
+
const own = machine
|
|
78
|
+
? ` So are ${MACHINE[runtime]}.`
|
|
79
|
+
: ` ${MACHINE[runtime][0].toUpperCase()}${MACHINE[runtime].slice(1)} are not mounted on this turn.`;
|
|
80
|
+
const missing = (rows, where) => (rows.length ? ` ${where}, but not mounted here: ${list(rows.map(({ name, reason }) => `${name} (${reason})`))}.` : '');
|
|
81
|
+
return `TOOLS ON THIS CARD. You have ${row.has}. You have ${row.lacks}. ${project}${own}${missing(fromProject, 'Listed in the base branch’s .mcp.json')}${missing(fromMachine, `In ${MACHINE[runtime]}`)} A server added to .mcp.json only in this worktree is not mounted; the base branch’s file is the one that counts.
|
|
82
|
+
There is no Flowviant setting that adds a tool, so never invent one. A missing
|
|
83
|
+
server can be configured in the base branch's .mcp.json or the machine's CLI.
|
|
84
|
+
${SUPPORTING_CAPABILITIES}`;
|
|
85
|
+
}
|