claude-code-session-manager 0.35.16 → 0.35.17

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 (35) hide show
  1. package/dist/assets/{TiptapBody-BD2BTBY-.js → TiptapBody-BM9Kz1Nm.js} +1 -1
  2. package/dist/assets/index-DX2w2YhJ.css +32 -0
  3. package/dist/assets/index-DuoC6oCy.js +3559 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/main/__tests__/adminServer.test.cjs +205 -0
  7. package/src/main/__tests__/chat-queue.test.cjs +27 -0
  8. package/src/main/__tests__/health-tick-liveness.test.cjs +143 -0
  9. package/src/main/__tests__/prd-group-allocator.test.cjs +119 -0
  10. package/src/main/__tests__/prdCreate.test.cjs +82 -0
  11. package/src/main/__tests__/runVerify.test.cjs +146 -0
  12. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
  13. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
  14. package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
  15. package/src/main/adminServer.cjs +87 -2
  16. package/src/main/browserCapture.cjs +21 -7
  17. package/src/main/chatRunner.cjs +5 -1
  18. package/src/main/health.cjs +114 -3
  19. package/src/main/hives.cjs +2 -1
  20. package/src/main/ipcSchemas.cjs +33 -4
  21. package/src/main/lib/kebabCase.cjs +12 -0
  22. package/src/main/lib/memorySlug.cjs +10 -0
  23. package/src/main/lib/prdCreate.cjs +73 -0
  24. package/src/main/memoryAggregate.cjs +1 -1
  25. package/src/main/memoryTool.cjs +2 -1
  26. package/src/main/pty.cjs +2 -1
  27. package/src/main/runVerify.cjs +119 -6
  28. package/src/main/scheduler/prdParser.cjs +88 -0
  29. package/src/main/scheduler.cjs +176 -9
  30. package/src/main/templates/PRD_AUTHORING.md +126 -0
  31. package/src/main/usage.cjs +4 -0
  32. package/src/main/webRemote.cjs +13 -18
  33. package/src/preload/api.d.ts +1 -0
  34. package/dist/assets/index-BwFHVuSk.css +0 -32
  35. package/dist/assets/index-C6ep3yfh.js +0 -3559
@@ -973,3 +973,149 @@ test('halt result + sentinel PASS + committedDuringRun:true → still halt', asy
973
973
  rmdir(tmp);
974
974
  }
975
975
  });
