claude-code-session-manager 0.35.13 → 0.35.15

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/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-LqC9hNy3.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-DRSHENEg.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-C7NyYuXu.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-B39Qiq0n.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.35.13",
3
+ "version": "0.35.15",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -1,16 +1,20 @@
1
1
  ---
2
2
  name: process-feedback
3
3
  description: >-
4
- Process the current project's inbound feedback folder end-to-end: read every
5
- open item, evaluate it against real code, and for work that belongs to this
4
+ Process the current project's inbound feedback folder end-to-end: first sync
5
+ any open GitHub issues on the project's repo into the intake as new feedback
6
+ files (deduped against already-tracked issues), then read every open item,
7
+ evaluate it against real code, and for work that belongs to this
6
8
  project queue it as scheduled PRDs via /develop (never implement inline),
7
9
  archive the item as processed **the moment it is queued** (the scheduler owns
8
10
  execution from there), and fold lessons back into the folder's README so
9
- future feedback (written by other agents/projects) gets better. Use whenever the user says "/process-feedback",
11
+ future feedback (written by other agents/projects, or GitHub issue filers)
12
+ gets better. Use whenever the user says "/process-feedback",
10
13
  "review the feedback folder", "work through the feedback", "any open
11
- feedback?", or drops new files into session-manager-operations/feedback/.
14
+ feedback?", "check the GitHub issues", "triage the GitHub repo", or drops
15
+ new files into session-manager-operations/feedback/.
12
16
  Keywords: feedback, intake, cross-project requests, process feedback, triage
13
- feedback.
17
+ feedback, GitHub issues.
14
18
  model: opus
15
19
  ---
16
20
 
@@ -68,7 +72,49 @@ Before anything else, check for the pre-migration layout: if a legacy
68
72
  `git mv feedback session-manager-operations/feedback` when the repo is
69
73
  git-tracked, else a plain `mv`. This converges any project this skill runs in
70
74
  to the new layout even if its bulk-relocation PRD hasn't landed yet. Only then
