@code-yeongyu/senpi-codemode 2026.10.1 → 2026.10.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.
@@ -0,0 +1,185 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { syncBuiltinESMExports } from "node:module";
3
+ import { isAbsolute, resolve, sep } from "node:path";
4
+ import { isMainThread } from "node:worker_threads";
5
+
6
+ // A worker thread cannot chdir (process.chdir throws ERR_WORKER_UNSUPPORTED_OPERATION under Node
7
+ // and Bun), so every relative path a cell used resolved against the host process directory: in a
8
+ // shared RPC host that is the host's state dir, not the session's project (omo#9371). The worker
9
+ // therefore applies the session directory to the path-taking APIs itself. Patches touch only this
10
+ // worker's module instances and globals; the host thread and its bash tool keep their own cwd.
11
+
12
+ // Indexes of the path arguments each fs function takes; `symlink`'s target stays relative to the link.
13
+ const FS_PATH_ARGS = {
14
+ access: [0],
15
+ appendFile: [0],
16
+ chmod: [0],
17
+ chown: [0],
18
+ copyFile: [0, 1],
19
+ cp: [0, 1],
20
+ createReadStream: [0],
21
+ createWriteStream: [0],
22
+ exists: [0],
23
+ lchmod: [0],
24
+ lchown: [0],
25
+ link: [0, 1],
26
+ lstat: [0],
27
+ lutimes: [0],
28
+ mkdir: [0],
29
+ mkdtemp: [0],
30
+ open: [0],
31
+ openAsBlob: [0],
32
+ opendir: [0],
33
+ readdir: [0],
34
+ readFile: [0],
35
+ readlink: [0],
36
+ realpath: [0],
37
+ rename: [0, 1],
38
+ rm: [0],
39
+ rmdir: [0],
40
+ stat: [0],
41
+ statfs: [0],
42
+ symlink: [1],
43
+ truncate: [0],
44
+ unlink: [0],
45
+ unwatchFile: [0],
46
+ utimes: [0],
47
+ watch: [0],
48
+ watchFile: [0],
49
+ writeFile: [0],
50
+ };
51
+
52
+ const CHILD_PROCESS_FILE_ARGS = ["spawn", "spawnSync", "execFile", "execFileSync", "fork"];
53
+ const CHILD_PROCESS_COMMAND_ARGS = ["exec", "execSync"];
54
+
55
+ export function installSessionCwd(cwd) {
56
+ if (isMainThread) throw new Error("installSessionCwd must only run inside a kernel worker thread");
57
+ const root = resolve(cwd);
58
+ const rootBytes = Buffer.from(root.endsWith(sep) ? root : `${root}${sep}`);
59
+ // fs also takes Buffer/Uint8Array paths; a relative one gets the session root prepended as bytes,
60
+ // so non-UTF-8 names survive. A file: URL is always absolute, so URLs pass through unchanged.
61
+ const at = (path) => {
62
+ if (typeof path === "string") return isAbsolute(path) ? path : resolve(root, path);
63
+ if (path instanceof Uint8Array && !isAbsolute(Buffer.from(path).toString("latin1"))) {
64
+ return Buffer.concat([rootBytes, path]);
65
+ }
66
+ return path;
67
+ };
68
+ process.cwd = () => root;
69
+ const path = process.getBuiltinModule("node:path");
70
+ const resolveFromProcess = path.resolve;
71
+ path.resolve = (...segments) => resolveFromProcess(root, ...segments);
72
+ patchFs(process.getBuiltinModule("node:fs"), process.getBuiltinModule("node:fs/promises"), at, root);
73
+ patchChildProcess(process.getBuiltinModule("node:child_process"), at, root);
74
+ patchBun(globalThis.Bun, at, root);
75
+ syncBuiltinESMExports();
76
+ }
77
+
78
+ function patchFs(fs, promises, at, root) {
79
+ const promiseModules = [...new Set([fs.promises, promises])];
80
+ for (const [name, indexes] of Object.entries(FS_PATH_ARGS)) {
81
+ replace(fs, name, (fn) => resolvingArgs(fn, indexes, at));
82
+ replace(fs, `${name}Sync`, (fn) => resolvingArgs(fn, indexes, at));
83
+ for (const target of promiseModules) replace(target, name, (fn) => resolvingArgs(fn, indexes, at));
84
+ }
85
+ replace(fs.realpath, "native", (fn) => resolvingArgs(fn, [0], at));
86
+ replace(fs.realpathSync, "native", (fn) => resolvingArgs(fn, [0], at));
87
+ for (const target of [fs, ...promiseModules]) {
88
+ for (const name of ["glob", "globSync"]) replace(target, name, (fn) => withOptionsCwd(fn, () => 1, root));
89
+ }
90
+ }
91
+
92
+ function patchChildProcess(childProcess, at, root) {
93
+ for (const name of CHILD_PROCESS_FILE_ARGS) {
94
+ replace(childProcess, name, (fn) => {
95
+ const withCwd = withOptionsCwd(fn, fileOptionsIndex, root);
96
+ return name === "fork" ? resolvingArgs(withCwd, [0], at) : withCwd;
97
+ });
98
+ }
99
+ for (const name of CHILD_PROCESS_COMMAND_ARGS) replace(childProcess, name, (fn) => withOptionsCwd(fn, () => 1, root));
100
+ }
101
+
102
+ function patchBun(bun, at, root) {
103
+ if (bun === null || typeof bun !== "object") return;
104
+ replace(bun, "file", (fn) => resolvingArgs(fn, [0], at));
105
+ replace(bun, "write", (fn) => resolvingArgs(fn, [0], at));
106
+ replace(bun, "spawn", (fn) => withBunSpawnCwd(fn, root));
107
+ replace(bun, "spawnSync", (fn) => withBunSpawnCwd(fn, root));
108
+ if (typeof bun.$?.cwd === "function") bun.$.cwd(root);
109
+ const glob = bun.Glob?.prototype;
110
+ if (glob) {
111
+ for (const name of ["scan", "scanSync"]) {
112
+ replace(glob, name, (fn) =>
113
+ function scan(options) {
114
+ return fn.call(this, globScanOptions(options, root));
115
+ },
116
+ );
117
+ }
118
+ }
119
+ }
120
+
121
+ function replace(target, name, wrap) {
122
+ const original = target?.[name];
123
+ if (typeof original !== "function") return;
124
+ target[name] = copyOwnProperties(original, wrap(original));
125
+ }
126
+
127
+ function copyOwnProperties(original, wrapped) {
128
+ for (const key of Reflect.ownKeys(original)) {
129
+ if (key === "length" || key === "name" || key === "prototype" || Object.hasOwn(wrapped, key)) continue;
130
+ const descriptor = Object.getOwnPropertyDescriptor(original, key);
131
+ if (descriptor) Object.defineProperty(wrapped, key, descriptor);
132
+ }
133
+ return wrapped;
134
+ }
135
+
136
+ function resolvingArgs(fn, indexes, at) {
137
+ return function resolvingPathArguments(...args) {
138
+ for (const index of indexes) if (index < args.length) args[index] = at(args[index]);
139
+ return fn.apply(this, args);
140
+ };
141
+ }
142
+
143
+ // spawn(file, args?, options?) and friends: options follow an args array, or an explicit
144
+ // undefined args slot, otherwise they sit right after the file.
145
+ function fileOptionsIndex(args) {
146
+ return Array.isArray(args[1]) || (args[1] == null && args.length > 2) ? 2 : 1;
147
+ }
148
+
149
+ function withOptionsCwd(fn, optionsIndex, root) {
150
+ return function withSessionCwd(...args) {
151
+ const index = optionsIndex(args);
152
+ const options = args[index];
153
+ if (options === undefined || options === null) args[index] = { cwd: root };
154
+ else if (typeof options === "function") args.splice(index, 0, { cwd: root });
155
+ else if (typeof options === "object") args[index] = { ...options, cwd: cwdFrom(options.cwd, root) };
156
+ return fn.apply(this, args);
157
+ };
158
+ }
159
+
160
+ function withBunSpawnCwd(fn, root) {
161
+ return function spawnInSessionCwd(...args) {
162
+ const [first, second] = args;
163
+ if (Array.isArray(first)) {
164
+ const options = second === undefined || second === null ? {} : second;
165
+ if (typeof options !== "object") return fn.apply(this, args);
166
+ return fn.call(this, first, { ...options, cwd: cwdFrom(options.cwd, root) }, ...args.slice(2));
167
+ }
168
+ if (first !== null && typeof first === "object") {
169
+ return fn.call(this, { ...first, cwd: cwdFrom(first.cwd, root) }, ...args.slice(1));
170
+ }
171
+ return fn.apply(this, args);
172
+ };
173
+ }
174
+
175
+ function globScanOptions(options, root) {
176
+ if (options === undefined || options === null) return { cwd: root };
177
+ if (typeof options === "string") return cwdFrom(options, root);
178
+ if (typeof options === "object") return { ...options, cwd: cwdFrom(options.cwd, root) };
179
+ return options;
180
+ }
181
+
182
+ function cwdFrom(cwd, root) {
183
+ if (cwd === undefined || cwd === null) return root;
184
+ return typeof cwd === "string" && !isAbsolute(cwd) ? resolve(root, cwd) : cwd;
185
+ }
@@ -1,8 +1,16 @@
1
+ import type { EventEmitter } from "node:events";
1
2
  import { Worker } from "node:worker_threads";
