amalgm 0.1.194 → 0.1.195

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.194",
3
+ "version": "0.1.195",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -4,7 +4,7 @@
4
4
  * Attached mode — drive the browser surface the user is watching.
5
5
  *
6
6
  * The desktop app advertises its chromium over CDP; the UI mounts the in-tab
7
- * split-view <webview> with a marker URL. This module owns the attach
7
+ * split-view native page with a marker URL. This module owns the attach
8
8
  * handshake and the command transport, built around one rule:
9
9
  *
10
10
  * A session's identity is established ONCE at attach and VERIFIED on every
@@ -17,17 +17,17 @@
17
17
  * ref". Both verified live. So instead of a per-command `tab` pin, attach:
18
18
  *
19
19
  * 1. Asks the UI to open the surface (a browser_surfaces state event).
20
- * 2. Selects the session's webview in the daemon ONCE (refs don't exist yet).
21
- * 3. Opens a persistent raw-CDP connection to the guest target and stamps it:
20
+ * 2. Selects the session's native page in the daemon ONCE (refs don't exist yet).
21
+ * 3. Opens a persistent raw-CDP connection to that target and stamps it:
22
22
  * `window.__amalgmBsid = session` now, plus the same script on every new
23
23
  * document — the stamp survives navigation and dies with the target.
24
24
  * 4. Prepends a guard eval to every batch: stamp mismatch throws, the batch
25
25
  * bails before the action runs, and the session re-attaches once. eval
26
26
  * does not reset refs, so @refs stay live across calls.
27
27
  *
28
- * The persistent guest connection doubles as the capture anchor: screenshots
28
+ * The persistent page connection doubles as the capture anchor: screenshots
29
29
  * and recordings connect straight to the verified target instead of hunting
