claude-code-session-manager 0.91.1 → 0.92.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 (47) hide show
  1. package/dist/assets/{AgentLibrary-Chmfxih8.js → AgentLibrary-Bu-1eT_n.js} +1 -1
  2. package/dist/assets/{DataModel-Cp38ePS_.js → DataModel-Bk9rkJ8x.js} +1 -1
  3. package/dist/assets/{History-SMWV7lD6.js → History-BB6wpf02.js} +1 -1
  4. package/dist/assets/{Hooks-JUWiyu-r.js → Hooks-zQZH56Sv.js} +1 -1
  5. package/dist/assets/{HostBilko-DFTixTqy.js → HostBilko-CW0pHoPS.js} +1 -1
  6. package/dist/assets/{Library-zUu71gsR.js → Library-DrGdaz9N.js} +1 -1
  7. package/dist/assets/{ListDetail-BK_PH4lf.js → ListDetail-D4bxU_-2.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-ITJDD0Mr.js → MarkdownEditor-H1ytN4Tk.js} +1 -1
  9. package/dist/assets/{McpServers-CWwrfGkx.js → McpServers-CYSRCI-j.js} +1 -1
  10. package/dist/assets/{Memory-DgHttor9.js → Memory-zMNpJyRU.js} +1 -1
  11. package/dist/assets/{Panel-C2Cb7TS4.js → Panel-RMz3Acpu.js} +1 -1
  12. package/dist/assets/{Permissions-hd8OBsj_.js → Permissions-Be6cIkPg.js} +1 -1
  13. package/dist/assets/{Plugins-BYa-ClGK.js → Plugins-B8sTWf2K.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-Dmhz8ZOB.js → ProvenanceBadge-CO-DZN38.js} +1 -1
  15. package/dist/assets/{SaveBar-1NvfbO9r.js → SaveBar-xQZ2EBOn.js} +1 -1
  16. package/dist/assets/{Scheduler-SYgINPCB.js → Scheduler-CF3zsJBw.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-MBmWNzFk.js → ScopeSwitcher-BAfquN6Y.js} +1 -1
  18. package/dist/assets/{Settings-C1WOHoYb.js → Settings-uPjmqutA.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-Cuse__ul.js → SkillReferenceGraph-BI1YtCbB.js} +1 -1
  20. package/dist/assets/{Skills-CQmLYgNh.js → Skills-BEy9FDYd.js} +1 -1
  21. package/dist/assets/{SystemPrompt-DkhAYRXL.js → SystemPrompt-BYkkw-LL.js} +1 -1
  22. package/dist/assets/{TagLibrary-DGxI_3m3.js → TagLibrary-Kg3yOC5b.js} +1 -1
  23. package/dist/assets/{TiptapBody-BY-fFjwh.js → TiptapBody-BptXAVR9.js} +1 -1
  24. package/dist/assets/{Toggle--nwLR-_8.js → Toggle-BwiClCSI.js} +1 -1
  25. package/dist/assets/{index-CRPJslSA.css → index-D3P_jldk.css} +1 -1
  26. package/dist/assets/{index-4dEn1t8J.js → index-a2MVk8qh.js} +329 -329
  27. package/dist/assets/{settingsSchema-DiqEpSBA.js → settingsSchema-DgxlStF5.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +52 -3
  31. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +40 -0
  32. package/src/main/build-info.json +4 -4
  33. package/src/main/chatRunner.cjs +27 -4
  34. package/src/main/config.cjs +5 -0
  35. package/src/main/docEdit.cjs +3 -2
  36. package/src/main/index.cjs +2 -0
  37. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +58 -2
  38. package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +79 -0
  39. package/src/main/lib/__tests__/updateCheck.test.cjs +63 -0
  40. package/src/main/lib/epicSpawnCwd.cjs +22 -13
  41. package/src/main/lib/epicSpawnPlan.cjs +27 -12
  42. package/src/main/lib/epicTranscriptPath.cjs +3 -2
  43. package/src/main/lib/epicWorktreeMerge.cjs +4 -4
  44. package/src/main/lib/updateCheck.cjs +102 -0
  45. package/src/main/pty.cjs +19 -7
  46. package/src/preload/api.d.ts +2 -0
  47. package/src/preload/index.cjs +1 -0
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-4dEn1t8J.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-a2MVk8qh.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-CRPJslSA.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-D3P_jldk.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.91.1",
3
+ "version": "0.92.0",
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",
@@ -47,14 +47,14 @@ function attachRecorder() {
47
47
  // Stub claude: logs its argv (one JSON line per spawn) to logFile. Invocation 1
48
48
  // fails with `firstStderr` (optionally after emitting assistant text); later
49
49
  // invocations succeed.
50
- function writeStub(logFile, { firstStderr, textFirst }) {
50
+ function writeStub(logFile, { firstStderr, textFirst, alwaysFail }) {
51
51
  const stubPath = path.join(os.tmpdir(), `sm-flagretry-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
52
52
  const body = `
53
53
  const fs = require('fs');
54
54
  const log = ${JSON.stringify(logFile)};
55
55
  fs.appendFileSync(log, JSON.stringify(process.argv.slice(2)) + '\\n');
56
56
  const n = fs.readFileSync(log, 'utf8').trim().split('\\n').length;
57
- if (n === 1) {
57
+ if (n === 1 || ${alwaysFail ? 'true' : 'false'}) {
58
58
  ${textFirst ? "process.stdout.write(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'partial' }] } }) + '\\n');" : ''}
59
59
  process.stderr.write(${JSON.stringify(firstStderr)});
60
60
  process.exit(1);
@@ -130,7 +130,7 @@ test('no retry when the failed attempt already streamed assistant text', async (
130
130
  expect(spawns(logFile)).toHaveLength(1);
131
131
  expect(terminals()).toHaveLength(1);
132
132
  expect(terminals()[0].channel).toBe('chat:run:error');
133
- expect(terminals()[0].payload.message).toMatch(/already in use/);
133
+ expect(terminals()[0].payload.code).toBe('session_flag_exhausted');
134
134
  } finally {
135
135
  fs.rmSync(cwd, { recursive: true, force: true });
136
136
  }
@@ -157,3 +157,52 @@ test('"No conversation found" on --resume retries once with --session-id (main o
157
157
  fs.rmSync(cwd, { recursive: true, force: true });
158
158
  }
159
159
  });
160
+
161
+ test('spent swap retry (both flags rejected) emits actionable session_flag_exhausted error', async () => {
162
+ attachRecorder();
163
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flagretry-cwd-'));
164
+ const logFile = path.join(cwd, 'argv.log');
165
+ const sessionId = `flag-sess4-${process.pid}`;
166
+ process.env.SM_CLAUDE_BIN = writeStub(logFile, {
167
+ firstStderr: `Error: Session ID ${sessionId} is already in use.\n`,
168
+ alwaysFail: true,
169
+ });
170
+ try {
171
+ curTab = 'tab-d';
172
+ cr.run({ tabId: 'tab-d', sessionId, prompt: 'hello', cwd, resume: false });
173
+ await waitFor(() => terminals().length > 0);
174
+ await new Promise((r) => setTimeout(r, 100));
175
+
176
+ expect(spawns(logFile)).toHaveLength(2);
177
+ expect(terminals()).toHaveLength(1);
178
+ const { channel, payload } = terminals()[0];
179
+ expect(channel).toBe('chat:run:error');
180
+ expect(payload.code).toBe('session_flag_exhausted');
181
+ expect(payload.message).toContain(cwd);
182
+ expect(payload.message).toContain(`${sessionId}.jsonl`);
183
+ expect(payload.message).toContain('--resume');
184
+ expect(payload.message).toMatch(/App version: /);
185
+ expect(payload.message).toMatch(/update the app first/);
186
+ } finally {
187
+ fs.rmSync(cwd, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test('non-zero exit with unrelated stderr keeps the generic fallback message', async () => {
192
+ attachRecorder();
193
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-flagretry-cwd-'));
194
+ const logFile = path.join(cwd, 'argv.log');
195
+ const sessionId = `flag-sess5-${process.pid}`;
196
+ process.env.SM_CLAUDE_BIN = writeStub(logFile, { firstStderr: 'boom: something else broke\n' });
197
+ try {
198
+ curTab = 'tab-e';
199
+ cr.run({ tabId: 'tab-e', sessionId, prompt: 'hello', cwd, resume: false });
200
+ await waitFor(() => terminals().length > 0);
201
+ const { channel, payload } = terminals()[0];
202
+ expect(channel).toBe('chat:run:error');
203
+ expect(payload.code).toBeUndefined();
204
+ expect(payload.message).toMatch(/^process exited without a result event/);
205
+ } finally {
206
+ fs.rmSync(cwd, { recursive: true, force: true });
207
+ }
208
+ });
@@ -154,3 +154,43 @@ test("spawn() falls back to cwd unchanged when the Epic's worktree field is abse
154
154
  expect(capturedOpts.cwd).toBe(mainCwd);
155
155
  manager.kill(sessionId);
156
156
  });
157
+
158
+ test('spawn() opens a merged-worktree Epic in the project cwd (dead worktree dir is never used)', () => {
159
+ const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-merged-'));
160
+ const sessionId = 'epic-session-merged';
161
+ const deadDir = path.join(os.tmpdir(), 'sm-pty-dead-worktree-does-not-exist');
162
+ writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-m', claudeSessionId: sessionId, worktreeDir: deadDir });
163
+ const idxPath = path.join(mainCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
164
+ const idx = JSON.parse(fs.readFileSync(idxPath, 'utf8'));
165
+ idx.sessions['epic-m'].worktree.status = 'merged';
166
+ fs.writeFileSync(idxPath, JSON.stringify(idx));
167
+
168
+ let capturedOpts = null;
169
+ nodePty.spawn = (shell, args, opts) => {
170
+ capturedOpts = opts;
171
+ return fakeProc();
172
+ };
173
+ manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
174
+ expect(capturedOpts.cwd).toBe(mainCwd);
175
+ manager.kill(sessionId);
176
+ });
177
+
178
+ test('spawn() refuses a completed Epic without spawning and returns the plan message', () => {
179
+ const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-closed-'));
180
+ const sessionId = 'epic-session-closed';
181
+ writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-c', claudeSessionId: sessionId, worktreeDir: mainCwd });
182
+ const idxPath = path.join(mainCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
183
+ const idx = JSON.parse(fs.readFileSync(idxPath, 'utf8'));
184
+ idx.sessions['epic-c'].status = 'completed';
185
+ fs.writeFileSync(idxPath, JSON.stringify(idx));
186
+
187
+ let spawned = false;
188
+ nodePty.spawn = () => {
189
+ spawned = true;
190
+ return fakeProc();
191
+ };
192
+ const result = manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
193
+ expect(spawned).toBe(false);
194
+ expect(result.pid).toBeNull();
195
+ expect(result.error).toMatch(/closed/);
196
+ });
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.91.1",
3
- "gitSha": "4ee78cf25ac1c0f896177df4f7c13f5287ae647d",
4
- "gitShortSha": "4ee78cf",
2
+ "version": "0.92.0",
3
+ "gitSha": "d0e160e50a2560620e956e6d819cb405b0423110",
4
+ "gitShortSha": "d0e160e",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-20T20:06:12.676Z",
6
+ "builtAt": "2026-09-20T21:26:16.666Z",
7
7
  "dirty": false
8
8
  }
@@ -311,7 +311,7 @@ function getConcurrencyCap() {
311
311
  // after the executor is invoked (see run()/pump()) so cancel() callers can
312
312
  // await full teardown instead of firing SIGTERM and returning immediately.
313
313
  const inFlight = new Map();
314
- const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }]
314
+ const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult, onSilentError }]
315
315
  // tabIds picked by pump() but not yet registered in `inFlight` by executeRun.
316
316
  // Closes the microtask-wide window in which per-tab exclusivity would
317
317
  // otherwise not hold. See pump().
@@ -361,7 +361,7 @@ function broadcast(channel, payload) {
361
361
  * per-tab prompt queue in chat.ts only ever calls this once per tab at a
362
362
  * time, but the guard holds regardless).
363
363
  *
364
- * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, promptId?: string }} opts
364
+ * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, onSilentError?: (message: string, code?: string) => void, promptId?: string }} opts
365
365
  */
366
366
  function run(opts) {
367
367
  // Per-tab exclusivity still holds — two `claude -p --resume` against ONE
@@ -450,10 +450,10 @@ sessionSlots.subscribe(() => { try { pump(); } catch { /* defensive */ } });
450
450
  * for the run's lifetime so cancel() can reach it. Never rejects — the queue
451
451
  * pump relies on the returned promise always settling so the lane frees.
452
452
  *
453
- * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, promptId?: string }} opts
453
+ * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void, onSilentError?: (message: string, code?: string) => void, promptId?: string }} opts
454
454
  * @returns {Promise<void>}
