synabun 2026.4.25-beta.2 → 2026.4.26-beta

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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Raw internal changelogs are archived in [changelog/](changelog/).
4
4
 
5
- ## 2026-04-25
5
+ ## 2026-04-26.beta-1
6
6
 
7
7
  ### Added — Dedicated Schedules Studio
8
8
  - **Standalone schedule window** — Added `neural-interface/public/shared/ui-schedules-studio.js` and initialized it from both `neural-interface/public/variant/2d/main.js` and `neural-interface/public/variant/3d/main.js`. The new window opens from `automations:open-schedules` and keeps schedule management out of the Automation Studio detail pane.
@@ -57,6 +57,27 @@ Raw internal changelogs are archived in [changelog/](changelog/).
57
57
  - **Official install URLs** — `claude-code` → `https://docs.claude.com/en/docs/claude-code/setup`, `codex` → `https://developers.openai.com/codex/cli/`, `opencode` → `https://opencode.ai/docs/`. (`gemini` constants defined for future use; no Gemini sidepanel currently exists.)
58
58
  - **Banner styles** — Added `.cp-cli-missing*`, `.cxp-cli-missing*`, `.ocp-cli-missing*` rule sets to `ui-claude-panel.js` style block, `cdx/cdx-styles.js`, and `ocp/ocp-styles.js`. Includes `.cp-cli-blocked` / `.cxp-cli-blocked` / `.ocp-cli-blocked` panel classes for the dimmed input state.
59
59
 
60
+ ### Fixed — CLI-Not-Installed Banner Hidden After Errors / Broken-Binary Cases (Sidepanels)
61
+ - **Banner relocated to top of messages container** — Was rendered inside `.cxp-empty` / `.cp-empty` / `.ocp-empty`. Once any system message appeared (e.g. `Codex session closed` + `Codex app-server exited before responding to request 1`), the empty-state collapsed and took the banner with it. Banner is now an independent `:first-child` of `#cxp-messages-container`, `#cp-messages-container`, and `.ocp-messages-container` via `container.insertBefore(banner, container.firstChild)`. Stays visible across transcript states. New `ensureCliBannerEl()` / `removeCliBannerEl()` / `refreshCliBanner()` helpers replace the old empty-state-scoped `renderCliMissingBanner` / `refreshCliBanners` in `neural-interface/public/shared/cdx/cdx-panel.js`, `neural-interface/public/shared/ui-claude-panel.js`, and `neural-interface/public/shared/ocp/ocp-panel.js`.
62
+ - **Spawn-failure pattern detection** — Banner only triggered when `/api/system/tool-versions` reported `installed: null`. A broken-but-present binary (e.g. `codex 0.125.0` spawning then exiting immediately) reports a real version, so the banner never showed. Added a per-panel `_cliInstallFailureForced` flag flipped on by WS error patterns:
63
+ - **Codex** — `applySocketError` in `cdx/cdx-tabs.js` matches `/Codex CLI not found|ENOENT|app-server exited before responding|app-server spawn/i` → dynamic-imports `cdx-panel.js` and calls exported `flagCdxCliInstallFailure()`.
64
+ - **Claude** — `case 'error'` in WS handler matches `/Claude CLI not found|ENOENT/i` → `flagClaudeCliInstallFailure()`.
65
+ - **OpenCode** — `onError` callback matches `/opencode.*(not.*found|ENOENT|spawn)|opencode CLI/i` → `flagOcpCliInstallFailure()`.
66
+ - All three flags clear automatically when the `cli-status` subscriber callback later receives an installed value.
67
+ - **Send-button gate now reads forced flag** — Claude `send()`, Codex `sendPrompt()` (via `.cxp-cli-blocked` panel class read in `syncInputEnabled`), and OpenCode `syncSendButton` / `handleSend` all check `_cliInstalled === null || _cliInstallFailureForced` so a broken-binary install also blocks send.
68
+ - **Banner restyled as sticky alert** — New `.cxp-cli-banner*`, `.cp-cli-banner*`, `.ocp-cli-banner*` rule sets in `cdx/cdx-styles.js`, `ui-claude-panel.js` style block, and `ocp/ocp-styles.js`. Three-column grid (icon, text, actions), orange/amber theme, `flex-shrink: 0`, sits at top of the scroll region with `z-index: 5` so it stays above transcript content.
69
+
70
+ ### Fixed — High Severity npm audit Alerts in Install CLI (Neural Interface)
71
+ - **Playwright SSL cert verification CVE** — `neural-interface/package.json` pinned `playwright` at `1.54.2`, vulnerable to GHSA-7mvr-c777-76hp (browser downloader skipped SSL cert authenticity check). Bumped to `^1.59.1` (non-major) — fixed in `<1.55.1`. `setup.js` install flow no longer prints the high severity warning after `Installing MCP Server dependencies`.
72
+ - **lodash code injection + protobufjs RCE via transitives** — `lodash@4.17.23` (pulled by `archiver` → `archiver-utils@5.0.2`) had high severity GHSA-r5fr-rjxr-66jc (`_.template` import key code injection) plus moderate prototype pollution. `protobufjs@7.5.4` (pulled by `@huggingface/transformers` → `onnxruntime-web@1.22.0-dev.20250409-89f8206ba4`) had critical GHSA-xq3m-2v4x-88gg (arbitrary code execution). Added `overrides` block in `neural-interface/package.json` pinning `lodash: ^4.18.1` and `protobufjs: ^7.5.5` — both within the parents' implicit ranges, no breaking change.
73
+
74
+ ### Fixed — Moderate/High npm audit Alerts in Install CLI (MCP Server)
75
+ - **uuid buffer bounds check CVE** — `mcp-server/package.json` had `uuid@^11.1.0`, vulnerable to GHSA-w5hq-g745-h8pq (missing buffer bounds check in `v3`/`v5`/`v6` when `buf` provided), fixed in `<14.0.0`. Bumped to `^14.0.0` (semver major) — `v4` named export unchanged, `src/tools/remember.ts` and `src/tui.ts` compile cleanly with no source modifications.
76
+ - **hono / @hono/node-server / postcss / vite / protobufjs transitives** — multiple moderate-to-high CVEs in vitest's transitive `vite@8.0.0-8.0.4` (path traversal, `server.fs.deny` bypass, websocket arbitrary file read), `hono@<4.12.14` (cookie validation, path traversal in `toSSG()`, ipRestriction IPv4-mapped IPv6), `@hono/node-server@<1.19.13` (serveStatic repeated-slash bypass), `postcss@<8.5.10` (XSS via unescaped `</style>`), and `protobufjs@<7.5.5`. Added `overrides` block in `mcp-server/package.json` pinning `protobufjs: ^7.5.5`, `hono: ^4.12.14`, `@hono/node-server: ^1.19.13`, `postcss: ^8.5.10`, `vite: ^8.0.10` — patches only, no breaking semver bumps to direct deps.
77
+
78
+ ### Changed — Removed Deprecated `@types/uuid` devDependency
79
+ - **uuid v14 ships own type defs** — removed `@types/uuid: ^10.0.0` from `mcp-server/package.json` devDependencies. npm warned the stub package as deprecated (`This is a stub types definition. uuid provides its own type definitions, so you do not need this installed`). `tsc` build verified — no missing-types errors.
80
+
60
81
  ## 2026-04-24
