synabun 2026.4.20-beta.0 → 2026.4.20-beta.10

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
@@ -4,6 +4,23 @@ Raw internal changelogs are archived in [changelog/](changelog/).
4
4
 
5
5
  ## 2026-04-20
6
6
 
7
+ ### Added — Install Toggles For Claude / Gemini / Codex / OpenCode In Onboarding Step 5
8
+ - **Step 5 rewritten as live install toggles, not copy-paste CLI cards** — In `neural-interface/public/onboarding.html`, replaced the `.tool-grid` tool-card selector + `#mcpSetupPanel` copy-snippet block with a `.mcp-toggle-list` 2×2 grid containing one `.mcp-toggle-row` per installable CLI (Claude Code → `~/.claude.json`, Gemini CLI → `~/.gemini/settings.json`, Codex CLI → `~/.codex/config.toml`, OpenCode → `~/.config/opencode/config.json`). Each row shows the target config path and a pill `.mtr-toggle` that, on click, fires `POST` (install) or `DELETE` (uninstall) against the provider's `api` field in the new `TOOLS` constant (`/api/claude-code/mcp`, `/api/setup/gemini/mcp`, `/api/setup/codex/mcp`, `/api/opencode/mcp`). New `onToolIntegrationStep()` hook fetches each provider's install state on step enter and hydrates toggles so pre-connected providers already show as "on". `toggleTool(id)` flips a `.busy` state during the in-flight fetch and surfaces outcomes via the existing `showMascotToast()` overlay.
9
+ - New `wizardState.enabledTools` array tracks which providers were toggled on during this onboarding pass. `validateStep(5)` now advances once any tool is enabled OR the user copied the generic JSON. `wizardState.selectedTool` auto-defaults to the first enabled tool (falls back to `claude`) so Step 6's ruleset preview still resolves the correct instruction-file format.
10
+ - `onCompleteStep()` rewrites `#restartToolName` to a comma-separated list of every enabled tool and auto-registers Claude Code hooks globally only when Claude was one of the enabled tools (previously tied to a single `selectedTool`). Renamed the Step 7 checklist item from "MCP config copied" to "AI tool integration enabled".
11
+ - Collapsed `TOOLS` from 5 entries (`claude/cursor/windsurf/codex/other`) to 4 installable entries with `{ id, name, instrFile, format, api, configPath }` metadata that drives both the toggle UI and `onRulesetStep()` — the old hardcoded `formatMap` in `onRulesetStep` was dropped in favour of `tool.format`.
12
+ - **"Other providers" collapsible for Cursor / Windsurf / etc.** — Below the toggle grid, new `.mcp-other-section` collapsible block (`#mcpOtherHeader` click toggles `.open` class + `#mcpOtherBody` display, with a rotating chevron) keeps the generic `mcpServers.SynaBun` JSON preview + copy button for tools without a server integration. `getGenericMcpJson()` builds the JSON from `wizardState.projectRoot` / `wizardState.packageRoot`. The existing `#mcpCodeWrap` copy handler still flips `wizardState.mcpWritten = true`, preserving advance-on-copy semantics for paste-only setups.
13
+
14
+ ### Added — `DELETE /api/opencode/mcp` Endpoint To Uninstall SynaBun From OpenCode Config
15
+ - **File-rewrite delete** — In `neural-interface/server.js`, added `app.delete('/api/opencode/mcp', ...)` that reads `~/.config/opencode/config.json`, deletes `cfg.mcp.SynaBun`, drops the parent `mcp` key when it becomes empty, and writes the file back. Name defaults to `SynaBun` but accepts `req.body.name` / `req.query.name` for symmetry with the `POST` signature. OpenCode was previously the only provider in the Setup / onboarding surface without an uninstall path — the new Step 5 toggles need `DELETE` to flip the pill off, so the runtime-only `ocpProxy('DELETE', ...)` call was deliberately omitted (config.json is the source of truth; OpenCode re-reads it on next launch).
16
+
17
+ ### Changed — OpenCode MCP Endpoints Persist-First, No Longer Require `_ocpReady`
18
+ - **`GET /api/opencode/mcp` now reads `config.json` directly** — Was `if (!_ocpReady) return { ok: false }` + `ocpProxy('GET', '/mcp')`, which made the toggle state unknown whenever OpenCode wasn't running. Rewritten to read `~/.config/opencode/config.json` and report `connected = !!(cfg?.mcp && cfg.mcp.SynaBun)`, returning `{ ok: true, connected, runtimeReady: !!_ocpReady, data }` so the onboarding toggle reflects the persisted install state regardless of whether the OpenCode embedded server happens to be up.
19
+ - **`POST /api/opencode/mcp` persists to file first, runtime register is best-effort** — Was `if (!_ocpReady) return 503` before any file write, so an onboarding install couldn't succeed until the user had first launched OpenCode. Now: when no body is supplied, defaults `name = 'SynaBun'` with canonical `{ command: 'node', args: [mcpIndexPath], env: { DOTENV_PATH, SYNABUN_DATA_HOME, MEMORY_DATA_DIR } }` — parity with the Gemini/Codex endpoints that take no body. `persistMcpToOpencodeConfig(name, mcpConfig)` runs unconditionally and returns a 500 only if the filesystem write itself fails. `ocpProxy('POST', '/mcp', ...)` only runs when `_ocpReady` is truthy and is wrapped in try/catch — a runtime-register failure is logged but doesn't fail the request since the config was already persisted.
20
+
21
+ ### Fixed — Onboarding Step 5 Toggle Grid Clipped Second Column Off-Screen
22
+ - **`min-width: auto` on grid items overrode `1fr` allocation** — After switching `.mcp-toggle-list` from `display: flex; flex-direction: column` to `display: grid; grid-template-columns: 1fr 1fr` for the 2×2 layout, the second column (Gemini CLI, OpenCode) rendered past the visible viewport — toggle buttons and half the config-path strings were clipped. CSS Grid items default to `min-width: auto` (resolved to min-content), and `.mtr-path` has `white-space: nowrap`, so each cell's min-content width was the full config-path string (`~/.config/opencode/config.json`) — that forced each cell wider than `1fr`, pushing the grid's total width past its container. Added `min-width: 0` to `.mcp-toggle-row` so the grid cell can shrink below its intrinsic content width, and changed `.mcp-toggle-row .mtr-body` from `flex: 1` to `flex: 1 1 0` + `overflow: hidden` so the nowrap path ellipsizes cleanly inside the flex child instead of blowing out the row.
23
+
7
24
  ### Fixed — OpenCode CLI Update Command Fails With EACCES On macOS Root-Owned Global `node_modules`
