@oh-my-pi/pi-coding-agent 10.3.2 → 10.5.0

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 (51) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/package.json +16 -11
  3. package/src/capability/index.ts +9 -0
  4. package/src/cli/update-cli.ts +2 -5
  5. package/src/config/settings-schema.ts +18 -0
  6. package/src/extensibility/custom-tools/wrapper.ts +9 -33
  7. package/src/extensibility/extensions/wrapper.ts +18 -31
  8. package/src/extensibility/hooks/tool-wrapper.ts +6 -16
  9. package/src/extensibility/tool-proxy.ts +25 -0
  10. package/src/index.ts +1 -0
  11. package/src/ipy/executor.ts +107 -3
  12. package/src/ipy/gateway-coordinator.ts +0 -4
  13. package/src/ipy/kernel.ts +65 -175
  14. package/src/main.ts +17 -0
  15. package/src/mcp/render.ts +10 -226
  16. package/src/modes/components/tool-execution.ts +83 -96
  17. package/src/modes/controllers/input-controller.ts +38 -0
  18. package/src/modes/interactive-mode.ts +13 -0
  19. package/src/patch/index.ts +1 -0
  20. package/src/prompts/system/system-prompt.md +5 -2
  21. package/src/prompts/tools/ask.md +6 -9
  22. package/src/prompts/tools/browser.md +26 -0
  23. package/src/prompts/tools/task.md +29 -4
  24. package/src/sdk.ts +21 -0
  25. package/src/session/session-manager.ts +1 -0
  26. package/src/task/executor.ts +5 -47
  27. package/src/tools/ask.ts +60 -71
  28. package/src/tools/bash.ts +1 -0
  29. package/src/tools/browser.ts +1138 -0
  30. package/src/tools/find.ts +11 -2
  31. package/src/tools/index.ts +4 -0
  32. package/src/tools/json-tree.ts +231 -0
  33. package/src/tools/notebook.ts +1 -0
  34. package/src/tools/puppeteer/00_stealth_tampering.txt +63 -0
  35. package/src/tools/puppeteer/01_stealth_activity.txt +20 -0
  36. package/src/tools/puppeteer/02_stealth_hairline.txt +11 -0
  37. package/src/tools/puppeteer/03_stealth_botd.txt +384 -0
  38. package/src/tools/puppeteer/04_stealth_iframe.txt +81 -0
  39. package/src/tools/puppeteer/05_stealth_webgl.txt +75 -0
  40. package/src/tools/puppeteer/06_stealth_screen.txt +72 -0
  41. package/src/tools/puppeteer/07_stealth_fonts.txt +97 -0
  42. package/src/tools/puppeteer/08_stealth_audio.txt +51 -0
  43. package/src/tools/puppeteer/09_stealth_locale.txt +46 -0
  44. package/src/tools/puppeteer/10_stealth_plugins.txt +206 -0
  45. package/src/tools/puppeteer/11_stealth_hardware.txt +8 -0
  46. package/src/tools/puppeteer/12_stealth_codecs.txt +40 -0
  47. package/src/tools/puppeteer/13_stealth_worker.txt +74 -0
  48. package/src/tools/python.ts +1 -0
  49. package/src/tools/ssh.ts +1 -0
  50. package/src/tools/todo-write.ts +1 -0
  51. package/src/tools/write.ts +1 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,40 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [10.5.0] - 2026-02-04
