claude-code-session-manager 0.35.16 → 0.35.18

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 (37) hide show
  1. package/dist/assets/{TiptapBody-BD2BTBY-.js → TiptapBody-DgFO_m3g.js} +1 -1
  2. package/dist/assets/index-4dJkn9nT.js +3559 -0
  3. package/dist/assets/index-DX2w2YhJ.css +32 -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__/pty-write-result.test.cjs +46 -0
  12. package/src/main/__tests__/runVerify.test.cjs +146 -0
  13. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +5 -3
  14. package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +54 -0
  15. package/src/main/__tests__/scheduler-transient-failure.test.cjs +141 -0
  16. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +181 -0
  17. package/src/main/adminServer.cjs +87 -2
  18. package/src/main/browserCapture.cjs +21 -7
  19. package/src/main/chatRunner.cjs +5 -1
  20. package/src/main/health.cjs +114 -3
  21. package/src/main/hives.cjs +2 -1
  22. package/src/main/ipcSchemas.cjs +33 -4
  23. package/src/main/lib/kebabCase.cjs +12 -0
  24. package/src/main/lib/memorySlug.cjs +10 -0
  25. package/src/main/lib/prdCreate.cjs +73 -0
  26. package/src/main/memoryAggregate.cjs +1 -1
  27. package/src/main/memoryTool.cjs +2 -1
  28. package/src/main/pty.cjs +7 -6
  29. package/src/main/runVerify.cjs +119 -6
  30. package/src/main/scheduler/prdParser.cjs +88 -0
  31. package/src/main/scheduler.cjs +176 -9
  32. package/src/main/templates/PRD_AUTHORING.md +126 -0
  33. package/src/main/usage.cjs +4 -0
  34. package/src/main/webRemote.cjs +70 -24
  35. package/src/preload/api.d.ts +1 -0
  36. package/dist/assets/index-BwFHVuSk.css +0 -32
  37. package/dist/assets/index-C6ep3yfh.js +0 -3559
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * web-remote-e2e-pinning.test.cjs — TOFU pinning for the web-remote E2E handshake.
5
+ *
6
+ * Once a browser's SPKI pubkey is manually SAS-confirmed for a paired device,
7
+ * a later `e2e:hello` presenting the SAME pubkey for that device should
8
+ * auto-authenticate (no `confirm-sas` needed); a DIFFERENT pubkey must still
9
+ * fall through to the manual `pending_sas` flow.
10
+ *
11
+ * Runs against a tmp HOME (config.cjs / webRemote.cjs both resolve paths off
12
+ * os.homedir()) and stubs the `electron` module in require.cache, mirroring
13
+ * the tmp-HOME + require.cache-stub pattern used by exchanges.test.cjs.
14
+ *
15
+ * Run: timeout 300 npx vitest run src/main/__tests__/web-remote-e2e-pinning.test.cjs
16
+ */
17
+
18
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest'
19
+
20
+ const fs = require('node:fs')
21
+ const fsp = require('node:fs/promises')
22
+ const path = require('node:path')
23
+ const os = require('node:os')
24
+ const crypto = require('node:crypto')
25
+
26
+ /** Generate a P-256 keypair in the same encoding webRemote.cjs uses (SPKI/PKCS8, base64url). */
27
+ function genP256KeyPairB64() {
28
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
29
+ namedCurve: 'P-256',
30
+ publicKeyEncoding: { type: 'spki', format: 'der' },
31
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
32
+ })
33
+ return {
34
+ priv: privateKey.toString('base64url'),
35
+ pub: publicKey.toString('base64url'),
36
+ }
37
+ }
38
+
39
+ let tmpHome
40
+ let origHome
41
+ let webRemote
42
+ let configPath
43
+ let handlers // captured ipcMain.handle(name, fn) map
44
+
45
+ function freshRequire(mod) {
46
+ delete require.cache[require.resolve(mod)]
47
+ return require(mod)
48
+ }
49
+
50
+ async function readConfig() {
51
+ const text = await fsp.readFile(configPath, 'utf8')
52
+ return JSON.parse(text)
53
+ }
54
+
55
+ async function writeConfig(cfg) {
56
+ await fsp.mkdir(path.dirname(configPath), { recursive: true })
57
+ await fsp.writeFile(configPath, JSON.stringify(cfg, null, 2) + '\n')
58
+ }
59
+
60
+ beforeAll(async () => {
61
+ tmpHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'web-remote-e2e-pinning-'))
62
+ origHome = process.env.HOME
63
+ process.env.HOME = tmpHome
64
+
65
+ // Stub 'electron' before webRemote.cjs (and logs.cjs, transitively) require it —
66
+ // outside a real Electron process, require('electron') just resolves to a path
67
+ // string, so destructuring { ipcMain, app } off it would throw at module load.
68
+ handlers = new Map()
69
+ const electronPath = require.resolve('electron')
70
+ require.cache[electronPath] = {
71
+ id: electronPath,
72
+ filename: electronPath,
73
+ loaded: true,
74
+ exports: {
75
+ ipcMain: {
76
+ handle: (name, fn) => handlers.set(name, fn),
77
+ on: () => {},
78
+ },
79
+ app: {
80
+ getPath: () => tmpHome,
81
+ getVersion: () => '0.0.0-test',
82
+ },
83
+ },
84
+ }
85
+
86
+ // Clear anything already cached under the real HOME so paths re-resolve under tmpHome.
87
+ for (const mod of ['../webRemote.cjs', '../config.cjs', '../logs.cjs', './e2eStateMachine.cjs']) {
88
+ try { delete require.cache[require.resolve(mod)] } catch { /* not yet loaded */ }
89
+ }
90
+
91
+ webRemote = freshRequire('../webRemote.cjs')
92
+ configPath = path.join(tmpHome, '.claude', 'session-manager', 'web-remote.json')
93
+
94
+ webRemote.registerRemoteHandlers()
95
+ })
96
+
97
+ afterAll(async () => {
98
+ process.env.HOME = origHome
99
+ await fsp.rm(tmpHome, { recursive: true, force: true })
100
+ })
101
+
102
+ describe('web-remote E2E SAS pinning', () => {
103
+ const deviceKeys = genP256KeyPairB64() // desktop's own E2E keypair
104
+ const browserA = genP256KeyPairB64() // first browser's keypair
105
+ const browserB = genP256KeyPairB64() // a different browser's keypair
106
+ const deviceId = 'device-under-test'
107
+
108
+ function baseDeviceRow(overrides = {}) {
109
+ return {
110
+ deviceId,
111
+ deviceToken: 'tok-abc',
112
+ e2ePrivateKey: deviceKeys.priv,
113
+ e2ePublicKey: deviceKeys.pub,
114
+ deviceName: 'Test Device',
115
+ issuedAt: new Date(0).toISOString(),
116
+ lastConnectedAt: null,
117
+ verifiedPeerPubKey: null,
118
+ ...overrides,
119
+ }
120
+ }
121
+
122
+ it('1) first e2e:hello for a never-confirmed device lands in pending_sas', async () => {
123
+ await writeConfig({ remoteEnabled: true, remoteControlEnabled: false, devices: [baseDeviceRow()] })
124
+
125
+ const cfg = await readConfig()
126
+ const device = cfg.devices[0]
127
+ await webRemote._internal.handleMessage(
128
+ JSON.stringify({ type: 'e2e:hello', id: 'm1', payload: { pubKey: browserA.pub } }),
129
+ device
130
+ )
131
+
132
+ expect(webRemote._internal.getE2eState().state).toBe('pending_sas')
133
+ })
134
+
135
+ it('2) a manual confirm-sas persists the connected device\'s verifiedPeerPubKey', async () => {
136
+ const confirmSas = handlers.get('webRemote:confirm-sas')
137
+ expect(typeof confirmSas).toBe('function')
138
+
139
+ const result = await confirmSas()
140
+ expect(result.ok).toBe(true)
141
+ expect(webRemote._internal.getE2eState().state).toBe('authenticated')
142
+
143
+ const cfg = await readConfig()
144
+ const persisted = cfg.devices.find((d) => d.deviceId === deviceId)
145
+ expect(persisted.verifiedPeerPubKey).toBe(browserA.pub)
146
+ })
147
+
148
+ it('3) a second e2e:hello presenting the SAME browserPubKey auto-authenticates (no confirm-sas)', async () => {
149
+ // Simulate a fresh reconnect: new WS session resets E2E state, and the device
150
+ // row is reloaded from disk (as connect() would), now carrying the pinned key.
151
+ webRemote._internal.resetE2e()
152
+ expect(webRemote._internal.getE2eState().state).toBe('idle')
153
+
154
+ const cfg = await readConfig()
155
+ const device = cfg.devices.find((d) => d.deviceId === deviceId)
156
+ expect(device.verifiedPeerPubKey).toBe(browserA.pub)
157
+
158
+ await webRemote._internal.handleMessage(
159
+ JSON.stringify({ type: 'e2e:hello', id: 'm2', payload: { pubKey: browserA.pub } }),
160
+ device
161
+ )
162
+
163
+ expect(webRemote._internal.getE2eState().state).toBe('authenticated')
164
+ })
165
+
166
+ it('4) a e2e:hello presenting a DIFFERENT browserPubKey still lands in pending_sas', async () => {
167
+ webRemote._internal.resetE2e()
168
+ expect(webRemote._internal.getE2eState().state).toBe('idle')
169
+
170
+ const cfg = await readConfig()
171
+ const device = cfg.devices.find((d) => d.deviceId === deviceId)
172
+ expect(device.verifiedPeerPubKey).toBe(browserA.pub) // still pinned to A, not B
173
+
174
+ await webRemote._internal.handleMessage(
175
+ JSON.stringify({ type: 'e2e:hello', id: 'm3', payload: { pubKey: browserB.pub } }),
176
+ device
177
+ )
178
+
179
+ expect(webRemote._internal.getE2eState().state).toBe('pending_sas')
180
+ })
181
+ })
@@ -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
  }
