@staix/agent-mem 0.8.0 → 0.10.0

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/src/setup.ts CHANGED
@@ -2,7 +2,7 @@ import { SETUP_CONFIG_BYTES, updateTomlArray } from "./setup-toml.ts";
2
2
  import { managedAfter, mcpRecord, putMcpRecord, restoreOwned, sameImage, type ConfigImage } from "./setup-owned.ts";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
- import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { closeSync, constants, fchmodSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { homedir, tmpdir } from "node:os";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -10,17 +10,20 @@ import { fileURLToPath } from "node:url";
10
10
  import { isDeepStrictEqual } from "node:util";
11
11
  import { HOOK_EVENTS } from "./hook-contract.ts";
12
12
  import { memoryHome } from "./memory/home.ts";
13
- import { canonicalPath, processLiveness, processSignature, realPath } from "./memory/platform.ts";
13
+ import { canonicalPath, memoryGitEnv, memoryGitSync, processLiveness, processSignature, realPath } from "./memory/platform.ts";
14
14
 
15
- export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi"] as const;
15
+ export const SETUP_TARGETS = ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy"] as const;
16
+ /** Workspace-scoped Antigravity setup is always selected explicitly. */
17
+ export const SETUP_ALL_TARGETS = SETUP_TARGETS.filter(target => target !== "agy");
16
18
  export type SetupTarget = typeof SETUP_TARGETS[number];
17
19
  type Env = Readonly<Record<string, string | undefined>>;
18
20
  type Image = ConfigImage;
19
21
  interface PlannedHook { event: string; command: string; timeout: number }
20
22
  interface Change { key: string; path: string; before: Image; after: Image; start?: Image }
21
- interface Receipt { version: 1; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
23
+ interface DirectoryIdentity { dev: number; ino: number }
24
+ interface Receipt { createdDirectory?: DirectoryIdentity; version: 1; target: SetupTarget; intent: string; phase: "prepared" | "applied"; changes: Change[] }
22
25
  export interface SetupContext {
23
- env?: Env; bun?: string; entry?: string; stdinTTY?: boolean;
26
+ env?: Env; bun?: string; entry?: string; stdinTTY?: boolean; cwd?: string;
24
27
  /** Trusted confirmation seam; ordinary CLI use always asks the person through stdin. */
25
28
  confirm?: (plan: SetupPlan) => Promise<boolean>;
26
29
  /** Host-only race fixture, called after temporary-file fsync and before the final comparison/rename. */
@@ -30,7 +33,7 @@ export interface SetupContext {
30
33
  }
31
34
  export interface SetupPlan {
32
35
  targets: SetupTarget[]; mode: "preview" | "apply" | "undo";
33
- actions: { target: SetupTarget; files: string[]; receipt: string; registration: string[] | null; lifecycle: string; available: boolean; hooks: PlannedHook[]; mcpChange: "add" | "replace" | "restore" | "unchanged"; commands: string[][]; state: string }[];
36
+ actions: { target: SetupTarget; files: string[]; receipt: string; cwd?: string; registration: string[] | null; lifecycle: string; available: boolean; hooks: PlannedHook[]; mcpChange: "add" | "replace" | "restore" | "unchanged"; commands: string[][]; state: string }[];
34
37
  notes: string[];
35
38
  }
36
39
  const MAX = SETUP_CONFIG_BYTES, JOURNAL_MAX = 12 * MAX;
@@ -86,20 +89,56 @@ function write(file: string, value: Image, expected?: Image, beforePublish?: (fi
86
89
  names(file); renameSync(temporary, file); const parent = openSync(dirname(file), constants.O_RDONLY); try { fsyncSync(parent); } finally { closeSync(parent); }
87
90
  } finally { rmSync(temporary, { force: true }); }
88
91
  }
89
- function context(options: SetupContext) {
92
+ interface WorkspaceBinding { root: string; ignored: boolean; assertCurrent(): void }
93
+ function workspaceBinding(options: SetupContext): WorkspaceBinding {
94
+ const requestedCwd = resolve(options.cwd ?? process.cwd()), cwd = realPath(requestedCwd), env = memoryGitEnv({ ...process.env, ...options.env }, { repositoryIsolation: true });
95
+ for (const key of ["GIT_LITERAL_PATHSPECS", "GIT_GLOB_PATHSPECS", "GIT_NOGLOB_PATHSPECS", "GIT_ICASE_PATHSPECS"]) delete env[key];
96
+ const git = (args: string[]) => memoryGitSync(["-C", cwd, ...args], { env, encoding: "utf8", timeout: 5000, maxBuffer: MAX });
97
+ const location = (args: string[]) => {
98
+ const result = git(args);
99
+ if (result.status !== 0 || result.error || !result.stdout.endsWith("\n")) throw Error("Antigravity setup needs an available Git repository");
100
+ return realPath(result.stdout.slice(0, -1));
101
+ };
102
+ const root = location(["rev-parse", "--show-toplevel"]), gitDir = location(["rev-parse", "--absolute-git-dir"]), stat = lstatSync(root);
103
+ const within = relative(root, cwd);
104
+ if (!stat.isDirectory() || within === ".." || within.startsWith("../") || isAbsolute(within)) throw Error("Antigravity workspace must contain the calling directory");
105
+ const tracked = () => {
106
+ // Case aliases can name the same tracked file on macOS. Refuse them on every host.
107
+ const result = git(["ls-files", "--error-unmatch", "--", ":(top,icase,literal).agents/mcp_config.json"]);
108
+ if (result.status === 0) throw Error("Antigravity setup refuses Git-tracked .agents/mcp_config.json in every mode");
109
+ if (result.status !== 1 || result.error) throw Error("Antigravity setup cannot verify configuration tracking");
110
+ };
111
+ const assertCurrent = () => {
112
+ const current = lstatSync(root);
113
+ if (realPath(requestedCwd) !== cwd || current.dev !== stat.dev || current.ino !== stat.ino || location(["rev-parse", "--show-toplevel"]) !== root || location(["rev-parse", "--absolute-git-dir"]) !== gitDir) throw Error("Antigravity workspace binding changed");
114
+ tracked();
115
+ };
116
+ assertCurrent();
117
+ const ignored = git(["check-ignore", "-q", "--", join(root, ".agents", "mcp_config.json")]);
118
+ if (![0, 1].includes(ignored.status ?? -1) || ignored.error) throw Error("Antigravity setup cannot verify configuration ignore status");
119
+ return { root, ignored: ignored.status === 0, assertCurrent };
120
+ }
121
+ function context(options: SetupContext, workspace?: WorkspaceBinding) {
90
122
  const env = options.env ?? process.env, home = realPath(resolve(env.HOME?.trim() || homedir()));
91
123
  const root = (key: string, fallback: string) => {
92
- if (!env[key]?.trim()) return join(home, fallback);
93
- const given = resolve(env[key]!), withinHome = relative(resolve(env.HOME?.trim() || homedir()), given);
124
+ const setting = env[key];
125
+ const present = key === "JCODE_HOME" ? setting !== undefined : key === "QWEN_HOME" ? !!setting : !!setting?.trim();
126
+ if (!present) return join(home, fallback);
127
+ const expanded = key === "QWEN_HOME" && /^~(?:[/\\]|$)/.test(setting!) ? join(home, ...setting!.slice(2).split(/[/\\]/)) : setting!;
128
+ const given = resolve(expanded), withinHome = relative(resolve(env.HOME?.trim() || homedir()), given);
94
129
  // Canonicalize the HOME alias only; named configuration-directory symlinks must still be refused.
95
130
  return withinHome !== ".." && !withinHome.startsWith("../") && !isAbsolute(withinHome) ? join(home, withinHome) : given;
96
131
  };
97
- const claude = root("CLAUDE_CONFIG_DIR", ".claude"), codex = root("CODEX_HOME", ".codex"), kimi = root("KIMI_CODE_HOME", ".kimi-code"), pi = root("PI_CODING_AGENT_DIR", ".pi/agent");
132
+ const claude = root("CLAUDE_CONFIG_DIR", ".claude"), codex = root("CODEX_HOME", ".codex"), kimi = root("KIMI_CODE_HOME", ".kimi-code"), pi = root("PI_CODING_AGENT_DIR", ".pi/agent"), qwen = root("QWEN_HOME", ".qwen");
98
133
  const paths: Record<SetupTarget, string[]> = {
99
134
  claude: [env.CLAUDE_CONFIG_DIR?.trim() ? join(claude, ".claude.json") : join(home, ".claude.json"), join(claude, "settings.json")],
100
135
  codex: [join(codex, "config.toml"), join(codex, "hooks.json")],
101
136
  kimi: [join(kimi, "mcp.json"), join(kimi, "config.toml")],
102
137
  pi: [join(pi, "mcp.json"), join(pi, "extensions", "amem-session.ts")],
138
+ qwen: [join(qwen, "settings.json")],
139
+ // Absolute JCODE_HOME spellings stay literal; unset uses the platform-home default.
140
+ jcode: [join(root("JCODE_HOME", ".jcode"), "mcp.json")],
141
+ agy: workspace ? [join(workspace.root, ".agents", "mcp_config.json")] : [],
103
142
  };
104
143
  const requestedBun = options.bun ?? "bun", bun = requestedBun === "bun" ? Bun.which("bun", { PATH: env.PATH ?? process.env.PATH }) : requestedBun;
105
144
  const entry = options.entry ?? fileURLToPath(new URL("../bin/amem.js", import.meta.url));
@@ -109,14 +148,21 @@ function context(options: SetupContext) {
109
148
  const roots = [resolve(tmpdir()), realPath(tmpdir())], entries = [resolve(entry), realPath(entry)];
110
149
  if (roots.some(root => entries.some(file => { const part = relative(root, file); return part !== ".." && !part.startsWith("../") && !isAbsolute(part); }))) throw Error("setup refuses a temporary package entry; install amem in a persistent location first");
111
150
  }
112
- return { env, home, paths, bun, entry, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
151
+ return { env, home, paths, bun, entry, workspace, state: join(canonicalPath(memoryHome({ ...env, AGENTHUB_HOME: env.AGENTHUB_HOME?.trim() || join(home, ".agenthub") })), "setup") };
113
152
  }
114
153
  function command(target: SetupTarget, bun: string, entry: string): string[] | null {
115
- if (target === "kimi") return null;
116
- return [target, "mcp", "add", ...(target === "claude" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
154
+ if (target === "kimi" || target === "jcode" || target === "agy") return null;
155
+ return [target, "mcp", "add", ...(target === "claude" || target === "qwen" ? ["--scope", "user", "--transport", "stdio"] : []), "amem", "--", bun, "--smol", entry, "mcp"];
117
156
  }
118
- function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry]); }
119
- function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, `${target}.json`); }
157
+ function assertJcodeHome(env: Env): void {
158
+ if (env.JCODE_HOME !== undefined && !isAbsolute(env.JCODE_HOME)) throw Error("setup requires an absolute JCODE_HOME; unset it to use the default");
159
+ }
160
+ function assertJcodeRuntime(c: ReturnType<typeof context>): void {
161
+ assertJcodeHome(c.env);
162
+ if ([c.bun, c.entry].some(value => value.includes("${"))) throw Error("Jcode interpolates environment placeholders in MCP command paths; setup cannot register this runtime exactly");
163
+ }
164
+ function intent(c: ReturnType<typeof context>, target: SetupTarget): string { return digest([1, target, c.paths[target], c.bun, c.entry, ...(target === "agy" ? [c.workspace!.root] : [])]); }
165
+ function journal(c: ReturnType<typeof context>, target: SetupTarget): string { return join(c.state, target === "agy" ? `agy-${digest(c.workspace!.root)}.json` : `${target}.json`); }
120
166
  function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRuntime = false): Receipt | undefined {
121
167
  const raw = read(journal(c, target), JOURNAL_MAX); if (raw.bytes === null) return;
122
168
  const value = record(JSON.parse(Buffer.from(raw.bytes, "base64").toString("utf8")));
@@ -128,17 +174,25 @@ function receipt(c: ReturnType<typeof context>, target: SetupTarget, requireRunt
128
174
  if (!Number.isInteger(v.mode) || Number(v.mode) < 0 || Number(v.mode) > 0o777 || v.bytes !== null && (typeof v.bytes !== "string" || v.bytes.length > 2 * MAX || Buffer.from(v.bytes, "base64").toString("base64") !== v.bytes)) throw Error("setup receipt invalid");
129
175
  }
130
176
  }
177
+ if (value.createdDirectory !== undefined) {
178
+ const created = record(value.createdDirectory);
179
+ if (target !== "agy" || Object.keys(created).sort().join() !== "dev,ino" || !Number.isSafeInteger(created.dev) || Number(created.dev) < 0 || !Number.isSafeInteger(created.ino) || Number(created.ino) <= 0) throw Error("setup receipt directory ownership invalid");
180
+ }
131
181
  return value as unknown as Receipt;
132
182
  }
