@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -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,9 +37,10 @@ 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" };
44
45
  import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
45
46
  type: "text",
@@ -49,10 +50,13 @@ import type { CompactionOutcome } from "../session/compaction";
49
50
  import { HistoryStorage } from "../session/history-storage";
50
51
  import type { SessionContext, SessionManager } from "../session/session-manager";
51
52
  import { getRecentSessions } from "../session/session-manager";
53
+ import { formatDuration } from "../slash-commands/helpers/format";
52
54
  import { STTController, type SttState } from "../stt";
53
- import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
55
+ import type { LspStartupServerInfo } from "../tools";
54
56
  import { normalizeLocalScheme } from "../tools/path-utils";
57
+ import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
55
58
  import { formatPhaseDisplayName } from "../tools/todo-write";
59
+ import { ToolError } from "../tools/tool-errors";
56
60
  import type { EventBus } from "../utils/event-bus";
57
61
  import { getEditorCommand, openInEditor } from "../utils/external-editor";
58
62
  import { getSessionAccentAnsi, getSessionAccentHex } from "../utils/session-color";
@@ -127,6 +131,22 @@ function formatHudNoteMarker(count: number): string {
127
131
  return theme.fg("dim", chalk.italic(` \u207a${sub}`));
128
132
  }
129
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
+
130
150
  /** Options for creating an InteractiveMode instance (for future API use) */
