synabun 2026.4.26 → 2026.4.27

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.
@@ -1546,14 +1546,400 @@ app.put('/api/settings', (req, res) => {
1546
1546
  }
1547
1547
  });
1548
1548
 
1549
- // POST /api/server/restart — Gracefully restart the server process
1550
- app.post('/api/server/restart', (req, res) => {
1551
- console.log('[server] Restart requested — shutting down in 500ms...');
1549
+ // ── Graceful shutdown — kills all spawned children before exiting ──
1550
+ //
1551
+ // Background: the previous /api/server/restart only ran closeDb() + exit(0),
1552
+ // leaving spawned children orphaned (opencode sidecar, agents, _sidecarProcess
1553
+ // CLI links, pty terminals, cloudflare tunnel). On Windows those orphans
1554
+ // kept file handles inside neural-interface/ open, causing EBUSY errors when
1555
+ // `npm i -g synabun@latest` tried to rename the package directory.
1556
+ //
1557
+ // gracefulShutdown() centralizes shutdown so /api/server/restart,
1558
+ // /api/server/shutdown, and the new /api/system/run-update all release
1559
+ // children before exiting.
1560
+
1561
+ let _shutdownInProgress = false;
1562
+
1563
+ async function gracefulShutdown(reason = 'shutdown') {
1564
+ if (_shutdownInProgress) return;
1565
+ _shutdownInProgress = true;
1566
+ console.log(`[server] gracefulShutdown(${reason}) — releasing children...`);
1567
+
1568
+ // 1. opencode sidecar — has its own teardown that handles managed PID + ollama.
1569
+ try { stopOpencodeServer(); } catch (err) { console.warn(' opencode stop err:', err?.message); }
1570
+
1571
+ // 2. Agents in agentRegistry — use platform-native tree kill so descendants die too.
1572
+ try {
1573
+ const isWin = process.platform === 'win32';
1574
+ for (const agent of agentRegistry.values()) {
1575
+ const pid = agent?.process?.pid;
1576
+ if (!pid || agent.status !== 'running') continue;
1577
+ agent._stopped = true;
1578
+ agent.status = 'stopped';
1579
+ try {
1580
+ if (isWin) {
1581
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { shell: true, windowsHide: true });
1582
+ } else {
1583
+ try { process.kill(-pid, 'SIGTERM'); } catch { try { agent.process.kill('SIGTERM'); } catch {} }
1584
+ }
1585
+ } catch {}
1586
+ }
1587
+ } catch (err) { console.warn(' agent kill err:', err?.message); }
1588
+
1589
+ // 3. Save snapshot + DB last so children release file handles before SQLite closes.
1590
+ try { saveSessionSnapshot(); } catch {}
1591
+
1592
+ // 4. Brief grace period so SIGTERM'd children can flush.
1593
+ await new Promise(r => setTimeout(r, 500));
1594
+
1595
+ try { closeDb(); } catch {}
1596
+
1597
+ // 5. Belt-and-suspenders: tree-kill THIS process's descendants on Windows.
1598
+ // Synabun spawns CLI links, pty terminals, cloudflare tunnel etc. that may
1599
+ // not be reachable through the registries above. The taskkill /T flag walks
1600
+ // the process tree and terminates every descendant.
1601
+ //
1602
+ // Do not run this for click-to-update. The updater terminal is deliberately
1603
+ // launched before this server exits; taskkill /T would kill that brand-new
1604
+ // terminal too, which looks like a brief blink and no update on Windows.
1605
+ if (process.platform === 'win32' && reason !== 'run-update') {
1606
+ try {
1607
+ // Use spawn(detached) so taskkill survives our own exit and finishes
1608
+ // killing children even if we exit before it completes.
1609
+ const tk = spawn('taskkill', ['/pid', String(process.pid), '/T'], {
1610
+ shell: true, windowsHide: true, detached: true, stdio: 'ignore',
1611
+ });
1612
+ tk.unref();
1613
+ } catch {}
1614
+ }
1615
+
1616
+ console.log(`[server] gracefulShutdown(${reason}) complete — exiting.`);
1617
+ }
1618
+
1619
+ // POST /api/server/restart — Gracefully restart the server process.
1620
+ // (Caller is expected to launch a new instance externally; we just exit.)
1621
+ app.post('/api/server/restart', async (req, res) => {
1622
+ console.log('[server] Restart requested.');
1552
1623
  res.json({ ok: true, message: 'Server restarting...' });
1553
- setTimeout(() => {
1554
- closeDb();
1624
+ // Defer so the response flushes before we start tearing down children.
1625
+ setTimeout(async () => {
1626
+ await gracefulShutdown('restart');
1555
1627
  process.exit(0);
1556
- }, 500);
1628
+ }, 200);
1629
+ });
1630
+
1631
+ // POST /api/server/shutdown — Graceful shutdown without restart. Used by
1632
+ // `npm uninstall synabun` (preuninstall.js) and the in-app updater.
1633
+ app.post('/api/server/shutdown', async (req, res) => {
1634
+ console.log('[server] Shutdown requested.');
1635
+ res.json({ ok: true, message: 'Server shutting down...' });
1636
+ setTimeout(async () => {
1637
+ await gracefulShutdown('shutdown');
1638
+ process.exit(0);
1639
+ }, 200);
1640
+ });
1641
+
1642
+ function shellSingleQuote(s) {
1643
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
1644
+ }
1645
+
1646
+ function collectWindowsDescendantPids(rootPid) {
1647
+ if (process.platform !== 'win32' || !rootPid) return [];
1648
+ try {
1649
+ const script = [
1650
+ `$root=${Number(rootPid)}`,
1651
+ '$procs=Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId',
1652
+ '$seen=@{}',
1653
+ '$front=@($root)',
1654
+ '$out=@()',
1655
+ 'while ($front.Count -gt 0) {',
1656
+ ' $next=@()',
1657
+ ' foreach ($p in $procs) {',
1658
+ ' if ($front -contains $p.ParentProcessId -and -not $seen.ContainsKey($p.ProcessId)) {',
1659
+ ' $seen[$p.ProcessId]=$true',
1660
+ ' $out += $p.ProcessId',
1661
+ ' $next += $p.ProcessId',
1662
+ ' }',
1663
+ ' }',
1664
+ ' $front=$next',
1665
+ '}',
1666
+ '$out -join ","',
1667
+ ].join('; ');
1668
+ const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
1669
+ encoding: 'utf8',
1670
+ windowsHide: true,
1671
+ timeout: 5000,
1672
+ });
1673
+ const out = result.status === 0 ? String(result.stdout || '').trim() : '';
1674
+ return out
1675
+ ? out.split(',').map(v => Number(v)).filter(pid => Number.isFinite(pid) && pid > 0 && pid !== rootPid)
1676
+ : [];
1677
+ } catch {
1678
+ return [];
1679
+ }
1680
+ }
1681
+
1682
+ function cleanupOldUpdaterStages(baseDir) {
1683
+ try {
1684
+ if (!existsSync(baseDir)) return;
1685
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
1686
+ for (const name of readdirSync(baseDir)) {
1687
+ const full = resolve(baseDir, name);
1688
+ try {
1689
+ const st = statSync(full);
1690
+ if (st.isDirectory() && st.mtimeMs < cutoff) rmSync(full, { recursive: true, force: true });
1691
+ } catch {}
1692
+ }
1693
+ } catch {}
1694
+ }
1695
+
1696
+ function stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids = [] }) {
1697
+ const sourceUpdater = resolve(PACKAGE_ROOT, 'updater.mjs');
1698
+ if (!existsSync(sourceUpdater)) {
1699
+ throw new Error(`updater.mjs not found at ${sourceUpdater}. SynaBun install may be corrupt - reinstall manually: ${installPlan.displayCommand}`);
1700
+ }
1701
+
1702
+ const stagesRoot = resolve(DATA_HOME, 'data', 'updater');
1703
+ mkdirSync(stagesRoot, { recursive: true });
1704
+ cleanupOldUpdaterStages(stagesRoot);
1705
+
1706
+ const stageDir = resolve(stagesRoot, `${Date.now()}-${randomBytes(4).toString('hex')}`);
1707
+ mkdirSync(stageDir, { recursive: true });
1708
+
1709
+ const stagedUpdater = resolve(stageDir, 'updater.mjs');
1710
+ const payloadPath = resolve(stageDir, 'payload.json');
1711
+ const runnerPath = resolve(stageDir, process.platform === 'win32' ? 'run-update.cmd' : 'run-update.sh');
1712
+ copyFileSync(sourceUpdater, stagedUpdater);
1713
+
1714
+ const payload = {
1715
+ serverPid,
1716
+ autoRestart,
1717
+ installSpec: installPlan.installSpec,
1718
+ displayCommand: installPlan.displayCommand,
1719
+ source: installPlan.source,
1720
+ current: installPlan.current,
1721
+ target: installPlan.target,
1722
+ channel: installPlan.channel,
1723
+ cleanupPids,
1724
+ };
1725
+ writeFileSync(payloadPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
1726
+
1727
+ if (process.platform === 'win32') {
1728
+ const nodeBin = String(process.execPath || 'node').replace(/"/g, '');
1729
+ writeFileSync(runnerPath, [
1730
+ '@echo off',
1731
+ 'setlocal enableextensions',
1732
+ 'chcp 65001 >nul 2>&1',
1733
+ 'title SynaBun Updater',
1734
+ 'cd /d "%~dp0"',
1735
+ 'set "EXIT_CODE=0"',
1736
+ `set "NODE_BIN=${nodeBin}"`,
1737
+ 'if defined SYNABUN_NODE_BIN set "NODE_BIN=%SYNABUN_NODE_BIN%"',
1738
+ 'echo SynaBun Updater starting...',
1739
+ 'echo Node: %NODE_BIN%',
1740
+ 'echo Stage: %~dp0',
1741
+ 'echo.',
1742
+ '"%NODE_BIN%" "%~dp0updater.mjs" --payload "%~dp0payload.json" --no-hold',
1743
+ 'set "EXIT_CODE=%ERRORLEVEL%"',
1744
+ 'echo.',
1745
+ 'if "%EXIT_CODE%"=="0" (',
1746
+ ' echo [Update finished. Press any key to close this window.]',
1747
+ ') else (',
1748
+ ' echo [Update exited with code %EXIT_CODE%. Press any key to close this window.]',
1749
+ ')',
1750
+ 'pause >nul',
1751
+ 'endlocal & exit /b %EXIT_CODE%',
1752
+ '',
1753
+ ].join('\r\n'), 'utf8');
1754
+ } else {
1755
+ const nodeBin = shellSingleQuote(process.execPath);
1756
+ writeFileSync(runnerPath, [
1757
+ '#!/usr/bin/env bash',
1758
+ 'set +e',
1759
+ 'SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"',
1760
+ 'cd "$SCRIPT_DIR" || exit 1',
1761
+ 'printf \'\\033]0;SynaBun Updater\\007\' 2>/dev/null || true',
1762
+ 'NODE_BIN="${SYNABUN_NODE_BIN:-}"',
1763
+ `if [ -z "$NODE_BIN" ]; then NODE_BIN=${nodeBin}; fi`,
1764
+ '"$NODE_BIN" "$SCRIPT_DIR/updater.mjs" --payload "$SCRIPT_DIR/payload.json" --no-hold',
1765
+ 'EXIT_CODE=$?',
1766
+ 'echo',
1767
+ 'if [ "$EXIT_CODE" = "0" ]; then',
1768
+ ' echo "[Update finished. Press Enter to close this window.]"',
1769
+ 'else',
1770
+ ' echo "[Update exited with code $EXIT_CODE. Press Enter to close this window.]"',
1771
+ 'fi',
1772
+ 'read -r _ || true',
1773
+ 'exit "$EXIT_CODE"',
1774
+ '',
1775
+ ].join('\n'), 'utf8');
1776
+ try { chmodSync(runnerPath, 0o755); } catch {}
1777
+ }
1778
+
1779
+ return { stageDir, runnerPath, payloadPath };
1780
+ }
1781
+
1782
+ // POST /api/system/run-update - click-to-update flow.
1783
+ //
1784
+ // The updater is staged under DATA_HOME, not PACKAGE_ROOT, before launch. This
1785
+ // matters on Windows: a terminal cwd or command line inside node_modules/synabun
1786
+ // can lock the directory npm is trying to replace.
1787
+ app.post('/api/system/run-update', async (req, res) => {
1788
+ try {
1789
+ // Auto-restart is always on — running the click-to-update flow implies the
1790
+ // user wants the new version live without a manual relaunch step.
1791
+ const autoRestart = true;
1792
+
1793
+ if (!_synabunUpdateCache.checkedAt) {
1794
+ try { await checkSynabunUpdate(); } catch {}
1795
+ }
1796
+ const installPlan = _synabunUpdateCache.installPlan;
1797
+ if (!_synabunUpdateCache.updateAvailable || !installPlan) {
1798
+ return res.status(409).json({ error: 'No SynaBun update is currently available.' });
1799
+ }
1800
+ if (installPlan.canAutoUpdate === false) {
1801
+ return res.status(409).json({
1802
+ error: installPlan.manualHint || `This installation cannot be auto-updated. Open ${installPlan.openUrl || SYNABUN_GITHUB_REPO_URL}`,
1803
+ installPlan,
1804
+ openUrl: installPlan.openUrl || SYNABUN_GITHUB_REPO_URL,
1805
+ });
1806
+ }
1807
+ if (!installPlan.installSpec) {
1808
+ return res.status(500).json({ error: 'SynaBun update plan is missing an npm install target.' });
1809
+ }
1810
+
1811
+ const plat = process.platform;
1812
+ const serverPid = process.pid;
1813
+ const cleanupPids = collectWindowsDescendantPids(serverPid);
1814
+ const staged = stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids });
1815
+
1816
+ let spawned = null;
1817
+ let launchError = null;
1818
+
1819
+ if (plat === 'win32') {
1820
+ // Use cmd's `start` builtin to spawn a fresh visible console.
1821
+ // Prior implementation used PowerShell + Start-Process -ArgumentList,
1822
+ // which re-quoted the runner path and produced `cmd /k """C:\path\..."""`
1823
+ // — cmd's quote-stripping then mangled it and the new window died before
1824
+ // running anything (visible to users as a brief blink). `cmd /c start ""
1825
+ // /D <dir> <runner>` is the canonical, reliable way to launch a fresh
1826
+ // console window on Windows.
1827
+ try {
1828
+ const comSpec = process.env.ComSpec || 'cmd.exe';
1829
+ spawned = spawn(comSpec, [
1830
+ '/c', 'start',
1831
+ '""', // empty title — required when next token is quoted path
1832
+ '/D', staged.stageDir, // working dir for new console
1833
+ staged.runnerPath, // batch file to run in new window
1834
+ ], {
1835
+ cwd: staged.stageDir,
1836
+ shell: false,
1837
+ detached: true,
1838
+ stdio: 'ignore',
1839
+ windowsHide: true, // hides only the wrapper cmd.exe; new console stays visible
1840
+ env: {
1841
+ ...process.env,
1842
+ SYNABUN_NODE_BIN: process.execPath,
1843
+ },
1844
+ });
1845
+ } catch (err) { launchError = err; }
1846
+ } else if (plat === 'darwin') {
1847
+ // Drive Terminal.app via osascript. The shim path may contain spaces;
1848
+ // wrap it in single quotes inside the AppleScript string. AppleScript
1849
+ // string is wrapped in JS double-quote, so we escape JS backslashes
1850
+ // and double-quotes after building the inner shell command.
1851
+ const appleCmd = shellSingleQuote(staged.runnerPath);
1852
+ try {
1853
+ spawned = spawn('osascript', [
1854
+ '-e', 'tell application "Terminal" to activate',
1855
+ '-e', `tell application "Terminal" to do script "${appleCmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`,
1856
+ ], { cwd: staged.stageDir, detached: true, stdio: 'ignore' });
1857
+ } catch (err) { launchError = err; }
1858
+ } else {
1859
+ // Linux/BSD/etc — try common terminal emulators in order. Each gets
1860
+ // the shim path as the program to run; the shim itself is a bash
1861
+ // script so we let the terminal exec it directly without bash -c.
1862
+ const quotedRunner = shellSingleQuote(staged.runnerPath);
1863
+ const candidates = [
1864
+ ['x-terminal-emulator', ['-e', staged.runnerPath]],
1865
+ ['gnome-terminal', ['--', staged.runnerPath]],
1866
+ ['konsole', ['-e', staged.runnerPath]],
1867
+ ['xfce4-terminal', ['-e', quotedRunner]],
1868
+ ['mate-terminal', ['-e', quotedRunner]],
1869
+ ['lxterminal', ['-e', staged.runnerPath]],
1870
+ ['terminator', ['-e', quotedRunner]],
1871
+ ['alacritty', ['-e', staged.runnerPath]],
1872
+ ['kitty', [staged.runnerPath]],
1873
+ ['wezterm', ['start', '--', staged.runnerPath]],
1874
+ ['urxvt', ['-e', staged.runnerPath]],
1875
+ ['xterm', ['-e', staged.runnerPath]],
1876
+ ];
1877
+ for (const [bin, args] of candidates) {
1878
+ let child = null;
1879
+ try {
1880
+ child = spawn(bin, args, { cwd: staged.stageDir, detached: true, stdio: 'ignore' });
1881
+ } catch {
1882
+ continue;
1883
+ }
1884
+ // spawn() doesn't throw synchronously when bin is missing — it emits
1885
+ // 'error' async (ENOENT). Probe briefly to detect that case before
1886
+ // declaring success and unrefing.
1887
+ const survived = await new Promise((resolveProbe) => {
1888
+ let settled = false;
1889
+ const onErr = () => {
1890
+ if (settled) return;
1891
+ settled = true;
1892
+ child.removeAllListeners('error');
1893
+ resolveProbe(false);
1894
+ };
1895
+ child.once('error', onErr);
1896
+ setTimeout(() => {
1897
+ if (settled) return;
1898
+ settled = true;
1899
+ child.removeAllListeners('error');
1900
+ resolveProbe(true);
1901
+ }, 150);
1902
+ });
1903
+ if (survived) { spawned = child; break; }
1904
+ }
1905
+ if (!spawned) {
1906
+ launchError = new Error(
1907
+ 'No supported terminal emulator found (tried: x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, mate-terminal, lxterminal, terminator, alacritty, kitty, wezterm, urxvt, xterm). ' +
1908
+ `Install one (e.g. \`apt install xterm\`) or run manually: ${installPlan.displayCommand}`
1909
+ );
1910
+ }
1911
+ }
1912
+
1913
+ if (!spawned) {
1914
+ const msg = launchError?.message || `Failed to launch updater terminal on ${plat}`;
1915
+ console.error('[server] run-update launch failed:', msg);
1916
+ return res.status(500).json({ error: msg });
1917
+ }
1918
+
1919
+ // Don't tie our shutdown to the spawned process — it's a long-living
1920
+ // terminal we don't want to wait on.
1921
+ spawned.unref();
1922
+ spawned.on('error', (err) => {
1923
+ // Async ENOENT etc. — log but the response already went out.
1924
+ console.error('[server] updater spawn async error:', err.message);
1925
+ });
1926
+
1927
+ console.log(`[server] Updater terminal launched (${installPlan.displayCommand}, autoRestart=${autoRestart}, stage=${staged.stageDir}). Shutting down...`);
1928
+ res.json({ ok: true, autoRestart, installPlan, stageDir: staged.stageDir, platform: plat });
1929
+
1930
+ // Give the spawned terminal time to fully launch before we tear down
1931
+ // (on Windows `start` returns instantly but the new window takes a
1932
+ // beat to init node; on macOS osascript dispatches AppleScript async).
1933
+ // 1.2s is generous; updater polls our PID anyway so an extra second
1934
+ // here is harmless.
1935
+ setTimeout(async () => {
1936
+ await gracefulShutdown('run-update');
1937
+ process.exit(0);
1938
+ }, 1200);
1939
+ } catch (err) {
1940
+ console.error('[server] run-update error:', err?.message);
1941
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1942
+ }
1557
1943
  });
