@diegopetrucci/pi-extensions 0.1.74 → 0.1.78

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 (108) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +17 -12
  3. package/extensions/agent-workflow-audit/README.md +1 -1
  4. package/extensions/agent-workflow-audit/index.ts +1631 -1009
  5. package/extensions/agent-workflow-audit/package.json +1 -1
  6. package/extensions/annotate-git-diff/README.md +3 -1
  7. package/extensions/annotate-git-diff/clipboard.ts +109 -100
  8. package/extensions/annotate-git-diff/git.ts +846 -768
  9. package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
  10. package/extensions/annotate-git-diff/index.ts +712 -671
  11. package/extensions/annotate-git-diff/package.json +1 -1
  12. package/extensions/annotate-git-diff/prompt.ts +45 -45
  13. package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
  14. package/extensions/annotate-git-diff/review-server.ts +337 -311
  15. package/extensions/annotate-git-diff/types.ts +132 -132
  16. package/extensions/annotate-git-diff/ui.ts +27 -27
  17. package/extensions/annotate-git-diff/watch.ts +74 -74
  18. package/extensions/annotate-git-diff/web/app.js +1947 -1884
  19. package/extensions/annotate-git-diff/web/index.html +5 -6
  20. package/extensions/annotate-last-message/README.md +1 -1
  21. package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
  22. package/extensions/annotate-last-message/index.ts +210 -206
  23. package/extensions/annotate-last-message/package.json +1 -1
  24. package/extensions/annotate-last-message/prompt.ts +87 -66
  25. package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
  26. package/extensions/annotate-last-message/session.ts +94 -89
  27. package/extensions/annotate-last-message/theme.ts +199 -193
  28. package/extensions/annotate-last-message/types.ts +23 -23
  29. package/extensions/annotate-last-message/ui.ts +27 -25
  30. package/extensions/annotate-last-message/web/app.js +4 -7
  31. package/extensions/annotate-last-message/web/index.html +3 -3
  32. package/extensions/annotate-last-message/web/md-renderer.js +1 -1
  33. package/extensions/brrr/README.md +1 -1
  34. package/extensions/brrr/index.ts +213 -191
  35. package/extensions/brrr/package.json +1 -1
  36. package/extensions/claude-fast/README.md +5 -79
  37. package/extensions/claude-fast/index.ts +31 -289
  38. package/extensions/claude-fast/package.json +2 -3
  39. package/extensions/code-reviewer/README.md +6 -6
  40. package/extensions/code-reviewer/index.ts +1825 -1373
  41. package/extensions/code-reviewer/package.json +1 -1
  42. package/extensions/confirm-destructive/README.md +1 -1
  43. package/extensions/confirm-destructive/index.ts +50 -52
  44. package/extensions/confirm-destructive/package.json +1 -1
  45. package/extensions/context-cap/README.md +9 -9
  46. package/extensions/context-cap/index.ts +313 -305
  47. package/extensions/context-cap/package.json +2 -2
  48. package/extensions/context-inspector/index.ts +1211 -1064
  49. package/extensions/context-inspector/package.json +1 -1
  50. package/extensions/contrarian/README.md +4 -3
  51. package/extensions/contrarian/index.ts +1863 -1737
  52. package/extensions/contrarian/package.json +1 -1
  53. package/extensions/dirty-repo-guard/index.ts +37 -41
  54. package/extensions/dirty-repo-guard/package.json +1 -1
  55. package/extensions/dynamic-context-pruning/README.md +11 -43
  56. package/extensions/dynamic-context-pruning/index.ts +2131 -2105
  57. package/extensions/dynamic-context-pruning/package.json +1 -1
  58. package/extensions/fast/README.md +52 -14
  59. package/extensions/fast/index.ts +513 -317
  60. package/extensions/fast/package.json +1 -1
  61. package/extensions/git-footer/README.md +1 -1
  62. package/extensions/git-footer/index.ts +458 -411
  63. package/extensions/git-footer/package.json +2 -3
  64. package/extensions/gnosis/index.ts +180 -130
  65. package/extensions/gnosis/package.json +1 -1
  66. package/extensions/inline-bash/index.ts +87 -103
  67. package/extensions/inline-bash/package.json +1 -1
  68. package/extensions/librarian/README.md +1 -1
  69. package/extensions/librarian/index.ts +1877 -1419
  70. package/extensions/librarian/package.json +1 -1
  71. package/extensions/minimal-footer/README.md +5 -5
  72. package/extensions/minimal-footer/index.ts +987 -1012
  73. package/extensions/minimal-footer/minimal-footer.example.json +1 -1
  74. package/extensions/minimal-footer/openai-usage.ts +195 -190
  75. package/extensions/minimal-footer/package.json +1 -1
  76. package/extensions/notify/index.ts +222 -230
  77. package/extensions/notify/package.json +1 -1
  78. package/extensions/openai-fast/README.md +5 -73
  79. package/extensions/openai-fast/index.ts +32 -272
  80. package/extensions/openai-fast/package.json +2 -3
  81. package/extensions/oracle/README.md +4 -3
  82. package/extensions/oracle/index.ts +1740 -1627
  83. package/extensions/oracle/package.json +1 -1
  84. package/extensions/permission-gate/README.md +3 -0
  85. package/extensions/permission-gate/index.ts +785 -612
  86. package/extensions/permission-gate/package.json +1 -1
  87. package/extensions/permission-gate/powershell-safety.ts +840 -793
  88. package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
  89. package/extensions/project-mcp-json/README.md +199 -0
  90. package/extensions/project-mcp-json/config.ts +564 -0
  91. package/extensions/project-mcp-json/index.ts +119 -0
  92. package/extensions/project-mcp-json/package.json +38 -0
  93. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  94. package/extensions/quiet-tools/README.md +30 -19
  95. package/extensions/quiet-tools/index.ts +376 -291
  96. package/extensions/quiet-tools/package.json +2 -2
  97. package/extensions/review/README.md +2 -2
  98. package/extensions/review/index.ts +1745 -1872
  99. package/extensions/review/package.json +1 -1
  100. package/extensions/todo/index.ts +280 -280
  101. package/extensions/todo/package.json +1 -1
  102. package/extensions/triage-comments/README.md +6 -4
  103. package/extensions/triage-comments/index.ts +2620 -1888
  104. package/extensions/triage-comments/package.json +1 -1
  105. package/package.json +9 -6
  106. package/assets/social-preview.png +0 -0
  107. package/extensions/claude-fast/claude-fast.example.json +0 -4
  108. package/extensions/openai-fast/openai-fast.example.json +0 -4
@@ -1,20 +1,27 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { Dirent, Stats } from "node:fs";
3
+ import { realpathSync } from "node:fs";
3
4
  import type { FileHandle } from "node:fs/promises";
4
5
  import * as fs from "node:fs/promises";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
8
+ import { fileURLToPath } from "node:url";
7
9
 
8
- import type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "@earendil-works/pi-coding-agent";
10
+ import type {
11
+ ExtensionAPI,
12
+ ExtensionContext,
13
+ ExtensionFactory,
14
+ } from "@earendil-works/pi-coding-agent";
9
15
  import type { Usage } from "@earendil-works/pi-ai";
10
16
  import {
11
- DefaultResourceLoader,
12
- SessionManager,
13
- SettingsManager,
14
- createAgentSession,
15
- getAgentDir,
16
- getMarkdownTheme,
17
+ DefaultResourceLoader,
18
+ SessionManager,
19
+ SettingsManager,
20
+ createAgentSession,
21
+ getAgentDir,
22
+ getMarkdownTheme,
17
23
  } from "@earendil-works/pi-coding-agent";
24
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
18
25
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
19
26
  import { Type } from "typebox";
20
27
 
@@ -41,692 +48,825 @@ type CacheMode = "disabled" | "enabled";
41
48
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
42
49
  type ThinkingLevelMap = Partial<Record<ThinkingLevel, unknown | null>>;
43
50
  type PiModel = {
44
- provider: string;
45
- id: string;
46
- reasoning?: boolean;
47
- thinkingLevelMap?: ThinkingLevelMap;
51
+ provider: string;
52
+ id: string;
53
+ reasoning?: boolean;
54
+ thinkingLevelMap?: ThinkingLevelMap;
48
55
  };
49
56
 
50
57
  type ModelSelectionContext = {
51
- model?: any;
52
- modelRegistry: { getAvailable(): any[] | Promise<any[]> };
53
- scopedModels?: ReadonlyArray<{ model: any; thinkingLevel?: ThinkingLevel }>;
58
+ model?: any;
59
+ modelRegistry: { getAvailable(): any[] | Promise<any[]> };
60
+ scopedModels?: ReadonlyArray<{ model: any; thinkingLevel?: ThinkingLevel }>;
54
61
  };
55
62
 
56
63
  function modelKey(model: any): string {
57
- return `${model.provider}/${model.id}`.toLowerCase();
64
+ return `${model.provider}/${model.id}`.toLowerCase();
58
65
  }
59
66
 
60
67
  function hasModelScope(ctx: ModelSelectionContext): boolean {
61
- return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
68
+ return Array.isArray(ctx.scopedModels) && ctx.scopedModels.length > 0;
62
69
  }
63
70
 
64
71
  function isModelInScope(ctx: ModelSelectionContext, model: any): boolean {
65
- if (!hasModelScope(ctx)) return true;
66
- const key = modelKey(model);
67
- return ctx.scopedModels!.some((entry) => modelKey(entry.model) === key);
72
+ if (!hasModelScope(ctx)) return true;
73
+ const key = modelKey(model);
74
+ return ctx.scopedModels!.some((entry) => modelKey(entry.model) === key);
68
75
  }
69
76
 
70
77
  async function getAutoSelectableModels(ctx: ModelSelectionContext): Promise<any[]> {
71
- const available = await ctx.modelRegistry.getAvailable();
72
- return hasModelScope(ctx) ? available.filter((model) => isModelInScope(ctx, model)) : available;
78
+ const available = await ctx.modelRegistry.getAvailable();
79
+ return hasModelScope(ctx) ? available.filter((model) => isModelInScope(ctx, model)) : available;
73
80
  }
74
81
 
75
82
  type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
76
83
 
77
- function getModelRuntimeOption(ctx: { modelRegistry?: unknown }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
78
- const modelRuntime = (ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined)?.runtime;
79
- return modelRuntime ? { modelRuntime } : {};
84
+ function getModelRuntimeOption(ctx: {
85
+ modelRegistry?: unknown;
86
+ }): Pick<CreateAgentSessionOptions, "modelRuntime"> {
87
+ const modelRuntime = (
88
+ ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
89
+ )?.runtime;
90
+ return modelRuntime ? { modelRuntime } : {};
80
91
  }
81
92
 
82
93
  const DEFAULT_CACHE_MODE: CacheMode = "disabled";
83
94
  const DEFAULT_THINKING_LEVEL: ThinkingLevel = "low";
84
95
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
96
+ const SUBAGENT_MCP_TOOLS = [
97
+ "mcp__*",
98
+ "tool_search",
99
+ "list_mcp_resources",
100
+ "list_mcp_resource_templates",
101
+ "read_mcp_resource",
102
+ ];
103
+
104
+ // Derive project-trust for the isolated subagent session from the host tool context.
105
+ // Fails closed: returns false if the host has not trusted the project, or if the subagent
106
+ // runs in a different directory (e.g. a temp workspace), preventing an untrusted project's
107
+ // .pi/mcp.json from being loaded by the MCP extension.
108
+ function resolveSubagentProjectTrusted(
109
+ ctx: { cwd: string; isProjectTrusted(): boolean },
110
+ subagentCwd: string,
111
+ ): boolean {
112
+ // Feature-detected for older Pi hosts that may lack isProjectTrusted.
113
+ const hostTrusted =
114
+ typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
115
+ ? ctx.isProjectTrusted()
116
+ : false;
117
+ if (!hostTrusted) return false;
118
+ try {
119
+ return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
120
+ } catch {
121
+ return false; // fail closed if realpath resolution fails
122
+ }
123
+ }
124
+
125
+ /** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage → false). */
126
+ function isMcpCompatibleVersion(v: string | undefined): boolean {
127
+ if (!v) return false;
128
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
129
+ if (!m) return false;
130
+ const major = Number(m[1]),
131
+ minor = Number(m[2]),
132
+ patch = Number(m[3]);
133
+ if (major !== 1) return major > 1;
134
+ if (minor !== 0) return minor > 0;
135
+ return patch >= 4;
136
+ }
137
+
138
+ // Safe runtime access to MCP/tool-search factories — feature-detected for Pi >=0.99.0 hosts.
139
+ // Additionally gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
140
+ // matches that resolve nothing, so spawning MCP servers provides no benefit.
141
+ function createMcpSubagentFactories(): ExtensionFactory[] {
142
+ const ns = piCodingAgent as Record<string, unknown>;
143
+ if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
144
+ const factories: ExtensionFactory[] = [];
145
+ const mcp = ns["createMcpExtension"];
146
+ const toolSearch = ns["createToolSearchExtension"];
147
+ if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
148
+ if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
149
+ return factories;
150
+ }
151
+
152
+ // Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
153
+ async function disposeSubagentSession(
154
+ session: { extensionRunner?: unknown; dispose(): void } | undefined,
155
+ ): Promise<void> {
156
+ if (!session) return;
157
+ try {
158
+ const runner = session.extensionRunner as
159
+ | {
160
+ hasHandlers(n: string): boolean;
161
+ emit(e: { type: string; reason: string }): Promise<unknown>;
162
+ }
163
+ | undefined;
164
+ if (runner?.hasHandlers("session_shutdown")) {
165
+ await runner.emit({ type: "session_shutdown", reason: "quit" });
166
+ }
167
+ } catch {
168
+ // Shutdown errors must not prevent dispose.
169
+ }
170
+ session.dispose();
171
+ }
85
172
 
86
173
  const PREFERRED_FAST_MODEL_PATTERNS = [
87
- /\bgpt[-_. ]?5\.5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
88
- /\bgpt[-_. ]?5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
89
- /\bgpt[-_. ]?4(?:\.1|o)?(?:[-_. ].*)?\b(?:mini|nano)\b/,
90
- /\bgemini\b.*\b(?:flash|flash-lite|lite)\b/,
91
- /\bclaude\b.*\bhaiku\b/,
92
- /\b(?:mistral|codestral)\b.*\b(?:small|mini|lite)\b/,
174
+ /\bgpt[-_. ]?5\.5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
175
+ /\bgpt[-_. ]?5(?:[-_. ].*)?\b(?:mini|nano|fast|lite)\b/,
176
+ /\bgpt[-_. ]?4(?:\.1|o)?(?:[-_. ].*)?\b(?:mini|nano)\b/,
177
+ /\bgemini\b.*\b(?:flash|flash-lite|lite)\b/,
178
+ /\bclaude\b.*\bhaiku\b/,
179
+ /\b(?:mistral|codestral)\b.*\b(?:small|mini|lite)\b/,
93
180
  ];
94
181
 
95
182
  const HEAVY_MODEL_PATTERN = /\b(?:opus|pro|ultra|max)\b/;
96
183
 
97
184
  type ToolCall = {
98
- id: string;
99
- name: string;
100
- args: unknown;
101
- startedAt: number;
102
- endedAt?: number;
103
- isError?: boolean;
185
+ id: string;
186
+ name: string;
187
+ args: unknown;
188
+ startedAt: number;
189
+ endedAt?: number;
190
+ isError?: boolean;
104
191
  };
105
192
 
106
193
  type CacheDetails = {
107
- mode: CacheMode;
108
- root: string;
109
- ttlDays: number;
110
- cleanupDeleted: number;
111
- cleanupErrors: string[];
112
- decisionReason: string;
194
+ mode: CacheMode;
195
+ root: string;
196
+ ttlDays: number;
197
+ cleanupDeleted: number;
198
+ cleanupErrors: string[];
199
+ decisionReason: string;
113
200
  };
114
201
 
115
202
  type LibrarianModelDetails = {
116
- modelRef: string;
117
- modelId: string;
118
- provider: string;
119
- thinkingLevel: ThinkingLevel;
120
- autoSelected: boolean;
121
- selectionReason: string;
203
+ modelRef: string;
204
+ modelId: string;
205
+ provider: string;
206
+ thinkingLevel: ThinkingLevel;
207
+ autoSelected: boolean;
208
+ selectionReason: string;
122
209
  };
123
210
 
124
211
  type LibrarianDetails = {
125
- status: LibrarianStatus;
126
- workspace: string;
127
- cache: CacheDetails;
128
- model: LibrarianModelDetails;
129
- turns: number;
130
- toolCalls: ToolCall[];
131
- startedAt: number;
132
- endedAt?: number;
133
- error?: string;
212
+ status: LibrarianStatus;
213
+ workspace: string;
214
+ cache: CacheDetails;
215
+ model: LibrarianModelDetails;
216
+ turns: number;
217
+ toolCalls: ToolCall[];
218
+ startedAt: number;
219
+ endedAt?: number;
220
+ error?: string;
134
221
  };
135
222
 
136
223
  const LibrarianParams = Type.Object({
137
- query: Type.String({
138
- description: [
139
- "Describe exactly what to find in GitHub code.",
140
- "Include known context in the query when you have it (symbols/behavior, repo or owner hints, refs/branches, paths, and desired output).",
141
- "Do not guess unknown details; if scope is uncertain, say that explicitly and let Librarian discover it.",
142
- "Librarian returns concise path-first findings with line-ranged evidence from GitHub files.",
143
- ].join("\n"),
144
- }),
145
- repos: Type.Optional(
146
- Type.Array(Type.String({ description: "Optional owner/repo filters (e.g. octocat/hello-world)" }), {
147
- description: "Optional explicit repository scope.",
148
- maxItems: 30,
149
- }),
150
- ),
151
- owners: Type.Optional(
152
- Type.Array(Type.String({ description: "Optional owner/org filters" }), {
153
- description: "Optional owner/org scope.",
154
- maxItems: 30,
155
- }),
156
- ),
157
- maxSearchResults: Type.Optional(
158
- Type.Number({
159
- description: `Maximum GitHub search hits per query (1-${MAX_SEARCH_RESULTS}, default ${DEFAULT_MAX_SEARCH_RESULTS})`,
160
- minimum: 1,
161
- maximum: MAX_SEARCH_RESULTS,
162
- default: DEFAULT_MAX_SEARCH_RESULTS,
163
- }),
164
- ),
165
- model: Type.Optional(
166
- Type.String({
167
- description: "Optional model override for the internal librarian subagent. Use provider/model or auto.",
168
- }),
169
- ),
170
- thinkingLevel: Type.Optional(
171
- Type.String({
172
- description: `Optional thinking override for the internal librarian subagent (${THINKING_LEVELS.join(" | ")}). Default ${DEFAULT_THINKING_LEVEL}.`,
173
- }),
174
- ),
224
+ query: Type.String({
225
+ description: [
226
+ "Describe exactly what to find in GitHub code.",
227
+ "Include known context in the query when you have it (symbols/behavior, repo or owner hints, refs/branches, paths, and desired output).",
228
+ "Do not guess unknown details; if scope is uncertain, say that explicitly and let Librarian discover it.",
229
+ "Librarian returns concise path-first findings with line-ranged evidence from GitHub files.",
230
+ ].join("\n"),
231
+ }),
232
+ repos: Type.Optional(
233
+ Type.Array(
234
+ Type.String({ description: "Optional owner/repo filters (e.g. octocat/hello-world)" }),
235
+ {
236
+ description: "Optional explicit repository scope.",
237
+ maxItems: 30,
238
+ },
239
+ ),
240
+ ),
241
+ owners: Type.Optional(
242
+ Type.Array(Type.String({ description: "Optional owner/org filters" }), {
243
+ description: "Optional owner/org scope.",
244
+ maxItems: 30,
245
+ }),
246
+ ),
247
+ maxSearchResults: Type.Optional(
248
+ Type.Number({
249
+ description: `Maximum GitHub search hits per query (1-${MAX_SEARCH_RESULTS}, default ${DEFAULT_MAX_SEARCH_RESULTS})`,
250
+ minimum: 1,
251
+ maximum: MAX_SEARCH_RESULTS,
252
+ default: DEFAULT_MAX_SEARCH_RESULTS,
253
+ }),
254
+ ),
255
+ model: Type.Optional(
256
+ Type.String({
257
+ description:
258
+ "Optional model override for the internal librarian subagent. Use provider/model or auto.",
259
+ }),
260
+ ),
261
+ thinkingLevel: Type.Optional(
262
+ Type.String({
263
+ description: `Optional thinking override for the internal librarian subagent (${THINKING_LEVELS.join(" | ")}). Default ${DEFAULT_THINKING_LEVEL}.`,
264
+ }),
265
+ ),
175
266
  });
176
267
 
177
268
  function asStringArray(value: unknown, maxItems = 30): string[] {
178
- if (!Array.isArray(value)) return [];
179
- const out: string[] = [];
180
- for (const item of value) {
181
- if (typeof item !== "string") continue;
182
- const trimmed = item.trim();
183
- if (!trimmed) continue;
184
- out.push(trimmed);
185
- if (out.length >= maxItems) break;
186
- }
187
- return out;
269
+ if (!Array.isArray(value)) return [];
270
+ const out: string[] = [];
271
+ for (const item of value) {
272
+ if (typeof item !== "string") continue;
273
+ const trimmed = item.trim();
274
+ if (!trimmed) continue;
275
+ out.push(trimmed);
276
+ if (out.length >= maxItems) break;
277
+ }
278
+ return out;
188
279
  }
189
280
 
190
281
  function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
191
- const parsed = typeof value === "number" ? value : Number(value);
192
- if (!Number.isFinite(parsed)) return fallback;
193
- return Math.min(max, Math.max(min, Math.floor(parsed)));
282
+ const parsed = typeof value === "number" ? value : Number(value);
283
+ if (!Number.isFinite(parsed)) return fallback;
284
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
194
285
  }
