claude-code-session-manager 0.75.3 → 0.76.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 (101) hide show
  1. package/dist/assets/{AgentLibrary-CzQqcObq.js → AgentLibrary-CBx9l4zN.js} +1 -1
  2. package/dist/assets/{DataModel-Bj_WlLz8.js → DataModel-Bf0EIE_t.js} +1 -1
  3. package/dist/assets/{History-DnSi_OHm.js → History-CpdtWhC8.js} +1 -1
  4. package/dist/assets/{Hooks-0BB0dp3S.js → Hooks-DyUbMDmg.js} +1 -1
  5. package/dist/assets/{HostBilko-DHpwwsLQ.js → HostBilko-By-wIpry.js} +1 -1
  6. package/dist/assets/{Library-CaJVqVvi.js → Library-CQmo4QVC.js} +1 -1
  7. package/dist/assets/{ListDetail-C1W2HmC2.js → ListDetail-BQMd6NOm.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-5Ob9FW3z.js → MarkdownEditor-DEp43FXX.js} +1 -1
  9. package/dist/assets/{McpServers-JxCSfm1S.js → McpServers-CLarzwqA.js} +1 -1
  10. package/dist/assets/{Memory-BDeqlqwH.js → Memory-B0sCdIy1.js} +1 -1
  11. package/dist/assets/{Panel-Dh9ZHuEj.js → Panel-BhWPVOCD.js} +1 -1
  12. package/dist/assets/{Permissions-DXy-CbEY.js → Permissions-Ddlq8T_O.js} +1 -1
  13. package/dist/assets/{Plugins-_n1Iuc8T.js → Plugins-D2oA_2Jl.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-BP_evfxE.js → ProvenanceBadge-DgAgavUM.js} +1 -1
  15. package/dist/assets/{SaveBar-D-gCUx4n.js → SaveBar-Qvc4Ek-H.js} +1 -1
  16. package/dist/assets/{Scheduler-Bpd4OGju.js → Scheduler-BmYJvNzK.js} +1 -1
  17. package/dist/assets/{ScopeSwitcher-CAWzM6RI.js → ScopeSwitcher-C_zWEtIl.js} +1 -1
  18. package/dist/assets/{Settings-DRRozLyT.js → Settings-2Vx3X5SI.js} +1 -1
  19. package/dist/assets/{SkillReferenceGraph-DGHDWlz4.js → SkillReferenceGraph-BDEUjlTQ.js} +1 -1
  20. package/dist/assets/{Skills-D8L66eiX.js → Skills-Cmrz_LeN.js} +1 -1
  21. package/dist/assets/{SystemPrompt-CYtUsonD.js → SystemPrompt-DVA1eYDP.js} +1 -1
  22. package/dist/assets/{TagLibrary-E5CLeuVk.js → TagLibrary-DYJGAKZu.js} +1 -1
  23. package/dist/assets/{TiptapBody-B2hRgbPE.js → TiptapBody-DmPc3amD.js} +1 -1
  24. package/dist/assets/{Toggle-BTwsbxam.js → Toggle-zfd5LJkK.js} +1 -1
  25. package/dist/assets/{index-DijufvkJ.js → index-B_4PNh9T.js} +676 -676
  26. package/dist/assets/{index-CMLnzdZC.css → index-DIjnPkRN.css} +1 -1
  27. package/dist/assets/{settingsSchema-D6wzxAi6.js → settingsSchema-B9es6fdA.js} +1 -1
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -1
  30. package/scripts/lib/activeSessions.cjs +116 -6
  31. package/scripts/scheduler-mcp-server.cjs +154 -95
  32. package/src/main/__tests__/epicStatusMirror.test.cjs +110 -0
  33. package/src/main/__tests__/health-delegation-chain.test.cjs +105 -0
  34. package/src/main/__tests__/prdAdminRoutes.test.cjs +295 -0
  35. package/src/main/__tests__/prdCreate.test.cjs +109 -0
  36. package/src/main/__tests__/scheduler-autofix-select.test.cjs +15 -3
  37. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +41 -0
  38. package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +60 -1
  39. package/src/main/__tests__/scheduler-stranded-investigation.test.cjs +185 -0
  40. package/src/main/__tests__/seedSchedulerMcp.test.cjs +66 -0
  41. package/src/main/__tests__/uniquePrdNumbers.test.cjs +14 -5
  42. package/src/main/bilkoHost.cjs +4 -3
  43. package/src/main/chatRunner.cjs +6 -1
  44. package/src/main/config.cjs +22 -33
  45. package/src/main/health.cjs +153 -2
  46. package/src/main/index.cjs +56 -4
  47. package/src/main/ipcSchemas.cjs +18 -1
  48. package/src/main/lib/__tests__/activeIndexRebuild.test.cjs +179 -0
  49. package/src/main/lib/__tests__/childWithLog.test.cjs +63 -0
  50. package/src/main/lib/__tests__/delegationReadiness.test.cjs +241 -42
  51. package/src/main/lib/__tests__/ephemeralCwd.test.cjs +91 -0
  52. package/src/main/lib/__tests__/epicWorktreeMint.test.cjs +1 -1
  53. package/src/main/lib/__tests__/gitWorktree.test.cjs +14 -2
  54. package/src/main/lib/__tests__/gitWorktreeSalvage.test.cjs +107 -0
  55. package/src/main/lib/__tests__/jobWorktree.test.cjs +2 -2
  56. package/src/main/lib/__tests__/loadGate.test.cjs +159 -0
  57. package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +101 -0
  58. package/src/main/lib/__tests__/opsRootAbsoluteCwd.test.cjs +151 -0
  59. package/src/main/lib/__tests__/opsRootResolve.test.cjs +149 -0
  60. package/src/main/lib/__tests__/projectRootResolve.test.cjs +148 -0
  61. package/src/main/lib/__tests__/reaperHelpers.test.cjs +112 -0
  62. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +19 -9
  63. package/src/main/lib/__tests__/schedulerBatchFairness.test.cjs +213 -0
  64. package/src/main/lib/__tests__/schedulerBatchProjectCap.test.cjs +127 -0
  65. package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +217 -0
  66. package/src/main/lib/activeIndexMerge.cjs +15 -0
  67. package/src/main/lib/activeIndexRebuild.cjs +133 -0
  68. package/src/main/lib/buildTarget.cjs +3 -2
  69. package/src/main/lib/childWithLog.cjs +33 -1
  70. package/src/main/lib/crossProjectFeedback.cjs +8 -1
  71. package/src/main/lib/delegationReadiness.cjs +408 -26
  72. package/src/main/lib/ephemeralCwd.cjs +78 -0
  73. package/src/main/lib/epicDelegationStats.cjs +2 -1
  74. package/src/main/lib/epicMint.cjs +17 -1
  75. package/src/main/lib/epicStatusMirror.cjs +95 -0
  76. package/src/main/lib/epicValidationHook.cjs +2 -1
  77. package/src/main/lib/gitWorktree.cjs +56 -2
  78. package/src/main/lib/jobWorktree.cjs +1 -0
  79. package/src/main/lib/loadGate.cjs +134 -0
  80. package/src/main/lib/mcpToolCatalog.cjs +285 -0
  81. package/src/main/lib/opsErrorLog.cjs +12 -1
  82. package/src/main/lib/opsOwnership.cjs +94 -0
  83. package/src/main/lib/prdAdminRoutes.cjs +43 -3
  84. package/src/main/lib/prdCreate.cjs +46 -14
  85. package/src/main/lib/prdLocations.cjs +13 -6
  86. package/src/main/lib/projectRootResolve.cjs +134 -0
  87. package/src/main/lib/promptSessionSchema.cjs +7 -0
  88. package/src/main/lib/queueStore.cjs +31 -5
  89. package/src/main/lib/rcaReport.cjs +1 -1
  90. package/src/main/lib/reaperHelpers.cjs +47 -1
  91. package/src/main/lib/schedulerBatch.cjs +171 -29
  92. package/src/main/lib/schedulerConfig.cjs +80 -0
  93. package/src/main/projectBrief.cjs +3 -2
  94. package/src/main/projectPages.cjs +2 -1
  95. package/src/main/promptSessionTranscript.cjs +0 -0
  96. package/src/main/pty.cjs +5 -0
  97. package/src/main/queueOps.cjs +15 -8
  98. package/src/main/scheduler.cjs +346 -49
  99. package/src/main/seedSchedulerMcp.cjs +58 -4
  100. package/src/preload/api.d.ts +69 -1
  101. package/src/preload/index.cjs +2 -0
