@deepseek-insight/dsi 0.8.2 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/build/client/_app/immutable/assets/0.cswkTiBk.css +2 -0
  2. package/build/client/_app/immutable/chunks/C7Sg_49n.js +1 -0
  3. package/build/client/_app/immutable/chunks/{CAoCg_fG.js → CnzmIbjr.js} +1 -1
  4. package/build/client/_app/immutable/chunks/DY0MQ1mO.js +1 -0
  5. package/build/client/_app/immutable/chunks/GKkjunMm.js +2 -0
  6. package/build/client/_app/immutable/entry/{app.DIq2Q3OX.js → app.DYNedUxy.js} +2 -2
  7. package/build/client/_app/immutable/entry/start.BzyrBssE.js +1 -0
  8. package/build/client/_app/immutable/nodes/{1.DKIIO5Pq.js → 1.CHB5qqQ9.js} +1 -1
  9. package/build/client/_app/immutable/nodes/{2.CpOJSq6u.js → 2.C6No1310.js} +222 -222
  10. package/build/client/_app/version.json +1 -1
  11. package/build/handler.js +4 -4
  12. package/build/index.js +4 -4
  13. package/build/server/chunks/chunks/{dsh-events.js-CGuF_had.js → dsh-events.js-BUkLR6ZV.js} +13 -1
  14. package/build/server/chunks/chunks/{git-probe.js-Cn_ugqcH.js → git-probe.js-CLF37SRh.js} +2 -2
  15. package/build/server/chunks/chunks/{internal2.js-DFG0wDsP.js → internal2.js-J3h-US64.js} +2 -2
  16. package/build/server/chunks/chunks/registry.js-CZNJZ0C-.js +1003 -0
  17. package/build/server/chunks/chunks/{state.js-Dki_Bq11.js → state.js-8RBD56dL.js} +2 -2
  18. package/build/server/chunks/chunks/{watcher.js-f54CFvdI.js → watcher.js-BVKnoQUw.js} +2 -2
  19. package/build/server/chunks/entries/endpoints/api/a2a/{_server.ts.js-DN0zl7cz.js → _server.ts.js-3kQCQuHT.js} +3 -3
  20. package/build/server/chunks/entries/endpoints/api/a2a/register/{_server.ts.js-C3byr74D.js → _server.ts.js-BUBTCLFn.js} +3 -3
  21. package/build/server/chunks/entries/endpoints/api/browser/_id_/activate/_server.ts.js-BdMJzENm.js +45 -0
  22. package/build/server/chunks/entries/endpoints/api/browser/_id_/close/{_server.ts.js-BKChU9D7.js → _server.ts.js-B5z9-N8Y.js} +2 -2
  23. package/build/server/chunks/entries/endpoints/api/browser/_id_/closetab/_server.ts.js-CnZHun5r.js +44 -0
  24. package/build/server/chunks/entries/endpoints/api/browser/_id_/element/_server.ts.js-C1oM2Ihf.js +43 -0
  25. package/build/server/chunks/entries/endpoints/api/browser/_id_/input/{_server.ts.js-CUFYX5kg.js → _server.ts.js-DmbqGRMg.js} +5 -4
  26. package/build/server/chunks/entries/endpoints/api/browser/_id_/mcp/{_server.ts.js-fg2eb_ZY.js → _server.ts.js-Cj80T1yH.js} +2 -2
  27. package/build/server/chunks/entries/endpoints/api/browser/_id_/navigate/{_server.ts.js-Dt1n31IP.js → _server.ts.js-6gi1UYTJ.js} +11 -7
  28. package/build/server/chunks/entries/endpoints/api/browser/_id_/opentab/_server.ts.js-Bhx_J7ot.js +47 -0
  29. package/build/server/chunks/entries/endpoints/api/browser/_id_/page/{_server.ts.js-DIF8juwJ.js → _server.ts.js-DvQXNnX9.js} +4 -3
  30. package/build/server/chunks/entries/endpoints/api/browser/_id_/stream/{_server.ts.js-BFdXJdhd.js → _server.ts.js-MflkVWUn.js} +2 -2
  31. package/build/server/chunks/entries/endpoints/api/browser/_id_/tabs/_server.ts.js-CoUSlLcP.js +34 -0
  32. package/build/server/chunks/entries/endpoints/api/browser/_id_/viewport/{_server.ts.js-BIF_7OHe.js → _server.ts.js-DNk4EgjJ.js} +6 -4
  33. package/build/server/chunks/entries/endpoints/api/browser/{_server.ts.js-B7yjEF1N.js → _server.ts.js-CECKap7O.js} +14 -4
  34. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/events/{_server.ts.js-lBsxHSo6.js → _server.ts.js-BzP30TXW.js} +2 -2
  35. package/build/server/chunks/entries/endpoints/api/dsh/session/_sessionId_/history/{_server.ts.js-BrEgFdCF.js → _server.ts.js-B7YRQhuJ.js} +2 -2
  36. package/build/server/chunks/entries/endpoints/api/workspace/file-write/{_server.ts.js-BRhlbYEe.js → _server.ts.js-B-5YihUA.js} +3 -3
  37. package/build/server/chunks/entries/endpoints/api/workspace/git-events/{_server.ts.js-B9YI66ZL.js → _server.ts.js-DT5jRZFE.js} +3 -3
  38. package/build/server/chunks/entries/endpoints/api/workspace/git-file-head/{_server.ts.js-DSvXRZQL.js → _server.ts.js-DtW-utrd.js} +3 -3
  39. package/build/server/chunks/entries/endpoints/api/workspace/git-map/{_server.ts.js-8A0skdlM.js → _server.ts.js-8AIAo9jX.js} +3 -3
  40. package/build/server/chunks/entries/endpoints/api/workspace/git-status/{_server.ts.js-Bc6J3t3m.js → _server.ts.js-CMw96vjY.js} +3 -3
  41. package/build/server/chunks/entries/endpoints/api/workspace/write-gate/{_server.ts.js-CfL8Por5.js → _server.ts.js-DKufa14_.js} +3 -3
  42. package/build/server/chunks/entries/pages/{_error.svelte.js-EnDLC3Jj.js → _error.svelte.js-DRPduJdP.js} +3 -3
  43. package/build/server/chunks/entries/pages/{_page.server.ts.js-ar1DbW1v.js → _page.server.ts.js-7x6ZfGoB.js} +2 -2
  44. package/build/server/chunks/entries/pages/{_page.svelte.js-CQUo8VGL.js → _page.svelte.js-Dek8Q1_v.js} +430 -101
  45. package/build/server/chunks/{handler-BBp0LoYK.js → handler-DUyvzXsb.js} +3 -3
  46. package/build/server/chunks/{index.js-S4257Wcs.js → index.js-BrP1SRzN.js} +2 -2
  47. package/build/server/chunks/{manifest.js-DajiWH1H.js → manifest.js-C4z6aoYx.js} +58 -23
  48. package/build/server/chunks/nodes/{0.js-DtkNHIFx.js → 0.js-BuB3t8Br.js} +3 -3
  49. package/build/server/chunks/nodes/1.js-CW4V8ni8.js +9 -0
  50. package/build/server/chunks/nodes/{2.js-BORDEffk.js → 2.js-C-o9KF10.js} +5 -5
  51. package/package.json +2 -7
  52. package/template/.dsi/prompts.sqlite-shm +0 -0
  53. package/template/.dsi/prompts.sqlite-wal +0 -0
  54. package/template/.dsi/settings.yaml +2 -2
  55. package/build/client/_app/immutable/assets/0.DBVVEjy9.css +0 -2
  56. package/build/client/_app/immutable/chunks/BHodvdRc.js +0 -1
  57. package/build/client/_app/immutable/chunks/D-jXFTzB.js +0 -1
  58. package/build/client/_app/immutable/chunks/TTdBFs3C.js +0 -2
  59. package/build/client/_app/immutable/entry/start.E6P3Z7tx.js +0 -1
  60. package/build/server/chunks/chunks/registry.js-BC-TNdmn.js +0 -616
  61. package/build/server/chunks/nodes/1.js-OAvYMTzU.js +0 -9
  62. /package/build/client/_app/immutable/nodes/{0.Dm_ry-Pr.js → 0.DQYP6BCS.js} +0 -0