8
25
  - **`npm install -g opencode-ai@latest` blocked by filesystem permissions** — The prior "Unified CLI Update Commands via npm" unification set `UPDATE_COMMANDS['opencode']` in `neural-interface/public/shared/ui-tool-updates.js` to `npm install -g opencode-ai@latest`. On macOS installations where `/usr/local/lib/node_modules` is root:wheel-owned (the default when Node was installed via the official pkg installer), the unprivileged `npm install -g` cannot `rename` the staging directory into place and errors with `npm error code EACCES` / `permission denied, rename '.../node_modules/opencode-ai' -> '.../node_modules/.opencode-ai-<hash>'`, so the click-through update path from the badge silently failed when the user clicked it. Switched `UPDATE_COMMANDS['opencode']` to `'opencode upgrade'` — OpenCode's built-in self-update subcommand (visible in `opencode --help` as `opencode upgrade [target] upgrade opencode to the latest or a specific version`). It detects the install method automatically (curl-install, brew, npm -g) and updates in place without requiring sudo or a user-owned node prefix (nvm/fnm/volta/asdf), so the badge's click-intercept now runs a self-contained update in the terminal shell tab that succeeds regardless of how OpenCode was originally installed.
9
26
 
@@ -6,7 +6,7 @@
6
6
  * Mount on any Express app or run standalone.
7
7
  */
8
8
 
9
- import express from 'express';
9
+ import express, { type Router } from 'express';
10
10
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
11
11
  import { ensureDatabase } from './services/sqlite.js';
12
12
  import { initCategoryCache } from './services/categories.js';
