claude-code-session-manager 0.37.0 → 0.37.2

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.
Files changed (38) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-Cg8YZhVZ.js → TiptapBody-BtrSXTRp.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index-_iBXLuvt.js → index-uVGdpAGF.js} +498 -490
  5. package/dist/index.html +2 -2
  6. package/package.json +2 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +24 -0
  9. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  10. package/scripts/lib/activeSessions.cjs +117 -0
  11. package/scripts/lib/watchdogHelpers.cjs +829 -0
  12. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  13. package/src/main/__tests__/docEdit.test.cjs +164 -2
  14. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  15. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  16. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  17. package/src/main/browserAgentServer.cjs +11 -10
  18. package/src/main/chatRunner.cjs +21 -2
  19. package/src/main/docEdit.cjs +125 -5
  20. package/src/main/health.cjs +15 -0
  21. package/src/main/index.cjs +12 -6
  22. package/src/main/ipcSchemas.cjs +14 -0
  23. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  24. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  25. package/src/main/lib/localAdminHttp.cjs +157 -0
  26. package/src/main/lib/personaImportHealth.cjs +161 -0
  27. package/src/main/lib/prdCreate.cjs +107 -17
  28. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  29. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  30. package/src/main/scheduler.cjs +198 -54
  31. package/src/main/templates/PRD_AUTHORING.md +4 -4
  32. package/src/main/transcripts.cjs +1 -85
  33. package/src/preload/api.d.ts +12 -1
  34. package/src/preload/index.cjs +6 -0
  35. package/dist/assets/index-CTTjT08J.css +0 -32
  36. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  37. package/src/main/__tests__/adminServer.test.cjs +0 -380
  38. package/src/main/adminServer.cjs +0 -242
