@astrosheep/keiyaku 4.6.17 → 4.6.18

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.
@@ -1,5 +1,5 @@
1
1
  import { projectAkumaBirth } from "./caller.js";
2
- import { createStatusFactReader, readLifeSnapshot, HeldAkumaLeash, isHeartAbsent, life, lifeAt, probeLeash, readHeart, readSeal, readSoul, } from "./heart/index.js";
2
+ import { createStatusFactReader, readLifeSnapshot, HeldAkumaLeash, heartExists, isHeartAbsent, life, lifeAt, probeLeash, readHeart, readSeal, readSoul, } from "./heart/index.js";
3
3
  import { pathsForAkuId } from "./identity.js";
4
4
  import { ordinarySnapshotBudget, projectTurns, selectHistory, selectSnapshot, } from "./projection.js";
5
5
  import { resolveProviderExecution } from "./providers/index.js";
@@ -30,7 +30,7 @@ export async function rosterListRow(paths, expected, aliases = []) {
30
30
  : { id: expected, life: "stillborn", seal, aliases };
31
31
  }
32
32
  catch (error) {
33
- if (isHeartAbsent(error))
33
+ if (isHeartAbsent(error) && !(await heartExists(paths)))
34
34
  return { id: expected, life: "unborn", aliases };
35
35
  throw error;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "4.6.17",
3
+ "version": "4.6.18",
4
4
  "workspaces": [
5
5
  "plugins/square",
6
6
  "plugins/paseo/forwarder"
@@ -1,10 +1,5 @@
1
1
  import { z } from "zod";
2
2
 
3
- export const identityKeys = [
4
- "CLAUDE_CODE_SESSION_ID", "CLAUDE_CODE_CHILD_SESSION", "CLAUDECODE", "CODEX_THREAD_ID",
5
- "OPENCODE_SESSION_ID", "PI_SESSION_ID", "PI_SESSION_FILE", "PASEO_AGENT_ID",
6
- "SQUARE_PARTICIPANT_NAME", "AKUMA_REQUESTS", "KEIYAKU_ACTOR_ID", "KEIYAKU_EXTERNAL_CALLER",
7
- ] as const;
8
3
  const count = z.number().int().nonnegative();
9
4
  const base = z.object({ sequence: count, at: z.string() });
10
5
  const turnFields = { turnSequence: count };
@@ -1,35 +1,37 @@
1
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
2
4
  import { z } from "zod";
3
- import { historySchema, identityKeys, type History } from "./keiyaku.js";
5
+ import { historySchema, type History } from "./keiyaku.js";
4
6
  import { tellReceiptSchema } from "./message.js";
5
- import { WORKER_SOURCE } from "./sdk-worker.js";
6
7
  import { stopAttempt } from "./stop.js";
7
8
  import type { AkumaTransport } from "./transport.js";
8
9
 
9
10
  const DEFAULT_TIMEOUT_MS = 30_000;
10
- const STDERR_LIMIT = 8 * 1024;
11
+ const PACKAGE_NAME = "@astrosheep/keiyaku";
12
+ const stopEvidence = z.string();
11
13
 
12
- /**
13
- * How one request behaves when it is abandoned or when its worker retires. A
14
- * bounded ordinary request carries its own id and its own outcome; an unbounded
15
- * stream frame carries the subscription it belongs to, so releasing it cancels
16
- * that observation alone and says what became of the frame.
17
- */
18
- type RequestMode = Readonly<{ timeout: boolean; subscription: boolean; tail: string }>;
19
- const ORDINARY_REQUEST: RequestMode = {
20
- timeout: true, subscription: false, tail: "its outcome is uncertain",
14
+ type PublicAkuma = {
15
+ tell(text: string, options: Readonly<{ signal: AbortSignal }>): Promise<unknown>;
16
+ kill(options: Readonly<{ signal: AbortSignal }>): Promise<unknown>;
17
+ status(): Promise<unknown>;
18
+ stream(options: Readonly<{ signal: AbortSignal }>): AsyncIterable<unknown>;
21
19
  };
22
- /** Opening a subscription is one bounded handshake naming the observation to serve. */
23
- const OPEN_STREAM: RequestMode = { timeout: true, subscription: true, tail: "the observation was released" };
24
- /** An idle frame has no bound: waiting for the next change is the stream's whole job. */
25
- const STREAM_FRAME: RequestMode = { timeout: false, subscription: true, tail: "the observation was released" };
26
-
27
- const workerResponse = z.union([
28
- z.object({ id: z.number().int().positive(), ok: z.literal(true), value: z.unknown() }),
29
- z.object({ id: z.number().int().positive(), ok: z.literal(false), error: z.object({ message: z.string() }) }),
30
- ]);
31
- const statusObservation = z.object({ provider: z.string().optional(), model: z.string().optional() });
32
- const stopEvidence = z.object({ evidence: z.string() });
20
+ type KeiyakuSdk = {
21
+ Akuma: {
22
+ prototype: { stream: unknown };
23
+ select(world: string, akuId: string): PublicAkuma;
24
+ };
25
+ };
26
+ type PackageManifest = Record<string, unknown> & { name?: unknown; version?: unknown };
27
+ type PackageRoot = Readonly<{ root: string; manifest: PackageManifest }>;
28
+ type ResolvedEntry = Readonly<{ entry: string; version: string }>;
29
+ type ActiveScope = Readonly<{
30
+ signal: AbortSignal;
31
+ closeMessage: string;
32
+ abort(reason: unknown): void;
33
+ dispose(): void;
34
+ }>;
33
35
 
34
36
  export type SdkTransportOptions = Readonly<{
35
37
  /** An explicit public-entry file or URL; discovery through PATH is the production path. */
@@ -38,158 +40,171 @@ export type SdkTransportOptions = Readonly<{
38
40
  timeoutMs?: number;
39
41
  }>;
40
42
 
41
- /**
42
- * The identity-free environment the worker inherits. The worker runs the
43
- * public operations in its own process, so inherited harness, Square, and
44
- * Keiyaku caller identities are removed rather than inherited or replaced. The
45
- * shared process environment is never mutated.
46
- */
47
- export function cleanEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
48
- const env: NodeJS.ProcessEnv = { ...source };
49
- for (const key of identityKeys) delete env[key];
50
- return env;
43
+ function record(value: unknown): Record<string, unknown> | undefined {
44
+ return typeof value === "object" && value !== null && !Array.isArray(value)
45
+ ? value as Record<string, unknown>
46
+ : undefined;
51
47
  }
52
48
 
53
- function diagnostic(error: unknown): string {
54
- return error instanceof Error ? error.message : String(error);
49
+ async function realFile(candidate: string): Promise<string | undefined> {
50
+ try { return await realpath(candidate); } catch { return undefined; }
55
51
  }
56
52
 
57
- type Pending = {
58
- /** The worker that owns this request; another worker's exit never settles it. */
59
- worker: ChildProcessWithoutNullStreams;
60
- resolve(value: unknown): void;
61
- reject(error: Error): void;
62
- /** Drop every listener and timer this request added, once it has settled. */
63
- release(): void;
64
- timer: NodeJS.Timeout | undefined;
65
- /** How this request's disappearance is spoken about when it never settles itself. */
66
- tail: string;
67
- };
53
+ async function findOnPath(name: string): Promise<string | undefined> {
54
+ const extensions = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
55
+ for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
56
+ if (directory === "") continue;
57
+ for (const extension of extensions) {
58
+ const found = await realFile(path.join(directory, name + extension));
59
+ if (found !== undefined) return found;
60
+ }
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ async function packageRoot(file: string): Promise<PackageRoot | undefined> {
66
+ let directory = path.dirname(file);
67
+ for (;;) {
68
+ try {
69
+ const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")) as PackageManifest;
70
+ if (manifest.name === PACKAGE_NAME) return { root: directory, manifest };
71
+ } catch { /* keep walking toward the package root */ }
72
+ const parent = path.dirname(directory);
73
+ if (parent === directory) return undefined;
74
+ directory = parent;
75
+ }
76
+ }
77
+
78
+ function declaredEntry(pkg: PackageRoot): string | undefined {
79
+ const rootExport = record(pkg.manifest.exports)?.["."];
80
+ const conditions = record(rootExport);
81
+ const relative = typeof rootExport === "string" ? rootExport
82
+ : typeof conditions?.import === "string" ? conditions.import
83
+ : typeof conditions?.default === "string" ? conditions.default : undefined;
84
+ const chosen = relative ?? (typeof pkg.manifest.main === "string" ? pkg.manifest.main : undefined);
85
+ return chosen === undefined ? undefined : path.join(pkg.root, chosen);
86
+ }
87
+
88
+ async function resolveEntry(explicit?: string): Promise<ResolvedEntry> {
89
+ if (explicit !== undefined && explicit !== "") {
90
+ const entry = explicit.startsWith("file:") ? fileURLToPath(explicit) : path.resolve(explicit);
91
+ const pkg = await packageRoot(entry);
92
+ return { entry, version: typeof pkg?.manifest.version === "string" ? pkg.manifest.version : "unknown" };
93
+ }
94
+ const bin = await findOnPath("keiyaku");
95
+ if (bin === undefined) throw new Error("the keiyaku executable is not on PATH; the Paseo bridge cannot locate its public package");
96
+ const pkg = await packageRoot(bin);
97
+ if (pkg === undefined) throw new Error(`no ${PACKAGE_NAME} package owns ${bin}`);
98
+ const entry = declaredEntry(pkg);
99
+ if (entry === undefined) throw new Error(`the installed ${PACKAGE_NAME} package declares no public entry`);
100
+ return { entry, version: typeof pkg.manifest.version === "string" ? pkg.manifest.version : "unknown" };
101
+ }
102
+
103
+ function assertSdkSurface(sdk: KeiyakuSdk, version: string): void {
104
+ if (typeof sdk.Akuma?.select !== "function")
105
+ throw new Error(`Installed ${PACKAGE_NAME} version ${version} does not expose Akuma.select`);
106
+ if (typeof sdk.Akuma.prototype?.stream !== "function")
107
+ throw new Error(`Installed ${PACKAGE_NAME} version ${version} does not expose Akuma.prototype.stream`);
108
+ }
109
+
110
+ function abortReason(signal: AbortSignal): Error {
111
+ return signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason ?? "Keiyaku SDK request was cancelled"));
112
+ }
113
+
114
+ /** Race an SDK operation with its owning signal even when the operation ignores cancellation. */
115
+ function raceAbort<T>(signal: AbortSignal, operation: () => T | PromiseLike<T>): Promise<T> {
116
+ signal.throwIfAborted();
117
+ return new Promise<T>((resolve, reject) => {
118
+ let settled = false;
119
+ const finish = (outcome: Readonly<{ value: T }> | Readonly<{ error: unknown }>): void => {
120
+ if (settled) return;
121
+ settled = true;
122
+ signal.removeEventListener("abort", onAbort);
123
+ if ("error" in outcome) reject(outcome.error);
124
+ else resolve(outcome.value);
125
+ };
126
+ const onAbort = () => finish({ error: abortReason(signal) });
127
+ signal.addEventListener("abort", onAbort, { once: true });
128
+ if (signal.aborted) { onAbort(); return; }
129
+ Promise.resolve().then(() => {
130
+ signal.throwIfAborted();
131
+ return operation();
132
+ }).then(
133
+ (value) => finish({ value }),
134
+ (error: unknown) => finish({ error }),
135
+ );
136
+ });
137
+ }
68
138
 
69
139
  /**
70
- * The dedicated public-SDK transport. One worker process serves every request
71
- * over a typed line protocol; requests carry their own cancellation and never
72
- * terminate one another. A worker that exits or fails to start settles only the
73
- * requests it owned, and the next request starts a fresh one.
140
+ * Adapt the installed public SDK in this plugin process. Each request owns its
141
+ * cancellation and timeout; a stream owns one signal across its idle frames.
74
142
  */
75
143
  export function createSdkTransport(options: SdkTransportOptions = {}): AkumaTransport {
76
144
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
77
- let child: ChildProcessWithoutNullStreams | undefined;
78
- let nextId = 1;
79
- let buffered = "";
80
- let stderr = "";
81
145
  let closed = false;
82
- const pending = new Map<number, Pending>();
146
+ let sdk: KeiyakuSdk | undefined;
147
+ let loading: Promise<KeiyakuSdk> | undefined;
148
+ const active = new Set<ActiveScope>();
83
149
 
84
- function settle(id: number, entry: Pending, outcome: Readonly<{ value: unknown }> | Readonly<{ error: Error }>): void {
85
- if (pending.get(id) !== entry) return;
86
- pending.delete(id);
87
- entry.release();
88
- if ("error" in outcome) entry.reject(outcome.error);
89
- else entry.resolve(outcome.value);
90
- }
91
-
92
- function retire(worker: ChildProcessWithoutNullStreams, diagnostic: (entry: Pending) => string): void {
93
- if (child === worker) child = undefined;
94
- for (const [id, entry] of [...pending]) if (entry.worker === worker) settle(id, entry, { error: new Error(diagnostic(entry)) });
150
+ async function loadSdk(): Promise<KeiyakuSdk> {
151
+ if (sdk !== undefined) return sdk;
152
+ if (loading !== undefined) return await loading;
153
+ const attempt = (async () => {
154
+ const resolved = await resolveEntry(options.entry);
155
+ const loaded = await import(pathToFileURL(resolved.entry).href) as KeiyakuSdk;
156
+ assertSdkSurface(loaded, resolved.version);
157
+ sdk = loaded;
158
+ return loaded;
159
+ })();
160
+ loading = attempt;
161
+ try {
162
+ return await attempt;
163
+ } finally {
164
+ if (loading === attempt) loading = undefined;
165
+ }
95
166
  }
96
167
 
97
- function ensure(): ChildProcessWithoutNullStreams {
98
- if (child !== undefined) return child;
99
- const args = ["--input-type=module", "-e", WORKER_SOURCE];
100
- if (options.entry !== undefined) args.push(options.entry);
101
- const spawned = spawn(process.execPath, args, { env: cleanEnvironment(), stdio: "pipe" });
102
- child = spawned;
103
- buffered = "";
104
- stderr = "";
105
- spawned.stdout.setEncoding("utf8");
106
- spawned.stderr.setEncoding("utf8");
107
- spawned.stdout.on("data", (chunk: string) => { buffered += chunk; read(); });
108
- spawned.stderr.on("data", (chunk: string) => {
109
- stderr = (stderr + chunk).slice(-STDERR_LIMIT);
110
- process.stderr.write(chunk);
111
- });
112
- // EPIPE can race a worker that is already retiring; the exit event owns its diagnostic.
113
- spawned.stdin.on("error", () => {});
114
- spawned.on("error", (error) => retire(spawned,
115
- (entry) => `Keiyaku SDK worker failed to start: ${diagnostic(error)}; ${entry.tail}`));
116
- spawned.on("exit", (code, signal) => retire(spawned, (entry) =>
117
- `Keiyaku SDK worker exited (${signal === null ? String(code) : signal})` +
118
- `${stderr.trim() === "" ? "" : `: ${stderr.trim()}`}; ${entry.tail}`));
119
- return spawned;
168
+ function createScope(signal: AbortSignal, closeMessage: string): ActiveScope {
169
+ signal.throwIfAborted();
170
+ if (closed) throw new Error("Keiyaku SDK transport is closed");
171
+ const controller = new AbortController();
172
+ const scope: ActiveScope = {
173
+ signal: AbortSignal.any([signal, controller.signal]),
174
+ closeMessage,
175
+ abort(reason) { if (!controller.signal.aborted) controller.abort(reason); },
176
+ dispose() { active.delete(scope); },
177
+ };
178
+ active.add(scope);
179
+ return scope;
120
180
  }
121
181
 
122
- function read(): void {
123
- for (;;) {
124
- const newline = buffered.indexOf("\n");
125
- if (newline < 0) return;
126
- const line = buffered.slice(0, newline);
127
- buffered = buffered.slice(newline + 1);
128
- if (line.trim() === "") continue;
129
- const decoded = workerResponse.safeParse((() => { try { return JSON.parse(line); } catch { return undefined; } })());
130
- if (!decoded.success) {
131
- process.stderr.write("Keiyaku SDK worker wrote an unrecognized response line\n");
132
- continue;
133
- }
134
- const entry = pending.get(decoded.data.id);
135
- if (entry === undefined) continue;
136
- settle(decoded.data.id, entry, decoded.data.ok ? { value: decoded.data.value } : { error: new Error(decoded.data.error.message) });
137
- }
182
+ function withTimeout<T>(scope: ActiveScope, tail: string, operation: () => T | PromiseLike<T>): Promise<T> {
183
+ const timer = setTimeout(() => scope.abort(
184
+ new Error(`Keiyaku SDK request timed out after ${timeoutMs}ms; ${tail}`),
185
+ ), timeoutMs);
186
+ return raceAbort(scope.signal, operation).finally(() => clearTimeout(timer));
138
187
  }
139
188
 
140
- function request(op: string, payload: Record<string, unknown>, signal: AbortSignal, mode: RequestMode = ORDINARY_REQUEST): Promise<unknown> {
141
- signal.throwIfAborted();
142
- if (closed) return Promise.reject(new Error("Keiyaku SDK transport is closed"));
143
- const worker = ensure();
144
- const id = nextId++;
145
- // A stream request names the subscription it serves; every other request is
146
- // its own subject.
147
- const target = mode.subscription && typeof payload.target === "number" ? payload.target : id;
148
- return new Promise<unknown>((resolve, reject) => {
149
- const cancel = () => {
150
- if (!worker.stdin.writable) return;
151
- worker.stdin.write(JSON.stringify(mode.subscription
152
- ? { id, op: "stream-cancel", target }
153
- : { id, op: "cancel", target: id }) + "\n");
154
- };
155
- const entry: Pending = {
156
- worker, resolve, reject, tail: mode.tail,
157
- release: () => { clearTimeout(entry.timer); signal.removeEventListener("abort", abandon); },
158
- timer: undefined,
159
- };
160
- // A cancelled request tells the worker to stop observing it. The worker
161
- // keeps serving every other request, and an observation of its own is
162
- // released without reaching any unrelated one.
163
- const abandon = () => {
164
- cancel();
165
- settle(id, entry, { error: signal.reason instanceof Error ? signal.reason : new Error("Keiyaku SDK request was cancelled") });
166
- };
167
- if (mode.timeout) entry.timer = setTimeout(() => {
168
- cancel();
169
- settle(id, entry, { error: new Error(`Keiyaku SDK request timed out after ${timeoutMs}ms; any admitted work's outcome is uncertain`) });
170
- }, timeoutMs);
171
- pending.set(id, entry);
172
- signal.addEventListener("abort", abandon, { once: true });
173
- worker.stdin.write(JSON.stringify({ id, op, ...payload }) + "\n", (error) => {
174
- if (error) settle(id, entry, { error: new Error(`Keiyaku SDK worker is not writable: ${diagnostic(error)}`) });
189
+ async function request<T>(
190
+ world: string, akuId: string, signal: AbortSignal,
191
+ operation: (akuma: PublicAkuma, signal: AbortSignal) => T | PromiseLike<T>,
192
+ ): Promise<T> {
193
+ const scope = createScope(signal,
194
+ "Keiyaku SDK transport closed while the request was in flight; its outcome is uncertain");
195
+ try {
196
+ return await withTimeout(scope, "any admitted work's outcome is uncertain", async () => {
197
+ const installed = await loadSdk();
198
+ scope.signal.throwIfAborted();
199
+ return await operation(installed.Akuma.select(world, akuId), scope.signal);
175
200
  });
176
- });
177
- }
178
-
179
- /**
180
- * Release one subscription without owning its response: the observation's
181
- * frames are already worthless to the caller that gave it up, and the worker
182
- * answers the cancel itself.
183
- */
184
- function releaseSubscription(target: number): void {
185
- if (closed) return;
186
- const worker = child;
187
- if (worker === undefined || !worker.stdin.writable) return;
188
- worker.stdin.write(JSON.stringify({ id: nextId++, op: "stream-cancel", target }) + "\n", () => {});
201
+ } finally {
202
+ scope.dispose();
203
+ }
189
204
  }
190
205
 
191
206
  const tell: AkumaTransport["tell"] = async (world, akuId, signal, text) => {
192
- const value = await request("tell", { world, aku: akuId, text }, signal);
207
+ const value = await request(world, akuId, signal, (akuma, requestSignal) => akuma.tell(text, { signal: requestSignal }));
193
208
  const decoded = tellReceiptSchema.parse(value);
194
209
  if (decoded.akuma !== akuId || decoded.tell.admission.tellId !== decoded.tell.row.tellId || decoded.tell.row.text !== text)
195
210
  throw new Error("Keiyaku Tell outcome is uncertain: the admission receipt did not name the submitted Tell");
@@ -197,73 +212,85 @@ export function createSdkTransport(options: SdkTransportOptions = {}): AkumaTran
197
212
  };
198
213
 
199
214
  const stop: AkumaTransport["stop"] = async (world, akuId, signal) => {
200
- const value = await request("stop", { world, aku: akuId }, signal);
201
- return stopAttempt(stopEvidence.parse(value).evidence, akuId);
215
+ const value = await request(world, akuId, signal, (akuma, requestSignal) => akuma.kill({ signal: requestSignal }));
216
+ return stopAttempt(stopEvidence.parse(value), akuId);
202
217
  };
203
218
 
204
219
  const status: AkumaTransport["status"] = async (world, akuId, signal) => {
205
- const decoded = statusObservation.parse(await request("status", { world, aku: akuId }, signal));
220
+ const value = await request(world, akuId, signal, (akuma) => akuma.status());
221
+ const fields = record(value);
206
222
  return {
207
- ...(decoded.provider === undefined ? {} : { provider: decoded.provider }),
208
- ...(decoded.model === undefined ? {} : { model: decoded.model }),
223
+ ...(typeof fields?.provider === "string" ? { provider: fields.provider } : {}),
224
+ ...(typeof fields?.model === "string" ? { model: fields.model } : {}),
209
225
  };
210
226
  };
211
227
 
212
- /**
213
- * One pull-backed observation. The subscription is established by the first
214
- * pull, each pull has at most one outstanding frame, and abandoning the
215
- * caller's signal or the iterator releases exactly this subscription.
216
- */
217
228
  const stream: AkumaTransport["stream"] = (world, akuId, signal) => ({
218
229
  [Symbol.asyncIterator](): AsyncIterator<History> {
219
- let subscription: number | undefined;
220
- let released = false;
230
+ let scope: ActiveScope | undefined;
231
+ let iterator: AsyncIterator<unknown> | undefined;
221
232
  let finished = false;
222
233
  let pulling = false;
223
- const release = (): void => {
224
- const target = subscription;
225
- subscription = undefined;
226
- if (target !== undefined) releaseSubscription(target);
234
+ const finish = (reason: unknown = new Error("Keiyaku SDK stream observation was released")): void => {
235
+ if (finished) return;
236
+ finished = true;
237
+ const current = iterator;
238
+ iterator = undefined;
239
+ const currentScope = scope;
240
+ scope = undefined;
241
+ if (currentScope !== undefined) {
242
+ currentScope.signal.removeEventListener("abort", abandon);
243
+ currentScope.abort(reason);
244
+ currentScope.dispose();
245
+ }
246
+ try {
247
+ const closing = current?.return?.();
248
+ if (closing !== undefined) void Promise.resolve(closing).catch(() => {});
249
+ } catch { /* the observation is already released */ }
250
+ };
251
+ const abandon = () => finish(scope?.signal.reason);
252
+ const observation = (): ActiveScope => {
253
+ scope ??= createScope(signal,
254
+ "Keiyaku SDK transport closed while the request was in flight; the observation was released");
255
+ scope.signal.addEventListener("abort", abandon, { once: true });
256
+ return scope;
227
257
  };
228
- // Abort releases the observation even while the caller is not pulling.
229
- const abandon = () => { released = true; release(); };
230
- signal.addEventListener("abort", abandon, { once: true });
231
- const unwind = (): void => { signal.removeEventListener("abort", abandon); release(); };
232
258
  return {
233
259
  async next(): Promise<IteratorResult<History>> {
234
- // One frame at a time is the transport's own contract, not a habit of
235
- // its caller: a second pull here would put two frames in flight.
236
260
  if (pulling)
237
261
  throw new Error("Keiyaku SDK stream subscription already has a frame in flight; pull one frame at a time");
262
+ if (finished) return { done: true, value: undefined };
238
263
  pulling = true;
239
264
  try {
240
- if (finished || released || signal.aborted) { unwind(); return { done: true, value: undefined }; }
241
- if (subscription === undefined) {
242
- const opened = z.object({ subscription: z.number().int().positive() })
243
- .parse(await request("stream", { world, aku: akuId }, signal, OPEN_STREAM));
244
- subscription = opened.subscription;
265
+ const currentScope = observation();
266
+ if (iterator === undefined) {
267
+ await withTimeout(currentScope, "the observation was released", async () => {
268
+ const installed = await loadSdk();
269
+ currentScope.signal.throwIfAborted();
270
+ iterator = installed.Akuma.select(world, akuId).stream({ signal: currentScope.signal })[Symbol.asyncIterator]();
271
+ });
245
272
  }
246
- const frame = z.union([z.object({ done: z.literal(true) }),
247
- z.object({ done: z.literal(false), history: z.unknown() })])
248
- .parse(await request("stream-pull", { target: subscription }, signal, STREAM_FRAME));
249
- if (frame.done) { unwind(); return { done: true, value: undefined }; }
250
- return { done: false, value: historySchema.parse(frame.history) };
273
+ const frame = await raceAbort(currentScope.signal, () => iterator!.next());
274
+ if (frame.done) {
275
+ finish();
276
+ return { done: true, value: undefined };
277
+ }
278
+ return { done: false, value: historySchema.parse(frame.value) };
251
279
  } catch (error) {
252
- // A failed or released observation is over: it never silently reopens
253
- // a subscription the caller did not ask for again.
254
- finished = true;
255
- unwind();
280
+ finish(error);
256
281
  throw error;
257
282
  } finally {
258
283
  pulling = false;
259
284
  }
260
285
  },
261
286
  async return(): Promise<IteratorResult<History>> {
262
- finished = true;
263
- unwind();
287
+ finish();
264
288
  return { done: true, value: undefined };
265
289
  },
266
- async throw(error?: unknown): Promise<IteratorResult<History>> { finished = true; unwind(); throw error; },
290
+ async throw(error?: unknown): Promise<IteratorResult<History>> {
291
+ finish(error);
292
+ throw error;
293
+ },
267
294
  };
268
295
  },
269
296
  });
@@ -271,18 +298,7 @@ export function createSdkTransport(options: SdkTransportOptions = {}): AkumaTran
271
298
  async function close(): Promise<void> {
272
299
  if (closed) return;
273
300
  closed = true;
274
- const worker = child;
275
- child = undefined;
276
- for (const [id, entry] of [...pending])
277
- settle(id, entry, { error: new Error(`Keiyaku SDK transport closed while the request was in flight; ${entry.tail}`) });
278
- if (worker === undefined) return;
279
- if (worker.exitCode !== null || worker.signalCode !== null) return;
280
- await new Promise<void>((resolve) => {
281
- const force = setTimeout(() => worker.kill("SIGKILL"), 2_000);
282
- worker.once("close", () => { clearTimeout(force); resolve(); });
283
- try { worker.stdin.end(); } catch { /* the worker may already be gone */ }
284
- worker.kill("SIGTERM");
285
- });
301
+ for (const scope of [...active]) scope.abort(new Error(scope.closeMessage));
286
302
  }
287
303
 
288
304
  return Object.freeze({ stream, tell, stop, status, close });
@@ -1,237 +0,0 @@
1
- /**
2
- * The dedicated Keiyaku worker the Paseo bridge spawns.
3
- *
4
- * Paseo compiles a directory-source plugin into one evaluated bundle, so the
5
- * plugin directory has no worker module to point at once it is loaded, and a
6
- * static or literal dynamic import of the package root is rejected by Paseo's
7
- * own module boundary (the repository self-link resolves outside the plugin
8
- * directory). The worker therefore travels as source text and is started with
9
- * `node --input-type=module -e`. It locates the installed public package
10
- * portably: the `keiyaku` executable on PATH, then the package root that owns
11
- * it, then that package's own declared public entry. An explicit entry
12
- * argument overrides discovery for tests and embedding.
13
- *
14
- * The worker is deliberately the thinnest possible host: it imports the public
15
- * package root, serves one JSON request per line, and answers with the public
16
- * operations' own values. It never formats CLI arguments, never renders text,
17
- * and never decides a lifecycle outcome. Identity is stripped by the spawning
18
- * transport's environment; this module never touches `process.env`.
19
- *
20
- * `tests/paseo-sdk-transport.test.ts` type-checks this text against the public
21
- * TypeScript surface and runs it as a real process, so keep it valid JavaScript
22
- * with JSDoc types and free of backticks and template substitutions.
23
- */
24
- export const WORKER_SOURCE = String.raw`import { readFile, realpath } from "node:fs/promises";
25
- import path from "node:path";
26
- import { pathToFileURL } from "node:url";
27
- import { createInterface } from "node:readline";
28
-
29
- const EXPLICIT_ENTRY = process.argv[1];
30
-
31
- /** @param {string} candidate @returns {Promise<string | undefined>} */
32
- async function realFile(candidate) {
33
- try { return await realpath(candidate); } catch { return undefined; }
34
- }
35
-
36
- /** @param {string} name @returns {Promise<string | undefined>} */
37
- async function findOnPath(name) {
38
- const extensions = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
39
- for (const directory of (process.env.PATH || "").split(path.delimiter)) {
40
- if (directory === "") continue;
41
- for (const extension of extensions) {
42
- const found = await realFile(path.join(directory, name + extension));
43
- if (found !== undefined) return found;
44
- }
45
- }
46
- return undefined;
47
- }
48
-
49
- /** @param {string} bin @returns {Promise<{ root: string, manifest: any } | undefined>} */
50
- async function packageRoot(bin) {
51
- let directory = path.dirname(bin);
52
- for (;;) {
53
- try {
54
- const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
55
- if (manifest.name === "@astrosheep/keiyaku") return { root: directory, manifest };
56
- } catch {}
57
- const parent = path.dirname(directory);
58
- if (parent === directory) return undefined;
59
- directory = parent;
60
- }
61
- }
62
-
63
- /** @param {string} root @param {any} manifest @returns {string | undefined} */
64
- function declaredEntry(root, manifest) {
65
- const main = manifest.exports === undefined ? undefined : manifest.exports["."];
66
- const relative = typeof main === "string" ? main : main === undefined ? undefined : main.import || main.default;
67
- const chosen = relative === undefined ? manifest.main : relative;
68
- return typeof chosen === "string" ? pathToFileURL(path.join(root, chosen)).href : undefined;
69
- }
70
-
71
- /** @returns {Promise<string>} */
72
- async function resolveEntry() {
73
- if (EXPLICIT_ENTRY !== undefined && EXPLICIT_ENTRY !== "") {
74
- return EXPLICIT_ENTRY.startsWith("file:") ? EXPLICIT_ENTRY : pathToFileURL(path.resolve(EXPLICIT_ENTRY)).href;
75
- }
76
- const bin = await findOnPath("keiyaku");
77
- if (bin === undefined) throw new Error("the keiyaku executable is not on PATH; the Paseo bridge cannot locate its public package");
78
- const found = await packageRoot(bin);
79
- if (found === undefined) throw new Error("no @astrosheep/keiyaku package owns " + bin);
80
- const entry = declaredEntry(found.root, found.manifest);
81
- if (entry === undefined) throw new Error("the installed @astrosheep/keiyaku package declares no public entry");
82
- return entry;
83
- }
84
-
85
- /** @returns {Promise<typeof import("@astrosheep/keiyaku")>} */
86
- async function loadSdk() {
87
- const entry = await resolveEntry();
88
- /** @type {typeof import("@astrosheep/keiyaku")} */
89
- const sdk = await import(entry);
90
- if (typeof sdk.Akuma?.select !== "function") {
91
- throw new Error("the public @astrosheep/keiyaku entry does not expose Akuma.select");
92
- }
93
- return sdk;
94
- }
95
-
96
- const boot = loadSdk();
97
- // A boot that fails before the first request still belongs to those requests;
98
- // keep the rejection handled until one of them observes it.
99
- boot.catch(() => {});
100
-
101
- /** @type {Map<number, AbortController>} */
102
- const controllers = new Map();
103
-
104
- /**
105
- * One live observation of an Akuma's current window. The iterator is undefined
106
- * only while the opening request is still resolving the public package.
107
- * @typedef {{ controller: AbortController, iterator: AsyncIterator<unknown> | undefined }} Subscription
108
- */
109
- /** @type {Map<number, Subscription>} */
110
- const subscriptions = new Map();
111
-
112
- /**
113
- * The installed public package may predate the streaming surface, so probe the
114
- * capability at runtime instead of assuming this bridge's own vintage.
115
- * @param {unknown} value @returns {value is { stream(options: { signal: AbortSignal }): AsyncIterable<unknown> }}
116
- */
117
- function exposesStream(value) {
118
- return typeof value === "object" && value !== null &&
119
- typeof (/** @type {{ stream?: unknown }} */ (value)).stream === "function";
120
- }
121
-
122
- /**
123
- * The model configuration a public status reports, when it reports one. An
124
- * installed package that carries no model in status stays unknown here; the
125
- * bridge never invents a configuration from its own defaults.
126
- * @param {unknown} status @returns {{ provider?: string, model?: string }}
127
- */
128
- function modelConfiguration(status) {
129
- if (typeof status !== "object" || status === null) return {};
130
- const fields = /** @type {Record<string, unknown>} */ (status);
131
- return {
132
- ...(typeof fields.provider === "string" ? { provider: fields.provider } : {}),
133
- ...(typeof fields.model === "string" ? { model: fields.model } : {}),
134
- };
135
- }
136
-
137
- /** @param {any} message */
138
- function respond(message) {
139
- process.stdout.write(JSON.stringify(message) + "\n");
140
- }
141
-
142
- /** @param {unknown} error @returns {string} */
143
- function messageOf(error) {
144
- return error instanceof Error ? error.message : String(error);
145
- }
146
-
147
- /**
148
- * One request through the public operations only. Every value is the
149
- * producer's own admission, evidence, page, or status projection.
150
- * @param {any} request @param {typeof import("@astrosheep/keiyaku")} sdk @param {AbortController} controller @returns {Promise<any>}
151
- */
152
- async function operate(request, sdk, controller) {
153
- const akuma = sdk.Akuma.select(request.world, request.aku);
154
- if (request.op === "tell") return await akuma.tell(request.text, { signal: controller.signal });
155
- if (request.op === "stop") return { evidence: await akuma.kill({ signal: controller.signal }) };
156
- if (request.op === "status") return modelConfiguration(await akuma.status());
157
- throw new Error("unsupported Keiyaku SDK operation: " + String(request.op));
158
- }
159
-
160
- /** @param {string} line */
161
- async function dispatch(line) {
162
- /** @type {any} */
163
- let request;
164
- try { request = JSON.parse(line); } catch { return; }
165
- if (request === null || typeof request !== "object" || typeof request.id !== "number") return;
166
- if (request.op === "cancel") {
167
- const cancelled = controllers.get(request.target);
168
- if (cancelled !== undefined) cancelled.abort();
169
- return;
170
- }
171
- // A stream subscription outlives its opening request: the pull requests that
172
- // follow carry the subscription's own identity, so ordinary requests keep
173
- // their own lifetime and cancellation never reaches an unrelated observation.
174
- if (request.op === "stream") {
175
- const controller = new AbortController();
176
- subscriptions.set(request.id, { controller, iterator: undefined });
177
- try {
178
- const sdk = await boot;
179
- const akuma = sdk.Akuma.select(request.world, request.aku);
180
- if (!exposesStream(akuma)) {
181
- throw new Error("the installed @astrosheep/keiyaku package does not expose Akuma.stream; " +
182
- "the Paseo bridge cannot observe this Akuma continuously");
183
- }
184
- if (controller.signal.aborted) throw new Error("Keiyaku SDK stream was cancelled before it opened");
185
- const observed = akuma.stream({ signal: controller.signal })[Symbol.asyncIterator]();
186
- subscriptions.set(request.id, { controller, iterator: observed });
187
- respond({ id: request.id, ok: true, value: { subscription: request.id } });
188
- } catch (error) {
189
- subscriptions.delete(request.id);
190
- controller.abort();
191
- respond({ id: request.id, ok: false, error: { message: messageOf(error) } });
192
- }
193
- return;
194
- }
195
- if (request.op === "stream-pull") {
196
- const subscription = subscriptions.get(request.target);
197
- if (subscription === undefined || subscription.iterator === undefined) {
198
- respond({ id: request.id, ok: false, error: { message: "Keiyaku SDK stream subscription is not open" } });
199
- return;
200
- }
201
- try {
202
- const frame = await subscription.iterator.next();
203
- // A released subscription discards its late frame instead of publishing it
204
- // into whatever observation the caller opened next.
205
- if (subscriptions.get(request.target) !== subscription) throw new Error("Keiyaku SDK stream subscription was released");
206
- respond({ id: request.id, ok: true, value: frame.done === true ? { done: true } : { done: false, history: frame.value } });
207
- } catch (error) {
208
- respond({ id: request.id, ok: false, error: { message: messageOf(error) } });
209
- }
210
- return;
211
- }
212
- if (request.op === "stream-cancel") {
213
- const subscription = subscriptions.get(request.target);
214
- if (subscription !== undefined) {
215
- subscriptions.delete(request.target);
216
- subscription.controller.abort();
217
- }
218
- respond({ id: request.id, ok: true, value: null });
219
- return;
220
- }
221
- const controller = new AbortController();
222
- controllers.set(request.id, controller);
223
- try {
224
- const sdk = await boot;
225
- if (controller.signal.aborted) throw new Error("Keiyaku SDK request was cancelled before it started");
226
- respond({ id: request.id, ok: true, value: await operate(request, sdk, controller) });
227
- } catch (error) {
228
- respond({ id: request.id, ok: false, error: { message: messageOf(error) } });
229
- } finally {
230
- controllers.delete(request.id);
231
- }
232
- }
233
-
234
- const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
235
- lines.on("line", (line) => { void dispatch(line); });
236
- lines.on("close", () => { process.exit(0); });
237
- `;