@oh-my-pi/pi-coding-agent 17.2.1 → 17.2.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/{CHANGELOG-dj46zzrm.md → CHANGELOG-xfpkakrn.md} +47 -0
  3. package/dist/cli.js +3891 -3885
  4. package/dist/types/config/keybindings.d.ts +6 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -5
  6. package/dist/types/edit/hashline/block-resolver.d.ts +5 -5
  7. package/dist/types/edit/hashline/diff.d.ts +4 -4
  8. package/dist/types/eval/executor-base.d.ts +27 -0
  9. package/dist/types/eval/js/context-manager.d.ts +7 -0
  10. package/dist/types/eval/js/executor.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/runner.d.ts +64 -5
  12. package/dist/types/extensibility/extensions/types.d.ts +15 -0
  13. package/dist/types/extensibility/extensions/wrapper.d.ts +1 -1
  14. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -1
  15. package/dist/types/live/visualizer.d.ts +3 -1
  16. package/dist/types/modes/acp/acp-agent.d.ts +3 -3
  17. package/dist/types/modes/components/keybinding-hints.d.ts +1 -1
  18. package/dist/types/modes/controllers/mcp-command-controller.d.ts +13 -0
  19. package/dist/types/modes/interactive-mode.d.ts +1 -0
  20. package/dist/types/modes/theme/theme.d.ts +1 -1
  21. package/dist/types/modes/types.d.ts +2 -0
  22. package/dist/types/secrets/index.d.ts +22 -0
  23. package/dist/types/secrets/obfuscator.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +4 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/codex-auto-reset.d.ts +182 -64
  27. package/dist/types/session/session-storage.d.ts +5 -2
  28. package/dist/types/session/session-tools.d.ts +2 -1
  29. package/dist/types/session/turn-recovery.d.ts +7 -1
  30. package/dist/types/system-prompt.d.ts +8 -0
  31. package/dist/types/task/executor.d.ts +27 -0
  32. package/dist/types/task/persisted-revive.d.ts +7 -0
  33. package/dist/types/tools/bash-interceptor.d.ts +1 -1
  34. package/dist/types/tools/browser/attach.d.ts +9 -1
  35. package/dist/types/tools/browser/launch.d.ts +20 -1
  36. package/dist/types/tools/browser/registry.d.ts +2 -0
  37. package/dist/types/tools/browser/tab-protocol.d.ts +8 -0
  38. package/dist/types/tools/browser/tab-supervisor.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  40. package/dist/types/tools/shell-tokenize.d.ts +11 -0
  41. package/dist/types/utils/image-loading.d.ts +2 -0
  42. package/dist/types/web/search/providers/duckduckgo.d.ts +9 -0
  43. package/dist/types/web/search/types.d.ts +2 -2
  44. package/package.json +12 -12
  45. package/src/commit/agentic/index.ts +2 -1
  46. package/src/commit/pipeline.ts +3 -2
  47. package/src/config/keybindings.ts +6 -1
  48. package/src/config/settings-schema.ts +16 -3
  49. package/src/edit/hashline/block-resolver.ts +5 -5
  50. package/src/edit/hashline/diff.ts +22 -15
  51. package/src/edit/hashline/execute.ts +14 -13
  52. package/src/edit/renderer.ts +3 -3
  53. package/src/eval/executor-base.ts +39 -0
  54. package/src/eval/jl/executor.ts +13 -2
  55. package/src/eval/js/context-manager.ts +97 -14
  56. package/src/eval/js/executor.ts +3 -0
  57. package/src/eval/js/index.ts +1 -0
  58. package/src/eval/py/executor.ts +55 -15
  59. package/src/eval/rb/executor.ts +8 -1
  60. package/src/extensibility/extensions/runner.ts +121 -4
  61. package/src/extensibility/extensions/types.ts +15 -0
  62. package/src/extensibility/extensions/wrapper.ts +19 -2
  63. package/src/extensibility/legacy-pi-coding-agent-shim.ts +5 -5
  64. package/src/live/visualizer.ts +8 -1
  65. package/src/lsp/index.ts +51 -11
  66. package/src/main.ts +3 -0
  67. package/src/modes/acp/acp-agent.ts +16 -5
  68. package/src/modes/components/assistant-message.ts +5 -10
  69. package/src/modes/components/custom-editor.ts +1 -1
  70. package/src/modes/components/keybinding-hints.ts +4 -13
  71. package/src/modes/components/status-line/segments.ts +4 -5
  72. package/src/modes/components/tool-execution.ts +4 -5
  73. package/src/modes/controllers/input-controller.ts +18 -40
  74. package/src/modes/controllers/live-command-controller.ts +1 -0
  75. package/src/modes/controllers/mcp-command-controller.ts +28 -11
  76. package/src/modes/interactive-mode.ts +59 -2
  77. package/src/modes/theme/theme.ts +13 -6
  78. package/src/modes/types.ts +2 -0
  79. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  80. package/src/prompts/system/plan-mode-active.md +5 -5
  81. package/src/sdk.ts +47 -16
  82. package/src/secrets/index.ts +83 -3
  83. package/src/secrets/obfuscator.ts +70 -15
  84. package/src/session/agent-session-types.ts +7 -1
  85. package/src/session/agent-session.ts +393 -112
  86. package/src/session/codex-auto-reset.ts +594 -123
  87. package/src/session/eval-runner.ts +2 -0
  88. package/src/session/messages.ts +1 -1
  89. package/src/session/session-maintenance.ts +48 -1
  90. package/src/session/session-manager.ts +4 -3
  91. package/src/session/session-stats.ts +14 -3
  92. package/src/session/session-storage.ts +47 -15
  93. package/src/session/session-tools.ts +30 -2
  94. package/src/session/turn-recovery.ts +15 -2
  95. package/src/slash-commands/builtin-registry.ts +21 -15
  96. package/src/slash-commands/helpers/reset-usage.ts +2 -0
  97. package/src/system-prompt.ts +13 -1
  98. package/src/task/executor.ts +168 -15
  99. package/src/task/persisted-revive.ts +26 -1
  100. package/src/telemetry-export.ts +8 -4
  101. package/src/tools/bash-interceptor.ts +85 -8
  102. package/src/tools/bash.ts +1 -1
  103. package/src/tools/browser/attach.ts +35 -10
  104. package/src/tools/browser/launch.ts +60 -11
  105. package/src/tools/browser/registry.ts +18 -2
  106. package/src/tools/browser/tab-protocol.ts +8 -0
  107. package/src/tools/browser/tab-supervisor.ts +31 -4
  108. package/src/tools/browser/tab-worker.ts +32 -2
  109. package/src/tools/output-meta.ts +16 -1
  110. package/src/tools/shell-tokenize.ts +101 -0
  111. package/src/utils/image-loading.ts +8 -3
  112. package/src/utils/shell-snapshot.ts +31 -21
  113. package/src/web/search/providers/duckduckgo.ts +183 -15
  114. package/src/web/search/types.ts +6 -1
