claude-code-session-manager 0.89.0 → 0.91.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 (80) hide show
  1. package/dist/assets/{AgentLibrary-tuj-j1kQ.js → AgentLibrary-zkgdnCnG.js} +1 -1
  2. package/dist/assets/DataModel-CNFdqxrR.js +1 -0
  3. package/dist/assets/{History-DylcasUk.js → History-C-qijfXz.js} +2 -2
  4. package/dist/assets/{Hooks--2xoag9a.js → Hooks-DGxt1nBd.js} +3 -3
  5. package/dist/assets/{HostBilko-oaw8paAh.js → HostBilko-sIYTK-m9.js} +1 -1
  6. package/dist/assets/{Library-DzKA09Iy.js → Library-51Jx0oGl.js} +1 -1
  7. package/dist/assets/{ListDetail-CEdcO80v.js → ListDetail-ByNJU-ie.js} +1 -1
  8. package/dist/assets/MarkdownEditor-Wwcpe9O1.js +1 -0
  9. package/dist/assets/{McpServers-BB7ayW7W.js → McpServers-Bs5xPbTG.js} +2 -2
  10. package/dist/assets/{Memory-BOdYvrL4.js → Memory-BsHGInmd.js} +6 -6
  11. package/dist/assets/{Panel-Yf3SxsRv.js → Panel-ilEDcV0m.js} +1 -1
  12. package/dist/assets/{Permissions-BYCpHNy5.js → Permissions-Djgc3TFk.js} +3 -3
  13. package/dist/assets/{Plugins-B0lXiYqV.js → Plugins-BBojr1mt.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-T7x8LWcV.js → ProvenanceBadge-Cz1VLnXb.js} +1 -1
  15. package/dist/assets/{SaveBar-BdVb4DbR.js → SaveBar-Cj0wz84u.js} +1 -1
  16. package/dist/assets/Scheduler-D4Ekm93R.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-WVhmNsL5.js → ScopeSwitcher-DA5MqWli.js} +1 -1
  18. package/dist/assets/{Settings-Bt4dVsl6.js → Settings-B1Kfgjvl.js} +2 -2
  19. package/dist/assets/{SkillReferenceGraph-R-0YykV8.js → SkillReferenceGraph-Dew-1uMy.js} +1 -1
  20. package/dist/assets/{Skills-DXV_LdJP.js → Skills-DzuhnXy9.js} +2 -2
  21. package/dist/assets/SystemPrompt-CNcCwEev.js +1 -0
  22. package/dist/assets/{TagLibrary-B9OuqU6M.js → TagLibrary-D0plTZgP.js} +1 -1
  23. package/dist/assets/{TiptapBody-HKKmB0AE.js → TiptapBody-BlmUt7SR.js} +1 -1
  24. package/dist/assets/{Toggle-CBggPonH.js → Toggle-yTfx2R-e.js} +1 -1
  25. package/dist/assets/{index-BHTX4OTc.css → index-CRPJslSA.css} +1 -1
  26. package/dist/assets/{index-BcJqLDiY.js → index-ju-h7BFf.js} +444 -444
  27. package/dist/assets/{settingsSchema-BYE9DVw_.js → settingsSchema-BkcnLBYk.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/README.md +1 -0
  31. package/src/main/__tests__/bilkoHost-integration.test.cjs +22 -9
  32. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +159 -0
  33. package/src/main/__tests__/health-delegation-chain.test.cjs +3 -1
  34. package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +16 -4
  35. package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +12 -0
  36. package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +10 -3
  37. package/src/main/__tests__/scheduler-looks-done.test.cjs +9 -2
  38. package/src/main/__tests__/scheduler-needs-review-autoresolve.test.cjs +14 -6
  39. package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +2 -2
  40. package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +8 -1
  41. package/src/main/__tests__/transcripts-paged-reads.test.cjs +4 -2
  42. package/src/main/__tests__/transcripts-worktree-epic-path.test.cjs +154 -0
  43. package/src/main/__tests__/usageSingleFlight.test.cjs +3 -1
  44. package/src/main/bilkoHost.cjs +6 -7
  45. package/src/main/build-info.json +4 -4
  46. package/src/main/chatRunner.cjs +108 -45
  47. package/src/main/health.cjs +20 -3
  48. package/src/main/index.cjs +1 -1
  49. package/src/main/ipcSchemas.cjs +3 -0
  50. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +34 -0
  51. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +117 -0
  52. package/src/main/lib/__tests__/epicTranscriptPath.test.cjs +163 -0
  53. package/src/main/lib/__tests__/localAdminHttp.test.cjs +5 -1
  54. package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +40 -1
  55. package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +8 -5
  56. package/src/main/lib/effectiveModelInfo.cjs +3 -3
  57. package/src/main/lib/epicDelegationStats.cjs +3 -2
  58. package/src/main/lib/epicSpawnCwd.cjs +36 -5
  59. package/src/main/lib/epicSpawnPlan.cjs +119 -0
  60. package/src/main/lib/epicTranscriptDiagnostic.cjs +79 -0
  61. package/src/main/lib/epicTranscriptPath.cjs +184 -0
  62. package/src/main/lib/gitWorktree.cjs +1 -1
  63. package/src/main/lib/mcpToolCatalog.cjs +6 -1
  64. package/src/main/lib/prdCreate.cjs +27 -1
  65. package/src/main/lib/prdFrontmatter.cjs +16 -5
  66. package/src/main/lib/rcaReport.cjs +27 -3
  67. package/src/main/lib/schedulerPaths.cjs +27 -4
  68. package/src/main/lib/telemetryClient.cjs +11 -4
  69. package/src/main/lib/terminalRunOutcome.cjs +1 -0
  70. package/src/main/pty.cjs +1 -1
  71. package/src/main/runVerify.cjs +89 -1
  72. package/src/main/scheduler.cjs +17 -4
  73. package/src/main/templates/PRD_AUTHORING.md +11 -0
  74. package/src/main/transcripts.cjs +28 -7
  75. package/src/preload/api.d.ts +4 -2
  76. package/src/preload/index.cjs +1 -0
  77. package/dist/assets/DataModel-DOim5SXW.js +0 -1
  78. package/dist/assets/MarkdownEditor-BMyo0F7s.js +0 -1
  79. package/dist/assets/Scheduler-DlOCWioV.js +0 -14
  80. package/dist/assets/SystemPrompt-DiQRo_WF.js +0 -1
@@ -0,0 +1,154 @@
1
+ /**
2
+ * transcripts-worktree-epic-path.test.cjs — transcriptPath()/subscribe() resolve
3
+ * an Epic's transcript through epicTranscriptPath (worktree encoding), while a
4
+ * non-Epic tab keeps the plain project encoding. Includes a parity check of the
5
+ * renderer's transcriptExists() (EpicTerminalPane's resume-vs-create decision)
6
+ * against the REAL transcriptPath instead of a constant mock.
7
+ *
8
+ * Run: timeout 120 npx vitest run src/main/__tests__/transcripts-worktree-epic-path.test.cjs
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ import { test, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
14
+ import { transcriptExists } from '../../renderer/lib/transcriptExists';
15
+
16
+ const fs = require('node:fs');
17
+ const os = require('node:os');
18
+ const path = require('node:path');
19
+
20
+ let tmpHome;
21
+ let originalHome;
22
+ let tr;
23
+ let resolver;
24
+ let encodeCwd;
25
+ let proj;
26
+ const SID = 'b8a5d6f4-0092-4db0-a687-009241f2a8b2';
27
+ const WT = '/tmp/sm-wt-test-epic-worktree-dir';
28
+
29
+ beforeAll(() => {
30
+ originalHome = process.env.HOME;
31
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-home-'));
32
+ process.env.HOME = tmpHome;
33
+ tr = require('../transcripts.cjs');
34
+ resolver = require('../lib/epicTranscriptPath.cjs');
35
+ ({ encodeCwd } = require('../lib/encodeCwd.cjs'));
36
+ });
37
+
38
+ afterAll(() => {
39
+ process.env.HOME = originalHome;
40
+ fs.rmSync(tmpHome, { recursive: true, force: true });
41
+ });
42
+
43
+ beforeEach(() => {
44
+ resolver.__resetCacheForTests();
45
+ proj = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-proj-'));
46
+ });
47
+
48
+ afterEach(() => {
49
+ fs.rmSync(proj, { recursive: true, force: true });
50
+ fs.rmSync(path.join(tmpHome, '.claude'), { recursive: true, force: true });
51
+ vi.unstubAllGlobals();
52
+ });
53
+
54
+ const projectsDir = () => path.join(tmpHome, '.claude', 'projects');
55
+ const plain = (cwd, id = SID) => path.join(projectsDir(), encodeCwd(cwd), `${id}.jsonl`);
56
+
57
+ function writeIndex(sessionId = SID) {
58
+ const dir = path.join(proj, 'session-manager-operations', 'prompt-sessions');
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ const s = { id: 'epic-1', cwd: proj, claudeSessionId: sessionId, status: 'active', worktree: { dir: WT, branch: 'sm-epic/epic-1', baseCwd: proj, status: 'active' } };
61
+ fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions: { 'epic-1': s }, events: {} }));
62
+ }
63
+
64
+ function writeTranscript(p, body = '{"type":"user"}\n') {
65
+ fs.mkdirSync(path.dirname(p), { recursive: true });
66
+ fs.writeFileSync(p, body);
67
+ }
68
+
69
+ test('worktree-present: transcript only under the worktree encoding resolves there', () => {
70
+ writeIndex();
71
+ fs.mkdirSync(WT, { recursive: true });
72
+ try {
73
+ writeTranscript(plain(WT));
74
+ expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
75
+ } finally {
76
+ fs.rmSync(WT, { recursive: true, force: true });
77
+ }
78
+ });
79
+
80
+ test('worktree-swept: worktree dir gone from disk, transcript still found', () => {
81
+ writeIndex();
82
+ expect(fs.existsSync(WT)).toBe(false);
83
+ writeTranscript(plain(WT));
84
+ expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
85
+ });
86
+
87
+ test('non-Epic tab resolves to exactly the pre-change path, even when an Epic exists', () => {
88
+ writeIndex();
89
+ writeTranscript(plain(WT));
90
+ const rawTab = 'raw-tab-id-123';
91
+ expect(tr.transcriptPath(proj, rawTab)).toBe(path.join(os.homedir(), '.claude', 'projects', encodeCwd(proj), `${rawTab}.jsonl`));
92
+ // no index at all
93
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-transcripts-wt-other-'));
94
+ try {
95
+ expect(tr.transcriptPath(other, SID)).toBe(plain(other));
96
+ expect(path.isAbsolute(tr.transcriptPath(other, SID))).toBe(true);
97
+ } finally {
98
+ fs.rmSync(other, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test('zero-byte transcript counts as existing and does not break usageFor', async () => {
103
+ writeIndex();
104
+ writeTranscript(plain(WT), '');
105
+ expect(tr.transcriptPath(proj, SID)).toBe(plain(WT));
106
+ const out = await tr.usageFor(proj, [SID]);
107
+ expect(out).toHaveProperty(SID);
108
+ });
109
+
110
+ test('subscribe does NOT mkdir when a transcript exists elsewhere (worktree encoding)', async () => {
111
+ writeIndex();
112
+ writeTranscript(plain(WT));
113
+ const before = fs.readdirSync(projectsDir()).sort();
114
+ const res = await tr.subscribe({ tabId: 'tab-wt', cwd: proj, sessionUuid: SID });
115
+ try {
116
+ expect(res.ok).toBe(true);
117
+ expect(res.path).toBe(plain(WT));
118
+ expect(fs.readdirSync(projectsDir()).sort()).toEqual(before);
119
+ expect(fs.existsSync(path.dirname(plain(proj)))).toBe(false);
120
+ } finally {
121
+ tr.closeTab('tab-wt');
122
+ }
123
+ });
124
+
125
+ test('subscribe of a brand-new Epic watches the spawn-cwd path and creates its dir', async () => {
126
+ writeIndex();
127
+ const res = await tr.subscribe({ tabId: 'tab-new', cwd: proj, sessionUuid: SID });
128
+ try {
129
+ expect(res.ok).toBe(true);
130
+ // no transcript anywhere → resolver's first candidate (spawn cwd), whose dir the CLI will write
131
+ expect(fs.existsSync(path.dirname(res.path))).toBe(true);
132
+ expect(fs.existsSync(res.path)).toBe(false);
133
+ } finally {
134
+ tr.closeTab('tab-new');
135
+ }
136
+ });
137
+
138
+ test('parity: renderer transcriptExists (Terminal resume decision) sees a worktree-only transcript', async () => {
139
+ writeIndex();
140
+ vi.stubGlobal('window', {
141
+ api: {
142
+ transcripts: { pathFor: async (c, s) => tr.transcriptPath(c, s) },
143
+ config: { exists: async (p) => fs.existsSync(p) },
144
+ },
145
+ });
146
+ expect(await transcriptExists(proj, SID)).toBe(false);
147
+ writeTranscript(plain(WT));
148
+ expect(await transcriptExists(proj, SID)).toBe(true);
149
+ // zero-byte still "exists"
150
+ writeTranscript(plain(WT), '');
151
+ expect(await transcriptExists(proj, SID)).toBe(true);
152
+ // raw tab never resolves into the Epic's worktree encoding
153
+ expect(await transcriptExists(proj, 'raw-tab')).toBe(false);
154
+ });
@@ -89,7 +89,9 @@ afterEach(() => {
89
89
  vi.unstubAllGlobals();
90
90
  vi.restoreAllMocks();
91
91
  purgeRequireCache();
92
- fs.rmSync(tmpHome, { recursive: true, force: true });
92
+ // maxRetries: a fire-and-forget cache write inside usage.cjs can land in .claude/ mid-rm
93
+ // (ENOTEMPTY) when the machine is loaded; this is cleanup robustness, not a test retry.
94
+ fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 10, retryDelay: 50 });
93
95
  });
94
96
 
95
97
  test('two concurrent fetchUsage() calls produce exactly one HTTP fetch', async () => {
@@ -220,13 +220,12 @@ async function removeDocument({ cwd, id }) {
220
220
 
221
221
  async function resolveDocumentHtml(cwd, doc) {
222
222
  if (doc.source.kind === 'project-page-lens') {
223
- // Only `home` is generated now (one home.html); the other lens names
224
- // are legacy and resolve to their old output/<lens>.html if one exists.
225
- const result = await config.readText(
226
- doc.source.lens === 'home'
227
- ? homePagePath(cwd)
228
- : opsPath(cwd, 'project-pages', 'output', `${doc.source.lens}.html`),
229
- );
223
+ // Only `home` is generated now (one home.html); other lens names are
224
+ // retired and have no output anywhere — fail loudly rather than fall back.
225
+ if (doc.source.lens !== 'home') {
226
+ throw new Error(`document "${doc.title}" points at the retired "${doc.source.lens}" Project Page lens; only the single home.html page is generated now`);
227
+ }
228
+ const result = await config.readText(homePagePath(cwd));
230
229
  if (!result.exists) {
231
230
  throw new Error(`document "${doc.title}" points at the ${doc.source.lens} Project Page, but it hasn't been generated yet`);
232
231
  }
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.89.0",
3
- "gitSha": "5ec3affd400f9a33189925d332d9c253fee1120c",
4
- "gitShortSha": "5ec3aff",
2
+ "version": "0.91.0",
3
+ "gitSha": "0029caa246d6296f7f8e83a502f5351c670b269c",
4
+ "gitShortSha": "0029caa",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-19T22:24:34.621Z",
6
+ "builtAt": "2026-09-20T19:42:50.657Z",
7
7
  "dirty": false
8
8
  }
@@ -53,7 +53,14 @@ const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
53
53
  const sessionSlots = require('./lib/sessionSlots.cjs');
54
54
  const opsErrorLog = require('./lib/opsErrorLog.cjs');
55
55
  const agentModelResolve = require('./lib/agentModelResolve.cjs');
56
- const { resolveEpicSpawnCwd } = require('./lib/epicSpawnCwd.cjs');
56
+ const { planEpicSpawn } = require('./lib/epicSpawnPlan.cjs');
57
+ const logs = require('./logs.cjs');
58
+
59
+ // The only two CLI errors that mean "wrong session flag" (verified against the
60
+ // real CLI). "in use" → the transcript exists, so resume; "no conversation" →
61
+ // nothing to resume, so create. Never widen this into a general retry.
62
+ const SESSION_IN_USE_RE = /Session ID .* is already in use/i;
63
+ const NO_CONVERSATION_RE = /No conversation found with session ID/i;
57
64
 
58
65
  // ─── Stop-signal protocol ──────────────────────────────────────────────────
59
66
  // Single source of truth for the sentinel and parser. The renderer (PRD 320)
@@ -540,21 +547,43 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
540
547
  // must never be left unpinned (CLAUDE.md model-pinning rule).
541
548
  const model = agentModelResolve.resolveEpicModel({ cwd, claudeSessionId: sessionId });
542
549
 
543
- // Build argv as an array — no shell: true, no string interpolation
544
- const args = [
545
- '-p', fullPrompt,
546
- '--model', model,
547
- '--dangerously-skip-permissions',
548
- '--output-format', 'stream-json',
549
- '--verbose',
550
- ];
551
- if (resume) {
552
- // --resume carries the session context; no --session-id needed
553
- args.push('--resume', sessionId);
554
- } else {
555
- args.push('--session-id', sessionId);
550
+ // Main is authoritative about --resume vs --session-id AND the spawn cwd, and it decides
551
+ // them TOGETHER (epicSpawnPlan.cjs): a transcript under an encoding the spawn cwd cannot see
552
+ // must never yield a claimed flag. Unreachable / closed Epics are refused before any spawn.
553
+ const plan = planEpicSpawn({ cwd, claudeSessionId: sessionId, fallbackResume: !!resume });
554
+ if (!plan.ok) {
555
+ emitTerminal('chat:run:error', { tabId, sessionId, code: plan.code, message: plan.message });
556
+ settle();
557
+ return;
558
+ }
559
+ const useResume = plan.useResume;
560
+ if (useResume !== !!resume) {
561
+ try {
562
+ logs.writeLine({
563
+ scope: 'chatRunner',
564
+ level: 'warn',
565
+ message: `session flag mismatch: renderer resume=${!!resume}, main resume=${useResume}; transcript=${plan.transcriptPath ?? 'none'}`,
566
+ meta: { tabId, sessionId, rendererResume: !!resume, mainResume: useResume, transcriptPath: plan.transcriptPath ?? null },
567
+ });
568
+ } catch { /* logging must never fail a turn */ }
556
569
  }
