claude-code-session-manager 0.35.2 → 0.35.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/assets/{TiptapBody-CIEmzy3k.js → TiptapBody-CVFG9ufh.js} +1 -1
  2. package/dist/assets/index-DASEOqMP.js +3558 -0
  3. package/dist/assets/index-DZgKJkf3.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/develop/standards.md +1 -0
  9. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +48 -35
  10. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +83 -0
  11. package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
  12. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +20 -12
  13. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +4 -3
  14. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +33 -12
  15. package/src/main/__tests__/adminServer.test.cjs +175 -0
  16. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +137 -0
  17. package/src/main/__tests__/mcpStatus.test.cjs +61 -0
  18. package/src/main/__tests__/runVerify.test.cjs +74 -4
  19. package/src/main/__tests__/scheduler-autofix-select.test.cjs +50 -2
  20. package/src/main/__tests__/scheduler-autopromote.test.cjs +19 -1
  21. package/src/main/adminServer.cjs +157 -0
  22. package/src/main/browserCapture.cjs +714 -0
  23. package/src/main/browserView.cjs +613 -0
  24. package/src/main/chatRunner.cjs +60 -0
  25. package/src/main/config.cjs +37 -1
  26. package/src/main/historyAggregator.cjs +92 -6
  27. package/src/main/index.cjs +37 -3
  28. package/src/main/ipcSchemas.cjs +106 -0
  29. package/src/main/lib/schedulerConfig.cjs +6 -2
  30. package/src/main/mcpStatus.cjs +93 -0
  31. package/src/main/runVerify.cjs +14 -1
  32. package/src/main/scheduler.cjs +159 -31
  33. package/src/main/sessionsStore.cjs +4 -3
  34. package/src/preload/api.d.ts +154 -5
  35. package/src/preload/browserViewPreload.cjs +67 -0
  36. package/src/preload/index.cjs +63 -5
  37. package/dist/assets/index-2Om-ouz6.js +0 -3534
  38. package/dist/assets/index-DeIJ8SM5.css +0 -32
  39. package/src/main/docEditor.cjs +0 -92
