synabun 2.0.0-beta → 2.0.0

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 (70) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/THIRD-PARTY-LICENSES.md +5 -0
  3. package/launcher.mjs +40 -0
  4. package/lib/claude-hooks.js +61 -8
  5. package/lib/client-config-repair.js +4 -3
  6. package/lib/data-home-migration.js +4 -0
  7. package/lib/desktop-pwa.js +206 -0
  8. package/lib/external-tools.js +221 -0
  9. package/lib/first-launch-protection.js +5 -0
  10. package/lib/packaged-runtime.js +110 -0
  11. package/lib/start-launcher.js +1309 -0
  12. package/neural-interface/i18n/en.json +41 -2
  13. package/neural-interface/i18n/pt-BR.json +41 -2
  14. package/neural-interface/lib/assistant-envelope.js +9 -6
  15. package/neural-interface/lib/augmented-path.js +23 -3
  16. package/neural-interface/lib/browser-language.js +83 -0
  17. package/neural-interface/lib/claude-agent-bridge.js +54 -147
  18. package/neural-interface/lib/claude-executable.js +241 -0
  19. package/neural-interface/lib/claude-model-catalog.js +52 -91
  20. package/neural-interface/lib/native-binary-runtime.js +69 -337
  21. package/neural-interface/lib/native-loop-providers.js +30 -44
  22. package/neural-interface/lib/offline-page.js +54 -0
  23. package/neural-interface/lib/session-title-generator.js +5 -0
  24. package/neural-interface/lib/synabun-update-plan.js +19 -0
  25. package/neural-interface/package-lock.json +0 -251
  26. package/neural-interface/public/install-app.html +31 -0
  27. package/neural-interface/public/manifest.json +2 -0
  28. package/neural-interface/public/offline.html +401 -66
  29. package/neural-interface/public/onboarding.html +252 -146
  30. package/neural-interface/public/shared/assistant/asst-mascot.js +12 -10
  31. package/neural-interface/public/shared/assistant/asst-rack-styles.js +0 -3
  32. package/neural-interface/public/shared/assistant/asst-render.js +58 -49
  33. package/neural-interface/public/shared/assistant/asst-styles.js +4 -2
  34. package/neural-interface/public/shared/command-runner-model.js +30 -0
  35. package/neural-interface/public/shared/html-shell.js +9 -5
  36. package/neural-interface/public/shared/pwa-install-page.js +34 -0
  37. package/neural-interface/public/shared/pwa-install.css +12 -0
  38. package/neural-interface/public/shared/pwa-install.js +28 -0
  39. package/neural-interface/public/shared/settings/settings-rules.js +160 -0
  40. package/neural-interface/public/shared/start-bridge.js +583 -0
  41. package/neural-interface/public/shared/styleguide/sg-colors.js +5 -2
  42. package/neural-interface/public/shared/styleguide/sg-import-export.js +4 -3
  43. package/neural-interface/public/shared/styleguide/sg-kit.js +4 -1
  44. package/neural-interface/public/shared/styleguide/sg-panel.js +9 -1
  45. package/neural-interface/public/shared/styleguide/sg-typography.js +1 -0
  46. package/neural-interface/public/shared/styles.css +801 -392
  47. package/neural-interface/public/shared/term-ready.js +307 -0
  48. package/neural-interface/public/shared/ui-claude-panel.js +3 -3
  49. package/neural-interface/public/shared/ui-command-runner.js +251 -468
  50. package/neural-interface/public/shared/ui-judgments.js +1 -1
  51. package/neural-interface/public/shared/ui-loading.js +221 -10
  52. package/neural-interface/public/shared/ui-menubar.js +11 -27
  53. package/neural-interface/public/shared/ui-settings.js +36 -59
  54. package/neural-interface/public/shared/ui-terminal.js +130 -144
  55. package/neural-interface/public/sw.js +3 -1
  56. package/neural-interface/public/variant/2d/main.js +2 -1
  57. package/neural-interface/public/variant/3d/main.js +2 -1
  58. package/neural-interface/scripts/rebuild-pty.js +15 -12
  59. package/neural-interface/server.js +220 -185
  60. package/package.json +13 -1
  61. package/postinstall.js +11 -81
  62. package/setup.js +72 -4
  63. package/docs/browser-v2.md +0 -109
  64. package/docs/hook-integrity.md +0 -89
  65. package/docs/judgments.md +0 -182
  66. package/docs/style-guide.md +0 -246
  67. package/scripts/audit-claude-hook-usage.mjs +0 -12
  68. package/scripts/audit-memory-duplicates.mjs +0 -44
  69. package/scripts/measure-claude-hook-overhead.mjs +0 -77
  70. package/scripts/recover-user-config.mjs +0 -254
