@oh-my-pi/pi-coding-agent 16.0.1 → 16.0.2

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.
@@ -139,9 +139,11 @@ import {
139
139
  filterAvailableModelsByEnabledPatterns,
140
140
  formatModelSelectorValue,
141
141
  formatModelString,
142
+ formatModelStringWithRouting,
142
143
  getModelMatchPreferences,
143
144
  parseModelString,
144
145
  type ResolvedModelRoleValue,
146
+ resolveModelOverride,
145
147
  resolveModelRoleValue,
146
148
  resolveRoleSelection,
147
149
  } from "../config/model-resolver";
@@ -273,6 +275,7 @@ import {
273
275
  type BashExecutionMessage,
274
276
  type CustomMessage,
275
277
  convertToLlm,
278
+ GENERIC_ABORT_SENTINEL,
276
279
  type PythonExecutionMessage,
277
280
  readQueueChipText,
278
281
  SILENT_ABORT_MARKER,
@@ -640,8 +643,7 @@ function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | u
640
643
  }
641
644
 
642
645
  function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
643
- const selector = formatModelString(model);
644
- return thinkingLevel ? `${selector}:${thinkingLevel}` : selector;
646
+ return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
645
647
  }
646
648
 
647
649
  function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string {
@@ -2402,6 +2404,11 @@ export class AgentSession {
2402
2404
  return;
2403
2405
  }
2404
2406
 
2407
+ if (this.#isRetryableReasonlessAbort(msg)) {
2408
+ const didRetry = await this.#handleRetryableError(msg, { allowModelFallback: false });
2409
+ if (didRetry) return;
2410
+ }
2411
+
2405
2412
  // A deliberate abort should settle the current turn, not trigger queued continuations.
2406
2413
  if (msg.stopReason === "aborted") {
2407
2414
  this.#resolveRetry();
@@ -2566,6 +2573,11 @@ export class AgentSession {
2566
2573
 
2567
2574
  #scheduleAutoContinuePrompt(generation: number): void {
2568
2575
  const continuePrompt = async () => {
2576
+ // Compaction summarizes away the first-message eager preludes, so re-assert the
2577
+ // delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs
2578
+ // at invocation (past the abort check below), so an aborted continuation queues
2579
+ // nothing; scoped to this request via prependMessages, never the shared queue.
2580
+ const eagerNudges = this.#buildPostCompactionEagerNudges();
2569
2581
  await this.#promptWithMessage(
2570
2582
  {
2571
2583
  role: "developer",
@@ -2574,7 +2586,10 @@ export class AgentSession {
2574
2586
  timestamp: Date.now(),
2575
2587
  },
2576
2588
  autoContinuePrompt,
2577
- { skipPostPromptRecoveryWait: true },
2589
+ {
2590
+ skipPostPromptRecoveryWait: true,
2591
+ prependMessages: eagerNudges.length > 0 ? eagerNudges : undefined,
2592
+ },
2578
2593
  );
2579
2594
  };
2580
2595
  this.#schedulePostPromptTask(
@@ -7774,7 +7789,9 @@ export class AgentSession {
7774
7789
  };
7775
7790
  }
7776
7791
 
7777
- #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
7792
+ #createEagerTodoPrelude(
7793
+ promptText: string | undefined,
7794
+ ): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
7778
7795
  const mode = this.settings.get("todo.eager");
7779
7796
  const todosEnabled = this.settings.get("todo.enabled");
7780
7797
  if (mode === "default" || !todosEnabled) {
@@ -7791,14 +7808,18 @@ export class AgentSession {
7791
7808
  // Only inject on the first user message of the conversation. Subsequent user
7792
7809
  // turns must not receive the eager todo reminder — they often correct, clarify,
7793
7810
  // or redirect the prior task, and forcing a brand-new todo list there is wrong.
7794
- const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
7795
- if (hasPriorUserMessage) {
7796
- return undefined;
7797
- }
7811
+ // When `promptText` is undefined (post-compaction re-injection) there is no fresh
7812
+ // user message to gate on, so skip the first-message and prompt-suffix checks.
7813
+ if (promptText !== undefined) {
7814
+ const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
7815
+ if (hasPriorUserMessage) {
7816
+ return undefined;
7817
+ }
7798
7818
 
7799
- const trimmedPromptText = promptText.trimEnd();
7800
- if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
7801
- return undefined;
7819
+ const trimmedPromptText = promptText.trimEnd();
7820
+ if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
7821
+ return undefined;
7822
+ }
7802
7823
  }
7803
7824
 
7804
7825
  // Must check the active tool set, not just the registry: tool discovery
@@ -7821,8 +7842,10 @@ export class AgentSession {
7821
7842
  timestamp: Date.now(),
7822
7843
  };
7823
7844
  // `preferred` suggests a todo list (reminder only); `always` also forces the
7824
- // `todo` tool on the first turn — the previous boolean-on behavior.
7825
- if (mode === "preferred") {
7845
+ // `todo` tool on the first turn — the previous boolean-on behavior. Post-compaction
7846
+ // re-injection (`promptText === undefined`) is always reminder-only: forcing a tool
7847
+ // onto the auto-resumed turn would override the agent's in-flight action.
7848
+ if (promptText === undefined || mode === "preferred") {
7826
7849
  return { message };
7827
7850
  }
7828
7851
  const todoToolChoice = buildNamedToolChoice("todo", this.model);
@@ -7840,7 +7863,7 @@ export class AgentSession {
7840
7863
  return { message, toolChoice: todoToolChoice };
7841
7864
  }
7842
7865
 
7843
- #createEagerTaskPrelude(promptText: string): AgentMessage | undefined {
7866
+ #createEagerTaskPrelude(promptText: string | undefined): AgentMessage | undefined {
7844
7867
  if (this.settings.get("task.eager") !== "always") return undefined;
7845
7868
  // Main agent only: subagents keep `task` active (the parent only filters `todo`),
7846
7869
  // so a salient delegate-reminder there would amplify nested fan-out. Gate on the
@@ -7848,9 +7871,13 @@ export class AgentSession {
7848
7871
  // still gets the reminder.
7849
7872
  if (this.#agentKind === "sub") return undefined;
7850
7873
  if (this.#planModeState?.enabled) return undefined;
7851
- if (this.agent.state.messages.some(m => m.role === "user")) return undefined;
7852
- const trimmed = promptText.trimEnd();
7853
- if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
7874
+ // First-message-only gates are skipped post-compaction (`promptText === undefined`),
7875
+ // where there is no fresh user message to suppress the reminder for.
7876
+ if (promptText !== undefined) {
7877
+ if (this.agent.state.messages.some(m => m.role === "user")) return undefined;
7878
+ const trimmed = promptText.trimEnd();
7879
+ if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
7880
+ }
7854
7881
  if (!this.getActiveToolNames().includes("task")) return undefined;
7855
7882
  return {
7856
7883
  role: "custom",
@@ -7861,6 +7888,24 @@ export class AgentSession {
7861
7888
  timestamp: Date.now(),
7862
7889
  };
7863
7890
  }
7891
+
7892
+ /**
7893
+ * Build the eager task/todo reminders to re-inject on the auto-continuation turn that
7894
+ * follows a compaction. The first-message preludes are the oldest messages, so
7895
+ * compaction summarizes them away and the agent silently loses the delegate-via-tasks
7896
+ * and phased-todo guidance mid-work; this re-asserts them, reminder-only (the todo
7897
+ * builder drops its forced tool_choice when `promptText` is undefined). Each builder
7898
+ * still applies its own mode / agent-kind / plan-mode / tool-active / surviving-todo
7899
+ * gates, so an empty array means nothing currently warrants a nudge.
7900
+ */
7901
+ #buildPostCompactionEagerNudges(): AgentMessage[] {
7902
+ const nudges: AgentMessage[] = [];
7903
+ const todo = this.#createEagerTodoPrelude(undefined);
7904
+ if (todo) nudges.push(todo.message);
7905
+ const task = this.#createEagerTaskPrelude(undefined);
7906
+ if (task) nudges.push(task);
7907
+ return nudges;
7908
+ }
7864
7909
  /**
7865
7910
  * Check if agent stopped with incomplete todos and prompt to continue.
7866
7911
  */
@@ -9136,9 +9181,31 @@ export class AgentSession {
9136
9181
  // Auto-Retry
9137
9182
  // =========================================================================
9138
9183
 
9184
+ /**
9185
+ * Retry an empty, reason-less provider abort: a turn that ended `aborted`
9186
+ * with no content and the generic sentinel (bare `abort()`), but only while
9187
+ * the session is neither aborting nor tearing down. A user/lifecycle abort
9188
+ * (`#abortInProgress`), a dispose-driven abort (`#isDisposed`), or a
9189
+ * session-induced streaming-edit guard abort (`#streamingEditAbortTriggered` —
9190
+ * auto-generated-file guard or failed-patch preview) is deliberate and MUST
9191
+ * settle the turn instead: routing it through retry would orphan
9192
+ * `#retryPromise` on a continuation the guard skips (hanging the in-flight
9193
+ * `prompt()`) or silently undo the guard's intended abort.
9194
+ */
9195
+ #isRetryableReasonlessAbort(message: AssistantMessage): boolean {
9196
+ return (
9197
+ message.stopReason === "aborted" &&
9198
+ message.content.length === 0 &&
9199
+ message.errorMessage === GENERIC_ABORT_SENTINEL &&
9200
+ !this.#abortInProgress &&
9201
+ !this.#isDisposed &&
9202
+ !this.#streamingEditAbortTriggered
9203
+ );
9204
+ }
9205
+
9139
9206
  /**
9140
9207
  * Check if an error is retryable (transient errors or usage limits).
9141
- * Context overflow errors are NOT retryable (handled by compaction instead).
9208
+ * Context overflow is NOT retryable (handled by compaction instead).
9142
9209
  * Usage-limit errors are retryable because the retry handler performs credential switching.
9143
9210
  */
9144
9211
  #isRetryableError(message: AssistantMessage): boolean {
@@ -9295,11 +9362,25 @@ export class AgentSession {
9295
9362
  const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9296
9363
  if (!parsedCurrent) return undefined;
9297
9364
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
9365
+ const currentPlainSelector = this.model
9366
+ ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
9367
+ : undefined;
9368
+ const currentPlainBaseSelector =
9369
+ currentPlainSelector && currentPlainSelector !== currentSelector
9370
+ ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
9371
+ : undefined;
9372
+
9373
+ for (const role of Object.keys(this.#getRetryFallbackChains())) {
9374
+ const primarySelector = this.#getRetryFallbackPrimarySelector(role);
9375
+ if (primarySelector?.raw === currentSelector) return role;
9376
+ }
9298
9377
  for (const role of Object.keys(this.#getRetryFallbackChains())) {
9299
9378
  const primarySelector = this.#getRetryFallbackPrimarySelector(role);
9300
9379
  if (!primarySelector) continue;
9301
- if (primarySelector.raw === currentSelector) return role;
9302
- if (formatRetryFallbackBaseSelector(primarySelector) === currentBaseSelector) return role;
9380
+ if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role;
9381
+ const primaryBaseSelector = formatRetryFallbackBaseSelector(primarySelector);
9382
+ if (primaryBaseSelector === currentBaseSelector) return role;
9383
+ if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role;
9303
9384
  }
9304
9385
  return undefined;
9305
9386
  }
@@ -9323,10 +9404,23 @@ export class AgentSession {
9323
9404
  if (chain.length <= 1) return [];
9324
9405
  const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9325
9406
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
9326
- const exactIndex = chain.findIndex(selector => selector.raw === currentSelector);
9407
+ const currentPlainSelector =
9408
+ this.model && parsedCurrent
9409
+ ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
9410
+ : undefined;
9411
+ const currentPlainBaseSelector =
9412
+ parsedCurrent && currentPlainSelector && currentPlainSelector !== currentSelector
9413
+ ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
9414
+ : undefined;
9415
+ const exactIndex = chain.findIndex(
9416
+ selector => selector.raw === currentSelector || selector.raw === currentPlainSelector,
9417
+ );
9327
9418
  if (exactIndex >= 0) return chain.slice(exactIndex + 1);
9328
9419
  const baseIndex = currentBaseSelector
9329
- ? chain.findIndex(selector => formatRetryFallbackBaseSelector(selector) === currentBaseSelector)
9420
+ ? chain.findIndex(selector => {
9421
+ const selectorBase = formatRetryFallbackBaseSelector(selector);
9422
+ return selectorBase === currentBaseSelector || selectorBase === currentPlainBaseSelector;
9423
+ })
9330
9424
  : -1;
9331
9425
  if (baseIndex >= 0) return chain.slice(baseIndex + 1);
9332
9426
  return chain.slice(1);
@@ -9338,7 +9432,8 @@ export class AgentSession {
9338
9432
  currentSelector: string,
9339
9433
  options?: { pinFallback?: boolean },
9340
9434
  ): Promise<void> {
9341
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9435
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
9436
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
9342
9437
  if (!candidate) {
9343
9438
  throw new Error(`Retry fallback model not found: ${selector.raw}`);
9344
9439
  }
@@ -9351,10 +9446,10 @@ export class AgentSession {
9351
9446
  // `auto` instead of collapsing it to the level it resolved to this turn.
9352
9447
  const currentThinkingLevel = this.configuredThinkingLevel();
9353
9448
  const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
9354
-
9449
+ const candidateSelector = formatModelStringWithRouting(candidate);
9355
9450
  this.#setModelWithProviderSessionReset(candidate);
9356
- this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
9357
- this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
9451
+ this.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE);
9452
+ this.settings.getStorage()?.recordModelUsage(candidateSelector);
9358
9453
  this.setThinkingLevel(nextThinkingLevel);
9359
9454
  if (!this.#activeRetryFallback) {
9360
9455
  this.#activeRetryFallback = {
@@ -9382,7 +9477,8 @@ export class AgentSession {
9382
9477
 
9383
9478
  for (const selector of this.#findRetryFallbackCandidates(role, currentSelector)) {
9384
9479
  if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
9385
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9480
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
9481
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
9386
9482
  if (!candidate) continue;
9387
9483
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
9388
9484
  if (!apiKey) continue;
@@ -9420,7 +9516,9 @@ export class AgentSession {
9420
9516
  }
9421
9517
  if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
9422
9518
 
9423
- const primaryModel = this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
9519
+ const resolvedPrimary = resolveModelOverride([originalSelector.raw], this.#modelRegistry, this.settings);
9520
+ const primaryModel =
9521
+ resolvedPrimary.model ?? this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
9424
9522
  if (!primaryModel) return;
9425
9523
  const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId);
9426
9524
  if (!apiKey) return;
@@ -9428,9 +9526,10 @@ export class AgentSession {
9428
9526
  const currentThinkingLevel = this.configuredThinkingLevel();
9429
9527
  const thinkingToApply =
9430
9528
  currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
9529
+ const primarySelector = formatModelStringWithRouting(primaryModel);
9431
9530
  this.#setModelWithProviderSessionReset(primaryModel);
9432
- this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
9433
- this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
9531
+ this.sessionManager.appendModelChange(primarySelector, EPHEMERAL_MODEL_CHANGE_ROLE);
9532
+ this.settings.getStorage()?.recordModelUsage(primarySelector);
9434
9533
  this.setThinkingLevel(thinkingToApply);
9435
9534
  this.#clearActiveRetryFallback();
9436
9535
  }
@@ -9490,7 +9589,10 @@ export class AgentSession {
9490
9589
  * Handle retryable errors with exponential backoff.
9491
9590
  * @returns true if retry was initiated, false if max retries exceeded or disabled
9492
9591
  */
9493
- async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
9592
+ async #handleRetryableError(
9593
+ message: AssistantMessage,
9594
+ options?: { allowModelFallback?: boolean },
9595
+ ): Promise<boolean> {
9494
9596
  const retrySettings = this.settings.getGroup("retry");
9495
9597
  if (!retrySettings.enabled) return false;
9496
9598
  const classifierRefusal = this.#isClassifierRefusal(message);
@@ -9578,9 +9680,10 @@ export class AgentSession {
9578
9680
  }
9579
9681
  }
9580
9682
 
9683
+ const allowModelFallback = options?.allowModelFallback !== false;
9581
9684
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
9582
9685
  if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
9583
- if (retrySettings.modelFallback) {
9686
+ if (allowModelFallback && retrySettings.modelFallback) {
9584
9687
  if (!classifierRefusal) {
9585
9688
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
9586
9689
  }
@@ -94,7 +94,7 @@ export function shouldRenderAbortReason(errorMessage: string | undefined): boole
94
94
 
95
95
  /** Sentinel `errorMessage` the agent stamps on any abort that carried no custom
96
96
  * reason (bare `abort()`). Renderers treat it as "no specific reason given". */
97
- const GENERIC_ABORT_SENTINEL = "Request was aborted";
97
+ export const GENERIC_ABORT_SENTINEL = "Request was aborted";
98
98
 
99
99
  /** Resolve the operator-facing label for an aborted assistant turn. A custom
100
100
  * abort reason threaded onto `errorMessage` is returned verbatim; aborts with
@@ -615,7 +615,13 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
615
615
  resolvedCustomPrompt,
616
616
  resolvedAppendPrompt,
617
617
  ]);
618
- const promptSources = [effectiveSystemPromptCustomization, resolvedCustomPrompt, resolvedAppendPrompt];
618
+ const contextPromptSources = contextFiles.map(file => file.content);
619
+ const promptSources = [
620
+ effectiveSystemPromptCustomization,
621
+ resolvedCustomPrompt,
622
+ resolvedAppendPrompt,
623
+ ...contextPromptSources,
624
+ ];
619
625
  const injectedAlwaysApplyRules = dedupeAlwaysApplyRules(alwaysApplyRules, promptSources);
620
626
 
621
627
  const environment = await logger.time("getEnvironmentInfo", getEnvironmentInfo);
@@ -7,11 +7,16 @@
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
9
9
  import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
- import type { Usage } from "@oh-my-pi/pi-ai";
10
+ import type { Api, Model, Usage } from "@oh-my-pi/pi-ai";
11
11
  import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import type { Rule } from "../capability/rule";
13
13
  import { ModelRegistry } from "../config/model-registry";
14
- import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
14
+ import {
15
+ formatModelSelectorValue,
16
+ formatModelStringWithRouting,
17
+ resolveModelOverride,
18
+ resolveModelOverrideWithAuthFallback,
19
+ } from "../config/model-resolver";
15
20
  import type { PromptTemplate } from "../config/prompt-templates";
16
21
  import { Settings } from "../config/settings";
17
22
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
@@ -120,6 +125,74 @@ function normalizeModelPatterns(value: string | string[] | undefined): string[]
120
125
  .filter(Boolean);
121
126
  }
122
127
 
128
+ const SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX = "subagent:";
129
+
130
+ interface SubagentRetryFallbackCandidate {
131
+ model: Model<Api>;
132
+ selector: string;
133
+ }
134
+
135
+ function resolveSubagentRetryFallbackCandidates(
136
+ modelPatterns: string[],
137
+ modelRegistry: ModelRegistry,
138
+ settings: Settings,
139
+ ): SubagentRetryFallbackCandidate[] {
140
+ const candidates: SubagentRetryFallbackCandidate[] = [];
141
+ const seen = new Set<string>();
142
+ for (const pattern of modelPatterns) {
143
+ const resolved = resolveModelOverride([pattern], modelRegistry, settings);
144
+ if (!resolved.model) continue;
145
+ const selector = resolved.explicitThinkingLevel
146
+ ? formatModelSelectorValue(formatModelStringWithRouting(resolved.model), resolved.thinkingLevel)
147
+ : formatModelStringWithRouting(resolved.model);
148
+ if (seen.has(selector)) continue;
149
+ seen.add(selector);
150
+ candidates.push({ model: resolved.model, selector });
151
+ }
152
+ return candidates;
153
+ }
154
+
155
+ function installSubagentRetryFallbackChain(args: {
156
+ settings: Settings;
157
+ id: string;
158
+ candidates: SubagentRetryFallbackCandidate[];
159
+ model: Model<Api> | undefined;
160
+ authFallbackUsed: boolean;
161
+ }): string | undefined {
162
+ const { settings, id, candidates, model, authFallbackUsed } = args;
163
+ if (!model || authFallbackUsed || candidates.length <= 1) return undefined;
164
+
165
+ const selectedIndex = candidates.findIndex(
166
+ candidate => candidate.model.provider === model.provider && candidate.model.id === model.id,
167
+ );
168
+ if (selectedIndex < 0) return undefined;
169
+ const fallbackSelectors = candidates.slice(selectedIndex + 1).map(candidate => candidate.selector);
170
+ if (fallbackSelectors.length === 0) return undefined;
171
+
172
+ const role = `${SUBAGENT_RETRY_FALLBACK_ROLE_PREFIX}${id}`;
173
+ const modelRoles: Record<string, string> = {};
174
+ const existingRoles = settings.getModelRoles();
175
+ for (const existingRole in existingRoles) {
176
+ const selector = existingRoles[existingRole];
177
+ if (selector) {
178
+ modelRoles[existingRole] = selector;
179
+ }
180
+ }
181
+ modelRoles[role] = candidates[selectedIndex].selector;
182
+ settings.override("modelRoles", modelRoles);
183
+ const fallbackChains: Record<string, string[]> = {
184
+ [role]: fallbackSelectors,
185
+ };
186
+ const existingFallbackChains = settings.get("retry.fallbackChains");
187
+ for (const existingRole in existingFallbackChains) {
188
+ if (existingRole !== role) {
189
+ fallbackChains[existingRole] = existingFallbackChains[existingRole];
190
+ }
191
+ }
192
+ settings.override("retry.fallbackChains", fallbackChains);
193
+ return role;
194
+ }
195
+
123
196
  function renderIrcPeerRoster(selfId: string): string {
124
197
  const peers = AgentRegistry.global()
125
198
  .list()
@@ -1222,6 +1295,16 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1222
1295
  popLoopPhase();
1223
1296
  }
1224
1297
  }
1298
+ if (event.type === "retry_fallback_applied") {
1299
+ progress.resolvedModel = event.to;
1300
+ scheduleProgress(true);
1301
+ return;
1302
+ }
1303
+ if (event.type === "retry_fallback_succeeded") {
1304
+ progress.resolvedModel = event.model;
1305
+ scheduleProgress(true);
1306
+ return;
1307
+ }
1225
1308
  });
1226
1309
 
1227
1310
  const captureSalvage = (session: AgentSession): void => {
@@ -1817,13 +1900,26 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1817
1900
  resolvedModel: model.id,
1818
1901
  });
1819
1902
  }
1903
+ const retryFallbackRole = installSubagentRetryFallbackChain({
1904
+ settings: subagentSettings,
1905
+ id,
1906
+ candidates: resolveSubagentRetryFallbackCandidates(modelPatterns, modelRegistry, settings),
1907
+ model,
1908
+ authFallbackUsed,
1909
+ });
1910
+ if (retryFallbackRole) {
1911
+ logger.debug("Configured subagent runtime model fallback chain", {
1912
+ role: retryFallbackRole,
1913
+ requested: modelPatterns,
1914
+ });
1915
+ }
1820
1916
  if (model?.contextWindow && model.contextWindow > 0) {
1821
1917
  progress.contextWindow = model.contextWindow;
1822
1918
  }
1823
1919
  if (model) {
1824
1920
  progress.resolvedModel = explicitThinkingLevel
1825
- ? `${model.provider}/${model.id}:${resolvedThinkingLevel}`
1826
- : `${model.provider}/${model.id}`;
1921
+ ? formatModelSelectorValue(formatModelStringWithRouting(model), resolvedThinkingLevel)
1922
+ : formatModelStringWithRouting(model);
1827
1923
  }
1828
1924
  const effectiveThinkingLevel = explicitThinkingLevel
1829
1925
  ? resolvedThinkingLevel
@@ -66,6 +66,7 @@ const EXTENSION_LANG: Record<string, readonly [string, string]> = {
66
66
  cljc: ["clojure", "clojure"],
67
67
  cljs: ["clojure", "clojure"],
68
68
  edn: ["clojure", "clojure"],
69
+ el: ["emacs-lisp", "emacs-lisp"],
69
70
 
70
71
  // .NET
71
72
  cs: ["csharp", "csharp"],
@@ -209,6 +210,7 @@ export function getLanguageFromPath(filePath: string): string | undefined {
209
210
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") {
210
211
  return "dockerfile";
211
212
  }
213
+ if (baseName === ".emacs") return "emacs-lisp";
212
214
  if (baseName === "justfile") return "just";
213
215
  if (baseName === "cmakelists.txt") return "cmake";
214
216
 
@@ -223,6 +225,9 @@ export function detectLanguageId(filePath: string): string {
223
225
  if (baseName === "dockerfile" || baseName.startsWith("dockerfile.") || baseName === "containerfile") {
224
226
  return "dockerfile";
225
227
  }
228
+ if (baseName === ".emacs") {
229
+ return "emacs-lisp";
230
+ }
226
231
  if (baseName === "makefile" || baseName === "gnumakefile") {
227
232
  return "makefile";
228
233
  }
@@ -1,4 +1,4 @@
1
- import { untilAborted } from "@oh-my-pi/pi-utils";
1
+ import { logger, untilAborted } from "@oh-my-pi/pi-utils";
2
2
  import type { Markit, StreamInfo } from "markit-ai";
3
3
  import { ToolAbortError } from "../tools/tool-errors";
4
4
 
@@ -8,6 +8,29 @@ export interface MarkitConversionResult {
8
8
  error?: string;
9
9
  }
10
10
 
11
+ interface MuPdfWasmModuleConfig {
12
+ print?: (...values: unknown[]) => void;
13
+ printErr?: (...values: unknown[]) => void;
14
+ }
15
+
16
+ declare global {
17
+ var $libmupdf_wasm_Module: MuPdfWasmModuleConfig | undefined;
18
+ }
19
+
20
+ function logMuPdfWasmOutput(stream: "stdout" | "stderr", values: unknown[]): void {
21
+ const message = values.length === 1 && typeof values[0] === "string" ? values[0] : values.map(String).join(" ");
22
+ logger.debug("mupdf wasm output", { stream, message });
23
+ }
24
+
25
+ function installMuPdfWasmLogger(): void {
26
+ const moduleConfig = globalThis.$libmupdf_wasm_Module ?? {};
27
+ moduleConfig.print = (...values) => logMuPdfWasmOutput("stdout", values);
28
+ moduleConfig.printErr = (...values) => logMuPdfWasmOutput("stderr", values);
29
+ globalThis.$libmupdf_wasm_Module = moduleConfig;
30
+ }
31
+
32
+ installMuPdfWasmLogger();
33
+
11
34
  let markit: () => Markit | Promise<Markit> = async () => {
12
35
  const promise = import("markit-ai").then(({ Markit }) => {
13
36
  const instance = new Markit();