@oh-my-pi/pi-coding-agent 16.5.1 → 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 (87) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3442 -3408
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  5. package/dist/types/eval/backend.d.ts +3 -3
  6. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  7. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  8. package/dist/types/goals/guided-setup.d.ts +12 -0
  9. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  10. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  13. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  15. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  16. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  18. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/types.d.ts +1 -0
  22. package/dist/types/session/messages.d.ts +15 -0
  23. package/dist/types/tools/grep.d.ts +0 -2
  24. package/dist/types/tools/read.d.ts +0 -4
  25. package/package.json +12 -12
  26. package/src/advisor/__tests__/advisor.test.ts +136 -49
  27. package/src/advisor/runtime.ts +16 -34
  28. package/src/autoresearch/dashboard.ts +2 -2
  29. package/src/cli/config-cli.ts +15 -3
  30. package/src/config/settings-schema.ts +10 -0
  31. package/src/cursor.ts +2 -0
  32. package/src/discovery/claude-plugins.ts +9 -3
  33. package/src/discovery/omp-plugins.ts +6 -2
  34. package/src/discovery/substitute-plugin-root.ts +32 -0
  35. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  36. package/src/eval/backend.ts +3 -3
  37. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  38. package/src/eval/py/prelude.py +28 -1
  39. package/src/exec/bash-executor.ts +30 -43
  40. package/src/extensibility/extensions/wrapper.ts +18 -18
  41. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  43. package/src/extensibility/plugins/manager.ts +7 -7
  44. package/src/goals/guided-setup.ts +29 -1
  45. package/src/internal-urls/history-protocol.ts +95 -15
  46. package/src/internal-urls/registry-helpers.ts +50 -1
  47. package/src/launch/broker.ts +38 -25
  48. package/src/mcp/oauth-discovery.ts +20 -1
  49. package/src/mcp/oauth-flow.ts +3 -1
  50. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  51. package/src/modes/components/agent-dashboard.ts +2 -2
  52. package/src/modes/components/agent-hub.ts +15 -2
  53. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  54. package/src/modes/components/chat-transcript-builder.ts +4 -3
  55. package/src/modes/components/dynamic-border.ts +9 -6
  56. package/src/modes/components/extensions/extension-list.ts +2 -2
  57. package/src/modes/components/hook-selector.ts +10 -4
  58. package/src/modes/components/login-dialog.ts +5 -0
  59. package/src/modes/components/mcp-add-wizard.ts +5 -0
  60. package/src/modes/components/plan-review-overlay.ts +11 -11
  61. package/src/modes/components/read-tool-group.ts +1 -8
  62. package/src/modes/controllers/input-controller.ts +4 -2
  63. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  64. package/src/modes/controllers/selector-controller.ts +9 -2
  65. package/src/modes/controllers/todo-command-controller.ts +18 -14
  66. package/src/modes/interactive-mode.ts +7 -3
  67. package/src/modes/prompt-action-autocomplete.ts +6 -1
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/system-prompt.md +1 -0
  70. package/src/prompts/tools/eval.md +2 -2
  71. package/src/prompts/tools/grep.md +1 -2
  72. package/src/prompts/tools/read.md +2 -4
  73. package/src/sdk.ts +28 -31
  74. package/src/session/agent-session.ts +44 -22
  75. package/src/session/messages.test.ts +66 -0
  76. package/src/session/messages.ts +37 -0
  77. package/src/system-prompt.test.ts +36 -0
  78. package/src/system-prompt.ts +1 -1
  79. package/src/tools/browser/registry.ts +17 -3
  80. package/src/tools/eval.ts +14 -9
  81. package/src/tools/gh.ts +3 -1
  82. package/src/tools/grep.ts +5 -45
  83. package/src/tools/path-utils.ts +7 -1
  84. package/src/tools/read.ts +23 -74
  85. package/src/utils/title-generator.ts +10 -6
  86. package/src/web/search/providers/perplexity-auth.ts +20 -11
  87. package/src/web/search/providers/perplexity.ts +14 -2
