@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.1

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 (230) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/scripts/format-prompts.ts +1 -1
  6. package/src/autoresearch/helpers.ts +17 -0
  7. package/src/autoresearch/tools/log-experiment.ts +9 -17
  8. package/src/autoresearch/tools/run-experiment.ts +2 -17
  9. package/src/capability/skill.ts +7 -0
  10. package/src/cli/args.ts +2 -2
  11. package/src/cli/list-models.ts +1 -1
  12. package/src/cli/shell-cli.ts +3 -13
  13. package/src/cli/update-cli.ts +1 -1
  14. package/src/cli.ts +11 -29
  15. package/src/commands/acp.ts +24 -0
  16. package/src/commands/launch.ts +6 -4
  17. package/src/commit/agentic/prompts/system.md +1 -1
  18. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  19. package/src/commit/analysis/conventional.ts +8 -66
  20. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  21. package/src/commit/pipeline.ts +2 -2
  22. package/src/commit/shared-llm.ts +89 -0
  23. package/src/config/config-file.ts +210 -0
  24. package/src/config/model-equivalence.ts +8 -11
  25. package/src/config/model-registry.ts +13 -2
  26. package/src/config/model-resolver.ts +31 -4
  27. package/src/config/settings-schema.ts +102 -1
  28. package/src/config/settings.ts +1 -1
  29. package/src/config.ts +3 -219
  30. package/src/edit/index.ts +22 -1
  31. package/src/edit/modes/patch.ts +10 -0
  32. package/src/edit/modes/replace.ts +3 -0
  33. package/src/edit/renderer.ts +17 -1
  34. package/src/eval/js/context-manager.ts +1 -1
  35. package/src/eval/js/executor.ts +3 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +122 -50
  37. package/src/eval/js/shared/runtime.ts +31 -4
  38. package/src/eval/js/tool-bridge.ts +43 -21
  39. package/src/eval/py/executor.ts +5 -0
  40. package/src/exa/factory.ts +2 -2
  41. package/src/exa/mcp-client.ts +74 -1
  42. package/src/exec/bash-executor.ts +5 -1
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +0 -11
  45. package/src/extensibility/extensions/runner.ts +55 -2
  46. package/src/extensibility/extensions/types.ts +98 -221
  47. package/src/extensibility/hooks/types.ts +89 -314
  48. package/src/extensibility/shared-events.ts +343 -0
  49. package/src/extensibility/skills.ts +42 -1
  50. package/src/goals/index.ts +3 -0
  51. package/src/goals/runtime.ts +500 -0
  52. package/src/goals/state.ts +37 -0
  53. package/src/goals/tools/goal-tool.ts +237 -0
  54. package/src/hashline/anchors.ts +2 -2
  55. package/src/hindsight/mental-models.ts +1 -1
  56. package/src/internal-urls/agent-protocol.ts +1 -20
  57. package/src/internal-urls/artifact-protocol.ts +1 -19
  58. package/src/internal-urls/docs-index.generated.ts +9 -10
  59. package/src/internal-urls/index.ts +1 -0
  60. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  61. package/src/internal-urls/registry-helpers.ts +25 -0
  62. package/src/internal-urls/router.ts +6 -3
  63. package/src/internal-urls/types.ts +22 -1
  64. package/src/main.ts +24 -11
  65. package/src/mcp/oauth-flow.ts +20 -0
  66. package/src/modes/acp/acp-agent.ts +412 -71
  67. package/src/modes/acp/acp-client-bridge.ts +152 -0
  68. package/src/modes/acp/acp-event-mapper.ts +180 -15
  69. package/src/modes/acp/terminal-auth.ts +37 -0
  70. package/src/modes/components/assistant-message.ts +14 -8
  71. package/src/modes/components/bash-execution.ts +24 -63
  72. package/src/modes/components/custom-message.ts +14 -40
  73. package/src/modes/components/eval-execution.ts +27 -57
  74. package/src/modes/components/execution-shared.ts +102 -0
  75. package/src/modes/components/hook-message.ts +17 -49
  76. package/src/modes/components/mcp-add-wizard.ts +26 -5
  77. package/src/modes/components/message-frame.ts +88 -0
  78. package/src/modes/components/model-selector.ts +1 -1
  79. package/src/modes/components/read-tool-group.ts +29 -1
  80. package/src/modes/components/session-observer-overlay.ts +6 -2
  81. package/src/modes/components/session-selector.ts +1 -1
  82. package/src/modes/components/status-line/segments.ts +55 -4
  83. package/src/modes/components/status-line/types.ts +4 -0
  84. package/src/modes/components/status-line.ts +28 -10
  85. package/src/modes/components/tool-execution.ts +7 -8
  86. package/src/modes/controllers/command-controller-shared.ts +108 -0
  87. package/src/modes/controllers/command-controller.ts +27 -10
  88. package/src/modes/controllers/event-controller.ts +60 -18
  89. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  90. package/src/modes/controllers/input-controller.ts +85 -39
  91. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  92. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  93. package/src/modes/interactive-mode.ts +675 -39
  94. package/src/modes/print-mode.ts +16 -86
  95. package/src/modes/rpc/rpc-mode.ts +30 -88
  96. package/src/modes/runtime-init.ts +115 -0
  97. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  98. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  99. package/src/modes/theme/theme.ts +18 -6
  100. package/src/modes/types.ts +20 -5
  101. package/src/modes/utils/context-usage.ts +13 -13
  102. package/src/modes/utils/ui-helpers.ts +25 -6
  103. package/src/plan-mode/approved-plan.ts +35 -1
  104. package/src/prompts/agents/designer.md +5 -5
  105. package/src/prompts/agents/explore.md +7 -7
  106. package/src/prompts/agents/init.md +9 -9
  107. package/src/prompts/agents/librarian.md +14 -14
  108. package/src/prompts/agents/plan.md +4 -4
  109. package/src/prompts/agents/reviewer.md +5 -5
  110. package/src/prompts/agents/task.md +10 -10
  111. package/src/prompts/commands/orchestrate.md +2 -2
  112. package/src/prompts/compaction/branch-summary.md +3 -3
  113. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  114. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  115. package/src/prompts/compaction/compaction-summary.md +5 -5
  116. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  117. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  118. package/src/prompts/goals/goal-budget-limit.md +16 -0
  119. package/src/prompts/goals/goal-continuation.md +28 -0
  120. package/src/prompts/goals/goal-mode-active.md +23 -0
  121. package/src/prompts/memories/consolidation.md +2 -2
  122. package/src/prompts/memories/read-path.md +1 -1
  123. package/src/prompts/memories/stage_one_input.md +1 -1
  124. package/src/prompts/memories/stage_one_system.md +5 -5
  125. package/src/prompts/review-request.md +4 -4
  126. package/src/prompts/system/agent-creation-architect.md +17 -17
  127. package/src/prompts/system/agent-creation-user.md +2 -2
  128. package/src/prompts/system/commit-message-system.md +2 -2
  129. package/src/prompts/system/custom-system-prompt.md +2 -2
  130. package/src/prompts/system/eager-todo.md +6 -6
  131. package/src/prompts/system/handoff-document.md +1 -1
  132. package/src/prompts/system/plan-mode-active.md +25 -24
  133. package/src/prompts/system/plan-mode-approved.md +4 -4
  134. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  135. package/src/prompts/system/plan-mode-reference.md +2 -2
  136. package/src/prompts/system/plan-mode-subagent.md +8 -8
  137. package/src/prompts/system/plan-mode-tool-decision-reminder.md +3 -3
  138. package/src/prompts/system/project-prompt.md +4 -4
  139. package/src/prompts/system/subagent-system-prompt.md +7 -7
  140. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  141. package/src/prompts/system/system-prompt.md +72 -71
  142. package/src/prompts/system/ttsr-interrupt.md +1 -1
  143. package/src/prompts/tools/apply-patch.md +1 -1
  144. package/src/prompts/tools/ast-edit.md +3 -3
  145. package/src/prompts/tools/ast-grep.md +3 -3
  146. package/src/prompts/tools/bash.md +6 -0
  147. package/src/prompts/tools/browser.md +3 -3
  148. package/src/prompts/tools/checkpoint.md +3 -3
  149. package/src/prompts/tools/find.md +3 -3
  150. package/src/prompts/tools/github.md +2 -5
  151. package/src/prompts/tools/goal.md +13 -0
  152. package/src/prompts/tools/hashline.md +104 -116
  153. package/src/prompts/tools/image-gen.md +3 -3
  154. package/src/prompts/tools/irc.md +1 -1
  155. package/src/prompts/tools/lsp.md +2 -2
  156. package/src/prompts/tools/patch.md +6 -6
  157. package/src/prompts/tools/read.md +8 -7
  158. package/src/prompts/tools/replace.md +5 -5
  159. package/src/prompts/tools/resolve.md +6 -5
  160. package/src/prompts/tools/retain.md +1 -1
  161. package/src/prompts/tools/rewind.md +2 -2
  162. package/src/prompts/tools/search.md +2 -2
  163. package/src/prompts/tools/ssh.md +2 -2
  164. package/src/prompts/tools/task.md +12 -6
  165. package/src/prompts/tools/web-search.md +2 -2
  166. package/src/prompts/tools/write.md +3 -3
  167. package/src/sdk.ts +81 -17
  168. package/src/session/agent-session.ts +656 -125
  169. package/src/session/blob-store.ts +36 -3
  170. package/src/session/client-bridge.ts +81 -0
  171. package/src/session/compaction/errors.ts +31 -0
  172. package/src/session/compaction/index.ts +1 -0
  173. package/src/session/messages.ts +67 -2
  174. package/src/session/session-manager.ts +131 -12
  175. package/src/session/session-storage.ts +33 -15
  176. package/src/session/streaming-output.ts +309 -13
  177. package/src/slash-commands/acp-builtins.ts +46 -0
  178. package/src/slash-commands/builtin-registry.ts +717 -116
  179. package/src/slash-commands/helpers/context-report.ts +39 -0
  180. package/src/slash-commands/helpers/format.ts +23 -0
  181. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  182. package/src/slash-commands/helpers/mcp.ts +532 -0
  183. package/src/slash-commands/helpers/parse.ts +85 -0
  184. package/src/slash-commands/helpers/ssh.ts +193 -0
  185. package/src/slash-commands/helpers/todo.ts +279 -0
  186. package/src/slash-commands/helpers/usage-report.ts +91 -0
  187. package/src/slash-commands/types.ts +126 -0
  188. package/src/ssh/ssh-executor.ts +5 -0
  189. package/src/system-prompt.ts +4 -2
  190. package/src/task/executor.ts +27 -10
  191. package/src/task/index.ts +20 -1
  192. package/src/task/render.ts +27 -18
  193. package/src/task/types.ts +4 -0
  194. package/src/tools/ast-edit.ts +21 -120
  195. package/src/tools/ast-grep.ts +21 -119
  196. package/src/tools/bash-interactive.ts +9 -1
  197. package/src/tools/bash.ts +203 -6
  198. package/src/tools/browser/attach.ts +3 -3
  199. package/src/tools/browser/launch.ts +81 -18
  200. package/src/tools/browser/registry.ts +1 -5
  201. package/src/tools/browser/tab-supervisor.ts +51 -14
  202. package/src/tools/conflict-detect.ts +21 -10
  203. package/src/tools/eval.ts +3 -1
  204. package/src/tools/fetch.ts +15 -4
  205. package/src/tools/find.ts +39 -39
  206. package/src/tools/gh-renderer.ts +0 -12
  207. package/src/tools/gh.ts +689 -182
  208. package/src/tools/github-cache.ts +548 -0
  209. package/src/tools/index.ts +25 -11
  210. package/src/tools/inspect-image.ts +3 -10
  211. package/src/tools/output-meta.ts +176 -37
  212. package/src/tools/path-utils.ts +125 -2
  213. package/src/tools/read.ts +605 -239
  214. package/src/tools/render-utils.ts +92 -0
  215. package/src/tools/renderers.ts +2 -0
  216. package/src/tools/resolve.ts +72 -44
  217. package/src/tools/search.ts +120 -186
  218. package/src/tools/write.ts +67 -10
  219. package/src/tui/code-cell.ts +70 -2
  220. package/src/utils/file-mentions.ts +1 -1
  221. package/src/utils/image-loading.ts +7 -3
  222. package/src/utils/image-resize.ts +32 -43
  223. package/src/vim/parser.ts +0 -17
  224. package/src/vim/render.ts +1 -1
  225. package/src/vim/types.ts +1 -1
  226. package/src/web/search/providers/gemini.ts +35 -95
  227. package/src/prompts/tools/exit-plan-mode.md +0 -6
  228. package/src/tools/exit-plan-mode.ts +0 -97
  229. package/src/utils/fuzzy.ts +0 -108
  230. 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 {
@@ -143,7 +151,7 @@ import { outputMeta } from "../tools/output-meta";
143
151
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
144
152
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
145
153
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
146
- import { ToolError } from "../tools/tool-errors";
154
+ import { ToolAbortError, ToolError } from "../tools/tool-errors";
147
155
  import { clampTimeout } from "../tools/tool-timeouts";
148
156
  import { parseCommandArgs } from "../utils/command-args";
149
157
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
@@ -151,7 +159,9 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
151
159
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
152
160
  import { buildNamedToolChoice } from "../utils/tool-choice";
153
161
  import type { AuthStorage } from "./auth-storage";
162
+ import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
154
163
  import {
164
+ CompactionCancelledError,
155
165
  type CompactionPreparation,
156
166
  type CompactionResult,
157
167
  calculateContextTokens,
@@ -172,6 +182,8 @@ import {
172
182
  convertToLlm,
173
183
  type FileMentionMessage,
174
184
  type PythonExecutionMessage,
185
+ readPendingDisplayTag,
186
+ SILENT_ABORT_MARKER,
175
187
  } from "./messages";
176
188
  import { formatSessionDumpText } from "./session-dump-format";
177
189
  import type {
@@ -206,7 +218,9 @@ export type AgentSessionEvent =
206
218
  | { type: "todo_reminder"; todos: TodoItem[]; attempt: number; maxAttempts: number }
207
219
  | { type: "todo_auto_clear" }
208
220
  | { type: "irc_message"; message: CustomMessage }
209
- | { 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 };
210
224
 
211
225
  /** Listener function for agent session events */
212
226
  export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
@@ -498,10 +512,107 @@ const noOpUIContext: ExtensionUIContext = {
498
512
  setToolsExpanded: () => {},
499
513
  };
500
514
 
515
+ // ============================================================================
516
+ // ACP Permission Gate
517
+ // ============================================================================
518
+
519
+ /** Tools that require user permission before execution when an ACP client is connected. */
520
+ const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "write", "ast_edit", "delete", "move"]);
521
+
522
+ /** Permission options presented to the client on each gated tool call. */
523
+ const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
524
+ { optionId: "allow_once", name: "Allow once", kind: "allow_once" },
525
+ { optionId: "allow_always", name: "Always allow", kind: "allow_always" },
526
+ { optionId: "reject_once", name: "Reject", kind: "reject_once" },
527
+ { optionId: "reject_always", name: "Always reject", kind: "reject_always" },
528
+ ];
529
+
530
+ const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option]));
531
+
532
+ function derivePermissionTitle(toolName: string, args: unknown): string {
533
+ const a = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
534
+ if (toolName === "bash") {
535
+ const cmd = typeof a.command === "string" ? a.command.slice(0, 80) : undefined;
536
+ if (cmd) return cmd;
537
+ } else if (toolName === "edit" || toolName === "write" || toolName === "delete") {
538
+ const p = typeof a.path === "string" ? a.path : undefined;
539
+ if (p) {
540
+ const verb = toolName === "edit" ? "Edit" : toolName === "write" ? "Write" : "Delete";
541
+ return `${verb} ${p}`;
542
+ }
543
+ } else if (toolName === "move") {
544
+ const from =
545
+ typeof a.oldPath === "string"
546
+ ? a.oldPath
547
+ : typeof a.path === "string"
548
+ ? a.path
549
+ : typeof a.from === "string"
550
+ ? a.from
551
+ : undefined;
552
+ const to =
553
+ typeof a.newPath === "string"
554
+ ? a.newPath
555
+ : typeof a.to === "string"
556
+ ? a.to
557
+ : typeof a.destination === "string"
558
+ ? a.destination
559
+ : undefined;
560
+ if (from && to) return `Move ${from} to ${to}`;
561
+ if (from) return `Move ${from}`;
562
+ } else if (toolName === "ast_edit") {
563
+ const paths = Array.isArray(a.paths)
564
+ ? (a.paths as unknown[]).filter(x => typeof x === "string").join(", ")
565
+ : undefined;
566
+ if (paths) return `AST edit ${paths}`;
567
+ }
568
+ return toolName;
569
+ }
570
+
571
+ function extractPermissionLocations(args: unknown, cwd: string): { path: string; line?: number }[] {
572
+ if (!args || typeof args !== "object") return [];
573
+ const a = args as Record<string, unknown>;
574
+ const out: { path: string; line?: number }[] = [];
575
+ const pushPath = (value: unknown) => {
576
+ if (typeof value !== "string" || value.length === 0) return;
577
+ // ACP locations carry file paths that the editor host will open or focus;
578
+ // they must be absolute or the client cannot resolve them. Resolve raw
579
+ // tool args (often cwd-relative) against the session cwd before sending.
580
+ let resolved: string;
581
+ try {
582
+ resolved = resolveToCwd(value, cwd);
583
+ } catch {
584
+ return;
585
+ }
586
+ if (out.some(location => location.path === resolved)) return;
587
+ out.push({ path: resolved });
588
+ };
589
+ pushPath(a.path);
590
+ pushPath(a.file);
591
+ if (Array.isArray(a.paths)) {
592
+ for (const p of a.paths) {
593
+ pushPath(p);
594
+ }
595
+ }
596
+ pushPath(a.oldPath);
597
+ pushPath(a.newPath);
598
+ pushPath(a.from);
599
+ pushPath(a.to);
600
+ pushPath(a.source);
601
+ pushPath(a.destination);
602
+ return out;
603
+ }
604
+
501
605
  // ============================================================================
