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.
- package/dist/assets/{TiptapBody-CtuLATFR.js → TiptapBody-MCzz_6Zm.js} +1 -1
- package/dist/assets/{index-CefVSjmm.js → index-BPbLVT6E.js} +490 -488
- package/dist/assets/{index-B6JNrbpD.css → index-DVlD8N1X.css} +2 -2
- package/dist/index.html +2 -2
- package/package.json +3 -3
- package/scripts/lib/activeSessions.cjs +26 -3
- package/scripts/lib/watchdogHelpers.cjs +13 -6
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +84 -0
- package/src/main/__tests__/prdCreate.test.cjs +6 -0
- package/src/main/__tests__/prdLocations.test.cjs +41 -0
- package/src/main/__tests__/prdMigration.test.cjs +41 -0
- package/src/main/__tests__/promptSessionEvents.test.cjs +106 -0
- package/src/main/__tests__/queueHistory.test.cjs +2 -2
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +108 -0
- package/src/main/__tests__/runVerify.test.cjs +459 -4
- package/src/main/__tests__/scheduler-admin-routes.test.cjs +52 -3
- package/src/main/__tests__/scheduler-archive-completed-prd.test.cjs +102 -0
- package/src/main/__tests__/scheduler-commit-guard-noop.test.cjs +126 -0
- package/src/main/__tests__/scheduler-committed-in-window.test.cjs +58 -3
- package/src/main/__tests__/scheduler-find-prd-dir.test.cjs +42 -0
- package/src/main/__tests__/scheduler-investigation-clean-skip.test.cjs +63 -0
- package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +23 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +68 -0
- package/src/main/__tests__/scheduler-reset-job-fields-guard.test.cjs +77 -0
- package/src/main/__tests__/scheduler-unreadable-queue-guard.test.cjs +62 -0
- package/src/main/browserView.cjs +5 -4
- package/src/main/chatRunner.cjs +90 -7
- package/src/main/config.cjs +9 -0
- package/src/main/ipcSchemas.cjs +1 -1
- package/src/main/lib/__tests__/terminalRunOutcome.test.cjs +118 -0
- package/src/main/lib/prdLocations.cjs +43 -6
- package/src/main/lib/prdMigration.cjs +5 -3
- package/src/main/lib/queueHistory.cjs +1 -1
- package/src/main/lib/rcaFeedbackHook.cjs +55 -5
- package/src/main/lib/terminalRunOutcome.cjs +100 -0
- package/src/main/promptSessionEvents.cjs +87 -0
- package/src/main/runVerify.cjs +195 -3
- package/src/main/scheduler.cjs +436 -80
- 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-
|
|
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-
|
|
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.
|
|
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 >=
|
|
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 >=
|
|
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
|
-
|
|
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
|
|
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
|
|
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', () => {
|