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
@@ -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
 
@@ -1151,6 +1151,19 @@ function isPromotableOriginal(status) {
1151
1151
  return status === 'failed' || status === 'needs_review';
1152
1152
  }
1153
1153
 
1154
+ /**
1155
+ * Pure helper: given a completed fix-plan slug (e.g. '451-fix-foo'), find the
1156
+ * SPECIFIC promotable original job it heals. Matches on the full
1157
+ * numeric-prefixed slug ('451-foo'), not just the base ('foo') — two
1158
+ * needs_review jobs can share a base slug with different NN prefixes
1159
+ * (e.g. '451-foo' and '453-foo'), and a fix plan must only heal the
1160
+ * lineage whose NN it was authored against. Exported for tests.
1161
+ */
1162
+ function healTargetForFix(fixSlug, jobs) {
1163
+ const originalSlug = fixSlug.replace(/^(\d+)-fix-/, '$1-');
1164
+ return jobs.find((x) => x.slug === originalSlug && isPromotableOriginal(x.status)) || null;
1165
+ }
1166
+
1154
1167
  /**
1155
1168
  * Spawn an Opus investigation session for a failed job. The investigator's job
1156
1169
  * is to read the failure log + original PRD, identify the root cause, and write
@@ -1329,6 +1342,13 @@ DO NOT attempt the fix. ONLY write the file. When the file exists, exit immediat
1329
1342
  console.log(`[scheduler] investigation produced fix plan: ${fixSlug}`);
1330
1343
  } else {
1331
1344
  console.log(`[scheduler] investigation finished WITHOUT producing fix plan (slug=${failedJob.slug}, code=${exitCode})`);
1345
+ // Record the no-plan outcome so selectAutoFixTargets can offer one
1346
+ // bounded retry instead of permanently dead-ending behind the
1347
+ // 1-attempt cap (autoFixAttempted stays true).
1348
+ mutate((s) => {
1349
+ const j = s.jobs.find((x) => x.slug === failedJob.slug);
1350
+ if (j) j.autoFixOutcome = 'no-plan';
1351
+ }).catch(() => {});
1332
1352
  }
1333
1353
  // Trigger a tick so the new fix plan is reconciled into the queue and fired.
1334
1354
  tickQueue().catch(() => {});
@@ -1525,17 +1545,16 @@ async function spawnJob(job, runId, runDir, defaultCwd) {
1525
1545
  // this, the queue stalls indefinitely behind a stale failure even
1526
1546
  // though the auto-recovery did its job.
1527
1547
  if (effectiveStatus === 'completed' && isFixPlanSlug(job.slug)) {
1528
- const originalSlug = job.slug.replace(/^(\d+)-fix-/, '$1-');
1529
- const orig = s.jobs.findIndex((x) => x.slug === originalSlug && isPromotableOriginal(x.status));
1530
- if (orig >= 0) {
1531
- const priorStatus = s.jobs[orig].status;
1532
- console.log(`[scheduler] auto-promote: ${originalSlug} (${priorStatus}) → completed because ${job.slug} succeeded`);
1533
- s.jobs[orig].status = 'completed';
1534
- s.jobs[orig].exitCode = 0;
1535
- s.jobs[orig].error = null;
1536
- s.jobs[orig].completedBy = job.slug;
1548
+ const orig = healTargetForFix(job.slug, s.jobs);
1549
+ if (orig) {
1550
+ const priorStatus = orig.status;
1551
+ console.log(`[scheduler] auto-promote: ${orig.slug} (${priorStatus}) completed because ${job.slug} succeeded`);
1552
+ orig.status = 'completed';
1553
+ orig.exitCode = 0;
1554
+ orig.error = null;
1555
+ orig.completedBy = job.slug;
1537
1556
  if (priorStatus === 'needs_review') {
1538
- delete s.jobs[orig].verifierVerdict;
1557
+ delete orig.verifierVerdict;
1539
1558
  }
1540
1559
  }
1541
1560
  }
@@ -1881,16 +1900,57 @@ function selectHistoryJobs(jobs, limit) {
1881
1900
  // heal a genuinely-unfinished job.
1882
1901
  const RESCANNABLE_VERDICTS = new Set(['transcript_errors', 'verify_unavailable']);
1883
1902
 
1903
+ /**
1904
+ * Backfill a job's missing runId by scanning RUNS_DIR for a run directory
1905
+ * whose contents reference this job's slug (a '<slug>.log' file inside it).
1906
+ * A needs_review job can lose its runId (e.g. an old queue-schema gap) and
1907
+ * become invisible to every auto-resolution path that requires job.runId
1908
+ * truthy. O(number of run dirs) — a single readdir + per-dir existsSync
1909
+ * check, no nested loop over user-scaled data. Dir names are ISO timestamps,
1910
+ * so lexical-descending sort picks the newest match. Exported for tests.
1911
+ */
1912
+ function resolveRunId(job, { runsDir = RUNS_DIR } = {}) {
1913
+ if (!job || job.runId) return job?.runId || null;
1914
+ if (!job.slug) return null;
1915
+ let dirs;
1916
+ try {
1917
+ dirs = fs.readdirSync(runsDir);
1918
+ } catch {
1919
+ return null;
1920
+ }
1921
+ const matches = dirs.filter((d) => {
1922
+ try {
1923
+ return fs.existsSync(path.join(runsDir, d, `${job.slug}.log`));
1924
+ } catch {
1925
+ return false;
1926
+ }
1927
+ });
1928
+ if (!matches.length) return null;
1929
+ matches.sort().reverse();
1930
+ return matches[0];
1931
+ }
1932
+
1933
+ /**
1934
+ * Pure predicate: a needs_review job with no runId and no backfillable run
1935
+ * directory can never self-heal or get an auto-fix investigation — it must
1936
+ * be surfaced for a human rather than silently dead-ended. Exported for
1937
+ * tests.
1938
+ */
1939
+ function isUnresolvableNeedsReview(job, { hasRunDir }) {
1940
+ return !!job && job.status === 'needs_review' && !job.runId && !hasRunDir;
1941
+ }
1942
+
1884
1943
  /**
1885
1944
  * Pure predicate: is this job eligible for the boot re-verify self-heal? Only
1886
- * needs_review jobs with a run log AND a transcript-scan verdict. Crucially
1887
- * EXCLUDES 'uncommitted_changes' (git commit-guard) — verifyRun can't see git,
1888
- * so re-scanning it would falsely heal an unfinished job. Exported for tests.
1945
+ * needs_review jobs with a run log (own or backfilled via resolveRunId) AND a
1946
+ * transcript-scan verdict. Crucially EXCLUDES 'uncommitted_changes' (git
1947
+ * commit-guard) — verifyRun can't see git, so re-scanning it would falsely
1948
+ * heal an unfinished job. Exported for tests.
1889
1949
  */