@@ -59,6 +59,7 @@ import type {
59
59
  Message,
60
60
  Model,
61
61
  ModelUsageHealth,
62
+ OAuthAccountIdentity,
62
63
  ProviderSessionState,
63
64
  ResetCreditAccountStatus,
64
65
  ResetCreditRedeemOutcome,
@@ -81,6 +82,7 @@ import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
81
82
  import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
82
83
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
83
84
  import {
85
+ $env,
84
86
  escapeXmlText,
85
87
  formatDuration,
86
88
  getAgentDbPath,
@@ -179,6 +181,7 @@ import {
179
181
  shouldDisableReasoning,
180
182
  toReasoningEffort,
181
183
  } from "../thinking";
184
+ import { isLowSignalTitleInput } from "../tiny/text";
182
185
  import { shutdownTinyTitleClient } from "../tiny/title-client";
183
186
  import { resolveApproval } from "../tools/approval";
184
187
  import { type AskToolDetails, type AskToolInput, recoverAskQuestions } from "../tools/ask";
@@ -243,9 +246,16 @@ import {
243
246
  } from "./checkpoint-entries";
244
247
  import type { ClientBridge } from "./client-bridge";
245
248
  import {
246
- type CodexAutoRedeemRedeemDecision,
249
+ type CodexAutoRedeemCoordinator,
250
+ type CodexResetAction,
251
+ type CodexResetPlan,
252
+ type CodexResetTrigger,
247
253
  defaultCodexAutoRedeemCoordinator,
248
- evaluateCodexAutoRedeem,
254
+ isTerminalRedeemOutcome,
255
+ overlayLiveResetCredits,
256
+ planCodexResetRedemptions,
257
+ REDEEM_RETRY_DEFER_MS,
258
+ SWEEP_MIN_INTERVAL_MS,
249
259
  shouldEvaluateCodexAutoRedeem,
250
260
  shouldPromptCodexAutoRedeem,
251
261
  } from "./codex-auto-reset";
@@ -502,6 +512,9 @@ export class AgentSession {
502
512
  #asyncDeliveryEpoch = 0;
503
513
 
504
514
  readonly #irc: IrcBridge;
515
+ #ircWakeTurnObserver:
516
+ | ((records: CustomMessage[]) => ((error?: unknown) => void | Promise<void>) | undefined)
517
+ | undefined;
505
518
  // Agent identity (registry id) used for IRC routing and job ownership.
506
519
  #agentId: string | undefined;
507
520
  #agentKind: "main" | "sub" = "main";
@@ -511,6 +524,8 @@ export class AgentSession {
511
524
  #autolearnCaptureAbortController: AbortController | undefined;
512
525
  #autolearnCaptureTask: Promise<void> | undefined;
513
526
  #isDisposed = false;
527
+ /** Process-wide by default (double-spend safety across sessions); injectable for tests. */
528
+ #codexResetCoordinator: CodexAutoRedeemCoordinator;
514
529
  // Extension system
515
530
  #extensionRunner: ExtensionRunner | undefined = undefined;
516
531
  /**
@@ -572,6 +587,7 @@ export class AgentSession {
572
587
  // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
573
588
  #promptGeneration = 0;
574
589
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
590
+ #inFlightSettledCallbacks: Array<() => void | Promise<void>> = [];
575
591
  #sessionStopContinuationCount = 0;
576
592
  #sessionStopHookActive = false;
577
593
  #obfuscator: SecretObfuscator | undefined;
@@ -637,12 +653,28 @@ export class AgentSession {
637
653
  }
638
654
  }
639
655
 
640
- #endInFlight(): void {
656
+ #endInFlight(onSettled?: () => void | Promise<void>): void {
657
+ if (onSettled) this.#inFlightSettledCallbacks.push(onSettled);
641
658
  this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
642
- if (this.#promptInFlightCount === 0) {
643
- this.#releasePowerAssertion();
644
- this.#flushPendingAgentEnd();
659
+ if (this.#promptInFlightCount !== 0) return;
660
+ this.#releasePowerAssertion();
661
+ this.#flushPendingAgentEnd();
662
+ if (this.#inFlightSettledCallbacks.length === 0) {
645
663
  this.#drainStrandedQueuedMessages();
664
+ return;
665
+ }
666
+ void this.#flushInFlightSettledCallbacks().finally(() => this.#drainStrandedQueuedMessages());
667
+ }
668
+
669
+ async #flushInFlightSettledCallbacks(): Promise<void> {
670
+ const callbacks = this.#inFlightSettledCallbacks;
671
+ this.#inFlightSettledCallbacks = [];
672
+ for (const callback of callbacks) {
673
+ try {
674
+ await callback();
675
+ } catch (error) {
676
+ logger.warn("In-flight settle callback failed", { error: String(error) });
677
+ }
646
678
  }
647
679
  }
648
680
 
@@ -726,21 +758,48 @@ export class AgentSession {
726
758
  if (parkedFollowUps.length > 0) {
727
759
  this.agent.replaceQueues([...this.agent.peekSteeringQueue()], []);
728
760
  }
761
+ let finishObservation: ((error?: unknown) => void | Promise<void>) | undefined;
762
+ try {
763
+ finishObservation = this.#ircWakeTurnObserver?.(records);
764
+ } catch (error) {
765
+ logger.warn("IRC wake turn observer failed to start", { error: String(error) });
766
+ }
729
767
  this.#resetPromptMaintenanceState();
768
+ // Capture the generation before the wake so its post-prompt recovery wait
769
+ // bails the instant an abort (which bumps #promptGeneration) supersedes
770
+ // this wake — otherwise the wait would follow a successor turn (a queued
771
+ // follow-up or another stranded IRC wake started by abort cleanup),
772
+ // delaying finishObservation and mis-attributing the successor's RPC
773
+ // progress to this now-dead wake monitor.
774
+ const generation = this.#promptGeneration;
730
775
  this.#beginInFlight();
776
+ let turnError: unknown;
731
777
  void this.agent
732
778
  .prompt(records)
733
779
  .catch(error => {
780
+ turnError = error;
734
781
  logger.warn("IRC wake turn failed", { error: String(error) });
735
782
  })
736
- .finally(() => {
783
+ .finally(async () => {
784
+ try {
785
+ await this.#waitForPostPromptRecovery(generation);
786
+ } catch (error) {
787
+ turnError ??= error;
788
+ logger.warn("IRC wake turn recovery failed", { error: String(error) });
789
+ }
737
790
  if (parkedFollowUps.length > 0) {
738
791
  this.agent.replaceQueues(
739
792
  [...this.agent.peekSteeringQueue()],
740
793
  [...parkedFollowUps, ...this.agent.peekFollowUpQueue()],
741
794
  );
742
795
  }
743
- this.#endInFlight();
796
+ this.#endInFlight(async () => {
797
+ try {
798
+ await finishObservation?.(turnError);
799
+ } catch (error) {
800
+ logger.warn("IRC wake turn observer failed to finish", { error: String(error) });
801
+ }
802
+ });
744
803
  });
745
804
  }
746
805
 
@@ -781,7 +840,11 @@ export class AgentSession {
781
840
  this.#promptInFlightCount = 0;
782
841
  this.#releasePowerAssertion();
783
842
  this.#flushPendingAgentEnd();
784
- this.#drainStrandedQueuedMessages();
843
+ if (this.#inFlightSettledCallbacks.length === 0) {
844
+ this.#drainStrandedQueuedMessages();
845
+ return;
846
+ }
847
+ void this.#flushInFlightSettledCallbacks().finally(() => this.#drainStrandedQueuedMessages());
785
848
  }
786
849
 
787
850
  #flushPendingAgentEnd(): void {
@@ -834,6 +897,7 @@ export class AgentSession {
834
897
  this.sessionManager = config.sessionManager;
835
898
  this.settings = config.settings;
836
899
  this.#modelRegistry = config.modelRegistry;
900
+ this.#codexResetCoordinator = config.codexResetCoordinator ?? defaultCodexAutoRedeemCoordinator;
837
901
  const bashHost: BashRunnerHost = {
838
902
  agent: this.agent,
839
903
  sessionManager: this.sessionManager,
@@ -966,7 +1030,7 @@ export class AgentSession {
966
1030
  sessionMessageAlreadyPersisted: message => this.#sessionMessageAlreadyPersisted(message),
967
1031
  setModelWithProviderSessionReset: model => this.#setModelWithProviderSessionReset(model),
968
1032
  resetCurrentResponsesProviderSession: reason => this.#resetCurrentResponsesProviderSession(reason),
969
- maybeAutoRedeemCodexReset: () => this.#maybeAutoRedeemCodexReset(),
1033
+ maybeAutoRedeemCodexReset: activeBlockUnblockAtMs => this.#maybeAutoRedeemCodexReset(activeBlockUnblockAtMs),
970
1034
  runAutoCompaction: (reason, willRetry, deferred, allowDefer, options) =>
971
1035
  this.#maintenance.runAutoCompaction(reason, willRetry, deferred, allowDefer, options),
972
1036
  withBashBranchTransition: operation => this.#bash.withBranchTransition(operation),
@@ -2122,7 +2186,7 @@ export class AgentSession {
2122
2186
  * On a user-interrupted (`Esc`) abort, copy the trailing thinking run into a
2123
2187
  * hidden `display: false` continuity message for the next turn WITHOUT
2124
2188
  * mutating the assistant message. The original thinking stays on the message
2125
- * so live render, reload, and Ctrl+L rebuilds keep showing it; `convertToLlm`
2189
+ * so live render, reload, and display-reset rebuilds keep showing it; `convertToLlm`
2126
2190
  * strips the run from the provider request (incomplete/unsigned thinking is
2127
2191
  * rejected on resend) when this continuity message follows the assistant turn.
2128
2192
  */
@@ -4039,10 +4103,6 @@ export class AgentSession {
4039
4103
  return this.#tools.applyActiveToolsByName(toolNames);
4040
4104
  }
4041
4105
 
4042
- #takePendingXdevMountNotice(): CustomMessage | undefined {
4043
- return this.#tools.takePendingXdevMountNotice();
4044
- }
4045
-
4046
4106
  /** Rediscovers reloadable skills and refreshes prompt metadata. */
4047
4107
  refreshSkills(): Promise<void> {
4048
4108
  return this.#tools.refreshSkills();
@@ -5018,11 +5078,10 @@ export class AgentSession {
5018
5078
  }
5019
5079
 
5020
5080
  // A pending xd:// delta accompanies the next user-authored prompt,
5021
- // never an agent-initiated continuation.
5022
- const xdevMountNotice = isUserQueuedMessage(message) ? this.#takePendingXdevMountNotice() : undefined;
5023
- if (xdevMountNotice) {
5024
- messages.push(xdevMountNotice);
5025
- }
5081
+ // never an agent-initiated continuation. Reserve its pre-user position,
5082
+ // but consume it only after before_agent_start determines whether the
5083
+ // final provider prompt still carries the base xd:// catalog.
5084
+ const xdevMountNoticeIndex = messages.length;
5026
5085
  messages.push(message);
5027
5086
  // Inject any pending "nextTurn" messages as context alongside the user message
5028
5087
  for (const msg of this.#pendingNextTurnMessages) {
@@ -5053,6 +5112,7 @@ export class AgentSession {
5053
5112
  if ((this.#isDisposed && !disposingBeforeTransition) || this.#promptGeneration !== generation) return;
5054
5113
  const beforeAgentStartSystemPrompt = await this.#buildSystemPromptForAgentStart(expandedText);
5055
5114
 
5115
+ let baseXdevCatalogDelivered = true;
5056
5116
  // Emit before_agent_start extension event
5057
5117
  if (this.#extensionRunner) {
5058
5118
  const result = await this.#extensionRunner.emitBeforeAgentStart(
@@ -5087,6 +5147,7 @@ export class AgentSession {
5087
5147
  }
5088
5148
 
5089
5149
  if (result?.systemPrompt !== undefined) {
5150
+ baseXdevCatalogDelivered = false;
5090
5151
  this.agent.setSystemPrompt(result.systemPrompt);
5091
5152
  } else {
5092
5153
  this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
@@ -5110,6 +5171,12 @@ export class AgentSession {
5110
5171
  return;
5111
5172
  }
5112
5173
  }
5174
+ const xdevMountNotice = isUserQueuedMessage(message)
5175
+ ? this.#tools.takePendingXdevMountNotice(baseXdevCatalogDelivered)
5176
+ : undefined;
5177
+ if (xdevMountNotice) {
5178
+ messages.splice(xdevMountNoticeIndex, 0, xdevMountNotice);
5179
+ }
5113
5180
 
5114
5181
  await this.#maintenance.runPrePromptCompactionIfNeeded(messages);
5115
5182
  if (this.#promptGeneration !== generation) {
@@ -5880,6 +5947,40 @@ export class AgentSession {
5880
5947
  this.#replanTitleRefreshInFlight = refresh;
5881
5948
  }
5882
5949
 
5950
+ /**
5951
+ * Start automatic title generation when the session and input are eligible.
5952
+ * Interactive and CLI-bootstrap submissions share this gate so every first
5953
+ * user message persists titles with the same environment, signal, and local
5954
+ * extension-command policy.
5955
+ */
5956
+ maybeStartTitleGeneration(firstMessage: string, onStart?: () => void): void {
5957
+ const extensionCommandSpace = firstMessage.indexOf(" ");
5958
+ const isLocalExtensionCommand =
5959
+ firstMessage.startsWith("/") &&
5960
+ this.#extensionRunner?.getCommand(
5961
+ extensionCommandSpace === -1 ? firstMessage.slice(1) : firstMessage.slice(1, extensionCommandSpace),
5962
+ ) !== undefined;
5963
+ if (isLocalExtensionCommand || this.sessionName || $env.PI_NO_TITLE || isLowSignalTitleInput(firstMessage)) {
5964
+ return;
5965
+ }
5966
+ onStart?.();
5967
+ this.generateTitle(firstMessage)
5968
+ .then(async title => {
5969
+ // Re-check after generation so concurrent attempts cannot replace
5970
+ // the first title that completed.
5971
+ if (title && !this.sessionName) {
5972
+ await this.sessionManager.setSessionName(title, "auto");
5973
+ }
5974
+ })
5975
+ .catch(err => {
5976
+ logger.warn("title-generator: uncaught auto-title error", {
5977
+ sessionId: this.sessionId,
5978
+ reason: "uncaught-auto-title-error",
5979
+ error: err instanceof Error ? err.message : String(err),
5980
+ });
5981
+ });
5982
+ }
5983
+
5883
5984
  /**
5884
5985
  * Generate an automatic session title tied to this session's lifecycle.
5885
5986
  * Input and replan callers share the signal so disposal cancels provider and
@@ -6868,6 +6969,13 @@ export class AgentSession {
6868
6969
  return this.#irc.deliver(msg, opts);
6869
6970
  }
6870
6971
 
6972
+ /** Installs task-executor monitoring around autonomous IRC wake turns. */
6973
+ setIrcWakeTurnObserver(
6974
+ observer: ((records: CustomMessage[]) => ((error?: unknown) => void | Promise<void>) | undefined) | undefined,
6975
+ ): void {
6976
+ this.#ircWakeTurnObserver = observer;
6977
+ }
6978
+
6871
6979
  /** Emits an IRC relay observation for UI rendering without persisting it. */
6872
6980
  emitIrcRelayObservation(record: CustomMessage): void {
6873
6981
  this.#irc.emitRelayObservation(record);
@@ -8042,7 +8150,7 @@ export class AgentSession {
8042
8150
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
8043
8151
  const authStorage = this.#modelRegistry.authStorage;
8044
8152
  if (!authStorage.fetchUsageReports) return null;
8045
- return authStorage.fetchUsageReports({
8153
+ const reports = await authStorage.fetchUsageReports({
8046
8154
  baseUrlResolver: provider => {
8047
8155
  if (provider === "google-antigravity") {
8048
8156
  const mode = this.settings.get("providers.antigravityEndpoint");
@@ -8056,6 +8164,11 @@ export class AgentSession {
8056
8164
  },
8057
8165
  signal,
8058
8166
  });
8167
+ // Every fresh usage snapshot doubles as the salvage-sweep heartbeat: the
8168
+ // status line calls this every 5 minutes while the TUI is open, so
8169
+ // expiring saved Codex resets are caught even when nothing is blocked.
8170
+ if (reports) this.#maybeScheduleCodexResetSweep(reports);
8171
+ return reports;
8059
8172
  }
8060
8173
 
8061
8174
  /** Models whose live `/usage` reports map to a quantitative provider scope. */
@@ -8126,35 +8239,50 @@ export class AgentSession {
8126
8239
  signal,
8127
8240
  });
8128
8241
  }
8129
- async #confirmCodexAutoRedeem(decision: CodexAutoRedeemRedeemDecision): Promise<boolean> {
8242
+ /**
8243
+ * Ask before the first auto-spend (`codexResets.autoRedeem === "unset"`).
8244
+ * The answer is persisted, so this fires at most once per install. Headless
8245
+ * hosts get a one-shot notice per episode instead of a prompt.
8246
+ */
8247
+ async #confirmCodexAutoRedeem(
8248
+ actions: CodexResetAction[],
8249
+ coordinator: CodexAutoRedeemCoordinator,
8250
+ ): Promise<boolean> {
8251
+ const first = actions[0];
8252
+ if (!first) return false;
8130
8253
  const runner = this.#extensionRunner;
8131
8254
  if (!runner?.hasUI()) {
8132
- this.emitNotice(
8133
- "warning",
8134
- "Codex saved reset is eligible, but auto-redeem is unset and no prompt UI is available. Run `/usage reset` or set codexResets.autoRedeem.",
8135
- "codex-auto-reset",
8136
- );
8255
+ if (!coordinator.notifiedKeys.has(first.attemptKey)) {
8256
+ coordinator.notifiedKeys.add(first.attemptKey);
8257
+ this.emitNotice(
8258
+ "warning",
8259
+ "Saved Codex resets are eligible to spend, but auto-redeem is unset and no prompt UI is available. Run `/usage reset` or set codexResets.autoRedeem.",
8260
+ "codex-auto-reset",
8261
+ );
8262
+ }
8137
8263
  return false;
8138
8264
  }
8139
8265
 
8140
- const who = decision.target.email ?? decision.target.accountId ?? "the active account";
8141
- const resetLabel = decision.availableCount === 1 ? "reset" : "resets";
8266
+ const lines = actions.map(action =>
8267
+ action.reason === "blocked-account"
8268
+ ? `${action.label} is blocked by the Codex ${(action.blockedWindows ?? []).join(" + ") || "usage"} limit for about ${formatDuration(action.remainingMs ?? 0)}.`
8269
+ : `${action.label}: a saved reset expires in ${formatDuration(action.expiresInMs ?? 0)} (${action.salvageWindow ?? "weekly"} window ${Math.round((action.salvageUsedFraction ?? action.weeklyUsedFraction ?? 0) * 100)}% used).`,
8270
+ );
8271
+ const question =
8272
+ actions.length === 1
8273
+ ? `Spend a saved Codex rate-limit reset?\n${lines[0]}`
8274
+ : `Spend ${actions.length} saved Codex rate-limit resets?\n${lines.join("\n")}`;
8142
8275
  try {
8143
- const choice = await runner
8144
- .getUIContext()
8145
- .select(
8146
- `Do you wanna redeem your reset?\n${who} is blocked by the weekly Codex limit for about ${formatDuration(decision.remainingMs)}. Spend 1 of ${decision.availableCount} saved ${resetLabel}?`,
8147
- [
8148
- {
8149
- label: "Yes",
8150
- description: "Redeem now and remember yes for future eligible Codex weekly blocks.",
8151
- },
8152
- {
8153
- label: "No",
8154
- description: "Do not auto-redeem saved Codex resets.",
8155
- },
8156
- ],
8157
- );
8276
+ const choice = await runner.getUIContext().select(question, [
8277
+ {
8278
+ label: "Yes",
8279
+ description: "Redeem now and remember yes for future eligible Codex resets.",
8280
+ },
8281
+ {
8282
+ label: "No",
8283
+ description: "Do not auto-redeem saved Codex resets.",
8284
+ },
8285
+ ]);
8158
8286
  if (choice === "Yes") {
8159
8287
  this.settings.set("codexResets.autoRedeem", "yes");
8160
8288
  return true;
@@ -8168,17 +8296,166 @@ export class AgentSession {
8168
8296
  return false;
8169
8297
  }
8170
8298
 
8299
+ /** Run the pure planner over a usage snapshot with this session's settings. */
8300
+ #planCodexResets(
8301
+ trigger: CodexResetTrigger,
8302
+ reports: UsageReport[] | null,
8303
+ identity: OAuthAccountIdentity | undefined,
8304
+ coordinator: CodexAutoRedeemCoordinator,
8305
+ activeBlockUnblockAtMs?: number,
8306
+ ): CodexResetPlan {
8307
+ const cfg = this.settings.getGroup("codexResets");
8308
+ const model = this.model;
8309
+ const plan = planCodexResetRedemptions({
8310
+ nowMs: Date.now(),
8311
+ trigger,
8312
+ provider: model?.provider ?? "",
8313
+ modelId: model?.id ?? "",
8314
+ settings: {
8315
+ enabled: shouldEvaluateCodexAutoRedeem(cfg.autoRedeem),
8316
+ minBlockedMinutes: Math.max(0, cfg.minBlockedMinutes),
8317
+ keepCredits: Math.max(0, Math.trunc(cfg.keepCredits)),
8318
+ salvageHorizonMs: Math.max(0, cfg.salvageHorizonHours) * 3_600_000,
8319
+ },
8320
+ identity,
8321
+ reports,
8322
+ attemptedKeys: coordinator.attemptedKeys,
8323
+ deferredUntilByKey: coordinator.deferredUntilByKey,
8324
+ lastAttemptAtByAccount: coordinator.lastAttemptAtByAccount,
8325
+ activeBlockUnblockAtMs,
8326
+ });
8327
+ if (plan.skipped.length > 0) {
8328
+ logger.debug("codex-auto-reset: plan", { trigger, actions: plan.actions.length, skipped: plan.skipped });
8329
+ }
8330
+ return plan;
8331
+ }
8332
+
8333
+ /**
8334
+ * Spend planned resets in order, re-checking the process-wide attempt set
8335
+ * immediately before each consume so a concurrent pass can never
8336
+ * double-spend an episode. Returns how many credits were actually redeemed.
8337
+ */
8338
+ async #executeCodexResetActions(
8339
+ actions: CodexResetAction[],
8340
+ coordinator: CodexAutoRedeemCoordinator,
8341
+ ): Promise<number> {
8342
+ const authStorage = this.#modelRegistry.authStorage;
8343
+ let redeemed = 0;
8344
+ for (const action of actions) {
8345
+ if (coordinator.attemptedKeys.has(action.attemptKey)) continue;
8346
+ // Commit the attempt BEFORE acting so this episode can never re-enter.
8347
+ coordinator.attemptedKeys.add(action.attemptKey);
8348
+ coordinator.lastAttemptAtByAccount.set(action.accountKey, Date.now());
8349
+ let outcome: ResetCreditRedeemOutcome;
8350
+ try {
8351
+ outcome = await authStorage.redeemResetCredit({
8352
+ target: action.target,
8353
+ baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
8354
+ // Not tied to the retry abort controller: aborting a consume
8355
+ // mid-flight leaves credit state unknown.
8356
+ signal: AbortSignal.timeout(15_000),
8357
+ });
8358
+ } catch (error) {
8359
+ // Thrown transport failure (network error, 15s timeout): same policy
8360
+ // as a non-terminal code — release the episode and retry after the
8361
+ // deferral. The next pass re-plans on a FRESH snapshot, so if an
8362
+ // ambiguous timeout actually landed server-side the spent credit is
8363
+ // gone from the plan before any retry could double-spend.
8364
+ coordinator.attemptedKeys.delete(action.attemptKey);
8365
+ coordinator.deferredUntilByKey.set(action.attemptKey, Date.now() + REDEEM_RETRY_DEFER_MS);
8366
+ logger.warn("codex-auto-reset: redeem threw, deferred", {
8367
+ account: action.accountKey,
8368
+ error: String(error),
8369
+ });
8370
+ continue;
8371
+ }
8372
+ if (!isTerminalRedeemOutcome(outcome.code)) {
8373
+ // `nothing_to_reset` (limits not constrained enough yet) or a
8374
+ // transport failure: the credit is STILL BANKED. Release the episode
8375
+ // and park it so a later pass retries once usage grows or the outage
8376
+ // clears — burying a live credit here is how resets expire unused.
8377
+ coordinator.attemptedKeys.delete(action.attemptKey);
8378
+ coordinator.deferredUntilByKey.set(action.attemptKey, Date.now() + REDEEM_RETRY_DEFER_MS);
8379
+ }
8380
+ switch (outcome.code) {
8381
+ case "reset": {
8382
+ redeemed++;
8383
+ const left =
8384
+ action.availableCount === undefined ? undefined : ` (${Math.max(0, action.availableCount - 1)} left)`;
8385
+ const detail =
8386
+ action.reason === "expiring-credit"
8387
+ ? `it was set to expire in ${formatDuration(action.expiresInMs ?? 0)}`
8388
+ : "retrying now";
8389
+ this.emitNotice(
8390
+ "info",
8391
+ `Auto-redeemed a saved Codex rate-limit reset for ${action.label}${left ?? ""}; ${detail}.`,
8392
+ "codex-auto-reset",
8393
+ );
8394
+ break;
8395
+ }
8396
+ case "already_redeemed":
8397
+ this.emitNotice(
8398
+ "warning",
8399
+ `A saved Codex reset for ${action.label} was already redeemed elsewhere.`,
8400
+ "codex-auto-reset",
8401
+ );
8402
+ break;
8403
+ case "no_credit":
8404
+ logger.debug("codex-auto-reset: no_credit (snapshot/live mismatch)", { account: action.accountKey });
8405
+ break;
8406
+ case "nothing_to_reset":
8407
+ // Routine for opportunistic salvage on a partially-used window —
8408
+ // keep the transcript quiet; a blocked turn's user is watching.
8409
+ if (action.reason === "blocked-account") {
8410
+ this.emitNotice(
8411
+ "warning",
8412
+ `Codex reset for ${action.label} reported nothing to reset; will retry later.`,
8413
+ "codex-auto-reset",
8414
+ );
8415
+ } else {
8416
+ logger.debug("codex-auto-reset: nothing_to_reset deferred", { account: action.accountKey });
8417
+ }
8418
+ break;
8419
+ default:
8420
+ if (action.reason === "blocked-account") {
8421
+ this.emitNotice(
8422
+ "warning",
8423
+ `Codex auto-redeem for ${action.label} failed (${outcome.code}); will retry later.`,
8424
+ "codex-auto-reset",
8425
+ );
8426
+ } else {
8427
+ logger.warn("codex-auto-reset: consume failed, deferred", {
8428
+ account: action.accountKey,
8429
+ code: outcome.code,
8430
+ });
8431
+ }
8432
+ break;
8433
+ }
8434
+ }
8435
+ // Reflect the reset in the next snapshot (redeem already invalidated the cache).
8436
+ if (redeemed > 0) void this.fetchUsageReports();
8437
+ return redeemed;
8438
+ }
8439
+
8171
8440
  /**
8172
8441
  * Auto-redeem hook for {@link AgentSession.#handleRetryableError}'s
8173
8442
  * usage-limit branch. Returns `true` only when a saved Codex reset was
8174
- * actually spent (so the caller retries immediately). The "unset" mode is
8175
- * reactive but asks before spending; "yes" skips that prompt, and "no" avoids
8176
- * the eligibility IO entirely. The decision remains heavily gated — see
8177
- * `./codex-auto-reset` and the design in `local://autoreset-spec.md`.
8178
- * Per-account in-flight dedup lets concurrent sessions adopt one redeem
8443
+ * actually spent (so the caller retries immediately). Usage is
8444
+ * force-refreshed first, but the live 429's parsed unblock timestamp
8445
+ * (`activeBlockUnblockAtMs`, captured at the error) stays authoritative for
8446
+ * the active account: the refreshed snapshot can still predate the block
8447
+ * (in-flight fetch adoption, last-good-on-failure under `/wham/usage` IP
8448
+ * throttling), and with no usable report at all the planner synthesizes the
8449
+ * active candidate and lets the redeem re-check credits live. The plan
8450
+ * covers ALL stored accounts — restoring an exhausted sibling clears its
8451
+ * credential blocks, so the retry's re-rank picks it up even when the
8452
+ * active account has no credits. The "unset" mode asks before spending;
8453
+ * "yes" skips the prompt; "no" avoids the eligibility IO entirely.
8454
+ * Per-account in-flight dedup lets concurrent sessions adopt one pass
8179
8455
  * instead of double-spending.
8180
8456
  */
8181
- async #maybeAutoRedeemCodexReset(coordinator = defaultCodexAutoRedeemCoordinator): Promise<boolean> {
8457
+ async #maybeAutoRedeemCodexReset(activeBlockUnblockAtMs?: number): Promise<boolean> {
8458
+ const coordinator = this.#codexResetCoordinator;
8182
8459
  const cfg = this.settings.getGroup("codexResets");
8183
8460
  const model = this.model;
8184
8461
  // Cheap exits before any IO.
@@ -8193,76 +8470,80 @@ export class AgentSession {
8193
8470
  if (existing) return existing;
8194
8471
 
8195
8472
  const run = (async (): Promise<boolean> => {
8473
+ // Live data: the cached report predates the block that got us here.
8474
+ await authStorage.invalidateUsageCache("openai-codex");
8196
8475
  const reports = await this.fetchUsageReports();
8197
- const decision = evaluateCodexAutoRedeem({
8198
- nowMs: Date.now(),
8199
- provider: model.provider,
8200
- modelId: model.id,
8201
- settings: {
8202
- autoRedeem: true,
8203
- minBlockedMinutes: Math.max(0, cfg.minBlockedMinutes),
8204
- keepCredits: Math.max(0, Math.trunc(cfg.keepCredits)),
8205
- },
8206
- identity,
8207
- reports,
8208
- attemptedBlockKeys: coordinator.attemptedBlockKeys,
8209
- lastAttemptAtByAccount: coordinator.lastAttemptAtByAccount,
8210
- });
8211
- if (!decision.redeem) {
8212
- logger.debug("codex-auto-reset: skipped", { reason: decision.reason, account: accountKey });
8213
- return false;
8476
+ // Live per-account credit counts: `/wham/usage` counts can be stale or
8477
+ // pre-feature, and a stale ZERO is never corrected by the detail merge
8478
+ // (it only runs when the usage payload already reports a positive
8479
+ // count) — so report counts must never veto a spend or fake a reserve.
8480
+ let effectiveReports = reports;
8481
+ try {
8482
+ const statuses = await this.listResetCredits(AbortSignal.timeout(10_000));
8483
+ effectiveReports = overlayLiveResetCredits(reports, statuses);
8484
+ } catch (error) {
8485
+ logger.debug("codex-auto-reset: live credit listing failed; keeping report counts", {
8486
+ error: String(error),
8487
+ });
8214
8488
  }
8215
- if (shouldPromptCodexAutoRedeem(cfg.autoRedeem) && !(await this.#confirmCodexAutoRedeem(decision))) {
8489
+ const plan = this.#planCodexResets("blocked", effectiveReports, identity, coordinator, activeBlockUnblockAtMs);
8490
+ if (plan.actions.length === 0) return false;
8491
+ if (
8492
+ shouldPromptCodexAutoRedeem(cfg.autoRedeem) &&
8493
+ !(await this.#confirmCodexAutoRedeem(plan.actions, coordinator))
8494
+ ) {
8216
8495
  return false;
8217
8496
  }
8218
- // Commit the attempt BEFORE acting so this block can never re-enter.
8219
- coordinator.attemptedBlockKeys.add(decision.blockKey);
8220
- coordinator.lastAttemptAtByAccount.set(decision.accountKey, Date.now());
8221
- const who = decision.target.email ?? decision.target.accountId ?? "the active account";
8222
- const outcome = await authStorage.redeemResetCredit({
8223
- target: decision.target,
8224
- baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
8225
- // Not tied to the retry abort controller: aborting a consume
8226
- // mid-flight leaves credit state unknown.
8227
- signal: AbortSignal.timeout(15_000),
8228
- });
8229
- switch (outcome.code) {
8230
- case "reset": {
8231
- const left = Math.max(0, decision.availableCount - 1);
8232
- this.emitNotice(
8233
- "info",
8234
- `Auto-redeemed a saved Codex rate-limit reset for ${who} (${left} left); retrying now.`,
8235
- "codex-auto-reset",
8236
- );
8237
- void this.fetchUsageReports();
8238
- return true;
8239
- }
8240
- case "already_redeemed":
8241
- this.emitNotice(
8242
- "warning",
8243
- "A saved Codex reset was already redeemed elsewhere; waiting for the window.",
8244
- "codex-auto-reset",
8245
- );
8246
- return false;
8247
- case "no_credit":
8248
- logger.debug("codex-auto-reset: no_credit (snapshot/live mismatch)", { account: accountKey });
8249
- return false;
8250
- case "nothing_to_reset":
8251
- this.emitNotice(
8252
- "warning",
8253
- "Codex reset reported nothing to reset; auto-redeem suppressed for this window.",
8254
- "codex-auto-reset",
8255
- );
8256
- return false;
8257
- default:
8258
- this.emitNotice("warning", `Codex auto-redeem failed (${outcome.code}).`, "codex-auto-reset");
8259
- return false;
8260
- }
8261
- })().finally(() => coordinator.inFlightByAccount.delete(accountKey));
8497
+ return (await this.#executeCodexResetActions(plan.actions, coordinator)) > 0;
8498
+ })()
8499
+ .catch(error => {
8500
+ // Eligibility IO (cache invalidation / usage fetch) failed; the
8501
+ // retry pipeline must keep running, so a blocked pass never rejects.
8502
+ logger.warn("codex-auto-reset: blocked pass failed", { account: accountKey, error: String(error) });
8503
+ return false;
8504
+ })
8505
+ .finally(() => coordinator.inFlightByAccount.delete(accountKey));
8262
8506
  coordinator.inFlightByAccount.set(accountKey, run);
8263
8507
  return run;
8264
8508
  }
8265
8509
 
8510
+ /**
8511
+ * Salvage-sweep entry, piggybacked on every successful usage fetch. Spends
8512
+ * saved Codex resets that would otherwise expire (the `expiring-credit`
8513
+ * rule in `./codex-auto-reset`) across ALL stored accounts, regardless of
8514
+ * the active model or blocked state. Fire-and-forget: never delays the
8515
+ * fetch, never runs concurrently with itself or a blocked pass, and the
8516
+ * attempt keys make re-sweeps of the same snapshot no-ops.
8517
+ */
8518
+ #maybeScheduleCodexResetSweep(reports: UsageReport[]): void {
8519
+ const coordinator = this.#codexResetCoordinator;
8520
+ const cfg = this.settings.getGroup("codexResets");
8521
+ if (!shouldEvaluateCodexAutoRedeem(cfg.autoRedeem) || cfg.salvageHorizonHours <= 0) return;
8522
+ // A blocked pass is planning over the same snapshot; let it own the spend.
8523
+ if (coordinator.sweepInFlight || coordinator.inFlightByAccount.size > 0) return;
8524
+ const now = Date.now();
8525
+ if (now - coordinator.lastSweepAt < SWEEP_MIN_INTERVAL_MS) return;
8526
+ if (!reports.some(r => r.provider === "openai-codex" && (r.resetCredits?.credits?.length ?? 0) > 0)) return;
8527
+ coordinator.sweepInFlight = true;
8528
+ coordinator.lastSweepAt = now;
8529
+ coordinator.sweepPromise = (async () => {
8530
+ const identity = this.#modelRegistry.authStorage.getOAuthAccountIdentity("openai-codex", this.sessionId);
8531
+ const plan = this.#planCodexResets("sweep", reports, identity, coordinator);
8532
+ if (plan.actions.length === 0) return;
8533
+ if (
8534
+ shouldPromptCodexAutoRedeem(cfg.autoRedeem) &&
8535
+ !(await this.#confirmCodexAutoRedeem(plan.actions, coordinator))
8536
+ ) {
8537
+ return;
8538
+ }
8539
+ await this.#executeCodexResetActions(plan.actions, coordinator);
8540
+ })()
8541
+ .catch(error => logger.warn("codex-reset sweep failed", { error: String(error) }))
8542
+ .finally(() => {
8543
+ coordinator.sweepInFlight = false;
8544
+ });
8545
+ }
8546
+
8266
8547
  /**
8267
8548
  * Export session to HTML.
8268
8549
  * @param outputPath Optional output path