@code-yeongyu/senpi-codemode 2026.9.5 → 2026.9.7-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 (41) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +34 -5
  3. package/package.json +4 -4
  4. package/src/bridge/protocol.ts +1 -0
  5. package/src/bridge/reserved.ts +2 -0
  6. package/src/extension/runtime-factory.ts +3 -0
  7. package/src/extension/session-manager.ts +7 -0
  8. package/src/kernels/AGENTS.md +7 -0
  9. package/src/kernels/jl/kernel.ts +6 -2
  10. package/src/kernels/js/context-manager.ts +73 -129
  11. package/src/kernels/js/inline-worker.ts +2 -2
  12. package/src/kernels/js/interrupt-bounds.ts +66 -0
  13. package/src/kernels/js/kernel-contract.ts +3 -0
  14. package/src/kernels/js/run-queue.ts +18 -3
  15. package/src/kernels/js/worker-core.js +59 -0
  16. package/src/kernels/js/worker-indirect-eval.js +10 -6
  17. package/src/kernels/js/worker-runtime.js +16 -0
  18. package/src/kernels/js/worker-shell-capture.d.ts +25 -0
  19. package/src/kernels/js/worker-shell-capture.js +70 -8
  20. package/src/kernels/js/worker-slot.ts +106 -0
  21. package/src/kernels/js/worker-startup.ts +70 -0
  22. package/src/kernels/py/kernel-contract.ts +3 -0
  23. package/src/kernels/py/transport.ts +11 -3
  24. package/src/kernels/rb/kernel.ts +6 -2
  25. package/src/kernels/session-env.ts +56 -0
  26. package/src/kernels/shared/runtime-asset.ts +40 -14
  27. package/src/kernels/shared/subprocess-contract.ts +3 -0
  28. package/src/kernels/shared/subprocess-kernel.ts +9 -1
  29. package/src/output/output-meta.ts +26 -2
  30. package/src/prompt/eval-prompt.ts +10 -5
  31. package/src/tool/cell-execution.ts +9 -11
  32. package/src/tool/detached-cell-contract.ts +45 -0
  33. package/src/tool/detached-cell-manager.ts +43 -71
  34. package/src/tool/detached-cell-notification.ts +4 -5
  35. package/src/tool/detached-cell-snapshot.ts +2 -0
  36. package/src/tool/detached-cell-status.ts +30 -0
  37. package/src/tool/detached-eval-result.ts +1 -0
  38. package/src/tool/detached-notification-queue.ts +2 -2
  39. package/src/tool/image.ts +12 -3
  40. package/src/tool/interrupt-note.ts +29 -15
  41. package/src/tool/types.ts +2 -0
@@ -0,0 +1,66 @@
1
+ import type { WorkerLike } from "./inline-worker.ts";
2
+ import type { PendingJavaScriptRun } from "./run-queue.ts";
3
+
4
+ /** How long the worker gets to acknowledge `interrupt`; silence means its event loop is blocked in synchronous code. */
5
+ export const INTERRUPT_ACK_MS = 500;
6
+ /** How long an acknowledged cell gets to settle before the VM is replaced. */
7
+ export const JS_INTERRUPT_GRACE_MS = 2_000;
8
+ /** How long `worker.terminate()` may take before the worker is abandoned as blocked in a synchronous call. */
9
+ export const WORKER_TERMINATE_DEADLINE_MS = 3_000;
10
+
11
+ export type CooperativeSettlement = "settled" | "unresponsive";
12
+ export type WorkerRetirement = "terminated" | "abandoned";
13
+
14
+ export interface CooperativeSettlementBounds {
15
+ readonly ackMs: number;
16
+ readonly graceMs: number;
17
+ }
18
+
19
+ const DEFAULT_SETTLEMENT_BOUNDS: CooperativeSettlementBounds = {
20
+ ackMs: INTERRUPT_ACK_MS,
21
+ graceMs: JS_INTERRUPT_GRACE_MS,
22
+ };
23
+
24
+ export async function awaitCooperativeSettlement(
25
+ run: PendingJavaScriptRun,
26
+ bounds = DEFAULT_SETTLEMENT_BOUNDS,
27
+ ): Promise<CooperativeSettlement> {
28
+ if (run.settled) return "settled";
29
+ const settled = run.settlement.then((): "settled" => "settled");
30
+ const acked = run.interruptAck?.promise.then((): "acked" => "acked") ?? Promise.resolve<"acked">("acked");
31
+ const first = await raceDeadline(Promise.race([settled, acked]), bounds.ackMs, "unresponsive");
32
+ if (first !== "acked") return first;
33
+ return await raceDeadline(settled, bounds.graceMs, "unresponsive");
34
+ }
35
+
36
+ export async function retireWorker(
37
+ worker: WorkerLike,
38
+ deadlineMs = WORKER_TERMINATE_DEADLINE_MS,
39
+ ): Promise<WorkerRetirement> {
40
+ const termination = worker.terminate().then((): WorkerRetirement => "terminated");
41
+ const outcome = await raceDeadline(termination, deadlineMs, "abandoned");
42
+ if (outcome === "abandoned") void termination.then(undefined, ignoreLateTerminationFailure);
43
+ return outcome;
44
+ }
45
+
46
+ export function abandonedWorkerNote(deadlineMs = WORKER_TERMINATE_DEADLINE_MS): string {
47
+ return `JavaScript worker did not stop within ${deadlineMs}ms: a synchronous call (for example Bun.spawnSync or child_process.spawnSync) is blocking it. A fresh worker replaced it; the blocked call keeps running until it returns.\n`;
48
+ }
49
+
50
+ async function raceDeadline<T extends string, Fallback extends string>(
51
+ operation: Promise<T>,
52
+ deadlineMs: number,
53
+ fallback: Fallback,
54
+ ): Promise<T | Fallback> {
55
+ let timer: ReturnType<typeof setTimeout> | undefined;
56
+ const deadline = new Promise<Fallback>((resolve) => {
57
+ timer = setTimeout(() => resolve(fallback), deadlineMs);
58
+ });
59
+ try {
60
+ return await Promise.race([operation, deadline]);
61
+ } finally {
62
+ if (timer !== undefined) clearTimeout(timer);
63
+ }
64
+ }
65
+
66
+ function ignoreLateTerminationFailure(): void {}
@@ -1,4 +1,5 @@
1
1
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { SessionEnvironment } from "../session-env.ts";
2
3
 
