claude-code-session-manager 0.39.3 → 0.40.0

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 (39) hide show
  1. package/dist/assets/{TiptapBody-B90xy18x.js → TiptapBody-CNr1qGoi.js} +1 -1
  2. package/dist/assets/{index-DVlD8N1X.css → index-CR-WdMgm.css} +1 -1
  3. package/dist/assets/index-oMNYp73N.js +3194 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +34 -27
  7. package/scripts/lib/watchdogHelpers.cjs +57 -21
  8. package/src/main/__tests__/health-prd-migration.test.cjs +37 -0
  9. package/src/main/__tests__/prdCreate.test.cjs +54 -15
  10. package/src/main/__tests__/prdSourcePromptIdBackfill.test.cjs +118 -0
  11. package/src/main/__tests__/queueHistory.test.cjs +9 -4
  12. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +12 -0
  13. package/src/main/__tests__/scheduler-archived-twin-guard.test.cjs +92 -0
  14. package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +1 -1
  15. package/src/main/__tests__/uniquePrdNumbers.test.cjs +119 -0
  16. package/src/main/chatRunner.cjs +11 -1
  17. package/src/main/config.cjs +10 -0
  18. package/src/main/health.cjs +46 -10
  19. package/src/main/index.cjs +28 -9
  20. package/src/main/ipcSchemas.cjs +10 -0
  21. package/src/main/lib/__tests__/instanceLock.test.cjs +87 -0
  22. package/src/main/lib/__tests__/sessionSlots.test.cjs +55 -0
  23. package/src/main/lib/epicMint.cjs +144 -0
  24. package/src/main/lib/instanceLock.cjs +100 -0
  25. package/src/main/lib/prdCreate.cjs +19 -6
  26. package/src/main/lib/prdLocations.cjs +82 -1
  27. package/src/main/lib/prdMigration.cjs +47 -1
  28. package/src/main/lib/queueHistory.cjs +85 -37
  29. package/src/main/lib/queueStore.cjs +299 -0
  30. package/src/main/lib/schedulerBatch.cjs +36 -4
  31. package/src/main/lib/sessionSlots.cjs +85 -0
  32. package/src/main/pty.cjs +9 -0
  33. package/src/main/queueOps.cjs +20 -1
  34. package/src/main/scheduler/prdParser.cjs +28 -4
  35. package/src/main/scheduler.cjs +333 -52
  36. package/src/main/templates/PRD_AUTHORING.md +9 -5
  37. package/src/preload/api.d.ts +18 -3
  38. package/src/preload/index.cjs +2 -0
  39. package/dist/assets/index-BUuhV6vT.js +0 -3180
