@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -16,7 +16,7 @@
16
16
  import * as crypto from "node:crypto";
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
-
19
+ import { scheduler } from "node:timers/promises";
20
20
  import {
21
21
  type Agent,
22
22
  AgentBusyError,
@@ -47,14 +47,20 @@ import {
47
47
  calculateRateLimitBackoffMs,
48
48
  getSupportedEfforts,
49
49
  isContextOverflow,
50
- isUnexpectedSocketCloseMessage,
51
50
  isUsageLimitError,
52
51
  modelsAreEqual,
53
52
  parseRateLimitReason,
54
53
  streamSimple,
55
54
  } from "@oh-my-pi/pi-ai";
56
55
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
57
- import { abortableSleep, getAgentDbPath, isEnoent, logger, prompt, Snowflake } from "@oh-my-pi/pi-utils";
56
+ import {
57
+ getAgentDbPath,
58
+ isEnoent,
59
+ isUnexpectedSocketCloseMessage,
60
+ logger,
61
+ prompt,
62
+ Snowflake,
63
+ } from "@oh-my-pi/pi-utils";
58
64
  import { type AsyncJob, AsyncJobManager } from "../async";
59
65
  import type { Rule } from "../capability/rule";
60
66
  import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
@@ -104,6 +110,8 @@ import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
104
110
  import type { HookCommandContext } from "../extensibility/hooks/types";
105
111
  import type { Skill, SkillWarning } from "../extensibility/skills";
106
112
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
113
+ import { GoalRuntime } from "../goals/runtime";
114
+ import type { Goal, GoalModeState } from "../goals/state";
107
115
  import type { HindsightSessionState } from "../hindsight/state";
108
116
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
109
117
  import {
@@ -174,6 +182,8 @@ import {
174
182
  convertToLlm,
175
183
  type FileMentionMessage,
176
184
  type PythonExecutionMessage,
185
+ readPendingDisplayTag,
186
+ SILENT_ABORT_MARKER,
177
187
  } from "./messages";
178
188
  import { formatSessionDumpText } from "./session-dump-format";
179
189
  import type {
@@ -208,7 +218,9 @@ export type AgentSessionEvent =
208
218
  | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
209
219
  | { type: "todo_auto_clear" }
210
220
  | { type: "irc_message"; message: CustomMessage }
211
- | { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string };
221
+ | { type: "notice"; level: "info" | "warning" | "error"; message: string; source?: string }
222
+ | { type: "thinking_level_changed"; thinkingLevel: ThinkingLevel | undefined }
223
+ | { type: "goal_updated"; goal: Goal | null; state?: GoalModeState };
212
224
 
213
225
  /** Listener function for agent session events */
214
226
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -594,6 +606,13 @@ function extractPermissionLocations(args: unknown, cwd: string): { path: string;
594
606
  // AgentSession Class
595
607
  // ============================================================================
596
608
 
609
+ /** Internal record stored in the steering/followUp display queues. The optional
610
+ * `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt
611
+ * custom messages queued during streaming) and is matched by the custom-role
612
+ * `message_start` dequeue branch; user-message pushes leave it undefined and
613
+ * rely on the existing text-equality match. */
614
+ type QueuedDisplayEntry = { text: string; tag?: string };
615
+
597
616
  export class AgentSession {
598
617
  readonly agent: Agent;
599
618
  readonly sessionManager: SessionManager;
@@ -612,14 +631,22 @@ export class AgentSession {
612
631
  #unsubscribeAgent?: () => void;
613
632
  #eventListeners: AgentSessionEventListener[] = [];
614
633
 
615
- /** Tracks pending steering messages for UI display. Removed when delivered. */
616
- #steeringMessages: string[] = [];
617
- /** Tracks pending follow-up messages for UI display. Removed when delivered. */
618
- #followUpMessages: string[] = [];
634
+ /** Tracks pending steering messages for UI display. Removed when delivered.
635
+ * Entry shape: `{ text }` for plain-text steers (user-message dequeue
636
+ * matches by `.text`); `{ text, tag }` for queued custom messages (skill
637
+ * invocations dispatched while streaming) — the custom-role dequeue
638
+ * matches by `.tag` so duplicate-args queued skills cannot collide. */
639
+ #steeringMessages: QueuedDisplayEntry[] = [];
640
+ /** Tracks pending follow-up messages for UI display. Removed when delivered.
641
+ * See `#steeringMessages` for entry shape. */
642
+ #followUpMessages: QueuedDisplayEntry[] = [];
619
643
  /** Messages queued to be included with the next user prompt as context ("asides"). */
620
644
  #pendingNextTurnMessages: CustomMessage[] = [];
621
645
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
622
646
  #planModeState: PlanModeState | undefined;
647
+ #goalModeState: GoalModeState | undefined;
648
+ #goalRuntime: GoalRuntime;
649
+ #goalTurnCounter = 0;
623
650
  #planReferenceSent = false;
624
651
  #planReferencePath = "local://PLAN.md";
625
652
  #clientBridge: ClientBridge | undefined;
@@ -729,6 +756,19 @@ export class AgentSession {
729
756
  #ttsrRetryToken = 0;
730
757
  #ttsrResumePromise: Promise<void> | undefined = undefined;
731
758
  #ttsrResumeResolve: (() => void) | undefined = undefined;
759
+
760
+ /** One-shot flag set in InteractiveMode.#approvePlan(compactBeforeExecute=true)
761
+ * before the plan-mode → compaction transition. Consumed inside
762
+ * #handleAgentEvent for the matching `message_end` + `stopReason: "aborted"`;
763
+ * cleared unconditionally by the caller's `finally` so it cannot leak into
764
+ * later unrelated aborts (e.g. when compaction returns cancelled/failed
765
+ * without producing an aborted message_end). */
766
+ #planCompactAbortPending = false;
767
+
768
+ /** Monotonic counter for `enqueueCustomMessageDisplay` tag generation;
769
+ * combined with `Date.now()` so tags stay unique even across rapid
770
+ * same-tick enqueues. */
771
+ #customDisplayTagCounter = 0;
732
772
  #postPromptTasks = new Set<Promise<void>>();
733
773
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
734
774
  #postPromptTasksResolve: (() => void) | undefined = undefined;
@@ -878,6 +918,44 @@ export class AgentSession {
878
918
  this.agent.providerSessionState = this.#providerSessionState;
879
919
  this.#syncAgentSessionId();
880
920
  this.#syncTodoPhasesFromBranch();
921
+ this.#goalRuntime = new GoalRuntime({
922
+ getState: () => this.#goalModeState,
923
+ setState: state => {
924
+ this.#goalModeState = state;
925
+ },
926
+ getCurrentUsage: () => {
927
+ const usage = this.getSessionStats().tokens;
928
+ return {
929
+ input: usage.input,
930
+ output: usage.output,
931
+ cacheRead: usage.cacheRead,
932
+ cacheWrite: usage.cacheWrite,
933
+ };
934
+ },
935
+ emit: event => {
936
+ if (event.type === "goal_updated") {
937
+ return this.#emitSessionEvent({ type: "goal_updated", goal: event.goal, state: event.state });
938
+ }
939
+ },
940
+ persist: (mode, state) => {
941
+ if (mode === "none") {
942
+ this.sessionManager.appendModeChange("none");
943
+ } else if (state) {
944
+ this.sessionManager.appendModeChange(mode, { goal: state.goal });
945
+ }
946
+ },
947
+ sendHiddenMessage: async message => {
948
+ await this.sendCustomMessage(
949
+ {
950
+ customType: message.customType,
951
+ content: message.content,
952
+ display: false,
953
+ attribution: "agent",
954
+ },
955
+ { deliverAs: message.deliverAs },
956
+ );
957
+ },
958
+ });
881
959
 
882
960
  // Always subscribe to agent events for internal handling
883
961
  // (session persistence, hooks, auto-compaction, retry logic)
@@ -925,6 +1003,19 @@ export class AgentSession {
925
1003
  return this.#toolChoiceQueue.peekInFlightInvoker();
926
1004
  }
927
1005
 
1006
+ /** Standing (long-lived) handler the `resolve` tool falls back to when no
1007
+ * queue invoker is in flight. Used by plan mode so the agent can submit
1008
+ * approval via `resolve` without forcing the tool choice every turn. */
1009
+ #standingResolveHandler: ((input: unknown) => Promise<unknown> | unknown) | undefined;
1010
+
1011
+ peekStandingResolveHandler(): ((input: unknown) => Promise<unknown> | unknown) | undefined {
1012
+ return this.#standingResolveHandler;
1013
+ }
1014
+
1015
+ setStandingResolveHandler(handler: ((input: unknown) => Promise<unknown> | unknown) | null): void {
1016
+ this.#standingResolveHandler = handler ?? undefined;
1017
+ }
1018
+
928
1019
  /** Provider-scoped mutable state store for transport/session caches. */
929
1020
  get providerSessionState(): Map<string, ProviderSessionState> {
930
1021
  return this.#providerSessionState;
@@ -950,6 +1041,49 @@ export class AgentSession {
950
1041
  return this.#ttsrAbortPending;
951
1042
  }
952
1043
 
1044
+ /** Whether the plan-mode → compaction transition's expected internal abort is
1045
+ * pending. Consumed by `#handleAgentEvent` to stamp `SILENT_ABORT_MARKER`
1046
+ * on the next aborted assistant message_end; cleared unconditionally by
1047
+ * `InteractiveMode.#approvePlan`'s `finally` block. */
1048
+ get isPlanCompactAbortPending(): boolean {
1049
+ return this.#planCompactAbortPending;
1050
+ }
1051
+
1052
+ /** Arm the silent-abort marker for the next aborted assistant message_end.
1053
+ * Caller MUST clear via `clearPlanCompactAbortPending()` in a `finally`
1054
+ * to guarantee no leak. */
1055
+ markPlanCompactAbortPending(): void {
1056
+ this.#planCompactAbortPending = true;
1057
+ }
1058
+
1059
+ /** Unconditionally clear the silent-abort flag. Idempotent: safe when the
1060
+ * flag was never set OR was already consumed by `#handleAgentEvent`. */
1061
+ clearPlanCompactAbortPending(): void {
1062
+ this.#planCompactAbortPending = false;
1063
+ }
1064
+
1065
+ /** Register a compact display string for a custom message that the caller is
1066
+ * about to dispatch via `promptCustomMessage` / `sendCustomMessage`.
1067
+ * Returns a stable tag the caller MUST embed in
1068
+ * `CustomMessage.details.__pendingDisplayTag` so the agent-side
1069
+ * `message_start` handler can remove the matching display entry when the
1070
+ * queued message is consumed.
1071
+ *
1072
+ * Does NOT push to the agent's steering/followUp queue — that happens
1073
+ * separately inside `sendCustomMessage`. */
1074
+ enqueueCustomMessageDisplay(text: string, mode: "steer" | "followUp"): string {
1075
+ const tag = `omp-cmd-${Date.now()}-${++this.#customDisplayTagCounter}`;
1076
+ const displayText = text.trim();
1077
+ if (!displayText) return tag;
1078
+ const entry: QueuedDisplayEntry = { text: displayText, tag };
1079
+ if (mode === "steer") {
1080
+ this.#steeringMessages.push(entry);
1081
+ } else {
1082
+ this.#followUpMessages.push(entry);
1083
+ }
1084
+ return tag;
1085
+ }
1086
+
953
1087
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
954
1088
  const manager = AsyncJobManager.instance();
955
1089
  if (!manager) return null;
@@ -1038,13 +1172,13 @@ export class AgentSession {
1038
1172
  if (event.type === "message_start" && event.message.role === "user") {
1039
1173
  const messageText = this.#getUserMessageText(event.message);
1040
1174
  if (messageText) {
1041
- // Check steering queue first
1042
- const steeringIndex = this.#steeringMessages.indexOf(messageText);
1175
+ // Check steering queue first (match by .text on tagged records)
1176
+ const steeringIndex = this.#steeringMessages.findIndex(e => e.text === messageText);
1043
1177
  if (steeringIndex !== -1) {
1044
1178
  this.#steeringMessages.splice(steeringIndex, 1);
1045
1179
  } else {
1046
1180
  // Check follow-up queue
1047
- const followUpIndex = this.#followUpMessages.indexOf(messageText);
1181
+ const followUpIndex = this.#followUpMessages.findIndex(e => e.text === messageText);
1048
1182
  if (followUpIndex !== -1) {
1049
1183
  this.#followUpMessages.splice(followUpIndex, 1);
1050
1184
  }
@@ -1052,6 +1186,48 @@ export class AgentSession {
1052
1186
  }
1053
1187
  }
1054
1188
 
1189
+ // Tag-based dequeue for custom messages (skills queued via promptCustomMessage).
1190
+ // The InputController attached a stable tag via CustomMessage.details when it
1191
+ // registered the display chip; pull it back here to remove the matching entry
1192
+ // from the pending bar atomically with the agent's queue consumption. Match by
1193
+ // tag (not text) — two queued skills with identical args cannot collide.
1194
+ if (event.type === "message_start" && event.message.role === "custom") {
1195
+ const tag = readPendingDisplayTag(event.message.details);
1196
+ if (tag) {
1197
+ const steerIdx = this.#steeringMessages.findIndex(e => e.tag === tag);
1198
+ if (steerIdx !== -1) {
1199
+ this.#steeringMessages.splice(steerIdx, 1);
1200
+ } else {
1201
+ const followUpIdx = this.#followUpMessages.findIndex(e => e.tag === tag);
1202
+ if (followUpIdx !== -1) {
1203
+ this.#followUpMessages.splice(followUpIdx, 1);
1204
+ }
1205
+ }
1206
+ }
1207
+ }
1208
+
1209
+ // Plan-mode → compaction transition: stamp `SILENT_ABORT_MARKER` on the
1210
+ // persisted message BEFORE the obfuscator's display-side copy below.
1211
+ // Invariant (must hold across refactors): this branch precedes the
1212
+ // `let displayEvent = event; ... displayEvent = { ...event, message: { ...message, content: deobfuscated } }`
1213
+ // block. After stamping, both `displayEvent.message` (via the spread)
1214
+ // and `event.message` (in-place mutation, used by SessionManager
1215
+ // persistence) carry the marker, guaranteeing streaming render and
1216
+ // history replay branch identically. The one-shot flag is consumed
1217
+ // here, scoped strictly to this aborted message_end; the caller's
1218
+ // `finally` (in `InteractiveMode.#approvePlan`) clears it again on
1219
+ // every terminal compaction outcome (`ok` / `cancelled` / `failed` /
1220
+ // throw) so a leaked flag cannot silence a later unrelated abort.
1221
+ if (
1222
+ event.type === "message_end" &&
1223
+ event.message.role === "assistant" &&
1224
+ event.message.stopReason === "aborted" &&
1225
+ this.#planCompactAbortPending
1226
+ ) {
1227
+ (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
1228
+ this.#planCompactAbortPending = false;
1229
+ }
1230
+
1055
1231
  // Deobfuscate assistant message content for display emission — the LLM echoes back
1056
1232
  // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
1057
1233
  // values. The original event.message stays obfuscated so the persistence path below
@@ -1067,6 +1243,16 @@ export class AgentSession {
1067
1243
  }
1068
1244
  }
1069
1245
 
1246
+ if (event.type === "turn_start") {
1247
+ const usage = this.getSessionStats().tokens;
1248
+ this.#goalRuntime.onTurnStart(`turn-${++this.#goalTurnCounter}`, {
1249
+ input: usage.input,
1250
+ output: usage.output,
1251
+ cacheRead: usage.cacheRead,
1252
+ cacheWrite: usage.cacheWrite,
1253
+ });
1254
+ }
1255
+
1070
1256
  await this.#emitSessionEvent(displayEvent);
1071
1257
 
1072
1258
  if (event.type === "turn_start") {
@@ -1090,6 +1276,13 @@ export class AgentSession {
1090
1276
  this.#toolChoiceQueue.resolve();
1091
1277
  }
1092
1278
  }
1279
+ if (event.type === "tool_execution_end") {
1280
+ if (event.toolName === "goal") {
1281
+ await this.#goalRuntime.onGoalToolCompleted();
1282
+ } else {
1283
+ await this.#goalRuntime.onToolCompleted(event.toolName);
1284
+ }
1285
+ }
1093
1286
  if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
1094
1287
  this.#lastSuccessfulYieldToolCallId = event.toolCallId;
1095
1288
  }
@@ -1325,6 +1518,15 @@ export class AgentSession {
1325
1518
 
1326
1519
  // Check auto-retry and auto-compaction after agent completes
1327
1520
  if (event.type === "agent_end") {
1521
+ const usage = this.getSessionStats().tokens;
1522
+ await this.#goalRuntime.onAgentEnd({
1523
+ currentUsage: {
1524
+ input: usage.input,
1525
+ output: usage.output,
1526
+ cacheRead: usage.cacheRead,
1527
+ cacheWrite: usage.cacheWrite,
1528
+ },
1529
+ });
1328
1530
  const fallbackAssistant = [...event.messages]
1329
1531
  .reverse()
1330
1532
  .find((message): message is AssistantMessage => message.role === "assistant");
@@ -1446,7 +1648,7 @@ export class AgentSession {
1446
1648
  const scheduled = (async () => {
1447
1649
  if (delayMs > 0) {
1448
1650
  try {
1449
- await abortableSleep(delayMs, signal);
1651
+ await scheduler.wait(delayMs, { signal });
1450
1652
  } catch {
1451
1653
  return;
1452
1654
  }
@@ -1480,7 +1682,10 @@ export class AgentSession {
1480
1682
  try {
1481
1683
  await this.#maybeRestoreRetryFallbackPrimary();
1482
1684
  await this.agent.continue();
1483
- } catch {
1685
+ } catch (error) {
1686
+ logger.warn("agent.continue failed after scheduling", {
1687
+ error: error instanceof Error ? error.message : String(error),
1688
+ });
1484
1689
  options?.onError?.();
1485
1690
  }
1486
1691
  },
@@ -2180,6 +2385,12 @@ export class AgentSession {
2180
2385
  attempt: event.attempt,
2181
2386
  maxAttempts: event.maxAttempts,
2182
2387
  });
2388
+ } else if (event.type === "goal_updated") {
2389
+ await this.#extensionRunner.emit({
2390
+ type: "goal_updated",
2391
+ goal: event.goal,
2392
+ state: event.state,
2393
+ });
2183
2394
  }
2184
2395
  }
2185
2396
 
@@ -2572,7 +2783,7 @@ export class AgentSession {
2572
2783
 
2573
2784
  /** Collect built-in tools the model can discover via search_tool_bm25. Restricted to tool
2574
2785
  * definitions whose `loadMode === "discoverable"`. This keeps hidden/internal tools
2575
- * (resolve, yield, exit_plan_mode, report_finding, report_tool_issue) out of the index
2786
+ * (resolve, yield, report_finding, report_tool_issue) out of the index
2576
2787
  * and avoids mislabeling extension/custom default-inactive tools as built-ins. */
2577
2788
  #collectDiscoverableBuiltinTools(): DiscoverableTool[] {
2578
2789
  const activeNames = new Set(this.getActiveToolNames());
@@ -3140,6 +3351,18 @@ export class AgentSession {
3140
3351
  }
3141
3352
  }
3142
3353
 
3354
+ getGoalModeState(): GoalModeState | undefined {
3355
+ return this.#goalModeState;
3356
+ }
3357
+
3358
+ setGoalModeState(state: GoalModeState | undefined): void {
3359
+ this.#goalModeState = state;
3360
+ }
3361
+
3362
+ get goalRuntime(): GoalRuntime {
3363
+ return this.#goalRuntime;
3364
+ }
3365
+
3143
3366
  markPlanReferenceSent(): void {
3144
3367
  this.#planReferenceSent = true;
3145
3368
  }
@@ -3191,6 +3414,21 @@ export class AgentSession {
3191
3414
  );
3192
3415
  }
3193
3416
 
3417
+ async sendGoalModeContext(options?: { deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
3418
+ const message = this.#buildGoalModeMessage();
3419
+ if (!message) return;
3420
+ await this.sendCustomMessage(
3421
+ {
3422
+ customType: message.customType,
3423
+ content: message.content,
3424
+ display: message.display,
3425
+ details: message.details,
3426
+ attribution: message.attribution,
3427
+ },
3428
+ options ? { deliverAs: options.deliverAs } : undefined,
3429
+ );
3430
+ }
3431
+
3194
3432
  resolveRoleModel(role: string): Model | undefined {
3195
3433
  return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model).model;
3196
3434
  }
@@ -3286,7 +3524,6 @@ export class AgentSession {
3286
3524
  askToolName: "ask",
3287
3525
  writeToolName: "write",
3288
3526
  editToolName: "edit",
3289
- exitToolName: "exit_plan_mode",
3290
3527
  reentry: state.reentry ?? false,
3291
3528
  iterative: state.workflow === "iterative",
3292
3529
  });
@@ -3301,6 +3538,19 @@ export class AgentSession {
3301
3538
  };
3302
3539
  }
3303
3540
 
3541
+ #buildGoalModeMessage(): CustomMessage | null {
3542
+ const content = this.#goalRuntime.buildActivePrompt();
3543
+ if (!content) return null;
3544
+ return {
3545
+ role: "custom",
3546
+ customType: "goal-mode-context",
3547
+ content,
3548
+ display: false,
3549
+ attribution: "agent",
3550
+ timestamp: Date.now(),
3551
+ };
3552
+ }
3553
+
3304
3554
  /**
3305
3555
  * Send a prompt to the agent.
3306
3556
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -3476,6 +3726,10 @@ export class AgentSession {
3476
3726
  if (planModeMessage) {
3477
3727
  messages.push(planModeMessage);
3478
3728
  }
3729
+ const goalModeMessage = this.#buildGoalModeMessage();
3730
+ if (goalModeMessage) {
3731
+ messages.push(goalModeMessage);
3732
+ }
3479
3733
  if (options?.prependMessages) {
3480
3734
  messages.push(...options.prependMessages);
3481
3735
  }
@@ -3719,7 +3973,7 @@ export class AgentSession {
3719
3973
  */
3720
3974
  async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
3721
3975
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
3722
- this.#steeringMessages.push(displayText);
3976
+ this.#steeringMessages.push({ text: displayText });
3723
3977
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
3724
3978
  if (images && images.length > 0) {
3725
3979
  content.push(...images);
@@ -3737,7 +3991,7 @@ export class AgentSession {
3737
3991
  */
3738
3992
  async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
3739
3993
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
3740
- this.#followUpMessages.push(displayText);
3994
+ this.#followUpMessages.push({ text: displayText });
3741
3995
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
3742
3996
  if (images && images.length > 0) {
3743
3997
  content.push(...images);
@@ -3973,8 +4227,8 @@ export class AgentSession {
3973
4227
  * Useful for restoring to editor when user aborts.
3974
4228
  */
3975
4229
  clearQueue(): { steering: string[]; followUp: string[] } {
3976
- const steering = [...this.#steeringMessages];
3977
- const followUp = [...this.#followUpMessages];
4230
+ const steering = this.#steeringMessages.map(e => e.text);
4231
+ const followUp = this.#followUpMessages.map(e => e.text);
3978
4232
  this.#steeringMessages = [];
3979
4233
  this.#followUpMessages = [];
3980
4234
  this.agent.clearAllQueues();
@@ -3986,27 +4240,35 @@ export class AgentSession {
3986
4240
  return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
3987
4241
  }
3988
4242
 
3989
- /** Get pending messages (read-only) */
4243
+ /** Get pending messages (read-only). Returns the public text-only view;
4244
+ * internal `{text, tag?}` records are mapped to `.text` so callers
4245
+ * (`updatePendingMessagesDisplay`, `restoreQueuedMessagesToEditor`) see
4246
+ * the unchanged historical shape. */
3990
4247
  getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
3991
- return { steering: this.#steeringMessages, followUp: this.#followUpMessages };
4248
+ return {
4249
+ steering: this.#steeringMessages.map(e => e.text),
4250
+ followUp: this.#followUpMessages.map(e => e.text),
4251
+ };
3992
4252
  }
3993
4253
 
3994
4254
  /**
3995
4255
  * Pop the last queued message (steering first, then follow-up).
3996
4256
  * Used by dequeue keybinding to restore messages to editor one at a time.
4257
+ * Returns the popped entry's `.text`; the tag (if any) dies with the
4258
+ * record — no orphan state can outlive the queue entry.
3997
4259
  */
3998
4260
  popLastQueuedMessage(): string | undefined {
3999
4261
  // Pop from steering first (LIFO)
4000
4262
  if (this.#steeringMessages.length > 0) {
4001
- const message = this.#steeringMessages.pop();
4263
+ const entry = this.#steeringMessages.pop();
4002
4264
  this.agent.popLastSteer();
4003
- return message;
4265
+ return entry?.text;
4004
4266
  }
4005
4267
  // Then from follow-up
4006
4268
  if (this.#followUpMessages.length > 0) {
4007
- const message = this.#followUpMessages.pop();
4269
+ const entry = this.#followUpMessages.pop();
4008
4270
  this.agent.popLastFollowUp();
4009
- return message;
4271
+ return entry?.text;
4010
4272
  }
4011
4273
  return undefined;
4012
4274
  }
@@ -4120,7 +4382,7 @@ export class AgentSession {
4120
4382
  /**
4121
4383
  * Abort current operation and wait for agent to become idle.
4122
4384
  */
4123
- async abort(): Promise<void> {
4385
+ async abort(options?: { goalReason?: "interrupted" | "internal" }): Promise<void> {
4124
4386
  this.abortRetry();
4125
4387
  this.#promptGeneration++;
4126
4388
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -4132,6 +4394,7 @@ export class AgentSession {
4132
4394
  this.agent.abort();
4133
4395
  await postPromptDrain;
4134
4396
  await this.agent.waitForIdle();
4397
+ await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
4135
4398
  // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
4136
4399
  // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
4137
4400
  // a subsequent prompt() can incorrectly observe the session as busy after an abort.
@@ -4545,6 +4808,7 @@ export class AgentSession {
4545
4808
  if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
4546
4809
  this.settings.set("defaultThinkingLevel", effectiveLevel);
4547
4810
  }
4811
+ this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel });
4548
4812
  }
4549
4813
  }
4550
4814
 
@@ -4678,8 +4942,6 @@ export class AgentSession {
4678
4942
 
4679
4943
  let hookCompaction: CompactionResult | undefined;
4680
4944
  let fromExtension = false;
4681
- let hookContext: string[] | undefined;
4682
- let hookPrompt: string | undefined;
4683
4945
  let preserveData: Record<string, unknown> | undefined;
4684
4946
 
4685
4947
  if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -4701,23 +4963,7 @@ export class AgentSession {
4701
4963
  }
4702
4964
  }
4703
4965
 
4704
- if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
4705
- const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
4706
- const result = (await this.#extensionRunner.emit({
4707
- type: "session.compacting",
4708
- sessionId: this.sessionId,
4709
- messages: compactMessages,
4710
- })) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
4711
-
4712
- hookContext = result?.context;
4713
- hookPrompt = result?.prompt;
4714
- preserveData = result?.preserveData;
4715
- }
4716
-
4717
- const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
4718
- if (memoryBackendContext) {
4719
- hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
4720
- }
4966
+ const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
4721
4967
 
4722
4968
  let summary: string;
4723
4969
  let shortSummary: string | undefined;
@@ -4725,14 +4971,13 @@ export class AgentSession {
4725
4971
  let tokensBefore: number;
4726
4972
  let details: unknown;
4727
4973
 
4728
- if (hookCompaction) {
4729
- // Extension provided compaction content
4730
- summary = hookCompaction.summary;
4731
- shortSummary = hookCompaction.shortSummary;
4732
- firstKeptEntryId = hookCompaction.firstKeptEntryId;
4733
- tokensBefore = hookCompaction.tokensBefore;
4734
- details = hookCompaction.details;
4735
- preserveData ??= hookCompaction.preserveData;
4974
+ if (compactionPrep.kind === "fromHook") {
4975
+ summary = compactionPrep.summary;
4976
+ shortSummary = compactionPrep.shortSummary;
4977
+ firstKeptEntryId = compactionPrep.firstKeptEntryId;
4978
+ tokensBefore = compactionPrep.tokensBefore;
4979
+ details = compactionPrep.details;
4980
+ preserveData = compactionPrep.preserveData;
4736
4981
  } else {
4737
4982
  // Generate compaction result. Only convert known abort-shaped
4738
4983
  // rejections (AbortError raised while the abort signal is set,
@@ -4751,8 +4996,8 @@ export class AgentSession {
4751
4996
  customInstructions,
4752
4997
  compactionAbortController.signal,
4753
4998
  {
4754
- promptOverride: hookPrompt,
4755
- extraContext: hookContext,
4999
+ promptOverride: compactionPrep.hookPrompt,
5000
+ extraContext: compactionPrep.hookContext,
4756
5001
  remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
4757
5002
  },
4758
5003
  );
@@ -4761,7 +5006,7 @@ export class AgentSession {
4761
5006
  firstKeptEntryId = result.firstKeptEntryId;
4762
5007
  tokensBefore = result.tokensBefore;
4763
5008
  details = result.details;
4764
- preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
5009
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
4765
5010
  } catch (err) {
4766
5011
  if (err instanceof CompactionCancelledError) {
4767
5012
  throw err;
@@ -5199,14 +5444,14 @@ export class AgentSession {
5199
5444
  }
5200
5445
 
5201
5446
  const calledRequiredTool = assistantMessage.content.some(
5202
- content => content.type === "toolCall" && (content.name === "ask" || content.name === "exit_plan_mode"),
5447
+ content => content.type === "toolCall" && (content.name === "ask" || content.name === "resolve"),
5203
5448
  );
5204
5449
  if (calledRequiredTool) {
5205
5450
  return;
5206
5451
  }
5207
- const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("exit_plan_mode");
5452
+ const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("resolve");
5208
5453
  if (!hasRequiredTools) {
5209
- logger.warn("Plan mode enforcement skipped because ask/exit tools are unavailable", {
5454
+ logger.warn("Plan mode enforcement skipped because ask/resolve tools are unavailable", {
5210
5455
  activeToolNames: this.agent.state.tools.map(tool => tool.name),
5211
5456
  });
5212
5457
  return;
@@ -5214,7 +5459,6 @@ export class AgentSession {
5214
5459
 
5215
5460
  const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
5216
5461
  askToolName: "ask",
5217
- exitToolName: "exit_plan_mode",
5218
5462
  });
5219
5463
 
5220
5464
  await this.prompt(reminder, {
@@ -5721,6 +5965,64 @@ export class AgentSession {
5721
5965
  throw this.#buildCompactionAuthError();
5722
5966
  }
5723
5967
 
5968
+ async #prepareCompactionFromHooks(
5969
+ preparation: CompactionPreparation,
5970
+ hookCompaction: CompactionResult | undefined,
5971
+ ): Promise<
5972
+ | {
5973
+ kind: "fromHook";
5974
+ summary: string;
5975
+ shortSummary: string | undefined;
5976
+ firstKeptEntryId: string;
5977
+ tokensBefore: number;
5978
+ details: unknown;
5979
+ preserveData: Record<string, unknown> | undefined;
5980
+ }
5981
+ | {
5982
+ kind: "needsLlm";
5983
+ hookContext: string[] | undefined;
5984
+ hookPrompt: string | undefined;
5985
+ preserveData: Record<string, unknown> | undefined;
5986
+ }
5987
+ > {
5988
+ let hookContext: string[] | undefined;
5989
+ let hookPrompt: string | undefined;
5990
+ let preserveData: Record<string, unknown> | undefined;
5991
+
5992
+ if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
5993
+ const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
5994
+ const result = (await this.#extensionRunner.emit({
5995
+ type: "session.compacting",
5996
+ sessionId: this.sessionId,
5997
+ messages: compactMessages,
5998
+ })) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
5999
+
6000
+ hookContext = result?.context;
6001
+ hookPrompt = result?.prompt;
6002
+ preserveData = result?.preserveData;
6003
+ }
6004
+
6005
+ const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
6006
+ if (memoryBackendContext) {
6007
+ hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
6008
+ }
6009
+
6010
+ if (hookCompaction) {
6011
+ preserveData ??= hookCompaction.preserveData;
6012
+ return {
6013
+ kind: "fromHook",
6014
+ summary: hookCompaction.summary,
6015
+ shortSummary: hookCompaction.shortSummary,
6016
+ firstKeptEntryId: hookCompaction.firstKeptEntryId,
6017
+ tokensBefore: hookCompaction.tokensBefore,
6018
+ details: hookCompaction.details,
6019
+ preserveData,
6020
+ };
6021
+ }
6022
+
6023
+ return { kind: "needsLlm", hookContext, hookPrompt, preserveData };
6024
+ }
6025
+
5724
6026
  /**
5725
6027
  * Internal: Run auto-compaction with events.
5726
6028
  */
@@ -5842,8 +6144,6 @@ export class AgentSession {
5842
6144
 
5843
6145
  let hookCompaction: CompactionResult | undefined;
5844
6146
  let fromExtension = false;
5845
- let hookContext: string[] | undefined;
5846
- let hookPrompt: string | undefined;
5847
6147
  let preserveData: Record<string, unknown> | undefined;
5848
6148
 
5849
6149
  if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -5872,23 +6172,7 @@ export class AgentSession {
5872
6172
  }
5873
6173
  }
5874
6174
 
5875
- if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
5876
- const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
5877
- const result = (await this.#extensionRunner.emit({
5878
- type: "session.compacting",
5879
- sessionId: this.sessionId,
5880
- messages: compactMessages,
5881
- })) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
5882
-
5883
- hookContext = result?.context;
5884
- hookPrompt = result?.prompt;
5885
- preserveData = result?.preserveData;
5886
- }
5887
-
5888
- const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
5889
- if (memoryBackendContext) {
5890
- hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
5891
- }
6175
+ const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
5892
6176
 
5893
6177
  let summary: string;
5894
6178
  let shortSummary: string | undefined;
@@ -5896,14 +6180,13 @@ export class AgentSession {
5896
6180
  let tokensBefore: number;
5897
6181
  let details: unknown;
5898
6182
 
5899
- if (hookCompaction) {
5900
- // Extension provided compaction content
5901
- summary = hookCompaction.summary;
5902
- shortSummary = hookCompaction.shortSummary;
5903
- firstKeptEntryId = hookCompaction.firstKeptEntryId;
5904
- tokensBefore = hookCompaction.tokensBefore;
5905
- details = hookCompaction.details;
5906
- preserveData ??= hookCompaction.preserveData;
6183
+ if (compactionPrep.kind === "fromHook") {
6184
+ summary = compactionPrep.summary;
6185
+ shortSummary = compactionPrep.shortSummary;
6186
+ firstKeptEntryId = compactionPrep.firstKeptEntryId;
6187
+ tokensBefore = compactionPrep.tokensBefore;
6188
+ details = compactionPrep.details;
6189
+ preserveData = compactionPrep.preserveData;
5907
6190
  } else {
5908
6191
  const candidates = this.#getCompactionModelCandidates(availableModels);
5909
6192
  const retrySettings = this.settings.getGroup("retry");
@@ -5918,8 +6201,8 @@ export class AgentSession {
5918
6201
  while (true) {
5919
6202
  try {
5920
6203
  compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
5921
- promptOverride: hookPrompt,
5922
- extraContext: hookContext,
6204
+ promptOverride: compactionPrep.hookPrompt,
6205
+ extraContext: compactionPrep.hookContext,
5923
6206
  remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
5924
6207
  metadata: this.agent.metadataForProvider(candidate.provider),
5925
6208
  initiatorOverride: "agent",
@@ -5976,7 +6259,7 @@ export class AgentSession {
5976
6259
  error: message,
5977
6260
  model: `${candidate.provider}/${candidate.id}`,
5978
6261
  });
5979
- await abortableSleep(delayMs, autoCompactionSignal);
6262
+ await scheduler.wait(delayMs, { signal: autoCompactionSignal });
5980
6263
  }
5981
6264
  }
5982
6265
 
@@ -5997,7 +6280,7 @@ export class AgentSession {
5997
6280
  firstKeptEntryId = compactResult.firstKeptEntryId;
5998
6281
  tokensBefore = compactResult.tokensBefore;
5999
6282
  details = compactResult.details;
6000
- preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
6283
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
6001
6284
  }
6002
6285
 
6003
6286
  if (autoCompactionSignal.aborted) {
@@ -6148,10 +6431,11 @@ export class AgentSession {
6148
6431
 
6149
6432
  #isTransientTransportErrorMessage(errorMessage: string): boolean {
6150
6433
  // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
6151
- // service unavailable, network/connection/socket errors, fetch failed, terminated, retry delay exceeded
6434
+ // service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
6435
+ // terminated, retry delay exceeded
6152
6436
  return (
6153
6437
  isUnexpectedSocketCloseMessage(errorMessage) ||
6154
- /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response/i.test(
6438
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response/i.test(
6155
6439
  errorMessage,
6156
6440
  )
6157
6441
  );
@@ -6501,7 +6785,7 @@ export class AgentSession {
6501
6785
  this.#retryAbortController?.abort();
6502
6786
  this.#retryAbortController = retryAbortController;
6503
6787
  try {
6504
- await abortableSleep(delayMs, retryAbortController.signal);
6788
+ await scheduler.wait(delayMs, { signal: retryAbortController.signal });
6505
6789
  } catch {
6506
6790
  if (this.#retryAbortController !== retryAbortController) {
6507
6791
  return false;
@@ -7833,21 +8117,11 @@ export class AgentSession {
7833
8117
  * @returns Text content, or undefined if no assistant message exists
7834
8118
  */
7835
8119
  getLastAssistantText(): string | undefined {
7836
- const lastAssistant = this.messages
7837
- .slice()
7838
- .reverse()
7839
- .find(m => {
7840
- if (m.role !== "assistant") return false;
7841
- const msg = m as AssistantMessage;
7842
- // Skip aborted messages with no content
7843
- if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
7844
- return true;
7845
- });
7846
-
8120
+ const lastAssistant = this.#getLastCopyCandidateAssistantMessage();
7847
8121
  if (!lastAssistant) return undefined;
7848
8122
 
7849
8123
  let text = "";
7850
- for (const content of (lastAssistant as AssistantMessage).content) {
8124
+ for (const content of lastAssistant.content) {
7851
8125
  if (content.type === "text") {
7852
8126
  text += content.text;
7853
8127
  }
@@ -7856,6 +8130,54 @@ export class AgentSession {
7856
8130
  return text.trim() || undefined;
7857
8131
  }
7858
8132
 
8133
+ hasCopyCandidateAssistantMessage(): boolean {
8134
+ return this.#getLastCopyCandidateAssistantMessage() !== undefined;
8135
+ }
8136
+
8137
+ #getLastCopyCandidateAssistantMessage(): AssistantMessage | undefined {
8138
+ for (let i = this.messages.length - 1; i >= 0; i--) {
8139
+ const message = this.messages[i];
8140
+ if (message.role !== "assistant") continue;
8141
+
8142
+ const assistantMessage = message as AssistantMessage;
8143
+ // Skip aborted messages with no content
8144
+ if (assistantMessage.stopReason === "aborted" && assistantMessage.content.length === 0) continue;
8145
+
8146
+ return assistantMessage;
8147
+ }
8148
+
8149
+ return undefined;
8150
+ }
8151
+ /**
8152
+ * Get text content of the most recent visible handoff message.
8153
+ * Fresh handoff sessions store the handoff context as a custom message, not
8154
+ * an assistant message, so callers that copy the "last" message can use this
8155
+ * as a fallback before the new session has an assistant response.
8156
+ */
8157
+ getLastVisibleHandoffText(): string | undefined {
8158
+ for (let i = this.messages.length - 1; i >= 0; i--) {
8159
+ const message = this.messages[i];
8160
+ if (message.role !== "custom") continue;
8161
+
8162
+ const customMessage = message as CustomMessage;
8163
+ if (customMessage.customType !== "handoff" || !customMessage.display) continue;
8164
+
8165
+ if (typeof customMessage.content === "string") {
8166
+ return customMessage.content.trim() || undefined;
8167
+ }
8168
+
8169
+ let text = "";
8170
+ for (const content of customMessage.content) {
8171
+ if (content.type === "text") {
8172
+ text += content.text;
8173
+ }
8174
+ }
8175
+ return text.trim() || undefined;
8176
+ }
8177
+
8178
+ return undefined;
8179
+ }
8180
+
7859
8181
  /**
7860
8182
  * Format the entire session as plain text for clipboard export.
7861
8183
  * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.