claude-code-session-manager 0.35.2 → 0.35.3

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 (31) hide show
  1. package/dist/assets/{TiptapBody-CIEmzy3k.js → TiptapBody-DmCekkUQ.js} +1 -1
  2. package/dist/assets/index-CHKMzzCM.js +3558 -0
  3. package/dist/assets/index-DVqmrWP3.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -2
  6. package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
  8. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +83 -0
  9. package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
  10. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +10 -6
  11. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +3 -2
  12. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +12 -7
  13. package/src/main/__tests__/adminServer.test.cjs +175 -0
  14. package/src/main/__tests__/runVerify.test.cjs +74 -4
  15. package/src/main/adminServer.cjs +157 -0
  16. package/src/main/browserCapture.cjs +714 -0
  17. package/src/main/browserView.cjs +613 -0
  18. package/src/main/config.cjs +37 -1
  19. package/src/main/historyAggregator.cjs +92 -6
  20. package/src/main/index.cjs +32 -3
  21. package/src/main/ipcSchemas.cjs +106 -0
  22. package/src/main/lib/schedulerConfig.cjs +6 -2
  23. package/src/main/runVerify.cjs +14 -1
  24. package/src/main/scheduler.cjs +6 -1
  25. package/src/main/sessionsStore.cjs +4 -3
  26. package/src/preload/api.d.ts +129 -5
  27. package/src/preload/browserViewPreload.cjs +67 -0
  28. package/src/preload/index.cjs +55 -5
  29. package/dist/assets/index-2Om-ouz6.js +0 -3534
  30. package/dist/assets/index-DeIJ8SM5.css +0 -32
  31. package/src/main/docEditor.cjs +0 -92
