@code-yeongyu/senpi-codemode 2026.9.3 → 2026.9.4-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,62 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.4-2] - 2026-09-04
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - `foregroundWindowSeconds` codemode setting (default `60`, env `SENPI_CODEMODE_FOREGROUND_SECONDS`): the longest an interactive `eval` call blocks the turn before the cell detaches. A larger `timeout` now frees the turn at this window while the cell keeps running to the hard limit, instead of blocking the agent loop for the whole `timeout`.
22
+
23
+ ### Changed
24
+
25
+ - The `eval` tool description is dieted a second time: the `Fields:` list now defers to the parameter schema (its single home), the detach guidance is one paragraph, and helper lines keep every signature with fewer words. gpt/codex dialect 1,489 -> 1,087 o200k tokens (description + guidelines); claude 1,173, kimi 1,190, default 1,189. Also fixes the fused `jl` handle form in the all-languages render.
26
+
27
+ - The `eval` tool description is dieted from ~2002 to ~1588 tokens (codex dialect): the three reuse-chain JSON examples, the `<workflow>` graph prose, the repeated state-persistence rules, and the per-dialect wait-doctrine clause are removed or folded; every helper signature and dialect routing is kept. The workflow block's fused `handle=True{ handle: true }` is fixed into per-language correct forms.
28
+
29
+ ### Fixed
30
+
31
+ ### Removed
32
+
33
+ ## [2026.9.4] - 2026-09-04
34
+
35
+ ### Breaking Changes
36
+
37
+ ### Added
38
+
39
+ ### Changed
40
+
41
+ ### Fixed
42
+
43
+ ### Removed
44
+
45
+ ## [2026.9.3-3] - 2026-09-03
46
+
47
+ ### Breaking Changes
48
+
49
+ ### Added
50
+
51
+ ### Changed
52
+
53
+ ### Fixed
54
+
55
+ ### Removed
56
+
57
+ ## [2026.9.3-2] - 2026-09-03
58
+
59
+ ### Breaking Changes
60
+
61
+ ### Added
62
+
63
+ ### Changed
64
+
65
+ ### Fixed
66
+
67
+ - JavaScript eval cells no longer leak child-process output onto the host terminal under Bun: `Bun.$` commands awaited without `.quiet()`/`.text()` and `Bun.spawn` children with the default stderr now route their output into the cell's stdout/stderr streams instead of the inherited fd 1/2 that the interactive TUI owns.
68
+
69
+ ### Removed
70
+
15
71
  ## [2026.9.3] - 2026-09-03
16
72
 
17
73
  ### Breaking Changes
@@ -209,6 +265,7 @@
209
265
 
210
266
  ### Fixed
211
267
 
268
+ - An explicit `timeout` no longer silently disables detach for interactive `eval` cells. Previously `timeout` was both the detach budget and the hard-limit extension with no cap, so a call like `timeout: 7000` (intended to keep a long detached cell alive) blocked the agent loop for ~2h before the hard limit killed it. The detach point is now capped at the foreground window; `on_timeout: "error"` (and print/json) keep `timeout` as the unclamped deadline, and the hard-limit extension (`max(hardLimitSeconds, timeout)`) is unchanged.
212
269
  - Detached-eval same-language busy errors now name each idle enabled kernel and tell the agent to continue the step there (`continue this step in an idle kernel: js`), instead of only pointing at peek and the output tail. A busy Python kernel no longer reads as "eval is unavailable", which previously sent agents to `bash`+`python3` while JavaScript (or another idle kernel) was free. Single-language sessions and fully-busy sessions omit the idle-kernel claim.
213
270
  - JavaScript eval cells now persist only top-level declarations, including destructuring bindings and uninitialized variables, without rewriting declaration-shaped text inside literals or comments.
214
271
  - Eval completion and detached-cell handling retain explicit lifecycle observability: nested tool counts, wall/kernel timing, detach state, `peek`, `stop`, hard limits, and crash recovery remain bounded and machine-readable for hosts and telemetry consumers.
package/README.md CHANGED
@@ -71,6 +71,7 @@ Configuration is loaded in this order:
71
71
  "jl": false
72
72
  },
73
73
  "cellTimeoutSeconds": 30,
74
+ "foregroundWindowSeconds": 60,
74
75
  "parallelPoolWidth": 4,
75
76
  "taskTools": {
76
77
  "task": "task",
@@ -88,6 +89,7 @@ Configuration is loaded in this order:
88
89
  | --- | --- | --- |
89
90
  | `languages` | `py`/`js` enabled; `rb`/`jl` disabled | Selects desired languages before interpreter detection. |
90
91
  | `cellTimeoutSeconds` | `30` | Idle timeout for one cell unless the call supplies `timeout`; interactive calls detach by default and print/json calls error. |
92
+ | `foregroundWindowSeconds` | `60` | Longest an interactive (detach-behavior) call blocks the turn before the cell detaches, capping the `timeout` detach budget. A larger `timeout` still raises the hard limit and keeps the cell running, but the turn is freed at this window. `on_timeout: "error"` calls keep the full `timeout` as an uncapped deadline. Env override: `SENPI_CODEMODE_FOREGROUND_SECONDS`. |
91
93
  | `parallelPoolWidth` | `4` | Maximum concurrent `parallel()` thunks. |
92
94
  | `taskTools.task` | `"task"` | Registered tool name used by `agent()`. |
93
95
  | `taskTools.output` | `"task_output"` | Registered tool name used by `output()`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.9.3",
3
+ "version": "2026.9.4-2",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -30,14 +30,14 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@babel/parser": "8.0.4",
33
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.3",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.4-2",
34
34
  "typebox": "1.3.18"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.9.3"
37
+ "@code-yeongyu/senpi": "2026.9.4-2"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.3"
40
+ "@code-yeongyu/senpi": "2026.9.4-2"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -19,6 +19,7 @@ export const codemodeSettingsSchema = Type.Object(
19
19
  ),
20
20
  ),
21
21
  cellTimeoutSeconds: Type.Optional(Type.Number({ minimum: 1 })),
22
+ foregroundWindowSeconds: Type.Optional(Type.Number({ minimum: 1 })),
22
23
  hardLimitSeconds: Type.Optional(Type.Number({ minimum: 1 })),