@@ -0,0 +1,714 @@
1
+ /**
2
+ * DOM Capture element picker (PRD 403). Foundation for the capture panel
3
+ * (PRD 406): while capture mode is active, injects a hover-highlight overlay
4
+ * into the embedded page and lets the user hover/click to build a selection,
5
+ * then reports a robust selector back to the renderer.
6
+ *
7
+ * The view is a sandboxed WebContentsView with no reachable app globals, so
8
+ * the picker itself has to be a self-contained IIFE delivered via
9
+ * `webContents.executeJavaScript(PICKER_SCRIPT, true)` — it cannot assume
10
+ * any preload bridge. Getting picks back out of that unprivileged context:
11
+ * the injected script buffers events on `window.__smPicker` and this module
12
+ * polls `window.__smPicker.drain()` on an interval while picking is active
13
+ * (the "polling drain" option called out in the PRD notes — simpler and
14
+ * more robust than scraping `console-message` for a sentinel prefix, and
15
+ * needs no wiring beyond executeJavaScript, which this module already uses
16
+ * for start/stop/drain).
17
+ *
18
+ * SELECTOR_CHAIN_SOURCE is the single source of truth for the selector
19
+ * fallback chain ([data-testid] -> [id] -> role+accessible-name -> minimal
20
+ * unique CSS path -> nth-of-type XPath). PRD 408's recorder injection should
21
+ * reuse this exact constant rather than growing a second selector generator.
22
+ */
23
+
24
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
25
+
26
+ let win = null;
27
+
28
+ function attachWindow(mainWindow) {
29
+ win = mainWindow;
30
+ }
31
+
32
+ // ── Selector chain (single source of truth — see file header) ─────────
33
+ const SELECTOR_CHAIN_SOURCE = `
34
+ function __smCssPath(el) {
35
+ var parts = [];
36
+ var node = el;
37
+ while (node && node.nodeType === 1 && node !== document.documentElement) {
38
+ var tag = node.tagName.toLowerCase();
39
+ var parent = node.parentElement;
40
+ var seg = tag;
41
+ if (parent) {
42
+ var siblings = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
43
+ if (siblings.length > 1) seg += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
44
+ }
45
+ parts.unshift(seg);
46
+ var candidate = parts.join(' > ');
47
+ try {
48
+ if (document.querySelectorAll(candidate).length === 1) return candidate;
49
+ } catch (e) {}
50
+ node = parent;
51
+ }
52
+ return null;
53
+ }
54
+ function __smXPath(el) {
55
+ var xparts = [];
56
+ var node = el;
57
+ while (node && node.nodeType === 1) {
58
+ var tag = node.tagName.toLowerCase();
59
+ var parent = node.parentElement;
60
+ var index = 1;
61
+ if (parent) {
62
+ var siblings = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
63
+ index = siblings.indexOf(node) + 1;
64
+ }
65
+ xparts.unshift(tag + '[' + index + ']');
66
+ node = parent;
67
+ }
68
+ return '/' + xparts.join('/');
69
+ }
70
+ function __smSelectorFor(el) {
71
+ if (!el || el.nodeType !== 1) return '';
72
+ try {
73
+ var testId = el.getAttribute && el.getAttribute('data-testid');
74
+ if (testId) return '[data-testid=' + JSON.stringify(testId) + ']';
75
+ if (el.id) return '#' + CSS.escape(el.id);
76
+ var role = el.getAttribute && el.getAttribute('role');
77
+ var ariaLabel = el.getAttribute && el.getAttribute('aria-label');
78
+ if (role && ariaLabel) return '[role=' + JSON.stringify(role) + '][aria-label=' + JSON.stringify(ariaLabel) + ']';
79
+ var css = __smCssPath(el);
80
+ if (css) return css;
81
+ return __smXPath(el);
82
+ } catch (e) {
83
+ return el.tagName ? el.tagName.toLowerCase() : '';
84
+ }
85
+ }
86
+ `;
87
+
88
+ // ── Injected picker overlay ─────────────────────────────────────────
89
+ // Neutral high-contrast accent (matches docs/design/browser-tab.design.jsx
90
+ // terracotta) since the highlight renders inside third-party page content.
91
+ const PICKER_SCRIPT = `
92
+ (function () {
93
+ if (window.__smPicker) return;
94
+ ${SELECTOR_CHAIN_SOURCE}
95
+
96
+ var ACCENT = '#b85c34';
97
+ var events = [];
98
+ var hovered = null;
99
+ var selected = new Map();
100
+
101
+ function labelFor(el) {
102
+ var text = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim();
103
+ return text.slice(0, 60);
104
+ }
105
+ function rectOf(el) {
106
+ var r = el.getBoundingClientRect();
107
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
108
+ }
109
+ function baseStyle(el, extra) {
110
+ el.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;box-sizing:border-box;' + extra;
111
+ }
112
+
113
+ var hoverBox = document.createElement('div');
114
+ baseStyle(hoverBox, 'border:2px dashed ' + ACCENT + ';display:none;');
115
+ var selectedLayer = document.createElement('div');
116
+ baseStyle(selectedLayer, '');
117
+ var labelEl = document.createElement('div');
118
+ baseStyle(labelEl, 'background:' + ACCENT + ';color:#fff;font:11px/1.4 monospace;padding:2px 6px;border-radius:3px;display:none;white-space:nowrap;');
119
+ document.documentElement.appendChild(hoverBox);
120
+ document.documentElement.appendChild(selectedLayer);
121
+ document.documentElement.appendChild(labelEl);
122
+
123
+ function isOverlay(el) {
124
+ return el === hoverBox || el === selectedLayer || el === labelEl || selectedLayer.contains(el);
125
+ }
126
+
127
+ function drawSelected() {
128
+ selectedLayer.innerHTML = '';
129
+ selected.forEach(function (_sel, el) {
130
+ if (!el.isConnected) return;
131
+ var r = el.getBoundingClientRect();
132
+ var box = document.createElement('div');
133
+ box.style.cssText = 'position:fixed;left:' + r.x + 'px;top:' + r.y + 'px;width:' + r.width + 'px;height:' + r.height + 'px;border:2px solid ' + ACCENT + ';box-sizing:border-box;pointer-events:none;';
134
+ selectedLayer.appendChild(box);
135
+ });
136
+ }
137
+
138
+ function positionHover(el) {
139
+ var r = el.getBoundingClientRect();
140
+ hoverBox.style.display = 'block';
141
+ hoverBox.style.left = r.x + 'px';
142
+ hoverBox.style.top = r.y + 'px';
143
+ hoverBox.style.width = r.width + 'px';
144
+ hoverBox.style.height = r.height + 'px';
145
+ labelEl.style.display = 'block';
146
+ labelEl.style.left = r.x + 'px';
147
+ labelEl.style.top = Math.max(0, r.y - 20) + 'px';
148
+ }
149
+
150
+ function onMove(e) {
151
+ var el = document.elementFromPoint(e.clientX, e.clientY);
152
+ if (!el || isOverlay(el)) {
153
+ hoverBox.style.display = 'none';
154
+ labelEl.style.display = 'none';
155
+ hovered = null;
156
+ return;
157
+ }
158
+ if (el === hovered) {
159
+ positionHover(el);
160
+ return;
161
+ }
162
+ hovered = el;
163
+ var sel = __smSelectorFor(el);
164
+ positionHover(el);
165
+ labelEl.textContent = sel;
166
+ events.push({ type: 'hover', selector: sel, label: labelFor(el), tag: el.tagName.toLowerCase(), rect: rectOf(el) });
167
+ }
168
+
169
+ function onClick(e) {
170
+ var el = document.elementFromPoint(e.clientX, e.clientY);
171
+ if (!el || isOverlay(el)) return;
172
+ e.preventDefault();
173
+ e.stopPropagation();
174
+ var sel = __smSelectorFor(el);
175
+ var payload = { selector: sel, label: labelFor(el), tag: el.tagName.toLowerCase(), rect: rectOf(el) };
176
+ if (e.metaKey || e.ctrlKey) {
177
+ if (selected.has(el)) {
178
+ selected.delete(el);
179
+ events.push(Object.assign({ type: 'unpick' }, payload));
180
+ } else {
181
+ selected.set(el, sel);
182
+ events.push(Object.assign({ type: 'pick' }, payload));
183
+ }
184
+ } else {
185
+ selected.forEach(function (prevSel, prevEl) {
186
+ if (prevEl === el) return;
187
+ events.push({ type: 'unpick', selector: prevSel, label: labelFor(prevEl), tag: prevEl.tagName.toLowerCase(), rect: rectOf(prevEl) });
188
+ });
189
+ selected.clear();
190
+ selected.set(el, sel);
191
+ events.push(Object.assign({ type: 'pick', replace: true }, payload));
192
+ }
193
+ drawSelected();
194
+ }
195
+
196
+ function onKeyDown(e) {
197
+ if (e.key === 'Escape') {
198
+ events.push({ type: 'exit' });
199
+ teardown();
200
+ }
201
+ }
202
+
203
+ function teardown() {
204
+ document.removeEventListener('mousemove', onMove, true);
205
+ document.removeEventListener('click', onClick, true);
206
+ document.removeEventListener('keydown', onKeyDown, true);
207
+ hoverBox.remove();
208
+ selectedLayer.remove();
209
+ labelEl.remove();
210
+ delete window.__smPicker;
211
+ }
212
+
213
+ document.addEventListener('mousemove', onMove, true);
214
+ document.addEventListener('click', onClick, true);
215
+ document.addEventListener('keydown', onKeyDown, true);
216
+
217
+ window.__smPicker = {
218
+ drain: function () {
219
+ var out = events;
220
+ events = [];
221
+ return out;
222
+ },
223
+ stop: teardown,
224
+ };
225
+ })();
226
+ `;
227
+
228
+ const POLL_MS = 120;
229
+
230
+ /** @type {Map<string, { interval: NodeJS.Timeout, reinject: () => void, onDestroyed: () => void, wc: Electron.WebContents }>} */
231
+ const pickingState = new Map();
232
+
233
+ function cleanup(viewId) {
234
+ const state = pickingState.get(viewId);
235
+ if (!state) return;
236
+ clearInterval(state.interval);
237
+ try {
238
+ state.wc.removeListener('did-finish-load', state.reinject);
239
+ state.wc.removeListener('destroyed', state.onDestroyed);
240
+ } catch {
241
+ // webContents already gone
242
+ }
243
+ pickingState.delete(viewId);
244
+ }
245
+
246
+ const EVENT_TYPES = new Set(['hover', 'pick', 'unpick', 'exit']);
247
+ const STR_MAX = 300;
248
+
249
+ // The drained payload comes out of the embedded page's own JS context — a
250
+ // hostile or compromised site could shadow `window.__smPicker` and feed us
251
+ // arbitrary data instead of a real pick. Bound and type-check every field
252
+ // before it reaches the renderer, mirroring handleRecordEvent's treatment of
253
+ // the (similarly page-controlled) recorder event stream in browserView.cjs.
254
+ function sanitizeEvent(raw) {
255
+ if (!raw || typeof raw !== 'object' || !EVENT_TYPES.has(raw.type)) return null;
256
+ const ev = { type: raw.type };
257
+ if (typeof raw.selector === 'string') ev.selector = raw.selector.slice(0, STR_MAX);
258
+ if (typeof raw.label === 'string') ev.label = raw.label.slice(0, STR_MAX);
259
+ if (typeof raw.tag === 'string') ev.tag = raw.tag.slice(0, 32);
260
+ if (raw.replace === true) ev.replace = true;
261
+ if (raw.rect && typeof raw.rect === 'object') {
262
+ const { x, y, width, height } = raw.rect;
263
+ if ([x, y, width, height].every((n) => typeof n === 'number' && Number.isFinite(n))) {
264
+ ev.rect = { x, y, width, height };
265
+ }
266
+ }
267
+ return ev;
268
+ }
269
+
270
+ async function pollOnce(viewId, wc) {
271
+ if (wc.isDestroyed()) {
272
+ cleanup(viewId);
273
+ return;
274
+ }
275
+ let events;
276
+ try {
277
+ events = await wc.executeJavaScript('window.__smPicker ? window.__smPicker.drain() : null');
278
+ } catch {
279
+ return;
280
+ }
281
+ if (!Array.isArray(events) || !events.length) return;
282
+ for (const raw of events) {
283
+ const ev = sanitizeEvent(raw);
284
+ if (!ev) continue;
285
+ sendIfAlive(win, `browser:picker-event:${viewId}`, ev);
286
+ if (ev.type === 'exit') {
287
+ cleanup(viewId);
288
+ return;
289
+ }
290
+ }
291
+ }
292
+
293
+ async function pickerStart({ viewId }, getView) {
294
+ const view = getView(viewId);
295
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
296
+ if (pickingState.has(viewId)) return { ok: true };
297
+ const wc = view.webContents;
298
+ try {
299
+ await wc.executeJavaScript(PICKER_SCRIPT, true);
300
+ } catch (e) {
301
+ return { ok: false, error: e && e.message ? e.message : String(e) };
302
+ }
303
+ // Page JS context is torn down on every navigation — re-inject so a
304
+ // picking session survives the user navigating while picking.
305
+ const reinject = () => {
306
+ if (!pickingState.has(viewId) || wc.isDestroyed()) return;
307
+ wc.executeJavaScript(PICKER_SCRIPT, true).catch(() => {});
308
+ };
309
+ wc.on('did-finish-load', reinject);
310
+ const onDestroyed = () => cleanup(viewId);
311
+ wc.once('destroyed', onDestroyed);
312
+ const interval = setInterval(() => pollOnce(viewId, wc), POLL_MS);
313
+ pickingState.set(viewId, { interval, reinject, onDestroyed, wc });
314
+ return { ok: true };
315
+ }
316
+
317
+ async function pickerStop({ viewId }, getView) {
318
+ const state = pickingState.get(viewId);
319
+ cleanup(viewId);
320
+ const view = getView(viewId);
321
+ if (view && !view.webContents.isDestroyed()) {
322
+ try {
323
+ await view.webContents.executeJavaScript('window.__smPicker && window.__smPicker.stop()');
324
+ } catch {
325
+ // view may have navigated/closed mid-teardown — nothing further to do
326
+ }
327
+ }
328
+ return { ok: true, wasPicking: Boolean(state) };
329
+ }
330
+
331
+ // ── Capture: filter -> prune -> summarize -> chunk (PRD 404) ──────────
332
+ //
333
+ // The four capture modes ('html' | 'a11y' | 'selector' | 'agent') share one
334
+ // shape: an unprivileged executeJavaScript pass serializes the selected
335
+ // subtree (or queries outerHTML / resolves a CDP node) into a plain JSON
336
+ // value, and everything downstream of that is pure and unit-testable
337
+ // without a live page — see tests/unit/browserCapture.spec.ts.
338
+
339
+ const NOISE_TAGS = new Set(['script', 'style', 'svg', 'noscript', 'template', 'link', 'meta']);
340
+ const KEEP_ATTRS = new Set(['role', 'name', 'data-testid', 'href', 'type', 'value', 'placeholder']);
341
+
342
+ function isHiddenNode(node) {
343
+ const style = node.style || {};
344
+ if (style.display === 'none' || style.visibility === 'hidden') return true;
345
+ const attrs = node.attrs || {};
346
+ if ('hidden' in attrs) return true;
347
+ if (attrs['aria-hidden'] === 'true') return true;
348
+ return false;
349
+ }
350
+
351
+ function pruneAttrs(attrs) {
352
+ const out = {};
353
+ for (const key of Object.keys(attrs || {})) {
354
+ if (KEEP_ATTRS.has(key) || key.startsWith('aria-')) out[key] = attrs[key];
355
+ }
356
+ return out;
357
+ }
358
+
359
+ // filter: drop noise/hidden nodes, keep interactive + semantic elements and
360
+ // non-empty text. O(n) over the subtree, n = node count in the selection.
361
+ function filterTree(node) {
362
+ if (!node || typeof node !== 'object') return null;
363
+ if (node.type === 'text') {
364
+ const text = (node.text || '').trim();
365
+ return text ? { type: 'text', text } : null;
366
+ }
367
+ const tag = (node.tag || '').toLowerCase();
368
+ if (NOISE_TAGS.has(tag)) return null;
369
+ if (isHiddenNode(node)) return null;
370
+ const children = (node.children || []).map(filterTree).filter(Boolean);
371
+ return { tag, attrs: pruneAttrs(node.attrs), children };
372
+ }
373
+
374
+ function textContent(node) {
375
+ if (!node) return '';
376
+ if (node.type === 'text') return node.text;
377
+ return (node.children || []).map(textContent).join('').trim();
378
+ }
379
+
380
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
381
+ const LANDMARK_ROLE_BY_TAG = { header: 'banner', footer: 'contentinfo', nav: 'navigation', main: 'main', section: 'region', article: 'article', aside: 'complementary', form: 'form' };
382
+
383
+ // summarize: flatten the filtered/pruned tree into compact pseudo-markup
384
+ // grouped by landmark/role, matching docs/design/browser-tab.design.jsx's
385
+ // CaptureOutput `agent` sample. O(n) over the (already filtered) subtree.
386
+ function summarizeTree(node, depth = 0) {
387
+ if (!node || node.type === 'text') return '';
388
+ const pad = ' '.repeat(depth);
389
+ const tag = node.tag;
390
+ const attrs = node.attrs || {};
391
+
392
+ if (HEADING_TAGS.has(tag)) {
393
+ return `${pad}<heading level=${tag.slice(1)}>${textContent(node)}</heading>`;
394
+ }
395
+ if (tag === 'button' || tag === 'a') {
396
+ const name = attrs.name || textContent(node);
397
+ const parts = [`name="${name}"`];
398
+ if (attrs['data-testid']) parts.push(`data-testid="${attrs['data-testid']}"`);
399
+ if (tag === 'a' && attrs.href) parts.push(`href="${attrs.href}"`);
400
+ const el = tag === 'a' ? 'link' : 'button';
401
+ return `${pad}<${el} ${parts.join(' ')}>${textContent(node)}</${el}>`;
402
+ }
403
+ if (tag === 'ul' || tag === 'ol') {
404
+ const items = (node.children || []).map((c) => summarizeTree(c, depth + 1)).filter(Boolean).join('\n');
405
+ return `${pad}<list>\n${items}\n${pad}</list>`;
406
+ }
407
+ if (tag === 'li') {
408
+ return `${pad}<item>${textContent(node)}</item>`;
409
+ }
410
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
411
+ const parts = [];
412
+ if (attrs.type) parts.push(`type="${attrs.type}"`);
413
+ if (attrs.placeholder) parts.push(`placeholder="${attrs.placeholder}"`);
414
+ if (attrs.value) parts.push(`value="${attrs.value}"`);
415
+ if (attrs['data-testid']) parts.push(`data-testid="${attrs['data-testid']}"`);
416
+ const attrStr = parts.length ? ' ' + parts.join(' ') : '';
417
+ return `${pad}<field${attrStr}/>`;
418
+ }
419
+
420
+ const role = attrs.role || LANDMARK_ROLE_BY_TAG[tag];
421
+ const children = node.children || [];
422
+ const childText = children.map((c) => summarizeTree(c, depth + 1)).filter(Boolean).join('\n');
423
+ if (role) {
424
+ const label = attrs['aria-label'];
425
+ const openAttr = label ? ` aria-label="${label}"` : '';
426
+ if (!childText) return '';
427
+ return `${pad}<region${openAttr}>\n${childText}\n${pad}</region>`;
428
+ }
429
+ // Plain wrapper (div/span/p/etc): if it's a single-text leaf, emit <text>;
430
+ // otherwise splice children in at this depth without adding a wrapper tag.
431
+ if (children.length === 1 && children[0].type === 'text') {
432
+ return `${pad}<text>${children[0].text}</text>`;
433
+ }
434
+ return childText;
435
+ }
436
+
437
+ function estimateTokens(text) {
438
+ return Math.ceil(text.length / 4);
439
+ }
440
+
441
+ // chunk: split on line boundaries so a chunk never exceeds budgetBytes.
442
+ // O(n) over the text, n = byte length.
443
+ function chunkText(text, budgetBytes) {
444
+ if (Buffer.byteLength(text, 'utf8') <= budgetBytes) return [text];
445
+ const lines = text.split(/(?<=\n)/);
446
+ const chunks = [];
447
+ let current = '';
448
+ for (const line of lines) {
449
+ if (current && Buffer.byteLength(current + line, 'utf8') > budgetBytes) {
450
+ chunks.push(current);
451
+ current = line;
452
+ } else {
453
+ current += line;
454
+ }
455
+ }
456
+ if (current) chunks.push(current);
457
+ return chunks;
458
+ }
459
+
460
+ const DEFAULT_CHUNK_BUDGET_BYTES = 8192;
461
+
462
+ function buildAgentCapture(rawTree, budgetBytes = DEFAULT_CHUNK_BUDGET_BYTES) {
463
+ const filtered = filterTree(rawTree);
464
+ const summary = filtered ? summarizeTree(filtered) : '';
465
+ const chunks = chunkText(summary, budgetBytes);
466
+ const text = chunks[0] || '';
467
+ return { text, meta: { chunks: chunks.length, tokens: estimateTokens(text) } };
468
+ }
469
+
470
+ function formatHtmlCapture(nodes) {
471
+ return nodes
472
+ .map((n, i) => `<!-- node ${i + 1}: ${n.selector} -->\n${n.outerHTML}`)
473
+ .join('\n\n');
474
+ }
475
+
476
+ // selector: reuse the SELECTOR_CHAIN_SOURCE precedence (testId > id > role+name
477
+ // > css > xpath) to pick the preferred entry among the fallback chain.
478
+ function formatSelectorChain({ css, xpath, role, name, id, testId } = {}) {
479
+ const lines = [];
480
+ if (css) lines.push({ key: 'css', text: `css ${css}` });
481
+ if (xpath) lines.push({ key: 'xpath', text: `xpath ${xpath}` });
482
+ if (role && name) lines.push({ key: 'aria', text: `aria role=${role} name="${name}"` });
483
+ if (testId) lines.push({ key: 'test', text: `test [data-testid="${testId}"]` });
484
+
485
+ const precedence = testId ? 'test' : id ? 'css' : role && name ? 'aria' : css ? 'css' : 'xpath';
486
+ return lines
487
+ .map((l) => (l.key === precedence ? `${l.text} ← preferred` : l.text))
488
+ .join('\n');
489
+ }
490
+
491
+ // a11y: format a normalized CDP AX-tree node (role/name/level/states/testId
492
+ // + children) as the indented tree in CaptureOutput's `a11y` sample.
493
+ function formatA11yTree(node, depth = 0) {
494
+ if (!node) return '';
495
+ const pad = ' '.repeat(depth);
496
+ const head = node.name ? `${pad}${node.role} "${node.name}"` : `${pad}${node.role}`;
497
+ const extras = [];
498
+ if (typeof node.level === 'number') extras.push(`level ${node.level}`);
499
+ for (const state of node.states || []) extras.push(state);
500
+ const lines = [extras.length ? `${head} ${extras.join(' ')}` : head];
501
+ if (node.testId) lines.push(`${pad} data-testid=${node.testId}`);
502
+ for (const child of node.children || []) {
503
+ const childText = formatA11yTree(child, depth + 1);
504
+ if (childText) lines.push(childText);
505
+ }
506
+ return lines.join('\n');
507
+ }
508
+
509
+ // ── CDP accessibility-tree capture ─────────────────────────────────────
510
+ async function captureA11y(wc, selector) {
511
+ const dbg = wc.debugger;
512
+ await dbg.attach('1.3');
513
+ try {
514
+ await dbg.sendCommand('DOM.enable');
515
+ await dbg.sendCommand('Accessibility.enable');
516
+ const { root } = await dbg.sendCommand('DOM.getDocument');
517
+ const { nodeId } = await dbg.sendCommand('DOM.querySelector', { nodeId: root.nodeId, selector });
518
+ if (!nodeId) return null;
519
+ const { nodes } = await dbg.sendCommand('Accessibility.getPartialAXTree', { nodeId, fetchRelatives: true });
520
+ return axNodesToTree(nodes);
521
+ } finally {
522
+ try {
523
+ await dbg.sendCommand('Accessibility.disable');
524
+ await dbg.sendCommand('DOM.disable');
525
+ } catch {
526
+ // best-effort; detach regardless
527
+ }
528
+ try {
529
+ dbg.detach();
530
+ } catch {
531
+ // already detached
532
+ }
533
+ }
534
+ }
535
+
536
+ function axPropValue(ax, name) {
537
+ const prop = (ax.properties || []).find((p) => p.name === name);
538
+ return prop ? prop.value.value : undefined;
539
+ }
540
+
541
+ // Accessibility.getPartialAXTree returns a flat array (root first, then
542
+ // relatives referenced by childIds) — rebuild the nested shape formatA11yTree
543
+ // expects. O(n) over the returned node count.
544
+ // AX node names/roles can carry attacker-influenced content (aria-label);
545
+ // bound string length, and cap total nodes visited to guard against a
546
+ // malformed/cyclic childIds graph causing unbounded recursion.
547
+ const AX_NODE_VISIT_CAP = 2_000;
548
+ const AX_STRING_CAP = 1_000;
549
+
550
+ function axNodesToTree(axNodes) {
551
+ if (!Array.isArray(axNodes) || !axNodes.length) return null;
552
+ const byId = new Map(axNodes.map((n) => [n.nodeId, n]));
553
+ const visited = new Set();
554
+ let count = 0;
555
+ function build(ax) {
556
+ if (!ax || visited.has(ax.nodeId) || count >= AX_NODE_VISIT_CAP) return null;
557
+ visited.add(ax.nodeId);
558
+ count += 1;
559
+ const states = [];
560
+ if (axPropValue(ax, 'focusable')) states.push('focusable');
561
+ if (axPropValue(ax, 'checked')) states.push('checked');
562
+ if (axPropValue(ax, 'expanded')) states.push('expanded');
563
+ if (axPropValue(ax, 'disabled')) states.push('disabled');
564
+ const children = (ax.childIds || []).map((id) => build(byId.get(id))).filter(Boolean);
565
+ return {
566
+ role: capString(ax.role && ax.role.value, AX_STRING_CAP),
567
+ name: capString(ax.name && ax.name.value, AX_STRING_CAP),
568
+ level: axPropValue(ax, 'level'),
569
+ states,
570
+ children,
571
+ };
572
+ }
573
+ return build(axNodes[0]);
574
+ }
575
+
576
+ // Everything below comes back from executeJavaScript running inside the
577
+ // embedded (possibly untrusted third-party) page — same threat model as
578
+ // sanitizeEvent above: a compromised page could return an oversized or
579
+ // malformed payload instead of real capture data. Bound every string field
580
+ // before it reaches formatting/IPC.
581
+ const CAPTURE_HTML_CAP = 200_000;
582
+ const CAPTURE_FIELD_CAP = 4_000;
583
+ const CAPTURE_TEXT_CAP = 20_000;
584
+ const CAPTURE_NODE_CAP = 5_000;
585
+
586
+ function capString(s, max) {
587
+ return typeof s === 'string' ? s.slice(0, max) : s;
588
+ }
589
+
590
+ function capRawTree(node, budget = { count: 0 }) {
591
+ if (!node || typeof node !== 'object' || budget.count >= CAPTURE_NODE_CAP) return null;
592
+ budget.count += 1;
593
+ if (node.type === 'text') return { type: 'text', text: capString(node.text, CAPTURE_TEXT_CAP) };
594
+ const attrs = {};
595
+ for (const key of Object.keys(node.attrs || {})) {
596
+ attrs[capString(key, 256)] = capString(node.attrs[key], CAPTURE_FIELD_CAP);
597
+ }
598
+ const children = (node.children || [])
599
+ .map((c) => capRawTree(c, budget))
600
+ .filter(Boolean);
601
+ return { tag: capString(node.tag, 64), attrs, style: node.style, children };
602
+ }
603
+
604
+ // ── Top-level capture dispatch ─────────────────────────────────────────
605
+ async function captureSelection({ viewId, selectors, mode }, getView) {
606
+ const view = getView(viewId);
607
+ if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
608
+ const wc = view.webContents;
609
+
610
+ if (mode === 'html') {
611
+ const nodes = await wc.executeJavaScript(
612
+ `(function () {
613
+ ${SELECTOR_CHAIN_SOURCE}
614
+ return (${JSON.stringify(selectors)}).map(function (sel) {
615
+ var el = document.querySelector(sel);
616
+ return el ? { selector: sel, outerHTML: el.outerHTML } : null;
617
+ }).filter(Boolean);
618
+ })()`,
619
+ );
620
+ const capped = (Array.isArray(nodes) ? nodes : []).slice(0, selectors.length).map((n) => ({
621
+ selector: capString(n && n.selector, CAPTURE_FIELD_CAP),
622
+ outerHTML: capString(n && n.outerHTML, CAPTURE_HTML_CAP),
623
+ }));
624
+ const text = formatHtmlCapture(capped);
625
+ return { ok: true, mode, text, meta: {} };
626
+ }
627
+
628
+ if (mode === 'selector') {
629
+ const infos = await wc.executeJavaScript(
630
+ `(function () {
631
+ ${SELECTOR_CHAIN_SOURCE}
632
+ return (${JSON.stringify(selectors)}).map(function (sel) {
633
+ var el = document.querySelector(sel);
634
+ if (!el) return null;
635
+ return {
636
+ css: __smCssPath(el) || sel,
637
+ xpath: __smXPath(el),
638
+ role: el.getAttribute('role') || undefined,
639
+ name: (el.getAttribute('aria-label') || el.innerText || '').trim().slice(0, 60) || undefined,
640
+ id: el.id || undefined,
641
+ testId: el.getAttribute('data-testid') || undefined,
642
+ };
643
+ }).filter(Boolean);
644
+ })()`,
645
+ );
646
+ const capped = (Array.isArray(infos) ? infos : []).slice(0, selectors.length).map((info) => ({
647
+ css: capString(info && info.css, CAPTURE_FIELD_CAP),
648
+ xpath: capString(info && info.xpath, CAPTURE_FIELD_CAP),
649
+ role: capString(info && info.role, 256),
650
+ name: capString(info && info.name, 256),
651
+ id: capString(info && info.id, 256),
652
+ testId: capString(info && info.testId, 256),
653
+ }));
654
+ const text = capped.map(formatSelectorChain).join('\n\n');
655
+ return { ok: true, mode, text, meta: {} };
656
+ }
657
+
658
+ if (mode === 'a11y') {
659
+ const selector = selectors[0];
660
+ const tree = await captureA11y(wc, selector);
661
+ const text = tree ? formatA11yTree(tree) : '';
662
+ return { ok: true, mode, text, meta: {} };
663
+ }
664
+
665
+ // mode === 'agent'
666
+ const rawTree = await wc.executeJavaScript(
667
+ `(function () {
668
+ function serialize(el) {
669
+ if (el.nodeType === 3) {
670
+ var t = el.textContent || '';
671
+ return t.trim() ? { type: 'text', text: t } : null;
672
+ }
673
+ if (el.nodeType !== 1) return null;
674
+ var attrs = {};
675
+ for (var i = 0; i < el.attributes.length; i++) {
676
+ attrs[el.attributes[i].name] = el.attributes[i].value;
677
+ }
678
+ var cs = window.getComputedStyle(el);
679
+ var children = [];
680
+ for (var j = 0; j < el.childNodes.length; j++) {
681
+ var c = serialize(el.childNodes[j]);
682
+ if (c) children.push(c);
683
+ }
684
+ return { tag: el.tagName.toLowerCase(), attrs: attrs, style: { display: cs.display, visibility: cs.visibility }, children: children };
685
+ }
686
+ var el = document.querySelector(${JSON.stringify(selectors[0])});
687
+ return el ? serialize(el) : null;
688
+ })()`,
689
+ );
690
+ const { text, meta } = buildAgentCapture(capRawTree(rawTree));
691
+ return { ok: true, mode, text, meta };
692
+ }
693
+
694
+ function registerBrowserCapture({ ipcMain, getView }) {
695
+ const { schemas, validated } = require('./ipcSchemas.cjs');
696
+ ipcMain.handle('browser:picker-start', validated(schemas.browserViewId, (payload) => pickerStart(payload, getView)));
697
+ ipcMain.handle('browser:picker-stop', validated(schemas.browserViewId, (payload) => pickerStop(payload, getView)));
698
+ ipcMain.handle('browser:capture', validated(schemas.browserCaptureSelection, (payload) => captureSelection(payload, getView)));
699
+ }
700
+
701
+ module.exports = {
702
+ registerBrowserCapture,
703
+ attachWindow,
704
+ SELECTOR_CHAIN_SOURCE,
705
+ PICKER_SCRIPT,
706
+ filterTree,
707
+ summarizeTree,
708
+ chunkText,
709
+ estimateTokens,
710
+ buildAgentCapture,
711
+ formatHtmlCapture,
712
+ formatSelectorChain,
713
+ formatA11yTree,
714
+ };