@diegopetrucci/pi-extensions 0.1.57 → 0.1.59

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.
Files changed (102) hide show
  1. package/.pi-fleet-tested-version +1 -1
  2. package/extensions/agent-workflow-audit/.pi-fleet-tested-version +1 -1
  3. package/extensions/agent-workflow-audit/index.ts +8 -1
  4. package/extensions/agent-workflow-audit/package.json +5 -1
  5. package/extensions/annotate-git-diff/.pi-fleet-tested-version +1 -1
  6. package/extensions/annotate-git-diff/package.json +5 -2
  7. package/extensions/annotate-last-message/.pi-fleet-tested-version +1 -1
  8. package/extensions/annotate-last-message/package.json +5 -2
  9. package/extensions/brrr/.pi-fleet-tested-version +1 -1
  10. package/extensions/brrr/README.md +2 -2
  11. package/extensions/brrr/package.json +5 -1
  12. package/extensions/claude-fast/.pi-fleet-tested-version +1 -1
  13. package/extensions/claude-fast/package.json +5 -1
  14. package/extensions/code-reviewer/.pi-fleet-tested-version +1 -1
  15. package/extensions/code-reviewer/index.ts +129 -39
  16. package/extensions/code-reviewer/package.json +5 -1
  17. package/extensions/confirm-destructive/.pi-fleet-tested-version +1 -1
  18. package/extensions/confirm-destructive/README.md +1 -1
  19. package/extensions/confirm-destructive/index.ts +20 -5
  20. package/extensions/confirm-destructive/package.json +5 -1
  21. package/extensions/context-cap/.pi-fleet-tested-version +1 -1
  22. package/extensions/context-cap/package.json +5 -1
  23. package/extensions/context-inspector/.pi-fleet-tested-version +1 -1
  24. package/extensions/context-inspector/package.json +5 -1
  25. package/extensions/contrarian/.pi-fleet-tested-version +1 -1
  26. package/extensions/contrarian/README.md +1 -1
  27. package/extensions/contrarian/index.ts +199 -40
  28. package/extensions/contrarian/package.json +5 -1
  29. package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
  30. package/extensions/dirty-repo-guard/README.md +1 -1
  31. package/extensions/dirty-repo-guard/package.json +5 -1
  32. package/extensions/dynamic-context-pruning/.pi-fleet-tested-version +1 -1
  33. package/extensions/dynamic-context-pruning/package.json +4 -1
  34. package/extensions/git-footer/.pi-fleet-tested-version +1 -1
  35. package/extensions/git-footer/package.json +5 -1
  36. package/extensions/gnosis/.pi-fleet-tested-version +1 -1
  37. package/extensions/gnosis/package.json +5 -1
  38. package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -1
  39. package/extensions/illustrations-to-explain-things/package.json +4 -1
  40. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/01-two-breakpoints.webp +0 -0
  41. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/02-minimum-loop.webp +0 -0
  42. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/03-sort-by-purpose.webp +0 -0
  43. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/04-one-fish-many-uses.webp +0 -0
  44. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/05-handoff-path.webp +0 -0
  45. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/06-three-sources.webp +0 -0
  46. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/07-three-content-jobs.webp +0 -0
  47. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/08-handoff-copy-toolbox.webp +0 -0
  48. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/09-common-pits-no-title.webp +0 -0
  49. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/10-information-well.webp +0 -0
  50. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/11-idea-press.webp +0 -0
  51. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/12-content-fermentation.webp +0 -0
  52. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/13-system-bearing.webp +0 -0
  53. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/14-trust-bridge.webp +0 -0
  54. package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
  55. package/extensions/inline-bash/README.md +1 -1
  56. package/extensions/inline-bash/package.json +5 -1
  57. package/extensions/librarian/.pi-fleet-tested-version +1 -1
  58. package/extensions/librarian/index.ts +393 -107
  59. package/extensions/librarian/package.json +5 -1
  60. package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
  61. package/extensions/minimal-footer/index.ts +2 -5
  62. package/extensions/minimal-footer/openai-usage.ts +58 -11
  63. package/extensions/minimal-footer/package.json +5 -1
  64. package/extensions/notify/.pi-fleet-tested-version +1 -1
  65. package/extensions/notify/README.md +3 -3
  66. package/extensions/notify/package.json +5 -1
  67. package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
  68. package/extensions/openai-fast/package.json +5 -1
  69. package/extensions/oracle/.pi-fleet-tested-version +1 -1
  70. package/extensions/oracle/README.md +1 -1
  71. package/extensions/oracle/index.ts +200 -41
  72. package/extensions/oracle/package.json +5 -1
  73. package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
  74. package/extensions/permission-gate/README.md +1 -1
  75. package/extensions/permission-gate/index.ts +453 -2
  76. package/extensions/permission-gate/package.json +5 -1
  77. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  78. package/extensions/quiet-tools/package.json +5 -1
  79. package/extensions/review/.pi-fleet-tested-version +1 -1
  80. package/extensions/review/index.ts +53 -19
  81. package/extensions/review/package.json +5 -1
  82. package/extensions/todo/.pi-fleet-tested-version +1 -1
  83. package/extensions/todo/README.md +1 -1
  84. package/extensions/todo/package.json +5 -1
  85. package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
  86. package/extensions/triage-comments/index.ts +115 -34
  87. package/extensions/triage-comments/package.json +5 -1
  88. package/package.json +10 -6
  89. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/01-two-breakpoints.png +0 -0
  90. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/02-minimum-loop.png +0 -0
  91. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/03-sort-by-purpose.png +0 -0
  92. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/04-one-fish-many-uses.png +0 -0
  93. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/05-handoff-path.png +0 -0
  94. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/06-three-sources.png +0 -0
  95. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/07-three-content-jobs.png +0 -0
  96. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/08-handoff-copy-toolbox.png +0 -0
  97. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/09-common-pits-no-title.png +0 -0
  98. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/10-information-well.png +0 -0
  99. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/11-idea-press.png +0 -0
  100. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/12-content-fermentation.png +0 -0
  101. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/13-system-bearing.png +0 -0
  102. package/extensions/illustrations-to-explain-things/skills/illustrations-to-explain-things/assets/examples/14-trust-bridge.png +0 -0
