@staix/agent-mem 0.9.1 → 0.11.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/.claude-plugin/plugin.json +1 -1
- package/README.md +17 -5
- package/package.json +1 -1
- package/src/cli.ts +22 -9
- package/src/commands-table.ts +5 -4
- package/src/index.ts +1 -0
- package/src/ingest.ts +98 -7
- package/src/mcp-memory.ts +47 -35
- package/src/mcp.ts +1 -1
- package/src/memory/candidates.ts +44 -1
- package/src/memory/capture.ts +24 -6
- package/src/memory/commands.ts +23 -4
- package/src/memory/consolidate.ts +9 -6
- package/src/memory/consolidation-clusters.ts +12 -7
- package/src/memory/eval-fixture.ts +7 -3
- package/src/memory/eval.ts +17 -8
- package/src/memory/external-import.ts +8 -1
- package/src/memory/platform.ts +8 -8
- package/src/memory/policy.ts +62 -3
- package/src/memory/project-import.ts +31 -14
- package/src/memory/search-fts.ts +68 -3
- package/src/memory/search-score.ts +66 -0
- package/src/memory/store.ts +40 -13
- package/src/session-brief-worker.ts +5 -1
- package/src/setup-agy.ts +9 -0
- package/src/setup-jcode.ts +7 -0
- package/src/setup-json.ts +151 -0
- package/src/setup-owned.ts +40 -19
- package/src/setup.ts +147 -34
package/src/setup-owned.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import { SETUP_CONFIG_BYTES, setTomlPath, updateTomlArray } from "./setup-toml.ts";
|
|
3
|
+
import { readJcodeMcp, putJcodeMcp, restoreJcodeMcp } from "./setup-jcode.ts";
|
|
4
|
+
import { readAgyMcp, putAgyMcp, restoreAgyMcp } from "./setup-agy.ts";
|
|
3
5
|
export interface ConfigImage { bytes: string | null; mode: number }
|
|
4
6
|
export const sameImage = (a: ConfigImage, b: ConfigImage): boolean => a.bytes === b.bytes && a.mode === b.mode;
|
|
5
7
|
const text = (image: ConfigImage) => image.bytes === null ? "" : new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(image.bytes, "base64"));
|
|
@@ -15,10 +17,14 @@ function finishJson(value: Record<string, unknown>, current: ConfigImage, origin
|
|
|
15
17
|
return rendered(output, current);
|
|
16
18
|
}
|
|
17
19
|
export function mcpRecord(target: string, image: ConfigImage): unknown {
|
|
20
|
+
if (target === "agy") return readAgyMcp(image);
|
|
21
|
+
if (target === "jcode") return readJcodeMcp(image);
|
|
18
22
|
const doc = target === "codex" ? toml(image) : json(image), servers = doc[target === "codex" ? "mcp_servers" : "mcpServers"];
|
|
19
23
|
return servers === undefined ? undefined : object(servers).amem;
|
|
20
24
|
}
|
|
21
25
|
export function putMcpRecord(target: string, current: ConfigImage, value: unknown): ConfigImage {
|
|
26
|
+
if (target === "agy") return putAgyMcp(current, value);
|
|
27
|
+
if (target === "jcode") return putJcodeMcp(current, value);
|
|
22
28
|
if (target === "codex") return rendered(setTomlPath(text(current), ["mcp_servers", "amem"], value), current);
|
|
23
29
|
const doc = json(current), servers = { ...object(doc.mcpServers ?? {}) };
|
|
24
30
|
if (value === undefined) delete servers.amem; else servers.amem = value;
|
|
@@ -26,19 +32,24 @@ export function putMcpRecord(target: string, current: ConfigImage, value: unknow
|
|
|
26
32
|
return finishJson(doc, current, { bytes: null, mode: current.mode });
|
|
27
33
|
}
|
|
28
34
|
interface Hook { event: string; matcher: string; core: Record<string, unknown>; id: string; command: string }
|
|
35
|
+
// QwenLM/qwen-code 1ce1731 packages/core/src/hooks/types.ts HOOKS_CONFIG_FIELDS.
|
|
36
|
+
// These are user-owned controls, not event groups; setup never enables or restores them.
|
|
37
|
+
const qwenHookControls = new Set(["enabled", "disabled", "notifications"]);
|
|
29
38
|
const matcher = (value: unknown) => value === undefined || value === "" || value === "*" ? "*" : String(value);
|
|
30
|
-
function hook(event: string, group: Record<string, unknown>, value: unknown): Hook | undefined {
|
|
39
|
+
function hook(event: string, group: Record<string, unknown>, value: unknown, strict = false): Hook | undefined {
|
|
31
40
|
const h = object(value);
|
|
32
|
-
const core = h.type === "command" ? { type: h.type, command: h.command, ...(h.timeout !== undefined ? { timeout: h.timeout } : {}) }
|
|
41
|
+
const core = h.type === "command" && !strict ? { type: h.type, command: h.command, ...(h.timeout !== undefined ? { timeout: h.timeout } : {}) }
|
|
33
42
|
: Object.fromEntries(Object.entries(h).filter(([key]) => !["trusted_hash", "trustedHash"].includes(key)).sort(([a], [b]) => a.localeCompare(b)));
|
|
34
43
|
const match = matcher(group.matcher);
|
|
35
|
-
|
|
44
|
+
const groupCore = strict ? Object.fromEntries(Object.entries(group).filter(([key]) => !["matcher", "hooks"].includes(key)).sort(([a], [b]) => a.localeCompare(b))) : undefined;
|
|
45
|
+
return { event, matcher: match, core, command: typeof h.command === "string" ? h.command : "", id: JSON.stringify([event, match, core, ...(strict ? [groupCore] : [])]) };
|
|
36
46
|
}
|
|
37
|
-
function hooks(doc: Record<string, unknown
|
|
47
|
+
function hooks(doc: Record<string, unknown>, strict = false): Hook[] {
|
|
38
48
|
const out: Hook[] = [];
|
|
39
49
|
for (const [event, raw] of Object.entries(object(doc.hooks ?? {}))) {
|
|
50
|
+
if (strict && qwenHookControls.has(event)) continue;
|
|
40
51
|
if (!Array.isArray(raw)) throw Error("setup hook groups must be arrays");
|
|
41
|
-
for (const value of raw) { const group = object(value); if (!Array.isArray(group.hooks)) throw Error("setup hook handlers must be arrays"); for (const h of group.hooks) { const row = hook(event, group, h); if (row) out.push(row); } }
|
|
52
|
+
for (const value of raw) { const group = object(value); if (!Array.isArray(group.hooks)) throw Error("setup hook handlers must be arrays"); for (const h of group.hooks) { const row = hook(event, group, h, strict); if (row) out.push(row); } }
|
|
42
53
|
}
|
|
43
54
|
return out;
|
|
44
55
|
}
|
|
@@ -46,8 +57,8 @@ function added<T>(before: T[], after: T[], key: (value: T) => string): T[] {
|
|
|
46
57
|
const counts = new Map<string, number>(); for (const row of before) counts.set(key(row), (counts.get(key(row)) ?? 0) + 1);
|
|
47
58
|
return after.filter(row => { const id = key(row), n = counts.get(id) ?? 0; if (!n) return true; counts.set(id, n - 1); return false; });
|
|
48
59
|
}
|
|
49
|
-
function restoreJsonHooks(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
|
|
50
|
-
const b = json(before), a = json(after), c = json(current), original = hooks(b), installed = hooks(a), live = hooks(c);
|
|
60
|
+
function restoreJsonHooks(before: ConfigImage, after: ConfigImage, current: ConfigImage, strict = false): ConfigImage {
|
|
61
|
+
const b = json(before), a = json(after), c = json(current), original = hooks(b, strict), installed = hooks(a, strict), live = hooks(c, strict);
|
|
51
62
|
const ours = added(original, installed, row => row.id), remaining = new Map<string, number>();
|
|
52
63
|
for (const row of ours) {
|
|
53
64
|
const count = live.filter(value => value.id === row.id).length;
|
|
@@ -56,11 +67,12 @@ function restoreJsonHooks(before: ConfigImage, after: ConfigImage, current: Conf
|
|
|
56
67
|
}
|
|
57
68
|
const groups = object(c.hooks ?? {});
|
|
58
69
|
for (const [event, raw] of Object.entries(groups)) {
|
|
70
|
+
if (strict && qwenHookControls.has(event)) continue;
|
|
59
71
|
const next: unknown[] = [];
|
|
60
72
|
for (const value of raw as unknown[]) {
|
|
61
73
|
const group = object(value); let removed = false;
|
|
62
74
|
const handlers = (group.hooks as unknown[]).filter(h => {
|
|
63
|
-
const row = hook(event, group, h), n = row && (remaining.get(row.id) ?? 0);
|
|
75
|
+
const row = hook(event, group, h, strict), n = row && (remaining.get(row.id) ?? 0);
|
|
64
76
|
if (!row || !n) return true; remaining.set(row.id, n - 1); removed = true; return false;
|
|
65
77
|
});
|
|
66
78
|
if (handlers.length || !removed) next.push({ ...group, hooks: handlers });
|
|
@@ -88,21 +100,26 @@ function restoreKimi(before: ConfigImage, after: ConfigImage, current: ConfigIma
|
|
|
88
100
|
if (before.bytes === null && Object.keys(object(Bun.TOML.parse(result))).length === 0) return { ...before, mode: current.mode };
|
|
89
101
|
return rendered(result, current);
|
|
90
102
|
}
|
|
103
|
+
function restoreMcpOwned(target: string, before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
|
|
104
|
+
const b = mcpRecord(target, before), a = mcpRecord(target, after), c = mcpRecord(target, current);
|
|
105
|
+
if (isDeepStrictEqual(c, b) && !(target === "codex" && b === undefined && toml(before).mcp_servers === undefined && toml(current).mcp_servers !== undefined && Object.keys(object(toml(current).mcp_servers)).length === 0)) return current;
|
|
106
|
+
if (c !== undefined && !isDeepStrictEqual(c, a)) throw Error("setup-owned MCP entry changed; refusing to overwrite it");
|
|
107
|
+
let result = putMcpRecord(target, current, b);
|
|
108
|
+
if (target === "codex" && b === undefined && toml(before).mcp_servers === undefined && Object.keys(object(toml(result).mcp_servers ?? {})).length === 0) result = rendered(setTomlPath(text(result), ["mcp_servers"], undefined), current);
|
|
109
|
+
if (target !== "codex" && json(before).mcpServers !== undefined && json(result).mcpServers === undefined) { const value = json(result); value.mcpServers = {}; result = rendered(JSON.stringify(value, null, 2) + "\n", current); }
|
|
110
|
+
if (result.bytes === null && before.bytes !== null) return target === "codex" ? rendered("", current) : finishJson({}, current, before);
|
|
111
|
+
if (before.bytes === null && Object.keys(target === "codex" ? toml(result) : json(result)).length === 0) return { ...before, mode: current.mode };
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
91
114
|
/** Exact restore when untouched; otherwise reverse only this receipt's managed entry/rows. */
|
|
92
115
|
export function restoreOwned(target: string, index: number, before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
|
|
116
|
+
if (target === "agy" && index === 0) return restoreAgyMcp(before, after, current);
|
|
117
|
+
if (target === "jcode" && index === 0) return restoreJcodeMcp(before, after, current);
|
|
93
118
|
if (sameImage(current, after)) return before;
|
|
94
119
|
if (sameImage(current, before)) return current;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (c !== undefined && !isDeepStrictEqual(c, a)) throw Error("setup-owned MCP entry changed; refusing to overwrite it");
|
|
99
|
-
let result = putMcpRecord(target, current, b);
|
|
100
|
-
if (target === "codex" && b === undefined && toml(before).mcp_servers === undefined && Object.keys(object(toml(result).mcp_servers ?? {})).length === 0) result = rendered(setTomlPath(text(result), ["mcp_servers"], undefined), current);
|
|
101
|
-
if (target !== "codex" && json(before).mcpServers !== undefined && json(result).mcpServers === undefined) { const value = json(result); value.mcpServers = {}; result = rendered(JSON.stringify(value, null, 2) + "\n", current); }
|
|
102
|
-
if (result.bytes === null && before.bytes !== null) return target === "codex" ? rendered("", current) : finishJson({}, current, before);
|
|
103
|
-
if (before.bytes === null && Object.keys(target === "codex" ? toml(result) : json(result)).length === 0) return { ...before, mode: current.mode };
|
|
104
|
-
return result;
|
|
105
|
-
}
|
|
120
|
+
// Qwen keeps both managed surfaces in one settings file and one receipt image.
|
|
121
|
+
if (target === "qwen" && index === 0) return restoreJsonHooks(before, after, restoreMcpOwned(target, before, after, current), true);
|
|
122
|
+
if (index === 0) return restoreMcpOwned(target, before, after, current);
|
|
106
123
|
if (target === "claude" || target === "codex") return restoreJsonHooks(before, after, current);
|
|
107
124
|
if (target === "kimi") return restoreKimi(before, after, current);
|
|
108
125
|
if (current.bytes === after.bytes) return { ...before, mode: current.mode };
|
|
@@ -110,6 +127,10 @@ export function restoreOwned(target: string, index: number, before: ConfigImage,
|
|
|
110
127
|
throw Error("setup-owned Pi extension changed; refusing to overwrite it");
|
|
111
128
|
}
|
|
112
129
|
export function managedAfter(target: string, index: number, before: ConfigImage, after: ConfigImage, current: ConfigImage): boolean {
|
|
130
|
+
if (target === "qwen" && index === 0) {
|
|
131
|
+
const needed = added(hooks(json(before), true), hooks(json(after), true), row => row.id), live = hooks(json(current), true);
|
|
132
|
+
return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current)) && needed.every(row => live.some(value => value.id === row.id));
|
|
133
|
+
}
|
|
113
134
|
if (index === 0) return isDeepStrictEqual(mcpRecord(target, after), mcpRecord(target, current));
|
|
114
135
|
if (target === "claude" || target === "codex") {
|
|
115
136
|
const needed = added(hooks(json(before)), hooks(json(after)), row => row.id), live = hooks(json(current));
|
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
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
const
|
|
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
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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
|
|