@oh-my-pi/pi-coding-agent 15.10.4 → 15.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/types/capability/rule-buckets.d.ts +1 -1
  3. package/dist/types/capability/rule.d.ts +6 -1
  4. package/dist/types/cli/update-cli.d.ts +11 -1
  5. package/dist/types/config/model-registry.d.ts +18 -1
  6. package/dist/types/discovery/at-imports.d.ts +15 -0
  7. package/dist/types/edit/diff.d.ts +3 -2
  8. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
  9. package/dist/types/eval/backend.d.ts +7 -0
  10. package/dist/types/eval/js/context-manager.d.ts +1 -0
  11. package/dist/types/eval/js/executor.d.ts +2 -0
  12. package/dist/types/eval/js/index.d.ts +1 -1
  13. package/dist/types/eval/js/shared/helpers.d.ts +6 -0
  14. package/dist/types/eval/js/shared/runtime.d.ts +5 -0
  15. package/dist/types/eval/js/worker-protocol.d.ts +6 -0
  16. package/dist/types/eval/py/executor.d.ts +7 -0
  17. package/dist/types/eval/py/index.d.ts +1 -1
  18. package/dist/types/exa/index.d.ts +1 -19
  19. package/dist/types/exa/mcp-client.d.ts +10 -3
  20. package/dist/types/exa/types.d.ts +0 -83
  21. package/dist/types/export/ttsr.d.ts +14 -0
  22. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  23. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  24. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  25. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  26. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  27. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  28. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  29. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  30. package/dist/types/modes/controllers/mcp-command-controller.d.ts +8 -0
  31. package/dist/types/modes/image-references.d.ts +8 -3
  32. package/dist/types/modes/interactive-mode.d.ts +9 -1
  33. package/dist/types/modes/theme/theme.d.ts +2 -1
  34. package/dist/types/modes/types.d.ts +3 -1
  35. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  36. package/dist/types/session/agent-session.d.ts +0 -2
  37. package/dist/types/task/render.d.ts +1 -0
  38. package/dist/types/tools/ask.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  40. package/dist/types/tools/index.d.ts +17 -2
  41. package/dist/types/tools/render-utils.d.ts +1 -1
  42. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  43. package/dist/types/utils/block-context.d.ts +35 -0
  44. package/dist/types/utils/git.d.ts +6 -0
  45. package/dist/types/utils/image-loading.d.ts +12 -0
  46. package/package.json +29 -9
  47. package/src/capability/rule-buckets.ts +4 -2
  48. package/src/capability/rule.ts +10 -1
  49. package/src/cli/auth-broker-cli.ts +6 -7
  50. package/src/cli/auth-gateway-cli.ts +4 -3
  51. package/src/cli/list-models.ts +5 -0
  52. package/src/cli/update-cli.ts +138 -16
  53. package/src/commit/agentic/tools/split-commit.ts +8 -1
  54. package/src/config/model-provider-priority.ts +1 -0
  55. package/src/config/model-registry.ts +81 -2
  56. package/src/debug/index.ts +4 -8
  57. package/src/discovery/at-imports.ts +273 -0
  58. package/src/discovery/builtin-rules/index.ts +4 -0
  59. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  60. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  61. package/src/discovery/helpers.ts +2 -1
  62. package/src/edit/diff.ts +114 -4
  63. package/src/edit/hashline/diff.ts +1 -1
  64. package/src/edit/hashline/execute.ts +1 -1
  65. package/src/edit/modes/patch.ts +6 -2
  66. package/src/edit/modes/replace.ts +1 -1
  67. package/src/edit/renderer.ts +12 -2
  68. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  69. package/src/eval/backend.ts +15 -0
  70. package/src/eval/js/context-manager.ts +4 -2
  71. package/src/eval/js/executor.ts +3 -0
  72. package/src/eval/js/index.ts +7 -1
  73. package/src/eval/js/shared/helpers.ts +53 -6
  74. package/src/eval/js/shared/runtime.ts +8 -0
  75. package/src/eval/js/worker-core.ts +1 -0
  76. package/src/eval/js/worker-protocol.ts +6 -0
  77. package/src/eval/py/executor.ts +12 -0
  78. package/src/eval/py/index.ts +7 -1
  79. package/src/eval/py/prelude.py +43 -4
  80. package/src/eval/py/runner.py +1 -0
  81. package/src/exa/index.ts +1 -26
  82. package/src/exa/mcp-client.ts +10 -10
  83. package/src/exa/types.ts +0 -97
  84. package/src/export/ttsr.ts +122 -1
  85. package/src/extensibility/extensions/types.ts +8 -1
  86. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  87. package/src/extensibility/plugins/doctor.ts +1 -1
  88. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  89. package/src/goals/tools/goal-tool.ts +1 -1
  90. package/src/internal-urls/docs-index.generated.ts +7 -6
  91. package/src/internal-urls/local-protocol.ts +13 -0
  92. package/src/lsp/render.ts +8 -6
  93. package/src/mcp/oauth-flow.ts +3 -3
  94. package/src/mcp/render.ts +7 -1
  95. package/src/modes/components/agent-dashboard.ts +6 -4
  96. package/src/modes/components/custom-editor.ts +12 -6
  97. package/src/modes/components/login-dialog.ts +1 -1
  98. package/src/modes/components/oauth-selector.ts +4 -4
  99. package/src/modes/components/read-tool-group.ts +10 -3
  100. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  101. package/src/modes/components/status-line/index.ts +1 -0
  102. package/src/modes/components/status-line/types.ts +23 -8
  103. package/src/modes/components/tool-execution.ts +1 -1
  104. package/src/modes/components/transcript-container.ts +17 -10
  105. package/src/modes/components/user-message.ts +6 -3
  106. package/src/modes/components/welcome.ts +1 -1
  107. package/src/modes/controllers/event-controller.ts +8 -0
  108. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  109. package/src/modes/controllers/input-controller.ts +60 -11
  110. package/src/modes/controllers/mcp-command-controller.ts +52 -17
  111. package/src/modes/controllers/selector-controller.ts +4 -11
  112. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  113. package/src/modes/image-references.ts +13 -7
  114. package/src/modes/interactive-mode.ts +35 -3
  115. package/src/modes/rpc/rpc-mode.ts +1 -1
  116. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  117. package/src/modes/theme/theme.ts +95 -1
  118. package/src/modes/types.ts +3 -1
  119. package/src/modes/utils/ui-helpers.ts +14 -5
  120. package/src/prompts/tools/bash.md +1 -1
  121. package/src/prompts/tools/eval.md +4 -4
  122. package/src/sdk.ts +31 -14
  123. package/src/session/agent-session.ts +290 -196
  124. package/src/session/session-manager.ts +1 -1
  125. package/src/slash-commands/builtin-registry.ts +9 -1
  126. package/src/system-prompt.ts +15 -9
  127. package/src/task/index.ts +9 -1
  128. package/src/task/render.ts +36 -14
  129. package/src/tools/ask.ts +14 -5
  130. package/src/tools/bash-interactive.ts +1 -1
  131. package/src/tools/bash.ts +14 -2
  132. package/src/tools/browser/render.ts +5 -2
  133. package/src/tools/browser/tab-worker.ts +211 -91
  134. package/src/tools/debug.ts +5 -2
  135. package/src/tools/eval-render.ts +6 -3
  136. package/src/tools/eval.ts +1 -1
  137. package/src/tools/gh-renderer.ts +29 -15
  138. package/src/tools/index.ts +32 -4
  139. package/src/tools/inspect-image-renderer.ts +12 -5
  140. package/src/tools/job.ts +9 -6
  141. package/src/tools/memory-render.ts +19 -5
  142. package/src/tools/read.ts +165 -18
  143. package/src/tools/render-utils.ts +3 -1
  144. package/src/tools/resolve.ts +1 -1
  145. package/src/tools/review.ts +1 -1
  146. package/src/tools/ssh.ts +4 -1
  147. package/src/tools/todo.ts +8 -1
  148. package/src/tools/tool-timeouts.ts +1 -1
  149. package/src/tools/write.ts +1 -1
  150. package/src/tui/code-cell.ts +1 -1
  151. package/src/utils/block-context.ts +312 -0
  152. package/src/utils/git.ts +41 -0
  153. package/src/utils/image-loading.ts +31 -1
  154. package/src/web/search/providers/codex.ts +1 -1
  155. package/src/web/search/render.ts +14 -6
  156. package/dist/types/exa/factory.d.ts +0 -13
  157. package/dist/types/exa/render.d.ts +0 -19
  158. package/dist/types/exa/researcher.d.ts +0 -9
  159. package/dist/types/exa/search.d.ts +0 -9
  160. package/dist/types/exa/websets.d.ts +0 -9
  161. package/src/exa/factory.ts +0 -60
  162. package/src/exa/render.ts +0 -244
  163. package/src/exa/researcher.ts +0 -36
  164. package/src/exa/search.ts +0 -47
  165. package/src/exa/websets.ts +0 -248