@@ -1,10 +1,12 @@
1
- import type { Dirent } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Dirent, Stats } from "node:fs";
2
3
  import type { FileHandle } from "node:fs/promises";
3
4
  import * as fs from "node:fs/promises";
4
5
  import * as os from "node:os";
5
6
  import * as path from "node:path";
6
7
 
7
8
  import type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "@earendil-works/pi-coding-agent";
9
+ import type { Usage } from "@earendil-works/pi-ai";
8
10
  import {
9
11
  DefaultResourceLoader,
10
12
  SessionManager,
@@ -26,6 +28,11 @@ const CACHE_TTL_MS = CACHE_TTL_DAYS * 24 * 60 * 60 * 1000;
26
28
  const CACHE_METADATA_FILE = ".pi-librarian-cache.json";
27
29
  const CACHE_MARKER_FILE = ".pi-librarian-cache-used";
28
30
  const CACHE_CONFIG_FILE = "librarian.json";
31
+ const CLEANUP_LOCK_FILE = ".cleanup.lock";
32
+ const CLEANUP_LOCK_STALE_MS = 5 * 60 * 1000;
33
+ const CLEANUP_LOCK_HEARTBEAT_MS = 60 * 1000;
34
+ // Absolute owner lease prevents PID reuse from keeping an abandoned lock forever.
35
+ const CLEANUP_LOCK_MAX_LEASE_MS = 24 * 60 * 60 * 1000;
29
36
 
30
37
  type LibrarianStatus = "running" | "done" | "error" | "aborted";
31
38
 
@@ -39,6 +46,13 @@ type PiModel = {
39
46
  thinkingLevelMap?: ThinkingLevelMap;
40
47
  };
41
48
 
49
+ type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
50
+
51
+ function getModelRuntimeOption(ctx: { modelRegistry?: unknown }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
52
+ const modelRuntime = (ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined)?.runtime;
53
+ return modelRuntime ? { modelRuntime } : {};
54
+ }
55
+
42
56
  const DEFAULT_CACHE_MODE: CacheMode = "disabled";
43
57
  const DEFAULT_THINKING_LEVEL: ThinkingLevel = "low";
44
58
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
@@ -245,54 +259,236 @@ async function isManagedRepoCache(repoDir: string): Promise<boolean> {
245
259
  (await pathExists(path.join(repoDir, CACHE_MARKER_FILE))));
246
260
  }
247
261
 
