claude-code-session-manager 0.39.2 → 0.39.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
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-BPbLVT6E.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-XGsBHqYu.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
- <link rel="stylesheet" crossorigin href="./assets/index-DVlD8N1X.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-DyLm7GM_.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.39.2",
3
+ "version": "0.39.4",
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",
@@ -77,7 +77,7 @@ can't load skills.
77
77
  do they actually share the same shape, or only look similar — confirm by reading, don't
78
78
  assume: a wrong assumption here means an inaccurate PRD, discovered only after the executor
79
79
  runs it), check existing tests for the area, and check whether a prior PRD already touched
80
- this subsystem (`ls <cwd>/session-manager-operations/scheduler/prds/` for related slugs, in
80
+ this subsystem (`ls <cwd>/session-manager-operations/scheduler/epics/*/prds/` for related slugs, in
81
81
  the target repo) — duplicating or contradicting a still-queued PRD is a real failure mode,
82
82
  not a hypothetical one.
83
83
 
@@ -197,7 +197,7 @@ can't load skills.
197
197
  and collides). PRDs are stored per-project, so `NN` allocation for a given PRD only needs
198
198
  *that project's own* prds directory scanned — not every project's:
199
199
  ```bash
200
- ls <cwd>/session-manager-operations/scheduler/prds/ | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
200
+ ls <cwd>/session-manager-operations/scheduler/epics/*/prds/ <cwd>/session-manager-operations/scheduler/prds-archived/ 2>/dev/null | grep -oE '^[0-9]+' | sort -n | uniq | tail -5
201
201
  ```
202
202
  (`<cwd>` is the target repo's absolute path — the same one this PRD's `cwd` field will use.)
203
203
  The last line is the current max within that project. Then: same `NN` as a logically
@@ -218,19 +218,25 @@ can't load skills.
218
218
  system that runs `claude -p <prd-body> --dangerously-skip-permissions` jobs around 5-hour
219
219
  token-window resets, with auto-pause on rate-limit and auto-resume.
220
220
 
221
- **Canonical location — non-negotiable.** PRDs MUST be written to, inside the target repo:
221
+ **Canonical location — non-negotiable.** Every PRD belongs to an **Epic** (the TAB → EPIC →
222
+ PRD domain model in the project CLAUDE.md). PRDs MUST be written to, inside the target repo:
222
223
  ```
223
- <cwd>/session-manager-operations/scheduler/prds/<NN>-<kebab-slug>.md
224
+ <cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/<NN>-<kebab-slug>.md
224
225
  ```
225
- **Anywhere else doesn't get scheduled.** If you write to `data/prds/`, `docs/prds/`, or the
226
- old, now-retired global `prds/` dir under `~/.claude/session-manager/scheduled-plans/`,
227
- the scheduler will not see it and the user loses their token-budget-managed execution. PRD
228
- *source* files are per-project — each active project's own
229
- `session-manager-operations/scheduler/prds/` directory, resolved at runtime from active
230
- session cwds (`src/main/lib/prdLocations.cjs`) — but there is still exactly one **queue**
231
- across all projects (`queue.json`/`history.jsonl`/`runs/` under
232
- `~/.claude/session-manager/scheduled-plans/` remain global bookkeeping), because the 5-hour
233
- token budget is global across all of the user's Claude work.
226
+ Get `<epic-id>`'s prds/ directory by minting (or joining) an Epic first:
227
+ ```bash
228
+ node <session-manager-repo>/scripts/mint-epic.cjs <cwd> "<one-line goal for this work>" [feature|bug|discussion]
229
+ ```
230
+ The last stdout line is the exact directory to write into. Reuse-by-goal makes it idempotent —
231
+ all PRDs of one /develop ask share one Epic. (The MCP `scheduler_create_prd` tool does all of
232
+ this automatically; the mint step is only for the manual-write fallback.)
233
+
234
+ **Anywhere else doesn't get scheduled or gets retired.** `data/prds/`, `docs/prds/`, and the
235
+ old global `prds/` dir under `~/.claude/session-manager/scheduled-plans/` are invisible to
236
+ the scheduler; the legacy flat `session-manager-operations/scheduler/prds/` dir is RETIRED —
237
+ anything written there is auto-consolidated into `prds-archived/` at the next app boot
238
+ without being executed. PRD *source* files are per-project and per-Epic, resolved at runtime
239
+ via `src/main/lib/prdLocations.cjs`.
234
240
 
