@oh-my-pi/pi-coding-agent 14.5.12 → 14.5.14

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 (112) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/commit/pipeline.ts +4 -3
  5. package/src/config/model-equivalence.ts +49 -16
  6. package/src/config/model-registry.ts +100 -25
  7. package/src/config/model-resolver.ts +29 -15
  8. package/src/config/settings-schema.ts +20 -6
  9. package/src/config/settings.ts +9 -8
  10. package/src/config.ts +18 -6
  11. package/src/eval/backend.ts +43 -0
  12. package/src/eval/eval.lark +43 -0
  13. package/src/eval/index.ts +5 -0
  14. package/src/eval/js/context-manager.ts +717 -0
  15. package/src/eval/js/executor.ts +131 -0
  16. package/src/eval/js/index.ts +46 -0
  17. package/src/eval/js/prelude.ts +2 -0
  18. package/src/eval/js/prelude.txt +84 -0
  19. package/src/eval/js/tool-bridge.ts +124 -0
  20. package/src/eval/parse.ts +337 -0
  21. package/src/{ipy → eval/py}/executor.ts +2 -180
  22. package/src/{ipy → eval/py}/gateway-coordinator.ts +2 -2
  23. package/src/eval/py/index.ts +58 -0
  24. package/src/{ipy → eval/py}/kernel.ts +9 -45
  25. package/src/{ipy → eval/py}/prelude.py +39 -227
  26. package/src/eval/types.ts +48 -0
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +8 -10
  29. package/src/extensibility/extensions/types.ts +2 -3
  30. package/src/internal-urls/docs-index.generated.ts +5 -5
  31. package/src/lsp/client.ts +9 -0
  32. package/src/lsp/index.ts +395 -0
  33. package/src/lsp/types.ts +15 -4
  34. package/src/main.ts +35 -14
  35. package/src/mcp/manager.ts +22 -0
  36. package/src/mcp/oauth-flow.ts +1 -1
  37. package/src/memories/index.ts +1 -1
  38. package/src/modes/acp/acp-event-mapper.ts +1 -1
  39. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  40. package/src/modes/components/login-dialog.ts +1 -1
  41. package/src/modes/components/oauth-selector.ts +2 -1
  42. package/src/modes/components/tool-execution.ts +3 -4
  43. package/src/modes/controllers/command-controller.ts +28 -8
  44. package/src/modes/controllers/input-controller.ts +4 -4
  45. package/src/modes/controllers/selector-controller.ts +2 -1
  46. package/src/modes/interactive-mode.ts +4 -5
  47. package/src/modes/rpc/rpc-client.ts +9 -0
  48. package/src/modes/rpc/rpc-mode.ts +6 -0
  49. package/src/modes/rpc/rpc-types.ts +9 -0
  50. package/src/modes/types.ts +3 -3
  51. package/src/modes/utils/ui-helpers.ts +2 -2
  52. package/src/prompts/system/system-prompt.md +3 -3
  53. package/src/prompts/tools/eval.md +92 -0
  54. package/src/prompts/tools/lsp.md +7 -3
  55. package/src/sdk.ts +64 -35
  56. package/src/session/agent-session.ts +152 -46
  57. package/src/session/messages.ts +1 -1
  58. package/src/slash-commands/builtin-registry.ts +1 -1
  59. package/src/system-prompt.ts +34 -66
  60. package/src/task/agents.ts +4 -5
  61. package/src/task/executor.ts +5 -9
  62. package/src/tools/archive-reader.ts +9 -3
  63. package/src/tools/browser/launch.ts +22 -0
  64. package/src/tools/browser/readable.ts +11 -6
  65. package/src/tools/browser/registry.ts +25 -244
  66. package/src/tools/browser/render.ts +1 -1
  67. package/src/tools/browser/tab-protocol.ts +101 -0
  68. package/src/tools/browser/tab-supervisor.ts +429 -0
  69. package/src/tools/browser/tab-worker-entry.ts +21 -0
  70. package/src/tools/browser/tab-worker.ts +1006 -0
  71. package/src/tools/browser.ts +17 -32
  72. package/src/tools/checkpoint.ts +2 -2
  73. package/src/tools/{python.ts → eval.ts} +324 -315
  74. package/src/tools/exit-plan-mode.ts +1 -1
  75. package/src/tools/image-gen.ts +2 -2
  76. package/src/tools/index.ts +62 -100
  77. package/src/tools/read.ts +0 -6
  78. package/src/tools/recipe/runners/pkg.ts +34 -32
  79. package/src/tools/renderers.ts +2 -2
  80. package/src/tools/resolve.ts +7 -2
  81. package/src/tools/todo-write.ts +0 -1
  82. package/src/tools/tool-timeouts.ts +2 -2
  83. package/src/tools/write.ts +8 -1
  84. package/src/utils/markit.ts +15 -7
  85. package/src/utils/tools-manager.ts +5 -5
  86. package/src/web/scrapers/crossref.ts +3 -3
  87. package/src/web/scrapers/devto.ts +1 -1
  88. package/src/web/scrapers/discourse.ts +5 -5
  89. package/src/web/scrapers/firefox-addons.ts +1 -1
  90. package/src/web/scrapers/flathub.ts +2 -2
  91. package/src/web/scrapers/gitlab.ts +1 -1
  92. package/src/web/scrapers/go-pkg.ts +2 -2
  93. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  94. package/src/web/scrapers/mastodon.ts +9 -9
  95. package/src/web/scrapers/mdn.ts +11 -7
  96. package/src/web/scrapers/pub-dev.ts +1 -1
  97. package/src/web/scrapers/rawg.ts +3 -3
  98. package/src/web/scrapers/readthedocs.ts +1 -1
  99. package/src/web/scrapers/spdx.ts +1 -1
  100. package/src/web/scrapers/stackoverflow.ts +2 -2
  101. package/src/web/scrapers/types.ts +53 -39
  102. package/src/web/scrapers/w3c.ts +1 -1
  103. package/src/web/search/index.ts +5 -5
  104. package/src/web/search/provider.ts +121 -39
  105. package/src/web/search/providers/gemini.ts +4 -4
  106. package/src/web/search/render.ts +2 -2
  107. package/src/ipy/modules.ts +0 -144
  108. package/src/prompts/tools/python.md +0 -57
  109. package/src/tools/browser/vm.ts +0 -792
  110. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  111. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  112. /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,6 +1,6 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import { getAntigravityHeaders, getEnvApiKey, type Model, StringEnum } from "@oh-my-pi/pi-ai";
