@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.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 (198) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3556 -3504
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +10 -0
  14. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  15. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  16. package/dist/types/eval/backend.d.ts +3 -3
  17. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  18. package/dist/types/eval/js/context-manager.d.ts +5 -3
  19. package/dist/types/eval/js/process-entry.d.ts +6 -0
  20. package/dist/types/eval/js/worker-core.d.ts +15 -1
  21. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  22. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  23. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  26. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  27. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  28. package/dist/types/goals/guided-setup.d.ts +12 -0
  29. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  30. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  31. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  32. package/dist/types/main.d.ts +1 -0
  33. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  34. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  35. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  36. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  37. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  38. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  39. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  40. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  41. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  42. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  43. package/dist/types/modes/interactive-mode.d.ts +1 -0
  44. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  45. package/dist/types/modes/orchestrate.d.ts +1 -1
  46. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  47. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  48. package/dist/types/modes/types.d.ts +1 -0
  49. package/dist/types/modes/ultrathink.d.ts +1 -1
  50. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  51. package/dist/types/modes/workflow.d.ts +1 -1
  52. package/dist/types/session/agent-session.d.ts +6 -0
  53. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  54. package/dist/types/session/messages.d.ts +15 -0
  55. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  56. package/dist/types/subprocess/worker-client.d.ts +6 -0
  57. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  58. package/dist/types/tools/grep.d.ts +0 -2
  59. package/dist/types/tools/read.d.ts +0 -4
  60. package/dist/types/web/search/provider.d.ts +10 -3
  61. package/dist/types/web/search/providers/codex.d.ts +5 -4
  62. package/package.json +12 -12
  63. package/src/advisor/__tests__/advisor.test.ts +917 -42
  64. package/src/advisor/advise-tool.ts +17 -1
  65. package/src/advisor/runtime.ts +270 -67
  66. package/src/autolearn/controller.ts +15 -3
  67. package/src/autoresearch/dashboard.ts +2 -2
  68. package/src/cli/args.ts +12 -0
  69. package/src/cli/auth-broker-cli.ts +30 -11
  70. package/src/cli/auth-gateway-cli.ts +5 -1
  71. package/src/cli/config-cli.ts +15 -3
  72. package/src/cli/dry-balance-cli.ts +14 -4
  73. package/src/cli/flag-tables.ts +21 -7
  74. package/src/cli/update-cli.ts +62 -11
  75. package/src/cli/usage-cli.ts +58 -5
  76. package/src/cli/usage-error.ts +7 -0
  77. package/src/cli.ts +23 -1
  78. package/src/commands/acp.ts +11 -2
  79. package/src/commands/launch.ts +12 -3
  80. package/src/commands/token.ts +3 -1
  81. package/src/config/api-key-resolver.ts +12 -3
  82. package/src/config/config-file.ts +30 -12
  83. package/src/config/model-registry.ts +7 -7
  84. package/src/config/model-resolver.ts +21 -7
  85. package/src/config/models-config.ts +1 -1
  86. package/src/config/settings-schema.ts +10 -0
  87. package/src/cursor.ts +2 -0
  88. package/src/discovery/claude-plugins.ts +9 -3
  89. package/src/discovery/omp-plugins.ts +6 -2
  90. package/src/discovery/substitute-plugin-root.ts +32 -0
  91. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  92. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  93. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  94. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  95. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  96. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  97. package/src/eval/agent-bridge.ts +121 -116
  98. package/src/eval/backend.ts +3 -3
  99. package/src/eval/bridge-timeout.ts +20 -2
  100. package/src/eval/executor-base.ts +85 -7
  101. package/src/eval/jl/kernel.ts +2 -1
  102. package/src/eval/js/context-manager.ts +109 -32
  103. package/src/eval/js/process-entry.ts +27 -0
  104. package/src/eval/js/shared/runtime.ts +1 -1
  105. package/src/eval/js/worker-core.ts +70 -9
  106. package/src/eval/js/worker-entry.ts +1 -1
  107. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  108. package/src/eval/py/kernel.ts +2 -1
  109. package/src/eval/py/prelude.py +28 -1
  110. package/src/eval/py/spawn-options.ts +13 -0
  111. package/src/eval/py/tool-bridge.ts +13 -14
  112. package/src/eval/rb/kernel.ts +2 -1
  113. package/src/exec/bash-executor.ts +30 -43
  114. package/src/extensibility/custom-tools/types.ts +3 -0
  115. package/src/extensibility/extensions/runner.ts +3 -0
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/extensions/wrapper.ts +18 -18
  118. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  119. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  120. package/src/extensibility/plugins/manager.ts +28 -7
  121. package/src/goals/guided-setup.ts +29 -1
  122. package/src/internal-urls/history-protocol.ts +95 -15
  123. package/src/internal-urls/memory-protocol.ts +13 -9
  124. package/src/internal-urls/registry-helpers.ts +50 -1
  125. package/src/launch/broker.ts +38 -25
  126. package/src/lsp/client.ts +7 -1
  127. package/src/main.ts +29 -0
  128. package/src/mcp/oauth-discovery.ts +20 -1
  129. package/src/mcp/oauth-flow.ts +3 -1
  130. package/src/mcp/tool-bridge.ts +57 -6
  131. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  132. package/src/modes/components/agent-dashboard.ts +2 -2
  133. package/src/modes/components/agent-hub.ts +15 -2
  134. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  135. package/src/modes/components/chat-transcript-builder.ts +26 -4
  136. package/src/modes/components/dynamic-border.ts +9 -6
  137. package/src/modes/components/extensions/extension-list.ts +2 -2
  138. package/src/modes/components/hook-selector.ts +10 -4
  139. package/src/modes/components/login-dialog.ts +5 -0
  140. package/src/modes/components/mcp-add-wizard.ts +5 -0
  141. package/src/modes/components/plan-review-overlay.ts +11 -11
  142. package/src/modes/components/read-tool-group.ts +1 -8
  143. package/src/modes/components/status-line/component.ts +10 -1
  144. package/src/modes/components/transcript-container.ts +110 -7
  145. package/src/modes/controllers/command-controller.ts +12 -4
  146. package/src/modes/controllers/event-controller.ts +80 -15
  147. package/src/modes/controllers/input-controller.ts +4 -2
  148. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  149. package/src/modes/controllers/selector-controller.ts +24 -5
  150. package/src/modes/controllers/todo-command-controller.ts +18 -14
  151. package/src/modes/interactive-mode.ts +7 -3
  152. package/src/modes/magic-keyword-boundary.ts +23 -0
  153. package/src/modes/orchestrate.ts +6 -5
  154. package/src/modes/print-mode.ts +9 -0
  155. package/src/modes/prompt-action-autocomplete.ts +6 -1
  156. package/src/modes/rpc/host-tools.ts +15 -0
  157. package/src/modes/rpc/rpc-mode.ts +123 -48
  158. package/src/modes/types.ts +1 -1
  159. package/src/modes/ultrathink.ts +6 -5
  160. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  161. package/src/modes/utils/ui-helpers.ts +27 -1
  162. package/src/modes/workflow.ts +6 -5
  163. package/src/prompts/advisor/system.md +1 -0
  164. package/src/prompts/system/system-prompt.md +1 -0
  165. package/src/prompts/tools/eval.md +2 -2
  166. package/src/prompts/tools/grep.md +1 -2
  167. package/src/prompts/tools/read.md +2 -4
  168. package/src/sdk.ts +61 -34
  169. package/src/session/agent-session.ts +283 -39
  170. package/src/session/exit-diagnostics.ts +108 -0
  171. package/src/session/messages.test.ts +66 -0
  172. package/src/session/messages.ts +37 -0
  173. package/src/session/streaming-output.ts +40 -12
  174. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  175. package/src/slash-commands/helpers/logout.ts +23 -3
  176. package/src/slash-commands/helpers/usage-report.ts +14 -2
  177. package/src/subprocess/worker-client.ts +9 -2
  178. package/src/system-prompt.test.ts +36 -0
  179. package/src/system-prompt.ts +1 -1
  180. package/src/task/executor.ts +8 -0
  181. package/src/task/render.test.ts +36 -0
  182. package/src/task/render.ts +55 -43
  183. package/src/tools/bash-skill-urls.ts +4 -1
  184. package/src/tools/bash.ts +1 -0
  185. package/src/tools/browser/registry.ts +17 -3
  186. package/src/tools/eval.ts +14 -9
  187. package/src/tools/gh.ts +3 -1
  188. package/src/tools/grep.ts +5 -45
  189. package/src/tools/path-utils.ts +7 -1
  190. package/src/tools/read.ts +23 -74
  191. package/src/tools/write.ts +82 -9
  192. package/src/tools/yield.ts +29 -1
  193. package/src/utils/title-generator.ts +10 -6
  194. package/src/web/search/index.ts +39 -22
  195. package/src/web/search/provider.ts +33 -16
  196. package/src/web/search/providers/codex.ts +68 -21
  197. package/src/web/search/providers/perplexity-auth.ts +20 -11
  198. package/src/web/search/providers/perplexity.ts +14 -2
