@oai404iao/pi-subagent 0.2.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/LICENSE +21 -0
- package/LICENSES/DeepSeek-Harness-MIT.txt +21 -0
- package/README.md +357 -0
- package/THIRD_PARTY_NOTICES.md +27 -0
- package/agents/planner.md +8 -0
- package/agents/reviewer.md +8 -0
- package/agents/scout.md +8 -0
- package/agents/worker.md +8 -0
- package/config.example.json +11 -0
- package/config.schema.json +61 -0
- package/package.json +88 -0
- package/provenance/deepseek-harness-4d03472.json +20 -0
- package/src/agent-sync.ts +583 -0
- package/src/agents.ts +174 -0
- package/src/catalog.ts +89 -0
- package/src/config.ts +182 -0
- package/src/coordinator.ts +1393 -0
- package/src/descriptor.ts +189 -0
- package/src/index.ts +386 -0
- package/src/providers.ts +139 -0
- package/src/render.ts +109 -0
- package/src/result.ts +131 -0
- package/src/schemas.ts +130 -0
- package/src/tool-policy.ts +131 -0
- package/src/types.ts +154 -0
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
readdirSync,
|
|
9
|
+
readlinkSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
symlinkSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from "node:fs";
|
|
15
|
+
import { hostname } from "node:os";
|
|
16
|
+
import { basename, dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const MANIFEST_VERSION = 1;
|
|
19
|
+
const STATE_DIR_NAME = ".pi-subagent";
|
|
20
|
+
const MANIFEST_FILE_NAME = "agents-manifest.json";
|
|
21
|
+
const BACKUPS_DIR_NAME = "backups";
|
|
22
|
+
const LOCK_DIR_NAME = "sync.lock";
|
|
23
|
+
const RECLAIM_LOCK_DIR_NAME = "sync.reclaim.lock";
|
|
24
|
+
const LOCK_OWNER_FILE_NAME = "owner.json";
|
|
25
|
+
const LOCK_RETRY_MS = 25;
|
|
26
|
+
const LOCK_TIMEOUT_MS = 30_000;
|
|
27
|
+
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
28
|
+
|
|
29
|
+
interface AgentManifest {
|
|
30
|
+
version: 1;
|
|
31
|
+
packageVersion: string;
|
|
32
|
+
files: Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface BundledAgentFile {
|
|
36
|
+
name: string;
|
|
37
|
+
content: Buffer;
|
|
38
|
+
hash: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type DestinationKind = "missing" | "file" | "symlink";
|
|
42
|
+
|
|
43
|
+
interface PlannedAction {
|
|
44
|
+
name: string;
|
|
45
|
+
kind: "install" | "replace" | "remove";
|
|
46
|
+
destination: string;
|
|
47
|
+
destinationKind: DestinationKind;
|
|
48
|
+
content?: Buffer;
|
|
49
|
+
stagedPath?: string;
|
|
50
|
+
backupPath?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AgentSyncOptions {
|
|
54
|
+
bundledDir: string;
|
|
55
|
+
agentDir: string;
|
|
56
|
+
packageRoot?: string;
|
|
57
|
+
packageVersion?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AgentBackup {
|
|
61
|
+
name: string;
|
|
62
|
+
path: string;
|
|
63
|
+
kind: "file" | "symlink";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AgentSyncResult {
|
|
67
|
+
packageVersion: string;
|
|
68
|
+
userAgentsDir: string;
|
|
69
|
+
manifestPath: string;
|
|
70
|
+
installed: string[];
|
|
71
|
+
updated: string[];
|
|
72
|
+
removed: string[];
|
|
73
|
+
preserved: string[];
|
|
74
|
+
backups: AgentBackup[];
|
|
75
|
+
diagnostics: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type UnknownRecord = Record<string, unknown>;
|
|
79
|
+
|
|
80
|
+
function asRecord(value: unknown, field: string): UnknownRecord {
|
|
81
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
82
|
+
throw new Error(`${field} must be an object`);
|
|
83
|
+
}
|
|
84
|
+
return value as UnknownRecord;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readPackageVersion(packageRoot: string): string {
|
|
88
|
+
const packagePath = join(packageRoot, "package.json");
|
|
89
|
+
let parsed: unknown;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(readFileSync(packagePath, "utf8"));
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`${packagePath}: cannot read package version: ${
|
|
95
|
+
error instanceof Error ? error.message : String(error)
|
|
96
|
+
}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const record = asRecord(parsed, packagePath);
|
|
100
|
+
if (typeof record.version !== "string" || record.version.trim().length === 0) {
|
|
101
|
+
throw new Error(`${packagePath}: package version must be a non-empty string`);
|
|
102
|
+
}
|
|
103
|
+
return record.version.trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function resolvePackageVersion(options: AgentSyncOptions): string {
|
|
107
|
+
if (options.packageVersion) return options.packageVersion;
|
|
108
|
+
if (options.packageRoot) return readPackageVersion(options.packageRoot);
|
|
109
|
+
throw new Error("syncBundledAgents requires packageVersion or packageRoot");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parseManifest(value: unknown, manifestPath: string): AgentManifest {
|
|
113
|
+
const input = asRecord(value, manifestPath);
|
|
114
|
+
if (input.version !== MANIFEST_VERSION) {
|
|
115
|
+
throw new Error(`unsupported manifest version: ${String(input.version)}`);
|
|
116
|
+
}
|
|
117
|
+
if (typeof input.packageVersion !== "string" || input.packageVersion.trim().length === 0) {
|
|
118
|
+
throw new Error("packageVersion must be a non-empty string");
|
|
119
|
+
}
|
|
120
|
+
const rawFiles = asRecord(input.files, "files");
|
|
121
|
+
const files: Record<string, string> = {};
|
|
122
|
+
for (const [name, hash] of Object.entries(rawFiles)) {
|
|
123
|
+
if (
|
|
124
|
+
basename(name) !== name ||
|
|
125
|
+
!name.endsWith(".md") ||
|
|
126
|
+
typeof hash !== "string" ||
|
|
127
|
+
!SHA256_PATTERN.test(hash)
|
|
128
|
+
) {
|
|
129
|
+
throw new Error(`files contains an invalid entry for "${name}"`);
|
|
130
|
+
}
|
|
131
|
+
files[name] = hash;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
version: MANIFEST_VERSION,
|
|
135
|
+
packageVersion: input.packageVersion.trim(),
|
|
136
|
+
files,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hash(content: Buffer): string {
|
|
141
|
+
return createHash("sha256").update(content).digest("hex");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function safeSegment(value: string): string {
|
|
145
|
+
const sanitized = value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
146
|
+
return sanitized || "unknown";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function timestamp(): string {
|
|
150
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stageFile(filePath: string, content: string | Buffer): string {
|
|
154
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
155
|
+
const tempPath = join(
|
|
156
|
+
dirname(filePath),
|
|
157
|
+
`.${basename(filePath)}.tmp-${process.pid}-${randomUUID()}`,
|
|
158
|
+
);
|
|
159
|
+
writeFileSync(tempPath, content);
|
|
160
|
+
return tempPath;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function bundledAgentFiles(bundledDir: string): BundledAgentFile[] {
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(bundledDir, { withFileTypes: true });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`${bundledDir}: cannot read bundled agents: ${
|
|
170
|
+
error instanceof Error ? error.message : String(error)
|
|
171
|
+
}`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const files = entries
|
|
175
|
+
.filter((entry) => entry.name.endsWith(".md") && (entry.isFile() || entry.isSymbolicLink()))
|
|
176
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
177
|
+
.map((entry) => {
|
|
178
|
+
const content = readFileSync(join(bundledDir, entry.name));
|
|
179
|
+
return { name: entry.name, content, hash: hash(content) };
|
|
180
|
+
});
|
|
181
|
+
if (files.length === 0) throw new Error(`${bundledDir}: no bundled agent definitions were found`);
|
|
182
|
+
return files;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readPreviousManifest(manifestPath: string): AgentManifest | undefined {
|
|
186
|
+
if (!existsSync(manifestPath)) return undefined;
|
|
187
|
+
try {
|
|
188
|
+
return parseManifest(JSON.parse(readFileSync(manifestPath, "utf8")), manifestPath);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
const corruptPath = `${manifestPath}.corrupt-${timestamp()}-${randomUUID().slice(0, 8)}`;
|
|
191
|
+
copyFileSync(manifestPath, corruptPath);
|
|
192
|
+
throw new Error(
|
|
193
|
+
`${manifestPath}: invalid manifest; a copy was preserved at ${corruptPath}: ${
|
|
194
|
+
error instanceof Error ? error.message : String(error)
|
|
195
|
+
}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Identify untouched files created by the old opt-out synchronizer without
|
|
202
|
+
* changing the user filesystem. Direct bundled discovery can then use newer
|
|
203
|
+
* package definitions while real user edits continue to override them.
|
|
204
|
+
*/
|
|
205
|
+
export function unmodifiedManagedAgentNames(agentDir: string): Set<string> {
|
|
206
|
+
const manifestPath = join(agentDir, STATE_DIR_NAME, MANIFEST_FILE_NAME);
|
|
207
|
+
if (!existsSync(manifestPath)) return new Set();
|
|
208
|
+
|
|
209
|
+
let manifest: AgentManifest;
|
|
210
|
+
try {
|
|
211
|
+
manifest = parseManifest(JSON.parse(readFileSync(manifestPath, "utf8")), manifestPath);
|
|
212
|
+
} catch {
|
|
213
|
+
// A malformed historical manifest must never cause a default read-only
|
|
214
|
+
// session to hide user files or rewrite state.
|
|
215
|
+
return new Set();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const unmodified = new Set<string>();
|
|
219
|
+
const userAgentsDir = join(agentDir, "agents");
|
|
220
|
+
for (const [name, expectedHash] of Object.entries(manifest.files)) {
|
|
221
|
+
try {
|
|
222
|
+
const path = join(userAgentsDir, name);
|
|
223
|
+
if (lstatSync(path).isFile() && hash(readFileSync(path)) === expectedHash) {
|
|
224
|
+
unmodified.add(name);
|
|
225
|
+
}
|
|
226
|
+
} catch {
|
|
227
|
+
// Missing, unreadable, or replaced paths are user-controlled and
|
|
228
|
+
// therefore remain visible to discovery.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return unmodified;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function sleepSync(milliseconds: number): void {
|
|
235
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function processIsAlive(pid: number): boolean {
|
|
239
|
+
try {
|
|
240
|
+
process.kill(pid, 0);
|
|
241
|
+
return true;
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
244
|
+
return code !== "ESRCH";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
interface LockOwner {
|
|
249
|
+
pid: number;
|
|
250
|
+
hostname: string;
|
|
251
|
+
token: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readLockOwner(lockPath: string): LockOwner | undefined {
|
|
255
|
+
try {
|
|
256
|
+
const owner = asRecord(
|
|
257
|
+
JSON.parse(readFileSync(join(lockPath, LOCK_OWNER_FILE_NAME), "utf8")),
|
|
258
|
+
"lock owner",
|
|
259
|
+
);
|
|
260
|
+
if (
|
|
261
|
+
typeof owner.pid !== "number" ||
|
|
262
|
+
!Number.isSafeInteger(owner.pid) ||
|
|
263
|
+
owner.pid <= 0 ||
|
|
264
|
+
typeof owner.hostname !== "string" ||
|
|
265
|
+
owner.hostname.length === 0 ||
|
|
266
|
+
typeof owner.token !== "string" ||
|
|
267
|
+
owner.token.length === 0
|
|
268
|
+
) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
return { pid: owner.pid, hostname: owner.hostname, token: owner.token };
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function sameLockOwner(left: LockOwner | undefined, right: LockOwner | undefined): boolean {
|
|
278
|
+
return (
|
|
279
|
+
left !== undefined &&
|
|
280
|
+
right !== undefined &&
|
|
281
|
+
left.pid === right.pid &&
|
|
282
|
+
left.hostname === right.hostname &&
|
|
283
|
+
left.token === right.token
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function tryReclaimDeadLock(stateDir: string, lockPath: string): boolean {
|
|
288
|
+
const reclaimPath = join(stateDir, RECLAIM_LOCK_DIR_NAME);
|
|
289
|
+
try {
|
|
290
|
+
mkdirSync(reclaimPath);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const observed = readLockOwner(lockPath);
|
|
297
|
+
if (
|
|
298
|
+
!observed ||
|
|
299
|
+
observed.hostname !== hostname() ||
|
|
300
|
+
processIsAlive(observed.pid)
|
|
301
|
+
) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
// Only a dead owner can reach this point, so it cannot release and be
|
|
305
|
+
// replaced between identity revalidation and removal. The reclaim mutex
|
|
306
|
+
// prevents two waiters from reaping different generations concurrently.
|
|
307
|
+
if (!sameLockOwner(observed, readLockOwner(lockPath))) return false;
|
|
308
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
309
|
+
return true;
|
|
310
|
+
} finally {
|
|
311
|
+
rmSync(reclaimPath, { recursive: true, force: true });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function acquireSyncLock(stateDir: string): () => void {
|
|
316
|
+
const lockPath = join(stateDir, LOCK_DIR_NAME);
|
|
317
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
318
|
+
while (true) {
|
|
319
|
+
const token = randomUUID();
|
|
320
|
+
const owner: LockOwner = { pid: process.pid, hostname: hostname(), token };
|
|
321
|
+
try {
|
|
322
|
+
mkdirSync(lockPath);
|
|
323
|
+
try {
|
|
324
|
+
writeFileSync(
|
|
325
|
+
join(lockPath, LOCK_OWNER_FILE_NAME),
|
|
326
|
+
`${JSON.stringify({ ...owner, createdAt: new Date().toISOString() })}\n`,
|
|
327
|
+
);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
let released = false;
|
|
333
|
+
return () => {
|
|
334
|
+
if (released) return;
|
|
335
|
+
released = true;
|
|
336
|
+
if (sameLockOwner(owner, readLockOwner(lockPath))) {
|
|
337
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
342
|
+
if (tryReclaimDeadLock(stateDir, lockPath)) continue;
|
|
343
|
+
if (Date.now() >= deadline) {
|
|
344
|
+
const ownerText = JSON.stringify(readLockOwner(lockPath) ?? "unknown owner");
|
|
345
|
+
throw new Error(
|
|
346
|
+
`timed out waiting for pi-subagent agent sync lock: ${lockPath} (${ownerText})`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
sleepSync(LOCK_RETRY_MS);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function destinationKind(filePath: string): { kind: DestinationKind; size?: number } {
|
|
355
|
+
try {
|
|
356
|
+
const stats = lstatSync(filePath);
|
|
357
|
+
if (stats.isSymbolicLink()) return { kind: "symlink" };
|
|
358
|
+
if (stats.isFile()) return { kind: "file", size: stats.size };
|
|
359
|
+
throw new Error(`${filePath}: managed agent destination must be a regular file or symbolic link`);
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function sameRegularFile(filePath: string, size: number | undefined, content: Buffer): boolean {
|
|
367
|
+
if (size !== content.length) return false;
|
|
368
|
+
return readFileSync(filePath).equals(content);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function createBackup(action: PlannedAction, backupDir: string): AgentBackup {
|
|
372
|
+
const backupPath = join(backupDir, action.name);
|
|
373
|
+
if (action.destinationKind === "symlink") {
|
|
374
|
+
symlinkSync(readlinkSync(action.destination), backupPath);
|
|
375
|
+
return { name: action.name, path: backupPath, kind: "symlink" };
|
|
376
|
+
}
|
|
377
|
+
copyFileSync(action.destination, backupPath);
|
|
378
|
+
return { name: action.name, path: backupPath, kind: "file" };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function restoreBackup(action: PlannedAction): void {
|
|
382
|
+
if (!action.backupPath) throw new Error(`missing rollback backup for ${action.name}`);
|
|
383
|
+
rmSync(action.destination, { force: true });
|
|
384
|
+
if (action.destinationKind === "symlink") {
|
|
385
|
+
const tempPath = `${action.destination}.rollback-${process.pid}-${randomUUID()}`;
|
|
386
|
+
try {
|
|
387
|
+
symlinkSync(readlinkSync(action.backupPath), tempPath);
|
|
388
|
+
renameSync(tempPath, action.destination);
|
|
389
|
+
} finally {
|
|
390
|
+
rmSync(tempPath, { force: true });
|
|
391
|
+
}
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const tempPath = `${action.destination}.rollback-${process.pid}-${randomUUID()}`;
|
|
395
|
+
try {
|
|
396
|
+
copyFileSync(action.backupPath, tempPath);
|
|
397
|
+
renameSync(tempPath, action.destination);
|
|
398
|
+
} finally {
|
|
399
|
+
rmSync(tempPath, { force: true });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function rollback(committed: PlannedAction[]): string[] {
|
|
404
|
+
const errors: string[] = [];
|
|
405
|
+
for (const action of [...committed].reverse()) {
|
|
406
|
+
try {
|
|
407
|
+
if (action.kind === "install") {
|
|
408
|
+
rmSync(action.destination, { force: true });
|
|
409
|
+
} else {
|
|
410
|
+
restoreBackup(action);
|
|
411
|
+
}
|
|
412
|
+
} catch (error) {
|
|
413
|
+
errors.push(`${action.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return errors;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function syncBundledAgents(options: AgentSyncOptions): AgentSyncResult {
|
|
420
|
+
const packageVersion = resolvePackageVersion(options);
|
|
421
|
+
const userAgentsDir = join(options.agentDir, "agents");
|
|
422
|
+
const stateDir = join(options.agentDir, STATE_DIR_NAME);
|
|
423
|
+
const manifestPath = join(stateDir, MANIFEST_FILE_NAME);
|
|
424
|
+
mkdirSync(userAgentsDir, { recursive: true });
|
|
425
|
+
mkdirSync(stateDir, { recursive: true });
|
|
426
|
+
const releaseLock = acquireSyncLock(stateDir);
|
|
427
|
+
try {
|
|
428
|
+
return syncBundledAgentsLocked(
|
|
429
|
+
options,
|
|
430
|
+
packageVersion,
|
|
431
|
+
userAgentsDir,
|
|
432
|
+
stateDir,
|
|
433
|
+
manifestPath,
|
|
434
|
+
);
|
|
435
|
+
} finally {
|
|
436
|
+
releaseLock();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function syncBundledAgentsLocked(
|
|
441
|
+
options: AgentSyncOptions,
|
|
442
|
+
packageVersion: string,
|
|
443
|
+
userAgentsDir: string,
|
|
444
|
+
stateDir: string,
|
|
445
|
+
manifestPath: string,
|
|
446
|
+
): AgentSyncResult {
|
|
447
|
+
const diagnostics: string[] = [];
|
|
448
|
+
const previous = readPreviousManifest(manifestPath);
|
|
449
|
+
const packageChanged = previous !== undefined && previous.packageVersion !== packageVersion;
|
|
450
|
+
const files = bundledAgentFiles(options.bundledDir);
|
|
451
|
+
const currentNames = new Set(files.map((file) => file.name));
|
|
452
|
+
const preserved: string[] = [];
|
|
453
|
+
const actions: PlannedAction[] = [];
|
|
454
|
+
|
|
455
|
+
// Plan the complete operation before changing any user agent file. A current
|
|
456
|
+
// manifest/source pair means ordinary restarts do not even read user content.
|
|
457
|
+
for (const file of files) {
|
|
458
|
+
const destination = join(userAgentsDir, file.name);
|
|
459
|
+
const destinationState = destinationKind(destination);
|
|
460
|
+
if (destinationState.kind === "missing") {
|
|
461
|
+
actions.push({
|
|
462
|
+
name: file.name,
|
|
463
|
+
kind: "install",
|
|
464
|
+
destination,
|
|
465
|
+
destinationKind: "missing",
|
|
466
|
+
content: file.content,
|
|
467
|
+
});
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const previousHash = previous?.files[file.name];
|
|
472
|
+
const bundledChanged = previousHash === undefined || previousHash !== file.hash;
|
|
473
|
+
const refresh = previous === undefined || packageChanged || bundledChanged;
|
|
474
|
+
if (!refresh) {
|
|
475
|
+
preserved.push(file.name);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (
|
|
479
|
+
destinationState.kind === "file" &&
|
|
480
|
+
sameRegularFile(destination, destinationState.size, file.content)
|
|
481
|
+
) {
|
|
482
|
+
preserved.push(file.name);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
actions.push({
|
|
486
|
+
name: file.name,
|
|
487
|
+
kind: "replace",
|
|
488
|
+
destination,
|
|
489
|
+
destinationKind: destinationState.kind,
|
|
490
|
+
content: file.content,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Retired bundled presets must not remain silently active. They are backed
|
|
495
|
+
// up like replacements, then removed; unrelated user-defined names remain.
|
|
496
|
+
for (const name of Object.keys(previous?.files ?? {}).sort((left, right) => left.localeCompare(right))) {
|
|
497
|
+
if (currentNames.has(name)) continue;
|
|
498
|
+
const destination = join(userAgentsDir, name);
|
|
499
|
+
const destinationState = destinationKind(destination);
|
|
500
|
+
if (destinationState.kind === "missing") continue;
|
|
501
|
+
actions.push({
|
|
502
|
+
name,
|
|
503
|
+
kind: "remove",
|
|
504
|
+
destination,
|
|
505
|
+
destinationKind: destinationState.kind,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const manifest: AgentManifest = {
|
|
510
|
+
version: MANIFEST_VERSION,
|
|
511
|
+
packageVersion,
|
|
512
|
+
files: Object.fromEntries(files.map((file) => [file.name, file.hash])),
|
|
513
|
+
};
|
|
514
|
+
const stagedPaths: string[] = [];
|
|
515
|
+
const backups: AgentBackup[] = [];
|
|
516
|
+
let backupDir: string | undefined;
|
|
517
|
+
let manifestStage: string | undefined;
|
|
518
|
+
const committed: PlannedAction[] = [];
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
// Stage every replacement and the manifest first, catching permissions or
|
|
522
|
+
// disk-space failures before any managed destination changes.
|
|
523
|
+
for (const action of actions) {
|
|
524
|
+
if (!action.content) continue;
|
|
525
|
+
action.stagedPath = stageFile(action.destination, action.content);
|
|
526
|
+
stagedPaths.push(action.stagedPath);
|
|
527
|
+
}
|
|
528
|
+
manifestStage = stageFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
529
|
+
stagedPaths.push(manifestStage);
|
|
530
|
+
|
|
531
|
+
const needsBackup = actions.some((action) => action.kind !== "install");
|
|
532
|
+
if (needsBackup) {
|
|
533
|
+
backupDir = join(
|
|
534
|
+
stateDir,
|
|
535
|
+
BACKUPS_DIR_NAME,
|
|
536
|
+
`${timestamp()}-to-${safeSegment(packageVersion)}-${randomUUID().slice(0, 8)}`,
|
|
537
|
+
);
|
|
538
|
+
mkdirSync(backupDir, { recursive: true });
|
|
539
|
+
for (const action of actions) {
|
|
540
|
+
if (action.kind === "install") continue;
|
|
541
|
+
const backup = createBackup(action, backupDir);
|
|
542
|
+
action.backupPath = backup.path;
|
|
543
|
+
backups.push(backup);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
for (const action of actions) {
|
|
548
|
+
if (action.kind === "remove") {
|
|
549
|
+
rmSync(action.destination, { force: true });
|
|
550
|
+
} else {
|
|
551
|
+
if (!action.stagedPath) throw new Error(`missing staged content for ${action.name}`);
|
|
552
|
+
renameSync(action.stagedPath, action.destination);
|
|
553
|
+
}
|
|
554
|
+
committed.push(action);
|
|
555
|
+
}
|
|
556
|
+
renameSync(manifestStage, manifestPath);
|
|
557
|
+
manifestStage = undefined;
|
|
558
|
+
} catch (error) {
|
|
559
|
+
const rollbackErrors = rollback(committed);
|
|
560
|
+
const backupText = backupDir ? ` Backups remain at ${backupDir}.` : "";
|
|
561
|
+
const rollbackText =
|
|
562
|
+
rollbackErrors.length > 0 ? ` Rollback errors: ${rollbackErrors.join("; ")}.` : "";
|
|
563
|
+
throw new Error(
|
|
564
|
+
`failed to synchronize bundled subagents: ${
|
|
565
|
+
error instanceof Error ? error.message : String(error)
|
|
566
|
+
}.${backupText}${rollbackText}`,
|
|
567
|
+
);
|
|
568
|
+
} finally {
|
|
569
|
+
for (const stagedPath of stagedPaths) rmSync(stagedPath, { force: true });
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return {
|
|
573
|
+
packageVersion,
|
|
574
|
+
userAgentsDir,
|
|
575
|
+
manifestPath,
|
|
576
|
+
installed: actions.filter((action) => action.kind === "install").map((action) => action.name),
|
|
577
|
+
updated: actions.filter((action) => action.kind === "replace").map((action) => action.name),
|
|
578
|
+
removed: actions.filter((action) => action.kind === "remove").map((action) => action.name),
|
|
579
|
+
preserved,
|
|
580
|
+
backups,
|
|
581
|
+
diagnostics,
|
|
582
|
+
};
|
|
583
|
+
}
|