@@ -39,7 +39,7 @@ export async function ensureInit() {
39
39
  * Auth is handled externally (URL-embedded key in server.js).
40
40
  * Can be mounted on an existing Express app: app.use('/mcp', createMcpRoutes());
41
41
  */
42
- export function createMcpRoutes() {
42
+ export function createMcpRoutes(): Router {
43
43
  const router = express.Router();
44
44
  router.use(express.json());
45
45
 
@@ -13,7 +13,6 @@
13
13
  "postinstall": "node scripts/rebuild-pty.js"
14
14
  },
15
15
  "dependencies": {
16
- "@anthropic-ai/claude-code": "^2.1.71",
17
16
  "@huggingface/transformers": "^3.0.0",
18
17
  "@openai/codex-sdk": "^0.121.0",
19
18
  "adm-zip": "^0.5.16",
@@ -443,6 +443,98 @@
443
443
  white-space: nowrap;
444
444
  }
445
445
 
446
+ /* ── MCP Toggle List (Step 5) — 2×2 grid ── */
447
+ .mcp-toggle-list {
448
+ display: grid;
449
+ grid-template-columns: 1fr 1fr;
450
+ gap: 8px;
451
+ margin-bottom: 14px;
452
+ }
453
+ .mcp-toggle-row {
454
+ display: flex;
455
+ align-items: center;
456
+ gap: 12px;
457
+ padding: 10px 14px;
458
+ border: 1px solid var(--b-light);
459
+ border-radius: var(--r-card);
460
+ background: var(--s-subtle);
461
+ transition: border-color 0.25s ease, background 0.25s ease;
462
+ min-width: 0; /* allow grid cell to shrink below intrinsic width */
463
+ }
464
+ .mcp-toggle-row.connected {
465
+ border-color: var(--accent-border);
466
+ background: var(--accent-bg);
467
+ }
468
+ .mcp-toggle-row .mtr-body { flex: 1 1 0; min-width: 0; overflow: hidden; }
469
+ .mcp-toggle-row .mtr-name {
470
+ font-size: 13px;
471
+ font-weight: 600;
472
+ color: var(--t-bright);
473
+ margin-bottom: 2px;
474
+ }
475
+ .mcp-toggle-row .mtr-path {
476
+ font-size: 11px;
477
+ color: var(--t-muted);
478
+ font-family: var(--mono, monospace);
479
+ white-space: nowrap;
480
+ overflow: hidden;
481
+ text-overflow: ellipsis;
482
+ }
483
+ .mcp-toggle-row .mtr-toggle {
484
+ flex-shrink: 0;
485
+ width: 36px; height: 20px;
486
+ border-radius: 10px;
487
+ background: var(--s-hover);
488
+ border: 1px solid var(--b-light);
489
+ position: relative;
490
+ cursor: pointer;
491
+ transition: background 0.2s ease, border-color 0.2s ease;
492
+ padding: 0;
493
+ }
494
+ .mcp-toggle-row .mtr-toggle::after {
495
+ content: '';
496
+ position: absolute;
497
+ top: 2px; left: 2px;
498
+ width: 14px; height: 14px;
499
+ border-radius: 50%;
500
+ background: var(--t-secondary);
501
+ transition: left 0.2s ease, background 0.2s ease;
502
+ }
503
+ .mcp-toggle-row .mtr-toggle.on {
504
+ background: var(--accent-border);
505
+ border-color: var(--accent-border);
506
+ }
507
+ .mcp-toggle-row .mtr-toggle.on::after {
508
+ left: 18px;
509
+ background: var(--t-bright);
510
+ }
511
+ .mcp-toggle-row .mtr-toggle:disabled,
512
+ .mcp-toggle-row .mtr-toggle.busy {
513
+ opacity: 0.4;
514
+ cursor: wait;
515
+ }
516
+ .mcp-other-section {
517
+ border: 1px solid var(--b-light);
518
+ border-radius: var(--r-card);
519
+ background: var(--s-subtle);
520
+ overflow: hidden;
521
+ }
522
+ .mcp-other-header {
523
+ display: flex;
524
+ align-items: center;
525
+ justify-content: space-between;
526
+ padding: 10px 14px;
527
+ cursor: pointer;
528
+ font-size: 12px;
529
+ color: var(--t-secondary);
530
+ user-select: none;
531
+ transition: background 0.2s ease;
532
+ }
533
+ .mcp-other-header:hover { background: var(--s-hover); }
534
+ .mcp-other-chevron { transition: transform 0.2s ease; }
535
+ .mcp-other-section.open .mcp-other-chevron { transform: rotate(180deg); }
536
+ .mcp-other-body { padding: 0 14px 12px; }
537
+
446
538
  /* ── Dependency List ── */
447
539
  .dep-list {
448
540
  list-style: none;
@@ -1040,15 +1132,21 @@
1040
1132
  <span class="mascot-toast" id="mascotToast"></span>
1041
1133
  </div>
1042
1134
  <h2>AI Tool Integration</h2>
1043
- <p class="subtitle">Select your AI coding tool to get setup instructions.</p>
1044
- <div class="tool-grid" id="toolGrid"></div>
1045
- <div id="mcpSetupPanel" style="display:none;">
1046
- <div id="mcpSetupLabel" style="font-size:12px;color:var(--t-secondary);margin-bottom:6px;"></div>
1047
- <div class="code-copy-wrap" id="mcpCodeWrap">
1048
- <div class="instructions-preview" id="mcpSetupCode"></div>
1049
- <span class="code-copy-label">copy</span>
1135
+ <p class="subtitle">Toggle on the AI coding tools you use. SynaBun registers itself with each one automatically.</p>
1136
+ <div class="mcp-toggle-list" id="mcpToggleList"></div>
1137
+ <div class="mcp-other-section" id="mcpOtherSection">
1138
+ <div class="mcp-other-header" id="mcpOtherHeader">
1139
+ <span>Other providers (Cursor, Windsurf, etc.)</span>
1140
+ <svg class="mcp-other-chevron" viewBox="0 0 24 24" width="14" height="14"><polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/></svg>
1141
+ </div>
1142
+ <div class="mcp-other-body" id="mcpOtherBody" style="display:none;">
1143
+ <div style="font-size:12px;color:var(--t-secondary);margin-bottom:6px;">Add this MCP server JSON to your tool's config file:</div>
1144
+ <div class="code-copy-wrap" id="mcpCodeWrap">
1145
+ <div class="instructions-preview" id="mcpSetupCode"></div>
1146
+ <span class="code-copy-label">copy</span>
1147
+ </div>
1148
+ <div style="margin-top:6px;font-size:11px;color:var(--t-muted);">Cursor: Settings &rarr; MCP Servers, or <code>.cursor/mcp.json</code>. Windsurf: <code>~/.codeium/windsurf/mcp_config.json</code>.</div>
1050
1149
  </div>
1051
- <div id="mcpSetupHint" style="margin-top:6px;font-size:11px;color:var(--t-muted);"></div>
1052
1150
  </div>
1053
1151
  </div>
1054
1152
 
@@ -1195,6 +1293,7 @@ const wizardState = {
1195
1293
  collectionDisplayName: 'Claude Memory',
1196
1294
  dbLocationPath: null,
1197
1295
  selectedTool: null,
1296
+ enabledTools: [], // tool ids toggled on via Step 5 install toggles
1198
1297
  projectRoot: '',
1199
1298
  packageRoot: '',
1200
1299
  buildReady: false,
@@ -1202,12 +1301,13 @@ const wizardState = {
1202
1301
  projects: [],
1203
1302
  };
1204
1303
 
1304
+ // Tools with server-side MCP install/uninstall endpoints. Each row maps a
1305
+ // toggle to the POST/DELETE endpoint that writes the provider's config file.
1205
1306
  const TOOLS = [
1206
- { id: 'claude', name: 'Claude Code', instrFile: 'CLAUDE.md' },
1207
- { id: 'cursor', name: 'Cursor', instrFile: '.cursorrules' },
1208
- { id: 'windsurf', name: 'Windsurf', instrFile: '.windsurfrules' },
1209
- { id: 'codex', name: 'Codex', instrFile: 'AGENTS.md' },
1210
- { id: 'other', name: 'Other', instrFile: 'AGENTS.md' },
1307
+ { id: 'claude', name: 'Claude Code', instrFile: 'CLAUDE.md', format: 'claude', api: '/api/claude-code/mcp', configPath: '~/.claude.json' },
1308
+ { id: 'gemini', name: 'Gemini CLI', instrFile: 'GEMINI.md', format: 'gemini', api: '/api/setup/gemini/mcp', configPath: '~/.gemini/settings.json' },
1309
+ { id: 'codex', name: 'Codex CLI', instrFile: 'AGENTS.md', format: 'codex', api: '/api/setup/codex/mcp', configPath: '~/.codex/config.toml' },
1310
+ { id: 'opencode', name: 'OpenCode', instrFile: 'AGENTS.md', format: 'generic', api: '/api/opencode/mcp', configPath: '~/.config/opencode/config.json' },
1211
1311
  ];
1212
1312
 
1213
1313
  // ═══════════════════════════════════════════
@@ -1330,7 +1430,13 @@ function validateStep(n) {
1330
1430
  return true;
1331
1431
  }
1332
1432
  case 5: {
1333
- if (!wizardState.selectedTool) return false;
1433
+ // Allow advancing once any tool is toggled on OR the user copied the generic JSON.
1434
+ if (wizardState.enabledTools.length === 0 && !wizardState.mcpWritten) return false;
1435
+ // Default the ruleset preview (Step 6) + restart hint (Step 7) to the first
1436
+ // toggled-on tool; fall back to Claude if only the generic JSON was copied.
1437
+ if (!wizardState.selectedTool) {
1438
+ wizardState.selectedTool = wizardState.enabledTools[0] || 'claude';
1439
+ }
1334
1440
  return true;
1335
1441
  }
1336
1442
  default:
@@ -1358,6 +1464,7 @@ async function onStepEnter(n) {
1358
1464
  }
1359
1465
  }
1360
1466
  if (n === 3) await onBuildStep();
1467
+ if (n === 5) await onToolIntegrationStep();
1361
1468
  if (n === 6) await onRulesetStep();
1362
1469
  if (n === 7) await onCompleteStep();
1363
1470
  // Re-measure after async content loads — but only if user is still on this step
@@ -2133,73 +2240,110 @@ document.getElementById('buildBtn').addEventListener('click', async () => {
2133
2240
  }
2134
2241
 
2135
2242
  // ═══════════════════════════════════════════
2136
- // STEP 5: Tool Selection
2243
+ // STEP 5: Tool Integration (install toggles + generic JSON fallback)
2137
2244
  // ═══════════════════════════════════════════
2138
2245
 
2139
- const $toolGrid = document.getElementById('toolGrid');
2246
+ const $toggleList = document.getElementById('mcpToggleList');
2247
+
2248
+ // Render one toggle row per installable tool. Statuses populate on enter.
2140
2249
  TOOLS.forEach(t => {
2141
- const card = document.createElement('div');
2142
- card.className = 'tool-card';
2143
- card.dataset.id = t.id;
2144
- card.innerHTML = `<div class="tname">${t.name}</div>`;
2145
- card.addEventListener('click', () => selectTool(t.id));
2146
- $toolGrid.appendChild(card);
2250
+ const row = document.createElement('div');
2251
+ row.className = 'mcp-toggle-row';
2252
+ row.dataset.id = t.id;
2253
+ row.innerHTML = `
2254
+ <div class="mtr-body">
2255
+ <div class="mtr-name">${t.name}</div>
2256
+ <div class="mtr-path" data-role="path">${t.configPath}</div>
2257
+ </div>
2258
+ <button class="mtr-toggle" data-role="toggle" aria-label="Toggle ${t.name}"></button>
2259
+ `;
2260
+ row.querySelector('[data-role="toggle"]').addEventListener('click', () => toggleTool(t.id));
2261
+ $toggleList.appendChild(row);
2147
2262
  });
2148
2263
 
2149
- function getMcpSetup(id) {
2264
+ async function onToolIntegrationStep() {
2265
+ // Fetch each provider's install state so toggles reflect reality.
2266
+ await Promise.all(TOOLS.map(async (t) => {
2267
+ try {
2268
+ const res = await fetch(t.api);
2269
+ const data = await res.json();
2270
+ const connected = !!data.connected;
2271
+ updateToggleRow(t.id, { connected });
2272
+ if (connected && !wizardState.enabledTools.includes(t.id)) {
2273
+ wizardState.enabledTools.push(t.id);
2274
+ }
2275
+ } catch { updateToggleRow(t.id, { connected: false }); }
2276
+ }));
2277
+ }
2278
+
2279
+ function updateToggleRow(id, { connected, busy }) {
2280
+ const row = $toggleList.querySelector(`[data-id="${id}"]`);
2281
+ if (!row) return;
2282
+ const toggle = row.querySelector('[data-role="toggle"]');
2283
+ const path = row.querySelector('[data-role="path"]');
2284
+ const tool = TOOLS.find(t => t.id === id);
2285
+ if (typeof connected === 'boolean') {
2286
+ row.classList.toggle('connected', connected);
2287
+ toggle.classList.toggle('on', connected);
2288
+ path.textContent = connected ? `Registered in ${tool.configPath}` : tool.configPath;
2289
+ }
2290
+ if (typeof busy === 'boolean') {
2291
+ toggle.classList.toggle('busy', busy);
2292
+ toggle.disabled = busy;
2293
+ }
2294
+ }
2295
+
2296
+ async function toggleTool(id) {
2297
+ const tool = TOOLS.find(t => t.id === id);
2298
+ if (!tool) return;
2299
+ const row = $toggleList.querySelector(`[data-id="${id}"]`);
2300
+ const toggle = row.querySelector('[data-role="toggle"]');
2301
+ const wasOn = toggle.classList.contains('on');
2302
+ updateToggleRow(id, { busy: true });
2303
+ try {
2304
+ const res = await fetch(tool.api, { method: wasOn ? 'DELETE' : 'POST' });
2305
+ const data = await res.json().catch(() => ({}));
2306
+ if (!res.ok || data.ok === false) {
2307
+ showMascotToast(data.error || `${tool.name} ${wasOn ? 'uninstall' : 'install'} failed`);
2308
+ updateToggleRow(id, { busy: false });
2309
+ return;
2310
+ }
2311
+ const nowOn = !wasOn;
2312
+ updateToggleRow(id, { connected: nowOn, busy: false });
2313
+ if (nowOn) {
2314
+ if (!wizardState.enabledTools.includes(id)) wizardState.enabledTools.push(id);
2315
+ wizardState.mcpWritten = true;
2316
+ showMascotToast(`${tool.name} connected`);
2317
+ } else {
2318
+ wizardState.enabledTools = wizardState.enabledTools.filter(x => x !== id);
2319
+ showMascotToast(`${tool.name} disconnected`);
2320
+ }
2321
+ } catch (err) {
2322
+ showMascotToast(`Failed: ${err.message}`);
2323
+ updateToggleRow(id, { busy: false });
2324
+ }
2325
+ }
2326
+
2327
+ // Generic MCP JSON for tools without a server integration (Cursor, Windsurf, other).
2328
+ function getGenericMcpJson() {
2150
2329
  const dataHome = (wizardState.projectRoot || '').replace(/\\/g, '/');
2151
2330
  const pkgRoot = (wizardState.packageRoot || dataHome).replace(/\\/g, '/');
2152
2331
  const mcpPath = pkgRoot + '/mcp-server/run.mjs';
2153
2332
  const envPath = dataHome + '/.env';
2154
2333
  const env = { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: dataHome, MEMORY_DATA_DIR: dataHome + '/mcp-data' };
2155
-
2156
- if (id === 'claude') {
2157
- const envFlags = Object.entries(env).map(([k, v]) => `-e "${k}=${v}"`).join(' ');
2158
- return {
2159
- label: 'Run this in your terminal to register SynaBun globally:',
2160
- code: `claude mcp add SynaBun node "${mcpPath}" -s user ${envFlags}`,
2161
- hint: 'Registers SynaBun globally. Restart Claude Code after running.',
2162
- };
2163
- }
2164
- if (id === 'cursor') {
2165
- const json = JSON.stringify({ mcpServers: { SynaBun: { command: 'node', args: [mcpPath], env } } }, null, 2);
2166
- return {
2167
- label: 'Add to Cursor Settings > MCP Servers, or paste into .cursor/mcp.json:',
2168
- code: json,
2169
- hint: 'Settings > MCP > Add Server, or save as .cursor/mcp.json',
2170
- };
2171
- }
2172
- if (id === 'windsurf') {
2173
- const json = JSON.stringify({ mcpServers: { SynaBun: { command: 'node', args: [mcpPath], env } } }, null, 2);
2174
- return {
2175
- label: 'Add to ~/.codeium/windsurf/mcp_config.json:',
2176
- code: json,
2177
- hint: 'Cascade sidebar > hammer icon > Configure',
2178
- };
2179
- }
2180
- // codex, other
2181
- const json = JSON.stringify({ mcpServers: { SynaBun: { command: 'node', args: [mcpPath], env } } }, null, 2);
2182
- return {
2183
- label: 'Add to your project\'s .mcp.json:',
2184
- code: json,
2185
- hint: 'Save as .mcp.json in your project root.',
2186
- };
2334
+ return JSON.stringify({ mcpServers: { SynaBun: { command: 'node', args: [mcpPath], env } } }, null, 2);
2187
2335
  }
2188
2336
 
2189
- function selectTool(id) {
2190
- wizardState.selectedTool = id;
2191
- $toolGrid.querySelectorAll('.tool-card').forEach(c => {
2192
- c.classList.toggle('selected', c.dataset.id === id);
2193
- });
2194
-
2195
- const setup = getMcpSetup(id);
2196
- document.getElementById('mcpSetupLabel').textContent = setup.label;
2197
- document.getElementById('mcpSetupCode').textContent = setup.code;
2198
- document.getElementById('mcpSetupHint').textContent = setup.hint;
2199
- document.getElementById('mcpSetupPanel').style.display = '';
2200
-
2337
+ // "Other providers" collapsible — shows the generic MCP JSON for paste-based setups.
2338
+ const $otherHeader = document.getElementById('mcpOtherHeader');
2339
+ const $otherBody = document.getElementById('mcpOtherBody');
2340
+ const $otherSection = document.getElementById('mcpOtherSection');
2341
+ $otherHeader.addEventListener('click', () => {
2342
+ const isOpen = $otherSection.classList.toggle('open');
2343
+ $otherBody.style.display = isOpen ? '' : 'none';
2344
+ if (isOpen) document.getElementById('mcpSetupCode').textContent = getGenericMcpJson();
2201
2345
  requestAnimationFrame(() => resizeViewport(currentStep));
2202
- }
2346
+ });
2203
2347
 
2204
2348
  function showMascotToast(msg, duration = 2000) {
2205
2349
  const toast = document.getElementById('mascotToast');
@@ -2211,6 +2355,7 @@ function showMascotToast(msg, duration = 2000) {
2211
2355
 
2212
2356
  document.getElementById('mcpCodeWrap').addEventListener('click', () => {
2213
2357
  const code = document.getElementById('mcpSetupCode').textContent;
2358
+ if (!code) return;
2214
2359
  navigator.clipboard.writeText(code);
2215
2360
  wizardState.mcpWritten = true;
2216
2361
  const label = document.querySelector('#mcpCodeWrap .code-copy-label');
@@ -2227,10 +2372,7 @@ document.getElementById('mcpCodeWrap').addEventListener('click', () => {
2227
2372
  async function onRulesetStep() {
2228
2373
  const tool = TOOLS.find(t => t.id === wizardState.selectedTool) || TOOLS[0];
2229
2374
  document.getElementById('rulesetFileName').textContent = tool.instrFile;
2230
-
2231
- // Map tool to API format
2232
- const formatMap = { claude: 'claude', cursor: 'cursor', windsurf: 'generic', codex: 'codex', other: 'generic' };
2233
- const format = formatMap[tool.id] || 'generic';
2375
+ const format = tool.format || 'generic';
2234
2376
 
2235
2377
  const $code = document.getElementById('rulesetCode');
2236
2378
  if ($code.dataset.loadedFormat === format) return; // already loaded
@@ -2270,11 +2412,14 @@ document.getElementById('rulesetCodeWrap').addEventListener('click', () => {
2270
2412
  async function onCompleteStep() {
2271
2413
  renderHappyBun();
2272
2414
 
2273
- const tool = TOOLS.find(t => t.id === wizardState.selectedTool) || TOOLS[0];
2274
- document.getElementById('restartToolName').textContent = tool.name;
2415
+ // List every tool the user toggled on; fall back to the ruleset tool if none.
2416
+ const enabled = wizardState.enabledTools.length
2417
+ ? wizardState.enabledTools.map(id => TOOLS.find(t => t.id === id)).filter(Boolean)
2418
+ : [TOOLS.find(t => t.id === wizardState.selectedTool) || TOOLS[0]];
2419
+ document.getElementById('restartToolName').textContent = enabled.map(t => t.name).join(', ');
2275
2420
 
2276
- // Auto-register hooks globally for Claude Code users
2277
- if (tool.id === 'claude') {
2421
+ // Auto-register hooks globally when Claude Code was toggled on
2422
+ if (enabled.some(t => t.id === 'claude')) {
2278
2423
  try {
2279
2424
  await fetch('/api/claude-code/integrations', {
2280
2425
  method: 'POST',
@@ -2289,7 +2434,7 @@ async function onCompleteStep() {
2289
2434
  { label: 'Memory name set', done: !!wizardState.collectionName },
2290
2435
  { label: 'Dependencies installed & MCP built', done: wizardState.buildReady },
2291
2436
  { label: 'Projects registered', done: (wizardState.projects || []).length > 0 },
2292
- { label: 'MCP config copied', done: wizardState.mcpWritten },
2437
+ { label: 'AI tool integration enabled', done: wizardState.enabledTools.length > 0 || wizardState.mcpWritten },
2293
2438
  { label: 'Ruleset copied', done: !!wizardState.rulesetCopied },
2294
2439
  ];
2295
2440
 
@@ -99,6 +99,7 @@ let _primaryAgents = new Set();
99
99
  let _sessions = [];
100
100
  const _userMsgIds = new Set(); // track user messageIDs to filter echoed SSE events
101
101
  const _partTypes = new Map(); // track partID → type (reasoning/text) from part.updated events
102
+ const _renderedAssistantMsgIds = new Set(); // prevents double-render when message.updated re-fires after finalize (Windows timing)
102
103
  let _activeSendRequestId = null; // WS request id of the in-flight message:send (for abort cancellation)
103
104
  let _renderSeq = 0; // guard against stale async renders overwriting fresh ones
104
105
  let _activeQuestionToolId = null; // toolId of the currently active (unanswered) question card
@@ -760,6 +761,10 @@ export async function sendMessage(content, options = {}) {
760
761
  removeThinking(container);
761
762
  finalizeStreamingMessage(container);
762
763
  }
764
+ // Mark this assistant message as rendered so late terminal SSE
765
+ // message.updated events (which arrive after POST resolves on some
766
+ // Windows/model combos) don't re-stream the full payload into a new bubble.
767
+ if (info.id) _renderedAssistantMsgIds.add(info.id);
763
768
  tab.running = false;
764
769
  tab.turnStartedAt = 0;
765
770
  if (info.tokens || info.usage) {
@@ -1163,6 +1168,15 @@ export function handleSSEEvent(eventType, event) {
1163
1168
  if (mid) _userMsgIds.add(mid);
1164
1169
  break;
1165
1170
  }
1171
+ // Guard: once we've rendered this assistant message's final payload, ignore
1172
+ // re-emitted message.updated events (OpenCode on Windows sometimes fires a
1173
+ // late duplicate after finalize, which would otherwise re-stream the whole
1174
+ // response into a brand-new assistant bubble).
1175
+ const assistantMid = info.id || event.messageID;
1176
+ if (assistantMid && _renderedAssistantMsgIds.has(assistantMid)) {
1177
+ update();
1178
+ break;
1179
+ }
1166
1180
  // If no streaming element was built by prior deltas, render the
1167
1181
  // assistant's content directly from the event (non-streaming path)
1168
1182
  const hasStreaming = container.querySelector('.ocp-msg-assistant.streaming');
@@ -1176,6 +1190,13 @@ export function handleSSEEvent(eventType, event) {
1176
1190
  removeThinking(container);
1177
1191
  finalizeStreamingMessage(container);
1178
1192
  _partTypes.clear();
1193
+ // Only mark the ID as rendered at the terminal signal (tokens/usage
1194
+ // arrived or status is explicitly terminal). Mid-turn message.updated
1195
+ // events may fire before streaming starts and shouldn't lock out later
1196
+ // part.delta / part.updated events.
1197
+ if (assistantMid && (explicitTerminal || info.tokens || info.usage)) {
1198
+ _renderedAssistantMsgIds.add(assistantMid);
1199
+ }
1179
1200
  if (info.tokens || info.usage) {
1180
1201
  const raw = info.tokens || info.usage || {};
1181
1202
  tab.threadTokenUsage = normalizeThreadTokenUsage(raw);
@@ -1202,6 +1223,8 @@ export function handleSSEEvent(eventType, event) {
1202
1223
  // Streaming text delta — the primary streaming event from OpenCode
1203
1224
  case 'message.part.delta': {
1204
1225
  if (_userMsgIds.has(event.messageID)) break; // skip user message echoes
1226
+ // Skip deltas for assistant messages already finalized (late echoes).
1227
+ if (event.messageID && _renderedAssistantMsgIds.has(event.messageID)) break;
1205
1228
  if (event.delta) {
1206
1229
  const partType = String(event.part?.type || event.partType || _partTypes.get(event.partID) || '').trim().toLowerCase();
1207
1230
  const isReasoning = partType === 'reasoning' || partType === 'thinking' || partType === 'thought';
@@ -1236,7 +1259,11 @@ export function handleSSEEvent(eventType, event) {
1236
1259
  update();
1237
1260
  } else if (updatedType === 'text') {
1238
1261
  const hasStreaming = container.querySelector('.ocp-msg-assistant.streaming');
1239
- if (!hasStreaming) {
1262
+ // Skip the fallback render if this assistant message has already been
1263
+ // finalized by POST response or a prior message.updated — otherwise we
1264
+ // create a duplicate bubble when late text part.updated events arrive.
1265
+ const partMsgId = part.messageID || part.messageId;
1266
+ if (!hasStreaming && !(partMsgId && _renderedAssistantMsgIds.has(partMsgId))) {
1240
1267
  renderAssistantPayload(container, part, { textMode: 'stream' });
1241
1268
  }
1242
1269
  setTurnStatus(tab, 'Writing response…', tabStatusDetail(tab));
@@ -1287,6 +1314,14 @@ export function handleSSEEvent(eventType, event) {
1287
1314
  }
1288
1315
  case 'message.completed': {
1289
1316
  removeThinking(container);
1317
+ const completedMid = event.messageID || event.id;
1318
+ if (completedMid && _renderedAssistantMsgIds.has(completedMid)) {
1319
+ tab.running = false;
1320
+ tab.turnStartedAt = 0;
1321
+ setTurnStatus(tab);
1322
+ update();
1323
+ break;
1324
+ }
1290
1325
  // Fallback: render content if no streaming element was built
1291
1326
  const hasStreamingLegacy = container.querySelector('.ocp-msg-assistant.streaming');
1292
1327
  renderAssistantPayload(container, event.content ?? event.parts ?? event.text ?? '', {
@@ -1294,6 +1329,7 @@ export function handleSSEEvent(eventType, event) {
1294
1329
  });
1295
1330
  finalizeStreamingMessage(container);
1296
1331
  _partTypes.clear();
1332
+ if (completedMid) _renderedAssistantMsgIds.add(completedMid);
1297
1333
  tab.running = false;
1298
1334
  tab.turnStartedAt = 0;
1299
1335
  setTurnStatus(tab);
@@ -1080,6 +1080,7 @@ function buildSetupTab(setupStatus) {
1080
1080
  const claudeConnected = setupStatus.claude?.connected || false;
1081
1081
  const geminiConnected = setupStatus.gemini?.connected || false;
1082
1082
  const codexConnected = setupStatus.codex?.connected || false;
1083
+ const opencodeConnected = setupStatus.opencode?.connected || false;
1083
1084
 
1084
1085
  const statusBadge = (on) => `<span class="setup-status-badge ${on ? 'active' : 'inactive'}">${on ? 'Connected' : 'Off'}</span>`;
1085
1086
 
@@ -1184,6 +1185,40 @@ function buildSetupTab(setupStatus) {
1184
1185
  </div>
1185
1186
  </div>
1186
1187
 
1188
+ <!-- OPENCODE -->
1189
+ <div class="iface-section collapsed" data-collapsible id="setup-opencode">
1190
+ <div class="gfx-group-title">
1191
+ <span style="display:flex;align-items:center;gap:8px">
1192
+ ${CHEVRON_ICON}
1193
+ ${OPENCODE_ICON}
1194
+ <span>OpenCode</span>
1195
+ ${statusBadge(opencodeConnected)}
1196
+ </span>
1197
+ </div>
1198
+ <div class="cc-section-body">
1199
+ <div class="cc-integration-item${opencodeConnected ? ' enabled' : ''}" id="setup-opencode-mcp-row">
1200
+ <div class="cc-integration-info">
1201
+ <div class="cc-integration-label">SynaBun MCP</div>
1202
+ <div class="cc-integration-path" id="setup-opencode-mcp-status">${opencodeConnected ? 'Registered in ~/.config/opencode/config.json' : 'Not connected'}</div>
1203
+ </div>
1204
+ <button class="cc-toggle${opencodeConnected ? ' on' : ''}" id="setup-opencode-mcp-toggle"></button>
1205
+ </div>
1206
+
1207
+ <div style="margin-top:12px">
1208
+ <div class="cc-greeting-label" style="margin-bottom:4px">Manual Config <span style="color:var(--t-faint)">(~/.config/opencode/config.json)</span></div>
1209
+ <div class="cc-ruleset-preview" id="setup-opencode-config-preview" style="max-height:120px">Loading...</div>
1210
+ <button class="cc-copy-btn" id="setup-opencode-config-copy" style="margin-top:4px">${COPY_ICON} Copy JSON Config</button>
1211
+ </div>
1212
+
1213
+ <div style="margin-top:12px">
1214
+ <div class="cc-greeting-label" style="margin-bottom:4px">AGENTS.md Ruleset</div>
1215
+ <div class="cc-ruleset-preview" id="setup-opencode-ruleset-preview">Loading...</div>
1216
+ <button class="cc-copy-btn" id="setup-opencode-ruleset-copy" style="margin-top:4px">${COPY_ICON} Copy Ruleset</button>
1217
+ <div class="setup-hint">Paste into your project's <code>AGENTS.md</code></div>
1218
+ </div>
1219
+ </div>
1220
+ </div>
1221
+
1187
1222
  <!-- COEXISTENCE RULES -->
1188
1223
  <div class="iface-section collapsed" data-collapsible id="setup-coexistence">
1189
1224
  <div class="gfx-group-title">
@@ -2855,7 +2890,7 @@ export async function openSettingsModal() {
2855
2890
  let greetingConfig = { defaults: {}, projects: {}, global: {} };
2856
2891
  let codexGreetingConfig = { enabled: true, defaults: {}, projects: {}, global: {} };
2857
2892
  let opencodeGreetingConfig = { enabled: false, defaults: {}, projects: {}, global: {} };
2858
- let setupStatus = { claude: {}, gemini: {}, codex: {}, paths: {} };
2893
+ let setupStatus = { claude: {}, gemini: {}, codex: {}, opencode: {}, paths: {} };
2859
2894
  let cliConfig = {};
2860
2895
  let toolPermissions = {};
2861
2896
  let toolCategories = [];
@@ -2944,7 +2979,7 @@ export async function openSettingsModal() {
2944
2979
  <nav class="settings-nav">
2945
2980
  ${buildNavHTML(variantTabs, {
2946
2981
  server: settings.storage === 'sqlite' ? 'connected' : 'disconnected',
2947
- setup: (setupStatus.claude?.connected || setupStatus.gemini?.connected || setupStatus.codex?.connected) ? 'connected' : 'disconnected',
2982
+ setup: (setupStatus.claude?.connected || setupStatus.gemini?.connected || setupStatus.codex?.connected || setupStatus.opencode?.connected) ? 'connected' : 'disconnected',
2948
2983
  discord: discordConfig.botToken ? 'connected' : 'disconnected',
2949
2984
  })}
2950
2985
  </nav>
@@ -4989,6 +5024,38 @@ export async function openSettingsModal() {
4989
5024
  }
4990
5025
  wireRulesetPreview('codex', 'codex');
4991
5026
 
5027
+ // ── OpenCode ──
5028
+ wireSetupMcpToggle('opencode', '/api/opencode/mcp', '~/.config/opencode/config.json');
5029
+ // Config preview — OpenCode stores MCP servers under the "mcp" key of config.json
5030
+ {
5031
+ const preview = overlay.querySelector('#setup-opencode-config-preview');
5032
+ let cachedConfig = '';
5033
+ if (preview) {
5034
+ const mp = setupStatus.paths?.mcpIndexPath || '<path-to>/mcp-server/run.mjs';
5035
+ const ep = setupStatus.paths?.envPath || '<path-to>/synabun/.env';
5036
+ fetch('/api/opencode/mcp').then(r => r.json()).then(data => {
5037
+ if (data.ok && data.data && Object.keys(data.data).length) {
5038
+ cachedConfig = JSON.stringify({ mcp: data.data }, null, 2);
5039
+ preview.textContent = cachedConfig;
5040
+ } else if (data.ok) {
5041
+ cachedConfig = JSON.stringify({
5042
+ mcp: {
5043
+ SynaBun: {
5044
+ type: 'stdio',
5045
+ command: 'node',
5046
+ args: [mp],
5047
+ env: { DOTENV_PATH: ep },
5048
+ },
5049
+ },
5050
+ }, null, 2);
5051
+ preview.textContent = cachedConfig;
5052
+ } else { preview.textContent = 'Could not load config.'; }
5053
+ }).catch(() => { preview.textContent = 'Failed to load.'; });
5054
+ }
5055
+ wireCopyBtn('setup-opencode-config-copy', () => cachedConfig, 'Copy JSON Config');
5056
+ }
5057
+ wireRulesetPreview('opencode', 'generic');
5058
+
4992
5059
  // ── Coexistence rules ──
4993
5060
  wireRulesetPreview('coexistence', 'coexistence');
4994
5061
  }