@oh-my-pi/pi-coding-agent 17.2.10 → 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 (247) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/{CHANGELOG-rhwzpexa.md → CHANGELOG-k9ghy5sn.md} +53 -0
  3. package/dist/cli.js +10775 -10762
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/capability/mcp.d.ts +11 -0
  6. package/dist/types/capability/skill.d.ts +6 -0
  7. package/dist/types/cli/command-help.d.ts +3 -0
  8. package/dist/types/commands/share.d.ts +25 -0
  9. package/dist/types/commit/agentic/index.d.ts +3 -1
  10. package/dist/types/commit/execute.d.ts +32 -0
  11. package/dist/types/commit/index.d.ts +2 -1
  12. package/dist/types/commit/pipeline.d.ts +7 -1
  13. package/dist/types/config/custom-models.d.ts +31 -0
  14. package/dist/types/config/model-config-values.d.ts +19 -0
  15. package/dist/types/config/model-patch.d.ts +93 -0
  16. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  17. package/dist/types/config/model-registry.d.ts +9 -52
  18. package/dist/types/config/settings-schema.d.ts +1 -34
  19. package/dist/types/config/settings.d.ts +5 -3
  20. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  21. package/dist/types/discovery/agent-plugin-format.d.ts +136 -0
  22. package/dist/types/discovery/agent-plugins.d.ts +1 -0
  23. package/dist/types/discovery/contained-path.d.ts +33 -0
  24. package/dist/types/discovery/index.d.ts +1 -0
  25. package/dist/types/eval/executor-base.d.ts +8 -2
  26. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  27. package/dist/types/export/share.d.ts +1 -1
  28. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  29. package/dist/types/extensibility/extensions/loader.d.ts +7 -0
  30. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  31. package/dist/types/extensibility/shared-events.d.ts +3 -2
  32. package/dist/types/extensibility/skills.d.ts +5 -0
  33. package/dist/types/lsp/diagnostics.d.ts +96 -0
  34. package/dist/types/lsp/index.d.ts +7 -128
  35. package/dist/types/lsp/servers.d.ts +72 -0
  36. package/dist/types/lsp/tool.d.ts +42 -0
  37. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  38. package/dist/types/lsp/writethrough.d.ts +33 -0
  39. package/dist/types/mcp/transports/header-policy.d.ts +46 -0
  40. package/dist/types/mcp/types.d.ts +15 -0
  41. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  42. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  43. package/dist/types/modes/components/agent-hub.d.ts +2 -0
  44. package/dist/types/modes/components/agent-transcript-viewer.d.ts +2 -0
  45. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  46. package/dist/types/modes/components/custom-editor.d.ts +1 -2
  47. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  48. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  49. package/dist/types/modes/theme/color.d.ts +19 -0
  50. package/dist/types/modes/theme/loader.d.ts +19 -0
  51. package/dist/types/modes/theme/schema.d.ts +175 -0
  52. package/dist/types/modes/theme/symbols.d.ts +28 -0
  53. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  54. package/dist/types/modes/theme/theme.d.ts +9 -280
  55. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  56. package/dist/types/modes/utils/transcript-render-helpers.d.ts +3 -2
  57. package/dist/types/registry/agent-registry.d.ts +1 -3
  58. package/dist/types/secrets/index.d.ts +26 -2
  59. package/dist/types/secrets/message-transform.d.ts +40 -0
  60. package/dist/types/secrets/obfuscator.d.ts +0 -108
  61. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  62. package/dist/types/secrets/placeholder.d.ts +94 -0
  63. package/dist/types/secrets/replacement.d.ts +82 -0
  64. package/dist/types/session/agent-session-events.d.ts +2 -2
  65. package/dist/types/session/agent-session-types.d.ts +6 -0
  66. package/dist/types/session/agent-session.d.ts +2 -2
  67. package/dist/types/session/messages.d.ts +1 -0
  68. package/dist/types/session/prewalk.d.ts +3 -1
  69. package/dist/types/session/session-handoff.d.ts +3 -3
  70. package/dist/types/session/session-manager.d.ts +32 -0
  71. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  72. package/dist/types/session/session-tools.d.ts +8 -0
  73. package/dist/types/session/turn-recovery.d.ts +9 -4
  74. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  75. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  76. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  77. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  78. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  79. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  80. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  81. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  82. package/dist/types/task/index.d.ts +2 -0
  83. package/dist/types/tools/gh-common.d.ts +69 -0
  84. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  85. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  86. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  87. package/dist/types/tools/gh-search.d.ts +61 -0
  88. package/dist/types/tools/gh-types.d.ts +340 -0
  89. package/dist/types/tools/gh-view.d.ts +88 -0
  90. package/dist/types/tools/gh.d.ts +4 -201
  91. package/dist/types/tools/read-archive.d.ts +16 -0
  92. package/dist/types/tools/read-format.d.ts +104 -0
  93. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  94. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  95. package/dist/types/tools/read-renderer.d.ts +24 -0
  96. package/dist/types/tools/read-selector.d.ts +27 -0
  97. package/dist/types/tools/read-sqlite.d.ts +16 -0
  98. package/dist/types/tools/read-summary.d.ts +19 -0
  99. package/dist/types/tools/read.d.ts +1 -23
  100. package/dist/types/tools/shell-tokenize.d.ts +18 -1
  101. package/dist/types/utils/changelog.d.ts +0 -5
  102. package/package.json +13 -13
  103. package/scripts/legacy-pi-virtual-module.ts +4 -1
  104. package/src/advisor/runtime.ts +12 -7
  105. package/src/capability/mcp.ts +11 -0
  106. package/src/capability/skill.ts +6 -0
  107. package/src/cli/command-help.ts +4 -0
  108. package/src/cli/gallery-cli.ts +1 -1
  109. package/src/cli-commands.ts +5 -0
  110. package/src/commands/commit.ts +16 -3
  111. package/src/commands/share.ts +71 -0
  112. package/src/commit/agentic/index.ts +39 -21
  113. package/src/commit/execute.ts +56 -0
  114. package/src/commit/index.ts +2 -1
  115. package/src/commit/pipeline.ts +20 -7
  116. package/src/config/custom-models.ts +188 -0
  117. package/src/config/model-config-values.ts +128 -0
  118. package/src/config/model-discovery.ts +1 -1
  119. package/src/config/model-patch.ts +252 -0
  120. package/src/config/model-provider-discovery.ts +132 -0
  121. package/src/config/model-registry.ts +84 -704
  122. package/src/config/settings-schema.ts +2 -33
  123. package/src/config/settings.ts +53 -3
  124. package/src/debug/raw-sse-buffer.ts +20 -0
  125. package/src/discovery/agent-plugin-format.ts +551 -0
  126. package/src/discovery/agent-plugins.ts +341 -0
  127. package/src/discovery/claude-plugins.ts +21 -5
  128. package/src/discovery/contained-path.ts +75 -0
  129. package/src/discovery/helpers.ts +23 -9
  130. package/src/discovery/index.ts +1 -0
  131. package/src/discovery/omp-plugins.ts +20 -7
  132. package/src/edit/hashline/diff.ts +5 -1
  133. package/src/eval/executor-base.ts +39 -6
  134. package/src/eval/jl/executor.ts +82 -371
  135. package/src/eval/kernel-session-registry.ts +398 -0
  136. package/src/eval/py/executor.ts +53 -261
  137. package/src/eval/rb/executor.ts +36 -279
  138. package/src/exec/non-interactive-env.ts +1 -0
  139. package/src/export/share.ts +2 -1
  140. package/src/extensibility/custom-tools/types.ts +2 -2
  141. package/src/extensibility/extensions/loader.ts +78 -14
  142. package/src/extensibility/extensions/runner.ts +6 -0
  143. package/src/extensibility/extensions/types.ts +12 -0
  144. package/src/extensibility/plugins/marketplace/manager.ts +2 -1
  145. package/src/extensibility/shared-events.ts +3 -2
  146. package/src/extensibility/skills.ts +9 -0
  147. package/src/internal-urls/skill-protocol.ts +14 -0
  148. package/src/internal-urls/vault-protocol.ts +2 -2
  149. package/src/launch/client.ts +11 -1
  150. package/src/launch/protocol.ts +7 -2
  151. package/src/lsp/diagnostics.ts +516 -0
  152. package/src/lsp/index.ts +20 -2819
  153. package/src/lsp/servers.ts +296 -0
  154. package/src/lsp/tool.ts +1352 -0
  155. package/src/lsp/workspace-diagnostics.ts +170 -0
  156. package/src/lsp/writethrough.ts +561 -0
  157. package/src/main.ts +3 -2
  158. package/src/mcp/config.ts +3 -0
  159. package/src/mcp/manager.ts +12 -9
  160. package/src/mcp/transports/header-policy.ts +95 -0
  161. package/src/mcp/transports/http.ts +35 -52
  162. package/src/mcp/transports/sse.ts +20 -27
  163. package/src/mcp/types.ts +15 -0
  164. package/src/modes/acp/acp-agent.ts +15 -4
  165. package/src/modes/components/agent-dashboard.ts +2 -0
  166. package/src/modes/components/agent-hub-projection.ts +2 -2
  167. package/src/modes/components/agent-hub-renderer.ts +3 -7
  168. package/src/modes/components/agent-hub.ts +5 -0
  169. package/src/modes/components/agent-transcript-viewer.ts +3 -0
  170. package/src/modes/components/chat-transcript-builder.ts +3 -0
  171. package/src/modes/components/custom-editor.ts +0 -9
  172. package/src/modes/components/status-line/component.ts +1 -0
  173. package/src/modes/components/status-line/segments.ts +6 -4
  174. package/src/modes/components/status-line/types.ts +2 -0
  175. package/src/modes/components/tool-execution.ts +13 -16
  176. package/src/modes/components/tree-selector.ts +62 -3
  177. package/src/modes/controllers/command-controller.ts +8 -2
  178. package/src/modes/controllers/event-controller.ts +15 -15
  179. package/src/modes/controllers/input-controller.ts +20 -2
  180. package/src/modes/controllers/selector-controller.ts +1 -0
  181. package/src/modes/theme/color.ts +133 -0
  182. package/src/modes/theme/loader.ts +178 -0
  183. package/src/modes/theme/schema.ts +263 -0
  184. package/src/modes/theme/symbols.ts +1000 -0
  185. package/src/modes/theme/theme-class.ts +611 -0
  186. package/src/modes/theme/theme.ts +27 -2453
  187. package/src/modes/theme/tui-adapters.ts +279 -0
  188. package/src/modes/utils/transcript-render-helpers.ts +4 -2
  189. package/src/modes/utils/ui-helpers.ts +1 -0
  190. package/src/registry/agent-registry.ts +2 -2
  191. package/src/sdk.ts +6 -49
  192. package/src/secrets/index.ts +51 -6
  193. package/src/secrets/message-transform.ts +287 -0
  194. package/src/secrets/obfuscator.ts +39 -1303
  195. package/src/secrets/placeholder-scan.ts +506 -0
  196. package/src/secrets/placeholder.ts +309 -0
  197. package/src/secrets/replacement.ts +216 -0
  198. package/src/session/agent-session-events.ts +2 -2
  199. package/src/session/agent-session-types.ts +6 -0
  200. package/src/session/agent-session.ts +146 -21
  201. package/src/session/indexed-session-storage.ts +7 -2
  202. package/src/session/messages.ts +1 -0
  203. package/src/session/prewalk.ts +57 -17
  204. package/src/session/session-advisors.ts +32 -34
  205. package/src/session/session-context.ts +3 -5
  206. package/src/session/session-handoff.ts +39 -16
  207. package/src/session/session-manager.ts +67 -3
  208. package/src/session/session-provider-boundary.ts +3 -6
  209. package/src/session/session-tools.ts +37 -3
  210. package/src/session/todo-tracker.ts +11 -1
  211. package/src/session/turn-recovery.ts +115 -84
  212. package/src/slash-commands/builtin-collaboration.ts +504 -0
  213. package/src/slash-commands/builtin-completions.ts +291 -0
  214. package/src/slash-commands/builtin-control.ts +78 -0
  215. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  216. package/src/slash-commands/builtin-marketplace.ts +567 -0
  217. package/src/slash-commands/builtin-modes.ts +518 -0
  218. package/src/slash-commands/builtin-registry.ts +21 -2996
  219. package/src/slash-commands/builtin-session.ts +592 -0
  220. package/src/task/executor.ts +17 -14
  221. package/src/task/index.ts +21 -6
  222. package/src/tools/bash-skill-urls.ts +15 -0
  223. package/src/tools/bash.ts +10 -11
  224. package/src/tools/debug.ts +1 -1
  225. package/src/tools/gh-common.ts +288 -0
  226. package/src/tools/gh-pr-checkout.ts +679 -0
  227. package/src/tools/gh-pr-diff.ts +473 -0
  228. package/src/tools/gh-run-watch.ts +1015 -0
  229. package/src/tools/gh-search.ts +500 -0
  230. package/src/tools/gh-types.ts +379 -0
  231. package/src/tools/gh-view.ts +612 -0
  232. package/src/tools/gh.ts +109 -3821
  233. package/src/tools/jtd-to-json-schema.ts +123 -21
  234. package/src/tools/read-archive.ts +209 -0
  235. package/src/tools/read-format.ts +593 -0
  236. package/src/tools/read-path-resolution.ts +36 -0
  237. package/src/tools/read-pdf-images.ts +250 -0
  238. package/src/tools/read-renderer.ts +289 -0
  239. package/src/tools/read-selector.ts +84 -0
  240. package/src/tools/read-sqlite.ts +215 -0
  241. package/src/tools/read-summary.ts +199 -0
  242. package/src/tools/read.ts +82 -1824
  243. package/src/tools/shell-tokenize.ts +99 -1
  244. package/src/utils/changelog.ts +1 -1
  245. package/src/utils/title-generator.ts +3 -1
  246. package/src/web/search/providers/exa.ts +1 -1
  247. 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,
