claude-code-session-manager 0.37.1 → 0.38.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 (60) hide show
  1. package/bin/cli.cjs +12 -1
  2. package/dist/assets/{TiptapBody-DXQDiOUx.js → TiptapBody-GMk5LS9Y.js} +1 -1
  3. package/dist/assets/index-CD_LuJZF.css +32 -0
  4. package/dist/assets/{index--a8m5_ET.js → index-CQqSHpIq.js} +444 -436
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +134 -36
  8. package/plugins/session-manager-dev/skills/develop/standards.md +25 -0
  9. package/plugins/session-manager-dev/skills/issue-address/0-select/0-fetch-pool/SKILL.md +38 -0
  10. package/plugins/session-manager-dev/skills/issue-address/0-select/1-reject-fixed/SKILL.md +64 -0
  11. package/plugins/session-manager-dev/skills/issue-address/0-select/2-reject-claimed/SKILL.md +71 -0
  12. package/plugins/session-manager-dev/skills/issue-address/0-select/3-rank-impact/SKILL.md +39 -0
  13. package/plugins/session-manager-dev/skills/issue-address/0-select/SKILL.md +75 -0
  14. package/plugins/session-manager-dev/skills/issue-address/1-confirm-open/SKILL.md +39 -0
  15. package/plugins/session-manager-dev/skills/issue-address/2-claim/SKILL.md +57 -0
  16. package/plugins/session-manager-dev/skills/issue-address/3-reproduce/SKILL.md +40 -0
  17. package/plugins/session-manager-dev/skills/issue-address/4-fix/SKILL.md +33 -0
  18. package/plugins/session-manager-dev/skills/issue-address/5-verify/SKILL.md +40 -0
  19. package/plugins/session-manager-dev/skills/issue-address/SKILL.md +120 -0
  20. package/plugins/session-manager-dev/skills/pr-review-sweep/0-fetch/SKILL.md +41 -0
  21. package/plugins/session-manager-dev/skills/pr-review-sweep/1-classify/SKILL.md +45 -0
  22. package/plugins/session-manager-dev/skills/pr-review-sweep/2-check-fixed/SKILL.md +49 -0
  23. package/plugins/session-manager-dev/skills/pr-review-sweep/3-queue/SKILL.md +50 -0
  24. package/plugins/session-manager-dev/skills/pr-review-sweep/4-land-and-resolve/SKILL.md +60 -0
  25. package/plugins/session-manager-dev/skills/pr-review-sweep/SKILL.md +124 -0
  26. package/plugins/session-manager-dev/skills/pr-signal/SKILL.md +67 -0
  27. package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +20 -17
  28. package/scripts/lib/watchdogHelpers.cjs +296 -166
  29. package/src/main/__tests__/chat-cancel-terminal.test.cjs +31 -0
  30. package/src/main/__tests__/chat-exit-close-race.test.cjs +140 -0
  31. package/src/main/__tests__/classifyTranscriptLine.test.cjs +90 -0
  32. package/src/main/__tests__/docEdit.test.cjs +164 -2
  33. package/src/main/__tests__/prdCreate.test.cjs +265 -25
  34. package/src/main/__tests__/scheduler-admin-routes.test.cjs +150 -0
  35. package/src/main/__tests__/scheduler-boot-orphans.test.cjs +172 -0
  36. package/src/main/__tests__/scheduler-committed-in-window.test.cjs +64 -0
  37. package/src/main/browserAgentServer.cjs +11 -10
  38. package/src/main/chatRunner.cjs +58 -14
  39. package/src/main/docEdit.cjs +125 -5
  40. package/src/main/health.cjs +15 -0
  41. package/src/main/index.cjs +12 -6
  42. package/src/main/ipcSchemas.cjs +16 -3
  43. package/src/main/lib/__tests__/localAdminHttp.test.cjs +120 -0
  44. package/src/main/lib/classifyTranscriptLine.cjs +89 -0
  45. package/src/main/lib/localAdminHttp.cjs +157 -0
  46. package/src/main/lib/personaImportHealth.cjs +161 -0
  47. package/src/main/lib/prdCreate.cjs +107 -17
  48. package/src/main/lib/rcaFeedbackHook.cjs +2 -2
  49. package/src/main/lib/singleInstanceGuard.cjs +20 -0
  50. package/src/main/scheduler.cjs +223 -56
  51. package/src/main/sessionsStore.cjs +4 -5
  52. package/src/main/templates/PRD_AUTHORING.md +4 -4
  53. package/src/main/transcripts.cjs +1 -85
  54. package/src/main/webRemote.cjs +3 -3
  55. package/src/preload/api.d.ts +16 -6
  56. package/src/preload/index.cjs +9 -2
  57. package/dist/assets/index-CTTjT08J.css +0 -32
  58. package/plugins/session-manager-dev/skills/security-review/SKILL.md +0 -105
  59. package/src/main/__tests__/adminServer.test.cjs +0 -380
  60. package/src/main/adminServer.cjs +0 -242