61
82
 
62
83
  ### Added — "Continue planning" in OpenCode, Codex, and Claude plan UIs
@@ -24,15 +24,21 @@
24
24
  "@modelcontextprotocol/sdk": "^1.12.1",
25
25
  "chalk": "^5.6.2",
26
26
  "express": "^4.21.0",
27
- "uuid": "^11.1.0",
27
+ "uuid": "^14.0.0",
28
28
  "zod": "^3.24.2"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/express": "^5.0.0",
32
32
  "@types/node": "^22.12.0",
33
- "@types/uuid": "^10.0.0",
34
33
  "tsx": "^4.19.0",
35
34
  "typescript": "^5.9.3",
36
35
  "vitest": "^4.1.0"
36
+ },
37
+ "overrides": {
38
+ "protobufjs": "^7.5.5",
39
+ "hono": "^4.12.14",
40
+ "@hono/node-server": "^1.19.13",
41
+ "postcss": "^8.5.10",
42
+ "vite": "^8.0.10"
37
43
  }
38
44
  }
@@ -21,8 +21,12 @@
21
21
  "express": "^4.21.2",
22
22
  "node-html-markdown": "^2.0.0",
23
23
  "node-pty": "^1.1.0",
24
- "playwright": "1.54.2",
24
+ "playwright": "^1.59.1",
25
25
  "undici": "^8.1.0",
26
26
  "ws": "^8.19.0"
27
+ },
28
+ "overrides": {
29
+ "lodash": "^4.18.1",
30
+ "protobufjs": "^7.5.5"
27
31
  }
28
32
  }
@@ -394,71 +394,79 @@ function startupEmptyText() {
394
394
  }
395
395
 
396
396
  // ── CLI installation status (banner + send-block) ──