3
4
  export type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
4
5
  export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
@@ -11,6 +12,8 @@ export interface JavaScriptKernelOptions {
11
12
  readonly parallelPoolWidth: number;
12
13
  readonly onMessage?: (message: KernelToHostMessage) => void;
13
14
  readonly workerEntryUrl?: URL;
15
+ /** Per-session PI_* values applied to the worker environment before the first cell runs. */
16
+ readonly sessionEnv?: SessionEnvironment;
14
17
  }
15
18
 
16
19
  export interface JavaScriptRunInput {
@@ -7,8 +7,13 @@ export interface PendingJavaScriptRun {
7
7
  readonly input: JavaScriptRunInput;
8
8
  readonly resolve: (message: ResultMessage) => void;
9
9
  readonly reject: (error: Error) => void;
10
+ readonly settlement: Promise<ResultMessage>;
10
11
  startedAtMs: number | null;
11
12
  settled: boolean;
13
+ /** Host-composed result that wins over whatever the worker reports once an interrupt is in flight. */
14
+ interruptResult: ResultMessage | null;
15
+ interruptAck: PromiseWithResolvers<void> | null;
16
+ settledByWorker: boolean;
12
17
  }
13
18
 
14
19
  export class JavaScriptRunQueue {
@@ -24,9 +29,19 @@ export class JavaScriptRunQueue {
24
29
  }
25
30
 
26
31
  enqueue(input: JavaScriptRunInput): Promise<ResultMessage> {
27
- return new Promise((resolve, reject) => {
28
- this.#queue.push({ input, resolve, reject, startedAtMs: null, settled: false });
32
+ const { promise, resolve, reject } = Promise.withResolvers<ResultMessage>();
33
+ this.#queue.push({
34
+ input,
35
+ resolve,
36
+ reject,
37
+ settlement: promise,
38
+ startedAtMs: null,
39
+ settled: false,
40
+ interruptResult: null,
41
+ interruptAck: null,
42
+ settledByWorker: false,
29
43
  });
44
+ return promise;
30
45
  }
31
46
 
32
47
  startNext(startedAtMs: number): PendingJavaScriptRun | null {
@@ -64,7 +79,7 @@ export class JavaScriptRunQueue {
64
79
  settleAll(message: string): void {
65
80
  const active = this.#active;
66
81
  this.#active = null;
67
- if (active) this.settle(active, stoppedResult(active.input.cellId, message));
82
+ if (active) this.settle(active, active.interruptResult ?? stoppedResult(active.input.cellId, message));
68
83
  for (const queued of this.#queue.splice(0)) this.settle(queued, stoppedResult(queued.input.cellId, message));
69
84
  }
70
85
 
@@ -1,7 +1,22 @@
1
1
  import { JsWorkerRuntime } from "./worker-runtime.js";
2
2
 
3
+ // Mirrors INTERRUPT_ACK_OP in src/bridge/reserved.ts (this worker file cannot import TypeScript).
4
+ const INTERRUPT_ACK_OP = "interrupt-ack";
5
+
6
+ // Mirrors SESSION_ENVIRONMENT_KEYS in src/kernels/session-env.ts (this worker file
7
+ // cannot import TypeScript). Keys the active session does not set must be dropped so a
8
+ // value inherited from the host environment never leaks into a cell or its children.
9
+ const SESSION_ENVIRONMENT_KEYS = [
10
+ "PI_SESSION_ID",
11
+ "PI_SESSION_FILE",
12
+ "PI_PROVIDER",
13
+ "PI_MODEL",
14
+ "PI_REASONING_LEVEL",
15
+ ];
16
+
3
17
  export function createWorkerCore(transport, options) {
4
18
  let runtime = null;
19
+ let activeCell = null;
5
20
  const pendingTools = new Map();
6
21
 
7
22
  function emit(message) {
@@ -14,6 +29,7 @@ export function createWorkerCore(transport, options) {
14
29
  return;
15
30
  }
16
31
  const startedAtMs = performance.now();
32
+ activeCell = { cellId: message.cellId, interruption: null };
17
33
  try {
18
34
  const value = await runtime.run(message.code, message.cellId, {
19
35
  emit,
@@ -22,18 +38,34 @@ export function createWorkerCore(transport, options) {
22
38
  emit({ type: "result", cellId: message.cellId, ok: true, valueRepr: valueRepr(value), durationMs: durationMs(startedAtMs) });
23
39
  } catch (error) {
24
40
  emit({ type: "result", cellId: message.cellId, ok: false, error: bridgeError(error), durationMs: durationMs(startedAtMs) });
41
+ } finally {
42
+ activeCell = null;
25
43
  }
26
44
  }
27
45
 
28
46
  async function callTool(toolName, args) {
47
+ if (activeCell?.interruption) throw activeCell.interruption;
29
48
  const callId = `js-${crypto.randomUUID()}`;
30
49
  const promise = new Promise((resolve, reject) => pendingTools.set(callId, { resolve, reject }));
31
50
  emit({ type: "tool-call", callId, toolName, args });
32
51
  return await promise;
33
52
  }
34
53
 
54
+ function interruptCell(reason) {
55
+ if (!activeCell || !runtime) return;
56
+ emit({ type: "status", event: { op: INTERRUPT_ACK_OP, cellId: activeCell.cellId } });
57
+ const interruption = cellInterruptedError(reason);
58
+ activeCell.interruption = interruption;
59
+ for (const [callId, pending] of pendingTools) {
60
+ pendingTools.delete(callId);
61
+ pending.reject(interruption);
62
+ }
63
+ runtime.interrupt();
64
+ }
65
+
35
66
  function onMessage(message) {
36
67
  if (message.type === "init") {
68
+ applySessionEnvironment(message.sessionEnv);
37
69
  runtime = new JsWorkerRuntime({
38
70
  cwd: options.cwd,
39
71
  parallelPoolWidth: options.parallelPoolWidth,
@@ -55,6 +87,10 @@ export function createWorkerCore(transport, options) {
55
87
  else pending.reject(errorFromBridge(message.error));
56
88
  return;
57
89
  }
90
+ if (message.type === "interrupt") {
91
+ interruptCell(message.reason ?? "interrupted");
92
+ return;
93
+ }
58
94
  if (message.type === "close") {
59
95
  emit({ type: "closed" });
60
96
  transport.close();
@@ -74,11 +110,34 @@ function durationMs(startedAtMs) {
74
110
  return Math.max(0, Math.round(performance.now() - startedAtMs));
75
111
  }
76
112
 
113
+ function applySessionEnvironment(sessionEnv) {
114
+ const provided = new Set(Object.keys(sessionEnv ?? {}));
115
+ const deleted = [];
116
+ for (const key of SESSION_ENVIRONMENT_KEYS) {
117
+ if (key in process.env && !provided.has(key)) deleted.push(key);
118
+ delete process.env[key];
119
+ }
120
+ const applied = Object.entries(sessionEnv ?? {});
121
+ for (const [key, value] of applied) process.env[key] = value;
122
+ // A worker's process.env is its own view: Bun.$ and node:child_process read it, but Bun.spawn
123
+ // without an explicit env inherits the OS environ, which also still holds deleted keys because
124
+ // `delete process.env.X` does not unsetenv under Bun. installShellCapture reads these flags and
125
+ // pins the worker's environment view for such children (see worker-shell-capture.js).
126
+ globalThis.__senpi_session_env_deletions__ = deleted;
127
+ globalThis.__senpi_session_env_applied__ = applied.length > 0 || deleted.length > 0;
128
+ }
129
+
77
130
  function valueRepr(value) {
78
131
  if (value === undefined) return undefined;
79
132
  return JSON.stringify(value);
80
133
  }
81
134
 
135
+ function cellInterruptedError(reason) {
136
+ const error = new Error(`JS cell interrupted: ${reason}`);
137
+ error.name = "CellInterruptedError";
138
+ return error;
139
+ }
140
+
82
141
  function bridgeError(error) {
83
142
  if (error instanceof Error) {
84
143
  return { name: error.name, message: error.message, stack: error.stack };
@@ -11,7 +11,7 @@ export async function awaitMaybePromise(value) {
11
11
 
12
12
  export function wrapUserCode(code) {
13
13
  const persistentCode = persistTopLevelDeclarations(code);
14
- if (/\breturn\b/u.test(persistentCode)) return `(async () => {\n${persistentCode}\n})()`;
14
+ if (scanTopLevelStatements(persistentCode).hasTopLevelReturn) return `(async () => {\n${persistentCode}\n})()`;
15
15
  return `(async () => {\n${captureLastExpression(persistentCode)}\n})()`;
16
16
  }
17
17
 
@@ -705,7 +705,7 @@ function skipBlockComment(code, start) {
705
705
  }
706
706
 
707
707
  function captureLastExpression(code) {
708
- const start = findLastTopLevelStatementStart(code);
708
+ const start = scanTopLevelStatements(code).lastStatementStart;
709
709
  const head = code.slice(0, start);
710
710
  const tail = code.slice(start).trim();
711
711
  if (!tail || isStatementOnly(tail)) return code;
@@ -726,7 +726,7 @@ function isStatementOnly(source) {
726
726
 
727
727
  const CONTROL_PAREN_KEYWORDS = new Set(["catch", "for", "if", "switch", "while", "with"]);
728
728
 
729
- function findLastTopLevelStatementStart(code) {
729
+ function scanTopLevelStatements(code) {
730
730
  let start = 0;
731
731
  let round = 0;
732
732
  let square = 0;
@@ -734,6 +734,7 @@ function findLastTopLevelStatementStart(code) {
734
734
  let canStartRegex = true;
735
735
  let lastSignificant = "";
736
736
  let pendingControlParen = false;
737
+ let hasTopLevelReturn = false;
737
738
  const controlParens = [];
738
739
  for (let index = 0; index < code.length; index += 1) {
739
740
  const char = code[index];
@@ -763,8 +764,11 @@ function findLastTopLevelStatementStart(code) {
763
764
  if (isIdentifierStart(char)) {
764
765
  const end = readIdentifier(code, index);
765
766
  const token = code.slice(index, end);
766
- canStartRegex = REGEX_PREFIX_KEYWORDS.has(token);
767
- pendingControlParen = CONTROL_PAREN_KEYWORDS.has(token);
767
+ const isPropertyName = lastSignificant === ".";
768
+ const atTopLevel = round === 0 && square === 0 && curly === 0;
769
+ if (token === "return" && atTopLevel && !isPropertyName) hasTopLevelReturn = true;
770
+ canStartRegex = !isPropertyName && REGEX_PREFIX_KEYWORDS.has(token);
771
+ pendingControlParen = !isPropertyName && CONTROL_PAREN_KEYWORDS.has(token);
768
772
  lastSignificant = code[end - 1];
769
773
  index = end - 1;
770
774
  continue;
@@ -828,7 +832,7 @@ function findLastTopLevelStatementStart(code) {
828
832
  pendingControlParen = false;
829
833
  }
830
834
  }
831
- return start;
835
+ return { lastStatementStart: start, hasTopLevelReturn };
832
836
  }
833
837
 
834
838
  const STATEMENT_CONTINUATION_KEYWORDS = new Set(["catch", "else", "finally"]);
@@ -16,6 +16,7 @@ export class JsWorkerRuntime {
16
16
  #env = new Map();
17
17
  #hooks = null;
18
18
  #pendingDisplays = [];
19
+ #children = new Set();
19
20
 
20
21
  constructor(options) {
21
22
  this.#cwd = options.cwd;
@@ -41,10 +42,24 @@ export class JsWorkerRuntime {
41
42
  return value;
42
43
  } finally {
43
44
  this.#pendingDisplays = [];
45
+ this.#children.clear();
44
46
  this.#hooks = null;
45
47
  }
46
48
  }
47
49
 
50
+ interrupt() {
51
+ for (const child of this.#children) {
52
+ if (child.exitCode === null && child.signalCode === null) child.kill();
53
+ }
54
+ }
55
+
56
+ #trackChild(child) {
57
+ if (child === null || typeof child !== "object" || typeof child.kill !== "function") return;
58
+ this.#children.add(child);
59
+ const forget = () => this.#children.delete(child);
60
+ if (child.exited instanceof Promise) child.exited.then(forget, forget);
61
+ }
62
+
48
63
  async #drainPendingDisplays() {
49
64
  while (this.#pendingDisplays.length > 0) {
50
65
  const pending = this.#pendingDisplays;
@@ -99,6 +114,7 @@ export class JsWorkerRuntime {
99
114
  const restoreShellCapture = installShellCapture({
100
115
  isActive: () => this.#hooks !== null,
101
116
  emitText: (stream, data) => this.#emitText(stream, data),
117
+ onChild: (child) => this.#trackChild(child),
102
118
  });
103
119
  globalThis.__senpi_restore_console__ = () => {
104
120
  console.log = originalLog;
@@ -2,9 +2,34 @@ export type ShellCaptureStream = "stdout" | "stderr";
2
2
 
3
3
  export type ShellCaptureRestore = () => void;
4
4
 
5
+ export interface ShellCaptureChild {
6
+ readonly exitCode: number | null;
7
+ readonly signalCode: string | null;
8
+ readonly exited: Promise<number>;
9
+ kill(): void;
10
+ }
11
+
5
12
  export interface ShellCaptureOptions {
6
13
  readonly isActive: () => boolean;
7
14
  readonly emitText: (stream: ShellCaptureStream, data: string) => void;
15
+ /** Receives every `Bun.spawn` child created while a cell is active so the runtime can kill it on interrupt. */
16
+ readonly onChild?: (child: ShellCaptureChild) => void;
8
17
  }
9
18
 
10
19
  export function installShellCapture(options: ShellCaptureOptions): ShellCaptureRestore;
20
+
21
+ declare global {
22
+ /**
23
+ * Set by the JS worker core when applying the session environment deleted inherited
24
+ * `PI_*` keys (see worker-core.js). Under Bun a `delete process.env.X` does not
25
+ * unsetenv, so shell capture pins the worker's environment view for spawned children
26
+ * while this list is non-empty.
27
+ */
28
+ var __senpi_session_env_deletions__: string[] | undefined;
29
+ /**
30
+ * Set by the JS worker core once a session environment was applied (values set or inherited
31
+ * keys deleted). Bun.spawn without an explicit env inherits the OS environ rather than the
32
+ * worker's process.env, so shell capture pins the worker's view whenever this is true.
33
+ */
34
+ var __senpi_session_env_applied__: boolean | undefined;
35
+ }
@@ -1,16 +1,35 @@
1
1
  const SHELL_CONFIG_METHODS = ["env", "cwd", "nothrow", "throws"];
2
2
  const SHELL_READ_METHODS = ["text", "json", "lines", "arrayBuffer", "bytes", "blob"];
3
+ // `true | ( … )` hands every command in the template an empty pipe as stdin. The worker thread shares
4
+ // the host process's fd 0 (the TUI's terminal), which Bun.$ would otherwise inherit, so a stdin
5
+ // reader would wait on the user's keyboard forever. The newline before `)` keeps a trailing comment
6
+ // from swallowing the closing paren; the Bun shell has no other stdin control (no `$.stdin`, no
7
+ // redirect on a subshell).
8
+ const STDIN_ISOLATION_HEAD = "true | (\n";
9
+ const STDIN_ISOLATION_TAIL = "\n)";
3
10
 
4
11
  export function installShellCapture(options) {
5
12
  const bun = globalThis.Bun;
6
13
  if (!isBunRuntime(bun)) return () => {};
7
14
  const originalShell = bun.$;
8
15
  const originalSpawn = bun.spawn;
16
+ const originalSpawnSync = typeof bun.spawnSync === "function" ? bun.spawnSync : null;
17
+ const deletedKeys = globalThis.__senpi_session_env_deletions__;
18
+ const pinEnv =
19
+ globalThis.__senpi_session_env_applied__ === true || (Array.isArray(deletedKeys) && deletedKeys.length > 0);
20
+ if (pinEnv && typeof originalShell.env === "function") {
21
+ // Bun.spawn without an explicit env inherits the OS environ, not the worker's process.env,
22
+ // and deleting from process.env does not unsetenv under Bun. Pinning the worker's
23
+ // environment view mirrors the bash tool, which always spawns with an explicit env.
24
+ originalShell.env({ ...process.env });
25
+ }
9
26
  bun.$ = capturedShell(originalShell, options);
10
- bun.spawn = capturedSpawn(originalSpawn, options);
27
+ bun.spawn = capturedSpawn(originalSpawn, options, pinEnv);
28
+ if (originalSpawnSync !== null) bun.spawnSync = capturedSpawnSync(originalSpawnSync, pinEnv);
11
29
  return () => {
12
30
  bun.$ = originalShell;
13
31
  bun.spawn = originalSpawn;
32
+ if (originalSpawnSync !== null) bun.spawnSync = originalSpawnSync;
14
33
  };
15
34
  }
16
35
 
@@ -20,8 +39,9 @@ function isBunRuntime(bun) {
20
39
 
21
40
  function capturedShell(originalShell, options) {
22
41
  const shell = (strings, ...expressions) => {
23
- const promise = originalShell(strings, ...expressions);
24
- return options.isActive() ? captureShellPromise(promise, options.emitText) : promise;
42
+ if (!options.isActive()) return originalShell(strings, ...expressions);
43
+ const promise = originalShell(isolateStdin(strings), ...expressions);
44
+ return captureShellPromise(promise, options.emitText);
25
45
  };
26
46
  for (const key of Object.keys(originalShell)) shell[key] = originalShell[key];
27
47
  for (const method of SHELL_CONFIG_METHODS) {
@@ -33,6 +53,18 @@ function capturedShell(originalShell, options) {
33
53
  return shell;
34
54
  }
35
55
 
56
+ function isolateStdin(strings) {
57
+ if (!Array.isArray(strings) || !Array.isArray(strings.raw)) return strings;
58
+ const cooked = [...strings];
59
+ const raw = [...strings.raw];
60
+ const last = cooked.length - 1;
61
+ cooked[0] = `${STDIN_ISOLATION_HEAD}${cooked[0]}`;
62
+ raw[0] = `${STDIN_ISOLATION_HEAD}${raw[0]}`;
63
+ cooked[last] = `${cooked[last]}${STDIN_ISOLATION_TAIL}`;
64
+ raw[last] = `${raw[last]}${STDIN_ISOLATION_TAIL}`;
65
+ return Object.freeze(Object.assign(cooked, { raw: Object.freeze(raw) }));
66
+ }
67
+
36
68
  function captureShellPromise(promise, emitText) {
37
69
  const prototype = Object.getPrototypeOf(promise);
38
70
  let echo = true;
@@ -83,17 +115,47 @@ function outputText(value) {
83
115
  return typeof value === "string" ? value : "";
84
116
  }
85
117
 
86
- function capturedSpawn(originalSpawn, options) {
118
+ // Bun.spawnSync inherits the OS environ the same way Bun.spawn does, so a cell calling it
119
+ // without an explicit env must get the worker's view pinned too (measured on Bun 1.4.0).
120
+ function capturedSpawnSync(originalSpawnSync, pinEnv) {
121
+ return (...args) => {
122
+ if (!pinEnv) return originalSpawnSync(...args);
123
+ const [first, second] = args;
124
+ if (Array.isArray(first)) {
125
+ const spawnOptions = second === undefined ? {} : second;
126
+ if (spawnOptions === null || typeof spawnOptions !== "object" || spawnOptions.env !== undefined)
127
+ return originalSpawnSync(...args);
128
+ return originalSpawnSync(first, { ...spawnOptions, env: { ...process.env } });
129
+ }
130
+ if (first !== null && typeof first === "object" && first.env === undefined)
131
+ return originalSpawnSync({ ...first, env: { ...process.env } });
132
+ return originalSpawnSync(...args);
133
+ };
134
+ }
135
+
136
+ function capturedSpawn(originalSpawn, options, pinEnv) {
87
137
  return (...args) => {
88
138
  if (!options.isActive()) return originalSpawn(...args);
89
139
  const [first, second] = args;
140
+ let child;
90
141
  if (Array.isArray(first)) {
91
142
  const spawnOptions = second === undefined ? {} : second;
92
- if (!needsStderrCapture(spawnOptions)) return originalSpawn(...args);
93
- return drainStderr(originalSpawn(first, { ...spawnOptions, stderr: "pipe" }), options.emitText);
143
+ const effective = pinEnv && spawnOptions.env === undefined ? { ...spawnOptions, env: { ...process.env } } : spawnOptions;
144
+ child = needsStderrCapture(effective)
145
+ ? drainStderr(originalSpawn(first, { ...effective, stderr: "pipe" }), options.emitText)
146
+ : effective === spawnOptions
147
+ ? originalSpawn(...args)
148
+ : originalSpawn(first, effective);
149
+ } else {
150
+ const effective = pinEnv && first !== null && typeof first === "object" && first.env === undefined ? { ...first, env: { ...process.env } } : first;
151
+ child = needsStderrCapture(effective)
152
+ ? drainStderr(originalSpawn({ ...effective, stderr: "pipe" }), options.emitText)
153
+ : effective === first
154
+ ? originalSpawn(...args)
155
+ : originalSpawn(effective);
94
156
  }
95
- if (!needsStderrCapture(first)) return originalSpawn(...args);
96
- return drainStderr(originalSpawn({ ...first, stderr: "pipe" }), options.emitText);
157
+ options.onChild?.(child);
158
+ return child;
97
159
  };
98
160
  }
99
161
 
@@ -0,0 +1,106 @@
1
+ import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { WorkerLike } from "./inline-worker.ts";
3
+ import { retireWorker, type WorkerRetirement } from "./interrupt-bounds.ts";
4
+ import type { JavaScriptKernelMode } from "./kernel-contract.ts";
5
+ import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
6
+ import { WorkerStartupCancelledError } from "./worker-host.ts";
7
+ import { startWorkerWithInlineFallback } from "./worker-startup.ts";
8
+
9
+ export interface WorkerSlotListeners {
10
+ isOpen(): boolean;
11
+ onMessage(message: KernelToHostMessage): void;
12
+ onCrash(error: Error): void;
13
+ }
14
+
15
+ /** The kernel's current worker generation: startup with inline fallback, message fencing, bounded retirement. */
16
+ export class WorkerSlot {
17
+ readonly #options: JavaScriptKernelOptions;
18
+ readonly #listeners: WorkerSlotListeners;
19
+ #worker: WorkerLike | null = null;
20
+ #mode: JavaScriptKernelMode = "worker";
21
+ #generation = 0;
22
+ #ready: Promise<void> | null = null;
23
+ #startupAbort: AbortController | null = null;
24
+
25
+ constructor(options: JavaScriptKernelOptions, listeners: WorkerSlotListeners) {
26
+ this.#options = options;
27
+ this.#listeners = listeners;
28
+ }
29
+
30
+ get mode(): JavaScriptKernelMode {
31
+ return this.#mode;
32
+ }
33
+
34
+ get present(): boolean {
35
+ return this.#worker !== null;
36
+ }
37
+
38
+ get startingUp(): boolean {
39
+ return this.#startupAbort !== null;
40
+ }
41
+
42
+ postMessage(message: HostToKernelMessage): void {
43
+ this.#worker?.postMessage(message);
44
+ }
45
+
46
+ async ensureReady(): Promise<void> {
47
+ if (!this.#ready) {
48
+ const generation = ++this.#generation;
49
+ const controller = new AbortController();
50
+ this.#startupAbort = controller;
51
+ const ready = startWorkerWithInlineFallback(
52
+ {
53
+ options: this.#options,
54
+ publish: (worker) => this.#publish(worker, generation),
55
+ isCurrent: (worker) => this.#isCurrent(worker, generation),
56
+ retire: (worker) => {
57
+ if (this.#worker === worker) this.#worker = null;
58
+ },
59
+ canFallBackInline: () => this.#listeners.isOpen() && generation === this.#generation,
60
+ },
61
+ controller.signal,
62
+ );
63
+ this.#ready = ready;
64
+ void ready.then(
65
+ () => {
66
+ if (this.#ready !== ready) return;
67
+ this.#startupAbort = null;
68
+ this.#mode = this.#worker?.mode ?? this.#mode;
69
+ },
70
+ () => {
71
+ if (this.#ready === ready) {
72
+ this.#ready = null;
73
+ this.#startupAbort = null;
74
+ }
75
+ },
76
+ );
77
+ }
78
+ return await this.#ready;
79
+ }
80
+
81
+ async retire(): Promise<WorkerRetirement> {
82
+ this.#generation += 1;
83
+ this.#startupAbort?.abort();
84
+ this.#startupAbort = null;
85
+ this.#ready = null;
86
+ const worker = this.#worker;
87
+ this.#worker = null;
88
+ if (!worker) return "terminated";
89
+ return await retireWorker(worker);
90
+ }
91
+
92
+ #publish(worker: WorkerLike, generation: number): void {
93
+ if (!this.#listeners.isOpen() || generation !== this.#generation) throw new WorkerStartupCancelledError();
94
+ this.#worker = worker;
95
+ worker.onMessage((message) => {
96
+ if (this.#isCurrent(worker, generation)) this.#listeners.onMessage(message);
97
+ });
98
+ worker.onError((error) => {
99
+ if (this.#isCurrent(worker, generation)) this.#listeners.onCrash(error);
100
+ });
101
+ }
102
+
103
+ #isCurrent(worker: WorkerLike, generation: number): boolean {
104
+ return this.#listeners.isOpen() && this.#worker === worker && this.#generation === generation;
105
+ }
106
+ }
@@ -0,0 +1,70 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
+ import { createInlineWorker, type WorkerLike } from "./inline-worker.ts";
5
+ import { type JavaScriptKernelOptions, localBridgeConnection } from "./local-module-loader.ts";
6
+ import { spawnNodeWorker, WorkerStartupCancelledError, waitForReady } from "./worker-host.ts";
7
+
8
+ export interface JavaScriptWorkerEntryUrlOptions extends CodemodeRuntimeAssetEnvironment {
9
+ readonly localPath?: string;
10
+ }
11
+
12
+ export function resolveJsWorkerEntryUrl(options: JavaScriptWorkerEntryUrlOptions = {}): URL {
13
+ const localPath = options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "worker-entry.js");
14
+ return pathToFileURL(requireCodemodeRuntimeAsset(localPath, join("kernels", "js", "worker-entry.js"), options));
15
+ }
16
+
17
+ export interface WorkerStartupHooks {
18
+ readonly options: JavaScriptKernelOptions;
19
+ /** Wires the worker into the kernel; throws `WorkerStartupCancelledError` once the generation is stale. */
20
+ publish(worker: WorkerLike): void;
21
+ isCurrent(worker: WorkerLike): boolean;
22
+ retire(worker: WorkerLike): void;
23
+ canFallBackInline(): boolean;
24
+ }
25
+
26
+ export async function startWorkerWithInlineFallback(hooks: WorkerStartupHooks, signal: AbortSignal): Promise<void> {
27
+ let worker = spawnWorker(hooks.options);
28
+ hooks.publish(worker);
29
+ try {
30
+ await initializeWorker(worker, hooks.options, signal);
31
+ return;
32
+ } catch (error) {
33
+ if (!hooks.isCurrent(worker) || error instanceof WorkerStartupCancelledError) {
34
+ await worker.terminate();
35
+ throw new WorkerStartupCancelledError();
36
+ }
37
+ if (worker.mode === "inline") throw error;
38
+ hooks.retire(worker);
39
+ await worker.terminate();
40
+ }
41
+ if (!hooks.canFallBackInline()) throw new WorkerStartupCancelledError();
42
+ worker = createInlineWorker(hooks.options.cwd, hooks.options.parallelPoolWidth);
43
+ hooks.publish(worker);
44
+ await initializeWorker(worker, hooks.options, signal);
45
+ }
46
+
47
+ function spawnWorker(options: JavaScriptKernelOptions): WorkerLike {
48
+ try {
49
+ const url = options.workerEntryUrl ?? resolveJsWorkerEntryUrl();
50
+ return spawnNodeWorker(url, options.cwd, options.parallelPoolWidth);
51
+ } catch (error) {
52
+ if (!(error instanceof Error)) throw error;
53
+ return createInlineWorker(options.cwd, options.parallelPoolWidth);
54
+ }
55
+ }
56
+
57
+ async function initializeWorker(
58
+ worker: WorkerLike,
59
+ options: JavaScriptKernelOptions,
60
+ signal: AbortSignal,
61
+ ): Promise<void> {
62
+ const ready = waitForReady(worker, signal);
63
+ worker.postMessage({
64
+ type: "init",
65
+ sessionId: options.sessionId,
66
+ connection: localBridgeConnection(options),
67
+ ...(options.sessionEnv === undefined ? {} : { sessionEnv: options.sessionEnv }),
68
+ });
69
+ await ready;
70
+ }
@@ -1,4 +1,5 @@
1
1
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { SessionEnvironment } from "../session-env.ts";
2
3
  import type { KernelSpawnProcess } from "./process.ts";
3
4
  import type { PythonTransportResult } from "./transport.ts";
4
5
 
@@ -8,6 +9,8 @@ export interface PythonKernelStartOptions {
8
9
  readonly cwd: string;
9
10
  readonly connection: BridgeConnectionConfig;
10
11
  readonly env?: NodeJS.ProcessEnv;
12
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
13
+ readonly sessionEnv?: SessionEnvironment;
11
14
  readonly startupTimeoutMs?: number;
12
15
  readonly onMessage?: (message: KernelToHostMessage) => void;
13
16
  readonly spawnProcess?: KernelSpawnProcess;