@code-yeongyu/senpi-codemode 2026.10.6 → 2026.10.8

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 (72) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +20 -2
  3. package/package.json +5 -4
  4. package/src/bridge/kernel-tools-protocol.ts +8 -0
  5. package/src/bridge/reserved.ts +10 -0
  6. package/src/bridges/eval-virtual-schemas.ts +100 -0
  7. package/src/bridges/handle-bridge.ts +72 -0
  8. package/src/bridges/reserved-dispatch.ts +20 -2
  9. package/src/bridges/schema-bridge.ts +4 -0
  10. package/src/completion/handle.ts +69 -0
  11. package/src/completion/tool-bridge.ts +31 -12
  12. package/src/environments/editable-check.ts +116 -0
  13. package/src/environments/install-lock.ts +223 -0
  14. package/src/environments/py-environment.ts +98 -0
  15. package/src/environments/py-installer.ts +187 -0
  16. package/src/environments/revision-store.ts +95 -0
  17. package/src/extension/bridge-dispatch.ts +41 -0
  18. package/src/extension/bridge-tool-call.ts +5 -1
  19. package/src/extension/host-facts.ts +18 -0
  20. package/src/extension/kernel-registration.ts +5 -0
  21. package/src/extension/removed-tool-hints.ts +15 -0
  22. package/src/extension/runtime-factory.ts +3 -0
  23. package/src/extension/session-manager-contract.ts +3 -0
  24. package/src/extension/session-manager.ts +32 -5
  25. package/src/extension/start-recovery.ts +111 -0
  26. package/src/handles/completion-handles.ts +202 -0
  27. package/src/handles/handle-args.ts +103 -0
  28. package/src/handles/handle-registry.ts +164 -0
  29. package/src/handles/wait.ts +214 -0
  30. package/src/handles/watch-queue.ts +102 -0
  31. package/src/index.ts +99 -94
  32. package/src/kernels/jl/handles.jl +124 -0
  33. package/src/kernels/jl/prelude.jl +9 -0
  34. package/src/kernels/jl/runner.jl +6 -1
  35. package/src/kernels/js/handles.js +93 -0
  36. package/src/kernels/js/kernel-tools-define.d.ts +6 -0
  37. package/src/kernels/js/kernel-tools-define.js +3 -1
  38. package/src/kernels/js/kernel-tools-errors.js +2 -0
  39. package/src/kernels/js/kernel-tools-errors.ts +2 -0
  40. package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
  41. package/src/kernels/js/kernel-tools-registry.js +18 -9
  42. package/src/kernels/js/kernel-tools-schema.js +6 -2
  43. package/src/kernels/js/worker-runtime.js +10 -1
  44. package/src/kernels/py/host-cell.ts +42 -0
  45. package/src/kernels/py/kernel-contract.ts +13 -2
  46. package/src/kernels/py/kernel-tools-host.ts +97 -0
  47. package/src/kernels/py/kernel.ts +88 -12
  48. package/src/kernels/py/prelude.py +797 -11
  49. package/src/kernels/py/process.ts +15 -4
  50. package/src/kernels/py/startup.ts +58 -20
  51. package/src/kernels/py/transport.ts +36 -7
  52. package/src/kernels/rb/handles.rb +97 -0
  53. package/src/kernels/rb/paths.rb +37 -0
  54. package/src/kernels/rb/prelude.rb +15 -38
  55. package/src/kernels/rb/runner.rb +7 -7
  56. package/src/kernels/sandbox/vendor/pi-codemode/LICENSE +21 -0
  57. package/src/kernels/sandbox/vendor/pi-codemode/VENDORED.md +42 -0
  58. package/src/kernels/sandbox/vendor/pi-codemode/identifier.ts +14 -0
  59. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +514 -0
  60. package/src/kernels/sandbox/vendor/pi-codemode/runtime/prelude-source.ts +431 -0
  61. package/src/kernels/sandbox/vendor/pi-codemode/runtime/protocol.ts +61 -0
  62. package/src/kernels/sandbox/vendor/pi-codemode/runtime/worker.ts +231 -0
  63. package/src/kernels/sandbox/vendor/pi-codemode/types.ts +185 -0
  64. package/src/kernels/sandbox/vendor/pi-codemode/wasm.ts +37 -0
  65. package/src/kernels/shared/kernel-tools-pump.ts +6 -0
  66. package/src/kernels/shared/process-cpu.ts +69 -0
  67. package/src/kernels/shared/subprocess-queue.ts +1 -0
  68. package/src/tool/cell-handler.ts +21 -18
  69. package/src/tool/cell-reserved-dispatch.ts +51 -0
  70. package/src/tool/eval-tool-options.ts +3 -0
  71. package/src/tool/run-eval-cell.ts +2 -0
  72. package/src/tool/types.ts +15 -0