6
+
7
+ ### Breaking Changes
8
+
9
+ - Changed `ask` tool to require `questions` array parameter; single-question mode with `question`, `options`, `multi`, and `recommended` parameters is no longer supported
10
+ - Removed support for local Python kernel gateway startup; shared gateway is now required
11
+
12
+ ### Added
13
+
14
+ - Added browser tool powered by Ulixee Hero with support for navigation, DOM interaction, screenshots, and readable content extraction
15
+ - Added `/browser` command to toggle browser headless vs visible mode in interactive sessions
16
+ - Added `browser.enabled` and `browser.headless` settings to control browser automation behavior
17
+ - Added Python prelude caching to improve startup performance by storing compiled prelude helpers and module metadata
18
+ - Added `OMP_DEBUG_STARTUP` environment variable for conditional startup performance debugging output
19
+ - Added autonomous memory system with storage, memory tools, and context injection
20
+
21
+ ### Changed
22
+
23
+ - Updated task tool guidance to enforce small, well-defined task scope with maximum 3-5 files per task to prevent timeouts and improve parallel execution
24
+ - Updated browser viewport to use 1.25x device scale factor for improved rendering on high-DPI displays
25
+ - Modified device pixel ratio detection to respect actual screen capabilities instead of forcing 1x ratio
26
+ - Updated system prompt guidance to state assumptions and proceed without asking for confirmation, reducing unnecessary round-trips
27
+ - Tightened `ask` tool conditions to require multiple approaches with significantly different tradeoffs before prompting user
28
+ - Strengthened `ask` tool guidance to default to action and only ask when genuinely blocked by decisions with materially different outcomes
29
+ - Changed refactor workflow to automatically remove now-unused elements and note removals instead of asking for confirmation
30
+ - Enforced exclusive concurrency mode for all file-modifying tools (edit, write, bash, python, ssh, todo-write) to prevent concurrent execution conflicts
31
+ - Updated `ask` tool guidance to prioritize proactive problem-solving and default to action, asking only when truly blocked by decisions that materially change scope or behavior
32
+ - Changed Python kernel initialization to require shared gateway mode; local gateway startup has been removed
33
+ - Changed shared gateway error handling to retry on server errors (5xx status codes) before failing
34
+
35
+ ### Fixed
36
+
37
+ - Fixed glob search returning no results when all files are ignored by gitignore by automatically retrying without gitignore filtering
38
+
5
39
  ## [10.3.2] - 2026-02-03
6
40
  ### Added
7
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "10.3.2",
3
+ "version": "10.5.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -79,31 +79,36 @@
79
79
  "test": "bun test"
80
80
  },
81
81
  "dependencies": {
82
- "@oh-my-pi/omp-stats": "10.3.2",
83
- "@oh-my-pi/pi-agent-core": "10.3.2",
84
- "@oh-my-pi/pi-ai": "10.3.2",
85
- "@oh-my-pi/pi-natives": "10.3.2",
86
- "@oh-my-pi/pi-tui": "10.3.2",
87
- "@oh-my-pi/pi-utils": "10.3.2",
88
- "@openai/agents": "^0.4.4",
82
+ "@mozilla/readability": "0.6.0",
83
+ "@oh-my-pi/omp-stats": "10.5.0",
84
+ "@oh-my-pi/pi-agent-core": "10.5.0",
85
+ "@oh-my-pi/pi-ai": "10.5.0",
86
+ "@oh-my-pi/pi-natives": "10.5.0",
87
+ "@oh-my-pi/pi-tui": "10.5.0",
88
+ "@oh-my-pi/pi-utils": "10.5.0",
89
+ "@openai/agents": "^0.4.5",
89
90
  "@sinclair/typebox": "^0.34.48",
90
91
  "ajv": "^8.17.1",
91
92
  "chalk": "^5.6.2",
92
93
  "diff": "^8.0.3",
93
94
  "file-type": "^21.3.0",
94
- "glob": "^13.0.0",
95
+ "glob": "^13.0.1",
95
96
  "handlebars": "^4.7.8",
96
97
  "ignore": "^7.0.5",
98
+ "jsdom": "28.0.0",
97
99
  "marked": "^17.0.1",
98
100
  "nanoid": "^5.1.6",
99
101
  "node-html-parser": "^7.0.2",
102
+ "puppeteer": "^24.36.1",
100
103
  "smol-toml": "^1.6.0",
101
104
  "zod": "^4.3.6"
102
105
  },
103
106
  "devDependencies": {
104
- "@types/diff": "^8.0.0",
107
+ "@types/diff": "^7.0.2",
108
+ "@types/jsdom": "27.0.0",
109
+ "bun-types": "^1.3.8",
105
110
  "@types/ms": "^2.1.0",
106
- "@types/node": "^25.0.10",
111
+ "@types/bun": "^1.3.8",
107
112
  "ms": "^2.1.3"
108
113
  },