557
570
 
571
+ // Build argv as an array — no shell: true, no string interpolation. Also
572
+ // used for the flag-swap retry so both attempts share one construction path.
573
+ const buildArgs = (resumeFlag) => {
574
+ const a = [
575
+ '-p', fullPrompt,
576
+ '--model', model,
577
+ '--dangerously-skip-permissions',
578
+ '--output-format', 'stream-json',
579
+ '--verbose',
580
+ ];
581
+ // --resume carries the session context; no --session-id needed
582
+ if (resumeFlag) a.push('--resume', sessionId);
583
+ else a.push('--session-id', sessionId);
584
+ return a;
585
+ };
586
+
558
587
  if (!silent) broadcast('chat:run:started', { tabId, sessionId });
559
588
 
560
589
  // sessionId IS the Epic's claudeSessionId for an Epic-backed tab (see
@@ -564,36 +593,14 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
564
593
  // below keeps using it exactly as before — only this ACTUAL spawn cwd
565
594
  // option does. See epicSpawnCwd.cjs's header comment (the ops-root
566
595
  // hazard) and jobWorktree.cjs's own (execCwd vs job.cwd) for why.
567
- const execCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: sessionId });
596
+ const execCwd = plan.execCwd;
568
597
 
569
- // Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
570
- // "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
571
- // real-time NDJSON streaming; stderr piped for error-message capture.
572
- let child;
573
- try {
574
- const target = claudeSpawnTarget('chat', sessionId, claudeBin);
575
- child = spawn(target.command, args, {
576
- ...(target.argv0 ? { argv0: target.argv0 } : {}),
577
- cwd: execCwd,
578
- env: childEnv,
579
- stdio: ['ignore', 'pipe', 'pipe'],
580
- detached: true, // own process group so killTree can SIGTERM descendants
581
- });
582
- } catch (err) {
583
- emitTerminal('chat:run:error', {
584
- tabId,
585
- sessionId,
586
- message: `spawn failed: ${err?.message ?? String(err)}`,
587
- });
588
- settle();
589
- return;
590
- }
591
-
592
- // One-shot kill helper — idempotent via the `killed` flag
593
598
  let killed = false;