23
24
  parallelPoolWidth: Type.Optional(Type.Number({ minimum: 1 })),
24
25
  taskTools: Type.Optional(
@@ -64,6 +65,13 @@ export interface CodemodeSettings {
64
65
  readonly jl: boolean;
65
66
  };
66
67
  readonly cellTimeoutSeconds: number;
68
+ /**
69
+ * Longest an interactive eval call blocks the agent loop before the cell detaches, independent
70
+ * of `timeout` (which becomes the detach budget only up to this window). A still-running cell
71
+ * keeps living up to the hard limit; this only frees the turn. Ignored for `on_timeout: "error"`
72
+ * (and print/json) calls, where `timeout` stays the unclamped deadline.
73
+ */
74
+ readonly foregroundWindowSeconds: number;
67
75
  /** Wall-clock kill deadline for a single cell; bounds detached cells too. */
68
76
  readonly hardLimitSeconds: number;
69
77
  readonly parallelPoolWidth: number;
@@ -98,6 +106,16 @@ export const DEFAULT_HARD_LIMIT_SECONDS = 1800;
98
106
 
99
107
  export const HARD_LIMIT_ENVIRONMENT_FLAG = "SENPI_CODEMODE_HARD_LIMIT_SECONDS";
100
108
 
109
+ /**
110
+ * Bash parity: `terminal/tools/foreground-window.ts` auto-detaches a still-running bash command to a
111
+ * background session at 60s regardless of its `timeout` kill deadline. An eval cell gets the same
112
+ * default foreground window so a large `timeout` extends the cell's lifetime without holding the turn
113
+ * hostage for hours.
114
+ */
115
+ export const DEFAULT_FOREGROUND_WINDOW_SECONDS = 60;
116
+
117
+ export const FOREGROUND_WINDOW_ENVIRONMENT_FLAG = "SENPI_CODEMODE_FOREGROUND_SECONDS";
118
+
101
119
  // OMP settings-schema.ts:3211-3299 has language/path settings only; eval.ts:427
102
120
  // defaults timeout to 30s, and codemode pins concurrency-bridge.ts:30 width to 4.
103
121
  export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
@@ -108,6 +126,7 @@ export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
108
126
  jl: false,
109
127
  },
110
128
  cellTimeoutSeconds: 30,
129
+ foregroundWindowSeconds: DEFAULT_FOREGROUND_WINDOW_SECONDS,
111
130
  hardLimitSeconds: DEFAULT_HARD_LIMIT_SECONDS,
112
131
  parallelPoolWidth: 4,
