claude-code-session-manager 0.35.3 → 0.35.5

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.
@@ -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');
@@ -0,0 +1,51 @@
1
+ /**
2
+ * scheduler-investigation-prompt.test.cjs — unit tests for buildInvestigationPrompt.
3
+ *
4
+ * Run: timeout 120 node --test src/main/__tests__/scheduler-investigation-prompt.test.cjs
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ const { test } = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const { buildInvestigationPrompt } = require('../scheduler.cjs');
12
+
13
+ function makeArgs(overrides = {}) {
14
+ return {
15
+ failedJob: { slug: '05-my-feature', title: 'My feature', exitCode: 1 },
16
+ cwd: '/home/bilko/Projects/session-manager',
17
+ failedLogPath: '/tmp/05-my-feature.log',
18
+ originalBody: 'Original PRD body.',
19
+ logTail: 'some log tail output',
20
+ fixPath: '/tmp/05-fix-my-feature.md',
21
+ group: 5,
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ test('prompt references the canonical standards.md file', () => {
27
+ const prompt = buildInvestigationPrompt(makeArgs());
28
+ assert.match(prompt, /standards\.md/);
29
+ assert.match(prompt, /plugins\/session-manager-dev\/skills\/develop\/standards\.md/);
30
+ });
31
+
32
+ test('prompt instructs inlining the Execution discipline section under Engineering standards', () => {
33
+ const prompt = buildInvestigationPrompt(makeArgs());
34
+ assert.match(prompt, /Engineering standards/);
35
+ assert.match(prompt, /Execution discipline \(headless runs\)/);
36
+ });
37
+
38
+ test('prompt contains the named "delegated instead of executed" detection check', () => {
39
+ const prompt = buildInvestigationPrompt(makeArgs());
40
+ assert.match(prompt, /ScheduleWakeup/);
41
+ assert.match(prompt, /session-manager-dev:develop/);
42
+ assert.match(prompt, /never re-queue or self-schedule|delegated instead of executed/);
43
+ });
44
+
45
+ test('prompt includes the resolved job fields', () => {
46
+ const prompt = buildInvestigationPrompt(makeArgs());
47
+ assert.match(prompt, /05-my-feature/);
48
+ assert.match(prompt, /Original PRD body\./);
49
+ assert.match(prompt, /some log tail output/);
50
+ assert.match(prompt, /05-fix-my-feature\.md/);
51
+ });
@@ -18,7 +18,7 @@ const path = require('path');
18
18
  const os = require('os');
19
19
  const fs = require('fs');
20
20
  const crypto = require('crypto');
21
- const { WebContentsView, shell } = require('electron');
21
+ const { WebContentsView } = require('electron');
22
22
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
23
23
  const { readJson, writeJson } = require('./config.cjs');
24
24
 
@@ -181,7 +181,7 @@ function wireNavEvents(viewId, view) {
181
181
 
182
182
  wc.setWindowOpenHandler(({ url }) => {
183
183
  if (/^https?:\/\//i.test(url)) {
184
- shell.openExternal(url).catch(() => {});
184
+ sendIfAlive(win, 'browser:open-tab-request', { url });
185
185
  }
186
186
  return { action: 'deny' };
187
187
  });
@@ -12,9 +12,14 @@
12
12
  * OOM that SIGKILLed a job on 2026-06-26.
13
13
  *
14
14
  * Public surface:
15
- * run({ tabId, sessionId, prompt, cwd, resume }): void — fire-and-forget enqueue
15
+ * run({ tabId, sessionId, prompt, cwd, resume, silent }): void — fire-and-forget enqueue.
16
+ * `silent` (PRD 470) still goes through the same per-tab FIFO lane, but suppresses the
17
+ * six turn-affecting broadcasts + recordExchange (see probeContextUsage below).
16
18
  * cancel(tabId): void
17
19
  * parseStopSignal(finalText): { questions: string[] } | null — exported for reuse
20
+ * parseContextUsageMarkdown(text): { usedTokens, totalTokens, usedPct, categories } | null
21
+ * — pure parser for `/context`
22
+ * probeContextUsage({ tabId, sessionId, cwd }): void — fire-and-forget silent `/context` probe
18
23
  * STOP_SENTINEL: string — exported for tests
19
24
  * __setExecutor(fn): void — test seam
20
25
  *
@@ -26,6 +31,9 @@
26
31
  * chat:run:complete { tabId, sessionId, finalMessage }
27
32
  * chat:run:needs-input { tabId, sessionId, questions, raw }
28
33
  * chat:run:error { tabId, sessionId, message }
34
+ * chat:run:notice { tabId, sessionId, message } — informational, not terminal
35
+ * chat:context-usage { tabId, sessionId, usedTokens, totalTokens, usedPct, categories }
36
+ * — result of a silent `/context` probe
29
37
  */