@@ -0,0 +1,613 @@
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, shell } = require('electron');
22
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
23
+ const { readJson, writeJson } = require('./config.cjs');
24
+
25
+ // ── History + zoom persistence (PRD 402) ──────────────────────────────
26
+ // Both files live under ~/.claude/session-manager/browser/, inside
27
+ // config.cjs's WRITE_PREFIXES allowlist, and are written through
28
+ // config.cjs's writeJson (tmp+rename atomic write) — never re-implemented.
29
+ const BROWSER_DATA_DIR = path.join(os.homedir(), '.claude', 'session-manager', 'browser');
30
+ const HISTORY_PATH = path.join(BROWSER_DATA_DIR, 'history.json');
31
+ const ZOOM_PATH = path.join(BROWSER_DATA_DIR, 'zoom.json');
32
+ const HISTORY_MAX = 500;
33
+
34
+ let lastZoomFactor = 1;
35
+ // Startup-only, fixed constant path (not user input) — a plain sync read is
36
+ // simpler than threading an async load through registerBrowserView's callers.
37
+ function loadPersistedZoom() {
38
+ try {
39
+ const raw = fs.readFileSync(ZOOM_PATH, 'utf8');
40
+ const data = JSON.parse(raw);
41
+ if (data && typeof data.factor === 'number' && Number.isFinite(data.factor)) {
42
+ lastZoomFactor = data.factor;
43
+ }
44
+ } catch {
45
+ // no persisted zoom yet — keep default 1
46
+ }
47
+ }
48
+
49
+ // Serializes read-modify-write cycles against history.json so concurrent
50
+ // navigations across multiple sub-tabs can't race and drop entries.
51
+ let historyWriteChain = Promise.resolve();
52
+ function appendHistoryEntry({ url, title }) {
53
+ if (!url || url === 'about:blank') return;
54
+ historyWriteChain = historyWriteChain.then(async () => {
55
+ try {
56
+ const existing = await readJson(HISTORY_PATH);
57
+ const list = Array.isArray(existing.data) ? existing.data : [];
58
+ list.unshift({ url, title: title || '', ts: Date.now() });
59
+ await writeJson(HISTORY_PATH, list.slice(0, HISTORY_MAX));
60
+ } catch {
61
+ // best-effort — history is a convenience, not load-bearing
62
+ }
63
+ });
64
+ }
65
+
66
+ /** @type {Map<string, WebContentsView>} */
67
+ const views = new Map();
68
+ // webContents.id of every browser-view's webContents — index.cjs's global
69
+ // will-navigate handler consults this set to exempt browser views from the
70
+ // main-window nav lock without weakening the lock itself.
71
+ const browserViewContentsIds = new Set();
72
+ let win = null;
73
+
74
+ // ── Recorder engine (PRD 408) ─────────────────────────────────────────
75
+ // viewId <-> webContents.id so the record-event channel (sent from the
76
+ // page's isolated-preload bridge, which only knows its own sender) can be
77
+ // routed back to the right viewId's step stream.
78
+ const contentsIdToViewId = new Map();
79
+ const recordingViewIds = new Set();
80
+ const stepCounters = new Map();
81
+ // contextBridge.exposeInMainWorld puts the recorder toggle in the EMBEDDED
82
+ // PAGE's own JS context — an adversarial site being recorded could call it
83
+ // directly. The actual step-forwarding gate is `recordingViewIds` (only this
84
+ // module can set it), so a rogue page can't forge/exfiltrate anything, but it
85
+ // could still pause a legitimate session out from under the user. Close that
86
+ // with a per-view secret the page can't read: generated here, handed to the
87
+ // preload via `additionalArguments`/`process.argv` (never touches the DOM or
88
+ // a script tag the page could inspect), and required on every toggle call.
89
+ const recordTokens = new Map();
90
+
91
+ // viewId -> last search text passed to findInPage, so repeat calls with the
92
+ // same text advance to the next/previous match (findNext: true) while a new
93
+ // search term restarts the highlight pass (findNext: false).
94
+ const lastFindText = new Map();
95
+
96
+ function emitRecordStep(viewId, partial) {
97
+ const n = (stepCounters.get(viewId) || 0) + 1;
98
+ stepCounters.set(viewId, n);
99
+ sendIfAlive(win, `browser:record-step:${viewId}`, { n, ...partial });
100
+ }
101
+
102
+ function attachWindow(mainWindow) {
103
+ win = mainWindow;
104
+ }
105
+
106
+ function isBrowserViewContents(id) {
107
+ return browserViewContentsIds.has(id);
108
+ }
109
+
110
+ function normalizeUrl(rawUrl) {
111
+ let url = String(rawUrl || '').trim();
112
+ if (!url) return { ok: false, error: 'empty url' };
113
+ if (/^https?:\/\//i.test(url)) {
114
+ // already has an allowed scheme
115
+ } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
116
+ // has some other scheme with an authority (file://, chrome://, etc.) — reject
117
+ return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
118
+ } else if (/^javascript:/i.test(url) || /^data:/i.test(url)) {
119
+ // schemes with no "//" authority that are still dangerous — reject explicitly
120
+ return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
121
+ } else {
122
+ // bare host[:port]/path — assume https
123
+ url = `https://${url}`;
124
+ }
125
+ if (!/^https:\/\//i.test(url) && !/^http:\/\//i.test(url)) {
126
+ return { ok: false, error: `disallowed scheme in "${rawUrl}"` };
127
+ }
128
+ // Upgrade http:// to https:// per AC (loadURL still allowed to redirect
129
+ // back down if the site truly has no https, but we always request https first).
130
+ if (/^http:\/\//i.test(url)) {
131
+ url = `https://${url.slice('http://'.length)}`;
132
+ }
133
+ return { ok: true, url };
134
+ }
135
+
136
+ function broadcastNavState(viewId, view) {
137
+ if (!view || view.webContents.isDestroyed()) return;
138
+ const wc = view.webContents;
139
+ const url = wc.getURL();
140
+ sendIfAlive(win, `browser:nav-state:${viewId}`, {
141
+ url,
142
+ title: wc.getTitle(),
143
+ canGoBack: wc.navigationHistory ? wc.navigationHistory.canGoBack() : wc.canGoBack(),
144
+ canGoForward: wc.navigationHistory ? wc.navigationHistory.canGoForward() : wc.canGoForward(),
145
+ loading: wc.isLoading(),
146
+ isSecure: /^https:/i.test(url),
147
+ });
148
+ }
149
+
150
+ function wireNavEvents(viewId, view) {
151
+ const wc = view.webContents;
152
+ const emit = () => broadcastNavState(viewId, view);
153
+
154
+ wc.on('did-start-navigation', emit);
155
+ wc.on('did-navigate', (_e, url) => {
156
+ emit();
157
+ // wc.getTitle() here still reports the PREVIOUS page's title — the new
158
+ // page's <title> hasn't parsed yet. Wait one tick for dom-ready so the
159
+ // history entry gets the title of the page actually navigated to.
160
+ wc.once('dom-ready', () => appendHistoryEntry({ url, title: wc.getTitle() }));
161
+ if (recordingViewIds.has(viewId)) emitRecordStep(viewId, { verb: 'navigate', target: url });
162
+ });
163
+ wc.on('did-navigate-in-page', emit);
164
+ wc.on('page-title-updated', emit);
165
+ wc.on('did-fail-load', (_e, errorCode) => {
166
+ // -3 is ERR_ABORTED — fires on routine cancelled/superseded loads, not a
167
+ // real failure. Skip so the renderer doesn't show a noisy error banner.
168
+ if (errorCode === -3) return;
169
+ emit();
170
+ });
171
+ // The recorder-preload's `capturing` flag lives in the page's JS context,
172
+ // which is torn down on every navigation — re-arm it after each load so a
173
+ // recording session survives the page it started on navigating away.
174
+ wc.on('did-finish-load', () => {
175
+ if (!recordingViewIds.has(viewId)) return;
176
+ const token = recordTokens.get(viewId);
177
+ wc.executeJavaScript(
178
+ `window.__smRecorder && window.__smRecorder.setRecording(true, ${JSON.stringify(token)})`,
179
+ ).catch(() => {});
180
+ });
181
+
182
+ wc.setWindowOpenHandler(({ url }) => {
183
+ if (/^https?:\/\//i.test(url)) {
184
+ shell.openExternal(url).catch(() => {});
185
+ }
186
+ return { action: 'deny' };
187
+ });
188
+
189
+ wc.session.on('will-download', (event) => {
190
+ event.preventDefault();
191
+ });
192
+
193
+ wc.on('found-in-page', (_e, result) => {
194
+ sendIfAlive(win, `browser:find-result:${viewId}`, {
195
+ requestId: result.requestId,
196
+ matches: result.matches,
197
+ activeMatchOrdinal: result.activeMatchOrdinal,
198
+ });
199
+ });
200
+ }
201
+
202
+ function create({ viewId, partition }) {
203
+ if (views.has(viewId)) return { ok: true };
204
+ const recordToken = crypto.randomBytes(16).toString('hex');
205
+ recordTokens.set(viewId, recordToken);
206
+ const view = new WebContentsView({
207
+ webPreferences: {
208
+ contextIsolation: true,
209
+ nodeIntegration: false,
210
+ sandbox: true,
211
+ partition,
212
+ preload: path.join(__dirname, '..', 'preload', 'browserViewPreload.cjs'),
213
+ additionalArguments: [`--sm-record-token=${recordToken}`],
214
+ },
215
+ });
216
+ views.set(viewId, view);
217
+ browserViewContentsIds.add(view.webContents.id);
218
+ contentsIdToViewId.set(view.webContents.id, viewId);
219
+ view.webContents.setZoomFactor(lastZoomFactor);
220
+ wireNavEvents(viewId, view);
221
+ view.webContents.once('destroyed', () => {
222
+ browserViewContentsIds.delete(view.webContents.id);
223
+ contentsIdToViewId.delete(view.webContents.id);
224
+ recordingViewIds.delete(viewId);
225
+ stepCounters.delete(viewId);
226
+ recordTokens.delete(viewId);
227
+ lastFindText.delete(viewId);
228
+ });
229
+ if (win && !win.isDestroyed()) {
230
+ win.contentView.addChildView(view);
231
+ }
232
+ return { ok: true };
233
+ }
234
+
235
+ function setBounds({ viewId, x, y, width, height }) {
236
+ const view = views.get(viewId);
237
+ if (!view) return { ok: false };
238
+ view.setBounds({
239
+ x: Math.round(x),
240
+ y: Math.round(y),
241
+ width: Math.round(width),
242
+ height: Math.round(height),
243
+ });
244
+ return { ok: true };
245
+ }
246
+
247
+ function show({ viewId }) {
248
+ const view = views.get(viewId);
249
+ if (!view) return { ok: false };
250
+ view.setVisible(true);
251
+ return { ok: true };
252
+ }
253
+
254
+ function hide({ viewId }) {
255
+ const view = views.get(viewId);
256
+ if (!view) return { ok: false };
257
+ view.setVisible(false);
258
+ return { ok: true };
259
+ }
260
+
261
+ function destroy({ viewId }) {
262
+ const view = views.get(viewId);
263
+ if (!view) return { ok: true };
264
+ browserViewContentsIds.delete(view.webContents.id);
265
+ contentsIdToViewId.delete(view.webContents.id);
266
+ recordingViewIds.delete(viewId);
267
+ stepCounters.delete(viewId);
268
+ lastFindText.delete(viewId);
269
+ if (win && !win.isDestroyed()) {
270
+ try { win.contentView.removeChildView(view); } catch { /* already detached */ }
271
+ }
272
+ try { view.webContents.close(); } catch { /* already closed */ }
273
+ views.delete(viewId);
274
+ return { ok: true };
275
+ }
276
+
277
+ // PRD 402: address-bar zoom control. Factor is clamped to Chromium's own
278
+ // findInPage-adjacent zoom range and persisted so the next-opened tab (and
279
+ // next app launch, via loadPersistedZoom) starts at the last value used.
280
+ function setZoom({ viewId, factor }) {
281
+ const view = views.get(viewId);
282
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
283
+ const clamped = Math.min(3, Math.max(0.25, Number(factor) || 1));
284
+ view.webContents.setZoomFactor(clamped);
285
+ lastZoomFactor = clamped;
286
+ writeJson(ZOOM_PATH, { factor: clamped }).catch(() => {});
287
+ return { ok: true, factor: clamped };
288
+ }
289
+
290
+ // PRD 402: Cmd/Ctrl+F find bar. Empty text clears the search instead of
291
+ // running an empty findInPage query (Electron throws on '').
292
+ function find({ viewId, text, forward }) {
293
+ const view = views.get(viewId);
294
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
295
+ if (!text) {
296
+ view.webContents.stopFindInPage('clearSelection');
297
+ lastFindText.delete(viewId);
298
+ return { ok: true };
299
+ }
300
+ const isRepeat = lastFindText.get(viewId) === text;
301
+ lastFindText.set(viewId, text);
302
+ view.webContents.findInPage(text, { forward: forward !== false, findNext: isRepeat });
303
+ return { ok: true };
304
+ }
305
+
306
+ function stopFind({ viewId }) {
307
+ const view = views.get(viewId);
308
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
309
+ view.webContents.stopFindInPage('clearSelection');
310
+ lastFindText.delete(viewId);
311
+ return { ok: true };
312
+ }
313
+
314
+ function recordStart({ viewId }) {
315
+ const view = views.get(viewId);
316
+ if (!view) return { ok: false, error: 'unknown viewId' };
317
+ recordingViewIds.add(viewId);
318
+ stepCounters.set(viewId, 0);
319
+ const token = recordTokens.get(viewId);
320
+ view.webContents
321
+ .executeJavaScript(`window.__smRecorder && window.__smRecorder.setRecording(true, ${JSON.stringify(token)})`)
322
+ .catch(() => {});
323
+ return { ok: true };
324
+ }
325
+
326
+ function recordStop({ viewId }) {
327
+ recordingViewIds.delete(viewId);
328
+ const view = views.get(viewId);
329
+ if (view && !view.webContents.isDestroyed()) {
330
+ const token = recordTokens.get(viewId);
331
+ view.webContents
332
+ .executeJavaScript(`window.__smRecorder && window.__smRecorder.setRecording(false, ${JSON.stringify(token)})`)
333
+ .catch(() => {});
334
+ }
335
+ return { ok: true };
336
+ }
337
+
338
+ // Sent by the recorder-preload's contextBridge (never the page itself — the
339
+ // page has no node/ipcRenderer access under sandbox+contextIsolation). Only
340
+ // forwarded while the view is actually in a recording session, and only the
341
+ // two verbs the preload emits; typed values are never included (privacy
342
+ // invariant — "sandboxed · no filesystem · no passwords").
343
+ function handleRecordEvent(event, payload) {
344
+ const viewId = contentsIdToViewId.get(event.sender.id);
345
+ if (!viewId || !recordingViewIds.has(viewId)) return;
346
+ const verb = payload && payload.verb;
347
+ if (verb !== 'click' && verb !== 'type') return;
348
+ const target = typeof payload.target === 'string' ? payload.target.slice(0, 300) : '';
349
+ const step = { verb, target };
350
+ if (verb === 'type') {
351
+ step.masked = true;
352
+ if (typeof payload.variableSuggestion === 'string') {
353
+ step.variableSuggestion = payload.variableSuggestion.slice(0, 64);
354
+ }
355
+ }
356
+ emitRecordStep(viewId, step);
357
+ }
358
+
359
+ // ── Replay engine (PRD 410) ───────────────────────────────────────────
360
+ // Each step is bounded so a bad/stale selector can't hang the run; the
361
+ // caller supplies `steps` (renderer owns the recorded step list — the main
362
+ // process never persists them), so replay is stateless between calls.
363
+ const REPLAY_STEP_TIMEOUT_MS = 10_000;
364
+ const REPLAY_WAIT_POLL_MS = 250;
365
+
366
+ function withTimeout(promise, ms, label) {
367
+ let timer;
368
+ const timeout = new Promise((_resolve, reject) => {
369
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
370
+ });
371
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
372
+ }
373
+
374
+ async function replayClickOrType(wc, step, values) {
375
+ const script =
376
+ step.verb === 'type'
377
+ ? `(() => {
378
+ const el = document.querySelector(${JSON.stringify(step.target)});
379
+ if (!el) return false;
380
+ el.focus();
381
+ // React (and similar frameworks) override the native value setter
382
+ // to track state; assigning el.value directly leaves that tracked
383
+ // state stale, so the framework's onChange never fires. Go through
384
+ // the native prototype setter — the same workaround React Testing
385
+ // Library / Playwright use — so the dispatched 'input' event is
386
+ // seen as a real change.
387
+ const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
388
+ const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value');
389
+ if (nativeSetter && nativeSetter.set) {
390
+ nativeSetter.set.call(el, ${JSON.stringify(step.variable && values[step.variable] !== undefined ? String(values[step.variable]) : '')});
391
+ } else {
392
+ el.value = ${JSON.stringify(step.variable && values[step.variable] !== undefined ? String(values[step.variable]) : '')};
393
+ }
394
+ el.dispatchEvent(new Event('input', { bubbles: true }));
395
+ el.dispatchEvent(new Event('change', { bubbles: true }));
396
+ return true;
397
+ })()`
398
+ : step.verb === 'select'
399
+ ? `(() => {
400
+ const el = document.querySelector(${JSON.stringify(step.target)});
401
+ if (!el) return false;
402
+ el.value = ${JSON.stringify(step.value ?? '')};
403
+ el.dispatchEvent(new Event('change', { bubbles: true }));
404
+ return true;
405
+ })()`
406
+ : `(() => {
407
+ const el = document.querySelector(${JSON.stringify(step.target)});
408
+ if (!el) return false;
409
+ el.click();
410
+ return true;
411
+ })()`;
412
+ const found = await withTimeout(wc.executeJavaScript(script), REPLAY_STEP_TIMEOUT_MS, step.verb);
413
+ if (!found) throw new Error(`selector not found: ${step.target}`);
414
+ }
415
+
416
+ async function replayWaitFor(wc, step) {
417
+ const target = step.target;
418
+ const deadline = Date.now() + REPLAY_STEP_TIMEOUT_MS;
419
+ const checkScript = `(() => {
420
+ try {
421
+ return (document.body && document.body.innerText.includes(${JSON.stringify(target)})) || location.href.includes(${JSON.stringify(target)});
422
+ } catch { return false; }
423
+ })()`;
424
+ for (;;) {
425
+ const found = await wc.executeJavaScript(checkScript).catch(() => false);
426
+ if (found) return;
427
+ if (Date.now() >= deadline) throw new Error(`wait-for timed out: ${target}`);
428
+ await new Promise((r) => setTimeout(r, REPLAY_WAIT_POLL_MS));
429
+ }
430
+ }
431
+
432
+ async function replayStep(view, step, values) {
433
+ const wc = view.webContents;
434
+ if (step.verb === 'navigate') {
435
+ const normalized = normalizeUrl(step.target);
436
+ if (!normalized.ok) throw new Error(normalized.error);
437
+ await withTimeout(wc.loadURL(normalized.url), REPLAY_STEP_TIMEOUT_MS, 'navigate');
438
+ return;
439
+ }
440
+ if (step.verb === 'click' || step.verb === 'type' || step.verb === 'select') {
441
+ await replayClickOrType(wc, step, values);
442
+ return;
443
+ }
444
+ if (step.verb === 'wait-for') {
445
+ await replayWaitFor(wc, step);
446
+ return;
447
+ }
448
+ throw new Error(`unsupported step verb: ${step.verb}`);
449
+ }
450
+
451
+ // `continueOnError` defaults to false: replay stops at the first failed step
452
+ // (fail-fast, matches the transport's "pass/fail per step" model) — the
453
+ // caller can opt into running every step regardless by passing `true`.
454
+ //
455
+ // Replayed navigations/clicks/types are synthetic but indistinguishable from
456
+ // real user input to the recorder's own listeners (did-navigate,
457
+ // browser:record-event) — without suppressing capture here, replaying a
458
+ // session that's still "recording" (armed until stop, not just paused; see
459
+ // browser.ts toggleRecordingPause) would re-append every replayed step back
460
+ // into the step list. Drop the viewId out of recordingViewIds for the
461
+ // duration of the run and restore its prior membership afterward.
462
+ async function replay({ viewId, steps, values, continueOnError }) {
463
+ const view = views.get(viewId);
464
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
465
+ const stepList = Array.isArray(steps) ? steps : [];
466
+ const resolvedValues = values || {};
467
+ const wasRecording = recordingViewIds.has(viewId);
468
+ recordingViewIds.delete(viewId);
469
+ try {
470
+ for (const step of stepList) {
471
+ try {
472
+ await replayStep(view, step, resolvedValues);
473
+ sendIfAlive(win, `browser:replay-step:${viewId}`, { n: step.n, status: 'pass' });
474
+ } catch (e) {
475
+ const detail = e && e.message ? e.message : String(e);
476
+ sendIfAlive(win, `browser:replay-step:${viewId}`, { n: step.n, status: 'fail', detail });
477
+ if (!continueOnError) return { ok: true, stopped: true, failedAt: step.n };
478
+ }
479
+ }
480
+ return { ok: true, stopped: false };
481
+ } finally {
482
+ if (wasRecording) recordingViewIds.add(viewId);
483
+ }
484
+ }
485
+
486
+ function navigate({ viewId, url }) {
487
+ const view = views.get(viewId);
488
+ if (!view) return { ok: false, error: 'unknown viewId' };
489
+ const normalized = normalizeUrl(url);
490
+ if (!normalized.ok) return { ok: false, error: normalized.error };
491
+ view.webContents.loadURL(normalized.url).catch(() => {
492
+ // Load failures surface via did-fail-load → broadcastNavState; nothing
493
+ // further to do here.
494
+ });
495
+ return { ok: true };
496
+ }
497
+
498
+ function back({ viewId }) {
499
+ const view = views.get(viewId);
500
+ if (!view) return { ok: false, error: 'unknown viewId' };
501
+ const wc = view.webContents;
502
+ const nav = wc.navigationHistory;
503
+ const canGoBack = nav ? nav.canGoBack() : wc.canGoBack();
504
+ if (!canGoBack) return { ok: false, error: 'cannot go back' };
505
+ if (nav) nav.goBack(); else wc.goBack();
506
+ return { ok: true };
507
+ }
508
+
509
+ function forward({ viewId }) {
510
+ const view = views.get(viewId);
511
+ if (!view) return { ok: false, error: 'unknown viewId' };
512
+ const wc = view.webContents;
513
+ const nav = wc.navigationHistory;
514
+ const canGoForward = nav ? nav.canGoForward() : wc.canGoForward();
515
+ if (!canGoForward) return { ok: false, error: 'cannot go forward' };
516
+ if (nav) nav.goForward(); else wc.goForward();
517
+ return { ok: true };
518
+ }
519
+
520
+ function reload({ viewId }) {
521
+ const view = views.get(viewId);
522
+ if (!view) return { ok: false, error: 'unknown viewId' };
523
+ view.webContents.reload();
524
+ return { ok: true };
525
+ }
526
+
527
+ function stop({ viewId }) {
528
+ const view = views.get(viewId);
529
+ if (!view) return { ok: false, error: 'unknown viewId' };
530
+ view.webContents.stop();
531
+ return { ok: true };
532
+ }
533
+
534
+ // Cap returned capture text so a huge DOM can't blow up the IPC channel.
535
+ const CAPTURE_TEXT_MAX = 500_000;
536
+
537
+ // PRD 407: grab page text/HTML for the Capture panel. Reuses the same
538
+ // executeJavaScript access path the recorder-preload re-arm already uses
539
+ // above (did-finish-load handler) — one round trip returns url/title/text
540
+ // together instead of three separate calls.
541
+ async function captureDom({ viewId, kind }) {
542
+ const view = views.get(viewId);
543
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
544
+ try {
545
+ const script = kind === 'html'
546
+ ? '({ url: location.href, title: document.title, text: document.documentElement.outerHTML })'
547
+ : '({ url: location.href, title: document.title, text: document.body ? document.body.innerText : "" })';
548
+ const result = await view.webContents.executeJavaScript(script);
549
+ let text = typeof result?.text === 'string' ? result.text : '';
550
+ let truncated = false;
551
+ if (text.length > CAPTURE_TEXT_MAX) {
552
+ text = text.slice(0, CAPTURE_TEXT_MAX);
553
+ truncated = true;
554
+ }
555
+ return { ok: true, url: result?.url || '', title: result?.title || '', text, truncated };
556
+ } catch (e) {
557
+ return { ok: false, error: e && e.message ? e.message : String(e) };
558
+ }
559
+ }
560
+
561
+ // PRD 407: screenshot the active browser sub-tab as a PNG data URL.
562
+ async function captureShot({ viewId }) {
563
+ const view = views.get(viewId);
564
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
565
+ try {
566
+ const wc = view.webContents;
567
+ const image = await wc.capturePage();
568
+ const dataUrl = image.toDataURL();
569
+ return { ok: true, url: wc.getURL(), title: wc.getTitle(), dataUrl };
570
+ } catch (e) {
571
+ return { ok: false, error: e && e.message ? e.message : String(e) };
572
+ }
573
+ }
574
+
575
+ // PRD 403: getter passed to browserCapture.cjs's registerBrowserCapture so
576
+ // the picker can reach a live WebContentsView without this module exposing
577
+ // its whole `views` Map as a mutable dependency surface.
578
+ function getView(viewId) {
579
+ return views.get(viewId);
580
+ }
581
+
582
+ function registerBrowserView({ mainWindow, ipcMain }) {
583
+ attachWindow(mainWindow);
584
+ loadPersistedZoom();
585
+ const { schemas, validated } = require('./ipcSchemas.cjs');
586
+ ipcMain.handle('browser:create', validated(schemas.browserCreate, (payload) => create(payload)));
587
+ ipcMain.handle('browser:set-bounds', validated(schemas.browserSetBounds, (payload) => setBounds(payload)));
588
+ ipcMain.handle('browser:show', validated(schemas.browserViewId, (payload) => show(payload)));
589
+ ipcMain.handle('browser:hide', validated(schemas.browserViewId, (payload) => hide(payload)));
590
+ ipcMain.handle('browser:destroy', validated(schemas.browserViewId, (payload) => destroy(payload)));
591
+ ipcMain.handle('browser:navigate', validated(schemas.browserNavigate, (payload) => navigate(payload)));
592
+ ipcMain.handle('browser:back', validated(schemas.browserViewId, (payload) => back(payload)));
593
+ ipcMain.handle('browser:forward', validated(schemas.browserViewId, (payload) => forward(payload)));
594
+ ipcMain.handle('browser:reload', validated(schemas.browserViewId, (payload) => reload(payload)));
595
+ ipcMain.handle('browser:stop', validated(schemas.browserViewId, (payload) => stop(payload)));
596
+ ipcMain.handle('browser:record-start', validated(schemas.browserViewId, (payload) => recordStart(payload)));
597
+ ipcMain.handle('browser:record-stop', validated(schemas.browserViewId, (payload) => recordStop(payload)));
598
+ ipcMain.on('browser:record-event', handleRecordEvent);
599
+ ipcMain.handle('browser:replay', validated(schemas.browserReplay, (payload) => replay(payload)));
600
+ ipcMain.handle('browser:capture-dom', validated(schemas.browserCaptureDom, (payload) => captureDom(payload)));
601
+ ipcMain.handle('browser:capture-shot', validated(schemas.browserViewId, (payload) => captureShot(payload)));
602
+ ipcMain.handle('browser:set-zoom', validated(schemas.browserSetZoom, (payload) => setZoom(payload)));
603
+ ipcMain.handle('browser:find', validated(schemas.browserFind, (payload) => find(payload)));
604
+ ipcMain.handle('browser:stop-find', validated(schemas.browserViewId, (payload) => stopFind(payload)));
605
+ ipcMain.handle('browser:save-binary', validated(schemas.browserSaveBinary, (payload) => {
606
+ const { writeBinaryAtomic } = require('./config.cjs');
607
+ return writeBinaryAtomic(payload.path, Buffer.from(payload.base64, 'base64'))
608
+ .then(() => ({ ok: true }))
609
+ .catch((e) => ({ ok: false, error: e && e.message ? e.message : String(e) }));
610
+ }));
611
+ }
612
+
613
+ module.exports = { registerBrowserView, attachWindow, views, isBrowserViewContents, getView };
@@ -103,7 +103,10 @@ function validateWrite(realAbs) {
103
103
  (p) => realAbs === p || realAbs.startsWith(p + path.sep)
104
104
  );
