@oh-my-pi/pi-coding-agent 17.1.5 → 17.1.7

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 (244) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-5k4dq4g6.md} +54 -0
  3. package/dist/cli.js +3342 -3274
  4. package/dist/types/capability/index.d.ts +1 -1
  5. package/dist/types/capability/types.d.ts +23 -1
  6. package/dist/types/config/settings-schema.d.ts +39 -2
  7. package/dist/types/cursor.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  9. package/dist/types/extensibility/shared-events.d.ts +18 -1
  10. package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
  11. package/dist/types/internal-urls/parse.d.ts +12 -0
  12. package/dist/types/internal-urls/router.d.ts +6 -0
  13. package/dist/types/internal-urls/types.d.ts +6 -0
  14. package/dist/types/lsp/config.d.ts +1 -0
  15. package/dist/types/lsp/types.d.ts +2 -0
  16. package/dist/types/mcp/manager.d.ts +5 -0
  17. package/dist/types/mcp/tool-bridge.d.ts +13 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  20. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  21. package/dist/types/sdk.d.ts +3 -1
  22. package/dist/types/session/agent-session-types.d.ts +8 -6
  23. package/dist/types/session/agent-session.d.ts +28 -1
  24. package/dist/types/session/model-controls.d.ts +4 -1
  25. package/dist/types/session/session-advisors.d.ts +7 -1
  26. package/dist/types/session/session-tools.d.ts +44 -6
  27. package/dist/types/session/streaming-output.d.ts +7 -2
  28. package/dist/types/session/tool-choice-queue.d.ts +6 -4
  29. package/dist/types/session/turn-recovery.d.ts +5 -3
  30. package/dist/types/task/index.d.ts +1 -1
  31. package/dist/types/task/types.d.ts +3 -11
  32. package/dist/types/thinking.d.ts +21 -2
  33. package/dist/types/tools/index.d.ts +8 -3
  34. package/dist/types/tools/output-meta.d.ts +5 -0
  35. package/dist/types/tools/read.d.ts +9 -1
  36. package/dist/types/tools/xdev.d.ts +53 -67
  37. package/dist/types/utils/cpuprofile.d.ts +51 -0
  38. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  39. package/dist/types/utils/profile-tree.d.ts +47 -0
  40. package/dist/types/utils/sample-profile.d.ts +67 -0
  41. package/dist/types/utils/title-generator.d.ts +17 -16
  42. package/package.json +12 -12
  43. package/src/capability/index.ts +43 -12
  44. package/src/capability/mcp.ts +21 -0
  45. package/src/capability/types.ts +20 -1
  46. package/src/cli/read-cli.ts +44 -2
  47. package/src/config/settings-schema.ts +42 -2
  48. package/src/config/settings.ts +35 -0
  49. package/src/cursor.ts +4 -3
  50. package/src/discovery/builtin-rules/index.ts +2 -0
  51. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  52. package/src/eval/py/runner.py +16 -2
  53. package/src/extensibility/extensions/runner.ts +117 -5
  54. package/src/extensibility/extensions/types.ts +0 -1
  55. package/src/extensibility/extensions/wrapper.ts +74 -42
  56. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  57. package/src/extensibility/hooks/types.ts +0 -1
  58. package/src/extensibility/shared-events.ts +18 -1
  59. package/src/internal-urls/mcp-protocol.ts +17 -3
  60. package/src/internal-urls/parse.ts +31 -0
  61. package/src/internal-urls/router.ts +24 -4
  62. package/src/internal-urls/types.ts +6 -0
  63. package/src/live/transport.ts +2 -2
  64. package/src/lsp/client.ts +2 -2
  65. package/src/lsp/config.ts +4 -0
  66. package/src/lsp/types.ts +2 -0
  67. package/src/mcp/config.ts +26 -14
  68. package/src/mcp/manager.ts +26 -9
  69. package/src/mcp/tool-bridge.ts +52 -1
  70. package/src/memories/index.ts +25 -6
  71. package/src/modes/components/custom-editor.ts +39 -16
  72. package/src/modes/components/status-line/segments.ts +3 -1
  73. package/src/modes/components/tips.txt +2 -1
  74. package/src/modes/components/tool-execution.ts +8 -7
  75. package/src/modes/controllers/command-controller.ts +10 -10
  76. package/src/modes/controllers/extension-ui-controller.ts +7 -7
  77. package/src/modes/controllers/selector-controller.ts +7 -2
  78. package/src/modes/rpc/rpc-mode.ts +60 -47
  79. package/src/prompts/steering/user-interjection.md +2 -5
  80. package/src/prompts/tools/task.md +4 -2
  81. package/src/sdk.ts +64 -58
  82. package/src/session/agent-session-types.ts +8 -5
  83. package/src/session/agent-session.ts +144 -11
  84. package/src/session/model-controls.ts +48 -12
  85. package/src/session/session-advisors.ts +30 -14
  86. package/src/session/session-listing.ts +66 -4
  87. package/src/session/session-tools.ts +162 -54
  88. package/src/session/streaming-output.ts +18 -6
  89. package/src/session/tool-choice-queue.ts +19 -4
  90. package/src/session/turn-recovery.ts +25 -6
  91. package/src/slash-commands/builtin-registry.ts +69 -0
  92. package/src/task/executor.ts +11 -3
  93. package/src/task/index.ts +43 -32
  94. package/src/task/types.ts +12 -17
  95. package/src/thinking.ts +68 -5
  96. package/src/tools/bash.ts +16 -9
  97. package/src/tools/index.ts +36 -16
  98. package/src/tools/output-meta.ts +20 -0
  99. package/src/tools/read.ts +89 -15
  100. package/src/tools/write.ts +16 -7
  101. package/src/tools/xdev.ts +198 -210
  102. package/src/utils/cpuprofile.ts +235 -0
  103. package/src/utils/inspect-image-mode.ts +39 -0
  104. package/src/utils/profile-tree.ts +111 -0
  105. package/src/utils/sample-profile.ts +437 -0
  106. package/src/utils/title-generator.ts +88 -34
  107. package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
  108. package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
  109. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
  110. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
  111. package/dist/types/cli/update-cli.test.d.ts +0 -1
  112. package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
  113. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
  114. package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
  115. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
  116. package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
  117. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
  118. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
  119. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
  120. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
  121. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
  122. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
  123. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
  124. package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
  125. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
  126. package/dist/types/hindsight/client.test.d.ts +0 -1
  127. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
  128. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
  129. package/dist/types/launch/broker-list-order.test.d.ts +0 -1
  130. package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
  131. package/dist/types/launch/protocol.test.d.ts +0 -1
  132. package/dist/types/launch/spawn-options.test.d.ts +0 -1
  133. package/dist/types/launch/terminal-output.test.d.ts +0 -1
  134. package/dist/types/live/protocol.test.d.ts +0 -1
  135. package/dist/types/mcp/config-writer.test.d.ts +0 -1
  136. package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
  137. package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
  138. package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
  139. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
  140. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
  141. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
  142. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
  143. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
  144. package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
  145. package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
  146. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
  147. package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
  148. package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
  149. package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
  150. package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
  151. package/dist/types/modes/print-mode.test.d.ts +0 -1
  152. package/dist/types/modes/session-teardown.test.d.ts +0 -1
  153. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
  154. package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
  155. package/dist/types/modes/warp-events.test.d.ts +0 -1
  156. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
  157. package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
  158. package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
  159. package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
  160. package/dist/types/session/blob-store.test.d.ts +0 -1
  161. package/dist/types/session/messages.test.d.ts +0 -1
  162. package/dist/types/session/session-context.test.d.ts +0 -1
  163. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
  164. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
  165. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
  166. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
  167. package/dist/types/system-prompt.test.d.ts +0 -1
  168. package/dist/types/task/render.test.d.ts +0 -1
  169. package/dist/types/task/spawn-policy.test.d.ts +0 -1
  170. package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
  171. package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
  172. package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
  173. package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
  174. package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
  175. package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
  176. package/src/advisor/__tests__/advisor.test.ts +0 -4889
  177. package/src/advisor/__tests__/config.test.ts +0 -349
  178. package/src/advisor/__tests__/emission-guard.test.ts +0 -147
  179. package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
  180. package/src/cli/update-cli.test.ts +0 -28
  181. package/src/config/__tests__/model-registry.test.ts +0 -182
  182. package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
  183. package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
  184. package/src/eval/__tests__/budget-bridge.test.ts +0 -80
  185. package/src/eval/__tests__/completion-bridge.test.ts +0 -412
  186. package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
  187. package/src/eval/__tests__/idle-timeout.test.ts +0 -80
  188. package/src/eval/__tests__/js-context-manager.test.ts +0 -456
  189. package/src/eval/__tests__/julia-prelude.test.ts +0 -66
  190. package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
  191. package/src/eval/__tests__/prelude-agent.test.ts +0 -156
  192. package/src/eval/__tests__/process-entry-import.test.ts +0 -137
  193. package/src/eval/py/__tests__/prelude.test.ts +0 -104
  194. package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
  195. package/src/hindsight/client.test.ts +0 -75
  196. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
  197. package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
  198. package/src/launch/broker-list-order.test.ts +0 -89
  199. package/src/launch/broker-output-snapshot.test.ts +0 -126
  200. package/src/launch/protocol.test.ts +0 -59
  201. package/src/launch/spawn-options.test.ts +0 -31
  202. package/src/launch/terminal-output.test.ts +0 -107
  203. package/src/live/protocol.test.ts +0 -140
  204. package/src/mcp/config-writer.test.ts +0 -43
  205. package/src/mcp/smithery-auth.test.ts +0 -29
  206. package/src/mcp/smithery-registry.test.ts +0 -51
  207. package/src/mcp/transports/stdio.test.ts +0 -427
  208. package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
  209. package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
  210. package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
  211. package/src/modes/components/__tests__/skill-message.test.ts +0 -94
  212. package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
  213. package/src/modes/components/custom-editor.test.ts +0 -510
  214. package/src/modes/components/login-dialog.test.ts +0 -56
  215. package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
  216. package/src/modes/components/status-line/component.test.ts +0 -84
  217. package/src/modes/components/tool-execution.test.ts +0 -162
  218. package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
  219. package/src/modes/noninteractive-dispose.test.ts +0 -73
  220. package/src/modes/print-mode.test.ts +0 -71
  221. package/src/modes/session-teardown.test.ts +0 -219
  222. package/src/modes/theme/mermaid-rendering.test.ts +0 -53
  223. package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
  224. package/src/modes/warp-events.test.ts +0 -794
  225. package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
  226. package/src/plan-mode/model-transition.test.ts +0 -60
  227. package/src/plan-mode/reentry-prompt.test.ts +0 -41
  228. package/src/session/agent-session-error-log.test.ts +0 -59
  229. package/src/session/blob-store.test.ts +0 -56
  230. package/src/session/messages.test.ts +0 -282
  231. package/src/session/session-context.test.ts +0 -384
  232. package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
  233. package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
  234. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
  235. package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
  236. package/src/system-prompt.test.ts +0 -236
  237. package/src/task/render.test.ts +0 -290
  238. package/src/task/spawn-policy.test.ts +0 -62
  239. package/src/tools/__tests__/eval-description.test.ts +0 -18
  240. package/src/tools/__tests__/glob.test.ts +0 -37
  241. package/src/tools/__tests__/json-tree.test.ts +0 -35
  242. package/src/tools/__tests__/vibe-render.test.ts +0 -210
  243. package/src/tools/hub/launch-compat.test.ts +0 -40
  244. package/src/vibe/__tests__/token-rate.test.ts +0 -96
