synabun 2026.4.26-beta.9 → 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 CHANGED
@@ -1,200 +1,14 @@
1
- # SynaBun Changelog
1
+ ## THIS IS A NEW CHANGELOG START FRESH ##
2
2
 
3
- Raw internal changelogs are archived in [changelog/](changelog/).
3
+ ## 2026-04-27
4
4
 
5
- ## 2026-04-25
5
+ ### Fixed — SynaBun memory tools blocked by permission gate in CLI/PTY sessions
6
6
 
7
- ### Added — Dedicated Schedules Studio
8
- - **Standalone schedule window** — Added `neural-interface/public/shared/ui-schedules-studio.js` and initialized it from both `neural-interface/public/variant/2d/main.js` and `neural-interface/public/variant/3d/main.js`. The new window opens from `automations:open-schedules` and keeps schedule management out of the Automation Studio detail pane.
9
- - **Tabbed schedule workspace** — Built Cron, Timers, Groups, and Activity tabs so recurring schedules, quick timers, group management, and run history have separate focused surfaces instead of sharing the automation editor area.
10
- - **Schedule editor flow** — Added a dedicated editor with cron presets, timezone, day selection themes, enabled state, group assignment, and launch overrides for profile/model/effort/MCP/browser settings.
11
- - **Timer controls** — Added quick timer creation, run-now actions, active timer countdowns, saved schedule timers, pause/resume, trigger, edit, and delete controls inside the schedules window.
12
-
13
- ### Added — Schedule Groups API and Persistence
14
- - **Group-aware schedule store** — Added `loadScheduleStore()`, `saveScheduleStore()`, `loadScheduleGroups()`, `saveScheduleGroups()`, `validateScheduleGroupId()`, and `applyScheduleLaunchOverrides()` in `neural-interface/server.js`. Existing `loop-schedules.json` data remains compatible while the store can now persist `{ schedules, groups }`.
15
- - **Schedule group endpoints** — Added `GET /api/schedule-groups`, `POST /api/schedule-groups`, `PUT /api/schedule-groups/:id`, `DELETE /api/schedule-groups/:id`, and `POST /api/schedule-groups/reorder` for group CRUD and ordering.
16
- - **Schedule assignment metadata** — Extended schedule create/update and launch payloads to carry `groupId`, `profile`, `model`, `effort`, `mcpProfile`, and `usesBrowser`, allowing schedules in a group to keep their launch configuration with the schedule itself.
17
- - **Client sync helpers** — Added schedule group API helpers in `neural-interface/public/shared/api.js` and broadcast handling for `schedule-group:created`, `schedule-group:updated`, `schedule-group:deleted`, and `schedule-group:reordered` in `neural-interface/public/shared/ui-sync.js`.
18
-
19
- ### Changed — Automation Studio Schedule Routing
20
- - **Schedules removed from the Automation Studio content flow** — `initAutomationStudio()` no longer handles `automations:open-schedules` by switching `_view` to `schedules`, and Automation Studio no longer initializes schedule WebSocket rendering. The header Schedules button now emits the shared schedules event for the standalone window.
21
- - **Automations submenu expanded** — Added Schedules, New Cron Schedule, Quick Timer, and Schedule Groups menu items in `neural-interface/public/shared/html-shell.js`, with handlers in `neural-interface/public/shared/ui-menubar.js` that open the dedicated schedules window directly to the requested flow.
22
- - **Schedules window styling** — Added the `.schedules-studio-panel` glass shell and `.sched-*` responsive layout styles in `neural-interface/public/shared/styles.css` so the standalone window has its own grouped sidebar, tab rail, schedule lists, dialogs, and mobile layout.
23
-
24
- ### Changed — Live Countdown on Schedule Cards
25
- - **Live ticking countdown** — Replaced static "Next: 4h" text on Cron-tab schedule cards with a 1-second-tick live countdown. `formatNextRun()` in `neural-interface/public/shared/ui-schedules-studio.js` rewritten as adaptive 2-unit format (`2d 3h`, `4h 23m`, `15m 42s`, `45s`, `Overdue`) that drops zero second-unit. Card markup wraps the value in `<span class="sched-countdown" data-countdown="<iso>">` so `updateTimerCountdowns()` can refresh text without re-rendering each tick. `POLL_INTERVAL` lowered from `5000` to `1000`; tick lifecycle stays bound to `startPolling()` / `stopPolling()` so it only runs while the panel is open.
26
- - **Tabular-nums styling** — Added `.sched-countdown` rule in `neural-interface/public/shared/styles.css` with `font-variant-numeric: tabular-nums` to prevent layout jitter as digits change each second.
27
-
28
- ### Added — Edit Prompt Shortcut on Schedule Cards
29
- - **Edit prompt icon button** — Each cron schedule card in `neural-interface/public/shared/ui-schedules-studio.js` now has an "Edit prompt" icon button (document-with-lines `script` icon) between Run Now and Edit Schedule. Click closes the Schedules panel and opens Automation Studio with the schedule's linked template selected directly in detail/editor view.
30
- - New `script` icon added to the `ICONS` map
31
- - New button uses `data-action="schedule-edit-prompt"` with `data-template-id="${s.templateId}"`
32
- - Existing pencil tooltip clarified from "Edit" to "Edit schedule" to differentiate
33
- - New `case 'schedule-edit-prompt'` handler reads `el.dataset.templateId`, calls `closePanel()`, then emits `automations:open` with `{templateId}` payload
34
- - **`automations:open` event accepts `{templateId}` payload** — `initAutomationStudio()` listener in `neural-interface/public/shared/ui-automation-studio.js` now passes `opts` to `openPanel(opts = {})`. When payload contains `templateId`: if panel already open, finds template in `_templates` and calls `switchToDetail(tpl)`; on fresh open, after `loadData()` runs, looks up template and routes to detail view instead of `renderView()`. Falls back to default welcome view when no payload or template not found.
35
-
36
- ### Added — Sidepanel Tray Placeholder Pills
37
- - **Persisted pills before panel bootstrap** — Added `neural-interface/public/shared/ui-sidepanel-tray.js` to render minimized placeholder pills for saved Claude, Codex, and OpenCode tabs before those provider panels have been lazy-loaded. The tray reads window-scoped tab storage, falls back to legacy single-session keys, filters out blank default sessions, and opens the matching provider via `claude-panel:show`, `codex-panel:show`, or `opencode-panel:show` with the selected `tabId`.
38
- - **Provider handoff events** — Wired `initSidepanelTrayPlaceholders()` from `ui-navbar.js`, then dispatch `sidepanel-tray:provider-loaded` from `toggleClaudePanel()`, Codex `ensurePanel()`, and OpenCode `ensurePanel()` so placeholders disappear once real provider-owned pills are restored.
39
-
40
- ### Fixed — Right-Side Toolbar Reservation During Sidepanel Switches
41
- - **Toolbar could overlap an open sidepanel** — `ui-sidepanel-layout.js` previously tracked one `_activeOwner`, so closing or switching one sidepanel could clear `--right-panel-width` and `--right-panel-gap` while another right panel was still active. Replaced that with a per-owner reservation map, applying the widest active reservation and returning `true` from `clearRightPanelLayout()` only when no reservations remain, preserving toolbar and adjacent editor spacing during multi-provider toggles.
42
-
43
- ### Changed — Claude Restored Tab Selection
44
- - **Restored Claude tabs keep stable IDs** — Updated `createTab()` in `ui-claude-panel.js` to accept a restored `id`, and changed `restoreTabs()` to pass saved tab IDs through. Placeholder click-through can now target the restored Claude tab instead of losing selection because the panel generated a fresh `crypto.randomUUID()` during bootstrap.
45
-
46
- ### Changed — Backup System Coverage Expanded
47
- - **Backup file lists extended** — Added `stored-plans.json`, `synabun-plugins.json`, `mcp-registry.json`, and `active-profile.json` to both manual `GET /api/system/backup` (`neural-interface/server.js:10457`) and auto-backup `buildBackupZipToFile()` (`neural-interface/server.js:10844`) file arrays. These cover the post-plan hook plan→memory tracker, plugin install registry, third-party MCP server registry + profile defs, and active MCP profile selection — all previously lost on restore.
48
- - **Plan markdown history backed up recursively** — Added `addDirRecursive('data/plans', ...)` to both manual and auto backup paths (`neural-interface/server.js:10483`, `:10868`) so the date-sliced plan tree (`data/plans/YYYY-MM-DD/*.md`) rides along.
49
- - **Restore creates `plans` subdir** — Added `'plans'` to the `mkdirSync` subdir list in `POST /api/system/restore` (`neural-interface/server.js:10617`). Existing recursive `data/` write loop already extracts plan files via `dirname(target)` mkdir.
50
- - **Backward compatible with old backups** — Manifest version check still accepts `version 1 || 2`. Old backups restore exactly as before — missing new files skipped silently, no schema migration required.
51
-
52
- ### Added — CLI-Not-Installed Indicator in Sidepanels
53
- - **Empty-state banner across Claude, Codex, OpenCode panels** — Detects when the corresponding CLI binary is missing and renders a warning banner inside the panel's empty state (`.cp-empty` / `.cxp-empty` / `.ocp-empty`) with the install command, an "Install guide" link to the official docs, and a "Re-check" button. Banner is removed automatically once the CLI is detected.
54
- - **Shared status module `cli-status.js`** — New `neural-interface/public/shared/cli-status.js` wraps the existing `/api/system/tool-versions` endpoint with a subscriber + auto-poll layer. Exposes `subscribeCliStatus(toolKey, cb)`, `recheckCliStatus(toolKey)`, `getCliDocUrl(toolKey)`, `getCliInstallCommand(toolKey)`, `getCliLabel(toolKey)`. Polls every 30s while at least one subscribed tool reports `installed === null`; stops once detected or unsubscribed. 10s in-memory cache + single in-flight fetch dedup.
55
- - **Send-button gating** — Each panel's send/input flow is now blocked while the CLI is missing. `ui-claude-panel.js` `send()` returns early with a notification. `cdx/cdx-tabs.js` `syncInputEnabled` reads the `.cxp-cli-blocked` panel class to force `send.disabled = true`, and `sendPrompt()` returns early with a system message. `ocp/ocp-panel.js` `syncSendButton` short-circuits to disabled with the "OpenCode CLI not installed" tooltip; `handleSend` returns early via `setStatus('offline', …)`.
56
- - **Banner re-render hooks** — `cdx/cdx-tabs.js` `clearTranscript` now dispatches a `cdx-empty-rebuilt` CustomEvent and `ocp/ocp-render.js` `renderEmptyState` dispatches `ocp-empty-rebuilt`, so the banner re-injects itself after the empty state is rebuilt during session resets.
57
- - **Official install URLs** — `claude-code` → `https://docs.claude.com/en/docs/claude-code/setup`, `codex` → `https://developers.openai.com/codex/cli/`, `opencode` → `https://opencode.ai/docs/`. (`gemini` constants defined for future use; no Gemini sidepanel currently exists.)
58
- - **Banner styles** — Added `.cp-cli-missing*`, `.cxp-cli-missing*`, `.ocp-cli-missing*` rule sets to `ui-claude-panel.js` style block, `cdx/cdx-styles.js`, and `ocp/ocp-styles.js`. Includes `.cp-cli-blocked` / `.cxp-cli-blocked` / `.ocp-cli-blocked` panel classes for the dimmed input state.
59
-
60
- ### Fixed — CLI-Not-Installed Banner Hidden After Errors / Broken-Binary Cases (Sidepanels)
61
- - **Banner relocated to top of messages container** — Was rendered inside `.cxp-empty` / `.cp-empty` / `.ocp-empty`. Once any system message appeared (e.g. `Codex session closed` + `Codex app-server exited before responding to request 1`), the empty-state collapsed and took the banner with it. Banner is now an independent `:first-child` of `#cxp-messages-container`, `#cp-messages-container`, and `.ocp-messages-container` via `container.insertBefore(banner, container.firstChild)`. Stays visible across transcript states. New `ensureCliBannerEl()` / `removeCliBannerEl()` / `refreshCliBanner()` helpers replace the old empty-state-scoped `renderCliMissingBanner` / `refreshCliBanners` in `neural-interface/public/shared/cdx/cdx-panel.js`, `neural-interface/public/shared/ui-claude-panel.js`, and `neural-interface/public/shared/ocp/ocp-panel.js`.
62
- - **Spawn-failure pattern detection** — Banner only triggered when `/api/system/tool-versions` reported `installed: null`. A broken-but-present binary (e.g. `codex 0.125.0` spawning then exiting immediately) reports a real version, so the banner never showed. Added a per-panel `_cliInstallFailureForced` flag flipped on by WS error patterns:
63
- - **Codex** — `applySocketError` in `cdx/cdx-tabs.js` matches `/Codex CLI not found|ENOENT|app-server exited before responding|app-server spawn/i` → dynamic-imports `cdx-panel.js` and calls exported `flagCdxCliInstallFailure()`.
64
- - **Claude** — `case 'error'` in WS handler matches `/Claude CLI not found|ENOENT/i` → `flagClaudeCliInstallFailure()`.
65
- - **OpenCode** — `onError` callback matches `/opencode.*(not.*found|ENOENT|spawn)|opencode CLI/i` → `flagOcpCliInstallFailure()`.
66
- - All three flags clear automatically when the `cli-status` subscriber callback later receives an installed value.
67
- - **Send-button gate now reads forced flag** — Claude `send()`, Codex `sendPrompt()` (via `.cxp-cli-blocked` panel class read in `syncInputEnabled`), and OpenCode `syncSendButton` / `handleSend` all check `_cliInstalled === null || _cliInstallFailureForced` so a broken-binary install also blocks send.
68
- - **Banner restyled as sticky alert** — New `.cxp-cli-banner*`, `.cp-cli-banner*`, `.ocp-cli-banner*` rule sets in `cdx/cdx-styles.js`, `ui-claude-panel.js` style block, and `ocp/ocp-styles.js`. Three-column grid (icon, text, actions), orange/amber theme, `flex-shrink: 0`, sits at top of the scroll region with `z-index: 5` so it stays above transcript content.
69
-
70
- ### Fixed — High Severity npm audit Alerts in Install CLI (Neural Interface)
71
- - **Playwright SSL cert verification CVE** — `neural-interface/package.json` pinned `playwright` at `1.54.2`, vulnerable to GHSA-7mvr-c777-76hp (browser downloader skipped SSL cert authenticity check). Bumped to `^1.59.1` (non-major) — fixed in `<1.55.1`. `setup.js` install flow no longer prints the high severity warning after `Installing MCP Server dependencies`.
72
- - **lodash code injection + protobufjs RCE via transitives** — `lodash@4.17.23` (pulled by `archiver` → `archiver-utils@5.0.2`) had high severity GHSA-r5fr-rjxr-66jc (`_.template` import key code injection) plus moderate prototype pollution. `protobufjs@7.5.4` (pulled by `@huggingface/transformers` → `onnxruntime-web@1.22.0-dev.20250409-89f8206ba4`) had critical GHSA-xq3m-2v4x-88gg (arbitrary code execution). Added `overrides` block in `neural-interface/package.json` pinning `lodash: ^4.18.1` and `protobufjs: ^7.5.5` — both within the parents' implicit ranges, no breaking change.
73
-
74
- ### Fixed — Moderate/High npm audit Alerts in Install CLI (MCP Server)
75
- - **uuid buffer bounds check CVE** — `mcp-server/package.json` had `uuid@^11.1.0`, vulnerable to GHSA-w5hq-g745-h8pq (missing buffer bounds check in `v3`/`v5`/`v6` when `buf` provided), fixed in `<14.0.0`. Bumped to `^14.0.0` (semver major) — `v4` named export unchanged, `src/tools/remember.ts` and `src/tui.ts` compile cleanly with no source modifications.
76
- - **hono / @hono/node-server / postcss / vite / protobufjs transitives** — multiple moderate-to-high CVEs in vitest's transitive `vite@8.0.0-8.0.4` (path traversal, `server.fs.deny` bypass, websocket arbitrary file read), `hono@<4.12.14` (cookie validation, path traversal in `toSSG()`, ipRestriction IPv4-mapped IPv6), `@hono/node-server@<1.19.13` (serveStatic repeated-slash bypass), `postcss@<8.5.10` (XSS via unescaped `</style>`), and `protobufjs@<7.5.5`. Added `overrides` block in `mcp-server/package.json` pinning `protobufjs: ^7.5.5`, `hono: ^4.12.14`, `@hono/node-server: ^1.19.13`, `postcss: ^8.5.10`, `vite: ^8.0.10` — patches only, no breaking semver bumps to direct deps.
77
-
78
- ### Changed — Removed Deprecated `@types/uuid` devDependency
79
- - **uuid v14 ships own type defs** — removed `@types/uuid: ^10.0.0` from `mcp-server/package.json` devDependencies. npm warned the stub package as deprecated (`This is a stub types definition. uuid provides its own type definitions, so you do not need this installed`). `tsc` build verified — no missing-types errors.
80
-
81
- ## 2026-04-24
82
-
83
- ### Added — "Continue planning" in OpenCode, Codex, and Claude plan UIs
84
- - **OpenCode** — Added `data-action="continue-planning"` button to the post-plan card in `ocp-render.js` and wired `onContinuePlanning` in `showPostPlanUI()` (`ocp-tabs.js`). Handler clears `showPostPlanActions` while keeping `mode='plan'`, so the user can keep prompting to refine the plan without exiting plan mode.
85
- - **Codex** — Added `Continue planning` button to the post-plan card in `cdx-render.js`. Handler removes the card, then dispatches a new prompt with `forcePlanModePrefix: true` and the current plan text as context, allowing iterative plan refinement.
86
- - **Claude** — Added `Continue planning` action entry in `ui-claude-panel.js`. Handler clears `showPostPlanActions` and saves tabs, keeping `tab.planMode = true` so the next user message continues in plan mode.
87
- - **OpenCode guard message** — Updated the "choose before sending" error to include "Continue planning" alongside the existing three options.
88
-
89
- ### Fixed — OpenCode Sidepanel Post-Plan Card Resurrection
90
- - **`setTabMode()` leaves `showPostPlanActions` flag true when leaving plan mode** — Mode button clicks, Tab-key mode cycling, and other `setTabMode()` callers bypassed `onContinue`'s flag clear, so `tab.showPostPlanActions` could persist into build/chat. Combined with the unguarded restore in `renderTabMessages()` at `ocp-tabs.js:2258`, any subsequent tab switch / session switch / revert / WS reconnect re-rendered the "PLAN COMPLETE" card in the message area even though the user had already moved on to implementation. Fixed in `neural-interface/public/shared/ocp/ocp-tabs.js` with two defenses:
91
- - `setTabMode()` now clears `tab.showPostPlanActions` and calls `removePostPlanCards(container)` on any `plan → non-plan` transition, symmetric with the existing `_exitPlanDetected` reset on entry.
92
- - `renderTabMessages()` restore guard tightened to `tab.mode === 'plan' && tab.showPostPlanActions && tab.planContent`, so a leaked flag cannot resurrect the card outside plan mode.
93
-
94
- ### Fixed — Loop Launch Silent Hang (Automation Studio Browser Loops)
95
- - **Orphan Chrome holding `SingletonLock` froze `launchPersistentContext`** — When a previous loop-owned Chrome in `data/browser-profiles/<hash>/` stayed alive past its session, the OS-level singleton lock remained held by the live process. Existing stale-file cleanup at `neural-interface/server.js:20080` only removed unlocked lock files — useless when a running Chrome owned the lock. Subsequent `chromium.launchPersistentContext()` calls blocked indefinitely, so `POST /api/loop/launch` never returned. UI waited with no CLI window, no toast, no error surfaced. Fix in `neural-interface/server.js:20062` (`launchPersistentProfile`) adds a pre-launch orphan-kill pass: Unix uses `pgrep -f "user-data-dir=<root>"` + `SIGKILL`, Windows uses PowerShell `Get-CimInstance Win32_Process` filtered by `CommandLine -like "*user-data-dir=$root*"` (path passed via `SYNABUN_KILL_ROOT` env var to avoid backslash quoting) + `taskkill /F /PID`.
96
- - **`launchPersistentContext` had no timeout** — Added `timeout: 45000` to `launchOpts` at `neural-interface/server.js:19938` as a platform-agnostic safety net. If any edge case still leaves the singleton locked, Playwright errors after 45s instead of hanging silently, letting the UI surface `Failed to launch loop`.
97
-
98
- ### Added — Diagnostic Alert Fallbacks for Silent Launch Failures
99
- - **`attachDetached` catch in `neural-interface/public/shared/ui-terminal.js:5132`** — `showTermToast` appends its toast to `#term-container`; when the terminal panel is hidden (`offsetParent === null`), the toast renders invisibly and the failure gets swallowed. Added a post-toast container-visibility check that triggers a browser `alert()` with the error message plus a DevTools pointer when the container is hidden, surfacing attach failures that otherwise disappear.
100
- - **`confirmLaunch` catch in `neural-interface/public/shared/ui-automation-studio.js:1491`** — Added an `alert()` alongside the existing `showToast` so errors thrown before the `terminal:attach-floating` emit (network failures, missing state, module-load errors) reach the user even when the toast host is offscreen.
101
-
102
- ### Fixed — Codex MCP Approval Gating
103
- - **MCP approval prompts bypassed sidepanel Auto mode** — `handleCodexSkinWebSocket()` in `neural-interface/server.js` previously auto-accepted `mcpServer/elicitation/request` events for any SynaBun tool listed in `~/.claude/settings.json`, so tools like `mcp__SynaBun__remember` could run without a Codex approval card even when the tab Auto toggle was off. Added per-turn `activeTurnAutoAccept` tracking and gated the silent MCP accept path on both the current tab's Auto state and the permitted-tool list.
104
- - **Auto state now travels with each Codex query** — `dispatchPrompt()` in `neural-interface/public/shared/cdx/cdx-tabs.js` includes `autoAccept: !!tab?.autoAccept` in the WebSocket `query` payload, and `server.js` passes it into `startTurn()` so approval behavior follows the visible tab state.
105
- - **Per-turn auto-accept state is cleared after the turn** — `server.js` resets `activeTurnAutoAccept` on `turn/completed`, `thread/closed`, Codex child close, and `turn/start` failure to prevent a prior Auto-enabled turn from leaking into later tool approvals.
106
-
107
- ### Fixed — Codex Sidepanel Edit Plan Cancel Handoff
108
- - **Post-plan card stayed disabled after closing the editor** — `renderPostPlanActions()` in `neural-interface/public/shared/cdx/cdx-render.js` intentionally marks the post-plan card busy when `Edit plan` opens the file editor, but the Codex `plan-edit-cancelled` path in `neural-interface/public/shared/cdx/cdx-panel.js` tried to recover through unregistered `_host.renderPostPlanActions` callbacks. The Codex `plan-saved` and `plan-edit-cancelled` listeners now import and call `appendAssistantMarkdownMessage()` / `renderPostPlanActions()` directly from `cdx-render.js`, so closing without saving re-renders a fresh clickable card and saving reopens the `PLAN UPDATED` handoff correctly.
109
-
110
- ### Fixed — Codex Loop Silent Failure (Sentinel Ignored Exit Code)
111
- - **`attachExecLoopDriver` in `neural-interface/server.js:17230` advanced 162 ghost iterations after codex broke** — Iteration sentinel was built as `<codex exec ...>; echo SYNABUN_ITER_DONE_<N>`, so the echo fired regardless of `codex` exit status. After codex started failing fast at iter 4 (~4s vs ~7m for real runs), driver kept seeing the sentinel and looped 162 fake iters in 33 minutes until the PTY died with exit code 1. Fixed by:
112
- - Sentinel now `<cmd>; rc=$?; echo SYNABUN_ITER_DONE_<N> rc=$rc` — captures exit code in the echo.
113
- - Detection regex `new RegExp(\`${EXEC_SENTINEL_PREFIX}${session._execCurrentIter} rc=(-?\\d+)\`)` extracts `rc`.
114
- - Per-driver `consecutiveNonZero` and `consecutiveFastFails` counters with `FAST_FAIL_THRESHOLD_MS=30000` and `FAIL_LIMIT=3`. On 3-streak (non-zero exit OR sub-30s duration), driver marks loop `active:false`, sets `stoppedReason: 'fast-fail-rc'` or `'fast-fail-duration'`, records `lastExitCode`, kills PTY, and clears the interval.
115
- - Iteration start timestamp tracked via `session._execIterStartedAt = Date.now()` at iter-begin.
116
-
117
- ### Fixed — PTY Exit Left Loop File Active (Zombie Loops)
118
- - **`createTerminalSession` PTY exit handler in `neural-interface/server.js:16718` did not update loop state** — When a loop-owned PTY died, `terminalSessions.delete()` ran and the driver interval cleared, but the on-disk loop file still showed `active:true`. UI/scheduler saw a phantom running loop forever. Fixed by adding a post-`pty:exit` block that calls `findLoopFileForTerminal(sessionId)`, marks the file `active:false`, sets `completedAt`, `stoppedReason: 'pty-exit-<code>'`, and records `lastExitCode`. Logs `pty:exit | marked loop inactive` for forensics.
119
-
120
- ### Changed — Loop Tab Isolation Hardening
121
- - **Dropped `_switchSessionTab` calls during loop launch** — `neural-interface/server.js:8351` (manual launch) and `:8751` (scheduled launch) previously called `_switchSessionTab(claimedSession, ..., browserTabId)` after `createSessionTab`, flipping the shared browser session's `activeTabId`. With concurrent loops sharing one Chromium, this caused active-tab churn and risked any code path falling back to `session.activeTabId`. Each loop already pins its own tab via the `SYNABUN_BROWSER_TAB` env var, so the switch was unnecessary. Removed both calls; replaced with a comment explaining the constraint.
122
- - **`SYNABUN_BROWSER_TAB` env now wins over caller-supplied `tabId`** — `mcp-server/src/services/neural-interface.ts:88` (`resolveSession`) reordered the resolution: pinned env value takes precedence over the explicit `tabId` argument. If the caller passes a different tabId, it's logged as `[MCP] tabId override ignored` and the pin is honored. Prevents an agent or model from accidentally leaking into another loop's tab by passing the wrong tabId. Rebuilt via `npm run build` in `mcp-server/`.
123
-
124
- ## 2026-04-23
125
-
126
- ### Added — Claude Code Plugin Install from MCP UI
127
- - **CLI-delegated plugin installer** — Rewrote `neural-interface/lib/plugin-installer.js` to call `claude plugin marketplace add <url>` + `claude plugin install <plugin>@<marketplace>` instead of hand-writing `~/.claude/plugins/*.json`. Claude Code now owns its own marketplace + cache state; installs survive CC restarts. Legacy `synabun-symlink` source type removed.
128
- - **Auto-repair on server startup** — `server.js` `app.listen` callback invokes `repairSynabunPlugins()`. Scans `~/.claude/plugins/known_marketplaces.json` for legacy `source: synabun-symlink` entries, purges dangling symlinks and stale `installed_plugins.json` rows, reinstalls via CLI from the remembered repo path.
129
- - **`POST /api/plugins/repair`** — Manual trigger for the same repair flow.
130
- - **`data/synabun-plugins.json` tracker** — New JSON file SynaBun owns, recording which plugins we installed (CC remains source of truth for install state). `setTrackerPath()` export pins the path to `DATA_HOME/data/synabun-plugins.json` on boot.
131
- - **GitHub URL preferred over local clone** — `/api/mcp/install/github` claude-plugin branch passes `githubUrl` into the installer; CC re-clones fresh into its own cache so the temp clone at `data/mcp/<name>/repo` is only used to read the manifest.
132
-
133
- ### Added — Plugin Slash Commands in Sidepanel Autocomplete
134
- - **`GET /api/claude-code/plugin-commands`** — New endpoint in `server.js`. Walks `~/.claude/plugins/cache/*/*/*/commands/*.{toml,md}`, parses `description` from TOML (`description = "..."`) or markdown frontmatter, returns `[{ name, description, source: 'plugin', plugin, marketplace }]`. No caching — fresh scan per request, so newly installed plugins appear without restart.
135
- - **Autocomplete merge in `ui-claude-panel.js`** — `loadSkills()` adds a fourth merge step after SynaBun skills + CLI built-ins + extras. Plugin commands show in the `/` hint dropdown with `(plugin: <name>)` suffix.
136
-
137
- ### Fixed — Broken Legacy Caveman Install
138
- - **CC marketplace loader errored with `Marketplace configuration file is corrupted`** — Caused by the old installer writing `"source": "synabun-symlink"` to `known_marketplaces.json`; CC prunes unknown source types and wiped the `cache/caveman/` symlink so `installPath` in `installed_plugins.json` pointed at a missing directory. Hooks never fired. Cleanup + CLI reinstall restored caveman — `claude plugin list` now shows `caveman@caveman ✔ enabled` with a valid `cache/caveman/caveman/<sha>` dir.
139
-
140
- ### Fixed — Sidepanel Slash-Hints Silent Bail
141
- - **`showSlashHints()` returned early when `_skillsCache` null** — First `/` keystroke could fire before the async `loadSkills()` fetch resolved, leaving no dropdown. Made `showSlashHints` async; awaits `loadSkills()` when cache empty so the dropdown is guaranteed on first keystroke.
142
-
143
- ### Fixed — Sidepanel Slash-Hints Clipped by Input Wrapper
144
- - **`.cp-input-wrap` had `overflow: hidden`** — Parent wrapper clipped the upward-expanding `.cp-slash-hints` dropdown (positioned `bottom: calc(100% + 4px)`). Removed the overflow rule — the wrapper's conic-gradient border already clips itself via `-webkit-mask-composite: xor`, so the overflow was redundant. Dropdown now expands unclipped and scrolls internally.
145
-
146
- ### Changed — Sidepanel Slash-Hints Dropdown Sizing
147
- - **`.cp-slash-hints` — `max-height: 200px → 320px`** — Fits more rows before scroll kicks in.
148
- - **Added `overscroll-behavior: contain`** — Mouse-wheel scroll inside the dropdown no longer bubbles to the message list beneath.
149
-
150
- ### Fixed — GitHub MCP Installer Discarded Servers on Install Failure
151
- - **`/api/mcp/install/github` soft-fails local dep errors** — In `neural-interface/server.js`, the install endpoint returned HTTP 500 and aborted the whole flow when `pip install -r requirements.txt` or `npm install` errored, even though the clone + detection already succeeded. Reproducible with `AminForou/mcp-gsc`: launch command was `uvx mcp-search-console` (self-fetching) but `pip` missing on PATH made the endpoint bail before registry write. Now install failures attach an `installWarning` field to the 200 response — the server still registers, the user sees a `⚠` line in the GitHub install form, and deps can be fixed manually.
152
- - **Self-fetcher runtimes skip local install** — Added `commandSelfFetches(detected)` in `neural-interface/lib/mcp-installer.js` that matches `uvx` / `uv run` / `npx` / `pipx` / `bunx` / `pnpm dlx` / `yarn dlx`. When true, `installDependencies` is never called — the server is registered with `install.skipped = true, reason: "command '<cmd>' self-fetches at runtime"` and the UI shows a `⚙ Skipped local deps` hint.
153
-
154
- ### Added — Cross-OS Preflight for Self-Fetching MCP Runtimes
155
- - **`checkCommandOnPath(cmd)` in `neural-interface/lib/mcp-installer.js`** — OS-agnostic PATH probe. Uses `where <cmd>` on Windows (which resolves `.exe` via PATHEXT), `sh -c 'command -v <cmd>'` on Unix. 5s timeout guard. Returns a boolean.
156
- - **`checkSelfFetcherAvailable(detected)` preflight** — Called by the install endpoint when `commandSelfFetches` is true. Returns `{ available, command, installHint }`. Missing binary attaches an OS-specific install command to `installWarning` (e.g. *"'uvx' not found on PATH. Install: brew install uv. Server registered anyway — will fail at launch until installed."*).
157
- - **`SELF_FETCHER_INSTALL_HINTS` map** — Per-OS install commands for `uvx`/`uv`/`pipx`/`npx`/`bunx`/`bun`/`pnpm`/`yarn`. Mac uses `brew`, Linux uses official curl installers, Windows uses `winget` or PowerShell `irm`. `installHintFor(cmd)` picks the row based on `process.platform`. Ensures fresh SynaBun installs on Windows/Mac/Linux get actionable guidance instead of a silent launch failure.
158
-
159
- ### Changed — Codex Sidepanel Reasoning and Response Controls
160
- - **Expanded Codex reasoning effort support** — Updated `neural-interface/public/shared/cdx/cdx-icons.js`, `cdx-panel.js`, `cdx-styles.js`, and `cdx-tabs.js` so the Codex footer effort toggle now supports the full current set of sidepanel overrides: `off`, `minimal`, `low`, `medium`, `high`, and `xhigh`. The toolbar indicator now renders five effort dots, includes visual states for `minimal` and `xhigh`, and `/effort` validates input while treating `default` and `none` as no per-turn override.
161
- - **Exposed response-control config keys** — Added `model_reasoning_summary` and `model_verbosity` to `CODEX_CONFIG_KEYS` in `neural-interface/server.js`, then surfaced both in the Codex settings form and `/status` output in `neural-interface/public/shared/cdx/cdx-tabs.js`. Settings now support reasoning summary choices `auto`, `concise`, `detailed`, and `none`, plus response verbosity choices `low`, `medium`, and `high`.
162
-
163
- ### Fixed — Codex Sidepanel Session Cross-Talk
164
- - **Per-tab WebSocket identity** — `sendSocket()` in `neural-interface/public/shared/cdx/cdx-tabs.js` now stamps every Codex sidepanel WebSocket message with the bound tab's `sessionId`, so bootstrap, query, reattach, model, interrupt, and compact traffic is routed to the owning tab instead of only the shared `windowId`.
165
- - **Strict orphan reattach keys** — `_codexOrphanKey()` in `neural-interface/server.js` now requires both `windowId` and `sessionId`, and the reattach handler returns `reattach_result: false` when the tab id is missing. The close handler refuses to create window-wide orphans and kills sessionless processes, preventing a newly opened tab from replaying another tab's buffered tool-call events.
166
- - **Reconnect state cleanup** — `onClose()` sets `pendingReattach` only for abnormal Codex socket closes and `onReattachResult()` clears it, preserving correct orphan recovery for the original tab without leaking state to other sessions.
167
-
168
- ### Fixed — Codex Sidepanel Active-Tab Stop Scope
169
- - **Stop button aborted every running Codex tab** — The shared composer stop state in `syncInputEnabled()` was keyed off `_tabs.some((t) => t?.running)`, and `cdx-panel.js` wired click/Escape to `interruptAllTabs()`, so stopping one visible tab sent `interrupt` and delayed `force_kill` to every running tab WebSocket. The handlers now call `interruptTurn()` only when `activeTab()?.running`, the stop icon is scoped to the active tab, and `interruptTab()` sends session-scoped stop messages for the bound tab.
170
- - **Stale force-kill retries could outlive successful interrupts** — `onInterruptAck()` now clears each tab's `_abortRetry`, preventing the fallback timer from killing a Codex app-server after the server already acknowledged an interrupt.
171
- - **Server accepted unscoped stop messages** — `validateCodexControlMessage()` in `server.js` rejects `interrupt`/`force_kill` without a matching `sessionId` or with a mismatched `threadId`, while `forceKillCodexChild()` logs pid/session/thread/reason before terminating the app-server.
172
-
173
- ### Fixed — Codex Plan Mode Handoff
174
- - **Post-plan actions no longer get skipped** — `dispatchPrompt()` in `neural-interface/public/shared/cdx/cdx-tabs.js` now marks sent plan turns with `planMode`, `planTurnActive`, and `lastPlanTurnId`, so `handleNotify('turn/completed')` captures the completed plan even if the toolbar `planMode` flag has already changed by the time the turn finishes.
175
- - **Approval gate persists across tab state** — Added `planApprovalPending` / `planTurnActive` persistence through `snapshotBoundState()`, `restoreBoundState()`, `commitBoundState()`, `bindTabState()`, `createTab()`, and `saveTabs()`, and blocks `sendPrompt()` / `advanceQueue()` until the user chooses Continue, Compact, or Edit.
176
- - **Continue/Edit paths clear or restore the handoff correctly** — `renderPostPlanActions()` in `cdx-render.js` clears pending plan state before dispatching the non-plan implementation prompt and restores it if send fails, while `cdx-panel.js` marks edited plan saves as pending approval again.
177
- - **Plan turns run read-only server-side** — `server.js` passes the client `planMode` flag into `startTurn()`, detects explicit `[PLAN MODE]` prompts with `isCodexPlanModePrompt()`, and forces `buildCodexSandboxPolicy(..., 'read-only')` for plan turns.
178
- - **Prompt text now enforces the sidepanel handoff** — `CODEX_PLAN_MODE_PREFIXES` in `cdx-icons.js` and `server.js` now tells Codex to stop after producing the plan and wait for the sidepanel’s Edit, Compact, or Continue choice.
179
-
180
- ### Fixed — CLI/TUI Rendering Corruption (Terminal)
181
- - **Stable xterm renderer path** — `loadXterm()` in `neural-interface/public/shared/ui-terminal.js` now intentionally skips `@xterm/addon-webgl` and `@xterm/addon-canvas`, leaving CLI/TUI windows on xterm's DOM renderer. This avoids WebGL glyph atlas/context corruption seen after multiple long-lived floating CLI sessions.
182
- - **Serialized terminal writes** — Added `_createTerminalWriter()` with 64KB chunking and `term.write()` callback backpressure, then routed fresh session, reconnect, and WebSocket reconnect output through it. This prevents large PTY bursts from piling up unbounded parser/render work during long Codex/Claude/Gemini sessions.
183
- - **Snapshot-based reconnects** — `createTerminalSession()` in `neural-interface/server.js` now maintains a capped `VTermBuffer` per PTY, updates it on output and resize, and `/ws/terminal/:id` sends a synthesized `snapshot` payload instead of replaying an arbitrary raw ANSI tail. The snapshot includes a plain-text companion so `_sendOnceReady()` can still detect ready prompts without waiting for its fallback timer.
184
- - **Virtual terminal snapshot support** — `neural-interface/public/shared/vterm-buffer.js` now exposes `useAltBuffer` and accepts a per-instance scrollback cap, letting live PTY sessions keep only the rows needed for reconnect recovery while preserving the larger default for existing capture use cases.
185
-
186
- ## 2026-04-22
187
-
188
- ### Added — Smart "Install from GitHub" (MCP + Claude Code Plugins Auto-Route)
189
- - **Repo classifier** — New `classifyRepo(repoPath)` in `neural-interface/lib/mcp-installer.js` runs BEFORE `detectServer()` and returns `{ kind: 'mcp' | 'claude-plugin' | 'unknown' }`. Prefers MCP when both markers exist, emits `alsoClaudePlugin` hint for mixed repos, scans for codex/gemini/cursor/windsurf adjacency signals.
190
- - **Claude Code plugin installer** — New `neural-interface/lib/plugin-installer.js` with `installClaudePlugin()`, `uninstallClaudePlugin()`, `listSynabunPlugins()`. Symlinks the cloned repo into `~/.claude/plugins/marketplaces/<name>` and `~/.claude/plugins/cache/<marketplace>/<plugin>/unknown`, appends SynaBun-tagged entries to `known_marketplaces.json` + `installed_plugins.json` (dedupe by `scope:'user' + installPath`), captures `gitCommitSha` via `git rev-parse HEAD`.
191
- - **`POST /api/mcp/install/github` branches on classification** — After clone, `kind: 'claude-plugin'` skips MCP detection/deps install and calls `installClaudePlugin`; `kind: 'unknown'` returns 422 with hints list; MCP path unchanged. New endpoints `GET /api/plugins/list` and `DELETE /api/plugins/:marketplace?pluginName=&purgeFiles=1`.
192
- - **Settings UI rename + plugin list** — "+ Add Server" button renamed to "+ Install from GitHub" in `neural-interface/public/shared/ui-settings.js`. New "Claude Code Plugins" subsection with per-row Uninstall. Install handler swaps the form into "plugin mode" (hides Type/Command/Args/env/platforms/Save) when response is `kind: 'claude-plugin'` since the plugin is already activated server-side.
193
-
194
- ### Added — Installed MCPs Sub-Tab (Cross-CLI Unified View)
195
- - **`GET /api/mcp/installed-all` endpoint** — Aggregates every externally-installed MCP across SynaBun registry (non-builtin), `~/.claude.json`, `~/.gemini/settings.json`, `~/.codex/config.toml` (regex `^\[mcp_servers\.([^\].]+)\]$` excludes nested `.tools.` sections), and OpenCode's `mcp.*` config. Merges by server name; `registeredWith` is a sorted array of CLI keys.
196
- - **Sub-tab navigation inside Settings → MCP** — New `.stg-mcp-page-tabs` at top of the page swaps between **Profiles** (existing matrix + always-on + per-profile External Servers toggles) and **Installed** (new cross-CLI list + Claude Code Plugins + Install form). SynaBun's built-in tool groups are excluded from the Installed view.
197
- - **Per-CLI badges** — Each row in the Installed list shows compact `SB/CC/OC/CX/GE` badges indicating which CLIs have the server registered. Styles added for `.stg-mcp-page-tabs`, `.stg-mcp-page-tab.active`, `.stg-mx-ext-cli-badge`.
198
-
199
- ### Added — Cross-CLI Uninstall Button on Installed-Tab Rows
200
- - **One-click uninstall across all registered CLIs** — Each row in the Installed sub-tab carries a `×` button with `data-registered` containing its precise CLI list. Click handler confirms using the badge labels (e.g., "Remove foo from CC, OC, CX?"), then: if `registeredWith` includes `synabun` → `DELETE /api/mcp/registry/servers/:name`; for every remaining CLI key → `DELETE /api/mcp/sync` with `{ name, platforms: cliList }`. Refreshes both Installed list and the Profiles-tab External Servers list afterward. Avoids pointless 404 churn by only hitting CLIs that actually have the server.
7
+ - **Memory tools auto-allow on MCP enable across all providers** — On a fresh install, SynaBun memory worked in the Neural Interface sidepanel (which injects `--allowedTools` from the global allowlist) but failed in NI's PTY terminal sessions because the bare `claude`/`codex`/`gemini`/`opencode` CLIs hit an interactive permission gate for every `mcp__SynaBun__*` call. The PTY-injected "Learn it" exploration prompt fired before the user could approve, so the model abandoned SynaBun and fell back to native memory / `CLAUDE.md` writes.
8
+ - **Root cause** — `ensureSynaBunPermissions()` in `neural-interface/server.js` was a misnamed no-op (only deleted legacy plan rules; the comment falsely claimed it injected MCP tool permissions). Codex/Gemini/OpenCode MCP-enable handlers had no permission injection at all.
9
+ - **Added `getMemoryToolKeys()`** in `neural-interface/server.js` to flatten the `memory` category from `SYNABUN_TOOL_CATEGORIES` to its 7 tool keys: `recall`, `remember`, `reflect`, `forget`, `memories`, `restore`, `sync`.
10
+ - **Added `injectClaudeMemoryPerms()`** — Set-union into `~/.claude/settings.json` `permissions.allow` when present; falls back to writing `<projectRoot>/.claude/settings.json` for every project in `loadHookProjects()` when no global Claude settings file exists. Inject-only, never overwrites.
11
+ - **Added `injectCodexMemoryPerms()`** — `tomlUpsertSection` writes `[mcp_servers.SynaBun.tools.<name>] approval_mode = "approve"` for each memory key in `~/.codex/config.toml`. Skips sections that already exist; bails when `[mcp_servers.SynaBun]` parent missing to avoid invalid-transport orphans. Deliberately does NOT use the destructive `syncCodexToolPermissions()` (which strips tool sections not in its allowlist).
12
+ - **Added `injectOpenCodeMemoryPerms()`** — sets `permission.tools[<key>] = "allow"` for each memory key in `~/.config/opencode/config.json` (or `XDG_CONFIG_HOME`). Skips keys already set.
13
+ - **Wired into 4 MCP-enable handlers** — `POST /api/claude-code/mcp` now calls `injectClaudeMemoryPerms()` (replaces the old broken `ensureSynaBunPermissions()` block); `POST /api/setup/codex/mcp` calls `injectCodexMemoryPerms()` after the toml write; `POST /api/setup/gemini/mcp` adds `trust: true` to the SynaBun MCP entry (Gemini's server-level trust flag skips per-tool prompts); `POST /api/opencode/mcp` calls `injectOpenCodeMemoryPerms()` after `persistMcpToOpencodeConfig()`.
14
+ - **DELETE handlers untouched** — disabling SynaBun MCP leaves permission entries in place (orphaned but harmless; same key re-added on re-enable).
@@ -5094,15 +5094,51 @@
5094
5094
  }
