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
@@ -0,0 +1,287 @@
1
+ // SynaBun sidepanel tray placeholders.
2
+ // Renders minimized pills for persisted sidepanel tabs before a panel is booted.
3
+
4
+ import { storage } from './storage.js';
5
+ import { emit } from './state.js';
6
+ import { getProviderMeta } from './provider-icons.js';
7
+ import { toggleClaudePanel, isClaudePanelOpen } from './ui-claude-panel.js';
8
+ import { toggleCodexPanel, isCodexPanelOpen } from './ui-codex-panel.js';
9
+ import { toggleOpencodePanel, isOpencodePanelOpen } from './ui-opencode-panel.js';
10
+
11
+ const PLACEHOLDER_CLASS = 'sidepanel-tray-placeholder';
12
+
13
+ const PROVIDERS = {
14
+ claude: {
15
+ providerId: 'claude-code',
16
+ windowKey: 'cp-window-id',
17
+ tabsKeyPrefix: 'synabun-claude-panel-tabs',
18
+ pillClass: 'cp-session-pill',
19
+ eventName: 'claude-panel:show',
20
+ },
21
+ codex: {
22
+ providerId: 'codex',
23
+ windowKey: 'cxp-window-id',
24
+ tabsKeyPrefix: 'synabun-codex-panel-tabs',
25
+ pillClass: 'cxp-session-pill',
26
+ eventName: 'codex-panel:show',
27
+ },
28
+ opencode: {
29
+ providerId: 'opencode',
30
+ windowKey: 'ocp-window-id',
31
+ tabsKeyPrefix: 'synabun-ocp-tabs',
32
+ pillClass: 'ocp-session-pill',
33
+ eventName: 'opencode-panel:show',
34
+ },
35
+ };
36
+
37
+ const _loadedProviders = new Set();
38
+ let _initialized = false;
39
+
40
+ function esc(s) {
41
+ return String(s)
42
+ .replace(/&/g, '&')
43
+ .replace(/</g, '&lt;')
44
+ .replace(/>/g, '&gt;')
45
+ .replace(/"/g, '&quot;');
46
+ }
47
+
48
+ function parseJson(raw, fallback = null) {
49
+ try { return raw ? JSON.parse(raw) : fallback; } catch { return fallback; }
50
+ }
51
+
52
+ function ensureWindowId(key) {
53
+ let id = sessionStorage.getItem(key);
54
+ if (!id) {
55
+ id = crypto.randomUUID();
56
+ sessionStorage.setItem(key, id);
57
+ }
58
+ return id;
59
+ }
60
+
61
+ function scopedTabsKey(meta) {
62
+ return `${meta.tabsKeyPrefix}-${ensureWindowId(meta.windowKey)}`;
63
+ }
64
+
65
+ function normalizeLabel(value) {
66
+ return String(value || '').trim();
67
+ }
68
+
69
+ function isDefaultLabel(label, defaults) {
70
+ const normalized = normalizeLabel(label).toLowerCase();
71
+ return !normalized || defaults.includes(normalized);
72
+ }
73
+
74
+ function hasClaudeState(tab) {
75
+ const label = normalizeLabel(tab.label);
76
+ return !!(
77
+ tab.sessionId
78
+ || tab.running
79
+ || tab.sessionCost
80
+ || tab.planFilePath
81
+ || tab.queue?.length
82
+ || !isDefaultLabel(label, ['new chat', 'new session', 'claude'])
83
+ );
84
+ }
85
+
86
+ function hasCodexState(tab) {
87
+ const label = normalizeLabel(tab.pendingSessionLabel || tab.title || tab.sessionLabel);
88
+ const draft = normalizeLabel(tab.draft);
89
+ return !!(
90
+ tab.threadId
91
+ || tab.running
92
+ || tab.startingThread
93
+ || draft
94
+ || tab.planContent
95
+ || tab.editedPlanContent
96
+ || tab.showPostPlanActions
97
+ || tab.planApprovalPending
98
+ || tab.showPostCompactionPrompt
99
+ || tab.postCompactionPending
100
+ || tab.queue?.length
101
+ || !isDefaultLabel(label, ['new session', 'saved session', 'untitled session', 'codex'])
102
+ );
103
+ }
104
+
105
+ function hasOpenCodeState(tab) {
106
+ const label = normalizeLabel(tab.sessionTitle);
107
+ const draft = normalizeLabel(tab.draft);
108
+ return !!(
109
+ tab.sessionId
110
+ || tab.running
111
+ || draft
112
+ || tab.planContent
113
+ || tab.editedPlanContent
114
+ || tab.showPostPlanActions
115
+ || tab.threadTokenUsage
116
+ || !isDefaultLabel(label, ['new session', 'opencode'])
117
+ );
118
+ }
119
+
120
+ function tabLabel(provider, tab) {
121
+ if (provider === 'claude') return normalizeLabel(tab.label) || 'New chat';
122
+ if (provider === 'codex') return normalizeLabel(tab.pendingSessionLabel || tab.title || tab.sessionLabel) || 'New session';
123
+ return normalizeLabel(tab.sessionTitle || tab.title) || 'New session';
124
+ }
125
+
126
+ function tabId(provider, tab, index) {
127
+ if (tab.id) return String(tab.id);
128
+ if (provider === 'claude' && tab.sessionId) return String(tab.sessionId);
129
+ if (provider === 'codex' && tab.threadId) return String(tab.threadId);
130
+ if (provider === 'opencode' && tab.sessionId) return String(tab.sessionId);
131
+ return `${provider}-${index}`;
132
+ }
133
+
134
+ function isRunning(tab) {
135
+ return !!(tab.running || tab.startingThread);
136
+ }
137
+
138
+ function providerPayload(provider) {
139
+ const meta = PROVIDERS[provider];
140
+ if (!meta) return null;
141
+ const key = scopedTabsKey(meta);
142
+ const data = parseJson(storage.getItem(key), null);
143
+ if (Array.isArray(data?.tabs) && data.tabs.length) {
144
+ return { key, activeIdx: Number(data.activeIdx) || 0, tabs: data.tabs };
145
+ }
146
+
147
+ if (provider === 'claude') {
148
+ const legacy = parseJson(storage.getItem('synabun-claude-panel-tabs'), null);
149
+ if (Array.isArray(legacy?.tabs) && legacy.tabs.length) {
150
+ return { key: 'synabun-claude-panel-tabs', activeIdx: Number(legacy.activeIdx) || 0, tabs: legacy.tabs };
151
+ }
152
+ const sessionId = storage.getItem('synabun-claude-panel-session');
153
+ if (sessionId) {
154
+ const label = storage.getItem(`synabun-session-label:${sessionId}`) || `${sessionId.slice(0, 8)}...`;
155
+ return { key: 'synabun-claude-panel-session', activeIdx: 0, tabs: [{ sessionId, label }], legacySingle: true };
156
+ }
157
+ }
158
+
159
+ if (provider === 'codex') {
160
+ const threadId = storage.getItem('synabun-codex-panel-thread');
161
+ const title = storage.getItem('synabun-codex-panel-title') || '';
162
+ if (threadId || title) {
163
+ return {
164
+ key: 'synabun-codex-panel-thread',
165
+ activeIdx: 0,
166
+ tabs: [{
167
+ id: 'legacy-codex-thread',
168
+ threadId,
169
+ title,
170
+ project: storage.getItem('synabun-codex-panel-project') || '',
171
+ }],
172
+ legacySingle: true,
173
+ };
174
+ }
175
+ }
176
+
177
+ return null;
178
+ }
179
+
180
+ function meaningfulTabs(provider, payload) {
181
+ const predicate = provider === 'claude'
182
+ ? hasClaudeState
183
+ : provider === 'codex'
184
+ ? hasCodexState
185
+ : hasOpenCodeState;
186
+ return (payload?.tabs || [])
187
+ .map((tab, index) => ({ tab, index }))
188
+ .filter(({ tab }) => predicate(tab));
189
+ }
190
+
191
+ function removePlaceholders(provider = '') {
192
+ const selector = provider
193
+ ? `.${PLACEHOLDER_CLASS}[data-sidepanel-provider="${provider}"]`
194
+ : `.${PLACEHOLDER_CLASS}`;
195
+ document.querySelectorAll(selector).forEach((node) => node.remove());
196
+ }
197
+
198
+ function writeProviderPayload(provider, payload, removeIndex) {
199
+ if (!payload || removeIndex < 0) return;
200
+ if (payload.legacySingle) {
201
+ if (provider === 'claude') storage.removeItem('synabun-claude-panel-session');
202
+ if (provider === 'codex') {
203
+ storage.removeItem('synabun-codex-panel-thread');
204
+ storage.removeItem('synabun-codex-panel-title');
205
+ }
206
+ return;
207
+ }
208
+
209
+ const tabs = payload.tabs.filter((_, index) => index !== removeIndex);
210
+ if (!tabs.length) {
211
+ storage.removeItem(payload.key);
212
+ return;
213
+ }
214
+ const activeIdx = Math.min(
215
+ Math.max((Number(payload.activeIdx) || 0) - (removeIndex < payload.activeIdx ? 1 : 0), 0),
216
+ tabs.length - 1,
217
+ );
218
+ storage.setItem(payload.key, JSON.stringify({ activeIdx, tabs }));
219
+ }
220
+
221
+ async function openProvider(provider, tabIdValue) {
222
+ if (provider === 'claude') {
223
+ if (!isClaudePanelOpen()) await toggleClaudePanel();
224
+ } else if (provider === 'codex') {
225
+ if (!isCodexPanelOpen()) await toggleCodexPanel();
226
+ } else if (provider === 'opencode') {
227
+ if (!isOpencodePanelOpen()) await toggleOpencodePanel();
228
+ }
229
+ emit(PROVIDERS[provider]?.eventName, { tabId: tabIdValue });
230
+ }
231
+
232
+ function createPlaceholder(provider, payload, tab, index) {
233
+ const tray = document.getElementById('term-minimized-tray');
234
+ const meta = PROVIDERS[provider];
235
+ if (!tray || !meta) return null;
236
+ const providerMeta = getProviderMeta(meta.providerId);
237
+ const id = tabId(provider, tab, index);
238
+ const pill = document.createElement('div');
239
+ pill.className = `term-minimized-pill ${meta.pillClass} ${PLACEHOLDER_CLASS}`;
240
+ pill.dataset.sidepanelProvider = provider;
241
+ pill.dataset.tabId = id;
242
+ pill.innerHTML = `
243
+ <span class="term-minimized-pill-icon" style="color:${providerMeta.color}">${providerMeta.icon}</span>
244
+ <span class="term-minimized-pill-label">${esc(tabLabel(provider, tab))}</span>
245
+ <button class="term-minimized-pill-close" data-tooltip="Close">&times;</button>
246
+ `;
247
+ pill.classList.toggle(`${meta.pillClass.replace('-session-pill', '')}-pill-running`, isRunning(tab));
248
+ pill.addEventListener('click', () => openProvider(provider, id));
249
+ pill.querySelector('.term-minimized-pill-close')?.addEventListener('click', (event) => {
250
+ event.stopPropagation();
251
+ writeProviderPayload(provider, payload, index);
252
+ pill.remove();
253
+ renderSidepanelTrayPlaceholders();
254
+ });
255
+ tray.appendChild(pill);
256
+ return pill;
257
+ }
258
+
259
+ export function renderSidepanelTrayPlaceholders() {
260
+ for (const provider of Object.keys(PROVIDERS)) {
261
+ if (_loadedProviders.has(provider)) {
262
+ removePlaceholders(provider);
263
+ continue;
264
+ }
265
+ removePlaceholders(provider);
266
+ const payload = providerPayload(provider);
267
+ for (const { tab, index } of meaningfulTabs(provider, payload)) {
268
+ createPlaceholder(provider, payload, tab, index);
269
+ }
270
+ }
271
+ }
272
+
273
+ export function markSidepanelTrayProviderLoaded(provider) {
274
+ if (!PROVIDERS[provider]) return;
275
+ _loadedProviders.add(provider);
276
+ removePlaceholders(provider);
277
+ }
278
+
279
+ export function initSidepanelTrayPlaceholders() {
280
+ if (_initialized) return;
281
+ _initialized = true;
282
+ window.addEventListener('sidepanel-tray:provider-loaded', (event) => {
283
+ markSidepanelTrayProviderLoaded(event.detail?.provider);
284
+ });
285
+ window.addEventListener('sidepanel-tray:refresh', renderSidepanelTrayPlaceholders);
286
+ renderSidepanelTrayPlaceholders();
287
+ }
@@ -79,11 +79,15 @@ const SYNC_HANDLERS = {
79
79
  'schedule:fired': (msg) => emit('sync:schedule:fired', msg),
80
80
  'schedule:completed': (msg) => emit('sync:schedule:completed', msg),
81
81
  'schedule:failed': (msg) => emit('sync:schedule:failed', msg),
82
- 'schedule:timer-set': (msg) => emit('sync:schedule:timer-set', msg),
83
- 'schedule:timer-fired': (msg) => emit('sync:schedule:timer-fired', msg),
84
- 'schedule:timer-cancelled': (msg) => emit('sync:schedule:timer-cancelled', msg),
85
-
86
- // Quick timer events
82
+ 'schedule:timer-set': (msg) => emit('sync:schedule:timer-set', msg),
83
+ 'schedule:timer-fired': (msg) => emit('sync:schedule:timer-fired', msg),
84
+ 'schedule:timer-cancelled': (msg) => emit('sync:schedule:timer-cancelled', msg),
85
+ 'schedule-group:created': (msg) => emit('sync:schedule-group:created', msg),
86
+ 'schedule-group:updated': (msg) => emit('sync:schedule-group:updated', msg),
87
+ 'schedule-group:deleted': (msg) => emit('sync:schedule-group:deleted', msg),
88
+ 'schedule-group:reordered': (msg) => emit('sync:schedule-group:reordered', msg),
89
+
90
+ // Quick timer events
87
91
  'quick-timer:set': (msg) => emit('sync:quick-timer:set', msg),
88
92
  'quick-timer:fired': (msg) => emit('sync:quick-timer:fired', msg),
89
93
  'quick-timer:fired-now': (msg) => emit('sync:quick-timer:fired-now', msg),
@@ -682,6 +682,41 @@ function _sendResize(session) {
682
682
  } catch {}
683
683
  }
684
684
 
685
+ const TERMINAL_WRITE_CHUNK = 64 * 1024;
686
+
687
+ function _createTerminalWriter(term) {
688
+ const queue = [];
689
+ let writing = false;
690
+ let disposed = false;
691
+
692
+ const pump = () => {
693
+ if (disposed || writing || !queue.length) return;
694
+ let chunk = queue.shift();
695
+ if (chunk.length > TERMINAL_WRITE_CHUNK) {
696
+ queue.unshift(chunk.slice(TERMINAL_WRITE_CHUNK));
697
+ chunk = chunk.slice(0, TERMINAL_WRITE_CHUNK);
698
+ }
699
+ writing = true;
700
+ try {
701
+ term.write(chunk, () => {
702
+ writing = false;
703
+ queueMicrotask(pump);
704
+ });
705
+ } catch {
706
+ writing = false;
707
+ }
708
+ };
709
+
710
+ const write = (data) => {
711
+ if (disposed || data == null || data === '') return;
712
+ queue.push(String(data));
713
+ pump();
714
+ };
715
+ write.clear = () => { queue.length = 0; writing = false; };
716
+ write.dispose = () => { disposed = true; queue.length = 0; };
717
+ return write;
718
+ }
719
+
685
720
 
686
721
  // ── Session registry (persists across page refresh) ──
687
722
 
@@ -856,23 +891,24 @@ async function loadXterm() {
856
891
  _xtermCSS = true;
857
892
  }
858
893
 
859
- // Dynamic ESM imports from CDN
894
+ // Dynamic ESM imports from CDN. Intentionally avoid addon-webgl/addon-canvas
895
+ // for CLI/TUI sessions: multiple long-lived terminals can exhaust/corrupt
896
+ // glyph atlases or WebGL contexts. xterm's DOM renderer is slower, but it is
897
+ // the most deterministic renderer for agent TUIs.
860
898
  try {
861
- const [xtermMod, fitMod, linksMod, searchMod, webglMod, canvasMod, unicodeMod] = await Promise.all([
899
+ const [xtermMod, fitMod, linksMod, searchMod, unicodeMod] = await Promise.all([
862
900
  import('https://esm.sh/@xterm/xterm@5.5.0'),
863
901
  import('https://esm.sh/@xterm/addon-fit@0.10.0'),
864
902
  import('https://esm.sh/@xterm/addon-web-links@0.11.0'),
865
903
  import('https://esm.sh/@xterm/addon-search@0.15.0'),
866
- import('https://esm.sh/@xterm/addon-webgl@0.18.0').catch(() => null),
867
- import('https://esm.sh/@xterm/addon-canvas@0.7.0').catch(() => null),
868
904
  import('https://esm.sh/@xterm/addon-unicode11@0.8.0'),
869
905
  ]);
870
906
  _Terminal = xtermMod.Terminal;
871
907
  _FitAddon = fitMod.FitAddon;
872
908
  _WebLinksAddon = linksMod.WebLinksAddon;
873
909
  _SearchAddon = searchMod.SearchAddon;
874
- _WebglAddon = webglMod?.WebglAddon || null;
875
- _CanvasAddon = canvasMod?.CanvasAddon || null;
910
+ _WebglAddon = null;
911
+ _CanvasAddon = null;
876
912
  _Unicode11Addon = unicodeMod.Unicode11Addon;
877
913
  } catch (err) {
878
914
  // Show error in terminal container if it exists, or throw
@@ -1608,6 +1644,7 @@ async function openSession(profile, cwd, existingSessionId, options = {}) {
1608
1644
  // and TUI layouts (box-drawing, spinners) are permanently misaligned.
1609
1645
  if (document.fonts?.ready) await document.fonts.ready;
1610
1646
  term.open(viewport);
1647
+ const writeTerminal = _createTerminalWriter(term);
1611
1648
 
1612
1649
  // Find xterm's internal textarea (used for paste handler below, after ws is created)
1613
1650
  const xtermTextarea = viewport.querySelector('.xterm-helper-textarea');
@@ -1832,14 +1869,15 @@ async function openSession(profile, cwd, existingSessionId, options = {}) {
1832
1869
  ws.onmessage = (e) => {
1833
1870
  try {
1834
1871
  const msg = JSON.parse(e.data);
1835
- if (msg.type === 'output' || msg.type === 'replay') {
1836
- term.write(msg.data);
1872
+ if (msg.type === 'output' || msg.type === 'replay' || msg.type === 'snapshot') {
1873
+ writeTerminal(msg.data);
1874
+ const replayData = (msg.type === 'snapshot' && msg.plain) ? msg.plain : msg.data;
1837
1875
  if (NOTIF_TRACKED_CLI_PROFILES.has(profile)) _scheduleCliStatusCheck(sessionId);
1838
1876
  // Accumulate output for _sendOnceReady replay buffer check
1839
1877
  const sess = _sessions.find(s => s.id === sessionId);
1840
1878
  if (sess) {
1841
1879
  if (!sess._replayBuf) sess._replayBuf = '';
1842
- sess._replayBuf += msg.data;
1880
+ sess._replayBuf += replayData;
1843
1881
  if (sess._replayBuf.length > 16384) sess._replayBuf = sess._replayBuf.slice(-8192);
1844
1882
  }
1845
1883
  }
@@ -1850,7 +1888,7 @@ async function openSession(profile, cwd, existingSessionId, options = {}) {
1850
1888
  }
1851
1889
  if (msg.type === 'error') {
1852
1890
  if (msg.message === 'Session not found') { markSessionDead(sessionId); return; }
1853
- term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
1891
+ writeTerminal(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
1854
1892
  }
1855
1893
  if (msg.type === 'image_saved' && msg.path) {
1856
1894
  _clipCopy(msg.path);
@@ -1903,6 +1941,7 @@ async function openSession(profile, cwd, existingSessionId, options = {}) {
1903
1941
  cwd: cwd || null,
1904
1942
  label: options.label || (cwdLabel ? `${profileDef?.label || profile} · ${cwdLabel}` : (profileDef?.label || profile)),
1905
1943
  term, fitAddon, searchAddon, ws, viewport, ro,
1944
+ _terminalWriter: writeTerminal,
1906
1945
  renderer,
1907
1946
  _webgl: webgl, // GPU renderer handle — disposed before term.dispose() on close
1908
1947
  dead: false,
@@ -2485,6 +2524,7 @@ async function reconnectSession(sessionId, profile, options = {}) {
2485
2524
  // Wait for fonts before opening (same FOUT fix as openSession)
2486
2525
  if (document.fonts?.ready) await document.fonts.ready;
2487
2526
  term.open(viewport);
2527
+ const writeTerminal = _createTerminalWriter(term);
2488
2528
 
2489
2529
  // Find xterm's internal textarea (used for paste handler below, after ws is created)
2490
2530
  const xtermTextarea = viewport.querySelector('.xterm-helper-textarea');
@@ -2659,13 +2699,14 @@ async function reconnectSession(sessionId, profile, options = {}) {
2659
2699
  ws.onmessage = (e) => {
2660
2700
  try {
2661
2701
  const msg = JSON.parse(e.data);
2662
- if (msg.type === 'output' || msg.type === 'replay') {
2663
- term.write(msg.data);
2702
+ if (msg.type === 'output' || msg.type === 'replay' || msg.type === 'snapshot') {
2703
+ writeTerminal(msg.data);
2704
+ const replayData = (msg.type === 'snapshot' && msg.plain) ? msg.plain : msg.data;
2664
2705
  // Accumulate output for _sendOnceReady replay buffer check
2665
2706
  const sess = _sessions.find(s => s.id === sessionId);
2666
2707
  if (sess) {
2667
2708
  if (!sess._replayBuf) sess._replayBuf = '';
2668
- sess._replayBuf += msg.data;
2709
+ sess._replayBuf += replayData;
2669
2710
  if (sess._replayBuf.length > 16384) sess._replayBuf = sess._replayBuf.slice(-8192);
2670
2711
  }
2671
2712
  }
@@ -2679,7 +2720,7 @@ async function reconnectSession(sessionId, profile, options = {}) {
2679
2720
  markSessionDead(sessionId);
2680
2721
  return;
2681
2722
  }
2682
- term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
2723
+ writeTerminal(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
2683
2724
  }
2684
2725
  if (msg.type === 'image_saved' && msg.path) {
2685
2726
  _clipCopy(msg.path);
@@ -2726,6 +2767,7 @@ async function reconnectSession(sessionId, profile, options = {}) {
2726
2767
  cwd: options.cwd || null,
2727
2768
  label: options.label || (profileDef?.label || profile),
2728
2769
  term, fitAddon, searchAddon, ws, viewport, ro,
2770
+ _terminalWriter: writeTerminal,
2729
2771
  renderer,
2730
2772
  _webgl: webgl, // GPU renderer handle — disposed before term.dispose() on close
2731
2773
  dead: false,
@@ -2825,8 +2867,9 @@ async function closeSession(idx) {
2825
2867
  if (session.ro) session.ro.disconnect();
2826
2868
  if (session._frameRenderer) session._frameRenderer.destroy();
2827
2869
  if (session.ws) session.ws.close();
2828
- // Dispose WebGL addon BEFORE term.dispose() so the GPU texture atlas is
2829
- // released — otherwise the atlas leaks across session close → new session.
2870
+ if (session._terminalWriter) session._terminalWriter.dispose();
2871
+ // Historical sessions may still have a WebGL addon handle from older code;
2872
+ // dispose it before term.dispose() so GPU resources are released.
2830
2873
  if (session._webgl) { try { session._webgl.dispose(); } catch {} session._webgl = null; }
2831
2874
  if (session.term) session.term.dispose();
2832
2875
  if (session._visibilityHandler) document.removeEventListener('visibilitychange', session._visibilityHandler);
@@ -2930,6 +2973,7 @@ export function disconnectAllSessions() {
2930
2973
  session.dead = true;
2931
2974
  if (session.ro) session.ro.disconnect();
2932
2975
  session.ws.close();
2976
+ if (session._terminalWriter) session._terminalWriter.dispose();
2933
2977
  if (session._webgl) { try { session._webgl.dispose(); } catch {} session._webgl = null; }
2934
2978
  if (session.term) session.term.dispose();
2935
2979
  session.viewport.remove();
@@ -2987,10 +3031,8 @@ function _reconnectTerminalWs(session, attempt = 0) {
2987
3031
  ws.onopen = () => {
2988
3032
  session._reconnecting = false;
2989
3033
  session.ws = ws;
2990
- // Server replays the full PTY outputBuffer on every connect. xterm.write()
2991
- // appends at cursor — without a reset, the prior render stacks under the
2992
- // replay, producing a visibly duplicated banner. Clear the screen and
2993
- // scrollback so the replay paints onto a clean slate.
3034
+ // Server restores reconnects with a terminal-state snapshot. Clear any
3035
+ // stale queued writes so the snapshot becomes the new canonical screen.
2994
3036
  session._needsReplayReset = true;
2995
3037
  session._replayBuf = '';
2996
3038
  if (session.term) {
@@ -3007,15 +3049,16 @@ function _reconnectTerminalWs(session, attempt = 0) {
3007
3049
  if (session.ws !== ws) return;
3008
3050
  try {
3009
3051
  const msg = JSON.parse(e.data);
3010
- if (msg.type === 'output' || msg.type === 'replay') {
3011
- if (msg.type === 'replay' && session._needsReplayReset) {
3052
+ if (msg.type === 'output' || msg.type === 'replay' || msg.type === 'snapshot') {
3053
+ if ((msg.type === 'replay' || msg.type === 'snapshot') && session._needsReplayReset) {
3012
3054
  session._needsReplayReset = false;
3013
3055
  if (session.term) {
3014
- try { session.term.reset(); } catch {}
3056
+ try { session._terminalWriter?.clear?.(); session.term.reset(); } catch {}
3015
3057
  }
3016
3058
  }
3017
3059
  if (session.term) {
3018
- session.term.write(msg.data);
3060
+ if (session._terminalWriter) session._terminalWriter(msg.data);
3061
+ else session.term.write(msg.data);
3019
3062
  if (NOTIF_TRACKED_CLI_PROFILES.has(session.profile)) _scheduleCliStatusCheck(session.id);
3020
3063
  }
3021
3064
  }
@@ -3025,7 +3068,8 @@ function _reconnectTerminalWs(session, attempt = 0) {
3025
3068
  }
3026
3069
  if (msg.type === 'error') {
3027
3070
  if (msg.message === 'Session not found') { markSessionDead(session.id); return; }
3028
- if (session.term) session.term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
3071
+ if (session._terminalWriter) session._terminalWriter(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
3072
+ else if (session.term) session.term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
3029
3073
  }
3030
3074
  if (msg.type === 'image_saved' && msg.path) {
3031
3075
  _clipCopy(msg.path);
@@ -5089,6 +5133,11 @@ async function attachDetached(terminalSessionId, profile, initialMessage, autoSu
5089
5133
  _pendingSessionIds.delete(terminalSessionId);
5090
5134
  console.error('[SynaBun] attachDetached failed:', err);
5091
5135
  showTermToast(`Failed to attach terminal: ${err.message || 'unknown error'}`);
5136
+ const termContainer = $('term-container');
5137
+ const termHidden = !termContainer || termContainer.offsetParent === null;
5138
+ if (termHidden) {
5139
+ try { alert(`[SynaBun] Launch failed: ${err.message || 'unknown error'}\n\nOpen DevTools Console for stack trace.`); } catch {}
5140
+ }
5092
5141
  }
5093
5142
  }
5094
5143
 
@@ -5187,8 +5236,8 @@ function _sendOnceReady(session, message, autoSubmit) {
5187
5236
  function onMessage(e) {
5188
5237
  try {
5189
5238
  const msg = JSON.parse(e.data);
5190
- if (msg.type === 'output' || msg.type === 'replay') {
5191
- _outputBuf += msg.data;
5239
+ if (msg.type === 'output' || msg.type === 'replay' || msg.type === 'snapshot') {
5240
+ _outputBuf += (msg.type === 'snapshot' && msg.plain) ? msg.plain : msg.data;
5192
5241
  // Strip ANSI codes before matching — CLI prompts are colorized
5193
5242
  const clean = _outputBuf.replace(ANSI_RE, '');
5194
5243
  if (READY_PATTERNS.some(p => p.test(clean))) {
@@ -53,22 +53,56 @@ function runUpdate(key, badge) {
53
53
  }
54
54
 
55
55
  emit('terminal:run-command', { command: cmd, label: `Update ${TOOL_LABELS[key] || key}` });
56
- // Optimistically clear the badge and restore parent tooltip
57
- if (badge) {
58
- badge.textContent = '';
59
- const parents = PARENT_ELEMENTS[key];
60
- if (parents?.toolbar) {
61
- const btn = $(parents.toolbar);
62
- if (btn?.dataset.tooltipOriginal) btn.dataset.tooltip = btn.dataset.tooltipOriginal;
63
- }
64
- }
65
- // Poll for the new installed version after the install completes.
66
- // npm-global installs typically take 5–45s; opencode upgrade similar.
67
- // Re-check a few times, bail once the badge is cleared or retries run out.
68
- const delays = [20000, 45000, 90000];
69
- for (const ms of delays) {
70
- setTimeout(() => { forceCheckToolUpdates(); }, ms);
56
+ // Optimistically clear BOTH toolbar + menu badges and restore parent tooltips.
57
+ const ids = TOOL_ELEMENTS[key];
58
+ const parents = PARENT_ELEMENTS[key];
59
+ for (const which of ['toolbar', 'menu']) {
60
+ const b = ids?.[which] && $(ids[which]);
61
+ if (b) b.textContent = '';
62
+ const pBtn = parents?.[which] && $(parents[which]);
63
+ if (pBtn?.dataset.tooltipOriginal) pBtn.dataset.tooltip = pBtn.dataset.tooltipOriginal;
71
64
  }
65
+ // Poll repeatedly until the server confirms the new version is installed
66
+ // (updateAvailable flips to false for this tool) or we hit the max window.
67
+ // Native installer updates can take 1–3min; npm-global 5–60s.
68
+ schedulePostUpdatePolling(key);
69
+ }
70
+
71
+ // Poll /api/system/tool-versions?force=1 until the specified tool reports
72
+ // updateAvailable=false, or a max window elapses. Safe to call repeatedly —
73
+ // latest call supersedes any prior poll loop for the same tool.
74
+ const _pollTimers = new Map();
75
+ function schedulePostUpdatePolling(key) {
76
+ const prior = _pollTimers.get(key);
77
+ if (prior) clearTimeout(prior);
78
+
79
+ const startedAt = Date.now();
80
+ const maxMs = 10 * 60 * 1000;
81
+ const initialDelay = 10000;
82
+ const interval = 10000;
83
+
84
+ const tick = async () => {
85
+ try {
86
+ const res = await fetch('/api/system/tool-versions?force=1');
87
+ if (res.ok) {
88
+ const data = await res.json();
89
+ applyBadges(data);
90
+ const info = data?.tools?.[key];
91
+ if (info && !info.updateAvailable) {
92
+ _pollTimers.delete(key);
93
+ return;
94
+ }
95
+ }
96
+ } catch { /* ignore, will retry */ }
97
+
98
+ if (Date.now() - startedAt >= maxMs) {
99
+ _pollTimers.delete(key);
100
+ return;
101
+ }
102
+ _pollTimers.set(key, setTimeout(tick, interval));
103
+ };
104
+
105
+ _pollTimers.set(key, setTimeout(tick, initialDelay));
72
106
  }
73
107
 
74
108
  function wireParentIntercept(parentId, badgeId, key) {
@@ -483,8 +483,10 @@ export const TUTORIAL_STEPS = [
483
483
  { id: 'claude-haiku-4-5', label: 'Haiku 4.5', desc: 'Fastest', tier: 'light' },
484
484
  ],
485
485
  'codex': [
486
- { id: 'o3', label: 'o3', desc: 'Deep reasoning', tier: 'top' },
487
- { id: 'o4-mini', label: 'o4-mini', desc: 'Fast reasoning', tier: 'default' },
486
+ { id: 'gpt-5.5', label: 'GPT-5.5', desc: 'Most capable', tier: 'top' },
487
+ { id: 'gpt-5.4', label: 'GPT-5.4', desc: 'Strong general' },
488
+ { id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', desc: 'Fast & cheap', tier: 'default' },
489
+ { id: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', desc: 'Codex-tuned' },
488
490
  ],
489
491
  'gemini': [
490
492
  { id: 'gemini-2.5-pro', label: '2.5 Pro', desc: 'Most capable', tier: 'default' },