@code-yeongyu/senpi-codemode 2026.9.5 → 2026.9.7-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +34 -5
  3. package/package.json +4 -4
  4. package/src/bridge/protocol.ts +1 -0
  5. package/src/bridge/reserved.ts +2 -0
  6. package/src/extension/runtime-factory.ts +3 -0
  7. package/src/extension/session-manager.ts +7 -0
  8. package/src/kernels/AGENTS.md +7 -0
  9. package/src/kernels/jl/kernel.ts +6 -2
  10. package/src/kernels/js/context-manager.ts +73 -129
  11. package/src/kernels/js/inline-worker.ts +2 -2
  12. package/src/kernels/js/interrupt-bounds.ts +66 -0
  13. package/src/kernels/js/kernel-contract.ts +3 -0
  14. package/src/kernels/js/run-queue.ts +18 -3
  15. package/src/kernels/js/worker-core.js +59 -0
  16. package/src/kernels/js/worker-indirect-eval.js +10 -6
  17. package/src/kernels/js/worker-runtime.js +16 -0
  18. package/src/kernels/js/worker-shell-capture.d.ts +25 -0
  19. package/src/kernels/js/worker-shell-capture.js +70 -8
  20. package/src/kernels/js/worker-slot.ts +106 -0
  21. package/src/kernels/js/worker-startup.ts +70 -0
  22. package/src/kernels/py/kernel-contract.ts +3 -0
  23. package/src/kernels/py/transport.ts +11 -3
  24. package/src/kernels/rb/kernel.ts +6 -2
  25. package/src/kernels/session-env.ts +56 -0
  26. package/src/kernels/shared/runtime-asset.ts +40 -14
  27. package/src/kernels/shared/subprocess-contract.ts +3 -0
  28. package/src/kernels/shared/subprocess-kernel.ts +9 -1
  29. package/src/output/output-meta.ts +26 -2
  30. package/src/prompt/eval-prompt.ts +10 -5
  31. package/src/tool/cell-execution.ts +9 -11
  32. package/src/tool/detached-cell-contract.ts +45 -0
  33. package/src/tool/detached-cell-manager.ts +43 -71
  34. package/src/tool/detached-cell-notification.ts +4 -5
  35. package/src/tool/detached-cell-snapshot.ts +2 -0
  36. package/src/tool/detached-cell-status.ts +30 -0
  37. package/src/tool/detached-eval-result.ts +1 -0
  38. package/src/tool/detached-notification-queue.ts +2 -2
  39. package/src/tool/image.ts +12 -3
  40. package/src/tool/interrupt-note.ts +29 -15
  41. package/src/tool/types.ts +2 -0
@@ -8,7 +8,8 @@ import {
8
8
  isKernelToHostMessage,
9
9
  type KernelToHostMessage,
10
10
  } from "../../bridge/protocol.ts";
