claude-code-session-manager 0.35.1 → 0.35.3

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 (31) hide show
  1. package/dist/assets/{TiptapBody-4v5sETwF.js → TiptapBody-DmCekkUQ.js} +1 -1
  2. package/dist/assets/index-CHKMzzCM.js +3558 -0
  3. package/dist/assets/index-DVqmrWP3.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/find-opportunity/SKILL.md +83 -0
  9. package/plugins/session-manager-dev/skills/memory-sanitation/SKILL.md +127 -0
  10. package/plugins/session-manager-dev/skills/my-feedback/SKILL.md +10 -6
  11. package/plugins/session-manager-dev/skills/optimize-kpi/SKILL.md +3 -2
  12. package/plugins/session-manager-dev/skills/process-feedback/SKILL.md +12 -7
  13. package/src/main/__tests__/adminServer.test.cjs +175 -0
  14. package/src/main/__tests__/runVerify.test.cjs +74 -4
  15. package/src/main/adminServer.cjs +157 -0
  16. package/src/main/browserCapture.cjs +714 -0
  17. package/src/main/browserView.cjs +613 -0
  18. package/src/main/config.cjs +37 -1
  19. package/src/main/historyAggregator.cjs +92 -6
  20. package/src/main/index.cjs +32 -3
  21. package/src/main/ipcSchemas.cjs +106 -0
  22. package/src/main/lib/schedulerConfig.cjs +6 -2
  23. package/src/main/runVerify.cjs +14 -1
  24. package/src/main/scheduler.cjs +6 -1
  25. package/src/main/sessionsStore.cjs +4 -3
  26. package/src/preload/api.d.ts +129 -5
  27. package/src/preload/browserViewPreload.cjs +67 -0
  28. package/src/preload/index.cjs +55 -5
  29. package/dist/assets/index-1DFsiqCu.js +0 -3534
  30. package/dist/assets/index-CPTin6qz.css +0 -32
  31. package/src/main/docEditor.cjs +0 -92
@@ -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,6 +7,8 @@ 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');
@@ -21,6 +23,8 @@ crashDiagnostics.startCrashReporter();
21
23
  const voiceHotkey = require('./voiceHotkey.cjs');
22
24
  const voiceWizard = require('./voiceWizard.cjs');
23
25
  const scheduler = require('./scheduler.cjs');
26
+ const { createAdminServer } = require('./adminServer.cjs');
27
+ const adminServer = createAdminServer(scheduler.remote);
24
28
  const supervisor = require('./supervisor.cjs');
25
29
  const watchers = require('./watchers.cjs');
26
30
  const teams = require('./teams.cjs');
@@ -33,7 +37,6 @@ const { registerHistoryAggregatorHandlers } = require('./historyAggregator.cjs')
33
37
  const memoryTool = require('./memoryTool.cjs');
34
38
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
35
39
  const agentMemory = require('./agentMemory.cjs');
36
- const { registerDocEditorHandlers } = require('./docEditor.cjs');
37
40
  const git = require('./git.cjs');
38
41
  const superagent = require('./superagent.cjs');
39
42
  const filesIpc = require('./files.cjs');
@@ -265,6 +268,8 @@ async function rebootApp() {
265
268
  configMgr.attachWindow(mainWindow);
266
269
  transcripts.attachWindow(mainWindow);
267
270
  usageMatrix.attachWindow(mainWindow);
271
+ browserView.attachWindow(mainWindow);
272
+ browserCapture.attachWindow(mainWindow);
268
273
  voiceHotkey.init(mainWindow).catch((e) => {
269
274
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
270
275
  });
@@ -445,6 +450,19 @@ ipcMain.handle('clipboard:paste-image', async () => {
445
450
  }
446
451
  });
447
452
 
