@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.
- package/CHANGELOG.md +54 -0
- package/README.md +121 -4
- package/package.json +5 -4
- package/src/bridge/http-server.ts +1 -0
- package/src/extension/eval-status-ticker.ts +20 -5
- package/src/extension/runtime-factory.ts +13 -2
- package/src/extension/session-cwd.ts +35 -0
- package/src/extension/session-manager.ts +11 -1
- package/src/extension/stale-context.ts +17 -0
- package/src/kernels/AGENTS.md +4 -0
- package/src/kernels/js/context-manager.ts +6 -7
- package/src/kernels/js/interrupt-bounds.ts +33 -1
- package/src/kernels/js/run-queue.ts +10 -1
- package/src/kernels/js/worker-core.js +11 -1
- package/src/kernels/js/worker-cwd.js +185 -0
- package/src/kernels/js/worker-host.ts +11 -6
- package/src/kernels/js/worker-runtime.js +13 -0
- package/src/kernels/js/worker-shell-capture.d.ts +2 -0
- package/src/kernels/js/worker-shell-capture.js +16 -3
- package/src/kernels/py/kernel-contract.ts +4 -0
- package/src/kernels/py/kernel.ts +3 -2
- package/src/kernels/py/prelude.py +11 -1
- package/src/kernels/py/process.ts +3 -1
- package/src/kernels/py/startup.ts +75 -0
- package/src/kernels/py/transport.ts +12 -11
- package/src/kernels/shared/subprocess-process.ts +3 -1
- package/src/prompt/eval-prompt-template.ts +1 -1
- package/src/skill/bun-1-4/SKILL.md +1 -1
- package/src/tool/cell-handler.ts +10 -14
- package/src/tool/image.ts +29 -2
- package/src/tool/render.ts +8 -5
- package/src/tool/run-eval-cell.ts +2 -2
- package/src/tool/types.ts +13 -5
|
@@ -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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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,
|
|
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. */
|
package/src/kernels/py/kernel.ts
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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}}
|
|
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: "
|
|
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
|
package/src/tool/cell-handler.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|