1558
1944
 
1559
1945
  // POST /api/settings/move-db — Move SQLite database to a new directory
@@ -2170,9 +2556,14 @@ function getClaudeBin() {
2170
2556
  // Might be a bare command in PATH — try which/where
2171
2557
  try {
2172
2558
  const envWithPath = { ...process.env, PATH: getAugmentedPath() };
2173
- const lookup = process.platform === 'win32'
2174
- ? execSync(`where "${userCmd}"`, { encoding: 'utf-8', env: envWithPath }).split('\n')[0].trim()
2175
- : execSync(`which "${userCmd}"`, { encoding: 'utf-8', env: envWithPath }).trim();
2559
+ let lookup;
2560
+ if (process.platform === 'win32') {
2561
+ const lines = execSync(`where "${userCmd}"`, { encoding: 'utf-8', env: envWithPath })
2562
+ .split(/\r?\n/).map(l => l.trim()).filter(Boolean);
2563
+ lookup = lines.find(l => /\.(cmd|exe|bat|ps1)$/i.test(l)) || lines[0];
2564
+ } else {
2565
+ lookup = execSync(`which "${userCmd}"`, { encoding: 'utf-8', env: envWithPath }).trim();
2566
+ }
2176
2567
  if (lookup) { _claudeBinPath = lookup; return _claudeBinPath; }
2177
2568
  } catch {}
2178
2569
  }
