@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
@@ -9,6 +9,9 @@ import {
9
9
  type ClientCapabilities,
10
10
  type CloseSessionRequest,
11
11
  type CloseSessionResponse,
12
+ type CreateElicitationResponse,
13
+ type ElicitationContentValue,
14
+ type ElicitationPropertySchema,
12
15
  type ForkSessionRequest,
13
16
  type ForkSessionResponse,
14
17
  type InitializeRequest,
@@ -44,8 +47,9 @@ import { logger, VERSION } from "@oh-my-pi/pi-utils";
44
47
  import { disableProvider, enableProvider, reset as resetCapabilities } from "../../capability";
45
48
  import { Settings } from "../../config/settings";
46
49
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../../discovery/helpers";
47
- import type { ExtensionUIContext } from "../../extensibility/extensions";
50
+ import type { ExtensionUIContext, ExtensionUIDialogOptions } from "../../extensibility/extensions";
48
51
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
52
+ import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
49
53
  import { buildSkillPromptMessage, getSkillSlashCommandName } from "../../extensibility/skills";
50
54
  import { loadSlashCommands } from "../../extensibility/slash-commands";
51
55
  import { MCPManager } from "../../mcp/manager";
@@ -53,7 +57,7 @@ import type { MCPServerConfig } from "../../mcp/types";
53
57
  import { loadAllExtensions } from "../../modes/components/extensions/state-manager";
54
58
  import { theme } from "../../modes/theme/theme";
55
59
  import type { AgentSession, AgentSessionEvent } from "../../session/agent-session";
56
- import { SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
60
+ import { isSilentAbort, SKILL_PROMPT_MESSAGE_TYPE } from "../../session/messages";
57
61
  import {
58
62
  SessionManager,
59
63
  type SessionInfo as StoredSessionInfo,
@@ -73,6 +77,15 @@ const MODEL_CONFIG_ID = "model";
73
77
  const THINKING_CONFIG_ID = "thinking";
74
78
  const THINKING_OFF = "off";
75
79
  const SESSION_PAGE_SIZE = 50;
80
+ /**
81
+ * Delay between `session/new` (or `session/load` / `session/resume` /
82
+ * `unstable_session/fork`) returning and the agent firing the first
83
+ * notifications against the new session id. Mitigates Zed's
84
+ * `Received session notification for unknown session` race — see
85
+ * `#scheduleBootstrapUpdates`. Exported so the ACP test harness can
86
+ * wait past this guard without hard-coding the literal.
87
+ */
88
+ export const ACP_BOOTSTRAP_RACE_GUARD_MS = 50;
76
89
 
77
90
  type AgentImageContent = {
78
91
  type: "image";
@@ -80,6 +93,11 @@ type AgentImageContent = {
80
93
  mimeType: string;
81
94
  };
82
95
 
96
+ type PromptQueueState = {
97
+ promise: Promise<void>;
98
+ release: (() => void) | undefined;
99
+ };
100
+
83
101
  type PromptTurnState = {
84
102
  userMessageId: string;
85
103
  cancelRequested: boolean;
@@ -88,15 +106,20 @@ type PromptTurnState = {
88
106
  unsubscribe: (() => void) | undefined;
89
107
  resolve: (value: PromptResponse) => void;
90
108
  reject: (reason?: unknown) => void;
109
+ promise: Promise<PromptResponse>;
91
110
  };
92
111
 
93
112
  type ManagedSessionRecord = {
94
113
  session: AgentSession;
95
114
  mcpManager: MCPManager | undefined;
96
115
  promptTurn: PromptTurnState | undefined;
116
+ promptQueue: PromptQueueState;
97
117
  liveMessageId: string | undefined;
98
118
  liveMessageProgress: { textEmitted: boolean; thoughtEmitted: boolean } | undefined;
99
119
  extensionsConfigured: boolean;
120
+ // Installed inside `#scheduleBootstrapUpdates` (post-race-guard); released
121
+ // in `#disposeSessionRecord`. Lives independent of any prompt turn.
122
+ lifetimeUnsubscribe: (() => void) | undefined;
100
123
  };
101
124
 
102
125
  type ReplayableMessage = {
@@ -126,35 +149,185 @@ type MCPSourceMap = {
126
149
 
127
150
  type CreateAcpSession = (cwd: string) => Promise<AgentSession>;
128
151
 
129
- const acpExtensionUiContext: ExtensionUIContext = {
130
- select: async () => undefined,
131
- confirm: async () => false,
132
- input: async () => undefined,
133
- notify: (message, type) => {
134
- logger.debug("ACP extension notification", { message, type });
135
- },
136
- onTerminalInput: () => () => {},
137
- setStatus: () => {},
138
- setWorkingMessage: () => {},
139
- setWidget: () => {},
140
- setFooter: () => {},
141
- setHeader: () => {},
142
- setTitle: () => {},
143
- custom: async () => undefined as never,
144
- pasteToEditor: () => {},
145
- setEditorText: () => {},
146
- getEditorText: () => "",
147
- editor: async () => undefined,
148
- setEditorComponent: () => {},
149
- get theme() {
150
- return theme;
151
- },
152
- getAllThemes: async () => [],
153
- getTheme: async () => undefined,
154
- setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }),
155
- getToolsExpanded: () => false,
156
- setToolsExpanded: () => {},
157
- };
152
+ /**
153
+ * Bridge a single ExtensionUIContext call to the ACP `unstable_createElicitation`
154
+ * surface. Skills/extensions ask for one value at a time (a chosen option, a
155
+ * confirmation, a piece of text), so every elicitation here uses a one-property
156
+ * `value` schema; the caller narrows the resulting `ElicitationContentValue`
157
+ * back to its concrete primitive type.
158
+ *
159
+ * `dialogOptions.signal` short-circuits the elicitation if it is already
160
+ * aborted and races the in-flight request against the abort event. The SDK
161
+ * exposes no `cancel_elicitation` surface for form-mode elicitations
162
+ * (`unstable_completeElicitation` is URL-mode only), so the ACP request itself
163
+ * keeps running on the client side until the user dismisses it — but
164
+ * resolving the local promise unblocks the caller (matches the RPC mode
165
+ * pattern in `requestRpcEditor`). The abort listener is removed once the
166
+ * elicitation settles so that callers which reuse the same signal across many
167
+ * elicitations (e.g. `ask` multi-select loops) don't accumulate listeners and
168
+ * trip Node's `MaxListeners` warning.
169
+ *
170
+ * `dialogOptions.timeout` mirrors `RpcExtensionUIContext.#createDialogPromise`:
171
+ * when the timer fires before the client responds, `onTimeout` is invoked and
172
+ * the caller's promise resolves to the stub fallback. Late SDK responses that
173
+ * arrive after abort/timeout — both rejections and successful `accept`s —
174
+ * are dropped silently (no `logger.warn`) to keep operator logs clean.
175
+ */
176
+ async function elicitFromAcpClient(
177
+ connection: AgentSideConnection,
178
+ sessionId: string,
179
+ method: "select" | "confirm" | "input",
180
+ message: string,
181
+ property: ElicitationPropertySchema,
182
+ dialogOptions: ExtensionUIDialogOptions | undefined,
183
+ ): Promise<ElicitationContentValue | undefined> {
184
+ const signal = dialogOptions?.signal;
185
+ if (signal?.aborted) {
186
+ return undefined;
187
+ }
188
+ const { promise, resolve } = Promise.withResolvers<CreateElicitationResponse | undefined>();
189
+ let settled = false;
190
+ let timeoutId: NodeJS.Timeout | undefined;
191
+ const finish = (value: CreateElicitationResponse | undefined) => {
192
+ if (settled) return;
193
+ settled = true;
194
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
195
+ signal?.removeEventListener("abort", onAbort);
196
+ resolve(value);
197
+ };
198
+ const onAbort = () => finish(undefined);
199
+ signal?.addEventListener("abort", onAbort, { once: true });
200
+ if (dialogOptions?.timeout !== undefined) {
201
+ timeoutId = setTimeout(() => {
202
+ if (settled) return;
203
+ try {
204
+ dialogOptions.onTimeout?.();
205
+ } catch (error) {
206
+ // A throwing `onTimeout` must not leave the elicitation promise
207
+ // pending — settle it via `finish` below regardless.
208
+ logger.warn("ACP elicitation onTimeout threw", { sessionId, method, error });
209
+ }
210
+ finish(undefined);
211
+ }, dialogOptions.timeout);
212
+ // A long pending timeout alone shouldn't keep the event loop alive when
213
+ // the rest of the agent has shut down — matches `job-manager.ts` /
214
+ // `executor.ts` timer hygiene. Connection + session lifetimes keep the
215
+ // loop alive on the happy path.
216
+ timeoutId.unref();
217
+ }
218
+ connection
219
+ .unstable_createElicitation({
220
+ mode: "form",
221
+ sessionId,
222
+ message,
223
+ requestedSchema: {
224
+ type: "object",
225
+ properties: { value: property },
226
+ required: ["value"],
227
+ },
228
+ })
229
+ .then(finish, error => {
230
+ // Caller may already have moved on via abort/timeout; suppress noise.
231
+ if (settled) return;
232
+ logger.warn("ACP elicitation failed", { sessionId, method, error });
233
+ finish(undefined);
234
+ });
235
+ const response = await promise;
236
+ if (!response || response.action !== "accept" || !response.content) {
237
+ return undefined;
238
+ }
239
+ return response.content.value;
240
+ }
241
+
242
+ /**
243
+ * Build an {@link ExtensionUIContext} that translates skill/extension UI
244
+ * requests into ACP elicitations against `connection` for the session
245
+ * returned by `getSessionId()`. The id is read lazily at each elicitation
246
+ * because `AgentSession.sessionId` is a getter over `sessionManager` state
247
+ * that mutates when an extension command calls `ctx.newSession` /
248
+ * `ctx.switchSession` — snapshotting it once at factory time would route
249
+ * later elicitations to the pre-switch id. Live reads keep the bridge
250
+ * symmetric with every other `sessionUpdate` call in this file
251
+ * (`record.session.sessionId` is always evaluated at emit time).
252
+ *
253
+ * The non-elicitation surface (custom components, editor, theming,
254
+ * terminal input) remains stubbed — ACP clients render those themselves
255
+ * or not at all. Capability gating respects the client's `initialize`
256
+ * advertisement.
257
+ */
258
+ export function createAcpExtensionUiContext(
259
+ connection: AgentSideConnection,
260
+ getSessionId: () => string,
261
+ clientCapabilities: ClientCapabilities | undefined,
262
+ ): ExtensionUIContext {
263
+ const supportsForm = clientCapabilities?.elicitation?.form != null;
264
+ return {
265
+ select: async (title, options, dialogOptions) => {
266
+ if (!supportsForm) return undefined;
267
+ const value = await elicitFromAcpClient(
268
+ connection,
269
+ getSessionId(),
270
+ "select",
271
+ title,
272
+ { type: "string", enum: options },
273
+ dialogOptions,
274
+ );
275
+ return typeof value === "string" ? value : undefined;
276
+ },
277
+ confirm: async (title, message, dialogOptions) => {
278
+ if (!supportsForm) return false;
279
+ const value = await elicitFromAcpClient(
280
+ connection,
281
+ getSessionId(),
282
+ "confirm",
283
+ message.trim().length > 0 ? `${title}\n\n${message}` : title,
284
+ { type: "boolean" },
285
+ dialogOptions,
286
+ );
287
+ return typeof value === "boolean" ? value : false;
288
+ },
289
+ input: async (title, placeholder, dialogOptions) => {
290
+ if (!supportsForm) return undefined;
291
+ const value = await elicitFromAcpClient(
292
+ connection,
293
+ getSessionId(),
294
+ "input",
295
+ title,
296
+ // ACP's `StringPropertySchema` has no `placeholder` field, so we
297
+ // surface the placeholder text as `description` — the closest
298
+ // semantic field a client can render alongside the input.
299
+ // Empty / whitespace-only placeholders are treated as absent.
300
+ { type: "string", ...(placeholder?.trim() ? { description: placeholder } : {}) },
301
+ dialogOptions,
302
+ );
303
+ return typeof value === "string" ? value : undefined;
304
+ },
305
+ notify: (message, type) => {
306
+ logger.debug("ACP extension notification", { message, type });
307
+ },
308
+ onTerminalInput: () => () => {},
309
+ setStatus: () => {},
310
+ setWorkingMessage: () => {},
311
+ setWidget: () => {},
312
+ setFooter: () => {},
313
+ setHeader: () => {},
314
+ setTitle: () => {},
315
+ custom: async () => undefined as never,
316
+ pasteToEditor: () => {},
317
+ setEditorText: () => {},
318
+ getEditorText: () => "",
319
+ editor: async () => undefined,
320
+ setEditorComponent: () => {},
321
+ get theme() {
322
+ return theme;
323
+ },
324
+ getAllThemes: async () => [],
325
+ getTheme: async () => undefined,
326
+ setTheme: async () => ({ success: false, error: "Theme changes are unavailable in ACP mode" }),
327
+ getToolsExpanded: () => false,
328
+ setToolsExpanded: () => {},
329
+ };
330
+ }
158
331
 
159
332
  export class AcpAgent implements Agent {
160
333
  #connection: AgentSideConnection;
@@ -314,13 +487,7 @@ export class AcpAgent implements Agent {
314
487
  sessionId: record.session.sessionId,
315
488
  update: this.#buildCurrentModeUpdate(record.session),
316
489
  });
317
- await this.#connection.sessionUpdate({
318
- sessionId: record.session.sessionId,
319
- update: {
320
- sessionUpdate: "config_option_update",
321
- configOptions: this.#buildConfigOptions(record.session),
322
- },
323
- });
490
+ await this.#pushConfigOptionUpdate(record);
324
491
  return {};
325
492
  }
326
493
 
@@ -354,57 +521,78 @@ export class AcpAgent implements Agent {
354
521
  });
355
522
  }
