From 88b57e56bb263be19340e2a549b5bfb75f011883 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 15 May 2026 15:51:03 +0100 Subject: [PATCH 1/5] fix(cli): auto-bump viewer port when 3113 is taken The viewer used to log 'Viewer port 3113 already in use, skipping viewer.' and silently fail. On every fresh terminal you'd lose the viewer for the rest of the session unless you noticed the warning buried in stdout. Retry the next 10 ports (3113 -> 3122) before giving up. On fallback, log: 'Viewer started on http://localhost:3114 (fallback from 3113)'. --- src/viewer/server.ts | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/viewer/server.ts b/src/viewer/server.ts index 1b63d132..533d4bb4 100644 --- a/src/viewer/server.ts +++ b/src/viewer/server.ts @@ -58,6 +58,8 @@ function readBody(req: IncomingMessage): Promise { }); } +const MAX_VIEWER_PORT_RETRIES = 10; + export function startViewerServer( port: number, _kv: unknown, @@ -66,6 +68,7 @@ export function startViewerServer( restPort?: number, ): Server { const resolvedRestPort = restPort ?? port - 2; + const requestedPort = port; const server = createServer(async (req, res) => { const raw = req.url || "/"; @@ -112,17 +115,40 @@ export function startViewerServer( } }); + let attempt = 0; + let currentPort = requestedPort; + + const tryListen = (): void => { + server.listen(currentPort, "127.0.0.1"); + }; + + server.on("listening", () => { + if (currentPort === requestedPort) { + console.log(`[agentmemory] Viewer: http://localhost:${currentPort}`); + } else { + console.log( + `[agentmemory] Viewer started on http://localhost:${currentPort} (fallback from ${requestedPort})`, + ); + } + }); + server.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE" && attempt < MAX_VIEWER_PORT_RETRIES) { + attempt++; + currentPort = requestedPort + attempt; + setImmediate(tryListen); + return; + } if (err.code === "EADDRINUSE") { - console.warn(`[agentmemory] Viewer port ${port} already in use, skipping viewer.`); + console.warn( + `[agentmemory] Viewer ports ${requestedPort}-${requestedPort + MAX_VIEWER_PORT_RETRIES} all in use, skipping viewer.`, + ); } else { console.error(`[agentmemory] Viewer error:`, err.message); } }); - server.listen(port, "127.0.0.1", () => { - console.log(`[agentmemory] Viewer: http://localhost:${port}`); - }); + tryListen(); return server; } From 1f5e77488bb1b7497baf256f5051fe22af71fe1f Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 15 May 2026 15:51:53 +0100 Subject: [PATCH 2/5] fix(cli): warn when iii-engine on PATH is not the pinned version agentmemory pins iii-engine to v0.11.2 (see IIPINNED_VERSION in src/cli.ts) but anyone with a different iii on PATH gets silent behavioral drift -- EPIPE reconnect loops, empty search after save -- because we never tell them their engine doesn't match. Add warnIfEngineVersionMismatch() and fire it from both: - startIiiBin() before we spawn (covers fresh starts) - main() early-return when isEngineRunning() returns true (covers attach-to-existing) The warn tells the operator how to silence (AGENTMEMORY_III_VERSION) and how to downgrade (curl | tar). --- src/cli.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 8f7e0d1a..b0afd2c3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -239,6 +239,21 @@ function iiiBinVersion(binPath: string): string | null { } } +let warnedVersionMismatch = false; +function warnIfEngineVersionMismatch(iiiBinPath: string | null | undefined): void { + if (!iiiBinPath || warnedVersionMismatch) return; + const detected = iiiBinVersion(iiiBinPath); + if (!detected || detected === IIPINNED_VERSION) return; + warnedVersionMismatch = true; + const asset = iiiReleaseAsset(); + const downloadHint = asset + ? `curl -fsSL https://github.com/iii-hq/iii/releases/download/iii/v${IIPINNED_VERSION}/${asset} | tar -xz -C ~/.local/bin` + : `download v${IIPINNED_VERSION} from https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}`; + p.log.warn( + `iii-engine on PATH is v${detected} but agentmemory v0.9.14+ pins v${IIPINNED_VERSION}. Set AGENTMEMORY_III_VERSION=${detected} to silence, or downgrade with: \`${downloadHint}\``, + ); +} + function enginePidfilePath(): string { return join(homedir(), ".agentmemory", "iii.pid"); } @@ -427,6 +442,7 @@ function spawnEngineBackground( } function startIiiBin(iiiBin: string, configPath: string): boolean { + warnIfEngineVersionMismatch(iiiBin); const s = p.spinner(); s.start(`Starting iii-engine: ${iiiBin}`); writeEngineState({ kind: "native", configPath }); @@ -622,6 +638,9 @@ async function main() { if (await isEngineRunning()) { p.log.success("iii-engine is running"); + const attachedBin = + whichBinary("iii") ?? fallbackIiiPaths().find((p) => existsSync(p)) ?? null; + warnIfEngineVersionMismatch(attachedBin); await import("./index.js"); return; } From 28440d657e28aebb558536f26dc54255764e9135 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 15 May 2026 15:52:42 +0100 Subject: [PATCH 3/5] fix(cli): add stop --force to bypass Docker-heuristic guard runStop() correctly refuses to signal host PIDs when state file is missing and a docker-compose.yml is discoverable -- the engine might have been started by another shell via Docker compose, and SIGTERMing the lsof match would kill an unrelated host process. But when the engine actually WAS started natively (e.g. by a stale agentmemory v0.9.13 with no state-writing) and the state file is gone, the guard becomes a foot-gun: stop refuses, the engine stays up, and the operator has no escape hatch short of `lsof | kill`. Add --force. Documented in --help. The non-force path remains the default and still refuses. --- src/cli.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index b0afd2c3..bb0779bf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -87,7 +87,10 @@ Commands: doctor Run diagnostic checks (server, flags, graph, providers) demo Seed sample sessions and show recall in action upgrade Upgrade local deps + iii runtime (best effort) - stop Stop the running iii-engine started by this CLI + stop [--force] Stop the running iii-engine started by this CLI. + --force bypasses the Docker-heuristic guard and signals + whatever pidfile+lsof report on the REST port (use when + the engine was started natively but state file is missing). mcp Start standalone MCP server (no engine required) import-jsonl [p] Import Claude Code JSONL transcripts (default: ~/.claude/projects) --max-files | --max-files=: override scan cap (default 200, max 1000; @@ -1480,6 +1483,7 @@ async function runStop(): Promise { const port = getRestPort(); const state = readEngineState(); const running = await isEngineRunning(); + const force = args.includes("--force"); if (state?.kind === "docker") { if (!running) { @@ -1517,10 +1521,16 @@ async function runStop(): Promise { if (!state) { const compose = discoverComposeFile(); if (compose && pidfilePid === null) { - p.log.error( - `Engine is running on :${port} but no pidfile or state file is present. It may have been started via Docker compose by a different shell. Refusing to signal host PIDs.\n\nStop it with:\n docker compose -f ${compose} down\n\nOr re-run with AGENTMEMORY_USE_DOCKER=1 to record state next time.`, - ); - process.exit(1); + if (force) { + p.log.warn( + `--force: bypassing Docker-heuristic guard. Falling back to native pidfile + lsof on :${port}.`, + ); + } else { + p.log.error( + `Engine is running on :${port} but no pidfile or state file is present. It may have been started via Docker compose by a different shell. Refusing to signal host PIDs.\n\nStop it with:\n docker compose -f ${compose} down\n\nOr re-run with --force to signal whatever lsof finds on :${port}, or AGENTMEMORY_USE_DOCKER=1 to record state next time.`, + ); + process.exit(1); + } } } From 898052c56cfc5a2155da64b1b3de094c1da45108 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 15 May 2026 15:53:19 +0100 Subject: [PATCH 4/5] fix(cli): adopt running engine on attach (write pidfile + state) When main() finds isEngineRunning() already true (e.g. a previous shell started it), we early-return without writing iii.pid or engine-state.json. Later `agentmemory stop` then hits the Docker-heuristic guard (state missing + compose discoverable) and refuses to act -- correct in principle, foot-gun in practice. Adopt on attach: write iii.pid from lsof-on-port and engine-state.json as kind=native attached=true, but only if neither already exists. Idempotent -- safe to re-run. Log: 'Attached to existing iii-engine (pid 12345)'. --- src/cli.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index bb0779bf..69862fd2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -266,7 +266,7 @@ function engineStatePath(): string { } type EngineState = - | { kind: "native"; configPath: string } + | { kind: "native"; configPath: string; attached?: boolean } | { kind: "docker"; composeFile: string }; function writeEnginePidfile(pid: number): void { @@ -333,6 +333,32 @@ function discoverComposeFile(): string | null { return candidates.find((c) => existsSync(c)) ?? null; } +function adoptRunningEngine(): void { + try { + const existingState = readEngineState(); + const existingPid = readEnginePidfile(); + if (existingState && existingPid) return; + + const pids = findEnginePidsByPort(getRestPort()); + const enginePid = pids[0]; + if (enginePid && !existingPid) { + writeEnginePidfile(enginePid); + } + if (!existingState) { + writeEngineState({ + kind: "native", + configPath: findIiiConfig() || "", + attached: true, + }); + } + if (enginePid && !existingPid) { + p.log.info(`Attached to existing iii-engine (pid ${enginePid})`); + } + } catch (err) { + vlog(`adoptRunningEngine: ${err instanceof Error ? err.message : String(err)}`); + } +} + async function runIiiInstaller(): Promise<{ ok: boolean; binPath: string | null }> { const releaseUrl = iiiReleaseUrl(); const asset = iiiReleaseAsset(); @@ -644,6 +670,7 @@ async function main() { const attachedBin = whichBinary("iii") ?? fallbackIiiPaths().find((p) => existsSync(p)) ?? null; warnIfEngineVersionMismatch(attachedBin); + adoptRunningEngine(); await import("./index.js"); return; } From 5bb65fe930928401360dd83abc36710baba33dc0 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 15 May 2026 15:56:26 +0100 Subject: [PATCH 5/5] fix(cli): hint that global install drops the `npx` prefix After the engine is ready, if invoked via npx (detected via npm_lifecycle_event / process.argv[1] containing _npx / npm_config_user_agent starting with npm/), emit one line: Tip: install globally for the bare `agentmemory` command: npm install -g @agentmemory/agentmemory Suppressed by ~/.agentmemory/preferences.json with skipNpxHint:true (a sibling PR will write that file when the user opts out). --- src/cli.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index 69862fd2..8102ce6b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -333,6 +333,35 @@ function discoverComposeFile(): string | null { return candidates.find((c) => existsSync(c)) ?? null; } +function isInvokedViaNpx(): boolean { + if (process.env["npm_lifecycle_event"] === "npx") return true; + const argv1 = process.argv[1] ?? ""; + if (argv1.includes("_npx")) return true; + const ua = process.env["npm_config_user_agent"] ?? ""; + if (ua.startsWith("npm/") || ua.includes(" npm/")) return true; + return false; +} + +function shouldSkipNpxHint(): boolean { + try { + const prefsPath = join(homedir(), ".agentmemory", "preferences.json"); + if (!existsSync(prefsPath)) return false; + const raw = readFileSync(prefsPath, "utf-8"); + const prefs = JSON.parse(raw) as { skipNpxHint?: boolean }; + return prefs?.skipNpxHint === true; + } catch { + return false; + } +} + +function maybeEmitNpxHint(): void { + if (!isInvokedViaNpx()) return; + if (shouldSkipNpxHint()) return; + p.log.info( + "Tip: install globally for the bare `agentmemory` command:\n npm install -g @agentmemory/agentmemory", + ); +} + function adoptRunningEngine(): void { try { const existingState = readEngineState(); @@ -671,6 +700,7 @@ async function main() { whichBinary("iii") ?? fallbackIiiPaths().find((p) => existsSync(p)) ?? null; warnIfEngineVersionMismatch(attachedBin); adoptRunningEngine(); + maybeEmitNpxHint(); await import("./index.js"); return; } @@ -739,6 +769,7 @@ async function main() { } s.stop("iii-engine is ready"); + maybeEmitNpxHint(); await import("./index.js"); }