claude-code-session-manager 0.62.0 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
  2. package/dist/assets/History-mWbqemhZ.js +2 -0
  3. package/dist/assets/Hooks-D_8sciZu.js +3 -0
  4. package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
  5. package/dist/assets/Library-CG1LDXLw.js +46 -0
  6. package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
  7. package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
  8. package/dist/assets/McpServers-Kc-djiVi.js +2 -0
  9. package/dist/assets/Memory-B60DGRTW.js +8 -0
  10. package/dist/assets/Panel-CbTPsYHq.js +1 -0
  11. package/dist/assets/Permissions-C8KrSfrd.js +3 -0
  12. package/dist/assets/Plugins-BT8EumHb.js +2 -0
  13. package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
  14. package/dist/assets/Scheduler-DbKPd0or.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
  16. package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
  18. package/dist/assets/Skills-CVL_4X3T.js +3 -0
  19. package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
  20. package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
  21. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
  22. package/dist/assets/Toggle-C0a-6xV7.js +1 -0
  23. package/dist/assets/index-CO_7DroC.js +3066 -0
  24. package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
  25. package/dist/assets/listSkills-QORduPIk.js +1 -0
  26. package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
  27. package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -3
  30. package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
  31. package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
  32. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
  33. package/src/main/__tests__/runLogRetention.test.cjs +343 -0
  34. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  35. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  36. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  37. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  38. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  39. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  40. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  41. package/src/main/config.cjs +32 -29
  42. package/src/main/files.cjs +20 -0
  43. package/src/main/health.cjs +32 -0
  44. package/src/main/heapSnapshot.cjs +122 -0
  45. package/src/main/historyAggregator.cjs +176 -36
  46. package/src/main/index.cjs +10 -63
  47. package/src/main/ipcSchemas.cjs +20 -209
  48. package/src/main/lib/opsOwnership.cjs +0 -2
  49. package/src/main/lib/runLogRetention.cjs +358 -0
  50. package/src/main/lib/summarize.cjs +0 -1
  51. package/src/main/runVerify.cjs +55 -6
  52. package/src/main/scheduler.cjs +46 -7
  53. package/src/main/transcripts.cjs +29 -5
  54. package/src/preload/api.d.ts +18 -199
  55. package/src/preload/index.cjs +12 -102
  56. package/dist/assets/index-BfiMRkln.js +0 -3225
  57. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  58. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  59. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  60. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  61. package/src/main/browserAgentServer.cjs +0 -224
  62. package/src/main/browserCapture.cjs +0 -728
  63. package/src/main/browserView.cjs +0 -916
  64. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  65. package/src/main/lib/browserAgentActions.cjs +0 -114
  66. package/src/main/lib/e2eStateMachine.cjs +0 -39
  67. package/src/main/webRemote.cjs +0 -1367
  68. package/src/preload/browserViewPreload.cjs +0 -118
