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.
- package/CHANGELOG.md +11 -126
- package/neural-interface/public/shared/cdx/cdx-panel.js +4 -4
- package/neural-interface/public/shared/cdx/cdx-styles.js +1 -1
- package/neural-interface/public/shared/ocp/ocp-panel.js +5 -4
- package/neural-interface/public/shared/ocp/ocp-styles.js +1 -1
- package/neural-interface/public/shared/styles.css +257 -151
- package/neural-interface/public/shared/ui-claude-panel.js +5 -5
- package/neural-interface/public/shared/ui-menubar.js +64 -36
- package/neural-interface/public/shared/ui-tool-updates.js +77 -3
- package/neural-interface/public/shared/ui-update.js +234 -32
- package/neural-interface/server.js +926 -74
- package/package.json +4 -1
- package/postinstall.js +20 -0
- package/preuninstall.js +140 -6
- package/updater.cmd +31 -0
- package/updater.mjs +245 -0
- package/updater.sh +29 -0
|
@@ -1546,14 +1546,400 @@ app.put('/api/settings', (req, res) => {
|
|
|
1546
1546
|
}
|
|
1547
1547
|
});
|
|
1548
1548
|
|
|
1549
|
-
//
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
1554
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
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 {
|
|
2214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
10084
|
-
|
|
10085
|
-
|
|
10086
|
-
|
|
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
|
-
|
|
10089
|
-
|
|
10090
|
-
|
|
10091
|
-
|
|
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
|
-
|
|
10094
|
-
|
|
10095
|
-
|
|
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
|
-
|
|
10098
|
-
const
|
|
10099
|
-
const
|
|
10100
|
-
|
|
10101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10106
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
10111
|
-
|
|
10112
|
-
|
|
10113
|
-
|
|
10114
|
-
|
|
10115
|
-
|
|
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
|
-
|
|
10118
|
-
|
|
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
|
-
|
|
10124
|
-
|
|
10125
|
-
|
|
10126
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
10152
|
-
|
|
10153
|
-
|
|
10154
|
-
|
|
10155
|
-
|
|
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
|
-
//
|
|
10308
|
-
//
|
|
10309
|
-
//
|
|
10310
|
-
//
|
|
10311
|
-
if (
|
|
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
|
|
10314
|
-
` — matches known-stale SynaBun bundle
|
|
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
|
-
//
|
|
14013
|
-
|
|
14014
|
-
|
|
14015
|
-
|
|
14016
|
-
|
|
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
|
|
22382
|
-
try { await
|
|
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
|
-
|
|
23156
|
-
|
|
23157
|
-
|
|
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 {} });
|