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.
Files changed (32) hide show
  1. package/dist/assets/{TiptapBody-D5b7nejd.js → TiptapBody-Cg8YZhVZ.js} +58 -58
  2. package/dist/assets/index-CTTjT08J.css +32 -0
  3. package/dist/assets/index-_iBXLuvt.js +3496 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/plugins/session-manager-dev/skills/develop/SKILL.md +5 -1
  7. package/src/main/__tests__/broadcastCoalescer.test.cjs +104 -0
  8. package/src/main/__tests__/historyDashboard.test.cjs +163 -0
  9. package/src/main/__tests__/historyRollup.test.cjs +333 -0
  10. package/src/main/__tests__/prdParserHighWater.test.cjs +74 -0
  11. package/src/main/__tests__/queueHistory.test.cjs +233 -0
  12. package/src/main/__tests__/queueOpsAutoArchive.test.cjs +142 -0
  13. package/src/main/__tests__/rcaFeedbackHook.test.cjs +259 -0
  14. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +51 -0
  15. package/src/main/chatRunner.cjs +46 -12
  16. package/src/main/config.cjs +8 -0
  17. package/src/main/historyAggregator.cjs +391 -50
  18. package/src/main/historyDashboard.cjs +226 -0
  19. package/src/main/index.cjs +35 -1
  20. package/src/main/ipcSchemas.cjs +5 -0
  21. package/src/main/lib/broadcastCoalescer.cjs +46 -0
  22. package/src/main/lib/historyRollup.cjs +148 -0
  23. package/src/main/lib/queueHistory.cjs +216 -0
  24. package/src/main/lib/rcaFeedbackHook.cjs +346 -0
  25. package/src/main/lib/schedulerConfig.cjs +16 -0
  26. package/src/main/queueOps.cjs +92 -0
  27. package/src/main/scheduler/prdParser.cjs +52 -1
  28. package/src/main/scheduler.cjs +237 -29
  29. package/src/preload/api.d.ts +69 -1
  30. package/src/preload/index.cjs +1 -0
  31. package/dist/assets/index-CkiGRskz.css +0 -32
  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
+ });
@@ -54,30 +54,49 @@ const { extractJson } = require('./lib/extractJson.cjs');
54
54
  const STOP_SENTINEL = '<<<SM_NEEDS_INPUT>>>';
55
55
 
56
56
  /**
57
- * Parse the final assistant text for the stop-signal protocol.
57
+ * Split the final assistant text at the stop-signal sentinel.
58
58
  *
59
- * Returns `{ questions: string[] }` when the sentinel is present and a
60
- * balanced JSON object with a `questions` array can be found anywhere in the
61
- * text after it tolerant of leading blank lines, code-fence wrapping,
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 parseStopSignal(finalText) {
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
- return { questions: parsed.questions };
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: If you need clarification from the user before you can continue, ` +
224
- `do NOT guess finish your turn by emitting, as the very last line, exactly:\n` +
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 = parseStopSignal(text);
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,
@@ -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}`);