@@ -16,6 +16,7 @@ import { getWhiteboardElementById } from './ui-whiteboard.js';
16
16
  import { createFrameRenderer } from './utils.js';
17
17
  import { notify, NOTIF_TYPE } from './ui-notifications.js';
18
18
  import { getProviderMeta } from './provider-icons.js';
19
+ import { readinessKind, typeWhenReady } from './term-ready.js';
19
20
  import { initRendererManager, acquireRenderer, disposeRenderer, hasWebgl, noteFocus, adoptRenderer, rendererState, rendererStats } from './term-renderer.js';
20
21
  import { mountAssistant } from './assistant/asst-panel.js';
21
22
  import { getAssistantSession } from './assistant/asst-api.js';
@@ -1424,10 +1425,22 @@ function _accumReplay(sess, text) {
1424
1425
  if (sess._replayBuf.length > 16384) sess._replayBuf = sess._replayBuf.slice(-8192);
1425
1426
  }
1426
1427
 
1427
- function _notifyOutputWatchers(sess, text) {
1428
- if (!sess?._outputWatchers?.size || !text) return;
1428
+ // `replaced`: a snapshot just became the whole screen (connect / reconnect),
1429
+ // so `text` is everything there is rather than more of the stream.
1430
+ function _notifyOutputWatchers(sess, text, replaced = false) {
1431
+ if (!sess?._outputWatchers?.size) return;
1432
+ if (!text && !replaced) return;
1429
1433
  for (const w of [...sess._outputWatchers]) {
1430
- try { w(text); } catch {}
1434
+ try { w(text || '', replaced); } catch {}
1435
+ }
1436
+ }
1437
+
1438
+ // A socket of this session opened (first connect or a reconnect). The wait for
1439
+ // readiness in _sendOnceReady starts its clock here, not when it was asked.
1440
+ function _notifySocketOpen(sess) {
1441
+ if (!sess?._openWatchers?.size) return;
1442
+ for (const w of [...sess._openWatchers]) {
1443
+ try { w(); } catch {}
1431
1444
  }
1432
1445
  }
1433
1446
 
@@ -1500,7 +1513,7 @@ function _handleTermWsEvent(e, ctx) {
1500
1513
  else term?.write(msg.data);
1501
1514
  }
1502
1515
  if (NOTIF_TRACKED_CLI_PROFILES.has(profile)) _scheduleCliStatusCheck(sessionId);
1503
- _notifyOutputWatchers(sess, text);
1516
+ _notifyOutputWatchers(sess, text, !!wantsReset);
1504
1517
  }