105
105
  if (inWritePrefix) return;
106
- // Also allowed inside a registered project root's .claude/ subtree.
106
+ // Also allowed inside a registered project root's .claude/ subtree, OR its
107
+ // tests/fixtures/browser-capture/ subtree (PRD 407 "PRD fixture" capture
108
+ // destination — narrowly scoped to that one path segment, not a general
109
+ // project-root write grant).
107
110
  for (const root of allowedRoots) {
108
111
  if (root === os.homedir()) continue;
109
112
  let realRoot;
@@ -113,6 +116,17 @@ function validateWrite(realAbs) {
113
116
  if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
114
117
  return;
115
118
  }
119
+ const fixturesSub = path.join(realRoot, 'tests', 'fixtures', 'browser-capture');
120
+ if (realAbs === fixturesSub || realAbs.startsWith(fixturesSub + path.sep)) {
121
+ return;
122
+ }
123
+ // PRD 410 Recorder → Playwright-spec export: narrowly scoped to
124
+ // tests/e2e/ (matches the repo's existing e2e spec location), not a
125
+ // general project-root write grant.
126
+ const e2eSub = path.join(realRoot, 'tests', 'e2e');
127
+ if (realAbs === e2eSub || realAbs.startsWith(e2eSub + path.sep)) {
128
+ return;
129
+ }
116
130
  }