@@ -0,0 +1,140 @@
1
+ /**
2
+ * chat-exit-close-race.test.cjs — regression for the exit/close race that
3
+ * silently dropped a pure-text-only final turn (PRD 716).
4
+ *
5
+ * Bug: the terminal-event fallback was wired to `child.on('exit', ...)`
6
+ * instead of `child.on('close', ...)`. Node only guarantees all buffered
7
+ * stdout 'data' events have been delivered by 'close', not 'exit' — 'exit'
8
+ * can fire before the final stdout chunk (last assistant text + terminating
9
+ * `result` line) reaches the parent's 'data' handler. When that happened,
10
+ * the exit fallback ran first, set the one-shot terminalSent latch, and
11
+ * broadcast a misleading chat:run:error. The real result line then parsed
12
+ * moments later, but emitTerminal silently no-op'd and the genuine
13
+ * chat:run:complete was dropped.
14
+ *
15
+ * Fix: use `child.on('close', ...)` for the fallback so it only fires once
16
+ * Node guarantees stdout is fully drained — a real result event that arrives
17
+ * (even "late", right up against process exit) always wins.
18
+ *
19
+ * This test mocks node:child_process.spawn so it can deterministically
20
+ * reproduce the exact race ordering (exit fires, THEN the final stdout chunk
21
+ * arrives, THEN close fires) rather than relying on real OS/pipe timing,
22
+ * which is not reliably reproducible from a real child process.
23
+ *
24
+ * Run: timeout 120 node --test src/main/__tests__/chat-exit-close-race.test.cjs
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ delete process.env.SM_CHAT_CONCURRENCY;
30
+
31
+ const { test } = require('node:test');
32
+ const assert = require('node:assert/strict');
33
+ const EventEmitter = require('node:events');
34
+ const cp = require('node:child_process');
35
+
36
+ // Replace spawn BEFORE chatRunner.cjs is first required, so the module's
37
+ // top-level `const { spawn } = require('node:child_process')` destructuring
38
+ // captures this mock instead of the real implementation.
39
+ const originalSpawn = cp.spawn;
40
+ let nextChild = null;
41
+ cp.spawn = () => {
42
+ const child = new EventEmitter();
43
+ child.stdout = new EventEmitter();
44
+ child.stderr = new EventEmitter();
45
+ child.pid = 424242;
46
+ child.kill = () => {};
47
+ nextChild = child;
48
+ return child;
49
+ };
50
+
51
+ const cr = require('../chatRunner.cjs');
52
+
53
+ const tick = () => new Promise((r) => setImmediate(r));
54
+
55
+ test('a final-text-only turn whose stdout chunk lands after exit still surfaces chat:run:complete', async () => {
56
+ nextChild = null;
57
+ const events = [];
58
+ cr.attachWindow({
59
+ isDestroyed: () => false,
60
+ webContents: {
61
+ isDestroyed: () => false,
62
+ send: (channel, payload) => events.push({ channel, payload }),
63
+ },
64
+ });
65
+
66
+ cr.run({ tabId: 'T2', sessionId: 'S2', prompt: 'show me a sample post', cwd: process.cwd(), resume: false });
67
+
68
+ for (let i = 0; i < 20 && !nextChild; i++) await tick();
69
+ const child = nextChild;
70
+ assert.ok(child, 'spawn should have been invoked');
71
+
72
+ const text = 'Here is your sample post, all in one go.';
73
+ const lines =
74
+ JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text }] } }) +
75
+ '\n' +
76
+ JSON.stringify({ type: 'result', subtype: 'success', result: text }) +
77
+ '\n';
78
+
79
+ // The exact race: 'exit' fires first (process already gone from the OS's
80
+ // perspective), the final stdout chunk is delivered to the 'data' handler
81
+ // only after that, and 'close' — which Node guarantees fires after all
82
+ // stdout data has been delivered — fires last.
83
+ child.emit('exit', 0, null);
84
+ child.stdout.emit('data', Buffer.from(lines));
85
+ child.emit('close', 0, null);
86
+
87
+ await tick();
88
+ await tick();
89
+
90
+ const terminal = events.filter((e) => isTerminal(e.channel));
91
+ assert.equal(terminal.length, 1, 'exactly one terminal event is emitted');
92
+ assert.equal(
93
+ terminal[0].channel,
94
+ 'chat:run:complete',
95
+ 'the real result event must win over the generic exit-fallback error',
96
+ );
97
+ assert.equal(terminal[0].payload.finalMessage, text);
98
+ });
99
+
100
+ test('a crashed process with no result event ever emitted still fires the fallback chat:run:error', async () => {
101
+ nextChild = null;
102
+ const events = [];
103
+ cr.attachWindow({
104
+ isDestroyed: () => false,
105
+ webContents: {
106
+ isDestroyed: () => false,
107
+ send: (channel, payload) => events.push({ channel, payload }),
108
+ },
109
+ });
110
+
111
+ cr.run({ tabId: 'T3', sessionId: 'S3', prompt: 'anything', cwd: process.cwd(), resume: false });
112
+
113
+ for (let i = 0; i < 20 && !nextChild; i++) await tick();
114
+ const child = nextChild;
115
+ assert.ok(child, 'spawn should have been invoked');
116
+
117
+ // Crashes with no stdout at all — 'close' is the only event that fires.
118
+ child.emit('close', 1, null);
119
+
120
+ await tick();
121
+ await tick();
122
+
123
+ const terminal = events.filter((e) => isTerminal(e.channel));
124
+ assert.equal(terminal.length, 1, 'exactly one terminal event is emitted');
125
+ assert.equal(terminal[0].channel, 'chat:run:error', 'a crash with no result surfaces as an error turn');
126
+ assert.match(terminal[0].payload.message, /process exited without a result event/);
127
+ });
128
+
129
+ function isTerminal(channel) {
130
+ return (
131
+ channel === 'chat:run:complete' ||
132
+ channel === 'chat:run:needs-input' ||
133
+ channel === 'chat:run:error'
134
+ );
135
+ }
136
+
137
+ test.after(() => {
138
+ cp.spawn = originalSpawn;
139
+ delete process.env.SM_CHAT_CONCURRENCY;
140
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * classifyTranscriptLine.test.cjs — unit tests for src/main/lib/classifyTranscriptLine.cjs.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/__tests__/classifyTranscriptLine.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ import { test, expect } from 'vitest';
10
+
11
+ const { classifyLine, trimContentArray, makeRaw, MAX_RAW_STR, EXEMPT_TYPES } = require('../lib/classifyTranscriptLine.cjs');
12
+
13
+ test('classifyLine returns null for non-object input', () => {
14
+ expect(classifyLine(null)).toBeNull();
15
+ expect(classifyLine('not an object')).toBeNull();
16
+ });
17
+
18
+ test('classifyLine tags usage events', () => {
19
+ const ev = classifyLine({ usage: { input_tokens: 10, output_tokens: 5 } });
20
+ expect(ev.kind).toBe('usage');
21
+ expect(ev.data).toEqual({ input_tokens: 10, output_tokens: 5 });
22
+ });
23
+
24
+ test('classifyLine tags TodoWrite tool_use as todo_write', () => {
25
+ const ev = classifyLine({
26
+ message: { content: [{ type: 'tool_use', name: 'TodoWrite', input: { todos: [{ content: 'x' }] } }] },
27
+ });
28
+ expect(ev.kind).toBe('todo_write');
29
+ expect(ev.data).toEqual([{ content: 'x' }]);
30
+ });
31
+
32
+ test('classifyLine tags ExitPlanMode as plan', () => {
33
+ const ev = classifyLine({
34
+ message: { content: [{ type: 'tool_use', name: 'ExitPlanMode', input: { plan: 'do things' } }] },
35
+ });
36
+ expect(ev.kind).toBe('plan');
37
+ });
38
+
39
+ test('classifyLine tags Agent/Task tool_use as agent_spawn with toolUseId', () => {
40
+ const ev = classifyLine({
41
+ message: { content: [{ type: 'tool_use', name: 'Agent', id: 'tool-1', input: { description: 'do work' } }] },
42
+ });
43
+ expect(ev.kind).toBe('agent_spawn');
44
+ expect(ev.data.toolUseId).toBe('tool-1');
45
+ });
46
+
47
+ test('classifyLine tags generic tool_use blocks', () => {
48
+ const ev = classifyLine({
49
+ message: { content: [{ type: 'tool_use', name: 'Bash', id: 'tool-2', input: { command: 'ls' } }] },
50
+ });
51
+ expect(ev.kind).toBe('tool_use');
52
+ expect(ev.data).toEqual({ name: 'Bash', input: { command: 'ls' }, id: 'tool-2' });
53
+ });
54
+
55
+ test('classifyLine tags tool_result blocks with toolUseId', () => {
56
+ const ev = classifyLine({
57
+ message: { content: [{ type: 'tool_result', tool_use_id: 'tool-1' }] },
58
+ });
59
+ expect(ev.kind).toBe('tool_result');
60
+ expect(ev.data).toEqual({ toolUseId: 'tool-1' });
61
+ });
62
+
63
+ test('classifyLine falls back to message kind', () => {
64
+ const ev = classifyLine({ type: 'assistant', message: { content: 'hello' } });
65
+ expect(ev.kind).toBe('assistant');
66
+ });
67
+
68
+ test('trimContentArray passes through non-array input', () => {
69
+ expect(trimContentArray('not-an-array')).toBe('not-an-array');
70
+ });
71
+
72
+ test('trimContentArray truncates long text fields past MAX_RAW_STR', () => {
73
+ const longText = 'a'.repeat(MAX_RAW_STR + 100);
74
+ const [block] = trimContentArray([{ type: 'text', text: longText }]);
75
+ expect(block.text.length).toBe(MAX_RAW_STR + 1);
76
+ expect(block.text.endsWith('…')).toBe(true);
77
+ });
78
+
79
+ test('trimContentArray leaves EXEMPT_TYPES blocks untouched', () => {
80
+ expect(EXEMPT_TYPES.has('tool_result')).toBe(true);
81
+ expect(EXEMPT_TYPES.has('tool_use')).toBe(true);
82
+ const longText = 'a'.repeat(MAX_RAW_STR + 100);
83
+ const [block] = trimContentArray([{ type: 'tool_use', text: longText }]);
84
+ expect(block.text.length).toBe(longText.length);
85
+ });
86
+
87
+ test('makeRaw builds slim projection from message content', () => {
88
+ const raw = makeRaw({ message: { content: [{ type: 'text', text: 'hi' }] } });
89
+ expect(raw.message.content).toEqual([{ type: 'text', text: 'hi' }]);
90
+ });
@@ -6,10 +6,27 @@
6
6
 
7
7
  'use strict';
8
8
 
9
- import { test, expect, describe } from 'vitest';
10
- const { parseDocEdit } = require('../docEdit.cjs');
9
+ import { test, expect, describe, beforeEach, vi } from 'vitest';
10
+
11
+ // docEdit.cjs requires chatRunner.cjs at module scope and calls `chatRunner.run(...)`
12
+ // as a property access, so patching `run` on the already-required (cached) module
13
+ // object — before docEdit.cjs's own require resolves to the same singleton — is
14
+ // enough to intercept it, mirroring the exchanges.cjs patch pattern in chatRunner.spec.ts.
15
+ const chatRunnerModule = require('../chatRunner.cjs');
16
+ const runCalls = [];
17
+ beforeEach(() => { runCalls.length = 0; });
18
+ chatRunnerModule.run = (opts) => { runCalls.push(opts); };
19
+
20
+ const { parseDocEdit, editPrompt, truncateDocumentText, MAX_DOC_CONTEXT, docEditViaSession, attachWindow } = require('../docEdit.cjs');
11
21
  const { duplicateNameFor } = require('../files.cjs');
12
22
 
23
+ const broadcasts = [];
24
+ attachWindow({
25
+ isDestroyed: () => false,
26
+ webContents: { isDestroyed: () => false, send: (channel, payload) => broadcasts.push({ channel, payload }) },
27
+ });
28
+ beforeEach(() => { broadcasts.length = 0; });
29
+
13
30
  describe('parseDocEdit', () => {
14
31
  test('valid JSON', () => {
15
32
  const result = parseDocEdit('{"after":"rewritten text"}');
@@ -57,6 +74,151 @@ describe('parseDocEdit', () => {
57
74
  });
58
75
  });
59
76
 
77
+ describe('editPrompt', () => {
78
+ test('omits the document block when documentText is absent', () => {
79
+ const prompt = editPrompt('selected text', 'make it concise');
80
+ expect(prompt).not.toMatch(/<document_/);
81
+ });
82
+
83
+ test('includes a nonce-tagged document block when documentText is provided', () => {
84
+ const prompt = editPrompt('selected text', 'make it concise', 'the whole document');
85
+ expect(prompt).toMatch(/<document_[0-9a-f]+>/);
86
+ expect(prompt).toContain('the whole document');
87
+ });
88
+
89
+ test('truncates an oversized documentText before embedding it', () => {
90
+ const huge = 'x'.repeat(MAX_DOC_CONTEXT + 5000);
91
+ const prompt = editPrompt('selected text', 'make it concise', huge);
92
+ expect(prompt).toContain('[...document truncated for length...]');
93
+ expect(prompt.length).toBeLessThan(huge.length + 2000);
94
+ });
95
+ });
96
+
97
+ describe('truncateDocumentText', () => {
98
+ test('passes short text through unchanged', () => {
99
+ expect(truncateDocumentText('short')).toBe('short');
100
+ });
101
+
102
+ test('truncates to the documented head+tail scheme rather than dropping or erroring', () => {
103
+ const head = 'H'.repeat(40000);
104
+ const tail = 'T'.repeat(20000);
105
+ const middle = 'M'.repeat(10000);
106
+ const result = truncateDocumentText(head + middle + tail);
107
+ expect(result).toBe(`${head}\n\n[...document truncated for length...]\n\n${tail}`);
108
+ expect(result).not.toContain('M');
109
+ });
110
+ });
111
+
112
+ describe('docEditViaSession', () => {
113
+ test('builds its prompt via the shared editPrompt and calls chatRunner.run with resume+silent', () => {
114
+ docEditViaSession({
115
+ tabId: 'tab-1',
116
+ sessionId: 'chat-session-1',
117
+ cwd: '/home/user/project',
118
+ before: 'old text',
119
+ instruction: 'make it punchier',
120
+ documentText: 'the whole document',
121
+ requestId: 'req-1',
122
+ });
123
+
124
+ expect(runCalls).toHaveLength(1);
125
+ const call = runCalls[0];
126
+ expect(call.tabId).toBe('tab-1');
127
+ expect(call.sessionId).toBe('chat-session-1');
128
+ expect(call.cwd).toBe('/home/user/project');
129
+ expect(call.resume).toBe(true);
130
+ expect(call.silent).toBe(true);
131
+ expect(typeof call.onSilentResult).toBe('function');
132
+
133
+ // The prompt is the shared editPrompt's output (nonce-tagged, so compare
134
+ // structurally rather than byte-for-byte) prefixed with the anti-injection
135
+ // system framing — not a second, forked prompt builder.
136
+ expect(call.prompt).toContain('Rewrite the SELECTION below per the INSTRUCTION');
137
+ expect(call.prompt).toMatch(/<selection_[0-9a-f]+>\nold text\n<\/selection_[0-9a-f]+>/);
138
+ expect(call.prompt).toContain('deterministic document-rewrite assistant');
139
+ });
140
+
141
+ test('onSilentResult parses the reply and broadcasts docedit:session-result', () => {
142
+ docEditViaSession({
143
+ tabId: 'tab-2',
144
+ sessionId: 'chat-session-2',
145
+ cwd: '/home/user/project',
146
+ before: 'old text',
147
+ instruction: 'shorten it',
148
+ requestId: 'req-2',
149
+ });
150
+
151
+ const { onSilentResult } = runCalls[runCalls.length - 1];
152
+ onSilentResult('{"after":"new text"}');
153
+ expect(broadcasts).toHaveLength(1);
154
+ expect(broadcasts[0]).toEqual({
155
+ channel: 'docedit:session-result',
156
+ payload: { tabId: 'tab-2', requestId: 'req-2', ok: true, after: 'new text' },
157
+ });
158
+ });
159
+
160
+ test('onSilentResult reports a parse failure as ok:false', () => {
161
+ docEditViaSession({
162
+ tabId: 'tab-3',
163
+ sessionId: 'chat-session-3',
164
+ cwd: '/home/user/project',
165
+ before: 'old text',
166
+ instruction: 'shorten it',
167
+ requestId: 'req-3',
168
+ });
169
+
170
+ const { onSilentResult } = runCalls[runCalls.length - 1];
171
+ onSilentResult('not json at all');
172
+ expect(broadcasts).toHaveLength(1);
173
+ expect(broadcasts[0].payload.ok).toBe(false);
174
+ expect(broadcasts[0].payload.error).toBeTruthy();
175
+ });
176
+
177
+ test('splits off the stop-signal protocol and reports it as a clarification request', () => {
178
+ docEditViaSession({
179
+ tabId: 'tab-4',
180
+ sessionId: 'chat-session-4',
181
+ cwd: '/home/user/project',
182
+ before: 'old text',
183
+ instruction: 'shorten it',
184
+ requestId: 'req-4',
185
+ });
186
+
187
+ const { onSilentResult } = runCalls[runCalls.length - 1];
188
+ onSilentResult('<<<SM_NEEDS_INPUT>>>\n{"questions":["which section?"]}');
189
+ expect(broadcasts).toHaveLength(1);
190
+ expect(broadcasts[0].payload.ok).toBe(false);
191
+ expect(broadcasts[0].payload.error).toContain('which section?');
192
+ });
193
+
194
+ test('times out and broadcasts a failure if chatRunner never calls onSilentResult (error/timeout/cancel/collision on the silent lane)', () => {
195
+ vi.useFakeTimers();
196
+ try {
197
+ docEditViaSession({
198
+ tabId: 'tab-5',
199
+ sessionId: 'chat-session-5',
200
+ cwd: '/home/user/project',
201
+ before: 'old text',
202
+ instruction: 'shorten it',
203
+ requestId: 'req-5',
204
+ });
205
+
206
+ // chatRunner.run() is a no-op stub in this test (never invokes
207
+ // onSilentResult) — nothing should broadcast until the bound elapses.
208
+ expect(broadcasts).toHaveLength(0);
209
+
210
+ vi.advanceTimersByTime(120_000);
211
+ expect(broadcasts).toHaveLength(1);
212
+ expect(broadcasts[0]).toEqual({
213
+ channel: 'docedit:session-result',
214
+ payload: { tabId: 'tab-5', requestId: 'req-5', ok: false, error: 'timed out waiting on the background session' },
215
+ });
216
+ } finally {
217
+ vi.useRealTimers();
218
+ }
219
+ });
220
+ });
221
+
60
222
  describe('duplicateNameFor', () => {
61
223
  test('fresh name — no collision', () => {
62
224
  const result = duplicateNameFor('/tmp/dir', 'notes.txt', () => false);