@oh-my-pi/pi-coding-agent 16.4.5 → 16.4.8

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 (45) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +3271 -3224
  3. package/dist/types/cli/bench-cli.d.ts +1 -7
  4. package/dist/types/cli/usage-cli.d.ts +1 -0
  5. package/dist/types/commands/usage.d.ts +7 -0
  6. package/dist/types/config/settings-schema.d.ts +1 -1
  7. package/dist/types/modes/components/custom-editor.d.ts +3 -8
  8. package/dist/types/modes/components/model-browser.d.ts +14 -1
  9. package/dist/types/modes/components/model-hub.d.ts +4 -3
  10. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  11. package/dist/types/modes/components/welcome.d.ts +4 -0
  12. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  13. package/dist/types/modes/interactive-mode.d.ts +2 -0
  14. package/dist/types/modes/queue-input.d.ts +8 -0
  15. package/dist/types/modes/types.d.ts +2 -0
  16. package/dist/types/session/agent-storage.d.ts +57 -0
  17. package/package.json +12 -12
  18. package/scripts/build-binary.ts +0 -1
  19. package/scripts/compile-binary.ts +4 -3
  20. package/src/cli/bench-cli.ts +7 -26
  21. package/src/cli/usage-cli.ts +11 -0
  22. package/src/commands/usage.ts +13 -2
  23. package/src/config/settings-schema.ts +1 -1
  24. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  25. package/src/modes/components/advisor-config.ts +3 -1
  26. package/src/modes/components/custom-editor.test.ts +58 -1
  27. package/src/modes/components/custom-editor.ts +42 -11
  28. package/src/modes/components/model-browser.ts +154 -60
  29. package/src/modes/components/model-hub.ts +475 -122
  30. package/src/modes/components/plan-review-overlay.ts +7 -0
  31. package/src/modes/components/tips.txt +2 -1
  32. package/src/modes/components/usage-row.ts +5 -6
  33. package/src/modes/components/welcome.ts +13 -14
  34. package/src/modes/controllers/input-controller.ts +140 -6
  35. package/src/modes/controllers/selector-controller.ts +20 -13
  36. package/src/modes/controllers/todo-command-controller.ts +1 -2
  37. package/src/modes/interactive-mode.ts +18 -0
  38. package/src/modes/queue-input.ts +132 -0
  39. package/src/modes/types.ts +2 -0
  40. package/src/modes/utils/ui-helpers.ts +19 -20
  41. package/src/session/agent-session.ts +184 -48
  42. package/src/session/agent-storage.ts +330 -3
  43. package/src/session/history-storage.ts +1 -34
  44. package/src/slash-commands/builtin-registry.ts +9 -0
  45. package/src/web/search/providers/perplexity.ts +18 -2
@@ -1060,6 +1060,7 @@ export interface FreshSessionResult {
1060
1060
 
1061
1061
  /** Standard thinking levels */
1062
1062
 
1063
+ /** `retry.fallbackChains` config: chain key (role name or model selector) → ordered fallback selectors. */
1063
1064
  type RetryFallbackChains = Record<string, string[]>;
1064
1065
 
1065
1066
  type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
@@ -1072,6 +1073,7 @@ interface RetryFallbackSelector {
1072
1073
  }
1073
1074
 
1074
1075
  interface ActiveRetryFallbackState {
1076
+ /** Chain key that produced this fallback: a model-role name or a model-selector key. */
1075
1077
  role: string;
1076
1078
  originalSelector: string;
1077
1079
  originalThinkingLevel: ConfiguredThinkingLevel | undefined;
@@ -1099,6 +1101,24 @@ function parseRetryFallbackSelector(
1099
1101
  };
1100
1102
  }
1101
1103
 
1104
+ /**
1105
+ * `retry.fallbackChains` keys are either model-role names (`smol`, `default`)
1106
+ * or model selectors (`provider/model-id[:thinking]`). Role names never
1107
+ * contain a slash, so its presence marks a model-keyed chain whose primary is
1108
+ * the key itself — the chain follows the model across role reassignments.
1109
+ */
1110
+ function isRetryFallbackModelKey(key: string): boolean {
1111
+ return key.includes("/");
1112
+ }
1113
+
1114
+ /**
1115
+ * A `provider/*` fallback-chain key: matches any active model of that provider,
1116
+ * so one entry covers every current and future model behind the provider.
1117
+ */
1118
+ function isRetryFallbackWildcardKey(key: string): boolean {
1119
+ return key.endsWith("/*");
1120
+ }
1121
+
1102
1122
  function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
1103
1123
  return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
1104
1124
  }
