claude-code-session-manager 0.62.0 → 0.62.1
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-Bn-LMce4.js → TiptapBody-BwN4pNC0.js} +1 -1
- package/dist/assets/{index-DOXvph-Z.css → index-CVCCMw5o.css} +1 -1
- package/dist/assets/{index-BfiMRkln.js → index-ZjTDk5b7.js} +1083 -1111
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
- package/src/main/config.cjs +1 -22
- package/src/main/files.cjs +20 -0
- package/src/main/index.cjs +1 -63
- package/src/main/ipcSchemas.cjs +11 -209
- package/src/main/lib/opsOwnership.cjs +0 -2
- package/src/main/lib/summarize.cjs +0 -1
- package/src/main/runVerify.cjs +55 -6
- package/src/main/scheduler.cjs +46 -7
- package/src/preload/api.d.ts +2 -197
- package/src/preload/index.cjs +1 -100
- package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
- package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
- package/src/main/browserAgentServer.cjs +0 -224
- package/src/main/browserCapture.cjs +0 -728
- package/src/main/browserView.cjs +0 -916
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
- package/src/main/lib/browserAgentActions.cjs +0 -114
- package/src/main/lib/e2eStateMachine.cjs +0 -39
- package/src/main/webRemote.cjs +0 -1367
- package/src/preload/browserViewPreload.cjs +0 -118
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-ZjTDk5b7.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-CVCCMw5o.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.62.
|
|
3
|
+
"version": "0.62.1",
|
|
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",
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runVerify-transcript-commit-evidence.test.cjs — regression tests for the
|
|
3
|
+
* transcript-commit-evidence fallback added to runVerify.cjs.
|
|
4
|
+
*
|
|
5
|
+
* Root cause (99-fix-e2e-needs-review-test, 2026-08-07): a job whose cwd is
|
|
6
|
+
* not a git repo (e.g. an investigation fix-plan that inherited cwd: /tmp)
|
|
7
|
+
* can never produce committedDuringRun=true — gitHead()/committedInWindow()
|
|
8
|
+
* both fail closed there — so the PASS+commit sentinel override could never
|
|
9
|
+
* fire even when the harness's own transcript recorded a real commit. This
|
|
10
|
+
* file asserts the fallback fires ONLY when both conditions hold: the cwd is
|
|
11
|
+
* genuinely not a git repo, AND the transcript itself carries harness-emitted
|
|
12
|
+
* commit evidence (never inferred from prose the model could fabricate).
|
|
13
|
+
*
|
|
14
|
+
* Run standalone: timeout 120 npx vitest run src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { test } from 'vitest';
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
const os = require('node:os');
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { execFileSync } = require('node:child_process');
|
|
23
|
+
const { verifyRun } = require('../runVerify.cjs');
|
|
24
|
+
|
|
25
|
+
// ─── helpers (mirrors runVerify.test.cjs) ─────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function makeTmpDir() {
|
|
28
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-commit-evidence-test-'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function rmdir(dir) {
|
|
32
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* */ }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function writeLog(dir, slug, events) {
|
|
36
|
+
const lines = ['[scheduler] starting ' + slug + ' at 2026-08-07T02:25:31.453Z'];
|
|
37
|
+
for (const ev of events) lines.push(JSON.stringify(ev));
|
|
38
|
+
lines.push('[scheduler] exit code=0 (raw code=0 signal=null) duration=47s');
|
|
39
|
+
fs.writeFileSync(path.join(dir, `${slug}.log`), lines.join('\n') + '\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writePrd(dir, slug, body) {
|
|
43
|
+
const text = `---\ntitle: Test PRD\ncwd: /tmp\nestimateMinutes: 30\n---\n${body}\n`;
|
|
44
|
+
fs.writeFileSync(path.join(dir, `${slug}.md`), text);
|
|
45
|
+
return path.join(dir, `${slug}.md`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function makeTmpGitRepo() {
|
|
49
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-commit-evidence-repo-'));
|
|
50
|
+
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
51
|
+
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir });
|
|
52
|
+
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir });
|
|
53
|
+
fs.writeFileSync(path.join(dir, 'README.md'), 'init\n');
|
|
54
|
+
execFileSync('git', ['add', 'README.md'], { cwd: dir });
|
|
55
|
+
execFileSync('git', ['commit', '-q', '-m', 'init'], { cwd: dir });
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a run log with:
|
|
61
|
+
* - padding events so a late is_error lands in the final 20%
|
|
62
|
+
* - a late is_error tool_result (main-agent, no self-recovery)
|
|
63
|
+
* - optionally a harness-emitted commit-evidence line
|
|
64
|
+
* - a SCHEDULER_VERDICT: PASS result event
|
|
65
|
+
*/
|
|
66
|
+
function buildRunEvents({ includeCommitEvidence, evidenceForm = 'vcs_state_changed' }) {
|
|
67
|
+
const events = [];
|
|
68
|
+
for (let k = 0; k < 8; k++) {
|
|
69
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
70
|
+
{ type: 'tool_use', id: `t${k}`, name: 'Read', input: { description: `read ${k}` } }] } });
|
|
71
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
72
|
+
{ type: 'tool_result', tool_use_id: `t${k}`, content: 'ok', is_error: false }] } });
|
|
73
|
+
}
|
|
74
|
+
events.push({ type: 'assistant', message: { role: 'assistant', content: [
|
|
75
|
+
{ type: 'tool_use', id: 'tbad', name: 'Agent', input: { description: 'run code review agent' } }] } });
|
|
76
|
+
events.push({ type: 'user', message: { role: 'user', content: [
|
|
77
|
+
{ type: 'tool_result', tool_use_id: 'tbad',
|
|
78
|
+
content: "Agent type 'code-reviewer' not found. Available agents: architect, …", is_error: true }] } });
|
|
79
|
+
|
|
80
|
+
if (includeCommitEvidence) {
|
|
81
|
+
if (evidenceForm === 'vcs_state_changed') {
|
|
82
|
+
events.push({ type: 'system', subtype: 'vcs_state_changed', kind: 'commit', cwd: '/tmp' });
|
|
83
|
+
} else {
|
|
84
|
+
events.push({
|
|
85
|
+
type: 'user',
|
|
86
|
+
message: { role: 'user', content: [
|
|
87
|
+
{ type: 'tool_result', tool_use_id: 'tcommit', content: 'committed', is_error: false }] },
|
|
88
|
+
tool_use_result: { gitOperation: { commit: { sha: '6ecbd40', kind: 'committed' } } },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
events.push({ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' });
|
|
94
|
+
return events;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── (1) regression case: non-git cwd + transcript evidence → clean ──────────
|
|
98
|
+
|
|
99
|
+
test('PASS + late is_error + transcript commit evidence + non-git cwd → clean (fallback fires)', async () => {
|
|
100
|
+
const tmp = makeTmpDir();
|
|
101
|
+
try {
|
|
102
|
+
const slug = '99-fix-e2e-needs-review-test';
|
|
103
|
+
writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true }));
|
|
104
|
+
const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
|
|
105
|
+
const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
|
|
106
|
+
try {
|
|
107
|
+
const verdict = await verifyRun({
|
|
108
|
+
runDir: tmp,
|
|
109
|
+
prdPath,
|
|
110
|
+
queueEntry: { slug, status: 'running', cwd: nonGitCwd },
|
|
111
|
+
allJobs: [],
|
|
112
|
+
committedDuringRun: false,
|
|
113
|
+
});
|
|
114
|
+
assert.equal(verdict.verdict, 'clean', `expected clean via transcript fallback, got ${verdict.verdict}: ${verdict.reason}`);
|
|
115
|
+
assert.equal(verdict.downgradeTo, null);
|
|
116
|
+
const sidecar = JSON.parse(fs.readFileSync(path.join(tmp, `${slug}.verdicts.json`), 'utf8'));
|
|
117
|
+
assert.equal(sidecar.commitEvidenceSource, 'transcript', 'sidecar should record the fallback source');
|
|
118
|
+
} finally { rmdir(nonGitCwd); }
|
|
119
|
+
} finally { rmdir(tmp); }
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('PASS + late is_error + gitOperation.commit evidence + non-git cwd → clean (fallback fires)', async () => {
|
|
123
|
+
const tmp = makeTmpDir();
|
|
124
|
+
try {
|
|
125
|
+
const slug = '99-fix-e2e-needs-review-test-alt';
|
|
126
|
+
writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true, evidenceForm: 'gitOperation' }));
|
|
127
|
+
const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
|
|
128
|
+
const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
|
|
129
|
+
try {
|
|
130
|
+
const verdict = await verifyRun({
|
|
131
|
+
runDir: tmp,
|
|
132
|
+
prdPath,
|
|
133
|
+
queueEntry: { slug, status: 'running', cwd: nonGitCwd },
|
|
134
|
+
allJobs: [],
|
|
135
|
+
committedDuringRun: false,
|
|
136
|
+
});
|
|
137
|
+
assert.equal(verdict.verdict, 'clean', `expected clean via transcript fallback, got ${verdict.verdict}: ${verdict.reason}`);
|
|
138
|
+
} finally { rmdir(nonGitCwd); }
|
|
139
|
+
} finally { rmdir(tmp); }
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ─── (2) no evidence lines → fallback must not fire, still transcript_errors ──
|
|
143
|
+
|
|
144
|
+
test('PASS + late is_error + NO transcript evidence + non-git cwd → transcript_errors (no fallback)', async () => {
|
|
145
|
+
const tmp = makeTmpDir();
|
|
146
|
+
try {
|
|
147
|
+
const slug = '99-fix-no-evidence';
|
|
148
|
+
writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: false }));
|
|
149
|
+
const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
|
|
150
|
+
const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
|
|
151
|
+
try {
|
|
152
|
+
const verdict = await verifyRun({
|
|
153
|
+
runDir: tmp,
|
|
154
|
+
prdPath,
|
|
155
|
+
queueEntry: { slug, status: 'running', cwd: nonGitCwd },
|
|
156
|
+
allJobs: [],
|
|
157
|
+
committedDuringRun: false,
|
|
158
|
+
});
|
|
159
|
+
assert.equal(verdict.verdict, 'transcript_errors', `expected transcript_errors without evidence, got ${verdict.verdict}: ${verdict.reason}`);
|
|
160
|
+
assert.equal(verdict.downgradeTo, 'needs_review');
|
|
161
|
+
} finally { rmdir(nonGitCwd); }
|
|
162
|
+
} finally { rmdir(tmp); }
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ─── (3) git-repo cwd + evidence + committedDuringRun:false → fallback must not apply ──
|
|
166
|
+
|
|
167
|
+
test('PASS + late is_error + transcript evidence + GIT-REPO cwd + committedDuringRun:false → transcript_errors (false-PASS guard)', async () => {
|
|
168
|
+
const tmp = makeTmpDir();
|
|
169
|
+
const repo = makeTmpGitRepo();
|
|
170
|
+
try {
|
|
171
|
+
const slug = '99-fix-git-repo-cwd';
|
|
172
|
+
writeLog(tmp, slug, buildRunEvents({ includeCommitEvidence: true }));
|
|
173
|
+
const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
|
|
174
|
+
const verdict = await verifyRun({
|
|
175
|
+
runDir: tmp,
|
|
176
|
+
prdPath,
|
|
177
|
+
queueEntry: { slug, status: 'running', cwd: repo },
|
|
178
|
+
allJobs: [],
|
|
179
|
+
committedDuringRun: false,
|
|
180
|
+
});
|
|
181
|
+
assert.equal(verdict.verdict, 'transcript_errors', `fallback must not apply when git ancestry is observable, got ${verdict.verdict}: ${verdict.reason}`);
|
|
182
|
+
assert.equal(verdict.downgradeTo, 'needs_review');
|
|
183
|
+
} finally { rmdir(tmp); rmdir(repo); }
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ─── (4) PASS + transcript evidence + non-git cwd must not be pass_no_commit ──
|
|
187
|
+
|
|
188
|
+
test('PASS (no error) + transcript evidence + non-git cwd → clean, not pass_no_commit', async () => {
|
|
189
|
+
const tmp = makeTmpDir();
|
|
190
|
+
try {
|
|
191
|
+
const slug = '99-fix-clean-pass';
|
|
192
|
+
const events = [
|
|
193
|
+
{ type: 'assistant', message: { role: 'assistant', content: [
|
|
194
|
+
{ type: 'tool_use', id: 't0', name: 'Bash', input: { description: 'run tests' } }] } },
|
|
195
|
+
{ type: 'user', message: { role: 'user', content: [
|
|
196
|
+
{ type: 'tool_result', tool_use_id: 't0', content: '535 passed', is_error: false }] } },
|
|
197
|
+
{ type: 'system', subtype: 'vcs_state_changed', kind: 'commit', cwd: '/tmp' },
|
|
198
|
+
{ type: 'result', subtype: 'success', result: 'All acceptance criteria verified.\nSCHEDULER_VERDICT: PASS' },
|
|
199
|
+
];
|
|
200
|
+
writeLog(tmp, slug, events);
|
|
201
|
+
const prdPath = writePrd(tmp, slug, '# Fix scheduler skip guard');
|
|
202
|
+
const nonGitCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'run-verify-nongit-cwd-'));
|
|
203
|
+
try {
|
|
204
|
+
const verdict = await verifyRun({
|
|
205
|
+
runDir: tmp,
|
|
206
|
+
prdPath,
|
|
207
|
+
queueEntry: { slug, status: 'running', cwd: nonGitCwd },
|
|
208
|
+
allJobs: [],
|
|
209
|
+
committedDuringRun: false,
|
|
210
|
+
});
|
|
211
|
+
assert.equal(verdict.verdict, 'clean', `expected clean, got ${verdict.verdict}: ${verdict.reason}`);
|
|
212
|
+
assert.notEqual(verdict.verdict, 'pass_no_commit');
|
|
213
|
+
} finally { rmdir(nonGitCwd); }
|
|
214
|
+
} finally { rmdir(tmp); }
|
|
215
|
+
});
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
|
|
9
9
|
const { test } = require('node:test');
|
|
10
10
|
const assert = require('node:assert/strict');
|
|
11
|
-
const
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
const { execFileSync } = require('node:child_process');
|
|
15
|
+
const { buildInvestigationPrompt, isGitRepoSync } = require('../scheduler.cjs');
|
|
12
16
|
|
|
13
17
|
function makeArgs(overrides = {}) {
|
|
14
18
|
return {
|
|
@@ -49,3 +53,40 @@ test('prompt includes the resolved job fields', () => {
|
|
|
49
53
|
assert.match(prompt, /some log tail output/);
|
|
50
54
|
assert.match(prompt, /05-fix-my-feature\.md/);
|
|
51
55
|
});
|
|
56
|
+
|
|
57
|
+
// ─── cwd-must-be-git-repo-root guidance (99-fix-e2e-needs-review-test) ────────
|
|
58
|
+
|
|
59
|
+
test('prompt instructs that cwd must be the git repo root where the fix will land', () => {
|
|
60
|
+
const prompt = buildInvestigationPrompt(makeArgs());
|
|
61
|
+
assert.match(prompt, /cwd.*must be the git repo root/);
|
|
62
|
+
assert.match(prompt, /needs_review/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('prompt frontmatter block still declares exactly the four expected keys', () => {
|
|
66
|
+
const prompt = buildInvestigationPrompt(makeArgs());
|
|
67
|
+
const fenceMatch = prompt.match(/```\s*\n\s*---\n([\s\S]*?)\n\s*---\n\s*```/);
|
|
68
|
+
assert.ok(fenceMatch, 'expected a fenced frontmatter block in the prompt');
|
|
69
|
+
const keys = fenceMatch[1]
|
|
70
|
+
.split('\n')
|
|
71
|
+
.filter((line) => line.trim().length > 0)
|
|
72
|
+
.map((line) => line.split(':')[0].trim());
|
|
73
|
+
assert.deepEqual(keys, ['title', 'cwd', 'parallelGroup', 'estimateMinutes']);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// ─── isGitRepoSync (scheduler.cjs) ─────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
test('isGitRepoSync: true for a real git repo, false for a non-repo dir, false for missing/undefined', () => {
|
|
79
|
+
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-isgitrepo-repo-'));
|
|
80
|
+
const nonRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-isgitrepo-plain-'));
|
|
81
|
+
try {
|
|
82
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
83
|
+
assert.equal(isGitRepoSync(repo), true);
|
|
84
|
+
assert.equal(isGitRepoSync(nonRepo), false);
|
|
85
|
+
assert.equal(isGitRepoSync(undefined), false);
|
|
86
|
+
assert.equal(isGitRepoSync(''), false);
|
|
87
|
+
assert.equal(isGitRepoSync('/definitely/does/not/exist'), false);
|
|
88
|
+
} finally {
|
|
89
|
+
fs.rmSync(repo, { recursive: true, force: true });
|
|
90
|
+
fs.rmSync(nonRepo, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
@@ -23,6 +23,7 @@ const path = require('node:path');
|
|
|
23
23
|
let tmpHome;
|
|
24
24
|
let originalHome;
|
|
25
25
|
let executeJob;
|
|
26
|
+
let spawnJob;
|
|
26
27
|
let PRDS_DIR;
|
|
27
28
|
|
|
28
29
|
beforeAll(() => {
|
|
@@ -30,7 +31,7 @@ beforeAll(() => {
|
|
|
30
31
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-skip-'));
|
|
31
32
|
process.env.HOME = tmpHome;
|
|
32
33
|
|
|
33
|
-
({ executeJob, PRDS_DIR } = require('../scheduler.cjs'));
|
|
34
|
+
({ executeJob, spawnJob, PRDS_DIR } = require('../scheduler.cjs'));
|
|
34
35
|
|
|
35
36
|
if (!PRDS_DIR.startsWith(tmpHome)) {
|
|
36
37
|
throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
@@ -87,3 +88,55 @@ test('a job whose PRD source exists but is unreadable for a non-ENOENT reason st
|
|
|
87
88
|
fs.rmSync(runDir, { recursive: true, force: true });
|
|
88
89
|
}
|
|
89
90
|
});
|
|
91
|
+
|
|
92
|
+
function registerActiveProject(cwd) {
|
|
93
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
94
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
95
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
96
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeProjectQueue(cwd, jobs) {
|
|
100
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
101
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
102
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
103
|
+
return path.join(stateDir, 'queue.json');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// This is the actual regression case: PRD 812's fix only covered executeJob's
|
|
107
|
+
// own return value. The bug lived one layer up, in spawnJob's post-run
|
|
108
|
+
// handler, which only short-circuited on skipped === 'prd-archived' and let
|
|
109
|
+
// 'prd-missing' fall through into the verify path meant for real runs —
|
|
110
|
+
// downgrading a stale-queue-entry retire into a needs_review with a bogus RCA
|
|
111
|
+
// feedback item. Drive spawnJob itself (not just executeJob) so this covers
|
|
112
|
+
// the actual defect, not just the classification it depends on.
|
|
113
|
+
test("spawnJob's post-run handler retires a prd-missing skip as a plain completion, without running verify", async () => {
|
|
114
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-spawnjob-project-'));
|
|
115
|
+
registerActiveProject(projectCwd);
|
|
116
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-missing-spawnjob-run-'));
|
|
117
|
+
try {
|
|
118
|
+
const slug = `812-test-spawnjob-missing-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
119
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
120
|
+
{ slug, status: 'pending', cwd: projectCwd },
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const job = { slug, cwd: projectCwd };
|
|
124
|
+
await spawnJob(job, 'run-spawnjob-missing', runDir, projectCwd);
|
|
125
|
+
|
|
126
|
+
const state = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
127
|
+
const row = state.jobs.find((j) => j.slug === slug);
|
|
128
|
+
expect(row).toBeDefined();
|
|
129
|
+
expect(row.status).toBe('completed');
|
|
130
|
+
expect(row.exitCode).toBe(0);
|
|
131
|
+
expect(row.error).toBeNull();
|
|
132
|
+
// No verify pass ran and no RCA feedback fired: a verified run always
|
|
133
|
+
// stamps a verify-derived status ('needs_review') or a landedCommit/verify
|
|
134
|
+
// field onto the row. Neither is present here — the row was completed by
|
|
135
|
+
// the early-return skip branch, not the verification block further down.
|
|
136
|
+
expect(row.status).not.toBe('needs_review');
|
|
137
|
+
expect(row.landedCommit).toBeUndefined();
|
|
138
|
+
} finally {
|
|
139
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
140
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
@@ -29,6 +29,12 @@ beforeEach(() => {
|
|
|
29
29
|
|
|
30
30
|
afterEach(() => {
|
|
31
31
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
32
|
+
// writeTranscript mkdir -p's this run's folder under the REAL
|
|
33
|
+
// ~/.claude/projects (that's where transcriptPath resolves to), so leaving
|
|
34
|
+
// it behind accumulates one phantom project directory per test run — 1760
|
|
35
|
+
// of them had piled up before this cleanup existed, and Home counted every
|
|
36
|
+
// one as a project. Remove it with the tmp cwd it mirrors.
|
|
37
|
+
fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
|
|
32
38
|
});
|
|
33
39
|
|
|
34
40
|
function writeTranscript(cwdArg, sessionId, lines) {
|
|
@@ -36,6 +36,12 @@ beforeEach(() => {
|
|
|
36
36
|
|
|
37
37
|
afterEach(() => {
|
|
38
38
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
39
|
+
// writeTranscript mkdir -p's this run's folder under the REAL
|
|
40
|
+
// ~/.claude/projects (that's where transcriptPath resolves to), so leaving
|
|
41
|
+
// it behind accumulates one phantom project directory per test run. Thousands
|
|
42
|
+
// had piled up before this cleanup existed, and Home counted every one as a
|
|
43
|
+
// project. Remove it with the tmp cwd it mirrors.
|
|
44
|
+
fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
|
|
39
45
|
});
|
|
40
46
|
|
|
41
47
|
function writeTranscript(cwdArg, sessionId, lines) {
|
|
@@ -24,6 +24,12 @@ beforeEach(() => {
|
|
|
24
24
|
|
|
25
25
|
afterEach(() => {
|
|
26
26
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
27
|
+
// writeTranscript mkdir -p's this run's folder under the REAL
|
|
28
|
+
// ~/.claude/projects (that's where transcriptPath resolves to), so leaving
|
|
29
|
+
// it behind accumulates one phantom project directory per test run. Thousands
|
|
30
|
+
// had piled up before this cleanup existed, and Home counted every one as a
|
|
31
|
+
// project. Remove it with the tmp cwd it mirrors.
|
|
32
|
+
fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
function writeTranscript(cwdArg, sessionId, lines) {
|
package/src/main/config.cjs
CHANGED
|
@@ -129,10 +129,7 @@ function validateWrite(realAbs) {
|
|
|
129
129
|
(p) => realAbs === p || realAbs.startsWith(p + path.sep)
|
|
130
130
|
);
|
|
131
131
|
if (inWritePrefix) return;
|
|
132
|
-
// Also allowed inside a registered project root's .claude/ subtree
|
|
133
|
-
// tests/fixtures/browser-capture/ subtree (PRD 407 "PRD fixture" capture
|
|
134
|
-
// destination — narrowly scoped to that one path segment, not a general
|
|
135
|
-
// project-root write grant).
|
|
132
|
+
// Also allowed inside a registered project root's .claude/ subtree.
|
|
136
133
|
for (const root of allowedRoots) {
|
|
137
134
|
if (root === os.homedir()) continue;
|
|
138
135
|
let realRoot;
|
|
@@ -142,24 +139,6 @@ function validateWrite(realAbs) {
|
|
|
142
139
|
if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
|
|
143
140
|
return;
|
|
144
141
|
}
|
|
145
|
-
const fixturesSub = path.join(realRoot, 'tests', 'fixtures', 'browser-capture');
|
|
146
|
-
if (realAbs === fixturesSub || realAbs.startsWith(fixturesSub + path.sep)) {
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
// PRD 410 Recorder → Playwright-spec export: narrowly scoped to
|
|
150
|
-
// tests/e2e/ (matches the repo's existing e2e spec location), not a
|
|
151
|
-
// general project-root write grant.
|
|
152
|
-
const e2eSub = path.join(realRoot, 'tests', 'e2e');
|
|
153
|
-
if (realAbs === e2eSub || realAbs.startsWith(e2eSub + path.sep)) {
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
// Browser tab scratch saves (DOM captures, screenshots, recorded
|
|
157
|
-
// flows): narrowly scoped to session-manager-operations/browser/,
|
|
158
|
-
// this repo's existing per-project artifact-store convention.
|
|
159
|
-
const browserSub = path.join(realRoot, 'session-manager-operations', 'browser');
|
|
160
|
-
if (realAbs === browserSub || realAbs.startsWith(browserSub + path.sep)) {
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
142
|
// PromptSession persistence (active-index.json + per-session archives,
|
|
164
143
|
// promptSessions.ts) and the scheduler's own read-modify-write of that
|
|
165
144
|
// same active index (promptSessionEvents.cjs, PRD 814) — narrowly
|
package/src/main/files.cjs
CHANGED
|
@@ -318,6 +318,21 @@ async function deleteEntry(filePath) {
|
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Binary-safe atomic write (formerly browser:save-binary). Routes through
|
|
323
|
+
* config.cjs's writeBinaryAtomic so the single-writer law still applies to
|
|
324
|
+
* ops-root destinations; `writer` is the renderer's declared owner id.
|
|
325
|
+
*/
|
|
326
|
+
async function saveBinary(filePath, base64, writer) {
|
|
327
|
+
const { writeBinaryAtomic } = require('./config.cjs');
|
|
328
|
+
try {
|
|
329
|
+
await writeBinaryAtomic(filePath, Buffer.from(base64, 'base64'), { writer });
|
|
330
|
+
return { ok: true };
|
|
331
|
+
} catch (e) {
|
|
332
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
321
336
|
async function openExternal(filePath) {
|
|
322
337
|
let resolved;
|
|
323
338
|
try { resolved = validateHomePath(filePath); }
|
|
@@ -385,6 +400,10 @@ function registerFilesHandlers() {
|
|
|
385
400
|
const { path: p } = filesPath.parse(payload);
|
|
386
401
|
return deleteEntry(p);
|
|
387
402
|
});
|
|
403
|
+
ipcMain.handle('files:save-binary', (_e, payload) => {
|
|
404
|
+
const { path: p, base64, writer } = schemas.filesSaveBinary.parse(payload);
|
|
405
|
+
return saveBinary(p, base64, writer);
|
|
406
|
+
});
|
|
388
407
|
// files:open-external / files:show-in-finder consolidated into shell:open
|
|
389
408
|
// (index.cjs, as: 'openPath' / 'revealPath'). The openExternal/showInFinder
|
|
390
409
|
// functions are exported for that handler to call.
|
|
@@ -406,4 +425,5 @@ module.exports = {
|
|
|
406
425
|
deleteEntry,
|
|
407
426
|
duplicateEntry,
|
|
408
427
|
duplicateNameFor,
|
|
428
|
+
saveBinary,
|
|
409
429
|
};
|
package/src/main/index.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard,
|
|
1
|
+
const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, powerSaveBlocker, protocol } = require('electron');
|
|
2
2
|
const { spawn, execFile, execFileSync } = require('node:child_process');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const fs = require('node:fs');
|
|
@@ -9,8 +9,6 @@ const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
|
9
9
|
const { terminateLosingInstance } = require('./lib/singleInstanceGuard.cjs');
|
|
10
10
|
const { acquireSchedulerOwnership, releaseSchedulerOwnership } = require('./lib/instanceLock.cjs');
|
|
11
11
|
const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
|
|
12
|
-
const browserView = require('./browserView.cjs');
|
|
13
|
-
const browserCapture = require('./browserCapture.cjs');
|
|
14
12
|
const configMgr = require('./config.cjs');
|
|
15
13
|
const transcripts = require('./transcripts.cjs');
|
|
16
14
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
@@ -40,13 +38,6 @@ const adminHttp = createAdminHttp();
|
|
|
40
38
|
scheduler.registerAdminRoutes(adminHttp);
|
|
41
39
|
prdCreate.registerAdminRoute(adminHttp, scheduler.remote);
|
|
42
40
|
chatRunner.registerAdminRoute(adminHttp);
|
|
43
|
-
const { createBrowserAgentServer } = require('./browserAgentServer.cjs');
|
|
44
|
-
const browserAgentServer = createBrowserAgentServer({
|
|
45
|
-
listTabs: () => browserView.listViews(),
|
|
46
|
-
screenshot: ({ viewId }) => browserView.captureShotForAgent({ viewId }),
|
|
47
|
-
action: (params) => browserView.dispatchViewAction(params),
|
|
48
|
-
dom: ({ viewId }) => browserView.captureAgentDom({ viewId }),
|
|
49
|
-
});
|
|
50
41
|
const supervisor = require('./supervisor.cjs');
|
|
51
42
|
const watchers = require('./watchers.cjs');
|
|
52
43
|
const teams = require('./teams.cjs');
|
|
@@ -69,7 +60,6 @@ const agentMemory = require('./agentMemory.cjs');
|
|
|
69
60
|
const git = require('./git.cjs');
|
|
70
61
|
const filesIpc = require('./files.cjs');
|
|
71
62
|
const { registerDocEditHandlers, attachWindow: attachDocEditWindow } = require('./docEdit.cjs');
|
|
72
|
-
const webRemote = require('./webRemote.cjs');
|
|
73
63
|
const { listExchanges } = require('./exchanges.cjs');
|
|
74
64
|
const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
|
|
75
65
|
const { checkInsideHome, assertInsideHome } = require('./lib/insideHome.cjs');
|
|
@@ -306,8 +296,6 @@ async function rebootApp() {
|
|
|
306
296
|
ptyManager.attachWindow(mainWindow);
|
|
307
297
|
configMgr.attachWindow(mainWindow);
|
|
308
298
|
transcripts.attachWindow(mainWindow);
|
|
309
|
-
browserView.attachWindow(mainWindow);
|
|
310
|
-
browserCapture.attachWindow(mainWindow);
|
|
311
299
|
voiceHotkey.init(mainWindow).catch((e) => {
|
|
312
300
|
logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
|
|
313
301
|
});
|
|
@@ -506,25 +494,6 @@ ipcMain.handle('app:pick-directory', async () => {
|
|
|
506
494
|
|
|
507
495
|
ipcMain.on('app:reboot-app', () => rebootApp());
|
|
508
496
|
|
|
509
|
-
// Recorder export "Save to file…" (PRD 412) — native Save As dialog, written
|
|
510
|
-
// directly since the path is user-chosen, not renderer input.
|
|
511
|
-
ipcMain.handle('browser:save-recording', validated(schemas.browserSaveRecording, async ({ defaultName, text }) => {
|
|
512
|
-
try {
|
|
513
|
-
const result = await dialog.showSaveDialog(mainWindow, {
|
|
514
|
-
defaultPath: defaultName,
|
|
515
|
-
filters: [
|
|
516
|
-
{ name: 'Markdown', extensions: ['md'] },
|
|
517
|
-
{ name: 'All Files', extensions: ['*'] },
|
|
518
|
-
],
|
|
519
|
-
});
|
|
520
|
-
if (result.canceled || !result.filePath) return { ok: false, canceled: true };
|
|
521
|
-
await fsp.writeFile(result.filePath, text, 'utf8');
|
|
522
|
-
return { ok: true, path: result.filePath };
|
|
523
|
-
} catch (e) {
|
|
524
|
-
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
525
|
-
}
|
|
526
|
-
}));
|
|
527
|
-
|
|
528
497
|
// Image paste — Ctrl+V in the Terminal pane. Reads the OS clipboard via
|
|
529
498
|
// Electron's native API (renderer's navigator.clipboard.read() doesn't expose
|
|
530
499
|
// raw image MIME types under contextIsolation), saves the bitmap to a temp
|
|
@@ -570,19 +539,6 @@ ipcMain.handle('clipboard:write-text', validated(schemas.clipboardWriteText, ({
|
|
|
570
539
|
}
|
|
571
540
|
}));
|
|
572
541
|
|
|
573
|
-
// PRD 407 Capture panel — write side of paste-image's read. Writes a
|
|
574
|
-
// screenshot capture to the OS clipboard as an image.
|
|
575
|
-
ipcMain.handle('browser:copy-image', validated(schemas.browserCopyImage, ({ dataUrl }) => {
|
|
576
|
-
try {
|
|
577
|
-
const img = nativeImage.createFromDataURL(dataUrl);
|
|
578
|
-
if (!img || img.isEmpty()) return { ok: false, error: 'empty image' };
|
|
579
|
-
clipboard.writeImage(img);
|
|
580
|
-
return { ok: true };
|
|
581
|
-
} catch (e) {
|
|
582
|
-
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
583
|
-
}
|
|
584
|
-
}));
|
|
585
|
-
|
|
586
542
|
// Hooks tab "Test fire": run a hook command with a fake event payload piped
|
|
587
543
|
// to stdin. shell:true is intentional — Claude Code's hook field is a shell
|
|
588
544
|
// string. Timeout is enforced via SIGKILL on a timer because spawn's built-in
|
|
@@ -841,7 +797,6 @@ agentMemory.registerAgentMemoryHandlers();
|
|
|
841
797
|
git.register(ipcMain);
|
|
842
798
|
filesIpc.registerFilesHandlers();
|
|
843
799
|
registerDocEditHandlers();
|
|
844
|
-
webRemote.registerRemoteHandlers();
|
|
845
800
|
chatRunner.registerChatHandlers();
|
|
846
801
|
|
|
847
802
|
// Direct in-process PRD authoring for a queued PromptTicket classified
|
|
@@ -943,11 +898,6 @@ app.on('web-contents-created', (_e, wc) => {
|
|
|
943
898
|
});
|
|
944
899
|
|
|
945
900
|
wc.on('will-navigate', (event, url) => {
|
|
946
|
-
// The embedded Browser tab's WebContentsView is a real browser — it must
|
|
947
|
-
// be able to navigate anywhere. Exempt ONLY webContents registered by
|
|
948
|
-
// browserView.cjs; the main window's lock below is unchanged.
|
|
949
|
-
if (browserView.isBrowserViewContents(wc.id)) return;
|
|
950
|
-
|
|
951
901
|
const allowed = useDevServer
|
|
952
902
|
? ['http://localhost:5173', 'http://127.0.0.1:5173']
|
|
953
903
|
: [];
|
|
@@ -1144,16 +1094,12 @@ app.whenReady().then(async () => {
|
|
|
1144
1094
|
ptyManager.attachWindow(mainWindow);
|
|
1145
1095
|
configMgr.attachWindow(mainWindow);
|
|
1146
1096
|
transcripts.attachWindow(mainWindow);
|
|
1147
|
-
browserView.registerBrowserView({ mainWindow, ipcMain });
|
|
1148
|
-
browserCapture.attachWindow(mainWindow);
|
|
1149
|
-
browserCapture.registerBrowserCapture({ ipcMain, getView: browserView.getView });
|
|
1150
1097
|
voiceHotkey.init(mainWindow).catch((e) => {
|
|
1151
1098
|
logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1152
1099
|
});
|
|
1153
1100
|
scheduler.attachWindow(mainWindow);
|
|
1154
1101
|
watchers.attachWindow(mainWindow);
|
|
1155
1102
|
pluginInstall.attachWindow(mainWindow);
|
|
1156
|
-
webRemote.attachWindow(mainWindow);
|
|
1157
1103
|
chatRunner.attachWindow(mainWindow);
|
|
1158
1104
|
promptSessionEvents.attachWindow(mainWindow);
|
|
1159
1105
|
attachDocEditWindow(mainWindow);
|
|
@@ -1177,18 +1123,12 @@ app.whenReady().then(async () => {
|
|
|
1177
1123
|
console.log(`[main] scheduler ownership held by pid ${ownership.holderPid} — running scheduler-passive (no reconciliation, no admin server)`);
|
|
1178
1124
|
logs.writeLine({ scope: 'scheduler', level: 'info', message: 'scheduler-passive: ownership held by another instance', meta: { holderPid: ownership.holderPid } });
|
|
1179
1125
|
}
|
|
1180
|
-
browserAgentServer.start().catch((e) => {
|
|
1181
|
-
logs.writeLine({ scope: 'browser-agent-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1182
|
-
});
|
|
1183
1126
|
// First-boot default: install the bundled session-manager-dev plugin (its 10
|
|
1184
1127
|
// dev skills) from the app's own marketplace. One-shot + idempotent; never
|
|
1185
1128
|
// throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
|
|
1186
1129
|
seedDevPlugin({ logger: console }).catch((e) => {
|
|
1187
1130
|
logs.writeLine({ scope: 'seed-dev-plugin', level: 'error', message: 'seed failed', meta: { error: e?.message } });
|
|
1188
1131
|
});
|
|
1189
|
-
webRemote.init().catch((e) => {
|
|
1190
|
-
logs.writeLine({ scope: 'webRemote', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1191
|
-
});
|
|
1192
1132
|
// History rollup finalize pass: deferred 30s past boot so it never competes
|
|
1193
1133
|
// with first-paint, fire-and-forget (cron/offline refresh is PRD 651 — this
|
|
1194
1134
|
// is just the one-shot in-app pass).
|
|
@@ -1249,7 +1189,6 @@ let teardownDone = false;
|
|
|
1249
1189
|
function runShutdownCleanup() {
|
|
1250
1190
|
if (teardownDone) return; // idempotent — will-quit may still fire after an app.exit path
|
|
1251
1191
|
teardownDone = true;
|
|
1252
|
-
webRemote.destroy();
|
|
1253
1192
|
// Mark a clean exit so the next boot can distinguish a graceful quit from an
|
|
1254
1193
|
// OOM-kill / native crash (which leaves the sentinel `open`).
|
|
1255
1194
|
crashDiagnostics.markCleanShutdown();
|
|
@@ -1287,7 +1226,6 @@ app.on('before-quit', () => {
|
|
|
1287
1226
|
transcripts.closeAll();
|
|
1288
1227
|
watchers.manager.killAll();
|
|
1289
1228
|
adminHttp.stop().catch(() => {});
|
|
1290
|
-
browserAgentServer.stop().catch(() => {});
|
|
1291
1229
|
// Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
|
|
1292
1230
|
// ceiling so a wedged exporter can't hold quit.
|
|
1293
1231
|
otel.shutdown().catch(() => {});
|