131
151
  export interface InteractiveModeOptions {
132
152
  /** Providers that were migrated during startup */
@@ -168,6 +188,8 @@ export class InteractiveMode implements InteractiveModeContext {
168
188
  todoExpanded = false;
169
189
  planModeEnabled = false;
170
190
  planModePaused = false;
191
+ goalModeEnabled = false;
192
+ goalModePaused = false;
171
193
  planModePlanFilePath: string | undefined = undefined;
172
194
  loopModeEnabled = false;
173
195
  loopPrompt: string | undefined = undefined;
@@ -216,6 +238,11 @@ export class InteractiveMode implements InteractiveModeContext {
216
238
  readonly #version: string;
217
239
  readonly #changelogMarkdown: string | undefined;
218
240
  #planModePreviousTools: string[] | undefined;
241
+ #goalModePreviousTools: string[] | undefined;
242
+ #goalContinuationTimer: NodeJS.Timeout | undefined;
243
+ #goalTurnHadToolCalls = false;
244
+ #goalContinuationTurnInFlight = false;
245
+ #goalSuppressNextContinuation = false;
219
246
  #planModePreviousModelState: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
220
247
  #pendingModelSwitch: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
221
248
  #planModeHasEntered = false;
@@ -477,6 +504,11 @@ export class InteractiveMode implements InteractiveModeContext {
477
504
  // Subscribe to agent events
478
505
  this.#subscribeToAgent();
479
506
 
507
+ this.#eventBusUnsubscribers.push(
508
+ this.session.subscribe(event => {
509
+ void this.#handleGoalSessionEvent(event);
510
+ }),
511
+ );
480
512
  // Set up theme file watcher
481
513
  onThemeChange(() => {
482
514
  clearRenderCache();
@@ -520,12 +552,16 @@ export class InteractiveMode implements InteractiveModeContext {
520
552
  }
521
553
 
522
554
  async getUserInput(): Promise<SubmittedUserInput> {
555
+ if (this.session.getGoalModeState()?.mode === "exiting") {
556
+ await this.#exitGoalMode({ reason: "completed", silent: true });
557
+ }
523
558
  const { promise, resolve } = Promise.withResolvers<SubmittedUserInput>();
524
559
  this.onInputCallback = input => {
525
560
  this.onInputCallback = undefined;
526
561
  resolve(input);
527
562
  };
528
563
  this.#scheduleLoopAutoSubmit();
564
+ this.#scheduleGoalContinuation();
529
565
  return promise;
530
566
  }
531
567
 
@@ -549,6 +585,48 @@ export class InteractiveMode implements InteractiveModeContext {
549
585
  }
550
586
  }
551
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
+
552
630
  async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
553
631
  if (!consumeLoopLimitIteration(this.loopLimit)) {
554
632
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
@@ -639,23 +717,36 @@ export class InteractiveMode implements InteractiveModeContext {
639
717
  }
640
718
  }
641
719
 
642
- startPendingSubmission(input: { text: string; images?: ImageContent[] }): SubmittedUserInput {
720
+ startPendingSubmission(input: {
721
+ text: string;
722
+ images?: ImageContent[];
723
+ customType?: string;
724
+ display?: boolean;
725
+ }): SubmittedUserInput {
643
726
  const submission: SubmittedUserInput = {
644
727
  text: input.text,
645
728
  images: input.images,
729
+ customType: input.customType,
730
+ display: input.display,
646
731
  cancelled: false,
647
732
  started: false,
648
733
  };
649
734
  this.#pendingSubmittedInput = submission;
650
- const imageCount = submission.images?.length ?? 0;
651
- this.optimisticUserMessageSignature = `${submission.text}\u0000${imageCount}`;
652
- this.#pendingSubmissionDispose = this.recordLocalSubmission(submission.text, imageCount);
653
- this.addMessageToChat({
654
- role: "user",
655
- content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
656
- attribution: "user",
657
- timestamp: Date.now(),
658
- });
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
+ }
659
750
  this.editor.setText("");
660
751
  this.ensureLoadingAnimation();
661
752
  this.ui.requestRender();
@@ -674,14 +765,19 @@ export class InteractiveMode implements InteractiveModeContext {
674
765
  this.#pendingSubmissionDispose?.();
675
766
  this.#pendingSubmissionDispose = undefined;
676
767
  this.#pendingWorkingMessage = undefined;
768
+ if (submission.customType === "goal-continuation") {
769
+ this.#goalContinuationTurnInFlight = false;
770
+ }
677
771
  if (this.loadingAnimation) {
678
772
  this.loadingAnimation.stop();
679
773
  this.loadingAnimation = undefined;
680
774
  this.statusContainer.clear();
681
775
  }
682
- this.pendingImages = submission.images ? [...submission.images] : [];
683
- this.rebuildChatFromMessages();
684
- 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
+ }
685
781
  this.updateEditorBorderColor();
686
782
  this.ui.requestRender();
687
783
  return true;
@@ -702,6 +798,9 @@ export class InteractiveMode implements InteractiveModeContext {
702
798
  this.#pendingSubmittedInput = undefined;
703
799
  this.#pendingSubmissionDispose = undefined;
704
800
  }
801
+ if (input.customType === "goal-continuation") {
802
+ this.#goalContinuationTurnInFlight = false;
803
+ }
705
804
 
706
805
  if (wasPendingSubmission && !this.session.isStreaming && !this.streamingComponent) {
707
806
  this.optimisticUserMessageSignature = undefined;
@@ -857,6 +956,95 @@ export class InteractiveMode implements InteractiveModeContext {
857
956
  this.ui.requestRender();
858
957
  }
859
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
+
860
1048
  async #applyPlanModeModel(): Promise<void> {
861
1049
  const resolved = this.session.resolveRoleModelWithThinking("plan");
862
1050
  if (!resolved.model) return;
@@ -903,6 +1091,28 @@ export class InteractiveMode implements InteractiveModeContext {
903
1091
  /** Restore mode state from session entries on resume (e.g. plan mode). */
904
1092
  async #restoreModeFromSession(): Promise<void> {
905
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
+ }
906
1116
  if (!this.session.settings.get("plan.enabled")) {
907
1117
  // Clear stale plan/plan_paused mode so re-enabling the setting
908
1118
  // later doesn't unexpectedly restore an old plan session.
@@ -925,13 +1135,17 @@ export class InteractiveMode implements InteractiveModeContext {
925
1135
  if (this.planModeEnabled) {
926
1136
  return;
927
1137
  }
1138
+ if (this.goalModeEnabled || this.goalModePaused) {
1139
+ this.showWarning("Exit goal mode first.");
1140
+ return;
1141
+ }
928
1142
 
929
1143
  this.planModePaused = false;
930
1144
 
931
1145
  const planFilePath = options?.planFilePath ?? (await this.#getPlanFilePath());
932
1146
  const previousTools = this.session.getActiveToolNames();
933
- const hasExitTool = this.session.getToolByName("exit_plan_mode") !== undefined;
934
- const planTools = hasExitTool ? [...previousTools, "exit_plan_mode"] : previousTools;
1147
+ const hasResolveTool = this.session.getToolByName("resolve") !== undefined;
1148
+ const planTools = hasResolveTool ? [...previousTools, "resolve"] : previousTools;
935
1149
  const uniquePlanTools = [...new Set(planTools)];
936
1150
 
937
1151
  this.#planModePreviousTools = previousTools;
@@ -945,6 +1159,7 @@ export class InteractiveMode implements InteractiveModeContext {
945
1159
  workflow: options?.workflow ?? "parallel",
946
1160
  reentry: this.#planModeHasEntered,
947
1161
  });
1162
+ this.session.setStandingResolveHandler?.(input => this.#runPlanApprovalResolve(input));
948
1163
  if (this.session.isStreaming) {
949
1164
  await this.session.sendPlanModeContext({ deliverAs: "steer" });
950
1165
  }
@@ -955,6 +1170,47 @@ export class InteractiveMode implements InteractiveModeContext {
955
1170
  this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`);
956
1171
  }
957
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
+
958
1214
  async #exitPlanMode(options?: { silent?: boolean; paused?: boolean }): Promise<void> {
959
1215
  if (!this.planModeEnabled) {
960
1216
  return;
@@ -990,6 +1246,7 @@ export class InteractiveMode implements InteractiveModeContext {
990
1246
  }
991
1247
  }
992
1248
  }
1249
+ this.session.setStandingResolveHandler?.(null);
993
1250
  this.session.setPlanModeState(undefined);
994
1251
  this.planModeEnabled = false;
995
1252
  this.planModePaused = options?.paused ?? false;
@@ -1004,6 +1261,73 @@ export class InteractiveMode implements InteractiveModeContext {
1004
1261
  }
1005
1262
  }
1006
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
+
1007
1331
  async #readPlanFile(planFilePath: string): Promise<string | null> {
1008
1332
  const resolvedPath = this.#resolvePlanFilePath(planFilePath);
1009
1333
  try {
@@ -1127,37 +1451,55 @@ export class InteractiveMode implements InteractiveModeContext {
1127
1451
  getSessionId: () => this.sessionManager.getSessionId(),
1128
1452
  });
1129
1453
  const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames();
1130
- await this.#exitPlanMode({ silent: true, paused: false });
1131
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
+ }
1132
1464
  let compactOutcome: CompactionOutcome | undefined;
1133
- if (!options.preserveContext) {
1134
- await this.handleClearCommand();
1135
- // The new session has a fresh local:// root — persist the approved plan there
1136
- // so `local://<title>.md` resolves correctly in the execution session.
1137
- const newLocalPath = resolveLocalUrlToPath(options.finalPlanFilePath, {
1138
- getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
1139
- getSessionId: () => this.sessionManager.getSessionId(),
1140
- });
1141
- await Bun.write(newLocalPath, planContent);
1142
- } else if (options.compactBeforeExecute) {
1143
- // Distill the plan-mode transcript before the execution turn is queued so
1144
- // the plan-approved synthetic prompt lands as a fresh cache anchor.
1145
- // Outcome is consumed after tool-restoration and plan-reference-path
1146
- // bookkeeping below; `markPlanReferenceSent` is intentionally deferred
1147
- // past the cancel guard see the comment at the cancel branch.
1148
- // Cancellation skips the synthetic-prompt dispatch (operator's explicit
1149
- // abort is honored); failure proceeds best-effort — approval intent stands.
1150
- const compactionPrompt = prompt.render(planModeCompactInstructionsPrompt, {
1151
- planFilePath: options.finalPlanFilePath,
1152
- });
1153
- // Pin the plan reference path BEFORE compaction so any user messages
1154
- // queued during the compaction await (which `handleCompactCommand`
1155
- // flushes via `flushCompactionQueue` before returning) see the
1156
- // approved plan in `#buildPlanReferenceMessage`. Reassignment at
1157
- // line 1161 below is idempotent and kept for the !compactBeforeExecute
1158
- // branch.
1159
- this.session.setPlanReferencePath(options.finalPlanFilePath);
1160
- compactOutcome = await this.handleCompactCommand(compactionPrompt);
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();
1161
1503
  }
1162
1504
 
1163
1505
  // Tool restoration runs on every path — the plan mode tools must be
@@ -1193,6 +1535,10 @@ export class InteractiveMode implements InteractiveModeContext {
1193
1535
  }
1194
1536
 
1195
1537
  async handlePlanModeCommand(initialPrompt?: string): Promise<void> {
1538
+ if (this.goalModeEnabled || this.goalModePaused) {
1539
+ this.showWarning("Exit goal mode first.");
1540
+ return;
1541
+ }
1196
1542
  if (this.planModeEnabled) {
1197
1543
  const confirmed = await this.showHookConfirm(
1198
1544
  "Exit plan mode?",
@@ -1212,16 +1558,241 @@ export class InteractiveMode implements InteractiveModeContext {
1212
1558
  }
1213
1559
  }
1214
1560
 
1215
- 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> {
1216
1787
  if (!this.planModeEnabled) {
1217
1788
  this.showWarning("Plan mode is not active.");
1218
1789
  return;
1219
1790
  }
1220
1791
 
1221
- // Abort the agent to prevent it from continuing (e.g., calling exit_plan_mode
1222
- // again) while the popup is showing. The event listener fires asynchronously
1223
- // (agent's #emit is fire-and-forget), so without this the model sees "Plan
1224
- // 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.
1225
1796
  await this.session.abort();
1226
1797
 
1227
1798
  const planFilePath = details.planFilePath || this.planModePlanFilePath || (await this.#getPlanFilePath());
@@ -1291,6 +1862,7 @@ export class InteractiveMode implements InteractiveModeContext {
1291
1862
  this.loadingAnimation = undefined;
1292
1863
  }
1293
1864
  this.#cleanupMicAnimation();
1865
+ this.#cancelGoalContinuation();
1294
1866
  if (this.#sttController) {
1295
1867
  this.#sttController.dispose();
1296
1868
  this.#sttController = undefined;