@@ -3820,6 +3840,16 @@ export class AgentSession {
3820
3840
  if (event.message.role === "assistant") {
3821
3841
  this.#lastAssistantMessage = event.message;
3822
3842
  const assistantMsg = event.message as AssistantMessage;
3843
+ // Fold this turn's timing into per-model perf aggregates (drives the
3844
+ // /models TPS/TTFT display). Errored turns measure nothing; aborted
3845
+ // turns with reported usage are still valid throughput samples.
3846
+ if (assistantMsg.stopReason !== "error" && assistantMsg.duration !== undefined) {
3847
+ this.settings.getStorage()?.recordModelPerf(`${assistantMsg.provider}/${assistantMsg.model}`, {
3848
+ outputTokens: assistantMsg.usage.output,
3849
+ durationMs: assistantMsg.duration,
3850
+ ttftMs: assistantMsg.ttft,
3851
+ });
3852
+ }
3823
3853
  if (
3824
3854
  assistantMsg.disabledFeatures?.includes("priority") &&
3825
3855
  this.#serviceTierByFamily.anthropic === "priority"
@@ -8724,12 +8754,7 @@ export class AgentSession {
8724
8754
 
8725
8755
  #syncTodoPhasesFromBranch(): void {
8726
8756
  const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
8727
- // Strip completed/abandoned tasks — they were done in a previous run,
8728
- // so they have no bearing on progress tracking for the new turn.
8729
- for (const phase of phases) {
8730
- phase.tasks = phase.tasks.filter(t => t.status !== "completed" && t.status !== "abandoned");
8731
- }
8732
- this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
8757
+ this.setTodoPhases(phases);
8733
8758
  }
8734
8759
 
8735
8760
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
@@ -13480,36 +13505,68 @@ export class AgentSession {
13480
13505
  const configuredChains = this.settings.get("retry.fallbackChains");
13481
13506
  if (configuredChains === undefined) return;
13482
13507
  if (!configuredChains || typeof configuredChains !== "object" || Array.isArray(configuredChains)) {
13483
- const msg = "retry.fallbackChains must be a mapping of role names to selector arrays.";
13508
+ const msg = "retry.fallbackChains must be a mapping of role names or model selectors to selector arrays.";
13484
13509
  logger.warn(msg);
13485
13510
  this.configWarnings.push(msg);
13486
13511
  return;
13487
13512
  }
13488
13513
 
13489
- for (const [role, chain] of Object.entries(configuredChains)) {
13514
+ for (const key in configuredChains) {
13515
+ const chain = (configuredChains as RetryFallbackChains)[key];
13516
+ const keyKind = isRetryFallbackModelKey(key) ? "model" : "role";
13517
+ if (keyKind === "model") {
13518
+ if (isRetryFallbackWildcardKey(key)) {
13519
+ const provider = key.slice(0, -2);
13520
+ if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
13521
+ const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`;
13522
+ logger.warn(msg);
13523
+ this.configWarnings.push(msg);
13524
+ }
13525
+ } else {
13526
+ const parsedKey = parseRetryFallbackSelector(key, this.#modelRegistry);
13527
+ if (!parsedKey) {
13528
+ const msg = `Invalid model selector key in retry.fallbackChains: ${key}`;
13529
+ logger.warn(msg);
13530
+ this.configWarnings.push(msg);
13531
+ } else if (!this.#modelRegistry.find(parsedKey.provider, parsedKey.id)) {
13532
+ const msg = `retry.fallbackChains key references unknown model: ${key}`;
13533
+ logger.warn(msg);
13534
+ this.configWarnings.push(msg);
13535
+ }
13536
+ }
13537
+ }
13490
13538
  if (!Array.isArray(chain)) {
13491
- const msg = `Fallback chain for role '${role}' must be an array of selector strings.`;
13539
+ const msg = `Fallback chain for ${keyKind} '${key}' must be an array of selector strings.`;
13492
13540
  logger.warn(msg);
13493
13541
  this.configWarnings.push(msg);
13494
13542
  continue;
13495
13543
  }
13496
13544
  for (const selectorStr of chain) {
13497
13545
  if (typeof selectorStr !== "string") {
13498
- const msg = `Fallback chain for role '${role}' contains a non-string selector.`;
13546
+ const msg = `Fallback chain for ${keyKind} '${key}' contains a non-string selector.`;
13499
13547
  logger.warn(msg);
13500
13548
  this.configWarnings.push(msg);
13501
13549
  continue;
13502
13550
  }
13551
+ if (isRetryFallbackWildcardKey(selectorStr)) {
13552
+ const provider = selectorStr.slice(0, -2);
13553
+ if (!this.#modelRegistry.getAll().some(model => model.provider === provider)) {
13554
+ const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`;
13555
+ logger.warn(msg);
13556
+ this.configWarnings.push(msg);
13557
+ }
13558
+ continue;
13559
+ }
13503
13560
  const parsed = parseRetryFallbackSelector(selectorStr, this.#modelRegistry);
13504
13561
  if (!parsed) {
13505
- const msg = `Invalid fallback selector format in role '${role}': ${selectorStr}`;
13562
+ const msg = `Invalid fallback selector format in ${keyKind} '${key}': ${selectorStr}`;
13506
13563
  logger.warn(msg);
13507
13564
  this.configWarnings.push(msg);
13508
13565
  continue;
13509
13566
  }
13510
13567
  const exists = this.#modelRegistry.find(parsed.provider, parsed.id);
13511
13568
  if (!exists) {
13512
- const msg = `Fallback chain for role '${role}' references unknown model: ${selectorStr}`;
13569
+ const msg = `Fallback chain for ${keyKind} '${key}' references unknown model: ${selectorStr}`;
13513
13570
  logger.warn(msg);
13514
13571
  this.configWarnings.push(msg);
13515
13572
  }
@@ -13522,6 +13579,8 @@ export class AgentSession {
13522
13579
  }
13523
13580
 
13524
13581
  #getRetryFallbackPrimarySelector(role: string): RetryFallbackSelector | undefined {
13582
+ if (isRetryFallbackWildcardKey(role)) return undefined;
13583
+ if (isRetryFallbackModelKey(role)) return parseRetryFallbackSelector(role, this.#modelRegistry);
13525
13584
  const configuredSelector = this.settings.getModelRole(role);
13526
13585
  return configuredSelector ? parseRetryFallbackSelector(configuredSelector, this.#modelRegistry) : undefined;
13527
13586
  }
@@ -13543,6 +13602,13 @@ export class AgentSession {
13543
13602
  this.#modelRegistry.suppressSelector(currentSelector, Date.now() + cooldownMs);
13544
13603
  }
13545
13604
 
13605
+ /**
13606
+ * Map the failing model selector to the chain key that owns it, by
13607
+ * specificity: an exact model-selector key, then a `provider/*` wildcard,
13608
+ * then a model role whose current assignment matches, then `default`.
13609
+ * Model-oriented keys win over roles so a chain follows the model across
13610
+ * role reassignments.
13611
+ */
13546
13612
  #resolveRetryFallbackRole(currentSelector: string): string | undefined {
13547
13613
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13548
13614
  if (!parsedCurrent) return undefined;
@@ -13556,18 +13622,33 @@ export class AgentSession {
13556
13622
  ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
13557
13623
  : undefined;
13558
13624
 
13559
- for (const role of Object.keys(chains)) {
13560
- const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13561
- if (primarySelector?.raw === currentSelector) return role;
13625
+ const exactModelKeys: string[] = [];
13626
+ const roleKeys: string[] = [];
13627
+ for (const key in chains) {
13628
+ if (!isRetryFallbackModelKey(key)) roleKeys.push(key);
13629
+ else if (!isRetryFallbackWildcardKey(key)) exactModelKeys.push(key);
13562
13630
  }
13563
- for (const role of Object.keys(chains)) {
13564
- const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13565
- if (!primarySelector) continue;
13566
- if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role;
13567
- const primaryBaseSelector = formatRetryFallbackBaseSelector(primarySelector);
13568
- if (primaryBaseSelector === currentBaseSelector) return role;
13569
- if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role;
13631
+ const matchesCurrent = (primary: RetryFallbackSelector | undefined): boolean => {
13632
+ if (!primary) return false;
13633
+ if (primary.raw === currentSelector || (currentPlainSelector && primary.raw === currentPlainSelector)) {
13634
+ return true;
13635
+ }
13636
+ const base = formatRetryFallbackBaseSelector(primary);
13637
+ return base === currentBaseSelector || (!!currentPlainBaseSelector && base === currentPlainBaseSelector);
13638
+ };
13639
+
13640
+ // 1. Exact model-selector keys — most specific.
13641
+ for (const key of exactModelKeys) {
13642
+ if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
13643
+ }
13644
+ // 2. Provider wildcard (`provider/*`) — any active model of this provider.
13645
+ const wildcardKey = `${parsedCurrent.provider}/*`;
13646
+ if (Array.isArray(chains[wildcardKey])) return wildcardKey;
13647
+ // 3. Role keys — matched by the role's currently-assigned model.
13648
+ for (const key of roleKeys) {
13649
+ if (matchesCurrent(this.#getRetryFallbackPrimarySelector(key))) return key;
13570
13650
  }
13651
+ // 4. The default chain, when default has no explicit role primary.
13571
13652
  const defaultChain = chains.default;
13572
13653
  if (
13573
13654
  Array.isArray(defaultChain) &&
@@ -13579,13 +13660,45 @@ export class AgentSession {
13579
13660
  return undefined;
13580
13661
  }
13581
13662
 
13582
- #getRetryFallbackEffectiveChain(role: string): RetryFallbackSelector[] {
13583
- const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13584
- if (!primarySelector) return [];
13585
- const chain = [primarySelector];
13586
- const seen = new Set<string>([primarySelector.raw]);
13663
+ /**
13664
+ * Parse one configured chain entry. A `provider/*` entry keeps the failing
13665
+ * model's id and swaps the provider (google-antigravity/x google/x);
13666
+ * ids the target provider lacks are skipped by the candidate loop's
13667
+ * registry lookup.
13668
+ */
13669
+ #parseRetryFallbackChainEntry(
13670
+ entry: string,
13671
+ current: RetryFallbackSelector | undefined,
13672
+ ): RetryFallbackSelector | undefined {
13673
+ if (isRetryFallbackWildcardKey(entry)) {
13674
+ if (!current) return undefined;
13675
+ const provider = entry.slice(0, -2);
13676
+ return { raw: `${provider}/${current.id}`, provider, id: current.id, thinkingLevel: undefined };
13677
+ }
13678
+ return parseRetryFallbackSelector(entry, this.#modelRegistry);
13679
+ }
13680
+
13681
+ #getRetryFallbackEffectiveChain(role: string, currentSelector?: string): RetryFallbackSelector[] {
13682
+ const parsedCurrent = currentSelector
13683
+ ? parseRetryFallbackSelector(currentSelector, this.#modelRegistry)
13684
+ : undefined;
13685
+ const seen = new Set<string>();
13686
+ const chain: RetryFallbackSelector[] = [];
13687
+ if (isRetryFallbackWildcardKey(role)) {
13688
+ // A wildcard key has no fixed primary: the active model is the
13689
+ // primary, followed by the configured provider-level fallbacks.
13690
+ if (parsedCurrent) {
13691
+ chain.push(parsedCurrent);
13692
+ seen.add(parsedCurrent.raw);
13693
+ }
13694
+ } else {
13695
+ const primarySelector = this.#getRetryFallbackPrimarySelector(role);
13696
+ if (!primarySelector) return [];
13697
+ chain.push(primarySelector);
13698
+ seen.add(primarySelector.raw);
13699
+ }
13587
13700
  for (const selector of this.#getRetryFallbackChains()[role] ?? []) {
13588
- const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
13701
+ const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent);
13589
13702
  if (!parsed || seen.has(parsed.raw)) continue;
13590
13703
  seen.add(parsed.raw);
13591
13704
  chain.push(parsed);
@@ -13594,7 +13707,7 @@ export class AgentSession {
13594
13707
  }
13595
13708
 
13596
13709
  #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
13597
- let chain = this.#getRetryFallbackEffectiveChain(role);
13710
+ let chain = this.#getRetryFallbackEffectiveChain(role, currentSelector);
13598
13711
  const parsedCurrent = parseRetryFallbackSelector(currentSelector, this.#modelRegistry);
13599
13712
  if (chain.length === 0 && role === "default" && parsedCurrent) {
13600
13713
  const chains = this.#getRetryFallbackChains();
@@ -13607,7 +13720,7 @@ export class AgentSession {
13607
13720
  const seen = new Set<string>([parsedCurrent.raw]);
13608
13721
  chain = [parsedCurrent];
13609
13722
  for (const selector of defaultChain) {
13610
- const parsed = parseRetryFallbackSelector(selector, this.#modelRegistry);
13723
+ const parsed = this.#parseRetryFallbackChainEntry(selector, parsedCurrent);
13611
13724
  if (!parsed || seen.has(parsed.raw)) continue;
13612
13725
  seen.add(parsed.raw);
13613
13726
  chain.push(parsed);
@@ -13880,20 +13993,13 @@ export class AgentSession {
13880
13993
  this.#retryResolve = resolve;
13881
13994
  }
13882
13995
 
13883
- if (this.#retryAttempt > retrySettings.maxRetries) {
13884
- await this.#persistRetryLifecycleErrorMessage(message);
13885
- // Max retries exceeded, emit final failure and reset
13886
- await this.#emitSessionEvent({
13887
- type: "auto_retry_end",
13888
- success: false,
13889
- attempt: this.#retryAttempt - 1,
13890
- finalError: message.errorMessage,
13891
- });
13892
- this.#clearPendingRecoveredRetryErrors();
13893
- this.#retryAttempt = 0;
13894
- this.#resolveRetry(); // Resolve so waitForRetry() completes
13895
- return false;
13896
- }
13996
+ // All attempts on the current model are spent. Don't fail yet: the
13997
+ // fallback chain below gets one last consult. Credential rotation can
13998
+ // consume the entire budget without the fallback branch ever running
13999
+ // (every rotation sets switchedCredential and skips it), so without
14000
+ // this last resort a provider-wide usage cap never fails over to the
14001
+ // configured chain.
14002
+ const retryBudgetExhausted = this.#retryAttempt > retrySettings.maxRetries;
13897
14003
 
13898
14004
  const errorMessage = message.errorMessage || "Unknown error";
13899
14005
  const id = this.#classifyRetryMessage(message);
@@ -13912,7 +14018,12 @@ export class AgentSession {
13912
14018
  this.#resetCurrentResponsesProviderSession("stale replay error");
13913
14019
  }
13914
14020
 
13915
- if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) {
14021
+ if (
14022
+ !retryBudgetExhausted &&
14023
+ this.model &&
14024
+ !staleOpenAIResponsesReplayError &&
14025
+ AIError.is(id, AIError.Flag.UsageLimit)
14026
+ ) {
13916
14027
  const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
13917
14028
  const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
13918
14029
  this.model.provider,
@@ -13958,7 +14069,9 @@ export class AgentSession {
13958
14069
  const allowModelFallback = options?.allowModelFallback !== false;
13959
14070
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
13960
14071
  if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
13961
- if (allowModelFallback && retrySettings.modelFallback) {
14072
+ // A refusal chain stops at the retry budget: the exhausted-attempt
14073
+ // last resort is for provider failures, not classifier decisions.
14074
+ if (allowModelFallback && retrySettings.modelFallback && !(retryBudgetExhausted && classifierRefusal)) {
13962
14075
  if (!classifierRefusal) {
13963
14076
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
13964
14077
  }
@@ -13977,6 +14090,26 @@ export class AgentSession {
13977
14090
  delayMs = parsedRetryAfterMs;
13978
14091
  }
13979
14092
  }
14093
+ if (retryBudgetExhausted) {
14094
+ if (!switchedModel) {
14095
+ await this.#persistRetryLifecycleErrorMessage(message);
14096
+ // Max retries exceeded and no fallback model to switch to: emit
14097
+ // final failure and reset.
14098
+ await this.#emitSessionEvent({
14099
+ type: "auto_retry_end",
14100
+ success: false,
14101
+ attempt: this.#retryAttempt - 1,
14102
+ finalError: message.errorMessage,
14103
+ });
14104
+ this.#clearPendingRecoveredRetryErrors();
14105
+ this.#retryAttempt = 0;
14106
+ this.#resolveRetry(); // Resolve so waitForRetry() completes
14107
+ return false;
14108
+ }
14109
+ // The fallback model gets a fresh retry budget — leaving the spent
14110
+ // counter in place would exhaust it again on its first error.
14111
+ this.#retryAttempt = 1;
14112
+ }
13980
14113
  if (classifierRefusal && !switchedModel) {
13981
14114
  this.#retryAttempt = 0;
13982
14115
  this.#resolveRetry();
@@ -14704,10 +14837,13 @@ export class AgentSession {
14704
14837
  // Side-channel turns must not share OpenAI/Codex append-only
14705
14838
  // conversation state with the main agent turn: IRC and /btw can run
14706
14839
  // while the main turn is mid-tool-call. Keep the prompt-cache key
14707
- // stable, but give provider routing a unique request lineage.
14840
+ // stable, but give provider routing a unique request lineage. The
14841
+ // shared provider state map is still required so Codex can allocate
14842
+ // websocket state under that side-channel session id.
14708
14843
  sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
14709
14844
  promptCacheKey: cacheSessionId,
14710
- preferWebsockets: false,
14845
+ preferWebsockets: this.#preferWebsockets,
14846
+ providerSessionState: this.#providerSessionState,
14711
14847
  reasoning: toReasoningEffort(this.thinkingLevel),
14712
14848
  disableReasoning: shouldDisableReasoning(this.thinkingLevel),
14713
14849
  hideThinkingSummary: this.agent.hideThinkingSummary,