@iseoane/agent-depot 0.6.8 → 0.6.9

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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.9] - 2026-11-04
11
+
12
+ ### Fixed
13
+
14
+ - Catalog and Installations now read cached Git Source manifests in a bounded
15
+ batch instead of starting a Git process per file. Readers share small immutable
16
+ snapshots for the same repository, directory scope and commit, while resolving
17
+ the current ref again on every read. Host state and operation safety checks
18
+ remain fresh; opening a view does not refresh its Sources.
19
+
10
20
  ## [0.6.8] - 2026-11-04
11
21
 
12
22
  ### Fixed
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { isLockContention } from "./atomic-file.js";
6
6
  import { assertNoSymlinkPath } from "./path-safety.js";
7
7
  import { runProcess } from "./process-runner.js";
8
+ import { decodeSnapshotBatch, reuseSourceSnapshot, SOURCE_SNAPSHOT_LIMITS } from "./source-snapshot.js";
8
9
  export const SKILL_TREE_LIMITS = Object.freeze({
9
10
  maxFiles: 256,
10
11
  maxFileBytes: 2 * 1024 * 1024,
@@ -62,11 +63,14 @@ class NodeGitSnapshotCommandRunner {
62
63
  async runBinary(command, args, options = {}) {
63
64
  const result = await runProcess(command, args, {
64
65
  captureStdout: true,
66
+ ...(options.stdin === undefined ? {} : { stdin: options.stdin, timeoutMs: 30_000, maxStderrBytes: 64 * 1024 }),
65
67
  ...(options.maxOutputBytes === undefined ? {} : { maxOutputBytes: options.maxOutputBytes }),
66
68
  });
67
69
  if (result.outputTooLarge) {
68
70
  throw new Error("git command output exceeds the safe size limit");
69
71
  }
72
+ if (result.timedOut)
73
+ throw new Error("git command timed out");
70
74
  if (result.code !== 0) {
71
75
  throw new Error(`git command failed: ${gitFailureDetail(result)}`);
72
76
  }
@@ -168,12 +172,13 @@ function parseSkillTreeRecord(record, relativeSkillPath) {
168
172
  }
169
173
  return { relativePath, objectId, executable: mode === "100755" };
170
174
  }
175
+ const defaultSnapshotRunner = new NodeGitSnapshotCommandRunner();
171
176
  export class GitSourceSnapshotAccess {
172
177
  cachePath;
173
178
  runner;
174
179
  constructor(options = {}) {
175
180
  this.cachePath = options.cachePath ?? defaultGitSourceCachePath();
176
- this.runner = options.runner ?? new NodeGitSnapshotCommandRunner();
181
+ this.runner = options.runner ?? defaultSnapshotRunner;
177
182
  }
178
183
  async requireMirror(source) {
179
184
  const destination = cachePathForSource(this.cachePath, source);
@@ -278,37 +283,46 @@ export class GitSourceSnapshotAccess {
278
283
  }
279
284
  async readSnapshot(source) {
280
285
  const destination = await this.requireMirror(source);
286
+ // Resolve on every read, even on a cache hit: refs can change outside Agent Depot.
281
287
  const commit = await this.readResolvedCommit(source);
282
288
  const directory = gitSourceLocation(source.url).directory;
283
- const tree = await this.runner.run("git", [
284
- "--git-dir", destination, "ls-tree", "-r", "-z", commit,
285
- ...(directory === undefined ? [] : ["--", directory]),
286
- ]);
287
- const files = [];
288
- for (const record of tree.split("\0")) {
289
- if (!record) {
290
- continue;
291
- }
292
- const separator = record.indexOf("\t");
293
- if (separator < 0) {
294
- continue;
289
+ const key = JSON.stringify([path.resolve(destination), commit, directory]);
290
+ return reuseSourceSnapshot(this.runner, key, async () => {
291
+ const tree = await this.runner.run("git", [
292
+ "--no-replace-objects", "--git-dir", destination, "ls-tree", "-r", "-z", commit,
293
+ ...(directory === undefined ? [] : ["--", directory]),
294
+ ], { maxOutputBytes: SKILL_TREE_LIMITS.maxListingBytes });
295
+ if (tree !== "" && !tree.endsWith("\0"))
296
+ throw new Error("Truncated Git snapshot listing");
297
+ const entries = [];
298
+ for (const record of tree.split("\0")) {
299
+ if (!record)
300
+ continue;
301
+ const separator = record.indexOf("\t");
302
+ if (separator < 0)
303
+ throw new Error("Malformed Git snapshot listing");
304
+ const [mode, type, objectId] = record.slice(0, separator).split(" ");
305
+ const relativePath = record.slice(separator + 1);
306
+ if (mode === "120000" || type !== "blob" || !relativePath
307
+ || (directory !== undefined && !relativePath.startsWith(`${directory}/`))
308
+ || !isSourceSnapshotFile(relativePath, directory)
309
+ || relativePath.split("/").slice(0, -1).some(segment => EXCLUDED_LIFECYCLE_SEGMENTS.has(segment)))
310
+ continue;
311
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(objectId)
312
+ || !["100644", "100755"].includes(mode))
313
+ throw new Error("Unsafe Git snapshot entry");
314
+ entries.push({ path: relativePath, objectId });
315
+ if (entries.length > SOURCE_SNAPSHOT_LIMITS.maxFiles)
316
+ throw new Error("Git snapshot exceeds file count limit");
295
317
  }
296
- const metadata = record.slice(0, separator).split(" ");
297
- const relativePath = record.slice(separator + 1);
298
- if (metadata[0] === "120000"
299
- || metadata[1] !== "blob"
300
- || !relativePath
301
- || (directory !== undefined && !relativePath.startsWith(`${directory}/`))
302
- || !isSourceSnapshotFile(relativePath, directory)
303
- || relativePath.split("/").slice(0, -1).some((segment) => EXCLUDED_LIFECYCLE_SEGMENTS.has(segment))) {
304
- continue;
305
- }
306
- files.push({
307
- path: relativePath,
308
- content: await this.runner.run("git", ["--git-dir", destination, "show", `${commit}:${relativePath}`]),
318
+ if (entries.length === 0)
319
+ return Object.freeze([]);
320
+ const output = await this.runner.runBinary("git", ["--no-replace-objects", "--git-dir", destination, "cat-file", "--batch"], {
321
+ stdin: Buffer.from(entries.map(entry => `${entry.objectId}\n`).join("")),
322
+ maxOutputBytes: SOURCE_SNAPSHOT_LIMITS.maxTotalBytes + entries.length * 130,
309
323
  });
310
- }
311
- return Object.freeze(files);
324
+ return decodeSnapshotBatch(output, entries);
325
+ });
312
326
  }
313
327
  /** File paths at or under `root`, from the same immutable commit as the other reads. */
314
328
  async listFiles(source, root) {
@@ -151,6 +151,10 @@ function signalProcesses(pids, signal) {
151
151
  */
152
152
  export function runProcess(command, args, options = {}) {
153
153
  return new Promise((resolve, reject) => {
154
+ if (options.stdin !== undefined && options.stdin.byteLength > 1024 * 1024) {
155
+ reject(new Error("Process stdin exceeds the safe size limit"));
156
+ return;
157
+ }
154
158
  const invocation = spawnInvocation(command, args);
155
159
  const sink = childOutputSinks.getStore();
156
160
  const child = spawn(invocation.command, [...invocation.args], {
@@ -158,7 +162,7 @@ export function runProcess(command, args, options = {}) {
158
162
  shell: false,
159
163
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
160
164
  // Without a capture, stdout is piped only for a sink, and then discarded after it is shown.
161
- stdio: ["ignore", options.captureStdout || sink !== undefined ? "pipe" : "ignore", "pipe"],
165
+ stdio: [options.stdin === undefined ? "ignore" : "pipe", options.captureStdout || sink !== undefined ? "pipe" : "ignore", "pipe"],
162
166
  });
163
167
  const stdout = [];
164
168
  let stdoutBytes = 0;
@@ -189,6 +193,7 @@ export function runProcess(command, args, options = {}) {
189
193
  };
190
194
  // A surviving descendant keeps the pipes open, so `close` may never come: give up on them.
191
195
  const giveUp = () => {
196
+ child.stdin?.destroy();
192
197
  child.stdout?.destroy();
193
198
  child.stderr?.destroy();
194
199
  settle(child.exitCode, child.signalCode);
@@ -263,5 +268,11 @@ export function runProcess(command, args, options = {}) {
263
268
  });
264
269
  child.on("error", reject);
265
270
  child.on("close", settle);
271
+ child.stdin?.on("error", (error) => {
272
+ // Windows reports EOF when a child exits with input still pending.
273
+ if (error.code !== "EPIPE" && error.code !== "EOF")
274
+ reject(error);
275
+ });
276
+ child.stdin?.end(options.stdin);
266
277
  });
267
278
  }
@@ -0,0 +1,59 @@
1
+ export const SOURCE_SNAPSHOT_LIMITS = Object.freeze({
2
+ maxFiles: 4096, maxFileBytes: 2 * 1024 * 1024, maxTotalBytes: 32 * 1024 * 1024,
3
+ });
4
+ /** Git batch payloads are byte framed: contents may themselves contain newlines or NULs. */
5
+ export function decodeSnapshotBatch(output, entries) {
6
+ const bytes = Buffer.from(output);
7
+ let offset = 0;
8
+ let total = 0;
9
+ const files = entries.map(entry => {
10
+ const end = bytes.indexOf(10, offset);
11
+ if (end < offset || end - offset > 128)
12
+ throw new Error("Malformed Git batch header");
13
+ const header = bytes.subarray(offset, end).toString("utf8").split(" ");
14
+ const size = Number(header[2]);
15
+ if (header.length !== 3 || header[0] !== entry.objectId || header[1] !== "blob"
16
+ || !/^\d+$/u.test(header[2]) || !Number.isSafeInteger(size) || size > SOURCE_SNAPSHOT_LIMITS.maxFileBytes) {
17
+ throw new Error("Invalid Git batch object or size limit");
18
+ }
19
+ total += size;
20
+ if (total > SOURCE_SNAPSHOT_LIMITS.maxTotalBytes)
21
+ throw new Error("Git snapshot exceeds total size limit");
22
+ offset = end + 1;
23
+ if (offset + size >= bytes.length || bytes[offset + size] !== 10)
24
+ throw new Error("Truncated Git batch object");
25
+ const content = bytes.subarray(offset, offset + size).toString("utf8");
26
+ offset += size + 1;
27
+ return Object.freeze({ path: entry.path, content });
28
+ });
29
+ if (offset !== bytes.length)
30
+ throw new Error("Unexpected trailing Git batch output");
31
+ return Object.freeze(files);
32
+ }
33
+ // Default readers share immutable work; injected runners have separate cache domains.
34
+ // Only small results are retained, with at most eight entries per runner (32 MiB of UTF-8 data).
35
+ const caches = new WeakMap();
36
+ export function reuseSourceSnapshot(owner, key, load) {
37
+ let cache = caches.get(owner);
38
+ if (!cache) {
39
+ cache = new Map();
40
+ caches.set(owner, cache);
41
+ }
42
+ const hit = cache.get(key);
43
+ if (hit) {
44
+ cache.delete(key);
45
+ cache.set(key, hit);
46
+ return hit;
47
+ }
48
+ const pending = load();
49
+ const remove = () => { if (cache.get(key) === pending)
50
+ cache.delete(key); };
51
+ cache.set(key, pending);
52
+ if (cache.size > 8)
53
+ cache.delete(cache.keys().next().value);
54
+ void pending.then(files => {
55
+ if (files.reduce((size, file) => size + Buffer.byteLength(file.content, "utf8"), 0) > 4 * 1024 * 1024)
56
+ remove();
57
+ }, remove);
58
+ return pending;
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iseoane/agent-depot",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "description": "A cross-platform catalog for reusable coding-agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "iseoane",