@@ -304,7 +304,7 @@ import {
304
304
  USER_INTERRUPT_LABEL,
305
305
  } from "./messages";
306
306
  import { ModelControls, type ModelControlsHost } from "./model-controls";
307
- import { PrewalkCoordinator, type PrewalkCoordinatorHost } from "./prewalk";
307
+ import { isPrewalkPlanNudge, PrewalkCoordinator, type PrewalkCoordinatorHost } from "./prewalk";
308
308
  import {
309
309
  isAdvisorCard,
310
310
  isDisplayableQueuedMessage,
@@ -881,8 +881,8 @@ export class AgentSession {
881
881
  /**
882
882
  * Arm prewalk outside the normal startup path so an explicit slash command starts immediately.
883
883
  */
884
- armPrewalk(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void {
885
- this.#prewalk.arm(target, thinkingLevel);
884
+ armPrewalk(target: Model, thinkingLevel?: ConfiguredThinkingLevel): boolean {
885
+ return this.#prewalk.arm(target, thinkingLevel);
886
886
  }
887
887
 
888
888
  /** Validate the active plan artifact and shape an `xd://propose` result for review-mode hosts. */
@@ -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) {
@@ -2489,14 +2524,17 @@ export class AgentSession {
2489
2524
  const persistMessageEnd = () => {
2490
2525
  // Check if this is a hook/custom message
2491
2526
  if (event.message.role === "hookMessage" || event.message.role === "custom") {
2492
- // Persist as CustomMessageEntry
2493
- this.sessionManager.appendCustomMessageEntry(
2494
- event.message.customType,
2495
- event.message.content,
2496
- event.message.display,
2497
- event.message.details,
2498
- event.message.attribution ?? "agent",
2499
- );
2527
+ // Prewalk's plan nudge is a one-run steering instruction. Persisting it would
2528
+ // resurrect the consumed prompt on resume, fork, or any context rebuild.
2529
+ if (!isPrewalkPlanNudge(event.message)) {
2530
+ this.sessionManager.appendCustomMessageEntry(
2531
+ event.message.customType,
2532
+ event.message.content,
2533
+ event.message.display,
2534
+ event.message.details,
2535
+ event.message.attribution ?? "agent",
2536
+ );
2537
+ }
2500
2538
  if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
2501
2539
  this.#ttsr.markInjectedFromDetails(event.message.details);
2502
2540
  }
@@ -2645,7 +2683,7 @@ export class AgentSession {
2645
2683
  // here after maintenance routing, tagged isTerminal so subscribers can
2646
2684
  // tell final settles from scheduled continuations.
2647
2685
  await this.#emitSessionEvent({ ...event, isTerminal: !options?.willContinue });
2648
- void this.#emitAgentEndNotification(activeMessages, options).catch(err => {
2686
+ void this.#emitAgentEndNotification([...activeMessages], options).catch(err => {
2649
2687
  logger.error("Agent end extension notification failed", { err });
2650
2688
  });
2651
2689
  };
@@ -3017,11 +3055,23 @@ export class AgentSession {
3017
3055
  }
3018
3056
  this.#beginInFlight();
3019
3057
  try {
3020
- await this.#recovery.maybeRestoreRetryFallbackPrimary();
3058
+ const reverted = await this.#recovery.maybeRestoreRetryFallbackPrimary();
3021
3059
  if (signal.aborted || this.#isDisposed) {
3022
3060
  this.#skipAgentContinue("post-restore-unavailable", options);
3023
3061
  return;
3024
3062
  }
3063
+ // A cooldown-expiry revert can drop the active window below the
3064
+ // accumulated context. The user-prompt path re-checks context after
3065
+ // the revert via runPrePromptCompactionIfNeeded; the auto-continue
3066
+ // path must do the same so agent.continue() never sends a
3067
+ // predictably oversized request to the reverted (smaller) model.
3068
+ if (reverted) {
3069
+ await this.#maintenance.runPrePromptCompactionIfNeeded([]);
3070
+ if (signal.aborted || this.#isDisposed) {
3071
+ this.#skipAgentContinue("post-restore-unavailable", options);
3072
+ return;
3073
+ }
3074
+ }
3025
3075
  if (this.settings.get("retry.usageAwareFallback")) {
3026
3076
  if (!(await this.#runQueuedUsageAwarePreflight(signal))) {
3027
3077
  this.#skipAgentContinue("session-unavailable", options);
@@ -3440,7 +3490,7 @@ export class AgentSession {
3440
3490
  success: event.success,
3441
3491
  attempt: event.attempt,
3442
3492
  finalError: event.finalError,
3443
- recoveredErrors: event.recoveredErrors,
3493
+ retryErrors: event.retryErrors,
3444
3494
  });
3445
3495
  } else if (event.type === "ttsr_triggered") {
3446
3496
  await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules });
@@ -3820,8 +3870,6 @@ export class AgentSession {
3820
3870
  this.#releasePowerAssertion();
3821
3871
  await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile);
3822
3872
  this.#movedFromEmptySessionFile = undefined;
3823
- // All teardown branches that can append session entries have settled.
3824
- await this.sessionManager.close();
3825
3873
  this.#closeAllProviderSessions("dispose");
3826
3874
  this.setHindsightSessionState(undefined);
3827
3875
  hindsightState?.dispose();
@@ -3836,6 +3884,77 @@ export class AgentSession {
3836
3884
  }
3837
3885
  this.#eventListeners = [];
3838
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();
3839
3958
  }
3840
3959
 
3841
3960
  #closeAllProviderSessions(reason: string): void {
@@ -5273,11 +5392,13 @@ export class AgentSession {
5273
5392
 
5274
5393
  if (result?.systemPrompt !== undefined) {
5275
5394
  baseXdevCatalogDelivered = false;
5276
- this.agent.setSystemPrompt(result.systemPrompt);
5395
+ this.#tools.setTurnSystemPromptOverride(result.systemPrompt);
5277
5396
  } else {
5397
+ this.#tools.clearTurnSystemPromptOverride();
5278
5398
  this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
5279
5399
  }
5280
5400
  } else {
5401
+ this.#tools.clearTurnSystemPromptOverride();
5281
5402
  this.agent.setSystemPrompt(beforeAgentStartSystemPrompt);
5282
5403
  }
5283
5404
 
@@ -5342,6 +5463,8 @@ export class AgentSession {
5342
5463
  await this.#waitForPostPromptRecovery(generation);
5343
5464
  }
5344
5465
  } finally {
5466
+ // The per-turn before_agent_start override lives only for this turn.
5467
+ this.#tools.clearTurnSystemPromptOverride();
5345
5468
  this.#usagePreflightReadyForNextModelCall = false;
5346
5469
  this.#endInFlight();
5347
5470
  }
@@ -6200,6 +6323,9 @@ export class AgentSession {
6200
6323
  this.abortRetry();
6201
6324
  this.#promptGeneration++;
6202
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"));
6203
6329
  if (options?.preserveCompaction) {
6204
6330
  // Manual `/compact` installed its own #compactionAbortController before
6205
6331
  // this internal abort and must keep it alive (that marker is what makes
@@ -6211,7 +6337,6 @@ export class AgentSession {
6211
6337
  } else {
6212
6338
  this.abortCompaction();
6213
6339
  }
6214
- this.abortHandoff();
6215
6340
  this.abortBash();
6216
6341
  this.abortEval();
6217
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;
@@ -42,6 +42,7 @@ import { formatOutputNotice } from "../tools/output-meta";
42
42
  export const SKILL_PROMPT_MESSAGE_TYPE = "skill-prompt";
43
43
  export const LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE = "lsp-late-diagnostic";
44
44
  export const BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE = "background-tan-dispatch";
45
+ export const PREWALK_PLAN_MESSAGE_TYPE = "prewalk-plan";
45
46
 
46
47
  /**
47
48
  * Logs provider-error turns so their actual cause is available outside the
@@ -14,11 +14,16 @@ import { type ConfiguredThinkingLevel, prewalkWouldBeNoop } from "../thinking";
14
14
  import type { PlanProposalHandler } from "../tools/resolve";
15
15
  import { ToolError } from "../tools/tool-errors";
16
16
  import type { PlanYolo, Prewalk } from "./agent-session-types";
17
+ import { PREWALK_PLAN_MESSAGE_TYPE } from "./messages";
17
18
  import type { SessionManager } from "./session-manager";
18
19
 
19
- const PREWALK_PLAN_MESSAGE_TYPE = "prewalk-plan";
20
20
  const PREWALK_CONTINUE_MESSAGE_TYPE = "prewalk-continue";
21
21
  const PREWALK_CHECKLIST_MESSAGE_TYPE = "prewalk-checklist";
22
+
23
+ /** Hidden plan steering is consumed within the live run and must not reappear after a context rebuild. */
24
+ export function isPrewalkPlanNudge(message: AgentMessage): boolean {
25
+ return message.role === "custom" && message.customType === PREWALK_PLAN_MESSAGE_TYPE;
26
+ }
22
27
  const PREWALK_ACTION_TOOLS: Record<string, true> = {
23
28
  edit: true,
24
29
  write: true,
@@ -99,10 +104,40 @@ export class PrewalkCoordinator {
99
104
  return this.#prewalk;
100
105
  }
101
106
 
107
+ #isNoop(prewalk: Prewalk): boolean {
108
+ return prewalkWouldBeNoop(
109
+ this.#host.model(),
110
+ this.#host.configuredThinkingLevel(),
111
+ prewalk.target,
112
+ prewalk.thinkingLevel,
113
+ );
114
+ }
115
+
116
+ #clearPrewalkState(): void {
117
+ this.#prewalk = undefined;
118
+ this.#planInjected = false;
119
+ this.#continuePending = false;
120
+ this.#todoSeen = false;
121
+ }
122
+
123
+ #disarmNoop(prewalk: Prewalk): void {
124
+ this.#clearPrewalkState();
125
+ this.#host.emitNotice(
126
+ "info",
127
+ `Prewalk: target ${prewalk.target.provider}/${prewalk.target.id} already matches the active model and thinking level; nothing to switch.`,
128
+ "prewalk",
129
+ );
130
+ }
131
+
102
132
  /** Advances the one-way prewalk switch at a completed assistant-turn boundary. */
103
133
  async advanceAtTurnEnd(liveMessages: AgentMessage[], context: AgentTurnEndContext | undefined): Promise<void> {
104
134
  const prewalk = this.#prewalk;
105
135
  if (!prewalk || context?.message.role !== "assistant") return;
136
+ if (this.#isNoop(prewalk)) {
137
+ this.#scrubPlanNudge(liveMessages);
138
+ this.#disarmNoop(prewalk);
139
+ return;
140
+ }
106
141
  if (context.toolResults.some(result => result.toolName === "todo" && !result.isError)) this.#todoSeen = true;
107
142
 
108
143
  const hasToolResults = context.toolResults.length > 0;
@@ -147,18 +182,12 @@ export class PrewalkCoordinator {
147
182
  }
148
183
  this.#scrubPlanNudge(liveMessages);
149
184
  const target = prewalk.target;
150
- const currentModel = this.#host.model();
151
- if (prewalkWouldBeNoop(currentModel, this.#host.configuredThinkingLevel(), target, prewalk.thinkingLevel)) {
152
- this.#prewalk = undefined;
153
- this.#host.emitNotice(
154
- "info",
155
- `Prewalk: target ${target.provider}/${target.id} already matches the active model and thinking level; nothing to switch.`,
156
- "prewalk",
157
- );
185
+ if (this.#isNoop(prewalk)) {
186
+ this.#disarmNoop(prewalk);
158
187
  return;
159
188
  }
160
189
  await this.#host.setModelTemporary(target, prewalk.thinkingLevel, { ephemeral: true });
161
- this.#prewalk = undefined;
190
+ this.#clearPrewalkState();
162
191
  this.#host.emitNotice(
163
192
  "info",
164
193
  `Prewalk: switched to ${target.provider}/${target.id} after first ${action.toolName} call.`,
@@ -175,18 +204,29 @@ export class PrewalkCoordinator {
175
204
  }
176
205
 
177
206
  /** Arms a prewalk immediately for an explicit slash-command request. */
178
- arm(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void {
179
- if (this.#prewalk) {
207
+ arm(target: Model, thinkingLevel?: ConfiguredThinkingLevel): boolean {
208
+ const active = this.#prewalk;
209
+ if (active) {
180
210
  this.#host.emitNotice(
181
211
  "info",
182
- `Prewalk: already armed for ${this.#prewalk.target.provider}/${this.#prewalk.target.id}, waiting for the first edit/write.`,
212
+ `Prewalk: already armed for ${active.target.provider}/${active.target.id}, waiting for the first edit/write.`,
183
213
  "prewalk",
184
214
  );
185
- return;
215
+ return (
216
+ active.target.provider === target.provider &&
217
+ active.target.id === target.id &&
218
+ active.thinkingLevel === thinkingLevel
219
+ );
220
+ }
221
+ const candidate = { target, thinkingLevel };
222
+ if (this.#isNoop(candidate)) {
223
+ this.#disarmNoop(candidate);
224
+ return false;
186
225
  }
187
- this.#prewalk = { target, thinkingLevel };
226
+ this.#prewalk = candidate;
188
227
  this.#planInjected = true;
189
228
  this.#continuePending = true;
229
+ this.#todoSeen = false;
190
230
  this.#host.agent.steer({
191
231
  role: "custom",
192
232
  customType: PREWALK_PLAN_MESSAGE_TYPE,
@@ -200,6 +240,7 @@ export class PrewalkCoordinator {
200
240
  `Prewalk: armed for ${target.provider}/${target.id} — will switch at the first edit/write once the todo list exists.`,
201
241
  "prewalk",
202
242
  );
243
+ return true;
203
244
  }
204
245
 
205
246
  /** Lazily enables plan-yolo's plan phase before the first prompt is built. */
@@ -220,8 +261,7 @@ export class PrewalkCoordinator {
220
261
 
221
262
  #scrubPlanNudge(liveMessages: AgentMessage[]): void {
222
263
  if (!this.#planInjected) return;
223
- const isPlanNudge = (message: AgentMessage): boolean =>
224
- message.role === "custom" && message.customType === PREWALK_PLAN_MESSAGE_TYPE;
264
+ const isPlanNudge = isPrewalkPlanNudge;
225
265
  for (let index = liveMessages.length - 1; index >= 0; index--) {
226
266
  if (!isPlanNudge(liveMessages[index])) continue;
227
267
  invalidateMessageCache(liveMessages[index]);
@@ -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;
@@ -8,6 +8,7 @@ import {
8
8
  INTERRUPTED_THINKING_MESSAGE_TYPE,
9
9
  isCustomMessageContent,
10
10
  normalizeCustomMessagePayload,
11
+ PREWALK_PLAN_MESSAGE_TYPE,
11
12
  } from "./messages";
12
13
  import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry } from "./session-entries";
13
14
 
@@ -332,15 +333,12 @@ export function buildSessionContext(
332
333
  const appendMessage = (entry: SessionEntry) => {
333
334
  handleEntryResetTracking(entry);
334
335
  if (entry.type === "message") {
335
- if (
336
- !options?.transcript &&
337
- entry.message.role === "assistant" &&
338
- entry.message.retryRecovery?.status === "recovered"
339
- ) {
336
+ if (!options?.transcript && entry.message.role === "assistant" && entry.message.retryRecovery) {
340
337
  return;
341
338
  }
342
339
  pushMessage(entry.message);
343
340
  } else if (entry.type === "custom_message") {
341
+ if (!options?.transcript && entry.customType === PREWALK_PLAN_MESSAGE_TYPE) return;
344
342
  if (!isCustomMessageContent(entry.content)) return;
345
343
  const normalized = normalizeCustomMessagePayload(entry);
346
344
  const attribution = entry.attribution === undefined ? undefined : normalized.attribution;
@@ -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,9 +331,10 @@ export class SessionHandoff {
309
331
 
310
332
  return { document: handoffText, savedPath };
311
333
  } catch (error) {
312
- if (handoffSignal.aborted || (error instanceof Error && error.name === "AbortError")) {
313
- throw new Error("Handoff cancelled");
314
- }
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);
315
338
  throw error;
316
339
  } finally {
317
340
  if (advisorRecordersDetached) {