@oh-my-pi/pi-coding-agent 14.5.11 → 14.5.13

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 (89) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/config/model-equivalence.ts +49 -16
  5. package/src/config/model-registry.ts +100 -25
  6. package/src/config/model-resolver.ts +29 -15
  7. package/src/config/settings-schema.ts +20 -6
  8. package/src/config/settings.ts +9 -8
  9. package/src/config.ts +9 -0
  10. package/src/eval/backend.ts +43 -0
  11. package/src/eval/eval.lark +43 -0
  12. package/src/eval/index.ts +5 -0
  13. package/src/eval/js/context-manager.ts +717 -0
  14. package/src/eval/js/executor.ts +131 -0
  15. package/src/eval/js/index.ts +46 -0
  16. package/src/eval/js/prelude.ts +2 -0
  17. package/src/eval/js/prelude.txt +84 -0
  18. package/src/eval/js/tool-bridge.ts +124 -0
  19. package/src/eval/parse.ts +337 -0
  20. package/src/{ipy → eval/py}/executor.ts +2 -180
  21. package/src/{ipy → eval/py}/gateway-coordinator.ts +4 -3
  22. package/src/eval/py/index.ts +58 -0
  23. package/src/{ipy → eval/py}/kernel.ts +5 -41
  24. package/src/{ipy → eval/py}/prelude.py +39 -227
  25. package/src/eval/types.ts +48 -0
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.js +23 -17
  28. package/src/extensibility/extensions/types.ts +2 -3
  29. package/src/internal-urls/docs-index.generated.ts +5 -5
  30. package/src/lsp/client.ts +9 -0
  31. package/src/lsp/index.ts +395 -0
  32. package/src/lsp/types.ts +15 -4
  33. package/src/main.ts +25 -14
  34. package/src/mcp/oauth-flow.ts +1 -1
  35. package/src/memories/index.ts +1 -1
  36. package/src/modes/acp/acp-event-mapper.ts +1 -1
  37. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  38. package/src/modes/components/login-dialog.ts +1 -1
  39. package/src/modes/components/oauth-selector.ts +2 -1
  40. package/src/modes/components/tool-execution.ts +3 -4
  41. package/src/modes/controllers/command-controller.ts +28 -8
  42. package/src/modes/controllers/input-controller.ts +4 -4
  43. package/src/modes/controllers/selector-controller.ts +2 -1
  44. package/src/modes/interactive-mode.ts +4 -5
  45. package/src/modes/types.ts +3 -3
  46. package/src/modes/utils/ui-helpers.ts +2 -2
  47. package/src/prompts/system/system-prompt.md +3 -3
  48. package/src/prompts/tools/atom.md +3 -2
  49. package/src/prompts/tools/browser.md +61 -16
  50. package/src/prompts/tools/eval.md +92 -0
  51. package/src/prompts/tools/lsp.md +7 -3
  52. package/src/sdk.ts +45 -31
  53. package/src/session/agent-session.ts +44 -54
  54. package/src/session/messages.ts +1 -1
  55. package/src/slash-commands/builtin-registry.ts +1 -1
  56. package/src/system-prompt.ts +34 -66
  57. package/src/task/executor.ts +5 -9
  58. package/src/tools/browser/attach.ts +175 -0
  59. package/src/tools/browser/launch.ts +576 -0
  60. package/src/tools/browser/readable.ts +90 -0
  61. package/src/tools/browser/registry.ts +198 -0
  62. package/src/tools/browser/render.ts +212 -0
  63. package/src/tools/browser/tab-protocol.ts +101 -0
  64. package/src/tools/browser/tab-supervisor.ts +429 -0
  65. package/src/tools/browser/tab-worker-entry.ts +21 -0
  66. package/src/tools/browser/tab-worker.ts +1006 -0
  67. package/src/tools/browser.ts +231 -1567
  68. package/src/tools/checkpoint.ts +2 -2
  69. package/src/tools/{python.ts → eval.ts} +324 -315
  70. package/src/tools/exit-plan-mode.ts +1 -1
  71. package/src/tools/index.ts +62 -100
  72. package/src/tools/plan-mode-guard.ts +27 -1
  73. package/src/tools/read.ts +0 -6
  74. package/src/tools/recipe/runners/pkg.ts +34 -32
  75. package/src/tools/renderers.ts +4 -2
  76. package/src/tools/resolve.ts +7 -2
  77. package/src/tools/todo-write.ts +0 -1
  78. package/src/tools/tool-timeouts.ts +2 -2
  79. package/src/utils/markit.ts +15 -7
  80. package/src/utils/tools-manager.ts +5 -5
  81. package/src/web/search/index.ts +5 -5
  82. package/src/web/search/provider.ts +121 -39
  83. package/src/web/search/providers/gemini.ts +2 -2
  84. package/src/web/search/render.ts +2 -2
  85. package/src/ipy/modules.ts +0 -144
  86. package/src/prompts/tools/python.md +0 -57
  87. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  88. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  89. /package/src/{ipy → eval/py}/runtime.ts +0 -0
