@code-yeongyu/senpi-codemode 2026.10.6 → 2026.10.7

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -0
  3. package/package.json +4 -4
  4. package/src/bridge/reserved.ts +10 -0
  5. package/src/bridges/eval-virtual-schemas.ts +79 -0
  6. package/src/bridges/handle-bridge.ts +72 -0
  7. package/src/bridges/reserved-dispatch.ts +20 -2
  8. package/src/bridges/schema-bridge.ts +4 -0
  9. package/src/completion/handle.ts +69 -0
  10. package/src/completion/tool-bridge.ts +31 -12
  11. package/src/extension/bridge-dispatch.ts +41 -0
  12. package/src/extension/bridge-tool-call.ts +5 -1
  13. package/src/extension/host-facts.ts +18 -0
  14. package/src/extension/removed-tool-hints.ts +15 -0
  15. package/src/extension/runtime-factory.ts +3 -0
  16. package/src/extension/session-manager-contract.ts +3 -0
  17. package/src/extension/session-manager.ts +17 -4
  18. package/src/extension/start-recovery.ts +111 -0
  19. package/src/handles/completion-handles.ts +202 -0
  20. package/src/handles/handle-args.ts +103 -0
  21. package/src/handles/handle-registry.ts +164 -0
  22. package/src/handles/wait.ts +214 -0
  23. package/src/handles/watch-queue.ts +102 -0
  24. package/src/index.ts +99 -94
  25. package/src/kernels/jl/handles.jl +124 -0
  26. package/src/kernels/jl/prelude.jl +9 -0
  27. package/src/kernels/jl/runner.jl +6 -1
  28. package/src/kernels/js/handles.js +93 -0
  29. package/src/kernels/js/kernel-tools-define.d.ts +6 -0
  30. package/src/kernels/js/kernel-tools-define.js +3 -1
  31. package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
  32. package/src/kernels/js/kernel-tools-registry.js +18 -9
  33. package/src/kernels/js/kernel-tools-schema.js +6 -2
  34. package/src/kernels/js/worker-runtime.js +10 -1
  35. package/src/kernels/py/host-cell.ts +42 -0
  36. package/src/kernels/py/kernel-contract.ts +2 -0
  37. package/src/kernels/py/kernel.ts +48 -4
  38. package/src/kernels/py/prelude.py +140 -4
  39. package/src/kernels/rb/handles.rb +97 -0
  40. package/src/kernels/rb/paths.rb +37 -0
  41. package/src/kernels/rb/prelude.rb +15 -38
  42. package/src/kernels/rb/runner.rb +7 -7
  43. package/src/tool/cell-handler.ts +21 -18
  44. package/src/tool/cell-reserved-dispatch.ts +51 -0
  45. package/src/tool/eval-tool-options.ts +3 -0
  46. package/src/tool/run-eval-cell.ts +2 -0
  47. package/src/tool/types.ts +15 -0