117
131
  }
118
132
  throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
@@ -182,6 +196,27 @@ async function writeTextAtomic(abs, text, opts = {}) {
182
196
  return { ok: true, mtimeMs: stat.mtimeMs };
183
197
  }
184
198
 
199
+ /**
200
+ * Binary-safe sibling of writeTextAtomic — same validate + tmp+rename flow,
201
+ * for callers (screenshot capture) whose payload is a Buffer, not utf8 text.
202
+ */
203
+ async function writeBinaryAtomic(abs, buffer) {
204
+ const real = validatePath(expandHome(abs));
205
+ validateWrite(real);
206
+ const dir = path.dirname(real);
207
+ await fsp.mkdir(dir, { recursive: true });
208
+ const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
209
+ try {
210
+ await fsp.writeFile(tmp, buffer);
211
+ await fsp.rename(tmp, real);
212
+ } catch (e) {
213
+ try { await fsp.unlink(tmp); } catch { /* tmp never created or already gone */ }
214
+ throw e;
215
+ }
216
+ const stat = await fsp.stat(real);
217
+ return { ok: true, mtimeMs: stat.mtimeMs };
218
+ }
219
+
185
220
  async function writeJson(abs, data) {
186
221
  const pretty = JSON.stringify(data, null, 2) + '\n';
187
222
  return writeTextAtomic(abs, pretty);
@@ -374,6 +409,7 @@ module.exports = {
374
409
  writeJson,
375
410
  writeJsonSync,
376
411
  writeTextAtomic,
412
+ writeBinaryAtomic,
377
413
  listDir,
378
414
  exists,
379
415
  addAllowedRoot,