30
38
 
31
39
  const { spawn } = require('node:child_process');
@@ -71,6 +79,145 @@ function parseStopSignal(finalText) {
71
79
  }
72
80
  }
73
81
 
82
+ // ─── `/context` probe (PRD 470) ────────────────────────────────────────────
83
+ // `claude -p "/context"` is answered entirely locally by the CLI (zero-cost,
84
+ // no real API call) with markdown containing a summary line and a per-category
85
+ // breakdown table. Parsed here so the app can mirror the CLI's own context-
86
+ // window accounting without re-implementing the estimate itself.
87
+
88
+ // "9k" -> 9000, "15.1k" -> 15100; bare "207" -> 207. Never throws — returns NaN
89
+ // for genuinely malformed input, which callers treat as a parse failure.
90
+ function parseTokenAmount(str) {
91
+ const suffixed = /^(\d+(?:\.\d+)?)k$/i.exec(str);
92
+ if (suffixed) return Math.round(parseFloat(suffixed[1]) * 1000);
93
+ return Number(str);
94
+ }
95
+
96
+ /**
97
+ * Parse the markdown produced by the CLI's own `/context` slash command into
98
+ * a structured summary. Pure, single line-by-line pass — O(n) over the lines
99
+ * of a small, bounded response, no nested scans.
100
+ *
101
+ * @param {string} text
102
+ * @returns {{ usedTokens: number, totalTokens: number, usedPct: number, categories: Array<{ category: string, tokens: number, pct: number }> } | null}
103
+ */
104
+ function parseContextUsageMarkdown(text) {
105
+ if (typeof text !== 'string' || !text) return null;
106
+ const lines = text.split('\n');
107
+
108
+ let usedTokens = null;
109
+ let totalTokens = null;
110
+ let usedPct = null;
111
+ const tokensLineRe = /\*\*Tokens:\*\*\s*(\S+)\s*\/\s*(\S+)\s*\(([\d.]+)%\)/;
112
+
113
+ let inTable = false;
114
+ let foundHeading = false;
115
+ const categories = [];
116
+
117
+ for (const line of lines) {
118
+ if (usedTokens === null) {
119
+ const m = tokensLineRe.exec(line);
120
+ if (m) {
121
+ usedTokens = parseTokenAmount(m[1]);
122
+ totalTokens = parseTokenAmount(m[2]);
123
+ usedPct = Number(m[3]);
124
+ }
125
+ }
126
+
127
+ if (!inTable) {
128
+ if (line.trim() === '### Estimated usage by category') {
129
+ foundHeading = true;
130
+ inTable = true;
131
+ }
132
+ continue;
133
+ }
134
+ const trimmed = line.trim();
135
+ if (trimmed.startsWith('###')) { inTable = false; continue; }
136
+ if (!trimmed.startsWith('|')) continue;
137
+
138
+ const cells = trimmed.split('|').map((c) => c.trim()).filter((c) => c.length > 0);
139
+ if (cells.length !== 3) continue;
140
+ if (cells[0].toLowerCase() === 'category') continue; // header row
141
+ if (/^:?-+:?$/.test(cells[0])) continue; // separator row
142
+
143
+ const tokens = parseTokenAmount(cells[1]);
144
+ const pctMatch = /^([\d.]+)%$/.exec(cells[2]);
145
+ if (!pctMatch || Number.isNaN(tokens)) continue;
146
+ categories.push({ category: cells[0], tokens, pct: Number(pctMatch[1]) });
147
+ }
148
+
149
+ if (
150
+ usedTokens === null || totalTokens === null || usedPct === null ||
151
+ Number.isNaN(usedTokens) || Number.isNaN(totalTokens) || Number.isNaN(usedPct) ||
152
+ !foundHeading || categories.length === 0
153
+ ) {
154
+ return null;
155
+ }
156
+
157
+ return { usedTokens, totalTokens, usedPct, categories };
158
+ }
159
+
160
+ /**
161
+ * Fire-and-forget silent probe of the resumed session's context usage. Reuses
162
+ * the normal `run()` queue/lane (resume: true, silent: true) so it can never
163
+ * race a real chat run against the same `--resume sessionId`. Broadcasts
164
+ * `chat:context-usage` on success; logs (never throws/broadcasts) on a parse
165
+ * failure.
166
+ *
167
+ * @param {{ tabId: string, sessionId: string, cwd: string }} params
168
+ */
169
+ function probeContextUsage({ tabId, sessionId, cwd }) {
170
+ run({
171
+ tabId,
172
+ sessionId,
173
+ prompt: '/context',
174
+ cwd,
175
+ resume: true,
176
+ silent: true,
177
+ onSilentResult: (text) => {
178
+ const parsed = parseContextUsageMarkdown(text);
179
+ if (!parsed) {
180
+ console.error('[chatRunner] parseContextUsageMarkdown failed to parse /context probe output');
181
+ return;
182
+ }
183
+ broadcast('chat:context-usage', {
184
+ tabId,
185
+ sessionId,
186
+ usedTokens: parsed.usedTokens,
187
+ totalTokens: parsed.totalTokens,
188
+ usedPct: parsed.usedPct,
189
+ categories: parsed.categories,
190
+ });
191
+ },
192
+ });
193
+ }
194
+
195
+ // ─── MCP consent-denial detection ──────────────────────────────────────────
196
+ // Best-effort substring heuristic over known CLI phrasing — NOT a documented
197
+ // stream-json schema field. Confirmed by extracting strings from the compiled
198
+ // `claude` CLI binary (2.1.207): a non-interactive/`-p` run that hits an
199
+ // MCP server requiring first-party consent (e.g. `claude_design`) throws a
200
+ // tool error whose message is exactly:
201
+ // "<tool label> The user hasn't granted this — run /design consent to
202
+ // grant it (it can't be approved automatically in this permission mode)."
203
+ // which surfaces to stdout as a `tool_result` block (is_error: true) inside a
204
+ // `type: "user"` stream-json event. Since consent flows exist for MCP servers
205
+ // beyond `claude_design`, match on the generic phrasing rather than the
206
+ // design-specific slash command.
207
+ const MCP_CONSENT_DENIAL_MARKERS = [
208
+ "hasn't granted this",
209
+ 'run /design consent',
210
+ 'connect to claude design',
211
+ 'requires consent',
212
+ 'needs a claude.ai credential',
213
+ ];
214
+
215
+ function hasMcpConsentDenial(text) {
216
+ if (typeof text !== 'string' || !text) return false;
217
+ const lower = text.toLowerCase();
218
+ return MCP_CONSENT_DENIAL_MARKERS.some((marker) => lower.includes(marker));
219
+ }
220
+
74
221
  // Instruction prepended to every prompt. Tells the agent how to signal that
