synabun 2026.4.21 → 2026.4.25-beta.1

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 (59) hide show
  1. package/CHANGELOG.md +161 -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 +44 -14
  22. package/neural-interface/public/shared/cdx/cdx-render.js +170 -43
  23. package/neural-interface/public/shared/cdx/cdx-styles.js +23 -10
  24. package/neural-interface/public/shared/cdx/cdx-tabs.js +384 -70
  25. package/neural-interface/public/shared/constants.js +3 -2
  26. package/neural-interface/public/shared/html-shell.js +32 -11
  27. package/neural-interface/public/shared/ocp/ocp-panel.js +45 -28
  28. package/neural-interface/public/shared/ocp/ocp-render.js +50 -43
  29. package/neural-interface/public/shared/ocp/ocp-tabs.js +693 -82
  30. package/neural-interface/public/shared/styles.css +635 -0
  31. package/neural-interface/public/shared/ui-automation-studio.js +38 -12
  32. package/neural-interface/public/shared/ui-claude-panel.js +216 -21
  33. package/neural-interface/public/shared/ui-file-explorer.js +38 -18
  34. package/neural-interface/public/shared/ui-menubar.js +34 -10
  35. package/neural-interface/public/shared/ui-navbar.js +8 -6
  36. package/neural-interface/public/shared/ui-schedules-studio.js +1464 -0
  37. package/neural-interface/public/shared/ui-settings.js +274 -50
  38. package/neural-interface/public/shared/ui-sidepanel-layout.js +18 -7
  39. package/neural-interface/public/shared/ui-sidepanel-tray.js +287 -0
  40. package/neural-interface/public/shared/ui-sync.js +9 -5
  41. package/neural-interface/public/shared/ui-terminal.js +76 -27
  42. package/neural-interface/public/shared/ui-tool-updates.js +49 -15
  43. package/neural-interface/public/shared/ui-tutorial-steps.js +4 -2
  44. package/neural-interface/public/shared/ui-tutorial.js +24 -18
  45. package/neural-interface/public/shared/ui-whiteboard.js +79 -31
  46. package/neural-interface/public/shared/vterm-buffer.js +9 -8
  47. package/neural-interface/public/variant/2d/main.js +9 -7
  48. package/neural-interface/public/variant/3d/main.js +10 -8
  49. package/neural-interface/server.js +1281 -252
  50. package/package.json +1 -1
  51. package/skills/leonardo/SKILL.md +5 -1
  52. package/skills/leonardo/modules/image-prompter.md +5 -1
  53. package/skills/leonardo/modules/video-prompter.md +5 -1
  54. package/skills/synabun/SKILL.md +44 -26
  55. package/skills/synabun/modules/audit.md +40 -36
  56. package/skills/synabun/modules/changelog.md +22 -18
  57. package/skills/synabun/modules/memorize.md +19 -15
  58. package/skills/synabun/modules/schedule.md +285 -0
  59. package/CLAUDE.md +0 -217