248
- async function cleanupExpiredCache(cacheRoot: string): Promise<{ deleted: number; errors: string[] }> {
249
- const errors: string[] = [];
250
- let deleted = 0;
251
- const now = Date.now();
252
- const root = path.resolve(cacheRoot);
253
- const lockPath = path.join(root, ".cleanup.lock");
254
- let lock: FileHandle | undefined;
262
+ type CleanupLockOwner = {
263
+ pid: number;
264
+ hostname: string;
265
+ token: string;
266
+ acquiredAt: string;
267
+ };
268
+
269
+ type CleanupLock = {
270
+ handle: FileHandle;
271
+ path: string;
272
+ owner: CleanupLockOwner;
273
+ identity: Pick<Stats, "dev" | "ino">;
274
+ };
275
+
276
+ function sameFileIdentity(left: Pick<Stats, "dev" | "ino">, right: Pick<Stats, "dev" | "ino">): boolean {
277
+ return left.dev === right.dev && left.ino === right.ino;
278
+ }
255
279
 
280
+ function parseCleanupLockOwner(value: string): CleanupLockOwner | undefined {
256
281
  try {
257
- lock = await fs.open(lockPath, "wx");
282
+ const owner = JSON.parse(value) as Partial<CleanupLockOwner>;
283
+ if (!Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0) return undefined;
284
+ if (typeof owner.hostname !== "string" || !owner.hostname) return undefined;
285
+ if (typeof owner.token !== "string" || !owner.token) return undefined;
286
+ if (typeof owner.acquiredAt !== "string" || !owner.acquiredAt) return undefined;
287
+ if (!Number.isFinite(Date.parse(owner.acquiredAt))) return undefined;
288
+ return owner as CleanupLockOwner;
289
+ } catch {
290
+ return undefined;
291
+ }
292
+ }
293
+
294
+ function isCleanupLockLeaseExpired(owner: CleanupLockOwner | undefined, now = Date.now()): boolean {
295
+ if (!owner) return true;
296
+ const acquiredAt = Date.parse(owner.acquiredAt);
297
+ if (!Number.isFinite(acquiredAt)) return true;
298
+ return Math.max(0, now - acquiredAt) > CLEANUP_LOCK_MAX_LEASE_MS;
299
+ }
300
+
301
+ function isRecordedOwnerAlive(owner: CleanupLockOwner | undefined): boolean {
302
+ if (!owner || owner.hostname !== os.hostname()) return false;
303
+ try {
304
+ process.kill(owner.pid, 0);
305
+ return true;
258
306
  } catch (error) {
259
- if ((error as NodeJS.ErrnoException).code === "EEXIST") return { deleted, errors };
307
+ return (error as NodeJS.ErrnoException).code === "EPERM";
308
+ }
309
+ }
310
+
311
+ async function restoreMovedLock(movedPath: string, lockPath: string): Promise<void> {
312
+ try {
313
+ // link() restores only when lockPath is still absent; unlike rename(), it cannot overwrite a newer owner.
314
+ await fs.link(movedPath, lockPath);
315
+ await fs.rm(movedPath, { force: true });
316
+ } catch (error) {
317
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
318
+ // Preserve both owners rather than deleting the moved lock when another owner won the canonical path.
319
+ }
320
+ }
321
+
322
+ async function removeLockPathIfOwned(lockPath: string, identity: Pick<Stats, "dev" | "ino">): Promise<void> {
323
+ const currentStat = await fs.stat(lockPath).catch(() => undefined);
324
+ if (!currentStat || !sameFileIdentity(identity, currentStat)) return;
325
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
326
+ }
327
+
328
+ async function getCleanupLockPathIdentity(lockPath: string): Promise<Pick<Stats, "dev" | "ino"> | undefined> {
329
+ const canonicalPath = await fs.realpath(lockPath).catch((error: NodeJS.ErrnoException) => {
330
+ if (error.code === "ENOENT") return undefined;
331
+ throw error;
332
+ });
333
+ if (!canonicalPath) return undefined;
334
+ const currentStat = await fs.stat(canonicalPath).catch((error: NodeJS.ErrnoException) => {
335
+ if (error.code === "ENOENT") return undefined;
260
336
  throw error;
337
+ });
338
+ return currentStat ? { dev: currentStat.dev, ino: currentStat.ino } : undefined;
339
+ }
340
+
341
+ async function cleanupLockOwnershipError(lock: CleanupLock, stage: string): Promise<string | undefined> {
342
+ const currentIdentity = await getCleanupLockPathIdentity(lock.path);
343
+ if (currentIdentity && sameFileIdentity(lock.identity, currentIdentity)) return undefined;
344
+ return `cleanup lock ownership lost before ${stage}; stopping cleanup.`;
345
+ }
346
+
347
+ async function tryAcquireCleanupLock(root: string): Promise<CleanupLock | undefined> {
348
+ const lockPath = path.join(root, CLEANUP_LOCK_FILE);
349
+ for (let attempt = 0; attempt < 3; attempt += 1) {
350
+ const owner: CleanupLockOwner = {
351
+ pid: process.pid,
352
+ hostname: os.hostname(),
353
+ token: randomUUID(),
354
+ acquiredAt: new Date().toISOString(),
355
+ };
356
+ try {
357
+ const handle = await fs.open(lockPath, "wx");
358
+ const identity = await handle.stat();
359
+ try {
360
+ await handle.writeFile(JSON.stringify(owner), "utf8");
361
+ await handle.sync();
362
+ return { handle, path: lockPath, owner, identity };
363
+ } catch (error) {
364
+ await handle.close().catch(() => undefined);
365
+ await removeLockPathIfOwned(lockPath, identity);
366
+ throw error;
367
+ }
368
+ } catch (error) {
369
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
370
+ }
371
+
372
+ let existing: FileHandle | undefined;
373
+ try {
374
+ existing = await fs.open(lockPath, "r");
375
+ } catch (error) {
376
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
377
+ throw error;
378
+ }
379
+ try {
380
+ const stat = await existing.stat();
381
+ const recordedOwner = parseCleanupLockOwner(await existing.readFile("utf8"));
382
+ const now = Date.now();
383
+ const ageMs = Math.max(0, now - stat.mtimeMs);
384
+ if (!recordedOwner) {
385
+ if (ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
386
+ } else {
387
+ const leaseExpired = isCleanupLockLeaseExpired(recordedOwner, now);
388
+ if (isRecordedOwnerAlive(recordedOwner) && !leaseExpired) return undefined;
389
+ if (!leaseExpired && ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
390
+ }
391
+
392
+ const stalePath = path.join(root, `${CLEANUP_LOCK_FILE}.stale-${process.pid}-${randomUUID()}`);
393
+ try {
394
+ await fs.rename(lockPath, stalePath);
395
+ } catch (renameError) {
396
+ if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue;
397
+ throw renameError;
398
+ }
399
+ const movedStat = await fs.stat(stalePath).catch(() => undefined);
400
+ if (!movedStat || !sameFileIdentity(stat, movedStat)) {
401
+ await restoreMovedLock(stalePath, lockPath);
402
+ return undefined;
403
+ }
404
+ await fs.rm(stalePath, { force: true });
405
+ } finally {
406
+ await existing.close().catch(() => undefined);
407
+ }
261
408
  }
409
+ return undefined;
410
+ }
262
411
 
412
+ async function heartbeatCleanupLock(lock: CleanupLock): Promise<void> {
413
+ const now = new Date();
414
+ await lock.handle.utimes(now, now);
415
+ }
416
+
417
+ async function releaseCleanupLock(lock: CleanupLock): Promise<void> {
418
+ await lock.handle.close().catch(() => undefined);
419
+ const currentStat = await fs.stat(lock.path).catch(() => undefined);
420
+ if (!currentStat || !sameFileIdentity(lock.identity, currentStat)) return;
421
+ const releasePath = `${lock.path}.release-${process.pid}-${lock.owner.token}`;
263
422
  try {
264
- const hosts = await safeReadDir(root);
265
- for (const host of hosts) {
266
- if (!host.isDirectory()) continue;
267
- const hostDir = path.join(root, host.name);
268
- const owners = await safeReadDir(hostDir);
269
- for (const owner of owners) {
270
- if (!owner.isDirectory()) continue;
271
- const ownerDir = path.join(hostDir, owner.name);
272
- const repos = await safeReadDir(ownerDir);
273
- for (const repo of repos) {
274
- if (!repo.isDirectory()) continue;
275
- const repoDir = path.join(ownerDir, repo.name);
276
- if (!isInside(root, repoDir)) continue;
423
+ await fs.rename(lock.path, releasePath);
424
+ } catch (error) {
425
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
426
+ throw error;
427
+ }
428
+ const movedStat = await fs.stat(releasePath).catch(() => undefined);
429
+ if (!movedStat || !sameFileIdentity(lock.identity, movedStat)) {
430
+ await restoreMovedLock(releasePath, lock.path);
431
+ return;
432
+ }
433
+ await fs.rm(releasePath, { force: true });
434
+ }
277
435
 
278
- try {
279
- if (!(await isManagedRepoCache(repoDir))) continue;
280
- const lastUsedAt = await getRepoLastUsedAt(repoDir);
281
- if (lastUsedAt === undefined || now - lastUsedAt <= CACHE_TTL_MS) continue;
282
- await fs.rm(repoDir, { recursive: true, force: true });
283
- deleted += 1;
284
- } catch (error) {
285
- const message = error instanceof Error ? error.message : String(error);
286
- errors.push(`${repoDir}: ${message}`);
287
- }
436
+ async function cleanupExpiredRepos(root: string, now: number, lock: CleanupLock): Promise<{ deleted: number; errors: string[] }> {
437
+ const errors: string[] = [];
438
+ let deleted = 0;
439
+ const ownershipBeforeScan = await cleanupLockOwnershipError(lock, "cache scan");
440
+ if (ownershipBeforeScan) return { deleted, errors: [ownershipBeforeScan] };
441
+
442
+ const hosts = await safeReadDir(root);
443
+ for (const host of hosts) {
444
+ if (!host.isDirectory()) continue;
445
+ const hostDir = path.join(root, host.name);
446
+ const owners = await safeReadDir(hostDir);
447
+ for (const owner of owners) {
448
+ if (!owner.isDirectory()) continue;
449
+ const ownerDir = path.join(hostDir, owner.name);
450
+ const repos = await safeReadDir(ownerDir);
451
+ for (const repo of repos) {
452
+ if (!repo.isDirectory()) continue;
453
+ const repoDir = path.join(ownerDir, repo.name);
454
+ if (!isInside(root, repoDir)) continue;
455
+
456
+ try {
457
+ if (!(await isManagedRepoCache(repoDir))) continue;
458
+ const lastUsedAt = await getRepoLastUsedAt(repoDir);
459
+ if (lastUsedAt === undefined || now - lastUsedAt <= CACHE_TTL_MS) continue;
460
+ const ownershipBeforeDelete = await cleanupLockOwnershipError(lock, `deleting ${repoDir}`);
461
+ if (ownershipBeforeDelete) return { deleted, errors: [...errors, ownershipBeforeDelete] };
462
+ await fs.rm(repoDir, { recursive: true, force: true });
463
+ deleted += 1;
464
+ } catch (error) {
465
+ const message = error instanceof Error ? error.message : String(error);
466
+ errors.push(`${repoDir}: ${message}`);
288
467
  }
289
468
  }
290
469
  }
470
+ }
291
471
 
292
- return { deleted, errors };
472
+ return { deleted, errors };
473
+ }
474
+
475
+ async function cleanupExpiredCache(cacheRoot: string): Promise<{ deleted: number; errors: string[] }> {
476
+ const now = Date.now();
477
+ const root = path.resolve(cacheRoot);
478
+ let lock: CleanupLock | undefined;
479
+ let heartbeat: NodeJS.Timeout | undefined;
480
+
481
+ try {
482
+ lock = await tryAcquireCleanupLock(root);
483
+ if (!lock) return { deleted: 0, errors: [] };
484
+ heartbeat = setInterval(() => {
485
+ if (lock) void heartbeatCleanupLock(lock).catch(() => undefined);
486
+ }, CLEANUP_LOCK_HEARTBEAT_MS);
487
+ heartbeat.unref?.();
488
+ return await cleanupExpiredRepos(root, now, lock);
293
489
  } finally {
294
- await lock?.close().catch(() => undefined);
295
- await fs.rm(lockPath, { force: true }).catch(() => undefined);
490
+ if (heartbeat) clearInterval(heartbeat);
491
+ if (lock) await releaseCleanupLock(lock).catch(() => undefined);
296
492
  }
297
493
  }
298
494
 
@@ -728,12 +924,44 @@ function buildUserPrompt(query: string, repos: string[], owners: string[], maxSe
728
924
  return `Task: locate and cite exact GitHub code locations that answer the query.\n\nQuery: ${query}\nRepository filters: ${repos.length ? repos.join(", ") : "(none)"}\nOwner filters: ${owners.length ? owners.join(", ") : "(none)"}\nMax search results per gh search call: ${maxSearchResults}\nLocal checkout cache: ${cache.mode === "enabled" ? `enabled at ${cache.root}` : "disabled"}\nCache decision: ${cache.decisionReason}\n\nRespond directly with concise, citation-heavy findings. Always pass --limit ${maxSearchResults} to gh search code unless a narrower command is clearly better.`;
729
925
  }
730
926
 
731
- type AssistantLikeMessage = {
732
- role?: string;
733
- content?: unknown;
734
- stopReason?: unknown;
735
- errorMessage?: unknown;
736
- };
927
+ function createEmptyUsage(): Usage {
928
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
929
+ }
930
+
931
+ function addUsage(total: Usage, usage: Usage): void {
932
+ total.input += usage.input || 0;
933
+ total.output += usage.output || 0;
934
+ total.cacheRead += usage.cacheRead || 0;
935
+ total.cacheWrite += usage.cacheWrite || 0;
936
+ if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
937
+ if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
938
+ total.totalTokens += usage.totalTokens || 0;
939
+ total.cost.input += usage.cost?.input || 0;
940
+ total.cost.output += usage.cost?.output || 0;
941
+ total.cost.cacheRead += usage.cost?.cacheRead || 0;
942
+ total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
943
+ total.cost.total += usage.cost?.total || 0;
944
+ }
945
+
946
+ type AssistantLikeMessage = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown; usage?: unknown };
947
+
948
+ function addAssistantMessageUsage(total: Usage, message: unknown): void {
949
+ const assistant = message as AssistantLikeMessage;
950
+ if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object") return;
951
+ addUsage(total, assistant.usage as Usage);
952
+ }
953
+
954
+ function aggregateAssistantUsage(messages: unknown[]): Usage {
955
+ const total = createEmptyUsage();
956
+ for (const message of messages) addAssistantMessageUsage(total, message);
957
+ return total;
958
+ }
959
+
960
+ function addSessionEventUsage(total: Usage, event: unknown): void {
961
+ const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
962
+ if (sessionEvent?.type === "message_end") return void addAssistantMessageUsage(total, sessionEvent.message);
963
+ if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage) addUsage(total, sessionEvent.result.usage);
964
+ }
737
965
 
738
966
  function getLastAssistantMessage(messages: unknown[]): AssistantLikeMessage | undefined {
739
967
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -743,17 +971,69 @@ function getLastAssistantMessage(messages: unknown[]): AssistantLikeMessage | un
743
971
  return undefined;
744
972
  }
745
973
 
746
- function extractLastAssistantText(messages: unknown[]): string {
974
+ function inspectFinalAssistant(messages: unknown[]):
975
+ | { ok: true; answer: string; message: AssistantLikeMessage; stopReason?: string }
976
+ | { ok: false; reason: string; message?: AssistantLikeMessage; stopReason?: string; errorMessage?: string } {
747
977
  const message = getLastAssistantMessage(messages);
748
- if (!message || !Array.isArray(message.content)) return "";
749
- const parts: string[] = [];
750
- for (const part of message.content) {
751
- if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
752
- const text = (part as { text?: unknown }).text;
753
- if (typeof text === "string") parts.push(text);
754
- }
978
+ if (!message) return { ok: false, reason: "the internal subagent produced no assistant message" };
979
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
980
+ const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
981
+ const answer = Array.isArray(message.content)
982
+ ? message.content
983
+ .map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string"
984
+ ? (part as { text: string }).text
985
+ : ""))
986
+ .join("")
987
+ .trim()
988
+ : "";
989
+ if (stopReason === "error") {
990
+ return {
991
+ ok: false,
992
+ reason: `the internal subagent stopped with an error: ${errorMessage || "provider/model error"}`,
993
+ message,
994
+ stopReason,
995
+ errorMessage: errorMessage || undefined,
996
+ };
997
+ }
998
+ if (stopReason === "aborted") {
999
+ return { ok: false, reason: "the internal subagent was aborted before producing an answer", message, stopReason };
755
1000
  }
756
- return parts.join("").trim();
1001
+ if (answer) return { ok: true, answer, message, stopReason: stopReason || undefined };
1002
+ return { ok: false, reason: describeNoAnswerReason(message, 0), message, stopReason: stopReason || undefined };
1003
+ }
1004
+
1005
+ function classifyRunFailure(error: unknown, callerAborted: boolean): { status: "aborted" | "error"; message: string; error?: string } {
1006
+ const message = callerAborted ? "Aborted" : error instanceof Error ? error.message : String(error);
1007
+ return { status: callerAborted ? "aborted" : "error", message, error: callerAborted ? undefined : message };
1008
+ }
1009
+
1010
+ function resolveLibrarianTerminalOutcome(
1011
+ outcome: ReturnType<typeof inspectFinalAssistant> | undefined,
1012
+ callerAborted: boolean,
1013
+ turns: number,
1014
+ fallbackReason?: string,
1015
+ ): { status: "done"; content: string } | { status: "aborted"; content: "Aborted" } | { status: "error"; reason: string } {
1016
+ if (callerAborted) return { status: "aborted", content: "Aborted" };
1017
+ if (outcome?.ok) return { status: "done", content: outcome.answer };
1018
+ const reason = fallbackReason ?? (outcome?.stopReason ? outcome.reason : describeNoAnswerReason(outcome?.message, turns));
1019
+ return { status: "error", reason };
1020
+ }
1021
+
1022
+ function createLibrarianAbortController(abortChild: () => void, onCallerAbort: () => void) {
1023
+ let callerAborted = false;
1024
+ return {
1025
+ get callerAborted() {
1026
+ return callerAborted;
1027
+ },
1028
+ abortForCleanup() {
1029
+ abortChild();
1030
+ },
1031
+ abortFromCaller() {
1032
+ callerAborted = true;
1033
+ onCallerAbort();
1034
+ abortChild();
1035
+ },
1036
+ };
757
1037
  }
758
1038
 
759
1039
  function describeNoAnswerReason(message: AssistantLikeMessage | undefined, turns: number): string {
@@ -801,18 +1081,29 @@ function renderAnswer(details: LibrarianDetails): string {
801
1081
  return details.status === "running" ? "(searching GitHub...)" : "(no output)";
802
1082
  }
803
1083
 
804
- function isAbortLikeError(error: unknown): boolean {
805
- if (error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError") return true;
806
- const message = error instanceof Error ? error.message : String(error);
807
- return /aborted|cancelled|canceled/i.test(message);
808
- }
809
-
810
1084
  export const __test__ = {
811
1085
  buildLibrarianCandidates,
812
1086
  findAvailableModel,
813
1087
  isModelAvailabilityError,
814
1088
  parseModelPreference,
815
1089
  resolveThinkingLevel,
1090
+ aggregateAssistantUsage,
1091
+ addAssistantMessageUsage,
1092
+ addSessionEventUsage,
1093
+ inspectFinalAssistant,
1094
+ classifyRunFailure,
1095
+ resolveLibrarianTerminalOutcome,
1096
+ createLibrarianAbortController,
1097
+ cleanupExpiredCache,
1098
+ cleanupExpiredRepos,
1099
+ cleanupLockOwnershipError,
1100
+ tryAcquireCleanupLock,
1101
+ heartbeatCleanupLock,
1102
+ releaseCleanupLock,
1103
+ CLEANUP_LOCK_FILE,
1104
+ CLEANUP_LOCK_STALE_MS,
1105
+ CLEANUP_LOCK_MAX_LEASE_MS,
1106
+ isCleanupLockLeaseExpired,
816
1107
  };
817
1108
 
818
1109
  export default function librarianExtension(pi: ExtensionAPI) {
@@ -1080,25 +1371,27 @@ export default function librarianExtension(pi: ExtensionAPI) {
1080
1371
  let unsubscribe: (() => void) | undefined;
1081
1372
  let runTimeout: NodeJS.Timeout | undefined;
1082
1373
  let abortListenerAdded = false;
1083
- let aborted = Boolean(signal?.aborted);
1374
+ const usage = createEmptyUsage();
1084
1375
 
1085
1376
  const emit = (force = false) => {
1086
1377
  void force;
1087
1378
  onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1088
1379
  };
1089
1380
 
1090
- const abort = () => {
1091
- aborted = true;
1092
- details.status = "aborted";
1093
- details.endedAt = Date.now();
1094
- lastContent = "Aborted";
1095
- emit(true);
1096
- void session?.abort();
1097
- };
1381
+ const abortController = createLibrarianAbortController(
1382
+ () => void session?.abort(),
1383
+ () => {
1384
+ details.status = "aborted";
1385
+ details.endedAt = Date.now();
1386
+ lastContent = "Aborted";
1387
+ emit(true);
1388
+ },
1389
+ );
1390
+ const abortFromCaller = () => abortController.abortFromCaller();
1098
1391
 
1099
- if (signal?.aborted) abort();
1392
+ if (signal?.aborted) abortFromCaller();
1100
1393
  if (signal && !signal.aborted) {
1101
- signal.addEventListener("abort", abort);
1394
+ signal.addEventListener("abort", abortFromCaller);
1102
1395
  abortListenerAdded = true;
1103
1396
  }
1104
1397
 
@@ -1137,7 +1430,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1137
1430
 
1138
1431
  const runAttempt = async (
1139
1432
  candidate: LibrarianCandidate,
1140
- ): Promise<{ answer: string; lastAssistant: AssistantLikeMessage | undefined }> => {
1433
+ ): Promise<ReturnType<typeof inspectFinalAssistant>> => {
1141
1434
  details.model = candidate.details;
1142
1435
  details.turns = 0;
1143
1436
  details.toolCalls = [];
@@ -1145,7 +1438,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1145
1438
 
1146
1439
  const created = await createAgentSession({
1147
1440
  cwd: workspace,
1148
- modelRegistry: ctx.modelRegistry,
1441
+ ...getModelRuntimeOption(ctx),
1149
1442
  resourceLoader,
1150
1443
  sessionManager: SessionManager.inMemory(workspace),
1151
1444
  model: candidate.model,
@@ -1154,7 +1447,9 @@ export default function librarianExtension(pi: ExtensionAPI) {
1154
1447
  });
1155
1448
 
1156
1449
  session = created.session as typeof session;
1450
+ if (abortController.callerAborted) abortController.abortForCleanup();
1157
1451
  unsubscribe = (created.session as any).subscribe((event: any) => {
1452
+ addSessionEventUsage(usage, event);
1158
1453
  switch (event.type) {
1159
1454
  case "turn_end":
1160
1455
  details.turns += 1;
@@ -1185,22 +1480,20 @@ export default function librarianExtension(pi: ExtensionAPI) {
1185
1480
  });
1186
1481
 
1187
1482
  try {
1188
- if (!aborted) {
1483
+ if (!abortController.callerAborted) {
1189
1484
  const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1190
1485
  expandPromptTemplates: false,
1191
1486
  });
1192
1487
  const timeoutPromise = new Promise<never>((_resolve, reject) => {
1193
1488
  runTimeout = setTimeout(() => {
1194
- abort();
1489
+ abortController.abortForCleanup();
1195
1490
  reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1196
1491
  }, MAX_RUN_MS);
1197
1492
  });
1198
1493
  await Promise.race([promptPromise, timeoutPromise]);
1199
1494
  }
1200
1495
 
1201
- const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1202
- const answer = session ? extractLastAssistantText(session.state.messages) : "";
1203
- return { answer, lastAssistant };
1496
+ return inspectFinalAssistant(session?.state.messages ?? []);
1204
1497
  } finally {
1205
1498
  if (runTimeout) {
1206
1499
  clearTimeout(runTimeout);
@@ -1213,57 +1506,48 @@ export default function librarianExtension(pi: ExtensionAPI) {
1213
1506
  }
1214
1507
  };
1215
1508
 
1216
- let answer = "";
1217
- let lastAssistant: AssistantLikeMessage | undefined;
1509
+ let lastOutcome: ReturnType<typeof inspectFinalAssistant> | undefined;
1218
1510
  let attemptErrorReason: string | undefined;
1219
1511
  for (let index = 0; index < candidates.length; index++) {
1220
- if (aborted) break;
1512
+ if (abortController.callerAborted) break;
1221
1513
  const candidate =
1222
1514
  index === 0 ? candidates[index] : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1223
1515
 
1224
- let attempt: { answer: string; lastAssistant: AssistantLikeMessage | undefined };
1516
+ let attempt: ReturnType<typeof inspectFinalAssistant>;
1225
1517
  try {
1226
1518
  attempt = await runAttempt(candidate);
1227
1519
  } catch (error) {
1228
- // Let aborts/timeouts propagate to the outer handler.
1229
- if (aborted || isAbortLikeError(error)) throw error;
1520
+ if (abortController.callerAborted) throw error;
1230
1521
  // Some providers throw (rather than carry the error on the assistant
1231
1522
  // message) when a model is unavailable; treat that like a
1232
1523
  // message-carried availability error and fall back to the next model.
1233
1524
  const message = error instanceof Error ? error.message : String(error);
1234
1525
  if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1235
- answer = "";
1236
- lastAssistant = undefined;
1526
+ lastOutcome = undefined;
1237
1527
  attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1238
1528
  continue;
1239
1529
  }
1240
1530
  throw error;
1241
1531
  }
1242
1532
 
1243
- answer = attempt.answer;
1244
- lastAssistant = attempt.lastAssistant;
1245
- if (answer || aborted) break;
1533
+ lastOutcome = attempt;
1534
+ if (abortController.callerAborted || attempt.ok) break;
1246
1535
 
1247
- attemptErrorReason = describeNoAnswerReason(lastAssistant, details.turns);
1248
- const errorMessage =
1249
- lastAssistant && lastAssistant.stopReason === "error" && typeof lastAssistant.errorMessage === "string"
1250
- ? lastAssistant.errorMessage
1251
- : undefined;
1252
- const canFallBack = index < candidates.length - 1 && isModelAvailabilityError(errorMessage);
1536
+ attemptErrorReason = attempt.stopReason ? attempt.reason : describeNoAnswerReason(attempt.message, details.turns);
1537
+ const canFallBack =
1538
+ index < candidates.length - 1 &&
1539
+ attempt.stopReason === "error" &&
1540
+ isModelAvailabilityError(attempt.errorMessage);
1253
1541
  if (!canFallBack) break;
1254
1542
  }
1255
1543
 
1256
- if (answer) {
1257
- lastContent = answer;
1258
- details.status = "done";
1259
- } else if (aborted) {
1260
- lastContent = "Aborted";
1261
- details.status = "aborted";
1544
+ const terminal = resolveLibrarianTerminalOutcome(lastOutcome, abortController.callerAborted, details.turns, attemptErrorReason);
1545
+ details.status = terminal.status;
1546
+ if (terminal.status === "error") {
1547
+ lastContent = formatInternalFailure(details, terminal.reason);
1548
+ details.error = terminal.reason;
1262
1549
  } else {
1263
- details.status = "error";
1264
- const reason = attemptErrorReason ?? describeNoAnswerReason(lastAssistant, details.turns);
1265
- lastContent = formatInternalFailure(details, reason);
1266
- details.error = reason;
1550
+ lastContent = terminal.content;
1267
1551
  }
1268
1552
  details.endedAt = Date.now();
1269
1553
  emit(true);
@@ -1271,12 +1555,13 @@ export default function librarianExtension(pi: ExtensionAPI) {
1271
1555
  return {
1272
1556
  content: [{ type: "text", text: lastContent }],
1273
1557
  details,
1558
+ usage,
1274
1559
  };
1275
1560
  } catch (error) {
1276
- const wasAbort = aborted || isAbortLikeError(error);
1277
- const message = wasAbort ? "Aborted" : error instanceof Error ? error.message : String(error);
1278
- details.status = wasAbort ? "aborted" : "error";
1279
- details.error = wasAbort ? undefined : message;
1561
+ const failure = classifyRunFailure(error, abortController.callerAborted);
1562
+ const { message } = failure;
1563
+ details.status = failure.status;
1564
+ details.error = failure.error;
1280
1565
  details.endedAt = Date.now();
1281
1566
  lastContent = message;
1282
1567
  emit(true);
@@ -1284,10 +1569,11 @@ export default function librarianExtension(pi: ExtensionAPI) {
1284
1569
  return {
1285
1570
  content: [{ type: "text", text: message }],
1286
1571
  details,
1572
+ usage,
1287
1573
  };
1288
1574
  } finally {
1289
1575
  if (runTimeout) clearTimeout(runTimeout);
1290
- if (signal && abortListenerAdded) signal.removeEventListener("abort", abort);
1576
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1291
1577
  unsubscribe?.();
1292
1578
  session?.dispose();
1293
1579
  }