@code-yeongyu/senpi-codemode 2026.9.13 → 2026.9.15-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.
@@ -3,10 +3,14 @@ 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 { encodeDisplayImage, resolveDisplayOps } from "./display-image.js";
6
+ import { terminateProcessTrees } from "./process-tree.js";
6
7
  import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect-eval.js";
7
8
  import { installShellCapture } from "./worker-shell-capture.js";
8
9
 
9
10
  const PREPARED_CELL_PREFIX = "/*senpi:prepared-cell*/";
11
+ // How long a child gets to honour SIGTERM before SIGKILL. Short, because the
12
+ // cell has already produced its value and the caller is waiting on settle.
13
+ const CHILD_TERMINATION_GRACE_MS = 1_000;
10
14
  const INTERNAL_URL = /^([a-z][a-z0-9+.-]*):\/\/(.*)$/iu;
11
15
 
12
16
  export class JsWorkerRuntime {
@@ -17,10 +21,12 @@ export class JsWorkerRuntime {
17
21
  #hooks = null;
18
22
  #pendingDisplays = [];
19
23
  #children = new Set();
24
+ #onChildEvent;
20
25
 
21
26
  constructor(options) {
22
27
  this.#cwd = options.cwd;
23
28
  this.#parallelPoolWidth = options.parallelPoolWidth;
29
+ this.#onChildEvent = typeof options.onChildEvent === "function" ? options.onChildEvent : null;
24
30
  this.#localRoots = { ...(options.localRoots ?? {}) };
25
31
  if (options.artifactsDir && !this.#localRoots.local) this.#localRoots.local = join(options.artifactsDir, "local");
26
32
  this.#installGlobals();
@@ -42,24 +48,46 @@ export class JsWorkerRuntime {
42
48
  return value;
43
49
  } finally {
44
50
  this.#pendingDisplays = [];
45
- this.#children.clear();
51
+ // A child still running here has lost its only owner: the cell that
52
+ // spawned it is over, nothing will await it again, and it would be
53
+ // reparented to init. Retire it the way timeout and abort cleanup
54
+ // already do, unless the cell asked for a detached process.
55
+ await this.#terminateChildren();
46
56
  this.#hooks = null;
47
57
  }
48
58
  }
49
59
 
50
60
  interrupt() {
51
- for (const child of this.#children) {
52
- if (child.exitCode === null && child.signalCode === null) child.kill();
53
- }
61
+ // The tree snapshot, SIGTERM, and SIGKILL escalation run on their own so
62
+ // the caller's interrupt latency stays that of the acknowledgement.
63
+ void this.#terminateChildren();
54
64
  }
55
65
 
56
- #trackChild(child) {
66
+ #trackChild(child, spawnOptions) {
57
67
  if (child === null || typeof child !== "object" || typeof child.kill !== "function") return;
68
+ // `detached: true` is the cell saying it wants the process to outlive it.
69
+ if (isPlainObject(spawnOptions) && spawnOptions.detached === true) return;
58
70
  this.#children.add(child);
59
- const forget = () => this.#children.delete(child);
71
+ const pid = Number.isInteger(child.pid) && child.pid > 0 ? child.pid : null;
72
+ // The host keeps its own copy of live pids: if this worker is terminated
73
+ // while blocked, only the host can still retire them.
74
+ if (pid !== null) this.#onChildEvent?.({ pid, state: "spawned" });
75
+ const forget = () => {
76
+ this.#children.delete(child);
77
+ if (pid !== null) this.#onChildEvent?.({ pid, state: "exited" });
78
+ };
60
79
  if (child.exited instanceof Promise) child.exited.then(forget, forget);
61
80
  }
62
81
 
82
+ #terminateChildren() {
83
+ const children = [...this.#children].filter(child => child.exitCode === null && child.signalCode === null);
84
+ this.#children.clear();
85
+ if (children.length === 0) return undefined;
86
+ const roots = children.map(child => child.pid).filter(pid => Number.isInteger(pid) && pid > 0);
87
+ const settled = Promise.allSettled(children.map(child => (child.exited instanceof Promise ? child.exited : Promise.resolve())));
88
+ return terminateProcessTrees(roots, { graceMs: CHILD_TERMINATION_GRACE_MS, settled });
89
+ }
90
+
63
91
  async #drainPendingDisplays() {
64
92
  while (this.#pendingDisplays.length > 0) {
65
93
  const pending = this.#pendingDisplays;
@@ -114,7 +142,7 @@ export class JsWorkerRuntime {
114
142
  const restoreShellCapture = installShellCapture({
115
143
  isActive: () => this.#hooks !== null,
116
144
  emitText: (stream, data) => this.#emitText(stream, data),
117
- onChild: (child) => this.#trackChild(child),
145
+ onChild: (child, spawnOptions) => this.#trackChild(child, spawnOptions),
118
146
  });
119
147
  globalThis.__senpi_restore_console__ = () => {
120
148
  console.log = originalLog;
@@ -3,6 +3,7 @@ export type ShellCaptureStream = "stdout" | "stderr";
3
3
  export type ShellCaptureRestore = () => void;
4
4
 
5
5
  export interface ShellCaptureChild {
6
+ readonly pid?: number;
6
7
  readonly exitCode: number | null;
7
8
  readonly signalCode: string | null;
8
9
  readonly exited: Promise<number>;
@@ -13,7 +14,7 @@ export interface ShellCaptureOptions {
13
14
  readonly isActive: () => boolean;
14
15
  readonly emitText: (stream: ShellCaptureStream, data: string) => void;
15
16
  /** Receives every `Bun.spawn` child created while a cell is active so the runtime can kill it on interrupt. */
16
- readonly onChild?: (child: ShellCaptureChild) => void;
17
+ readonly onChild?: (child: ShellCaptureChild, spawnOptions?: unknown) => void;
17
18
  }
18
19
 
19
20
  export function installShellCapture(options: ShellCaptureOptions): ShellCaptureRestore;
@@ -154,7 +154,7 @@ function capturedSpawn(originalSpawn, options, pinEnv) {
154
154
  ? originalSpawn(...args)
155
155
  : originalSpawn(effective);
156
156
  }
157
- options.onChild?.(child);
157
+ options.onChild?.(child, Array.isArray(first) ? second : first);
158
158
  return child;
159
159
  };
160
160
  }
@@ -23,7 +23,7 @@ import uuid
23
23
  from collections.abc import Iterable
24
24
  from concurrent.futures import ThreadPoolExecutor, as_completed
25
25
  from pathlib import Path
26
- from threading import Lock
26
+ from threading import Lock, Thread
27
27
  from typing import Any, Callable
28
28
  from urllib.parse import unquote
29
29
 
@@ -1022,14 +1022,48 @@ def handle(message: dict[str, Any]) -> bool:
1022
1022
  return True
1023
1023
 
1024
1024
 
1025
+ def _terminate_process_group() -> None:
1026
+ # The kernel is spawned into its own session (setsid), so its pid is its process
1027
+ # group id and a cell's subprocesses inherit that group. Killing the group takes
1028
+ # those children down with the kernel instead of orphaning them to init.
1029
+ if os.name != "posix":
1030
+ return
1031
+ with contextlib.suppress(OSError):
1032
+ os.killpg(os.getpgrp(), signal.SIGKILL)
1033
+
1034
+
1035
+ def _watch_parent(initial_ppid: int) -> None:
1036
+ # A cell blocked in the main thread (for example a multiprocessing pool) never
1037
+ # returns to the stdin loop, so it cannot notice the host closing its pipe. This
1038
+ # daemon thread notices the reparenting instead and takes the whole group down.
1039
+ while True:
1040
+ time.sleep(1.0)
1041
+ if os.getppid() != initial_ppid:
1042
+ _terminate_process_group()
1043
+ return
1044
+
1045
+
1046
+ def _start_parent_watch() -> None:
1047
+ if os.name != "posix":
1048
+ return
1049
+ Thread(target=_watch_parent, args=(os.getppid(),), name="senpi-parent-watch", daemon=True).start()
1050
+
1051
+
1025
1052
  def main() -> None:
1026
1053
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1054
+ _start_parent_watch()
1055
+ host_closed = False
1027
1056
  for raw in sys.stdin:
1028
1057
  try:
1029
1058
  if not handle(json.loads(raw)):
1059
+ host_closed = True
1030
1060
  break
1031
1061
  except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
1032
1062
  emit({"type": "init-failed", "error": bridge_error(exc)})
1063
+ # Reaching here without a close frame means the host's pipe hit EOF: it is gone,
1064
+ # so retire any subprocess the last cell left running before the interpreter exits.
1065
+ if not host_closed:
1066
+ _terminate_process_group()
1033
1067
 
1034
1068
 
1035
1069
  if __name__ == "__main__":
@@ -45,6 +45,12 @@ export function splitCommand(commandLine: string): { readonly command: string; r
45
45
  return { command, args };
46
46
  }
47
47
 
48
+ function errorCode(error: unknown): string | undefined {
49
+ if (typeof error !== "object" || error === null) return undefined;
50
+ const code = Reflect.get(error, "code");
51
+ return typeof code === "string" ? code : undefined;
52
+ }
53
+
48
54
  export function numberOrNull(value: unknown): number | null {
49
55
  return typeof value === "number" ? value : null;
50
56
  }
@@ -80,6 +86,20 @@ export async function waitForExit(child: KernelChild, timeoutMs: number): Promis
80
86
  });
81
87
  }
82
88
 
89
+ // A cell's own subprocess is spawned into the detached kernel's process group. When the
90
+ // leader exits gracefully on close, kill the group so a Popen the cell left running does
91
+ // not outlive the kernel; the group is addressed by the leader pid (== pgid) while members live.
92
+ export function sweepProcessGroup(child: KernelChild): void {
93
+ if (child.pid === undefined || process.platform === "win32") return;
94
+ try {
95
+ process.kill(-child.pid, "SIGKILL");
96
+ } catch (error) {
97
+ // ESRCH means the group is already gone (the success case). Any other error
98
+ // (for example EPERM on a member we cannot signal) must not fail a graceful close.
99
+ if (errorCode(error) !== "ESRCH") return;
100
+ }
101
+ }
102
+
83
103
  export async function hardKill(child: KernelChild, timeoutMs: number): Promise<void> {
84
104
  await new Promise<void>((resolve, reject) => {
85
105
  let timer: NodeJS.Timeout | undefined;
@@ -19,6 +19,7 @@ import {
19
19
  numberOrNull,
20
20
  signalOrNull,
21
21
  splitCommand,
22
+ sweepProcessGroup,
22
23
  waitForExit,
23
24
  withTimeout,
24
25
  } from "./process.ts";
@@ -145,7 +146,10 @@ export class PythonKernelTransport {
145
146
  } catch (error) {
146
147
  if (!(error instanceof Error)) throw error;
147
148
  }
148
- if (!(await exited)) await hardKill(this.#child, hardKillWaitMs);
149
+ // hardKill kills the whole group; a graceful leader exit does not, so sweep it
150
+ // to retire any subprocess the cell left running in the kernel's process group.
151
+ if (await exited) sweepProcessGroup(this.#child);
152
+ else await hardKill(this.#child, hardKillWaitMs);
149
153
  }
150
154
 
151
155
  retire(): Promise<void> {
@@ -12,6 +12,8 @@
12
12
  export const SESSION_ENVIRONMENT_KEYS = [
13
13
  "PI_SESSION_ID",
14
14
  "PI_SESSION_FILE",
15
+ "PI_SESSION_CWD",
16
+ "PI_GOAL_STORE_FILE",
15
17
  "PI_PROVIDER",
16
18
  "PI_MODEL",
17
19
  "PI_REASONING_LEVEL",
@@ -22,6 +24,8 @@ export type SessionEnvironment = Readonly<Record<string, string>>;
22
24
 
23
25
  /** Structural slice of `ExtensionContext` the session environment is resolved from. */
24
26
  export interface SessionEnvironmentSource {
27
+ readonly cwd: string;
28
+ readonly goalStoreFile?: string;
25
29
  readonly sessionManager: {
26
30
  getSessionId(): string;
27
31
  getSessionFile(): string | undefined;
@@ -33,6 +37,8 @@ export interface SessionEnvironmentSource {
33
37
  export function sessionEnvironmentFrom(source: SessionEnvironmentSource): SessionEnvironment {
34
38
  const env: Record<string, string> = {};
35
39
  env.PI_SESSION_ID = source.sessionManager.getSessionId();
40
+ env.PI_SESSION_CWD = source.cwd;
41
+ if (source.goalStoreFile) env.PI_GOAL_STORE_FILE = source.goalStoreFile;
36
42
  const sessionFile = source.sessionManager.getSessionFile();
37
43
  if (sessionFile) env.PI_SESSION_FILE = sessionFile;
38
44
  const model = source.model;
@@ -2,6 +2,9 @@ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
2
2
  import type { KernelResult, KernelRunInput, ToolCallMessage } from "./subprocess-contract.ts";
3
3
  import { createPendingRun, failureResult, type PendingRun, settlePendingRun } from "./subprocess-run.ts";
4
4
 
5
+ // Same bound as the JS kernel's pull-API fallback queue (context-manager.ts); clears happen at run teardown.
6
+ const MAX_PENDING_TOOL_CALLS = 256;
7
+
5
8
  export class SubprocessRunQueue {
6
9
  readonly #queue: PendingRun[] = [];
7
10
  readonly #pendingCalls: ToolCallMessage[] = [];
@@ -59,7 +62,10 @@ export class SubprocessRunQueue {
59
62
  pushToolCall(message: ToolCallMessage): void {
60
63
  const waiter = this.#callWaiters.shift();
61
64
  if (waiter) waiter(message);
62
- else this.#pendingCalls.push(message);
65
+ else {
66
+ this.#pendingCalls.push(message);
67
+ if (this.#pendingCalls.length > MAX_PENDING_TOOL_CALLS) this.#pendingCalls.shift();
68
+ }
63
69
  }
64
70
 
65
71
  handleMessage(
@@ -1,14 +1,12 @@
1
1
  import type { AgentToolResult } from "@code-yeongyu/senpi";
2
2
  import { DEFAULT_HARD_LIMIT_SECONDS, DEFAULT_RUN_BUDGET_SECONDS } from "../config/settings.ts";
3
3
  import type { WakeSourceState } from "../extension/wake-source-state.ts";
4
- import { type CellDeadlineExpiry, CellDeadlines } from "./cell-deadlines.ts";
4
+ import type { CellDeadlineExpiry } from "./cell-deadlines.ts";
5
5
  import type {
6
6
  EvalDetachedCellManagerOptions,
7
7
  EvalDetachedCellSnapshot,
8
- EvalDetachedCellState,
9
8
  EvalDetachedCellStatusEntry,
10
9
  } from "./detached-cell-contract.ts";
11
- import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
12
10
  import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
13
11
  import {
14
12
  activeDetachedCellReuseError,
@@ -17,6 +15,8 @@ import {
17
15
  } from "./detached-cell-state.ts";
18
16
  import { detachedStatusEntries, detachedWakeSourceState } from "./detached-cell-status.ts";
19
17
  import { DetachedNotificationQueue } from "./detached-notification-queue.ts";
18
+ import { createManagedCell, type LiveResultProvider, type ManagedCell } from "./managed-cell.ts";
19
+ import { TerminalSnapshotStore } from "./terminal-snapshot-store.ts";
20
20
  import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
21
21
 
22
22
  export type {
@@ -28,40 +28,13 @@ export type {
28
28
  EvalDetachedCellStatusEntry,
29
29
  } from "./detached-cell-contract.ts";
30
30
 
31
- type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
32
-
33
- type ManagedCell = {
34
- readonly cellId: string;
35
- readonly input: EvalToolInput;
36
- readonly spillPath: string | undefined;
37
- readonly startedAtMs: number;
38
- readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
39
- state: EvalDetachedCellState;
40
- canDetach: boolean;
41
- wasDetached: boolean;
42
- kernel: EvalKernel | undefined;
43
- stateRetained: boolean | undefined;
44
- interruptNote: string | undefined;
45
- /** Holds the completion notification until the interrupt has reported whether kernel state survived. */
46
- interruptOutcome: PromiseWithResolvers<void> | undefined;
47
- liveResult: LiveResultProvider | undefined;
48
- terminalResult: AgentToolResult<EvalToolDetails> | undefined;
49
- notificationQueued: boolean;
50
- readonly deadlines: CellDeadlines;
51
- readonly hardLimitSeconds: number;
52
- readonly runBudgetSeconds: number;
53
- hardLimited: boolean;
54
- runBudgetExhausted: boolean;
55
- /** Foreground killer: the still-awaited CellExecution owns interrupting and rejecting its own call; bound from creation so a deadline firing during kernel boot still ends it. */
56
- onKill: ((error: Error) => void) | undefined;
57
- };
58
-
59
31
  export class EvalDetachedCellManager {
60
32
  readonly #artifactsDir: string | undefined;
61
33
  readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
62
34
  readonly #onWakeSourceState: ((state: WakeSourceState) => void) | undefined;
63
35
  readonly #cells = new Map<string, ManagedCell>();
64
36
  readonly #detachedByLanguage = new Map<EvalLanguage, ManagedCell>();
37
+ readonly #terminalSnapshots = new TerminalSnapshotStore();
65
38
  readonly #notificationQueue: DetachedNotificationQueue;
66
39
  readonly #now: () => number;
67
40
  readonly #hardLimitSeconds: number;
@@ -83,40 +56,20 @@ export class EvalDetachedCellManager {
83
56
  if (detachedCellIsActive(existing.state)) throw activeDetachedCellReuseError(existing);
84
57
  this.#cells.delete(cellId);
85
58
  }
86
- // An explicit longer per-call timeout raises the deadline, mirroring bash keeping explicit timeouts.
87
- const hardLimitSeconds = Math.max(this.#hardLimitSeconds, input.timeout ?? 0);
88
- const runBudgetSeconds = input.timeout ?? this.#runBudgetSeconds;
89
- const cell: ManagedCell = {
59
+ this.#terminalSnapshots.delete(cellId);
60
+ const cell = createManagedCell({
90
61
  cellId,
91
62
  input,
92
- spillPath: detachedNotificationSpillPath(this.#artifactsDir, cellId),
93
- startedAtMs: this.#now(),
94
- state: "running",
95
- canDetach: false,
96
- wasDetached: false,
97
- kernel: undefined,
98
- stateRetained: undefined,
99
- interruptNote: undefined,
100
- interruptOutcome: undefined,
101
- liveResult: undefined,
102
- terminalResult: undefined,
103
- notificationQueued: false,
104
- deadlines: new CellDeadlines({
105
- cellId,
106
- hardLimitSeconds,
107
- runBudgetSeconds,
108
- onExpire: (expiry) => {
109
- const managed = this.#cells.get(cellId);
110
- if (managed !== undefined) void this.#expireDeadline(managed, expiry);
111
- },
112
- }),
113
- hardLimitSeconds,
114
- runBudgetSeconds,
115
- hardLimited: false,
116
- runBudgetExhausted: false,
63
+ artifactsDir: this.#artifactsDir,
64
+ now: this.#now,
65
+ defaultHardLimitSeconds: this.#hardLimitSeconds,
66
+ defaultRunBudgetSeconds: this.#runBudgetSeconds,
117
67
  onKill,
118
- terminal: Promise.withResolvers<EvalDetachedCellSnapshot>(),
119
- };
68
+ onExpire: (expiredId, expiry) => {
69
+ const managed = this.#cells.get(expiredId);
70
+ if (managed !== undefined) void this.#expireDeadline(managed, expiry);
71
+ },
72
+ });
120
73
  this.#cells.set(cellId, cell);
121
74
  return cell;
122
75
  }
@@ -144,7 +97,12 @@ export class EvalDetachedCellManager {
144
97
  }
145
98
 
146
99
  detach(cell: ManagedCell): boolean {
147
- if (!cell.canDetach || !allowsDetachedCellTransition(cell.state, "detached")) return false;
100
+ if (
101
+ !cell.canDetach ||
102
+ !allowsDetachedCellTransition(cell.state, "detached") ||
103
+ this.#detachedByLanguage.has(cell.input.language)
104
+ )
105
+ return false;
148
106
  cell.state = "detached";
149
107
  cell.wasDetached = true;
150
108
  this.#detachedByLanguage.set(cell.input.language, cell);
@@ -161,13 +119,15 @@ export class EvalDetachedCellManager {
161
119
  }
162
120
 
163
121
  async stop(cellId: string, reason = "Stopped detached eval cell"): Promise<EvalDetachedCellSnapshot> {
164
- const cell = this.#get(cellId);
165
- if (cell.state === "detached") await this.#cancel(cell, reason);
166
- return this.#snapshot(cell);
122
+ const live = this.#cells.get(cellId);
123
+ if (live === undefined) return this.#terminal(cellId);
124
+ if (live.state === "detached") await this.#cancel(live, reason);
125
+ return this.#snapshot(live);
167
126
  }
168
127
 
169
128
  peek(cellId: string): EvalDetachedCellSnapshot {
170
- return this.#snapshot(this.#get(cellId));
129
+ const live = this.#cells.get(cellId);
130
+ return live === undefined ? this.#terminal(cellId) : this.#snapshot(live);
171
131
  }
172
132
 
173
133
  busyFor(language: EvalLanguage): EvalDetachedCellSnapshot | undefined {
@@ -176,7 +136,8 @@ export class EvalDetachedCellManager {
176
136
  }
177
137
 
178
138
  async waitForTerminal(cellId: string): Promise<EvalDetachedCellSnapshot> {
179
- return await this.#get(cellId).terminal.promise;
139
+ const live = this.#cells.get(cellId);
140
+ return live === undefined ? this.#terminal(cellId) : await live.terminal.promise;
180
141
  }
181
142
 
182
143
  async dispose(): Promise<void> {
@@ -186,6 +147,8 @@ export class EvalDetachedCellManager {
186
147
  );
187
148
  if (detached.length === 0) this.#emitWakeSourceState([]);
188
149
  await this.#notificationQueue.flush();
150
+ this.#cells.clear();
151
+ this.#terminalSnapshots.clear();
189
152
  }
190
153
 
191
154
  async flushNotifications(): Promise<void> {
@@ -208,6 +171,8 @@ export class EvalDetachedCellManager {
208
171
  cell.terminalResult = result;
209
172
  cell.liveResult = undefined;
210
173
  cell.terminal.resolve(this.#snapshot(cell));
174
+ this.#cells.delete(cell.cellId);
175
+ this.#refreshTerminalSnapshot(cell);
211
176
  if (cell.wasDetached) {
212
177
  if (this.#detachedByLanguage.get(cell.input.language) === cell)
213
178
  this.#detachedByLanguage.delete(cell.input.language);
@@ -252,9 +217,14 @@ export class EvalDetachedCellManager {
252
217
  cell.stateRetained = await handle.stateRetained;
253
218
  } finally {
254
219
  outcome.resolve();
220
+ this.#refreshTerminalSnapshot(cell);
255
221
  }
256
222
  }
257
223
 
224
+ #refreshTerminalSnapshot(cell: ManagedCell): void {
225
+ if (!this.#cells.has(cell.cellId)) this.#terminalSnapshots.remember(this.#snapshot(cell));
226
+ }
227
+
258
228
  #emitStatus(): void {
259
229
  const liveCells = [...this.#detachedByLanguage.values()];
260
230
  this.#onStatusChange?.(detachedStatusEntries(liveCells));
@@ -269,9 +239,9 @@ export class EvalDetachedCellManager {
269
239
  return snapshotDetachedCell(cell, this.#now());
270
240
  }
271
241
 
272
- #get(cellId: string): ManagedCell {
273
- const cell = this.#cells.get(cellId);
274
- if (cell === undefined) throw new Error(`Unknown detached eval cell "${cellId}"`);
275
- return cell;
242
+ #terminal(cellId: string): EvalDetachedCellSnapshot {
243
+ const snapshot = this.#terminalSnapshots.get(cellId);
244
+ if (snapshot === undefined) throw new Error(`Unknown detached eval cell "${cellId}"`);
245
+ return snapshot;
276
246
  }
277
247
  }
@@ -0,0 +1,52 @@
1
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
2
+ import { convertToPng, formatDimensionNote, resizeImage } from "@code-yeongyu/senpi";
3
+
4
+ export interface EvalImageContent {
5
+ readonly type: "image";
6
+ readonly data: string;
7
+ readonly mimeType: string;
8
+ }
9
+
10
+ export interface EvalImageResizeResult {
11
+ readonly image: EvalImageContent;
12
+ readonly dimensionNote?: string;
13
+ }
14
+
15
+ export type EvalImageResizer = (
16
+ image: EvalImageContent,
17
+ model: ExtensionContext["model"],
18
+ ) => Promise<EvalImageResizeResult>;
19
+
20
+ type WebpModel = { readonly provider: string; readonly api: string } | undefined;
21
+
22
+ export function webpExclusionForModel(model: WebpModel): true | undefined {
23
+ if (model === undefined) return undefined;
24
+ return model.provider === "ollama" ||
25
+ model.provider === "ollama-cloud" ||
26
+ model.provider === "llama.cpp" ||
27
+ model.provider === "lm-studio" ||
28
+ model.provider === "local-server" ||
29
+ model.api === "ollama-chat"
30
+ ? true
31
+ : undefined;
32
+ }
33
+
34
+ export const resizeEvalImage: EvalImageResizer = async (image, model) => {
35
+ const excludeWebP = webpExclusionForModel(model);
36
+ const forceWebpConversion = excludeWebP === true && image.mimeType === "image/webp";
37
+ const resized = await resizeImage(
38
+ Buffer.from(image.data, "base64"),
39
+ image.mimeType,
40
+ forceWebpConversion ? { maxBytes: Buffer.byteLength(image.data, "utf8") } : undefined,
41
+ );
42
+ let output: EvalImageContent =
43
+ resized === null ? image : { type: "image", data: resized.data, mimeType: resized.mimeType };
44
+ if (excludeWebP === true && output.mimeType === "image/webp") {
45
+ const converted = await convertToPng(output.data, output.mimeType);
46
+ if (converted === null)
47
+ throw new TypeError(`Unable to convert ${output.mimeType} display output for the active model`);
48
+ output = { type: "image", data: converted.data, mimeType: converted.mimeType };
49
+ }
50
+ const dimensionNote = resized === null ? undefined : formatDimensionNote(resized);
51
+ return { image: output, ...(dimensionNote === undefined ? {} : { dimensionNote }) };
52
+ };