356
523
 
357
- const configOptions = this.#buildConfigOptions(record.session);
358
- await this.#connection.sessionUpdate({
359
- sessionId: record.session.sessionId,
360
- update: {
361
- sessionUpdate: "config_option_update",
362
- configOptions,
363
- },
364
- });
365
- return { configOptions };
524
+ // For `thinking` the lifetime subscription pushes post-bootstrap; only
525
+ // push here when it's not yet installed so pre-bootstrap callers still
526
+ // see the change without a post-bootstrap duplicate.
527
+ const thinkingHandledBySubscription =
528
+ params.configId === THINKING_CONFIG_ID && record.lifetimeUnsubscribe !== undefined;
529
+ if (!thinkingHandledBySubscription) {
530
+ await this.#pushConfigOptionUpdate(record);
531
+ }
532
+ return { configOptions: this.#buildConfigOptions(record.session) };
366
533
  }
367
534
 
368
535
  async unstable_setSessionModel(params: SetSessionModelRequest): Promise<SetSessionModelResponse> {
369
536
  const record = this.#getSessionRecord(params.sessionId);
370
537
  await this.#setModelById(record.session, params.modelId);
371
- await this.#connection.sessionUpdate({
372
- sessionId: record.session.sessionId,
373
- update: {
374
- sessionUpdate: "config_option_update",
375
- configOptions: this.#buildConfigOptions(record.session),
376
- },
377
- });
538
+ await this.#pushConfigOptionUpdate(record);
378
539
  return {};
