flowviant 0.125.28 → 0.125.30
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 +364 -0
- package/bin/cli.mjs +23 -0
- package/bin/lib/browserHandoff.mjs +25 -0
- package/bin/lib/desktopContract.mjs +8 -6
- package/bin/lib/environmentProcess.mjs +44 -0
- package/bin/lib/fleet.mjs +2 -0
- package/bin/lib/linuxDesktopBridge.mjs +62 -0
- package/bin/lib/managedBrowser.mjs +132 -30
- package/bin/lib/managedBrowserPage.mjs +12 -7
- package/bin/lib/mergeBatch.mjs +1 -1
- package/bin/lib/noWindowEnv.mjs +4 -2
- package/bin/lib/projectToolRuntimes.mjs +4 -2
- package/bin/lib/projectTools.mjs +2 -2
- package/bin/lib/repositoryPlaces.mjs +1 -1
- package/bin/lib/repositorySetup.mjs +15 -24
- package/bin/lib/reviewBridge.mjs +110 -0
- package/bin/lib/reviewDatabase.mjs +112 -0
- package/bin/lib/reviewDesktop.mjs +153 -0
- package/bin/lib/reviewEnvironment.mjs +286 -0
- package/bin/lib/reviewEnvironmentConfig.mjs +104 -0
- package/bin/lib/reviewEnvironmentMain.mjs +38 -0
- package/bin/lib/reviewImage.mjs +58 -0
- package/bin/lib/reviewMcpClient.mjs +69 -0
- package/bin/lib/reviewProcess.mjs +51 -0
- package/bin/lib/reviewTargets.mjs +50 -0
- package/bin/lib/robloxReview.mjs +234 -0
- package/bin/lib/robloxStudioBridge.mjs +68 -0
- package/bin/lib/runtimeAuth.mjs +56 -16
- package/bin/lib/runtimeReview.mjs +185 -0
- package/bin/lib/uiReview.mjs +5 -2
- package/bin/lib/work.mjs +22 -1
- package/bin/lib/workAgentCheck.mjs +24 -1
- package/bin/lib/workAgentTools.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +75 -11
- package/package.json +1 -1
|
@@ -40,6 +40,8 @@ export function browserAction(input) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const TOOLS = [
|
|
43
|
+
{ name: 'handoff', description: 'Keep this browser open while a person signs in or tries the app. Call before asking for human input. The daemon waits without model polling and resumes this turn after control is returned or the handoff expires.', inputSchema: { type: 'object', additionalProperties: false,
|
|
44
|
+
properties: { kind: { type: 'string', enum: ['signin', 'control'] }, reason: { type: 'string', minLength: 1, maxLength: 300 } }, required: ['kind', 'reason'] } },
|
|
43
45
|
{ name: 'open', description: 'Open the shared managed browser on a dev-server port measured in this worktree. Start the dev server first. Requires disposable test fixtures. Returns the actual page as native image input.', inputSchema: { type: 'object', additionalProperties: false,
|
|
44
46
|
properties: { port: { type: 'integer', minimum: 1, maximum: 65535 }, hostname: { type: 'string', enum: ['localhost'], description: 'Use localhost when the app’s configured authentication callback or cookies require that hostname instead of the measured loopback IP.' }, scheme: { type: 'string', enum: ['http', 'https'], description: 'Match the dev server protocol. HTTPS uses normal browser certificate validation.' }, path: { type: 'string' }, fixture: { type: 'string', description: 'Describe the disposable test data/account and why these interactions are safe.' } }, required: ['port', 'fixture'] } },
|
|
45
47
|
{ name: 'act', description: 'Interact with the SAME browser shown in Flowviant. Snapshot returns page text, controls, console/network errors and a screenshot. User control pauses agent actions; continue other work until returned.', inputSchema: { type: 'object', additionalProperties: false,
|
|
@@ -58,7 +60,7 @@ export function managedPreviewOrigin(wt, port, hostname, scheme = 'http') {
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
export async function createManagedBrowser({ wt, turnId, signal, onChild, openPage = openManagedPage,
|
|
61
|
-
measuredOrigin = managedPreviewOrigin, connect = url => new WebSocket(url, {
|
|
63
|
+
measuredOrigin = managedPreviewOrigin, handoffTimeoutMs = 10 * 60_000, connect = url => new WebSocket(url, {
|
|
62
64
|
headers: { ...fleetHeaders({ json: false }), 'X-Flowviant-Instance': DAEMON_INSTANCE },
|
|
63
65
|
handshakeTimeout: 10_000, maxPayload: 16_384,
|
|
64
66
|
}) }) {
|
|
@@ -68,11 +70,40 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
68
70
|
let lastRxAt = 0;
|
|
69
71
|
let captures = 0;
|
|
70
72
|
let observationEpoch = 0, pageEpoch = 0;
|
|
73
|
+
let pageOrigin = null, pageMeasured = null, pageScope = null, statusPending = false, statusRetry = null;
|
|
74
|
+
let handoff = null, handoffResult = null;
|
|
75
|
+
let transportStarted = false;
|
|
71
76
|
const controller = new AbortController();
|
|
72
|
-
const send = message => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
const send = message => {
|
|
78
|
+
if (ws?.readyState !== 1 || ws.bufferedAmount >= 1_200_000) return false;
|
|
79
|
+
ws.send(JSON.stringify(message)); return true;
|
|
80
|
+
};
|
|
81
|
+
const flushStatus = () => {
|
|
82
|
+
if (!statusPending) return;
|
|
83
|
+
if (send(status.private ? { ...status, path: '/', error: undefined } : status)) {
|
|
84
|
+
statusPending = false; clearTimeout(statusRetry); statusRetry = null;
|
|
85
|
+
} else if (!closed && ws?.readyState === 1 && !statusRetry) {
|
|
86
|
+
statusRetry = setTimeout(() => { statusRetry = null; flushStatus(); }, 100);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const change = patch => { status = { ...status, ...patch }; statusPending = true; flushStatus(); };
|
|
90
|
+
const frame = message => { if (closed || stopped) return; lastFrame = message; flushStatus(); if (!statusPending) send(message); };
|
|
91
|
+
const release = () => { pageEpoch++; observationEpoch++; page?.close(); page = null; pageOrigin = null; pageMeasured = null; pageScope = null; lastFrame = null; };
|
|
92
|
+
const endHandoff = reason => {
|
|
93
|
+
if (!handoff) return;
|
|
94
|
+
const active = handoff; handoff = null; clearTimeout(active.timer);
|
|
95
|
+
handoffResult = { reason }; active.resolve(handoffResult);
|
|
96
|
+
};
|
|
97
|
+
const beginHandoff = () => {
|
|
98
|
+
if (handoff) return;
|
|
99
|
+
let resolve;
|
|
100
|
+
const promise = new Promise(done => { resolve = done; });
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
endHandoff('expired'); stopped = true; controller.abort(); release();
|
|
103
|
+
change({ state: 'ended', mode: 'agent', private: false, handoff: undefined, error: 'The browser handoff expired. Run the app again to start a new session.' });
|
|
104
|
+
}, handoffTimeoutMs);
|
|
105
|
+
handoff = { promise, resolve, timer }; handoffResult = null;
|
|
106
|
+
};
|
|
76
107
|
const observe = async (retain = false) => {
|
|
77
108
|
const epoch = observationEpoch;
|
|
78
109
|
if (status.mode === 'user') throw new Error('A person has control. Browser observations are paused until control is returned.');
|
|
@@ -91,38 +122,89 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
91
122
|
}
|
|
92
123
|
return { content: [text({ page: shot.observation, diagnostics: shot.diagnostics, path: status.path, pendingOrigins: status.pendingOrigins, networkError: status.networkError, ...(artifact ? { artifact } : {}) }), { type: 'image', data: shot.data, mimeType: 'image/jpeg' }] };
|
|
93
124
|
};
|
|
94
|
-
const
|
|
125
|
+
const requireModelControl = () => {
|
|
95
126
|
if (closed || signal?.aborted) throw new Error('This browser turn has ended.');
|
|
96
127
|
if (stopped) throw new Error('A person stopped the browser for this turn.');
|
|
128
|
+
if (status.handoff && name !== 'handoff') throw new Error('The browser is waiting for a person. Continue other work or finish this invocation; the daemon will resume after the handoff.');
|
|
97
129
|
if (status.mode === 'user') throw new Error('A person has control of the shared browser. Continue other work and retry after they return control.');
|
|
98
130
|
if (busy) throw new Error('A browser action is already running.');
|
|
131
|
+
};
|
|
132
|
+
const call = async (name, input, scope = wt) => {
|
|
133
|
+
requireModelControl();
|
|
99
134
|
busy = true;
|
|
135
|
+
let openingNew = false;
|
|
100
136
|
try {
|
|
101
|
-
if (name === '
|
|
137
|
+
if (name === 'handoff') {
|
|
138
|
+
if (status.protocol !== 2) throw new Error('Update the app and daemon to use browser handoff.');
|
|
139
|
+
if (!page || status.state !== 'live') throw new Error('Open the managed browser before requesting handoff.');
|
|
140
|
+
if (!['signin', 'control'].includes(input.kind) || typeof input.reason !== 'string' || !input.reason.trim() || input.reason.length > 300) throw new Error('Supply a handoff kind and a short reason.');
|
|
141
|
+
beginHandoff(); change({ handoff: { kind: input.kind, reason: input.reason.trim() } });
|
|
142
|
+
return { content: [text({ waiting: input.kind, reason: input.reason.trim(), instruction: 'Ask the person to take control in the Live app frame. Do other work or return your current result; the daemon holds this turn and resumes it after the handoff.' })] };
|
|
143
|
+
} else if (name === 'open') {
|
|
102
144
|
const path = browserPath(input.path ?? '/');
|
|
103
145
|
if (typeof input.fixture !== 'string' || input.fixture.trim().length < 12 || input.fixture.length > 2000) throw new Error('Describe the disposable test fixtures before opening the browser.');
|
|
104
146
|
if (input.hostname != null && input.hostname !== 'localhost') throw new Error('The optional preview hostname must be localhost.');
|
|
105
|
-
const measured = measuredOrigin(
|
|
147
|
+
const measured = measuredOrigin(scope, input.port, undefined, input.scheme);
|
|
106
148
|
const upstreamUrl = new URL(measured);
|
|
107
149
|
const connectHost = upstreamUrl.hostname === '[::1]' ? '::1' : upstreamUrl.hostname;
|
|
108
150
|
if (input.hostname === 'localhost') upstreamUrl.hostname = 'localhost';
|
|
109
151
|
const upstream = upstreamUrl.origin;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
152
|
+
if (!transportStarted) { transportStarted = true; openSocket(); }
|
|
153
|
+
if (!page || status.state !== 'live' || pageOrigin !== upstream || pageMeasured !== measured || pageScope !== scope) {
|
|
154
|
+
openingNew = true;
|
|
155
|
+
release(); change({ state: 'starting', surface: 'browser', target: undefined, error: undefined, networkError: undefined, pendingOrigins: [], path });
|
|
156
|
+
const opening = pageEpoch;
|
|
157
|
+
page = await openPage({ origin: upstream, connectHost: input.hostname === 'localhost' ? connectHost : undefined, signal: controller.signal, stillOwned: () => {
|
|
158
|
+
try { return measuredOrigin(scope, input.port, undefined, input.scheme) === measured; } catch { return false; }
|
|
159
|
+
}, onFrame: message => { if (opening === pageEpoch) frame(message); },
|
|
160
|
+
onChange: patch => { if (opening === pageEpoch && !closed && !stopped) change(patch.error ? { ...patch, state: 'error' } : patch); }, onChild });
|
|
161
|
+
if (opening !== pageEpoch || closed || stopped) { page.close(); page = null; throw new Error('The browser turn has ended.'); }
|
|
162
|
+
pageOrigin = upstream; pageMeasured = measured; pageScope = scope;
|
|
163
|
+
}
|
|
117
164
|
await page.act({ type: 'navigate', path });
|
|
118
165
|
change({ state: 'live' });
|
|
119
166
|
} else {
|
|
120
167
|
if (!page || status.state !== 'live') throw new Error('Open the managed browser first.');
|
|
121
168
|
await page.act(browserAction(input));
|
|
122
169
|
}
|
|
123
|
-
|
|
170
|
+
const result = await observe(name === 'act' && input.type === 'snapshot');
|
|
171
|
+
if (!handoff) handoffResult = null;
|
|
172
|
+
return result;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (openingNew && status.mode !== 'user') { release(); if (!stopped && !closed) change({ state: 'error', error: reviewDiagnostic(error.message) }); }
|
|
175
|
+
throw error;
|
|
176
|
+
} finally { busy = false; }
|
|
177
|
+
};
|
|
178
|
+
/** Runtime adapters share this transport, ownership and private handoff.
|
|
179
|
+
* The factory is daemon-owned; no raw desktop/debugging API crosses the relay. */
|
|
180
|
+
const openSurface = async ({ key, target, surface, open }) => {
|
|
181
|
+
requireModelControl();
|
|
182
|
+
if (!['desktop', 'engine'].includes(surface) || typeof key !== 'string' || key.length > 300 || !/^[A-Za-z0-9_-]{1,60}$/.test(target)) throw new Error('Invalid runtime surface.');
|
|
183
|
+
busy = true;
|
|
184
|
+
try {
|
|
185
|
+
if (!transportStarted) { transportStarted = true; openSocket(); }
|
|
186
|
+
if (!page || status.state !== 'live' || pageOrigin !== key) {
|
|
187
|
+
release(); change({ state: 'starting', surface, target, path: '/', error: undefined, networkError: undefined, pendingOrigins: [] });
|
|
188
|
+
const opening = pageEpoch;
|
|
189
|
+
const opened = await open({ signal: controller.signal, onChild,
|
|
190
|
+
onFrame: message => { if (opening === pageEpoch) frame(message); },
|
|
191
|
+
onChange: patch => {
|
|
192
|
+
if (opening !== pageEpoch || closed || stopped) return;
|
|
193
|
+
if (patch.ended) {
|
|
194
|
+
release(); change({ state: 'ended', mode: 'agent', private: false, handoff: undefined, error: undefined }); endHandoff('returned');
|
|
195
|
+
} else if (patch.error) change({ state: 'error', error: reviewDiagnostic(patch.error) });
|
|
196
|
+
} });
|
|
197
|
+
if (opening !== pageEpoch || closed || stopped) { opened.close(); throw new Error('The interactive turn has ended.'); }
|
|
198
|
+
page = opened; pageOrigin = key;
|
|
199
|
+
if (status.mode === 'user') beginHandoff();
|
|
200
|
+
if (status.private) await page.setPrivate(true);
|
|
201
|
+
}
|
|
202
|
+
change({ state: 'live' });
|
|
203
|
+
return await observe();
|
|
124
204
|
} catch (error) {
|
|
125
|
-
|
|
205
|
+
// Taking control while startup is finishing fences this model result;
|
|
206
|
+
// the newly opened app still belongs to the person holding the frame.
|
|
207
|
+
if (status.mode !== 'user') { release(); if (!closed && !stopped) change({ state: 'error', error: reviewDiagnostic(error.message) }); }
|
|
126
208
|
throw error;
|
|
127
209
|
} finally { busy = false; }
|
|
128
210
|
};
|
|
@@ -133,10 +215,11 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
133
215
|
observationEpoch++;
|
|
134
216
|
if (Number.isSafeInteger(msg.controlId) && msg.controlId >= 0) status.controlId = msg.controlId;
|
|
135
217
|
}
|
|
136
|
-
if (msg.action === 'take') { change({ mode: 'user' }); return; }
|
|
218
|
+
if (msg.action === 'take') { if (!stopped && page) beginHandoff(); change({ mode: 'user' }); return; }
|
|
137
219
|
if (msg.action === 'signin') {
|
|
138
220
|
if (status.protocol !== 2) throw new Error('Update the app and daemon to use private sign-in.');
|
|
139
221
|
const epoch = observationEpoch;
|
|
222
|
+
if (!stopped && page) beginHandoff();
|
|
140
223
|
status = { ...status, mode: 'user', private: true }; lastFrame = null;
|
|
141
224
|
await page?.setPrivate?.(true);
|
|
142
225
|
if (epoch === observationEpoch && !closed && !stopped) change({});
|
|
@@ -146,10 +229,10 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
146
229
|
const epoch = observationEpoch;
|
|
147
230
|
lastFrame = null;
|
|
148
231
|
await page?.setPrivate?.(false);
|
|
149
|
-
if (epoch === observationEpoch && !closed && !stopped) change({ mode: 'agent', private: false });
|
|
232
|
+
if (epoch === observationEpoch && !closed && !stopped) { change({ mode: 'agent', private: false, handoff: undefined }); endHandoff('returned'); }
|
|
150
233
|
return;
|
|
151
234
|
}
|
|
152
|
-
if (msg.action === 'stop') { stopped = true; controller.abort(); release(); change({ state: 'ended', mode: 'agent', private: false, error: undefined }); return; }
|
|
235
|
+
if (msg.action === 'stop') { endHandoff('stopped'); stopped = true; controller.abort(); release(); change({ state: 'ended', mode: 'agent', private: false, handoff: undefined, error: undefined }); return; }
|
|
153
236
|
if (status.mode !== 'user') throw new Error('Take control before interacting with the browser.');
|
|
154
237
|
if (busy) throw new Error('The preceding browser action is still finishing.');
|
|
155
238
|
if (!page || status.state !== 'live') throw new Error('The browser is not open.');
|
|
@@ -173,13 +256,14 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
173
256
|
if (closed) return;
|
|
174
257
|
try { ws = connect(FLEET_URL.replace(/\/agents\/?$/, `/managed-browser/${encodeURIComponent(turnId)}`).replace(/^http/, 'ws')); }
|
|
175
258
|
catch { reconnect = setTimeout(openSocket, 3000); return; }
|
|
176
|
-
|
|
177
|
-
lastRxAt = Date.now();
|
|
259
|
+
const connected = () => {
|
|
260
|
+
lastRxAt = Date.now(); change({}); if (lastFrame && !statusPending) send(lastFrame);
|
|
178
261
|
ping = setInterval(() => {
|
|
179
262
|
if (Date.now() - lastRxAt > 45_000) { ws.terminate(); return; }
|
|
180
|
-
send({ type: 'heartbeat' });
|
|
263
|
+
flushStatus(); send({ type: 'heartbeat' });
|
|
181
264
|
}, 15_000);
|
|
182
|
-
}
|
|
265
|
+
};
|
|
266
|
+
ws.on('open', connected);
|
|
183
267
|
ws.on('message', data => {
|
|
184
268
|
lastRxAt = Date.now();
|
|
185
269
|
let msg; try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
@@ -188,7 +272,13 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
188
272
|
void control(msg).catch(error => { if (!closed && !stopped) send({ type: 'notice', message: reviewDiagnostic(error.message) }); });
|
|
189
273
|
});
|
|
190
274
|
ws.on('error', () => {});
|
|
191
|
-
ws.on('close', code => {
|
|
275
|
+
ws.on('close', code => {
|
|
276
|
+
clearInterval(ping); clearTimeout(statusRetry); statusRetry = null; statusPending = true;
|
|
277
|
+
if (!status.private && status.mode === 'user') { status = { ...status, mode: 'agent', handoff: undefined }; endHandoff('returned'); }
|
|
278
|
+
if (!closed && code !== 1008) reconnect = setTimeout(openSocket, 3000);
|
|
279
|
+
});
|
|
280
|
+
// Test transports and already-open local adapters can connect immediately.
|
|
281
|
+
if (ws.readyState === 1) connected();
|
|
192
282
|
};
|
|
193
283
|
const held = await listenHeldTool({ envPrefix: 'FLOWVIANT_BROWSER', verb: '__managed-browser-mcp', requestTimeoutMs: 35_000,
|
|
194
284
|
isClosed: () => closed, respond: async (req, res) => {
|
|
@@ -200,20 +290,32 @@ export async function createManagedBrowser({ wt, turnId, signal, onChild, openPa
|
|
|
200
290
|
res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
|
|
201
291
|
} catch (error) { res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ isError: true, content: [text(reviewDiagnostic(error.message))] })); }
|
|
202
292
|
} });
|
|
203
|
-
|
|
293
|
+
const secretKey = `browser:${held.capability.slice(0, 12)}`;
|
|
294
|
+
learnExtraSecrets(secretKey, [{ name: 'FLOWVIANT_BROWSER_CAPABILITY', value: held.capability }]);
|
|
204
295
|
const close = async () => {
|
|
205
296
|
if (closed) return;
|
|
206
297
|
closed = true; signal?.removeEventListener('abort', abort);
|
|
207
|
-
clearTimeout(reconnect); clearInterval(ping);
|
|
298
|
+
clearTimeout(reconnect); clearInterval(ping); clearTimeout(statusRetry); statusRetry = null; endHandoff('closed');
|
|
208
299
|
controller.abort(); release(); change({ state: 'ended', mode: 'agent' });
|
|
209
300
|
ws?.close(1000); held.server.close(); held.server.closeAllConnections();
|
|
210
|
-
forgetExtraSecrets(
|
|
301
|
+
forgetExtraSecrets(secretKey);
|
|
211
302
|
};
|
|
212
303
|
const abort = () => { void close(); };
|
|
213
304
|
signal?.addEventListener('abort', abort, { once: true });
|
|
214
|
-
if (signal?.aborted) await close();
|
|
305
|
+
if (signal?.aborted) await close();
|
|
215
306
|
return { env: held.env, config: { command: process.execPath, args: held.args, env: held.env }, close,
|
|
216
|
-
|
|
307
|
+
open: (input, scope = wt) => call('open', input, scope), act: input => call('act', input), handoff: input => call('handoff', input), openSurface,
|
|
308
|
+
assertControl: requireModelControl,
|
|
309
|
+
stopSurface() { requireModelControl(); release(); change({ state: 'ended', mode: 'agent', private: false, handoff: undefined }); },
|
|
310
|
+
async waitForHandoff() {
|
|
311
|
+
for (;;) {
|
|
312
|
+
const result = handoff ? await handoff.promise : handoffResult;
|
|
313
|
+
if (handoff) continue;
|
|
314
|
+
if (handoffResult === result) handoffResult = null;
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
instructions: 'Use card_browser.open and card_browser.act for interactive browser work. Both CLIs use this same Flowviant-managed browser, visible live to the project. Start the repository-supported dev server in this worktree first; open its measured port with disposable fixtures. Match the app’s configured auth callback hostname: pass hostname localhost to open when its callback/cookies use localhost rather than 127.0.0.1. Use a disposable test account when provided. If login, MFA or SSO needs a person, call card_browser.handoff with kind signin and a short reason BEFORE asking them to use Sign in privately in the Live app frame; never ask for passwords or one-time codes in chat. For human testing use handoff kind control. Human control pauses browser actions and observations. After requesting handoff, do other work or return your current result; the daemon keeps this turn and browser open without model polling for up to ten minutes, then resumes the same conversation after return, stop or expiry. Repeated open calls on the same measured origin preserve login and origin approvals; another origin gets a fresh profile. Additional HTTP(S) origins require a person to approve the actual requested origin in the frame and retry. You cannot approve network access through tools. Popups, frames, workers, sockets and downloads are blocked; use same-tab sign-in and report unsupported passkey, client-certificate or popup flows. Network access is that of the daemon machine, including its VPN/DNS/certificates; do not claim a VPN is connected from a timeout. Report observed failures, ask the person to connect that machine if needed, and retry after they confirm. Do not open an independent browser or publish a preview to bypass access. Screenshots are native image input: inspect them and report actual observations, errors and skipped checks. Call act with type snapshot before finishing to retain a screenshot as a task artifact. Interactions do not establish a passing test or final-source verification; use the local UI review helper for retained scenario evidence.' };
|
|
217
319
|
}
|
|
218
320
|
|
|
219
321
|
export async function managedBrowserMain() {
|
|
@@ -15,7 +15,7 @@ import { createBrowserNetwork, MANAGED_BROWSER_CSP } from './managedBrowserNetwo
|
|
|
15
15
|
const SNAPSHOT = `JSON.stringify({title:document.title,path:location.pathname,
|
|
16
16
|
text:document.body?.innerText.slice(0,12000),controls:Array.from(document.querySelectorAll('a,button,input,textarea,select,[role="button"]')).slice(0,100).map(e=>({tag:e.tagName,id:e.id,name:e.getAttribute('name'),type:e.getAttribute('type'),text:(e.innerText||e.getAttribute('aria-label')||e.getAttribute('placeholder')||'').slice(0,160)}))})`;
|
|
17
17
|
|
|
18
|
-
export async function openManagedPage({ origin, connectHost, stillOwned, signal, onFrame = () => {}, onChange = () => {}, onChild = () => () => {}, browser = resolveBrowser() }) {
|
|
18
|
+
export async function openManagedPage({ origin, connectHost, stillOwned, signal, onFrame = () => {}, onChange = () => {}, onChild = () => () => {}, browser = resolveBrowser(), navigationTimeoutMs = 15_000 }) {
|
|
19
19
|
if (!browser) throw new Error('No Chrome/Chromium/Edge found on this machine.');
|
|
20
20
|
if (browser.viaWindows) throw new Error('A Windows browser reached through WSL cannot carry the scoped DevTools pipe.');
|
|
21
21
|
if (signal?.aborted) throw new Error('The browser turn has ended.');
|
|
@@ -34,24 +34,29 @@ export async function openManagedPage({ origin, connectHost, stillOwned, signal,
|
|
|
34
34
|
let streamActive = true, streamEpoch = 0, streamQueue = Promise.resolve();
|
|
35
35
|
const screencast = { format: 'jpeg', quality: 65, maxWidth: 1440, maxHeight: 900, everyNthFrame: 1 };
|
|
36
36
|
const note = text => { if (privateInput) return; diagnostics.push(reviewDiagnostic(text)); if (diagnostics.length > 40) diagnostics.shift(); };
|
|
37
|
-
const close =
|
|
37
|
+
const close = reason => {
|
|
38
38
|
if (closed) return;
|
|
39
39
|
closed = true;
|
|
40
40
|
clearTimeout(frameTimer);
|
|
41
41
|
socketDeny.close();
|
|
42
42
|
signal?.removeEventListener('abort', close);
|
|
43
|
-
cdp?.close('Managed browser closed.');
|
|
43
|
+
cdp?.close(typeof reason === 'string' ? reason : 'Managed browser closed.');
|
|
44
44
|
if (child) killGroup(child);
|
|
45
45
|
untrack?.();
|
|
46
46
|
// Reap only after Chromium exits; Windows cannot unlink an open profile.
|
|
47
47
|
if (child && child.exitCode == null && child.signalCode == null) child.once('close', () => rmSync(home, { recursive: true, force: true }));
|
|
48
48
|
else rmSync(home, { recursive: true, force: true });
|
|
49
49
|
};
|
|
50
|
+
const fail = reason => { if (!closed) { onChange({ error: reviewDiagnostic(reason) }); close(reason); } };
|
|
50
51
|
signal?.addEventListener('abort', close, { once: true });
|
|
51
52
|
const raw = (method, params = {}, session) => {
|
|
52
53
|
let timer;
|
|
53
54
|
return Promise.race([cdp.send(method, params, session), new Promise((_, reject) => {
|
|
54
|
-
|
|
55
|
+
const timeoutMs = method === 'Page.navigate' ? navigationTimeoutMs : 15_000;
|
|
56
|
+
timer = setTimeout(() => {
|
|
57
|
+
const reason = `The managed browser did not answer within ${timeoutMs / 1000} seconds.`;
|
|
58
|
+
fail(reason); reject(new Error(reason));
|
|
59
|
+
}, timeoutMs);
|
|
55
60
|
})]).finally(() => clearTimeout(timer));
|
|
56
61
|
};
|
|
57
62
|
const send = (method, params) => raw(method, params, sessionId);
|
|
@@ -73,8 +78,8 @@ export async function openManagedPage({ origin, connectHost, stillOwned, signal,
|
|
|
73
78
|
if (!child.stdio?.[3] || !child.stdio?.[4]) throw new Error('No DevTools pipe is available.');
|
|
74
79
|
cdp = createCdpClient(child.stdio[3], child.stdio[4], { maxMessageBytes: 8 * 1024 * 1024 });
|
|
75
80
|
child.stderr.on('data', data => note(data.toString()));
|
|
76
|
-
child.on('error', error =>
|
|
77
|
-
child.on('exit', () =>
|
|
81
|
+
child.on('error', error => fail(error.message));
|
|
82
|
+
child.on('exit', () => fail('The managed browser exited.'));
|
|
78
83
|
for (const pipe of [child.stdio[3], child.stdio[4]]) pipe.on('error', () => {});
|
|
79
84
|
let lastFrame = 0;
|
|
80
85
|
cdp.on(msg => {
|
|
@@ -95,7 +100,7 @@ export async function openManagedPage({ origin, connectHost, stillOwned, signal,
|
|
|
95
100
|
responsePhrase: p.responseStatusText ?? '', responseHeaders: [...(p.responseHeaders ?? []), { name: 'Content-Security-Policy', value: MANAGED_BROWSER_CSP }] });
|
|
96
101
|
} else await send('Fetch.continueRequest', { requestId: p.requestId });
|
|
97
102
|
})().catch(error => {
|
|
98
|
-
note(error.message);
|
|
103
|
+
note(error.message); fail('Browser network policy could not be applied.');
|
|
99
104
|
});
|
|
100
105
|
}
|
|
101
106
|
if (msg.method === 'Page.screencastFrame') {
|
package/bin/lib/mergeBatch.mjs
CHANGED
|
@@ -65,7 +65,7 @@ export async function integrateMergeBatch({ spec, progress = {}, previousError,
|
|
|
65
65
|
await report(states);
|
|
66
66
|
for (const r of rows) {
|
|
67
67
|
const stamp = resolve(r.wt, git(['rev-parse', '--git-path', 'flowviant-batch-setup'], r.wt));
|
|
68
|
-
if (!existsSync(stamp)) { await setup(r.repoRoot, r.wt); writeFileSync(stamp, 'ready'); }
|
|
68
|
+
if (!existsSync(stamp)) { await setup(r.repoRoot, r.wt, { baseRef: r.baseRef }); writeFileSync(stamp, 'ready'); }
|
|
69
69
|
}
|
|
70
70
|
const worktrees = rows.map((r) => ({ wt: r.wt, branch: r.branch, repoRoot: r.repoRoot }));
|
|
71
71
|
if (spec.prMode && pr.ghReady()) throw new Error('This project merges through pull requests. GitHub CLI is not installed or signed in on this machine.');
|
package/bin/lib/noWindowEnv.mjs
CHANGED
|
@@ -67,8 +67,10 @@ export function withoutWindows(env, browserHome = null) {
|
|
|
67
67
|
/**
|
|
68
68
|
* EVERY CLI CHILD THE DAEMON SPAWNS UNATTENDED, ONE RULE (2026-10-10, the
|
|
69
69
|
* audit): no machine credential (machineEnv.mjs), and no way to a window
|
|
70
|
-
* unless a person at the keyboard asked for one (
|
|
71
|
-
*
|
|
70
|
+
* unless a person at the keyboard asked for one (a Terminal tab), or the
|
|
71
|
+
* runtime-review daemon leaf supplied its own private X display/authority.
|
|
72
|
+
* `display: true` is never used to inherit a personal display on a code-agent
|
|
73
|
+
* CLI. It was composed by hand twice — a turn's `cliEnv`
|
|
72
74
|
* (runTurn.mjs) and the project check's `checkEnv` (workAgentCheck.mjs) — and
|
|
73
75
|
* skipped outright by the two spawns the daemon makes for its own purposes:
|
|
74
76
|
* the startup skills probe (runtimeCapabilities.mjs), whose `claude` started
|
|
@@ -80,6 +80,7 @@ function codexServerTables(servers) {
|
|
|
80
80
|
let out = '';
|
|
81
81
|
for (const [name, server] of Object.entries(servers ?? {})) {
|
|
82
82
|
out += `\n[mcp_servers.${name}]\n`;
|
|
83
|
+
if (Number.isInteger(server.toolTimeoutSec) && server.toolTimeoutSec > 0) out += `tool_timeout_sec = ${server.toolTimeoutSec}\n`;
|
|
83
84
|
const approve = 'default_tools_approval_mode = "approve"\n';
|
|
84
85
|
if (typeof server.command === 'string') {
|
|
85
86
|
out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n${approve}`;
|
|
@@ -282,7 +283,8 @@ function reviewedClaudePlugin(dir, snapshot) {
|
|
|
282
283
|
* turn)` returns the runner-specific part of prepareAgentTools' result — under
|
|
283
284
|
* `dir`, which dies with the turn, except what `turn.codexHome` names a
|
|
284
285
|
* lasting place for. `turn.mcpServers` is the base branch's servers this
|
|
285
|
-
* runtime takes, expanded; `turn.
|
|
286
|
+
* runtime takes, expanded; `turn.turnServers` retains daemon-owned metadata
|
|
287
|
+
* omitted from Claude's file; `turn.fenced` a posture that reads no config file
|
|
286
288
|
* of the runner's own.
|
|
287
289
|
*/
|
|
288
290
|
const TOOL_RUNTIMES = {
|
|
@@ -301,7 +303,7 @@ const TOOL_RUNTIMES = {
|
|
|
301
303
|
// dies with the turn, as every home did before. A fenced turn ignores the
|
|
302
304
|
// home's config.toml, so no server is written into it (2026-10-17).
|
|
303
305
|
prepare: (dir, _snapshot, env, worktree, turn) => {
|
|
304
|
-
const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.fenced ? {} : turn?.mcpServers);
|
|
306
|
+
const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.fenced ? {} : { ...turn?.mcpServers, ...turn?.turnServers });
|
|
305
307
|
return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions, codexApprovals: turn?.fenced ? [] : isolated.approvals, codexMachineServers: turn?.fenced ? null : isolated.machineServers };
|
|
306
308
|
},
|
|
307
309
|
// A fenced posture ignores that home's config.toml (2026-10-10).
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -220,7 +220,7 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
// Daemon-owned names cannot be replaced by project config. A turn server's
|
|
223
|
-
// `toolTimeoutSec`
|
|
223
|
+
// `toolTimeoutSec` belongs to Codex's config.toml or fenced mount; it is
|
|
224
224
|
// no key of Claude's mcp.json.
|
|
225
225
|
for (const [name, { toolTimeoutSec: _codexOnly, ...config }] of Object.entries(turnServers)) mcpServers[name] = config;
|
|
226
226
|
const mcpPath = join(dir, 'mcp.json');
|
|
@@ -238,7 +238,7 @@ export function prepareAgentTools(snapshot, runner, callerEnv = process.env, wor
|
|
|
238
238
|
].filter(Boolean).join('\n\n');
|
|
239
239
|
let prepared;
|
|
240
240
|
try {
|
|
241
|
-
prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers, fenced });
|
|
241
|
+
prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers, turnServers, fenced });
|
|
242
242
|
} catch (error) {
|
|
243
243
|
rmSync(dir, { recursive: true, force: true });
|
|
244
244
|
throw error;
|
|
@@ -137,7 +137,7 @@ export function createRepositoryPlaces({ repoRoot, baseDir, repositories, setup
|
|
|
137
137
|
const repo = catalog.find((r) => r.id === item.id);
|
|
138
138
|
if (!repo?.repoRoot) throw new Error(`Repository ${item.name} is not checked out on this machine.`);
|
|
139
139
|
setupOptions.signal?.throwIfAborted();
|
|
140
|
-
await setup(repo.repoRoot, join(home, item.folder), setupOptions);
|
|
140
|
+
await setup(repo.repoRoot, join(home, item.folder), { ...setupOptions, baseRef: repo.baseRef });
|
|
141
141
|
item.setup = true;
|
|
142
142
|
save(home, state);
|
|
143
143
|
}
|
|
@@ -2,15 +2,25 @@
|
|
|
2
2
|
* A checked-in .flowviant/setup.sh replaces the default dependency install. */
|
|
3
3
|
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join, dirname } from 'node:path';
|
|
5
|
-
import { spawn } from 'node:child_process';
|
|
6
|
-
import { killGroup } from './processGroup.mjs';
|
|
7
5
|
import { git } from './git.mjs';
|
|
8
|
-
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
9
6
|
import { workLanesStopped } from './standDownGate.mjs';
|
|
7
|
+
import { environmentCommand } from './environmentProcess.mjs';
|
|
8
|
+
import { readEnvironmentRecipe } from './reviewEnvironmentConfig.mjs';
|
|
9
|
+
import { prepareWorktreeEnvironment } from './reviewEnvironment.mjs';
|
|
10
10
|
|
|
11
|
-
export async function setupRepository(root, wt, { signal, onSpawn = () => {}, onClose = () => {} } = {}) {
|
|
11
|
+
export async function setupRepository(root, wt, { signal, onSpawn = () => {}, onClose = () => {}, baseRef = 'HEAD', snapshot = readEnvironmentRecipe(root, baseRef) } = {}) {
|
|
12
12
|
signal?.throwIfAborted();
|
|
13
13
|
if (workLanesStopped()) throw new Error('The daemon is stopping. Repository setup has not started.');
|
|
14
|
+
const command = existsSync(join(wt, '.flowviant/setup.sh')) ? ['sh', '.flowviant/setup.sh'] :
|
|
15
|
+
['bun.lock', 'bun.lockb'].some((f) => existsSync(join(wt, f))) ? ['bun', 'install', '--frozen-lockfile'] :
|
|
16
|
+
existsSync(join(wt, 'pnpm-lock.yaml')) ? ['pnpm', 'install', '--frozen-lockfile'] :
|
|
17
|
+
existsSync(join(wt, 'package-lock.json')) ? ['npm', 'ci'] :
|
|
18
|
+
existsSync(join(wt, 'yarn.lock')) ? ['yarn', 'install', '--frozen-lockfile'] : null;
|
|
19
|
+
const options = { signal, onSpawn, onClose };
|
|
20
|
+
// An explicit recipe replaces blind environment copying. Allocate first so
|
|
21
|
+
// even an install/setup hook receives the worktree's scoped database.
|
|
22
|
+
if (snapshot) return prepareWorktreeEnvironment(root, wt, { ...options, snapshot,
|
|
23
|
+
install: command ? env => environmentCommand(command, { ...options, cwd: wt, env, label: 'Repository setup', captureOutput: false, includeDiagnostics: true }) : null });
|
|
14
24
|
const copyEnv = (relative = '') => {
|
|
15
25
|
for (const name of readdirSync(join(root, relative))) {
|
|
16
26
|
if (['.git', 'node_modules', '.flowviant', 'dist', 'target', '.next', '.cache', '.venv'].includes(name)) continue;
|
|
@@ -28,27 +38,8 @@ export async function setupRepository(root, wt, { signal, onSpawn = () => {}, on
|
|
|
28
38
|
}
|
|
29
39
|
};
|
|
30
40
|
copyEnv();
|
|
31
|
-
const command = existsSync(join(wt, '.flowviant/setup.sh')) ? ['sh', '.flowviant/setup.sh'] :
|
|
32
|
-
['bun.lock', 'bun.lockb'].some((f) => existsSync(join(wt, f))) ? ['bun', 'install', '--frozen-lockfile'] :
|
|
33
|
-
existsSync(join(wt, 'pnpm-lock.yaml')) ? ['pnpm', 'install', '--frozen-lockfile'] :
|
|
34
|
-
existsSync(join(wt, 'package-lock.json')) ? ['npm', 'ci'] :
|
|
35
|
-
existsSync(join(wt, 'yarn.lock')) ? ['yarn', 'install', '--frozen-lockfile'] : null;
|
|
36
41
|
if (!command) return;
|
|
37
|
-
await
|
|
38
|
-
const child = spawn(command[0], command.slice(1), { cwd: wt, env: unattendedEnv({ ...process.env, GIT_TERMINAL_PROMPT: '0' }), detached: true, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
39
|
-
const abort = () => killGroup(child);
|
|
40
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
41
|
-
if (signal?.aborted) abort();
|
|
42
|
-
onSpawn(child);
|
|
43
|
-
let tail = '';
|
|
44
|
-
child.stderr.on('data', (s) => { tail = (tail + s).slice(-2000); });
|
|
45
|
-
const timer = setTimeout(() => { killGroup(child); }, 10 * 60_000);
|
|
46
|
-
child.once('error', (e) => { clearTimeout(timer); reject(e); });
|
|
47
|
-
child.once('close', (code) => {
|
|
48
|
-
clearTimeout(timer); signal?.removeEventListener('abort', abort); onClose(child);
|
|
49
|
-
code === 0 ? resolve() : reject(new Error(`${command.join(' ')} failed in ${wt}: ${tail}`));
|
|
50
|
-
});
|
|
51
|
-
});
|
|
42
|
+
await environmentCommand(command, { ...options, cwd: wt, env: process.env, label: 'Repository setup', captureOutput: false, includeDiagnostics: true });
|
|
52
43
|
}
|
|
53
44
|
|
|
54
45
|
/** Installs are daemon children too: shutdown must drain their process groups. */
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Native/engine helpers speak bounded JSON-RPC over a private stdio pipe. */
|
|
2
|
+
import { startReviewProcess } from './reviewProcess.mjs';
|
|
3
|
+
import { reviewDiagnostic } from './uiReviewBrowser.mjs';
|
|
4
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
5
|
+
|
|
6
|
+
function image(value) {
|
|
7
|
+
if (!value || value.width !== 1440 || value.height !== 900 || typeof value.data !== 'string' || value.data.length > 1_000_000 || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.data)) throw new Error('The runtime bridge returned an invalid bounded frame.');
|
|
8
|
+
const bytes = Buffer.from(value.data, 'base64');
|
|
9
|
+
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error('Runtime bridge frames must be JPEG images.');
|
|
10
|
+
return { type: 'frame', data: value.data, width: 1440, height: 900 };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function openBridgePage({ command, cwd, worktree, configuration = {}, env, signal, timeoutMs = 600_000, onChild, onFrame = () => {}, onChange = () => {}, requestTimeoutMs = 20_000, closeGraceMs = 500 }) {
|
|
14
|
+
signal?.throwIfAborted();
|
|
15
|
+
// Engine helpers may need to end their own play session over MCP before
|
|
16
|
+
// their child can be killed. Cancellation therefore closes the parent pipe.
|
|
17
|
+
const child = startReviewProcess(command, { cwd, env, onChild, timeoutMs: timeoutMs + closeGraceMs, stdio: ['pipe', 'pipe', 'pipe'], captureStdout: false });
|
|
18
|
+
const pending = new Map();
|
|
19
|
+
let serial = 0, epoch = 0, closed = false, ready = false, buffer = '', closeTimer, lifetime;
|
|
20
|
+
const decoder = new StringDecoder('utf8');
|
|
21
|
+
const close = (reason = new Error('The runtime bridge closed.')) => {
|
|
22
|
+
if (closed) return child.done;
|
|
23
|
+
closed = true;
|
|
24
|
+
clearTimeout(lifetime); signal?.removeEventListener('abort', abort);
|
|
25
|
+
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(reason); }
|
|
26
|
+
pending.clear(); child.child.stdin.end();
|
|
27
|
+
// Let a well-behaved host helper release its session on parent-pipe EOF.
|
|
28
|
+
// A stalled helper still loses its owned group within a bounded grace.
|
|
29
|
+
closeTimer = setTimeout(() => { void child.close(); }, closeGraceMs); closeTimer.unref?.();
|
|
30
|
+
if (!child.running()) clearTimeout(closeTimer);
|
|
31
|
+
return child.done;
|
|
32
|
+
};
|
|
33
|
+
const exited = result => new Error(result.error ?? `The runtime bridge exited (${result.code}).`);
|
|
34
|
+
const abort = () => { void close(); };
|
|
35
|
+
const fail = error => { if (!closed) { onChange({ error: reviewDiagnostic(error.message) }); close(error); } };
|
|
36
|
+
lifetime = setTimeout(() => fail(new Error('The runtime bridge reached its time limit.')), timeoutMs); lifetime.unref?.();
|
|
37
|
+
signal?.addEventListener('abort', abort, { once: true }); if (signal?.aborted) abort();
|
|
38
|
+
child.done.then(result => { clearTimeout(closeTimer); if (!closed) fail(exited(result)); });
|
|
39
|
+
child.child.stdout.on('data', bytes => {
|
|
40
|
+
if (closed) return;
|
|
41
|
+
buffer += decoder.write(bytes);
|
|
42
|
+
if (Buffer.byteLength(buffer) > 1_400_000) { fail(new Error('The runtime bridge exceeded its message bound.')); return; }
|
|
43
|
+
let newline;
|
|
44
|
+
while (!closed && (newline = buffer.indexOf('\n')) >= 0) {
|
|
45
|
+
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
|
|
46
|
+
try {
|
|
47
|
+
const message = JSON.parse(line);
|
|
48
|
+
if (message.jsonrpc !== '2.0') throw new Error('The runtime bridge protocol is invalid.');
|
|
49
|
+
if (message.id != null) {
|
|
50
|
+
const entry = pending.get(message.id); if (!entry) continue;
|
|
51
|
+
pending.delete(message.id); clearTimeout(entry.timer);
|
|
52
|
+
if (message.error) entry.reject(new Error(reviewDiagnostic(message.error.message ?? 'Runtime bridge operation failed.')));
|
|
53
|
+
else entry.resolve(message.result);
|
|
54
|
+
} else if (ready && message.params?.epoch === epoch) {
|
|
55
|
+
if (message.method === 'frame') onFrame(image(message.params));
|
|
56
|
+
else if (message.method === 'ended') onChange({ ended: true });
|
|
57
|
+
else if (message.method === 'failure') fail(new Error(reviewDiagnostic(message.params.message)));
|
|
58
|
+
}
|
|
59
|
+
} catch (error) { fail(error); }
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
const request = (method, params) => {
|
|
63
|
+
if (closed) return Promise.reject(new Error('The runtime bridge is closed.'));
|
|
64
|
+
const id = ++serial;
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const timer = setTimeout(() => { pending.delete(id); const error = new Error('The runtime bridge did not answer in time.'); reject(error); fail(error); }, requestTimeoutMs);
|
|
67
|
+
pending.set(id, { resolve, reject, timer });
|
|
68
|
+
child.child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n', error => {
|
|
69
|
+
if (!error || closed) return;
|
|
70
|
+
// A fast launcher refusal (e.g. a held flock) can close stdin before
|
|
71
|
+
// Node observes its exit. Preserve that measured refusal over EPIPE.
|
|
72
|
+
void (async () => {
|
|
73
|
+
let timer;
|
|
74
|
+
try {
|
|
75
|
+
const result = await Promise.race([child.done, new Promise(resolve => { timer = setTimeout(() => resolve(null), 250); })]);
|
|
76
|
+
fail(result ? exited(result) : error);
|
|
77
|
+
} finally { clearTimeout(timer); }
|
|
78
|
+
})();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const hello = await request('initialize', { protocol: 1, worktree, width: 1440, height: 900, configuration });
|
|
84
|
+
if (hello?.protocol !== 1 || !['owned-session', 'consented-window'].includes(hello.isolation) || hello.privateHandoff !== true) throw new Error('The bridge must bind an owned session or consented window and support private handoff fences.');
|
|
85
|
+
await request('open', { epoch }); ready = true;
|
|
86
|
+
return { kind: 'engine', pid: child.child.pid, close,
|
|
87
|
+
async setPrivate(privateInput) {
|
|
88
|
+
ready = false; const next = ++epoch;
|
|
89
|
+
const ack = await request('privacy', { private: privateInput, epoch: next });
|
|
90
|
+
if (ack?.epoch !== next || ack.drained !== true) { close(); throw new Error('The bridge did not acknowledge its private capture fence.'); }
|
|
91
|
+
ready = true;
|
|
92
|
+
},
|
|
93
|
+
async act(input) {
|
|
94
|
+
if (input.type === 'snapshot') return;
|
|
95
|
+
if (input.selector || ['navigate', 'retry'].includes(input.type)) throw new Error('Use this runtime’s coordinate input or engine tools; browser navigation is unavailable.');
|
|
96
|
+
await request('act', { input, epoch });
|
|
97
|
+
},
|
|
98
|
+
async snapshot() {
|
|
99
|
+
const current = epoch, shot = await request('snapshot', { epoch: current });
|
|
100
|
+
// A deliberate handoff invalidates this model observation, not the
|
|
101
|
+
// native session the person just took control of.
|
|
102
|
+
if (current !== epoch || !ready) throw new Error('Runtime control changed during capture.');
|
|
103
|
+
try {
|
|
104
|
+
if (shot?.epoch !== current) throw new Error('The runtime bridge returned a stale snapshot epoch.');
|
|
105
|
+
return { data: image(shot).data, observation: reviewDiagnostic(shot.observation ?? 'Native runtime observation.'), diagnostics: [] };
|
|
106
|
+
} catch (error) { fail(error); throw error; }
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
} catch (error) { close(); throw error; }
|
|
110
|
+
}
|