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.
- package/dist/assets/{TiptapBody-DmCekkUQ.js → TiptapBody-CUO_twO8.js} +1 -1
- package/dist/assets/index-BHyeZfve.css +32 -0
- package/dist/assets/index-DIVOtBGO.js +3558 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +14 -5
- package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
- package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +48 -35
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +20 -16
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +4 -4
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +32 -16
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +137 -0
- package/src/main/__tests__/mcpStatus.test.cjs +61 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +50 -2
- package/src/main/__tests__/scheduler-autopromote.test.cjs +19 -1
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +51 -0
- package/src/main/browserView.cjs +2 -2
- package/src/main/chatRunner.cjs +218 -21
- package/src/main/index.cjs +19 -0
- package/src/main/ipcSchemas.cjs +7 -0
- package/src/main/mcpStatus.cjs +93 -0
- package/src/main/scheduler.cjs +221 -73
- package/src/preload/api.d.ts +34 -0
- package/src/preload/index.cjs +14 -0
- package/dist/assets/index-CHKMzzCM.js +0 -3558
- package/dist/assets/index-DVqmrWP3.css +0 -32
|
@@ -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
|
+
});
|
package/src/main/browserView.cjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
184
|
+
sendIfAlive(win, 'browser:open-tab-request', { url });
|
|
185
185
|
}
|
|
186
186
|
return { action: 'deny' };
|
|
187
187
|
});
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
};
|
package/src/main/index.cjs
CHANGED
|
@@ -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 }) => {
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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 };
|