@@ -46,7 +46,7 @@ export class ExitPlanModeTool implements AgentTool<typeof exitPlanModeSchema, Ex
46
46
  readonly parameters = exitPlanModeSchema;
47
47
  readonly strict = true;
48
48
  readonly concurrency = "exclusive";
49
- readonly intent = (): string => "Exiting plan mode";
49
+ readonly intent = (): string => "present plan";
50
50
 
51
51
  constructor(private readonly session: ToolSession) {
52
52
  this.description = prompt.render(exitPlanModeDescription);
@@ -1,14 +1,13 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
2
  import type { ToolChoice } from "@oh-my-pi/pi-ai";
3
- import { $env, $flag, isBunTestRuntime, logger } from "@oh-my-pi/pi-utils";
3
+ import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
4
4
  import type { AsyncJobManager } from "../async";
5
5
  import type { PromptTemplate } from "../config/prompt-templates";
6
6
  import type { Settings } from "../config/settings";
7
7
  import { EditTool } from "../edit";
8
+ import { checkPythonKernelAvailability } from "../eval/py/kernel";
8
9
  import type { Skill } from "../extensibility/skills";
9
10
  import type { InternalUrlRouter } from "../internal-urls";
10
- import { getPreludeDocs, resetPreludeDocsCache, warmPythonEnvironment } from "../ipy/executor";
11
- import { checkPythonKernelAvailability } from "../ipy/kernel";
12
11
  import { LspTool } from "../lsp";
13
12
  import type { DiscoverableMCPSearchIndex, DiscoverableMCPTool } from "../mcp/discoverable-tool-metadata";
14
13
  import type { PlanModeState } from "../plan-mode/state";
@@ -27,6 +26,7 @@ import { BrowserTool } from "./browser";
27
26
  import { CalculatorTool } from "./calculator";
28
27
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
29
28
  import { DebugTool } from "./debug";
29
+ import { EvalTool } from "./eval";
30
30
  import { ExitPlanModeTool } from "./exit-plan-mode";
31
31
  import { FindTool } from "./find";
32
32
  import { GithubTool } from "./gh";
@@ -35,7 +35,6 @@ import { IrcTool } from "./irc";
35
35
  import { JobTool } from "./job";
36
36
  import { NotebookTool } from "./notebook";
37
37
  import { wrapToolWithMetaNotice } from "./output-meta";
38
- import { PythonTool } from "./python";
39
38
  import { ReadTool } from "./read";
40
39
  import { RecipeTool } from "./recipe";
41
40
  import { RenderMermaidTool } from "./render-mermaid";
@@ -66,6 +65,7 @@ export * from "./browser";
66
65
  export * from "./calculator";
67
66
  export * from "./checkpoint";
68
67
  export * from "./debug";
68
+ export * from "./eval";
69
69
  export * from "./exit-plan-mode";
70
70
  export * from "./find";
71
71
  export * from "./gh";
@@ -74,7 +74,6 @@ export * from "./inspect-image";
74
74
  export * from "./irc";
75
75
  export * from "./job";
76
76
  export * from "./notebook";
77
- export * from "./python";
78
77
  export * from "./read";
79
78
  export * from "./recipe";
80
79
  export * from "./render-mermaid";
@@ -108,8 +107,6 @@ export interface ToolSession {
108
107
  hasUI: boolean;
109
108
  /** Skip Python kernel availability check and warmup */
110
109
  skipPythonPreflight?: boolean;
111
- /** Force Python prelude warmup even when test env would normally skip it */
112
- forcePythonWarmup?: boolean;
113
110
  /** Pre-loaded context files (AGENTS.md, etc) */
114
111
  contextFiles?: ContextFileEntry[];
115
112
  /** Pre-loaded skills */
@@ -130,16 +127,18 @@ export interface ToolSession {
130
127
  taskDepth?: number;
131
128
  /** Get session file */
132
129
  getSessionFile: () => string | null;
133
- /** Get Python kernel owner ID for session-scoped retained-kernel cleanup */
134
- getPythonKernelOwnerId?: () => string | null;
135
- /** Reject new Python work once session disposal has started. */
136
- assertPythonExecutionAllowed?: () => void;
137
- /** Track tool-owned Python work so session disposal can await/abort it like direct session Python runs. */
138
- trackPythonExecution?<T>(execution: Promise<T>, abortController: AbortController): Promise<T>;
130
+ /** Get eval kernel owner ID for session-scoped retained-kernel cleanup. */
131
+ getEvalKernelOwnerId?: () => string | null;
132
+ /** Reject new eval (python or js) work once session disposal has started. */
133
+ assertEvalExecutionAllowed?: () => void;
134
+ /** Track tool-owned eval work so session disposal can await/abort it like direct session eval runs. */
135
+ trackEvalExecution?<T>(execution: Promise<T>, abortController: AbortController): Promise<T>;
139
136
  /** Get session ID */
140
137
  getSessionId?: () => string | null;
141
138
  /** Agent identity used for IRC routing. Returns the registry id (e.g. "0-Main", "0-AuthLoader"). */
142
139
  getAgentId?: () => string | null;
140
+ /** Look up a registered tool by name (used by the eval js backend's tool bridge). */
141
+ getToolByName?: (name: string) => AgentTool | undefined;
143
142
  /** Agent registry for IRC routing across live sessions. */
144
143
  agentRegistry?: AgentRegistry;
145
144
  /** Get artifacts directory for artifact:// URLs */
@@ -210,7 +209,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
210
209
  ask: AskTool.createIf,
211
210
  bash: s => new BashTool(s),
212
211
  debug: DebugTool.createIf,
213
- python: s => new PythonTool(s),
212
+ eval: s => new EvalTool(s),
214
213
  calc: s => new CalculatorTool(s),
215
214
  ssh: loadSshTool,
216
215
  edit: s => new EditTool(s),
@@ -244,34 +243,40 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
244
243
 
245
244
  export type ToolName = keyof typeof BUILTIN_TOOLS;
246
245
 
247
- export type PythonToolMode = "ipy-only" | "bash-only" | "both";
246
+ export interface EvalBackendsAllowance {
247
+ python: boolean;
248
+ js: boolean;
249
+ }
248
250
 
249
251
  /**
250
- * Parse PI_PY environment variable to determine Python tool mode.
251
- * Returns null if not set or invalid.
252
- *
253
- * Values:
254
- * - "0" or "bash" → bash-only
255
- * - "1" or "py" → ipy-only
256
- * - "mix" or "both" → both
252
+ * Parse PI_PY / PI_JS environment variables. Each is a boolean flag; unset
253
+ * means "not specified, defer to settings". Returns null when neither is set
254
+ * so the caller can fall through to `readEvalBackendsAllowance` per key.
257
255
  */
258
- function getPythonModeFromEnv(): PythonToolMode | null {
259
- const value = $env.PI_PY?.toLowerCase();
260
- if (!value) return null;
256
+ function getEvalBackendsFromEnv(): EvalBackendsAllowance | null {
257
+ const pyEnv = $env.PI_PY;
258
+ const jsEnv = $env.PI_JS;
259
+ if (pyEnv === undefined && jsEnv === undefined) return null;
260
+ return {
261
+ python: pyEnv === undefined ? true : $flag("PI_PY"),
262
+ js: jsEnv === undefined ? true : $flag("PI_JS"),
263
+ };
264
+ }
261
265
 
262
- switch (value) {
263
- case "0":
264
- case "bash":
265
- return "bash-only";
266
- case "1":
267
- case "py":
268
- return "ipy-only";
269
- case "mix":
270
- case "both":
271
- return "both";
272
- default:
273
- return null;
274
- }
266
+ /** Read per-backend allowance from settings (defaults true). */
267
+ export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance {
268
+ return {
269
+ python: session.settings.get("eval.py") ?? true,
270
+ js: session.settings.get("eval.js") ?? true,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Materialize the active eval backend allowance: PI_PY / PI_JS env flags
276
+ * override the per-key settings; otherwise settings (defaults true) win.
277
+ */
278
+ export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance {
279
+ return getEvalBackendsFromEnv() ?? readEvalBackendsAllowance(session);
275
280
  }
276
281
 
277
282
  /**
@@ -285,77 +290,34 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
285
290
  if (requestedTools && !requestedTools.includes("exit_plan_mode")) {
286
291
  requestedTools.push("exit_plan_mode");
287
292
  }
288
- const pythonMode = getPythonModeFromEnv() ?? session.settings.get("python.toolMode");
293
+ const backends = resolveEvalBackends(session);
294
+ const allowPython = backends.python;
295
+ const allowJs = backends.js;
289
296
  const skipPythonPreflight = session.skipPythonPreflight === true;
297
+ // Eval tool is enabled if EITHER backend is reachable. We only need to know
298
+ // whether python is reachable when JS is disabled — otherwise allowEval is
299
+ // already true and the python-availability check can be deferred to first
300
+ // invocation of the python backend (already handled inside the executor).
290
301
  let pythonAvailable = true;
291
- const shouldCheckPython =
302
+ if (
292
303
  !skipPythonPreflight &&
293
- pythonMode !== "bash-only" &&
294
- (requestedTools === undefined || requestedTools.includes("python"));
295
- const isTestEnv = isBunTestRuntime();
296
- const forcePythonWarmup = session.forcePythonWarmup === true;
297
- const skipPythonWarm = (isTestEnv && !forcePythonWarmup) || $flag("PI_PYTHON_SKIP_CHECK");
298
- const cachedPreludeDocs = getPreludeDocs();
299
- const shouldWarmPython = !skipPythonWarm && (forcePythonWarmup || cachedPreludeDocs.length === 0);
300
- if (shouldCheckPython) {
304
+ allowPython &&
305
+ !allowJs &&
306
+ (requestedTools === undefined || requestedTools.includes("eval"))
307
+ ) {
301
308
  const availability = await logger.time("createTools:pythonCheck", checkPythonKernelAvailability, session.cwd);
302
309
  pythonAvailable = availability.ok;
303
310
  if (!availability.ok) {
304
- logger.warn("Python kernel unavailable, falling back to bash", {
311
+ logger.warn("Python kernel unavailable and JS backend disabled; eval will be unavailable", {
305
312
  reason: availability.reason,
306
313
  });
307
- } else if (shouldWarmPython) {
308
- const sessionFile = session.getSessionFile?.() ?? undefined;
309
- const kernelOwnerId = session.getPythonKernelOwnerId?.() ?? undefined;
310
- const warmSessionId = sessionFile ? `session:${sessionFile}:cwd:${session.cwd}` : `cwd:${session.cwd}`;
311
- const warmupAbortController = new AbortController();
312
- try {
313
- session.assertPythonExecutionAllowed?.();
314
- if (forcePythonWarmup && cachedPreludeDocs.length > 0) {
315
- resetPreludeDocsCache();
316
- }
317
- const warmupExecution = session.trackPythonExecution
318
- ? logger.time(
319
- "createTools:warmPython",
320
- warmPythonEnvironment,
321
- session.cwd,
322
- warmSessionId,
323
- session.settings.get("python.sharedGateway"),
324
- sessionFile,
325
- kernelOwnerId,
326
- warmupAbortController.signal,
327
- )
328
- : logger.time(
329
- "createTools:warmPython",
330
- warmPythonEnvironment,
331
- session.cwd,
332
- warmSessionId,
333
- session.settings.get("python.sharedGateway"),
334
- sessionFile,
335
- kernelOwnerId,
336
- );
337
- await (session.trackPythonExecution?.(warmupExecution, warmupAbortController) ?? warmupExecution);
338
- session.assertPythonExecutionAllowed?.();
339
- } catch (err) {
340
- logger.warn("Failed to warm Python environment", {
341
- error: err instanceof Error ? err.message : String(err),
342
- });
343
- }
344
314
  }
345
315
  }
346
316
 
347
- const effectiveMode = pythonAvailable ? pythonMode : "bash-only";
348
- const allowBash = effectiveMode !== "ipy-only";
349
- const allowPython = effectiveMode !== "bash-only";
350
- if (
351
- requestedTools &&
352
- allowBash &&
353
- !allowPython &&
354
- requestedTools.includes("python") &&
355
- !requestedTools.includes("bash")
356
- ) {
357
- requestedTools.push("bash");
358
- }
317
+ const effectivePythonAllowed = allowPython && pythonAvailable;
318
+ // Eval is exposed whenever any backend is reachable. The python backend may
319
+ // be unreachable, in which case eval dispatches exclusively to js.
320
+ const allowEval = effectivePythonAllowed || allowJs;
359
321
 
360
322
  // Auto-include AST counterparts when their text-based sibling is present
361
323
  if (requestedTools) {
@@ -384,8 +346,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
384
346
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
385
347
  const isToolAllowed = (name: string) => {
386
348
  if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
387
- if (name === "bash") return allowBash;
388
- if (name === "python") return allowPython;
349
+ if (name === "bash") return true;
350
+ if (name === "eval") return allowEval;
389
351
  if (name === "debug") return session.settings.get("debug.enabled");
390
352
  if (name === "todo_write") return !includeYield && session.settings.get("todo.enabled");
391
353
  if (name === "find") return session.settings.get("find.enabled");
@@ -1,3 +1,4 @@
1
+ import * as path from "node:path";
1
2
  import { resolveLocalUrlToPath } from "../internal-urls";
2
3
  import type { ToolSession } from ".";
3
4
  import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
@@ -5,7 +6,7 @@ import { ToolError } from "./tool-errors";
5
6
 
6
7
  const LOCAL_SCHEME_PREFIX = "local:";
7
8
 
8
- export function resolvePlanPath(session: ToolSession, targetPath: string): string {
9
+ function resolveRawPath(session: ToolSession, targetPath: string): string {
9
10
  const normalized = normalizeLocalScheme(targetPath);
10
11
  if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
11
12
  return resolveLocalUrlToPath(normalized, {
@@ -17,6 +18,31 @@ export function resolvePlanPath(session: ToolSession, targetPath: string): strin
17
18
  return resolveToCwd(normalized, session.cwd);
18
19
  }
19
20
 
21
+ /**
22
+ * Resolve a write/edit target to its absolute filesystem path.
23
+ *
24
+ * In plan mode, transparently redirects targets whose basename matches the
25
+ * plan file's basename (e.g. a bare `PLAN.md` or `./PLAN.md`) to the canonical
26
+ * plan file location at `state.planFilePath`. This lets `write` and `edit`
27
+ * accept the unqualified plan filename and have the change land at the
28
+ * session-scoped `local://PLAN.md` artifact instead of a stray cwd-relative
29
+ * file the plan-mode guard would otherwise reject.
30
+ *
31
+ * Outside plan mode (or when the basename does not match) this is a no-op.
32
+ */
33
+ export function resolvePlanPath(session: ToolSession, targetPath: string): string {
34
+ const resolved = resolveRawPath(session, targetPath);
35
+
36
+ const state = session.getPlanModeState?.();
37
+ if (!state?.enabled) return resolved;
38
+
39
+ const planResolved = resolveRawPath(session, state.planFilePath);
40
+ if (resolved === planResolved) return resolved;
41
+ if (path.basename(resolved) !== path.basename(planResolved)) return resolved;
42
+
43
+ return planResolved;
44
+ }
45
+
20
46
  export function enforcePlanModeWrite(
21
47
  session: ToolSession,
22
48
  targetPath: string,
package/src/tools/read.ts CHANGED
@@ -456,12 +456,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
456
456
  readonly parameters = readSchema;
457
457
  readonly nonAbortable = true;
458
458
  readonly strict = true;
459
- readonly intent = (args: Partial<ReadParams>): string => {
460
- const p = typeof args.path === "string" ? args.path.trim() : "";
461
- if (!p) return "Reading";
462
- const isUrl = /^(https?|ftp):\/\//i.test(p);
463
- return isUrl ? `Fetching ${p}` : `Reading ${p}`;
464
- };
465
459
 
466
460
  readonly #autoResizeImages: boolean;
467
461
  readonly #defaultLimit: number;
@@ -10,21 +10,19 @@ interface PackageJsonInfo {
10
10
  }
11
11
 
12
12
  async function resolvePackageRunner(cwd: string): Promise<string> {
13
- if ((await isFile(path.join(cwd, "bun.lock"))) || (await isFile(path.join(cwd, "bun.lockb")))) {
14
- return "bun run";
15
- }
16
- if (await isFile(path.join(cwd, "pnpm-lock.yaml"))) {
17
- return "pnpm run";
18
- }
19
- if (await isFile(path.join(cwd, "yarn.lock"))) {
20
- return "yarn";
21
- }
22
- if ((await isFile(path.join(cwd, "package-lock.json"))) || (await isFile(path.join(cwd, "npm-shrinkwrap.json")))) {
23
- return "npm run";
24
- }
25
- if ($which("bun")) {
26
- return "bun run";
27
- }
13
+ const [bunLock, bunLockb, pnpmLock, yarnLock, npmLock, npmShrink] = await Promise.all([
14
+ isFile(path.join(cwd, "bun.lock")),
15
+ isFile(path.join(cwd, "bun.lockb")),
16
+ isFile(path.join(cwd, "pnpm-lock.yaml")),
17
+ isFile(path.join(cwd, "yarn.lock")),
18
+ isFile(path.join(cwd, "package-lock.json")),
19
+ isFile(path.join(cwd, "npm-shrinkwrap.json")),
20
+ ]);
21
+ if (bunLock || bunLockb) return "bun run";
22
+ if (pnpmLock) return "pnpm run";
23
+ if (yarnLock) return "yarn";
24
+ if (npmLock || npmShrink) return "npm run";
25
+ if ($which("bun")) return "bun run";
28
26
  return "npm run";
29
27
  }
30
28
 
@@ -86,18 +84,23 @@ async function readPackageJson(filePath: string): Promise<PackageJsonInfo | null
86
84
  async function findWorkspacePackageJsons(cwd: string, patterns: string[]): Promise<string[]> {
87
85
  const includePatterns = patterns.filter(pattern => !pattern.startsWith("!")).map(normalizeWorkspacePattern);
88
86
  const excludePatterns = patterns.filter(pattern => pattern.startsWith("!")).map(normalizeWorkspacePattern);
89
- const excluded = new Set<string>();
90
- for (const pattern of excludePatterns) {
91
- for await (const entry of new Bun.Glob(pattern.slice(1)).scan({ cwd, onlyFiles: true })) {
92
- excluded.add(path.normalize(String(entry)));
93
- }
94
- }
95
- const files = new Set<string>();
96
- for (const pattern of includePatterns) {
87
+
88
+ const collect = async (pattern: string): Promise<string[]> => {
89
+ const out: string[] = [];
97
90
  for await (const entry of new Bun.Glob(pattern).scan({ cwd, onlyFiles: true })) {
98
- const normalized = path.normalize(String(entry));
99
- if (normalized !== "package.json" && !excluded.has(normalized)) files.add(normalized);
91
+ out.push(path.normalize(String(entry)));
100
92
  }
93
+ return out;
94
+ };
95
+
96
+ const [excludedLists, includedLists] = await Promise.all([
97
+ Promise.all(excludePatterns.map(pattern => collect(pattern.slice(1)))),
98
+ Promise.all(includePatterns.map(pattern => collect(pattern))),
99
+ ]);
100
+ const excluded = new Set<string>(excludedLists.flat());
101
+ const files = new Set<string>();
102
+ for (const entry of includedLists.flat()) {
103
+ if (entry !== "package.json" && !excluded.has(entry)) files.add(entry);
101
104
  }
102
105
  return [...files].sort((left, right) => left.localeCompare(right));
103
106
  }
@@ -132,10 +135,10 @@ async function readPackageTasks(cwd: string): Promise<RunnerTask[] | null> {
132
135
  );
133
136
  }
134
137
 
135
- for (const packageJsonPath of workspacePackageJsons) {
136
- const pkg = await readPackageJson(path.join(cwd, packageJsonPath));
137
- if (!pkg || pkg.scripts.length === 0) continue;
138
- const packageDir = path.dirname(packageJsonPath);
138
+ const pkgs = await Promise.all(workspacePackageJsons.map(p => readPackageJson(path.join(cwd, p))));
139
+ pkgs.forEach((pkg, index) => {
140
+ if (!pkg || pkg.scripts.length === 0) return;
141
+ const packageDir = path.dirname(workspacePackageJsons[index]);
139
142
  tasks.push(
140
143
  ...tasksForPackage({
141
144
  pkg,
@@ -143,7 +146,7 @@ async function readPackageTasks(cwd: string): Promise<RunnerTask[] | null> {
143
146
  namespaced: true,
144
147
  }),
145
148
  );
146
- }
149
+ });
147
150
 
148
151
  return tasks.length > 0 ? tasks : null;
149
152
  }
@@ -153,8 +156,7 @@ export const pkgRunner: TaskRunner = {
153
156
  label: "Pkg",
154
157
  async detect(cwd: string): Promise<DetectedRunner | null> {
155
158
  try {
156
- const commandPrefix = await resolvePackageRunner(cwd);
157
- const tasks = await readPackageTasks(cwd);
159
+ const [commandPrefix, tasks] = await Promise.all([resolvePackageRunner(cwd), readPackageTasks(cwd)]);
158
160
  if (!tasks || tasks.length === 0) return null;
159
161
  return { id: "pkg", label: "Pkg", commandPrefix, tasks };
160
162
  } catch (err) {
@@ -14,14 +14,15 @@ import { askToolRenderer } from "./ask";
14
14
  import { astEditToolRenderer } from "./ast-edit";
15
15
  import { astGrepToolRenderer } from "./ast-grep";
16
16
  import { bashToolRenderer } from "./bash";
17
+ import { browserToolRenderer } from "./browser/render";
17
18
  import { calculatorToolRenderer } from "./calculator";
18
19
  import { debugToolRenderer } from "./debug";
20
+ import { evalToolRenderer } from "./eval";
19
21
  import { findToolRenderer } from "./find";
20
22
  import { githubToolRenderer } from "./gh-renderer";
21
23
  import { inspectImageToolRenderer } from "./inspect-image-renderer";
22
24
  import { jobToolRenderer } from "./job";
23
25
  import { notebookToolRenderer } from "./notebook";
24
- import { pythonToolRenderer } from "./python";
25
26
  import { readToolRenderer } from "./read";
26
27
  import { recipeToolRenderer } from "./recipe/render";
27
28
  import { resolveToolRenderer } from "./resolve";
@@ -49,9 +50,10 @@ export const toolRenderers: Record<string, ToolRenderer> = {
49
50
  ast_grep: astGrepToolRenderer as ToolRenderer,
50
51
  ast_edit: astEditToolRenderer as ToolRenderer,
51
52
  bash: bashToolRenderer as ToolRenderer,
53
+ browser: browserToolRenderer as ToolRenderer,
52
54
  recipe: recipeToolRenderer as ToolRenderer,
53
55
  debug: debugToolRenderer as ToolRenderer,
54
- python: pythonToolRenderer as ToolRenderer,
56
+ eval: evalToolRenderer as ToolRenderer,
55
57
  calc: calculatorToolRenderer as ToolRenderer,
56
58
  edit: editToolRenderer as ToolRenderer,
57
59
  apply_patch: editToolRenderer as ToolRenderer,
@@ -110,8 +110,13 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
110
110
  readonly description: string;
111
111
  readonly parameters = resolveSchema;
112
112
  readonly strict = true;
113
- readonly intent = (args: Partial<ResolveParams>) =>
114
- args.action === "discard" ? "Discarding pending action" : "Applying pending action";
113
+ readonly intent = (args: Partial<ResolveParams>) => {
114
+ if (args.action === "discard") {
115
+ return args.reason ? `discarding: ${args.reason}` : "aiscarding changes";
116
+ } else {
117
+ return args.reason ? `accepting: ${args.reason}` : "accepting changes";
118
+ }
119
+ };
115
120
 
116
121
  constructor(private readonly session: ToolSession) {
117
122
  this.description = prompt.render(resolveDescription);
@@ -507,7 +507,6 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
507
507
  readonly parameters = todoWriteSchema;
508
508
  readonly concurrency = "exclusive";
509
509
  readonly strict = true;
510
- readonly intent = "omit" as const;
511
510
 
512
511
  constructor(private readonly session: ToolSession) {
513
512
  this.description = prompt.render(todoWriteDescription);
@@ -9,8 +9,8 @@ export interface ToolTimeoutConfig {
9
9
 
10
10
  export const TOOL_TIMEOUTS = {
11
11
  bash: { default: 300, min: 1, max: 3600 },
12
- python: { default: 30, min: 1, max: 600 },
13
- browser: { default: 30, min: 1, max: 120 },
12
+ eval: { default: 30, min: 1, max: 600 },
13
+ browser: { default: 30, min: 1, max: 30 },
14
14
  ssh: { default: 60, min: 1, max: 3600 },
15
15
  fetch: { default: 20, min: 1, max: 45 },
16
16
  lsp: { default: 20, min: 5, max: 60 },
@@ -1,6 +1,5 @@
1
1
  import { untilAborted } from "@oh-my-pi/pi-utils";
2
- import type { StreamInfo } from "markit-ai";
3
- import { Markit } from "markit-ai";
2
+ import type { Markit, StreamInfo } from "markit-ai";
4
3
  import { ToolAbortError } from "../tools/tool-errors";
5
4
 
6
5
  export interface MarkitConversionResult {
@@ -9,7 +8,15 @@ export interface MarkitConversionResult {
9
8
  error?: string;
10
9
  }
11
10
 
12
- const markit = new Markit();
11
+ let markit: () => Markit | Promise<Markit> = async () => {
12
+ const promise = import("markit-ai").then(({ Markit }) => {
13
+ const instance = new Markit();
14
+ markit = () => instance;
15
+ return instance;
16
+ });
17
+ markit = () => promise;
18
+ return promise;
19
+ };
13
20
 
14
21
  function normalizeExtension(extension: string): string {
15
22
  const trimmed = extension.trim().toLowerCase();
@@ -24,9 +31,10 @@ function normalizeError(error: unknown): string {
24
31
  return "Conversion failed";
25
32
  }
26
33
 
27
- async function runMarkitConversion<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T> {
34
+ async function runMarkitConversion<T>(task: (markit: Markit) => Promise<T>, signal?: AbortSignal): Promise<T> {
28
35
  try {
29
- return signal ? await untilAborted(signal, task) : await task();
36
+ const instance = await markit();
37
+ return signal ? await untilAborted(signal, () => task(instance)) : await task(instance);
30
38
  } catch (error) {
31
39
  if (error instanceof ToolAbortError) {
32
40
  throw error;
@@ -48,7 +56,7 @@ function finalizeConversion(markdown?: string): MarkitConversionResult {
48
56
 
49
57
  export async function convertFileWithMarkit(filePath: string, signal?: AbortSignal): Promise<MarkitConversionResult> {
50
58
  try {
51
- const result = await runMarkitConversion(() => markit.convertFile(filePath), signal);
59
+ const result = await runMarkitConversion(markit => markit.convertFile(filePath), signal);
52
60
  return finalizeConversion(result.markdown);
53
61
  } catch (error) {
54
62
  if (error instanceof ToolAbortError) {
@@ -70,7 +78,7 @@ export async function convertBufferWithMarkit(
70
78
  };
71
79
 
72
80
  try {
73
- const result = await runMarkitConversion(() => markit.convert(Buffer.from(buffer), streamInfo), signal);
81
+ const result = await runMarkitConversion(markit => markit.convert(Buffer.from(buffer), streamInfo), signal);
74
82
  return finalizeConversion(result.markdown);
75
83
  } catch (error) {
76
84
  if (error instanceof ToolAbortError) {
@@ -74,14 +74,14 @@ const TOOLS: Record<string, ToolConfig> = {
74
74
  },
75
75
  };
76
76
 
77
- // Python packages installed via uv/pip
78
- interface PythonToolConfig {
77
+ // CLI packages installed via uv/pip
78
+ interface PythonPackageToolConfig {
79
79
  name: string;
80
80
  package: string; // PyPI package name
81
81
  binaryName: string; // CLI command name after install
82
82
  }
83
83
 
84
- const PYTHON_TOOLS: Record<string, PythonToolConfig> = {
84
+ const PYTHON_TOOLS: Record<string, PythonPackageToolConfig> = {
85
85
  trafilatura: {
86
86
  name: "trafilatura",
87
87
  package: "trafilatura",
@@ -93,7 +93,7 @@ export type ToolName = "sd" | "sg" | "yt-dlp" | "trafilatura";
93
93
 
94
94
  // Get the path to a tool (system-wide or in our tools dir)
95
95
  export function getToolPath(tool: ToolName): string | null {
96
- // Check Python tools first
96
+ // Check uv/pip-installed CLI packages first
97
97
  const pythonConfig = PYTHON_TOOLS[tool];
98
98
  if (pythonConfig) {
99
99
  return $which(pythonConfig.binaryName);
@@ -306,7 +306,7 @@ export async function ensureTool(tool: ToolName, silentOrOptions?: EnsureToolOpt
306
306
  return undefined;
307
307
  }
308
308
 
309
- // Handle Python tools
309
+ // Handle uv/pip-installed CLI packages
310
310
  const pythonConfig = PYTHON_TOOLS[tool];
311
311
  if (pythonConfig) {
312
312
  if (!silent) {
@@ -15,7 +15,7 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
15
15
  import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
16
16
  import type { ToolSession } from "../../tools";
17
17
  import { formatAge } from "../../tools/render-utils";
18
- import { getSearchProvider, resolveProviderChain, type SearchProvider } from "./provider";
18
+ import { getSearchProvider, getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
19
19
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
20
20
  import type { SearchProviderId, SearchResponse } from "./types";
21
21
  import { SearchProviderError } from "./types";
@@ -63,7 +63,7 @@ function formatProviderError(error: unknown, provider: SearchProvider): string {
63
63
  if (error.provider === "zai") {
64
64
  return error.message;
65
65
  }
66
- return `${getSearchProvider(error.provider).label} authorization failed (${error.status}). Check API key or base URL.`;
66
+ return `${getSearchProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
67
67
  }
68
68
  return error.message;
69
69
  }
@@ -139,9 +139,9 @@ async function executeSearch(
139
139
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
140
140
  const providers =
141
141
  params.provider && params.provider !== "auto"
142
- ? (await getSearchProvider(params.provider).isAvailable())
143
- ? [getSearchProvider(params.provider)]
144
- : await resolveProviderChain("auto")
142
+ ? await getSearchProvider(params.provider).then(provider =>
143
+ provider.isAvailable() ? [provider] : resolveProviderChain("auto"),
144
+ )
145
145
  : await resolveProviderChain();
146
146
  if (providers.length === 0) {
147
147
  const message = "No web search provider configured.";