453
+ // PRD 407 Capture panel — write side of paste-image's read. Writes a
454
+ // screenshot capture to the OS clipboard as an image.
455
+ ipcMain.handle('browser:copy-image', validated(schemas.browserCopyImage, ({ dataUrl }) => {
456
+ try {
457
+ const img = nativeImage.createFromDataURL(dataUrl);
458
+ if (!img || img.isEmpty()) return { ok: false, error: 'empty image' };
459
+ clipboard.writeImage(img);
460
+ return { ok: true };
461
+ } catch (e) {
462
+ return { ok: false, error: e && e.message ? e.message : String(e) };
463
+ }
464
+ }));
465
+
448
466
  // Hooks tab "Test fire": run a hook command with a fake event payload piped
449
467
  // to stdin. shell:true is intentional — Claude Code's hook field is a shell
450
468
  // string. Timeout is enforced via SIGKILL on a timer because spawn's built-in
@@ -673,7 +691,6 @@ pluginInstall.registerPluginInstallHandlers();
673
691
  memoryTool.registerMemoryHandlers();
674
692
  registerMemoryAggregateIpc();
675
693
  agentMemory.registerAgentMemoryHandlers();
676
- registerDocEditorHandlers();
677
694
  git.register(ipcMain);
678
695
  superagent.registerSuperAgentHandlers();
679
696
  filesIpc.registerFilesHandlers();
@@ -774,6 +791,11 @@ app.on('web-contents-created', (_e, wc) => {
774
791
  });
775
792
 
