@oh-my-pi/pi-coding-agent 15.10.3 → 15.10.5

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 (161) hide show
  1. package/CHANGELOG.md +72 -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/__tests__/js-context-manager.test.d.ts +1 -0
  10. package/dist/types/eval/backend.d.ts +7 -0
  11. package/dist/types/eval/bridge-timeout.d.ts +1 -1
  12. package/dist/types/eval/{llm-bridge.d.ts → completion-bridge.d.ts} +8 -8
  13. package/dist/types/eval/idle-timeout.d.ts +1 -1
  14. package/dist/types/eval/js/context-manager.d.ts +1 -0
  15. package/dist/types/eval/js/executor.d.ts +2 -0
  16. package/dist/types/eval/js/index.d.ts +1 -1
  17. package/dist/types/eval/js/shared/helpers.d.ts +6 -0
  18. package/dist/types/eval/js/shared/runtime.d.ts +5 -0
  19. package/dist/types/eval/js/worker-protocol.d.ts +6 -0
  20. package/dist/types/eval/py/executor.d.ts +7 -0
  21. package/dist/types/eval/py/index.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -0
  23. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  24. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  25. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  26. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  27. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  28. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  29. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  30. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  31. package/dist/types/modes/image-references.d.ts +8 -3
  32. package/dist/types/modes/interactive-mode.d.ts +1 -1
  33. package/dist/types/modes/theme/theme.d.ts +2 -1
  34. package/dist/types/modes/types.d.ts +2 -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/tools/ask.d.ts +1 -0
  38. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  39. package/dist/types/tools/index.d.ts +17 -0
  40. package/dist/types/tools/render-utils.d.ts +1 -1
  41. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  42. package/dist/types/utils/block-context.d.ts +35 -0
  43. package/dist/types/utils/image-loading.d.ts +12 -0
  44. package/package.json +29 -9
  45. package/src/capability/rule-buckets.ts +4 -2
  46. package/src/capability/rule.ts +10 -1
  47. package/src/cli/auth-broker-cli.ts +6 -7
  48. package/src/cli/auth-gateway-cli.ts +1 -1
  49. package/src/cli/list-models.ts +5 -0
  50. package/src/cli/update-cli.ts +138 -16
  51. package/src/config/model-registry.ts +81 -2
  52. package/src/debug/index.ts +4 -8
  53. package/src/discovery/at-imports.ts +273 -0
  54. package/src/discovery/builtin-rules/index.ts +4 -0
  55. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  56. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  57. package/src/discovery/helpers.ts +2 -1
  58. package/src/edit/diff.ts +114 -4
  59. package/src/edit/hashline/diff.ts +1 -1
  60. package/src/edit/hashline/execute.ts +1 -1
  61. package/src/edit/modes/patch.ts +6 -2
  62. package/src/edit/modes/replace.ts +1 -1
  63. package/src/edit/renderer.ts +12 -2
  64. package/src/eval/__tests__/agent-bridge.test.ts +13 -0
  65. package/src/eval/__tests__/{llm-bridge.test.ts → completion-bridge.test.ts} +60 -54
  66. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  67. package/src/eval/__tests__/js-context-manager.test.ts +241 -0
  68. package/src/eval/agent-bridge.ts +6 -1
  69. package/src/eval/backend.ts +15 -0
  70. package/src/eval/bridge-timeout.ts +1 -1
  71. package/src/eval/{llm-bridge.ts → completion-bridge.ts} +30 -27
  72. package/src/eval/idle-timeout.ts +1 -1
  73. package/src/eval/js/context-manager.ts +70 -8
  74. package/src/eval/js/executor.ts +3 -0
  75. package/src/eval/js/index.ts +7 -1
  76. package/src/eval/js/shared/helpers.ts +53 -6
  77. package/src/eval/js/shared/prelude.txt +4 -4
  78. package/src/eval/js/shared/runtime.ts +8 -0
  79. package/src/eval/js/tool-bridge.ts +3 -3
  80. package/src/eval/js/worker-core.ts +1 -0
  81. package/src/eval/js/worker-entry.ts +6 -0
  82. package/src/eval/js/worker-protocol.ts +6 -0
  83. package/src/eval/py/executor.ts +12 -0
  84. package/src/eval/py/index.ts +7 -1
  85. package/src/eval/py/prelude.py +46 -7
  86. package/src/eval/py/runner.py +1 -0
  87. package/src/exa/render.ts +1 -1
  88. package/src/export/ttsr.ts +122 -1
  89. package/src/extensibility/extensions/types.ts +8 -1
  90. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  91. package/src/extensibility/plugins/doctor.ts +1 -1
  92. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  93. package/src/goals/tools/goal-tool.ts +1 -1
  94. package/src/internal-urls/docs-index.generated.ts +8 -6
  95. package/src/internal-urls/local-protocol.ts +13 -0
  96. package/src/lsp/render.ts +8 -6
  97. package/src/mcp/oauth-flow.ts +3 -3
  98. package/src/mcp/render.ts +7 -1
  99. package/src/modes/components/custom-editor.ts +12 -6
  100. package/src/modes/components/login-dialog.ts +1 -1
  101. package/src/modes/components/oauth-selector.ts +4 -4
  102. package/src/modes/components/read-tool-group.ts +10 -3
  103. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  104. package/src/modes/components/status-line/index.ts +1 -0
  105. package/src/modes/components/status-line/types.ts +23 -8
  106. package/src/modes/components/tips.txt +1 -1
  107. package/src/modes/components/tool-execution.ts +1 -1
  108. package/src/modes/components/transcript-container.ts +17 -10
  109. package/src/modes/components/user-message.ts +6 -3
  110. package/src/modes/components/welcome.ts +1 -1
  111. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  112. package/src/modes/controllers/input-controller.ts +36 -10
  113. package/src/modes/controllers/mcp-command-controller.ts +28 -12
  114. package/src/modes/controllers/selector-controller.ts +4 -11
  115. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  116. package/src/modes/image-references.ts +13 -7
  117. package/src/modes/interactive-mode.ts +2 -2
  118. package/src/modes/rpc/rpc-mode.ts +1 -1
  119. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  120. package/src/modes/theme/theme.ts +95 -1
  121. package/src/modes/types.ts +2 -1
  122. package/src/modes/utils/ui-helpers.ts +14 -5
  123. package/src/prompts/system/tiny-title-system.md +1 -1
  124. package/src/prompts/system/title-system.md +16 -3
  125. package/src/prompts/system/workflow-notice.md +1 -1
  126. package/src/prompts/tools/bash.md +1 -1
  127. package/src/prompts/tools/eval.md +6 -6
  128. package/src/sdk.ts +31 -14
  129. package/src/session/agent-session.ts +213 -155
  130. package/src/session/session-manager.ts +1 -1
  131. package/src/slash-commands/builtin-registry.ts +1 -1
  132. package/src/system-prompt.ts +15 -9
  133. package/src/task/render.ts +20 -8
  134. package/src/tools/ask.ts +14 -5
  135. package/src/tools/bash-interactive.ts +1 -1
  136. package/src/tools/bash.ts +14 -2
  137. package/src/tools/browser/render.ts +5 -2
  138. package/src/tools/browser/tab-worker.ts +211 -91
  139. package/src/tools/debug.ts +5 -2
  140. package/src/tools/eval-render.ts +8 -5
  141. package/src/tools/eval.ts +2 -2
  142. package/src/tools/gh-renderer.ts +29 -15
  143. package/src/tools/index.ts +32 -0
  144. package/src/tools/inspect-image-renderer.ts +12 -5
  145. package/src/tools/job.ts +9 -6
  146. package/src/tools/memory-render.ts +19 -5
  147. package/src/tools/read.ts +165 -18
  148. package/src/tools/render-utils.ts +3 -1
  149. package/src/tools/resolve.ts +1 -1
  150. package/src/tools/review.ts +1 -1
  151. package/src/tools/ssh.ts +4 -1
  152. package/src/tools/todo.ts +8 -1
  153. package/src/tools/tool-timeouts.ts +1 -1
  154. package/src/tools/write.ts +1 -1
  155. package/src/tui/code-cell.ts +1 -1
  156. package/src/utils/block-context.ts +312 -0
  157. package/src/utils/image-loading.ts +31 -1
  158. package/src/utils/title-generator.ts +2 -2
  159. package/src/web/search/providers/codex.ts +1 -1
  160. package/src/web/search/render.ts +14 -6
  161. /package/dist/types/eval/__tests__/{llm-bridge.test.d.ts → completion-bridge.test.d.ts} +0 -0
