claude-code-session-manager 0.91.1 → 0.92.1
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-Chmfxih8.js → AgentLibrary-Ci6u03bq.js} +1 -1
- package/dist/assets/{DataModel-Cp38ePS_.js → DataModel-BIqcjsv2.js} +1 -1
- package/dist/assets/{History-SMWV7lD6.js → History-BOR_fJNr.js} +1 -1
- package/dist/assets/{Hooks-JUWiyu-r.js → Hooks-BtD436iL.js} +1 -1
- package/dist/assets/{HostBilko-DFTixTqy.js → HostBilko-CMe5cH3H.js} +1 -1
- package/dist/assets/{Library-zUu71gsR.js → Library-DeKdY-tD.js} +1 -1
- package/dist/assets/{ListDetail-BK_PH4lf.js → ListDetail-CtVjrHKB.js} +1 -1
- package/dist/assets/{MarkdownEditor-ITJDD0Mr.js → MarkdownEditor-CbzC-Oqf.js} +1 -1
- package/dist/assets/{McpServers-CWwrfGkx.js → McpServers-DKN75Shn.js} +1 -1
- package/dist/assets/{Memory-DgHttor9.js → Memory-B68AJjg2.js} +1 -1
- package/dist/assets/{Panel-C2Cb7TS4.js → Panel-C2YTW-qP.js} +1 -1
- package/dist/assets/{Permissions-hd8OBsj_.js → Permissions-Cr0RRvNy.js} +1 -1
- package/dist/assets/{Plugins-BYa-ClGK.js → Plugins-DbZkJs5l.js} +2 -2
- package/dist/assets/{ProvenanceBadge-Dmhz8ZOB.js → ProvenanceBadge-kJ__BOQv.js} +1 -1
- package/dist/assets/{SaveBar-1NvfbO9r.js → SaveBar-87ZJfscX.js} +1 -1
- package/dist/assets/{Scheduler-SYgINPCB.js → Scheduler-DdEaxku8.js} +1 -1
- package/dist/assets/{ScopeSwitcher-MBmWNzFk.js → ScopeSwitcher-CKzpqjJ_.js} +1 -1
- package/dist/assets/{Settings-C1WOHoYb.js → Settings-CjywVsNB.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-Cuse__ul.js → SkillReferenceGraph-D_-wg4r9.js} +1 -1
- package/dist/assets/{Skills-CQmLYgNh.js → Skills-Cd_yPO_l.js} +1 -1
- package/dist/assets/{SystemPrompt-DkhAYRXL.js → SystemPrompt-2z6eGPSF.js} +1 -1
- package/dist/assets/{TagLibrary-DGxI_3m3.js → TagLibrary-DcaWQSp4.js} +1 -1
- package/dist/assets/{TiptapBody-BY-fFjwh.js → TiptapBody-DYXIp8aG.js} +1 -1
- package/dist/assets/{Toggle--nwLR-_8.js → Toggle-CDXR5l3m.js} +1 -1
- package/dist/assets/{index-CRPJslSA.css → index-D3P_jldk.css} +1 -1
- package/dist/assets/{index-4dEn1t8J.js → index-D5H_H5wC.js} +329 -329
- package/dist/assets/{settingsSchema-DiqEpSBA.js → settingsSchema-JRDfjgHb.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 +105 -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 +32 -6
- 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-D5H_H5wC.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.1",
|
|
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,108 @@ 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
|
+
function setEpic(mainCwd, epicId, patch) {
|
|
179
|
+
const idxPath = path.join(mainCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
|
|
180
|
+
const idx = JSON.parse(fs.readFileSync(idxPath, 'utf8'));
|
|
181
|
+
const rec = idx.sessions[epicId];
|
|
182
|
+
if (patch.status) rec.status = patch.status;
|
|
183
|
+
if (patch.worktreeStatus) rec.worktree.status = patch.worktreeStatus;
|
|
184
|
+
fs.writeFileSync(idxPath, JSON.stringify(idx));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function captureSpawn() {
|
|
188
|
+
const out = { opts: null, count: 0 };
|
|
189
|
+
nodePty.spawn = (shell, args, opts) => {
|
|
190
|
+
out.opts = opts;
|
|
191
|
+
out.count += 1;
|
|
192
|
+
return fakeProc();
|
|
193
|
+
};
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function fakeWindow(sent) {
|
|
198
|
+
return {
|
|
199
|
+
isDestroyed: () => false,
|
|
200
|
+
webContents: { isDestroyed: () => false, isCrashed: () => false, send: (channel, payload) => sent.push({ channel, payload }) },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
test('spawn() opens a completed Epic (merged worktree) as a shell in the project cwd with an informational line', () => {
|
|
205
|
+
const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-closed-'));
|
|
206
|
+
const sessionId = 'epic-session-closed';
|
|
207
|
+
const deadDir = path.join(os.tmpdir(), 'sm-pty-closed-dead-worktree');
|
|
208
|
+
writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-c', claudeSessionId: sessionId, worktreeDir: deadDir });
|
|
209
|
+
setEpic(mainCwd, 'epic-c', { status: 'completed', worktreeStatus: 'merged' });
|
|
210
|
+
|
|
211
|
+
const cap = captureSpawn();
|
|
212
|
+
const sent = [];
|
|
213
|
+
manager.window = fakeWindow(sent);
|
|
214
|
+
try {
|
|
215
|
+
const result = manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
|
|
216
|
+
expect(cap.count).toBe(1);
|
|
217
|
+
expect(cap.opts.cwd).toBe(mainCwd);
|
|
218
|
+
expect(result.pid).toBe(4242);
|
|
219
|
+
expect(result.error).toBeUndefined();
|
|
220
|
+
const info = sent.find((m) => m.channel === `pty:data:${sessionId}`);
|
|
221
|
+
expect(info.payload).toMatch(/Epic is completed/);
|
|
222
|
+
expect(info.payload).toContain(mainCwd);
|
|
223
|
+
} finally {
|
|
224
|
+
manager.window = null;
|
|
225
|
+
manager.kill(sessionId);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('spawn() opens a completed Epic whose worktree dir still exists in that worktree dir', () => {
|
|
230
|
+
const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-closed-live-'));
|
|
231
|
+
const worktreeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-pty-closed-live-wt-'));
|
|
232
|
+
const sessionId = 'epic-session-closed-live';
|
|
233
|
+
writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-cl', claudeSessionId: sessionId, worktreeDir: worktreeCwd });
|
|
234
|
+
setEpic(mainCwd, 'epic-cl', { status: 'completed' });
|
|
235
|
+
|
|
236
|
+
const cap = captureSpawn();
|
|
237
|
+
manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
|
|
238
|
+
expect(cap.count).toBe(1);
|
|
239
|
+
expect(cap.opts.cwd).toBe(worktreeCwd);
|
|
240
|
+
manager.kill(sessionId);
|
|
241
|
+
fs.rmSync(worktreeCwd, { recursive: true, force: true });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('spawn() still refuses session_unreachable without any node-pty spawn', () => {
|
|
245
|
+
const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-unreachable-'));
|
|
246
|
+
const sessionId = 'epic-session-unreachable';
|
|
247
|
+
const deadDir = path.join(os.tmpdir(), 'sm-pty-unreachable-dead-worktree');
|
|
248
|
+
writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-u', claudeSessionId: sessionId, worktreeDir: deadDir });
|
|
249
|
+
setEpic(mainCwd, 'epic-u', { worktreeStatus: 'merged' });
|
|
250
|
+
// Transcript exists only under the dead worktree's encoded project dir.
|
|
251
|
+
const enc = deadDir.replace(/[^a-zA-Z0-9]/g, '-');
|
|
252
|
+
const tdir = path.join(tmpHome, '.claude', 'projects', enc);
|
|
253
|
+
fs.mkdirSync(tdir, { recursive: true });
|
|
254
|
+
fs.writeFileSync(path.join(tdir, `${sessionId}.jsonl`), '{"type":"user"}\n');
|
|
255
|
+
|
|
256
|
+
const cap = captureSpawn();
|
|
257
|
+
const result = manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
|
|
258
|
+
expect(cap.count).toBe(0);
|
|
259
|
+
expect(result.pid).toBeNull();
|
|
260
|
+
expect(result.error).toMatch(/cannot be resumed/);
|
|
261
|
+
});
|
package/src/main/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitShortSha": "
|
|
2
|
+
"version": "0.92.1",
|
|
3
|
+
"gitSha": "cc3a80b2b9933e408e8effca99e550e3409b4d42",
|
|
4
|
+
"gitShortSha": "cc3a80b",
|
|
5
5
|
"gitBranch": "HEAD",
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-20T22:38:10.510Z",
|
|
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;
|