@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.6

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 (135) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/cli.js +692 -607
  3. package/dist/types/cli/usage-cli.d.ts +10 -1
  4. package/dist/types/commands/usage.d.ts +9 -0
  5. package/dist/types/config/api-key-resolver.d.ts +9 -3
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/model-discovery.d.ts +6 -4
  8. package/dist/types/config/model-registry.d.ts +7 -4
  9. package/dist/types/config/settings-schema.d.ts +508 -155
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/mnemopi/config.d.ts +3 -1
  12. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  15. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  16. package/dist/types/modes/components/tool-execution.d.ts +26 -1
  17. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +10 -0
  21. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  22. package/dist/types/modes/theme/theme.d.ts +23 -3
  23. package/dist/types/modes/types.d.ts +2 -0
  24. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  25. package/dist/types/session/agent-session.d.ts +28 -8
  26. package/dist/types/session/auth-storage.d.ts +1 -1
  27. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +129 -0
  29. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  30. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  31. package/dist/types/system-prompt.d.ts +3 -1
  32. package/dist/types/task/render.d.ts +17 -6
  33. package/dist/types/tools/gh.d.ts +3 -0
  34. package/dist/types/tools/render-utils.d.ts +8 -16
  35. package/dist/types/tools/todo.d.ts +0 -11
  36. package/dist/types/utils/session-color.d.ts +15 -3
  37. package/dist/types/web/kagi.d.ts +1 -2
  38. package/dist/types/web/search/providers/codex.d.ts +1 -1
  39. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  40. package/package.json +11 -11
  41. package/src/auto-thinking/classifier.ts +1 -5
  42. package/src/cli/usage-cli.ts +187 -16
  43. package/src/commands/usage.ts +8 -0
  44. package/src/commit/model-selection.ts +3 -6
  45. package/src/config/api-key-resolver.ts +10 -3
  46. package/src/config/keybindings.ts +1 -1
  47. package/src/config/model-discovery.ts +60 -46
  48. package/src/config/model-registry.ts +21 -8
  49. package/src/config/model-resolver.ts +57 -3
  50. package/src/config/settings-schema.ts +654 -153
  51. package/src/config/settings.ts +9 -0
  52. package/src/eval/completion-bridge.ts +1 -5
  53. package/src/export/html/template.generated.ts +1 -1
  54. package/src/export/html/template.js +13 -6
  55. package/src/internal-urls/docs-index.generated.ts +6 -6
  56. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  57. package/src/memories/index.ts +2 -10
  58. package/src/mnemopi/backend.ts +30 -8
  59. package/src/mnemopi/config.ts +6 -1
  60. package/src/mnemopi/state.ts +6 -0
  61. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  62. package/src/modes/components/plan-review-overlay.ts +15 -17
  63. package/src/modes/components/plugin-settings.ts +22 -5
  64. package/src/modes/components/reset-usage-selector.ts +161 -0
  65. package/src/modes/components/session-selector.ts +8 -2
  66. package/src/modes/components/settings-defs.ts +19 -4
  67. package/src/modes/components/settings-selector.ts +510 -95
  68. package/src/modes/components/status-line/component.ts +3 -1
  69. package/src/modes/components/status-line/segments.ts +3 -1
  70. package/src/modes/components/tool-execution.ts +87 -12
  71. package/src/modes/components/transcript-container.ts +49 -1
  72. package/src/modes/components/tree-selector.ts +16 -6
  73. package/src/modes/controllers/command-controller.ts +61 -8
  74. package/src/modes/controllers/event-controller.ts +1 -0
  75. package/src/modes/controllers/input-controller.ts +68 -6
  76. package/src/modes/controllers/selector-controller.ts +149 -61
  77. package/src/modes/interactive-mode.ts +63 -2
  78. package/src/modes/rpc/rpc-mode.ts +2 -1
  79. package/src/modes/session-observer-registry.ts +61 -3
  80. package/src/modes/shared.ts +2 -0
  81. package/src/modes/theme/theme.ts +102 -9
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/context-usage.ts +78 -2
  84. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  85. package/src/modes/utils/ui-helpers.ts +9 -5
  86. package/src/prompts/system/personalities/default.md +26 -0
  87. package/src/prompts/system/personalities/friendly.md +17 -0
  88. package/src/prompts/system/personalities/pragmatic.md +15 -0
  89. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  90. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  91. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  92. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  93. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  94. package/src/prompts/system/system-prompt.md +5 -22
  95. package/src/prompts/tools/browser.md +33 -43
  96. package/src/prompts/tools/eval.md +27 -50
  97. package/src/prompts/tools/irc.md +29 -31
  98. package/src/prompts/tools/read.md +31 -37
  99. package/src/prompts/tools/task.md +3 -3
  100. package/src/prompts/tools/todo.md +1 -2
  101. package/src/sdk.ts +23 -1
  102. package/src/session/agent-session.ts +221 -29
  103. package/src/session/auth-storage.ts +4 -0
  104. package/src/session/codex-auto-reset.ts +190 -0
  105. package/src/session/session-dump-format.ts +8 -1
  106. package/src/session/session-manager.ts +5 -5
  107. package/src/session/snapcompact-inline.ts +524 -0
  108. package/src/slash-commands/builtin-registry.ts +145 -8
  109. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  110. package/src/slash-commands/helpers/context-report.ts +28 -1
  111. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  112. package/src/slash-commands/helpers/usage-report.ts +36 -3
  113. package/src/system-prompt.ts +15 -1
  114. package/src/task/index.ts +30 -7
  115. package/src/task/render.ts +57 -32
  116. package/src/tool-discovery/tool-index.ts +2 -0
  117. package/src/tools/bash.ts +10 -3
  118. package/src/tools/eval-render.ts +13 -8
  119. package/src/tools/gh.ts +39 -1
  120. package/src/tools/image-gen.ts +114 -78
  121. package/src/tools/inspect-image.ts +1 -5
  122. package/src/tools/job.ts +25 -5
  123. package/src/tools/read.ts +1 -57
  124. package/src/tools/render-utils.ts +29 -31
  125. package/src/tools/ssh.ts +3 -3
  126. package/src/tools/todo.ts +8 -128
  127. package/src/tools/tts.ts +40 -20
  128. package/src/utils/clipboard.ts +56 -4
  129. package/src/utils/commit-message-generator.ts +1 -5
  130. package/src/utils/session-color.ts +83 -9
  131. package/src/utils/title-generator.ts +1 -1
  132. package/src/web/kagi.ts +26 -27
  133. package/src/web/search/providers/codex.ts +42 -40
  134. package/src/web/search/providers/gemini.ts +42 -22
  135. package/src/web/search/providers/perplexity.ts +22 -10