11
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
11
+ import { applySessionEnvironment, type SessionEnvironment } from "../session-env.ts";
12
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
12
13
  import {
13
14
  defaultSpawn,
14
15
  hardKill,
@@ -36,6 +37,8 @@ export interface PythonTransportOptions {
36
37
  readonly cwd: string;
37
38
  readonly connection: BridgeConnectionConfig;
38
39
  readonly env?: NodeJS.ProcessEnv;
40
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
41
+ readonly sessionEnv?: SessionEnvironment;
39
42
  readonly startupTimeoutMs: number;
40
43
  readonly onMessage?: (message: KernelToHostMessage) => void;
41
44
  readonly spawnProcess?: KernelSpawnProcess;
@@ -53,7 +56,7 @@ export interface PythonPreludePathOptions extends CodemodeRuntimeAssetEnvironmen
53
56
  }
54
57
 
55
58
  export function resolvePythonPreludePath(options: PythonPreludePathOptions = {}): string {
56
- return resolveCodemodeRuntimeAsset(
59
+ return requireCodemodeRuntimeAsset(
57
60
  options.localPath ?? join(dirname(fileURLToPath(import.meta.url)), "prelude.py"),
58
61
  join("kernels", "py", "prelude.py"),
59
62
  options,
@@ -83,7 +86,12 @@ export class PythonKernelTransport {
83
86
  command: invocation.command,
84
87
  args: [...invocation.args, "-u", scriptPath],
85
88
  cwd: options.cwd,
86
- env: { ...process.env, ...options.env, PYTHONUNBUFFERED: "1", PYTHONIOENCODING: "utf-8" },
89
+ env: {
90
+ ...applySessionEnvironment(process.env, options.sessionEnv),
91
+ ...options.env,
92
+ PYTHONUNBUFFERED: "1",
93
+ PYTHONIOENCODING: "utf-8",
94
+ },
87
95
  };
88
96
  const child = (options.spawnProcess ?? defaultSpawn)(spawnOptions);
89
97
  const transport = new PythonKernelTransport(options, child);
@@ -1,12 +1,15 @@
1
1
  import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
- import { type CodemodeRuntimeAssetEnvironment, resolveCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
3
+ import type { SessionEnvironment } from "../session-env.ts";
4
+ import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
4
5
  import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
5
6
 
6
7
  export interface RubyKernelStartOptions {
7
8
  readonly cwd: string;
8
9
  readonly sessionId: string;
9
10
  readonly connection: BridgeConnectionConfig;
11
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
12
+ readonly sessionEnv?: SessionEnvironment;
10
13
  readonly command?: string;
11
14
  readonly spawn?: SubprocessSpawn;
12
15
  readonly onMessage?: (message: KernelToHostMessage) => void;
@@ -17,7 +20,7 @@ export interface RubyRunnerPathOptions extends CodemodeRuntimeAssetEnvironment {
17
20
  }
18
21
 
19
22
  export function resolveRubyRunnerPath(options: RubyRunnerPathOptions = {}): string {
20
- return resolveCodemodeRuntimeAsset(
23
+ return requireCodemodeRuntimeAsset(
21
24
  options.localPath ?? join(import.meta.dirname, "runner.rb"),
22
25
  join("kernels", "rb", "runner.rb"),
23
26
  options,
@@ -31,6 +34,7 @@ export class RubyKernel extends SubprocessKernel {
31
34
  args: [resolveRubyRunnerPath()],
32
35
  cwd: options.cwd,
33
36
  sessionId: options.sessionId,
37
+ sessionEnv: options.sessionEnv,
34
38
  connection: options.connection,
35
39
  spawn: options.spawn,
36
40
  onMessage: options.onMessage,
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Session environment exposed to eval kernels and every child they spawn.
3
+ *
4
+ * Mirrors the shell-tool session environment contract in the senpi core
5
+ * (`resolveSpawnContext` in `packages/coding-agent/src/core/tools/bash.ts` and
6
+ * `docs/environment-variables.md`): the per-session `PI_*` variables are
7
+ * deleted from the inherited environment first, then set from the active
8
+ * session, so a stale inherited value never leaks into a kernel child. A child
9
+ * spawned from an eval cell must see the same session environment a child
10
+ * spawned from the bash tool sees.
11
+ */
12
+ export const SESSION_ENVIRONMENT_KEYS = [
13
+ "PI_SESSION_ID",
14
+ "PI_SESSION_FILE",
15
+ "PI_PROVIDER",
16
+ "PI_MODEL",
17
+ "PI_REASONING_LEVEL",
18
+ ] as const;
19
+
20
+ /** Resolved per-session values for {@link SESSION_ENVIRONMENT_KEYS}; absent keys stay unset. */
21
+ export type SessionEnvironment = Readonly<Record<string, string>>;
22
+
23
+ /** Structural slice of `ExtensionContext` the session environment is resolved from. */
24
+ export interface SessionEnvironmentSource {
25
+ readonly sessionManager: {
26
+ getSessionId(): string;
27
+ getSessionFile(): string | undefined;
28
+ };
29
+ readonly model?: { readonly provider: string; readonly id: string } | undefined;
30
+ readonly thinkingLevel?: string | undefined;
31
+ }
32
+
33
+ export function sessionEnvironmentFrom(source: SessionEnvironmentSource): SessionEnvironment {
34
+ const env: Record<string, string> = {};
35
+ env.PI_SESSION_ID = source.sessionManager.getSessionId();
36
+ const sessionFile = source.sessionManager.getSessionFile();
37
+ if (sessionFile) env.PI_SESSION_FILE = sessionFile;
38
+ const model = source.model;
39
+ if (model) {
40
+ env.PI_PROVIDER = model.provider;
41
+ env.PI_MODEL = model.id;
42
+ }
43
+ if (source.thinkingLevel) env.PI_REASONING_LEVEL = source.thinkingLevel;
44
+ return env;
45
+ }
46
+
47
+ /**
48
+ * Merges a session environment over a base environment the way the bash tool
49
+ * does: every {@link SESSION_ENVIRONMENT_KEYS} entry is dropped from `base`
50
+ * first, then the provided session values are applied.
51
+ */
52
+ export function applySessionEnvironment(base: NodeJS.ProcessEnv, sessionEnv?: SessionEnvironment): NodeJS.ProcessEnv {
53
+ const merged: NodeJS.ProcessEnv = { ...base };
54
+ for (const key of SESSION_ENVIRONMENT_KEYS) delete merged[key];
55
+ return { ...merged, ...sessionEnv };
56
+ }
@@ -6,26 +6,52 @@ export interface CodemodeRuntimeAssetEnvironment {
6
6
  readonly executablePath?: string;
7
7
  }
8
8
 
9
+ export class CodemodeRuntimeAssetMissingError extends Error {
10
+ readonly packageRelativePath: string;
11
+ readonly localPath: string;
12
+ readonly sidecarPath: string;
13
+ readonly executablePath: string;
14
+
15
+ constructor(packageRelativePath: string, localPath: string, sidecarPath: string, executablePath: string) {
16
+ super(
17
+ `codemode runtime asset ${packageRelativePath} is unavailable: module-relative path ${localPath} is not readable and the codemode sidecar is missing beside the executable ${executablePath} (expected ${sidecarPath}). Ship node_modules/@code-yeongyu/senpi-codemode next to the executable.`,
18
+ );
19
+ this.name = "CodemodeRuntimeAssetMissingError";
20
+ this.packageRelativePath = packageRelativePath;
21
+ this.localPath = localPath;
22
+ this.sidecarPath = sidecarPath;
23
+ this.executablePath = executablePath;
24
+ }
25
+ }
26
+
27
+ export function isBunVirtualPath(path: string): boolean {
28
+ return path.includes("/$bunfs/") || path.includes("~BUN") || path.includes("%7EBUN");
29
+ }
30
+
31
+ function sidecarPathFor(packageRelativePath: string, executablePath: string): string {
32
+ return join(dirname(executablePath), "node_modules", "@code-yeongyu", "senpi-codemode", "src", packageRelativePath);
33
+ }
34
+
35
+ export function requireCodemodeRuntimeAsset(
36
+ localPath: string,
37
+ packageRelativePath: string,
38
+ { executablePath = process.execPath }: CodemodeRuntimeAssetEnvironment = {},
39
+ ): string {
40
+ const sidecarPath = sidecarPathFor(packageRelativePath, executablePath);
41
+ if (!isBunVirtualPath(localPath) && existsSync(localPath)) return localPath;
42
+ if (existsSync(sidecarPath)) return sidecarPath;
43
+ throw new CodemodeRuntimeAssetMissingError(packageRelativePath, localPath, sidecarPath, executablePath);
44
+ }
45
+
9
46
  export function resolveCodemodeRuntimeAsset(
10
47
  localPath: string,
11
48
  packageRelativePath: string,
12
49
  { bunVersion = process.versions.bun, executablePath = process.execPath }: CodemodeRuntimeAssetEnvironment = {},
13
50
  ): string {
14
- if (existsSync(localPath)) {
15
- return localPath;
16
- }
51
+ if (existsSync(localPath)) return localPath;
17
52
  if (bunVersion) {
18
- const sidecarPath = join(
19
- dirname(executablePath),
20
- "node_modules",
21
- "@code-yeongyu",
22
- "senpi-codemode",
23
- "src",
24
- packageRelativePath,
25
- );
26
- if (existsSync(sidecarPath)) {
27
- return sidecarPath;
28
- }
53
+ const sidecarPath = sidecarPathFor(packageRelativePath, executablePath);
54
+ if (existsSync(sidecarPath)) return sidecarPath;
29
55
  }
30
56
  return localPath;
31
57
  }
@@ -1,4 +1,5 @@
1
1
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
2
+ import type { SessionEnvironment } from "../session-env.ts";
2
3
  import type { SubprocessSpawn } from "./subprocess-process.ts";
3
4
 
4
5
  export interface KernelRunInput {
@@ -15,6 +16,8 @@ export interface SubprocessKernelOptions {
15
16
  readonly args: readonly string[];
16
17
  readonly cwd?: string;
17
18
  readonly env?: NodeJS.ProcessEnv;
19
+ /** Per-session PI_* values merged into the interpreter environment at spawn. */
20
+ readonly sessionEnv?: SessionEnvironment;
18
21
  readonly sessionId: string;
19
22
  readonly connection: BridgeConnectionConfig;
20
23
  readonly spawn?: SubprocessSpawn;
@@ -1,6 +1,7 @@
1
1
  import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { KernelInterruptHandle } from "../../tool/types.ts";
4
+ import { applySessionEnvironment } from "../session-env.ts";
4
5
  import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
5
6
  import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
6
7
  import { SubprocessRunQueue } from "./subprocess-queue.ts";
@@ -133,7 +134,14 @@ export class SubprocessKernel {
133
134
  }
134
135
 
135
136
  private spawnProcess(): void {
136
- const child = spawnSubprocess(this.options.spawn, this.options);
137
+ const child = spawnSubprocess(this.options.spawn, {
138
+ ...this.options,
139
+ env:
140
+ this.options.env ??
141
+ (this.options.sessionEnv
142
+ ? applySessionEnvironment(globalThis.process.env, this.options.sessionEnv)
143
+ : undefined),
144
+ });
137
145
  const process = new SubprocessProcess(child, {
138
146
  onLine: (source, line) => this.handleLine(source, line),
139
147
  onStderr: (source, data) => this.handleMessage(source, { type: "text", stream: "stderr", data }),
@@ -28,12 +28,14 @@ export function resolveSessionArtifactsDir(sessionFile: string | undefined): Ses
28
28
 
29
29
  export interface TruncationMeta {
30
30
  readonly direction: "head" | "tail" | "middle";
31
- readonly truncatedBy: "lines" | "bytes" | "middle";
31
+ readonly truncatedBy: "lines" | "bytes" | "columns" | "middle";
32
32
  readonly totalLines: number;
33
33
  readonly totalBytes: number;
34
34
  readonly outputLines: number;
35
35
  readonly outputBytes: number;
36
36
  readonly maxBytes?: number;
37
+ readonly maxColumns?: number;
38
+ readonly columnTruncatedLines?: number;
37
39
  readonly shownRange?: { readonly start: number; readonly end: number };
38
40
  readonly headRange?: { readonly start: number; readonly end: number };
39
41
  readonly tailRange?: { readonly start: number; readonly end: number };
@@ -72,7 +74,7 @@ export function formatTruncationWarning(meta: TruncationMeta | undefined): strin
72
74
  meta.shownRange !== undefined && meta.shownRange.end >= meta.shownRange.start
73
75
  ? `Showing lines ${meta.shownRange.start}-${meta.shownRange.end} of ${meta.totalLines}`
74
76
  : `Showing ${meta.outputLines} of ${meta.totalLines} lines`;
75
- if (meta.truncatedBy === "bytes") message += ` (${formatBytes(meta.maxBytes ?? meta.outputBytes)} limit)`;
77
+ message += formatByteLoss(meta);
76
78
  break;
77
79
  default:
78
80
  return assertNever(meta.direction);
@@ -81,6 +83,28 @@ export function formatTruncationWarning(meta: TruncationMeta | undefined): strin
81
83
  return `[${message}]`;
82
84
  }
83
85
 
86
+ function formatByteLoss(meta: TruncationMeta): string {
87
+ const dropped = formatBytes(Math.max(0, meta.totalBytes - meta.outputBytes));
88
+ switch (meta.truncatedBy) {
89
+ case "columns": {
90
+ const clamped = meta.columnTruncatedLines ?? 0;
91
+ const width = meta.maxColumns === undefined ? "the column cap" : `${meta.maxColumns} columns`;
92
+ return `; ${clamped} line${clamped === 1 ? "" : "s"} clamped to ${width} (${dropped} dropped)`;
93
+ }
94
+ case "bytes":
95
+ return meta.maxBytes === undefined ? ` (${dropped} dropped)` : ` (${formatBytes(meta.maxBytes)} limit)`;
96
+ case "lines":
97
+ case "middle":
98
+ return "";
99
+ default:
100
+ return assertNeverCause(meta.truncatedBy);
101
+ }
102
+ }
103
+
104
+ function assertNeverCause(value: never): never {
105
+ throw new TypeError(`Unhandled truncation cause: ${String(value)}`);
106
+ }
107
+
84
108
  export function stripOutputNotice(text: string, meta: TruncationMeta | undefined): string {
85
109
  const notice = formatTruncationWarning(meta);
86
110
  if (notice === null) return text;
@@ -77,9 +77,9 @@ const EVAL_PROMPT_TEMPLATE = `Run one step of code in a persistent kernel.
77
77
  {{#if monitor}}- Start long-running work (build, test run, deploy, or watch) through \`tool.monitor({ command, filter })\`, putting the decisive-line filter inside the same cell, then keep working until its event wakes the turn.{{/if}}
78
78
  </eval_first_batching>{{/if}}{{#if styleGpt}}<gpt_eval_dialect>
79
79
  GPT eval: compose multi-tool work inside one cell with \`tool.<name>(args)\` and \`parallel(thunks)\`; do not split a planned step into serial tool calls.
80
- - Long cells detach on timeout and notify on completion; do not poll or re-run them.
80
+ {{#if monitor}}- A wait or a long run (build, test run, deploy, watch) starts through \`tool.monitor({ command, filter })\` in that same cell with the decisive-line filter; its event wakes the turn, so no cell sits on the wait and no child is spawned for it.
81
+ {{/if}}- Long cells detach on timeout and notify on completion; do not poll or re-run them.
81
82
  - Filter, join, and aggregate tool results in the cell; return only decision-relevant facts.
82
- {{#if monitor}}- For long-running build, test run, deploy, or watch work, start \`tool.monitor({ command, filter })\` with the decisive-line filter in the same cell; keep working until its event wakes the turn.{{/if}}
83
83
  </gpt_eval_dialect>{{/if}}{{#if styleCodex}}Route multi-call steps through eval: one cell per step, independent lookups dispatched together via \`parallel(thunks)\`; keep work sequential only when one result determines the next action.
84
84
  - Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically — filter, join, aggregate.
85
85
  - Wrap failable calls in try/except inside the cell; a failed item degrades only itself. After two distinct failed strategies for the same fact, fall back to direct tool calls.
@@ -102,7 +102,7 @@ Host: {{hostLine}} — cells execute here. Size \`parallel(thunks)\` pools to it
102
102
  A cell that outlives the foreground window detaches: it keeps its language kernel busy (another language can continue) and completes as one notification with its value or error and buffered output. Do not re-run a detached cell; read or cancel it with \`eval({ action: "peek", cell_id })\` / \`eval({ action: "stop", cell_id })\`.
103
103
 
104
104
  {{#if py}}Python runs on a live event loop: use top-level \`await\`; \`asyncio.run(…)\` raises.{{/if}}
105
- {{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot.{{#if bunSkillPath}} MUST READ the bun-1-4 skill at {{bunSkillPath}} before your first js cell — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
105
+ {{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available, including \`new Bun.WebView()\` — a headless browser (navigate/click/evaluate/screenshot) to reach for before \`curl\` or a browser CLI when a page needs JS, a login, or a screenshot. Shell out through \`Bun.$\` or \`Bun.spawn\`, never \`Bun.spawnSync\`: a synchronous child blocks the worker, so a stop or timeout then loses every variable.{{#if bunSkillPath}} MUST READ the bun-1-4 skill at {{bunSkillPath}} before your first js cell — its builtins replace the npm packages you would otherwise install.{{/if}}{{else}}JS runs under Node.js worker: top-level \`await\`/\`return\` work; \`fetch\`/\`Buffer\` available.{{/if}}{{/if}}
106
106
  {{#if rb}}Ruby: synchronous; helper options are keyword args{{#if spawns}} (e.g. \`output("id", limit: 2)\`){{/if}}; the last expression auto-displays unless it is \`nil\`, an assignment, or a definition (like IRB).{{/if}}
107
107
  {{#if jl}}Julia: synchronous; helper options are standard keyword args{{#if spawns}} (e.g. \`output("id", limit=2)\`){{/if}}; the last expression auto-displays unless it is an assignment or a definition (like the Julia REPL).{{/if}}
108
108
  On error, fix and re-run only the failing step; a normal error keeps state, while a timeout or stop message says whether the kernel restarted.
@@ -182,7 +182,7 @@ export function buildEvalPrompt(
182
182
  description,
183
183
  promptSnippet: "Run one incremental code cell in a persistent language kernel.",
184
184
  promptGuidelines: [
185
- BATCHING_GUIDELINES[style],
185
+ style === "gpt" && context.monitor === true ? GPT_MONITOR_BATCHING_GUIDELINE : BATCHING_GUIDELINES[style],
186
186
  "Use eval reset only when a language kernel must be wiped; reset is scoped to the selected language.",
187
187
  ],
188
188
  };
@@ -191,8 +191,13 @@ export function buildEvalPrompt(
191
191
  /**
192
192
  * System-prompt guideline per emphasis dialect. The default dialect carries
193
193
  * maximum emphasis so unmapped models still batch through eval; the others are
194
- * tuned to what steers that family reliably.
194
+ * tuned to what steers that family reliably. The GPT line routes waits to the
195
+ * subscription when `monitor` is reachable, because a GPT model that reads
196
+ * "long cells detach" as the way to wait awaits a `--watch` inside a cell.
195
197
  */
198
+ const GPT_MONITOR_BATCHING_GUIDELINE =
199
+ "Use eval to compose tool work in one cell; a wait or a long run starts through `tool.monitor` in that cell, so no cell sits on it and nothing polls.";
200
+
196
201
  const BATCHING_GUIDELINES: Record<EvalEmphasisStyle, string> = {
197
202
  default:
198
203
  "**EVAL FIRST.** Any step needing MORE THAN ONE tool call MUST be ONE eval cell: run independent calls in parallel, wrap risky calls in try/except, and return distilled facts — NEVER a chain of single tool calls.",
@@ -1,5 +1,5 @@
1
1
  import { IdleTimeout, type IdleTimeoutOptions, type TimeoutPauseHandle } from "../timeouts/idle-timeout.ts";
2
- import type { EvalKernel } from "./types.ts";
2
+ import type { EvalKernel, KernelInterruptHandle } from "./types.ts";
3
3
 
4
4
  const INTERRUPT_DELIVERY_GRACE_MS = 100;
5
5
 
@@ -104,7 +104,8 @@ export class CellExecution {
104
104
  this.#abort(this.#callerSignal.reason);
105
105
  };
106
106
 
107
- interruptStateRetained: Promise<boolean> | undefined;
107
+ /** Resolves with the kernel's interrupt handle once the abort reached it; undefined when no kernel was bound. */
108
+ interruptHandle: Promise<KernelInterruptHandle> | undefined;
108
109
 
109
110
  #abort(reason: unknown): void {
110
111
  if (!this.#active) return;
@@ -118,15 +119,12 @@ export class CellExecution {
118
119
  return;
119
120
  }
120
121
  this.#interruptDeadline = setTimeout(() => this.#settleAbort(error), INTERRUPT_DELIVERY_GRACE_MS);
121
- void Promise.resolve()
122
- .then(async () => {
123
- const handle = await kernel.interrupt(error.message);
124
- this.interruptStateRetained = handle?.stateRetained;
125
- })
126
- .then(
127
- () => this.#settleAbort(error),
128
- (interruptError: unknown) => this.#settleAbort(interruptError),
129
- );
122
+ const handle = Promise.resolve().then(async () => await kernel.interrupt(error.message));
123
+ this.interruptHandle = handle;
124
+ void handle.then(
125
+ () => this.#settleAbort(error),
126
+ (interruptError: unknown) => this.#settleAbort(interruptError),
127
+ );
130
128
  }
131
129
 
132
130
  #settleAbort(reason: unknown): void {
@@ -0,0 +1,45 @@
1
+ import type { AgentToolResult } from "@code-yeongyu/senpi";
2
+ import type { WakeSourceState } from "../extension/wake-source-state.ts";
3
+ import type { EvalLanguage, EvalToolDetails } from "./types.ts";
4
+
5
+ export type EvalDetachedCellState = "running" | "detached" | "completed" | "failed" | "cancelled";
6
+
7
+ export interface EvalDetachedCellSnapshot {
8
+ readonly cellId: string;
9
+ readonly language: EvalLanguage;
10
+ readonly state: EvalDetachedCellState;
11
+ readonly outputTail: string;
12
+ readonly result: AgentToolResult<EvalToolDetails>;
13
+ readonly stateRetained: boolean | undefined;
14
+ /** Kernel-supplied detail about the interrupt outcome, e.g. an abandoned blocked worker. */
15
+ readonly interruptNote?: string;
16
+ /** Set only when the wall-clock kill deadline ended this cell. */
17
+ readonly hardLimitSeconds?: number;
18
+ }
19
+
20
+ export interface EvalDetachedCellNotification {
21
+ readonly cellId: string;
22
+ readonly content: string;
23
+ }
24
+
25
+ export interface EvalDetachedCellNotifier {
26
+ notify(cells: readonly EvalDetachedCellNotification[]): void;
27
+ }
28
+
29
+ export interface EvalDetachedCellStatusEntry {
30
+ readonly cellId: string;
31
+ readonly language: EvalLanguage;
32
+ readonly summary?: string;
33
+ readonly startedAtMs: number;
34
+ }
35
+
36
+ export interface EvalDetachedCellManagerOptions {
37
+ readonly artifactsDir?: string;
38
+ readonly notifier?: EvalDetachedCellNotifier;
39
+ /** Wall-clock kill deadline in seconds; defaults to the bash-parity 1800s. */
40
+ readonly hardLimitSeconds?: number;
41
+ readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
42
+ /** Receives a full per-source liveness snapshot on every detached-cell transition; used by the goal builtin. */
43
+ readonly onWakeSourceState?: (state: WakeSourceState) => void;
44
+ readonly now?: () => number;
45
+ }
@@ -1,6 +1,12 @@
1
1
  import type { AgentToolResult } from "@code-yeongyu/senpi";
2
2
  import { DEFAULT_HARD_LIMIT_SECONDS } from "../config/settings.ts";
3
- import { SENPI_CODEMODE_WAKE_SOURCE, type WakeSourceState } from "../extension/wake-source-state.ts";
3
+ import type { WakeSourceState } from "../extension/wake-source-state.ts";
4
+ import type {
5
+ EvalDetachedCellManagerOptions,
6
+ EvalDetachedCellSnapshot,
7
+ EvalDetachedCellState,
8
+ EvalDetachedCellStatusEntry,
9
+ } from "./detached-cell-contract.ts";
4
10
  import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
5
11
  import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
6
12
  import {
@@ -8,10 +14,18 @@ import {
8
14
  allowsDetachedCellTransition,
9
15
  detachedCellIsActive,
10
16
  } from "./detached-cell-state.ts";
17
+ import { detachedStatusEntries, detachedWakeSourceState } from "./detached-cell-status.ts";
11
18
  import { DetachedNotificationQueue } from "./detached-notification-queue.ts";
12
19
  import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
13
20
 
14
- export type EvalDetachedCellState = "running" | "detached" | "completed" | "failed" | "cancelled";
21
+ export type {
22
+ EvalDetachedCellManagerOptions,
23
+ EvalDetachedCellNotification,
24
+ EvalDetachedCellNotifier,
25
+ EvalDetachedCellSnapshot,
26
+ EvalDetachedCellState,
27
+ EvalDetachedCellStatusEntry,
28
+ } from "./detached-cell-contract.ts";
15
29
 
16
30
  type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
17
31
 
@@ -26,6 +40,9 @@ type ManagedCell = {
26
40
  wasDetached: boolean;
27
41
  kernel: EvalKernel | undefined;
28
42
  stateRetained: boolean | undefined;
43
+ interruptNote: string | undefined;
44
+ /** Holds the completion notification until the interrupt has reported whether kernel state survived. */
45
+ interruptOutcome: PromiseWithResolvers<void> | undefined;
29
46
  liveResult: LiveResultProvider | undefined;
30
47
  terminalResult: AgentToolResult<EvalToolDetails> | undefined;
31
48
  notificationQueued: boolean;
@@ -35,44 +52,6 @@ type ManagedCell = {
35
52
  onHardLimit: ((error: Error) => void) | undefined;
36
53
  };
37
54
 
38
- export interface EvalDetachedCellSnapshot {
39
- readonly cellId: string;
40
- readonly language: EvalLanguage;
41
- readonly state: EvalDetachedCellState;
42
- readonly outputTail: string;
43
- readonly result: AgentToolResult<EvalToolDetails>;
44
- readonly stateRetained: boolean | undefined;
45
- /** Set only when the wall-clock kill deadline ended this cell. */
46
- readonly hardLimitSeconds?: number;
47
- }
48
-
49
- export interface EvalDetachedCellNotification {
50
- readonly cellId: string;
51
- readonly content: string;
52
- }
53
-
54
- export interface EvalDetachedCellNotifier {
55
- notify(cells: readonly EvalDetachedCellNotification[]): void;
56
- }
57
-
58
- export interface EvalDetachedCellStatusEntry {
59
- readonly cellId: string;
60
- readonly language: EvalLanguage;
61
- readonly summary?: string;
62
- readonly startedAtMs: number;
63
- }
64
-
65
- export interface EvalDetachedCellManagerOptions {
66
- readonly artifactsDir?: string;
67
- readonly notifier?: EvalDetachedCellNotifier;
68
- /** Wall-clock kill deadline in seconds; defaults to the bash-parity 1800s. */
69
- readonly hardLimitSeconds?: number;
70
- readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
71
- /** Receives a full per-source liveness snapshot on every detached-cell transition; used by the goal builtin. */
72
- readonly onWakeSourceState?: (state: WakeSourceState) => void;
73
- readonly now?: () => number;
74
- }
75
-
76
55
  export function hardLimitError(cellId: string, hardLimitSeconds: number): Error {
77
56
  const error = new Error(`Eval cell ${cellId} was killed at the ${hardLimitSeconds}s hard limit.`);
78
57
  error.name = "TimeoutError";
@@ -114,6 +93,8 @@ export class EvalDetachedCellManager {
114
93
  wasDetached: false,
115
94
  kernel: undefined,
116
95
  stateRetained: undefined,
96
+ interruptNote: undefined,
97
+ interruptOutcome: undefined,
117
98
  liveResult: undefined,
118
99
  terminalResult: undefined,
119
100
  notificationQueued: false,
@@ -162,13 +143,7 @@ export class EvalDetachedCellManager {
162
143
 
163
144
  async stop(cellId: string, reason = "Stopped detached eval cell"): Promise<EvalDetachedCellSnapshot> {
164
145
  const cell = this.#get(cellId);
165
- if (cell.state === "detached") {
166
- const claimed = this.#settle(cell, "cancelled", currentDetachedResult(cell));
167
- if (claimed && cell.kernel !== undefined) {
168
- const handle = await cell.kernel.interrupt(reason);
169
- cell.stateRetained = await handle.stateRetained;
170
- }
171
- }
146
+ if (cell.state === "detached") await this.#cancel(cell, reason);
172
147
  return this.#snapshot(cell);
173
148
  }
174
149
 
@@ -221,7 +196,10 @@ export class EvalDetachedCellManager {
221
196
  if (!cell.notificationQueued) {
222
197
  cell.notificationQueued = true;
223
198
  this.#notificationQueue.enqueue({
224
- snapshot: () => this.#snapshot(cell),
199
+ snapshot: async () => {
200
+ await cell.interruptOutcome?.promise;
201
+ return this.#snapshot(cell);
202
+ },
225
203
  spillPath: cell.spillPath,
226
204
  });
227
205
  }
@@ -250,40 +228,34 @@ export class EvalDetachedCellManager {
250
228
  const foreground = cell.state === "running" && cell.onHardLimit !== undefined;
251
229
  cell.hardLimited = true;
252
230
  const error = hardLimitError(cell.cellId, cell.hardLimitSeconds);
253
- if (!this.#settle(cell, "cancelled", currentDetachedResult(cell))) return;
254
231
  if (foreground) {
255
- cell.onHardLimit?.(error);
232
+ if (this.#settle(cell, "cancelled", currentDetachedResult(cell))) cell.onHardLimit?.(error);
256
233
  return;
257
234
  }
258
- if (cell.kernel === undefined) return;
259
- const handle = await cell.kernel.interrupt(error.message);
260
- cell.stateRetained = await handle.stateRetained;
235
+ await this.#cancel(cell, error.message);
236
+ }
237
+
238
+ async #cancel(cell: ManagedCell, reason: string): Promise<void> {
239
+ const outcome = Promise.withResolvers<void>();
240
+ cell.interruptOutcome = outcome;
241
+ try {
242
+ if (!this.#settle(cell, "cancelled", currentDetachedResult(cell)) || cell.kernel === undefined) return;
243
+ const handle = await cell.kernel.interrupt(reason);
244
+ cell.interruptNote = handle.note;
245
+ cell.stateRetained = await handle.stateRetained;
246
+ } finally {
247
+ outcome.resolve();
248
+ }
261
249
  }
262
250
 
263
251
  #emitStatus(): void {
264
252
  const liveCells = [...this.#detachedByLanguage.values()];
265
- this.#onStatusChange?.(
266
- liveCells.map((cell) => ({
267
- cellId: cell.cellId,
268
- language: cell.input.language,
269
- startedAtMs: cell.startedAtMs,
270
- ...(cell.input.summary === undefined ? {} : { summary: cell.input.summary }),
271
- })),
272
- );
253
+ this.#onStatusChange?.(detachedStatusEntries(liveCells));
273
254
  this.#emitWakeSourceState(liveCells);
274
255
  }
275
256
 
276
257
  #emitWakeSourceState(liveCells: readonly ManagedCell[]): void {
277
- this.#onWakeSourceState?.({
278
- source: SENPI_CODEMODE_WAKE_SOURCE,
279
- activeCount: liveCells.length,
280
- items: liveCells.map((cell) => ({
281
- id: cell.cellId,
282
- description:
283
- cell.input.summary === undefined || cell.input.summary.length === 0 ? cell.cellId : cell.input.summary,
284
- startedAtMs: cell.startedAtMs,
285
- })),
286
- });
258
+ this.#onWakeSourceState?.(detachedWakeSourceState(liveCells));
287
259
  }
288
260
 
289
261
  #snapshot(cell: ManagedCell): EvalDetachedCellSnapshot {
@@ -1,6 +1,7 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import type { EvalDetachedCellNotification, EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
4
+ import { interruptionStateNote, unknownInterruptionStateNote } from "./interrupt-note.ts";
4
5
 
5
6
  const NOTIFICATION_TAIL_BYTES = 512;
6
7
 
@@ -74,11 +75,9 @@ function outcomeOf(cell: EvalDetachedCellSnapshot): string {
74
75
  }
75
76
 
76
77
  function stateNoteOf(cell: EvalDetachedCellSnapshot): string {
77
- if (cell.state === "cancelled" && cell.language === "js")
78
- return "JavaScript worker was restarted; VM state was lost.";
79
- if (cell.state === "cancelled" && cell.language === "py")
80
- return "Python kernel was interrupted; its existing variables are preserved.";
81
- return "Kernel state updated - variables are available to the next eval cell.";
78
+ if (cell.state !== "cancelled") return "Kernel state updated - variables are available to the next eval cell.";
79
+ const note = interruptionStateNote(cell.language, cell.stateRetained) ?? unknownInterruptionStateNote(cell.language);
80
+ return cell.interruptNote === undefined ? note : `${note} ${cell.interruptNote.trim()}`;
82
81
  }
83
82
 
84
83
  function safeCellId(cellId: string): string {
@@ -10,6 +10,7 @@ export interface DetachedCellResultSource {
10
10
  state: EvalDetachedCellState;
11
11
  kernel: EvalKernel | undefined;
12
12
  stateRetained: boolean | undefined;
13
+ interruptNote?: string | undefined;
13
14
  liveResult: (() => AgentToolResult<EvalToolDetails>) | undefined;
14
15
  terminalResult: AgentToolResult<EvalToolDetails> | undefined;
15
16
  hardLimited?: boolean;
@@ -26,6 +27,7 @@ export function snapshotDetachedCell(cell: DetachedCellResultSource, nowMs: numb
26
27
  outputTail: detachedOutputTail(result),
27
28
  result,
28
29
  stateRetained: cell.stateRetained,
30
+ ...(cell.interruptNote === undefined ? {} : { interruptNote: cell.interruptNote }),
29
31
  ...(cell.hardLimited === true && cell.hardLimitSeconds !== undefined
30
32
  ? { hardLimitSeconds: cell.hardLimitSeconds }
31
33
  : {}),