@diegopetrucci/pi-extensions 0.1.52 → 0.1.54

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 (68) hide show
  1. package/.pi-fleet-tested-version +1 -1
  2. package/README.md +35 -15
  3. package/extensions/agent-workflow-audit/.pi-fleet-tested-version +1 -1
  4. package/extensions/agent-workflow-audit/index.ts +12 -0
  5. package/extensions/agent-workflow-audit/package.json +1 -1
  6. package/extensions/annotate-git-diff/.pi-fleet-tested-version +1 -1
  7. package/extensions/annotate-git-diff/git.ts +1 -0
  8. package/extensions/annotate-git-diff/index.ts +1 -1
  9. package/extensions/annotate-git-diff/package.json +1 -1
  10. package/extensions/annotate-git-diff/prompt.ts +2 -1
  11. package/extensions/annotate-last-message/.pi-fleet-tested-version +1 -1
  12. package/extensions/annotate-last-message/index.ts +1 -1
  13. package/extensions/annotate-last-message/package.json +1 -1
  14. package/extensions/brrr/.pi-fleet-tested-version +1 -1
  15. package/extensions/brrr/index.ts +4 -3
  16. package/extensions/brrr/package.json +1 -1
  17. package/extensions/claude-fast/.pi-fleet-tested-version +1 -1
  18. package/extensions/claude-fast/package.json +1 -1
  19. package/extensions/confirm-destructive/.pi-fleet-tested-version +1 -1
  20. package/extensions/confirm-destructive/package.json +1 -1
  21. package/extensions/context-cap/.pi-fleet-tested-version +1 -1
  22. package/extensions/context-cap/package.json +1 -1
  23. package/extensions/context-inspector/.pi-fleet-tested-version +1 -1
  24. package/extensions/context-inspector/index.ts +5 -0
  25. package/extensions/context-inspector/package.json +1 -1
  26. package/extensions/contrarian/.pi-fleet-tested-version +1 -1
  27. package/extensions/contrarian/index.ts +160 -35
  28. package/extensions/contrarian/package.json +1 -1
  29. package/extensions/dirty-repo-guard/.pi-fleet-tested-version +1 -1
  30. package/extensions/dirty-repo-guard/package.json +1 -1
  31. package/extensions/git-footer/.pi-fleet-tested-version +1 -1
  32. package/extensions/git-footer/index.ts +42 -14
  33. package/extensions/git-footer/package.json +1 -1
  34. package/extensions/gnosis/.pi-fleet-tested-version +1 -1
  35. package/extensions/gnosis/package.json +1 -1
  36. package/extensions/illustrations-to-explain-things/.pi-fleet-tested-version +1 -1
  37. package/extensions/illustrations-to-explain-things/package.json +1 -1
  38. package/extensions/inline-bash/.pi-fleet-tested-version +1 -1
  39. package/extensions/inline-bash/package.json +1 -1
  40. package/extensions/librarian/.pi-fleet-tested-version +1 -1
  41. package/extensions/librarian/index.ts +206 -85
  42. package/extensions/librarian/package.json +1 -1
  43. package/extensions/minimal-footer/.pi-fleet-tested-version +1 -1
  44. package/extensions/minimal-footer/index.ts +132 -72
  45. package/extensions/minimal-footer/package.json +1 -1
  46. package/extensions/notify/.pi-fleet-tested-version +1 -1
  47. package/extensions/notify/package.json +1 -1
  48. package/extensions/openai-fast/.pi-fleet-tested-version +1 -1
  49. package/extensions/openai-fast/package.json +1 -1
  50. package/extensions/oracle/.pi-fleet-tested-version +1 -1
  51. package/extensions/oracle/index.ts +146 -34
  52. package/extensions/oracle/package.json +1 -1
  53. package/extensions/permission-gate/.pi-fleet-tested-version +1 -1
  54. package/extensions/permission-gate/index.ts +5 -1
  55. package/extensions/permission-gate/package.json +1 -1
  56. package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
  57. package/extensions/quiet-tools/index.ts +6 -0
  58. package/extensions/quiet-tools/package.json +1 -1
  59. package/extensions/review/.pi-fleet-tested-version +1 -1
  60. package/extensions/review/index.ts +251 -197
  61. package/extensions/review/package.json +1 -1
  62. package/extensions/todo/.pi-fleet-tested-version +1 -1
  63. package/extensions/todo/index.ts +1 -1
  64. package/extensions/todo/package.json +1 -1
  65. package/extensions/triage-comments/.pi-fleet-tested-version +1 -1
  66. package/extensions/triage-comments/index.ts +25 -3
  67. package/extensions/triage-comments/package.json +1 -1
  68. package/package.json +6 -5