379
540
  }
380
541
 
381
542
  async prompt(params: PromptRequest): Promise<PromptResponse> {
382
543
  const record = this.#getSessionRecord(params.sessionId);
383
- if (record.promptTurn && !record.promptTurn.settled) {
544
+ const activeTurn = record.promptTurn;
545
+ if (activeTurn && !activeTurn.settled && record.session.isStreaming) {
384
546
  throw new Error("ACP prompt already in progress for this session");
385
547
  }
548
+ return await this.#queuePrompt(record, async () => {
549
+ const queuedTurn = record.promptTurn;
550
+ if (queuedTurn && !queuedTurn.settled) {
551
+ await queuedTurn.promise.catch(() => undefined);
552
+ }
386
553
 
387
- const converted = this.#convertPromptBlocks(params.prompt);
388
- const pendingPrompt = Promise.withResolvers<PromptResponse>();
389
- record.promptTurn = {
390
- userMessageId: params.messageId ?? crypto.randomUUID(),
391
- cancelRequested: false,
392
- settled: false,
393
- usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
394
- unsubscribe: undefined,
395
- resolve: pendingPrompt.resolve,
396
- reject: pendingPrompt.reject,
397
- };
554
+ const converted = this.#convertPromptBlocks(params.prompt);
555
+ const pendingPrompt = Promise.withResolvers<PromptResponse>();
556
+ record.promptTurn = {
557
+ userMessageId: params.messageId ?? crypto.randomUUID(),
558
+ cancelRequested: false,
559
+ settled: false,
560
+ usageBaseline: this.#cloneUsageStatistics(record.session.sessionManager.getUsageStatistics()),
561
+ unsubscribe: undefined,
562
+ resolve: pendingPrompt.resolve,
563
+ reject: pendingPrompt.reject,
564
+ promise: pendingPrompt.promise,
565
+ };
398
566
 
399
- record.promptTurn.unsubscribe = record.session.subscribe(event => {
400
- void this.#handlePromptEvent(record, event);
401
- });
567
+ record.promptTurn.unsubscribe = record.session.subscribe(event => {
568
+ void this.#handlePromptEvent(record, event);
569
+ });
402
570
 
403
- this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => {
404
- this.#finishPrompt(record, undefined, error);
571
+ this.#runPromptOrCommand(record, converted.text, converted.images).catch((error: unknown) => {
572
+ this.#finishPrompt(record, undefined, error);
573
+ });
574
+
575
+ return await pendingPrompt.promise;
405
576
  });