71
- continue with step 0.
75
+ continue with step 0b.
76
+
77
+ ### 0b. Sync open GitHub issues into the intake (source sync)
78
+
79
+ For a project with a GitHub remote, pull open issues and materialize any not already tracked as a
80
+ new feedback file — **before** the quick-exit check below evaluates what's open, since this step
81
+ can create new open items that check needs to see. This lets a repo's GitHub issue tracker feed
82
+ the same triage → queue pipeline without a human manually copying issues into the feedback folder.
83
+
84
+ - Skip silently (no error, no report) if `gh repo view --json nameWithOwner -q .nameWithOwner`
85
+ fails — no GitHub remote or `gh` unauthenticated. This step is opportunistic, not required.
86
+ - Fetch open issues via `gh api repos/<owner>/<repo>/issues --paginate --jq '...'` (per-issue
87
+ `number,title,body,labels`) rather than `gh issue list`/`gh issue view` — the latter two can hit
88
+ a known `gh` CLI bug on repos with legacy GitHub Projects (classic) boards
89
+ (`GraphQL: Projects (classic) is being deprecated ... (repository.issue.projectCards)`,
90
+ documented in `standards.md`'s Execution discipline section); `gh api` sidesteps it outright by
91
+ never touching the deprecated field. Note `gh api .../issues` also returns pull requests (they
92
+ have an `issue.pull_request` key) — filter those out, this step is issues only.
93
+ - **Dedup before creating anything.** An issue already tracked has a feedback file (open OR
94
+ already in `processed/`) containing the token `gh-issue-<N>` — `grep -rl "gh-issue-<N>"
95
+ session-manager-operations/feedback/` (recursive, covers both the inbox and `processed/`) before
96
+ materializing issue `N`. Skip any issue that already has a match.
97
+ - For each untracked open issue, create
98
+ `session-manager-operations/feedback/<yyyy-mm-dd>-gh-issue-<N>-<slug>.md` following the folder's
99
+ normal frontmatter convention (see `README.md`):
100
+ - `title`: the issue title, verbatim.
101
+ - `source`: `GitHub issue gh-issue-<N> (<issue URL>)` — the `gh-issue-<N>` token here is what
102
+ the dedup grep matches on next run; don't reword it out.
103
+ - `type`: infer from labels (`bug` label → `bug`; `enhancement`/no matching label → `enhancement`;
104
+ `security` → `security`; `performance` → `performance`).
105
+ - `severity`: `normal` unless the issue's own labels/title clearly indicate `blocker`/`high`
106
+ (data loss, crash, security hole, or an unusable feature with no workaround — same bar as the
107
+ README's own severity guidance; don't inflate).
108
+ - Body: `# What happens / what's missing` = the issue body (verbatim, or a faithful trim if very
109
+ long — don't paraphrase away specifics); `# Evidence` = the issue URL + number, plus any
110
+ screenshot/repro references already in the issue body; `# Suggested direction (optional)` =
111
+ any proposed-solution section already present in the issue body.
112
+ - Synced files then flow through steps 1–6 below exactly like any other feedback file — no
113
+ special-casing after creation, same evaluate → queue → disposition → archive pipeline.
114
+ - When step 4 dispositions a synced issue by queuing a PRD, the `## RESOLUTION` must also state
115
+ the originating issue number/URL. This skill does **not** auto-close or auto-comment on the
116
+ GitHub issue — that stays a human (or a later, deliberate step) decision once the fix actually
117
+ ships, not something this sync step does on queue.
72
118
 
73
119
  ### 0. Quick-exit — bail in milliseconds if there's nothing to do
74
120
 
@@ -0,0 +1,186 @@
1
+ /**
2
+ * browserAgentServer.test.cjs — unit tests for the loopback-only browser
3
+ * agent HTTP API (PRD 535).
4
+ *
5
+ * Run: timeout 120 node --test src/main/__tests__/browserAgentServer.test.cjs
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { test } = require('node:test');
11
+ const assert = require('node:assert/strict');
12
+ const http = require('node:http');
13
+ const { createBrowserAgentServer } = require('../browserAgentServer.cjs');
14
+
15
+ function request(port, { method = 'GET', path: reqPath, token, body }) {
16
+ return new Promise((resolve, reject) => {
17
+ const headers = {};
18
+ if (token !== undefined) headers.Authorization = `Bearer ${token}`;
19
+ let payload;
20
+ if (body !== undefined) {
21
+ payload = JSON.stringify(body);
22
+ headers['Content-Type'] = 'application/json';
23
+ headers['Content-Length'] = Buffer.byteLength(payload);
24
+ }
25
+ const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
26
+ const chunks = [];
27
+ res.on('data', (c) => chunks.push(c));
28
+ res.on('end', () => {
29
+ const text = Buffer.concat(chunks).toString('utf8');
30
+ let json = null;
31
+ try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
32
+ resolve({ status: res.statusCode, json, text });
33
+ });
34
+ });
35
+ req.on('error', reject);
36
+ if (payload) req.write(payload);
37
+ req.end();
38
+ });
39
+ }
40
+
41
+ function makeFakeRemote() {
42
+ const actions = [];
43
+ return {
44
+ actions,
45
+ async listTabs() {
46
+ return [{ viewId: 'v1', url: 'https://example.com', title: 'Example', active: true }];
47
+ },
48
+ async screenshot({ viewId }) {
49
+ if (!viewId && viewId !== undefined) return { ok: false, error: 'unknown viewId' };
50
+ return { ok: true, viewId: viewId || 'v1', dataUrl: 'data:image/png;base64,AAA', scale: 1 };
51
+ },
52
+ async action(params) {
53
+ actions.push(params);
54
+ if (params.action === 'boom') return { ok: false, error: 'unsupported action: boom' };
55
+ return { ok: true };
56
+ },
57
+ async dom({ viewId }) {
58
+ return { ok: true, viewId: viewId || 'v1', text: 'hello', elements: [] };
59
+ },
60
+ };
61
+ }
62
+
63
+ test('rejects requests without a bearer token', async () => {
64
+ const remote = makeFakeRemote();
65
+ const server = createBrowserAgentServer(remote);
66
+ const { port } = await server.start();
67
+ try {
68
+ const res = await request(port, { path: '/agent/browser/tabs' });
69
+ assert.equal(res.status, 401);
70
+ assert.equal(res.json.ok, false);
71
+ } finally {
72
+ await server.stop();
73
+ }
74
+ });
75
+
76
+ test('rejects requests with a wrong token', async () => {
77
+ const remote = makeFakeRemote();
78
+ const server = createBrowserAgentServer(remote);
79
+ const { port } = await server.start();
80
+ try {
81
+ const res = await request(port, { path: '/agent/browser/tabs', token: 'wrong-token' });
82
+ assert.equal(res.status, 401);
83
+ } finally {
84
+ await server.stop();
85
+ }
86
+ });
87
+
88
+ test('GET /agent/browser/tabs lists open tabs', async () => {
89
+ const remote = makeFakeRemote();
90
+ const server = createBrowserAgentServer(remote);
91
+ const { port, token } = await server.start();
92
+ try {
93
+ const res = await request(port, { path: '/agent/browser/tabs', token });
94
+ assert.equal(res.status, 200);
95
+ assert.equal(res.json.ok, true);
96
+ assert.equal(res.json.tabs.length, 1);
97
+ assert.equal(res.json.tabs[0].active, true);
98
+ } finally {
99
+ await server.stop();
100
+ }
101
+ });
102
+
103
+ test('POST /agent/browser/screenshot returns a resized image payload', async () => {
104
+ const remote = makeFakeRemote();
105
+ const server = createBrowserAgentServer(remote);
106
+ const { port, token } = await server.start();
107
+ try {
108
+ const res = await request(port, { method: 'POST', path: '/agent/browser/screenshot', token, body: {} });
109
+ assert.equal(res.status, 200);
110
+ assert.equal(res.json.ok, true);
111
+ assert.ok(res.json.dataUrl.startsWith('data:image/png'));
112
+ } finally {
113
+ await server.stop();
114
+ }
115
+ });
116
+
117
+ test('POST /agent/browser/action dispatches a single action', async () => {
118
+ const remote = makeFakeRemote();
119
+ const server = createBrowserAgentServer(remote);
120
+ const { port, token } = await server.start();
121
+ try {
122
+ const res = await request(port, {
123
+ method: 'POST',
124
+ path: '/agent/browser/action',
125
+ token,
126
+ body: { action: 'left_click', coordinate: [10, 20] },
127
+ });
128
+ assert.equal(res.status, 200);
129
+ assert.equal(res.json.ok, true);
130
+ assert.equal(remote.actions.length, 1);
131
+ assert.deepEqual(remote.actions[0].coordinate, [10, 20]);
132
+ } finally {
133
+ await server.stop();
134
+ }
135
+ });
136
+
137
+ test('POST /agent/browser/action without an action field is a 400', async () => {
138
+ const remote = makeFakeRemote();
139
+ const server = createBrowserAgentServer(remote);
140
+ const { port, token } = await server.start();
141
+ try {
142
+ const res = await request(port, { method: 'POST', path: '/agent/browser/action', token, body: {} });
143
+ assert.equal(res.status, 400);
144
+ assert.equal(res.json.ok, false);
145
+ } finally {
146
+ await server.stop();
147
+ }
148
+ });
149
+
150
+ test('POST /agent/browser/action surfaces a remote failure as a 400', async () => {
151
+ const remote = makeFakeRemote();
152
+ const server = createBrowserAgentServer(remote);
153
+ const { port, token } = await server.start();
154
+ try {
155
+ const res = await request(port, { method: 'POST', path: '/agent/browser/action', token, body: { action: 'boom' } });
156
+ assert.equal(res.status, 400);
157
+ assert.equal(res.json.ok, false);
158
+ } finally {
159
+ await server.stop();
160
+ }
161
+ });
162
+
163
+ test('GET /agent/browser/dom returns a text snapshot', async () => {
164
+ const remote = makeFakeRemote();
165
+ const server = createBrowserAgentServer(remote);
166
+ const { port, token } = await server.start();
167
+ try {
168
+ const res = await request(port, { path: '/agent/browser/dom', token });
169
+ assert.equal(res.status, 200);
170
+ assert.equal(res.json.text, 'hello');
171
+ } finally {
172
+ await server.stop();
173
+ }
174
+ });
175
+
176
+ test('unknown route is a 404', async () => {
177
+ const remote = makeFakeRemote();
178
+ const server = createBrowserAgentServer(remote);
179
+ const { port, token } = await server.start();
180
+ try {
181
+ const res = await request(port, { path: '/agent/browser/nope', token });
182
+ assert.equal(res.status, 404);
183
+ } finally {
184
+ await server.stop();
185
+ }
186
+ });
@@ -11,6 +11,7 @@ const assert = require('node:assert/strict');
11
11
  const {
12
12
  selectAutoFixTargets, isUnresolvableNeedsReview, isRescanCandidate,
13
13
  healTargetForFix, isFixPlanBeyondDepthCap, MAX_INVESTIGATION_DEPTH,
14
+ isEligibleForImmediateAutoFix,
14
15
  } = require('../scheduler.cjs');
15
16
 
16
17
  const noSiblingOnDisk = () => false;
@@ -213,4 +214,48 @@ test('healTargetForFix: depth-2 fix-of-a-fix slug resolves back to the depth-1 f
213
214
  assert.strictEqual(resolved, depth1FixJob);
214
215
  });
215
216
 
217
+ // ---- isEligibleForImmediateAutoFix (spawnJob's same-tick auto-fix trigger,
218
+ // PRD 2026-07-12: needs_review jobs no longer wait up to 10 min for
219
+ // reverifyNeedsReview()'s periodic pass) ----
220
+
221
+ test('isEligibleForImmediateAutoFix: fresh needs_review job (fresh from spawnJob, not reverifyNeedsReview) is eligible', () => {
222
+ const job = makeJob();
223
+ const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
224
+ assert.strictEqual(result, true);
225
+ });
226
+
227
+ test('isEligibleForImmediateAutoFix: job already autoFixAttempted is excluded (prevents double-spawn with the periodic pass)', () => {
228
+ const job = makeJob({ autoFixAttempted: true });
229
+ const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
230
+ assert.strictEqual(result, false);
231
+ });
232
+
233
+ test('isEligibleForImmediateAutoFix: fix sibling already on disk excludes the job', () => {
234
+ const job = makeJob();
235
+ // fixSlug = '05-fix-my-feature'
236
+ const result = isEligibleForImmediateAutoFix(job, [job], (s) => s === '05-fix-my-feature');
237
+ assert.strictEqual(result, false);
238
+ });
239
+
240
+ test('isEligibleForImmediateAutoFix: fix-plan job beyond the depth cap is excluded', () => {
241
+ const job = makeJob({ slug: '05-fix-foo', investigationDepth: 3 });
242
+ const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
243
+ assert.strictEqual(result, false);
244
+ });
245
+
246
+ test('isEligibleForImmediateAutoFix: a different job in the same queue does not affect this job\'s eligibility', () => {
247
+ const job = makeJob();
248
+ const other = { slug: '05-fix-my-feature', status: 'pending', runId: null };
249
+ const result = isEligibleForImmediateAutoFix(job, [job, other], noSiblingOnDisk);
250
+ // The fix sibling is already queued, so this job is excluded too (matches
251
+ // selectAutoFixTargets's "no fix sibling in queue" rule).
252
+ assert.strictEqual(result, false);
253
+ });
254
+
255
+ test('isEligibleForImmediateAutoFix: a job whose status is not needs_review (e.g. still pending) is excluded', () => {
256
+ const job = makeJob({ status: 'pending' });
257
+ const result = isEligibleForImmediateAutoFix(job, [job], noSiblingOnDisk);
258
+ assert.strictEqual(result, false);
259
+ });
260
+
216
261
  console.log('scheduler-autofix-select tests: PASS');
@@ -0,0 +1,46 @@
1
+ /**
2
+ * scheduler-force-tick-outcome.test.cjs — unit tests for forceTickOutcome().
3
+ *
4
+ * Covers the "No pending jobs" vs "Already running" ambiguity: when
5
+ * pickNextBatch() returns an empty batch with no hold reason, that's only
6
+ * genuinely "no pending jobs" if nothing is running either — if a job
7
+ * already transitioned to running (e.g. the when-available poll won a race
8
+ * against a manual force-tick click), the message must say so instead of
9
+ * flatly claiming nothing is pending.
10
+ *
11
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-force-tick-outcome.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const { forceTickOutcome } = require('../scheduler.cjs');
19
+
20
+ test('forceTickOutcome: null result → No pending jobs', () => {
21
+ const outcome = forceTickOutcome(null);
22
+ assert.strictEqual(outcome.message, 'No pending jobs');
23
+ });
24
+
25
+ test('forceTickOutcome: fired → Fired N job(s)', () => {
26
+ const outcome = forceTickOutcome({ fired: true, count: 2, group: 0 });
27
+ assert.strictEqual(outcome.message, 'Fired 2 job(s) (group g0)');
28
+ });
29
+
30
+ test('forceTickOutcome: reason=drained (genuinely nothing pending or running) → No pending jobs', () => {
31
+ const outcome = forceTickOutcome({ fired: false, reason: 'drained' });
32
+ assert.strictEqual(outcome.message, 'No pending jobs');
33
+ });
34
+
35
+ test('forceTickOutcome: reason=already-running (race: poll fired it first) → Already running message, not "No pending jobs"', () => {
36
+ const outcome = forceTickOutcome({ fired: false, reason: 'already-running', runningCount: 1 });
37
+ assert.strictEqual(outcome.message, 'Already running — 1 job(s) in flight');
38
+ assert.notStrictEqual(outcome.message, 'No pending jobs');
39
+ });
40
+
41
+ test('forceTickOutcome: reason=held → detail message surfaces', () => {
42
+ const outcome = forceTickOutcome({ fired: false, reason: 'held', detail: '[scheduler] concurrency [/a]: cap reached, no slots' });
43
+ assert.strictEqual(outcome.message, 'cap reached, no slots');
44
+ });
45
+
46
+ console.log('scheduler-force-tick-outcome tests: PASS');
@@ -0,0 +1,202 @@
1
+ /**
2
+ * browserAgentServer.cjs — loopback-only HTTP API for driving the Browser
3
+ * tab one ad-hoc action at a time (PRD 535 foundation).
4
+ *
5
+ * Deliberately a SEPARATE server from adminServer.cjs, not a route added to
6
+ * it: adminServer.cjs documents itself as intentionally narrow ("Only two
7
+ * routes... writePrd/pause/resume are intentionally NOT exposed here").
8
+ * Arbitrary click/type/navigate/screenshot control of a live browser is a
9
+ * materially larger capability surface than "reset one scheduler job" and
10
+ * deserves its own security boundary, not creeping scope onto the existing
11
+ * narrow one. A future MCP server (PRD 536) wraps this HTTP API the same way
12
+ * scripts/scheduler-mcp-server.cjs wraps adminServer.cjs.
13
+ *
14
+ * Security posture (mirrors adminServer.cjs exactly):
15
+ * - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
16
+ * off-box.
17
+ * - Bearer token, regenerated every app boot, written to
18
+ * ~/.claude/session-manager/browser-agent-api.json with 0600 perms.
19
+ * A distinct file from admin-api.json — this token is never shared with
20
+ * or derived from the scheduler's token.
21
+ * - Token compared with crypto.timingSafeEqual to avoid a timing
22
+ * side-channel on the comparison itself.
23
+ * - Four routes: list tabs (read-only), screenshot, single action
24
+ * (click/type/key/scroll/navigate), DOM/text snapshot.
25
+ *
26
+ * Out of scope for THIS server (see PRD 535's "Out of scope" section):
27
+ * no confirmation/consent gating, no domain allowlist/blocklist, no
28
+ * content-injection scanning. That safety layer is a deliberate, separate
29
+ * follow-up decision, not an oversight.
30
+ */
31
+
32
+ 'use strict';
33
+
34
+ const http = require('node:http');
35
+ const os = require('node:os');
36
+ const path = require('node:path');
37
+ const crypto = require('node:crypto');
38
+ const fsp = require('node:fs/promises');
39
+ const config = require('./config.cjs');
40
+
41
+ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'browser-agent-api.json');
42
+ // Screenshots are base64 PNG data URLs — bigger cap than adminServer's 1MB,
43
+ // still bounded so a malformed/malicious body can't exhaust memory.
44
+ const BODY_MAX_BYTES = 8 * 1024 * 1024;
45
+
46
+ function timingSafeEqualStrings(a, b) {
47
+ const bufA = Buffer.from(String(a ?? ''));
48
+ const bufB = Buffer.from(String(b ?? ''));
49
+ if (bufA.length !== bufB.length) {
50
+ // Compare against a same-length buffer anyway so the failure path still
51
+ // takes constant time relative to the (fixed) token length, rather than
52
+ // returning early on a length mismatch.
53
+ crypto.timingSafeEqual(bufA, bufA);
54
+ return false;
55
+ }
56
+ return crypto.timingSafeEqual(bufA, bufB);
57
+ }
58
+
59
+ function readBody(req, maxBytes = BODY_MAX_BYTES) {
60
+ return new Promise((resolve, reject) => {
61
+ let total = 0;
62
+ const chunks = [];
63
+ req.on('data', (chunk) => {
64
+ total += chunk.length;
65
+ if (total > maxBytes) {
66
+ reject(new Error('body too large'));
67
+ req.destroy();
68
+ return;
69
+ }
70
+ chunks.push(chunk);
71
+ });
72
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
73
+ req.on('error', reject);
74
+ });
75
+ }
76
+
77
+ function sendJson(res, status, obj) {
78
+ const body = JSON.stringify(obj);
79
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
80
+ res.end(body);
81
+ }
82
+
83
+ function safeParseJson(raw) {
84
+ if (!raw) return {};
85
+ try {
86
+ return JSON.parse(raw);
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Create the browser-agent HTTP server. `remote` exposes the browserView.cjs
94
+ * functions this server dispatches to (injected, not required directly, so
95
+ * this module stays testable without booting Electron) — shape:
96
+ * { listTabs(): Promise<Array>, screenshot({viewId}): Promise<obj>,
97
+ * action(params): Promise<obj>, dom({viewId}): Promise<obj> }
98
+ */
99
+ function createBrowserAgentServer(remote) {
100
+ let server = null;
101
+ let token = null;
102
+
103
+ async function ensureToken() {
104
+ token = crypto.randomBytes(32).toString('hex');
105
+ await config.writeJson(TOKEN_PATH, { port: null, token });
106
+ try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
107
+ return token;
108
+ }
109
+
110
+ async function persistPort(port) {
111
+ await config.writeJson(TOKEN_PATH, { port, token });
112
+ try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
113
+ }
114
+
115
+ function authorized(req) {
116
+ const header = req.headers.authorization || '';
117
+ const match = /^Bearer (.+)$/.exec(header);
118
+ if (!match) return false;
119
+ return timingSafeEqualStrings(match[1], token);
120
+ }
121
+
122
+ async function handleRequest(req, res) {
123
+ if (!authorized(req)) {
124
+ sendJson(res, 401, { ok: false, error: 'unauthorized' });
125
+ return;
126
+ }
127
+ try {
128
+ const url = new URL(req.url, 'http://127.0.0.1');
129
+
130
+ if (req.method === 'GET' && url.pathname === '/agent/browser/tabs') {
131
+ const tabs = await remote.listTabs();
132
+ sendJson(res, 200, { ok: true, tabs });
133
+ return;
134
+ }
135
+
136
+ if (req.method === 'POST' && url.pathname === '/agent/browser/screenshot') {
137
+ const raw = await readBody(req);
138
+ const parsed = safeParseJson(raw);
139
+ if (parsed === undefined) {
140
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
141
+ return;
142
+ }
143
+ const result = await remote.screenshot({ viewId: typeof parsed.viewId === 'string' ? parsed.viewId : undefined });
144
+ sendJson(res, result.ok ? 200 : 400, result);
145
+ return;
146
+ }
147
+
148
+ if (req.method === 'POST' && url.pathname === '/agent/browser/action') {
149
+ const raw = await readBody(req);
150
+ const parsed = safeParseJson(raw);
151
+ if (parsed === undefined) {
152
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
153
+ return;
154
+ }
155
+ if (typeof parsed.action !== 'string' || !parsed.action) {
156
+ sendJson(res, 400, { ok: false, error: 'missing action' });
157
+ return;
158
+ }
159
+ const result = await remote.action(parsed);
160
+ sendJson(res, result.ok ? 200 : 400, result);
161
+ return;
162
+ }
163
+
164
+ if (req.method === 'GET' && url.pathname === '/agent/browser/dom') {
165
+ const viewId = url.searchParams.get('viewId') || undefined;
166
+ const result = await remote.dom({ viewId });
167
+ sendJson(res, result.ok ? 200 : 400, result);
168
+ return;
169
+ }
170
+
171
+ sendJson(res, 404, { ok: false, error: 'not found' });
172
+ } catch (e) {
173
+ sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
174
+ }
175
+ }
176
+
177
+ async function start() {
178
+ await ensureToken();
179
+ server = http.createServer((req, res) => {
180
+ handleRequest(req, res).catch(() => {
181
+ try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
182
+ });
183
+ });
184
+ await new Promise((resolve, reject) => {
185
+ server.once('error', reject);
186
+ server.listen(0, '127.0.0.1', resolve);
187
+ });
188
+ const { port } = server.address();
189
+ await persistPort(port);
190
+ return { port, token };
191
+ }
192
+
193
+ async function stop() {
194
+ if (!server) return;
195
+ await new Promise((resolve) => server.close(() => resolve()));
196
+ server = null;
197
+ }
198
+
199
+ return { start, stop, get token() { return token; }, get server() { return server; } };
200
+ }
201
+
202
+ module.exports = { createBrowserAgentServer, TOKEN_PATH };