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/assets/{TiptapBody-DlPTesio.js → TiptapBody-R9igz9Iz.js} +1 -1
- package/dist/assets/{index-CBSyk1aI.js → index-BJRUaZ96.js} +163 -163
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +11 -0
- package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +8 -1
- package/src/main/__tests__/browserAgentServer.test.cjs +50 -1
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +99 -0
- package/src/main/browserAgentServer.cjs +26 -5
- package/src/main/chatRunner.cjs +44 -0
- package/src/main/index.cjs +2 -1
- package/src/main/ipcSchemas.cjs +20 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +72 -1
- package/src/main/lib/localAdminHttp.cjs +78 -5
- package/src/main/lib/prdCreate.cjs +4 -1
- package/src/main/scheduler/prdParser.cjs +5 -0
- package/src/main/scheduler.cjs +69 -1
- package/src/main/webRemote.cjs +10 -0
- package/src/preload/api.d.ts +11 -0
- package/src/preload/index.cjs +7 -0
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-
|
|
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.
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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 };
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -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
|
};
|
package/src/main/index.cjs
CHANGED
|
@@ -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');
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
}
|