776
793
  wc.on('will-navigate', (event, url) => {
794
+ // The embedded Browser tab's WebContentsView is a real browser — it must
795
+ // be able to navigate anywhere. Exempt ONLY webContents registered by
796
+ // browserView.cjs; the main window's lock below is unchanged.
797
+ if (browserView.isBrowserViewContents(wc.id)) return;
798
+
777
799
  const allowed = useDevServer
778
800
  ? ['http://localhost:5173', 'http://127.0.0.1:5173']
779
801
  : [];
@@ -971,6 +993,9 @@ app.whenReady().then(async () => {
971
993
  configMgr.attachWindow(mainWindow);
972
994
  transcripts.attachWindow(mainWindow);
973
995
  usageMatrix.attachWindow(mainWindow);
996
+ browserView.registerBrowserView({ mainWindow, ipcMain });
997
+ browserCapture.attachWindow(mainWindow);
998
+ browserCapture.registerBrowserCapture({ ipcMain, getView: browserView.getView });
974
999
  voiceHotkey.init(mainWindow).catch((e) => {
975
1000
  logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
976
1001
  });
@@ -983,6 +1008,9 @@ app.whenReady().then(async () => {
983
1008
  scheduler.init().catch((e) => {
984
1009
  logs.writeLine({ scope: 'scheduler', level: 'error', message: 'init failed', meta: { error: e?.message } });
985
1010
  });
1011
+ adminServer.start().catch((e) => {
1012
+ logs.writeLine({ scope: 'admin-server', level: 'error', message: 'init failed', meta: { error: e?.message } });
1013
+ });
986
1014
  // First-boot default: install the bundled session-manager-dev plugin (its 10
987
1015
  // dev skills) from the app's own marketplace. One-shot + idempotent; never
988
1016
  // throws. SM_SEED_DEV_PLUGIN_DISABLE=1 to opt out.
@@ -1072,6 +1100,7 @@ app.on('before-quit', () => {
1072
1100
  configMgr.closeAllWatchers();
1073
1101
  transcripts.closeAll();
1074
1102
  watchers.manager.killAll();
1103
+ adminServer.stop().catch(() => {});
1075
1104
  // Best-effort flush of any pending OTEL spans. shutdown() has its own 2s
1076
1105
  // ceiling so a wedged exporter can't hold quit.
1077
1106
  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).
@@ -616,6 +616,18 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
616
616
  ? { ...(annotations.length ? { annotations } : {}), ...sentinelFields }
617
617
  : undefined;
618
618
 
619
+ // No pattern hits is not automatically "clean": a run that neither
620
+ // committed anything nor ever emitted the finish-protocol sentinel likely
621
+ // ended before doing real work (e.g. stopped on a clarifying question).
622
+ // Weaker evidence than a caught transcript error, but still not clean.
623
+ if (sentinel === null && !committedDuringRun) {
624
+ issues.push({
625
+ verdict: 'no_verdict_sentinel',
626
+ reason: 'run made no commit and emitted no SCHEDULER_VERDICT sentinel — likely ended before the finish protocol (e.g. stopped on a clarifying question)',
627
+ priority: 1,
628
+ });
629
+ }
630
+
619
631
  if (issues.length === 0) {
620
632
  const reason = annotations.length
621
633
  ? `no blocking issues (${annotations.length} annotation(s): ${annotations.map((a) => a.reason).join('; ')})`
@@ -623,7 +635,8 @@ async function verifyRun({ runDir, prdPath, queueEntry, allJobs = [], committedD
623
635
  return conclude('clean', reason, null, extras);
624
636
  }
625
637
 
626
- // Pick highest-priority issue (transcript_errors > verify_unavailable).
638
+ // Pick highest-priority issue (transcript_errors > verify_unavailable ==
639
+ // no_verdict_sentinel; ties keep the loop's original order via stable sort).
627
640
  issues.sort((a, b) => b.priority - a.priority);
628
641
  const top = issues[0];
629
642
 
@@ -2479,7 +2479,12 @@ const remote = {
2479
2479
  });
2480
2480
  if (!found) return { ok: false, error: 'not found' };
2481
2481
  await broadcast();
2482
- return { ok: true };
2482
+ return { ok: true, slug, status: 'pending' };
2483
+ },
2484
+
2485
+ async listJobs() {
2486
+ const state = await readQueue();
2487
+ return state.jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
2483
2488
  },
2484
2489
 
2485
2490
  async runNow() {
@@ -5,9 +5,9 @@
5
5
  * Storage: ~/.config/session-manager/tabs.json
6
6
  * Shape: { tabs: PersistedTab[], activeTabId: string | null, savedAt: number }
7
7
  *
8
- * Only serializable, durable fields are persisted: id, claudeSessionId, cwd,
9
- * label, presetId. Runtime-only fields (pid, status, startupCommand,
10
- * exitCode) are recomputed on boot.
8
+ * Only serializable, durable fields are persisted: id, claudeSessionId,
9
+ * chatSessionId, cwd, label, presetId. Runtime-only fields (pid, status,
10
+ * startupCommand, exitCode) are recomputed on boot.
11
11
  */
12
12
 
13
13
  const fs = require('node:fs');
@@ -59,6 +59,7 @@ async function markFreshRestart() {
59
59
  const freshTabs = tabs.map((t) => ({
60
60
  ...t,
61
61
  claudeSessionId: crypto.randomUUID(),
62
+ chatSessionId: crypto.randomUUID(),
62
63
  }));
63
64
  await save({ tabs: freshTabs, activeTabId, freshStart: true });
64
65
  }
@@ -15,6 +15,72 @@ export interface WriteErrorEvent {
15
15
  reason: string;
16
16
  }
17
17
 
18
+ export interface BrowserNavState {
19
+ url: string;
20
+ title: string;
21
+ canGoBack: boolean;
22
+ canGoForward: boolean;
23
+ loading: boolean;
24
+ isSecure: boolean;
25
+ }
26
+
27
+ /** One captured recorder step (PRD 408 engine → PRD 409 panel). */
28
+ export interface RecordStep {
29
+ n: number;
30
+ /** `select` is accepted by replay/export (PRD 410) for forward-compat;
31
+ * the live engine does not emit it yet. */
32
+ verb: 'navigate' | 'click' | 'type' | 'select' | 'wait-for';
33
+ target: string;
34
+ kind?: 'nav' | 'assert';
35
+ /** True for `type` steps — the actual typed value is never captured. */
36
+ masked?: boolean;
37
+ /** Engine-suggested `{{var}}` name for a `type` step (e.g. field name). */
38
+ variableSuggestion?: string;
39
+ /** Renderer-owned: set once the user checks "parameterize as {{var}}". */
40
+ variable?: string | null;
41
+ /** `select` steps only — the option value to choose on replay/export. */
42
+ value?: string;
43
+ }
44
+
45
+ /** Per-step replay outcome (PRD 410), streamed as `browser:replay-step:<viewId>`. */
46
+ export interface ReplayStepResult {
47
+ n: number;
48
+ status: 'pass' | 'fail';
49
+ detail?: string;
50
+ }
51
+
52
+ /** find-in-page result (PRD 402), streamed as `browser:find-result:<viewId>`. */
53
+ export interface FindResult {
54
+ requestId: number;
55
+ matches: number;
56
+ activeMatchOrdinal: number;
57
+ }
58
+
59
+ /** Element-picker event (PRD 403), streamed as `browser:picker-event:<viewId>`. */
60
+ export interface PickerEvent {
61
+ type: 'hover' | 'pick' | 'unpick' | 'exit';
62
+ selector?: string;
63
+ label?: string;
64
+ tag?: string;
65
+ rect?: { x: number; y: number; width: number; height: number };
66
+ /** Present on a 'pick' event fired by an unmodified click — the selection was replaced, not accumulated. */
67
+ replace?: boolean;
68
+ }
69
+
70
+ /** One entry in `~/.claude/session-manager/browser/history.json` (PRD 402). */
71
+ export interface BrowserHistoryEntry {
72
+ url: string;
73
+ title: string;
74
+ ts: number;
75
+ }
76
+
77
+ /** One entry in `~/.claude/session-manager/browser/bookmarks.json` (PRD 402). */
78
+ export interface BrowserBookmark {
79
+ url: string;
80
+ title: string;
81
+ ts: number;
82
+ }
83
+
18
84
  export interface ReadJsonResult {
19
85
  exists: boolean;
20
86
  raw: string;
@@ -82,6 +148,8 @@ export interface SubscribeResult {
82
148
  export interface PersistedTab {
83
149
  id: string;
84
150
  claudeSessionId: string;
151
+ /** Chat mode's own session id, decoupled from claudeSessionId. Optional for backwards-compat with tabs.json written before this field existed. */
152
+ chatSessionId?: string;
85
153
  cwd: string;
86
154
  label: string;
87
155
  presetId: string | null;
@@ -435,12 +503,23 @@ export interface DayProjectRow {
435
503
  sessionCount: number;
436
504
  errorCount: number;
437
505
  estimatedCostUsd: number;
506
+ byModel: Record<string, {
507
+ inputTokens: number;
508
+ outputTokens: number;
509
+ cacheReadTokens: number;
510
+ cacheCreationTokens: number;
511
+ costUsd: number;
512
+ /** Set when the model id didn't match opus/sonnet/haiku and was priced at Sonnet rates as a fallback. */
513
+ estimated?: boolean;
514
+ }>;
438
515
  }
439
516
 
440
517
  export interface HistoryAggregateResult {
441
518
  rows: DayProjectRow[];
442
519
  partial: boolean;
443
520
  scannedMs: number;
521
+ /** Total $ saved across all rows from cache-read pricing vs. full input pricing. Global, not per-row. */
522
+ cacheSavingsUsd: number;
444
523
  }
445
524
 
446
525
  export interface SessionScanEntry {
@@ -932,6 +1011,54 @@ export interface SessionManagerAPI {
932
1011
  onExit: (tabId: string, handler: (info: PtyExit) => void) => () => void;
933
1012
  onWriteError: (handler: (ev: WriteErrorEvent) => void) => () => void;
934
1013
  };
1014
+ browser: {
1015
+ create: (payload: { viewId: string; partition: string }) => Promise<{ ok: boolean }>;
1016
+ setBounds: (payload: { viewId: string; x: number; y: number; width: number; height: number }) => Promise<{ ok: boolean }>;
1017
+ show: (viewId: string) => Promise<{ ok: boolean }>;
1018
+ hide: (viewId: string) => Promise<{ ok: boolean }>;
1019
+ destroy: (viewId: string) => Promise<{ ok: boolean }>;
1020
+ navigate: (payload: { viewId: string; url: string }) => Promise<{ ok: boolean; error?: string }>;
1021
+ back: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1022
+ forward: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1023
+ reload: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1024
+ stop: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1025
+ onNavState: (viewId: string, handler: (state: BrowserNavState) => void) => () => void;
1026
+ recordStart: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1027
+ recordStop: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1028
+ onRecordStep: (viewId: string, handler: (step: RecordStep) => void) => () => void;
1029
+ captureDom: (payload: { viewId: string; kind: 'text' | 'html' }) => Promise<
1030
+ | { ok: true; url: string; title: string; text: string; truncated?: boolean }
1031
+ | { ok: false; error: string }
1032
+ >;
1033
+ captureShot: (viewId: string) => Promise<
1034
+ | { ok: true; url: string; title: string; dataUrl: string }
1035
+ | { ok: false; error: string }
1036
+ >;
1037
+ saveBinary: (path: string, base64: string) => Promise<{ ok: boolean; error?: string }>;
1038
+ replay: (payload: {
1039
+ viewId: string;
1040
+ steps: RecordStep[];
1041
+ values?: Record<string, string>;
1042
+ continueOnError?: boolean;
1043
+ }) => Promise<{ ok: boolean; error?: string; stopped?: boolean; failedAt?: number }>;
1044
+ onReplayStep: (viewId: string, handler: (step: ReplayStepResult) => void) => () => void;
1045
+ setZoom: (payload: { viewId: string; factor: number }) => Promise<{ ok: boolean; error?: string; factor?: number }>;
1046
+ find: (payload: { viewId: string; text: string; forward?: boolean }) => Promise<{ ok: boolean; error?: string }>;
1047
+ stopFind: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1048
+ onFindResult: (viewId: string, handler: (result: FindResult) => void) => () => void;
1049
+ pickerStart: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1050
+ pickerStop: (viewId: string) => Promise<{ ok: boolean; error?: string; wasPicking?: boolean }>;
1051
+ onPickerEvent: (viewId: string, handler: (ev: PickerEvent) => void) => () => void;
1052
+ /** Selection-scoped capture pipeline (PRD 404): filter -> prune -> summarize -> chunk for 'agent', outerHTML for 'html', CDP AX-tree for 'a11y', fallback-chain for 'selector'. */
1053
+ capture: (payload: {
1054
+ viewId: string;
1055
+ selectors: string[];
1056
+ mode: 'agent' | 'html' | 'a11y' | 'selector';
1057
+ }) => Promise<
1058
+ | { ok: true; mode: string; text: string; meta: { chunks?: number; tokens?: number } }
1059
+ | { ok: false; error: string }
1060
+ >;
1061
+ };
935
1062
  transcripts: {
936
1063
  subscribe: (payload: { tabId: string; cwd: string; sessionUuid: string }) => Promise<SubscribeResult>;
937
1064
  /** Release the sub back to the LRU cache (view-switch). Does not destroy the watcher. */
@@ -1099,6 +1226,8 @@ export interface SessionManagerAPI {
1099
1226
  | { ok: true; path: string; bytes: number }
1100
1227
  | { ok: false; empty?: true; error?: string }
1101
1228
  >;
1229
+ /** Write side — copies a Capture-panel screenshot data URL to the OS clipboard. */
1230
+ copyImage: (dataUrl: string) => Promise<{ ok: boolean; error?: string }>;
1102
1231
  };
1103
1232
  memory: {
1104
1233
  /** List markdown memory entries for the given workspace (defaults to 'default'). */
@@ -1129,11 +1258,6 @@ export interface SessionManagerAPI {
1129
1258
  /** Delete one entry. Removes the file outright when last entry is removed. */
1130
1259
  delete: (agentId: string, entryId: string) => Promise<AgentMemoryMutationResult>;
1131
1260
  };
1132
- docEditor: {
1133
- pickFile: (payload?: { lastDir?: string }) => Promise<{ path: string | null; error?: string }>;
1134
- readFile: (path: string) => Promise<{ ok: boolean; text?: string; mtimeMs?: number; error?: string }>;
1135
- writeFile: (path: string, text: string) => Promise<{ ok: boolean; mtimeMs?: number; error?: string }>;
1136
- };
1137
1261
  git: {
1138
1262
  /** Full git status for `cwd`. Returns null when not a git repo, git is
1139
1263
  * missing, or the call times out (5s ceiling). Cached per-cwd for 5s. */