@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
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import * as fs from "node:fs/promises";
6
6
  import * as path from "node:path";
7
- import { type Agent, type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
+ import { type Agent, type AgentMessage, type AgentToolResult, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
8
8
  import {
9
9
  type AssistantMessage,
10
10
  type ImageContent,
@@ -37,18 +37,26 @@ import type {
37
37
  } from "../extensibility/extensions";
38
38
  import type { CompactOptions } from "../extensibility/extensions/types";
39
39
  import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
40
+ import type { Goal, GoalModeState } from "../goals/state";
40
41
  import { resolveLocalUrlToPath } from "../internal-urls";
41
42
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
42
- import { renameApprovedPlanFile } from "../plan-mode/approved-plan";
43
+ import { normalizePlanTitle, type PlanApprovalDetails, renameApprovedPlanFile } from "../plan-mode/approved-plan";
43
44
  import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
45
+ import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
46
+ type: "text",
47
+ };
44
48
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
49
+ import type { CompactionOutcome } from "../session/compaction";
45
50
  import { HistoryStorage } from "../session/history-storage";
46
51
  import type { SessionContext, SessionManager } from "../session/session-manager";
47
52
  import { getRecentSessions } from "../session/session-manager";
53
+ import { formatDuration } from "../slash-commands/helpers/format";
48
54
  import { STTController, type SttState } from "../stt";
49
- import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
55
+ import type { LspStartupServerInfo } from "../tools";
50
56
  import { normalizeLocalScheme } from "../tools/path-utils";
57
+ import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
51
58
  import { formatPhaseDisplayName } from "../tools/todo-write";
59
+ import { ToolError } from "../tools/tool-errors";
52
60
  import type { EventBus } from "../utils/event-bus";
53
61
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
54
62
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
@@ -123,6 +131,22 @@ function formatHudNoteMarker(count: number): string {
123
131
  return theme.fg("dim", chalk.italic(` \u207a${sub}`));
124
132
  }
125
133
 
134
+ type GoalSubcommand = "set" | "show" | "pause" | "resume" | "drop" | "budget";
135
+
136
+ const GOAL_SUBCOMMANDS = new Set<GoalSubcommand>(["set", "show", "pause", "resume", "drop", "budget"]);
137
+
138
+ function parseGoalSubcommand(args: string): { sub: GoalSubcommand | undefined; rest: string } {
139
+ const trimmed = args.trim();
140
+ if (!trimmed) return { sub: undefined, rest: "" };
141
+ const match = /^(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
142
+ if (!match) return { sub: undefined, rest: trimmed };
143
+ const first = match[1].toLowerCase();
144
+ if (GOAL_SUBCOMMANDS.has(first as GoalSubcommand)) {
145
+ return { sub: first as GoalSubcommand, rest: match[2]?.trim() ?? "" };
146
+ }
147
+ return { sub: undefined, rest: trimmed };
148
+ }
149
+
126
150
  /** Options for creating an InteractiveMode instance (for future API use) */
127
151
  export interface InteractiveModeOptions {
128
152
  /** Providers that were migrated during startup */
@@ -164,6 +188,8 @@ export class InteractiveMode implements InteractiveModeContext {
164
188
  todoExpanded = false;
165
189
  planModeEnabled = false;
166
190
  planModePaused = false;
191
+ goalModeEnabled = false;
192
+ goalModePaused = false;
167
193
  planModePlanFilePath: string | undefined = undefined;
168
194
  loopModeEnabled = false;
169
195
  loopPrompt: string | undefined = undefined;
@@ -212,6 +238,11 @@ export class InteractiveMode implements InteractiveModeContext {
212
238
  readonly #version: string;
213
239
  readonly #changelogMarkdown: string | undefined;
214
240
  #planModePreviousTools: string[] | undefined;
241
+ #goalModePreviousTools: string[] | undefined;
242
+ #goalContinuationTimer: NodeJS.Timeout | undefined;
243
+ #goalTurnHadToolCalls = false;
244
+ #goalContinuationTurnInFlight = false;
245
+ #goalSuppressNextContinuation = false;
215
246
  #planModePreviousModelState: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
216
247
  #pendingModelSwitch: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
217
248
  #planModeHasEntered = false;
@@ -473,6 +504,11 @@ export class InteractiveMode implements InteractiveModeContext {
473
504
  // Subscribe to agent events
474
505
  this.#subscribeToAgent();
475
506
 
507
+ this.#eventBusUnsubscribers.push(
508
+ this.session.subscribe(event => {
509
+ void this.#handleGoalSessionEvent(event);
510
+ }),
511
+ );
476
512
  // Set up theme file watcher
477
513
  onThemeChange(() => {
478
514
  clearRenderCache();
@@ -516,12 +552,16 @@ export class InteractiveMode implements InteractiveModeContext {
516
552
  }
517
553
 
518
554
  async getUserInput(): Promise<SubmittedUserInput> {
555
+ if (this.session.getGoalModeState()?.mode === "exiting") {
556
+ await this.#exitGoalMode({ reason: "completed", silent: true });
557
+ }
519
558
  const { promise, resolve } = Promise.withResolvers<SubmittedUserInput>();
520
559
  this.onInputCallback = input => {
521
560
  this.onInputCallback = undefined;
522
561
  resolve(input);
523
562
  };
524
563
  this.#scheduleLoopAutoSubmit();
564
+ this.#scheduleGoalContinuation();
525
565
  return promise;
526
566
  }
527
567
 
@@ -545,6 +585,48 @@ export class InteractiveMode implements InteractiveModeContext {
545
585
  }
546
586
  }
547
587
 
588
+ #scheduleGoalContinuation(): void {
589
+ this.#cancelGoalContinuation();
590
+ if (this.loopModeEnabled) return;
591
+ if (!this.onInputCallback) return;
592
+ if (!this.session.settings.get("goal.continuationModes").includes("interactive")) return;
593
+ if (this.planModeEnabled || this.planModePaused) return;
594
+ if (!this.goalModeEnabled || this.goalModePaused) return;
595
+ if (this.#goalSuppressNextContinuation) return;
596
+ if (this.#pendingSubmittedInput) return;
597
+ if (this.editor.getText().trim().length > 0) return;
598
+ if ((this.pendingImages?.length ?? 0) > 0) return;
599
+ const state = this.session.getGoalModeState();
600
+ if (!state?.enabled || state.goal.status !== "active") return;
601
+ const prompt = this.session.goalRuntime.buildContinuationPrompt();
602
+ if (!prompt) return;
603
+ this.#goalContinuationTimer = setTimeout(() => {
604
+ this.#goalContinuationTimer = undefined;
605
+ if (!this.onInputCallback) return;
606
+ if (!this.goalModeEnabled || this.goalModePaused) return;
607
+ if (this.#pendingSubmittedInput) return;
608
+ if (this.editor.getText().trim().length > 0) return;
609
+ if ((this.pendingImages?.length ?? 0) > 0) return;
610
+ const latestState = this.session.getGoalModeState();
611
+ if (!latestState?.enabled || latestState.goal.status !== "active") return;
612
+ this.#goalContinuationTurnInFlight = true;
613
+ this.onInputCallback(
614
+ this.startPendingSubmission({
615
+ text: prompt,
616
+ customType: "goal-continuation",
617
+ display: false,
618
+ }),
619
+ );
620
+ }, 800);
621
+ }
622
+
623
+ #cancelGoalContinuation(): void {
624
+ if (this.#goalContinuationTimer) {
625
+ clearTimeout(this.#goalContinuationTimer);
626
+ this.#goalContinuationTimer = undefined;
627
+ }
628
+ }
629
+
548
630
  async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
549
631
  if (!consumeLoopLimitIteration(this.loopLimit)) {
550
632
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
@@ -635,23 +717,36 @@ export class InteractiveMode implements InteractiveModeContext {
635
717
  }
636
718
  }
637
719
 
638
- startPendingSubmission(input: { text: string; images?: ImageContent[] }): SubmittedUserInput {
720
+ startPendingSubmission(input: {
721
+ text: string;
722
+ images?: ImageContent[];
723
+ customType?: string;
724
+ display?: boolean;
725
+ }): SubmittedUserInput {
639
726
  const submission: SubmittedUserInput = {
640
727
  text: input.text,
641
728
  images: input.images,
729
+ customType: input.customType,
730
+ display: input.display,
642
731
  cancelled: false,
643
732
  started: false,
644
733
  };
645
734
  this.#pendingSubmittedInput = submission;
646
- const imageCount = submission.images?.length ?? 0;
647
- this.optimisticUserMessageSignature = `${submission.text}\u0000${imageCount}`;
648
- this.#pendingSubmissionDispose = this.recordLocalSubmission(submission.text, imageCount);
649
- this.addMessageToChat({
650
- role: "user",
651
- content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
652
- attribution: "user",
653
- timestamp: Date.now(),
654
- });
735
+ if (!submission.customType) {
736
+ this.#resetGoalContinuationSuppression();
737
+ const imageCount = submission.images?.length ?? 0;
738
+ this.optimisticUserMessageSignature = `${submission.text}\u0000${imageCount}`;
739
+ this.#pendingSubmissionDispose = this.recordLocalSubmission(submission.text, imageCount);
740
+ this.addMessageToChat({
741
+ role: "user",
742
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
743
+ attribution: "user",
744
+ timestamp: Date.now(),
745
+ });
746
+ } else {
747
+ this.optimisticUserMessageSignature = undefined;
748
+ this.#pendingSubmissionDispose = undefined;
749
+ }
655
750
  this.editor.setText("");
656
751
  this.ensureLoadingAnimation();
657
752
  this.ui.requestRender();
@@ -670,14 +765,19 @@ export class InteractiveMode implements InteractiveModeContext {
670
765
  this.#pendingSubmissionDispose?.();
671
766
  this.#pendingSubmissionDispose = undefined;
672
767
  this.#pendingWorkingMessage = undefined;
768
+ if (submission.customType === "goal-continuation") {
769
+ this.#goalContinuationTurnInFlight = false;
770
+ }
673
771
  if (this.loadingAnimation) {
674
772
  this.loadingAnimation.stop();
675
773
  this.loadingAnimation = undefined;
676
774
  this.statusContainer.clear();
677
775
  }
678
- this.pendingImages = submission.images ? [...submission.images] : [];
679
- this.rebuildChatFromMessages();
680
- this.editor.setText(submission.text);
776
+ if (!submission.customType) {
777
+ this.pendingImages = submission.images ? [...submission.images] : [];
778
+ this.rebuildChatFromMessages();
779
+ this.editor.setText(submission.text);
780
+ }
681
781
  this.updateEditorBorderColor();
682
782
  this.ui.requestRender();
683
783
  return true;
@@ -698,6 +798,9 @@ export class InteractiveMode implements InteractiveModeContext {
698
798
  this.#pendingSubmittedInput = undefined;
699
799
  this.#pendingSubmissionDispose = undefined;
700
800
  }
801
+ if (input.customType === "goal-continuation") {
802
+ this.#goalContinuationTurnInFlight = false;
803
+ }
701
804
 
702
805
  if (wasPendingSubmission && !this.session.isStreaming && !this.streamingComponent) {
703
806
  this.optimisticUserMessageSignature = undefined;
@@ -853,6 +956,95 @@ export class InteractiveMode implements InteractiveModeContext {
853
956
  this.ui.requestRender();
854
957
  }
855
958
 
959
+ #updateGoalModeStatus(): void {
960
+ const status =
961
+ this.goalModeEnabled || this.goalModePaused
962
+ ? { enabled: this.goalModeEnabled, paused: this.goalModePaused }
963
+ : undefined;
964
+ this.statusLine.setGoalModeStatus(status);
965
+ this.updateEditorTopBorder();
966
+ this.ui.requestRender();
967
+ }
968
+
969
+ #resetGoalContinuationSuppression(): void {
970
+ this.#goalSuppressNextContinuation = false;
971
+ }
972
+
973
+ #getPausedGoalState(): GoalModeState | undefined {
974
+ const state = this.session.getGoalModeState();
975
+ if (!state?.goal || state.enabled || state.goal.status !== "paused") {
976
+ return undefined;
977
+ }
978
+ return state;
979
+ }
980
+
981
+ #goalFromModeData(modeData: SessionContext["modeData"]): Goal | undefined {
982
+ const goal = modeData?.goal;
983
+ if (!goal || typeof goal !== "object") return undefined;
984
+ const value = goal as Record<string, unknown>;
985
+ if (
986
+ typeof value.id !== "string" ||
987
+ typeof value.objective !== "string" ||
988
+ typeof value.status !== "string" ||
989
+ typeof value.tokensUsed !== "number" ||
990
+ typeof value.timeUsedSeconds !== "number" ||
991
+ typeof value.createdAt !== "number" ||
992
+ typeof value.updatedAt !== "number"
993
+ ) {
994
+ return undefined;
995
+ }
996
+ return {
997
+ id: value.id,
998
+ objective: value.objective,
999
+ status: value.status as Goal["status"],
1000
+ tokenBudget: typeof value.tokenBudget === "number" ? value.tokenBudget : undefined,
1001
+ tokensUsed: value.tokensUsed,
1002
+ timeUsedSeconds: value.timeUsedSeconds,
1003
+ createdAt: value.createdAt,
1004
+ updatedAt: value.updatedAt,
1005
+ };
1006
+ }
1007
+
1008
+ async #handleGoalSessionEvent(event: AgentSessionEvent): Promise<void> {
1009
+ if (event.type === "agent_start") {
1010
+ this.#goalTurnHadToolCalls = false;
1011
+ this.#cancelGoalContinuation();
1012
+ return;
1013
+ }
1014
+ if (event.type === "tool_execution_start") {
1015
+ this.#goalTurnHadToolCalls = true;
1016
+ if (!this.#goalContinuationTurnInFlight) {
1017
+ this.#resetGoalContinuationSuppression();
1018
+ }
1019
+ return;
1020
+ }
1021
+ if (event.type === "message_start" && event.message.role === "user" && !event.message.synthetic) {
1022
+ this.#resetGoalContinuationSuppression();
1023
+ return;
1024
+ }
1025
+ if (event.type === "goal_updated") {
1026
+ this.goalModeEnabled = event.state?.enabled === true;
1027
+ this.goalModePaused = event.state?.enabled !== true && event.state?.goal?.status === "paused";
1028
+ if (!event.state?.enabled) {
1029
+ this.#cancelGoalContinuation();
1030
+ }
1031
+ this.#updateGoalModeStatus();
1032
+ return;
1033
+ }
1034
+ if (event.type !== "agent_end") {
1035
+ return;
1036
+ }
1037
+ if (this.#goalContinuationTurnInFlight) {
1038
+ this.#goalSuppressNextContinuation = !this.#goalTurnHadToolCalls;
1039
+ this.#goalContinuationTurnInFlight = false;
1040
+ }
1041
+ if (this.session.getGoalModeState()?.mode === "exiting") {
1042
+ await this.#exitGoalMode({ reason: "completed", silent: true });
1043
+ return;
1044
+ }
1045
+ this.#scheduleGoalContinuation();
1046
+ }
1047
+
856
1048
  async #applyPlanModeModel(): Promise<void> {
857
1049
  const resolved = this.session.resolveRoleModelWithThinking("plan");
858
1050
  if (!resolved.model) return;
@@ -899,6 +1091,28 @@ export class InteractiveMode implements InteractiveModeContext {
899
1091
  /** Restore mode state from session entries on resume (e.g. plan mode). */
900
1092
  async #restoreModeFromSession(): Promise<void> {
901
1093
  const sessionContext = this.sessionManager.buildSessionContext();
1094
+ const goalEnabled = this.session.settings.get("goal.enabled");
1095
+ if (!goalEnabled && (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused")) {
1096
+ this.sessionManager.appendModeChange("none");
1097
+ return;
1098
+ }
1099
+ if (sessionContext.mode === "goal" || sessionContext.mode === "goal_paused") {
1100
+ const goal = this.#goalFromModeData(sessionContext.modeData);
1101
+ if (!goal) {
1102
+ this.sessionManager.appendModeChange("none");
1103
+ return;
1104
+ }
1105
+ this.session.setGoalModeState({
1106
+ enabled: sessionContext.mode === "goal",
1107
+ mode: "active",
1108
+ goal,
1109
+ });
1110
+ const restored = await this.session.goalRuntime.onThreadResumed();
1111
+ this.goalModeEnabled = restored?.enabled === true;
1112
+ this.goalModePaused = restored?.enabled !== true && restored?.goal.status === "paused";
1113
+ this.#updateGoalModeStatus();
1114
+ return;
1115
+ }
902
1116
  if (!this.session.settings.get("plan.enabled")) {
903
1117
  // Clear stale plan/plan_paused mode so re-enabling the setting
904
1118
  // later doesn't unexpectedly restore an old plan session.
@@ -921,13 +1135,17 @@ export class InteractiveMode implements InteractiveModeContext {
921
1135
  if (this.planModeEnabled) {
922
1136
  return;
923
1137
  }
1138
+ if (this.goalModeEnabled || this.goalModePaused) {
1139
+ this.showWarning("Exit goal mode first.");
1140
+ return;
1141
+ }
924
1142
 
925
1143
  this.planModePaused = false;
926
1144
 
927
1145
  const planFilePath = options?.planFilePath ?? (await this.#getPlanFilePath());
928
1146
  const previousTools = this.session.getActiveToolNames();
929
- const hasExitTool = this.session.getToolByName("exit_plan_mode") !== undefined;
930
- const planTools = hasExitTool ? [...previousTools, "exit_plan_mode"] : previousTools;
1147
+ const hasResolveTool = this.session.getToolByName("resolve") !== undefined;
1148
+ const planTools = hasResolveTool ? [...previousTools, "resolve"] : previousTools;
931
1149
  const uniquePlanTools = [...new Set(planTools)];
932
1150
 
933
1151
  this.#planModePreviousTools = previousTools;
@@ -941,6 +1159,7 @@ export class InteractiveMode implements InteractiveModeContext {
941
1159
  workflow: options?.workflow ?? "parallel",
942
1160
  reentry: this.#planModeHasEntered,
943
1161
  });
1162
+ this.session.setStandingResolveHandler?.(input => this.#runPlanApprovalResolve(input));
944
1163
  if (this.session.isStreaming) {
945
1164
  await this.session.sendPlanModeContext({ deliverAs: "steer" });
946
1165
  }
@@ -951,6 +1170,47 @@ export class InteractiveMode implements InteractiveModeContext {
951
1170
  this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
952
1171
  }
953
1172
 
1173
+ /** Standing resolve dispatcher registered while plan mode is active. The agent
1174
+ * submits the finalized plan by calling `resolve { action: "apply", extra: { title } }`;
1175
+ * this handler validates the plan file exists, normalizes the title, and shapes the
1176
+ * payload that `event-controller` forwards to `handlePlanApproval`. */
1177
+ #runPlanApprovalResolve(input: unknown): Promise<AgentToolResult<ResolveToolDetails>> {
1178
+ return runResolveInvocation(input as Parameters<typeof runResolveInvocation>[0], {
1179
+ sourceToolName: "plan_approval",
1180
+ label: "Plan ready for approval",
1181
+ apply: async (_reason, extra) => {
1182
+ const state = this.session.getPlanModeState?.();
1183
+ if (!state?.enabled) {
1184
+ throw new ToolError("Plan mode is not active.");
1185
+ }
1186
+ const title = extra?.title;
1187
+ if (typeof title !== "string" || title.trim() === "") {
1188
+ throw new ToolError(
1189
+ 'Plan approval requires `extra: { title: "<PLAN_TITLE>" }`. Provide a title with letters, numbers, underscores, or hyphens only.',
1190
+ );
1191
+ }
1192
+ const normalized = normalizePlanTitle(title);
1193
+ const planFilePath = state.planFilePath;
1194
+ const planContent = await this.#readPlanFile(planFilePath);
1195
+ if (planContent === null) {
1196
+ throw new ToolError(
1197
+ `Plan file not found at ${planFilePath}. Write the finalized plan to ${planFilePath} before requesting approval.`,
1198
+ );
1199
+ }
1200
+ const details: PlanApprovalDetails = {
1201
+ planFilePath,
1202
+ finalPlanFilePath: `local://${normalized.fileName}`,
1203
+ title: normalized.title,
1204
+ planExists: true,
1205
+ };
1206
+ return {
1207
+ content: [{ type: "text" as const, text: "Plan ready for approval." }],
1208
+ details,
1209
+ };
1210
+ },
1211
+ });
1212
+ }
1213
+
954
1214
  async #exitPlanMode(options?: { silent?: boolean; paused?: boolean }): Promise<void> {
955
1215
  if (!this.planModeEnabled) {
956
1216
  return;
@@ -986,6 +1246,7 @@ export class InteractiveMode implements InteractiveModeContext {
986
1246
  }
987
1247
  }
988
1248
  }
1249
+ this.session.setStandingResolveHandler?.(null);
989
1250
  this.session.setPlanModeState(undefined);
990
1251
  this.planModeEnabled = false;
991
1252
  this.planModePaused = options?.paused ?? false;
@@ -1000,6 +1261,73 @@ export class InteractiveMode implements InteractiveModeContext {
1000
1261
  }
1001
1262
  }
1002
1263
 
1264
+ async #enterGoalMode(options: { objective?: string; resume?: boolean; silent?: boolean }): Promise<void> {
1265
+ if (this.goalModeEnabled) {
1266
+ return;
1267
+ }
1268
+ if (this.planModeEnabled || this.planModePaused) {
1269
+ this.showWarning("Exit plan mode first.");
1270
+ return;
1271
+ }
1272
+ const previousTools = this.session.getActiveToolNames().filter(name => name !== "goal");
1273
+ const goalTools = [...new Set([...previousTools, "goal"])];
1274
+ this.#goalModePreviousTools = previousTools;
1275
+ this.goalModePaused = false;
1276
+ const state = options.resume
1277
+ ? await this.session.goalRuntime.resumeGoal()
1278
+ : await this.session.goalRuntime.createGoal({ objective: options.objective ?? "" });
1279
+ await this.session.setActiveToolsByName(goalTools);
1280
+ this.session.setGoalModeState(state);
1281
+ this.goalModeEnabled = true;
1282
+ this.#resetGoalContinuationSuppression();
1283
+ this.#updateGoalModeStatus();
1284
+ if (this.session.isStreaming) {
1285
+ await this.session.sendGoalModeContext({ deliverAs: "steer" });
1286
+ }
1287
+ if (!options.silent) {
1288
+ this.showStatus(options.resume ? "Goal mode resumed." : "Goal mode enabled.");
1289
+ }
1290
+ }
1291
+
1292
+ async #exitGoalMode(options?: {
1293
+ silent?: boolean;
1294
+ paused?: boolean;
1295
+ reason?: "completed" | "paused" | "dropped";
1296
+ }): Promise<void> {
1297
+ const previousTools = this.#goalModePreviousTools;
1298
+ if (this.goalModeEnabled && previousTools) {
1299
+ await this.session.setActiveToolsByName(previousTools);
1300
+ }
1301
+ const currentState = this.session.getGoalModeState();
1302
+ if (options?.reason === "completed") {
1303
+ this.session.setGoalModeState(undefined);
1304
+ this.sessionManager.appendModeChange("none");
1305
+ this.sessionManager.appendCustomEntry("goal-completed", {
1306
+ objective: currentState?.goal?.objective,
1307
+ tokensUsed: currentState?.goal?.tokensUsed,
1308
+ tokenBudget: currentState?.goal?.tokenBudget,
1309
+ timeUsedSeconds: currentState?.goal?.timeUsedSeconds,
1310
+ });
1311
+ }
1312
+ this.goalModeEnabled = false;
1313
+ this.goalModePaused = options?.paused ?? false;
1314
+ this.#goalModePreviousTools = undefined;
1315
+ this.#goalContinuationTurnInFlight = false;
1316
+ this.#cancelGoalContinuation();
1317
+ this.#updateGoalModeStatus();
1318
+ if (!options?.silent) {
1319
+ if (options?.reason === "completed") {
1320
+ this.showStatus("Goal mode completed.");
1321
+ } else if (options?.reason === "dropped") {
1322
+ this.showStatus("Goal dropped.");
1323
+ } else if (options?.paused) {
1324
+ this.showStatus("Goal mode paused.");
1325
+ } else {
1326
+ this.showStatus("Goal mode disabled.");
1327
+ }
1328
+ }
1329
+ }
1330
+
1003
1331
  async #readPlanFile(planFilePath: string): Promise<string | null> {
1004
1332
  const resolvedPath = this.#resolvePlanFilePath(planFilePath);
1005
1333
  try {
@@ -1109,7 +1437,12 @@ export class InteractiveMode implements InteractiveModeContext {
1109
1437
 
1110
1438
  async #approvePlan(
1111
1439
  planContent: string,
1112
- options: { planFilePath: string; finalPlanFilePath: string; preserveContext?: boolean },
1440
+ options: {
1441
+ planFilePath: string;
1442
+ finalPlanFilePath: string;
1443
+ preserveContext?: boolean;
1444
+ compactBeforeExecute?: boolean;
1445
+ },
1113
1446
  ): Promise<void> {
1114
1447
  await renameApprovedPlanFile({
1115
1448
  planFilePath: options.planFilePath,
@@ -1118,21 +1451,80 @@ export class InteractiveMode implements InteractiveModeContext {
1118
1451
  getSessionId: () => this.sessionManager.getSessionId(),
1119
1452
  });
1120
1453
  const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames();
1121
- await this.#exitPlanMode({ silent: true, paused: false });
1122
- if (!options.preserveContext) {
1123
- await this.handleClearCommand();
1124
- // The new session has a fresh local:// root persist the approved plan there
1125
- // so `local://<title>.md` resolves correctly in the execution session.
1126
- const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
1127
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1128
- getSessionId: () => this.sessionManager.getSessionId(),
1129
- });
1130
- await Bun.write(newLocalPath, planContent);
1454
+
1455
+ // Mark the pending abort caused by the plan-mode → compaction transition as
1456
+ // silent BEFORE #exitPlanMode raises it. The `finally` below clears the
1457
+ // flag on every terminal compaction outcome (ok / cancelled / failed /
1458
+ // throw) so a leaked flag cannot silence a later unrelated abort.
1459
+ // Branchless mark+clear when !compactBeforeExecute: mark is gated; clear
1460
+ // is unconditional and idempotent.
1461
+ if (options.compactBeforeExecute) {
1462
+ this.session.markPlanCompactAbortPending();
1463
+ }
1464
+ let compactOutcome: CompactionOutcome | undefined;
1465
+ try {
1466
+ await this.#exitPlanMode({ silent: true, paused: false });
1467
+
1468
+ if (!options.preserveContext) {
1469
+ await this.handleClearCommand();
1470
+ // The new session has a fresh local:// root — persist the approved plan there
1471
+ // so `local://<title>.md` resolves correctly in the execution session.
1472
+ const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
1473
+ getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1474
+ getSessionId: () => this.sessionManager.getSessionId(),
1475
+ });
1476
+ await Bun.write(newLocalPath, planContent);
1477
+ } else if (options.compactBeforeExecute) {
1478
+ // Distill the plan-mode transcript before the execution turn is queued so
1479
+ // the plan-approved synthetic prompt lands as a fresh cache anchor.
1480
+ // Outcome is consumed after tool-restoration and plan-reference-path
1481
+ // bookkeeping below; `markPlanReferenceSent` is intentionally deferred
1482
+ // past the cancel guard — see the comment at the cancel branch.
1483
+ // Cancellation skips the synthetic-prompt dispatch (operator's explicit
1484
+ // abort is honored); failure proceeds best-effort — approval intent stands.
1485
+ const compactionPrompt = prompt.render(planModeCompactInstructionsPrompt, {
1486
+ planFilePath: options.finalPlanFilePath,
1487
+ });
1488
+ // Pin the plan reference path BEFORE compaction so any user messages
1489
+ // queued during the compaction await (which `handleCompactCommand`
1490
+ // flushes via `flushCompactionQueue` before returning) see the
1491
+ // approved plan in `#buildPlanReferenceMessage`. Reassignment after
1492
+ // the try/finally is idempotent and kept for the !compactBeforeExecute
1493
+ // branch.
1494
+ this.session.setPlanReferencePath(options.finalPlanFilePath);
1495
+ compactOutcome = await this.handleCompactCommand(compactionPrompt);
1496
+ }
1497
+ } finally {
1498
+ // Unconditional clear. Idempotent: a no-op when the flag was never set
1499
+ // (i.e., the !compactBeforeExecute branch), and a no-op when the flag
1500
+ // was already consumed by AgentSession.#handleAgentEvent's aborted
1501
+ // message_end stamping. Guarantees the flag is dead at every exit.
1502
+ this.session.clearPlanCompactAbortPending();
1131
1503
  }
1504
+
1505
+ // Tool restoration runs on every path — the plan mode tools must be
1506
+ // retired regardless of whether the synthetic prompt fires.
1132
1507
  if (previousTools.length > 0) {
1133
1508
  await this.session.setActiveToolsByName(previousTools);
1134
1509
  }
1135
1510
  this.session.setPlanReferencePath(options.finalPlanFilePath);
1511
+
1512
+ if (compactOutcome === "cancelled") {
1513
+ // Explicit abort: honor it. `executeCompaction` already surfaced
1514
+ // `showError("Compaction cancelled")` to the operator; we add the
1515
+ // deferred-dispatch warning and exit. `markPlanReferenceSent` is
1516
+ // intentionally skipped here: `#planReferenceSent` stays false, so
1517
+ // `AgentSession.#buildPlanReferenceMessage` will inject the plan
1518
+ // reference on the operator's next `prompt()` call. If we marked it
1519
+ // sent here, the executor's first turn would have no plan context.
1520
+ this.showWarning(
1521
+ "Plan approved, but compaction was cancelled — execution not dispatched. Submit a turn to continue.",
1522
+ );
1523
+ return;
1524
+ }
1525
+
1526
+ // markPlanReferenceSent fires only on the dispatch path so the synthetic
1527
+ // plan-approved prompt is the source of the reference injection.
1136
1528
  this.session.markPlanReferenceSent();
1137
1529
  const planModePrompt = prompt.render(planModeApprovedPrompt, {
1138
1530
  planContent,
@@ -1143,6 +1535,10 @@ export class InteractiveMode implements InteractiveModeContext {
1143
1535
  }
1144
1536
 
1145
1537
  async handlePlanModeCommand(initialPrompt?: string): Promise<void> {
1538
+ if (this.goalModeEnabled || this.goalModePaused) {
1539
+ this.showWarning("Exit goal mode first.");
1540
+ return;
1541
+ }
1146
1542
  if (this.planModeEnabled) {
1147
1543
  const confirmed = await this.showHookConfirm(
1148
1544
  "Exit plan mode?",
@@ -1162,16 +1558,241 @@ export class InteractiveMode implements InteractiveModeContext {
1162
1558
  }
1163
1559
  }
1164
1560
 
1165
- async handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void> {
1561
+ async #handleGoalBudgetCommand(rawBudget: string): Promise<void> {
1562
+ const state = this.session.getGoalModeState();
1563
+ if (!this.goalModeEnabled || !state?.enabled) {
1564
+ this.showWarning("No active goal.");
1565
+ return;
1566
+ }
1567
+ if (state.goal.status === "complete") {
1568
+ this.showStatus("Goal is already complete.");
1569
+ return;
1570
+ }
1571
+ const trimmed = rawBudget.trim().toLowerCase();
1572
+ let nextBudget: number | undefined;
1573
+ if (trimmed !== "off") {
1574
+ const parsed = Number.parseInt(trimmed, 10);
1575
+ if (!Number.isInteger(parsed) || parsed <= 0) {
1576
+ this.showError("Goal budget must be a positive integer or `off`.");
1577
+ return;
1578
+ }
1579
+ nextBudget = parsed;
1580
+ }
1581
+ await this.session.goalRuntime.onBudgetMutated(nextBudget);
1582
+ this.#resetGoalContinuationSuppression();
1583
+ this.#scheduleGoalContinuation();
1584
+ this.showStatus(nextBudget === undefined ? "Goal budget cleared." : `Goal budget set to ${nextBudget}.`);
1585
+ }
1586
+
1587
+ async handleGoalModeCommand(rest?: string): Promise<void> {
1588
+ try {
1589
+ if (this.planModeEnabled || this.planModePaused) {
1590
+ this.showWarning("Exit plan mode first.");
1591
+ return;
1592
+ }
1593
+ if (!this.session.settings.get("goal.enabled")) {
1594
+ this.showWarning("Goal mode is disabled. Enable it in settings (goal.enabled).");
1595
+ return;
1596
+ }
1597
+ const { sub, rest: subRest } = parseGoalSubcommand(rest ?? "");
1598
+ if (sub) {
1599
+ await this.#dispatchGoalSubcommand(sub, subRest);
1600
+ return;
1601
+ }
1602
+ if (this.goalModeEnabled) {
1603
+ if (subRest) {
1604
+ this.showStatus("Goal mode is already active. Use /goal to manage it, or /goal drop to start over.");
1605
+ return;
1606
+ }
1607
+ await this.#openGoalMenu("active");
1608
+ return;
1609
+ }
1610
+ const pausedState = this.#getPausedGoalState();
1611
+ if (pausedState) {
1612
+ if (subRest) {
1613
+ this.showWarning("Resume the current goal first, or drop it before setting a new objective.");
1614
+ return;
1615
+ }
1616
+ await this.#openGoalMenu("paused");
1617
+ return;
1618
+ }
1619
+ if (subRest) {
1620
+ await this.#startGoalFromObjective(subRest);
1621
+ return;
1622
+ }
1623
+ const objective = (
1624
+ await this.showHookEditor("Goal objective", undefined, undefined, { promptStyle: true })
1625
+ )?.trim();
1626
+ if (!objective) return;
1627
+ await this.#startGoalFromObjective(objective);
1628
+ } catch (error) {
1629
+ this.showError(error instanceof Error ? error.message : String(error));
1630
+ }
1631
+ }
1632
+
1633
+ async #dispatchGoalSubcommand(sub: GoalSubcommand, rest: string): Promise<void> {
1634
+ switch (sub) {
1635
+ case "set":
1636
+ await this.#handleGoalSetSubcommand(rest);
1637
+ return;
1638
+ case "show":
1639
+ this.#showGoalDetails();
1640
+ return;
1641
+ case "pause":
1642
+ await this.#pauseGoalAction();
1643
+ return;
1644
+ case "resume":
1645
+ await this.#resumeGoalAction();
1646
+ return;
1647
+ case "drop":
1648
+ await this.#confirmAndDropGoal();
1649
+ return;
1650
+ case "budget":
1651
+ if (!this.goalModeEnabled) {
1652
+ this.showWarning(
1653
+ this.#getPausedGoalState() ? "Resume the goal before adjusting the budget." : "No active goal.",
1654
+ );
1655
+ return;
1656
+ }
1657
+ if (!rest) {
1658
+ await this.#promptGoalBudgetEdit();
1659
+ return;
1660
+ }
1661
+ await this.#handleGoalBudgetCommand(rest);
1662
+ return;
1663
+ }
1664
+ }
1665
+
1666
+ async #openGoalMenu(state: "active" | "paused"): Promise<void> {
1667
+ const goal = this.session.getGoalModeState()?.goal;
1668
+ if (!goal) return;
1669
+ const summary = goal.objective.length > 48 ? `${goal.objective.slice(0, 47)}…` : goal.objective;
1670
+ const title = state === "active" ? `Goal: ${summary} (${goal.status})` : `Goal paused: ${summary}`;
1671
+ const items =
1672
+ state === "active"
1673
+ ? ["Show details", "Adjust budget…", "Pause", "Drop"]
1674
+ : ["Resume", "Show details", "Adjust budget…", "Drop"];
1675
+ const choice = await this.showHookSelector(title, items);
1676
+ if (!choice) return;
1677
+ switch (choice) {
1678
+ case "Show details":
1679
+ this.#showGoalDetails();
1680
+ return;
1681
+ case "Adjust budget…":
1682
+ await this.#promptGoalBudgetEdit();
1683
+ return;
1684
+ case "Pause":
1685
+ await this.#pauseGoalAction();
1686
+ return;
1687
+ case "Resume":
1688
+ await this.#resumeGoalAction();
1689
+ return;
1690
+ case "Drop":
1691
+ await this.#confirmAndDropGoal();
1692
+ return;
1693
+ }
1694
+ }
1695
+
1696
+ #showGoalDetails(): void {
1697
+ const state = this.session.getGoalModeState();
1698
+ const goal = state?.goal;
1699
+ if (!goal) {
1700
+ this.showStatus("No goal set.");
1701
+ return;
1702
+ }
1703
+ const used = goal.tokensUsed.toLocaleString();
1704
+ const budgetLine =
1705
+ goal.tokenBudget !== undefined
1706
+ ? `${used} / ${goal.tokenBudget.toLocaleString()} (${Math.max(0, goal.tokenBudget - goal.tokensUsed).toLocaleString()} left)`
1707
+ : `${used} (no budget)`;
1708
+ const lines = [
1709
+ `Objective: ${goal.objective}`,
1710
+ `Status: ${goal.status}${state?.enabled ? "" : " (paused)"}`,
1711
+ `Tokens: ${budgetLine}`,
1712
+ `Time spent: ${formatDuration(goal.timeUsedSeconds * 1000)}`,
1713
+ ];
1714
+ this.showStatus(lines.join("\n"));
1715
+ }
1716
+
1717
+ async #promptGoalBudgetEdit(): Promise<void> {
1718
+ const goal = this.session.getGoalModeState()?.goal;
1719
+ const prefill = goal?.tokenBudget !== undefined ? String(goal.tokenBudget) : "";
1720
+ const input = (
1721
+ await this.showHookEditor("Goal budget (number, `off`, or empty to cancel)", prefill, undefined, {
1722
+ promptStyle: true,
1723
+ })
1724
+ )?.trim();
1725
+ if (!input) return;
1726
+ await this.#handleGoalBudgetCommand(input);
1727
+ }
1728
+
1729
+ async #pauseGoalAction(): Promise<void> {
1730
+ if (!this.goalModeEnabled) {
1731
+ this.showWarning("No active goal to pause.");
1732
+ return;
1733
+ }
1734
+ await this.session.goalRuntime.pauseGoal();
1735
+ await this.#exitGoalMode({ paused: true, reason: "paused" });
1736
+ }
1737
+
1738
+ async #resumeGoalAction(): Promise<void> {
1739
+ if (!this.#getPausedGoalState()) {
1740
+ this.showWarning("No paused goal to resume.");
1741
+ return;
1742
+ }
1743
+ await this.#enterGoalMode({ resume: true, silent: true });
1744
+ this.showStatus("Goal mode resumed.");
1745
+ this.#scheduleGoalContinuation();
1746
+ }
1747
+
1748
+ async #confirmAndDropGoal(): Promise<void> {
1749
+ if (!this.goalModeEnabled && !this.#getPausedGoalState()) {
1750
+ this.showWarning("No goal to drop.");
1751
+ return;
1752
+ }
1753
+ const confirmed = await this.showHookConfirm(
1754
+ "Drop goal?",
1755
+ "This removes the goal record. Accumulated usage stays in the session log.",
1756
+ );
1757
+ if (!confirmed) return;
1758
+ await this.session.goalRuntime.dropGoal();
1759
+ await this.#exitGoalMode({ reason: "dropped" });
1760
+ }
1761
+
1762
+ async #startGoalFromObjective(objective: string): Promise<void> {
1763
+ await this.#enterGoalMode({ objective, silent: true });
1764
+ this.#resetGoalContinuationSuppression();
1765
+ if (this.onInputCallback) {
1766
+ this.onInputCallback(this.startPendingSubmission({ text: objective }));
1767
+ }
1768
+ }
1769
+
1770
+ async #handleGoalSetSubcommand(rest: string): Promise<void> {
1771
+ if (this.goalModeEnabled) {
1772
+ this.showStatus("Goal mode is already active. Use /goal drop to start over.");
1773
+ return;
1774
+ }
1775
+ if (this.#getPausedGoalState()) {
1776
+ this.showWarning("Resume the current goal first, or drop it before setting a new objective.");
1777
+ return;
1778
+ }
1779
+ const objective = rest.trim()
1780
+ ? rest.trim()
1781
+ : (await this.showHookEditor("Goal objective", undefined, undefined, { promptStyle: true }))?.trim();
1782
+ if (!objective) return;
1783
+ await this.#startGoalFromObjective(objective);
1784
+ }
1785
+
1786
+ async handlePlanApproval(details: PlanApprovalDetails): Promise<void> {
1166
1787
  if (!this.planModeEnabled) {
1167
1788
  this.showWarning("Plan mode is not active.");
1168
1789
  return;
1169
1790
  }
1170
1791
 
1171
- // Abort the agent to prevent it from continuing (e.g., calling exit_plan_mode
1172
- // again) while the popup is showing. The event listener fires asynchronously
1173
- // (agent's #emit is fire-and-forget), so without this the model sees "Plan
1174
- // ready for approval." and immediately calls exit_plan_mode in a loop.
1792
+ // Abort the agent to prevent it from continuing (e.g., re-submitting the
1793
+ // plan) while the popup is showing. The event listener fires asynchronously
1794
+ // (agent's #emit is fire-and-forget), so without this the model sees
1795
+ // "Plan ready for approval." and immediately re-invokes `resolve` in a loop.
1175
1796
  await this.session.abort();
1176
1797
 
1177
1798
  const planFilePath = details.planFilePath || this.planModePlanFilePath || (await this.#getPlanFilePath());
@@ -1185,14 +1806,24 @@ export class InteractiveMode implements InteractiveModeContext {
1185
1806
  this.#renderPlanPreview(planContent, { append: true });
1186
1807
  const choice = await this.showHookSelector(
1187
1808
  "Plan mode - next step",
1188
- ["Approve and execute", "Approve and keep context", "Refine plan", "Stay in plan mode"],
1809
+ [
1810
+ "Approve and execute",
1811
+ "Approve and compact context",
1812
+ "Approve and keep context",
1813
+ "Refine plan",
1814
+ "Stay in plan mode",
1815
+ ],
1189
1816
  {
1190
1817
  helpText: this.#getPlanReviewHelpText(),
1191
1818
  onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath),
1192
1819
  },
1193
1820
  );
1194
1821
 
1195
- if (choice === "Approve and execute" || choice === "Approve and keep context") {
1822
+ if (
1823
+ choice === "Approve and execute" ||
1824
+ choice === "Approve and compact context" ||
1825
+ choice === "Approve and keep context"
1826
+ ) {
1196
1827
  const finalPlanFilePath = details.finalPlanFilePath || planFilePath;
1197
1828
  try {
1198
1829
  const latestPlanContent = await this.#readPlanFile(planFilePath);
@@ -1203,7 +1834,8 @@ export class InteractiveMode implements InteractiveModeContext {
1203
1834
  await this.#approvePlan(latestPlanContent, {
1204
1835
  planFilePath,
1205
1836
  finalPlanFilePath,
1206
- preserveContext: choice === "Approve and keep context",
1837
+ preserveContext: choice !== "Approve and execute",
1838
+ compactBeforeExecute: choice === "Approve and compact context",
1207
1839
  });
1208
1840
  } catch (error) {
1209
1841
  this.showError(
@@ -1230,6 +1862,7 @@ export class InteractiveMode implements InteractiveModeContext {
1230
1862
  this.loadingAnimation = undefined;
1231
1863
  }
1232
1864
  this.#cleanupMicAnimation();
1865
+ this.#cancelGoalContinuation();
1233
1866
  if (this.#sttController) {
1234
1867
  this.#sttController.dispose();
1235
1868
  this.#sttController = undefined;
@@ -1727,7 +2360,7 @@ export class InteractiveMode implements InteractiveModeContext {
1727
2360
  await controller.handle(text);
1728
2361
  }
1729
2362
 
1730
- handleCompactCommand(customInstructions?: string): Promise<void> {
2363
+ handleCompactCommand(customInstructions?: string): Promise<CompactionOutcome> {
1731
2364
  return this.#commandController.handleCompactCommand(customInstructions);
1732
2365
  }
1733
2366
 
@@ -1735,7 +2368,10 @@ export class InteractiveMode implements InteractiveModeContext {
1735
2368
  return this.#commandController.handleHandoffCommand(customInstructions);
1736
2369
  }
1737
2370
 
1738
- executeCompaction(customInstructionsOrOptions?: string | CompactOptions, isAuto?: boolean): Promise<void> {
2371
+ executeCompaction(
2372
+ customInstructionsOrOptions?: string | CompactOptions,
2373
+ isAuto?: boolean,
2374
+ ): Promise<CompactionOutcome> {
1739
2375
  return this.#commandController.executeCompaction(customInstructionsOrOptions, isAuto);
1740
2376
  }
1741
2377