amalgm 0.1.91 → 0.1.92

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.
@@ -3,15 +3,18 @@
3
3
  /**
4
4
  * Session recorder: capture any browser session to a local WebM file.
5
5
  *
6
- * Implementation is a direct CDP Page.startScreencast consumer piped into
7
- * ffmpeg (ffmpeg-static when bundled, PATH ffmpeg otherwise). This is
8
- * deliberately NOT agent-browser's `record` command:
6
+ * Frames come from capture.js (CDP Page.startScreencast on a target that
7
+ * actually rasters the page itself headless, the app window cropped to the
8
+ * webview rectangle when attached) and pipe into ffmpeg. Recordings are
9
+ * local-only: nothing leaves this computer, and in attached mode the crop
10
+ * runs inside ffmpeg before any frame reaches disk, so the file only ever
11
+ * contains the page — not the app around it.
12
+ *
13
+ * This is deliberately NOT agent-browser's `record` command:
9
14
  * - `record` needs ffmpeg on the daemon's PATH at daemon-spawn time, and
10
15
  * silently "starts" even when ffmpeg is missing (fails only on stop).
11
16
  * - `record` calls Target.createBrowserContext, which Electron forbids — so
12
17
  * it can never capture the visible in-tab webview.
13
- * A raw screencast works against every CDP target we care about: launched
14
- * chromium AND the desktop app's splitscreen webview.
15
18
  *
16
19
  * Storage: recordings belong to the Amalgm project the work is for —
17
20
  * <project>/.amalgm/recordings/ when a project path is known, otherwise
@@ -23,9 +26,13 @@ const path = require('path');
23
26
  const { spawn } = require('child_process');
24
27
 
25
28
  const cli = require('./cli');
26
- const engine = require('./engine');
29
+ const backend = require('./backend');
30
+ const capture = require('./capture');
31
+ const sessions = require('./sessions');
32
+
33
+ const active = new Map(); // session -> { cdp, ffmpeg, file, frames, startedAt }
27
34
 
28
- const active = new Map(); // session -> { ws, ffmpeg, file, frames, startedAt }
35
+ const FFMPEG_CLOSE_TIMEOUT_MS = 10_000;
29
36
 
30
37
  function ffmpegBinary() {
31
38
  if (process.env.AMALGM_FFMPEG) return process.env.AMALGM_FFMPEG;
@@ -88,34 +95,8 @@ function recordingFile(args, context, session) {
88
95
  return path.join(recordingDir(args, context, session), `${name}-${stamp}.webm`);
89
96
  }
90
97
 
91
- /**
92
- * Page.startScreencast only works on a page-level CDP connection. Launched
93
- * sessions report a browser-level ws URL, so resolve the active page target
94
- * through the /json/list endpoint on the same debugger port.
95
- */
96
- async function pageWsUrl(wsUrl) {
97
- if (!/\/devtools\/browser\//.test(wsUrl)) return wsUrl;
98
- const { hostname, port } = new URL(wsUrl);
99
- const list = await new Promise((resolve, reject) => {
100
- const http = require('http');
101
- const req = http.get({ hostname, port, path: '/json/list', timeout: 5_000 }, (res) => {
102
- let raw = '';
103
- res.on('data', (chunk) => { raw += chunk.toString(); });
104
- res.on('end', () => {
105
- try { resolve(JSON.parse(raw)); } catch (err) { reject(err); }
106
- });
107
- });
108
- req.on('error', reject);
109
- req.on('timeout', () => req.destroy(new Error('Timed out listing CDP targets')));
110
- });
111
- const page = list.find((t) => t.type === 'webview' && t.webSocketDebuggerUrl)
112
- || list.find((t) => t.type === 'page' && t.webSocketDebuggerUrl && !t.url.startsWith('devtools://'));
113
- if (!page) throw new Error('No recordable page target found on the CDP endpoint.');
114
- return page.webSocketDebuggerUrl;
115
- }
116
-
117
98
  async function start(args = {}, context = {}) {
118
- const session = engine.sessionFor(args, context);
99
+ const session = sessions.sessionFor(args, context);
119
100
  if (active.has(session)) {
120
101
  throw new Error(`Recording already active for session "${session}". Call record_stop first.`);
121
102
  }
@@ -124,30 +105,29 @@ async function start(args = {}, context = {}) {
124
105
  // and a clean refusal beats a half-started recording.
125
106
  const binary = ensureFfmpeg();
126
107
 
127
- const rawUrl = await engine.cdpUrlForSession(session);
128
- if (!rawUrl) throw new Error('Could not resolve a CDP endpoint for this browser session.');
129
- const wsUrl = await pageWsUrl(rawUrl);
108
+ // Where frames come from — and, in attached mode, the crop that keeps the
109
+ // surrounding app UI out of the file. Resolved with hard deadlines.
110
+ const source = await capture.plan(session, context);
130
111
 
131
- const WebSocket = require('ws');
132
112
  const file = recordingFile(args, context, session);
133
113
  const fps = Math.min(Math.max(Number(args.fps) || 10, 1), 30);
114
+ const filters = [source.crop, 'pad=ceil(iw/2)*2:ceil(ih/2)*2'].filter(Boolean).join(',');
134
115
 
135
116
  const ffmpeg = spawn(binary, [
136
117
  '-y', '-f', 'image2pipe', '-c:v', 'mjpeg', '-framerate', String(fps), '-i', 'pipe:0',
137
- '-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-c:v', 'libvpx', '-pix_fmt', 'yuv420p', file,
118
+ '-vf', filters, '-c:v', 'libvpx', '-pix_fmt', 'yuv420p', file,
138
119
  ], { stdio: ['pipe', 'ignore', 'pipe'] });
139
120
  let ffmpegErr = '';
140
121
  ffmpeg.stderr.on('data', (chunk) => { ffmpegErr = (ffmpegErr + chunk.toString()).slice(-2000); });
141
122
 
142
- const ws = new WebSocket(wsUrl, { perMessageDeflate: false });
143
- const recording = { ws, ffmpeg, file, frames: 0, startedAt: Date.now(), ffmpegErr: () => ffmpegErr };
123
+ const recording = { cdp: null, ffmpeg, file, frames: 0, startedAt: Date.now(), ffmpegErr: () => ffmpegErr };
144
124
 
145
125
  // Failures after the preflight (binary swapped out, EMFILE, ffmpeg dying
146
126
  // mid-recording and EPIPE-ing stdin) end this recording, not the server.
147
127
  ffmpeg.on('error', (err) => {
148
128
  ffmpegErr = `${err.message}${ffmpegErr ? ` ${ffmpegErr}` : ''}`;
149
129
  if (active.get(session) === recording) active.delete(session);
150
- try { ws.close(); } catch {}
130
+ try { recording.cdp?.close(); } catch {}
151
131
  });
152
132
  ffmpeg.stdin.on('error', () => {
153
133
  // Swallow stream errors; stop() reports the failure via exit code + stderr.
@@ -155,54 +135,62 @@ async function start(args = {}, context = {}) {
155
135
 
156
136
  active.set(session, recording);
157
137
 
158
- await new Promise((resolve, reject) => {
159
- const timer = setTimeout(() => reject(new Error('Timed out connecting to browser CDP endpoint')), 10_000);
160
- ws.on('open', () => { clearTimeout(timer); resolve(); });
161
- ws.on('error', (err) => { clearTimeout(timer); reject(err); });
162
- }).catch((err) => {
138
+ try {
139
+ recording.cdp = await capture.connectCdp(source.wsUrl);
140
+ recording.cdp.on('Page.screencastFrame', (params) => {
141
+ recording.frames += 1;
142
+ try { ffmpeg.stdin.write(Buffer.from(params.data, 'base64')); } catch {}
143
+ recording.cdp.send('Page.screencastFrameAck', { sessionId: params.sessionId });
144
+ });
145
+ await recording.cdp.call('Page.enable').catch(() => {});
146
+ await recording.cdp.call('Page.startScreencast', { format: 'jpeg', quality: 70, everyNthFrame: 1 });
147
+ } catch (err) {
163
148
  active.delete(session);
149
+ try { recording.cdp?.close(); } catch {}
164
150
  try { ffmpeg.kill('SIGKILL'); } catch {}
165
151
  throw err;
166
- });
167
-
168
- let id = 0;
169
- const send = (method, params = {}) => ws.send(JSON.stringify({ id: ++id, method, params }));
170
- ws.on('message', (raw) => {
171
- let msg;
172
- try { msg = JSON.parse(raw); } catch { return; }
173
- if (msg.method === 'Page.screencastFrame') {
174
- recording.frames += 1;
175
- try { ffmpeg.stdin.write(Buffer.from(msg.params.data, 'base64')); } catch {}
176
- send('Page.screencastFrameAck', { sessionId: msg.params.sessionId });
177
- }
178
- });
179
- send('Page.enable');
180
- send('Page.startScreencast', { format: 'jpeg', quality: 70, everyNthFrame: 1 });
152
+ }
181
153
 
182
- return { session, path: file, fps, mimeType: 'video/webm', backend: engine.mode() };
154
+ return {
155
+ session,
156
+ path: file,
157
+ fps,
158
+ mimeType: 'video/webm',
159
+ backend: backend.mode(),
160
+ cropped: Boolean(source.crop),
161
+ };
183
162
  }
184
163
 
185
164
  async function stop(args = {}, context = {}) {
186
- const session = engine.sessionFor(args, context);
165
+ const session = sessions.sessionFor(args, context);
187
166
  const recording = active.get(session);
188
167
  if (!recording) throw new Error(`No active recording for session "${session}".`);
189
168
  active.delete(session);
190
169
 
191
- try {
192
- recording.ws.send(JSON.stringify({ id: 9_999, method: 'Page.stopScreencast' }));
193
- } catch {}
194
- try { recording.ws.close(); } catch {}
170
+ try { recording.cdp?.send('Page.stopScreencast'); } catch {}
171
+ try { recording.cdp?.close(); } catch {}
195
172
 
173
+ // ffmpeg gets a bounded window to flush and finalize the container; a
174
+ // wedged encoder is killed rather than wedging record_stop.
196
175
  const exitCode = await new Promise((resolve) => {
197
- recording.ffmpeg.on('close', resolve);
198
- try { recording.ffmpeg.stdin.end(); } catch { resolve(-1); }
176
+ const killTimer = setTimeout(() => {
177
+ try { recording.ffmpeg.kill('SIGKILL'); } catch {}
178
+ }, FFMPEG_CLOSE_TIMEOUT_MS);
179
+ recording.ffmpeg.on('close', (code) => {
180
+ clearTimeout(killTimer);
181
+ resolve(code);
182
+ });
183
+ try { recording.ffmpeg.stdin.end(); } catch {
184
+ clearTimeout(killTimer);
185
+ resolve(-1);
186
+ }
199
187
  });
200
188
 
201
189
  const durationMs = Date.now() - recording.startedAt;
202
190
  if (recording.frames === 0 || exitCode !== 0) {
203
191
  try { fs.rmSync(recording.file, { force: true }); } catch {}
204
192
  throw new Error(recording.frames === 0
205
- ? 'No frames captured — the page may not have repainted while recording. (Screencast only emits on visual change.)'
193
+ ? 'No frames captured — nothing repainted while recording (the screencast only emits on visual change).'
206
194
  : `ffmpeg failed (exit ${exitCode}): ${recording.ffmpegErr()}`);
207
195
  }
208
196
 
@@ -219,7 +207,7 @@ async function stop(args = {}, context = {}) {
219
207
  }
220
208
 
221
209
  function list(args = {}, context = {}) {
222
- const session = engine.sessionFor(args, context);
210
+ const session = sessions.sessionFor(args, context);
223
211
  const dir = recordingDir(args, context, session);
224
212
  let entries = [];
225
213
  try {
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Browser session identity and registry.
5
+ *
6
+ * A session is 1:1 with a durable profile directory; the default session id
7
+ * is the caller's chat session, so one conversation keeps one browser across
8
+ * tool calls without anyone passing ids around. Parallel sessions stay
9
+ * isolated because each gets its own agent-browser daemon and profile.
10
+ */
11
+
12
+ const cli = require('./cli');
13
+ const backend = require('./backend');
14
+
15
+ /** Sessions that completed the attached-mode handshake (webview selected + focused). */
16
+ const attachedSessions = new Set();
17
+
18
+ /** Per-session bookkeeping: webviewTabId, lastActiveAt, last url/title. */
19
+ const knownSessions = new Map();
20
+
21
+ function sessionFor(args = {}, context = {}) {
22
+ return cli.safeId(
23
+ args.session
24
+ || args.profile
25
+ || args.browserProfileId
26
+ || args.browserSessionId
27
+ || args.sessionId
28
+ || args.tabId
29
+ || context?.callerSessionId
30
+ || cli.DEFAULT_SESSION,
31
+ );
32
+ }
33
+
34
+ function rememberProfile(session) {
35
+ try {
36
+ require('./profiles').touchBrowserProfile({
37
+ id: session,
38
+ name: session,
39
+ source: 'agent-browser',
40
+ profilePath: cli.profileDir(session),
41
+ }, { source: 'agent-browser' });
42
+ } catch {}
43
+ }
44
+
45
+ function sessionInfo(session, extra = {}) {
46
+ rememberProfile(session);
47
+ const info = {
48
+ session,
49
+ browserSessionId: session,
50
+ browserProfileId: session,
51
+ backend: backend.mode(),
52
+ ...extra,
53
+ };
54
+ knownSessions.set(session, { ...knownSessions.get(session), ...info, lastActiveAt: Date.now() });
55
+ return info;
56
+ }
57
+
58
+ module.exports = {
59
+ attachedSessions,
60
+ knownSessions,
61
+ rememberProfile,
62
+ sessionFor,
63
+ sessionInfo,
64
+ };
@@ -1,16 +1,20 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Browser tools — the thin surface.
4
+ * Browser core actions — the thin surface.
5
5
  *
6
- * Nine verbs, built on the agent-browser CLI (vercel-labs/agent-browser).
7
- * The loop is: open → snapshot (refs) → click/fill (@ref) → re-snapshot.
6
+ * Ten verbs built on the agent-browser CLI (vercel-labs/agent-browser). The
7
+ * loop is: open → snapshot (@refs) → click/fill (@ref) → re-snapshot.
8
8
  * Everything else agent-browser can do (scroll, hover, cookies, network
9
- * mocking, traces, diffs, React inspection, ...) is reachable through
10
- * browser_cli without widening this schema.
9
+ * mocking, traces, diffs, React inspection, ...) is reachable through `cli`
10
+ * without widening this schema.
11
11
  *
12
- * In the Amalgm desktop app these drive the visible in-tab splitscreen
13
- * browser; elsewhere they drive a standalone chromium.
12
+ * Action names are short verbs the toolbox layer prefixes them, so agents
13
+ * see toolbox__browser_open, toolbox__browser_snapshot, and so on.
14
+ *
15
+ * Visibility is environmental, never an argument: in the Amalgm desktop app
16
+ * these drive the visible in-tab split-view browser; everywhere else they
17
+ * drive a headless chromium. Same actions, same results.
14
18
  */
15
19
 
16
20
  const { textResult, errorResult } = require('../lib/tool-result');
@@ -26,7 +30,7 @@ const sessionProperty = {
26
30
  const targetProperty = {
27
31
  target: {
28
32
  type: 'string',
29
- description: 'Element target: a snapshot ref like "@e12" (preferred — run browser_snapshot first), a CSS selector, or "text=Visible label".',
33
+ description: 'Element target: a snapshot ref like "@e12" (preferred — run snapshot first), a CSS selector, or "text=Visible label".',
30
34
  },
31
35
  };
32
36
 
@@ -38,8 +42,8 @@ function line(result, extra = '') {
38
42
 
39
43
  module.exports = [
40
44
  {
41
- name: 'browser_open',
42
- description: 'Open a URL in the browser. In the Amalgm desktop app this appears in the in-tab splitscreen surface. Follow with browser_snapshot to see interactive elements.',
45
+ name: 'open',
46
+ description: 'Open a URL. The browser persists between calls — open once, then interact. Follow with snapshot to see the page as @refs. (In the Amalgm desktop app the user watches this browser in a split view.)',
43
47
  inputSchema: {
44
48
  type: 'object',
45
49
  properties: { url: { type: 'string', description: 'URL to open' }, ...sessionProperty },
@@ -50,15 +54,15 @@ module.exports = [
50
54
  try {
51
55
  const result = await engine.open(args, ctx);
52
56
  return textResult(line(result, `Opened: ${result.title || args.url}`)
53
- + '\n\nNext: browser_snapshot lists interactive elements as @refs.');
57
+ + '\n\nNext: snapshot lists interactive elements as @refs.');
54
58
  } catch (err) {
55
59
  return errorResult(`Open failed: ${err.message}`);
56
60
  }
57
61
  },
58
62
  },
59
63
  {
60
- name: 'browser_snapshot',
61
- description: 'Accessibility snapshot of the current page with stable @refs for every interactive element. This is how you see the page; use the refs with browser_click and browser_fill.',
64
+ name: 'snapshot',
65
+ description: 'Read the page: an accessibility snapshot with a stable @ref for every interactive element. This is how you see the page pass the refs to click and fill.',
62
66
  inputSchema: { type: 'object', properties: sessionProperty },
63
67
  async handler(args, ctx) {
64
68
  try {
@@ -71,22 +75,22 @@ module.exports = [
71
75
  },
72
76
  },
73
77
  {
74
- name: 'browser_screenshot',
75
- description: 'Screenshot the current page (PNG). Use browser_snapshot for interaction targets; use this when you need to see pixels.',
78
+ name: 'screenshot',
79
+ description: 'Screenshot the page (PNG). Prefer snapshot for driving the page; use this to verify visuals. fullPage captures the whole scroll height when headless; in the desktop app you always get the visible surface.',
76
80
  inputSchema: {
77
81
  type: 'object',
78
82
  properties: {
79
- fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport' },
83
+ fullPage: { type: 'boolean', description: 'Capture the full scrollable page instead of the viewport (headless only)' },
80
84
  ...sessionProperty,
81
85
  },
82
86
  },
83
87
  async handler(args, ctx) {
84
88
  try {
85
- const capture = await engine.screenshot(args, ctx);
89
+ const shot = await engine.screenshot(args, ctx);
86
90
  return {
87
91
  content: [
88
- { type: 'image', data: capture.base64, mimeType: 'image/png' },
89
- { type: 'text', text: `Screenshot (${capture.bytes} bytes, ${capture.mode || 'viewport'})` },
92
+ { type: 'image', data: shot.base64, mimeType: 'image/png' },
93
+ { type: 'text', text: `Screenshot (${shot.bytes} bytes, ${shot.mode})` },
90
94
  ],
91
95
  };
92
96
  } catch (err) {
@@ -95,7 +99,7 @@ module.exports = [
95
99
  },
96
100
  },
97
101
  {
98
- name: 'browser_click',
102
+ name: 'click',
99
103
  description: 'Click an element. Target with a snapshot @ref, CSS selector, or "text=Label".',
100
104
  inputSchema: {
101
105
  type: 'object',
@@ -106,14 +110,14 @@ module.exports = [
106
110
  if (!args.target) return errorResult('target is required (e.g. "@e3", "#submit", "text=Sign in")');
107
111
  try {
108
112
  const result = await engine.click(args, ctx);
109
- return textResult(`Clicked ${args.target}\nSession: ${result.session}\n\nRe-run browser_snapshot if the page changed.`);
113
+ return textResult(`Clicked ${args.target}\nSession: ${result.session}\n\nRe-run snapshot if the page changed.`);
110
114
  } catch (err) {
111
115
  return errorResult(`Click failed: ${err.message}`);
112
116
  }
113
117
  },
114
118
  },
115
119
  {
116
- name: 'browser_fill',
120
+ name: 'fill',
117
121
  description: 'Clear a field and type into it. Optionally press Enter to submit.',
118
122
  inputSchema: {
119
123
  type: 'object',
@@ -136,7 +140,7 @@ module.exports = [
136
140
  },
137
141
  },
138
142
  {
139
- name: 'browser_press',
143
+ name: 'press',
140
144
  description: 'Press a key or combination at the focused element, e.g. "Enter", "Escape", "Tab", "Control+a".',
141
145
  inputSchema: {
142
146
  type: 'object',
@@ -154,8 +158,8 @@ module.exports = [
154
158
  },
155
159
  },
156
160
  {
157
- name: 'browser_eval',
158
- description: 'Run JavaScript in the page and return the result. The general-purpose escape hatch for reading or mutating page state.',
161
+ name: 'eval',
162
+ description: 'Run JavaScript in the page and return the result the escape hatch for reading or mutating anything the page knows.',
159
163
  inputSchema: {
160
164
  type: 'object',
161
165
  properties: { script: { type: 'string', description: 'JavaScript to evaluate' }, ...sessionProperty },
@@ -173,7 +177,7 @@ module.exports = [
173
177
  },
174
178
  },
175
179
  {
176
- name: 'browser_wait',
180
+ name: 'wait',
177
181
  description: 'Wait for a CSS selector to appear, a URL substring/wildcard to match, or a fixed number of milliseconds.',
178
182
  inputSchema: {
179
183
  type: 'object',
@@ -195,7 +199,7 @@ module.exports = [
195
199
  },
196
200
  },
197
201
  {
198
- name: 'browser_cli',
202
+ name: 'cli',
199
203
  description: 'Run any agent-browser CLI command against this session — the full surface beyond the core verbs: ["scroll","down"], ["hover","@e3"], ["back"], ["tab"], ["get","html","@e1"], ["cookies","get"], ["network","requests"], ["find","role","button","click","--name","Submit"], and more. Run ["--help"] for the command list.',
200
204
  inputSchema: {
201
205
  type: 'object',
@@ -212,18 +216,18 @@ module.exports = [
212
216
  const output = typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
213
217
  return textResult(output.slice(0, 15_000) || 'ok');
214
218
  } catch (err) {
215
- return errorResult(`browser_cli failed: ${err.message}`);
219
+ return errorResult(`cli failed: ${err.message}`);
216
220
  }
217
221
  },
218
222
  },
219
223
  {
220
- name: 'browser_close',
221
- description: 'Close the browser session. A fresh one launches on the next browser call.',
224
+ name: 'close',
225
+ description: 'End the browser session. Headless browsers shut down; the visible desktop surface stays open for the user. A fresh session starts on the next browser call.',
222
226
  inputSchema: { type: 'object', properties: sessionProperty },
223
227
  async handler(args, ctx) {
224
228
  try {
225
229
  await engine.close(args, ctx);
226
- return textResult('Browser closed.');
230
+ return textResult('Browser session closed.');
227
231
  } catch (err) {
228
232
  return errorResult(`Close failed: ${err.message}`);
229
233
  }
@@ -8,10 +8,6 @@
8
8
  * circular dependency on the MCP server.
9
9
  */
10
10
 
11
- function withCapability(tools, capability) {
12
- return tools.map((tool) => ({ ...tool, capability }));
13
- }
14
-
15
11
  function group(id, name, description, tools, extra = {}) {
16
12
  return {
17
13
  id,
@@ -64,19 +60,13 @@ const CORE_TOOL_GROUPS = [
64
60
  ),
65
61
  // Browser is ONE toolbox entry. Users enable/disable it per agent as a
66
62
  // single tool; fine-grained control stays available because loadouts can
67
- // hold individual action ids (`browser.cua_click`). Each action carries a
68
- // `capability` tag so UIs can section the card (core / computer-use /
69
- // recording / auth) without splitting the entry.
63
+ // hold individual action ids (`browser.cua_click`). Action list, capability
64
+ // sections, and description live with the implementation in ../browser.
70
65
  group(
71
66
  'browser',
72
67
  'Browser',
73
- "Drive Amalgm's browser: open pages, snapshot interactive elements as @refs, click, fill, record sessions to project videos, manage durable login profiles, and run any agent-browser CLI command. Visible in the desktop split view; headless on servers.",
74
- [
75
- ...withCapability(require('../browser/tools'), 'core'),
76
- ...withCapability(require('../browser/cua-tools'), 'computer-use'),
77
- ...withCapability(require('../browser/recorder-tools'), 'recording'),
78
- ...withCapability(require('../browser/auth-tools'), 'auth'),
79
- ],
68
+ require('../browser').DESCRIPTION,
69
+ require('../browser').ACTIONS,
80
70
  ),
81
71
  ];
82
72
 
@@ -24,6 +24,27 @@ const { buildMcpRequestContext } = require('./mcp-request-context');
24
24
 
25
25
  const TOOLS = DEFAULT_STATIC_MCP_TOOLS;
26
26
 
27
+ /**
28
+ * One greppable line whenever a session's tool registry changes shape. If a
29
+ * client ever reports "No such tool available" again, this proves what the
30
+ * server actually served (the 2026-06-10 partial-registry incident was
31
+ * unattributable because nothing logged tools/list).
32
+ */
33
+ const lastToolListHash = new Map();
34
+ function logToolList(ctx, tools) {
35
+ try {
36
+ const names = tools.map((tool) => tool.name).sort();
37
+ const hash = require('crypto').createHash('sha1').update(names.join('\n')).digest('hex').slice(0, 8);
38
+ const session = ctx?.callerSessionId || 'unknown';
39
+ if (lastToolListHash.get(session) === hash) return;
40
+ lastToolListHash.set(session, hash);
41
+ if (lastToolListHash.size > 500) {
42
+ lastToolListHash.delete(lastToolListHash.keys().next().value);
43
+ }
44
+ console.error(`[amalgm-mcp] tools/list session=${session} count=${tools.length} names-sha1=${hash}`);
45
+ } catch {}
46
+ }
47
+
27
48
  function createMcpServer(options = {}) {
28
49
  const surface = options.surface || createToolboxMcpSurface();
29
50
  const buildRequestContext = options.buildRequestContext || buildMcpRequestContext;
@@ -34,7 +55,9 @@ function createMcpServer(options = {}) {
34
55
 
35
56
  server.setRequestHandler(ListToolsRequestSchema, async (_request, extra) => {
36
57
  const ctx = await buildRequestContext(extra);
37
- return { tools: await surface.listTools(ctx) };
58
+ const tools = await surface.listTools(ctx);
59
+ logToolList(ctx, tools);
60
+ return { tools };
38
61
  });
39
62
 
40
63
  server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
@@ -0,0 +1,87 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const test = require('node:test');
5
+ const http = require('node:http');
6
+
7
+ const capture = require('../browser/capture');
8
+
9
+ test('clipFor turns a webview rect into integer CSS-px clip bounds', () => {
10
+ const clip = capture.clipFor({ x: 411.5, y: 52.25, width: 868.75, height: 781.5, vw: 1280, vh: 834 });
11
+ assert.deepEqual(clip, { x: 412, y: 52, width: 869, height: 782, scale: 1 });
12
+ // Negative origins (mid-animation surfaces) clamp to the viewport.
13
+ assert.equal(capture.clipFor({ x: -4, y: 0, width: 100, height: 100 }).x, 0);
14
+ // Retina displays render captures at devicePixelRatio; scale divides it
15
+ // back out so image pixels map 1:1 onto cua input coordinates.
16
+ assert.equal(capture.clipFor({ x: 0, y: 0, width: 100, height: 100, dpr: 2 }).scale, 0.5);
17
+ });
18
+
19
+ test('cropFilterFor crops by viewport fraction so any frame resolution works', () => {
20
+ const filter = capture.cropFilterFor({ x: 320, y: 0, width: 640, height: 600, vw: 1280, vh: 800 });
21
+ // width 640/1280=0.5, height 600/800=0.75, x 320/1280=0.25, y 0.
22
+ assert.equal(filter, 'crop=floor(iw*0.500000/2)*2:floor(ih*0.750000/2)*2:floor(iw*0.250000):floor(ih*0.000000)');
23
+ // Fractions clamp into [0, 1] even for slightly-out-of-viewport rects.
24
+ assert.match(capture.cropFilterFor({ x: -10, y: 0, width: 2000, height: 100, vw: 1000, vh: 500 }),
25
+ /^crop=floor\(iw\*1\.000000\/2\)\*2/);
26
+ });
27
+
28
+ test('pageTargets keeps only capturable page targets', () => {
29
+ const targets = [
30
+ { type: 'page', url: 'devtools://devtools/bundled/devtools_app.html', webSocketDebuggerUrl: 'ws://x/1' },
31
+ { type: 'webview', url: 'https://example.com/', webSocketDebuggerUrl: 'ws://x/2' },
32
+ { type: 'page', url: 'http://localhost:3100/', webSocketDebuggerUrl: 'ws://x/3' },
33
+ { type: 'page', url: 'http://localhost:3100/popup' },
34
+ ];
35
+ assert.deepEqual(capture.pageTargets(targets).map((t) => t.webSocketDebuggerUrl), ['ws://x/3']);
36
+ });
37
+
38
+ test('rectScript matches by marker, then current URL, then the only webview', () => {
39
+ const script = capture.rectScript('amalgm-bsid-abc', 'https://example.com/');
40
+ assert.match(script, /amalgm-bsid-abc/);
41
+ assert.match(script, /https:\/\/example\.com\//);
42
+ // No current URL → the byUrl branch is disabled, not matching everything.
43
+ const bare = capture.rectScript('amalgm-bsid-abc', '');
44
+ assert.match(bare, /"" \? views\.find/);
45
+ });
46
+
47
+ test('targetListUrl accepts bare ports and ws urls', () => {
48
+ assert.equal(capture.targetListUrl('9242'), 'http://127.0.0.1:9242/json/list');
49
+ assert.equal(
50
+ capture.targetListUrl('ws://127.0.0.1:9242/devtools/browser/abc'),
51
+ 'http://127.0.0.1:9242/json/list',
52
+ );
53
+ });
54
+
55
+ test('deadline rejects instead of hanging', async () => {
56
+ await assert.rejects(
57
+ () => capture.deadline(new Promise(() => {}), 50, 'Page.captureScreenshot'),
58
+ /Page\.captureScreenshot timed out after 50ms/,
59
+ );
60
+ assert.equal(await capture.deadline(Promise.resolve('ok'), 50, 'fast'), 'ok');
61
+ });
62
+
63
+ test('a CDP endpoint that accepts but never answers cannot wedge a capture', async () => {
64
+ // Plain HTTP server: the websocket upgrade never completes, so connectCdp
65
+ // must give up on its own deadline rather than waiting forever — this is
66
+ // the daemon-wedge class of failure, now structurally impossible.
67
+ const server = http.createServer(() => {});
68
+ server.on('upgrade', () => { /* swallow the upgrade — never respond */ });
69
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
70
+ const { port } = server.address();
71
+ const startedAt = Date.now();
72
+ await assert.rejects(
73
+ () => capture.connectCdp(`ws://127.0.0.1:${port}/devtools/page/never`),
74
+ /CDP connect timed out/,
75
+ );
76
+ assert.equal(Date.now() - startedAt < 15_000, true);
77
+ server.close();
78
+ });
79
+
80
+ test('listTargets rejects cleanly when nothing listens on the port', async () => {
81
+ // Find a free port, then close it so the request must fail fast.
82
+ const server = http.createServer(() => {});
83
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
84
+ const { port } = server.address();
85
+ await new Promise((resolve) => server.close(resolve));
86
+ await assert.rejects(() => capture.listTargets(String(port)));
87
+ });