@oh-my-pi/pi-coding-agent 15.3.2 → 15.4.2

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 (193) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/types/cli/file-processor.d.ts +1 -1
  3. package/dist/types/config/settings-schema.d.ts +45 -3
  4. package/dist/types/config/settings.d.ts +1 -1
  5. package/dist/types/debug/raw-sse.d.ts +2 -0
  6. package/dist/types/edit/file-read-cache.d.ts +15 -4
  7. package/dist/types/edit/index.d.ts +3 -8
  8. package/dist/types/edit/renderer.d.ts +1 -2
  9. package/dist/types/eval/__tests__/shared-executors.test.d.ts +1 -0
  10. package/dist/types/eval/js/shared/local-module-loader.d.ts +16 -0
  11. package/dist/types/eval/js/shared/rewrite-imports.d.ts +4 -0
  12. package/dist/types/eval/js/shared/runtime.d.ts +14 -8
  13. package/dist/types/eval/py/executor.d.ts +1 -2
  14. package/dist/types/eval/py/kernel.d.ts +6 -0
  15. package/dist/types/eval/py/tool-bridge.d.ts +1 -5
  16. package/dist/types/eval/session-id.d.ts +3 -0
  17. package/dist/types/extensibility/extensions/types.d.ts +1 -3
  18. package/dist/types/hashline/anchors.d.ts +15 -9
  19. package/dist/types/hashline/constants.d.ts +0 -2
  20. package/dist/types/hashline/diff.d.ts +1 -2
  21. package/dist/types/hashline/executor.d.ts +52 -0
  22. package/dist/types/hashline/hash.d.ts +44 -93
  23. package/dist/types/hashline/index.d.ts +2 -1
  24. package/dist/types/hashline/input.d.ts +2 -9
  25. package/dist/types/hashline/recovery.d.ts +3 -9
  26. package/dist/types/hashline/tokenizer.d.ts +91 -0
  27. package/dist/types/hashline/types.d.ts +5 -7
  28. package/dist/types/modes/components/extensions/types.d.ts +0 -4
  29. package/dist/types/modes/types.d.ts +1 -0
  30. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +2 -0
  32. package/dist/types/session/agent-session.d.ts +11 -15
  33. package/dist/types/session/agent-storage.d.ts +11 -10
  34. package/dist/types/slash-commands/acp-builtins.d.ts +3 -3
  35. package/dist/types/slash-commands/types.d.ts +0 -5
  36. package/dist/types/task/executor.d.ts +2 -0
  37. package/dist/types/tool-discovery/tool-index.d.ts +0 -50
  38. package/dist/types/tools/index.d.ts +2 -8
  39. package/dist/types/tools/match-line-format.d.ts +4 -4
  40. package/dist/types/tools/output-schema-validator.d.ts +64 -0
  41. package/dist/types/tools/review.d.ts +13 -0
  42. package/dist/types/tools/search-tool-bm25.d.ts +1 -1
  43. package/dist/types/tools/search.d.ts +4 -3
  44. package/dist/types/utils/edit-mode.d.ts +1 -1
  45. package/dist/types/web/kagi.d.ts +4 -2
  46. package/dist/types/web/parallel.d.ts +4 -3
  47. package/dist/types/web/scrapers/types.d.ts +2 -1
  48. package/dist/types/web/search/index.d.ts +12 -4
  49. package/dist/types/web/search/provider.d.ts +2 -1
  50. package/dist/types/web/search/providers/anthropic.d.ts +9 -4
  51. package/dist/types/web/search/providers/base.d.ts +34 -2
  52. package/dist/types/web/search/providers/brave.d.ts +8 -1
  53. package/dist/types/web/search/providers/codex.d.ts +13 -9
  54. package/dist/types/web/search/providers/exa.d.ts +10 -1
  55. package/dist/types/web/search/providers/gemini.d.ts +20 -23
  56. package/dist/types/web/search/providers/jina.d.ts +2 -1
  57. package/dist/types/web/search/providers/kagi.d.ts +4 -1
  58. package/dist/types/web/search/providers/kimi.d.ts +10 -1
  59. package/dist/types/web/search/providers/parallel.d.ts +3 -2
  60. package/dist/types/web/search/providers/perplexity.d.ts +5 -2
  61. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  62. package/dist/types/web/search/providers/synthetic.d.ts +5 -8
  63. package/dist/types/web/search/providers/tavily.d.ts +11 -4
  64. package/dist/types/web/search/providers/utils.d.ts +8 -6
  65. package/dist/types/web/search/providers/zai.d.ts +12 -3
  66. package/package.json +7 -7
  67. package/src/cli/file-processor.ts +12 -2
  68. package/src/cli.ts +0 -8
  69. package/src/commands/commit.ts +8 -8
  70. package/src/config/prompt-templates.ts +6 -6
  71. package/src/config/settings-schema.ts +47 -3
  72. package/src/config/settings.ts +5 -5
  73. package/src/debug/raw-sse.ts +68 -3
  74. package/src/edit/file-read-cache.ts +68 -25
  75. package/src/edit/index.ts +6 -37
  76. package/src/edit/renderer.ts +9 -47
  77. package/src/edit/streaming.ts +43 -56
  78. package/src/eval/__tests__/shared-executors.test.ts +520 -0
  79. package/src/eval/js/context-manager.ts +64 -53
  80. package/src/eval/js/shared/local-module-loader.ts +265 -0
  81. package/src/eval/js/shared/prelude.txt +4 -0
  82. package/src/eval/js/shared/rewrite-imports.ts +85 -0
  83. package/src/eval/js/shared/runtime.ts +129 -86
  84. package/src/eval/js/worker-core.ts +23 -38
  85. package/src/eval/py/executor.ts +155 -84
  86. package/src/eval/py/kernel.ts +10 -1
  87. package/src/eval/py/prelude.py +22 -24
  88. package/src/eval/py/runner.py +203 -85
  89. package/src/eval/py/tool-bridge.ts +17 -10
  90. package/src/eval/session-id.ts +8 -0
  91. package/src/exec/bash-executor.ts +27 -16
  92. package/src/extensibility/extensions/runner.ts +0 -1
  93. package/src/extensibility/extensions/types.ts +1 -3
  94. package/src/hashline/anchors.ts +56 -65
  95. package/src/hashline/apply.ts +29 -31
  96. package/src/hashline/constants.ts +0 -3
  97. package/src/hashline/diff-preview.ts +4 -5
  98. package/src/hashline/diff.ts +30 -4
  99. package/src/hashline/execute.ts +91 -26
  100. package/src/hashline/executor.ts +239 -0
  101. package/src/hashline/grammar.lark +12 -10
  102. package/src/hashline/hash.ts +69 -114
  103. package/src/hashline/index.ts +2 -1
  104. package/src/hashline/input.ts +48 -41
  105. package/src/hashline/prefixes.ts +21 -11
  106. package/src/hashline/recovery.ts +63 -71
  107. package/src/hashline/stream.ts +2 -2
  108. package/src/hashline/tokenizer.ts +467 -0
  109. package/src/hashline/types.ts +6 -8
  110. package/src/internal-urls/docs-index.generated.ts +7 -7
  111. package/src/modes/components/extensions/types.ts +0 -5
  112. package/src/modes/components/session-observer-overlay.ts +11 -2
  113. package/src/modes/components/settings-selector.ts +10 -1
  114. package/src/modes/components/tree-selector.ts +10 -2
  115. package/src/modes/controllers/command-controller.ts +1 -3
  116. package/src/modes/controllers/extension-ui-controller.ts +10 -11
  117. package/src/modes/controllers/selector-controller.ts +5 -5
  118. package/src/modes/theme/theme.ts +4 -2
  119. package/src/modes/types.ts +4 -1
  120. package/src/modes/utils/ui-helpers.ts +4 -0
  121. package/src/prompts/agents/explore.md +1 -1
  122. package/src/prompts/tools/ast-edit.md +1 -1
  123. package/src/prompts/tools/ast-grep.md +1 -1
  124. package/src/prompts/tools/eval.md +1 -1
  125. package/src/prompts/tools/hashline.md +73 -94
  126. package/src/prompts/tools/read.md +4 -4
  127. package/src/prompts/tools/search.md +3 -3
  128. package/src/sdk.ts +33 -26
  129. package/src/session/agent-session.ts +59 -66
  130. package/src/session/agent-storage.ts +13 -14
  131. package/src/slash-commands/acp-builtins.ts +3 -3
  132. package/src/slash-commands/types.ts +0 -6
  133. package/src/task/executor.ts +26 -57
  134. package/src/task/index.ts +8 -4
  135. package/src/tool-discovery/tool-index.ts +0 -134
  136. package/src/tools/ast-edit.ts +36 -13
  137. package/src/tools/ast-grep.ts +45 -4
  138. package/src/tools/browser/tab-worker.ts +3 -2
  139. package/src/tools/eval.ts +2 -1
  140. package/src/tools/fetch.ts +23 -14
  141. package/src/tools/index.ts +2 -8
  142. package/src/tools/irc.ts +59 -5
  143. package/src/tools/match-line-format.ts +5 -7
  144. package/src/tools/output-schema-validator.ts +132 -0
  145. package/src/tools/read.ts +142 -31
  146. package/src/tools/review.ts +23 -0
  147. package/src/tools/search-tool-bm25.ts +3 -30
  148. package/src/tools/search.ts +48 -16
  149. package/src/tools/write.ts +3 -3
  150. package/src/tools/yield.ts +32 -41
  151. package/src/utils/edit-mode.ts +1 -2
  152. package/src/utils/file-mentions.ts +2 -2
  153. package/src/web/kagi.ts +15 -6
  154. package/src/web/parallel.ts +9 -6
  155. package/src/web/scrapers/types.ts +7 -1
  156. package/src/web/scrapers/youtube.ts +13 -7
  157. package/src/web/search/index.ts +37 -11
  158. package/src/web/search/provider.ts +5 -3
  159. package/src/web/search/providers/anthropic.ts +30 -21
  160. package/src/web/search/providers/base.ts +35 -2
  161. package/src/web/search/providers/brave.ts +4 -4
  162. package/src/web/search/providers/codex.ts +118 -89
  163. package/src/web/search/providers/exa.ts +3 -2
  164. package/src/web/search/providers/gemini.ts +58 -155
  165. package/src/web/search/providers/jina.ts +4 -4
  166. package/src/web/search/providers/kagi.ts +17 -11
  167. package/src/web/search/providers/kimi.ts +29 -13
  168. package/src/web/search/providers/parallel.ts +171 -23
  169. package/src/web/search/providers/perplexity.ts +38 -37
  170. package/src/web/search/providers/searxng.ts +3 -1
  171. package/src/web/search/providers/synthetic.ts +16 -19
  172. package/src/web/search/providers/tavily.ts +23 -18
  173. package/src/web/search/providers/utils.ts +11 -17
  174. package/src/web/search/providers/zai.ts +16 -8
  175. package/dist/types/hashline/parser.d.ts +0 -7
  176. package/dist/types/mcp/discoverable-tool-metadata.d.ts +0 -7
  177. package/dist/types/tools/vim.d.ts +0 -58
  178. package/dist/types/vim/buffer.d.ts +0 -41
  179. package/dist/types/vim/commands.d.ts +0 -6
  180. package/dist/types/vim/engine.d.ts +0 -47
  181. package/dist/types/vim/parser.d.ts +0 -3
  182. package/dist/types/vim/render.d.ts +0 -25
  183. package/dist/types/vim/types.d.ts +0 -182
  184. package/src/hashline/parser.ts +0 -246
  185. package/src/mcp/discoverable-tool-metadata.ts +0 -24
  186. package/src/prompts/tools/vim.md +0 -98
  187. package/src/tools/vim.ts +0 -949
  188. package/src/vim/buffer.ts +0 -309
  189. package/src/vim/commands.ts +0 -382
  190. package/src/vim/engine.ts +0 -2409
  191. package/src/vim/parser.ts +0 -134
  192. package/src/vim/render.ts +0 -252
  193. package/src/vim/types.ts +0 -197
