@oh-my-pi/pi-coding-agent 15.9.0 → 15.9.3

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 (124) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  6. package/dist/types/config/model-registry.d.ts +5 -0
  7. package/dist/types/config/models-config-schema.d.ts +18 -0
  8. package/dist/types/config/settings-schema.d.ts +3 -3
  9. package/dist/types/config/settings.d.ts +11 -0
  10. package/dist/types/discovery/helpers.d.ts +1 -0
  11. package/dist/types/exa/mcp-client.d.ts +2 -1
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  13. package/dist/types/hindsight/bank.d.ts +17 -9
  14. package/dist/types/hindsight/mental-models.d.ts +1 -1
  15. package/dist/types/hindsight/state.d.ts +9 -3
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/manager.d.ts +1 -1
  18. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  22. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  23. package/dist/types/modes/components/session-selector.d.ts +8 -3
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  27. package/dist/types/session/agent-session.d.ts +13 -1
  28. package/dist/types/session/auth-storage.d.ts +2 -2
  29. package/dist/types/session/history-storage.d.ts +3 -4
  30. package/dist/types/session/messages.d.ts +1 -0
  31. package/dist/types/session/session-manager.d.ts +1 -0
  32. package/dist/types/slash-commands/types.d.ts +17 -4
  33. package/dist/types/task/types.d.ts +2 -0
  34. package/dist/types/tiny/text.d.ts +17 -0
  35. package/dist/types/tools/index.d.ts +16 -0
  36. package/dist/types/tools/path-utils.d.ts +11 -0
  37. package/dist/types/web/search/providers/base.d.ts +14 -0
  38. package/dist/types/web/search/providers/exa.d.ts +9 -0
  39. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +2 -1
  41. package/package.json +9 -9
  42. package/src/cli/dry-balance-cli.ts +823 -0
  43. package/src/cli/session-picker.ts +1 -0
  44. package/src/cli/update-cli.ts +54 -2
  45. package/src/cli-commands.ts +1 -0
  46. package/src/commands/completions.ts +1 -1
  47. package/src/commands/dry-balance.ts +43 -0
  48. package/src/config/append-only-context-mode.ts +37 -0
  49. package/src/config/model-registry.ts +6 -0
  50. package/src/config/models-config-schema.ts +3 -0
  51. package/src/config/settings-schema.ts +2 -2
  52. package/src/config/settings.ts +38 -0
  53. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  54. package/src/discovery/github.ts +37 -1
  55. package/src/discovery/helpers.ts +3 -1
  56. package/src/exa/mcp-client.ts +11 -5
  57. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  58. package/src/hindsight/backend.ts +184 -35
  59. package/src/hindsight/bank.ts +32 -22
  60. package/src/hindsight/mental-models.ts +1 -1
  61. package/src/hindsight/state.ts +21 -7
  62. package/src/internal-urls/docs-index.generated.ts +5 -5
  63. package/src/internal-urls/omp-protocol.ts +8 -2
  64. package/src/main.ts +4 -2
  65. package/src/mcp/json-rpc.ts +8 -0
  66. package/src/mcp/manager.ts +40 -21
  67. package/src/mcp/render.ts +3 -0
  68. package/src/mcp/tool-bridge.ts +10 -2
  69. package/src/mcp/transports/http.ts +33 -16
  70. package/src/mnemopi/state.ts +4 -4
  71. package/src/modes/acp/acp-agent.ts +168 -3
  72. package/src/modes/components/agent-dashboard.ts +103 -31
  73. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  74. package/src/modes/components/history-search.ts +128 -14
  75. package/src/modes/components/plugin-settings.ts +270 -36
  76. package/src/modes/components/session-selector.ts +45 -14
  77. package/src/modes/components/settings-selector.ts +1 -1
  78. package/src/modes/components/tips.txt +5 -1
  79. package/src/modes/components/transcript-container.ts +35 -6
  80. package/src/modes/components/tree-selector.ts +29 -2
  81. package/src/modes/controllers/command-controller.ts +4 -3
  82. package/src/modes/controllers/input-controller.ts +18 -7
  83. package/src/modes/controllers/selector-controller.ts +30 -19
  84. package/src/modes/interactive-mode.ts +38 -3
  85. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  86. package/src/modes/utils/keybinding-matchers.ts +10 -0
  87. package/src/prompts/agents/explore.md +1 -0
  88. package/src/prompts/agents/librarian.md +1 -0
  89. package/src/prompts/dry-balance-bench.md +8 -0
  90. package/src/prompts/steering/user-interjection.md +10 -0
  91. package/src/prompts/system/agent-creation-architect.md +1 -26
  92. package/src/prompts/system/system-prompt.md +143 -145
  93. package/src/prompts/system/title-system.md +3 -2
  94. package/src/prompts/tools/browser.md +29 -29
  95. package/src/prompts/tools/render-mermaid.md +2 -2
  96. package/src/sdk.ts +87 -30
  97. package/src/session/agent-session.ts +96 -14
  98. package/src/session/auth-storage.ts +4 -0
  99. package/src/session/history-storage.ts +11 -18
  100. package/src/session/messages.ts +80 -0
  101. package/src/session/session-manager.ts +7 -1
  102. package/src/slash-commands/types.ts +27 -10
  103. package/src/task/executor.ts +6 -2
  104. package/src/task/index.ts +8 -7
  105. package/src/task/types.ts +2 -0
  106. package/src/tiny/text.ts +112 -1
  107. package/src/tools/bash.ts +3 -4
  108. package/src/tools/index.ts +16 -0
  109. package/src/tools/job.ts +3 -3
  110. package/src/tools/memory-recall.ts +1 -1
  111. package/src/tools/memory-reflect.ts +3 -3
  112. package/src/tools/path-utils.ts +21 -0
  113. package/src/tools/search.ts +18 -1
  114. package/src/tools/ssh.ts +26 -10
  115. package/src/tools/write.ts +14 -2
  116. package/src/tui/status-line.ts +15 -4
  117. package/src/utils/file-mentions.ts +7 -107
  118. package/src/utils/title-generator.ts +66 -38
  119. package/src/web/search/index.ts +3 -1
  120. package/src/web/search/provider.ts +1 -1
  121. package/src/web/search/providers/base.ts +17 -0
  122. package/src/web/search/providers/exa.ts +111 -7
  123. package/src/web/search/providers/perplexity.ts +8 -4
  124. package/src/web/search/types.ts +2 -1