@@ -57,6 +57,27 @@ if "__omp_prelude_loaded__" not in globals():
57
57
 
58
58
  _OMP_INTERNAL_URL_RE = re.compile(r"^([a-z][a-z0-9+.-]*)://(.*)$", re.IGNORECASE)
59
59
 
60
+ def _should_delegate_read(path: str | Path) -> bool:
61
+ return (
62
+ isinstance(path, str)
63
+ and _OMP_INTERNAL_URL_RE.match(path) is not None
64
+ and not path.lower().startswith("local://")
65
+ )
66
+
67
+ def _read_line_selector(offset: int, limit: int | None) -> str | None:
68
+ if offset <= 1 and limit is None:
69
+ return None
70
+ start = max(1, offset)
71
+ if limit is None:
72
+ return f"{start}-"
73
+ return f"{start}-{start + limit - 1}"
74
+
75
+ def _read_tool_text(path: str) -> str:
76
+ result = _bridge_call("read", {"path": path})
77
+ if isinstance(result, dict) and "text" in result:
78
+ return result["text"]
79
+ return result
80
+
60
81
  def _resolve_omp_path(path: str | Path) -> Path:
61
82
  """Map a helper path to a real filesystem Path.
62
83
 
@@ -94,7 +115,13 @@ if "__omp_prelude_loaded__" not in globals():
94
115
  return Path(resolved)
95
116
 
96
117
  def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str:
97
- """Read file contents. offset/limit are 1-indexed line numbers."""
118
+ """Read file or read-tool URI contents. offset/limit are 1-indexed lines."""
119
+ if _should_delegate_read(path):
120
+ if limit is not None and limit <= 0:
121
+ return ""
122
+ selector = _read_line_selector(offset, limit)
123
+ tool_path = path if selector is None else f"{path}:{selector}"
124
+ return _read_tool_text(tool_path)
98
125
  p = _resolve_omp_path(path)
99
126
  data = p.read_text(encoding="utf-8")
100
127
  lines = data.splitlines(keepends=True)
@@ -40,6 +40,19 @@ export function shouldHideKernelWindow(opts: {
40
40
  return !opts.hostHasInheritableConsole;
41
41
  }
42
42
 
43
+ /**
44
+ * Keep eval kernels outside the host's POSIX terminal session.
45
+ *
46
+ * User code can start an interactive shell which calls `tcsetpgrp(3)`. If the
47
+ * kernel shares OMP's session, that shell can replace OMP as the controlling
48
+ * terminal's foreground process group and the host is then stopped by SIGTTIN
49
+ * on its next stdin read. Bun implements `detached: true` with `setsid(2)` on
50
+ * POSIX, making the kernel a session leader with no controlling terminal.
51
+ */
52
+ export function shouldDetachKernel(platform: NodeJS.Platform): boolean {
53
+ return platform !== "win32";
54
+ }
55
+
43
56
  /**
44
57
  * TTY-based fallback used when the Win32 console probe is unavailable.
45
58
  *
@@ -15,6 +15,7 @@ export interface PyToolBridgeEntry {
15
15
  toolSession: ToolSession;
16
16
  signal?: AbortSignal;
17
17
  emitStatus?: (event: JsStatusEvent) => void;
18
+ abortRequested?: () => boolean;
18
19
  }
19
20
 
20
21
  export interface PyToolBridgeInfo {
@@ -31,23 +32,21 @@ const registrations = new Map<string, PyToolBridgeEntry>();
31
32
  let serverPromise: Promise<BridgeServer> | null = null;
32
33
 
33
34
  /**
34
- * Forward a bridge call to {@link callSessionTool}, but resolve the HTTP request
35
- * the instant the cell's signal aborts instead of waiting for the tool/subagent
36
- * to fully tear down.
35
+ * Forward a bridge call to {@link callSessionTool} while respecting eval abort
36
+ * shielding.
37
37
  *
38
- * The kernel invokes this bridge with a *blocking* `urllib` request from a
39
- * worker thread (each `agent()` / `tool.*` call). When the cell is interrupted,
40
- * `parallel()`'s `ThreadPoolExecutor.__exit__` joins those worker threads
41
- * (`shutdown(wait=True)`), so they cannot unwind until their `urllib` call
42
- * returns i.e. until this handler responds. A host-side `agent()` teardown
43
- * (aborting nested LLM streams + tools across a wide fan-out) routinely exceeds
44
- * the kernel's SIGINT escalation window, so the kernel was hard-killed and its
45
- * persistent state lost while the subagents were still winding down. Responding
46
- * immediately on abort lets the kernel raise through the blocked call and settle
47
- * cleanly (preserving state); the already-signaled call keeps tearing down in
48
- * the background, its eventual result/rejection swallowed.
38
+ * Python invokes this bridge with blocking `urllib` requests from worker threads
39
+ * (each `agent()` / `tool.*` call). The base executor defers the registered
40
+ * signal while a bridge call is already paused so in-flight subagents can finish
41
+ * and persist output instead of being orphaned. Once an abort has been requested,
42
+ * later bridge calls are rejected before starting; once the shielded signal
43
+ * finally aborts, this handler still resolves the HTTP request promptly so the
44
+ * kernel can unwind without being hard-killed.
49
45
  */
50
46
  async function callSessionToolPromptOnAbort(name: string, args: unknown, entry: PyToolBridgeEntry): Promise<unknown> {
47
+ if (entry.abortRequested?.()) {
48
+ throw new Error(`bridge call ${JSON.stringify(name)} aborted: eval cell was interrupted`);
49
+ }
51
50
  const call = callSessionTool(name, args, {
52
51
  session: entry.toolSession,
53
52
  signal: entry.signal,
@@ -16,7 +16,7 @@ import { $ } from "bun";
16
16
  import { Settings } from "../../config/settings";
17
17
  import { BaseKernel, getRemainingTimeMs, type KernelRuntimeEnv, type KernelStartOptions } from "../kernel-base";
18
18
  import type { KernelDisplayOutput } from "../py/display";
19
- import { hostHasInheritableConsole, shouldHideKernelWindow } from "../py/spawn-options";
19
+ import { hostHasInheritableConsole, shouldDetachKernel, shouldHideKernelWindow } from "../py/spawn-options";
20
20
  import { RUBY_PRELUDE } from "./prelude";
21
21
  import RUNNER_SCRIPT from "./runner.rb" with { type: "text" };
22
22
  import {
@@ -186,6 +186,7 @@ export class RubyKernel extends BaseKernel<KernelExecuteOptions> {
186
186
 
187
187
  const proc = Bun.spawn([runtime.rubyPath, scriptPath], {
188
188
  cwd: options.cwd,
189
+ detached: shouldDetachKernel(process.platform),
189
190
  env: spawnEnv,
190
191
  stdin: "pipe",
191
192
  stdout: "pipe",
@@ -4,7 +4,7 @@
4
4
  * Uses brush-core via native bindings for shell execution.
5
5
  */
6
6
  import { ExponentialYield } from "@oh-my-pi/pi-agent-core/utils/yield";
7
- import { executeShell, type MinimizerOptions, Shell, type ShellRunResult } from "@oh-my-pi/pi-natives";
7
+ import { type MinimizerOptions, Shell, type ShellRunResult } from "@oh-my-pi/pi-natives";
8
8
  import { isExecutable, type ShellConfig } from "@oh-my-pi/pi-utils/procmgr";
9
9
  import { Settings, type ShellMinimizerSettings } from "../config/settings";
10
10
  import { OutputSink } from "../session/streaming-output";
@@ -250,6 +250,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions
250
250
  };
251
251
  }
252
252
 
253
+ const shellOptions = {
254
+ sessionEnv: shellEnv,
255
+ snapshotPath: snapshotPath ?? undefined,
256
+ minimizer,
257
+ };
253
258
  const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
254
259
  const persistentSessionBroken = brokenShellSessions.has(sessionKey);
255
260
  if (persistentSessionBroken) {
@@ -264,13 +269,10 @@ export async function executeBash(command: string, options?: BashExecutorOptions
264
269
  const sessionBusy = shellSessionsInUse.has(sessionKey);
265
270
  let shellSession = persistentSessionBroken || sessionBusy ? undefined : shellSessions.get(sessionKey);
266
271
  if (!shellSession && !persistentSessionBroken && !sessionBusy) {
267
- shellSession = new Shell({
268
- sessionEnv: shellEnv,
269
- snapshotPath: snapshotPath ?? undefined,
270
- minimizer,
271
- });
272
+ shellSession = new Shell(shellOptions);
272
273
  shellSessions.set(sessionKey, shellSession);
273
274
  }
275
+ const executionShell = shellSession ?? new Shell(shellOptions);
274
276
  const ownsPersistentSession = shellSession !== undefined;
275
277
  if (ownsPersistentSession) {
276
278
  shellSessionsInUse.add(sessionKey);
@@ -278,13 +280,15 @@ export async function executeBash(command: string, options?: BashExecutorOptions
278
280
  const userSignal = options?.signal;
279
281
  const runAbortController = new AbortController();
280
282
  let abortCleanupPromise: Promise<void> | undefined;
283
+ const abortShell = (): Promise<void> => {
284
+ abortCleanupPromise ??= executionShell.abort().catch(() => undefined);
285
+ return abortCleanupPromise;
286
+ };
281
287
  const abortCurrentExecution = () => {
282
288
  if (!runAbortController.signal.aborted) {
283
289
  runAbortController.abort();
284
290
  }
285
- if (shellSession && !abortCleanupPromise) {
286
- abortCleanupPromise = shellSession.abort().catch(() => undefined);
287
- }
291
+ void abortShell();
288
292
  };
289
293
  const abortDeferred = Promise.withResolvers<"abort">();
290
294
  const abortHandler = () => {
@@ -317,38 +321,20 @@ export async function executeBash(command: string, options?: BashExecutorOptions
317
321
  let resetSession = false;
318
322
 
319
323
  try {
320
- const runPromise = shellSession
321
- ? shellSession.run(
322
- {
323
- command: finalCommand,
324
- cwd: commandCwd,
325
- env: commandEnv,
326
- timeoutMs: nativeTimeoutMs,
327
- signal: runAbortController.signal,
328
- },
329
- (err, chunk) => {
330
- if (!err) {
331
- enqueueChunk(chunk);
332
- }
333
- },
334
- )
335
- : executeShell(
336
- {
337
- command: finalCommand,
338
- cwd: commandCwd,
339
- env: commandEnv,
340
- sessionEnv: shellEnv,
341
- snapshotPath: snapshotPath ?? undefined,
342
- minimizer,
343
- timeoutMs: nativeTimeoutMs,
344
- signal: runAbortController.signal,
345
- },
346
- (err, chunk) => {
347
- if (!err) {
348
- enqueueChunk(chunk);
349
- }
350
- },
351
- );
324
+ const runPromise = executionShell.run(
325
+ {
326
+ command: finalCommand,
327
+ cwd: commandCwd,
328
+ env: commandEnv,
329
+ timeoutMs: nativeTimeoutMs,
330
+ signal: runAbortController.signal,
331
+ },
332
+ (err, chunk) => {
333
+ if (!err) {
334
+ enqueueChunk(chunk);
335
+ }
336
+ },
337
+ );
352
338
 
353
339
  const ey = new ExponentialYield();
354
340
  const winner = await ey.race<
@@ -361,11 +347,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
361
347
 
362
348
  if (winner.kind === "timeout" || winner.kind === "abort") {
363
349
  acceptingChunks = false;
350
+ const cleanupPromise = abortShell();
364
351
  if (shellSession) {
365
352
  resetSession = true;
366
- quarantineShellSession(sessionKey, runPromise, abortCleanupPromise);
353
+ quarantineShellSession(sessionKey, runPromise, cleanupPromise);
367
354
  } else {
368
- void runPromise.catch(() => undefined);
355
+ void Promise.allSettled([runPromise, cleanupPromise]);
369
356
  }
370
357
  return {
371
358
  exitCode: undefined,
@@ -23,6 +23,7 @@ import type { Settings } from "../../config/settings";
23
23
  import type { ExecOptions, ExecResult } from "../../exec/exec";
24
24
  import type { HookUIContext } from "../../extensibility/hooks/types";
25
25
  import type * as PiCodingAgent from "../../index";
26
+ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
26
27
  import type { Theme } from "../../modes/theme/theme";
27
28
  import type { ReadonlySessionManager } from "../../session/session-manager";
28
29
  import type { TodoItem } from "../../tools/todo";
@@ -96,6 +97,8 @@ export interface CustomToolContext {
96
97
  settings?: Settings;
97
98
  /** Fetch implementation for outbound HTTP; defaults to global fetch when omitted. */
98
99
  fetch?: FetchImpl;
100
+ /** Calling session's `local://` root mapping for tools that bridge out of the OMP process. */
101
+ localProtocolOptions?: LocalProtocolOptions;
99
102
  /** Whether to auto-approve all destructive tool operations (--auto-approve CLI flag) */
100
103
  autoApprove?: boolean;
101
104
  }
@@ -7,6 +7,7 @@ import type { KeyId } from "@oh-my-pi/pi-tui";
7
7
  import { logger } from "@oh-my-pi/pi-utils";
8
8
  import type { ModelRegistry } from "../../config/model-registry";
9
9
  import type { Settings } from "../../config/settings";
10
+ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
10
11
  import type { MemoryRuntimeContext } from "../../memory-backend";
11
12
  import { type Theme, theme } from "../../modes/theme/theme";
12
13
  import type { SessionManager } from "../../session/session-manager";
@@ -256,6 +257,7 @@ export class ExtensionRunner {
256
257
  private readonly modelRegistry: ModelRegistry,
257
258
  getMemory?: () => MemoryRuntimeContext | undefined,
258
259
  private readonly settings?: Settings,
260
+ private readonly localProtocolOptions?: LocalProtocolOptions,
259
261
  ) {
260
262
  this.#uiContext = noOpUIContext;
261
263
  this.#getMemoryFn = getMemory;
@@ -538,6 +540,7 @@ export class ExtensionRunner {
538
540
  hasPendingMessages: () => this.#hasPendingMessagesFn(),
539
541
  shutdown: () => this.#shutdownHandler(),
540
542
  getSystemPrompt: () => this.#getSystemPromptFn(),
543
+ localProtocolOptions: this.localProtocolOptions,
541
544
  memory: this.#getMemoryFn?.(),
542
545
  };
543
546
  }
@@ -41,6 +41,7 @@ import type { PythonResult } from "../../eval/py/executor";
41
41
  import type { BashResult } from "../../exec/bash-executor";
42
42
  import type { ExecOptions, ExecResult } from "../../exec/exec";
43
43
  import type * as PiCodingAgent from "../../index";
44
+ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
44
45
  import type { MemoryRuntimeContext } from "../../memory-backend";
45
46
  import type { CustomEditor } from "../../modes/components/custom-editor";
46
47
  import type { Theme } from "../../modes/theme/theme";
@@ -420,6 +421,8 @@ export interface ExtensionContext {
420
421
  sessionManager: ReadonlySessionManager;
421
422
  /** Model registry for API key resolution */
422
423
  modelRegistry: ModelRegistry;
424
+ /** Calling session's `local://` root mapping for external tool bridges. */
425
+ localProtocolOptions?: LocalProtocolOptions;
423
426
  /** Current model (may be undefined) */
424
427
  model: Model | undefined;
425
428
  /** Read-only model query facade: list / current / resolve / family. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Tool wrappers for extensions.
3
3
  */
4
- import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai";
6
6
  import type { Settings } from "../../config/settings";
7
7
  import type { Theme } from "../../modes/theme/theme";
@@ -110,7 +110,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
110
110
  signal?: AbortSignal,
111
111
  onUpdate?: AgentToolUpdateCallback<TDetails, TParameters>,
112
112
  context?: AgentToolContext,
113
- ) {
113
+ ): Promise<AgentToolResult<TDetails, TParameters>> {
114
114
  // 1. Check approval policy (before extension handlers).
115
115
  // CLI `--auto-approve` / `--yolo` sets approval mode to yolo.
116
116
  // User `tools.approval.<tool>` policies are still applied in all modes.
@@ -237,23 +237,23 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
237
237
  const modifiedContent: (TextContent | ImageContent)[] = resultResult.content ?? result.content;
238
238
  const modifiedDetails = (resultResult.details ?? result.details) as TDetails;
239
239
 
240
- // Extension can override error status
241
- if (resultResult.isError === true && !executionError) {
242
- // Extension marks a successful result as error
243
- const textBlocks = (modifiedContent ?? []).filter((c): c is TextContent => c.type === "text");
244
- const errorText = textBlocks.map(t => t.text).join("\n") || "Tool result marked as error by extension";
245
- throw new Error(errorText);
246
- }
247
- if (resultResult.isError === false && executionError) {
248
- // Extension clears the error - return success
249
- return { content: modifiedContent, details: modifiedDetails };
250
- }
240
+ // Effective error state: an explicit handler override wins; otherwise the
241
+ // original execution outcome stands. This lets a handler rewrite a failed
242
+ // call's model-visible content/details while keeping it an error, flip a
243
+ // failure to success, or flag a success as an error.
244
+ const effectiveError = resultResult.isError ?? !!executionError;
251
245
 
252
- // Error status unchanged, but content/details may be modified
253
- if (executionError) {
254
- throw executionError;
255
- }
256
- return { content: modifiedContent, details: modifiedDetails };
246
+ // Return the (possibly modified) result carrying the error flag rather than
247
+ // rethrowing the original exception. The agent loop honors
248
+ // `AgentToolResult.isError` and surfaces it as a tool error on the wire (see
249
+ // `coerceToolResult` in agent-loop), so replacement failure content reaches
250
+ // the model while the call remains an error — the original exception text is
251
+ // no longer forced through, which previously discarded the replacement.
252
+ return {
253
+ content: modifiedContent,
254
+ details: modifiedDetails,
255
+ ...(effectiveError ? { isError: true } : {}),
256
+ };
257
257
  }
258
258
  }
259
259
 
@@ -0,0 +1,91 @@
1
+ import type { Dirent } from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { isEnoent } from "@oh-my-pi/pi-utils";
5
+ import type { GitSource } from "./git-url";
6
+
7
+ interface CommandResult {
8
+ readonly exitCode: number;
9
+ readonly stdout: string;
10
+ readonly stderr: string;
11
+ }
12
+
13
+ async function runCommand(command: string[], cwd: string): Promise<CommandResult> {
14
+ const proc = Bun.spawn(command, {
15
+ cwd,
16
+ stdin: "ignore",
17
+ stdout: "pipe",
18
+ stderr: "pipe",
19
+ windowsHide: true,
20
+ });
21
+ const [exitCode, stdout, stderr] = await Promise.all([
22
+ proc.exited,
23
+ new Response(proc.stdout).text(),
24
+ new Response(proc.stderr).text(),
25
+ ]);
26
+ return { exitCode, stdout, stderr };
27
+ }
28
+
29
+ function normalizeRepositoryUrl(repository: string): string {
30
+ const withoutFragment = repository.replace(/^git\+/i, "").replace(/#.*$/, "");
31
+ const scpLike = withoutFragment.match(/^(?:[^@]+@)?([^:]+):(.+)$/);
32
+ if (scpLike && !withoutFragment.includes("://")) {
33
+ const host = scpLike[1]?.toLowerCase() ?? "";
34
+ const repoPath = (scpLike[2] ?? "").replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
35
+ return `ssh://${host}/${repoPath}`;
36
+ }
37
+
38
+ try {
39
+ const parsed = new URL(withoutFragment);
40
+ const repoPath = parsed.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, "");
41
+ return `${parsed.protocol.toLowerCase()}//${parsed.host.toLowerCase()}/${repoPath}`;
42
+ } catch {
43
+ return withoutFragment.replace(/\/+$/g, "").replace(/\.git$/i, "");
44
+ }
45
+ }
46
+
47
+ /** Fetches current heads and tags into Bun's matching cached bare clone before a plugin update. */
48
+ export async function refreshBunGitCache(source: GitSource, cwd: string): Promise<void> {
49
+ const cacheResult = await runCommand(["bun", "pm", "cache"], cwd);
50
+ if (cacheResult.exitCode !== 0) {
51
+ throw new Error(`bun pm cache failed: ${cacheResult.stderr}`);
52
+ }
53
+ const cacheDir = cacheResult.stdout.trim();
54
+ if (!cacheDir) {
55
+ throw new Error("bun pm cache returned an empty cache path");
56
+ }
57
+
58
+ let entries: Dirent[];
59
+ try {
60
+ entries = await fs.readdir(cacheDir, { withFileTypes: true });
61
+ } catch (err) {
62
+ if (isEnoent(err)) return;
63
+ throw err;
64
+ }
65
+
66
+ const repositoryUrl = normalizeRepositoryUrl(source.repo);
67
+ for (const entry of entries) {
68
+ if (!entry.isDirectory() || !entry.name.endsWith(".git")) continue;
69
+ const repositoryDir = path.join(cacheDir, entry.name);
70
+ const originResult = await runCommand(["git", "-C", repositoryDir, "config", "--get", "remote.origin.url"], cwd);
71
+ if (originResult.exitCode !== 0 || normalizeRepositoryUrl(originResult.stdout.trim()) !== repositoryUrl) continue;
72
+
73
+ const fetchResult = await runCommand(
74
+ [
75
+ "git",
76
+ "-C",
77
+ repositoryDir,
78
+ "fetch",
79
+ "--force",
80
+ "--prune",
81
+ "origin",
82
+ "+refs/heads/*:refs/heads/*",
83
+ "+refs/tags/*:refs/tags/*",
84
+ ],
85
+ cwd,
86
+ );
87
+ if (fetchResult.exitCode !== 0) {
88
+ throw new Error(`Failed to refresh Bun's git cache for ${source.host}/${source.path}: ${fetchResult.stderr}`);
89
+ }
90
+ }
91
+ }
@@ -1135,10 +1135,9 @@ async function realpathOrSelfUncached(p: string): Promise<string> {
1135
1135
  * Extension-local bare dependency entries are also included so their relative
1136
1136
  * children receive the reload mtime tag; bare imports inside those dependencies
1137
1137
  * remain native Bun resolutions to avoid taking over full third-party graphs.
1138
- * CommonJS dependency entries stay native too, with one exception: napi-rs
1139
- * style loaders whose bare requires resolve to `.node` addons are hooked so
1140
- * their requires can be pinned to absolute paths (unresolvable by bare
1141
- * specifier inside `bun build --compile` binaries).
1138
+ * CommonJS modules reached through `require()` stay on Bun's native loader.
1139
+ * The only exception is a module whose bare requires resolve to native addons:
1140
+ * those require a synchronous hook that pins the addon to an absolute path.
1142
1141
  */
1143
1142
  async function collectExtensionModules(entryRealPath: string): Promise<Map<string, string>> {
1144
1143
  const modules = new Map<string, string>();
@@ -1159,32 +1158,40 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
1159
1158
  let source: string;
1160
1159
  try {
1161
1160
  source = await Bun.file(file).text();
1162
- if (nativeAddonLoaderModulePaths.has(file)) {
1163
- // CJS requires cannot await an async onLoad hook. Resolve and
1164
- // rewrite native-addon paths before installing its sync hook.
1165
- source = await rewriteExtensionNativeAddonRequires(source, file);
1166
- }
1167
1161
  } catch {
1168
1162
  continue;
1169
1163
  }
1170
1164
  modules.set(file, source);
1171
1165
  const dir = path.dirname(file);
1172
1166
  const specifiers = new Set<string>();
1167
+ const requiredSpecifiers = new Set<string>();
1173
1168
  for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) {
1174
1169
  if (match[2]) specifiers.add(match[2]);
1175
1170
  }
1176
1171
  for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) {
1177
- if (match[2]) specifiers.add(match[2]);
1172
+ if (match[2]) {
1173
+ specifiers.add(match[2]);
1174
+ requiredSpecifiers.add(match[2]);
1175
+ }
1178
1176
  }
1179
1177
  for (const specifier of specifiers) {
1180
1178
  try {
1181
1179
  let resolved: string | null = null;
1182
1180
  let nextFollowsBareDependencies = followBareDependencies;
1181
+ const isRequired = requiredSpecifiers.has(specifier);
1183
1182
  if (specifier.startsWith(".")) {
1184
1183
  const candidate = Bun.resolveSync(specifier, dir);
1185
- resolved = hasSourceModuleExtension(candidate) ? await realpathOrSelf(candidate) : null;
1184
+ if (
1185
+ hasSourceModuleExtension(candidate) &&
1186
+ (!isRequired || (await moduleRequiresNativeAddon(candidate)))
1187
+ ) {
1188
+ resolved = await realpathOrSelf(candidate);
1189
+ }
1186
1190
  } else if (specifier.startsWith("#")) {
1187
- resolved = await resolvePackageImportSpecifier(specifier, file);
1191
+ const candidate = await resolvePackageImportSpecifier(specifier, file);
1192
+ if (candidate && (!isRequired || (await moduleRequiresNativeAddon(candidate)))) {
1193
+ resolved = candidate;
1194
+ }
1188
1195
  } else if (
1189
1196
  followBareDependencies &&
1190
1197
  isBareExtensionDependencySpecifier(specifier) &&
@@ -1206,14 +1213,17 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
1206
1213
  isHookableEntry && isCommonJsEntry && dependencyEntry
1207
1214
  ? await moduleRequiresNativeAddon(dependencyEntry)
1208
1215
  : false;
1209
- if (isHookableEntry && dependencyEntry && (!isCommonJsEntry || hookCommonJsEntry)) {
1216
+ if (isHookableEntry && dependencyEntry && ((!isRequired && !isCommonJsEntry) || hookCommonJsEntry)) {
1210
1217
  resolved = await realpathOrSelf(dependencyEntry);
1211
- if (hookCommonJsEntry) {
1212
- nativeAddonLoaderModulePaths.add(resolved);
1213
- }
1218
+ }
1219
+ if (resolved && hookCommonJsEntry) {
1220
+ nativeAddonLoaderModulePaths.add(resolved);
1214
1221
  }
1215
1222
  nextFollowsBareDependencies = false;
1216
1223
  }
1224
+ if (resolved && isRequired) {
1225
+ nativeAddonLoaderModulePaths.add(resolved);
1226
+ }
1217
1227
  if (resolved && !modules.has(resolved)) {
1218
1228
  const queuedFollowsBareDependencies = queuedFollowBareDependencies.get(resolved) ?? false;
1219
1229
  const mergedFollowsBareDependencies = queuedFollowsBareDependencies || nextFollowsBareDependencies;
@@ -1225,6 +1235,12 @@ async function collectExtensionModules(entryRealPath: string): Promise<Map<strin
1225
1235
  }
1226
1236
  }
1227
1237
  }
1238
+ for (const modulePath of nativeAddonLoaderModulePaths) {
1239
+ const source = modules.get(modulePath);
1240
+ if (source !== undefined) {
1241
+ modules.set(modulePath, await rewriteExtensionNativeAddonRequires(source, modulePath));
1242
+ }
1243
+ }
1228
1244
  return modules;
1229
1245
  }
1230
1246
 
@@ -12,6 +12,7 @@ import {
12
12
  logger,
13
13
  } from "@oh-my-pi/pi-utils";
14
14
  import { withExitGuard } from "../utils";
15
+ import { refreshBunGitCache } from "./bun-git-cache";
15
16
  import { type GitSource, parseGitUrl } from "./git-url";
16
17
  import { installLegacyPiSpecifierShim, loadLegacyPiModule } from "./legacy-pi-compat";
17
18
  import { resolvePluginManifestEntries } from "./loader";
@@ -211,6 +212,16 @@ export class PluginManager {
211
212
  }
212
213
  }
213
214
 
215
+ async #removeDependencyEntry(pkgJsonPath: string, name: string): Promise<void> {
216
+ const pkgJson: { dependencies?: Record<string, string>; [key: string]: unknown } =
217
+ await Bun.file(pkgJsonPath).json();
218
+ if (!pkgJson.dependencies || !(name in pkgJson.dependencies)) {
219
+ return;
220
+ }
221
+ delete pkgJson.dependencies[name];
222
+ await Bun.write(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
223
+ }
224
+
214
225
  #collectInstalledNames(deps: Record<string, string>, config: PluginRuntimeConfig): Set<string> {
215
226
  const installedNames = new Set<string>();
216
227
  for (const name of Object.keys(deps)) {
@@ -450,6 +461,17 @@ export class PluginManager {
450
461
  // validation throws.
451
462
  let actualName: string | undefined;
452
463
  try {
464
+ // Bun treats a dependency replacement from `repo#old-ref` to the same
465
+ // package at `repo`/`repo#new-ref` as a self-edge and bails with
466
+ // DependencyLoop. Remove only the stale manifest edge; rollback restores
467
+ // the original package.json and node_modules snapshot on failure.
468
+ if (gitSource && existingActualName) {
469
+ const installedSource = parseGitUrl(depsBefore[existingActualName] ?? "");
470
+ if (installedSource && installedSource.ref !== gitSource.ref) {
471
+ await this.#removeDependencyEntry(pkgJsonPath, existingActualName);
472
+ }
473
+ }
474
+
453
475
  // Step 1: write the spec into plugins/package.json + node_modules.
454
476
  const installProc = Bun.spawn(["bun", "install", packageInstallSpec], {
455
477
  cwd: getPluginsDir(),
@@ -500,14 +522,13 @@ export class PluginManager {
500
522
 
501
523
  // Step 2: refresh the git lockfile pin when re-installing an existing
502
524
  // git plugin. `bun install <spec>` is a no-op when the spec matches the
503
- // lockfile entry it never re-resolves the remote ref — so re-running
504
- // `omp plugin install github:owner/repo` would silently keep the user on
505
- // the original resolved commit even after upstream moved (#3063).
506
- // `bun update <name>` re-resolves the ref against the remote and
507
- // rewrites the pin; SHA-pinned refs stay put because the commit can't
508
- // move. First-time installs skip this — the initial `bun install` already
509
- // fetched HEAD. Rollback is handled by the outer catch.
525
+ // lockfile entry, while `bun update <name>` resolves through Bun's bare
526
+ // clone cache. Fetch the matching cache clone first so a stale cached
527
+ // ref cannot silently preserve the old pin (#3063, #5401). First-time
528
+ // installs skip this because the initial `bun install` populated the
529
+ // cache from the remote. Rollback is handled by the outer catch.
510
530
  if (gitSource && existingActualName) {
531
+ await refreshBunGitCache(gitSource, getPluginsDir());
511
532
  const updateProc = Bun.spawn(["bun", "update", actualName], {
512
533
  cwd: getPluginsDir(),
513
534
  stdin: "ignore",