@@ -3,6 +3,7 @@ import { JsRuntime, type RuntimeHooks } from "./shared/runtime";
3
3
  import type { RunErrorPayload, SessionSnapshot, ToolReply, Transport, WorkerInbound } from "./worker-protocol";
4
4
 
5
5
  interface PendingTool {
6
+ runId: string;
6
7
  resolve(value: unknown): void;
7
8
  reject(error: Error): void;
8
9
  }
@@ -36,8 +37,7 @@ function errorFromPayload(payload: RunErrorPayload): Error {
36
37
  export class WorkerCore {
37
38
  #transport: Transport;
38
39
  #runtime: JsRuntime | null = null;
39
- #queue: Promise<void> = Promise.resolve();
40
- #active: ActiveRun | null = null;
40
+ #runs = new Map<string, ActiveRun>();
41
41
  #unsubscribe: () => void;
42
42
 
43
43
  constructor(transport: Transport) {
@@ -52,7 +52,7 @@ export class WorkerCore {
52
52
  this.#ensureRuntime(msg.snapshot);
53
53
  return;
54
54
  case "run":
55
- this.#enqueueRun(msg.runId, msg.code, msg.filename, msg.snapshot);
55
+ void this.#runOne(msg.runId, msg.code, msg.filename, msg.snapshot);
56
56
  return;
57
57
  case "tool-reply":
58
58
  this.#deliverToolReply(msg.id, msg.reply);
@@ -71,73 +71,58 @@ export class WorkerCore {
71
71
  this.#runtime = new JsRuntime({
72
72
  initialCwd: snapshot.cwd,
73
73
  sessionId: snapshot.sessionId,
74
- getHooks: () => this.#hooksForCurrentRun(),
75
74
  });
76
75
  return this.#runtime;
77
76
  }
78
77
 
79
- #hooksForCurrentRun(): RuntimeHooks | null {
80
- const active = this.#active;
81
- if (!active) return null;
82
- const runId = active.runId;
83
- return {
78
+ async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
79
+ const runtime = this.#ensureRuntime(snapshot);
80
+ runtime.setCwd(snapshot.cwd);
81
+ const active: ActiveRun = { runId, pendingTools: new Map() };
82
+ this.#runs.set(runId, active);
83
+ const hooks: RuntimeHooks = {
84
84
  onText: chunk => this.#transport.send({ type: "text", runId, chunk }),
85
85
  onDisplay: output => this.#transport.send({ type: "display", runId, output }),
86
86
  callTool: (name, args) => this.#callTool(active, name, args),
87
87
  };
88
- }
89
-
90
- #enqueueRun(runId: string, code: string, filename: string, snapshot: SessionSnapshot): void {
91
- const previous = this.#queue;
92
- const next = (async () => {
93
- await previous.catch(() => undefined);
94
- await this.#runOne(runId, code, filename, snapshot);
95
- })();
96
- this.#queue = next.catch(() => undefined);
97
- }
98
-
99
- async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
100
- const runtime = this.#ensureRuntime(snapshot);
101
- runtime.setCwd(snapshot.cwd);
102
- this.#active = { runId, pendingTools: new Map() };
103
88
  try {
104
- const value = await runtime.run(code, filename);
105
- runtime.displayValue(value);
89
+ const value = await runtime.run(code, filename, hooks, { runId, cwd: snapshot.cwd });
90
+ runtime.displayValue(value, hooks);
106
91
  this.#transport.send({ type: "result", runId, ok: true });
107
92
  } catch (error) {
108
93
  this.#transport.send({ type: "result", runId, ok: false, error: errorPayload(error) });
109
94
  } finally {
110
- this.#active = null;
95
+ this.#runs.delete(runId);
111
96
  }
112
97
  }