@@ -1,380 +0,0 @@
1
- /**
2
- * adminServer.test.cjs — unit tests for the loopback-only scheduler admin API.
3
- *
4
- * Run: timeout 120 node --test src/main/__tests__/adminServer.test.cjs
5
- */
6
-
7
- 'use strict';
8
-
9
- const { test } = require('node:test');
10
- const assert = require('node:assert/strict');
11
- const http = require('node:http');
12
- const fs = require('node:fs');
13
- const fsp = require('node:fs/promises');
14
- const os = require('node:os');
15
- const path = require('node:path');
16
- const { createAdminServer } = require('../adminServer.cjs');
17
- const config = require('../config.cjs');
18
-
19
- function request(port, { method = 'GET', path: reqPath, token, body }) {
20
- return new Promise((resolve, reject) => {
21
- const headers = {};
22
- if (token !== undefined) headers.Authorization = `Bearer ${token}`;
23
- let payload;
24
- if (body !== undefined) {
25
- payload = JSON.stringify(body);
26
- headers['Content-Type'] = 'application/json';
27
- headers['Content-Length'] = Buffer.byteLength(payload);
28
- }
29
- const req = http.request({ hostname: '127.0.0.1', port, method, path: reqPath, headers }, (res) => {
30
- const chunks = [];
31
- res.on('data', (c) => chunks.push(c));
32
- res.on('end', () => {
33
- const text = Buffer.concat(chunks).toString('utf8');
34
- let json = null;
35
- try { json = JSON.parse(text); } catch { /* leave null for non-JSON bodies */ }
36
- resolve({ status: res.statusCode, json, text });
37
- });
38
- });
39
- req.on('error', reject);
40
- if (payload) req.write(payload);
41
- req.end();
42
- });
43
- }
44
-
45
- function makeFakeRemote({ jobs = [] } = {}) {
46
- return {
47
- async listJobs() {
48
- return jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
49
- },
50
- async resetJob(slug) {
51
- const job = jobs.find((j) => j.slug === slug);
52
- if (!job) return { ok: false, error: 'not found' };
53
- job.status = 'pending';
54
- job.runId = null;
55
- job.startedAt = null;
56
- job.finishedAt = null;
57
- job.exitCode = null;
58
- job.error = null;
59
- delete job.runtime;
60
- delete job.verifierVerdict;
61
- return { ok: true, slug, status: 'pending' };
62
- },
63
- };
64
- }
65
-
66
- test('request without bearer token returns 401', async () => {
67
- const remote = makeFakeRemote();
68
- const admin = createAdminServer(remote);
69
- const { port } = await admin.start();
70
- try {
71
- const res = await request(port, { path: '/admin/scheduler/jobs' });
72
- assert.strictEqual(res.status, 401);
73
- assert.strictEqual(res.json.ok, false);
74
- } finally {
75
- await admin.stop();
76
- }
77
- });
78
-
79
- test('request with wrong bearer token returns 401', async () => {
80
- const remote = makeFakeRemote();
81
- const admin = createAdminServer(remote);
82
- const { port } = await admin.start();
83
- try {
84
- const res = await request(port, { path: '/admin/scheduler/jobs', token: 'not-the-token' });
85
- assert.strictEqual(res.status, 401);
86
- } finally {
87
- await admin.stop();
88
- }
89
- });
90
-
91
- test('GET /admin/scheduler/jobs with correct token returns 200 + array', async () => {
92
- const remote = makeFakeRemote({
93
- jobs: [{ slug: '10-foo', title: 'Foo', status: 'completed', cwd: '/tmp/foo' }],
94
- });
95
- const admin = createAdminServer(remote);
96
- const { port, token } = await admin.start();
97
- try {
98
- const res = await request(port, { path: '/admin/scheduler/jobs', token });
99
- assert.strictEqual(res.status, 200);
100
- assert.ok(Array.isArray(res.json));
101
- assert.strictEqual(res.json.length, 1);
102
- assert.strictEqual(res.json[0].slug, '10-foo');
103
- } finally {
104
- await admin.stop();
105
- }
106
- });
107
-
108
- test('POST /admin/scheduler/reset-job with unknown slug returns ok:false', async () => {
109
- const remote = makeFakeRemote({ jobs: [] });
110
- const admin = createAdminServer(remote);
111
- const { port, token } = await admin.start();
112
- try {
113
- const res = await request(port, {
114
- method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: 'does-not-exist' },
115
- });
116
- assert.strictEqual(res.status, 200);
117
- assert.strictEqual(res.json.ok, false);
118
- } finally {
119
- await admin.stop();
120
- }
121
- });
122
-
123
- test('POST /admin/scheduler/reset-job with known slug flips job to pending and clears run fields', async () => {
124
- const job = {
125
- slug: '10-foo',
126
- title: 'Foo',
127
- status: 'completed',
128
- cwd: '/tmp/foo',
129
- runId: 'run-1',
130
- startedAt: 100,
131
- finishedAt: 200,
132
- exitCode: 0,
133
- error: null,
134
- runtime: { pid: 1234 },
135
- verifierVerdict: 'PASS',
136
- };
137
- const remote = makeFakeRemote({ jobs: [job] });
138
- const admin = createAdminServer(remote);
139
- const { port, token } = await admin.start();
140
- try {
141
- const res = await request(port, {
142
- method: 'POST', path: '/admin/scheduler/reset-job', token, body: { slug: '10-foo' },
143
- });
144
- assert.strictEqual(res.status, 200);
145
- assert.deepStrictEqual(res.json, { ok: true, slug: '10-foo', status: 'pending' });
146
- assert.strictEqual(job.status, 'pending');
147
- assert.strictEqual(job.runId, null);
148
- assert.strictEqual(job.startedAt, null);
149
- assert.strictEqual(job.finishedAt, null);
150
- assert.strictEqual(job.exitCode, null);
151
- assert.strictEqual(job.error, null);
152
- assert.strictEqual('runtime' in job, false);
153
- assert.strictEqual('verifierVerdict' in job, false);
154
- } finally {
155
- await admin.stop();
156
- }
157
- });
158
-
159
- test('unknown path/method returns 404', async () => {
160
- const remote = makeFakeRemote();
161
- const admin = createAdminServer(remote);
162
- const { port, token } = await admin.start();
163
- try {
164
- const res = await request(port, { path: '/admin/scheduler/nope', token });
165
- assert.strictEqual(res.status, 404);
166
- } finally {
167
- await admin.stop();
168
- }
169
- });
170
-
171
- test('server binds only to 127.0.0.1', async () => {
172
- const remote = makeFakeRemote();
173
- const admin = createAdminServer(remote);
174
- await admin.start();
175
- try {
176
- assert.strictEqual(admin.server.address().address, '127.0.0.1');
177
- } finally {
178
- await admin.stop();
179
- }
180
- });
181
-
182
- // ──────────────────────────────────────────── create-prd
183
-
184
- const prdParser = require('../scheduler/prdParser.cjs');
185
-
186
- // config.cjs's validateWrite only allows writes under a registered non-home
187
- // allowedRoot's `.claude/` subtree (mirrors the real ROOT layout, which is
188
- // $HOME/.claude/session-manager/...) — so the temp prdsDir must live under
189
- // `<root>/.claude/...`, not directly under the temp root itself.
190
- async function mkTmpPrdsDir() {
191
- const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-admin-create-prd-'));
192
- config.addAllowedRoot(root);
193
- const prdsDir = path.join(root, '.claude', 'prds');
194
- await fsp.mkdir(prdsDir, { recursive: true });
195
- return prdsDir;
196
- }
197
-
198
- // Mirrors scheduler.cjs's real `remote.allocateParallelGroup` / `remote.writePrd`
199
- // (same underlying prdParser.allocateParallelGroup + config.writeTextAtomic
200
- // calls) but pointed at a throwaway temp dir instead of the user's real
201
- // scheduled-plans/prds — so these tests never touch $HOME/.claude.
202
- function makeFakeRemoteWithPrdsDir(prdsDir) {
203
- return {
204
- async allocateParallelGroup() {
205
- return prdParser.allocateParallelGroup(prdsDir);
206
- },
207
- async readPrd(slug) {
208
- try {
209
- const text = await fsp.readFile(path.join(prdsDir, `${slug}.md`), 'utf8');
210
- return { ok: true, text };
211
- } catch (e) {
212
- return { ok: false, error: e?.message };
213
- }
214
- },
215
- async writePrd(slug, body) {
216
- try {
217
- const filePath = path.join(prdsDir, `${slug}.md`);
218
- await config.writeTextAtomic(filePath, body);
219
- return { ok: true };
220
- } catch (e) {
221
- return { ok: false, error: e?.message };
222
- }
223
- },
224
- };
225
- }
226
-
227
- function validCreateBody(overrides = {}) {
228
- return {
229
- title: 'Add widget frobnication',
230
- cwd: os.homedir(),
231
- estimateMinutes: 15,
232
- goal: 'Add frobnication to the widget subsystem.',
233
- acceptanceCriteria: ['widget frobnicates on click', 'timeout 300 npm run typecheck passes'],
234
- implementationNotes: 'See src/widget.cjs:10.',
235
- outOfScope: ['not touching gadgets'],
236
- ...overrides,
237
- };
238
- }
239
-
240
- test('POST /admin/scheduler/create-prd without token returns 401', async () => {
241
- const remote = makeFakeRemote();
242
- const admin = createAdminServer(remote);
243
- const { port } = await admin.start();
244
- try {
245
- const res = await request(port, {
246
- method: 'POST', path: '/admin/scheduler/create-prd', body: validCreateBody(),
247
- });
248
- assert.strictEqual(res.status, 401);
249
- } finally {
250
- await admin.stop();
251
- }
252
- });
253
-
254
- test('POST /admin/scheduler/create-prd with a valid payload writes a file with frontmatter + standards appended, returns {nn, filename, status}', async () => {
255
- const prdsDir = await mkTmpPrdsDir();
256
- const remote = makeFakeRemoteWithPrdsDir(prdsDir);
257
- const admin = createAdminServer(remote);
258
- const { port, token } = await admin.start();
259
- try {
260
- const res = await request(port, {
261
- method: 'POST', path: '/admin/scheduler/create-prd', token, body: validCreateBody(),
262
- });
263
- assert.strictEqual(res.status, 200);
264
- assert.strictEqual(res.json.status, 'queued');
265
- assert.strictEqual(typeof res.json.nn, 'number');
266
- assert.match(res.json.filename, /^\d+-add-widget-frobnication\.md$/);
267
-
268
- const written = await fsp.readFile(path.join(prdsDir, res.json.filename), 'utf8');
269
- assert.match(written, /^---\n/);
270
- assert.match(written, /title: Add widget frobnication/);
271
- assert.match(written, /cwd: .+/);
272
- assert.match(written, /estimateMinutes: 15/);
273
- assert.match(written, /# Goal/);
274
- assert.match(written, /# Acceptance criteria/);
275
- assert.match(written, /- \[ \] widget frobnicates on click/);
276
- assert.match(written, /# Implementation notes/);
277
- assert.match(written, /# Out of scope/);
278
- assert.match(written, /## Engineering standards/);
279
- assert.ok(written.includes('Execution discipline'), 'must inline standards.md verbatim');
280
- } finally {
281
- await admin.stop();
282
- }
283
- });
284
-
285
- test('POST /admin/scheduler/create-prd with malformed frontmatter (missing acceptanceCriteria) is rejected and writes no file', async () => {
286
- const prdsDir = await mkTmpPrdsDir();
287
- const remote = makeFakeRemoteWithPrdsDir(prdsDir);
288
- const admin = createAdminServer(remote);
289
- const { port, token } = await admin.start();
290
- try {
291
- const body = validCreateBody();
292
- delete body.acceptanceCriteria;
293
- const res = await request(port, {
294
- method: 'POST', path: '/admin/scheduler/create-prd', token, body,
295
- });
296
- assert.strictEqual(res.status, 400);
297
- assert.strictEqual(res.json.ok, false);
298
- const entries = await fsp.readdir(prdsDir);
299
- assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
300
- } finally {
301
- await admin.stop();
302
- }
303
- });
304
-
305
- test('POST /admin/scheduler/create-prd with cwd outside allowed roots is rejected and writes no file', async () => {
306
- const prdsDir = await mkTmpPrdsDir();
307
- const remote = makeFakeRemoteWithPrdsDir(prdsDir);
308
- const admin = createAdminServer(remote);
309
- const { port, token } = await admin.start();
310
- try {
311
- const res = await request(port, {
312
- method: 'POST', path: '/admin/scheduler/create-prd', token,
313
- body: validCreateBody({ cwd: '/etc/passwd-adjacent-outside-home' }),
314
- });
315
- assert.strictEqual(res.status, 400);
316
- assert.strictEqual(res.json.ok, false);
317
- const entries = await fsp.readdir(prdsDir);
318
- assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
319
- } finally {
320
- await admin.stop();
321
- }
322
- });
323
-
324
- test('POST /admin/scheduler/create-prd rejects a title containing a newline (frontmatter-injection guard) and writes no file', async () => {
325
- const prdsDir = await mkTmpPrdsDir();
326
- const remote = makeFakeRemoteWithPrdsDir(prdsDir);
327
- const admin = createAdminServer(remote);
328
- const { port, token } = await admin.start();
329
- try {
330
- const res = await request(port, {
331
- method: 'POST', path: '/admin/scheduler/create-prd', token,
332
- body: validCreateBody({ title: 'Legit title\n---\nEvil: injected' }),
333
- });
334
- assert.strictEqual(res.status, 400);
335
- assert.strictEqual(res.json.ok, false);
336
- const entries = await fsp.readdir(prdsDir);
337
- assert.strictEqual(entries.filter((f) => f.endsWith('.md')).length, 0);
338
- } finally {
339
- await admin.stop();
340
- }
341
- });
342
-
343
- test('POST /admin/scheduler/create-prd with an explicit parallelGroup+slug that already exists on disk returns 409 and does not clobber the file', async () => {
344
- const prdsDir = await mkTmpPrdsDir();
345
- const remote = makeFakeRemoteWithPrdsDir(prdsDir);
346
- const admin = createAdminServer(remote);
347
- const { port, token } = await admin.start();
348
- try {
349
- await fsp.writeFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'ORIGINAL CONTENT\n');
350
- const res = await request(port, {
351
- method: 'POST', path: '/admin/scheduler/create-prd', token,
352
- body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
353
- });
354
- assert.strictEqual(res.status, 409);
355
- assert.strictEqual(res.json.ok, false);
356
- const stillThere = await fsp.readFile(path.join(prdsDir, '777-my-explicit-slug.md'), 'utf8');
357
- assert.strictEqual(stillThere, 'ORIGINAL CONTENT\n', 'existing PRD file must not be overwritten');
358
- } finally {
359
- await admin.stop();
360
- }
361
- });
362
-
363
- test('POST /admin/scheduler/create-prd honors an explicit slug + parallelGroup instead of deriving/allocating', async () => {
364
- const prdsDir = await mkTmpPrdsDir();
365
- const remote = makeFakeRemoteWithPrdsDir(prdsDir);
366
- const admin = createAdminServer(remote);
367
- const { port, token } = await admin.start();
368
- try {
369
- const res = await request(port, {
370
- method: 'POST', path: '/admin/scheduler/create-prd', token,
371
- body: validCreateBody({ slug: 'my-explicit-slug', parallelGroup: 777 }),
372
- });
373
- assert.strictEqual(res.status, 200);
374
- assert.strictEqual(res.json.nn, 777);
375
- assert.strictEqual(res.json.filename, '777-my-explicit-slug.md');
376
- assert.ok(fs.existsSync(path.join(prdsDir, '777-my-explicit-slug.md')));
377
- } finally {
378
- await admin.stop();
379
- }
380
- });
@@ -1,242 +0,0 @@
1
- /**
2
- * adminServer.cjs — loopback-only HTTP admin API for the scheduler.
3
- *
4
- * Foundation for PRD 449 (an MCP server wraps this HTTP API). Exposes a
5
- * narrow, token-authed surface so a same-machine script/tool can reset a
6
- * stuck scheduler job or list jobs without hand-editing queue.json from
7
- * outside the running app (which would race the in-process mutate() queue
8
- * — see scheduler.cjs's serialized-mutation comment).
9
- *
10
- * Security posture:
11
- * - Binds 127.0.0.1 only, OS-assigned ephemeral port. Never reachable
12
- * off-box.
13
- * - Bearer token, regenerated every app boot, written to
14
- * ~/.claude/session-manager/admin-api.json with 0600 perms.
15
- * - Token compared with crypto.timingSafeEqual to avoid a timing
16
- * side-channel on the comparison itself.
17
- * - Three routes: reset-job (flips fields on an already-vetted job),
18
- * jobs (read-only list), and create-prd (PRD 549 — authors a BRAND-NEW
19
- * file that the scheduler will later run as `claude -p
20
- * --dangerously-skip-permissions` in a caller-chosen, home-dir-bounded
21
- * cwd; a materially larger blast radius than reset-job, justified only
22
- * because it unblocks external automation with no other queueing path).
23
- * cwd is validated via config.cjs's validatePath; title/cwd are
24
- * newline-blocked at the zod boundary (ipcSchemas.cjs) to prevent
25
- * frontmatter injection into the hand-rolled PRD serializer
26
- * (prdCreate.cjs). pause/resume/cancel/update remain intentionally NOT
27
- * exposed here.
28
- */
29
-
30
- 'use strict';
31
-
32
- const http = require('node:http');
33
- const os = require('node:os');
34
- const path = require('node:path');
35
- const crypto = require('node:crypto');
36
- const fsp = require('node:fs/promises');
37
- const config = require('./config.cjs');
38
- const { expandHome } = require('./lib/expandHome.cjs');
39
- const { schemas } = require('./ipcSchemas.cjs');
40
- const prdCreate = require('./lib/prdCreate.cjs');
41
-
42
- const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
43
-
44
- function timingSafeEqualStrings(a, b) {
45
- const bufA = Buffer.from(String(a ?? ''));
46
- const bufB = Buffer.from(String(b ?? ''));
47
- if (bufA.length !== bufB.length) {
48
- // Compare against a same-length buffer anyway so the failure path still
49
- // takes constant time relative to the (fixed) token length, rather than
50
- // returning early on a length mismatch.
51
- crypto.timingSafeEqual(bufA, bufA);
52
- return false;
53
- }
54
- return crypto.timingSafeEqual(bufA, bufB);
55
- }
56
-
57
- function readBody(req, maxBytes = 1024 * 1024) {
58
- return new Promise((resolve, reject) => {
59
- let total = 0;
60
- const chunks = [];
61
- req.on('data', (chunk) => {
62
- total += chunk.length;
63
- if (total > maxBytes) {
64
- reject(new Error('body too large'));
65
- req.destroy();
66
- return;
67
- }
68
- chunks.push(chunk);
69
- });
70
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
71
- req.on('error', reject);
72
- });
73
- }
74
-
75
- function sendJson(res, status, obj) {
76
- const body = JSON.stringify(obj);
77
- res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
78
- res.end(body);
79
- }
80
-
81
- /**
82
- * Create the admin HTTP server. `remote` is scheduler.cjs's remote object
83
- * (injected, not required directly, so this module stays testable without
84
- * booting Electron).
85
- */
86
- function createAdminServer(remote) {
87
- let server = null;
88
- let token = null;
89
-
90
- async function ensureToken() {
91
- token = crypto.randomBytes(32).toString('hex');
92
- await config.writeJson(TOKEN_PATH, { port: null, token });
93
- try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* best-effort on platforms without POSIX perms */ }
94
- return token;
95
- }
96
-
97
- async function persistPort(port) {
98
- await config.writeJson(TOKEN_PATH, { port, token });
99
- try { await fsp.chmod(TOKEN_PATH, 0o600); } catch { /* */ }
100
- }
101
-
102
- function authorized(req) {
103
- const header = req.headers.authorization || '';
104
- const match = /^Bearer (.+)$/.exec(header);
105
- if (!match) return false;
106
- return timingSafeEqualStrings(match[1], token);
107
- }
108
-
109
- async function handleRequest(req, res) {
110
- if (!authorized(req)) {
111
- sendJson(res, 401, { ok: false, error: 'unauthorized' });
112
- return;
113
- }
114
- try {
115
- if (req.method === 'GET' && req.url === '/admin/scheduler/jobs') {
116
- const jobs = await remote.listJobs();
117
- sendJson(res, 200, jobs);
118
- return;
119
- }
120
- if (req.method === 'POST' && req.url === '/admin/scheduler/reset-job') {
121
- const raw = await readBody(req);
122
- let parsed;
123
- try {
124
- parsed = raw ? JSON.parse(raw) : {};
125
- } catch {
126
- sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
127
- return;
128
- }
129
- const slug = typeof parsed.slug === 'string' ? parsed.slug : null;
130
- if (!slug) {
131
- sendJson(res, 400, { ok: false, error: 'missing slug' });
132
- return;
133
- }
134
- const result = await remote.resetJob(slug);
135
- sendJson(res, 200, result);
136
- return;
137
- }
138
- if (req.method === 'POST' && req.url === '/admin/scheduler/create-prd') {
139
- const raw = await readBody(req);
140
- let parsed;
141
- try {
142
- parsed = raw ? JSON.parse(raw) : {};
143
- } catch {
144
- sendJson(res, 400, { ok: false, error: 'invalid JSON body' });
145
- return;
146
- }
147
-
148
- let input;
149
- try {
150
- input = schemas.schedulerCreatePrd.parse(parsed);
151
- } catch (e) {
152
- sendJson(res, 400, { ok: false, error: 'invalid PRD payload', details: e?.issues ?? e?.message });
153
- return;
154
- }
155
-
156
- // cwd is untrusted: this is the first *creating* mutation on a
157
- // token-authed API whose product is "a command that will later run
158
- // with --dangerously-skip-permissions in a chosen cwd". Route it
159
- // through config.cjs's validatePath (allowedRoots = home dir) —
160
- // same boundary every other fs-touching IPC handler uses — never a
161
- // bespoke check here.
162
- try {
163
- config.validatePath(expandHome(input.cwd));
164
- } catch (e) {
165
- sendJson(res, 400, { ok: false, error: `cwd rejected: ${e?.message ?? 'outside allowed roots'}` });
166
- return;
167
- }
168
-
169
- const slug = input.slug || prdCreate.deriveSlugFromTitle(input.title);
170
- if (!slug || !prdCreate.PRD_CREATE_SLUG_RE.test(slug)) {
171
- sendJson(res, 400, { ok: false, error: 'could not derive a valid kebab-case slug from title; supply "slug" explicitly' });
172
- return;
173
- }
174
-
175
- // NN allocation is delegated to allocateParallelGroup() (PRD 548) via
176
- // the injected remote — never re-derived here — unless the caller
177
- // opted into an existing group explicitly.
178
- const nn = input.parallelGroup ?? await remote.allocateParallelGroup();
179
- const filenameSlug = `${nn}-${slug}`;
180
-
181
- // An explicit `parallelGroup` bypasses allocateParallelGroup()'s
182
- // collision-proof reservation, so re-check for an existing file at
183
- // this exact destination before writing — remote.writePrd itself has
184
- // no existence guard (by design, it doubles as the edit-in-place
185
- // path for Scheduler UI PRD edits), so "create" must not silently
186
- // clobber an existing job's PRD.
187
- const existing = await remote.readPrd(filenameSlug);
188
- if (existing?.ok) {
189
- sendJson(res, 409, { ok: false, error: `PRD already exists: ${filenameSlug}.md` });
190
- return;
191
- }
192
-
193
- let standardsText;
194
- try {
195
- standardsText = await prdCreate.readStandards();
196
- } catch (e) {
197
- sendJson(res, 500, { ok: false, error: `could not read engineering standards: ${e?.message}` });
198
- return;
199
- }
200
-
201
- const body = prdCreate.buildPrdBody(input, standardsText);
202
- const writeResult = await remote.writePrd(filenameSlug, body);
203
- if (!writeResult?.ok) {
204
- sendJson(res, 500, { ok: false, error: writeResult?.error ?? 'write failed' });
205
- return;
206
- }
207
-
208
- sendJson(res, 200, { nn, filename: `${filenameSlug}.md`, status: 'queued' });
209
- return;
210
- }
211
- sendJson(res, 404, { ok: false, error: 'not found' });
212
- } catch (e) {
213
- sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
214
- }
215
- }
216
-
217
- async function start() {
218
- await ensureToken();
219
- server = http.createServer((req, res) => {
220
- handleRequest(req, res).catch(() => {
221
- try { sendJson(res, 500, { ok: false, error: 'internal error' }); } catch { /* */ }
222
- });
223
- });
224
- await new Promise((resolve, reject) => {
225
- server.once('error', reject);
226
- server.listen(0, '127.0.0.1', resolve);
227
- });
228
- const { port } = server.address();
229
- await persistPort(port);
230
- return { port, token };
231
- }
232
-
233
- async function stop() {
234
- if (!server) return;
235
- await new Promise((resolve) => server.close(() => resolve()));
236
- server = null;
237
- }
238
-
239
- return { start, stop, get token() { return token; }, get server() { return server; } };
240
- }
241
-
242
- module.exports = { createAdminServer, TOKEN_PATH };