2
3
  import type { KernelToHostMessage } from "../../bridge/protocol.ts";
3
4
  import type { WorkerLike } from "./inline-worker.ts";
4
5
  import type { JavaScriptKernelMode } from "./kernel-contract.ts";
5
6
 
7
+ declare global {
8
+ // Inert outside the gate. Bun does not refresh named builtin constructor exports.
9
+ var __senpiCodemodeGateObserveResource:
10
+ | ((kind: "processes" | "workers" | "handles", resource: EventEmitter, closeEvent: "exit" | "close") => void)
11
+ | undefined;
12
+ }
13
+
6
14
  export class WorkerStartupCancelledError extends Error {
7
15
  readonly name = "WorkerStartupCancelledError";
8
16
 
@@ -27,12 +35,9 @@ export function spawnNodeWorker(
27
35
  parallelPoolWidth: number,
28
36
  mode: JavaScriptKernelMode = "worker",
29
37
  ): WorkerLike {
30
- return wrapNodeWorker(
31
- new Worker(url, {
32
- workerData: { cwd, parallelPoolWidth },
33
- }),
34
- mode,
35
- );
38
+ const worker = new Worker(url, { workerData: { cwd, parallelPoolWidth } });
39
+ globalThis.__senpiCodemodeGateObserveResource?.("workers", worker, "exit");
40
+ return wrapNodeWorker(worker, mode);
36
41
  }
37
42
 
38
43
  export function waitForReady(worker: WorkerLike, signal: AbortSignal): Promise<void> {
@@ -30,13 +30,16 @@ export class JsWorkerRuntime {
30
30
  #hooks = null;
31
31
  #pendingDisplays = [];
32
32
  #children = new Set();
33
+ #shellWaits = new Set();
33
34
  #onChildEvent;
35
+ #onShellWaitChange;
34
36
  #tools;
35
37
 
36
38
  constructor(options) {
37
39
  this.#cwd = options.cwd;
38
40
  this.#parallelPoolWidth = options.parallelPoolWidth;
39
41
  this.#onChildEvent = typeof options.onChildEvent === "function" ? options.onChildEvent : null;
42
+ this.#onShellWaitChange = options.onShellWaitChange;
40
43
  this.#localRoots = { ...(options.localRoots ?? {}) };
41
44
  if (options.artifactsDir && !this.#localRoots.local) this.#localRoots.local = join(options.artifactsDir, "local");
42
45
  this.#tools = createKernelToolRegistry({
@@ -51,7 +54,12 @@ export class JsWorkerRuntime {
51
54
  return this.#tools;
52
55
  }
53
56
 
57
+ get shellWaitActive() {
58
+ return this.#shellWaits.size > 0;
59
+ }
60
+
54
61
  async run(code, cellId, hooks) {
62
+ this.#shellWaits.clear();
55
63
  this.#hooks = hooks;
56
64
  try {
57
65
  let prelude = "";
@@ -158,6 +166,11 @@ export class JsWorkerRuntime {
158
166
  isActive: () => this.#hooks !== null,
159
167
  emitText: (stream, data) => this.#emitText(stream, data),
160
168
  onChild: (child, spawnOptions) => this.#trackChild(child, spawnOptions),
169
+ onShellWait: (promise, waiting) => {
170
+ if (waiting) this.#shellWaits.add(promise);
171
+ else this.#shellWaits.delete(promise);
172
+ this.#onShellWaitChange?.();
173
+ },
161
174
  });
162
175
  globalThis.__senpi_restore_console__ = () => {
163
176
  console.log = originalLog;
@@ -15,6 +15,8 @@ export interface ShellCaptureOptions {
15
15
  readonly emitText: (stream: ShellCaptureStream, data: string) => void;
16
16
  /** Receives every `Bun.spawn` child created while a cell is active so the runtime can kill it on interrupt. */
17
17
  readonly onChild?: (child: ShellCaptureChild, spawnOptions?: unknown) => void;
18
+ /** Tracks native shell waits without starting an unawaited ShellPromise. */
19
+ readonly onShellWait?: (promise: object, waiting: boolean) => void;
18
20
  }
19
21
 
20
22
  export function installShellCapture(options: ShellCaptureOptions): ShellCaptureRestore;
@@ -41,7 +41,7 @@ function capturedShell(originalShell, options) {
41
41
  const shell = (strings, ...expressions) => {
42
42
  if (!options.isActive()) return originalShell(strings, ...expressions);
43
43
  const promise = originalShell(isolateStdin(strings), ...expressions);
44
- return captureShellPromise(promise, options.emitText);
44
+ return captureShellPromise(promise, options);
45
45
  };
46
46
  for (const key of Object.keys(originalShell)) shell[key] = originalShell[key];
47
47
  for (const method of SHELL_CONFIG_METHODS) {
@@ -65,13 +65,20 @@ function isolateStdin(strings) {
65
65
  return Object.freeze(Object.assign(cooked, { raw: Object.freeze(raw) }));
66
66
  }
67
67
 
68
- function captureShellPromise(promise, emitText) {
68
+ function captureShellPromise(promise, options) {
69
69
  const prototype = Object.getPrototypeOf(promise);
70
+ let waiting = false;
71
+ let settled = false;
72
+ const finish = () => {
73
+ settled = true;
74
+ if (waiting) options.onShellWait?.(promise, false);
75
+ waiting = false;
76
+ };
70
77
  let echo = true;
71
78
  const echoOnce = (output) => {
72
79
  if (!echo) return;
73
80
  echo = false;
74
- emitShellOutput(output, emitText);
81
+ emitShellOutput(output, options.emitText);
75
82
  };
76
83
  prototype.quiet.call(promise);
77
84
  promise.quiet = function quiet() {
@@ -86,13 +93,19 @@ function captureShellPromise(promise, emitText) {
86
93
  };
87
94
  }
88
95
  promise.then = function then(onFulfilled, onRejected) {
96
+ if (!waiting && !settled) {
97
+ waiting = true;
98
+ options.onShellWait?.(promise, true);
99
+ }
89
100
  return prototype.then.call(
90
101
  this,
91
102
  (output) => {
103
+ finish();
92
104
  echoOnce(output);
93
105
  return onFulfilled ? onFulfilled(output) : output;
94
106
  },
95
107
  (error) => {
108
+ finish();
96
109
  echoOnce(error);
97
110
  if (onRejected) return onRejected(error);
98
111
  throw error;
@@ -3,6 +3,7 @@ import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/p
3
3
  import type { EvalKernelRunInput } from "../../tool/types.ts";
4
4
  import type { SessionEnvironment } from "../session-env.ts";
5
5
  import type { KernelSpawnProcess } from "./process.ts";
6
+ import type { PythonStartupStage } from "./startup.ts";
6
7
  import type { PythonTransportResult } from "./transport.ts";
7
8
 
8
9
  export interface PythonKernelStartOptions {
@@ -13,7 +14,10 @@ export interface PythonKernelStartOptions {
13
14
  readonly env?: NodeJS.ProcessEnv;
14
15
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
15
16
  readonly sessionEnv?: SessionEnvironment;
17
+ /** Python bootstrap inactivity guard per advancing stage, not a total readiness deadline. */
16
18
  readonly startupTimeoutMs?: number;
19
+ /** Observes bootstrap control events without mixing them into cell output callbacks. */
20
+ readonly onStartupProgress?: (stage: PythonStartupStage) => void;
17
21
  readonly onMessage?: (message: KernelToHostMessage) => void;
18
22
  readonly spawnProcess?: KernelSpawnProcess;
19
23
  /** Post-cell collection, notice, and ceiling thresholds sent on `init`; absent leaves memory unmanaged. */
@@ -1,15 +1,16 @@
1
+ // allow: SIZE_OK — one persistent Python lifecycle state machine owns queue, generation, and retirement.
1
2
  import type { KernelInterruptHandle } from "../../tool/types.ts";
2
3
  import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
3
4
  import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
4
5
  import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
5
6
  import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
6
7
  import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
8
+ import { pythonStartupHangGuardMs } from "./startup.ts";
7
9
  import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
8
10
 
9
11
  export type { PythonKernelRunOptions, PythonKernelStartOptions } from "./kernel-contract.ts";
10
12
  export type { KernelChild, KernelSpawnOptions, KernelSpawnProcess } from "./process.ts";
11
13
 
12
- const startupTimeoutMs = 5_000;
13
14
  const interruptEscalationMs = 5_000;
14
15
 
15
16
  export class PythonKernel {
@@ -229,7 +230,7 @@ export class PythonKernel {
229
230
  : (this.#active?.input.onMessage ?? this.#options.onMessage);
230
231
  callback?.(message);
231
232
  },
232
- startupTimeoutMs: this.#options.startupTimeoutMs ?? startupTimeoutMs,
233
+ startupTimeoutMs: this.#options.startupTimeoutMs ?? pythonStartupHangGuardMs,
233
234
  isOwned: () => !this.#closed && generation === this.#generation,
234
235
  onRetirementFailure: (transport, error) => {
235
236
  if (!this.#transport) this.#transport = transport;
@@ -1,6 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  # noqa: SIZE_OK — this dependency-free subprocess prelude must ship as one file.
4
+ import sys
5
+
6
+ # Emit before importing the stdlib graph: a cold interpreter can still be making
7
+ # progress after the former five-second total startup deadline.
8
+ sys.__stdout__.write('{"type":"status","event":{"op":"kernel-startup","stage":"stdlib-imports"}}\n')
9
+ sys.__stdout__.flush()
10
+
4
11
  import ast
5
12
  import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
6
13
  import base64
@@ -15,7 +22,6 @@ import os
15
22
  import re
16
23
  import signal
17
24
  import subprocess
18
- import sys
19
25
  import time
20
26
  import traceback
21
27
  import types
@@ -29,6 +35,9 @@ from threading import Lock, Thread
29
35
  from typing import Any, Callable, Union
30
36
  from urllib.parse import unquote
31
37
 
38
+ sys.__stdout__.write('{"type":"status","event":{"op":"kernel-startup","stage":"runtime-init"}}\n')
39
+ sys.__stdout__.flush()
40
+
32
41
  CONNECTION: dict[str, Any] = {}
33
42
  USER_NS: dict[str, Any] = {"__name__": "__main__", "__doc__": None, "__builtins__": __builtins__}
34
43
  LOOP = asyncio.new_event_loop()
@@ -1463,6 +1472,7 @@ def _start_parent_watch() -> None:
1463
1472
 
1464
1473
 
1465
1474
  def main() -> None:
1475
+ emit_status("kernel-startup", force=True, stage="host-init")
1466
1476
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1467
1477
  _start_parent_watch()
1468
1478
  host_closed = False
@@ -34,13 +34,15 @@ export function defaultSpawn(options: KernelSpawnOptions): KernelChild {
34
34
  // watch for a parent that is already gone by the time the interpreter boots.
35
35
  const env =
36
36
  process.platform === "win32" ? options.env : { ...options.env, SENPI_PY_KERNEL_PARENT_PID: String(process.pid) };
37
- return spawn(options.command, [...options.args], {
37
+ const child = spawn(options.command, [...options.args], {
38
38
  cwd: options.cwd,
39
39
  env,
40
40
  stdio: "pipe",
41
41
  detached: process.platform !== "win32",
42
42
  windowsHide: true,
43
43
  });
44
+ globalThis.__senpiCodemodeGateObserveResource?.("processes", child, "close");
45
+ return child;
44
46
  }
45
47
 
46
48
  export function splitCommand(commandLine: string): { readonly command: string; readonly args: readonly string[] } {
@@ -0,0 +1,75 @@
1
+ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
+
3
+ // Fresh-cache Windows bootstrap: p99 5,220 ms across 30 samples in Actions run
4
+ // 36882163342. Twice that observation, rounded up to a second, is a hang guard,
5
+ // not a total startup budget: only an advancing kernel stage rearms it.
6
+ export const pythonStartupHangGuardMs = 11_000;
7
+
8
+ const stages = ["interpreter-launch", "stdlib-imports", "runtime-init", "host-init"] as const;
9
+ export type PythonStartupStage = (typeof stages)[number];
10
+
11
+ export class PythonKernelStartupError extends Error {
12
+ readonly stage: PythonStartupStage;
13
+
14
+ constructor(stage: PythonStartupStage, reason: string, cause?: Error) {
15
+ super(`Python kernel startup failed at ${stage}: ${reason}`, { cause });
16
+ this.name = "PythonKernelStartupError";
17
+ this.stage = stage;
18
+ }
19
+ }
20
+
21
+ /** Owns the ready event and a per-stage inactivity watchdog, never a total deadline. */
22
+ export class PythonStartup {
23
+ readonly #timeoutMs: number;
24
+ readonly #failureDetail: () => string;
25
+ readonly #ready = Promise.withResolvers<void>();
26
+ readonly ready = this.#ready.promise;
27
+ #timer: NodeJS.Timeout | undefined;
28
+ #stageIndex = 0;
29
+ #settled = false;
30
+
31
+ constructor(timeoutMs: number, failureDetail: () => string) {
32
+ this.#timeoutMs = timeoutMs;
33
+ this.#failureDetail = failureDetail;
34
+ this.#arm();
35
+ }
36
+
37
+ progress(message: KernelToHostMessage): PythonStartupStage | undefined {
38
+ if (this.#settled || message.type !== "status" || message.event.op !== "kernel-startup") return;
39
+ const stage = stages.find((candidate) => candidate === message.event.stage);
40
+ if (stage === undefined) return;
41
+ const next = stages.indexOf(stage);
42
+ if (next <= this.#stageIndex) return;
43
+ this.#stageIndex = next;
44
+ this.#arm();
45
+ return stage;
46
+ }
47
+
48
+ settle(error?: Error): boolean {
49
+ if (this.#settled) return false;
50
+ this.#settled = true;
51
+ clearTimeout(this.#timer);
52
+ if (error) {
53
+ const stage = stages[this.#stageIndex] ?? "interpreter-launch";
54
+ this.#ready.reject(
55
+ error instanceof PythonKernelStartupError
56
+ ? error
57
+ : new PythonKernelStartupError(stage, error.message, error),
58
+ );
59
+ } else this.#ready.resolve();
60
+ return true;
61
+ }
62
+
63
+ #arm(): void {
64
+ clearTimeout(this.#timer);
65
+ this.#timer = setTimeout(() => {
66
+ const detail = this.#failureDetail().trim();
67
+ this.settle(
68
+ new PythonKernelStartupError(
69
+ stages[this.#stageIndex] ?? "interpreter-launch",
70
+ `no progress for ${this.#timeoutMs}ms${detail ? `; ${detail}` : ""}`,
71
+ ),
72
+ );
73
+ }, this.#timeoutMs);
74
+ }
75
+ }
@@ -23,8 +23,8 @@ import {
23
23
  splitCommand,
24
24
  sweepProcessGroup,
25
25
  waitForExit,
26
- withTimeout,
27
26
  } from "./process.ts";
27
+ import { PythonStartup, type PythonStartupStage } from "./startup.ts";
28
28
 
29
29
  export type PythonTransportResult = Extract<KernelToHostMessage, { type: "result" }>;
30
30
 
@@ -44,6 +44,7 @@ export interface PythonTransportOptions {
44
44
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
45
45
  readonly sessionEnv?: SessionEnvironment;
46
46
  readonly startupTimeoutMs: number;
47
+ readonly onStartupProgress?: (stage: PythonStartupStage) => void;
47
48
  readonly memory?: KernelMemoryThresholds;
48
49
  readonly onMessage?: (message: KernelToHostMessage) => void;
49
50
  readonly spawnProcess?: KernelSpawnProcess;
@@ -73,7 +74,7 @@ export class PythonKernelTransport {
73
74
  readonly #child: KernelChild;
74
75
  #stdoutBuffer = "";
75
76
  #stderrTail = "";
76
- #settleReady: ((error?: Error) => void) | null = null;
77
+ #startup: PythonStartup | null = null;
77
78
  #detachChildListeners: (() => void) | null = null;
78
79
  #active = true;
79
80
  #exited = false;
@@ -173,9 +174,8 @@ export class PythonKernelTransport {
173
174
  }
174
175
 
175
176
  async #initialize(): Promise<void> {
176
- const ready = new Promise<void>((resolve, reject) => {
177
- this.#settleReady = (error) => (error ? reject(error) : resolve());
178
- });
177
+ const startup = new PythonStartup(this.#options.startupTimeoutMs, () => this.#stderrTail);
178
+ this.#startup = startup;
179
179
  const onStdout = (chunk: unknown) => this.#onStdout(String(chunk));
180
180
  const onStderr = (chunk: unknown) => this.#onStderr(String(chunk));
181
181
  const onError = (error: unknown) => this.#onError(error instanceof Error ? error : new Error(String(error)));
@@ -192,7 +192,7 @@ export class PythonKernelTransport {
192
192
  this.#child.on("exit", onExit);
193
193
  const { sessionId, connection, memory } = this.#options;
194
194
  this.#write({ type: "init", sessionId, connection, ...(memory === undefined ? {} : { memory }) });
195
- await withTimeout(ready, this.#options.startupTimeoutMs, "Python kernel did not become ready");
195
+ await startup.ready;
196
196
  }
197
197
 
198
198
  #write(message: HostToKernelMessage): void {
@@ -225,6 +225,11 @@ export class PythonKernelTransport {
225
225
  }
226
226
  if (!isKernelToHostMessage(decoded.message)) return;
227
227
  const message = decoded.message;
228
+ if (message.type === "status" && message.event.op === "kernel-startup") {
229
+ const stage = this.#startup?.progress(message);
230
+ if (stage !== undefined) this.#options.onStartupProgress?.(stage);
231
+ return;
232
+ }
228
233
  if (message.type === "ready") this.#settleStartup();
229
234
  else if (message.type === "init-failed") this.#settleStartup(new Error(message.error.message));
230
235
  else if (message.type === "result") this.#options.onResult(this, message);
@@ -258,11 +263,7 @@ export class PythonKernelTransport {
258
263
  }
259
264
 
260
265
  #settleStartup(error?: Error): boolean {
261
- const settle = this.#settleReady;
262
- if (!settle) return false;
263
- this.#settleReady = null;
264
- settle(error);
265
- return true;
266
+ return this.#startup?.settle(error) ?? false;
266
267
  }
267
268
  }
268
269
 
@@ -165,10 +165,12 @@ export class SubprocessProcess {
165
165
 
166
166
  export function spawnSubprocess(spawn: SubprocessSpawn | undefined, request: SubprocessSpawnRequest): SubprocessLike {
167
167
  if (spawn) return spawn(request.command, request.args, { cwd: request.cwd, env: request.env });
168
- return nodeSpawn(request.command, [...request.args], {
168
+ const child = nodeSpawn(request.command, [...request.args], {
169
169
  cwd: request.cwd,
170
170
  detached: true,
171
171
  env: request.env,
172
172
  stdio: ["pipe", "pipe", "pipe"],
173
173
  });
174
+ globalThis.__senpiCodemodeGateObserveResource?.("processes", child, "close");
175
+ return child;
174
176
  }
@@ -29,7 +29,7 @@ Host: {{hostLine}} — cells execute here. Size \`parallel(thunks)\` pools to it
29
29
  A cell that outlives the foreground window detaches and keeps running; further cells on the same language queue behind it in submission order on the same kernel (state is shared, execution is serial) while other languages run at once, up to {{maxDetachedCells}} background cells per session; each detached cell completes as one notification. Do not re-run a detached or queued cell; read, cancel or enumerate them with \`eval({ action: "peek" | "stop", cell_id })\` / \`eval({ action: "list" })\`; list shows live and recently settled cells across languages without consuming notifications. Its own execution time is capped at {{runBudgetSeconds}}s — queue time and time inside host tool calls are not charged; the hard limit stays wall-clock from submission; raise \`timeout\` only for a declared long run — and a killed js cell that cannot settle restarts its kernel with every global lost.
30
30
 
31
31
  {{#if py}}Python runs on a live event loop: use top-level \`await\`; \`asyncio.run(…)\` raises.{{/if}}
32
- {{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot. Shell out through \`Bun.$\` or \`Bun.spawn\`, never \`Bun.spawnSync\`: a synchronous child blocks the worker and cannot be interrupted.{{#if bunSkillPath}} MUST READ the bun-1-4 skill at {{bunSkillPath}} before your first js cell — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
32
+ {{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot. Shell out through \`Bun.$\` or \`Bun.spawn\`, never \`Bun.spawnSync\`: a synchronous child blocks the worker and cannot be interrupted.{{#if bunSkillPath}} Before a cell that installs a package, spawns a server or PTY, or starts a long run, read the bun-1-4 skill at {{bunSkillPath}} — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
33
33
  {{#if rb}}Ruby: synchronous; helper options are keyword args{{#if spawns}} (e.g. \`output("id", limit: 2)\`){{/if}}; the last expression auto-displays unless it is \`nil\`, an assignment, or a definition (like IRB).{{/if}}
34
34
  {{#if jl}}Julia: synchronous; helper options are standard keyword args{{#if spawns}} (e.g. \`output("id", limit=2)\`){{/if}}; the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
35
35
  On error, fix and re-run only the failing step; a normal error keeps state, while a timeout or stop message says whether the kernel restarted.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bun-1-4
3
- description: "MUST READ before your first js eval cell: this session's eval js kernel runs Bun 1.4+ (this skill is present only when it does). Also read before any bun -e, script, server, CLI, test, bundle, or package-management work. Bun 1.4 ships builtins that replace 15+ npm deps — check here BEFORE installing sharp, puppeteer/playwright (scraping), marked, node-cron, node-pty, concurrently, serve-static, tar, json5, fast-xml-parser, string-width. Triggers: eval js, bun, Bun.serve, bun test, bun build, bun install, bun run, image resize, headless browser, markdown render, cron, PTY."
3
+ description: "Read before a js eval cell that installs a package, spawns a server or PTY, or starts a long run, and before any bun -e, script, server, CLI, test, bundle, or package-management work: this session's eval js kernel runs Bun 1.4+ (this skill is present only when it does). Bun 1.4 ships builtins that replace 15+ npm deps — check here BEFORE installing sharp, puppeteer/playwright (scraping), marked, node-cron, node-pty, concurrently, serve-static, tar, json5, fast-xml-parser, string-width. Triggers: eval js, bun, Bun.serve, bun test, bun build, bun install, bun run, image resize, headless browser, markdown render, cron, PTY."
4
4
  ---
5
5
 
6
6
  # Bun 1.4 — Use the Builtins First
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import {
2
3
  type AgentToolResult,
3
4
  type ExtensionContext,
@@ -54,11 +55,18 @@ export class CellHandler {
54
55
  readonly #state: CellState;
55
56
  readonly #runtime: CellBridgeRuntime;
56
57
  readonly #resultBuilder: CellResultBuilder;
58
+ readonly #dispatchContext: ReturnType<typeof AsyncLocalStorage.snapshot>;
57
59
 
58
60
  constructor(kernel: EvalKernel, state: CellState, runtime: CellBridgeRuntime) {
59
61
  this.#kernel = kernel;
60
62
  this.#state = state;
61
63
  this.#runtime = runtime;
64
+ // Construct in the submitting cell's host context; worker callbacks cannot supply it (#2512).
65
+ // Bind this cell's capability once; undefined clears any enclosing JS grant for non-JS cells.
66
+ this.#dispatchContext =
67
+ runtime.kernelTools === undefined
68
+ ? kernelToolsStorage.exit(() => AsyncLocalStorage.snapshot())
69
+ : kernelToolsStorage.run(runtime.kernelTools, () => AsyncLocalStorage.snapshot());
62
70
  const settings = runtime.settings.outputSink;
63
71
  this.#resultBuilder = new CellResultBuilder({
64
72
  state,
@@ -89,7 +97,8 @@ export class CellHandler {
89
97
  this.#resultBuilder.display(message);
90
98
  return;
91
99
  case "tool-call": {
92
- const pending = this.#dispatchToolCall(message);
100
+ // A retained worker carries its creation context, not this cell's RPC connection.
101
+ const pending = this.#dispatchContext(() => this.#handleToolCall(message));
93
102
  this.#state.pendingBridgeCalls.push(pending);
94
103
  await pending;
95
104
  return;
@@ -122,19 +131,6 @@ export class CellHandler {
122
131
  return this.#resultBuilder.liveResult();
123
132
  }
124
133
 
125
- /**
126
- * The worker's message loop fires outside the async context `run-eval-cell.ts` enters around the
127
- * awaited run chain, so the capability has to be entered here — around the whole dispatch, including
128
- * the reserved agent()/output() bridges where a host task tool resolves the parent's kernel tools —
129
- * for exactly the duration of each host tool call this cell makes (#1754). Without a capability
130
- * (py/rb/jl) the store stays empty and `ctx.kernelTools` remains undefined.
131
- */
132
- async #dispatchToolCall(message: Extract<KernelToHostMessage, { type: "tool-call" }>): Promise<void> {
133
- const kernelTools = this.#runtime.kernelTools;
134
- if (kernelTools === undefined) return await this.#handleToolCall(message);
135
- return await kernelToolsStorage.run(kernelTools, async () => await this.#handleToolCall(message));
136
- }
137
-
138
134
  async #handleToolCall(message: Extract<KernelToHostMessage, { type: "tool-call" }>): Promise<void> {
139
135
  const startedAt = Date.now();
140
136
  const metric = createToolCallMetric(message.toolName, startedAt);