@oh-my-pi/pi-coding-agent 8.4.0 → 8.4.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 (92) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +6 -6
  3. package/scripts/format-prompts.ts +65 -23
  4. package/src/commit/agentic/prompts/session-user.md +0 -1
  5. package/src/commit/agentic/prompts/split-confirm.md +1 -1
  6. package/src/commit/agentic/prompts/system.md +1 -1
  7. package/src/commit/prompts/analysis-system.md +23 -26
  8. package/src/commit/prompts/analysis-user.md +1 -1
  9. package/src/commit/prompts/changelog-system.md +1 -2
  10. package/src/commit/prompts/changelog-user.md +1 -2
  11. package/src/commit/prompts/file-observer-system.md +1 -3
  12. package/src/commit/prompts/file-observer-user.md +1 -2
  13. package/src/commit/prompts/reduce-system.md +16 -16
  14. package/src/commit/prompts/reduce-user.md +1 -1
  15. package/src/commit/prompts/summary-retry.md +1 -2
  16. package/src/commit/prompts/summary-system.md +10 -10
  17. package/src/commit/prompts/summary-user.md +1 -1
  18. package/src/commit/prompts/types-description.md +1 -1
  19. package/src/config/keybindings.ts +3 -0
  20. package/src/config/settings-manager.ts +5 -0
  21. package/src/internal-urls/index.ts +1 -0
  22. package/src/internal-urls/plan-protocol.ts +95 -0
  23. package/src/modes/components/status-line/presets.ts +7 -7
  24. package/src/modes/components/status-line/segments.ts +16 -0
  25. package/src/modes/components/status-line/types.ts +4 -0
  26. package/src/modes/components/status-line-segment-editor.ts +1 -0
  27. package/src/modes/components/status-line.ts +16 -2
  28. package/src/modes/controllers/command-controller.ts +42 -0
  29. package/src/modes/controllers/event-controller.ts +13 -0
  30. package/src/modes/controllers/input-controller.ts +16 -0
  31. package/src/modes/interactive-mode.ts +219 -1
  32. package/src/modes/theme/theme.ts +7 -0
  33. package/src/modes/types.ts +7 -0
  34. package/src/patch/index.ts +9 -3
  35. package/src/plan-mode/state.ts +6 -0
  36. package/src/prompts/agents/explore.md +1 -1
  37. package/src/prompts/agents/frontmatter.md +1 -1
  38. package/src/prompts/agents/init.md +1 -1
  39. package/src/prompts/agents/plan.md +33 -49
  40. package/src/prompts/agents/reviewer.md +7 -7
  41. package/src/prompts/agents/task.md +1 -2
  42. package/src/prompts/compaction/branch-summary-preamble.md +1 -1
  43. package/src/prompts/compaction/branch-summary.md +3 -1
  44. package/src/prompts/compaction/compaction-summary.md +3 -1
  45. package/src/prompts/compaction/compaction-turn-prefix.md +2 -1
  46. package/src/prompts/compaction/compaction-update-summary.md +3 -1
  47. package/src/prompts/review-request.md +4 -1
  48. package/src/prompts/system/custom-system-prompt.md +8 -8
  49. package/src/prompts/system/file-operations.md +1 -1
  50. package/src/prompts/system/plan-mode-active.md +113 -0
  51. package/src/prompts/system/plan-mode-approved.md +16 -0
  52. package/src/prompts/system/plan-mode-reference.md +14 -0
  53. package/src/prompts/system/plan-mode-subagent.md +36 -0
  54. package/src/prompts/system/summarization-system.md +1 -1
  55. package/src/prompts/system/system-prompt.md +17 -27
  56. package/src/prompts/system/title-system.md +1 -1
  57. package/src/prompts/system/ttsr-interrupt.md +1 -1
  58. package/src/prompts/system/web-search.md +1 -1
  59. package/src/prompts/tools/ask.md +1 -3
  60. package/src/prompts/tools/bash.md +1 -1
  61. package/src/prompts/tools/calculator.md +1 -1
  62. package/src/prompts/tools/enter-plan-mode.md +92 -0
  63. package/src/prompts/tools/exit-plan-mode.md +38 -0
  64. package/src/prompts/tools/fetch.md +1 -1
  65. package/src/prompts/tools/find.md +1 -1
  66. package/src/prompts/tools/gemini-image.md +1 -1
  67. package/src/prompts/tools/grep.md +1 -1
  68. package/src/prompts/tools/lsp.md +1 -1
  69. package/src/prompts/tools/patch.md +1 -3
  70. package/src/prompts/tools/python.md +2 -4
  71. package/src/prompts/tools/read.md +1 -1
  72. package/src/prompts/tools/replace.md +16 -16
  73. package/src/prompts/tools/ssh.md +1 -4
  74. package/src/prompts/tools/task.md +1 -3
  75. package/src/prompts/tools/todo-write.md +13 -16
  76. package/src/prompts/tools/web-search.md +1 -1
  77. package/src/prompts/tools/write.md +1 -1
  78. package/src/sdk.ts +61 -10
  79. package/src/session/agent-session.ts +267 -0
  80. package/src/task/executor.ts +1 -0
  81. package/src/task/index.ts +18 -4
  82. package/src/tools/enter-plan-mode.ts +76 -0
  83. package/src/tools/exit-plan-mode.ts +62 -0
  84. package/src/tools/find.ts +5 -2
  85. package/src/tools/grep.ts +13 -12
  86. package/src/tools/index.ts +19 -1
  87. package/src/tools/plan-mode-guard.ts +46 -0
  88. package/src/tools/read.ts +8 -4
  89. package/src/tools/write.ts +3 -2
  90. package/src/utils/tools-manager.ts +38 -9
  91. package/src/web/search/providers/perplexity.ts +3 -1
  92. package/src/web/search/types.ts +3 -1