133
183
  function plannedHooks(c: ReturnType<typeof context>, target: SetupTarget): PlannedHook[] {
184
+ if (target === "jcode" || target === "agy") return [];
134
185
  const launch = `${quote(c.bun)} --smol ${quote(c.entry)}`;
135
186
  return target === "claude" ? HOOK_EVENTS.map(event => ({ event, command: `${launch} hook ${event}`, timeout: 1 }))
136
187
  : [{ event: target === "pi" ? "session_shutdown" : "SessionEnd", command: `${launch} ingest ${target}`, timeout: target === "pi" ? 2.5 : 3 }];
137
188
  }
138
- const lifecycle = (target: SetupTarget) => target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
189
+ const lifecycle = (target: SetupTarget) => target === "jcode" || target === "agy" ? "MCP only; native capture unavailable" : target === "claude" ? "seven bounded native hooks" : target === "pi" ? "session_shutdown incremental ingest" : "SessionEnd incremental ingest";
139
190
  export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", options: SetupContext = {}): SetupPlan {
140
- if (target !== "all" && !SETUP_TARGETS.includes(target as SetupTarget)) throw Error("usage: amem setup claude|codex|kimi|pi|all [--apply|--undo]");
141
- const c = context(options), targets = target === "all" ? [...SETUP_TARGETS] : [target as SetupTarget];
191
+ if (target !== "all" && !SETUP_TARGETS.includes(target as SetupTarget)) throw Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|agy|all [--apply|--undo]");
192
+ const targets: SetupTarget[] = target === "all" ? [...SETUP_ALL_TARGETS] : [target as SetupTarget];
193
+ if (targets.includes("jcode")) assertJcodeHome(options.env ?? process.env);
194
+ const c = context(options, targets.includes("agy") ? workspaceBinding(options) : undefined);
195
+ if (mode !== "undo" && targets.includes("jcode")) assertJcodeRuntime(c);
142
196
  const pluginNotes: string[] = [];
