claude-code-session-manager 0.35.13 → 0.35.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-B_2hT1QJ.js → TiptapBody-o1hQcwtq.js} +1 -1
- package/dist/assets/index-B39Qiq0n.css +32 -0
- package/dist/assets/{index-LqC9hNy3.js → index-DqWSSMkp.js} +463 -463
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/browserAgentServer.test.cjs +186 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +45 -0
- package/src/main/browserAgentServer.cjs +202 -0
- package/src/main/browserView.cjs +197 -1
- package/src/main/index.cjs +11 -0
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +76 -0
- package/src/main/lib/browserAgentActions.cjs +114 -0
- package/src/main/lib/schedulerBatch.cjs +40 -33
- package/src/main/scheduler.cjs +105 -13
- package/src/preload/api.d.ts +8 -2
- package/dist/assets/index-C7NyYuXu.css +0 -32
package/src/main/browserView.cjs
CHANGED
|
@@ -21,6 +21,11 @@ const crypto = require('crypto');
|
|
|
21
21
|
const { WebContentsView } = require('electron');
|
|
22
22
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
23
23
|
const { readJson, writeJson } = require('./config.cjs');
|
|
24
|
+
const {
|
|
25
|
+
parseKeyCombo,
|
|
26
|
+
computeScrollDelta,
|
|
27
|
+
computeScreenshotScale,
|
|
28
|
+
} = require('./lib/browserAgentActions.cjs');
|
|
24
29
|
|
|
25
30
|
// ── History + zoom persistence (PRD 402) ──────────────────────────────
|
|
26
31
|
// Both files live under ~/.claude/session-manager/browser/, inside
|
|
@@ -70,6 +75,10 @@ const views = new Map();
|
|
|
70
75
|
// main-window nav lock without weakening the lock itself.
|
|
71
76
|
const browserViewContentsIds = new Set();
|
|
72
77
|
let win = null;
|
|
78
|
+
// Last viewId shown via show() — the renderer's proxy for "which sub-tab is
|
|
79
|
+
// active", since WebContentsView exposes setVisible() but no getter. Used to
|
|
80
|
+
// default viewId-optional browser-agent-server routes to the active tab.
|
|
81
|
+
let lastShownViewId = null;
|
|
73
82
|
|
|
74
83
|
// ── Recorder engine (PRD 408) ─────────────────────────────────────────
|
|
75
84
|
// viewId <-> webContents.id so the record-event channel (sent from the
|
|
@@ -248,6 +257,7 @@ function show({ viewId }) {
|
|
|
248
257
|
const view = views.get(viewId);
|
|
249
258
|
if (!view) return { ok: false };
|
|
250
259
|
view.setVisible(true);
|
|
260
|
+
lastShownViewId = viewId;
|
|
251
261
|
return { ok: true };
|
|
252
262
|
}
|
|
253
263
|
|
|
@@ -266,6 +276,7 @@ function destroy({ viewId }) {
|
|
|
266
276
|
recordingViewIds.delete(viewId);
|
|
267
277
|
stepCounters.delete(viewId);
|
|
268
278
|
lastFindText.delete(viewId);
|
|
279
|
+
if (lastShownViewId === viewId) lastShownViewId = null;
|
|
269
280
|
if (win && !win.isDestroyed()) {
|
|
270
281
|
try { win.contentView.removeChildView(view); } catch { /* already detached */ }
|
|
271
282
|
}
|
|
@@ -576,6 +587,181 @@ function stop({ viewId }) {
|
|
|
576
587
|
return { ok: true };
|
|
577
588
|
}
|
|
578
589
|
|
|
590
|
+
// ── On-demand single-action agent API (PRD 535 foundation) ────────────
|
|
591
|
+
// Modeled on Anthropic's published computer_use tool schema. Reuses the same
|
|
592
|
+
// dispatch primitives the Recorder replay engine already established above
|
|
593
|
+
// (replayPositionedClick for coordinate clicks, normalizeUrl/withTimeout for
|
|
594
|
+
// navigate) instead of forking a second copy — the difference from replay()
|
|
595
|
+
// is that this drives ONE ad-hoc action at a time by raw coordinate, not a
|
|
596
|
+
// recorded `steps` array by selector.
|
|
597
|
+
function listViews() {
|
|
598
|
+
const result = [];
|
|
599
|
+
for (const [viewId, view] of views.entries()) {
|
|
600
|
+
if (view.webContents.isDestroyed()) continue;
|
|
601
|
+
result.push({
|
|
602
|
+
viewId,
|
|
603
|
+
url: view.webContents.getURL(),
|
|
604
|
+
title: view.webContents.getTitle(),
|
|
605
|
+
active: viewId === lastShownViewId,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function resolveViewId(viewId) {
|
|
612
|
+
return viewId || lastShownViewId;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function dispatchAction(view, params) {
|
|
616
|
+
const wc = view.webContents;
|
|
617
|
+
switch (params.action) {
|
|
618
|
+
case 'left_click': {
|
|
619
|
+
const [x, y] = Array.isArray(params.coordinate) ? params.coordinate : [];
|
|
620
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
621
|
+
throw new Error('left_click requires coordinate: [x, y]');
|
|
622
|
+
}
|
|
623
|
+
await replayPositionedClick(wc, x, y);
|
|
624
|
+
return { ok: true };
|
|
625
|
+
}
|
|
626
|
+
case 'type': {
|
|
627
|
+
if (typeof params.text !== 'string') throw new Error('type requires text');
|
|
628
|
+
await wc.insertText(params.text);
|
|
629
|
+
return { ok: true };
|
|
630
|
+
}
|
|
631
|
+
case 'key': {
|
|
632
|
+
if (typeof params.key !== 'string' || !params.key) throw new Error('key requires key');
|
|
633
|
+
const { keyCode, modifiers } = parseKeyCombo(params.key);
|
|
634
|
+
wc.sendInputEvent({ type: 'keyDown', keyCode, modifiers });
|
|
635
|
+
if (keyCode.length === 1) wc.sendInputEvent({ type: 'char', keyCode, modifiers });
|
|
636
|
+
wc.sendInputEvent({ type: 'keyUp', keyCode, modifiers });
|
|
637
|
+
return { ok: true };
|
|
638
|
+
}
|
|
639
|
+
case 'scroll': {
|
|
640
|
+
const [x, y] = Array.isArray(params.coordinate) ? params.coordinate : [0, 0];
|
|
641
|
+
const { deltaX, deltaY } = computeScrollDelta(params);
|
|
642
|
+
wc.sendInputEvent({ type: 'mouseWheel', x: Number(x) || 0, y: Number(y) || 0, deltaX, deltaY });
|
|
643
|
+
return { ok: true };
|
|
644
|
+
}
|
|
645
|
+
case 'navigate': {
|
|
646
|
+
if (typeof params.url !== 'string' || !params.url) throw new Error('navigate requires url');
|
|
647
|
+
const normalized = normalizeUrl(params.url);
|
|
648
|
+
if (!normalized.ok) throw new Error(normalized.error);
|
|
649
|
+
await withTimeout(wc.loadURL(normalized.url), REPLAY_STEP_TIMEOUT_MS, 'navigate');
|
|
650
|
+
return { ok: true };
|
|
651
|
+
}
|
|
652
|
+
default:
|
|
653
|
+
throw new Error(`unsupported action: ${params.action}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Single-action entry point (as opposed to replay()'s recorded-steps-array
|
|
658
|
+
// entry point) — resolves viewId (defaulting to the active tab), dispatches
|
|
659
|
+
// exactly one action, and normalizes errors into the { ok, error } shape the
|
|
660
|
+
// rest of this module already uses.
|
|
661
|
+
async function dispatchViewAction(params) {
|
|
662
|
+
const viewId = resolveViewId(params.viewId);
|
|
663
|
+
if (!viewId) return { ok: false, error: 'no viewId given and no active tab' };
|
|
664
|
+
const view = views.get(viewId);
|
|
665
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
666
|
+
try {
|
|
667
|
+
return await dispatchAction(view, params);
|
|
668
|
+
} catch (e) {
|
|
669
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// On-demand screenshot for the agent API. Reuses wc.capturePage() (same call
|
|
674
|
+
// captureShot() below already uses) but additionally resizes to a fixed
|
|
675
|
+
// logical resolution — Anthropic's computer-use API silently downscales
|
|
676
|
+
// oversized screenshots server-side, which breaks coordinate mapping unless
|
|
677
|
+
// caller and model agree on one fixed max dimension. `scale` in the response
|
|
678
|
+
// lets a caller map a coordinate clicked on the returned image back to the
|
|
679
|
+
// real page: realX = clickedX / scale.
|
|
680
|
+
async function captureShotForAgent({ viewId } = {}) {
|
|
681
|
+
const id = resolveViewId(viewId);
|
|
682
|
+
if (!id) return { ok: false, error: 'no viewId given and no active tab' };
|
|
683
|
+
const view = views.get(id);
|
|
684
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
685
|
+
try {
|
|
686
|
+
const wc = view.webContents;
|
|
687
|
+
const image = await wc.capturePage();
|
|
688
|
+
const { width, height } = image.getSize();
|
|
689
|
+
const target = computeScreenshotScale(width, height);
|
|
690
|
+
const resized = target.scale < 1 ? image.resize({ width: target.width, height: target.height }) : image;
|
|
691
|
+
return {
|
|
692
|
+
ok: true,
|
|
693
|
+
viewId: id,
|
|
694
|
+
url: wc.getURL(),
|
|
695
|
+
title: wc.getTitle(),
|
|
696
|
+
dataUrl: resized.toDataURL(),
|
|
697
|
+
scale: target.scale,
|
|
698
|
+
width: target.width,
|
|
699
|
+
height: target.height,
|
|
700
|
+
originalWidth: width,
|
|
701
|
+
originalHeight: height,
|
|
702
|
+
};
|
|
703
|
+
} catch (e) {
|
|
704
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Cheap text/accessibility-style snapshot for the agent API — an alternative
|
|
709
|
+
// to vision-only driving since a real DOM is available here. Follows the
|
|
710
|
+
// same executeJavaScript sandboxing pattern as captureDom() below (plain
|
|
711
|
+
// IIFE returning a plain object, no new capability granted to the page).
|
|
712
|
+
// Kept cheap on purpose: innerText + a capped list of interactive elements
|
|
713
|
+
// with center-point coordinates, no full HTML serialization or computed
|
|
714
|
+
// style walking.
|
|
715
|
+
const AGENT_DOM_TEXT_MAX = 100_000;
|
|
716
|
+
const AGENT_DOM_ELEMENT_LIMIT = 200;
|
|
717
|
+
|
|
718
|
+
async function captureAgentDom({ viewId } = {}) {
|
|
719
|
+
const id = resolveViewId(viewId);
|
|
720
|
+
if (!id) return { ok: false, error: 'no viewId given and no active tab' };
|
|
721
|
+
const view = views.get(id);
|
|
722
|
+
if (!view || view.webContents.isDestroyed()) return { ok: false, error: 'unknown viewId' };
|
|
723
|
+
try {
|
|
724
|
+
const script = `(() => {
|
|
725
|
+
const SEL = 'button, a[href], input, textarea, select, [role="button"], [onclick]';
|
|
726
|
+
const els = Array.from(document.querySelectorAll(SEL)).slice(0, ${AGENT_DOM_ELEMENT_LIMIT});
|
|
727
|
+
const elements = els.map((el) => {
|
|
728
|
+
const rect = el.getBoundingClientRect();
|
|
729
|
+
const label = (el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').trim().slice(0, 120);
|
|
730
|
+
return {
|
|
731
|
+
tag: el.tagName.toLowerCase(),
|
|
732
|
+
label,
|
|
733
|
+
x: Math.round(rect.left + rect.width / 2),
|
|
734
|
+
y: Math.round(rect.top + rect.height / 2),
|
|
735
|
+
};
|
|
736
|
+
}).filter((e) => e.x >= 0 && e.y >= 0 && Number.isFinite(e.x) && Number.isFinite(e.y));
|
|
737
|
+
return {
|
|
738
|
+
url: location.href,
|
|
739
|
+
title: document.title,
|
|
740
|
+
text: document.body ? document.body.innerText : '',
|
|
741
|
+
elements,
|
|
742
|
+
};
|
|
743
|
+
})()`;
|
|
744
|
+
const result = await view.webContents.executeJavaScript(script);
|
|
745
|
+
let text = typeof result?.text === 'string' ? result.text : '';
|
|
746
|
+
let truncated = false;
|
|
747
|
+
if (text.length > AGENT_DOM_TEXT_MAX) {
|
|
748
|
+
text = text.slice(0, AGENT_DOM_TEXT_MAX);
|
|
749
|
+
truncated = true;
|
|
750
|
+
}
|
|
751
|
+
return {
|
|
752
|
+
ok: true,
|
|
753
|
+
viewId: id,
|
|
754
|
+
url: result?.url || '',
|
|
755
|
+
title: result?.title || '',
|
|
756
|
+
text,
|
|
757
|
+
truncated,
|
|
758
|
+
elements: Array.isArray(result?.elements) ? result.elements : [],
|
|
759
|
+
};
|
|
760
|
+
} catch (e) {
|
|
761
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
579
765
|
// Cap returned capture text so a huge DOM can't blow up the IPC channel.
|
|
580
766
|
const CAPTURE_TEXT_MAX = 500_000;
|
|
581
767
|
|
|
@@ -655,4 +841,14 @@ function registerBrowserView({ mainWindow, ipcMain }) {
|
|
|
655
841
|
}));
|
|
656
842
|
}
|
|
657
843
|
|
|
658
|
-
module.exports = {
|
|
844
|
+
module.exports = {
|
|
845
|
+
registerBrowserView,
|
|
846
|
+
attachWindow,
|
|
847
|
+
views,
|
|
848
|
+
isBrowserViewContents,
|
|
849
|
+
getView,
|
|
850
|
+
listViews,
|
|
851
|
+
dispatchViewAction,
|
|
852
|
+
captureShotForAgent,
|
|
853
|
+
captureAgentDom,
|
|
854
|
+
};
|
package/src/main/index.cjs
CHANGED
|
@@ -26,6 +26,13 @@ const voiceWizard = require('./voiceWizard.cjs');
|
|
|
26
26
|
const scheduler = require('./scheduler.cjs');
|
|
27
27
|
const { createAdminServer } = require('./adminServer.cjs');
|
|
28
28
|
const adminServer = createAdminServer(scheduler.remote);
|
|
29
|
+
const { createBrowserAgentServer } = require('./browserAgentServer.cjs');
|
|
30
|
+
const browserAgentServer = createBrowserAgentServer({
|
|
31
|
+
listTabs: () => browserView.listViews(),
|
|
32
|
+
screenshot: ({ viewId }) => browserView.captureShotForAgent({ viewId }),
|
|
33
|
+
action: (params) => browserView.dispatchViewAction(params),
|
|
34
|
+
dom: ({ viewId }) => browserView.captureAgentDom({ viewId }),
|
|
35
|
+
});
|
|
29
36
|
const supervisor = require('./supervisor.cjs');
|
|
30
37
|
const watchers = require('./watchers.cjs');
|
|
31
38
|
const teams = require('./teams.cjs');
|
|
@@ -1059,6 +1066,9 @@ app.whenReady().then(async () => {
|
|
|
1059
1066
|
adminServer.start().catch((e) => {
|
|
1060
1067
|
logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1061
1068
|
});
|
|
1069
|
+
browserAgentServer.start().catch((e) => {
|
|
1070
|
+
logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1071
|
+
});
|
|
1062
1072
|
// First-boot default: install the bundled session-manager-dev plugin (its 10
|
|
1063
1073
|
// dev skills) from the app's own marketplace. One-shot + idempotent; never
|
|
1064
1074
|
// throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
|
|
@@ -1149,6 +1159,7 @@ app.on('before-quit', () => {
|
|
|
1149
1159
|
transcripts.closeAll();
|
|
1150
1160
|
watchers.manager.killAll();
|
|
1151
1161
|
adminServer.stop().catch(() => {});
|
|
1162
|
+
browserAgentServer.stop().catch(() => {});
|
|
1152
1163
|
// Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
|
|
1153
1164
|
// ceiling so a wedged exporter can't hold quit.
|
|
1154
1165
|
otel.shutdown().catch(() => {});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browserAgentActions.test.cjs — unit tests for the pure action-mapping
|
|
3
|
+
* logic behind the on-demand browser-agent API (PRD 535).
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 node --test src/main/lib/__tests__/browserAgentActions.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
const { test } = require('node:test');
|
|
11
|
+
const assert = require('node:assert/strict');
|
|
12
|
+
const {
|
|
13
|
+
parseKeyCombo,
|
|
14
|
+
computeScrollDelta,
|
|
15
|
+
computeScreenshotScale,
|
|
16
|
+
AGENT_SCREENSHOT_MAX_DIM,
|
|
17
|
+
} = require('../browserAgentActions.cjs');
|
|
18
|
+
|
|
19
|
+
test('parseKeyCombo: plain special key', () => {
|
|
20
|
+
assert.deepEqual(parseKeyCombo('Return'), { keyCode: 'Enter', modifiers: [] });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('parseKeyCombo: single modifier + letter', () => {
|
|
24
|
+
assert.deepEqual(parseKeyCombo('ctrl+s'), { keyCode: 's', modifiers: ['control'] });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('parseKeyCombo: multiple modifiers', () => {
|
|
28
|
+
assert.deepEqual(parseKeyCombo('ctrl+shift+p'), { keyCode: 'p', modifiers: ['control', 'shift'] });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('parseKeyCombo: cmd/meta/option aliases normalize', () => {
|
|
32
|
+
assert.deepEqual(parseKeyCombo('cmd+option+Escape'), { keyCode: 'Escape', modifiers: ['meta', 'alt'] });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('parseKeyCombo: unknown modifier throws', () => {
|
|
36
|
+
assert.throws(() => parseKeyCombo('foo+a'), /unknown modifier/);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('parseKeyCombo: empty string throws', () => {
|
|
40
|
+
assert.throws(() => parseKeyCombo(''), /empty key/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('computeScrollDelta: up/down/left/right map to signed deltas', () => {
|
|
44
|
+
assert.deepEqual(computeScrollDelta({ scroll_direction: 'up', scroll_amount: 2 }), { deltaX: 0, deltaY: 200 });
|
|
45
|
+
assert.deepEqual(computeScrollDelta({ scroll_direction: 'down', scroll_amount: 2 }), { deltaX: 0, deltaY: -200 });
|
|
46
|
+
assert.deepEqual(computeScrollDelta({ scroll_direction: 'left', scroll_amount: 1 }), { deltaX: 100, deltaY: 0 });
|
|
47
|
+
assert.deepEqual(computeScrollDelta({ scroll_direction: 'right', scroll_amount: 1 }), { deltaX: -100, deltaY: 0 });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('computeScrollDelta: defaults scroll_amount to 1 when not finite', () => {
|
|
51
|
+
assert.deepEqual(computeScrollDelta({ scroll_direction: 'up' }), { deltaX: 0, deltaY: 100 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('computeScrollDelta: unsupported direction throws', () => {
|
|
55
|
+
assert.throws(() => computeScrollDelta({ scroll_direction: 'diagonal' }), /unsupported scroll_direction/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('computeScreenshotScale: leaves small images untouched', () => {
|
|
59
|
+
const result = computeScreenshotScale(800, 600);
|
|
60
|
+
assert.equal(result.scale, 1);
|
|
61
|
+
assert.equal(result.width, 800);
|
|
62
|
+
assert.equal(result.height, 600);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('computeScreenshotScale: downscales long edge to the fixed max', () => {
|
|
66
|
+
const result = computeScreenshotScale(2560, 1440);
|
|
67
|
+
assert.equal(result.width, AGENT_SCREENSHOT_MAX_DIM);
|
|
68
|
+
assert.ok(result.scale < 1);
|
|
69
|
+
assert.equal(result.height, Math.round(1440 * result.scale));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('computeScreenshotScale: portrait image scales on height', () => {
|
|
73
|
+
const result = computeScreenshotScale(900, 3000);
|
|
74
|
+
assert.equal(result.height, AGENT_SCREENSHOT_MAX_DIM);
|
|
75
|
+
assert.ok(result.width < 900);
|
|
76
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browserAgentActions.cjs — pure mapping logic for the on-demand single-
|
|
3
|
+
* action browser-agent API (PRD 535). Extracted out of browserView.cjs so
|
|
4
|
+
* the action -> input-event/scale mapping can be unit-tested without
|
|
5
|
+
* booting Electron — mirrors how lib/schedulerBatch.cjs was pulled out of
|
|
6
|
+
* scheduler.cjs for the same reason. Anything that actually touches a live
|
|
7
|
+
* WebContentsView (sendInputEvent, capturePage, executeJavaScript) stays in
|
|
8
|
+
* browserView.cjs; this module only computes the values passed to those
|
|
9
|
+
* calls.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
// Anthropic's computer_use tool scrolls in "clicks"; one click here maps to
|
|
15
|
+
// this many CSS pixels of wheel delta.
|
|
16
|
+
const SCROLL_UNIT_PX = 100;
|
|
17
|
+
|
|
18
|
+
// Anthropic-tool-use-style key names -> Electron's sendInputEvent keyCode
|
|
19
|
+
// strings. Anything not in this table is passed through as-is (covers plain
|
|
20
|
+
// single characters like "a" or "s").
|
|
21
|
+
const SPECIAL_KEY_MAP = {
|
|
22
|
+
return: 'Enter',
|
|
23
|
+
enter: 'Enter',
|
|
24
|
+
tab: 'Tab',
|
|
25
|
+
escape: 'Escape',
|
|
26
|
+
esc: 'Escape',
|
|
27
|
+
backspace: 'Backspace',
|
|
28
|
+
delete: 'Delete',
|
|
29
|
+
space: ' ',
|
|
30
|
+
up: 'Up',
|
|
31
|
+
down: 'Down',
|
|
32
|
+
left: 'Left',
|
|
33
|
+
right: 'Right',
|
|
34
|
+
page_up: 'PageUp',
|
|
35
|
+
pageup: 'PageUp',
|
|
36
|
+
page_down: 'PageDown',
|
|
37
|
+
pagedown: 'PageDown',
|
|
38
|
+
home: 'Home',
|
|
39
|
+
end: 'End',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const MODIFIER_MAP = {
|
|
43
|
+
ctrl: 'control',
|
|
44
|
+
control: 'control',
|
|
45
|
+
shift: 'shift',
|
|
46
|
+
alt: 'alt',
|
|
47
|
+
option: 'alt',
|
|
48
|
+
cmd: 'meta',
|
|
49
|
+
meta: 'meta',
|
|
50
|
+
super: 'meta',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Parses a computer-use-style key combo ("ctrl+s", "Return", "a") into the
|
|
54
|
+
// { keyCode, modifiers } shape Electron's webContents.sendInputEvent wants.
|
|
55
|
+
function parseKeyCombo(keyStr) {
|
|
56
|
+
const parts = String(keyStr || '')
|
|
57
|
+
.split('+')
|
|
58
|
+
.map((p) => p.trim())
|
|
59
|
+
.filter(Boolean);
|
|
60
|
+
if (!parts.length) throw new Error('empty key');
|
|
61
|
+
const last = parts.pop();
|
|
62
|
+
const modifiers = parts.map((p) => {
|
|
63
|
+
const mapped = MODIFIER_MAP[p.toLowerCase()];
|
|
64
|
+
if (!mapped) throw new Error(`unknown modifier: ${p}`);
|
|
65
|
+
return mapped;
|
|
66
|
+
});
|
|
67
|
+
const lowerLast = last.toLowerCase();
|
|
68
|
+
const keyCode = SPECIAL_KEY_MAP[lowerLast] || last;
|
|
69
|
+
return { keyCode, modifiers };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Maps a computer-use-style scroll action to a mouseWheel delta.
|
|
73
|
+
function computeScrollDelta({ scroll_direction, scroll_amount }) {
|
|
74
|
+
const amount = Number.isFinite(scroll_amount) ? scroll_amount : 1;
|
|
75
|
+
switch (scroll_direction) {
|
|
76
|
+
case 'up':
|
|
77
|
+
return { deltaX: 0, deltaY: amount * SCROLL_UNIT_PX };
|
|
78
|
+
case 'down':
|
|
79
|
+
return { deltaX: 0, deltaY: -amount * SCROLL_UNIT_PX };
|
|
80
|
+
case 'left':
|
|
81
|
+
return { deltaX: amount * SCROLL_UNIT_PX, deltaY: 0 };
|
|
82
|
+
case 'right':
|
|
83
|
+
return { deltaX: -amount * SCROLL_UNIT_PX, deltaY: 0 };
|
|
84
|
+
default:
|
|
85
|
+
throw new Error(`unsupported scroll_direction: ${scroll_direction}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Fixed max long-edge a screenshot is resized to before being returned.
|
|
90
|
+
// Anthropic's computer-use API silently downscales oversized images
|
|
91
|
+
// server-side, which breaks coordinate mapping unless caller and model agree
|
|
92
|
+
// on one fixed logical resolution up front — this is that fixed point.
|
|
93
|
+
const AGENT_SCREENSHOT_MAX_DIM = 1280;
|
|
94
|
+
|
|
95
|
+
// Given a captured screenshot's real pixel size, returns the target size to
|
|
96
|
+
// resize to plus the scale factor a caller must divide a clicked coordinate
|
|
97
|
+
// on the resized image by to recover the real-page coordinate.
|
|
98
|
+
function computeScreenshotScale(width, height) {
|
|
99
|
+
const longEdge = Math.max(width, height);
|
|
100
|
+
const scale = longEdge > AGENT_SCREENSHOT_MAX_DIM ? AGENT_SCREENSHOT_MAX_DIM / longEdge : 1;
|
|
101
|
+
return {
|
|
102
|
+
scale,
|
|
103
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
104
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = {
|
|
109
|
+
parseKeyCombo,
|
|
110
|
+
computeScrollDelta,
|
|
111
|
+
computeScreenshotScale,
|
|
112
|
+
AGENT_SCREENSHOT_MAX_DIM,
|
|
113
|
+
SCROLL_UNIT_PX,
|
|
114
|
+
};
|
|
@@ -33,13 +33,15 @@ const DEFAULT_PROJECT_CWD = path.join(os.homedir(), 'Projects', 'session-manager
|
|
|
33
33
|
* runningSet that belong to this project.
|
|
34
34
|
* @param {number} slots - Maximum jobs to return (global remaining slots;
|
|
35
35
|
* caller enforces the global cap across projects).
|
|
36
|
-
* @returns {object[]} Jobs to spawn for this
|
|
36
|
+
* @returns {{ batch: object[], reason: string | null }} Jobs to spawn for this
|
|
37
|
+
* project this tick, plus (when batch is empty because a gate held it) the
|
|
38
|
+
* human-readable reason text that would otherwise only reach console.log.
|
|
37
39
|
*/
|
|
38
40
|
function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
39
41
|
const pending = projectJobs.filter(
|
|
40
42
|
(j) => j.status === 'pending' && !runningSlugsInProject.has(j.slug),
|
|
41
43
|
);
|
|
42
|
-
if (pending.length === 0) return [];
|
|
44
|
+
if (pending.length === 0) return { batch: [], reason: null };
|
|
43
45
|
|
|
44
46
|
const projectCwd = (projectJobs.find((j) => j.cwd) || {}).cwd || DEFAULT_PROJECT_CWD;
|
|
45
47
|
|
|
@@ -57,12 +59,11 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
57
59
|
);
|
|
58
60
|
if (blockingFailures.length > 0) {
|
|
59
61
|
const slugs = blockingFailures.map((j) => j.slug).join(', ');
|
|
60
|
-
|
|
61
|
-
`[scheduler] failure-gate [${projectCwd}]: holding g${lowestPendingGroup} — ` +
|
|
62
|
+
const reason = `[scheduler] failure-gate [${projectCwd}]: holding g${lowestPendingGroup} — ` +
|
|
62
63
|
`${blockingFailures.length} failed job(s) in earlier groups [${slugs}]. ` +
|
|
63
|
-
`Reset to pending or archive to unblock
|
|
64
|
-
);
|
|
65
|
-
return [];
|
|
64
|
+
`Reset to pending or archive to unblock.`;
|
|
65
|
+
console.log(reason);
|
|
66
|
+
return { batch: [], reason };
|
|
66
67
|
}
|
|
67
68
|
|
|
68
69
|
// Groups with at least one job in flight: either tracked in runningSlugsInProject
|
|
@@ -84,20 +85,18 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
84
85
|
const lowestActive = Math.min(...activeGroups);
|
|
85
86
|
if (lowestPendingGroup > lowestActive) {
|
|
86
87
|
// Earlier group still running — wait for it to drain before advancing.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return [];
|
|
88
|
+
const reason = `[scheduler] concurrency [${projectCwd}]: g${lowestActive} in flight, holding g${lowestPendingGroup}`;
|
|
89
|
+
console.log(reason);
|
|
90
|
+
return { batch: [], reason };
|
|
91
91
|
}
|
|
92
92
|
if (lowestPendingGroup < lowestActive) {
|
|
93
93
|
// Late-arrival: a lower-numbered (higher-priority) PRD reconciled AFTER
|
|
94
94
|
// a higher-numbered group was already picked. Fire it now in parallel
|
|
95
95
|
// with the active group rather than starving it until drain.
|
|
96
96
|
if (slots <= 0) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return [];
|
|
97
|
+
const reason = `[scheduler] concurrency [${projectCwd}]: no slots for late-arrival g${lowestPendingGroup}`;
|
|
98
|
+
console.log(reason);
|
|
99
|
+
return { batch: [], reason };
|
|
101
100
|
}
|
|
102
101
|
const batch = pending
|
|
103
102
|
.filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
|
|
@@ -106,12 +105,13 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
106
105
|
`[scheduler] concurrency [${projectCwd}]: firing late-arrival g${lowestPendingGroup} ` +
|
|
107
106
|
`(${batch.length} job(s)) alongside active g${lowestActive}`,
|
|
108
107
|
);
|
|
109
|
-
return batch;
|
|
108
|
+
return { batch, reason: null };
|
|
110
109
|
}
|
|
111
110
|
// Backfill slots remaining in the current group.
|
|
112
111
|
if (slots <= 0) {
|
|
113
|
-
|
|
114
|
-
|
|
112
|
+
const reason = `[scheduler] concurrency [${projectCwd}]: cap reached, no slots`;
|
|
113
|
+
console.log(reason);
|
|
114
|
+
return { batch: [], reason };
|
|
115
115
|
}
|
|
116
116
|
const batch = pending
|
|
117
117
|
.filter((j) => (j.parallelGroup ?? 99) === lowestActive)
|
|
@@ -121,13 +121,14 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
121
121
|
`[scheduler] concurrency [${projectCwd}]: backfilling ${batch.length} into g${lowestActive}`,
|
|
122
122
|
);
|
|
123
123
|
}
|
|
124
|
-
return batch;
|
|
124
|
+
return { batch, reason: null };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
// No active group — start the next group fresh.
|
|
128
128
|
if (slots <= 0) {
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
const reason = `[scheduler] concurrency [${projectCwd}]: cap reached, no slots`;
|
|
130
|
+
console.log(reason);
|
|
131
|
+
return { batch: [], reason };
|
|
131
132
|
}
|
|
132
133
|
const batch = pending
|
|
133
134
|
.filter((j) => (j.parallelGroup ?? 99) === lowestPendingGroup)
|
|
@@ -135,7 +136,7 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
135
136
|
console.log(
|
|
136
137
|
`[scheduler] concurrency [${projectCwd}]: starting g${lowestPendingGroup} with ${batch.length} job(s)`,
|
|
137
138
|
);
|
|
138
|
-
return batch;
|
|
139
|
+
return { batch, reason: null };
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
/**
|
|
@@ -150,10 +151,14 @@ function pickForProject(projectJobs, runningSlugsInProject, slots) {
|
|
|
150
151
|
* @param {object[]} allJobs - Full queue.json job list.
|
|
151
152
|
* @param {Set<string>} running - In-process running slugs (runningSet).
|
|
152
153
|
* @param {number} cap - concurrencyCap.
|
|
153
|
-
* @returns {object[]} Jobs to spawn this
|
|
154
|
+
* @returns {{ batch: object[], reason: string | null }} Jobs to spawn this
|
|
155
|
+
* tick, plus (when batch is empty because a gate held it) the human-readable
|
|
156
|
+
* hold reason that would otherwise only reach console.log.
|
|
154
157
|
*/
|
|
155
158
|
function pickNextBatch(allJobs, running, cap) {
|
|
156
|
-
if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug)))
|
|
159
|
+
if (!allJobs.some((j) => j.status === 'pending' && !running.has(j.slug))) {
|
|
160
|
+
return { batch: [], reason: null };
|
|
161
|
+
}
|
|
157
162
|
|
|
158
163
|
// Global slot accounting: take the higher of in-process running count and
|
|
159
164
|
// queue.json running count (handles orphaned running entries from a previous
|
|
@@ -162,10 +167,9 @@ function pickNextBatch(allJobs, running, cap) {
|
|
|
162
167
|
const effectiveRunning = Math.max(running.size, queueRunningCount);
|
|
163
168
|
let slots = cap - effectiveRunning;
|
|
164
169
|
if (slots <= 0) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return [];
|
|
170
|
+
const reason = `[scheduler] concurrency: cap ${cap} reached (${effectiveRunning} running), no slots`;
|
|
171
|
+
console.log(reason);
|
|
172
|
+
return { batch: [], reason };
|
|
169
173
|
}
|
|
170
174
|
|
|
171
175
|
// Group all jobs by project cwd.
|
|
@@ -198,15 +202,18 @@ function pickNextBatch(allJobs, running, cap) {
|
|
|
198
202
|
// slot allocation ties across projects.
|
|
199
203
|
projectCandidates.sort((a, b) => a.lowestPendingForProject - b.lowestPendingForProject);
|
|
200
204
|
|
|
201
|
-
// Aggregate batch across projects, consuming global slots as we go.
|
|
205
|
+
// Aggregate batch across projects, consuming global slots as we go. Track
|
|
206
|
+
// the first hold reason seen so callers can explain an empty overall batch.
|
|
202
207
|
const batch = [];
|
|
208
|
+
let heldReason = null;
|
|
203
209
|
for (const { projectJobs, runningSlugsInProject } of projectCandidates) {
|
|
204
210
|
if (slots <= 0) break;
|
|
205
|
-
const
|
|
206
|
-
batch.push(...
|
|
207
|
-
slots -=
|
|
211
|
+
const projectResult = pickForProject(projectJobs, runningSlugsInProject, slots);
|
|
212
|
+
batch.push(...projectResult.batch);
|
|
213
|
+
slots -= projectResult.batch.length;
|
|
214
|
+
if (heldReason === null && projectResult.reason) heldReason = projectResult.reason;
|
|
208
215
|
}
|
|
209
|
-
return batch;
|
|
216
|
+
return { batch, reason: batch.length === 0 ? heldReason : null };
|
|
210
217
|
}
|
|
211
218
|
|
|
212
219
|
module.exports = { pickForProject, pickNextBatch, DEFAULT_PROJECT_CWD };
|