package/src/sdk.ts CHANGED
@@ -50,6 +50,7 @@ import {
50
50
  loadCustomCommands as loadCustomCommandsInternal,
51
51
  } from "./extensibility/custom-commands";
52
52
  import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "./extensibility/custom-tools/types";
53
+ import { CustomToolAdapter } from "./extensibility/custom-tools/wrapper";
53
54
  import {
54
55
  discoverAndLoadExtensions,
55
56
  type ExtensionContext,
@@ -69,6 +70,7 @@ import {
69
70
  AgentProtocolHandler,
70
71
  ArtifactProtocolHandler,
71
72
  InternalUrlRouter,
73
+ PlanProtocolHandler,
72
74
  RuleProtocolHandler,
73
75
  SkillProtocolHandler,
74
76
  } from "./internal-urls";
@@ -743,12 +745,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
743
745
  outputSchema: options.outputSchema,
744
746
  requireCompleteTool: options.requireCompleteTool,
745
747
  getSessionFile: () => sessionManager.getSessionFile() ?? null,
748
+ getSessionId: () => sessionManager.getSessionId?.() ?? null,
746
749
  getSessionSpawns: () => options.spawns ?? "*",
747
750
  getModelString: () => (hasExplicitModel && model ? formatModelString(model) : undefined),
748
751
  getActiveModelString: () => {
749
752
  const activeModel = agent?.state.model;
750
753
  return activeModel ? formatModelString(activeModel) : undefined;
751
754
  },
755
+ getPlanModeState: () => session.getPlanModeState(),
752
756
  settings: settingsManager,
753
757
  settingsManager,
754
758
  authStorage,
@@ -763,6 +767,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
763
767
  };
764
768
  internalRouter.register(new AgentProtocolHandler({ getArtifactsDir }));
765
769
  internalRouter.register(new ArtifactProtocolHandler({ getArtifactsDir }));
