claude-code-session-manager 0.61.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-CCmz3PdX.js → TiptapBody-BwN4pNC0.js} +42 -42
- package/dist/assets/{index-CPMP2XZ_.css → index-CVCCMw5o.css} +1 -1
- package/dist/assets/index-ZjTDk5b7.js +3197 -0
- package/dist/index.html +2 -2
- package/package.json +4 -2
- package/src/main/__tests__/chat-preamble-anchors.test.cjs +100 -0
- 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/chatRunner.cjs +4 -0
- package/src/main/config.cjs +30 -23
- 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/__tests__/coldBootPromptSessionsWrite.test.cjs +87 -0
- package/src/main/lib/activeIndexMerge.cjs +9 -0
- 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/dist/assets/index-B5994QOJ.js +0 -3222
- 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.
|
|
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",
|
|
@@ -40,7 +40,9 @@
|
|
|
40
40
|
"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",
|
|
41
41
|
"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/",
|
|
42
42
|
"health": "node src/main/health.cjs",
|
|
43
|
-
"lint:selectors": "node scripts/check-unstable-selectors.cjs"
|
|
43
|
+
"lint:selectors": "node scripts/check-unstable-selectors.cjs",
|
|
44
|
+
"lint:hooks": "node scripts/check-conditional-hooks.cjs",
|
|
45
|
+
"lint": "npm run lint:selectors && npm run lint:hooks"
|
|
44
46
|
},
|
|
45
47
|
"keywords": [
|
|
46
48
|
"claude",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* chat-preamble-anchors.test.cjs — drift guard between chatRunner's injected
|
|
3
|
+
* instruction blocks and the renderer's splitter that hides them.
|
|
4
|
+
*
|
|
5
|
+
* `src/renderer/lib/promptPreamble.ts` anchors each block on its FIRST and
|
|
6
|
+
* LAST sentence only, because it cannot import this main-process .cjs. That
|
|
7
|
+
* indirection is safe ONLY while those anchors still bracket the real text —
|
|
8
|
+
* so this test composes the exact `fullPrompt` chatRunner builds and asserts
|
|
9
|
+
* the splitter recovers the human's words exactly. Reword either block's
|
|
10
|
+
* opening or closing sentence and this fails here, loudly, instead of the
|
|
11
|
+
* boilerplate silently reappearing in every user bubble.
|
|
12
|
+
*
|
|
13
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/chat-preamble-anchors.test.cjs
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
import { test, expect } from 'vitest';
|
|
21
|
+
|
|
22
|
+
const { STOP_SIGNAL_INSTRUCTION, CHAT_MODE_TRUTH_INSTRUCTION } = require('../chatRunner.cjs');
|
|
23
|
+
|
|
24
|
+
const HUMAN = 'Review the Alpaca limits and report back what we can trade for free.';
|
|
25
|
+
|
|
26
|
+
async function loadSplitter() {
|
|
27
|
+
const mod = await import(
|
|
28
|
+
/* @vite-ignore */ path.resolve(__dirname, '..', '..', 'renderer', 'lib', 'promptPreamble.ts')
|
|
29
|
+
);
|
|
30
|
+
return mod;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('the real fullPrompt splits back into exactly the human’s words', async () => {
|
|
34
|
+
const { splitInjectedPreamble } = await loadSplitter();
|
|
35
|
+
// Mirrors chatRunner's own composition at its `-p` call site.
|
|
36
|
+
const fullPrompt = STOP_SIGNAL_INSTRUCTION + CHAT_MODE_TRUTH_INSTRUCTION + HUMAN;
|
|
37
|
+
|
|
38
|
+
const split = splitInjectedPreamble(fullPrompt);
|
|
39
|
+
expect(split.body).toBe(HUMAN);
|
|
40
|
+
expect(split.blockKeys).toEqual(['stop-signal', 'chat-mode-truth']);
|
|
41
|
+
expect(split.preamble).toBeTruthy();
|
|
42
|
+
// Nothing is invented or lost: preamble + body reconstitute the input.
|
|
43
|
+
expect(split.preamble.length + split.body.length).toBeLessThanOrEqual(fullPrompt.length);
|
|
44
|
+
expect(fullPrompt.startsWith(split.preamble)).toBe(true);
|
|
45
|
+
expect(fullPrompt.endsWith(split.body)).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('each block strips independently, so one being absent still works', async () => {
|
|
49
|
+
const { splitInjectedPreamble } = await loadSplitter();
|
|
50
|
+
|
|
51
|
+
const onlyStop = splitInjectedPreamble(STOP_SIGNAL_INSTRUCTION + HUMAN);
|
|
52
|
+
expect(onlyStop.body).toBe(HUMAN);
|
|
53
|
+
expect(onlyStop.blockKeys).toEqual(['stop-signal']);
|
|
54
|
+
|
|
55
|
+
const onlyTruth = splitInjectedPreamble(CHAT_MODE_TRUTH_INSTRUCTION + HUMAN);
|
|
56
|
+
expect(onlyTruth.body).toBe(HUMAN);
|
|
57
|
+
expect(onlyTruth.blockKeys).toEqual(['chat-mode-truth']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a prompt with no injected blocks is returned untouched', async () => {
|
|
61
|
+
const { splitInjectedPreamble } = await loadSplitter();
|
|
62
|
+
const split = splitInjectedPreamble(HUMAN);
|
|
63
|
+
expect(split.preamble).toBe(null);
|
|
64
|
+
expect(split.body).toBe(HUMAN);
|
|
65
|
+
expect(split.blockKeys).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('EDGE: a boilerplate-only prompt is NOT stripped to an empty bubble', async () => {
|
|
69
|
+
const { splitInjectedPreamble } = await loadSplitter();
|
|
70
|
+
const onlyBoilerplate = STOP_SIGNAL_INSTRUCTION + CHAT_MODE_TRUTH_INSTRUCTION;
|
|
71
|
+
const split = splitInjectedPreamble(onlyBoilerplate);
|
|
72
|
+
expect(split.preamble).toBe(null);
|
|
73
|
+
expect(split.body).toBe(onlyBoilerplate);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('EDGE: a truncated block (opening present, closing missing) is left fully intact', async () => {
|
|
77
|
+
const { splitInjectedPreamble } = await loadSplitter();
|
|
78
|
+
// Simulates a reworded/cut-off block: better to over-show boilerplate than
|
|
79
|
+
// to guess at a boundary and eat the human's words.
|
|
80
|
+
const truncated = `${STOP_SIGNAL_INSTRUCTION.slice(0, 120)}\n\n${HUMAN}`;
|
|
81
|
+
const split = splitInjectedPreamble(truncated);
|
|
82
|
+
expect(split.preamble).toBe(null);
|
|
83
|
+
expect(split.body).toBe(truncated);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the two blocks strip in either order', async () => {
|
|
87
|
+
const { splitInjectedPreamble } = await loadSplitter();
|
|
88
|
+
const reversed = CHAT_MODE_TRUTH_INSTRUCTION + STOP_SIGNAL_INSTRUCTION + HUMAN;
|
|
89
|
+
const split = splitInjectedPreamble(reversed);
|
|
90
|
+
expect(split.body).toBe(HUMAN);
|
|
91
|
+
expect(split.blockKeys).toEqual(['chat-mode-truth', 'stop-signal']);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('EDGE: boilerplate quoted inside the human’s own message is never stripped', async () => {
|
|
95
|
+
const { splitInjectedPreamble } = await loadSplitter();
|
|
96
|
+
const quoting = `Why does chat prepend this?\n\n${STOP_SIGNAL_INSTRUCTION}`;
|
|
97
|
+
const split = splitInjectedPreamble(quoting);
|
|
98
|
+
expect(split.preamble).toBe(null);
|
|
99
|
+
expect(split.body).toBe(quoting);
|
|
100
|
+
});
|
|
@@ -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/chatRunner.cjs
CHANGED
|
@@ -891,6 +891,10 @@ module.exports = {
|
|
|
891
891
|
splitStopSignal,
|
|
892
892
|
parseContextUsageMarkdown,
|
|
893
893
|
STOP_SENTINEL,
|
|
894
|
+
// Both instruction blocks are exported so the renderer's preamble splitter
|
|
895
|
+
// (lib/promptPreamble.ts) can be drift-tested against the REAL text rather
|
|
896
|
+
// than a copy — see chat-preamble-anchors.test.cjs.
|
|
897
|
+
STOP_SIGNAL_INSTRUCTION,
|
|
894
898
|
CHAT_MODE_TRUTH_INSTRUCTION,
|
|
895
899
|
KILL_CEILING_MS,
|
|
896
900
|
KILL_CEILING_MIN,
|
package/src/main/config.cjs
CHANGED
|
@@ -70,6 +70,30 @@ function addAllowedRoot(dir) {
|
|
|
70
70
|
if (dir) allowedRoots.add(path.resolve(dir));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// PromptSession archive writes (state/promptSessions.ts's markCompleted ->
|
|
74
|
+
// window.api.config.writeJson(promptSessionArchivePath(...))) reach this
|
|
75
|
+
// generic IPC handler with no pty ever having spawned for their cwd (a
|
|
76
|
+
// chat-only Epic on a cold boot), same hazard prdCreate.cjs:152-155 already
|
|
77
|
+
// documents for PRD creation. Registering the root here — narrowly, only
|
|
78
|
+
// when the resolved path is actually under a project's
|
|
79
|
+
// session-manager-operations/prompt-sessions/ — mirrors that precedent for
|
|
80
|
+
// the one other write path (besides mergeActiveIndex's own) that needs it,
|
|
81
|
+
// without widening addAllowedRoot's use to every config:write-json call.
|
|
82
|
+
const PROMPT_SESSIONS_MARKER = `${path.sep}session-manager-operations${path.sep}prompt-sessions${path.sep}`;
|
|
83
|
+
|
|
84
|
+
function registerPromptSessionsRoot(p) {
|
|
85
|
+
if (!p || typeof p !== 'string') return;
|
|
86
|
+
let abs;
|
|
87
|
+
try {
|
|
88
|
+
abs = path.resolve(expandHome(p));
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const idx = abs.indexOf(PROMPT_SESSIONS_MARKER);
|
|
93
|
+
if (idx === -1) return;
|
|
94
|
+
addAllowedRoot(abs.slice(0, idx));
|
|
95
|
+
}
|
|
96
|
+
|
|
73
97
|
function validatePath(abs) {
|
|
74
98
|
const real = realResolve(abs);
|
|
75
99
|
for (const root of allowedRoots) {
|
|
@@ -105,10 +129,7 @@ function validateWrite(realAbs) {
|
|
|
105
129
|
(p) => realAbs === p || realAbs.startsWith(p + path.sep)
|
|
106
130
|
);
|
|
107
131
|
if (inWritePrefix) return;
|
|
108
|
-
// Also allowed inside a registered project root's .claude/ subtree
|
|
109
|
-
// tests/fixtures/browser-capture/ subtree (PRD 407 "PRD fixture" capture
|
|
110
|
-
// destination — narrowly scoped to that one path segment, not a general
|
|
111
|
-
// project-root write grant).
|
|
132
|
+
// Also allowed inside a registered project root's .claude/ subtree.
|
|
112
133
|
for (const root of allowedRoots) {
|
|
113
134
|
if (root === os.homedir()) continue;
|
|
114
135
|
let realRoot;
|
|
@@ -118,24 +139,6 @@ function validateWrite(realAbs) {
|
|
|
118
139
|
if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
|
|
119
140
|
return;
|
|
120
141
|
}
|
|
121
|
-
const fixturesSub = path.join(realRoot, 'tests', 'fixtures', 'browser-capture');
|
|
122
|
-
if (realAbs === fixturesSub || realAbs.startsWith(fixturesSub + path.sep)) {
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
// PRD 410 Recorder → Playwright-spec export: narrowly scoped to
|
|
126
|
-
// tests/e2e/ (matches the repo's existing e2e spec location), not a
|
|
127
|
-
// general project-root write grant.
|
|
128
|
-
const e2eSub = path.join(realRoot, 'tests', 'e2e');
|
|
129
|
-
if (realAbs === e2eSub || realAbs.startsWith(e2eSub + path.sep)) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
// Browser tab scratch saves (DOM captures, screenshots, recorded
|
|
133
|
-
// flows): narrowly scoped to session-manager-operations/browser/,
|
|
134
|
-
// this repo's existing per-project artifact-store convention.
|
|
135
|
-
const browserSub = path.join(realRoot, 'session-manager-operations', 'browser');
|
|
136
|
-
if (realAbs === browserSub || realAbs.startsWith(browserSub + path.sep)) {
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
142
|
// PromptSession persistence (active-index.json + per-session archives,
|
|
140
143
|
// promptSessions.ts) and the scheduler's own read-modify-write of that
|
|
141
144
|
// same active index (promptSessionEvents.cjs, PRD 814) — narrowly
|
|
@@ -438,7 +441,10 @@ function registerConfigHandlers() {
|
|
|
438
441
|
ipcMain.handle('config:read-text', v(s.configPath, ({ path: p }) => readText(p)));
|
|
439
442
|
// `writer` is the renderer's declared owner id for the single-writer law
|
|
440
443
|
// (lib/opsOwnership.cjs). Ignored outside the ops root; required inside it.
|
|
441
|
-
ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) =>
|
|
444
|
+
ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => {
|
|
445
|
+
registerPromptSessionsRoot(p);
|
|
446
|
+
return writeJson(p, data, { writer });
|
|
447
|
+
}));
|
|
442
448
|
ipcMain.handle('config:write-text', v(s.configWriteText, ({ path: p, text, writer }) => writeTextAtomic(p, text, { writer })));
|
|
443
449
|
ipcMain.handle('config:list-dir', v(s.configListDir, ({ path: p, opts }) => listDir(p, opts || {})));
|
|
444
450
|
ipcMain.handle('config:exists', v(s.configPath, ({ path: p }) => exists(p)));
|
|
@@ -469,6 +475,7 @@ module.exports = {
|
|
|
469
475
|
listDir,
|
|
470
476
|
exists,
|
|
471
477
|
addAllowedRoot,
|
|
478
|
+
registerPromptSessionsRoot,
|
|
472
479
|
validatePath,
|
|
473
480
|
validateWrite,
|
|
474
481
|
};
|
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
|
};
|