@code-yeongyu/senpi-codemode 2026.9.9-2 → 2026.9.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,36 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.9.10-2] - 2026-09-10
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ ### Fixed
24
+
25
+ ### Removed
26
+
27
+ ## [2026.9.10] - 2026-09-10
28
+
29
+ ### Breaking Changes
30
+
31
+ - The eval `timeout` argument is now the cell's run budget (a kill deadline for the cell's own execution time) instead of the interactive detach budget; interactive calls detach at `cellTimeoutSeconds` capped by `foregroundWindowSeconds` regardless of `timeout`, and print/json calls are bounded by the run budget instead of a `cellTimeoutSeconds` idle kill.
32
+
33
+ ### Added
34
+
35
+ - Every eval cell carries a run budget (`runBudgetSeconds`, default 300s, env `SENPI_CODEMODE_RUN_BUDGET_SECONDS`, per-call `timeout`) that charges only its own execution time, is paused while a host tool call is in flight, keeps counting after detach, and kills the cell through the cooperative interrupt path with a result or notification that names the exhausted budget and the kernel-state outcome.
36
+
37
+ ### Changed
38
+
39
+ - The eval tool schema and description state the configured run budget, detach point, and hard limit, and say that a killed JavaScript cell that cannot settle restarts its kernel and loses every global.
40
+
41
+ ### Fixed
42
+
43
+ ### Removed
44
+
15
45
  ## [2026.9.9-2] - 2026-09-09
16
46
 
17
47
  ### Breaking Changes
package/README.md CHANGED
@@ -85,6 +85,8 @@ Configuration is loaded in this order:
85
85
  },
86
86
  "cellTimeoutSeconds": 30,
87
87
  "foregroundWindowSeconds": 60,
88
+ "runBudgetSeconds": 300,
89
+ "hardLimitSeconds": 1800,
88
90
  "parallelPoolWidth": 4,
