synabun 2026.4.25-beta.1 → 2026.4.25-beta.3

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
@@ -43,6 +43,41 @@ Raw internal changelogs are archived in [changelog/](changelog/).
43
43
  ### Changed — Claude Restored Tab Selection
44
44
  - **Restored Claude tabs keep stable IDs** — Updated `createTab()` in `ui-claude-panel.js` to accept a restored `id`, and changed `restoreTabs()` to pass saved tab IDs through. Placeholder click-through can now target the restored Claude tab instead of losing selection because the panel generated a fresh `crypto.randomUUID()` during bootstrap.
45
45
 
46
+ ### Changed — Backup System Coverage Expanded
47
+ - **Backup file lists extended** — Added `stored-plans.json`, `synabun-plugins.json`, `mcp-registry.json`, and `active-profile.json` to both manual `GET /api/system/backup` (`neural-interface/server.js:10457`) and auto-backup `buildBackupZipToFile()` (`neural-interface/server.js:10844`) file arrays. These cover the post-plan hook plan→memory tracker, plugin install registry, third-party MCP server registry + profile defs, and active MCP profile selection — all previously lost on restore.
48
+ - **Plan markdown history backed up recursively** — Added `addDirRecursive('data/plans', ...)` to both manual and auto backup paths (`neural-interface/server.js:10483`, `:10868`) so the date-sliced plan tree (`data/plans/YYYY-MM-DD/*.md`) rides along.
49
+ - **Restore creates `plans` subdir** — Added `'plans'` to the `mkdirSync` subdir list in `POST /api/system/restore` (`neural-interface/server.js:10617`). Existing recursive `data/` write loop already extracts plan files via `dirname(target)` mkdir.
50
+ - **Backward compatible with old backups** — Manifest version check still accepts `version 1 || 2`. Old backups restore exactly as before — missing new files skipped silently, no schema migration required.
51
+
52
+ ### Added — CLI-Not-Installed Indicator in Sidepanels
53
+ - **Empty-state banner across Claude, Codex, OpenCode panels** — Detects when the corresponding CLI binary is missing and renders a warning banner inside the panel's empty state (`.cp-empty` / `.cxp-empty` / `.ocp-empty`) with the install command, an "Install guide" link to the official docs, and a "Re-check" button. Banner is removed automatically once the CLI is detected.
54
+ - **Shared status module `cli-status.js`** — New `neural-interface/public/shared/cli-status.js` wraps the existing `/api/system/tool-versions` endpoint with a subscriber + auto-poll layer. Exposes `subscribeCliStatus(toolKey, cb)`, `recheckCliStatus(toolKey)`, `getCliDocUrl(toolKey)`, `getCliInstallCommand(toolKey)`, `getCliLabel(toolKey)`. Polls every 30s while at least one subscribed tool reports `installed === null`; stops once detected or unsubscribed. 10s in-memory cache + single in-flight fetch dedup.
55
+ - **Send-button gating** — Each panel's send/input flow is now blocked while the CLI is missing. `ui-claude-panel.js` `send()` returns early with a notification. `cdx/cdx-tabs.js` `syncInputEnabled` reads the `.cxp-cli-blocked` panel class to force `send.disabled = true`, and `sendPrompt()` returns early with a system message. `ocp/ocp-panel.js` `syncSendButton` short-circuits to disabled with the "OpenCode CLI not installed" tooltip; `handleSend` returns early via `setStatus('offline', …)`.
56
+ - **Banner re-render hooks** — `cdx/cdx-tabs.js` `clearTranscript` now dispatches a `cdx-empty-rebuilt` CustomEvent and `ocp/ocp-render.js` `renderEmptyState` dispatches `ocp-empty-rebuilt`, so the banner re-injects itself after the empty state is rebuilt during session resets.
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
+ - **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
+
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
+
46
81
  ## 2026-04-24
47
82
 
48
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
  }
@@ -4,6 +4,7 @@
4
4
  import { storage } from '../storage.js';
5
5
  import { state, emit, on } from '../state.js';
6
6
  import { reserveRightPanelLayout, clearRightPanelLayout } from '../ui-sidepanel-layout.js';
7
+ import { subscribeCliStatus, recheckCliStatus, getCliDocUrl, getCliInstallCommand, getCliLabel } from '../cli-status.js';
7
8
  import { isClaudePanelOpen, toggleClaudePanel } from '../ui-claude-panel.js';
8
9
  import { injectStyles } from './cdx-styles.js';
9
10
  import { appendAssistantMarkdownMessage, flushCardBody, renderPostPlanActions } from './cdx-render.js';
