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.
Files changed (60) hide show
  1. package/dist/assets/{AgentLibrary-B1lh6YEM.js → AgentLibrary-BowrgaXK.js} +1 -1
  2. package/dist/assets/{History-B1jNdKFk.js → History-B8rJXcxk.js} +1 -1
  3. package/dist/assets/{Hooks-BWHFkF7z.js → Hooks-D-KzXjDk.js} +1 -1
  4. package/dist/assets/{HostBilko-CYQt0b-t.js → HostBilko-BSJhBE9I.js} +1 -1
  5. package/dist/assets/{Library-DZVKlOBQ.js → Library-CtqgqUc6.js} +1 -1
  6. package/dist/assets/{ListDetail-rBF1ZQv8.js → ListDetail-D7k4TXvO.js} +1 -1
  7. package/dist/assets/{MarkdownEditor-zS0QmHxL.js → MarkdownEditor-DFm0I9B_.js} +1 -1
  8. package/dist/assets/{McpServers-BA54nZee.js → McpServers-DDK04FMq.js} +1 -1
  9. package/dist/assets/{Memory-DN97rjG5.js → Memory-B1p59Mas.js} +1 -1
  10. package/dist/assets/{Panel-DLn9L0Cv.js → Panel-BJpqcDzp.js} +1 -1
  11. package/dist/assets/{Permissions-BnDRq-EM.js → Permissions-CFC3RusZ.js} +1 -1
  12. package/dist/assets/{Plugins-aPC4a4Bi.js → Plugins-TeCQMr6X.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-Bc1gskID.js → ProvenanceBadge-BfIzSRrY.js} +1 -1
  14. package/dist/assets/{SaveBar-CjvWkRaQ.js → SaveBar-7SmNcZB-.js} +1 -1
  15. package/dist/assets/{Scheduler-86oyi_P2.js → Scheduler-B9ovEniK.js} +1 -1
  16. package/dist/assets/{ScopeSwitcher-DJvnhSk3.js → ScopeSwitcher-KmemfTLa.js} +1 -1
  17. package/dist/assets/Settings-CxZijd66.js +3 -0
  18. package/dist/assets/{SkillReferenceGraph-CZjMRk3z.js → SkillReferenceGraph-BhACzgW7.js} +1 -1
  19. package/dist/assets/{Skills-EueUDa9X.js → Skills-CFZU6ltE.js} +1 -1
  20. package/dist/assets/{SystemPrompt-BQQudmyk.js → SystemPrompt-3tHpyoge.js} +1 -1
  21. package/dist/assets/{TagLibrary-BEAqaJlp.js → TagLibrary-BoJHe-aY.js} +1 -1
  22. package/dist/assets/{TiptapBody-DKmVDGB5.js → TiptapBody-DzlxVXaw.js} +1 -1
  23. package/dist/assets/{Toggle-B7Qb5e2O.js → Toggle-DKmoif0X.js} +1 -1
  24. package/dist/assets/{index-l-hKv9ou.js → index-Dtv0whpT.js} +671 -671
  25. package/dist/assets/{index-CNmekpRK.css → index-N_v_4fqC.css} +1 -1
  26. package/dist/assets/{settingsSchema-DEW4x9LG.js → settingsSchema-CW-0QfXi.js} +1 -1
  27. package/dist/index.html +2 -2
  28. package/package.json +1 -1
  29. package/scripts/lib/activeSessions.cjs +10 -1
  30. package/src/main/__tests__/chatRunner-epic-worktree-execcwd.test.cjs +125 -0
  31. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +156 -0
  32. package/src/main/__tests__/queueOpsArchiveDestination.test.cjs +65 -0
  33. package/src/main/__tests__/stop-signal-anchor.test.cjs +75 -0
  34. package/src/main/chatRunner.cjs +11 -1
  35. package/src/main/index.cjs +12 -6
  36. package/src/main/ipcSchemas.cjs +35 -0
  37. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +193 -0
  38. package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +136 -0
  39. package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +130 -0
  40. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +115 -0
  41. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +79 -0
  42. package/src/main/lib/__tests__/gitWorktree.test.cjs +400 -0
  43. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +126 -0
  44. package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +82 -0
  45. package/src/main/lib/epicSpawnCwd.cjs +95 -0
  46. package/src/main/lib/epicWorktreeBoot.cjs +49 -0
  47. package/src/main/lib/epicWorktreeMerge.cjs +63 -0
  48. package/src/main/lib/epicWorktreeMint.cjs +54 -0
  49. package/src/main/lib/epicWorktreeProjectConfig.cjs +102 -0
  50. package/src/main/lib/gitWorktree.cjs +446 -0
  51. package/src/main/lib/jobWorktree.cjs +36 -293
  52. package/src/main/lib/promptSessionSchema.cjs +15 -0
  53. package/src/main/lib/queueStore.cjs +18 -0
  54. package/src/main/lib/terminalRunOutcome.cjs +14 -2
  55. package/src/main/pty.cjs +11 -1
  56. package/src/main/queueOps.cjs +40 -12
  57. package/src/main/scheduler.cjs +14 -0
  58. package/src/preload/api.d.ts +59 -0
  59. package/src/preload/index.cjs +22 -0
  60. 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
+ });
@@ -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
@@ -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: 'New Session',
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: 'Reboot Session',
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 DevTools', accelerator: 'F12', role: 'toggleDevTools' },
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
  ],
@@ -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,