claude-code-session-manager 0.39.1 → 0.39.2

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 (39) hide show
  1. package/dist/assets/{TiptapBody-CtuLATFR.js → TiptapBody-MCzz_6Zm.js} +1 -1
  2. package/dist/assets/{index-CefVSjmm.js → index-BPbLVT6E.js} +490 -488
  3. package/dist/assets/{index-B6JNrbpD.css → index-DVlD8N1X.css} +2 -2
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -3
  6. package/scripts/lib/activeSessions.cjs +26 -3
  7. package/scripts/lib/watchdogHelpers.cjs +13 -6
  8. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +84 -0
  9. package/src/main/__tests__/prdCreate.test.cjs +6 -0
  10. package/src/main/__tests__/prdLocations.test.cjs +41 -0
  11. package/src/main/__tests__/prdMigration.test.cjs +41 -0
  12. package/src/main/__tests__/promptSessionEvents.test.cjs +106 -0
  13. package/src/main/__tests__/queueHistory.test.cjs +2 -2
  14. package/src/main/__tests__/rcaFeedbackHook.test.cjs +108 -0
  15. package/src/main/__tests__/runVerify.test.cjs +459 -4
  16. package/src/main/__tests__/scheduler-admin-routes.test.cjs +52 -3
  17. package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +102 -0
  18. package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +126 -0
  19. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +58 -3
  20. package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +42 -0
  21. package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +63 -0
  22. package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +23 -0
  23. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +68 -0
  24. package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +77 -0
  25. package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +62 -0
  26. package/src/main/browserView.cjs +5 -4
  27. package/src/main/chatRunner.cjs +90 -7
  28. package/src/main/config.cjs +9 -0
  29. package/src/main/ipcSchemas.cjs +1 -1
  30. package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +118 -0
  31. package/src/main/lib/prdLocations.cjs +43 -6
  32. package/src/main/lib/prdMigration.cjs +5 -3
  33. package/src/main/lib/queueHistory.cjs +1 -1
  34. package/src/main/lib/rcaFeedbackHook.cjs +55 -5
  35. package/src/main/lib/terminalRunOutcome.cjs +100 -0
  36. package/src/main/promptSessionEvents.cjs +87 -0
  37. package/src/main/runVerify.cjs +195 -3
  38. package/src/main/scheduler.cjs +436 -80
  39. package/src/preload/api.d.ts +5 -1
package/dist/index.html CHANGED
@@ -7,10 +7,10 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400&family=Geist:wght@300;400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="./assets/index-CefVSjmm.js"></script>
10
+ <script type="module" crossorigin src="./assets/index-BPbLVT6E.js"></script>
11
11
  <link rel="modulepreload" crossorigin href="./assets/monaco-editor-BW5C4Iv1.js">
12
12
  <link rel="stylesheet" crossorigin href="./assets/monaco-editor-BTnBOi8r.css">
13
- <link rel="stylesheet" crossorigin href="./assets/index-B6JNrbpD.css">
13
+ <link rel="stylesheet" crossorigin href="./assets/index-DVlD8N1X.css">
14
14
  </head>
15
15
  <body class="bg-bg text-fg font-sans antialiased">
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.39.1",
3
+ "version": "0.39.2",
4
4
  "description": "Local cockpit for the Claude Code CLI — multi-tab terminal, full config surface, scheduler, voice dictation, and live observability.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -31,13 +31,13 @@
31
31
  "postinstall": "node scripts/postinstall.cjs",
32
32
  "prepublishOnly": "vite build",
33
33
  "test:unit": "vitest run",
34
- "test:verify": "node src/main/__tests__/runVerify.test.cjs",
35
34
  "test:e2e": "xvfb-run -a playwright test",
36
35
  "smoke:darwin": "playwright test tests/smoke/darwin-boot.spec.ts",
37
36
  "test:e2e:mic": "xvfb-run -a playwright test e2e/mic.spec.mjs",
38
37
  "test:e2e:gen-fixture": "espeak-ng -w /tmp/sm-raw.wav -s 140 'testing one two three four five' && ffmpeg -y -i /tmp/sm-raw.wav -ar 48000 -ac 2 -sample_fmt s16 e2e/fixtures/speech.wav",
