claude-code-session-manager 0.72.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 (59) hide show
  1. package/dist/assets/{AgentLibrary-CXw7Z5Iw.js → AgentLibrary-BowrgaXK.js} +1 -1
  2. package/dist/assets/{History-D64U_aDf.js → History-B8rJXcxk.js} +1 -1
  3. package/dist/assets/{Hooks-BFVkM6QH.js → Hooks-D-KzXjDk.js} +1 -1
  4. package/dist/assets/{HostBilko-BRERoCra.js → HostBilko-BSJhBE9I.js} +1 -1
  5. package/dist/assets/{Library-C6shVsjv.js → Library-CtqgqUc6.js} +1 -1
  6. package/dist/assets/{ListDetail-dXWSZuDV.js → ListDetail-D7k4TXvO.js} +1 -1
  7. package/dist/assets/{MarkdownEditor-D_akyPJ2.js → MarkdownEditor-DFm0I9B_.js} +1 -1
  8. package/dist/assets/{McpServers-DxROkYGa.js → McpServers-DDK04FMq.js} +1 -1
  9. package/dist/assets/{Memory-OqUyTRrr.js → Memory-B1p59Mas.js} +1 -1
  10. package/dist/assets/{Panel-Biw98xRO.js → Panel-BJpqcDzp.js} +1 -1
  11. package/dist/assets/{Permissions-CIdZOzK8.js → Permissions-CFC3RusZ.js} +1 -1
  12. package/dist/assets/{Plugins-Dt147wEE.js → Plugins-TeCQMr6X.js} +2 -2
  13. package/dist/assets/{ProvenanceBadge-BUikIHf4.js → ProvenanceBadge-BfIzSRrY.js} +1 -1
  14. package/dist/assets/{SaveBar-BoHRDHlp.js → SaveBar-7SmNcZB-.js} +1 -1
  15. package/dist/assets/{Scheduler-CipmAcy7.js → Scheduler-B9ovEniK.js} +1 -1
  16. package/dist/assets/{ScopeSwitcher-CkCwUYU5.js → ScopeSwitcher-KmemfTLa.js} +1 -1
  17. package/dist/assets/Settings-CxZijd66.js +3 -0
  18. package/dist/assets/{SkillReferenceGraph-BuSuA3B7.js → SkillReferenceGraph-BhACzgW7.js} +1 -1
  19. package/dist/assets/{Skills-BM8w5BGV.js → Skills-CFZU6ltE.js} +1 -1
  20. package/dist/assets/{SystemPrompt-BWOdtfze.js → SystemPrompt-3tHpyoge.js} +1 -1
  21. package/dist/assets/{TagLibrary-cn3JECC5.js → TagLibrary-BoJHe-aY.js} +1 -1
  22. package/dist/assets/{TiptapBody-BwoXKY8J.js → TiptapBody-DzlxVXaw.js} +1 -1
  23. package/dist/assets/{Toggle-BlNVxkq-.js → Toggle-DKmoif0X.js} +1 -1
  24. package/dist/assets/{index-BpzvONtX.js → index-Dtv0whpT.js} +672 -672
  25. package/dist/assets/{index-DDpA9yBe.css → index-N_v_4fqC.css} +1 -1
  26. package/dist/assets/{settingsSchema-DePBZnaw.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/chatRunner.cjs +11 -1
  34. package/src/main/index.cjs +6 -0
  35. package/src/main/ipcSchemas.cjs +35 -0
  36. package/src/main/lib/__tests__/epicSpawnCwd.test.cjs +193 -0
  37. package/src/main/lib/__tests__/epicWorktreeBoot.test.cjs +136 -0
  38. package/src/main/lib/__tests__/epicWorktreeMerge.test.cjs +130 -0
  39. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +115 -0
  40. package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +79 -0
  41. package/src/main/lib/__tests__/gitWorktree.test.cjs +400 -0
  42. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +126 -0
  43. package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +82 -0
  44. package/src/main/lib/epicSpawnCwd.cjs +95 -0
  45. package/src/main/lib/epicWorktreeBoot.cjs +49 -0
  46. package/src/main/lib/epicWorktreeMerge.cjs +63 -0
  47. package/src/main/lib/epicWorktreeMint.cjs +54 -0
  48. package/src/main/lib/epicWorktreeProjectConfig.cjs +102 -0
  49. package/src/main/lib/gitWorktree.cjs +446 -0
  50. package/src/main/lib/jobWorktree.cjs +36 -293
  51. package/src/main/lib/promptSessionSchema.cjs +15 -0
  52. package/src/main/lib/queueStore.cjs +18 -0
  53. package/src/main/lib/terminalRunOutcome.cjs +14 -2
  54. package/src/main/pty.cjs +11 -1
  55. package/src/main/queueOps.cjs +40 -12
  56. package/src/main/scheduler.cjs +14 -0
  57. package/src/preload/api.d.ts +59 -0
  58. package/src/preload/index.cjs +22 -0
  59. package/dist/assets/Settings-CmU_7z2e.js +0 -3
@@ -2,28 +2,28 @@
2
2
  * jobWorktree.cjs — per-job `git worktree` isolation for concurrent scheduler
3
3
  * runs (PRD 994).
4
4
  *
5
- * Problem: once the queue genuinely runs several jobs wide in one project,
6
- * N headless `claude -p` processes edit the SAME working tree simultaneously.
7
- * Observed live on 2026-08-02 with only two concurrent writers: a full test
8
- * run failed on another job's half-written renderer files, a commit had to be
9
- * hand-staged to avoid sweeping a sibling job's WIP, and a commit was silently
10
- * rewritten to a new SHA by a concurrent rebase. This module gives each job
11
- * its own linked worktree — its edits, its test runs, its commit — isolated
12
- * from every sibling job and from the human's own interactive session in the
13
- * same repo.
5
+ * As of PRD 1032 this is a thin, byte-for-byte-compatible wrapper over the
6
+ * generalized primitives in gitWorktree.cjs (kind: 'job') — every export
7
+ * below keeps its original call shape so scheduler.cjs and any other
8
+ * existing caller need zero changes. New callers wanting the same isolation
9
+ * for a different kind of unit of work (e.g. an Epic session — kind: 'epic')
10
+ * should use gitWorktree.cjs's `create/integrate/cleanupEpicWorktree`
11
+ * directly rather than adding a second copy here.
14
12
  *
15
- * ---------- the ops-root hazard (read before touching cwd plumbing) ----------
13
+ * Problem this solves: once the queue genuinely runs several jobs wide in
14
+ * one project, N headless `claude -p` processes edit the SAME working tree
15
+ * simultaneously. Observed live on 2026-08-02 with only two concurrent
16
+ * writers: a full test run failed on another job's half-written renderer
17
+ * files, a commit had to be hand-staged to avoid sweeping a sibling job's
18
+ * WIP, and a commit was silently rewritten to a new SHA by a concurrent
19
+ * rebase. This module gives each job its own linked worktree — its edits,
20
+ * its test runs, its commit — isolated from every sibling job and from the
21
+ * human's own interactive session in the same repo.
16
22
  *
17
- * `<cwd>/session-manager-operations/` (PRDs, queue state, run logs) is
18
- * resolved from a project's cwd by lib/prdLocations.cjs and lib/queueStore.cjs
19
- * — every function there takes `cwd` as an explicit parameter, never
20
- * `process.cwd()`. That means the ops root follows WHATEVER cwd value the
21
- * caller passes in. A prior incident (recorded in this project's memory as
22
- * `no_schedule_self_e2e`) is exactly this mistake: swapping the job's cwd for
23
- * a worktree cwd gives the job a different, EMPTY ops root and orphans its own
24
- * PRD/queue row.
23
+ * ---------- the ops-root hazard (read before touching cwd plumbing) ----------
25
24
  *
26
- * The fix here is structural: this module never changes `job.cwd`. It hands
25
+ * See gitWorktree.cjs's own header comment for the full writeup — the rule
26
+ * carries forward unchanged: this module never changes `job.cwd`. It hands
27
27
  * back a SEPARATE `execCwd` (the worktree directory) that scheduler.cjs uses
28
28
  * ONLY as the spawned child process's `cwd` spawn option. Every PRD-path
29
29
  * resolution, queue read/write, and run-log path in scheduler.cjs keeps using
@@ -46,282 +46,25 @@
46
46
  */
47
47
  'use strict';
48
48
 
49
- const fs = require('node:fs');
50
- const fsp = require('node:fs/promises');
51
- const path = require('node:path');
52
- const os = require('node:os');
53
- const crypto = require('node:crypto');
54
- const { execFile } = require('node:child_process');
55
-
56
- // Root under which every job worktree is checked out. Kept OUTSIDE any
57
- // project's own tree (os.tmpdir(), not `<cwd>/.git/...`) so a job's worktree
58
- // never shows up in the main tree's own file listings, `find`, or a tsc
59
- // rootDir scan of `<cwd>`.
60
- const WORKTREE_ROOT = path.join(os.tmpdir(), 'session-manager-job-worktrees');
61
-
62
- // Disk estimate for this repo (session-manager): `git worktree add` checks
63
- // out only git-tracked source, not `node_modules`/`dist` — a fresh checkout
64
- // of this repo's tracked tree is ~30-40 MB (measured via `git ls-files | xargs
65
- // du -ch` on the working tree, minus the .git object store the worktree
66
- // shares with the main tree rather than duplicating). At the default cap of
67
- // 4 concurrent worktrees that's under 200 MB — negligible next to the
68
- // multi-GB `node_modules` a job's own `npm install`/build step may add inside
69
- // its worktree (uncounted here; that risk is bounded by MIN_FREE_MB_PER_JOB's
70
- // existing per-job memory gate in scheduler.cjs, not by this cap).
71
- const DEFAULT_MAX_CONCURRENT_WORKTREES = 4;
72
-
73
- function isWorktreeDisabled() {
74
- return process.env.SM_JOB_WORKTREE_DISABLE === '1';
75
- }
76
-
77
- function getMaxConcurrentWorktrees() {
78
- const raw = Number(process.env.SM_JOB_WORKTREE_MAX);
79
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_CONCURRENT_WORKTREES;
80
- }
81
-
82
- // In-memory count of worktrees currently checked out by THIS process. Reset
83
- // to 0 on every restart by design — a crash can never leave this counter
84
- // permanently wedged above the cap; reconcileWorktreesOnBoot cleans up any
85
- // leaked ON-DISK checkouts separately (see below).
86
- let activeWorktreeCount = 0;
87
-
88
- function execGit(args, { cwd, timeout = 20_000 } = {}) {
89
- return new Promise((resolve, reject) => {
90
- execFile('git', args, { cwd, timeout, windowsHide: true, encoding: 'utf8' }, (err, stdout, stderr) => {
91
- if (err) {
92
- err.stderrText = stderr;
93
- reject(err);
94
- return;
95
- }
96
- resolve(stdout || '');
97
- });
98
- });
99
- }
100
-
101
- async function isGitRepo(cwd) {
102
- if (!cwd) return false;
103
- try {
104
- const out = await execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 10_000 });
105
- return out.trim() === 'true';
106
- } catch {
107
- return false;
108
- }
109
- }
110
-
111
- /** True when `cwd`'s own working tree (not any worktree) has zero pending changes. */
112
- async function isBaseTreeClean(cwd) {
113
- try {
114
- const out = await execGit(['status', '--porcelain'], { cwd, timeout: 10_000 });
115
- return out.trim().length === 0;
116
- } catch {
117
- return false;
118
- }
119
- }
120
-
121
- function hashOf(input) {
122
- return crypto.createHash('sha1').update(input).digest('hex').slice(0, 16);
123
- }
124
-
125
- function worktreeDirFor(cwd, slug) {
126
- return path.join(WORKTREE_ROOT, hashOf(cwd), slug);
127
- }
128
-
129
- function branchNameFor(slug) {
130
- return `sm-job/${slug}`;
131
- }
132
-
133
- /** Best-effort teardown of one worktree checkout — never throws. */
134
- async function removeWorktreeDir(cwd, dir) {
135
- try {
136
- await execGit(['worktree', 'remove', '--force', dir], { cwd, timeout: 15_000 });
137
- } catch {
138
- // Not registered (already removed) or dir already gone — fall through to
139
- // a plain rm so a half-created checkout never leaks disk either.
140
- }
141
- try {
142
- await fsp.rm(dir, { recursive: true, force: true });
143
- } catch {
144
- /* best-effort */
145
- }
146
- }
147
-
148
- /**
149
- * Create a linked worktree for one job, on a fresh branch checked out from
150
- * the main tree's current HEAD. Returns `{ ok: true, dir, branch, baseCwd }`
151
- * on success, or `{ ok: false, reason }` — the reason is always a short,
152
- * human-readable string meant to be logged verbatim so a fallback to running
153
- * in place is never silent.
154
- *
155
- * Never throws: every failure mode (not a repo, dirty base, cap reached, git
156
- * error) is a normal, expected outcome for a project that hasn't opted into
157
- * — or currently can't support — isolation, not an exceptional one.
158
- */
159
- async function createJobWorktree({ cwd, slug }) {
160
- if (isWorktreeDisabled()) return { ok: false, reason: 'disabled via SM_JOB_WORKTREE_DISABLE=1' };
161
- if (!cwd || typeof cwd !== 'string') return { ok: false, reason: 'no cwd provided' };
162
- if (!slug || typeof slug !== 'string') return { ok: false, reason: 'no slug provided' };
163
-
164
- if (!(await isGitRepo(cwd))) return { ok: false, reason: 'not a git repository' };
165
-
166
- // A dirty base tree means the job may be depending on the human's own
167
- // uncommitted WIP in `cwd` — a worktree only ever checks out committed
168
- // HEAD content, so isolating into one here would silently drop that WIP
169
- // from what the job sees. Falling back to running in place is strictly
170
- // safer than guessing.
171
- if (!(await isBaseTreeClean(cwd))) return { ok: false, reason: 'base working tree has uncommitted changes' };
172
-
173
- if (activeWorktreeCount >= getMaxConcurrentWorktrees()) {
174
- return { ok: false, reason: `worktree cap reached (${getMaxConcurrentWorktrees()} concurrent)` };
175
- }
176
- // Reserve the slot SYNCHRONOUSLY (before any `await` below) so two jobs
177
- // spawned in the same tick can't both pass the check above and both
178
- // proceed — without this, the cap is a TOCTOU race: N concurrent callers
179
- // all read the pre-increment count before either increments it. Released
180
- // again below on any failure path so a failed create never permanently
181
- // shrinks capacity.
182
- activeWorktreeCount++;
183
-
184
- const dir = worktreeDirFor(cwd, slug);
185
- const branch = branchNameFor(slug);
186
- try {
187
- await fsp.mkdir(path.dirname(dir), { recursive: true });
188
- // Defensive: a same-slug leftover from a prior crashed run (same slug can
189
- // legitimately re-fire after a transient failure) must not collide with
190
- // `git worktree add`'s own branch/path checks.
191
- await removeWorktreeDir(cwd, dir);
192
- try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* didn't exist */ }
193
- await execGit(['worktree', 'add', '-b', branch, dir, 'HEAD'], { cwd, timeout: 30_000 });
194
- } catch (e) {
195
- activeWorktreeCount = Math.max(0, activeWorktreeCount - 1);
196
- return { ok: false, reason: `git worktree add failed: ${(e && (e.stderrText || e.message)) || e}` };
197
- }
198
- return { ok: true, dir, branch, baseCwd: cwd };
199
- }
200
-
201
- /**
202
- * Integrate a job's branch back into `cwd`'s current HEAD — fast-forward
203
- * when possible, a real merge commit when the main tree advanced underneath
204
- * (a sibling job merged first) since the worktree was created. Returns
205
- * `{ ok: true, integrated: boolean, ...}` on success (integrated:false means
206
- * the branch had no new commits — a legitimate no-op job, not a failure), or
207
- * `{ ok: false, reason }` when neither ff-only nor a real merge could land —
208
- * e.g. a genuine content conflict between two jobs that touched the same
209
- * lines. On failure the branch is left un-merged and NOT deleted (see
210
- * cleanupJobWorktree) so the work is recoverable, never silently discarded.
211
- */
212
- async function integrateJobBranch({ cwd, branch, slug }) {
213
- if (!cwd || !branch) return { ok: false, reason: 'missing cwd/branch' };
214
- let branchHead;
215
- try {
216
- branchHead = (await execGit(['rev-parse', branch], { cwd, timeout: 10_000 })).trim();
217
- } catch (e) {
218
- return { ok: false, reason: `branch ${branch} not found: ${(e && (e.stderrText || e.message)) || e}` };
219
- }
220
- let mergeBase = '';
221
- try {
222
- mergeBase = (await execGit(['merge-base', 'HEAD', branch], { cwd, timeout: 10_000 })).trim();
223
- } catch {
224
- mergeBase = '';
225
- }
226
- if (mergeBase && mergeBase === branchHead) {
227
- return { ok: true, integrated: false, reason: 'branch has no new commits' };
228
- }
229
-
230
- try {
231
- await execGit(['merge', '--ff-only', branch], { cwd, timeout: 30_000 });
232
- return { ok: true, integrated: true, fastForward: true };
233
- } catch {
234
- // Main tree advanced since the worktree branched (a sibling job merged
235
- // first) — a real merge commit still lands the job's own commit(s).
236
- }
237
- try {
238
- await execGit(['merge', '--no-ff', '--no-edit', '-m', `merge scheduler job ${slug || branch}`, branch], { cwd, timeout: 30_000 });
239
- return { ok: true, integrated: true, mergeCommit: true };
240
- } catch (e) {
241
- // Abort a half-applied merge so `cwd` isn't left in a mid-merge state.
242
- try { await execGit(['merge', '--abort'], { cwd, timeout: 10_000 }); } catch { /* nothing to abort */ }
243
- return { ok: false, reason: `merge failed (likely a real content conflict): ${(e && (e.stderrText || e.message)) || e}` };
244
- }
245
- }
246
-
247
- /**
248
- * Tear down one job's worktree checkout after it has been integrated (or
249
- * failed to integrate). Always removes the linked-worktree checkout (freeing
250
- * its disk); only deletes the branch ref when `keepBranch` is falsy — a
251
- * failed integration keeps the branch around for manual recovery per
252
- * integrateJobBranch's contract above. Never throws.
253
- */
254
- async function cleanupJobWorktree({ cwd, dir, branch, keepBranch }) {
255
- if (dir) await removeWorktreeDir(cwd, dir);
256
- if (branch && !keepBranch) {
257
- try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
258
- }
259
- try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
260
- activeWorktreeCount = Math.max(0, activeWorktreeCount - 1);
261
- }
262
-
263
- /** Parse `git worktree list --porcelain` into `[{ worktree, branch }]`. */
264
- function parseWorktreeListPorcelain(text) {
265
- const entries = [];
266
- let cur = null;
267
- for (const line of String(text || '').split('\n')) {
268
- if (line.startsWith('worktree ')) {
269
- cur = { worktree: line.slice('worktree '.length).trim(), branch: null };
270
- entries.push(cur);
271
- } else if (line.startsWith('branch ') && cur) {
272
- cur.branch = line.slice('branch '.length).trim().replace(/^refs\/heads\//, '');
273
- }
274
- }
275
- return entries;
276
- }
49
+ const gitWorktree = require('./gitWorktree.cjs');
277
50
 
278
- /**
279
- * Boot reconciliation: a job worktree that survives a process crash (app
280
- * killed mid-run, host reboot) leaks disk and a dangling branch forever
281
- * unless something cleans it up — this is that something. For each known
282
- * project cwd, lists every registered worktree, forcibly removes any that
283
- * live under WORKTREE_ROOT (ours; never touches a worktree a human created
284
- * for their own purposes), deletes its branch, and prunes stale registrations.
285
- * Never throws — a project that isn't a git repo, or has no worktrees, is a
286
- * silent no-op.
287
- */
288
- async function reconcileWorktreesOnBoot(cwds) {
289
- const list = Array.isArray(cwds) ? cwds.filter(Boolean) : [];
290
- for (const cwd of list) {
291
- if (!(await isGitRepo(cwd))) continue;
292
- let out = '';
293
- try {
294
- out = await execGit(['worktree', 'list', '--porcelain'], { cwd, timeout: 15_000 });
295
- } catch {
296
- continue;
297
- }
298
- const entries = parseWorktreeListPorcelain(out);
299
- for (const entry of entries) {
300
- if (!entry.worktree || !entry.worktree.startsWith(WORKTREE_ROOT + path.sep)) continue;
301
- await removeWorktreeDir(cwd, entry.worktree);
302
- if (entry.branch) {
303
- try { await execGit(['branch', '-D', entry.branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
304
- }
305
- }
306
- try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
307
- }
308
- }
51
+ const KIND = 'job';
309
52
 
310
53
  module.exports = {
311
- WORKTREE_ROOT,
312
- DEFAULT_MAX_CONCURRENT_WORKTREES,
313
- isWorktreeDisabled,
314
- getMaxConcurrentWorktrees,
315
- isGitRepo,
316
- isBaseTreeClean,
317
- worktreeDirFor,
318
- branchNameFor,
319
- createJobWorktree,
320
- integrateJobBranch,
321
- cleanupJobWorktree,
322
- parseWorktreeListPorcelain,
323
- reconcileWorktreesOnBoot,
54
+ WORKTREE_ROOT: gitWorktree.worktreeRootFor(KIND),
55
+ DEFAULT_MAX_CONCURRENT_WORKTREES: gitWorktree.KIND_CONFIG[KIND].defaultMax,
56
+ isWorktreeDisabled: () => gitWorktree.isWorktreeDisabled(KIND),
57
+ getMaxConcurrentWorktrees: () => gitWorktree.getMaxConcurrentWorktrees(KIND),
58
+ isGitRepo: gitWorktree.isGitRepo,
59
+ isBaseTreeClean: gitWorktree.isBaseTreeClean,
60
+ worktreeDirFor: (cwd, slug) => gitWorktree.worktreeDirFor(KIND, cwd, slug),
61
+ branchNameFor: (slug) => gitWorktree.branchNameFor(KIND, slug),
62
+ createJobWorktree: gitWorktree.createJobWorktree,
63
+ integrateJobBranch: gitWorktree.integrateJobBranch,
64
+ cleanupJobWorktree: gitWorktree.cleanupJobWorktree,
65
+ parseWorktreeListPorcelain: gitWorktree.parseWorktreeListPorcelain,
66
+ reconcileWorktreesOnBoot: (cwds) => gitWorktree.reconcileWorktreesOnBoot(cwds, { kind: KIND }),
324
67
  // Test-only escape hatch for the in-memory concurrency counter.
325
- _resetActiveWorktreeCountForTests(n = 0) { activeWorktreeCount = n; },
326
- _getActiveWorktreeCountForTests() { return activeWorktreeCount; },
68
+ _resetActiveWorktreeCountForTests(n = 0) { gitWorktree._resetActiveWorktreeCountForTests(KIND, n); },
69
+ _getActiveWorktreeCountForTests() { return gitWorktree._getActiveWorktreeCountForTests(KIND); },
327
70
  };
@@ -55,6 +55,17 @@ const EpicIntakeSectionSchema = z.object({
55
55
  source: z.string().optional(),
56
56
  });
57
57
 
58
+ // Mirrors PromptSession's `worktree` field (src/renderer/state/
59
+ // promptSessions.ts) — this Epic's isolated `git worktree` checkout, when
60
+ // one exists. `baseCwd` is always the Epic's real owning-project cwd, never
61
+ // the worktree dir itself (see gitWorktree.cjs's "ops-root hazard" comment).
62
+ const EpicWorktreeSchema = z.object({
63
+ dir: z.string(),
64
+ branch: z.string(),
65
+ baseCwd: z.string(),
66
+ status: z.enum(['active', 'needs_merge_resolution', 'merged', 'disabled']),
67
+ });
68
+
58
69
  // Mirrors PromptSession (src/renderer/state/promptSessions.ts:29-76).
59
70
  const PromptSessionSchema = z.object({
60
71
  id: z.string(),
@@ -73,6 +84,9 @@ const PromptSessionSchema = z.object({
73
84
  // rendering the flat `openingPrompt` as a single block (see
74
85
  // ChatTranscriptTurn.tsx's EpicIntakeCard).
75
86
  sections: z.array(EpicIntakeSectionSchema).optional(),
87
+ // Passthrough only as of PRD 1032 — nothing populates this yet; the next
88
+ // PRD in this chain wires Epic worktree minting.
89
+ worktree: EpicWorktreeSchema.optional(),
76
90
  });
77
91
 
78
92
  /**
@@ -97,5 +111,6 @@ module.exports = {
97
111
  EpicSourceSchema,
98
112
  EpicTagSchema,
99
113
  EpicIntakeSectionSchema,
114
+ EpicWorktreeSchema,
100
115
  assertValidPromptSession,
101
116
  };
@@ -40,8 +40,26 @@ const MACHINE_STATE_PATH = path.join(os.homedir(), '.claude', 'session-manager',
40
40
  const LEGACY_QUEUE_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json');
41
41
  const STATE_SUBPATH = ['session-manager-operations', 'scheduler', 'state'];
42
42
 
43
+ /**
44
+ * A project's `session-manager-operations/scheduler/state/` directory.
45
+ *
46
+ * The cwd MUST be absolute. `path.join()` happily accepts a relative one and
47
+ * silently resolves it against `process.cwd()`, which turns a bad caller into
48
+ * a whole ops root materialized in the wrong place — observed live on
49
+ * 2026-08-13 as five stray `session-manager-operations/scheduler/state/
50
+ * queue.json` trees under source directories (`src/main/lib/`,
51
+ * `src/renderer/state/`, …), each created by a caller that passed a
52
+ * repo-relative path like `src/main/lib`. That is the same ops-root hazard
53
+ * gitWorktree.cjs's header comment describes, reached from the other end: not
54
+ * a worktree dir substituted for a project cwd, but a relative fragment
55
+ * accepted as one. Fail closed — a caller that cannot name an absolute
56
+ * project cwd has no business writing that project's queue.
57
+ */
43
58
  function projectStateDir(cwd) {
44
59
  if (!cwd || typeof cwd !== 'string') throw new Error('projectStateDir: cwd is required');
60
+ if (!path.isAbsolute(cwd)) {
61
+ throw new Error(`projectStateDir: cwd must be an absolute path, got "${cwd}"`);
62
+ }
45
63
  return path.join(cwd, ...STATE_SUBPATH);
46
64
  }
47
65
 
@@ -67,7 +67,16 @@ function latestTerminalOutcomeForSlug(slug, { runsDir, fsImpl = fs } = {}) {
67
67
  try {
68
68
  meta = JSON.parse(fsImpl.readFileSync(metaPath, 'utf8'));
69
69
  } catch {
70
- return null;
70
+ // An unreadable/half-written sidecar in THIS dir says nothing about the
71
+ // older ones — fall through to the next-newest candidate rather than
72
+ // abandoning the scan. Returning null here made the caller treat the
73
+ // slug as having no terminal run at all, which resurrects and re-runs
74
+ // an already-completed PRD (the anti-resurrection guard silently
75
+ // switching itself off on the one input it exists to survive). This is
76
+ // also what makes MAX_DIRS_SCANNED mean what its name says: before,
77
+ // every branch below returned on the FIRST candidate, so the newest dir
78
+ // was the only one ever consulted.
79
+ continue;
71
80
  }
72
81
 
73
82
  // meta.json's finishedAt is an epoch-ms number; callers (e.g. the
@@ -86,7 +95,10 @@ function latestTerminalOutcomeForSlug(slug, { runsDir, fsImpl = fs } = {}) {
86
95
  try {
87
96
  verdicts = JSON.parse(fsImpl.readFileSync(verdictsPath, 'utf8'));
88
97
  } catch {
89
- return null;
98
+ // Same reasoning as the meta read above: a run whose verdicts sidecar
99
+ // never landed (killed between the two writes) must not erase an older,
100
+ // complete terminal record for this slug.
101
+ continue;
90
102
  }
91
103
 
92
104
  if (COMPLETED_EQUIVALENT_VERDICTS.has(verdicts.verdict)) {
package/src/main/pty.cjs CHANGED
@@ -21,6 +21,7 @@ const { cleanChildEnv, pathWithUserBins } = require('./lib/cleanEnv.cjs');
21
21
  const { checkInsideHome } = require('./lib/insideHome.cjs');
22
22
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
23
23
  const opsErrorLog = require('./lib/opsErrorLog.cjs');
24
+ const { resolveEpicSpawnCwd } = require('./lib/epicSpawnCwd.cjs');
24
25
 
25
26
  // Absolute path to the installed package root (src/main/ -> ../../), shown in
26
27
  // the remediation message so the user can cd there and rebuild.
@@ -159,6 +160,15 @@ class PtyManager {
159
160
 
160
161
  const shell = process.env.SHELL || '/bin/bash';
161
162
  console.log('[pty] spawning shell', shell);
163
+ // Tab ID = claudeSessionId (see CLAUDE.md), so for an Epic-attached tab
164
+ // this resolves the Epic's own record in `cwd`'s active-index.json and
165
+ // returns its isolated worktree dir when one exists — `cwd` itself is
166
+ // NEVER repointed; only this actual PTY spawn option is. See
167
+ // epicSpawnCwd.cjs's header comment (the ops-root hazard) for why.
168
+ const spawnCwd = resolveEpicSpawnCwd({ cwd, claudeSessionId: tabId });
169
+ if (spawnCwd !== cwd) {
170
+ console.log(`[pty] tabId=${tabId} isolated in worktree ${spawnCwd}`);
171
+ }
162
172
  let proc;
163
173
  try {
164
174
  // Interactive login shell so aliases / nvm / PATH resolve correctly.
@@ -166,7 +176,7 @@ class PtyManager {
166
176
  name: 'xterm-256color',
167
177
  cols,
168
178
  rows,
169
- cwd,
179
+ cwd: spawnCwd,
170
180
  env,
171
181
  });
172
182
  } catch (err) {
@@ -287,19 +287,44 @@ async function lintAll() {
287
287
  // ────────────────────────────────────────────── archive
288
288
 
289
289
  /**
290
- * Move PRDS_DIR/<slug>.md → PRDS_ARCHIVE_DIR/<ISO>/<slug>.md.
290
+ * Move <srcDir>/<slug>.md → <srcDir>/../prds-archived/<ISO>/<slug>.md.
291
291
  * Atomic rename. Path containment checks both source and destination.
292
292
  * Never deletes — always reversible from prds-archived/.
293
293
  */
294
- async function archiveOne(slug, archiveDir) {
294
+ /**
295
+ * The archive directory a PRD found in `srcDir` belongs in: `prds-archived/`
296
+ * SIBLING to its own `prds/`, which for a per-project PRD is
297
+ * `<cwd>/session-manager-operations/scheduler/epics/<epic-id>/prds-archived/`.
298
+ *
299
+ * PRD sources are per-project and per-Epic (CLAUDE.md's TAB → EPIC → PRD
300
+ * model); the machine-level `~/.claude/session-manager/scheduled-plans/` tree
301
+ * holds only run logs and PRD_AUTHORING.md. Archiving therefore has to follow
302
+ * the source, not a fixed global constant — otherwise a manual archive yanks a
303
+ * PRD out of its Epic and strands it in the retired global tree, splitting one
304
+ * Epic's history across two locations (observed 2026-08-13 with
305
+ * 1035-epic-worktree-ui-surfacing, whose three chain siblings sat in the Epic's
306
+ * own prds-archived/ while it landed under scheduled-plans/). A PRD still in
307
+ * the legacy flat `PRDS_DIR` resolves to `ROOT/prds-archived` here, which is
308
+ * exactly the old behavior — so that case is unchanged.
309
+ */
310
+ function archiveDirForSource(srcDir, ts) {
311
+ return path.join(path.dirname(srcDir), 'prds-archived', ts);
312
+ }
313
+
314
+ async function archiveOne(slug, ts) {
295
315
  if (!SLUG_RE.test(slug)) return { ok: false, slug, error: 'invalid slug' };
296
316
  const srcDir = await findPrdDir(slug);
297
317
  if (!srcDir) return { ok: false, slug, error: 'not found in any PRDs dir' };
298
318
  const src = path.resolve(path.join(srcDir, `${slug}.md`));
299
319
  if (!src.startsWith(srcDir + path.sep)) return { ok: false, slug, error: 'path escape (src)' };
320
+ // Destination is derived per-slug (not once for the whole batch): a single
321
+ // archive call can span several projects/Epics, and each PRD has to land
322
+ // beside its own source.
323
+ const archiveDir = archiveDirForSource(srcDir, ts);
300
324
  const dst = path.resolve(path.join(archiveDir, `${slug}.md`));
301
- if (!dst.startsWith(PRDS_ARCHIVE_DIR + path.sep)) return { ok: false, slug, error: 'path escape (dst)' };
325
+ if (!dst.startsWith(path.resolve(archiveDir) + path.sep)) return { ok: false, slug, error: 'path escape (dst)' };
302
326
  try {
327
+ await fsp.mkdir(archiveDir, { recursive: true });
303
328
  await fsp.rename(src, dst);
304
329
  return { ok: true, slug, archivedTo: dst };
305
330
  } catch (e) {
@@ -321,17 +346,14 @@ async function archiveMany(slugs) {
321
346
  if (!Array.isArray(slugs) || slugs.length === 0) {
322
347
  return { ok: true, archived: 0, archivedTo: null, results: [] };
323
348
  }
349
+ // One timestamp for the whole batch (so a multi-slug archive reads as one
350
+ // event), but each slug's destination DIRECTORY is resolved from its own
351
+ // source inside archiveOne — see archiveDirForSource. mkdir moved in there
352
+ // for the same reason: there is no single batch-wide dir to pre-create.
324
353
  const ts = new Date().toISOString().replace(/[:.]/g, '-');
325
- const archiveDir = path.join(PRDS_ARCHIVE_DIR, ts);
326
- try {
327
- await fsp.mkdir(archiveDir, { recursive: true });
328
- } catch (e) {
329
- logs.writeLine({ level: 'error', scope: 'queueOps', message: 'archiveMany: mkdir failed', meta: { error: e?.message } });
330
- return { ok: false, archived: 0, archivedTo: null, results: [], error: e?.message ?? 'mkdir failed' };
331
- }
332
354
  const results = [];
333
355
  for (const slug of slugs) {
334
- results.push(await archiveOne(slug, archiveDir));
356
+ results.push(await archiveOne(slug, ts));
335
357
  }
336
358
  const archived = results.filter((r) => r.ok).length;
337
359
  const archivedSlugs = results.filter((r) => r.ok).map((r) => r.slug);
@@ -340,7 +362,12 @@ async function archiveMany(slugs) {
340
362
  logs.writeLine({ level: 'warn', scope: 'queueOps', message: 'archiveMany: retireCompletedSlugs failed', meta: { error: e?.message } });
341
363
  });
342
364
  }
343
- return { ok: true, archived, archivedTo: archiveDir, results };
365
+ // archivedTo reports where the batch actually landed. With per-source
366
+ // destinations that is only well-defined when every move agreed on one dir
367
+ // (the common single-project case); a genuinely cross-project batch reports
368
+ // null rather than naming one project's dir as if it covered them all.
369
+ const dirs = new Set(results.filter((r) => r.ok).map((r) => path.dirname(r.archivedTo)));
370
+ return { ok: true, archived, archivedTo: dirs.size === 1 ? [...dirs][0] : null, results };
344
371
  }
345
372
 
346
373
  // ────────────────────────────────────────────── auto-archive completed PRDs
@@ -597,6 +624,7 @@ module.exports = {
597
624
  retagMany,
598
625
  PRDS_DIR,
599
626
  PRDS_ARCHIVE_DIR,
627
+ archiveDirForSource,
600
628
  candidatePrdsDirs,
601
629
  findPrdDir,
602
630
  };
@@ -124,6 +124,7 @@ function resolveOriginSessionId(cwd, epicId) {
124
124
  }
125
125
  const sessionSlots = require('./lib/sessionSlots.cjs');
126
126
  const jobWorktree = require('./lib/jobWorktree.cjs');
127
+ const { reconcileEpicWorktreesOnBoot } = require('./lib/epicWorktreeBoot.cjs');
127
128
  const queueStore = require('./lib/queueStore.cjs');
128
129
  const { splitFrontmatter, parsePrdFile, serializePrdFile } = require('./lib/prdFrontmatter.cjs');
129
130
  const { migratePrds, consolidateFlatPrds, legacyAdoptExistingPrds } = require('./lib/prdMigration.cjs');
@@ -4967,6 +4968,19 @@ async function init() {
4967
4968
  console.error('[scheduler] boot worktree reconciliation failed', e?.message);
4968
4969
  }
4969
4970
 
4971
+ // Epic worktree boot reconciliation (PRD 1033): same sweep, epic kind —
4972
+ // reaps only a worktree whose owning Epic is no longer 'active' in that
4973
+ // project's own active-index.json (see epicWorktreeBoot.cjs). Reuses the
4974
+ // same known-cwd set as the job sweep above; best-effort, never blocks
4975
+ // the rest of boot.
4976
+ try {
4977
+ const worktreeCwds = new Set(bootSnap.jobs.map((j) => j.cwd).filter(Boolean));
4978
+ worktreeCwds.add(DEFAULT_PROJECT_CWD);
4979
+ await reconcileEpicWorktreesOnBoot([...worktreeCwds]);
4980
+ } catch (e) {
4981
+ console.error('[scheduler] boot epic-worktree reconciliation failed', e?.message);
4982
+ }
4983
+
4970
4984
  const { immediate: immediateSlugs, deferred: deferredSlugs } = partitionBootOrphans(bootSnap.jobs);
4971
4985
  const bootOutcomes = new Map();
4972
4986
  for (const j of bootSnap.jobs) {