@@ -73,6 +73,9 @@ import type {
73
73
  Model,
74
74
  ProviderResponseMetadata,
75
75
  ProviderSessionState,
76
+ ResetCreditAccountStatus,
77
+ ResetCreditRedeemOutcome,
78
+ ResetCreditTarget,
76
79
  ServiceTier,
77
80
  SimpleStreamOptions,
78
81
  TextContent,
@@ -107,7 +110,7 @@ import {
107
110
  relativePathWithinRoot,
108
111
  Snowflake,
109
112
  } from "@oh-my-pi/pi-utils";
110
- import { snapcompactCompact } from "@oh-my-pi/snapcompact";
113
+ import * as snapcompact from "@oh-my-pi/snapcompact";
111
114
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
112
115
  import { classifyDifficulty } from "../auto-thinking/classifier";
113
116
  import { reset as resetCapabilities } from "../capability";
@@ -237,6 +240,7 @@ import { normalizeModelContextImages } from "../utils/image-loading";
237
240
  import { buildNamedToolChoice } from "../utils/tool-choice";
238
241
  import type { AuthStorage } from "./auth-storage";
239
242
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
243
+ import { defaultCodexAutoRedeemCoordinator, evaluateCodexAutoRedeem } from "./codex-auto-reset";
240
244
  import {
241
245
  type BashExecutionMessage,
242
246
  type CustomMessage,
@@ -855,8 +859,13 @@ function extractPermissionLocations(
855
859
  * `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt
856
860
  * custom messages queued during streaming) and is matched by the custom-role
857
861
  * `message_start` dequeue branch; user-message pushes leave it undefined and
858
- * rely on the existing text-equality match. */
859
- type QueuedDisplayEntry = { text: string; tag?: string };
862
+ * rely on the existing text-equality match. `images` carries the original
863
+ * (pre-normalization) image blocks so queue restoration (Esc / Alt+Up) can
864
+ * hand them back to the editor instead of dropping them. */
865
+ type QueuedDisplayEntry = { text: string; tag?: string; images?: ImageContent[] };
866
+
867
+ /** Entry returned by {@link AgentSession.clearQueue} / {@link AgentSession.popLastQueuedMessage}. */
868
+ export type RestoredQueuedMessage = { text: string; images?: ImageContent[] };
860
869
 
861
870
  export class AgentSession {
862
871
  readonly agent: Agent;
@@ -5028,7 +5037,7 @@ export class AgentSession {
5028
5037
  async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
5029
5038
  const normalizedImages = await normalizeModelContextImages(images);
5030
5039
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
5031
- this.#steeringMessages.push({ text: displayText });
5040
+ this.#steeringMessages.push({ text: displayText, images });
5032
5041
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5033
5042
  if (normalizedImages && normalizedImages.length > 0) {
5034
5043
  content.push(...normalizedImages);
@@ -5040,6 +5049,16 @@ export class AgentSession {
5040
5049
  attribution: "user",
5041
5050
  timestamp: Date.now(),
5042
5051
  });
5052
+ // A steer can land on an idle session: the caller checked isStreaming
5053
+ // before the (potentially slow) image normalization above, so the turn
5054
+ // may have ended in between. Without a drain the message would strand in
5055
+ // the queue until the next manual prompt — schedule an immediate continue,
5056
+ // mirroring #queueFollowUp's idle-path delivery.
5057
+ if (this.#canAutoContinueForFollowUp()) {
5058
+ this.#scheduleAgentContinue({
5059
+ shouldContinue: () => this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages(),
5060
+ });
5061
+ }
5043
5062
  }
5044
5063
 
5045
5064
  /**
@@ -5048,7 +5067,7 @@ export class AgentSession {
5048
5067
  async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
5049
5068
  const normalizedImages = await normalizeModelContextImages(images);
5050
5069
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
5051
- this.#followUpMessages.push({ text: displayText });
5070
+ this.#followUpMessages.push({ text: displayText, images });
5052
5071
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
5053
5072
  if (normalizedImages && normalizedImages.length > 0) {
5054
5073
  content.push(...normalizedImages);
@@ -5297,12 +5316,14 @@ export class AgentSession {
5297
5316
  }
5298
5317
 
5299
5318
  /**
5300
- * Clear queued messages and return them.
5301
- * Useful for restoring to editor when user aborts.
5319
+ * Clear queued messages and return them (text plus any attached images).
5320
+ * Useful for restoring to editor when user aborts. The internal entry
5321
+ * arrays are handed out as-is — a `tag` (if any) is inert once the record
5322
+ * leaves the queue.
5302
5323
  */
5303
- clearQueue(): { steering: string[]; followUp: string[] } {
5304
- const steering = this.#steeringMessages.map(e => e.text);
5305
- const followUp = this.#followUpMessages.map(e => e.text);
5324
+ clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } {
5325
+ const steering = this.#steeringMessages;
5326
+ const followUp = this.#followUpMessages;
5306
5327
  this.#steeringMessages = [];
5307
5328
  this.#followUpMessages = [];
5308
5329
  this.agent.clearAllQueues();
@@ -5328,21 +5349,21 @@ export class AgentSession {
5328
5349
  /**
5329
5350
  * Pop the last queued message (steering first, then follow-up).
5330
5351
  * Used by dequeue keybinding to restore messages to editor one at a time.
5331
- * Returns the popped entry's `.text`; the tag (if any) dies with the
5332
- * record — no orphan state can outlive the queue entry.
5352
+ * Returns the popped entry's text and images; the tag (if any) dies with
5353
+ * the record — no orphan state can outlive the queue entry.
5333
5354
  */
5334
- popLastQueuedMessage(): string | undefined {
5355
+ popLastQueuedMessage(): RestoredQueuedMessage | undefined {
5335
5356
  // Pop from steering first (LIFO)
5336
5357
  if (this.#steeringMessages.length > 0) {
5337
5358
  const entry = this.#steeringMessages.pop();
5338
5359
  this.agent.popLastSteer();
5339
- return entry?.text;
5360
+ return entry;
5340
5361
  }
5341
5362
  // Then from follow-up
5342
5363
  if (this.#followUpMessages.length > 0) {
5343
5364
  const entry = this.#followUpMessages.pop();
5344
5365
  this.agent.popLastFollowUp();
5345
- return entry?.text;
5366
+ return entry;
5346
5367
  }
5347
5368
  return undefined;
5348
5369
  }
@@ -5382,11 +5403,7 @@ export class AgentSession {
5382
5403
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
5383
5404
  return phases.map(phase => ({
5384
5405
  name: phase.name,
5385
- tasks: phase.tasks.map(task => {
5386
- const out: TodoItem = { content: task.content, status: task.status };
5387
- if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
5388
- return out;
5389
- }),
5406
+ tasks: phase.tasks.map(task => ({ content: task.content, status: task.status })),
5390
5407
  }));
5391
5408
  }
5392
5409
 
@@ -6368,7 +6385,10 @@ export class AgentSession {
6368
6385
  details = compactionPrep.details;
6369
6386
  preserveData = compactionPrep.preserveData;
6370
6387
  } else if (snapcompactReady) {
6371
- const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
6388
+ const snapcompactResult = await snapcompact.compact(preparation, {
6389
+ convertToLlm,
6390
+ model: this.model,
6391
+ });
6372
6392
  summary = snapcompactResult.summary;
6373
6393
  shortSummary = snapcompactResult.shortSummary;
6374
6394
  firstKeptEntryId = snapcompactResult.firstKeptEntryId;
@@ -6582,7 +6602,7 @@ export class AgentSession {
6582
6602
  const rawHandoffText = await generateHandoff(
6583
6603
  this.agent.state.messages,
6584
6604
  model,
6585
- apiKey,
6605
+ this.#modelRegistry.resolver(model, this.sessionId),
6586
6606
  {
6587
6607
  systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
6588
6608
  tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools),
@@ -7252,6 +7272,22 @@ export class AgentSession {
7252
7272
  this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
7253
7273
  }
7254
7274
 
7275
+ #resetCurrentResponsesProviderSession(reason: string): void {
7276
+ const currentModel = this.model;
7277
+ if (currentModel?.api !== "openai-responses" && currentModel?.api !== "openai-codex-responses") {
7278
+ return;
7279
+ }
7280
+
7281
+ this.#closeProviderSessionsForModelSwitch(currentModel, currentModel);
7282
+ this.agent.appendOnlyContext?.invalidateForModelChange();
7283
+ logger.debug("Reset Responses provider session after stale replay error", {
7284
+ provider: currentModel.provider,
7285
+ model: currentModel.id,
7286
+ api: currentModel.api,
7287
+ reason,
7288
+ });
7289
+ }
7290
+
7255
7291
  /**
7256
7292
  * Re-evaluate append-only context mode, creating or destroying the
7257
7293
  * manager as needed. Called on model switch AND setting change.
@@ -7577,7 +7613,7 @@ export class AgentSession {
7577
7613
  return await compact(
7578
7614
  this.#obfuscatePreparationForProvider(preparation),
7579
7615
  candidate,
7580
- apiKey,
7616
+ this.#modelRegistry.resolver(candidate, this.sessionId),
7581
7617
  this.#obfuscateTextForProvider(customInstructions),
7582
7618
  signal,
7583
7619
  {
@@ -7882,7 +7918,10 @@ export class AgentSession {
7882
7918
  } else if (action === "snapcompact") {
7883
7919
  // Local, deterministic: render discarded history onto PNG frames.
7884
7920
  // No model candidates, no API key, no retry loop.
7885
- const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
7921
+ const snapcompactResult = await snapcompact.compact(preparation, {
7922
+ convertToLlm,
7923
+ model: this.model,
7924
+ });
7886
7925
  summary = snapcompactResult.summary;
7887
7926
  shortSummary = snapcompactResult.shortSummary;
7888
7927
  firstKeptEntryId = snapcompactResult.firstKeptEntryId;
@@ -7906,7 +7945,7 @@ export class AgentSession {
7906
7945
  compactResult = await compact(
7907
7946
  this.#obfuscatePreparationForProvider(preparation),
7908
7947
  candidate,
7909
- apiKey,
7948
+ this.#modelRegistry.resolver(candidate, this.sessionId),
7910
7949
  undefined,
7911
7950
  autoCompactionSignal,
7912
7951
  {
@@ -8293,11 +8332,33 @@ export class AgentSession {
8293
8332
  if (isContextOverflow(message, contextWindow)) return false;
8294
8333
 
8295
8334
  if (this.#isClassifierRefusal(message)) return true;
8335
+ if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
8296
8336
 
8297
8337
  const err = message.errorMessage;
8298
8338
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
8299
8339
  }
8300
8340
 
8341
+ #isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
8342
+ const currentApi = this.model?.api;
8343
+ if (
8344
+ message.api !== "openai-responses" &&
8345
+ message.api !== "openai-codex-responses" &&
8346
+ currentApi !== "openai-responses" &&
8347
+ currentApi !== "openai-codex-responses"
8348
+ ) {
8349
+ return false;
8350
+ }
8351
+
8352
+ const errorMessage = message.errorMessage;
8353
+ if (!errorMessage) return false;
8354
+
8355
+ return (
8356
+ /\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
8357
+ (/previous[ _]?response/i.test(errorMessage) &&
8358
+ /not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage))
8359
+ );
8360
+ }
8361
+
8301
8362
  #isClassifierRefusal(message: AssistantMessage): boolean {
8302
8363
  if (message.stopReason !== "error") return false;
8303
8364
  const stopType = message.stopDetails?.type;
@@ -8631,15 +8692,22 @@ export class AgentSession {
8631
8692
  }
8632
8693
 
8633
8694
  const errorMessage = message.errorMessage || "Unknown error";
8695
+ const staleOpenAIResponsesReplayError = this.#isStaleOpenAIResponsesReplayError(message);
8634
8696
  const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
8635
- let delayMs = calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt);
8697
+ let delayMs = staleOpenAIResponsesReplayError
8698
+ ? 0
8699
+ : calculateRetryBackoffDelayMs(retrySettings.baseDelayMs, this.#retryAttempt);
8636
8700
  let switchedCredential = false;
8637
8701
  let switchedModel = false;
8638
8702
  // Set when a usage-limit error pinned the wait to credential
8639
8703
  // availability — suppresses the generic retry-after bump below.
8640
8704
  let usageLimitWaitMs: number | undefined;
8641
8705
 
8642
- if (this.model && isUsageLimitError(errorMessage)) {
8706
+ if (staleOpenAIResponsesReplayError) {
8707
+ this.#resetCurrentResponsesProviderSession("stale replay error");
8708
+ }
8709
+
8710
+ if (this.model && !staleOpenAIResponsesReplayError && isUsageLimitError(errorMessage)) {
8643
8711
  const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
8644
8712
  const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
8645
8713
  this.model.provider,
@@ -8653,6 +8721,13 @@ export class AgentSession {
8653
8721
  if (outcome.switched) {
8654
8722
  switchedCredential = true;
8655
8723
  delayMs = 0;
8724
+ } else if (await this.#maybeAutoRedeemCodexReset()) {
8725
+ // A live usage-limit 429 on the active Codex account, with a banked
8726
+ // reset and the opt-in setting on: spend the reset and retry
8727
+ // immediately instead of waiting out the window. Runs after the
8728
+ // free sibling-switch above and before model fallback below.
8729
+ switchedCredential = true;
8730
+ delayMs = 0;
8656
8731
  } else {
8657
8732
  // No sibling credential is usable right now. Wait for whichever
8658
8733
  // comes first: the provider's retry-after window for the current
@@ -8676,7 +8751,7 @@ export class AgentSession {
8676
8751
  }
8677
8752
 
8678
8753
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
8679
- if (!switchedCredential && currentSelector) {
8754
+ if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
8680
8755
  if (retrySettings.modelFallback) {
8681
8756
  if (!classifierRefusal) {
8682
8757
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
@@ -9813,7 +9888,7 @@ export class AgentSession {
9813
9888
  const branchSummarySettings = this.settings.getGroup("branchSummary");
9814
9889
  const result = await generateBranchSummary(entriesToSummarize, {
9815
9890
  model,
9816
- apiKey,
9891
+ apiKey: this.#modelRegistry.resolver(model, this.sessionId),
9817
9892
  signal: this.#branchSummaryAbortController.signal,
9818
9893
  customInstructions: this.#obfuscateTextForProvider(options.customInstructions),
9819
9894
  reserveTokens: branchSummarySettings.reserveTokens,
@@ -10070,6 +10145,123 @@ export class AgentSession {
10070
10145
  });
10071
10146
  }
10072
10147
 
10148
+ /**
10149
+ * Redeem one saved Codex rate-limit reset for a specific account, injecting
10150
+ * the provider base URL like {@link AgentSession.fetchUsageReports}. Powers
10151
+ * the `/usage reset` command and auto-redeem. Never throws for business
10152
+ * outcomes — inspect the returned `code`.
10153
+ */
10154
+ async redeemResetCredit(target: ResetCreditTarget, signal?: AbortSignal): Promise<ResetCreditRedeemOutcome> {
10155
+ return this.#modelRegistry.authStorage.redeemResetCredit({
10156
+ target,
10157
+ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
10158
+ signal,
10159
+ });
10160
+ }
10161
+
10162
+ /**
10163
+ * List saved Codex rate-limit resets per stored account, fetched live from
10164
+ * the dedicated credits endpoint (bypasses the usage cache). Powers the
10165
+ * `/usage reset` account selector.
10166
+ */
10167
+ async listResetCredits(signal?: AbortSignal): Promise<ResetCreditAccountStatus[]> {
10168
+ return this.#modelRegistry.authStorage.listResetCredits({
10169
+ sessionId: this.sessionId,
10170
+ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
10171
+ signal,
10172
+ });
10173
+ }
10174
+
10175
+ /**
10176
+ * Auto-redeem hook for {@link AgentSession.#handleRetryableError}'s
10177
+ * usage-limit branch. Returns `true` only when a saved Codex reset was
10178
+ * actually spent (so the caller retries immediately). Opt-in, reactive, and
10179
+ * heavily gated — see `./codex-auto-reset` and the design in
10180
+ * `local://autoreset-spec.md`. Per-account in-flight dedup lets concurrent
10181
+ * sessions adopt one redeem instead of double-spending.
10182
+ */
10183
+ async #maybeAutoRedeemCodexReset(coordinator = defaultCodexAutoRedeemCoordinator): Promise<boolean> {
10184
+ const cfg = this.settings.getGroup("codexResets");
10185
+ const model = this.model;
10186
+ // Cheap exits before any IO.
10187
+ if (!cfg.autoRedeem || !model || model.provider !== "openai-codex") return false;
10188
+ const authStorage = this.#modelRegistry.authStorage;
10189
+ // Capture identity BEFORE awaits: markUsageLimitReached leaves the
10190
+ // usage-limit session credential sticky, so this names the blocked account.
10191
+ const identity = authStorage.getOAuthAccountIdentity("openai-codex", this.sessionId);
10192
+ const accountKey = (identity?.accountId ?? identity?.email)?.trim().toLowerCase();
10193
+ if (!accountKey) return false;
10194
+ const existing = coordinator.inFlightByAccount.get(accountKey);
10195
+ if (existing) return existing;
10196
+
10197
+ const run = (async (): Promise<boolean> => {
10198
+ const reports = await this.fetchUsageReports();
10199
+ const decision = evaluateCodexAutoRedeem({
10200
+ nowMs: Date.now(),
10201
+ provider: model.provider,
10202
+ modelId: model.id,
10203
+ settings: {
10204
+ autoRedeem: cfg.autoRedeem,
10205
+ minBlockedMinutes: Math.max(0, cfg.minBlockedMinutes),
10206
+ keepCredits: Math.max(0, Math.trunc(cfg.keepCredits)),
10207
+ },
10208
+ identity,
10209
+ reports,
10210
+ attemptedBlockKeys: coordinator.attemptedBlockKeys,
10211
+ lastAttemptAtByAccount: coordinator.lastAttemptAtByAccount,
10212
+ });
10213
+ if (!decision.redeem) {
10214
+ logger.debug("codex-auto-reset: skipped", { reason: decision.reason });
10215
+ return false;
10216
+ }
10217
+ // Commit the attempt BEFORE acting so this block can never re-enter.
10218
+ coordinator.attemptedBlockKeys.add(decision.blockKey);
10219
+ coordinator.lastAttemptAtByAccount.set(decision.accountKey, Date.now());
10220
+ const who = decision.target.email ?? decision.target.accountId ?? "the active account";
10221
+ const outcome = await authStorage.redeemResetCredit({
10222
+ target: decision.target,
10223
+ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
10224
+ // Not tied to the retry abort controller: aborting a consume
10225
+ // mid-flight leaves credit state unknown.
10226
+ signal: AbortSignal.timeout(15_000),
10227
+ });
10228
+ switch (outcome.code) {
10229
+ case "reset": {
10230
+ const left = Math.max(0, decision.availableCount - 1);
10231
+ this.emitNotice(
10232
+ "info",
10233
+ `Auto-redeemed a saved Codex rate-limit reset for ${who} (${left} left); retrying now.`,
10234
+ "codex-auto-reset",
10235
+ );
10236
+ void this.fetchUsageReports();
10237
+ return true;
10238
+ }
10239
+ case "already_redeemed":
10240
+ this.emitNotice(
10241
+ "warning",
10242
+ "A saved Codex reset was already redeemed elsewhere; waiting for the window.",
10243
+ "codex-auto-reset",
10244
+ );
10245
+ return false;
10246
+ case "no_credit":
10247
+ logger.debug("codex-auto-reset: no_credit (snapshot/live mismatch)", { account: accountKey });
10248
+ return false;
10249
+ case "nothing_to_reset":
10250
+ this.emitNotice(
10251
+ "warning",
10252
+ "Codex reset reported nothing to reset; auto-redeem suppressed for this window.",
10253
+ "codex-auto-reset",
10254
+ );
10255
+ return false;
10256
+ default:
10257
+ this.emitNotice("warning", `Codex auto-redeem failed (${outcome.code}).`, "codex-auto-reset");
10258
+ return false;
10259
+ }
10260
+ })().finally(() => coordinator.inFlightByAccount.delete(accountKey));
10261
+ coordinator.inFlightByAccount.set(accountKey, run);
10262
+ return run;
10263
+ }
10264
+
10073
10265
  /**
10074
10266
  * Estimate context tokens from messages, using the last assistant usage when available.
10075
10267
  */
@@ -12,7 +12,11 @@ export type {
12
12
  AuthStorageOptions,
13
13
  CredentialOrigin,
14
14
  CredentialOriginKind,
15
+ OAuthAccountIdentity,
15
16
  OAuthCredential,
17
+ ResetCreditAccountStatus,
18
+ ResetCreditRedeemOutcome,
19
+ ResetCreditTarget,
16
20
  SerializedAuthStorage,
17
21
  SnapshotResponse,
18
22
  StoredAuthCredential,
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Pure decision predicate for auto-redeeming a saved OpenAI Codex rate-limit
3
+ * reset, plus the process-wide coordinator that serializes attempts.
4
+ *
5
+ * WHY THIS IS REACTIVE-ONLY (never proactive):
6
+ * The only trustworthy "blocked right now" signal is a live 429 /
7
+ * `usage_limit_reached` from a request authenticated as the session's active
8
+ * Codex credential. The session hook calls this predicate from the usage-limit
9
+ * branch of the retry pipeline, *after* free remedies (sibling-account switch)
10
+ * fail and *before* model fallback. A proactive surface (the status-line usage
11
+ * poll) cannot be used: at `used_percent < 100` the account is not actually
12
+ * limited, so redeeming would be a credit-wasting no-op; at exactly 100 the
13
+ * user may be idle, so the freshly-reset weekly window would tick away with
14
+ * nobody working. Saved resets are a scarce, ~monthly, effectively
15
+ * irreversible resource — every gate here is biased to precision over recall:
16
+ * we would rather miss a redeem than waste a credit.
17
+ *
18
+ * THE DECISION-2 TRAP (status MUST NOT be used to find the blocker):
19
+ * `openai-codex.ts` applies the top-level `rate_limit.limit_reached` flag to
20
+ * BOTH the primary (5h) and secondary (weekly) `buildUsageLimit` calls, so when
21
+ * an account is blocked, *both* limit entries carry `status: "exhausted"`
22
+ * regardless of which window is actually at 100%. Only `amount.usedFraction`
23
+ * disambiguates which window is the real blocker. This module therefore keys
24
+ * eligibility off exact limit ids (`openai-codex:primary` /
25
+ * `openai-codex:secondary`) and `usedFraction`, never off `status`.
26
+ *
27
+ * ANTI-WASTE GATES (in evaluation order): the policy must be OFF unless opted
28
+ * in; the active model must be Codex (not Spark — a Spark block lives on a
29
+ * separate meter and it is unknown whether a credit even resets it); a fresh
30
+ * usage report for the active account must confirm `limitReached`; the WEEKLY
31
+ * (secondary) window must be genuinely exhausted — a 5h-only block self-heals
32
+ * within the hour, so a credit spent there buys nothing; the natural reset must be far
33
+ * enough away to justify spending a ~30-day credit yet within one plausible
34
+ * window length; a credit must be verifiably available above the reserve; and
35
+ * the same block episode must not have been attempted already (debounce +
36
+ * per-account cooldown). All of this is pure — no fetches, no IO. The only
37
+ * stateful piece is the {@link CodexAutoRedeemCoordinator} container, whose
38
+ * read-only views are passed in so the predicate itself stays deterministic.
39
+ */
40
+ import type { OAuthAccountIdentity, ResetCreditTarget, UsageReport } from "@oh-my-pi/pi-ai";
41
+ import { reportMatchesActiveAccount } from "../slash-commands/helpers/active-oauth-account";
42
+
43
+ /** Weekly window counts as exhausted at `usedFraction >= 0.999` (used_percent >= 99.9). */
44
+ export const WEEKLY_EXHAUSTED_MIN_FRACTION = 0.999;
45
+ /** A weekly reset can never be more than one window length (7d) away; +1h slack for skew. */
46
+ export const MAX_PLAUSIBLE_REMAINING_MS = 7 * 24 * 3_600_000 + 60 * 60_000;
47
+ /** Report must be no older than the 5-min usage cache TTL plus slack. */
48
+ export const REPORT_FRESHNESS_MS = 10 * 60_000;
49
+ /** Per-account cooldown that catches blockKey drift across a minute boundary. */
50
+ export const ATTEMPT_COOLDOWN_MS = 60_000;
51
+ /** Minute bucket for blockKey, absorbing `reset_after_seconds`-derived jitter. */
52
+ export const DEBOUNCE_BUCKET_MS = 60_000;
53
+
54
+ export type CodexAutoRedeemSkipReason =
55
+ | "disabled"
56
+ | "wrong-provider"
57
+ | "spark-model"
58
+ | "no-identity"
59
+ | "no-report"
60
+ | "stale-report"
61
+ | "not-limit-reached"
62
+ | "weekly-not-exhausted"
63
+ | "no-reset-time"
64
+ | "reset-too-soon"
65
+ | "reset-implausible"
66
+ | "credits-unknown"
67
+ | "reserve"
68
+ | "already-attempted"
69
+ | "cooldown";
70
+
71
+ export interface CodexAutoRedeemInput {
72
+ nowMs: number;
73
+ /** `this.model.provider`. */
74
+ provider: string;
75
+ /** `this.model.id`. */
76
+ modelId: string;
77
+ settings: { autoRedeem: boolean; minBlockedMinutes: number; keepCredits: number };
78
+ /** `getOAuthAccountIdentity("openai-codex", sessionId)`, captured at hook entry before any await. */
79
+ identity: OAuthAccountIdentity | undefined;
80
+ /** `session.fetchUsageReports()` (≤5-min cache). */
81
+ reports: UsageReport[] | null;
82
+ attemptedBlockKeys: ReadonlySet<string>;
83
+ lastAttemptAtByAccount: ReadonlyMap<string, number>;
84
+ }
85
+
86
+ export type CodexAutoRedeemDecision =
87
+ | {
88
+ redeem: true;
89
+ target: ResetCreditTarget;
90
+ accountKey: string;
91
+ blockKey: string;
92
+ weeklyResetAtMs: number;
93
+ remainingMs: number;
94
+ availableCount: number;
95
+ }
96
+ | { redeem: false; reason: CodexAutoRedeemSkipReason };
97
+
98
+ /** Trimmed lowercase, or undefined when blank. Mirrors `normalizeIdentityValue` in active-oauth-account.ts. */
99
+ function normalize(value: unknown): string | undefined {
100
+ return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined;
101
+ }
102
+
103
+ /**
104
+ * Decide whether to auto-redeem a saved Codex reset for the active account.
105
+ *
106
+ * Pure: every gate below is a function of the snapshot inputs only. Order
107
+ * matters — cheapest / most-decisive gates first so the common "not eligible"
108
+ * paths short-circuit before any account/report matching.
109
+ */
110
+ export function evaluateCodexAutoRedeem(input: CodexAutoRedeemInput): CodexAutoRedeemDecision {
111
+ const { nowMs, settings } = input;
112
+ if (!settings.autoRedeem) return { redeem: false, reason: "disabled" };
113
+ if (input.provider !== "openai-codex") return { redeem: false, reason: "wrong-provider" };
114
+ // Unknown #1: it is unknown whether a credit resets the separate Spark meter.
115
+ if (input.modelId.includes("-spark")) return { redeem: false, reason: "spark-model" };
116
+
117
+ const accountKey = normalize(input.identity?.accountId) ?? normalize(input.identity?.email);
118
+ if (!accountKey) return { redeem: false, reason: "no-identity" };
119
+
120
+ const report = input.reports?.find(
121
+ r => r.provider === "openai-codex" && reportMatchesActiveAccount(r, input.identity),
122
+ );
123
+ if (!report) return { redeem: false, reason: "no-report" };
124
+ if (nowMs - report.fetchedAt > REPORT_FRESHNESS_MS) return { redeem: false, reason: "stale-report" };
125
+ // The wire's own blocked flag must confirm the 429.
126
+ if (report.metadata?.limitReached !== true) return { redeem: false, reason: "not-limit-reached" };
127
+
128
+ // EXACT ids — never `status` (see the Decision-2 trap in the module docs).
129
+ // The saved reset applies to the WEEKLY window, so that is the blocker we act
130
+ // on. A 5h-only block (weekly still has headroom) self-heals within the hour,
131
+ // so spending a scarce ~monthly credit there would be wasted.
132
+ const weekly = report.limits.find(l => l.id === "openai-codex:secondary");
133
+ const wUsed = weekly?.amount.usedFraction;
134
+ if (!weekly || wUsed === undefined || wUsed < WEEKLY_EXHAUSTED_MIN_FRACTION) {
135
+ return { redeem: false, reason: "weekly-not-exhausted" };
136
+ }
137
+
138
+ const resetsAt = weekly.window?.resetsAt;
139
+ if (resetsAt === undefined) return { redeem: false, reason: "no-reset-time" };
140
+ const remainingMs = resetsAt - nowMs;
141
+ // anti-waste: too close to the natural reset — let it roll over instead of spending a credit.
142
+ if (remainingMs < settings.minBlockedMinutes * 60_000) return { redeem: false, reason: "reset-too-soon" };
143
+ if (remainingMs > MAX_PLAUSIBLE_REMAINING_MS) return { redeem: false, reason: "reset-implausible" };
144
+
145
+ const available = report.resetCredits?.availableCount;
146
+ // can't verify availability from the snapshot → don't spend (precision over recall).
147
+ if (available === undefined) return { redeem: false, reason: "credits-unknown" };
148
+ if (available - Math.max(0, Math.trunc(settings.keepCredits)) < 1) {
149
+ return { redeem: false, reason: "reserve" };
150
+ }
151
+
152
+ const blockKey = `${accountKey}|${Math.round(resetsAt / DEBOUNCE_BUCKET_MS)}`;
153
+ if (input.attemptedBlockKeys.has(blockKey)) return { redeem: false, reason: "already-attempted" };
154
+ const lastAt = input.lastAttemptAtByAccount.get(accountKey);
155
+ if (lastAt !== undefined && nowMs - lastAt < ATTEMPT_COOLDOWN_MS) return { redeem: false, reason: "cooldown" };
156
+
157
+ return {
158
+ redeem: true,
159
+ target: { accountId: input.identity?.accountId, email: input.identity?.email },
160
+ accountKey,
161
+ blockKey,
162
+ weeklyResetAtMs: resetsAt,
163
+ remainingMs,
164
+ availableCount: available,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Process-wide (NOT per-session) coordinator state. Parallel subagent sessions
170
+ * share the same Codex accounts and must not race a double-spend, so this is a
171
+ * single shared container, not a per-session field.
172
+ *
173
+ * - `attemptedBlockKeys`: one attempt EVER per block episode, regardless of
174
+ * outcome — recorded before calling the consume so exceptions can't re-enter.
175
+ * - `lastAttemptAtByAccount`: per-account cooldown timestamps (epoch ms),
176
+ * catching blockKey drift across a minute boundary.
177
+ * - `inFlightByAccount`: serializes per account — a second session for the same
178
+ * account adopts the in-flight promise instead of starting a second consume.
179
+ */
180
+ export interface CodexAutoRedeemCoordinator {
181
+ attemptedBlockKeys: Set<string>;
182
+ lastAttemptAtByAccount: Map<string, number>;
183
+ inFlightByAccount: Map<string, Promise<boolean>>;
184
+ }
185
+
186
+ export const defaultCodexAutoRedeemCoordinator: CodexAutoRedeemCoordinator = {
187
+ attemptedBlockKeys: new Set(),
188
+ lastAttemptAtByAccount: new Map(),
189
+ inFlightByAccount: new Map(),
190
+ };
@@ -4,6 +4,7 @@
4
4
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
5
5
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
6
6
  import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
7
+ import { isZodSchema, zodToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
7
8
  import {
8
9
  type BashExecutionMessage,
9
10
  type BranchSummaryMessage,
@@ -47,6 +48,12 @@ function stripTypeBoxFields(obj: unknown): unknown {
47
48
  return obj;
48
49
  }
49
50
 
51
+ /** Resolve tool parameters to a plain JSON Schema object for dump output. */
52
+ function toolParametersToJsonSchema(parameters: unknown): unknown {
53
+ if (isZodSchema(parameters)) return zodToWireSchema(parameters);
54
+ return stripTypeBoxFields(parameters);
55
+ }
56
+
50
57
  /** Serialize an object as XML parameter elements, one per key. */
51
58
  function formatArgsAsXml(args: Record<string, unknown>, indent = "\t"): string {
52
59
  const parts: string[] = [];
@@ -89,7 +96,7 @@ export function formatSessionDumpText(options: FormatSessionDumpTextOptions): st
89
96
  for (const tool of tools) {
90
97
  lines.push(`<tool name="${tool.name}">`);
91
98
  lines.push(tool.description);
92
- const parametersClean = stripTypeBoxFields(tool.parameters);
99
+ const parametersClean = toolParametersToJsonSchema(tool.parameters);
93
100
  lines.push(`\nParameters:\n${formatArgsAsXml(parametersClean as Record<string, unknown>)}`);
94
101
  lines.push("<" + "/tool>\n");
95
102
  }