claude-code-session-manager 0.38.2 → 0.38.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.
package/dist/index.html CHANGED
@@ -7,7 +7,7 @@
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-CZPF8Qve.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-BJRUaZ96.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
13
  <link rel="stylesheet" crossorigin href="./assets/index-DyOGjslF.css">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.38.2",
3
+ "version": "0.38.3",
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",
@@ -38,6 +38,17 @@ there's a concrete failing case (a real input that breaks today); otherwise **fe
38
38
  Record the reasoning either way — `pr-review-sweep:queue` uses it to pick the PRD template,
39
39
  and a future reader should be able to see why a borderline call went the way it did.
40
40
 
41
+ ## Known-pattern check (if the project curates one)
42
+
43
+ Before finalizing type/reasoning, check whether the thread matches an already-documented
44
+ recurring pattern. Some projects keep a curated, evolving corpus of past review findings
45
+ (commonly `docs/review-*.md`, indexed from `docs/README.md`, fed by a project-local
46
+ `project-status-local` mining step — sigma is the reference example). If such docs exist in
47
+ this repo and the thread matches a named pattern there, say so in the reasoning and cite the
48
+ doc/section — it strengthens the bug-vs-feature call (a documented recurring bug is bug,
49
+ full stop) and carries forward to `:queue`. If no such docs exist, or the thread doesn't
50
+ match anything in them, this is a no-op — don't go hunting for docs a project doesn't have.
51
+
41
52
  ## Output
42
53
 
43
54
  Per thread: `{disposition, type | null, reasoning}`. Needs-my-decision threads carry the
@@ -34,7 +34,14 @@ existing PR's branch), and the standard two independent verification passes befo
34
34
  - **`bug` bundle** — additionally require: reproduce each defect described in the bundled
35
35
  threads, write a regression test per defect that fails before the fix and passes after,
36
36
  then fix. The PRD body should quote the reviewer's exact wording per thread so the
37
- executor root-causes the actual reported defect, not a guessed one.
37
+ executor root-causes the actual reported defect, not a guessed one. If `:classify` flagged
38
+ a thread as matching a known documented pattern (see its "Known-pattern check"), the PRD
39
+ must say so explicitly and instruct the executor to **grep the rest of the codebase for
40
+ the same pattern, not just fix the one flagged instance** — several of sigma's own
41
+ documented patterns (e.g. an SSR-unsafe `useLayoutEffect`, an un-deduped array into a D1
42
+ `IN (...)`) turned out to be the exact same bug copy-pasted across 5+ sibling files, each
43
+ caught as a separate review comment on a separate PR because the first fix never swept the
44
+ rest. One PRD closing all occurrences beats N PRDs closing them one review cycle at a time.
38
45
  - **`feature` bundle** — additionally require: scope the change to exactly what each
39
46
  bundled thread asked for (no piggybacked scope creep across threads even though they're
40
47
  bundled together), match existing patterns/conventions in the touched files, and add a
@@ -10,7 +10,9 @@
10
10
  const { test } = require('node:test');
11
11
  const assert = require('node:assert/strict');
12
12
  const http = require('node:http');
13
- const { createBrowserAgentServer } = require('../browserAgentServer.cjs');
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+ const { createBrowserAgentServer, TOKEN_PATH, resolveTokenPath } = require('../browserAgentServer.cjs');
14
16
 