@@ -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)', () => {
@@ -0,0 +1,119 @@
1
+ // PRD 832 — unique per-project PRD numbers + dependsOn eligibility.
2
+ // User decision 2026-07-31: duplicate NN must be impossible; ordering is
3
+ // expressed via dependsOn frontmatter, never via shared numbers.
4
+ import { describe, it, expect } from 'vitest';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const prdParser = require('../scheduler/prdParser.cjs');
10
+ const { pickForProject } = require('../lib/schedulerBatch.cjs');
11
+
12
+ function tmpdir() {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'sm-832-'));
14
+ }
15
+
16
+ const job = (slug, status, group, extra = {}) => ({
17
+ slug, status, parallelGroup: group, cwd: '/proj', ...extra,
18
+ });
19
+
20
+ describe('allocateParallelGroup extraFloor (PRD 832)', () => {
21
+ it('never allocates at or below the cross-dir max', async () => {
22
+ const dir = tmpdir();
23
+ // Bookkeeping dir itself is empty; another dir in the project already
24
+ // used numbers up to 830.
25
+ const nn = await prdParser.allocateParallelGroup(dir, { extraFloor: 830 });
26
+ expect(nn).toBe(831);
27
+ // And the high-water mark persists the raised floor for later calls.
28
+ const nn2 = await prdParser.allocateParallelGroup(dir);
29
+ expect(nn2).toBe(832);
30
+ });
31
+
32
+ it('local scan still wins when higher than extraFloor', async () => {
33
+ const dir = tmpdir();
34
+ fs.writeFileSync(path.join(dir, '900-existing.md'), '---\ntitle: x\n---\n# Goal\n');
35
+ const nn = await prdParser.allocateParallelGroup(dir, { extraFloor: 10 });
36
+ expect(nn).toBe(901);
37
+ });
38
+ });
39
+
40
+ describe('parsePrdRaw dependsOn (PRD 832)', () => {
41
+ it('parses bracket and comma forms, rejects junk', async () => {
42
+ const dir = tmpdir();
43
+ const fp = path.join(dir, '840-x.md');
44
+ fs.writeFileSync(fp, '---\ntitle: x\ncwd: /proj\ndependsOn: [833-epics-review-fixes, 834-guard]\n---\n# Goal\nbody\n');
45
+ const p = await prdParser.parsePrdRaw(fp);
46
+ expect(p.dependsOn).toEqual(['833-epics-review-fixes', '834-guard']);
47
+
48
+ const fp2 = path.join(dir, '841-y.md');
49
+ fs.writeFileSync(fp2, '---\ntitle: y\ncwd: /proj\ndependsOn: 833-a, ../evil/path, 834-b\n---\n# Goal\nbody\n');
50
+ const p2 = await prdParser.parsePrdRaw(fp2);
51
+ expect(p2.dependsOn).toEqual(['833-a', '834-b']);
52
+
53
+ const fp3 = path.join(dir, '842-z.md');
54
+ fs.writeFileSync(fp3, '---\ntitle: z\ncwd: /proj\n---\n# Goal\nbody\n');
55
+ const p3 = await prdParser.parsePrdRaw(fp3);
56
+ expect(p3.dependsOn).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe('pickForProject dependsOn eligibility (PRD 832)', () => {
61
+ it('holds a job while its dep row is pending/running; releases when completed', () => {
62
+ const dep = job('850-base', 'running', 850);
63
+ const dependent = job('851-next', 'pending', 851, { dependsOn: ['850-base'] });
64
+
65
+ // Dep running (tracked in queue rows, not runningSet) — not eligible.
66
+ let res = pickForProject([dep, dependent], new Set(), 3);
67
+ expect(res.batch.map((j) => j.slug)).not.toContain('851-next');
68
+
69
+ // Dep completed — eligible.
70
+ res = pickForProject([{ ...dep, status: 'completed' }, dependent], new Set(), 3);
71
+ expect(res.batch.map((j) => j.slug)).toContain('851-next');
72
+ });
73
+
74
+ it('a dep slug with NO queue row counts as done (retired to history)', () => {
75
+ const dependent = job('853-next', 'pending', 853, { dependsOn: ['852-archived-long-ago'] });
76
+ const res = pickForProject([dependent], new Set(), 3);
77
+ expect(res.batch.map((j) => j.slug)).toContain('853-next');
78
+ });
79
+
80
+ it('a FAILED dep holds the dependent with an explicit depends-gate reason', () => {
81
+ const dep = job('854-base', 'failed', 854);
82
+ const dependent = job('855-next', 'pending', 855, { dependsOn: ['854-base'] });
83
+ const res = pickForProject([dep, dependent], new Set(), 3);
84
+ expect(res.batch).toEqual([]);
85
+ expect(res.reason).toMatch(/depends-gate/);
86
+ expect(res.reason).toContain('855-next');
87
+ expect(res.reason).toContain('854-base');
88
+ });
89
+
90
+ it('legacy same-NN pending jobs keep their wave order untouched', () => {
91
+ const a = job('827-one', 'pending', 827);
92
+ const b = job('827-two', 'pending', 827);
93
+ const later = job('828-three', 'pending', 828);
94
+ const res = pickForProject([a, b, later], new Set(), 3);
95
+ expect(res.batch.map((j) => j.slug).sort()).toEqual(['827-one', '827-two']);
96
+ });
97
+ });
98
+
99
+ describe('reconcile retires archived-twin pending rows (post-832 cleanup)', () => {
100
+ it('drops a pending row whose PRD lives in prds-archived, keeps a normal invisible pending row', async () => {
101
+ const scheduler = require('../scheduler.cjs');
102
+ const root = tmpdir();
103
+ const opsDir = path.join(root, 'session-manager-operations', 'scheduler');
104
+ fs.mkdirSync(path.join(opsDir, 'prds'), { recursive: true });
105
+ fs.mkdirSync(path.join(opsDir, 'prds-archived'), { recursive: true });
106
+ fs.writeFileSync(path.join(opsDir, 'prds-archived', '833-landed-inline.md'), '---\ntitle: x\n---\n# Goal\n');
107
+
108
+ const state = {
109
+ jobs: [
110
+ job('833-landed-inline', 'pending', 833, { cwd: root }),
111
+ job('900-still-hidden', 'pending', 900, { cwd: root }),
112
+ ],
113
+ };
114
+ await scheduler.reconcile(state);
115
+ const slugs = state.jobs.map((j) => j.slug);
116
+ expect(slugs).not.toContain('833-landed-inline');
117
+ expect(slugs).toContain('900-still-hidden');
118
+ });
119
+ });
@@ -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
  /**
@@ -152,6 +152,16 @@ function validateWrite(realAbs) {
152
152
  if (realAbs === promptSessionsSub || realAbs.startsWith(promptSessionsSub + path.sep)) {
153
153
  return;
154
154
  }
155
+ // Scheduler PRD sources (PRD 825 — per-project Epic PRD dirs under
156
+ // scheduler/epics/<id>/prds/, plus the legacy flat scheduler/prds/ and
157
+ // scheduler/state/ shards): narrowly scoped to
158
+ // session-manager-operations/scheduler/, this repo's existing
159
+ // per-project artifact-store convention (scheduler.cjs's remote.writePrd
160
+ // writes here via config.writeTextAtomic).
161
+ const schedulerSub = path.join(realRoot, 'session-manager-operations', 'scheduler');
162
+ if (realAbs === schedulerSub || realAbs.startsWith(schedulerSub + path.sep)) {
163
+ return;
164
+ }
155
165
  }
156
166
  }
157
167
  throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
@@ -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 };
@@ -7,6 +7,7 @@ const os = require('node:os');
7
7
  const { schemas, validated } = require('./ipcSchemas.cjs');
8
8
  const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
9
9
  const { terminateLosingInstance } = require('./lib/singleInstanceGuard.cjs');
10
+ const { acquireSchedulerOwnership, releaseSchedulerOwnership } = require('./lib/instanceLock.cjs');
10
11
  const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
11
12
  const browserView = require('./browserView.cjs');
12
13
  const browserCapture = require('./browserCapture.cjs');
@@ -450,9 +451,12 @@ ipcMain.handle('app:home-self-check', () => bootHomeSelfCheck);
450
451
  ipcMain.handle('app:pick-directory', async () => {
451
452
  console.log('[main] pick-directory invoked');
452
453
  const result = await dialog.showOpenDialog(mainWindow, {
453
- properties: ['openDirectory'],
454
+ // createDirectory enables the picker's "New Folder" affordance (macOS;
455
+ // the GTK chooser has its own) — this is the "Start Project" path:
456
+ // create a fresh folder in place, open it as the project's TAB.
457
+ properties: ['openDirectory', 'createDirectory'],
454
458
  defaultPath: os.homedir(),
455
- title: 'Choose project directory for new session',
459
+ title: 'Open a project folder — or create a new one to start a project',
456
460
  });
457
461
  console.log('[main] pick-directory result:', result);
458
462
  if (result.canceled || result.filePaths.length === 0) return null;
@@ -760,7 +764,7 @@ voiceWizard.registerWizardHandlers();
760
764
  scheduler.registerScheduleHandlers();
761
765
  watchers.registerWatcherHandlers();
762
766
  teams.registerTeamsHandlers();
763
- queueOps.registerQueueOpsHandlers();
767
+ queueOps.registerQueueOpsHandlers({ retireCompletedSlugs: scheduler.retireCompletedSlugs });
764
768
  registerHistoryAggregatorHandlers();
765
769
  registerHistoryDashboardHandlers();
766
770
  pluginInstall.registerPluginInstallHandlers();
@@ -1087,12 +1091,26 @@ app.whenReady().then(async () => {
1087
1091
  webRemote.attachWindow(mainWindow);
1088
1092
  chatRunner.attachWindow(mainWindow);
1089
1093
  attachDocEditWindow(mainWindow);
1090
- scheduler.init().catch((e) => {
1091
- logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
1092
- });
1093
- adminHttp.start().catch((e) => {
1094
- logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1095
- });
1094
+ // Scheduler ownership (PRD 834): exactly one instance machine-wide runs
1095
+ // scheduler mutation (boot reconciliation, ticking, sweep, supervisor) and
1096
+ // the admin API. Electron's own single-instance lock is keyed by userData
1097
+ // identity, which a dev/e2e/Playwright launch bypasses — live incident
1098
+ // 2026-07-31: a secondary instance's boot reconciliation SIGTERM'd the
1099
+ // owner's running job and overwrote admin-api.json. Losing instances run
1100
+ // scheduler-PASSIVE: read IPC still works (handlers registered above),
1101
+ // nothing mutates.
1102
+ const ownership = acquireSchedulerOwnership();
1103
+ if (ownership.owner) {
1104
+ scheduler.init().catch((e) => {
1105
+ logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
1106
+ });
1107
+ adminHttp.start().catch((e) => {
1108
+ logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1109
+ });
1110
+ } else {
1111
+ console.log(`[main] scheduler ownership held by pid ${ownership.holderPid} — running scheduler-passive (no reconciliation, no admin server)`);
1112
+ logs.writeLine({ scope: 'scheduler', level: 'info', message: 'scheduler-passive: ownership held by another instance', meta: { holderPid: ownership.holderPid } });
1113
+ }
1096
1114
  browserAgentServer.start().catch((e) => {
1097
1115
  logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1098
1116
  });
@@ -1196,6 +1214,7 @@ app.on('activate', () => {
1196
1214
  });
1197
1215
 
1198
1216
  app.on('before-quit', () => {
1217
+ releaseSchedulerOwnership();
1199
1218
  supervisor.stopSupervisor();
1200
1219
  ptyManager.killAll();
1201
1220
  configMgr.closeAllWatchers();
@@ -20,6 +20,10 @@ const ptySpawn = z.object({
20
20
 
21
21
  const ptyTabId = z.object({ tabId: z.string().min(1).max(128) });
22
22
 
23
+ // PRD 833 C1: Epics-workspace reconcile — which of these session keys still
24
+ // have a live PTY? Bounded so the renderer can't ask about unbounded lists.
25
+ const ptyAlive = z.object({ tabIds: z.array(z.string().min(1).max(128)).max(500) });
26
+
23
27
  // v2 mobile: subscribe to a session's live state + summary. cwd is needed to
24
28
  // locate the transcript JSONL (transcriptPath); validated against home-dir boundary
25
29
  // in webRemote before any fs access.
@@ -266,7 +270,12 @@ const schedulerCreatePrd = z.object({
266
270
  implementationNotes: z.string().min(1).max(20000),
267
271
  outOfScope: z.array(z.string().min(1).max(2000)).max(100).optional(),
268
272
  slug: z.string().min(1).max(60).regex(PRD_CREATE_SLUG_RE).optional(),
273
+ // DEPRECATED (PRD 832): accepted for wire-compat but ignored — numbers are
274
+ // strictly unique per project; use dependsOn for ordering.
269
275
  parallelGroup: z.number().int().min(1).max(999999).optional(),
276
+ // Explicit ordering (PRD 832): slugs that must complete before this PRD
277
+ // becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
278
+ dependsOn: z.array(z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/)).max(20).optional(),
270
279
  // Originating PromptTicket.id (PRD 748) when this PRD was authored from a
271
280
  // ticket classified 'develop' (PRD 749) — traces the PRD back to the
272
281
  // prompt that spawned it. Same newline-injection guard as title/cwd since
@@ -726,6 +735,7 @@ module.exports = {
726
735
  webRemoteAuditTail,
727
736
  ptySpawn,
728
737
  ptyTabId,
738
+ ptyAlive,
729
739
  ptyWrite,
730
740
  ptyResize,
731
741
  sessionSubscribe,
@@ -0,0 +1,87 @@
1
+ // PRD 834 — machine-wide scheduler-ownership lock. Secondary instances must
2
+ // go scheduler-passive instead of running boot reconciliation / admin server
3
+ // against the owner's queue state (live incident 2026-07-31).
4
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+
9
+ const {
10
+ acquireSchedulerOwnership,
11
+ releaseSchedulerOwnership,
12
+ pidAlive,
13
+ lockPath,
14
+ } = require('../instanceLock.cjs');
15
+
16
+ let dir;
17
+
18
+ beforeEach(() => {
19
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-instance-lock-'));
20
+ process.env.SM_SCHEDULER_LOCK_PATH = path.join(dir, 'scheduler-owner.lock');
21
+ });
22
+
23
+ afterEach(() => {
24
+ delete process.env.SM_SCHEDULER_LOCK_PATH;
25
+ fs.rmSync(dir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('instanceLock (PRD 834)', () => {
29
+ it('first acquirer becomes owner and writes its pid', () => {
30
+ const res = acquireSchedulerOwnership();
31
+ expect(res.owner).toBe(true);
32
+ const onDisk = JSON.parse(fs.readFileSync(lockPath(), 'utf8'));
33
+ expect(onDisk.pid).toBe(process.pid);
34
+ });
35
+
36
+ it('a live foreign holder makes acquisition passive with the holder pid reported', () => {
37
+ // Use our own live pid as the "foreign" holder — pidAlive(process.pid) is
38
+ // true, and acquire treats pid !== process.pid as foreign, so fake one by
39
+ // writing pid 1 (init — always alive, never ours).
40
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
41
+ fs.writeFileSync(lockPath(), JSON.stringify({ pid: 1, startedAt: 'x' }));
42
+ const res = acquireSchedulerOwnership();
43
+ expect(res.owner).toBe(false);
44
+ expect(res.holderPid).toBe(1);
45
+ // The holder's lock is untouched.
46
+ expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(1);
47
+ });
48
+
49
+ it('a stale lock (dead pid) is broken and ownership taken', () => {
50
+ // Find a pid that is certainly dead: spawn nothing — use a huge pid
51
+ // beyond pid_max defaults.
52
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
53
+ fs.writeFileSync(lockPath(), JSON.stringify({ pid: 2 ** 30, startedAt: 'x' }));
54
+ const res = acquireSchedulerOwnership();
55
+ expect(res.owner).toBe(true);
56
+ expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(process.pid);
57
+ });
58
+
59
+ it('re-acquiring our own lock stays owner (idempotent across re-init)', () => {
60
+ expect(acquireSchedulerOwnership().owner).toBe(true);
61
+ expect(acquireSchedulerOwnership().owner).toBe(true);
62
+ });
63
+
64
+ it('release removes only our own lock', () => {
65
+ acquireSchedulerOwnership();
66
+ releaseSchedulerOwnership();
67
+ expect(fs.existsSync(lockPath())).toBe(false);
68
+ // A foreign lock is never removed by release.
69
+ fs.writeFileSync(lockPath(), JSON.stringify({ pid: 1, startedAt: 'x' }));
70
+ releaseSchedulerOwnership();
71
+ expect(fs.existsSync(lockPath())).toBe(true);
72
+ });
73
+
74
+ it('a corrupt lock file is treated as stale, not fatal', () => {
75
+ fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
76
+ fs.writeFileSync(lockPath(), 'not json {');
77
+ const res = acquireSchedulerOwnership();
78
+ expect(res.owner).toBe(true);
79
+ });
80
+
81
+ it('pidAlive: own pid alive, absurd pid dead', () => {
82
+ expect(pidAlive(process.pid)).toBe(true);
83
+ expect(pidAlive(2 ** 30)).toBe(false);
84
+ expect(pidAlive(-5)).toBe(false);
85
+ expect(pidAlive(0)).toBe(false);
86
+ });
87
+ });
@@ -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
+ });