claude-code-session-manager 0.35.3 → 0.35.4

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.
@@ -0,0 +1,137 @@
1
+ /**
2
+ * chat-mcp-consent-notice.test.cjs — regression for the headless MCP-consent
3
+ * hang: a chat run with stdin closed can never answer an MCP server's own
4
+ * interactive consent flow (e.g. `/design consent`). This drives the real
5
+ * stdout parser with a synthetic stream-json `tool_result` denial line and
6
+ * asserts a `chat:run:notice` event is broadcast — informational, not a
7
+ * terminal event.
8
+ *
9
+ * Run: timeout 120 node --test src/main/__tests__/chat-mcp-consent-notice.test.cjs
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ delete process.env.SM_CHAT_CONCURRENCY;
15
+
16
+ const { test } = require('node:test');
17
+ const assert = require('node:assert/strict');
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+
22
+ function writeStub(lines) {
23
+ const stubPath = path.join(os.tmpdir(), `sm-claude-stub-${process.pid}-${Math.floor(Math.random() * 1e9)}.cjs`);
24
+ const body = lines.map((l) => `process.stdout.write(${JSON.stringify(JSON.stringify(l))} + "\\n");`).join('\n');
25
+ fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\nprocess.exit(0);\n`, { mode: 0o755 });
26
+ return stubPath;
27
+ }
28
+
29
+ function isTerminal(channel) {
30
+ return (
31
+ channel === 'chat:run:complete' ||
32
+ channel === 'chat:run:needs-input' ||
33
+ channel === 'chat:run:error'
34
+ );
35
+ }
36
+
37
+ test('MCP consent denial in a tool_result surfaces a chat:run:notice event', async () => {
38
+ const stubPath = writeStub([
39
+ {
40
+ type: 'user',
41
+ message: {
42
+ content: [
43
+ {
44
+ type: 'tool_result',
45
+ tool_use_id: 'toolu_1',
46
+ is_error: true,
47
+ content: [
48
+ {
49
+ type: 'text',
50
+ text:
51
+ "Claude Design: write_files The user hasn't granted this — run /design consent " +
52
+ "to grant it (it can't be approved automatically in this permission mode).",
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ },
58
+ },
59
+ { type: 'result', subtype: 'success', result: 'done anyway' },
60
+ ]);
61
+ process.env.SM_CLAUDE_BIN = stubPath;
62
+ delete require.cache[require.resolve('../chatRunner.cjs')];
63
+ const cr = require('../chatRunner.cjs');
64
+
65
+ const events = [];
66
+ cr.attachWindow({
67
+ isDestroyed: () => false,
68
+ webContents: {
69
+ isDestroyed: () => false,
70
+ send: (channel, payload) => events.push({ channel, payload }),
71
+ },
72
+ });
73
+
74
+ cr.run({ tabId: 'T-notice', sessionId: 'S-notice', prompt: 'do a design thing', cwd: process.cwd(), resume: false });
75
+
76
+ for (let i = 0; i < 60 && !events.some((e) => isTerminal(e.channel)); i++) {
77
+ await new Promise((r) => setTimeout(r, 25));
78
+ }
79
+
80
+ const notice = events.find((e) => e.channel === 'chat:run:notice');
81
+ assert.ok(notice, 'chat:run:notice should be broadcast');
82
+ assert.match(notice.payload.message, /consent/i, 'message mentions consent');
83
+ assert.match(notice.payload.message, /terminal/i, 'message mentions the raw terminal session');
84
+
85
+ // Notice is informational, not terminal — the normal result still fires.
86
+ const terminal = events.filter((e) => isTerminal(e.channel));
87
+ assert.equal(terminal.length, 1, 'exactly one terminal event still fires');
88
+ assert.equal(terminal[0].channel, 'chat:run:complete');
89
+
90
+ try { fs.unlinkSync(stubPath); } catch { /* already gone */ }
91
+ delete process.env.SM_CLAUDE_BIN;
92
+ });
93
+
94
+ test('a normal tool_result with no consent marker does not fire chat:run:notice', async () => {
95
+ const stubPath = writeStub([
96
+ {
97
+ type: 'user',
98
+ message: {
99
+ content: [
100
+ {
101
+ type: 'tool_result',
102
+ tool_use_id: 'toolu_2',
103
+ is_error: false,
104
+ content: [{ type: 'text', text: 'Wrote 3 files successfully.' }],
105
+ },
106
+ ],
107
+ },
108
+ },
109
+ { type: 'result', subtype: 'success', result: 'all good' },
110
+ ]);
111
+ process.env.SM_CLAUDE_BIN = stubPath;
112
+ delete require.cache[require.resolve('../chatRunner.cjs')];
113
+ const cr = require('../chatRunner.cjs');
114
+
115
+ const events = [];
116
+ cr.attachWindow({
117
+ isDestroyed: () => false,
118
+ webContents: {
119
+ isDestroyed: () => false,
120
+ send: (channel, payload) => events.push({ channel, payload }),
121
+ },
122
+ });
123
+
124
+ cr.run({ tabId: 'T-clean', sessionId: 'S-clean', prompt: 'do a normal thing', cwd: process.cwd(), resume: false });
125
+
126
+ for (let i = 0; i < 60 && !events.some((e) => isTerminal(e.channel)); i++) {
127
+ await new Promise((r) => setTimeout(r, 25));
128
+ }
129
+
130
+ assert.ok(
131
+ !events.some((e) => e.channel === 'chat:run:notice'),
132
+ 'no chat:run:notice should fire for a clean tool_result (no false positive)',
133
+ );
134
+
135
+ try { fs.unlinkSync(stubPath); } catch { /* already gone */ }
136
+ delete process.env.SM_CLAUDE_BIN;
137
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * mcpStatus.test.cjs — unit tests for the `claude mcp list` output parser.
3
+ *
4
+ * Run: timeout 120 node --test src/main/__tests__/mcpStatus.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const { parseMcpList } = require('../mcpStatus.cjs');
12
+
13
+ const SAMPLE = `Checking MCP server health…
14
+
15
+ claude.ai Gmail: https://gmailmcp.googleapis.com/mcp/v1 - ✔ Connected
16
+ fetch: uvx mcp-server-fetch - ✔ Connected
17
+ n8n: bash /home/bilko/.config/n8n-mcp/run.sh - ✘ Failed to connect
18
+ session-manager-scheduler: node scripts/scheduler-mcp-server.cjs - ⏸ Pending approval (run \`claude\` to approve)
19
+ claude_design: https://api.anthropic.com/v1/design/mcp (HTTP) - ✔ Connected
20
+ claude.ai Google Drive: https://drivemcp.googleapis.com/mcp/v1 - ! Needs authentication
21
+ `;
22
+
23
+ test('parses the captured claude mcp list sample', () => {
24
+ const servers = parseMcpList(SAMPLE);
25
+ assert.ok(servers.length >= 6, `expected >=6 servers, got ${servers.length}`);
26
+
27
+ const byName = Object.fromEntries(servers.map((s) => [s.name, s]));
28
+
29
+ assert.equal(byName['claude.ai Gmail'].status, 'connected');
30
+ assert.equal(byName['fetch'].status, 'connected');
31
+ assert.equal(byName['n8n'].status, 'failed');
32
+ assert.equal(byName['session-manager-scheduler'].status, 'pending');
33
+ assert.equal(byName['claude_design'].status, 'connected');
34
+ assert.equal(byName['claude_design'].transport, 'http');
35
+ assert.equal(byName['claude.ai Google Drive'].status, 'needs-auth');
36
+ });
37
+
38
+ test('ignores the header and blank lines', () => {
39
+ const servers = parseMcpList(SAMPLE);
40
+ assert.ok(!servers.some((s) => /Checking MCP server health/i.test(s.name)));
41
+ });
42
+
43
+ test('defaults transport to stdio for non-HTTP targets', () => {
44
+ const servers = parseMcpList(SAMPLE);
45
+ const byName = Object.fromEntries(servers.map((s) => [s.name, s]));
46
+ assert.equal(byName['n8n'].transport, 'stdio');
47
+ assert.equal(byName['fetch'].transport, 'stdio');
48
+ });
49
+
50
+ test('returns [] (not a throw) on empty input', () => {
51
+ assert.deepEqual(parseMcpList(''), []);
52
+ });
53
+
54
+ test('returns [] (not a throw) on garbage input', () => {
55
+ assert.deepEqual(parseMcpList('this is not\na valid mcp list output\n12345'), []);
56
+ });
57
+
58
+ test('returns [] on non-string input without throwing', () => {
59
+ assert.deepEqual(parseMcpList(null), []);
60
+ assert.deepEqual(parseMcpList(undefined), []);
61
+ });
@@ -8,9 +8,10 @@
8
8
 
9
9
  const { test } = require('node:test');
10
10
  const assert = require('node:assert/strict');
11
- const { selectAutoFixTargets } = require('../scheduler.cjs');
11
+ const { selectAutoFixTargets, isUnresolvableNeedsReview } = require('../scheduler.cjs');
12
12
 
13
13
  const noSiblingOnDisk = () => false;
14
+ const noRunDir = () => null;
14
15
 
15
16
  function makeJob(overrides = {}) {
16
17
  return {
@@ -53,8 +54,55 @@ test('excludes a completed job', () => {
53
54
  assert.strictEqual(result.length, 0);
54
55
  });
55
56
 
56
- test('excludes a job missing runId', () => {
57
+ test('excludes a job missing runId when no run dir resolves', () => {
57
58
  const jobs = [makeJob({ runId: null })];
59
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk, resolveJobRunId: noRunDir });
60
+ assert.strictEqual(result.length, 0);
61
+ });
62
+
63
+ test('selects a job missing runId when a run dir resolves (gap 1: runId backfill)', () => {
64
+ const jobs = [makeJob({ runId: null })];
65
+ const result = selectAutoFixTargets(jobs, {
66
+ fixSlugExists: noSiblingOnDisk,
67
+ resolveJobRunId: () => '2026-06-16T10-00-00-000Z',
68
+ });
69
+ assert.strictEqual(result.length, 1);
70
+ });
71
+
72
+ test('isUnresolvableNeedsReview: needs_review + no runId + no run dir → true', () => {
73
+ const job = makeJob({ runId: null });
74
+ assert.strictEqual(isUnresolvableNeedsReview(job, { hasRunDir: false }), true);
75
+ });
76
+
77
+ test('isUnresolvableNeedsReview: needs_review + no runId + has run dir → false', () => {
78
+ const job = makeJob({ runId: null });
79
+ assert.strictEqual(isUnresolvableNeedsReview(job, { hasRunDir: true }), false);
80
+ });
81
+
82
+ test('isUnresolvableNeedsReview: needs_review + runId present → false', () => {
83
+ const job = makeJob();
84
+ assert.strictEqual(isUnresolvableNeedsReview(job, { hasRunDir: false }), false);
85
+ });
86
+
87
+ test('isUnresolvableNeedsReview: non needs_review status → false', () => {
88
+ const job = makeJob({ runId: null, status: 'failed' });
89
+ assert.strictEqual(isUnresolvableNeedsReview(job, { hasRunDir: false }), false);
90
+ });
91
+
92
+ test('autoFixOutcome no-plan with retries=0 is retried', () => {
93
+ const jobs = [makeJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan', autoFixRetries: 0 })];
94
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
95
+ assert.strictEqual(result.length, 1);
96
+ });
97
+
98
+ test('autoFixOutcome no-plan with retries=1 is exhausted (not selected)', () => {
99
+ const jobs = [makeJob({ autoFixAttempted: true, autoFixOutcome: 'no-plan', autoFixRetries: 1 })];
100
+ const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
101
+ assert.strictEqual(result.length, 0);
102
+ });
103
+
104
+ test('autoFixAttempted true with no outcome recorded stays excluded (existing 1-attempt cap)', () => {
105
+ const jobs = [makeJob({ autoFixAttempted: true })];
58
106
  const result = selectAutoFixTargets(jobs, { fixSlugExists: noSiblingOnDisk });
59
107
  assert.strictEqual(result.length, 0);
60
108
  });
@@ -8,7 +8,7 @@
8
8
 
9
9
  const { test } = require('node:test');
10
10
  const assert = require('node:assert/strict');
11
- const { isPromotableOriginal } = require('../scheduler.cjs');
11
+ const { isPromotableOriginal, healTargetForFix } = require('../scheduler.cjs');
12
12
 
13
13
  test('isPromotableOriginal: failed → true', () => {
14
14
  assert.strictEqual(isPromotableOriginal('failed'), true);
@@ -30,4 +30,22 @@ test('isPromotableOriginal: pending → false', () => {
30
30
  assert.strictEqual(isPromotableOriginal('pending'), false);
31
31
  });
32
32
 
33
+ test('healTargetForFix: matches the specific original by full numeric-prefixed slug, not just base', () => {
34
+ const jobs = [
35
+ { slug: '451-foo', status: 'needs_review' },
36
+ { slug: '453-foo', status: 'needs_review' },
37
+ ];
38
+ const target = healTargetForFix('451-fix-foo', jobs);
39
+ assert.strictEqual(target.slug, '451-foo');
40
+ });
41
+
42
+ test('healTargetForFix: returns null when no promotable original matches', () => {
43
+ const jobs = [
44
+ { slug: '451-foo', status: 'completed' },
45
+ { slug: '453-foo', status: 'needs_review' },
46
+ ];
47
+ const target = healTargetForFix('451-fix-foo', jobs);
48
+ assert.strictEqual(target, null);
49
+ });
50
+
33
51
  console.log('scheduler-autopromote tests: PASS');
@@ -26,6 +26,7 @@
26
26
  * chat:run:complete { tabId, sessionId, finalMessage }
27
27
  * chat:run:needs-input { tabId, sessionId, questions, raw }
28
28
  * chat:run:error { tabId, sessionId, message }
29
+ * chat:run:notice { tabId, sessionId, message } — informational, not terminal
29
30
  */
30
31
 
31
32
  const { spawn } = require('node:child_process');
@@ -71,6 +72,32 @@ function parseStopSignal(finalText) {
71
72
  }
72
73
  }
73
74
 
75
+ // ─── MCP consent-denial detection ──────────────────────────────────────────
76
+ // Best-effort substring heuristic over known CLI phrasing — NOT a documented
77
+ // stream-json schema field. Confirmed by extracting strings from the compiled
78
+ // `claude` CLI binary (2.1.207): a non-interactive/`-p` run that hits an
79
+ // MCP server requiring first-party consent (e.g. `claude_design`) throws a
80
+ // tool error whose message is exactly:
81
+ // "<tool label> The user hasn't granted this — run /design consent to
82
+ // grant it (it can't be approved automatically in this permission mode)."
83
+ // which surfaces to stdout as a `tool_result` block (is_error: true) inside a
84
+ // `type: "user"` stream-json event. Since consent flows exist for MCP servers
85
+ // beyond `claude_design`, match on the generic phrasing rather than the
86
+ // design-specific slash command.
87
+ const MCP_CONSENT_DENIAL_MARKERS = [
88
+ "hasn't granted this",
89
+ 'run /design consent',
90
+ 'connect to claude design',
91
+ 'requires consent',
92
+ 'needs a claude.ai credential',
93
+ ];
94
+
95
+ function hasMcpConsentDenial(text) {
96
+ if (typeof text !== 'string' || !text) return false;
97
+ const lower = text.toLowerCase();
98
+ return MCP_CONSENT_DENIAL_MARKERS.some((marker) => lower.includes(marker));
99
+ }
100
+
74
101
  // Instruction prepended to every prompt. Tells the agent how to signal that
75
102
  // it needs clarification vs. having completed the task.
76
103
  const STOP_SIGNAL_INSTRUCTION =
@@ -188,6 +215,24 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
188
215
  broadcast(channel, payload);
189
216
  };
190
217
 
218
+ // Separate one-shot guard for the MCP-consent notice (orthogonal to
219
+ // terminalSent — a run can hit this mid-stream and still legitimately
220
+ // finish with a normal complete/error afterward).
221
+ let noticeSent = false;
222
+ const emitNoticeOnce = () => {
223
+ if (noticeSent) return;
224
+ noticeSent = true;
225
+ broadcast('chat:run:notice', {
226
+ tabId,
227
+ sessionId,
228
+ message:
229
+ 'This run needs interactive consent for an MCP server (e.g. Claude Design), which ' +
230
+ "can't be granted in chat mode because stdin is closed for headless runs. Open a " +
231
+ 'raw terminal session for this tab ("Back to chat" toggle) and grant consent there, ' +
232
+ 'then retry.',
233
+ });
234
+ };
235
+
191
236
  const claudeBin = resolveClaudeBin();
192
237
  const childEnv = cleanChildEnv({ PATH: pathWithUserBins() });
193
238
 
@@ -285,6 +330,21 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
285
330
  broadcast('chat:run:tool-use', { tabId, id: block.id, ...classified });
286
331
  }
287
332
  }
333
+ } else if (event.type === 'user') {
334
+ // Tool results come back as content blocks on a synthetic "user" event.
335
+ // An MCP consent-denial surfaces here as an is_error tool_result whose
336
+ // text carries one of MCP_CONSENT_DENIAL_MARKERS.
337
+ const content = Array.isArray(event.message?.content) ? event.message.content : [];
338
+ for (const block of content) {
339
+ if (block.type !== 'tool_result') continue;
340
+ const inner = block.content;
341
+ const text = typeof inner === 'string'
342
+ ? inner
343
+ : Array.isArray(inner)
344
+ ? inner.filter((c) => c?.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n')
345
+ : '';
346
+ if (hasMcpConsentDenial(text)) emitNoticeOnce();
347
+ }
288
348
  } else if (event.type === 'result') {
289
349
  // Use the authoritative `result` field when available; fall back to
290
350
  // accumulated assistant text (same content, different source).
@@ -14,6 +14,7 @@ const transcripts = require('./transcripts.cjs');
14
14
  const usageMatrix = require('./usageMatrix.cjs');
15
15
  const sessionsStore = require('./sessionsStore.cjs');
16
16
  const billing = require('./usage.cjs');
17
+ const { probeMcpStatus } = require('./mcpStatus.cjs');
17
18
  const logs = require('./logs.cjs');
18
19
  const crashDiagnostics = require('./crashDiagnostics.cjs');
19
20
  // Start the local minidump collector before app-ready (required by Electron).
@@ -408,6 +409,10 @@ ipcMain.handle('app:launch-mode', () => ({
408
409
  cwd: process.cwd(),
409
410
  }));
410
411
 
412
+ // MCP Servers tab (PRD 456 consumes this): probes live connection status via
413
+ // `claude mcp list`. Read-only, single in-flight call — no polling.
414
+ ipcMain.handle('mcp:status', () => probeMcpStatus());
415
+
411
416
  ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
412
417
 
413
418
  // Boot diagnostics — renderer polls these to surface toasts when `claude` isn't
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * mcpStatus.cjs — probes live MCP server connection status by shelling out to
5
+ * `claude mcp list` (a plain subcommand, no LLM call — no --model pin needed).
6
+ *
7
+ * Read-only, non-polling: only runs on explicit renderer request (mcp:status
8
+ * IPC). Coalesces concurrent callers onto a single in-flight probe so bursts
9
+ * don't fan out into parallel `claude` processes.
10
+ */
11
+
12
+ const { spawn } = require('node:child_process');
13
+ const { resolveClaudeBin } = require('./lib/claudeBin.cjs');
14
+
15
+ const PROBE_TIMEOUT_MS = 30_000;
16
+
17
+ // One server line: "<name>: <target> - <glyph> <status text>"
18
+ const LINE_RE = /^(.+?):\s(.+?)\s-\s(\S+)\s*(.*)$/;
19
+
20
+ /**
21
+ * Pure parser — no I/O. Extracts { name, target, transport, status } per
22
+ * server line. Ignores the "Checking MCP server health…" header and blank
23
+ * lines. Never throws: unparseable input yields [].
24
+ */
25
+ function parseMcpList(stdout) {
26
+ if (!stdout || typeof stdout !== 'string') return [];
27
+ const servers = [];
28
+ for (const rawLine of stdout.split('\n')) {
29
+ const line = rawLine.trim();
30
+ if (!line) continue;
31
+ if (line.toLowerCase().startsWith('checking mcp server health')) continue;
32
+ const m = LINE_RE.exec(line);
33
+ if (!m) continue;
34
+ const [, name, target, glyph, statusText] = m;
35
+ const transport = /\(HTTP\)$/.test(target) || /^https?:\/\//i.test(target) ? 'http' : 'stdio';
36
+ let status;
37
+ if (glyph === '✔') status = 'connected';
38
+ else if (glyph === '✘') status = 'failed';
39
+ else if (glyph === '!') status = 'needs-auth';
40
+ else if (glyph === '⏸') status = 'pending';
41
+ else status = 'unknown';
42
+ servers.push({ name: name.trim(), target: target.trim(), transport, status, statusText: statusText.trim() });
43
+ }
44
+ return servers;
45
+ }
46
+
47
+ let inFlight = null;
48
+
49
+ /** Spawns `claude mcp list`, parses it. Resolves — never rejects. */
50
+ function runProbe() {
51
+ return new Promise((resolve) => {
52
+ let bin;
53
+ try { bin = resolveClaudeBin(); } catch (e) {
54
+ resolve({ ok: false, servers: [], error: `claude not found: ${e?.message}`, checkedAt: Date.now() });
55
+ return;
56
+ }
57
+ let child;
58
+ try {
59
+ child = spawn(bin, ['mcp', 'list'], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
60
+ } catch (e) {
61
+ resolve({ ok: false, servers: [], error: e?.message || 'spawn error', checkedAt: Date.now() });
62
+ return;
63
+ }
64
+ let out = '';
65
+ let err = '';
66
+ const MAX_OUT_BYTES = 1 * 1024 * 1024;
67
+ let settled = false;
68
+ const finish = (result) => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } };
69
+ const timer = setTimeout(() => {
70
+ try { child.kill('SIGKILL'); } catch { /* */ }
71
+ finish({ ok: false, servers: [], error: 'timeout', checkedAt: Date.now() });
72
+ }, PROBE_TIMEOUT_MS);
73
+ child.stdout.on('data', (d) => { if (out.length < MAX_OUT_BYTES) out += d; });
74
+ child.stderr.on('data', (d) => { if (err.length < MAX_OUT_BYTES) err += d; });
75
+ child.on('error', (e) => { finish({ ok: false, servers: [], error: e?.message || 'spawn error', checkedAt: Date.now() }); });
76
+ child.on('close', (code) => {
77
+ if (code !== 0 && !out) {
78
+ finish({ ok: false, servers: [], error: err.slice(0, 500) || `exit ${code}`, checkedAt: Date.now() });
79
+ return;
80
+ }
81
+ finish({ ok: true, servers: parseMcpList(out), checkedAt: Date.now() });
82
+ });
83
+ });
84
+ }
85
+
86
+ /** Public entry point. Coalesces concurrent callers onto one in-flight probe. */
87
+ function probeMcpStatus() {
88
+ if (inFlight) return inFlight;
89
+ inFlight = runProbe().finally(() => { inFlight = null; });
90
+ return inFlight;
91
+ }
92
+
93
+ module.exports = { probeMcpStatus, parseMcpList };