577
+ }
406
578
 
407
- return await pendingPrompt.promise;
579
+ async #queuePrompt(record: ManagedSessionRecord, run: () => Promise<PromptResponse>): Promise<PromptResponse> {
580
+ const nextQueue = Promise.withResolvers<void>();
581
+ const releaseQueue = nextQueue.resolve;
582
+ const previousQueue = record.promptQueue;
583
+ record.promptQueue = {
584
+ promise: nextQueue.promise,
585
+ release: releaseQueue,
586
+ };
587
+ await previousQueue.promise;
588
+ try {
589
+ return await run();
590
+ } finally {
591
+ releaseQueue();
592
+ if (record.promptQueue.release === releaseQueue) {
593
+ record.promptQueue.release = undefined;
594
+ }
595
+ }
408
596
  }
409
597
 
410
598
  async #runPromptOrCommand(record: ManagedSessionRecord, text: string, images: AgentImageContent[]): Promise<void> {
@@ -432,13 +620,7 @@ export class AcpAgent implements Agent {
432
620
  });
433
621
  },
434
622
  notifyConfigChanged: async () => {
435
- await this.#connection.sessionUpdate({
436
- sessionId: record.session.sessionId,
437
- update: {
438
- sessionUpdate: "config_option_update",
439
- configOptions: this.#buildConfigOptions(record.session),
440
- },
441
- });
623
+ await this.#pushConfigOptionUpdate(record);
442
624
  },