113
132
  taskTools: {
@@ -166,6 +185,15 @@ export function resolveHardLimitSeconds(settings: CodemodeSettings, env: Environ
166
185
  return parsed;
167
186
  }
168
187
 
188
+ /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
189
+ export function resolveForegroundWindowSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
190
+ const override = env[FOREGROUND_WINDOW_ENVIRONMENT_FLAG];
191
+ if (override === undefined) return settings.foregroundWindowSeconds;
192
+ const parsed = Number.parseInt(override, 10);
193
+ if (!Number.isFinite(parsed) || parsed <= 0) return settings.foregroundWindowSeconds;
194
+ return parsed;
195
+ }
196
+
169
197
  async function loadSettingsFile(path: string): Promise<LoadedCodemodeSettings> {
170
198
  const raw = await readFile(path, "utf8");
171
199
  let parsed: unknown;
@@ -200,6 +228,7 @@ function mergeSettings(input: CodemodeSettingsInput): ResolvedCodemodeSettings {
200
228
  jl: input.languages?.jl ?? defaultCodemodeSettings.languages.jl,
201
229
  },
202
230
  cellTimeoutSeconds: input.cellTimeoutSeconds ?? defaultCodemodeSettings.cellTimeoutSeconds,
231
+ foregroundWindowSeconds: input.foregroundWindowSeconds ?? defaultCodemodeSettings.foregroundWindowSeconds,
203
232
  hardLimitSeconds: input.hardLimitSeconds ?? defaultCodemodeSettings.hardLimitSeconds,
204
233
  parallelPoolWidth: input.parallelPoolWidth ?? defaultCodemodeSettings.parallelPoolWidth,
205
234
  taskTools: {
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import type { ExtensionContext } from "@code-yeongyu/senpi";
3
3
  import type { AgentExecuteTool } from "./bridges/agent-bridge.ts";
4
4
  import type { EvalSchemaToolInfo } from "./bridges/schema-bridge.ts";
5
5
  import { type CompletionRequest, type CompletionResult, createCompletionHandler } from "./completion/handler.ts";
6
- import { defaultCodemodeSettings, resolveHardLimitSeconds } from "./config/settings.ts";
6
+ import { defaultCodemodeSettings, resolveForegroundWindowSeconds, resolveHardLimitSeconds } from "./config/settings.ts";
7
7
  import { EvalNotifier } from "./extension/eval-notifier.ts";
8
8
  import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
9
9
  import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
@@ -64,6 +64,15 @@ export interface SenpiCodemodeOptions {
64
64
  readonly now?: () => number;
65
65
  }
66
66
 
67
+ /** Whether the session registry holds `monitor`; false when the runtime cannot be read yet. */
68
+ function monitorIsRegistered(pi: CodemodeExtensionAPI): boolean {
69
+ try {
70
+ return pi.getAllTools().some((tool) => tool.name === "monitor");
71
+ } catch {
72
+ return false;
73
+ }
74
+ }
75
+
67
76
  export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCodemodeOptions = {}): void {
68
77
  const manager = new SessionManagerProxy();
69
78
  const complete = options.complete ?? ((request, ctx) => createCompletionHandler()(ctx)(request));
@@ -108,11 +117,17 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
108
117
  pi.rpc?.emit(EVAL_EXECUTION_EVENT, toEvalExecutionRpcPayload(payload));
109
118
  pi.events?.emit(EVAL_EXECUTION_EVENT, payload);
110
119
  };
120
+ // `listTools` below survives because it is lazy; this read is eager, and the loader's
121
+ // action methods throw while extensions are still loading (the bundled codemode path
122
+ // reaches this before the runtime is bound). An unreadable registry means "do not teach
123
+ // a tool we cannot confirm"; session_start / model_select re-register once it is live.
124
+ const monitor = monitorIsRegistered(pi);
111
125
  pi.registerTool(
112
126
  createEvalTool({
113
127
  enabledLanguages: runtime.enabledLanguages,
114
128
  kernelManager: manager,
115
129
  cellTimeoutSeconds: runtime.settings.cellTimeoutSeconds,
130
+ foregroundWindowSeconds: resolveForegroundWindowSeconds(runtime.settings),
116
131
  executeTool: runtime.executeTool,
117
132
  listTools: () => pi.getAllTools(),
118
133
  complete,
@@ -122,6 +137,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
122
137
  executionTracker: manager,
123
138
  onCellSettled,
124
139
  renderers,
140
+ monitor,
125
141
  spawns: runtime.spawns,
126
142
  spawnDefaultAgent: runtime.settings.taskTools.task,
127
143
  hostLine: hostLine(),
@@ -146,6 +162,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
146
162
  enabledLanguages: { py: true, js: true, rb: true, jl: true },
147
163
  kernelManager: manager,
148
164
  cellTimeoutSeconds: defaultCodemodeSettings.cellTimeoutSeconds,
165
+ foregroundWindowSeconds: resolveForegroundWindowSeconds(defaultCodemodeSettings),
149
166
  executeTool: createExecuteTool(pi),
150
167
  listTools: () => pi.getAllTools(),
151
168
  complete,
@@ -159,6 +176,8 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
159
176
  }),
160
177
  executionTracker: manager,
161
178
  renderers,
179
+ // The baseline tool is registered before extensions such as monitor load.
180
+ monitor: false,
162
181
  hostLine: hostLine(),
163
182
  runtimes: { js: jsRuntimeInfo() },
164
183
  ...(bunSkillPath === undefined ? {} : { bunSkillPath }),
@@ -3,6 +3,7 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join, normalize, resolve, sep } from "node:path";
4
4
  import { inspect } from "node:util";
5
5
  import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
6
+ import { installShellCapture } from "./worker-shell-capture.js";
6
7
 
7
8
  const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
8
9
  const INTERNAL_URL = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/iu;
@@ -84,11 +85,16 @@ export class JsWorkerRuntime {
84
85
  process.stderr.write = routeWrite(process.stderr, originalStderrWrite, "stderr");
85
86
  console.log = (...values) => this.#emitText("stdout", `${values.map(formatValue).join(" ")}\n`);
86
87
  console.error = (...values) => this.#emitText("stderr", `${values.map(formatValue).join(" ")}\n`);
88
+ const restoreShellCapture = installShellCapture({
89
+ isActive: () => this.#hooks !== null,
90
+ emitText: (stream, data) => this.#emitText(stream, data),
91
+ });
87
92
  globalThis.__senpi_restore_console__ = () => {
88
93
  console.log = originalLog;
89
94
  console.error = originalError;
90
95
  process.stdout.write = originalStdoutWrite;
91
96
  process.stderr.write = originalStderrWrite;
97
+ restoreShellCapture();
92
98
  };
93
99
  }
94
100
 
@@ -0,0 +1,10 @@
1
+ export type ShellCaptureStream = "stdout" | "stderr";
2
+
3
+ export type ShellCaptureRestore = () => void;
4
+
5
+ export interface ShellCaptureOptions {
6
+ readonly isActive: () => boolean;
7
+ readonly emitText: (stream: ShellCaptureStream, data: string) => void;
8
+ }
9
+
10
+ export function installShellCapture(options: ShellCaptureOptions): ShellCaptureRestore;
@@ -0,0 +1,126 @@
1
+ const SHELL_CONFIG_METHODS = ["env", "cwd", "nothrow", "throws"];
2
+ const SHELL_READ_METHODS = ["text", "json", "lines", "arrayBuffer", "bytes", "blob"];
3
+
4
+ export function installShellCapture(options) {
5
+ const bun = globalThis.Bun;
6
+ if (!isBunRuntime(bun)) return () => {};
7
+ const originalShell = bun.$;
8
+ const originalSpawn = bun.spawn;
9
+ bun.$ = capturedShell(originalShell, options);
10
+ bun.spawn = capturedSpawn(originalSpawn, options);
11
+ return () => {
12
+ bun.$ = originalShell;
13
+ bun.spawn = originalSpawn;
14
+ };
15
+ }
16
+
17
+ function isBunRuntime(bun) {
18
+ return bun !== null && typeof bun === "object" && typeof bun.$ === "function" && typeof bun.spawn === "function";
19
+ }
20
+
21
+ function capturedShell(originalShell, options) {
22
+ const shell = (strings, ...expressions) => {
23
+ const promise = originalShell(strings, ...expressions);
24
+ return options.isActive() ? captureShellPromise(promise, options.emitText) : promise;
25
+ };
26
+ for (const key of Object.keys(originalShell)) shell[key] = originalShell[key];
27
+ for (const method of SHELL_CONFIG_METHODS) {
28
+ shell[method] = (...args) => {
29
+ originalShell[method](...args);
30
+ return shell;
31
+ };
32
+ }
33
+ return shell;
34
+ }
35
+
36
+ function captureShellPromise(promise, emitText) {
37
+ const prototype = Object.getPrototypeOf(promise);
38
+ let echo = true;
39
+ const echoOnce = (output) => {
40
+ if (!echo) return;
41
+ echo = false;
42
+ emitShellOutput(output, emitText);
43
+ };
44
+ prototype.quiet.call(promise);
45
+ promise.quiet = function quiet() {
46
+ echo = false;
47
+ return prototype.quiet.call(this);
48
+ };
49
+ for (const method of SHELL_READ_METHODS) {
50
+ if (typeof prototype[method] !== "function") continue;
51
+ promise[method] = function read(...args) {
52
+ echo = false;
53
+ return prototype[method].apply(this, args);
54
+ };
55
+ }
56
+ promise.then = function then(onFulfilled, onRejected) {
57
+ return prototype.then.call(
58
+ this,
59
+ (output) => {
60
+ echoOnce(output);
61
+ return onFulfilled ? onFulfilled(output) : output;
62
+ },
63
+ (error) => {
64
+ echoOnce(error);
65
+ if (onRejected) return onRejected(error);
66
+ throw error;
67
+ },
68
+ );
69
+ };
70
+ return promise;
71
+ }
72
+
73
+ function emitShellOutput(output, emitText) {
74
+ if (output === null || typeof output !== "object") return;
75
+ const stdout = outputText(output.stdout);
76
+ if (stdout) emitText("stdout", stdout);
77
+ const stderr = outputText(output.stderr);
78
+ if (stderr) emitText("stderr", stderr);
79
+ }
80
+
81
+ function outputText(value) {
82
+ if (value instanceof Uint8Array) return new TextDecoder().decode(value);
83
+ return typeof value === "string" ? value : "";
84
+ }
85
+
86
+ function capturedSpawn(originalSpawn, options) {
87
+ return (...args) => {
88
+ if (!options.isActive()) return originalSpawn(...args);
89
+ const [first, second] = args;
90
+ if (Array.isArray(first)) {
91
+ const spawnOptions = second === undefined ? {} : second;
92
+ if (!needsStderrCapture(spawnOptions)) return originalSpawn(...args);
93
+ return drainStderr(originalSpawn(first, { ...spawnOptions, stderr: "pipe" }), options.emitText);
94
+ }
95
+ if (!needsStderrCapture(first)) return originalSpawn(...args);
96
+ return drainStderr(originalSpawn({ ...first, stderr: "pipe" }), options.emitText);
97
+ };
98
+ }
99
+
100
+ function needsStderrCapture(spawnOptions) {
101
+ return (
102
+ spawnOptions !== null &&
103
+ typeof spawnOptions === "object" &&
104
+ spawnOptions.stdio === undefined &&
105
+ spawnOptions.stderr === undefined
106
+ );
107
+ }
108
+
109
+ function drainStderr(child, emitText) {
110
+ const stream = child?.stderr;
111
+ if (!(stream instanceof ReadableStream)) return child;
112
+ void readStream(stream, emitText).catch((error) => {
113
+ emitText("stderr", `[spawn stderr capture failed: ${String(error)}]\n`);
114
+ });
115
+ return child;
116
+ }
117
+
118
+ async function readStream(stream, emitText) {
119
+ const decoder = new TextDecoder();
120
+ for await (const chunk of stream) {
121
+ const text = decoder.decode(chunk, { stream: true });
122
+ if (text) emitText("stderr", text);
123
+ }
124
+ const tail = decoder.decode();
125
+ if (tail) emitText("stderr", tail);
126
+ }
@@ -15,6 +15,8 @@ export interface EvalPromptParts {
15
15
 
16
16
  export interface EvalPromptOptions {
17
17
  readonly spawns: boolean;
18
+ /** Whether the session registry exposes the monitor tool through eval. */
19
+ readonly monitor?: boolean;
18
20
  readonly spawnDefaultAgent?: string;
19
21
  /** Active model id; selects the emphasis dialect of the batching guidance. */
20
22
  readonly modelId?: string;
@@ -62,86 +64,48 @@ export function evalEmphasisStyle(modelId: string | undefined): EvalEmphasisStyl
62
64
 
63
65
  type ContextValue = string | boolean;
64
66
  type Context = Readonly<Record<string, ContextValue>>;
65
- type EvalPromptExample = {
66
- readonly caption: string;
67
- readonly language: keyof EnabledLanguages;
68
- readonly summary: string;
69
- readonly code: string;
70
- };
71
-
72
- // senpi ToolDefinition has no examples field, so description embeds the examples.
73
- // ADAPTATION: payloads diverge from omp's json-config chain to teach batch read,
74
- // comprehension filtering, and parallel tool.<name> fan-out while keeping the
75
- // three-cell reuse narrative.
76
- const REUSE_CHAIN_EXAMPLES = [
77
- {
78
- caption: "First call — set up once",
79
- language: "js",
80
- summary: "Count all TypeScript source files under src/ excluding tests",
81
- code: "import { readdir } from 'node:fs/promises'\nimport { extname } from 'node:path'\nconst files = (await readdir('src', { recursive: true })).filter(f => extname(f) === '.ts' && !f.includes('test'))\nprint(files.length)",
82
- },
83
- {
84
- caption: "Second call — reuse `files`, fan out session tools in parallel",
85
- language: "js",
86
- summary: "Grep legacyClient per directory in one cell",
87
- code: "const dirs = [...new Set(files.map(f => f.split('/')[0]))]\nconst hits = await Promise.all(dirs.map(d => tool.grep({ pattern: 'legacyClient', path: d })))\ndisplay(hits.map(h => h.matches?.length ?? 0))",
88
- },
89
- {
90
- caption: "JS kernel is busy with a detached cell — continue in py",
91
- language: "py",
92
- summary: "Aggregate legacyClient hits while JS is busy",
93
- code: "from pathlib import Path\nprint(sum('legacyClient' in read(p) for p in Path('src').rglob('*.ts')))",
94
- },
95
- ] as const satisfies readonly EvalPromptExample[];
96
-
97
67
  const EVAL_PROMPT_TEMPLATE = `Run one step of code in a persistent kernel.
98
68
 
99
69
  <instruction>
100
- **One eval call = one cell = one logical step.** State persists per language across separate eval calls and tool calls{{#if spawns}}, and \`task\` subagents{{/if}} define helpers, datasets, and clients in one call, then later calls reuse them directly.
101
-
102
- Work incrementally: imports in one call, define in the next, test, then use — each its own eval call. Re-run setup ONLY after \`reset\`, a kernel crash, or a \`NameError\`/\`ReferenceError\` proving the state is gone.
70
+ **One eval call = one cell = one logical step.** Top-level names persist per language across eval calls{{#if spawns}}, tool calls and \`task\` subagents{{else}} and tool calls{{/if}}: define helpers and clients once and reuse them instead of re-importing or re-reading. Rebuild state only after \`reset\`, a kernel restart, or a \`NameError\`/\`ReferenceError\`, and check a sentinel variable first so a re-run cannot duplicate side effects.
103
71
 
104
72
  {{#if styleClaude}}<eval_first_batching>
105
73
  \`eval\` is your default execution surface: if a step needs more than one tool call, write ONE cell that performs the whole step — never issue the calls one at a time.
106
74
  - Enumerate every lookup the step needs, then run all independent ones simultaneously with \`parallel(thunks)\` inside the cell; keep calls sequential only when one result feeds the next.
107
75
  - Write real code around the calls: loop or comprehend over file sets with \`read()\`/stdlib, branch per case, and wrap risky calls in try/except so one failure degrades only its item — recover or retry inside the cell, keep the batch alive.
108
- - Post-process \`tool.<name>()\` results programmatically and return distilled facts, not raw dumps.
76
+ - Post-process \`tool.<name>()\` results programmatically — filter, join, aggregate — and return distilled facts, not raw dumps.
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}}
109
78
  </eval_first_batching>{{/if}}{{#if styleGpt}}<gpt_eval_dialect>
110
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.
111
- - Long pure-compute cells detach on timeout and notify on completion. Do not poll or re-run them; use \`eval({ action: "peek"|"stop", cell_id })\` only to inspect or stop a detached cell.
112
- - Reduce tool results in the cell and return only decision-relevant facts.
80
+ - Long cells detach on timeout and notify on completion; do not poll or re-run them.
81
+ - 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}}
113
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.
114
- - Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically.
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.
115
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.
116
- - Reduce large results in-kernel to the facts the task needs before returning.{{/if}}{{#if styleKimi}}**EVAL IS YOUR SUPERPOWER — MAKE IT YOUR DEFAULT WAY TO ACT.** Before any step, think: "how do I execute this WHOLE step in ONE parallelized cell?" — then write that ONE cell.
86
+ - Reduce large results in-kernel to the facts the task needs before returning.
87
+ {{#if monitor}}- Long-running build/test/deploy/watch work: start \`tool.monitor({ command, filter })\` with the decisive-line filter inside the same cell, then continue working until its event wakes the turn.{{/if}}{{/if}}{{#if styleKimi}}**EVAL IS YOUR SUPERPOWER — MAKE IT YOUR DEFAULT WAY TO ACT.** Before any step, think: "how do I execute this WHOLE step in ONE parallelized cell?" — then write that ONE cell.
117
88
  - **BATCH EVERYTHING AT ONCE:** enumerate EVERY independent lookup the step needs and dispatch them ALL simultaneously with \`parallel(thunks)\` in that cell; keep calls sequential only when one result feeds the next.
118
89
  - **WRITE REAL CODE, NOT CALL CHAINS:** loop or comprehend over file sets with \`read()\`/stdlib, post-process \`tool.<name>()\` results programmatically, and put try/except around each risky call so the rest of the batch completes.
119
- - **DISTILL IN-KERNEL:** filter and aggregate results in code, then return ONLY the distilled facts.{{/if}}{{#if styleDefault}}**EVAL IS YOUR PRIMARY EXECUTION SURFACE.** Any step that needs MORE THAN ONE tool call MUST be written as ONE cell — NEVER as a chain of single tool calls.
90
+ - **DISTILL IN-KERNEL:** filter, join, and aggregate \`tool.<name>()\` results in code, then return ONLY the distilled facts.
91
+ {{#if monitor}}- **DO start long-running build, test run, deploy, or watch work with \`tool.monitor({ command, filter })\`, put the decisive-line filter INSIDE THE SAME CELL, and KEEP WORKING until its event wakes the turn.**{{/if}}{{/if}}{{#if styleDefault}}**EVAL IS YOUR PRIMARY EXECUTION SURFACE.** Any step that needs MORE THAN ONE tool call MUST be written as ONE cell — NEVER as a chain of single tool calls.
120
92
  - **PLAN THE WHOLE STEP, THEN BATCH IT.** Enumerate every read/search/lookup the step needs and dispatch ALL independent ones through \`parallel(thunks)\` in one cell.
121
93
  - **WRITE REAL CODE, NOT CALL LISTS.** Loop or comprehend over file sets with \`read()\`/stdlib, branch \`if\`/\`else\` per case, post-process \`tool.<name>()\` results programmatically, and wrap EVERY risky call in try/except so ONE failure NEVER kills the batch.
122
- - **DISTILL IN-KERNEL.** Filter, diff, and aggregate in code before returning; return facts, NOT dumps.{{/if}}
94
+ - **DISTILL IN-KERNEL.** Filter, join, diff, and aggregate in code before returning; return facts, NOT dumps.
95
+ {{#if monitor}}- **LONG-RUNNING build, test run, deploy, or watch work MUST start with \`tool.monitor({ command, filter })\`, with the decisive-line filter INSIDE THE SAME CELL; KEEP WORKING until its event wakes the turn.**{{/if}}{{/if}}
123
96
  {{#if hostLine}}
124
97
  Host: {{hostLine}} — cells execute here. Size \`parallel(thunks)\` pools to its cores; \`tool.<name>()\` shell commands must fit this platform, even when the code you are writing targets another machine.
125
98
  {{/if}}
126
99
 
127
- Fields:
128
-
129
- - \`language\` — {{#if py}}\`"py"\` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}\`"js"\` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}\`"rb"\` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}\`"jl"\` persistent Julia kernel{{/if}}.
130
- - \`code\` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
131
- - \`summary\` (REQUIRED for run) — ONE line in the USER'S conversational language stating WHAT this cell does and FOR WHAT PURPOSE (e.g. Korean conversation -> "src 전체에서 legacyClient 사용처 집계"); shown in the TUI while the cell runs; >80 chars is force-truncated.
132
- - \`timeout\` (optional) — seconds. Raise only for heavy compute or long{{#if spawns}} non-agent{{/if}} tool calls.
133
- - \`on_timeout\` (optional) — \`"detach"\` keeps pure computation running in interactive sessions (the default); \`"error"\` interrupts for deadline-sensitive work and is the print/json default.
134
- - Every cell is killed at a wall-clock hard limit (default 1800s) that survives detach and is never paused by tool calls; a larger explicit \`timeout\` raises it, and a killed cell notifies you that it hit the limit.
135
- - \`reset\` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a \`py\` reset never touches the JS VM.{{/ifAll}}
136
- - \`action\` (optional) — defaults to \`"run"\`. A detached cell returns its id: use \`eval({ action: "peek", cell_id })\` for buffered output/state or \`eval({ action: "stop", cell_id })\` to cancel it.
100
+ \`language\`: {{#if py}}\`"py"\` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}\`"js"\` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}\`"rb"\` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}\`"jl"\` persistent Julia kernel{{/if}}.
137
101
 
138
- A detached cell keeps its language kernel busy while it finishes; another language can continue. Do not re-run a detached cell: the same-language busy error names its cell id and output tail. Completion arrives as one notification with the final value/error and buffered output. Stopping a cell interrupts its kernel; the stop result states whether kernel state survived or the kernel was restarted and its variables lost.
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 })\`.
139
103
 
140
- {{#if py}}Live event loop: use top-level \`await\` directly; \`asyncio.run(…)\` raises "cannot be called from a running event loop".{{/if}}
104
+ {{#if py}}Python runs on a live event loop: use top-level \`await\`; \`asyncio.run(…)\` raises.{{/if}}
141
105
  {{#if js}}{{#if jsBun}}JS runs in-process on Bun {{jsVersion}}: top-level \`await\`/\`return\` work; \`Bun.*\` builtins available.{{#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}}
142
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}}
143
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}}
144
- On error, fix and re-run only the failing step. State usually survives a normal error, but a timeout or stop may have restarted the kernel — its message says which. Before rebuilding state, check a sentinel (a variable you defined earlier); only re-establish what is actually gone, since blind re-runs duplicate side effects.
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.
145
109
  </instruction>
146
110
 
147
111
  <prelude>
@@ -156,25 +120,21 @@ read(path, offset?=1, limit?=None) → str
156
120
  write(path, content) → str
157
121
  Write file (creates parents) → resolved path. \`local://…\` persists across turns/subagents.
158
122
  env(key?=None, value?=None) → str | None | dict
159
- No args → full env dict; one → value of \`key\`; two → set \`key=value\`, return value.
123
+ No args → full env dict; one → value; two → set \`key=value\`.
160
124
  {{#if spawns}}output(*ids, format?="raw", offset?=None, limit?=None) → str | dict | list[dict]
161
- Task/agent output by id. Reads immediately: running tasks return their status; \`format\` selects full (\`"raw"\`) or trailing (\`"tail"\`) output.
125
+ Task/agent output by id. Reads immediately: running tasks return their status; \`format\` \`"raw"\` = full, \`"tail"\` = trailing.
162
126
  {{/if}}tool.<name>(args) → unknown
163
127
  Invoke any session tool; \`args\` = its parameter object.
164
128
  tool_schema(name?) → dict
165
- Parameter schema of a tool without calling it; omit \`name\` to list tool names.
166
- Use it before calling a tool you have not called before — a failed call also
167
- returns the expected parameters, so fix the args and retry in the next cell
168
- instead of abandoning eval.
129
+ Parameter schema of a tool (omit \`name\` to list tool names); a failed \`tool.<name>()\` call also returns the expected parameters.
169
130
  completion(prompt, model?="default", system?=None, schema?=None) → str | dict
170
- Oneshot, stateless (no history/tools). \`model\`: \`"smol"\` fast | \`"default"\` session | \`"slow"\` most capable. \`schema\` (JSON-Schema) → structured output, parsed object.
131
+ Oneshot, stateless. \`model\`: \`"smol"\` fast | \`"default"\` session | \`"slow"\` most capable. \`schema\` (JSON-Schema) → parsed structured output.
171
132
  {{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
172
- Run a subagent → final output. \`agent\` picks another discovered agent; omit it to use \`{{spawnDefaultAgent}}\`. \`schema\` as in completion(). Background via \`local://\` files named in the prompt. \`handle\` → workflow node dict { text, output, handle: \`agent://<id>\`, id, agent } (parsed under \`data\` when \`schema\` set).
173
- {{#if js}} JS: options are ONE trailing object — agent(prompt, { agent, schema, handle }).
174
- {{/if}}{{/if}}parallel(thunks) list
175
- Thunks through a bounded pool (wide as a \`task\` batch — don't pre-shrink), input order kept; returns when all finish, a throwing thunk propagates.
133
+ Run a subagent → final output. \`agent\` picks a discovered agent. \`schema\` as in completion(). \`handle\` → workflow node { text, output, handle: \`agent://<id>\`, id, agent } (parsed under \`data\` with \`schema\`).
134
+ {{/if}}parallel(thunks) list
135
+ Thunks through a bounded pool (as wide as a \`task\` batch), input order kept; a throwing thunk propagates.
176
136
  pipeline(items, ...stages) → list
177
- Map items through one-arg stages left-to-right, barrier between stages; stage 1 gets the item, later stages the previous result.
137
+ Map items through one-arg stages with a barrier between stages; each stage receives the previous stage's result.
178
138
  log(message) → None
179
139
  Progress line above the status tree.
180
140
  phase(title) → None
@@ -183,18 +143,10 @@ phase(title) → None
183
143
  </prelude>
184
144
  {{#if spawns}}
185
145
  <workflow>
186
- Define the workflow spec IN CODE: partition the work into logically distinct steps, one node per step, then wire them as acyclic waves never hand-author the graph as a single opaque call.
187
- - **Name nodes.** Capture each \`agent(…, {{#if py}}handle=True{{/if}}{{#if js}}{ handle: true }{{/if}}{{#if jl}}handle=true{{/if}})\` result; carries \`handle\` (\`agent://<id>\`) + \`output\`.
188
- - **Wire edges by reference.** Put an upstream node's \`handle\`/\`output\` in the dependent stage's prompt — large transcript never re-inlined. Bulk: \`write("local://<name>.md", …)\`, pass the URI.
189
- - **\`pipeline(items, *stages)\` = staged waves**, barrier between stages (every item clears stage N before any enters N+1). **\`parallel(thunks)\` = one wave** of independent nodes.
190
- - **Isolate failure.** A raising node re-raises the lowest-index error, aborts its wave; wrap risky nodes in try/except so a failure degrades only its dependent subtree, independent branches finish.
191
- - **Acyclic only.** A node never waits on its own descendant.
146
+ Multi-agent work is an acyclic graph in code: one \`agent(…)\` node per step with its handle option ({{#if py}}\`handle=True\`{{/if}}{{#ifAll py js}} / {{/ifAll}}{{#if js}}\`{ handle: true }\`{{/if}}{{#if jl}}{{#ifAny py js}} / {{/ifAny}}\`handle=true\`{{/if}}), \`parallel(thunks)\` for independent nodes, \`pipeline(items, *stages)\` for staged waves. Pass an upstream node's \`handle\` or \`output\` (or a \`write("local://…")\` URI for bulk text) into dependents instead of re-inlining transcripts, and wrap risky nodes in try/except so a failure aborts only its subtree.
192
147
  </workflow>
193
148
  {{/if}}
194
-
195
- <critical>
196
- Prior top-level names (\`data\`, \`sessions\`, helpers, imports) survive into the next eval call — reuse them; NEVER re-import, re-require, or re-declare a helper. Re-read a file only if it may have changed since the last read.
197
- </critical>`;
149
+ `;
198
150
 
199
151
  export function buildEvalPrompt(
200
152
  enabled: EnabledLanguages,
@@ -211,6 +163,7 @@ export function buildEvalPrompt(
211
163
  rb: enabled.rb,
212
164
  jl: enabled.jl,
213
165
  spawns: options.spawns,
166
+ monitor: options.monitor === true,
214
167
  spawnDefaultAgent,
215
168
  styleClaude: style === "claude",
216
169
  styleCodex: style === "codex",
@@ -222,20 +175,9 @@ export function buildEvalPrompt(
222
175
  jsVersion: options.jsRuntime?.version ?? "",
223
176
  bunSkillPath: options.bunSkillPath ?? "",
224
177
  };
225
- const examples = REUSE_CHAIN_EXAMPLES.filter((example) => enabled[example.language])
226
- .map((example) => {
227
- const call = { language: example.language, summary: example.summary, code: example.code };
228
- return `### ${example.caption}\n\`\`\`json\n${JSON.stringify(call, null, 2)}\n\`\`\``;
229
- })
230
- .join("\n\n");
231
- const description = [
232
- renderTemplate(EVAL_PROMPT_TEMPLATE, context)
233
- .replace(/\n{3,}/g, "\n\n")
234
- .trim(),
235
- examples === "" ? "" : `<examples>\n${examples}\n</examples>`,
236
- ]
237
- .filter((part) => part !== "")
238
- .join("\n\n");
178
+ const description = renderTemplate(EVAL_PROMPT_TEMPLATE, context)
179
+ .replace(/\n{3,}/g, "\n\n")
180
+ .trim();
239
181
  return {
240
182
  description,
241
183
  promptSnippet: "Run one incremental code cell in a persistent language kernel.",
@@ -17,6 +17,12 @@ export interface CellExecutionOptions {
17
17
  readonly callerSignal: AbortSignal;
18
18
  readonly cellId: string;
19
19
  readonly timeoutMs: number;
20
+ /**
21
+ * Caps how long a host-bridge pause may suspend the idle watchdog. When the cell will detach on
22
+ * timeout this is set to the foreground window so a bridge-parked cell still frees the turn at the
23
+ * window; left undefined (error mode) it keeps the idle-timeout default grace.
24
+ */
25
+ readonly maxPauseGraceMs?: number;
20
26
  readonly timeoutFactory: EvalTimeoutFactory;
21
27
  readonly onTimeout: (error: Error) => void;
22
28
  readonly onAbort: (error: Error) => void;
@@ -46,6 +52,7 @@ export class CellExecution {
46
52
  this.#watchdog = options.timeoutFactory.create({
47
53
  cellId: options.cellId,
48
54
  timeoutMs: options.timeoutMs,
55
+ ...(options.maxPauseGraceMs === undefined ? {} : { maxPauseGraceMs: options.maxPauseGraceMs }),
49
56
  onTimeout: ({ error }) => options.onTimeout(error),
50
57
  });
51
58
  this.#callerSignal.addEventListener("abort", this.#handleCallerAbort, {
@@ -21,6 +21,12 @@ export interface CreateEvalToolOptions {
21
21
  readonly enabledLanguages: EnabledEvalLanguages;
22
22
  readonly kernelManager: EvalKernelManager;
23
23
  readonly cellTimeoutSeconds: number;
24
+ /**
25
+ * Longest an interactive (detach-behavior) call blocks the agent loop before the cell detaches,
26
+ * capping the `timeout` detach budget. Defaults to {@link DEFAULT_FOREGROUND_WINDOW_SECONDS}.
27
+ * Does not affect `on_timeout: "error"` calls or the wall-clock hard limit.
28
+ */
29
+ readonly foregroundWindowSeconds?: number;
24
30
  /** Wall-clock kill deadline applied to every cell; only used when this factory creates its own manager. */
25
31
  readonly hardLimitSeconds?: number;
26
32
  readonly executeTool: ExecuteTool;
@@ -36,6 +42,8 @@ export interface CreateEvalToolOptions {
36
42
  readonly proxyExecutor?: (params: EvalToolInput, signal?: AbortSignal) => Promise<AgentToolResult<EvalToolDetails>>;
37
43
  readonly renderers?: Pick<ToolDefinition<EvalInputSchema, EvalToolDetails>, "renderCall" | "renderResult">;
38
44
  readonly spawns?: boolean;
45
+ /** Whether the session registry exposes the monitor tool through eval. */
46
+ readonly monitor?: boolean;
39
47
  readonly spawnDefaultAgent?: string;
40
48
  readonly modelId?: string;
41
49
  readonly hostLine?: string;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import type { AgentToolResult, ExtensionContext, ToolDefinition } from "@code-yeongyu/senpi";
4
- import { defaultCodemodeSettings } from "../config/settings.ts";
4
+ import { DEFAULT_FOREGROUND_WINDOW_SECONDS, defaultCodemodeSettings } from "../config/settings.ts";
5
5
  import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
6
6
  import { TIMEOUT_PAUSE_OP, TIMEOUT_RESUME_OP } from "../timeouts/bridge-timeout.ts";
7
7
  import { abortError, CellExecution, defaultTimeoutFactory } from "./cell-execution.ts";
@@ -28,6 +28,7 @@ export function createEvalTool(options: CreateEvalToolOptions): ToolDefinition<E
28
28
  const parameters = createEvalInputSchema(options.enabledLanguages);
29
29
  const prompt = buildEvalPrompt(options.enabledLanguages, {
30
30
  spawns: options.spawns ?? false,
31
+ monitor: options.monitor,
31
32
  ...(options.spawnDefaultAgent === undefined ? {} : { spawnDefaultAgent: options.spawnDefaultAgent }),
32
33
  ...(options.modelId === undefined ? {} : { modelId: options.modelId }),
33
34
  ...(options.hostLine === undefined ? {} : { hostLine: options.hostLine }),
@@ -100,8 +101,19 @@ async function runEvalCell(
100
101
  invocation: EvalCellInvocation,
101
102
  ): Promise<AgentToolResult<EvalToolDetails>> {
102
103
  if (invocation.signal.aborted) throw abortError(invocation.signal.reason);
103
- const timeoutMs = Math.floor((invocation.input.timeout ?? options.cellTimeoutSeconds) * 1_000);
104
104
  const timeoutBehavior = evalTimeoutBehavior(invocation.input, invocation.ctx);
105
+ const requestedTimeoutMs = Math.floor((invocation.input.timeout ?? options.cellTimeoutSeconds) * 1_000);
106
+ // The `timeout` (and its `cellTimeoutSeconds` default) is the detach budget for interactive calls.
107
+ // Cap it at the foreground window so a large `timeout` — whose real purpose is to raise the
108
+ // wall-clock hard limit (see EvalDetachedCellManager) — frees the turn at the window instead of
109
+ // blocking the agent loop for its full duration. `on_timeout: "error"` (and print/json) keep the
110
+ // unclamped deadline, since there the cell is killed rather than detached.
111
+ const foregroundWindowMs = (options.foregroundWindowSeconds ?? DEFAULT_FOREGROUND_WINDOW_SECONDS) * 1_000;
112
+ const timeoutMs =
113
+ timeoutBehavior === "detach" ? Math.min(requestedTimeoutMs, foregroundWindowMs) : requestedTimeoutMs;
114
+ // A cell that pauses its watchdog for a host bridge call would otherwise wait the full pause grace
115
+ // (~10 min) before detaching; cap the grace at the foreground window too so the detach guarantee
116
+ // holds for bridge-parked cells. Error mode keeps the default grace (its timeout is the deadline).
105
117
  const bridgeAbortController = new AbortController();
106
118
  const cellSignal = AbortSignal.any([invocation.signal, bridgeAbortController.signal]);
107
119
  const bridgeContext: ExtensionContext = { ...invocation.ctx, signal: cellSignal };
@@ -130,6 +142,7 @@ async function runEvalCell(
130
142
  callerSignal: invocation.signal,
131
143
  cellId: invocation.cellId,
132
144
  timeoutMs,
145
+ ...(timeoutBehavior === "detach" ? { maxPauseGraceMs: foregroundWindowMs } : {}),
133
146
  timeoutFactory: options.timeoutFactory ?? defaultTimeoutFactory,
134
147
  onTimeout: (error) => {
135
148
  if (timeoutBehavior === "detach" && cellManager.detach(cell)) {
package/src/tool/types.ts CHANGED
@@ -13,6 +13,12 @@ export function enabledLanguageList(enabled: EnabledEvalLanguages): EvalLanguage
13
13
 
14
14
  export const EVAL_SUMMARY_MAX_LENGTH = 80;
15
15
 
16
+ const TIMEOUT_FIELD_DESCRIPTION =
17
+ "Seconds the cell may block the turn before it detaches, and the amount by which it raises the wall-clock hard limit. In interactive sessions the detach point is capped at the foreground window (default 60s), so a large value frees the turn at the window while the cell keeps running; on_timeout:'error' (and print/json) keep the full value as the uncapped deadline.";
18
+
19
+ const ON_TIMEOUT_FIELD_DESCRIPTION =
20
+ "Timeout behavior. Interactive sessions detach by default (at the foreground window); print/json sessions error by default. 'error' uses the full timeout as an uncapped deadline.";
21
+
16
22
  export interface EvalToolInput {
17
23
  readonly language: EvalLanguage;
18
24
  readonly code: string;
@@ -47,10 +53,10 @@ const fullEvalInputSchema = Type.Object({
47
53
  "REQUIRED for run. ONE line in the USER'S conversational language (Korean conversation -> Korean summary) stating WHAT this cell does and FOR WHAT PURPOSE; shown in the TUI while the cell runs. Longer values are force-truncated to 80 chars.",
48
54
  }),
49
55
  ),
50
- timeout: Type.Optional(Type.Number({ minimum: 1, description: "Timeout in seconds." })),
56
+ timeout: Type.Optional(Type.Number({ minimum: 1, description: TIMEOUT_FIELD_DESCRIPTION })),
51
57
  on_timeout: Type.Optional(
52
58
  Type.Union([Type.Literal("detach"), Type.Literal("error")], {
53
- description: "Timeout behavior. Interactive sessions detach by default; print/json sessions error by default.",
59
+ description: ON_TIMEOUT_FIELD_DESCRIPTION,
54
60
  }),
55
61
  ),
56
62
  reset: Type.Optional(Type.Boolean({ description: "Reset this language kernel before running." })),
@@ -83,11 +89,10 @@ export function createEvalInputSchema(enabled: EnabledEvalLanguages): EvalInputS
83
89
  "REQUIRED for run. ONE line in the USER'S conversational language (Korean conversation -> Korean summary) stating WHAT this cell does and FOR WHAT PURPOSE; shown in the TUI while the cell runs. Longer values are force-truncated to 80 chars.",
84
90
  }),
85
91
  ),
86
- timeout: Type.Optional(Type.Number({ minimum: 1, description: "Timeout in seconds." })),
92
+ timeout: Type.Optional(Type.Number({ minimum: 1, description: TIMEOUT_FIELD_DESCRIPTION })),
87
93
  on_timeout: Type.Optional(
88
94
  Type.Union([Type.Literal("detach"), Type.Literal("error")], {
89
- description:
90
- "Timeout behavior. Interactive sessions detach by default; print/json sessions error by default.",
95
+ description: ON_TIMEOUT_FIELD_DESCRIPTION,
91
96
  }),
92
97
  ),
93
98
  reset: Type.Optional(Type.Boolean({ description: "Reset this language kernel before running." })),