3
+ import { getAntigravityUserAgent, getEnvApiKey, type Model, StringEnum } from "@oh-my-pi/pi-ai";
4
4
  import {
5
5
  CODEX_BASE_URL,
6
6
  getCodexAccountId,
@@ -1055,7 +1055,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1055
1055
  Authorization: `Bearer ${apiKey.apiKey}`,
1056
1056
  "Content-Type": "application/json",
1057
1057
  Accept: "text/event-stream",
1058
- ...getAntigravityHeaders(),
1058
+ "User-Agent": getAntigravityUserAgent(),
1059
1059
  },
1060
1060
  body: JSON.stringify(requestBody),
1061
1061
  signal: requestSignal,
@@ -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");
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) {
@@ -17,12 +17,12 @@ import { bashToolRenderer } from "./bash";
17
17
  import { browserToolRenderer } from "./browser/render";
18
18
  import { calculatorToolRenderer } from "./calculator";
19
19
  import { debugToolRenderer } from "./debug";
20
+ import { evalToolRenderer } from "./eval";
20
21
  import { findToolRenderer } from "./find";
21
22
  import { githubToolRenderer } from "./gh-renderer";
22
23
  import { inspectImageToolRenderer } from "./inspect-image-renderer";
23
24
  import { jobToolRenderer } from "./job";
24
25
  import { notebookToolRenderer } from "./notebook";
25
- import { pythonToolRenderer } from "./python";
26
26
  import { readToolRenderer } from "./read";
27
27
  import { recipeToolRenderer } from "./recipe/render";
28
28
  import { resolveToolRenderer } from "./resolve";
@@ -53,7 +53,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
53
53
  browser: browserToolRenderer as ToolRenderer,
54
54
  recipe: recipeToolRenderer as ToolRenderer,
55
55
  debug: debugToolRenderer as ToolRenderer,
56
- python: pythonToolRenderer as ToolRenderer,
56
+ eval: evalToolRenderer as ToolRenderer,
57
57
  calc: calculatorToolRenderer as ToolRenderer,
58
58
  edit: editToolRenderer as ToolRenderer,
59
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 },
@@ -6,7 +6,6 @@ import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
- import { unzipSync, zipSync } from "fflate";
10
9
  import { stripHashlinePrefixes } from "../edit";