@@ -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()) {
@@ -57,9 +57,9 @@ if (!globalThis.__omp_js_prelude_loaded__) {
57
57
 
58
58
  const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
59
59
 
60
- const llm = async (prompt, opts, ...rest) => {
61
- const o = optionsArg("llm", opts, rest, "{ model, system, schema }");
62
- const res = await globalThis.__omp_call_tool__("__llm__", { prompt, ...o });
60
+ const completion = async (prompt, opts, ...rest) => {
61
+ const o = optionsArg("completion", opts, rest, "{ model, system, schema }");
62
+ const res = await globalThis.__omp_call_tool__("__completion__", { prompt, ...o });
63
63
  const text = res && typeof res === "object" ? res.text : res;
64
64
  return hasOwn(o, "schema") ? JSON.parse(text) : text;
65
65
  };
@@ -164,7 +164,7 @@ if (!globalThis.__omp_js_prelude_loaded__) {
164
164
  globalThis.print = consoleBridge.log;
165
165
  globalThis.display = display;
166
166
  globalThis.tool = tool;
167
- globalThis.llm = llm;
167
+ globalThis.completion = completion;
168
168
  globalThis.output = output;
169
169
  globalThis.agent = agent;
170
170
  globalThis.parallel = parallel;
@@ -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);
@@ -3,8 +3,8 @@ import type { ToolSession } from "../../tools";
3
3
  import { ToolError } from "../../tools/tool-errors";
4
4
  import { EVAL_AGENT_BRIDGE_NAME, runEvalAgent } from "../agent-bridge";
5
5
  import { EVAL_BUDGET_BRIDGE_NAME, type EvalBudgetResult, runEvalBudget } from "../budget-bridge";
6
+ import { EVAL_COMPLETION_BRIDGE_NAME, runEvalCompletion } from "../completion-bridge";
6
7
  import { EVAL_CONCURRENCY_BRIDGE_NAME, type EvalConcurrencyResult, runEvalConcurrency } from "../concurrency-bridge";
7
- import { EVAL_LLM_BRIDGE_NAME, runEvalLlm } from "../llm-bridge";
8
8
  import type { JsStatusEvent } from "./shared/types";
9
9
 
10
10
  export type { JsStatusEvent } from "./shared/types";
@@ -107,8 +107,8 @@ function summarizeToolResult(
107
107
  }
108
108
 
109
109
  export async function callSessionTool(name: string, args: unknown, options: ToolBridgeOptions): Promise<ToolValue> {
110
- if (name === EVAL_LLM_BRIDGE_NAME) {
111
- return await runEvalLlm(args, options);
110
+ if (name === EVAL_COMPLETION_BRIDGE_NAME) {
111
+ return await runEvalCompletion(args, options);
112
112
  }
113
113
  if (name === EVAL_AGENT_BRIDGE_NAME) {
114
114
  return await runEvalAgent(args, options);
@@ -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
  }
@@ -18,6 +18,12 @@ const transport: Transport = {
18
18
  } catch {
19
19
  // Already closed.
20
20
  }
21
+
22
+ // `parentPort.close()` only disconnects the channel in Bun; it does not
23
+ // make the Worker emit `close` or reap ref'ed user handles. Exit from
24
+ // inside the worker after `WorkerCore` has sent the `closed` ack so the
25
+ // host can observe real worker exit without calling `Worker.terminate()`.
26
+ setTimeout(() => process.exit(0), 0);
21
27
  },
22
28
  };
23
29
 
@@ -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)
@@ -463,8 +502,8 @@ if "__omp_prelude_loaded__" not in globals():
463
502
 
464
503
  tool = _ToolProxy()
465
504
 
466
- def llm(prompt, *, model="default", system=None, schema=None):
467
- """Oneshot, stateless LLM call against a model tier.
505
+ def completion(prompt, *, model="default", system=None, schema=None):
506
+ """Oneshot, stateless completion against a model tier.
468
507
 
469
508
  `model` selects a tier: "smol", "default" (the session's active model),
470
509
  or "slow". Pass `system` for a system prompt. Pass a JSON-Schema dict
@@ -476,7 +515,7 @@ if "__omp_prelude_loaded__" not in globals():
476
515
  args["system"] = system
477
516
  if schema is not None:
478
517
  args["schema"] = schema
479
- res = _bridge_call("__llm__", args)
518
+ res = _bridge_call("__completion__", args)
480
519
  text = res.get("text") if isinstance(res, dict) else res
481
520
  return json.loads(text) if schema is not None else text
482
521
 
@@ -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/render.ts CHANGED
@@ -93,7 +93,7 @@ export function renderExaResult(
93
93
  const cost = response.costDollars?.total;
94
94
  const time = response.searchTime;
95
95
 
96
- const icon = formatStatusIcon(resultCount > 0 ? "success" : "warning", uiTheme);
96
+ const icon = resultCount > 0 ? uiTheme.styledSymbol("tool.exa", "accent") : formatStatusIcon("warning", uiTheme);
97
97
 
98
98
  const metaParts = [formatCount("result", resultCount)];
99
99
  if (cost !== undefined) metaParts.push(`cost:$${cost.toFixed(4)}`);
@@ -5,6 +5,8 @@
5
5
  * the agent's output. When a match occurs, the stream is aborted, the rule is
6
6
  * injected as a system reminder, and the request is retried.
7
7
  */
8
+ import * as path from "node:path";
9
+ import { AstMatchStrictness, astMatch } from "@oh-my-pi/pi-natives";
8
10
  import { logger } from "@oh-my-pi/pi-utils";
9
11
  import type { Rule } from "../capability/rule";
10
12
  import type { TtsrSettings } from "../config/settings";
@@ -38,6 +40,8 @@ interface TtsrScope {
38
40
  interface TtsrEntry {
39
41
  rule: Rule;
40
42
  conditions: RegExp[];
43
+ /** ast-grep pattern strings; matched only against edit/write tool snapshots. */
44
+ astConditions: string[];
41
45
  scope: TtsrScope;
42
46
  globalPathGlobs?: Bun.Glob[];
43
47
  }
@@ -70,6 +74,8 @@ export class TtsrManager {
70
74
  readonly #rules = new Map<string, TtsrEntry>();
71
75
  readonly #injectionRecords = new Map<string, InjectionRecord>();
72
76
  readonly #buffers = new Map<string, string>();
77
+ /** Last snapshot evaluated for AST conditions, keyed by stream key, to dedupe matcher runs. */
78
+ readonly #lastAstSnapshots = new Map<string, string>();
73
79
  #messageCount = 0;
74
80
 
75
81
  constructor(settings?: TtsrSettings) {
@@ -302,7 +308,8 @@ export class TtsrManager {
302
308
  }
303
309
 
304
310
  const conditions = this.#compileConditions(rule);
305
- if (conditions.length === 0) {
311
+ const astConditions = (rule.astCondition ?? []).map(pattern => pattern.trim()).filter(p => p.length > 0);
312
+ if (conditions.length === 0 && astConditions.length === 0) {
306
313
  return false;
307
314
  }
308
315
 
@@ -318,6 +325,7 @@ export class TtsrManager {
318
325
  this.#rules.set(rule.name, {
319
326
  rule,
320
327
  conditions,
328
+ astConditions,
321
329
  scope,
322
330
  globalPathGlobs,
323
331
  });
@@ -325,6 +333,7 @@ export class TtsrManager {
325
333
  logger.debug("TTSR rule registered", {
326
334
  ruleName: rule.name,
327
335
  conditions: rule.condition,
336
+ astConditions: rule.astCondition,
328
337
  scope: rule.scope,
329
338
  globs: rule.globs,
330
339
  });
@@ -359,6 +368,112 @@ export class TtsrManager {
359
368
  return this.#matchBuffer(snapshot, context);
360
369
  }
361
370
 
371
+ /** Derive an ast-grep language alias from candidate paths (bare extension, e.g. "ts"), if any. */
372
+ #deriveLang(filePaths: string[] | undefined): string | undefined {
373
+ for (const filePath of filePaths ?? []) {
374
+ const ext = path.extname(this.#normalizePath(filePath));
375
+ if (ext.length > 1) {
376
+ return ext.slice(1).toLowerCase();
377
+ }
378
+ }
379
+ return undefined;
380
+ }
381
+
382
+ /**
383
+ * Evaluate ast-grep `astCondition` rules against a reconstructed tool snapshot.
384
+ *
385
+ * Only edit/write tool streams reach here (AST conditions need a language, which
386
+ * we infer from the file extension on the tool's path argument). The snapshot is
387
+ * matched in memory by the native engine (`astMatch`), so this is async and
388
+ * intentionally throttled: identical consecutive snapshots (the common case when
389
+ * only non-source arguments change between deltas) are skipped.
390
+ */
391
+ async checkAstSnapshot(snapshot: string, context: TtsrMatchContext): Promise<Rule[]> {
392
+ if (!this.#settings.enabled || context.source !== "tool") {
393
+ return [];
394
+ }
395
+
396
+ const lang = this.#deriveLang(context.filePaths);
397
+ if (!lang) {
398
+ return [];
399
+ }
400
+
401
+ const candidates: TtsrEntry[] = [];
402
+ for (const [name, entry] of this.#rules) {
403
+ if (entry.astConditions.length === 0) {
404
+ continue;
405
+ }
406
+ if (
407
+ !this.#canTrigger(name) ||
408
+ !this.#matchesScope(entry, context) ||
409
+ !this.#matchesGlobalPaths(entry, context)
410
+ ) {
411
+ continue;
412
+ }
413
+ candidates.push(entry);
414
+ }
415
+ if (candidates.length === 0) {
416
+ return [];
417
+ }
418
+
419
+ // Throttle: skip re-running the matcher when the source content is unchanged.
420
+ const bufferKey = this.#bufferKey(context);
421
+ if (this.#lastAstSnapshots.get(bufferKey) === snapshot) {
422
+ return [];
423
+ }
424
+ this.#lastAstSnapshots.set(bufferKey, snapshot);
425
+
426
+ const matches: Rule[] = [];
427
+ for (const entry of candidates) {
428
+ if (await this.#astConditionsMatch(entry.astConditions, snapshot, lang)) {
429
+ matches.push(entry.rule);
430
+ logger.debug("TTSR ast condition matched", {
431
+ ruleName: entry.rule.name,
432
+ astConditions: entry.rule.astCondition,
433
+ toolName: context.toolName,
434
+ filePaths: context.filePaths,
435
+ });
436
+ }
437
+ }
438
+ return matches;
439
+ }
440
+
441
+ async #astConditionsMatch(patterns: string[], source: string, lang: string): Promise<boolean> {
442
+ try {
443
+ const result = await astMatch({
444
+ patterns,
445
+ source,
446
+ lang,
447
+ strictness: AstMatchStrictness.Smart,
448
+ limit: 1,
449
+ });
450
+ if (result.parseErrors && result.parseErrors.length > 0) {
451
+ logger.debug("TTSR ast match reported parse errors", { parseErrors: result.parseErrors });
452
+ }
453
+ return result.totalMatches > 0;
454
+ } catch (error) {
455
+ logger.warn("TTSR ast match failed, treating as no match", {
456
+ patterns,
457
+ lang,
458
+ error: error instanceof Error ? error.message : String(error),
459
+ });
460
+ return false;
461
+ }
462
+ }
463
+
464
+ /** True when any registered rule carries ast-grep conditions. */
465
+ hasAstRules(): boolean {
466
+ if (!this.#settings.enabled) {
467
+ return false;
468
+ }
469
+ for (const entry of this.#rules.values()) {
470
+ if (entry.astConditions.length > 0) {
471
+ return true;
472
+ }
473
+ }
474
+ return false;
475
+ }
476
+
362
477
  #matchBuffer(buffer: string, context: TtsrMatchContext): Rule[] {
363
478
  if (!this.#settings.enabled) {
364
479
  return [];
@@ -435,6 +550,7 @@ export class TtsrManager {
435
550
  /** Reset stream buffers (called on new turn). */
436
551
  resetBuffer(): void {
437
552
  this.#buffers.clear();
553
+ this.#lastAstSnapshots.clear();
438
554
  }
439
555
 
440
556
  /** Check if any TTSR rules are registered. */
@@ -445,6 +561,11 @@ export class TtsrManager {
445
561
  return this.#rules.size > 0;
446
562
  }
447
563
 
564
+ /** All rules currently registered for TTSR monitoring, in registration order. */
565
+ getRules(): Rule[] {
566
+ return Array.from(this.#rules.values(), entry => entry.rule);
567
+ }
568
+
448
569
  /** Increment message counter (call after each turn). */
449
570
  incrementMessageCount(): void {
450
571
  this.#messageCount++;
@@ -28,7 +28,7 @@ import type {
28
28
  TextContent,
29
29
  TSchema,
30
30
  } from "@oh-my-pi/pi-ai";
31
- import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/utils/oauth/types";
31
+ import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
32
32
  import type * as piCodingAgent from "@oh-my-pi/pi-coding-agent";
33
33
  import type { AutocompleteItem, Component, EditorTheme, KeyId, TUI } from "@oh-my-pi/pi-tui";
34
34
  import type { logger as PiLogger } from "@oh-my-pi/pi-utils";
@@ -1134,6 +1134,13 @@ export interface ProviderConfig {
1134
1134
  /** Optional model rewrite hook for credential-aware routing (e.g., enterprise URLs). */
1135
1135
  modifyModels?(models: Model<Api>[], credentials: OAuthCredentials): Model<Api>[];
1136
1136
  };
1137
+ /**
1138
+ * Async factory that fetches the live model list from the provider endpoint.
1139
+ * Runs through the same SQLite model-cache as built-in providers (keyed by
1140
+ * provider name, default 24 h TTL). Receives the resolved API key (undefined
1141
+ * when unauthenticated). Mutually exclusive with `models`.
1142
+ */
1143
+ fetchDynamicModels?: (apiKey: string | undefined) => Promise<readonly ProviderModelConfig[]>;
1137
1144
  }
1138
1145
 
1139
1146
  /** Configuration for a model within a provider. */
@@ -8,7 +8,7 @@
8
8
  * entrypoint. Legacy extensions still author parameter schemas as
9
9
  * `Type.Object({ ... })`, so this file is served by `legacy-pi-compat.ts` in
10
10
  * place of the real pi-ai entrypoint whenever a legacy extension imports the
11
- * bare package root. Subpath imports (`@oh-my-pi/pi-ai/utils/oauth`, etc.)
11
+ * bare package root. Subpath imports (`@oh-my-pi/pi-ai/oauth`, etc.)
12
12
  * continue to resolve directly against the bundled pi-ai package.
13
13
  *
14
14
  * The `Type` runtime is borrowed from the Zod-backed TypeBox shim that
@@ -48,7 +48,7 @@ export function formatDoctorResults(checks: DoctorCheck[]): string {
48
48
  for (const check of checks) {
49
49
  const icon =
50
50
  check.status === "ok"
51
- ? theme.status.success
51
+ ? theme.status.enabled
52
52
  : check.status === "warning"
53
53
  ? theme.status.warning
54
54
  : theme.status.error;
@@ -33,10 +33,11 @@ const PI_PACKAGE_ALTERNATION = PI_PACKAGE_NAMES.join("|");
33
33
  // bundled copy. Add new entries as `pkg/from -> pkg/to` whenever a plugin
34
34
  // surfaces another upstream-only subpath that breaks resolution.
35
35
  const PI_SUBPATH_REMAPS: ReadonlyMap<string, string> = new Map<string, string>([
36
- // `@mariozechner/pi-ai/oauth` re-exported `./utils/oauth/index.js`.
37
- // Our pi-ai keeps the implementation under `utils/oauth` but never added a
38
- // root-level re-export, so map the upstream subpath onto it directly.
39
- ["pi-ai/oauth", "pi-ai/utils/oauth"],
36
+ // (currently empty) Upstream `@mariozechner/pi-ai/oauth` re-exported
37
+ // `./utils/oauth/index.js`. Our pi-ai now exposes the same surface at the
38
+ // real `@oh-my-pi/pi-ai/oauth` export, so the legacy subpath canonicalizes
39
+ // straight to it with no rewrite. Add `from -> to` entries here whenever a
40
+ // future upstream-only subpath surfaces that breaks resolution.
40
41
  ]);
41
42
 
42
43
  const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`);
@@ -119,7 +120,7 @@ const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT
119
120
  // longer satisfies those imports. The override below redirects only the bare
120
121
  // pi-ai package root onto a sibling shim that re-exports the canonical surface
121
122
  // plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath
122
- // imports such as `@oh-my-pi/pi-ai/utils/oauth` continue to resolve directly
123
+ // imports such as `@oh-my-pi/pi-ai/oauth` continue to resolve directly
123
124
  // against the bundled pi-ai package.
124
125
  const LEGACY_PI_AI_SHIM_PATH = BUNFS_PACKAGE_ROOT
125
126
  ? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-ai-shim.js")
@@ -209,7 +209,7 @@ export const goalToolRenderer = {
209
209
 
210
210
  const header = renderStatusLine(
211
211
  {
212
- icon: "success",
212
+ iconOverride: uiTheme.styledSymbol("tool.goal", "accent"),
213
213
  title: "Goal",
214
214
  description,
215
215
  badge: { label: goal.status, color: goalBadgeColor(goal.status) },