@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 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 marks all three
239
- optional only because the control actions (`peek`, `stop`, `list`) share it; a
240
- run request missing any of them fails with a teaching error that names what to
241
- add.
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.1",
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.1",
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.1"
38
+ "@code-yeongyu/senpi": "2026.10.2"
38
39
  },
39
40
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.10.1"
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, and ticks producing the already-rendered label are skipped.
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
- private tick(): void {
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
- this.render(status);
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 { ExtensionContext } from "@code-yeongyu/senpi";
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) return 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
+ }
@@ -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, stoppedResult } from "./run-queue.ts";
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, run.interruptResult ?? stoppedResult(run.input.cellId, message));
246
+ this.#runs.settle(run, restartedResult(run, message));
246
247
  void this.#recover(() => Promise.resolve());
247
- return retirement === "abandoned"
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.active?.interruptAck?.resolve();
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 { PendingJavaScriptRun } from "./run-queue.ts";
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
- emit({ type: "status", event: { op: INTERRUPT_ACK_OP, cellId: activeCell.cellId } });
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 } }));