@code-yeongyu/senpi-codemode 2026.10.1-3 → 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 +22 -0
- package/README.md +110 -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/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/process.ts +3 -1
- package/src/kernels/shared/subprocess-process.ts +3 -1
- package/src/tool/image.ts +29 -2
- package/src/tool/types.ts +13 -5
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,28 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.10.2] - 2026-10-02
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- An eval regression gate records the full prompt and schema surfaces, helper witnesses across five required runtime legs, codemode-scoped eager imports, measured teardown resources (including global, named-import, promise and AbortSignal timers, named by creation site) and legacy contract results against a frozen baseline. CI provisions every interpreter and publishes the report; unrelated host imports and slow child startup cannot cause a regression failure. ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Fixed Mistral-hosted GLM 5.3 omitting required `eval` run fields when tool use is forced. ([#2444](https://github.com/code-yeongyu/senpi/pull/2444) by [@urbanbreach](https://github.com/urbanbreach))
|
|
28
|
+
- Anthropic-compatible gateways that reject an `enum` inside a root `anyOf` branch (HTTP 400, code 11133) accept the `eval` schema again. ([#2569](https://github.com/code-yeongyu/senpi/issues/2569), reported and verified by [@DevNewbie1826](https://github.com/DevNewbie1826))
|
|
29
|
+
- Reloading or replacing a session while a detached eval cell is running no longer kills the process from the footer's elapsed-time ticker. The ticker stops when its session's context is retired and starts again with the next session's cells; any other footer error still surfaces ([#2549](https://github.com/code-yeongyu/senpi/issues/2549) by [@rhyme227](https://github.com/rhyme227)).
|
|
30
|
+
|
|
31
|
+
- JavaScript eval cells run in the session's project directory: a relative path in `Bun.file`, `Bun.write`, `node:fs`, `path.resolve`, `Bun.$`, spawned children, or `Bun.Glob` now resolves inside the project instead of the host process directory, which made `Bun.file("src/todo.ts")` fail with ENOENT in desktop threads and under `--cwd`. A missing or deleted session directory fails the cell with `CodemodeSessionCwdUnavailableError` instead of falling back to another directory. The bash tool's working directory is unchanged ([omo#9371](https://github.com/code-yeongyu/oh-my-openagent/issues/9371)).
|
|
32
|
+
|
|
33
|
+
- Stop now explicitly reports when a running `Bun.$` wait forces the JavaScript kernel to restart and clears its variables, and recommends `Bun.spawn` or the bash tool for stoppable commands. Native shell semantics remain unchanged. Thanks to [@floweredao](https://github.com/floweredao) for the investigation ([#2475](https://github.com/code-yeongyu/senpi/pull/2475)); native cancellation remains tracked in [#2453](https://github.com/code-yeongyu/senpi/issues/2453).
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
|
|
15
37
|
## [2026.10.1-3] - 2026-10-01
|
|
16
38
|
|
|
17
39
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -81,6 +81,20 @@ what a child spawned from the bash tool sees. The values snapshot at kernel star
|
|
|
81
81
|
mid-session model switch updates the bash tool's next command but not already-running
|
|
82
82
|
kernels; a new session starts fresh kernels with fresh values.
|
|
83
83
|
|
|
84
|
+
### Working directory
|
|
85
|
+
|
|
86
|
+
Every kernel runs in the session's working directory, the same directory the read,
|
|
87
|
+
edit, and bash tools use. Python, Ruby, and Julia start their interpreter there. The
|
|
88
|
+
JavaScript kernel is a worker thread, which cannot change directory, so it applies the
|
|
89
|
+
session directory itself: `process.cwd()`, `path.resolve`, `node:fs` and
|
|
90
|
+
`node:fs/promises`, `node:child_process`, `Bun.file`, `Bun.write`, `Bun.$`,
|
|
91
|
+
`Bun.spawn`/`Bun.spawnSync`, `Bun.Glob` scans, and relative `import()` all resolve a
|
|
92
|
+
relative path inside the session directory, never the host process directory. The host
|
|
93
|
+
process and the bash tool keep their own directory. When the session directory is
|
|
94
|
+
missing or deleted, the next cell fails with `CodemodeSessionCwdUnavailableError` naming
|
|
95
|
+
the directory instead of running somewhere else. A new session (including a switch to
|
|
96
|
+
another project) starts fresh kernels in its own directory.
|
|
97
|
+
|
|
84
98
|
`PI_GOAL_STORE_FILE` is supplied by the host's optional `ExtensionContext.goalStoreFile`
|
|
85
99
|
getter and may name a file that does not exist yet. It honors session-directory overrides
|
|
86
100
|
and in-memory sessions; it cannot be derived reliably from `PI_SESSION_FILE`. If the host
|
|
@@ -246,10 +260,10 @@ user writes in: a progress update saying what the agent is doing and why, not
|
|
|
246
260
|
a label for the code. The
|
|
247
261
|
summary is shown in the TUI while the cell runs and in the finished result, so
|
|
248
262
|
you can always tell what is running and why. It has no length limit; a
|
|
249
|
-
collapsed block shows its first three lines. The schema
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
263
|
+
collapsed block shows its first three lines. The schema requires all three
|
|
264
|
+
for runs, including when `action` is omitted. Control actions (`peek`, `stop`,
|
|
265
|
+
`list`) do not require run fields. Each action branch declares its own fields
|
|
266
|
+
so providers can interpret it independently.
|
|
253
267
|
|
|
254
268
|
## Detached cells
|
|
255
269
|
|
|
@@ -319,6 +333,12 @@ restarted, or outcome unknown - never a per-language assumption; oversized
|
|
|
319
333
|
buffered output is written under the session local root and referenced as
|
|
320
334
|
`local://…`.
|
|
321
335
|
|
|
336
|
+
When Stop restarts a kernel waiting on `Bun.$`, the result explicitly says that
|
|
337
|
+
the kernel restarted and its variables were cleared. Use `Bun.spawn` or the
|
|
338
|
+
bash tool for long-running commands you may want to stop. Native `Bun.$`
|
|
339
|
+
cancellation is tracked in [Bun #11868](https://github.com/oven-sh/bun/issues/11868);
|
|
340
|
+
the shell's interpretation and object redirects are not replaced.
|
|
341
|
+
|
|
322
342
|
Commands a cell runs through `Bun.$` never read the host's terminal: the worker
|
|
323
343
|
thread shares the TUI's stdin, so the shell wrapper hands every template an
|
|
324
344
|
empty pipe (`true | ( … )`) while a cell is active. Output, exit codes, `cwd`,
|
|
@@ -360,6 +380,92 @@ namespace to prevent recursive execution.
|
|
|
360
380
|
|
|
361
381
|
## Validation
|
|
362
382
|
|
|
383
|
+
The regression gate compares full prompt content (240 dialect/capability/runtime/host
|
|
384
|
+
combinations), schemas, helper census, live helper witnesses and eager imports,
|
|
385
|
+
and runs the legacy contracts. It requires Bun, Node, Python, Ruby and Julia; a missing
|
|
386
|
+
interpreter fails rather than skipping a runtime. It does not gate wall-clock
|
|
387
|
+
timing or absolute memory footprints; those belong to the paired benchmark.
|
|
388
|
+
Ruby 3.4 and later also require the `base64` gem (`gem install base64 --version 0.3.0 --no-document`).
|
|
389
|
+
|
|
390
|
+
The eager-import probe starts without third-party validation modules loaded.
|
|
391
|
+
It validates observer records only after measurement, so a target sharing the
|
|
392
|
+
harness dependency tree has the same cold census as a separate checkout.
|
|
393
|
+
Module IDs are package-relative. The exact census covers codemode, its non-virtual
|
|
394
|
+
dependency closure and Node builtins. The observer records parent edges and reads
|
|
395
|
+
both loader `VIRTUAL_MODULES` tables: their backing packages and dependencies
|
|
396
|
+
reachable only across virtual edges belong to the host and are excluded from
|
|
397
|
+
set equality. Every observed edge and classification remains in the report.
|
|
398
|
+
Workspace imports resolve through built `dist` entries. The gate build records
|
|
399
|
+
the source file set, inherited build configs and content hashes after a successful
|
|
400
|
+
build; preflight rejects missing, changed or deleted inputs. Unchanged content
|
|
401
|
+
remains valid after timestamp refreshes. Each ignored `.senpi-gate-inputs.json`
|
|
402
|
+
certificate sits beside its workspace manifest, outside the published `dist` tree.
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
bun packages/senpi-codemode/scripts/gate-build.ts
|
|
406
|
+
bun run --cwd packages/senpi-codemode gate --baseline test/gate/baseline.json
|
|
407
|
+
bun run --cwd packages/senpi-codemode test -- test/gate
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
The package test script already selects `test/`, so the last command intentionally
|
|
411
|
+
runs the full package suite. For a focused gate-only run, invoke Vitest directly:
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
bun run --cwd packages/senpi-codemode vitest run test/gate
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Install and build both the head checkout and a clean checkout of the PR merge
|
|
418
|
+
base with `bun install --ignore-scripts --frozen-lockfile`. Build each target with
|
|
419
|
+
the head harness, `bun packages/senpi-codemode/scripts/gate-build.ts <checkout>`.
|
|
420
|
+
Record the baseline with the **head harness** against that freshly built base
|
|
421
|
+
checkout, not by running an older harness:
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
bun run --cwd packages/senpi-codemode gate --target <base-checkout> \
|
|
425
|
+
--baseline test/gate/baseline.json --write-baseline
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
The report is gitignored `gate-report.json` by default (`--report <path>` overrides it).
|
|
429
|
+
`test/gate/allowlist.json` contains reviewed additive changes keyed by plan node;
|
|
430
|
+
it cannot authorize removal or modification of a legacy entry. The test-only
|
|
431
|
+
`SENPI_CODEMODE_GATE_MUTATE=drop-phase` report mutation proves that helper removal
|
|
432
|
+
is rejected. `SENPI_CODEMODE_GATE_MUTATE=leak-kernel` leaves the real kernel
|
|
433
|
+
alive at the teardown witness, then closes it in `finally`; nonzero process,
|
|
434
|
+
worker, socket, handle, subscription and active-resource listener counts fail
|
|
435
|
+
by name. Constructors are observed because Bun's active-handle/report APIs
|
|
436
|
+
return empty arrays even for live workers.
|
|
437
|
+
Live timers are observed independently of the import census, including unref'd
|
|
438
|
+
global timers, named and namespace imports from `node:timers`,
|
|
439
|
+
`node:timers/promises` (including interval iterators and the scheduler), and
|
|
440
|
+
self-rearming `AbortSignal.timeout` polls. Failures name the timer API and its
|
|
441
|
+
creation site. The gate installs delegating wrappers before the kernel graph
|
|
442
|
+
loads; on Bun its module-replacement API also updates builtin ESM bindings.
|
|
443
|
+
These wrappers run only in gate processes and keep the native timer behavior.
|
|
444
|
+
The only production-source additions are an inert gate observer at the existing
|
|
445
|
+
worker, interpreter-process and bridge-server constructors. It is undefined in
|
|
446
|
+
normal execution. This hook is necessary because Bun does not refresh named
|
|
447
|
+
builtin exports when `syncBuiltinESMExports()` runs; patching their default
|
|
448
|
+
exports alone otherwise reports zero resources even for a leaked real kernel.
|
|
449
|
+
|
|
450
|
+
| Inert hook site | Measured resource | Verification |
|
|
451
|
+
| --- | --- | --- |
|
|
452
|
+
| `src/kernels/js/worker-host.ts` | Worker exit | Real Bun kernel close/leak tests |
|
|
453
|
+
| `src/kernels/py/process.ts` | Python child close | Five-runtime gate and kernel-leak mutation |
|
|
454
|
+
| `src/kernels/shared/subprocess-process.ts` | Ruby/Julia child close | Five-runtime gate and kernel-leak mutation |
|
|
455
|
+
| `src/bridge/http-server.ts` | Server close and accepted sockets | Real Bun bridge close/leak tests |
|
|
456
|
+
|
|
457
|
+
The gate-only `leak-bridge` mutation leaves the real bridge server open for the
|
|
458
|
+
witness and closes it in `finally`; the real-runtime test must observe an open
|
|
459
|
+
handle. Neither mutation is read by production code.
|
|
460
|
+
|
|
461
|
+
Legacy scenario identities are compared exactly, so deleting or renaming a
|
|
462
|
+
test cannot make the gate green. Platform-dependent skip outcomes remain
|
|
463
|
+
visible in the report. Driver tests await child close events, with a generous
|
|
464
|
+
hang watchdog; the infinite-loop timeout uses an injected clock after the
|
|
465
|
+
worker announces execution. No deadline tests are excluded from the gate.
|
|
466
|
+
The held-child probe advances the parent clock past all former startup
|
|
467
|
+
deadlines three times before releasing the child's IPC barrier.
|
|
468
|
+
|
|
363
469
|
```bash
|
|
364
470
|
cd packages/senpi-codemode
|
|
365
471
|
bun run test
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.2",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"LICENSE"
|
|
17
17
|
],
|
|
18
18
|
"scripts": {
|
|
19
|
+
"gate": "bun scripts/gate-eval.ts",
|
|
19
20
|
"test": "vitest run test/",
|
|
20
21
|
"clean": "node -e \"\"",
|
|
21
22
|
"build": "node -e \"\""
|
|
@@ -30,14 +31,14 @@
|
|
|
30
31
|
},
|
|
31
32
|
"dependencies": {
|
|
32
33
|
"@babel/parser": "8.0.4",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.
|
|
34
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.2",
|
|
34
35
|
"typebox": "1.3.34"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
38
|
+
"@code-yeongyu/senpi": "2026.10.2"
|
|
38
39
|
},
|
|
39
40
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
41
|
+
"@code-yeongyu/senpi": "2026.10.2"
|
|
41
42
|
},
|
|
42
43
|
"keywords": [
|
|
43
44
|
"senpi",
|
|
@@ -49,6 +49,7 @@ export async function startBridgeServer(options: BridgeServerOptions): Promise<B
|
|
|
49
49
|
const server = createServer((request, response) => {
|
|
50
50
|
void handleRequest(request, response, token, options);
|
|
51
51
|
});
|
|
52
|
+
globalThis.__senpiCodemodeGateObserveResource?.("handles", server, "close");
|
|
52
53
|
server.on("connection", (socket) => {
|
|
53
54
|
sockets.add(socket);
|
|
54
55
|
socket.on("close", () => sockets.delete(socket));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EvalDetachedCellStatusEntry } from "../tool/detached-cell-manager.ts";
|
|
2
2
|
import { formatEvalCellStatus } from "./eval-status.ts";
|
|
3
|
+
import { isStaleExtensionContextError } from "./stale-context.ts";
|
|
3
4
|
|
|
4
5
|
/** Footer live-elapsed refresh cadence while at least one detached cell is running. */
|
|
5
6
|
export const EVAL_STATUS_TICK_INTERVAL_MS = 1000;
|
|
@@ -17,7 +18,8 @@ export interface EvalStatusTickerOptions {
|
|
|
17
18
|
* Drives a once-per-second footer refresh while detached eval cells are running so
|
|
18
19
|
* the "↗ py · … (Ns)" elapsed label advances live instead of freezing between
|
|
19
20
|
* cell-set transitions. Same shape as the terminal builtin's MonitorStatusTicker:
|
|
20
|
-
* the interval is unref'd,
|
|
21
|
+
* the interval is unref'd, ticks producing the already-rendered label are skipped, and a
|
|
22
|
+
* render that hits a retired extension context retires the ticker until the next sync.
|
|
21
23
|
*/
|
|
22
24
|
export class EvalStatusTicker {
|
|
23
25
|
private readonly render: EvalStatusRender;
|
|
@@ -43,7 +45,7 @@ export class EvalStatusTicker {
|
|
|
43
45
|
sync(entries: readonly EvalDetachedCellStatusEntry[]): void {
|
|
44
46
|
this.entries = entries;
|
|
45
47
|
this.hasRendered = false;
|
|
46
|
-
this.tick();
|
|
48
|
+
if (!this.tick()) return;
|
|
47
49
|
if (entries.length === 0) {
|
|
48
50
|
this.stopInterval();
|
|
49
51
|
return;
|
|
@@ -69,11 +71,24 @@ export class EvalStatusTicker {
|
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
/** Returns false when the render hit a retired context and the ticker retired. */
|
|
75
|
+
private tick(): boolean {
|
|
73
76
|
const status = formatEvalCellStatus(this.entries, this.now());
|
|
74
|
-
if (this.hasRendered && status === this.lastRenderedStatus) return;
|
|
77
|
+
if (this.hasRendered && status === this.lastRenderedStatus) return true;
|
|
75
78
|
this.hasRendered = true;
|
|
76
79
|
this.lastRenderedStatus = status;
|
|
77
|
-
|
|
80
|
+
try {
|
|
81
|
+
this.render(status);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
// A context retired by session replacement or reload throws from inside the
|
|
84
|
+
// interval callback, where nothing catches it and the process exits. Retire
|
|
85
|
+
// instead; the next sync() (with a live context) re-arms the ticker.
|
|
86
|
+
if (isStaleExtensionContextError(error)) {
|
|
87
|
+
this.stop();
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
78
93
|
}
|
|
79
94
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
// Only these codes mean the directory is gone or was never one; anything else (EACCES, ELOOP,
|
|
4
|
+
// EMFILE, EIO, ...) is a real failure that reopening the session would not fix, so it surfaces as is.
|
|
5
|
+
const MISSING_DIRECTORY_CODES: ReadonlySet<string> = new Set(["ENOENT", "ENOTDIR"]);
|
|
6
|
+
|
|
7
|
+
export class CodemodeSessionCwdUnavailableError extends Error {
|
|
8
|
+
readonly name = "CodemodeSessionCwdUnavailableError";
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
|
|
11
|
+
constructor(cwd: string, reason: string) {
|
|
12
|
+
super(
|
|
13
|
+
`The session working directory ${cwd} is unavailable (${reason}). Eval cells run in the session's project directory; reopen the session on an existing directory.`,
|
|
14
|
+
);
|
|
15
|
+
this.cwd = cwd;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function assertSessionCwdAvailable(cwd: string): Promise<void> {
|
|
20
|
+
let isDirectory: boolean;
|
|
21
|
+
try {
|
|
22
|
+
isDirectory = (await stat(cwd)).isDirectory();
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if (
|
|
25
|
+
error instanceof Error &&
|
|
26
|
+
"code" in error &&
|
|
27
|
+
typeof error.code === "string" &&
|
|
28
|
+
MISSING_DIRECTORY_CODES.has(error.code)
|
|
29
|
+
) {
|
|
30
|
+
throw new CodemodeSessionCwdUnavailableError(cwd, error.code);
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
if (!isDirectory) throw new CodemodeSessionCwdUnavailableError(cwd, "not a directory");
|
|
35
|
+
}
|
|
@@ -16,6 +16,7 @@ import { RubyKernel } from "../kernels/rb/kernel.ts";
|
|
|
16
16
|
import type { SessionEnvironment } from "../kernels/session-env.ts";
|
|
17
17
|
import { marshalToolResult } from "../tool/image.ts";
|
|
18
18
|
import type { EvalKernel, EvalKernelManager, EvalLanguage, ExecuteTool } from "../tool/types.ts";
|
|
19
|
+
import { assertSessionCwdAvailable } from "./session-cwd.ts";
|
|
19
20
|
|
|
20
21
|
export interface CodemodeSessionManager extends EvalKernelManager {
|
|
21
22
|
dispose(): Promise<void>;
|
|
@@ -130,7 +131,10 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
130
131
|
// to the current cell, not the one that first created the kernel.
|
|
131
132
|
this.#onMessageRefs.set(language, onMessage);
|
|
132
133
|
const existing = this.#kernels.get(language);
|
|
133
|
-
if (existing)
|
|
134
|
+
if (existing) {
|
|
135
|
+
await assertSessionCwdAvailable(this.#options.cwd);
|
|
136
|
+
return existing;
|
|
137
|
+
}
|
|
134
138
|
const pending = this.#kernelCreations.get(language);
|
|
135
139
|
if (pending) return await pending;
|
|
136
140
|
// A bound method, never a closure in this frame: the dispatcher outlives every
|
|
@@ -204,12 +208,18 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
204
208
|
onMessage: (message: KernelToHostMessage) => void,
|
|
205
209
|
generation: number,
|
|
206
210
|
): Promise<EvalKernel> {
|
|
211
|
+
await assertSessionCwdAvailable(this.#options.cwd);
|
|
207
212
|
const kernel = await this.#createKernel(language, onMessage);
|
|
208
213
|
if (generation !== this.#generation) {
|
|
209
214
|
await kernel.close();
|
|
210
215
|
throw new CodemodeSessionDisposedError();
|
|
211
216
|
}
|
|
212
217
|
this.#kernels.set(language, kernel);
|
|
218
|
+
// The directory can vanish while the interpreter starts; every caller sharing this creation
|
|
219
|
+
// must see that, not only the next one. The kernel stays stored and dispose still closes it.
|
|
220
|
+
await assertSessionCwdAvailable(this.#options.cwd);
|
|
221
|
+
// A dispose that started during the check above already owns this stored kernel.
|
|
222
|
+
if (generation !== this.#generation) throw new CodemodeSessionDisposedError();
|
|
213
223
|
return kernel;
|
|
214
224
|
}
|
|
215
225
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection for the error a retired host extension context throws once its session
|
|
3
|
+
* was replaced (new/fork/switch) or reloaded. Mirrors the host's
|
|
4
|
+
* `builtin/goal/stale-context.ts`, which stays internal to the host's builtins (the
|
|
5
|
+
* `@code-yeongyu/senpi` package does not export it). Older hosts also predate the reload
|
|
6
|
+
* message, so codemode carries both messages locally for compatibility.
|
|
7
|
+
*/
|
|
8
|
+
const STALE_EXTENSION_CONTEXT_ERROR_PREFIX = "This extension ctx is stale after session replacement or reload.";
|
|
9
|
+
const STALE_EXTENSION_GENERATION_AFTER_RELOAD_MESSAGE = "stale extension generation after reload";
|
|
10
|
+
|
|
11
|
+
export function isStaleExtensionContextError(error: unknown): error is Error {
|
|
12
|
+
return (
|
|
13
|
+
error instanceof Error &&
|
|
14
|
+
(error.message.startsWith(STALE_EXTENSION_CONTEXT_ERROR_PREFIX) ||
|
|
15
|
+
error.message === STALE_EXTENSION_GENERATION_AFTER_RELOAD_MESSAGE)
|
|
16
|
+
);
|
|
17
|
+
}
|
package/src/kernels/AGENTS.md
CHANGED
|
@@ -36,6 +36,10 @@ runner/prelude assets).
|
|
|
36
36
|
sees. The JS worker applies it at init (`worker-core.js`; shell capture pins
|
|
37
37
|
the env view under Bun because `delete process.env.X` does not unsetenv),
|
|
38
38
|
and py/rb/jl spawn with it merged into the interpreter environment.
|
|
39
|
+
- Every kernel runs in the session cwd. py/rb/jl spawn there; the JS worker cannot
|
|
40
|
+
chdir, so `js/worker-cwd.js` applies the cwd to `process.cwd`, `path.resolve`,
|
|
41
|
+
`fs`, `child_process`, and `Bun.file`/`write`/`$`/`spawn`/`Glob` at init. The
|
|
42
|
+
session manager rejects a cell whose cwd is missing (`extension/session-cwd.ts`).
|
|
39
43
|
|
|
40
44
|
## ANTI-PATTERNS
|
|
41
45
|
|
|
@@ -3,10 +3,11 @@ import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP, MEMORY_COLLECTED_OP } from "../..
|
|
|
3
3
|
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
4
4
|
import { KernelMemoryPolicy } from "../shared/kernel-memory.ts";
|
|
5
5
|
import {
|
|
6
|
-
abandonedWorkerNote,
|
|
7
6
|
awaitCooperativeSettlement,
|
|
8
7
|
DEFAULT_INTERRUPT_BOUNDS,
|
|
9
8
|
type JavaScriptInterruptBounds,
|
|
9
|
+
restartedResult,
|
|
10
|
+
restartOutcome,
|
|
10
11
|
type WorkerRetirement,
|
|
11
12
|
} from "./interrupt-bounds.ts";
|
|
12
13
|
import {
|
|
@@ -27,7 +28,7 @@ import type {
|
|
|
27
28
|
} from "./kernel-tools-types.ts";
|
|
28
29
|
import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-loader.ts";
|
|
29
30
|
import { terminateProcessTrees } from "./process-tree-host.ts";
|
|
30
|
-
import { JavaScriptRunQueue, type PendingJavaScriptRun
|
|
31
|
+
import { JavaScriptRunQueue, type PendingJavaScriptRun } from "./run-queue.ts";
|
|
31
32
|
import { bridgeError, WorkerStartupCancelledError } from "./worker-host.ts";
|
|
32
33
|
import { WorkerSlot } from "./worker-slot.ts";
|
|
33
34
|
|
|
@@ -242,11 +243,9 @@ export class JavaScriptKernel {
|
|
|
242
243
|
}
|
|
243
244
|
if (!this.#runs.releaseActive(run)) return { retained: run.settledByWorker };
|
|
244
245
|
const retirement = await this.#terminate();
|
|
245
|
-
this.#runs.settle(run,
|
|
246
|
+
this.#runs.settle(run, restartedResult(run, message));
|
|
246
247
|
void this.#recover(() => Promise.resolve());
|
|
247
|
-
return retirement
|
|
248
|
-
? { retained: false, note: abandonedWorkerNote(this.#interruptBounds.terminateDeadlineMs) }
|
|
249
|
-
: { retained: false };
|
|
248
|
+
return restartOutcome(run, retirement, this.#interruptBounds);
|
|
250
249
|
} finally {
|
|
251
250
|
this.#clearToolCalls();
|
|
252
251
|
}
|
|
@@ -283,7 +282,7 @@ export class JavaScriptKernel {
|
|
|
283
282
|
#handleMessage(message: KernelToHostMessage): void {
|
|
284
283
|
if (this.#kernelTools.consume(message) && message.type !== "tool-call") return;
|
|
285
284
|
if (message.type === "status" && message.event.op === INTERRUPT_ACK_OP) {
|
|
286
|
-
this.#runs.
|
|
285
|
+
this.#runs.acknowledgeInterrupt(message.event);
|
|
287
286
|
return;
|
|
288
287
|
}
|
|
289
288
|
if (message.type === "status" && message.event.op === CHILD_LIFECYCLE_OP) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
1
2
|
import type { WorkerLike } from "./inline-worker.ts";
|
|
2
|
-
import type
|
|
3
|
+
import { type PendingJavaScriptRun, stoppedResult } from "./run-queue.ts";
|
|
3
4
|
|
|
4
5
|
/** How long the worker gets to acknowledge `interrupt`; silence means its event loop is blocked in synchronous code. */
|
|
5
6
|
export const INTERRUPT_ACK_MS = 500;
|
|
@@ -53,6 +54,37 @@ export function abandonedWorkerNote(deadlineMs = WORKER_TERMINATE_DEADLINE_MS):
|
|
|
53
54
|
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`;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
const SHELL_RESTART_NOTICE =
|
|
58
|
+
"JavaScript kernel restarted while waiting on Bun.$; its variables were cleared.\nUse Bun.spawn or the bash tool for commands you may want to stop.";
|
|
59
|
+
|
|
60
|
+
export function restartedResult(
|
|
61
|
+
run: PendingJavaScriptRun,
|
|
62
|
+
message: string,
|
|
63
|
+
): Extract<KernelToHostMessage, { type: "result" }> {
|
|
64
|
+
const result = run.interruptResult ?? stoppedResult(run.input.cellId, message);
|
|
65
|
+
if (!run.shellWaitActive || result.ok) return result;
|
|
66
|
+
return {
|
|
67
|
+
...result,
|
|
68
|
+
error: {
|
|
69
|
+
...result.error,
|
|
70
|
+
code: "js_shell_interrupt_restart",
|
|
71
|
+
message: `${result.error.message}\n${SHELL_RESTART_NOTICE}`,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function restartOutcome(
|
|
77
|
+
run: PendingJavaScriptRun,
|
|
78
|
+
retirement: WorkerRetirement,
|
|
79
|
+
bounds: JavaScriptInterruptBounds,
|
|
80
|
+
): { readonly retained: false; readonly note?: string } {
|
|
81
|
+
const notes = [
|
|
82
|
+
...(run.shellWaitActive ? [SHELL_RESTART_NOTICE] : []),
|
|
83
|
+
...(retirement === "abandoned" ? [abandonedWorkerNote(bounds.terminateDeadlineMs)] : []),
|
|
84
|
+
];
|
|
85
|
+
return { retained: false, ...(notes.length === 0 ? {} : { note: notes.join("\n") }) };
|
|
86
|
+
}
|
|
87
|
+
|
|
56
88
|
async function raceDeadline<T extends string, Fallback extends string>(
|
|
57
89
|
operation: Promise<T>,
|
|
58
90
|
deadlineMs: number,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
1
|
+
import type { EvalStatusEvent, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
2
|
import type { JavaScriptRunInput } from "./kernel-contract.ts";
|
|
3
3
|
|
|
4
4
|
type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
|
|
@@ -14,6 +14,7 @@ export interface PendingJavaScriptRun {
|
|
|
14
14
|
interruptResult: ResultMessage | null;
|
|
15
15
|
interruptAck: PromiseWithResolvers<void> | null;
|
|
16
16
|
settledByWorker: boolean;
|
|
17
|
+
shellWaitActive: boolean;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export class JavaScriptRunQueue {
|
|
@@ -40,6 +41,7 @@ export class JavaScriptRunQueue {
|
|
|
40
41
|
interruptResult: null,
|
|
41
42
|
interruptAck: null,
|
|
42
43
|
settledByWorker: false,
|
|
44
|
+
shellWaitActive: false,
|
|
43
45
|
});
|
|
44
46
|
return promise;
|
|
45
47
|
}
|
|
@@ -53,6 +55,13 @@ export class JavaScriptRunQueue {
|
|
|
53
55
|
return next;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
acknowledgeInterrupt(event: EvalStatusEvent): void {
|
|
59
|
+
const active = this.#active;
|
|
60
|
+
if (!active || event.cellId !== active.input.cellId) return;
|
|
61
|
+
active.shellWaitActive = event.shellWaitActive === true;
|
|
62
|
+
active.interruptAck?.resolve();
|
|
63
|
+
}
|
|
64
|
+
|
|
56
65
|
remove(cellId: string, reason = "interrupted"): boolean {
|
|
57
66
|
const index = this.#queue.findIndex((run) => run.input.cellId === cellId);
|
|
58
67
|
if (index < 0) return false;
|
|
@@ -2,6 +2,7 @@ import { kernelToolCallContext } from "./kernel-tools-context.js";
|
|
|
2
2
|
import { kernelToolError } from "./kernel-tools-errors.js";
|
|
3
3
|
import { createKernelToolPump } from "./kernel-tools-pump.js";
|
|
4
4
|
import { hostDeniedError, hostToolRefusal } from "./kernel-tools-scope.js";
|
|
5
|
+
import { installSessionCwd } from "./worker-cwd.js";
|
|
5
6
|
import { createWorkerMemory } from "./worker-memory.js";
|
|
6
7
|
import { JsWorkerRuntime } from "./worker-runtime.js";
|
|
7
8
|
import { installKernelWebView } from "./worker-webview.js";
|
|
@@ -91,7 +92,7 @@ export function createWorkerCore(transport, options) {
|
|
|
91
92
|
|
|
92
93
|
function interruptCell(reason) {
|
|
93
94
|
if (!activeCell || !runtime) return;
|
|
94
|
-
|
|
95
|
+
acknowledgeInterrupt();
|
|
95
96
|
const interruption = cellInterruptedError(reason);
|
|
96
97
|
activeCell.interruption = interruption;
|
|
97
98
|
for (const [callId, pending] of pendingTools) {
|
|
@@ -102,6 +103,11 @@ export function createWorkerCore(transport, options) {
|
|
|
102
103
|
runtime.interrupt();
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
function acknowledgeInterrupt() {
|
|
107
|
+
if (!activeCell || !runtime) return;
|
|
108
|
+
emit({ type: "status", event: { op: INTERRUPT_ACK_OP, cellId: activeCell.cellId, shellWaitActive: runtime.shellWaitActive } });
|
|
109
|
+
}
|
|
110
|
+
|
|
105
111
|
function onMessage(message) {
|
|
106
112
|
if (message.type === "run" || message.type === "interrupt" || message.type === "close") memory?.cancelIdle();
|
|
107
113
|
if (kernelTools.handle(message)) return;
|
|
@@ -118,6 +124,7 @@ export function createWorkerCore(transport, options) {
|
|
|
118
124
|
}
|
|
119
125
|
if (message.type === "init") {
|
|
120
126
|
applySessionEnvironment(message.sessionEnv);
|
|
127
|
+
installSessionCwd(options.cwd);
|
|
121
128
|
installKernelWebView(requestWebViewPort);
|
|
122
129
|
runtime = new JsWorkerRuntime({
|
|
123
130
|
cwd: options.cwd,
|
|
@@ -128,6 +135,9 @@ export function createWorkerCore(transport, options) {
|
|
|
128
135
|
hostToolNames: message.hostToolNames ?? [],
|
|
129
136
|
foreignLanguageNames: message.foreignLanguageNames ?? [],
|
|
130
137
|
onChildEvent: (event) => emit({ type: "status", event: { op: CHILD_LIFECYCLE_OP, ...event } }),
|
|
138
|
+
onShellWaitChange: () => {
|
|
139
|
+
if (activeCell?.interruption) acknowledgeInterrupt();
|
|
140
|
+
},
|
|
131
141
|
});
|
|
132
142
|
if (message.memory) {
|
|
133
143
|
memory = createWorkerMemory(message.memory, (report) => emit({ type: "status", event: { op: MEMORY_COLLECTED_OP, ...report } }));
|
|
@@ -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;
|
|
@@ -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[] } {
|
|
@@ -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
|
}
|
package/src/tool/image.ts
CHANGED
|
@@ -30,6 +30,28 @@ const MAX_DISPLAY_IMAGES_PER_CELL = 8;
|
|
|
30
30
|
const MAX_DISPLAY_IMAGE_BYTES_PER_CELL = 24 * 1024 * 1024;
|
|
31
31
|
const MAX_JSON_OUTPUTS_PER_CELL = 64;
|
|
32
32
|
|
|
33
|
+
// Base64 of the signatures of the formats providers accept inline (PNG, JPEG except JPEG-LS, GIF,
|
|
34
|
+
// "RIFF....WEBP"). Signatures start at byte 0, so their encodings are prefixes.
|
|
35
|
+
const IMAGE_SIGNATURES: ReadonlyArray<readonly [string, RegExp]> = [
|
|
36
|
+
["image/png", /^iVBORw0KGg/],
|
|
37
|
+
["image/jpeg", /^[/]9j[/](?!9)/],
|
|
38
|
+
["image/gif", /^R0lGOD[dl]h/],
|
|
39
|
+
["image/webp", /^UklG.{8}RUJQ/],
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// Providers reject the whole request on a bad image, and a kept image block is resent on every later turn,
|
|
43
|
+
// so invalid data is dropped with a reason instead. The detected type wins over the declared one.
|
|
44
|
+
function validateDisplayImage(dataBase64: string): { data: string; mimeType: string } | { reason: string } {
|
|
45
|
+
const data = dataBase64.replace(/\s+/g, "");
|
|
46
|
+
if (data.length === 0 || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
|
47
|
+
return { reason: "the image data is not valid base64 (truncated or corrupted?)" };
|
|
48
|
+
}
|
|
49
|
+
const head = data.slice(0, 16);
|
|
50
|
+
const signature = IMAGE_SIGNATURES.find(([, pattern]) => pattern.test(head));
|
|
51
|
+
if (signature === undefined) return { reason: "the image data is not a PNG, JPEG, GIF, or WebP image" };
|
|
52
|
+
return { data, mimeType: signature[0] };
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
export interface EvalOutputOptions {
|
|
34
56
|
readonly artifactPath?: string;
|
|
35
57
|
readonly headBytes: number;
|
|
@@ -98,6 +120,11 @@ export class EvalOutputCollector {
|
|
|
98
120
|
|
|
99
121
|
display(message: DisplayMessage): void {
|
|
100
122
|
if (message.mimeType.startsWith("image/")) {
|
|
123
|
+
const image = validateDisplayImage(message.dataBase64);
|
|
124
|
+
if ("reason" in image) {
|
|
125
|
+
this.#sink.push(`[display: image dropped \u2014 ${image.reason}]\n`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
101
128
|
if (
|
|
102
129
|
this.#displayImages.length >= MAX_DISPLAY_IMAGES_PER_CELL ||
|
|
103
130
|
this.#displayImageBytes + message.dataBase64.length > MAX_DISPLAY_IMAGE_BYTES_PER_CELL
|
|
@@ -105,8 +132,8 @@ export class EvalOutputCollector {
|
|
|
105
132
|
this.#displayImagesElided++;
|
|
106
133
|
return;
|
|
107
134
|
}
|
|
108
|
-
this.#displayImages.push({ type: "image", mimeType:
|
|
109
|
-
this.#displayImageBytes +=
|
|
135
|
+
this.#displayImages.push({ type: "image", mimeType: image.mimeType, data: image.data });
|
|
136
|
+
this.#displayImageBytes += image.data.length;
|
|
110
137
|
return;
|
|
111
138
|
}
|
|
112
139
|
const text = Buffer.from(message.dataBase64, "base64").toString("utf8");
|
package/src/tool/types.ts
CHANGED
|
@@ -66,8 +66,7 @@ export type EvalControlInput =
|
|
|
66
66
|
|
|
67
67
|
export type EvalToolRequest = EvalToolInput | EvalControlInput;
|
|
68
68
|
|
|
69
|
-
//
|
|
70
|
-
// actions share it; the description teaches the requirement and parseEvalRequest enforces it.
|
|
69
|
+
// Run fields are optional at the root for control calls, but required in the run branch.
|
|
71
70
|
const LANGUAGE_FIELD_DESCRIPTION =
|
|
72
71
|
"REQUIRED for run. Kernel that runs the cell; each language keeps its own persistent state across eval calls.";
|
|
73
72
|
const CODE_FIELD_DESCRIPTION = "REQUIRED for run. Cell body, verbatim.";
|
|
@@ -124,11 +123,20 @@ export function createEvalInputSchema(
|
|
|
124
123
|
const languages = enabledLanguageList(enabled);
|
|
125
124
|
if (languages.length === 0) throw new Error("eval requires at least one enabled language");
|
|
126
125
|
const languageSchema = evalLanguageUnion(languages);
|
|
126
|
+
const properties = evalInputProperties(languageSchema, deadlines);
|
|
127
127
|
return Type.Unsafe<EvalToolRequest>(
|
|
128
|
-
Type.Object(
|
|
128
|
+
Type.Object(properties, {
|
|
129
|
+
// Keep branches self-contained for Mistral-hosted GLM (#2240).
|
|
129
130
|
anyOf: [
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
Type.Object(
|
|
132
|
+
{ ...properties, action: Type.Optional(Type.Literal("run")) },
|
|
133
|
+
{ required: ["language", "code", "summary"] },
|
|
134
|
+
),
|
|
135
|
+
Type.Object({ action: Type.Literal("list") }),
|
|
136
|
+
Type.Object(
|
|
137
|
+
{ action: Type.Union([Type.Literal("peek"), Type.Literal("stop")]), cell_id: properties.cell_id },
|
|
138
|
+
{ required: ["action", "cell_id"] },
|
|
139
|
+
),
|
|
132
140
|
],
|
|
133
141
|
}),
|
|
134
142
|
) as EvalInputSchema;
|