claude-code-session-manager 0.38.1 → 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-CBSyk1aI.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.1",
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
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * scheduler-notify-originating-tab.test.cjs — unit tests for
3
+ * notifyOriginatingTab (PRD 761): on a job's terminal completed/failed
4
+ * transition, push a short status prompt into the chat tab that originated
5
+ * it via enqueueExternalPrompt (PRD 753).
6
+ *
7
+ * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-notify-originating-tab.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, vi } from 'vitest';
13
+ const { notifyOriginatingTab, isNotifiableTerminalStatus } = require('../scheduler.cjs');
14
+
15
+ test('isNotifiableTerminalStatus: completed and failed are notifiable', () => {
16
+ expect(isNotifiableTerminalStatus('completed')).toBe(true);
17
+ expect(isNotifiableTerminalStatus('failed')).toBe(true);
18
+ });
19
+
20
+ test('isNotifiableTerminalStatus: needs_review is not notifiable (may still auto-fix)', () => {
21
+ expect(isNotifiableTerminalStatus('needs_review')).toBe(false);
22
+ });
23
+
24
+ test('isNotifiableTerminalStatus: a rateLimited exit never reaches an effectiveStatus (undefined) and is not notifiable', () => {
25
+ // spawnJob's treatAsPending branch (rateLimited || paused for rate_limit)
26
+ // resets the job to pending and returns before effectiveStatus is ever
27
+ // computed — so this hook is only ever evaluated with undefined/pending,
28
+ // never with a real status, on that path.
29
+ expect(isNotifiableTerminalStatus(undefined)).toBe(false);
30
+ expect(isNotifiableTerminalStatus('pending')).toBe(false);
31
+ });
32
+
33
+ test('resolves via sourceTabId when present on the PRD frontmatter', async () => {
34
+ const sendPrompt = vi.fn();
35
+ const parsePrdRaw = vi.fn(async () => ({ sourceTabId: 'tab-from-frontmatter' }));
36
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
37
+
38
+ await notifyOriginatingTab(
39
+ { slug: '761-notify', status: 'completed', cwd: '/some/cwd' },
40
+ { parsePrdRaw, loadSessions, sendPrompt },
41
+ );
42
+
43
+ expect(loadSessions).not.toHaveBeenCalled();
44
+ expect(sendPrompt).toHaveBeenCalledTimes(1);
45
+ expect(sendPrompt).toHaveBeenCalledWith(
46
+ 'tab-from-frontmatter',
47
+ expect.stringContaining('761-notify'),
48
+ );
49
+ });
50
+
51
+ test('falls back to the first open tab whose cwd matches the job cwd', async () => {
52
+ const sendPrompt = vi.fn();
53
+ const parsePrdRaw = vi.fn(async () => ({ sourceTabId: null }));
54
+ const loadSessions = vi.fn(async () => ({
55
+ tabs: [
56
+ { id: 'tab-other', cwd: '/other/cwd' },
57
+ { id: 'tab-match', cwd: '/some/cwd' },
58
+ { id: 'tab-second-match', cwd: '/some/cwd' },
59
+ ],
60
+ }));
61
+
62
+ await notifyOriginatingTab(
63
+ { slug: '761-notify', status: 'failed', cwd: '/some/cwd' },
64
+ { parsePrdRaw, loadSessions, sendPrompt },
65
+ );
66
+
67
+ expect(sendPrompt).toHaveBeenCalledTimes(1);
68
+ expect(sendPrompt).toHaveBeenCalledWith('tab-match', expect.stringContaining('761-notify'));
69
+ });
70
+
71
+ test('no-ops without throwing when neither sourceTabId nor a cwd-matching tab resolves', async () => {
72
+ const sendPrompt = vi.fn();
73
+ const parsePrdRaw = vi.fn(async () => ({ sourceTabId: null }));
74
+ const loadSessions = vi.fn(async () => ({ tabs: [{ id: 'tab-other', cwd: '/unrelated' }] }));
75
+
76
+ await expect(
77
+ notifyOriginatingTab(
78
+ { slug: '761-notify', status: 'completed', cwd: '/some/cwd' },
79
+ { parsePrdRaw, loadSessions, sendPrompt },
80
+ ),
81
+ ).resolves.not.toThrow();
82
+
83
+ expect(sendPrompt).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test('no-ops when parsing the PRD frontmatter throws', async () => {
87
+ const sendPrompt = vi.fn();
88
+ const parsePrdRaw = vi.fn(async () => { throw new Error('ENOENT'); });
89
+ const loadSessions = vi.fn(async () => ({ tabs: [] }));
90
+
91
+ await expect(
92
+ notifyOriginatingTab(
93
+ { slug: '761-notify', status: 'completed', cwd: '/some/cwd' },
94
+ { parsePrdRaw, loadSessions, sendPrompt },
95
+ ),
96
+ ).resolves.not.toThrow();
97
+
98
+ expect(sendPrompt).not.toHaveBeenCalled();
99
+ });
@@ -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 };
@@ -677,6 +677,48 @@ function cancel(tabId) {
677
677
  return Promise.resolve();
678
678
  }
679
679
 
680
+ // ─── External-caller entry point (PRD 753) ─────────────────────────────────
681
+ // Pushes a prompt into an already-open tab's chat queue from outside the
682
+ // renderer — Web Remote, the admin HTTP route, or the scheduler MCP tool.
683
+ // Fire-and-forget over IPC: the renderer-side listener (chat.ts) resolves
684
+ // the tab's live sessionId/cwd from useSessions and calls useChat.send()
685
+ // directly, reusing send()'s existing queued-vs-immediate branching rather
686
+ // than duplicating it here.
687
+
688
+ function enqueueExternalPrompt(tabId, prompt) {
689
+ broadcast('chat:external-send', { tabId, prompt });
690
+ }
691
+
692
+ /**
693
+ * Registers the admin-HTTP entry point for enqueueExternalPrompt, mirroring
694
+ * prdCreate.cjs's registerAdminRoute pattern (auth is the injected transport's
695
+ * job; this only owns route logic + body parsing).
696
+ */
697
+ function registerAdminRoute(adminHttp) {
698
+ const { readBody, sendJson } = require('./lib/localAdminHttp.cjs');
699
+ const { schemas } = require('./ipcSchemas.cjs');
700
+
701
+ adminHttp.registerRoute('POST', '/admin/chat/send-prompt', async (req, res) => {
702
+ const raw = await readBody(req);
703
+ let parsed;
704
+ try {
705
+ parsed = raw ? JSON.parse(raw) : {};
706
+ } catch {
707
+ sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
708
+ return;
709
+ }
710
+ let body;
711
+ try {
712
+ body = schemas.chatExternalSend.parse(parsed);
713
+ } catch (e) {
714
+ sendJson(res, 400, { ok: false, error: e?.message ?? 'invalid body' });
715
+ return;
716
+ }
717
+ enqueueExternalPrompt(body.tabId, body.prompt);
718
+ sendJson(res, 200, { ok: true });
719
+ });
720
+ }
721
+
680
722
  // ─── IPC handler registration ─────────────────────────────────────────────
681
723
 
682
724
  function registerChatHandlers() {
@@ -718,4 +760,6 @@ module.exports = {
718
760
  STOP_SENTINEL,
719
761
  CHAT_MODE_TRUTH_INSTRUCTION,
720
762
  __setExecutor,
763
+ enqueueExternalPrompt,
764
+ registerAdminRoute,
721
765
  };
@@ -27,9 +27,11 @@ const voiceWizard = require('./voiceWizard.cjs');
27
27
  const scheduler = require('./scheduler.cjs');
28
28
  const { createAdminHttp } = require('./lib/localAdminHttp.cjs');
29
29
  const prdCreate = require('./lib/prdCreate.cjs');
30
+ const chatRunner = require('./chatRunner.cjs');
30
31
  const adminHttp = createAdminHttp();
31
32
  scheduler.registerAdminRoutes(adminHttp);
32
33
  prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
34
+ chatRunner.registerAdminRoute(adminHttp);
33
35
  const { createBrowserAgentServer } = require('./browserAgentServer.cjs');
34
36
  const browserAgentServer = createBrowserAgentServer({
35
37
  listTabs: () => browserView.listViews(),
@@ -60,7 +62,6 @@ const searchIpc = require('./search.cjs');
60
62
  const repoAnalyzer = require('./repoAnalyzer.cjs');
61
63
  const hivesIpc = require('./hives.cjs');
62
64
  const webRemote = require('./webRemote.cjs');
63
- const chatRunner = require('./chatRunner.cjs');
64
65
  const { listExchanges } = require('./exchanges.cjs');
65
66
  const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
66
67
  const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
@@ -257,6 +257,10 @@ const schedulerCreatePrd = z.object({
257
257
  // prompt that spawned it. Same newline-injection guard as title/cwd since
258
258
  // it also becomes a frontmatter value.
259
259
  sourcePromptId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
260
+ // Originating tab id (PRD 761) — used at job completion to route a status
261
+ // prompt back into the chat tab that queued this PRD, via
262
+ // enqueueExternalPrompt (PRD 753). Same newline-injection guard.
263
+ sourceTabId: z.string().min(1).max(128).regex(NO_NEWLINE_RE, 'must not contain newlines').optional(),
260
264
  });
261
265
 
262
266
  // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
@@ -476,6 +480,18 @@ const chatClassifyTicket = z.object({
476
480
  ),
477
481
  });
478
482
 
483
+ // External-caller entry point (admin HTTP route + MCP tool + web-remote
484
+ // command, PRD 753) — pushes a prompt into an already-open tab's chat queue
485
+ // from outside the renderer. tabId only (no sessionId/cwd): those are
486
+ // resolved renderer-side from useSessions, same as the AC requires.
487
+ const chatExternalSend = z.object({
488
+ tabId: z.string().min(1).max(128),
489
+ prompt: z.string().min(1).refine(
490
+ (s) => Buffer.byteLength(s, 'utf8') <= CHAT_PROMPT_MAX_BYTES,
491
+ `prompt must be ≤ ${CHAT_PROMPT_MAX_BYTES} bytes`,
492
+ ),
493
+ });
494
+
479
495
  // ──────────────────────────────────────────── Web Remote
480
496
  // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
481
497
  const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
@@ -683,6 +699,9 @@ const MUTATE_COMMANDS = new Set([
683
699
  'cmd:schedule:reset-job',
684
700
  'cmd:schedule:run-now',
685
701
  'cmd:schedule:set-config',
702
+ // Pushes a prompt into an open tab's chat queue — a real mutation of that
703
+ // tab's session, same tier as pty:write.
704
+ 'cmd:chat:send',
686
705
  ]);
687
706
 
688
707
  const ALLOWED_COMMANDS = new Set([...READ_COMMANDS, ...SAS_GATED_READS, ...MUTATE_COMMANDS]);
@@ -774,6 +793,7 @@ module.exports = {
774
793
  chatCancel,
775
794
  chatProbeContext,
776
795
  chatClassifyTicket,
796
+ chatExternalSend,
777
797
  exchangesList,
778
798
  },
779
799
  validated,
@@ -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,
@@ -48,7 +48,7 @@ function deriveSlugFromTitle(title) {
48
48
  function buildPrdBody(input) {
49
49
  const {
50
50
  title, cwd, estimateMinutes, goal, acceptanceCriteria,
51
- implementationNotes, outOfScope, sourcePromptId,
51
+ implementationNotes, outOfScope, sourcePromptId, sourceTabId,
52
52
  } = input;
53
53
 
54
54
  // No `parallelGroup` frontmatter key by convention (SKILL.md) — the NN-
@@ -58,6 +58,9 @@ function buildPrdBody(input) {
58
58
  // Optional, additive: traces this PRD back to the PromptTicket (PRD 748)
59
59
  // that was classified 'develop' and spawned it (PRD 749).
60
60
  if (sourcePromptId) fmLines.push(`sourcePromptId: ${sourcePromptId}`);
61
+ // Optional, additive: the tab that queued this PRD, read back by
62
+ // scheduler.cjs at job completion (PRD 761) to route a status prompt.
63
+ if (sourceTabId) fmLines.push(`sourceTabId: ${sourceTabId}`);
61
64
  fmLines.push('---', '');
62
65
 
63
66
  const acLines = acceptanceCriteria.map((line) => `- [ ] ${line}`).join('\n');
@@ -68,6 +68,11 @@ async function parsePrdRaw(filePath) {
68
68
  // classified 'develop' and spawned this PRD (PRD 749). Additive — absent
69
69
  // on every PRD authored before this field existed.
70
70
  sourcePromptId: fm.sourcePromptId || null,
71
+ // Optional traceability back to the chat tab that queued this PRD (PRD
72
+ // 761) — read back at job completion to route a status prompt via
73
+ // enqueueExternalPrompt (PRD 753). Additive — absent on every PRD
74
+ // authored before this field existed.
75
+ sourceTabId: fm.sourceTabId || null,
71
76
  body: body.trim(),
72
77
  };
73
78
  }