30
- * the target list per capture (the hunt's "no webviews yet → first page
30
+ * the target list per capture (the hunt's "no surfaces yet → first page
31
31
  * target" fallback is how cua_screenshot once captured the app's own chat UI).
32
32
  */
33
33
 
@@ -37,16 +37,16 @@ const cdp = require('./cdp');
37
37
  const { surfaces, knownSessions, backendFor } = require('./sessions');
38
38
 
39
39
  const SURFACE_EVENT_RESOURCE = 'browser_surfaces';
40
- const WEBVIEW_MARKER_PREFIX = 'amalgm-bsid-';
41
- const WEBVIEW_WAIT_MS = 15_000;
42
- const WEBVIEW_POLL_MS = 500;
40
+ const SURFACE_MARKER_PREFIX = 'amalgm-bsid-';
41
+ const SURFACE_WAIT_MS = 15_000;
42
+ const SURFACE_POLL_MS = 500;
43
43
  const GUARD_ERROR = 'amalgm-surface-guard';
44
44
  const SURFACE_HIDDEN_MESSAGE = 'The browser surface is hidden in the desktop app and did not reopen. '
45
45
  + 'Retry; if it keeps failing, close this browser session and open again '
46
46
  + '(a fresh surface will mount), or force a headless browser with AMALGM_BROWSER_BACKEND=cli.';
47
47
 
48
48
  function marker(session) {
49
- return `${WEBVIEW_MARKER_PREFIX}${session}`;
49
+ return `${SURFACE_MARKER_PREFIX}${session}`;
50
50
  }
51
51
 
52
52
  function stampScript(session) {
@@ -87,7 +87,7 @@ function requestSurfaceOpen(session, context) {
87
87
  }
88
88
 
89
89
  /**
90
- * The session's webview rectangle in the app window, or null when the
90
+ * The session's surface rectangle in the app window, or null when the
91
91
  * splitview is hidden (0x0) or gone. Raw CDP, deadline-bounded.
92
92
  */
93
93
  async function surfaceRect(session, hintUrl) {
@@ -106,13 +106,13 @@ async function surfaceRect(session, hintUrl) {
106
106
  * slot; either way it must have real layout geometry. When it does not, ask
107
107
  * the UI to mount it and poll until geometry appears.
108
108
  */
109
- async function ensureSurfaceVisible(session, hintUrl, context, waitMs = WEBVIEW_WAIT_MS) {
109
+ async function ensureSurfaceVisible(session, hintUrl, context, waitMs = SURFACE_WAIT_MS) {
110
110
  let found = await surfaceRect(session, hintUrl);
111
111
  if (found) return found;
112
112
  requestSurfaceOpen(session, context);
113
113
  const deadlineAt = Date.now() + waitMs;
114
114
  while (Date.now() < deadlineAt) {
115
- await new Promise((resolve) => setTimeout(resolve, WEBVIEW_POLL_MS));
115
+ await new Promise((resolve) => setTimeout(resolve, SURFACE_POLL_MS));
116
116
  found = await surfaceRect(session, hintUrl);
117
117
  if (found) return found;
118
118
  }
@@ -120,12 +120,12 @@ async function ensureSurfaceVisible(session, hintUrl, context, waitMs = WEBVIEW_
120
120
  }
121
121
 
122
122
  /**
123
- * This session's candidate webviews out of a target/tab list. Marker in the
123
+ * This session's candidate surfaces out of a target/tab list. Marker in the
124
124
  * url or title identifies fresh surfaces exactly; the live URL the embedder
125
125
  * DOM reports for the session's marked element covers surfaces that
126
126
  * navigated away from the marker (e.g. after an MCP restart) — but two
127
127
  * sessions can browse the same URL, so URL matches are candidates to be
128
- * confirmed by stamp, never picked blindly. And never "the only webview":
128
+ * confirmed by stamp, never picked blindly. And never "the only surface":
129
129
  * a displaced session adopting whatever surface remains is how one chat's
130
130
  * browser ends up reading another chat's page.
131
131
  */
@@ -155,10 +155,21 @@ async function readStamp(wsUrl) {
155
155
  }
156
156
  }
157
157
 
158
+ function candidateHasMarker(candidate, session) {
159
+ const markerText = marker(session);
160
+ return String(candidate?.url || '').includes(markerText)
161
+ || String(candidate?.title || '').includes(markerText);
162
+ }
163
+
158
164
  /** Resolve same-URL ambiguity: the candidate whose page carries this
159
- * session's stamp wins. A unique candidate stands on its own. */
160
- async function confirmGuestCandidates(candidates, session) {
161
- if (candidates.length <= 1) return candidates[0] || null;
165
+ * session's stamp wins. A fresh marker target is already unambiguous. */
166
+ async function confirmSurfaceCandidates(candidates, session) {
167
+ if (
168
+ candidates.length === 1
169
+ && (candidateHasMarker(candidates[0], session) || candidates[0].type === 'webview')
170
+ ) {
171
+ return candidates[0];
172
+ }
162
173
  for (const candidate of candidates) {
163
174
  if (await readStamp(candidate.webSocketDebuggerUrl) === session) return candidate;
164
175
  }
@@ -166,13 +177,13 @@ async function confirmGuestCandidates(candidates, session) {
166
177
  }
167
178
 
168
179
  /**
169
- * Stamp the guest and keep the connection open. The on-new-document script
180
+ * Stamp the surface and keep the connection open. The on-new-document script
170
181
  * lives exactly as long as this CDP session — which is exactly as long as
171
- * the stamp should live: the connection drops when the target dies (webview
182
+ * the stamp should live: the connection drops when the target dies (surface
172
183
  * remount, app restart), the surface record is dropped with it, and the next
173
184
  * command re-attaches against the new reality.
174
185
  */
175
- async function stampGuest(session, wsUrl) {
186
+ async function stampSurface(session, wsUrl) {
176
187
  const client = await cdp.connectCdp(wsUrl);
177
188
  try {
178
189
  await client.call('Page.enable').catch(() => {});
@@ -186,10 +197,8 @@ async function stampGuest(session, wsUrl) {
186
197
  }
187
198
 
188
199
  /**
189
- * Chromium drops CDP Input events to an Electron <webview> guest until the
190
- * guest widget has focus (verified live: clicks report success but never
191
- * land). One focus pass over raw CDP — the embedder focuses the element, the
192
- * guest focuses its window. Best-effort: reads work unfocused regardless.
200
+ * Give the marked host and native page one best-effort focus pass before
201
+ * input. The main process also focuses a visible WebContentsView directly.
193
202
  */
194
203
  async function focusSurface(session, surface) {
195
204
  try {
@@ -198,9 +207,9 @@ async function focusSurface(session, surface) {
198
207
  try {
199
208
  await embedder.call('Runtime.evaluate', {
200
209
  expression: `(() => {
201
- const view = [...document.querySelectorAll('webview')]
210
+ const view = [...document.querySelectorAll('[data-amalgm-surface]')]
202
211
  .find((w) => (w.getAttribute('data-amalgm-surface') || '').includes(${JSON.stringify(marker(session))}));
203
- if (view) view.focus();
212
+ if (view) view.focus({ preventScroll: true });
204
213
  return Boolean(view);
205
214
  })()`,
206
215
  returnByValue: true,
@@ -224,7 +233,7 @@ function commandNeedsOwnedFocus(command) {
224
233
 
225
234
  /**
226
235
  * Focus is owned by the browser session, not by the user's currently selected
227
- * Amalgm tab. Before commands that need native guest focus, reacquire focus on
236
+ * Amalgm tab. Before commands that need native page focus, reacquire focus on
228
237
  * this session's stamped surface. Reads and evals do not disturb the user.
229
238
  */
230
239
  async function acquireOwnedFocus(session, context, surface, hintUrl = '') {
@@ -238,8 +247,8 @@ async function acquireOwnedFocus(session, context, surface, hintUrl = '') {
238
247
  }
239
248
 
240
249
  /**
241
- * Connect the session to the advertised chromium on first use: select the
242
- * in-tab webview in the daemon, stamp the guest over a persistent raw-CDP
250
+ * Connect the session to the advertised Chromium on first use: select the
251
+ * in-tab native page in the daemon, stamp it over a persistent raw-CDP
243
252
  * connection, focus it. The returned record is the session's one source of
244
253
  * identity — commands verify against the stamp, captures connect to the
245
254
  * target it names.
@@ -249,11 +258,14 @@ async function ensureReady(session, context) {
249
258
  const live = surfaces.get(session);
250
259
  if (live) return live;
251
260
 
252
- // Explicit external CDP endpoints (plain chromium) have page targets, not
253
- // webviews — drive whatever the daemon selected; no surface, no stamp.
261
+ // Explicit external CDP endpoints (plain Chromium) have no marked Amalgm
262
+ // host element. WebContentsView targets are also type:"page", so target
263
+ // type alone can no longer distinguish an external browser from Electron.
254
264
  if (process.env.AMALGM_BROWSER_CDP_URL) {
255
265
  const targets = await cdp.listTargets(backend.cdpEndpoint()).catch(() => []);
256
- if (!targets.some((t) => t.type === 'webview')) {
266
+ const markedTarget = targets.some((target) => candidateHasMarker(target, session));
267
+ const markedHost = await surfaceRect(session, '');
268
+ if (!targets.some((target) => target.type === 'webview') && !markedTarget && !markedHost) {
257
269
  const external = { external: true, client: null, tabId: null, wsUrl: null };
258
270
  surfaces.set(session, external);
259
271
  return external;
@@ -264,24 +276,30 @@ async function ensureReady(session, context) {
264
276
  // navigated away from the marker URL (data-amalgm-surface survives where
265
277
  // target URLs do not).
266
278
  const domSrc = async () => (await surfaceRect(session, ''))?.rect?.src || null;
267
- const webviewTabs = async () => {
279
+ const surfaceTabs = async () => {
268
280
  const tabs = await cli.runCli(['tab'], cliOptions(session, { timeoutMs: 15_000 }));
269
- return (tabs?.data?.tabs || tabs?.tabs || []).filter((tab) => tab.type === 'webview');
281
+ const targetTypes = new Set(backend.surfaceTargetTypes());
282
+ return (tabs?.data?.tabs || tabs?.tabs || []).filter((tab) =>
283
+ targetTypes.has(tab.type)
284
+ );
270
285
  };
271
286
 
272
- let candidates = identityCandidates(await webviewTabs(), session, await domSrc());
287
+ let candidates = identityCandidates(await surfaceTabs(), session, await domSrc());
273
288
  if (!candidates.length) {
274
289
  requestSurfaceOpen(session, context);
275
- const deadlineAt = Date.now() + WEBVIEW_WAIT_MS;
290
+ const deadlineAt = Date.now() + SURFACE_WAIT_MS;
276
291
  while (!candidates.length && Date.now() < deadlineAt) {
277
- await new Promise((resolve) => setTimeout(resolve, WEBVIEW_POLL_MS));
278
- candidates = identityCandidates(await webviewTabs(), session, await domSrc());
292
+ await new Promise((resolve) => setTimeout(resolve, SURFACE_POLL_MS));
293
+ candidates = identityCandidates(await surfaceTabs(), session, await domSrc());
279
294
  }
280
295
  }
281
296
 
282
297
  // Same-URL candidates are told apart by the stamp their pages carry from a
283
298
  // previous attach: pin each in turn and read it back through the daemon.
284
- let selected = candidates.length === 1 ? candidates[0] : null;
299
+ let selected = candidates.length === 1
300
+ && (candidateHasMarker(candidates[0], session) || candidates[0].type === 'webview')
301
+ ? candidates[0]
302
+ : null;
285
303
  for (const candidate of candidates) {
286
304
  if (selected) break;
287
305
  await cli.runCli(['tab', candidate.tabId], cliOptions(session, { timeoutMs: 15_000 }));
@@ -298,43 +316,48 @@ async function ensureReady(session, context) {
298
316
  );
299
317
  }
300
318
 
301
- // The webview target existing is not enough — the session surface must be
319
+ // The native page target existing is not enough — the session surface must be
302
320
  // compositor-backed, or input drops and captures have nothing to render.
303
321
  // The app window itself may be hidden or minimized; the app keeps attached
304
322
  // surfaces compositing while the browser tool owns them.
305
323
  const visible = await ensureSurfaceVisible(session, selected.url, context);
306
324
  if (!visible) throw new Error(SURFACE_HIDDEN_MESSAGE);
307
325
 
308
- // Select the session's webview in the daemon — once. Refs don't exist yet,
326
+ // Select the session's page in the daemon — once. Refs don't exist yet,
309
327
  // so this is the one place a `tab` command is allowed.
310
328
  await cli.runCli(['tab', selected.tabId], cliOptions(session, { timeoutMs: 15_000 }));
311
329
 
312
- // The guest's raw-CDP target, by the same identity as the daemon tab —
313
- // stamp-confirmed when several webviews share the URL.
314
- const guest = await confirmGuestCandidates(identityCandidates(
330
+ // The surface's raw-CDP target, by the same identity as the daemon tab —
331
+ // stamp-confirmed when several pages share the URL.
332
+ const advertisedTargetTypes = new Set(backend.surfaceTargetTypes());
333
+ const target = await confirmSurfaceCandidates(identityCandidates(
334
+ // Rev 5 exposes guest targets as `webview`; rev 6 exposes
335
+ // WebContentsView pages as `page`. The bridge advertisement selects one
336
+ // contract, so app renderer pages never enter the candidate set.
315
337
  (await cdp.listTargets(backend.cdpEndpoint()))
316
- .filter((t) => t.type === 'webview' && t.webSocketDebuggerUrl),
338
+ .filter((t) => advertisedTargetTypes.has(t.type) && t.webSocketDebuggerUrl),
317
339
  session,
318
340
  visible.rect?.src || null,
319
341
  ), session);
320
- if (!guest) throw new Error(SURFACE_HIDDEN_MESSAGE);
342
+ if (!target) throw new Error(SURFACE_HIDDEN_MESSAGE);
321
343
 
322
- const client = await stampGuest(session, guest.webSocketDebuggerUrl);
344
+ const client = await stampSurface(session, target.webSocketDebuggerUrl);
323
345
  const surface = {
324
346
  tabId: selected.tabId,
325
- targetId: guest.id,
326
- wsUrl: guest.webSocketDebuggerUrl,
347
+ targetId: target.id,
348
+ targetType: target.type,
349
+ wsUrl: target.webSocketDebuggerUrl,
327
350
  embedderWsUrl: visible.pageWsUrl || null,
328
351
  client,
329
352
  external: false,
330
353
  };
331
- // The connection dropping IS the detach signal: webview remount, app
354
+ // The connection dropping IS the detach signal: surface remount, app
332
355
  // restart, surface close — all of them invalidate this identity at once.
333
356
  client.onClose(() => {
334
357
  if (surfaces.get(session) === surface) surfaces.delete(session);
335
358
  });
336
359
  surfaces.set(session, surface);
337
- knownSessions.set(session, { ...knownSessions.get(session), webviewTabId: selected.tabId });
360
+ knownSessions.set(session, { ...knownSessions.get(session), surfaceTabId: selected.tabId });
338
361
 
339
362
  await focusSurface(session, surface);
340
363
  return surface;
@@ -419,7 +442,7 @@ async function execute(session, commands, options = {}, single) {
419
442
  return await attempt();
420
443
  } catch (err) {
421
444
  if (guarded && looksLikeLostConnection(err)) {
422
- // The app restarted, the webview remounted, or the daemon drifted off
445
+ // The app restarted, the surface remounted, or the daemon drifted off
423
446
  // this session's page; re-attach once against current reality.
424
447
  detach(session);
425
448
  const nextSurface = await ensureReady(session, context);
@@ -450,12 +473,12 @@ async function readText(session, commandArgs, timeoutMs) {
450
473
  }
451
474
 
452
475
  /**
453
- * The CDP websocket url for raster capture — the surface's own guest target,
476
+ * The CDP websocket url for raster capture — the surface's own page target,
454
477
  * established at attach. Requires the surface on screen (the one physical
455
- * precondition for guest pixels); ensureSurfaceVisible self-heals a
478
+ * precondition for attached pixels); ensureSurfaceVisible self-heals a
456
479
  * backgrounded tab first. Null for external plain-chromium endpoints.
457
480
  */
458
- async function guestWsUrl(session, context) {
481
+ async function surfaceTarget(session, context) {
459
482
  let surface = await ensureReady(session, context);
460
483
  if (surface?.external) return null;
461
484
  const visible = await ensureSurfaceVisible(session, '', context);
@@ -463,12 +486,19 @@ async function guestWsUrl(session, context) {
463
486
  // The target may have died between ensureReady and now — the close handler
464
487
  // clears the record, so a second look re-attaches against the new target.
465
488
  surface = surfaces.get(session) || await ensureReady(session, context);
466
- return surface.wsUrl;
489
+ return {
490
+ wsUrl: surface.wsUrl,
491
+ type: surface.targetType || 'webview',
492
+ };
493
+ }
494
+
495
+ async function surfaceWsUrl(session, context) {
496
+ return (await surfaceTarget(session, context))?.wsUrl || null;
467
497
  }
468
498
 
469
499
  /**
470
500
  * Tell the UI this session's surface is done — the renderer unmounts the
471
- * splitview/tab, so ended sessions stop accumulating webviews (each one is
501
+ * splitview/tab, so ended sessions stop accumulating page renderers (each is
472
502
  * a full renderer process).
473
503
  */
474
504
  function requestSurfaceClose(session) {
@@ -490,13 +520,14 @@ module.exports = {
490
520
  GUARD_ERROR,
491
521
  SURFACE_EVENT_RESOURCE,
492
522
  SURFACE_HIDDEN_MESSAGE,
493
- WEBVIEW_MARKER_PREFIX,
523
+ SURFACE_MARKER_PREFIX,
494
524
  cliOptions,
495
525
  detach,
496
526
  ensureReady,
497
527
  ensureSurfaceVisible,
498
528
  guardCommand,
499
- guestWsUrl,
529
+ surfaceWsUrl,
530
+ surfaceTarget,
500
531
  looksLikeLostConnection,
501
532
  marker,
502
533
  identityCandidates,
@@ -58,9 +58,29 @@ function readBridgeAdvertisement() {
58
58
  for (const file of bridgeFileCandidates()) {
59
59
  try {
60
60
  const data = JSON.parse(fs.readFileSync(file, 'utf8'));
61
- if (typeof data?.cdpUrl !== 'string' || !data.cdpUrl) continue;
61
+ const cdpUrl = typeof data?.cdp?.url === 'string'
62
+ ? data.cdp.url
63
+ : data?.cdpUrl;
64
+ const cdpPort = Number.isInteger(data?.cdp?.port)
65
+ ? data.cdp.port
66
+ : data?.cdpPort;
67
+ if (typeof cdpUrl !== 'string' || !cdpUrl) continue;
62
68
  if (typeof data?.pid === 'number' && !pidIsRunning(data.pid)) continue;
63
- return { cdpUrl: data.cdpUrl, cdpPort: data.cdpPort, label: data.label, file };
69
+ return {
70
+ cdpUrl,
71
+ cdpPort,
72
+ label: data.label,
73
+ surfaceProtocol: Number.isInteger(data?.browserSurfaceProtocol)
74
+ ? data.browserSurfaceProtocol
75
+ : 5,
76
+ surfaceTargetType: data?.browserSurfaceTargetType === 'page'
77
+ ? 'page'
78
+ : 'webview',
79
+ surfaceTargetTypes: Array.isArray(data?.browserSurfaceTargetTypes)
80
+ ? data.browserSurfaceTargetTypes.filter((value) => value === 'page' || value === 'webview')
81
+ : null,
82
+ file,
83
+ };
64
84
  } catch {}
65
85
  }
66
86
  return null;
@@ -81,7 +101,7 @@ function cdpFlagValue(input) {
81
101
  /**
82
102
  * The CDP endpoint for attached mode, as a --cdp flag value. agent-browser's
83
103
  * `connect` command launches a managed browser; raw --cdp attach is the mode
84
- * that discovers Electron <webview> targets, so that is all we use.
104
+ * that discovers Electron page targets, so that is all we use.
85
105
  */
86
106
  function cdpEndpoint() {
87
107
  if (process.env.AMALGM_BROWSER_CDP_URL) return cdpFlagValue(process.env.AMALGM_BROWSER_CDP_URL);
@@ -91,6 +111,21 @@ function cdpEndpoint() {
91
111
  return cdpFlagValue(advertisement.cdpUrl);
92
112
  }
93
113
 
114
+ /**
115
+ * Target kinds spoken by the advertised desktop shell. Missing protocol
116
+ * means the rev-5 webview contract. Explicit operator endpoints are allowed
117
+ * to be plain Chromium or either Electron generation, so probe both.
118
+ */
119
+ function surfaceTargetTypes() {
120
+ if (process.env.AMALGM_BROWSER_CDP_URL) return ['page', 'webview'];
121
+ const advertisement = readBridgeAdvertisement();
122
+ if (!advertisement) return [];
123
+ if (advertisement.surfaceTargetTypes?.length) {
124
+ return [...new Set(advertisement.surfaceTargetTypes)];
125
+ }
126
+ return [advertisement.surfaceTargetType];
127
+ }
128
+
94
129
  /** The debug override, or null when the owner's stamp decides. */
95
130
  function forcedMode() {
96
131
  const requested = (process.env.AMALGM_BROWSER_BACKEND || '').toLowerCase();
@@ -121,4 +156,5 @@ module.exports = {
121
156
  forcedMode,
122
157
  modeFor,
123
158
  readBridgeAdvertisement,
159
+ surfaceTargetTypes,
124
160
  };
@@ -5,18 +5,13 @@
5
5
  *
6
6
  * Pixels come straight from the session's own page:
7
7
  * - cli (headless) sessions capture their page target;
8
- * - attached sessions capture the <webview> guest target whose identity was
9
- * established at attach (attach.guestWsUrl) — never a target-list hunt,
8
+ * - attached sessions capture the native page target whose identity was
9
+ * established at attach (attach.surfaceTarget) — never a target-list hunt,
10
10
  * never a "first page target" fallback (that fallback is how
11
11
  * cua_screenshot once returned the app's own chat UI).
12
12
  *
13
- * The one physical law (verified live in all four states): a guest rasters
14
- * iff its <webview> is composited on-screen in the app's layout. The app
15
- * window may be hidden or minimized — the desktop app disables background
16
- * throttling while a surface is attached, so frames keep flowing. Only a
17
- * surface in a background tab (display:none) cannot produce pixels, and
18
- * ensureSurfaceVisible self-heals that by asking the UI to refocus the
19
- * surface tab before any capture is attempted.
13
+ * Background automation surfaces remain composited in a private native
14
+ * parking window, so captures keep working without covering the user's tab.
20
15
  *
21
16
  * Every exchange carries a hard deadline (cdp.js): a capture may fail, it
22
17
  * can never wedge a session.
@@ -48,34 +43,75 @@ async function pageTargetWsUrl(endpoint) {
48
43
 
49
44
  /**
50
45
  * Where to capture from for this session:
51
- * { wsUrl, guest } — guest marks an attached <webview> target, whose output
52
- * is normalized to CSS pixels so coordinates map 1:1 onto cua_* input.
46
+ * { wsUrl, attached, legacyWebview } — attached output is normalized to
47
+ * CSS pixels so coordinates map 1:1 onto cua_* input. The legacy flag
48
+ * preserves rev-5's transparent-PNG compatibility during rolling updates.
53
49
  */
54
50
  async function plan(session, context) {
55
51
  if (backendFor(session) !== 'attached') {
56
52
  const raw = await daemonCdpUrl(session);
57
53
  if (!raw) throw new Error('Could not resolve a CDP endpoint for this browser session.');
58
54
  const wsUrl = /\/devtools\/browser\//.test(raw) ? await pageTargetWsUrl(raw) : raw;
59
- return { wsUrl, guest: false };
55
+ return { wsUrl, attached: false };
60
56
  }
61
57
 
62
- // Desktop app: the session's own webview guest, identity from attach.
58
+ // Desktop app: the session's own native page, identity from attach.
63
59
  // Null only for explicit external CDP endpoints (plain chromium) — there
64
60
  // the driven page is the capture source.
65
- const wsUrl = await attach.guestWsUrl(session, context);
66
- if (wsUrl) return { wsUrl, guest: true };
67
- return { wsUrl: await pageTargetWsUrl(backend.cdpEndpoint()), guest: false };
61
+ const target = await attach.surfaceTarget(session, context);
62
+ if (target) {
63
+ return {
64
+ wsUrl: target.wsUrl,
65
+ attached: true,
66
+ legacyWebview: target.type === 'webview',
67
+ };
68
+ }
69
+ return {
70
+ wsUrl: await pageTargetWsUrl(backend.cdpEndpoint()),
71
+ attached: false,
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Rev-5 Electron webview guests can return a transparent PNG base even when
77
+ * the page looks white. Keep the old white-flatten pass only for an attached
78
+ * target positively identified as `webview`; native pages and headless
79
+ * Chromium skip the process entirely.
80
+ */
81
+ function flattenPng(buffer) {
82
+ const { spawn } = require('child_process');
83
+ return cdp.deadline(new Promise((resolve) => {
84
+ const child = spawn(cli.ffmpegBinary(), [
85
+ '-y', '-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0',
86
+ '-filter_complex', '[0:v]format=rgba,split[fg1][fg2];[fg1]drawbox=color=white:t=fill[bg];'
87
+ + '[bg][fg2]overlay=format=auto,format=rgb24',
88
+ '-frames:v', '1', '-f', 'image2pipe', '-c:v', 'png', 'pipe:1',
89
+ ], { stdio: ['pipe', 'pipe', 'ignore'] });
90
+ const chunks = [];
91
+ child.stdout.on('data', (chunk) => chunks.push(chunk));
92
+ child.on('error', () => resolve(buffer));
93
+ child.on('close', (code) => {
94
+ const output = Buffer.concat(chunks);
95
+ resolve(code === 0 && output.length ? output : buffer);
96
+ });
97
+ child.stdin.on('error', () => {});
98
+ child.stdin.end(buffer);
99
+ }), 10_000, 'screenshot flatten').catch(() => buffer);
68
100
  }
69
101
 
70
- /** Why a guest capture starved, in words an agent can act on. */
102
+ function shouldFlattenCapture(source) {
103
+ return source?.attached === true && source?.legacyWebview === true;
104
+ }
105
+
106
+ /** Why an attached capture starved, in words an agent can act on. */
71
107
  function starvationReason() {
72
- return 'the browser surface is not rendering. Its tab in the Amalgm app is hidden '
73
- + '(the app refocuses it automatically — retry), or the app is gone. '
108
+ return 'the browser surface compositor is unavailable while the app is closing '
109
+ + 'or recreating that surface. Retry once; if the app is gone, reopen it. '
74
110
  + 'AMALGM_BROWSER_BACKEND=cli forces a headless browser instead';
75
111
  }
76
112
 
77
113
  /**
78
- * The guest's viewport as a CSS-pixel clip. Captures render at the device
114
+ * The attached surface viewport as a CSS-pixel clip. Captures render at the device
79
115
  * scale factor; scale divides it back out so the image is CSS-pixel sized
80
116
  * and its coordinates map 1:1 onto cua_click/cua_move input.
81
117
  */
@@ -95,7 +131,7 @@ async function cssViewportClip(client) {
95
131
  * failure — an actionable refusal at start instead of an empty file at stop.
96
132
  */
97
133
  async function assertCapturable(client, source) {
98
- if (!source.guest) return; // headless page targets raster unconditionally
134
+ if (!source.attached) return; // headless page targets raster unconditionally
99
135
  try {
100
136
  await client.call('Page.captureScreenshot', {
101
137
  format: 'jpeg',
@@ -107,42 +143,13 @@ async function assertCapturable(client, source) {
107
143
  }
108
144
  }
109
145
 
110
- /**
111
- * Electron <webview> guests raster over a transparent base, so a page
112
- * without its own background captures with alpha — which viewers and
113
- * encoders composite as black. (CDP's background override is a no-op on
114
- * guests; verified live.) Flatten guest PNGs onto white with the bundled
115
- * ffmpeg — the pixels are intact under the alpha. Headless page targets
116
- * raster opaque and skip this entirely.
117
- */
118
- function flattenPng(buffer) {
119
- const { spawn } = require('child_process');
120
- return cdp.deadline(new Promise((resolve) => {
121
- const child = spawn(cli.ffmpegBinary(), [
122
- '-y', '-f', 'image2pipe', '-c:v', 'png', '-i', 'pipe:0',
123
- '-filter_complex', '[0:v]format=rgba,split[fg1][fg2];[fg1]drawbox=color=white:t=fill[bg];'
124
- + '[bg][fg2]overlay=format=auto,format=rgb24',
125
- '-frames:v', '1', '-f', 'image2pipe', '-c:v', 'png', 'pipe:1',
126
- ], { stdio: ['pipe', 'pipe', 'ignore'] });
127
- const chunks = [];
128
- child.stdout.on('data', (chunk) => chunks.push(chunk));
129
- child.on('error', () => resolve(buffer)); // no ffmpeg — alpha beats no image
130
- child.on('close', (code) => {
131
- const out = Buffer.concat(chunks);
132
- resolve(code === 0 && out.length ? out : buffer);
133
- });
134
- child.stdin.on('error', () => {});
135
- child.stdin.end(buffer);
136
- }), 10_000, 'screenshot flatten').catch(() => buffer);
137
- }
138
-
139
146
  async function screenshot(session, args = {}, context) {
140
147
  const fullPage = Boolean(args.fullPage);
141
148
  const source = await plan(session, context);
142
149
  const client = await cdp.connectCdp(source.wsUrl);
143
150
  try {
144
151
  const params = { format: 'png' };
145
- if (source.guest || !fullPage) {
152
+ if (source.attached || !fullPage) {
146
153
  // Every backend normalizes to CSS pixels — a dpr-2 display would
147
154
  // otherwise return a 2x image whose coordinates no longer match the
148
155
  // cua_* input space.
@@ -165,19 +172,20 @@ async function screenshot(session, args = {}, context) {
165
172
  try {
166
173
  ({ data } = await client.call('Page.captureScreenshot', params, SCREENSHOT_TIMEOUT_MS));
167
174
  } catch (err) {
168
- if (source.guest && /timed out/i.test(String(err.message || ''))) {
175
+ if (source.attached && /timed out/i.test(String(err.message || ''))) {
169
176
  throw new Error(`Screenshot starved: ${starvationReason()}.`);
170
177
  }
171
178
  throw err;
172
179
  }
173
180
  if (!data) throw new Error('Empty screenshot response');
174
- const image = source.guest
175
- ? await flattenPng(Buffer.from(data, 'base64'))
176
- : Buffer.from(data, 'base64');
181
+ const rawImage = Buffer.from(data, 'base64');
182
+ const image = shouldFlattenCapture(source)
183
+ ? await flattenPng(rawImage)
184
+ : rawImage;
177
185
  return {
178
186
  base64: image.toString('base64'),
179
187
  bytes: image.length,
180
- mode: source.guest ? 'visible surface' : (fullPage ? 'full page' : 'viewport'),
188
+ mode: source.attached ? 'visible surface' : (fullPage ? 'full page' : 'viewport'),
181
189
  };
182
190
  } finally {
183
191
  client.close();
@@ -195,6 +203,7 @@ module.exports = {
195
203
  plan,
196
204
  rectScript: cdp.rectScript,
197
205
  screenshot,
206
+ shouldFlattenCapture,
198
207
  starvationReason,
199
208
  targetListUrl: cdp.targetListUrl,
200
209
  };
@@ -116,14 +116,14 @@ function pageTargets(targets) {
116
116
  }
117
117
 
118
118
  /**
119
- * Locate the session's <webview> element inside a candidate app window.
119
+ * Locate the session's browser-surface marker inside a candidate app window.
120
120
  * Identity, strongest first:
121
121
  * - data-amalgm-surface attribute — stamped once at mount, survives
122
122
  * navigation (the src attribute is rewritten on every navigate, so the
123
123
  * marker URL it starts with does not last);
124
- * - marker still in the src attribute — fresh, never-navigated surfaces;
124
+ * - marker still in the live URL — fresh, never-navigated surfaces;
125
125
  * - exact current URL — old-app fallback, needs a fresh hint.
126
- * Never "the only webview": adopting an unidentified surface is how one
126
+ * Never "the only surface": adopting an unidentified surface is how one
127
127
  * chat's session ends up driving another chat's page.
128
128
  *
129
129
  * A session can match several elements — remounts (chat-tab switches) leave
@@ -132,24 +132,25 @@ function pageTargets(targets) {
132
132
  * capturable surface used to be reported as "hidden".
133
133
  *
134
134
  * Returns layout geometry only when a matched element has on-screen size,
135
- * plus the embedder's visibilityState and the webview's live URL (to re-find
135
+ * plus the embedder's visibilityState and the surface's live URL (to re-find
136
136
  * the daemon-side target after a restart).
137
137
  */
138
138
  function rectScript(markerText, currentUrl) {
139
139
  const urlLiteral = JSON.stringify(String(currentUrl || ''));
140
140
  return `(() => {
141
- const views = [...document.querySelectorAll('webview')];
141
+ const views = [...document.querySelectorAll('[data-amalgm-surface]')];
142
+ const liveUrl = (w) => w.getAttribute('data-amalgm-url') || w.src || w.getAttribute('src') || '';
142
143
  const matches = [
143
144
  ...views.filter((w) => (w.getAttribute('data-amalgm-surface') || '').includes(${JSON.stringify(markerText)})),
144
- ...views.filter((w) => (w.getAttribute('src') || '').includes(${JSON.stringify(markerText)})),
145
- ...(${urlLiteral} ? views.filter((w) => (w.src || '') === ${urlLiteral}) : []),
145
+ ...views.filter((w) => liveUrl(w).includes(${JSON.stringify(markerText)})),
146
+ ...(${urlLiteral} ? views.filter((w) => liveUrl(w) === ${urlLiteral}) : []),
146
147
  ];
147
148
  if (!matches.length) return null;
148
149
  const sized = matches.map((w) => ({ w, r: w.getBoundingClientRect() }));
149
150
  const match = sized.find(({ r }) => r.width && r.height);
150
151
  if (!match) return null;
151
152
  const { w, r } = match;
152
- return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height, vw: window.innerWidth, vh: window.innerHeight, dpr: window.devicePixelRatio || 1, src: w.src || '', embedderVisibility: document.visibilityState });
153
+ return JSON.stringify({ x: r.x, y: r.y, width: r.width, height: r.height, vw: window.innerWidth, vh: window.innerHeight, dpr: window.devicePixelRatio || 1, src: liveUrl(w), embedderVisibility: document.visibilityState });
153
154
  })()`;
154
155
  }
155
156
 
@@ -164,8 +165,8 @@ async function embedderRect(cdp, markerText, currentUrl) {
164
165
  }
165
166
 
166
167
  /**
167
- * Find the app window hosting the session's webview and the webview's
168
- * on-screen rectangle. Null when the surface is hidden (or no webview
168
+ * Find the app window hosting the session's marker and its on-screen
169
+ * rectangle. Null when the surface is hidden (or no marker
169
170
  * matches) — callers decide whether to reopen or fail.
170
171
  */
171
172
  async function findSurfaceRect(endpoint, markerText, currentUrl) {
@@ -135,7 +135,7 @@ function baseArgs(session, json = true, cdp = null) {
135
135
  '--screenshot-dir', screenshotDir(),
136
136
  ];
137
137
  // Raw CDP attach (drives an existing chromium — e.g. the Amalgm desktop
138
- // app's visible webview — instead of launching one). Must be passed on
138
+ // app's native browser page — instead of launching one). Must be passed on
139
139
  // every command: `connect` launches a managed browser, which is not what
140
140
  // attached mode wants.
141
141
  if (cdp) args.push('--cdp', String(cdp));
@@ -463,7 +463,7 @@ async function close(args = {}, context) {
463
463
  try { await recorder.stop({ ...args, session }, context); } catch {}
464
464
  }
465
465
  // Tear down this session's daemon. In attached mode this only disconnects
466
- // from the app's chromium (verified live: the app, its webviews, and other
466
+ // from the app's Chromium (verified live: the app, its native pages, and other
467
467
  // sessions' daemons survive). Without this, every attached session leaks a
468
468
  // daemon process for the life of the machine.
469
469
  try {
@@ -473,7 +473,7 @@ async function close(args = {}, context) {
473
473
  }
474
474
  // Tell the UI to unmount the splitview/tab. Each surface is a full
475
475
  // renderer process; ended sessions must not accumulate them (ten zombie
476
- // webviews were live in the app when this was written).
476
+ // browser surfaces were live in the app when this was written).
477
477
  if (sessions.surfaces.has(session) || sessions.knownSessions.has(session)) {
478
478
  attach.requestSurfaceClose(session);
479
479
  }
@@ -498,6 +498,7 @@ module.exports = {
498
498
  cdpEndpoint: backend.cdpEndpoint,
499
499
  modeFor: backend.modeFor,
500
500
  readBridgeAdvertisement: backend.readBridgeAdvertisement,
501
+ surfaceTargetTypes: backend.surfaceTargetTypes,
501
502
  // Session façade (sessions.js).
502
503
  backendFor: sessions.backendFor,
503
504
  ensureCookieSourceLoaded,
@@ -3,8 +3,9 @@
3
3
  /**
4
4
  * Recorder actions — session recording as a first-class primitive any agent
5
5
  * can use (QA bots especially). Captures the browser session — including the
6
- * desktop app's visible in-tab webview, cropped so the file shows only the
7
- * page — to a local WebM stored with the Amalgm project the work belongs to.
6
+ * desktop app's native in-tab page, cropped so the file shows only the
7
+ * page directly — to a local WebM stored with the Amalgm project the work
8
+ * belongs to.
8
9
  */
9
10
 
10
11
  const { textResult, errorResult } = require('../lib/tool-result');
@@ -4,7 +4,7 @@
4
4
  * Session recorder: capture any browser session to a local WebM file.
5
5
  *
6
6
  * Frames come from capture.js (CDP Page.startScreencast on the session's own
7
- * page target — the headless page, or the attached <webview> guest itself)
7
+ * page target — the headless page or the attached native surface itself)
8
8
  * and pipe into ffmpeg. The frames are the page and nothing else, so no
9
9
  * cropping is ever needed; recordings are local-only and nothing leaves this
10
10
  * computer.
@@ -13,7 +13,7 @@
13
13
  * - `record` needs ffmpeg on the daemon's PATH at daemon-spawn time, and
14
14
  * silently "starts" even when ffmpeg is missing (fails only on stop).
15
15
  * - `record` calls Target.createBrowserContext, which Electron forbids — so
16
- * it can never capture the visible in-tab webview.
16
+ * it can never capture the in-tab native surface.
17
17
  *
18
18
  * Storage: recordings belong to the Amalgm project the work is for —
19
19
  * <project>/.amalgm/recordings/ when a project path is known, otherwise
@@ -171,11 +171,9 @@ async function start(args = {}, context = {}) {
171
171
 
172
172
  const file = recordingFile(args, context, session);
173
173
  const fps = clampFps(args.fps);
174
- // Frames arrive as PNG with alpha: Electron <webview> guests raster over a
175
- // transparent base, so a page without its own background would encode as
176
- // black. Composite every frame onto white before the encoder sees it
177
- // (opaque headless frames pass through unchanged), then pad to the even
178
- // dimensions VP8 wants.
174
+ // Composite every frame onto white for legacy transparent sources, then
175
+ // pad to the even dimensions VP8 wants. Opaque native-page frames pass
176
+ // through unchanged.
179
177
  const filters = '[0:v]format=rgba,split[fg1][fg2];[fg1]drawbox=color=white:t=fill[bg];'
180
178
  + '[bg][fg2]overlay=format=auto,pad=ceil(iw/2)*2:ceil(ih/2)*2,format=yuv420p';
181
179
 
@@ -246,7 +244,7 @@ async function start(args = {}, context = {}) {
246
244
  fps,
247
245
  mimeType: 'video/webm',
248
246
  backend: sessions.backendFor(session),
249
- source: source.guest ? 'visible surface' : 'page',
247
+ source: source.attached ? 'visible surface' : 'page',
250
248
  };
251
249
  }
252
250
 
@@ -15,13 +15,13 @@ const backend = require('./backend');
15
15
 
16
16
  /**
17
17
  * Attached surfaces: session -> { tabId, targetId, wsUrl, embedderWsUrl,
18
- * client, external }. An entry exists exactly while its guest CDP connection
18
+ * client, external }. An entry exists exactly while its page CDP connection
19
19
  * is open — the connection's close handler removes it, so presence means
20
20
  * live. attach.js owns the lifecycle.
21
21
  */
22
22
  const surfaces = new Map();
23
23
 
24
- /** Per-session bookkeeping: webviewTabId, lastActiveAt, last url/title. */
24
+ /** Per-session bookkeeping: surfaceTabId, lastActiveAt, last url/title. */
25
25
  const knownSessions = new Map();
26
26
 
27
27
  /**
@@ -36,7 +36,7 @@ test('rectScript identifies the session surface and never guesses', () => {
36
36
  // No current URL → the byUrl branch is disabled, not matching everything.
37
37
  const bare = capture.rectScript('amalgm-bsid-abc', '');
38
38
  assert.match(bare, /"" \? views\.filter/);
39
- // "The only webview" is how one chat's capture reads another chat's page —
39
+ // "The only surface" is how one chat's capture reads another chat's page —
40
40
  // an unidentified surface must resolve to null, never to a guess.
41
41
  assert.doesNotMatch(script, /views\.length === 1/);
42
42
  // Remounts leave stale 0x0 copies of a session's surface behind; the match
@@ -70,9 +70,9 @@ test('rectScript prefers the on-screen copy over a stale 0x0 duplicate', async (
70
70
  assert.equal(rect.src, 'https://example.com/');
71
71
  });
72
72
 
73
- test('starvationReason names the real precondition: the surface tab', () => {
73
+ test('starvationReason names the real precondition: the surface compositor', () => {
74
74
  const reason = capture.starvationReason();
75
- assert.match(reason, /tab in the Amalgm app is hidden/);
75
+ assert.match(reason, /surface compositor is unavailable/);
76
76
  assert.match(reason, /AMALGM_BROWSER_BACKEND=cli/);
77
77
  // Window state is irrelevant since the app keeps attached surfaces
78
78
  // compositing while hidden — the message must not send users window-hunting.
@@ -83,11 +83,17 @@ test('assertCapturable converts a starved probe into a named refusal', async ()
83
83
  const starvedClient = { call: () => new Promise(() => {}) };
84
84
  const probe = capture.assertCapturable(
85
85
  { call: (m, p, t) => capture.deadline(new Promise(() => {}), 50, m) },
86
- { guest: true },
86
+ { attached: true },
87
87
  );
88
- await assert.rejects(() => probe, /Cannot capture this surface: .*tab in the Amalgm app is hidden/);
88
+ await assert.rejects(() => probe, /Cannot capture this surface: .*surface compositor is unavailable/);
89
89
  // Headless page targets raster unconditionally — no probe, no rejection.
90
- await capture.assertCapturable(starvedClient, { guest: false });
90
+ await capture.assertCapturable(starvedClient, { attached: false });
91
+ });
92
+
93
+ test('only a positively identified rev-5 webview uses PNG flattening', () => {
94
+ assert.equal(capture.shouldFlattenCapture({ attached: true, legacyWebview: true }), true);
95
+ assert.equal(capture.shouldFlattenCapture({ attached: true, legacyWebview: false }), false);
96
+ assert.equal(capture.shouldFlattenCapture({ attached: false, legacyWebview: true }), false);
91
97
  });
92
98
 
93
99
  test('targetListUrl accepts bare ports and ws urls', () => {
@@ -95,6 +95,42 @@ test('ownership routing: a fresh advertisement attaches desktop-born chats ONLY'
95
95
  });
96
96
  });
97
97
 
98
+ test('bridge compatibility: missing protocol means rev-5 webview, rev-6 means page', () => {
99
+ const legacyDir = tempDir('bridge-v5');
100
+ writeAdvertisement(legacyDir, {
101
+ cdpUrl: 'ws://127.0.0.1:9242/devtools/browser/legacy',
102
+ cdpPort: 9242,
103
+ pid: process.pid,
104
+ });
105
+ withEnv({ AMALGM_RUNTIME_STATE_DIR: legacyDir }, () => {
106
+ assert.equal(engine.readBridgeAdvertisement().surfaceProtocol, 5);
107
+ assert.deepEqual(engine.surfaceTargetTypes(), ['webview']);
108
+ });
109
+
110
+ const nativeDir = tempDir('bridge-v6');
111
+ writeAdvertisement(nativeDir, {
112
+ browserSurfaceProtocol: 6,
113
+ browserSurfaceTargetType: 'page',
114
+ browserSurfaceTargetTypes: ['page', 'webview'],
115
+ cdp: {
116
+ url: 'ws://127.0.0.1:9342/devtools/browser/native',
117
+ port: 9342,
118
+ },
119
+ pid: process.pid,
120
+ });
121
+ withEnv({ AMALGM_RUNTIME_STATE_DIR: nativeDir }, () => {
122
+ assert.equal(engine.readBridgeAdvertisement().surfaceProtocol, 6);
123
+ assert.deepEqual(engine.surfaceTargetTypes(), ['page', 'webview']);
124
+ assert.equal(engine.cdpEndpoint(), '9342');
125
+ });
126
+ });
127
+
128
+ test('bridge compatibility: explicit CDP probes both Electron generations', () => {
129
+ withEnv({ AMALGM_BROWSER_CDP_URL: 'ws://127.0.0.1:9442/devtools/browser/operator' }, () => {
130
+ assert.deepEqual(engine.surfaceTargetTypes(), ['page', 'webview']);
131
+ });
132
+ });
133
+
98
134
  test('ownership routing: stale advertisement (dead pid) means headless', () => {
99
135
  const stateDir = tempDir('stale');
100
136
  writeAdvertisement(stateDir, {
@@ -171,17 +207,17 @@ test('entrypoint routing: candidate order is state dir, derived label dir, AMALG
171
207
 
172
208
  // ---------------------------------------------------------------------------
173
209
  // Session → surface routing. The 2026-06-10 stress tests caught two parallel
174
- // chats reading each other's pages: a displaced session (its webview died on
175
- // a chat-tab switch) fell back to "the only webview" — someone else's.
210
+ // chats reading each other's pages: a displaced session (its surface died on
211
+ // a chat-tab switch) fell back to "the only surface" — someone else's.
176
212
  // ---------------------------------------------------------------------------
177
213
 
178
214
  test('surface routing: marker first, then the embedder DOM src as candidates', () => {
179
215
  const attach = require('../browser/attach');
180
216
 
181
- const fresh = { tabId: 't2', type: 'webview', url: 'about:blank#amalgm-bsid-s1' };
182
- const navigated = { tabId: 't3', type: 'webview', url: 'https://example.com/' };
183
- const twin = { tabId: 't5', type: 'webview', url: 'https://example.com/' };
184
- const other = { tabId: 't4', type: 'webview', url: 'https://other.test/' };
217
+ const fresh = { tabId: 't2', type: 'page', url: 'about:blank#amalgm-bsid-s1' };
218
+ const navigated = { tabId: 't3', type: 'page', url: 'https://example.com/' };
219
+ const twin = { tabId: 't5', type: 'page', url: 'https://example.com/' };
220
+ const other = { tabId: 't4', type: 'page', url: 'https://other.test/' };
185
221
 
186
222
  // Fresh surface: the marker URL identifies it exactly.
187
223
  assert.deepEqual(attach.identityCandidates([other, fresh], 's1', null), [fresh]);
@@ -198,11 +234,11 @@ test('surface routing: marker first, then the embedder DOM src as candidates', (
198
234
  );
199
235
  });
200
236
 
201
- test('surface routing: an unidentified lone webview is never adopted', () => {
237
+ test('surface routing: an unidentified lone page is never adopted', () => {
202
238
  const attach = require('../browser/attach');
203
- // One webview left and it is not provably ours — that is the cross-chat
239
+ // One page left and it is not provably ours — that is the cross-chat
204
240
  // leak shape. Fail closed; ensureReady reopens a marked surface instead.
205
- const stranger = { tabId: 't9', type: 'webview', url: 'https://another-chats-page.test/' };
241
+ const stranger = { tabId: 't9', type: 'page', url: 'https://another-chats-page.test/' };
206
242
  assert.deepEqual(attach.identityCandidates([stranger], 'displaced-session', null), []);
207
243
  assert.deepEqual(attach.identityCandidates([], 'displaced-session', null), []);
208
244
  });
@@ -148,7 +148,7 @@ console.log(JSON.stringify({ success: true, data: cmds.map((c) => ({ command: c,
148
148
  process.env.AMALGM_BROWSER_CDP_URL = '9242';
149
149
  // A live surface record makes ensureReady a no-op and turns the guard on.
150
150
  surfaces.set('guard-test', { tabId: 't7', wsUrl: 'ws://x/guest', client: { close() {} }, external: false });
151
- knownSessions.set('guard-test', { webviewTabId: 't7' });
151
+ knownSessions.set('guard-test', { surfaceTabId: 't7' });
152
152
  try {
153
153
  const single = await attach.run('guard-test', ['get', 'url'], { timeoutMs: 5000 });
154
154
  assert.deepEqual(single.data, { echoed: ['get', 'url'] }, 'single result keeps its unbatched shape');
@@ -169,6 +169,18 @@ test('checkpoint captures are local cache; their operations travel on the one wa
169
169
  assert.match(first, /repo-checkpoints-v7/);
170
170
  });
171
171
 
172
+ test('a materialized peer card is immediately a valid checkpoint base', () => {
173
+ const oid = 'a'.repeat(40);
174
+ assert.equal(_test.checkpointBaseReady({
175
+ head: { branch: 'main', oid },
176
+ }, {
177
+ branch: 'main',
178
+ baseOid: oid,
179
+ indexDiff: '',
180
+ worktreeDiff: '',
181
+ }), true);
182
+ });
183
+
172
184
  test('an uploading checkpoint payload is not published as a transition', () => {
173
185
  const pending = {
174
186
  revision: 'a'.repeat(64),
@@ -178,6 +178,16 @@ function readCheckpointValue(baseOid, value) {
178
178
  return { state: 'ready', indexDiff, worktreeDiff };
179
179
  }
180
180
 
181
+ function checkpointBaseReady(card, reduced) {
182
+ return Boolean(
183
+ reduced
184
+ && reduced.baseOid === card.head?.oid
185
+ && reduced.branch === card.head?.branch
186
+ && typeof reduced.indexDiff === 'string'
187
+ && typeof reduced.worktreeDiff === 'string'
188
+ );
189
+ }
190
+
181
191
  async function publishPass(replicaId = null) {
182
192
  const machine = machineId();
183
193
  const run = { published: 0, skips: 0, waiting: 0, failures: 0 };
@@ -187,16 +197,8 @@ async function publishPass(replicaId = null) {
187
197
  const repoKey = repoKeyOfCard(card);
188
198
  if (!repoKey || !card.head?.oid) continue;
189
199
  try {
190
- const publishedCard = require('./wall').publishedCardFor(card);
191
200
  const reduced = require('./reducer').readRepoState(repoKey);
192
- if (
193
- !publishedCard
194
- || !reduced
195
- || reduced.baseOid !== card.head.oid
196
- || reduced.branch !== card.head.branch
197
- || typeof reduced.indexDiff !== 'string'
198
- || typeof reduced.worktreeDiff !== 'string'
199
- ) {
201
+ if (!checkpointBaseReady(card, reduced)) {
200
202
  run.waiting += 1;
201
203
  continue;
202
204
  }
@@ -372,6 +374,7 @@ function checkpointStats() {
372
374
 
373
375
  module.exports = {
374
376
  _test: {
377
+ checkpointBaseReady,
375
378
  checkpointForSharedState,
376
379
  },
377
380
  CHECKPOINT_CONTRACT,