@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/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -7
- package/package.json +1 -1
- package/src/cli.ts +23 -12
- package/src/commands-table.ts +7 -6
- package/src/ingest.ts +98 -7
- package/src/mcp-memory.ts +42 -34
- package/src/memory/capture.ts +25 -7
- package/src/memory/commands.ts +13 -4
- package/src/memory/eval.ts +2 -2
- package/src/memory/import.ts +91 -0
- package/src/memory/platform.ts +5 -1
- package/src/memory/policy.ts +88 -9
- package/src/memory/search-fts.ts +59 -3
- package/src/memory/shard-sync-format.ts +3 -4
- package/src/memory/shard-sync.ts +135 -17
- 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/memory/shard-sync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
-
import { constants, closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, rmSync, writeSync } from "node:fs";
|
|
2
|
+
import { constants, closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { canonicalJSON, fsyncDirectory, hashJSON, readProfileJSON } from "./generation.ts";
|
|
5
5
|
import { generationSyncLock } from "./generation-sync.ts";
|
|
@@ -14,8 +14,14 @@ import type { Entry, MemoryStore } from "./store.ts";
|
|
|
14
14
|
const OID = /^[a-f0-9]{40}$/, PACK = /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/;
|
|
15
15
|
const digest = (bytes: string | Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
|
|
16
16
|
const zero = "0".repeat(40);
|
|
17
|
+
// The descriptor is at most 64 KiB and an original prune at most 16 KiB; keep receipt parsing bounded.
|
|
18
|
+
const HOLD_MAX_BYTES = 128 * 1024;
|
|
17
19
|
type PendingPrune = { plan: PrunePlan; row: PruneRow; line: string; offset: number; prefixHash: string };
|
|
18
|
-
type
|
|
20
|
+
type HoldSource = { ref: string; commit: string; pack: string; blob: string };
|
|
21
|
+
type HoldReceipt = { schema: "agent-mem.prune-hold/v1"; remote: string; realm: ShardSyncRealm; receivingGeneration: string; row: PruneRow; payloadHash: string; source: HoldSource };
|
|
22
|
+
type Hold = { file: string; bytes: Buffer };
|
|
23
|
+
type SyncPhase = "published" | "hold-written" | "hold-installed" | "imported";
|
|
24
|
+
type Target = { store: MemoryStore; realm: ShardSyncRealm; rows: Map<string, ShardSyncRow>; fetched: Set<string>; sources: PruneSourceRow[]; pending: PendingPrune[]; origins: Map<string, HoldSource>; holds: Hold[]; generation: string; local?: string; remote?: string; commit?: string };
|
|
19
25
|
class SyncRefusal extends Error {}
|
|
20
26
|
function refuse(reason: string): never { throw new SyncRefusal(reason); }
|
|
21
27
|
function directory(path: string): void {
|
|
@@ -146,8 +152,10 @@ function provePrunes(targets: readonly Target[]): void {
|
|
|
146
152
|
}
|
|
147
153
|
const snapshot: PruneSnapshot = { rows: [...sources.values()], expiries: [] };
|
|
148
154
|
const prunes = [...target.rows.values()].filter((row): row is PruneRow => row.op === "prune");
|
|
149
|
-
const
|
|
150
|
-
|
|
155
|
+
for (const generation of new Set(prunes.map(row => row.generation))) {
|
|
156
|
+
const proof = reducePruneRows(prunes.filter(row => row.generation === generation).map(row => ({ row, placement: "synced" as const })), snapshot, generation);
|
|
157
|
+
if (proof.invalid || proof.pending.length) refuse(`${shardSyncLabel(target.realm)}: prune lacks complete public witnesses`);
|
|
158
|
+
}
|
|
151
159
|
}
|
|
152
160
|
}
|
|
153
161
|
function installPack(store: MemoryStore, bytes: Buffer): void {
|
|
@@ -176,7 +184,7 @@ function importRows(target: Target): void {
|
|
|
176
184
|
for (const pending of target.pending) {
|
|
177
185
|
port.persistPrunePlan(pending.plan); port.appendPrune(pending.row, pending.line, pending.plan.seal); resumed.add(pending.row.id);
|
|
178
186
|
}
|
|
179
|
-
for (const row of target.rows.values()) if (row.op === "prune") {
|
|
187
|
+
for (const row of target.rows.values()) if (row.op === "prune" && row.generation === target.generation) {
|
|
180
188
|
if (resumed.has(row.id)) continue;
|
|
181
189
|
// A transport imports an already-authored person row without changing its author or generation.
|
|
182
190
|
// Native durable-plan append confirms fsync even for an already parsed id after a crash.
|
|
@@ -186,8 +194,104 @@ function importRows(target: Target): void {
|
|
|
186
194
|
port.refresh();
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
/**
|
|
190
|
-
|
|
197
|
+
/** Receipts are private, immutable transport metadata, outside every native generation reducer.
|
|
198
|
+
* ponytail: foreign targets remain visible; an explicit cross-generation apply protocol must
|
|
199
|
+
* re-prove witnesses and establish a compatible reader floor before lifting this hold ceiling. */
|
|
200
|
+
const hasHoldPath = (file: string): boolean => lstatSync(file, { throwIfNoEntry: false }) !== undefined;
|
|
201
|
+
function privateHoldPath(file: string, folder: boolean): void {
|
|
202
|
+
if (folder) directory(file); else regular(file);
|
|
203
|
+
if (lstatSync(file).mode & 0o077) refuse("shard sync prune holds require private permissions");
|
|
204
|
+
}
|
|
205
|
+
function checkPendingHold(file: string, bytes: Buffer): void {
|
|
206
|
+
privateHoldPath(file, false);
|
|
207
|
+
if (lstatSync(file).size > bytes.length || !readFileSync(file).equals(bytes.subarray(0, lstatSync(file).size))) refuse("shard sync partial prune hold conflicts with verified receipt");
|
|
208
|
+
}
|
|
209
|
+
function installHold(root: string, hold: Hold, phase?: (phase: SyncPhase) => void): void {
|
|
210
|
+
const parts = hold.file.slice(root.length + 1).split("/"), name = parts.pop()!;
|
|
211
|
+
let folder = root;
|
|
212
|
+
for (const part of parts) {
|
|
213
|
+
const parent = folder; folder = join(folder, part);
|
|
214
|
+
if (!hasHoldPath(folder)) mkdirSync(folder, { mode: 0o700 });
|
|
215
|
+
privateHoldPath(folder, true); fsyncDirectory(parent);
|
|
216
|
+
}
|
|
217
|
+
const file = join(folder, name), pending = file + ".pending";
|
|
218
|
+
if (hasHoldPath(file)) {
|
|
219
|
+
privateHoldPath(file, false);
|
|
220
|
+
if (!readFileSync(file).equals(hold.bytes)) refuse("shard sync immutable prune hold conflict");
|
|
221
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
222
|
+
fsyncDirectory(folder); return;
|
|
223
|
+
}
|
|
224
|
+
if (hasHoldPath(pending)) checkPendingHold(pending, hold.bytes);
|
|
225
|
+
const fd = openSync(pending, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
226
|
+
try {
|
|
227
|
+
// Interrupted private staging bytes may be completed; committed receipts are never rewritten.
|
|
228
|
+
let offset = 0; while (offset < hold.bytes.length) { const n = writeSync(fd, hold.bytes, offset, hold.bytes.length - offset, offset); if (n <= 0) refuse("shard sync prune hold write unavailable"); offset += n; }
|
|
229
|
+
fsyncSync(fd);
|
|
230
|
+
} finally { closeSync(fd); }
|
|
231
|
+
phase?.("hold-written");
|
|
232
|
+
// The transport mutation lock serializes this create-once rename. Unlike hard-link installation,
|
|
233
|
+
// a crash cannot leave a committed receipt with a second link that later fails validation.
|
|
234
|
+
if (hasHoldPath(file)) refuse("shard sync prune hold changed concurrently");
|
|
235
|
+
renameSync(pending, file); fsyncDirectory(folder); phase?.("hold-installed");
|
|
236
|
+
}
|
|
237
|
+
function holdWarning(targets: readonly Target[], root: string): string | undefined {
|
|
238
|
+
const warnings: string[] = [];
|
|
239
|
+
for (const target of targets) if (target.holds.length) {
|
|
240
|
+
const rows = [...target.rows.values()].filter((row): row is PruneRow => row.op === "prune" && row.generation !== target.generation);
|
|
241
|
+
const ids = [...new Set(rows.flatMap(row => row.targets.map(item => item.id)))].sort(), shown = ids.slice(0, 10);
|
|
242
|
+
const location = target.holds[0]!.file.slice(root.length + 1).split("/").slice(0, -2).join("/");
|
|
243
|
+
warnings.push(`${shardSyncLabel(target.realm)}: ${rows.length} foreign-generation prune(s) held, not applied; targets ${shown.join(",")}${ids.length > shown.length ? ` (+${ids.length - shown.length} more)` : ""} may remain visible; receipts shard-sync/${location}; bring this machine to the origin generation, or prune the entry here as the person`);
|
|
244
|
+
}
|
|
245
|
+
return warnings.length ? warnings.join("; ") : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
type ProveHoldSource = (target: Target, receipt: HoldReceipt) => Promise<void>;
|
|
249
|
+
async function prepareHold(root: string, url: string, target: Target, row: PruneRow, proveSource: ProveHoldSource): Promise<Hold> {
|
|
250
|
+
const source = target.origins.get(row.id);
|
|
251
|
+
if (!source || !target.remote || !target.fetched.has(row.id)) refuse(`${shardSyncLabel(target.realm)}: foreign prune has no fetched provenance`);
|
|
252
|
+
const receipt: HoldReceipt = { schema: "agent-mem.prune-hold/v1", remote: digest(url), realm: target.realm,
|
|
253
|
+
receivingGeneration: target.generation, row, payloadHash: hashJSON(row), source };
|
|
254
|
+
const folders = ["prune-holds", receipt.remote, digest(shardSyncRef(target.realm)), target.generation, row.generation];
|
|
255
|
+
let folder = root;
|
|
256
|
+
for (const name of folders) { folder = join(folder, name); if (hasHoldPath(folder)) privateHoldPath(folder, true); }
|
|
257
|
+
const file = join(folder, `${row.id}-${receipt.payloadHash}.json`);
|
|
258
|
+
let selected = receipt;
|
|
259
|
+
if (hasHoldPath(file)) {
|
|
260
|
+
privateHoldPath(file, false);
|
|
261
|
+
const value = readProfileJSON(file, HOLD_MAX_BYTES) as HoldReceipt | undefined;
|
|
262
|
+
if (!value || !value.source || Object.keys(value.source).length !== 4 || !OID.test(value.source.commit)
|
|
263
|
+
|| !OID.test(value.source.blob) || !PACK.test(value.source.pack)
|
|
264
|
+
|| canonicalJSON({ ...value, source }) !== canonicalJSON(receipt)) refuse(`${shardSyncLabel(target.realm)}: invalid prune hold receipt`);
|
|
265
|
+
// A receipt is evidence of a hold, never application authority. Recheck its original
|
|
266
|
+
// immutable source against this invocation's fully audited selected remote history.
|
|
267
|
+
await proveSource(target, value);
|
|
268
|
+
selected = value;
|
|
269
|
+
}
|
|
270
|
+
const bytes = Buffer.from(canonicalJSON(selected) + "\n");
|
|
271
|
+
if (bytes.length > HOLD_MAX_BYTES) refuse("shard sync prune hold exceeds metadata bound");
|
|
272
|
+
if (hasHoldPath(file) && !readFileSync(file).equals(bytes)) refuse("shard sync prune hold is not canonical");
|
|
273
|
+
if (hasHoldPath(file + ".pending")) checkPendingHold(file + ".pending", bytes);
|
|
274
|
+
return { file, bytes };
|
|
275
|
+
}
|
|
276
|
+
async function prepareHolds(targets: readonly Target[], root: string, url: string, proveSource: ProveHoldSource): Promise<void> {
|
|
277
|
+
for (const target of targets) {
|
|
278
|
+
target.holds = [];
|
|
279
|
+
for (const row of target.rows.values()) if (row.op === "prune" && row.generation !== target.generation) {
|
|
280
|
+
target.holds.push(await prepareHold(root, url, target, row, proveSource));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function mergePackOrigins(into: Map<string, HoldSource>, byRealm: Map<string, Map<string, HoldSource>>, rows: readonly ShardSyncRow[], source: HoldSource): void {
|
|
285
|
+
const first = byRealm.get(source.ref) ?? new Map<string, HoldSource>(); byRealm.set(source.ref, first);
|
|
286
|
+
// Oldest-first history keeps a receipt's source stable across tip advancement and partial-write retry.
|
|
287
|
+
for (const row of rows) {
|
|
288
|
+
if (!first.has(row.id)) first.set(row.id, source);
|
|
289
|
+
if (!into.has(row.id)) into.set(row.id, first.get(row.id)!);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Sync only borrowed canonical project/user targets. Internal error/phase observers are test-only, never CLI/SyncOptions ports. */
|
|
294
|
+
export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions = {}, testOnlyUnexpectedError?: (error: unknown) => void, testOnlyPhase?: (phase: SyncPhase) => void): Promise<SyncResult> {
|
|
191
295
|
let pushed = false, releaseTransport: (() => void) | undefined, releaseSync: (() => void) | undefined;
|
|
192
296
|
const releases: (() => void)[] = [], transient: string[] = [], temporary: string[] = [];
|
|
193
297
|
const fail = (error: string): SyncResult => ({ ok: false, pushed, entries: 0, error });
|
|
@@ -216,7 +320,7 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
216
320
|
targets = borrowed.map(opened => {
|
|
217
321
|
const profile = opened.profile;
|
|
218
322
|
if (!profile.sharded || profile.kind !== "generation" || !profile.generation || !profile.shardUser && !profile.shardKey) refuse("shard sync target identity unavailable");
|
|
219
|
-
return { store: opened, realm: profile.shardUser ? { scope: "user" as const } : { scope: "project" as const, key: profile.shardKey! }, rows: new Map<string, ShardSyncRow>(), fetched: new Set<string>(), sources: [], pending: [], generation: profile.generation };
|
|
323
|
+
return { store: opened, realm: profile.shardUser ? { scope: "user" as const } : { scope: "project" as const, key: profile.shardKey! }, rows: new Map<string, ShardSyncRow>(), fetched: new Set<string>(), sources: [], pending: [], origins: new Map<string, HoldSource>(), holds: [], generation: profile.generation };
|
|
220
324
|
});
|
|
221
325
|
if (!targets.length || targets.length > 2 || targets.some(target => target.store.profile.home !== store.profile.home)
|
|
222
326
|
|| new Set(targets.map(target => shardSyncRef(target.realm))).size !== targets.length
|
|
@@ -280,12 +384,13 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
280
384
|
if (identity !== object) refuse("shard sync public blob identity mismatch");
|
|
281
385
|
blobCache.set(object, bytes); return bytes;
|
|
282
386
|
};
|
|
283
|
-
type History = { rows: Map<string, ShardSyncRow>; files: Map<string, Buffer> };
|
|
387
|
+
type History = { rows: Map<string, ShardSyncRow>; files: Map<string, Buffer>; origins: Map<string, HoldSource> };
|
|
284
388
|
// Keep only requested tips. Per-commit full row maps would make append-only history quadratic in memory.
|
|
285
389
|
const histories = new Map<string, History>(), audited = new Set<string>();
|
|
390
|
+
const firstOrigins = new Map<string, Map<string, HoldSource>>();
|
|
286
391
|
const treeState = async (commit: string, target: Target): Promise<History> => {
|
|
287
392
|
const entries = (await required(["ls-tree", "-r", "-z", commit], "shard sync tree unavailable")).split("\0").filter(Boolean);
|
|
288
|
-
const rows = new Map<string, ShardSyncRow>(), files = new Map<string, Buffer>();
|
|
393
|
+
const rows = new Map<string, ShardSyncRow>(), files = new Map<string, Buffer>(), origins = new Map<string, HoldSource>();
|
|
289
394
|
for (const entry of entries) {
|
|
290
395
|
const match = /^100644 blob ([a-f0-9]{40})\t(.+)$/.exec(entry);
|
|
291
396
|
if (!match || match[2] !== "shard.json" && !PACK.test(match[2]!)) refuse(`${shardSyncLabel(target.realm)}: forbidden historical tree entry`);
|
|
@@ -298,10 +403,11 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
298
403
|
if (!pack) { pack = decodeShardPack(path, bytes, target.realm, target.generation); decoded.set(key, pack); }
|
|
299
404
|
else if (!path.endsWith(`/${digest(bytes)}.jsonl`) || pack.some(row => path !== `rows/${digest(row.machine)}/${digest(bytes)}.jsonl`)) refuse(`${shardSyncLabel(target.realm)}: immutable pack path changed`);
|
|
300
405
|
mergeRows(rows, pack, target.realm);
|
|
406
|
+
mergePackOrigins(origins, firstOrigins, pack, { ref: shardSyncRef(target.realm), commit, pack: path, blob: object });
|
|
301
407
|
}
|
|
302
408
|
}
|
|
303
409
|
if (!files.has("shard.json")) refuse(`${shardSyncLabel(target.realm)}: historical descriptor missing`);
|
|
304
|
-
return { rows, files };
|
|
410
|
+
return { rows, files, origins };
|
|
305
411
|
};
|
|
306
412
|
const history = async (tip: string, target: Target): Promise<History> => {
|
|
307
413
|
const key = `${shardSyncRef(target.realm)}:${tip}`, cached = histories.get(key); if (cached) return cached;
|
|
@@ -345,7 +451,7 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
345
451
|
if (target.local) await required(["merge-base", "--is-ancestor", target.local, tip], `${shardSyncLabel(target.realm)}: remote history is not fast-forward`);
|
|
346
452
|
const remote = await history(tip, target); target.remote = tip;
|
|
347
453
|
mergeRows(target.rows, Array.from(remote.rows.values()), target.realm);
|
|
348
|
-
for (const row of remote.rows.values()) target.fetched.add(row.id);
|
|
454
|
+
for (const row of remote.rows.values()) { target.fetched.add(row.id); target.origins.set(row.id, remote.origins.get(row.id)!); }
|
|
349
455
|
}
|
|
350
456
|
};
|
|
351
457
|
const localTargets = () => {
|
|
@@ -385,10 +491,17 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
385
491
|
await history(target.commit, target);
|
|
386
492
|
}
|
|
387
493
|
};
|
|
388
|
-
|
|
494
|
+
const proveHoldSource: ProveHoldSource = async (target, value) => {
|
|
495
|
+
await required(["merge-base", "--is-ancestor", value.source.commit, target.remote!], "shard sync prune hold source is not remote history");
|
|
496
|
+
const original = await history(value.source.commit, target), bytes = original.files.get(value.source.pack);
|
|
497
|
+
if (!bytes || createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex") !== value.source.blob
|
|
498
|
+
|| !decodeShardPack(value.source.pack, bytes, target.realm, target.generation).some(item => canonicalJSON(item) === canonicalJSON(value.row))
|
|
499
|
+
|| value.source.ref !== shardSyncRef(target.realm)) refuse(`${shardSyncLabel(target.realm)}: prune hold source mismatch`);
|
|
500
|
+
};
|
|
501
|
+
localTargets(); await fetchTargets(); localTargets(); provePrunes(targets); await prepareHolds(targets, root, url, proveHoldSource); check();
|
|
389
502
|
// Every selected realm, including its complete remote history/prunes, is preflighted before the first append or publication.
|
|
390
503
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
391
|
-
if (attempt) { await fetchTargets(); localTargets(); provePrunes(targets); check(); }
|
|
504
|
+
if (attempt) { await fetchTargets(); localTargets(); provePrunes(targets); await prepareHolds(targets, root, url, proveHoldSource); check(); }
|
|
392
505
|
await buildTargets(); check();
|
|
393
506
|
const sent = await git(["push", "--atomic", "--no-follow-tags", "-q", "origin", ...targets.map(target => `${target.commit!}:${shardSyncRef(target.realm)}`)]);
|
|
394
507
|
if (sent.code === 0) pushed = true;
|
|
@@ -396,13 +509,18 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
396
509
|
if (sent.code === 0) {
|
|
397
510
|
pushed = true;
|
|
398
511
|
// No local append before a successful atomic publication: a raced foreign prune can still refuse the entire retry safely.
|
|
399
|
-
localTargets(); provePrunes(targets); check();
|
|
512
|
+
localTargets(); provePrunes(targets); check(); testOnlyPhase?.("published");
|
|
513
|
+
// No selected ordinary/native imports can precede durable receipts for every foreign prune.
|
|
514
|
+
for (const target of targets) for (const hold of target.holds) installHold(root, hold, testOnlyPhase);
|
|
515
|
+
check();
|
|
400
516
|
for (const target of targets) {
|
|
401
517
|
assertCuratedShardPaths(target.store.profile.home, target.realm.scope === "user" ? { user: true } : { project: target.realm.key });
|
|
402
|
-
importRows(target);
|
|
518
|
+
importRows(target); testOnlyPhase?.("imported");
|
|
403
519
|
}
|
|
404
520
|
for (const target of targets) await required(["update-ref", "--no-deref", shardSyncRef(target.realm), target.commit!, target.local ?? zero], "shard sync cached ref changed concurrently");
|
|
405
|
-
|
|
521
|
+
const warning = holdWarning(targets, root);
|
|
522
|
+
return { ok: true, pushed, entries: targets.reduce((n, target) => n + [...target.rows.values()].filter(row => row.op === "note").length, 0),
|
|
523
|
+
...(warning ? { warning } : {}) };
|
|
406
524
|
}
|
|
407
525
|
if (opts.signal?.aborted) return fail("stopped");
|
|
408
526
|
}
|
|
@@ -50,7 +50,11 @@ if (import.meta.main) {
|
|
|
50
50
|
if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
|
|
51
51
|
const expectedScope = hookCaptureScope(options.cwd, options.base!);
|
|
52
52
|
let project: string;
|
|
53
|
-
|
|
53
|
+
// Overlap the shared resolver's existing origin/metadata reads on a cold cache;
|
|
54
|
+
// serial native launches can exhaust every hook before any binding or replay is published.
|
|
55
|
+
// ponytail: cold identity must still fit one hook deadline. Revalidated durable
|
|
56
|
+
// identity progress is the upgrade if slower native reads keep exhausting that slice.
|
|
57
|
+
try { project = resolveAlias(options.base!, await resolveHookProjectAsync({ cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining, maintainCache: event !== "SessionStart", prefetch: true }, nativeUsage)); }
|
|
54
58
|
catch {
|
|
55
59
|
if (expectedScope !== undefined) quarantineHookScope({ cwd: options.cwd, base: options.base!, scope: expectedScope });
|
|
56
60
|
project = stop();
|
package/src/setup-agy.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ConfigImage } from "./setup-owned.ts";
|
|
2
|
+
import { createJsonMcpCodec } from "./setup-json.ts";
|
|
3
|
+
|
|
4
|
+
// ponytail: Antigravity setup supports strict JSON only. JSONC needs a comment-aware
|
|
5
|
+
// lexical view and trailing-comma ownership before byte-preserving edits can support it.
|
|
6
|
+
const codec = createJsonMcpCodec({ canonicalKey: "mcpServers", label: "Antigravity strict JSON" });
|
|
7
|
+
export function readAgyMcp(image: ConfigImage): unknown { return codec.read(image); }
|
|
8
|
+
export function putAgyMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
|
|
9
|
+
export function restoreAgyMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ConfigImage } from "./setup-owned.ts";
|
|
2
|
+
import { createJsonMcpCodec } from "./setup-json.ts";
|
|
3
|
+
|
|
4
|
+
const codec = createJsonMcpCodec({ canonicalKey: "servers", aliases: ["mcpServers"], label: "Jcode" });
|
|
5
|
+
export function readJcodeMcp(image: ConfigImage): unknown { return codec.read(image); }
|
|
6
|
+
export function putJcodeMcp(image: ConfigImage, value: unknown): ConfigImage { return codec.put(image, value); }
|
|
7
|
+
export function restoreJcodeMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage { return codec.restore(before, after, current); }
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import type { ConfigImage } from "./setup-owned.ts";
|
|
3
|
+
import { SETUP_CONFIG_BYTES } from "./setup-toml.ts";
|
|
4
|
+
|
|
5
|
+
interface Member { key: string; keyStart: number; keyEnd: number; valueStart: number; valueEnd: number; comma?: number }
|
|
6
|
+
interface ObjectSpan { close: number; members: Member[] }
|
|
7
|
+
interface Document { text: string; root: ObjectSpan; container?: Member; servers?: ObjectSpan; owned?: Member }
|
|
8
|
+
interface Edit { start: number; end: number; text: string }
|
|
9
|
+
const decode = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
10
|
+
const same = (a: ConfigImage, b: ConfigImage) => a.bytes === b.bytes && a.mode === b.mode;
|
|
11
|
+
export interface JsonMcpCodecOptions { canonicalKey: string; aliases?: readonly string[]; label: string }
|
|
12
|
+
|
|
13
|
+
/** Strict JSON only. Shared lexical edits never re-render unrelated configuration data. */
|
|
14
|
+
export function createJsonMcpCodec(options: JsonMcpCodecOptions) {
|
|
15
|
+
const { canonicalKey, label } = options, keys = new Set([canonicalKey, ...(options.aliases ?? [])]);
|
|
16
|
+
const fail = (): never => { throw Error(`setup ${label} MCP configuration unavailable or ambiguous; no configuration changed`); };
|
|
17
|
+
const whitespace = (text: string, start: number) => { let at = start; while (at < text.length && /[ \t\r\n]/.test(text[at]!)) at++; return at; };
|
|
18
|
+
function stringEnd(text: string, start: number): number {
|
|
19
|
+
let at = start + 1;
|
|
20
|
+
while (at < text.length) { if (text[at] === "\\") at += 2; else if (text[at++] === '"') return at; }
|
|
21
|
+
return fail();
|
|
22
|
+
}
|
|
23
|
+
/** JSON.parse validates syntax first; this scanner retains only lexical edit boundaries. */
|
|
24
|
+
function valueEnd(text: string, start: number): number {
|
|
25
|
+
if (text[start] === '"') return stringEnd(text, start);
|
|
26
|
+
if (text[start] === "{" || text[start] === "[") {
|
|
27
|
+
let depth = 0, at = start;
|
|
28
|
+
while (at < text.length) {
|
|
29
|
+
const char = text[at]!;
|
|
30
|
+
if (char === '"') { at = stringEnd(text, at); continue; }
|
|
31
|
+
if (char === "{" || char === "[") depth++;
|
|
32
|
+
else if ((char === "}" || char === "]") && --depth === 0) return at + 1;
|
|
33
|
+
at++;
|
|
34
|
+
}
|
|
35
|
+
return fail();
|
|
36
|
+
}
|
|
37
|
+
let at = start; while (at < text.length && !/[ \t\r\n,}\]]/.test(text[at]!)) at++; return at;
|
|
38
|
+
}
|
|
39
|
+
function objectSpan(text: string, start: number): ObjectSpan {
|
|
40
|
+
if (text[start] !== "{") return fail();
|
|
41
|
+
const members: Member[] = []; let at = whitespace(text, start + 1);
|
|
42
|
+
while (text[at] !== "}") {
|
|
43
|
+
const keyStart = at, keyEnd = stringEnd(text, at), key: unknown = JSON.parse(text.slice(keyStart, keyEnd));
|
|
44
|
+
if (typeof key !== "string") return fail();
|
|
45
|
+
const valueStart = whitespace(text, whitespace(text, keyEnd) + 1), end = valueEnd(text, valueStart), next = whitespace(text, end);
|
|
46
|
+
const comma = text[next] === "," ? next : undefined;
|
|
47
|
+
members.push({ key, keyStart, keyEnd, valueStart, valueEnd: end, ...(comma === undefined ? {} : { comma }) });
|
|
48
|
+
at = comma === undefined ? next : whitespace(text, comma + 1);
|
|
49
|
+
}
|
|
50
|
+
return { close: at, members };
|
|
51
|
+
}
|
|
52
|
+
/** Only the owned value requires recursive key uniqueness; unrelated JSON stays opaque. */
|
|
53
|
+
function uniqueOwnedKeys(text: string, member: Member): void {
|
|
54
|
+
const objects: (Set<string> | null)[] = [];
|
|
55
|
+
let at = member.valueStart;
|
|
56
|
+
while (at < member.valueEnd) {
|
|
57
|
+
const char = text[at]!;
|
|
58
|
+
if (char === '"') {
|
|
59
|
+
const end = stringEnd(text, at), current = objects.at(-1);
|
|
60
|
+
if (text[whitespace(text, end)] === ":" && current) {
|
|
61
|
+
const key = JSON.parse(text.slice(at, end)) as string;
|
|
62
|
+
if (current.has(key)) return fail();
|
|
63
|
+
current.add(key);
|
|
64
|
+
}
|
|
65
|
+
at = end;
|
|
66
|
+
} else {
|
|
67
|
+
if (char === "{") objects.push(new Set());
|
|
68
|
+
else if (char === "[") objects.push(null);
|
|
69
|
+
else if (char === "}" || char === "]") objects.pop();
|
|
70
|
+
at++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function document(image: ConfigImage): Document {
|
|
75
|
+
let text = "{}";
|
|
76
|
+
if (image.bytes !== null) {
|
|
77
|
+
if (image.bytes.length > Math.ceil(SETUP_CONFIG_BYTES / 3) * 4) return fail();
|
|
78
|
+
const bytes = Buffer.from(image.bytes, "base64");
|
|
79
|
+
if (bytes.toString("base64") !== image.bytes || bytes.length > SETUP_CONFIG_BYTES) return fail();
|
|
80
|
+
try { text = decode.decode(bytes); } catch { return fail(); }
|
|
81
|
+
}
|
|
82
|
+
let value: unknown; try { value = JSON.parse(text); } catch { return fail(); }
|
|
83
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return fail();
|
|
84
|
+
const root = objectSpan(text, whitespace(text, 0));
|
|
85
|
+
const containers = root.members.filter(member => keys.has(member.key));
|
|
86
|
+
if (containers.length > 1) return fail();
|
|
87
|
+
const container = containers[0]; if (!container) return { text, root };
|
|
88
|
+
const servers = objectSpan(text, container.valueStart), owned = servers.members.filter(member => member.key === "amem");
|
|
89
|
+
if (owned.length > 1) return fail();
|
|
90
|
+
if (owned[0]) uniqueOwnedKeys(text, owned[0]);
|
|
91
|
+
return { text, root, container, servers, ...(owned[0] ? { owned: owned[0] } : {}) };
|
|
92
|
+
}
|
|
93
|
+
const entry = (doc: Document): unknown => doc.owned ? JSON.parse(doc.text.slice(doc.owned.valueStart, doc.owned.valueEnd)) : undefined;
|
|
94
|
+
function readMcp(image: ConfigImage): unknown { return entry(document(image)); }
|
|
95
|
+
|
|
96
|
+
function insert(object: ObjectSpan, text: string): Edit {
|
|
97
|
+
const last = object.members.at(-1);
|
|
98
|
+
const at = last?.valueEnd ?? object.close;
|
|
99
|
+
return { start: at, end: at, text: `${last ? "," : ""}${text}` };
|
|
100
|
+
}
|
|
101
|
+
function remove(object: ObjectSpan, member: Member): Edit {
|
|
102
|
+
if (member.comma !== undefined) return { start: member.keyStart, end: member.comma + 1, text: "" };
|
|
103
|
+
const previous = object.members[object.members.indexOf(member) - 1];
|
|
104
|
+
return { start: previous?.comma ?? member.keyStart, end: member.valueEnd, text: "" };
|
|
105
|
+
}
|
|
106
|
+
function publish(image: ConfigImage, doc: Document, edits: Edit[]): ConfigImage {
|
|
107
|
+
let text = doc.text;
|
|
108
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
|
|
109
|
+
if (text === doc.text) return image;
|
|
110
|
+
if (Buffer.byteLength(text) > SETUP_CONFIG_BYTES) return fail();
|
|
111
|
+
const result = { bytes: Buffer.from(text).toString("base64"), mode: image.mode };
|
|
112
|
+
document(result); return result;
|
|
113
|
+
}
|
|
114
|
+
function put(image: ConfigImage, value: unknown, options: { key?: string; keepEmpty?: boolean; rawValue?: string; emptyContainer?: string } = {}): ConfigImage {
|
|
115
|
+
const doc = document(image), edits: Edit[] = [], key = options.key ?? canonicalKey;
|
|
116
|
+
if (!doc.container) {
|
|
117
|
+
if (value === undefined && !options.keepEmpty) return image;
|
|
118
|
+
const body = value === undefined ? options.emptyContainer ?? "{}" : `{"amem":${options.rawValue ?? JSON.stringify(value)}}`;
|
|
119
|
+
edits.push(insert(doc.root, `${JSON.stringify(key)}:${body}`));
|
|
120
|
+
} else if (value === undefined && !options.keepEmpty && doc.servers!.members.every(member => member.key === "amem")) {
|
|
121
|
+
edits.push(remove(doc.root, doc.container));
|
|
122
|
+
} else {
|
|
123
|
+
if (doc.container.key !== key) edits.push({ start: doc.container.keyStart, end: doc.container.keyEnd, text: JSON.stringify(key) });
|
|
124
|
+
if (value === undefined && doc.owned) edits.push(remove(doc.servers!, doc.owned));
|
|
125
|
+
else if (value !== undefined && !isDeepStrictEqual(entry(doc), value)) {
|
|
126
|
+
const rendered = options.rawValue ?? JSON.stringify(value); if (rendered === undefined) return fail();
|
|
127
|
+
edits.push(doc.owned ? { start: doc.owned.valueStart, end: doc.owned.valueEnd, text: rendered } : insert(doc.servers!, `"amem":${rendered}`));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return publish(image, doc, edits);
|
|
131
|
+
}
|
|
132
|
+
/** Canonicalize only the owned container key; all unrelated bytes remain untouched. */
|
|
133
|
+
function putMcp(image: ConfigImage, value: unknown): ConfigImage { return put(image, value); }
|
|
134
|
+
|
|
135
|
+
/** Restore the owned entry (and our alias rename), preserving unrelated lexical edits. */
|
|
136
|
+
function restoreMcp(before: ConfigImage, after: ConfigImage, current: ConfigImage): ConfigImage {
|
|
137
|
+
const original = document(before), installed = document(after), live = document(current);
|
|
138
|
+
if (same(current, after)) return before;
|
|
139
|
+
if (same(current, before)) return current;
|
|
140
|
+
const previous = entry(original), expected = entry(installed), present = entry(live);
|
|
141
|
+
const previousRestored = isDeepStrictEqual(present, previous);
|
|
142
|
+
const pendingLayout = !!original.container && !!installed.container && original.container.key !== installed.container.key && live.container?.key === installed.container.key;
|
|
143
|
+
if (previousRestored && !pendingLayout) return current;
|
|
144
|
+
if (!previousRestored && !isDeepStrictEqual(present, expected)) throw Error(`setup-owned ${label} MCP entry changed; refusing to overwrite it`);
|
|
145
|
+
const restored = put(current, previous, { key: original.container?.key ?? canonicalKey, keepEmpty: !!original.container && !original.servers!.members.length,
|
|
146
|
+
...(original.owned ? { rawValue: original.text.slice(original.owned.valueStart, original.owned.valueEnd) } : {}),
|
|
147
|
+
...(original.container && !original.servers!.members.length ? { emptyContainer: original.text.slice(original.container.valueStart, original.container.valueEnd) } : {}) });
|
|
148
|
+
return before.bytes === null && !document(restored).root.members.length ? { bytes: null, mode: current.mode } : restored;
|
|
149
|
+
}
|
|
150
|
+
return { read: readMcp, put: putMcp, restore: restoreMcp };
|
|
151
|
+
}
|
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));
|