synabun 2026.4.21 → 2026.4.25-beta.2

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 (61) hide show
  1. package/CHANGELOG.md +175 -881
  2. package/README.md +1 -1
  3. package/hooks/claude-code/prompt-submit.mjs +39 -18
  4. package/hooks/claude-code/session-start.mjs +10 -3
  5. package/hooks/claude-code/shared.mjs +39 -2
  6. package/hooks/claude-code/stop.mjs +33 -6
  7. package/mcp-server/src/index.ts +9 -1
  8. package/mcp-server/src/services/neural-interface.ts +42 -14
  9. package/mcp-server/src/services/profiles.ts +8 -7
  10. package/mcp-server/src/tools/browser-advanced.ts +12 -10
  11. package/mcp-server/src/tools/browser-interact.ts +39 -38
  12. package/mcp-server/src/tools/browser-navigate.ts +12 -4
  13. package/mcp-server/src/tools/loop.ts +7 -3
  14. package/mcp-server/src/tools/profile.ts +19 -1
  15. package/neural-interface/lib/loop-logger.js +103 -0
  16. package/neural-interface/lib/mcp-installer.js +109 -0
  17. package/neural-interface/lib/plugin-installer.js +264 -0
  18. package/neural-interface/package.json +1 -1
  19. package/neural-interface/public/shared/api.js +66 -17
  20. package/neural-interface/public/shared/cdx/cdx-icons.js +5 -4
  21. package/neural-interface/public/shared/cdx/cdx-panel.js +117 -14
  22. package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
  23. package/neural-interface/public/shared/cdx/cdx-styles.js +55 -10
  24. package/neural-interface/public/shared/cdx/cdx-tabs.js +394 -72
  25. package/neural-interface/public/shared/cli-status.js +157 -0
  26. package/neural-interface/public/shared/constants.js +3 -2
  27. package/neural-interface/public/shared/html-shell.js +32 -11
  28. package/neural-interface/public/shared/ocp/ocp-panel.js +127 -28
  29. package/neural-interface/public/shared/ocp/ocp-render.js +52 -44
  30. package/neural-interface/public/shared/ocp/ocp-styles.js +33 -0
  31. package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
  32. package/neural-interface/public/shared/styles.css +635 -0
  33. package/neural-interface/public/shared/ui-automation-studio.js +38 -12
  34. package/neural-interface/public/shared/ui-claude-panel.js +322 -15
  35. package/neural-interface/public/shared/ui-file-explorer.js +38 -18
  36. package/neural-interface/public/shared/ui-menubar.js +34 -10
  37. package/neural-interface/public/shared/ui-navbar.js +8 -6
  38. package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
  39. package/neural-interface/public/shared/ui-settings.js +274 -50
  40. package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
  41. package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
  42. package/neural-interface/public/shared/ui-sync.js +9 -5
  43. package/neural-interface/public/shared/ui-terminal.js +76 -27
  44. package/neural-interface/public/shared/ui-tool-updates.js +49 -15
  45. package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
  46. package/neural-interface/public/shared/ui-tutorial.js +24 -18
  47. package/neural-interface/public/shared/ui-whiteboard.js +79 -31
  48. package/neural-interface/public/shared/vterm-buffer.js +9 -8
  49. package/neural-interface/public/variant/2d/main.js +9 -7
  50. package/neural-interface/public/variant/3d/main.js +10 -8
  51. package/neural-interface/server.js +1292 -255
  52. package/package.json +1 -1
  53. package/skills/leonardo/SKILL.md +5 -1
  54. package/skills/leonardo/modules/image-prompter.md +5 -1
  55. package/skills/leonardo/modules/video-prompter.md +5 -1
  56. package/skills/synabun/SKILL.md +44 -26
  57. package/skills/synabun/modules/audit.md +40 -36
  58. package/skills/synabun/modules/changelog.md +22 -18
  59. package/skills/synabun/modules/memorize.md +19 -15
  60. package/skills/synabun/modules/schedule.md +285 -0
  61. package/CLAUDE.md +0 -217
@@ -9,6 +9,7 @@ import { fetchClaudeSessions, fetchBrowserSessions, searchSessions } from './api
9
9
  import { createFrameRenderer } from './utils.js';
10
10
  import { notify, NOTIF_TYPE } from './ui-notifications.js';
11
11
  import { reserveRightPanelLayout, clearRightPanelLayout } from './ui-sidepanel-layout.js';
12
+ import { subscribeCliStatus, recheckCliStatus, getCliDocUrl, getCliInstallCommand, getCliLabel } from './cli-status.js';
12
13
 
13
14
  const CLAUDE_ICON = '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>';
14
15
 
@@ -120,6 +121,10 @@ function activeTab() { return _tabs[_activeTabIdx] || null; }
120
121
  let _browserEmbed = null; // { sessionId, ws, canvas, ctx, urlBar, container }
121
122
  let _browserEmbedVisible = false;
122
123
 
124
+ // ── CLI-installed status (claude binary detection) ──
125
+ let _cliInstalled = null; // null = unknown / missing, version string = installed
126
+ let _cliUnsub = null;
127
+
123
128
  // Marked.js (lazy loaded)
124
129
  let _marked = null;