39
38
  "refresh:vad-assets": "cp node_modules/@ricky0123/vad-web/dist/silero_vad_v5.onnx node_modules/@ricky0123/vad-web/dist/vad.worklet.bundle.min.js src/renderer/public/vad/ && cp node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.wasm node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.mjs node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.mjs src/renderer/public/vad/",
40
- "health": "node src/main/health.cjs"
39
+ "health": "node src/main/health.cjs",
40
+ "lint:selectors": "node scripts/check-unstable-selectors.cjs"
41
41
  },
42
42
  "keywords": [
43
43
  "claude",
@@ -61,14 +61,18 @@ function readTailLines(filePath, maxBytes) {
61
61
  */
62
62
  function activeProjectCwds(maxAgeMin = 90, {
63
63
  projectsDir = path.join(HOME, '.claude', 'projects'),
64
+ maxCwds = MAX_CWDS,
64
65
  } = {}) {
66
+ // maxAgeMin === Infinity means "every project ever seen, no recency filter"
67
+ // (allProjectCwds below). Date.now() - Infinity is -Infinity, which every
68
+ // mtime clears — spelled out here because it reads like an accident.
65
69
  const cutoffMs = Date.now() - maxAgeMin * 60 * 1000;
66
70
  const seen = new Set();
67
71
  const result = [];
68
72
 
69
73
  function addCwd(cwd) {
70
74
  if (!cwd || typeof cwd !== 'string') return;
71
- if (seen.has(cwd) || result.length >= MAX_CWDS) return;
75
+ if (seen.has(cwd) || result.length >= maxCwds) return;
72
76
  try { fs.statSync(cwd); } catch { return; } // must exist on disk
73
77
  seen.add(cwd);
74
78
  result.push(cwd);
@@ -79,7 +83,7 @@ function activeProjectCwds(maxAgeMin = 90, {
79
83
  try { slugs = fs.readdirSync(projectsDir); } catch { return result; }
80
84
 
81
85
  for (const slug of slugs) {
82
- if (result.length >= MAX_CWDS) break;
86
+ if (result.length >= maxCwds) break;
83
87
  const projDir = path.join(projectsDir, slug);
84
88
  let entries;
85
89
  try {
@@ -114,4 +118,23 @@ function activeProjectCwds(maxAgeMin = 90, {
114
118
  return result;
115
119
  }
116
120
 
117
- module.exports = { activeProjectCwds };
121
+ /**
122
+ * allProjectCwds(opts?) → string[]
123
+ *
124
+ * Every project cwd this machine has ever opened a Claude session in, with NO
125
+ * recency filter — the same transcript scan, minus the cutoff.
126
+ *
127
+ * Recency is the right question for "which projects should I sweep for new
128
+ * feedback". It is the WRONG question for "which projects own PRDs": a
129
+ * project that has been quiet for 90 minutes still owns its queued work, and
130
+ * treating its PRD dir as non-existent makes reconcile() read absence as
131
+ * deletion. (2026-07-31: 142 PRDs across 6 quiet projects went unscannable.)
132
+ *
133
+ * The cap is raised well above activeProjectCwds' 50 because this list is
134
+ * historical rather than "currently in flight".
135
+ */
136
+ function allProjectCwds(opts = {}) {
137
+ return activeProjectCwds(Infinity, { maxCwds: 500, ...opts });
138
+ }
139
+
140
+ module.exports = { activeProjectCwds, allProjectCwds };
@@ -8,6 +8,7 @@ const os = require('node:os');
8
8
  const path = require('node:path');
9
9
 
10
10
  const { activeProjectCwds } = require('./activeSessions.cjs');
11
+ const { resolvePrdWriteDir } = require('../../src/main/lib/prdLocations.cjs');
11
12
 
12
13
  // Mirrors scheduler.cjs:215 (single source of truth there; kept in sync here).
13
14
  const DEFAULT_HEARTBEAT_PATH = path.join(
@@ -101,9 +102,6 @@ function heartbeatFresh(heartbeatPath = DEFAULT_HEARTBEAT_PATH, maxAgeMs = DEFAU
101
102
  const DEFAULT_QUEUE_PATH = path.join(
102
103
  os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'queue.json',
103
104
  );
104
- const DEFAULT_PRDS_DIR = path.join(
105
- os.homedir(), '.claude', 'session-manager', 'scheduled-plans', 'prds',
106
- );
107
105
  const PLUGIN_CACHE_ROOT = path.join(
108
106
  os.homedir(), '.claude', 'plugins', 'cache', 'session-manager', 'session-manager-dev',
109
107
  );
@@ -295,7 +293,7 @@ function hasOpenFeedback(cwd) {
295
293
  * Complexity: O(P) over PRD files in prdsDir + O(J) over queue jobs.
296
294
  */
297
295
  function emitFeedbackPRD(cwd, {
298
- prdsDir = DEFAULT_PRDS_DIR,
296
+ prdsDir,
299
297
  queuePath = DEFAULT_QUEUE_PATH,
300
298
  skillPath,
301
299
  standardsPath,
@@ -303,7 +301,14 @@ function emitFeedbackPRD(cwd, {
303
301
  } = {}) {
304
302
  // Explicit overrides (used by tests) are honored verbatim and skip the
305
303
  // candidate search entirely — only resolve via candidates when the caller
306
- // didn't pass a path.
304
+ // didn't pass a path. Same convention for prdsDir: an explicit override
305
+ // (tests pass a tmpdir) wins verbatim; otherwise resolve the project's own
306
+ // PRD dir so the sweep writer agrees with scheduler.cjs's reader (see
307
+ // prdLocations.cjs — PRD chain 808→811 moved PRD sources out of the legacy
308
+ // global dir into `<cwd>/session-manager-operations/scheduler/prds`).
309
+ if (prdsDir === undefined) {
310
+ prdsDir = resolvePrdWriteDir(cwd);
311
+ }
307
312
  const skillPathExplicit = skillPath !== undefined;
308
313
  const standardsPathExplicit = standardsPath !== undefined;
309
314
  if (!skillPathExplicit) {
@@ -616,7 +621,7 @@ function maybeRelaunchApp({
616
621
  */
617
622
  function sweep({
618
623
  projectsDir,
619
- prdsDir = DEFAULT_PRDS_DIR,
624
+ prdsDir,
620
625
  queuePath = DEFAULT_QUEUE_PATH,
621
626
  skillPath,
622
627
  standardsPath,
@@ -634,6 +639,8 @@ function sweep({
634
639
  scanned++;
635
640
  if (!hasOpenFeedback(cwd)) continue;
636
641
 
642
+ // prdsDir unset → emitFeedbackPRD resolves per-cwd (project's own PRD
643
+ // dir); an explicit override (tests) is forwarded verbatim to every cwd.
637
644
  let result;
638
645
  try {
639
646
  result = emitFeedbackPRD(cwd, { prdsDir, queuePath, skillPath, standardsPath });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * browserView-destroyed-handler.test.cjs — regression test for the
3
+ * webContents.id-after-destroy crash (see
4
+ * session-manager-operations/feedback/processed/
5
+ * 2026-07-30-rca-v0390-blank-screen-unstable-selector-185.md, item 4).
6
+ *
7
+ * The webContents `'destroyed'` handler used to read `view.webContents.id`
8
+ * from inside its own callback, after `view.webContents` could already be
9
+ * undefined — throwing "Cannot read properties of undefined (reading 'id')"
10
+ * on every app quit with an open Browser view. The fix captures the id at
11
+ * creation time, before the destroyed handler is registered.
12
+ *
13
+ * `electron` isn't installed as a runnable binary here, and browserView.cjs
14
+ * pulls it in via a plain CJS `require` (not vite-node's ESM graph), so
15
+ * `vi.mock('electron', ...)` doesn't intercept it. Instead we pre-seed
16
+ * Node's own require cache for the resolved `electron` path with a fake
17
+ * `WebContentsView`, the only export browserView.cjs actually uses.
18
+ *
19
+ * Run: timeout 300 npx vitest run src/main/__tests__/browserView-destroyed-handler.test.cjs
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ import { test, expect, beforeEach } from 'vitest';
25
+ import { EventEmitter } from 'node:events';
26
+
27
+ let nextWcId = 1;
28
+ class FakeWebContents extends EventEmitter {
29
+ constructor() {
30
+ super();
31
+ this.id = nextWcId++;
32
+ this.session = { on() {} };
33
+ }
34
+ setZoomFactor() {}
35
+ setWindowOpenHandler() {}
36
+ isDestroyed() { return false; }
37
+ }
38
+ class FakeWebContentsView {
39
+ constructor() {
40
+ this.webContents = new FakeWebContents();
41
+ }
42
+ }
43
+
44
+ const electronPath = require.resolve('electron');
45
+ require.cache[electronPath] = {
46
+ id: electronPath,
47
+ filename: electronPath,
48
+ loaded: true,
49
+ exports: { WebContentsView: FakeWebContentsView },
50
+ };
51
+
52
+ const { registerBrowserView, isBrowserViewContents, views } = require('../browserView.cjs');
53
+
54
+ function makeFakeIpcMain() {
55
+ const handlers = new Map();
56
+ return {
57
+ handle: (channel, fn) => handlers.set(channel, fn),
58
+ on: (channel, fn) => handlers.set(channel, fn),
59
+ invoke: (channel, payload) => handlers.get(channel)(null, payload),
60
+ };
61
+ }
62
+
63
+ let ipcMain;
64
+ beforeEach(() => {
65
+ ipcMain = makeFakeIpcMain();
66
+ registerBrowserView({ mainWindow: { isDestroyed: () => true }, ipcMain });
67
+ });
68
+
69
+ test('destroyed handler does not throw when view.webContents is already undefined', async () => {
70
+ const viewId = `t-${Date.now()}-${Math.random().toString(36).slice(2)}`;
71
+ const result = await ipcMain.invoke('browser:create', { viewId, partition: 'sm-test' });
72
+ expect(result.ok).toBe(true);
73
+
74
+ const view = views.get(viewId);
75
+ const wc = view.webContents;
76
+ expect(isBrowserViewContents(wc.id)).toBe(true);
77
+
78
+ // Simulate the real crash precondition: webContents already gone by the
79
+ // time the 'destroyed' listener fires (e.g. on app quit).
80
+ view.webContents = undefined;
81
+
82
+ expect(() => wc.emit('destroyed')).not.toThrow();
83
+ expect(isBrowserViewContents(wc.id)).toBe(false);
84
+ });
@@ -194,9 +194,15 @@ function validCreateBody(overrides = {}) {
194
194
  }
195
195
 
196
196
  async function startWithRemote(remote) {
197
+ // Force the e2e-suffixed token path so this suite never races the real
198
+ // ~/.claude/session-manager/admin-api.json against other test files (or a
199
+ // live production instance) that also call createAdminHttp() concurrently.
200
+ const prevE2e = process.env.SM_E2E;
201
+ process.env.SM_E2E = '1';
197
202
  const admin = createAdminHttp();
198
203
  registerAdminRoute(admin, remote);
199
204
  const { port, token } = await admin.start();
205
+ if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
200
206
  return { admin, port, token };
201
207
  }
202
208
 
@@ -33,6 +33,47 @@ test('resolvePrdWriteDir throws on a missing cwd', () => {
33
33
  expect(() => resolvePrdWriteDir('')).toThrow();
34
34
  });
35
35
 
36
+ test('resolvePrdsDirs still finds a QUIET project\'s PRDs dir (no recency filter)', async () => {
37
+ // Regression cover for 2026-07-31: discovery used activeProjectCwds' 90-min
38
+ // window, so a project nobody had touched recently became unscannable — and
39
+ // reconcile() reads an unscannable PRD as a deleted one and drops its queue
40
+ // row. 142 PRDs across 6 quiet projects were affected.
41
+ const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-quiet-projects-'));
42
+ tmpDirs.push(projectsDir);
43
+ const quietCwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-quiet-cwd-'));
44
+ tmpDirs.push(quietCwd);
45
+
46
+ // The project owns a real PRDs dir on disk...
47
+ fs.mkdirSync(resolvePrdWriteDir(quietCwd), { recursive: true });
48
+
49
+ const projDir = path.join(projectsDir, 'quiet-project');
50
+ fs.mkdirSync(projDir, { recursive: true });
51
+ const transcript = path.join(projDir, 'session1.jsonl');
52
+ fs.writeFileSync(transcript, `${JSON.stringify({ cwd: quietCwd })}\n`);
53
+ // ...but its last session was a week ago, far outside the active window.
54
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
55
+ fs.utimesSync(transcript, weekAgo, weekAgo);
56
+
57
+ const dirs = resolvePrdsDirs(90, { projectsDir });
58
+ expect(dirs).toContain(resolvePrdWriteDir(quietCwd));
59
+ });
60
+
61
+ test('resolvePrdsDirs skips a quiet project that owns no PRDs dir', async () => {
62
+ const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-nodir-projects-'));
63
+ tmpDirs.push(projectsDir);
64
+ const quietCwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdloc-nodir-cwd-'));
65
+ tmpDirs.push(quietCwd);
66
+
67
+ const projDir = path.join(projectsDir, 'quiet-no-prds');
68
+ fs.mkdirSync(projDir, { recursive: true });
69
+ const transcript = path.join(projDir, 'session1.jsonl');
70
+ fs.writeFileSync(transcript, `${JSON.stringify({ cwd: quietCwd })}\n`);
71
+ const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
72
+ fs.utimesSync(transcript, weekAgo, weekAgo);
73
+
74
+ expect(resolvePrdsDirs(90, { projectsDir })).not.toContain(resolvePrdWriteDir(quietCwd));
75
+ });
76
+
36
77
  test('resolvePrdsDirs maps each active project cwd to its own PRDs dir', async () => {
37
78
  const projectsDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdlocations-projects-'));
38
79
  tmpDirs.push(projectsDir);
@@ -103,6 +103,47 @@ test('migratePrds ignores non-.md and dotfiles, and no-ops on a missing legacy d
103
103
  expect(missing).toEqual({ moved: 0, skipped: 0, unresolved: [] });
104
104
  });
105
105
 
106
+ test('migratePrds expands a tilde-prefixed cwd and moves the file', async () => {
107
+ const legacyDir = await mkLegacyDir();
108
+ const projectCwd = await fsp.mkdtemp(path.join(os.homedir(), '.sm-prdmigration-tilde-'));
109
+ tmpDirs.push(projectCwd);
110
+ const tildeCwd = path.join('~', path.relative(os.homedir(), projectCwd));
111
+ const body = `---\ntitle: Tilde cwd\ncwd: ${tildeCwd}\nestimateMinutes: 15\n---\n\nBody.\n`;
112
+ await fsp.writeFile(path.join(legacyDir, '07-tilde.md'), body, 'utf8');
113
+
114
+ const result = await migratePrds(legacyDir);
115
+
116
+ expect(result.moved).toBe(1);
117
+ expect(result.unresolved).toEqual([]);
118
+ const dest = path.join(resolvePrdWriteDir(projectCwd), '07-tilde.md');
119
+ expect(fs.existsSync(dest)).toBe(true);
120
+ });
121
+
122
+ test('migratePrds expands a tilde cwd under a temp HOME (regression: stale installs lacking expandHome leave these unresolved)', async () => {
123
+ const legacyDir = await mkLegacyDir();
124
+ const fakeHome = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prdmigration-fakehome-'));
125
+ tmpDirs.push(fakeHome);
126
+ const projectCwd = path.join(fakeHome, 'Projects', 'session-manager');
127
+ await fsp.mkdir(projectCwd, { recursive: true });
128
+
129
+ const originalHome = process.env.HOME;
130
+ process.env.HOME = fakeHome;
131
+ try {
132
+ const body = `---\ntitle: Tilde under fake HOME\ncwd: ~/Projects/session-manager\nestimateMinutes: 15\n---\n\nBody.\n`;
133
+ await fsp.writeFile(path.join(legacyDir, '08-tilde-fakehome.md'), body, 'utf8');
134
+
135
+ const result = await migratePrds(legacyDir);
136
+
137
+ expect(result.moved).toBe(1);
138
+ expect(result.unresolved).toEqual([]);
139
+ expect(fs.existsSync(path.join(legacyDir, '08-tilde-fakehome.md'))).toBe(false);
140
+ const dest = path.join(resolvePrdWriteDir(projectCwd), '08-tilde-fakehome.md');
141
+ expect(fs.existsSync(dest)).toBe(true);
142
+ } finally {
143
+ process.env.HOME = originalHome;
144
+ }
145
+ });
146
+
106
147
  test('migratePrds moves multiple PRDs to different projects independently', async () => {
107
148
  const legacyDir = await mkLegacyDir();
108
149
  const cwdA = await mkProjectCwd();
@@ -0,0 +1,106 @@
1
+ /**
2
+ * promptSessionEvents.test.cjs — unit tests for appendResponseEventIfKnown
3
+ * (PRD 814): the scheduler's main-process counterpart to promptSessions.ts's
4
+ * event chain, used to route a PRD-finished notification into a known
5
+ * PromptSession's own event chain instead of an unrelated tab.
6
+ *
7
+ * Run: timeout 120 npx vitest run src/main/__tests__/promptSessionEvents.test.cjs
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ import { test, expect, beforeEach } from 'vitest';
13
+ const fs = require('node:fs');
14
+ const fsp = require('node:fs/promises');
15
+ const os = require('node:os');
16
+ const path = require('node:path');
17
+ const config = require('../config.cjs');
18
+ const { appendResponseEventIfKnown, promptSessionActiveIndexPath } = require('../promptSessionEvents.cjs');
19
+
20
+ let cwd;
21
+
22
+ beforeEach(async () => {
23
+ cwd = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-prompt-session-events-'));
24
+ config.addAllowedRoot(cwd);
25
+ });
26
+
27
+ async function writeIndex(index) {
28
+ const indexPath = promptSessionActiveIndexPath(cwd);
29
+ await fsp.mkdir(path.dirname(indexPath), { recursive: true });
30
+ await fsp.writeFile(indexPath, JSON.stringify(index, null, 2));
31
+ }
32
+
33
+ test('returns false when no active-index.json exists for the cwd', async () => {
34
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-unknown', 'hi');
35
+ expect(routed).toBe(false);
36
+ });
37
+
38
+ test('returns false when the sourcePromptId is not in the index', async () => {
39
+ await writeIndex({ sessions: {}, events: {} });
40
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-unknown', 'hi');
41
+ expect(routed).toBe(false);
42
+ });
43
+
44
+ test('returns false when the session is completed (no longer active)', async () => {
45
+ await writeIndex({
46
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'completed' } },
47
+ events: { 'psess-1': [{ id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' }] },
48
+ });
49
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'hi');
50
+ expect(routed).toBe(false);
51
+ });
52
+
53
+ test('appends a response event chained to the current tail and returns true', async () => {
54
+ await writeIndex({
55
+ sessions: { 'psess-1': { id: 'psess-1', cwd, status: 'active' } },
56
+ events: {
57
+ 'psess-1': [
58
+ { id: 'pevt-1', promptSessionId: 'psess-1', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' },
59
+ { id: 'pevt-2', promptSessionId: 'psess-1', kind: 'prd_created', causedByEventId: 'pevt-1', at: '2026-01-01T00:01:00.000Z', prdSlug: '814-foo' },
60
+ ],
61
+ },
62
+ });
63
+
64
+ const routed = await appendResponseEventIfKnown(cwd, 'psess-1', 'PRD 814-foo finished: completed.');
65
+ expect(routed).toBe(true);
66
+
67
+ const onDisk = JSON.parse(await fsp.readFile(promptSessionActiveIndexPath(cwd), 'utf8'));
68
+ const events = onDisk.events['psess-1'];
69
+ expect(events).toHaveLength(3);
70
+ expect(events[2].kind).toBe('response');
71
+ expect(events[2].causedByEventId).toBe('pevt-2');
72
+ expect(events[2].text).toBe('PRD 814-foo finished: completed.');
73
+ });
74
+
75
+ test('two different PromptSessions in the same cwd each get their own non-interleaved event appended', async () => {
76
+ await writeIndex({
77
+ sessions: {
78
+ 'psess-a': { id: 'psess-a', cwd, status: 'active' },
79
+ 'psess-b': { id: 'psess-b', cwd, status: 'active' },
80
+ },
81
+ events: {
82
+ 'psess-a': [{ id: 'pevt-a1', promptSessionId: 'psess-a', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' }],
83
+ 'psess-b': [{ id: 'pevt-b1', promptSessionId: 'psess-b', kind: 'prompt', causedByEventId: null, at: '2026-01-01T00:00:00.000Z' }],
84
+ },
85
+ });
86
+
87
+ const [routedA, routedB] = await Promise.all([
88
+ appendResponseEventIfKnown(cwd, 'psess-a', 'PRD A finished'),
89
+ appendResponseEventIfKnown(cwd, 'psess-b', 'PRD B finished'),
90
+ ]);
91
+ expect(routedA).toBe(true);
92
+ expect(routedB).toBe(true);
93
+
94
+ const onDisk = JSON.parse(await fsp.readFile(promptSessionActiveIndexPath(cwd), 'utf8'));
95
+ expect(onDisk.events['psess-a']).toHaveLength(2);
96
+ expect(onDisk.events['psess-b']).toHaveLength(2);
97
+ expect(onDisk.events['psess-a'][1].text).toBe('PRD A finished');
98
+ expect(onDisk.events['psess-a'][1].causedByEventId).toBe('pevt-a1');
99
+ expect(onDisk.events['psess-b'][1].text).toBe('PRD B finished');
100
+ expect(onDisk.events['psess-b'][1].causedByEventId).toBe('pevt-b1');
101
+ });
102
+
103
+ test('returns false (never throws) when cwd or sourcePromptId is missing', async () => {
104
+ await expect(appendResponseEventIfKnown(null, 'psess-1', 'hi')).resolves.toBe(false);
105
+ await expect(appendResponseEventIfKnown(cwd, null, 'hi')).resolves.toBe(false);
106
+ });
@@ -208,8 +208,8 @@ test('historyTerminalBySlug: returns status + finishedAt for every archived slug
208
208
  await queueHistory.appendHistory([a, b]);
209
209
 
210
210
  const map = await queueHistory.historyTerminalBySlug();
211
- expect(map.get('term-completed')).toEqual({ status: 'completed', finishedAt: a.finishedAt });
212
- expect(map.get('term-failed')).toEqual({ status: 'failed', finishedAt: b.finishedAt });
211
+ expect(map.get('term-completed')).toEqual({ status: 'completed', finishedAt: a.finishedAt, landedCommit: null });
212
+ expect(map.get('term-failed')).toEqual({ status: 'failed', finishedAt: b.finishedAt, landedCommit: null });
213
213
  expect(map.has('never-archived-slug')).toBe(false);
214
214
  });
215
215
 
@@ -115,6 +115,62 @@ test('fileRcaFeedback: new runId for the same slug files a new RCA', async () =>
115
115
  expect(files.length).toBe(2);
116
116
  });
117
117
 
118
+ test('fileRcaFeedback: a duplicate is still detected after /process-feedback archives the file into processed/', async () => {
119
+ const cwd = makeProjectWithInbox();
120
+ const runDir = path.join(tmpHome, 'run1');
121
+ writeRun(runDir, 'testslug');
122
+ writePrd(cwd, 'testslug');
123
+ const job = baseJob({ cwd });
124
+
125
+ const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
126
+ expect(first.filed).toBe(true);
127
+
128
+ const feedbackDir = path.join(cwd, 'session-manager-operations', 'feedback');
129
+ const processedDir = path.join(feedbackDir, 'processed');
130
+ fs.mkdirSync(processedDir, { recursive: true });
131
+ const fileName = path.basename(first.path);
132
+ fs.renameSync(first.path, path.join(processedDir, fileName));
133
+
134
+ const second = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
135
+ expect(second.filed).toBe(false);
136
+ expect(second.reason).toBe('duplicate');
137
+ expect(second.path).toBe(path.join(processedDir, fileName));
138
+
139
+ expect(fs.existsSync(path.join(feedbackDir, fileName))).toBe(false);
140
+ const liveFiles = fs.readdirSync(feedbackDir).filter((f) => f !== 'processed');
141
+ expect(liveFiles.length).toBe(0);
142
+ expect(fs.readdirSync(processedDir).length).toBe(1);
143
+ });
144
+
145
+ test('fileRcaFeedback: investigationText updates the file in processed/ in place instead of duplicating it in the live dir', async () => {
146
+ const cwd = makeProjectWithInbox();
147
+ const runDir = path.join(tmpHome, 'run1');
148
+ writeRun(runDir, 'testslug');
149
+ writePrd(cwd, 'testslug');
150
+ const job = baseJob({ cwd });
151
+
152
+ const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
153
+ expect(first.filed).toBe(true);
154
+
155
+ const feedbackDir = path.join(cwd, 'session-manager-operations', 'feedback');
156
+ const processedDir = path.join(feedbackDir, 'processed');
157
+ fs.mkdirSync(processedDir, { recursive: true });
158
+ const fileName = path.basename(first.path);
159
+ const processedPath = path.join(processedDir, fileName);
160
+ fs.renameSync(first.path, processedPath);
161
+
162
+ const second = await rcaFeedbackHook.fileRcaFeedback({
163
+ job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
164
+ });
165
+ expect(second.filed).toBe(true);
166
+ expect(second.updated).toBe(true);
167
+ expect(second.path).toBe(processedPath);
168
+
169
+ const after = fs.readFileSync(processedPath, 'utf8');
170
+ expect(after).toContain('## Investigation analysis');
171
+ expect(fs.existsSync(path.join(feedbackDir, fileName))).toBe(false);
172
+ });
173
+
118
174
  // ─── failure-class matching ───────────────────────────────────────────────────
119
175
 
120
176
  test('classifyFailure: detects stuck-loop from an until/while-true/sleep tail', () => {
@@ -147,6 +203,58 @@ test('classifyFailure: unknown verdict with no matching markers falls back to un
147
203
  expect(rcaFeedbackHook.classifyFailure({ verdict: 'halt', logTail: 'nothing special' })).toBe('unknown');
148
204
  });
149
205
 
206
+ test('classifyFailure: detects self-queue from a "Launching skill: session-manager-dev:develop" tail', () => {
207
+ const logTail = ['doing setup', 'Launching skill: session-manager-dev:develop', 'skill running'].join('\n');
208
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
209
+ });
210
+
211
+ test('classifyFailure: detects self-queue from a "Launching skill: session-manager-dev:process-feedback" tail', () => {
212
+ const logTail = ['doing setup', 'Launching skill: session-manager-dev:process-feedback', 'skill running'].join('\n');
213
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
214
+ });
215
+
216
+ test('classifyFailure: detects self-queue from a ScheduleWakeup tool_use even when the tail also contains "sleep" (PRD 771 regression)', () => {
217
+ const logTail = [
218
+ 'launching background build: eas-cli build --wait',
219
+ 'the app will sleep between polls',
220
+ '{"type":"tool_use","name":"ScheduleWakeup","input":{"delaySeconds":600}}',
221
+ ].join('\n');
222
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('self-queue');
223
+ });
224
+
225
+ test('classifyFailure: detects already-shipped from a pass_no_commit tail citing a prior commit', () => {
226
+ const logTail = ['re-verified acceptance criteria', 'this work was already committed in 9cf0384', 'nothing left to change'].join('\n');
227
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('already-shipped');
228
+ });
229
+
230
+ test('classifyFailure: detects already-shipped from a pass_no_commit tail with "nothing new to commit"', () => {
231
+ const logTail = ['checked the diff', 'nothing new to commit for this PRD', 'exiting clean'].join('\n');
232
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('already-shipped');
233
+ });
234
+
235
+ test('classifyFailure: ALREADY_SHIPPED is checked before SELF_QUEUE, so a no_verdict_sentinel tail with both markers classifies as already-shipped', () => {
236
+ const logTail = [
237
+ 'Launching skill: session-manager-dev:develop',
238
+ 'the acceptance criteria were already implemented',
239
+ ].join('\n');
240
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('already-shipped');
241
+ });
242
+
243
+ test('classifyFailure: pass_no_commit tail with no already-shipped phrasing still falls back to no-sentinel (regression)', () => {
244
+ const logTail = ['ran the tests', 'everything looked fine', 'printed PASS'].join('\n');
245
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail })).toBe('no-sentinel');
246
+ });
247
+
248
+ test('classifyFailure: STUCK_LOOP/POST_AC_OVERRUN reordering does not change classification when no self-queue marker is present', () => {
249
+ const stuckLoopTail = ['doing work', 'until curl -s http://x | jq .uptime; do sleep 5; done', 'still waiting'].join('\n');
250
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: stuckLoopTail })).toBe('stuck-loop');
251
+
252
+ const postAcLines = ['- [x] first AC item done', '- [x] last AC item done'];
253
+ for (let i = 0; i < 30; i++) postAcLines.push(`extra post-AC work line ${i}`);
254
+ const postAcTail = postAcLines.join('\n');
255
+ expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: postAcTail })).toBe('post-ac-overrun');
256
+ });
257
+
150
258
  // ─── AC extraction ─────────────────────────────────────────────────────────────
151
259
 
152
260
  test('extractAcceptanceCriteria: pulls the section body between the heading and the next heading', () => {