397
- function renderCliMissingBanner(emptyEl) {
398
- if (!emptyEl) return;
397
+ let _cliInstallFailureForced = false;
398
+
399
+ function ensureCliBannerEl() {
400
+ if (!_panel) return null;
401
+ const container = panelEl('#cxp-messages-container');
402
+ if (!container) return null;
403
+ let banner = container.querySelector(':scope > .cxp-cli-banner');
404
+ if (banner) return banner;
399
405
  const label = getCliLabel('codex');
400
406
  const cmd = getCliInstallCommand('codex');
401
407
  const url = getCliDocUrl('codex');
402
- const statusEl = emptyEl.querySelector('.cxp-empty-status');
403
- if (statusEl) statusEl.style.display = 'none';
404
- let banner = emptyEl.querySelector('.cxp-cli-missing');
405
- if (!banner) {
406
- banner = document.createElement('div');
407
- banner.className = 'cxp-cli-missing';
408
- banner.innerHTML = `
409
- <div class="cxp-cli-missing-title">${label} CLI not installed</div>
410
- <div class="cxp-cli-missing-body">Run: <code>${cmd}</code></div>
411
- <div class="cxp-cli-missing-actions">
412
- <a class="cxp-cli-missing-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
413
- <button class="cxp-cli-missing-recheck" type="button">Re-check</button>
414
- </div>
415
- `;
416
- emptyEl.appendChild(banner);
417
- const btn = banner.querySelector('.cxp-cli-missing-recheck');
418
- btn?.addEventListener('click', async () => {
419
- btn.disabled = true;
420
- btn.textContent = 'Checking…';
421
- try { await recheckCliStatus('codex'); }
422
- finally {
423
- if (btn.isConnected) {
424
- btn.disabled = false;
425
- btn.textContent = 'Re-check';
426
- }
408
+ banner = document.createElement('div');
409
+ banner.className = 'cxp-cli-banner';
410
+ banner.innerHTML = `
411
+ <div class="cxp-cli-banner-icon">!</div>
412
+ <div class="cxp-cli-banner-text">
413
+ <div class="cxp-cli-banner-title">${label} CLI not installed</div>
414
+ <div class="cxp-cli-banner-body">Run <code>${cmd}</code> or follow the install guide.</div>
415
+ </div>
416
+ <div class="cxp-cli-banner-actions">
417
+ <a class="cxp-cli-banner-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
418
+ <button class="cxp-cli-banner-recheck" type="button">Re-check</button>
419
+ </div>
420
+ `;
421
+ container.insertBefore(banner, container.firstChild);
422
+ const btn = banner.querySelector('.cxp-cli-banner-recheck');
423
+ btn?.addEventListener('click', async () => {
424
+ btn.disabled = true;
425
+ btn.textContent = 'Checking…';
426
+ try { await recheckCliStatus('codex'); }
427
+ finally {
428
+ if (btn.isConnected) {
429
+ btn.disabled = false;
430
+ btn.textContent = 'Re-check';
427
431
  }
428
- });
429
- }
432
+ }
433
+ });
434
+ return banner;
430
435
  }
431
436
 
432
- function refreshCliBanners() {
437
+ function removeCliBannerEl() {
433
438
  if (!_panel) return;
434
- const empties = _panel.querySelectorAll('.cxp-empty');
435
- if (_cliInstalled === null) {
436
- _panel.classList.add('cxp-cli-blocked');
437
- for (const el of empties) renderCliMissingBanner(el);
438
- } else {
439
- _panel.classList.remove('cxp-cli-blocked');
440
- for (const el of empties) {
441
- const banner = el.querySelector('.cxp-cli-missing');
442
- if (banner) banner.remove();
443
- const statusEl = el.querySelector('.cxp-empty-status');
444
- if (statusEl) statusEl.style.display = '';
445
- }
446
- }
447
- // Re-evaluate send button state with new CLI flag
439
+ const container = panelEl('#cxp-messages-container');
440
+ const banner = container?.querySelector(':scope > .cxp-cli-banner');
441
+ banner?.remove();
442
+ }
443
+
444
+ function refreshCliBanner() {
445
+ if (!_panel) return;
446
+ const missing = _cliInstalled === null || _cliInstallFailureForced;
447
+ _panel.classList.toggle('cxp-cli-blocked', missing);
448
+ if (missing) ensureCliBannerEl();
449
+ else removeCliBannerEl();
448
450
  try { syncInputEnabled(); } catch {}
449
451
  }
450
452
 
451
453
  export function isCdxCliInstalled() {
452
- return _cliInstalled !== null;
454
+ return _cliInstalled !== null && !_cliInstallFailureForced;
455
+ }
456
+
457
+ export function flagCdxCliInstallFailure() {
458
+ _cliInstallFailureForced = true;
459
+ refreshCliBanner();
460
+ recheckCliStatus('codex').catch(() => {});
453
461
  }
454
462
 
455
463
  function ensureCliSubscription() {
456
464
  if (_cliUnsub) return;
457
465
  _cliUnsub = subscribeCliStatus('codex', (info) => {
458
466
  _cliInstalled = info?.installed || null;
459
- refreshCliBanners();
467
+ if (_cliInstalled) _cliInstallFailureForced = false;
468
+ refreshCliBanner();
460
469
  });
461
- document.addEventListener('cdx-empty-rebuilt', () => refreshCliBanners());
462
470
  }
463
471
 
464
472
  // ═══════════════════════════════════════════
@@ -497,7 +505,7 @@ function setVisible(nextVisible) {
497
505
  loadCurrentProfile();
498
506
  loadRecallProfile();
499
507
  panelEl('#cxp-input')?.focus();
500
- refreshCliBanners();
508
+ refreshCliBanner();
501
509
  } else {
502
510
  document.removeEventListener('keydown', _cdxDocEscHandler, { capture: true });
503
511
  }
@@ -984,36 +984,68 @@ export function injectStyles() {
984
984
  .cxp-empty-logo svg { width: 32px; height: 32px; opacity: 0.25; }
985
985
  .cxp-empty-name { font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.35); letter-spacing: 0.02em; }
986
986
  .cxp-empty-status { font-size: 11px; color: rgba(255,255,255,0.28); line-height: 1.5; min-height: 1em; }
987
- .cxp-cli-missing {
988
- display: flex; flex-direction: column; align-items: center; gap: 8px;
989
- max-width: 320px; padding: 14px 16px; margin-top: 4px;
990
- border: 1px solid rgba(255,180,80,0.35); border-radius: 8px;
991
- background: rgba(255,180,80,0.06);
992
- }
993
- .cxp-cli-missing-title {
994
- font-size: 12px; font-weight: 600; color: rgba(255,200,120,0.9);
995
- letter-spacing: 0.02em;
996
- }
997
- .cxp-cli-missing-body {
998
- font-size: 11px; color: rgba(255,255,255,0.55); text-align: center; line-height: 1.45;
999
- }
1000
- .cxp-cli-missing-body code {
1001
- display: inline-block; padding: 1px 6px; margin-top: 4px;
1002
- background: rgba(0,0,0,0.35); border-radius: 4px; font-size: 10.5px;
1003
- color: rgba(255,255,255,0.75);
987
+ .cxp-cli-banner {
988
+ flex-shrink: 0;
989
+ display: grid;
990
+ grid-template-columns: auto 1fr auto;
991
+ gap: 10px;
992
+ align-items: center;
993
+ margin: 8px 12px 0;
994
+ padding: 10px 12px;
995
+ border: 1px solid rgba(255,180,80,0.45);
996
+ border-radius: 8px;
997
+ background: rgba(255,180,80,0.08);
998
+ color: rgba(255,235,200,0.92);
999
+ font-size: 11px;
1000
+ line-height: 1.4;
1001
+ position: relative;
1002
+ z-index: 5;
1003
+ }
1004
+ .cxp-cli-banner-icon {
1005
+ width: 22px; height: 22px;
1006
+ display: flex; align-items: center; justify-content: center;
1007
+ border-radius: 50%;
1008
+ background: rgba(255,180,80,0.25);
1009
+ color: rgba(255,210,140,1);
1010
+ font-weight: 700;
1011
+ }
1012
+ .cxp-cli-banner-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1013
+ .cxp-cli-banner-title {
1014
+ font-size: 12px; font-weight: 600;
1015
+ color: rgba(255,210,140,0.95);
1016
+ }
1017
+ .cxp-cli-banner-body {
1018
+ font-size: 11px;
1019
+ color: rgba(255,255,255,0.7);
1020
+ word-break: break-word;
1021
+ }
1022
+ .cxp-cli-banner-body code {
1023
+ padding: 1px 5px;
1024
+ background: rgba(0,0,0,0.35);
1025
+ border-radius: 3px;
1026
+ font-size: 10.5px;
1027
+ color: rgba(255,255,255,0.85);
1004
1028
  }
1005
- .cxp-cli-missing-actions { display: flex; gap: 8px; margin-top: 4px; }
1006
- .cxp-cli-missing-link, .cxp-cli-missing-recheck {
1029
+ .cxp-cli-banner-actions { display: flex; gap: 6px; }
1030
+ .cxp-cli-banner-link, .cxp-cli-banner-recheck {
1007
1031
  display: inline-flex; align-items: center; gap: 4px;
1008
- padding: 5px 10px; font-size: 11px; font-weight: 500;
1009
- border-radius: 5px; border: 1px solid rgba(255,255,255,0.15);
1010
- background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.85);
1011
- text-decoration: none; cursor: pointer; user-select: none;
1032
+ padding: 4px 9px;
1033
+ font-size: 11px;
1034
+ font-weight: 500;
1035
+ border-radius: 4px;
1036
+ border: 1px solid rgba(255,255,255,0.2);
1037
+ background: rgba(255,255,255,0.05);
1038
+ color: rgba(255,255,255,0.9);
1039
+ text-decoration: none;
1040
+ cursor: pointer;
1041
+ user-select: none;
1042
+ font-family: inherit;
1012
1043
  }
1013
- .cxp-cli-missing-link:hover, .cxp-cli-missing-recheck:hover {
1014
- background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.3);
1044
+ .cxp-cli-banner-link:hover, .cxp-cli-banner-recheck:hover {
1045
+ background: rgba(255,255,255,0.12);
1046
+ border-color: rgba(255,255,255,0.4);
1015
1047
  }
1016
- .cxp-cli-missing-recheck:disabled { opacity: 0.5; cursor: default; }
1048
+ .cxp-cli-banner-recheck:disabled { opacity: 0.5; cursor: default; }
1017
1049
  .codex-panel.cxp-cli-blocked .cxp-send { opacity: 0.4 !important; cursor: not-allowed !important; }
1018
1050
  .codex-panel.cxp-cli-blocked .cxp-input { opacity: 0.7; }
1019
1051
  .cxp-system {
@@ -552,6 +552,9 @@ function applySocketError(msg) {
552
552
  if (_compacting) setCompactingUI(false);
553
553
  appendSystem(text, 'error');
554
554
  setStatus(text, 'error');
555
+ if (/Codex CLI not found|ENOENT|app-server exited before responding|app-server spawn/i.test(text)) {
556
+ import('./cdx-panel.js').then((m) => m.flagCdxCliInstallFailure?.()).catch(() => {});
557
+ }
555
558
  if (msg.requestId) {
556
559
  if (_sessionListRequest?.requestId === msg.requestId) {
557
560
  _sessionListRequest.reject?.(new Error(text));
@@ -910,7 +910,7 @@ async function handleSend() {
910
910
  if (!input) return;
911
911
  const tab = activeTab();
912
912
  if (!tab) return;
913
- if (_cliInstalled === null) {
913
+ if (_cliInstalled === null || _cliInstallFailureForced) {
914
914
  setStatus('offline', 'OpenCode CLI not installed. Install it first, then click Re-check.');
915
915
  return;
916
916
  }
@@ -1013,7 +1013,7 @@ function syncSendButton() {
1013
1013
  const tab = activeTab();
1014
1014
  if (!sendBtn) return;
1015
1015
 
1016
- const cliBlocked = _cliInstalled === null;
1016
+ const cliBlocked = _cliInstalled === null || _cliInstallFailureForced;
1017
1017
 
1018
1018
  if (cliBlocked) {
1019
1019
  sendBtn.classList.remove('running');
@@ -1521,66 +1521,74 @@ function _ocpDocEscHandler(event) {
1521
1521
  }
1522
1522
 
1523
1523
  // ── CLI installation status (banner + send-block) ──
1524
- function renderCliMissingBanner(emptyEl) {
1525
- if (!emptyEl) return;
1524
+ let _cliInstallFailureForced = false;
1525
+
1526
+ function ensureCliBannerEl() {
1527
+ if (!_panel) return null;
1528
+ const container = _panel.querySelector('.ocp-messages-container');
1529
+ if (!container) return null;
1530
+ let banner = container.querySelector(':scope > .ocp-cli-banner');
1531
+ if (banner) return banner;
1526
1532
  const label = getCliLabel('opencode');
1527
1533
  const cmd = getCliInstallCommand('opencode');
1528
1534
  const url = getCliDocUrl('opencode');
1529
- const hintEl = emptyEl.querySelector('.ocp-empty-hint');
1530
- if (hintEl) hintEl.style.display = 'none';
1531
- let banner = emptyEl.querySelector('.ocp-cli-missing');
1532
- if (!banner) {
1533
- banner = document.createElement('div');
1534
- banner.className = 'ocp-cli-missing';
1535
- banner.innerHTML = `
1536
- <div class="ocp-cli-missing-title">${label} CLI not installed</div>
1537
- <div class="ocp-cli-missing-body">Run: <code>${cmd}</code></div>
1538
- <div class="ocp-cli-missing-actions">
1539
- <a class="ocp-cli-missing-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
1540
- <button class="ocp-cli-missing-recheck" type="button">Re-check</button>
1541
- </div>
1542
- `;
1543
- emptyEl.appendChild(banner);
1544
- const btn = banner.querySelector('.ocp-cli-missing-recheck');
1545
- btn?.addEventListener('click', async () => {
1546
- btn.disabled = true;
1547
- btn.textContent = 'Checking…';
1548
- try { await recheckCliStatus('opencode'); }
1549
- finally {
1550
- if (btn.isConnected) {
1551
- btn.disabled = false;
1552
- btn.textContent = 'Re-check';
1553
- }
1535
+ banner = document.createElement('div');
1536
+ banner.className = 'ocp-cli-banner';
1537
+ banner.innerHTML = `
1538
+ <div class="ocp-cli-banner-icon">!</div>
1539
+ <div class="ocp-cli-banner-text">
1540
+ <div class="ocp-cli-banner-title">${label} CLI not installed</div>
1541
+ <div class="ocp-cli-banner-body">Run <code>${cmd}</code> or follow the install guide.</div>
1542
+ </div>
1543
+ <div class="ocp-cli-banner-actions">
1544
+ <a class="ocp-cli-banner-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
1545
+ <button class="ocp-cli-banner-recheck" type="button">Re-check</button>
1546
+ </div>
1547
+ `;
1548
+ container.insertBefore(banner, container.firstChild);
1549
+ const btn = banner.querySelector('.ocp-cli-banner-recheck');
1550
+ btn?.addEventListener('click', async () => {
1551
+ btn.disabled = true;
1552
+ btn.textContent = 'Checking…';
1553
+ try { await recheckCliStatus('opencode'); }
1554
+ finally {
1555
+ if (btn.isConnected) {
1556
+ btn.disabled = false;
1557
+ btn.textContent = 'Re-check';
1554
1558
  }
1555
- });
1556
- }
1559
+ }
1560
+ });
1561
+ return banner;
1557
1562
  }
1558
1563
 
1559
- function refreshCliBanners() {
1564
+ function removeCliBannerEl() {
1560
1565
  if (!_panel) return;
1561
- const empties = _panel.querySelectorAll('.ocp-empty');
1562
- if (_cliInstalled === null) {
1563
- _panel.classList.add('ocp-cli-blocked');
1564
- for (const el of empties) renderCliMissingBanner(el);
1565
- } else {
1566
- _panel.classList.remove('ocp-cli-blocked');
1567
- for (const el of empties) {
1568
- const banner = el.querySelector('.ocp-cli-missing');
1569
- if (banner) banner.remove();
1570
- const hintEl = el.querySelector('.ocp-empty-hint');
1571
- if (hintEl) hintEl.style.display = '';
1572
- }
1573
- }
1566
+ const banner = _panel.querySelector('.ocp-messages-container > .ocp-cli-banner');
1567
+ banner?.remove();
1568
+ }
1569
+
1570
+ function refreshCliBanner() {
1571
+ if (!_panel) return;
1572
+ const missing = _cliInstalled === null || _cliInstallFailureForced;
1573
+ _panel.classList.toggle('ocp-cli-blocked', missing);
1574
+ if (missing) ensureCliBannerEl();
1575
+ else removeCliBannerEl();
1574
1576
  syncSendButton();
1575
1577
  }
1576
1578
 
1579
+ export function flagOcpCliInstallFailure() {
1580
+ _cliInstallFailureForced = true;
1581
+ refreshCliBanner();
1582
+ recheckCliStatus('opencode').catch(() => {});
1583
+ }
1584
+
1577
1585
  function ensureCliSubscription() {
1578
1586
  if (_cliUnsub) return;
1579
1587
  _cliUnsub = subscribeCliStatus('opencode', (info) => {
1580
1588
  _cliInstalled = info?.installed || null;
1581
- refreshCliBanners();
1589
+ if (_cliInstalled) _cliInstallFailureForced = false;
1590
+ refreshCliBanner();
1582
1591
  });
1583
- document.addEventListener('ocp-empty-rebuilt', () => refreshCliBanners());
1584
1592
  }
1585
1593
 
1586
1594
  function setVisible(nextVisible) {
@@ -1608,7 +1616,7 @@ function setVisible(nextVisible) {
1608
1616
  if (_curProject) loadBranches(_curProject);
1609
1617
  loadCurrentProfile();
1610
1618
  loadRecallProfile();
1611
- refreshCliBanners();
1619
+ refreshCliBanner();
1612
1620
  } else {
1613
1621
  document.removeEventListener('keydown', _ocpDocEscHandler, { capture: true });
1614
1622
  }
@@ -1664,6 +1672,9 @@ function wsCallbacks() {
1664
1672
  },
1665
1673
  onError(msg) {
1666
1674
  console.error('[ocp] Error:', msg.message);
1675
+ if (typeof msg.message === 'string' && /opencode.*(not.*found|ENOENT|spawn)|opencode CLI/i.test(msg.message)) {
1676
+ flagOcpCliInstallFailure();
1677
+ }
1667
1678
  },
1668
1679
  };
1669
1680
  }
@@ -1521,38 +1521,68 @@ export function injectStyles() {
1521
1521
  stroke: currentColor; fill: none;
1522
1522
  stroke-width: 1; opacity: 0.3;
1523
1523
  }
1524
- .ocp-cli-missing {
1525
- display: flex; flex-direction: column; align-items: center; gap: 8px;
1526
- max-width: 320px; padding: 14px 16px; margin-top: 4px;
1527
- border: 1px solid rgba(255,180,80,0.35); border-radius: 8px;
1528
- background: rgba(255,180,80,0.06);
1529
- color: rgba(255,255,255,0.6);
1530
- }
1531
- .ocp-cli-missing-title {
1532
- font-size: 12px; font-weight: 600; color: rgba(255,200,120,0.9);
1533
- letter-spacing: 0.02em;
1524
+ .ocp-cli-banner {
1525
+ flex-shrink: 0;
1526
+ display: grid;
1527
+ grid-template-columns: auto 1fr auto;
1528
+ gap: 10px;
1529
+ align-items: center;
1530
+ margin: 8px 12px 0;
1531
+ padding: 10px 12px;
1532
+ border: 1px solid rgba(255,180,80,0.45);
1533
+ border-radius: 8px;
1534
+ background: rgba(255,180,80,0.08);
1535
+ color: rgba(255,235,200,0.92);
1536
+ font-size: 11px;
1537
+ line-height: 1.4;
1538
+ position: relative;
1539
+ z-index: 5;
1534
1540
  }
1535
- .ocp-cli-missing-body {
1536
- font-size: 11px; color: rgba(255,255,255,0.55); text-align: center; line-height: 1.45;
1541
+ .ocp-cli-banner-icon {
1542
+ width: 22px; height: 22px;
1543
+ display: flex; align-items: center; justify-content: center;
1544
+ border-radius: 50%;
1545
+ background: rgba(255,180,80,0.25);
1546
+ color: rgba(255,210,140,1);
1547
+ font-weight: 700;
1537
1548
  }
1538
- .ocp-cli-missing-body code {
1539
- display: inline-block; padding: 1px 6px; margin-top: 4px;
1540
- background: rgba(0,0,0,0.35); border-radius: 4px; font-size: 10.5px;
1541
- color: rgba(255,255,255,0.75);
1549
+ .ocp-cli-banner-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
1550
+ .ocp-cli-banner-title {
1551
+ font-size: 12px; font-weight: 600;
1552
+ color: rgba(255,210,140,0.95);
1542
1553
  }
1543
- .ocp-cli-missing-actions { display: flex; gap: 8px; margin-top: 4px; }
1544
- .ocp-cli-missing-link, .ocp-cli-missing-recheck {
1554
+ .ocp-cli-banner-body {
1555
+ font-size: 11px;
1556
+ color: rgba(255,255,255,0.7);
1557
+ word-break: break-word;
1558
+ }
1559
+ .ocp-cli-banner-body code {
1560
+ padding: 1px 5px;
1561
+ background: rgba(0,0,0,0.35);
1562
+ border-radius: 3px;
1563
+ font-size: 10.5px;
1564
+ color: rgba(255,255,255,0.85);
1565
+ }
1566
+ .ocp-cli-banner-actions { display: flex; gap: 6px; }
1567
+ .ocp-cli-banner-link, .ocp-cli-banner-recheck {
1545
1568
  display: inline-flex; align-items: center; gap: 4px;
1546
- padding: 5px 10px; font-size: 11px; font-weight: 500;
1547
- border-radius: 5px; border: 1px solid rgba(255,255,255,0.15);
1548
- background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.85);
1549
- text-decoration: none; cursor: pointer; user-select: none;
1569
+ padding: 4px 9px;
1570
+ font-size: 11px;
1571
+ font-weight: 500;
1572
+ border-radius: 4px;
1573
+ border: 1px solid rgba(255,255,255,0.2);
1574
+ background: rgba(255,255,255,0.05);
1575
+ color: rgba(255,255,255,0.9);
1576
+ text-decoration: none;
1577
+ cursor: pointer;
1578
+ user-select: none;
1550
1579
  font-family: inherit;
1551
1580
  }
1552
- .ocp-cli-missing-link:hover, .ocp-cli-missing-recheck:hover {
1553
- background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.3);
1581
+ .ocp-cli-banner-link:hover, .ocp-cli-banner-recheck:hover {
1582
+ background: rgba(255,255,255,0.12);
1583
+ border-color: rgba(255,255,255,0.4);
1554
1584
  }
1555
- .ocp-cli-missing-recheck:disabled { opacity: 0.5; cursor: default; }
1585
+ .ocp-cli-banner-recheck:disabled { opacity: 0.5; cursor: default; }
1556
1586
  .ocp-panel.ocp-cli-blocked .ocp-input { opacity: 0.7; }
1557
1587
 
1558
1588
  /* ── Thinking indicator ── */
@@ -432,38 +432,70 @@ function injectStyles() {
432
432
  .cp-messages:has(.msg) .cp-empty,
433
433
  .cp-messages:has(.msg-status) .cp-empty { display: none; }
434
434
 
435
- /* ── CLI-not-installed banner (replaces empty-state hint when claude binary missing) ── */
436
- .cp-cli-missing {
437
- display: flex; flex-direction: column; align-items: center; gap: 8px;
438
- max-width: 320px; padding: 14px 16px; margin-top: 4px;
439
- border: 1px solid rgba(255,180,80,0.35); border-radius: 8px;
440
- background: rgba(255,180,80,0.06); pointer-events: auto;
441
- }
442
- .cp-cli-missing-title {
443
- font-size: 12px; font-weight: 600; color: rgba(255,200,120,0.9);
444
- letter-spacing: 0.02em;
435
+ /* ── CLI-not-installed banner (sticky top of messages container) ── */
436
+ .cp-cli-banner {
437
+ flex-shrink: 0;
438
+ display: grid;
439
+ grid-template-columns: auto 1fr auto;
440
+ gap: 10px;
441
+ align-items: center;
442
+ margin: 8px 12px 0;
443
+ padding: 10px 12px;
444
+ border: 1px solid rgba(255,180,80,0.45);
445
+ border-radius: 8px;
446
+ background: rgba(255,180,80,0.08);
447
+ color: rgba(255,235,200,0.92);
448
+ font-size: 11px;
449
+ line-height: 1.4;
450
+ pointer-events: auto;
451
+ position: relative;
452
+ z-index: 5;
445
453
  }
446
- .cp-cli-missing-body {
447
- font-size: 11px; color: rgba(255,255,255,0.55); text-align: center; line-height: 1.45;
454
+ .cp-cli-banner-icon {
455
+ width: 22px; height: 22px;
456
+ display: flex; align-items: center; justify-content: center;
457
+ border-radius: 50%;
458
+ background: rgba(255,180,80,0.25);
459
+ color: rgba(255,210,140,1);
460
+ font-weight: 700;
448
461
  }
449
- .cp-cli-missing-body code {
450
- display: inline-block; padding: 1px 6px; margin-top: 4px;
451
- background: rgba(0,0,0,0.35); border-radius: 4px; font-size: 10.5px;
452
- color: rgba(255,255,255,0.75);
462
+ .cp-cli-banner-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
463
+ .cp-cli-banner-title {
464
+ font-size: 12px; font-weight: 600;
465
+ color: rgba(255,210,140,0.95);
453
466
  }
454
- .cp-cli-missing-actions { display: flex; gap: 8px; margin-top: 4px; }
455
- .cp-cli-missing-link, .cp-cli-missing-recheck {
467
+ .cp-cli-banner-body {
468
+ font-size: 11px;
469
+ color: rgba(255,255,255,0.7);
470
+ word-break: break-word;
471
+ }
472
+ .cp-cli-banner-body code {
473
+ padding: 1px 5px;
474
+ background: rgba(0,0,0,0.35);
475
+ border-radius: 3px;
476
+ font-size: 10.5px;
477
+ color: rgba(255,255,255,0.85);
478
+ }
479
+ .cp-cli-banner-actions { display: flex; gap: 6px; }
480
+ .cp-cli-banner-link, .cp-cli-banner-recheck {
456
481
  display: inline-flex; align-items: center; gap: 4px;
457
- padding: 5px 10px; font-size: 11px; font-weight: 500;
458
- border-radius: 5px; border: 1px solid rgba(255,255,255,0.15);
459
- background: rgba(255,255,255,0.04); color: rgba(255,255,255,0.85);
460
- text-decoration: none; cursor: pointer; user-select: none;
482
+ padding: 4px 9px;
483
+ font-size: 11px;
484
+ font-weight: 500;
485
+ border-radius: 4px;
486
+ border: 1px solid rgba(255,255,255,0.2);
487
+ background: rgba(255,255,255,0.05);
488
+ color: rgba(255,255,255,0.9);
489
+ text-decoration: none;
490
+ cursor: pointer;
491
+ user-select: none;
492
+ font-family: inherit;
461
493
  }
462
- .cp-cli-missing-link:hover, .cp-cli-missing-recheck:hover {
463
- background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.3);
494
+ .cp-cli-banner-link:hover, .cp-cli-banner-recheck:hover {
495
+ background: rgba(255,255,255,0.12);
496
+ border-color: rgba(255,255,255,0.4);
464
497
  }
465
- .cp-cli-missing-recheck:disabled { opacity: 0.5; cursor: default; }
466
- /* Block send button visually + functionally when CLI missing */
498
+ .cp-cli-banner-recheck:disabled { opacity: 0.5; cursor: default; }
467
499
  .claude-panel.cp-cli-blocked .cp-send { opacity: 0.4 !important; cursor: not-allowed !important; }
468
500
  .claude-panel.cp-cli-blocked .cp-input { opacity: 0.7; }
469
501
 
@@ -2752,7 +2784,6 @@ function createTab(sessionId = null, label = 'New chat', { autoSwitch = true, id
2752
2784
  msgDiv.innerHTML = `<div class="cp-empty"><div class="cp-empty-logo">${CLAUDE_ICON}</div><span class="cp-empty-name">Claude</span><span class="cp-empty-hint">Send a message to start</span></div>`;
2753
2785
  container.appendChild(msgDiv);
2754
2786
  tab.messagesEl = msgDiv;
2755
- if (_cliInstalled === null) _renderCliMissingBanner(msgDiv.querySelector('.cp-empty'));
2756
2787
  // Connect WebSocket
2757
2788
  connectTab(tab);
2758
2789
  // Create pill (rendered in shared tray, not inside panel)
@@ -3576,7 +3607,6 @@ async function selectSession(sid, label) {
3576
3607
  const btn = _panel?.querySelector('.cp-session-label');
3577
3608
  if (btn) btn.textContent = tab.label;
3578
3609
  tab.messagesEl.innerHTML = `<div class="cp-empty"><div class="cp-empty-logo">${CLAUDE_ICON}</div><span class="cp-empty-name">Claude</span><span class="cp-empty-hint">Send a message to start</span></div>`;
3579
- if (_cliInstalled === null) _renderCliMissingBanner(tab.messagesEl.querySelector('.cp-empty'));
3580
3610
  if (sid) {
3581
3611
  loadSessionHistory(sid, tab.messagesEl);
3582
3612
  // Restore session cost from server
@@ -4371,6 +4401,9 @@ function _processTabMsg(tab, msg) {
4371
4401
  if (tab.compacting) { tab.compacting = false; if (tab === activeTab()) _setCompactingUI(false); }
4372
4402
  appendError(tab, msg.message);
4373
4403
  notify('panel', NOTIF_TYPE.ERROR, tab.label || 'Claude Code', { tabId: tab.id });
4404
+ if (typeof msg.message === 'string' && /Claude CLI not found|ENOENT/i.test(msg.message)) {
4405
+ flagClaudeCliInstallFailure();
4406
+ }
4374
4407
  break;
4375
4408
  }
4376
4409
  }
@@ -6586,7 +6619,7 @@ function runSlashCommand(tab, raw) {
6586
6619
  }
6587
6620
 
6588
6621
  function send({ shift = false } = {}) {
6589
- if (_cliInstalled === null) {
6622
+ if (_cliInstalled === null || _cliInstallFailureForced) {
6590
6623
  notify({ type: NOTIF_TYPE.WARN, title: 'Claude CLI not installed', body: 'Install the Claude Code CLI before sending a message.' });
6591
6624
  return;
6592
6625
  }
@@ -7293,65 +7326,73 @@ function syncReservedWidth() {
7293
7326
  }
7294
7327
 
7295
7328
  // ── CLI installation status (banner + send-block) ──
7296
- function _renderCliMissingBanner(emptyEl) {
7297
- if (!emptyEl) return;
7329
+ let _cliInstallFailureForced = false;
7330
+
7331
+ function _ensureCliBannerEl() {
7332
+ if (!_panel) return null;
7333
+ const container = _panel.querySelector('#cp-messages-container');
7334
+ if (!container) return null;
7335
+ let banner = container.querySelector(':scope > .cp-cli-banner');
7336
+ if (banner) return banner;
7298
7337
  const label = getCliLabel('claude-code');
7299
7338
  const cmd = getCliInstallCommand('claude-code');
7300
7339
  const url = getCliDocUrl('claude-code');
7301
- const hint = emptyEl.querySelector('.cp-empty-hint');
7302
- if (hint) hint.style.display = 'none';
7303
- let banner = emptyEl.querySelector('.cp-cli-missing');
7304
- if (!banner) {
7305
- banner = document.createElement('div');
7306
- banner.className = 'cp-cli-missing';
7307
- banner.innerHTML = `
7308
- <div class="cp-cli-missing-title">${label} CLI not installed</div>
7309
- <div class="cp-cli-missing-body">Run: <code>${cmd}</code></div>
7310
- <div class="cp-cli-missing-actions">
7311
- <a class="cp-cli-missing-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
7312
- <button class="cp-cli-missing-recheck" type="button">Re-check</button>
7313
- </div>
7314
- `;
7315
- emptyEl.appendChild(banner);
7316
- const btn = banner.querySelector('.cp-cli-missing-recheck');
7317
- btn?.addEventListener('click', async () => {
7318
- btn.disabled = true;
7319
- btn.textContent = 'Checking…';
7320
- try { await recheckCliStatus('claude-code'); }
7321
- finally {
7322
- if (btn.isConnected) {
7323
- btn.disabled = false;
7324
- btn.textContent = 'Re-check';
7325
- }
7340
+ banner = document.createElement('div');
7341
+ banner.className = 'cp-cli-banner';
7342
+ banner.innerHTML = `
7343
+ <div class="cp-cli-banner-icon">!</div>
7344
+ <div class="cp-cli-banner-text">
7345
+ <div class="cp-cli-banner-title">${label} CLI not installed</div>
7346
+ <div class="cp-cli-banner-body">Run <code>${cmd}</code> or follow the install guide.</div>
7347
+ </div>
7348
+ <div class="cp-cli-banner-actions">
7349
+ <a class="cp-cli-banner-link" href="${url}" target="_blank" rel="noopener noreferrer">Install guide</a>
7350
+ <button class="cp-cli-banner-recheck" type="button">Re-check</button>
7351
+ </div>
7352
+ `;
7353
+ container.insertBefore(banner, container.firstChild);
7354
+ const btn = banner.querySelector('.cp-cli-banner-recheck');
7355
+ btn?.addEventListener('click', async () => {
7356
+ btn.disabled = true;
7357
+ btn.textContent = 'Checking…';
7358
+ try { await recheckCliStatus('claude-code'); }
7359
+ finally {
7360
+ if (btn.isConnected) {
7361
+ btn.disabled = false;
7362
+ btn.textContent = 'Re-check';
7326
7363
  }
7327
- });
7328
- }
7364
+ }
7365
+ });
7366
+ return banner;
7367
+ }
7368
+
7369
+ function _removeCliBannerEl() {
7370
+ if (!_panel) return;
7371
+ const banner = _panel.querySelector('#cp-messages-container > .cp-cli-banner');
7372
+ banner?.remove();
7329
7373
  }
7330
7374
 
7331
7375
  function _refreshCliBanners() {
7332
7376
  if (!_panel) return;
7333
- const empties = _panel.querySelectorAll('.cp-empty');
7334
- if (_cliInstalled === null) {
7335
- _panel.classList.add('cp-cli-blocked');
7336
- for (const el of empties) _renderCliMissingBanner(el);
7337
- } else {
7338
- _panel.classList.remove('cp-cli-blocked');
7339
- for (const el of empties) {
7340
- const banner = el.querySelector('.cp-cli-missing');
7341
- if (banner) banner.remove();
7342
- const hint = el.querySelector('.cp-empty-hint');
7343
- if (hint) hint.style.display = '';
7344
- }
7345
- }
7346
- // Re-sync send button state — installed transition should re-enable input drafts
7377
+ const missing = _cliInstalled === null || _cliInstallFailureForced;
7378
+ _panel.classList.toggle('cp-cli-blocked', missing);
7379
+ if (missing) _ensureCliBannerEl();
7380
+ else _removeCliBannerEl();
7347
7381
  const $send = _panel.querySelector('#cp-send');
7348
- if ($send && _cliInstalled === null) $send.disabled = true;
7382
+ if ($send && missing) $send.disabled = true;
7383
+ }
7384
+
7385
+ export function flagClaudeCliInstallFailure() {
7386
+ _cliInstallFailureForced = true;
7387
+ _refreshCliBanners();
7388
+ recheckCliStatus('claude-code').catch(() => {});
7349
7389
  }
7350
7390
 
7351
7391
  function _ensureCliSubscription() {
7352
7392
  if (_cliUnsub) return;
7353
7393
  _cliUnsub = subscribeCliStatus('claude-code', (info) => {
7354
7394
  _cliInstalled = info?.installed || null;
7395
+ if (_cliInstalled) _cliInstallFailureForced = false;
7355
7396
  _refreshCliBanners();
7356
7397
  });
7357
7398
  }
@@ -10094,11 +10094,9 @@ async function checkNpmUpdate() {
10094
10094
  const data = await res.json();
10095
10095
  const latest = data.version;
10096
10096
 
10097
- const cur = current.split('.').map(Number);
10098
- const lat = latest.split('.').map(Number);
10099
- const updateAvailable = lat[0] > cur[0]
10100
- || (lat[0] === cur[0] && lat[1] > cur[1])
10101
- || (lat[0] === cur[0] && lat[1] === cur[1] && lat[2] > cur[2]);
10097
+ const cleanCur = parseVersion(current) || current;
10098
+ const cleanLat = parseVersion(latest) || latest;
10099
+ const updateAvailable = semverNewer(cleanCur, cleanLat);
10102
10100
 
10103
10101
  _npmUpdateCache = { current, latest, updateAvailable, checkedAt: new Date().toISOString() };
10104
10102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synabun",
3
- "version": "2026.4.25-beta.2",
3
+ "version": "2026.4.26-beta",
4
4
  "description": "Persistent vector memory for AI assistants",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The SynaBun Authors",