455
455
  */
456
- function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult, promptId }) {
456
+ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult, onSilentError, promptId }) {
457
457
  return new Promise((resolve) => {
458
458
  const startedAt = Date.now();
459
459
  // Last few tool_use blocks seen on the stream, oldest first — surfaced in
@@ -553,6 +553,11 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
553
553
  const plan = planEpicSpawn({ cwd, claudeSessionId: sessionId, fallbackResume: !!resume });
554
554
  if (!plan.ok) {
555
555
  emitTerminal('chat:run:error', { tabId, sessionId, code: plan.code, message: plan.message });
556
+ // emitTerminal is a no-op broadcast for silent runs, so hand the refusal to the caller
557
+ // directly — otherwise it waits out its own timeout and reports a generic one.
558
+ if (silent && typeof onSilentError === 'function') {
559
+ try { onSilentError(plan.message, plan.code); } catch { /* caller bug must not wedge the lane */ }
560
+ }
556
561
  settle();
557
562
  return;
558
563
  }
@@ -846,6 +851,24 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
846
851
  sessionId,
847
852
  message: 'run cancelled',
848
853
  });
854
+ } else if (SESSION_IN_USE_RE.test(stderrBuffer) || NO_CONVERSATION_RE.test(stderrBuffer)) {
855
+ // A session-flag rejection that survived the one-shot swap (or was
856
+ // not eligible for it). The raw stderr dump names neither the flag,
857
+ // the cwd nor the transcript — a real report cost a full diagnostic
858
+ // session to trace to a stale app build, because flag resolution
859
+ // lives in main and older builds resolve it with older rules.
860
+ let appVersion = 'unknown';
861
+ try { appVersion = require('electron').app.getVersion() || 'unknown'; } catch { /* never fail the turn */ }
862
+ emitTerminal('chat:run:error', {
863
+ tabId,
864
+ sessionId,
865
+ code: 'session_flag_exhausted',
866
+ message: `The Claude CLI rejected the session flag (${resumeFlag ? '--resume' : '--session-id'} on this attempt; `
867
+ + `${attemptNo === 2 ? 'the one-shot swap retry with the other flag was already spent' : 'no swap retry was possible'}). `
868
+ + `Spawn cwd: ${execCwd}. Transcript: ${plan.transcriptPath ?? 'no transcript found on disk'}. `
869
+ + `App version: ${appVersion}. If this version is behind the published latest, update the app first — `
870
+ + 'the session-flag resolution logic lives in main, and a stale build resolves it with older rules.',
871
+ });
849
872
  } else {
850
873
  const errDetail = stderrBuffer.trim()
851
874
  ? `: ${stderrBuffer.trim().slice(0, 300)}`
@@ -129,6 +129,11 @@ function validateWrite(realAbs) {
129
129
  (p) => realAbs === p || realAbs.startsWith(p + path.sep)
130
130
  );
131
131
  if (inWritePrefix) return;
132
+ // The app's own userData dir (update-check.json cache).
133
+ try {
134
+ const ud = require('electron').app.getPath('userData');
135
+ if (realAbs === path.join(ud, 'update-check.json')) return;
136
+ } catch { /* electron app unavailable (unit tests) */ }
132
137
  // Also allowed inside a registered project root's .claude/ subtree.
133
138
  for (const root of allowedRoots) {
134
139
  if (root === os.homedir()) continue;
@@ -157,8 +157,8 @@ function broadcast(channel, payload) {
157
157
  } catch { /* render frame may be gone */ }
158
158
  }
159
159
 
160
- // `chatRunner.run()`'s silent lane only ever invokes `onSilentResult` on its
161
- // success path (`chatRunner.cjs`'s `emitTerminal` no-ops every OTHER terminal
160
+ // `chatRunner.run()`'s silent lane invokes `onSilentResult` on its success path and
161
+ // `onSilentError` when planEpicSpawn refuses the run (`chatRunner.cjs`'s `emitTerminal` no-ops every OTHER terminal
162
162
  // event — error/timeout/spawn-failure/cancel — for `silent` runs, since those
163
163
  // six broadcasts are deliberately suppressed for probes). `run()` is also a
164
164
  // silent no-op if the tabId already has an active/queued run (its per-tab
@@ -208,6 +208,7 @@ function docEditViaSession({ tabId, sessionId, cwd, before, instruction, documen
208
208
  prompt,
209
209
  resume: true,
210
210
  silent: true,
211
+ onSilentError: (message) => settleOnce({ ok: false, error: message }),
211
212
  onSilentResult: (text) => {
212
213
  // chatRunner.cjs prepends its own stop-signal protocol instruction to
213
214
  // every prompt (including this resumed one) — unlike the isolated
@@ -491,6 +491,8 @@ function createWindow() {
491
491
 
492
492
  ipcMain.handle('app:version', () => app.getVersion());
493
493
 
494
+ ipcMain.handle('app:update-status', () => require('./lib/updateCheck.cjs').getUpdateStatus());
495
+
494
496
  ipcMain.handle('app:home-dir', () => os.homedir());
495
497
 
496
498
  ipcMain.handle('app:cwd', () => process.cwd());
@@ -10,7 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- import { test, expect } from 'vitest';
13
+ import { test, expect, vi } from 'vitest';
14
14
  const { resolveEpicSpawnCwd } = require('../epicSpawnCwd.cjs');
15
15
 
16
16
  function stubReadActiveIndex(sessions) {
@@ -210,12 +210,68 @@ test('restore:true re-attaches the worktree and returns the recorded dir (transc
210
210
  expect(calls).toEqual([{ dir: '/tmp/session-manager-epic-worktrees/deadbeef/epic-1', branch: 'sm-epic/epic-1', baseCwd: '/projects/foo' }]);
211
211
  });
212
212
 
213
- test('restore:true with a failed restore still returns the recorded dir — never the project cwd', () => {
213
+ test('restore:true with a failed restore returns the project cwd — never a dead recorded dir', () => {
214
214
  const result = resolveEpicSpawnCwd({
215
215
  cwd: '/projects/foo',
216
216
  claudeSessionId: 'sess-1',
217
217
  deps: { readActiveIndex: stubReadActiveIndex(WORKTREE_SESSIONS), statSync: statGone, restore: true, restoreWorktree: () => false },
218
218
  });
219
+ expect(result).toBe('/projects/foo');
220
+ });
221
+
222
+ test('a merged Epic with a missing dir returns the project cwd and never attempts a restore', () => {
223
+ const spy = vi.fn(() => true);
224
+ for (const status of ['merged', 'disabled']) {
225
+ const result = resolveEpicSpawnCwd({
226
+ cwd: '/projects/foo',
227
+ claudeSessionId: 'sess-1',
228
+ deps: {
229
+ readActiveIndex: stubReadActiveIndex({
230
+ 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1', worktree: { dir: '/tmp/session-manager-epic-worktrees/deadbeef/epic-1', branch: 'sm-epic/epic-1', status } },
231
+ }),
232
+ statSync: statGone,
233
+ restore: true,
234
+ restoreWorktree: spy,
235
+ },
236
+ });
237
+ expect(result).toBe('/projects/foo');
238
+ }
239
+ expect(spy).not.toHaveBeenCalled();
240
+ });
241
+
242
+ test('an active Epic with a missing dir attempts a restore and returns the project cwd when it fails', () => {
243
+ const spy = vi.fn(() => false);
244
+ const result = resolveEpicSpawnCwd({
245
+ cwd: '/projects/foo',
246
+ claudeSessionId: 'sess-1',
247
+ deps: {
248
+ readActiveIndex: stubReadActiveIndex({
249
+ 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1', worktree: { dir: '/tmp/session-manager-epic-worktrees/deadbeef/epic-1', branch: 'sm-epic/epic-1', status: 'active' } },
250
+ }),
251
+ statSync: statGone,
252
+ restore: true,
253
+ restoreWorktree: spy,
254
+ },
255
+ });
256
+ expect(spy).toHaveBeenCalledTimes(1);
257
+ expect(result).toBe('/projects/foo');
258
+ });
259
+
260
+ test('a worktree record with no status key is restore-eligible and does not throw', () => {
261
+ const spy = vi.fn(() => true);
262
+ const result = resolveEpicSpawnCwd({
263
+ cwd: '/projects/foo',
264
+ claudeSessionId: 'sess-1',
265
+ deps: {
266
+ readActiveIndex: stubReadActiveIndex({
267
+ 'epic-1': { id: 'epic-1', claudeSessionId: 'sess-1', worktree: { dir: '/tmp/session-manager-epic-worktrees/deadbeef/epic-1', branch: 'sm-epic/epic-1' } },
268
+ }),
269
+ statSync: statGone,
270
+ restore: true,
271
+ restoreWorktree: spy,
272
+ },
273
+ });
274
+ expect(spy).toHaveBeenCalledTimes(1);
219
275
  expect(result).toBe('/tmp/session-manager-epic-worktrees/deadbeef/epic-1');
220
276
  });
221
277
 
@@ -74,6 +74,46 @@ test('transcript under the project encoding, no worktree => ok, resume from proj
74
74
  expect(plan).toMatchObject({ ok: true, useResume: true, execCwd: PROJECT });
75
75
  });
76
76
 
77
+ test('merged worktree (dir gone), transcript only under project encoding => ok, resume from project cwd (reported incident)', () => {
78
+ const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-realproj-'));
79
+ try {
80
+ const sid = SID;
81
+ const d = path.join(home, '.claude', 'projects', encodeCwd(proj));
82
+ fs.mkdirSync(d, { recursive: true });
83
+ fs.writeFileSync(path.join(d, `${sid}.jsonl`), '{"type":"user"}\n');
84
+ const merged = epic({ worktree: { dir: DEAD, branch: 'sm-epic/e1', status: 'merged' } });
85
+ const deps = { homeDir: home, readActiveIndex: () => ({ sessions: merged }) };
86
+ const first = planEpicSpawn({ cwd: proj, claudeSessionId: sid, deps });
87
+ expect(first).toMatchObject({ ok: true, execCwd: proj, useResume: true });
88
+ // No circuit-breaker entry from the fallback: a second call is also ok.
89
+ expect(planEpicSpawn({ cwd: proj, claudeSessionId: sid, deps })).toMatchObject({ ok: true, execCwd: proj, useResume: true });
90
+ } finally { fs.rmSync(proj, { recursive: true, force: true }); }
91
+ });
92
+
93
+ test('merged worktree, no transcript anywhere => ok, real directory, no resume', () => {
94
+ const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-realproj-'));
95
+ try {
96
+ const merged = epic({ worktree: { dir: DEAD, branch: 'sm-epic/e1', status: 'merged' } });
97
+ const plan = planEpicSpawn({ cwd: proj, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: merged }) } });
98
+ expect(plan.ok).toBe(true);
99
+ expect(plan.useResume).toBe(false);
100
+ expect(fs.statSync(plan.execCwd).isDirectory()).toBe(true);
101
+ } finally { fs.rmSync(proj, { recursive: true, force: true }); }
102
+ });
103
+
104
+ test('merged worktree, transcript only under dead encoding => refused, no git worktree add command', () => {
105
+ const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-realproj-'));
106
+ try {
107
+ transcriptUnder(DEAD);
108
+ const merged = epic({ worktree: { dir: DEAD, branch: 'sm-epic/e1', status: 'merged' } });
109
+ const plan = planEpicSpawn({ cwd: proj, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: merged }) } });
110
+ expect(plan).toMatchObject({ ok: false, code: 'session_unreachable' });
111
+ expect(plan.message).toMatch(/merged to main/);
112
+ expect(plan.message).not.toMatch(/git worktree add/);
113
+ expect(plan.message).not.toMatch(/swept from \/tmp/);
114
+ } finally { fs.rmSync(proj, { recursive: true, force: true }); }
115
+ });
116
+
77
117
  test('closed (completed) Epic is refused with its own message', () => {
78
118
  const plan = planEpicSpawn({ cwd: PROJECT, claudeSessionId: SID, deps: { homeDir: home, readActiveIndex: () => ({ sessions: epic({ status: 'completed' }) }) } });
79
119
  expect(plan).toMatchObject({ ok: false, code: 'epic_closed' });
@@ -115,3 +155,42 @@ test('chatRunner: dead-encoding transcript => zero CLI spawns, exactly one clear
115
155
  fs.rmSync(project, { recursive: true, force: true });
116
156
  }
117
157
  });