113
98
 
114
99
  async #callTool(active: ActiveRun, name: string, args: unknown): Promise<unknown> {
115
100
  const id = `tc-${active.runId}-${crypto.randomUUID()}`;
116
101
  const { promise, resolve, reject } = Promise.withResolvers<unknown>();
117
- active.pendingTools.set(id, { resolve, reject });
102
+ active.pendingTools.set(id, { runId: active.runId, resolve, reject });
118
103
  this.#transport.send({ type: "tool-call", id, runId: active.runId, name, args });
119
104
  return await promise;
120
105
  }
121
106
 
122
107
  #deliverToolReply(id: string, reply: ToolReply): void {
123
- const active = this.#active;
124
- if (!active) return;
125
- const pending = active.pendingTools.get(id);
126
- if (!pending) return;
127
- active.pendingTools.delete(id);
128
- if (reply.ok) pending.resolve(reply.value);
129
- else pending.reject(errorFromPayload(reply.error));
108
+ for (const active of this.#runs.values()) {
109
+ const pending = active.pendingTools.get(id);
110
+ if (!pending) continue;
111
+ active.pendingTools.delete(id);
112
+ if (reply.ok) pending.resolve(reply.value);
113
+ else pending.reject(errorFromPayload(reply.error));
114
+ return;
115
+ }
130
116
  }