@@ -532,10 +532,13 @@ export class AdvisorRuntime {
532
532
  // as a failed turn so endpoint rejections trip the retry path.
533
533
  const promptError = this.agent.state.error;
534
534
  if (promptError) throw new Error(promptError);
535
- const emptyResponseError = getAdvisorEmptyResponseError(
536
- this.agent.state.messages.slice(messageSnapshot),
537
- );
538
- if (emptyResponseError) throw emptyResponseError;
535
+ // A content-less stop is a deliberate silent review — the documented
536
+ // verifier behavior ("prefer silence when the agent is on track") — and
537
+ // completes the turn. Sessions can legitimately have nothing to advise
538
+ // on for any number of consecutive turns, so silence is never warned
539
+ // about (#5216 did, spamming "Advisor unavailable" at quiet models).
540
+ const turnError = getAdvisorTurnError(this.agent.state.messages.slice(messageSnapshot));
541
+ if (turnError) throw turnError;
539
542
  success = true;
540
543
  this.#consecutiveFailures = 0;
541
544
  this.#failureNotified = false;
@@ -594,36 +597,15 @@ export class AdvisorRuntime {
594
597
  }
595
598
  }
596
599
 
597
- function getAdvisorEmptyResponseError(messages: readonly AgentMessage[]): Error | undefined {
598
- let sawAssistant = false;
599
- for (const message of messages) {
600
- if (message.role !== "assistant") continue;
601
- sawAssistant = true;
602
- if (message.stopReason !== "stop") return undefined;
603
- if (hasAdvisorResponseContent(message)) return undefined;
604
- }
605
- if (sawAssistant) return new Error("Advisor turn returned an empty stop response without advice");
606
- if (messages.length > 0) return new Error("Advisor turn ended without an assistant response");
607
- return undefined;
608
- }
609
-
610
- function hasAdvisorResponseContent(message: AssistantMessage): boolean {
611
- return message.content.some(block => {
612
- switch (block.type) {
613
- case "text":
614
- return block.text.trim().length > 0;
615
- case "thinking":
616
- return block.thinking.trim().length > 0;
617
- case "redactedThinking":
618
- return block.data.length > 0;
619
- case "toolCall":
620
- return block.name.trim().length > 0;
621
- case "fallback":
622
- return false;
623
- default:
624
- return false;
625
- }
626
- });
600
+ /**
601
+ * The only malformed advisor turn shape: the prompt resolved but produced no
602
+ * assistant response at all. Everything an assistant message carries advice,
603
+ * reasoning, or deliberate silence (empty `stop`) — is a completed review.
604
+ */
605
+ function getAdvisorTurnError(messages: readonly AgentMessage[]): Error | undefined {
606
+ if (messages.length === 0) return undefined;
607
+ if (messages.some(message => message.role === "assistant")) return undefined;
608
+ return new Error("Advisor turn ended without an assistant response");
627
609
  }
628
610
 
629
611
  type TextualContent = string | readonly (TextContent | ImageContent)[];
@@ -95,9 +95,9 @@ export function createDashboardController(): DashboardController {
95
95
  done(undefined);
96
96
  return;
97
97
  }
98
- if (matchesKey(data, "up") || data === "k") {
98
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
99
99
  scrollOffset = Math.max(0, scrollOffset - 1);
100
- } else if (matchesKey(data, "down") || data === "j") {
100
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
101
101
  scrollOffset = Math.min(maxScroll, scrollOffset + 1);
102
102
  } else if (matchesKey(data, "pageUp")) {
103
103
  scrollOffset = Math.max(0, scrollOffset - viewportRows);
@@ -241,7 +241,7 @@ export async function runConfigCommand(cmd: ConfigCommandArgs): Promise<void> {
241
241
 
242
242
  switch (cmd.action) {
243
243
  case "list":
244
- handleList(cmd.flags);
244
+ await handleList(cmd.flags);
245
245
  break;
246
246
  case "get":
247
247
  handleGet(cmd.key, cmd.flags);
@@ -261,7 +261,19 @@ export async function runConfigCommand(cmd: ConfigCommandArgs): Promise<void> {
261
261
  }
262
262
  }
263
263
 
264
- function handleList(flags: { json?: boolean }): void {
264
+ async function writeStdout(text: string): Promise<void> {
265
+ const pending = Promise.withResolvers<void>();
266
+ process.stdout.write(text, error => {
267
+ if (error) {
268
+ pending.reject(error);
269
+ return;
270
+ }
271
+ pending.resolve();
272
+ });
273
+ await pending.promise;
274
+ }
275
+
276
+ async function handleList(flags: { json?: boolean }): Promise<void> {
265
277
  const defs = ALL_SETTING_PATHS.map(path => findSettingDef(path)).filter((def): def is CliSettingDef => !!def);
266
278
 
267
279
  if (flags.json) {
@@ -273,7 +285,7 @@ function handleList(flags: { json?: boolean }): void {
273
285
  description: def.description,
274
286
  };
275
287
  }
276
- console.log(JSON.stringify(result, null, 2));
288
+ await writeStdout(`${JSON.stringify(result, null, 2)}\n`);
277
289
  return;
278
290
  }
279
291
 
@@ -3592,6 +3592,16 @@ export const SETTINGS_SCHEMA = {
3592
3592
  description: "Enable the tts tool for on-device (Kokoro) or xAI Grok Voice speech-file synthesis",
3593
3593
  },
3594
3594
  },
3595
+ "generate_image.enabled": {
3596
+ type: "boolean",
3597
+ default: true,
3598
+ ui: {
3599
+ tab: "tools",
3600
+ group: "Available Tools",
3601
+ label: "Generate Image",
3602
+ description: "Enable the generate_image tool for text-to-image generation and editing",
3603
+ },
3604
+ },
3595
3605
 
3596
3606
  "inspect_image.enabled": {
3597
3607
  type: "boolean",
package/src/cursor.ts CHANGED
@@ -93,6 +93,7 @@ async function executeTool(
93
93
  result = buildToolErrorResult(message);
94
94
  isError = true;
95
95
  }
96
+ isError ||= result.isError === true;
96
97
 
97
98
  const sanitizedFinalResult: AgentToolResult<unknown> = {
98
99
  content: result.content.map(c => (c.type === "text" ? { ...c, text: sanitizeText(c.text) } : c)),
@@ -283,6 +284,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
283
284
  result = buildToolErrorResult(message);
284
285
  isError = true;
285
286
  }
287
+ isError ||= result.isError === true;
286
288
 
287
289
  // onUpdate may not fire for every chunk — flush any remaining output
288
290
  // from the final result that wasn't already streamed.
@@ -24,7 +24,7 @@ import {
24
24
  scanSkillsFromDir,
25
25
  } from "./helpers";
26
26
 
27
- import { substitutePluginRoot } from "./substitute-plugin-root";
27
+ import { resolvePluginStdioPaths, substitutePluginRoot } from "./substitute-plugin-root";
28
28
 
29
29
  const PROVIDER_ID = "claude-plugins";
30
30
  const DISPLAY_NAME = "Claude Code Marketplace";
@@ -441,14 +441,20 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
441
441
  continue;
442
442
  }
443
443
  const namespacedName = root.plugin ? `${root.plugin}:${serverName}` : serverName;
444
+ const substitutedCommand =
445
+ raw.command !== undefined ? substitutePluginRoot(raw.command, root.path) : undefined;
446
+ const substitutedCwd = raw.cwd !== undefined ? substitutePluginRoot(raw.cwd, root.path) : undefined;
447
+ // Root relative command/cwd at the plugin's config directory, not the
448
+ // session cwd (MCP stdio spawning resolves relative values there).
449
+ const rooted = resolvePluginStdioPaths({ command: substitutedCommand, cwd: substitutedCwd }, root.path);
444
450
  const server: MCPServer = {
445
451
  name: namespacedName,
446
452
  ...(raw.enabled !== undefined && { enabled: raw.enabled }),
447
453
  ...(raw.timeout !== undefined && { timeout: raw.timeout }),
448
- ...(raw.command !== undefined && { command: substitutePluginRoot(raw.command, root.path) }),
454
+ ...(rooted.command !== undefined && { command: rooted.command }),
449
455
  ...(raw.args !== undefined && { args: substitutePluginRoot(raw.args, root.path) }),
450
456
  ...(raw.env !== undefined && { env: substitutePluginRoot(raw.env, root.path) }),
451
- ...(raw.cwd !== undefined && { cwd: substitutePluginRoot(raw.cwd, root.path) }),
457
+ ...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
452
458
  ...(raw.url !== undefined && { url: expandEnvVarsDeep(raw.url) }),
453
459
  ...(raw.headers !== undefined && { headers: expandEnvVarsDeep(raw.headers) }),
454
460
  ...(raw.auth !== undefined && { auth: raw.auth }),
@@ -30,6 +30,7 @@ import { type CustomTool, toolCapability } from "../capability/tool";
30
30
  import type { LoadContext, LoadResult } from "../capability/types";
31
31
  import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
32
32
  import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots";
33
+ import { resolvePluginStdioPaths } from "./substitute-plugin-root";
33
34
 
34
35
  const PROVIDER_ID = "omp-plugins";
35
36
  const DISPLAY_NAME = "OMP Extension Packages";
@@ -301,14 +302,17 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
301
302
  warnings.push(`[omp-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
302
303
  continue;
303
304
  }
305
+ // Root relative command/cwd at the plugin's config directory, not the
306
+ // session cwd (MCP stdio spawning resolves relative values there).
307
+ const rooted = resolvePluginStdioPaths({ command: cfg.command, cwd: cfg.cwd }, root.path);
304
308
  items.push({
305
309
  name: serverName,
306
310
  ...(cfg.enabled !== undefined && { enabled: cfg.enabled }),
307
311
  ...(cfg.timeout !== undefined && { timeout: cfg.timeout }),
308
- ...(cfg.command !== undefined && { command: cfg.command }),
312
+ ...(rooted.command !== undefined && { command: rooted.command }),
309
313
  ...(cfg.args !== undefined && { args: cfg.args }),
310
314
  ...(cfg.env !== undefined && { env: cfg.env }),
311
- ...(cfg.cwd !== undefined && { cwd: cfg.cwd }),
315
+ ...(rooted.cwd !== undefined && { cwd: rooted.cwd }),
312
316
  ...(cfg.url !== undefined && { url: cfg.url }),
313
317
  ...(cfg.headers !== undefined && { headers: cfg.headers }),
314
318
  ...(cfg.auth !== undefined && { auth: cfg.auth }),
@@ -1,3 +1,5 @@
1
+ import * as path from "node:path";
2
+
1
3
  /**
2
4
  * Recursively substitute ${CLAUDE_PLUGIN_ROOT} and ${OMP_PLUGIN_ROOT}
3
5
  * with the actual plugin root path in strings, arrays, and plain objects.
@@ -27,3 +29,33 @@ export function substitutePluginRoot<T>(value: T, rootPath: string): T {
27
29
  }
28
30
  return value;
29
31
  }
32
+
33
+ /**
34
+ * Rebase relative filesystem values in a discovered plugin stdio config against
35
+ * the directory of the `.mcp.json` that declared them.
36
+ *
37
+ * External plugin configs (bundled ChatGPT/Codex plugins, Claude marketplace
38
+ * plugins) express `command`/`cwd` relative to their own config file, but MCP
39
+ * stdio spawning roots relative values at the session cwd — so a plugin shipping
40
+ * `command: "./bin/server"`, `cwd: "."` launches from the wrong directory and
41
+ * fails with ENOENT. This resolves those against `configDir` instead:
42
+ *
43
+ * - relative `cwd` → resolved against `configDir`;
44
+ * - path-like `command` (`./`, `../`, or the Windows `.\`/`..\` forms) →
45
+ * resolved against `configDir`;
46
+ * - bare executables (`npx`, `uvx`, …) and absolute paths are left untouched.
47
+ */
48
+ export function resolvePluginStdioPaths(
49
+ config: { command?: string; cwd?: string },
50
+ configDir: string,
51
+ ): { command?: string; cwd?: string } {
52
+ const resolved: { command?: string; cwd?: string } = {};
53
+ if (typeof config.cwd === "string") {
54
+ resolved.cwd = path.isAbsolute(config.cwd) ? config.cwd : path.resolve(configDir, config.cwd);
55
+ }
56
+ if (config.command !== undefined) {
57
+ const isPathLike = /^\.\.?[/\\]/.test(config.command);
58
+ resolved.command = isPathLike ? path.resolve(configDir, config.command) : config.command;
59
+ }
60
+ return resolved;
61
+ }
@@ -105,3 +105,23 @@ describe("eval js agent() handle", () => {
105
105
  expect("branchName" in node).toBe(false);
106
106
  });
107
107
  });
108
+
109
+ describe("eval js read() URI delegation", () => {
110
+ it("appends line selectors to delegated URI paths", async () => {
111
+ const calls: Array<{ name: string; args: unknown }> = [];
112
+ const sandbox = loadPrelude(async (name, args) => {
113
+ calls.push({ name, args });
114
+ return { text: "resource contents" };
115
+ });
116
+
117
+ const result = await vm.runInContext(`read("mcp://server/resource", { offset: 10, limit: 5 })`, sandbox);
118
+
119
+ expect(result).toBe("resource contents");
120
+ expect(calls).toEqual([
121
+ {
122
+ name: "read",
123
+ args: { path: "mcp://server/resource:10-14" },
124
+ },
125
+ ]);
126
+ });
127
+ });
@@ -15,10 +15,10 @@ export interface ExecutorBackendExecOptions {
15
15
  * driven entirely by `signal`, which the eval tool arms as a watchdog that
16
16
  * pauses on bridge timeout-control status events and fires a `TimeoutError`
17
17
  * reason only while the Python/JS runtime owns control. Backends use this
18
- * value only for timeout-annotation text and as cold-start headroom; they MUST
19
- * NOT derive a competing wall-clock timer from it.
18
+ * value only for timeout-annotation text and as cold-start headroom; undefined
19
+ * disables the cell timeout. Backends MUST NOT derive a competing wall-clock timer from it.
20
20
  */
21
- idleTimeoutMs: number;
21
+ idleTimeoutMs?: number;
22
22
  reset: boolean;
23
23
  onChunk: (chunk: string) => void;
24
24
  /**
@@ -1,6 +1,30 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { PYTHON_PRELUDE } from "../prelude";
3
3
 
4
+ const pythonPath = Bun.env.PYTHON ?? "python3";
5
+
6
+ async function runPrelude(
7
+ code: string,
8
+ env: Record<string, string>,
9
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
10
+ const prelude = PYTHON_PRELUDE.replace(
11
+ "from __future__ import annotations",
12
+ "from __future__ import annotations\n__omp_display = lambda *args, **kwargs: None",
13
+ );
14
+ const script = `${prelude}\n${code}`;
15
+ const proc = Bun.spawn([pythonPath, "-c", script], {
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ env: { ...process.env, ...env },
19
+ });
20
+ const [stdout, stderr, exitCode] = await Promise.all([
21
+ new Response(proc.stdout).text(),
22
+ new Response(proc.stderr).text(),
23
+ proc.exited,
24
+ ]);
25
+ return { stdout, stderr, exitCode };
26
+ }
27
+
4
28
  describe("python prelude", () => {
5
29
  it("exposes read(path, offset?, limit?) with positional optional args", () => {
6
30
  // The eval docs advertise `read(path, offset?=1, limit?=None)`. A
@@ -17,6 +41,54 @@ describe("python prelude", () => {
17
41
  expect(signature).toContain("limit");
18
42
  });
19
43
 
44
+ it("appends line selectors to delegated URI paths", async () => {
45
+ const requests: unknown[] = [];
46
+ const server = Bun.serve({
47
+ hostname: "127.0.0.1",
48
+ port: 0,
49
+ fetch: async request => {
50
+ requests.push(await request.json());
51
+ return Response.json({
52
+ ok: true,
53
+ value: { text: "resource contents", details: { resolvedPath: "/tmp/resource.txt" } },
54
+ });
55
+ },
56
+ });
57
+
58
+ try {
59
+ const result = await runPrelude(
60
+ [`print(read("artifact://21", 3, 2))`, `print(read("mcp://server/resource", 10, 5))`].join("\n"),
61
+ {
62
+ PI_TOOL_BRIDGE_URL: server.url.toString(),
63
+ PI_TOOL_BRIDGE_TOKEN: "test-token",
64
+ PI_TOOL_BRIDGE_SESSION: "test-session",
65
+ },
66
+ );
67
+
68
+ expect(result).toEqual({
69
+ stdout: "resource contents\nresource contents\n",
70
+ stderr: "",
71
+ exitCode: 0,
72
+ });
73
+ expect(requests).toEqual([
74
+ {
75
+ session: "test-session",
76
+ run: null,
77
+ name: "read",
78
+ args: { path: "artifact://21:3-4" },
79
+ },
80
+ {
81
+ session: "test-session",
82
+ run: null,
83
+ name: "read",
84
+ args: { path: "mcp://server/resource:10-14" },
85
+ },
86
+ ]);
87
+ } finally {
88
+ server.stop(true);
89
+ }
90
+ });
91
+
20
92
  it("exposes isolation artifacts on the agent() handle node", () => {
21
93
  // agent(..., handle=True) is the only escape hatch for
22
94
  // recovering apply=False patch/branch/nested artifacts (the bare
@@ -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)
@@ -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,
@@ -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