@@ -37,6 +37,7 @@ import type {
37
37
  ExtensionRuntime,
38
38
  ExtensionShortcut,
39
39
  ExtensionUIContext,
40
+ ExtensionUIDialogOptions,
40
41
  InputEvent,
41
42
  InputEventResult,
42
43
  MessageRenderer,
@@ -105,6 +106,66 @@ function handlerTimeoutForEvent(eventType: string): number {
105
106
  const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout");
106
107
  const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted");
107
108
 
109
+ function attachHandlerSignal(
110
+ dialogOptions: ExtensionUIDialogOptions | undefined,
111
+ handlerSignal: AbortSignal,
112
+ ): ExtensionUIDialogOptions {
113
+ if (!dialogOptions) return { signal: handlerSignal };
114
+ if (!dialogOptions.signal) return { ...dialogOptions, signal: handlerSignal };
115
+ if (dialogOptions.signal === handlerSignal) return dialogOptions;
116
+ return { ...dialogOptions, signal: AbortSignal.any([dialogOptions.signal, handlerSignal]) };
117
+ }
118
+
119
+ function createHandlerUIContext(ui: ExtensionUIContext, handlerSignal: AbortSignal): ExtensionUIContext {
120
+ const askDialog = ui.askDialog;
121
+ const dialogMethods = {
122
+ select: (title, options, dialogOptions) =>
123
+ ui.select(title, options, attachHandlerSignal(dialogOptions, handlerSignal)),
124
+ confirm: (title, message, dialogOptions) =>
125
+ ui.confirm(title, message, attachHandlerSignal(dialogOptions, handlerSignal)),
126
+ input: (title, placeholder, dialogOptions) =>
127
+ ui.input(title, placeholder, attachHandlerSignal(dialogOptions, handlerSignal)),
128
+ askDialog: askDialog
129
+ ? (questions, dialogOptions) =>
130
+ askDialog.call(ui, questions, attachHandlerSignal(dialogOptions, handlerSignal))
131
+ : undefined,
132
+ editor: (title, prefill, dialogOptions, editorOptions) =>
133
+ ui.editor(title, prefill, attachHandlerSignal(dialogOptions, handlerSignal), editorOptions),
134
+ } satisfies Pick<ExtensionUIContext, "select" | "confirm" | "input" | "askDialog" | "editor">;
135
+ const delegatedMethods = new Map<PropertyKey, unknown>();
136
+
137
+ return new Proxy(ui, {
138
+ get(target, property) {
139
+ if (Object.hasOwn(dialogMethods, property)) {
140
+ return Reflect.get(dialogMethods, property, dialogMethods);
141
+ }
142
+ const cached = delegatedMethods.get(property);
143
+ if (cached) return cached;
144
+ const value: unknown = Reflect.get(target, property, target);
145
+ if (typeof value !== "function") return value;
146
+ const delegated: unknown = value.bind(target);
147
+ delegatedMethods.set(property, delegated);
148
+ return delegated;
149
+ },
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Scope `ctx` to a single handler run without spreading it: `{ ...ctx }` would
155
+ * snapshot live accessors (notably the `model` getter), so a handler calling
156
+ * `pi.setModel()` and then reading `ctx.model` would see a stale model.
157
+ * Prototype delegation keeps every getter live while overriding `ui`.
158
+ */
159
+ function createHandlerContext(ctx: ExtensionContext, handlerSignal: AbortSignal): ExtensionContext {
160
+ const scoped: ExtensionContext = Object.create(ctx);
161
+ Object.defineProperty(scoped, "ui", {
162
+ value: createHandlerUIContext(ctx.ui, handlerSignal),
163
+ enumerable: true,
164
+ configurable: true,
165
+ });
166
+ return scoped;
167
+ }
168
+
108
169
  /**
109
170
  * Race `work` against a `timeoutMs` budget and optional cancellation signal,
110
171
  * clearing the timer and abort listener as soon as one branch settles.
@@ -118,19 +179,37 @@ const EXTENSION_HANDLER_ABORTED = Symbol("extensionHandlerAborted");
118
179
  * can `clearTimeout` on the winning branch.
119
180
  */
120
181
  async function raceHandlerWithTimeout<T>(
121
- work: Promise<T>,
182
+ work: (handlerSignal: AbortSignal) => Promise<T> | T,
122
183
  timeoutMs: number,
123
184
  signal?: AbortSignal,
124
185
  ): Promise<T | typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED> {
125
186
  if (signal?.aborted) return EXTENSION_HANDLER_ABORTED;
187
+
188
+ const timeoutController = new AbortController();
189
+ const handlerSignal = signal ? AbortSignal.any([signal, timeoutController.signal]) : timeoutController.signal;
126
190
  const { promise: interruptPromise, resolve: resolveInterrupt } = Promise.withResolvers<
127
191
  typeof EXTENSION_HANDLER_TIMEOUT | typeof EXTENSION_HANDLER_ABORTED
128
192
  >();
129
- const timer = setTimeout(() => resolveInterrupt(EXTENSION_HANDLER_TIMEOUT), timeoutMs);
130
193
  const onAbort = () => resolveInterrupt(EXTENSION_HANDLER_ABORTED);
131
194
  signal?.addEventListener("abort", onAbort, { once: true });
195
+ const timer = setTimeout(() => {
196
+ timeoutController.abort(new DOMException(`Handler timed out after ${timeoutMs}ms`, "TimeoutError"));
197
+ resolveInterrupt(EXTENSION_HANDLER_TIMEOUT);
198
+ }, timeoutMs);
132
199
  try {
133
- return await Promise.race([work, interruptPromise]);
200
+ if (signal?.aborted) return EXTENSION_HANDLER_ABORTED;
201
+ const workPromise = Promise.resolve(work(handlerSignal));
202
+ const result = await Promise.race([workPromise, interruptPromise]);
203
+ if (result === EXTENSION_HANDLER_TIMEOUT) {
204
+ await Promise.race([
205
+ workPromise.then(
206
+ () => undefined,
207
+ () => undefined,
208
+ ),
209
+ Bun.sleep(0),
210
+ ]);
211
+ }
212
+ return result;
134
213
  } finally {
135
214
  clearTimeout(timer);
136
215
  signal?.removeEventListener("abort", onAbort);
@@ -277,6 +356,32 @@ export class ExtensionRunner {
277
356
  #managedTimers = new ManagedTimers((event, error, stack) =>
278
357
  this.emitError({ extensionPath: "<timer>", event, error, stack }),
279
358
  );
359
+ /**
360
+ * Dedup markers for `tool_call` emission, keyed `${toolCallId}:${toolName}`.
361
+ * The agent loop emits `tool_call` at arg-prep time (before scheduling and
362
+ * `tool_execution_start`) via the session's `beforeToolCall` wiring; the
363
+ * marker tells `ExtensionToolWrapper.execute` not to emit a second event for
364
+ * the same dispatch. Keyed by call id + tool name because a nested xd://
365
+ * device dispatch reuses the model's toolCallId under a different tool name
366
+ * and must still emit its own event. Bounded: markers for calls whose
367
+ * execute path never runs (policy deny, validation failure) would otherwise
368
+ * accumulate for the session's lifetime.
369
+ */
370
+ #emittedToolCalls = new Set<string>();
371
+
372
+ /** Records that the loop already emitted `tool_call` for this dispatch. */
373
+ markToolCallEmitted(toolCallId: string, toolName: string): void {
374
+ if (this.#emittedToolCalls.size >= 512) {
375
+ const oldest = this.#emittedToolCalls.values().next().value;
376
+ if (oldest !== undefined) this.#emittedToolCalls.delete(oldest);
377
+ }
378
+ this.#emittedToolCalls.add(`${toolCallId}:${toolName}`);
379
+ }
380
+
381
+ /** Consumes a {@link markToolCallEmitted} marker; true when the loop already emitted. */
382
+ consumeToolCallEmitted(toolCallId: string, toolName: string): boolean {
383
+ return this.#emittedToolCalls.delete(`${toolCallId}:${toolName}`);
384
+ }
280
385
 
281
386
  constructor(
282
387
  private readonly extensions: Extension[],
@@ -637,7 +742,11 @@ export class ExtensionRunner {
637
742
  : undefined;
638
743
  if (signal?.aborted) return undefined;
639
744
  try {
640
- const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs, signal);
745
+ const handlerResult = await raceHandlerWithTimeout(
746
+ handlerSignal => handler(event, createHandlerContext(ctx, handlerSignal)),
747
+ timeoutMs,
748
+ signal,
749
+ );
641
750
  if (handlerResult === EXTENSION_HANDLER_ABORTED) return undefined;
642
751
  if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
643
752
  const error = `handler timed out after ${timeoutMs}ms`;
@@ -799,7 +908,10 @@ export class ExtensionRunner {
799
908
 
800
909
  for (const handler of handlers) {
801
910
  try {
802
- const handlerResult = await raceHandlerWithTimeout(Promise.resolve(handler(event, ctx)), timeoutMs);
911
+ const handlerResult = await raceHandlerWithTimeout(
912
+ handlerSignal => handler(event, createHandlerContext(ctx, handlerSignal)),
913
+ timeoutMs,
914
+ );
803
915
 
804
916
  if (handlerResult === EXTENSION_HANDLER_TIMEOUT) {
805
917
  const error = `handler timed out after ${timeoutMs}ms`;
@@ -1044,7 +1044,6 @@ export interface RegisteredCommand {
1044
1044
  // ============================================================================
1045
1045
 
1046
1046
  /** Handler function type for events */
1047
- // biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements
1048
1047
  export type ExtensionHandler<E, R = undefined> = (event: E, ctx: ExtensionContext) => Promise<R | void> | R | void;
1049
1048
 
1050
1049
  /** Service tiers accepted by each provider family. */
@@ -157,15 +157,70 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
157
157
  onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
158
158
  context?: AgentToolContext,
159
159
  ): Promise<AgentToolResult<TDetails, TParameters>> {
160
- // 1. Check approval policy (before extension handlers).
161
- // CLI `--auto-approve` / `--yolo` sets approval mode to yolo.
162
- // User `tools.approval.<tool>` policies are still applied in all modes.
160
+ // The agent loop emits `tool_call` at arg-prep time (session
161
+ // `beforeToolCall` wiring) so a handler revision lands before concurrency
162
+ // scheduling and `tool_execution_start`. Consume the marker
163
+ // unconditionally so it cannot go stale; emit here only for dispatches
164
+ // the loop never saw — nested xd:// device dispatches and direct
165
+ // (non-loop) execution such as Cursor exec handlers.
166
+ const loopEmittedToolCall = this.runner.consumeToolCallEmitted(toolCallId, this.tool.name);
167
+ // Resolve approval settings up front. A `deny` on the original input short-circuits before the
168
+ // runner is touched — an already-denied tool never emits `tool_call` — while the full gate below
169
+ // re-resolves against the (possibly revised) input so a handler cannot rewrite into a denied or
170
+ // newly prompt-gated command and have it run unapproved.
163
171
  const cliAutoApprove = context?.autoApprove === true;
164
172
  const settings: Settings | undefined = context?.settings;
165
173
  const configuredMode = (settings?.get("tools.approvalMode") ?? "yolo") as ApprovalMode;
166
174
  const approvalMode: ApprovalMode = cliAutoApprove ? "yolo" : configuredMode;
167
175
  const userPolicies = (settings?.get("tools.approval") ?? {}) as Record<string, unknown>;
168
- const resolvedArgs = approvalArgs(params, context);
176
+ if (resolveApproval(this.tool, approvalArgs(params, context), approvalMode, userPolicies).policy === "deny") {
177
+ throw new Error(
178
+ `Tool "${this.tool.name}" is blocked by user policy.\n` +
179
+ `To allow: remove "tools.approval.${this.tool.name}: deny" from config.`,
180
+ );
181
+ }
182
+
183
+ // 1. Emit tool_call event first - extensions can block execution or revise the input the tool
184
+ // runs with. Doing this BEFORE the approval gate means approval (below) resolves against the
185
+ // input that actually executes, closing the "approve one thing, run another" gap: the prompt
186
+ // text, policy resolution, and provider safety checks all see `effectiveParams`.
187
+ let effectiveParams = params;
188
+ if (!loopEmittedToolCall && this.runner.hasHandlers("tool_call")) {
189
+ try {
190
+ const callResult = (await this.runner.emitToolCall({
191
+ type: "tool_call",
192
+ toolName: this.tool.name,
193
+ toolCallId,
194
+ input: normalizeToolEventInput(
195
+ this.tool.name,
196
+ resolveToolEventInput(this.tool, toolEventArgs(params, context)),
197
+ ),
198
+ })) as ToolCallEventResult | undefined;
199
+
200
+ if (callResult?.block) {
201
+ const reason = callResult.reason || "Tool execution was blocked by an extension";
202
+ throw new Error(reason);
203
+ }
204
+ // A non-blocking handler may replace the execution input. The returned object is the raw
205
+ // input passed to `execute` (handler-owned; not re-normalized). Skipped for `computer`
206
+ // tool calls, whose event input is a synthetic {actions,pendingSafetyChecks} view
207
+ // (see toolEventArgs) rather than the real execution params.
208
+ if (callResult?.input !== undefined && context?.toolCall?.providerMetadata?.type !== "computer") {
209
+ effectiveParams = callResult.input as typeof params;
210
+ }
211
+ } catch (err) {
212
+ if (err instanceof Error) {
213
+ throw err;
214
+ }
215
+ throw new Error(`Extension failed, blocking execution: ${String(err)}`);
216
+ }
217
+ }
218
+
219
+ // 2. Full approval gate against the (possibly revised) input that will actually run — resolves
220
+ // policy and prompts on `effectiveParams`, so the user approves exactly what executes. A revised
221
+ // input that newly resolves to `deny` is caught here even though the original passed the
222
+ // short-circuit above.
223
+ const resolvedArgs = approvalArgs(effectiveParams, context);
169
224
  const resolved = resolveApproval(this.tool, resolvedArgs, approvalMode, userPolicies);
170
225
  if (resolved.policy === "deny") {
171
226
  throw new Error(
@@ -175,15 +230,17 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
175
230
  }
176
231
  const pendingSafetyChecks = computerSafetyChecks(context);
177
232
  // An xd:// device dispatch already cleared the write tool's outer gate at
178
- // this tool's tier — re-prompting would double-ask for one action. Explicit
179
- // per-tool "prompt" policies and tool-demanded overrides still prompt.
180
- // Provider safety checks are stronger: yolo, per-tool allow, and xdev approval
181
- // never acknowledge them on the user's behalf.
233
+ // this tool's tier — re-prompting would double-ask for one action. The
234
+ // bypass only holds while the input is exactly what that outer gate
235
+ // approved: a handler revision here may have raised the tier, so revised
236
+ // input always faces the full gate. Explicit per-tool "prompt" policies
237
+ // and tool-demanded overrides still prompt. Provider safety checks are
238
+ // stronger: yolo, per-tool allow, and xdev approval never acknowledge
239
+ // them on the user's behalf.
182
240
  const explicitPrompt = resolved.override || Object.hasOwn(userPolicies, this.tool.name);
241
+ const xdevBypass = context?.xdevApproved === true && effectiveParams === params;
183
242
  const approvalCheck = {
184
- required:
185
- pendingSafetyChecks.length > 0 ||
186
- (resolved.policy === "prompt" && (explicitPrompt || context?.xdevApproved !== true)),
243
+ required: pendingSafetyChecks.length > 0 || (resolved.policy === "prompt" && (explicitPrompt || !xdevBypass)),
187
244
  reason: resolved.reason,
188
245
  };
189
246
 
@@ -202,7 +259,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
202
259
  });
203
260
  }
204
261
 
205
- const resolveApproval = async (approved: boolean, reason?: string) => {
262
+ const emitApprovalResolved = async (approved: boolean, reason?: string) => {
206
263
  if (!hasApprovalHandlers) return;
207
264
  await this.runner.emit({
208
265
  type: "tool_approval_resolved",
@@ -218,7 +275,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
218
275
  // ordinary tier approval, no setting or yolo mode may bypass this gate.
219
276
  if (!this.runner.hasUI()) {
220
277
  const reason = "no interactive UI available";
221
- await resolveApproval(false, reason);
278
+ await emitApprovalResolved(false, reason);
222
279
  if (pendingSafetyChecks.length > 0) {
223
280
  throw new Error(
224
281
  `Tool "${this.tool.name}" has pending provider safety checks but no interactive UI is available.`,
@@ -243,11 +300,11 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
243
300
  try {
244
301
  choice = await uiContext.select(safetyPrompt, ["Approve", "Deny"]);
245
302
  } catch (err) {
246
- await resolveApproval(false, err instanceof Error ? err.message : "approval aborted");
303
+ await emitApprovalResolved(false, err instanceof Error ? err.message : "approval aborted");
247
304
  throw err;
248
305
  }
249
306
  const approved = choice === "Approve";
250
- await resolveApproval(approved, approved ? undefined : "denied by user");
307
+ await emitApprovalResolved(approved, approved ? undefined : "denied by user");
251
308
  if (!approved) {
252
309
  throw new Error(`Tool call denied by user: ${this.tool.name}`);
253
310
  }
@@ -257,37 +314,12 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
257
314
  }
258
315
  }
259
316
 
260
- // 2. Emit tool_call event - extensions can block execution
261
- if (this.runner.hasHandlers("tool_call")) {
262
- try {
263
- const callResult = (await this.runner.emitToolCall({
264
- type: "tool_call",
265
- toolName: this.tool.name,
266
- toolCallId,
267
- input: normalizeToolEventInput(
268
- this.tool.name,
269
- resolveToolEventInput(this.tool, toolEventArgs(params, context)),
270
- ),
271
- })) as ToolCallEventResult | undefined;
272
-
273
- if (callResult?.block) {
274
- const reason = callResult.reason || "Tool execution was blocked by an extension";
275
- throw new Error(reason);
276
- }
277
- } catch (err) {
278
- if (err instanceof Error) {
279
- throw err;
280
- }
281
- throw new Error(`Extension failed, blocking execution: ${String(err)}`);
282
- }
283
- }
284
-
285
317
  // Execute the actual tool
286
318
  let result: AgentToolResult<TDetails, TParameters>;
287
319
  let executionError: Error | undefined;
288
320
 
289
321
  try {
290
- result = await this.tool.execute(toolCallId, params, signal, onUpdate, context);
322
+ result = await this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context);
291
323
  } catch (err) {
292
324
  executionError = err instanceof Error ? err : new Error(String(err));
293
325
  result = {
@@ -304,7 +336,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
304
336
  toolCallId,
305
337
  input: normalizeToolEventInput(
306
338
  this.tool.name,
307
- resolveToolEventInput(this.tool, toolEventArgs(params, context)),
339
+ resolveToolEventInput(this.tool, toolEventArgs(effectiveParams, context)),
308
340
  ),
309
341
  content: result.content,
310
342
  details: result.details,
@@ -39,8 +39,9 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
39
39
  onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
40
40
  context?: AgentToolContext,
41
41
  ) {
42
- // Emit tool_call event - hooks can block execution
42
+ // Emit tool_call event - hooks can block execution or revise the input the tool runs with.
43
43
  // If hook errors/times out, block by default (fail-safe)
44
+ let effectiveParams = params;
44
45
  if (this.hookRunner.hasHandlers("tool_call")) {
45
46
  try {
46
47
  const callResult = (await this.hookRunner.emitToolCall({
@@ -57,6 +58,12 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
57
58
  const reason = callResult.reason || "Tool execution was blocked by a hook";
58
59
  throw new Error(reason);
59
60
  }
61
+ // A non-blocking handler may replace the execution input. The returned object is the raw
62
+ // input the tool runs with (handler-owned); it is not re-normalized. Skipped for `computer`
63
+ // tool calls, whose real parameters are not represented by the event input.
64
+ if (callResult?.input !== undefined && context?.toolCall?.providerMetadata?.type !== "computer") {
65
+ effectiveParams = callResult.input as Static<TParameters>;
66
+ }
60
67
  } catch (err) {
61
68
  // Hook error or block - throw to mark as error
62
69
  if (err instanceof Error) {
@@ -68,7 +75,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
68
75
 
69
76
  // Execute the actual tool, forwarding onUpdate for progress streaming
70
77
  try {
71
- const result = await this.tool.execute(toolCallId, params, signal, onUpdate, context);
78
+ const result = await this.tool.execute(toolCallId, effectiveParams, signal, onUpdate, context);
72
79
 
73
80
  // Emit tool_result event - hooks can modify the result
74
81
  if (this.hookRunner.hasHandlers("tool_result")) {
@@ -78,7 +85,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
78
85
  toolCallId,
79
86
  input: normalizeToolEventInput(
80
87
  this.tool.name,
81
- resolveToolEventInput(this.tool, params as Record<string, unknown>),
88
+ resolveToolEventInput(this.tool, effectiveParams as Record<string, unknown>),
82
89
  ),
83
90
  content: result.content,
84
91
  details: result.details,
@@ -104,7 +111,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
104
111
  toolCallId,
105
112
  input: normalizeToolEventInput(
106
113
  this.tool.name,
107
- resolveToolEventInput(this.tool, params as Record<string, unknown>),
114
+ resolveToolEventInput(this.tool, effectiveParams as Record<string, unknown>),
108
115
  ),
109
116
  content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
110
117
  details: undefined,
@@ -444,7 +444,6 @@ export type {
444
444
  * Handler function type for each event.
445
445
  * Handlers can return R, undefined, or void (bare return statements).
446
446
  */
447
- // biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements in handlers
448
447
  export type HookHandler<E, R = undefined> = (event: E, ctx: HookContext) => Promise<R | void> | R | void;
449
448
 
450
449
  export interface HookMessageRenderOptions {
@@ -289,13 +289,30 @@ export interface TodoReminderEvent {
289
289
 
290
290
  /**
291
291
  * Return type for `tool_call` handlers.
292
- * Allows handlers to block tool execution.
292
+ * Allows handlers to block tool execution or revise the input the tool runs with.
293
293
  */
294
294
  export interface ToolCallEventResult {
295
295
  /** If true, block the tool from executing */
296
296
  block?: boolean;
297
297
  /** Reason for blocking (returned to LLM as error) */
298
298
  reason?: string;
299
+ /**
300
+ * Replacement input the tool executes with, instead of the original arguments. Ignored when
301
+ * `block` is true. This is the raw execution input passed to the tool's `execute` (the handler
302
+ * owns its correctness) — not the normalized `event.input` view, which may carry derived
303
+ * gate-only fields (e.g. hashline `edit` `path`/`paths`) that are not real parameters. When
304
+ * multiple handlers set `input`, the last one wins; handlers do not observe each other's
305
+ * revisions (each sees the original `event.input`). Not applied to `computer` tool calls.
306
+ *
307
+ * For model-issued tool calls the event fires at arg-prep time in the agent loop, before
308
+ * concurrency scheduling, `tool_execution_start`, and the approval gate: the revision is
309
+ * revalidated against the tool schema and becomes what the loop schedules, displays, persists,
310
+ * and executes — the user always approves what actually runs. For dispatches the loop never
311
+ * sees (nested `write xd://` device calls, Cursor direct execution) the tool wrapper applies
312
+ * the revision before its own approval gate; a revised nested xd:// input forfeits the outer
313
+ * write gate's approval and faces the full prompt again.
314
+ */
315
+ input?: Record<string, unknown>;
299
316
  }
300
317
 
301
318
  /**
@@ -21,6 +21,15 @@ function getUriTemplateMatchScore(
21
21
  }
22
22
 
23
23
  function extractResourceUri(url: InternalUrl): string {
24
+ const scheme = url.protocol.replace(/:$/, "").toLowerCase();
25
+ if (scheme !== "mcp") {
26
+ // Server-advertised native URI (hierarchical or opaque). Preserve the
27
+ // input byte-for-byte: `resolveTargetServer` matches by exact string
28
+ // equality, so e.g. `catalog://root/` must keep its trailing slash.
29
+ return url.rawHref ?? url.href;
30
+ }
31
+ // Legacy `mcp://<resource-uri>` wrapper: reconstruct the wrapped URI and
32
+ // elide a bare trailing `/` that URL parsing adds to host-only forms.
24
33
  const host = url.rawHost || url.hostname;
25
34
  const rawPathname = url.rawPathname ?? url.pathname;
26
35
  const hasPath = rawPathname && rawPathname !== "/";
@@ -95,10 +104,11 @@ function formatAvailableResources(mcpManager: MCPManager): string {
95
104
  }
96
105
 
97
106
  /**
98
- * Protocol handler for mcp:// URLs.
107
+ * Protocol handler for MCP resources.
99
108
  *
100
- * URL form:
109
+ * URL forms:
101
110
  * - mcp://<resource-uri> (e.g. mcp://test://notes, mcp://ibkr://portfolio/positions)
111
+ * - A resource's native URI when its scheme has no OMP handler (e.g. ags://capabilities/current-host)
102
112
  */
103
113
  export class McpProtocolHandler implements ProtocolHandler {
104
114
  readonly scheme = "mcp";
@@ -111,7 +121,11 @@ export class McpProtocolHandler implements ProtocolHandler {
111
121
  }
112
122
 
113
123
  const uri = extractResourceUri(url);
114
- const targetServer = resolveTargetServer(mcpManager, uri);
124
+ let targetServer = resolveTargetServer(mcpManager, uri);
125
+ if (!targetServer) {
126
+ await Promise.allSettled(mcpManager.getConnectedServers().map(name => mcpManager.ensureServerResources(name)));
127
+ targetServer = resolveTargetServer(mcpManager, uri);
128
+ }
115
129
  if (!targetServer) {
116
130
  throw new Error(
117
131
  `No MCP server has resource "${uri}".\n\nAvailable resources:\n${formatAvailableResources(mcpManager)}`,
@@ -13,6 +13,36 @@ import type { InternalUrl } from "./types";
13
13
 
14
14
  const SCHEME_HOST_RE = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]*)/i;
15
15
  const PATHNAME_RE = /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*(\/[^?#]*)?/i;
16
+ // Opaque URI form (`urn:example:document`, `custom:item`) — an RFC 3986 scheme
17
+ // followed by `:` without `//`. Guarded separately in `extractUriScheme`.
18
+ const OPAQUE_URI_RE = /^([a-z][a-z0-9+.-]*):(.+)$/is;
19
+ // A read-tool selector chain (`12`, `1-20,30+5`, `raw`, `raw:2-4`, `conflicts`)
20
+ // so `Makefile:12` or `notes:raw` is not mistaken for an opaque URI.
21
+ const SELECTOR_CHUNK_SRC = String.raw`(?:raw|conflicts|-?\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)`;
22
+ const SELECTOR_CHAIN_RE = new RegExp(`^${SELECTOR_CHUNK_SRC}(?::${SELECTOR_CHUNK_SRC})*$`, "i");
23
+
24
+ /**
25
+ * Extract the lowercased scheme from a URI-shaped input, or `undefined` when
26
+ * the input does not look like a URI.
27
+ *
28
+ * Accepts both hierarchical (`scheme://…`) and opaque (`scheme:rest`) forms —
29
+ * MCP resource URIs may be opaque (`urn:example:document`, `custom:item`).
30
+ * The opaque form is guarded against path-like false positives:
31
+ * - Windows drive paths (`C:\…`, `C:/…`, `C:foo`) — single-letter scheme.
32
+ * - Filenames with extensions (`foo.ts:50`) — dot in the scheme segment.
33
+ * - Read-tool selector tails (`Makefile:12`, `README:raw:1-20`).
34
+ */
35
+ export function extractUriScheme(input: string): string | undefined {
36
+ const hierarchical = input.match(SCHEME_HOST_RE);
37
+ if (hierarchical) return hierarchical[1].toLowerCase();
38
+ const opaque = input.match(OPAQUE_URI_RE);
39
+ if (!opaque) return undefined;
40
+ const [, scheme, rest] = opaque;
41
+ if (scheme.length === 1) return undefined;
42
+ if (scheme.includes(".")) return undefined;
43
+ if (SELECTOR_CHAIN_RE.test(rest)) return undefined;
44
+ return scheme.toLowerCase();
45
+ }
16
46
 
17
47
  /**
18
48
  * Parse an internal URL into an InternalUrl.
@@ -68,5 +98,6 @@ export function parseInternalUrl(input: string): InternalUrl {
68
98
  const result = parsed as InternalUrl;
69
99
  result.rawHost = rawHost;
70
100
  result.rawPathname = pathMatch?.[1] ?? parsed.pathname;
101
+ result.rawHref = input;
71
102
  return result;
72
103
  }
@@ -13,7 +13,7 @@ import { LocalProtocolHandler } from "./local-protocol";
13
13
  import { McpProtocolHandler } from "./mcp-protocol";
14
14
  import { MemoryProtocolHandler } from "./memory-protocol";
15
15
  import { OmpProtocolHandler } from "./omp-protocol";
16
- import { parseInternalUrl } from "./parse";
16
+ import { extractUriScheme, parseInternalUrl } from "./parse";
17
17
  import { RuleProtocolHandler } from "./rule-protocol";
18
18
  import { SkillProtocolHandler } from "./skill-protocol";
19
19
  import { SshProtocolHandler } from "./ssh-protocol";
@@ -79,6 +79,20 @@ export class InternalUrlRouter {
79
79
  return this.#handlers.has(match[1].toLowerCase());
80
80
  }
81
81
 
82
+ /**
83
+ * Whether read can resolve this URL through either a native handler or the
84
+ * MCP resource fallback. MCP resources may use arbitrary custom schemes and
85
+ * may be opaque (`urn:example:document`) rather than hierarchical.
86
+ */
87
+ canResolve(input: string): boolean {
88
+ const scheme = extractUriScheme(input);
89
+ if (!scheme) return false;
90
+ // Registered handlers only accept the hierarchical `scheme://` form;
91
+ // opaque inputs reach the MCP resource fallback alone.
92
+ if (this.#handlers.has(scheme)) return this.canHandle(input);
93
+ return this.#isMcpResourceScheme(scheme);
94
+ }
95
+
82
96
  /** Schemes whose handler supports host/path autocomplete. */
83
97
  completionSchemes(): string[] {
84
98
  const schemes: string[] = [];
@@ -98,10 +112,16 @@ export class InternalUrlRouter {
98
112
  return handler.complete(query, context);
99
113
  }
100
114
 
101
- #route(input: string): { parsed: InternalUrl; handler: ProtocolHandler } {
115
+ #isMcpResourceScheme(scheme: string): boolean {
116
+ return !["file", "http", "https"].includes(scheme) && this.#handlers.has("mcp");
117
+ }
118
+
119
+ #route(input: string, allowMcpResource = false): { parsed: InternalUrl; handler: ProtocolHandler } {
102
120
  const parsed = parseInternalUrl(input);
103
121
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
104
- const handler = this.#handlers.get(scheme);
122
+ const handler =
123
+ this.#handlers.get(scheme) ??
124
+ (allowMcpResource && this.#isMcpResourceScheme(scheme) ? this.#handlers.get("mcp") : undefined);
105
125
  if (!handler) {
106
126
  const available = Array.from(this.#handlers.keys())
107
127
  .map(candidate => `${candidate}://`)
@@ -113,7 +133,7 @@ export class InternalUrlRouter {
113
133
 
114
134
  /** Resolve an internal URL through its registered protocol handler. */
115
135
  async resolve(input: string, context?: ResolveContext): Promise<InternalResource> {
116
- const { parsed, handler } = this.#route(input);
136
+ const { parsed, handler } = this.#route(input, true);
117
137
  const resource = await handler.resolve(parsed, context);
118
138
  return { ...resource, immutable: resource.immutable ?? handler.immutable };
119
139
  }
@@ -70,6 +70,12 @@ export interface InternalUrl extends URL {
70
70
  * Raw pathname extracted from input, preserving traversal markers before URL normalization.
71
71
  */
72
72
  rawPathname?: string;
73
+ /**
74
+ * Exact input string this URL was parsed from, before any normalization.
75
+ * Set by `parseInternalUrl`; used where byte-exact URI matching matters
76
+ * (e.g. MCP resource URIs compared by string equality).
77
+ */
78
+ rawHref?: string;
73
79
  }
74
80
 
75
81
  /**
@@ -1,5 +1,5 @@
1
1
  import { type AuthStorage, isAuthRetryableError, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
2
- import { getProxyForProvider, wrapFetchForProxy } from "@oh-my-pi/pi-ai/utils/proxy";
2
+ import { getProxyForUrl, wrapFetchForProxy } from "@oh-my-pi/pi-ai/utils/proxy";
3
3
  import {
4
4
  CODEX_BASE_URL,
5
5
  CODEX_CLIENT_VERSION,
@@ -252,7 +252,7 @@ export class CodexLiveTransport {
252
252
  const url = buildLiveSidebandUrl(callId);
253
253
  const options = {
254
254
  headers: liveSessionHeaders(access, this.#options.sessionId, this.#realtimeSessionId, attestation),
255
- proxy: getProxyForProvider(LIVE_PROVIDER),
255
+ proxy: getProxyForUrl(LIVE_PROVIDER, new URL(url)),
256
256
  } satisfies Bun.WebSocketOptions;
257
257
  const socket: Bun.WebSocket = Reflect.construct(WebSocket, [url, options]);
258
258
  socket.binaryType = "nodebuffer";
package/src/lsp/client.ts CHANGED
@@ -918,7 +918,7 @@ export async function ensureFileOpen(client: LspClient, filePath: string, signal
918
918
  if (isEnoent(err)) return;
919
919
  throw err;
920
920
  }
921
- const languageId = detectLanguageId(filePath);
921
+ const languageId = client.config.languageId ?? detectLanguageId(filePath);
922
922
  throwIfAborted(signal);
923
923
 
924
924
  await sendNotification(
@@ -992,7 +992,7 @@ export async function syncContent(
992
992
 
993
993
  if (!info) {
994
994
  // Open file with provided content instead of reading from disk
995
- const languageId = detectLanguageId(filePath);
995
+ const languageId = client.config.languageId ?? detectLanguageId(filePath);
996
996
  throwIfAborted(signal);
997
997
  await sendNotification(
998
998
  client,