125
130
  (async () => {
@@ -150,8 +155,24 @@ const STOR = {
150
155
  windowRegistry: 'synabun-claude-panel-windows', // JSON map of windowId → lastSeen timestamp
151
156
  bootId: 'synabun-claude-panel-boot-id', // server boot ID — detect restarts
152
157
  defaultModel: 'synabun-claude-panel-default-model', // user's preferred default model
158
+ sessionSnapshots: 'synabun-claude-panel-session-snapshots', // global, per-sessionId rendered HTML cache
153
159
  };
154
160
 
161
+ // ── Session HTML snapshot cache ──
162
+ // Global per-sessionId cache of rendered transcript HTML so resume on a fresh
163
+ // browser load mirrors the exact render that was produced during the live run.
164
+ // Falls back to upstream JSONL rebuild when no snapshot exists or it is stale.
165
+ const MAX_SESSION_SNAPSHOTS = 24;
166
+ const MAX_SESSION_SNAPSHOT_CHARS = 2_500_000;
167
+ let _sessionSnapshots = (() => {
168
+ try {
169
+ const raw = storage.getItem(STOR.sessionSnapshots);
170
+ if (!raw) return {};
171
+ const parsed = JSON.parse(raw);
172
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
173
+ } catch { return {}; }
174
+ })();
175
+
155
176
  const EFFORT_LEVELS = ['off', 'low', 'medium', 'high', 'max'];
156
177
  const EFFORT_LABELS = { off: 'Think', low: 'lo', medium: 'med', high: 'hi', max: 'max' };
157
178
  const EFFORT_TITLES = {
@@ -411,6 +432,41 @@ function injectStyles() {
411
432
  .cp-messages:has(.msg) .cp-empty,
412
433
  .cp-messages:has(.msg-status) .cp-empty { display: none; }
413
434
 
435
+ /* ── CLI-not-installed banner (replaces empty-state hint when claude binary missing) ── */
436
+ .cp-cli-missing {
437
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
438
+ max-width: 320px; padding: 14px 16px; margin-top: 4px;
439
+ border: 1px solid rgba(255,180,80,0.35); border-radius: 8px;
440
+ background: rgba(255,180,80,0.06); pointer-events: auto;
441
+ }
442
+ .cp-cli-missing-title {
443
+ font-size: 12px; font-weight: 600; color: rgba(255,200,120,0.9);
444
+ letter-spacing: 0.02em;
445
+ }
446
+ .cp-cli-missing-body {
447
+ font-size: 11px; color: rgba(255,255,255,0.55); text-align: center; line-height: 1.45;
448
+ }
449
+ .cp-cli-missing-body code {
450
+ display: inline-block; padding: 1px 6px; margin-top: 4px;
451
+ background: rgba(0,0,0,0.35); border-radius: 4px; font-size: 10.5px;
452
+ color: rgba(255,255,255,0.75);
453
+ }
454
+ .cp-cli-missing-actions { display: flex; gap: 8px; margin-top: 4px; }
455
+ .cp-cli-missing-link, .cp-cli-missing-recheck {
456
+ display: inline-flex; align-items: center; gap: 4px;
457
+ padding: 5px 10px; font-size: 11px; font-weight: 500;
458
+ border-radius: 5px; border: 1px solid rgba(255,255,255,0.15);
459
+ background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.85);
460
+ text-decoration: none; cursor: pointer; user-select: none;
461
+ }
462
+ .cp-cli-missing-link:hover, .cp-cli-missing-recheck:hover {
463
+ background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.3);
464
+ }
465
+ .cp-cli-missing-recheck:disabled { opacity: 0.5; cursor: default; }
466
+ /* Block send button visually + functionally when CLI missing */
467
+ .claude-panel.cp-cli-blocked .cp-send { opacity: 0.4 !important; cursor: not-allowed !important; }
468
+ .claude-panel.cp-cli-blocked .cp-input { opacity: 0.7; }
469
+
414
470
  /* ── Browser embed (live screencast inside panel) ── */
415
471
  .cp-browser-embed {
416
472
  display: none;
@@ -1076,7 +1132,7 @@ function injectStyles() {
1076
1132
  padding: 1px;
1077
1133
  background: rgba(255,255,255,0.05);
1078
1134
  transition: background 0.4s;
1079
- min-width: 0; overflow: hidden;
1135
+ min-width: 0;
1080
1136
  }
1081
1137
  .cp-input-wrap::before {
1082
1138
  content: '';
@@ -1476,7 +1532,8 @@ function injectStyles() {
1476
1532
  backdrop-filter: blur(20px);
1477
1533
  border: 1px solid rgba(255,255,255,0.08);
1478
1534
  border-radius: 10px; padding: 4px;
1479
- z-index: 310; max-height: 200px; overflow-y: auto;
1535
+ z-index: 310; max-height: 320px; overflow-y: auto;
1536
+ overscroll-behavior: contain;
1480
1537
  box-shadow: var(--shadow-lg);
1481
1538
  }
1482
1539
  .cp-slash-hints.open { display: block; }
@@ -2632,14 +2689,14 @@ document.addEventListener('click', (e) => {
2632
2689
  });
2633
2690
 
2634
2691
  // ── Tab lifecycle ──
2635
- function createTab(sessionId = null, label = 'New chat', { autoSwitch = true } = {}) {
2692
+ function createTab(sessionId = null, label = 'New chat', { autoSwitch = true, id = null } = {}) {
2636
2693
  if (_tabs.length >= MAX_TABS) return null;
2637
2694
  // Capture current dropdown state for the new tab's project/model/effort
2638
2695
  const _$proj = _panel?.querySelector('#cp-project');
2639
2696
  const _$mod = _panel?.querySelector('#cp-model');
2640
2697
  const _$think = _panel?.querySelector('#cp-think-toggle');
2641
2698
  const tab = {
2642
- id: crypto.randomUUID(),
2699
+ id: id || crypto.randomUUID(),
2643
2700
  sessionId,
2644
2701
  label,
2645
2702
  project: ddGetValue(_$proj) || '', // per-tab project isolation
@@ -2695,6 +2752,7 @@ function createTab(sessionId = null, label = 'New chat', { autoSwitch = true } =
2695
2752
  msgDiv.innerHTML = `<div class="cp-empty"><div class="cp-empty-logo">${CLAUDE_ICON}</div><span class="cp-empty-name">Claude</span><span class="cp-empty-hint">Send a message to start</span></div>`;
2696
2753
  container.appendChild(msgDiv);
2697
2754
  tab.messagesEl = msgDiv;
2755
+ if (_cliInstalled === null) _renderCliMissingBanner(msgDiv.querySelector('.cp-empty'));
2698
2756
  // Connect WebSocket
2699
2757
  connectTab(tab);
2700
2758
  // Create pill (rendered in shared tray, not inside panel)
@@ -2729,6 +2787,7 @@ function switchTab(idx) {
2729
2787
  if (idx < 0 || idx >= _tabs.length) return;
2730
2788
  const prev = activeTab();
2731
2789
  if (prev && prev !== _tabs[idx]) {
2790
+ flushSessionSnapshotSave(prev);
2732
2791
  const $input = _panel?.querySelector('#cp-input');
2733
2792
  if ($input) prev.draft = $input.value;
2734
2793
  // Animate out
@@ -2818,6 +2877,7 @@ function switchTab(idx) {
2818
2877
  function closeTab(idx) {
2819
2878
  if (idx < 0 || idx >= _tabs.length) return;
2820
2879
  const tab = _tabs[idx];
2880
+ flushSessionSnapshotSave(tab);
2821
2881
  recordHookEvent(tab, 'SessionEnd', tab.sessionId || '(new)');
2822
2882
  tab.closed = true;
2823
2883
  clearTimeout(tab.reconnectTimer);
@@ -2956,7 +3016,7 @@ function restoreTabs() {
2956
3016
  const data = JSON.parse(raw);
2957
3017
  if (data.tabs?.length) {
2958
3018
  for (const saved of data.tabs) {
2959
- const t = createTab(saved.sessionId, saved.label, { autoSwitch: false });
3019
+ const t = createTab(saved.sessionId, saved.label, { autoSwitch: false, id: saved.id });
2960
3020
  if (t && saved.sessionCost) t.sessionCost = saved.sessionCost;
2961
3021
  if (t && saved.running) t._wasRunning = true; // trigger reattach on WS open
2962
3022
  if (t && saved.project) t.project = saved.project;
@@ -2979,7 +3039,7 @@ function restoreTabs() {
2979
3039
  const data = JSON.parse(legacyRaw);
2980
3040
  if (data.tabs?.length) {
2981
3041
  for (const saved of data.tabs) {
2982
- const t = createTab(saved.sessionId, saved.label, { autoSwitch: false });
3042
+ const t = createTab(saved.sessionId, saved.label, { autoSwitch: false, id: saved.id });
2983
3043
  if (t && saved.sessionCost) t.sessionCost = saved.sessionCost;
2984
3044
  if (t && saved.running) t._wasRunning = true;
2985
3045
  if (t && saved.project) t.project = saved.project;
@@ -3055,6 +3115,7 @@ async function _releaseAllSessionLocks() {
3055
3115
 
3056
3116
  // Release locks when window closes
3057
3117
  window.addEventListener('beforeunload', () => {
3118
+ flushAllSessionSnapshots();
3058
3119
  _stopHeartbeat();
3059
3120
  // Use sendBeacon for reliable delivery on close
3060
3121
  navigator.sendBeacon(
@@ -3063,6 +3124,11 @@ window.addEventListener('beforeunload', () => {
3063
3124
  );
3064
3125
  });
3065
3126
 
3127
+ window.addEventListener('pagehide', flushAllSessionSnapshots);
3128
+ document.addEventListener('visibilitychange', () => {
3129
+ if (document.hidden) flushAllSessionSnapshots();
3130
+ });
3131
+
3066
3132
  // Re-sync UI state when window regains focus (catches stale running states from background tabs)
3067
3133
  window.addEventListener('focus', () => {
3068
3134
  if (!_panel) return;
@@ -3510,6 +3576,7 @@ async function selectSession(sid, label) {
3510
3576
  const btn = _panel?.querySelector('.cp-session-label');
3511
3577
  if (btn) btn.textContent = tab.label;
3512
3578
  tab.messagesEl.innerHTML = `<div class="cp-empty"><div class="cp-empty-logo">${CLAUDE_ICON}</div><span class="cp-empty-name">Claude</span><span class="cp-empty-hint">Send a message to start</span></div>`;
3579
+ if (_cliInstalled === null) _renderCliMissingBanner(tab.messagesEl.querySelector('.cp-empty'));
3513
3580
  if (sid) {
3514
3581
  loadSessionHistory(sid, tab.messagesEl);
3515
3582
  // Restore session cost from server
@@ -3713,9 +3780,151 @@ function promptNameNewSession() {
3713
3780
  });
3714
3781
  }
3715
3782
 
3783
+ // ── Session HTML snapshot helpers ──
3784
+ function _normalizeSnapshotEntry(entry) {
3785
+ if (!entry || typeof entry !== 'object') return null;
3786
+ const html = typeof entry.html === 'string' ? entry.html : '';
3787
+ if (!html) return null;
3788
+ return {
3789
+ html,
3790
+ updatedAt: Number(entry.updatedAt) || 0,
3791
+ label: typeof entry.label === 'string' ? entry.label : '',
3792
+ usage: (entry.usage && typeof entry.usage === 'object') ? entry.usage : null,
3793
+ contextWindow: Number(entry.contextWindow) || 0,
3794
+ turns: Number(entry.turns) || 0,
3795
+ sessionCost: Number(entry.sessionCost) || 0,
3796
+ itemCount: Number(entry.itemCount) || 0,
3797
+ };
3798
+ }
3799
+
3800
+ function _persistSessionSnapshots() {
3801
+ try {
3802
+ const source = (_sessionSnapshots && typeof _sessionSnapshots === 'object' && !Array.isArray(_sessionSnapshots))
3803
+ ? _sessionSnapshots : {};
3804
+ const entries = Object.entries(source)
3805
+ .map(([sid, e]) => [sid, _normalizeSnapshotEntry(e)])
3806
+ .filter(([, e]) => !!e)
3807
+ .sort(([, a], [, b]) => (b.updatedAt || 0) - (a.updatedAt || 0));
3808
+ const next = {};
3809
+ let totalChars = 0;
3810
+ for (const [sid, entry] of entries) {
3811
+ const size = entry.html.length;
3812
+ if (Object.keys(next).length >= MAX_SESSION_SNAPSHOTS) break;
3813
+ if (totalChars + size > MAX_SESSION_SNAPSHOT_CHARS && Object.keys(next).length) continue;
3814
+ totalChars += size;
3815
+ next[sid] = entry;
3816
+ }
3817
+ _sessionSnapshots = next;
3818
+ storage.setItem(STOR.sessionSnapshots, JSON.stringify(next));
3819
+ } catch {}
3820
+ }
3821
+
3822
+ function getSessionSnapshot(sid) {
3823
+ if (!sid) return null;
3824
+ return _normalizeSnapshotEntry(_sessionSnapshots?.[sid]);
3825
+ }
3826
+
3827
+ function writeSessionSnapshot(tab) {
3828
+ const sid = tab?.sessionId || null;
3829
+ const $msgs = tab?.messagesEl || null;
3830
+ if (!sid || !$msgs) return;
3831
+ const html = $msgs.innerHTML || '';
3832
+ if (!html.trim()) return;
3833
+ // Skip snapshot if the transcript only contains the empty-state placeholder
3834
+ if ($msgs.children.length === 1 && $msgs.firstElementChild?.classList?.contains('cp-empty')) return;
3835
+ if (!_sessionSnapshots || typeof _sessionSnapshots !== 'object' || Array.isArray(_sessionSnapshots)) {
3836
+ _sessionSnapshots = {};
3837
+ }
3838
+ const itemCount = [...$msgs.children].filter(n =>
3839
+ !n.classList?.contains('cp-empty') && !n.classList?.contains('thinking')
3840
+ ).length;
3841
+ _sessionSnapshots[sid] = {
3842
+ html,
3843
+ updatedAt: Date.now(),
3844
+ label: tab.label || '',
3845
+ usage: tab.usage ? { ...tab.usage } : null,
3846
+ contextWindow: tab.contextWindow || 0,
3847
+ turns: tab.turns || 0,
3848
+ sessionCost: tab.sessionCost || 0,
3849
+ itemCount,
3850
+ };
3851
+ _persistSessionSnapshots();
3852
+ }
3853
+
3854
+ function scheduleSessionSnapshotSave(tab, ms = 350) {
3855
+ if (!tab?.sessionId || !tab?.messagesEl) return;
3856
+ if (tab._snapshotTimer) clearTimeout(tab._snapshotTimer);
3857
+ tab._snapshotTimer = setTimeout(() => {
3858
+ tab._snapshotTimer = null;
3859
+ writeSessionSnapshot(tab);
3860
+ }, ms);
3861
+ }
3862
+
3863
+ function flushSessionSnapshotSave(tab) {
3864
+ if (!tab) return;
3865
+ if (tab._snapshotTimer) { clearTimeout(tab._snapshotTimer); tab._snapshotTimer = null; }
3866
+ writeSessionSnapshot(tab);
3867
+ }
3868
+
3869
+ function flushAllSessionSnapshots() {
3870
+ for (const tab of _tabs) flushSessionSnapshotSave(tab);
3871
+ }
3872
+
3873
+ function renderStoredSession(snapshot, $msgs, tab) {
3874
+ const norm = _normalizeSnapshotEntry(snapshot);
3875
+ if (!norm || !$msgs) return false;
3876
+ $msgs.innerHTML = norm.html;
3877
+ // Disable interactive controls — restored DOM has no event handlers and
3878
+ // any pending state (permission prompts, plan cards, ask cards) is stale.
3879
+ $msgs.querySelectorAll('button, input, select, textarea').forEach(node => { node.disabled = true; });
3880
+ // Drop transient overlays that should never resurrect after a fresh load
3881
+ $msgs.querySelectorAll('.thinking, .msg-permission-prompt, .post-plan-card').forEach(n => n.remove());
3882
+ // Re-attach copy buttons + file path links — innerHTML wipes their listeners
3883
+ $msgs.querySelectorAll('pre .cp-copy-btn').forEach(btn => btn.remove());
3884
+ addCopyButtons($msgs);
3885
+ linkifyFilePaths($msgs);
3886
+ if (tab) {
3887
+ if (norm.usage) {
3888
+ tab.usage.inputTokens = norm.usage.inputTokens || 0;
3889
+ tab.usage.outputTokens = norm.usage.outputTokens || 0;
3890
+ tab.usage.cacheRead = norm.usage.cacheRead || 0;
3891
+ tab.usage.cacheWrite = norm.usage.cacheWrite || 0;
3892
+ }
3893
+ if (norm.contextWindow) tab.contextWindow = norm.contextWindow;
3894
+ if (norm.turns) tab.turns = norm.turns;
3895
+ if (norm.sessionCost) tab.sessionCost = norm.sessionCost;
3896
+ renderGauge(tab);
3897
+ }
3898
+ return true;
3899
+ }
3900
+
3716
3901
  async function loadSessionHistory(sid, $msgs) {
3717
3902
  if (!$msgs) $msgs = activeTab()?.messagesEl;
3718
3903
  if (!$msgs) return;
3904
+ // Try local snapshot first — exact replay of the live render, lossless.
3905
+ const snap = getSessionSnapshot(sid);
3906
+ if (snap && snap.html) {
3907
+ const tab = _tabs.find(t => t.messagesEl === $msgs) || activeTab();
3908
+ if (renderStoredSession(snap, $msgs, tab)) {
3909
+ scrollEnd();
3910
+ // Background refresh: pull latest JSONL in case the session continued in
3911
+ // another window. If upstream has more turns than the snapshot, fall back
3912
+ // to the rebuild path below so the resumed view is current.
3913
+ try {
3914
+ const $project = _panel?.querySelector('#cp-project');
3915
+ const project = ddGetValue($project) || undefined;
3916
+ const params = new URLSearchParams({ limit: '1' });
3917
+ if (project) params.set('project', project);
3918
+ const probe = await fetch(`/api/claude-code/sessions/${encodeURIComponent(sid)}/messages?${params}`);
3919
+ const probeData = await probe.json();
3920
+ if ((probeData?.total || 0) > (snap.itemCount || 0) + 2) {
3921
+ // Upstream has materially more content — fall through to full rebuild
3922
+ } else {
3923
+ return;
3924
+ }
3925
+ } catch { return; }
3926
+ }
3927
+ }
3719
3928
  const $project = _panel?.querySelector('#cp-project');
3720
3929
  const project = ddGetValue($project) || undefined;
3721
3930
  const params = new URLSearchParams({ limit: '500' });
@@ -3737,6 +3946,13 @@ async function loadSessionHistory(sid, $msgs) {
3737
3946
  const avatar = document.createElement('div'); avatar.className = 'msg-avatar';
3738
3947
  avatar.innerHTML = CLAUDE_ICON; el.appendChild(avatar);
3739
3948
  const wrap = document.createElement('div'); wrap.className = 'msg-content';
3949
+ if (m.thinking) {
3950
+ const thinkEl = document.createElement('details');
3951
+ thinkEl.className = 'msg-thinking';
3952
+ thinkEl.innerHTML = `<summary><span class="msg-thinking-icon">${THINKING_ICON}</span><span class="msg-thinking-label">Thinking</span><span class="msg-thinking-chevron">&#x203A;</span></summary><div class="msg-thinking-content"></div>`;
3953
+ thinkEl.querySelector('.msg-thinking-content').textContent = m.thinking;
3954
+ wrap.appendChild(thinkEl);
3955
+ }
3740
3956
  if (m.text) {
3741
3957
  const body = document.createElement('div'); body.className = 'msg-body';
3742
3958
  body._rawMd = m.text;
@@ -3788,7 +4004,7 @@ async function loadSessionHistory(sid, $msgs) {
3788
4004
  $msgs.prepend(note);
3789
4005
  }
3790
4006
  // Populate context gauge from session history usage
3791
- const tab = activeTab();
4007
+ const tab = _tabs.find(t => t.messagesEl === $msgs) || activeTab();
3792
4008
  if (tab && data.usage) {
3793
4009
  tab.usage.inputTokens = data.usage.input_tokens || 0;
3794
4010
  tab.usage.outputTokens = data.usage.output_tokens || 0;
@@ -3799,6 +4015,9 @@ async function loadSessionHistory(sid, $msgs) {
3799
4015
  renderGauge(tab);
3800
4016
  }
3801
4017
  scrollEnd();
4018
+ // Cache the rebuilt transcript so the next resume on a fresh browser hits
4019
+ // the snapshot path and avoids paying the JSONL → DOM cost again.
4020
+ if (tab && tab.sessionId === sid) writeSessionSnapshot(tab);
3802
4021
  } catch {
3803
4022
  $msgs.innerHTML = '<div class="msg-status">Failed to load history</div>';
3804
4023
  }
@@ -4261,6 +4480,7 @@ function handleTabEvent(tab, ev) {
4261
4480
  }
4262
4481
  if (ev.type === 'tool_result') {
4263
4482
  updateToolResult(tab, ev);
4483
+ scheduleSessionSnapshotSave(tab);
4264
4484
  // Re-show thinking — Claude is processing the tool result
4265
4485
  if (tab.running && !tab.pendingAsk) showThinking(tab);
4266
4486
  return;
@@ -4527,6 +4747,7 @@ function renderPostPlanActions(tab, headerText) {
4527
4747
 
4528
4748
  const actions = [
4529
4749
  { label: 'Continue with implementation', prompt: 'Continue with the implementation based on the approved plan.', primary: true },
4750
+ { label: 'Continue planning', action: 'continue-planning' },
4530
4751
  { label: 'Compact context', action: 'compact' },
4531
4752
  { label: 'Edit plan', action: 'plan' },
4532
4753
  ];
@@ -4537,7 +4758,11 @@ function renderPostPlanActions(tab, headerText) {
4537
4758
  btn.addEventListener('click', () => {
4538
4759
  card.style.opacity = '0.45';
4539
4760
  card.style.pointerEvents = 'none';
4540
- if (a.action === 'compact') {
4761
+ if (a.action === 'continue-planning') {
4762
+ tab.showPostPlanActions = false;
4763
+ saveTabs();
4764
+ return;
4765
+ } else if (a.action === 'compact') {
4541
4766
  // Guard: cannot compact while running
4542
4767
  if (tab.running) { appendStatus(tab, 'Cannot compact while Claude is processing.'); card.style.opacity = '1'; card.style.pointerEvents = 'auto'; return; }
4543
4768
  if (tab.ws?.readyState === WebSocket.OPEN) {
@@ -4879,6 +5104,7 @@ function renderAssistant(tab, msg) {
4879
5104
  $msgs.appendChild(postPlanMsg);
4880
5105
  }
4881
5106
  if (tab === activeTab()) scrollEnd();
5107
+ scheduleSessionSnapshotSave(tab);
4882
5108
  }
4883
5109
 
4884
5110
  /** Build an interactive AskUserQuestion card from a tool_use block */
@@ -5993,6 +6219,7 @@ function appendUser(tab, text, images, files) {
5993
6219
  if (text) { const txt = document.createTextNode(text); bubble.appendChild(txt); }
5994
6220
  el.appendChild(bubble); $msgs.appendChild(el);
5995
6221
  if (tab === activeTab()) scrollEnd();
6222
+ scheduleSessionSnapshotSave(tab);
5996
6223
  }
5997
6224
 
5998
6225
  function showThinking(tab) {
@@ -6022,11 +6249,13 @@ function appendStatus(tab, text) {
6022
6249
  const $msgs = tab.messagesEl; if (!$msgs) return;
6023
6250
  const el = document.createElement('div'); el.className = 'msg-status'; el.textContent = text;
6024
6251
  $msgs.appendChild(el); pruneMessages($msgs); if (tab === activeTab()) scrollEnd();
6252
+ scheduleSessionSnapshotSave(tab);
6025
6253
  }
6026
6254
  function appendError(tab, text) {
6027
6255
  const $msgs = tab.messagesEl; if (!$msgs) return;
6028
6256
  const el = document.createElement('div'); el.className = 'msg-error'; el.textContent = text;
6029
6257
  $msgs.appendChild(el); pruneMessages($msgs); if (tab === activeTab()) scrollEnd();
6258
+ scheduleSessionSnapshotSave(tab);
6030
6259
  }
6031
6260
 
6032
6261
  function scrollEnd() {
@@ -6357,6 +6586,10 @@ function runSlashCommand(tab, raw) {
6357
6586
  }
6358
6587
 
6359
6588
  function send({ shift = false } = {}) {
6589
+ if (_cliInstalled === null) {
6590
+ notify({ type: NOTIF_TYPE.WARN, title: 'Claude CLI not installed', body: 'Install the Claude Code CLI before sending a message.' });
6591
+ return;
6592
+ }
6360
6593
  const tab = activeTab();
6361
6594
  if (!tab) return;
6362
6595
  const $input = _panel?.querySelector('#cp-input');
@@ -7059,6 +7292,70 @@ function syncReservedWidth() {
7059
7292
  }
7060
7293
  }
7061
7294
 
7295
+ // ── CLI installation status (banner + send-block) ──
7296
+ function _renderCliMissingBanner(emptyEl) {
7297
+ if (!emptyEl) return;
7298
+ const label = getCliLabel('claude-code');
7299
+ const cmd = getCliInstallCommand('claude-code');
7300
+ const url = getCliDocUrl('claude-code');
7301
+ const hint = emptyEl.querySelector('.cp-empty-hint');
7302
+ if (hint) hint.style.display = 'none';
7303
+ let banner = emptyEl.querySelector('.cp-cli-missing');
7304
+ if (!banner) {
7305
+ banner = document.createElement('div');
7306
+ banner.className = 'cp-cli-missing';
7307
+ banner.innerHTML = `
7308
+ <div class="cp-cli-missing-title">${label} CLI not installed</div>
7309
+ <div class="cp-cli-missing-body">Run: <code>${cmd}</code></div>
7310
+ <div class="cp-cli-missing-actions">
7311
+ <a class="cp-cli-missing-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
7312
+ <button class="cp-cli-missing-recheck" type="button">Re-check</button>
7313
+ </div>
7314
+ `;
7315
+ emptyEl.appendChild(banner);
7316
+ const btn = banner.querySelector('.cp-cli-missing-recheck');
7317
+ btn?.addEventListener('click', async () => {
7318
+ btn.disabled = true;
7319
+ btn.textContent = 'Checking…';
7320
+ try { await recheckCliStatus('claude-code'); }
7321
+ finally {
7322
+ if (btn.isConnected) {
7323
+ btn.disabled = false;
7324
+ btn.textContent = 'Re-check';
7325
+ }
7326
+ }
7327
+ });
7328
+ }
7329
+ }
7330
+
7331
+ function _refreshCliBanners() {
7332
+ if (!_panel) return;
7333
+ const empties = _panel.querySelectorAll('.cp-empty');
7334
+ if (_cliInstalled === null) {
7335
+ _panel.classList.add('cp-cli-blocked');
7336
+ for (const el of empties) _renderCliMissingBanner(el);
7337
+ } else {
7338
+ _panel.classList.remove('cp-cli-blocked');
7339
+ for (const el of empties) {
7340
+ const banner = el.querySelector('.cp-cli-missing');
7341
+ if (banner) banner.remove();
7342
+ const hint = el.querySelector('.cp-empty-hint');
7343
+ if (hint) hint.style.display = '';
7344
+ }
7345
+ }
7346
+ // Re-sync send button state — installed transition should re-enable input drafts
7347
+ const $send = _panel.querySelector('#cp-send');
7348
+ if ($send && _cliInstalled === null) $send.disabled = true;
7349
+ }
7350
+
7351
+ function _ensureCliSubscription() {
7352
+ if (_cliUnsub) return;
7353
+ _cliUnsub = subscribeCliStatus('claude-code', (info) => {
7354
+ _cliInstalled = info?.installed || null;
7355
+ _refreshCliBanners();
7356
+ });
7357
+ }
7358
+
7062
7359
  // ── Public API ──
7063
7360
  export async function toggleClaudePanel() {
7064
7361
  if (!_panel) {
@@ -7073,6 +7370,8 @@ export async function toggleClaudePanel() {
7073
7370
  syncSessionCosts();
7074
7371
  _wireBrowserSync();
7075
7372
  _startHeartbeat();
7373
+ window.dispatchEvent(new CustomEvent('sidepanel-tray:provider-loaded', { detail: { provider: 'claude' } }));
7374
+ _ensureCliSubscription();
7076
7375
  }
7077
7376
  _visible = !_visible;
7078
7377
  if (_visible) {
@@ -7081,6 +7380,7 @@ export async function toggleClaudePanel() {
7081
7380
  _panel.classList.add('open');
7082
7381
  syncReservedWidth();
7083
7382
  _panel.querySelector('#cp-input')?.focus();
7383
+ _refreshCliBanners();
7084
7384
  } else {
7085
7385
  const tab = activeTab();
7086
7386
  const $input = _panel?.querySelector('#cp-input');
@@ -7217,13 +7517,24 @@ async function loadSkills() {
7217
7517
  { name: 'loop', description: 'Run a command on a recurring interval' },
7218
7518
  ];
7219
7519
  for (const b of extras) { if (!seen.has(b.name)) { _skillsCache.push(b); seen.add(b.name); } }
7520
+ try {
7521
+ const pc = await fetch('/api/claude-code/plugin-commands').then(r => r.json());
7522
+ for (const c of (pc?.commands || [])) {
7523
+ if (!c?.name || seen.has(c.name)) continue;
7524
+ const label = c.plugin ? `${c.description || ''}${c.description ? ' · ' : ''}(plugin: ${c.plugin})` : (c.description || '');
7525
+ _skillsCache.push({ name: c.name, description: label });
7526
+ seen.add(c.name);
7527
+ }
7528
+ } catch {}
7220
7529
  _skillsCache.sort((a, b) => a.name.localeCompare(b.name));
7221
7530
  } catch { _skillsCache = SLASH_COMMANDS.map(c => ({ name: c.name, description: c.desc })); }
7222
7531
  return _skillsCache;
7223
7532
  }
7224
- function showSlashHints(filter) {
7533
+ async function showSlashHints(filter) {
7225
7534
  const $hints = _panel?.querySelector('#cp-slash-hints');
7226
- if (!$hints || !_skillsCache) return;
7535
+ if (!$hints) return;
7536
+ if (!_skillsCache) await loadSkills();
7537
+ if (!_skillsCache) return;
7227
7538
  const q = filter.toLowerCase();
7228
7539
  const matches = _skillsCache.filter(s => s.name.toLowerCase().startsWith(q));
7229
7540
  if (!matches.length || !filter) { hideSlashHints(); return; }
@@ -8210,11 +8521,7 @@ function wireEvents() {
8210
8521
  if (source && source !== 'claude') return;
8211
8522
  const tab = activeTab();
8212
8523
  if (!tab || (tabId && tab.id !== tabId)) return;
8213
- // Restore post-plan card interactivity
8214
- tab.messagesEl?.querySelectorAll('.post-plan-card').forEach(card => {
8215
- card.style.opacity = '1';
8216
- card.style.pointerEvents = 'auto';
8217
- });
8524
+ renderPostPlanActions(tab, tab._editedPlanContent ? 'PLAN UPDATED' : 'PLAN COMPLETE');
8218
8525
  });
8219
8526
 
8220
8527
  on('changelog-edit-cancelled', () => {
@@ -41,11 +41,28 @@ let _planEditMode = false; // true when editing a plan file from Claude p
41
41
  let _planEditFilePath = null; // path of the plan file being edited
42
42
  let _planEditSource = null; // panel that opened the plan editor
43
43
  let _planEditTabId = null; // tab id to route plan editor events back
44
- let _changelogEditMode = false; // true when editing a changelog draft from Claude panel
45
- let _changelogEditFilePath = null; // path of the changelog draft being edited
44
+ let _changelogEditMode = false; // true when editing a changelog draft from Claude panel
45
+ let _changelogEditFilePath = null; // path of the changelog draft being edited
46
46
 
47
47
  const MIN_WIDTH = 240;
48
- const MAX_WIDTH = 500;
48
+ const MAX_WIDTH = 500;
49
+
50
+ function planEditorSourceLabel(source) {
51
+ if (source === 'codex') return 'Codex';
52
+ if (source === 'opencode') return 'OpenCode';
53
+ return 'Claude';
54
+ }
55
+
56
+ function cancelPlanEditMode() {
57
+ if (!_planEditMode) return;
58
+ const source = _planEditSource;
59
+ const tabId = _planEditTabId;
60
+ _planEditMode = false;
61
+ _planEditFilePath = null;
62
+ _planEditSource = null;
63
+ _planEditTabId = null;
64
+ emit('plan-edit-cancelled', { source, tabId });
65
+ }
49
66
 
50
67
  // ─── SVG icons ────────────────────────
51
68
  const FOLDER_SVG = '<svg viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
@@ -1779,12 +1796,14 @@ async function openFileEditor(filePath, opts = {}) {
1779
1796
  if (!res.ok) {
1780
1797
  const isOpencodeConfig = normalizedPath.endsWith('/.config/opencode/config.json');
1781
1798
  const isAllowlistMiss = res.status === 403 && data?.error === 'Path outside registered project roots';
1782
- if (isOpencodeConfig && isAllowlistMiss) {
1783
- showToast('Restart Synabun to apply OpenCode config editor support');
1784
- return;
1785
- }
1786
- showToast(data.error || 'Cannot open file');
1787
- return;
1799
+ if (isOpencodeConfig && isAllowlistMiss) {
1800
+ showToast('Restart Synabun to apply OpenCode config editor support');
1801
+ cancelPlanEditMode();
1802
+ return;
1803
+ }
1804
+ showToast(data.error || 'Cannot open file');
1805
+ cancelPlanEditMode();
1806
+ return;
1788
1807
  }
1789
1808
 
1790
1809
  _editorOpen = true;
@@ -1843,11 +1862,11 @@ async function openFileEditor(filePath, opts = {}) {
1843
1862
  // Plan/changelog edit banner
1844
1863
  const edPanel = $('fe-editor-panel');
1845
1864
  edPanel?.querySelector('.fe-plan-banner')?.remove();
1846
- if (_planEditMode) {
1847
- const banner = document.createElement('div');
1848
- banner.className = 'fe-plan-banner';
1849
- banner.textContent = 'Editing plan — save to send to Claude';
1850
- edPanel?.prepend(banner);
1865
+ if (_planEditMode) {
1866
+ const banner = document.createElement('div');
1867
+ banner.className = 'fe-plan-banner';
1868
+ banner.textContent = `Editing plan — save to send to ${planEditorSourceLabel(_planEditSource)}`;
1869
+ edPanel?.prepend(banner);
1851
1870
  } else if (_changelogEditMode) {
1852
1871
  const banner = document.createElement('div');
1853
1872
  banner.className = 'fe-plan-banner';
@@ -1866,10 +1885,11 @@ async function openFileEditor(filePath, opts = {}) {
1866
1885
  updateScrollmap();
1867
1886
  syncHighlightScroll();
1868
1887
  });
1869
- } catch (err) {
1870
- showToast('Failed to open file');
1871
- }
1872
- }
1888
+ } catch (err) {
1889
+ cancelPlanEditMode();
1890
+ showToast('Failed to open file');
1891
+ }
1892
+ }
1873
1893
 
1874
1894
  function closeFileEditor() {
1875
1895
  if (_editorDirty) {