89
91
  "taskTools": {
90
92
  "task": "task",
@@ -101,8 +103,10 @@ Configuration is loaded in this order:
101
103
  | Key | Default | Effect |
102
104
  | --- | --- | --- |
103
105
  | `languages` | `py`/`js` enabled; `rb`/`jl` disabled | Selects desired languages before interpreter detection. |
104
- | `cellTimeoutSeconds` | `30` | Idle timeout for one cell unless the call supplies `timeout`; interactive calls detach by default and print/json calls error. |
105
- | `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`. |
106
+ | `cellTimeoutSeconds` | `30` | Idle time an interactive call blocks the turn before the cell detaches. Print/json calls never detach. |
107
+ | `foregroundWindowSeconds` | `60` | Caps `cellTimeoutSeconds` and the grace a bridge-parked cell gets before it detaches, so an interactive call never blocks the turn longer than this. Env override: `SENPI_CODEMODE_FOREGROUND_SECONDS`. |
108
+ | `runBudgetSeconds` | `300` | Kill deadline for a cell's own execution time - child processes, network, timers, CPU. Time parked in host tool calls (`agent()`, `tool.*`) is not charged, and the budget keeps counting after the cell detaches. A per-call `timeout` replaces it for that cell. Env override: `SENPI_CODEMODE_RUN_BUDGET_SECONDS`. |
109
+ | `hardLimitSeconds` | `1800` | Wall-clock kill deadline for a cell, parked or not; a per-call `timeout` above it raises it. Env override: `SENPI_CODEMODE_HARD_LIMIT_SECONDS`. |
106
110
  | `parallelPoolWidth` | `4` | Maximum concurrent `parallel()` thunks. |
107
111
  | `taskTools.task` | `"task"` | Registered tool name used by `agent()`. |
108
112
  | `taskTools.output` | `"task_output"` | Registered tool name used by `output()`. |
@@ -173,6 +177,17 @@ cell keeps only its own language kernel busy. A new same-language call returns
173
177
  a busy error with its cell id and output tail; calls in other languages continue
174
178
  normally. Do not re-run the cell.
175
179
 
180
+ Every cell, detached or not, is bounded by two kill deadlines. The run budget
181
+ (`runBudgetSeconds`, or the call's `timeout`) charges only the cell's own
182
+ execution time and is paused while a host tool call is in flight, so a cell
183
+ waiting on `agent()` survives while a runaway child process or loop does not.
184
+ The hard limit (`hardLimitSeconds`, raised by a larger `timeout`) is wall-clock
185
+ and bounds parked cells too. A cell killed by either deadline reports which one
186
+ in its result or completion notification, together with whether kernel state
187
+ survived; the tool schema states the configured numbers. The `timeout` value
188
+ never changes when an interactive call detaches: that is `cellTimeoutSeconds`
189
+ capped by `foregroundWindowSeconds`.
190
+
176
191
  While any cell is detached, the interactive footer shows a highlighted
177
192
  `↗ <language> · <summary>` status on the extension status line (the cell id
178
193
  when the call had no summary), clearing as soon as the last detached cell settles.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.9.9-2",
3
+ "version": "2026.9.10-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.9-2",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.10-2",
34
34
  "typebox": "1.3.18"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.9.9-2"
37
+ "@code-yeongyu/senpi": "2026.9.10-2"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.9.9-2"
40
+ "@code-yeongyu/senpi": "2026.9.10-2"
41
41
  },
42
42
  "keywords": [
43
43
  "senpi",
@@ -20,6 +20,7 @@ export const codemodeSettingsSchema = Type.Object(
20
20
  ),
21
21
  cellTimeoutSeconds: Type.Optional(Type.Number({ minimum: 1 })),
22
22
  foregroundWindowSeconds: Type.Optional(Type.Number({ minimum: 1 })),
23
+ runBudgetSeconds: Type.Optional(Type.Number({ minimum: 1 })),
23
24
  hardLimitSeconds: Type.Optional(Type.Number({ minimum: 1 })),
24
25
  parallelPoolWidth: Type.Optional(Type.Number({ minimum: 1 })),
25
26
  taskTools: Type.Optional(
@@ -64,15 +65,20 @@ export interface CodemodeSettings {
64
65
  readonly rb: boolean;
65
66
  readonly jl: boolean;
66
67
  };
68
+ /** Idle time an interactive call blocks the turn before the cell detaches; capped by the foreground window. */
67
69
  readonly cellTimeoutSeconds: number;
68
70
  /**
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.
71
+ * Longest an interactive eval call blocks the agent loop before the cell detaches, capping
72
+ * `cellTimeoutSeconds` and the bridge-parked grace. A still-running cell keeps living up to its
73
+ * run budget and the hard limit; this only frees the turn. Print/json calls never detach.
73
74
  */
74
75
  readonly foregroundWindowSeconds: number;
75
- /** Wall-clock kill deadline for a single cell; bounds detached cells too. */
76
+ /**
77
+ * Kill deadline for a cell's own execution time — child processes, network, timers, CPU — with
78
+ * time parked on host tool calls excluded. A per-call `timeout` replaces it for that cell.
79
+ */
80
+ readonly runBudgetSeconds: number;
81
+ /** Wall-clock kill deadline for a single cell; bounds detached and bridge-parked cells too. */
76
82
  readonly hardLimitSeconds: number;
77
83
  readonly parallelPoolWidth: number;
78
84
  readonly taskTools?: CodemodeTaskTools;
@@ -116,6 +122,15 @@ export const DEFAULT_FOREGROUND_WINDOW_SECONDS = 60;
116
122
 
117
123
  export const FOREGROUND_WINDOW_ENVIRONMENT_FLAG = "SENPI_CODEMODE_FOREGROUND_SECONDS";
118
124
 
125
+ /**
126
+ * One language kernel runs one cell at a time and a killed JavaScript cell that cannot settle
127
+ * cooperatively restarts its worker, so a runaway cell costs far more than a runaway bash command:
128
+ * five minutes of own execution time is the default before the cell is killed.
129
+ */
130
+ export const DEFAULT_RUN_BUDGET_SECONDS = 300;
131
+
132
+ export const RUN_BUDGET_ENVIRONMENT_FLAG = "SENPI_CODEMODE_RUN_BUDGET_SECONDS";
133
+
119
134
  // OMP settings-schema.ts:3211-3299 has language/path settings only; eval.ts:427
120
135
  // defaults timeout to 30s, and codemode pins concurrency-bridge.ts:30 width to 4.
121
136
  export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
@@ -127,6 +142,7 @@ export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
127
142
  },
128
143
  cellTimeoutSeconds: 30,
129
144
  foregroundWindowSeconds: DEFAULT_FOREGROUND_WINDOW_SECONDS,
145
+ runBudgetSeconds: DEFAULT_RUN_BUDGET_SECONDS,
130
146
  hardLimitSeconds: DEFAULT_HARD_LIMIT_SECONDS,
131
147
  parallelPoolWidth: 4,
132
148
  taskTools: {
@@ -178,20 +194,23 @@ export function resolveEnabledLanguages(
178
194
 
179
195
  /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
180
196
  export function resolveHardLimitSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
181
- const override = env[HARD_LIMIT_ENVIRONMENT_FLAG];
182
- if (override === undefined) return settings.hardLimitSeconds;
183
- const parsed = Number.parseInt(override, 10);
184
- if (!Number.isFinite(parsed) || parsed <= 0) return settings.hardLimitSeconds;
185
- return parsed;
197
+ return positiveSecondsOverride(env[HARD_LIMIT_ENVIRONMENT_FLAG]) ?? settings.hardLimitSeconds;
186
198
  }
187
199
 
188
200
  /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
189
201
  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;
202
+ return positiveSecondsOverride(env[FOREGROUND_WINDOW_ENVIRONMENT_FLAG]) ?? settings.foregroundWindowSeconds;
203
+ }
204
+
205
+ /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
206
+ export function resolveRunBudgetSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
207
+ return positiveSecondsOverride(env[RUN_BUDGET_ENVIRONMENT_FLAG]) ?? settings.runBudgetSeconds;
208
+ }
209
+
210
+ function positiveSecondsOverride(value: string | undefined): number | undefined {
211
+ if (value === undefined) return undefined;
212
+ const parsed = Number.parseInt(value, 10);
213
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
195
214
  }
196
215
 
197
216
  async function loadSettingsFile(path: string): Promise<LoadedCodemodeSettings> {
@@ -229,6 +248,7 @@ function mergeSettings(input: CodemodeSettingsInput): ResolvedCodemodeSettings {
229
248
  },
230
249
  cellTimeoutSeconds: input.cellTimeoutSeconds ?? defaultCodemodeSettings.cellTimeoutSeconds,
231
250
  foregroundWindowSeconds: input.foregroundWindowSeconds ?? defaultCodemodeSettings.foregroundWindowSeconds,
251
+ runBudgetSeconds: input.runBudgetSeconds ?? defaultCodemodeSettings.runBudgetSeconds,
232
252
  hardLimitSeconds: input.hardLimitSeconds ?? defaultCodemodeSettings.hardLimitSeconds,
233
253
  parallelPoolWidth: input.parallelPoolWidth ?? defaultCodemodeSettings.parallelPoolWidth,
234
254
  taskTools: {
package/src/index.ts CHANGED
@@ -3,7 +3,12 @@ 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, resolveForegroundWindowSeconds, resolveHardLimitSeconds } from "./config/settings.ts";
6
+ import {
7
+ defaultCodemodeSettings,
8
+ resolveForegroundWindowSeconds,
9
+ resolveHardLimitSeconds,
10
+ resolveRunBudgetSeconds,
11
+ } from "./config/settings.ts";
7
12
  import { EvalNotifier } from "./extension/eval-notifier.ts";
8
13
  import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
9
14
  import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
@@ -128,6 +133,8 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
128
133
  kernelManager: manager,
129
134
  cellTimeoutSeconds: runtime.settings.cellTimeoutSeconds,
130
135
  foregroundWindowSeconds: resolveForegroundWindowSeconds(runtime.settings),
136
+ runBudgetSeconds: resolveRunBudgetSeconds(runtime.settings),
137
+ hardLimitSeconds: resolveHardLimitSeconds(runtime.settings),
131
138
  executeTool: runtime.executeTool,
132
139
  listTools: () => pi.getAllTools(),
133
140
  complete,
@@ -163,6 +170,8 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
163
170
  kernelManager: manager,
164
171
  cellTimeoutSeconds: defaultCodemodeSettings.cellTimeoutSeconds,
165
172
  foregroundWindowSeconds: resolveForegroundWindowSeconds(defaultCodemodeSettings),
173
+ runBudgetSeconds: resolveRunBudgetSeconds(defaultCodemodeSettings),
174
+ hardLimitSeconds: resolveHardLimitSeconds(defaultCodemodeSettings),
166
175
  executeTool: createExecuteTool(pi),
167
176
  listTools: () => pi.getAllTools(),
168
177
  complete,
@@ -170,6 +179,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
170
179
  cellManager: new EvalDetachedCellManager({
171
180
  notifier,
172
181
  hardLimitSeconds: resolveHardLimitSeconds(defaultCodemodeSettings),
182
+ runBudgetSeconds: resolveRunBudgetSeconds(defaultCodemodeSettings),
173
183
  onStatusChange: showDetachedCells,
174
184
  onWakeSourceState: emitWakeSourceState,
175
185
  ...(options.now === undefined ? {} : { now: options.now }),
@@ -185,7 +195,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
185
195
  );
186
196
  pi.registerRemovedToolHint(
187
197
  "exec",
188
- 'exec was removed; use eval({ language: "js", code }) instead. Long eval cells detach on timeout and notify when complete.',
198
+ 'exec was removed; use eval({ language: "js", code }) instead. Long eval cells detach on their own and notify when complete.',
189
199
  );
190
200
  pi.registerRemovedToolHint(
191
201
  "wait",
@@ -207,6 +217,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
207
217
  artifactsDir: runtime.artifactsDir,
208
218
  notifier,
209
219
  hardLimitSeconds: resolveHardLimitSeconds(runtime.settings),
220
+ runBudgetSeconds: resolveRunBudgetSeconds(runtime.settings),
210
221
  onStatusChange: showDetachedCells,
211
222
  onWakeSourceState: emitWakeSourceState,
212
223
  ...(options.now === undefined ? {} : { now: options.now }),
@@ -0,0 +1,76 @@
1
+ export const EVAL_PROMPT_TEMPLATE = `Run one step of code in a persistent kernel.
2
+
3
+ <instruction>
4
+ **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.
5
+
6
+ {{#if styleClaude}}<eval_first_batching>
7
+ Batch a step's independent calls in one cell with \`parallel(thunks)\`; write real code around them - loops, branches, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
8
+ {{#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}}
9
+ </eval_first_batching>{{/if}}{{#if styleGpt}}<gpt_eval_dialect>
10
+ GPT eval: batch a step's independent tool calls in one cell with \`tool.<name>(args)\` and \`parallel(thunks)\` and inspect every result.
11
+ {{#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.
12
+ {{/if}}- Long cells detach on their own and notify on completion; do not poll or re-run them.
13
+ - Keep every failed or missing item in the result verbatim and re-read truncated output before deciding.
14
+ </gpt_eval_dialect>{{/if}}{{#if styleCodex}}Route a step's independent lookups through one eval cell via \`parallel(thunks)\` and inspect every result.
15
+ - Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically.
16
+ - Wrap failable calls in try/except inside the cell and keep every failed item in the result verbatim; after two distinct failed strategies for the same fact, fall back to direct tool calls.
17
+ - Re-read truncated output before deciding on it.
18
+ {{#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}}Put a step's independent calls into one cell with \`parallel(thunks)\`.
19
+ - Write real code around the calls - loops, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
20
+ {{#if monitor}}- 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}}Batch a step's independent calls in one cell with \`parallel(thunks)\`.
21
+ - Write real code around the calls - loops, branches, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
22
+ {{#if monitor}}- Long-running build, test run, deploy, or watch work starts with \`tool.monitor({ command, filter })\`, with the decisive-line filter inside the same cell; keep working until its event wakes the turn.{{/if}}{{/if}}
23
+ {{#if hostLine}}
24
+ 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.
25
+ {{/if}}
26
+
27
+ \`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}}.
28
+
29
+ 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. Its own execution time is capped at {{runBudgetSeconds}}s — time inside host tool calls is not charged; raise \`timeout\` only for a declared long run — and a killed js cell that cannot settle restarts its kernel with every global lost. Do not re-run a detached cell; read or cancel it with \`eval({ action: "peek", cell_id })\` / \`eval({ action: "stop", cell_id })\`.
30
+
31
+ {{#if py}}Python runs on a live event loop: use top-level \`await\`; \`asyncio.run(…)\` raises.{{/if}}
32
+ {{#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 and cannot be interrupted.{{#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}}
33
+ {{#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}}
34
+ {{#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}}
35
+ 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.
36
+ </instruction>
37
+
38
+ <prelude>
39
+ {{#ifAll py js}}Same helpers + arg order, both runtimes. Python: sync, options = trailing kwargs. JS: async/\`await\`able, options = ONE trailing object literal, never positional (extras throw).{{else}}{{#if py}}Sync; options = trailing kwargs.{{/if}}{{#if js}}Async/\`await\`able; options = ONE trailing object literal, never positional (extras throw).{{/if}}{{/ifAll}}{{#if rb}} Ruby: sync, options = trailing keyword args.{{/if}}{{#if jl}} Julia: sync, options = trailing keyword args.{{/if}}
40
+ \`\`\`
41
+ display(value) → None
42
+ Cell output. Images reach you only through display: pass a figure, image bytes, a tool result, or its \`images[i]\`.
43
+ print(value, ...) → None
44
+ Text output.
45
+ read(path, offset?=1, limit?=None) → str
46
+ File as text; offset/limit are 1-indexed lines. Accepts \`local://…\`.
47
+ write(path, content) → str
48
+ Write file (creates parents) → resolved path. \`local://…\` persists across turns/subagents.
49
+ env(key?=None, value?=None) → str | None | dict
50
+ No args → full env dict; one → value; two → set \`key=value\`.
51
+ {{#if spawns}}output(*ids, format?="raw", offset?=None, limit?=None) → str | dict | list[dict]
52
+ Task/agent output by id. Reads immediately: running tasks return their status; \`format\` \`"raw"\` = full, \`"tail"\` = trailing.
53
+ {{/if}}tool.<name>(args) → { text, images?, details?, hasError? }
54
+ Invoke any session tool; image results (e.g. \`tool.read\` on a png) arrive in \`images[i]\` as { mimeType, dataBase64 }.
55
+ tool_schema(name?) → dict
56
+ Parameter schema of a tool (omit \`name\` to list tool names); a failed \`tool.<name>()\` call also returns the expected parameters.
57
+ completion(prompt, model?="default", system?=None, schema?=None) → str | dict
58
+ Oneshot, stateless. \`model\`: \`"smol"\` fast | \`"default"\` session | \`"slow"\` most capable. \`schema\` (JSON-Schema) → parsed structured output.
59
+ {{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
60
+ 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\`).
61
+ {{/if}}parallel(thunks) → list
62
+ Thunks through a bounded pool (as wide as a \`task\` batch), input order kept; a throwing thunk propagates.
63
+ pipeline(items, ...stages) → list
64
+ Map items through one-arg stages with a barrier between stages; each stage receives the previous stage's result.
65
+ log(message) → None
66
+ Progress line above the status tree.
67
+ phase(title) → None
68
+ Phase grouping subsequent status lines.
69
+ \`\`\`
70
+ </prelude>
71
+ {{#if spawns}}
72
+ <workflow>
73
+ 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.
74
+ </workflow>
75
+ {{/if}}
76
+ `;
@@ -1,4 +1,6 @@
1
+ import { DEFAULT_RUN_BUDGET_SECONDS } from "../config/settings.ts";
1
2
  import type { EvalRuntimeInfo } from "../tool/types.ts";
3
+ import { EVAL_PROMPT_TEMPLATE } from "./eval-prompt-template.ts";
2
4
 
3
5
  export interface EnabledLanguages {
4
6
  readonly py: boolean;
@@ -26,6 +28,8 @@ export interface EvalPromptOptions {
26
28
  readonly jsRuntime?: EvalRuntimeInfo;
27
29
  /** Absolute path of the active bun-1-4 skill; rendered as a MUST READ pointer only on a bun kernel. */
28
30
  readonly bunSkillPath?: string;
31
+ /** Kill deadline for a cell's own execution time, as configured; the description states it. */
32
+ readonly runBudgetSeconds?: number;
29
33
  }
30
34
 
31
35
  /** Prompt dialect for the eval-first batching emphasis. */
@@ -64,82 +68,6 @@ export function evalEmphasisStyle(modelId: string | undefined): EvalEmphasisStyl
64
68
 
65
69
  type ContextValue = string | boolean;
66
70
  type Context = Readonly<Record<string, ContextValue>>;
67
- const EVAL_PROMPT_TEMPLATE = `Run one step of code in a persistent kernel.
68
-
69
- <instruction>
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.
71
-
72
- {{#if styleClaude}}<eval_first_batching>
73
- Batch a step's independent calls in one cell with \`parallel(thunks)\`; write real code around them - loops, branches, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
74
- {{#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}}
75
- </eval_first_batching>{{/if}}{{#if styleGpt}}<gpt_eval_dialect>
76
- GPT eval: batch a step's independent tool calls in one cell with \`tool.<name>(args)\` and \`parallel(thunks)\` and inspect every result.
77
- {{#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.
78
- {{/if}}- Long cells detach on timeout and notify on completion; do not poll or re-run them.
79
- - Keep every failed or missing item in the result verbatim and re-read truncated output before deciding.
80
- </gpt_eval_dialect>{{/if}}{{#if styleCodex}}Route a step's independent lookups through one eval cell via \`parallel(thunks)\` and inspect every result.
81
- - Loop or comprehend over file sets with \`read()\`/stdlib instead of reading files one call at a time; post-process \`tool.<name>()\` results programmatically.
82
- - Wrap failable calls in try/except inside the cell and keep every failed item in the result verbatim; after two distinct failed strategies for the same fact, fall back to direct tool calls.
83
- - Re-read truncated output before deciding on it.
84
- {{#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}}Put a step's independent calls into one cell with \`parallel(thunks)\`.
85
- - Write real code around the calls - loops, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
86
- {{#if monitor}}- 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}}Batch a step's independent calls in one cell with \`parallel(thunks)\`.
87
- - Write real code around the calls - loops, branches, joins, a try/except per risky item - and keep every failed or missing item in the result verbatim; re-read truncated output before deciding.
88
- {{#if monitor}}- Long-running build, test run, deploy, or watch work starts with \`tool.monitor({ command, filter })\`, with the decisive-line filter inside the same cell; keep working until its event wakes the turn.{{/if}}{{/if}}
89
- {{#if hostLine}}
90
- 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.
91
- {{/if}}
92
-
93
- \`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}}.
94
-
95
- 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 })\`.
96
-
97
- {{#if py}}Python runs on a live event loop: use top-level \`await\`; \`asyncio.run(…)\` raises.{{/if}}
98
- {{#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}}
99
- {{#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}}
100
- {{#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}}
101
- 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.
102
- </instruction>
103
-
104
- <prelude>
105
- {{#ifAll py js}}Same helpers + arg order, both runtimes. Python: sync, options = trailing kwargs. JS: async/\`await\`able, options = ONE trailing object literal, never positional (extras throw).{{else}}{{#if py}}Sync; options = trailing kwargs.{{/if}}{{#if js}}Async/\`await\`able; options = ONE trailing object literal, never positional (extras throw).{{/if}}{{/ifAll}}{{#if rb}} Ruby: sync, options = trailing keyword args.{{/if}}{{#if jl}} Julia: sync, options = trailing keyword args.{{/if}}
106
- \`\`\`
107
- display(value) → None
108
- Cell output. Images reach you only through display: pass a figure, image bytes, a tool result, or its \`images[i]\`.
109
- print(value, ...) → None
110
- Text output.
111
- read(path, offset?=1, limit?=None) → str
112
- File as text; offset/limit are 1-indexed lines. Accepts \`local://…\`.
113
- write(path, content) → str
114
- Write file (creates parents) → resolved path. \`local://…\` persists across turns/subagents.
115
- env(key?=None, value?=None) → str | None | dict
116
- No args → full env dict; one → value; two → set \`key=value\`.
117
- {{#if spawns}}output(*ids, format?="raw", offset?=None, limit?=None) → str | dict | list[dict]
118
- Task/agent output by id. Reads immediately: running tasks return their status; \`format\` \`"raw"\` = full, \`"tail"\` = trailing.
119
- {{/if}}tool.<name>(args) → { text, images?, details?, hasError? }
120
- Invoke any session tool; image results (e.g. \`tool.read\` on a png) arrive in \`images[i]\` as { mimeType, dataBase64 }.
121
- tool_schema(name?) → dict
122
- Parameter schema of a tool (omit \`name\` to list tool names); a failed \`tool.<name>()\` call also returns the expected parameters.
123
- completion(prompt, model?="default", system?=None, schema?=None) → str | dict
124
- Oneshot, stateless. \`model\`: \`"smol"\` fast | \`"default"\` session | \`"slow"\` most capable. \`schema\` (JSON-Schema) → parsed structured output.
125
- {{#if spawns}}agent(prompt, agent?="{{spawnDefaultAgent}}", model?=None, label?=None, schema?=None, handle?=False) → str | dict
126
- 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\`).
127
- {{/if}}parallel(thunks) → list
128
- Thunks through a bounded pool (as wide as a \`task\` batch), input order kept; a throwing thunk propagates.
129
- pipeline(items, ...stages) → list
130
- Map items through one-arg stages with a barrier between stages; each stage receives the previous stage's result.
131
- log(message) → None
132
- Progress line above the status tree.
133
- phase(title) → None
134
- Phase grouping subsequent status lines.
135
- \`\`\`
136
- </prelude>
137
- {{#if spawns}}
138
- <workflow>
139
- 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.
140
- </workflow>
141
- {{/if}}
142
- `;
143
71
 
144
72
  export function buildEvalPrompt(
145
73
  enabled: EnabledLanguages,
@@ -167,6 +95,7 @@ export function buildEvalPrompt(
167
95
  jsBun: options.jsRuntime?.name === "bun",
168
96
  jsVersion: options.jsRuntime?.version ?? "",
169
97
  bunSkillPath: options.bunSkillPath ?? "",
98
+ runBudgetSeconds: String(options.runBudgetSeconds ?? DEFAULT_RUN_BUDGET_SECONDS),
170
99
  };
171
100
  const description = renderTemplate(EVAL_PROMPT_TEMPLATE, context)
172
101
  .replace(/\n{3,}/g, "\n\n")
@@ -197,7 +126,7 @@ const BATCHING_GUIDELINES: Record<EvalEmphasisStyle, string> = {
197
126
  claude:
198
127
  "Prefer eval for a step's independent calls: one cell runs them together and keeps every failure in its result.",
199
128
  codex: "Route a step's independent calls through one eval cell and inspect every result; a direct tool call is right when one call is sufficient.",
200
- gpt: "Use eval to batch a step's independent tool calls in one cell and inspect every result; long cells detach on timeout and notify on completion, so do not poll.",
129
+ gpt: "Use eval to batch a step's independent tool calls in one cell and inspect every result; long cells detach on their own and notify on completion, so do not poll.",
201
130
  kimi: "Put a step's independent calls into one eval cell with parallel(thunks) and keep every failed item in the result.",
202
131
  };
203
132
 
@@ -0,0 +1,107 @@
1
+ import type { TimeoutPauseHandle } from "./idle-timeout.ts";
2
+
3
+ export interface RunBudgetEvent {
4
+ readonly cellId: string;
5
+ readonly budgetMs: number;
6
+ readonly error: Error;
7
+ }
8
+
9
+ export interface RunBudgetOptions {
10
+ readonly cellId: string;
11
+ readonly budgetMs: number;
12
+ readonly onExhausted: (event: RunBudgetEvent) => void;
13
+ }
14
+
15
+ export function runBudgetError(cellId: string, budgetSeconds: number): Error {
16
+ const error = new Error(
17
+ `Eval cell ${cellId} exhausted its ${budgetSeconds}s run budget (own execution time; host tool calls excluded) and was killed.`,
18
+ );
19
+ error.name = "TimeoutError";
20
+ return error;
21
+ }
22
+
23
+ /**
24
+ * Bounds a cell's own execution time. Unlike the idle watchdog it accumulates across host tool
25
+ * calls instead of restarting after each one, and time spent parked on a bridge call is never
26
+ * charged, so a long `agent()` wait survives while a runaway loop or child process does not.
27
+ */
28
+ export class RunBudget implements TimeoutPauseHandle {
29
+ readonly budgetMs: number;
30
+ readonly #cellId: string;
31
+ readonly #onExhausted: (event: RunBudgetEvent) => void;
32
+ #chargedMs = 0;
33
+ #runningSinceMs: number | undefined;
34
+ #pauseDepth = 0;
35
+ #timer: ReturnType<typeof setTimeout> | undefined;
36
+ #settled = false;
37
+
38
+ constructor(options: RunBudgetOptions) {
39
+ this.#cellId = options.cellId;
40
+ this.budgetMs = Math.max(1, Math.floor(options.budgetMs));
41
+ this.#onExhausted = options.onExhausted;
42
+ this.#start();
43
+ }
44
+
45
+ get consumedMs(): number {
46
+ const running = this.#runningSinceMs === undefined ? 0 : Date.now() - this.#runningSinceMs;
47
+ return this.#chargedMs + running;
48
+ }
49
+
50
+ pause(): void {
51
+ if (this.#settled) return;
52
+ this.#pauseDepth++;
53
+ if (this.#pauseDepth !== 1) return;
54
+ this.#chargedMs = this.consumedMs;
55
+ this.#runningSinceMs = undefined;
56
+ this.#clearTimer();
57
+ }
58
+
59
+ resume(): void {
60
+ if (this.#settled || this.#pauseDepth === 0) return;
61
+ this.#pauseDepth--;
62
+ if (this.#pauseDepth > 0) return;
63
+ this.#start();
64
+ }
65
+
66
+ dispose(): void {
67
+ if (this.#settled) return;
68
+ this.#settled = true;
69
+ this.#clearTimer();
70
+ }
71
+
72
+ #start(): void {
73
+ this.#runningSinceMs = Date.now();
74
+ this.#arm(this.budgetMs - this.#chargedMs);
75
+ }
76
+
77
+ #arm(delayMs: number): void {
78
+ this.#clearTimer();
79
+ const timer = setTimeout(() => this.#expire(), Math.max(0, delayMs));
80
+ timer.unref?.();
81
+ this.#timer = timer;
82
+ }
83
+
84
+ #clearTimer(): void {
85
+ if (this.#timer === undefined) return;
86
+ clearTimeout(this.#timer);
87
+ this.#timer = undefined;
88
+ }
89
+
90
+ #expire(): void {
91
+ this.#timer = undefined;
92
+ if (this.#settled || this.#pauseDepth > 0) return;
93
+ const remainingMs = this.budgetMs - this.consumedMs;
94
+ if (remainingMs > 0) {
95
+ this.#arm(remainingMs);
96
+ return;
97
+ }
98
+ this.#settled = true;
99
+ this.#chargedMs = this.consumedMs;
100
+ this.#runningSinceMs = undefined;
101
+ this.#onExhausted({
102
+ cellId: this.#cellId,
103
+ budgetMs: this.budgetMs,
104
+ error: runBudgetError(this.#cellId, this.budgetMs / 1_000),
105
+ });
106
+ }
107
+ }
@@ -0,0 +1,76 @@
1
+ import type { TimeoutPauseHandle } from "../timeouts/idle-timeout.ts";
2
+ import { RunBudget } from "../timeouts/run-budget.ts";
3
+
4
+ export type CellDeadlineKind = "hard-limit" | "run-budget";
5
+
6
+ export interface CellDeadlineExpiry {
7
+ readonly kind: CellDeadlineKind;
8
+ readonly error: Error;
9
+ }
10
+
11
+ export interface CellDeadlinesOptions {
12
+ readonly cellId: string;
13
+ readonly hardLimitSeconds: number;
14
+ readonly runBudgetSeconds: number;
15
+ readonly onExpire: (expiry: CellDeadlineExpiry) => void;
16
+ }
17
+
18
+ export function hardLimitError(cellId: string, hardLimitSeconds: number): Error {
19
+ const error = new Error(`Eval cell ${cellId} was killed at the ${hardLimitSeconds}s hard limit.`);
20
+ error.name = "TimeoutError";
21
+ return error;
22
+ }
23
+
24
+ /**
25
+ * The two kill deadlines every cell carries from creation to settlement, detached or not: the
26
+ * wall-clock hard limit, which nothing pauses, and the run budget, which charges only the cell's
27
+ * own execution time and is paused while a host bridge call is in flight. Whichever expires first
28
+ * ends the cell; the other is disarmed with it.
29
+ */
30
+ export class CellDeadlines implements TimeoutPauseHandle {
31
+ readonly hardLimitSeconds: number;
32
+ readonly runBudgetSeconds: number;
33
+ readonly #onExpire: (expiry: CellDeadlineExpiry) => void;
34
+ readonly #runBudget: RunBudget;
35
+ #hardLimitTimer: ReturnType<typeof setTimeout> | undefined;
36
+ #settled = false;
37
+
38
+ constructor(options: CellDeadlinesOptions) {
39
+ this.hardLimitSeconds = options.hardLimitSeconds;
40
+ this.runBudgetSeconds = options.runBudgetSeconds;
41
+ this.#onExpire = options.onExpire;
42
+ const hardLimitTimer = setTimeout(
43
+ () => this.#expire({ kind: "hard-limit", error: hardLimitError(options.cellId, options.hardLimitSeconds) }),
44
+ options.hardLimitSeconds * 1_000,
45
+ );
46
+ hardLimitTimer.unref?.();
47
+ this.#hardLimitTimer = hardLimitTimer;
48
+ this.#runBudget = new RunBudget({
49
+ cellId: options.cellId,
50
+ budgetMs: options.runBudgetSeconds * 1_000,
51
+ onExhausted: ({ error }) => this.#expire({ kind: "run-budget", error }),
52
+ });
53
+ }
54
+
55
+ pause(): void {
56
+ this.#runBudget.pause();
57
+ }
58
+
59
+ resume(): void {
60
+ this.#runBudget.resume();
61
+ }
62
+
63
+ clear(): void {
64
+ if (this.#settled) return;
65
+ this.#settled = true;
66
+ if (this.#hardLimitTimer !== undefined) clearTimeout(this.#hardLimitTimer);
67
+ this.#hardLimitTimer = undefined;
68
+ this.#runBudget.dispose();
69
+ }
70
+
71
+ #expire(expiry: CellDeadlineExpiry): void {
72
+ if (this.#settled) return;
73
+ this.clear();
74
+ this.#onExpire(expiry);
75
+ }
76
+ }