195
286
 
196
287
  function shorten(text: string, max: number): string {
197
- const oneLine = text.replace(/\s+/g, " ").trim();
198
- if (oneLine.length <= max) return oneLine;
199
- return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
288
+ const oneLine = text.replace(/\s+/g, " ").trim();
289
+ if (oneLine.length <= max) return oneLine;
290
+ return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
200
291
  }
201
292
 
202
293
  function getUserCacheReposRoot(): string {
203
- if (process.env.PI_LIBRARIAN_CACHE_ROOT?.trim()) {
204
- return path.resolve(expandHome(process.env.PI_LIBRARIAN_CACHE_ROOT.trim()));
205
- }
294
+ if (process.env.PI_LIBRARIAN_CACHE_ROOT?.trim()) {
295
+ return path.resolve(expandHome(process.env.PI_LIBRARIAN_CACHE_ROOT.trim()));
296
+ }
206
297
 
207
- if (process.platform === "darwin") {
208
- return path.join(os.homedir(), "Library", "Caches", "pi-librarian", "repos");
209
- }
298
+ if (process.platform === "darwin") {
299
+ return path.join(os.homedir(), "Library", "Caches", "pi-librarian", "repos");
300
+ }
210
301
 
211
- if (process.platform === "win32") {
212
- const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
213
- return path.join(localAppData, "pi-librarian", "repos");
214
- }
302
+ if (process.platform === "win32") {
303
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
304
+ return path.join(localAppData, "pi-librarian", "repos");
305
+ }
215
306
 
216
- const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
217
- return path.join(xdgCacheHome, "pi-librarian", "repos");
307
+ const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
308
+ return path.join(xdgCacheHome, "pi-librarian", "repos");
218
309
  }
219
310
 
220
311
  function expandHome(value: string): string {
221
- if (value === "~") return os.homedir();
222
- if (value.startsWith(`~${path.sep}`)) return path.join(os.homedir(), value.slice(2));
223
- if (path.sep === "/" && value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
224
- return value;
312
+ if (value === "~") return os.homedir();
313
+ if (value.startsWith(`~${path.sep}`)) return path.join(os.homedir(), value.slice(2));
314
+ if (path.sep === "/" && value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
315
+ return value;
225
316
  }
226
317
 
227
318
  async function pathExists(filePath: string): Promise<boolean> {
228
- try {
229
- await fs.access(filePath);
230
- return true;
231
- } catch {
232
- return false;
233
- }
319
+ try {
320
+ await fs.access(filePath);
321
+ return true;
322
+ } catch {
323
+ return false;
324
+ }
234
325
  }
235
326
 
236
327
  async function safeReadDir(dir: string): Promise<Dirent[]> {
237
- try {
238
- return await fs.readdir(dir, { withFileTypes: true });
239
- } catch (error) {
240
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
241
- throw error;
242
- }
328
+ try {
329
+ return await fs.readdir(dir, { withFileTypes: true });
330
+ } catch (error) {
331
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
332
+ throw error;
333
+ }
243
334
  }
244
335
 
245
336
  function isInside(parent: string, child: string): boolean {
246
- const parentResolved = path.resolve(parent);
247
- const childResolved = path.resolve(child);
248
- return childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`);
337
+ const parentResolved = path.resolve(parent);
338
+ const childResolved = path.resolve(child);
339
+ return (
340
+ childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`)
341
+ );
249
342
  }
250
343
 
251
344
  function parseLastUsedAt(value: unknown): number | undefined {
252
- if (typeof value === "number" && Number.isFinite(value)) return value;
253
- if (typeof value === "string") {
254
- const numeric = Number(value);
255
- if (Number.isFinite(numeric)) return numeric;
256
- const parsed = Date.parse(value);
257
- if (Number.isFinite(parsed)) return parsed;
258
- }
259
- return undefined;
345
+ if (typeof value === "number" && Number.isFinite(value)) return value;
346
+ if (typeof value === "string") {
347
+ const numeric = Number(value);
348
+ if (Number.isFinite(numeric)) return numeric;
349
+ const parsed = Date.parse(value);
350
+ if (Number.isFinite(parsed)) return parsed;
351
+ }
352
+ return undefined;
260
353
  }
261
354
 
262
355
  async function getRepoLastUsedAt(repoDir: string): Promise<number | undefined> {
263
- const metadataPath = path.join(repoDir, CACHE_METADATA_FILE);
264
- try {
265
- const raw = await fs.readFile(metadataPath, "utf8");
266
- const parsed = JSON.parse(raw) as { lastUsedAt?: unknown; updatedAt?: unknown; createdAt?: unknown };
267
- const fromMetadata =
268
- parseLastUsedAt(parsed.lastUsedAt) ?? parseLastUsedAt(parsed.updatedAt) ?? parseLastUsedAt(parsed.createdAt);
269
- if (fromMetadata !== undefined) return fromMetadata;
270
- } catch {
271
- // Fall through to marker mtime for older managed caches.
272
- }
273
-
274
- const markerPath = path.join(repoDir, CACHE_MARKER_FILE);
275
- try {
276
- return (await fs.stat(markerPath)).mtimeMs;
277
- } catch {
278
- return undefined;
279
- }
356
+ const metadataPath = path.join(repoDir, CACHE_METADATA_FILE);
357
+ try {
358
+ const raw = await fs.readFile(metadataPath, "utf8");
359
+ const parsed = JSON.parse(raw) as {
360
+ lastUsedAt?: unknown;
361
+ updatedAt?: unknown;
362
+ createdAt?: unknown;
363
+ };
364
+ const fromMetadata =
365
+ parseLastUsedAt(parsed.lastUsedAt) ??
366
+ parseLastUsedAt(parsed.updatedAt) ??
367
+ parseLastUsedAt(parsed.createdAt);
368
+ if (fromMetadata !== undefined) return fromMetadata;
369
+ } catch {
370
+ // Fall through to marker mtime for older managed caches.
371
+ }
372
+
373
+ const markerPath = path.join(repoDir, CACHE_MARKER_FILE);
374
+ try {
375
+ return (await fs.stat(markerPath)).mtimeMs;
376
+ } catch {
377
+ return undefined;
378
+ }
280
379
  }
281
380
 
282
381
  async function isManagedRepoCache(repoDir: string): Promise<boolean> {
283
- return (await pathExists(path.join(repoDir, ".git"))) &&
284
- ((await pathExists(path.join(repoDir, CACHE_METADATA_FILE))) ||
285
- (await pathExists(path.join(repoDir, CACHE_MARKER_FILE))));
382
+ return (
383
+ (await pathExists(path.join(repoDir, ".git"))) &&
384
+ ((await pathExists(path.join(repoDir, CACHE_METADATA_FILE))) ||
385
+ (await pathExists(path.join(repoDir, CACHE_MARKER_FILE))))
386
+ );
286
387
  }
287
388
 
288
389
  type CleanupLockOwner = {
289
- pid: number;
290
- hostname: string;
291
- token: string;
292
- acquiredAt: string;
390
+ pid: number;
391
+ hostname: string;
392
+ token: string;
393
+ acquiredAt: string;
293
394
  };
294
395
 
295
396
  type CleanupLock = {
296
- handle: FileHandle;
297
- path: string;
298
- owner: CleanupLockOwner;
299
- identity: Pick<Stats, "dev" | "ino">;
397
+ handle: FileHandle;
398
+ path: string;
399
+ owner: CleanupLockOwner;
400
+ identity: Pick<Stats, "dev" | "ino">;
300
401
  };
301
402
 
