@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.
- package/CHANGELOG.md +45 -0
- package/README.md +16 -4
- package/package.json +4 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/kernels/js/context-manager.ts +62 -13
- package/src/kernels/js/process-tree-host.ts +158 -0
- package/src/kernels/js/process-tree.d.ts +24 -0
- package/src/kernels/js/process-tree.js +151 -0
- package/src/kernels/js/worker-core.js +5 -1
- package/src/kernels/js/worker-runtime.js +35 -7
- package/src/kernels/js/worker-shell-capture.d.ts +2 -1
- package/src/kernels/js/worker-shell-capture.js +1 -1
- package/src/kernels/py/prelude.py +35 -1
- package/src/kernels/py/process.ts +20 -0
- package/src/kernels/py/transport.ts +5 -1
- package/src/kernels/session-env.ts +6 -0
- package/src/kernels/shared/subprocess-queue.ts +7 -1
- package/src/tool/detached-cell-manager.ts +42 -72
- package/src/tool/image-resize.ts +52 -0
- package/src/tool/image.ts +37 -72
- package/src/tool/managed-cell.ts +78 -0
- package/src/tool/render.ts +30 -5
- package/src/tool/run-eval-cell.ts +31 -12
- package/src/tool/terminal-snapshot-store.ts +35 -0
- package/src/tool/tool-result-marshal.ts +23 -0
- package/src/tool/tool-widgets.ts +16 -1
- package/src/tool/types.ts +1 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
87
|
-
const
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
165
|
-
if (
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
273
|
-
const
|
|
274
|
-
if (
|
|
275
|
-
return
|
|
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
|
+
};
|