502
606
  // AgentSession Class
503
607
  // ============================================================================
504
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
+
505
616
  export class AgentSession {
506
617
  readonly agent: Agent;
507
618
  readonly sessionManager: SessionManager;
@@ -520,16 +631,27 @@ export class AgentSession {
520
631
  #unsubscribeAgent?: () => void;
521
632
  #eventListeners: AgentSessionEventListener[] = [];
522
633
 
523
- /** Tracks pending steering messages for UI display. Removed when delivered. */
524
- #steeringMessages: string[] = [];
525
- /** Tracks pending follow-up messages for UI display. Removed when delivered. */
526
- #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[] = [];
527
643
  /** Messages queued to be included with the next user prompt as context ("asides"). */
528
644
  #pendingNextTurnMessages: CustomMessage[] = [];
529
645
  #scheduledHiddenNextTurnGeneration: number | undefined = undefined;
530
646
  #planModeState: PlanModeState | undefined;
647
+ #goalModeState: GoalModeState | undefined;
648
+ #goalRuntime: GoalRuntime;
649
+ #goalTurnCounter = 0;
531
650
  #planReferenceSent = false;
532
651
  #planReferencePath = "local://PLAN.md";
652
+ #clientBridge: ClientBridge | undefined;
653
+ /** Per-session memory of allow_always / reject_always decisions for gated tools. */
654
+ #acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
533
655
 
534
656
  // Compaction state
535
657
  #compactionAbortController: AbortController | undefined = undefined;
@@ -634,6 +756,19 @@ export class AgentSession {
634
756
  #ttsrRetryToken = 0;
635
757
  #ttsrResumePromise: Promise<void> | undefined = undefined;
636
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;
637
772
  #postPromptTasks = new Set<Promise<void>>();
638
773
  #postPromptTasksPromise: Promise<void> | undefined = undefined;
639
774
  #postPromptTasksResolve: (() => void) | undefined = undefined;
@@ -783,6 +918,44 @@ export class AgentSession {
783
918
  this.agent.providerSessionState = this.#providerSessionState;
784
919
  this.#syncAgentSessionId();
785
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
+ });
786
959
 
787
960
  // Always subscribe to agent events for internal handling
788
961
  // (session persistence, hooks, auto-compaction, retry logic)
@@ -830,6 +1003,19 @@ export class AgentSession {
830
1003
  return this.#toolChoiceQueue.peekInFlightInvoker();
831
1004
  }
832
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
+
833
1019
  /** Provider-scoped mutable state store for transport/session caches. */
834
1020
  get providerSessionState(): Map<string, ProviderSessionState> {
835
1021
  return this.#providerSessionState;
@@ -855,6 +1041,49 @@ export class AgentSession {
855
1041
  return this.#ttsrAbortPending;
856
1042
  }
857
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
+
858
1087
  getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
859
1088
  const manager = AsyncJobManager.instance();
860
1089
  if (!manager) return null;
@@ -943,13 +1172,13 @@ export class AgentSession {
943
1172
  if (event.type === "message_start" && event.message.role === "user") {
944
1173
  const messageText = this.#getUserMessageText(event.message);
945
1174
  if (messageText) {
946
- // Check steering queue first
947
- 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);
948
1177
  if (steeringIndex !== -1) {
949
1178
  this.#steeringMessages.splice(steeringIndex, 1);
950
1179
  } else {
951
1180
  // Check follow-up queue
952
- const followUpIndex = this.#followUpMessages.indexOf(messageText);
1181
+ const followUpIndex = this.#followUpMessages.findIndex(e => e.text === messageText);
953
1182
  if (followUpIndex !== -1) {
954
1183
  this.#followUpMessages.splice(followUpIndex, 1);
955
1184
  }
@@ -957,6 +1186,48 @@ export class AgentSession {
957
1186
  }
958
1187
  }
959
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
+
960
1231
  // Deobfuscate assistant message content for display emission — the LLM echoes back
961
1232
  // obfuscated placeholders, but listeners (TUI, extensions, exporters) must see real
962
1233
  // values. The original event.message stays obfuscated so the persistence path below
@@ -972,6 +1243,16 @@ export class AgentSession {
972
1243
  }
973
1244
  }
974
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
+
975
1256
  await this.#emitSessionEvent(displayEvent);
976
1257
 
977
1258
  if (event.type === "turn_start") {
@@ -995,6 +1276,13 @@ export class AgentSession {
995
1276
  this.#toolChoiceQueue.resolve();
996
1277
  }
997
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
+ }
998
1286
  if (event.type === "tool_execution_end" && event.toolName === "yield" && !event.isError) {
999
1287
  this.#lastSuccessfulYieldToolCallId = event.toolCallId;
1000
1288
  }
@@ -1230,6 +1518,15 @@ export class AgentSession {
1230
1518
 
1231
1519
  // Check auto-retry and auto-compaction after agent completes
1232
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
+ });
1233
1530
  const fallbackAssistant = [...event.messages]
1234
1531
  .reverse()
1235
1532
  .find((message): message is AssistantMessage => message.role === "assistant");
@@ -1351,7 +1648,7 @@ export class AgentSession {
1351
1648
  const scheduled = (async () => {
1352
1649
  if (delayMs > 0) {
1353
1650
  try {
1354
- await abortableSleep(delayMs, signal);
1651
+ await scheduler.wait(delayMs, { signal });
1355
1652
  } catch {
1356
1653
  return;
1357
1654
  }
@@ -1385,7 +1682,10 @@ export class AgentSession {
1385
1682
  try {
1386
1683
  await this.#maybeRestoreRetryFallbackPrimary();
1387
1684
  await this.agent.continue();
1388
- } catch {
1685
+ } catch (error) {
1686
+ logger.warn("agent.continue failed after scheduling", {
1687
+ error: error instanceof Error ? error.message : String(error),
1688
+ });
1389
1689
  options?.onError?.();
1390
1690
  }
1391
1691
  },
@@ -2085,6 +2385,12 @@ export class AgentSession {
2085
2385
  attempt: event.attempt,
2086
2386
  maxAttempts: event.maxAttempts,
2087
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
+ });
2088
2394
  }
2089
2395
  }
2090
2396
 
@@ -2477,7 +2783,7 @@ export class AgentSession {
2477
2783
 
2478
2784
  /** Collect built-in tools the model can discover via search_tool_bm25. Restricted to tool
2479
2785
  * definitions whose `loadMode === "discoverable"`. This keeps hidden/internal tools
2480
- * (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
2481
2787
  * and avoids mislabeling extension/custom default-inactive tools as built-ins. */
2482
2788
  #collectDiscoverableBuiltinTools(): DiscoverableTool[] {
2483
2789
  const activeNames = new Set(this.getActiveToolNames());
@@ -2547,6 +2853,85 @@ export class AgentSession {
2547
2853
  return [...new Set(activated)];
2548
2854
  }
2549
2855
 
2856
+ /**
2857
+ * Wrap a tool with a permission-gate proxy when an ACP client is connected.
2858
+ * Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
2859
+ * the bridge exposes `requestPermission`. No-ops for all other cases.
2860
+ */
2861
+ #wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
2862
+ const bridge = this.#clientBridge;
2863
+ // Match the capability+method gating pattern used by read/write/bash.
2864
+ if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
2865
+ if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
2866
+ return new Proxy(tool, {
2867
+ get: (target, prop, receiver) => {
2868
+ if (prop !== "execute") return Reflect.get(target, prop, receiver);
2869
+ return async (
2870
+ toolCallId: string,
2871
+ args: unknown,
2872
+ signal: AbortSignal | undefined,
2873
+ onUpdate: never,
2874
+ ctx: never,
2875
+ ) => {
2876
+ // Short-circuit on persisted decisions.
2877
+ const persisted = this.#acpPermissionDecisions.get(target.name);
2878
+ if (persisted === "allow_always") {
2879
+ return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
2880
+ }
2881
+ if (persisted === "reject_always") {
2882
+ throw new ToolError(`Tool call rejected by user (preference)`);
2883
+ }
2884
+ if (signal?.aborted) {
2885
+ throw new ToolAbortError("Permission request cancelled");
2886
+ }
2887
+ type PermissionRaceResult =
2888
+ | { kind: "permission"; outcome: ClientBridgePermissionOutcome }
2889
+ | { kind: "aborted" };
2890
+ const { promise: abortPromise, resolve: resolveAbort } = Promise.withResolvers<PermissionRaceResult>();
2891
+ const onAbort = () => resolveAbort({ kind: "aborted" });
2892
+ signal?.addEventListener("abort", onAbort, { once: true });
2893
+ let raced: PermissionRaceResult;
2894
+ try {
2895
+ const permissionPromise = bridge.requestPermission!(
2896
+ {
2897
+ toolCallId,
2898
+ toolName: target.name,
2899
+ title: derivePermissionTitle(target.name, args),
2900
+ rawInput: args,
2901
+ locations: extractPermissionLocations(args, this.sessionManager.getCwd()),
2902
+ },
2903
+ PERMISSION_OPTIONS,
2904
+ signal,
2905
+ ).then(outcome => ({ kind: "permission" as const, outcome }));
2906
+ raced = await Promise.race([permissionPromise, abortPromise]);
2907
+ } finally {
2908
+ signal?.removeEventListener("abort", onAbort);
2909
+ }
2910
+ if (raced.kind === "aborted" || signal?.aborted) {
2911
+ throw new ToolAbortError("Permission request cancelled");
2912
+ }
2913
+ const outcome = raced.outcome;
2914
+ if (outcome.outcome === "cancelled") {
2915
+ throw new ToolAbortError("Permission request cancelled");
2916
+ }
2917
+ const selectedOption = PERMISSION_OPTIONS_BY_ID.get(outcome.optionId);
2918
+ if (!selectedOption) {
2919
+ throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
2920
+ }
2921
+ if (selectedOption.kind === "allow_always") {
2922
+ this.#acpPermissionDecisions.set(target.name, "allow_always");
2923
+ } else if (selectedOption.kind === "reject_always") {
2924
+ this.#acpPermissionDecisions.set(target.name, "reject_always");
2925
+ }
2926
+ if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
2927
+ throw new ToolError(`Tool call rejected by user (${target.name})`);
2928
+ }
2929
+ return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
2930
+ };
2931
+ },
2932
+ }) as T;
2933
+ }
2934
+
2550
2935
  async #applyActiveToolsByName(
2551
2936
  toolNames: string[],
2552
2937
  options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
@@ -2558,7 +2943,7 @@ export class AgentSession {
2558
2943
  for (const name of toolNames) {
2559
2944
  const tool = this.#toolRegistry.get(name);
2560
2945
  if (tool) {
2561
- tools.push(tool);
2946
+ tools.push(this.#wrapToolForAcpPermission(tool));
2562
2947
  validToolNames.push(name);
2563
2948
  }
2564
2949
  }
@@ -2566,7 +2951,7 @@ export class AgentSession {
2566
2951
  if (isAutoQaEnabled(this.settings) && !validToolNames.includes("report_tool_issue")) {
2567
2952
  const qaTool = this.#toolRegistry.get("report_tool_issue");
2568
2953
  if (qaTool) {
2569
- tools.push(qaTool);
2954
+ tools.push(this.#wrapToolForAcpPermission(qaTool));
2570
2955
  validToolNames.push("report_tool_issue");
2571
2956
  }
2572
2957
  }
@@ -2966,6 +3351,18 @@ export class AgentSession {
2966
3351
  }
2967
3352
  }
2968
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
+
2969
3366
  markPlanReferenceSent(): void {
2970
3367
  this.#planReferenceSent = true;
2971
3368
  }
@@ -2974,6 +3371,21 @@ export class AgentSession {
2974
3371
  this.#planReferencePath = path;
2975
3372
  }
2976
3373
 
3374
+ get clientBridge(): ClientBridge | undefined {
3375
+ return this.#clientBridge;
3376
+ }
3377
+
3378
+ setClientBridge(bridge: ClientBridge | undefined): void {
3379
+ this.#clientBridge = bridge;
3380
+ this.#acpPermissionDecisions.clear();
3381
+ const activeToolNames = this.getActiveToolNames();
3382
+ const activeTools = activeToolNames
3383
+ .map(name => this.#toolRegistry.get(name))
3384
+ .filter((tool): tool is AgentTool => tool !== undefined)
3385
+ .map(tool => this.#wrapToolForAcpPermission(tool));
3386
+ this.agent.setTools(activeTools);
3387
+ }
3388
+
2977
3389
  getCheckpointState(): CheckpointState | undefined {
2978
3390
  return this.#checkpointState;
2979
3391
  }
@@ -3002,6 +3414,21 @@ export class AgentSession {
3002
3414
  );
3003
3415
  }
3004
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
+
3005
3432
  resolveRoleModel(role: string): Model | undefined {
3006
3433
  return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model).model;
3007
3434
  }
@@ -3097,7 +3524,6 @@ export class AgentSession {
3097
3524
  askToolName: "ask",
3098
3525
  writeToolName: "write",
3099
3526
  editToolName: "edit",
3100
- exitToolName: "exit_plan_mode",
3101
3527
  reentry: state.reentry ?? false,
3102
3528
  iterative: state.workflow === "iterative",
3103
3529
  });
@@ -3112,6 +3538,19 @@ export class AgentSession {
3112
3538
  };
3113
3539
  }
3114
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
+
3115
3554
  /**
3116
3555
  * Send a prompt to the agent.
3117
3556
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -3287,6 +3726,10 @@ export class AgentSession {
3287
3726
  if (planModeMessage) {
3288
3727
  messages.push(planModeMessage);
3289
3728
  }
3729
+ const goalModeMessage = this.#buildGoalModeMessage();
3730
+ if (goalModeMessage) {
3731
+ messages.push(goalModeMessage);
3732
+ }
3290
3733
  if (options?.prependMessages) {
3291
3734
  messages.push(...options.prependMessages);
3292
3735
  }
@@ -3530,7 +3973,7 @@ export class AgentSession {
3530
3973
  */
3531
3974
  async #queueSteer(text: string, images?: ImageContent[]): Promise<void> {
3532
3975
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
3533
- this.#steeringMessages.push(displayText);
3976
+ this.#steeringMessages.push({ text: displayText });
3534
3977
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
3535
3978
  if (images && images.length > 0) {
3536
3979
  content.push(...images);
@@ -3548,7 +3991,7 @@ export class AgentSession {
3548
3991
  */
3549
3992
  async #queueFollowUp(text: string, images?: ImageContent[]): Promise<void> {
3550
3993
  const displayText = text || (images && images.length > 0 ? "[Image]" : "");
3551
- this.#followUpMessages.push(displayText);
3994
+ this.#followUpMessages.push({ text: displayText });
3552
3995
  const content: (TextContent | ImageContent)[] = [{ type: "text", text }];
3553
3996
  if (images && images.length > 0) {
3554
3997
  content.push(...images);
@@ -3784,8 +4227,8 @@ export class AgentSession {
3784
4227
  * Useful for restoring to editor when user aborts.
3785
4228
  */
3786
4229
  clearQueue(): { steering: string[]; followUp: string[] } {
3787
- const steering = [...this.#steeringMessages];
3788
- const followUp = [...this.#followUpMessages];
4230
+ const steering = this.#steeringMessages.map(e => e.text);
4231
+ const followUp = this.#followUpMessages.map(e => e.text);
3789
4232
  this.#steeringMessages = [];
3790
4233
  this.#followUpMessages = [];
3791
4234
  this.agent.clearAllQueues();
@@ -3797,27 +4240,35 @@ export class AgentSession {
3797
4240
  return this.#steeringMessages.length + this.#followUpMessages.length + this.#pendingNextTurnMessages.length;
3798
4241
  }
3799
4242
 
3800
- /** 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. */
3801
4247
  getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
3802
- 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
+ };
3803
4252
  }
3804
4253
 
3805
4254
  /**
3806
4255
  * Pop the last queued message (steering first, then follow-up).
3807
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.
3808
4259
  */
3809
4260
  popLastQueuedMessage(): string | undefined {
3810
4261
  // Pop from steering first (LIFO)
3811
4262
  if (this.#steeringMessages.length > 0) {
3812
- const message = this.#steeringMessages.pop();
4263
+ const entry = this.#steeringMessages.pop();
3813
4264
  this.agent.popLastSteer();
3814
- return message;
4265
+ return entry?.text;
3815
4266
  }
3816
4267
  // Then from follow-up
3817
4268
  if (this.#followUpMessages.length > 0) {
3818
- const message = this.#followUpMessages.pop();
4269
+ const entry = this.#followUpMessages.pop();
3819
4270
  this.agent.popLastFollowUp();
3820
- return message;
4271
+ return entry?.text;
3821
4272
  }
3822
4273
  return undefined;
3823
4274
  }
@@ -3931,7 +4382,7 @@ export class AgentSession {
3931
4382
  /**
3932
4383
  * Abort current operation and wait for agent to become idle.
3933
4384
  */
3934
- async abort(): Promise<void> {
4385
+ async abort(options?: { goalReason?: "interrupted" | "internal" }): Promise<void> {
3935
4386
  this.abortRetry();
3936
4387
  this.#promptGeneration++;
3937
4388
  this.#scheduledHiddenNextTurnGeneration = undefined;
@@ -3943,6 +4394,7 @@ export class AgentSession {
3943
4394
  this.agent.abort();
3944
4395
  await postPromptDrain;
3945
4396
  await this.agent.waitForIdle();
4397
+ await this.#goalRuntime.onTaskAborted({ reason: options?.goalReason ?? "interrupted" });
3946
4398
  // Clear prompt-in-flight state: waitForIdle resolves when the agent loop's finally
3947
4399
  // block runs, but nested prompt setup/finalizers may still be unwinding. Without this,
3948
4400
  // a subsequent prompt() can incorrectly observe the session as busy after an abort.
@@ -4356,6 +4808,7 @@ export class AgentSession {
4356
4808
  if (persist && effectiveLevel !== undefined && effectiveLevel !== ThinkingLevel.Off) {
4357
4809
  this.settings.set("defaultThinkingLevel", effectiveLevel);
4358
4810
  }
4811
+ this.#emit({ type: "thinking_level_changed", thinkingLevel: effectiveLevel });
4359
4812
  }
4360
4813
  }
4361
4814
 
@@ -4489,8 +4942,6 @@ export class AgentSession {
4489
4942
 
4490
4943
  let hookCompaction: CompactionResult | undefined;
4491
4944
  let fromExtension = false;
4492
- let hookContext: string[] | undefined;
4493
- let hookPrompt: string | undefined;
4494
4945
  let preserveData: Record<string, unknown> | undefined;
4495
4946
 
4496
4947
  if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -4503,7 +4954,7 @@ export class AgentSession {
4503
4954
  })) as SessionBeforeCompactResult | undefined;
4504
4955
 
4505
4956
  if (result?.cancel) {
4506
- throw new Error("Compaction cancelled");
4957
+ throw new CompactionCancelledError();
4507
4958
  }
4508
4959
 
4509
4960
  if (result?.compaction) {
@@ -4512,23 +4963,7 @@ export class AgentSession {
4512
4963
  }
4513
4964
  }
4514
4965
 
4515
- if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
4516
- const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
4517
- const result = (await this.#extensionRunner.emit({
4518
- type: "session.compacting",
4519
- sessionId: this.sessionId,
4520
- messages: compactMessages,
4521
- })) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
4522
-
4523
- hookContext = result?.context;
4524
- hookPrompt = result?.prompt;
4525
- preserveData = result?.preserveData;
4526
- }
4527
-
4528
- const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
4529
- if (memoryBackendContext) {
4530
- hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
4531
- }
4966
+ const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
4532
4967
 
4533
4968
  let summary: string;
4534
4969
  let shortSummary: string | undefined;
@@ -4536,36 +4971,55 @@ export class AgentSession {
4536
4971
  let tokensBefore: number;
4537
4972
  let details: unknown;
4538
4973
 
4539
- if (hookCompaction) {
4540
- // Extension provided compaction content
4541
- summary = hookCompaction.summary;
4542
- shortSummary = hookCompaction.shortSummary;
4543
- firstKeptEntryId = hookCompaction.firstKeptEntryId;
4544
- tokensBefore = hookCompaction.tokensBefore;
4545
- details = hookCompaction.details;
4546
- 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;
4547
4981
  } else {
4548
- // Generate compaction result
4549
- const result = await this.#compactWithFallbackModel(
4550
- preparation,
4551
- customInstructions,
4552
- compactionAbortController.signal,
4553
- {
4554
- promptOverride: hookPrompt,
4555
- extraContext: hookContext,
4556
- remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
4557
- },
4558
- );
4559
- summary = result.summary;
4560
- shortSummary = result.shortSummary;
4561
- firstKeptEntryId = result.firstKeptEntryId;
4562
- tokensBefore = result.tokensBefore;
4563
- details = result.details;
4564
- preserveData = { ...(preserveData ?? {}), ...(result.preserveData ?? {}) };
4982
+ // Generate compaction result. Only convert known abort-shaped
4983
+ // rejections (AbortError raised while the abort signal is set,
4984
+ // or an already-typed sentinel) into `CompactionCancelledError`
4985
+ // so downstream callers can discriminate cancel from generic
4986
+ // failure via `instanceof` without inspecting message strings.
4987
+ // Real compaction bugs (network, server, parsing, etc.) keep
4988
+ // their original shape — they must not be silently relabeled
4989
+ // as cancellations even if the signal happens to be aborted
4990
+ // for an unrelated reason. Assignments live inside the try
4991
+ // block because every catch path throws — the post-try reads
4992
+ // of the result-derived locals are reachable only on success.
4993
+ try {
4994
+ const result = await this.#compactWithFallbackModel(
4995
+ preparation,
4996
+ customInstructions,
4997
+ compactionAbortController.signal,
4998
+ {
4999
+ promptOverride: compactionPrep.hookPrompt,
5000
+ extraContext: compactionPrep.hookContext,
5001
+ remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
5002
+ },
5003
+ );
5004
+ summary = result.summary;
5005
+ shortSummary = result.shortSummary;
5006
+ firstKeptEntryId = result.firstKeptEntryId;
5007
+ tokensBefore = result.tokensBefore;
5008
+ details = result.details;
5009
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(result.preserveData ?? {}) };
5010
+ } catch (err) {
5011
+ if (err instanceof CompactionCancelledError) {
5012
+ throw err;
5013
+ }
5014
+ if (compactionAbortController.signal.aborted && err instanceof Error && err.name === "AbortError") {
5015
+ throw new CompactionCancelledError();
5016
+ }
5017
+ throw err;
5018
+ }
4565
5019
  }
4566
5020
 
4567
5021
  if (compactionAbortController.signal.aborted) {
4568
- throw new Error("Compaction cancelled");
5022
+ throw new CompactionCancelledError();
4569
5023
  }
4570
5024
 
4571
5025
  this.sessionManager.appendCompaction(
@@ -4990,14 +5444,14 @@ export class AgentSession {
4990
5444
  }
4991
5445
 
4992
5446
  const calledRequiredTool = assistantMessage.content.some(
4993
- content => content.type === "toolCall" && (content.name === "ask" || content.name === "exit_plan_mode"),
5447
+ content => content.type === "toolCall" && (content.name === "ask" || content.name === "resolve"),
4994
5448
  );
4995
5449
  if (calledRequiredTool) {
4996
5450
  return;
4997
5451
  }
4998
- const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("exit_plan_mode");
5452
+ const hasRequiredTools = this.#toolRegistry.has("ask") && this.#toolRegistry.has("resolve");
4999
5453
  if (!hasRequiredTools) {
5000
- 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", {
5001
5455
  activeToolNames: this.agent.state.tools.map(tool => tool.name),
5002
5456
  });
5003
5457
  return;
@@ -5005,7 +5459,6 @@ export class AgentSession {
5005
5459
 
5006
5460
  const reminder = prompt.render(planModeToolDecisionReminderPrompt, {
5007
5461
  askToolName: "ask",
5008
- exitToolName: "exit_plan_mode",
5009
5462
  });
5010
5463
 
5011
5464
  await this.prompt(reminder, {
@@ -5512,6 +5965,64 @@ export class AgentSession {
5512
5965
  throw this.#buildCompactionAuthError();
5513
5966
  }
5514
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
+
5515
6026
  /**
5516
6027
  * Internal: Run auto-compaction with events.
5517
6028
  */
@@ -5633,8 +6144,6 @@ export class AgentSession {
5633
6144
 
5634
6145
  let hookCompaction: CompactionResult | undefined;
5635
6146
  let fromExtension = false;
5636
- let hookContext: string[] | undefined;
5637
- let hookPrompt: string | undefined;
5638
6147
  let preserveData: Record<string, unknown> | undefined;
5639
6148
 
5640
6149
  if (this.#extensionRunner?.hasHandlers("session_before_compact")) {
@@ -5663,23 +6172,7 @@ export class AgentSession {
5663
6172
  }
5664
6173
  }
5665
6174
 
5666
- if (!hookCompaction && this.#extensionRunner?.hasHandlers("session.compacting")) {
5667
- const compactMessages = preparation.messagesToSummarize.concat(preparation.turnPrefixMessages);
5668
- const result = (await this.#extensionRunner.emit({
5669
- type: "session.compacting",
5670
- sessionId: this.sessionId,
5671
- messages: compactMessages,
5672
- })) as { context?: string[]; prompt?: string; preserveData?: Record<string, unknown> } | undefined;
5673
-
5674
- hookContext = result?.context;
5675
- hookPrompt = result?.prompt;
5676
- preserveData = result?.preserveData;
5677
- }
5678
-
5679
- const memoryBackendContext = await this.#collectMemoryBackendContext(preparation);
5680
- if (memoryBackendContext) {
5681
- hookContext = hookContext ? [...hookContext, memoryBackendContext] : [memoryBackendContext];
5682
- }
6175
+ const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
5683
6176
 
5684
6177
  let summary: string;
5685
6178
  let shortSummary: string | undefined;
@@ -5687,14 +6180,13 @@ export class AgentSession {
5687
6180
  let tokensBefore: number;
5688
6181
  let details: unknown;
5689
6182
 
5690
- if (hookCompaction) {
5691
- // Extension provided compaction content
5692
- summary = hookCompaction.summary;
5693
- shortSummary = hookCompaction.shortSummary;
5694
- firstKeptEntryId = hookCompaction.firstKeptEntryId;
5695
- tokensBefore = hookCompaction.tokensBefore;
5696
- details = hookCompaction.details;
5697
- 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;
5698
6190
  } else {
5699
6191
  const candidates = this.#getCompactionModelCandidates(availableModels);
5700
6192
  const retrySettings = this.settings.getGroup("retry");
@@ -5709,8 +6201,8 @@ export class AgentSession {
5709
6201
  while (true) {
5710
6202
  try {
5711
6203
  compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
5712
- promptOverride: hookPrompt,
5713
- extraContext: hookContext,
6204
+ promptOverride: compactionPrep.hookPrompt,
6205
+ extraContext: compactionPrep.hookContext,
5714
6206
  remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
5715
6207
  metadata: this.agent.metadataForProvider(candidate.provider),
5716
6208
  initiatorOverride: "agent",
@@ -5767,7 +6259,7 @@ export class AgentSession {
5767
6259
  error: message,
5768
6260
  model: `${candidate.provider}/${candidate.id}`,
5769
6261
  });
5770
- await abortableSleep(delayMs, autoCompactionSignal);
6262
+ await scheduler.wait(delayMs, { signal: autoCompactionSignal });
5771
6263
  }
5772
6264
  }
5773
6265
 
@@ -5788,7 +6280,7 @@ export class AgentSession {
5788
6280
  firstKeptEntryId = compactResult.firstKeptEntryId;
5789
6281
  tokensBefore = compactResult.tokensBefore;
5790
6282
  details = compactResult.details;
5791
- preserveData = { ...(preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
6283
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(compactResult.preserveData ?? {}) };
5792
6284
  }
5793
6285
 
5794
6286
  if (autoCompactionSignal.aborted) {
@@ -5939,10 +6431,11 @@ export class AgentSession {
5939
6431
 
5940
6432
  #isTransientTransportErrorMessage(errorMessage: string): boolean {
5941
6433
  // Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
5942
- // 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
5943
6436
  return (
5944
6437
  isUnexpectedSocketCloseMessage(errorMessage) ||
5945
- /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(
5946
6439
  errorMessage,
5947
6440
  )
5948
6441
  );
@@ -6292,7 +6785,7 @@ export class AgentSession {
6292
6785
  this.#retryAbortController?.abort();
6293
6786
  this.#retryAbortController = retryAbortController;
6294
6787
  try {
6295
- await abortableSleep(delayMs, retryAbortController.signal);
6788
+ await scheduler.wait(delayMs, { signal: retryAbortController.signal });
6296
6789
  } catch {
6297
6790
  if (this.#retryAbortController !== retryAbortController) {
6298
6791
  return false;
@@ -7624,21 +8117,11 @@ export class AgentSession {
7624
8117
  * @returns Text content, or undefined if no assistant message exists
7625
8118
  */
7626
8119
  getLastAssistantText(): string | undefined {
7627
- const lastAssistant = this.messages
7628
- .slice()
7629
- .reverse()
7630
- .find(m => {
7631
- if (m.role !== "assistant") return false;
7632
- const msg = m as AssistantMessage;
7633
- // Skip aborted messages with no content
7634
- if (msg.stopReason === "aborted" && msg.content.length === 0) return false;
7635
- return true;
7636
- });
7637
-
8120
+ const lastAssistant = this.#getLastCopyCandidateAssistantMessage();
7638
8121
  if (!lastAssistant) return undefined;
7639
8122
 
7640
8123
  let text = "";
7641
- for (const content of (lastAssistant as AssistantMessage).content) {
8124
+ for (const content of lastAssistant.content) {
7642
8125
  if (content.type === "text") {
7643
8126
  text += content.text;
7644
8127
  }
@@ -7647,6 +8130,54 @@ export class AgentSession {
7647
8130
  return text.trim() || undefined;
7648
8131
  }
7649
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
+
7650
8181
  /**
7651
8182
  * Format the entire session as plain text for clipboard export.
7652
8183
  * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.