@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
|
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
11
|
+
const PACKAGE_NAME = "@astrosheep/keiyaku";
|
|
12
|
+
const stopEvidence = z.string();
|
|
11
13
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
54
|
-
return
|
|
49
|
+
async function realFile(candidate: string): Promise<string | undefined> {
|
|
50
|
+
try { return await realpath(candidate); } catch { return undefined; }
|
|
55
51
|
}
|
|
56
52
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
*
|
|
71
|
-
*
|
|
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
|
-
|
|
146
|
+
let sdk: KeiyakuSdk | undefined;
|
|
147
|
+
let loading: Promise<KeiyakuSdk> | undefined;
|
|
148
|
+
const active = new Set<ActiveScope>();
|
|
83
149
|
|
|
84
|
-
function
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
|
141
|
-
signal
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
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(
|
|
201
|
-
return stopAttempt(stopEvidence.parse(value)
|
|
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
|
|
220
|
+
const value = await request(world, akuId, signal, (akuma) => akuma.status());
|
|
221
|
+
const fields = record(value);
|
|
206
222
|
return {
|
|
207
|
-
...(
|
|
208
|
-
...(
|
|
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
|
|
220
|
-
let
|
|
230
|
+
let scope: ActiveScope | undefined;
|
|
231
|
+
let iterator: AsyncIterator<unknown> | undefined;
|
|
221
232
|
let finished = false;
|
|
222
233
|
let pulling = false;
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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 =
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
unwind();
|
|
287
|
+
finish();
|
|
264
288
|
return { done: true, value: undefined };
|
|
265
289
|
},
|
|
266
|
-
async throw(error?: unknown): Promise<IteratorResult<History>> {
|
|
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
|
|
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
|
-
`;
|