11
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
11
  import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
@@ -44,6 +43,12 @@ import {
44
43
  import { ToolError } from "./tool-errors";
45
44
  import { toolResult } from "./tool-result";
46
45
 
46
+ let fflateModulePromise: Promise<typeof import("fflate")> | undefined;
47
+ async function loadFflate(): Promise<typeof import("fflate")> {
48
+ if (!fflateModulePromise) fflateModulePromise = import("fflate");
49
+ return fflateModulePromise;
50
+ }
51
+
47
52
  const writeSchema = Type.Object({
48
53
  path: Type.String({ description: "file path", examples: ["src/new.ts"] }),
49
54
  content: Type.String({ description: "file content" }),
@@ -229,6 +234,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
229
234
  if (resolvedArchivePath.exists) {
230
235
  try {
231
236
  const bytes = await Bun.file(resolvedArchivePath.absolutePath).bytes();
237
+ const { unzipSync } = await loadFflate();
232
238
  const existing = unzipSync(new Uint8Array(bytes));
233
239
  for (const [entryPath, data] of Object.entries(existing)) {
234
240
  zipEntries[entryPath.replace(/\\/g, "/")] = data;
@@ -241,6 +247,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
241
247
  zipEntries[resolvedArchivePath.archiveSubPath] = new TextEncoder().encode(content);
242
248
 
243
249
  try {
250
+ const { zipSync } = await loadFflate();
244
251
  const zipBuffer = zipSync(zipEntries);
245
252
  await Bun.write(resolvedArchivePath.absolutePath, zipBuffer);
246
253
  } catch (error) {
@@ -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) {
@@ -66,10 +66,10 @@ function formatDate(date?: CrossrefDate): string | null {
66
66
  return formatted.join("-");
67
67
  }
68
68
 
69
- function formatAbstract(abstract?: string): string | null {
69
+ async function formatAbstract(abstract?: string): Promise<string | null> {
70
70
  if (!abstract) return null;
71
71
  const normalized = abstract.replace(/<\/?jats:p[^>]*>/g, match => (match.startsWith("</") ? "</p>" : "<p>"));
72
- const markdown = htmlToBasicMarkdown(normalized);
72
+ const markdown = await htmlToBasicMarkdown(normalized);
73
73
  return markdown.trim().length > 0 ? markdown : null;
74
74
  }
75
75
 
@@ -114,7 +114,7 @@ export const handleCrossref: SpecialHandler = async (
114
114
  formatDate(message.issued) ||
115
115
  formatDate(message.created);
116
116
  const doiValue = message.DOI || doi;
117
- const abstract = formatAbstract(message.abstract);
117
+ const abstract = await formatAbstract(message.abstract);
118
118
  const type = message.type?.replace(/-/g, " ");
119
119
 
120
120
  let md = `# ${title}\n\n`;
@@ -133,7 +133,7 @@ export const handleDevTo: SpecialHandler = async (
133
133
  if (article.body_markdown) {
134
134
  md += article.body_markdown;
135
135
  } else if (article.body_html) {
136
- md += htmlToBasicMarkdown(article.body_html);
136
+ md += await htmlToBasicMarkdown(article.body_html);
137
137
  }
138
138
 
139
139
  notes.push("Fetched via dev.to API");