Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 93 additions & 6 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N> | --max-files=<N>: override scan cap (default 200, max 1000;
Expand Down Expand Up @@ -239,6 +242,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");
}
Expand All @@ -248,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 {
Expand Down Expand Up @@ -315,6 +333,61 @@ 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();
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)}`);
}
}
Comment on lines +365 to +389
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Only adopt engines you can prove are local/native.

This path currently treats any reachable getBaseUrl() as a local native iii instance. With a remote AGENTMEMORY_URL, a Docker-published engine, or Windows (where findEnginePidsByPort() always returns []), it still persists { kind: "native", attached: true } and may emit a local-binary version warning for the wrong process. That changes later stop behavior from the existing Docker/ambiguity guard into native PID signaling or a dead-end “could not locate engine process”.

At minimum, don't persist native state or run the version check unless you've established that the target is loopback and you actually found a native engine PID to adopt.

Minimal guard to avoid misclassifying foreign engines
-function adoptRunningEngine(): void {
+function adoptRunningEngine(): boolean {
   try {
     const existingState = readEngineState();
     const existingPid = readEnginePidfile();
-    if (existingState && existingPid) return;
+    if (existingState && existingPid) return true;
+
+    const base = new URL(getBaseUrl());
+    const isLoopback = new Set(["localhost", "127.0.0.1", "::1"]).has(base.hostname);
+    if (!isLoopback) return false;

     const pids = findEnginePidsByPort(getRestPort());
     const enginePid = pids[0];
+    if (!enginePid) return false;
+
     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})`);
     }
+    return true;
   } catch (err) {
     vlog(`adoptRunningEngine: ${err instanceof Error ? err.message : String(err)}`);
+    return false;
   }
 }

-    warnIfEngineVersionMismatch(attachedBin);
-    adoptRunningEngine();
+    const adopted = adoptRunningEngine();
+    if (adopted) warnIfEngineVersionMismatch(attachedBin);

Also applies to: 699-703

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cli.ts` around lines 365 - 389, The adoptRunningEngine function currently
marks any reachable engine as a local/native instance; change it so it only
writes native state and a pidfile (via writeEngineState and writeEnginePidfile)
and runs any native-version checks when you can prove the service is local and
native: require that findEnginePidsByPort(getRestPort()) actually returns a PID
and that the base URL is loopback/localhost (check getBaseUrl() or the rest port
binding) before persisting { kind: "native", attached: true } or logging
"Attached to existing iii-engine"; if no local PID or not loopback, do not write
native state or pidfile and skip the native-version check/logging. Apply the
same guard to the other place that writes engine state/pid (the later
writeEngineState/writeEnginePidfile usage referenced in the review) so
remote/Docker/Windows cases are not misclassified.


async function runIiiInstaller(): Promise<{ ok: boolean; binPath: string | null }> {
const releaseUrl = iiiReleaseUrl();
const asset = iiiReleaseAsset();
Expand Down Expand Up @@ -427,6 +500,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 });
Expand Down Expand Up @@ -622,6 +696,11 @@ 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);
adoptRunningEngine();
maybeEmitNpxHint();
await import("./index.js");
return;
}
Expand Down Expand Up @@ -690,6 +769,7 @@ async function main() {
}

s.stop("iii-engine is ready");
maybeEmitNpxHint();
await import("./index.js");
}

Expand Down Expand Up @@ -1461,6 +1541,7 @@ async function runStop(): Promise<void> {
const port = getRestPort();
const state = readEngineState();
const running = await isEngineRunning();
const force = args.includes("--force");

if (state?.kind === "docker") {
if (!running) {
Expand Down Expand Up @@ -1498,10 +1579,16 @@ async function runStop(): Promise<void> {
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);
}
}
}

Expand Down
34 changes: 30 additions & 4 deletions src/viewer/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ function readBody(req: IncomingMessage): Promise<string> {
});
}

const MAX_VIEWER_PORT_RETRIES = 10;

export function startViewerServer(
port: number,
_kv: unknown,
Expand All @@ -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 || "/";
Expand Down Expand Up @@ -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;
}
Expand Down