@@ -1,916 +0,0 @@
1
- /**
2
- * Owns the native WebContentsView(s) that embed real web content inside the
3
- * Browser tab. `<webview>` and iframes are blocked app-wide (see index.cjs
4
- * will-attach-webview + CSP frame-src 'none'), so this is the only way to
5
- * show remote content: a WebContentsView is a sibling layer the main process
6
- * positions directly on top of the renderer's BrowserWindow, keyed by a
7
- * renderer-generated viewId.
8
- *
9
- * PRD 399 created/positioned/showed/hid/destroyed the view. This PRD (400)
10
- * adds real navigation: browser:navigate/back/forward/reload/stop, nav-state
11
- * broadcasts, the will-navigate nav-lock exemption (index.cjs allows this
12
- * view's webContents to navigate anywhere while the main window stays
13
- * locked), external-link routing, and download blocking. Bounds reporting
14
- * from a ResizeObserver remains out of scope (PRD 401).
15
- */
16
-
17
- const path = require('path');
18
- const os = require('os');
19
- const fs = require('fs');
20
- const crypto = require('crypto');
21
- const { WebContentsView } = require('electron');
22
- const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
23
- const { readJson, writeJson } = require('./config.cjs');
24
- const {
25
- parseKeyCombo,
26
- computeScrollDelta,
27
- computeScreenshotScale,
28
- } = require('./lib/browserAgentActions.cjs');
29
-
30
- // ── History + zoom persistence (PRD 402) ──────────────────────────────
31
- // Both files live under ~/.claude/session-manager/browser/, inside
32
- // config.cjs's WRITE_PREFIXES allowlist, and are written through
33
- // config.cjs's writeJson (tmp+rename atomic write) — never re-implemented.
34
- const BROWSER_DATA_DIR = path.join(os.homedir(), '.claude', 'session-manager', 'browser');
35
- const HISTORY_PATH = path.join(BROWSER_DATA_DIR, 'history.json');
36
- const ZOOM_PATH = path.join(BROWSER_DATA_DIR, 'zoom.json');
37
- const HISTORY_MAX = 500;
38
-
39
- // ── Identity-provider OAuth popup allowlist ───────────────────────────
40
- // window.open() popups from these hosts get a REAL child BrowserWindow
41
- // (setWindowOpenHandler returns `allow`) instead of the default deny —
42
- // Google's GIS/FedCM handshake (and equivalent flows from other providers)
43
- // needs a genuine window.opener to postMessage the credential back to.
44
- // Every other popup keeps the existing deny + browser:open-tab-request
45
- // broadcast. Add appleid.apple.com / login.microsoftonline.com / github.com
46
- // here when those flows need the same treatment — no restructuring needed.
47
- const IDENTITY_PROVIDER_HOSTS = ['accounts.google.com'];
48
-
49
- function isIdentityProviderUrl(rawUrl) {
50
- try {
51
- return IDENTITY_PROVIDER_HOSTS.includes(new URL(rawUrl).hostname);
52
- } catch {
53
- return false;
54
- }
55
- }
56
-
57
- // A realistic desktop Chrome UA built from this Electron build's OWN
58
- // Chromium version (process.versions.chrome) rather than a hardcoded string
59
- // that drifts stale — OAuth endpoints can flag Electron's default UA
60
- // ("… Electron/x.y.z …") under a disallowed_useragent policy for embedded
61
- // webviews.
62
- function buildChromeUserAgent() {
63
- const chromeVersion = process.versions.chrome;
64
- const platformToken = process.platform === 'win32'
65
- ? 'Windows NT 10.0; Win64; x64'
66
- : process.platform === 'darwin'
67
- ? 'Macintosh; Intel Mac OS X 10_15_7'
68
- : 'X11; Linux x86_64';
69
- return `Mozilla/5.0 (${platformToken}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeVersion} Safari/537.36`;
70
- }
71
-
72
- let lastZoomFactor = 1;
73
- // Startup-only, fixed constant path (not user input) — a plain sync read is
74
- // simpler than threading an async load through registerBrowserView's callers.
75
- function loadPersistedZoom() {
76
- try {
77
- const raw = fs.readFileSync(ZOOM_PATH, 'utf8');
78
- const data = JSON.parse(raw);
79
- if (data && typeof data.factor === 'number' && Number.isFinite(data.factor)) {
80
- lastZoomFactor = data.factor;
81
- }
82
- } catch {
83
- // no persisted zoom yet — keep default 1
84
- }
85
- }
86
-
87
- // Serializes read-modify-write cycles against history.json so concurrent
88
- // navigations across multiple sub-tabs can't race and drop entries.
89
- let historyWriteChain = Promise.resolve();
90
- function appendHistoryEntry({ url, title }) {
91
- if (!url || url === 'about:blank') return;
92
- historyWriteChain = historyWriteChain.then(async () => {
93
- try {
94
- const existing = await readJson(HISTORY_PATH);
95
- const list = Array.isArray(existing.data) ? existing.data : [];
96
- list.unshift({ url, title: title || '', ts: Date.now() });
97
- await writeJson(HISTORY_PATH, list.slice(0, HISTORY_MAX));
98
- } catch {
99
- // best-effort — history is a convenience, not load-bearing
100
- }
101
- });
102
- }
103
-
104
- /** @type {Map<string, WebContentsView>} */
105
- const views = new Map();
106
- // webContents.id of every browser-view's webContents — index.cjs's global
107
- // will-navigate handler consults this set to exempt browser views from the
108
- // main-window nav lock without weakening the lock itself.
109
- const browserViewContentsIds = new Set();
110
- let win = null;
111
- // Last viewId shown via show() — the renderer's proxy for "which sub-tab is
112
- // active", since WebContentsView exposes setVisible() but no getter. Used to
113
- // default viewId-optional browser-agent-server routes to the active tab.
114
- let lastShownViewId = null;
115
-
116
- // ── Recorder engine (PRD 408) ─────────────────────────────────────────
117
- // viewId <-> webContents.id so the record-event channel (sent from the
118
- // page's isolated-preload bridge, which only knows its own sender) can be
119
- // routed back to the right viewId's step stream.
120
- const contentsIdToViewId = new Map();
121
- const recordingViewIds = new Set();
122
- const stepCounters = new Map();
123
- // contextBridge.exposeInMainWorld puts the recorder toggle in the EMBEDDED
124
- // PAGE's own JS context — an adversarial site being recorded could call it
125
- // directly. The actual step-forwarding gate is `recordingViewIds` (only this
126
- // module can set it), so a rogue page can't forge/exfiltrate anything, but it
127
- // could still pause a legitimate session out from under the user. Close that
128
- // with a per-view secret the page can't read: generated here, handed to the
129
- // preload via `additionalArguments`/`process.argv` (never touches the DOM or
130
- // a script tag the page could inspect), and required on every toggle call.
131
- const recordTokens = new Map();
132
-
133
- // viewId -> last search text passed to findInPage, so repeat calls with the
134
- // same text advance to the next/previous match (findNext: true) while a new
135
- // search term restarts the highlight pass (findNext: false).
136
- const lastFindText = new Map();
137
-
138
- function emitRecordStep(viewId, partial) {
139
- const n = (stepCounters.get(viewId) || 0) + 1;
140
- stepCounters.set(viewId, n);
141
- sendIfAlive(win, `browser:record-step:${viewId}`, { n, ...partial });
142
- }
143
-
144
- function attachWindow(mainWindow) {
145
- win = mainWindow;
146
- }
147
-
148
- function isBrowserViewContents(id) {
149
- return browserViewContentsIds.has(id);
150
- }
151
-
152
- function normalizeUrl(rawUrl) {
153
- let url = String(rawUrl || '').trim();
154
- if (!url) return { ok: false, error: 'empty url' };
155
- if (/^https?:\/\//i.test(url)) {
156
- // already has an allowed scheme
157
- } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
158
- // has some other scheme with an authority (file://, chrome://, etc.) — reject
159
- return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
160
- } else if (/^javascript:/i.test(url) || /^data:/i.test(url)) {
161
- // schemes with no "//" authority that are still dangerous — reject explicitly
162
- return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
163
- } else {
164
- // bare host[:port]/path — assume https
165
- url = `https://${url}`;
166
- }
167
- if (!/^https:\/\//i.test(url) && !/^http:\/\//i.test(url)) {
168
- return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
169
- }
170
- // Upgrade http:// to https:// per AC (loadURL still allowed to redirect
171
- // back down if the site truly has no https, but we always request https first).
172
- if (/^http:\/\//i.test(url)) {
173
- url = `https://${url.slice('http://'.length)}`;
174
- }
175
- return { ok: true, url };
176
- }
177
-
178
- function broadcastNavState(viewId, view) {
179
- if (!view || view.webContents.isDestroyed()) return;
180
- const wc = view.webContents;
181
- const url = wc.getURL();
182
- sendIfAlive(win, `browser:nav-state:${viewId}`, {
183
- url,
184
- title: wc.getTitle(),
185
- canGoBack: wc.navigationHistory ? wc.navigationHistory.canGoBack() : wc.canGoBack(),
186
- canGoForward: wc.navigationHistory ? wc.navigationHistory.canGoForward() : wc.canGoForward(),
187
- loading: wc.isLoading(),
188
- isSecure: /^https:/i.test(url),
189
- });
190
- }
191
-
192
- function wireNavEvents(viewId, view) {
193
- const wc = view.webContents;
194
- const emit = () => broadcastNavState(viewId, view);
195
-
196
- wc.on('did-start-navigation', emit);
197
- wc.on('did-navigate', (_e, url) => {
198
- emit();
199
- // wc.getTitle() here still reports the PREVIOUS page's title — the new
200
- // page's <title> hasn't parsed yet. Wait one tick for dom-ready so the
201
- // history entry gets the title of the page actually navigated to.
202
- wc.once('dom-ready', () => appendHistoryEntry({ url, title: wc.getTitle() }));
203
- if (recordingViewIds.has(viewId)) emitRecordStep(viewId, { verb: 'navigate', target: url });
204
- });
205
- wc.on('did-navigate-in-page', emit);
206
- wc.on('page-title-updated', emit);
207
- wc.on('did-fail-load', (_e, errorCode) => {
208
- // -3 is ERR_ABORTED — fires on routine cancelled/superseded loads, not a
209
- // real failure. Skip so the renderer doesn't show a noisy error banner.
210
- if (errorCode === -3) return;
211
- emit();
212
- });
213
- // The recorder-preload's `capturing` flag lives in the page's JS context,
214
- // which is torn down on every navigation — re-arm it after each load so a
215
- // recording session survives the page it started on navigating away.
216
- wc.on('did-finish-load', () => {
217
- if (!recordingViewIds.has(viewId)) return;
218
- const token = recordTokens.get(viewId);
219
- wc.executeJavaScript(
220
- `window.__smRecorder && window.__smRecorder.setRecording(true, ${JSON.stringify(token)})`,
221
- ).catch(() => {});
222
- });
223
-
224
- wc.setWindowOpenHandler(({ url }) => {
225
- if (isIdentityProviderUrl(url)) {
226
- return {
227
- action: 'allow',
228
- overrideBrowserWindowOptions: {
229
- webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
230
- },
231
- };
232
- }
233
- if (/^https?:\/\//i.test(url)) {
234
- sendIfAlive(win, 'browser:open-tab-request', { url });
235
- }
236
- return { action: 'deny' };
237
- });
238
-
239
- // Fires only when setWindowOpenHandler above returned `allow` — i.e. only
240
- // for identity-provider popups. Sets a real Chrome UA on the popup (same
241
- // reasoning as the main view below) and registers its webContents.id in
242
- // browserViewContentsIds so index.cjs's global will-navigate lock exempts
243
- // it too — an OAuth popup needs to navigate through several redirects
244
- // (consent screen, callback) exactly like the main Browser view already
245
- // does, otherwise the very handshake this fix enables would immediately
246
- // get nav-locked shut. Cleaned up on the popup's own 'destroyed' event,
247
- // following create()'s `view.webContents.once('destroyed', ...)` pattern
248
- // so nothing dangles once the popup closes.
249
- wc.on('did-create-window', (childWindow) => {
250
- const childWc = childWindow.webContents;
251
- childWc.setUserAgent(buildChromeUserAgent());
252
- browserViewContentsIds.add(childWc.id);
253
- childWc.once('destroyed', () => {
254
- browserViewContentsIds.delete(childWc.id);
255
- });
256
- });
257
-
258
- wc.session.on('will-download', (event) => {
259
- event.preventDefault();
260
- });
261
-
262
- wc.on('found-in-page', (_e, result) => {
263
- sendIfAlive(win, `browser:find-result:${viewId}`, {
264
- requestId: result.requestId,
265
- matches: result.matches,
266
- activeMatchOrdinal: result.activeMatchOrdinal,
267
- });
268
- });
269
- }
270
-
271
- function create({ viewId, partition }) {
272
- if (views.has(viewId)) return { ok: true };
273
- const recordToken = crypto.randomBytes(16).toString('hex');
274
- recordTokens.set(viewId, recordToken);
275
- const view = new WebContentsView({
276
- webPreferences: {
277
- contextIsolation: true,
278
- nodeIntegration: false,
279
- sandbox: true,
280
- partition,
281
- preload: path.join(__dirname, '..', 'preload', 'browserViewPreload.cjs'),
282
- additionalArguments: [`--sm-record-token=${recordToken}`],
283
- },
284
- });
285
- views.set(viewId, view);
286
- const wcId = view.webContents.id;
287
- browserViewContentsIds.add(wcId);
288
- contentsIdToViewId.set(wcId, viewId);
289
- view.webContents.setZoomFactor(lastZoomFactor);
290
- view.webContents.setUserAgent(buildChromeUserAgent());
291
- wireNavEvents(viewId, view);
292
- view.webContents.once('destroyed', () => {
293
- browserViewContentsIds.delete(wcId);
294
- contentsIdToViewId.delete(wcId);
295
- recordingViewIds.delete(viewId);
296
- stepCounters.delete(viewId);
297
- recordTokens.delete(viewId);
298
- lastFindText.delete(viewId);
299
- });
300
- if (win && !win.isDestroyed()) {
301
- win.contentView.addChildView(view);
302
- }
303
- return { ok: true };
304
- }
305
-
306
- function setBounds({ viewId, x, y, width, height }) {
307
- const view = views.get(viewId);
308
- if (!view) return { ok: false };
309
- view.setBounds({
310
- x: Math.round(x),
311
- y: Math.round(y),
312
- width: Math.round(width),
313
- height: Math.round(height),
314
- });
315
- return { ok: true };
316
- }
317
-
318
- function show({ viewId }) {
319
- const view = views.get(viewId);
320
- if (!view) return { ok: false };
321
- view.setVisible(true);
322
- lastShownViewId = viewId;
323
- return { ok: true };
324
- }
325
-
326
- function hide({ viewId }) {
327
- const view = views.get(viewId);
328
- if (!view) return { ok: false };
329
- view.setVisible(false);
330
- return { ok: true };
331
- }
332
-
333
- function destroy({ viewId }) {
334
- const view = views.get(viewId);
335
- if (!view) return { ok: true };
336
- browserViewContentsIds.delete(view.webContents.id);
337
- contentsIdToViewId.delete(view.webContents.id);
338
- recordingViewIds.delete(viewId);
339
- stepCounters.delete(viewId);
340
- lastFindText.delete(viewId);
341
- if (lastShownViewId === viewId) lastShownViewId = null;
342
- if (win && !win.isDestroyed()) {
343
- try { win.contentView.removeChildView(view); } catch { /* already detached */ }
344
- }
345
- try { view.webContents.close(); } catch { /* already closed */ }
346
- views.delete(viewId);
347
- return { ok: true };
348
- }
349
-
350
- // PRD 402: address-bar zoom control. Factor is clamped to Chromium's own
351
- // findInPage-adjacent zoom range and persisted so the next-opened tab (and
352
- // next app launch, via loadPersistedZoom) starts at the last value used.
353
- function setZoom({ viewId, factor }) {
354
- const view = views.get(viewId);
355
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
356
- const clamped = Math.min(3, Math.max(0.25, Number(factor) || 1));
357
- view.webContents.setZoomFactor(clamped);
358
- lastZoomFactor = clamped;
359
- writeJson(ZOOM_PATH, { factor: clamped }).catch(() => {});
360
- return { ok: true, factor: clamped };
361
- }
362
-
363
- // PRD 402: Cmd/Ctrl+F find bar. Empty text clears the search instead of
364
- // running an empty findInPage query (Electron throws on '').
365
- function find({ viewId, text, forward }) {
366
- const view = views.get(viewId);
367
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
368
- if (!text) {
369
- view.webContents.stopFindInPage('clearSelection');
370
- lastFindText.delete(viewId);
371
- return { ok: true };
372
- }
373
- const isRepeat = lastFindText.get(viewId) === text;
374
- lastFindText.set(viewId, text);
375
- view.webContents.findInPage(text, { forward: forward !== false, findNext: isRepeat });
376
- return { ok: true };
377
- }
378
-
379
- function stopFind({ viewId }) {
380
- const view = views.get(viewId);
381
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
382
- view.webContents.stopFindInPage('clearSelection');
383
- lastFindText.delete(viewId);
384
- return { ok: true };
385
- }
386
-
387
- function recordStart({ viewId }) {
388
- const view = views.get(viewId);
389
- if (!view) return { ok: false, error: 'unknown viewId' };
390
- recordingViewIds.add(viewId);
391
- stepCounters.set(viewId, 0);
392
- const token = recordTokens.get(viewId);
393
- view.webContents
394
- .executeJavaScript(`window.__smRecorder && window.__smRecorder.setRecording(true, ${JSON.stringify(token)})`)
395
- .catch(() => {});
396
- return { ok: true };
397
- }
398
-
399
- function recordStop({ viewId }) {
400
- recordingViewIds.delete(viewId);
401
- const view = views.get(viewId);
402
- if (view && !view.webContents.isDestroyed()) {
403
- const token = recordTokens.get(viewId);
404
- view.webContents
405
- .executeJavaScript(`window.__smRecorder && window.__smRecorder.setRecording(false, ${JSON.stringify(token)})`)
406
- .catch(() => {});
407
- }
408
- return { ok: true };
409
- }
410
-
411
- // Sent by the recorder-preload's contextBridge (never the page itself — the
412
- // page has no node/ipcRenderer access under sandbox+contextIsolation). Only
413
- // forwarded while the view is actually in a recording session, and only the
414
- // two verbs the preload emits; typed values are never included (privacy
415
- // invariant — "sandboxed · no filesystem · no passwords").
416
- function handleRecordEvent(event, payload) {
417
- const viewId = contentsIdToViewId.get(event.sender.id);
418
- if (!viewId || !recordingViewIds.has(viewId)) return;
419
- const verb = payload && payload.verb;
420
- if (verb !== 'click' && verb !== 'type' && verb !== 'drag') return;
421
- const target = typeof payload.target === 'string' ? payload.target.slice(0, 300) : '';
422
- const step = { verb, target };
423
- if (verb === 'type') {
424
- step.masked = true;
425
- if (typeof payload.variableSuggestion === 'string') {
426
- step.variableSuggestion = payload.variableSuggestion.slice(0, 64);
427
- }
428
- }
429
- if (verb === 'click') {
430
- const x = Number(payload.x);
431
- const y = Number(payload.y);
432
- if (Number.isFinite(x)) step.x = x;
433
- if (Number.isFinite(y)) step.y = y;
434
- }
435
- if (verb === 'drag') {
436
- if (typeof payload.endTarget === 'string') step.endTarget = payload.endTarget.slice(0, 300);
437
- for (const key of ['x', 'y', 'endX', 'endY']) {
438
- const n = Number(payload[key]);
439
- if (Number.isFinite(n)) step[key] = n;
440
- }
441
- }
442
- emitRecordStep(viewId, step);
443
- }
444
-
445
- // ── Replay engine (PRD 410) ───────────────────────────────────────────
446
- // Each step is bounded so a bad/stale selector can't hang the run; the
447
- // caller supplies `steps` (renderer owns the recorded step list — the main
448
- // process never persists them), so replay is stateless between calls.
449
- const REPLAY_STEP_TIMEOUT_MS = 10_000;
450
- const REPLAY_WAIT_POLL_MS = 250;
451
-
452
- function withTimeout(promise, ms, label) {
453
- let timer;
454
- const timeout = new Promise((_resolve, reject) => {
455
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
456
- });
457
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
458
- }
459
-
460
- async function replayPositionedClick(wc, x, y) {
461
- wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
462
- wc.sendInputEvent({ type: 'mouseUp', x, y, button: 'left', clickCount: 1 });
463
- }
464
-
465
- async function replayClickOrType(wc, step, values) {
466
- if (step.verb === 'click' && Number.isFinite(step.x) && Number.isFinite(step.y)) {
467
- await replayPositionedClick(wc, step.x, step.y);
468
- return;
469
- }
470
- const script =
471
- step.verb === 'type'
472
- ? `(() => {
473
- const el = document.querySelector(${JSON.stringify(step.target)});
474
- if (!el) return false;
475
- el.focus();
476
- // React (and similar frameworks) override the native value setter
477
- // to track state; assigning el.value directly leaves that tracked
478
- // state stale, so the framework's onChange never fires. Go through
479
- // the native prototype setter — the same workaround React Testing
480
- // Library / Playwright use — so the dispatched 'input' event is
481
- // seen as a real change.
482
- const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
483
- const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value');
484
- if (nativeSetter && nativeSetter.set) {
485
- nativeSetter.set.call(el, ${JSON.stringify(step.variable && values[step.variable] !== undefined ? String(values[step.variable]) : '')});
486
- } else {
487
- el.value = ${JSON.stringify(step.variable && values[step.variable] !== undefined ? String(values[step.variable]) : '')};
488
- }
489
- el.dispatchEvent(new Event('input', { bubbles: true }));
490
- el.dispatchEvent(new Event('change', { bubbles: true }));
491
- return true;
492
- })()`
493
- : step.verb === 'select'
494
- ? `(() => {
495
- const el = document.querySelector(${JSON.stringify(step.target)});
496
- if (!el) return false;
497
- el.value = ${JSON.stringify(step.value ?? '')};
498
- el.dispatchEvent(new Event('change', { bubbles: true }));
499
- return true;
500
- })()`
501
- : `(() => {
502
- const el = document.querySelector(${JSON.stringify(step.target)});
503
- if (!el) return false;
504
- el.click();
505
- return true;
506
- })()`;
507
- const found = await withTimeout(wc.executeJavaScript(script), REPLAY_STEP_TIMEOUT_MS, step.verb);
508
- if (!found) throw new Error(`selector not found: ${step.target}`);
509
- }
510
-
511
- async function replayWaitFor(wc, step) {
512
- const target = step.target;
513
- const deadline = Date.now() + REPLAY_STEP_TIMEOUT_MS;
514
- const checkScript = `(() => {
515
- try {
516
- return (document.body && document.body.innerText.includes(${JSON.stringify(target)})) || location.href.includes(${JSON.stringify(target)});
517
- } catch { return false; }
518
- })()`;
519
- for (;;) {
520
- const found = await wc.executeJavaScript(checkScript).catch(() => false);
521
- if (found) return;
522
- if (Date.now() >= deadline) throw new Error(`wait-for timed out: ${target}`);
523
- await new Promise((r) => setTimeout(r, REPLAY_WAIT_POLL_MS));
524
- }
525
- }
526
-
527
- const REPLAY_DRAG_STEPS = 3;
528
-
529
- async function replayDrag(wc, step) {
530
- const { x, y, endX, endY } = step;
531
- if (![x, y, endX, endY].every((n) => Number.isFinite(n))) {
532
- throw new Error('drag step missing coordinates');
533
- }
534
- wc.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
535
- for (let i = 1; i <= REPLAY_DRAG_STEPS; i += 1) {
536
- const t = i / REPLAY_DRAG_STEPS;
537
- wc.sendInputEvent({
538
- type: 'mouseMove',
539
- x: Math.round(x + (endX - x) * t),
540
- y: Math.round(y + (endY - y) * t),
541
- });
542
- }
543
- wc.sendInputEvent({ type: 'mouseUp', x: endX, y: endY, button: 'left', clickCount: 1 });
544
- }
545
-
546
- async function replayStep(view, step, values) {
547
- const wc = view.webContents;
548
- if (step.verb === 'navigate') {
549
- const normalized = normalizeUrl(step.target);
550
- if (!normalized.ok) throw new Error(normalized.error);
551
- await withTimeout(wc.loadURL(normalized.url), REPLAY_STEP_TIMEOUT_MS, 'navigate');
552
- return;
553
- }
554
- if (step.verb === 'click' || step.verb === 'type' || step.verb === 'select') {
555
- await replayClickOrType(wc, step, values);
556
- return;
557
- }
558
- if (step.verb === 'wait-for') {
559
- await replayWaitFor(wc, step);
560
- return;
561
- }
562
- if (step.verb === 'drag') {
563
- await withTimeout(replayDrag(wc, step), REPLAY_STEP_TIMEOUT_MS, 'drag');
564
- return;
565
- }
566
- throw new Error(`unsupported step verb: ${step.verb}`);
567
- }
568
-
569
- // `continueOnError` defaults to false: replay stops at the first failed step
570
- // (fail-fast, matches the transport's "pass/fail per step" model) — the
571
- // caller can opt into running every step regardless by passing `true`.
572
- //
573
- // Replayed navigations/clicks/types are synthetic but indistinguishable from
574
- // real user input to the recorder's own listeners (did-navigate,
575
- // browser:record-event) — without suppressing capture here, replaying a
576
- // session that's still "recording" (armed until stop, not just paused; see
577
- // browser.ts toggleRecordingPause) would re-append every replayed step back
578
- // into the step list. Drop the viewId out of recordingViewIds for the
579
- // duration of the run and restore its prior membership afterward.
580
- async function replay({ viewId, steps, values, continueOnError }) {
581
- const view = views.get(viewId);
582
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
583
- const stepList = Array.isArray(steps) ? steps : [];
584
- const resolvedValues = values || {};
585
- const wasRecording = recordingViewIds.has(viewId);
586
- recordingViewIds.delete(viewId);
587
- try {
588
- for (const step of stepList) {
589
- try {
590
- await replayStep(view, step, resolvedValues);
591
- sendIfAlive(win, `browser:replay-step:${viewId}`, { n: step.n, status: 'pass' });
592
- } catch (e) {
593
- const detail = e && e.message ? e.message : String(e);
594
- sendIfAlive(win, `browser:replay-step:${viewId}`, { n: step.n, status: 'fail', detail });
595
- if (!continueOnError) return { ok: true, stopped: true, failedAt: step.n };
596
- }
597
- }
598
- return { ok: true, stopped: false };
599
- } finally {
600
- if (wasRecording) recordingViewIds.add(viewId);
601
- }
602
- }
603
-
604
- function navigate({ viewId, url }) {
605
- const view = views.get(viewId);
606
- if (!view) return { ok: false, error: 'unknown viewId' };
607
- const normalized = normalizeUrl(url);
608
- if (!normalized.ok) return { ok: false, error: normalized.error };
609
- view.webContents.loadURL(normalized.url).catch(() => {
610
- // Load failures surface via did-fail-load → broadcastNavState; nothing
611
- // further to do here.
612
- });
613
- return { ok: true };
614
- }
615
-
616
- function back({ viewId }) {
617
- const view = views.get(viewId);
618
- if (!view) return { ok: false, error: 'unknown viewId' };
619
- const wc = view.webContents;
620
- const nav = wc.navigationHistory;
621
- const canGoBack = nav ? nav.canGoBack() : wc.canGoBack();
622
- if (!canGoBack) return { ok: false, error: 'cannot go back' };
623
- if (nav) nav.goBack(); else wc.goBack();
624
- return { ok: true };
625
- }
626
-
627
- function forward({ viewId }) {
628
- const view = views.get(viewId);
629
- if (!view) return { ok: false, error: 'unknown viewId' };
630
- const wc = view.webContents;
631
- const nav = wc.navigationHistory;
632
- const canGoForward = nav ? nav.canGoForward() : wc.canGoForward();
633
- if (!canGoForward) return { ok: false, error: 'cannot go forward' };
634
- if (nav) nav.goForward(); else wc.goForward();
635
- return { ok: true };
636
- }
637
-
638
- function reload({ viewId }) {
639
- const view = views.get(viewId);
640
- if (!view) return { ok: false, error: 'unknown viewId' };
641
- view.webContents.reload();
642
- return { ok: true };
643
- }
644
-
645
- function stop({ viewId }) {
646
- const view = views.get(viewId);
647
- if (!view) return { ok: false, error: 'unknown viewId' };
648
- view.webContents.stop();
649
- return { ok: true };
650
- }
651
-
652
- // ── On-demand single-action agent API (PRD 535 foundation) ────────────
653
- // Modeled on Anthropic's published computer_use tool schema. Reuses the same
654
- // dispatch primitives the Recorder replay engine already established above
655
- // (replayPositionedClick for coordinate clicks, normalizeUrl/withTimeout for
656
- // navigate) instead of forking a second copy — the difference from replay()
657
- // is that this drives ONE ad-hoc action at a time by raw coordinate, not a
658
- // recorded `steps` array by selector.
659
- function listViews() {
660
- const result = [];
661
- for (const [viewId, view] of views.entries()) {
662
- if (view.webContents.isDestroyed()) continue;
663
- result.push({
664
- viewId,
665
- url: view.webContents.getURL(),
666
- title: view.webContents.getTitle(),
667
- active: viewId === lastShownViewId,
668
- });
669
- }
670
- return result;
671
- }
672
-
673
- function resolveViewId(viewId) {
674
- return viewId || lastShownViewId;
675
- }
676
-
677
- async function dispatchAction(view, params) {
678
- const wc = view.webContents;
679
- switch (params.action) {
680
- case 'left_click': {
681
- const [x, y] = Array.isArray(params.coordinate) ? params.coordinate : [];
682
- if (!Number.isFinite(x) || !Number.isFinite(y)) {
683
- throw new Error('left_click requires coordinate: [x, y]');
684
- }
685
- await replayPositionedClick(wc, x, y);
686
- return { ok: true };
687
- }
688
- case 'type': {
689
- if (typeof params.text !== 'string') throw new Error('type requires text');
690
- await wc.insertText(params.text);
691
- return { ok: true };
692
- }
693
- case 'key': {
694
- if (typeof params.key !== 'string' || !params.key) throw new Error('key requires key');
695
- const { keyCode, modifiers } = parseKeyCombo(params.key);
696
- wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers });
697
- if (keyCode.length === 1) wc.sendInputEvent({ type: 'char', keyCode, modifiers });
698
- wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers });
699
- return { ok: true };
700
- }
701
- case 'scroll': {
702
- const [x, y] = Array.isArray(params.coordinate) ? params.coordinate : [0, 0];
703
- const { deltaX, deltaY } = computeScrollDelta(params);
704
- wc.sendInputEvent({ type: 'mouseWheel', x: Number(x) || 0, y: Number(y) || 0, deltaX, deltaY });
705
- return { ok: true };
706
- }
707
- case 'navigate': {
708
- if (typeof params.url !== 'string' || !params.url) throw new Error('navigate requires url');
709
- const normalized = normalizeUrl(params.url);
710
- if (!normalized.ok) throw new Error(normalized.error);
711
- await withTimeout(wc.loadURL(normalized.url), REPLAY_STEP_TIMEOUT_MS, 'navigate');
712
- return { ok: true };
713
- }
714
- default:
715
- throw new Error(`unsupported action: ${params.action}`);
716
- }
717
- }
718
-
719
- // Single-action entry point (as opposed to replay()'s recorded-steps-array
720
- // entry point) — resolves viewId (defaulting to the active tab), dispatches
721
- // exactly one action, and normalizes errors into the { ok, error } shape the
722
- // rest of this module already uses.
723
- async function dispatchViewAction(params) {
724
- const viewId = resolveViewId(params.viewId);
725
- if (!viewId) return { ok: false, error: 'no viewId given and no active tab' };
726
- const view = views.get(viewId);
727
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
728
- try {
729
- return await dispatchAction(view, params);
730
- } catch (e) {
731
- return { ok: false, error: e && e.message ? e.message : String(e) };
732
- }
733
- }
734
-
735
- // On-demand screenshot for the agent API. Reuses wc.capturePage() (same call
736
- // captureShot() below already uses) but additionally resizes to a fixed
737
- // logical resolution — Anthropic's computer-use API silently downscales
738
- // oversized screenshots server-side, which breaks coordinate mapping unless
739
- // caller and model agree on one fixed max dimension. `scale` in the response
740
- // lets a caller map a coordinate clicked on the returned image back to the
741
- // real page: realX = clickedX / scale.
742
- async function captureShotForAgent({ viewId } = {}) {
743
- const id = resolveViewId(viewId);
744
- if (!id) return { ok: false, error: 'no viewId given and no active tab' };
745
- const view = views.get(id);
746
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
747
- try {
748
- const wc = view.webContents;
749
- const image = await wc.capturePage();
750
- const { width, height } = image.getSize();
751
- const target = computeScreenshotScale(width, height);
752
- const resized = target.scale < 1 ? image.resize({ width: target.width, height: target.height }) : image;
753
- return {
754
- ok: true,
755
- viewId: id,
756
- url: wc.getURL(),
757
- title: wc.getTitle(),
758
- dataUrl: resized.toDataURL(),
759
- scale: target.scale,
760
- width: target.width,
761
- height: target.height,
762
- originalWidth: width,
763
- originalHeight: height,
764
- };
765
- } catch (e) {
766
- return { ok: false, error: e && e.message ? e.message : String(e) };
767
- }
768
- }
769
-
770
- // Cheap text/accessibility-style snapshot for the agent API — an alternative
771
- // to vision-only driving since a real DOM is available here. Follows the
772
- // same executeJavaScript sandboxing pattern as captureDom() below (plain
773
- // IIFE returning a plain object, no new capability granted to the page).
774
- // Kept cheap on purpose: innerText + a capped list of interactive elements
775
- // with center-point coordinates, no full HTML serialization or computed
776
- // style walking.
777
- const AGENT_DOM_TEXT_MAX = 100_000;
778
- const AGENT_DOM_ELEMENT_LIMIT = 200;
779
-
780
- async function captureAgentDom({ viewId } = {}) {
781
- const id = resolveViewId(viewId);
782
- if (!id) return { ok: false, error: 'no viewId given and no active tab' };
783
- const view = views.get(id);
784
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
785
- try {
786
- const script = `(() => {
787
- const SEL = 'button, a[href], input, textarea, select, [role="button"], [onclick]';
788
- const els = Array.from(document.querySelectorAll(SEL)).slice(0, ${AGENT_DOM_ELEMENT_LIMIT});
789
- const elements = els.map((el) => {
790
- const rect = el.getBoundingClientRect();
791
- const label = (el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').trim().slice(0, 120);
792
- return {
793
- tag: el.tagName.toLowerCase(),
794
- label,
795
- x: Math.round(rect.left + rect.width / 2),
796
- y: Math.round(rect.top + rect.height / 2),
797
- };
798
- }).filter((e) => e.x >= 0 && e.y >= 0 && Number.isFinite(e.x) && Number.isFinite(e.y));
799
- return {
800
- url: location.href,
801
- title: document.title,
802
- text: document.body ? document.body.innerText : '',
803
- elements,
804
- };
805
- })()`;
806
- const result = await view.webContents.executeJavaScript(script);
807
- let text = typeof result?.text === 'string' ? result.text : '';
808
- let truncated = false;
809
- if (text.length > AGENT_DOM_TEXT_MAX) {
810
- text = text.slice(0, AGENT_DOM_TEXT_MAX);
811
- truncated = true;
812
- }
813
- return {
814
- ok: true,
815
- viewId: id,
816
- url: result?.url || '',
817
- title: result?.title || '',
818
- text,
819
- truncated,
820
- elements: Array.isArray(result?.elements) ? result.elements : [],
821
- };
822
- } catch (e) {
823
- return { ok: false, error: e && e.message ? e.message : String(e) };
824
- }
825
- }
826
-
827
- // Cap returned capture text so a huge DOM can't blow up the IPC channel.
828
- const CAPTURE_TEXT_MAX = 500_000;
829
-
830
- // PRD 407: grab page text/HTML for the Capture panel. Reuses the same
831
- // executeJavaScript access path the recorder-preload re-arm already uses
832
- // above (did-finish-load handler) — one round trip returns url/title/text
833
- // together instead of three separate calls.
834
- async function captureDom({ viewId, kind }) {
835
- const view = views.get(viewId);
836
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
837
- try {
838
- const script = kind === 'html'
839
- ? '({ url: location.href, title: document.title, text: document.documentElement.outerHTML })'
840
- : '({ url: location.href, title: document.title, text: document.body ? document.body.innerText : "" })';
841
- const result = await view.webContents.executeJavaScript(script);
842
- let text = typeof result?.text === 'string' ? result.text : '';
843
- let truncated = false;
844
- if (text.length > CAPTURE_TEXT_MAX) {
845
- text = text.slice(0, CAPTURE_TEXT_MAX);
846
- truncated = true;
847
- }
848
- return { ok: true, url: result?.url || '', title: result?.title || '', text, truncated };
849
- } catch (e) {
850
- return { ok: false, error: e && e.message ? e.message : String(e) };
851
- }
852
- }
853
-
854
- // PRD 407: screenshot the active browser sub-tab as a PNG data URL.
855
- async function captureShot({ viewId }) {
856
- const view = views.get(viewId);
857
- if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
858
- try {
859
- const wc = view.webContents;
860
- const image = await wc.capturePage();
861
- const dataUrl = image.toDataURL();
862
- return { ok: true, url: wc.getURL(), title: wc.getTitle(), dataUrl };
863
- } catch (e) {
864
- return { ok: false, error: e && e.message ? e.message : String(e) };
865
- }
866
- }
867
-
868
- // PRD 403: getter passed to browserCapture.cjs's registerBrowserCapture so
869
- // the picker can reach a live WebContentsView without this module exposing
870
- // its whole `views` Map as a mutable dependency surface.
871
- function getView(viewId) {
872
- return views.get(viewId);
873
- }
874
-
875
- function registerBrowserView({ mainWindow, ipcMain }) {
876
- attachWindow(mainWindow);
877
- loadPersistedZoom();
878
- const { schemas, validated } = require('./ipcSchemas.cjs');
879
- ipcMain.handle('browser:create', validated(schemas.browserCreate, (payload) => create(payload)));
880
- ipcMain.handle('browser:set-bounds', validated(schemas.browserSetBounds, (payload) => setBounds(payload)));
881
- ipcMain.handle('browser:show', validated(schemas.browserViewId, (payload) => show(payload)));
882
- ipcMain.handle('browser:hide', validated(schemas.browserViewId, (payload) => hide(payload)));
883
- ipcMain.handle('browser:destroy', validated(schemas.browserViewId, (payload) => destroy(payload)));
884
- ipcMain.handle('browser:navigate', validated(schemas.browserNavigate, (payload) => navigate(payload)));
885
- ipcMain.handle('browser:back', validated(schemas.browserViewId, (payload) => back(payload)));
886
- ipcMain.handle('browser:forward', validated(schemas.browserViewId, (payload) => forward(payload)));
887
- ipcMain.handle('browser:reload', validated(schemas.browserViewId, (payload) => reload(payload)));
888
- ipcMain.handle('browser:stop', validated(schemas.browserViewId, (payload) => stop(payload)));
889
- ipcMain.handle('browser:record-start', validated(schemas.browserViewId, (payload) => recordStart(payload)));
890
- ipcMain.handle('browser:record-stop', validated(schemas.browserViewId, (payload) => recordStop(payload)));
891
- ipcMain.on('browser:record-event', handleRecordEvent);
892
- ipcMain.handle('browser:replay', validated(schemas.browserReplay, (payload) => replay(payload)));
893
- ipcMain.handle('browser:capture-dom', validated(schemas.browserCaptureDom, (payload) => captureDom(payload)));
894
- ipcMain.handle('browser:capture-shot', validated(schemas.browserViewId, (payload) => captureShot(payload)));
895
- ipcMain.handle('browser:set-zoom', validated(schemas.browserSetZoom, (payload) => setZoom(payload)));
896
- ipcMain.handle('browser:find', validated(schemas.browserFind, (payload) => find(payload)));
897
- ipcMain.handle('browser:stop-find', validated(schemas.browserViewId, (payload) => stopFind(payload)));
898
- ipcMain.handle('browser:save-binary', validated(schemas.browserSaveBinary, (payload) => {
899
- const { writeBinaryAtomic } = require('./config.cjs');
900
- return writeBinaryAtomic(payload.path, Buffer.from(payload.base64, 'base64'), { writer: payload.writer })
901
- .then(() => ({ ok: true }))
902
- .catch((e) => ({ ok: false, error: e && e.message ? e.message : String(e) }));
903
- }));
904
- }
905
-
906
- module.exports = {
907
- registerBrowserView,
908
- attachWindow,
909
- views,
910
- isBrowserViewContents,
911
- getView,
912
- listViews,
913
- dispatchViewAction,
914
- captureShotForAgent,
915
- captureAgentDom,
916
- };