@@ -0,0 +1,111 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
3
+ import type { createEvalTool } from "../tool/eval-tool.ts";
4
+ import { CodemodeSessionDisposedError } from "./session-manager.ts";
5
+ import { CodemodeSessionNotStartedError } from "./session-manager-proxy.ts";
6
+
7
+ export class CodemodeRuntimeRecreationError extends Error {
8
+ readonly name = "CodemodeRuntimeRecreationError";
9
+
10
+ constructor(reason: string) {
11
+ super(`codemode runtime could not be re-created: ${reason}. Start a new session or reload to bring eval back.`);
12
+ }
13
+ }
14
+
15
+ type StartSession = (event: unknown, ctx: ExtensionContext) => Promise<void>;
16
+
17
+ /**
18
+ * A session_start whose runtime could not be created leaves the session running with no usable manager. The
19
+ * next eval re-creates the runtime once for that failed start (concurrent evals share the attempt) and runs; a
20
+ * failed re-creation is reported to every later eval until the next session_start. One stderr line names the
21
+ * failed start with a hashed session id so a recurrence stays visible. A session that ended is never recovered.
22
+ */
23
+ export class StartRecovery {
24
+ readonly #startSession: StartSession;
25
+ readonly #report: (line: string) => void;
26
+ #failedStartEvent: { readonly event: unknown } | undefined;
27
+ #recreation: Promise<void> | undefined;
28
+ #recreationFailure: CodemodeRuntimeRecreationError | undefined;
29
+
30
+ constructor(
31
+ startSession: StartSession,
32
+ report: (line: string) => void = (line) => globalThis.process.stderr.write(`${line}\n`),
33
+ ) {
34
+ this.#startSession = startSession;
35
+ this.#report = report;
36
+ }
37
+
38
+ async runStart(event: unknown, ctx: ExtensionContext): Promise<void> {
39
+ try {
40
+ await this.#startSession(event, ctx);
41
+ this.#reset();
42
+ } catch (error) {
43
+ this.#reset();
44
+ this.#failedStartEvent = { event };
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ sessionEnded(): void {
50
+ this.#reset();
51
+ }
52
+
53
+ wrapExecute<Args extends unknown[], Result>(
54
+ execute: (...args: Args) => Promise<Result>,
55
+ contextOf: (args: Args) => ExtensionContext,
56
+ ): (...args: Args) => Promise<Result> {
57
+ return async (...args) => {
58
+ try {
59
+ return await execute(...args);
60
+ } catch (error) {
61
+ if (!(error instanceof CodemodeSessionDisposedError || error instanceof CodemodeSessionNotStartedError)) {
62
+ throw error;
63
+ }
64
+ if (this.#recreationFailure !== undefined) throw this.#recreationFailure;
65
+ const recreation = this.#recreation ?? this.#beginRecreation(contextOf(args));
66
+ if (recreation === undefined) throw error;
67
+ await recreation;
68
+ return await execute(...args);
69
+ }
70
+ };
71
+ }
72
+
73
+ #beginRecreation(ctx: ExtensionContext): Promise<void> | undefined {
74
+ const failed = this.#failedStartEvent;
75
+ if (failed === undefined) return undefined;
76
+ this.#report(
77
+ `[senpi-codemode] eval re-created a runtime left disposed by a failed session_start (session ${sessionHash(ctx)})`,
78
+ );
79
+ const recreation = this.#startSession(failed.event, ctx).then(
80
+ () => {
81
+ this.#recreation = undefined;
82
+ this.#reset();
83
+ },
84
+ (error: unknown) => {
85
+ this.#recreation = undefined;
86
+ this.#recreationFailure = new CodemodeRuntimeRecreationError(
87
+ error instanceof Error ? error.message : String(error),
88
+ );
89
+ throw this.#recreationFailure;
90
+ },
91
+ );
92
+ this.#recreation = recreation;
93
+ return recreation;
94
+ }
95
+
96
+ #reset(): void {
97
+ this.#failedStartEvent = undefined;
98
+ this.#recreationFailure = undefined;
99
+ }
100
+ }
101
+
102
+ export function withStartRecovery<Tool extends ReturnType<typeof createEvalTool>>(
103
+ recovery: StartRecovery,
104
+ tool: Tool,
105
+ ): Tool {
106
+ return { ...tool, execute: recovery.wrapExecute(tool.execute, (args) => args[4]) };
107
+ }
108
+
109
+ function sessionHash(ctx: ExtensionContext): string {
110
+ return createHash("sha256").update(ctx.sessionManager.getSessionId()).digest("hex").slice(0, 12);
111
+ }
@@ -0,0 +1,202 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ type CancelReceipt,
4
+ EvalHandleError,
5
+ type HandleError,
6
+ type HandleOutcome,
7
+ type HandlePhase,
8
+ type HandleRef,
9
+ type HandleSnapshot,
10
+ type HandleWatch,
11
+ type OutputRequest,
12
+ type OutputSnapshot,
13
+ } from "@code-yeongyu/senpi";
14
+ import { refKey } from "./handle-args.ts";
15
+ import { WatchQueue } from "./watch-queue.ts";
16
+
17
+ export interface CompletionStart {
18
+ /** Runs the host completion; the signal aborts on cancel, on the deadline, and when the session generation drops. */
19
+ readonly run: (signal: AbortSignal) => Promise<unknown>;
20
+ /** Absolute wall-clock deadline (ms), derived from the creating cell's hard deadline. */
21
+ readonly deadlineMs: number;
22
+ }
23
+
24
+ interface CompletionEntry {
25
+ readonly ref: HandleRef;
26
+ phase: HandlePhase;
27
+ hostStatus: string;
28
+ revision: number;
29
+ value?: unknown;
30
+ error?: HandleError;
31
+ readonly controller: AbortController;
32
+ timer: ReturnType<typeof setTimeout> | undefined;
33
+ deadlineHit: boolean;
34
+ }
35
+
36
+ const TERMINAL: ReadonlySet<HandlePhase> = new Set(["succeeded", "failed", "cancelled", "lost"]);
37
+ const DEADLINE_MESSAGE = "completion handle exceeded its cell's hard deadline";
38
+ const MAX_TIMER_MS = 2 ** 31 - 1;
39
+
40
+ /**
41
+ * codemode-owned completion handles (`completion(prompt, {handle: true})`). They live in the session's
42
+ * registry, so they survive a kernel restart and need no host capability; once the session generation
43
+ * is dropped every ref fails closed with `eval_handle_stale`.
44
+ */
45
+ export class CompletionHandles {
46
+ readonly #entries = new Map<string, CompletionEntry>();
47
+ readonly #watches = new Set<WatchQueue>();
48
+ readonly #now: () => number;
49
+ #disposed = false;
50
+
51
+ constructor(now: () => number = Date.now) {
52
+ this.#now = now;
53
+ }
54
+
55
+ start(input: CompletionStart): HandleRef {
56
+ this.#assertLive();
57
+ const ref: HandleRef = { kind: "completion", id: `cp_${randomBytes(16).toString("hex")}`, run_epoch: 0 };
58
+ const controller = new AbortController();
59
+ const entry: CompletionEntry = {
60
+ ref,
61
+ phase: "pending",
62
+ hostStatus: "running",
63
+ revision: 1,
64
+ controller,
65
+ timer: undefined,
66
+ deadlineHit: false,
67
+ };
68
+ this.#entries.set(ref.id, entry);
69
+ const remainingMs = Math.min(MAX_TIMER_MS, Math.max(0, input.deadlineMs - this.#now()));
70
+ if (Number.isFinite(remainingMs)) {
71
+ entry.timer = setTimeout(() => {
72
+ entry.deadlineHit = true;
73
+ controller.abort(new Error(DEADLINE_MESSAGE));
74
+ }, remainingMs);
75
+ entry.timer.unref?.();
76
+ }
77
+ input.run(controller.signal).then(
78
+ (value) => this.#settle(entry, { phase: "succeeded", hostStatus: "completed", value }),
79
+ (error: unknown) => {
80
+ // cancel() and dispose() settle the entry before aborting; their rejection is not an outcome.
81
+ if (TERMINAL.has(entry.phase)) return;
82
+ const hostStatus = entry.deadlineHit ? "deadline" : "failed";
83
+ this.#settle(entry, { phase: "failed", hostStatus, error: toHandleError(error, entry.deadlineHit) });
84
+ },
85
+ );
86
+ return ref;
87
+ }
88
+
89
+ get openWatches(): number {
90
+ return this.#watches.size;
91
+ }
92
+
93
+ owns(ref: HandleRef): boolean {
94
+ return ref.kind === "completion";
95
+ }
96
+
97
+ watch(refs: readonly HandleRef[]): HandleWatch {
98
+ const entries = refs.map((ref) => this.#fence(ref));
99
+ const queue = new WatchQueue(refs.map(refKey));
100
+ // Subscribe first, then snapshot: a settle between the two is queued and arrives in updates once.
101
+ this.#watches.add(queue);
102
+ const initial = entries.map((entry) => snapshotOf(entry));
103
+ return queue.toWatch(initial, () => this.#watches.delete(queue));
104
+ }
105
+
106
+ result(ref: HandleRef): HandleOutcome {
107
+ const entry = this.#fence(ref);
108
+ if (!TERMINAL.has(entry.phase)) throw new EvalHandleError("eval_handle_pending", `${ref.id} is still running`);
109
+ if (entry.phase === "succeeded") return { status: "fulfilled", ref: entry.ref, value: entry.value };
110
+ return {
111
+ status: "rejected",
112
+ ref: entry.ref,
113
+ error: entry.error ?? { code: "eval_handle_lost", message: `${ref.id} ended without an outcome` },
114
+ };
115
+ }
116
+
117
+ cancel(ref: HandleRef): CancelReceipt {
118
+ const entry = this.#fence(ref);
119
+ if (TERMINAL.has(entry.phase)) return { ref: entry.ref, cancelled: false, phase: entry.phase };
120
+ const error = { code: "eval_handle_cancelled", message: `${ref.id} was cancelled` };
121
+ this.#settle(entry, { phase: "cancelled", hostStatus: "cancelled", error });
122
+ entry.controller.abort(new Error(error.message));
123
+ return { ref: entry.ref, cancelled: true, phase: "cancelled" };
124
+ }
125
+
126
+ output(ref: HandleRef, request: OutputRequest): OutputSnapshot {
127
+ const entry = this.#fence(ref);
128
+ const text =
129
+ entry.phase === "succeeded"
130
+ ? typeof entry.value === "string"
131
+ ? entry.value
132
+ : JSON.stringify(entry.value)
133
+ : (entry.error?.message ?? "");
134
+ const lines = text.length === 0 ? [] : text.split("\n");
135
+ const total = lines.length;
136
+ const window =
137
+ request.format === "tail"
138
+ ? lines.slice(-(request.limit ?? 20))
139
+ : lines.slice(request.offset ?? 0, (request.offset ?? 0) + (request.limit ?? total));
140
+ const offset = request.format === "tail" ? total - window.length : (request.offset ?? 0);
141
+ return { ref: entry.ref, text: window.join("\n"), offset, total, truncated: window.length < total };
142
+ }
143
+
144
+ /** Aborts every in-flight completion and closes every watch; afterwards every ref is stale. */
145
+ dispose(): void {
146
+ if (this.#disposed) return;
147
+ this.#disposed = true;
148
+ for (const entry of this.#entries.values()) {
149
+ if (entry.timer !== undefined) clearTimeout(entry.timer);
150
+ if (!TERMINAL.has(entry.phase)) {
151
+ entry.phase = "lost";
152
+ entry.hostStatus = "session-dropped";
153
+ entry.controller.abort(new Error("session generation dropped"));
154
+ }
155
+ }
156
+ for (const queue of this.#watches) queue.close();
157
+ this.#watches.clear();
158
+ }
159
+
160
+ #assertLive(): void {
161
+ if (this.#disposed) throw new EvalHandleError("eval_handle_stale", "this session generation was dropped");
162
+ }
163
+
164
+ #fence(ref: HandleRef): CompletionEntry {
165
+ this.#assertLive();
166
+ const entry = this.#entries.get(ref.id);
167
+ if (ref.kind !== "completion" || !entry)
168
+ throw new EvalHandleError("eval_handle_not_found", `no completion handle ${ref.id}`);
169
+ if (ref.run_epoch !== entry.ref.run_epoch)
170
+ throw new EvalHandleError("eval_handle_stale", `${ref.id} run epoch ${ref.run_epoch} is not live`);
171
+ return entry;
172
+ }
173
+
174
+ #settle(
175
+ entry: CompletionEntry,
176
+ next: { phase: HandlePhase; hostStatus: string; value?: unknown; error?: HandleError },
177
+ ): void {
178
+ if (TERMINAL.has(entry.phase)) return;
179
+ if (entry.timer !== undefined) clearTimeout(entry.timer);
180
+ entry.timer = undefined;
181
+ entry.phase = next.phase;
182
+ entry.hostStatus = next.hostStatus;
183
+ if ("value" in next) entry.value = next.value;
184
+ if (next.error) entry.error = next.error;
185
+ entry.revision += 1;
186
+ const snapshot = snapshotOf(entry);
187
+ for (const queue of this.#watches) queue.push(snapshot);
188
+ }
189
+ }
190
+
191
+ function snapshotOf(entry: CompletionEntry): HandleSnapshot {
192
+ return { ref: entry.ref, phase: entry.phase, host_status: entry.hostStatus, revision: entry.revision };
193
+ }
194
+
195
+ function toHandleError(error: unknown, timedOut: boolean): HandleError {
196
+ if (timedOut) return { code: "completion_deadline", message: DEADLINE_MESSAGE };
197
+ if (error instanceof Error) {
198
+ const code = "code" in error && typeof error.code === "string" ? error.code : "completion_failed";
199
+ return { code, message: error.message };
200
+ }
201
+ return { code: "completion_failed", message: String(error) };
202
+ }
@@ -0,0 +1,103 @@
1
+ import type { HandleRef, OutputRequest } from "@code-yeongyu/senpi";
2
+ import { type Static, type TSchema, Type } from "typebox";
3
+ import { Check, Errors } from "typebox/value";
4
+
5
+ /** Wire shapes of the five reserved handle tools; every ref carries kind, id and run epoch so the host can fence it. */
6
+ const handleRefSchema = Type.Object(
7
+ {
8
+ kind: Type.Union([Type.Literal("agent"), Type.Literal("completion"), Type.Literal("workpool")]),
9
+ id: Type.String({ minLength: 1 }),
10
+ run_epoch: Type.Integer({ minimum: 0 }),
11
+ },
12
+ { additionalProperties: true },
13
+ );
14
+
15
+ export const WAIT_MODES = ["all", "any", "settled"] as const;
16
+ export type WaitMode = (typeof WAIT_MODES)[number];
17
+
18
+ const waitArgsSchema = Type.Object(
19
+ {
20
+ refs: Type.Array(handleRefSchema),
21
+ timeout: Type.Optional(Type.Number({ minimum: 0 })),
22
+ mode: Type.Optional(Type.Union(WAIT_MODES.map((mode) => Type.Literal(mode)))),
23
+ },
24
+ { additionalProperties: false },
25
+ );
26
+ const refArgsSchema = Type.Object({ ref: handleRefSchema }, { additionalProperties: false });
27
+ const outputArgsSchema = Type.Object(
28
+ {
29
+ ref: handleRefSchema,
30
+ format: Type.Optional(Type.Union([Type.Literal("raw"), Type.Literal("tail")])),
31
+ offset: Type.Optional(Type.Integer({ minimum: 0 })),
32
+ limit: Type.Optional(Type.Integer({ minimum: 1 })),
33
+ },
34
+ { additionalProperties: false },
35
+ );
36
+ const sendArgsSchema = Type.Object(
37
+ { ref: handleRefSchema, message: Type.String({ minLength: 1 }) },
38
+ { additionalProperties: false },
39
+ );
40
+
41
+ export interface WaitRequest {
42
+ readonly refs: readonly HandleRef[];
43
+ readonly mode: WaitMode;
44
+ readonly timeoutSeconds?: number;
45
+ }
46
+
47
+ export class HandleArgumentsError extends Error {
48
+ readonly name = "HandleArgumentsError";
49
+ readonly code = "eval_handle_invalid_arguments";
50
+
51
+ constructor(helper: string, summary: string) {
52
+ super(`eval_handle_invalid_arguments: ${helper} received invalid arguments: ${summary}`);
53
+ }
54
+ }
55
+
56
+ export function parseWaitArgs(value: unknown): WaitRequest {
57
+ const parsed = parseWith(waitArgsSchema, value, "wait()");
58
+ if (parsed.timeout !== undefined && !Number.isFinite(parsed.timeout)) {
59
+ throw new HandleArgumentsError("wait()", "timeout must be a finite number of seconds >= 0");
60
+ }
61
+ return {
62
+ refs: parsed.refs.map(toRef),
63
+ mode: parsed.mode ?? "all",
64
+ ...(parsed.timeout === undefined ? {} : { timeoutSeconds: parsed.timeout }),
65
+ };
66
+ }
67
+
68
+ export function parseRefArgs(value: unknown, helper: string): HandleRef {
69
+ return toRef(parseWith(refArgsSchema, value, helper).ref);
70
+ }
71
+
72
+ export function parseOutputArgs(value: unknown): { readonly ref: HandleRef; readonly request: OutputRequest } {
73
+ const parsed = parseWith(outputArgsSchema, value, "control.output()");
74
+ return {
75
+ ref: toRef(parsed.ref),
76
+ request: {
77
+ ...(parsed.format === undefined ? {} : { format: parsed.format }),
78
+ ...(parsed.offset === undefined ? {} : { offset: parsed.offset }),
79
+ ...(parsed.limit === undefined ? {} : { limit: parsed.limit }),
80
+ },
81
+ };
82
+ }
83
+
84
+ export function parseSendArgs(value: unknown): { readonly ref: HandleRef; readonly message: string } {
85
+ const parsed = parseWith(sendArgsSchema, value, "control.send()");
86
+ return { ref: toRef(parsed.ref), message: parsed.message };
87
+ }
88
+
89
+ export function refKey(ref: HandleRef): string {
90
+ return `${ref.kind}:${ref.id}:${ref.run_epoch}`;
91
+ }
92
+
93
+ function toRef(value: Static<typeof handleRefSchema>): HandleRef {
94
+ return { kind: value.kind, id: value.id, run_epoch: value.run_epoch };
95
+ }
96
+
97
+ function parseWith<T extends TSchema>(schema: T, value: unknown, helper: string): Static<T> {
98
+ if (Check(schema, value)) return value;
99
+ const summary = Errors(schema, value)
100
+ .map((error) => `${error.instancePath || "/"} ${error.message}`)
101
+ .join("; ");
102
+ throw new HandleArgumentsError(helper, summary || "invalid value");
103
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ type CancelReceipt,
3
+ EvalHandleError,
4
+ type EvalHandleHost,
5
+ type HandleCallContext,
6
+ type HandleOutcome,
7
+ type HandleRef,
8
+ type HandleSnapshot,
9
+ type HandleWatch,
10
+ type OutputRequest,
11
+ type OutputSnapshot,
12
+ } from "@code-yeongyu/senpi";
13
+ import { CompletionHandles, type CompletionStart } from "./completion-handles.ts";
14
+ import { refKey } from "./handle-args.ts";
15
+ import { mergeWatches } from "./watch-queue.ts";
16
+
17
+ /** What a bridge call brings along: the session's host capability (if any) and the calling cell's signal. */
18
+ export interface HandleBackend {
19
+ readonly host: EvalHandleHost | undefined;
20
+ readonly signal: AbortSignal | undefined;
21
+ }
22
+
23
+ export interface HandleRegistryOptions {
24
+ /** The agent session that owns this registry; every host call is fenced with it. */
25
+ readonly ownerSessionId: string;
26
+ readonly now?: () => number;
27
+ }
28
+
29
+ /**
30
+ * One registry per session generation (created at `session_start`, disposed in `dropRuntime`). Agent and
31
+ * workpool refs are routed to the session's `EvalHandleHost`; completion refs are codemode's own in-flight
32
+ * completions. After disposal every ref fails closed with `eval_handle_stale` before any host is touched.
33
+ */
34
+ export class HandleRegistry {
35
+ readonly ownerSessionId: string;
36
+ readonly #completions: CompletionHandles;
37
+ readonly #open = new Set<HandleWatch>();
38
+ #disposed = false;
39
+
40
+ constructor(options: HandleRegistryOptions) {
41
+ this.ownerSessionId = options.ownerSessionId;
42
+ this.#completions = new CompletionHandles(options.now);
43
+ }
44
+
45
+ get disposed(): boolean {
46
+ return this.#disposed;
47
+ }
48
+
49
+ get openWatches(): number {
50
+ return this.#open.size;
51
+ }
52
+
53
+ get openCompletionWatches(): number {
54
+ return this.#completions.openWatches;
55
+ }
56
+
57
+ startCompletion(input: CompletionStart): HandleRef {
58
+ this.#assertLive();
59
+ return this.#completions.start(input);
60
+ }
61
+
62
+ async watch(refs: readonly HandleRef[], backend: HandleBackend): Promise<HandleWatch> {
63
+ this.#assertLive();
64
+ const local = refs.filter((ref) => this.#completions.owns(ref));
65
+ const remote = refs.filter((ref) => !this.#completions.owns(ref));
66
+ const host = remote.length > 0 ? this.#requireHost(backend, remote) : undefined;
67
+ const parts: HandleWatch[] = [];
68
+ if (local.length > 0) parts.push(this.#completions.watch(local));
69
+ if (host !== undefined) {
70
+ try {
71
+ parts.push(await host.watch(remote, this.#callContext(backend)));
72
+ } catch (error) {
73
+ for (const part of parts) part.close();
74
+ throw error;
75
+ }
76
+ }
77
+ const byKey = new Map(parts.flatMap((part) => part.initial.map((snapshot) => [refKey(snapshot.ref), snapshot])));
78
+ const ordered = refs.map((ref) => {
79
+ const snapshot = byKey.get(refKey(ref));
80
+ if (snapshot === undefined) throw new EvalHandleError("eval_handle_not_found", `no snapshot for ${ref.id}`);
81
+ return snapshot;
82
+ });
83
+ const merged = mergeWatches(ordered, parts);
84
+ const tracked: HandleWatch = {
85
+ initial: merged.initial,
86
+ updates: merged.updates,
87
+ close: () => {
88
+ merged.close();
89
+ this.#open.delete(tracked);
90
+ },
91
+ };
92
+ this.#open.add(tracked);
93
+ if (this.#disposed) tracked.close();
94
+ return tracked;
95
+ }
96
+
97
+ async status(ref: HandleRef, backend: HandleBackend): Promise<HandleSnapshot> {
98
+ const watch = await this.watch([ref], backend);
99
+ try {
100
+ const snapshot = watch.initial[0];
101
+ if (snapshot === undefined) throw new EvalHandleError("eval_handle_not_found", `no snapshot for ${ref.id}`);
102
+ return snapshot;
103
+ } finally {
104
+ watch.close();
105
+ }
106
+ }
107
+
108
+ async result(ref: HandleRef, backend: HandleBackend): Promise<HandleOutcome> {
109
+ this.#assertLive();
110
+ if (this.#completions.owns(ref)) return this.#completions.result(ref);
111
+ return await this.#requireHost(backend, [ref]).result(ref, this.#callContext(backend));
112
+ }
113
+
114
+ async send(ref: HandleRef, message: string, backend: HandleBackend): Promise<HandleSnapshot> {
115
+ this.#assertLive();
116
+ if (this.#completions.owns(ref)) {
117
+ throw new EvalHandleError("eval_handle_operation_unsupported", "send() is for agent handles only");
118
+ }
119
+ return await this.#requireHost(backend, [ref]).send(ref, message, this.#callContext(backend));
120
+ }
121
+
122
+ async cancel(ref: HandleRef, backend: HandleBackend): Promise<CancelReceipt> {
123
+ this.#assertLive();
124
+ if (this.#completions.owns(ref)) return this.#completions.cancel(ref);
125
+ return await this.#requireHost(backend, [ref]).cancel(ref, this.#callContext(backend));
126
+ }
127
+
128
+ async output(ref: HandleRef, request: OutputRequest, backend: HandleBackend): Promise<OutputSnapshot> {
129
+ this.#assertLive();
130
+ if (this.#completions.owns(ref)) return this.#completions.output(ref, request);
131
+ return await this.#requireHost(backend, [ref]).output(ref, request, this.#callContext(backend));
132
+ }
133
+
134
+ /** Closes every open watch and aborts every in-flight completion; later calls fail with `eval_handle_stale`. */
135
+ dispose(): void {
136
+ if (this.#disposed) return;
137
+ this.#disposed = true;
138
+ for (const watch of [...this.#open]) watch.close();
139
+ this.#open.clear();
140
+ this.#completions.dispose();
141
+ }
142
+
143
+ #assertLive(): void {
144
+ if (this.#disposed) {
145
+ throw new EvalHandleError("eval_handle_stale", "this session generation was dropped; its handles are gone");
146
+ }
147
+ }
148
+
149
+ #requireHost(backend: HandleBackend, refs: readonly HandleRef[]): EvalHandleHost {
150
+ if (backend.host !== undefined) return backend.host;
151
+ const kinds = [...new Set(refs.map((ref) => ref.kind))].join("/");
152
+ throw new EvalHandleError(
153
+ "eval_wait_unavailable",
154
+ `wait()/handle() over ${kinds} work is not supported by this runtime: no EvalHandleHost is provided for this session`,
155
+ );
156
+ }
157
+
158
+ #callContext(backend: HandleBackend): HandleCallContext {
159
+ return {
160
+ ownerSessionId: this.ownerSessionId,
161
+ ...(backend.signal === undefined ? {} : { signal: backend.signal }),
162
+ };
163
+ }
164
+ }