@@ -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
+ }
@@ -0,0 +1,214 @@
1
+ import {
2
+ EvalHandleError,
3
+ type HandleOutcome,
4
+ type HandlePhase,
5
+ type HandleRef,
6
+ type HandleSnapshot,
7
+ type HandleWatch,
8
+ } from "@code-yeongyu/senpi";
9
+ import { refKey, type WaitRequest } from "./handle-args.ts";
10
+
11
+ const MAX_TIMER_MS = 2 ** 31 - 1;
12
+
13
+ export interface WaitBackend {
14
+ watch(refs: readonly HandleRef[]): Promise<HandleWatch>;
15
+ result(ref: HandleRef): Promise<HandleOutcome>;
16
+ readonly signal?: AbortSignal;
17
+ }
18
+
19
+ export type WaitAnyResult = { readonly index: number; readonly ref: HandleRef; readonly value: unknown };
20
+
21
+ /** A handle's own failure, raised by `wait(..., {mode: "all"})` and `control.wait()`; `code` is in the text. */
22
+ export class HandleOutcomeError extends Error {
23
+ readonly name = "HandleOutcomeError";
24
+ readonly code: string;
25
+ readonly ref: HandleRef;
26
+ readonly details: unknown;
27
+
28
+ constructor(outcome: Extract<HandleOutcome, { status: "rejected" }>) {
29
+ super(`${outcome.error.code}: ${outcome.error.message}`);
30
+ this.code = outcome.error.code;
31
+ this.ref = outcome.ref;
32
+ this.details = outcome.error.details;
33
+ }
34
+ }
35
+
36
+ export class WaitAllRejectedError extends Error {
37
+ readonly name = "WaitAllRejectedError";
38
+ readonly code = "eval_wait_all_rejected";
39
+ readonly outcomes: readonly HandleOutcome[];
40
+
41
+ constructor(outcomes: readonly HandleOutcome[]) {
42
+ const summary = outcomes
43
+ .map((outcome) =>
44
+ outcome.status === "rejected" ? `${outcome.ref.id}: ${outcome.error.code}` : outcome.ref.id,
45
+ )
46
+ .join("; ");
47
+ super(`eval_wait_all_rejected: wait(any) found no successful handle among ${outcomes.length}; ${summary}`);
48
+ this.outcomes = outcomes;
49
+ }
50
+ }
51
+
52
+ const TERMINAL: ReadonlySet<HandlePhase> = new Set(["succeeded", "failed", "cancelled", "lost"]);
53
+
54
+ /**
55
+ * The in-cell barrier. Subscribes once per distinct ref, keeps duplicate slots, settles according to the
56
+ * mode, and never cancels work: a timeout only closes the subscription. The timeout runs from entry and,
57
+ * like cancelling the cell, also ends a wait that is still subscribing or fetching a result. Parked time
58
+ * is the caller's concern (the bridge-call path pauses the run budget around this call).
59
+ */
60
+ export async function waitForHandles(request: WaitRequest, backend: WaitBackend): Promise<unknown> {
61
+ const { refs, mode } = request;
62
+ if (refs.length === 0) {
63
+ if (mode === "any") throw new EvalHandleError("eval_wait_empty", "wait(any) needs at least one handle");
64
+ return [];
65
+ }
66
+ const unique = [...new Map(refs.map((ref) => [refKey(ref), ref])).values()];
67
+ const outcomes = new Map<string, HandleOutcome>();
68
+ const revisions = new Map<string, number>();
69
+ const stop = interruption(request.timeoutSeconds, backend.signal, refs, outcomes);
70
+ let watch: HandleWatch | undefined;
71
+ const finish = (): unknown => {
72
+ switch (mode) {
73
+ case "all":
74
+ return refs.map((ref) => settledValue(outcomes, ref));
75
+ case "settled":
76
+ return refs.map((ref) => outcomeOf(outcomes, ref));
77
+ case "any":
78
+ return firstSuccess(refs, outcomes) ?? rejectAll(refs, outcomes);
79
+ default:
80
+ return assertNever(mode);
81
+ }
82
+ };
83
+ // Returns true once the mode can settle without waiting for the remaining handles.
84
+ const absorb = async (snapshot: HandleSnapshot): Promise<boolean> => {
85
+ const key = refKey(snapshot.ref);
86
+ const known = revisions.get(key);
87
+ if (known !== undefined && snapshot.revision <= known) return false;
88
+ revisions.set(key, snapshot.revision);
89
+ if (snapshot.ref.kind === "workpool" && snapshot.phase === "pending" && snapshot.host_status === "open") {
90
+ throw new EvalHandleError(
91
+ "eval_pool_open",
92
+ `workpool ${snapshot.ref.id} is still open; close() it before waiting`,
93
+ );
94
+ }
95
+ if (!TERMINAL.has(snapshot.phase) || outcomes.has(key)) return false;
96
+ const outcome = await stop.race(backend.result(snapshot.ref));
97
+ outcomes.set(key, outcome);
98
+ if (mode === "all" && outcome.status === "rejected") throw new HandleOutcomeError(outcome);
99
+ if (mode === "any" && outcome.status === "fulfilled") return true;
100
+ return outcomes.size === unique.length;
101
+ };
102
+ try {
103
+ const subscribing = backend.watch(unique);
104
+ // A watch that resolves after the wait already ended is closed, so its subscription never leaks.
105
+ void subscribing.then(
106
+ (late) => {
107
+ if (stop.ended) late.close();
108
+ },
109
+ () => undefined,
110
+ );
111
+ watch = await stop.race(subscribing);
112
+ for (const snapshot of watch.initial) {
113
+ if (await absorb(snapshot)) return finish();
114
+ }
115
+ if (outcomes.size === unique.length) return finish();
116
+ if (request.timeoutSeconds === 0) throw timeoutError(0, refs, outcomes);
117
+ const iterator = watch.updates[Symbol.asyncIterator]();
118
+ try {
119
+ while (true) {
120
+ const step = await stop.race(iterator.next());
121
+ if (step.done) return finish();
122
+ if (await absorb(step.value)) return finish();
123
+ }
124
+ } finally {
125
+ await iterator.return?.();
126
+ }
127
+ } finally {
128
+ stop.end();
129
+ watch?.close();
130
+ }
131
+ }
132
+
133
+ /** One deadline armed at entry plus the cell's abort; `race` rejects any pending step once either fires. */
134
+ function interruption(
135
+ timeoutSeconds: number | undefined,
136
+ signal: AbortSignal | undefined,
137
+ refs: readonly HandleRef[],
138
+ outcomes: ReadonlyMap<string, HandleOutcome>,
139
+ ) {
140
+ let timer: ReturnType<typeof setTimeout> | undefined;
141
+ let onAbort: (() => void) | undefined;
142
+ let ended = false;
143
+ const fired = new Promise<never>((_resolve, reject) => {
144
+ if (timeoutSeconds !== undefined && timeoutSeconds > 0) {
145
+ // A timer longer than the platform maximum fires at once, so a long deadline is armed in chunks.
146
+ const deadline = Date.now() + timeoutSeconds * 1_000;
147
+ const arm = (): void => {
148
+ const remainingMs = deadline - Date.now();
149
+ if (remainingMs <= 0) {
150
+ reject(timeoutError(timeoutSeconds, refs, outcomes));
151
+ return;
152
+ }
153
+ timer = setTimeout(arm, Math.min(remainingMs, MAX_TIMER_MS));
154
+ };
155
+ arm();
156
+ }
157
+ if (signal !== undefined) {
158
+ onAbort = () =>
159
+ reject(signal.reason instanceof Error ? signal.reason : new Error("wait() cancelled with its cell"));
160
+ if (signal.aborted) onAbort();
161
+ else signal.addEventListener("abort", onAbort, { once: true });
162
+ }
163
+ });
164
+ fired.catch(() => undefined);
165
+ return {
166
+ get ended(): boolean {
167
+ return ended;
168
+ },
169
+ race<T>(step: Promise<T>): Promise<T> {
170
+ return Promise.race([step, fired]);
171
+ },
172
+ end(): void {
173
+ ended = true;
174
+ if (timer !== undefined) clearTimeout(timer);
175
+ if (signal !== undefined && onAbort !== undefined) signal.removeEventListener("abort", onAbort);
176
+ },
177
+ };
178
+ }
179
+
180
+ function timeoutError(seconds: number, refs: readonly HandleRef[], outcomes: ReadonlyMap<string, HandleOutcome>) {
181
+ const settled = refs.filter((ref) => outcomes.has(refKey(ref))).length;
182
+ return new EvalHandleError(
183
+ "eval_wait_timeout",
184
+ `wait() timed out after ${seconds}s; ${settled}/${refs.length} handles settled; work was not cancelled`,
185
+ );
186
+ }
187
+
188
+ function outcomeOf(outcomes: ReadonlyMap<string, HandleOutcome>, ref: HandleRef): HandleOutcome {
189
+ const outcome = outcomes.get(refKey(ref));
190
+ if (outcome === undefined) throw new EvalHandleError("eval_handle_pending", `${ref.id} did not settle`);
191
+ return outcome;
192
+ }
193
+
194
+ function settledValue(outcomes: ReadonlyMap<string, HandleOutcome>, ref: HandleRef): unknown {
195
+ const outcome = outcomeOf(outcomes, ref);
196
+ if (outcome.status === "rejected") throw new HandleOutcomeError(outcome);
197
+ return outcome.value;
198
+ }
199
+
200
+ function firstSuccess(refs: readonly HandleRef[], outcomes: ReadonlyMap<string, HandleOutcome>) {
201
+ for (const [index, ref] of refs.entries()) {
202
+ const outcome = outcomes.get(refKey(ref));
203
+ if (outcome?.status === "fulfilled") return { index, ref, value: outcome.value } satisfies WaitAnyResult;
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ function rejectAll(refs: readonly HandleRef[], outcomes: ReadonlyMap<string, HandleOutcome>): never {
209
+ throw new WaitAllRejectedError(refs.map((ref) => outcomeOf(outcomes, ref)));
210
+ }
211
+
212
+ function assertNever(value: never): never {
213
+ throw new TypeError(`Unsupported wait mode: ${String(value)}`);
214
+ }
@@ -0,0 +1,102 @@
1
+ import type { HandleSnapshot, HandleWatch } from "@code-yeongyu/senpi";
2
+ import { refKey } from "./handle-args.ts";
3
+
4
+ /**
5
+ * A pull-side buffer for handle snapshots. Pushes queue up until the consumer iterates, so a change
6
+ * that lands between subscription and `initial` is delivered exactly once; `close()` is idempotent
7
+ * and ends the iterator.
8
+ */
9
+ export class WatchQueue {
10
+ readonly #keys: ReadonlySet<string> | undefined;
11
+ readonly #queue: HandleSnapshot[] = [];
12
+ #wake: (() => void) | undefined;
13
+ #closed = false;
14
+ #failure: unknown;
15
+ #failed = false;
16
+
17
+ /** Without keys every snapshot is accepted (used by the merger); with keys only matching refs are. */
18
+ constructor(keys?: readonly string[]) {
19
+ this.#keys = keys === undefined ? undefined : new Set(keys);
20
+ }
21
+
22
+ get closed(): boolean {
23
+ return this.#closed;
24
+ }
25
+
26
+ push(snapshot: HandleSnapshot): void {
27
+ if (this.#closed || (this.#keys !== undefined && !this.#keys.has(refKey(snapshot.ref)))) return;
28
+ this.#queue.push(snapshot);
29
+ this.#wake?.();
30
+ }
31
+
32
+ close(): void {
33
+ if (this.#closed) return;
34
+ this.#closed = true;
35
+ this.#wake?.();
36
+ }
37
+
38
+ /** Ends the stream with an error: the next pull rejects instead of reporting a clean end. */
39
+ fail(error: unknown): void {
40
+ if (this.#closed) return;
41
+ this.#failed = true;
42
+ this.#failure = error;
43
+ this.close();
44
+ }
45
+
46
+ toWatch(initial: readonly HandleSnapshot[], onClose?: () => void): HandleWatch {
47
+ const close = (): void => {
48
+ this.close();
49
+ onClose?.();
50
+ };
51
+ const next = async (): Promise<IteratorResult<HandleSnapshot>> => {
52
+ while (this.#queue.length === 0 && !this.#closed) {
53
+ await new Promise<void>((resolve) => {
54
+ this.#wake = resolve;
55
+ });
56
+ }
57
+ this.#wake = undefined;
58
+ const value = this.#queue.shift();
59
+ if (value !== undefined) return { done: false, value };
60
+ if (this.#failed) throw this.#failure;
61
+ return { done: true, value: undefined };
62
+ };
63
+ const updates: AsyncIterable<HandleSnapshot> = {
64
+ [Symbol.asyncIterator]: () => ({
65
+ next,
66
+ return: async (): Promise<IteratorResult<HandleSnapshot>> => {
67
+ close();
68
+ return { done: true, value: undefined };
69
+ },
70
+ }),
71
+ };
72
+ return { initial, updates, close };
73
+ }
74
+ }
75
+
76
+ /** One watch over several: `initial` keeps the caller's order, updates from every part are drained eagerly. */
77
+ export function mergeWatches(ordered: readonly HandleSnapshot[], parts: readonly HandleWatch[]): HandleWatch {
78
+ if (parts.length === 1 && parts[0] !== undefined) return { ...parts[0], initial: ordered };
79
+ const queue = new WatchQueue();
80
+ let open = parts.length;
81
+ const drain = async (part: HandleWatch): Promise<void> => {
82
+ try {
83
+ for await (const snapshot of part.updates) queue.push(snapshot);
84
+ } catch (error) {
85
+ queue.fail(error);
86
+ } finally {
87
+ open -= 1;
88
+ if (open === 0) queue.close();
89
+ }
90
+ };
91
+ if (parts.length === 0) queue.close();
92
+ for (const part of parts) void drain(part);
93
+ const merged = queue.toWatch(ordered);
94
+ return {
95
+ initial: merged.initial,
96
+ updates: merged.updates,
97
+ close: () => {
98
+ for (const part of parts) part.close();
99
+ merged.close();
100
+ },
101
+ };
102
+ }