claude-code-session-manager 0.71.0 → 0.73.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-B1lh6YEM.js → AgentLibrary-BowrgaXK.js} +1 -1
- package/dist/assets/{History-B1jNdKFk.js → History-B8rJXcxk.js} +1 -1
- package/dist/assets/{Hooks-BWHFkF7z.js → Hooks-D-KzXjDk.js} +1 -1
- package/dist/assets/{HostBilko-CYQt0b-t.js → HostBilko-BSJhBE9I.js} +1 -1
- package/dist/assets/{Library-DZVKlOBQ.js → Library-CtqgqUc6.js} +1 -1
- package/dist/assets/{ListDetail-rBF1ZQv8.js → ListDetail-D7k4TXvO.js} +1 -1
- package/dist/assets/{MarkdownEditor-zS0QmHxL.js → MarkdownEditor-DFm0I9B_.js} +1 -1
- package/dist/assets/{McpServers-BA54nZee.js → McpServers-DDK04FMq.js} +1 -1
- package/dist/assets/{Memory-DN97rjG5.js → Memory-B1p59Mas.js} +1 -1
- package/dist/assets/{Panel-DLn9L0Cv.js → Panel-BJpqcDzp.js} +1 -1
- package/dist/assets/{Permissions-BnDRq-EM.js → Permissions-CFC3RusZ.js} +1 -1
- package/dist/assets/{Plugins-aPC4a4Bi.js → Plugins-TeCQMr6X.js} +2 -2
- package/dist/assets/{ProvenanceBadge-Bc1gskID.js → ProvenanceBadge-BfIzSRrY.js} +1 -1
- package/dist/assets/{SaveBar-CjvWkRaQ.js → SaveBar-7SmNcZB-.js} +1 -1
- package/dist/assets/{Scheduler-86oyi_P2.js → Scheduler-B9ovEniK.js} +1 -1
- package/dist/assets/{ScopeSwitcher-DJvnhSk3.js → ScopeSwitcher-KmemfTLa.js} +1 -1
- package/dist/assets/Settings-CxZijd66.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CZjMRk3z.js → SkillReferenceGraph-BhACzgW7.js} +1 -1
- package/dist/assets/{Skills-EueUDa9X.js → Skills-CFZU6ltE.js} +1 -1
- package/dist/assets/{SystemPrompt-BQQudmyk.js → SystemPrompt-3tHpyoge.js} +1 -1
- package/dist/assets/{TagLibrary-BEAqaJlp.js → TagLibrary-BoJHe-aY.js} +1 -1
- package/dist/assets/{TiptapBody-DKmVDGB5.js → TiptapBody-DzlxVXaw.js} +1 -1
- package/dist/assets/{Toggle-B7Qb5e2O.js → Toggle-DKmoif0X.js} +1 -1
- package/dist/assets/{index-l-hKv9ou.js → index-Dtv0whpT.js} +671 -671
- package/dist/assets/{index-CNmekpRK.css → index-N_v_4fqC.css} +1 -1
- package/dist/assets/{settingsSchema-DEW4x9LG.js → settingsSchema-CW-0QfXi.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/scripts/lib/activeSessions.cjs +10 -1
- package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +125 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +156 -0
- package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +65 -0
- package/src/main/__tests__/stop-signal-anchor.test.cjs +75 -0
- package/src/main/chatRunner.cjs +11 -1
- package/src/main/index.cjs +12 -6
- package/src/main/ipcSchemas.cjs +35 -0
- package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +193 -0
- package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +136 -0
- package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +130 -0
- package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +115 -0
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +79 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +400 -0
- package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +126 -0
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +82 -0
- package/src/main/lib/epicSpawnCwd.cjs +95 -0
- package/src/main/lib/epicWorktreeBoot.cjs +49 -0
- package/src/main/lib/epicWorktreeMerge.cjs +63 -0
- package/src/main/lib/epicWorktreeMint.cjs +54 -0
- package/src/main/lib/epicWorktreeProjectConfig.cjs +102 -0
- package/src/main/lib/gitWorktree.cjs +446 -0
- package/src/main/lib/jobWorktree.cjs +36 -293
- package/src/main/lib/promptSessionSchema.cjs +15 -0
- package/src/main/lib/queueStore.cjs +18 -0
- package/src/main/lib/terminalRunOutcome.cjs +14 -2
- package/src/main/pty.cjs +11 -1
- package/src/main/queueOps.cjs +40 -12
- package/src/main/scheduler.cjs +14 -0
- package/src/preload/api.d.ts +59 -0
- package/src/preload/index.cjs +22 -0
- package/dist/assets/Settings-B7_1p_xA.js +0 -3
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chatRunner-epic-worktree-execcwd.test.cjs — PRD 1033, LOAD-BEARING
|
|
3
|
+
* acceptance criterion: an Epic-backed headless `claude -p` run must spawn
|
|
4
|
+
* with its `cwd` spawn option pointed at the Epic's isolated worktree dir
|
|
5
|
+
* when one is recorded, while `session-manager-operations/` for that Epic's
|
|
6
|
+
* project — the ops root — is never touched under the worktree dir. Mirrors
|
|
7
|
+
* scheduler-worktree-exec-cwd.test.cjs's stub-`claude`-binary pattern
|
|
8
|
+
* (SM_CLAUDE_BIN), which chatRunner.cjs's resolveClaudeBin also honors.
|
|
9
|
+
*
|
|
10
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
|
|
20
|
+
let tmpHome;
|
|
21
|
+
let originalHome;
|
|
22
|
+
let cr;
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
originalHome = process.env.HOME;
|
|
26
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chatrunner-execcwd-home-'));
|
|
27
|
+
process.env.HOME = tmpHome;
|
|
28
|
+
cr = require('../chatRunner.cjs');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
process.env.HOME = originalHome;
|
|
33
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
38
|
+
cr.__resetQueueForTests();
|
|
39
|
+
cr.attachWindow(null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Writes a stub `claude` binary that records ITS OWN process.cwd() to a
|
|
43
|
+
// marker file (proving where it actually ran), then emits a minimal
|
|
44
|
+
// stream-json success result and exits 0 — same pattern as
|
|
45
|
+
// scheduler-worktree-exec-cwd.test.cjs's writeClaudeStub.
|
|
46
|
+
function writeClaudeStub() {
|
|
47
|
+
const stubPath = path.join(os.tmpdir(), `sm-chatrunner-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
|
|
48
|
+
const body = `
|
|
49
|
+
const fs = require('fs');
|
|
50
|
+
const path = require('path');
|
|
51
|
+
fs.writeFileSync(path.join(process.cwd(), 'ran-here.marker'), 'yes', 'utf8');
|
|
52
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok' }) + '\\n');
|
|
53
|
+
process.exit(0);
|
|
54
|
+
`;
|
|
55
|
+
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
56
|
+
return stubPath;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeActiveIndexWithWorktree(cwd, { epicId, claudeSessionId, worktreeDir }) {
|
|
60
|
+
const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
62
|
+
const session = {
|
|
63
|
+
id: epicId,
|
|
64
|
+
cwd,
|
|
65
|
+
goalText: 'test epic',
|
|
66
|
+
claudeSessionId,
|
|
67
|
+
status: 'active',
|
|
68
|
+
createdAt: new Date(0).toISOString(),
|
|
69
|
+
completedAt: null,
|
|
70
|
+
worktree: { dir: worktreeDir, branch: `sm-epic/${epicId}`, baseCwd: cwd, status: 'active' },
|
|
71
|
+
};
|
|
72
|
+
fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions: { [epicId]: session }, events: {} }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function waitForFile(filePath, timeoutMs = 5000) {
|
|
76
|
+
const start = Date.now();
|
|
77
|
+
while (!fs.existsSync(filePath)) {
|
|
78
|
+
if (Date.now() - start > timeoutMs) throw new Error(`timed out waiting for ${filePath}`);
|
|
79
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
test('executeRun spawns the claude child in the Epic worktree dir, while the project ops-root is never touched under it', async () => {
|
|
84
|
+
const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chatrunner-main-'));
|
|
85
|
+
const worktreeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chatrunner-worktree-'));
|
|
86
|
+
const sessionId = `epic-sess-${process.pid}`;
|
|
87
|
+
writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-1', claudeSessionId: sessionId, worktreeDir: worktreeCwd });
|
|
88
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
cr.run({ tabId: 'tab-1', sessionId, prompt: 'hello', cwd: mainCwd, resume: false });
|
|
92
|
+
await waitForFile(path.join(worktreeCwd, 'ran-here.marker'));
|
|
93
|
+
|
|
94
|
+
// 1. The child process really ran with cwd = the Epic's worktree dir.
|
|
95
|
+
expect(fs.existsSync(path.join(worktreeCwd, 'ran-here.marker'))).toBe(true);
|
|
96
|
+
expect(fs.existsSync(path.join(mainCwd, 'ran-here.marker'))).toBe(false);
|
|
97
|
+
|
|
98
|
+
// 2. CRITICAL invariant: session-manager-operations/ for this Epic's
|
|
99
|
+
// project must never be touched under the worktree dir — only the real
|
|
100
|
+
// project cwd, which still holds exactly the active-index.json this
|
|
101
|
+
// test wrote.
|
|
102
|
+
expect(fs.existsSync(path.join(worktreeCwd, 'session-manager-operations'))).toBe(false);
|
|
103
|
+
const index = JSON.parse(
|
|
104
|
+
fs.readFileSync(path.join(mainCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json'), 'utf8'),
|
|
105
|
+
);
|
|
106
|
+
expect(index.sessions['epic-1'].claudeSessionId).toBe(sessionId);
|
|
107
|
+
} finally {
|
|
108
|
+
fs.rmSync(mainCwd, { recursive: true, force: true });
|
|
109
|
+
fs.rmSync(worktreeCwd, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('executeRun falls back to cwd unchanged when the Epic has no worktree recorded (unchanged behaviour)', async () => {
|
|
114
|
+
const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-chatrunner-plain-'));
|
|
115
|
+
const sessionId = `plain-sess-${process.pid}`;
|
|
116
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
cr.run({ tabId: 'tab-2', sessionId, prompt: 'hello', cwd: mainCwd, resume: false });
|
|
120
|
+
await waitForFile(path.join(mainCwd, 'ran-here.marker'));
|
|
121
|
+
expect(fs.existsSync(path.join(mainCwd, 'ran-here.marker'))).toBe(true);
|
|
122
|
+
} finally {
|
|
123
|
+
fs.rmSync(mainCwd, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pty-epic-worktree-spawn-cwd.test.cjs — PRD 1033, LOAD-BEARING acceptance
|
|
3
|
+
* criterion: an Epic-attached Terminal tab's PTY must actually launch its
|
|
4
|
+
* shell in the Epic's isolated worktree dir when one is recorded, while
|
|
5
|
+
* `cwd` itself (home-boundary validation, opsErrorLog attribution, the
|
|
6
|
+
* returned/tracked cwd) stays the real project cwd — see epicSpawnCwd.cjs's
|
|
7
|
+
* "ops-root hazard" header comment.
|
|
8
|
+
*
|
|
9
|
+
* Monkey-patches node-pty's own `spawn` export (a plain mutable property on
|
|
10
|
+
* its shared, cached module object) to capture the real spawn options
|
|
11
|
+
* without launching an actual shell — no existing test in this repo mocks a
|
|
12
|
+
* module import, so this stays a direct property override rather than
|
|
13
|
+
* introducing a new vi.mock pattern.
|
|
14
|
+
*
|
|
15
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
let tmpHome;
|
|
26
|
+
let originalHome;
|
|
27
|
+
let manager;
|
|
28
|
+
let nodePty;
|
|
29
|
+
let originalSpawn;
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
originalHome = process.env.HOME;
|
|
33
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-pty-execcwd-home-'));
|
|
34
|
+
process.env.HOME = tmpHome;
|
|
35
|
+
({ manager } = require('../pty.cjs'));
|
|
36
|
+
nodePty = require('node-pty');
|
|
37
|
+
originalSpawn = nodePty.spawn;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterAll(() => {
|
|
41
|
+
nodePty.spawn = originalSpawn;
|
|
42
|
+
process.env.HOME = originalHome;
|
|
43
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
nodePty.spawn = originalSpawn;
|
|
48
|
+
manager.sessions.clear();
|
|
49
|
+
manager.buffers.clear();
|
|
50
|
+
manager.killed.clear();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function fakeProc() {
|
|
54
|
+
return {
|
|
55
|
+
pid: 4242,
|
|
56
|
+
exitCode: null,
|
|
57
|
+
onData: () => {},
|
|
58
|
+
onExit: () => {},
|
|
59
|
+
resize: () => {},
|
|
60
|
+
kill: () => {},
|
|
61
|
+
write: () => {},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeActiveIndexWithWorktree(cwd, { epicId, claudeSessionId, worktreeDir }) {
|
|
66
|
+
const dir = path.join(cwd, 'session-manager-operations', 'prompt-sessions');
|
|
67
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
68
|
+
const session = {
|
|
69
|
+
id: epicId,
|
|
70
|
+
cwd,
|
|
71
|
+
goalText: 'test epic',
|
|
72
|
+
claudeSessionId,
|
|
73
|
+
status: 'active',
|
|
74
|
+
createdAt: new Date(0).toISOString(),
|
|
75
|
+
completedAt: null,
|
|
76
|
+
worktree: { dir: worktreeDir, branch: `sm-epic/${epicId}`, baseCwd: cwd, status: 'active' },
|
|
77
|
+
};
|
|
78
|
+
fs.writeFileSync(path.join(dir, 'active-index.json'), JSON.stringify({ sessions: { [epicId]: session }, events: {} }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
test('spawn() launches the shell at the Epic worktree dir when one is recorded, while cwd itself stays the project cwd', () => {
|
|
82
|
+
const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-main-'));
|
|
83
|
+
const worktreeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-pty-worktree-'));
|
|
84
|
+
const sessionId = 'epic-session-1';
|
|
85
|
+
writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-1', claudeSessionId: sessionId, worktreeDir: worktreeCwd });
|
|
86
|
+
|
|
87
|
+
let capturedOpts = null;
|
|
88
|
+
nodePty.spawn = (shell, args, opts) => {
|
|
89
|
+
capturedOpts = opts;
|
|
90
|
+
return fakeProc();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const result = manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
|
|
94
|
+
|
|
95
|
+
expect(capturedOpts).not.toBeNull();
|
|
96
|
+
// The ACTUAL spawn cwd is the worktree dir.
|
|
97
|
+
expect(capturedOpts.cwd).toBe(worktreeCwd);
|
|
98
|
+
// CRITICAL invariant: cwd used for the home-boundary check, tracked
|
|
99
|
+
// session bookkeeping, and the value handed back to the renderer is the
|
|
100
|
+
// real project cwd, never the worktree dir.
|
|
101
|
+
expect(result.cwd).toBe(mainCwd);
|
|
102
|
+
expect(manager.sessions.get(sessionId).cwd).toBe(mainCwd);
|
|
103
|
+
|
|
104
|
+
manager.kill(sessionId);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('spawn() falls back to cwd unchanged when the tab has no matching Epic worktree', () => {
|
|
108
|
+
const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-plain-'));
|
|
109
|
+
const tabId = 'plain-tab-1';
|
|
110
|
+
|
|
111
|
+
let capturedOpts = null;
|
|
112
|
+
nodePty.spawn = (shell, args, opts) => {
|
|
113
|
+
capturedOpts = opts;
|
|
114
|
+
return fakeProc();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
manager.spawn({ tabId, cwd: mainCwd, cols: 80, rows: 24 });
|
|
118
|
+
|
|
119
|
+
expect(capturedOpts.cwd).toBe(mainCwd);
|
|
120
|
+
manager.kill(tabId);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("spawn() falls back to cwd unchanged when the Epic's worktree field is absent from the index", () => {
|
|
124
|
+
const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-noworktree-'));
|
|
125
|
+
const sessionId = 'epic-session-2';
|
|
126
|
+
const dir = path.join(mainCwd, 'session-manager-operations', 'prompt-sessions');
|
|
127
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
128
|
+
fs.writeFileSync(
|
|
129
|
+
path.join(dir, 'active-index.json'),
|
|
130
|
+
JSON.stringify({
|
|
131
|
+
sessions: {
|
|
132
|
+
'epic-2': {
|
|
133
|
+
id: 'epic-2',
|
|
134
|
+
cwd: mainCwd,
|
|
135
|
+
goalText: 'x',
|
|
136
|
+
claudeSessionId: sessionId,
|
|
137
|
+
status: 'active',
|
|
138
|
+
createdAt: new Date(0).toISOString(),
|
|
139
|
+
completedAt: null,
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
events: {},
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
let capturedOpts = null;
|
|
147
|
+
nodePty.spawn = (shell, args, opts) => {
|
|
148
|
+
capturedOpts = opts;
|
|
149
|
+
return fakeProc();
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
|
|
153
|
+
|
|
154
|
+
expect(capturedOpts.cwd).toBe(mainCwd);
|
|
155
|
+
manager.kill(sessionId);
|
|
156
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* queueOpsArchiveDestination.test.cjs — an archived PRD lands beside its OWN
|
|
3
|
+
* source, not in the machine-level scheduled-plans tree.
|
|
4
|
+
*
|
|
5
|
+
* PRD sources are per-project and per-Epic (CLAUDE.md's TAB → EPIC → PRD
|
|
6
|
+
* model): `<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds/`.
|
|
7
|
+
* `~/.claude/session-manager/scheduled-plans/` holds only run logs and
|
|
8
|
+
* PRD_AUTHORING.md. archiveOne resolved its SOURCE per-project (findPrdDir)
|
|
9
|
+
* but hardcoded its DESTINATION to the global PRDS_ARCHIVE_DIR, so a manual
|
|
10
|
+
* archive yanked a PRD out of its Epic and stranded it in the retired global
|
|
11
|
+
* tree — splitting one Epic's history across two locations.
|
|
12
|
+
*
|
|
13
|
+
* Observed 2026-08-13: archiving 1035-epic-worktree-ui-surfacing put it under
|
|
14
|
+
* scheduled-plans/prds-archived/ while its three chain siblings (1032/1033/
|
|
15
|
+
* 1034, archived by scheduler.cjs's own on-completion mover) sat correctly in
|
|
16
|
+
* the Epic's prds-archived/. Two movers, two destinations, same operation.
|
|
17
|
+
*
|
|
18
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/queueOpsArchiveDestination.test.cjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
import { test, expect } from 'vitest';
|
|
24
|
+
const path = require('node:path');
|
|
25
|
+
const { archiveDirForSource, PRDS_DIR, PRDS_ARCHIVE_DIR } = require('../queueOps.cjs');
|
|
26
|
+
|
|
27
|
+
const EPIC_PRDS = '/home/u/Projects/proj/session-manager-operations/scheduler/epics/my-epic-abc123/prds';
|
|
28
|
+
const TS = '2026-08-14T05-03-41-156Z';
|
|
29
|
+
|
|
30
|
+
test('an Epic PRD archives into that Epic\'s own prds-archived/, beside its prds/', () => {
|
|
31
|
+
const dir = archiveDirForSource(EPIC_PRDS, TS);
|
|
32
|
+
expect(dir).toBe(path.join(path.dirname(EPIC_PRDS), 'prds-archived', TS));
|
|
33
|
+
// Concretely: same Epic dir, sibling of prds/.
|
|
34
|
+
expect(dir).toBe(
|
|
35
|
+
`/home/u/Projects/proj/session-manager-operations/scheduler/epics/my-epic-abc123/prds-archived/${TS}`,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('an Epic PRD never lands under the machine-level scheduled-plans tree', () => {
|
|
40
|
+
const dir = archiveDirForSource(EPIC_PRDS, TS);
|
|
41
|
+
expect(dir.startsWith(PRDS_ARCHIVE_DIR)).toBe(false);
|
|
42
|
+
expect(dir).not.toContain('.claude/session-manager/scheduled-plans');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('the archived PRD stays inside its own project — the ops root never changes', () => {
|
|
46
|
+
const dir = archiveDirForSource(EPIC_PRDS, TS);
|
|
47
|
+
expect(dir.startsWith('/home/u/Projects/proj/session-manager-operations/')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('two Epics in the same project archive to their own separate dirs', () => {
|
|
51
|
+
const other = '/home/u/Projects/proj/session-manager-operations/scheduler/epics/other-epic-def456/prds';
|
|
52
|
+
expect(archiveDirForSource(EPIC_PRDS, TS)).not.toBe(archiveDirForSource(other, TS));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a legacy flat PRD still archives to the global prds-archived/ — old behavior unchanged', () => {
|
|
56
|
+
const dir = archiveDirForSource(PRDS_DIR, TS);
|
|
57
|
+
expect(dir).toBe(path.join(PRDS_ARCHIVE_DIR, TS));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('the batch timestamp is shared, so one archive call reads as one event', () => {
|
|
61
|
+
const a = archiveDirForSource(EPIC_PRDS, TS);
|
|
62
|
+
const b = archiveDirForSource('/home/u/Projects/other/session-manager-operations/scheduler/epics/e/prds', TS);
|
|
63
|
+
expect(path.basename(a)).toBe(TS);
|
|
64
|
+
expect(path.basename(b)).toBe(TS);
|
|
65
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stop-signal-anchor.test.cjs — drift guard between chatRunner's
|
|
3
|
+
* STOP_SENTINEL/splitStopSignal and the renderer's duplicated copy.
|
|
4
|
+
*
|
|
5
|
+
* `src/renderer/lib/stopSignal.ts` cannot import this main-process `.cjs`
|
|
6
|
+
* (CommonJS main / ESM renderer split), so it carries its own literal
|
|
7
|
+
* `STOP_SENTINEL` + a re-implementation of `splitStopSignal`. This test
|
|
8
|
+
* requires the REAL exported constant and asserts the renderer's copy is
|
|
9
|
+
* byte-identical, and that both splitters agree on `body` for the same
|
|
10
|
+
* composed sample — so a rework of either constant/function fails here
|
|
11
|
+
* loudly instead of the two forms of a stop-signal reply silently drifting
|
|
12
|
+
* back out of sync (which is exactly what caused the doubled-message bug
|
|
13
|
+
* this module exists to fix).
|
|
14
|
+
*
|
|
15
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/stop-signal-anchor.test.cjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const path = require('path');
|
|
21
|
+
|
|
22
|
+
import { test, expect } from 'vitest';
|
|
23
|
+
|
|
24
|
+
const { STOP_SENTINEL, splitStopSignal } = require('../chatRunner.cjs');
|
|
25
|
+
|
|
26
|
+
async function loadRendererCopy() {
|
|
27
|
+
const mod = await import(
|
|
28
|
+
/* @vite-ignore */ path.resolve(__dirname, '..', '..', 'renderer', 'lib', 'stopSignal.ts')
|
|
29
|
+
);
|
|
30
|
+
return mod;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('the renderer STOP_SENTINEL literal is byte-identical to chatRunner.cjs', async () => {
|
|
34
|
+
const rendererCopy = await loadRendererCopy();
|
|
35
|
+
expect(rendererCopy.STOP_SENTINEL).toBe(STOP_SENTINEL);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('both splitters agree on body for a composed stop-signal reply', async () => {
|
|
39
|
+
const rendererCopy = await loadRendererCopy();
|
|
40
|
+
const body = 'Here is what I found and what I still need from you.';
|
|
41
|
+
const full = `${body}\n\n${STOP_SENTINEL}\n${JSON.stringify({ questions: ['Deploy to prod now?'] })}`;
|
|
42
|
+
|
|
43
|
+
const mainSplit = splitStopSignal(full);
|
|
44
|
+
const rendererSplit = rendererCopy.splitStopSignal(full);
|
|
45
|
+
|
|
46
|
+
expect(mainSplit).not.toBeNull();
|
|
47
|
+
expect(rendererSplit).not.toBeNull();
|
|
48
|
+
expect(rendererSplit.body).toBe(mainSplit.answerBody);
|
|
49
|
+
expect(rendererSplit.body).toBe(body);
|
|
50
|
+
expect(rendererSplit.questions ?? mainSplit.questions).toBeDefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('both splitters return null for text with no sentinel', async () => {
|
|
54
|
+
const rendererCopy = await loadRendererCopy();
|
|
55
|
+
const plain = 'Just a normal reply, no protocol block.';
|
|
56
|
+
expect(splitStopSignal(plain)).toBeNull();
|
|
57
|
+
expect(rendererCopy.splitStopSignal(plain)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('both splitters refuse a quoted sentinel with no valid JSON tail', async () => {
|
|
61
|
+
const rendererCopy = await loadRendererCopy();
|
|
62
|
+
const quoting = `Why does chat use ${STOP_SENTINEL} internally? Not real JSON after it.`;
|
|
63
|
+
expect(splitStopSignal(quoting)).toBeNull();
|
|
64
|
+
expect(rendererCopy.splitStopSignal(quoting)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('both splitters anchor on the LAST sentinel occurrence', async () => {
|
|
68
|
+
const rendererCopy = await loadRendererCopy();
|
|
69
|
+
const body = 'first mention of the sentinel text, then the real one';
|
|
70
|
+
const full = `${STOP_SENTINEL} mentioned earlier in prose.\n\n${body}\n\n${STOP_SENTINEL}\n${JSON.stringify({ questions: ['q'] })}`;
|
|
71
|
+
|
|
72
|
+
const mainSplit = splitStopSignal(full);
|
|
73
|
+
const rendererSplit = rendererCopy.splitStopSignal(full);
|
|
74
|
+
expect(mainSplit.answerBody).toBe(rendererSplit.body);
|
|
75
|
+
});
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -53,6 +53,7 @@ const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
|
|
|
53
53
|
const sessionSlots = require('./lib/sessionSlots.cjs');
|
|
54
54
|
const opsErrorLog = require('./lib/opsErrorLog.cjs');
|
|
55
55
|
const agentModelResolve = require('./lib/agentModelResolve.cjs');
|
|
56
|
+
const { resolveEpicSpawnCwd } = require('./lib/epicSpawnCwd.cjs');
|
|
56
57
|
|
|
57
58
|
// ─── Stop-signal protocol ──────────────────────────────────────────────────
|
|
58
59
|
// Single source of truth for the sentinel and parser. The renderer (PRD 320)
|
|
@@ -548,13 +549,22 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
548
549
|
|
|
549
550
|
if (!silent) broadcast('chat:run:started', { tabId, sessionId });
|
|
550
551
|
|
|
552
|
+
// sessionId IS the Epic's claudeSessionId for an Epic-backed tab (see
|
|
553
|
+
// comment above childEnv) — resolves to that Epic's isolated worktree
|
|
554
|
+
// dir when one exists (PRD 1033), else `cwd` unchanged. `cwd` itself is
|
|
555
|
+
// NEVER repointed: every ops-root/model/exchange resolution above and
|
|
556
|
+
// below keeps using it exactly as before — only this ACTUAL spawn cwd
|
|
557
|
+
// option does. See epicSpawnCwd.cjs's header comment (the ops-root
|
|
558
|
+
// hazard) and jobWorktree.cjs's own (execCwd vs job.cwd) for why.
|
|
559
|
+
const execCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: sessionId });
|
|
560
|
+
|
|
551
561
|
// Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
|
|
552
562
|
// "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
|
|
553
563
|
// real-time NDJSON streaming; stderr piped for error-message capture.
|
|
554
564
|
let child;
|
|
555
565
|
try {
|
|
556
566
|
child = spawn(claudeBin, args, {
|
|
557
|
-
cwd,
|
|
567
|
+
cwd: execCwd,
|
|
558
568
|
env: childEnv,
|
|
559
569
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
560
570
|
detached: true, // own process group so killTree can SIGTERM descendants
|
package/src/main/index.cjs
CHANGED
|
@@ -32,6 +32,9 @@ const chatRunner = require('./chatRunner.cjs');
|
|
|
32
32
|
const promptSessionEvents = require('./promptSessionEvents.cjs');
|
|
33
33
|
const activeIndexMerge = require('./lib/activeIndexMerge.cjs');
|
|
34
34
|
const promptSessionsCreateEpic = require('./lib/promptSessionsCreateEpic.cjs');
|
|
35
|
+
const epicWorktreeMint = require('./lib/epicWorktreeMint.cjs');
|
|
36
|
+
const epicWorktreeMerge = require('./lib/epicWorktreeMerge.cjs');
|
|
37
|
+
const epicWorktreeProjectConfig = require('./lib/epicWorktreeProjectConfig.cjs');
|
|
35
38
|
const agentLibrary = require('./agentLibrary.cjs');
|
|
36
39
|
const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
|
|
37
40
|
const { resolveBuildTarget } = require('./lib/buildTarget.cjs');
|
|
@@ -784,6 +787,9 @@ pluginInstall.registerPluginInstallHandlers();
|
|
|
784
787
|
memoryTool.registerMemoryHandlers();
|
|
785
788
|
activeIndexMerge.registerActiveIndexMergeHandlers();
|
|
786
789
|
promptSessionsCreateEpic.registerPromptSessionsCreateEpicHandlers();
|
|
790
|
+
epicWorktreeMint.registerEpicWorktreeMintHandlers();
|
|
791
|
+
epicWorktreeMerge.registerEpicWorktreeMergeHandlers();
|
|
792
|
+
epicWorktreeProjectConfig.registerEpicWorktreeProjectConfigHandlers();
|
|
787
793
|
registerMemoryAggregateIpc();
|
|
788
794
|
registerProjectBriefIpc();
|
|
789
795
|
registerProjectPagesIpc();
|
|
@@ -1057,7 +1063,7 @@ app.whenReady().then(async () => {
|
|
|
1057
1063
|
label: 'Session Manager',
|
|
1058
1064
|
submenu: [
|
|
1059
1065
|
{
|
|
1060
|
-
label: '
|
|
1066
|
+
label: 'Open / Start Project…',
|
|
1061
1067
|
accelerator: 'CmdOrCtrl+N',
|
|
1062
1068
|
click: () => {
|
|
1063
1069
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
@@ -1066,12 +1072,12 @@ app.whenReady().then(async () => {
|
|
|
1066
1072
|
},
|
|
1067
1073
|
},
|
|
1068
1074
|
{
|
|
1069
|
-
label: 'Restart App',
|
|
1075
|
+
label: 'Restart Session Manager App',
|
|
1070
1076
|
accelerator: 'CmdOrCtrl+Shift+R',
|
|
1071
1077
|
click: rebootApp,
|
|
1072
1078
|
},
|
|
1073
1079
|
{
|
|
1074
|
-
label: '
|
|
1080
|
+
label: 'Restart Terminal in Active Tab',
|
|
1075
1081
|
accelerator: 'CmdOrCtrl+Shift+S',
|
|
1076
1082
|
click: () => {
|
|
1077
1083
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
@@ -1080,14 +1086,14 @@ app.whenReady().then(async () => {
|
|
|
1080
1086
|
},
|
|
1081
1087
|
},
|
|
1082
1088
|
{ type: 'separator' },
|
|
1083
|
-
{ label: 'Quit', accelerator: 'CmdOrCtrl+Q', role: 'quit' },
|
|
1089
|
+
{ label: 'Quit Session Manager', accelerator: 'CmdOrCtrl+Q', role: 'quit' },
|
|
1084
1090
|
],
|
|
1085
1091
|
},
|
|
1086
1092
|
{
|
|
1087
1093
|
label: 'Dev',
|
|
1088
1094
|
submenu: [
|
|
1089
|
-
{ label: 'Toggle
|
|
1090
|
-
{ label: 'Reload', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
|
1095
|
+
{ label: 'Toggle Developer Tools', accelerator: 'F12', role: 'toggleDevTools' },
|
|
1096
|
+
{ label: 'Reload App Window', accelerator: 'CmdOrCtrl+R', role: 'reload' },
|
|
1091
1097
|
// Diagnostic-only; absent unless SM_HEAP_SNAPSHOT=1 (see heapSnapshot.cjs).
|
|
1092
1098
|
...(heapSnapshotMenuItem ? [heapSnapshotMenuItem] : []),
|
|
1093
1099
|
],
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -203,6 +203,37 @@ const promptSessionsCreateEpic = z.object({
|
|
|
203
203
|
sections: z.array(EpicIntakeSectionSchema).max(200).optional(),
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
// Renderer entry point for creating an Epic's isolated git worktree
|
|
207
|
+
// (lib/epicWorktreeMint.cjs) at the proposed->active transition — PRD 1033.
|
|
208
|
+
const promptSessionsCreateWorktree = z.object({
|
|
209
|
+
cwd: z.string().min(1).max(4096),
|
|
210
|
+
epicId: z.string().regex(PROMPT_SESSION_ID_RE),
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Renderer entry point for folding an Epic's isolated git worktree branch
|
|
214
|
+
// back into its owning project's main tree (lib/epicWorktreeMerge.cjs) — PRD
|
|
215
|
+
// 1034, the ONLY point where the per-Epic isolation PRD 1033 wires resolves.
|
|
216
|
+
// `branch`/`dir` are supplied by the caller (the renderer's own in-memory
|
|
217
|
+
// `session.worktree`) rather than re-derived from active-index.json, since
|
|
218
|
+
// markCompleted calls this while the Epic's row is about to leave that index
|
|
219
|
+
// entirely for its archive.
|
|
220
|
+
const promptSessionsMergeToMain = z.object({
|
|
221
|
+
cwd: z.string().min(1).max(4096),
|
|
222
|
+
epicId: z.string().regex(PROMPT_SESSION_ID_RE),
|
|
223
|
+
branch: z.string().regex(/^sm-epic\/[A-Za-z0-9_-]{1,64}$/),
|
|
224
|
+
dir: z.string().min(1).max(4096),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Renderer entry points for the per-project "disable Epic worktree
|
|
228
|
+
// isolation" UI toggle (lib/epicWorktreeProjectConfig.cjs) — PRD 1035.
|
|
229
|
+
const promptSessionsGetWorktreeDisabled = z.object({
|
|
230
|
+
cwd: z.string().min(1).max(4096),
|
|
231
|
+
});
|
|
232
|
+
const promptSessionsSetWorktreeDisabled = z.object({
|
|
233
|
+
cwd: z.string().min(1).max(4096),
|
|
234
|
+
disabled: z.boolean(),
|
|
235
|
+
});
|
|
236
|
+
|
|
206
237
|
// ──────────────────────────────────────────── Sessions
|
|
207
238
|
const sessionsPayload = z.object({
|
|
208
239
|
tabs: z.array(z.object({
|
|
@@ -909,6 +940,10 @@ module.exports = {
|
|
|
909
940
|
promptSessionTranscriptRead,
|
|
910
941
|
promptSessionsMergeActiveIndex,
|
|
911
942
|
promptSessionsCreateEpic,
|
|
943
|
+
promptSessionsCreateWorktree,
|
|
944
|
+
promptSessionsMergeToMain,
|
|
945
|
+
promptSessionsGetWorktreeDisabled,
|
|
946
|
+
promptSessionsSetWorktreeDisabled,
|
|
912
947
|
auditLogAppend,
|
|
913
948
|
agentMemoryList,
|
|
914
949
|
agentMemoryGet,
|