@@ -31,6 +31,11 @@ type CommandRunner = (
31
31
  options: { cwd: string; signal: AbortSignal },
32
32
  ) => Promise<CommandResult>;
33
33
 
34
+ type RunCommandSafelyResult =
35
+ | { kind: "ok"; result: CommandResult }
36
+ | { kind: "transient" }
37
+ | { kind: "unavailable" };
38
+
34
39
  type TimerHandle = unknown;
35
40
 
36
41
  type Clock = {
@@ -286,6 +291,10 @@ function defaultClock(): Clock {
286
291
  };
287
292
  }
288
293
 
294
+ function isCommandUnavailableError(error: unknown): boolean {
295
+ return !!error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT";
296
+ }
297
+
289
298
  class GitFooterCache {
290
299
  private readonly cwd: () => string;
291
300
  private readonly runner: CommandRunner;
@@ -345,7 +354,7 @@ class GitFooterCache {
345
354
  const result = await this.fetchGitStatus();
346
355
  if (this.disposed) return;
347
356
  if (result.kind === "transient") return;
348
- if (result.kind === "not-a-repo") {
357
+ if (result.kind === "not-a-repo" || result.kind === "unavailable") {
349
358
  this.statusSnapshot = undefined;
350
359
  this.pullRequestSnapshot = undefined;
351
360
  this.lastSeenBranch = undefined;
@@ -371,8 +380,8 @@ class GitFooterCache {
371
380
 
372
381
  const pr = await this.fetchPullRequest();
373
382
  if (this.disposed) return;
374
- if (pr !== undefined) this.pullRequestSnapshot = pr;
375
- else if (branchChanged) this.pullRequestSnapshot = undefined;
383
+ if (pr.kind === "ok") this.pullRequestSnapshot = pr.pullRequest;
384
+ else if (pr.kind === "not-found" || pr.kind === "unavailable") this.pullRequestSnapshot = undefined;
376
385
 
377
386
  this.emitChangeIfSnapshotsChanged(previousStatusSnapshot, previousPullRequestSnapshot);
378
387
  }
@@ -399,32 +408,40 @@ class GitFooterCache {
399
408
  | { kind: "ok"; status: GitStatusSnapshot }
400
409
  | { kind: "not-a-repo" }
401
410
  | { kind: "transient" }
411
+ | { kind: "unavailable" }
402
412
  > {
403
413
  const result = await this.runCommandSafely("git", GIT_STATUS_ARGS, this.gitTimeoutMs);
404
- if (!result) return { kind: "transient" };
405
- if (result.exitCode !== 0) return { kind: "not-a-repo" };
406
- return { kind: "ok", status: parseGitStatusPorcelainV2(result.stdout) };
414
+ if (result.kind !== "ok") return result;
415
+ if (result.result.exitCode !== 0) return { kind: "not-a-repo" };
416
+ return { kind: "ok", status: parseGitStatusPorcelainV2(result.result.stdout) };
407
417
  }
408
418
 
409
- private async fetchPullRequest(): Promise<PullRequestSnapshot | undefined> {
419
+ private async fetchPullRequest(): Promise<
420
+ | { kind: "ok"; pullRequest: PullRequestSnapshot | undefined }
421
+ | { kind: "not-found" }
422
+ | { kind: "transient" }
423
+ | { kind: "unavailable" }
424
+ > {
410
425
  const result = await this.runCommandSafely("gh", GH_PR_VIEW_ARGS, this.ghTimeoutMs);
411
- if (!result || result.exitCode !== 0) return undefined;
412
- return parsePullRequestJson(result.stdout);
426
+ if (result.kind !== "ok") return result;
427
+ if (result.result.exitCode !== 0) return { kind: "not-found" };
428
+ return { kind: "ok", pullRequest: parsePullRequestJson(result.result.stdout) };
413
429
  }
414
430
 
415
431
  private async runCommandSafely(
416
432
  command: string,
417
433
  args: readonly string[],
418
434
  timeoutMs: number,
419
- ): Promise<CommandResult | undefined> {
420
- if (this.disposed) return undefined;
435
+ ): Promise<RunCommandSafelyResult> {
436
+ if (this.disposed) return { kind: "transient" };
421
437
  const controller = new AbortController();
422
438
  this.inflightControllers.add(controller);
423
439
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
424
440
  try {
425
- return await this.runner(command, args, { cwd: this.cwd(), signal: controller.signal });
426
- } catch {
427
- return undefined;
441
+ const result = await this.runner(command, args, { cwd: this.cwd(), signal: controller.signal });
442
+ return { kind: "ok", result };
443
+ } catch (error) {
444
+ return isCommandUnavailableError(error) ? { kind: "unavailable" } : { kind: "transient" };
428
445
  } finally {
429
446
  clearTimeout(timeoutId);
430
447
  this.inflightControllers.delete(controller);
@@ -478,3 +495,14 @@ export default function (pi: ExtensionAPI) {
478
495
  ctx.ui.setStatus(STATUS_KEY, undefined);
479
496
  });
480
497
  }
498
+
499
+ export const __testing = {
500
+ GitFooterCache,
501
+ GIT_STATUS_ARGS,
502
+ GH_PR_VIEW_ARGS,
503
+ parseGitStatusPorcelainV2,
504
+ parsePullRequestJson,
505
+ formatGitStatusFooterSegment,
506
+ formatPullRequestFooterSegment,
507
+ formatGitFooterStatus,
508
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-git-footer",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A TLH-style git status add-on for pi's built-in footer.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.79.10
1
+ 0.80.3
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-gnosis",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A pi extension that exposes the gnosis repo-local knowledge base CLI as an agent tool.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.79.10
1
+ 0.80.3
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-illustrations-to-explain-things",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A pi skill for generating clean, absurd Xiaohei-style article illustrations, shot lists, and visual metaphors.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.79.10
1
+ 0.80.3
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-inline-bash",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A pi extension that expands inline bash commands in user prompts.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.79.10
1
+ 0.80.3
@@ -477,53 +477,104 @@ async function findAvailableModel(
477
477
  return undefined;
478
478
  }
479
479
 
480
- async function selectLibrarianModel(
480
+ // A model the catalog advertises but the active provider/subscription cannot
481
+ // serve surfaces as a not-found/404-style error (legacy snapshots or
482
+ // access-gated tiers). These are NOT transient: the same model keeps failing,
483
+ // so we fall back to the next candidate instead of retrying it.
484
+ const MODEL_AVAILABILITY_ERROR_PATTERN =
485
+ /\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;
486
+
487
+ function isModelAvailabilityError(message: string | undefined): boolean {
488
+ if (!message) return false;
489
+ return MODEL_AVAILABILITY_ERROR_PATTERN.test(message);
490
+ }
491
+
492
+ type LibrarianCandidate = { model: any; details: LibrarianModelDetails };
493
+
494
+ function withLibrarianFallbackReason(candidate: LibrarianCandidate, previous: LibrarianCandidate): LibrarianCandidate {
495
+ return {
496
+ model: candidate.model,
497
+ details: {
498
+ ...candidate.details,
499
+ selectionReason: `${candidate.details.selectionReason} (Fell back from ${previous.details.modelRef} after a model-availability error.)`,
500
+ },
501
+ };
502
+ }
503
+
504
+ // Build an ordered list of model candidates to try. The catalog can advertise
505
+ // models the active provider/subscription cannot actually serve, so callers run
506
+ // these in order and skip ones that fail with a model-availability error,
507
+ // ultimately falling back to the known-good current session model.
508
+ async function buildLibrarianCandidates(
481
509
  ctx: { model?: any; modelRegistry: { getAvailable(): any[] | Promise<any[]> } },
482
510
  modelPreference: string | undefined,
483
511
  thinkingLevel: ThinkingLevel,
484
- ): Promise<{ model: any; details: LibrarianModelDetails }> {
512
+ ): Promise<LibrarianCandidate[]> {
513
+ const candidates: LibrarianCandidate[] = [];
514
+ const seen = new Set<string>();
515
+ const push = (model: any, details: LibrarianModelDetails) => {
516
+ if (!model) return;
517
+ const key = `${model.provider}/${model.id}`.toLowerCase();
518
+ if (seen.has(key)) return;
519
+ seen.add(key);
520
+ candidates.push({ model, details });
521
+ };
522
+
485
523
  const normalized = modelPreference?.trim();
524
+ let configuredMatched: any | undefined;
486
525
  if (normalized) {
487
- const matched = await findAvailableModel(ctx, normalized);
488
- if (matched) {
489
- return {
490
- model: matched,
491
- details: {
492
- modelRef: modelRef(matched),
493
- provider: matched.provider,
494
- modelId: matched.id,
495
- thinkingLevel,
496
- autoSelected: false,
497
- selectionReason: "Using the configured librarian model.",
498
- },
499
- };
526
+ configuredMatched = await findAvailableModel(ctx, normalized);
527
+ if (configuredMatched) {
528
+ push(configuredMatched, {
529
+ modelRef: modelRef(configuredMatched),
530
+ provider: configuredMatched.provider,
531
+ modelId: configuredMatched.id,
532
+ thinkingLevel,
533
+ autoSelected: false,
534
+ selectionReason: "Using the configured librarian model.",
535
+ });
500
536
  }
501
537
  }
502
538
 
503
539
  const available = await ctx.modelRegistry.getAvailable();
504
540
  const preferred = available.filter(isPreferredFastLibrarianModel);
505
- const winner = preferred.length > 0
506
- ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))[0]
507
- : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b))[0];
508
- if (!winner) {
509
- throw new Error("No authenticated models are available for Librarian. Log in or configure an API key first.");
510
- }
541
+ const ranked = preferred.length > 0
542
+ ? [...preferred].sort((a, b) => rankPreferredFastLibrarianModel(a) - rankPreferredFastLibrarianModel(b))
543
+ : [...available].sort((a, b) => rankLibrarianModel(a) - rankLibrarianModel(b));
544
+ const fallbackText = normalized && !configuredMatched
545
+ ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.`
546
+ : "";
547
+ ranked.forEach((model, index) => {
548
+ const topReason = preferred.length > 0
549
+ ? `Selected a preferred fast Librarian model.${fallbackText}`
550
+ : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
551
+ push(model, {
552
+ modelRef: modelRef(model),
553
+ provider: model.provider,
554
+ modelId: model.id,
555
+ thinkingLevel,
556
+ autoSelected: true,
557
+ selectionReason: index === 0 ? topReason : `Auto-selected ${modelRef(model)} as a lower-ranked fallback.`,
558
+ });
559
+ });
511
560
 
512
- const fallbackText = normalized ? ` Configured model ${normalized} was unavailable, so Librarian fell back to auto-selection.` : "";
513
- const selectionReason = preferred.length > 0
514
- ? `Selected a preferred fast Librarian model.${fallbackText}`
515
- : `Selected the cheapest available model because no preferred fast Librarian model was available.${fallbackText}`;
516
- return {
517
- model: winner,
518
- details: {
519
- modelRef: modelRef(winner),
520
- provider: winner.provider,
521
- modelId: winner.id,
561
+ // Final fallback: the active session model is known to be servable.
562
+ if (ctx.model) {
563
+ push(ctx.model, {
564
+ modelRef: modelRef(ctx.model),
565
+ provider: ctx.model.provider,
566
+ modelId: ctx.model.id,
522
567
  thinkingLevel,
523
568
  autoSelected: true,
524
- selectionReason,
525
- },
526
- };
569
+ selectionReason: `Used the current session model ${modelRef(ctx.model)} as a final fallback.`,
570
+ });
571
+ }
572
+
573
+ if (candidates.length === 0) {
574
+ throw new Error("No authenticated models are available for Librarian. Log in or configure an API key first.");
575
+ }
576
+
577
+ return candidates;
527
578
  }
528
579
 
529
580
  function resolveToolPath(cwd: string, rawPath: string): string {
@@ -726,6 +777,13 @@ function isAbortLikeError(error: unknown): boolean {
726
777
  return /aborted|cancelled|canceled/i.test(message);
727
778
  }
728
779
 
780
+ export const __test__ = {
781
+ buildLibrarianCandidates,
782
+ findAvailableModel,
783
+ isModelAvailabilityError,
784
+ parseModelPreference,
785
+ };
786
+
729
787
  export default function librarianExtension(pi: ExtensionAPI) {
730
788
  let cachePreference: CacheMode = DEFAULT_CACHE_MODE;
731
789
  let modelPreference: string | undefined;
@@ -944,7 +1002,8 @@ export default function librarianExtension(pi: ExtensionAPI) {
944
1002
  parseThinkingLevel((params as { thinkingLevel?: unknown }).thinkingLevel) ??
945
1003
  explicitModel.thinkingLevel ??
946
1004
  thinkingPreference;
947
- const selectedModel = await selectLibrarianModel(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
1005
+ const candidates = await buildLibrarianCandidates(ctx, explicitModel.model ?? modelPreference, thinkingLevel);
1006
+ const selectedModel = candidates[0];
948
1007
 
949
1008
  const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
950
1009
  await fs.mkdir(workspaceBase, { recursive: true });
@@ -1045,62 +1104,124 @@ export default function librarianExtension(pi: ExtensionAPI) {
1045
1104
 
1046
1105
  await resourceLoader.reload();
1047
1106
 
1048
- const created = await createAgentSession({
1049
- cwd: workspace,
1050
- modelRegistry: ctx.modelRegistry,
1051
- resourceLoader,
1052
- sessionManager: SessionManager.inMemory(workspace),
1053
- model: selectedModel.model,
1054
- thinkingLevel,
1055
- tools: ["read", "bash"],
1056
- });
1107
+ const runAttempt = async (
1108
+ candidate: LibrarianCandidate,
1109
+ ): Promise<{ answer: string; lastAssistant: AssistantLikeMessage | undefined }> => {
1110
+ details.model = candidate.details;
1111
+ details.turns = 0;
1112
+ details.toolCalls = [];
1113
+ emit(true);
1114
+
1115
+ const created = await createAgentSession({
1116
+ cwd: workspace,
1117
+ modelRegistry: ctx.modelRegistry,
1118
+ resourceLoader,
1119
+ sessionManager: SessionManager.inMemory(workspace),
1120
+ model: candidate.model,
1121
+ thinkingLevel,
1122
+ tools: ["read", "bash"],
1123
+ });
1057
1124
 
1058
- session = created.session as typeof session;
1059
- unsubscribe = (created.session as any).subscribe((event: any) => {
1060
- switch (event.type) {
1061
- case "turn_end":
1062
- details.turns += 1;
1063
- emit();
1064
- break;
1065
- case "tool_execution_start":
1066
- details.toolCalls.push({
1067
- id: event.toolCallId,
1068
- name: event.toolName,
1069
- args: event.args,
1070
- startedAt: Date.now(),
1071
- });
1072
- if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1073
- details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1074
- }
1075
- emit(true);
1076
- break;
1077
- case "tool_execution_end": {
1078
- const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1079
- if (call) {
1080
- call.endedAt = Date.now();
1081
- call.isError = event.isError;
1125
+ session = created.session as typeof session;
1126
+ unsubscribe = (created.session as any).subscribe((event: any) => {
1127
+ switch (event.type) {
1128
+ case "turn_end":
1129
+ details.turns += 1;
1130
+ emit();
1131
+ break;
1132
+ case "tool_execution_start":
1133
+ details.toolCalls.push({
1134
+ id: event.toolCallId,
1135
+ name: event.toolName,
1136
+ args: event.args,
1137
+ startedAt: Date.now(),
1138
+ });
1139
+ if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
1140
+ details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
1141
+ }
1142
+ emit(true);
1143
+ break;
1144
+ case "tool_execution_end": {
1145
+ const call = details.toolCalls.find((item) => item.id === event.toolCallId);
1146
+ if (call) {
1147
+ call.endedAt = Date.now();
1148
+ call.isError = event.isError;
1149
+ }
1150
+ emit(true);
1151
+ break;
1082
1152
  }
1083
- emit(true);
1084
- break;
1085
1153
  }
1154
+ });
1155
+
1156
+ try {
1157
+ if (!aborted) {
1158
+ const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1159
+ expandPromptTemplates: false,
1160
+ });
1161
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1162
+ runTimeout = setTimeout(() => {
1163
+ abort();
1164
+ reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1165
+ }, MAX_RUN_MS);
1166
+ });
1167
+ await Promise.race([promptPromise, timeoutPromise]);
1168
+ }
1169
+
1170
+ const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1171
+ const answer = session ? extractLastAssistantText(session.state.messages) : "";
1172
+ return { answer, lastAssistant };
1173
+ } finally {
1174
+ if (runTimeout) {
1175
+ clearTimeout(runTimeout);
1176
+ runTimeout = undefined;
1177
+ }
1178
+ unsubscribe?.();
1179
+ unsubscribe = undefined;
1180
+ session?.dispose();
1181
+ session = undefined;
1086
1182
  }
1087
- });
1183
+ };
1088
1184
 
1089
- if (!aborted) {
1090
- const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
1091
- expandPromptTemplates: false,
1092
- });
1093
- const timeoutPromise = new Promise<never>((_resolve, reject) => {
1094
- runTimeout = setTimeout(() => {
1095
- abort();
1096
- reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
1097
- }, MAX_RUN_MS);
1098
- });
1099
- await Promise.race([promptPromise, timeoutPromise]);
1185
+ let answer = "";
1186
+ let lastAssistant: AssistantLikeMessage | undefined;
1187
+ let attemptErrorReason: string | undefined;
1188
+ for (let index = 0; index < candidates.length; index++) {
1189
+ if (aborted) break;
1190
+ const candidate =
1191
+ index === 0 ? candidates[index] : withLibrarianFallbackReason(candidates[index], candidates[index - 1]);
1192
+
1193
+ let attempt: { answer: string; lastAssistant: AssistantLikeMessage | undefined };
1194
+ try {
1195
+ attempt = await runAttempt(candidate);
1196
+ } catch (error) {
1197
+ // Let aborts/timeouts propagate to the outer handler.
1198
+ if (aborted || isAbortLikeError(error)) throw error;
1199
+ // Some providers throw (rather than carry the error on the assistant
1200
+ // message) when a model is unavailable; treat that like a
1201
+ // message-carried availability error and fall back to the next model.
1202
+ const message = error instanceof Error ? error.message : String(error);
1203
+ if (index < candidates.length - 1 && isModelAvailabilityError(message)) {
1204
+ answer = "";
1205
+ lastAssistant = undefined;
1206
+ attemptErrorReason = `the internal subagent stopped with an error: ${message}`;
1207
+ continue;
1208
+ }
1209
+ throw error;
1210
+ }
1211
+
1212
+ answer = attempt.answer;
1213
+ lastAssistant = attempt.lastAssistant;
1214
+ if (answer || aborted) break;
1215
+
1216
+ attemptErrorReason = describeNoAnswerReason(lastAssistant, details.turns);
1217
+ const errorMessage =
1218
+ lastAssistant && lastAssistant.stopReason === "error" && typeof lastAssistant.errorMessage === "string"
1219
+ ? lastAssistant.errorMessage
1220
+ : undefined;
1221
+ const canFallBack = index < candidates.length - 1 && isModelAvailabilityError(errorMessage);
1222
+ if (!canFallBack) break;
1100
1223
  }
1101
1224
 
1102
- const lastAssistant = session ? getLastAssistantMessage(session.state.messages) : undefined;
1103
- const answer = session ? extractLastAssistantText(session.state.messages) : "";
1104
1225
  if (answer) {
1105
1226
  lastContent = answer;
1106
1227
  details.status = "done";
@@ -1109,7 +1230,7 @@ export default function librarianExtension(pi: ExtensionAPI) {
1109
1230
  details.status = "aborted";
1110
1231
  } else {
1111
1232
  details.status = "error";
1112
- const reason = describeNoAnswerReason(lastAssistant, details.turns);
1233
+ const reason = attemptErrorReason ?? describeNoAnswerReason(lastAssistant, details.turns);
1113
1234
  lastContent = formatInternalFailure(details, reason);
1114
1235
  details.error = reason;
1115
1236
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diegopetrucci/pi-librarian",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "A pi GitHub research scout with a toggleable local repo checkout cache under the user's OS cache directory.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1 +1 @@
1
- 0.79.10
1
+ 0.80.3