1505
1518
  if (msg.type === 'exit') {
1506
1519
  if (sess) sess._exitReceived = true;
@@ -1538,6 +1551,7 @@ function _onTermWsOpen(sessionId, ws, fallback = null) {
1538
1551
  try { (s?._terminalWriter || fallback?._terminalWriter)?.reset?.(); } catch {}
1539
1552
  send({ type: 'hello', flow: 1 });
1540
1553
  if (document.hidden) send({ type: 'visibility', visible: false });
1554
+ _notifySocketOpen(s);
1541
1555
  const term = s?.term;
1542
1556
  if (!term) return;
1543
1557
  // Hidden container (restore, parked dock) → no resize now; _scheduleFit
@@ -3381,6 +3395,7 @@ async function reconnectBrowserSession(sessionId, liveData, saved) {
3381
3395
  ws, viewport, ro, renderer: null,
3382
3396
  dead: false, pinned: saved?.pinned || false,
3383
3397
  _userRenamed: saved?.userRenamed || false,
3398
+ _floatColor: saved?.floatColor || null,
3384
3399
  _isBrowser: true, _browserUrl: currentUrl, _browserTitle: liveData?.title || '',
3385
3400
  _browserCanvas: canvas, _browserCtx: ctx, _frameRenderer: frameRenderer, _visibilityHandler,
3386
3401
  _idleTimer: null, _screencastIdlePaused: false,
@@ -4488,13 +4503,29 @@ function _applyFloatColor(win, colorId) {
4488
4503
  win.style.setProperty('--fh', String(c.h));
4489
4504
  win.style.setProperty('--fs', c.s + '%');
4490
4505
  }
4491
- // Update color strip
4506
+ // Update color strip. Only the color: the `background` shorthand would reset
4507
+ // the stylesheet's background-clip, and the dot would fill its whole hit area.
4492
4508
  const strip = win.querySelector('.term-float-color-strip');
4493
4509
  if (strip) {
4494
- strip.style.background = c.s === 0 ? 'rgba(255,255,255,0.15)' : `hsl(${c.h}, ${Math.round(c.s * 0.5)}%, 30%)`;
4510
+ strip.style.backgroundColor = c.s === 0 ? 'rgba(255,255,255,0.15)' : `hsl(${c.h}, ${Math.round(c.s * 0.5)}%, 30%)`;
4495
4511
  }
4496
4512
  }
4497
4513
 
4514
+ /** Place the palette popup by the color button: above it, or below when there
4515
+ * is no room, with its first swatch over the dot and inside the viewport. */
4516
+ function _placeFloatColorPicker(picker, colorBtn) {
4517
+ const btn = colorBtn.getBoundingClientRect();
4518
+ const box = picker.getBoundingClientRect();
4519
+ const first = picker.firstElementChild?.getBoundingClientRect();
4520
+ const inset = first ? first.left + first.width / 2 - box.left : 0;
4521
+ const margin = 8, gap = 6;
4522
+ const left = btn.left + btn.width / 2 - inset;
4523
+ let top = btn.top - gap - box.height;
4524
+ if (top < margin) top = btn.bottom + gap;
4525
+ picker.style.left = Math.max(margin, Math.min(left, window.innerWidth - box.width - margin)) + 'px';
4526
+ picker.style.top = Math.max(margin, Math.min(top, window.innerHeight - box.height - margin)) + 'px';
4527
+ }
4528
+
4498
4529
  function getRightPanelReservedWidth(cs = getComputedStyle(document.documentElement)) {
4499
4530
  return parseFloat(cs.getPropertyValue('--right-panel-width'))
4500
4531
  || parseFloat(cs.getPropertyValue('--claude-panel-width'))
@@ -4813,19 +4844,18 @@ function detachTab(idx, opts = {}) {
4813
4844
  if (existing) { existing.remove(); return; }
4814
4845
  const picker = document.createElement('div');
4815
4846
  picker.className = 'float-color-picker';
4816
- // Position above the color strip button
4817
- const btnRect = colorBtn.getBoundingClientRect();
4818
- picker.style.left = btnRect.left + 'px';
4819
- picker.style.bottom = (window.innerHeight - btnRect.top + 6) + 'px';
4820
- picker.style.top = 'auto';
4847
+ // The session object may have been replaced by _pushSession after a
4848
+ // reconnection — read and write the color on the one in _sessions
4849
+ const live = () => _sessions.find(s => s.id === session.id) || session;
4821
4850
  FLOAT_COLORS.forEach(c => {
4822
4851
  const swatch = document.createElement('button');
4823
4852
  swatch.className = 'float-color-swatch';
4824
- if (c.id === (session._floatColor || 'blue')) swatch.classList.add('active');
4853
+ if (c.id === (live()._floatColor || 'blue')) swatch.classList.add('active');
4825
4854
  swatch.style.background = c.s === 0 ? 'rgba(255,255,255,0.15)' : `hsl(${c.h}, ${Math.round(c.s * 0.5)}%, 25%)`;
4826
4855
  swatch.addEventListener('click', (ev) => {
4827
4856
  ev.stopPropagation();
4828
4857
  session._floatColor = c.id;
4858
+ live()._floatColor = c.id;
4829
4859
  _applyFloatColor(win, c.id);
4830
4860
  saveSessionRegistry();
4831
4861
  saveTerminalLayout();
@@ -4834,8 +4864,10 @@ function detachTab(idx, opts = {}) {
4834
4864
  picker.appendChild(swatch);
4835
4865
  });
4836
4866
  document.body.appendChild(picker);
4837
- // Close on outside click
4867
+ _placeFloatColorPicker(picker, colorBtn);
4868
+ // Close on outside click (the dot closes it through the toggle above)
4838
4869
  const close = (ev) => {
4870
+ if (colorBtn.contains(ev.target) && picker.isConnected) return;
4839
4871
  if (!picker.contains(ev.target)) { picker.remove(); document.removeEventListener('mousedown', close); }
4840
4872
  };
4841
4873
  setTimeout(() => document.addEventListener('mousedown', close), 0);
@@ -5722,6 +5754,8 @@ export async function initTerminal() {
5722
5754
  window.addEventListener('keydown', (e) => {
5723
5755
  if (e.key !== 'Escape') return;
5724
5756
  const target = e.target;
5757
+ // The command library owns Escape for its dialogs, search and window.
5758
+ if (target?.closest?.('.command-runner-panel')) return;
5725
5759
  let session = null;
5726
5760
  if (target?.classList?.contains?.('xterm-helper-textarea')) {
5727
5761
  const sid = target.closest('.term-viewport')?.dataset?.sessionId;
@@ -6337,8 +6371,19 @@ async function launchDetached(profile, initialMessage, autoSubmit, opts = {}) {
6337
6371
  async function runCommandInNewTab({ command, cwd, label }) {
6338
6372
  if (!command) return;
6339
6373
  await _whenOpenIdle();
6340
- const session = await openSession('shell', cwd || null);
6341
- if (!session) return;
6374
+ let session = await openSession('shell', cwd || null);
6375
+ if (!session) {
6376
+ // Another open slipped in between the queue and this call (openSession
6377
+ // returns null instead of stacking): take one more turn rather than
6378
+ // dropping the command without a word.
6379
+ await _whenOpenIdle();
6380
+ session = await openSession('shell', cwd || null);
6381
+ }
6382
+ if (!session) {
6383
+ ensurePanel();
6384
+ showTermToast(`Could not open a terminal for ${label || 'this command'}. Try again`);
6385
+ return;
6386
+ }
6342
6387
  if (label) {
6343
6388
  session.label = label;
6344
6389
  renderTabBar();
@@ -6414,140 +6459,72 @@ async function attachDetached(terminalSessionId, profile, initialMessage, autoSu
6414
6459
  }
6415
6460
  }
6416
6461
 
6417
- /** Send a message to a session once the WebSocket is ready and CLI has booted.
6418
- * Watches terminal output for the CLI's input prompt (e.g. Claude Code's ">" or ❯)
6419
- * instead of using a blind timeout — prevents input from being swallowed by the
6420
- * shell (cmd.exe) before the CLI is ready. Falls back to 15s timeout.
6462
+ /** Type a message into a session once it can take it.
6463
+ * A shell tab (Command Runner) is ready when its line editor is: zsh, bash 5
6464
+ * and fish announce it (bracketed paste on), every other prompt is read off
6465
+ * the last line, and a prompt nothing recognises is taken once the output
6466
+ * goes quiet. A CLI keeps its own reading: watch for its input prompt (">" /
6467
+ * ❯) and fall back after 15 s, because a CLI swallows what is typed before it
6468
+ * is up. The rules, and the sequence wait → settle → type, are in term-ready.js.
6421
6469
  *
6422
- * KEY FIX: Also checks session._replayBuf for output that arrived BEFORE this
6423
- * function was called (e.g. replay data from reconnectSession's ws.onopen).
6424
- * Without this, the CLI prompt is missed when the PTY boots faster than the
6425
- * automation studio's browser-open + launchLoop HTTP round-trip. */
6470
+ * The wait lives on the session, not on one socket: a socket that fails
6471
+ * before it opens, or is swapped by a reconnect, changes nothing, and the
6472
+ * readiness window only starts once a socket of the session is open. Output
6473
+ * that arrived before this call is read from session._replayBuf, and the
6474
+ * terminal's own parser state covers a prompt that only came back inside a
6475
+ * snapshot. Resolves { ok, reason } once the message is typed (or could not be). */
6426
6476
  function _sendOnceReady(session, message, autoSubmit) {
6427
6477
  if (!session?.ws) {
6428
6478
  console.warn('[SynaBun] _sendOnceReady: no session.ws — aborting');
6429
- return;
6430
- }
6431
-
6432
- console.log('[SynaBun] _sendOnceReady: starting, ws.readyState =', session.ws.readyState, ', msg length =', message?.length, ', replayBuf length =', session._replayBuf?.length || 0);
6433
-
6434
- // Strip ANSI escape sequences so color codes around prompts don't block matching.
6435
- // ConPTY on Windows sends private-mode CSI like \x1b[?25h (show cursor) after prompts.
6436
- // The [\x20-\x3f]* range covers ?, !, >, = (ECMA-48 parameter bytes) plus digits/semicolons.
6437
- const ANSI_RE = /\x1b\[[\x20-\x3f]*[\x40-\x7e]|\x1b\][^\x07]*\x07|\x1b[()][AB012]/g;
6438
-
6439
- // Patterns that indicate a CLI is ready for input (matched against ANSI-stripped output)
6440
- // Claude Code: ">" at start of line or "❯", Codex/Gemini: similar prompts
6441
- const READY_PATTERNS = [
6442
- /^>\s*$/m, // Claude Code prompt: ">" on its own line
6443
- /\n>\s*$/, // ">" after newline at end of output
6444
- />\s*$/, // ">" at end of buffer (catch partial lines)
6445
- /\u276F/, // ❯ (some CLI prompts)
6446
- /\$ $/, // Shell prompt fallback
6447
- ];
6448
- const MAX_WAIT = 15000;
6449
- let _outputBuf = '';
6450
- let _sent = false;
6451
- let _listener = null;
6452
- let _fallbackTimer = null;
6453
-
6454
- function doSend() {
6455
- if (_sent) return;
6456
- _sent = true;
6457
- // Clean up watcher and timer
6458
- if (_listener) session._outputWatchers?.delete(_listener);
6459
- if (_fallbackTimer) clearTimeout(_fallbackTimer);
6460
- console.log('[SynaBun] _sendOnceReady: doSend triggered, ws.readyState =', session.ws?.readyState);
6461
- // Small delay after detecting ready — let the CLI fully settle
6462
- setTimeout(() => {
6463
- if (session.ws?.readyState !== WebSocket.OPEN) {
6464
- console.warn('[SynaBun] _sendOnceReady: WS not OPEN at send time, readyState =', session.ws?.readyState);
6465
- return;
6466
- }
6467
- // Chunk the input to avoid ConPTY input buffer overflow on Windows.
6468
- // Writing 1000+ chars in a single pty.write() can silently drop data.
6469
- const full = message + '\r';
6470
- const CHUNK = 256;
6471
- const DELAY = 30; // ms between chunks
6472
- console.log('[SynaBun] _sendOnceReady: sending prompt in chunks (' + message.length + ' chars, ' + Math.ceil(full.length / CHUNK) + ' chunks)');
6473
- for (let i = 0; i < full.length; i += CHUNK) {
6474
- const chunk = full.slice(i, i + CHUNK);
6475
- const delay = (i / CHUNK) * DELAY;
6476
- setTimeout(() => {
6477
- if (session.ws?.readyState === WebSocket.OPEN) {
6478
- session.ws.send(JSON.stringify({ type: 'input', data: chunk }));
6479
- }
6480
- }, delay);
6481
- }
6482
- if (autoSubmit) {
6483
- // Send a second Enter after all chunks + extra delay to auto-confirm Claude Code's prompt
6484
- const totalChunkTime = Math.ceil(full.length / CHUNK) * DELAY;
6485
- setTimeout(() => {
6486
- if (session.ws?.readyState === WebSocket.OPEN) {
6487
- console.log('[SynaBun] _sendOnceReady: sending auto-submit Enter');
6488
- session.ws.send(JSON.stringify({ type: 'input', data: '\r' }));
6489
- }
6490
- }, totalChunkTime + 4000);
6491
- }
6492
- }, 500);
6493
- }
6494
-
6495
- // Check if the CLI prompt already appeared in replay data that arrived
6496
- // before this function was called (the core race condition fix).
6497
- function checkReplayBuffer() {
6498
- if (session._replayBuf) {
6499
- const clean = session._replayBuf.replace(ANSI_RE, '');
6500
- if (READY_PATTERNS.some(p => p.test(clean))) {
6501
- console.log('[SynaBun] _sendOnceReady: ready pattern found in existing replay buffer — sending immediately');
6502
- doSend();
6503
- return true;
6479
+ return Promise.resolve({ ok: false, reason: 'no-socket' });
6480
+ }
6481
+ if (!message) return Promise.resolve({ ok: false, reason: 'empty' });
6482
+
6483
+ const kind = readinessKind(session.profile);
6484
+ const alive = () => !session.dead && !session._exitReceived && _sessions.includes(session);
6485
+
6486
+ return typeWhenReady({
6487
+ kind,
6488
+ text: message + '\r',
6489
+ socket: () => session.ws,
6490
+ alive,
6491
+ OPEN: WebSocket.OPEN,
6492
+ // The emulator's own state: true while the shell's line editor is reading.
6493
+ lineEditorActive: () => session.term?.modes?.bracketedPasteMode === true,
6494
+ // What already arrived (replay data lands before an attach calls this).
6495
+ buffered: session._replayBuf || '',
6496
+ subscribe: ({ output, opened, parsed }) => {
6497
+ (session._outputWatchers ||= new Set()).add(output);
6498
+ (session._openWatchers ||= new Set()).add(opened);
6499
+ // Bracketed paste can arrive inside a snapshot's escape data, where the
6500
+ // watcher only sees plain text: ask the parser after each write instead.
6501
+ let onParsed = null;
6502
+ if (kind === 'shell') {
6503
+ try { onParsed = session.term?.onWriteParsed?.(parsed) || null; } catch {}
6504
6504
  }
6505
+ return () => {
6506
+ session._outputWatchers?.delete(output);
6507
+ session._openWatchers?.delete(opened);
6508
+ try { onParsed?.dispose(); } catch {}
6509
+ };
6510
+ },
6511
+ onReady: (reason, waitedMs) => console.log(`[SynaBun] _sendOnceReady: ${kind} ready (${reason}) after ${waitedMs} ms`),
6512
+ }).then((result) => {
6513
+ if (!result.ok) {
6514
+ console.warn(`[SynaBun] _sendOnceReady: not delivered (${result.reason}, ${result.sent}/${result.chunks} chunks)`);
6515
+ if (alive()) showTermToast('The command did not reach the terminal. Run it again');
6516
+ return result;
6517
+ }
6518
+ if (autoSubmit) {
6519
+ // A second Enter confirms Claude Code's prompt once the paste settled
6520
+ setTimeout(() => {
6521
+ if (session.ws?.readyState === WebSocket.OPEN) {
6522
+ session.ws.send(JSON.stringify({ type: 'input', data: '\r' }));
6523
+ }
6524
+ }, 4000);
6505
6525
  }
6506
- return false;
6507
- }
6508
-
6509
- function onOutput(text) {
6510
- _outputBuf += text;
6511
- // Strip ANSI codes before matching — CLI prompts are colorized
6512
- const clean = _outputBuf.replace(ANSI_RE, '');
6513
- if (READY_PATTERNS.some(p => p.test(clean))) {
6514
- console.log('[SynaBun] _sendOnceReady: ready pattern matched in live output');
6515
- doSend();
6516
- }
6517
- }
6518
-
6519
- function attach() {
6520
- // Before watching future output, check what already arrived.
6521
- // The replay from ws.onopen fires synchronously before this function runs
6522
- // (reconnectSession awaits, replay lands in ws.onmessage, accumulates
6523
- // in session._replayBuf). If the CLI prompt is already there, send now.
6524
- if (checkReplayBuffer()) return;
6525
-
6526
- console.log('[SynaBun] _sendOnceReady: attach() — adding output watcher (no prompt in replay buffer yet)');
6527
- // Session-level output watcher (fed by _handleTermWsEvent) — unlike a raw
6528
- // ws 'message' listener, this survives _reconnectTerminalWs swapping session.ws.
6529
- _listener = onOutput;
6530
- (session._outputWatchers ||= new Set()).add(_listener);
6531
- // Fallback: if no prompt detected within MAX_WAIT, send anyway
6532
- _fallbackTimer = setTimeout(() => {
6533
- console.warn('[SynaBun] _sendOnceReady: CLI prompt not detected, sending after timeout');
6534
- doSend();
6535
- }, MAX_WAIT);
6536
- }
6537
-
6538
- if (session.ws.readyState === WebSocket.OPEN) attach();
6539
- else {
6540
- session.ws.addEventListener('open', attach, { once: true });
6541
- // Safety net: if the WS never opens, force-send after MAX_WAIT to avoid silent failure
6542
- setTimeout(() => {
6543
- if (!_sent) {
6544
- // One last check of the replay buffer before force-sending
6545
- if (checkReplayBuffer()) return;
6546
- console.warn('[SynaBun] _sendOnceReady: WS never opened — forcing send attempt');
6547
- doSend();
6548
- }
6549
- }, MAX_WAIT + 2000);
6550
- }
6526
+ return result;
6527
+ });
6551
6528
  }
6552
6529
 
6553
6530
  /** Snapshot terminal state for workspace save */
@@ -6566,6 +6543,7 @@ export function getTerminalSnapshot() {
6566
6543
  isDetached: _detachedTabs.has(s.id),
6567
6544
  userRenamed: s._userRenamed || false,
6568
6545
  claudeSessionId: s._claudeSessionId || null,
6546
+ floatColor: s._floatColor || null,
6569
6547
  assistantSessionId: s._isAssistant ? s.id : null,
6570
6548
  brain: s._isAssistant ? (s._brain || null) : null,
6571
6549
  })),
@@ -6622,12 +6600,20 @@ function applyTerminalLayout(snap) {
6622
6600
  }
6623
6601
  if (!session) continue;
6624
6602
 
6603
+ // The snapshot's color goes onto the session before detachTab reads it:
6604
+ // a session a workspace restore rebuilt has none and would get a random one
6605
+ const savedColor = FLOAT_COLORS.some(c => c.id === dt.floatColor) ? dt.floatColor : null;
6606
+ if (savedColor) session._floatColor = savedColor;
6607
+
6625
6608
  const idx = _sessions.indexOf(session);
6626
6609
  if (!_detachedTabs.has(session.id)) {
6627
6610
  detachTab(idx);
6628
6611
  }
6629
6612
  const tabState = _detachedTabs.get(session.id);
6630
6613
  if (tabState) {
6614
+ // Already floating (browser tabs detach while reconnecting): recolor it
6615
+ if (savedColor) _applyFloatColor(tabState.el, savedColor);
6616
+
6631
6617
  // Validate saved dimensions — enforce minimums and keep on screen
6632
6618
  const minW = 280, minH = 160;
6633
6619
  const w = Math.max(minW, dt.width || minW);
@@ -6745,7 +6731,7 @@ export async function restoreTerminalSnapshot(snap) {
6745
6731
  }
6746
6732
  if (liveIds.has(saved.id)) {
6747
6733
  const live = liveMap.get(saved.id);
6748
- const opts = { label: saved.label, pinned: saved.pinned, cwd: live?.cwd || null, userRenamed: saved.userRenamed, claudeSessionId: saved.claudeSessionId };
6734
+ const opts = { label: saved.label, pinned: saved.pinned, cwd: live?.cwd || null, userRenamed: saved.userRenamed, claudeSessionId: saved.claudeSessionId, floatColor: saved.floatColor };
6749
6735
  if (CLI_PROFILES.has(saved.profile)) {
6750
6736
  await reconnectSession(saved.id, saved.profile, opts);
6751
6737
  } else {
@@ -1,4 +1,6 @@
1
- const CACHE_NAME = 'synabun-offline-v9';
1
+ // Bump when offline.html changes shape: the new worker drops the old cache and
2
+ // stores the page again (v12: the start command of a packaged application).
3
+ const CACHE_NAME = 'synabun-offline-v12';
2
4
  const OFFLINE_URL = '/offline.html';
3
5
 
4
6
  self.addEventListener('install', (event) => {
@@ -21,7 +21,7 @@ import { initI18n, t } from '../../shared/i18n.js';
21
21
  // ── Shared UI modules (side-effect: each registers its own event listeners) ──
22
22
  import { initTooltip } from '../../shared/ui-tooltip.js';
23
23
  import { initPanelSystem, initPinToggle, clampPanelsToViewport } from '../../shared/ui-panels.js';
24
- import { initLoading, showLoadingError, hideLoading } from '../../shared/ui-loading.js';
24
+ import { initLoading, showLoadingError, hideLoading, rememberHealth } from '../../shared/ui-loading.js';
25
25
  import { initNavbar } from '../../shared/ui-navbar.js';
26
26
  import { initMenubar } from '../../shared/ui-menubar.js';
27
27
  import { initSearch } from '../../shared/ui-search.js';
@@ -304,6 +304,7 @@ async function boot() {
304
304
  try {
305
305
  const healthRes = await fetch('/api/health');
306
306
  const health = await healthRes.json();
307
+ rememberHealth(health);
307
308
  if (!health.ok) {
308
309
  const messages = {
309
310
  db_missing: [t('loading.health.databaseUnreachable.title'), health.detail || t('loading.health.databaseUnreachable.sub')],
@@ -21,7 +21,7 @@ import { initI18n, t } from '../../shared/i18n.js';
21
21
  // ── Shared UI modules (side-effect: each registers its own event listeners) ──
22
22
  import { initTooltip } from '../../shared/ui-tooltip.js';
23
23
  import { initPanelSystem, initPinToggle, clampPanelsToViewport } from '../../shared/ui-panels.js';
24
- import { initLoading, showLoadingError, hideLoading } from '../../shared/ui-loading.js';
24
+ import { initLoading, showLoadingError, hideLoading, rememberHealth } from '../../shared/ui-loading.js';
25
25
  import { initNavbar } from '../../shared/ui-navbar.js';
26
26
  import { initMenubar, closeMenubar } from '../../shared/ui-menubar.js';
27
27
  import { initSearch } from '../../shared/ui-search.js';
@@ -389,6 +389,7 @@ async function boot() {
389
389
  try {
390
390
  const healthRes = await fetch('/api/health');
391
391
  const health = await healthRes.json();
392
+ rememberHealth(health);
392
393
  if (!health.ok) {
393
394
  const messages = {
394
395
  db_missing: [t('loading.health.databaseUnreachable.title'), health.detail || t('loading.health.databaseUnreachable.sub')],
@@ -15,27 +15,30 @@ import { existsSync, cpSync, mkdirSync } from 'node:fs';
15
15
  import { resolve, dirname } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { ensureVendoredExecutables } from '../lib/native-binary-runtime.js';
18
+ import { pruneExternalTools } from '../../lib/external-tools.js';
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const neuralRoot = resolve(__dirname, '..');
21
22
  const ptyDir = resolve(neuralRoot, 'node_modules', 'node-pty');
22
23
 
23
- // Step 1a: Restore execute permissions on every vendored native binary.
24
+ // Step 0: Claude Code, Codex, OpenCode and Gemini CLI are installed by the user,
25
+ // never by SynaBun. The lockfile holds none of them; an `npm install` that
26
+ // brought one anyway (a lockfile made again without scripts/strip-external-tools.mjs,
27
+ // a package an older SynaBun depended on) has it removed here, right after the
28
+ // install. See lib/external-tools.js at the package root.
29
+ for (const item of pruneExternalTools(resolve(neuralRoot, 'node_modules')).removed) {
30
+ console.log(`[rebuild-pty] removed ${item.name}: ${item.tool} is installed separately, not with SynaBun`);
31
+ }
32
+
33
+ // Step 1a: Restore execute permissions on SynaBun's own native helpers.
24
34
  //
25
35
  // npm does not preserve the execute bit for files shipped via a package's
26
- // `files` array with no `bin` entry — which is exactly how the Claude Agent SDK
27
- // and Codex ship their native payloads. This repo also commits node_modules, so
28
- // a checkout can materialize them 0644 as well.
36
+ // `files` array with no `bin` entry, and a checkout with core.filemode=false
37
+ // can materialize them 0644 as well.
29
38
  //
30
39
  // The previous version of this step hardcoded four prebuild directories (naming
31
- // linux dirs that do not exist here while missing ones that do) and chmod'd
32
- // node_modules/.bin/claude and @anthropic-ai/claude-code/cli.js — neither of
33
- // which exists any more, since that package was replaced by
34
- // @anthropic-ai/claude-agent-sdk. The sweep below enumerates instead.
35
- //
36
- // This runs BEFORE the node-pty guard below: the sweep also covers the Claude
37
- // and Codex binaries, which must be repaired even in an install that has no
38
- // node-pty at all.
40
+ // linux dirs that do not exist here while missing ones that do). The sweep
41
+ // below enumerates instead.
39
42
  function sweepVendoredBinaries(label) {
40
43
  const sweep = ensureVendoredExecutables({ root: neuralRoot, log: console.log });
41
44
  for (const failure of sweep.failed) {