15
17
  function request(port, { method = 'GET', path: reqPath, token, body }) {
16
18
  return new Promise((resolve, reject) => {
@@ -184,3 +186,50 @@ test('unknown route is a 404', async () => {
184
186
  await server.stop();
185
187
  }
186
188
  });
189
+
190
+ test('SM_DEV=1 writes to a dev-suffixed path, never the production browser-agent-api.json', async () => {
191
+ const devPath = path.join(path.dirname(TOKEN_PATH), 'browser-agent-api.dev.json');
192
+ const prevMtime = fs.existsSync(TOKEN_PATH) ? fs.statSync(TOKEN_PATH).mtimeMs : null;
193
+ const prevEnv = process.env.SM_DEV;
194
+ const remote = makeFakeRemote();
195
+ let server;
196
+ try {
197
+ process.env.SM_DEV = '1';
198
+ server = createBrowserAgentServer(remote);
199
+ const { port, token } = await server.start();
200
+ assert.equal(resolveTokenPath(), devPath);
201
+ const parsed = JSON.parse(fs.readFileSync(devPath, 'utf8'));
202
+ assert.equal(parsed.port, port);
203
+ assert.equal(parsed.token, token);
204
+ if (prevMtime !== null) {
205
+ assert.equal(fs.statSync(TOKEN_PATH).mtimeMs, prevMtime);
206
+ } else {
207
+ assert.equal(fs.existsSync(TOKEN_PATH), false);
208
+ }
209
+ } finally {
210
+ if (server) await server.stop();
211
+ if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
212
+ fs.rmSync(devPath, { force: true });
213
+ }
214
+ });
215
+
216
+ test('with neither SM_DEV nor SM_E2E set, writes to the original browser-agent-api.json path', async () => {
217
+ const prevDev = process.env.SM_DEV;
218
+ const prevE2e = process.env.SM_E2E;
219
+ const remote = makeFakeRemote();
220
+ let server;
221
+ try {
222
+ delete process.env.SM_DEV;
223
+ delete process.env.SM_E2E;
224
+ assert.equal(resolveTokenPath(), TOKEN_PATH);
225
+ server = createBrowserAgentServer(remote);
226
+ const { port, token } = await server.start();
227
+ const parsed = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
228
+ assert.equal(parsed.port, port);
229
+ assert.equal(parsed.token, token);
230
+ } finally {
231
+ if (server) await server.stop();
232
+ if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
233
+ if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
234
+ }
235
+ });
@@ -21,6 +21,11 @@
21
21
  * or derived from the scheduler's token.
22
22
  * - Token compared with crypto.timingSafeEqual to avoid a timing
23
23
  * side-channel on the comparison itself.
24
+ * - Mode-scoped token file, same rationale/fix as localAdminHttp.cjs's
25
+ * resolveTokenPath() (confirmed-live incident 2026-07-29): a dev/e2e
26
+ * launch (SM_DEV=1 / SM_E2E=1) writes to browser-agent-api.dev.json /
27
+ * browser-agent-api.e2e.json instead of the production file, so it can
28
+ * never silently orphan a concurrently-running production instance.
24
29
  * - Four routes: list tabs (read-only), screenshot, single action
25
30
  * (click/type/key/scroll/navigate), DOM/text snapshot.
26
31
  *
@@ -44,6 +49,20 @@ const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'browse
44
49
  // still bounded so a malformed/malicious body can't exhaust memory.
45
50
  const BODY_MAX_BYTES = 8 * 1024 * 1024;
46
51
 
52
+ /**
53
+ * Mode-aware token file path, resolved fresh on every call — see
54
+ * localAdminHttp.cjs's resolveTokenPath() for the full rationale.
55
+ */
56
+ function resolveTokenPath() {
57
+ if (process.env.SM_DEV === '1') {
58
+ return path.join(os.homedir(), '.claude', 'session-manager', 'browser-agent-api.dev.json');
59
+ }
60
+ if (process.env.SM_E2E === '1') {
61
+ return path.join(os.homedir(), '.claude', 'session-manager', 'browser-agent-api.e2e.json');
62
+ }
63
+ return TOKEN_PATH;
64
+ }
65
+
47
66
  function timingSafeEqualStrings(a, b) {
48
67
  const bufA = Buffer.from(String(a ?? ''));
49
68
  const bufB = Buffer.from(String(b ?? ''));
@@ -103,14 +122,16 @@ function createBrowserAgentServer(remote) {
103
122
 
104
123
  async function ensureToken() {
105
124
  token = crypto.randomBytes(32).toString('hex');
106
- await config.writeJson(TOKEN_PATH, { port: null, token });
107
- try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
125
+ const tokenPath = resolveTokenPath();
126
+ await config.writeJson(tokenPath, { port: null, token });
127
+ try { await fsp.chmod(tokenPath, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
108
128
  return token;
109
129
  }
110
130
 
111
131
  async function persistPort(port) {
112
- await config.writeJson(TOKEN_PATH, { port, token });
113
- try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
132
+ const tokenPath = resolveTokenPath();
133
+ await config.writeJson(tokenPath, { port, token });
134
+ try { await fsp.chmod(tokenPath, 0o600); } catch { /* */ }
114
135
  }
115
136
 
116
137
  function authorized(req) {
@@ -200,4 +221,4 @@ function createBrowserAgentServer(remote) {
200
221
  return { start, stop, get token() { return token; }, get server() { return server; } };
201
222
  }
202
223
 
203
- module.exports = { createBrowserAgentServer, TOKEN_PATH };
224
+ module.exports = { createBrowserAgentServer, TOKEN_PATH, resolveTokenPath };
@@ -14,7 +14,8 @@
14
14
  import { test, expect } from 'vitest';
15
15
  const http = require('node:http');
16
16
  const fs = require('node:fs');
17
- const { createAdminHttp, TOKEN_PATH } = require('../localAdminHttp.cjs');
17
+ const path = require('node:path');
18
+ const { createAdminHttp, TOKEN_PATH, resolveTokenPath } = require('../localAdminHttp.cjs');
18
19
 
19
20
  function request(port, { method = 'GET', path: reqPath, token, body }) {
20
21
  return new Promise((resolve, reject) => {
@@ -118,3 +119,73 @@ test('start() persists token file with port + token, mode 0600', async () => {
118
119
  await admin.stop();
119
120
  }
120
121
  });
122
+
123
+ test('SM_DEV=1 writes to a dev-suffixed path, never the production admin-api.json', async () => {
124
+ const devPath = path.join(path.dirname(TOKEN_PATH), 'admin-api.dev.json');
125
+ const prevMtime = fs.existsSync(TOKEN_PATH) ? fs.statSync(TOKEN_PATH).mtimeMs : null;
126
+ const prevEnv = process.env.SM_DEV;
127
+ process.env.SM_DEV = '1';
128
+ const admin = createAdminHttp();
129
+ try {
130
+ const { port, token } = await admin.start();
131
+ expect(resolveTokenPath()).toBe(devPath);
132
+ const parsed = JSON.parse(fs.readFileSync(devPath, 'utf8'));
133
+ expect(parsed.port).toBe(port);
134
+ expect(parsed.token).toBe(token);
135
+ // Production file must be untouched by the dev-mode instance.
136
+ if (prevMtime !== null) {
137
+ expect(fs.statSync(TOKEN_PATH).mtimeMs).toBe(prevMtime);
138
+ } else {
139
+ expect(fs.existsSync(TOKEN_PATH)).toBe(false);
140
+ }
141
+ } finally {
142
+ await admin.stop();
143
+ if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
144
+ fs.rmSync(devPath, { force: true });
145
+ }
146
+ });
147
+
148
+ test('with neither SM_DEV nor SM_E2E set, writes to the original admin-api.json path', async () => {
149
+ const prevDev = process.env.SM_DEV;
150
+ const prevE2e = process.env.SM_E2E;
151
+ delete process.env.SM_DEV;
152
+ delete process.env.SM_E2E;
153
+ let admin;
154
+ try {
155
+ expect(resolveTokenPath()).toBe(TOKEN_PATH);
156
+ admin = createAdminHttp();
157
+ const { port, token } = await admin.start();
158
+ const parsed = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
159
+ expect(parsed.port).toBe(port);
160
+ expect(parsed.token).toBe(token);
161
+ } finally {
162
+ if (admin) await admin.stop();
163
+ if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
164
+ if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
165
+ }
166
+ });
167
+
168
+ test('dev and production token paths never collide', () => {
169
+ const prevEnv = process.env.SM_DEV;
170
+ try {
171
+ process.env.SM_DEV = '1';
172
+ const devPath = resolveTokenPath();
173
+ delete process.env.SM_DEV;
174
+ const prodPath = resolveTokenPath();
175
+ expect(devPath).not.toBe(prodPath);
176
+ } finally {
177
+ if (prevEnv === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevEnv;
178
+ }
179
+ });
180
+
181
+ test('start()s self-check answers on the health path with the fresh bearer token', async () => {
182
+ const admin = createAdminHttp();
183
+ const { port, token } = await admin.start();
184
+ try {
185
+ const res = await request(port, { path: '/__admin/health', token });
186
+ expect(res.status).toBe(200);
187
+ expect(res.json).toEqual({ ok: true });
188
+ } finally {
189
+ await admin.stop();
190
+ }
191
+ });
@@ -15,6 +15,18 @@
15
15
  * ~/.claude/session-manager/admin-api.json with 0600 perms.
16
16
  * - Token compared with crypto.timingSafeEqual to avoid a timing
17
17
  * side-channel on the comparison itself.
18
+ *
19
+ * Mode-scoped token file (confirmed-live incident 2026-07-29): a dev-mode or
20
+ * e2e Electron launch (SM_DEV=1 / SM_E2E=1) intentionally skips the
21
+ * single-instance lock (index.cjs), so it can run concurrently with a
22
+ * production instance. Without a mode-specific path, that dev/e2e instance's
23
+ * boot silently overwrote the production instance's admin-api.json with its
24
+ * own (soon-to-be-dead) port+token, orphaning scheduler-mcp-server.cjs's
25
+ * access to the still-running production app with no error anywhere. Dev/e2e
26
+ * instances now write to admin-api.dev.json / admin-api.e2e.json instead —
27
+ * see resolveTokenPath(). Production (neither env var set) is unaffected:
28
+ * it still writes/reads admin-api.json, matching scheduler-mcp-server.cjs's
29
+ * hardcoded read path.
18
30
  */
19
31
 
20
32
  'use strict';
@@ -28,6 +40,29 @@ const config = require('../config.cjs');
28
40
 
29
41
  const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
30
42
 
43
+ /**
44
+ * Mode-aware token file path, resolved fresh on every call (not cached at
45
+ * module load) so a dev/e2e launch never collides with a production
46
+ * instance's admin-api.json. index.cjs treats SM_DEV and SM_E2E as one OR'd
47
+ * `isDev` boolean with no ordering between them; if both happen to be set,
48
+ * this resolves to the SM_DEV path (checked first below) — an arbitrary but
49
+ * harmless tie-break, since real launches only ever set one or the other.
50
+ */
51
+ function resolveTokenPath() {
52
+ if (process.env.SM_DEV === '1') {
53
+ return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.dev.json');
54
+ }
55
+ if (process.env.SM_E2E === '1') {
56
+ return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.e2e.json');
57
+ }
58
+ return TOKEN_PATH;
59
+ }
60
+
61
+ // Reserved health-check path, handled directly (not via registerRoute) so
62
+ // start()'s post-boot self-check never depends on route-registration order
63
+ // relative to start() across callers (chatRunner/scheduler/prdCreate).
64
+ const HEALTH_PATH = '/__admin/health';
65
+
31
66
  function timingSafeEqualStrings(a, b) {
32
67
  const bufA = Buffer.from(String(a ?? ''));
33
68
  const bufB = Buffer.from(String(b ?? ''));
@@ -79,14 +114,16 @@ function createAdminHttp() {
79
114
 
80
115
  async function ensureToken() {
81
116
  token = crypto.randomBytes(32).toString('hex');
82
- await config.writeJson(TOKEN_PATH, { port: null, token });
83
- try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
117
+ const tokenPath = resolveTokenPath();
118
+ await config.writeJson(tokenPath, { port: null, token });
119
+ try { await fsp.chmod(tokenPath, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
84
120
  return token;
85
121
  }
86
122
 
87
123
  async function persistPort(port) {
88
- await config.writeJson(TOKEN_PATH, { port, token });
89
- try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
124
+ const tokenPath = resolveTokenPath();
125
+ await config.writeJson(tokenPath, { port, token });
126
+ try { await fsp.chmod(tokenPath, 0o600); } catch { /* */ }
90
127
  }
91
128
 
92
129
  function authorized(req) {
@@ -105,6 +142,10 @@ function createAdminHttp() {
105
142
  sendJson(res, 401, { ok: false, error: 'unauthorized' });
106
143
  return;
107
144
  }
145
+ if (req.method === 'GET' && req.url === HEALTH_PATH) {
146
+ sendJson(res, 200, { ok: true });
147
+ return;
148
+ }
108
149
  try {
109
150
  const handler = routes.get(`${req.method} ${req.url}`);
110
151
  if (!handler) {
@@ -117,6 +158,31 @@ function createAdminHttp() {
117
158
  }
118
159
  }
119
160
 
161
+ /**
162
+ * Post-boot self-check: confirm the server actually answers on the port
163
+ * it just wrote to the token file, before start() reports success. Catches
164
+ * the class of bug where the written port/token is stale or the listener
165
+ * never came up right — otherwise the only symptom is a caller getting
166
+ * "unauthorized"/connection-refused with zero context in the logs.
167
+ */
168
+ function selfCheck(port) {
169
+ return new Promise((resolve, reject) => {
170
+ const req = http.request(
171
+ { hostname: '127.0.0.1', port, method: 'GET', path: HEALTH_PATH, headers: { Authorization: `Bearer ${token}` }, timeout: 2000 },
172
+ (res) => {
173
+ res.resume();
174
+ res.on('end', () => {
175
+ if (res.statusCode === 200) resolve();
176
+ else reject(new Error(`admin HTTP self-check got status ${res.statusCode} from port ${port}`));
177
+ });
178
+ },
179
+ );
180
+ req.on('timeout', () => { req.destroy(new Error(`admin HTTP self-check timed out on port ${port}`)); });
181
+ req.on('error', (e) => reject(new Error(`admin HTTP self-check failed to reach port ${port}: ${e.message}`)));
182
+ req.end();
183
+ });
184
+ }
185
+
120
186
  async function start() {
121
187
  await ensureToken();
122
188
  server = http.createServer((req, res) => {
@@ -129,7 +195,13 @@ function createAdminHttp() {
129
195
  server.listen(0, '127.0.0.1', resolve);
130
196
  });
131
197
  const { port } = server.address();
132
- await persistPort(port);
198
+ try {
199
+ await persistPort(port);
200
+ await selfCheck(port);
201
+ } catch (e) {
202
+ await stop();
203
+ throw e;
204
+ }
133
205
  return { port, token };
134
206
  }
135
207
 
@@ -151,6 +223,7 @@ function createAdminHttp() {
151
223
  module.exports = {
152
224
  createAdminHttp,
153
225
  TOKEN_PATH,
226
+ resolveTokenPath,
154
227
  timingSafeEqualStrings,
155
228
  readBody,
156
229
  sendJson,