599
+ let child = null;
594
600
  const doKill = (sig) => {
595
601
  if (killed) return;
596
602
  killed = true;
603
+ if (!child) return;
597
604
  // Negative pid targets the process group (requires detached: true)
598
605
  try { process.kill(-child.pid, sig); }
599
606
  catch { try { child.kill(sig); } catch { /* already dead */ } }
@@ -647,10 +654,40 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
647
654
  }, KILL_CEILING_MS);
648
655
  if (killTimer.unref) killTimer.unref();
649
656
 
657
+ // One spawn of the CLI. Per-attempt state (buffers, child) lives in here;
658
+ // everything turn-level (timers, latch, lane) is in the enclosing scope.
659
+ const startAttempt = (resumeFlag, attemptNo) => {
660
+ // Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
661
+ // "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
662
+ // real-time NDJSON streaming; stderr piped for error-message capture.
663
+ let thisChild;
664
+ try {
665
+ const target = claudeSpawnTarget('chat', sessionId, claudeBin);
666
+ thisChild = spawn(target.command, buildArgs(resumeFlag), {
667
+ ...(target.argv0 ? { argv0: target.argv0 } : {}),
668
+ cwd: execCwd,
669
+ env: childEnv,
670
+ stdio: ['ignore', 'pipe', 'pipe'],
671
+ detached: true, // own process group so killTree can SIGTERM descendants
672
+ });
673
+ } catch (err) {
674
+ clearTimeout(killTimer);
675
+ clearTimeout(warnTimer);
676
+ emitTerminal('chat:run:error', {
677
+ tabId,
678
+ sessionId,
679
+ message: `spawn failed: ${err?.message ?? String(err)}`,
680
+ });
681
+ settle();
682
+ return;
683
+ }
684
+ child = thisChild;
685
+
650
686
  // ─── Stream parsing ────────────────────────────────────────────────────
651
687
  // stdout is newline-delimited JSON (stream-json format). We buffer partial
652
688
  // lines across TCP/pipe chunks. O(output-size) in memory per run.
653
689
 
690
+ // Per-attempt state — a retry starts from empty buffers.
654
691
  let lineBuffer = '';
655
692
  let finalAssistantText = '';
656
693
  let stderrBuffer = '';
@@ -738,7 +775,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
738
775
  }
