@anureo/adapter-ahp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,360 @@
1
+ /** Negotiates a remote AHP peer and routes its validated streams into SDK session sinks. */
2
+ import { HarnessSdkError, } from "@anureo/sdk";
3
+ import { assertDefinition, assertEvent, assertRequest, assertResult, negotiateVersion, publicValue, supportsExtension, } from "@anureo/protocol";
4
+ import { AhpWire, createStdioTransport } from "./wire.js";
5
+ export { AhpWire, createStdioTransport } from "./wire.js";
6
+ export { createWebSocketTransport } from "./websocket.js";
7
+ /** Create an adapter without I/O. Pass it to createClient and dispose the resulting client. */
8
+ export function createAhpAdapter(options) {
9
+ return new AhpAdapter(options);
10
+ }
11
+ /**
12
+ * Connect SDK sessions to one AHP peer. Direct users await initialize before opening sessions,
13
+ * release their backend handles, and then await adapter disposal.
14
+ */
15
+ export class AhpAdapter {
16
+ options;
17
+ /** Configure the connection without opening it. */
18
+ constructor(options) {
19
+ this.options = options;
20
+ }
21
+ /**
22
+ * Negotiate once and send initialized before resolving. Valid calls share the first attempt,
23
+ * including rejection. Invalid params or unsupported offered versions throw synchronously.
24
+ * Disposal, transport and handshake failures reject. The SDK's output wrapper awaits this result
25
+ * and uses the negotiated output capability before Client stores the capabilities.
26
+ */
27
+ initialize(params) {
28
+ if (this.disposed)
29
+ return Promise.reject(new HarnessSdkError("RUNTIME_DISPOSED"));
30
+ assertRequest("initialize", params);
31
+ negotiateVersion(params.protocolVersions);
32
+ return this.initialized ??= this.initializeConnection(params);
33
+ }
34
+ /**
35
+ * Open a remote session. OutputSession.open supplies the sink, which may receive notifications
36
+ * before the open reply. Resolve with the validated backend handle after any fallback metadata
37
+ * publication. Duplicate IDs, RPC/schema failures, invalid bindings, retired streams and sink
38
+ * failures reject. Failed setup removes local routing. The SDK wrapper owns backend disposal.
39
+ */
40
+ async openSession(params, sink) {
41
+ if (!this.wire || this.disposed)
42
+ throw new HarnessSdkError("NOT_INITIALIZED");
43
+ if (this.sessions.has(params.binding.sessionId))
44
+ throw new HarnessSdkError("SESSION_ALREADY_OPEN");
45
+ const wire = this.wire;
46
+ const record = { sink, lastByStream: new Map(), pendingInteractionIds: new Set() };
47
+ // The peer may send source events before replying to session/open.
48
+ this.sessions.set(params.binding.sessionId, record);
49
+ try {
50
+ const opened = await wire.request("session/open", params);
51
+ assertResult("session/open", opened);
52
+ this.bindOpenedStream(record, params.binding, opened);
53
+ if (!record.metadataObserved && opened.state?.metadata) {
54
+ await sink.emit({ type: "session/metadata", payload: opened.state.metadata });
55
+ }
56
+ return this.createBackendSession({ wire, params, record, opened });
57
+ }
58
+ catch (cause) {
59
+ this.sessions.delete(params.binding.sessionId);
60
+ throw cause;
61
+ }
62
+ }
63
+ /**
64
+ * Close the owned connection and clear routing after successful close. The SDK wrapper attempts
65
+ * session cleanup first. The first disposal awaits wire cleanup and can reject. Later calls return
66
+ * immediately. If acquisition is pending, initialize closes the arriving transport before rejecting.
67
+ */
68
+ async dispose() {
69
+ if (this.disposed)
70
+ return;
71
+ this.disposed = true;
72
+ await this.wire?.close();
73
+ this.sessions.clear();
74
+ }
75
+ wire;
76
+ initialized;
77
+ result;
78
+ sessions = new Map();
79
+ retiredStreams = new Set();
80
+ // Registered while the SDK's responder runs. Its finally block or wire close clears the entry.
81
+ inFlightInteractionResponses = new Map();
82
+ disposed = false;
83
+ /**
84
+ * initialize shares this attempt. Attach wire callbacks before sending the handshake, then store
85
+ * capabilities for notification handling before notifying the peer of readiness. Acquisition,
86
+ * validation and write failures reject the shared attempt. Adapter disposal owns wire cleanup.
87
+ */
88
+ async initializeConnection(params) {
89
+ const selection = this.selectTransport();
90
+ const transport = "ready" in selection ? selection.ready : await selection.pending;
91
+ if (this.disposed) {
92
+ await transport.close();
93
+ throw new HarnessSdkError("RUNTIME_DISPOSED");
94
+ }
95
+ const wire = this.wire = new AhpWire(transport, this.options);
96
+ wire.onNotification = (method, event) => this.notification(wire, method, event);
97
+ wire.onClose = cause => this.failConnection(cause);
98
+ const result = await wire.request("initialize", withMetadataCapability(params));
99
+ assertResult("initialize", result);
100
+ negotiateVersion([result.protocolVersion]);
101
+ this.result = result;
102
+ await wire.notify("initialized");
103
+ return result;
104
+ }
105
+ /**
106
+ * Select one transport for initializeConnection. Keep a supplied instance synchronous so the
107
+ * wire starts immediately. Factory promises and selection errors flow to the initialization attempt.
108
+ */
109
+ selectTransport() {
110
+ if (this.options.transport) {
111
+ return typeof this.options.transport === "function"
112
+ ? { pending: this.options.transport() }
113
+ : { ready: this.options.transport };
114
+ }
115
+ if (this.options.url) {
116
+ if (!this.options.connect)
117
+ throw new HarnessSdkError("WEBSOCKET_FACTORY_REQUIRED");
118
+ return { pending: this.options.connect(this.options.url) };
119
+ }
120
+ if (!this.options.command)
121
+ throw new HarnessSdkError("HARNESS_UNAVAILABLE");
122
+ return { pending: createStdioTransport({ ...this.options, command: this.options.command }) };
123
+ }
124
+ /** Commit the open reply's stream after checking its identity. A throw leaves openSession to remove routing. */
125
+ bindOpenedStream(record, requested, opened) {
126
+ const { binding } = opened;
127
+ if (binding.sessionId !== requested.sessionId
128
+ || binding.adapterId !== requested.adapterId
129
+ || binding.runtimeConfigId !== requested.runtimeConfigId) {
130
+ throw new HarnessSdkError("SESSION_BINDING_MISMATCH");
131
+ }
132
+ const streamId = opened.streamId;
133
+ if (this.retiredStreams.has(streamKey(requested.sessionId, streamId))) {
134
+ throw new HarnessSdkError("SOURCE_STREAM_UNAVAILABLE");
135
+ }
136
+ record.streamId = streamId;
137
+ }
138
+ /** Build the backend that openSession transfers to OutputSession after setup and metadata publication. */
139
+ createBackendSession({ wire, params, record, opened }) {
140
+ const adapter = this;
141
+ return {
142
+ binding: opened.binding,
143
+ capabilities: opened.capabilities,
144
+ state: opened.state,
145
+ /** OutputSession.execute supplies validated input and receives the peer result or RPC/schema rejection. */
146
+ async execute(method, params) {
147
+ const result = await wire.request(method, params);
148
+ assertResult(method, result);
149
+ return result;
150
+ },
151
+ /** OutputSession.dispose releases local routing here. The SDK sends session/close separately. */
152
+ async dispose() {
153
+ adapter.retire(params.binding.sessionId, record);
154
+ },
155
+ };
156
+ }
157
+ /** AhpWire awaits this on close. Fail sinks in registration order before rejecting response waiters. */
158
+ async failConnection(cause) {
159
+ for (const session of this.sessions.values())
160
+ await session.sink.fail(cause);
161
+ for (const response of this.inFlightInteractionResponses.values())
162
+ response.reject(cause);
163
+ this.inFlightInteractionResponses.clear();
164
+ }
165
+ /**
166
+ * AhpWire serializes this callback while continuing to read RPC replies. Ignore duplicate
167
+ * sequences and retired streams, repair gaps before delivery, and forward unknown methods as
168
+ * notices. Rejection starts wire cleanup, which invokes failConnection.
169
+ */
170
+ async notification(wire, method, value) {
171
+ if (!KNOWN_NOTIFICATIONS.includes(method)) {
172
+ const raw = publicValue(JSON.parse(JSON.stringify(value ?? null)));
173
+ for (const session of this.sessions.values()) {
174
+ await session.sink.emit({
175
+ type: "notice",
176
+ payload: { severity: "info", code: "UNKNOWN_NOTIFICATION", message: "Unknown AHP notification", raw },
177
+ });
178
+ }
179
+ return;
180
+ }
181
+ assertEvent(value);
182
+ if (value.type !== method)
183
+ throw new HarnessSdkError("PROTOCOL_ERROR");
184
+ if (this.retiredStreams.has(streamKey(value.sessionId, value.streamId)))
185
+ return;
186
+ const session = this.sessions.get(value.sessionId);
187
+ if (!session)
188
+ throw new HarnessSdkError("SOURCE_SESSION_MISMATCH");
189
+ const last = session.lastByStream.get(value.streamId) ?? 0;
190
+ if (value.seq <= last)
191
+ return;
192
+ if (session.streamId && session.streamId !== value.streamId) {
193
+ throw new HarnessSdkError("SOURCE_STREAM_UNAVAILABLE");
194
+ }
195
+ session.streamId = value.streamId;
196
+ if (value.seq !== last + 1)
197
+ await this.replayMissingEvents({ wire, session, next: value, last });
198
+ await this.deliver(wire, session, value);
199
+ }
200
+ /**
201
+ * Fill the prefix immediately before the live event. notification awaits each repaired event before
202
+ * delivering the live one. Replayed events can already have reached the sink when a later entry
203
+ * proves the range invalid or incomplete and rejects the callback.
204
+ */
205
+ async replayMissingEvents({ wire, session, next, last }) {
206
+ if (!this.result || !supportsExtension(this.result.capabilities, "x-anureo/event-replay", "1")) {
207
+ throw new HarnessSdkError("SOURCE_SEQUENCE_GAP");
208
+ }
209
+ const raw = await wire.request("x-anureo/events/read", {
210
+ sessionId: next.sessionId,
211
+ streamId: next.streamId,
212
+ afterSeq: last,
213
+ throughSeq: next.seq - 1,
214
+ });
215
+ if (!raw || typeof raw !== "object" || !("events" in raw) || !Array.isArray(raw.events)) {
216
+ throw new HarnessSdkError("SOURCE_STREAM_UNAVAILABLE");
217
+ }
218
+ for (const event of raw.events) {
219
+ assertEvent(event);
220
+ if (event.sessionId !== next.sessionId || event.streamId !== next.streamId) {
221
+ throw new HarnessSdkError("SOURCE_SEQUENCE_GAP");
222
+ }
223
+ const nextExpectedSeq = (session.lastByStream.get(next.streamId) ?? 0) + 1;
224
+ if (event.seq !== nextExpectedSeq || event.seq >= next.seq) {
225
+ throw new HarnessSdkError("SOURCE_SEQUENCE_GAP");
226
+ }
227
+ await this.deliver(wire, session, event);
228
+ }
229
+ if ((session.lastByStream.get(next.streamId) ?? 0) + 1 !== next.seq) {
230
+ throw new HarnessSdkError("SOURCE_SEQUENCE_GAP");
231
+ }
232
+ }
233
+ /**
234
+ * Admit one source event from notification or replayMissingEvents. Advance its sequence before
235
+ * awaiting publication, so replay checks the admitted sequence. Await sink operations except the
236
+ * interaction answer, whose confirmation can itself arrive on this notification queue.
237
+ */
238
+ async deliver(wire, session, event) {
239
+ session.lastByStream.set(event.streamId, event.seq);
240
+ const scope = { turnId: event.turnId, itemId: event.itemId };
241
+ if (event.type === "session/metadata" || (event.type === "extension" && event.payload.name === METADATA_EXTENSION.name)) {
242
+ const metadata = this.readMetadata(event);
243
+ if (Object.hasOwn(metadata, "title"))
244
+ session.metadataObserved = true;
245
+ await session.sink.emit({ type: "session/metadata", payload: metadata });
246
+ return;
247
+ }
248
+ if (event.type === "interaction/requested") {
249
+ this.registerInteraction(wire, session, event);
250
+ return;
251
+ }
252
+ if (event.type === "interaction/resolved") {
253
+ const key = interactionKey(event.sessionId, event.payload.interactionId);
254
+ const inFlightResponse = this.inFlightInteractionResponses.get(key);
255
+ if (inFlightResponse) {
256
+ inFlightResponse.resolve(event.payload);
257
+ return;
258
+ }
259
+ // Only unresolved requests can still be settled by the peer.
260
+ if (session.pendingInteractionIds.delete(event.payload.interactionId)) {
261
+ await session.sink.resolveInteraction(event.payload);
262
+ }
263
+ return;
264
+ }
265
+ if (event.type === "session/state" && event.payload.metadata && Object.hasOwn(event.payload.metadata, "title")) {
266
+ session.metadataObserved = true;
267
+ }
268
+ await session.sink.emit({ type: event.type, payload: event.payload }, scope);
269
+ }
270
+ /** Decode either metadata channel for deliver. Reject unnegotiated versions or invalid payloads before publication. */
271
+ readMetadata(event) {
272
+ if (!this.result || !supportsExtension(this.result.capabilities, METADATA_EXTENSION.name, METADATA_EXTENSION.version)) {
273
+ throw new HarnessSdkError("CAPABILITY_UNSUPPORTED");
274
+ }
275
+ if (event.type === "extension" && event.payload.version !== METADATA_EXTENSION.version) {
276
+ throw new HarnessSdkError("CAPABILITY_UNSUPPORTED");
277
+ }
278
+ const metadata = event.type === "session/metadata" ? event.payload : event.payload.data;
279
+ assertDefinition("SessionMetadata", metadata);
280
+ return metadata;
281
+ }
282
+ /**
283
+ * Register the sink's responder when deliver receives a native interaction. The sink owns local
284
+ * publication and settlement. Observe rejection without blocking the notification queue.
285
+ */
286
+ registerInteraction(wire, session, event) {
287
+ const request = event.payload;
288
+ const key = interactionKey(event.sessionId, request.interactionId);
289
+ session.pendingInteractionIds.add(request.interactionId);
290
+ void session.sink.requestInteraction(request, (response, reason) => this.respondToInteraction({ wire, session, event, request, key, response, reason })).catch(() => { });
291
+ }
292
+ /**
293
+ * The SDK invokes the responder on answer or cancellation, including rejection at admission.
294
+ * Await the first RPC reply or matching resolution, validate it, and reject unknown outcomes.
295
+ * The SDK publishes local settlement. Release both registrations on exit and leave any outstanding
296
+ * RPC under the wire's own deadline and close handling.
297
+ */
298
+ async respondToInteraction({ wire, session, event, request, key, response, reason }) {
299
+ let resolve;
300
+ let reject;
301
+ const confirmation = new Promise((yes, no) => { resolve = yes; reject = no; });
302
+ this.inFlightInteractionResponses.set(key, { resolve, reject });
303
+ try {
304
+ const method = response ? "interaction/respond" : "interaction/cancel";
305
+ const call = wire.request(method, {
306
+ requestId: `ahp:${event.streamId}:${event.seq}`,
307
+ sessionId: event.sessionId,
308
+ interactionId: request.interactionId,
309
+ ...(response ? { response } : { reason }),
310
+ });
311
+ const result = await Promise.race([call, confirmation]);
312
+ assertResult(method, result);
313
+ if (result.outcome === "unknown")
314
+ throw new HarnessSdkError("RESULT_UNKNOWN", "RESULT_UNKNOWN", false, true);
315
+ }
316
+ finally {
317
+ this.inFlightInteractionResponses.delete(key);
318
+ session.pendingInteractionIds.delete(request.interactionId);
319
+ }
320
+ }
321
+ /** Backend disposal removes its own registration and remembers up to 128 streams to ignore late events. */
322
+ retire(sessionId, record) {
323
+ if (this.sessions.get(sessionId) !== record)
324
+ return;
325
+ this.sessions.delete(sessionId);
326
+ if (!record.streamId)
327
+ return;
328
+ this.retiredStreams.add(streamKey(sessionId, record.streamId));
329
+ if (this.retiredStreams.size > 128) {
330
+ const oldest = this.retiredStreams.values().next();
331
+ if (!oldest.done)
332
+ this.retiredStreams.delete(oldest.value);
333
+ }
334
+ }
335
+ }
336
+ const METADATA_EXTENSION = { name: "x-anureo/session-metadata", version: "1" };
337
+ const KNOWN_NOTIFICATIONS = [
338
+ "submission/bound", "submission/status", "session/metadata", "session/state", "turn/started",
339
+ "turn/completed", "item/started", "item/delta", "item/completed", "usage/updated",
340
+ "interaction/requested", "interaction/resolved", "notice", "extension",
341
+ ];
342
+ /** Replace offers for this extension so initializeConnection always advertises the version it can decode. */
343
+ function withMetadataCapability(params) {
344
+ const clientCapabilities = {
345
+ ...params.clientCapabilities,
346
+ extensions: [
347
+ ...(params.clientCapabilities?.extensions ?? []).filter(e => e.name !== METADATA_EXTENSION.name),
348
+ METADATA_EXTENSION,
349
+ ],
350
+ };
351
+ return { ...params, clientCapabilities };
352
+ }
353
+ /** Preserve tuple boundaries even when either peer-supplied identifier contains a separator. */
354
+ function streamKey(sessionId, streamId) {
355
+ return JSON.stringify([sessionId, streamId]);
356
+ }
357
+ /** Correlate the response callback and remote resolution using the same session/interaction key. */
358
+ function interactionKey(sessionId, interactionId) {
359
+ return `${sessionId}:${interactionId}`;
360
+ }
@@ -0,0 +1,23 @@
1
+ import type { AhpTransport } from "./wire.js";
2
+ /**
3
+ * Open a text-frame transport for AhpAdapterOptions.connect or direct AhpWire use. Resolve once
4
+ * the socket is open, before AHP negotiation. The owner must close the returned transport.
5
+ * Accept wss: or loopback ws: and reject parsed credentials or nonempty query/fragment components.
6
+ * Address, construction and queue setup failures reject setup. An error or close before open
7
+ * rejects with RPC_CONNECTION_FAILED. At the deadline the socket is closed before reporting
8
+ * RPC_TIMEOUT, so a synchronous close event may report RPC_CONNECTION_FAILED first.
9
+ * Native error and close events end reads. Non-text or rejected frames also initiate socket close.
10
+ * Buffer up to 2048 incoming frames and drain buffered frames before reporting read failure.
11
+ * Send resolves after socket.send accepts data. Close resolves after initiating native closure.
12
+ * A native exception during either operation rejects that operation.
13
+ */
14
+ export declare function createWebSocketTransport(url: string, options?: {
15
+ /** Construct a socket from the original validated URL. Omission uses the global WebSocket or rejects if unavailable. */
16
+ factory?: (url: string) => WebSocket;
17
+ /** Socket open timeout in milliseconds. Defaults to 30,000. */
18
+ timeoutMs?: number;
19
+ /** Incoming UTF-8 frame budget. Defaults to 8 MiB and must be a positive safe integer. */
20
+ maxFrameBytes?: number;
21
+ /** Incoming byte budget and outgoing bufferedAmount threshold. Defaults to 16 MiB and must be a positive safe integer. */
22
+ maxBufferedBytes?: number;
23
+ }): Promise<AhpTransport>;
@@ -0,0 +1,126 @@
1
+ /** Owns WebSocket address policy, connection setup and bounded incoming text frames. */
2
+ import { BoundedEventQueue, HarnessSdkError } from "@anureo/sdk";
3
+ /**
4
+ * Open a text-frame transport for AhpAdapterOptions.connect or direct AhpWire use. Resolve once
5
+ * the socket is open, before AHP negotiation. The owner must close the returned transport.
6
+ * Accept wss: or loopback ws: and reject parsed credentials or nonempty query/fragment components.
7
+ * Address, construction and queue setup failures reject setup. An error or close before open
8
+ * rejects with RPC_CONNECTION_FAILED. At the deadline the socket is closed before reporting
9
+ * RPC_TIMEOUT, so a synchronous close event may report RPC_CONNECTION_FAILED first.
10
+ * Native error and close events end reads. Non-text or rejected frames also initiate socket close.
11
+ * Buffer up to 2048 incoming frames and drain buffered frames before reporting read failure.
12
+ * Send resolves after socket.send accepts data. Close resolves after initiating native closure.
13
+ * A native exception during either operation rejects that operation.
14
+ */
15
+ export async function createWebSocketTransport(url, options = {}) {
16
+ validateWebSocketAddress(url);
17
+ if (!options.factory && typeof WebSocket === "undefined")
18
+ throw new HarnessSdkError("WEBSOCKET_FACTORY_REQUIRED");
19
+ const socket = options.factory ? options.factory(url) : new WebSocket(url);
20
+ const transport = createSocketTransport(socket, options);
21
+ await waitForConnection(socket, options.timeoutMs ?? 30_000);
22
+ return transport;
23
+ }
24
+ const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024;
25
+ /**
26
+ * Check address policy before the factory receives the URL. Propagate URL parse failures and
27
+ * throw INVALID_PARAMS for unsupported protocols, restricted URL components or remote plaintext.
28
+ */
29
+ function validateWebSocketAddress(url) {
30
+ const address = new URL(url);
31
+ const isSupportedProtocol = ["ws:", "wss:"].includes(address.protocol);
32
+ const hasForbiddenAddressInfo = Boolean(address.username || address.password || address.search || address.hash);
33
+ const isPlaintextRemoteHost = address.protocol === "ws:"
34
+ && !["127.0.0.1", "localhost", "[::1]"].includes(address.hostname);
35
+ if (!isSupportedProtocol || hasForbiddenAddressInfo || isPlaintextRemoteHost) {
36
+ throw new HarnessSdkError("INVALID_PARAMS", "Invalid AHP WebSocket address");
37
+ }
38
+ }
39
+ /**
40
+ * Set up framed reads and local close state before createWebSocketTransport waits for open,
41
+ * so early frames and failures are retained. Invalid queue budgets throw during construction.
42
+ */
43
+ function createSocketTransport(socket, options) {
44
+ const queue = new BoundedEventQueue({
45
+ maxFrameBytes: options.maxFrameBytes,
46
+ maxBytes: options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES,
47
+ maxEvents: 2048,
48
+ }, frame => new TextEncoder().encode(frame).byteLength);
49
+ let closed = false;
50
+ /** Message events admit text frames. Failure ends the queue with the cause before native close can fire. */
51
+ const receiveFrame = (event) => {
52
+ try {
53
+ if (typeof event.data !== "string")
54
+ throw new HarnessSdkError("PROTOCOL_ERROR");
55
+ queue.push(event.data);
56
+ }
57
+ catch (cause) {
58
+ queue.end(cause);
59
+ socket.close();
60
+ }
61
+ };
62
+ /** Native errors and closes fail reads after buffered frames drain. The owner still handles socket release. */
63
+ const failReads = () => {
64
+ queue.end(new HarnessSdkError("RPC_CONNECTION_CLOSED", "RPC_CONNECTION_CLOSED", false, true));
65
+ };
66
+ socket.addEventListener("message", receiveFrame);
67
+ socket.addEventListener("close", () => {
68
+ closed = true;
69
+ failReads();
70
+ });
71
+ socket.addEventListener("error", failReads);
72
+ return {
73
+ messages: queue,
74
+ /** Reject unless open or if bufferedAmount exceeds the threshold before sending. Native send failures propagate. */
75
+ async send(message) {
76
+ if (closed || socket.readyState !== 1) {
77
+ throw new HarnessSdkError("RPC_CONNECTION_CLOSED", "RPC_CONNECTION_CLOSED", false, true);
78
+ }
79
+ if (socket.bufferedAmount > (options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES)) {
80
+ throw new HarnessSdkError("BACKPRESSURE_EXCEEDED");
81
+ }
82
+ socket.send(message);
83
+ },
84
+ /** Mark closed before invoking native close to prevent duplicate close calls. End reads after native close returns. */
85
+ async close() {
86
+ if (closed)
87
+ return;
88
+ closed = true;
89
+ socket.close();
90
+ queue.end();
91
+ },
92
+ };
93
+ }
94
+ /**
95
+ * Await socket readiness for createWebSocketTransport, accepting an already-open factory socket.
96
+ * Open/error/close settle setup and remove the temporary listeners. The timer initiates close
97
+ * before rejecting, preserving native error/close ordering.
98
+ */
99
+ function waitForConnection(socket, timeoutMs) {
100
+ return new Promise((resolve, reject) => {
101
+ const timeout = setTimeout(() => {
102
+ socket.close();
103
+ reject(new HarnessSdkError("RPC_TIMEOUT"));
104
+ }, timeoutMs);
105
+ /** Detach setup callbacks and clear the deadline when a socket event settles setup. */
106
+ const removeHandshakeListeners = () => {
107
+ clearTimeout(timeout);
108
+ socket.removeEventListener("open", onOpen);
109
+ socket.removeEventListener("error", onConnectionFailure);
110
+ socket.removeEventListener("close", onConnectionFailure);
111
+ };
112
+ const onOpen = () => {
113
+ removeHandshakeListeners();
114
+ resolve();
115
+ };
116
+ const onConnectionFailure = () => {
117
+ removeHandshakeListeners();
118
+ reject(new HarnessSdkError("RPC_CONNECTION_FAILED"));
119
+ };
120
+ socket.addEventListener("open", onOpen);
121
+ socket.addEventListener("error", onConnectionFailure);
122
+ socket.addEventListener("close", onConnectionFailure);
123
+ if (socket.readyState === 1)
124
+ onOpen();
125
+ });
126
+ }
package/src/wire.d.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { type HarnessProcess, type HarnessProcessOptions } from "@anureo/sdk/process";
2
+ /**
3
+ * Supply framed text I/O to AhpWire or through AhpAdapterOptions.transport/connect.
4
+ * Ownership transfers to the wire or adapter. Implementations report source failure by
5
+ * rejecting iteration. Direct wire users await AhpWire.close to release the connection.
6
+ */
7
+ export interface AhpTransport {
8
+ /** Yield one complete JSON-RPC text frame per value. AhpWire claims the reader immediately. */
9
+ messages: AsyncIterable<string>;
10
+ /** Submit one frame and resolve when accepted locally. Reject write or admission failures; sends can overlap. */
11
+ send: (message: string) => Promise<void>;
12
+ /** Release the connection and unblock a waiting reader. Report local cleanup failures by rejecting. */
13
+ close: () => Promise<void>;
14
+ }
15
+ /** Configure AhpWire directly or through the fields inherited by AhpAdapterOptions. */
16
+ export interface WireOptions {
17
+ /** Per-request reply deadline in milliseconds, starting before send; defaults to 30,000. */
18
+ timeoutMs?: number;
19
+ /** Maximum UTF-8 bytes per incoming frame or outgoing request. Defaults to 8 MiB. */
20
+ maxFrameBytes?: number;
21
+ /** Maximum requests awaiting settlement, including during send. Defaults to 256. */
22
+ maxPending?: number;
23
+ /** Maximum queued and running notification callbacks; defaults to 2048. */
24
+ maxNotifications?: number;
25
+ /**
26
+ * Called once at cleanup with the first cause's SDK error code, or PROTOCOL_ERROR for other
27
+ * causes. Throws are ignored.
28
+ */
29
+ diagnostic?: (code: string) => void;
30
+ }
31
+ /**
32
+ * Exchange JSON-RPC requests and serialized notifications over one owned transport. AhpAdapter
33
+ * uses this for its handshake, session operations and replay, and validates the returned payloads.
34
+ * Direct consumers likewise own payload validation and must await close to observe cleanup failure.
35
+ */
36
+ export declare class AhpWire {
37
+ private readonly transport;
38
+ private readonly options;
39
+ /** Take ownership of the transport and start reading immediately. Install callbacks before sending requests. */
40
+ constructor(transport: AhpTransport, options?: WireOptions);
41
+ /**
42
+ * Receives notifications serially without blocking reply reads. AhpAdapter.initializeConnection
43
+ * registers its notification router here so replay can await RPC replies. A throw or rejection
44
+ * starts cleanup. The queue looks up the current callback at delivery time and keeps processing
45
+ * frames already queued when close starts.
46
+ */
47
+ onNotification?: (method: string, params: unknown) => Promise<void> | void;
48
+ /**
49
+ * Invoked once with the first close cause, concurrently with transport cleanup.
50
+ * AhpAdapter.initializeConnection registers failConnection to fail session sinks and interaction
51
+ * waiters. A throw or rejection becomes a resource-release failure reported by close.
52
+ * The callback must return independently of close, which awaits its completion.
53
+ */
54
+ onClose?: (cause: unknown) => Promise<void> | void;
55
+ /**
56
+ * Send a request and resolve with its correlated result. Callers validate the result schema.
57
+ * Serialization errors throw synchronously. Remote errors, malformed replies, admission limits,
58
+ * timeouts and connection loss reject the promise. The reply deadline includes sending. Timeouts
59
+ * and asynchronous send failures reject with outcomeUnknown and leave the wire open. A synchronous
60
+ * transport send exception rejects as supplied and leaves correlation until reply, timeout or close.
61
+ * Callers own retry decisions. Use close to stop waiting for outstanding replies.
62
+ */
63
+ request(method: string, params: unknown): Promise<unknown>;
64
+ /**
65
+ * Send a notification directly through transport.send, which controls admission and completion
66
+ * and receives the frame regardless of the wire's request and frame limits.
67
+ * Omitted or undefined params leave the field out. Serialization can throw synchronously and
68
+ * transport failures propagate as supplied.
69
+ * AhpAdapter.initializeConnection awaits this for initialized before reporting readiness.
70
+ */
71
+ notify(method: string, params?: unknown): Promise<void>;
72
+ /**
73
+ * Stop admitting requests and reject outstanding ones with RPC_CONNECTION_CLOSED immediately.
74
+ * Await onClose and transport.close concurrently. Repeated calls share the same promise, including
75
+ * after background failure. It settles when both release operations finish and rejects with
76
+ * RESOURCE_RELEASE_FAILED if either fails. The reader and notification queue run independently.
77
+ * AhpAdapter.dispose awaits this after the SDK has released its session handles.
78
+ */
79
+ close(): Promise<void>;
80
+ private nextId;
81
+ private readonly pending;
82
+ private closing;
83
+ private closePromise?;
84
+ private notificationTail;
85
+ private queuedNotificationCount;
86
+ /**
87
+ * Register correlation and its deadline before dispatch so a fast reply can settle the request.
88
+ * Asynchronous send rejection retires any still-pending entry and reports an unknown outcome.
89
+ */
90
+ private sendRequest;
91
+ /** Read until EOF or failure and initiate cleanup unless it has already started. */
92
+ private read;
93
+ /** Return the method-not-found write for read to await. Correlation and notification admission run inline. */
94
+ private dispatchMessage;
95
+ /** Consume matching correlation once. A malformed reply rejects that caller with PROTOCOL_ERROR. */
96
+ private settleResponse;
97
+ /** Remove live correlation and clear its deadline so a late response or send failure cannot settle it again. */
98
+ private takePending;
99
+ /** Serialize callback delivery in frame order. Backlog overflow throws to read and starts cleanup. */
100
+ private enqueueNotification;
101
+ /** Retire pending requests immediately and cache first-cause cleanup before invoking callbacks. */
102
+ private beginClose;
103
+ /** Run both releases and report their stable failure codes in RESOURCE_RELEASE_FAILED, without retaining private causes. */
104
+ private releaseConnection;
105
+ }
106
+ /**
107
+ * Acquire a process and expose newline-delimited UTF-8 frames as soon as process startup succeeds.
108
+ * Startup failures reject. Reads accept CRLF, skip empty lines and reject oversized frames or
109
+ * an unterminated nonblank tail. The transport drains stderr privately. Send appends a newline
110
+ * and completes with process.write. Close awaits process.dispose and follows its ownership policy.
111
+ * AhpAdapter.initializeConnection transfers the transport to AhpWire or closes it if disposal
112
+ * occurred during acquisition. Direct transport users own close, including after EOF.
113
+ */
114
+ export declare function createStdioTransport(options: HarnessProcessOptions & {
115
+ /** Acquire a process from the supplied options. The transport reads its output and disposes it on close. */
116
+ processFactory?: (p: HarnessProcessOptions) => Promise<HarnessProcess>;
117
+ /** Incoming decoded UTF-8 frame budget, including unfinished lines; defaults to 8 MiB. */
118
+ maxFrameBytes?: number;
119
+ }): Promise<AhpTransport>;