302
- function sameFileIdentity(left: Pick<Stats, "dev" | "ino">, right: Pick<Stats, "dev" | "ino">): boolean {
303
- return left.dev === right.dev && left.ino === right.ino;
403
+ function sameFileIdentity(
404
+ left: Pick<Stats, "dev" | "ino">,
405
+ right: Pick<Stats, "dev" | "ino">,
406
+ ): boolean {
407
+ return left.dev === right.dev && left.ino === right.ino;
304
408
  }
305
409
 
306
410
  function parseCleanupLockOwner(value: string): CleanupLockOwner | undefined {
307
- try {
308
- const owner = JSON.parse(value) as Partial<CleanupLockOwner>;
309
- if (!Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0) return undefined;
310
- if (typeof owner.hostname !== "string" || !owner.hostname) return undefined;
311
- if (typeof owner.token !== "string" || !owner.token) return undefined;
312
- if (typeof owner.acquiredAt !== "string" || !owner.acquiredAt) return undefined;
313
- if (!Number.isFinite(Date.parse(owner.acquiredAt))) return undefined;
314
- return owner as CleanupLockOwner;
315
- } catch {
316
- return undefined;
317
- }
411
+ try {
412
+ const owner = JSON.parse(value) as Partial<CleanupLockOwner>;
413
+ if (!Number.isSafeInteger(owner.pid) || (owner.pid ?? 0) <= 0) return undefined;
414
+ if (typeof owner.hostname !== "string" || !owner.hostname) return undefined;
415
+ if (typeof owner.token !== "string" || !owner.token) return undefined;
416
+ if (typeof owner.acquiredAt !== "string" || !owner.acquiredAt) return undefined;
417
+ if (!Number.isFinite(Date.parse(owner.acquiredAt))) return undefined;
418
+ return owner as CleanupLockOwner;
419
+ } catch {
420
+ return undefined;
421
+ }
318
422
  }
319
423
 
320
424
  function isCleanupLockLeaseExpired(owner: CleanupLockOwner | undefined, now = Date.now()): boolean {
321
- if (!owner) return true;
322
- const acquiredAt = Date.parse(owner.acquiredAt);
323
- if (!Number.isFinite(acquiredAt)) return true;
324
- return Math.max(0, now - acquiredAt) > CLEANUP_LOCK_MAX_LEASE_MS;
425
+ if (!owner) return true;
426
+ const acquiredAt = Date.parse(owner.acquiredAt);
427
+ if (!Number.isFinite(acquiredAt)) return true;
428
+ return Math.max(0, now - acquiredAt) > CLEANUP_LOCK_MAX_LEASE_MS;
325
429
  }
326
430
 
327
431
  function isRecordedOwnerAlive(owner: CleanupLockOwner | undefined): boolean {
328
- if (!owner || owner.hostname !== os.hostname()) return false;
329
- try {
330
- process.kill(owner.pid, 0);
331
- return true;
332
- } catch (error) {
333
- return (error as NodeJS.ErrnoException).code === "EPERM";
334
- }
432
+ if (!owner || owner.hostname !== os.hostname()) return false;
433
+ try {
434
+ process.kill(owner.pid, 0);
435
+ return true;
436
+ } catch (error) {
437
+ return (error as NodeJS.ErrnoException).code === "EPERM";
438
+ }
335
439
  }
336
440
 
337
441
  async function restoreMovedLock(movedPath: string, lockPath: string): Promise<void> {
338
- try {
339
- // link() restores only when lockPath is still absent; unlike rename(), it cannot overwrite a newer owner.
340
- await fs.link(movedPath, lockPath);
341
- await fs.rm(movedPath, { force: true });
342
- } catch (error) {
343
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
344
- // Preserve both owners rather than deleting the moved lock when another owner won the canonical path.
345
- }
346
- }
347
-
348
- async function removeLockPathIfOwned(lockPath: string, identity: Pick<Stats, "dev" | "ino">): Promise<void> {
349
- const currentStat = await fs.stat(lockPath).catch(() => undefined);
350
- if (!currentStat || !sameFileIdentity(identity, currentStat)) return;
351
- await fs.rm(lockPath, { force: true }).catch(() => undefined);
352
- }
353
-
354
- async function getCleanupLockPathIdentity(lockPath: string): Promise<Pick<Stats, "dev" | "ino"> | undefined> {
355
- const canonicalPath = await fs.realpath(lockPath).catch((error: NodeJS.ErrnoException) => {
356
- if (error.code === "ENOENT") return undefined;
357
- throw error;
358
- });
359
- if (!canonicalPath) return undefined;
360
- const currentStat = await fs.stat(canonicalPath).catch((error: NodeJS.ErrnoException) => {
361
- if (error.code === "ENOENT") return undefined;
362
- throw error;
363
- });
364
- return currentStat ? { dev: currentStat.dev, ino: currentStat.ino } : undefined;
365
- }
366
-
367
- async function cleanupLockOwnershipError(lock: CleanupLock, stage: string): Promise<string | undefined> {
368
- const currentIdentity = await getCleanupLockPathIdentity(lock.path);
369
- if (currentIdentity && sameFileIdentity(lock.identity, currentIdentity)) return undefined;
370
- return `cleanup lock ownership lost before ${stage}; stopping cleanup.`;
442
+ try {
443
+ // link() restores only when lockPath is still absent; unlike rename(), it cannot overwrite a newer owner.
444
+ await fs.link(movedPath, lockPath);
445
+ await fs.rm(movedPath, { force: true });
446
+ } catch (error) {
447
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
448
+ // Preserve both owners rather than deleting the moved lock when another owner won the canonical path.
449
+ }
450
+ }
451
+
452
+ async function removeLockPathIfOwned(
453
+ lockPath: string,
454
+ identity: Pick<Stats, "dev" | "ino">,
455
+ ): Promise<void> {
456
+ const currentStat = await fs.stat(lockPath).catch(() => undefined);
457
+ if (!currentStat || !sameFileIdentity(identity, currentStat)) return;
458
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
459
+ }
460
+
461
+ async function getCleanupLockPathIdentity(
462
+ lockPath: string,
463
+ ): Promise<Pick<Stats, "dev" | "ino"> | undefined> {
464
+ const canonicalPath = await fs.realpath(lockPath).catch((error: NodeJS.ErrnoException) => {
465
+ if (error.code === "ENOENT") return undefined;
466
+ throw error;
467
+ });
468
+ if (!canonicalPath) return undefined;
469
+ const currentStat = await fs.stat(canonicalPath).catch((error: NodeJS.ErrnoException) => {
470
+ if (error.code === "ENOENT") return undefined;
471
+ throw error;
472
+ });
473
+ return currentStat ? { dev: currentStat.dev, ino: currentStat.ino } : undefined;
474
+ }
475
+
476
+ async function cleanupLockOwnershipError(
477
+ lock: CleanupLock,
478
+ stage: string,
479
+ ): Promise<string | undefined> {
480
+ const currentIdentity = await getCleanupLockPathIdentity(lock.path);
481
+ if (currentIdentity && sameFileIdentity(lock.identity, currentIdentity)) return undefined;
482
+ return `cleanup lock ownership lost before ${stage}; stopping cleanup.`;
371
483
  }
372
484
 
373
485
  async function tryAcquireCleanupLock(root: string): Promise<CleanupLock | undefined> {
374
- const lockPath = path.join(root, CLEANUP_LOCK_FILE);
375
- for (let attempt = 0; attempt < 3; attempt += 1) {
376
- const owner: CleanupLockOwner = {
377
- pid: process.pid,
378
- hostname: os.hostname(),
379
- token: randomUUID(),
380
- acquiredAt: new Date().toISOString(),
381
- };
382
- try {
383
- const handle = await fs.open(lockPath, "wx");
384
- const identity = await handle.stat();
385
- try {
386
- await handle.writeFile(JSON.stringify(owner), "utf8");
387
- await handle.sync();
388
- return { handle, path: lockPath, owner, identity };
389
- } catch (error) {
390
- await handle.close().catch(() => undefined);
391
- await removeLockPathIfOwned(lockPath, identity);
392
- throw error;
393
- }
394
- } catch (error) {
395
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
396
- }
397
-
398
- let existing: FileHandle | undefined;
399
- try {
400
- existing = await fs.open(lockPath, "r");
401
- } catch (error) {
402
- if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
403
- throw error;
404
- }
405
- try {
406
- const stat = await existing.stat();
407
- const recordedOwner = parseCleanupLockOwner(await existing.readFile("utf8"));
408
- const now = Date.now();
409
- const ageMs = Math.max(0, now - stat.mtimeMs);
410
- if (!recordedOwner) {
411
- if (ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
412
- } else {
413
- const leaseExpired = isCleanupLockLeaseExpired(recordedOwner, now);
414
- if (isRecordedOwnerAlive(recordedOwner) && !leaseExpired) return undefined;
415
- if (!leaseExpired && ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
416
- }
417
-
418
- const stalePath = path.join(root, `${CLEANUP_LOCK_FILE}.stale-${process.pid}-${randomUUID()}`);
419
- try {
420
- await fs.rename(lockPath, stalePath);
421
- } catch (renameError) {
422
- if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue;
423
- throw renameError;
424
- }
425
- const movedStat = await fs.stat(stalePath).catch(() => undefined);
426
- if (!movedStat || !sameFileIdentity(stat, movedStat)) {
427
- await restoreMovedLock(stalePath, lockPath);
428
- return undefined;
429
- }
430
- await fs.rm(stalePath, { force: true });
431
- } finally {
432
- await existing.close().catch(() => undefined);
433
- }
434
- }
435
- return undefined;
486
+ const lockPath = path.join(root, CLEANUP_LOCK_FILE);
487
+ for (let attempt = 0; attempt < 3; attempt += 1) {
488
+ const owner: CleanupLockOwner = {
489
+ pid: process.pid,
490
+ hostname: os.hostname(),
491
+ token: randomUUID(),
492
+ acquiredAt: new Date().toISOString(),
493
+ };
494
+ try {
495
+ const handle = await fs.open(lockPath, "wx");
496
+ const identity = await handle.stat();
497
+ try {
498
+ await handle.writeFile(JSON.stringify(owner), "utf8");
499
+ await handle.sync();
500
+ return { handle, path: lockPath, owner, identity };
501
+ } catch (error) {
502
+ await handle.close().catch(() => undefined);
503
+ await removeLockPathIfOwned(lockPath, identity);
504
+ throw error;
505
+ }
506
+ } catch (error) {
507
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
508
+ }
509
+
510
+ let existing: FileHandle | undefined;
511
+ try {
512
+ existing = await fs.open(lockPath, "r");
513
+ } catch (error) {
514
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
515
+ throw error;
516
+ }
517
+ try {
518
+ const stat = await existing.stat();
519
+ const recordedOwner = parseCleanupLockOwner(await existing.readFile("utf8"));
520
+ const now = Date.now();
521
+ const ageMs = Math.max(0, now - stat.mtimeMs);
522
+ if (!recordedOwner) {
523
+ if (ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
524
+ } else {
525
+ const leaseExpired = isCleanupLockLeaseExpired(recordedOwner, now);
526
+ if (isRecordedOwnerAlive(recordedOwner) && !leaseExpired) return undefined;
527
+ if (!leaseExpired && ageMs <= CLEANUP_LOCK_STALE_MS) return undefined;
528
+ }
529
+
530
+ const stalePath = path.join(
531
+ root,
532
+ `${CLEANUP_LOCK_FILE}.stale-${process.pid}-${randomUUID()}`,
533
+ );
534
+ try {
535
+ await fs.rename(lockPath, stalePath);
536
+ } catch (renameError) {
537
+ if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue;
538
+ throw renameError;
539
+ }
540
+ const movedStat = await fs.stat(stalePath).catch(() => undefined);
541
+ if (!movedStat || !sameFileIdentity(stat, movedStat)) {
542
+ await restoreMovedLock(stalePath, lockPath);
543
+ return undefined;
544
+ }
545
+ await fs.rm(stalePath, { force: true });
546
+ } finally {
547
+ await existing.close().catch(() => undefined);
548
+ }
549
+ }
550
+ return undefined;
436
551
  }
437
552
 
438
553
  async function heartbeatCleanupLock(lock: CleanupLock): Promise<void> {
439
- const now = new Date();
440
- await lock.handle.utimes(now, now);
554
+ const now = new Date();
555
+ await lock.handle.utimes(now, now);
441
556
  }
442
557
 
443
558
  async function releaseCleanupLock(lock: CleanupLock): Promise<void> {
444
- await lock.handle.close().catch(() => undefined);
445
- const currentStat = await fs.stat(lock.path).catch(() => undefined);
446
- if (!currentStat || !sameFileIdentity(lock.identity, currentStat)) return;
447
- const releasePath = `${lock.path}.release-${process.pid}-${lock.owner.token}`;
448
- try {
449
- await fs.rename(lock.path, releasePath);
450
- } catch (error) {
451
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
452
- throw error;
453
- }
454
- const movedStat = await fs.stat(releasePath).catch(() => undefined);
455
- if (!movedStat || !sameFileIdentity(lock.identity, movedStat)) {
456
- await restoreMovedLock(releasePath, lock.path);
457
- return;
458
- }
459
- await fs.rm(releasePath, { force: true });
460
- }
461
-
462
- async function cleanupExpiredRepos(root: string, now: number, lock: CleanupLock): Promise<{ deleted: number; errors: string[] }> {
463
- const errors: string[] = [];
464
- let deleted = 0;
465
- const ownershipBeforeScan = await cleanupLockOwnershipError(lock, "cache scan");
466
- if (ownershipBeforeScan) return { deleted, errors: [ownershipBeforeScan] };
467
-
468
- const hosts = await safeReadDir(root);
469
- for (const host of hosts) {
470
- if (!host.isDirectory()) continue;
471
- const hostDir = path.join(root, host.name);
472
- const owners = await safeReadDir(hostDir);
473
- for (const owner of owners) {
474
- if (!owner.isDirectory()) continue;
475
- const ownerDir = path.join(hostDir, owner.name);
476
- const repos = await safeReadDir(ownerDir);
477
- for (const repo of repos) {
478
- if (!repo.isDirectory()) continue;
479
- const repoDir = path.join(ownerDir, repo.name);
480
- if (!isInside(root, repoDir)) continue;
481
-
482
- try {
483
- if (!(await isManagedRepoCache(repoDir))) continue;
484
- const lastUsedAt = await getRepoLastUsedAt(repoDir);
485
- if (lastUsedAt === undefined || now - lastUsedAt <= CACHE_TTL_MS) continue;
486
- const ownershipBeforeDelete = await cleanupLockOwnershipError(lock, `deleting ${repoDir}`);
487
- if (ownershipBeforeDelete) return { deleted, errors: [...errors, ownershipBeforeDelete] };
488
- await fs.rm(repoDir, { recursive: true, force: true });
489
- deleted += 1;
490
- } catch (error) {
491
- const message = error instanceof Error ? error.message : String(error);
492
- errors.push(`${repoDir}: ${message}`);
493
- }
494
- }
495
- }
496
- }
497
-
498
- return { deleted, errors };
499
- }
500
-
501
- async function cleanupExpiredCache(cacheRoot: string): Promise<{ deleted: number; errors: string[] }> {
502
- const now = Date.now();
503
- const root = path.resolve(cacheRoot);
504
- let lock: CleanupLock | undefined;
505
- let heartbeat: NodeJS.Timeout | undefined;
506
-
507
- try {
508
- lock = await tryAcquireCleanupLock(root);
509
- if (!lock) return { deleted: 0, errors: [] };
510
- heartbeat = setInterval(() => {
511
- if (lock) void heartbeatCleanupLock(lock).catch(() => undefined);
512
- }, CLEANUP_LOCK_HEARTBEAT_MS);
513
- heartbeat.unref?.();
514
- return await cleanupExpiredRepos(root, now, lock);
515
- } finally {
516
- if (heartbeat) clearInterval(heartbeat);
517
- if (lock) await releaseCleanupLock(lock).catch(() => undefined);
518
- }
559
+ await lock.handle.close().catch(() => undefined);
560
+ const currentStat = await fs.stat(lock.path).catch(() => undefined);
561
+ if (!currentStat || !sameFileIdentity(lock.identity, currentStat)) return;
562
+ const releasePath = `${lock.path}.release-${process.pid}-${lock.owner.token}`;
563
+ try {
564
+ await fs.rename(lock.path, releasePath);
565
+ } catch (error) {
566
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
567
+ throw error;
568
+ }
569
+ const movedStat = await fs.stat(releasePath).catch(() => undefined);
570
+ if (!movedStat || !sameFileIdentity(lock.identity, movedStat)) {
571
+ await restoreMovedLock(releasePath, lock.path);
572
+ return;
573
+ }
574
+ await fs.rm(releasePath, { force: true });
575
+ }
576
+
577
+ async function cleanupExpiredRepos(
578
+ root: string,
579
+ now: number,
580
+ lock: CleanupLock,
581
+ ): Promise<{ deleted: number; errors: string[] }> {
582
+ const errors: string[] = [];
583
+ let deleted = 0;
584
+ const ownershipBeforeScan = await cleanupLockOwnershipError(lock, "cache scan");
585
+ if (ownershipBeforeScan) return { deleted, errors: [ownershipBeforeScan] };
586
+
587
+ const hosts = await safeReadDir(root);
588
+ for (const host of hosts) {
589
+ if (!host.isDirectory()) continue;
590
+ const hostDir = path.join(root, host.name);
591
+ const owners = await safeReadDir(hostDir);
592
+ for (const owner of owners) {
593
+ if (!owner.isDirectory()) continue;
594
+ const ownerDir = path.join(hostDir, owner.name);
595
+ const repos = await safeReadDir(ownerDir);
596
+ for (const repo of repos) {
597
+ if (!repo.isDirectory()) continue;
598
+ const repoDir = path.join(ownerDir, repo.name);
599
+ if (!isInside(root, repoDir)) continue;
600
+
601
+ try {
602
+ if (!(await isManagedRepoCache(repoDir))) continue;
603
+ const lastUsedAt = await getRepoLastUsedAt(repoDir);
604
+ if (lastUsedAt === undefined || now - lastUsedAt <= CACHE_TTL_MS) continue;
605
+ const ownershipBeforeDelete = await cleanupLockOwnershipError(
606
+ lock,
607
+ `deleting ${repoDir}`,
608
+ );
609
+ if (ownershipBeforeDelete) return { deleted, errors: [...errors, ownershipBeforeDelete] };
610
+ await fs.rm(repoDir, { recursive: true, force: true });
611
+ deleted += 1;
612
+ } catch (error) {
613
+ const message = error instanceof Error ? error.message : String(error);
614
+ errors.push(`${repoDir}: ${message}`);
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ return { deleted, errors };
621
+ }
622
+
623
+ async function cleanupExpiredCache(
624
+ cacheRoot: string,
625
+ ): Promise<{ deleted: number; errors: string[] }> {
626
+ const now = Date.now();
627
+ const root = path.resolve(cacheRoot);
628
+ let lock: CleanupLock | undefined;
629
+ let heartbeat: NodeJS.Timeout | undefined;
630
+
631
+ try {
632
+ lock = await tryAcquireCleanupLock(root);
633
+ if (!lock) return { deleted: 0, errors: [] };
634
+ heartbeat = setInterval(() => {
635
+ if (lock) void heartbeatCleanupLock(lock).catch(() => undefined);
636
+ }, CLEANUP_LOCK_HEARTBEAT_MS);
637
+ heartbeat.unref?.();
638
+ return await cleanupExpiredRepos(root, now, lock);
639
+ } finally {
640
+ if (heartbeat) clearInterval(heartbeat);
641
+ if (lock) await releaseCleanupLock(lock).catch(() => undefined);
642
+ }
519
643
  }
520
644
 
521
645
  function getCacheConfigPath(): string {
522
- return path.join(getAgentDir(), "extensions", CACHE_CONFIG_FILE);
646
+ return path.join(getAgentDir(), "extensions", CACHE_CONFIG_FILE);
523
647
  }
524
648
 
525
649
  type LibrarianPreferences = {
526
- cacheMode: CacheMode;
527
- model?: string;
528
- thinkingLevel: ThinkingLevel;
650
+ cacheMode: CacheMode;
651
+ model?: string;
652
+ thinkingLevel: ThinkingLevel;
529
653
  };
530
654
 
531
655
  function parseCacheMode(value: unknown): CacheMode | undefined {
532
- if (typeof value === "string") {
533
- const normalized = value.trim().toLowerCase();
534
- if (normalized === "enabled" || normalized === "on" || normalized === "true") return "enabled";
535
- if (normalized === "disabled" || normalized === "off" || normalized === "false") return "disabled";
536
- }
537
- if (value === true) return "enabled";
538
- if (value === false) return "disabled";
539
- return undefined;
656
+ if (typeof value === "string") {
657
+ const normalized = value.trim().toLowerCase();
658
+ if (normalized === "enabled" || normalized === "on" || normalized === "true") return "enabled";
659
+ if (normalized === "disabled" || normalized === "off" || normalized === "false")
660
+ return "disabled";
661
+ }
662
+ if (value === true) return "enabled";
663
+ if (value === false) return "disabled";
664
+ return undefined;
540
665
  }
541
666
 
542
667
  function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
543
- if (typeof value !== "string") return undefined;
544
- const normalized = value.trim().toLowerCase();
545
- return (THINKING_LEVELS as readonly string[]).includes(normalized) ? (normalized as ThinkingLevel) : undefined;
668
+ if (typeof value !== "string") return undefined;
669
+ const normalized = value.trim().toLowerCase();
670
+ return (THINKING_LEVELS as readonly string[]).includes(normalized)
671
+ ? (normalized as ThinkingLevel)
672
+ : undefined;
546
673
  }
547
674
 
548
675
  function isThinkingLevelSupported(model: PiModel, level: ThinkingLevel): boolean {
549
- if (!model.reasoning) return level === "off";
550
- const map = model.thinkingLevelMap;
551
- if (level === "xhigh" || level === "max") {
552
- return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
553
- }
554
- return map?.[level] !== null;
676
+ if (!model.reasoning) return level === "off";
677
+ const map = model.thinkingLevelMap;
678
+ if (level === "xhigh" || level === "max") {
679
+ return !!map && Object.prototype.hasOwnProperty.call(map, level) && map[level] != null;
680
+ }
681
+ return map?.[level] !== null;
555
682
  }
556
683
 
557
684
  function resolveThinkingLevel(model: PiModel, requested: ThinkingLevel): ThinkingLevel {
558
- if (isThinkingLevelSupported(model, requested)) return requested;
559
- const requestedIndex = THINKING_LEVELS.indexOf(requested);
560
- for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
561
- const level = THINKING_LEVELS[index];
562
- if (isThinkingLevelSupported(model, level)) return level;
563
- }
564
- for (let index = requestedIndex - 1; index >= 0; index -= 1) {
565
- const level = THINKING_LEVELS[index];
566
- if (isThinkingLevelSupported(model, level)) return level;
567
- }
568
- return "off";
685
+ if (isThinkingLevelSupported(model, requested)) return requested;
686
+ const requestedIndex = THINKING_LEVELS.indexOf(requested);
687
+ for (let index = requestedIndex + 1; index < THINKING_LEVELS.length; index += 1) {
688
+ const level = THINKING_LEVELS[index];
689
+ if (isThinkingLevelSupported(model, level)) return level;
690
+ }
691
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
692
+ const level = THINKING_LEVELS[index];
693
+ if (isThinkingLevelSupported(model, level)) return level;
694
+ }
695
+ return "off";
569
696
  }
570
697
 
571
698
  function normalizeModelPreference(value: unknown): string | undefined {
572
- if (typeof value !== "string") return undefined;
573
- const trimmed = value.trim();
574
- const normalized = trimmed.toLowerCase();
575
- if (!trimmed || normalized === "auto" || normalized === "current") return undefined;
576
- return trimmed;
699
+ if (typeof value !== "string") return undefined;
700
+ const trimmed = value.trim();
701
+ const normalized = trimmed.toLowerCase();
702
+ if (!trimmed || normalized === "auto" || normalized === "current") return undefined;
703
+ return trimmed;
577
704
  }
578
705
 
579
706
  function parseModelPreference(value: unknown): { model?: string; thinkingLevel?: ThinkingLevel } {
580
- const model = normalizeModelPreference(value);
581
- if (!model) return {};
582
- const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh|max)$/i);
583
- if (!match?.[1]) return { model };
584
- const baseModel = match[1].trim();
585
- if (!baseModel || baseModel.toLowerCase() === "auto" || baseModel.toLowerCase() === "current") {
586
- return { thinkingLevel: parseThinkingLevel(match[2]) };
587
- }
588
- return { model: baseModel, thinkingLevel: parseThinkingLevel(match[2]) };
707
+ const model = normalizeModelPreference(value);
708
+ if (!model) return {};
709
+ const match = model.match(/^(.*):(off|minimal|low|medium|high|xhigh|max)$/i);
710
+ if (!match?.[1]) return { model };
711
+ const baseModel = match[1].trim();
712
+ if (!baseModel || baseModel.toLowerCase() === "auto" || baseModel.toLowerCase() === "current") {
713
+ return { thinkingLevel: parseThinkingLevel(match[2]) };
714
+ }
715
+ return { model: baseModel, thinkingLevel: parseThinkingLevel(match[2]) };
589
716
  }
590
717
 
591
718
  async function readLibrarianPreferences(): Promise<LibrarianPreferences> {
592
- try {
593
- const raw = await fs.readFile(getCacheConfigPath(), "utf8");
594
- const parsed = JSON.parse(raw) as {
595
- cacheMode?: unknown;
596
- cacheEnabled?: unknown;
597
- cache?: { mode?: unknown; enabled?: unknown };
598
- model?: unknown;
599
- defaultModel?: unknown;
600
- thinkingLevel?: unknown;
601
- defaultThinkingLevel?: unknown;
602
- };
603
- return {
604
- cacheMode:
605
- parseCacheMode(parsed.cacheMode) ??
606
- parseCacheMode(parsed.cache?.mode) ??
607
- parseCacheMode(parsed.cacheEnabled) ??
608
- parseCacheMode(parsed.cache?.enabled) ??
609
- DEFAULT_CACHE_MODE,
610
- model: parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
611
- thinkingLevel:
612
- parseThinkingLevel(parsed.thinkingLevel) ??
613
- parseThinkingLevel(parsed.defaultThinkingLevel) ??
614
- parseModelPreference(parsed.model).thinkingLevel ??
615
- parseModelPreference(parsed.defaultModel).thinkingLevel ??
616
- DEFAULT_THINKING_LEVEL,
617
- };
618
- } catch (error) {
619
- return {
620
- cacheMode: (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled",
621
- thinkingLevel: DEFAULT_THINKING_LEVEL,
622
- };
623
- }
719
+ try {
720
+ const raw = await fs.readFile(getCacheConfigPath(), "utf8");
721
+ const parsed = JSON.parse(raw) as {
722
+ cacheMode?: unknown;
723
+ cacheEnabled?: unknown;
724
+ cache?: { mode?: unknown; enabled?: unknown };
725
+ model?: unknown;
726
+ defaultModel?: unknown;
727
+ thinkingLevel?: unknown;
728
+ defaultThinkingLevel?: unknown;
729
+ };
730
+ return {
731
+ cacheMode:
732
+ parseCacheMode(parsed.cacheMode) ??
733
+ parseCacheMode(parsed.cache?.mode) ??
734
+ parseCacheMode(parsed.cacheEnabled) ??
735
+ parseCacheMode(parsed.cache?.enabled) ??
736
+ DEFAULT_CACHE_MODE,
737
+ model:
738
+ parseModelPreference(parsed.model).model ?? parseModelPreference(parsed.defaultModel).model,
739
+ thinkingLevel:
740
+ parseThinkingLevel(parsed.thinkingLevel) ??
741
+ parseThinkingLevel(parsed.defaultThinkingLevel) ??
742
+ parseModelPreference(parsed.model).thinkingLevel ??
743
+ parseModelPreference(parsed.defaultModel).thinkingLevel ??
744
+ DEFAULT_THINKING_LEVEL,
745
+ };
746
+ } catch (error) {
747
+ return {
748
+ cacheMode:
749
+ (error as NodeJS.ErrnoException).code === "ENOENT" ? DEFAULT_CACHE_MODE : "disabled",
750
+ thinkingLevel: DEFAULT_THINKING_LEVEL,
751
+ };
752
+ }
624
753
  }
625
754
 
626
755
  async function writeLibrarianPreferences(preferences: LibrarianPreferences): Promise<void> {
627
- const configPath = getCacheConfigPath();
628
- const config = {
629
- cacheMode: preferences.cacheMode,
630
- cacheEnabled: preferences.cacheMode === "enabled",
631
- ...(preferences.model ? { model: preferences.model } : {}),
632
- thinkingLevel: preferences.thinkingLevel,
633
- updatedAt: new Date().toISOString(),
634
- };
756
+ const configPath = getCacheConfigPath();
757
+ const config = {
758
+ cacheMode: preferences.cacheMode,
759
+ cacheEnabled: preferences.cacheMode === "enabled",
760
+ ...(preferences.model ? { model: preferences.model } : {}),
761
+ thinkingLevel: preferences.thinkingLevel,
762
+ updatedAt: new Date().toISOString(),
763
+ };
635
764
 
636
- await fs.mkdir(path.dirname(configPath), { recursive: true });
637
- await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
765
+ await fs.mkdir(path.dirname(configPath), { recursive: true });
766
+ await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
638
767
  }
639
768
 
640
769
  async function writeCachePreference(preference: CacheMode): Promise<void> {
641
- const preferences = await readLibrarianPreferences();
642
- await writeLibrarianPreferences({ ...preferences, cacheMode: preference });
770
+ const preferences = await readLibrarianPreferences();
771
+ await writeLibrarianPreferences({ ...preferences, cacheMode: preference });
643
772
  }
644
773
 
645
774
  function resolveCacheDecision(preference: CacheMode): { enabled: boolean; reason: string } {
646
- if (preference === "enabled") {
647
- return { enabled: true, reason: "cache preference enabled; using cached local checkouts" };
648
- }
775
+ if (preference === "enabled") {
776
+ return { enabled: true, reason: "cache preference enabled; using cached local checkouts" };
777
+ }
649
778
 
650
- return { enabled: false, reason: "cache preference disabled; using GitHub API/temp files only" };
779
+ return { enabled: false, reason: "cache preference disabled; using GitHub API/temp files only" };
651
780
  }
652
781
 
653
782
  function formatCachePreference(preference: CacheMode): string {
654
- return preference === "enabled" ? "on" : "off";
783
+ return preference === "enabled" ? "on" : "off";
655
784
  }
656
785
 
657
786
  function formatLibrarianPreferences(preferences: LibrarianPreferences): string {
658
- return `Librarian defaults: cache=${formatCachePreference(preferences.cacheMode)}, model=${preferences.model ?? "auto"}, thinkingLevel=${preferences.thinkingLevel}. Config: ${getCacheConfigPath()}`;
787
+ return `Librarian defaults: cache=${formatCachePreference(preferences.cacheMode)}, model=${preferences.model ?? "auto"}, thinkingLevel=${preferences.thinkingLevel}. Config: ${getCacheConfigPath()}`;
659
788
  }
660
789
 
661
- function notifyCommand(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
662
- if (ctx.hasUI) {
663
- ctx.ui.notify(message, type);
664
- return;
665
- }
790
+ function notifyCommand(
791
+ ctx: ExtensionContext,
792
+ message: string,
793
+ type: "info" | "warning" | "error" = "info",
794
+ ): void {
795
+ if (ctx.hasUI) {
796
+ ctx.ui.notify(message, type);
797
+ return;
798
+ }
666
799
 
667
- console.error(message);
800
+ console.error(message);
668
801
  }
669
802
 
670
803
  function modelRef(model: any): string {
671
- return `${model.provider}/${model.id}`;
804
+ return `${model.provider}/${model.id}`;
672
805
  }
673
806
 
674
807
  function modelCostScore(model: any): number {
675
- const cost = model.cost ?? {};
676
- const input = typeof cost.input === "number" ? cost.input : 0;
677
- const output = typeof cost.output === "number" ? cost.output : 0;
678
- return input + output;
808
+ const cost = model.cost ?? {};
809
+ const input = typeof cost.input === "number" ? cost.input : 0;
810
+ const output = typeof cost.output === "number" ? cost.output : 0;
811
+ return input + output;
679
812
  }
680
813
 
681
814
  function rankLibrarianModel(model: any): number {
682
- const text = `${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
683
- let score = modelCostScore(model) * 1_000_000;
684
- if (model.reasoning) score += 50;
685
- if (/\b(?:mini|nano|haiku|flash|lite|small|fast|instant)\b/.test(text)) score -= 10;
686
- if (HEAVY_MODEL_PATTERN.test(text)) score += 1_000;
687
- if ((model.contextWindow ?? 0) < 32_000) score += 100;
688
- return score;
815
+ const text = `${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
816
+ let score = modelCostScore(model) * 1_000_000;
817
+ if (model.reasoning) score += 50;
818
+ if (/\b(?:mini|nano|haiku|flash|lite|small|fast|instant)\b/.test(text)) score -= 10;
819
+ if (HEAVY_MODEL_PATTERN.test(text)) score += 1_000;
820
+ if ((model.contextWindow ?? 0) < 32_000) score += 100;
821
+ return score;
689
822
  }
690
823
 
691
824
  function isPreferredFastLibrarianModel(model: any): boolean {
692
- const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
693
- if (HEAVY_MODEL_PATTERN.test(text)) return false;
694
- return PREFERRED_FAST_MODEL_PATTERNS.some((pattern) => pattern.test(text));
825
+ const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
826
+ if (HEAVY_MODEL_PATTERN.test(text)) return false;
827
+ return PREFERRED_FAST_MODEL_PATTERNS.some((pattern) => pattern.test(text));
695
828
  }
696
829
 
697
830
  function rankPreferredFastLibrarianModel(model: any): number {
698
- const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
699
- let score = rankLibrarianModel(model);
700
- const patternIndex = PREFERRED_FAST_MODEL_PATTERNS.findIndex((pattern) => pattern.test(text));
701
- if (patternIndex >= 0) score += patternIndex * 100;
702
- return score;
831
+ const text = `${model.provider ?? ""} ${model.id ?? ""} ${model.name ?? ""}`.toLowerCase();
832
+ let score = rankLibrarianModel(model);
833
+ const patternIndex = PREFERRED_FAST_MODEL_PATTERNS.findIndex((pattern) => pattern.test(text));
834
+ if (patternIndex >= 0) score += patternIndex * 100;
835
+ return score;
703
836
  }
704
837
 
705
838
  async function findAvailableModel(
706
- ctx: ModelSelectionContext,
707
- modelPreference: string,
839
+ ctx: ModelSelectionContext,
840
+ modelPreference: string,
708
841
  ): Promise<any | undefined> {
709
- const available = await ctx.modelRegistry.getAvailable();
710
- const trimmed = modelPreference.trim();
711
- const provider = trimmed.includes("/") ? trimmed.split("/")[0].toLowerCase() : ctx.model?.provider?.toLowerCase();
712
- const id = trimmed.includes("/") ? trimmed.split("/").slice(1).join("/").toLowerCase() : trimmed.toLowerCase();
713
-
714
- const exact = available.find(
715
- (model) => model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
716
- );
717
- if (exact) return exact;
718
-
719
- const partial = available.find(
720
- (model) => model.id.toLowerCase().includes(id) && (!provider || model.provider.toLowerCase() === provider),
721
- );
722
- if (partial) return partial;
723
-
724
- if (!provider) {
725
- const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
726
- if (uniqueById.length === 1) return uniqueById[0];
727
- }
728
-
729
- return undefined;
842
+ const available = await ctx.modelRegistry.getAvailable();
843
+ const trimmed = modelPreference.trim();
844
+ const provider = trimmed.includes("/")
845
+ ? trimmed.split("/")[0].toLowerCase()
846
+ : ctx.model?.provider?.toLowerCase();
847
+ const id = trimmed.includes("/")
848
+ ? trimmed.split("/").slice(1).join("/").toLowerCase()
849
+ : trimmed.toLowerCase();
850
+
851
+ const exact = available.find(
852
+ (model) =>
853
+ model.id.toLowerCase() === id && (!provider || model.provider.toLowerCase() === provider),
854
+ );
855
+ if (exact) return exact;
856
+
857
+ const partial = available.find(
858
+ (model) =>
859
+ model.id.toLowerCase().includes(id) &&
860
+ (!provider || model.provider.toLowerCase() === provider),
861
+ );
862
+ if (partial) return partial;
863
+
864
+ if (!provider) {
865
+ const uniqueById = available.filter((model) => model.id.toLowerCase() === id);
866
+ if (uniqueById.length === 1) return uniqueById[0];
867
+ }
868
+
869
+ return undefined;
730
870
  }
731
871
 
732
872
  // A model the catalog advertises but the active provider/subscription cannot
@@ -734,23 +874,26 @@ async function findAvailableModel(
734
874
  // access-gated tiers). These are NOT transient: the same model keeps failing,
735
875
  // so we fall back to the next candidate instead of retrying it.
736
876
  const MODEL_AVAILABILITY_ERROR_PATTERN =
737
- /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
877
+ /\b(?:404|403|not[_ ]?found(?:[_ ]?error)?|model[_ ]?not[_ ]?found(?:[_ ]?error)?|no such model|unknown model|does not exist|is not available|not available|model[_ ]?not[_ ]?available|unsupported model|invalid model|forbidden|access[ _-]?denied|permission[ _-]?denied|not[ _-]?entitled|do(?:es)? not have access)\b/i;
738
878
 
739
879
  function isModelAvailabilityError(message: string | undefined): boolean {
740
- if (!message) return false;
741
- return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
880
+ if (!message) return false;
881
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
742
882
  }
743
883
 
744
884
  type LibrarianCandidate = { model: any; details: LibrarianModelDetails };
745
885
 
746
- function withLibrarianFallbackReason(candidate: LibrarianCandidate, previous: LibrarianCandidate): LibrarianCandidate {
747
- return {
748
- model: candidate.model,
749
- details: {
750
- ...candidate.details,
751
- selectionReason: `${candidate.details.selectionReason} (Fell back from ${previous.details.modelRef} after a model-availability error.)`,
752
- },
753
- };
886
+ function withLibrarianFallbackReason(
887
+ candidate: LibrarianCandidate,
888
+ previous: LibrarianCandidate,
889
+ ): LibrarianCandidate {
890
+ return {
891
+ model: candidate.model,
892
+ details: {
893
+ ...candidate.details,
894
+ selectionReason: `${candidate.details.selectionReason} (Fell back from ${previous.details.modelRef} after a model-availability error.)`,
895
+ },
896
+ };
754
897
  }
755
898
 
756
899
  // Build an ordered list of model candidates to try. The catalog can advertise
@@ -758,953 +901,1268 @@ function withLibrarianFallbackReason(candidate: LibrarianCandidate, previous: Li
758
901
  // these in order and skip ones that fail with a model-availability error,
759
902
  // ultimately falling back to the known-good current session model.
760
903
  async function buildLibrarianCandidates(
761
- ctx: ModelSelectionContext,
762
- modelPreference: string | undefined,
763
- thinkingLevel: ThinkingLevel,
904
+ ctx: ModelSelectionContext,
905
+ modelPreference: string | undefined,
906
+ thinkingLevel: ThinkingLevel,
764
907
  ): Promise<LibrarianCandidate[]> {
765
- const candidates: LibrarianCandidate[] = [];
766
- const seen = new Set<string>();
767
- const push = (model: any, details: LibrarianModelDetails) => {
768
- if (!model) return;
769
- const key = `${model.provider}/${model.id}`.toLowerCase();
770
- if (seen.has(key)) return;
771
- seen.add(key);
772
- candidates.push({ model, details });
773
- };
774
-
775
- const normalized = modelPreference?.trim();
776
- let configuredMatched: any | undefined;
777
- if (normalized) {
778
- configuredMatched = await findAvailableModel(ctx, normalized);
779
- if (configuredMatched) {
780
- push(configuredMatched, {
781
- modelRef: modelRef(configuredMatched),
782
- provider: configuredMatched.provider,
783
- modelId: configuredMatched.id,
784
- thinkingLevel: resolveThinkingLevel(configuredMatched, thinkingLevel),
785
- autoSelected: false,
786
- selectionReason: "Using the configured librarian model.",
787
- });
788
- }
789
- }
790
-
791
- const available = await getAutoSelectableModels(ctx);
792
- const preferred = available.filter(isPreferredFastLibrarianModel);
793
- const ranked = preferred.length > 0
794
- ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))
795
- : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b));
796
- const fallbackText = normalized && !configuredMatched
797
- ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.`
798
- : "";
799
- ranked.forEach((model, index) => {
800
- const topReason = preferred.length > 0
801
- ? `Selected a preferred fast Librarian model.${fallbackText}`
802
- : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
803
- push(model, {
804
- modelRef: modelRef(model),
805
- provider: model.provider,
806
- modelId: model.id,
807
- thinkingLevel: resolveThinkingLevel(model, thinkingLevel),
808
- autoSelected: true,
809
- selectionReason: index === 0 ? topReason : `Auto-selected ${modelRef(model)} as a lower-ranked fallback.`,
810
- });
811
- });
812
-
813
- // Final fallback: the active session model is known to be servable.
814
- if (ctx.model && isModelInScope(ctx, ctx.model)) {
815
- push(ctx.model, {
816
- modelRef: modelRef(ctx.model),
817
- provider: ctx.model.provider,
818
- modelId: ctx.model.id,
819
- thinkingLevel: resolveThinkingLevel(ctx.model, thinkingLevel),
820
- autoSelected: true,
821
- selectionReason: `Used the current session model ${modelRef(ctx.model)} as a final fallback.`,
822
- });
823
- }
824
-
825
- if (candidates.length === 0) {
826
- throw new Error(
827
- hasModelScope(ctx)
828
- ? "No authenticated models are available for Librarian in the current session model scope. Adjust the scope, log in, or configure an API key."
829
- : "No authenticated models are available for Librarian. Log in or configure an API key first.",
830
- );
831
- }
832
-
833
- return candidates;
908
+ const candidates: LibrarianCandidate[] = [];
909
+ const seen = new Set<string>();
910
+ const push = (model: any, details: LibrarianModelDetails) => {
911
+ if (!model) return;
912
+ const key = `${model.provider}/${model.id}`.toLowerCase();
913
+ if (seen.has(key)) return;
914
+ seen.add(key);
915
+ candidates.push({ model, details });
916
+ };
917
+
918
+ const normalized = modelPreference?.trim();
919
+ let configuredMatched: any | undefined;
920
+ if (normalized) {
921
+ configuredMatched = await findAvailableModel(ctx, normalized);
922
+ if (configuredMatched) {
923
+ push(configuredMatched, {
924
+ modelRef: modelRef(configuredMatched),
925
+ provider: configuredMatched.provider,
926
+ modelId: configuredMatched.id,
927
+ thinkingLevel: resolveThinkingLevel(configuredMatched, thinkingLevel),
928
+ autoSelected: false,
929
+ selectionReason: "Using the configured librarian model.",
930
+ });
931
+ }
932
+ }
933
+
934
+ const available = await getAutoSelectableModels(ctx);
935
+ const preferred = available.filter(isPreferredFastLibrarianModel);
936
+ const ranked =
937
+ preferred.length > 0
938
+ ? [...preferred].sort(
939
+ (a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b),
940
+ )
941
+ : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b));
942
+ const fallbackText =
943
+ normalized && !configuredMatched
944
+ ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.`
945
+ : "";
946
+ ranked.forEach((model, index) => {
947
+ const topReason =
948
+ preferred.length > 0
949
+ ? `Selected a preferred fast Librarian model.${fallbackText}`
950
+ : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
951
+ push(model, {
952
+ modelRef: modelRef(model),
953
+ provider: model.provider,
954
+ modelId: model.id,
955
+ thinkingLevel: resolveThinkingLevel(model, thinkingLevel),
956
+ autoSelected: true,
957
+ selectionReason:
958
+ index === 0 ? topReason : `Auto-selected ${modelRef(model)} as a lower-ranked fallback.`,
959
+ });
960
+ });
961
+
962
+ // Final fallback: the active session model is known to be servable.
963
+ if (ctx.model && isModelInScope(ctx, ctx.model)) {
964
+ push(ctx.model, {
965
+ modelRef: modelRef(ctx.model),
966
+ provider: ctx.model.provider,
967
+ modelId: ctx.model.id,
968
+ thinkingLevel: resolveThinkingLevel(ctx.model, thinkingLevel),
969
+ autoSelected: true,
970
+ selectionReason: `Used the current session model ${modelRef(ctx.model)} as a final fallback.`,
971
+ });
972
+ }
973
+
974
+ if (candidates.length === 0) {
975
+ throw new Error(
976
+ hasModelScope(ctx)
977
+ ? "No authenticated models are available for Librarian in the current session model scope. Adjust the scope, log in, or configure an API key."
978
+ : "No authenticated models are available for Librarian. Log in or configure an API key first.",
979
+ );
980
+ }
981
+
982
+ return candidates;
983
+ }
984
+
985
+ const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
986
+
987
+ /**
988
+ * Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
989
+ * non-win32 paths.
990
+ */
991
+ function normalizeWindowsShellPathLocal(filePath: string): string {
992
+ if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
993
+ return filePath;
994
+ const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
995
+ if (!match) return filePath;
996
+ const suffix = match[2]?.replaceAll("/", "\\");
997
+ return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
998
+ }
999
+
1000
+ /**
1001
+ * Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
1002
+ * drive-path normalisation always runs; only tilde expansion is gated by
1003
+ * expandTilde.
1004
+ */
1005
+ function normalizePath(
1006
+ input: string,
1007
+ opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
1008
+ ): string {
1009
+ const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
1010
+ let p = input;
1011
+ if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
1012
+ if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
1013
+ if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
1014
+ if (expandTilde) {
1015
+ const home = os.homedir();
1016
+ if (p === "~") p = home;
1017
+ else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
1018
+ p = path.join(home, p.slice(2));
1019
+ }
1020
+ if (/^file:\/\//.test(p)) p = fileURLToPath(p);
1021
+ return p;
1022
+ }
1023
+
1024
+ /**
1025
+ * Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
1026
+ * __test__ for parity tests; not imported by other extensions.
1027
+ */
1028
+ function resolveToCwd(rawPath: string, cwd: string): string {
1029
+ const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
1030
+ const base = normalizePath(cwd);
1031
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
1032
+ }
1033
+
1034
+ /** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
1035
+ function tryMacOSScreenshotPathVariant(filePath: string): string {
1036
+ return filePath.replace(/ (AM|PM)\./gi, " $1.");
1037
+ }
1038
+ /** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
1039
+ function tryNFDPathVariant(filePath: string): string {
1040
+ return filePath.normalize("NFD");
1041
+ }
1042
+ /** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
1043
+ function tryCurlyQuotePathVariant(filePath: string): string {
1044
+ return filePath.replace(/'/g, "’");
1045
+ }
1046
+ /**
1047
+ * Returns all read path candidates including Pi's resolveReadPathAsync fallback
1048
+ * variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
1049
+ */
1050
+ function getReadPathCandidates(resolved: string): string[] {
1051
+ const nfd = tryNFDPathVariant(resolved);
1052
+ return [
1053
+ resolved,
1054
+ tryMacOSScreenshotPathVariant(resolved),
1055
+ nfd,
1056
+ tryCurlyQuotePathVariant(resolved),
1057
+ tryCurlyQuotePathVariant(nfd),
1058
+ ];
834
1059
  }
835
1060
 
836
1061
  function resolveToolPath(cwd: string, rawPath: string): string {
837
- const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
838
- return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
839
- }
840
-
841
- function getBlockedBashReason(command: string, options: { workspace: string; cacheRoot: string; cacheEnabled: boolean }): string | undefined {
842
- if (!options.cacheEnabled && command.includes(options.cacheRoot)) {
843
- return "Local repo checkout cache is disabled for this Librarian call.";
844
- }
845
-
846
- let scan = command.split(options.workspace).join("<WORKSPACE>");
847
- if (options.cacheEnabled) scan = scan.split(options.cacheRoot).join("<CACHE>");
848
- if (/(^|[\n;&|()])\s*\//.test(scan)) return "Librarian bash blocks absolute-path executables.";
849
-
850
- const destructiveLocal = /(^|[\n;&|()])\s*(?:sudo|su|rm|rmdir|mv|cp|chmod|chown|dd|truncate|killall|pkill|launchctl|osascript|pbcopy|pbpaste|eval|exec|xargs)(?=$|[\s;&|()])/;
851
- if (destructiveLocal.test(scan)) return "Librarian bash blocks destructive/local side-effect commands.";
852
-
853
- const extraNetworkOrShell = /(^|[\n;&|()])\s*(?:curl|wget|nc|netcat|ssh|scp|sftp|rsync|bash|sh|zsh|fish|python|python3|perl|ruby|node|deno)(?=$|[\s;&|()])/;
854
- if (extraNetworkOrShell.test(scan)) return "Librarian bash is limited to gh/git and simple local inspection commands.";
855
-
856
- if (/(^|[\s"'=])(?:~\/|\$HOME\b|\/etc\/|\/var\/|\/private\/|\/root\/|\/Users\/|\/home\/|\/opt\/)/.test(scan)) {
857
- return "Librarian bash may only access its workspace and approved cache root.";
858
- }
859
-
860
- if (/\bgh\s+auth\s+token\b/.test(scan) || /(^|[\n;&|()])\s*(?:env|printenv|set|export|declare|echo|printf)(?=$|[\s;&|()])/.test(scan)) {
861
- return "Librarian bash blocks credential/environment inspection.";
862
- }
863
-
864
- if (/\bgh\s+api\b(?=.*(?:(?:-X|--method)\s*(?:POST|PUT|PATCH|DELETE)\b|(?:-f|--field|-F|--raw-field|--input)\b))/i.test(scan)) {
865
- return "Librarian bash allows read-only GitHub API calls only.";
866
- }
867
-
868
- if (/\bgh\s+(?:repo\s+(?:delete|edit|archive|fork|create)|pr\s+(?:merge|close|edit)|issue\s+(?:close|edit|delete)|release\s+(?:create|delete|upload)|workflow\s+run|gist\s+(?:create|delete|edit))\b/.test(scan)) {
869
- return "Librarian bash blocks mutating gh commands.";
870
- }
871
-
872
- if (/\bgit\b[^\n;&|]*\b(?:push|commit|merge|rebase|clean|reset|tag)\b/.test(scan)) {
873
- return "Librarian bash blocks mutating git commands except local checkout/fetch for cache use.";
874
- }
875
-
876
- return undefined;
1062
+ const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
1063
+ return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
1064
+ }
1065
+
1066
+ function getBlockedBashReason(
1067
+ command: string,
1068
+ options: { workspace: string; cacheRoot: string; cacheEnabled: boolean },
1069
+ ): string | undefined {
1070
+ if (!options.cacheEnabled && command.includes(options.cacheRoot)) {
1071
+ return "Local repo checkout cache is disabled for this Librarian call.";
1072
+ }
1073
+
1074
+ let scan = command.split(options.workspace).join("<WORKSPACE>");
1075
+ if (options.cacheEnabled) scan = scan.split(options.cacheRoot).join("<CACHE>");
1076
+ if (/(^|[\n;&|()])\s*\//.test(scan)) return "Librarian bash blocks absolute-path executables.";
1077
+
1078
+ const destructiveLocal =
1079
+ /(^|[\n;&|()])\s*(?:sudo|su|rm|rmdir|mv|cp|chmod|chown|dd|truncate|killall|pkill|launchctl|osascript|pbcopy|pbpaste|eval|exec|xargs)(?=$|[\s;&|()])/;
1080
+ if (destructiveLocal.test(scan))
1081
+ return "Librarian bash blocks destructive/local side-effect commands.";
1082
+
1083
+ const extraNetworkOrShell =
1084
+ /(^|[\n;&|()])\s*(?:curl|wget|nc|netcat|ssh|scp|sftp|rsync|bash|sh|zsh|fish|python|python3|perl|ruby|node|deno)(?=$|[\s;&|()])/;
1085
+ if (extraNetworkOrShell.test(scan))
1086
+ return "Librarian bash is limited to gh/git and simple local inspection commands.";
1087
+
1088
+ if (
1089
+ /(^|[\s"'=])(?:~\/|\$HOME\b|\/etc\/|\/var\/|\/private\/|\/root\/|\/Users\/|\/home\/|\/opt\/)/.test(
1090
+ scan,
1091
+ )
1092
+ ) {
1093
+ return "Librarian bash may only access its workspace and approved cache root.";
1094
+ }
1095
+
1096
+ if (
1097
+ /\bgh\s+auth\s+token\b/.test(scan) ||
1098
+ /(^|[\n;&|()])\s*(?:env|printenv|set|export|declare|echo|printf)(?=$|[\s;&|()])/.test(scan)
1099
+ ) {
1100
+ return "Librarian bash blocks credential/environment inspection.";
1101
+ }
1102
+
1103
+ if (
1104
+ /\bgh\s+api\b(?=.*(?:(?:-X|--method)\s*(?:POST|PUT|PATCH|DELETE)\b|(?:-f|--field|-F|--raw-field|--input)\b))/i.test(
1105
+ scan,
1106
+ )
1107
+ ) {
1108
+ return "Librarian bash allows read-only GitHub API calls only.";
1109
+ }
1110
+
1111
+ if (
1112
+ /\bgh\s+(?:repo\s+(?:delete|edit|archive|fork|create)|pr\s+(?:merge|close|edit)|issue\s+(?:close|edit|delete)|release\s+(?:create|delete|upload)|workflow\s+run|gist\s+(?:create|delete|edit))\b/.test(
1113
+ scan,
1114
+ )
1115
+ ) {
1116
+ return "Librarian bash blocks mutating gh commands.";
1117
+ }
1118
+
1119
+ if (/\bgit\b[^\n;&|]*\b(?:push|commit|merge|rebase|clean|reset|tag)\b/.test(scan)) {
1120
+ return "Librarian bash blocks mutating git commands except local checkout/fetch for cache use.";
1121
+ }
1122
+
1123
+ return undefined;
877
1124
  }
878
1125
 
879
1126
  function createLibrarianRuntimeGuardExtension(options: {
880
- maxTurns: number;
881
- workspace: string;
882
- cacheRoot: string;
883
- cacheEnabled: boolean;
1127
+ maxTurns: number;
1128
+ workspace: string;
1129
+ cacheRoot: string;
1130
+ cacheEnabled: boolean;
884
1131
  }): ExtensionFactory {
885
- return (pi) => {
886
- let currentTurn = 0;
887
-
888
- pi.on("turn_start", async (event) => {
889
- currentTurn = event.turnIndex;
890
- });
891
-
892
- pi.on("tool_call", async (event) => {
893
- if (currentTurn >= options.maxTurns - 1) {
894
- return {
895
- block: true,
896
- reason: `Tool use is disabled on final Librarian turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
897
- };
898
- }
899
-
900
- if (event.toolName === "read") {
901
- const input = event.input as { path?: unknown };
902
- if (typeof input.path !== "string") return undefined;
903
- const resolved = resolveToolPath(options.workspace, input.path);
904
- const realPath = await fs.realpath(resolved).catch(() => resolved);
905
- const allowed =
906
- isInside(options.workspace, realPath) ||
907
- (options.cacheEnabled && isInside(options.cacheRoot, realPath));
908
- if (!allowed) return { block: true, reason: `Librarian read is limited to its workspace/cache: ${realPath}` };
909
- }
910
-
911
- if (event.toolName === "bash") {
912
- const input = event.input as { command?: unknown; timeout?: unknown };
913
- if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
914
-
915
- const command = typeof input.command === "string" ? input.command : "";
916
- if (!options.cacheEnabled && /\b(?:git\s+clone|gh\s+repo\s+clone)\b/.test(command)) {
917
- return { block: true, reason: "Local repo checkout cache is disabled for this Librarian call." };
918
- }
919
-
920
- const blockedReason = getBlockedBashReason(command, options);
921
- if (blockedReason) return { block: true, reason: blockedReason };
922
- }
923
-
924
- return undefined;
925
- });
926
-
927
- pi.on("tool_result", async (event) => ({
928
- content: [
929
- ...(event.content ?? []),
930
- {
931
- type: "text",
932
- text: `\n\n[librarian turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
933
- },
934
- ],
935
- }));
936
- };
1132
+ return (pi) => {
1133
+ let currentTurn = 0;
1134
+
1135
+ pi.on("turn_start", async (event) => {
1136
+ currentTurn = event.turnIndex;
1137
+ });
1138
+
1139
+ pi.on("tool_call", async (event) => {
1140
+ if (currentTurn >= options.maxTurns - 1) {
1141
+ return {
1142
+ block: true,
1143
+ reason: `Tool use is disabled on final Librarian turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
1144
+ };
1145
+ }
1146
+
1147
+ if (event.toolName === "read") {
1148
+ const input = event.input as { path?: unknown };
1149
+ if (typeof input.path !== "string") return undefined;
1150
+ // Canonicalize allowed roots. A path is allowed if it is inside EITHER
1151
+ // the original root (options.workspace / options.cacheRoot) OR its
1152
+ // realpath'd form. This handles both: a realpath'd file under a
1153
+ // symlinked alias workspace (lexical match on original root) and an
1154
+ // existing file canonicalized beyond the alias (match on real root).
1155
+ const realWorkspace = await fs
1156
+ .realpath(options.workspace)
1157
+ .catch(() => path.resolve(options.workspace));
1158
+ const realCacheRoot = await fs
1159
+ .realpath(options.cacheRoot)
1160
+ .catch(() => path.resolve(options.cacheRoot));
1161
+ function isAllowedPath(p: string): boolean {
1162
+ return (
1163
+ isInside(options.workspace, p) ||
1164
+ isInside(realWorkspace, p) ||
1165
+ (options.cacheEnabled && (isInside(options.cacheRoot, p) || isInside(realCacheRoot, p)))
1166
+ );
1167
+ }
1168
+ const resolved = resolveToolPath(options.workspace, input.path);
1169
+ const realPath = await fs.realpath(resolved).catch(() => resolved);
1170
+ if (!isAllowedPath(realPath))
1171
+ return {
1172
+ block: true,
1173
+ reason: `Librarian read is limited to its workspace/cache: ${realPath}`,
1174
+ };
1175
+ // Pi-effective path check (monotonic: block if either resolution is
1176
+ // outside). For the read tool, also check Pi's resolveReadPathAsync
1177
+ // fallback variants so a missing file whose variant resolves outside is
1178
+ // not silently allowed.
1179
+ try {
1180
+ const piResolved = resolveToCwd(input.path, options.workspace);
1181
+ const candidates = getReadPathCandidates(piResolved);
1182
+ // Deliberate policy: all candidates are checked regardless of existence (not
1183
+ // Pi's existence-ordered selection) so the guard cannot be raced by creating
1184
+ // a variant between the hook and the read. Trade-off: a read is blocked when
1185
+ // an escaping variant symlink sits next to a safe primary file.
1186
+ for (const candidate of candidates) {
1187
+ const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
1188
+ if (!isAllowedPath(candidateRealPath))
1189
+ return {
1190
+ block: true,
1191
+ reason: `Librarian read is limited to its workspace/cache: ${candidateRealPath}`,
1192
+ };
1193
+ }
1194
+ } catch {
1195
+ return { block: true, reason: "Librarian read path could not be resolved safely." };
1196
+ }
1197
+ }
1198
+
1199
+ if (event.toolName === "bash") {
1200
+ const input = event.input as { command?: unknown; timeout?: unknown };
1201
+ if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
1202
+
1203
+ const command = typeof input.command === "string" ? input.command : "";
1204
+ if (!options.cacheEnabled && /\b(?:git\s+clone|gh\s+repo\s+clone)\b/.test(command)) {
1205
+ return {
1206
+ block: true,
1207
+ reason: "Local repo checkout cache is disabled for this Librarian call.",
1208
+ };
1209
+ }
1210
+
1211
+ const blockedReason = getBlockedBashReason(command, options);
1212
+ if (blockedReason) return { block: true, reason: blockedReason };
1213
+ }
1214
+
1215
+ return undefined;
1216
+ });
1217
+
1218
+ pi.on("tool_result", async (event) => ({
1219
+ content: [
1220
+ ...(event.content ?? []),
1221
+ {
1222
+ type: "text",
1223
+ text: `\n\n[librarian turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
1224
+ },
1225
+ ],
1226
+ }));
1227
+ };
937
1228
  }
938
1229
 
939
1230
  function buildSystemPrompt(options: {
940
- workspace: string;
941
- maxSearchResults: number;
942
- cacheEnabled: boolean;
943
- cacheRoot: string;
1231
+ workspace: string;
1232
+ maxSearchResults: number;
1233
+ cacheEnabled: boolean;
1234
+ cacheRoot: string;
944
1235
  }): string {
945
- const cacheSection = options.cacheEnabled
946
- ? `\nLocal checkout cache is ENABLED for this call.\n- Cache root: ${options.cacheRoot}\n- Use checkout path pattern: ${options.cacheRoot}/github.com/<owner>/<repo>\n- Reuse an existing checkout when it has a .git directory. Fetch/prune before relying on it: git -C "$DIR" fetch --all --prune --tags --quiet\n- If missing, clone with gh repo clone "$REPO" "$DIR" (or git clone https://github.com/$REPO.git "$DIR").\n- If a ref/branch/SHA is requested, fetch it and check it out locally before citing files from that ref.\n- After using a checkout, update its cache marker: touch "$DIR/${CACHE_MARKER_FILE}"\n- Prefer local rg/read inside cached checkouts once a repo is cloned, and cite absolute cached paths with line ranges.\n- Clone only repositories that are relevant to the query; do not bulk-clone broad owner/org scopes unless necessary.`
947
- : `\nLocal checkout cache is DISABLED for this call.\n- Do not clone repositories.\n- Use gh search/API/tree/contents calls and cache only necessary proof files under ${options.workspace}/repos/<owner>/<repo>/<path>.`;
1236
+ const cacheSection = options.cacheEnabled
1237
+ ? `\nLocal checkout cache is ENABLED for this call.\n- Cache root: ${options.cacheRoot}\n- Use checkout path pattern: ${options.cacheRoot}/github.com/<owner>/<repo>\n- Reuse an existing checkout when it has a .git directory. Fetch/prune before relying on it: git -C "$DIR" fetch --all --prune --tags --quiet\n- If missing, clone with gh repo clone "$REPO" "$DIR" (or git clone https://github.com/$REPO.git "$DIR").\n- If a ref/branch/SHA is requested, fetch it and check it out locally before citing files from that ref.\n- After using a checkout, update its cache marker: touch "$DIR/${CACHE_MARKER_FILE}"\n- Prefer local rg/read inside cached checkouts once a repo is cloned, and cite absolute cached paths with line ranges.\n- Clone only repositories that are relevant to the query; do not bulk-clone broad owner/org scopes unless necessary.`
1238
+ : `\nLocal checkout cache is DISABLED for this call.\n- Do not clone repositories.\n- Use gh search/API/tree/contents calls and cache only necessary proof files under ${options.workspace}/repos/<owner>/<repo>/<path>.`;
948
1239
 
949
- return `You are Librarian, an evidence-first GitHub code scout running inside pi.\n\nUse only the available bash/read tools. Use gh, jq, rg, find/fd, ls, stat, mkdir, base64, and nl -ba for GitHub reconnaissance and numbered evidence. Use read for focused local file inspection.\n\nWorkspace: ${options.workspace}\nDefault gh search limit: ${options.maxSearchResults}\nTurn budget: ${MAX_TURNS} turns total, including your final answer. Stop searching once you have enough evidence.\n${cacheSection}\n\nPerformance guidance:\n- Prefer fast, parallel exploration. When probes are independent, issue multiple separate tool calls in the same assistant turn instead of waiting for each result one-by-one.\n- Aim for 4-8 independent bash/read calls in an exploration turn when useful: parallel gh searches, tree probes, contents fetches, and targeted file reads.\n- Keep dependent work sequential, and avoid concurrent writes to the same file, checkout, or cache directory.\n\nNon-negotiable constraints:\n- Never treat gh search snippets as proof by themselves. Use fetched files or local checkouts for code-content claims.\n- Keep temporary workspace writes under ${options.workspace}/repos unless local checkout cache is enabled, in which case writes under the cache root are also allowed.
1240
+ return `You are Librarian, an evidence-first GitHub code scout running inside pi.\n\nUse the available bash and read tools for local inspection, and any configured MCP tools for additional context (MCP tools run without confirmation inside this subagent; use them only to gather evidence, not for state-changing actions unless the task explicitly asks; MCP access requires host Pi >=1.0.4, older hosts degrade to no MCP). Use gh, jq, rg, find/fd, ls, stat, mkdir, base64, and nl -ba for GitHub reconnaissance and numbered evidence. Use read for focused local file inspection.\n\nWorkspace: ${options.workspace}\nDefault gh search limit: ${options.maxSearchResults}\nTurn budget: ${MAX_TURNS} turns total, including your final answer. Stop searching once you have enough evidence.\n${cacheSection}\n\nPerformance guidance:\n- Prefer fast, parallel exploration. When probes are independent, issue multiple separate tool calls in the same assistant turn instead of waiting for each result one-by-one.\n- Aim for 4-8 independent bash/read calls in an exploration turn when useful: parallel gh searches, tree probes, contents fetches, and targeted file reads.\n- Keep dependent work sequential, and avoid concurrent writes to the same file, checkout, or cache directory.\n\nNon-negotiable constraints:\n- Never treat gh search snippets as proof by themselves. Use fetched files or local checkouts for code-content claims.\n- Keep temporary workspace writes under ${options.workspace}/repos unless local checkout cache is enabled, in which case writes under the cache root are also allowed.
950
1241
  - A runtime guard blocks destructive shell commands, credential/environment inspection, and reads outside the workspace/cache.\n- Never paste whole files. Use short snippets only when they clarify the evidence.\n- If evidence is partial or access fails (404/403), state the limitation clearly.\n- Do not present anything as fact unless it appeared in tool output or in a file you read.\n\nRecommended search flow:\n1. If symbols/text are known, start with gh search code and the provided repo/owner filters.\n2. If a repo is known but paths are unclear, resolve the default branch and inspect the git tree or contents API.\n3. Fetch or clone only the files/repos required to prove the answer.\n4. Use rg/read/nl -ba locally to produce stable path and line evidence.\n\nUseful gh patterns:\n- gh repo view "$REPO" --json defaultBranchRef --jq '.defaultBranchRef.name'\n- gh search code '<terms>' --json path,repository,sha,url,textMatches --limit ${options.maxSearchResults}\n- gh api "repos/$REPO/git/trees/$REF?recursive=1" > tree.json\n- gh api "repos/$REPO/contents/$FILE?ref=$REF" --jq .content | tr -d '\\n' | base64 --decode > "repos/$REPO/$FILE"\n- rg -n '<pattern>' '<local path>'\n- nl -ba '<local file>' | sed -n '10,30p'\n\nOutput format, exact order:\n## Summary\n1-3 concise sentences.\n## Locations\n- \`path\` or \`path:lineStart-lineEnd\` — what is here and why it matters; include GitHub URL when useful. If nothing relevant is found, write \`- (none)\`.\n## Evidence\n- \`path\` or \`path:lineStart-lineEnd\` — what this proves. Include concise snippets only if useful.\n## Searched\nOnly include when incomplete/not found or when the search path matters. List queries, filters, and probes used.\n## Next steps\nOptional: 1-3 narrow follow-up checks for remaining ambiguity.`;
951
1242
  }
952
1243
 
953
- function buildUserPrompt(query: string, repos: string[], owners: string[], maxSearchResults: number, cache: CacheDetails): string {
954
- 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.`;
1244
+ function buildUserPrompt(
1245
+ query: string,
1246
+ repos: string[],
1247
+ owners: string[],
1248
+ maxSearchResults: number,
1249
+ cache: CacheDetails,
1250
+ ): string {
1251
+ 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.`;
955
1252
  }
956
1253
 
957
1254
  function createEmptyUsage(): Usage {
958
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
1255
+ return {
1256
+ input: 0,
1257
+ output: 0,
1258
+ cacheRead: 0,
1259
+ cacheWrite: 0,
1260
+ totalTokens: 0,
1261
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1262
+ };
959
1263
  }
960
1264
 
961
1265
  function addUsage(total: Usage, usage: Usage): void {
962
- total.input += usage.input || 0;
963
- total.output += usage.output || 0;
964
- total.cacheRead += usage.cacheRead || 0;
965
- total.cacheWrite += usage.cacheWrite || 0;
966
- if (usage.cacheWrite1h !== undefined) total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
967
- if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
968
- total.totalTokens += usage.totalTokens || 0;
969
- total.cost.input += usage.cost?.input || 0;
970
- total.cost.output += usage.cost?.output || 0;
971
- total.cost.cacheRead += usage.cost?.cacheRead || 0;
972
- total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
973
- total.cost.total += usage.cost?.total || 0;
974
- }
975
-
976
- type AssistantLikeMessage = { role?: string; content?: unknown; stopReason?: unknown; errorMessage?: unknown; usage?: unknown };
1266
+ total.input += usage.input || 0;
1267
+ total.output += usage.output || 0;
1268
+ total.cacheRead += usage.cacheRead || 0;
1269
+ total.cacheWrite += usage.cacheWrite || 0;
1270
+ if (usage.cacheWrite1h !== undefined)
1271
+ total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
1272
+ if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
1273
+ total.totalTokens += usage.totalTokens || 0;
1274
+ total.cost.input += usage.cost?.input || 0;
1275
+ total.cost.output += usage.cost?.output || 0;
1276
+ total.cost.cacheRead += usage.cost?.cacheRead || 0;
1277
+ total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
1278
+ total.cost.total += usage.cost?.total || 0;
1279
+ }
1280
+
1281
+ type AssistantLikeMessage = {
1282
+ role?: string;
1283
+ content?: unknown;
1284
+ stopReason?: unknown;
1285
+ errorMessage?: unknown;
1286
+ usage?: unknown;
1287
+ };
977
1288
 
978
1289
  function addAssistantMessageUsage(total: Usage, message: unknown): void {
979
- const assistant = message as AssistantLikeMessage;
980
- if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object") return;
981
- addUsage(total, assistant.usage as Usage);
1290
+ const assistant = message as AssistantLikeMessage;
1291
+ if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object")
1292
+ return;
1293
+ addUsage(total, assistant.usage as Usage);
982
1294
  }
983
1295
 
984
1296
  function aggregateAssistantUsage(messages: unknown[]): Usage {
985
- const total = createEmptyUsage();
986
- for (const message of messages) addAssistantMessageUsage(total, message);
987
- return total;
1297
+ const total = createEmptyUsage();
1298
+ for (const message of messages) addAssistantMessageUsage(total, message);
1299
+ return total;
988
1300
  }
989
1301
 
990
1302
  function addSessionEventUsage(total: Usage, event: unknown): void {
991
- const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
992
- if (sessionEvent?.type === "message_end") return void addAssistantMessageUsage(total, sessionEvent.message);
993
- if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage) addUsage(total, sessionEvent.result.usage);
1303
+ const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
1304
+ if (sessionEvent?.type === "message_end")
1305
+ return void addAssistantMessageUsage(total, sessionEvent.message);
1306
+ if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage)
1307
+ addUsage(total, sessionEvent.result.usage);
994
1308
  }
995
1309
 
996
1310
  function getLastAssistantMessage(messages: unknown[]): AssistantLikeMessage | undefined {
997
- for (let i = messages.length - 1; i >= 0; i--) {
998
- const message = messages[i] as AssistantLikeMessage;
999
- if (message?.role === "assistant") return message;
1000
- }
1001
- return undefined;
1311
+ for (let i = messages.length - 1; i >= 0; i--) {
1312
+ const message = messages[i] as AssistantLikeMessage;
1313
+ if (message?.role === "assistant") return message;
1314
+ }
1315
+ return undefined;
1002
1316
  }
1003
1317
 
1004
1318
  function inspectFinalAssistant(messages: unknown[]):
1005
- | { ok: true; answer: string; message: AssistantLikeMessage; stopReason?: string }
1006
- | { ok: false; reason: string; message?: AssistantLikeMessage; stopReason?: string; errorMessage?: string } {
1007
- const message = getLastAssistantMessage(messages);
1008
- if (!message) return { ok: false, reason: "the internal subagent produced no assistant message" };
1009
- const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
1010
- const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
1011
- const answer = Array.isArray(message.content)
1012
- ? message.content
1013
- .map((part) => (part && typeof part === "object" && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string"
1014
- ? (part as { text: string }).text
1015
- : ""))
1016
- .join("")
1017
- .trim()
1018
- : "";
1019
- if (stopReason === "error") {
1020
- return {
1021
- ok: false,
1022
- reason: `the internal subagent stopped with an error: ${errorMessage || "provider/model error"}`,
1023
- message,
1024
- stopReason,
1025
- errorMessage: errorMessage || undefined,
1026
- };
1027
- }
1028
- if (stopReason === "aborted") {
1029
- return { ok: false, reason: "the internal subagent was aborted before producing an answer", message, stopReason };
1030
- }
1031
- if (answer) return { ok: true, answer, message, stopReason: stopReason || undefined };
1032
- return { ok: false, reason: describeNoAnswerReason(message, 0), message, stopReason: stopReason || undefined };
1033
- }
1034
-
1035
- function classifyRunFailure(error: unknown, callerAborted: boolean): { status: "aborted" | "error"; message: string; error?: string } {
1036
- const message = callerAborted ? "Aborted" : error instanceof Error ? error.message : String(error);
1037
- return { status: callerAborted ? "aborted" : "error", message, error: callerAborted ? undefined : message };
1319
+ | { ok: true; answer: string; message: AssistantLikeMessage; stopReason?: string }
1320
+ | {
1321
+ ok: false;
1322
+ reason: string;
1323
+ message?: AssistantLikeMessage;
1324
+ stopReason?: string;
1325
+ errorMessage?: string;
1326
+ } {
1327
+ const message = getLastAssistantMessage(messages);
1328
+ if (!message) return { ok: false, reason: "the internal subagent produced no assistant message" };
1329
+ const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
1330
+ const errorMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
1331
+ const answer = Array.isArray(message.content)
1332
+ ? message.content
1333
+ .map((part) =>
1334
+ part &&
1335
+ typeof part === "object" &&
1336
+ (part as { type?: string }).type === "text" &&
1337
+ typeof (part as { text?: unknown }).text === "string"
1338
+ ? (part as { text: string }).text
1339
+ : "",
1340
+ )
1341
+ .join("")
1342
+ .trim()
1343
+ : "";
1344
+ if (stopReason === "error") {
1345
+ return {
1346
+ ok: false,
1347
+ reason: `the internal subagent stopped with an error: ${errorMessage || "provider/model error"}`,
1348
+ message,
1349
+ stopReason,
1350
+ errorMessage: errorMessage || undefined,
1351
+ };
1352
+ }
1353
+ if (stopReason === "aborted") {
1354
+ return {
1355
+ ok: false,
1356
+ reason: "the internal subagent was aborted before producing an answer",
1357
+ message,
1358
+ stopReason,
1359
+ };
1360
+ }
1361
+ if (answer) return { ok: true, answer, message, stopReason: stopReason || undefined };
1362
+ return {
1363
+ ok: false,
1364
+ reason: describeNoAnswerReason(message, 0),
1365
+ message,
1366
+ stopReason: stopReason || undefined,
1367
+ };
1368
+ }
1369
+
1370
+ function classifyRunFailure(
1371
+ error: unknown,
1372
+ callerAborted: boolean,
1373
+ ): { status: "aborted" | "error"; message: string; error?: string } {
1374
+ const message = callerAborted
1375
+ ? "Aborted"
1376
+ : error instanceof Error
1377
+ ? error.message
1378
+ : String(error);
1379
+ return {
1380
+ status: callerAborted ? "aborted" : "error",
1381
+ message,
1382
+ error: callerAborted ? undefined : message,
1383
+ };
1038
1384
  }
1039
1385
 
1040
1386
  function resolveLibrarianTerminalOutcome(
1041
- outcome: ReturnType<typeof inspectFinalAssistant> | undefined,
1042
- callerAborted: boolean,
1043
- turns: number,
1044
- fallbackReason?: string,
1045
- ): { status: "done"; content: string } | { status: "aborted"; content: "Aborted" } | { status: "error"; reason: string } {
1046
- if (callerAborted) return { status: "aborted", content: "Aborted" };
1047
- if (outcome?.ok) return { status: "done", content: outcome.answer };
1048
- const reason = fallbackReason ?? (outcome?.stopReason ? outcome.reason : describeNoAnswerReason(outcome?.message, turns));
1049
- return { status: "error", reason };
1387
+ outcome: ReturnType<typeof inspectFinalAssistant> | undefined,
1388
+ callerAborted: boolean,
1389
+ turns: number,
1390
+ fallbackReason?: string,
1391
+ ):
1392
+ | { status: "done"; content: string }
1393
+ | { status: "aborted"; content: "Aborted" }
1394
+ | { status: "error"; reason: string } {
1395
+ if (callerAborted) return { status: "aborted", content: "Aborted" };
1396
+ if (outcome?.ok) return { status: "done", content: outcome.answer };
1397
+ const reason =
1398
+ fallbackReason ??
1399
+ (outcome?.stopReason ? outcome.reason : describeNoAnswerReason(outcome?.message, turns));
1400
+ return { status: "error", reason };
1050
1401
  }
1051
1402
 
1052
1403
  function createLibrarianAbortController(abortChild: () => void, onCallerAbort: () => void) {
1053
- let callerAborted = false;
1054
- return {
1055
- get callerAborted() {
1056
- return callerAborted;
1057
- },
1058
- abortForCleanup() {
1059
- abortChild();
1060
- },
1061
- abortFromCaller() {
1062
- callerAborted = true;
1063
- onCallerAbort();
1064
- abortChild();
1065
- },
1066
- };
1404
+ let callerAborted = false;
1405
+ return {
1406
+ get callerAborted() {
1407
+ return callerAborted;
1408
+ },
1409
+ abortForCleanup() {
1410
+ abortChild();
1411
+ },
1412
+ abortFromCaller() {
1413
+ callerAborted = true;
1414
+ onCallerAbort();
1415
+ abortChild();
1416
+ },
1417
+ };
1067
1418
  }
1068
1419
 
1069
1420
  function describeNoAnswerReason(message: AssistantLikeMessage | undefined, turns: number): string {
1070
- if (!message) return "the internal subagent produced no assistant message";
1071
- if (message.stopReason === "error") {
1072
- const errorMessage = typeof message.errorMessage === "string" && message.errorMessage.trim()
1073
- ? message.errorMessage.trim()
1074
- : "provider/model error";
1075
- return `the internal subagent stopped with an error: ${errorMessage}`;
1076
- }
1077
- if (message.stopReason === "aborted") return "the internal subagent was aborted before producing an answer";
1078
- if (turns >= MAX_TURNS) return `the internal subagent reached the ${MAX_TURNS}-turn budget without producing a final answer`;
1079
- const stopReason = typeof message.stopReason === "string" && message.stopReason.trim()
1080
- ? ` (stopReason: ${message.stopReason.trim()})`
1081
- : "";
1082
- return `the internal subagent completed without final assistant text${stopReason}`;
1421
+ if (!message) return "the internal subagent produced no assistant message";
1422
+ if (message.stopReason === "error") {
1423
+ const errorMessage =
1424
+ typeof message.errorMessage === "string" && message.errorMessage.trim()
1425
+ ? message.errorMessage.trim()
1426
+ : "provider/model error";
1427
+ return `the internal subagent stopped with an error: ${errorMessage}`;
1428
+ }
1429
+ if (message.stopReason === "aborted")
1430
+ return "the internal subagent was aborted before producing an answer";
1431
+ if (turns >= MAX_TURNS)
1432
+ return `the internal subagent reached the ${MAX_TURNS}-turn budget without producing a final answer`;
1433
+ const stopReason =
1434
+ typeof message.stopReason === "string" && message.stopReason.trim()
1435
+ ? ` (stopReason: ${message.stopReason.trim()})`
1436
+ : "";
1437
+ return `the internal subagent completed without final assistant text${stopReason}`;
1083
1438
  }
1084
1439
 
1085
1440
  function formatInternalFailure(details: LibrarianDetails, reason: string): string {
1086
- return [
1087
- `Internal librarian run failed: ${reason}.`,
1088
- `Diagnostics: status=${details.status}; model=${details.model.modelRef}; thinking=${details.model.thinkingLevel}; modelSelection=${details.model.autoSelected ? "auto" : "configured"}; turns=${details.turns}; toolCalls=${details.toolCalls.length}.`,
1089
- "No answer was produced. This is not a reliable \"no results found\" signal; retry later or configure a different Librarian model with /librarian-config.",
1090
- ].join("\n");
1441
+ return [
1442
+ `Internal librarian run failed: ${reason}.`,
1443
+ `Diagnostics: status=${details.status}; model=${details.model.modelRef}; thinking=${details.model.thinkingLevel}; modelSelection=${details.model.autoSelected ? "auto" : "configured"}; turns=${details.turns}; toolCalls=${details.toolCalls.length}.`,
1444
+ 'No answer was produced. This is not a reliable "no results found" signal; retry later or configure a different Librarian model with /librarian-config.',
1445
+ ].join("\n");
1091
1446
  }
1092
1447
 
1093
1448
  function formatToolCall(call: ToolCall): string {
1094
- const args = call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
1095
- if (call.name === "read") {
1096
- const readPath = typeof args.path === "string" ? args.path : "";
1097
- const offset = typeof args.offset === "number" ? args.offset : undefined;
1098
- const limit = typeof args.limit === "number" ? args.limit : undefined;
1099
- const range = offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1100
- return `read ${readPath}${range}`.trim();
1101
- }
1102
- if (call.name === "bash") {
1103
- const command = typeof args.command === "string" ? args.command : "";
1104
- return `bash ${shorten(command, 120)}`.trim();
1105
- }
1106
- return call.name;
1449
+ const args =
1450
+ call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
1451
+ if (call.name === "read") {
1452
+ const readPath = typeof args.path === "string" ? args.path : "";
1453
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
1454
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
1455
+ const range =
1456
+ offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
1457
+ return `read ${readPath}${range}`.trim();
1458
+ }
1459
+ if (call.name === "bash") {
1460
+ const command = typeof args.command === "string" ? args.command : "";
1461
+ return `bash ${shorten(command, 120)}`.trim();
1462
+ }
1463
+ return call.name;
1107
1464
  }
1108
1465
 
1109
1466
  function renderAnswer(details: LibrarianDetails): string {
1110
- if (details.error) return details.error;
1111
- return details.status === "running" ? "(searching GitHub...)" : "(no output)";
1467
+ if (details.error) return details.error;
1468
+ return details.status === "running" ? "(searching GitHub...)" : "(no output)";
1112
1469
  }
1113
1470
 
1114
1471
  export const __test__ = {
1115
- buildLibrarianCandidates,
1116
- findAvailableModel,
1117
- isModelAvailabilityError,
1118
- parseModelPreference,
1119
- resolveThinkingLevel,
1120
- aggregateAssistantUsage,
1121
- addAssistantMessageUsage,
1122
- addSessionEventUsage,
1123
- inspectFinalAssistant,
1124
- classifyRunFailure,
1125
- resolveLibrarianTerminalOutcome,
1126
- createLibrarianAbortController,
1127
- cleanupExpiredCache,
1128
- cleanupExpiredRepos,
1129
- cleanupLockOwnershipError,
1130
- tryAcquireCleanupLock,
1131
- heartbeatCleanupLock,
1132
- releaseCleanupLock,
1133
- CLEANUP_LOCK_FILE,
1134
- CLEANUP_LOCK_STALE_MS,
1135
- CLEANUP_LOCK_MAX_LEASE_MS,
1136
- isCleanupLockLeaseExpired,
1472
+ SUBAGENT_MCP_TOOLS,
1473
+ isMcpCompatibleVersion,
1474
+ resolveSubagentProjectTrusted,
1475
+ createMcpSubagentFactories,
1476
+ disposeSubagentSession,
1477
+ createLibrarianRuntimeGuardExtension,
1478
+ resolveToCwd,
1479
+ buildLibrarianCandidates,
1480
+ findAvailableModel,
1481
+ isModelAvailabilityError,
1482
+ parseModelPreference,
1483
+ resolveThinkingLevel,
1484
+ aggregateAssistantUsage,
1485
+ addAssistantMessageUsage,
1486
+ addSessionEventUsage,
1487
+ inspectFinalAssistant,
1488
+ classifyRunFailure,
1489
+ resolveLibrarianTerminalOutcome,
1490
+ createLibrarianAbortController,
1491
+ cleanupExpiredCache,
1492
+ cleanupExpiredRepos,
1493
+ cleanupLockOwnershipError,
1494
+ tryAcquireCleanupLock,
1495
+ heartbeatCleanupLock,
1496
+ releaseCleanupLock,
1497
+ CLEANUP_LOCK_FILE,
1498
+ CLEANUP_LOCK_STALE_MS,
1499
+ CLEANUP_LOCK_MAX_LEASE_MS,
1500
+ isCleanupLockLeaseExpired,
1137
1501
  };
1138
1502
 
1139
1503
  export default function librarianExtension(pi: ExtensionAPI) {
1140
- let cachePreference: CacheMode = DEFAULT_CACHE_MODE;
1141
- let modelPreference: string | undefined;
1142
- let thinkingPreference: ThinkingLevel = DEFAULT_THINKING_LEVEL;
1143
-
1144
- pi.on("session_start", async () => {
1145
- const preferences = await readLibrarianPreferences();
1146
- cachePreference = preferences.cacheMode;
1147
- modelPreference = preferences.model;
1148
- thinkingPreference = preferences.thinkingLevel;
1149
- });
1150
-
1151
- const currentPreferences = (): LibrarianPreferences => ({
1152
- cacheMode: cachePreference,
1153
- ...(modelPreference ? { model: modelPreference } : {}),
1154
- thinkingLevel: thinkingPreference,
1155
- });
1156
-
1157
- const savePreferences = async (preferences: LibrarianPreferences): Promise<string | undefined> => {
1158
- cachePreference = preferences.cacheMode;
1159
- modelPreference = preferences.model;
1160
- thinkingPreference = preferences.thinkingLevel;
1161
- try {
1162
- await writeLibrarianPreferences(preferences);
1163
- return undefined;
1164
- } catch (error) {
1165
- const message = error instanceof Error ? error.message : String(error);
1166
- return `Preference changed for this process, but could not save ${getCacheConfigPath()}: ${message}`;
1167
- }
1168
- };
1169
-
1170
- pi.registerCommand("librarian-config", {
1171
- description: "Configure Librarian subagent model and thinking defaults",
1172
- getArgumentCompletions: (prefix) => {
1173
- const parts = prefix.trim().split(/\s+/).filter(Boolean);
1174
- if (parts.length <= 1) {
1175
- const commands = ["status", "model", "thinking", "clear"];
1176
- const query = parts[0]?.toLowerCase() ?? "";
1177
- return commands.filter((command) => command.startsWith(query)).map((value) => ({ value, label: value }));
1178
- }
1179
- if (parts[0]?.toLowerCase() === "thinking") {
1180
- const query = parts[1]?.toLowerCase() ?? "";
1181
- return [...THINKING_LEVELS, "auto"].filter((level) => level.startsWith(query)).map((value) => ({ value, label: value }));
1182
- }
1183
- return null;
1184
- },
1185
- handler: async (args, ctx) => {
1186
- const tokens = args.trim().split(/\s+/).filter(Boolean);
1187
- const [command = "status", ...rest] = tokens;
1188
- const action = command.toLowerCase();
1189
-
1190
- if (action === "status" || action === "show") {
1191
- notifyCommand(ctx, formatLibrarianPreferences(currentPreferences()));
1192
- return;
1193
- }
1194
-
1195
- if (action === "model") {
1196
- const value = rest.join(" ").trim();
1197
- if (!value) {
1198
- notifyCommand(ctx, "Usage: /librarian-config model <provider/model|auto>", "warning");
1199
- return;
1200
- }
1201
- const normalized = value.toLowerCase();
1202
- if (normalized === "current" || normalized.startsWith("current:")) {
1203
- notifyCommand(ctx, "Librarian model=current is no longer supported. Use /librarian-config model auto or choose an explicit provider/model.", "warning");
1204
- return;
1205
- }
1206
- const parsedModel = parseModelPreference(value);
1207
- const next = normalized === "auto" || normalized === "clear" || normalized === "default"
1208
- ? { ...currentPreferences(), model: undefined }
1209
- : {
1210
- ...currentPreferences(),
1211
- model: parsedModel.model,
1212
- thinkingLevel: parsedModel.thinkingLevel ?? thinkingPreference,
1213
- };
1214
- const warning = await savePreferences(next);
1215
- notifyCommand(ctx, `Librarian model default updated. ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
1216
- return;
1217
- }
1218
-
1219
- if (action === "thinking" || action === "think" || action === "thinking-level") {
1220
- const value = rest[0]?.trim().toLowerCase();
1221
- if (!value) {
1222
- notifyCommand(ctx, `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
1223
- return;
1224
- }
1225
- const thinkingLevel = value === "auto" || value === "clear" || value === "default"
1226
- ? DEFAULT_THINKING_LEVEL
1227
- : parseThinkingLevel(value);
1228
- if (!thinkingLevel) {
1229
- notifyCommand(ctx, `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`, "warning");
1230
- return;
1231
- }
1232
- const warning = await savePreferences({ ...currentPreferences(), thinkingLevel });
1233
- notifyCommand(ctx, `Librarian thinking default set to ${thinkingLevel}. ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
1234
- return;
1235
- }
1236
-
1237
- if (action === "clear" || action === "reset") {
1238
- const target = rest[0]?.trim().toLowerCase() || "all";
1239
- let next: LibrarianPreferences;
1240
- if (target === "all") next = { cacheMode: cachePreference, thinkingLevel: DEFAULT_THINKING_LEVEL };
1241
- else if (target === "model") next = { ...currentPreferences(), model: undefined };
1242
- else if (target === "thinking" || target === "thinking-level") next = { ...currentPreferences(), thinkingLevel: DEFAULT_THINKING_LEVEL };
1243
- else {
1244
- notifyCommand(ctx, "Usage: /librarian-config clear [all|model|thinking]", "warning");
1245
- return;
1246
- }
1247
- const warning = await savePreferences(next);
1248
- notifyCommand(ctx, `Librarian defaults cleared (${target}). ${warning ?? formatLibrarianPreferences(currentPreferences())}`, warning ? "warning" : "info");
1249
- return;
1250
- }
1251
-
1252
- notifyCommand(ctx, "Usage: /librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|max|auto> | clear [all|model|thinking]", "warning");
1253
- },
1254
- });
1255
-
1256
- pi.registerCommand("librarian-cache", {
1257
- description: "Toggle Librarian local checkout cache for future librarian calls",
1258
- getArgumentCompletions: (prefix) => {
1259
- const commands = ["on", "off", "toggle", "status"];
1260
- const query = prefix.trim().toLowerCase();
1261
- const matches = commands.filter((command) => command.startsWith(query));
1262
- return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1263
- },
1264
- handler: async (args, ctx) => {
1265
- const action = args.trim().toLowerCase() || "status";
1266
- const cacheRoot = getUserCacheReposRoot();
1267
- const configPath = getCacheConfigPath();
1268
-
1269
- const setPreference = async (mode: CacheMode): Promise<string | undefined> => {
1270
- cachePreference = mode;
1271
- try {
1272
- await writeCachePreference(mode);
1273
- return undefined;
1274
- } catch (error) {
1275
- const message = error instanceof Error ? error.message : String(error);
1276
- return `Preference changed for this process, but could not save ${configPath}: ${message}`;
1277
- }
1278
- };
1279
-
1280
- const formatSetMessage = (mode: CacheMode, warning?: string): string => {
1281
- const main = mode === "enabled"
1282
- ? `Librarian cache enabled. Future librarian calls will reuse local checkouts under ${cacheRoot}.`
1283
- : "Librarian cache disabled. Future librarian calls will use GitHub API/search and temporary fetched files only.";
1284
- return warning ? `${main} ${warning}` : main;
1285
- };
1286
-
1287
- if (action === "on" || action === "enable") {
1288
- const warning = await setPreference("enabled");
1289
- notifyCommand(ctx, formatSetMessage("enabled", warning), warning ? "warning" : "info");
1290
- return;
1291
- }
1292
-
1293
- if (action === "off" || action === "disable") {
1294
- const warning = await setPreference("disabled");
1295
- notifyCommand(ctx, formatSetMessage("disabled", warning), warning ? "warning" : "info");
1296
- return;
1297
- }
1298
-
1299
- if (action === "toggle") {
1300
- const next = cachePreference === "enabled" ? "disabled" : "enabled";
1301
- const warning = await setPreference(next);
1302
- notifyCommand(ctx, formatSetMessage(next, warning), warning ? "warning" : "info");
1303
- return;
1304
- }
1305
-
1306
- if (action === "status") {
1307
- notifyCommand(
1308
- ctx,
1309
- `Librarian cache is ${formatCachePreference(cachePreference)}. Cache directory: ${cacheRoot}. Config: ${configPath}. Repos unused for ${CACHE_TTL_DAYS} days are removed lazily.`,
1310
- );
1311
- return;
1312
- }
1313
-
1314
- notifyCommand(ctx, "Usage: /librarian-cache on | off | toggle | status", "warning");
1315
- },
1316
- });
1317
-
1318
- pi.on("tool_result", async (event) => {
1319
- if (event.toolName !== "librarian") return undefined;
1320
- const details = event.details as LibrarianDetails | undefined;
1321
- if (details?.status !== "error") return undefined;
1322
- return { isError: true };
1323
- });
1324
-
1325
- pi.registerTool({
1326
- name: "librarian",
1327
- label: "Librarian",
1328
- description:
1329
- "GitHub research scout for coding and personal-assistant tasks. Use when the answer likely lives in GitHub repos, exact repo/path locations are unknown, or you'd otherwise do exploratory gh search/tree probes plus local rg/read inspection. Librarian uses an optional 7-day local checkout cache that is disabled by default; toggle it with /librarian-cache. Configure its internal subagent defaults with /librarian-config.",
1330
- promptSnippet:
1331
- "Research GitHub repositories with evidence-first path and line citations; local checkout cache is disabled by default and user-toggleable with /librarian-cache. Internal subagent defaults are user-configurable with /librarian-config and default to low thinking.",
1332
- promptGuidelines: [
1333
- "Use librarian when the answer likely requires exploratory GitHub repository search or line-cited evidence from external repos.",
1334
- "Do not use librarian for files already present in the current workspace unless the user asks for external GitHub research.",
1335
- "Use model or thinkingLevel only when the user explicitly asks for a non-default internal librarian model or thinking level.",
1336
- ],
1337
- parameters: LibrarianParams,
1338
-
1339
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
1340
- const rawQuery = (params as { query?: unknown }).query;
1341
- const query = typeof rawQuery === "string" ? rawQuery.trim() : "";
1342
- if (!query) throw new Error("Invalid parameters: expected query to be a non-empty string.");
1343
-
1344
- const repos = asStringArray((params as { repos?: unknown }).repos);
1345
- const owners = asStringArray((params as { owners?: unknown }).owners);
1346
- const maxSearchResults = clampNumber(
1347
- (params as { maxSearchResults?: unknown }).maxSearchResults,
1348
- 1,
1349
- MAX_SEARCH_RESULTS,
1350
- DEFAULT_MAX_SEARCH_RESULTS,
1351
- );
1352
- const explicitModel = parseModelPreference((params as { model?: unknown }).model);
1353
- const thinkingLevel =
1354
- parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
1355
- explicitModel.thinkingLevel ??
1356
- thinkingPreference;
1357
- const candidates = await buildLibrarianCandidates(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
1358
- const selectedModel = candidates[0];
1359
-
1360
- const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
1361
- await fs.mkdir(workspaceBase, { recursive: true });
1362
- const workspace = await fs.mkdtemp(path.join(workspaceBase, "run-"));
1363
- await fs.mkdir(path.join(workspace, "repos"), { recursive: true });
1364
-
1365
- const cacheRoot = getUserCacheReposRoot();
1366
- let cacheDecision = resolveCacheDecision(cachePreference);
1367
- let cleanup: { deleted: number; errors: string[] } = { deleted: 0, errors: [] };
1368
- if (cacheDecision.enabled) {
1369
- try {
1370
- await fs.mkdir(cacheRoot, { recursive: true });
1371
- cleanup = await cleanupExpiredCache(cacheRoot);
1372
- } catch (error) {
1373
- const message = error instanceof Error ? error.message : String(error);
1374
- cacheDecision = {
1375
- enabled: false,
1376
- reason: `cache setup failed (${message}); using GitHub API/temp files only`,
1377
- };
1378
- cleanup = { deleted: 0, errors: [`cache setup: ${message}`] };
1379
- }
1380
- }
1381
-
1382
- const details: LibrarianDetails = {
1383
- status: "running",
1384
- workspace,
1385
- cache: {
1386
- mode: cacheDecision.enabled ? "enabled" : "disabled",
1387
- root: cacheRoot,
1388
- ttlDays: CACHE_TTL_DAYS,
1389
- cleanupDeleted: cleanup.deleted,
1390
- cleanupErrors: cleanup.errors,
1391
- decisionReason: cacheDecision.reason,
1392
- },
1393
- model: selectedModel.details,
1394
- turns: 0,
1395
- toolCalls: [],
1396
- startedAt: Date.now(),
1397
- };
1398
-
1399
- let lastContent = "(searching GitHub...)";
1400
- let session: { abort: () => Promise<void>; dispose: () => void; state: { messages: unknown[] }; prompt: Function } | undefined;
1401
- let unsubscribe: (() => void) | undefined;
1402
- let runTimeout: NodeJS.Timeout | undefined;
1403
- let abortListenerAdded = false;
1404
- const usage = createEmptyUsage();
1405
-
1406
- const emit = (force = false) => {
1407
- void force;
1408
- onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1409
- };
1410
-
1411
- const abortController = createLibrarianAbortController(
1412
- () => void session?.abort(),
1413
- () => {
1414
- details.status = "aborted";
1415
- details.endedAt = Date.now();
1416
- lastContent = "Aborted";
1417
- emit(true);
1418
- },
1419
- );
1420
- const abortFromCaller = () => abortController.abortFromCaller();
1421
-
1422
- if (signal?.aborted) abortFromCaller();
1423
- if (signal && !signal.aborted) {
1424
- signal.addEventListener("abort", abortFromCaller);
1425
- abortListenerAdded = true;
1426
- }
1427
-
1428
- try {
1429
- emit(true);
1430
-
1431
- const systemPrompt = buildSystemPrompt({
1432
- workspace,
1433
- maxSearchResults,
1434
- cacheEnabled: cacheDecision.enabled,
1435
- cacheRoot,
1436
- });
1437
-
1438
- // Keep the research session from inheriting user/project tool, shell,
1439
- // retry, or provider-request settings. The host model runtime is still
1440
- // passed explicitly below so the selected provider/auth policy is kept.
1441
- const isolatedSettingsManager = SettingsManager.inMemory({});
1442
- const resourceLoader = new DefaultResourceLoader({
1443
- cwd: workspace,
1444
- agentDir: getAgentDir(),
1445
- settingsManager: isolatedSettingsManager,
1446
- noExtensions: true,
1447
- noSkills: true,
1448
- noPromptTemplates: true,
1449
- noThemes: true,
1450
- noContextFiles: true,
1451
- extensionFactories: [
1452
- createLibrarianRuntimeGuardExtension({
1453
- maxTurns: MAX_TURNS,
1454
- workspace,
1455
- cacheRoot,
1456
- cacheEnabled: cacheDecision.enabled,
1457
- }),
1458
- ],
1459
- systemPromptOverride: () => systemPrompt,
1460
- skillsOverride: () => ({ skills: [], diagnostics: [] }),
1461
- agentsFilesOverride: () => ({ agentsFiles: [] }),
1462
- });
1463
-
1464
- await resourceLoader.reload();
1465
-
1466
- const runAttempt = async (
1467
- candidate: LibrarianCandidate,
1468
- ): Promise<ReturnType<typeof inspectFinalAssistant>> => {
1469
- details.model = candidate.details;
1470
- details.turns = 0;
1471
- details.toolCalls = [];
1472
- emit(true);
1473
-
1474
- const created = await createAgentSession({
1475
- cwd: workspace,
1476
- ...getModelRuntimeOption(ctx),
1477
- resourceLoader,
1478
- settingsManager: isolatedSettingsManager,
1479
- sessionManager: SessionManager.inMemory(workspace),
1480
- model: candidate.model,
1481
- thinkingLevel: candidate.details.thinkingLevel,
1482
- tools: ["read", "bash"],
1483
- });
1484
-
1485
- session = created.session as typeof session;
1486
- if (abortController.callerAborted) abortController.abortForCleanup();
1487
- unsubscribe = (created.session as any).subscribe((event: any) => {
1488
- addSessionEventUsage(usage, event);
1489
- switch (event.type) {
1490
- case "turn_end":
1491
- details.turns += 1;
1492
- emit();
1493
- break;
1494
- case "tool_execution_start":
1495
- details.toolCalls.push({
1496
- id: event.toolCallId,
1497
- name: event.toolName,
1498
- args: event.args,
1499
- startedAt: Date.now(),
1500
- });
1501
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1502
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1503
- }
1504
- emit(true);
1505
- break;
1506
- case "tool_execution_end": {
1507
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1508
- if (call) {
1509
- call.endedAt = Date.now();
1510
- call.isError = event.isError;
1511
- }
1512
- emit(true);
1513
- break;
1514
- }
1515
- }
1516
- });
1517
-
1518
- try {
1519
- if (!abortController.callerAborted) {
1520
- const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1521
- expandPromptTemplates: false,
1522
- });
1523
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
1524
- runTimeout = setTimeout(() => {
1525
- abortController.abortForCleanup();
1526
- reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1527
- }, MAX_RUN_MS);
1528
- });
1529
- await Promise.race([promptPromise, timeoutPromise]);
1530
- }
1531
-
1532
- return inspectFinalAssistant(session?.state.messages ?? []);
1533
- } finally {
1534
- if (runTimeout) {
1535
- clearTimeout(runTimeout);
1536
- runTimeout = undefined;
1537
- }
1538
- unsubscribe?.();
1539
- unsubscribe = undefined;
1540
- session?.dispose();
1541
- session = undefined;
1542
- }
1543
- };
1544
-
1545
- let lastOutcome: ReturnType<typeof inspectFinalAssistant> | undefined;
1546
- let attemptErrorReason: string | undefined;
1547
- for (let index = 0; index < candidates.length; index++) {
1548
- if (abortController.callerAborted) break;
1549
- const candidate =
1550
- index === 0 ? candidates[index] : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1551
-
1552
- let attempt: ReturnType<typeof inspectFinalAssistant>;
1553
- try {
1554
- attempt = await runAttempt(candidate);
1555
- } catch (error) {
1556
- if (abortController.callerAborted) throw error;
1557
- // Some providers throw (rather than carry the error on the assistant
1558
- // message) when a model is unavailable; treat that like a
1559
- // message-carried availability error and fall back to the next model.
1560
- const message = error instanceof Error ? error.message : String(error);
1561
- if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1562
- lastOutcome = undefined;
1563
- attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1564
- continue;
1565
- }
1566
- throw error;
1567
- }
1568
-
1569
- lastOutcome = attempt;
1570
- if (abortController.callerAborted || attempt.ok) break;
1571
-
1572
- attemptErrorReason = attempt.stopReason ? attempt.reason : describeNoAnswerReason(attempt.message, details.turns);
1573
- const canFallBack =
1574
- index < candidates.length - 1 &&
1575
- attempt.stopReason === "error" &&
1576
- isModelAvailabilityError(attempt.errorMessage);
1577
- if (!canFallBack) break;
1578
- }
1579
-
1580
- const terminal = resolveLibrarianTerminalOutcome(lastOutcome, abortController.callerAborted, details.turns, attemptErrorReason);
1581
- details.status = terminal.status;
1582
- if (terminal.status === "error") {
1583
- lastContent = formatInternalFailure(details, terminal.reason);
1584
- details.error = terminal.reason;
1585
- } else {
1586
- lastContent = terminal.content;
1587
- }
1588
- details.endedAt = Date.now();
1589
- emit(true);
1590
-
1591
- return {
1592
- content: [{ type: "text", text: lastContent }],
1593
- details,
1594
- usage,
1595
- };
1596
- } catch (error) {
1597
- const failure = classifyRunFailure(error, abortController.callerAborted);
1598
- const { message } = failure;
1599
- details.status = failure.status;
1600
- details.error = failure.error;
1601
- details.endedAt = Date.now();
1602
- lastContent = message;
1603
- emit(true);
1604
-
1605
- return {
1606
- content: [{ type: "text", text: message }],
1607
- details,
1608
- usage,
1609
- };
1610
- } finally {
1611
- if (runTimeout) clearTimeout(runTimeout);
1612
- if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
1613
- unsubscribe?.();
1614
- session?.dispose();
1615
- }
1616
- },
1617
-
1618
- renderCall(args, theme) {
1619
- const query = typeof (args as { query?: unknown })?.query === "string" ? (args as { query: string }).query : "";
1620
- const repos = Array.isArray((args as { repos?: unknown })?.repos) ? (args as { repos: unknown[] }).repos.length : 0;
1621
- const owners = Array.isArray((args as { owners?: unknown })?.owners)
1622
- ? (args as { owners: unknown[] }).owners.length
1623
- : 0;
1624
- return new Text(
1625
- `${theme.fg("muted", `repos:${repos} owners:${owners}`)} · ${theme.fg("toolOutput", shorten(query, 80))}`,
1626
- 0,
1627
- 0,
1628
- );
1629
- },
1630
-
1631
- renderResult(result, { expanded, isPartial }, theme) {
1632
- const details = result.details as LibrarianDetails | undefined;
1633
- if (!details) {
1634
- const first = result.content[0];
1635
- return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
1636
- }
1637
-
1638
- const status = isPartial ? "running" : details.status;
1639
- const icon =
1640
- status === "done"
1641
- ? theme.fg("success", "✓")
1642
- : status === "error"
1643
- ? theme.fg("error", "✗")
1644
- : status === "aborted"
1645
- ? theme.fg("warning", "◼")
1646
- : theme.fg("warning", "⏳");
1647
-
1648
- const cacheLabel =
1649
- details.cache.mode === "enabled"
1650
- ? theme.fg("success", "cache:on")
1651
- : theme.fg("muted", "cache:off");
1652
- const header = `${icon} ${theme.fg("toolTitle", theme.bold("librarian "))}${theme.fg(
1653
- "dim",
1654
- `${details.turns} turns • ${details.toolCalls.length} tools • `,
1655
- )}${cacheLabel}`;
1656
-
1657
- const workspaceLine = `${theme.fg("muted", "workspace: ")}${theme.fg("toolOutput", details.workspace)}`;
1658
- const modelLine = `${theme.fg("muted", "model: ")}${theme.fg("toolOutput", details.model.modelRef)} ${theme.fg(
1659
- "dim",
1660
- `(${details.model.thinkingLevel}, ${details.model.autoSelected ? "auto" : "configured"})`,
1661
- )}`;
1662
- const cacheLine = `${theme.fg("muted", "cache: ")}${theme.fg("toolOutput", details.cache.root)} ${theme.fg(
1663
- "dim",
1664
- `(${details.cache.mode}, ${details.cache.ttlDays}d TTL, cleaned ${details.cache.cleanupDeleted})`,
1665
- )}`;
1666
-
1667
- const answer =
1668
- (result.content[0]?.type === "text" ? result.content[0].text : renderAnswer(details)).trim() || "(no output)";
1669
-
1670
- const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
1671
- const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
1672
- return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
1673
- });
1674
- if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
1675
-
1676
- if (status === "running") {
1677
- const parts = [header, workspaceLine, modelLine, cacheLine];
1678
- if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
1679
- parts.push("", theme.fg("muted", "Searching GitHub…"));
1680
- return new Text(parts.join("\n"), 0, 0);
1681
- }
1682
-
1683
- if (!expanded) {
1684
- const previewLines = answer.split("\n").slice(0, 18);
1685
- const parts = [header, workspaceLine, modelLine, cacheLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
1686
- if (answer.split("\n").length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
1687
- if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
1688
- return new Text(parts.join("\n"), 0, 0);
1689
- }
1690
-
1691
- const container = new Container();
1692
- container.addChild(new Text(header, 0, 0));
1693
- container.addChild(new Text(workspaceLine, 0, 0));
1694
- container.addChild(new Text(modelLine, 0, 0));
1695
- container.addChild(new Text(cacheLine, 0, 0));
1696
- if (details.cache.cleanupErrors.length) {
1697
- container.addChild(
1698
- new Text(theme.fg("warning", `cache cleanup warnings: ${details.cache.cleanupErrors.length}`), 0, 0),
1699
- );
1700
- }
1701
- if (toolLines.length) {
1702
- container.addChild(new Spacer(1));
1703
- container.addChild(new Text([theme.fg("muted", "Tools:"), ...toolLines].join("\n"), 0, 0));
1704
- }
1705
- container.addChild(new Spacer(1));
1706
- container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
1707
- return container;
1708
- },
1709
- });
1504
+ let cachePreference: CacheMode = DEFAULT_CACHE_MODE;
1505
+ let modelPreference: string | undefined;
1506
+ let thinkingPreference: ThinkingLevel = DEFAULT_THINKING_LEVEL;
1507
+
1508
+ pi.on("session_start", async () => {
1509
+ const preferences = await readLibrarianPreferences();
1510
+ cachePreference = preferences.cacheMode;
1511
+ modelPreference = preferences.model;
1512
+ thinkingPreference = preferences.thinkingLevel;
1513
+ });
1514
+
1515
+ const currentPreferences = (): LibrarianPreferences => ({
1516
+ cacheMode: cachePreference,
1517
+ ...(modelPreference ? { model: modelPreference } : {}),
1518
+ thinkingLevel: thinkingPreference,
1519
+ });
1520
+
1521
+ const savePreferences = async (
1522
+ preferences: LibrarianPreferences,
1523
+ ): Promise<string | undefined> => {
1524
+ cachePreference = preferences.cacheMode;
1525
+ modelPreference = preferences.model;
1526
+ thinkingPreference = preferences.thinkingLevel;
1527
+ try {
1528
+ await writeLibrarianPreferences(preferences);
1529
+ return undefined;
1530
+ } catch (error) {
1531
+ const message = error instanceof Error ? error.message : String(error);
1532
+ return `Preference changed for this process, but could not save ${getCacheConfigPath()}: ${message}`;
1533
+ }
1534
+ };
1535
+
1536
+ pi.registerCommand("librarian-config", {
1537
+ description: "Configure Librarian subagent model and thinking defaults",
1538
+ getArgumentCompletions: (prefix) => {
1539
+ const parts = prefix.trim().split(/\s+/).filter(Boolean);
1540
+ if (parts.length <= 1) {
1541
+ const commands = ["status", "model", "thinking", "clear"];
1542
+ const query = parts[0]?.toLowerCase() ?? "";
1543
+ return commands
1544
+ .filter((command) => command.startsWith(query))
1545
+ .map((value) => ({ value, label: value }));
1546
+ }
1547
+ if (parts[0]?.toLowerCase() === "thinking") {
1548
+ const query = parts[1]?.toLowerCase() ?? "";
1549
+ return [...THINKING_LEVELS, "auto"]
1550
+ .filter((level) => level.startsWith(query))
1551
+ .map((value) => ({ value, label: value }));
1552
+ }
1553
+ return null;
1554
+ },
1555
+ handler: async (args, ctx) => {
1556
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
1557
+ const [command = "status", ...rest] = tokens;
1558
+ const action = command.toLowerCase();
1559
+
1560
+ if (action === "status" || action === "show") {
1561
+ notifyCommand(ctx, formatLibrarianPreferences(currentPreferences()));
1562
+ return;
1563
+ }
1564
+
1565
+ if (action === "model") {
1566
+ const value = rest.join(" ").trim();
1567
+ if (!value) {
1568
+ notifyCommand(ctx, "Usage: /librarian-config model <provider/model|auto>", "warning");
1569
+ return;
1570
+ }
1571
+ const normalized = value.toLowerCase();
1572
+ if (normalized === "current" || normalized.startsWith("current:")) {
1573
+ notifyCommand(
1574
+ ctx,
1575
+ "Librarian model=current is no longer supported. Use /librarian-config model auto or choose an explicit provider/model.",
1576
+ "warning",
1577
+ );
1578
+ return;
1579
+ }
1580
+ const parsedModel = parseModelPreference(value);
1581
+ const next =
1582
+ normalized === "auto" || normalized === "clear" || normalized === "default"
1583
+ ? { ...currentPreferences(), model: undefined }
1584
+ : {
1585
+ ...currentPreferences(),
1586
+ model: parsedModel.model,
1587
+ thinkingLevel: parsedModel.thinkingLevel ?? thinkingPreference,
1588
+ };
1589
+ const warning = await savePreferences(next);
1590
+ notifyCommand(
1591
+ ctx,
1592
+ `Librarian model default updated. ${warning ?? formatLibrarianPreferences(currentPreferences())}`,
1593
+ warning ? "warning" : "info",
1594
+ );
1595
+ return;
1596
+ }
1597
+
1598
+ if (action === "thinking" || action === "think" || action === "thinking-level") {
1599
+ const value = rest[0]?.trim().toLowerCase();
1600
+ if (!value) {
1601
+ notifyCommand(
1602
+ ctx,
1603
+ `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`,
1604
+ "warning",
1605
+ );
1606
+ return;
1607
+ }
1608
+ const thinkingLevel =
1609
+ value === "auto" || value === "clear" || value === "default"
1610
+ ? DEFAULT_THINKING_LEVEL
1611
+ : parseThinkingLevel(value);
1612
+ if (!thinkingLevel) {
1613
+ notifyCommand(
1614
+ ctx,
1615
+ `Usage: /librarian-config thinking ${THINKING_LEVELS.join(" | ")} | auto`,
1616
+ "warning",
1617
+ );
1618
+ return;
1619
+ }
1620
+ const warning = await savePreferences({ ...currentPreferences(), thinkingLevel });
1621
+ notifyCommand(
1622
+ ctx,
1623
+ `Librarian thinking default set to ${thinkingLevel}. ${warning ?? formatLibrarianPreferences(currentPreferences())}`,
1624
+ warning ? "warning" : "info",
1625
+ );
1626
+ return;
1627
+ }
1628
+
1629
+ if (action === "clear" || action === "reset") {
1630
+ const target = rest[0]?.trim().toLowerCase() || "all";
1631
+ let next: LibrarianPreferences;
1632
+ if (target === "all")
1633
+ next = { cacheMode: cachePreference, thinkingLevel: DEFAULT_THINKING_LEVEL };
1634
+ else if (target === "model") next = { ...currentPreferences(), model: undefined };
1635
+ else if (target === "thinking" || target === "thinking-level")
1636
+ next = { ...currentPreferences(), thinkingLevel: DEFAULT_THINKING_LEVEL };
1637
+ else {
1638
+ notifyCommand(ctx, "Usage: /librarian-config clear [all|model|thinking]", "warning");
1639
+ return;
1640
+ }
1641
+ const warning = await savePreferences(next);
1642
+ notifyCommand(
1643
+ ctx,
1644
+ `Librarian defaults cleared (${target}). ${warning ?? formatLibrarianPreferences(currentPreferences())}`,
1645
+ warning ? "warning" : "info",
1646
+ );
1647
+ return;
1648
+ }
1649
+
1650
+ notifyCommand(
1651
+ ctx,
1652
+ "Usage: /librarian-config status | model <provider/model|auto> | thinking <off|minimal|low|medium|high|xhigh|max|auto> | clear [all|model|thinking]",
1653
+ "warning",
1654
+ );
1655
+ },
1656
+ });
1657
+
1658
+ pi.registerCommand("librarian-cache", {
1659
+ description: "Toggle Librarian local checkout cache for future librarian calls",
1660
+ getArgumentCompletions: (prefix) => {
1661
+ const commands = ["on", "off", "toggle", "status"];
1662
+ const query = prefix.trim().toLowerCase();
1663
+ const matches = commands.filter((command) => command.startsWith(query));
1664
+ return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
1665
+ },
1666
+ handler: async (args, ctx) => {
1667
+ const action = args.trim().toLowerCase() || "status";
1668
+ const cacheRoot = getUserCacheReposRoot();
1669
+ const configPath = getCacheConfigPath();
1670
+
1671
+ const setPreference = async (mode: CacheMode): Promise<string | undefined> => {
1672
+ cachePreference = mode;
1673
+ try {
1674
+ await writeCachePreference(mode);
1675
+ return undefined;
1676
+ } catch (error) {
1677
+ const message = error instanceof Error ? error.message : String(error);
1678
+ return `Preference changed for this process, but could not save ${configPath}: ${message}`;
1679
+ }
1680
+ };
1681
+
1682
+ const formatSetMessage = (mode: CacheMode, warning?: string): string => {
1683
+ const main =
1684
+ mode === "enabled"
1685
+ ? `Librarian cache enabled. Future librarian calls will reuse local checkouts under ${cacheRoot}.`
1686
+ : "Librarian cache disabled. Future librarian calls will use GitHub API/search and temporary fetched files only.";
1687
+ return warning ? `${main} ${warning}` : main;
1688
+ };
1689
+
1690
+ if (action === "on" || action === "enable") {
1691
+ const warning = await setPreference("enabled");
1692
+ notifyCommand(ctx, formatSetMessage("enabled", warning), warning ? "warning" : "info");
1693
+ return;
1694
+ }
1695
+
1696
+ if (action === "off" || action === "disable") {
1697
+ const warning = await setPreference("disabled");
1698
+ notifyCommand(ctx, formatSetMessage("disabled", warning), warning ? "warning" : "info");
1699
+ return;
1700
+ }
1701
+
1702
+ if (action === "toggle") {
1703
+ const next = cachePreference === "enabled" ? "disabled" : "enabled";
1704
+ const warning = await setPreference(next);
1705
+ notifyCommand(ctx, formatSetMessage(next, warning), warning ? "warning" : "info");
1706
+ return;
1707
+ }
1708
+
1709
+ if (action === "status") {
1710
+ notifyCommand(
1711
+ ctx,
1712
+ `Librarian cache is ${formatCachePreference(cachePreference)}. Cache directory: ${cacheRoot}. Config: ${configPath}. Repos unused for ${CACHE_TTL_DAYS} days are removed lazily.`,
1713
+ );
1714
+ return;
1715
+ }
1716
+
1717
+ notifyCommand(ctx, "Usage: /librarian-cache on | off | toggle | status", "warning");
1718
+ },
1719
+ });
1720
+
1721
+ pi.on("tool_result", async (event) => {
1722
+ if (event.toolName !== "librarian") return undefined;
1723
+ const details = event.details as LibrarianDetails | undefined;
1724
+ if (details?.status !== "error") return undefined;
1725
+ return { isError: true };
1726
+ });
1727
+
1728
+ pi.registerTool({
1729
+ name: "librarian",
1730
+ label: "Librarian",
1731
+ description:
1732
+ "GitHub research scout for coding and personal-assistant tasks. Use when the answer likely lives in GitHub repos, exact repo/path locations are unknown, or you'd otherwise do exploratory gh search/tree probes plus local rg/read inspection. Librarian uses an optional 7-day local checkout cache that is disabled by default; toggle it with /librarian-cache. Configure its internal subagent defaults with /librarian-config.",
1733
+ promptSnippet:
1734
+ "Research GitHub repositories with evidence-first path and line citations; local checkout cache is disabled by default and user-toggleable with /librarian-cache. Internal subagent defaults are user-configurable with /librarian-config and default to low thinking.",
1735
+ promptGuidelines: [
1736
+ "Use librarian when the answer likely requires exploratory GitHub repository search or line-cited evidence from external repos.",
1737
+ "Do not use librarian for files already present in the current workspace unless the user asks for external GitHub research.",
1738
+ "Use model or thinkingLevel only when the user explicitly asks for a non-default internal librarian model or thinking level.",
1739
+ ],
1740
+ parameters: LibrarianParams,
1741
+
1742
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
1743
+ const rawQuery = (params as { query?: unknown }).query;
1744
+ const query = typeof rawQuery === "string" ? rawQuery.trim() : "";
1745
+ if (!query) throw new Error("Invalid parameters: expected query to be a non-empty string.");
1746
+
1747
+ const repos = asStringArray((params as { repos?: unknown }).repos);
1748
+ const owners = asStringArray((params as { owners?: unknown }).owners);
1749
+ const maxSearchResults = clampNumber(
1750
+ (params as { maxSearchResults?: unknown }).maxSearchResults,
1751
+ 1,
1752
+ MAX_SEARCH_RESULTS,
1753
+ DEFAULT_MAX_SEARCH_RESULTS,
1754
+ );
1755
+ const explicitModel = parseModelPreference((params as { model?: unknown }).model);
1756
+ const thinkingLevel =
1757
+ parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
1758
+ explicitModel.thinkingLevel ??
1759
+ thinkingPreference;
1760
+ const candidates = await buildLibrarianCandidates(
1761
+ ctx,
1762
+ explicitModel.model ?? modelPreference,
1763
+ thinkingLevel,
1764
+ );
1765
+ const selectedModel = candidates[0];
1766
+
1767
+ const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
1768
+ await fs.mkdir(workspaceBase, { recursive: true });
1769
+ const workspace = await fs.mkdtemp(path.join(workspaceBase, "run-"));
1770
+ await fs.mkdir(path.join(workspace, "repos"), { recursive: true });
1771
+
1772
+ const cacheRoot = getUserCacheReposRoot();
1773
+ let cacheDecision = resolveCacheDecision(cachePreference);
1774
+ let cleanup: { deleted: number; errors: string[] } = { deleted: 0, errors: [] };
1775
+ if (cacheDecision.enabled) {
1776
+ try {
1777
+ await fs.mkdir(cacheRoot, { recursive: true });
1778
+ cleanup = await cleanupExpiredCache(cacheRoot);
1779
+ } catch (error) {
1780
+ const message = error instanceof Error ? error.message : String(error);
1781
+ cacheDecision = {
1782
+ enabled: false,
1783
+ reason: `cache setup failed (${message}); using GitHub API/temp files only`,
1784
+ };
1785
+ cleanup = { deleted: 0, errors: [`cache setup: ${message}`] };
1786
+ }
1787
+ }
1788
+
1789
+ const details: LibrarianDetails = {
1790
+ status: "running",
1791
+ workspace,
1792
+ cache: {
1793
+ mode: cacheDecision.enabled ? "enabled" : "disabled",
1794
+ root: cacheRoot,
1795
+ ttlDays: CACHE_TTL_DAYS,
1796
+ cleanupDeleted: cleanup.deleted,
1797
+ cleanupErrors: cleanup.errors,
1798
+ decisionReason: cacheDecision.reason,
1799
+ },
1800
+ model: selectedModel.details,
1801
+ turns: 0,
1802
+ toolCalls: [],
1803
+ startedAt: Date.now(),
1804
+ };
1805
+
1806
+ let lastContent = "(searching GitHub...)";
1807
+ let session:
1808
+ | {
1809
+ abort: () => Promise<void>;
1810
+ dispose: () => void;
1811
+ state: { messages: unknown[] };
1812
+ prompt: Function;
1813
+ }
1814
+ | undefined;
1815
+ let unsubscribe: (() => void) | undefined;
1816
+ let runTimeout: NodeJS.Timeout | undefined;
1817
+ let abortListenerAdded = false;
1818
+ const usage = createEmptyUsage();
1819
+
1820
+ const emit = () => {
1821
+ onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
1822
+ };
1823
+
1824
+ const abortController = createLibrarianAbortController(
1825
+ () => void session?.abort(),
1826
+ () => {
1827
+ details.status = "aborted";
1828
+ details.endedAt = Date.now();
1829
+ lastContent = "Aborted";
1830
+ emit();
1831
+ },
1832
+ );
1833
+ const abortFromCaller = () => abortController.abortFromCaller();
1834
+
1835
+ if (signal?.aborted) abortFromCaller();
1836
+ if (signal && !signal.aborted) {
1837
+ signal.addEventListener("abort", abortFromCaller);
1838
+ abortListenerAdded = true;
1839
+ }
1840
+
1841
+ try {
1842
+ emit();
1843
+
1844
+ const systemPrompt = buildSystemPrompt({
1845
+ workspace,
1846
+ maxSearchResults,
1847
+ cacheEnabled: cacheDecision.enabled,
1848
+ cacheRoot,
1849
+ });
1850
+
1851
+ // Keep the research session from inheriting user/project tool, shell,
1852
+ // retry, or provider-request settings. The host model runtime is still
1853
+ // passed explicitly below so the selected provider/auth policy is kept.
1854
+ // workspace is a temp clone dir — always a different dir from ctx.cwd,
1855
+ // so projectTrusted is always false here (project .pi/mcp.json is never loaded).
1856
+ const projectTrusted = resolveSubagentProjectTrusted(ctx, workspace);
1857
+ const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
1858
+ const resourceLoader = new DefaultResourceLoader({
1859
+ cwd: workspace,
1860
+ agentDir: getAgentDir(),
1861
+ settingsManager: isolatedSettingsManager,
1862
+ noExtensions: true,
1863
+ noSkills: true,
1864
+ noPromptTemplates: true,
1865
+ noThemes: true,
1866
+ noContextFiles: true,
1867
+ extensionFactories: [
1868
+ createLibrarianRuntimeGuardExtension({
1869
+ maxTurns: MAX_TURNS,
1870
+ workspace,
1871
+ cacheRoot,
1872
+ cacheEnabled: cacheDecision.enabled,
1873
+ }),
1874
+ ...createMcpSubagentFactories(),
1875
+ ],
1876
+ systemPromptOverride: () => systemPrompt,
1877
+ skillsOverride: () => ({ skills: [], diagnostics: [] }),
1878
+ agentsFilesOverride: () => ({ agentsFiles: [] }),
1879
+ });
1880
+
1881
+ await resourceLoader.reload();
1882
+
1883
+ const runAttempt = async (
1884
+ candidate: LibrarianCandidate,
1885
+ ): Promise<ReturnType<typeof inspectFinalAssistant>> => {
1886
+ details.model = candidate.details;
1887
+ details.turns = 0;
1888
+ details.toolCalls = [];
1889
+ emit();
1890
+
1891
+ const created = await createAgentSession({
1892
+ cwd: workspace,
1893
+ ...getModelRuntimeOption(ctx),
1894
+ resourceLoader,
1895
+ settingsManager: isolatedSettingsManager,
1896
+ sessionManager: SessionManager.inMemory(workspace),
1897
+ model: candidate.model,
1898
+ thinkingLevel: candidate.details.thinkingLevel,
1899
+ tools: ["read", "bash", ...SUBAGENT_MCP_TOOLS],
1900
+ });
1901
+
1902
+ // Assign session BEFORE bindExtensions so the finally block can dispose it
1903
+ // even if binding fails partway through (e.g. a server starts then an
1904
+ // extension's session_start throws).
1905
+ session = created.session as typeof session;
1906
+ try {
1907
+ // Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
1908
+ await (created.session as any).bindExtensions?.({ mode: "json" });
1909
+ if (abortController.callerAborted) abortController.abortForCleanup();
1910
+ unsubscribe = (created.session as any).subscribe((event: any) => {
1911
+ addSessionEventUsage(usage, event);
1912
+ switch (event.type) {
1913
+ case "turn_end":
1914
+ details.turns += 1;
1915
+ emit();
1916
+ break;
1917
+ case "tool_execution_start":
1918
+ details.toolCalls.push({
1919
+ id: event.toolCallId,
1920
+ name: event.toolName,
1921
+ args: event.args,
1922
+ startedAt: Date.now(),
1923
+ });
1924
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1925
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1926
+ }
1927
+ emit();
1928
+ break;
1929
+ case "tool_execution_end": {
1930
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1931
+ if (call) {
1932
+ call.endedAt = Date.now();
1933
+ call.isError = event.isError;
1934
+ }
1935
+ emit();
1936
+ break;
1937
+ }
1938
+ }
1939
+ });
1940
+
1941
+ if (!abortController.callerAborted) {
1942
+ const promptPromise = created.session.prompt(
1943
+ buildUserPrompt(query, repos, owners, maxSearchResults, details.cache),
1944
+ {
1945
+ expandPromptTemplates: false,
1946
+ },
1947
+ );
1948
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1949
+ runTimeout = setTimeout(() => {
1950
+ abortController.abortForCleanup();
1951
+ reject(
1952
+ new Error(
1953
+ `Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
1954
+ ),
1955
+ );
1956
+ }, MAX_RUN_MS);
1957
+ });
1958
+ await Promise.race([promptPromise, timeoutPromise]);
1959
+ }
1960
+
1961
+ return inspectFinalAssistant(session?.state.messages ?? []);
1962
+ } finally {
1963
+ if (runTimeout) {
1964
+ clearTimeout(runTimeout);
1965
+ runTimeout = undefined;
1966
+ }
1967
+ unsubscribe?.();
1968
+ unsubscribe = undefined;
1969
+ await disposeSubagentSession(session);
1970
+ session = undefined;
1971
+ }
1972
+ };
1973
+
1974
+ let lastOutcome: ReturnType<typeof inspectFinalAssistant> | undefined;
1975
+ let attemptErrorReason: string | undefined;
1976
+ for (let index = 0; index < candidates.length; index++) {
1977
+ if (abortController.callerAborted) break;
1978
+ const candidate =
1979
+ index === 0
1980
+ ? candidates[index]
1981
+ : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1982
+
1983
+ let attempt: ReturnType<typeof inspectFinalAssistant>;
1984
+ try {
1985
+ attempt = await runAttempt(candidate);
1986
+ } catch (error) {
1987
+ if (abortController.callerAborted) throw error;
1988
+ // Some providers throw (rather than carry the error on the assistant
1989
+ // message) when a model is unavailable; treat that like a
1990
+ // message-carried availability error and fall back to the next model.
1991
+ const message = error instanceof Error ? error.message : String(error);
1992
+ if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1993
+ lastOutcome = undefined;
1994
+ attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1995
+ continue;
1996
+ }
1997
+ throw error;
1998
+ }
1999
+
2000
+ lastOutcome = attempt;
2001
+ if (abortController.callerAborted || attempt.ok) break;
2002
+
2003
+ attemptErrorReason = attempt.stopReason
2004
+ ? attempt.reason
2005
+ : describeNoAnswerReason(attempt.message, details.turns);
2006
+ const canFallBack =
2007
+ index < candidates.length - 1 &&
2008
+ attempt.stopReason === "error" &&
2009
+ isModelAvailabilityError(attempt.errorMessage);
2010
+ if (!canFallBack) break;
2011
+ }
2012
+
2013
+ const terminal = resolveLibrarianTerminalOutcome(
2014
+ lastOutcome,
2015
+ abortController.callerAborted,
2016
+ details.turns,
2017
+ attemptErrorReason,
2018
+ );
2019
+ details.status = terminal.status;
2020
+ if (terminal.status === "error") {
2021
+ lastContent = formatInternalFailure(details, terminal.reason);
2022
+ details.error = terminal.reason;
2023
+ } else {
2024
+ lastContent = terminal.content;
2025
+ }
2026
+ details.endedAt = Date.now();
2027
+ emit();
2028
+
2029
+ return {
2030
+ content: [{ type: "text", text: lastContent }],
2031
+ details,
2032
+ usage,
2033
+ };
2034
+ } catch (error) {
2035
+ const failure = classifyRunFailure(error, abortController.callerAborted);
2036
+ const { message } = failure;
2037
+ details.status = failure.status;
2038
+ details.error = failure.error;
2039
+ details.endedAt = Date.now();
2040
+ lastContent = message;
2041
+ emit();
2042
+
2043
+ return {
2044
+ content: [{ type: "text", text: message }],
2045
+ details,
2046
+ usage,
2047
+ };
2048
+ } finally {
2049
+ if (runTimeout) clearTimeout(runTimeout);
2050
+ if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
2051
+ unsubscribe?.();
2052
+ await disposeSubagentSession(session);
2053
+ }
2054
+ },
2055
+
2056
+ renderCall(args, theme) {
2057
+ const query =
2058
+ typeof (args as { query?: unknown })?.query === "string"
2059
+ ? (args as { query: string }).query
2060
+ : "";
2061
+ const repos = Array.isArray((args as { repos?: unknown })?.repos)
2062
+ ? (args as { repos: unknown[] }).repos.length
2063
+ : 0;
2064
+ const owners = Array.isArray((args as { owners?: unknown })?.owners)
2065
+ ? (args as { owners: unknown[] }).owners.length
2066
+ : 0;
2067
+ return new Text(
2068
+ `${theme.fg("muted", `repos:${repos} owners:${owners}`)} · ${theme.fg("toolOutput", shorten(query, 80))}`,
2069
+ 0,
2070
+ 0,
2071
+ );
2072
+ },
2073
+
2074
+ renderResult(result, { expanded, isPartial }, theme) {
2075
+ const details = result.details as LibrarianDetails | undefined;
2076
+ if (!details) {
2077
+ const first = result.content[0];
2078
+ return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
2079
+ }
2080
+
2081
+ const status = isPartial ? "running" : details.status;
2082
+ const icon =
2083
+ status === "done"
2084
+ ? theme.fg("success", "✓")
2085
+ : status === "error"
2086
+ ? theme.fg("error", "✗")
2087
+ : status === "aborted"
2088
+ ? theme.fg("warning", "◼")
2089
+ : theme.fg("warning", "⏳");
2090
+
2091
+ const cacheLabel =
2092
+ details.cache.mode === "enabled"
2093
+ ? theme.fg("success", "cache:on")
2094
+ : theme.fg("muted", "cache:off");
2095
+ const header = `${icon} ${theme.fg("toolTitle", theme.bold("librarian "))}${theme.fg(
2096
+ "dim",
2097
+ `${details.turns} turns • ${details.toolCalls.length} tools • `,
2098
+ )}${cacheLabel}`;
2099
+
2100
+ const workspaceLine = `${theme.fg("muted", "workspace: ")}${theme.fg("toolOutput", details.workspace)}`;
2101
+ const modelLine = `${theme.fg("muted", "model: ")}${theme.fg("toolOutput", details.model.modelRef)} ${theme.fg(
2102
+ "dim",
2103
+ `(${details.model.thinkingLevel}, ${details.model.autoSelected ? "auto" : "configured"})`,
2104
+ )}`;
2105
+ const cacheLine = `${theme.fg("muted", "cache: ")}${theme.fg("toolOutput", details.cache.root)} ${theme.fg(
2106
+ "dim",
2107
+ `(${details.cache.mode}, ${details.cache.ttlDays}d TTL, cleaned ${details.cache.cleanupDeleted})`,
2108
+ )}`;
2109
+
2110
+ const answer =
2111
+ (result.content[0]?.type === "text"
2112
+ ? result.content[0].text
2113
+ : renderAnswer(details)
2114
+ ).trim() || "(no output)";
2115
+
2116
+ const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
2117
+ const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
2118
+ return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
2119
+ });
2120
+ if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
2121
+
2122
+ if (status === "running") {
2123
+ const parts = [header, workspaceLine, modelLine, cacheLine];
2124
+ if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
2125
+ parts.push("", theme.fg("muted", "Searching GitHub…"));
2126
+ return new Text(parts.join("\n"), 0, 0);
2127
+ }
2128
+
2129
+ if (!expanded) {
2130
+ const previewLines = answer.split("\n").slice(0, 18);
2131
+ const parts = [
2132
+ header,
2133
+ workspaceLine,
2134
+ modelLine,
2135
+ cacheLine,
2136
+ "",
2137
+ theme.fg("toolOutput", previewLines.join("\n")),
2138
+ ];
2139
+ if (answer.split("\n").length > previewLines.length)
2140
+ parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
2141
+ if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
2142
+ return new Text(parts.join("\n"), 0, 0);
2143
+ }
2144
+
2145
+ const container = new Container();
2146
+ container.addChild(new Text(header, 0, 0));
2147
+ container.addChild(new Text(workspaceLine, 0, 0));
2148
+ container.addChild(new Text(modelLine, 0, 0));
2149
+ container.addChild(new Text(cacheLine, 0, 0));
2150
+ if (details.cache.cleanupErrors.length) {
2151
+ container.addChild(
2152
+ new Text(
2153
+ theme.fg("warning", `cache cleanup warnings: ${details.cache.cleanupErrors.length}`),
2154
+ 0,
2155
+ 0,
2156
+ ),
2157
+ );
2158
+ }
2159
+ if (toolLines.length) {
2160
+ container.addChild(new Spacer(1));
2161
+ container.addChild(new Text([theme.fg("muted", "Tools:"), ...toolLines].join("\n"), 0, 0));
2162
+ }
2163
+ container.addChild(new Spacer(1));
2164
+ container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
2165
+ return container;
2166
+ },
2167
+ });
1710
2168
  }