@@ -2210,8 +2601,12 @@ function getClaudeBin() {
2210
2601
  // 2. Fall back to global install via which/where (with augmented PATH)
2211
2602
  const envWithPath = { ...process.env, PATH: getAugmentedPath() };
2212
2603
  if (process.platform === 'win32') {
2213
- try { _claudeBinPath = execSync('where claude', { encoding: 'utf-8', env: envWithPath }).split('\n')[0].trim(); }
2214
- catch { _claudeBinPath = null; }
2604
+ try {
2605
+ const lines = execSync('where claude', { encoding: 'utf-8', env: envWithPath })
2606
+ .split(/\r?\n/).map(l => l.trim()).filter(Boolean);
2607
+ // Prefer .cmd/.exe/.bat/.ps1 over the bare extensionless shim (which is a sh script Windows can't spawn)
2608
+ _claudeBinPath = lines.find(l => /\.(cmd|exe|bat|ps1)$/i.test(l)) || lines[0] || null;
2609
+ } catch { _claudeBinPath = null; }
2215
2610
  } else {
2216
2611
  try { _claudeBinPath = execSync('which claude', { encoding: 'utf-8', env: envWithPath }).trim(); }
2217
2612
  catch { _claudeBinPath = null; }
@@ -4259,12 +4654,36 @@ function stopOpencodeServer() {
4259
4654
  stopOpencodeSSERelay();
4260
4655
  const managedPid = readOpencodeManagedPid();
4261
4656
  const procPid = _ocpProc?.pid || null;
4657
+ const isWin = process.platform === 'win32';
4658
+
4659
+ // On Windows `process.kill(pid, 'SIGTERM')` is unreliable for non-Node
4660
+ // children (cmd.exe shells, opencode native binaries). Use taskkill /T /F
4661
+ // to walk the process tree and force-terminate every descendant.
4662
+ // This is the recurring source of EBUSY errors during `npm i -g
4663
+ // synabun@latest` — opencode-ai's `serve` process survives parent SynaBun
4664
+ // exit and keeps file handles inside neural-interface/ open.
4665
+ const winKill = (pid) => {
4666
+ try {
4667
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {
4668
+ shell: true, windowsHide: true, stdio: 'ignore',
4669
+ });
4670
+ } catch {}
4671
+ };
4672
+
4262
4673
  if (_ocpProc && !_ocpProc.killed) {
4263
- try { _ocpProc.kill(); } catch {}
4674
+ if (isWin && procPid) {
4675
+ winKill(procPid);
4676
+ } else {
4677
+ try { _ocpProc.kill(); } catch {}
4678
+ }
4264
4679
  _ocpProc = null;
4265
4680
  }
4266
4681
  if (managedPid && managedPid !== procPid) {
4267
- signalPids([managedPid], 'SIGTERM');
4682
+ if (isWin) {
4683
+ winKill(managedPid);
4684
+ } else {
4685
+ signalPids([managedPid], 'SIGTERM');
4686
+ }
4268
4687
  }
4269
4688
  clearOpencodeManagedPid();
4270
4689
  _ocpReady = false;
@@ -5412,6 +5831,8 @@ app.post('/api/opencode/mcp', async (req, res) => {
5412
5831
  try { persistMcpToOpencodeConfig(name, mcpConfig); } catch (e) {
5413
5832
  return res.status(500).json({ ok: false, error: `Persist failed: ${e.message}` });
5414
5833
  }
5834
+ // Force-allow the 7 SynaBun memory tools so CLI sessions skip prompts.
5835
+ injectOpenCodeMemoryPerms();
5415
5836
  if (_ocpReady) {
5416
5837
  try { await ocpProxy('POST', '/mcp', { name, config: { type: 'stdio', ...mcpConfig } }); } catch (e) {
5417
5838
  console.warn(`[opencode] Runtime register failed (config persisted): ${e.message}`);
@@ -10073,60 +10494,300 @@ app.post('/api/search/memories', async (req, res) => {
10073
10494
  });
10074
10495
 
10075
10496
  // ═══════════════════════════════════════════
10076
- // NPM UPDATE CHECK
10497
+ // SYNABUN UPDATE CHECK (NPM + GITHUB RELEASES)
10077
10498
  // ═══════════════════════════════════════════
10499
+ //
10500
+ // Checks both the npm registry AND the project's GitHub releases. Alerts
10501
+ // when EITHER source is newer than the installed version (catches one-sided
10502
+ // publishes — e.g. release tagged on GitHub before npm publish completes).
10503
+
10504
+ let _synabunUpdateCache = {
10505
+ current: null,
10506
+ installedChannel: 'stable', // 'stable' | 'prerelease'
10507
+ npmLatestStable: null, // dist-tags.latest
10508
+ npmLatestBeta: null, // dist-tags.beta (or null if absent)
10509
+ npmLatestTag: null, // dist-tag selected for install
10510
+ npmLatest: null, // chosen target for installed channel
10511
+ gitLatest: null, // tag_name from GitHub release matching channel
10512
+ gitLatestRaw: null, // original GitHub tag, preserving leading v
10513
+ latest: null, // newer of (npmLatest, gitLatest) — legacy field
10514
+ npmUpdateAvailable: false,
10515
+ gitUpdateAvailable: false,
10516
+ updateAvailable: false, // npmUpdateAvailable || gitUpdateAvailable
10517
+ source: null, // 'npm' | 'github' | 'both' | null
10518
+ installPlan: null, // server-owned npm/GitHub action target for click-to-update
10519
+ checkedAt: null,
10520
+ npmError: null,
10521
+ gitError: null,
10522
+ };
10078
10523
 
10079
- let _npmUpdateCache = { current: null, latest: null, updateAvailable: false, checkedAt: null };
10524
+ const SYNABUN_GITHUB_REPO_URL = 'https://github.com/danilokhury/Synabun';
10525
+
10526
+ function isSynabunGitHubRemote(remoteText) {
10527
+ return /github\.com[:/]danilokhury\/Synabun(?:\.git)?(?:[\s)]|$)/i.test(String(remoteText || ''));
10528
+ }
10529
+
10530
+ function detectSynabunInstallSource() {
10531
+ const normalizedRoot = PACKAGE_ROOT.replace(/\\/g, '/');
10532
+ if (/\/node_modules\/synabun$/i.test(normalizedRoot)) {
10533
+ return { kind: 'npm-global' };
10534
+ }
10080
10535
 
10081
- async function checkNpmUpdate() {
10082
10536
  try {
10083
- const pkgPath = resolve(PACKAGE_ROOT, 'package.json');
10084
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
10085
- const current = pkg.version;
10086
- _npmUpdateCache.current = current;
10537
+ const inside = execSync('git rev-parse --is-inside-work-tree', {
10538
+ cwd: PACKAGE_ROOT,
10539
+ encoding: 'utf8',
10540
+ stdio: ['ignore', 'pipe', 'ignore'],
10541
+ timeout: 2000,
10542
+ }).trim();
10543
+ if (inside === 'true') {
10544
+ let remote = '';
10545
+ let remotes = '';
10546
+ try {
10547
+ remote = execSync('git remote get-url origin', {
10548
+ cwd: PACKAGE_ROOT,
10549
+ encoding: 'utf8',
10550
+ stdio: ['ignore', 'pipe', 'ignore'],
10551
+ timeout: 2000,
10552
+ }).trim();
10553
+ } catch {}
10554
+ try {
10555
+ remotes = execSync('git remote -v', {
10556
+ cwd: PACKAGE_ROOT,
10557
+ encoding: 'utf8',
10558
+ stdio: ['ignore', 'pipe', 'ignore'],
10559
+ timeout: 2000,
10560
+ }).trim();
10561
+ } catch {}
10562
+ if (isSynabunGitHubRemote(`${remote}\n${remotes}`)) {
10563
+ const firstRemote = remote || remotes.split(/\s+/).find(part => isSynabunGitHubRemote(part)) || null;
10564
+ return { kind: 'github-clone', remote: firstRemote };
10565
+ }
10566
+ return { kind: 'git-clone', remote: remote || null };
10567
+ }
10568
+ } catch {}
10087
10569
 
10088
- const controller = new AbortController();
10089
- const timeout = setTimeout(() => controller.abort(), 5000);
10090
- const res = await fetch('https://registry.npmjs.org/synabun/latest', { signal: controller.signal });
10091
- clearTimeout(timeout);
10570
+ return { kind: 'local' };
10571
+ }
10572
+
10573
+ function buildSynabunInstallPlan({ current, installedChannel, latest, updateAvailable }) {
10574
+ if (!updateAvailable) return null;
10575
+
10576
+ const installSource = detectSynabunInstallSource();
10577
+ if (installSource.kind !== 'npm-global') {
10578
+ const clonedHint = installSource.kind === 'github-clone'
10579
+ ? 'This SynaBun install is a GitHub checkout. Open the repository to pull or reinstall from the correct source.'
10580
+ : 'This SynaBun install was not detected as npm. Open the repository for the correct update path.';
10581
+ return {
10582
+ source: 'github',
10583
+ installSource: installSource.kind,
10584
+ canAutoUpdate: false,
10585
+ current,
10586
+ target: latest,
10587
+ channel: installedChannel,
10588
+ displayCommand: SYNABUN_GITHUB_REPO_URL,
10589
+ openUrl: SYNABUN_GITHUB_REPO_URL,
10590
+ manualCommand: installSource.kind === 'github-clone' ? 'git pull --ff-only' : null,
10591
+ manualHint: clonedHint,
10592
+ remote: installSource.remote,
10593
+ };
10594
+ }
10595
+
10596
+ const installSpec = 'synabun@latest';
10597
+ return {
10598
+ source: 'npm',
10599
+ installSource: installSource.kind,
10600
+ canAutoUpdate: true,
10601
+ current,
10602
+ target: latest,
10603
+ channel: installedChannel,
10604
+ installSpec,
10605
+ displayCommand: 'npm i -g synabun@latest',
10606
+ npmTag: 'latest',
10607
+ };
10608
+ }
10092
10609
 
10093
- if (!res.ok) throw new Error(`npm registry returned ${res.status}`);
10094
- const data = await res.json();
10095
- const latest = data.version;
10610
+ async function checkSynabunUpdate() {
10611
+ let current = _synabunUpdateCache.current;
10612
+ try {
10613
+ const pkg = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf-8'));
10614
+ current = pkg.version;
10615
+ } catch { /* keep prior current if package.json read fails */ }
10616
+
10617
+ const curParsed = parseSemver(current);
10618
+ // Numeric-only suffix (`2026.4.26-1`) is a post-release iteration, not a
10619
+ // prerelease — keep those on the stable channel. Only true alpha/beta/rc
10620
+ // suffixes flip the channel.
10621
+ const installedChannel = (curParsed?.pre && curParsed.iter == null) ? 'prerelease' : 'stable';
10096
10622
 
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]);
10623
+ const fetchJsonWithTimeout = async (url, ms = 5000) => {
10624
+ const controller = new AbortController();
10625
+ const timeout = setTimeout(() => controller.abort(), ms);
10626
+ try {
10627
+ const res = await fetch(url, {
10628
+ signal: controller.signal,
10629
+ headers: { 'Accept': 'application/json', 'User-Agent': 'synabun-update-check' },
10630
+ });
10631
+ if (!res.ok) throw new Error(`${url} returned ${res.status}`);
10632
+ return await res.json();
10633
+ } finally {
10634
+ clearTimeout(timeout);
10635
+ }
10636
+ };
10102
10637
 
10103
- _npmUpdateCache = { current, latest, updateAvailable, checkedAt: new Date().toISOString() };
10638
+ // npm: fetch the FULL registry document so we can read every dist-tag
10639
+ // (latest + beta + any future channel). The /latest shorthand only
10640
+ // surfaces dist-tags.latest, which made beta installs invisible.
10641
+ // github: when installed is a prerelease, /releases/latest is wrong
10642
+ // because it skips prereleases — fetch the full list and pick newest.
10643
+ const npmUrl = 'https://registry.npmjs.org/synabun';
10644
+ const githubUrl = installedChannel === 'prerelease'
10645
+ ? 'https://api.github.com/repos/danilokhury/Synabun/releases?per_page=20'
10646
+ : 'https://api.github.com/repos/danilokhury/Synabun/releases/latest';
10647
+
10648
+ const [npmRes, gitRes] = await Promise.allSettled([
10649
+ fetchJsonWithTimeout(npmUrl),
10650
+ fetchJsonWithTimeout(githubUrl),
10651
+ ]);
10104
10652
 
10105
- if (updateAvailable) {
10106
- console.log(` Updates: v${current} → v${latest} available`);
10653
+ let npmLatestStable = null, npmLatestBeta = null, npmLatestBetaTag = null, npmLatestTag = null;
10654
+ let gitLatest = null, gitLatestRaw = null;
10655
+ let npmError = null, gitError = null;
10656
+
10657
+ if (npmRes.status === 'fulfilled') {
10658
+ const tags = npmRes.value?.['dist-tags'] || {};
10659
+ npmLatestStable = tags.latest || null;
10660
+ for (const tagName of ['beta', 'next', 'rc', 'alpha']) {
10661
+ if (tags[tagName]) {
10662
+ npmLatestBeta = tags[tagName];
10663
+ npmLatestBetaTag = tagName;
10664
+ break;
10665
+ }
10666
+ }
10667
+ } else {
10668
+ npmError = npmRes.reason?.message || 'npm fetch failed';
10669
+ }
10670
+
10671
+ if (gitRes.status === 'fulfilled') {
10672
+ if (Array.isArray(gitRes.value)) {
10673
+ // /releases — pick newest non-draft entry whose tag parses as semver.
10674
+ // For stable channel users we'd skip prereleases, but this branch only
10675
+ // runs for prerelease users (per githubUrl above), so include both.
10676
+ let best = null;
10677
+ for (const r of gitRes.value) {
10678
+ if (r?.draft) continue;
10679
+ const tag = r?.tag_name || r?.name || '';
10680
+ const parsed = parseSemver(tag);
10681
+ if (!parsed) continue;
10682
+ if (!best || compareSemver(parsed, best.parsed) > 0) {
10683
+ best = { tag, parsed };
10684
+ }
10685
+ }
10686
+ gitLatestRaw = best?.tag || null;
10687
+ gitLatest = gitLatestRaw ? gitLatestRaw.replace(/^v\.?/, '') : null;
10107
10688
  } else {
10108
- console.log(` Updates: up to date (v${current})`);
10689
+ // /releases/latest — single object
10690
+ const tag = gitRes.value?.tag_name || gitRes.value?.name || '';
10691
+ gitLatestRaw = tag || null;
10692
+ gitLatest = tag ? tag.replace(/^v\.?/, '') : null;
10109
10693
  }
10110
- } catch (err) {
10111
- if (!_npmUpdateCache.current) {
10112
- try {
10113
- const pkg = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf-8'));
10114
- _npmUpdateCache.current = pkg.version;
10115
- } catch {}
10694
+ } else {
10695
+ gitError = gitRes.reason?.message || 'github fetch failed';
10696
+ }
10697
+
10698
+ // Channel-aware npm target:
10699
+ // - prerelease user: pick whichever of (stable, beta) is newest. Either
10700
+ // is genuinely "newer than current"; surfacing stable when on beta is
10701
+ // intentional (some users manually flip back to stable).
10702
+ // - stable user: only stable. Beta on a stable install would look like
10703
+ // a downgrade ("2026.4.26 → 2026.4.26-beta.3" via semver = older).
10704
+ let npmLatest = null;
10705
+ if (installedChannel === 'prerelease') {
10706
+ if (npmLatestStable && npmLatestBeta) {
10707
+ if (compareSemver(npmLatestBeta, npmLatestStable) > 0) {
10708
+ npmLatest = npmLatestBeta;
10709
+ npmLatestTag = npmLatestBetaTag;
10710
+ } else {
10711
+ npmLatest = npmLatestStable;
10712
+ npmLatestTag = 'latest';
10713
+ }
10714
+ } else {
10715
+ npmLatest = npmLatestStable || npmLatestBeta;
10716
+ npmLatestTag = npmLatestStable ? 'latest' : npmLatestBetaTag;
10116
10717
  }
10117
- _npmUpdateCache.checkedAt = new Date().toISOString();
10118
- console.log(` Updates: check failed (${err.message})`);
10718
+ } else {
10719
+ npmLatest = npmLatestStable;
10720
+ npmLatestTag = npmLatest ? 'latest' : null;
10721
+ }
10722
+
10723
+ const npmUpdateAvailable = !!(npmLatest && semverNewer(current, npmLatest));
10724
+ const gitUpdateAvailable = !!(gitLatest && semverNewer(current, gitLatest));
10725
+ const updateAvailable = npmUpdateAvailable || gitUpdateAvailable;
10726
+
10727
+ // Newer of the two (npm vs github) for legacy "latest" display.
10728
+ let latest = null;
10729
+ if (npmLatest && gitLatest) {
10730
+ latest = compareSemver(npmLatest, gitLatest) >= 0 ? npmLatest : gitLatest;
10731
+ } else {
10732
+ latest = npmLatest || gitLatest;
10733
+ }
10734
+
10735
+ let source = null;
10736
+ if (npmUpdateAvailable && gitUpdateAvailable) source = 'both';
10737
+ else if (npmUpdateAvailable) source = 'npm';
10738
+ else if (gitUpdateAvailable) source = 'github';
10739
+
10740
+ const installPlan = buildSynabunInstallPlan({
10741
+ current,
10742
+ installedChannel,
10743
+ latest,
10744
+ updateAvailable,
10745
+ });
10746
+
10747
+ _synabunUpdateCache = {
10748
+ current,
10749
+ installedChannel,
10750
+ npmLatestStable,
10751
+ npmLatestBeta,
10752
+ npmLatestTag,
10753
+ npmLatest,
10754
+ gitLatest,
10755
+ gitLatestRaw,
10756
+ latest,
10757
+ npmUpdateAvailable,
10758
+ gitUpdateAvailable,
10759
+ updateAvailable,
10760
+ source,
10761
+ installPlan,
10762
+ checkedAt: new Date().toISOString(),
10763
+ npmError,
10764
+ gitError,
10765
+ };
10766
+
10767
+ if (updateAvailable) {
10768
+ console.log(` Updates: v${current} → v${latest} (${source}; channel: ${installedChannel})`);
10769
+ } else if (npmError && gitError) {
10770
+ console.log(` Updates: check failed (npm: ${npmError}; github: ${gitError})`);
10771
+ } else if (npmError) {
10772
+ console.log(` Updates: up to date (v${current}; npm check failed: ${npmError})`);
10773
+ } else if (gitError) {
10774
+ console.log(` Updates: up to date (v${current}; github check failed: ${gitError})`);
10775
+ } else {
10776
+ console.log(` Updates: up to date (v${current}; channel: ${installedChannel})`);
10119
10777
  }
10120
10778
  }
10121
10779
 
10122
10780
  app.get('/api/system/version', async (req, res) => {
10123
- if (_npmUpdateCache.checkedAt) {
10124
- const age = Date.now() - new Date(_npmUpdateCache.checkedAt).getTime();
10125
- if (age > 6 * 60 * 60 * 1000) {
10126
- checkNpmUpdate().catch(() => {});
10781
+ const force = req.query.force === '1';
10782
+ if (force || !_synabunUpdateCache.checkedAt) {
10783
+ try { await checkSynabunUpdate(); } catch {}
10784
+ } else {
10785
+ const age = Date.now() - new Date(_synabunUpdateCache.checkedAt).getTime();
10786
+ if (age > 60 * 60 * 1000) {
10787
+ checkSynabunUpdate().catch(() => {});
10127
10788
  }
10128
10789
  }
10129
- res.json(_npmUpdateCache);
10790
+ res.json(_synabunUpdateCache);
10130
10791
  });
10131
10792
 
10132
10793
  // ═══════════════════════════════════════════
@@ -10142,17 +10803,99 @@ const TOOL_DEFS = [
10142
10803
 
10143
10804
  let _toolVersionCache = { checkedAt: null, tools: {} };
10144
10805
 
10806
+ // Backwards-compat helper — returns a "MAJOR.MINOR.PATCH" string (no pre).
10807
+ // Kept because legacy callers expect a string for display purposes only.
10808
+ // Comparison logic must use parseSemver/compareSemver below to handle
10809
+ // prerelease/iteration suffixes correctly.
10145
10810
  function parseVersion(raw) {
10146
- const m = raw.match(/(\d+\.\d+\.\d+)/);
10147
- return m ? m[1] : null;
10811
+ const m = String(raw ?? '').match(/(\d+)\.(\d+)\.(\d+)/);
10812
+ return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
10813
+ }
10814
+
10815
+ // Structured semver parse — handles calver bumps and prerelease tags:
10816
+ // "2026.4.26" -> { major:2026, minor:4, patch:26, pre:null, iter:null }
10817
+ // "2026.4.20-2" -> { major:2026, minor:4, patch:20, pre:"2", iter:2 }
10818
+ // "2026.4.26-beta.3" -> { major:2026, minor:4, patch:26, pre:"beta.3", iter:null }
10819
+ // "v1.2.3-rc.1" -> { major:1, minor:2, patch:3, pre:"rc.1", iter:null }
10820
+ //
10821
+ // `iter` is set when the suffix is purely numeric (SynaBun's calver iteration
10822
+ // scheme: `2026.4.26-1` is the FIRST patch published after `2026.4.26`).
10823
+ // Strict semver §9 treats any prerelease suffix as PRE-release (lower than no
10824
+ // suffix). SynaBun publishes calver iterations as `latest` on npm, so we need
10825
+ // to treat numeric-only suffixes as POST-release iterations instead.
10826
+ function parseSemver(raw) {
10827
+ if (raw == null) return null;
10828
+ const s = String(raw).trim().replace(/^v\.?/, '');
10829
+ const m = s.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
10830
+ if (!m) return null;
10831
+ const pre = m[4] || null;
10832
+ const iter = pre && /^\d+$/.test(pre) ? Number(pre) : null;
10833
+ return {
10834
+ major: +m[1],
10835
+ minor: +m[2],
10836
+ patch: +m[3],
10837
+ pre,
10838
+ iter,
10839
+ };
10840
+ }
10841
+
10842
+ // Precedence (mix of semver §11 + SynaBun calver-iteration convention):
10843
+ // 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0 < 1.0.0-1 < 1.0.0-2
10844
+ // Numeric-only suffix = post-release iteration (NEWER than no-suffix release).
10845
+ // Mixed/alpha suffix = prerelease (OLDER than no-suffix release, per semver).
10846
+ // Returns negative if a<b, positive if a>b, 0 if equal. Accepts strings or
10847
+ // parsed objects.
10848
+ function compareSemver(a, b) {
10849
+ const pa = (a && typeof a === 'object' && 'major' in a) ? a : parseSemver(a);
10850
+ const pb = (b && typeof b === 'object' && 'major' in b) ? b : parseSemver(b);
10851
+ if (!pa && !pb) return 0;
10852
+ if (!pa) return -1;
10853
+ if (!pb) return 1;
10854
+ if (pa.major !== pb.major) return pa.major - pb.major;
10855
+ if (pa.minor !== pb.minor) return pa.minor - pb.minor;
10856
+ if (pa.patch !== pb.patch) return pa.patch - pb.patch;
10857
+ // Equal x.y.z. Order from oldest → newest:
10858
+ // alpha-prerelease < no-suffix release < numeric iteration
10859
+ if (!pa.pre && !pb.pre) return 0;
10860
+ // Numeric iterations are POST-release: always newer than no-suffix and
10861
+ // newer than alpha prereleases.
10862
+ if (pa.iter != null && pb.iter == null) return 1;
10863
+ if (pa.iter == null && pb.iter != null) return -1;
10864
+ if (pa.iter != null && pb.iter != null) return pa.iter - pb.iter;
10865
+ // Neither side is a numeric iteration. Standard semver §11: no-suffix is
10866
+ // greater than alpha prerelease.
10867
+ if (!pa.pre && pb.pre) return 1;
10868
+ if ( pa.pre && !pb.pre) return -1;
10869
+ // Both have alpha-flavoured prerelease — compare identifiers per semver §11.4.
10870
+ const ia = pa.pre.split('.');
10871
+ const ib = pb.pre.split('.');
10872
+ const len = Math.max(ia.length, ib.length);
10873
+ for (let i = 0; i < len; i++) {
10874
+ if (i >= ia.length) return -1; // shorter set has lower precedence
10875
+ if (i >= ib.length) return 1;
10876
+ const xa = ia[i], xb = ib[i];
10877
+ const na = /^\d+$/.test(xa), nb = /^\d+$/.test(xb);
10878
+ if (na && nb) {
10879
+ const da = +xa, db = +xb;
10880
+ if (da !== db) return da - db;
10881
+ } else if (na && !nb) {
10882
+ return -1; // numeric < alphanumeric
10883
+ } else if (!na && nb) {
10884
+ return 1;
10885
+ } else {
10886
+ if (xa !== xb) return xa < xb ? -1 : 1;
10887
+ }
10888
+ }
10889
+ return 0;
10148
10890
  }
10149
10891
 
10150
10892
  function semverNewer(a, b) {
10151
- if (!a || !b) return false;
10152
- const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
10153
- return pb[0] > pa[0]
10154
- || (pb[0] === pa[0] && pb[1] > pa[1])
10155
- || (pb[0] === pa[0] && pb[1] === pa[1] && pb[2] > pa[2]);
10893
+ // True iff b is strictly newer than a. Falls back to false if either side
10894
+ // can't be parsed — same defensive contract as the prior implementation.
10895
+ if (a == null || b == null) return false;
10896
+ const pa = parseSemver(a), pb = parseSemver(b);
10897
+ if (!pa || !pb) return false;
10898
+ return compareSemver(pa, pb) < 0;
10156
10899
  }
10157
10900
 
10158
10901
  function resolveBinPath(cmd, env) {
@@ -10176,6 +10919,7 @@ function detectInstallSource(binPath) {
10176
10919
  if (/\/node_modules\//.test(p) || /\/npm\/node_modules\//.test(p)) return 'npm';
10177
10920
  if (p.includes('/opt/homebrew/') || p.includes('/usr/local/cellar/') || /\/homebrew\//.test(p)) return 'brew';
10178
10921
  if (p.includes('/.claude/local/') || p.includes('/anthropicclaude/')) return 'native';
10922
+ if (/\/\.local\/bin\//.test(p)) return 'native';
10179
10923
  if (/\/program files( \(x86\))?\//.test(p) || /\/localappdata\/.*anthropicclaude/.test(p)) return 'native';
10180
10924
  if (p.includes('/winget/') || p.includes('/microsoft/winget/')) return 'winget';
10181
10925
  if (/\/usr\/local\/lib\/node_modules\//.test(p)) return 'npm';
@@ -10304,15 +11048,19 @@ async function checkToolVersions() {
10304
11048
  }
10305
11049
 
10306
11050
  // Nuclear safety net for the recurring claude phantom-version bug:
10307
- // if the probe returned a known historical bundled version of claude-code,
10308
- // reject it even if the binary-path detection failed. A real user-global
10309
- // install of claude is always current (2.1.116+), so seeing 2.1.89 etc.
10310
- // means we're reading a stale bundle SynaBun shipped in a prior release.
10311
- if (tool.key === 'claude-code' && installed && KNOWN_STALE_CLAUDE_VERSIONS.has(installed)) {
11051
+ // only reject when binary path detection failed (truly phantom). If we
11052
+ // resolved a path AND it's outside SynaBun's own dirs, trust the version
11053
+ // — Anthropic ships these versions in the native installer too, so a
11054
+ // version-only blacklist hits legit installs at ~/.local/bin/claude.exe.
11055
+ if (
11056
+ tool.key === 'claude-code' &&
11057
+ installed &&
11058
+ KNOWN_STALE_CLAUDE_VERSIONS.has(installed) &&
11059
+ !probedBinPath
11060
+ ) {
10312
11061
  console.warn(
10313
- ` [version-guard] Rejected claude v${installed} from ${probedBinPath || '(unknown path)'}` +
10314
- ` — matches known-stale SynaBun bundle. Treating as not installed. ` +
10315
- `If you actually have this version installed, remove it from KNOWN_STALE_CLAUDE_VERSIONS.`
11062
+ ` [version-guard] Rejected claude v${installed} from (unknown path)` +
11063
+ ` — matches known-stale SynaBun bundle and no binary path could be resolved.`
10316
11064
  );
10317
11065
  installed = null;
10318
11066
  }
@@ -12152,6 +12900,89 @@ const SYNABUN_TOOL_CATEGORIES = [
12152
12900
  },
12153
12901
  ];
12154
12902
 
12903
+ // ── Memory-tool permission auto-injection ──
12904
+ // On MCP enable for any provider, force-allow the 7 SynaBun memory tools
12905
+ // (recall/remember/reflect/forget/memories/restore/sync) so PTY/CLI sessions
12906
+ // can use SynaBun memory without per-call permission prompts. Inject-only,
12907
+ // never overwrites existing entries. Falls back to project-scoped settings
12908
+ // when the user has no global settings file.
12909
+ function getMemoryToolKeys() {
12910
+ const cat = SYNABUN_TOOL_CATEGORIES.find(c => c.id === 'memory');
12911
+ return cat ? cat.tools.map(t => t.key) : [];
12912
+ }
12913
+
12914
+ function injectClaudeMemoryPerms() {
12915
+ const keys = getMemoryToolKeys();
12916
+ const globalPath = getGlobalClaudeSettingsPath();
12917
+
12918
+ if (existsSync(globalPath)) {
12919
+ const settings = readClaudeSettings(globalPath) || {};
12920
+ if (!settings.permissions) settings.permissions = {};
12921
+ if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
12922
+ const set = new Set(settings.permissions.allow);
12923
+ for (const k of keys) set.add(k);
12924
+ settings.permissions.allow = [...set];
12925
+ writeClaudeSettings(globalPath, settings);
12926
+ return { scope: 'global', paths: [globalPath] };
12927
+ }
12928
+
12929
+ const projects = loadHookProjects();
12930
+ const written = [];
12931
+ for (const p of projects) {
12932
+ if (!p?.path) continue;
12933
+ const projPath = getClaudeSettingsPath(p.path);
12934
+ const s = readClaudeSettings(projPath) || {};
12935
+ if (!s.permissions) s.permissions = {};
12936
+ if (!Array.isArray(s.permissions.allow)) s.permissions.allow = [];
12937
+ const set = new Set(s.permissions.allow);
12938
+ for (const k of keys) set.add(k);
12939
+ s.permissions.allow = [...set];
12940
+ writeClaudeSettings(projPath, s);
12941
+ written.push(projPath);
12942
+ }
12943
+ return { scope: 'project', paths: written };
12944
+ }
12945
+
12946
+ function injectCodexMemoryPerms() {
12947
+ try {
12948
+ const configPath = join(getHomePath(), '.codex', 'config.toml');
12949
+ if (!existsSync(configPath)) return { scope: 'none', paths: [] };
12950
+ let content = readFileSync(configPath, 'utf-8');
12951
+ if (!tomlHasSection(content, 'mcp_servers.SynaBun')) return { scope: 'none', paths: [] };
12952
+ for (const k of getMemoryToolKeys()) {
12953
+ const shortName = k.replace('mcp__SynaBun__', '');
12954
+ const section = `mcp_servers.SynaBun.tools.${shortName}`;
12955
+ if (!tomlHasSection(content, section)) {
12956
+ content = tomlUpsertSection(content, section, { approval_mode: 'approve' });
12957
+ }
12958
+ }
12959
+ writeFileSync(configPath, content, 'utf-8');
12960
+ return { scope: 'global', paths: [configPath] };
12961
+ } catch (err) {
12962
+ console.error('[memory-perms] Codex inject failed:', err.message);
12963
+ return { scope: 'error', paths: [] };
12964
+ }
12965
+ }
12966
+
12967
+ function injectOpenCodeMemoryPerms() {
12968
+ try {
12969
+ const configPath = getOpencodeConfigPath();
12970
+ if (!existsSync(configPath)) return { scope: 'none', paths: [] };
12971
+ let cfg = {};
12972
+ try { cfg = JSON.parse(readFileSync(configPath, 'utf8')); } catch {}
12973
+ if (!cfg.permission) cfg.permission = {};
12974
+ if (!cfg.permission.tools || typeof cfg.permission.tools !== 'object') cfg.permission.tools = {};
12975
+ for (const k of getMemoryToolKeys()) {
12976
+ if (!(k in cfg.permission.tools)) cfg.permission.tools[k] = 'allow';
12977
+ }
12978
+ writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
12979
+ return { scope: 'global', paths: [configPath] };
12980
+ } catch (err) {
12981
+ console.error('[memory-perms] OpenCode inject failed:', err.message);
12982
+ return { scope: 'error', paths: [] };
12983
+ }
12984
+ }
12985
+
12155
12986
  function addHookToSettings(settings, onlyEvent, targetProjectPath) {
12156
12987
  if (!settings) settings = {};
12157
12988
  if (!settings.hooks) settings.hooks = {};
@@ -14009,11 +14840,12 @@ app.post('/api/claude-code/mcp', (req, res) => {
14009
14840
  };
14010
14841
  writeFileSync(claudeJsonPath, JSON.stringify(data, null, 2), 'utf-8');
14011
14842
 
14012
- // Also inject tool permissions into global settings so tools work without prompts
14013
- const globalSettingsPath = getGlobalClaudeSettingsPath();
14014
- let globalSettings = readClaudeSettings(globalSettingsPath) || {};
14015
- ensureSynaBunPermissions(globalSettings);
14016
- writeClaudeSettings(globalSettingsPath, globalSettings);
14843
+ // Force-allow the 7 SynaBun memory tools so CLI/PTY sessions skip per-call
14844
+ // permission prompts. Writes to ~/.claude/settings.json if present, otherwise
14845
+ // falls back to project-scoped <project>/.claude/settings.json for each
14846
+ // SynaBun-tracked project.
14847
+ const permResult = injectClaudeMemoryPerms();
14848
+ console.log(`[memory-perms] Claude memory tools injected (${permResult.scope}): ${permResult.paths.join(', ')}`);
14017
14849
 
14018
14850
  res.json({ ok: true, message: 'SynaBun MCP registered (HTTP). Restart Claude Code to connect.' });
14019
14851
  } catch (err) {
@@ -14249,6 +15081,9 @@ app.post('/api/setup/gemini/mcp', (req, res) => {
14249
15081
  command: 'node',
14250
15082
  args: [mcpIndexPath],
14251
15083
  env: { DOTENV_PATH: envPath, SYNABUN_DATA_HOME: DATA_HOME, MEMORY_DATA_DIR: resolve(DATA_HOME, 'mcp-data') },
15084
+ // trust:true skips per-tool permission prompts for this MCP server, so CLI
15085
+ // sessions can use SynaBun memory without interactive approval gates.
15086
+ trust: true,
14252
15087
  };
14253
15088
  writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
14254
15089
  res.json({ ok: true, message: 'SynaBun MCP registered in Gemini CLI. Restart Gemini to connect.' });
@@ -14302,6 +15137,8 @@ app.post('/api/setup/codex/mcp', (req, res) => {
14302
15137
  env: buildCodexMcpEnv(),
14303
15138
  });
14304
15139
  writeFileSync(configPath, content, 'utf-8');
15140
+ // Force-allow the 7 SynaBun memory tools so CLI sessions skip prompts.
15141
+ injectCodexMemoryPerms();
14305
15142
  res.json({ ok: true, message: 'SynaBun MCP registered in Codex CLI. Restart Codex to connect.' });
14306
15143
  } catch (err) {
14307
15144
  res.status(500).json({ error: err.message });
@@ -22378,8 +23215,8 @@ const httpServer = app.listen(PORT, async () => {
22378
23215
  }
22379
23216
  } catch { /* ok */ }
22380
23217
 
22381
- // Check for npm updates
22382
- try { await checkNpmUpdate(); } catch {}
23218
+ // Check for SynaBun updates (npm + github releases)
23219
+ try { await checkSynabunUpdate(); } catch {}
22383
23220
 
22384
23221
  // Aggressively prune any stale bundled CLI tools left in our node_modules
22385
23222
  // before the version check runs — defends against `npm install` not auto-
@@ -23152,6 +23989,21 @@ function saveSessionSnapshot() {
23152
23989
  setInterval(saveSessionSnapshot, SESSION_SNAPSHOT_INTERVAL_MS);
23153
23990
 
23154
23991
  // Also try to save on clean shutdown (works with Ctrl+C, not window close)
23155
- process.on('SIGINT', () => { stopOpencodeServer(); saveSessionSnapshot(); process.exit(0); });
23156
- process.on('SIGTERM', () => { stopOpencodeServer(); saveSessionSnapshot(); process.exit(0); });
23157
- process.on('exit', () => { stopOpencodeServer(); saveSessionSnapshot(); });
23992
+ // Manual termination paths (Ctrl+C, kill, parent shell exit) all flow through
23993
+ // gracefulShutdown — same teardown the in-app restart/shutdown/update paths
23994
+ // use. Without this, hand-killed servers still leave orphaned opencode +
23995
+ // agent children holding file handles, which blocks `npm i -g synabun`.
23996
+ process.on('SIGINT', async () => {
23997
+ console.log('\n[server] SIGINT received.');
23998
+ await gracefulShutdown('sigint');
23999
+ process.exit(0);
24000
+ });
24001
+ process.on('SIGTERM', async () => {
24002
+ console.log('\n[server] SIGTERM received.');
24003
+ await gracefulShutdown('sigterm');
24004
+ process.exit(0);
24005
+ });
24006
+ // `exit` fires after process.exit() — only safe for synchronous final tasks.
24007
+ // gracefulShutdown is async so we cannot await here; just snapshot + ocp stop
24008
+ // best-effort. The async paths above are the primary teardown.
24009
+ process.on('exit', () => { try { stopOpencodeServer(); } catch {} try { saveSessionSnapshot(); } catch {} });