@@ -64,9 +64,15 @@ export class OmpProtocolHandler implements ProtocolHandler {
64
64
  throw new Error("Path traversal (..) is not allowed in omp:// URLs");
65
65
  }
66
66
 
67
- const content = EMBEDDED_DOCS[normalized];
67
+ const docPath =
68
+ normalized === "docs" ? "" : normalized.startsWith("docs/") ? normalized.slice("docs/".length) : normalized;
69
+ if (!docPath) {
70
+ return this.#listDocs(url);
71
+ }
72
+
73
+ const content = EMBEDDED_DOCS[docPath];
68
74
  if (content === undefined) {
69
- const lookup = normalized.replace(/\.md$/, "");
75
+ const lookup = docPath.replace(/\.md$/, "");
70
76
  const suggestions = EMBEDDED_DOC_FILENAMES.filter(
71
77
  f => f.includes(lookup) || lookup.includes(f.replace(/\.md$/, "")),
72
78
  ).slice(0, 5);
package/src/main.ts CHANGED
@@ -822,10 +822,12 @@ export async function runRootCommand(
822
822
  if (parsedArgs.noTitle || parsedArgs.mode === "rpc" || parsedArgs.mode === "rpc-ui" || parsedArgs.mode === "acp") {
823
823
  Bun.env.PI_NO_TITLE = "1";
824
824
  }
825
- const pipedInput = await logger.time("readPipedInput", readPipedInput);
825
+ const mode = parsedArgs.mode || "text";
826
+ const isProtocolMode = mode === "rpc" || mode === "rpc-ui" || mode === "acp";
827
+ // Protocol modes own stdin; treating it as prompt text would consume JSON-RPC frames before their transports start.
828
+ const pipedInput = isProtocolMode ? undefined : await logger.time("readPipedInput", readPipedInput);
826
829
  const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
827
830
  const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
828
- const mode = parsedArgs.mode || "text";
829
831
 
830
832
  // Initialize discovery system with settings for provider persistence
831
833
  logger.time("initializeWithSettings", initializeWithSettings, settingsInstance);
@@ -37,18 +37,25 @@ export interface JsonRpcResponse<T = unknown> {
37
37
  };
38
38
  }
39
39
 
40
+ /** Options controlling a single MCP JSON-RPC HTTP request. */
41
+ export interface CallMcpOptions {
42
+ signal?: AbortSignal;
43
+ }
44
+
40
45
  /**
41
46
  * Call an MCP server with JSON-RPC 2.0 over HTTPS.
42
47
  *
43
48
  * @param url - Full MCP server URL (including any query parameters)
44
49
  * @param method - JSON-RPC method name (e.g., "tools/list", "tools/call")
45
50
  * @param params - Method parameters
51
+ * @param options - Optional transport controls such as cancellation.
46
52
  * @returns Parsed JSON-RPC response
47
53
  */
48
54
  export async function callMCP<T = unknown>(
49
55
  url: string,
50
56
  method: string,
51
57
  params?: Record<string, unknown>,
58
+ options?: CallMcpOptions,
52
59
  ): Promise<JsonRpcResponse<T>> {
53
60
  const body = {
54
61
  jsonrpc: "2.0",
@@ -64,6 +71,7 @@ export async function callMCP<T = unknown>(
64
71
  Accept: "application/json, text/event-stream",
65
72
  },
66
73
  body: JSON.stringify(body),
74
+ signal: options?.signal,
67
75
  });
68
76
 
69
77
  if (!response.ok) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import * as path from "node:path";
8
8
  import * as url from "node:url";
9
- import type { TSchema } from "@oh-my-pi/pi-ai";
9
+ import { isDefinitiveOAuthFailure, type TSchema } from "@oh-my-pi/pi-ai";
10
10
  import { logger } from "@oh-my-pi/pi-utils";
11
11
  import type { SourceMeta } from "../capability/types";
12
12
  import { resolveConfigValue } from "../config/resolve-config-value";
@@ -1184,29 +1184,48 @@ export class MCPManager {
1184
1184
  await this.#authStorage.set(credentialId, refreshedCredential);
1185
1185
  credential = refreshedCredential;
1186
1186
  } catch (refreshError) {
1187
- logger.warn("MCP OAuth refresh failed, using existing token", {
1188
- credentialId,
1189
- error: refreshError,
1190
- });
1187
+ const errorMsg = refreshError instanceof Error ? refreshError.message : String(refreshError);
1188
+ if (isDefinitiveOAuthFailure(errorMsg)) {
1189
+ // `invalid_grant` / `invalid_token` / 401 from the token endpoint means
1190
+ // the server has retired this credential — keeping the stale access
1191
+ // token would just re-fail with 401 on every MCP request and leave a
1192
+ // poisoned row in agent.db that survives restarts. Drop it now so the
1193
+ // next connect attempt surfaces a clean "needs reauth" failure and
1194
+ // the user can recover with `/mcp reauth <server>` (or `/mcp unauth`
1195
+ // to forget the server entirely).
1196
+ logger.warn("MCP OAuth refresh failed definitively; cleared credential", {
1197
+ credentialId,
1198
+ error: errorMsg,
1199
+ });
1200
+ await this.#authStorage.remove(credentialId);
1201
+ credential = undefined;
1202
+ } else {
1203
+ logger.warn("MCP OAuth refresh failed, using existing token", {
1204
+ credentialId,
1205
+ error: refreshError,
1206
+ });
1207
+ }
1191
1208
  }
1192
1209
  }