235
241
  **Filename rules.** `NN` is the 2-digit zero-padded parallel group (picked per the `ls`
236
242
  command above — same `NN` as an independent sibling, or next free `NN` = max+1 when this PRD
@@ -8,7 +8,9 @@ const os = require('node:os');
8
8
  const path = require('node:path');
9
9
 
10
10
  const { activeProjectCwds } = require('./activeSessions.cjs');
11
- const { resolvePrdWriteDir } = require('../../src/main/lib/prdLocations.cjs');
11
+ const { resolvePrdWriteDir, listEpicPrdDirs } = require('../../src/main/lib/prdLocations.cjs');
12
+ const { ensureEpic, appendPrdCreatedEvent } = require('../../src/main/lib/epicMint.cjs');
13
+ const { projectQueuePath } = require('../../src/main/lib/queueStore.cjs');
12
14
 
13
15
  // Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
14
16
  const DEFAULT_HEARTBEAT_PATH = path.join(
@@ -294,7 +296,7 @@ function hasOpenFeedback(cwd) {
294
296
  */
295
297
  function emitFeedbackPRD(cwd, {
296
298
  prdsDir,
297
- queuePath = DEFAULT_QUEUE_PATH,
299
+ queuePath,
298
300
  skillPath,
299
301
  standardsPath,
300
302
  pluginCacheRoot = PLUGIN_CACHE_ROOT,
@@ -306,8 +308,18 @@ function emitFeedbackPRD(cwd, {
306
308
  // PRD dir so the sweep writer agrees with scheduler.cjs's reader (see
307
309
  // prdLocations.cjs — PRD chain 808→811 moved PRD sources out of the legacy
308
310
  // global dir into `<cwd>/session-manager-operations/scheduler/prds`).
311
+ let epicId = null;
309
312
  if (prdsDir === undefined) {
310
- prdsDir = resolvePrdWriteDir(cwd);
313
+ // Every PRD belongs to an Epic (CLAUDE.md domain model). The sweep joins
314
+ // one standing "Inbound feedback" Epic per project (reuseByGoal) so
315
+ // recurring ticks chain into a single Epic rather than minting one each.
316
+ const epic = ensureEpic(cwd, {
317
+ goalText: 'Inbound feedback processing',
318
+ tag: 'discussion',
319
+ reuseByGoal: true,
320
+ });
321
+ epicId = epic.epicId;
322
+ prdsDir = epic.prdDir;
311
323
  }
312
324
  const skillPathExplicit = skillPath !== undefined;
313
325
  const standardsPathExplicit = standardsPath !== undefined;
@@ -319,11 +331,20 @@ function emitFeedbackPRD(cwd, {
319
331
  }
320
332
  const project = slugify(path.basename(cwd));
321
333
 
322
- // De-dup: check queue.json for pending/running feedback job for this project.
323
- let queueState = { jobs: [] };
324
- try {
325
- queueState = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
326
- } catch { /* queue.json may not exist */ }
334
+ // De-dup: check the queue for a pending/running feedback job for this
335
+ // project. Federated state (2026-07-31): the project's own shard is the
336
+ // system of record; the legacy global path is still consulted as a fallback
337
+ // during transition. An explicit queuePath override (tests) wins verbatim.
338
+ const queuePaths = queuePath !== undefined
339
+ ? [queuePath]
340
+ : [projectQueuePath(cwd), DEFAULT_QUEUE_PATH];
341
+ const queueState = { jobs: [] };
342
+ for (const qp of queuePaths) {
343
+ try {
344
+ const parsed = JSON.parse(fs.readFileSync(qp, 'utf8'));
345
+ queueState.jobs.push(...(Array.isArray(parsed.jobs) ? parsed.jobs : []));
346
+ } catch { /* file may not exist */ }
347
+ }
327
348
 
328
349
  // project only contains [a-z0-9-] after slugify — no regex metachar escaping needed.
329
350
  const dupRe = new RegExp(`^\\d+-feedback-${project}$`);
@@ -341,18 +362,28 @@ function emitFeedbackPRD(cwd, {
341
362
  // watchdog tick emits a fresh NN-feedback-<project>.md, unbounded.
342
363
  const prdDupRe = new RegExp(`^\\d+-feedback-${project}\\.md$`);
343
364
  let maxNN = 0;
344
- let prdFiles = [];
345
- try {
346
- prdFiles = fs.readdirSync(prdsDir);
347
- } catch { /* prdsDir may not exist yet */ }
348
- for (const f of prdFiles) {
349
- if (prdDupRe.test(f)) {
350
- return { emitted: false, reason: 'duplicate' };
351
- }
352
- const m = f.match(/^(\d+)-/);
353
- if (m) {
354
- const n = parseInt(m[1], 10);
355
- if (n > maxNN) maxNN = n;
365
+ // NN numbering and the on-disk dedup are project-global, so scan every PRD
366
+ // location the project has: the write dir, the legacy flat dir, and every
367
+ // Epic's prds/ dir. (An explicit prdsDir override — tests — scans only it.)
368
+ const scanDirs = new Set([prdsDir]);
369
+ if (epicId !== null) {
370
+ try { scanDirs.add(resolvePrdWriteDir(cwd)); } catch { /* unusable cwd */ }
371
+ for (const d of listEpicPrdDirs(cwd)) scanDirs.add(d);
372
+ }
373
+ for (const dir of scanDirs) {
374
+ let prdFiles = [];
375
+ try {
376
+ prdFiles = fs.readdirSync(dir);
377
+ } catch { continue; /* dir may not exist yet */ }
378
+ for (const f of prdFiles) {
379
+ if (prdDupRe.test(f)) {
380
+ return { emitted: false, reason: 'duplicate' };
381
+ }
382
+ const m = f.match(/^(\d+)-/);
383
+ if (m) {
384
+ const n = parseInt(m[1], 10);
385
+ if (n > maxNN) maxNN = n;
386
+ }
356
387
  }
357
388
  }
358
389
  const nn = String(maxNN + 1).padStart(2, '0');
@@ -440,6 +471,11 @@ function emitFeedbackPRD(cwd, {
440
471
  fs.mkdirSync(prdsDir, { recursive: true });
441
472
  fs.writeFileSync(tmpPath, body + '\n', 'utf8');
442
473
  fs.renameSync(tmpPath, prdPath);
474
+ if (epicId !== null) {
475
+ // Trace the dispatch on the Epic's event chain (prompt → prd_created →
476
+ // ...) so the Epic conversation shows the PRD it spawned.
477
+ try { appendPrdCreatedEvent(cwd, epicId, slug, `Feedback sweep emitted ${slug}`); } catch { /* trace is best-effort */ }
478
+ }
443
479
  } catch (e) {
444
480
  try { fs.unlinkSync(tmpPath); } catch { /* best-effort cleanup */ }
445
481
  throw e;
@@ -622,7 +658,7 @@ function maybeRelaunchApp({
622
658
  function sweep({
623
659
  projectsDir,
624
660
  prdsDir,
625
- queuePath = DEFAULT_QUEUE_PATH,
661
+ queuePath,
626
662
  skillPath,
627
663
  standardsPath,
628
664
  } = {}) {
@@ -0,0 +1,37 @@
1
+ /**
2
+ * health-prd-migration.test.cjs — stranded-PRD-migration health signal
3
+ * (behind two real 2026-07-31 incidents: a single stranded PRD causing an
4
+ * instant ENOENT job failure, and 223 stranded legacy PRDs + a missing
5
+ * history.jsonl resurrecting 189 completed jobs). Exercises
6
+ * evaluatePrdMigrationHealth() directly since it's pure — no need to spawn
7
+ * the full check() (which shells out to npm run typecheck) to cover this.
8
+ *
9
+ * Run: timeout 120 npx vitest run src/main/__tests__/health-prd-migration.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ import { test, expect } from 'vitest';
15
+ const { evaluatePrdMigrationHealth } = require('../health.cjs');
16
+
17
+ const LEGACY_DIR = '/home/user/.claude/session-manager/scheduled-plans/prds';
18
+
19
+ test('reports ok when nothing is stranded', () => {
20
+ const result = evaluatePrdMigrationHealth({ moved: 3, skipped: 0, unresolved: [] }, LEGACY_DIR);
21
+ expect(result.ok).toBe(true);
22
+ expect(result.strandedCount).toBe(0);
23
+ expect(result.legacyDir).toBe(LEGACY_DIR);
24
+ expect(result.unresolved).toBeUndefined();
25
+ });
26
+
27
+ test('reports RED with count and legacy dir path when files are stranded', () => {
28
+ const unresolved = [
29
+ { file: '01-stuck.md', reason: 'no cwd in frontmatter' },
30
+ { file: '02-stuck.md', reason: 'cwd does not exist on disk' },
31
+ ];
32
+ const result = evaluatePrdMigrationHealth({ moved: 0, skipped: 0, unresolved }, LEGACY_DIR);
33
+ expect(result.ok).toBe(false);
34
+ expect(result.strandedCount).toBe(2);
35
+ expect(result.legacyDir).toBe(LEGACY_DIR);
36
+ expect(result.unresolved).toEqual(unresolved);
37
+ });
@@ -146,11 +146,13 @@ test('readHistory respects limit and returns [] when file is absent', async () =
146
146
  const empty = await queueHistory.readHistory({ limit: 5 });
147
147
  expect(empty).toEqual([]);
148
148
 
149
- const entries = Array.from({ length: 8 }, (_, i) => old({ slug: `s${i}`, runId: `r${i}`, finishedAt: new Date(NOW - i * 60_000).toISOString() }));
149
+ // Realistic append order: oldest finishedAt first (append-only file grows
150
+ // forward in time), so s7 is both last-appended and newest.
151
+ const entries = Array.from({ length: 8 }, (_, i) => old({ slug: `s${i}`, runId: `r${i}`, finishedAt: new Date(NOW - (7 - i) * 60_000).toISOString() }));
150
152
  await queueHistory.appendHistory(entries);
151
153
  const read = await queueHistory.readHistory({ limit: 3 });
152
154
  expect(read.length).toBe(3);
153
- // newest-appended-first == last 3 entries reversed
155
+ // Federated readHistory (2026-07-31) orders by finishedAt newest-first.
154
156
  expect(read.map((j) => j.slug)).toEqual(['s7', 's6', 's5']);
155
157
  });
156
158
 
@@ -165,8 +167,11 @@ test('readHistory tail-reads correctly when the file is larger than one chunk',
165
167
  }));
166
168
  await queueHistory.appendHistory(bigEntries);
167
169
  const read = await queueHistory.readHistory({ limit: 5 });
168
- // appended in ascending index order; readHistory reverses to newest-appended-first
169
- expect(read.map((j) => j.slug)).toEqual(['bulk-1999', 'bulk-1998', 'bulk-1997', 'bulk-1996', 'bulk-1995']);
170
+ // The tail read only touches the file's end; whatever was read comes back
171
+ // ordered by finishedAt newest-first.
172
+ expect(read.length).toBe(5);
173
+ const t = (j) => Date.parse(j.finishedAt);
174
+ for (let i = 1; i < read.length; i++) expect(t(read[i - 1])).toBeGreaterThanOrEqual(t(read[i]));
170
175
  });
171
176
 
172
177
  // ---------- selectHistoryJobs merge (scheduler.cjs) ----------
@@ -140,3 +140,15 @@ test('autoArchiveCompleted: tolerates a missing/malformed state.jobs', async ()
140
140
  const result = await autoArchiveCompleted({}, { nowMs: NOW });
141
141
  expect(result.archived).toBe(0);
142
142
  });
143
+
144
+ // ---------- archiving never strands a runnable queue job (scheduler PRD:
145
+ // archived-prd-skip — job 822-epics-nav-rename fired ENOENT after its PRD
146
+ // was archived out from under a still-queued job) ----------
147
+
148
+ test('selectAutoArchivable never selects a slug whose job is still pending or running, so auto-archive can never leave a runnable job pointing at an archived PRD', () => {
149
+ const pending = old({ slug: '13-pending', status: 'pending', finishedAt: null });
150
+ const running = old({ slug: '14-running', status: 'running', finishedAt: null });
151
+ const completed = old({ slug: '15-done', status: 'completed' });
152
+ const slugs = selectAutoArchivable([pending, running, completed], { nowMs: NOW });
153
+ expect(slugs).toEqual(['15-done']);
154
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * scheduler-archived-twin-guard.test.cjs — unit tests for archivedTwinExists
3
+ * (PRD 822-promptsession-prd-trace-events recurrence fix): a queue row whose
4
+ * PRD `.md` has already been moved to the sibling `prds-archived/` dir (work
5
+ * shipped out-of-band) must be recognized as stale, not a genuine missing-PRD
6
+ * failure.
7
+ *
8
+ * Uses a temp cwd fixture (`prdDirForCwd` is a pure path join off `cwd`) so
9
+ * this never touches the real `~/.claude/session-manager/` tree.
10
+ *
11
+ * Run: timeout 120 npx vitest run src/main/__tests__/scheduler-archived-twin-guard.test.cjs
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ import { test, expect, afterEach } from 'vitest';
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+ const { archivedTwinExists, archivedPrdPathForJob, prdDirForCwd } = require('../scheduler.cjs');
21
+
22
+ const tmpDirs = [];
23
+
24
+ function makeFixtureCwd() {
25
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-archived-twin-'));
26
+ tmpDirs.push(cwd);
27
+ return cwd;
28
+ }
29
+
30
+ afterEach(() => {
31
+ while (tmpDirs.length) {
32
+ const dir = tmpDirs.pop();
33
+ fs.rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test('archivedTwinExists returns true when the archived twin exists and the live PRD does not', async () => {
38
+ const cwd = makeFixtureCwd();
39
+ const slug = 'test-shipped-and-archived';
40
+ const prdsDir = prdDirForCwd(cwd);
41
+ const archivedDir = path.join(prdsDir, '..', 'prds-archived');
42
+ fs.mkdirSync(archivedDir, { recursive: true });
43
+ fs.writeFileSync(path.join(archivedDir, `${slug}.md`), '# Goal\n\nshipped\n', 'utf8');
44
+
45
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(true);
46
+ });
47
+
48
+ test('archivedTwinExists returns false when neither the live PRD nor an archived twin exists', async () => {
49
+ const cwd = makeFixtureCwd();
50
+ const slug = 'test-genuinely-missing';
51
+ fs.mkdirSync(prdDirForCwd(cwd), { recursive: true });
52
+
53
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(false);
54
+ });
55
+
56
+ test('archivedTwinExists returns false for a slug whose archived twin belongs to a different cwd', async () => {
57
+ const cwd = makeFixtureCwd();
58
+ const otherCwd = makeFixtureCwd();
59
+ const slug = 'test-cross-cwd-twin';
60
+ const otherArchivedDir = path.join(prdDirForCwd(otherCwd), '..', 'prds-archived');
61
+ fs.mkdirSync(otherArchivedDir, { recursive: true });
62
+ fs.writeFileSync(path.join(otherArchivedDir, `${slug}.md`), '# Goal\n\nother project\n', 'utf8');
63
+ fs.mkdirSync(prdDirForCwd(cwd), { recursive: true });
64
+
65
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(false);
66
+ });
67
+
68
+ test('archivedTwinExists returns false when the PRD is still live in prds/ (never short-circuits a normal run)', async () => {
69
+ const cwd = makeFixtureCwd();
70
+ const slug = 'test-still-live';
71
+ const prdsDir = prdDirForCwd(cwd);
72
+ fs.mkdirSync(prdsDir, { recursive: true });
73
+ fs.writeFileSync(path.join(prdsDir, `${slug}.md`), '# Goal\n\nlive\n', 'utf8');
74
+
75
+ await expect(archivedTwinExists({ slug, cwd })).resolves.toBe(false);
76
+ });
77
+
78
+ test('archivedPrdPathForJob resolves to the sibling prds-archived/<slug>.md for a job cwd', () => {
79
+ const cwd = makeFixtureCwd();
80
+ const job = { cwd, slug: 'my-slug' };
81
+ const prdsDir = prdDirForCwd(cwd);
82
+ const expected = path.join(prdsDir, '..', 'prds-archived', 'my-slug.md');
83
+ expect(archivedPrdPathForJob(job)).toBe(expected);
84
+ });
85
+
86
+ test('archivedPrdPathForJob does not escape prds-archived/ for a well-formed slug', () => {
87
+ const cwd = makeFixtureCwd();
88
+ const job = { cwd, slug: 'well-formed-slug' };
89
+ const resolved = path.resolve(archivedPrdPathForJob(job));
90
+ const archiveDir = path.resolve(path.join(prdDirForCwd(cwd), '..', 'prds-archived'));
91
+ expect(resolved.startsWith(archiveDir + path.sep)).toBe(true);
92
+ });
@@ -32,7 +32,7 @@ const unreadableState = () => ({
32
32
  });
33
33
 
34
34
  test('writeQueue refuses to persist a state derived from an unreadable read', async () => {
35
- await expect(writeQueue(unreadableState())).rejects.toThrow(/refusing to write queue\.json/i);
35
+ await expect(writeQueue(unreadableState())).rejects.toThrow(/refusing to write queue/i);
36
36
  });
37
37
 
38
38
  test('writeQueue still persists a normal state (guard is not a blanket block)', () => {
@@ -54,6 +54,7 @@ const { recordExchange } = require('./exchanges.cjs');
54
54
  const { classifyToolUse } = require('./lib/toolUseClassify.cjs');
55
55
  const { extractJson } = require('./lib/extractJson.cjs');
56
56
  const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
57
+ const sessionSlots = require('./lib/sessionSlots.cjs');
57
58
 
58
59
  // ─── Stop-signal protocol ──────────────────────────────────────────────────
59
60
  // Single source of truth for the sentinel and parser. The renderer (PRD 320)
@@ -387,6 +388,12 @@ function run(opts) {
387
388
  // the remainder. O(n) over the waiting list (bounded by open tabs).
388
389
  function pump() {
389
390
  while (activeCount < getConcurrencyCap() && waiting.length > 0) {
391
+ // The chat lane cap is a POLICY bound; actual capacity comes from the
392
+ // Session-Manager-wide slot pool shared with the scheduler
393
+ // (lib/sessionSlots.cjs). No slot → everyone keeps waiting; the next
394
+ // settle() in either subsystem re-pumps.
395
+ const slotToken = sessionSlots.acquire(`chat:${waiting[0].tabId}`);
396
+ if (!slotToken) break;
390
397
  const job = waiting.shift();
391
398
  activeCount += 1;
392
399
  Promise.resolve()
@@ -397,7 +404,7 @@ function pump() {
397
404
  return donePromise;
398
405
  })
399
406
  .catch(() => { /* executeRun never rejects; defensive */ })
400
- .finally(() => { activeCount -= 1; pump(); });
407
+ .finally(() => { sessionSlots.release(slotToken); activeCount -= 1; pump(); });
401
408
  }
402
409
  // Anyone still waiting gets a 1-based position update. Silent (automated
403
410
  // probe) runs must stay invisible to the renderer, same as every other
@@ -409,6 +416,9 @@ function pump() {
409
416
  });
410
417
  }
411
418
 
419
+ // A slot freed anywhere (e.g. a scheduler job settled) may unblock our lane.
420
+ sessionSlots.subscribe(() => { try { pump(); } catch { /* defensive */ } });
421
+
412
422
  // ─── Core runner ──────────────────────────────────────────────────────────
413
423
 
414
424
  /**
@@ -12,6 +12,8 @@ const { execFileSync } = require('node:child_process');
12
12
  const { POLL_INTERVAL_MS } = require('./lib/schedulerConfig.cjs');
13
13
  const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
14
14
  const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
15
+ const { migratePrds } = require('./lib/prdMigration.cjs');
16
+ const queueStore = require('./lib/queueStore.cjs');
15
17
 
16
18
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
17
19
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
@@ -119,6 +121,19 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
119
121
  };
120
122
  }
121
123
 
124
+ // Pure evaluator over migratePrds()'s { moved, skipped, unresolved } result —
125
+ // kept separate from the fs-touching check() call site so it's directly
126
+ // unit-testable, matching evaluateTickLiveness's pattern.
127
+ function evaluatePrdMigrationHealth(migrationResult, legacyPrdsDir) {
128
+ const strandedCount = migrationResult.unresolved.length;
129
+ return {
130
+ ok: strandedCount === 0,
131
+ legacyDir: legacyPrdsDir,
132
+ strandedCount,
133
+ ...(strandedCount > 0 ? { unresolved: migrationResult.unresolved } : {}),
134
+ };
135
+ }
136
+
122
137
  async function check() {
123
138
  const start = Date.now();
124
139
  const status = {
@@ -183,16 +198,14 @@ async function check() {
183
198
  status.ok = false;
184
199
  }
185
200
 
186
- // 3. Check scheduler PRD directory and queue.json.
187
- const schedulerBaseDir = path.join(
188
- os.homedir(),
189
- '.claude/session-manager/scheduled-plans'
190
- );
191
- const queuePath = path.join(schedulerBaseDir, 'queue.json');
201
+ // 3. Check scheduler state (federated, 2026-07-31): machine runtime file +
202
+ // per-project job shards merged via queueStore — the retired global
203
+ // queue.json is no longer consulted.
204
+ const queuePath = queueStore.MACHINE_STATE_PATH;
192
205
  let queueState = null;
193
206
  try {
194
- const queueText = await fsp.readFile(queuePath, 'utf8');
195
- queueState = JSON.parse(queueText);
207
+ queueState = queueStore.readMergedSync();
208
+ if (queueState.unreadable) throw new Error(queueState.unreadable);
196
209
  const runningCount = Object.values(queueState.jobs || {}).filter(
197
210
  (j) => j.status === 'running'
198
211
  ).length;
@@ -266,6 +279,29 @@ async function check() {
266
279
  ...(prdsDirs.length === 0 || anyPrdsDirAccessible ? {} : { exists: false }),
267
280
  };
268
281
 
282
+ // 4.5. Check for stranded legacy-dir PRDs left behind by runPrdMigration()
283
+ // (scheduler.cjs). A stranded PRD is the cheapest generic leading indicator
284
+ // of a stale installed build vs. the git repo — it fires regardless of
285
+ // *why* the build is stale (the 2026-07-31 burrow-project ENOENT + the
286
+ // 223-file/189-resurrected-job incident both trace back to this).
287
+ const legacyPrdsDir = path.join(
288
+ os.homedir(),
289
+ '.claude/session-manager/scheduled-plans/prds'
290
+ );
291
+ try {
292
+ const migrationResult = await migratePrds(legacyPrdsDir);
293
+ status.components.prd_migration = evaluatePrdMigrationHealth(migrationResult, legacyPrdsDir);
294
+ } catch (e) {
295
+ status.components.prd_migration = { ok: false, legacyDir: legacyPrdsDir, error: e.message };
296
+ }
297
+ if (!status.components.prd_migration.ok) {
298
+ status.issues.push(
299
+ status.components.prd_migration.error
300
+ ? `PRD migration check failed: ${status.components.prd_migration.error}`
301
+ : `PRD migration: ${status.components.prd_migration.strandedCount} file(s) stranded in legacy dir ${legacyPrdsDir} — could not be resolved into a per-project dir`
302
+ );
303
+ }
304
+
269
305
  // 5. Check transcripts directory (where live session logs are tailed).
270
306
  const projectsDir = path.join(os.homedir(), '.claude/projects');
271
307
  try {
@@ -329,7 +365,7 @@ async function check() {
329
365
  // Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
330
366
  // Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
331
367
  // Informational: app log age (shows if app is running, but not blocking).
332
- const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue'];
368
+ const criticalComponents = ['nodejs', 'config_dir', 'typescript', 'build_artifact', 'test_infrastructure', 'scheduler_queue', 'prd_migration'];
333
369
  status.ok = criticalComponents.every((c) => status.components[c]?.ok !== false);
334
370
 
335
371
  status.elapsedMs = Date.now() - start;
@@ -345,4 +381,4 @@ if (require.main === module) {
345
381
  })();
346
382
  }
347
383
 
348
- module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
384
+ module.exports = { check, evaluateTickLiveness, readFreshHeartbeat, evaluatePrdMigrationHealth, TICK_STALL_THRESHOLD_MS, HEARTBEAT_STALE_MS };
@@ -450,9 +450,12 @@ ipcMain.handle('app:home-self-check', () => bootHomeSelfCheck);
450
450
  ipcMain.handle('app:pick-directory', async () => {
451
451
  console.log('[main] pick-directory invoked');
452
452
  const result = await dialog.showOpenDialog(mainWindow, {
453
- properties: ['openDirectory'],
453
+ // createDirectory enables the picker's "New Folder" affordance (macOS;
454
+ // the GTK chooser has its own) — this is the "Start Project" path:
455
+ // create a fresh folder in place, open it as the project's TAB.
456
+ properties: ['openDirectory', 'createDirectory'],
454
457
  defaultPath: os.homedir(),
455
- title: 'Choose project directory for new session',
458
+ title: 'Open a project folder — or create a new one to start a project',
456
459
  });
457
460
  console.log('[main] pick-directory result:', result);
458
461
  if (result.canceled || result.filePaths.length === 0) return null;
@@ -760,7 +763,7 @@ voiceWizard.registerWizardHandlers();
760
763
  scheduler.registerScheduleHandlers();
761
764
  watchers.registerWatcherHandlers();
762
765
  teams.registerTeamsHandlers();
763
- queueOps.registerQueueOpsHandlers();
766
+ queueOps.registerQueueOpsHandlers({ retireCompletedSlugs: scheduler.retireCompletedSlugs });
764
767
  registerHistoryAggregatorHandlers();
765
768
  registerHistoryDashboardHandlers();
766
769
  pluginInstall.registerPluginInstallHandlers();
@@ -0,0 +1,55 @@
1
+ // sessionSlots.cjs — the Session-Manager-owned machine-wide claude -p pool.
2
+ // vitest globals (test/beforeEach/afterEach) — same convention as the other .cjs tests.
3
+ const assert = require('node:assert');
4
+ const slots = require('../sessionSlots.cjs');
5
+
6
+ beforeEach(() => slots.__resetForTests());
7
+ afterEach(() => {
8
+ slots.__resetForTests();
9
+ delete process.env.SM_SESSION_SLOTS;
10
+ });
11
+
12
+ test('pool defaults to 3 slots and exhausts', () => {
13
+ assert.equal(slots.totalSlots(), 3);
14
+ const t1 = slots.acquire('a');
15
+ const t2 = slots.acquire('b');
16
+ const t3 = slots.acquire('c');
17
+ assert.ok(t1 && t2 && t3);
18
+ assert.equal(slots.available(), 0);
19
+ assert.equal(slots.acquire('d'), null, 'fourth acquire must be refused');
20
+ slots.release(t2);
21
+ assert.equal(slots.available(), 1);
22
+ assert.ok(slots.acquire('d'));
23
+ });
24
+
25
+ test('SM_SESSION_SLOTS overrides, clamped to [1,3]', () => {
26
+ process.env.SM_SESSION_SLOTS = '1';
27
+ assert.equal(slots.totalSlots(), 1);
28
+ process.env.SM_SESSION_SLOTS = '9';
29
+ assert.equal(slots.totalSlots(), 3, 'clamped high');
30
+ process.env.SM_SESSION_SLOTS = '0';
31
+ assert.equal(slots.totalSlots(), 1, 'clamped low');
32
+ process.env.SM_SESSION_SLOTS = 'junk';
33
+ assert.equal(slots.totalSlots(), 3, 'non-numeric falls back to default');
34
+ });
35
+
36
+ test('release is idempotent and notifies subscribers exactly on real releases', () => {
37
+ let notified = 0;
38
+ const unsub = slots.subscribe(() => { notified += 1; });
39
+ const t = slots.acquire('a');
40
+ assert.equal(slots.release(t), true);
41
+ assert.equal(slots.release(t), false, 'double release is a no-op');
42
+ assert.equal(notified, 1, 'no notification for the no-op release');
43
+ unsub();
44
+ const t2 = slots.acquire('b');
45
+ slots.release(t2);
46
+ assert.equal(notified, 1, 'unsubscribed listener stays quiet');
47
+ });
48
+
49
+ test('snapshot names holders for attribution', () => {
50
+ slots.acquire('scheduler:42-fix-thing');
51
+ slots.acquire('chat:tab-1');
52
+ const snap = slots.snapshot();
53
+ assert.equal(snap.inUse, 2);
54
+ assert.deepEqual(snap.holders.map((h) => h.owner).sort(), ['chat:tab-1', 'scheduler:42-fix-thing']);
55
+ });