143
197
  if (targets.includes("claude")) {
144
198
  const settings = json(read(c.paths.claude[1]!));
@@ -148,20 +202,28 @@ export function planSetup(target: string, mode: SetupPlan["mode"] = "preview", o
148
202
  try { for (const key of Object.keys(record(json(read(join(dirname(c.paths.claude[1]!), "plugins", "installed_plugins.json"))).plugins ?? {}))) ids.add(key); } catch { /* Optional plugin metadata must never prevent setup or recovery; authoritative settings/receipts keep their guards. */ }
149
203
  for (const id of [...ids].filter(key => /^agent-mem(?:@[A-Za-z0-9._-]+)?$/.test(key))) pluginNotes.push(`Installed Claude plugin ${id} detected: if enabled, direct setup would duplicate its MCP server and seven hooks. Choose plugin installation or direct setup; disable/uninstall the plugin before applying direct setup.`);
150
204
  }
205
+ if (targets.includes("qwen")) pluginNotes.push("Qwen capture supports the verified 0.26.1 native format. For custom advanced.runtimeOutputDir, export the matching QWEN_RUNTIME_DIR for manual and SessionEnd ingest.");
206
+ if (targets.includes("jcode")) pluginNotes.push("Jcode setup registers MCP only. Native saved sessions do not prove per-message project scope, so capture and ingest hooks are unavailable; no hook or config.toml is installed.");
207
+ if (c.workspace) {
208
+ pluginNotes.push(`Antigravity MCP is pinned to this repository only: ${c.workspace.root}. Restart the CLI/IDE and use one workspace; no global or plugin definitions are replaced, and same-name definitions elsewhere are unsupported. Native capture is unavailable; no hooks are installed. Only strict JSON is supported; JSONC is refused. Native TUI/IDE presence and live behavior are unverified; availability means this direct configuration operation is supported.`);
209
+ if (!c.workspace.ignored) pluginNotes.push("WARNING: .agents/mcp_config.json is not Git-ignored; it contains absolute local paths. Setup will refuse it if it becomes tracked.");
210
+ }
211
+ if (target === "all") pluginNotes.push("Antigravity is repository-scoped and excluded from all; run amem setup agy explicitly inside its repository.");
212
+ if (targets.includes("jcode")) pluginNotes.push("Launch Jcode with JCODE_DISABLE_CLAUDE_MCP=1 and without a same-name project override: imported Claude stdio entries can replace this private amem binding with a shared daemon-cwd server. Use a new workspace-bound session when changing projects.");
151
213
  return { targets, mode, actions: targets.map(target => {
152
214
  const prior = receipt(c, target, false); for (const file of c.paths[target]) read(file);
153
215
  const original = read(c.paths[target][0]!);
154
- const document = target === "codex" ? toml(original.bytes === null ? "" : Buffer.from(original.bytes, "base64").toString("utf8")) : json(original);
155
- const hasAmem = Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
216
+ const document: Record<string, unknown> = target === "agy" ? {} : target === "codex" ? toml(original.bytes === null ? "" : Buffer.from(original.bytes, "base64").toString("utf8")) : json(original);
217
+ const hasAmem = target === "jcode" || target === "agy" ? mcpRecord(target, original) !== undefined : Object.hasOwn(record(document[target === "codex" ? "mcp_servers" : "mcpServers"] ?? {}), "amem");
156
218
  const unchanged = !!prior && prior.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => { try { const live = read(change.path); restoreChange(prior, change, index, live); return managedAfter(target, index, change.before, change.after, live); } catch { return false; } });
157
- const add = command(target, c.bun, c.entry), remove = [target, "mcp", "remove", ...(target === "claude" ? ["--scope", "user"] : []), "amem"];
158
- return { target, available: available(c, target, options), files: c.paths[target], receipt: journal(c, target), registration: mode === "undo" ? null : add, lifecycle: mode === "undo" ? "restore original MCP/hook files" : lifecycle(target), hooks: mode === "undo" || unchanged ? [] : plannedHooks(c, target), mcpChange: mode === "undo" ? prior ? "restore" : "unchanged" : unchanged ? "unchanged" : hasAmem ? "replace" : "add", commands: mode === "undo" || prior?.intent === intent(c, target) || !add || !available(c, target, options) ? [] : [...(hasAmem ? [remove] : []), add], state: prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
219
+ const add = command(target, c.bun, c.entry), remove = [target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"];
220
+ return { target, available: available(c, target, options), files: c.paths[target], receipt: journal(c, target), ...(target === "agy" ? { cwd: c.workspace!.root } : {}), registration: mode === "undo" ? null : add, lifecycle: mode === "undo" ? "restore original MCP/hook files" : lifecycle(target), hooks: mode === "undo" || unchanged ? [] : plannedHooks(c, target), mcpChange: mode === "undo" ? prior ? "restore" : "unchanged" : unchanged ? "unchanged" : hasAmem ? "replace" : "add", commands: mode === "undo" || prior?.intent === intent(c, target) || !add || !available(c, target, options) ? [] : [...(hasAmem ? [remove] : []), add], state: prior && prior.intent !== intent(c, target) ? "runtime changed; managed entries will update" : prior?.phase ?? "not installed" };
159
221
  }), notes: [...pluginNotes, ...(/\/[^/]*\d+\.\d+[^/]*\/(?:bin\/)?bun$/.test(c.bun) ? ["this bun path is version-specific; rerun setup --apply after changing Bun"] : []), "Preview only unless --apply or --undo is confirmed in a person's terminal.", "Native registration commands run in a private staging HOME; only the listed configuration files are published.", "Original file bytes restore exactly when untouched; otherwise undo preserves CLI-owned changes and restores only managed entries, refusing edits to those entries.", "No AGENTS.md, CLAUDE.md or rule files are edited.", `A private ${join(c.state, "setup.lock")} serializes mutations; temporary ${join(realPath(tmpdir()), "amem-setup-*")} staging homes are removed.`, "Codex requires its native /hooks review before newly registered hooks run; setup does not grant hook trust. Runtime registrations use the PATH-resolved absolute Bun path without resolving its stable symlink; setup all reports and skips missing clients."] };
160
222
  }
161
223
  function verifyNativeMcp(target: SetupTarget, env: Env, cwd: string): void {
162
224
  // In older free-form Pi CLIs, an unknown subcommand can be treated as a model prompt.
163
225
  const help = spawnSync(target, ["--help"], { cwd, env: { ...env }, encoding: "utf8", timeout: 5000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
164
- if (help.status !== 0 || !/^\s+(?:pi\s+)?mcp(?:\s|$)/m.test(help.stdout)) throw Error(`installed ${target} CLI does not expose MCP registration; no registration command was run`);
226
+ if (help.status !== 0 || !/^\s+(?:(?:pi|qwen)\s+)?mcp(?:\s|$)/m.test(help.stdout)) throw Error(`installed ${target} CLI does not expose MCP registration; no registration command was run`);
165
227
  }
166
228
  function native(argv: readonly string[], env: Env, cwd: string): void {
167
229
  const result = spawnSync(argv[0]!, argv.slice(1), { cwd, env: { ...env }, encoding: "utf8", timeout: 10_000, maxBuffer: MAX, stdio: ["ignore", "pipe", "pipe"] });
@@ -192,17 +254,22 @@ function restoreChange(value: Receipt, change: Change, index: number, live: Imag
192
254
  return restoreOwned(value.target, index, change.before, change.after, live);
193
255
  }
194
256
  function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): Prepared {
257
+ if (target === "jcode") assertJcodeRuntime(c);
195
258
  const prior = receipt(c, target, false), expected = c.paths[target].map(path => read(path));
196
259
  const restored = prior ? prior.changes.map((change, index) => restoreChange(prior, change, index, expected[index]!)) : expected;
197
260
  if (prior?.phase === "applied" && prior.intent === intent(c, target) && prior.changes.every((change, index) => managedAfter(target, index, change.before, change.after, expected[index]!))) return { value: prior, expected, unchanged: true };
198
261
  const changes: Change[] = c.paths[target].map((path, i) => ({ key: `${target}:${i}`, path, before: restored[i]!, after: restored[i]!, start: expected[i]! }));
199
262
  const staging = mkdtempSync(join(realPath(tmpdir()), "amem-setup-"));
200
263
  try {
201
- const env: Record<string, string | undefined> = { ...c.env, HOME: staging, AMEM_HOME: join(staging, "memory"), AGENTHUB_HOME: join(staging, "hub"), CODEX_HOME: join(staging, ".codex"), KIMI_CODE_HOME: join(staging, ".kimi-code"), PI_CODING_AGENT_DIR: join(staging, ".pi", "agent") };
264
+ const env: Record<string, string | undefined> = { ...c.env, HOME: staging, AMEM_HOME: join(staging, "memory"), AGENTHUB_HOME: join(staging, "hub"), CODEX_HOME: join(staging, ".codex"), KIMI_CODE_HOME: join(staging, ".kimi-code"), PI_CODING_AGENT_DIR: join(staging, ".pi", "agent"), QWEN_HOME: join(staging, ".qwen"), JCODE_HOME: join(staging, ".jcode") };
202
265
  Object.assign(env, { XDG_CONFIG_HOME: join(staging, ".config"), XDG_DATA_HOME: join(staging, ".local", "share"), XDG_CACHE_HOME: join(staging, ".cache"), XDG_STATE_HOME: join(staging, ".local", "state"), TMPDIR: join(staging, "tmp"), TMP: join(staging, "tmp"), TEMP: join(staging, "tmp") });
266
+ env.QWEN_RUNTIME_DIR = join(staging, "qwen-runtime");
267
+ env.QWEN_CODE_SYSTEM_SETTINGS_PATH = join(staging, "qwen-system-settings.json");
268
+ env.QWEN_CODE_SYSTEM_DEFAULTS_PATH = join(staging, "qwen-system-defaults.json");
203
269
  mkdirSync(env.TMPDIR!, { mode: 0o700 });
204
270
  if (c.env.CLAUDE_CONFIG_DIR?.trim()) env.CLAUDE_CONFIG_DIR = join(staging, ".claude"); else delete env.CLAUDE_CONFIG_DIR;
205
- const staged = context({ ...options, env });
271
+ // The staging workspace is a trusted private binding, not a Git repository or the real caller cwd.
272
+ const staged = context({ ...options, env, cwd: staging }, target === "agy" ? { root: staging, ignored: true, assertCurrent() {} } : undefined);
206
273
  // Codex refuses an explicit CODEX_HOME that does not exist, even for mcp add.
207
274
  mkdirSync(dirname(staged.paths[target][0]!), { recursive: true, mode: 0o700 });
208
275
  for (let i = 0; i < changes.length; i++) if (expected[i]!.bytes !== null) write(staged.paths[target][i]!, expected[i]!);
@@ -214,7 +281,7 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
214
281
  const text = expected[0]!.bytes === null ? "" : Buffer.from(expected[0]!.bytes!, "base64").toString("utf8");
215
282
  const document = target === "codex" ? toml(text) : json(expected[0]!);
216
283
  const servers = document[target === "codex" ? "mcp_servers" : "mcpServers"];
217
- if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" ? ["--scope", "user"] : []), "amem"], env, staging);
284
+ if (servers && Object.hasOwn(record(servers), "amem")) (options.native ?? native)([target, "mcp", "remove", ...(target === "claude" || target === "qwen" ? ["--scope", "user"] : []), "amem"], env, staging);
218
285
  (options.native ?? native)(argv, env, staging);
219
286
  changes[0]!.after = { ...read(staged.paths[target][0]!), mode: changes[0]!.before.mode };
220
287
  if (changes[0]!.after.bytes === null) throw Error("native registration produced no configuration");
@@ -229,9 +296,16 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
229
296
  if (!isDeepStrictEqual(withoutAmem(document), withoutAmem(installed))) throw Error("native registration changed unrelated settings; no configuration published");
230
297
  } else {
231
298
  // Keep all unrelated JSON settings from the original; use only the CLI-generated server record.
232
- document.mcpServers = { ...record(document.mcpServers ?? {}), amem: installedServer };
233
- changes[0]!.after = objectImage(document, changes[0]!.before);
299
+ // Qwen's restored image also retires its previous managed hook on a runtime upgrade.
300
+ const kept = target === "qwen" ? json(changes[0]!.before) : document;
301
+ kept.mcpServers = { ...record(kept.mcpServers ?? {}), amem: installedServer };
302
+ changes[0]!.after = objectImage(kept, changes[0]!.before);
234
303
  }
304
+ } else if (target === "agy") {
305
+ changes[0]!.after = putMcpRecord(target, changes[0]!.before, { command: c.bun, args: ["--smol", c.entry, "mcp"], cwd: c.workspace!.root });
306
+ } else if (target === "jcode") {
307
+ // Shared native pools use the daemon cwd; amem must bind the calling session's project.
308
+ changes[0]!.after = putMcpRecord(target, changes[0]!.before, { command: c.bun, args: ["--smol", c.entry, "mcp"], shared: false });
235
309
  } else {
236
310
  const document = json(changes[0]!.before), servers = document.mcpServers === undefined ? {} : record(document.mcpServers);
237
311
  servers.amem = { command: c.bun, args: ["--smol", c.entry, "mcp"] }; document.mcpServers = servers;
@@ -240,6 +314,8 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
240
314
  const capture = (kind: string) => `${quote(c.bun)} --smol ${quote(c.entry)} ingest ${kind}`;
241
315
  if (target === "claude") changes[1]!.after = hookDocument(changes[1]!.before, HOOK_EVENTS, event => `${quote(c.bun)} --smol ${quote(c.entry)} hook ${event}`);
242
316
  if (target === "codex") changes[1]!.after = hookDocument(changes[1]!.before, ["SessionEnd"], () => capture("codex"), 3);
317
+ // Qwen's user MCP and hooks share settings.json: publish and journal one combined image.
318
+ if (target === "qwen") changes[0]!.after = hookDocument(changes[0]!.after, ["SessionEnd"], () => capture("qwen"), 3);
243
319
  if (target === "kimi") {
244
320
  const old = changes[1]!.before.bytes === null ? "" : Buffer.from(changes[1]!.before.bytes!, "base64").toString("utf8");
245
321
  const parsed = toml(old), hooks = parsed.hooks ?? [];
@@ -254,34 +330,67 @@ function prepare(c: ReturnType<typeof context>, target: SetupTarget, options: Se
254
330
  }
255
331
  // Never publish an image that this installer's own read/undo boundary cannot reopen.
256
332
  if (changes.some(change => change.after.bytes !== null && Buffer.from(change.after.bytes, "base64").byteLength > MAX)) throw Error("planned setup configuration exceeds the size bound; no configuration published");
257
- return { value: { version: 1, target, intent: intent(c, target), phase: "prepared", changes }, expected, unchanged: false };
333
+ return { value: { version: 1, target, intent: intent(c, target), phase: "prepared", changes, ...(prior?.createdDirectory ? { createdDirectory: prior.createdDirectory } : {}) }, expected, unchanged: false };
258
334
  } finally { rmSync(staging, { recursive: true, force: true }); }
259
335
  }
260
336
  function save(c: ReturnType<typeof context>, value: Receipt): void { write(journal(c, value.target), image(JSON.stringify(value) + "\n")); }
261
- function available(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): boolean { return !!options.native || Bun.which(target, { PATH: c.env.PATH ?? process.env.PATH }) !== null; }
337
+ function available(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext): boolean { return target === "agy" || !!options.native || Bun.which(target, { PATH: c.env.PATH ?? process.env.PATH }) !== null; }
262
338
  export interface SetupApplyResult { applied: SetupTarget[]; unchanged: SetupTarget[]; skipped?: { target: SetupTarget; reason: string }[] }
339
+ /** Claim only a directory this invocation actually created, never one merely absent at preview. */
340
+ function prepareCreatedDirectory(c: ReturnType<typeof context>, value: Receipt): void {
341
+ if (value.target !== "agy") return;
342
+ const dir = dirname(c.paths.agy[0]!); names(dir);
343
+ try { mkdirSync(dir, { mode: 0o700 }); }
344
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return; throw error; }
345
+ // ponytail: mkdir/lstat and lstat/rmdir below cannot exclude external namespace swaps.
346
+ // Descriptor-relative conditional removal or a shared writer lock is the upgrade.
347
+ const stat = lstatSync(dir); value.createdDirectory = { dev: stat.dev, ino: stat.ino };
348
+ // ponytail: a crash between mkdir and this save can leave an empty directory unclaimed.
349
+ // A durable filesystem intent protocol is the upgrade; never guess ownership during undo.
350
+ save(c, value);
351
+ }
352
+ function removeCreatedDirectory(c: ReturnType<typeof context>, value: Receipt): void {
353
+ if (value.target !== "agy" || !value.createdDirectory) return; // Legacy receipts prove no directory ownership.
354
+ const dir = dirname(c.paths.agy[0]!), stat = lstatSync(dir, { throwIfNoEntry: false });
355
+ if (!stat?.isDirectory() || stat.isSymbolicLink() || stat.dev !== value.createdDirectory.dev || stat.ino !== value.createdDirectory.ino) return;
356
+ try { rmdirSync(dir); } // Atomic empty-only removal also preserves files added after the identity check.
357
+ catch (error) { if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; }
358
+ }
359
+ function contextForPlan(plan: SetupPlan, options: SetupContext) {
360
+ const c = context(options, plan.targets.includes("agy") ? workspaceBinding(options) : undefined);
361
+ if (c.workspace) {
362
+ const action = plan.actions.find(action => action.target === "agy");
363
+ if (!action || action.cwd !== c.workspace.root || !isDeepStrictEqual(action.files, c.paths.agy)) throw Error("Antigravity workspace changed since preview");
364
+ }
365
+ return c;
366
+ }
367
+ function publishGuard(c: ReturnType<typeof context>, target: SetupTarget, options: SetupContext) {
368
+ return target === "agy" ? (file: string) => { options.beforePublish?.(file); c.workspace!.assertCurrent(); } : options.beforePublish;
369
+ }
263
370
  /** Caller enforces person markers/TTY and confirmation; CLI-owned unrelated fields remain live. */
264
371
  function applyUnlocked(plan: SetupPlan, options: SetupContext = {}): SetupApplyResult {
265
- const c = context(options), skipped: { target: SetupTarget; reason: string }[] = [];
372
+ const c = contextForPlan(plan, options), skipped: { target: SetupTarget; reason: string }[] = [];
266
373
  const targets = plan.targets.filter(target => { if (available(c, target, options)) return true; if (plan.targets.length === 1) throw Error(`${target} CLI is not installed`); skipped.push({ target, reason: "CLI not installed" }); return false; });
267
374
  const prepared = targets.map(target => prepare(c, target, options));
268
375
  for (const item of prepared) if (!item.unchanged) for (let i = 0; i < item.value.changes.length; i++) if (!sameImage(read(item.value.changes[i]!.path), item.expected[i]!)) throw Error("setup configuration changed during preparation");
269
376
  const applied: SetupTarget[] = [], unchanged: SetupTarget[] = [];
270
377
  for (const item of prepared) {
271
378
  const value = item.value;
379
+ if (value.target === "agy") c.workspace!.assertCurrent();
272
380
  if (item.unchanged) { unchanged.push(value.target); continue; }
273
381
  save(c, value);
274
- for (let i = 0; i < value.changes.length; i++) { const change = value.changes[i]!; if (!sameImage(item.expected[i]!, change.after)) write(change.path, change.after, item.expected[i]!, options.beforePublish); }
382
+ prepareCreatedDirectory(c, value);
383
+ for (let i = 0; i < value.changes.length; i++) { const change = value.changes[i]!; if (!sameImage(item.expected[i]!, change.after)) write(change.path, change.after, item.expected[i]!, publishGuard(c, value.target, options)); }
275
384
  value.phase = "applied"; save(c, value); applied.push(value.target);
276
385
  }
277
386
  return { applied, unchanged, ...(skipped.length ? { skipped } : {}) };
278
387
  }
279
388
  /** Byte-exact for untouched files, otherwise remove/restore only this installer's owned entries. */
280
389
  function undoUnlocked(plan: SetupPlan, options: SetupContext = {}): { restored: SetupTarget[] } {
281
- const c = context(options), receipts = plan.targets.map(target => receipt(c, target, false)).filter((value): value is Receipt => !!value);
390
+ const c = contextForPlan(plan, options), receipts = plan.targets.map(target => receipt(c, target, false)).filter((value): value is Receipt => !!value);
282
391
  const pending = receipts.map(value => ({ value, files: value.changes.map((change, index) => { const expected = read(change.path); return { change, expected, restored: restoreChange(value, change, index, expected) }; }) }));
283
392
  for (const item of pending) for (const file of item.files) if (!sameImage(read(file.change.path), file.expected)) throw Error("setup configuration changed during undo preparation");
284
- for (const item of pending) { for (const file of item.files) if (!sameImage(file.expected, file.restored)) write(file.change.path, file.restored, file.expected, options.beforePublish); rmSync(journal(c, item.value.target)); }
393
+ for (const item of pending) { if (item.value.target === "agy") c.workspace!.assertCurrent(); for (const file of item.files) if (!sameImage(file.expected, file.restored)) write(file.change.path, file.restored, file.expected, publishGuard(c, item.value.target, options)); removeCreatedDirectory(c, item.value); rmSync(journal(c, item.value.target)); }
285
394
  return { restored: receipts.map(value => value.target) };
286
395
  }
287
396
 
@@ -306,9 +415,13 @@ function withSetupLock<T>(options: SetupContext, run: () => T): T {
306
415
  finally { closeSync(fd); if (read(file).bytes === Buffer.from(token).toString("base64")) rmSync(file); }
307
416
  }
308
417
  export function applySetup(plan: SetupPlan, options: SetupContext = {}): SetupApplyResult {
418
+ if (plan.targets.includes("jcode")) assertJcodeHome(options.env ?? process.env);
419
+ if (plan.targets.includes("agy")) contextForPlan(plan, options);
309
420
  return withSetupLock(options, () => applyUnlocked(plan, options));
310
421
  }
311
422
  export function undoSetup(plan: SetupPlan, options: SetupContext = {}): { restored: SetupTarget[] } {
423
+ if (plan.targets.includes("jcode")) assertJcodeHome(options.env ?? process.env);
424
+ if (plan.targets.includes("agy")) contextForPlan(plan, options);
312
425
  return withSetupLock(options, () => undoUnlocked(plan, options));
313
426
  }
314
427