claude-code-session-manager 0.91.0 → 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.
- package/dist/assets/{AgentLibrary-zkgdnCnG.js → AgentLibrary-Bu-1eT_n.js} +1 -1
- package/dist/assets/{DataModel-CNFdqxrR.js → DataModel-Bk9rkJ8x.js} +1 -1
- package/dist/assets/{History-C-qijfXz.js → History-BB6wpf02.js} +1 -1
- package/dist/assets/{Hooks-DGxt1nBd.js → Hooks-zQZH56Sv.js} +1 -1
- package/dist/assets/{HostBilko-sIYTK-m9.js → HostBilko-CW0pHoPS.js} +1 -1
- package/dist/assets/{Library-51Jx0oGl.js → Library-DrGdaz9N.js} +1 -1
- package/dist/assets/{ListDetail-ByNJU-ie.js → ListDetail-D4bxU_-2.js} +1 -1
- package/dist/assets/{MarkdownEditor-Wwcpe9O1.js → MarkdownEditor-H1ytN4Tk.js} +1 -1
- package/dist/assets/{McpServers-Bs5xPbTG.js → McpServers-CYSRCI-j.js} +1 -1
- package/dist/assets/{Memory-BsHGInmd.js → Memory-zMNpJyRU.js} +1 -1
- package/dist/assets/{Panel-ilEDcV0m.js → Panel-RMz3Acpu.js} +1 -1
- package/dist/assets/{Permissions-Djgc3TFk.js → Permissions-Be6cIkPg.js} +1 -1
- package/dist/assets/{Plugins-BBojr1mt.js → Plugins-B8sTWf2K.js} +2 -2
- package/dist/assets/{ProvenanceBadge-Cz1VLnXb.js → ProvenanceBadge-CO-DZN38.js} +1 -1
- package/dist/assets/{SaveBar-Cj0wz84u.js → SaveBar-xQZ2EBOn.js} +1 -1
- package/dist/assets/{Scheduler-D4Ekm93R.js → Scheduler-CF3zsJBw.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DA5MqWli.js → ScopeSwitcher-BAfquN6Y.js} +1 -1
- package/dist/assets/{Settings-B1Kfgjvl.js → Settings-uPjmqutA.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Dew-1uMy.js → SkillReferenceGraph-BI1YtCbB.js} +1 -1
- package/dist/assets/{Skills-DzuhnXy9.js → Skills-BEy9FDYd.js} +1 -1
- package/dist/assets/{SystemPrompt-CNcCwEev.js → SystemPrompt-BYkkw-LL.js} +1 -1
- package/dist/assets/{TagLibrary-D0plTZgP.js → TagLibrary-Kg3yOC5b.js} +1 -1
- package/dist/assets/{TiptapBody-BlmUt7SR.js → TiptapBody-BptXAVR9.js} +1 -1
- package/dist/assets/{Toggle-yTfx2R-e.js → Toggle-BwiClCSI.js} +1 -1
- package/dist/assets/{index-CRPJslSA.css → index-D3P_jldk.css} +1 -1
- package/dist/assets/{index-ju-h7BFf.js → index-a2MVk8qh.js} +329 -329
- package/dist/assets/{settingsSchema-BkcnLBYk.js → settingsSchema-DgxlStF5.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +52 -3
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +40 -0
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +27 -4
- package/src/main/config.cjs +5 -0
- package/src/main/docEdit.cjs +3 -2
- package/src/main/index.cjs +2 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +58 -2
- package/src/main/lib/__tests__/epicSpawnPlan.test.cjs +79 -0
- package/src/main/lib/__tests__/updateCheck.test.cjs +63 -0
- package/src/main/lib/epicSpawnCwd.cjs +22 -13
- package/src/main/lib/epicSpawnPlan.cjs +27 -12
- package/src/main/lib/epicTranscriptPath.cjs +3 -2
- package/src/main/lib/epicWorktreeMerge.cjs +4 -4
- package/src/main/lib/updateCheck.cjs +102 -0
- package/src/main/pty.cjs +19 -7
- package/src/preload/api.d.ts +2 -0
- 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-
|
|
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-
|
|
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.
|
|
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.
|
|
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
|
+
});
|
package/src/main/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitShortSha": "
|
|
2
|
+
"version": "0.92.0",
|
|
3
|
+
"gitSha": "d0e160e50a2560620e956e6d819cb405b0423110",
|
|
4
|
+
"gitShortSha": "d0e160e",
|
|
5
5
|
"gitBranch": "HEAD",
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-20T21:26:16.666Z",
|
|
7
7
|
"dirty": false
|
|
8
8
|
}
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -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)}`
|
package/src/main/config.cjs
CHANGED
|
@@ -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;
|
package/src/main/docEdit.cjs
CHANGED
|
@@ -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
|
|
161
|
-
//
|
|
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
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
|
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
|
|
89
|
-
*
|
|
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
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
// Epic's
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
12
|
-
* at the SAME recorded path (encoding unchanged), so the CLI sees the transcript again
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
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}
|
|
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
|
|
9
|
-
*
|
|
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`
|