75
222
  // it needs clarification vs. having completed the task.
76
223
  const STOP_SIGNAL_INSTRUCTION =
@@ -94,7 +241,7 @@ const CONCURRENCY_CAP = Math.min(
94
241
 
95
242
  // tabId → cancel() for every ACTIVE run; FIFO list of WAITING runs; live count.
96
243
  const inFlight = new Map();
97
- const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume }]
244
+ const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }]
98
245
  let activeCount = 0;
99
246
 
100
247
  // Indirection so tests can stub the spawn without launching claude.
@@ -126,7 +273,7 @@ function broadcast(channel, payload) {
126
273
  * queue and are announced via chat:run:queued. De-dupes a tab already in the
127
274
  * pipeline (the UI disables input while running, but guard anyway).
128
275
  *
129
- * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean }} opts
276
+ * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void }} opts
130
277
  */
131
278
  function run(opts) {
132
279
  if (inFlight.has(opts.tabId) || waiting.some((w) => w.tabId === opts.tabId)) return;
@@ -159,10 +306,10 @@ function pump() {
159
306
  * for the run's lifetime so cancel() can reach it. Never rejects — the queue
160
307
  * pump relies on the returned promise always settling so the lane frees.
161
308
  *
162
- * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean }} opts
309
+ * @param {{ tabId: string, sessionId: string, prompt: string, cwd: string, resume: boolean, silent?: boolean, onSilentResult?: (text: string) => void }} opts
163
310
  * @returns {Promise<void>}