1890
1950
  function isRescanCandidate(job) {
1891
1951
  return !!job
1892
1952
  && job.status === 'needs_review'
1893
- && !!job.runId
1953
+ && !!(job.runId || resolveRunId(job))
1894
1954
  && RESCANNABLE_VERDICTS.has(job.verifierVerdict);
1895
1955
  }
1896
1956
 
@@ -1906,23 +1966,32 @@ function isRescanCandidate(job) {
1906
1966
  * @returns {Promise<{rescanned:number, healed:string[]}>}
1907
1967
  */
1908
1968
  /**
1909
- * Pure helper — no I/O. Returns the subset of jobs eligible for automatic
1910
- * fix-plan authoring after a reverify pass leaves them still in needs_review.
1969
+ * Pure helper — no I/O by default (resolveJobRunId is injectable; production
1970
+ * caller passes resolveRunId, which does touch disk). Returns the subset of
1971
+ * jobs eligible for automatic fix-plan authoring after a reverify pass
1972
+ * leaves them still in needs_review.
1911
1973
  *
1912
1974
  * Exclusion rules (all must pass):
1913
1975
  * - status === 'needs_review'
1914
- * - truthy runId (need a run log to investigate)
1915
- * - autoFixAttempted !== true (1-attempt cap)
1976
+ * - a runId, own or backfilled via resolveJobRunId (need a run log to investigate)
1916
1977
  * - not itself a fix-plan slug (avoids infinite recursion)
1978
+ * - autoFixAttempted cap: a job with no prior attempt is eligible; a job
1979
+ * whose prior attempt outcome was 'no-plan' gets ONE bounded retry
1980
+ * (autoFixRetries < 1); any other attempted job (or an exhausted
1981
+ * no-plan retry) is excluded
1917
1982
  * - no fix sibling on disk (fixSlugExists) or already in the queue
1918
1983
  */
1919
- function selectAutoFixTargets(jobs, { fixSlugExists }) {
1984
+ function selectAutoFixTargets(jobs, { fixSlugExists, resolveJobRunId = resolveRunId }) {
1920
1985
  const slugsInQueue = new Set(jobs.map((j) => j.slug));
1921
1986
  return jobs.filter((job) => {
1922
1987
  if (job.status !== 'needs_review') return false;
1923
- if (!job.runId) return false;
1924
- if (job.autoFixAttempted) return false;
1988
+ const runId = job.runId || resolveJobRunId(job);
1989
+ if (!runId) return false;
1925
1990
  if (isFixPlanSlug(job.slug)) return false;
1991
+ if (job.autoFixAttempted) {
1992
+ if (job.autoFixOutcome !== 'no-plan') return false;
1993
+ if ((job.autoFixRetries ?? 0) >= 1) return false;
1994
+ }
1926
1995
  const fixSlug = `${String(job.parallelGroup ?? 99).padStart(2, '0')}-fix-${job.slug.replace(/^\d+-/, '')}`;
1927
1996
  if (fixSlugExists(fixSlug)) return false;
1928
1997
  if (slugsInQueue.has(fixSlug)) return false;
@@ -1936,7 +2005,7 @@ async function reverifyNeedsReview() {
1936
2005
  const healed = [];
1937
2006
  const leftForReview = [];
1938
2007
  for (const job of candidates) {
1939
- const runDir = path.join(RUNS_DIR, job.runId);
2008
+ const runDir = path.join(RUNS_DIR, job.runId || resolveRunId(job));
1940
2009
  const prdPath = path.join(PRDS_DIR, `${job.slug}.md`);
1941
2010
  // Derive committedDuringRun from the recorded run window. The live
1942
2011
  // commit-guard uses gitHead() (before/after HEAD diff); here the run is
@@ -1978,24 +2047,72 @@ async function reverifyNeedsReview() {
1978
2047
  console.log(`[scheduler] boot reverify: left for review: ${detail}`);
1979
2048
  }
1980
2049
 
2050
+ // Surface needs_review jobs that can never self-heal or get an auto-fix
2051
+ // investigation (no runId, no backfillable run dir) instead of leaving
2052
+ // them silently stranded. Also surface no-plan auto-fix jobs whose one
2053
+ // bounded retry is already exhausted. Both are annotated, never looped on.
2054
+ const afterHealForAnnotate = await readQueue();
2055
+ const unresolvable = afterHealForAnnotate.jobs.filter(
2056
+ (j) => isUnresolvableNeedsReview(j, { hasRunDir: !!resolveRunId(j) }) && j.verifierVerdict !== 'no_run_artifacts',
2057
+ );
2058
+ const exhaustedNoPlan = afterHealForAnnotate.jobs.filter(
2059
+ (j) => j.status === 'needs_review'
2060
+ && j.autoFixAttempted && j.autoFixOutcome === 'no-plan' && (j.autoFixRetries ?? 0) >= 1
2061
+ && j.verifierVerdict !== 'autofix_no_plan',
2062
+ );
2063
+ if (unresolvable.length || exhaustedNoPlan.length) {
2064
+ const unresolvableSet = new Set(unresolvable.map((j) => j.slug));
2065
+ const exhaustedSet = new Set(exhaustedNoPlan.map((j) => j.slug));
2066
+ await mutate((s) => {
2067
+ for (const j of s.jobs) {
2068
+ if (unresolvableSet.has(j.slug)) {
2069
+ j.verifierVerdict = 'no_run_artifacts';
2070
+ j.error = 'no run artifacts — manual review';
2071
+ } else if (exhaustedSet.has(j.slug)) {
2072
+ j.verifierVerdict = 'autofix_no_plan';
2073
+ j.error = 'auto-fix investigation produced no fix plan after retry — manual review';
2074
+ }
2075
+ }
2076
+ });
2077
+ if (unresolvable.length) {
2078
+ console.log(`[scheduler] boot reverify: no run artifacts for ${unresolvable.map((j) => j.slug).join(', ')} — flagged for manual review`);
2079
+ }
2080
+ if (exhaustedNoPlan.length) {
2081
+ console.log(`[scheduler] boot reverify: auto-fix retry exhausted for ${exhaustedNoPlan.map((j) => j.slug).join(', ')} — flagged for manual review`);
2082
+ }
2083
+ await broadcast();
2084
+ }
2085
+
1981
2086
  // Auto-fix: spawn a fix-plan investigation for each job still in
1982
2087
  // needs_review after the heal pass (kill-switch: SM_AUTOFIX_DISABLE=1).
2088
+ // spawnInvestigation early-returns once investigationsInFlight reaches
2089
+ // MAX_CONCURRENT_INVESTIGATIONS (queues the rest for retry), so this loop
2090
+ // cannot fan out past the cap regardless of how many targets are selected.
1983
2091
  if (process.env.SM_AUTOFIX_DISABLE !== '1') {
1984
2092
  const afterHeal = await readQueue();
1985
2093
  const targets = selectAutoFixTargets(afterHeal.jobs, {
1986
2094
  fixSlugExists: (s) => fs.existsSync(path.join(PRDS_DIR, `${s}.md`)),
1987
2095
  });
1988
2096
  for (const job of targets) {
1989
- const runDir = path.join(RUNS_DIR, job.runId);
2097
+ const runId = job.runId || resolveRunId(job);
2098
+ const runDir = path.join(RUNS_DIR, runId);
2099
+ const isNoPlanRetry = job.autoFixAttempted && job.autoFixOutcome === 'no-plan';
1990
2100
  // Persist the attempt BEFORE spawning — a crash mid-investigation still
1991
2101
  // counts it (mirrors orphanRetries). Safe even when the slot is busy: the
1992
2102
  // investigation is queued and drained as slots free, so it is genuinely
1993
2103
  // attempted rather than silently dropped.
1994
2104
  await mutate((s) => {
1995
2105
  const j = s.jobs.find((x) => x.slug === job.slug);
1996
- if (j) j.autoFixAttempted = true;
2106
+ if (j) {
2107
+ j.autoFixAttempted = true;
2108
+ if (!j.runId && runId) j.runId = runId;
2109
+ if (isNoPlanRetry) {
2110
+ j.autoFixRetries = (j.autoFixRetries ?? 0) + 1;
2111
+ delete j.autoFixOutcome;
2112
+ }
2113
+ }
1997
2114
  });
1998
- console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (1/1)`);
2115
+ console.log(`[scheduler] auto-fix: needs_review ${job.slug} → authoring fix-plan (${isNoPlanRetry ? 'retry' : '1/1'})`);
1999
2116
  spawnInvestigation(job, runDir).catch((e) => {
2000
2117
  console.error('[scheduler] auto-fix spawnInvestigation error', job.slug, e);
2001
2118
  });
@@ -2335,10 +2452,16 @@ async function init() {
2335
2452
  // Periodic self-heal: re-run the verifier over stale needs_review jobs so a
2336
2453
  // job whose work actually landed (committed in-window, no FAIL sentinel)
2337
2454
  // auto-clears WITHOUT waiting for the next app restart. Cheap-guarded — the
2338
- // log scan only runs when something is actually flagged.
2339
- const s = readQueueSync();
2340
- if (s.jobs.some((j) => j.status === 'needs_review')) {
2341
- reverifyNeedsReview().catch(() => {});
2455
+ // log scan only runs when something is actually flagged. Kill-switch:
2456
+ // SM_REVERIFY_PERIODIC_DISABLE=1 (boot reverify above stays always-on).
2457
+ // reverifyNeedsReview's auto-fix loop is capped downstream by
2458
+ // MAX_CONCURRENT_INVESTIGATIONS (spawnInvestigation queues/early-returns
2459
+ // past it), so this interval firing cannot fan out investigations.
2460
+ if (process.env.SM_REVERIFY_PERIODIC_DISABLE !== '1') {
2461
+ const s = readQueueSync();
2462
+ if (s.jobs.some((j) => j.status === 'needs_review')) {
2463
+ reverifyNeedsReview().catch(() => {});
2464
+ }
2342
2465
  }
2343
2466
  }, 10 * 60_000);
2344
2467
 
@@ -2479,7 +2602,12 @@ const remote = {
2479
2602
  });
2480
2603
  if (!found) return { ok: false, error: 'not found' };
2481
2604
  await broadcast();
2482
- return { ok: true };
2605
+ return { ok: true, slug, status: 'pending' };
2606
+ },
2607
+
2608
+ async listJobs() {
2609
+ const state = await readQueue();
2610
+ return state.jobs.map((j) => ({ slug: j.slug, title: j.title, status: j.status, cwd: j.cwd }));
2483
2611
  },
2484
2612
 
2485
2613
  async runNow() {
@@ -2505,4 +2633,4 @@ const remote = {
2505
2633
  },
2506
2634
  };
2507
2635
 
2508
- module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets };
2636
+ module.exports = { registerScheduleHandlers, attachWindow, init, ROOT, PRDS_DIR, selectHistoryJobs, parsePorcelain, FINISH_PROTOCOL, remote, pickNextBatch, pickForProject, reapDeadRunningJobs, pollRecoveryClearSource, memoryLimitedBatchSize, availableForJobs, reverifyNeedsReview, isRescanCandidate, isPromotableOriginal, selectAutoFixTargets, resolveRunId, isUnresolvableNeedsReview, healTargetForFix };
@@ -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;
@@ -130,6 +198,21 @@ export type BillingFetchResult =
130
198
  | { kind: 'transient'; message: string; httpStatus: number | null }
131
199
  | { kind: 'config'; message: string };
132
200
 
201
+ // ── MCP server live connection probe (`claude mcp list`)
202
+ export interface McpServerStatus {
203
+ name: string;
204
+ target: string;
205
+ transport: 'stdio' | 'http' | 'sse' | 'ws' | 'unknown';
206
+ status: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'unknown';
207
+ }
208
+
209
+ export interface McpStatusResult {
210
+ ok: boolean;
211
+ servers: McpServerStatus[];
212
+ error?: string;
213
+ checkedAt: number;
214
+ }
215
+
133
216
  // ── Usage matrix (AgOps dashboard, main-side aggregator)
134
217
  export type UsageMatrixState = 'idle' | 'executing' | 'plan' | 'background';
135
218
  export type UsageMatrixIntensity = 'idle' | 'low' | 'medium' | 'critical';
@@ -435,12 +518,23 @@ export interface DayProjectRow {
435
518
  sessionCount: number;
436
519
  errorCount: number;
437
520
  estimatedCostUsd: number;
521
+ byModel: Record<string, {
522
+ inputTokens: number;
523
+ outputTokens: number;
524
+ cacheReadTokens: number;
525
+ cacheCreationTokens: number;
526
+ costUsd: number;
527
+ /** Set when the model id didn't match opus/sonnet/haiku and was priced at Sonnet rates as a fallback. */
528
+ estimated?: boolean;
529
+ }>;
438
530
  }
439
531
 
440
532
  export interface HistoryAggregateResult {
441
533
  rows: DayProjectRow[];
442
534
  partial: boolean;
443
535
  scannedMs: number;
536
+ /** Total $ saved across all rows from cache-read pricing vs. full input pricing. Global, not per-row. */
537
+ cacheSavingsUsd: number;
444
538
  }
445
539
 
446
540
  export interface SessionScanEntry {
@@ -899,6 +993,12 @@ export interface ChatRunErrorEvent {
899
993
  message: string;
900
994
  }
901
995
 
996
+ export interface ChatRunNoticeEvent {
997
+ tabId: string;
998
+ sessionId: string;
999
+ message: string;
1000
+ }
1001
+
902
1002
  export interface SessionManagerAPI {
903
1003
  app: {
904
1004
  version: () => Promise<string>;
@@ -932,6 +1032,54 @@ export interface SessionManagerAPI {
932
1032
  onExit: (tabId: string, handler: (info: PtyExit) => void) => () => void;
933
1033
  onWriteError: (handler: (ev: WriteErrorEvent) => void) => () => void;
934
1034
  };
1035
+ browser: {
1036
+ create: (payload: { viewId: string; partition: string }) => Promise<{ ok: boolean }>;
1037
+ setBounds: (payload: { viewId: string; x: number; y: number; width: number; height: number }) => Promise<{ ok: boolean }>;
1038
+ show: (viewId: string) => Promise<{ ok: boolean }>;
1039
+ hide: (viewId: string) => Promise<{ ok: boolean }>;
1040
+ destroy: (viewId: string) => Promise<{ ok: boolean }>;
1041
+ navigate: (payload: { viewId: string; url: string }) => Promise<{ ok: boolean; error?: string }>;
1042
+ back: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1043
+ forward: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1044
+ reload: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1045
+ stop: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1046
+ onNavState: (viewId: string, handler: (state: BrowserNavState) => void) => () => void;
1047
+ recordStart: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1048
+ recordStop: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1049
+ onRecordStep: (viewId: string, handler: (step: RecordStep) => void) => () => void;
1050
+ captureDom: (payload: { viewId: string; kind: 'text' | 'html' }) => Promise<
1051
+ | { ok: true; url: string; title: string; text: string; truncated?: boolean }
1052
+ | { ok: false; error: string }
1053
+ >;
1054
+ captureShot: (viewId: string) => Promise<
1055
+ | { ok: true; url: string; title: string; dataUrl: string }
1056
+ | { ok: false; error: string }
1057
+ >;
1058
+ saveBinary: (path: string, base64: string) => Promise<{ ok: boolean; error?: string }>;
1059
+ replay: (payload: {
1060
+ viewId: string;
1061
+ steps: RecordStep[];
1062
+ values?: Record<string, string>;
1063
+ continueOnError?: boolean;
1064
+ }) => Promise<{ ok: boolean; error?: string; stopped?: boolean; failedAt?: number }>;
1065
+ onReplayStep: (viewId: string, handler: (step: ReplayStepResult) => void) => () => void;
1066
+ setZoom: (payload: { viewId: string; factor: number }) => Promise<{ ok: boolean; error?: string; factor?: number }>;
1067
+ find: (payload: { viewId: string; text: string; forward?: boolean }) => Promise<{ ok: boolean; error?: string }>;
1068
+ stopFind: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1069
+ onFindResult: (viewId: string, handler: (result: FindResult) => void) => () => void;
1070
+ pickerStart: (viewId: string) => Promise<{ ok: boolean; error?: string }>;
1071
+ pickerStop: (viewId: string) => Promise<{ ok: boolean; error?: string; wasPicking?: boolean }>;
1072
+ onPickerEvent: (viewId: string, handler: (ev: PickerEvent) => void) => () => void;
1073
+ /** Selection-scoped capture pipeline (PRD 404): filter -> prune -> summarize -> chunk for 'agent', outerHTML for 'html', CDP AX-tree for 'a11y', fallback-chain for 'selector'. */
1074
+ capture: (payload: {
1075
+ viewId: string;
1076
+ selectors: string[];
1077
+ mode: 'agent' | 'html' | 'a11y' | 'selector';
1078
+ }) => Promise<
1079
+ | { ok: true; mode: string; text: string; meta: { chunks?: number; tokens?: number } }
1080
+ | { ok: false; error: string }
1081
+ >;
1082
+ };
935
1083
  transcripts: {
936
1084
  subscribe: (payload: { tabId: string; cwd: string; sessionUuid: string }) => Promise<SubscribeResult>;
937
1085
  /** Release the sub back to the LRU cache (view-switch). Does not destroy the watcher. */
@@ -949,6 +1097,9 @@ export interface SessionManagerAPI {
949
1097
  billing: {
950
1098
  fetch: () => Promise<BillingFetchResult>;
951
1099
  };
1100
+ mcp: {
1101
+ status: () => Promise<McpStatusResult>;
1102
+ };
952
1103
  usageMatrix: {
953
1104
  snapshot: () => Promise<UsageMatrixSnapshot>;
954
1105
  onTick: (handler: (snap: UsageMatrixSnapshot) => void) => () => void;
@@ -1099,6 +1250,8 @@ export interface SessionManagerAPI {
1099
1250
  | { ok: true; path: string; bytes: number }
1100
1251
  | { ok: false; empty?: true; error?: string }
1101
1252
  >;
1253
+ /** Write side — copies a Capture-panel screenshot data URL to the OS clipboard. */
1254
+ copyImage: (dataUrl: string) => Promise<{ ok: boolean; error?: string }>;
1102
1255
  };
1103
1256
  memory: {
1104
1257
  /** List markdown memory entries for the given workspace (defaults to 'default'). */
@@ -1129,11 +1282,6 @@ export interface SessionManagerAPI {
1129
1282
  /** Delete one entry. Removes the file outright when last entry is removed. */
1130
1283
  delete: (agentId: string, entryId: string) => Promise<AgentMemoryMutationResult>;
1131
1284
  };
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
1285
  git: {
1138
1286
  /** Full git status for `cwd`. Returns null when not a git repo, git is
1139
1287
  * missing, or the call times out (5s ceiling). Cached per-cwd for 5s. */
@@ -1187,6 +1335,7 @@ export interface SessionManagerAPI {
1187
1335
  onNeedsInput: (handler: (e: ChatRunNeedsInputEvent) => void) => () => void;
1188
1336
  onComplete: (handler: (e: ChatRunCompleteEvent) => void) => () => void;
1189
1337
  onError: (handler: (e: ChatRunErrorEvent) => void) => () => void;
1338
+ onNotice: (handler: (e: ChatRunNoticeEvent) => void) => () => void;
1190
1339
  };
1191
1340
  exchanges: {
1192
1341
  /** Durable per-exchange log entries for a project, newest-first (max 100 by default). */
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Preload for embedded Browser-tab WebContentsViews (PRD 408 recorder
3
+ * engine). Exposes a minimal `window.__smRecorder.setRecording(bool)` toggle
4
+ * via contextBridge so the main process can arm/disarm step capture without
5
+ * ever giving page script node/ipcRenderer access. Typed values are never
6
+ * captured — only the target selector + a `masked` flag — per the tab's
7
+ * "sandboxed · no filesystem · no passwords" guardrail.
8
+ */
9
+ const { contextBridge, ipcRenderer } = require('electron');
10
+
11
+ // Secret handed to us via `additionalArguments` (never touches the DOM or a
12
+ // script tag the embedded page could inspect) — required on every toggle so
13
+ // the page itself can't call the exposeInMainWorld bridge to interfere with
14
+ // a recording session it has no business controlling.
15
+ const TOKEN_PREFIX = '--sm-record-token=';
16
+ const expectedToken = (process.argv.find((a) => a.startsWith(TOKEN_PREFIX)) || '').slice(TOKEN_PREFIX.length) || null;
17
+
18
+ let capturing = false;
19
+
20
+ function selectorFor(el) {
21
+ if (!el || el.nodeType !== 1) return '';
22
+ if (el.id) return `#${el.id}`;
23
+ const testId = typeof el.getAttribute === 'function' ? el.getAttribute('data-testid') : null;
24
+ if (testId) return `[data-testid="${testId}"]`;
25
+ const tag = el.tagName.toLowerCase();
26
+ const cls = typeof el.className === 'string' ? el.className.trim().split(/\s+/)[0] : '';
27
+ return cls ? `${tag}.${cls}` : tag;
28
+ }
29
+
30
+ function variableSuggestionFor(el) {
31
+ const name = String(el.name || el.id || '').toLowerCase();
32
+ if (el.type === 'password' || /pass/.test(name)) return 'password';
33
+ if (/email/.test(name)) return 'email';
34
+ if (/user/.test(name)) return 'username';
35
+ return name || 'value';
36
+ }
37
+
38
+ document.addEventListener(
39
+ 'click',
40
+ (e) => {
41
+ if (!capturing) return;
42
+ ipcRenderer.send('browser:record-event', { verb: 'click', target: selectorFor(e.target) });
43
+ },
44
+ true,
45
+ );
46
+
47
+ document.addEventListener(
48
+ 'change',
49
+ (e) => {
50
+ if (!capturing) return;
51
+ const el = e.target;
52
+ if (!el || (el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA')) return;
53
+ ipcRenderer.send('browser:record-event', {
54
+ verb: 'type',
55
+ target: selectorFor(el),
56
+ variableSuggestion: variableSuggestionFor(el),
57
+ });
58
+ },
59
+ true,
60
+ );
61
+
62
+ contextBridge.exposeInMainWorld('__smRecorder', {
63
+ setRecording: (v, token) => {
64
+ if (!expectedToken || token !== expectedToken) return;
65
+ capturing = !!v;
66
+ },
67
+ });