739
776
  };
740
777
 
741
- child.stdout.on('data', (chunk) => {
778
+ thisChild.stdout.on('data', (chunk) => {
742
779
  lineBuffer += chunk.toString('utf8');
743
780
  let nl;
744
781
  while ((nl = lineBuffer.indexOf('\n')) !== -1) {
@@ -747,11 +784,11 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
747
784
  }
748
785
  });
749
786
 
750
- child.stderr.on('data', (chunk) => {
787
+ thisChild.stderr.on('data', (chunk) => {
751
788
  stderrBuffer += chunk.toString('utf8');
752
789
  });
753
790
 
754
- child.on('error', (err) => {
791
+ thisChild.on('error', (err) => {
755
792
  clearTimeout(killTimer);
756
793
  clearTimeout(warnTimer);
757
794
  emitTerminal('chat:run:error', {
@@ -768,12 +805,35 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
768
805
  // line) reaches the 'data' handler above, which raced this fallback into
769
806
  // firing first and dropping the real chat:run:complete behind the
770
807
  // terminalSent latch.
771
- child.on('close', (code, signal) => {
772
- clearTimeout(killTimer);
773
- clearTimeout(warnTimer);
808
+ thisChild.on('close', (code, signal) => {
774
809
  // Flush any partial line that didn't end with \n
775
810
  if (lineBuffer.trim()) processLine(lineBuffer.trim());
776
811
 
812
+ // Flag-swap retry: the CLI rejected the session flag before doing any
813
+ // work. Retry exactly once with the other flag; the timers, lane and
814
+ // terminal latch stay at executeRun level, so this is still one turn.
815
+ if (!terminalSent && !killed && code !== 0 && attemptNo === 1
816
+ && finalAssistantText === '' && recentToolUses.length === 0
817
+ && !/rate.?limit/i.test(stderrBuffer)) {
818
+ const swapTo = resumeFlag
819
+ ? (NO_CONVERSATION_RE.test(stderrBuffer) ? false : null)
820
+ : (SESSION_IN_USE_RE.test(stderrBuffer) ? true : null);
821
+ if (swapTo !== null) {
822
+ if (!silent) {
823
+ broadcast('chat:run:notice', {
824
+ tabId,
825
+ sessionId,
826
+ message: `Session flag mismatch (${resumeFlag ? '--resume' : '--session-id'} rejected); retrying once with ${swapTo ? '--resume' : '--session-id'}.`,
827
+ });
828
+ }
829
+ startAttempt(swapTo, 2);
830
+ return;
831
+ }
832
+ }
833
+
834
+ clearTimeout(killTimer);
835
+ clearTimeout(warnTimer);
836
+
777
837
  // Emit a fallback terminal event for any run that ended without one — a
778
838
  // cancel/SIGTERM (killed=true), a crash before a result, or a silent
779
839
  // exit. `emitTerminal` no-ops if the run already sent complete/needs-
@@ -799,6 +859,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
799
859
  }
800
860
  settle();
801
861
  });
862
+ };
863
+
864
+ startAttempt(useResume, 1);
802
865
  });
803
866
  }
804
867
 
@@ -25,6 +25,7 @@ const { auditLogPath, readTail } = require('./lib/auditLog.cjs');
25
25
  const { resolveBuildIdentity } = require('./lib/buildIdentity.cjs');
26
26
  const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
27
27
  const { allProjectCwds } = require('./lib/activeSessions.cjs');
28
+ const { scanEpicTranscripts } = require('./lib/epicTranscriptDiagnostic.cjs');
28
29
 
29
30
  const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
30
31
  const PROJECT_ROOT = path.resolve(__dirname, '../..');
@@ -716,7 +717,10 @@ function evaluateBlockingParkHealth(jobs, now, thresholdMs = REVERIFY_INTERVAL_M
716
717
  };
717
718
  }
718
719
 
719
- async function check() {
720
+ // opts.skipTypecheck: skip the whole-repo `tsc` shell-out (~15s idle, unbounded under CPU load).
721
+ // For callers that assert on one other component; the typescript component is then
722
+ // reported { ok: true, skipped: true } so it can never read as a real pass.
723
+ async function check(opts = {}) {
720
724
  const start = Date.now();
721
725
  const status = {
722
726
  ok: true,
@@ -736,8 +740,8 @@ async function check() {
736
740
  }
737
741
 
738
742
  // 1.5. Check TypeScript compilation (no errors).
739
- const typesOk = runCheck('npm run typecheck 2>&1 | grep -q "error" && exit 1 || exit 0');
740
- status.components.typescript = { ok: typesOk };
743
+ const typesOk = opts.skipTypecheck ? true : runCheck('npm run typecheck 2>&1 | grep -q "error" && exit 1 || exit 0');
744
+ status.components.typescript = opts.skipTypecheck ? { ok: true, skipped: true } : { ok: typesOk };
741
745
  if (!typesOk) {
742
746
  status.issues.push('TypeScript compilation has errors');
743
747
  status.ok = false;
@@ -1146,6 +1150,19 @@ async function check() {
1146
1150
  status.issues.push(`Epic index health check failed: ${e.message}`);
1147
1151
  }
1148
1152
 
1153
+ // 6.66. Epic transcript location (NON-fatal warning): a mislocated/duplicated
1154
+ // transcript is read correctly by epicTranscriptPath, so it never reddens the
1155
+ // rollup (not in criticalComponents). See scripts/check-epic-transcripts.cjs.
1156
+ try {
1157
+ const findings = scanEpicTranscripts({ cwds: allProjectCwds() });
1158
+ status.components.epic_transcripts = { ok: true, findings: findings.length, duplicated: findings.filter((f) => f.classification === 'duplicated').length };
1159
+ if (findings.length > 0) {
1160
+ status.issues.push(`Warning: ${findings.length} Epic transcript(s) mislocated/duplicated — run: node scripts/check-epic-transcripts.cjs`);
1161
+ }
1162
+ } catch (e) {
1163
+ status.components.epic_transcripts = { ok: true, error: e.message };
1164
+ }
1165
+
1149
1166
  // 6.7. Build freshness (informational): running heartbeat vs installed
1150
1167
  // build-info vs repo HEAD.
1151
1168
  try {
@@ -513,7 +513,7 @@ ipcMain.handle('app:launch-mode', () => ({
513
513
  // `claude mcp list`. Read-only, single in-flight call — no polling.
514
514
  ipcMain.handle('mcp:status', () => probeMcpStatus());
515
515
 
516
- // Project Home's "Agent tools" block (PhAgentTools): the same catalog data
516
+ // The Dashboard's "Agent tools" section (HomeAgentTools): the same catalog data
517
517
  // GET /admin/mcp/catalog serves to external callers, read in-process here so
518
518
  // the renderer never makes a direct HTTP call. Static data, no cwd needed.
519
519
  ipcMain.handle('mcp:catalog', () => ({ ok: true, tools: MCP_TOOL_CATALOG, recipes: MCP_RECIPES }));
@@ -351,6 +351,9 @@ const schedulerCreatePrd = z.object({
351
351
  // createPrd() requires it in that situation for an interactive caller and
352
352
  // defaults to 'append' (logged as a default) for a non-interactive one.
353
353
  disposition: PrdDispositionSchema.optional(),
354
+ // Artifact-only PRD declaration — cross-field rules enforced in prdCreate.cjs.
355
+ deliverable: z.literal('artifact').optional(),
356
+ artifactPaths: z.array(z.string().min(1).max(1024).regex(NO_NEWLINE_RE, 'must not contain newlines')).max(50).optional(),
354
357
  // An EXISTING Epic's promptSessionId (PRD 748) — NOT a PromptTicket.id.
355
358
  // ensureEpic() (epicMint.cjs) joins the Epic whose active-index.json
356
359
  // sessions key literally equals this value; the renderer only ever sends
@@ -191,3 +191,37 @@ test('a real, existing directory IS returned — the check gates on disk state,
191
191
  fs.rmSync(realDir, { recursive: true, force: true });
192
192
  }
193
193
  });
194
+
195
+ // ─── spawn-side: a vanished worktree is restored at the SAME path, never swapped for cwd ───
196
+
197
+ test('restore:true re-attaches the worktree and returns the recorded dir (transcript encoding unchanged)', () => {
198
+ const calls = [];
199
+ const result = resolveEpicSpawnCwd({
200
+ cwd: '/projects/foo',
201
+ claudeSessionId: 'sess-1',
202
+ deps: {
203
+ readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS),
204
+ statSync: statGone,
205
+ restore: true,
206
+ restoreWorktree: (a) => { calls.push(a); return true; },
207
+ },
208
+ });
209
+ expect(result).toBe('/tmp/session-manager-epic-worktrees/deadbeef/epic-1');
210
+ expect(calls).toEqual([{ dir: '/tmp/session-manager-epic-worktrees/deadbeef/epic-1', branch: 'sm-epic/epic-1', baseCwd: '/projects/foo' }]);
211
+ });
212
+
213
+ test('restore:true with a failed restore still returns the recorded dir — never the project cwd', () => {
214
+ const result = resolveEpicSpawnCwd({
215
+ cwd: '/projects/foo',
216
+ claudeSessionId: 'sess-1',
217
+ deps: { readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS), statSync: statGone, restore: true, restoreWorktree: () => false },
218
+ });
219
+ expect(result).toBe('/tmp/session-manager-epic-worktrees/deadbeef/epic-1');
220
+ });
221
+
222
+ test('restoreEpicWorktree refuses dirs outside an epic-worktrees segment and non sm-epic branches', () => {
223
+ const { restoreEpicWorktree } = require('../epicSpawnCwd.cjs');
224
+ expect(restoreEpicWorktree({ dir: '/etc/evil', branch: 'sm-epic/x', baseCwd: '/projects/foo' })).toBe(false);
225
+ expect(restoreEpicWorktree({ dir: '/tmp/session-manager-epic-worktrees/a/b', branch: 'main; rm -rf', baseCwd: '/projects/foo' })).toBe(false);
226
+ expect(restoreEpicWorktree({ dir: 'relative/session-manager-epic-worktrees/a', branch: 'sm-epic/x', baseCwd: '/projects/foo' })).toBe(false);
227
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * epicSpawnPlan.test.cjs — PRD 1319: spawn cwd + --resume flag are decided together; a session
3
+ * whose transcript is stranded under a dead worktree encoding is refused BEFORE any CLI spawn
4
+ * with an actionable message, later requests are refused cheaply, closed Epics are refused.
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/epicSpawnPlan.test.cjs
7
+ */
8
+ 'use strict';
9
+
10
+ import { test, expect, beforeEach, afterEach } from 'vitest';
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { EventEmitter } = require('node:events');
15
+ const { encodeCwd } = require('../encodeCwd.cjs');
16
+ const { planEpicSpawn, __resetForTests } = require('../epicSpawnPlan.cjs');
17
+ const { __resetCacheForTests } = require('../epicTranscriptPath.cjs');
18
+
19
+ const SID = '00ff31cb-56b6-421c-829c-012ef95b15e2';
20
+ const PROJECT = '/projects/starry-night-ships';
21
+ const DEAD = '/tmp/session-manager-epic-worktrees/abc123/blender-epic-442d711d';
22
+
23
+ let home;
24
+ beforeEach(() => {
25
+ __resetForTests();
26
+ __resetCacheForTests();
27
+ home = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-home-'));
28
+ });
29
+ afterEach(() => fs.rmSync(home, { recursive: true, force: true }));
30
+
31
+ function transcriptUnder(dir) {
32
+ const d = path.join(home, '.claude', 'projects', encodeCwd(dir));
33
+ fs.mkdirSync(d, { recursive: true });
34
+ const f = path.join(d, `${SID}.jsonl`);
35
+ fs.writeFileSync(f, '{"type":"user"}\n');
36
+ return f;
37
+ }
38
+ const epic = (extra = {}) => ({ e1: { id: 'e1', claudeSessionId: SID, status: 'active', worktree: { dir: DEAD, branch: 'sm-epic/e1', baseCwd: PROJECT, status: 'active' }, ...extra } });
39
+
40
+ test('transcript under a dead worktree encoding + restore failure => refused, names the worktree, no raw CLI text', () => {
41
+ transcriptUnder(DEAD);
42
+ const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), restoreWorktree: () => false } });
43
+ expect(plan.ok).toBe(false);
44
+ expect(plan.code).toBe('session_unreachable');
45
+ expect(plan.message).toContain(DEAD);
46
+ expect(plan.message).not.toMatch(/already in use/i);
47
+ });
48
+
49
+ test('circuit breaker: 2nd..Nth request refused without re-running restore', () => {
50
+ transcriptUnder(DEAD);
51
+ let restores = 0;
52
+ const deps = { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), restoreWorktree: () => { restores++; return false; } };
53
+ const first = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps });
54
+ for (let i = 0; i < 4; i++) expect(planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps })).toEqual(first);
55
+ expect(restores).toBe(1);
56
+ });
57
+
58
+ test('worktree re-attached at the same path => ok, resume, spawn cwd is the worktree', () => {
59
+ transcriptUnder(DEAD);
60
+ const live = new Set();
61
+ const statSync = (p) => (live.has(p) ? { isDirectory: () => true } : fs.statSync(p));
62
+ const plan = planEpicSpawn({
63
+ cwd: PROJECT, claudeSessionId: SID,
64
+ deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic() }), statSync, restoreWorktree: ({ dir }) => { live.add(dir); return true; } },
65
+ });
66
+ expect(plan.ok).toBe(true);
67
+ expect(plan.useResume).toBe(true);
68
+ expect(plan.execCwd).toBe(DEAD);
69
+ });
70
+
71
+ test('transcript under the project encoding, no worktree => ok, resume from project cwd', () => {
72
+ transcriptUnder(PROJECT);
73
+ const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic({ worktree: undefined }) }), statSync: (p) => (p === PROJECT ? { isDirectory: () => true } : fs.statSync(p)) } });
74
+ expect(plan).toMatchObject({ ok: true, useResume: true, execCwd: PROJECT });
75
+ });
76
+
77
+ test('closed (completed) Epic is refused with its own message', () => {
78
+ const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic({ status: 'completed' }) }) } });
79
+ expect(plan).toMatchObject({ ok: false, code: 'epic_closed' });
80
+ expect(plan.message).toMatch(/closed/i);
81
+ });
82
+
83
+ test('chatRunner: dead-encoding transcript => zero CLI spawns, exactly one clear error event', async () => {
84
+ const project = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-proj-'));
85
+ const prevHome = process.env.HOME;
86
+ process.env.HOME = home;
87
+ try {
88
+ const idxDir = path.join(project, 'session-manager-operations', 'prompt-sessions');
89
+ fs.mkdirSync(idxDir, { recursive: true });
90
+ fs.writeFileSync(path.join(idxDir, 'active-index.json'), JSON.stringify({ sessions: epic() }));
91
+ transcriptUnder(DEAD);
92
+ const cp = require('node:child_process');
93
+ const realSpawn = cp.spawn;
94
+ let spawns = 0;
95
+ cp.spawn = () => { spawns++; const c = new EventEmitter(); c.stdout = new EventEmitter(); c.stderr = new EventEmitter(); c.pid = 1; c.kill = () => {}; return c; };
96
+ const chatRunner = require('../../chatRunner.cjs');
97
+ chatRunner.__resetQueueForTests();
98
+ const sent = [];
99
+ chatRunner.attachWindow({ isDestroyed: () => false, webContents: { isDestroyed: () => false, send: (channel, payload) => sent.push({ channel, payload }) } });
100
+ try {
101
+ for (let i = 0; i < 3; i++) chatRunner.run({ tabId: `t${i}`, sessionId: SID, prompt: 'hi', cwd: project, resume: true });
102
+ await new Promise((r) => setTimeout(r, 100));
103
+ } finally { cp.spawn = realSpawn; }
104
+ const errs = sent.filter((e) => e.channel === 'chat:run:error');
105
+ expect(spawns).toBe(0);
106
+ expect(errs.length).toBe(3);
107
+ for (const e of errs) expect(e.payload.message).toContain(DEAD);
108
+ // Single-writer law: the rescued session's ops write (error log) lands under the PROJECT root.
109
+ const logsDir = path.join(project, 'session-manager-operations', 'logs');
110
+ expect(fs.existsSync(logsDir)).toBe(true);
111
+ expect(fs.readdirSync(logsDir).some((n) => n.startsWith('errors-'))).toBe(true);
112
+ expect(fs.existsSync(path.join(DEAD, 'session-manager-operations'))).toBe(false);
113
+ } finally {
114
+ process.env.HOME = prevHome;
115
+ fs.rmSync(project, { recursive: true, force: true });
116
+ }
117
+ });