976
+
977
+ // ─── -merge-main postcondition exemption tests (2026-07-18 feedback) ──────────
978
+
979
+ const { isMergeMainSlug, extractMergeMainPrNumber } = require('../runVerify.cjs');
980
+
981
+ test('isMergeMainSlug matches the NN-prXXX-merge-main and NN-fix-prXXX-merge-main conventions', () => {
982
+ assert.equal(isMergeMainSlug('565-pr188-merge-main'), true);
983
+ assert.equal(isMergeMainSlug('571-fix-pr141-merge-main'), true);
984
+ assert.equal(isMergeMainSlug('523-fix-bounded-fix-plan-retry'), false);
985
+ assert.equal(isMergeMainSlug('406-browser-capture-panel-ui'), false);
986
+ assert.equal(isMergeMainSlug(''), false);
987
+ assert.equal(isMergeMainSlug(undefined), false);
988
+ });
989
+
990
+ test('extractMergeMainPrNumber prefers "PR #<n>" in text, falls back to slug', () => {
991
+ assert.equal(extractMergeMainPrNumber('565-pr188-merge-main', 'title: Merge current main into sigma PR #188 (round 2)'), 188);
992
+ assert.equal(extractMergeMainPrNumber('565-pr188-merge-main', 'no pr mention here'), 188);
993
+ assert.equal(extractMergeMainPrNumber('non-standard-slug', 'no pr mention here'), null);
994
+ });
995
+
996
+ // (1) -merge-main + PASS + no commit + gh reports MERGEABLE/non-CONFLICTING → verified, no issue
997
+ test('merge-main exemption: gh reports MERGEABLE → pass_no_commit_target_verified, no issue pushed', async () => {
998
+ const tmp = makeTmpDir();
999
+ try {
1000
+ const slug = '565-pr188-merge-main';
1001
+ writeLog(tmp, slug, noOpRunEvents('PR #188 was already merged into main by another actor.\nSCHEDULER_VERDICT: PASS'));
1002
+ const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
1003
+ let ghCalls = 0;
1004
+ const ghExecImpl = (cmd, args, opts) => {
1005
+ ghCalls++;
1006
+ assert.equal(cmd, 'gh');
1007
+ assert.deepEqual(args, ['pr', 'view', '188', '--json', 'mergeable,mergeStateStatus']);
1008
+ assert.equal(opts.cwd, '/tmp/sigma');
1009
+ return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'BEHIND' });
1010
+ };
1011
+ const verdict = await verifyRun({
1012
+ runDir: tmp,
1013
+ prdPath,
1014
+ queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
1015
+ allJobs: [],
1016
+ committedDuringRun: false,
1017
+ ghExecImpl,
1018
+ });
1019
+ assert.equal(ghCalls, 1);
1020
+ assert.equal(verdict.verdict, 'pass_no_commit_target_verified', `expected verified verdict, got ${verdict.verdict}: ${verdict.reason}`);
1021
+ assert.equal(verdict.downgradeTo, null);
1022
+ assert.equal(verdict.verifiedPrNumber, 188);
1023
+ } finally {
1024
+ rmdir(tmp);
1025
+ }
1026
+ });
1027
+
1028
+ // (2) -merge-main + PASS + no commit + gh reports CONFLICTING → falls through to pass_no_commit
1029
+ test('merge-main exemption: gh reports CONFLICTING → falls through to pass_no_commit', async () => {
1030
+ const tmp = makeTmpDir();
1031
+ try {
1032
+ const slug = '565-pr188-merge-main';
1033
+ writeLog(tmp, slug, noOpRunEvents('PR #188 needs no further work.\nSCHEDULER_VERDICT: PASS'));
1034
+ const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
1035
+ const ghExecImpl = () => JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CONFLICTING' });
1036
+ const verdict = await verifyRun({
1037
+ runDir: tmp,
1038
+ prdPath,
1039
+ queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
1040
+ allJobs: [],
1041
+ committedDuringRun: false,
1042
+ ghExecImpl,
1043
+ });
1044
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1045
+ assert.equal(verdict.downgradeTo, 'needs_review');
1046
+ } finally {
1047
+ rmdir(tmp);
1048
+ }
1049
+ });
1050
+
1051
+ // (3) -merge-main + PASS + no commit + gh call fails/times out → falls through to pass_no_commit, never throws
1052
+ test('merge-main exemption: gh call throws → falls through to pass_no_commit, never throws', async () => {
1053
+ const tmp = makeTmpDir();
1054
+ try {
1055
+ const slug = '565-pr188-merge-main';
1056
+ writeLog(tmp, slug, noOpRunEvents('PR #188 needs no further work.\nSCHEDULER_VERDICT: PASS'));
1057
+ const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
1058
+ const ghExecImpl = () => { throw new Error('gh: command not found'); };
1059
+ const verdict = await verifyRun({
1060
+ runDir: tmp,
1061
+ prdPath,
1062
+ queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
1063
+ allJobs: [],
1064
+ committedDuringRun: false,
1065
+ ghExecImpl,
1066
+ });
1067
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected fallback to pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1068
+ assert.equal(verdict.downgradeTo, 'needs_review');
1069
+ } finally {
1070
+ rmdir(tmp);
1071
+ }
1072
+ });
1073
+
1074
+ // (4) non-merge-main slug, PASS + no commit → unchanged pass_no_commit behavior, zero gh invocations
1075
+ test('merge-main exemption: non-merge-main slug never attempts a gh call', async () => {
1076
+ const tmp = makeTmpDir();
1077
+ try {
1078
+ const slug = '406-browser-capture-panel-ui';
1079
+ writeLog(tmp, slug, noOpRunEvents('All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS'));
1080
+ const prdPath = writePrd(tmp, slug, '# Browser capture panel UI');
1081
+ let ghCalls = 0;
1082
+ const ghExecImpl = () => { ghCalls++; return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' }); };
1083
+ const verdict = await verifyRun({
1084
+ runDir: tmp,
1085
+ prdPath,
1086
+ queueEntry: { slug, status: 'running', cwd: '/tmp/proj' },
1087
+ allJobs: [],
1088
+ committedDuringRun: false,
1089
+ ghExecImpl,
1090
+ });
1091
+ assert.equal(ghCalls, 0, 'gh must never be invoked for a non-merge-main slug');
1092
+ assert.equal(verdict.verdict, 'pass_no_commit', `expected unchanged pass_no_commit, got ${verdict.verdict}: ${verdict.reason}`);
1093
+ assert.equal(verdict.downgradeTo, 'needs_review');
1094
+ } finally {
1095
+ rmdir(tmp);
1096
+ }
1097
+ });
1098
+
1099
+ // (5) -merge-main slug but a real commit DID land → unaffected, path unreachable, zero gh invocations
1100
+ test('merge-main exemption: commit landed → path unreachable, zero gh invocations, verdict clean', async () => {
1101
+ const tmp = makeTmpDir();
1102
+ try {
1103
+ const slug = '565-pr188-merge-main';
1104
+ writeLog(tmp, slug, noOpRunEvents('Merged and pushed.\nSCHEDULER_VERDICT: PASS'));
1105
+ const prdPath = writePrd(tmp, slug, '# Merge current main into sigma PR #188');
1106
+ let ghCalls = 0;
1107
+ const ghExecImpl = () => { ghCalls++; return JSON.stringify({ mergeable: 'MERGEABLE', mergeStateStatus: 'CLEAN' }); };
1108
+ const verdict = await verifyRun({
1109
+ runDir: tmp,
1110
+ prdPath,
1111
+ queueEntry: { slug, status: 'running', cwd: '/tmp/sigma' },
1112
+ allJobs: [],
1113
+ committedDuringRun: true,
1114
+ ghExecImpl,
1115
+ });
1116
+ assert.equal(ghCalls, 0, 'gh must never be invoked when a commit already landed');
1117
+ assert.equal(verdict.verdict, 'clean', `expected clean (commit landed, sentinel PASS), got ${verdict.verdict}: ${verdict.reason}`);
1118
+ } finally {
1119
+ rmdir(tmp);
1120
+ }
1121
+ });
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * scheduler-committed-in-window.test.cjs — unit tests for committedInWindow.
3
3
  *
4
- * Run: timeout 300 npx vitest run src/main/__tests__/scheduler-committed-in-window.test.cjs
4
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-committed-in-window.test.cjs
5
5
  */
6
6
 
7
7
  'use strict';
8
8
 
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
9
11
  const fs = require('node:fs');
10
12
  const os = require('node:os');
11
13
  const path = require('node:path');
@@ -42,7 +44,7 @@ test('committedInWindow returns true for a commit landed on a non-checked-out br
42
44
  git(dir, ['checkout', '-q', '-b', 'other-branch-at-exit']);
43
45
 
44
46
  const result = await committedInWindow(dir, startedAt, finishedAt);
45
- expect(result).toBe(true);
47
+ assert.strictEqual(result, true);
46
48
  } finally {
47
49
  fs.rmSync(dir, { recursive: true, force: true });
48
50
  }
@@ -55,7 +57,7 @@ test('committedInWindow returns false when the window covers no commit', async (
55
57
  const farFutureEnd = new Date(Date.now() + 366 * 24 * 60 * 60 * 1000).toISOString();
56
58
 
57
59
  const result = await committedInWindow(dir, farFutureStart, farFutureEnd);
58
- expect(result).toBe(false);
60
+ assert.strictEqual(result, false);
59
61
  } finally {
60
62
  fs.rmSync(dir, { recursive: true, force: true });
61
63
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * scheduler-tick-cancel-token.test.cjs — regression test for the 2026-07-14
3
+ * scheduler stall (PRD 543/544): the tick loop stopped picking up pending
4
+ * jobs after a job's run paused the queue (e.g. a rate limit), because the
5
+ * pause's cancelToken.cancelled flag was only ever reset inside
6
+ * runDueJobs() — clearPause() (used by the poll-loop's auth/network
7
+ * auto-recovery and the manual "Resume" button) never reset it, so every
8
+ * subsequent tickQueue() call short-circuited on the stale flag forever,
9
+ * even though queue.json's `paused` field was correctly null.
10
+ *
11
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-tick-cancel-token.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const { applyPauseCleared, pickNextBatch } = require('../scheduler.cjs');
19
+
20
+ test('clearPause un-cancels the tick guard so the next pending job still starts', () => {
21
+ // A job's run pauses the queue (e.g. rate limit) — setPaused() cancels the
22
+ // in-flight tick batch.
23
+ const cancelToken = { cancelled: true };
24
+
25
+ // The pause is cleared via a recovery path OTHER than runDueJobs() (the
26
+ // poll-loop's auth/network auto-recovery, or the manual "Resume" button —
27
+ // both call clearPause() directly). Before the fix, this left
28
+ // cancelToken.cancelled stuck at true forever.
29
+ applyPauseCleared(true, cancelToken);
30
+
31
+ assert.strictEqual(
32
+ cancelToken.cancelled,
33
+ false,
34
+ 'clearPause must un-cancel the tick guard, or every future tick short-circuits forever even though paused=null',
35
+ );
36
+
37
+ // With the guard cleared, the next tick's batch-picking proceeds normally
38
+ // and starts the next pending job after the failed one.
39
+ const jobs = [
40
+ { slug: '543-chat-mode-data-table-rendering', status: 'failed', cwd: '/proj' },
41
+ { slug: '544-committed-in-window-test-runner-consistency', status: 'pending', cwd: '/proj' },
42
+ ];
43
+ const { batch } = pickNextBatch(jobs, new Set(), 3);
44
+ assert.strictEqual(batch.length, 1);
45
+ assert.strictEqual(batch[0].slug, '544-committed-in-window-test-runner-consistency');
46
+ });
47
+
48
+ test('applyPauseCleared is a no-op when the queue was not actually paused', () => {
49
+ const cancelToken = { cancelled: false };
50
+ applyPauseCleared(false, cancelToken);
51
+ assert.strictEqual(cancelToken.cancelled, false);
52
+ });
53
+
54
+ console.log('scheduler-tick-cancel-token tests: PASS');
@@ -0,0 +1,141 @@
1
+ /**
2
+ * scheduler-transient-failure.test.cjs — classifying network-outage vs. real
3
+ * code failures so a transient outage (PRD 543/545: ENOTFOUND) gets a bounded
4
+ * requeue instead of burning a whole run + an auto-fix investigation on an
5
+ * outage that has nothing to do with the code.
6
+ *
7
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-transient-failure.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const { test } = require('node:test');
13
+ const assert = require('node:assert/strict');
14
+ const fs = require('node:fs');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const {
18
+ detectNetworkErrorInLog,
19
+ detectRateLimitInLog,
20
+ classifyFailureOutcome,
21
+ TRANSIENT_RETRY_CAP,
22
+ } = require('../scheduler.cjs');
23
+
24
+ // Real tail captured from the 543 incident run (2026-07-14T14-40-39-306Z),
25
+ // per the PRD's "reuse it, don't re-derive" instruction.
26
+ const NETWORK_OUTAGE_LOG_TAIL = String.raw`
27
+ {"type":"result","subtype":"success","is_error":true,"api_error_status":null,"duration_ms":592749,"duration_api_ms":83313,"num_turns":19,"result":"API Error: Unable to connect to API (ENOTFOUND)","stop_reason":"stop_sequence","session_id":"85b1a3f4-3625-4f2d-a9f0-b3994f062b39","total_cost_usd":0.75,"terminal_reason":"api_error","uuid":"cc041889-4497-4fca-9f83-73e3dae6de54"}
28
+ `;
29
+
30
+ const RATE_LIMIT_LOG_TAIL = String.raw`
31
+ {"type":"result","subtype":"error","is_error":true,"api_error_status":429,"rateLimitType":"five_hour","result":"You've hit your limit","uuid":"abc"}
32
+ `;
33
+
34
+ const REAL_CODE_FAILURE_LOG_TAIL = String.raw`
35
+ {"type":"result","subtype":"error","is_error":true,"result":"Error: expected 2 but got 3\n at test/foo.test.ts:12:5","terminal_reason":"error_max_turns","uuid":"def"}
36
+ `;
37
+
38
+ function writeTmpLog(contents) {
39
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transient-log-'));
40
+ const p = path.join(dir, 'run.log');
41
+ fs.writeFileSync(p, contents);
42
+ return p;
43
+ }
44
+
45
+ test('detectNetworkErrorInLog fires only on terminal_reason:api_error + a network-class error', () => {
46
+ const p = writeTmpLog(NETWORK_OUTAGE_LOG_TAIL);
47
+ try {
48
+ assert.strictEqual(detectNetworkErrorInLog(p), true);
49
+ } finally {
50
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
51
+ }
52
+ });
53
+
54
+ test('detectNetworkErrorInLog does not fire on a genuine code failure (no terminal_reason:api_error)', () => {
55
+ const p = writeTmpLog(REAL_CODE_FAILURE_LOG_TAIL);
56
+ try {
57
+ assert.strictEqual(detectNetworkErrorInLog(p), false);
58
+ } finally {
59
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
60
+ }
61
+ });
62
+
63
+ test('detectNetworkErrorInLog does not fire on a rate-limit log (rateLimited handling stays separate)', () => {
64
+ const p = writeTmpLog(RATE_LIMIT_LOG_TAIL);
65
+ try {
66
+ assert.strictEqual(detectNetworkErrorInLog(p), false);
67
+ // And the reverse: the rate-limit detector doesn't fire on the network log.
68
+ assert.strictEqual(detectRateLimitInLog(p), true);
69
+ } finally {
70
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test('detectRateLimitInLog does not fire on a network-outage log', () => {
75
+ const p = writeTmpLog(NETWORK_OUTAGE_LOG_TAIL);
76
+ try {
77
+ assert.strictEqual(detectRateLimitInLog(p), false);
78
+ } finally {
79
+ fs.rmSync(path.dirname(p), { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ test('classifyFailureOutcome: ENOTFOUND-class network error with a clean tree retries (bounded)', () => {
84
+ const decision = classifyFailureOutcome({
85
+ exitCode: 1,
86
+ networkError: true,
87
+ durationMs: 594_000,
88
+ transientRetries: 0,
89
+ newlyDirtyCount: 0,
90
+ });
91
+ assert.strictEqual(decision.action, 'retry');
92
+ assert.strictEqual(decision.transientKind, 'network');
93
+ assert.strictEqual(decision.retries, 0);
94
+ });
95
+
96
+ test('classifyFailureOutcome: network error with uncommitted work left behind refuses to requeue', () => {
97
+ const decision = classifyFailureOutcome({
98
+ exitCode: 1,
99
+ networkError: true,
100
+ durationMs: 594_000,
101
+ transientRetries: 0,
102
+ newlyDirtyCount: 2,
103
+ });
104
+ assert.strictEqual(decision.action, 'fail-dirty');
105
+ assert.strictEqual(decision.newlyDirtyCount, 2);
106
+ });
107
+
108
+ test('classifyFailureOutcome: retry cap exhausted fails without auto-fix, never loops unboundedly', () => {
109
+ const decision = classifyFailureOutcome({
110
+ exitCode: 1,
111
+ networkError: true,
112
+ durationMs: 594_000,
113
+ transientRetries: TRANSIENT_RETRY_CAP,
114
+ newlyDirtyCount: 0,
115
+ });
116
+ assert.strictEqual(decision.action, 'fail-cap');
117
+ assert.strictEqual(decision.retries, TRANSIENT_RETRY_CAP);
118
+ });
119
+
120
+ test('classifyFailureOutcome: a genuine code failure (non-network exit 1) is still terminal + investigated', () => {
121
+ const decision = classifyFailureOutcome({
122
+ exitCode: 1,
123
+ networkError: false,
124
+ durationMs: 30_000,
125
+ transientRetries: 0,
126
+ newlyDirtyCount: 0,
127
+ });
128
+ assert.strictEqual(decision.action, 'investigate');
129
+ });
130
+
131
+ test('classifyFailureOutcome: signal-kill transient (exit 143, short run) still retries as before', () => {
132
+ const decision = classifyFailureOutcome({
133
+ exitCode: 143,
134
+ networkError: false,
135
+ durationMs: 60_000,
136
+ transientRetries: 0,
137
+ newlyDirtyCount: 0,
138
+ });
139
+ assert.strictEqual(decision.action, 'retry');
140
+ assert.strictEqual(decision.transientKind, 'exit=143');
141
+ });
@@ -14,8 +14,17 @@
14
14
  * ~/.claude/session-manager/admin-api.json with 0600 perms.
15
15
  * - Token compared with crypto.timingSafeEqual to avoid a timing
16
16
  * side-channel on the comparison itself.
17
- * - Only two routes: reset-job (one narrow mutation) and jobs (read-only
18
- * list). writePrd/pause/resume are intentionally NOT exposed here.
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.
19
28
  */
20
29
 
21
30
  'use strict';
@@ -26,6 +35,9 @@ const path = require('node:path');
26
35
  const crypto = require('node:crypto');
27
36
  const fsp = require('node:fs/promises');
28
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');
29
41
 
30
42
  const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
31
43
 
@@ -123,6 +135,79 @@ function createAdminServer(remote) {
123
135
  sendJson(res, 200, result);
124
136
  return;
125
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
+ }
126
211
  sendJson(res, 404, { ok: false, error: 'not found' });
127
212
  } catch (e) {
128
213
  sendJson(res, 500, { ok: false, error: e?.message ?? 'internal error' });
@@ -656,14 +656,21 @@ async function captureSelection({ viewId, selectors, mode }, getView) {
656
656
  }
657
657
 
658
658
  if (mode === 'a11y') {
659
- const selector = selectors[0];
660
- const tree = await captureA11y(wc, selector);
661
- const text = tree ? formatA11yTree(tree) : '';
659
+ // captureA11y attaches/detaches the CDP debugger per call, so loop
660
+ // sequentially rather than firing selectors in parallel (mirrors the
661
+ // multi-select behavior of the html/selector modes above, which apply to
662
+ // the whole `selectors` array instead of only the first entry).
663
+ const trees = [];
664
+ for (const selector of selectors) {
665
+ const tree = await captureA11y(wc, selector);
666
+ if (tree) trees.push(tree);
667
+ }
668
+ const text = trees.map((t) => formatA11yTree(t)).join('\n\n');
662
669
  return { ok: true, mode, text, meta: {} };
663
670
  }
664
671
 
665
672
  // mode === 'agent'
666
- const rawTree = await wc.executeJavaScript(
673
+ const rawTrees = await wc.executeJavaScript(
667
674
  `(function () {
668
675
  function serialize(el) {
669
676
  if (el.nodeType === 3) {
@@ -683,11 +690,18 @@ async function captureSelection({ viewId, selectors, mode }, getView) {
683
690
  }
684
691
  return { tag: el.tagName.toLowerCase(), attrs: attrs, style: { display: cs.display, visibility: cs.visibility }, children: children };
685
692
  }
686
- var el = document.querySelector(${JSON.stringify(selectors[0])});
687
- return el ? serialize(el) : null;
693
+ return (${JSON.stringify(selectors)}).map(function (sel) {
694
+ var el = document.querySelector(sel);
695
+ return el ? serialize(el) : null;
696
+ }).filter(Boolean);
688
697
  })()`,
689
698
  );
690
- const { text, meta } = buildAgentCapture(capRawTree(rawTree));
699
+ const parts = (Array.isArray(rawTrees) ? rawTrees : []).map((t) => buildAgentCapture(capRawTree(t)));
700
+ const text = parts.map((p) => p.text).join('\n\n');
701
+ const meta = {
702
+ chunks: parts.reduce((a, p) => a + (p.meta.chunks || 0), 0),
703
+ tokens: parts.reduce((a, p) => a + (p.meta.tokens || 0), 0),
704
+ };
691
705
  return { ok: true, mode, text, meta };
692
706
  }
693
707
 
@@ -310,8 +310,12 @@ function pump() {
310
310
  .catch(() => { /* executeRun never rejects; defensive */ })
311
311
  .finally(() => { activeCount -= 1; pump(); });
312
312
  }
313
- // Anyone still waiting gets a 1-based position update.
313
+ // Anyone still waiting gets a 1-based position update. Silent (automated
314
+ // probe) runs must stay invisible to the renderer, same as every other
315
+ // broadcast in executeRun — otherwise a queued probe would flip a tab's
316
+ // `running`/`queuedPosition` UI state for a run the user never initiated.
314
317
  waiting.forEach((w, i) => {
318
+ if (w.silent) return;
315
319
  broadcast('chat:run:queued', { tabId: w.tabId, sessionId: w.sessionId, position: i + 1 });
316
320
  });
317
321
  }