@code-yeongyu/senpi-codemode 2026.8.12 → 2026.8.13

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,56 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.8.13] - 2026-08-13
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - Gave every eval cell a wall-clock hard limit (`hardLimitSeconds`, default 1800s, overridable with `SENPI_CODEMODE_HARD_LIMIT_SECONDS`) so a detached or tool-call-heavy cell can no longer run unbounded: the deadline survives `detach()` and is never paused by bridge tool calls, and a cell it kills reports itself to the agent as killed at the hard limit ([#857](https://github.com/code-yeongyu/senpi/pull/857)).
22
+
23
+ ### Changed
24
+
25
+ ### Fixed
26
+
27
+ ### Removed
28
+
29
+ ## [2026.8.12-4] - 2026-08-12
30
+
31
+ ### Breaking Changes
32
+
33
+ ### Added
34
+
35
+ ### Changed
36
+
37
+ ### Fixed
38
+
39
+ ### Removed
40
+
41
+ ## [2026.8.12-3] - 2026-08-12
42
+
43
+ ### Breaking Changes
44
+
45
+ ### Added
46
+
47
+ ### Changed
48
+
49
+ ### Fixed
50
+
51
+ ### Removed
52
+
53
+ ## [2026.8.12-2] - 2026-08-12
54
+
55
+ ### Breaking Changes
56
+
57
+ ### Added
58
+
59
+ ### Changed
60
+
61
+ ### Fixed
62
+
63
+ ### Removed
64
+
15
65
  ## [2026.8.12] - 2026-08-12
16
66
 
17
67
  ### Breaking Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.8.12",
3
+ "version": "2026.8.13",
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.8.12",
33
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.8.13",
34
34
  "typebox": "1.3.8"
35
35
  },
36
36
  "peerDependencies": {
37
- "@code-yeongyu/senpi": "2026.8.12"
37
+ "@code-yeongyu/senpi": "2026.8.13"
38
38
  },
39
39
  "devDependencies": {
40
- "@code-yeongyu/senpi": "2026.8.12"
40
+ "@code-yeongyu/senpi": "2026.8.13"
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
+ hardLimitSeconds: Type.Optional(Type.Number({ minimum: 1 })),
22
23
  parallelPoolWidth: Type.Optional(Type.Number({ minimum: 1 })),
23
24
  taskTools: Type.Optional(
24
25
  Type.Object(
@@ -63,6 +64,8 @@ export interface CodemodeSettings {
63
64
  readonly jl: boolean;
64
65
  };
65
66
  readonly cellTimeoutSeconds: number;
67
+ /** Wall-clock kill deadline for a single cell; bounds detached cells too. */
68
+ readonly hardLimitSeconds: number;
66
69
  readonly parallelPoolWidth: number;
67
70
  readonly taskTools?: CodemodeTaskTools;
68
71
  readonly outputSink?: CodemodeOutputSink;
@@ -86,6 +89,15 @@ export interface LoadedCodemodeSettings {
86
89
  readonly warnings: readonly string[];
87
90
  }
88
91
 
92
+ /**
93
+ * Bash parity: `bash-timeout/timeout.ts` kills a command at 1800s. An eval cell gets the same
94
+ * unconditional wall-clock kill deadline, which — unlike `cellTimeoutSeconds` — is neither paused by
95
+ * host tool calls nor discarded when the cell detaches.
96
+ */
97
+ export const DEFAULT_HARD_LIMIT_SECONDS = 1800;
98
+
99
+ export const HARD_LIMIT_ENVIRONMENT_FLAG = "SENPI_CODEMODE_HARD_LIMIT_SECONDS";
100
+
89
101
  // OMP settings-schema.ts:3211-3299 has language/path settings only; eval.ts:427
90
102
  // defaults timeout to 30s, and codemode pins concurrency-bridge.ts:30 width to 4.
91
103
  export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
@@ -96,6 +108,7 @@ export const defaultCodemodeSettings: ResolvedCodemodeSettings = {
96
108
  jl: false,
97
109
  },
98
110
  cellTimeoutSeconds: 30,
111
+ hardLimitSeconds: DEFAULT_HARD_LIMIT_SECONDS,
99
112
  parallelPoolWidth: 4,
100
113
  taskTools: {
101
114
  task: "task",
@@ -144,6 +157,15 @@ export function resolveEnabledLanguages(
144
157
  };
145
158
  }
146
159
 
160
+ /** Environment override wins over the settings file; a non-positive or malformed value is ignored. */
161
+ export function resolveHardLimitSeconds(settings: CodemodeSettings, env: Environment = process.env): number {
162
+ const override = env[HARD_LIMIT_ENVIRONMENT_FLAG];
163
+ if (override === undefined) return settings.hardLimitSeconds;
164
+ const parsed = Number.parseInt(override, 10);
165
+ if (!Number.isFinite(parsed) || parsed <= 0) return settings.hardLimitSeconds;
166
+ return parsed;
167
+ }
168
+
147
169
  async function loadSettingsFile(path: string): Promise<LoadedCodemodeSettings> {
148
170
  const raw = await readFile(path, "utf8");
149
171
  let parsed: unknown;
@@ -178,6 +200,7 @@ function mergeSettings(input: CodemodeSettingsInput): ResolvedCodemodeSettings {
178
200
  jl: input.languages?.jl ?? defaultCodemodeSettings.languages.jl,
179
201
  },
180
202
  cellTimeoutSeconds: input.cellTimeoutSeconds ?? defaultCodemodeSettings.cellTimeoutSeconds,
203
+ hardLimitSeconds: input.hardLimitSeconds ?? defaultCodemodeSettings.hardLimitSeconds,
181
204
  parallelPoolWidth: input.parallelPoolWidth ?? defaultCodemodeSettings.parallelPoolWidth,
182
205
  taskTools: {
183
206
  task: input.taskTools?.task ?? defaultCodemodeSettings.taskTools.task,
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 } from "./config/settings.ts";
6
+ import { defaultCodemodeSettings, 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";
@@ -131,6 +131,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
131
131
  settings: defaultCodemodeSettings,
132
132
  cellManager: new EvalDetachedCellManager({
133
133
  notifier,
134
+ hardLimitSeconds: resolveHardLimitSeconds(defaultCodemodeSettings),
134
135
  onStatusChange: showDetachedCells,
135
136
  onWakeSourceState: emitWakeSourceState,
136
137
  ...(options.now === undefined ? {} : { now: options.now }),
@@ -162,6 +163,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
162
163
  const cellManager = new EvalDetachedCellManager({
163
164
  artifactsDir: runtime.artifactsDir,
164
165
  notifier,
166
+ hardLimitSeconds: resolveHardLimitSeconds(runtime.settings),
165
167
  onStatusChange: showDetachedCells,
166
168
  onWakeSourceState: emitWakeSourceState,
167
169
  ...(options.now === undefined ? {} : { now: options.now }),
@@ -125,6 +125,7 @@ Fields:
125
125
  - \`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.
126
126
  - \`timeout\` (optional) — seconds. Raise only for heavy compute or long{{#if spawns}} non-agent{{/if}} tool calls.
127
127
  - \`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.
128
+ - 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.
128
129
  - \`reset\` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a \`py\` reset never touches the JS VM.{{/ifAll}}
129
130
  - \`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.
130
131
 
@@ -1,4 +1,5 @@
1
1
  import type { AgentToolResult } from "@code-yeongyu/senpi";
2
+ import { DEFAULT_HARD_LIMIT_SECONDS } from "../config/settings.ts";
2
3
  import { SENPI_CODEMODE_WAKE_SOURCE, type WakeSourceState } from "../extension/wake-source-state.ts";
3
4
  import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
4
5
  import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
@@ -28,6 +29,10 @@ type ManagedCell = {
28
29
  liveResult: LiveResultProvider | undefined;
29
30
  terminalResult: AgentToolResult<EvalToolDetails> | undefined;
30
31
  notificationQueued: boolean;
32
+ hardLimitSeconds: number;
33
+ hardLimitTimer: ReturnType<typeof setTimeout> | undefined;
34
+ hardLimited: boolean;
35
+ onHardLimit: ((error: Error) => void) | undefined;
31
36
  };
32
37
 
33
38
  export interface EvalDetachedCellSnapshot {
@@ -37,6 +42,8 @@ export interface EvalDetachedCellSnapshot {
37
42
  readonly outputTail: string;
38
43
  readonly result: AgentToolResult<EvalToolDetails>;
39
44
  readonly stateRetained: boolean | undefined;
45
+ /** Set only when the wall-clock kill deadline ended this cell. */
46
+ readonly hardLimitSeconds?: number;
40
47
  }
41
48
 
42
49
  export interface EvalDetachedCellNotification {
@@ -58,12 +65,20 @@ export interface EvalDetachedCellStatusEntry {
58
65
  export interface EvalDetachedCellManagerOptions {
59
66
  readonly artifactsDir?: string;
60
67
  readonly notifier?: EvalDetachedCellNotifier;
68
+ /** Wall-clock kill deadline in seconds; defaults to the bash-parity 1800s. */
69
+ readonly hardLimitSeconds?: number;
61
70
  readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
62
71
  /** Receives a full per-source liveness snapshot on every detached-cell transition; used by the goal builtin. */
63
72
  readonly onWakeSourceState?: (state: WakeSourceState) => void;
64
73
  readonly now?: () => number;
65
74
  }
66
75
 
76
+ export function hardLimitError(cellId: string, hardLimitSeconds: number): Error {
77
+ const error = new Error(`Eval cell ${cellId} was killed at the ${hardLimitSeconds}s hard limit.`);
78
+ error.name = "TimeoutError";
79
+ return error;
80
+ }
81
+
67
82
  export class EvalDetachedCellManager {
68
83
  readonly #artifactsDir: string | undefined;
69
84
  readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
@@ -72,6 +87,7 @@ export class EvalDetachedCellManager {
72
87
  readonly #detachedByLanguage = new Map<EvalLanguage, ManagedCell>();
73
88
  readonly #notificationQueue: DetachedNotificationQueue;
74
89
  readonly #now: () => number;
90
+ readonly #hardLimitSeconds: number;
75
91
 
76
92
  constructor(options: EvalDetachedCellManagerOptions = {}) {
77
93
  this.#artifactsDir = options.artifactsDir;
@@ -79,6 +95,7 @@ export class EvalDetachedCellManager {
79
95
  this.#onWakeSourceState = options.onWakeSourceState;
80
96
  this.#notificationQueue = new DetachedNotificationQueue(options.notifier, options.artifactsDir);
81
97
  this.#now = options.now ?? Date.now;
98
+ this.#hardLimitSeconds = options.hardLimitSeconds ?? DEFAULT_HARD_LIMIT_SECONDS;
82
99
  }
83
100
 
84
101
  create(cellId: string, input: EvalToolInput): ManagedCell {
@@ -100,14 +117,27 @@ export class EvalDetachedCellManager {
100
117
  liveResult: undefined,
101
118
  terminalResult: undefined,
102
119
  notificationQueued: false,
120
+ // An explicit longer per-call timeout raises the deadline, mirroring bash keeping explicit timeouts.
121
+ hardLimitSeconds: Math.max(this.#hardLimitSeconds, input.timeout ?? 0),
122
+ hardLimitTimer: undefined,
123
+ hardLimited: false,
124
+ onHardLimit: undefined,
103
125
  terminal: Promise.withResolvers<EvalDetachedCellSnapshot>(),
104
126
  };
105
127
  this.#cells.set(cellId, cell);
128
+ this.#armHardLimit(cell);
106
129
  return cell;
107
130
  }
108
131
 
109
- markRunning(cell: ManagedCell, kernel: EvalKernel, liveResult: LiveResultProvider): void {
132
+ markRunning(
133
+ cell: ManagedCell,
134
+ kernel: EvalKernel,
135
+ liveResult: LiveResultProvider,
136
+ /** Foreground killer: the still-awaited CellExecution owns interrupting and rejecting its own call. */
137
+ onHardLimit?: (error: Error) => void,
138
+ ): void {
110
139
  if (cell.state !== "running") return;
140
+ cell.onHardLimit = onHardLimit;
111
141
  cell.kernel = kernel;
112
142
  cell.liveResult = liveResult;
113
143
  cell.canDetach = true;
@@ -179,6 +209,7 @@ export class EvalDetachedCellManager {
179
209
  result: AgentToolResult<EvalToolDetails>,
180
210
  ): boolean {
181
211
  if (!allowsDetachedCellTransition(cell.state, state)) return false;
212
+ this.#clearHardLimit(cell);
182
213
  cell.state = state;
183
214
  cell.terminalResult = result;
184
215
  cell.liveResult = undefined;
@@ -198,6 +229,37 @@ export class EvalDetachedCellManager {
198
229
  return true;
199
230
  }
200
231
 
232
+ #armHardLimit(cell: ManagedCell): void {
233
+ const timer = setTimeout(() => void this.#expireHardLimit(cell), cell.hardLimitSeconds * 1_000);
234
+ timer.unref?.();
235
+ cell.hardLimitTimer = timer;
236
+ }
237
+
238
+ #clearHardLimit(cell: ManagedCell): void {
239
+ if (cell.hardLimitTimer === undefined) return;
240
+ clearTimeout(cell.hardLimitTimer);
241
+ cell.hardLimitTimer = undefined;
242
+ }
243
+
244
+ /**
245
+ * The wall-clock kill deadline. Unlike the idle watchdog it is never paused by a bridge tool call and
246
+ * survives detach, so it is the only bound a detached cell has.
247
+ */
248
+ async #expireHardLimit(cell: ManagedCell): Promise<void> {
249
+ if (!detachedCellIsActive(cell.state)) return;
250
+ const foreground = cell.state === "running" && cell.onHardLimit !== undefined;
251
+ cell.hardLimited = true;
252
+ const error = hardLimitError(cell.cellId, cell.hardLimitSeconds);
253
+ if (!this.#settle(cell, "cancelled", currentDetachedResult(cell))) return;
254
+ if (foreground) {
255
+ cell.onHardLimit?.(error);
256
+ return;
257
+ }
258
+ if (cell.kernel === undefined) return;
259
+ const handle = await cell.kernel.interrupt(error.message);
260
+ cell.stateRetained = await handle.stateRetained;
261
+ }
262
+
201
263
  #emitStatus(): void {
202
264
  const liveCells = [...this.#detachedByLanguage.values()];
203
265
  this.#onStatusChange?.(
@@ -66,6 +66,7 @@ function textContent(cell: EvalDetachedCellSnapshot): string {
66
66
  }
67
67
 
68
68
  function outcomeOf(cell: EvalDetachedCellSnapshot): string {
69
+ if (cell.hardLimitSeconds !== undefined) return `was killed at the ${cell.hardLimitSeconds}s hard limit`;
69
70
  if (cell.state === "completed") return "completed";
70
71
  if (cell.state === "cancelled") return "cancelled";
71
72
  return "failed";
@@ -12,6 +12,8 @@ export interface DetachedCellResultSource {
12
12
  stateRetained: boolean | undefined;
13
13
  liveResult: (() => AgentToolResult<EvalToolDetails>) | undefined;
14
14
  terminalResult: AgentToolResult<EvalToolDetails> | undefined;
15
+ hardLimited?: boolean;
16
+ hardLimitSeconds?: number;
15
17
  }
16
18
 
17
19
  export function snapshotDetachedCell(cell: DetachedCellResultSource, nowMs: number): EvalDetachedCellSnapshot {
@@ -24,6 +26,9 @@ export function snapshotDetachedCell(cell: DetachedCellResultSource, nowMs: numb
24
26
  outputTail: detachedOutputTail(result),
25
27
  result,
26
28
  stateRetained: cell.stateRetained,
29
+ ...(cell.hardLimited === true && cell.hardLimitSeconds !== undefined
30
+ ? { hardLimitSeconds: cell.hardLimitSeconds }
31
+ : {}),
27
32
  };
28
33
  }
29
34
 
@@ -19,6 +19,8 @@ export interface CreateEvalToolOptions {
19
19
  readonly enabledLanguages: EnabledEvalLanguages;
20
20
  readonly kernelManager: EvalKernelManager;
21
21
  readonly cellTimeoutSeconds: number;
22
+ /** Wall-clock kill deadline applied to every cell; only used when this factory creates its own manager. */
23
+ readonly hardLimitSeconds?: number;
22
24
  readonly executeTool: ExecuteTool;
23
25
  readonly listTools?: () => readonly EvalSchemaToolInfo[];
24
26
  readonly complete?: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
@@ -32,7 +32,12 @@ export function createEvalTool(options: CreateEvalToolOptions): ToolDefinition<E
32
32
  ...(options.hostLine === undefined ? {} : { hostLine: options.hostLine }),
33
33
  });
34
34
  const languages = enabledLanguageList(options.enabledLanguages);
35
- const cellManager = options.cellManager ?? new EvalDetachedCellManager({ artifactsDir: options.artifactsDir });
35
+ const cellManager =
36
+ options.cellManager ??
37
+ new EvalDetachedCellManager({
38
+ ...(options.artifactsDir === undefined ? {} : { artifactsDir: options.artifactsDir }),
39
+ ...(options.hardLimitSeconds === undefined ? {} : { hardLimitSeconds: options.hardLimitSeconds }),
40
+ });
36
41
  return {
37
42
  name: "eval",
38
43
  label: "Eval",
@@ -194,7 +199,12 @@ async function executeCell(
194
199
  ...(options.imageResizer === undefined ? {} : { imageResizer: options.imageResizer }),
195
200
  });
196
201
  handler = activeHandler;
197
- cellManager.markRunning(cell, kernel, () => activeHandler.liveResult());
202
+ cellManager.markRunning(
203
+ cell,
204
+ kernel,
205
+ () => activeHandler.liveResult(),
206
+ (error) => execution.cancel(error),
207
+ );
198
208
  if ("setContext" in options.kernelManager && typeof options.kernelManager.setContext === "function") {
199
209
  options.kernelManager.setContext(bridgeContext);
200
210
  }