@@ -0,0 +1,217 @@
1
+ /**
2
+ * schedulerMcpServerHelp.test.cjs — session_manager_help argument handling
3
+ * (none/tool/topic/unknown/both) and the pointer-suffix contract every
4
+ * isError return in scheduler-mcp-server.cjs now carries. Also covers the
5
+ * "app not running" degradation: the static catalog must still answer even
6
+ * when the admin API is unreachable — only the readiness section may report
7
+ * unavailable.
8
+ *
9
+ * TOKEN_PATH (scripts/scheduler-mcp-server.cjs) is computed once, at
10
+ * require-time, from os.homedir(). Since os.homedir() reads process.env.HOME
11
+ * on POSIX, each test temporarily repoints HOME at a fresh tmp dir, clears
12
+ * the require cache, and re-requires the module so TOKEN_PATH resolves under
13
+ * that tmp dir — never touching the real ~/.claude/session-manager/admin-api.json.
14
+ *
15
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs
16
+ */
17
+ 'use strict';
18
+
19
+ import { test, expect, afterEach } from 'vitest';
20
+ const fsp = require('node:fs/promises');
21
+ const http = require('node:http');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+
25
+ const SERVER_PATH = path.join(__dirname, '../../../../scripts/scheduler-mcp-server.cjs');
26
+
27
+ const tmpDirs = [];
28
+ const servers = [];
29
+ let originalHome;
30
+
31
+ afterEach(async () => {
32
+ if (originalHome !== undefined) {
33
+ process.env.HOME = originalHome;
34
+ originalHome = undefined;
35
+ }
36
+ delete require.cache[require.resolve(SERVER_PATH)];
37
+ while (servers.length) {
38
+ const s = servers.pop();
39
+ await new Promise((resolve) => s.close(resolve));
40
+ }
41
+ while (tmpDirs.length) {
42
+ const d = tmpDirs.pop();
43
+ await fsp.rm(d, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ async function mkTmp() {
48
+ const d = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-mcp-help-'));
49
+ tmpDirs.push(d);
50
+ return d;
51
+ }
52
+
53
+ /** Starts a fake admin HTTP server answering GET /admin/mcp/readiness. */
54
+ function startFakeAdminServer(token, readinessBody) {
55
+ return new Promise((resolve) => {
56
+ const server = http.createServer((req, res) => {
57
+ if (req.headers.authorization !== `Bearer ${token}`) {
58
+ res.writeHead(401, { 'Content-Type': 'application/json' });
59
+ res.end(JSON.stringify({ ok: false, error: 'bad token' }));
60
+ return;
61
+ }
62
+ res.writeHead(200, { 'Content-Type': 'application/json' });
63
+ res.end(JSON.stringify(readinessBody));
64
+ });
65
+ server.listen(0, '127.0.0.1', () => resolve(server));
66
+ });
67
+ }
68
+
69
+ /** Requires a fresh scheduler-mcp-server.cjs with TOKEN_PATH under `homeDir`. */
70
+ function requireServerWithHome(homeDir) {
71
+ originalHome = process.env.HOME;
72
+ process.env.HOME = homeDir;
73
+ delete require.cache[require.resolve(SERVER_PATH)];
74
+ const mod = require(SERVER_PATH);
75
+ process.env.HOME = originalHome;
76
+ originalHome = undefined;
77
+ return mod;
78
+ }
79
+
80
+ async function writeAdminConfig(homeDir, { port, token }) {
81
+ const tokenPath = path.join(homeDir, '.claude', 'session-manager', 'admin-api.json');
82
+ await fsp.mkdir(path.dirname(tokenPath), { recursive: true });
83
+ await fsp.writeFile(tokenPath, JSON.stringify({ port, token }), 'utf8');
84
+ }
85
+
86
+ function callTool(handleCallTool, name, args) {
87
+ return handleCallTool({ params: { name, arguments: args } });
88
+ }
89
+
90
+ test('session_manager_help with no arguments returns grouped tool list + recipe titles + readiness', async () => {
91
+ const homeDir = await mkTmp();
92
+ const token = 'test-token';
93
+ const server = await startFakeAdminServer(token, { ok: true, ready: true, checks: [{ id: 'scheduler-mcp', ok: true }] });
94
+ servers.push(server);
95
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
96
+
97
+ const { handleCallTool } = requireServerWithHome(homeDir);
98
+ const result = await callTool(handleCallTool, 'session_manager_help', {});
99
+ expect(result.isError).toBeFalsy();
100
+ const body = JSON.parse(result.content[0].text);
101
+ expect(Array.isArray(body.tools)).toBe(true);
102
+ expect(body.tools.some((t) => t.name === 'session_manager_help')).toBe(true);
103
+ expect(Array.isArray(body.recipes)).toBe(true);
104
+ expect(body.recipes.some((r) => r.id === 'queue-work-via-develop')).toBe(true);
105
+ expect(body.tool).toBeUndefined();
106
+ expect(body.recipe).toBeUndefined();
107
+ expect(body.readiness.available).toBe(true);
108
+ expect(body.readiness.ok).toBe(true);
109
+ });
110
+
111
+ test('session_manager_help with a valid tool returns that entry including exampleArgs', async () => {
112
+ const homeDir = await mkTmp();
113
+ const token = 'test-token';
114
+ const server = await startFakeAdminServer(token, { ok: true, ready: true, checks: [] });
115
+ servers.push(server);
116
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
117
+
118
+ const { handleCallTool } = requireServerWithHome(homeDir);
119
+ const result = await callTool(handleCallTool, 'session_manager_help', { tool: 'scheduler_create_prd' });
120
+ expect(result.isError).toBeFalsy();
121
+ const body = JSON.parse(result.content[0].text);
122
+ expect(body.tool.name).toBe('scheduler_create_prd');
123
+ expect(body.tool.exampleArgs).toBeTruthy();
124
+ expect(body.tools).toBeUndefined();
125
+ });
126
+
127
+ test('session_manager_help with a valid topic returns that recipe\'s steps', async () => {
128
+ const homeDir = await mkTmp();
129
+ const token = 'test-token';
130
+ const server = await startFakeAdminServer(token, { ok: true, ready: true, checks: [] });
131
+ servers.push(server);
132
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
133
+
134
+ const { handleCallTool } = requireServerWithHome(homeDir);
135
+ const result = await callTool(handleCallTool, 'session_manager_help', { topic: 'unstick-needs-review-job' });
136
+ expect(result.isError).toBeFalsy();
137
+ const body = JSON.parse(result.content[0].text);
138
+ expect(body.recipe.id).toBe('unstick-needs-review-job');
139
+ expect(Array.isArray(body.recipe.steps)).toBe(true);
140
+ expect(body.recipe.steps.length).toBeGreaterThan(0);
141
+ });
142
+
143
+ test('session_manager_help accepts both tool and topic together, returning both sections', async () => {
144
+ const homeDir = await mkTmp();
145
+ const token = 'test-token';
146
+ const server = await startFakeAdminServer(token, { ok: true, ready: true, checks: [] });
147
+ servers.push(server);
148
+ await writeAdminConfig(homeDir, { port: server.address().port, token });
149
+
150
+ const { handleCallTool } = requireServerWithHome(homeDir);
151
+ const result = await callTool(handleCallTool, 'session_manager_help', {
152
+ tool: 'scheduler_list_jobs',
153
+ topic: 'queue-work-via-develop',
154
+ });
155
+ expect(result.isError).toBeFalsy();
156
+ const body = JSON.parse(result.content[0].text);
157
+ expect(body.tool.name).toBe('scheduler_list_jobs');
158
+ expect(body.recipe.id).toBe('queue-work-via-develop');
159
+ });
160
+
161
+ test('session_manager_help with an unknown tool returns a helpful error listing valid names', async () => {
162
+ const homeDir = await mkTmp();
163
+ const { handleCallTool } = requireServerWithHome(homeDir);
164
+ const result = await callTool(handleCallTool, 'session_manager_help', { tool: 'not_a_real_tool' });
165
+ expect(result.isError).toBe(true);
166
+ expect(result.content[0].text).toContain('not_a_real_tool');
167
+ expect(result.content[0].text).toContain('scheduler_create_prd');
168
+ expect(result.content[0].text).toContain('call session_manager_help for the correct usage');
169
+ });
170
+
171
+ test('session_manager_help with an unknown topic returns a helpful error listing valid ids', async () => {
172
+ const homeDir = await mkTmp();
173
+ const { handleCallTool } = requireServerWithHome(homeDir);
174
+ const result = await callTool(handleCallTool, 'session_manager_help', { topic: 'not-a-real-recipe' });
175
+ expect(result.isError).toBe(true);
176
+ expect(result.content[0].text).toContain('not-a-real-recipe');
177
+ expect(result.content[0].text).toContain('queue-work-via-develop');
178
+ });
179
+
180
+ test('session_manager_help still returns the static catalog when the app is not running, with readiness unavailable', async () => {
181
+ // No admin-api.json written under this tmp HOME at all — simulates the app
182
+ // being down. The catalog is bundled in-process, so this must NOT fail
183
+ // wholesale; only the readiness half degrades.
184
+ const homeDir = await mkTmp();
185
+ const { handleCallTool } = requireServerWithHome(homeDir);
186
+ const result = await callTool(handleCallTool, 'session_manager_help', {});
187
+ expect(result.isError).toBeFalsy();
188
+ const body = JSON.parse(result.content[0].text);
189
+ expect(Array.isArray(body.tools)).toBe(true);
190
+ expect(body.tools.length).toBeGreaterThan(0);
191
+ expect(body.readiness.available).toBe(false);
192
+ expect(typeof body.readiness.reason).toBe('string');
193
+ expect(body.readiness.reason.length).toBeGreaterThan(0);
194
+ });
195
+
196
+ test('every isError return from the tool handler carries the session_manager_help pointer', async () => {
197
+ const homeDir = await mkTmp();
198
+ const { handleCallTool } = requireServerWithHome(homeDir);
199
+
200
+ const missingSlug = await callTool(handleCallTool, 'scheduler_reset_job', {});
201
+ expect(missingSlug.isError).toBe(true);
202
+ expect(missingSlug.content[0].text).toContain('call session_manager_help for the correct usage');
203
+
204
+ const unknownTool = await callTool(handleCallTool, 'not_a_tool_at_all', {});
205
+ expect(unknownTool.isError).toBe(true);
206
+ expect(unknownTool.content[0].text).toContain('call session_manager_help for the correct usage');
207
+ });
208
+
209
+ test('NOT_RUNNING_ERROR (app not running) carries the pointer and is surfaced via isError', async () => {
210
+ const homeDir = await mkTmp();
211
+ const { handleCallTool, NOT_RUNNING_ERROR } = requireServerWithHome(homeDir);
212
+ expect(NOT_RUNNING_ERROR).toContain('call session_manager_help for the correct usage');
213
+
214
+ const result = await callTool(handleCallTool, 'scheduler_list_jobs', {});
215
+ expect(result.isError).toBe(true);
216
+ expect(result.content[0].text).toBe(NOT_RUNNING_ERROR);
217
+ });
@@ -36,6 +36,7 @@
36
36
  const path = require('path');
37
37
  const { activeIndexPath, withPathLock } = require('./epicMint.cjs');
38
38
  const config = require('../config.cjs');
39
+ const { mirrorEpicStatus } = require('./epicStatusMirror.cjs');
39
40
 
40
41
  function hasOwn(obj, key) {
41
42
  return Object.prototype.hasOwnProperty.call(obj, key);
@@ -160,6 +161,20 @@ async function mergeActiveIndex(cwd, { sessions: rawMemorySessions = {}, events:
160
161
  // payload, so a compromised/forged renderer call can't claim a
161
162
  // different writer id.
162
163
  await config.writeJson(filePath, index, { writer: 'epics' });
164
+ // Status mirror (epicStatusMirror.cjs) — this IPC handler is the
165
+ // renderer-driven persist path (persistActiveIndex), the write site for
166
+ // both the 'proposed'->'active' start transition (approveProposed) and
167
+ // every other in-place session mutation. Mirrors every surviving row so
168
+ // a lost/clobbered active-index.json can be reconstructed from these
169
+ // files (activeIndexRebuild.cjs). Best-effort per row: one bad write must
170
+ // not undo the index write that already landed above.
171
+ for (const [id, sess] of Object.entries(mergedSessions)) {
172
+ try {
173
+ mirrorEpicStatus(cwd, id, { session: sess, status: sess.status });
174
+ } catch (e) {
175
+ console.warn(`[activeIndexMerge] mirrorEpicStatus failed for ${id}`, e);
176
+ }
177
+ }
163
178
  return { sessions: mergedSessions, events: mergedEvents };
164
179
  });
165
180
  }
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * activeIndexRebuild.cjs — reconstructs `active-index.json` from the
5
+ * per-Epic status mirrors `epicStatusMirror.cjs` writes onto `prompt-
6
+ * sessions/<epicId>.json`. This is the recovery path for the incident the
7
+ * mirror exists to survive: a lost, clobbered, git-reverted, or
8
+ * worktree-snapshotted index that would otherwise silently erase every open
9
+ * Epic it held with no way to get them back.
10
+ *
11
+ * Rebuild NEVER mints — SINGLE-CREATOR LAW (epicMint.cjs) is untouched
12
+ * because no new Epic id is ever created here, only rows for ids that
13
+ * already have a mirrored file re-materialized. It also never resurrects a
14
+ * tombstoned or archived Epic (see the two "never resurrect" guards below) —
15
+ * a human's delete/complete decision always wins.
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+ const { activeIndexPath, readActiveIndex, writeActiveIndex, withPathLock } = require('./epicMint.cjs');
21
+
22
+ function promptSessionsDir(cwd) {
23
+ const { opsPath } = require('./opsOwnership.cjs');
24
+ return opsPath(cwd, 'prompt-sessions');
25
+ }
26
+
27
+ function hasOwn(obj, key) {
28
+ return Object.prototype.hasOwnProperty.call(obj, key);
29
+ }
30
+
31
+ function listMirrorFiles(dir) {
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(dir);
35
+ } catch {
36
+ return [];
37
+ }
38
+ return entries.filter((f) => f.endsWith('.json') && f !== 'active-index.json');
39
+ }
40
+
41
+ /**
42
+ * rebuildActiveIndex(cwd, { dryRun? }) → { rows, skipped, reasons }
43
+ *
44
+ * `rows` — reconstructed PromptSession objects, one per mirrored file that
45
+ * is a live (proposed/active), non-tombstoned, non-archived Epic. These
46
+ * become the new `sessions` map (dryRun: false is the default — pass
47
+ * `{ dryRun: true }` to preview without writing).
48
+ * `skipped` — file names not turned into a row.
49
+ * `reasons` — `{ [fileName]: humanReadableReason }`, one entry per skipped
50
+ * file. A file is never guessed at: no mirrored `status` is a skip, not a
51
+ * fabricated 'unknown' row (see scripts/mirror-epic-status.cjs for the
52
+ * only place that vocabulary is deliberately introduced, for the
53
+ * migration's own back-fill bookkeeping).
54
+ *
55
+ * `events` and `tombstones` from the existing on-disk index (if any) are
56
+ * carried through completely untouched — this function only ever
57
+ * reconstructs `sessions`.
58
+ */
59
+ function rebuildActiveIndex(cwd, { dryRun = false } = {}) {
60
+ if (!cwd || typeof cwd !== 'string') throw new Error('rebuildActiveIndex: cwd is required');
61
+ cwd = path.resolve(cwd);
62
+ return withPathLock(activeIndexPath(cwd), () => {
63
+ const dir = promptSessionsDir(cwd);
64
+ const existing = readActiveIndex(cwd);
65
+ const tombstones = existing.tombstones || {};
66
+ const files = listMirrorFiles(dir);
67
+
68
+ const rows = [];
69
+ const skipped = [];
70
+ const reasons = {};
71
+
72
+ for (const file of files) {
73
+ const id = file.slice(0, -'.json'.length);
74
+ const full = path.join(dir, file);
75
+ let parsed;
76
+ try {
77
+ parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
78
+ } catch (e) {
79
+ skipped.push(file);
80
+ reasons[file] = `unreadable: ${e.message}`;
81
+ continue;
82
+ }
83
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
84
+ skipped.push(file);
85
+ reasons[file] = 'unreadable: not a JSON object';
86
+ continue;
87
+ }
88
+ // Never resurrected — a tombstoned id stays out even if its file says
89
+ // 'active' (see activeIndexMerge.cjs's header on why tombstones are
90
+ // never auto-cleared).
91
+ if (hasOwn(tombstones, id)) {
92
+ skipped.push(file);
93
+ reasons[file] = 'tombstoned — never resurrected by a rebuild';
94
+ continue;
95
+ }
96
+ if (!parsed.status) {
97
+ skipped.push(file);
98
+ reasons[file] = 'no status mirror on this file — never guessed at';
99
+ continue;
100
+ }
101
+ // A completed archive (markCompleted's PromptSessionArchive, or any
102
+ // mirror written after archival) is never restored as a live row.
103
+ if (parsed.archivedAt) {
104
+ skipped.push(file);
105
+ reasons[file] = 'archivedAt is set — completed Epic, not a live row';
106
+ continue;
107
+ }
108
+ if (parsed.status !== 'proposed' && parsed.status !== 'active') {
109
+ skipped.push(file);
110
+ reasons[file] = `status '${parsed.status}' is not a live row`;
111
+ continue;
112
+ }
113
+ // Drop mirror-only bookkeeping (`indexedAt`) — it is not part of the
114
+ // PromptSession shape active-index.json's `sessions` map holds.
115
+ const { indexedAt, archivedAt, ...row } = parsed;
116
+ rows.push({ ...row, id, cwd: parsed.cwd || cwd });
117
+ }
118
+
119
+ if (!dryRun) {
120
+ const sessions = {};
121
+ for (const row of rows) sessions[row.id] = row;
122
+ writeActiveIndex(cwd, {
123
+ sessions,
124
+ events: existing.events || {},
125
+ tombstones,
126
+ });
127
+ }
128
+
129
+ return { rows, skipped, reasons };
130
+ });
131
+ }
132
+
133
+ module.exports = { rebuildActiveIndex };
@@ -30,7 +30,7 @@
30
30
  const fs = require('node:fs');
31
31
  const path = require('node:path');
32
32
 
33
- const BUILD_TARGET_SUBPATH = ['session-manager-operations', 'architecture', 'build-target.json'];
33
+ const BUILD_TARGET_SUBPATH = ['architecture', 'build-target.json']; // joined under opsOwnership.opsPath
34
34
 
35
35
  function readJson(filePath) {
36
36
  try {
@@ -48,7 +48,8 @@ function readJson(filePath) {
48
48
  function resolveBuildTarget(cwd) {
49
49
  if (!cwd || typeof cwd !== 'string') return null;
50
50
 
51
- const configPath = path.join(cwd, ...BUILD_TARGET_SUBPATH);
51
+ const { opsPath } = require('./opsOwnership.cjs');
52
+ const configPath = opsPath(cwd, ...BUILD_TARGET_SUBPATH);
52
53
  const explicit = readJson(configPath);
53
54
  if (explicit && typeof explicit === 'object' && !Array.isArray(explicit) && typeof explicit.packageName === 'string') {
54
55
  return explicit;
@@ -49,6 +49,10 @@
49
49
  const fs = require('node:fs');
50
50
  const { spawn } = require('node:child_process');
51
51
 
52
+ // Grace period between the post-exit SIGTERM sweep and the follow-up SIGKILL
53
+ // sweep of a detached child's process group. See sweepChildProcessGroup below.
54
+ const POST_EXIT_GROUP_SWEEP_GRACE_MS = 5000;
55
+
52
56
  // ---------- Phase 1 ----------
53
57
 
54
58
  /**
@@ -119,6 +123,33 @@ function openLog(logPath) {
119
123
  * }} opts
120
124
  * @returns {{ child: import('child_process').ChildProcess | null, cancel: () => void }}
121
125
  */
126
+ // Post-exit process-group sweep.
127
+ //
128
+ // Incident (2026-08-31, ~/Projects/starry-night-ships): scheduler jobs spawn
129
+ // detached: true, and every watchdog path (result-tail, deadman, idle-tail)
130
+ // kills the whole process group via ctx.killTree — but only on an *overrun*
131
+ // exit. When the executor child exits normally, nothing swept the group, so
132
+ // anything the agent had backgrounded to work around the foreground call cap
133
+ // (the `bash -c '<gate> > /tmp/x.log; echo $? > /tmp/x.done'` shape) reparented
134
+ // to init and kept running for nobody: three orphaned test batteries at PPID 1
135
+ // were found, one burning ~5 cores for 56 minutes. The invariant this restores:
136
+ // a job's descendants must not outlive the job. Killing the job's own process
137
+ // group at the job's own exit is ownership-based and needs no /proc heuristics
138
+ // to distinguish an abandoned descendant from a live one.
139
+ function sweepChildProcessGroup(child, detached) {
140
+ if (!detached) return;
141
+ if (!child || !child.pid || child.pid <= 1) return;
142
+ // Defensive: never target the Session Manager process's own group.
143
+ if (child.pid === process.pid) return;
144
+
145
+ try { process.kill(-child.pid, 'SIGTERM'); } catch { /* ESRCH: empty group, the normal case */ }
146
+
147
+ const t = setTimeout(() => {
148
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* ESRCH: empty group, the normal case */ }
149
+ }, POST_EXIT_GROUP_SWEEP_GRACE_MS);
150
+ if (t.unref) t.unref();
151
+ }
152
+
122
153
  function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watchdogs = [], onExit }) {
123
154
  const startedAt = Date.now();
124
155
 
@@ -183,6 +214,7 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
183
214
  safeLog,
184
215
  });
185
216
  }
217
+ sweepChildProcessGroup(ctx.child, !!spawnSpec.options?.detached);
186
218
  closeFd();
187
219
  };
188
220
 
@@ -215,4 +247,4 @@ function withChildAndLog({ fd, logPath, safeLog, closeFd, spawn: spawnSpec, watc
215
247
  return { child, cancel };
216
248
  }
217
249
 
218
- module.exports = { openLog, withChildAndLog };
250
+ module.exports = { openLog, withChildAndLog, POST_EXIT_GROUP_SWEEP_GRACE_MS };
@@ -66,6 +66,7 @@ const {
66
66
  } = require('./epicMint.cjs');
67
67
  const { appendAuditEvent } = require('./auditLog.cjs');
68
68
  const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
69
+ const { resolveProjectContext } = require('./projectRootResolve.cjs');
69
70
 
70
71
  /** Only these three Epic tags make sense for an inbound report — they are
71
72
  * exactly the tags that route through /develop (tagLibrary.ts). 'build',
@@ -251,8 +252,14 @@ function validateFeedbackInput(input = {}) {
251
252
  } catch (e) {
252
253
  return bad(400, `toCwd rejected: ${e.message}`);
253
254
  }
255
+ // Normalize fromCwd (worktree -> main tree, ops-internal path -> project
256
+ // root) BEFORE validatePath/the same-project check below — an agent
257
+ // sending feedback from inside its own Epic's worktree pwd must be
258
+ // compared/chained against its REAL project, not the ephemeral checkout.
259
+ // See projectRootResolve.cjs's header.
260
+ const normalizedFromCwd = resolveProjectContext({ cwd: input.fromCwd }).cwd || input.fromCwd;
254
261
  try {
255
- fromCwd = config.validatePath(path.resolve(input.fromCwd));
262
+ fromCwd = config.validatePath(path.resolve(normalizedFromCwd));
256
263
  } catch (e) {
257
264
  return bad(400, `fromCwd rejected: ${e.message}`);
258
265
  }