158
+
159
+ test('chatRunner: silent run refused by planEpicSpawn => onSilentError with the refusal, no broadcasts, no spawn', async () => {
160
+ const project = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-proj-'));
161
+ const prevHome = process.env.HOME;
162
+ process.env.HOME = home;
163
+ try {
164
+ const idxDir = path.join(project, 'session-manager-operations', 'prompt-sessions');
165
+ fs.mkdirSync(idxDir, { recursive: true });
166
+ fs.writeFileSync(path.join(idxDir, 'active-index.json'), JSON.stringify({ sessions: epic({ status: 'completed' }) }));
167
+ const cp = require('node:child_process');
168
+ const realSpawn = cp.spawn;
169
+ let spawns = 0;
170
+ cp.spawn = () => { spawns++; throw new Error('must not spawn'); };
171
+ const chatRunner = require('../../chatRunner.cjs');
172
+ chatRunner.__resetQueueForTests();
173
+ const sent = [];
174
+ chatRunner.attachWindow({ isDestroyed: () => false, webContents: { isDestroyed: () => false, send: (channel, payload) => sent.push({ channel, payload }) } });
175
+ const errors = [];
176
+ const results = [];
177
+ try {
178
+ chatRunner.run({
179
+ tabId: 'silent1', sessionId: SID, prompt: 'hi', cwd: project, resume: true, silent: true,
180
+ onSilentResult: (t) => results.push(t),
181
+ onSilentError: (message, code) => errors.push({ message, code }),
182
+ });
183
+ // no timer: delivery is a few microtasks after run()
184
+ await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
185
+ } finally { cp.spawn = realSpawn; }
186
+ expect(spawns).toBe(0);
187
+ expect(results).toEqual([]);
188
+ expect(errors.length).toBe(1);
189
+ expect(errors[0].code).toBe('epic_closed');
190
+ expect(errors[0].message).toMatch(/closed/i);
191
+ expect(sent.filter((e) => e.channel.startsWith('chat:run:'))).toEqual([]);
192
+ } finally {
193
+ process.env.HOME = prevHome;
194
+ fs.rmSync(project, { recursive: true, force: true });
195
+ }
196
+ });
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+ const { getUpdateStatus, isBehind } = require('../updateCheck.cjs');
7
+
8
+ function mk(over = {}) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'upd-'));
10
+ const fetchJson = vi.fn().mockResolvedValue({ latest: '0.91.1' });
11
+ const writeJson = vi.fn(async (f, data) => { fs.writeFileSync(f, JSON.stringify(data)); });
12
+ const deps = {
13
+ app: { getVersion: () => '0.89.0', getPath: () => dir },
14
+ env: {}, now: () => 1_000_000, packageName: () => 'pkg-x', fetchJson, writeJson, ...over,
15
+ };
16
+ return { dir, fetchJson, writeJson, deps, cache: path.join(dir, 'update-check.json') };
17
+ }
18
+
19
+ describe('getUpdateStatus', () => {
20
+ it('fresh fetch populates the cache and reports behind', async () => {
21
+ const t = mk();
22
+ expect(await getUpdateStatus({ deps: t.deps })).toEqual({ current: '0.89.0', latest: '0.91.1', behind: true });
23
+ expect(t.fetchJson).toHaveBeenCalledWith('https://registry.npmjs.org/-/package/pkg-x/dist-tags', 3000);
24
+ expect(JSON.parse(fs.readFileSync(t.cache, 'utf8'))).toEqual({ checkedAt: 1_000_000, latest: '0.91.1' });
25
+ });
26
+ it('cache hit inside 6h makes no network call', async () => {
27
+ const t = mk();
28
+ fs.writeFileSync(t.cache, JSON.stringify({ checkedAt: 1_000_000 - 1000, latest: '0.91.1' }));
29
+ expect((await getUpdateStatus({ deps: t.deps })).behind).toBe(true);
30
+ expect(t.fetchJson).not.toHaveBeenCalled();
31
+ });
32
+ it('expired cache refetches', async () => {
33
+ const t = mk({ now: () => 1_000_000 + 6 * 3600_000 + 1 });
34
+ fs.writeFileSync(t.cache, JSON.stringify({ checkedAt: 1_000_000, latest: '0.90.0' }));
35
+ expect((await getUpdateStatus({ deps: t.deps })).latest).toBe('0.91.1');
36
+ expect(t.fetchJson).toHaveBeenCalledTimes(1);
37
+ });
38
+ it('corrupt cache is a miss', async () => {
39
+ const t = mk();
40
+ fs.writeFileSync(t.cache, '{nope');
41
+ expect((await getUpdateStatus({ deps: t.deps })).latest).toBe('0.91.1');
42
+ });
43
+ it('offline falls back to latest null', async () => {
44
+ const t = mk({ fetchJson: vi.fn().mockRejectedValue(new Error('ENOTFOUND')) });
45
+ expect(await getUpdateStatus({ deps: t.deps })).toEqual({ current: '0.89.0', latest: null, behind: false });
46
+ });
47
+ it('SM_UPDATE_CHECK=0 and E2E short-circuit with no network call', async () => {
48
+ for (const env of [{ SM_UPDATE_CHECK: '0' }, { SM_E2E: '1' }]) {
49
+ const t = mk({ env });
50
+ expect(await getUpdateStatus({ deps: t.deps })).toEqual({ current: '0.89.0', latest: null, behind: false });
51
+ expect(t.fetchJson).not.toHaveBeenCalled();
52
+ }
53
+ });
54
+ });
55
+
56
+ describe('isBehind', () => {
57
+ it('compares numeric triples', () => {
58
+ expect(isBehind('0.89.0', '0.91.1')).toBe(true);
59
+ expect(isBehind('0.91.1', '0.91.1')).toBe(false);
60
+ expect(isBehind('0.100.0', '0.91.1')).toBe(false);
61
+ expect(isBehind('0.92.0', '0.91.1')).toBe(false);
62
+ });
63
+ });
@@ -85,8 +85,9 @@ function restoreEpicWorktree({ dir, branch, baseCwd }) {
85
85
 
86
86
  /**
87
87
  * @param {{ cwd: string, claudeSessionId: string, deps?: object }} opts
88
- * @returns {string} `worktree.dir` when the matching Epic has one AND it still
89
- * exists on disk, else `cwd` unchanged.
88
+ * @returns {string} `worktree.dir` when it exists (or was just restored), else `cwd`.
89
+ * Never a nonexistent path. The transcript-split hazard is guarded by
90
+ * epicSpawnPlan.cjs's reachability check, not by returning a dead path.
90
91
  */
91
92
  function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
92
93
  if (!cwd || !claudeSessionId) return cwd;
@@ -99,19 +100,27 @@ function resolveEpicSpawnCwd({ cwd, claudeSessionId, deps = {} } = {}) {
99
100
  const dir = session.worktree?.dir;
100
101
  if (!dir) return cwd;
101
102
  if (!isUsableDir(dir, statSync)) {
102
- // NEVER fall back to the project cwd: the CLI files the transcript under
103
- // encodeCwd(spawn cwd), so a resumed session started in a different dir splits
104
- // its transcript across two encodings ("lost session"). Instead re-attach the
105
- // Epic's own branch at the SAME recorded path (encoding unchanged); if that is
106
- // impossible, return the recorded dir anyway so the spawn fails loudly (ENOENT)
107
- // rather than silently forking the transcript. Read-side callers (no `restore`)
108
- // keep the old cwd answer — they never spawn and must never mutate git state.
109
- if (deps.restore) {
103
+ // The checkout is gone. A merged/disabled Epic's branch was deleted with it
104
+ // (epicWorktreeMerge.cjs cleans up with keepBranch:false), so re-attaching can
105
+ // never succeed — skip the two git subprocesses. Otherwise (spawn side only)
106
+ // re-attach the Epic's branch at the SAME recorded path so the transcript
107
+ // encoding is unchanged. Read-side callers (no `restore`) never mutate git state.
108
+ const st = session.worktree?.status;
109
+ const restorable = st !== 'merged' && st !== 'disabled';
110
+ if (deps.restore && restorable) {
110
111
  const restored = (deps.restoreWorktree || restoreEpicWorktree)({ dir, branch: session.worktree?.branch, baseCwd: cwd });
111
- console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — ${restored ? 'restored at same path' : 'restore failed; spawn will fail rather than split the transcript'}`);
112
- return dir;
112
+ if (restored) {
113
+ console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — restored at same path`);
114
+ return dir;
115
+ }
116
+ console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — restore failed; falling back to ${cwd}`);
117
+ } else if (deps.restore) {
118
+ console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — checkout was removed on ${st}; restore correctly skipped, falling back to ${cwd}`);
119
+ } else {
120
+ console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — read-side falls back to ${cwd}`);
113
121
  }
114
- console.log(`[epicSpawnCwd] worktree dir gone (${dir}) — read-side falls back to ${cwd}`);
122
+ // Always a REAL directory; epicSpawnPlan.cjs refuses when the transcript is not
123
+ // visible from this cwd, so this cannot silently fork a transcript.
115
124
  return cwd;
116
125
  }
117
126
  return dir;
@@ -8,11 +8,13 @@
8
8
  * conversation found"), the flag-swap retry then hit `--session-id` ("already in use"), and both
9
9
  * doors were locked.
10
10
  *
11
- * Chosen fix: (a) first — resolveEpicSpawnCwd({restore:true}) re-attaches the Epic's own branch
12
- * at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again. When that
13
- * is impossible, (b): refuse BEFORE spawning with an actionable message naming the missing
14
- * worktree path, never claim a flag that cannot work. Reachability = "some existing transcript
15
- * lives under encodeCwd(spawn cwd)" and the spawn cwd is a real directory.
11
+ * Three-way decision: (1) re-attach — resolveEpicSpawnCwd({restore:true}) re-attaches the Epic's
12
+ * own branch at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again;
13
+ * (2) project-cwd fallback — when that spawn cwd cannot see the transcript but the project cwd can
14
+ * (transcript filed under encodeCwd(project cwd)), spawn from the project cwd; nothing forks since
15
+ * it writes to the directory it already occupies; (3) refuse BEFORE spawning with a message stating
16
+ * the real cause, never claim a flag that cannot work. Reachable = "some existing transcript lives
17
+ * under encodeCwd(candidate cwd)" and the candidate is a real directory.
16
18
  *
17
19
  * Also refuses requests routed to a closed (`completed`) Epic, and keeps a per-session circuit
18
20
  * breaker: once a session is found unreachable, later requests are refused after ONE stat (is the
@@ -95,19 +97,32 @@ function planEpicSpawn({ cwd, claudeSessionId, fallbackResume = false, deps = {}
95
97
  useResume = !!resolved.existsAnywhere;
96
98
  } catch { /* keep the caller-supplied flag */ }
97
99
 
98
- const execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
100
+ let execCwd = (deps.resolveSpawnCwd || resolveEpicSpawnCwd)({ cwd, claudeSessionId, deps: { restore: true, readActiveIndex: deps.readActiveIndex, statSync: deps.statSync, restoreWorktree: deps.restoreWorktree } });
99
101
 
100
102
  if (resolved && resolved.existsAnywhere) {
101
103
  const projectsDir = path.join(deps.homeDir || os.homedir(), '.claude', 'projects');
102
- const home = path.join(projectsDir, encodeCwd(execCwd));
103
- const reachable = isDir(execCwd, statSync) && resolved.existingPaths.some((p) => path.dirname(p) === home);
104
- if (!reachable) {
104
+ // O(candidates × existingPaths); both are tiny. Candidate order: chosen execCwd, then project cwd.
105
+ const findReachable = (dir) => {
106
+ if (!isDir(dir, statSync)) return null;
107
+ const home = path.join(projectsDir, encodeCwd(dir));
108
+ return resolved.existingPaths.find((p) => path.dirname(p) === home) || null;
109
+ };
110
+ let hit = findReachable(execCwd);
111
+ if (!hit && execCwd !== cwd) {
112
+ // The transcript may already live under the project encoding; spawning there writes back to
113
+ // the directory it occupies, so nothing forks.
114
+ hit = findReachable(cwd);
115
+ if (hit) execCwd = cwd;
116
+ }
117
+ if (!hit) {
105
118
  const missing = brokenDir || execCwd;
119
+ const wt = epic?.worktree;
120
+ const recovery = wt?.status === 'merged'
121
+ ? `That checkout was removed when the Epic was merged to main and its ${wt.branch || 'sm-epic/*'} branch was deleted, so it cannot be recreated. Start a new Epic.`
122
+ : `The checkout is gone and could not be re-attached (it may have been swept from its temp/state directory). Recreate it (git worktree add "${missing}" ${wt?.branch || '<sm-epic branch>'}) or start a new Epic.`;
106
123
  const message =
107
124
  `Session ${claudeSessionId} cannot be resumed: its transcript is at ${resolved.existingPaths[0]}, ` +
108
- `which the CLI only finds when run from ${missing}, and that worktree no longer exists and could not be ` +
109
- `re-attached (likely swept from /tmp). Recreate it (git worktree add "${missing}" ${epic?.worktree?.branch || '<sm-epic branch>'}) ` +
110
- `or start a new Epic.`;
125
+ `which the CLI only finds when run from ${missing}. ${recovery}`;
111
126
  unreachable.set(claudeSessionId, { dir: missing, message });
112
127
  return { ok: false, code: 'session_unreachable', message };
113
128
  }
@@ -5,8 +5,9 @@
5
5
  * transcript actually live?".
6
6
  *
7
7
  * The CLI writes `~/.claude/projects/<encodeCwd(spawn cwd)>/<id>.jsonl`, and an
8
- * Epic's spawn cwd is its isolated worktree dir (chatRunner.cjs via
9
- * resolveEpicSpawnCwd), NOT the project cwd. Turn 1 can still land under the
8
+ * Epic's spawn cwd is normally its isolated worktree dir (chatRunner.cjs and
9
+ * pty.cjs via epicSpawnPlan.cjs), falling back to the project cwd when the
10
+ * worktree is merged/gone. Turn 1 can still land under the
10
11
  * project encoding (the worktree mint is fire-and-forget), and the worktree dir
11
12
  * itself is routinely swept from /tmp while its transcript survives under
12
13
  * ~/.claude/projects. So candidates are built from the recorded `worktree.dir`