5095
5095
  .update-backup-btn:hover { background: rgba(52,199,89,0.16); }
5096
5096
  .update-backup-btn:disabled { opacity: 0.5; pointer-events: none; }
5097
- .update-backup-status {
5098
- display: flex;
5099
- align-items: center;
5100
- gap: 8px;
5097
+ .update-backup-status {
5098
+ display: flex;
5099
+ align-items: center;
5100
+ gap: 8px;
5101
5101
  font-size: 11px;
5102
5102
  color: var(--t-muted);
5103
- padding: 4px 0;
5104
- }
5105
- .update-backup-icon { font-size: 14px; font-weight: 700; }
5103
+ padding: 4px 0;
5104
+ }
5105
+ .update-run-status {
5106
+ align-items: flex-start;
5107
+ gap: 10px;
5108
+ padding: 10px 12px;
5109
+ border-radius: var(--r-card);
5110
+ border: 1px solid rgba(255,255,255,0.08);
5111
+ background: rgba(255,255,255,0.045);
5112
+ color: var(--t-secondary);
5113
+ font-size: 12px;
5114
+ line-height: 1.4;
5115
+ }
5116
+ .update-run-status .update-backup-icon {
5117
+ flex: 0 0 auto;
5118
+ margin-top: 1px;
5119
+ }
5120
+ .update-run-status strong {
5121
+ display: block;
5122
+ color: var(--t-primary);
5123
+ font-size: 12px;
5124
+ font-weight: 600;
5125
+ margin-bottom: 2px;
5126
+ }
5127
+ .update-run-detail,
5128
+ .update-run-hint {
5129
+ display: block;
5130
+ color: var(--t-muted);
5131
+ }
5132
+ .update-run-hint {
5133
+ margin-top: 5px;
5134
+ opacity: .82;
5135
+ }
5136
+ .update-run-status code {
5137
+ font-family: var(--ff-mono);
5138
+ font-size: 11px;
5139
+ color: var(--t-secondary);
5140
+ }
5141
+ .update-backup-icon { font-size: 14px; font-weight: 700; }
5106
5142
  .update-backup-icon.spin { animation: updateSpin 1s linear infinite; }