443
625
  });
444
626
  if (builtinResult !== false) {
@@ -688,6 +870,8 @@ export class AcpAgent implements Agent {
688
870
  async #registerPreparedSession(session: AgentSession, mcpServers: McpServer[]): Promise<ManagedSessionRecord> {
689
871
  const record = this.#createManagedSessionRecord(session);
690
872
  session.setClientBridge(createAcpClientBridge(this.#connection, session.sessionId, this.#clientCapabilities));
873
+ // `record.lifetimeUnsubscribe` is installed in `#scheduleBootstrapUpdates`
874
+ // so it shares the bootstrap race guard — see that comment for why.
691
875
  try {
692
876
  await this.#configureExtensions(record);
693
877
  await this.#configureMcpServers(record, mcpServers);
@@ -704,12 +888,28 @@ export class AcpAgent implements Agent {
704
888
  session,
705
889
  mcpManager: undefined,
706
890
  promptTurn: undefined,
891
+ promptQueue: { promise: Promise.resolve(), release: undefined },
707
892
  liveMessageId: undefined,
708
893
  liveMessageProgress: undefined,
709
894
  extensionsConfigured: false,
895
+ lifetimeUnsubscribe: undefined,
710
896
  };
711
897
  }
712
898
 
899
+ async #handleLifetimeEvent(record: ManagedSessionRecord, event: AgentSessionEvent): Promise<void> {
900
+ if (event.type !== "thinking_level_changed") {
901
+ return;
902
+ }
903
+ try {
904
+ await this.#pushConfigOptionUpdate(record);
905
+ } catch (error) {
906
+ logger.warn("Failed to push thinking-level config_option_update", {
907
+ sessionId: record.session.sessionId,
908
+ error,
909
+ });
910
+ }
911
+ }
912
+
713
913
  #getSessionRecord(sessionId: string): ManagedSessionRecord {
714
914
  const record = this.#sessions.get(sessionId);
715
915
  if (!record) {
@@ -766,6 +966,7 @@ export class AcpAgent implements Agent {
766
966
 
767
967
  if (event.type === "agent_end") {
768
968
  await this.#emitEndOfTurnUpdates(record);
969
+ await record.session.waitForIdle();
769
970
  this.#finishPrompt(record, {
770
971
  stopReason: this.#resolveStopReason(event, promptTurn.cancelRequested),
771
972
  usage: this.#buildTurnUsage(promptTurn.usageBaseline, record.session.sessionManager.getUsageStatistics()),
@@ -912,6 +1113,16 @@ export class AcpAgent implements Agent {
912
1113
  };
913
1114
  }
914
1115
 
1116
+ async #pushConfigOptionUpdate(record: ManagedSessionRecord): Promise<void> {
1117
+ await this.#connection.sessionUpdate({
1118
+ sessionId: record.session.sessionId,
1119
+ update: {
1120
+ sessionUpdate: "config_option_update",
1121
+ configOptions: this.#buildConfigOptions(record.session),
1122
+ },
1123
+ });
1124
+ }
1125
+
915
1126
  #buildConfigOptions(session: AgentSession): SessionConfigOption[] {
916
1127
  const currentModeId = this.#getCurrentModeId(session);
917
1128
  const modeOptions = this.#getAvailableModes(session).map(mode => ({
@@ -1124,18 +1335,25 @@ export class AcpAgent implements Agent {
1124
1335
  }
1125
1336
 
1126
1337
  #scheduleBootstrapUpdates(sessionId: string): void {
1127
- // Delay the bootstrap so the client has time to handle the `session/new`
1128
- // (or `session/load` / `session/resume`) RPC response and register the
1129
- // new sessionId before we start firing notifications against it. Zed's
1130
- // agent-client-protocol reader dispatches responses and notifications
1131
- // to different async tasks; sending the first `available_commands_update`
1132
- // from `setTimeout(0)` reliably loses the race against the response
1133
- // handler and Zed logs `Received session notification for unknown
1134
- // session` then drops the update leaving the slash-command palette
1135
- // empty (#1015 follow-up; see zed-industries/zed#55965 for the same
1136
- // race biting other ACP agents). 50ms is invisible to the operator and
1137
- // large enough that the response future has scheduled before our timer
1138
- // fires on stdio-only transports.
1338
+ // Defer first notifications until the response has reached the client.
1339
+ // Zed's agent-client-protocol reader dispatches responses and
1340
+ // notifications to different async tasks; sending the first
1341
+ // `available_commands_update` from `setTimeout(0)` reliably loses the
1342
+ // race against the response handler and Zed logs `Received session
1343
+ // notification for unknown session` then drops the update leaving
1344
+ // the slash-command palette empty (#1015 follow-up; see
1345
+ // zed-industries/zed#55965 for the same race biting other ACP agents).
1346
+ // `ACP_BOOTSTRAP_RACE_GUARD_MS` is invisible to the operator and large
1347
+ // enough that the response future has scheduled before our timer fires
1348
+ // on stdio-only transports.
1349
+ //
1350
+ // The session-lifetime subscription is installed inside the same timer
1351
+ // so it shares this guard — without it, an extension's `session_start`
1352
+ // handler (or any async work it schedules) calling `setThinkingLevel`
1353
+ // would push a `config_option_update` for a session id the client
1354
+ // hasn't been told about yet. The pre-bootstrap thinking level is
1355
+ // reported in the response's `configOptions`, so deferring the
1356
+ // notification loses no state.
1139
1357
  setTimeout(() => {
1140
1358
  if (this.#connection.signal.aborted) {
1141
1359
  return;
@@ -1144,8 +1362,13 @@ export class AcpAgent implements Agent {
1144
1362
  if (!record) {
1145
1363
  return;
1146
1364
  }
1365
+ if (!record.lifetimeUnsubscribe) {
1366
+ record.lifetimeUnsubscribe = record.session.subscribe(event => {
1367
+ void this.#handleLifetimeEvent(record, event);
1368
+ });
1369
+ }
1147
1370
  void this.#emitBootstrapUpdates(sessionId, record);
1148
- }, 50);
1371
+ }, ACP_BOOTSTRAP_RACE_GUARD_MS);
1149
1372
  }
1150
1373
 
1151
1374
  async #emitBootstrapUpdates(sessionId: string, record: ManagedSessionRecord): Promise<void> {
@@ -1393,7 +1616,7 @@ export class AcpAgent implements Agent {
1393
1616
  }
1394
1617
  }
