@zvada/agent-server 0.2.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/LICENSE +21 -0
- package/README.md +71 -0
- package/package.json +87 -0
- package/src/client/client.ts +589 -0
- package/src/client/index.ts +18 -0
- package/src/client/transports.ts +84 -0
- package/src/core/agents/acp/acp-agent.ts +322 -0
- package/src/core/agents/acp/adapter.ts +260 -0
- package/src/core/agents/acp/client.ts +212 -0
- package/src/core/agents/acp/known-agents.ts +29 -0
- package/src/core/agents/acp/mappings.ts +136 -0
- package/src/core/agents/base.ts +145 -0
- package/src/core/agents/claude-code/adapter.ts +451 -0
- package/src/core/agents/claude-code/claude-agent.ts +235 -0
- package/src/core/agents/claude-code/generator-session.ts +344 -0
- package/src/core/agents/claude-code/options.ts +161 -0
- package/src/core/agents/claude-code/session-manager.ts +159 -0
- package/src/core/agents/codex-app-server/adapter.ts +214 -0
- package/src/core/agents/codex-app-server/client.ts +221 -0
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
- package/src/core/agents/codex-items.ts +122 -0
- package/src/core/agents/codex-sdk/adapter.ts +204 -0
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
- package/src/core/agents/config-fingerprint.ts +19 -0
- package/src/core/agents/error-classifier.ts +68 -0
- package/src/core/agents/registry.ts +40 -0
- package/src/core/agents/session-store.ts +72 -0
- package/src/core/agents/tool-meta.ts +68 -0
- package/src/core/agents/types.ts +54 -0
- package/src/core/index.ts +114 -0
- package/src/core/presets.ts +78 -0
- package/src/core/provision/extract.ts +31 -0
- package/src/core/provision/index.ts +10 -0
- package/src/core/provision/npm.ts +114 -0
- package/src/core/provision/pins.ts +51 -0
- package/src/core/provision/platform.ts +73 -0
- package/src/core/provision/provisioner.ts +478 -0
- package/src/core/proxy/anthropic-proxy.ts +69 -0
- package/src/core/proxy/api-key-store.ts +34 -0
- package/src/core/proxy/index.ts +7 -0
- package/src/core/runtime/agent-runtime.ts +363 -0
- package/src/core/runtime/event-processor.ts +218 -0
- package/src/core/runtime/event-sink.ts +37 -0
- package/src/core/utils/errors.ts +41 -0
- package/src/index.ts +4 -0
- package/src/protocol/async-queue.ts +68 -0
- package/src/protocol/config.ts +100 -0
- package/src/protocol/factories.ts +125 -0
- package/src/protocol/harness.ts +50 -0
- package/src/protocol/ids.ts +53 -0
- package/src/protocol/index.ts +16 -0
- package/src/protocol/lifecycle.ts +309 -0
- package/src/protocol/models.ts +45 -0
- package/src/protocol/part-input.ts +58 -0
- package/src/protocol/parts.ts +60 -0
- package/src/protocol/thinking.ts +32 -0
- package/src/protocol/tokens.ts +39 -0
- package/src/protocol/tool-state.ts +89 -0
- package/src/protocol/wire.ts +313 -0
- package/src/server/acp/binding.ts +163 -0
- package/src/server/acp/translate.ts +160 -0
- package/src/server/agent-server.ts +357 -0
- package/src/server/bin.ts +174 -0
- package/src/server/index.ts +24 -0
- package/src/server/install.ts +51 -0
- package/src/server/session-log.ts +66 -0
- package/src/server/transports.ts +149 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AsyncQueue,
|
|
3
|
+
type EventsReplayResult,
|
|
4
|
+
EventsReplayResultSchema,
|
|
5
|
+
type InitializeResult,
|
|
6
|
+
InitializeResultSchema,
|
|
7
|
+
type JsonRpcId,
|
|
8
|
+
type LifecycleEvent,
|
|
9
|
+
type PermissionOutcome,
|
|
10
|
+
PermissionRespondResultSchema,
|
|
11
|
+
SessionCloseResultSchema,
|
|
12
|
+
ShutdownResultSchema,
|
|
13
|
+
type TurnCancelResult,
|
|
14
|
+
TurnCancelResultSchema,
|
|
15
|
+
type TurnEndedEvent,
|
|
16
|
+
type TurnStartParams,
|
|
17
|
+
TurnStartResultSchema,
|
|
18
|
+
WIRE_ERROR_CODES,
|
|
19
|
+
WIRE_METHODS,
|
|
20
|
+
WIRE_PROTOCOL_VERSION,
|
|
21
|
+
type WireEventEnvelope,
|
|
22
|
+
WireEventEnvelopeSchema,
|
|
23
|
+
type WireImplementationInfo,
|
|
24
|
+
type WireTransport,
|
|
25
|
+
decodeWireMessage,
|
|
26
|
+
encodeRequest,
|
|
27
|
+
generateUUIDv7,
|
|
28
|
+
} from "../protocol/index.ts";
|
|
29
|
+
import {
|
|
30
|
+
type SpawnServerOptions,
|
|
31
|
+
type WebSocketLike,
|
|
32
|
+
connectTransport,
|
|
33
|
+
socketTransport,
|
|
34
|
+
spawnTransport,
|
|
35
|
+
} from "./transports.ts";
|
|
36
|
+
|
|
37
|
+
/** The server answered a request with a JSON-RPC error. */
|
|
38
|
+
export class WireRequestError extends Error {
|
|
39
|
+
override readonly name = "WireRequestError";
|
|
40
|
+
constructor(
|
|
41
|
+
readonly code: number,
|
|
42
|
+
message: string,
|
|
43
|
+
readonly data?: unknown,
|
|
44
|
+
) {
|
|
45
|
+
super(message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The transport dropped (and reconnection, if enabled, gave up). */
|
|
50
|
+
export class ConnectionClosedError extends Error {
|
|
51
|
+
override readonly name = "ConnectionClosedError";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Events were lost irrecoverably: the replay window was evicted or the server
|
|
56
|
+
* restarted. The consumer's local view of the session is stale.
|
|
57
|
+
*/
|
|
58
|
+
export class EventGapError extends Error {
|
|
59
|
+
override readonly name = "EventGapError";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A running turn: consume `events` (ends after `turn.ended`) or await `result`. */
|
|
63
|
+
export interface TurnHandle {
|
|
64
|
+
sessionId: string;
|
|
65
|
+
turnId: string;
|
|
66
|
+
events: AsyncIterableIterator<LifecycleEvent>;
|
|
67
|
+
result: Promise<TurnEndedEvent>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AgentServerClientOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Redial and replay missed events on transport drop. Defaults to true for
|
|
73
|
+
* `connect`, false for `spawn`/`attach` (a respawned server is a new
|
|
74
|
+
* process with empty session state).
|
|
75
|
+
*/
|
|
76
|
+
reconnect?: boolean;
|
|
77
|
+
maxReconnectAttempts?: number;
|
|
78
|
+
/** Base reconnect delay; grows exponentially, capped at 10s. */
|
|
79
|
+
reconnectDelayMs?: number;
|
|
80
|
+
requestTimeoutMs?: number;
|
|
81
|
+
/** Attempts to heal one sequence gap before failing its turn. Default 3. */
|
|
82
|
+
maxReplayAttempts?: number;
|
|
83
|
+
/** Delay between failed replay requests. Defaults to reconnectDelayMs. */
|
|
84
|
+
replayRetryDelayMs?: number;
|
|
85
|
+
/** Sent in `initialize`. */
|
|
86
|
+
clientInfo?: WireImplementationInfo;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface PendingRequest {
|
|
90
|
+
resolve: (raw: unknown) => void;
|
|
91
|
+
reject: (error: unknown) => void;
|
|
92
|
+
timer: ReturnType<typeof setTimeout>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface InternalTurn {
|
|
96
|
+
turnId: string;
|
|
97
|
+
queue: AsyncQueue<LifecycleEvent>;
|
|
98
|
+
resolveResult: (event: TurnEndedEvent) => void;
|
|
99
|
+
rejectResult: (error: unknown) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ResultSchema<T> {
|
|
103
|
+
safeParse(raw: unknown): { success: true; data: T } | { success: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type TransportFactory = () => Promise<WireTransport> | WireTransport;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Typed client for the agent-server wire. Turns are quick-ack: `runTurn`
|
|
110
|
+
* resolves once the server accepts the turn and hands back an async iterable
|
|
111
|
+
* that yields every lifecycle event for the session, ending with `turn.ended`.
|
|
112
|
+
* Per-session `seq` is tracked so duplicates are dropped, in-stream gaps are
|
|
113
|
+
* healed via `events/replay`, and — in `connect` mode — a dropped socket is
|
|
114
|
+
* redialed and resynced transparently.
|
|
115
|
+
*/
|
|
116
|
+
export class AgentServerClient {
|
|
117
|
+
private transport: WireTransport | null = null;
|
|
118
|
+
private nextId = 1;
|
|
119
|
+
private readonly pending = new Map<JsonRpcId, PendingRequest>();
|
|
120
|
+
private readonly lastSeq = new Map<string, number>();
|
|
121
|
+
private readonly heldBack = new Map<string, WireEventEnvelope[]>();
|
|
122
|
+
private readonly syncing = new Set<string>();
|
|
123
|
+
private readonly eventHandlers = new Set<(envelope: WireEventEnvelope) => void>();
|
|
124
|
+
private readonly turnBySession = new Map<string, InternalTurn>();
|
|
125
|
+
private initializePromise: Promise<InitializeResult> | null = null;
|
|
126
|
+
private closed = false;
|
|
127
|
+
private reconnecting = false;
|
|
128
|
+
|
|
129
|
+
private constructor(
|
|
130
|
+
private readonly factory: TransportFactory,
|
|
131
|
+
private readonly options: AgentServerClientOptions,
|
|
132
|
+
private readonly reconnectEnabled: boolean,
|
|
133
|
+
) {}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Bring your own transport: the factory is invoked for the initial
|
|
137
|
+
* connection and for every reconnect attempt (reconnect defaults to true).
|
|
138
|
+
*/
|
|
139
|
+
static async fromTransportFactory(
|
|
140
|
+
factory: TransportFactory,
|
|
141
|
+
options: AgentServerClientOptions = {},
|
|
142
|
+
): Promise<AgentServerClient> {
|
|
143
|
+
const client = new AgentServerClient(factory, options, options.reconnect ?? true);
|
|
144
|
+
await client.openTransport();
|
|
145
|
+
return client;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Spawn an agent-server subprocess and talk over its stdio. */
|
|
149
|
+
static async spawn(
|
|
150
|
+
spawnOpts: SpawnServerOptions,
|
|
151
|
+
options: AgentServerClientOptions = {},
|
|
152
|
+
): Promise<AgentServerClient> {
|
|
153
|
+
const client = new AgentServerClient(
|
|
154
|
+
() => spawnTransport(spawnOpts),
|
|
155
|
+
options,
|
|
156
|
+
options.reconnect ?? false,
|
|
157
|
+
);
|
|
158
|
+
await client.openTransport();
|
|
159
|
+
return client;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Dial a listening agent-server (reconnects by default). */
|
|
163
|
+
static async connect(
|
|
164
|
+
url: string,
|
|
165
|
+
options: AgentServerClientOptions = {},
|
|
166
|
+
): Promise<AgentServerClient> {
|
|
167
|
+
const client = new AgentServerClient(
|
|
168
|
+
() => connectTransport(url),
|
|
169
|
+
options,
|
|
170
|
+
options.reconnect ?? true,
|
|
171
|
+
);
|
|
172
|
+
await client.openTransport();
|
|
173
|
+
return client;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Wrap an already-connected socket (e.g. the accepted end of a dial-out). */
|
|
177
|
+
static async attach(
|
|
178
|
+
socket: WebSocketLike | WireTransport,
|
|
179
|
+
options: AgentServerClientOptions = {},
|
|
180
|
+
): Promise<AgentServerClient> {
|
|
181
|
+
const transport = "onLine" in socket ? socket : socketTransport(socket);
|
|
182
|
+
let used = false;
|
|
183
|
+
const client = new AgentServerClient(
|
|
184
|
+
() => {
|
|
185
|
+
if (used) throw new ConnectionClosedError("attached socket cannot be re-opened");
|
|
186
|
+
used = true;
|
|
187
|
+
return transport;
|
|
188
|
+
},
|
|
189
|
+
options,
|
|
190
|
+
false,
|
|
191
|
+
);
|
|
192
|
+
await client.openTransport();
|
|
193
|
+
return client;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- public API --------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/** Handshake (memoized). Called lazily by `runTurn`/`startTurn`. */
|
|
199
|
+
initialize(): Promise<InitializeResult> {
|
|
200
|
+
this.initializePromise ??= this.request(
|
|
201
|
+
WIRE_METHODS.initialize,
|
|
202
|
+
{ protocolVersion: WIRE_PROTOCOL_VERSION, client: this.options.clientInfo },
|
|
203
|
+
InitializeResultSchema,
|
|
204
|
+
)
|
|
205
|
+
.then((result) => {
|
|
206
|
+
if (result.protocolVersion !== WIRE_PROTOCOL_VERSION) {
|
|
207
|
+
throw new WireRequestError(
|
|
208
|
+
WIRE_ERROR_CODES.protocolVersionMismatch,
|
|
209
|
+
`server selected unsupported protocol version: ${result.protocolVersion}`,
|
|
210
|
+
{ supported: [WIRE_PROTOCOL_VERSION] },
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
})
|
|
215
|
+
.catch((err) => {
|
|
216
|
+
this.initializePromise = null;
|
|
217
|
+
throw err;
|
|
218
|
+
});
|
|
219
|
+
return this.initializePromise;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Start a turn and stream it. Ids are minted client-side when omitted so
|
|
224
|
+
* events arriving around the ack are never dropped.
|
|
225
|
+
*/
|
|
226
|
+
async runTurn(params: TurnStartParams): Promise<TurnHandle> {
|
|
227
|
+
await this.initialize();
|
|
228
|
+
const sessionId = params.sessionId ?? generateUUIDv7();
|
|
229
|
+
const turnId = params.turnId ?? generateUUIDv7();
|
|
230
|
+
if (this.turnBySession.has(sessionId)) {
|
|
231
|
+
throw new Error(`session ${sessionId} already has an active turn`);
|
|
232
|
+
}
|
|
233
|
+
const queue = new AsyncQueue<LifecycleEvent>();
|
|
234
|
+
let resolveResult!: (event: TurnEndedEvent) => void;
|
|
235
|
+
let rejectResult!: (error: unknown) => void;
|
|
236
|
+
const result = new Promise<TurnEndedEvent>((resolve, reject) => {
|
|
237
|
+
resolveResult = resolve;
|
|
238
|
+
rejectResult = reject;
|
|
239
|
+
});
|
|
240
|
+
// Consumers may only iterate `events`; don't surface an unhandled rejection.
|
|
241
|
+
result.catch(() => undefined);
|
|
242
|
+
this.turnBySession.set(sessionId, { turnId, queue, resolveResult, rejectResult });
|
|
243
|
+
try {
|
|
244
|
+
await this.request(
|
|
245
|
+
WIRE_METHODS.turnStart,
|
|
246
|
+
{ ...params, sessionId, turnId },
|
|
247
|
+
TurnStartResultSchema,
|
|
248
|
+
);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
this.turnBySession.delete(sessionId);
|
|
251
|
+
queue.fail(err);
|
|
252
|
+
rejectResult(err);
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
return { sessionId, turnId, events: queue, result };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Quick-ack only — observe the turn via `onEvent` / `replay`. */
|
|
259
|
+
async startTurn(params: TurnStartParams): Promise<{ sessionId: string; turnId: string }> {
|
|
260
|
+
await this.initialize();
|
|
261
|
+
return this.request(WIRE_METHODS.turnStart, params, TurnStartResultSchema);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Answer a `permission.requested` event. */
|
|
265
|
+
async respondPermission(
|
|
266
|
+
sessionId: string,
|
|
267
|
+
requestId: string,
|
|
268
|
+
outcome: PermissionOutcome,
|
|
269
|
+
): Promise<boolean> {
|
|
270
|
+
const { accepted } = await this.request(
|
|
271
|
+
WIRE_METHODS.permissionRespond,
|
|
272
|
+
{ sessionId, requestId, outcome },
|
|
273
|
+
PermissionRespondResultSchema,
|
|
274
|
+
);
|
|
275
|
+
return accepted;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Cancel the session's active turn; resolves once cancellation propagated. */
|
|
279
|
+
cancelTurn(sessionId: string): Promise<TurnCancelResult> {
|
|
280
|
+
return this.request(WIRE_METHODS.turnCancel, { sessionId }, TurnCancelResultSchema);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Release an idle session's replay history and harness-native resources. */
|
|
284
|
+
async closeSession(sessionId: string): Promise<boolean> {
|
|
285
|
+
if (this.turnBySession.has(sessionId)) {
|
|
286
|
+
throw new Error(`cannot close session ${sessionId} while its turn is active`);
|
|
287
|
+
}
|
|
288
|
+
const { closed } = await this.request(
|
|
289
|
+
WIRE_METHODS.sessionClose,
|
|
290
|
+
{ sessionId },
|
|
291
|
+
SessionCloseResultSchema,
|
|
292
|
+
);
|
|
293
|
+
// Keep the seq watermark: the server continues a reused id's seq where the
|
|
294
|
+
// closed incarnation stopped, so post-reuse events stay contiguous for this
|
|
295
|
+
// client too. A genuine server restart surfaces as a transport drop, and the
|
|
296
|
+
// resync's unknownSession path resets the watermark then.
|
|
297
|
+
this.heldBack.delete(sessionId);
|
|
298
|
+
this.syncing.delete(sessionId);
|
|
299
|
+
return closed;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Fetch buffered events (`seq >= fromSeq`) without touching seq tracking. */
|
|
303
|
+
replay(sessionId: string, fromSeq: number): Promise<EventsReplayResult> {
|
|
304
|
+
return this.request(
|
|
305
|
+
WIRE_METHODS.eventsReplay,
|
|
306
|
+
{ sessionId, fromSeq },
|
|
307
|
+
EventsReplayResultSchema,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Firehose of sequenced events across all sessions (post-dedupe, in order). */
|
|
312
|
+
onEvent(handler: (envelope: WireEventEnvelope) => void): () => void {
|
|
313
|
+
this.eventHandlers.add(handler);
|
|
314
|
+
return () => this.eventHandlers.delete(handler);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Ask the server to shut down, then close this client. */
|
|
318
|
+
async shutdownServer(): Promise<void> {
|
|
319
|
+
await this.request(WIRE_METHODS.shutdown, {}, ShutdownResultSchema).catch(() => undefined);
|
|
320
|
+
await this.close();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async close(): Promise<void> {
|
|
324
|
+
if (this.closed) return;
|
|
325
|
+
this.closed = true;
|
|
326
|
+
const reason = new ConnectionClosedError("client closed");
|
|
327
|
+
this.failPending(reason);
|
|
328
|
+
this.failAllTurns(reason);
|
|
329
|
+
this.transport?.close();
|
|
330
|
+
this.transport = null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---- transport lifecycle -----------------------------------------------
|
|
334
|
+
|
|
335
|
+
private async openTransport(): Promise<void> {
|
|
336
|
+
const transport = await this.factory();
|
|
337
|
+
if (this.closed) {
|
|
338
|
+
transport.close();
|
|
339
|
+
throw new ConnectionClosedError("client closed while connecting");
|
|
340
|
+
}
|
|
341
|
+
if (transport.closed) throw new ConnectionClosedError("transport opened already closed");
|
|
342
|
+
this.transport = transport;
|
|
343
|
+
transport.onLine((line) => this.handleLine(line));
|
|
344
|
+
transport.onClose((reason) => this.handleTransportClose(transport, reason));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private handleTransportClose(transport: WireTransport, reason?: string): void {
|
|
348
|
+
// A late close from a superseded transport must not tear down the current one.
|
|
349
|
+
if (this.transport !== transport) return;
|
|
350
|
+
this.transport = null;
|
|
351
|
+
this.failPending(new ConnectionClosedError(reason ?? "connection closed"));
|
|
352
|
+
if (this.closed) return;
|
|
353
|
+
if (!this.reconnectEnabled) {
|
|
354
|
+
this.closed = true;
|
|
355
|
+
this.failAllTurns(new ConnectionClosedError(reason ?? "connection closed"));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
void this.reconnectLoop();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private async reconnectLoop(): Promise<void> {
|
|
362
|
+
if (this.reconnecting) return;
|
|
363
|
+
this.reconnecting = true;
|
|
364
|
+
const maxAttempts = this.options.maxReconnectAttempts ?? 8;
|
|
365
|
+
const base = this.options.reconnectDelayMs ?? 250;
|
|
366
|
+
try {
|
|
367
|
+
for (let attempt = 0; attempt < maxAttempts && !this.closed; attempt++) {
|
|
368
|
+
await sleep(Math.min(10_000, base * 2 ** Math.min(attempt, 5)));
|
|
369
|
+
if (this.closed) break;
|
|
370
|
+
try {
|
|
371
|
+
await this.openTransport();
|
|
372
|
+
if (this.closed || !this.transport) continue;
|
|
373
|
+
// Heal every tracked session — including turns that saw no event
|
|
374
|
+
// before the drop (or none after it): without a resync they would
|
|
375
|
+
// never receive their turn.ended and would hang forever.
|
|
376
|
+
const sessionIds = new Set([...this.lastSeq.keys(), ...this.turnBySession.keys()]);
|
|
377
|
+
for (const sessionId of sessionIds) void this.syncSession(sessionId);
|
|
378
|
+
if (!this.transport) continue;
|
|
379
|
+
return;
|
|
380
|
+
} catch {
|
|
381
|
+
// next attempt
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (!this.closed) {
|
|
385
|
+
this.closed = true;
|
|
386
|
+
this.failAllTurns(new ConnectionClosedError("reconnect failed"));
|
|
387
|
+
}
|
|
388
|
+
} finally {
|
|
389
|
+
this.reconnecting = false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---- inbound dispatch ----------------------------------------------------
|
|
394
|
+
|
|
395
|
+
private handleLine(line: string): void {
|
|
396
|
+
const msg = decodeWireMessage(line);
|
|
397
|
+
if (msg.kind === "response") {
|
|
398
|
+
if (msg.id === null) return; // protocol-level error for an unparseable line
|
|
399
|
+
const pending = this.pending.get(msg.id);
|
|
400
|
+
if (!pending) return;
|
|
401
|
+
this.pending.delete(msg.id);
|
|
402
|
+
clearTimeout(pending.timer);
|
|
403
|
+
if (msg.error)
|
|
404
|
+
pending.reject(new WireRequestError(msg.error.code, msg.error.message, msg.error.data));
|
|
405
|
+
else pending.resolve(msg.result);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (msg.kind === "notification" && msg.method === WIRE_METHODS.event) {
|
|
409
|
+
const parsed = WireEventEnvelopeSchema.safeParse(msg.params);
|
|
410
|
+
if (parsed.success) this.handleEnvelope(parsed.data);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Seq discipline: drop duplicates, deliver contiguous, hold back and heal
|
|
416
|
+
* gaps via replay. Events are always delivered in seq order per session.
|
|
417
|
+
*/
|
|
418
|
+
private handleEnvelope(envelope: WireEventEnvelope): void {
|
|
419
|
+
const sessionId = envelope.sessionId;
|
|
420
|
+
const last = this.lastSeq.get(sessionId) ?? 0;
|
|
421
|
+
if (envelope.seq <= last) return; // duplicate (e.g. replay overlap)
|
|
422
|
+
if (this.syncing.has(sessionId)) {
|
|
423
|
+
this.holdBack(sessionId, envelope);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
if (envelope.seq === last + 1) {
|
|
427
|
+
this.deliver(envelope);
|
|
428
|
+
this.drainHeld(sessionId);
|
|
429
|
+
} else {
|
|
430
|
+
this.holdBack(sessionId, envelope);
|
|
431
|
+
void this.syncSession(sessionId);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private holdBack(sessionId: string, envelope: WireEventEnvelope): void {
|
|
436
|
+
const held = this.heldBack.get(sessionId) ?? [];
|
|
437
|
+
held.push(envelope);
|
|
438
|
+
this.heldBack.set(sessionId, held);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private drainHeld(sessionId: string): void {
|
|
442
|
+
const held = this.heldBack.get(sessionId);
|
|
443
|
+
if (!held?.length) return;
|
|
444
|
+
held.sort((a, b) => a.seq - b.seq);
|
|
445
|
+
while (held.length) {
|
|
446
|
+
const next = held[0] as WireEventEnvelope;
|
|
447
|
+
const last = this.lastSeq.get(sessionId) ?? 0;
|
|
448
|
+
if (next.seq <= last) held.shift();
|
|
449
|
+
else if (next.seq === last + 1) {
|
|
450
|
+
held.shift();
|
|
451
|
+
this.deliver(next);
|
|
452
|
+
} else break;
|
|
453
|
+
}
|
|
454
|
+
if (!held.length) this.heldBack.delete(sessionId);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private async syncSession(sessionId: string): Promise<void> {
|
|
458
|
+
if (this.syncing.has(sessionId) || this.closed || !this.transport) return;
|
|
459
|
+
this.syncing.add(sessionId);
|
|
460
|
+
try {
|
|
461
|
+
const maxAttempts = Math.max(1, this.options.maxReplayAttempts ?? 3);
|
|
462
|
+
const retryDelay = this.options.replayRetryDelayMs ?? this.options.reconnectDelayMs ?? 250;
|
|
463
|
+
for (let attempt = 1; attempt <= maxAttempts && !this.closed && this.transport; attempt++) {
|
|
464
|
+
const fromSeq = (this.lastSeq.get(sessionId) ?? 0) + 1;
|
|
465
|
+
try {
|
|
466
|
+
const replayed = await this.replay(sessionId, fromSeq);
|
|
467
|
+
if (replayed.firstAvailableSeq !== null && replayed.firstAvailableSeq > fromSeq) {
|
|
468
|
+
// The prefix was evicted — the gap is unfillable. Fail the turn
|
|
469
|
+
// handle (its part reconstruction is broken) and jump forward.
|
|
470
|
+
this.failTurn(
|
|
471
|
+
sessionId,
|
|
472
|
+
new EventGapError(
|
|
473
|
+
`events ${fromSeq}..${replayed.firstAvailableSeq - 1} for session ${sessionId} were evicted`,
|
|
474
|
+
),
|
|
475
|
+
);
|
|
476
|
+
this.lastSeq.set(sessionId, replayed.firstAvailableSeq - 1);
|
|
477
|
+
}
|
|
478
|
+
for (const envelope of replayed.events) this.holdBack(sessionId, envelope);
|
|
479
|
+
break;
|
|
480
|
+
} catch (err) {
|
|
481
|
+
if (err instanceof WireRequestError && err.code === WIRE_ERROR_CODES.unknownSession) {
|
|
482
|
+
this.failTurn(
|
|
483
|
+
sessionId,
|
|
484
|
+
new EventGapError(`session ${sessionId} is unknown to the server (restarted?)`),
|
|
485
|
+
);
|
|
486
|
+
this.lastSeq.delete(sessionId);
|
|
487
|
+
this.heldBack.delete(sessionId);
|
|
488
|
+
break;
|
|
489
|
+
}
|
|
490
|
+
if (!this.transport || this.closed) break; // reconnect performs a fresh resync
|
|
491
|
+
if (attempt === maxAttempts) {
|
|
492
|
+
this.failTurn(
|
|
493
|
+
sessionId,
|
|
494
|
+
new EventGapError(
|
|
495
|
+
`failed to replay events from ${fromSeq} for session ${sessionId} after ${maxAttempts} attempts`,
|
|
496
|
+
),
|
|
497
|
+
);
|
|
498
|
+
this.heldBack.delete(sessionId);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
await sleep(retryDelay);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} finally {
|
|
505
|
+
this.syncing.delete(sessionId);
|
|
506
|
+
}
|
|
507
|
+
this.drainHeld(sessionId);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
private deliver(envelope: WireEventEnvelope): void {
|
|
511
|
+
this.lastSeq.set(envelope.sessionId, envelope.seq);
|
|
512
|
+
for (const handler of [...this.eventHandlers]) {
|
|
513
|
+
try {
|
|
514
|
+
handler(envelope);
|
|
515
|
+
} catch {
|
|
516
|
+
// consumer handler errors must not break dispatch
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const turn = this.turnBySession.get(envelope.sessionId);
|
|
520
|
+
if (!turn) return;
|
|
521
|
+
turn.queue.push(envelope.event);
|
|
522
|
+
if (envelope.event.type === "turn.ended" && envelope.event.turnId === turn.turnId) {
|
|
523
|
+
this.turnBySession.delete(envelope.sessionId);
|
|
524
|
+
turn.resolveResult(envelope.event);
|
|
525
|
+
turn.queue.end();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private failTurn(sessionId: string, error: Error): void {
|
|
530
|
+
const turn = this.turnBySession.get(sessionId);
|
|
531
|
+
if (!turn) return;
|
|
532
|
+
this.turnBySession.delete(sessionId);
|
|
533
|
+
turn.rejectResult(error);
|
|
534
|
+
turn.queue.fail(error);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private failAllTurns(error: Error): void {
|
|
538
|
+
for (const sessionId of [...this.turnBySession.keys()]) this.failTurn(sessionId, error);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private failPending(error: Error): void {
|
|
542
|
+
for (const [, pending] of this.pending) {
|
|
543
|
+
clearTimeout(pending.timer);
|
|
544
|
+
pending.reject(error);
|
|
545
|
+
}
|
|
546
|
+
this.pending.clear();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ---- outbound ------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
private request<T>(method: string, params: unknown, schema: ResultSchema<T>): Promise<T> {
|
|
552
|
+
if (this.closed) return Promise.reject(new ConnectionClosedError("client closed"));
|
|
553
|
+
const transport = this.transport;
|
|
554
|
+
if (!transport) return Promise.reject(new ConnectionClosedError("not connected"));
|
|
555
|
+
const id = this.nextId++;
|
|
556
|
+
const timeoutMs = this.options.requestTimeoutMs ?? 30_000;
|
|
557
|
+
return new Promise<T>((resolve, reject) => {
|
|
558
|
+
const timer = setTimeout(() => {
|
|
559
|
+
this.pending.delete(id);
|
|
560
|
+
reject(
|
|
561
|
+
new WireRequestError(WIRE_ERROR_CODES.internalError, `request timed out: ${method}`),
|
|
562
|
+
);
|
|
563
|
+
}, timeoutMs);
|
|
564
|
+
this.pending.set(id, {
|
|
565
|
+
resolve: (raw) => {
|
|
566
|
+
const parsed = schema.safeParse(raw);
|
|
567
|
+
if (parsed.success) resolve(parsed.data);
|
|
568
|
+
else
|
|
569
|
+
reject(
|
|
570
|
+
new WireRequestError(WIRE_ERROR_CODES.internalError, `malformed ${method} result`),
|
|
571
|
+
);
|
|
572
|
+
},
|
|
573
|
+
reject,
|
|
574
|
+
timer,
|
|
575
|
+
});
|
|
576
|
+
try {
|
|
577
|
+
transport.send(encodeRequest(id, method, params));
|
|
578
|
+
} catch (error) {
|
|
579
|
+
clearTimeout(timer);
|
|
580
|
+
this.pending.delete(id);
|
|
581
|
+
reject(error);
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function sleep(ms: number): Promise<void> {
|
|
588
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
589
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// @zvada/agent-server/client — typed client for the agent-server wire.
|
|
2
|
+
// The only package a remote consumer needs to import.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
AgentServerClient,
|
|
6
|
+
type AgentServerClientOptions,
|
|
7
|
+
ConnectionClosedError,
|
|
8
|
+
EventGapError,
|
|
9
|
+
type TurnHandle,
|
|
10
|
+
WireRequestError,
|
|
11
|
+
} from "./client.ts";
|
|
12
|
+
export {
|
|
13
|
+
connectTransport,
|
|
14
|
+
type SpawnServerOptions,
|
|
15
|
+
socketTransport,
|
|
16
|
+
spawnTransport,
|
|
17
|
+
type WebSocketLike,
|
|
18
|
+
} from "./transports.ts";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { type WireTransport, channelTransport, pushNdjsonLines } from "../protocol/index.ts";
|
|
4
|
+
|
|
5
|
+
export interface SpawnServerOptions {
|
|
6
|
+
command: string;
|
|
7
|
+
args?: string[];
|
|
8
|
+
cwd?: string;
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Spawn an agent-server subprocess and talk NDJSON over its stdio
|
|
14
|
+
* (deus-machine's model). The child's stderr passes through to ours.
|
|
15
|
+
*/
|
|
16
|
+
export function spawnTransport(opts: SpawnServerOptions): WireTransport {
|
|
17
|
+
const child = nodeSpawn(opts.command, opts.args ?? [], {
|
|
18
|
+
cwd: opts.cwd,
|
|
19
|
+
env: opts.env ? { ...process.env, ...opts.env } : process.env,
|
|
20
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
21
|
+
});
|
|
22
|
+
if (!child.stdin || !child.stdout) throw new Error("failed to open child stdio pipes");
|
|
23
|
+
const stdin = child.stdin;
|
|
24
|
+
const { transport, push, end } = channelTransport({
|
|
25
|
+
send: (line) => void stdin.write(`${line}\n`),
|
|
26
|
+
close: () => void child.kill(),
|
|
27
|
+
});
|
|
28
|
+
const rl = createInterface({ input: child.stdout, terminal: false });
|
|
29
|
+
rl.on("line", push);
|
|
30
|
+
child.on("exit", (code) => end(`server exited (code ${code ?? "signal"})`));
|
|
31
|
+
child.on("error", (err) => end(err.message));
|
|
32
|
+
// A dead server pipe (EPIPE) must route through the close path — an
|
|
33
|
+
// unhandled stream 'error' would crash the consumer process instead.
|
|
34
|
+
stdin.on("error", (err: Error) => end(`server stdin error: ${err.message}`));
|
|
35
|
+
child.stdout.on("error", (err: Error) => end(`server stdout error: ${err.message}`));
|
|
36
|
+
return transport;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Dial a listening agent-server; resolves once the socket is open. */
|
|
40
|
+
export function connectTransport(url: string): Promise<WireTransport> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const ws = new WebSocket(url);
|
|
43
|
+
const { transport, push, end } = channelTransport({
|
|
44
|
+
send: (line) => void ws.send(line),
|
|
45
|
+
close: () => ws.close(),
|
|
46
|
+
});
|
|
47
|
+
let opened = false;
|
|
48
|
+
ws.addEventListener("open", () => {
|
|
49
|
+
opened = true;
|
|
50
|
+
resolve(transport);
|
|
51
|
+
});
|
|
52
|
+
ws.addEventListener("message", (ev) => pushNdjsonLines(push, ev.data));
|
|
53
|
+
ws.addEventListener("close", () => {
|
|
54
|
+
if (!opened) reject(new Error(`websocket connect failed: ${url}`));
|
|
55
|
+
end("socket closed");
|
|
56
|
+
});
|
|
57
|
+
// A close event always follows; nothing to do here.
|
|
58
|
+
ws.addEventListener("error", () => {});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The subset of a WebSocket this package needs — matches browser/Bun/Node and
|
|
64
|
+
* Cloudflare Workers sockets alike.
|
|
65
|
+
*/
|
|
66
|
+
export interface WebSocketLike {
|
|
67
|
+
send(data: string): void;
|
|
68
|
+
close(): void;
|
|
69
|
+
addEventListener(type: "message" | "close", listener: (event: { data?: unknown }) => void): void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wrap an already-accepted socket (echo's model: the sandboxed server dialed
|
|
74
|
+
* out to us and this is the accepted end).
|
|
75
|
+
*/
|
|
76
|
+
export function socketTransport(socket: WebSocketLike): WireTransport {
|
|
77
|
+
const { transport, push, end } = channelTransport({
|
|
78
|
+
send: (line) => socket.send(line),
|
|
79
|
+
close: () => socket.close(),
|
|
80
|
+
});
|
|
81
|
+
socket.addEventListener("message", (ev) => pushNdjsonLines(push, ev.data));
|
|
82
|
+
socket.addEventListener("close", () => end("socket closed"));
|
|
83
|
+
return transport;
|
|
84
|
+
}
|