@@ -9,10 +9,27 @@ const fsp = require('node:fs/promises');
9
9
  const path = require('node:path');
10
10
  const os = require('node:os');
11
11
  const { execFileSync } = require('node:child_process');
12
+ const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
12
13
 
13
14
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
14
15
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
15
16
 
17
+ // tickQueue() only fires (and updates lastRunAt) when a batch is actually
18
+ // spawned — it does not tick on a fixed cadence — but the poll loop that
19
+ // *invokes* tickQueue backs off starting from POLL_INTERVAL_MS (scheduler.cjs
20
+ // pollLoop). 3x gives the poll loop three chances to notice free capacity +
21
+ // pending work before we call it a stall, absorbing normal jitter (backoff,
22
+ // memory-gate deferrals, boot warmup) without waiting so long that a real
23
+ // outage goes unnoticed for hours (the 2026-07-14 incident sat stalled for
24
+ // 16.5h before anything asserted on it).
25
+ const TICK_STALL_MULTIPLIER = 3;
26
+ const TICK_STALL_THRESHOLD_MS = TICK_STALL_MULTIPLIER * POLL_INTERVAL_MS;
27
+ // A heartbeat line older than this is treated as "the app isn't running /
28
+ // we can't observe live utilization" rather than "utilization is low" —
29
+ // scheduler-heartbeat.log is only appended to while Electron is running, so
30
+ // a stale line here is silent-on-purpose, not evidence of anything.
31
+ const HEARTBEAT_STALE_MS = 5 * 60_000;
32
+
16
33
  function runCheck(cmd, cwd = PROJECT_ROOT) {
17
34
  try {
18
35
  execFileSync('bash', ['-c', cmd], {
@@ -26,6 +43,80 @@ function runCheck(cmd, cwd = PROJECT_ROOT) {
26
43
  }
27
44
  }
28
45
 
46
+ // Reads the last line of scheduler-heartbeat.log, if fresh enough to trust.
47
+ // Returns null when the file is missing, empty, unparseable, or stale — all
48
+ // of which mean "can't observe live utilization right now", not "utilization
49
+ // is low".
50
+ function readFreshHeartbeat(heartbeatPath) {
51
+ let lines;
52
+ try {
53
+ lines = fs.readFileSync(heartbeatPath, 'utf8').split('\n').filter(Boolean);
54
+ } catch {
55
+ return null;
56
+ }
57
+ if (lines.length === 0) return null;
58
+ let entry;
59
+ try {
60
+ entry = JSON.parse(lines[lines.length - 1]);
61
+ } catch {
62
+ return null;
63
+ }
64
+ if (typeof entry.ts !== 'number' || Date.now() - entry.ts > HEARTBEAT_STALE_MS) return null;
65
+ return entry;
66
+ }
67
+
68
+ // Evaluates whether the scheduler tick looks stalled: pending work exists,
69
+ // there's free capacity to run it, and nothing about the queue's own state
70
+ // explains why it hasn't. Kept as a pure function of (queueState, heartbeat,
71
+ // now) so it's testable without touching the filesystem.
72
+ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
73
+ const jobs = queueState.jobs || [];
74
+ const pending = jobs.filter((j) => j.status === 'pending');
75
+ const running = runningCount ?? jobs.filter((j) => j.status === 'running').length;
76
+ const config = queueState.config || {};
77
+ const concurrencyCap = config.concurrencyCap ?? Infinity;
78
+
79
+ if (pending.length === 0) return { stalled: false, reason: 'no-pending-jobs' };
80
+ if (queueState.paused) return { stalled: false, reason: 'paused' };
81
+ if (config.enabled === false) return { stalled: false, reason: 'disabled' };
82
+ if (running >= concurrencyCap) return { stalled: false, reason: 'at-capacity' };
83
+
84
+ const lastRunAt = queueState.lastRunAt ? Date.parse(queueState.lastRunAt) : null;
85
+ // No lastRunAt at all (fresh install, never ticked) — nothing to measure
86
+ // staleness against yet; don't manufacture a false positive.
87
+ if (lastRunAt == null || Number.isNaN(lastRunAt)) {
88
+ return { stalled: false, reason: 'no-lastRunAt', caveat: true };
89
+ }
90
+ const tickAgeMs = now - lastRunAt;
91
+ if (tickAgeMs <= TICK_STALL_THRESHOLD_MS) return { stalled: false, reason: 'recent-tick' };
92
+
93
+ // Candidate stall. The 'when-available' firePolicy legitimately holds
94
+ // pending jobs when billing utilization is at/above utilizationThreshold —
95
+ // rule that out before calling it a stall.
96
+ if (config.firePolicy === 'when-available' && typeof config.utilizationThreshold === 'number') {
97
+ if (!heartbeat) {
98
+ return {
99
+ stalled: false,
100
+ caveat: true,
101
+ reason: 'cannot-verify-utilization',
102
+ tickAgeMs,
103
+ oldestPendingSlug: pending[0]?.slug,
104
+ };
105
+ }
106
+ if (typeof heartbeat.utilization === 'number' && heartbeat.utilization >= config.utilizationThreshold) {
107
+ return { stalled: false, reason: 'utilization-at-threshold', utilization: heartbeat.utilization };
108
+ }
109
+ }
110
+
111
+ return {
112
+ stalled: true,
113
+ reason: 'stalled',
114
+ tickAgeMs,
115
+ oldestPendingSlug: pending[0]?.slug,
116
+ pendingCount: pending.length,
117
+ };
118
+ }
119
+
29
120
  async function check() {
30
121
  const start = Date.now();
31
122
  const status = {
@@ -106,13 +197,33 @@ async function check() {
106
197
  const failedCount = Object.values(queueState.jobs || {}).filter(
107
198
  (j) => j.status === 'failed'
108
199
  ).length;
200
+ const heartbeatPath = path.join(
201
+ os.homedir(),
202
+ '.claude/session-manager/scheduler-heartbeat.log'
203
+ );
204
+ const heartbeat = readFreshHeartbeat(heartbeatPath);
205
+ const liveness = evaluateTickLiveness(queueState, heartbeat, Date.now(), runningCount);
109
206
  status.components.scheduler_queue = {
110
- ok: true,
207
+ ok: !liveness.stalled,
111
208
  path: queuePath,
112
209
  jobs: Object.keys(queueState.jobs || {}).length,
113
210
  running: runningCount,
114
211
  failed: failedCount,
212
+ tickLiveness: liveness.reason,
115
213
  };
214
+ if (liveness.stalled) {
215
+ const ageMin = Math.round(liveness.tickAgeMs / 60_000);
216
+ status.components.scheduler_queue.stalledJob = liveness.oldestPendingSlug;
217
+ status.components.scheduler_queue.tickAgeMs = liveness.tickAgeMs;
218
+ status.issues.push(
219
+ `Scheduler tick appears stalled: "${liveness.oldestPendingSlug}" (and ${liveness.pendingCount - 1} other pending job(s)) has been waiting ~${ageMin}m with free capacity and no tick progress`
220
+ );
221
+ } else if (liveness.caveat) {
222
+ status.components.scheduler_queue.caveat =
223
+ liveness.reason === 'cannot-verify-utilization'
224
+ ? `Tick hasn't advanced in a while but scheduler-heartbeat.log is missing/stale, so current billing utilization can't be checked — cannot rule out a legitimate when-available hold`
225
+ : 'No lastRunAt recorded yet — cannot assess tick liveness';
226
+ }
116
227
  } catch (e) {
117
228
  if (e.code !== 'ENOENT') {
118
229
  status.issues.push(`Scheduler queue unreadable: ${e.message}`);
@@ -197,7 +308,7 @@ async function check() {
197
308
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
198
309
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
199
310
  // Informational: app log age (shows if app is running, but not blocking).
200
- const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure'];
311
+ const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue'];
201
312
  status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
202
313
 
203
314
  status.elapsedMs = Date.now() - start;
@@ -213,4 +324,4 @@ if (require.main === module) {
213
324
  })();
214
325
  }
215
326
 
216
- module.exports = { check };
327
+ module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
@@ -33,6 +33,7 @@ const path = require('node:path');
33
33
  const os = require('node:os');
34
34
  const { z } = require('zod');
35
35
  const config = require('./config.cjs');
36
+ const { kebabCase: sharedKebabCase } = require('./lib/kebabCase.cjs');
36
37
 
37
38
  // ──────────────────────────────────────────── caps
38
39
  const SLUG_RE = /^[a-z0-9-_]{1,64}$/;
@@ -89,7 +90,7 @@ async function ensureRoot() {
89
90
 
90
91
  // ──────────────────────────────────────────── helpers
91
92
  function kebabCase(s) {
92
- return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'agent';
93
+ return sharedKebabCase(s, { maxLen: 64, fallback: 'agent' });
93
94
  }
94
95
 
95
96
  // ──────────────────────────────────────────── legacy migration
@@ -228,6 +228,33 @@ const scheduleWritePrd = z.object({
228
228
  ),
229
229
  });
230
230
 
231
+ // PRD create (admin API — scheduler_create_prd, PRD 549). This slug is
232
+ // deliberately STRICTER than SCHEDULE_SLUG_RE: it becomes a brand-new
233
+ // filename segment written to disk by the create route, not a lookup key
234
+ // for an existing file, so it excludes '.' and '_' too (matches
235
+ // pluginInstall.cjs's slug precedent, minus '/' — this slug must never
236
+ // introduce a subdirectory).
237
+ const PRD_CREATE_SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
238
+ // title/cwd become frontmatter VALUES (`title: ${title}`) in prdCreate.cjs's
239
+ // hand-rolled `key: value` serializer, which — unlike a real YAML
240
+ // library — has no escaping. A title/cwd containing '\n' could inject a
241
+ // bare `\n---\n` that terminates the frontmatter block early (per
242
+ // prdFrontmatter.cjs's `indexOf('\n---')` parse) and smuggle extra
243
+ // frontmatter-shaped lines into the body. Block newlines at the boundary
244
+ // instead of trying to escape them later.
245
+ const NO_NEWLINE_RE = /^[^\r\n]*$/;
246
+ const schedulerCreatePrd = z.object({
247
+ title: z.string().min(1).max(200).regex(NO_NEWLINE_RE, 'must not contain newlines'),
248
+ cwd: z.string().min(1).max(4096).regex(NO_NEWLINE_RE, 'must not contain newlines'),
249
+ estimateMinutes: z.number().int().min(1).max(100000),
250
+ goal: z.string().min(1).max(20000),
251
+ acceptanceCriteria: z.array(z.string().min(1).max(2000)).min(1).max(100),
252
+ implementationNotes: z.string().min(1).max(20000),
253
+ outOfScope: z.array(z.string().min(1).max(2000)).max(100).optional(),
254
+ slug: z.string().min(1).max(60).regex(PRD_CREATE_SLUG_RE).optional(),
255
+ parallelGroup: z.number().int().min(1).max(999999).optional(),
256
+ });
257
+
231
258
  // Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
232
259
  const scheduleArchivePrd = z.object({
233
260
  slugs: z.array(z.string().regex(SCHEDULE_SLUG_RE)).min(1).max(500),
@@ -290,11 +317,11 @@ const setConfigSchema = z.object({
290
317
  }).strict();
291
318
 
292
319
  // ──────────────────────────────────────────── Memory tool (Bundle C, cycle 3)
293
- // Workspace-scoped markdown store at ~/.claude/session-manager/memories/<ws>/.
294
- // Slug regex must match memoryTool.cjs SLUG_RE; workspace regex matches its
295
- // encodeWorkspace() output (alphanumeric + dash) plus 'default'.
320
+ // Workspace-scoped markdown store at ~/.claude/projects/<ws>/memory/.
321
+ // Slug regex is shared with memoryTool.cjs/memoryAggregate.cjs via lib/memorySlug.cjs;
322
+ // workspace regex matches encodeWorkspace() output (alphanumeric + dash) plus 'default'.
296
323
  const MEMORY_WORKSPACE_RE = /^[a-zA-Z0-9-_]{1,256}$/;
297
- const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
324
+ const { MEMORY_SLUG_RE } = require('./lib/memorySlug.cjs');
298
325
  // 1 MiB hard cap — matches MAX_FILE_BYTES in memoryTool.cjs.
299
326
  const MEMORY_MAX_BYTES = 1024 * 1024;
300
327
 
@@ -613,6 +640,7 @@ module.exports = {
613
640
  // direct test()/match() containment checks alongside the zod parses.
614
641
  SCHEDULE_SLUG_RE,
615
642
  SCHEDULE_RUN_ID_RE,
643
+ PRD_CREATE_SLUG_RE,
616
644
  READ_COMMANDS,
617
645
  SAS_GATED_READS,
618
646
  MUTATE_COMMANDS,
@@ -651,6 +679,7 @@ module.exports = {
651
679
  scheduleSlug,
652
680
  scheduleReadLog,
653
681
  scheduleWritePrd,
682
+ schedulerCreatePrd,
654
683
  scheduleArchivePrd,
655
684
  scheduleRetagPrd,
656
685
  setConfigSchema,
@@ -0,0 +1,12 @@
1
+ /**
2
+ * kebabCase.cjs — shared lowercase/kebab-case slugify. Extracted from
3
+ * hives.cjs (private `kebabCase` helper) so prdCreate.cjs doesn't fork a
4
+ * second, subtly-different copy (API reuse: one concept, one implementation).
5
+ */
6
+ 'use strict';
7
+
8
+ function kebabCase(s, { maxLen = 64, fallback = '' } = {}) {
9
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, maxLen) || fallback;
10
+ }
11
+
12
+ module.exports = { kebabCase };
@@ -0,0 +1,10 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared workspace-memory filename regex. memoryTool.cjs (CRUD), memoryAggregate.cjs
5
+ * (clustering), and ipcSchemas.cjs (IPC validation) all gate on the same slug shape —
6
+ * single source of truth so the three don't drift.
7
+ */
8
+ const MEMORY_SLUG_RE = /^[a-z0-9-_]+\.md$/;
9
+
10
+ module.exports = { MEMORY_SLUG_RE };