claude-code-session-manager 0.35.2 → 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.
- package/dist/assets/{TiptapBody-CIEmzy3k.js → TiptapBody-CVFG9ufh.js} +1 -1
- package/dist/assets/index-DASEOqMP.js +3558 -0
- package/dist/assets/index-DZgKJkf3.css +32 -0
- package/dist/index.html +2 -2
- package/package.json +3 -2
- package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
- 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/find-opportunity/SKILL.md +83 -0
- package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
- package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +20 -12
- package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +4 -3
- package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +33 -12
- package/src/main/__tests__/adminServer.test.cjs +175 -0
- 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__/runVerify.test.cjs +74 -4
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +50 -2
- package/src/main/__tests__/scheduler-autopromote.test.cjs +19 -1
- package/src/main/adminServer.cjs +157 -0
- package/src/main/browserCapture.cjs +714 -0
- package/src/main/browserView.cjs +613 -0
- package/src/main/chatRunner.cjs +60 -0
- package/src/main/config.cjs +37 -1
- package/src/main/historyAggregator.cjs +92 -6
- package/src/main/index.cjs +37 -3
- package/src/main/ipcSchemas.cjs +106 -0
- package/src/main/lib/schedulerConfig.cjs +6 -2
- package/src/main/mcpStatus.cjs +93 -0
- package/src/main/runVerify.cjs +14 -1
- package/src/main/scheduler.cjs +159 -31
- package/src/main/sessionsStore.cjs +4 -3
- package/src/preload/api.d.ts +154 -5
- package/src/preload/browserViewPreload.cjs +67 -0
- package/src/preload/index.cjs +63 -5
- package/dist/assets/index-2Om-ouz6.js +0 -3534
- package/dist/assets/index-DeIJ8SM5.css +0 -32
- package/src/main/docEditor.cjs +0 -92
package/src/main/chatRunner.cjs
CHANGED
|
@@ -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).
|
package/src/main/config.cjs
CHANGED
|
@@ -103,7 +103,10 @@ function validateWrite(realAbs) {
|
|
|
103
103
|
(p) => realAbs === p || realAbs.startsWith(p + path.sep)
|
|
104
104
|
);
|
|
105
105
|
if (inWritePrefix) return;
|
|
106
|
-
// Also allowed inside a registered project root's .claude/ subtree
|
|
106
|
+
// Also allowed inside a registered project root's .claude/ subtree, OR its
|
|
107
|
+
// tests/fixtures/browser-capture/ subtree (PRD 407 "PRD fixture" capture
|
|
108
|
+
// destination — narrowly scoped to that one path segment, not a general
|
|
109
|
+
// project-root write grant).
|
|
107
110
|
for (const root of allowedRoots) {
|
|
108
111
|
if (root === os.homedir()) continue;
|
|
109
112
|
let realRoot;
|
|
@@ -113,6 +116,17 @@ function validateWrite(realAbs) {
|
|
|
113
116
|
if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
|
|
114
117
|
return;
|
|
115
118
|
}
|
|
119
|
+
const fixturesSub = path.join(realRoot, 'tests', 'fixtures', 'browser-capture');
|
|
120
|
+
if (realAbs === fixturesSub || realAbs.startsWith(fixturesSub + path.sep)) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// PRD 410 Recorder → Playwright-spec export: narrowly scoped to
|
|
124
|
+
// tests/e2e/ (matches the repo's existing e2e spec location), not a
|
|
125
|
+
// general project-root write grant.
|
|
126
|
+
const e2eSub = path.join(realRoot, 'tests', 'e2e');
|
|
127
|
+
if (realAbs === e2eSub || realAbs.startsWith(e2eSub + path.sep)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
116
130
|
}
|
|
117
131
|
}
|
|
118
132
|
throw new Error(`Write outside allowed write boundaries: ${realAbs}`);
|
|
@@ -182,6 +196,27 @@ async function writeTextAtomic(abs, text, opts = {}) {
|
|
|
182
196
|
return { ok: true, mtimeMs: stat.mtimeMs };
|
|
183
197
|
}
|
|
184
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Binary-safe sibling of writeTextAtomic — same validate + tmp+rename flow,
|
|
201
|
+
* for callers (screenshot capture) whose payload is a Buffer, not utf8 text.
|
|
202
|
+
*/
|
|
203
|
+
async function writeBinaryAtomic(abs, buffer) {
|
|
204
|
+
const real = validatePath(expandHome(abs));
|
|
205
|
+
validateWrite(real);
|
|
206
|
+
const dir = path.dirname(real);
|
|
207
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
208
|
+
const tmp = `${real}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
209
|
+
try {
|
|
210
|
+
await fsp.writeFile(tmp, buffer);
|
|
211
|
+
await fsp.rename(tmp, real);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
try { await fsp.unlink(tmp); } catch { /* tmp never created or already gone */ }
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
const stat = await fsp.stat(real);
|
|
217
|
+
return { ok: true, mtimeMs: stat.mtimeMs };
|
|
218
|
+
}
|
|
219
|
+
|
|
185
220
|
async function writeJson(abs, data) {
|
|
186
221
|
const pretty = JSON.stringify(data, null, 2) + '\n';
|
|
187
222
|
return writeTextAtomic(abs, pretty);
|
|
@@ -374,6 +409,7 @@ module.exports = {
|
|
|
374
409
|
writeJson,
|
|
375
410
|
writeJsonSync,
|
|
376
411
|
writeTextAtomic,
|
|
412
|
+
writeBinaryAtomic,
|
|
377
413
|
listDir,
|
|
378
414
|
exists,
|
|
379
415
|
addAllowedRoot,
|
|
@@ -11,6 +11,32 @@ const PARSE_BUDGET_MS = 2_000;
|
|
|
11
11
|
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
|
12
12
|
const CACHE_MAX = 500;
|
|
13
13
|
|
|
14
|
+
// Dollars per million tokens (input / output / cache-read). Cache-read
|
|
15
|
+
// tokens are priced far below input because they're served from
|
|
16
|
+
// Anthropic's prompt cache, not re-processed — this is what makes the
|
|
17
|
+
// cache-savings figure in the dashboard real money, not a vanity stat.
|
|
18
|
+
const MODEL_PRICING = {
|
|
19
|
+
opus: { i: 15, o: 75, c: 1.5 },
|
|
20
|
+
sonnet: { i: 3, o: 15, c: 0.3 },
|
|
21
|
+
haiku: { i: 0.8, o: 4, c: 0.08 },
|
|
22
|
+
};
|
|
23
|
+
const DEFAULT_PRICING_KEY = 'sonnet'; // fallback for unrecognized model ids
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a raw model id (e.g. "claude-opus-4-8-20260115") to a pricing
|
|
27
|
+
* bucket key. Model ids aren't stable enough to match exactly, so this is a
|
|
28
|
+
* case-insensitive substring check. Unrecognized ids fall back to
|
|
29
|
+
* DEFAULT_PRICING_KEY and are flagged so callers can annotate them as
|
|
30
|
+
* estimated rather than exact.
|
|
31
|
+
*/
|
|
32
|
+
function resolvePricingKey(modelId) {
|
|
33
|
+
const id = String(modelId ?? '').toLowerCase();
|
|
34
|
+
if (id.includes('opus')) return { key: 'opus', estimated: false };
|
|
35
|
+
if (id.includes('sonnet')) return { key: 'sonnet', estimated: false };
|
|
36
|
+
if (id.includes('haiku')) return { key: 'haiku', estimated: false };
|
|
37
|
+
return { key: DEFAULT_PRICING_KEY, estimated: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
14
40
|
// ── LRU cache ─────────────────────────────────────────────────────────────────
|
|
15
41
|
// Backed by an insertion-order Map: delete+re-insert on access = O(1) LRU.
|
|
16
42
|
class LRUCache {
|
|
@@ -114,6 +140,19 @@ function scanAggrLines(lines, acc, captureFirst) {
|
|
|
114
140
|
if (typeof outT === 'number') acc.outputTokens += outT;
|
|
115
141
|
if (typeof cacheR === 'number') acc.cacheReadTokens += cacheR;
|
|
116
142
|
if (typeof cacheC === 'number') acc.cacheCreationTokens += cacheC;
|
|
143
|
+
|
|
144
|
+
// Only assistant usage lines carry model+usage together in practice;
|
|
145
|
+
// read defensively rather than assuming every usage line has a model.
|
|
146
|
+
const modelId = obj.message?.model;
|
|
147
|
+
if (typeof modelId === 'string' && modelId) {
|
|
148
|
+
const bucket = acc.byModel[modelId] ?? (acc.byModel[modelId] = {
|
|
149
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
|
|
150
|
+
});
|
|
151
|
+
if (typeof inT === 'number') bucket.inputTokens += inT;
|
|
152
|
+
if (typeof outT === 'number') bucket.outputTokens += outT;
|
|
153
|
+
if (typeof cacheR === 'number') bucket.cacheReadTokens += cacheR;
|
|
154
|
+
if (typeof cacheC === 'number') bucket.cacheCreationTokens += cacheC;
|
|
155
|
+
}
|
|
117
156
|
}
|
|
118
157
|
|
|
119
158
|
const content = obj.message?.content ?? obj.content;
|
|
@@ -157,6 +196,7 @@ async function parseJSONL(filePath, stat) {
|
|
|
157
196
|
cacheCreationTokens: 0,
|
|
158
197
|
toolCallCount: 0,
|
|
159
198
|
toolBreakdown: {},
|
|
199
|
+
byModel: {},
|
|
160
200
|
errorCount: 0,
|
|
161
201
|
sessionDate: null,
|
|
162
202
|
skipped: false,
|
|
@@ -195,6 +235,7 @@ async function parseJSONL(filePath, stat) {
|
|
|
195
235
|
cacheCreationTokens: prev.cacheCreationTokens + delta.cacheCreationTokens,
|
|
196
236
|
toolCallCount: prev.toolCallCount + delta.toolCallCount,
|
|
197
237
|
toolBreakdown: { ...prev.toolBreakdown },
|
|
238
|
+
byModel: {},
|
|
198
239
|
errorCount: prev.errorCount + delta.errorCount,
|
|
199
240
|
sessionDate: prev.sessionDate, // firstTs doesn't change on appends
|
|
200
241
|
skipped: false,
|
|
@@ -202,6 +243,18 @@ async function parseJSONL(filePath, stat) {
|
|
|
202
243
|
for (const [k, v] of Object.entries(delta.toolBreakdown)) {
|
|
203
244
|
merged.toolBreakdown[k] = (merged.toolBreakdown[k] ?? 0) + v;
|
|
204
245
|
}
|
|
246
|
+
for (const [modelId, srcBucket] of Object.entries(prev.byModel ?? {})) {
|
|
247
|
+
merged.byModel[modelId] = { ...srcBucket };
|
|
248
|
+
}
|
|
249
|
+
for (const [modelId, deltaBucket] of Object.entries(delta.byModel)) {
|
|
250
|
+
const b = merged.byModel[modelId] ?? (merged.byModel[modelId] = {
|
|
251
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
|
|
252
|
+
});
|
|
253
|
+
b.inputTokens += deltaBucket.inputTokens;
|
|
254
|
+
b.outputTokens += deltaBucket.outputTokens;
|
|
255
|
+
b.cacheReadTokens += deltaBucket.cacheReadTokens;
|
|
256
|
+
b.cacheCreationTokens += deltaBucket.cacheCreationTokens;
|
|
257
|
+
}
|
|
205
258
|
// readOffset advances to the last complete newline so the next tail
|
|
206
259
|
// always starts at a line boundary. size stays at stat.size so the
|
|
207
260
|
// exact-hit check works correctly on the next call.
|
|
@@ -307,6 +360,7 @@ async function aggregate(req) {
|
|
|
307
360
|
cacheCreationTokens: 0,
|
|
308
361
|
toolCallCount: 0,
|
|
309
362
|
toolBreakdown: {},
|
|
363
|
+
byModel: {},
|
|
310
364
|
sessionCount: 0,
|
|
311
365
|
errorCount: 0,
|
|
312
366
|
});
|
|
@@ -322,6 +376,15 @@ async function aggregate(req) {
|
|
|
322
376
|
for (const [tool, cnt] of Object.entries(parsed.toolBreakdown)) {
|
|
323
377
|
b.toolBreakdown[tool] = (b.toolBreakdown[tool] ?? 0) + cnt;
|
|
324
378
|
}
|
|
379
|
+
for (const [modelId, srcBucket] of Object.entries(parsed.byModel ?? {})) {
|
|
380
|
+
const dst = b.byModel[modelId] ?? (b.byModel[modelId] = {
|
|
381
|
+
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0,
|
|
382
|
+
});
|
|
383
|
+
dst.inputTokens += srcBucket.inputTokens;
|
|
384
|
+
dst.outputTokens += srcBucket.outputTokens;
|
|
385
|
+
dst.cacheReadTokens += srcBucket.cacheReadTokens;
|
|
386
|
+
dst.cacheCreationTokens += srcBucket.cacheCreationTokens;
|
|
387
|
+
}
|
|
325
388
|
b.sessionCount++;
|
|
326
389
|
b.errorCount += parsed.errorCount;
|
|
327
390
|
|
|
@@ -337,15 +400,30 @@ async function aggregate(req) {
|
|
|
337
400
|
}
|
|
338
401
|
}
|
|
339
402
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
403
|
+
let cacheSavingsUsd = 0;
|
|
404
|
+
const rows = Array.from(buckets.values()).map((b) => {
|
|
405
|
+
const byModel = {};
|
|
406
|
+
let estimatedCostUsd = 0;
|
|
407
|
+
for (const [modelId, bucket] of Object.entries(b.byModel)) {
|
|
408
|
+
const { key, estimated } = resolvePricingKey(modelId);
|
|
409
|
+
const pricing = MODEL_PRICING[key];
|
|
410
|
+
const costUsd =
|
|
411
|
+
(bucket.inputTokens + bucket.cacheCreationTokens) * pricing.i / 1e6 +
|
|
412
|
+
bucket.outputTokens * pricing.o / 1e6 +
|
|
413
|
+
bucket.cacheReadTokens * pricing.c / 1e6;
|
|
414
|
+
byModel[modelId] = { ...bucket, costUsd, ...(estimated ? { estimated: true } : {}) };
|
|
415
|
+
estimatedCostUsd += costUsd;
|
|
416
|
+
// What those cache-read tokens would have cost at the full input rate,
|
|
417
|
+
// minus what they actually cost at the cache rate.
|
|
418
|
+
cacheSavingsUsd += bucket.cacheReadTokens * (pricing.i - pricing.c) / 1e6;
|
|
419
|
+
}
|
|
420
|
+
return { ...b, byModel, estimatedCostUsd };
|
|
421
|
+
});
|
|
344
422
|
|
|
345
423
|
rows.sort((a, b) => a.date.localeCompare(b.date) || a.projectCwd.localeCompare(b.projectCwd));
|
|
346
424
|
|
|
347
425
|
const scannedMs = Date.now() - t0;
|
|
348
|
-
return { rows, partial: truncated, truncated, scannedMs, skippedLargeFiles };
|
|
426
|
+
return { rows, partial: truncated, truncated, scannedMs, skippedLargeFiles, cacheSavingsUsd };
|
|
349
427
|
}
|
|
350
428
|
|
|
351
429
|
// ── IPC registration ──────────────────────────────────────────────────────────
|
|
@@ -398,4 +476,12 @@ function registerHistoryAggregatorHandlers() {
|
|
|
398
476
|
|
|
399
477
|
const remote = { aggregate };
|
|
400
478
|
|
|
401
|
-
module.exports = {
|
|
479
|
+
module.exports = {
|
|
480
|
+
registerHistoryAggregatorHandlers,
|
|
481
|
+
remote,
|
|
482
|
+
MODEL_PRICING,
|
|
483
|
+
// exported for tests
|
|
484
|
+
scanAggrLines,
|
|
485
|
+
parseJSONL,
|
|
486
|
+
resolvePricingKey,
|
|
487
|
+
};
|
package/src/main/index.cjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, powerSaveBlocker, protocol } = require('electron');
|
|
1
|
+
const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut, shell, clipboard, nativeImage, powerSaveBlocker, protocol } = require('electron');
|
|
2
2
|
const { spawn, execFile, execFileSync } = require('node:child_process');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const fs = require('node:fs');
|
|
@@ -7,11 +7,14 @@ const os = require('node:os');
|
|
|
7
7
|
const { schemas, validated } = require('./ipcSchemas.cjs');
|
|
8
8
|
const { cleanChildEnv } = require('./lib/cleanEnv.cjs');
|
|
9
9
|
const { manager: ptyManager, registerPtyHandlers } = require('./pty.cjs');
|
|
10
|
+
const browserView = require('./browserView.cjs');
|
|
11
|
+
const browserCapture = require('./browserCapture.cjs');
|
|
10
12
|
const configMgr = require('./config.cjs');
|
|
11
13
|
const transcripts = require('./transcripts.cjs');
|
|
12
14
|
const usageMatrix = require('./usageMatrix.cjs');
|
|
13
15
|
const sessionsStore = require('./sessionsStore.cjs');
|
|
14
16
|
const billing = require('./usage.cjs');
|
|
17
|
+
const { probeMcpStatus } = require('./mcpStatus.cjs');
|
|
15
18
|
const logs = require('./logs.cjs');
|
|
16
19
|
const crashDiagnostics = require('./crashDiagnostics.cjs');
|
|
17
20
|
// Start the local minidump collector before app-ready (required by Electron).
|
|
@@ -21,6 +24,8 @@ crashDiagnostics.startCrashReporter();
|
|
|
21
24
|
const voiceHotkey = require('./voiceHotkey.cjs');
|
|
22
25
|
const voiceWizard = require('./voiceWizard.cjs');
|
|
23
26
|
const scheduler = require('./scheduler.cjs');
|
|
27
|
+
const { createAdminServer } = require('./adminServer.cjs');
|
|
28
|
+
const adminServer = createAdminServer(scheduler.remote);
|
|
24
29
|
const supervisor = require('./supervisor.cjs');
|
|
25
30
|
const watchers = require('./watchers.cjs');
|
|
26
31
|
const teams = require('./teams.cjs');
|
|
@@ -33,7 +38,6 @@ const { registerHistoryAggregatorHandlers } = require('./historyAggregator.cjs')
|
|
|
33
38
|
const memoryTool = require('./memoryTool.cjs');
|
|
34
39
|
const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
|
|
35
40
|
const agentMemory = require('./agentMemory.cjs');
|
|
36
|
-
const { registerDocEditorHandlers } = require('./docEditor.cjs');
|
|
37
41
|
const git = require('./git.cjs');
|
|
38
42
|
const superagent = require('./superagent.cjs');
|
|
39
43
|
const filesIpc = require('./files.cjs');
|
|
@@ -265,6 +269,8 @@ async function rebootApp() {
|
|
|
265
269
|
configMgr.attachWindow(mainWindow);
|
|
266
270
|
transcripts.attachWindow(mainWindow);
|
|
267
271
|
usageMatrix.attachWindow(mainWindow);
|
|
272
|
+
browserView.attachWindow(mainWindow);
|
|
273
|
+
browserCapture.attachWindow(mainWindow);
|
|
268
274
|
voiceHotkey.init(mainWindow).catch((e) => {
|
|
269
275
|
logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
|
|
270
276
|
});
|
|
@@ -403,6 +409,10 @@ ipcMain.handle('app:launch-mode', () => ({
|
|
|
403
409
|
cwd: process.cwd(),
|
|
404
410
|
}));
|
|
405
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
|
+
|
|
406
416
|
ipcMain.handle('app:engage-rules-path', () => process.env.SESSION_MANAGER_ENGAGE_RULES || null);
|
|
407
417
|
|
|
408
418
|
// Boot diagnostics — renderer polls these to surface toasts when `claude` isn't
|
|
@@ -445,6 +455,19 @@ ipcMain.handle('clipboard:paste-image', async () => {
|
|
|
445
455
|
}
|
|
446
456
|
});
|
|
447
457
|
|
|
458
|
+
// PRD 407 Capture panel — write side of paste-image's read. Writes a
|
|
459
|
+
// screenshot capture to the OS clipboard as an image.
|
|
460
|
+
ipcMain.handle('browser:copy-image', validated(schemas.browserCopyImage, ({ dataUrl }) => {
|
|
461
|
+
try {
|
|
462
|
+
const img = nativeImage.createFromDataURL(dataUrl);
|
|
463
|
+
if (!img || img.isEmpty()) return { ok: false, error: 'empty image' };
|
|
464
|
+
clipboard.writeImage(img);
|
|
465
|
+
return { ok: true };
|
|
466
|
+
} catch (e) {
|
|
467
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
468
|
+
}
|
|
469
|
+
}));
|
|
470
|
+
|
|
448
471
|
// Hooks tab "Test fire": run a hook command with a fake event payload piped
|
|
449
472
|
// to stdin. shell:true is intentional — Claude Code's hook field is a shell
|
|
450
473
|
// string. Timeout is enforced via SIGKILL on a timer because spawn's built-in
|
|
@@ -673,7 +696,6 @@ pluginInstall.registerPluginInstallHandlers();
|
|
|
673
696
|
memoryTool.registerMemoryHandlers();
|
|
674
697
|
registerMemoryAggregateIpc();
|
|
675
698
|
agentMemory.registerAgentMemoryHandlers();
|
|
676
|
-
registerDocEditorHandlers();
|
|
677
699
|
git.register(ipcMain);
|
|
678
700
|
superagent.registerSuperAgentHandlers();
|
|
679
701
|
filesIpc.registerFilesHandlers();
|
|
@@ -774,6 +796,11 @@ app.on('web-contents-created', (_e, wc) => {
|
|
|
774
796
|
});
|
|
775
797
|
|
|
776
798
|
wc.on('will-navigate', (event, url) => {
|
|
799
|
+
// The embedded Browser tab's WebContentsView is a real browser — it must
|
|
800
|
+
// be able to navigate anywhere. Exempt ONLY webContents registered by
|
|
801
|
+
// browserView.cjs; the main window's lock below is unchanged.
|
|
802
|
+
if (browserView.isBrowserViewContents(wc.id)) return;
|
|
803
|
+
|
|
777
804
|
const allowed = useDevServer
|
|
778
805
|
? ['http://localhost:5173', 'http://127.0.0.1:5173']
|
|
779
806
|
: [];
|
|
@@ -971,6 +998,9 @@ app.whenReady().then(async () => {
|
|
|
971
998
|
configMgr.attachWindow(mainWindow);
|
|
972
999
|
transcripts.attachWindow(mainWindow);
|
|
973
1000
|
usageMatrix.attachWindow(mainWindow);
|
|
1001
|
+
browserView.registerBrowserView({ mainWindow, ipcMain });
|
|
1002
|
+
browserCapture.attachWindow(mainWindow);
|
|
1003
|
+
browserCapture.registerBrowserCapture({ ipcMain, getView: browserView.getView });
|
|
974
1004
|
voiceHotkey.init(mainWindow).catch((e) => {
|
|
975
1005
|
logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
976
1006
|
});
|
|
@@ -983,6 +1013,9 @@ app.whenReady().then(async () => {
|
|
|
983
1013
|
scheduler.init().catch((e) => {
|
|
984
1014
|
logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
985
1015
|
});
|
|
1016
|
+
adminServer.start().catch((e) => {
|
|
1017
|
+
logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
|
|
1018
|
+
});
|
|
986
1019
|
// First-boot default: install the bundled session-manager-dev plugin (its 10
|
|
987
1020
|
// dev skills) from the app's own marketplace. One-shot + idempotent; never
|
|
988
1021
|
// throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
|
|
@@ -1072,6 +1105,7 @@ app.on('before-quit', () => {
|
|
|
1072
1105
|
configMgr.closeAllWatchers();
|
|
1073
1106
|
transcripts.closeAll();
|
|
1074
1107
|
watchers.manager.killAll();
|
|
1108
|
+
adminServer.stop().catch(() => {});
|
|
1075
1109
|
// Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
|
|
1076
1110
|
// ceiling so a wedged exporter can't hold quit.
|
|
1077
1111
|
otel.shutdown().catch(() => {});
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -45,6 +45,100 @@ const ptyResize = z.object({
|
|
|
45
45
|
rows: z.number().int().min(3).max(1000),
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
// ──────────────────────────────────────────── Browser (WebContentsView embed)
|
|
49
|
+
// viewId is a renderer-generated identifier; restrict to a safe charset (no
|
|
50
|
+
// '/', no '.') since it keys the in-process Map<viewId, WebContentsView> —
|
|
51
|
+
// not used as a filesystem path, but kept consistent with tabId conventions.
|
|
52
|
+
const BROWSER_VIEW_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
53
|
+
const browserViewId = z.object({
|
|
54
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const browserCreate = z.object({
|
|
58
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
59
|
+
// Non-persistent partition string (PRD 400 run-mode isolation). No leading
|
|
60
|
+
// 'persist:' enforced here — callers choose persistence explicitly.
|
|
61
|
+
partition: z.string().min(1).max(256),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const BOUNDS_INT = z.number().int().min(0).max(100000);
|
|
65
|
+
const browserSetBounds = z.object({
|
|
66
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
67
|
+
x: BOUNDS_INT,
|
|
68
|
+
y: BOUNDS_INT,
|
|
69
|
+
width: BOUNDS_INT,
|
|
70
|
+
height: BOUNDS_INT,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const browserNavigate = z.object({
|
|
74
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
75
|
+
url: z.string().min(1).max(8192),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// PRD 407: DOM/text capture from the active browser sub-tab.
|
|
79
|
+
const browserCaptureDom = z.object({
|
|
80
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
81
|
+
kind: z.enum(['text', 'html']),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// PRD 404: filter -> prune -> summarize -> chunk capture of a picked
|
|
85
|
+
// selection (browser:capture). selectors comes from the PRD 403 picker.
|
|
86
|
+
const browserCaptureSelection = z.object({
|
|
87
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
88
|
+
selectors: z.array(z.string().min(1).max(2048)).min(1).max(50),
|
|
89
|
+
mode: z.enum(['agent', 'html', 'a11y', 'selector']),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// PRD 407: clipboard image write (browser:copy-image). dataUrl is a PNG data
|
|
93
|
+
// URL from webContents.capturePage() — capped well above any realistic
|
|
94
|
+
// screenshot so a malformed/huge payload can't wedge the IPC channel.
|
|
95
|
+
const browserCopyImage = z.object({
|
|
96
|
+
dataUrl: z.string().min(1).max(50_000_000),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// PRD 407: binary-safe atomic write (browser:save-binary) for screenshot
|
|
100
|
+
// captures — config:write-text is utf8-only.
|
|
101
|
+
const browserSaveBinary = z.object({
|
|
102
|
+
path: z.string().min(1).max(4096),
|
|
103
|
+
base64: z.string().min(1).max(50_000_000),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// PRD 410: replay a recorded step list against a live view. The renderer
|
|
107
|
+
// owns the step list (main never persists recorded steps), so every call is
|
|
108
|
+
// self-contained. `select` is accepted for forward-compat even though the
|
|
109
|
+
// live recorder engine doesn't emit it yet.
|
|
110
|
+
const browserReplayStep = z.object({
|
|
111
|
+
n: z.number().int().min(1),
|
|
112
|
+
verb: z.enum(['navigate', 'click', 'type', 'select', 'wait-for']),
|
|
113
|
+
target: z.string().max(2000),
|
|
114
|
+
value: z.string().max(2000).optional(),
|
|
115
|
+
variable: z.string().max(64).nullable().optional(),
|
|
116
|
+
kind: z.enum(['nav', 'assert']).optional(),
|
|
117
|
+
masked: z.boolean().optional(),
|
|
118
|
+
variableSuggestion: z.string().max(64).optional(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const browserReplay = z.object({
|
|
122
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
123
|
+
steps: z.array(browserReplayStep).max(500),
|
|
124
|
+
values: z.record(z.string().max(64), z.string().max(2000)).optional(),
|
|
125
|
+
continueOnError: z.boolean().optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// PRD 402: address-bar zoom control. factor is clamped again in
|
|
129
|
+
// browserView.cjs's setZoom — this just bounds the wire payload.
|
|
130
|
+
const browserSetZoom = z.object({
|
|
131
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
132
|
+
factor: z.number().min(0.1).max(10),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// PRD 402: Cmd/Ctrl+F find bar.
|
|
136
|
+
const browserFind = z.object({
|
|
137
|
+
viewId: z.string().min(1).max(128).regex(BROWSER_VIEW_ID_RE),
|
|
138
|
+
text: z.string().max(2000),
|
|
139
|
+
forward: z.boolean().optional(),
|
|
140
|
+
});
|
|
141
|
+
|
|
48
142
|
// ──────────────────────────────────────────── Transcripts
|
|
49
143
|
const SESSION_UUID_RE = /^[a-zA-Z0-9-]{1,64}$/;
|
|
50
144
|
|
|
@@ -90,6 +184,7 @@ const sessionsPayload = z.object({
|
|
|
90
184
|
tabs: z.array(z.object({
|
|
91
185
|
id: z.string().min(1).max(128),
|
|
92
186
|
claudeSessionId: z.string().min(1).max(128),
|
|
187
|
+
chatSessionId: z.string().min(1).max(128).optional(),
|
|
93
188
|
cwd: z.string().min(1).max(4096),
|
|
94
189
|
label: z.string().max(256),
|
|
95
190
|
presetId: z.string().max(128).nullable(),
|
|
@@ -512,6 +607,17 @@ module.exports = {
|
|
|
512
607
|
ptyWrite,
|
|
513
608
|
ptyResize,
|
|
514
609
|
sessionSubscribe,
|
|
610
|
+
browserViewId,
|
|
611
|
+
browserCreate,
|
|
612
|
+
browserSetBounds,
|
|
613
|
+
browserNavigate,
|
|
614
|
+
browserCaptureDom,
|
|
615
|
+
browserCaptureSelection,
|
|
616
|
+
browserCopyImage,
|
|
617
|
+
browserSaveBinary,
|
|
618
|
+
browserReplay,
|
|
619
|
+
browserSetZoom,
|
|
620
|
+
browserFind,
|
|
515
621
|
transcriptSubscribe,
|
|
516
622
|
transcriptTabId,
|
|
517
623
|
transcriptPath,
|
|
@@ -3,8 +3,12 @@ module.exports = {
|
|
|
3
3
|
// `when-available` policy notices the 5h window crossing the utilization
|
|
4
4
|
// threshold — i.e. when to stop (util ≥ threshold) and start (util < threshold)
|
|
5
5
|
// jobs around the 5-hour limit. Reset-time resume is scheduled exactly (not
|
|
6
|
-
// poll-bound), so
|
|
7
|
-
|
|
6
|
+
// poll-bound), so 1 min only bounds how late we react to utilization drift
|
|
7
|
+
// (e.g. freed host memory unblocking a pending job in another project).
|
|
8
|
+
// Tradeoff accepted: this raises billing.fetchUsage() calls from 6x/hour to
|
|
9
|
+
// 60x/hour against Anthropic's usage endpoint (src/main/usage.cjs) — not a
|
|
10
|
+
// job-execution cost, just a lighter-weight polling GET.
|
|
11
|
+
POLL_INTERVAL_MS: 60_000,
|
|
8
12
|
// Exponential backoff floor for polling retries after transient failures.
|
|
9
13
|
POLL_MIN_INTERVAL_MS: 90_000,
|
|
10
14
|
// Cadence for refreshing the AppStatusBar's 5h-usage chip (billing meter).
|
|
@@ -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 };
|