claude-code-session-manager 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{TiptapBody-D5b7nejd.js → TiptapBody-Cg8YZhVZ.js} +58 -58
- package/dist/assets/index-CTTjT08J.css +32 -0
- package/dist/assets/index-_iBXLuvt.js +3496 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
- package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
- package/src/main/__tests__/historyDashboard.test.cjs +163 -0
- package/src/main/__tests__/historyRollup.test.cjs +333 -0
- package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
- package/src/main/__tests__/queueHistory.test.cjs +233 -0
- package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
- package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
- package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
- package/src/main/chatRunner.cjs +46 -12
- package/src/main/config.cjs +8 -0
- package/src/main/historyAggregator.cjs +391 -50
- package/src/main/historyDashboard.cjs +226 -0
- package/src/main/index.cjs +35 -1
- package/src/main/ipcSchemas.cjs +5 -0
- package/src/main/lib/broadcastCoalescer.cjs +46 -0
- package/src/main/lib/historyRollup.cjs +148 -0
- package/src/main/lib/queueHistory.cjs +216 -0
- package/src/main/lib/rcaFeedbackHook.cjs +346 -0
- package/src/main/lib/schedulerConfig.cjs +16 -0
- package/src/main/queueOps.cjs +92 -0
- package/src/main/scheduler/prdParser.cjs +52 -1
- package/src/main/scheduler.cjs +237 -29
- package/src/preload/api.d.ts +69 -1
- package/src/preload/index.cjs +1 -0
- package/dist/assets/index-CkiGRskz.css +0 -32
- package/dist/assets/index-DrzirIUy.js +0 -3562
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rcaFeedbackHook.test.cjs — unit tests for src/main/lib/rcaFeedbackHook.cjs.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/rcaFeedbackHook.test.cjs
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
let tmpHome;
|
|
15
|
+
let realHome;
|
|
16
|
+
let rcaFeedbackHook;
|
|
17
|
+
|
|
18
|
+
// config.cjs and rcaFeedbackHook.cjs both bake os.homedir() into top-level
|
|
19
|
+
// consts at first require (allowedRoots/WRITE_PREFIXES, PRDS_DIR/SM_REPO_ROOT).
|
|
20
|
+
// Node's require cache must be purged per test so each test's tmpHome takes
|
|
21
|
+
// effect — vi.resetModules() only resets vitest's ESM graph, not require().
|
|
22
|
+
const MODULES_TO_RELOAD = ['../lib/rcaFeedbackHook.cjs', '../config.cjs'];
|
|
23
|
+
|
|
24
|
+
function purgeRequireCache() {
|
|
25
|
+
for (const m of MODULES_TO_RELOAD) {
|
|
26
|
+
try { delete require.cache[require.resolve(m)]; } catch { /* not loaded yet */ }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
realHome = process.env.HOME;
|
|
32
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'rca-feedback-hook-test-'));
|
|
33
|
+
process.env.HOME = tmpHome;
|
|
34
|
+
delete process.env.SM_RCA_DISABLE;
|
|
35
|
+
purgeRequireCache();
|
|
36
|
+
rcaFeedbackHook = require('../lib/rcaFeedbackHook.cjs');
|
|
37
|
+
// The session-manager-repo fallback destination must exist as a real dir.
|
|
38
|
+
fs.mkdirSync(path.join(tmpHome, 'Projects', 'session-manager', 'session-manager-operations', 'feedback'), { recursive: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
process.env.HOME = realHome;
|
|
43
|
+
delete process.env.SM_RCA_DISABLE;
|
|
44
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
45
|
+
purgeRequireCache();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function makeProjectWithInbox(name = 'proj') {
|
|
51
|
+
const cwd = path.join(tmpHome, name);
|
|
52
|
+
fs.mkdirSync(path.join(cwd, 'session-manager-operations', 'feedback'), { recursive: true });
|
|
53
|
+
return cwd;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function makeProjectWithoutInbox(name = 'bare-proj') {
|
|
57
|
+
const cwd = path.join(tmpHome, name);
|
|
58
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
59
|
+
return cwd;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeRun(runDir, slug, { logLines = ['line1', 'line2'], exitCode = 1, durationMs = 1234 } = {}) {
|
|
63
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
64
|
+
fs.writeFileSync(path.join(runDir, `${slug}.log`), logLines.join('\n') + '\n');
|
|
65
|
+
fs.writeFileSync(path.join(runDir, `${slug}.meta.json`), JSON.stringify({ exitCode, durationMs }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writePrd(slug, { acBody = '- [ ] `timeout 10 echo ok` passes.' } = {}) {
|
|
69
|
+
const prdsDir = path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'prds');
|
|
70
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
71
|
+
const body = ['---', `title: ${slug}`, '---', '', '# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', acBody, '', '# Out of scope', '', '- N/A'].join('\n');
|
|
72
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), body);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function baseJob(overrides = {}) {
|
|
76
|
+
return { slug: 'testslug', runId: '2026-07-24T00-00-00-000Z', exitCode: 1, error: 'something broke', ...overrides };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── dedupe / new-runId ───────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
test('fileRcaFeedback: second call with same (slug, runId) is a no-op', async () => {
|
|
82
|
+
const cwd = makeProjectWithInbox();
|
|
83
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
84
|
+
writeRun(runDir, 'testslug');
|
|
85
|
+
writePrd('testslug');
|
|
86
|
+
const job = baseJob({ cwd });
|
|
87
|
+
|
|
88
|
+
const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
|
|
89
|
+
expect(first.filed).toBe(true);
|
|
90
|
+
|
|
91
|
+
const second = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
|
|
92
|
+
expect(second.filed).toBe(false);
|
|
93
|
+
expect(second.reason).toBe('duplicate');
|
|
94
|
+
|
|
95
|
+
const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
|
|
96
|
+
expect(files.length).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('fileRcaFeedback: new runId for the same slug files a new RCA', async () => {
|
|
100
|
+
const cwd = makeProjectWithInbox();
|
|
101
|
+
const runDir1 = path.join(tmpHome, 'run1');
|
|
102
|
+
const runDir2 = path.join(tmpHome, 'run2');
|
|
103
|
+
writeRun(runDir1, 'testslug');
|
|
104
|
+
writeRun(runDir2, 'testslug');
|
|
105
|
+
writePrd('testslug');
|
|
106
|
+
|
|
107
|
+
const r1 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-aaa' }), runDir: runDir1, verdict: 'transcript_errors' });
|
|
108
|
+
const r2 = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, runId: 'run-bbb' }), runDir: runDir2, verdict: 'transcript_errors' });
|
|
109
|
+
|
|
110
|
+
expect(r1.filed).toBe(true);
|
|
111
|
+
expect(r2.filed).toBe(true);
|
|
112
|
+
expect(r1.path).not.toBe(r2.path);
|
|
113
|
+
|
|
114
|
+
const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
|
|
115
|
+
expect(files.length).toBe(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ─── failure-class matching ───────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
test('classifyFailure: detects stuck-loop from an until/while-true/sleep tail', () => {
|
|
121
|
+
const logTail = ['doing work', 'until curl -s http://x | jq .uptime; do sleep 5; done', 'still waiting'].join('\n');
|
|
122
|
+
expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('stuck-loop');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('classifyFailure: detects post-ac-overrun when work continues well past the last AC checkbox with no PASS', () => {
|
|
126
|
+
const lines = ['- [x] first AC item done', '- [x] last AC item done'];
|
|
127
|
+
for (let i = 0; i < 30; i++) lines.push(`extra post-AC work line ${i}`);
|
|
128
|
+
const logTail = lines.join('\n');
|
|
129
|
+
expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail })).toBe('post-ac-overrun');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('classifyFailure: falls back to uncommitted-changes for that verdict', () => {
|
|
133
|
+
expect(rcaFeedbackHook.classifyFailure({ verdict: 'uncommitted_changes', logTail: 'no special markers here' })).toBe('uncommitted-changes');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('classifyFailure: falls back to no-sentinel for no_verdict_sentinel/pass_no_commit', () => {
|
|
137
|
+
expect(rcaFeedbackHook.classifyFailure({ verdict: 'no_verdict_sentinel', logTail: 'clean tail, no loops' })).toBe('no-sentinel');
|
|
138
|
+
expect(rcaFeedbackHook.classifyFailure({ verdict: 'pass_no_commit', logTail: 'clean tail, no loops' })).toBe('no-sentinel');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('classifyFailure: falls back to transcript-errors for transcript_errors/verify_unavailable', () => {
|
|
142
|
+
expect(rcaFeedbackHook.classifyFailure({ verdict: 'transcript_errors', logTail: 'Traceback (most recent call last)' })).toBe('transcript-errors');
|
|
143
|
+
expect(rcaFeedbackHook.classifyFailure({ verdict: 'verify_unavailable', logTail: 'verifier threw' })).toBe('transcript-errors');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('classifyFailure: unknown verdict with no matching markers falls back to unknown', () => {
|
|
147
|
+
expect(rcaFeedbackHook.classifyFailure({ verdict: 'halt', logTail: 'nothing special' })).toBe('unknown');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ─── AC extraction ─────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
test('extractAcceptanceCriteria: pulls the section body between the heading and the next heading', () => {
|
|
153
|
+
const body = ['# Goal', '', 'Do the thing.', '', '# Acceptance criteria', '', '- [ ] one', '- [ ] two', '', '# Out of scope', '', '- N/A'].join('\n');
|
|
154
|
+
expect(rcaFeedbackHook.extractAcceptanceCriteria(body)).toBe('- [ ] one\n- [ ] two');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('extractAcceptanceCriteria: returns null when no such heading exists', () => {
|
|
158
|
+
const body = ['# Goal', '', 'Do the thing.'].join('\n');
|
|
159
|
+
expect(rcaFeedbackHook.extractAcceptanceCriteria(body)).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ─── investigation <RCA> block extraction ──────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
test('extractRcaBlock: pulls plain-text block content', () => {
|
|
165
|
+
const log = 'preamble\n<RCA>\nForgot to bound the poll loop.\n</RCA>\npostamble';
|
|
166
|
+
expect(rcaFeedbackHook.extractRcaBlock(log)).toBe('Forgot to bound the poll loop.');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('extractRcaBlock: unescapes JSON-escaped newlines from a stream-json transcript', () => {
|
|
170
|
+
const log = '{"text":"<RCA>\\nForgot to bound the poll loop.\\nSee scheduler.cjs:1509.\\n</RCA>"}';
|
|
171
|
+
expect(rcaFeedbackHook.extractRcaBlock(log)).toBe('Forgot to bound the poll loop.\nSee scheduler.cjs:1509.');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('extractRcaBlock: returns null when no block is present', () => {
|
|
175
|
+
expect(rcaFeedbackHook.extractRcaBlock('no block here')).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ─── destination resolution ────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
test('resolveDestination: uses the job cwd inbox when it exists', () => {
|
|
181
|
+
const cwd = makeProjectWithInbox();
|
|
182
|
+
const dest = rcaFeedbackHook.resolveDestination({ cwd });
|
|
183
|
+
expect(dest.dir).toBe(path.join(cwd, 'session-manager-operations', 'feedback'));
|
|
184
|
+
expect(dest.targetProjectNote).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('resolveDestination: falls back to the session-manager repo inbox when the target has none', () => {
|
|
188
|
+
const cwd = makeProjectWithoutInbox();
|
|
189
|
+
const dest = rcaFeedbackHook.resolveDestination({ cwd });
|
|
190
|
+
expect(dest.dir).toBe(rcaFeedbackHook.SM_REPO_FEEDBACK_DIR);
|
|
191
|
+
expect(dest.targetProjectNote).toContain(cwd);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('fileRcaFeedback: actually writes into the fallback dir when the target inbox is missing', async () => {
|
|
195
|
+
const cwd = makeProjectWithoutInbox();
|
|
196
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
197
|
+
writeRun(runDir, 'testslug');
|
|
198
|
+
writePrd('testslug');
|
|
199
|
+
|
|
200
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
|
|
201
|
+
expect(res.filed).toBe(true);
|
|
202
|
+
expect(res.path.startsWith(rcaFeedbackHook.SM_REPO_FEEDBACK_DIR)).toBe(true);
|
|
203
|
+
const content = fs.readFileSync(res.path, 'utf8');
|
|
204
|
+
expect(content).toContain(cwd);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('fileRcaFeedback: rejects a slug containing path-traversal segments', async () => {
|
|
208
|
+
const cwd = makeProjectWithInbox();
|
|
209
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
210
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
211
|
+
|
|
212
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd, slug: '../../etc/passwd' }), runDir, verdict: 'transcript_errors' });
|
|
213
|
+
expect(res.filed).toBe(false);
|
|
214
|
+
expect(res.reason).toBe('unsafe-slug');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ─── kill-switch ────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
test('fileRcaFeedback: SM_RCA_DISABLE=1 skips without writing', async () => {
|
|
220
|
+
process.env.SM_RCA_DISABLE = '1';
|
|
221
|
+
const cwd = makeProjectWithInbox();
|
|
222
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
223
|
+
writeRun(runDir, 'testslug');
|
|
224
|
+
writePrd('testslug');
|
|
225
|
+
|
|
226
|
+
const res = await rcaFeedbackHook.fileRcaFeedback({ job: baseJob({ cwd }), runDir, verdict: 'transcript_errors' });
|
|
227
|
+
expect(res.filed).toBe(false);
|
|
228
|
+
expect(res.reason).toBe('disabled');
|
|
229
|
+
expect(fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback')).length).toBe(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ─── investigation-text enrichment ──────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
test('fileRcaFeedback: investigationText updates the existing RCA instead of duplicating it', async () => {
|
|
235
|
+
const cwd = makeProjectWithInbox();
|
|
236
|
+
const runDir = path.join(tmpHome, 'run1');
|
|
237
|
+
writeRun(runDir, 'testslug');
|
|
238
|
+
writePrd('testslug');
|
|
239
|
+
const job = baseJob({ cwd });
|
|
240
|
+
|
|
241
|
+
const first = await rcaFeedbackHook.fileRcaFeedback({ job, runDir, verdict: 'transcript_errors' });
|
|
242
|
+
expect(first.filed).toBe(true);
|
|
243
|
+
const before = fs.readFileSync(first.path, 'utf8');
|
|
244
|
+
expect(before).not.toContain('## Investigation analysis');
|
|
245
|
+
|
|
246
|
+
const second = await rcaFeedbackHook.fileRcaFeedback({
|
|
247
|
+
job, runDir, verdict: 'transcript_errors', investigationText: 'Root cause: forgot to bound the poll loop.',
|
|
248
|
+
});
|
|
249
|
+
expect(second.filed).toBe(true);
|
|
250
|
+
expect(second.updated).toBe(true);
|
|
251
|
+
expect(second.path).toBe(first.path);
|
|
252
|
+
|
|
253
|
+
const after = fs.readFileSync(second.path, 'utf8');
|
|
254
|
+
expect(after).toContain('## Investigation analysis');
|
|
255
|
+
expect(after).toContain('forgot to bound the poll loop');
|
|
256
|
+
|
|
257
|
+
const files = fs.readdirSync(path.join(cwd, 'session-manager-operations', 'feedback'));
|
|
258
|
+
expect(files.length).toBe(1);
|
|
259
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-effective-concurrency.test.cjs — unit tests for the
|
|
3
|
+
* effectiveConcurrency field on buildScheduleStatePayload.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 300 npx vitest run src/main/__tests__/scheduler-effective-concurrency.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
const { buildScheduleStatePayload } = require('../scheduler.cjs');
|
|
12
|
+
|
|
13
|
+
const ENV_KEY = 'SM_SCHEDULER_MAX_CONCURRENCY';
|
|
14
|
+
let savedEnv;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
savedEnv = process.env[ENV_KEY];
|
|
18
|
+
delete process.env[ENV_KEY];
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
if (savedEnv === undefined) delete process.env[ENV_KEY];
|
|
23
|
+
else process.env[ENV_KEY] = savedEnv;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function fakeState(concurrencyCap) {
|
|
27
|
+
return {
|
|
28
|
+
config: { concurrencyCap },
|
|
29
|
+
jobs: [],
|
|
30
|
+
scheduledFor: null,
|
|
31
|
+
lastRunAt: null,
|
|
32
|
+
paused: null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('effectiveConcurrency is config-sourced when env var is unset', () => {
|
|
37
|
+
const payload = buildScheduleStatePayload(fakeState(5));
|
|
38
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 5, source: 'config' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('effectiveConcurrency is env-sourced and wins over config when env var is set', () => {
|
|
42
|
+
process.env[ENV_KEY] = '7';
|
|
43
|
+
const payload = buildScheduleStatePayload(fakeState(5));
|
|
44
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 7, source: 'env' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('effectiveConcurrency clamps env var into [1, 20]', () => {
|
|
48
|
+
process.env[ENV_KEY] = '999';
|
|
49
|
+
const payload = buildScheduleStatePayload(fakeState(5));
|
|
50
|
+
expect(payload.effectiveConcurrency).toEqual({ cap: 20, source: 'env' });
|
|
51
|
+
});
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -54,30 +54,49 @@ const { extractJson } = require('./lib/extractJson.cjs');
|
|
|
54
54
|
const STOP_SENTINEL = '<<<SM_NEEDS_INPUT>>>';
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
*
|
|
57
|
+
* Split the final assistant text at the stop-signal sentinel.
|
|
58
58
|
*
|
|
59
|
-
* Returns `{ questions: string[] }` when the sentinel is
|
|
60
|
-
* balanced JSON object with a `questions` array can be found
|
|
61
|
-
* text after it
|
|
62
|
-
* multi-line pretty-printing, and trailing prose after the closing
|
|
59
|
+
* Returns `{ answerBody: string, questions: string[] }` when the sentinel is
|
|
60
|
+
* present and a balanced JSON object with a `questions` array can be found
|
|
61
|
+
* anywhere in the text after it (tolerant of leading blank lines, code-fence
|
|
62
|
+
* wrapping, multi-line pretty-printing, and trailing prose after the closing
|
|
63
|
+
* `}`). `answerBody` is everything before the LAST sentinel occurrence, with
|
|
64
|
+
* the trailing sentinel line and JSON line removed and whitespace trimmed —
|
|
65
|
+
* an earlier literal sentinel string embedded in the agent's own answer text
|
|
66
|
+
* is preserved as part of `answerBody` (lastIndexOf semantics).
|
|
63
67
|
* Returns `null` when the sentinel is absent OR when no such JSON object is
|
|
64
|
-
* found — both cases are treated as a completed run with no crash.
|
|
68
|
+
* found after it — both cases are treated as a completed run with no crash.
|
|
65
69
|
*
|
|
66
70
|
* @param {string} finalText
|
|
67
|
-
* @returns {{ questions: string[] } | null}
|
|
71
|
+
* @returns {{ answerBody: string, questions: string[] } | null}
|
|
68
72
|
*/
|
|
69
|
-
function
|
|
73
|
+
function splitStopSignal(finalText) {
|
|
70
74
|
if (typeof finalText !== 'string') return null;
|
|
71
75
|
const idx = finalText.lastIndexOf(STOP_SENTINEL);
|
|
72
76
|
if (idx === -1) return null;
|
|
73
77
|
const after = finalText.slice(idx + STOP_SENTINEL.length);
|
|
74
78
|
const parsed = extractJson(after);
|
|
75
79
|
if (parsed && Array.isArray(parsed.questions)) {
|
|
76
|
-
|
|
80
|
+
const answerBody = finalText.slice(0, idx).trim();
|
|
81
|
+
return { answerBody, questions: parsed.questions };
|
|
77
82
|
}
|
|
78
83
|
return null;
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Parse the final assistant text for the stop-signal protocol.
|
|
88
|
+
*
|
|
89
|
+
* Thin wrapper over `splitStopSignal` — kept for existing callers that only
|
|
90
|
+
* need the questions, not the pre-sentinel answer body.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} finalText
|
|
93
|
+
* @returns {{ questions: string[] } | null}
|
|
94
|
+
*/
|
|
95
|
+
function parseStopSignal(finalText) {
|
|
96
|
+
const split = splitStopSignal(finalText);
|
|
97
|
+
return split ? { questions: split.questions } : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
// ─── `/context` probe (PRD 470) ────────────────────────────────────────────
|
|
82
101
|
// `claude -p "/context"` is answered entirely locally by the CLI (zero-cost,
|
|
83
102
|
// no real API call) with markdown containing a summary line and a per-category
|
|
@@ -220,10 +239,14 @@ function hasMcpConsentDenial(text) {
|
|
|
220
239
|
// Instruction prepended to every prompt. Tells the agent how to signal that
|
|
221
240
|
// it needs clarification vs. having completed the task.
|
|
222
241
|
const STOP_SIGNAL_INSTRUCTION =
|
|
223
|
-
`IMPORTANT:
|
|
224
|
-
`
|
|
242
|
+
`IMPORTANT: First deliver everything you CAN answer or produce right now — findings, ` +
|
|
243
|
+
`the requested content, work already completed — as normal output. Only THEN, if ` +
|
|
244
|
+
`something still blocks you from finishing, end your turn by emitting, as the very ` +
|
|
245
|
+
`last line, exactly:\n` +
|
|
225
246
|
`${STOP_SENTINEL}\n` +
|
|
226
247
|
`followed on the next line by a single-line JSON object {"questions":["..."]}. ` +
|
|
248
|
+
`Never make the question your entire reply when the user asked for content — do NOT ` +
|
|
249
|
+
`guess on what's genuinely blocked, but always answer what you can first. ` +
|
|
227
250
|
`Otherwise complete the task and end with a concise summary of what you did.\n\n`;
|
|
228
251
|
|
|
229
252
|
// ─── Serial run queue (v0.34) ───────────────────────────────────────────────
|
|
@@ -507,14 +530,24 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
|
|
|
507
530
|
emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
|
|
508
531
|
if (typeof onSilentResult === 'function') onSilentResult(text);
|
|
509
532
|
} else {
|
|
510
|
-
const signal =
|
|
533
|
+
const signal = splitStopSignal(text);
|
|
511
534
|
if (signal) {
|
|
512
535
|
emitTerminal('chat:run:needs-input', {
|
|
513
536
|
tabId,
|
|
514
537
|
sessionId,
|
|
515
538
|
questions: signal.questions,
|
|
539
|
+
answerBody: signal.answerBody,
|
|
516
540
|
raw: text,
|
|
517
541
|
});
|
|
542
|
+
// Record durable exchange off the hot path — UI must not wait on Haiku
|
|
543
|
+
recordExchange({
|
|
544
|
+
sessionId,
|
|
545
|
+
cwd,
|
|
546
|
+
prompt,
|
|
547
|
+
result: `${signal.answerBody}\n\n[asked: ${signal.questions.join(' | ')}]`,
|
|
548
|
+
}).catch((err) => {
|
|
549
|
+
console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
|
|
550
|
+
});
|
|
518
551
|
} else {
|
|
519
552
|
emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
|
|
520
553
|
// Record durable exchange off the hot path — UI must not wait on Haiku
|
|
@@ -640,6 +673,7 @@ module.exports = {
|
|
|
640
673
|
attachWindow,
|
|
641
674
|
registerChatHandlers,
|
|
642
675
|
parseStopSignal,
|
|
676
|
+
splitStopSignal,
|
|
643
677
|
parseContextUsageMarkdown,
|
|
644
678
|
probeContextUsage,
|
|
645
679
|
STOP_SENTINEL,
|
package/src/main/config.cjs
CHANGED
|
@@ -134,6 +134,14 @@ function validateWrite(realAbs) {
|
|
|
134
134
|
if (realAbs === browserSub || realAbs.startsWith(browserSub + path.sep)) {
|
|
135
135
|
return;
|
|
136
136
|
}
|
|
137
|
+
// Feedback inbox writes (scheduler RCA hook — rcaFeedbackHook.cjs — and
|
|
138
|
+
// any interactive feedback filing): narrowly scoped to
|
|
139
|
+
// session-manager-operations/feedback/, this repo's existing
|
|
140
|
+
// per-project intake convention.
|
|
141
|
+
const feedbackSub = path.join(realRoot, 'session-manager-operations', 'feedback');
|
|
142
|
+
if (realAbs === feedbackSub || realAbs.startsWith(feedbackSub + path.sep)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
|