1395
1618
  }
1396
- if (notifications.length === 0 && message.errorMessage) {
1619
+ if (notifications.length === 0 && message.errorMessage && !isSilentAbort(message.errorMessage)) {
1397
1620
  notifications.push({
1398
1621
  sessionId,
1399
1622
  update: {
@@ -1519,7 +1742,7 @@ export class AcpAgent implements Agent {
1519
1742
  getActiveTools: () => record.session.getActiveToolNames(),
1520
1743
  getAllTools: () => record.session.getAllToolNames(),
1521
1744
  setActiveTools: toolNames => record.session.setActiveToolsByName(toolNames),
1522
- getCommands: () => [],
1745
+ getCommands: () => getSessionSlashCommands(record.session),
1523
1746
  setModel: async model => {
1524
1747
  const apiKey = await record.session.modelRegistry.getApiKey(model);
1525
1748
  if (!apiKey) {
@@ -1574,7 +1797,15 @@ export class AcpAgent implements Agent {
1574
1797
  },
1575
1798
  compact: instructionsOrOptions => runExtensionCompact(record.session, instructionsOrOptions),
1576
1799
  },
1577
- acpExtensionUiContext,
1800
+ // Per-session getter: `record.session.sessionId` reads through to
1801
+ // `sessionManager.getSessionId()` (it's a getter, not a field), so an
1802
+ // extension command that calls `ctx.newSession` / `ctx.switchSession`
1803
+ // — both exposed in the block just above — mutates the underlying id
1804
+ // mid-flight. Reading lazily on each elicitation matches every other
1805
+ // `sessionUpdate` call in this file. Hoisting the factory to an
1806
+ // `AcpAgent` field would still be wrong because it would also lose
1807
+ // the per-`record` binding.
1808
+ createAcpExtensionUiContext(this.#connection, () => record.session.sessionId, this.#clientCapabilities),
1578
1809
  );
1579
1810
  await extensionRunner.emit({ type: "session_start" });
1580
1811
  record.extensionsConfigured = true;
@@ -1674,6 +1905,7 @@ export class AcpAgent implements Agent {
1674
1905
  }
1675
1906
 
1676
1907
  async #disposeSessionRecord(record: ManagedSessionRecord): Promise<void> {
1908
+ record.lifetimeUnsubscribe?.();
1677
1909
  if (record.mcpManager) {
1678
1910
  try {
1679
1911
  await record.mcpManager.disconnectAll();
@@ -3,8 +3,8 @@ import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } fro
3
3
  import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
5
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
6
+ import { isSilentAbort } from "../../session/messages";
6
7
  import { resolveImageOptions } from "../../tools/render-utils";
7
- import { convertToPng } from "../../utils/image-convert";
8
8
 
9
9
  /**
10
10
  * Component that renders a complete assistant message
@@ -76,14 +76,15 @@ export class AssistantMessageComponent extends Container {
76
76
  const key = `${toolCallId}:${index}`;
77
77
  if (this.#convertedKittyImages.has(key) || this.#kittyConversionsInFlight.has(key)) continue;
78
78
  this.#kittyConversionsInFlight.add(key);
79
- convertToPng(image.data, image.mimeType)
80
- .then(converted => {
79
+ new Bun.Image(Buffer.from(image.data, "base64"))
80
+ .png()
81
+ .toBase64()
82
+ .then(data => {
81
83
  this.#kittyConversionsInFlight.delete(key);
82
- if (!converted) return;
83
84
  this.#convertedKittyImages.set(key, {
84
85
  type: "image",
85
- data: converted.data,
86
- mimeType: converted.mimeType,
86
+ data,
87
+ mimeType: "image/png",
87
88
  });
88
89
  if (this.#lastMessage) {
89
90
  this.updateContent(this.#lastMessage);
@@ -184,7 +185,7 @@ export class AssistantMessageComponent extends Container {
184
185
  // But only if there are no tool calls (tool execution components will show the error)
185
186
  const hasToolCalls = message.content.some(c => c.type === "toolCall");
186
187
  if (!hasToolCalls) {
187
- if (message.stopReason === "aborted") {
188
+ if (message.stopReason === "aborted" && !isSilentAbort(message.errorMessage)) {
188
189
  const abortMessage =
189
190
  message.errorMessage && message.errorMessage !== "Request was aborted"
190
191
  ? message.errorMessage
@@ -201,7 +202,12 @@ export class AssistantMessageComponent extends Container {
201
202
  this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
202
203
  }
203
204
  }
204
- if (message.errorMessage && message.stopReason !== "aborted" && message.stopReason !== "error") {
205
+ if (
206
+ message.errorMessage &&
207
+ !isSilentAbort(message.errorMessage) &&
208
+ message.stopReason !== "aborted" &&
209
+ message.stopReason !== "error"
210
+ ) {
205
211
  this.#contentContainer.addChild(new Spacer(1));
206
212
  this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${message.errorMessage}`), 1, 0));
207
213
  }