@@ -260,6 +260,7 @@ function renderEditHeader(
260
260
  uiTheme: Theme,
261
261
  options: {
262
262
  icon: "pending" | "success" | "error";
263
+ iconOverride?: string;
263
264
  spinnerFrame?: number;
264
265
  op?: Operation;
265
266
  rawPath: string;
@@ -279,8 +280,16 @@ function renderEditHeader(
279
280
  const formatted = formatEditDescription(options.rawPath, uiTheme, descriptionOptions);
280
281
  const suffix = `${options.statsSuffix ?? ""}${options.extraSuffix ?? ""}`;
281
282
  const buildHeader = (description: string): string =>
282
- renderStatusLine({ icon: options.icon, spinnerFrame: options.spinnerFrame, title, description }, uiTheme) +
283
- suffix;
283
+ renderStatusLine(
284
+ {
285
+ icon: options.icon,
286
+ iconOverride: options.iconOverride,
287
+ spinnerFrame: options.spinnerFrame,
288
+ title,
289
+ description,
290
+ },
291
+ uiTheme,
292
+ ) + suffix;
284
293
 
285
294
  const header = buildHeader(formatted.description);
286
295
  const overflow = visibleWidth(header) - editHeaderLabelBudget(width, uiTheme);
@@ -633,6 +642,7 @@ function renderSingleFileResult(
633
642
  const statsSuffix = headerDiff ? formatDiffStatsSuffix(headerDiff, uiTheme) : "";
634
643
  const header = renderEditHeader(width, uiTheme, {
635
644
  icon: isError ? "error" : "success",
645
+ iconOverride: !isError && !options.isPartial ? uiTheme.styledSymbol("tool.edit", "accent") : undefined,
636
646
  op,
637
647
  rawPath,
638
648
  rename,
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { TempDir } from "@oh-my-pi/pi-utils";
4
+ import { createHelpers, type HelperContext } from "../js/shared/helpers";
5
+
6
+ /**
7
+ * The eval helpers (`read`/`write`/`append`) must substitute injected on-disk
8
+ * roots for internal-URL schemes. Without it, `write("local://x.md")` hits a
9
+ * stdlib `path.resolve` that collapses `local://` to `local:/`, creating a junk
10
+ * `local:` directory under the cwd instead of landing where `read local://x.md`
11
+ * resolves. These lock the substitution contract and its guards.
12
+ */
13
+ function makeCtx(cwd: string, roots: Record<string, string>): HelperContext {
14
+ return {
15
+ cwd: () => cwd,
16
+ env: new Map(),
17
+ localRoots: () => roots,
18
+ emitStatus: () => {},
19
+ };
20
+ }
21
+
22
+ describe("eval js helpers internal-url resolution", () => {
23
+ it("writes, reads, and appends local:// under the injected root", async () => {
24
+ using tmp = TempDir.createSync("@eval-helpers-local-");
25
+ const root = path.join(tmp.path(), "local");
26
+ const helpers = createHelpers(makeCtx(tmp.path(), { local: root }));
27
+
28
+ const written = await helpers.writeFile("local://notes/merge-map.md", "hello");
29
+ expect(written).toBe(path.join(root, "notes", "merge-map.md"));
30
+ expect(await Bun.file(written).text()).toBe("hello");
31
+ expect(await helpers.read("local://notes/merge-map.md")).toBe("hello");
32
+
33
+ await helpers.append("local://notes/merge-map.md", " world");
34
+ expect(await helpers.read("local://notes/merge-map.md")).toBe("hello world");
35
+
36
+ // Regression: no literal `local:` directory created under the cwd.
37
+ expect(await Bun.file(path.join(tmp.path(), "local:")).exists()).toBe(false);
38
+ expect(await Bun.file(path.join(tmp.path(), "local:", "notes", "merge-map.md")).exists()).toBe(false);
39
+ });
40
+
41
+ it("rejects traversal and schemes without an injected root", async () => {
42
+ using tmp = TempDir.createSync("@eval-helpers-guard-");
43
+ const helpers = createHelpers(makeCtx(tmp.path(), { local: path.join(tmp.path(), "local") }));
44
+
45
+ await expect(helpers.writeFile("local://../escape.md", "x")).rejects.toThrow(/traversal|escapes/i);
46
+ await expect(helpers.writeFile("memory://x.md", "x")).rejects.toThrow(/not supported/i);
47
+ await expect(helpers.read("https://example.com/page")).rejects.toThrow(/not supported/i);
48
+ });
49
+
50
+ it("leaves plain relative and absolute paths resolving against the cwd", async () => {
51
+ using tmp = TempDir.createSync("@eval-helpers-plain-");
52
+ const helpers = createHelpers(makeCtx(tmp.path(), {}));
53
+
54
+ const rel = await helpers.writeFile("foo/bar.txt", "bar");
55
+ expect(rel).toBe(path.join(tmp.path(), "foo", "bar.txt"));
56
+ expect(await helpers.read("foo/bar.txt")).toBe("bar");
57
+ });
58
+ });
@@ -1,3 +1,4 @@
1
+ import { buildEvalUrlRoots, type LocalProtocolOptions } from "../internal-urls";
1
2
  import type { ToolSession } from "../tools";
2
3
  import type { EvalDisplayOutput, EvalLanguage, EvalStatusEvent } from "./types";
3
4
 
@@ -56,3 +57,17 @@ export interface ExecutorBackend {
56
57
  /** Execute one cell. Caller invokes once per cell and aggregates results. */
57
58
  execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult>;
58
59
  }
60
+
61
+ /**
62
+ * Resolve the on-disk roots that the eval helpers substitute for internal-URL
63
+ * schemes (currently `local://`). Prefers the session's own
64
+ * {@link LocalProtocolOptions} — the exact mapping `read local://…` uses — so an
65
+ * eval `write("local://x")` and a later `read local://x` agree on the location.
66
+ */
67
+ export function resolveEvalUrlRoots(session: ToolSession): Record<string, string> {
68
+ const options: LocalProtocolOptions = session.localProtocolOptions ?? {
69
+ getArtifactsDir: () => session.getArtifactsDir?.() ?? null,
70
+ getSessionId: () => session.getSessionId?.() ?? null,
71
+ };
72
+ return buildEvalUrlRoots(options);
73
+ }
@@ -68,6 +68,7 @@ export async function executeInVmContext(options: {
68
68
  sessionId: string;
69
69
  cwd: string;
70
70
  session: ToolSession;
71
+ localRoots?: Record<string, string>;
71
72
  reset?: boolean;
72
73
  code: string;
73
74
  filename: string;
@@ -100,7 +101,7 @@ export async function executeInVmContext(options: {
100
101
  }
101
102
  const session = await acquireSession(
102
103
  options.sessionKey,
103
- { cwd: options.cwd, sessionId: options.sessionId },
104
+ { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots },
104
105
  options.timeoutMs,
105
106
  );
106
107
  return await runOnce(session, options);
@@ -132,6 +133,7 @@ async function runOnce(
132
133
  sessionId: string;
133
134
  cwd: string;
134
135
  session: ToolSession;
136
+ localRoots?: Record<string, string>;
135
137
  code: string;
136
138
  filename: string;
137
139
  runState: VmRunState;
@@ -171,7 +173,7 @@ async function runOnce(
171
173
  runId,
172
174
  code: options.code,
173
175
  filename: options.filename,
174
- snapshot: { cwd: options.cwd, sessionId: options.sessionId },
176
+ snapshot: { cwd: options.cwd, sessionId: options.sessionId, localRoots: options.localRoots },
175
177
  });
176
178
  return await promise;
177
179
  } finally {
@@ -24,6 +24,8 @@ export interface JsExecutorOptions {
24
24
  artifactPath?: string;
25
25
  artifactId?: string;
26
26
  session: ToolSession;
27
+ /** On-disk roots the helpers substitute for internal-URL schemes (e.g. `local://`). */
28
+ localRoots?: Record<string, string>;
27
29
  }
28
30
 
29
31
  export interface JsResult {
@@ -96,6 +98,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
96
98
  sessionId: options.sessionId,
97
99
  cwd: options.cwd ?? options.session.cwd,
98
100
  session: options.session,
101
+ localRoots: options.localRoots,
99
102
  reset: options.reset,
100
103
  code,
101
104
  filename: `js-cell-${crypto.randomUUID()}.js`,
@@ -1,5 +1,10 @@
1
1
  import type { ToolSession } from "../../tools";
2
- import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend";
2
+ import {
3
+ type ExecutorBackend,
4
+ type ExecutorBackendExecOptions,
5
+ type ExecutorBackendResult,
6
+ resolveEvalUrlRoots,
7
+ } from "../backend";
3
8
  import { executeJs } from "./executor";
4
9
 
5
10
  const JS_SESSION_PREFIX = "js:";
@@ -30,6 +35,7 @@ export default {
30
35
  onChunk: opts.onChunk,
31
36
  onStatus: opts.onStatus,
32
37
  session: opts.session,
38
+ localRoots: resolveEvalUrlRoots(opts.session),
33
39
  });
34
40
  return {
35
41
  output: result.output,
@@ -24,6 +24,12 @@ export interface HelperOptions {
24
24
  export interface HelperContext {
25
25
  cwd(): string;
26
26
  env: Map<string, string>;
27
+ /**
28
+ * On-disk roots for internal-URL schemes the helpers accept (e.g.
29
+ * `{ local: "/…/artifacts/local" }`). A path like `local://x.md` is rewritten
30
+ * to `<root>/x.md` before any filesystem op; unknown schemes are rejected.
31
+ */
32
+ localRoots(): Record<string, string>;
27
33
  emitStatus(event: JsStatusEvent): void;
28
34
  }
29
35
 
@@ -66,7 +72,7 @@ export function createHelpers(ctx: HelperContext): HelperBundle {
66
72
  if (!isWriteData(data)) {
67
73
  throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
68
74
  }
69
- const filePath = resolvePath(ctx, rawPath);
75
+ const filePath = resolveHelperPath(ctx, rawPath, "write");
70
76
  if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) {
71
77
  await Bun.write(filePath, data);
72
78
  } else {
@@ -76,7 +82,7 @@ export function createHelpers(ctx: HelperContext): HelperBundle {
76
82
  return filePath;
77
83
  },
78
84
  append: async (rawPath, content) => {
79
- const target = resolvePath(ctx, rawPath);
85
+ const target = resolveHelperPath(ctx, rawPath, "write");
80
86
  await Bun.write(
81
87
  target,
82
88
  `${await Bun.file(target)
@@ -202,19 +208,60 @@ function getMergedEnv(ctx: HelperContext): Record<string, string> {
202
208
  return merged;
203
209
  }
204
210
 
211
+ const INTERNAL_URL_RE = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/i;
212
+
205
213
  function resolvePath(ctx: HelperContext, value: string): string {
206
214
  if (path.isAbsolute(value)) return path.normalize(value);
207
215
  return path.resolve(ctx.cwd(), value);
208
216
  }
209
217
 
218
+ /**
219
+ * Map a raw helper path to an absolute filesystem path. Plain paths resolve
220
+ * against the cwd; an internal-URL whose scheme has an injected root (e.g.
221
+ * `local://`) is rewritten under that root; any other `scheme://` is rejected
222
+ * so we never silently create a literal `scheme:/` directory.
223
+ */
224
+ function resolveHelperPath(ctx: HelperContext, rawPath: string, op: "read" | "write"): string {
225
+ const match = INTERNAL_URL_RE.exec(rawPath);
226
+ if (!match) return resolvePath(ctx, rawPath);
227
+ const scheme = match[1].toLowerCase();
228
+ const root = ctx.localRoots()[scheme];
229
+ if (!root) {
230
+ throw new ToolError(`Protocol paths are not supported by ${op}(): ${rawPath}`);
231
+ }
232
+ return resolveUnderRoot(scheme, root, match[2], rawPath);
233
+ }
234
+
235
+ /** Resolve an internal-URL relative path under its root, mirroring the host
236
+ * local-protocol handler: decode, reject absolute/traversal, confine to root. */
237
+ function resolveUnderRoot(scheme: string, root: string, rawRelative: string, rawPath: string): string {
238
+ let relative: string;
239
+ try {
240
+ relative = decodeURIComponent(rawRelative.replaceAll("\\", "/"));
241
+ } catch {
242
+ throw new ToolError(`Invalid URL encoding in ${scheme}:// path: ${rawPath}`);
243
+ }
244
+ const rootPath = path.resolve(root);
245
+ if (relative === "") return rootPath;
246
+ if (path.isAbsolute(relative)) {
247
+ throw new ToolError(`Absolute paths are not allowed in ${scheme}:// URLs: ${rawPath}`);
248
+ }
249
+ const normalized = path.normalize(relative);
250
+ if (normalized.startsWith("..") || normalized.includes("/../") || normalized.includes("/..")) {
251
+ throw new ToolError(`Path traversal (..) is not allowed in ${scheme}:// URLs: ${rawPath}`);
252
+ }
253
+ const resolved = path.resolve(rootPath, normalized);
254
+ if (resolved !== rootPath && !resolved.startsWith(`${rootPath}${path.sep}`)) {
255
+ throw new ToolError(`${scheme}:// path escapes its root: ${rawPath}`);
256
+ }
257
+ return resolved;
258
+ }
259
+
210
260
  async function resolveRegularFile(
211
261
  ctx: HelperContext,
212
262
  rawPath: string,
213
263
  ): Promise<{ filePath: string; file: Bun.BunFile; size: number }> {
214
- if (/^[a-z][a-z0-9+.-]*:\/\//i.test(rawPath)) {
215
- throw new ToolError(`Protocol paths are not supported by read(): ${rawPath}`);
216
- }
217
- const filePath = resolvePath(ctx, rawPath);
264
+ const filePath = resolveHelperPath(ctx, rawPath, "read");
218
265
  const file = Bun.file(filePath);
219
266
  const stat = await file.stat();
220
267
  if (stat.isDirectory()) {
@@ -42,6 +42,11 @@ export interface RuntimeOptions {
42
42
  * via `setRunScope()` instead.
43
43
  */
44
44
  extraGlobals?: Record<string, unknown>;
45
+ /**
46
+ * On-disk roots the helpers substitute for internal-URL schemes (e.g.
47
+ * `{ local: "/…/artifacts/local" }`). Stable for the worker's lifetime.
48
+ */
49
+ localRoots?: Record<string, string>;
45
50
  }
46
51
 
47
52
  // Strict base64: characters from the standard alphabet plus optional `=` padding, and a
@@ -126,15 +131,18 @@ export class JsRuntime {
126
131
  #env: Map<string, string>;
127
132
  #als = new AsyncLocalStorage<RunContext>();
128
133
  #moduleLoader: LocalModuleLoader;
134
+ #localRoots: Record<string, string>;
129
135
 
130
136
  constructor(opts: RuntimeOptions) {
131
137
  this.#cwd = opts.initialCwd;
132
138
  this.sessionId = opts.sessionId;
133
139
  this.#env = new Map();
134
140
  this.#moduleLoader = new LocalModuleLoader(this.sessionId);
141
+ this.#localRoots = opts.localRoots ?? {};
135
142
  this.helpers = createHelpers({
136
143
  cwd: () => this.#activeCwd(),
137
144
  env: this.#env,
145
+ localRoots: () => this.#localRoots,
138
146
  emitStatus: event => this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event }),
139
147
  });
140
148
  this.#install(opts.extraGlobals);
@@ -71,6 +71,7 @@ export class WorkerCore {
71
71
  this.#runtime = new JsRuntime({
72
72
  initialCwd: snapshot.cwd,
73
73
  sessionId: snapshot.sessionId,
74
+ localRoots: snapshot.localRoots,
74
75
  });
75
76
  return this.#runtime;
76
77
  }
@@ -5,6 +5,12 @@ export type { JsDisplayOutput } from "./shared/types";
5
5
  export interface SessionSnapshot {
6
6
  cwd: string;
7
7
  sessionId: string;
8
+ /**
9
+ * On-disk roots the helpers substitute for internal-URL schemes
10
+ * (e.g. `{ local: "/…/artifacts/local" }`). Lets `read`/`write`/`append`
11
+ * accept `local://…` paths instead of writing a literal `local:/` directory.
12
+ */
13
+ localRoots?: Record<string, string>;
8
14
  }
9
15
 
10
16
  export interface RunErrorPayload {
@@ -56,6 +56,13 @@ export interface PythonExecutorOptions {
56
56
  /** Artifact path/id for full output storage */
57
57
  artifactPath?: string;
58
58
  artifactId?: string;
59
+ /**
60
+ * On-disk roots the prelude helpers (`read`/`write`/`append`) substitute for
61
+ * internal-URL schemes (e.g. `{ local: "/…/artifacts/local" }`). Exported to
62
+ * the kernel as `PI_EVAL_LOCAL_ROOTS` (JSON) so `write("local://x")` lands
63
+ * where `read local://x` resolves instead of a literal `local:/` directory.
64
+ */
65
+ localRoots?: Record<string, string>;
59
66
  /**
60
67
  * ToolSession used to resolve host-side `tool.<name>(args)` calls made from
61
68
  * the Python prelude's bridge proxy. When omitted, the bridge env vars are
@@ -275,6 +282,7 @@ const MANAGED_KERNEL_ENV_KEYS = [
275
282
  "PI_TOOL_BRIDGE_URL",
276
283
  "PI_TOOL_BRIDGE_TOKEN",
277
284
  "PI_TOOL_BRIDGE_SESSION",
285
+ "PI_EVAL_LOCAL_ROOTS",
278
286
  ] as const;
279
287
 
280
288
  function buildKernelEnvPatch(options: {
@@ -282,13 +290,16 @@ function buildKernelEnvPatch(options: {
282
290
  artifactsDir?: string;
283
291
  bridgeSessionId?: string;
284
292
  bridge?: { url: string; token: string };
293
+ localRoots?: Record<string, string>;
285
294
  }): KernelRuntimeEnv {
295
+ const localRoots = options.localRoots;
286
296
  return {
287
297
  PI_SESSION_FILE: options.sessionFile ?? null,
288
298
  PI_ARTIFACTS_DIR: options.artifactsDir ?? null,
289
299
  PI_TOOL_BRIDGE_URL: options.bridge?.url ?? null,
290
300
  PI_TOOL_BRIDGE_TOKEN: options.bridge?.token ?? null,
291
301
  PI_TOOL_BRIDGE_SESSION: options.bridge && options.bridgeSessionId ? options.bridgeSessionId : null,
302
+ PI_EVAL_LOCAL_ROOTS: localRoots && Object.keys(localRoots).length > 0 ? JSON.stringify(localRoots) : null,
292
303
  };
293
304
  }
294
305
 
@@ -297,6 +308,7 @@ function buildKernelEnv(options: {
297
308
  artifactsDir?: string;
298
309
  bridgeSessionId?: string;
299
310
  bridge?: { url: string; token: string };
311
+ localRoots?: Record<string, string>;
300
312
  }): Record<string, string> | undefined {
301
313
  const patch = buildKernelEnvPatch(options);
302
314
  const env: Record<string, string> = {};
@@ -1,5 +1,10 @@
1
1
  import type { ToolSession } from "../../tools";
2
- import type { ExecutorBackend, ExecutorBackendExecOptions, ExecutorBackendResult } from "../backend";
2
+ import {
3
+ type ExecutorBackend,
4
+ type ExecutorBackendExecOptions,
5
+ type ExecutorBackendResult,
6
+ resolveEvalUrlRoots,
7
+ } from "../backend";
3
8
  import { executePython, type PythonExecutorOptions } from "./executor";
4
9
  import { checkPythonKernelAvailability } from "./kernel";
5
10
 
@@ -34,6 +39,7 @@ export default {
34
39
  kernelMode,
35
40
  sessionFile: opts.sessionFile,
36
41
  artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
42
+ localRoots: resolveEvalUrlRoots(opts.session),
37
43
  kernelOwnerId: opts.kernelOwnerId,
38
44
  reset: opts.reset,
39
45
  artifactPath: opts.artifactPath,
@@ -3,7 +3,8 @@ from __future__ import annotations
3
3
  if "__omp_prelude_loaded__" not in globals():
4
4
  __omp_prelude_loaded__ = True
5
5
  from pathlib import Path
6
- import os, json, math
6
+ import os, json, math, re
7
+ from urllib.parse import unquote
7
8
 
8
9
  # __omp_display is injected by runner.py before the prelude executes; it
9
10
  # mirrors IPython's display() semantics with the same MIME bundle output.
@@ -53,9 +54,47 @@ if "__omp_prelude_loaded__" not in globals():
53
54
  _emit_status("env", key=key, value=val, action="get")
54
55
  return val
55
56
 
57
+ _OMP_INTERNAL_URL_RE = re.compile(r"^([a-z][a-z0-9+.-]*)://(.*)$", re.IGNORECASE)
58
+
59
+ def _resolve_omp_path(path: str | Path) -> Path:
60
+ """Map a helper path to a real filesystem Path.
61
+
62
+ A `scheme://…` whose scheme has an injected on-disk root (e.g.
63
+ `local://`, via PI_EVAL_LOCAL_ROOTS) is rewritten under that root so it
64
+ lands where `read local://…` resolves — not a literal `local:/`
65
+ directory under the cwd (which `Path("local://x")` collapses to). Plain
66
+ paths pass through unchanged; any other `scheme://` is rejected."""
67
+ if not isinstance(path, str):
68
+ return Path(path)
69
+ match = _OMP_INTERNAL_URL_RE.match(path)
70
+ if not match:
71
+ return Path(path)
72
+ scheme = match.group(1).lower()
73
+ try:
74
+ roots = json.loads(os.environ.get("PI_EVAL_LOCAL_ROOTS") or "{}")
75
+ except (ValueError, TypeError):
76
+ roots = {}
77
+ root = roots.get(scheme) if isinstance(roots, dict) else None
78
+ if not root:
79
+ raise ValueError(f"Protocol paths are not supported by this helper: {path}")
80
+ relative = unquote(match.group(2).replace("\\", "/"))
81
+ # Mirror the host `path.resolve`/`resolveLocalUrlToPath`: normalize and
82
+ # make absolute WITHOUT realpath'ing symlinks (Path.resolve would turn
83
+ # /tmp into /private/tmp and diverge from the read-side resolution).
84
+ root_path = os.path.abspath(root)
85
+ if relative == "":
86
+ return Path(root_path)
87
+ rel_path = Path(relative)
88
+ if rel_path.is_absolute() or ".." in rel_path.parts:
89
+ raise ValueError(f"Unsafe {scheme}:// path (absolute or traversal): {path}")
90
+ resolved = os.path.abspath(os.path.join(root_path, relative))
91
+ if resolved != root_path and not resolved.startswith(root_path + os.sep):
92
+ raise ValueError(f"{scheme}:// path escapes its root: {path}")
93
+ return Path(resolved)
94
+
56
95
  def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str:
57
96
  """Read file contents. offset/limit are 1-indexed line numbers."""
58
- p = Path(path)
97
+ p = _resolve_omp_path(path)
59
98
  data = p.read_text(encoding="utf-8")
60
99
  lines = data.splitlines(keepends=True)
61
100
  if offset > 1 or limit is not None:
@@ -69,7 +108,7 @@ if "__omp_prelude_loaded__" not in globals():
69
108
 
70
109
  def write(path: str | Path, content: str) -> Path:
71
110
  """Write file contents (create parents)."""
72
- p = Path(path)
111
+ p = _resolve_omp_path(path)
73
112
  p.parent.mkdir(parents=True, exist_ok=True)
74
113
  p.write_text(content, encoding="utf-8")
75
114
  _emit_status("write", path=str(p), chars=len(content))
@@ -77,7 +116,7 @@ if "__omp_prelude_loaded__" not in globals():
77
116
 
78
117
  def append(path: str | Path, content: str) -> Path:
79
118
  """Append to file."""
80
- p = Path(path)
119
+ p = _resolve_omp_path(path)
81
120
  p.parent.mkdir(parents=True, exist_ok=True)
82
121
  with p.open("a", encoding="utf-8") as f:
83
122
  f.write(content)
@@ -819,6 +819,7 @@ _MANAGED_ENV_KEYS = (
819
819
  "PI_TOOL_BRIDGE_URL",
820
820
  "PI_TOOL_BRIDGE_TOKEN",
821
821
  "PI_TOOL_BRIDGE_SESSION",
822
+ "PI_EVAL_LOCAL_ROOTS",
822
823
  )
823
824
 
824
825
 
package/src/exa/index.ts CHANGED
@@ -1,27 +1,2 @@
1
- /**
2
- * Exa MCP Tools
3
- *
4
- * 22 tools for Exa's MCP servers:
5
- * - 4 search tools (search, deep, code, crawl)
6
- * - 1 LinkedIn search tool
7
- * - 1 company research tool
8
- * - 2 researcher tools (start, poll)
9
- * - 14 websets tools (CRUD, items, search, enrichment, monitor)
10
- */
11
- import type { CustomTool } from "../extensibility/custom-tools/types";
12
- import { researcherTools } from "./researcher";
13
- import { searchTools } from "./search";
14
- import type { ExaRenderDetails } from "./types";
15
- import { websetsTools } from "./websets";
16
-
17
- /** All Exa tools (22 total) - static export for backward compatibility */
18
- export const exaTools: CustomTool<any, ExaRenderDetails>[] = [...searchTools, ...researcherTools, ...websetsTools];
19
-
20
1
  export * from "./mcp-client";
21
- export { renderExaCall, renderExaResult } from "./render";
22
- export { researcherTools } from "./researcher";
23
- // Re-export individual modules for selective importing
24
- export { searchTools } from "./search";
25
- // Re-export types and utilities
26
- export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult, MCPToolWrapperConfig } from "./types";
27
- export { websetsTools } from "./websets";
2
+ export type { ExaSearchResponse, MCPCallResponse, MCPTool, MCPToolsResponse, MCPToolWrapperConfig } from "./types";
@@ -2,14 +2,14 @@ import type { TSchema } from "@oh-my-pi/pi-ai";
2
2
  import { $env, logger } from "@oh-my-pi/pi-utils";
3
3
  import type { CustomTool, CustomToolResult } from "../extensibility/custom-tools/types";
4
4
  import { type CallMcpOptions, callMCP } from "../mcp/json-rpc";
5
- import type {
6
- ExaRenderDetails,
7
- ExaSearchResponse,
8
- MCPCallResponse,
9
- MCPTool,
10
- MCPToolsResponse,
11
- MCPToolWrapperConfig,
12
- } from "./types";
5
+ import type { ExaSearchResponse, MCPCallResponse, MCPTool, MCPToolsResponse, MCPToolWrapperConfig } from "./types";
6
+
7
+ type MCPWrappedToolDetails = {
8
+ response?: ExaSearchResponse;
9
+ error?: string;
10
+ toolName?: string;
11
+ raw?: unknown;
12
+ };
13
13
 
14
14
  /** Find EXA_API_KEY from Bun.env or .env files */
15
15
  export function findApiKey(): string | null {
@@ -296,7 +296,7 @@ export async function fetchMCPToolSchema(
296
296
  * This allows tools to be generated from MCP server schemas without hardcoding,
297
297
  * reducing drift when MCP servers add new parameters.
298
298
  */
299
- export class MCPWrappedTool implements CustomTool<TSchema, ExaRenderDetails> {
299
+ export class MCPWrappedTool implements CustomTool<TSchema, MCPWrappedToolDetails> {
300
300
  readonly name: string;
301
301
  readonly label: string;
302
302
 
@@ -315,7 +315,7 @@ export class MCPWrappedTool implements CustomTool<TSchema, ExaRenderDetails> {
315
315
  _onUpdate?: unknown,
316
316
  _ctx?: unknown,
317
317
  _signal?: AbortSignal,
318
- ): Promise<CustomToolResult<ExaRenderDetails>> {
318
+ ): Promise<CustomToolResult<MCPWrappedToolDetails>> {
319
319
  try {
320
320
  const apiKey = findApiKey();
321
321
  // Websets tools require an API key; basic Exa MCP tools work without one
package/src/exa/types.ts CHANGED
@@ -5,10 +5,6 @@
5
5
  */
6
6
  import type { TSchema } from "@oh-my-pi/pi-ai";
7
7
 
8
- /** MCP endpoint URLs */
9
- export const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
10
- export const WEBSETS_MCP_URL = "https://websetsmcp.exa.ai/mcp";
11
-
12
8
  /** MCP tool definition from server */
13
9
  export interface MCPTool {
14
10
  name: string;
@@ -71,96 +67,3 @@ export interface ExaSearchResponse {
71
67
  searchTime?: number;
72
68
  requestId?: string;
73
69
  }
74
-
75
- /** Researcher task status */
76
- export interface ResearcherStatus {
77
- id: string;
78
- status: "pending" | "running" | "completed" | "failed";
79
- result?: string;
80
- error?: string;
81
- }
82
-
83
- /** Webset definition */
84
- export interface Webset {
85
- id: string;
86
- name: string;
87
- description?: string;
88
- createdAt?: string;
89
- updatedAt?: string;
90
- }
91
-
92
- /** Webset item */
93
- export interface WebsetItem {
94
- id: string;
95
- websetId: string;
96
- url: string;
97
- title?: string;
98
- content?: string;
99
- metadata?: Record<string, unknown>;
100
- }
101
-
102
- /** Webset search */
103
- export interface WebsetSearch {
104
- id: string;
105
- websetId: string;
106
- query: string;
107
- status: "pending" | "running" | "completed" | "cancelled";
108
- resultCount?: number;
109
- }
110
-
111
- /** Webset enrichment */
112
- export interface WebsetEnrichment {
113
- id: string;
114
- websetId: string;
115
- name: string;
116
- prompt: string;
117
- status: "pending" | "running" | "completed" | "cancelled";
118
- }
119
-
120
- /** Tool name mappings: MCP name -> our tool name */
121
- export const EXA_TOOL_MAPPINGS = {
122
- // Search tools
123
- web_search_exa: "exa_search",
124
- get_code_context_exa: "exa_search_code",
125
- crawling_exa: "exa_crawl",
126
- // LinkedIn
127
- linkedin_search_exa: "exa_linkedin",
128
- // Company
129
- company_research_exa: "exa_company",
130
- // Researcher
131
- deep_researcher_start: "exa_researcher_start",
132
- deep_researcher_check: "exa_researcher_poll",
133
- } as const;
134
-
135
- export const WEBSETS_TOOL_MAPPINGS = {
136
- create_webset: "webset_create",
137
- list_websets: "webset_list",
138
- get_webset: "webset_get",
139
- update_webset: "webset_update",
140
- delete_webset: "webset_delete",
141
- list_webset_items: "webset_items_list",
142
- get_item: "webset_item_get",
143
- create_search: "webset_search_create",
144
- get_search: "webset_search_get",
145
- cancel_search: "webset_search_cancel",
146
- create_enrichment: "webset_enrichment_create",
147
- get_enrichment: "webset_enrichment_get",
148
- update_enrichment: "webset_enrichment_update",
149
- delete_enrichment: "webset_enrichment_delete",
150
- cancel_enrichment: "webset_enrichment_cancel",
151
- create_monitor: "webset_monitor_create",
152
- } as const;
153
-
154
- export type ExaMcpToolName = keyof typeof EXA_TOOL_MAPPINGS;
155
- export type WebsetsMcpToolName = keyof typeof WEBSETS_TOOL_MAPPINGS;
156
- export type ExaToolName = (typeof EXA_TOOL_MAPPINGS)[ExaMcpToolName];
157
- export type WebsetsToolName = (typeof WEBSETS_TOOL_MAPPINGS)[WebsetsMcpToolName];
158
-
159
- /** Render details for TUI */
160
- export interface ExaRenderDetails {
161
- response?: ExaSearchResponse;
162
- error?: string;
163
- toolName?: string;
164
- /** Raw result for non-search responses */
165
- raw?: unknown;
166
- }