131
117
 
132
118
  #close(): void {
133
- const active = this.#active;
134
- if (active) {
119
+ for (const active of this.#runs.values()) {
135
120
  for (const pending of active.pendingTools.values()) {
136
121
  pending.reject(new ToolError("JS worker closed"));
137
122
  }
138
123
  active.pendingTools.clear();
139
124
  }
140
- this.#active = null;
125
+ this.#runs.clear();
141
126
  this.#runtime = null;
142
127
  this.#transport.send({ type: "closed" });
143
128
  this.#unsubscribe();
@@ -1,14 +1,17 @@
1
+ import * as path from "node:path";
2
+
1
3
  import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
2
4
  import { Settings } from "../../config/settings";
3
5
  import { OutputSink } from "../../session/streaming-output";
4
6
  import type { ToolSession } from "../../tools";
5
7
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../../tools/output-meta";
6
8
  import type { JsStatusEvent } from "../js/shared/types";
7
- import type { KernelDisplayOutput } from "./display";
8
9
  import {
9
10
  checkPythonKernelAvailability,
11
+ type KernelDisplayOutput,
10
12
  type KernelExecuteOptions,
11
13
  type KernelExecuteResult,
14
+ type KernelRuntimeEnv,
12
15
  PythonKernel,
13
16
  } from "./kernel";
14
17
  import { ensurePyToolBridge, registerPyToolBridge } from "./tool-bridge";
@@ -92,20 +95,32 @@ export interface PythonResult {
92
95
  // ---------------------------------------------------------------------------
93
96
  // Session bookkeeping
94
97
  //
95
- // One PythonKernel subprocess per session id. Sessions are reused until they
96
- // die or are explicitly disposed. Multiple agent owners can register against
97
- // the same session id; the kernel stays alive until the last owner detaches.
98
+ // One PythonKernel subprocess per (session id, cwd) tuple. The runner mutates
99
+ // process-global cwd/sys.path during execution, so cross-directory work MUST
100
+ // never share a live kernel. Multiple agent owners can still register against
101
+ // the same tuple; the kernel stays alive until the last owner detaches.
98
102
  // ---------------------------------------------------------------------------
99
103
 
100
104
  interface PythonSession {
105
+ sessionKey: string;
101
106
  sessionId: string;
107
+ cwd: string;
102
108
  kernel: PythonKernel;
103
109
  ownerIds: Set<string>;
104
110
  hasFallbackOwner: boolean;
105
- queue: Promise<void>;
106
111
  }
107
112
 
108
113
  const sessions = new Map<string, PythonSession>();
114
+ const startingSessions = new Map<string, Promise<PythonSession>>();
115
+ const resettingSessions = new Set<string>();
116
+
117
+ function normalizeSessionCwd(cwd: string): string {
118
+ return path.resolve(cwd);
119
+ }
120
+
121
+ function buildSessionKey(sessionId: string, cwd: string): string {
122
+ return `${sessionId}\0${normalizeSessionCwd(cwd)}`;
123
+ }
109
124
 
110
125
  // ---------------------------------------------------------------------------
111
126
  // Cancellation plumbing
@@ -240,19 +255,40 @@ function createCancelledPythonResult(timedOut: boolean, timeoutMs?: number): Pyt
240
255
  // Kernel start helpers
241
256
  // ---------------------------------------------------------------------------
242
257
 
258
+ const MANAGED_KERNEL_ENV_KEYS = [
259
+ "PI_SESSION_FILE",
260
+ "PI_ARTIFACTS_DIR",
261
+ "PI_TOOL_BRIDGE_URL",
262
+ "PI_TOOL_BRIDGE_TOKEN",
263
+ "PI_TOOL_BRIDGE_SESSION",
264
+ ] as const;
265
+
266
+ function buildKernelEnvPatch(options: {
267
+ sessionFile?: string;
268
+ artifactsDir?: string;
269
+ bridgeSessionId?: string;
270
+ bridge?: { url: string; token: string };
271
+ }): KernelRuntimeEnv {
272
+ return {
273
+ PI_SESSION_FILE: options.sessionFile ?? null,
274
+ PI_ARTIFACTS_DIR: options.artifactsDir ?? null,
275
+ PI_TOOL_BRIDGE_URL: options.bridge?.url ?? null,
276
+ PI_TOOL_BRIDGE_TOKEN: options.bridge?.token ?? null,
277
+ PI_TOOL_BRIDGE_SESSION: options.bridge && options.bridgeSessionId ? options.bridgeSessionId : null,
278
+ };
279
+ }
280
+
243
281
  function buildKernelEnv(options: {
244
282
  sessionFile?: string;
245
283
  artifactsDir?: string;
246
284
  bridgeSessionId?: string;
247
285
  bridge?: { url: string; token: string };
248
286
  }): Record<string, string> | undefined {
287
+ const patch = buildKernelEnvPatch(options);
249
288
  const env: Record<string, string> = {};
250
- if (options.sessionFile) env.PI_SESSION_FILE = options.sessionFile;
251
- if (options.artifactsDir) env.PI_ARTIFACTS_DIR = options.artifactsDir;
252
- if (options.bridge && options.bridgeSessionId) {
253
- env.PI_TOOL_BRIDGE_URL = options.bridge.url;
254
- env.PI_TOOL_BRIDGE_TOKEN = options.bridge.token;
255
- env.PI_TOOL_BRIDGE_SESSION = options.bridgeSessionId;
289
+ for (const key of MANAGED_KERNEL_ENV_KEYS) {
290
+ const value = patch[key];
291
+ if (value !== null) env[key] = value;
256
292
  }
257
293
  return Object.keys(env).length > 0 ? env : undefined;
258
294
  }
@@ -282,23 +318,44 @@ function attachOwner(session: PythonSession, sessionId: string, ownerId: string
282
318
  }
283
319
  }
284
320
 
285
- async function acquireSession(sessionId: string, cwd: string, options: PythonExecutorOptions): Promise<PythonSession> {
286
- const existing = sessions.get(sessionId);
321
+ async function acquireSession(
322
+ sessionKey: string,
323
+ sessionId: string,
324
+ cwd: string,
325
+ options: PythonExecutorOptions,
326
+ ): Promise<PythonSession> {
327
+ const existing = sessions.get(sessionKey);
287
328
  if (existing) {
288
329
  attachOwner(existing, sessionId, options.kernelOwnerId);
289
330
  return existing;
290
331
  }
291
- const kernel = await startKernel(cwd, options);
292
- const session: PythonSession = {
293
- sessionId,
294
- kernel,
295
- ownerIds: new Set(),
296
- hasFallbackOwner: false,
297
- queue: Promise.resolve(),
298
- };
299
- attachOwner(session, sessionId, options.kernelOwnerId);
300
- sessions.set(sessionId, session);
301
- return session;
332
+ const starting = startingSessions.get(sessionKey);
333
+ if (starting) {
334
+ const session = await starting;
335
+ attachOwner(session, sessionId, options.kernelOwnerId);
336
+ return session;
337
+ }
338
+ const startup = (async () => {
339
+ const kernel = await startKernel(cwd, options);
340
+ const session: PythonSession = {
341
+ sessionKey,
342
+ sessionId,
343
+ cwd,
344
+ kernel,
345
+ ownerIds: new Set(),
346
+ hasFallbackOwner: false,
347
+ };
348
+ sessions.set(sessionKey, session);
349
+ return session;
350
+ })();
351
+ startingSessions.set(sessionKey, startup);
352
+ try {
353
+ const session = await startup;
354
+ attachOwner(session, sessionId, options.kernelOwnerId);
355
+ return session;
356
+ } finally {
357
+ if (startingSessions.get(sessionKey) === startup) startingSessions.delete(sessionKey);
358
+ }
302
359
  }
303
360
 
304
361
  async function replaceSessionKernel(
@@ -311,52 +368,40 @@ async function replaceSessionKernel(
311
368
  await old
312
369
  .shutdown(remaining !== undefined ? { timeoutMs: Math.max(0, remaining) } : undefined)
313
370
  .catch(() => undefined);
314
- if (sessions.get(session.sessionId) !== session) {
371
+ if (sessions.get(session.sessionKey) !== session) {
315
372
  throw new PythonExecutionCancelledError(false);
316
373
  }
317
374
  requireRemainingTimeoutMs(options.deadlineMs);
318
375
  const next = await startKernel(cwd, options);
319
- if (sessions.get(session.sessionId) !== session) {
376
+ if (sessions.get(session.sessionKey) !== session) {
320
377
  await next.shutdown().catch(() => undefined);
321
378
  throw new PythonExecutionCancelledError(false);
322
379
  }
323
380
  session.kernel = next;
324
381
  }
325
382
 
326
- async function resetSession(sessionId: string): Promise<void> {
327
- const existing = sessions.get(sessionId);
383
+ async function resetSession(sessionKey: string): Promise<void> {
384
+ const existing = sessions.get(sessionKey) ?? (await startingSessions.get(sessionKey)?.catch(() => undefined));
328
385
  if (!existing) return;
329
- sessions.delete(sessionId);
386
+ sessions.delete(sessionKey);
330
387
  await existing.kernel.shutdown().catch(() => undefined);
331
388
  }
332
389
 
333
- async function runQueued<T>(
334
- session: PythonSession,
335
- options: Pick<PythonExecutorOptions, "signal" | "deadlineMs">,
336
- work: () => Promise<T>,
337
- ): Promise<T> {
338
- const previous = session.queue;
339
- const { promise: ourSlot, resolve: releaseSlot } = Promise.withResolvers<void>();
340
- // Keep the queue chained even if WE bail out: future runs must still wait
341
- // for `previous` to finish before they touch the kernel.
342
- session.queue = previous.catch(() => undefined).then(() => ourSlot);
343
- try {
344
- await waitForPromiseWithCancellation(
345
- previous.catch(() => undefined),
346
- options,
347
- );
348
- return await work();
349
- } finally {
350
- releaseSlot();
351
- }
352
- }
353
-
354
390
  // ---------------------------------------------------------------------------
355
391
  // Public dispose entry points
356
392
  // ---------------------------------------------------------------------------
357
393
 
358
394
  export async function disposeAllKernelSessions(): Promise<void> {
395
+ const pending = [...startingSessions.values()];
396
+ startingSessions.clear();
397
+ const started = await Promise.allSettled(pending);
359
398
  const all = [...sessions.entries()];
399
+ for (const result of started) {
400
+ if (result.status !== "fulfilled") continue;
401
+ if (!all.some(([, session]) => session === result.value)) {
402
+ all.push([result.value.sessionKey, result.value]);
403
+ }
404
+ }
360
405
  for (const [id, session] of all) {
361
406
  if (sessions.get(id) === session) sessions.delete(id);
362
407
  }
@@ -366,7 +411,12 @@ export async function disposeAllKernelSessions(): Promise<void> {
366
411
  const result = results[i];
367
412
  if (result.status === "fulfilled" && result.value?.confirmed !== false) continue;
368
413
  const reason = result.status === "rejected" ? result.reason : "not confirmed";
369
- logger.warn("Python kernel shutdown not confirmed", { sessionId: id, reason });
414
+ logger.warn("Python kernel shutdown not confirmed", {
415
+ sessionId: session.sessionId,
416
+ sessionKey: id,
417
+ cwd: session.cwd,
418
+ reason,
419
+ });
370
420
  if (!sessions.has(id)) sessions.set(id, session);
371
421
  }
372
422
  }
@@ -382,7 +432,7 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
382
432
  session.ownerIds.delete(ownerId);
383
433
  }
384
434
  for (const session of toShutdown) {
385
- if (sessions.get(session.sessionId) === session) sessions.delete(session.sessionId);
435
+ if (sessions.get(session.sessionKey) === session) sessions.delete(session.sessionKey);
386
436
  }
387
437
  const results = await Promise.allSettled(toShutdown.map(session => session.kernel.shutdown()));
388
438
  for (let i = 0; i < toShutdown.length; i += 1) {
@@ -393,8 +443,13 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
393
443
  continue;
394
444
  }
395
445
  const reason = result.status === "rejected" ? result.reason : "not confirmed";
396
- logger.warn("Python kernel shutdown not confirmed", { sessionId: session.sessionId, reason });
397
- if (!sessions.has(session.sessionId)) sessions.set(session.sessionId, session);
446
+ logger.warn("Python kernel shutdown not confirmed", {
447
+ sessionId: session.sessionId,
448
+ sessionKey: session.sessionKey,
449
+ cwd: session.cwd,
450
+ reason,
451
+ });
452
+ if (!sessions.has(session.sessionKey)) sessions.set(session.sessionKey, session);
398
453
  }
399
454
  }
400
455
 
@@ -424,9 +479,10 @@ async function executeWithKernel(
424
479
  ((event: JsStatusEvent) => {
425
480
  displayOutputs.push({ type: "status", event });
426
481
  });
482
+ const runId = `py-${crypto.randomUUID()}`;
427
483
  const unregisterBridge =
428
484
  options?.toolSession && options?.bridgeSessionId
429
- ? registerPyToolBridge(options.bridgeSessionId, {
485
+ ? registerPyToolBridge(options.bridgeSessionId, runId, {
430
486
  toolSession: options.toolSession,
431
487
  signal: options.signal,
432
488
  emitStatus,
@@ -436,6 +492,9 @@ async function executeWithKernel(
436
492
  try {
437
493
  executionTimeoutMs = requireRemainingTimeoutMs(deadlineMs);
438
494
  const result = await kernel.execute(code, {
495
+ cwd: options?.cwd,
496
+ env: buildKernelEnvPatch(options ?? {}),
497
+ id: runId,
439
498
  signal: options?.signal,
440
499
  timeoutMs: executionTimeoutMs,
441
500
  onChunk: text => sink.push(text),
@@ -516,7 +575,7 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
516
575
  }
517
576
  const kernel = await startKernel(cwd, options);
518
577
  try {
519
- return await executeWithKernel(kernel, code, options);
578
+ return await executeWithKernel(kernel, code, { ...options, cwd: undefined });
520
579
  } finally {
521
580
  await kernel.shutdown().catch(() => undefined);
522
581
  }
@@ -524,42 +583,53 @@ async function executePerCall(code: string, cwd: string, options: PythonExecutor
524
583
 
525
584
  async function executeOnSession(code: string, cwd: string, options: PythonExecutorOptions): Promise<PythonResult> {
526
585
  const sessionId = options.sessionId ?? `session:${cwd}`;
586
+ const sessionKey = buildSessionKey(sessionId, cwd);
527
587
  if (options.bridge && !options.bridgeSessionId) {
528
588
  options.bridgeSessionId = sessionId;
529
589
  }
530
590
  if (options.reset) {
531
- await resetSession(sessionId);
532
- }
533
- const session = await acquireSession(sessionId, cwd, options);
534
- return await runQueued(session, options, async () => {
535
- if (options.signal?.aborted) {
536
- throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
591
+ if (resettingSessions.has(sessionKey)) {
592
+ throw new Error("Python kernel reset already in progress");
593
+ }
594
+ resettingSessions.add(sessionKey);
595
+ try {
596
+ await resetSession(sessionKey);
597
+ } finally {
598
+ resettingSessions.delete(sessionKey);
537
599
  }
538
- if (sessions.get(session.sessionId) !== session) {
600
+ } else if (resettingSessions.has(sessionKey)) {
601
+ throw new Error("Python kernel reset in progress");
602
+ }
603
+ const session = await acquireSession(sessionKey, sessionId, cwd, options);
604
+ if (options.signal?.aborted) {
605
+ throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
606
+ }
607
+ if (sessions.get(session.sessionKey) !== session) {
608
+ throw new PythonExecutionCancelledError(false);
609
+ }
610
+ if (!session.kernel.isAlive()) {
611
+ await replaceSessionKernel(session, cwd, options);
612
+ if (sessions.get(session.sessionKey) !== session) {
539
613
  throw new PythonExecutionCancelledError(false);
540
614
  }
541
- if (!session.kernel.isAlive()) {
542
- await replaceSessionKernel(session, cwd, options);
543
- if (sessions.get(session.sessionId) !== session) {
544
- throw new PythonExecutionCancelledError(false);
545
- }
615
+ }
616
+ const runOptions = { ...options, cwd: undefined };
617
+ try {
618
+ return await executeWithKernel(session.kernel, code, runOptions);
619
+ } catch (err) {
620
+ if (isCancellationError(err) || options.signal?.aborted) throw err;
621
+ if (session.kernel.isAlive()) throw err;
622
+ if (sessions.get(session.sessionKey) !== session) {
623
+ throw new PythonExecutionCancelledError(false);
546
624
  }
547
- try {
548
- return await executeWithKernel(session.kernel, code, options);
549
- } catch (err) {
550
- if (isCancellationError(err) || options.signal?.aborted) throw err;
551
- if (session.kernel.isAlive()) throw err;
552
- if (sessions.get(session.sessionId) !== session) {
553
- throw new PythonExecutionCancelledError(false);
554
- }
555
- // Kernel died during execute. Replace it and retry once on a fresh one.
556
- await replaceSessionKernel(session, cwd, options);
557
- if (sessions.get(session.sessionId) !== session) {
558
- throw new PythonExecutionCancelledError(false);
559
- }
560
- return await executeWithKernel(session.kernel, code, options);
625
+ // Shared kernels are keyed by cwd, so a dead kernel can be recreated in place
626
+ // without risking cross-directory state bleed.
627
+ await replaceSessionKernel(session, cwd, options);
628
+ if (sessions.get(session.sessionKey) !== session) {
629
+ throw new PythonExecutionCancelledError(false);
561
630
  }
562
- });
631
+ return await executeWithKernel(session.kernel, code, runOptions);
632
+ }
563
633
  }
564
634
 
565
635
  export async function executePythonWithKernel(
@@ -571,10 +641,11 @@ export async function executePythonWithKernel(
571
641
  }
572
642
 
573
643
  export async function executePython(code: string, options?: PythonExecutorOptions): Promise<PythonResult> {
574
- const cwd = options?.cwd ?? getProjectDir();
644
+ const cwd = normalizeSessionCwd(options?.cwd ?? getProjectDir());
575
645
  const deadlineMs = getExecutionDeadlineMs(options);
576
646
  const executionOptions: PythonExecutorOptions = {
577
647
  ...(options ?? {}),
648
+ cwd,
578
649
  deadlineMs,
579
650
  };
580
651
 
@@ -51,7 +51,14 @@ const STARTUP_TIMEOUT_MS = 10_000;
51
51
  // kernel's state, so we only kill as a last-resort recovery path.
52
52
  const INTERRUPT_ESCALATION_MS = 5_000;
53
53
 
54
+ export type KernelRuntimeEnv = Record<string, string | null>;
55
+
54
56
  export interface KernelExecuteOptions {
57
+ id?: string;
58
+ /** Runtime working directory applied immediately before this request executes. */
59
+ cwd?: string;
60
+ /** Managed runtime environment variables applied immediately before this request executes. */
61
+ env?: KernelRuntimeEnv;
55
62
  signal?: AbortSignal;
56
63
  onChunk?: (text: string) => Promise<void> | void;
57
64
  onDisplay?: (output: KernelDisplayOutput) => Promise<void> | void;
@@ -260,7 +267,7 @@ export class PythonKernel {
260
267
  throw new Error("Python kernel is not running");
261
268
  }
262
269
 
263
- const msgId = Snowflake.next();
270
+ const msgId = options?.id ?? Snowflake.next();
264
271
  const { promise, resolve } = Promise.withResolvers<KernelExecuteResult>();
265
272
  const pending: PendingExecution = {
266
273
  resolve,
@@ -345,6 +352,8 @@ export class PythonKernel {
345
352
  const payload = JSON.stringify({
346
353
  id: msgId,
347
354
  code,
355
+ cwd: options?.cwd,
356
+ env: options?.env,
348
357
  silent: options?.silent ?? false,
349
358
  storeHistory: options?.storeHistory ?? !(options?.silent ?? false),
350
359
  });
@@ -377,13 +377,20 @@ if "__omp_prelude_loaded__" not in globals():
377
377
  return current
378
378
 
379
379
 
380
+ def _tool_proxy_from_env() -> tuple[str, str, str]:
381
+ base = os.environ.get("PI_TOOL_BRIDGE_URL")
382
+ token = os.environ.get("PI_TOOL_BRIDGE_TOKEN")
383
+ session = os.environ.get("PI_TOOL_BRIDGE_SESSION")
384
+ if not base or not token or not session:
385
+ raise RuntimeError("tool bridge is unavailable in this kernel")
386
+ return (base.rstrip("/"), token, session)
387
+
380
388
  class _ToolCallable:
381
389
  """Invokes one host-side tool via the loopback HTTP bridge."""
382
390
 
383
- __slots__ = ("_proxy", "_name")
391
+ __slots__ = ("_name",)
384
392
 
385
- def __init__(self, proxy: "_ToolProxy", name: str):
386
- self._proxy = proxy
393
+ def __init__(self, name: str):
387
394
  self._name = name
388
395
 
389
396
  def __repr__(self) -> str:
@@ -402,16 +409,19 @@ if "__omp_prelude_loaded__" not in globals():
402
409
  merged.update(kwargs)
403
410
  if "_i" not in merged:
404
411
  merged["_i"] = "py prelude"
412
+ base, token, session = _tool_proxy_from_env()
413
+ _run_id_getter = globals().get("__omp_current_run_id__")
414
+ _run_id = _run_id_getter() if callable(_run_id_getter) else globals().get("__omp_run_id__")
405
415
  payload = json.dumps(
406
- {"session": self._proxy._session, "name": self._name, "args": merged}
416
+ {"session": session, "run": _run_id, "name": self._name, "args": merged}
407
417
  ).encode("utf-8")
408
418
  req = urllib.request.Request(
409
- f"{self._proxy._base}/v1/tool",
419
+ f"{base}/v1/tool",
410
420
  data=payload,
411
421
  method="POST",
412
422
  headers={
413
423
  "Content-Type": "application/json",
414
- "Authorization": f"Bearer {self._proxy._token}",
424
+ "Authorization": f"Bearer {token}",
415
425
  },
416
426
  )
417
427
  try:
@@ -433,30 +443,18 @@ if "__omp_prelude_loaded__" not in globals():
433
443
  class _ToolProxy:
434
444
  """`tool.<name>(args)` proxy mirroring the JS runtime bridge."""
435
445
 
436
- __slots__ = ("_base", "_token", "_session")
437
-
438
- def __init__(self, base: str, token: str, session: str):
439
- self._base = base.rstrip("/")
440
- self._token = token
441
- self._session = session
446
+ __slots__ = ()
442
447
 
443
448
  def __getattr__(self, name: str) -> _ToolCallable:
444
449
  if name.startswith("_"):
445
450
  raise AttributeError(name)
446
- return _ToolCallable(self, name)
451
+ return _ToolCallable(name)
447
452
 
448
453
  def __getitem__(self, name: str) -> _ToolCallable:
449
- return _ToolCallable(self, name)
454
+ return _ToolCallable(name)
450
455
 
451
456
  def __repr__(self) -> str:
452
- return f"<tool proxy session={self._session}>"
457
+ session = os.environ.get("PI_TOOL_BRIDGE_SESSION")
458
+ return f"<tool proxy session={session}>" if session else "<tool proxy unavailable>"
453
459
 
454
- if all(
455
- _k in os.environ
456
- for _k in ("PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION")
457
- ):
458
- tool = _ToolProxy(
459
- os.environ["PI_TOOL_BRIDGE_URL"],
460
- os.environ["PI_TOOL_BRIDGE_TOKEN"],
461
- os.environ["PI_TOOL_BRIDGE_SESSION"],
462
- )
460
+ tool = _ToolProxy()