@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12

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 (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
@@ -174,8 +174,8 @@ import {
174
174
  deobfuscateSessionContext,
175
175
  deobfuscateToolArguments,
176
176
  obfuscateProviderContext,
177
- type SecretObfuscator,
178
- } from "../secrets/obfuscator";
177
+ } from "../secrets/message-transform";
178
+ import type { SecretObfuscator } from "../secrets/obfuscator";
179
179
  import {
180
180
  AUTO_THINKING,
181
181
  type ConfiguredThinkingLevel,
@@ -2056,6 +2056,41 @@ export class AgentSession {
2056
2056
  */
2057
2057
  #prunedTerminalRefusal: AssistantMessage | undefined = undefined;
2058
2058
 
2059
+ /**
2060
+ * In-flight {@link #dispatchAgentEvent} promises. agent-core invokes the
2061
+ * event subscriber fire-and-forget, so a `message_end`/`agent_end` handler
2062
+ * can still be awaiting extension/subscriber/maintenance work — and thus its
2063
+ * `sessionManager`/`agent.state` append — after `agent.waitForIdle()`
2064
+ * resolves. Dispose drains this set so the late append lands *before* the
2065
+ * memory release, never after it.
2066
+ */
2067
+ #inFlightEventHandlers = new Set<Promise<void>>();
2068
+
2069
+ /**
2070
+ * Subscriber entry point. Delegates to {@link #dispatchAgentEvent} and
2071
+ * records the dispatch in {@link #inFlightEventHandlers} until it settles so
2072
+ * {@link #drainInFlightEventHandlers} can await the session's async
2073
+ * event/persistence pipeline during teardown.
2074
+ */
2075
+ #handleAgentEvent = (event: AgentEvent): Promise<void> => {
2076
+ const processing = this.#dispatchAgentEvent(event);
2077
+ this.#inFlightEventHandlers.add(processing);
2078
+ void processing.finally(() => this.#inFlightEventHandlers.delete(processing)).catch(() => {});
2079
+ return processing;
2080
+ };
2081
+
2082
+ /**
2083
+ * Await every in-flight event handler (and any it chains into) so a late
2084
+ * message/entry append cannot land after the caller clears session memory.
2085
+ * The agent must already be idle — otherwise new events keep arriving and
2086
+ * this never drains.
2087
+ */
2088
+ async #drainInFlightEventHandlers(): Promise<void> {
2089
+ while (this.#inFlightEventHandlers.size > 0) {
2090
+ await Promise.allSettled([...this.#inFlightEventHandlers]);
2091
+ }
2092
+ }
2093
+
2059
2094
  /** Internal handler for agent events - shared by subscribe and reconnect.
2060
2095
  *
2061
2096
  * `agent_end` handling schedules deferred post-prompt recovery work
@@ -2070,7 +2105,7 @@ export class AgentSession {
2070
2105
  * `#postPromptTasksPromise` is set the moment `#emit` invokes this handler, so
2071
2106
  * the recovery wait always sees the in-flight handler and blocks until it — and
2072
2107
  * everything it schedules — settles. */
2073
- #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
2108
+ #dispatchAgentEvent = async (event: AgentEvent): Promise<void> => {
2074
2109
  if (event.type === "tool_execution_end" && this.#isTerminalYieldToolResult(event)) {
2075
2110
  const alreadyTerminated = this.#synchronouslyTerminatedYieldToolCallIds.delete(event.toolCallId);
2076
2111
  if (!alreadyTerminated) {
@@ -2648,7 +2683,7 @@ export class AgentSession {
2648
2683
  // here after maintenance routing, tagged isTerminal so subscribers can
2649
2684
  // tell final settles from scheduled continuations.
2650
2685
  await this.#emitSessionEvent({ ...event, isTerminal: !options?.willContinue });
2651
- void this.#emitAgentEndNotification(activeMessages, options).catch(err => {
2686
+ void this.#emitAgentEndNotification([...activeMessages], options).catch(err => {
2652
2687
  logger.error("Agent end extension notification failed", { err });
2653
2688
  });
2654
2689
  };
@@ -3835,8 +3870,6 @@ export class AgentSession {
3835
3870
  this.#releasePowerAssertion();
3836
3871
  await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile);
3837
3872
  this.#movedFromEmptySessionFile = undefined;
3838
- // All teardown branches that can append session entries have settled.
3839
- await this.sessionManager.close();
3840
3873
  this.#closeAllProviderSessions("dispose");
3841
3874
  this.setHindsightSessionState(undefined);
3842
3875
  hindsightState?.dispose();
@@ -3851,6 +3884,77 @@ export class AgentSession {
3851
3884
  }
3852
3885
  this.#eventListeners = [];
3853
3886
  this.#sessionChangeCallbacks.clear();
3887
+
3888
+ // A dispose triggered mid-turn (Ctrl-C / timeout / hard-killed subagent)
3889
+ // only *signals* the agent loop via the earlier abort(); the loop and the
3890
+ // session's fire-and-forget event handlers still unwind asynchronously.
3891
+ // Detach the response/SSE interceptors so a late frame cannot re-record
3892
+ // into rawSseDebugBuffer, then wait (bounded) for both the core run AND
3893
+ // the in-flight event/persistence handlers to settle — the latter can
3894
+ // still append the finished message/entries after agent.waitForIdle()
3895
+ // alone. Without this the release races the unwind and a disposed session
3896
+ // is repopulated with exactly the state we are trying to drop.
3897
+ this.agent.setProviderResponseInterceptor(undefined);
3898
+ this.agent.setRawSseEventInterceptor(undefined);
3899
+ let drained = false;
3900
+ try {
3901
+ await withTimeout(
3902
+ (async () => {
3903
+ await this.agent.waitForIdle();
3904
+ await this.#drainInFlightEventHandlers();
3905
+ })(),
3906
+ options.drainTimeoutMs ?? POST_PROMPT_DRAIN_TIMEOUT_MS,
3907
+ "Timed out waiting for the active agent run to settle during dispose",
3908
+ );
3909
+ drained = true;
3910
+ } catch (error) {
3911
+ logger.warn("Active agent run still settling at dispose deadline", { error: String(error) });
3912
+ }
3913
+
3914
+ // Event handlers can reopen the append writer while they persist their
3915
+ // terminal message; that pipeline has drained (or hit the deadline).
3916
+ // Raise the write barrier BEFORE the final close: a handler that
3917
+ // outlived the deadline could otherwise enqueue disk work behind the
3918
+ // closing tail while we await it, and that work would run against the
3919
+ // file after a revival reopens it. The seal also bumps the disk epoch,
3920
+ // superseding queued tail work and fencing already-running atomic
3921
+ // rewrites at their commit guard; hot-path appends drained above are
3922
+ // already durable, and close() (scheduled post-seal) still flushes and
3923
+ // closes the writer.
3924
+ this.sessionManager.seal();
3925
+ await this.sessionManager.close();
3926
+
3927
+ // Release retained conversation memory. dispose() is terminal, and every
3928
+ // revival path reopens the transcript from disk (AgentLifecycleManager
3929
+ // reviver / persisted-revive / `history://`), so the in-memory copy is
3930
+ // dead weight from here on. Dropping it lets a parked subagent's session
3931
+ // graph shed its heavy payloads even while the lifecycle adoption record's
3932
+ // reviver closure still references the session object. Fixes #8003.
3933
+ this.#releaseRetainedSessionMemory();
3934
+
3935
+ // The deadline does not cancel the drain: a handler parked in a slow
3936
+ // extension hook resumes afterwards and would repopulate exactly the
3937
+ // state released above. Its disk writes are already dead — the release
3938
+ // SEALED the session manager (a revival may reopen the same JSONL
3939
+ // through a new manager the moment dispose returns, and this manager
3940
+ // must never race that writer) — so re-run only the in-memory reset
3941
+ // once the pipeline genuinely settles. The extension runner bounds hook
3942
+ // runtime, so this deferred pass is not unbounded.
3943
+ if (!drained) {
3944
+ void (async () => {
3945
+ await this.agent.waitForIdle();
3946
+ await this.#drainInFlightEventHandlers();
3947
+ this.#releaseRetainedSessionMemory();
3948
+ })().catch(error => logger.warn("Deferred dispose finalization failed", { error: String(error) }));
3949
+ }
3950
+ }
3951
+
3952
+ /** Drop the in-memory conversation state after the terminal dispose flush. */
3953
+ #releaseRetainedSessionMemory(): void {
3954
+ this.agent.reset();
3955
+ this.agent.setAppendOnlyContext(undefined);
3956
+ this.rawSseDebugBuffer.clear();
3957
+ this.sessionManager.releaseRetainedEntries();
3854
3958
  }
3855
3959
 
3856
3960
  #closeAllProviderSessions(reason: string): void {
@@ -6219,6 +6323,9 @@ export class AgentSession {
6219
6323
  this.abortRetry();
6220
6324
  this.#promptGeneration++;
6221
6325
  this.#scheduledHiddenNextTurnGeneration = undefined;
6326
+ // Abort the handoff first so generic compaction cancellation cannot replace
6327
+ // the harness reason with an unreasoned "Handoff cancelled".
6328
+ this.#handoff.abortHandoff(new Error(options?.reason ?? "Handoff aborted by session"));
6222
6329
  if (options?.preserveCompaction) {
6223
6330
  // Manual `/compact` installed its own #compactionAbortController before
6224
6331
  // this internal abort and must keep it alive (that marker is what makes
@@ -6230,7 +6337,6 @@ export class AgentSession {
6230
6337
  } else {
6231
6338
  this.abortCompaction();
6232
6339
  }
6233
- this.abortHandoff();
6234
6340
  this.abortBash();
6235
6341
  this.abortEval();
6236
6342
  const postPromptDrain = this.#cancelPostPromptTasks();
@@ -120,8 +120,13 @@ export class IndexedSessionStorage implements SessionStorage {
120
120
  }
121
121
 
122
122
  async drain(): Promise<void> {
123
- while (this.#drainPending.size > 0) {
124
- await Promise.allSettled(this.#drainPending);
123
+ // Quiesce EVERY pending backend operation, not just the drain-tracked
124
+ // fire-and-forget publishes: an atomic write whose commit guard passed
125
+ // just before a terminal seal is still on the wire with
126
+ // `trackDrain: false`, and a graceful shutdown (SessionManager.close)
127
+ // must not return while it can still publish under a reopened path.
128
+ while (this.#drainPending.size > 0 || this.#pathPending.size > 0) {
129
+ await Promise.allSettled([...this.#drainPending, ...this.#pathPending.values()]);
125
130
  }
126
131
  const error = this.#firstDrainError;
127
132
  this.#firstDrainError = undefined;
@@ -1167,47 +1167,42 @@ export class SessionAdvisors {
1167
1167
  const failedMessage = failedMessages.findLast(
1168
1168
  (message): message is AssistantMessage => message.role === "assistant",
1169
1169
  );
1170
- if (failedMessage?.stopReason !== "error") {
1171
- // Stream setup can reject before any assistant turn is recorded (e.g.
1172
- // an HTTP 429 thrown from prompt()); classify the raw error so a
1173
- // structural usage limit still marks the exhausted credential.
1174
- const message = error instanceof Error ? error.message : String(error);
1175
- if (!AIError.isUsageLimit(error) && !isUsageLimitOutcome(extractHttpStatusFromError(error), message)) {
1176
- return false;
1177
- }
1178
- const currentModel = advisor.agent.state.model;
1179
- const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached(
1180
- currentModel.provider,
1181
- advisor.providerSessionId,
1182
- {
1183
- retryAfterMs: extractRetryHint(undefined, message),
1184
- baseUrl: currentModel.baseUrl,
1185
- modelId: currentModel.id,
1186
- signal,
1187
- },
1188
- );
1189
- return outcome.switched;
1190
- }
1191
- if (failedMessage.content.some(block => block.type === "toolCall")) return false;
1170
+ const assistantFailure = failedMessage?.stopReason === "error" ? failedMessage : undefined;
1171
+ if (assistantFailure?.content.some(block => block.type === "toolCall")) return false;
1192
1172
 
1193
1173
  const currentModel = advisor.agent.state.model;
1194
- const message = failedMessage.errorMessage ?? (error instanceof Error ? error.message : String(error));
1195
- const errorId = AIError.classifyMessage({
1196
- api: currentModel.api,
1197
- errorId: failedMessage.errorId,
1198
- errorMessage: message,
1199
- errorStatus: failedMessage.errorStatus,
1200
- });
1174
+ const message = assistantFailure?.errorMessage ?? (error instanceof Error ? error.message : String(error));
1175
+ const errorId = assistantFailure
1176
+ ? AIError.classifyMessage({
1177
+ api: currentModel.api,
1178
+ errorId: assistantFailure.errorId,
1179
+ errorMessage: message,
1180
+ errorStatus: assistantFailure.errorStatus,
1181
+ })
1182
+ : AIError.classify(error, currentModel.api);
1201
1183
  if (AIError.is(errorId, AIError.Flag.Abort) || AIError.is(errorId, AIError.Flag.UserInterrupt)) return false;
1202
- if (AIError.isContextOverflow(failedMessage, currentModel.contextWindow ?? 0)) return false;
1184
+ if (
1185
+ AIError.is(errorId, AIError.Flag.ContextOverflow) ||
1186
+ (assistantFailure && AIError.isContextOverflow(assistantFailure, currentModel.contextWindow ?? 0))
1187
+ ) {
1188
+ return false;
1189
+ }
1203
1190
 
1204
- const currentSelector = formatRetryFallbackSelector(currentModel, advisor.thinkingLevel);
1191
+ const accountPolicyDenial = AIError.is(errorId, AIError.Flag.AccountPolicy);
1192
+ if (accountPolicyDenial) {
1193
+ const switched = await this.#host.modelRegistry.authStorage.rotateSessionCredential(
1194
+ currentModel.provider,
1195
+ advisor.providerSessionId,
1196
+ { error: message, modelId: currentModel.id, signal },
1197
+ );
1198
+ if (switched) return true;
1199
+ }
1205
1200
 
1206
1201
  const retryAfterMs = extractRetryHint(undefined, message);
1207
- if (
1202
+ const usageLimit =
1208
1203
  AIError.is(errorId, AIError.Flag.UsageLimit) ||
1209
- isUsageLimitOutcome(extractHttpStatusFromError(error), message)
1210
- ) {
1204
+ isUsageLimitOutcome(extractHttpStatusFromError(error), message);
1205
+ if (usageLimit) {
1211
1206
  const outcome = await this.#host.modelRegistry.authStorage.markUsageLimitReached(
1212
1207
  currentModel.provider,
1213
1208
  advisor.providerSessionId,
@@ -1220,6 +1215,9 @@ export class SessionAdvisors {
1220
1215
  );
1221
1216
  if (outcome.switched) return true;
1222
1217
  }
1218
+ if (!assistantFailure && !accountPolicyDenial && !usageLimit) return false;
1219
+
1220
+ const currentSelector = formatRetryFallbackSelector(currentModel, advisor.thinkingLevel);
1223
1221
 
1224
1222
  const retrySettings = this.#host.settings.getGroup("retry");
1225
1223
  if (!retrySettings.enabled || !retrySettings.modelFallback) return false;
@@ -14,7 +14,8 @@ import { logger, Snowflake } from "@oh-my-pi/pi-utils";
14
14
  import type { ModelRegistry } from "../config/model-registry";
15
15
  import type { Settings } from "../config/settings";
16
16
  import type { ExtensionRunner, SessionBeforeSwitchResult } from "../extensibility/extensions";
17
- import { obfuscateProviderContext, type SecretObfuscator } from "../secrets/obfuscator";
17
+ import { obfuscateProviderContext } from "../secrets/message-transform";
18
+ import type { SecretObfuscator } from "../secrets/obfuscator";
18
19
  import type { HandoffResult, SessionHandoffOptions } from "./agent-session-types";
19
20
  import type { BashSessionTransition } from "./bash-runner";
20
21
  import type { SessionContext } from "./session-context";
@@ -29,6 +30,17 @@ function createHandoffFileName(date = new Date()): string {
29
30
  return `handoff-${fileTimestamp}.md`;
30
31
  }
31
32
 
33
+ function throwIfHandoffAborted(signal: AbortSignal): void {
34
+ if (!signal.aborted) return;
35
+ const reason = signal.reason;
36
+ if (reason instanceof DOMException && reason.name === "AbortError") {
37
+ throw new Error("Handoff cancelled");
38
+ }
39
+ if (reason instanceof Error) throw reason;
40
+ if (typeof reason === "string" && reason.length > 0) throw new Error(reason);
41
+ throw new Error("Handoff aborted by session");
42
+ }
43
+
32
44
  /** Capabilities borrowed from the owning AgentSession. */
33
45
  export interface SessionHandoffHost {
34
46
  agent: Agent;
@@ -80,10 +92,10 @@ export class SessionHandoff {
80
92
  this.#host = host;
81
93
  }
82
94
  /**
83
- * Cancel in-progress handoff generation.
95
+ * Cancel in-progress handoff generation, preserving a harness-provided reason.
84
96
  */
85
- abortHandoff(): void {
86
- this.#handoffAbortController?.abort();
97
+ abortHandoff(reason?: Error): void {
98
+ this.#handoffAbortController?.abort(reason);
87
99
  }
88
100
 
89
101
  /**
@@ -117,7 +129,7 @@ export class SessionHandoff {
117
129
  const sourceSignal = options?.signal;
118
130
  const onSourceAbort = () => {
119
131
  if (!handoffSignal.aborted) {
120
- handoffAbortController.abort();
132
+ handoffAbortController.abort(sourceSignal?.reason);
121
133
  }
122
134
  };
123
135
  if (sourceSignal) {
@@ -130,9 +142,7 @@ export class SessionHandoff {
130
142
  let advisorRecordersDetached = false;
131
143
  let sessionTransitioned = false;
132
144
  try {
133
- if (handoffSignal.aborted) {
134
- throw new Error("Handoff cancelled");
135
- }
145
+ throwIfHandoffAborted(handoffSignal);
136
146
 
137
147
  const model = this.#host.model();
138
148
  if (!model) {
@@ -207,11 +217,23 @@ export class SessionHandoff {
207
217
  );
208
218
  const handoffText = this.#host.deobfuscateFromProvider(rawHandoffText);
209
219
 
210
- if (handoffSignal.aborted) {
211
- throw new Error("Handoff cancelled");
212
- }
213
- if (!handoffText) {
214
- return undefined;
220
+ throwIfHandoffAborted(handoffSignal);
221
+ if (!handoffText || handoffText.trim().length === 0) {
222
+ // Empty/whitespace-only generation is a real failure, not a user
223
+ // cancellation. #7904 stopped masking provider errors as "Handoff
224
+ // cancelled"; an empty document is the remaining path that produced the
225
+ // same misleading, undebuggable message (#7993).
226
+ logger.warn("Handoff generation produced no content", {
227
+ sessionId: this.#host.sessionId(),
228
+ autoTriggered: options?.autoTriggered ?? false,
229
+ });
230
+ // Auto-handoff is best-effort: returning undefined lets maintenance fall
231
+ // back to context-full compaction. A user-initiated handoff must surface
232
+ // the failure instead of a silent, misleading "cancelled".
233
+ if (options?.autoTriggered) {
234
+ return undefined;
235
+ }
236
+ throw new Error("Handoff generation produced no content");
215
237
  }
216
238
 
217
239
  // Start a new session
@@ -309,13 +331,10 @@ export class SessionHandoff {
309
331
 
310
332
  return { document: handoffText, savedPath };
311
333
  } catch (error) {
312
- // Only a genuine abort (user Esc or the source turn cancelling) is a
313
- // cancellation. A provider that throws a name==="AbortError" error without the
314
- // handoff signal being aborted (stall/idle timeout, nested resolution failure)
315
- // is a real failure and must surface verbatim, not be masked as "cancelled".
316
- if (handoffSignal.aborted) {
317
- throw new Error("Handoff cancelled");
318
- }
334
+ // Only a genuine cancellation (user Esc or an unreasoned source-signal
335
+ // abort) maps to "Handoff cancelled". A harness-provided abort reason and
336
+ // provider failures surface verbatim.
337
+ throwIfHandoffAborted(handoffSignal);
319
338
  throw error;
320
339
  } finally {
321
340
  if (advisorRecordersDetached) {
@@ -468,6 +468,8 @@ export class SessionManager {
468
468
 
469
469
  /** The single open append writer; the manager only ever writes one file at a time. */
470
470
  #writer: SessionStorageWriter | undefined;
471
+ /** Sealed by {@link releaseRetainedEntries}: every later append/title/rewrite is a dropped no-op. */
472
+ #released = false;
471
473
  /** Serializes async disk work (flush/close/atomic rewrite). Appends are synchronous and bypass it. */
472
474
  #diskTail: Promise<void> = Promise.resolve();
473
475
  #diskFailure: Error | undefined;
@@ -641,6 +643,16 @@ export class SessionManager {
641
643
  }
642
644
 
643
645
  async #authoritativelyRewriteCurrentStateLocked(operationError: Error): Promise<void> {
646
+ if (this.#released) {
647
+ // Terminal seal: repair would reset the disk tail (escaping the
648
+ // close() serialization) and atomically publish #fileBody() — after
649
+ // release that truncates, and a revival may already own the file.
650
+ // The original operation error still propagates to the caller.
651
+ logger.warn("Skipped authoritative session repair after terminal release", {
652
+ error: String(operationError),
653
+ });
654
+ return;
655
+ }
644
656
  if (!this.#persist || !this.#sessionFile) return;
645
657
  const previousDiskTail = this.#diskTail;
646
658
  const writer = this.#writer;
@@ -687,7 +699,7 @@ export class SessionManager {
687
699
  const body = this.#fileBody();
688
700
  try {
689
701
  await this.#storage.writeTextAtomic(sessionFile, body, {
690
- commitGuard: () => this.#diskEpoch === epoch,
702
+ commitGuard: () => !this.#released && this.#diskEpoch === epoch,
691
703
  });
692
704
  } catch (error) {
693
705
  const recoveryErrors = [toError(error)];
@@ -791,6 +803,7 @@ export class SessionManager {
791
803
  * concurrent completed entries are durable without recreating a vacated source.
792
804
  */
793
805
  #rewriteSynchronously(): void {
806
+ if (this.#released) return;
794
807
  if (!this.#persist || !this.#shouldHaveSessionFile()) return;
795
808
  const targetPath = this.#liveRelocationWritePath() ?? this.#sessionFile;
796
809
  if (!targetPath) return;
@@ -833,6 +846,7 @@ export class SessionManager {
833
846
  */
834
847
  async #rewriteAtomically(): Promise<void> {
835
848
  if (!this.#persist || !this.#sessionFile) return;
849
+ if (this.#released) return;
836
850
 
837
851
  const startEpoch = this.#diskEpoch;
838
852
  await this.#scheduleDiskWork(
@@ -858,6 +872,7 @@ export class SessionManager {
858
872
  * their post-publish state updates.
859
873
  */
860
874
  async #runFencedAtomicRewrite(epoch: number): Promise<boolean> {
875
+ if (this.#released) return false;
861
876
  this.#atomicRewriteFenceEpoch = epoch;
862
877
  try {
863
878
  do {
@@ -867,7 +882,7 @@ export class SessionManager {
867
882
  if (!sessionFile) return false;
868
883
  if (this.#diskEpoch !== epoch) return false;
869
884
  await this.#storage.writeTextAtomic(sessionFile, this.#fileBody(), {
870
- commitGuard: () => this.#diskEpoch === epoch,
885
+ commitGuard: () => !this.#released && this.#diskEpoch === epoch,
871
886
  });
872
887
  if (this.#diskEpoch !== epoch) return false;
873
888
  } while (this.#atomicRewriteDirty);
@@ -884,7 +899,7 @@ export class SessionManager {
884
899
  }
885
900
 
886
901
  #appendToSessionFile(entry: SessionEntry): void {
887
- if (!this.#persist || !this.#sessionFile) return;
902
+ if (this.#released || !this.#persist || !this.#sessionFile) return;
888
903
  if (this.#atomicEntryBatch) {
889
904
  this.#fileIsCurrent = false;
890
905
  this.#rewriteRequired = true;
@@ -979,6 +994,7 @@ export class SessionManager {
979
994
  const line = this.#lineFor(entry);
980
995
  await this.#scheduleDiskWork(
981
996
  async () => {
997
+ if (this.#released) return;
982
998
  const sessionFile = this.#sessionFile;
983
999
  if (!sessionFile) return;
984
1000
  try {
@@ -1093,6 +1109,10 @@ export class SessionManager {
1093
1109
  }
1094
1110
 
1095
1111
  #recordEntry(entry: SessionEntry): void {
1112
+ if (this.#released) {
1113
+ logger.warn("Dropped session entry appended after terminal release", { type: entry.type });
1114
+ return;
1115
+ }
1096
1116
  this.#entries.push(entry);
1097
1117
  this.#index.insert(entry);
1098
1118
  const batch = this.#atomicEntryBatch;
@@ -1707,6 +1727,49 @@ export class SessionManager {
1707
1727
  if (this.#diskFailure) throw this.#diskFailure;
1708
1728
  }
1709
1729
 
1730
+ /**
1731
+ * Raise the terminal write barrier ahead of the final {@link close}. Once
1732
+ * sealed:
1733
+ * - every later append, title change, and rewrite is a dropped no-op —
1734
+ * including work an event handler tries to enqueue while dispose is
1735
+ * awaiting `close()` on the disk tail;
1736
+ * - the disk epoch is bumped, so queued-but-unexecuted tail work is
1737
+ * superseded and an ALREADY-RUNNING fenced/repair rewrite (awaiting the
1738
+ * tail, drain, writer close, or the atomic stage) fails its commit guard
1739
+ * at the rename fence instead of publishing over a revived file.
1740
+ * The final `close()` itself is scheduled after the bump and still runs;
1741
+ * pre-seal hot-path appends are already in the page cache. Idempotent;
1742
+ * terminal.
1743
+ */
1744
+ seal(): void {
1745
+ if (this.#released) return;
1746
+ this.#released = true;
1747
+ this.#diskEpoch++;
1748
+ }
1749
+
1750
+ /**
1751
+ * Terminal release: drop the in-memory transcript and complete the
1752
+ * {@link seal}. The entry journal and its index mirror the agent's message
1753
+ * array (tool results, file contents, base64 frame images); on a disposed
1754
+ * session — e.g. a parked subagent still referenced by the lifecycle
1755
+ * adoption record — they would otherwise stay pinned for the process
1756
+ * lifetime.
1757
+ *
1758
+ * Closes the append writer; with the seal up, nothing can reopen it. A
1759
+ * revival may reopen the same JSONL through a NEW manager the moment
1760
+ * dispose returns; a late event handler resuming on THIS manager must
1761
+ * never race that writer — and a post-release rewrite would persist the
1762
+ * now-empty entry list, truncating the transcript. Reads after this point
1763
+ * reopen from disk (revival, `history://`). Only call from session
1764
+ * dispose, after the final `close()`; idempotent.
1765
+ */
1766
+ releaseRetainedEntries(): void {
1767
+ this.seal();
1768
+ this.#entries = [];
1769
+ this.#index.clear();
1770
+ this.#closeWriterEventually();
1771
+ }
1772
+
1710
1773
  getCwd(): string {
1711
1774
  return this.#cwd;
1712
1775
  }
@@ -1924,6 +1987,7 @@ export class SessionManager {
1924
1987
  * Auto titles are ignored once the user has set a name.
1925
1988
  */
1926
1989
  async setSessionName(name: string, source: SessionTitleSource = "auto", trigger?: string): Promise<boolean> {
1990
+ if (this.#released) return false;
1927
1991
  if (this.#titleSource === "user" && source === "auto") return false;
1928
1992
 
1929
1993
  const title = SessionManager.#cleanTitle(name);
@@ -10,12 +10,9 @@ import { formatModelString } from "../config/model-resolver";
10
10
  import type { Settings } from "../config/settings";
11
11
  import { validateProviderMaxInFlightRequests } from "../config/settings";
12
12
  import type { LocalProtocolOptions } from "../internal-urls";
13
- import {
14
- deobfuscateSessionContext,
15
- obfuscateMessages,
16
- type SecretObfuscator,
17
- stripPendingSecretPlaceholderSuffix,
18
- } from "../secrets/obfuscator";
13
+ import { deobfuscateSessionContext, obfuscateMessages } from "../secrets/message-transform";
14
+ import type { SecretObfuscator } from "../secrets/obfuscator";
15
+ import { stripPendingSecretPlaceholderSuffix } from "../secrets/placeholder";
19
16
  import { normalizeModelContextImages } from "../utils/image-loading";
20
17
  import { describeAttachedImagesForTextModel } from "../utils/image-vision-fallback";
21
18
  import { type CustomMessage, convertToLlm } from "./messages";
@@ -924,9 +924,9 @@ export class TurnRecovery {
924
924
  }
925
925
 
926
926
  /**
927
- * Check if an error is retryable (transient errors or usage limits).
927
+ * Check if an error is retryable (transient errors, usage limits, or
928
+ * account-scoped policy denials that can rotate credentials).
928
929
  * Context overflow is NOT retryable (handled by compaction instead).
929
- * Usage-limit errors are retryable because the retry handler performs credential switching.
930
930
  */
931
931
  isRetryableError(message: AssistantMessage): boolean {
932
932
  if (message.stopReason !== "error") return false;
@@ -937,12 +937,11 @@ export class TurnRecovery {
937
937
  const contextWindow = this.#host.model()?.contextWindow ?? 0;
938
938
  if (AIError.isContextOverflow(message, contextWindow)) return false;
939
939
 
940
- // A classifier refusal/sensitivity stop is the model's decision, not a route
941
- // failure, but only after we confirm no replay-unsafe output has already
942
- // streamed. Committed text, images, tool calls, or server tools must not be
943
- // discarded and replayed.
940
+ // Credential rotation and classifier fallbacks are safe only before
941
+ // committed text, images, tool calls, or server tools. Thinking-only
942
+ // output remains replay-safe.
944
943
  if (this.#hasReplayUnsafeOutput(message)) return false;
945
- if (this.isClassifierRefusal(message)) return true;
944
+ if (AIError.is(id, AIError.Flag.AccountPolicy) || this.isClassifierRefusal(message)) return true;
946
945
  return AIError.retriable(id);
947
946
  }
948
947
 
@@ -1603,6 +1602,7 @@ export class TurnRecovery {
1603
1602
  const id = this.#classifyRetryMessage(message);
1604
1603
  const rateLimitReason = parseRateLimitReason(errorMessage);
1605
1604
  const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
1605
+ const accountPolicyDenial = AIError.is(id, AIError.Flag.AccountPolicy);
1606
1606
  const recordedUsageLimitOutcome = await this.#usageLimitOutcomes.get(message);
1607
1607
  const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
1608
1608
  let delayMs = staleOpenAIResponsesReplayError
@@ -1672,6 +1672,14 @@ export class TurnRecovery {
1672
1672
  const currentSelector = currentModel
1673
1673
  ? formatRetryFallbackSelector(currentModel, this.#host.thinkingLevel())
1674
1674
  : undefined;
1675
+ if (accountPolicyDenial && currentModel) {
1676
+ switchedCredential = await this.#host.modelRegistry.authStorage.rotateSessionCredential(
1677
+ currentModel.provider,
1678
+ this.#host.sessionId(),
1679
+ { error: errorMessage, modelId: currentModel.id },
1680
+ );
1681
+ if (switchedCredential) delayMs = 0;
1682
+ }
1675
1683
  if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
1676
1684
  // A refusal chain stops at the retry budget: the exhausted-attempt
1677
1685
  // last resort is for provider failures, not classifier decisions.
@@ -1696,7 +1704,7 @@ export class TurnRecovery {
1696
1704
  }
1697
1705
 
1698
1706
  if (retryBudgetExhausted) {
1699
- if (!switchedModel) {
1707
+ if (!switchedModel && !switchedCredential) {
1700
1708
  const attempt = this.#retryAttempt - 1;
1701
1709
  message.errorMessage = `Retry budget exhausted after ${attempt} ${attempt === 1 ? "retry" : "retries"}: ${errorMessage}`;
1702
1710
  await this.persistTerminalEmptyErrorTurn(message);
@@ -1713,11 +1721,12 @@ export class TurnRecovery {
1713
1721
  this.resolveRetry(); // Resolve so waitForRetry() completes
1714
1722
  return false;
1715
1723
  }
1716
- // The fallback model gets a fresh retry budget leaving the spent
1717
- // counter in place would exhaust it again on its first error.
1718
- this.#retryAttempt = 1;
1724
+ // A fallback model gets a fresh retry budget. Credential rotation
1725
+ // instead keeps the cumulative attempt count while bypassing the
1726
+ // same-route budget: every distinct account must be tried first.
1727
+ if (switchedModel) this.#retryAttempt = 1;
1719
1728
  }
1720
- if (classifierRefusal && !switchedModel) {
1729
+ if ((classifierRefusal || accountPolicyDenial) && !switchedCredential && !switchedModel) {
1721
1730
  // A prior attempt in this saga already announced `auto_retry_start`
1722
1731
  // (retryAttempt was incremented for each call to this method, so > 1
1723
1732
  // means at least one earlier attempt started the loop) but this