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.
Files changed (39) hide show
  1. package/dist/assets/{TiptapBody-CIEmzy3k.js → TiptapBody-CVFG9ufh.js} +1 -1
  2. package/dist/assets/index-DASEOqMP.js +3558 -0
  3. package/dist/assets/index-DZgKJkf3.css +32 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +3 -2
  6. package/plugins/session-manager-dev/.claude-plugin/plugin.json +3 -1
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +12 -0
  8. package/plugins/session-manager-dev/skills/develop/standards.md +1 -0
  9. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +48 -35
  10. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +83 -0
  11. package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
  12. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +20 -12
  13. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +4 -3
  14. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +33 -12
  15. package/src/main/__tests__/adminServer.test.cjs +175 -0
  16. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +137 -0
  17. package/src/main/__tests__/mcpStatus.test.cjs +61 -0
  18. package/src/main/__tests__/runVerify.test.cjs +74 -4
  19. package/src/main/__tests__/scheduler-autofix-select.test.cjs +50 -2
  20. package/src/main/__tests__/scheduler-autopromote.test.cjs +19 -1
  21. package/src/main/adminServer.cjs +157 -0
  22. package/src/main/browserCapture.cjs +714 -0
  23. package/src/main/browserView.cjs +613 -0
  24. package/src/main/chatRunner.cjs +60 -0
  25. package/src/main/config.cjs +37 -1
  26. package/src/main/historyAggregator.cjs +92 -6
  27. package/src/main/index.cjs +37 -3
  28. package/src/main/ipcSchemas.cjs +106 -0
  29. package/src/main/lib/schedulerConfig.cjs +6 -2
  30. package/src/main/mcpStatus.cjs +93 -0
  31. package/src/main/runVerify.cjs +14 -1
  32. package/src/main/scheduler.cjs +159 -31
  33. package/src/main/sessionsStore.cjs +4 -3
  34. package/src/preload/api.d.ts +154 -5
  35. package/src/preload/browserViewPreload.cjs +67 -0
  36. package/src/preload/index.cjs +63 -5
  37. package/dist/assets/index-2Om-ouz6.js +0 -3534
  38. package/dist/assets/index-DeIJ8SM5.css +0 -32
  39. package/src/main/docEditor.cjs +0 -92
@@ -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).
@@ -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
- const rows = Array.from(buckets.values()).map((b) => ({
341
- ...b,
342
- estimatedCostUsd: (b.inputTokens * 3 + b.outputTokens * 15) / 1_000_000,
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 = { registerHistoryAggregatorHandlers, remote };
479
+ module.exports = {
480
+ registerHistoryAggregatorHandlers,
481
+ remote,
482
+ MODEL_PRICING,
483
+ // exported for tests
484
+ scanAggrLines,
485
+ parseJSONL,
486
+ resolvePricingKey,
487
+ };
@@ -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(() => {});
@@ -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 10 min only bounds how late we react to utilization drift.
7
- POLL_INTERVAL_MS: 10 * 60_000,
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 };