@@ -46,6 +47,8 @@ document.addEventListener('visibilitychange', () => {
46
47
  let _panel = null;
47
48
  let _visible = false;
48
49
  let _resizeBound = false;
50
+ let _cliInstalled = null;
51
+ let _cliUnsub = null;
49
52
 
50
53
  // ═══════════════════════════════════════════
51
54
  // Bound State (mirrors active tab — managed by cdx-tabs)
@@ -390,6 +393,82 @@ function startupEmptyText() {
390
393
  return '';
391
394
  }
392
395
 
396
+ // ── CLI installation status (banner + send-block) ──
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;
405
+ const label = getCliLabel('codex');
406
+ const cmd = getCliInstallCommand('codex');
407
+ const url = getCliDocUrl('codex');
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';
431
+ }
432
+ }
433
+ });
434
+ return banner;
435
+ }
436
+
437
+ function removeCliBannerEl() {
438
+ if (!_panel) return;
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();
450
+ try { syncInputEnabled(); } catch {}
451
+ }
452
+
453
+ export function isCdxCliInstalled() {
454
+ return _cliInstalled !== null && !_cliInstallFailureForced;
455
+ }
456
+
457
+ export function flagCdxCliInstallFailure() {
458
+ _cliInstallFailureForced = true;
459
+ refreshCliBanner();
460
+ recheckCliStatus('codex').catch(() => {});
461
+ }
462
+
463
+ function ensureCliSubscription() {
464
+ if (_cliUnsub) return;
465
+ _cliUnsub = subscribeCliStatus('codex', (info) => {
466
+ _cliInstalled = info?.installed || null;
467
+ if (_cliInstalled) _cliInstallFailureForced = false;
468
+ refreshCliBanner();
469
+ });
470
+ }
471
+
393
472
  // ═══════════════════════════════════════════
394
473
  // setVisible — Show / Hide Panel
395
474
  // ═══════════════════════════════════════════
@@ -426,6 +505,7 @@ function setVisible(nextVisible) {
426
505
  loadCurrentProfile();
427
506
  loadRecallProfile();
428
507
  panelEl('#cxp-input')?.focus();
508
+ refreshCliBanner();
429
509
  } else {
430
510
  document.removeEventListener('keydown', _cdxDocEscHandler, { capture: true });
431
511
  }
