From 29107569100754ee8dec2bd25b377c3f65515d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20CHEN?= Date: Sun, 25 Oct 2026 22:20:29 +0800 Subject: [PATCH] feat: launch the backend silently at startup, drop the visible startup tab The startup health-check launch now takes the exact path recovery launches have used since 4.1.0: resolve the newest dist/cli.mjs and spawn node directly on it - no wt.exe, no terminal tab, no focus steal, ever. The 4.2.0 rollback to a visible startup tab was triggered by console flashes at OpenCode startup that are actually caused by OpenCode's own local-MCP spawns (cmd.exe /d /s /c npx ... allocates a console per server, and every OpenCode server instance spawns the whole local fleet). Live process-tree audits show the plugin's spawned tree (node -> cli.mjs -> iii.exe) carries no cmd.exe/conhost at all. Startup launches still do not count toward the failure backoff, and launch diagnostics stay in the plugin log (warn) plus agentmemory doctor. --- AGENTS.md | 2 +- CHANGELOG.md | 5 +++ README.md | 9 +++--- src/agentmemory-launcher.ts | 61 +++++++++++-------------------------- 4 files changed, 28 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 513397d..b47b6e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ src/agentmemory-launcher.ts # Plugin entry (V2 module) Flow: on plugin load (`setup()`) → `GET /agentmemory/livez` (2s timeout) → spawns `npx @agentmemory/agentmemory` if down → 60s supervision loop → cleanup function stops the loop on unload. -Windows launch path (two-mode): a launch triggered by the startup health check opens a visible Windows Terminal tab (`wt -w 0 nt --title agentmemory --suppressApplicationTitle node ` — WT launches `node` itself, no cmd/pwsh shell hop) — focus is taken once at startup, which is acceptable. Every launch from the 60s supervision loop is silent: plain `windowsHide` is not enough there (it hides only the direct child, and every cmd.exe hop — `shell: true`, npx's `.cmd` shim — lets a grandchild allocate a NEW console, which Windows Terminal shows as a focus-stealing tab), so recovery resolves `dist/cli.mjs` from the **newest** agentmemory it can find — the npm/npx cache (cache dir discovered via `npm_config_cache` → `~/.npmrc` `cache=` → `%LOCALAPPDATA%/npm-cache`, plus a `../cache/_npx` probe beside each PATH entry for portable installs like scoop) or a global install on `PATH` (`/node_modules/@agentmemory/agentmemory` and `/lib/node_modules/@agentmemory/agentmemory`), highest version wins — and spawns `node` directly on it: the tree (node → cli.mjs → iii.exe) contains no cmd.exe and never allocates a console (the CLI spawns iii-engine with `windowsHide` itself). Falls back to the legacy npx spawn when no candidate is found or the direct spawn errors. Relaunches are gated by a 90s boot grace window plus a cross-instance self-expiring lock (`%TEMP%/agentmemory-launcher.lock`) because every OpenCode server runs its own plugin instance against the same backend. +Windows launch path (always silent): every launch — startup and 60s-supervision recovery alike — bypasses npx/cmd and spawns `node` directly on `dist/cli.mjs` resolved from the **newest** agentmemory it can find (the npm/npx cache discovered via `npm_config_cache` → `~/.npmrc` `cache=` → `%LOCALAPPDATA%/npm-cache`, plus a `../cache/_npx` probe beside each PATH entry for portable installs like scoop, or a global install on `PATH` at `/node_modules/@agentmemory/agentmemory` / `/lib/node_modules/@agentmemory/agentmemory`, highest version wins). The tree (node → cli.mjs → iii.exe) contains no cmd.exe and never allocates a console (the CLI spawns iii-engine with `windowsHide` itself; live process-tree audits show no cmd.exe/conhost under the spawned backend). Falls back to the legacy npx spawn only when no candidate is found or the direct spawn errors. Plain `windowsHide` is not enough for the npx path: it hides only the direct child, and every cmd.exe hop — `shell: true`, npx's `.cmd` shim — lets a grandchild allocate a NEW console, which Windows Terminal shows as a focus-stealing window. Relaunches are gated by a 90s boot grace window plus a cross-instance self-expiring lock (`%TEMP%/agentmemory-launcher.lock`) because every OpenCode server runs its own plugin instance against the same backend. ## Agentmemory Backend (Critical Knowledge) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2a2b5e..14ba6c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to opencode-agentmemory-launcher will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Changed +- **Windows launch is now always silent — the startup tab is gone.** A launch triggered by the startup health check (backend down when the plugin loads) now takes the exact path recovery launches have used since 4.1.0: resolve the newest `dist/cli.mjs` and spawn `node` directly on it, so no window, terminal tab, or focus steal ever appears at startup either. The earlier rollback to a visible startup tab (4.2.0) was triggered by console flashes at OpenCode startup that are actually caused by OpenCode's own local-MCP spawns (`cmd.exe /d /s /c npx …` allocates a console per server, and each OpenCode server instance spawns the whole local fleet) — not by this plugin, whose spawned tree (node → cli.mjs → iii.exe) carries no cmd.exe/conhost at all (verified by live process-tree audits). Diagnostics for a failed launch remain in the plugin log (`warn`) plus `agentmemory doctor`. + ## [4.2.1] - 2026-10-07 ### Changed diff --git a/README.md b/README.md index b233ab8..5e62ea0 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,10 @@ Restart OpenCode to relaunch agentmemory with the updated version. 1. **On load** (`setup()`): the plugin starts a health-check interval (60s) 2. **Health check**: Pings `GET /agentmemory/livez` on the backend (always public, no auth — even with agentmemory 0.9.30's auth-by-default) -3. **Auto-restart (two-mode)**: If the health check fails, the backend is launched. - - **Startup** (backend down when the plugin loads): opens a visible Windows Terminal tab titled `agentmemory` (`wt -w 0 nt --title agentmemory --suppressApplicationTitle node `) — Windows Terminal launches `node` itself, so no cmd/pwsh shell is involved. The backend coming up is visible; focus is taken once here — acceptable at startup, and never on the recovery path. Falls back to the silent path when `wt.exe` is unavailable. - - **Recovery** (every later (re)launch from the 60s loop): fully silent. On Windows the plugin bypasses npx/cmd entirely — it resolves the **newest** `dist/cli.mjs` it can find in the npm/npx cache or a global install on `PATH`, and spawns `node` directly on it, so the process tree (node → cli.mjs → iii.exe) never touches cmd.exe and never allocates a console: no window/tab and no focus steal, mid-session or while idle (plain `windowsHide` is insufficient because every cmd.exe hop lets a grandchild allocate a new console). Falls back to an npx spawn when no local copy is found. Relaunches are throttled by a 90s boot grace window plus a cross-instance launch lock (multiple OpenCode servers share one backend) +3. **Auto-restart**: If the health check fails, the backend is launched. + - **Always silent** (startup and every later (re)launch from the 60s loop): on Windows the plugin bypasses npx/cmd entirely — it resolves the **newest** `dist/cli.mjs` it can find in the npm/npx cache or a global install on `PATH`, and spawns `node` directly on it, so the process tree (node → cli.mjs → iii.exe) never touches cmd.exe and never allocates a console: no window/tab and no focus steal, at startup, mid-session or while idle (plain `windowsHide` is insufficient because every cmd.exe hop lets a grandchild allocate a new console). Falls back to an npx spawn when no local copy is found. Relaunches are throttled by a 90s boot grace window plus a cross-instance launch lock (multiple OpenCode servers share one backend); a startup launch does not count toward the failure backoff, so a failed startup is retried ~90s later 4. **Debug mode**: Set `OPENCODE_AGENTMEMORY_DEBUG=1` for verbose logging -5. **Failure diagnostics**: A backend launch that exits with a non-zero code is logged at `warn` with the likely causes — a daemon already answering on the port (agentmemory 0.9.30 refuses to start a second instance) or the enforced iii-engine pin mismatch. A visible startup tab whose process exits with a non-zero code stays open (Windows Terminal's `closeOnExit: graceful`) so the error text remains readable +5. **Failure diagnostics**: A backend launch that exits with a non-zero code is logged at `warn` with the likely causes — a daemon already answering on the port (agentmemory 0.9.30 refuses to start a second instance) or the enforced iii-engine pin mismatch. The backend runs with `stdio: "ignore"`, so for the actual error text run `npx @agentmemory/agentmemory doctor` ## Environment Variables @@ -126,7 +125,7 @@ Two common causes: agentmemory 0.9.30 enforces its iii-engine pin (v0.22.1), so npx @agentmemory/agentmemory doctor ``` -**A visible startup tab shows `agentmemory worker did not become ready within 15s` and exits with code 1.** +**The launcher log shows `agentmemory worker did not become ready within 15s` and the launch exits with code 1.** A stale instance from an earlier session is still holding the REST port, so the fresh CLI starts its engine but its worker registration never becomes ready — a healthy fresh start completes in ~2s. Diagnose and restart the whole chain (never kill a single worker process: it may be the active route handler, and killing it makes `/agentmemory/livez` return 404): ```powershell diff --git a/src/agentmemory-launcher.ts b/src/agentmemory-launcher.ts index ab83178..8b8939d 100644 --- a/src/agentmemory-launcher.ts +++ b/src/agentmemory-launcher.ts @@ -9,18 +9,18 @@ import { delimiter, join } from "node:path"; // Uses /agentmemory/livez (always public, no auth) for health checks. // Health-checks every 60s and restarts the backend if it is down. // -// Windows launch is two-mode: -// • startup — the backend is down when the plugin loads, so open a visible -// Windows Terminal tab titled "agentmemory" for it. Taking focus once at -// startup is acceptable: the user sees the backend come up. -// • recovery — every later (re)launch from the 60s supervision loop must be -// invisible. `windowsHide` alone is NOT enough there: it only suppresses -// the direct child, and every hop through cmd.exe (npx's shim, -// `shell: true`) lets a grandchild allocate a NEW console, which Windows -// Terminal surfaces as a focus-stealing tab. So recovery bypasses npx/cmd -// and spawns `node` directly on the resolved CLI entry; the tree -// (node → cli.mjs → iii.exe) never touches cmd.exe and never owns a -// console (the CLI spawns iii-engine with `windowsHide` itself). +// Windows launch is always silent — no window, no tab, no focus steal, at +// startup and on recovery alike. `windowsHide` alone is NOT enough: it only +// suppresses the direct child, and every hop through cmd.exe (npx's shim, +// `shell: true`) lets a grandchild allocate a NEW console, which Windows +// Terminal surfaces as a focus-stealing window. So every launch bypasses +// npx/cmd and spawns `node` directly on the resolved CLI entry; the tree +// (node → cli.mjs → iii.exe) never touches cmd.exe and never owns a console +// (the CLI spawns iii-engine with `windowsHide` itself). Live process-tree +// audits confirm the spawned backend carries no cmd.exe/conhost at all, +// while OpenCode's own local-MCP spawns (`cmd.exe /d /s /c npx …`) each +// allocate a console — console flashes at OpenCode startup come from those +// MCP spawns, not from this plugin. // // OpenCode V2 plugin: the host loads the default export `{ id, setup }`. // Supervision starts in `setup()` (V2 has no `config` hook) and the returned @@ -54,12 +54,13 @@ let lastLaunchAt = 0; let launchAttempts = 0; let timer: ReturnType | null = null; -/** Startup launches open a visible tab; recovery launches are silent. */ +/** + * Why a launch is happening. Startup launches (backend down when the plugin + * loads) do not count toward the failure backoff, so a failed startup is + * retried ~90s later rather than 180s. Both modes spawn identically silent. + */ type LaunchReason = "startup" | "recovery"; -/** Title of the visible startup tab (kept stable via --suppressApplicationTitle). */ -const TAB_TITLE = "agentmemory"; - type Level = "info" | "warn" | "error" | "debug"; // V2 has no `client.app.log` equivalent — warn/error go to stderr @@ -271,27 +272,6 @@ function spawnSilent(cli: string, env: NodeJS.ProcessEnv, label: string): void { void log("info", `launching backend silently via ${label}`); } -/** - * Startup launch: open a visible Windows Terminal tab running the backend. - * No shell is involved — Windows Terminal launches `node` itself, so the - * visible path is as shell-free as the silent one (no cmd/pwsh hop). - * Focus is taken once — acceptable at startup, never used on the recovery path. - * Degrades to the silent launch when wt.exe is unavailable. - */ -function spawnVisibleTab(cli: string, env: NodeJS.ProcessEnv, label: string): void { - const child = spawn( - "wt.exe", - ["-w", "0", "nt", "--title", TAB_TITLE, "--suppressApplicationTitle", "node", cli], - { detached: true, stdio: "ignore", windowsHide: true, env }, - ); - child.on("error", (err) => { - void log("warn", `visible tab spawn failed (${err.message}); falling back to a silent launch`); - spawnSilent(cli, env, label); - }); - watch(child, `visible tab → ${label}`); - void log("info", `launching backend in a visible tab via ${label}`); -} - function launch(reason: LaunchReason): void { const now = Date.now(); // Exponential backoff across consecutive FAILED attempts (90s, 180s, …, @@ -310,12 +290,7 @@ function launch(reason: LaunchReason): void { const resolved = process.platform === "win32" ? resolveCli() : null; if (resolved) { - const label = `${resolved.version} (${resolved.path})`; - if (process.platform === "win32" && reason === "startup") { - spawnVisibleTab(resolved.path, env, label); - } else { - spawnSilent(resolved.path, env, label); - } + spawnSilent(resolved.path, env, `${resolved.version} (${resolved.path})`); return; }