@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.
- package/CHANGELOG.md +74 -0
- package/README.md +34 -5
- package/package.json +4 -4
- package/src/bridge/protocol.ts +1 -0
- package/src/bridge/reserved.ts +2 -0
- package/src/extension/runtime-factory.ts +3 -0
- package/src/extension/session-manager.ts +7 -0
- package/src/kernels/AGENTS.md +7 -0
- package/src/kernels/jl/kernel.ts +6 -2
- package/src/kernels/js/context-manager.ts +73 -129
- package/src/kernels/js/inline-worker.ts +2 -2
- package/src/kernels/js/interrupt-bounds.ts +66 -0
- package/src/kernels/js/kernel-contract.ts +3 -0
- package/src/kernels/js/run-queue.ts +18 -3
- package/src/kernels/js/worker-core.js +59 -0
- package/src/kernels/js/worker-indirect-eval.js +10 -6
- package/src/kernels/js/worker-runtime.js +16 -0
- package/src/kernels/js/worker-shell-capture.d.ts +25 -0
- package/src/kernels/js/worker-shell-capture.js +70 -8
- package/src/kernels/js/worker-slot.ts +106 -0
- package/src/kernels/js/worker-startup.ts +70 -0
- package/src/kernels/py/kernel-contract.ts +3 -0
- package/src/kernels/py/transport.ts +11 -3
- package/src/kernels/rb/kernel.ts +6 -2
- package/src/kernels/session-env.ts +56 -0
- package/src/kernels/shared/runtime-asset.ts +40 -14
- package/src/kernels/shared/subprocess-contract.ts +3 -0
- package/src/kernels/shared/subprocess-kernel.ts +9 -1
- package/src/output/output-meta.ts +26 -2
- package/src/prompt/eval-prompt.ts +10 -5
- package/src/tool/cell-execution.ts +9 -11
- package/src/tool/detached-cell-contract.ts +45 -0
- package/src/tool/detached-cell-manager.ts +43 -71
- package/src/tool/detached-cell-notification.ts +4 -5
- package/src/tool/detached-cell-snapshot.ts +2 -0
- package/src/tool/detached-cell-status.ts +30 -0
- package/src/tool/detached-eval-result.ts +1 -0
- package/src/tool/detached-notification-queue.ts +2 -2
- package/src/tool/image.ts +12 -3
- package/src/tool/interrupt-note.ts +29 -15
- 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
|
-
|
|
28
|
-
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
-
|
|
767
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
return
|
|
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;
|