5107
5143
  @keyframes updateSpin {
5108
5144
  from { transform: rotate(0deg); }
@@ -7,17 +7,18 @@
7
7
  import { showUpdateToast } from './ui-tool-updates.js';
8
8
 
9
9
  const $ = (id) => document.getElementById(id);
10
+ const SYNABUN_GITHUB_URL = 'https://github.com/danilokhury/Synabun';
10
11
 
11
12
  let _versionData = null;
12
13
  let _btnWired = false;
13
14
  let _pollWired = false;
14
15
 
15
16
  function sourceLabel(data) {
16
- const src = data?.installPlan?.source || data?.source;
17
- if (data?.source === 'both' && src === 'npm') return 'npm + GitHub';
17
+ const src = data?.source;
18
+ if (src === 'both') return 'npm + GitHub';
18
19
  if (src === 'github') return 'GitHub';
19
20
  if (src === 'npm') return 'npm';
20
- return data?.source === 'both' ? 'npm + GitHub' : data?.source || 'update';
21
+ return src || 'update';
21
22
  }
22
23
 
23
24
  function applyTopRightButton(data) {
@@ -187,15 +188,38 @@ function openUpdateModal() {
187
188
 
188
189
  function renderStep3() {
189
190
  const installPlan = _versionData.installPlan || {};
190
- const installCmd = installPlan.displayCommand || 'npm install -g synabun@latest';
191
- const installSource = installPlan.source === 'github'
192
- ? 'Installing from GitHub because that release is newer than npm.'
193
- : 'Installing from npm.';
191
+ const canAutoUpdate = installPlan.canAutoUpdate === true;
192
+ const repoUrl = installPlan.openUrl || SYNABUN_GITHUB_URL;
193
+ const installCmd = canAutoUpdate
194
+ ? (installPlan.displayCommand || 'npm i -g synabun@latest')
195
+ : repoUrl;
196
+ const installIntro = canAutoUpdate
197
+ ? 'SynaBun will close and run this command in a new terminal window:'
198
+ : 'This SynaBun install was not detected as an npm install. Open the GitHub repository to update from your checkout or installer:';
199
+ const title = canAutoUpdate ? 'Run the Update' : 'Update from GitHub';
200
+ const installSource = canAutoUpdate
201
+ ? 'Installing from npm.'
202
+ : (installPlan.manualHint || 'Open the SynaBun repository for the correct update path.');
203
+ const autoRestartControl = canAutoUpdate ? `
204
+ <p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:10px 0 0">
205
+ SynaBun will relaunch automatically after the update finishes.
206
+ </p>` : '';
207
+ const manualButton = canAutoUpdate ? `
208
+ <button class="action-btn action-btn--ghost" id="update-manual" title="Run the command yourself in your own terminal">Manual</button>` : '';
209
+ const updateNowButton = canAutoUpdate ? `
210
+ <button class="action-btn action-btn--primary" id="update-run-now">
211
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>
212
+ Update Now
213
+ </button>` : `
214
+ <button class="action-btn action-btn--primary" id="update-run-now" data-url="${esc(repoUrl)}">
215
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
216
+ Open GitHub
217
+ </button>`;
194
218
 
195
219
  return `
196
- <h3 class="update-modal-title">Run the Update</h3>
220
+ <h3 class="update-modal-title">${title}</h3>
197
221
  <p style="font-size:12px;color:var(--t-muted);margin:4px 0 12px">
198
- SynaBun will close and run this command in a new terminal window:
222
+ ${esc(installIntro)}
199
223
  </p>
200
224
  <div class="update-code-block">
201
225
  <code>${esc(installCmd)}</code>
@@ -203,12 +227,8 @@ function openUpdateModal() {
203
227
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
204
228
  </button>
205
229
  </div>
206
- <p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:8px 0 0">${installSource}</p>
207
-
208
- <label class="update-skip-label" style="margin:14px 0 8px;display:flex;align-items:center;gap:8px;cursor:pointer">
209
- <input type="checkbox" id="update-auto-restart" checked>
210
- <span>Relaunch SynaBun after update</span>
211
- </label>
230
+ <p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:8px 0 0">${esc(installSource)}</p>
231
+ ${autoRestartControl}
212
232
 
213
233
  <div class="update-run-status" id="update-run-status" style="display:none;margin:10px 0 0">
214
234
  <span class="update-backup-icon" id="update-run-icon"></span>
@@ -217,15 +237,12 @@ function openUpdateModal() {
217
237
 
218
238
  <div class="tag-delete-modal-actions" style="margin-top:14px;flex-wrap:wrap;gap:8px">
219
239
  <button class="action-btn action-btn--ghost" id="update-back">Back</button>
220
- <button class="action-btn action-btn--ghost" id="update-manual" title="Run the command yourself in your own terminal">Manual</button>
221
- <button class="action-btn action-btn--primary" id="update-run-now">
222
- <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>
223
- Update Now
224
- </button>
240
+ ${manualButton}
241
+ ${updateNowButton}
225
242
  </div>
226
243
 
227
244
  <p id="update-manual-note" style="display:none;font-size:11px;color:var(--t-muted);opacity:.75;margin:10px 0 0">
228
- After running the command, restart SynaBun: <code>synabun</code>
245
+ After running the command, restart SynaBun with <code>synabun</code>.
229
246
  </p>`;
230
247
  }
231
248
 
@@ -273,8 +290,8 @@ function openUpdateModal() {
273
290
  // Manual — surface the command + relaunch hint, hide auto-update controls
274
291
  overlay.querySelector('#update-manual')?.addEventListener('click', () => {
275
292
  overlay.querySelector('#update-manual-note').style.display = '';
276
- overlay.querySelector('#update-run-now').style.display = 'none';
277
- overlay.querySelector('#update-auto-restart').closest('label').style.display = 'none';
293
+ const runNow = overlay.querySelector('#update-run-now');
294
+ if (runNow) runNow.style.display = 'none';
278
295
  });
279
296
 
280
297
  // Skip checkbox
@@ -289,8 +306,12 @@ function openUpdateModal() {
289
306
  }
290
307
 
291
308
  async function handleRunUpdate() {
292
- const autoRestartCb = overlay.querySelector('#update-auto-restart');
293
- const autoRestart = !!autoRestartCb?.checked;
309
+ const installPlan = _versionData?.installPlan || {};
310
+ const canAutoUpdate = installPlan.canAutoUpdate === true;
311
+ const repoUrl = installPlan.openUrl || SYNABUN_GITHUB_URL;
312
+
313
+ // Auto-restart is always on; server forces it true regardless of payload.
314
+ const autoRestart = true;
294
315
 
295
316
  const runBtn = overlay.querySelector('#update-run-now');
296
317
  const backBtn = overlay.querySelector('#update-back');
@@ -299,14 +320,27 @@ function openUpdateModal() {
299
320
  const icon = overlay.querySelector('#update-run-icon');
300
321
  const text = overlay.querySelector('#update-run-text');
301
322
 
323
+ if (!runBtn || !backBtn || !status || !icon || !text) return;
324
+
325
+ if (!canAutoUpdate) {
326
+ window.open(repoUrl, '_blank', 'noopener,noreferrer');
327
+ status.style.display = 'flex';
328
+ icon.className = 'update-backup-icon';
329
+ icon.textContent = '↗';
330
+ icon.style.color = '#4fc3f7';
331
+ text.innerHTML = `<strong>GitHub repository opened</strong><span class="update-run-detail">Use the repository instructions to update this non-npm installation.</span>`;
332
+ return;
333
+ }
334
+
302
335
  runBtn.disabled = true;
303
336
  backBtn.disabled = true;
304
- manualBtn.disabled = true;
337
+ if (manualBtn) manualBtn.disabled = true;
305
338
  runBtn.innerHTML = '<span class="update-spinner"></span> Launching updater...';
306
339
  status.style.display = 'flex';
307
340
  icon.className = 'update-backup-icon spin';
308
341
  icon.textContent = '⟳';
309
- text.textContent = 'Opening updater terminal and shutting down server...';
342
+ icon.style.color = '';
343
+ text.innerHTML = '<strong>Launching updater</strong><span class="update-run-detail">Opening a terminal window for the npm update.</span>';
310
344
 
311
345
  try {
312
346
  const res = await fetch('/api/system/run-update', {
@@ -319,12 +353,12 @@ function openUpdateModal() {
319
353
  try { const b = await res.json(); msg = b.error || msg; } catch {}
320
354
  throw new Error(msg);
321
355
  }
322
- const body = await res.json();
356
+ await res.json();
323
357
 
324
358
  icon.className = 'update-backup-icon';
325
359
  icon.textContent = '✓';
326
360
  icon.style.color = '#34c759';
327
- text.innerHTML = `Updater terminal launched. <strong>This window will go offline.</strong> Watch the new terminal for install progress${autoRestart ? ' — SynaBun relaunches when done.' : '. Run <code>synabun</code> after install completes.'}`;
361
+ text.innerHTML = `<strong>Updater terminal launched</strong><span class="update-run-detail">SynaBun is shutting down so npm can replace the package. Watch the terminal for install progress; SynaBun will relaunch automatically when it finishes.</span><span class="update-run-hint">If no terminal window appears, run <code>${esc(installPlan.displayCommand || 'npm i -g synabun@latest')}</code> manually.</span>`;
328
362
  runBtn.innerHTML = 'Updater running…';
329
363
 
330
364
  // Server is shutting down. Optionally redirect to a friendly offline
@@ -338,7 +372,7 @@ function openUpdateModal() {
338
372
  text.textContent = 'Failed to launch updater: ' + err.message;
339
373
  runBtn.disabled = false;
340
374
  backBtn.disabled = false;
341
- manualBtn.disabled = false;
375
+ if (manualBtn) manualBtn.disabled = false;
342
376
  runBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg> Retry`;
343
377
  }
344
378
  }
@@ -1552,7 +1552,7 @@ app.put('/api/settings', (req, res) => {
1552
1552
  // leaving spawned children orphaned (opencode sidecar, agents, _sidecarProcess
1553
1553
  // CLI links, pty terminals, cloudflare tunnel). On Windows those orphans
1554
1554
  // kept file handles inside neural-interface/ open, causing EBUSY errors when
1555
- // `npm install -g synabun@latest` tried to rename the package directory.
1555
+ // `npm i -g synabun@latest` tried to rename the package directory.
1556
1556
  //
1557
1557
  // gracefulShutdown() centralizes shutdown so /api/server/restart,
1558
1558
  // /api/server/shutdown, and the new /api/system/run-update all release
@@ -1598,7 +1598,11 @@ async function gracefulShutdown(reason = 'shutdown') {
1598
1598
  // Synabun spawns CLI links, pty terminals, cloudflare tunnel etc. that may
1599
1599
  // not be reachable through the registries above. The taskkill /T flag walks
1600
1600
  // the process tree and terminates every descendant.
1601
- if (process.platform === 'win32') {
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') {
1602
1606
  try {
1603
1607
  // Use spawn(detached) so taskkill survives our own exit and finishes
1604
1608
  // killing children even if we exit before it completes.
@@ -1639,6 +1643,42 @@ function shellSingleQuote(s) {
1639
1643
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
1640
1644
  }
1641
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
+
1642
1682
  function cleanupOldUpdaterStages(baseDir) {
1643
1683
  try {
1644
1684
  if (!existsSync(baseDir)) return;
@@ -1653,7 +1693,7 @@ function cleanupOldUpdaterStages(baseDir) {
1653
1693
  } catch {}
1654
1694
  }
1655
1695
 
1656
- function stageSynabunUpdater({ installPlan, serverPid, autoRestart }) {
1696
+ function stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids = [] }) {
1657
1697
  const sourceUpdater = resolve(PACKAGE_ROOT, 'updater.mjs');
1658
1698
  if (!existsSync(sourceUpdater)) {
1659
1699
  throw new Error(`updater.mjs not found at ${sourceUpdater}. SynaBun install may be corrupt - reinstall manually: ${installPlan.displayCommand}`);
@@ -1680,18 +1720,25 @@ function stageSynabunUpdater({ installPlan, serverPid, autoRestart }) {
1680
1720
  current: installPlan.current,
1681
1721
  target: installPlan.target,
1682
1722
  channel: installPlan.channel,
1723
+ cleanupPids,
1683
1724
  };
1684
1725
  writeFileSync(payloadPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
1685
1726
 
1686
1727
  if (process.platform === 'win32') {
1728
+ const nodeBin = String(process.execPath || 'node').replace(/"/g, '');
1687
1729
  writeFileSync(runnerPath, [
1688
1730
  '@echo off',
1689
1731
  'setlocal enableextensions',
1732
+ 'chcp 65001 >nul 2>&1',
1690
1733
  'title SynaBun Updater',
1691
1734
  'cd /d "%~dp0"',
1692
1735
  'set "EXIT_CODE=0"',
1693
- 'set "NODE_BIN=node"',
1736
+ `set "NODE_BIN=${nodeBin}"`,
1694
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.',
1695
1742
  '"%NODE_BIN%" "%~dp0updater.mjs" --payload "%~dp0payload.json" --no-hold',
1696
1743
  'set "EXIT_CODE=%ERRORLEVEL%"',
1697
1744
  'echo.',
@@ -1739,41 +1786,60 @@ function stageSynabunUpdater({ installPlan, serverPid, autoRestart }) {
1739
1786
  // can lock the directory npm is trying to replace.
1740
1787
  app.post('/api/system/run-update', async (req, res) => {
1741
1788
  try {
1742
- const autoRestart = !!req.body?.autoRestart;
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;
1743
1792
 
1744
1793
  if (!_synabunUpdateCache.checkedAt) {
1745
1794
  try { await checkSynabunUpdate(); } catch {}
1746
1795
  }
1747
1796
  const installPlan = _synabunUpdateCache.installPlan;
1748
- if (!_synabunUpdateCache.updateAvailable || !installPlan?.installSpec) {
1797
+ if (!_synabunUpdateCache.updateAvailable || !installPlan) {
1749
1798
  return res.status(409).json({ error: 'No SynaBun update is currently available.' });
1750
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
+ }
1751
1810
 
1752
1811
  const plat = process.platform;
1753
1812
  const serverPid = process.pid;
1754
- const staged = stageSynabunUpdater({ installPlan, serverPid, autoRestart });
1813
+ const cleanupPids = collectWindowsDescendantPids(serverPid);
1814
+ const staged = stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids });
1755
1815
 
1756
1816
  let spawned = null;
1757
1817
  let launchError = null;
1758
1818
 
1759
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.
1760
1827
  try {
1761
- spawned = spawn('powershell.exe', [
1762
- '-NoProfile',
1763
- '-ExecutionPolicy', 'Bypass',
1764
- '-Command',
1765
- '$p=$env:SYNABUN_UPDATER_RUNNER; $w=$env:SYNABUN_UPDATER_CWD; Start-Process -FilePath $p -WorkingDirectory $w -WindowStyle Normal',
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
1766
1834
  ], {
1767
1835
  cwd: staged.stageDir,
1768
1836
  shell: false,
1769
1837
  detached: true,
1770
1838
  stdio: 'ignore',
1771
- windowsHide: true,
1839
+ windowsHide: true, // hides only the wrapper cmd.exe; new console stays visible
1772
1840
  env: {
1773
1841
  ...process.env,
1774
1842
  SYNABUN_NODE_BIN: process.execPath,
1775
- SYNABUN_UPDATER_RUNNER: staged.runnerPath,
1776
- SYNABUN_UPDATER_CWD: staged.stageDir,
1777
1843
  },
1778
1844
  });
1779
1845
  } catch (err) { launchError = err; }
@@ -2490,9 +2556,14 @@ function getClaudeBin() {
2490
2556
  // Might be a bare command in PATH — try which/where
2491
2557
  try {
2492
2558
  const envWithPath = { ...process.env, PATH: getAugmentedPath() };
2493
- const lookup = process.platform === 'win32'
2494
- ? execSync(`where "${userCmd}"`, { encoding: 'utf-8', env: envWithPath }).split('\n')[0].trim()
2495
- : 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
+ }
2496
2567
  if (lookup) { _claudeBinPath = lookup; return _claudeBinPath; }
2497
2568
  } catch {}
2498
2569
  }
@@ -2530,8 +2601,12 @@ function getClaudeBin() {
2530
2601
  // 2. Fall back to global install via which/where (with augmented PATH)
2531
2602
  const envWithPath = { ...process.env, PATH: getAugmentedPath() };
2532
2603
  if (process.platform === 'win32') {
2533
- try { _claudeBinPath = execSync('where claude', { encoding: 'utf-8', env: envWithPath }).split('\n')[0].trim(); }
2534
- 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; }
2535
2610
  } else {
2536
2611
  try { _claudeBinPath = execSync('which claude', { encoding: 'utf-8', env: envWithPath }).trim(); }
2537
2612
  catch { _claudeBinPath = null; }
@@ -4584,7 +4659,7 @@ function stopOpencodeServer() {
4584
4659
  // On Windows `process.kill(pid, 'SIGTERM')` is unreliable for non-Node
4585
4660
  // children (cmd.exe shells, opencode native binaries). Use taskkill /T /F
4586
4661
  // to walk the process tree and force-terminate every descendant.
4587
- // This is the recurring source of EBUSY errors during `npm install -g
4662
+ // This is the recurring source of EBUSY errors during `npm i -g
4588
4663
  // synabun@latest` — opencode-ai's `serve` process survives parent SynaBun
4589
4664
  // exit and keeps file handles inside neural-interface/ open.
4590
4665
  const winKill = (pid) => {
@@ -5756,6 +5831,8 @@ app.post('/api/opencode/mcp', async (req, res) => {
5756
5831
  try { persistMcpToOpencodeConfig(name, mcpConfig); } catch (e) {
5757
5832
  return res.status(500).json({ ok: false, error: `Persist failed: ${e.message}` });
5758
5833
  }
5834
+ // Force-allow the 7 SynaBun memory tools so CLI sessions skip prompts.
5835
+ injectOpenCodeMemoryPerms();
5759
5836
  if (_ocpReady) {
5760
5837
  try { await ocpProxy('POST', '/mcp', { name, config: { type: 'stdio', ...mcpConfig } }); } catch (e) {
5761
5838
  console.warn(`[opencode] Runtime register failed (config persisted): ${e.message}`);
@@ -10438,62 +10515,95 @@ let _synabunUpdateCache = {
10438
10515
  gitUpdateAvailable: false,
10439
10516
  updateAvailable: false, // npmUpdateAvailable || gitUpdateAvailable
10440
10517
  source: null, // 'npm' | 'github' | 'both' | null
10441
- installPlan: null, // server-owned npm install target for click-to-update
10518
+ installPlan: null, // server-owned npm/GitHub action target for click-to-update
10442
10519
  checkedAt: null,
10443
10520
  npmError: null,
10444
10521
  gitError: null,
10445
10522
  };
10446
10523
 
10447
- function normalizeGitHubTagForNpm(rawTag, normalizedVersion) {
10448
- const tag = String(rawTag || '').trim();
10449
- if (tag && parseSemver(tag)) return tag;
10450
- return normalizedVersion ? `v${normalizedVersion}` : null;
10451
- }
10452
-
10453
- function buildSynabunInstallPlan({
10454
- current,
10455
- installedChannel,
10456
- npmLatest,
10457
- npmLatestTag,
10458
- npmUpdateAvailable,
10459
- gitLatest,
10460
- gitLatestRaw,
10461
- gitUpdateAvailable,
10462
- }) {
10463
- if (!npmUpdateAvailable && !gitUpdateAvailable) return null;
10524
+ const SYNABUN_GITHUB_REPO_URL = 'https://github.com/danilokhury/Synabun';
10464
10525
 
10465
- let targetSource = null;
10466
- if (npmUpdateAvailable && gitUpdateAvailable) {
10467
- targetSource = compareSemver(gitLatest, npmLatest) > 0 ? 'github' : 'npm';
10468
- } else {
10469
- targetSource = gitUpdateAvailable ? 'github' : 'npm';
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' };
10470
10534
  }
10471
10535
 
10472
- if (targetSource === 'github') {
10473
- const tag = normalizeGitHubTagForNpm(gitLatestRaw, gitLatest);
10474
- if (!tag) return null;
10475
- const installSpec = `github:danilokhury/Synabun#${tag}`;
10536
+ try {
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 {}
10569
+
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.';
10476
10581
  return {
10477
10582
  source: 'github',
10583
+ installSource: installSource.kind,
10584
+ canAutoUpdate: false,
10478
10585
  current,
10479
- target: gitLatest,
10586
+ target: latest,
10480
10587
  channel: installedChannel,
10481
- installSpec,
10482
- displayCommand: `npm install -g ${installSpec}`,
10483
- githubTag: tag,
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,
10484
10593
  };
10485
10594
  }
10486
10595
 
10487
- const tag = npmLatestTag || 'latest';
10488
- const installSpec = `synabun@${tag}`;
10596
+ const installSpec = 'synabun@latest';
10489
10597
  return {
10490
10598
  source: 'npm',
10599
+ installSource: installSource.kind,
10600
+ canAutoUpdate: true,
10491
10601
  current,
10492
- target: npmLatest,
10602
+ target: latest,
10493
10603
  channel: installedChannel,
10494
10604
  installSpec,
10495
- displayCommand: `npm install -g ${installSpec}`,
10496
- npmTag: tag,
10605
+ displayCommand: 'npm i -g synabun@latest',
10606
+ npmTag: 'latest',
10497
10607
  };
10498
10608
  }
10499
10609
 
@@ -10505,7 +10615,10 @@ async function checkSynabunUpdate() {
10505
10615
  } catch { /* keep prior current if package.json read fails */ }
10506
10616
 
10507
10617
  const curParsed = parseSemver(current);
10508
- const installedChannel = curParsed?.pre ? 'prerelease' : 'stable';
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';
10509
10622
 
10510
10623
  const fetchJsonWithTimeout = async (url, ms = 5000) => {
10511
10624
  const controller = new AbortController();
@@ -10627,12 +10740,8 @@ async function checkSynabunUpdate() {
10627
10740
  const installPlan = buildSynabunInstallPlan({
10628
10741
  current,
10629
10742
  installedChannel,
10630
- npmLatest,
10631
- npmLatestTag,
10632
- npmUpdateAvailable,
10633
- gitLatest,
10634
- gitLatestRaw,
10635
- gitUpdateAvailable,
10743
+ latest,
10744
+ updateAvailable,
10636
10745
  });
10637
10746
 
10638
10747
  _synabunUpdateCache = {
@@ -10704,25 +10813,36 @@ function parseVersion(raw) {
10704
10813
  }
10705
10814
 
10706
10815
  // Structured semver parse — handles calver bumps and prerelease tags:
10707
- // "2026.4.26" -> { major:2026, minor:4, patch:26, pre:null }
10708
- // "2026.4.20-2" -> { major:2026, minor:4, patch:20, pre:"2" }
10709
- // "2026.4.26-beta.3" -> { major:2026, minor:4, patch:26, pre:"beta.3" }
10710
- // "v1.2.3-rc.1" -> { major:1, minor:2, patch:3, pre:"rc.1" }
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.
10711
10826
  function parseSemver(raw) {
10712
10827
  if (raw == null) return null;
10713
10828
  const s = String(raw).trim().replace(/^v\.?/, '');
10714
10829
  const m = s.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
10715
10830
  if (!m) return null;
10831
+ const pre = m[4] || null;
10832
+ const iter = pre && /^\d+$/.test(pre) ? Number(pre) : null;
10716
10833
  return {
10717
10834
  major: +m[1],
10718
10835
  minor: +m[2],
10719
10836
  patch: +m[3],
10720
- pre: m[4] || null,
10837
+ pre,
10838
+ iter,
10721
10839
  };
10722
10840
  }
10723
10841
 
10724
- // Semver-spec precedence including prerelease handling:
10725
- // 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0
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).
10726
10846
  // Returns negative if a<b, positive if a>b, 0 if equal. Accepts strings or
10727
10847
  // parsed objects.
10728
10848
  function compareSemver(a, b) {
@@ -10734,11 +10854,19 @@ function compareSemver(a, b) {
10734
10854
  if (pa.major !== pb.major) return pa.major - pb.major;
10735
10855
  if (pa.minor !== pb.minor) return pa.minor - pb.minor;
10736
10856
  if (pa.patch !== pb.patch) return pa.patch - pb.patch;
10737
- // Equal x.y.z. Prerelease vs none: a release without prerelease is GREATER.
10857
+ // Equal x.y.z. Order from oldest → newest:
10858
+ // alpha-prerelease < no-suffix release < numeric iteration
10738
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.
10739
10867
  if (!pa.pre && pb.pre) return 1;
10740
10868
  if ( pa.pre && !pb.pre) return -1;
10741
- // Both have prerelease — compare identifiers per semver §11.4.
10869
+ // Both have alpha-flavoured prerelease — compare identifiers per semver §11.4.
10742
10870
  const ia = pa.pre.split('.');
10743
10871
  const ib = pb.pre.split('.');
10744
10872
  const len = Math.max(ia.length, ib.length);
@@ -10791,6 +10919,7 @@ function detectInstallSource(binPath) {
10791
10919
  if (/\/node_modules\//.test(p) || /\/npm\/node_modules\//.test(p)) return 'npm';
10792
10920
  if (p.includes('/opt/homebrew/') || p.includes('/usr/local/cellar/') || /\/homebrew\//.test(p)) return 'brew';
10793
10921
  if (p.includes('/.claude/local/') || p.includes('/anthropicclaude/')) return 'native';
10922
+ if (/\/\.local\/bin\//.test(p)) return 'native';
10794
10923
  if (/\/program files( \(x86\))?\//.test(p) || /\/localappdata\/.*anthropicclaude/.test(p)) return 'native';
10795
10924
  if (p.includes('/winget/') || p.includes('/microsoft/winget/')) return 'winget';
10796
10925
  if (/\/usr\/local\/lib\/node_modules\//.test(p)) return 'npm';
@@ -10919,15 +11048,19 @@ async function checkToolVersions() {
10919
11048
  }
10920
11049
 
10921
11050
  // Nuclear safety net for the recurring claude phantom-version bug:
10922
- // if the probe returned a known historical bundled version of claude-code,
10923
- // reject it even if the binary-path detection failed. A real user-global
10924
- // install of claude is always current (2.1.116+), so seeing 2.1.89 etc.
10925
- // means we're reading a stale bundle SynaBun shipped in a prior release.
10926
- 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
+ ) {
10927
11061
  console.warn(
10928
- ` [version-guard] Rejected claude v${installed} from ${probedBinPath || '(unknown path)'}` +
10929
- ` — matches known-stale SynaBun bundle. Treating as not installed. ` +
10930
- `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.`
10931
11064
  );
10932
11065
  installed = null;
10933
11066
  }
@@ -12767,6 +12900,89 @@ const SYNABUN_TOOL_CATEGORIES = [
12767
12900
  },
12768
12901
  ];
12769
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
+
12770
12986
  function addHookToSettings(settings, onlyEvent, targetProjectPath) {
12771
12987
  if (!settings) settings = {};
12772
12988
  if (!settings.hooks) settings.hooks = {};
@@ -14624,11 +14840,12 @@ app.post('/api/claude-code/mcp', (req, res) => {
14624
14840
  };
14625
14841
  writeFileSync(claudeJsonPath, JSON.stringify(data, null, 2), 'utf-8');
14626
14842
 
14627
- // Also inject tool permissions into global settings so tools work without prompts
14628
- const globalSettingsPath = getGlobalClaudeSettingsPath();
14629
- let globalSettings = readClaudeSettings(globalSettingsPath) || {};
14630
- ensureSynaBunPermissions(globalSettings);
14631
- 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(', ')}`);
14632
14849
 
14633
14850
  res.json({ ok: true, message: 'SynaBun MCP registered (HTTP). Restart Claude Code to connect.' });
14634
14851
  } catch (err) {
@@ -14864,6 +15081,9 @@ app.post('/api/setup/gemini/mcp', (req, res) => {
14864
15081
  command: 'node',
14865
15082
  args: [mcpIndexPath],
14866
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,
14867
15087
  };
14868
15088
  writeFileSync(settingsPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
14869
15089
  res.json({ ok: true, message: 'SynaBun MCP registered in Gemini CLI. Restart Gemini to connect.' });
@@ -14917,6 +15137,8 @@ app.post('/api/setup/codex/mcp', (req, res) => {
14917
15137
  env: buildCodexMcpEnv(),
14918
15138
  });
14919
15139
  writeFileSync(configPath, content, 'utf-8');
15140
+ // Force-allow the 7 SynaBun memory tools so CLI sessions skip prompts.
15141
+ injectCodexMemoryPerms();
14920
15142
  res.json({ ok: true, message: 'SynaBun MCP registered in Codex CLI. Restart Codex to connect.' });
14921
15143
  } catch (err) {
14922
15144
  res.status(500).json({ error: err.message });
@@ -23770,7 +23992,7 @@ setInterval(saveSessionSnapshot, SESSION_SNAPSHOT_INTERVAL_MS);
23770
23992
  // Manual termination paths (Ctrl+C, kill, parent shell exit) all flow through
23771
23993
  // gracefulShutdown — same teardown the in-app restart/shutdown/update paths
23772
23994
  // use. Without this, hand-killed servers still leave orphaned opencode +
23773
- // agent children holding file handles, which blocks `npm install -g synabun`.
23995
+ // agent children holding file handles, which blocks `npm i -g synabun`.
23774
23996
  process.on('SIGINT', async () => {
23775
23997
  console.log('\n[server] SIGINT received.');
23776
23998
  await gracefulShutdown('sigint');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synabun",
3
- "version": "2026.4.26-beta.9",
3
+ "version": "2026.4.27",
4
4
  "description": "Persistent vector memory for AI assistants",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The SynaBun Authors",
package/preuninstall.js CHANGED
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * SynaBun — NPM preuninstall script
5
5
  *
6
- * Runs before `npm uninstall synabun` AND before `npm install -g synabun@x`
6
+ * Runs before `npm uninstall synabun` AND before `npm i -g synabun@x`
7
7
  * overwrites the existing install.
8
8
  *
9
9
  * 1. Politely ask any running SynaBun server to shut down so its child
package/updater.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // Normal flow:
5
5
  // 1. The running server copies this file into a user-data staging directory.
6
6
  // 2. The staged runner calls it with --payload payload.json.
7
- // 3. This process waits for the old server to exit, then runs npm install -g.
7
+ // 3. This process waits for the old server to exit, then runs npm i -g.
8
8
  //
9
9
  // Keeping the updater outside the installed package is intentional. On Windows,
10
10
  // running from node_modules/synabun can hold locks on the directory npm needs to
@@ -18,8 +18,9 @@ const payload = loadPayload(args.payload);
18
18
 
19
19
  const legacyTag = sanitizeTag(args.tag || 'latest');
20
20
  const installSpec = sanitizeInstallSpec(payload.installSpec || args['install-spec'] || `synabun@${legacyTag}`);
21
- const displayCommand = payload.displayCommand || `npm install -g ${installSpec}`;
21
+ const displayCommand = payload.displayCommand || `npm i -g ${installSpec}`;
22
22
  const serverPid = toPid(payload.serverPid ?? args.pid);
23
+ const cleanupPids = sanitizePidList(payload.cleanupPids).filter(pid => pid !== serverPid && pid !== process.pid);
23
24
  const autoRestart = payload.autoRestart != null ? !!payload.autoRestart : !!args.restart;
24
25
  const safeCwd = payload.cwd || process.cwd();
25
26
  const noHold = !!args['no-hold'];
@@ -46,8 +47,8 @@ try {
46
47
  if (exited) {
47
48
  console.log(' Server exited.\n');
48
49
  } else {
49
- console.warn(' Server still alive after 30s - forcing kill.\n');
50
- forceKillTree(serverPid);
50
+ console.warn(' Server still alive after 30s - forcing server process kill.\n');
51
+ forceKillPid(serverPid);
51
52
  await sleep(1500);
52
53
  }
53
54
  } else {
@@ -55,11 +56,19 @@ try {
55
56
  }
56
57
 
57
58
  await sleep(1500);
58
- if (isWin && serverPid) forceKillTree(serverPid);
59
+ if (cleanupPids.length) {
60
+ console.log(`Cleaning up ${cleanupPids.length} pre-existing child process${cleanupPids.length === 1 ? '' : 'es'}...`);
61
+ for (const pid of cleanupPids) {
62
+ if (!isPidAlive(pid)) continue;
63
+ forceKillTree(pid);
64
+ }
65
+ await sleep(1000);
66
+ console.log(' Cleanup complete.\n');
67
+ }
59
68
 
60
69
  console.log(`Running: ${displayCommand}\n`);
61
70
  console.log(HR);
62
- const installRes = spawnSync(NPM_BIN, ['install', '-g', installSpec], {
71
+ const installRes = spawnSync(NPM_BIN, ['i', '-g', installSpec], {
63
72
  cwd: safeCwd,
64
73
  stdio: 'inherit',
65
74
  shell: isWin,
@@ -68,7 +77,7 @@ try {
68
77
 
69
78
  if (installRes.error) throw installRes.error;
70
79
  if (installRes.status !== 0) {
71
- console.error(`\nnpm install failed with exit code ${installRes.status}.\n`);
80
+ console.error(`\nnpm update command failed with exit code ${installRes.status}.\n`);
72
81
  printFixTips(displayCommand);
73
82
  await maybeHoldOpen(noHold);
74
83
  process.exit(installRes.status || 1);
@@ -135,8 +144,6 @@ function sanitizeInstallSpec(raw) {
135
144
  const spec = String(raw || '').trim();
136
145
  if (/^synabun@(latest|beta|next|rc|alpha)$/.test(spec)) return spec;
137
146
  if (/^synabun@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) return spec;
138
- if (/^github:danilokhury\/Synabun#[vV]\.?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) return spec;
139
- if (/^github:danilokhury\/Synabun#\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) return spec;
140
147
  return null;
141
148
  }
142
149
 
@@ -145,6 +152,19 @@ function toPid(raw) {
145
152
  return Number.isFinite(pid) && pid > 0 ? pid : null;
146
153
  }
147
154
 
155
+ function sanitizePidList(raw) {
156
+ if (!Array.isArray(raw)) return [];
157
+ const out = [];
158
+ const seen = new Set();
159
+ for (const item of raw) {
160
+ const pid = toPid(item);
161
+ if (!pid || seen.has(pid)) continue;
162
+ seen.add(pid);
163
+ out.push(pid);
164
+ }
165
+ return out;
166
+ }
167
+
148
168
  async function waitForExit(pid, timeoutMs) {
149
169
  const deadline = Date.now() + timeoutMs;
150
170
  while (Date.now() < deadline) {
@@ -178,6 +198,20 @@ function forceKillTree(pid) {
178
198
  } catch {}
179
199
  }
180
200
 
201
+ function forceKillPid(pid) {
202
+ try {
203
+ if (isWin) {
204
+ spawnSync('taskkill', ['/pid', String(pid), '/F'], {
205
+ shell: true,
206
+ windowsHide: true,
207
+ stdio: 'ignore',
208
+ });
209
+ } else {
210
+ try { process.kill(pid, 'SIGKILL'); } catch {}
211
+ }
212
+ } catch {}
213
+ }
214
+
181
215
  function printFixTips(command) {
182
216
  console.error('Common fixes:');
183
217
  if (isWin) {