@@ -0,0 +1,1003 @@
1
+ import { d as readBrowserConfig } from './insight-config.js-Dp_2gB5J.js';
2
+ import { mkdirSync, rmSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { spawn, spawnSync } from 'node:child_process';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { chromium } from 'playwright-core';
7
+
8
+ //#region src/lib/server/browser/relay.ts
9
+ var RelayClosedError = class extends Error {
10
+ constructor() {
11
+ super("relay is closed");
12
+ this.name = "RelayClosedError";
13
+ }
14
+ };
15
+ var BrowserRelay = class {
16
+ quality;
17
+ maxFps;
18
+ cdp;
19
+ subscribers = /* @__PURE__ */ new Set();
20
+ seq = 0;
21
+ latest = null;
22
+ chain = Promise.resolve();
23
+ started = false;
24
+ /** ADR-0040 D1: an INACTIVE tab's screencast is suspended (no Chrome-side
25
+ * encode, no wire cost) while the relay — ring, seq, subscribers — lives
26
+ * on, so re-activation resumes instead of rebuilding. */
27
+ paused = false;
28
+ /** JPEG quality 0..100 — from BrowserConfig.screencastQuality
29
+ * (default 90; yaml `browser.screencast:` override, amendment 3).
30
+ * maxFps — ADR-0039 D1: target fps intent; start() derives CDP's
31
+ * everyNthFrame from a nominal 30 Hz paint clock (clamp ≥1). */
32
+ constructor(cdp, quality = 90, maxFps = 15) {
33
+ this.quality = quality;
34
+ this.maxFps = maxFps;
35
+ this.cdp = cdp;
36
+ }
37
+ async start() {
38
+ if (!this.cdp || this.started) throw new RelayClosedError();
39
+ this.cdp.on("Page.screencastFrame", (ev) => {
40
+ this.seq += 1;
41
+ this.latest = {
42
+ seq: this.seq,
43
+ jpeg: ev.data,
44
+ metadata: ev.metadata
45
+ };
46
+ for (const sub of this.subscribers) sub(this.latest);
47
+ this.cdp?.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => void 0);
48
+ });
49
+ await this.cdp.send("Page.startScreencast", {
50
+ format: "jpeg",
51
+ quality: this.quality,
52
+ everyNthFrame: Math.max(1, Math.round(30 / this.maxFps))
53
+ });
54
+ this.started = true;
55
+ this.paused = false;
56
+ }
57
+ /** Suspend the screencast, keep the relay (ADR-0040 D1). Idempotent. */
58
+ async pause() {
59
+ if (!this.cdp || !this.started || this.paused) return;
60
+ this.paused = true;
61
+ await this.cdp.send("Page.stopScreencast").catch(() => void 0);
62
+ }
63
+ /** Re-start the screencast after a pause (same dials). Idempotent. */
64
+ async resume() {
65
+ if (!this.cdp || !this.started || !this.paused) return;
66
+ this.paused = false;
67
+ await this.cdp.send("Page.startScreencast", {
68
+ format: "jpeg",
69
+ quality: this.quality,
70
+ everyNthFrame: Math.max(1, Math.round(30 / this.maxFps))
71
+ }).catch(() => void 0);
72
+ }
73
+ /** Activation MUST stream (ADR-0040 D4): START a never-started (lazily
74
+ * attached) relay, RESUME a paused one, no-op an already-streaming one.
75
+ * BUGFIX 2026-10-24: activate() called resume() only — a lazily attached
76
+ * tab (started=false) never began streaming and the mirror froze on its
77
+ * seeded boundary frame (the operator's hung second YouTube tab). */
78
+ async ensureStreaming() {
79
+ if (!this.cdp) throw new RelayClosedError();
80
+ if (!this.started) return this.start();
81
+ if (this.paused) return this.resume();
82
+ }
83
+ /** Deterministic first-frame seed (RCA 2026-10-23 The First Frame That
84
+ * Never Knocked): a starved screencast birth leaves the ring empty
85
+ * FOREVER — screencast only speaks on paint damage. The opener synthesizes
86
+ * the first frame via Page.captureScreenshot and seeds the ring here so
87
+ * attachers get a frame immediately. Does not advance past a real frame. */
88
+ seed(frame) {
89
+ if (!this.cdp) throw new RelayClosedError();
90
+ if (this.latest) return;
91
+ this.seq += 1;
92
+ this.latest = {
93
+ ...frame,
94
+ seq: this.seq
95
+ };
96
+ for (const sub of this.subscribers) sub(this.latest);
97
+ }
98
+ /** Navigate reseed (ADR-0039 follow-up, 2026-10-25): unlike seed(), this
99
+ * REPLACES an existing frame. A discrete navigation under a paced
100
+ * screencast (everyNthFrame > 2, headless) composites too few frames for
101
+ * Chrome to emit anything — and the ring still holds the OPENER's
102
+ * synthesized frame, which seed() refuses to advance past, so the glass
103
+ * would freeze on the old face forever. A navigation is a real face
104
+ * change: it must land even over a synthesized frame. */
105
+ reseed(frame) {
106
+ if (!this.cdp) throw new RelayClosedError();
107
+ this.seq += 1;
108
+ this.latest = {
109
+ ...frame,
110
+ seq: this.seq
111
+ };
112
+ for (const sub of this.subscribers) sub(this.latest);
113
+ }
114
+ latestFrame() {
115
+ if (!this.cdp) throw new RelayClosedError();
116
+ return this.latest;
117
+ }
118
+ subscribe(fn) {
119
+ if (!this.cdp) throw new RelayClosedError();
120
+ this.subscribers.add(fn);
121
+ return () => this.subscribers.delete(fn);
122
+ }
123
+ /** Serialized dispatch — every input waits for the previous one. */
124
+ dispatch(input) {
125
+ if (!this.cdp) return Promise.reject(new RelayClosedError());
126
+ const run = async () => {
127
+ if (!this.cdp) throw new RelayClosedError();
128
+ if (input.kind === "mouse") await this.cdp.send("Input.dispatchMouseEvent", {
129
+ type: input.type,
130
+ x: input.x,
131
+ y: input.y,
132
+ button: input.button ?? "none",
133
+ clickCount: input.clickCount,
134
+ deltaX: input.deltaX,
135
+ deltaY: input.deltaY,
136
+ pointerType: "mouse"
137
+ });
138
+ else if (input.kind === "key") await this.cdp.send("Input.dispatchKeyEvent", {
139
+ type: input.type,
140
+ key: input.key,
141
+ code: input.code,
142
+ windowsVirtualKeyCode: input.windowsVirtualKeyCode,
143
+ text: input.text
144
+ });
145
+ else await this.cdp.send("Input.insertText", { text: input.text });
146
+ };
147
+ const next = this.chain.then(run, run);
148
+ this.chain = next.catch(() => void 0);
149
+ return next;
150
+ }
151
+ stop() {
152
+ const cdp = this.cdp;
153
+ this.cdp = null;
154
+ this.subscribers.clear();
155
+ this.latest = null;
156
+ if (cdp && this.started) cdp.send("Page.stopScreencast").catch(() => void 0);
157
+ }
158
+ };
159
+ Number(process.env.DSI_MCP_PORT ?? 9515);
160
+ /** The reaper's ONLY test — pure: a session is reaper-eligible when nobody
161
+ * watches its stream AND its last MCP tool traffic is older than idleMs.
162
+ * An active agent (traffic younger than idleMs) is NEVER eligible (D2). */
163
+ function reaperEligible(e, idleMs, now) {
164
+ if (e.streamSubscribers > 0) return false;
165
+ return now - e.lastMcpActivity >= idleMs;
166
+ }
167
+ var BrowserRegistryRefusal = class extends Error {
168
+ code;
169
+ constructor(code, message) {
170
+ super(message);
171
+ this.name = "BrowserRegistryRefusal";
172
+ this.code = code;
173
+ }
174
+ };
175
+ function state() {
176
+ const p = process;
177
+ if (!p.__DSI_BROWSER_STATE__) {
178
+ p.__DSI_BROWSER_STATE__ = {
179
+ entries: /* @__PURE__ */ new Map(),
180
+ closed: /* @__PURE__ */ new Set()
181
+ };
182
+ sweepOrphanProfiles(new Set([...p.__DSI_BROWSER_STATE__.entries.keys()].map((id) => id.slice(0, 8))));
183
+ }
184
+ return p.__DSI_BROWSER_STATE__;
185
+ }
186
+ /** Boot-time sweep of profile dirs left by sessions that died without a
187
+ * close (crash, kill -9, reaper miss). keep = prefixes of LIVE entries —
188
+ * a module hot reload re-runs state() init against a process-global map
189
+ * that may hold live sessions, and this sweep must never delete one. */
190
+ function sweepOrphanProfiles(keep) {
191
+ try {
192
+ const root = readBrowserConfig().profileRoot;
193
+ for (const name of readdirSync(root)) if (!keep.has(name)) rmSync(join(root, name), {
194
+ recursive: true,
195
+ force: true
196
+ });
197
+ } catch {}
198
+ }
199
+ /** Brain re-homing (BUGFIX 2026-10-25): release the stable brain port
200
+ * from any OTHER live session so the NEWEST glass binds it. The DSH
201
+ * preset's brain URL is static (9515) — without this, the agent keeps
202
+ * driving an older glass while the operator watches the new one. The
203
+ * evicted glass mirrors fine; its status strip honestly reports the
204
+ * brain down (ADR-0029 D7: the mirror never depends on the brain). */
205
+ function evictBrainPort(brainPort, exceptId) {
206
+ for (const e of state().entries.values()) {
207
+ if (e.id === exceptId || !e.mcpInProcess || e.mcpInProcess.port !== brainPort) continue;
208
+ const brain = e.mcpInProcess;
209
+ (async () => {
210
+ try {
211
+ await new Promise((res) => {
212
+ brain.httpServer?.closeAllConnections?.();
213
+ brain.httpServer?.close?.(() => res());
214
+ setTimeout(res, 1500);
215
+ });
216
+ await brain.connection?.close?.();
217
+ } catch {}
218
+ })();
219
+ e.mcpInProcess = null;
220
+ e.mcpHealth = "down";
221
+ e.mcpError = "brain re-homed to a newer glass";
222
+ }
223
+ }
224
+ /** The in-process brain (D7): createConnection attached to the SAME headed
225
+ * browser via cdpEndpoint, served over a SIBLING http listener (SSE +
226
+ * messages endpoints) so a streamable-http MCP client (the DSH agent) can
227
+ * reach it without a child process. Port = session port + 100 (9501...):
228
+ * unique per session, out of the debug-port range. */
229
+ async function startInProcessMcp(browserPort, brainPort, onTraffic) {
230
+ const [{ createConnection }, { StreamableHTTPServerTransport }, http] = await Promise.all([
231
+ import('@playwright/mcp'),
232
+ import('@modelcontextprotocol/sdk/server/streamableHttp.js'),
233
+ import('node:http')
234
+ ]);
235
+ const connection = await createConnection({ browser: { cdpEndpoint: "http://127.0.0.1:" + browserPort } });
236
+ const transport = new StreamableHTTPServerTransport({
237
+ sessionIdGenerator: void 0,
238
+ enableJsonResponse: true
239
+ });
240
+ await connection.connect(transport);
241
+ const server = http.createServer(async (req, res) => {
242
+ if (new URL(req.url ?? "/", "http://127.0.0.1").pathname !== "/sse") {
243
+ res.statusCode = 404;
244
+ res.end();
245
+ return;
246
+ }
247
+ onTraffic?.();
248
+ try {
249
+ await transport.handleRequest(req, res);
250
+ } catch {
251
+ if (!res.writableEnded) res.end();
252
+ }
253
+ });
254
+ let port;
255
+ try {
256
+ port = await listen(server, brainPort);
257
+ } catch {
258
+ let lastErr = null;
259
+ port = 0;
260
+ for (let p = brainPort + 1; p <= brainPort + 60; p++) try {
261
+ port = await listen(server, p);
262
+ break;
263
+ } catch (e) {
264
+ lastErr = e;
265
+ }
266
+ if (port === 0) throw lastErr ?? /* @__PURE__ */ new Error("no free brain port in " + brainPort + "+ range");
267
+ }
268
+ return {
269
+ connection,
270
+ httpServer: server,
271
+ port
272
+ };
273
+ }
274
+ var listen = (server, port) => new Promise((res, rej) => {
275
+ const onError = () => {
276
+ server.off("listening", onListen);
277
+ rej(/* @__PURE__ */ new Error("EADDRINUSE " + port));
278
+ };
279
+ const onListen = () => {
280
+ server.off("error", onError);
281
+ const a = server.address();
282
+ res(typeof a === "object" && a ? a.port : port);
283
+ };
284
+ server.once("error", onError);
285
+ server.once("listening", onListen);
286
+ server.listen(port, "127.0.0.1");
287
+ });
288
+ function pidOfPort(port) {
289
+ const p = spawnSync("lsof", [
290
+ "-ti",
291
+ "tcp:" + port,
292
+ "-sTCP:LISTEN"
293
+ ], { stdio: "pipe" });
294
+ return p.status === 0 ? Number(p.stdout.toString().trim().split("\n")[0]) : null;
295
+ }
296
+ function portFree(port) {
297
+ return spawnSync("lsof", [
298
+ "-ti",
299
+ "tcp:" + port,
300
+ "-sTCP:LISTEN"
301
+ ], { stdio: "pipe" }).status !== 0;
302
+ }
303
+ function freePort(preferred) {
304
+ if (preferred && portFree(preferred)) return preferred;
305
+ for (let p = 9400; p < 9500; p++) if (portFree(p)) return p;
306
+ throw new Error("no free debug port in 9400..9499");
307
+ }
308
+ var BrowserRegistry = class {
309
+ list() {
310
+ return [...state().entries.values()].map((e) => ({
311
+ id: e.id,
312
+ port: e.port,
313
+ pid: e.pid,
314
+ profileDir: e.profileDir,
315
+ createdAt: e.createdAt,
316
+ mcp: e.mcpChild ? "child" : e.mcpInProcess ? "in-process" : "none",
317
+ mcpHealth: e.mcpHealth,
318
+ mcpError: e.mcpError
319
+ }));
320
+ }
321
+ peek(id) {
322
+ const e = state().entries.get(id);
323
+ if (!e) throw new BrowserRegistryRefusal("NO_SESSION", "no such browser session");
324
+ return e;
325
+ }
326
+ /** The brain's live SSE URL — the ACTUAL bound port (stable 9515 in the
327
+ * common case, session-relative in the rare fallback). */
328
+ mcpBrainUrl(id) {
329
+ const e = this.peek(id);
330
+ return "http://127.0.0.1:" + (e.mcpInProcess?.port ?? e.brainPort) + "/sse";
331
+ }
332
+ /** True when the id was closed (tombstoned) — routes use it to answer
333
+ * 409 CLOSED instead of a misleading 404. */
334
+ isClosed(id) {
335
+ return state().closed.has(id);
336
+ }
337
+ /** Re-bind lookup (ADR-0030 D1): does this persisted id still own a live
338
+ * session? Pure read — never creates, never throws. */
339
+ findLive(id) {
340
+ if (!id) return false;
341
+ return state().entries.has(id) && !state().closed.has(id);
342
+ }
343
+ /** Reaper inputs — the routes call these so the registry never imports a
344
+ * route (the wiring-point rule stays one-directional). */
345
+ noteStreamOpen(id) {
346
+ const e = state().entries.get(id);
347
+ if (e) e.streamSubscribers += 1;
348
+ }
349
+ noteStreamClose(id) {
350
+ const e = state().entries.get(id);
351
+ if (e) e.streamSubscribers = Math.max(0, e.streamSubscribers - 1);
352
+ }
353
+ noteMcpTraffic(id) {
354
+ const e = state().entries.get(id);
355
+ if (e) e.lastMcpActivity = Date.now();
356
+ }
357
+ /** The idle reaper (ADR-0030 D2): a SAFETY NET, never the owner. A
358
+ * session with zero stream subscribers and no MCP tool traffic for
359
+ * `browser.idleMs` is closed through the SAME idempotent close(id) the
360
+ * panel-close click uses. Headless-default makes this a correctness
361
+ * requirement: an invisible browser must not outlive its last driver. */
362
+ ensureReaper(idleMs) {
363
+ const s = state();
364
+ if (s.reaperTimer) return;
365
+ s.reaperTimer = setInterval(() => {
366
+ const now = Date.now();
367
+ for (const e of state().entries.values()) {
368
+ if (!reaperEligible(e, idleMs, now)) continue;
369
+ this.close(e.id).catch(() => void 0);
370
+ }
371
+ }, Math.min(idleMs / 2, 3e4));
372
+ s.reaperTimer.unref?.();
373
+ }
374
+ /** The ACTIVE tab's relay — what the stream route binds (ADR-0040 D2). */
375
+ relay(id) {
376
+ const e = this.peek(id);
377
+ const h = e.activeTargetId ? e.tabs.get(e.activeTargetId) : null;
378
+ if (!h) throw new BrowserRegistryRefusal("CLOSED", "no active tab handle");
379
+ return h.relay;
380
+ }
381
+ /** Resolve a verb's target handle: explicit targetId, else the active tab
382
+ * (ADR-0040 D3 — one verb seam, optional pointer). An ENUMERATED but
383
+ * not-yet-attached target attaches lazily here — attach is not stream
384
+ * (D1): verbs may reach any tab the strip can list. */
385
+ async handleOf(e, targetId) {
386
+ const key = targetId ?? e.activeTargetId ?? void 0;
387
+ if (!key) throw new BrowserRegistryRefusal("NO_TARGET", "no active tab");
388
+ const have = e.tabs.get(key);
389
+ if (have) return have;
390
+ for (let attempt = 0; attempt < 5 && !e.tabs.has(key); attempt += 1) {
391
+ if (attempt > 0) await new Promise((r) => setTimeout(r, 400));
392
+ for (const page of e.context.pages()) {
393
+ const scdp = await e.context.newCDPSession(page);
394
+ let tid = null;
395
+ try {
396
+ tid = (await scdp.send("Target.getTargetInfo")).targetInfo?.targetId ?? null;
397
+ } catch {}
398
+ if (!tid || e.tabs.has(tid)) {
399
+ await scdp.detach().catch(() => void 0);
400
+ continue;
401
+ }
402
+ const cfg = readBrowserConfig();
403
+ const handle = {
404
+ targetId: tid,
405
+ cdp: scdp,
406
+ relay: new BrowserRelay(scdp, cfg.screencastQuality, cfg.screencastMaxFps),
407
+ page
408
+ };
409
+ this.applyPanelViewport(e, handle).catch(() => void 0);
410
+ await scdp.send("Page.enable").catch(() => void 0);
411
+ await this.applyPanelViewport(e, {
412
+ cdp: scdp,
413
+ relay: handle.relay,
414
+ page
415
+ }).catch(() => void 0);
416
+ e.tabs.set(tid, handle);
417
+ if (tid === key) break;
418
+ }
419
+ }
420
+ const h = e.tabs.get(key);
421
+ if (!h) throw new BrowserRegistryRefusal("NO_TARGET", "no such tab: " + key);
422
+ return h;
423
+ }
424
+ /** Enumerate the context's page targets (ADR-0040 D5 feed) — type:page
425
+ * only; iframes/omnibox/service workers are chrome noise (wire capture
426
+ * 2026-10-24). Read-only: never attaches, never streams (D1). */
427
+ async listTabs(id) {
428
+ const e = this.peek(id);
429
+ const src = (e.activeTargetId ? e.tabs.get(e.activeTargetId) : null)?.cdp ?? [...e.tabs.values()][0]?.cdp;
430
+ if (!src) return [];
431
+ return ((await src.send("Target.getTargets").catch(() => null))?.targetInfos ?? []).filter((t) => t.type === "page").map((t) => ({
432
+ targetId: t.targetId,
433
+ url: t.url,
434
+ title: t.title,
435
+ active: t.targetId === e.activeTargetId
436
+ }));
437
+ }
438
+ /** Attach (lazily), seed the first frame, re-apply the viewport, and move
439
+ * the active pointer (ADR-0040 D1/D2/D4). The OLD active relay pauses —
440
+ * only the active tab streams. Returns the boundary payload. */
441
+ async activate(id, targetId) {
442
+ const e = this.peek(id);
443
+ if (e.closed) throw new BrowserRegistryRefusal("CLOSED", "session is closing");
444
+ const h = await this.handleOf(e, targetId);
445
+ const old = e.activeTargetId ? e.tabs.get(e.activeTargetId) : null;
446
+ if (old && old.targetId !== targetId) await old.relay.pause().catch(() => void 0);
447
+ await h.cdp.send("Page.enable").catch(() => void 0);
448
+ await this.applyPanelViewport(e, h);
449
+ await h.cdp.send("Page.bringToFront").catch(() => void 0);
450
+ await h.relay.ensureStreaming().catch(() => void 0);
451
+ if (h.relay.latestFrame() === null) for (let i = 0; i < 10 && h.relay.latestFrame() === null; i += 1) await new Promise((r) => setTimeout(r, 100));
452
+ if (h.relay.latestFrame() === null) {
453
+ const cfg = readBrowserConfig();
454
+ const shot = await h.cdp.send("Page.captureScreenshot", {
455
+ format: "jpeg",
456
+ quality: cfg.screencastQuality
457
+ }).catch(() => null);
458
+ if (shot?.data) h.relay.seed({
459
+ seq: 0,
460
+ jpeg: shot.data,
461
+ metadata: { seeded: true }
462
+ });
463
+ }
464
+ e.activeTargetId = targetId;
465
+ let url = "";
466
+ let title = "";
467
+ try {
468
+ const pi = await this.pageInfo(id, targetId);
469
+ url = pi.url;
470
+ title = pi.title;
471
+ } catch {}
472
+ return {
473
+ targetId,
474
+ seq: h.relay.latestFrame()?.seq ?? 0,
475
+ jpeg: h.relay.latestFrame()?.jpeg ?? null,
476
+ url,
477
+ title
478
+ };
479
+ }
480
+ async open(options = {}) {
481
+ const cfg = readBrowserConfig();
482
+ const id = randomUUID();
483
+ const port = freePort(options.port);
484
+ const profileDir = join(cfg.profileRoot, id.slice(0, 8));
485
+ mkdirSync(profileDir, { recursive: true });
486
+ const ignoreDefaultArgs = ["--enable-automation", "--mute-audio"];
487
+ const launch = (useChrome) => chromium.launchPersistentContext(profileDir, {
488
+ ...useChrome ? { channel: "chrome" } : {},
489
+ headless: options.headless ?? cfg.headless,
490
+ ignoreDefaultArgs,
491
+ args: [
492
+ "--remote-debugging-port=" + port,
493
+ "--no-first-run",
494
+ "--disable-blink-features=AutomationControlled",
495
+ "--disable-features=IsolateOrigins,site-per-process",
496
+ "--disable-dev-shm-usage",
497
+ "--disable-backgrounding-occluded-windows",
498
+ "--disable-renderer-backgrounding",
499
+ "--disable-background-timer-throttling",
500
+ "--autoplay-policy=no-user-gesture-required",
501
+ "--window-position=-32000,-32000",
502
+ "--window-size=" + (Math.max(200, Math.min(1920, options.width ?? 730)) + 6) + "," + (Math.max(120, Math.min(1080, options.height ?? 830)) + 70)
503
+ ],
504
+ viewport: options.headless ?? cfg.headless ? {
505
+ width: Math.max(200, Math.min(1920, options.width ?? 730)),
506
+ height: Math.max(120, Math.min(1080, options.height ?? 830))
507
+ } : null,
508
+ userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
509
+ timezoneId: "America/New_York",
510
+ locale: "en-US"
511
+ });
512
+ let context;
513
+ try {
514
+ context = await launch(true);
515
+ } catch (chromeErr) {
516
+ context = await launch(false);
517
+ }
518
+ context.browser();
519
+ const pid = pidOfPort(port);
520
+ const cdp = await context.newCDPSession(context.pages()[0] ?? await context.newPage());
521
+ const relay = new BrowserRelay(cdp, cfg.screencastQuality, cfg.screencastMaxFps);
522
+ const tabs = /* @__PURE__ */ new Map();
523
+ let activeTargetId = null;
524
+ try {
525
+ activeTargetId = (await cdp.send("Target.getTargetInfo")).targetInfo?.targetId ?? null;
526
+ } catch {}
527
+ if (activeTargetId) tabs.set(activeTargetId, {
528
+ targetId: activeTargetId,
529
+ cdp,
530
+ relay,
531
+ page: context.pages()[0]
532
+ });
533
+ await relay.start();
534
+ const start = options.url ?? "data:text/html,<title>Glass Browser</title><body style=\"font-family:system-ui;color:gray;display:grid;place-items:center;height:100vh;margin:0\">Glass Browser — waiting for the agent</body>";
535
+ await cdp.send("Page.enable").catch(() => void 0);
536
+ await cdp.send("Page.navigate", { url: start }).catch(() => void 0);
537
+ await new Promise((r) => setTimeout(r, 600));
538
+ if (relay.latestFrame() === null) for (let i = 0; i < 10 && relay.latestFrame() === null; i += 1) await new Promise((r) => setTimeout(r, 100));
539
+ if (relay.latestFrame() === null) {
540
+ const shot = await cdp.send("Page.captureScreenshot", {
541
+ format: "jpeg",
542
+ quality: cfg.screencastQuality
543
+ }).catch(() => null);
544
+ if (shot) relay.seed({
545
+ seq: 0,
546
+ jpeg: shot.data,
547
+ metadata: { seeded: true }
548
+ });
549
+ }
550
+ const brainPort = Number(process.env.DSI_MCP_PORT ?? cfg.brainPort);
551
+ if (cfg.enabled) this.ensureReaper(cfg.idleMs);
552
+ const entry = {
553
+ id,
554
+ context,
555
+ tabs,
556
+ activeTargetId,
557
+ viewport: {
558
+ width: Math.max(200, Math.min(1920, options.width ?? 730)),
559
+ height: Math.max(120, Math.min(1080, options.height ?? 830))
560
+ },
561
+ headless: options.headless ?? cfg.headless,
562
+ port,
563
+ pid,
564
+ profileDir,
565
+ brainPort,
566
+ streamSubscribers: 0,
567
+ lastMcpActivity: Date.now(),
568
+ mcpChild: null,
569
+ mcpInProcess: null,
570
+ mcpHealth: "up",
571
+ mcpError: null,
572
+ closed: false,
573
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
574
+ };
575
+ state().entries.set(id, entry);
576
+ if (options.spawnMcp ?? true) {
577
+ if (process.env.DSI_MCP_IN_PROCESS === "1") {
578
+ evictBrainPort(brainPort, id);
579
+ try {
580
+ entry.mcpInProcess = await startInProcessMcp(entry.port, brainPort, () => {
581
+ entry.lastMcpActivity = Date.now();
582
+ });
583
+ } catch (e) {
584
+ entry.mcpHealth = "down";
585
+ entry.mcpError = e.message;
586
+ }
587
+ } else this.spawnMcpChild(entry, cfg.mcpPin);
588
+ }
589
+ return {
590
+ id,
591
+ port,
592
+ pid,
593
+ profileDir,
594
+ createdAt: entry.createdAt,
595
+ mcp: entry.mcpChild ? "child" : entry.mcpInProcess ? "in-process" : "none",
596
+ mcpHealth: entry.mcpHealth,
597
+ mcpError: entry.mcpError
598
+ };
599
+ }
600
+ /** Wave 3 (3.1) owns the real arg matrix; Wave 1 wires the spawn so close
601
+ * ordering (mcp child → cdp → browser) is pinned from day one. */
602
+ spawnMcpChild(entry, mcpPin) {
603
+ const child = spawn("npx", [
604
+ "@playwright/mcp@" + mcpPin,
605
+ "--cdp-endpoint",
606
+ "http://127.0.0.1:" + entry.port
607
+ ], {
608
+ stdio: "ignore",
609
+ detached: false
610
+ });
611
+ entry.mcpChild = child;
612
+ child.on("error", (e) => {
613
+ entry.mcpHealth = "down";
614
+ entry.mcpError = String(e);
615
+ });
616
+ child.on("exit", (code) => {
617
+ if (entry.mcpChild === child) {
618
+ entry.mcpHealth = "down";
619
+ entry.mcpError = "mcp child exited code " + code;
620
+ }
621
+ });
622
+ }
623
+ /** D8 owner-release. Idempotent: closing a closed id resolves cleanly. */
624
+ async close(id) {
625
+ const entry = state().entries.get(id);
626
+ if (!entry) return {
627
+ ok: true,
628
+ alreadyClosed: true
629
+ };
630
+ if (entry.closed) {
631
+ state().entries.delete(id);
632
+ return {
633
+ ok: true,
634
+ alreadyClosed: true
635
+ };
636
+ }
637
+ entry.closed = true;
638
+ state().closed.add(id);
639
+ if (state().closed.size > 256) state().closed.delete(state().closed.values().next().value);
640
+ const errs = [];
641
+ if (entry.mcpInProcess) try {
642
+ const brain = entry.mcpInProcess;
643
+ await new Promise((res) => {
644
+ brain.httpServer?.closeAllConnections?.();
645
+ brain.httpServer?.close?.(() => res());
646
+ setTimeout(res, 2e3);
647
+ });
648
+ await brain.connection?.close?.();
649
+ entry.mcpInProcess = null;
650
+ } catch (e) {
651
+ errs.push(e);
652
+ }
653
+ if (entry.mcpChild?.pid) try {
654
+ entry.mcpChild.kill("SIGTERM");
655
+ } catch (e) {
656
+ errs.push(e);
657
+ }
658
+ try {
659
+ for (const h of entry.tabs.values()) h.relay.stop();
660
+ entry.tabs.clear();
661
+ } catch (e) {
662
+ errs.push(e);
663
+ }
664
+ try {
665
+ await entry.context.close();
666
+ } catch (e) {
667
+ errs.push(e);
668
+ }
669
+ try {
670
+ rmSync(entry.profileDir, {
671
+ recursive: true,
672
+ force: true
673
+ });
674
+ } catch (e) {
675
+ errs.push(e);
676
+ }
677
+ state().entries.delete(id);
678
+ return {
679
+ ok: errs.length === 0,
680
+ alreadyClosed: false
681
+ };
682
+ }
683
+ /** Wait for the page's next load event, capped — the navigate idiom
684
+ * (a send races the context teardown otherwise) shared by reload and
685
+ * history travel (ADR-0031 D3/D4). */
686
+ async waitLoad(h, capMs = 3e3) {
687
+ await new Promise((res) => {
688
+ const on = (p) => {
689
+ h.cdp.off("Page.loadEventFired", on);
690
+ res();
691
+ };
692
+ h.cdp.on("Page.loadEventFired", on);
693
+ setTimeout(res, capMs);
694
+ });
695
+ }
696
+ /** Navigate the session's active page — public because causing page
697
+ * damage is the only reliable way to make screencast emit a frame.
698
+ * BUGFIX (ADR-0039 follow-up, 2026-10-25): at a paced cadence finer than
699
+ * everyNthFrame=2 (e.g. the operator's live screencastMaxFps: 10 →
700
+ * everyNthFrame 3), a HEADLESS single-damage navigate composites only
701
+ * 1–2 frames and Chrome emits NOTHING — the glass froze on the old face
702
+ * after every header navigate. A bounded no-frame grace re-fires the
703
+ * deterministic captureScreenshot seed (the activate()/resize idiom):
704
+ * pacing still governs LIVE video; a discrete navigation always lands. */
705
+ async navigate(id, url, opts) {
706
+ const e = this.peek(id);
707
+ const h = await this.handleOf(e, opts?.targetId);
708
+ await h.cdp.send("Page.enable").catch(() => void 0);
709
+ const prevSeq = h.relay.latestFrame()?.seq ?? -1;
710
+ if (opts?.waitLoad) {
711
+ const loaded = this.waitLoad(h);
712
+ await h.cdp.send("Page.navigate", { url });
713
+ await loaded;
714
+ } else await h.cdp.send("Page.navigate", { url });
715
+ this.seedAfterNavigate(h, prevSeq).catch(() => void 0);
716
+ }
717
+ /** Match the page viewport to the panel's glass box (ADR-0031
718
+ * amendment 2): the screencast frame then equals the panel size, so
719
+ * the mirror shows content at native scale instead of zooming a
720
+ * fixed default viewport. CDP-side clamps keep hostile sizes out. */
721
+ async setViewport(id, width, height, targetId) {
722
+ console.log("[viewport] set", id.slice(0, 8), width + "x" + height, "target", targetId ?? "active");
723
+ const w = Math.max(200, Math.min(8192, Math.round(width)));
724
+ const h = Math.max(120, Math.min(8192, Math.round(height)));
725
+ const e = this.peek(id);
726
+ e.viewport = {
727
+ width: w,
728
+ height: h
729
+ };
730
+ const handle = await this.handleOf(e, targetId);
731
+ await handle.cdp.send("Page.enable").catch(() => void 0);
732
+ const prevSeq = handle.relay.latestFrame()?.seq ?? -1;
733
+ await this.applyPanelViewport(e, handle);
734
+ await this.seedAfterResize(handle, prevSeq).catch(() => void 0);
735
+ }
736
+ /** ADR-0041 D3: a resize must REPAINT, not wait for the next page
737
+ * change — the screencast speaks only on change, so a static page
738
+ * keeps the old-aspect frame letterboxed on the panel forever (the
739
+ * operator's black bands, 2026-10-25). Bounded grace for a FRESH
740
+ * frame (seq past prevSeq); silence answers with the deterministic
741
+ * captureScreenshot seed — activate()'s first-frame idiom re-fired
742
+ * for the resize path. Never hangs the verb past the grace. */
743
+ async seedAfterResize(handle, prevSeq) {
744
+ for (let i = 0; i < 10 && (handle.relay.latestFrame()?.seq ?? -1) === prevSeq; i += 1) await new Promise((r) => setTimeout(r, 100));
745
+ if ((handle.relay.latestFrame()?.seq ?? -1) !== prevSeq) return;
746
+ const cfg = readBrowserConfig();
747
+ const shot = await handle.cdp.send("Page.captureScreenshot", {
748
+ format: "jpeg",
749
+ quality: cfg.screencastQuality
750
+ }).catch(() => null);
751
+ if (shot?.data) handle.relay.seed({
752
+ seq: 0,
753
+ jpeg: shot.data,
754
+ metadata: {
755
+ seeded: true,
756
+ resize: true
757
+ }
758
+ });
759
+ }
760
+ /** The navigate path's seed arm (ADR-0039 follow-up): same bounded grace,
761
+ * but the synthesized replacement must land OVER the opener's synthesized
762
+ * frame — seed() refuses that, reseed() is the honest verb (the mirror
763
+ * froze on the old face after every paced navigate otherwise). */
764
+ async seedAfterNavigate(handle, prevSeq) {
765
+ for (let i = 0; i < 10 && (handle.relay.latestFrame()?.seq ?? -1) === prevSeq; i += 1) await new Promise((r) => setTimeout(r, 100));
766
+ if ((handle.relay.latestFrame()?.seq ?? -1) !== prevSeq) return;
767
+ const cfg = readBrowserConfig();
768
+ const shot = await handle.cdp.send("Page.captureScreenshot", {
769
+ format: "jpeg",
770
+ quality: cfg.screencastQuality
771
+ }).catch(() => null);
772
+ if (shot?.data) handle.relay.reseed({
773
+ seq: 0,
774
+ jpeg: shot.data,
775
+ metadata: {
776
+ seeded: true,
777
+ navigate: true
778
+ }
779
+ });
780
+ }
781
+ /** The history position (ADR-0031): every proxy-verb answer carries it
782
+ * so the header's back/forward buttons stay honest at the ends. */
783
+ async navInfo(id, targetId) {
784
+ const e = this.peek(id);
785
+ const h = await this.handleOf(e, targetId);
786
+ await h.cdp.send("Page.enable").catch(() => void 0);
787
+ const hist = await h.cdp.send("Page.getNavigationHistory", {});
788
+ return {
789
+ currentIndex: hist.currentIndex,
790
+ entryCount: hist.entries.length
791
+ };
792
+ }
793
+ /** Reload the session's active page (ADR-0031 D4): Page.reload keeps
794
+ * the history entry, unlike re-navigating the current URL. */
795
+ async reload(id, targetId) {
796
+ const e = this.peek(id);
797
+ const h = await this.handleOf(e, targetId);
798
+ await h.cdp.send("Page.enable").catch(() => void 0);
799
+ const loaded = this.waitLoad(h);
800
+ await h.cdp.send("Page.reload", {});
801
+ await loaded;
802
+ return this.pageInfo(id, targetId);
803
+ }
804
+ /** Travel the browser's OWN back/forward list (ADR-0031 D3):
805
+ * Page.getNavigationHistory picks the entry, Page.navigateToHistoryEntry
806
+ * is the browser's travel verb — never Runtime.evaluate('history.back()'),
807
+ * which races the teardown and lies about the browser-level list.
808
+ * Bounds refusals keep the header buttons honest at the list ends. */
809
+ async pageHistory(id, dir, targetId) {
810
+ const e = this.peek(id);
811
+ const h = await this.handleOf(e, targetId);
812
+ await h.cdp.send("Page.enable").catch(() => void 0);
813
+ const hist = await h.cdp.send("Page.getNavigationHistory", {});
814
+ const target = dir === "back" ? hist.currentIndex - 1 : hist.currentIndex + 1;
815
+ if (target < 0 || target >= hist.entries.length) throw new BrowserRegistryRefusal("CLOSED", "HISTORY_END — cannot go " + dir + " from index " + hist.currentIndex + " of " + hist.entries.length);
816
+ const loaded = this.waitLoad(h);
817
+ await h.cdp.send("Page.navigateToHistoryEntry", { entryId: hist.entries[target].id });
818
+ await loaded;
819
+ return {
820
+ ...await this.pageInfo(id, targetId),
821
+ currentIndex: target,
822
+ entryCount: hist.entries.length
823
+ };
824
+ }
825
+ /** Race an evaluate against a cap — a hung evaluate must never wedge a
826
+ * route (the navigate-hang finding, spec W2 2.3 debugging). */
827
+ async evaluateCapped(cdp, expr, capMs = 3e3) {
828
+ let timer;
829
+ const cap = new Promise((res) => {
830
+ timer = setTimeout(() => res(null), capMs);
831
+ });
832
+ try {
833
+ const res = await Promise.race([cdp.send("Runtime.evaluate", {
834
+ expression: expr,
835
+ returnByValue: true
836
+ }), cap]);
837
+ return res ? String(res.result?.value ?? "") : null;
838
+ } finally {
839
+ if (timer) clearTimeout(timer);
840
+ }
841
+ }
842
+ /** The active page's url + title + inner viewport — the status strip's
843
+ * location read, the e2e's DOM-truth channel (the glass proves clicks
844
+ * by effects the page itself reports), and the viewport-follow witness
845
+ * (innerWidth/innerHeight prove the emulation landed, amend 2). */
846
+ async pageInfo(id, targetId) {
847
+ const e = this.peek(id);
848
+ const h = await this.handleOf(e, targetId);
849
+ const raw = await this.evaluateCapped(h.cdp, "JSON.stringify({ url: location.href, title: document.title, dpr: window.devicePixelRatio, innerWidth: window.innerWidth, innerHeight: window.innerHeight, playerError: (document.querySelector(\".ytp-error-content-wrap-reason, .ytp-error-content, [role=alert]\")?.textContent || \"\").trim() || null, mediaErrorCode: (document.querySelector(\"video\")?.error?.code) ?? null })", 3e3);
850
+ if (raw === null) return {
851
+ url: "(busy)",
852
+ title: "(busy)",
853
+ dpr: 1,
854
+ innerWidth: 0,
855
+ innerHeight: 0,
856
+ playerError: null,
857
+ mediaErrorCode: null
858
+ };
859
+ return JSON.parse(raw);
860
+ }
861
+ /** The page element under viewport coords — the glass context menu's
862
+ * data pass (BUGFIX 2026-10-25: Chrome's NATIVE right-click menu is
863
+ * browser UI and never renders in the screencast, so DSI supplies its
864
+ * own menu; this tells it what the right-click landed on). */
865
+ async elementAt(id, x, y, targetId) {
866
+ const e = this.peek(id);
867
+ const h = await this.handleOf(e, targetId);
868
+ const expr = "JSON.stringify((() => { const el = document.elementFromPoint(" + Number(x) + "," + Number(y) + "); if (!el) return { href: null, src: null, text: null }; const a = el.closest(\"a\"); const img = el.closest(\"img\"); return { href: a ? a.href : null, src: img ? (img.currentSrc || img.src) : null, text: (a && a.textContent || \"\").trim().slice(0, 80) || null }; })())";
869
+ const raw = await this.evaluateCapped(h.cdp, expr, 2e3);
870
+ if (raw === null) return {
871
+ href: null,
872
+ src: null,
873
+ text: null
874
+ };
875
+ try {
876
+ return JSON.parse(raw);
877
+ } catch {
878
+ return {
879
+ href: null,
880
+ src: null,
881
+ text: null
882
+ };
883
+ }
884
+ }
885
+ /** Open a NEW tab in the glass and make it the active face. With a
886
+ * `url`: the context-menu open-link/image path — create BLANK then
887
+ * navigate through the verb seam (createTarget with a URL races its own
888
+ * navigation and lands on chrome-error, observed 2026-10-25). WITHOUT a
889
+ * url (ADR-0042 D2, the + chip): createTarget ONLY — the birth state
890
+ * again, no navigation. Either way activate() applies the first-frame
891
+ * contract (ADR-0040 D4 / ADR-0042 D4: grace, then a captureScreenshot
892
+ * seed) and returns the boundary payload the caller paints. */
893
+ async openTab(id, url, opts) {
894
+ const e = this.peek(id);
895
+ if (e.closed) throw new BrowserRegistryRefusal("CLOSED", "session is closing");
896
+ const src = (e.activeTargetId ? e.tabs.get(e.activeTargetId) : null)?.cdp ?? [...e.tabs.values()][0]?.cdp;
897
+ if (!src) throw new BrowserRegistryRefusal("CLOSED", "no tab handle to create from");
898
+ const targetId = (await src.send("Target.createTarget", { url: "about:blank" }))?.targetId;
899
+ if (!targetId) throw new BrowserRegistryRefusal("NO_TARGET", "Target.createTarget returned no id");
900
+ if (url) await this.navigate(id, url, {
901
+ waitLoad: true,
902
+ targetId
903
+ });
904
+ if (opts?.activate ?? true) return {
905
+ ...await this.activate(id, targetId),
906
+ targetId
907
+ };
908
+ const h = e.tabs.get(targetId);
909
+ return {
910
+ targetId,
911
+ seq: h?.relay.latestFrame()?.seq ?? 0,
912
+ jpeg: h?.relay.latestFrame()?.jpeg ?? null,
913
+ url: url ?? "about:blank",
914
+ title: ""
915
+ };
916
+ }
917
+ /** Apply the remembered panel viewport by RESIZING THE WINDOW (round 10:
918
+ * emulation-on-headed renders the page centered in the big off-screen
919
+ * window — the small/cut-off mirror). Also clears any stale override. */
920
+ /** Resize the glass's WINDOW to the panel size and clear emulation.
921
+ * ROUND 10 (the REAL small/cut-off root cause, headed-proven):
922
+ * Emulation.setDeviceMetricsOverride on HEADED chrome renders the
923
+ * emulated viewport CENTERED INSIDE the physical off-screen window
924
+ * (black around it) and the screencast captures the whole window —
925
+ * the mirror shows a tiny page. Resizing the WINDOW itself makes the
926
+ * page fill it natively; the panel is the window's face (ADR-0029 D1).
927
+ * Re-asserted on every attach/switch/setViewport (round 11: something
928
+ * re-expands the window — the mirror shrinks again — so this is now
929
+ * idempotent and called from all three paths). */
930
+ async applyPanelViewport(e, handle) {
931
+ await handle.cdp.send("Page.enable").catch(() => void 0);
932
+ if (e.headless && handle.page) {
933
+ await Promise.race([handle.page.setViewportSize({
934
+ width: e.viewport.width,
935
+ height: e.viewport.height
936
+ }).catch(() => void 0), new Promise((res) => setTimeout(res, 2500))]);
937
+ return;
938
+ }
939
+ const race = async (p, fb) => await Promise.race([p.catch(() => fb), new Promise((res) => setTimeout(() => res(fb), 2500))]);
940
+ const anyCdp = handle.cdp;
941
+ const dims = await race(anyCdp.send("Runtime.evaluate", {
942
+ expression: "JSON.stringify({ oh: window.outerHeight - window.innerHeight, ow: window.outerWidth - window.innerWidth })",
943
+ returnByValue: true
944
+ }), null);
945
+ let chromeH = 0, chromeW = 0;
946
+ try {
947
+ const d = JSON.parse(dims && dims.result && dims.result.value || "{}");
948
+ chromeH = Number(d.oh) || 0;
949
+ chromeW = Number(d.ow) || 0;
950
+ } catch {}
951
+ const anyCdp2 = handle.cdp;
952
+ const win = await race(anyCdp2.send("Target.getWindowForTarget"), null);
953
+ if (win && win.windowId) {
954
+ await race(anyCdp2.send("Browser.setWindowBounds", {
955
+ windowId: win.windowId,
956
+ bounds: {
957
+ width: Math.max(200, e.viewport.width + chromeW),
958
+ height: Math.max(120, e.viewport.height + chromeH),
959
+ windowState: "normal"
960
+ }
961
+ }), null);
962
+ const check = await race(anyCdp2.send("Browser.getWindowForTarget", { targetId: e.activeTargetId }), null);
963
+ console.log("[window] resized ->", JSON.stringify(check && check.bounds ? check.bounds : check), "want", e.viewport.width + chromeW, "x", e.viewport.height + chromeH);
964
+ } else console.log("[window] no windowId — resize skipped (raced out)");
965
+ await race(anyCdp2.send("Emulation.clearDeviceMetricsOverride"), null);
966
+ }
967
+ /** Close ONE tab (the strip X): Target.closeTarget, stop its relay,
968
+ * drop the handle — and if it was the active face, the pointer moves
969
+ * to the first surviving tab (re-activated, first-frame contract
970
+ * re-fired). Closing the LAST tab is refused: the kill ladder owns
971
+ * session death (the trash is the operator-facing kill). */
972
+ async closeTab(id, targetId) {
973
+ const e = this.peek(id);
974
+ if (e.closed) throw new BrowserRegistryRefusal("CLOSED", "session is closing");
975
+ if (e.tabs.size <= 1) throw new BrowserRegistryRefusal("NO_TARGET", "cannot close the last tab — use the trash to close the glass");
976
+ const victim = e.tabs.get(targetId);
977
+ if (!victim) throw new BrowserRegistryRefusal("NO_TARGET", "no such tab: " + targetId);
978
+ victim.relay.stop();
979
+ e.tabs.delete(targetId);
980
+ await victim.cdp.send("Target.closeTarget", { targetId }).catch(() => void 0);
981
+ await victim.cdp.detach().catch(() => void 0);
982
+ if (e.activeTargetId === targetId) {
983
+ const next = [...e.tabs.keys()][0] ?? null;
984
+ e.activeTargetId = next;
985
+ if (next) await this.activate(id, next).catch(() => void 0);
986
+ else e.activeTargetId = null;
987
+ }
988
+ return {
989
+ ok: true,
990
+ activeTargetId: e.activeTargetId
991
+ };
992
+ }
993
+ async dispatch(id, input, targetId) {
994
+ if (!state().entries.has(id) && state().closed.has(id)) throw new BrowserRegistryRefusal("CLOSED", "session was closed");
995
+ const e = this.peek(id);
996
+ if (e.closed) throw new BrowserRegistryRefusal("CLOSED", "session is closing");
997
+ await (await this.handleOf(e, targetId)).relay.dispatch(input);
998
+ }
999
+ };
1000
+ var browserRegistry = new BrowserRegistry();
1001
+
1002
+ export { BrowserRegistryRefusal as B, browserRegistry as b };
1003
+ //# sourceMappingURL=registry.js-CZNJZ0C-.js.map