1193
1210
 
1194
- if (resolved.type === "http" || resolved.type === "sse") {
1195
- resolved = {
1196
- ...resolved,
1197
- headers: {
1198
- ...resolved.headers,
1199
- Authorization: `Bearer ${credential.access}`,
1200
- },
1201
- };
1202
- } else {
1203
- resolved = {
1204
- ...resolved,
1205
- env: {
1206
- ...resolved.env,
1207
- OAUTH_ACCESS_TOKEN: credential.access,
1208
- },
1209
- };
1211
+ if (credential?.type === "oauth") {
1212
+ if (resolved.type === "http" || resolved.type === "sse") {
1213
+ resolved = {
1214
+ ...resolved,
1215
+ headers: {
1216
+ ...resolved.headers,
1217
+ Authorization: `Bearer ${credential.access}`,
1218
+ },
1219
+ };
1220
+ } else {
1221
+ resolved = {
1222
+ ...resolved,
1223
+ env: {
1224
+ ...resolved.env,
1225
+ OAUTH_ACCESS_TOKEN: credential.access,
1226
+ },
1227
+ };
1228
+ }
1210
1229
  }
1211
1230
  }
1212
1231
  } catch (error) {
package/src/mcp/render.ts CHANGED
@@ -51,6 +51,9 @@ export function renderMCPResult(
51
51
  ): Component {
52
52
  const { expanded } = options;
53
53
  const lines: string[] = [];
54
+ const isError = result.isError ?? result.details?.isError ?? false;
55
+ const title = result.details ? `${result.details.serverName}/${result.details.mcpToolName}` : "MCP";
56
+ lines.push(renderStatusLine({ icon: isError ? "error" : "success", title }, theme));
54
57
 
55
58
  // Args section (when expanded)
56
59
  if (expanded && args && typeof args === "object" && Object.keys(args).length > 0) {
@@ -116,10 +116,12 @@ function buildResult(
116
116
  provider,
117
117
  providerName,
118
118
  };
119
+ const contentText = result.isError ? `Error: ${text}` : text;
120
+ const toolResult: CustomToolResult<MCPToolDetails> = { content: [{ type: "text", text: contentText }], details };
119
121
  if (result.isError) {
120
- return { content: [{ type: "text", text: `Error: ${text}` }], details };
122
+ toolResult.isError = true;
121
123
  }
122
- return { content: [{ type: "text", text }], details };
124
+ return toolResult;
123
125
  }
124
126
 
125
127
  /** Build an error CustomToolResult from a caught exception. */
@@ -134,6 +136,7 @@ function buildErrorResult(
134
136
  return {
135
137
  content: [{ type: "text", text: `MCP error: ${message}` }],
136
138
  details: { serverName, mcpToolName, isError: true, provider, providerName },
139
+ isError: true,
137
140
  };
138
141
  }
139
142
 
@@ -217,6 +220,8 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
217
220
  readonly mcpToolName: string;
218
221
  /** Server name */
219
222
  readonly mcpServerName: string;
223
+ /** Render completed MCP calls with the result header replacing the pending call header. */
224
+ readonly mergeCallAndResult = true;
220
225
 
221
226
  /** Create MCPTool instances for all tools from an MCP server connection */
222
227
  static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[], reconnect?: MCPReconnect): MCPTool[] {
@@ -300,6 +305,9 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
300
305
  readonly mcpToolName: string;
301
306
  /** Server name */
302
307
  readonly mcpServerName: string;
308
+ /** Render completed MCP calls with the result header replacing the pending call header. */
309
+ readonly mergeCallAndResult = true;
310
+
303
311
  readonly #fallbackProvider: string | undefined;
304
312
  readonly #fallbackProviderName: string | undefined;
305
313
 
@@ -87,45 +87,62 @@ export class HttpTransport implements MCPTransport {
87
87
  headers["Mcp-Session-Id"] = this.#sessionId;
88
88
  }
89
89
 
90
- let response: Response;
90
+ let response: Response | null;
91
91
  let timedOut = false;
92
+ let startupFinished = false;
93
+ const connection = this.#sseConnection;
92
94
  const startupTimeoutMs = resolveSSEConnectTimeoutMs(this.config.timeout);
93
- const timeoutId =
95
+ const fetchPromise = fetch(this.config.url, {
96
+ method: "GET",
97
+ headers,
98
+ signal: connection.signal,
99
+ });
100
+ const timeoutPromise =
94
101
  startupTimeoutMs > 0
95
- ? setTimeout(() => {
96
- timedOut = true;
97
- this.#sseConnection?.abort();
98
- }, startupTimeoutMs)
102
+ ? new Promise<null>(resolve => {
103
+ setTimeout(() => {
104
+ if (!startupFinished) {
105
+ timedOut = true;
106
+ connection.abort();
107
+ }
108
+ resolve(null);
109
+ }, startupTimeoutMs);
110
+ })
99
111
  : null;
100
112
  try {
101
- response = await fetch(this.config.url, {
102
- method: "GET",
103
- headers,
104
- signal: this.#sseConnection.signal,
105
- });
113
+ response = timeoutPromise === null ? await fetchPromise : await Promise.race([fetchPromise, timeoutPromise]);
106
114
  } catch (error) {
107
- this.#sseConnection = null;
115
+ if (this.#sseConnection === connection) this.#sseConnection = null;
108
116
  if (error instanceof Error && error.name !== "AbortError" && !timedOut) {
109
117
  this.onError?.(error);
110
118
  }
111
119
  return;
112
120
  } finally {
113
- if (timeoutId !== null) clearTimeout(timeoutId);
121
+ startupFinished = true;
122
+ }
123
+ if (response === null) {
124
+ if (this.#sseConnection === connection) this.#sseConnection = null;
125
+ void fetchPromise.then(lateResponse => lateResponse.body?.cancel()).catch(() => {});
126
+ return;
114
127
  }
115
128
 
129
+ if (this.#sseConnection !== connection) {
130
+ await response.body?.cancel();
131
+ return;
132
+ }
116
133
  if (response.status === 405 || !response.ok || !response.body) {
117
134
  await response.body?.cancel();
118
- this.#sseConnection = null;
135
+ if (this.#sseConnection === connection) this.#sseConnection = null;
119
136
  return;
120
137
  }
121
138
 
122
139
  // Connection established — read messages in background.
123
140
  // If the stream ends unexpectedly (server restart, network drop),
124
141
  // fire onClose so the manager can trigger reconnection.
125
- const signal = this.#sseConnection.signal;
142
+ const signal = connection.signal;
126
143
  void this.#readSSEStream(response.body!, signal).finally(() => {
127
144
  const wasConnected = this.#connected;
128
- this.#sseConnection = null;
145
+ if (this.#sseConnection === connection) this.#sseConnection = null;
129
146
  if (wasConnected) this.onClose?.();
130
147
  });
131
148
  }
@@ -173,7 +173,7 @@ export class MnemopiSessionState {
173
173
  return lines.join("\n\n");
174
174
  }
175
175
 
176
- collectScopedRecallResults(query: string): RecallResult[] {
176
+ async collectScopedRecallResults(query: string): Promise<RecallResult[]> {
177
177
  const merged: RecallResult[] = [];
178
178
  const byId = new Map<string, number>();
179
179
  const byContent = new Map<string, number>();
@@ -187,7 +187,7 @@ export class MnemopiSessionState {
187
187
  target.bank === this.scoped.global?.bank && sharedFallbackQuery ? [query, sharedFallbackQuery] : [query];
188
188
  try {
189
189
  for (const recallQuery of queries) {
190
- const results = target.memory.recallEnhanced(recallQuery, this.config.recallLimit, {
190
+ const results = await target.memory.recallEnhanced(recallQuery, this.config.recallLimit, {
191
191
  includeFacts: true,
192
192
  channelId: target.bank,
193
193
  });
@@ -209,7 +209,7 @@ export class MnemopiSessionState {
209
209
  return merged;
210
210
  }
211
211
 
212
- recallResultsScoped(query: string): RecallResult[] {
212
+ recallResultsScoped(query: string): Promise<RecallResult[]> {
213
213
  return this.collectScopedRecallResults(query);
214
214
  }
215
215
 
@@ -242,7 +242,7 @@ export class MnemopiSessionState {
242
242
  }
243
243
 
244
244
  async recallForContext(query: string): Promise<string | undefined> {
245
- const results = this.collectScopedRecallResults(query);
245
+ const results = await this.collectScopedRecallResults(query);
246
246
  if (results.length === 0) return undefined;
247
247
  return formatRecallBlock(results);
248
248
  }
@@ -42,8 +42,9 @@ import {
42
42
  type SetSessionModeResponse,
43
43
  type Usage,
44
44
  } from "@agentclientprotocol/sdk";
45
+ import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
45
46
  import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
46
- import { logger, VERSION } from "@oh-my-pi/pi-utils";
47
+ import { isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils";
47
48
  import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
48
49
  import { Settings } from "../../config/settings";
49
50
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
@@ -56,10 +57,12 @@ import { runExtensionCompact } from "../../extensibility/extensions/compact-hand
56
57
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
57
58
  import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
58
59
  import { loadSlashCommands } from "../../extensibility/slash-commands";
60
+ import { resolveLocalUrlToPath } from "../../internal-urls";
59
61
  import { MCPManager } from "../../mcp/manager";
60
62
  import type { MCPServerConfig } from "../../mcp/types";
61
63
  import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
62
64
  import { theme } from "../../modes/theme/theme";
65
+ import { type PlanApprovalDetails, renameApprovedPlanFile, resolvePlanTitle } from "../../plan-mode/approved-plan";
63
66
  import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
64
67
  import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
65
68
  import {
@@ -69,6 +72,9 @@ import {
69
72
  } from "../../session/session-manager";
70
73
  import { ACP_BUILTIN_SLASH_COMMANDS, executeAcpBuiltinSlashCommand } from "../../slash-commands/acp-builtins";
71
74
  import { AUTO_THINKING, parseConfiguredThinkingLevel } from "../../thinking";
75
+ import { normalizeLocalScheme } from "../../tools/path-utils";
76
+ import { runResolveInvocation } from "../../tools/resolve";
77
+ import { ToolError } from "../../tools/tool-errors";
72
78
  import { createAcpClientBridge } from "./acp-client-bridge";
73
79
  import {
74
80
  buildToolCallStartUpdate,
@@ -80,6 +86,8 @@ import { ACP_TERMINAL_AUTH_FLAG } from "./terminal-auth";
80
86
  const ACP_DEFAULT_MODE_ID = "default";
81
87
  const ACP_PLAN_MODE_ID = "plan";
82
88
  const DEFAULT_PLAN_FILE_URL = "local://PLAN.md";
89
+ const APPROVE_OPTION = "Approve and execute";
90
+ const REFINE_OPTION = "Refine plan";
83
91
  const MODE_CONFIG_ID = "mode";
84
92
  const MODEL_CONFIG_ID = "model";
85
93
  const THINKING_CONFIG_ID = "thinking";
@@ -1229,11 +1237,15 @@ export class AcpAgent implements Agent {
1229
1237
  }
1230
1238
 
1231
1239
  async #pushConfigOptionUpdate(record: ManagedSessionRecord): Promise<void> {
1240
+ await this.#pushConfigOptionUpdateForSession(record.session);
1241
+ }
1242
+
1243
+ async #pushConfigOptionUpdateForSession(session: AgentSession): Promise<void> {
1232
1244
  await this.#connection.sessionUpdate({
1233
- sessionId: record.session.sessionId,
1245
+ sessionId: session.sessionId,
1234
1246
  update: {
1235
1247
  sessionUpdate: "config_option_update",
1236
- configOptions: this.#buildConfigOptions(record.session),
1248
+ configOptions: this.#buildConfigOptions(session),
1237
1249
  },
1238
1250
  });
1239
1251
  }
@@ -1380,11 +1392,164 @@ export class AcpAgent implements Agent {
1380
1392
  workflow: previous?.workflow ?? "parallel",
1381
1393
  reentry: previous !== undefined,
1382
1394
  });
1395
+ // Mirror `InteractiveMode.#enterPlanMode`: register the standing resolve
1396
+ // handler that consumes `resolve { action: "apply" }` from plan-mode.
1397
+ // Without this, the agent's resolve call falls through to the "No
1398
+ // pending action to resolve" error (issue #1869).
1399
+ session.setStandingResolveHandler?.(input => this.#runAcpPlanApprovalResolve(session, input));
1383
1400
  } else {
1401
+ session.setStandingResolveHandler?.(null);
1384
1402
  session.setPlanModeState(undefined);
1385
1403
  }
1386
1404
  }
1387
1405
 
1406
+ /**
1407
+ * Standing resolve handler installed while ACP plan mode is active. The agent
1408
+ * submits the finalized plan via `resolve { action: "apply", extra: { title } }`;
1409
+ * this handler validates the plan file, normalizes the title, asks the ACP
1410
+ * client to confirm (via `unstable_createElicitation` when supported), and on
1411
+ * approval renames the plan to `local://<title>.md`, exits plan mode, and
1412
+ * notifies the client of both mode surfaces so the agent regains full tools.
1413
+ *
1414
+ * Mirrors `InteractiveMode.#runPlanApprovalResolve` for the parts the agent
1415
+ * sees (same `PlanApprovalDetails` shape, same source tool name `plan_approval`).
1416
+ * Clients without form-mode elicitation get an auto-approve so plan mode is
1417
+ * never stranded — the agent always has a way out.
1418
+ */
1419
+ #runAcpPlanApprovalResolve(session: AgentSession, input: unknown): Promise<AgentToolResult<unknown>> {
1420
+ return runResolveInvocation(input as Parameters<typeof runResolveInvocation>[0], {
1421
+ sourceToolName: "plan_approval",
1422
+ label: "Plan ready for approval",
1423
+ apply: async (_reason, extra) => {
1424
+ const state = session.getPlanModeState();
1425
+ if (!state?.enabled) {
1426
+ throw new ToolError("Plan mode is not active.");
1427
+ }
1428
+ const planFilePath = state.planFilePath;
1429
+ const planContent = await this.#readAcpPlanFile(session, planFilePath);
1430
+ if (planContent === null) {
1431
+ throw new ToolError(
1432
+ `Plan file not found at ${planFilePath}. Write the finalized plan to ${planFilePath} before requesting approval.`,
1433
+ );
1434
+ }
1435
+ const normalized = resolvePlanTitle({
1436
+ suppliedTitle: extra?.title,
1437
+ planContent,
1438
+ planFilePath,
1439
+ });
1440
+ const finalPlanFilePath = `local://${normalized.fileName}`;
1441
+ const approved = await this.#requestAcpPlanApprovalChoice(session.sessionId, normalized.title, planContent);
1442
+ const details: PlanApprovalDetails = {
1443
+ planFilePath,
1444
+ finalPlanFilePath,
1445
+ title: normalized.title,
1446
+ planExists: true,
1447
+ };
1448
+ if (!approved) {
1449
+ // User chose to refine: leave plan mode active so the agent
1450
+ // keeps the read-only toolset and can iterate on the plan file.
1451
+ return {
1452
+ content: [
1453
+ {
1454
+ type: "text" as const,
1455
+ text: 'Plan refinement requested. Update the plan file, then call `resolve { action: "apply" }` again when ready.',
1456
+ },
1457
+ ],
1458
+ details,
1459
+ };
1460
+ }
1461
+ // Approved. Rename plan to its titled filename, set the plan
1462
+ // reference so the next turn injects the plan content as
1463
+ // context, then exit plan mode so the agent regains full tools.
1464
+ await renameApprovedPlanFile({
1465
+ planFilePath,
1466
+ finalPlanFilePath,
1467
+ getArtifactsDir: () => session.sessionManager.getArtifactsDir(),
1468
+ getSessionId: () => session.sessionManager.getSessionId(),
1469
+ });
1470
+ session.setPlanReferencePath(finalPlanFilePath);
1471
+ session.setStandingResolveHandler?.(null);
1472
+ session.setPlanModeState(undefined);
1473
+ try {
1474
+ await this.#connection.sessionUpdate({
1475
+ sessionId: session.sessionId,
1476
+ update: this.#buildCurrentModeUpdate(session),
1477
+ });
1478
+ await this.#pushConfigOptionUpdateForSession(session);
1479
+ } catch (error) {
1480
+ logger.warn("Failed to emit mode updates after plan approval", {
1481
+ sessionId: session.sessionId,
1482
+ error,
1483
+ });
1484
+ }
1485
+ return {
1486
+ content: [
1487
+ {
1488
+ type: "text" as const,
1489
+ text: `Plan approved at ${finalPlanFilePath}. Plan mode exited; proceed with the implementation.`,
1490
+ },
1491
+ ],
1492
+ details,
1493
+ };
1494
+ },
1495
+ });
1496
+ }
1497
+
1498
+ #resolveAcpPlanFilePath(session: AgentSession, planFilePath: string): string {
1499
+ if (planFilePath.startsWith("local:")) {
1500
+ const normalized = normalizeLocalScheme(planFilePath);
1501
+ return resolveLocalUrlToPath(normalized, {
1502
+ getArtifactsDir: () => session.sessionManager.getArtifactsDir(),
1503
+ getSessionId: () => session.sessionManager.getSessionId(),
1504
+ });
1505
+ }
1506
+ return path.resolve(session.sessionManager.getCwd(), planFilePath);
1507
+ }
1508
+
1509
+ async #readAcpPlanFile(session: AgentSession, planFilePath: string): Promise<string | null> {
1510
+ const resolvedPath = this.#resolveAcpPlanFilePath(session, planFilePath);
1511
+ try {
1512
+ return await Bun.file(resolvedPath).text();
1513
+ } catch (error) {
1514
+ if (isEnoent(error)) {
1515
+ return null;
1516
+ }
1517
+ throw error;
1518
+ }
1519
+ }
1520
+
1521
+ /**
1522
+ * Ask the ACP client to confirm plan approval. Returns `true` only on an
1523
+ * explicit `APPROVE_OPTION` selection. Refine, dismissal (`undefined`), or
1524
+ * any unrecognized value falls through to refine semantics — the caller
1525
+ * keeps plan mode active and surfaces guidance text to the agent. Clients
1526
+ * without `elicitation.form` support auto-approve because there is no
1527
+ * confirmation surface available; without that, plan mode would strand
1528
+ * the agent (the bug this method exists to fix).
1529
+ */
1530
+ async #requestAcpPlanApprovalChoice(sessionId: string, title: string, planContent: string): Promise<boolean> {
1531
+ const supportsForm = this.#clientCapabilities?.elicitation?.form != null;
1532
+ if (!supportsForm) return true;
1533
+ // Include a short preview of the plan so the user has context in the
1534
+ // dialog. Keep the body bounded — Zed renders elicitation messages
1535
+ // inline and a multi-thousand-line plan blows out the dialog.
1536
+ const previewLines = planContent.split("\n").slice(0, 12).join("\n");
1537
+ const ellipsis = planContent.split("\n").length > 12 ? "\n…" : "";
1538
+ const message = `Approve plan "${title}" and start implementation?\n\n${previewLines}${ellipsis}`;
1539
+ const value = await elicitFromAcpClient(
1540
+ this.#connection,
1541
+ sessionId,
1542
+ "select",
1543
+ message,
1544
+ { type: "string", enum: [APPROVE_OPTION, REFINE_OPTION] },
1545
+ undefined,
1546
+ );
1547
+ // Approve ONLY on the explicit approve selection. Dismissal, cancel,
1548
+ // timeout, or any other non-approve response falls through to refine
1549
+ // semantics so closing the dialog can never grant write access.
1550
+ return value === APPROVE_OPTION;
1551
+ }
1552
+
1388
1553
  #buildModeState(session: AgentSession): SessionModeState {
1389
1554
  return {
1390
1555
  availableModes: this.#getAvailableModes(session),