@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
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,60 @@
|
|
|
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
|
+
|
|
37
|
+
## [2026.10.1-3] - 2026-10-01
|
|
38
|
+
|
|
39
|
+
### Breaking Changes
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- The `eval` tool points at the bun-1-4 skill before a cell that installs a package, spawns a server or PTY, or starts a long run, instead of demanding it before the first JavaScript cell ([#2505](https://github.com/code-yeongyu/senpi/issues/2505)).
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Kernel-originated tool approvals now reach the RPC client that submitted the cell, including approvals requested after a cell detaches and its original turn ends. Reusing a kernel no longer sends permission dialogs to its creation context. ([#2512](https://github.com/code-yeongyu/senpi/issues/2512))
|
|
50
|
+
|
|
51
|
+
- Python eval startup waits for kernel readiness with advancing stage events instead of a five-second total deadline. Cold Windows imports can complete normally; a hung start identifies its stalled stage. ([#2452](https://github.com/code-yeongyu/senpi/issues/2452))
|
|
52
|
+
|
|
53
|
+
- Fixed the running eval spinner freezing between output updates. ([#2503](https://github.com/code-yeongyu/senpi/issues/2503))
|
|
54
|
+
|
|
55
|
+
### Removed
|
|
56
|
+
|
|
57
|
+
## [2026.10.1-2] - 2026-10-01
|
|
58
|
+
|
|
59
|
+
### Breaking Changes
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
### Removed
|
|
68
|
+
|
|
15
69
|
## [2026.10.1] - 2026-10-01
|
|
16
70
|
|
|
17
71
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -18,6 +18,9 @@ task-tool names are known.
|
|
|
18
18
|
events, tool-call summaries, elapsed duration, and structured display state;
|
|
19
19
|
a terminal peek preserves the exact final result.
|
|
20
20
|
- Loopback, bearer-authenticated kernel bridge with bounded JSONL frames.
|
|
21
|
+
- Tool calls retain the submitting cell's host context, including its RPC approval
|
|
22
|
+
channel, even after detachment or kernel reuse. Headless sessions deny commands
|
|
23
|
+
that require approval rather than waiting for an absent UI.
|
|
21
24
|
- Structured status events for file operations, environment access, phases,
|
|
22
25
|
bridge activity, and delegated task progress.
|
|
23
26
|
- One versioned `senpi.eval.execution` event at terminal cell settlement. The
|
|
@@ -56,6 +59,14 @@ task-tool names are known.
|
|
|
56
59
|
A missing optional interpreter removes that language from the session's `eval`
|
|
57
60
|
schema; it is not an installation failure.
|
|
58
61
|
|
|
62
|
+
Python startup waits for the interpreter's `ready` event. It reports progress
|
|
63
|
+
through `stdlib-imports`, `runtime-init`, and `host-init`; advancing to the next
|
|
64
|
+
stage resets an inactivity guard rather than consuming a total startup budget.
|
|
65
|
+
The default guard is 11 seconds per stage, derived from a measured 5.220-second
|
|
66
|
+
Windows fresh-cache bootstrap p99 (30 samples). A stalled or failed start names
|
|
67
|
+
the last stage and retains the interpreter's diagnostic error. The low-level
|
|
68
|
+
`PythonKernel.start({ startupTimeoutMs })` override applies per stage.
|
|
69
|
+
|
|
59
70
|
### Session environment
|
|
60
71
|
|
|
61
72
|
Every kernel starts with the active session's `PI_*` environment — `PI_SESSION_ID`,
|
|
@@ -70,6 +81,20 @@ what a child spawned from the bash tool sees. The values snapshot at kernel star
|
|
|
70
81
|
mid-session model switch updates the bash tool's next command but not already-running
|
|
71
82
|
kernels; a new session starts fresh kernels with fresh values.
|
|
72
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
|
+
|
|
73
98
|
`PI_GOAL_STORE_FILE` is supplied by the host's optional `ExtensionContext.goalStoreFile`
|
|
74
99
|
getter and may name a file that does not exist yet. It honors session-directory overrides
|
|
75
100
|
and in-memory sessions; it cannot be derived reliably from `PI_SESSION_FILE`. If the host
|
|
@@ -235,10 +260,10 @@ user writes in: a progress update saying what the agent is doing and why, not
|
|
|
235
260
|
a label for the code. The
|
|
236
261
|
summary is shown in the TUI while the cell runs and in the finished result, so
|
|
237
262
|
you can always tell what is running and why. It has no length limit; a
|
|
238
|
-
collapsed block shows its first three lines. The schema
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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.
|
|
242
267
|
|
|
243
268
|
## Detached cells
|
|
244
269
|
|
|
@@ -308,6 +333,12 @@ restarted, or outcome unknown - never a per-language assumption; oversized
|
|
|
308
333
|
buffered output is written under the session local root and referenced as
|
|
309
334
|
`local://…`.
|
|
310
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
|
+
|
|
311
342
|
Commands a cell runs through `Bun.$` never read the host's terminal: the worker
|
|
312
343
|
thread shares the TUI's stdin, so the shell wrapper hands every template an
|
|
313
344
|
empty pipe (`true | ( … )`) while a cell is active. Output, exit codes, `cwd`,
|
|
@@ -349,6 +380,92 @@ namespace to prevent recursive execution.
|
|
|
349
380
|
|
|
350
381
|
## Validation
|
|
351
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
|
+
|
|
352
469
|
```bash
|
|
353
470
|
cd packages/senpi-codemode
|
|
354
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
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ExtensionContext, withBundledBunCommands } from "@code-yeongyu/senpi";
|
|
2
2
|
import type { AgentExecuteTool } from "../bridges/agent-bridge.ts";
|
|
3
3
|
import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
|
|
4
4
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
@@ -67,7 +67,7 @@ export async function createRuntime(
|
|
|
67
67
|
const executeTool = createExecuteTool(pi, activeTools);
|
|
68
68
|
const create = options.createSessionManager ?? createCodemodeSessionManager;
|
|
69
69
|
const sessionId = sessionIdFrom(event);
|
|
70
|
-
const sessionEnv = sessionEnvironmentFrom(ctx);
|
|
70
|
+
const sessionEnv = { ...sessionEnvironmentFrom(ctx), ...bundledBunPathEntry() };
|
|
71
71
|
const configuredPoolWidth = settings.parallelPoolWidth;
|
|
72
72
|
const parallelPoolWidth = Number.isFinite(configuredPoolWidth) ? Math.max(1, Math.trunc(configuredPoolWidth)) : 1;
|
|
73
73
|
const manager = await create({
|
|
@@ -105,6 +105,17 @@ export function createExecuteTool(pi: CodemodeRuntimeAPI, activeTools?: Readonly
|
|
|
105
105
|
});
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// In a compiled executable, Bun Shell runs `bun` as this executable unless PATH has one, which booted
|
|
109
|
+
// a second agent from `bun test` in a cell (omo#9362). Kernel children get the same bundled `bun`
|
|
110
|
+
// directory the bash tool's PATH carries.
|
|
111
|
+
function bundledBunPathEntry(): Record<string, string> {
|
|
112
|
+
const env = withBundledBunCommands(process.env);
|
|
113
|
+
if (env === process.env) return {};
|
|
114
|
+
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
115
|
+
const value = env[pathKey];
|
|
116
|
+
return value === undefined ? {} : { [pathKey]: value };
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
function sessionIdFrom(event: unknown): string {
|
|
109
120
|
if (typeof event === "object" && event !== null && "sessionId" in event && typeof event.sessionId === "string") {
|
|
110
121
|
return event.sessionId;
|
|
@@ -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 } }));
|