@@ -21,6 +21,7 @@ import {
21
21
  reorderLoopFolders,
22
22
  fetchActiveLoop,
23
23
  launchLoop,
24
+ sendClientLoopLog,
24
25
  stopLoop,
25
26
  launchAgent,
26
27
  fetchAgents,
@@ -181,7 +182,8 @@ const CLI_MODELS = {
181
182
  { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Fastest' },
182
183
  ],
183
184
  'codex': [
184
- { id: 'gpt-5.4', label: 'GPT-5.4', desc: 'Most capable', tier: 'top' },
185
+ { id: 'gpt-5.5', label: 'GPT-5.5', desc: 'Most capable', tier: 'top' },
186
+ { id: 'gpt-5.4', label: 'GPT-5.4', desc: 'Strong general' },
185
187
  { id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', desc: 'Fast & cheap', tier: 'default' },
186
188
  { id: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', desc: 'Codex-tuned' },
187
189
  ],
@@ -1469,15 +1471,30 @@ async function confirmLaunch() {
1469
1471
  // Launch as floating terminal + floating browser
1470
1472
  showToast('Launching loop...');
1471
1473
  emit('terminal:expect-managed');
1474
+ sendClientLoopLog(null, 'launch:client', 'confirmLaunch about to POST /api/loop/launch', {
1475
+ profile: params.profile,
1476
+ usesBrowser: !!params.usesBrowser,
1477
+ iterations: params.iterations,
1478
+ maxMinutes: params.maxMinutes,
1479
+ taskLen: (params.task || '').length,
1480
+ cwd: params.cwd || null,
1481
+ browserSessionId: params.browserSessionId || null,
1482
+ });
1472
1483
  console.log('[AS] confirmLaunch: calling launchLoop, profile =', params.profile, ', usesBrowser =', params.usesBrowser);
1473
1484
  const result = await launchLoop(params);
1474
1485
  console.log('[AS] confirmLaunch: launchLoop result =', JSON.stringify(result));
1486
+ sendClientLoopLog(result?.terminalSessionId || null, 'launch:client', 'launchLoop response received', { result });
1475
1487
  if (!result?.ok) {
1476
1488
  emit('terminal:attach-floating', {});
1477
1489
  showToast(result?.error || 'Failed to launch loop');
1478
1490
  return;
1479
1491
  }
1480
1492
  console.log('[AS] confirmLaunch: emitting terminal:attach-floating, terminalSessionId =', result.terminalSessionId);
1493
+ sendClientLoopLog(result.terminalSessionId, 'launch:client', 'emit terminal:attach-floating', {
1494
+ terminalSessionId: result.terminalSessionId,
1495
+ profile: params.profile || 'claude-code',
1496
+ initialMessage: '[SynaBun Loop] Begin task.',
1497
+ });
1481
1498
  emit('terminal:attach-floating', {
1482
1499
  terminalSessionId: result.terminalSessionId,
1483
1500
  profile: params.profile || 'claude-code',
@@ -1491,7 +1508,9 @@ async function confirmLaunch() {
1491
1508
  // Clear the managed-terminal flag since we won't emit terminal:attach-floating
1492
1509
  emit('terminal:attach-floating', {});
1493
1510
  console.error('[AS] confirmLaunch: caught error:', err);
1511
+ sendClientLoopLog(null, 'launch:client', 'confirmLaunch caught error', { err: err?.message || String(err) });
1494
1512
  showToast('Launch failed: ' + (err.message || 'unknown error'));
1513
+ try { alert(`[SynaBun] Loop launch failed: ${err?.message || err || 'unknown error'}\n\nOpen DevTools Console for stack trace.`); } catch {}
1495
1514
  }
1496
1515
  }
1497
1516
 
@@ -1541,20 +1560,25 @@ function buildTemplateCommand(template) {
1541
1560
  // ═══════════════════════════════════════════
1542
1561
 
1543
1562
  export function initAutomationStudio() {
1544
- on('automations:open', () => openPanel());
1563
+ on('automations:open', (opts) => openPanel(opts || {}));
1545
1564
  on('automations:open-wizard', () => { openPanel(); });
1546
- on('automations:open-schedules', () => { openPanel().then(() => { _view = 'schedules'; _selected = null; loadScheduleData().then(() => renderView()); }); });
1547
1565
  on('automations:import', () => { openPanel().then(() => triggerImport()); });
1548
1566
  setupAgentWebSocket();
1549
- setupScheduleWebSocket();
1550
1567
  }
1551
1568
 
1552
- async function openPanel() {
1569
+ async function openPanel(opts = {}) {
1553
1570
  if (isGuest() && !hasPermission('automations')) {
1554
1571
  showGuestToast('Automation Studio is disabled by the host');
1555
1572
  return;
1556
1573
  }
1557
- if (_panel) { _panel.focus(); return; }
1574
+ if (_panel) {
1575
+ if (opts.templateId) {
1576
+ const tpl = _templates.find(t => t.id === opts.templateId);
1577
+ if (tpl) switchToDetail(tpl);
1578
+ }
1579
+ _panel.focus();
1580
+ return;
1581
+ }
1558
1582
 
1559
1583
  // Backdrop
1560
1584
  _backdrop = document.createElement('div');
@@ -1574,7 +1598,12 @@ async function openPanel() {
1574
1598
 
1575
1599
  wirePanel();
1576
1600
  await loadData();
1577
- renderView();
1601
+ if (opts.templateId) {
1602
+ const tpl = _templates.find(t => t.id === opts.templateId);
1603
+ if (tpl) switchToDetail(tpl); else renderView();
1604
+ } else {
1605
+ renderView();
1606
+ }
1578
1607
  requestAnimationFrame(() => { _backdrop.classList.add('open'); _panel.classList.add('open'); });
1579
1608
  startPolling();
1580
1609
  }
@@ -1659,7 +1688,7 @@ function wirePanel() {
1659
1688
  $('as-close')?.addEventListener('click', closePanel);
1660
1689
  $('as-new-btn')?.addEventListener('click', () => { _view = 'picker'; _selected = null; renderView(); });
1661
1690
  $('as-new-folder-btn')?.addEventListener('click', (e) => openFolderPopover(e.currentTarget, null));
1662
- $('as-schedules-btn')?.addEventListener('click', () => { _view = 'schedules'; _selected = null; loadScheduleData().then(() => renderView()); });
1691
+ $('as-schedules-btn')?.addEventListener('click', () => emit('automations:open-schedules'));
1663
1692
  $('as-import-btn')?.addEventListener('click', () => triggerImport());
1664
1693
 
1665
1694
  $('as-sidebar-toggle')?.addEventListener('click', () => {
@@ -1721,8 +1750,6 @@ function wirePanel() {
1721
1750
  const onEsc = (e) => {
1722
1751
  if (e.key === 'Escape' && _panel) {
1723
1752
  if (_view === 'launch') { closeLaunchInline(); return; }
1724
- if (_view === 'schedule-editor') { _view = 'schedules'; _editingSchedule = null; renderView(); return; }
1725
- if (_view === 'schedules') { _view = 'welcome'; renderView(); return; }
1726
1753
  if (_view === 'wizard') { _view = _selected ? 'detail' : 'welcome'; renderView(); return; }
1727
1754
  if (_view === 'detail' || _view === 'running' || _view === 'picker') {
1728
1755
  if (_detailDirty && !confirm('Discard unsaved changes?')) return;
@@ -2233,8 +2260,6 @@ function renderView() {
2233
2260
  case 'detail': renderDetailMain(); break;
2234
2261
  case 'wizard': renderWizardMain(); break;
2235
2262
  case 'running': renderRunningMain(); break;
2236
- case 'schedules': renderSchedulesMain(); break;
2237
- case 'schedule-editor': renderScheduleEditorMain(); break;
2238
2263
  case 'launch': break; // launch panel is rendered by showLaunchInline
2239
2264
  }
2240
2265
  }
@@ -3413,6 +3438,7 @@ function renderRunningMain() {
3413
3438
  </div>
3414
3439
  <div class="as-running-controls">
3415
3440
  <button class="as-btn-stop" data-action="force-stop" data-id="${loop.terminalSessionId || ''}">Stop</button>
3441
+ ${loop.terminalSessionId ? `<a class="as-btn-stop" style="margin-left:8px;text-decoration:none;display:inline-flex;align-items:center;" href="/api/loop/log/${encodeURIComponent(loop.terminalSessionId)}" target="_blank" rel="noopener">View Loop Log</a>` : ''}
3416
3442
  </div>
3417
3443
  </div>`;
3418
3444
  }
@@ -150,8 +150,24 @@ const STOR = {
150
150
  windowRegistry: 'synabun-claude-panel-windows', // JSON map of windowId → lastSeen timestamp
151
151
  bootId: 'synabun-claude-panel-boot-id', // server boot ID — detect restarts
152
152
  defaultModel: 'synabun-claude-panel-default-model', // user's preferred default model
153
+ sessionSnapshots: 'synabun-claude-panel-session-snapshots', // global, per-sessionId rendered HTML cache
153
154
  };
154
155
 
156
+ // ── Session HTML snapshot cache ──
157
+ // Global per-sessionId cache of rendered transcript HTML so resume on a fresh
158
+ // browser load mirrors the exact render that was produced during the live run.
159
+ // Falls back to upstream JSONL rebuild when no snapshot exists or it is stale.
160
+ const MAX_SESSION_SNAPSHOTS = 24;
161
+ const MAX_SESSION_SNAPSHOT_CHARS = 2_500_000;
162
+ let _sessionSnapshots = (() => {
163
+ try {
164
+ const raw = storage.getItem(STOR.sessionSnapshots);
165
+ if (!raw) return {};
166
+ const parsed = JSON.parse(raw);
167
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
168
+ } catch { return {}; }
169
+ })();
170
+
155
171
  const EFFORT_LEVELS = ['off', 'low', 'medium', 'high', 'max'];
156
172
  const EFFORT_LABELS = { off: 'Think', low: 'lo', medium: 'med', high: 'hi', max: 'max' };
157
173
  const EFFORT_TITLES = {
@@ -1076,7 +1092,7 @@ function injectStyles() {
1076
1092
  padding: 1px;
1077
1093
  background: rgba(255,255,255,0.05);
1078
1094
  transition: background 0.4s;
1079
- min-width: 0; overflow: hidden;
1095
+ min-width: 0;
1080
1096
  }
1081
1097
  .cp-input-wrap::before {
1082
1098
  content: '';
@@ -1476,7 +1492,8 @@ function injectStyles() {
1476
1492
  backdrop-filter: blur(20px);
1477
1493
  border: 1px solid rgba(255,255,255,0.08);
1478
1494
  border-radius: 10px; padding: 4px;
1479
- z-index: 310; max-height: 200px; overflow-y: auto;
1495
+ z-index: 310; max-height: 320px; overflow-y: auto;
1496
+ overscroll-behavior: contain;
1480
1497
  box-shadow: var(--shadow-lg);
1481
1498
  }
1482
1499
  .cp-slash-hints.open { display: block; }
@@ -2632,14 +2649,14 @@ document.addEventListener('click', (e) => {
2632
2649
  });
2633
2650
 
2634
2651
  // ── Tab lifecycle ──
2635
- function createTab(sessionId = null, label = 'New chat', { autoSwitch = true } = {}) {
2652
+ function createTab(sessionId = null, label = 'New chat', { autoSwitch = true, id = null } = {}) {
2636
2653
  if (_tabs.length >= MAX_TABS) return null;
2637
2654
  // Capture current dropdown state for the new tab's project/model/effort
2638
2655
  const _$proj = _panel?.querySelector('#cp-project');
2639
2656
  const _$mod = _panel?.querySelector('#cp-model');
2640
2657
  const _$think = _panel?.querySelector('#cp-think-toggle');
2641
2658
  const tab = {
2642
- id: crypto.randomUUID(),
2659
+ id: id || crypto.randomUUID(),
2643
2660
  sessionId,
2644
2661
  label,
2645
2662
  project: ddGetValue(_$proj) || '', // per-tab project isolation
@@ -2729,6 +2746,7 @@ function switchTab(idx) {
2729
2746
  if (idx < 0 || idx >= _tabs.length) return;
2730
2747
  const prev = activeTab();
2731
2748
  if (prev && prev !== _tabs[idx]) {
2749
+ flushSessionSnapshotSave(prev);
2732
2750
  const $input = _panel?.querySelector('#cp-input');
2733
2751
  if ($input) prev.draft = $input.value;
2734
2752
  // Animate out
@@ -2818,6 +2836,7 @@ function switchTab(idx) {
2818
2836
  function closeTab(idx) {
2819
2837
  if (idx < 0 || idx >= _tabs.length) return;
2820
2838
  const tab = _tabs[idx];
2839
+ flushSessionSnapshotSave(tab);
2821
2840
  recordHookEvent(tab, 'SessionEnd', tab.sessionId || '(new)');
2822
2841
  tab.closed = true;
2823
2842
  clearTimeout(tab.reconnectTimer);
@@ -2956,7 +2975,7 @@ function restoreTabs() {
2956
2975
  const data = JSON.parse(raw);
2957
2976
  if (data.tabs?.length) {
2958
2977
  for (const saved of data.tabs) {
2959
- const t = createTab(saved.sessionId, saved.label, { autoSwitch: false });
2978
+ const t = createTab(saved.sessionId, saved.label, { autoSwitch: false, id: saved.id });
2960
2979
  if (t && saved.sessionCost) t.sessionCost = saved.sessionCost;
2961
2980
  if (t && saved.running) t._wasRunning = true; // trigger reattach on WS open
2962
2981
  if (t && saved.project) t.project = saved.project;
@@ -2979,7 +2998,7 @@ function restoreTabs() {
2979
2998
  const data = JSON.parse(legacyRaw);
2980
2999
  if (data.tabs?.length) {
2981
3000
  for (const saved of data.tabs) {
2982
- const t = createTab(saved.sessionId, saved.label, { autoSwitch: false });
3001
+ const t = createTab(saved.sessionId, saved.label, { autoSwitch: false, id: saved.id });
2983
3002
  if (t && saved.sessionCost) t.sessionCost = saved.sessionCost;
2984
3003
  if (t && saved.running) t._wasRunning = true;
2985
3004
  if (t && saved.project) t.project = saved.project;
@@ -3055,6 +3074,7 @@ async function _releaseAllSessionLocks() {
3055
3074
 
3056
3075
  // Release locks when window closes
3057
3076
  window.addEventListener('beforeunload', () => {
3077
+ flushAllSessionSnapshots();
3058
3078
  _stopHeartbeat();
3059
3079
  // Use sendBeacon for reliable delivery on close
3060
3080
  navigator.sendBeacon(
@@ -3063,6 +3083,11 @@ window.addEventListener('beforeunload', () => {
3063
3083
  );
3064
3084
  });
3065
3085
 
3086
+ window.addEventListener('pagehide', flushAllSessionSnapshots);
3087
+ document.addEventListener('visibilitychange', () => {
3088
+ if (document.hidden) flushAllSessionSnapshots();
3089
+ });
3090
+
3066
3091
  // Re-sync UI state when window regains focus (catches stale running states from background tabs)
3067
3092
  window.addEventListener('focus', () => {
3068
3093
  if (!_panel) return;
@@ -3713,9 +3738,151 @@ function promptNameNewSession() {
3713
3738
  });
3714
3739
  }
3715
3740
 
3741
+ // ── Session HTML snapshot helpers ──
3742
+ function _normalizeSnapshotEntry(entry) {
3743
+ if (!entry || typeof entry !== 'object') return null;
3744
+ const html = typeof entry.html === 'string' ? entry.html : '';
3745
+ if (!html) return null;
3746
+ return {
3747
+ html,
3748
+ updatedAt: Number(entry.updatedAt) || 0,
3749
+ label: typeof entry.label === 'string' ? entry.label : '',
3750
+ usage: (entry.usage && typeof entry.usage === 'object') ? entry.usage : null,
3751
+ contextWindow: Number(entry.contextWindow) || 0,
3752
+ turns: Number(entry.turns) || 0,
3753
+ sessionCost: Number(entry.sessionCost) || 0,
3754
+ itemCount: Number(entry.itemCount) || 0,
3755
+ };
3756
+ }
3757
+
3758
+ function _persistSessionSnapshots() {
3759
+ try {
3760
+ const source = (_sessionSnapshots && typeof _sessionSnapshots === 'object' && !Array.isArray(_sessionSnapshots))
3761
+ ? _sessionSnapshots : {};
3762
+ const entries = Object.entries(source)
3763
+ .map(([sid, e]) => [sid, _normalizeSnapshotEntry(e)])
3764
+ .filter(([, e]) => !!e)
3765
+ .sort(([, a], [, b]) => (b.updatedAt || 0) - (a.updatedAt || 0));
3766
+ const next = {};
3767
+ let totalChars = 0;
3768
+ for (const [sid, entry] of entries) {
3769
+ const size = entry.html.length;
3770
+ if (Object.keys(next).length >= MAX_SESSION_SNAPSHOTS) break;
3771
+ if (totalChars + size > MAX_SESSION_SNAPSHOT_CHARS && Object.keys(next).length) continue;
3772
+ totalChars += size;
3773
+ next[sid] = entry;
3774
+ }
3775
+ _sessionSnapshots = next;
3776
+ storage.setItem(STOR.sessionSnapshots, JSON.stringify(next));
3777
+ } catch {}
3778
+ }
3779
+
3780
+ function getSessionSnapshot(sid) {
3781
+ if (!sid) return null;
3782
+ return _normalizeSnapshotEntry(_sessionSnapshots?.[sid]);
3783
+ }
3784
+
3785
+ function writeSessionSnapshot(tab) {
3786
+ const sid = tab?.sessionId || null;
3787
+ const $msgs = tab?.messagesEl || null;
3788
+ if (!sid || !$msgs) return;
3789
+ const html = $msgs.innerHTML || '';
3790
+ if (!html.trim()) return;
3791
+ // Skip snapshot if the transcript only contains the empty-state placeholder
3792
+ if ($msgs.children.length === 1 && $msgs.firstElementChild?.classList?.contains('cp-empty')) return;
3793
+ if (!_sessionSnapshots || typeof _sessionSnapshots !== 'object' || Array.isArray(_sessionSnapshots)) {
3794
+ _sessionSnapshots = {};
3795
+ }
3796
+ const itemCount = [...$msgs.children].filter(n =>
3797
+ !n.classList?.contains('cp-empty') && !n.classList?.contains('thinking')
3798
+ ).length;
3799
+ _sessionSnapshots[sid] = {
3800
+ html,
3801
+ updatedAt: Date.now(),
3802
+ label: tab.label || '',
3803
+ usage: tab.usage ? { ...tab.usage } : null,
3804
+ contextWindow: tab.contextWindow || 0,
3805
+ turns: tab.turns || 0,
3806
+ sessionCost: tab.sessionCost || 0,
3807
+ itemCount,
3808
+ };
3809
+ _persistSessionSnapshots();
3810
+ }
3811
+
3812
+ function scheduleSessionSnapshotSave(tab, ms = 350) {
3813
+ if (!tab?.sessionId || !tab?.messagesEl) return;
3814
+ if (tab._snapshotTimer) clearTimeout(tab._snapshotTimer);
3815
+ tab._snapshotTimer = setTimeout(() => {
3816
+ tab._snapshotTimer = null;
3817
+ writeSessionSnapshot(tab);
3818
+ }, ms);
3819
+ }
3820
+
3821
+ function flushSessionSnapshotSave(tab) {
3822
+ if (!tab) return;
3823
+ if (tab._snapshotTimer) { clearTimeout(tab._snapshotTimer); tab._snapshotTimer = null; }
3824
+ writeSessionSnapshot(tab);
3825
+ }
3826
+
3827
+ function flushAllSessionSnapshots() {
3828
+ for (const tab of _tabs) flushSessionSnapshotSave(tab);
3829
+ }
3830
+
3831
+ function renderStoredSession(snapshot, $msgs, tab) {
3832
+ const norm = _normalizeSnapshotEntry(snapshot);
3833
+ if (!norm || !$msgs) return false;
3834
+ $msgs.innerHTML = norm.html;
3835
+ // Disable interactive controls — restored DOM has no event handlers and
3836
+ // any pending state (permission prompts, plan cards, ask cards) is stale.
3837
+ $msgs.querySelectorAll('button, input, select, textarea').forEach(node => { node.disabled = true; });
3838
+ // Drop transient overlays that should never resurrect after a fresh load
3839
+ $msgs.querySelectorAll('.thinking, .msg-permission-prompt, .post-plan-card').forEach(n => n.remove());
3840
+ // Re-attach copy buttons + file path links — innerHTML wipes their listeners
3841
+ $msgs.querySelectorAll('pre .cp-copy-btn').forEach(btn => btn.remove());
3842
+ addCopyButtons($msgs);
3843
+ linkifyFilePaths($msgs);
3844
+ if (tab) {
3845
+ if (norm.usage) {
3846
+ tab.usage.inputTokens = norm.usage.inputTokens || 0;
3847
+ tab.usage.outputTokens = norm.usage.outputTokens || 0;
3848
+ tab.usage.cacheRead = norm.usage.cacheRead || 0;
3849
+ tab.usage.cacheWrite = norm.usage.cacheWrite || 0;
3850
+ }
3851
+ if (norm.contextWindow) tab.contextWindow = norm.contextWindow;
3852
+ if (norm.turns) tab.turns = norm.turns;
3853
+ if (norm.sessionCost) tab.sessionCost = norm.sessionCost;
3854
+ renderGauge(tab);
3855
+ }
3856
+ return true;
3857
+ }
3858
+
3716
3859
  async function loadSessionHistory(sid, $msgs) {
3717
3860
  if (!$msgs) $msgs = activeTab()?.messagesEl;
3718
3861
  if (!$msgs) return;
3862
+ // Try local snapshot first — exact replay of the live render, lossless.
3863
+ const snap = getSessionSnapshot(sid);
3864
+ if (snap && snap.html) {
3865
+ const tab = _tabs.find(t => t.messagesEl === $msgs) || activeTab();
3866
+ if (renderStoredSession(snap, $msgs, tab)) {
3867
+ scrollEnd();
3868
+ // Background refresh: pull latest JSONL in case the session continued in
3869
+ // another window. If upstream has more turns than the snapshot, fall back
3870
+ // to the rebuild path below so the resumed view is current.
3871
+ try {
3872
+ const $project = _panel?.querySelector('#cp-project');
3873
+ const project = ddGetValue($project) || undefined;
3874
+ const params = new URLSearchParams({ limit: '1' });
3875
+ if (project) params.set('project', project);
3876
+ const probe = await fetch(`/api/claude-code/sessions/${encodeURIComponent(sid)}/messages?${params}`);
3877
+ const probeData = await probe.json();
3878
+ if ((probeData?.total || 0) > (snap.itemCount || 0) + 2) {
3879
+ // Upstream has materially more content — fall through to full rebuild
3880
+ } else {
3881
+ return;
3882
+ }
3883
+ } catch { return; }
3884
+ }
3885
+ }
3719
3886
  const $project = _panel?.querySelector('#cp-project');
3720
3887
  const project = ddGetValue($project) || undefined;
3721
3888
  const params = new URLSearchParams({ limit: '500' });
@@ -3737,6 +3904,13 @@ async function loadSessionHistory(sid, $msgs) {
3737
3904
  const avatar = document.createElement('div'); avatar.className = 'msg-avatar';
3738
3905
  avatar.innerHTML = CLAUDE_ICON; el.appendChild(avatar);
3739
3906
  const wrap = document.createElement('div'); wrap.className = 'msg-content';
3907
+ if (m.thinking) {
3908
+ const thinkEl = document.createElement('details');
3909
+ thinkEl.className = 'msg-thinking';
3910
+ 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>`;
3911
+ thinkEl.querySelector('.msg-thinking-content').textContent = m.thinking;
3912
+ wrap.appendChild(thinkEl);
3913
+ }
3740
3914
  if (m.text) {
3741
3915
  const body = document.createElement('div'); body.className = 'msg-body';
3742
3916
  body._rawMd = m.text;
@@ -3788,7 +3962,7 @@ async function loadSessionHistory(sid, $msgs) {
3788
3962
  $msgs.prepend(note);
3789
3963
  }
3790
3964
  // Populate context gauge from session history usage
3791
- const tab = activeTab();
3965
+ const tab = _tabs.find(t => t.messagesEl === $msgs) || activeTab();
3792
3966
  if (tab && data.usage) {
3793
3967
  tab.usage.inputTokens = data.usage.input_tokens || 0;
3794
3968
  tab.usage.outputTokens = data.usage.output_tokens || 0;
@@ -3799,6 +3973,9 @@ async function loadSessionHistory(sid, $msgs) {
3799
3973
  renderGauge(tab);
3800
3974
  }
3801
3975
  scrollEnd();
3976
+ // Cache the rebuilt transcript so the next resume on a fresh browser hits
3977
+ // the snapshot path and avoids paying the JSONL → DOM cost again.
3978
+ if (tab && tab.sessionId === sid) writeSessionSnapshot(tab);
3802
3979
  } catch {
3803
3980
  $msgs.innerHTML = '<div class="msg-status">Failed to load history</div>';
3804
3981
  }
@@ -4261,6 +4438,7 @@ function handleTabEvent(tab, ev) {
4261
4438
  }
4262
4439
  if (ev.type === 'tool_result') {
4263
4440
  updateToolResult(tab, ev);
4441
+ scheduleSessionSnapshotSave(tab);
4264
4442
  // Re-show thinking — Claude is processing the tool result
4265
4443
  if (tab.running && !tab.pendingAsk) showThinking(tab);
4266
4444
  return;
@@ -4527,6 +4705,7 @@ function renderPostPlanActions(tab, headerText) {
4527
4705
 
4528
4706
  const actions = [
4529
4707
  { label: 'Continue with implementation', prompt: 'Continue with the implementation based on the approved plan.', primary: true },
4708
+ { label: 'Continue planning', action: 'continue-planning' },
4530
4709
  { label: 'Compact context', action: 'compact' },
4531
4710
  { label: 'Edit plan', action: 'plan' },
4532
4711
  ];
@@ -4537,7 +4716,11 @@ function renderPostPlanActions(tab, headerText) {
4537
4716
  btn.addEventListener('click', () => {
4538
4717
  card.style.opacity = '0.45';
4539
4718
  card.style.pointerEvents = 'none';
4540
- if (a.action === 'compact') {
4719
+ if (a.action === 'continue-planning') {
4720
+ tab.showPostPlanActions = false;
4721
+ saveTabs();
4722
+ return;
4723
+ } else if (a.action === 'compact') {
4541
4724
  // Guard: cannot compact while running
4542
4725
  if (tab.running) { appendStatus(tab, 'Cannot compact while Claude is processing.'); card.style.opacity = '1'; card.style.pointerEvents = 'auto'; return; }
4543
4726
  if (tab.ws?.readyState === WebSocket.OPEN) {
@@ -4879,6 +5062,7 @@ function renderAssistant(tab, msg) {
4879
5062
  $msgs.appendChild(postPlanMsg);
4880
5063
  }
4881
5064
  if (tab === activeTab()) scrollEnd();
5065
+ scheduleSessionSnapshotSave(tab);
4882
5066
  }
4883
5067
 
4884
5068
  /** Build an interactive AskUserQuestion card from a tool_use block */
@@ -5993,6 +6177,7 @@ function appendUser(tab, text, images, files) {
5993
6177
  if (text) { const txt = document.createTextNode(text); bubble.appendChild(txt); }
5994
6178
  el.appendChild(bubble); $msgs.appendChild(el);
5995
6179
  if (tab === activeTab()) scrollEnd();
6180
+ scheduleSessionSnapshotSave(tab);
5996
6181
  }
5997
6182
 
5998
6183
  function showThinking(tab) {
@@ -6022,11 +6207,13 @@ function appendStatus(tab, text) {
6022
6207
  const $msgs = tab.messagesEl; if (!$msgs) return;
6023
6208
  const el = document.createElement('div'); el.className = 'msg-status'; el.textContent = text;
6024
6209
  $msgs.appendChild(el); pruneMessages($msgs); if (tab === activeTab()) scrollEnd();
6210
+ scheduleSessionSnapshotSave(tab);
6025
6211
  }
6026
6212
  function appendError(tab, text) {
6027
6213
  const $msgs = tab.messagesEl; if (!$msgs) return;
6028
6214
  const el = document.createElement('div'); el.className = 'msg-error'; el.textContent = text;
6029
6215
  $msgs.appendChild(el); pruneMessages($msgs); if (tab === activeTab()) scrollEnd();
6216
+ scheduleSessionSnapshotSave(tab);
6030
6217
  }
6031
6218
 
6032
6219
  function scrollEnd() {
@@ -7068,12 +7255,13 @@ export async function toggleClaudePanel() {
7068
7255
  wireEvents();
7069
7256
  await loadConfig(); // must complete before restoreTabs — checks server boot ID
7070
7257
  loadSkills();
7071
- restoreTabs();
7072
- loadMonthlyCost();
7073
- syncSessionCosts();
7074
- _wireBrowserSync();
7075
- _startHeartbeat();
7076
- }
7258
+ restoreTabs();
7259
+ loadMonthlyCost();
7260
+ syncSessionCosts();
7261
+ _wireBrowserSync();
7262
+ _startHeartbeat();
7263
+ window.dispatchEvent(new CustomEvent('sidepanel-tray:provider-loaded', { detail: { provider: 'claude' } }));
7264
+ }
7077
7265
  _visible = !_visible;
7078
7266
  if (_visible) {
7079
7267
  if (_tabs.length === 0) createTab(null, 'New chat');
@@ -7217,13 +7405,24 @@ async function loadSkills() {
7217
7405
  { name: 'loop', description: 'Run a command on a recurring interval' },
7218
7406
  ];
7219
7407
  for (const b of extras) { if (!seen.has(b.name)) { _skillsCache.push(b); seen.add(b.name); } }
7408
+ try {
7409
+ const pc = await fetch('/api/claude-code/plugin-commands').then(r => r.json());
7410
+ for (const c of (pc?.commands || [])) {
7411
+ if (!c?.name || seen.has(c.name)) continue;
7412
+ const label = c.plugin ? `${c.description || ''}${c.description ? ' · ' : ''}(plugin: ${c.plugin})` : (c.description || '');
7413
+ _skillsCache.push({ name: c.name, description: label });
7414
+ seen.add(c.name);
7415
+ }
7416
+ } catch {}
7220
7417
  _skillsCache.sort((a, b) => a.name.localeCompare(b.name));
7221
7418
  } catch { _skillsCache = SLASH_COMMANDS.map(c => ({ name: c.name, description: c.desc })); }
7222
7419
  return _skillsCache;
7223
7420
  }
7224
- function showSlashHints(filter) {
7421
+ async function showSlashHints(filter) {
7225
7422
  const $hints = _panel?.querySelector('#cp-slash-hints');
7226
- if (!$hints || !_skillsCache) return;
7423
+ if (!$hints) return;
7424
+ if (!_skillsCache) await loadSkills();
7425
+ if (!_skillsCache) return;
7227
7426
  const q = filter.toLowerCase();
7228
7427
  const matches = _skillsCache.filter(s => s.name.toLowerCase().startsWith(q));
7229
7428
  if (!matches.length || !filter) { hideSlashHints(); return; }
@@ -8210,11 +8409,7 @@ function wireEvents() {
8210
8409
  if (source && source !== 'claude') return;
8211
8410
  const tab = activeTab();
8212
8411
  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
- });
8412
+ renderPostPlanActions(tab, tab._editedPlanContent ? 'PLAN UPDATED' : 'PLAN COMPLETE');
8218
8413
  });
8219
8414
 
8220
8415
  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) {