770
+ internalRouter.register(
771
+ new PlanProtocolHandler({
772
+ getPlansDirectory: settingsManager.getPlansDirectory.bind(settingsManager),
773
+ cwd,
774
+ }),
775
+ );
766
776
  internalRouter.register(
767
777
  new SkillProtocolHandler({
768
778
  getSkills: () => skills,
@@ -924,14 +934,33 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
924
934
  const toolContextStore = new ToolContextStore(getSessionContext);
925
935
 
926
936
  const registeredTools = extensionRunner?.getAllRegisteredTools() ?? [];
927
- const allCustomTools = [
928
- ...registeredTools,
929
- ...(options.customTools?.map(tool => {
930
- const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool;
931
- return { definition, extensionPath: "<sdk>" };
932
- }) ?? []),
933
- ];
934
- const wrappedExtensionTools = extensionRunner ? wrapRegisteredTools(allCustomTools, extensionRunner) : [];
937
+ let wrappedExtensionTools: AgentTool[];
938
+
939
+ if (extensionRunner) {
940
+ // With extension runner: convert CustomTools to ToolDefinitions and wrap all together
941
+ const allCustomTools = [
942
+ ...registeredTools,
943
+ ...(options.customTools?.map(tool => {
944
+ const definition = isCustomTool(tool) ? customToolToDefinition(tool) : tool;
945
+ return { definition, extensionPath: "<sdk>" };
946
+ }) ?? []),
947
+ ];
948
+ wrappedExtensionTools = wrapRegisteredTools(allCustomTools, extensionRunner);
949
+ } else {
950
+ // Without extension runner: wrap CustomTools directly with CustomToolAdapter
951
+ // ToolDefinition items require ExtensionContext and cannot be used without a runner
952
+ const customToolContext = (): CustomToolContext => ({
953
+ sessionManager,
954
+ modelRegistry,
955
+ model: agent?.state.model,
956
+ isIdle: () => !session?.isStreaming,
957
+ hasQueuedMessages: () => (session?.queuedMessageCount ?? 0) > 0,
958
+ abort: () => session?.abort(),
959
+ });
960
+ wrappedExtensionTools = (options.customTools ?? [])
961
+ .filter(isCustomTool)
962
+ .map(tool => CustomToolAdapter.wrap(tool, customToolContext) as AgentTool);
963
+ }
935
964
 
936
965
  // All built-in tools are active (conditional tools like git/ask return null from factory if disabled)
937
966
  const toolRegistry = new Map<string, AgentTool>();
@@ -989,7 +1018,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
989
1018
  return options.systemPrompt(defaultPrompt);
990
1019
  };
991
1020
 
992
- const systemPrompt = await rebuildSystemPrompt(Array.from(toolRegistry.keys()), toolRegistry);
1021
+ const toolNamesFromRegistry = Array.from(toolRegistry.keys());
1022
+ const requestedToolNames = options.toolNames ?? toolNamesFromRegistry;
1023
+ const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1024
+ const includeExitPlanMode = options.toolNames?.includes("exit_plan_mode") ?? false;
1025
+ const initialToolNames = includeExitPlanMode
1026
+ ? normalizedRequested
1027
+ : normalizedRequested.filter(name => name !== "exit_plan_mode");
1028
+
1029
+ // Custom tools are always included regardless of toolNames filter
1030
+ if (options.customTools) {
1031
+ const customToolNames = options.customTools.map(t => (isCustomTool(t) ? t.name : t.name));
1032
+ for (const name of customToolNames) {
1033
+ if (toolRegistry.has(name) && !initialToolNames.includes(name)) {
1034
+ initialToolNames.push(name);
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ const systemPrompt = await rebuildSystemPrompt(initialToolNames, toolRegistry);
993
1040
  time("buildSystemPrompt");
994
1041
 
995
1042
  const promptTemplates = options.promptTemplates ?? (await discoverPromptTemplates(cwd, agentDir));
@@ -1038,12 +1085,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1038
1085
  toolContextStore.setUIContext(uiContext, hasUI);
1039
1086
  };
1040
1087
 
1088
+ const initialTools = initialToolNames
1089
+ .map(name => toolRegistry.get(name))
1090
+ .filter((tool): tool is AgentTool => tool !== undefined);
1091
+
1041
1092
  agent = new Agent({
1042
1093
  initialState: {
1043
1094
  systemPrompt,
1044
1095
  model,
1045
1096
  thinkingLevel,
1046
- tools: Array.from(toolRegistry.values()),
1097
+ tools: initialTools,
1047
1098
  },
1048
1099
  convertToLlm: convertToLlmWithBlockImages,
1049
1100
  sessionId: sessionManager.getSessionId(),
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import * as fs from "node:fs";
17
+
17
18
  import type { Agent, AgentEvent, AgentMessage, AgentState, AgentTool, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
18
19
  import type {
19
20
  AssistantMessage,
@@ -26,6 +27,7 @@ import type {
26
27
  UsageReport,
27
28
  } from "@oh-my-pi/pi-ai";
28
29
  import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
30
+ import { resolvePlanUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls";
29
31
  import { abortableSleep, isEnoent, logger } from "@oh-my-pi/pi-utils";
30
32
  import { YAML } from "bun";
31
33
  import type { Rule } from "../capability/rule";
@@ -62,6 +64,9 @@ import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slas
62
64
  import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
63
65
  import { theme } from "../modes/theme/theme";
64
66
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
67
+ import type { PlanModeState } from "../plan-mode/state";
68
+ import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
69
+ import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
65
70
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
66
71
  import { closeAllConnections } from "../ssh/connection-manager";
67
72
  import { unmountAll } from "../ssh/sshfs-mount";
@@ -188,6 +193,11 @@ export interface SessionStats {
188
193
  cost: number;
189
194
  }
190
195
 
196
+ /** Result from handoff() */
197
+ export interface HandoffResult {
198
+ document: string;
199
+ }
200
+
191
201
  /** Internal marker for hook messages queued through the agent loop */
192
202
  // ============================================================================
193
203
  // Constants
@@ -255,6 +265,8 @@ export class AgentSession {
255
265
  private _followUpMessages: string[] = [];
256
266
  /** Messages queued to be included with the next user prompt as context ("asides"). */
257
267
  private _pendingNextTurnMessages: CustomMessage[] = [];
268
+ private _planModeState: PlanModeState | undefined;
269
+ private _planReferenceSent = false;
258
270
 
259
271
  // Compaction state
260
272
  private _compactionAbortController: AbortController | undefined = undefined;
@@ -263,6 +275,9 @@ export class AgentSession {
263
275
  // Branch summarization state
264
276
  private _branchSummaryAbortController: AbortController | undefined = undefined;
265
277
 
278
+ // Handoff state
279
+ private _handoffAbortController: AbortController | undefined = undefined;
280
+
266
281
  // Retry state
267
282
  private _retryAbortController: AbortController | undefined = undefined;
268
283
  private _retryAttempt = 0;
@@ -970,6 +985,25 @@ export class AgentSession {
970
985
  }
971
986
 
972
987
  /** Prompt templates */
988
+ getPlanModeState(): PlanModeState | undefined {
989
+ return this._planModeState;
990
+ }
991
+
992
+ setPlanModeState(state: PlanModeState | undefined): void {
993
+ this._planModeState = state;
994
+ if (state?.enabled) {
995
+ this._planReferenceSent = false;
996
+ }
997
+ }
998
+
999
+ markPlanReferenceSent(): void {
1000
+ this._planReferenceSent = true;
1001
+ }
1002
+
1003
+ resolveRoleModel(role: string): Model<any> | undefined {
1004
+ return this._resolveRoleModel(role, this._modelRegistry.getAvailable(), this.model);
1005
+ }
1006
+
973
1007
  get promptTemplates(): ReadonlyArray<PromptTemplate> {
974
1008
  return this._promptTemplates;
975
1009
  }
@@ -983,6 +1017,95 @@ export class AgentSession {
983
1017
  // Prompting
984
1018
  // =========================================================================
985
1019
 
1020
+ /**
1021
+ * Build a plan mode message.
1022
+ * Returns null if plan mode is not enabled.
1023
+ * @returns The plan mode message, or null if plan mode is not enabled.
1024
+ */
1025
+ private async _buildPlanReferenceMessage(): Promise<CustomMessage | null> {
1026
+ if (this._planModeState?.enabled) return null;
1027
+ if (this._planReferenceSent) return null;
1028
+
1029
+ const planFilePath = `plan://${this.sessionManager.getSessionId()}/plan.md`;
1030
+ const resolvedPlanPath = resolvePlanUrlToPath(planFilePath, {
1031
+ getPlansDirectory: this.settingsManager.getPlansDirectory.bind(this.settingsManager),
1032
+ cwd: this.sessionManager.getCwd(),
1033
+ });
1034
+ let planContent: string;
1035
+ try {
1036
+ planContent = await fs.promises.readFile(resolvedPlanPath, "utf-8");
1037
+ } catch (error) {
1038
+ if (isEnoent(error)) {
1039
+ return null;
1040
+ }
1041
+ throw error;
1042
+ }
1043
+
1044
+ const content = renderPromptTemplate(planModeReferencePrompt, {
1045
+ planFilePath,
1046
+ planContent,
1047
+ });
1048
+
1049
+ this._planReferenceSent = true;
1050
+
1051
+ return {
1052
+ role: "custom",
1053
+ customType: "plan-mode-reference",
1054
+ content,
1055
+ display: false,
1056
+ timestamp: Date.now(),
1057
+ };
1058
+ }
1059
+
1060
+ private async _buildPlanModeMessage(): Promise<CustomMessage | null> {
1061
+ const state = this._planModeState;
1062
+ if (!state?.enabled) return null;
1063
+ const sessionPlanUrl = `plan://${this.sessionManager.getSessionId()}/plan.md`;
1064
+ const resolvedPlanPath = state.planFilePath.startsWith("plan://")
1065
+ ? resolvePlanUrlToPath(state.planFilePath, {
1066
+ getPlansDirectory: this.settingsManager.getPlansDirectory.bind(this.settingsManager),
1067
+ cwd: this.sessionManager.getCwd(),
1068
+ })
1069
+ : resolveToCwd(state.planFilePath, this.sessionManager.getCwd());
1070
+ const resolvedSessionPlan = resolvePlanUrlToPath(sessionPlanUrl, {
1071
+ getPlansDirectory: this.settingsManager.getPlansDirectory.bind(this.settingsManager),
1072
+ cwd: this.sessionManager.getCwd(),
1073
+ });
1074
+ const displayPlanPath =
1075
+ state.planFilePath.startsWith("plan://") || resolvedPlanPath !== resolvedSessionPlan
1076
+ ? state.planFilePath
1077
+ : sessionPlanUrl;
1078
+
1079
+ let planExists = false;
1080
+ try {
1081
+ const stat = await fs.promises.stat(resolvedPlanPath);
1082
+ planExists = stat.isFile();
1083
+ } catch (error) {
1084
+ if (!isEnoent(error)) {
1085
+ throw error;
1086
+ }
1087
+ }
1088
+
1089
+ const content = renderPromptTemplate(planModeActivePrompt, {
1090
+ planFilePath: displayPlanPath,
1091
+ planExists,
1092
+ askToolName: "ask",
1093
+ writeToolName: "write",
1094
+ editToolName: "edit",
1095
+ exitToolName: "exit_plan_mode",
1096
+ reentry: state.reentry ?? false,
1097
+ iterative: state.workflow === "iterative",
1098
+ });
1099
+
1100
+ return {
1101
+ role: "custom",
1102
+ customType: "plan-mode-context",
1103
+ content,
1104
+ display: false,
1105
+ timestamp: Date.now(),
1106
+ };
1107
+ }
1108
+
986
1109
  /**
987
1110
  * Send a prompt to the agent.
988
1111
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -1069,6 +1192,14 @@ export class AgentSession {
1069
1192
 
1070
1193
  // Build messages array (custom messages if any, then user message)
1071
1194
  const messages: AgentMessage[] = [];
1195
+ const planReferenceMessage = await this._buildPlanReferenceMessage?.();
1196
+ if (planReferenceMessage) {
1197
+ messages.push(planReferenceMessage);
1198
+ }
1199
+ const planModeMessage = await this._buildPlanModeMessage();
1200
+ if (planModeMessage) {
1201
+ messages.push(planModeMessage);
1202
+ }
1072
1203
 
1073
1204
  // Add user message
1074
1205
  const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
@@ -1512,6 +1643,7 @@ export class AgentSession {
1512
1643
  this._followUpMessages = [];
1513
1644
  this._pendingNextTurnMessages = [];
1514
1645
  this._todoReminderCount = 0;
1646
+ this._planReferenceSent = false;
1515
1647
  this._reconnectToAgent();
1516
1648
 
1517
1649
  // Emit session_switch event with reason "new" to hooks
@@ -1945,6 +2077,141 @@ export class AgentSession {
1945
2077
  this._branchSummaryAbortController?.abort();
1946
2078
  }
1947
2079
 
2080
+ /**
2081
+ * Cancel in-progress handoff generation.
2082
+ */
2083
+ abortHandoff(): void {
2084
+ this._handoffAbortController?.abort();
2085
+ }
2086
+
2087
+ /**
2088
+ * Check if handoff generation is in progress.
2089
+ */
2090
+ get isGeneratingHandoff(): boolean {
2091
+ return this._handoffAbortController !== undefined;
2092
+ }
2093
+
2094
+ /**
2095
+ * Generate a handoff document by asking the agent, then start a new session with it.
2096
+ *
2097
+ * This prompts the current agent to write a comprehensive handoff document,
2098
+ * waits for completion, then starts a fresh session with the handoff as context.
2099
+ *
2100
+ * @param customInstructions Optional focus for the handoff document
2101
+ * @returns The handoff document text, or undefined if cancelled/failed
2102
+ */
2103
+ async handoff(customInstructions?: string): Promise<HandoffResult | undefined> {
2104
+ const entries = this.sessionManager.getBranch();
2105
+ const messageCount = entries.filter(e => e.type === "message").length;
2106
+
2107
+ if (messageCount < 2) {
2108
+ throw new Error("Nothing to hand off (no messages yet)");
2109
+ }
2110
+
2111
+ this._handoffAbortController = new AbortController();
2112
+
2113
+ // Build the handoff prompt
2114
+ let handoffPrompt = `Write a comprehensive handoff document that will allow another instance of yourself to seamlessly continue this work. The document should capture everything needed to resume without access to this conversation.
2115
+
2116
+ Use this format:
2117
+
2118
+ ## Goal
2119
+ [What the user is trying to accomplish]
2120
+
2121
+ ## Constraints & Preferences
2122
+ - [Any constraints, preferences, or requirements mentioned]
2123
+
2124
+ ## Progress
2125
+ ### Done
2126
+ - [x] [Completed tasks with specifics]
2127
+
2128
+ ### In Progress
2129
+ - [ ] [Current work if any]
2130
+
2131
+ ### Pending
2132
+ - [ ] [Tasks mentioned but not started]
2133
+
2134
+ ## Key Decisions
2135
+ - **[Decision]**: [Rationale]
2136
+
2137
+ ## Critical Context
2138
+ - [Code snippets, file paths, error messages, or data essential to continue]
2139
+ - [Repository state if relevant]
2140
+
2141
+ ## Next Steps
2142
+ 1. [What should happen next]
2143
+
2144
+ Be thorough - include exact file paths, function names, error messages, and technical details. Output ONLY the handoff document, no other text.`;
2145
+
2146
+ if (customInstructions) {
2147
+ handoffPrompt += `\n\nAdditional focus: ${customInstructions}`;
2148
+ }
2149
+
2150
+ // Create a promise that resolves when the agent completes
2151
+ let handoffText: string | undefined;
2152
+ const completionPromise = new Promise<void>((resolve, reject) => {
2153
+ const unsubscribe = this.subscribe(event => {
2154
+ if (this._handoffAbortController?.signal.aborted) {
2155
+ unsubscribe();
2156
+ reject(new Error("Handoff cancelled"));
2157
+ return;
2158
+ }
2159
+
2160
+ if (event.type === "agent_end") {
2161
+ unsubscribe();
2162
+ // Extract text from the last assistant message
2163
+ const messages = this.agent.state.messages;
2164
+ for (let i = messages.length - 1; i >= 0; i--) {
2165
+ const msg = messages[i];
2166
+ if (msg.role === "assistant") {
2167
+ const content = (msg as AssistantMessage).content;
2168
+ const textParts = content
2169
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
2170
+ .map(c => c.text);
2171
+ if (textParts.length > 0) {
2172
+ handoffText = textParts.join("\n");
2173
+ break;
2174
+ }
2175
+ }
2176
+ }
2177
+ resolve();
2178
+ }
2179
+ });
2180
+ });
2181
+
2182
+ try {
2183
+ // Send the prompt and wait for completion
2184
+ await this.prompt(handoffPrompt, { expandPromptTemplates: false });
2185
+ await completionPromise;
2186
+
2187
+ if (!handoffText || this._handoffAbortController.signal.aborted) {
2188
+ return undefined;
2189
+ }
2190
+
2191
+ // Start a new session
2192
+ await this.sessionManager.flush();
2193
+ this.sessionManager.newSession();
2194
+ this.agent.reset();
2195
+ this.agent.sessionId = this.sessionManager.getSessionId();
2196
+ this._steeringMessages = [];
2197
+ this._followUpMessages = [];
2198
+ this._pendingNextTurnMessages = [];
2199
+ this._todoReminderCount = 0;
2200
+
2201
+ // Inject the handoff document as a custom message
2202
+ const handoffContent = `<handoff-context>\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from a previous session. Use this context to continue the work seamlessly.`;
2203
+ this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true);
2204
+
2205
+ // Rebuild agent messages from session
2206
+ const sessionContext = this.sessionManager.buildSessionContext();
2207
+ this.agent.replaceMessages(sessionContext.messages);
2208
+
2209
+ return { document: handoffText };
2210
+ } finally {
2211
+ this._handoffAbortController = undefined;
2212
+ }
2213
+ }
2214
+
1948
2215
  /**
1949
2216
  * Check if compaction is needed and run it.
1950
2217
  * Called after agent_end and before prompt submission.
@@ -81,6 +81,7 @@ export interface ExecutorOptions {
81
81
  modelRegistry?: ModelRegistry;
82
82
  settingsManager?: {
83
83
  serialize: () => import("@oh-my-pi/pi-coding-agent/config/settings-manager").Settings;
84
+ getPlansDirectory: (cwd?: string) => string;
84
85
  getPythonToolMode?: () => "ipy-only" | "bash-only" | "both";
85
86
  getPythonKernelMode?: () => "session" | "per-call";
86
87
  getPythonSharedGateway?: () => boolean;
package/src/task/index.ts CHANGED
@@ -17,6 +17,9 @@ import * as os from "node:os";
17
17
  import path from "node:path";
18
18
  import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
19
19
  import type { Usage } from "@oh-my-pi/pi-ai";
20
+ import planModeSubagentPrompt from "@oh-my-pi/pi-coding-agent/prompts/system/plan-mode-subagent.md" with {
21
+ type: "text",
22
+ };
20
23
  import { $ } from "bun";
21
24
  import { nanoid } from "nanoid";
22
25
  import type { ToolSession } from "..";
@@ -192,14 +195,25 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
192
195
  };
193
196
  }
194
197
 
195
- const effectiveAgentModel = isDefaultModelAlias(agent.model) ? undefined : agent.model;
198
+ const planModeState = this.session.getPlanModeState?.();
199
+ const planModeTools = ["read", "grep", "find", "ls", "lsp", "fetch", "web_search"];
200
+ const effectiveAgent: typeof agent = planModeState?.enabled
201
+ ? {
202
+ ...agent,
203
+ systemPrompt: `${planModeSubagentPrompt}\n\n${agent.systemPrompt}`,
204
+ tools: planModeTools,
205
+ spawns: undefined,
206
+ }
207
+ : agent;
208
+
209
+ const effectiveAgentModel = isDefaultModelAlias(effectiveAgent.model) ? undefined : effectiveAgent.model;
196
210
  const modelOverride =
197
211
  effectiveAgentModel ?? this.session.getActiveModelString?.() ?? this.session.getModelString?.();
198
- const thinkingLevelOverride = agent.thinkingLevel;
212
+ const thinkingLevelOverride = effectiveAgent.thinkingLevel;
199
213
 
200
214
  // Output schema priority: agent frontmatter > params > inherited from parent session
201
- const schemaOverridden = outputSchema !== undefined && agent.output !== undefined;
202
- const effectiveOutputSchema = agent.output ?? outputSchema ?? this.session.outputSchema;
215
+ const schemaOverridden = outputSchema !== undefined && effectiveAgent.output !== undefined;
216
+ const effectiveOutputSchema = effectiveAgent.output ?? outputSchema ?? this.session.outputSchema;
203
217
 
204
218
  // Handle empty or missing tasks
205
219
  if (!params.tasks || params.tasks.length === 0) {
@@ -0,0 +1,76 @@
1
+ import * as fs from "node:fs/promises";
2
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
+ import { renderPromptTemplate } from "@oh-my-pi/pi-coding-agent/config/prompt-templates";
4
+ import { resolvePlanUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls";
5
+ import enterPlanModeDescription from "@oh-my-pi/pi-coding-agent/prompts/tools/enter-plan-mode.md" with { type: "text" };
6
+ import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
7
+ import { ToolError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
8
+ import { isEnoent } from "@oh-my-pi/pi-utils";
9
+ import { Type } from "@sinclair/typebox";
10
+
11
+ const enterPlanModeSchema = Type.Object({
12
+ workflow: Type.Optional(Type.Union([Type.Literal("parallel"), Type.Literal("iterative")])),
13
+ });
14
+
15
+ export interface EnterPlanModeDetails {
16
+ planFilePath: string;
17
+ planExists: boolean;
18
+ workflow?: "parallel" | "iterative";
19
+ }
20
+
21
+ export class EnterPlanModeTool implements AgentTool<typeof enterPlanModeSchema, EnterPlanModeDetails> {
22
+ public readonly name = "enter_plan_mode";
23
+ public readonly label = "EnterPlanMode";
24
+ public readonly description: string;
25
+ public readonly parameters = enterPlanModeSchema;
26
+
27
+ private readonly session: ToolSession;
28
+
29
+ constructor(session: ToolSession) {
30
+ this.session = session;
31
+ this.description = renderPromptTemplate(enterPlanModeDescription);
32
+ }
33
+
34
+ public async execute(
35
+ _toolCallId: string,
36
+ params: { workflow?: "parallel" | "iterative" },
37
+ _signal?: AbortSignal,
38
+ _onUpdate?: AgentToolUpdateCallback<EnterPlanModeDetails>,
39
+ _context?: AgentToolContext,
40
+ ): Promise<AgentToolResult<EnterPlanModeDetails>> {
41
+ const state = this.session.getPlanModeState?.();
42
+ if (state?.enabled) {
43
+ throw new ToolError("Plan mode is already active.");
44
+ }
45
+
46
+ const sessionId = this.session.getSessionId?.();
47
+ if (!sessionId) {
48
+ throw new ToolError("Plan mode requires an active session.");
49
+ }
50
+
51
+ const settingsManager = this.session.settingsManager;
52
+ if (!settingsManager) {
53
+ throw new ToolError("Settings manager unavailable for plan mode.");
54
+ }
55
+
56
+ const planFilePath = `plan://${sessionId}/plan.md`;
57
+ const resolvedPlanPath = resolvePlanUrlToPath(planFilePath, {
58
+ getPlansDirectory: settingsManager.getPlansDirectory.bind(settingsManager),
59
+ cwd: this.session.cwd,
60
+ });
61
+ let planExists = false;
62
+ try {
63
+ const stat = await fs.stat(resolvedPlanPath);
64
+ planExists = stat.isFile();
65
+ } catch (error) {
66
+ if (!isEnoent(error)) {
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ return {
72
+ content: [{ type: "text", text: "Plan mode requested." }],
73
+ details: { planFilePath, planExists, workflow: params.workflow },
74
+ };
75
+ }
76
+ }
@@ -0,0 +1,62 @@
1
+ import * as fs from "node:fs/promises";
2
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
3
+ import { isEnoent } from "@oh-my-pi/pi-utils";
4
+ import { Type } from "@sinclair/typebox";
5
+ import { renderPromptTemplate } from "../config/prompt-templates";
6
+ import exitPlanModeDescription from "../prompts/tools/exit-plan-mode.md" with { type: "text" };
7
+ import type { ToolSession } from ".";
8
+ import { resolvePlanPath } from "./plan-mode-guard";
9
+ import { ToolError } from "./tool-errors";
10
+
11
+ const exitPlanModeSchema = Type.Object({});
12
+
13
+ export interface ExitPlanModeDetails {
14
+ planFilePath: string;
15
+ planExists: boolean;
16
+ }
17
+
18
+ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, ExitPlanModeDetails> {
19
+ public readonly name = "exit_plan_mode";
20
+ public readonly label = "ExitPlanMode";
21
+ public readonly description: string;
22
+ public readonly parameters = exitPlanModeSchema;
23
+
24
+ private readonly session: ToolSession;
25
+
26
+ constructor(session: ToolSession) {
27
+ this.session = session;
28
+ this.description = renderPromptTemplate(exitPlanModeDescription);
29
+ }
30
+
31
+ public async execute(
32
+ _toolCallId: string,
33
+ _params: Record<string, never>,
34
+ _signal?: AbortSignal,
35
+ _onUpdate?: AgentToolUpdateCallback<ExitPlanModeDetails>,
36
+ _context?: AgentToolContext,
37
+ ): Promise<AgentToolResult<ExitPlanModeDetails>> {
38
+ const state = this.session.getPlanModeState?.();
39
+ if (!state?.enabled) {
40
+ throw new ToolError("Plan mode is not active.");
41
+ }
42
+
43
+ const resolvedPlanPath = resolvePlanPath(this.session, state.planFilePath);
44
+ let planExists = false;
45
+ try {
46
+ const stat = await fs.stat(resolvedPlanPath);
47
+ planExists = stat.isFile();
48
+ } catch (error) {
49
+ if (!isEnoent(error)) {
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ return {
55
+ content: [{ type: "text", text: "Plan ready for approval." }],
56
+ details: {
57
+ planFilePath: state.planFilePath,
58
+ planExists,
59
+ },
60
+ };
61
+ }
62
+ }
package/src/tools/find.ts CHANGED
@@ -127,7 +127,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
127
127
  params: Static<typeof findSchema>,
128
128
  signal?: AbortSignal,
129
129
  _onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
130
- _context?: AgentToolContext,
130
+ context?: AgentToolContext,
131
131
  ): Promise<AgentToolResult<FindToolDetails>> {
132
132
  const { pattern, path: searchDir, limit, hidden, type } = params;
133
133
 
@@ -196,7 +196,10 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
196
196
  }
197
197
 
198
198
  // Default: use fd
199
- const fdPath = await ensureTool("fd", true);
199
+ const fdPath = await ensureTool("fd", {
200
+ silent: true,
201
+ notify: message => context?.ui?.notify(message, "info"),
202
+ });
200
203
  if (!fdPath) {
201
204
  throw new ToolError("fd is not available and could not be downloaded");
202
205
  }