164
311
  */
165
- function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
312
+ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentResult }) {
166
313
  return new Promise((resolve) => {
167
314
  let settled = false;
168
315
  // Frees the lane exactly once: drops the cancel fn and resolves the promise
@@ -185,9 +332,30 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
185
332
  const emitTerminal = (channel, payload) => {
186
333
  if (terminalSent) return;
187
334
  terminalSent = true;
335
+ // Silent (probe) runs still need this bookkeeping so the lane frees
336
+ // correctly, but must never surface on the six turn-affecting channels.
337
+ if (silent) return;
188
338
  broadcast(channel, payload);
189
339
  };
190
340
 
341
+ // Separate one-shot guard for the MCP-consent notice (orthogonal to
342
+ // terminalSent — a run can hit this mid-stream and still legitimately
343
+ // finish with a normal complete/error afterward).
344
+ let noticeSent = false;
345
+ const emitNoticeOnce = () => {
346
+ if (noticeSent) return;
347
+ noticeSent = true;
348
+ broadcast('chat:run:notice', {
349
+ tabId,
350
+ sessionId,
351
+ message:
352
+ 'This run needs interactive consent for an MCP server (e.g. Claude Design), which ' +
353
+ "can't be granted in chat mode because stdin is closed for headless runs. Open a " +
354
+ 'raw terminal session for this tab ("Back to chat" toggle) and grant consent there, ' +
355
+ 'then retry.',
356
+ });
357
+ };
358
+
191
359
  const claudeBin = resolveClaudeBin();
192
360
  const childEnv = cleanChildEnv({ PATH: pathWithUserBins() });
193
361
 
@@ -209,7 +377,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
209
377
  args.push('--session-id', sessionId);
210
378
  }
211
379
 
212
- broadcast('chat:run:started', { tabId, sessionId });
380
+ if (!silent) broadcast('chat:run:started', { tabId, sessionId });
213
381
 
214
382
  // Spawn with stdin closed (mirrors scheduler's 'ignore' — prevents the
215
383
  // "claude -p stdin must be closed" gotcha from kg.cjs). stdout is piped for
@@ -279,32 +447,54 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume }) {
279
447
  for (const block of content) {
280
448
  if (block.type === 'text' && typeof block.text === 'string') {
281
449
  finalAssistantText += block.text;
282
- broadcast('chat:run:output', { tabId, delta: block.text });
450
+ if (!silent) broadcast('chat:run:output', { tabId, delta: block.text });
283
451
  } else if (block.type === 'tool_use' && typeof block.name === 'string') {
284
452
  const classified = classifyToolUse(block);
285
- broadcast('chat:run:tool-use', { tabId, id: block.id, ...classified });
453
+ if (!silent) broadcast('chat:run:tool-use', { tabId, id: block.id, ...classified });
286
454
  }
287
455
  }
456
+ } else if (event.type === 'user') {
457
+ // Tool results come back as content blocks on a synthetic "user" event.
458
+ // An MCP consent-denial surfaces here as an is_error tool_result whose
459
+ // text carries one of MCP_CONSENT_DENIAL_MARKERS.
460
+ const content = Array.isArray(event.message?.content) ? event.message.content : [];
461
+ for (const block of content) {
462
+ if (block.type !== 'tool_result') continue;
463
+ const inner = block.content;
464
+ const text = typeof inner === 'string'
465
+ ? inner
466
+ : Array.isArray(inner)
467
+ ? inner.filter((c) => c?.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n')
468
+ : '';
469
+ if (hasMcpConsentDenial(text)) emitNoticeOnce();
470
+ }
288
471
  } else if (event.type === 'result') {
289
472
  // Use the authoritative `result` field when available; fall back to
290
473
  // accumulated assistant text (same content, different source).
291
474
  const text = typeof event.result === 'string' ? event.result : finalAssistantText;
292
475
 
293
476
  if (event.subtype === 'success') {
294
- const signal = parseStopSignal(text);
295
- if (signal) {
296
- emitTerminal('chat:run:needs-input', {
297
- tabId,
298
- sessionId,
299
- questions: signal.questions,
300
- raw: text,
301
- });
302
- } else {
477
+ if (silent) {
478
+ // Silent probe: no turn broadcast, no recordExchange — just hand
479
+ // the final text to whoever enqueued the probe (e.g. probeContextUsage).
303
480
  emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
304
- // Record durable exchange off the hot path — UI must not wait on Haiku
305
- recordExchange({ sessionId, cwd, prompt, result: text }).catch((err) => {
306
- console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
307
- });
481
+ if (typeof onSilentResult === 'function') onSilentResult(text);
482
+ } else {
483
+ const signal = parseStopSignal(text);
484
+ if (signal) {
485
+ emitTerminal('chat:run:needs-input', {
486
+ tabId,
487
+ sessionId,
488
+ questions: signal.questions,
489
+ raw: text,
490
+ });
491
+ } else {
492
+ emitTerminal('chat:run:complete', { tabId, sessionId, finalMessage: text });
493
+ // Record durable exchange off the hot path — UI must not wait on Haiku
494
+ recordExchange({ sessionId, cwd, prompt, result: text }).catch((err) => {
495
+ console.error('[chatRunner] recordExchange failed:', err?.message ?? err);
496
+ });
497
+ }
308
498
  }
309
499
  } else {
310
500
  emitTerminal('chat:run:error', {
@@ -410,6 +600,11 @@ function registerChatHandlers() {
410
600
  try { tabId = schemas.chatCancel.parse(payload).tabId; } catch { return; }
411
601
  cancel(tabId);
412
602
  });
603
+
604
+ ipcMain.handle('chat:probe-context', validated(schemas.chatProbeContext, async ({ tabId, sessionId, cwd }) => {
605
+ probeContextUsage({ tabId, sessionId, cwd });
606
+ return { ok: true };
607
+ }));
413
608
  }
414
609
 
415
610
  module.exports = {
@@ -418,6 +613,8 @@ module.exports = {
418
613
  attachWindow,
419
614
  registerChatHandlers,
420
615
  parseStopSignal,
616
+ parseContextUsageMarkdown,
617
+ probeContextUsage,
421
618
  STOP_SENTINEL,
422
619
  __setExecutor,
423
620
  };
@@ -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
@@ -450,6 +455,20 @@ ipcMain.handle('clipboard:paste-image', async () => {
450
455
  }
451
456
  });
452
457
 
458
+ // Text paste — same rationale as clipboard:paste-image above: the renderer's
459
+ // navigator.clipboard.readText() requires the 'clipboard-read' permission,
460
+ // which setPermissionRequestHandler denies (MEDIA_PERMS only), so it always
461
+ // rejects. Reading via Electron's main-process clipboard.readText() sidesteps
462
+ // that permission gate entirely — no permission-handler changes needed.
463
+ ipcMain.handle('clipboard:paste-text', async () => {
464
+ try {
465
+ const text = clipboard.readText();
466
+ return { ok: true, text: text || '' };
467
+ } catch (e) {
468
+ return { ok: false, error: e && e.message ? e.message : String(e) };
469
+ }
470
+ });
471
+
453
472
  // PRD 407 Capture panel — write side of paste-image's read. Writes a
454
473
  // screenshot capture to the OS clipboard as an image.
455
474
  ipcMain.handle('browser:copy-image', validated(schemas.browserCopyImage, ({ dataUrl }) => {
@@ -382,6 +382,12 @@ const chatCancel = z.object({
382
382
  tabId: z.string().min(1).max(128),
383
383
  });
384
384
 
385
+ const chatProbeContext = z.object({
386
+ tabId: z.string().min(1).max(128),
387
+ sessionId: z.string().min(1).max(128),
388
+ cwd: z.string().min(1).max(4096),
389
+ });
390
+
385
391
  // ──────────────────────────────────────────── Web Remote
386
392
  // OTP is 8 uppercase alphanumeric chars (case-insensitive entry, normalised to upper in handler).
387
393
  const WEB_REMOTE_OTP_RE = /^[A-Z0-9]{8}$/i;
@@ -665,6 +671,7 @@ module.exports = {
665
671
  watchersKillTab,
666
672
  chatRun,
667
673
  chatCancel,
674
+ chatProbeContext,
668
675
  exchangesList,
669
676
  },
670
677
  validated,
@@ -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 };