@@ -971,6 +1051,7 @@ function ensurePanel() {
971
1051
  autosizeInput();
972
1052
  restoreTabs();
973
1053
  syncInputEnabled();
1054
+ ensureCliSubscription();
974
1055
  window.dispatchEvent(new CustomEvent('sidepanel-tray:provider-loaded', { detail: { provider: 'codex' } }));
975
1056
  // Close Codex when a Claude/OpenCode pill is clicked (mutual exclusion)
976
1057
  const tray = document.getElementById('term-minimized-tray');
@@ -984,6 +984,70 @@ 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-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);
1028
+ }
1029
+ .cxp-cli-banner-actions { display: flex; gap: 6px; }
1030
+ .cxp-cli-banner-link, .cxp-cli-banner-recheck {
1031
+ display: inline-flex; align-items: center; gap: 4px;
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;
1043
+ }
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);
1047
+ }
1048
+ .cxp-cli-banner-recheck:disabled { opacity: 0.5; cursor: default; }
1049
+ .codex-panel.cxp-cli-blocked .cxp-send { opacity: 0.4 !important; cursor: not-allowed !important; }
1050
+ .codex-panel.cxp-cli-blocked .cxp-input { opacity: 0.7; }
987
1051
  .cxp-system {
988
1052
  align-self: center;
989
1053
  max-width: 92%;
@@ -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));
@@ -2041,6 +2044,7 @@ function startupEmptyText() {
2041
2044
  export function clearTranscript(emptyText = freshThreadText()) {
2042
2045
  if (!_messagesEl) return;
2043
2046
  _messagesEl.innerHTML = `<div class="cxp-empty"><div class="cxp-empty-logo">${OPENAI_ICON}</div><span class="cxp-empty-name">Codex</span><span class="cxp-empty-status"></span></div>`;
2047
+ document.dispatchEvent(new CustomEvent('cdx-empty-rebuilt'));
2044
2048
  _items.clear();
2045
2049
  _requestCards.clear();
2046
2050
  _blockingServerRequests = new Map();
@@ -3271,11 +3275,12 @@ export function syncInputEnabled() {
3271
3275
  const hasPaths = !!(tab?.pendingPaths?.length);
3272
3276
  const blockedByRequest = hasBlockingServerRequests();
3273
3277
  if (input) input.disabled = !_connected || !_bootstrapped || _startingThread || blockedByRequest;
3278
+ const cliBlocked = document.getElementById('codex-panel')?.classList.contains('cxp-cli-blocked');
3274
3279
  if (send) {
3275
3280
  const isStopMode = !!(tab?.running || _running);
3276
3281
  send.classList.toggle('cxp-send-stopping', !!isStopMode);
3277
- send.title = isStopMode ? 'Stop' : 'Send';
3278
- send.disabled = isStopMode ? false : (!_connected || !_bootstrapped || _running || _startingThread || blockedByRequest || (!hasText && !hasImages && !hasPaths));
3282
+ send.title = cliBlocked ? 'Codex CLI not installed' : (isStopMode ? 'Stop' : 'Send');
3283
+ send.disabled = cliBlocked || (isStopMode ? false : (!_connected || !_bootstrapped || _running || _startingThread || blockedByRequest || (!hasText && !hasImages && !hasPaths)));
3279
3284
  }
3280
3285
  if (fresh) {
3281
3286
  pruneDiscardableTabs({ keepTab: tab, persist: true });
@@ -4528,6 +4533,12 @@ export function sendPrompt() {
4528
4533
  const { input, tab, prompt, images, paths } = payload;
4529
4534
  if (!input || !_connected || !_bootstrapped) return;
4530
4535
  if (!prompt && !images.length && !paths.length) return;
4536
+ // Block when CLI binary missing — banner already informs user.
4537
+ const panel = document.getElementById('codex-panel');
4538
+ if (panel?.classList.contains('cxp-cli-blocked')) {
4539
+ appendSystem('Codex CLI not installed. Install it first, then click Re-check.', 'error');
4540
+ return;
4541
+ }
4531
4542
  handleLocalSlashCommand(payload).then((handled) => {
4532
4543
  if (handled) return;
4533
4544
  if (_running && tab) {
@@ -0,0 +1,157 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun — CLI Status (shared)
3
+ // Detects whether Claude / Codex / OpenCode CLIs are installed by
4
+ // querying /api/system/tool-versions. Caches the response, fans out
5
+ // updates to subscribers, and auto-polls (every 30s) while at least
6
+ // one subscribed tool reports installed=null.
7
+ // ═══════════════════════════════════════════
8
+
9
+ const TOOL_KEYS = ['claude-code', 'codex', 'opencode', 'gemini'];
10
+
11
+ const CLI_DOC_URLS = {
12
+ 'claude-code': 'https://docs.claude.com/en/docs/claude-code/setup',
13
+ 'codex': 'https://developers.openai.com/codex/cli/',
14
+ 'opencode': 'https://opencode.ai/docs/',
15
+ 'gemini': 'https://github.com/google-gemini/gemini-cli',
16
+ };
17
+
18
+ const CLI_INSTALL_CMDS = {
19
+ 'claude-code': 'npm install -g @anthropic-ai/claude-code',
20
+ 'codex': 'npm install -g @openai/codex',
21
+ 'opencode': 'npm install -g opencode-ai',
22
+ 'gemini': 'npm install -g @google/gemini-cli',
23
+ };
24
+
25
+ const CLI_LABELS = {
26
+ 'claude-code': 'Claude Code',
27
+ 'codex': 'Codex',
28
+ 'opencode': 'OpenCode',
29
+ 'gemini': 'Gemini CLI',
30
+ };
31
+
32
+ const POLL_INTERVAL_MS = 30000;
33
+ const CACHE_TTL_MS = 10000;
34
+
35
+ let _cache = { fetchedAt: 0, tools: null };
36
+ let _inflight = null;
37
+ const _subscribers = new Map();
38
+ let _pollTimer = null;
39
+
40
+ export function getCliDocUrl(toolKey) {
41
+ return CLI_DOC_URLS[toolKey] || null;
42
+ }
43
+
44
+ export function getCliInstallCommand(toolKey) {
45
+ return CLI_INSTALL_CMDS[toolKey] || '';
46
+ }
47
+
48
+ export function getCliLabel(toolKey) {
49
+ return CLI_LABELS[toolKey] || toolKey;
50
+ }
51
+
52
+ async function _fetchToolVersions(force) {
53
+ if (!force && _cache.tools && (Date.now() - _cache.fetchedAt) < CACHE_TTL_MS) {
54
+ return _cache.tools;
55
+ }
56
+ if (_inflight) return _inflight;
57
+ const url = force ? '/api/system/tool-versions?force=1' : '/api/system/tool-versions';
58
+ _inflight = (async () => {
59
+ try {
60
+ const res = await fetch(url);
61
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
62
+ const data = await res.json();
63
+ _cache = { fetchedAt: Date.now(), tools: data?.tools || {} };
64
+ return _cache.tools;
65
+ } finally {
66
+ _inflight = null;
67
+ }
68
+ })();
69
+ return _inflight;
70
+ }
71
+
72
+ function _emitChange(prevTools, nextTools) {
73
+ for (const key of TOOL_KEYS) {
74
+ const prev = prevTools?.[key]?.installed ?? null;
75
+ const next = nextTools?.[key]?.installed ?? null;
76
+ const subs = _subscribers.get(key);
77
+ if (!subs || !subs.size) continue;
78
+ if (prev !== next || prevTools == null) {
79
+ const info = nextTools?.[key] || { installed: null };
80
+ for (const cb of subs) {
81
+ try { cb(info); } catch (e) { console.error('[cli-status] subscriber error:', e); }
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ function _hasMissingSubscribed() {
88
+ for (const key of _subscribers.keys()) {
89
+ const subs = _subscribers.get(key);
90
+ if (!subs?.size) continue;
91
+ const installed = _cache.tools?.[key]?.installed ?? null;
92
+ if (!installed) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function _maybeStartPoller() {
98
+ if (_pollTimer) return;
99
+ if (!_hasMissingSubscribed()) return;
100
+ _pollTimer = setInterval(async () => {
101
+ if (!_hasMissingSubscribed()) {
102
+ _stopPoller();
103
+ return;
104
+ }
105
+ const prev = _cache.tools;
106
+ try {
107
+ const next = await _fetchToolVersions(true);
108
+ _emitChange(prev, next);
109
+ if (!_hasMissingSubscribed()) _stopPoller();
110
+ } catch { /* ignore — retry next tick */ }
111
+ }, POLL_INTERVAL_MS);
112
+ }
113
+
114
+ function _stopPoller() {
115
+ if (_pollTimer) {
116
+ clearInterval(_pollTimer);
117
+ _pollTimer = null;
118
+ }
119
+ }
120
+
121
+ export async function getCliStatus(toolKey, { force = false } = {}) {
122
+ const tools = await _fetchToolVersions(force);
123
+ return tools?.[toolKey] || { installed: null, latest: null };
124
+ }
125
+
126
+ export function subscribeCliStatus(toolKey, callback) {
127
+ if (!TOOL_KEYS.includes(toolKey)) return () => {};
128
+ let set = _subscribers.get(toolKey);
129
+ if (!set) { set = new Set(); _subscribers.set(toolKey, set); }
130
+ set.add(callback);
131
+
132
+ const cached = _cache.tools?.[toolKey];
133
+ if (cached) {
134
+ try { callback(cached); } catch (e) { console.error('[cli-status] subscriber error:', e); }
135
+ _maybeStartPoller();
136
+ } else {
137
+ _fetchToolVersions(false).then((tools) => {
138
+ try { callback(tools?.[toolKey] || { installed: null }); } catch {}
139
+ _maybeStartPoller();
140
+ }).catch(() => {});
141
+ }
142
+
143
+ return () => {
144
+ const s = _subscribers.get(toolKey);
145
+ if (!s) return;
146
+ s.delete(callback);
147
+ if (!s.size) _subscribers.delete(toolKey);
148
+ if (!_hasMissingSubscribed()) _stopPoller();
149
+ };
150
+ }
151
+
152
+ export async function recheckCliStatus(toolKey) {
153
+ const prev = _cache.tools;
154
+ const next = await _fetchToolVersions(true);
155
+ _emitChange(prev, next);
156
+ return next?.[toolKey] || { installed: null };
157
+ }
@@ -8,6 +8,7 @@ import { state, emit, on } from '../state.js';
8
8
  import { storage } from '../storage.js';
9
9
  import { fetchProjects } from '../api.js';
10
10
  import { reserveRightPanelLayout, clearRightPanelLayout } from '../ui-sidepanel-layout.js';
11
+ import { subscribeCliStatus, recheckCliStatus, getCliDocUrl, getCliInstallCommand, getCliLabel } from '../cli-status.js';
11
12
 
12
13
  import { injectStyles } from './ocp-styles.js';
13
14
  import {
@@ -64,6 +65,8 @@ let _serverVersion = null;
64
65
  let _serverManaged = false;
65
66
  let _projects = [];
66
67
  let _projectsLoaded = false;
68
+ let _cliInstalled = null;
69
+ let _cliUnsub = null;
67
70
 
68
71
  function panelEl(sel) { return _panel?.querySelector(sel) || null; }
69
72
 
@@ -907,6 +910,10 @@ async function handleSend() {
907
910
  if (!input) return;
908
911
  const tab = activeTab();
909
912
  if (!tab) return;
913
+ if (_cliInstalled === null || _cliInstallFailureForced) {
914
+ setStatus('offline', 'OpenCode CLI not installed. Install it first, then click Re-check.');
915
+ return;
916
+ }
910
917
  let text = input.value.trim();
911
918
  const images = Array.isArray(tab.attachedImages) ? [...tab.attachedImages] : [];
912
919
  if (!text && !images.length) return;
@@ -1006,6 +1013,16 @@ function syncSendButton() {
1006
1013
  const tab = activeTab();
1007
1014
  if (!sendBtn) return;
1008
1015
 
1016
+ const cliBlocked = _cliInstalled === null || _cliInstallFailureForced;
1017
+
1018
+ if (cliBlocked) {
1019
+ sendBtn.classList.remove('running');
1020
+ sendBtn.disabled = true;
1021
+ sendBtn.setAttribute('data-tooltip', 'OpenCode CLI not installed');
1022
+ sendBtn.setAttribute('aria-label', 'OpenCode CLI not installed');
1023
+ return;
1024
+ }
1025
+
1009
1026
  if (tab?.running) {
1010
1027
  sendBtn.disabled = false;
1011
1028
  sendBtn.classList.add('running');
@@ -1503,6 +1520,77 @@ function _ocpDocEscHandler(event) {
1503
1520
  abortMessage();
1504
1521
  }
1505
1522
 
1523
+ // ── CLI installation status (banner + send-block) ──
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;
1532
+ const label = getCliLabel('opencode');
1533
+ const cmd = getCliInstallCommand('opencode');
1534
+ const url = getCliDocUrl('opencode');
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';
1558
+ }
1559
+ }
1560
+ });
1561
+ return banner;
1562
+ }
1563
+
1564
+ function removeCliBannerEl() {
1565
+ if (!_panel) return;
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();
1576
+ syncSendButton();
1577
+ }
1578
+
1579
+ export function flagOcpCliInstallFailure() {
1580
+ _cliInstallFailureForced = true;
1581
+ refreshCliBanner();
1582
+ recheckCliStatus('opencode').catch(() => {});
1583
+ }
1584
+
1585
+ function ensureCliSubscription() {
1586
+ if (_cliUnsub) return;
1587
+ _cliUnsub = subscribeCliStatus('opencode', (info) => {
1588
+ _cliInstalled = info?.installed || null;
1589
+ if (_cliInstalled) _cliInstallFailureForced = false;
1590
+ refreshCliBanner();
1591
+ });
1592
+ }
1593
+
1506
1594
  function setVisible(nextVisible) {
1507
1595
  if (!_panel) return;
1508
1596
  if (!nextVisible) {
@@ -1528,6 +1616,7 @@ function setVisible(nextVisible) {
1528
1616
  if (_curProject) loadBranches(_curProject);
1529
1617
  loadCurrentProfile();
1530
1618
  loadRecallProfile();
1619
+ refreshCliBanner();
1531
1620
  } else {
1532
1621
  document.removeEventListener('keydown', _ocpDocEscHandler, { capture: true });
1533
1622
  }
@@ -1583,6 +1672,9 @@ function wsCallbacks() {
1583
1672
  },
1584
1673
  onError(msg) {
1585
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
+ }
1586
1678
  },
1587
1679
  };
1588
1680
  }
@@ -1651,6 +1743,7 @@ function ensurePanel() {
1651
1743
  window.dispatchEvent(new CustomEvent('sidepanel-tray:provider-loaded', { detail: { provider: 'opencode' } }));
1652
1744
 
1653
1745
  syncSendButton();
1746
+ ensureCliSubscription();
1654
1747
 
1655
1748
  // Connect WebSocket
1656
1749
  connectWs(wsCallbacks());
@@ -985,9 +985,10 @@ export function renderEmptyState(container) {
985
985
  <div class="ocp-empty">
986
986
  <svg viewBox="0 0 240 300"><path fill-rule="evenodd" d="M0 0h240v300H0V0zm30 30v240h180V30H30z" fill="currentColor"/><rect x="30" y="150" width="180" height="120" opacity=".45" fill="currentColor"/></svg>
987
987
  <span>OpenCode</span>
988
- <span style="font-size:10px;opacity:0.5">Send a message to start</span>
988
+ <span class="ocp-empty-hint" style="font-size:10px;opacity:0.5">Send a message to start</span>
989
989
  </div>
990
990
  `;
991
+ document.dispatchEvent(new CustomEvent('ocp-empty-rebuilt'));
991
992
  }
992
993
 
993
994
  // ── Render full history from messages:list response ──
@@ -1521,6 +1521,69 @@ export function injectStyles() {
1521
1521
  stroke: currentColor; fill: none;
1522
1522
  stroke-width: 1; opacity: 0.3;
1523
1523
  }
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;
1540
+ }
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;
1548
+ }
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);
1553
+ }
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 {
1568
+ display: inline-flex; align-items: center; gap: 4px;
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;
1579
+ font-family: inherit;
1580
+ }
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);
1584
+ }
1585
+ .ocp-cli-banner-recheck:disabled { opacity: 0.5; cursor: default; }
1586
+ .ocp-panel.ocp-cli-blocked .ocp-input { opacity: 0.7; }
1524
1587
 
1525
1588
  /* ── Thinking indicator ── */
1526
1589
  .ocp-thinking {
@@ -9,6 +9,7 @@ import { fetchClaudeSessions, fetchBrowserSessions, searchSessions } from './api
9
9
  import { createFrameRenderer } from './utils.js';
10
10
  import { notify, NOTIF_TYPE } from './ui-notifications.js';
11
11
  import { reserveRightPanelLayout, clearRightPanelLayout } from './ui-sidepanel-layout.js';
12
+ import { subscribeCliStatus, recheckCliStatus, getCliDocUrl, getCliInstallCommand, getCliLabel } from './cli-status.js';
12
13
 
13
14
  const CLAUDE_ICON = '<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>';
14
15
 
@@ -120,6 +121,10 @@ function activeTab() { return _tabs[_activeTabIdx] || null; }
120
121
  let _browserEmbed = null; // { sessionId, ws, canvas, ctx, urlBar, container }
121
122
  let _browserEmbedVisible = false;
122
123
 
124
+ // ── CLI-installed status (claude binary detection) ──
125
+ let _cliInstalled = null; // null = unknown / missing, version string = installed
126
+ let _cliUnsub = null;
127
+
123
128
  // Marked.js (lazy loaded)
124
129
  let _marked = null;
125
130
  (async () => {
@@ -427,6 +432,73 @@ function injectStyles() {
427
432
  .cp-messages:has(.msg) .cp-empty,
428
433
  .cp-messages:has(.msg-status) .cp-empty { display: none; }
429
434
 
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;
453
+ }
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;
461
+ }
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);
466
+ }
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 {
481
+ display: inline-flex; align-items: center; gap: 4px;
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;
493
+ }
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);
497
+ }
498
+ .cp-cli-banner-recheck:disabled { opacity: 0.5; cursor: default; }
499
+ .claude-panel.cp-cli-blocked .cp-send { opacity: 0.4 !important; cursor: not-allowed !important; }
500
+ .claude-panel.cp-cli-blocked .cp-input { opacity: 0.7; }
501
+
430
502
  /* ── Browser embed (live screencast inside panel) ── */
431
503
  .cp-browser-embed {
432
504
  display: none;
@@ -2649,14 +2721,14 @@ document.addEventListener('click', (e) => {
2649
2721
  });
2650
2722
 
2651
2723
  // ── Tab lifecycle ──
2652
- function createTab(sessionId = null, label = 'New chat', { autoSwitch = true, id = null } = {}) {
2724
+ function createTab(sessionId = null, label = 'New chat', { autoSwitch = true, id = null } = {}) {
2653
2725
  if (_tabs.length >= MAX_TABS) return null;
2654
2726
  // Capture current dropdown state for the new tab's project/model/effort
2655
2727
  const _$proj = _panel?.querySelector('#cp-project');
2656
2728
  const _$mod = _panel?.querySelector('#cp-model');
2657
2729
  const _$think = _panel?.querySelector('#cp-think-toggle');
2658
2730
  const tab = {
2659
- id: id || crypto.randomUUID(),
2731
+ id: id || crypto.randomUUID(),
2660
2732
  sessionId,
2661
2733
  label,
2662
2734
  project: ddGetValue(_$proj) || '', // per-tab project isolation
@@ -2975,7 +3047,7 @@ function restoreTabs() {
2975
3047
  const data = JSON.parse(raw);
2976
3048
  if (data.tabs?.length) {
2977
3049
  for (const saved of data.tabs) {
2978
- const t = createTab(saved.sessionId, saved.label, { autoSwitch: false, id: saved.id });
3050
+ const t = createTab(saved.sessionId, saved.label, { autoSwitch: false, id: saved.id });
2979
3051
  if (t && saved.sessionCost) t.sessionCost = saved.sessionCost;
2980
3052
  if (t && saved.running) t._wasRunning = true; // trigger reattach on WS open
2981
3053
  if (t && saved.project) t.project = saved.project;
@@ -2998,7 +3070,7 @@ function restoreTabs() {
2998
3070
  const data = JSON.parse(legacyRaw);
2999
3071
  if (data.tabs?.length) {
3000
3072
  for (const saved of data.tabs) {
3001
- const t = createTab(saved.sessionId, saved.label, { autoSwitch: false, id: saved.id });
3073
+ const t = createTab(saved.sessionId, saved.label, { autoSwitch: false, id: saved.id });
3002
3074
  if (t && saved.sessionCost) t.sessionCost = saved.sessionCost;
3003
3075
  if (t && saved.running) t._wasRunning = true;
3004
3076
  if (t && saved.project) t.project = saved.project;
@@ -4329,6 +4401,9 @@ function _processTabMsg(tab, msg) {
4329
4401
  if (tab.compacting) { tab.compacting = false; if (tab === activeTab()) _setCompactingUI(false); }
4330
4402
  appendError(tab, msg.message);
4331
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
+ }
4332
4407
  break;
4333
4408
  }
4334
4409
  }
@@ -6544,6 +6619,10 @@ function runSlashCommand(tab, raw) {
6544
6619
  }
6545
6620
 
6546
6621
  function send({ shift = false } = {}) {
6622
+ if (_cliInstalled === null || _cliInstallFailureForced) {
6623
+ notify({ type: NOTIF_TYPE.WARN, title: 'Claude CLI not installed', body: 'Install the Claude Code CLI before sending a message.' });
6624
+ return;
6625
+ }
6547
6626
  const tab = activeTab();
6548
6627
  if (!tab) return;
6549
6628
  const $input = _panel?.querySelector('#cp-input');
@@ -7246,6 +7325,78 @@ function syncReservedWidth() {
7246
7325
  }
7247
7326
  }
7248
7327
 
7328
+ // ── CLI installation status (banner + send-block) ──
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;
7337
+ const label = getCliLabel('claude-code');
7338
+ const cmd = getCliInstallCommand('claude-code');
7339
+ const url = getCliDocUrl('claude-code');
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';
7363
+ }
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();
7373
+ }
7374
+
7375
+ function _refreshCliBanners() {
7376
+ if (!_panel) return;
7377
+ const missing = _cliInstalled === null || _cliInstallFailureForced;
7378
+ _panel.classList.toggle('cp-cli-blocked', missing);
7379
+ if (missing) _ensureCliBannerEl();
7380
+ else _removeCliBannerEl();
7381
+ const $send = _panel.querySelector('#cp-send');
7382
+ if ($send && missing) $send.disabled = true;
7383
+ }
7384
+
7385
+ export function flagClaudeCliInstallFailure() {
7386
+ _cliInstallFailureForced = true;
7387
+ _refreshCliBanners();
7388
+ recheckCliStatus('claude-code').catch(() => {});
7389
+ }
7390
+
7391
+ function _ensureCliSubscription() {
7392
+ if (_cliUnsub) return;
7393
+ _cliUnsub = subscribeCliStatus('claude-code', (info) => {
7394
+ _cliInstalled = info?.installed || null;
7395
+ if (_cliInstalled) _cliInstallFailureForced = false;
7396
+ _refreshCliBanners();
7397
+ });
7398
+ }
7399
+
7249
7400
  // ── Public API ──
7250
7401
  export async function toggleClaudePanel() {
7251
7402
  if (!_panel) {
@@ -7255,13 +7406,14 @@ export async function toggleClaudePanel() {
7255
7406
  wireEvents();
7256
7407
  await loadConfig(); // must complete before restoreTabs — checks server boot ID
7257
7408
  loadSkills();
7258
- restoreTabs();
7259
- loadMonthlyCost();
7260
- syncSessionCosts();
7261
- _wireBrowserSync();
7262
- _startHeartbeat();
7263
- window.dispatchEvent(new CustomEvent('sidepanel-tray:provider-loaded', { detail: { provider: 'claude' } }));
7264
- }
7409
+ restoreTabs();
7410
+ loadMonthlyCost();
7411
+ syncSessionCosts();
7412
+ _wireBrowserSync();
7413
+ _startHeartbeat();
7414
+ window.dispatchEvent(new CustomEvent('sidepanel-tray:provider-loaded', { detail: { provider: 'claude' } }));
7415
+ _ensureCliSubscription();
7416
+ }
7265
7417
  _visible = !_visible;
7266
7418
  if (_visible) {
7267
7419
  if (_tabs.length === 0) createTab(null, 'New chat');
@@ -7269,6 +7421,7 @@ export async function toggleClaudePanel() {
7269
7421
  _panel.classList.add('open');
7270
7422
  syncReservedWidth();
7271
7423
  _panel.querySelector('#cp-input')?.focus();
7424
+ _refreshCliBanners();
7272
7425
  } else {
7273
7426
  const tab = activeTab();
7274
7427
  const $input = _panel?.querySelector('#cp-input');
@@ -10454,7 +10454,8 @@ app.get('/api/system/backup', async (req, res) => {
10454
10454
  'loop-templates.json', 'loop-schedules.json', 'loop-folders.json', 'auto-backup-config.json',
10455
10455
  'cost-tracking.json', 'skin-config.json', 'image-favorites.json',
10456
10456
  'browser-config.json', 'browser-storage.json', 'last-session.json',
10457
- 'invite-sessions.json']) {
10457
+ 'invite-sessions.json', 'stored-plans.json', 'synabun-plugins.json',
10458
+ 'mcp-registry.json', 'active-profile.json']) {
10458
10459
  addFile(`data/${f}`, resolve(dataDir, f));
10459
10460
  }
10460
10461
 
@@ -10478,6 +10479,9 @@ app.get('/api/system/backup', async (req, res) => {
10478
10479
  // Screenshots, whiteboard captures, staged images
10479
10480
  addDirRecursive('data/images', resolve(dataDir, 'images'));
10480
10481
 
10482
+ // Plan markdown history (data/plans/YYYY-MM-DD/*.md)
10483
+ addDirRecursive('data/plans', resolve(dataDir, 'plans'));
10484
+
10481
10485
  // 3. mcp-server/data/
10482
10486
  if (existsSync(CATEGORIES_DATA_DIR)) {
10483
10487
  for (const f of readdirSync(CATEGORIES_DATA_DIR)) {
@@ -10613,7 +10617,7 @@ app.post('/api/system/restore',
10613
10617
  // data/ files
10614
10618
  const dataDir = resolve(DATA_HOME, 'data');
10615
10619
  mkdirSync(dataDir, { recursive: true });
10616
- for (const subdir of ['pending-remember', 'pending-compact', 'loop']) {
10620
+ for (const subdir of ['pending-remember', 'pending-compact', 'loop', 'plans']) {
10617
10621
  mkdirSync(resolve(dataDir, subdir), { recursive: true });
10618
10622
  }
10619
10623
 
@@ -10837,7 +10841,8 @@ function buildBackupZipToFile(destPath) {
10837
10841
  'loop-templates.json', 'loop-schedules.json', 'loop-folders.json', 'auto-backup-config.json',
10838
10842
  'cost-tracking.json', 'skin-config.json', 'image-favorites.json',
10839
10843
  'browser-config.json', 'browser-storage.json', 'last-session.json',
10840
- 'invite-sessions.json']) {
10844
+ 'invite-sessions.json', 'stored-plans.json', 'synabun-plugins.json',
10845
+ 'mcp-registry.json', 'active-profile.json']) {
10841
10846
  addFile(`data/${f}`, resolve(dataDir, f));
10842
10847
  }
10843
10848
 
@@ -10859,6 +10864,9 @@ function buildBackupZipToFile(destPath) {
10859
10864
  // Screenshots, whiteboard captures, staged images
10860
10865
  addDirRecursive('data/images', resolve(dataDir, 'images'));
10861
10866
 
10867
+ // Plan markdown history (data/plans/YYYY-MM-DD/*.md)
10868
+ addDirRecursive('data/plans', resolve(dataDir, 'plans'));
10869
+
10862
10870
  if (existsSync(CATEGORIES_DATA_DIR)) {
10863
10871
  for (const f of readdirSync(CATEGORIES_DATA_DIR)) {
10864
10872
  if (f.endsWith('.json')) addFile(`mcp-data/${f}`, resolve(CATEGORIES_DATA_DIR, f));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synabun",
3
- "version": "2026.4.25-beta.1",
3
+ "version": "2026.4.25-beta.3",
4
4
  "description": "Persistent vector memory for AI assistants",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The SynaBun Authors",