Skip to content
Open
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
65 changes: 62 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type ChildProcess,
} from "node:child_process";
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
Expand Down Expand Up @@ -38,6 +39,13 @@ import {
type ConnectManifest,
type RemoveOptions,
} from "./cli/remove-plan.js";
import {
isBundledConfig,
legacyDataMigrations,
resolveEngineCwd,
rewriteBundledConfig,
runtimeConfigPath,
} from "./cli/engine-launch.js";
import { renderSplash } from "./cli/splash.js";
import { isFirstRun, readPrefs, resetPrefs, writePrefs } from "./cli/preferences.js";
import { runOnboarding } from "./cli/onboarding.js";
Expand Down Expand Up @@ -816,12 +824,14 @@ function spawnEngineBackground(
bin: string,
spawnArgs: string[],
label: string,
cwd?: string,
): ChildProcess {
vlog(`spawn: ${bin} ${spawnArgs.join(" ")}`);
vlog(`spawn: ${bin} ${spawnArgs.join(" ")}${cwd ? ` (cwd: ${cwd})` : ""}`);
const child = spawn(bin, spawnArgs, {
detached: true,
stdio: ["ignore", "ignore", "pipe"],
windowsHide: true,
...(cwd ? { cwd } : {}),
});
const isDocker = label.includes("Docker");
if (!isDocker && typeof child.pid === "number") {
Expand Down Expand Up @@ -862,11 +872,60 @@ function spawnEngineBackground(
return child;
}

// The bundled config uses cwd-relative paths (./data stores, src watch,
// node dist/index.mjs exec) that only resolve from a repo checkout. When
// the engine starts from a global or npx install those paths land in
// whatever directory the user happened to be in, and the iii-exec worker
// supervision never resolves at all, so nothing respawns a dead worker.
// Rewriting the bundled config with absolute paths and anchoring the
// engine cwd at ~/.agentmemory fixes both; user-supplied configs (env,
// cwd, ~/.agentmemory) are passed through untouched.
function prepareEngineLaunch(configPath: string): { configPath: string; cwd: string } {
const home = homedir();
const cwd = resolveEngineCwd(configPath, process.cwd(), home);
try {
mkdirSync(cwd, { recursive: true });
} catch {
return { configPath, cwd: process.cwd() };
}
if (!isBundledConfig(configPath, __dirname)) {
return { configPath, cwd };
}
try {
const workerEntry = join(__dirname, "index.mjs");
const rewritten = rewriteBundledConfig(
readFileSync(configPath, "utf-8"),
home,
process.execPath,
workerEntry,
);
const runtimePath = runtimeConfigPath(home);
mkdirSync(dirname(runtimePath), { recursive: true });
writeFileSync(runtimePath, rewritten, "utf-8");
for (const m of legacyDataMigrations(process.cwd(), home)) {
if (existsSync(m.from) && !existsSync(m.to)) {
try {
mkdirSync(dirname(m.to), { recursive: true });
cpSync(m.from, m.to, { recursive: true });
p.log.info(`Copied existing data: ${m.from} -> ${m.to}`);
} catch (err) {
vlog(`data copy failed for ${m.from}: ${String(err)}`);
}
}
}
return { configPath: runtimePath, cwd };
} catch (err) {
vlog(`runtime config generation failed, using bundled config verbatim: ${String(err)}`);
return { configPath, cwd };
}
}

function startIiiBin(iiiBin: string, configPath: string): boolean {
const s = p.spinner();
s.start(`Starting iii-engine: ${iiiBin}`);
writeEngineState({ kind: "native", configPath, binPath: iiiBin });
spawnEngineBackground(iiiBin, ["--config", configPath], "iii-engine");
const launch = prepareEngineLaunch(configPath);
writeEngineState({ kind: "native", configPath: launch.configPath, binPath: iiiBin });
spawnEngineBackground(iiiBin, ["--config", launch.configPath], "iii-engine", launch.cwd);
s.stop("iii-engine process started");
return true;
}
Expand Down
74 changes: 74 additions & 0 deletions src/cli/engine-launch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { join, resolve } from "node:path";

export function agentmemoryHome(home: string): string {
return join(home, ".agentmemory");
}

export function runtimeConfigPath(home: string): string {
return join(agentmemoryHome(home), "iii-config.runtime.yaml");
}

export function isBundledConfig(configPath: string, packageDir: string): boolean {
const resolved = resolve(configPath);
return (
resolved === resolve(join(packageDir, "iii-config.yaml")) ||
resolved === resolve(join(packageDir, "..", "iii-config.yaml"))
);
}

export function resolveEngineCwd(
configPath: string,
invocationCwd: string,
home: string,
): string {
if (resolve(configPath) === resolve(join(invocationCwd, "iii-config.yaml"))) {
return invocationCwd;
}
return agentmemoryHome(home);
}

function yamlSingleQuote(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}

export function rewriteBundledConfig(
raw: string,
home: string,
nodeBin: string,
workerEntry: string,
): string {
const dataDir = join(agentmemoryHome(home), "data");
return raw
.replace(
"file_path: ./data/state_store.db",
`file_path: ${yamlSingleQuote(join(dataDir, "state_store.db"))}`,
)
.replace(
"file_path: ./data/stream_store",
`file_path: ${yamlSingleQuote(join(dataDir, "stream_store"))}`,
)
.replace("- src/**/*.ts", `- ${yamlSingleQuote(workerEntry)}`)
.replace(
"- node dist/index.mjs",
`- ${yamlSingleQuote(`"${nodeBin}" "${workerEntry}"`)}`,
);
}

export interface DataMigration {
from: string;
to: string;
}

export function legacyDataMigrations(invocationCwd: string, home: string): DataMigration[] {
const dataDir = join(agentmemoryHome(home), "data");
return [
{
from: join(invocationCwd, "data", "state_store.db"),
to: join(dataDir, "state_store.db"),
},
{
from: join(invocationCwd, "data", "stream_store"),
to: join(dataDir, "stream_store"),
},
];
}
10 changes: 10 additions & 0 deletions src/cli/remove-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { runtimeConfigPath } from "./engine-launch.js";

export type RemovePlanItem = {
/** Stable id, used in tests and CLI output. */
Expand Down Expand Up @@ -197,6 +198,15 @@ export function buildRemovePlan(
sizeBytes: -1,
});

plan.push({
id: "runtime-config",
description: "Delete generated iii-config.runtime.yaml",
path: runtimeConfigPath(home),
alwaysAsk: false,
applicable: pathExists(runtimeConfigPath(home)),
sizeBytes: safeSize(runtimeConfigPath(home)),
});

// Iterate over connect-installed agent symlinks. We always honor these
// (even with --keep-data, since they're outside ~/.agentmemory/).
if (connectManifest?.installed?.length) {
Expand Down
108 changes: 108 additions & 0 deletions test/engine-launch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
agentmemoryHome,
isBundledConfig,
legacyDataMigrations,
resolveEngineCwd,
rewriteBundledConfig,
runtimeConfigPath,
} from "../src/cli/engine-launch.js";

const HOME = "/Users/test";

describe("engine-launch path resolution", () => {
it("agentmemoryHome and runtimeConfigPath anchor under ~/.agentmemory", () => {
expect(agentmemoryHome(HOME)).toBe(join(HOME, ".agentmemory"));
expect(runtimeConfigPath(HOME)).toBe(
join(HOME, ".agentmemory", "iii-config.runtime.yaml"),
);
});

it("isBundledConfig matches both package config locations", () => {
const dist = "/opt/pkg/dist";
expect(isBundledConfig(join(dist, "iii-config.yaml"), dist)).toBe(true);
expect(isBundledConfig(join(dist, "..", "iii-config.yaml"), dist)).toBe(true);
expect(isBundledConfig("/opt/pkg/iii-config.yaml", dist)).toBe(true);
expect(isBundledConfig(join(HOME, ".agentmemory", "iii-config.yaml"), dist)).toBe(false);
expect(isBundledConfig("/some/project/iii-config.yaml", dist)).toBe(false);
});

it("resolveEngineCwd keeps the invocation cwd for repo-local configs", () => {
const repo = "/work/agentmemory";
expect(resolveEngineCwd(join(repo, "iii-config.yaml"), repo, HOME)).toBe(repo);
});

it("resolveEngineCwd anchors at ~/.agentmemory for bundled and home configs", () => {
const repo = "/work/some-project";
expect(resolveEngineCwd("/opt/pkg/dist/iii-config.yaml", repo, HOME)).toBe(
join(HOME, ".agentmemory"),
);
expect(
resolveEngineCwd(join(HOME, ".agentmemory", "iii-config.yaml"), repo, HOME),
).toBe(join(HOME, ".agentmemory"));
expect(resolveEngineCwd("/etc/custom-iii.yaml", repo, HOME)).toBe(
join(HOME, ".agentmemory"),
);
});

it("legacyDataMigrations pairs cwd data files with the home data dir", () => {
const migrations = legacyDataMigrations("/work/proj", HOME);
expect(migrations).toEqual([
{
from: join("/work/proj", "data", "state_store.db"),
to: join(HOME, ".agentmemory", "data", "state_store.db"),
},
{
from: join("/work/proj", "data", "stream_store"),
to: join(HOME, ".agentmemory", "data", "stream_store"),
},
]);
});
});

describe("rewriteBundledConfig", () => {
const SAMPLE = [
" file_path: ./data/state_store.db",
" file_path: ./data/stream_store",
" watch:",
" - src/**/*.ts",
" exec:",
" - node dist/index.mjs",
].join("\n");

it("substitutes data paths, watch entry, and exec command with absolute paths", () => {
const out = rewriteBundledConfig(SAMPLE, HOME, "/usr/bin/node", "/opt/pkg/dist/index.mjs");
expect(out).toContain(
`file_path: '${join(HOME, ".agentmemory", "data", "state_store.db")}'`,
);
expect(out).toContain(
`file_path: '${join(HOME, ".agentmemory", "data", "stream_store")}'`,
);
expect(out).toContain("- '/opt/pkg/dist/index.mjs'");
expect(out).toContain(`- '"/usr/bin/node" "/opt/pkg/dist/index.mjs"'`);
expect(out).not.toContain("./data/");
expect(out).not.toContain("src/**/*.ts");
});

it("escapes apostrophes in paths for single-quoted YAML", () => {
const out = rewriteBundledConfig(
SAMPLE,
"/Users/o'brien",
"/usr/bin/node",
"/opt/pkg/dist/index.mjs",
);
expect(out).toContain("o''brien");
});

it("rewrites the real bundled config with no relative paths left", () => {
const raw = readFileSync(join(import.meta.dirname, "..", "iii-config.yaml"), "utf-8");
const out = rewriteBundledConfig(raw, HOME, process.execPath, "/opt/pkg/dist/index.mjs");
expect(out).not.toContain("./data/");
expect(out).not.toContain("src/**/*.ts");
expect(out).not.toContain("- node dist/index.mjs");
expect(out).toContain(join(HOME, ".agentmemory", "data", "state_store.db"));
expect(out).toContain(join(HOME, ".agentmemory", "data", "stream_store"));
});
});