109
114
  "keywords": [
@@ -8,6 +8,12 @@
8
8
  */
9
9
  import * as os from "node:os";
10
10
  import * as path from "node:path";
11
+
12
+ /** Conditional startup debug prints (stderr) when OMP_DEBUG_STARTUP is set */
13
+ const debugStartup = process.env.OMP_DEBUG_STARTUP
14
+ ? (stage: string) => process.stderr.write(`[startup] ${stage}\n`)
15
+ : () => {};
16
+
11
17
  import type { Settings } from "../config/settings";
12
18
  import { clearCache as clearFsCache, cacheStats as fsCacheStats, invalidate as invalidateFs } from "./fs";
13
19
  import type {
@@ -109,9 +115,12 @@ async function loadImpl<T>(
109
115
  const results = await Promise.all(
110
116
  providers.map(async provider => {
111
117
  try {
118
+ debugStartup(`capability:${capability.id}:${provider.id}:start`);
112
119
  const result = await provider.load(ctx);
120
+ debugStartup(`capability:${capability.id}:${provider.id}:done`);
113
121
  return { provider, result };
114
122
  } catch (error) {
123
+ debugStartup(`capability:${capability.id}:${provider.id}:error`);
115
124
  return { provider, error };
116
125
  }
117
126
  }),
@@ -7,7 +7,6 @@
7
7
  import { execSync, spawnSync } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
9
  import * as path from "node:path";
10
- import { Readable } from "node:stream";
11
10
  import { pipeline } from "node:stream/promises";
12
11
  import { isEnoent } from "@oh-my-pi/pi-utils";
13
12
  import chalk from "chalk";
@@ -200,8 +199,7 @@ async function updateViaBinary(release: ReleaseInfo): Promise<void> {
200
199
  }
201
200
 
202
201
  const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 });
203
- const nodeStream = Readable.fromWeb(response.body as import("stream/web").ReadableStream);
204
- await pipeline(nodeStream, fileStream);
202
+ await pipeline(response.body, fileStream);
205
203
 
206
204
  // Download native addon
207
205
  console.log(chalk.dim(`Downloading ${nativeAddonName}...`));
@@ -212,8 +210,7 @@ async function updateViaBinary(release: ReleaseInfo): Promise<void> {
212
210
  }
213
211
 
214
212
  const nativeFileStream = fs.createWriteStream(nativeTempPath, { mode: 0o755 });
215
- const nativeNodeStream = Readable.fromWeb(nativeResponse.body as import("stream/web").ReadableStream);
216
- await pipeline(nativeNodeStream, nativeFileStream);
213
+ await pipeline(nativeResponse.body, nativeFileStream);
217
214
 
218
215
  // Replace current binary
219
216
  console.log(chalk.dim("Installing update..."));
@@ -358,6 +358,24 @@ export const SETTINGS_SCHEMA = {
358
358
  description: "Enable the calculator tool for basic calculations",
359
359
  },
360
360
  },
361
+ "browser.enabled": {
362
+ type: "boolean",
363
+ default: false,
364
+ ui: {
365
+ tab: "tools",
366
+ label: "Enable Browser",
367
+ description: "Enable the browser tool (Ulixee Hero)",
368
+ },
369
+ },
370
+ "browser.headless": {
371
+ type: "boolean",
372
+ default: true,
373
+ ui: {
374
+ tab: "tools",
375
+ label: "Browser headless",
376
+ description: "Launch browser in headless mode (disable to show browser UI)",
377
+ },
378
+ },
361
379
 
362
380
  // ─────────────────────────────────────────────────────────────────────────
363
381
  // Startup settings
@@ -1,34 +1,25 @@
1
1
  /**
2
2
  * CustomToolAdapter wraps CustomTool instances into AgentTool for use with the agent.
3
3
  */
4
- import type { AgentTool, AgentToolResult, AgentToolUpdateCallback, RenderResultOptions } from "@oh-my-pi/pi-agent-core";
5
- import type { Component } from "@oh-my-pi/pi-tui";
4
+ import type { AgentTool, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
6
5
  import type { Static, TSchema } from "@sinclair/typebox";
7
6
  import type { Theme } from "../../modes/theme/theme";
7
+ import { applyToolProxy } from "../tool-proxy";
8
8
  import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types";
9
9
 
10
10
  export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any, TTheme extends Theme = Theme>
11
11
  implements AgentTool<TParams, TDetails, TTheme>
12
12
  {
13
+ declare name: string;
14
+ declare label: string;
15
+ declare description: string;
16
+ declare parameters: TParams;
17
+
13
18
  constructor(
14
19
  private tool: CustomTool<TParams, TDetails>,
15
20
  private getContext: () => CustomToolContext,
16
- ) {}
17
-
18
- get name() {
19
- return this.tool.name;
20
- }
21
-
22
- get label() {
23
- return this.tool.label;
24
- }
25
-
26
- get description() {
27
- return this.tool.description;
28
- }
29
-
30
- get parameters() {
31
- return this.tool.parameters;
21
+ ) {
22
+ applyToolProxy(tool, this);
32
23
  }
33
24
 
34
25
  execute(
@@ -41,21 +32,6 @@ export class CustomToolAdapter<TParams extends TSchema = TSchema, TDetails = any
41
32
  return this.tool.execute(toolCallId, params, onUpdate, context ?? this.getContext(), signal);
42
33
  }
43
34
 
44
- /** Optional custom rendering for tool call display (returns UI component) */
45
- renderCall(args: Static<TParams>, theme: TTheme): Component | undefined {
46
- return this.tool.renderCall?.(args, theme);
47
- }
48
-
49
- /** Optional custom rendering for tool result display (returns UI component) */
50
- renderResult(
51
- result: AgentToolResult<TDetails>,
52
- options: RenderResultOptions,
53
- theme: TTheme,
54
- args?: Static<TParams>,
55
- ): Component | undefined {
56
- return this.tool.renderResult?.(result, options, theme, args);
57
- }
58
-
59
35
  /**
60
36
  * Backward-compatible export of factory function for existing callers.
61
37
  * Prefer CustomToolAdapter constructor directly.
@@ -5,6 +5,7 @@ import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-m
5
5
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
6
6
  import type { Static, TSchema } from "@sinclair/typebox";
7
7
  import type { Theme } from "../../modes/theme/theme";
8
+ import { applyToolProxy } from "../tool-proxy";
8
9
  import type { ExtensionRunner } from "./runner";
9
10
  import type { RegisteredTool, ToolCallEventResult, ToolResultEventResult } from "./types";
10
11
 
@@ -12,20 +13,16 @@ import type { RegisteredTool, ToolCallEventResult, ToolResultEventResult } from
12
13
  * Adapts a RegisteredTool into an AgentTool.
13
14
  */
14
15
  export class RegisteredToolAdapter implements AgentTool<any, any, any> {
15
- readonly name: string;
16
- readonly label: string;
17
- readonly description: string;
18
- readonly parameters: any;
16
+ declare name: string;
17
+ declare description: string;
18
+ declare parameters: any;
19
+ declare label: string;
19
20
 
20
21
  constructor(
21
22
  private registeredTool: RegisteredTool,
22
23
  private runner: ExtensionRunner,
23
24
  ) {
24
- const { definition } = registeredTool;
25
- this.name = definition.name;
26
- this.label = definition.label || "";
27
- this.description = definition.description;
28
- this.parameters = definition.parameters;
25
+ applyToolProxy(registeredTool.definition, this);
29
26
  }
30
27
 
31
28
  async execute(
@@ -74,35 +71,25 @@ export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: E
74
71
  export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetails = unknown>
75
72
  implements AgentTool<TParameters, TDetails>
76
73
  {
77
- renderCall?: AgentTool<TParameters, TDetails>["renderCall"];
78
- renderResult?: AgentTool<TParameters, TDetails>["renderResult"];
79
- mergeCallAndResult?: boolean;
80
- inline?: boolean;
74
+ declare name: string;
75
+ declare description: string;
76
+ declare parameters: TParameters;
77
+ declare label: string;
81
78
 
82
79
  constructor(
83
80
  private tool: AgentTool<TParameters, TDetails>,
84
81
  private runner: ExtensionRunner,
85
82
  ) {
86
- this.renderCall = tool.renderCall?.bind(tool);
87
- this.renderResult = tool.renderResult?.bind(tool);
88
- this.mergeCallAndResult = (tool as { mergeCallAndResult?: boolean }).mergeCallAndResult;
89
- this.inline = (tool as { inline?: boolean }).inline;
83
+ applyToolProxy(tool, this);
90
84
  }
91
85
 
92
- get name(): string {
93
- return this.tool.name;
94
- }
95
-
96
- get label(): string {
97
- return this.tool.label ?? "";
98
- }
99
-
100
- get description(): string {
101
- return this.tool.description;
102
- }
103
-
104
- get parameters(): TParameters {
105
- return this.tool.parameters;
86
+ /**
87
+ * Forward browser mode changes when available.
88
+ */
89
+ restartForModeChange(): Promise<void> {
90
+ const target = this.tool as { restartForModeChange?: () => Promise<void> };
91
+ if (!target.restartForModeChange) return Promise.resolve();
92
+ return target.restartForModeChange();
106
93
  }
107
94
 
108
95
  async execute(
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Static, TSchema } from "@sinclair/typebox";
6
+ import { applyToolProxy } from "../tool-proxy";
6
7
  import type { HookRunner } from "./runner";
7
8
  import type { ToolCallEventResult, ToolResultEventResult } from "./types";
8
9
 
@@ -17,27 +18,16 @@ import type { ToolCallEventResult, ToolResultEventResult } from "./types";
17
18
  export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = unknown>
18
19
  implements AgentTool<TParameters, TDetails>
19
20
  {
20
- name: string;
21
- label: string;
22
- description: string;
23
- parameters: TParameters;
24
- renderCall?: AgentTool<TParameters, TDetails>["renderCall"];
25
- renderResult?: AgentTool<TParameters, TDetails>["renderResult"];
26
- mergeCallAndResult?: boolean;
27
- inline?: boolean;
21
+ declare name: string;
22
+ declare description: string;
23
+ declare parameters: TParameters;
24
+ declare label: string;
28
25
 
29
26
  constructor(
30
27
  private tool: AgentTool<TParameters, TDetails>,
31
28
  private hookRunner: HookRunner,
32
29
  ) {
33
- this.name = tool.name;
34
- this.label = tool.label ?? "";
35
- this.description = tool.description;
36
- this.parameters = tool.parameters;
37
- this.renderCall = tool.renderCall?.bind(tool);
38
- this.renderResult = tool.renderResult?.bind(tool);
39
- this.mergeCallAndResult = (tool as { mergeCallAndResult?: boolean }).mergeCallAndResult;
40
- this.inline = (tool as { inline?: boolean }).inline;
30
+ applyToolProxy(tool, this);
41
31
  }
42
32
 
43
33
  async execute(
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Defines lazy proxy properties on a wrapper so it forwards to the underlying tool.
3
+ */
4
+ export function applyToolProxy<TTool extends object>(tool: TTool, wrapper: object): void {
5
+ const visited = new Set<PropertyKey>();
6
+ let current: object | null = tool;
7
+
8
+ while (current && current !== Object.prototype) {
9
+ for (const key of Reflect.ownKeys(current)) {
10
+ if (key === "constructor" || visited.has(key) || key in wrapper) {
11
+ continue;
12
+ }
13
+ visited.add(key);
14
+ Object.defineProperty(wrapper, key, {
15
+ get() {
16
+ const value = (tool as Record<PropertyKey, unknown>)[key];
17
+ return typeof value === "function" ? value.bind(tool) : value;
18
+ },
19
+ enumerable: true,
20
+ configurable: true,
21
+ });
22
+ }
23
+ current = Object.getPrototypeOf(current);
24
+ }
25
+ }
package/src/index.ts CHANGED
@@ -239,6 +239,7 @@ export {
239
239
  // Tools (detail types and utilities)
240
240
  export {
241
241
  type BashToolDetails,
242
+ type BrowserToolDetails,
242
243
  DEFAULT_MAX_BYTES,
243
244
  DEFAULT_MAX_LINES,
244
245
  type FindOperations,
@@ -1,4 +1,6 @@
1
- import { logger } from "@oh-my-pi/pi-utils";
1
+ import * as path from "node:path";
2
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
3
+ import { getAgentDir } from "../config";
2
4
  import { OutputSink } from "../session/streaming-output";
3
5
  import { time } from "../utils/timings";
4
6
  import { shutdownSharedGateway } from "./gateway-coordinator";
@@ -10,6 +12,12 @@ import {
10
12
  type PreludeHelper,
11
13
  PythonKernel,
12
14
  } from "./kernel";
15
+ import { discoverPythonModules } from "./modules";
16
+ import { PYTHON_PRELUDE } from "./prelude";
17
+
18
+ const debugStartup = process.env.OMP_DEBUG_STARTUP
19
+ ? (stage: string) => process.stderr.write(`[startup] ${stage}\n`)
20
+ : () => {};
13
21
 
14
22
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
15
23
  const MAX_KERNEL_SESSIONS = 4;
@@ -86,6 +94,72 @@ const kernelSessions = new Map<string, KernelSession>();
86
94
  let cachedPreludeDocs: PreludeHelper[] | null = null;
87
95
  let cleanupTimer: NodeJS.Timeout | null = null;
88
96
 
97
+ interface PreludeCacheSource {
98
+ path: string;
99
+ hash: string;
100
+ }
101
+
102
+ interface PreludeCachePayload {
103
+ helpers: PreludeHelper[];
104
+ sources: PreludeCacheSource[];
105
+ }
106
+
107
+ interface PreludeCacheState {
108
+ cacheKey: string;
109
+ cachePath: string;
110
+ sources: PreludeCacheSource[];
111
+ }
112
+
113
+ const PRELUDE_CACHE_DIR = "pycache";
114
+
115
+ function hashPreludeContent(content: string): string {
116
+ return Bun.hash(content).toString(16);
117
+ }
118
+
119
+ async function buildPreludeCacheState(cwd: string): Promise<PreludeCacheState> {
120
+ const modules = await discoverPythonModules({ cwd });
121
+ const moduleSources = modules
122
+ .map(module => ({ path: module.path, hash: hashPreludeContent(module.content) }))
123
+ .sort((a, b) => a.path.localeCompare(b.path));
124
+ const sources: PreludeCacheSource[] = [
125
+ { path: "omp:prelude", hash: hashPreludeContent(PYTHON_PRELUDE) },
126
+ ...moduleSources,
127
+ ];
128
+ const composite = sources.map(source => `${source.path}:${source.hash}`).join("|");
129
+ const cacheKey = Bun.hash(composite).toString(16);
130
+ const cachePath = path.join(getAgentDir(), PRELUDE_CACHE_DIR, `${cacheKey}.json`);
131
+ return { cacheKey, cachePath, sources };
132
+ }
133
+
134
+ async function readPreludeCache(state: PreludeCacheState): Promise<PreludeHelper[] | null> {
135
+ let raw: string;
136
+ try {
137
+ raw = await Bun.file(state.cachePath).text();
138
+ } catch (err) {
139
+ if (isEnoent(err)) return null;
140
+ logger.warn("Failed to read Python prelude cache", { path: state.cachePath, error: String(err) });
141
+ return null;
142
+ }
143
+ try {
144
+ const parsed = JSON.parse(raw) as PreludeCachePayload | PreludeHelper[];
145
+ const helpers = Array.isArray(parsed) ? parsed : parsed.helpers;
146
+ if (!Array.isArray(helpers) || helpers.length === 0) return null;
147
+ return helpers;
148
+ } catch (err) {
149
+ logger.warn("Failed to parse Python prelude cache", { path: state.cachePath, error: String(err) });
150
+ return null;
151
+ }
152
+ }
153
+
154
+ async function writePreludeCache(state: PreludeCacheState, helpers: PreludeHelper[]): Promise<void> {
155
+ const payload: PreludeCachePayload = { helpers, sources: state.sources };
156
+ try {
157
+ await Bun.write(state.cachePath, JSON.stringify(payload));
158
+ } catch (err) {
159
+ logger.warn("Failed to write Python prelude cache", { path: state.cachePath, error: String(err) });
160
+ }
161
+ }
162
+
89
163
  function startCleanupTimer(): void {
90
164
  if (cleanupTimer) return;
91
165
  cleanupTimer = setInterval(() => {
@@ -153,19 +227,37 @@ export async function warmPythonEnvironment(
153
227
  useSharedGateway?: boolean,
154
228
  sessionFile?: string,
155
229
  ): Promise<{ ok: boolean; reason?: string; docs: PreludeHelper[] }> {
230
+ const isTestEnv = process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test";
231
+ let cacheState: PreludeCacheState | null = null;
156
232
  try {
233
+ debugStartup("warmPython:ensureKernel:start");
157
234
  await ensureKernelAvailable(cwd);
235
+ debugStartup("warmPython:ensureKernel:done");
158
236
  time("warmPython:ensureKernelAvailable");
159
237
  } catch (err: unknown) {
160
238
  const reason = err instanceof Error ? err.message : String(err);
161
239
  cachedPreludeDocs = [];
162
240
  return { ok: false, reason, docs: [] };
163
241
  }
242
+ if (!isTestEnv) {
243
+ try {
244
+ cacheState = await buildPreludeCacheState(cwd);
245
+ const cached = await readPreludeCache(cacheState);
246
+ if (cached) {
247
+ cachedPreludeDocs = cached;
248
+ return { ok: true, docs: cached };
249
+ }
250
+ } catch (err) {
251
+ logger.warn("Failed to resolve Python prelude cache", { error: String(err) });
252
+ cacheState = null;
253
+ }
254
+ }
164
255
  if (cachedPreludeDocs && cachedPreludeDocs.length > 0) {
165
256
  return { ok: true, docs: cachedPreludeDocs };
166
257
  }
167
258
  const resolvedSessionId = sessionId ?? `session:${cwd}`;
168
259
  try {
260
+ debugStartup("warmPython:withKernelSession:start");
169
261
  const docs = await withKernelSession(
170
262
  resolvedSessionId,
171
263
  cwd,
@@ -173,8 +265,13 @@ export async function warmPythonEnvironment(
173
265
  useSharedGateway,
174
266
  sessionFile,
175
267
  );
268
+ debugStartup("warmPython:withKernelSession:done");
176
269
  time("warmPython:withKernelSession");
177
270
  cachedPreludeDocs = docs;
271
+ if (!isTestEnv && docs.length > 0) {
272
+ const state = cacheState ?? (await buildPreludeCacheState(cwd));
273
+ await writePreludeCache(state, docs);
274
+ }
178
275
  return { ok: true, docs };
179
276
  } catch (err: unknown) {
180
277
  const reason = err instanceof Error ? err.message : String(err);
@@ -226,6 +323,7 @@ async function createKernelSession(
226
323
  artifactsDir?: string,
227
324
  isRetry?: boolean,
228
325
  ): Promise<KernelSession> {
326
+ debugStartup("kernel:createSession:entry");
229
327
  const env: Record<string, string> | undefined =
230
328
  sessionFile || artifactsDir
231
329
  ? {
@@ -236,7 +334,9 @@ async function createKernelSession(
236
334
 
237
335
  let kernel: PythonKernel;
238
336
  try {
337
+ debugStartup("kernel:PythonKernel.start:start");
239
338
  kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
339
+ debugStartup("kernel:PythonKernel.start:done");
240
340
  time("createKernelSession:PythonKernel.start");
241
341
  } catch (err) {
242
342
  if (!isRetry && isResourceExhaustionError(err)) {
@@ -314,6 +414,7 @@ async function withKernelSession<T>(
314
414
  sessionFile?: string,
315
415
  artifactsDir?: string,
316
416
  ): Promise<T> {
417
+ debugStartup("kernel:withSession:entry");
317
418
  let session = kernelSessions.get(sessionId);
318
419
  if (!session) {
319
420
  // Evict oldest session if at capacity
@@ -321,17 +422,21 @@ async function withKernelSession<T>(
321
422
  await evictOldestSession();
322
423
  }
323
424
  session = await createKernelSession(sessionId, cwd, useSharedGateway, sessionFile, artifactsDir);
425
+ debugStartup("kernel:withSession:created");
324
426
  kernelSessions.set(sessionId, session);
325
427
  startCleanupTimer();
326
428
  }
327
429
 
328
430
  const run = async (): Promise<T> => {
431
+ debugStartup("kernel:withSession:run");
329
432
  session!.lastUsedAt = Date.now();
330
433
  if (session!.dead || !session!.kernel.isAlive()) {
331
434
  await restartKernelSession(session!, cwd, useSharedGateway, sessionFile, artifactsDir);
332
435
  }
333
436
  try {
437
+ debugStartup("kernel:withSession:handler:start");
334
438
  const result = await handler(session!.kernel);
439
+ debugStartup("kernel:withSession:handler:done");
335
440
  session!.restartCount = 0;
336
441
  return result;
337
442
  } catch (err) {
@@ -424,8 +529,7 @@ export async function executePython(code: string, options?: PythonExecutorOption
424
529
  await ensureKernelAvailable(cwd);
425
530
 
426
531
  const kernelMode = options?.kernelMode ?? "session";
427
- const isTestEnv = process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test";
428
- const useSharedGateway = isTestEnv ? false : options?.useSharedGateway;
532
+ const useSharedGateway = options?.useSharedGateway;
429
533
  const sessionFile = options?.sessionFile;
430
534
  const artifactsDir = options?.artifactsDir;
431
535
 
@@ -313,10 +313,6 @@ async function killGateway(pid: number, context: string): Promise<void> {
313
313
  }
314
314
 
315
315
  export async function acquireSharedGateway(cwd: string): Promise<AcquireResult | null> {
316
- if (process.env.BUN_ENV === "test" || process.env.NODE_ENV === "test") {
317
- return null;
318
- }
319
-
320
316
  try {
321
317
  return await withGatewayLock(async () => {
322
318
  time("acquireSharedGateway:lockAcquired");