@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -0
  3. package/package.json +87 -0
  4. package/src/client/client.ts +589 -0
  5. package/src/client/index.ts +18 -0
  6. package/src/client/transports.ts +84 -0
  7. package/src/core/agents/acp/acp-agent.ts +322 -0
  8. package/src/core/agents/acp/adapter.ts +260 -0
  9. package/src/core/agents/acp/client.ts +212 -0
  10. package/src/core/agents/acp/known-agents.ts +29 -0
  11. package/src/core/agents/acp/mappings.ts +136 -0
  12. package/src/core/agents/base.ts +145 -0
  13. package/src/core/agents/claude-code/adapter.ts +451 -0
  14. package/src/core/agents/claude-code/claude-agent.ts +235 -0
  15. package/src/core/agents/claude-code/generator-session.ts +344 -0
  16. package/src/core/agents/claude-code/options.ts +161 -0
  17. package/src/core/agents/claude-code/session-manager.ts +159 -0
  18. package/src/core/agents/codex-app-server/adapter.ts +214 -0
  19. package/src/core/agents/codex-app-server/client.ts +221 -0
  20. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
  21. package/src/core/agents/codex-items.ts +122 -0
  22. package/src/core/agents/codex-sdk/adapter.ts +204 -0
  23. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
  24. package/src/core/agents/config-fingerprint.ts +19 -0
  25. package/src/core/agents/error-classifier.ts +68 -0
  26. package/src/core/agents/registry.ts +40 -0
  27. package/src/core/agents/session-store.ts +72 -0
  28. package/src/core/agents/tool-meta.ts +68 -0
  29. package/src/core/agents/types.ts +54 -0
  30. package/src/core/index.ts +114 -0
  31. package/src/core/presets.ts +78 -0
  32. package/src/core/provision/extract.ts +31 -0
  33. package/src/core/provision/index.ts +10 -0
  34. package/src/core/provision/npm.ts +114 -0
  35. package/src/core/provision/pins.ts +51 -0
  36. package/src/core/provision/platform.ts +73 -0
  37. package/src/core/provision/provisioner.ts +478 -0
  38. package/src/core/proxy/anthropic-proxy.ts +69 -0
  39. package/src/core/proxy/api-key-store.ts +34 -0
  40. package/src/core/proxy/index.ts +7 -0
  41. package/src/core/runtime/agent-runtime.ts +363 -0
  42. package/src/core/runtime/event-processor.ts +218 -0
  43. package/src/core/runtime/event-sink.ts +37 -0
  44. package/src/core/utils/errors.ts +41 -0
  45. package/src/index.ts +4 -0
  46. package/src/protocol/async-queue.ts +68 -0
  47. package/src/protocol/config.ts +100 -0
  48. package/src/protocol/factories.ts +125 -0
  49. package/src/protocol/harness.ts +50 -0
  50. package/src/protocol/ids.ts +53 -0
  51. package/src/protocol/index.ts +16 -0
  52. package/src/protocol/lifecycle.ts +309 -0
  53. package/src/protocol/models.ts +45 -0
  54. package/src/protocol/part-input.ts +58 -0
  55. package/src/protocol/parts.ts +60 -0
  56. package/src/protocol/thinking.ts +32 -0
  57. package/src/protocol/tokens.ts +39 -0
  58. package/src/protocol/tool-state.ts +89 -0
  59. package/src/protocol/wire.ts +313 -0
  60. package/src/server/acp/binding.ts +163 -0
  61. package/src/server/acp/translate.ts +160 -0
  62. package/src/server/agent-server.ts +357 -0
  63. package/src/server/bin.ts +174 -0
  64. package/src/server/index.ts +24 -0
  65. package/src/server/install.ts +51 -0
  66. package/src/server/session-log.ts +66 -0
  67. package/src/server/transports.ts +149 -0
@@ -0,0 +1,357 @@
1
+ import { type AgentRuntime, callbackSink } from "../core/index.ts";
2
+ import {
3
+ type AgentCapabilities,
4
+ type AgentHarness,
5
+ EventsReplayParamsSchema,
6
+ InitializeParamsSchema,
7
+ type InitializeResult,
8
+ type JsonRpcId,
9
+ PermissionRespondParamsSchema,
10
+ type RunRequest,
11
+ SessionCloseParamsSchema,
12
+ TurnCancelParamsSchema,
13
+ TurnStartParamsSchema,
14
+ WIRE_ERROR_CODES,
15
+ WIRE_METHODS,
16
+ WIRE_PROTOCOL_VERSION,
17
+ type WireEventEnvelope,
18
+ type WireImplementationInfo,
19
+ type WireTransport,
20
+ decodeWireMessage,
21
+ encodeErrorResponse,
22
+ encodeNotification,
23
+ encodeResponse,
24
+ generateUUIDv7,
25
+ } from "../protocol/index.ts";
26
+ import { DEFAULT_BUFFER_SIZE, SessionLog } from "./session-log.ts";
27
+
28
+ /** Structural slice of a ZodError — keeps zod out of this package's deps. */
29
+ interface ValidationError {
30
+ issues: ReadonlyArray<{ path: ReadonlyArray<PropertyKey>; message: string }>;
31
+ }
32
+
33
+ export interface AgentServerOptions {
34
+ /** Replay ring-buffer size per session (envelopes). */
35
+ bufferSize?: number;
36
+ /** Reported in the `initialize` result. */
37
+ info?: WireImplementationInfo;
38
+ /** Called once a `shutdown` request has been acked and the runtime drained. */
39
+ onShutdown?: () => void;
40
+ }
41
+
42
+ /**
43
+ * The wire binding (phase 2): serves the engine over JSON-RPC 2.0 on any
44
+ * number of attached transports. Requests flow client -> server only; every
45
+ * lifecycle event is stamped with a per-session monotonic `seq`, buffered for
46
+ * `events/replay`, and broadcast to all attached transports as an `event`
47
+ * notification. `turn/start` is quick-ack: the response only accepts the turn;
48
+ * completion is the `turn.ended` event. One active turn per session.
49
+ */
50
+ export class AgentServer {
51
+ private readonly transports = new Set<WireTransport>();
52
+ private readonly sessions = new Map<string, SessionLog>();
53
+ private readonly activeTurns = new Map<string, { turnId: string; harness: AgentHarness }>();
54
+ private readonly closingSessions = new Set<string>();
55
+ /** Last assigned seq of closed sessions, so a reused id continues
56
+ * monotonically. Memory: one number per closed-and-never-reused session id —
57
+ * unbounded only across a server lifetime with unbounded distinct ids, the
58
+ * same order of growth already accepted for `sessions`. */
59
+ private readonly retiredSeqs = new Map<string, number>();
60
+ private shuttingDown = false;
61
+
62
+ constructor(
63
+ private readonly runtime: AgentRuntime,
64
+ private readonly options: AgentServerOptions = {},
65
+ ) {}
66
+
67
+ /** Serve the wire on a transport; returns a detach function. */
68
+ attach(transport: WireTransport): () => void {
69
+ this.transports.add(transport);
70
+ const offLine = transport.onLine((line) => void this.handleLine(transport, line));
71
+ const detach = () => {
72
+ offLine();
73
+ this.transports.delete(transport);
74
+ };
75
+ transport.onClose(detach);
76
+ return detach;
77
+ }
78
+
79
+ /** Cancel all turns, terminate harnesses, and close every transport. */
80
+ async shutdown(): Promise<void> {
81
+ if (this.shuttingDown) return;
82
+ this.shuttingDown = true;
83
+ // Drain first so cancelled turns still broadcast their turn.ended.
84
+ await this.runtime.shutdown();
85
+ for (const transport of [...this.transports]) transport.close();
86
+ this.options.onShutdown?.();
87
+ }
88
+
89
+ private async handleLine(transport: WireTransport, line: string): Promise<void> {
90
+ if (!line.trim()) return;
91
+ const msg = decodeWireMessage(line);
92
+ if (msg.kind === "invalid") {
93
+ transport.send(encodeErrorResponse(null, WIRE_ERROR_CODES.parseError, msg.error));
94
+ return;
95
+ }
96
+ // Clients only send requests on this wire; stray notifications/responses
97
+ // are tolerated and dropped.
98
+ if (msg.kind !== "request") return;
99
+ try {
100
+ await this.handleRequest(transport, msg.id, msg.method, msg.params);
101
+ } catch (err) {
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ transport.send(encodeErrorResponse(msg.id, WIRE_ERROR_CODES.internalError, message));
104
+ }
105
+ }
106
+
107
+ private async handleRequest(
108
+ transport: WireTransport,
109
+ id: JsonRpcId,
110
+ method: string,
111
+ params: unknown,
112
+ ): Promise<void> {
113
+ switch (method) {
114
+ case WIRE_METHODS.initialize: {
115
+ const parsed = InitializeParamsSchema.safeParse(params ?? {});
116
+ if (!parsed.success) return this.invalidParams(transport, id, parsed.error);
117
+ if (parsed.data.protocolVersion !== WIRE_PROTOCOL_VERSION) {
118
+ return transport.send(
119
+ encodeErrorResponse(
120
+ id,
121
+ WIRE_ERROR_CODES.protocolVersionMismatch,
122
+ `unsupported protocol version: ${parsed.data.protocolVersion}`,
123
+ { supported: [WIRE_PROTOCOL_VERSION] },
124
+ ),
125
+ );
126
+ }
127
+ const harnesses: Record<string, AgentCapabilities> = {};
128
+ for (const harness of this.runtime.harnesses) {
129
+ harnesses[harness] = this.runtime.capabilities(harness);
130
+ }
131
+ const result: InitializeResult = {
132
+ protocolVersion: WIRE_PROTOCOL_VERSION,
133
+ server: this.options.info ?? { name: "agent-server" },
134
+ harnesses,
135
+ };
136
+ return transport.send(encodeResponse(id, result));
137
+ }
138
+
139
+ case WIRE_METHODS.turnStart: {
140
+ const parsed = TurnStartParamsSchema.safeParse(params);
141
+ if (!parsed.success) return this.invalidParams(transport, id, parsed.error);
142
+ if (this.shuttingDown) {
143
+ return transport.send(
144
+ encodeErrorResponse(id, WIRE_ERROR_CODES.shuttingDown, "server is shutting down"),
145
+ );
146
+ }
147
+ const { config, input } = parsed.data;
148
+ if (!this.runtime.harnesses.includes(config.harness)) {
149
+ return transport.send(
150
+ encodeErrorResponse(
151
+ id,
152
+ WIRE_ERROR_CODES.harnessNotAvailable,
153
+ `harness not available: ${config.harness}`,
154
+ { available: this.runtime.harnesses },
155
+ ),
156
+ );
157
+ }
158
+ const sessionId = parsed.data.sessionId ?? generateUUIDv7();
159
+ const turnId = parsed.data.turnId ?? generateUUIDv7();
160
+ if (this.closingSessions.has(sessionId)) {
161
+ return transport.send(
162
+ encodeErrorResponse(
163
+ id,
164
+ WIRE_ERROR_CODES.sessionClosing,
165
+ `session ${sessionId} is closing`,
166
+ ),
167
+ );
168
+ }
169
+ const existing = this.sessions.get(sessionId);
170
+ if (existing && existing.harness !== config.harness) {
171
+ return transport.send(
172
+ encodeErrorResponse(
173
+ id,
174
+ WIRE_ERROR_CODES.invalidParams,
175
+ `session ${sessionId} is bound to harness ${existing.harness}`,
176
+ ),
177
+ );
178
+ }
179
+ if (this.activeTurns.has(sessionId)) {
180
+ return transport.send(
181
+ encodeErrorResponse(
182
+ id,
183
+ WIRE_ERROR_CODES.turnActive,
184
+ `session ${sessionId} already has an active turn`,
185
+ { turnId: this.activeTurns.get(sessionId)?.turnId },
186
+ ),
187
+ );
188
+ }
189
+ const log =
190
+ existing ??
191
+ new SessionLog(
192
+ sessionId,
193
+ config.harness,
194
+ this.options.bufferSize ?? DEFAULT_BUFFER_SIZE,
195
+ (this.retiredSeqs.get(sessionId) ?? 0) + 1,
196
+ );
197
+ this.retiredSeqs.delete(sessionId);
198
+ this.sessions.set(sessionId, log);
199
+ this.activeTurns.set(sessionId, { turnId, harness: config.harness });
200
+ // Quick-ack before the first event can possibly be emitted.
201
+ transport.send(encodeResponse(id, { sessionId, turnId }));
202
+ void this.executeTurn({ sessionId, turnId, input, config }, log);
203
+ return;
204
+ }
205
+
206
+ case WIRE_METHODS.turnCancel: {
207
+ const parsed = TurnCancelParamsSchema.safeParse(params);
208
+ if (!parsed.success) return this.invalidParams(transport, id, parsed.error);
209
+ const active = this.activeTurns.get(parsed.data.sessionId);
210
+ if (!active) return transport.send(encodeResponse(id, { cancelled: false }));
211
+ await this.runtime.cancel(active.harness, parsed.data.sessionId);
212
+ return transport.send(encodeResponse(id, { cancelled: true }));
213
+ }
214
+
215
+ case WIRE_METHODS.sessionClose: {
216
+ const parsed = SessionCloseParamsSchema.safeParse(params);
217
+ if (!parsed.success) return this.invalidParams(transport, id, parsed.error);
218
+ const { sessionId } = parsed.data;
219
+ if (this.closingSessions.has(sessionId)) {
220
+ return transport.send(
221
+ encodeErrorResponse(
222
+ id,
223
+ WIRE_ERROR_CODES.sessionClosing,
224
+ `session ${sessionId} is closing`,
225
+ ),
226
+ );
227
+ }
228
+ const active = this.activeTurns.get(sessionId);
229
+ if (active) {
230
+ return transport.send(
231
+ encodeErrorResponse(
232
+ id,
233
+ WIRE_ERROR_CODES.turnActive,
234
+ `session ${sessionId} has an active turn`,
235
+ { turnId: active.turnId },
236
+ ),
237
+ );
238
+ }
239
+ const log = this.sessions.get(sessionId);
240
+ if (!log) return transport.send(encodeResponse(id, { closed: false }));
241
+ this.closingSessions.add(sessionId);
242
+ try {
243
+ await this.runtime.closeSession(log.harness, sessionId);
244
+ if (log.latestSeq !== null) this.retiredSeqs.set(sessionId, log.latestSeq);
245
+ if (this.sessions.get(sessionId) === log) this.sessions.delete(sessionId);
246
+ return transport.send(encodeResponse(id, { closed: true }));
247
+ } finally {
248
+ this.closingSessions.delete(sessionId);
249
+ }
250
+ }
251
+
252
+ case WIRE_METHODS.permissionRespond: {
253
+ const parsed = PermissionRespondParamsSchema.safeParse(params);
254
+ if (!parsed.success) return this.invalidParams(transport, id, parsed.error);
255
+ const accepted = this.runtime.respondPermission(
256
+ parsed.data.sessionId,
257
+ parsed.data.requestId,
258
+ parsed.data.outcome,
259
+ );
260
+ return transport.send(encodeResponse(id, { accepted }));
261
+ }
262
+
263
+ case WIRE_METHODS.eventsReplay: {
264
+ const parsed = EventsReplayParamsSchema.safeParse(params);
265
+ if (!parsed.success) return this.invalidParams(transport, id, parsed.error);
266
+ const log = this.sessions.get(parsed.data.sessionId);
267
+ if (!log) {
268
+ return transport.send(
269
+ encodeErrorResponse(
270
+ id,
271
+ WIRE_ERROR_CODES.unknownSession,
272
+ `unknown session: ${parsed.data.sessionId}`,
273
+ ),
274
+ );
275
+ }
276
+ return transport.send(
277
+ encodeResponse(id, {
278
+ events: log.replay(parsed.data.fromSeq),
279
+ firstAvailableSeq: log.firstAvailableSeq,
280
+ latestSeq: log.latestSeq,
281
+ }),
282
+ );
283
+ }
284
+
285
+ case WIRE_METHODS.shutdown: {
286
+ transport.send(encodeResponse(id, {}));
287
+ await this.shutdown();
288
+ return;
289
+ }
290
+
291
+ default:
292
+ return transport.send(
293
+ encodeErrorResponse(id, WIRE_ERROR_CODES.methodNotFound, `unknown method: ${method}`),
294
+ );
295
+ }
296
+ }
297
+
298
+ private async executeTurn(request: RunRequest, log: SessionLog): Promise<void> {
299
+ // Only release the busy mark if it still belongs to THIS turn — a newer
300
+ // turn may have been accepted between our turn.ended broadcast and run()
301
+ // settling, and its lock must survive our cleanup.
302
+ const releaseTurn = () => {
303
+ if (this.activeTurns.get(request.sessionId)?.turnId === request.turnId) {
304
+ this.activeTurns.delete(request.sessionId);
305
+ }
306
+ };
307
+ const sink = callbackSink((event) => {
308
+ // Once turn.ended is on the wire the session is idle — clear the busy
309
+ // mark here (not when run() settles) so clients acting on the event
310
+ // never race the bookkeeping.
311
+ if (event.type === "turn.ended") releaseTurn();
312
+ this.broadcast(log.append(event));
313
+ });
314
+ try {
315
+ await this.runtime.run(request, sink);
316
+ } catch (err) {
317
+ // runtime.run reports errors via events; a throw means it died before
318
+ // emitting turn.ended — synthesize one so clients are never left hanging.
319
+ const message = err instanceof Error ? err.message : String(err);
320
+ this.broadcast(
321
+ log.append({
322
+ type: "turn.ended",
323
+ sessionId: request.sessionId,
324
+ turnId: request.turnId,
325
+ stopReason: "error",
326
+ error: { name: err instanceof Error ? err.name : "Error", message },
327
+ timestamp: Date.now(),
328
+ }),
329
+ );
330
+ } finally {
331
+ releaseTurn();
332
+ }
333
+ }
334
+
335
+ private broadcast(envelope: WireEventEnvelope): void {
336
+ const line = encodeNotification(WIRE_METHODS.event, envelope);
337
+ for (const transport of this.transports) {
338
+ try {
339
+ transport.send(line);
340
+ } catch {
341
+ // One faulty transport must not starve the others; the event is
342
+ // already in the session log, so the client heals via replay.
343
+ }
344
+ }
345
+ }
346
+
347
+ private invalidParams(transport: WireTransport, id: JsonRpcId, error: ValidationError): void {
348
+ transport.send(
349
+ encodeErrorResponse(
350
+ id,
351
+ WIRE_ERROR_CODES.invalidParams,
352
+ "invalid params",
353
+ error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
354
+ ),
355
+ );
356
+ }
357
+ }
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env bun
2
+ // agent-server — run the engine as a standalone wire server.
3
+ //
4
+ // agent-server # NDJSON JSON-RPC over stdio (default)
5
+ // agent-server --listen 4747 # accept WebSocket clients on a port
6
+ // agent-server --dial ws://host/p # dial out to a consumer and reconnect
7
+ // agent-server --harness a,b # restrict registered harnesses
8
+ // agent-server --acp-agent "npx -y pi-acp" # enable the acp harness
9
+ // agent-server --acp --harness claude-code # BE an ACP agent on stdio (for Zed etc.)
10
+ // agent-server --buffer 8192 # replay buffer size per session
11
+ // agent-server install # prefetch the pinned agent CLIs
12
+ //
13
+ // All logs go to stderr; stdout is reserved for the wire in stdio mode.
14
+ import {
15
+ KNOWN_ACP_AGENTS,
16
+ type ProvisionMode,
17
+ createAgentRuntime,
18
+ resolveAcpLaunch,
19
+ } from "../core/index.ts";
20
+ import { isAgentHarness } from "../protocol/index.ts";
21
+ import { AgentServer } from "./agent-server.ts";
22
+ import { runInstall } from "./install.ts";
23
+ import { dial, listen, stdioTransport } from "./transports.ts";
24
+
25
+ const VERSION = "0.1.0";
26
+
27
+ interface Args {
28
+ command: "serve" | "install";
29
+ mode: "stdio" | "listen" | "dial" | "acp";
30
+ listen?: { hostname?: string; port: number };
31
+ dialUrl?: string;
32
+ harnesses?: string[];
33
+ acpAgent?: string;
34
+ bufferSize?: number;
35
+ provisionMode?: ProvisionMode;
36
+ }
37
+
38
+ function usage(exitCode: number): never {
39
+ console.error(
40
+ [
41
+ "usage: agent-server [install] [--listen [host:]port | --dial <ws-url>] [options]",
42
+ "",
43
+ " (default) serve NDJSON JSON-RPC on stdio",
44
+ " --acp speak ACP v1 on stdio instead (be the agent)",
45
+ " install download the pinned agent CLIs into the managed cache",
46
+ " (always downloads — run at image build time to erase",
47
+ " sandbox cold starts; --provision applies to serving only)",
48
+ " --listen [host:]port accept WebSocket clients",
49
+ " --dial <ws-url> dial out to a consumer (auto-reconnect)",
50
+ " --harness <a,b> restrict harnesses (default: all installed)",
51
+ " --provision <mode> CLI provisioning: auto | pinned | system (default auto)",
52
+ " --buffer <n> replay buffer size per session (default 4096)",
53
+ " --version, --help",
54
+ "",
55
+ "env: CLAUDE_CLI_PATH / CODEX_CLI_PATH (explicit binaries),",
56
+ " AGENT_SERVER_PROVISION, AGENT_SERVER_CACHE_DIR, AGENT_SERVER_NPM_REGISTRY",
57
+ ].join("\n"),
58
+ );
59
+ process.exit(exitCode);
60
+ }
61
+
62
+ function parseArgs(argv: string[]): Args {
63
+ const args: Args = { command: "serve", mode: "stdio" };
64
+ for (let i = 0; i < argv.length; i++) {
65
+ const arg = argv[i] as string;
66
+ const next = () => {
67
+ const v = argv[++i];
68
+ if (v === undefined) usage(1);
69
+ return v;
70
+ };
71
+ if (arg === "install" && i === 0) args.command = "install";
72
+ else if (arg === "--stdio") args.mode = "stdio";
73
+ else if (arg === "--acp") args.mode = "acp";
74
+ else if (arg === "--listen") {
75
+ args.mode = "listen";
76
+ const [a, b] = next().split(":");
77
+ args.listen = b !== undefined ? { hostname: a, port: Number(b) } : { port: Number(a) };
78
+ if (!Number.isInteger(args.listen.port)) usage(1);
79
+ } else if (arg === "--dial") {
80
+ args.mode = "dial";
81
+ args.dialUrl = next();
82
+ } else if (arg === "--harness") args.harnesses = next().split(",").filter(Boolean);
83
+ else if (arg === "--provision") {
84
+ const mode = next();
85
+ if (mode !== "auto" && mode !== "pinned" && mode !== "system") usage(1);
86
+ args.provisionMode = mode;
87
+ } else if (arg === "--buffer") {
88
+ args.bufferSize = Number(next());
89
+ if (!Number.isInteger(args.bufferSize) || args.bufferSize <= 0) usage(1);
90
+ } else if (arg === "--version") {
91
+ console.log(VERSION);
92
+ process.exit(0);
93
+ } else if (arg === "--help") usage(0);
94
+ else usage(1);
95
+ }
96
+ return args;
97
+ }
98
+
99
+ const args = parseArgs(process.argv.slice(2));
100
+
101
+ const harnesses = args.harnesses?.map((h) => {
102
+ if (isAgentHarness(h)) return h;
103
+ if (KNOWN_ACP_AGENTS[h]) {
104
+ // Friendly ACP agent names ("pi", "gemini") ride the acp harness.
105
+ args.acpAgent ??= h;
106
+ return "acp" as const;
107
+ }
108
+ console.error(`unknown harness: ${h}`);
109
+ process.exit(1);
110
+ });
111
+ const provision = args.provisionMode ? { mode: args.provisionMode } : {};
112
+ const acpLaunch = resolveAcpLaunch(args.acpAgent ?? process.env.AGENT_SERVER_ACP_AGENT);
113
+ if (harnesses?.includes("acp") && !acpLaunch) {
114
+ console.error('the acp harness needs an agent: --harness pi|gemini or --acp-agent "<spec>"');
115
+ process.exit(1);
116
+ }
117
+
118
+ if (args.command === "install") {
119
+ try {
120
+ // install always downloads to the managed cache; --provision (a serving
121
+ // concern) is deliberately not forwarded.
122
+ const results = await runInstall({ harnesses });
123
+ for (const { tool, cli } of results) {
124
+ console.error(`${tool} ${cli.version ?? ""} ready: ${cli.path} (${cli.source})`);
125
+ }
126
+ process.exit(0);
127
+ } catch (error) {
128
+ console.error(`install failed: ${error instanceof Error ? error.message : String(error)}`);
129
+ process.exit(1);
130
+ }
131
+ }
132
+
133
+ const runtime = createAgentRuntime({
134
+ ...(harnesses ? { harnesses } : {}),
135
+ ...(acpLaunch ? { acp: { resolveLaunch: () => acpLaunch } } : {}),
136
+ provision,
137
+ });
138
+
139
+ let shutdown: () => void;
140
+ if (args.mode === "acp") {
141
+ // Speak ACP v1 on stdio: this process IS the agent (Zed-spawnable).
142
+ const { serveAcpStdio } = await import("./acp/binding.ts");
143
+ const frontedHarness = harnesses?.[0] ?? "claude-code";
144
+ const connection = await serveAcpStdio({ runtime, harness: frontedHarness, version: VERSION });
145
+ console.error(`agent-server ${VERSION} speaking ACP v1 on stdio (harness: ${frontedHarness})`);
146
+ shutdown = () => void runtime.shutdown().then(() => process.exit(0));
147
+ // Client hung up — shut down cleanly (mirrors the stdio wire transport).
148
+ void connection.closed.then(() => shutdown());
149
+ } else {
150
+ const server = new AgentServer(runtime, {
151
+ bufferSize: args.bufferSize,
152
+ info: { name: "agent-server", version: VERSION },
153
+ onShutdown: () => process.exit(0),
154
+ });
155
+ shutdown = () => void server.shutdown();
156
+
157
+ if (args.mode === "listen") {
158
+ const bound = listen(server, args.listen);
159
+ console.error(`agent-server ${VERSION} listening on ${bound.url}`);
160
+ } else if (args.mode === "dial") {
161
+ dial(server, args.dialUrl as string, {
162
+ onStateChange: (state) => console.error(`agent-server dial ${args.dialUrl}: ${state}`),
163
+ });
164
+ } else {
165
+ const transport = stdioTransport();
166
+ server.attach(transport);
167
+ // Parent hung up — shut down cleanly.
168
+ transport.onClose(() => shutdown());
169
+ }
170
+ }
171
+
172
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
173
+ process.on(signal, () => shutdown());
174
+ }
@@ -0,0 +1,24 @@
1
+ // @zvada/agent-server/server — the standard wire (phase 2).
2
+ // JSON-RPC 2.0 over stdio/WebSocket: quick-ack turns, per-session sequenced
3
+ // events, bounded replay, engine-brokered permissions.
4
+
5
+ export { AgentServer, type AgentServerOptions } from "./agent-server.ts";
6
+ export {
7
+ type InstallOptions,
8
+ type InstallResult,
9
+ runInstall,
10
+ toolsFor,
11
+ } from "./install.ts";
12
+ export { DEFAULT_BUFFER_SIZE, SessionLog } from "./session-log.ts";
13
+ export {
14
+ dial,
15
+ type DialOptions,
16
+ listen,
17
+ type ListenOptions,
18
+ stdioTransport,
19
+ } from "./transports.ts";
20
+ export {
21
+ type AcpBindingOptions,
22
+ createAcpAgentApp,
23
+ serveAcpStdio,
24
+ } from "./acp/binding.ts";
@@ -0,0 +1,51 @@
1
+ import {
2
+ CLI_TOOLS,
3
+ CliProvisioner,
4
+ type CliTool,
5
+ type ProvisionOptions,
6
+ type ResolvedCli,
7
+ toolForHarness,
8
+ } from "../core/index.ts";
9
+ import type { AgentHarness } from "../protocol/index.ts";
10
+
11
+ export interface InstallOptions {
12
+ /** Restrict to the CLIs these harnesses need. Defaults to every tool. */
13
+ harnesses?: AgentHarness[];
14
+ provision?: ProvisionOptions;
15
+ /** Injectable provisioner boundary for tests. */
16
+ createProvisioner?: (options: ProvisionOptions) => Pick<CliProvisioner, "install">;
17
+ }
18
+
19
+ export interface InstallResult {
20
+ tool: CliTool;
21
+ cli: ResolvedCli;
22
+ }
23
+
24
+ /** Which managed CLIs a set of harnesses needs (deduped, stable order). */
25
+ export function toolsFor(harnesses: AgentHarness[] | undefined): CliTool[] {
26
+ if (!harnesses) return [...CLI_TOOLS];
27
+ // Unmanaged harnesses (acp) resolve to undefined — nothing to prefetch.
28
+ const needed = new Set(
29
+ harnesses.map(toolForHarness).filter((t): t is CliTool => t !== undefined),
30
+ );
31
+ return CLI_TOOLS.filter((tool) => needed.has(tool));
32
+ }
33
+
34
+ /**
35
+ * Prefetch the pinned CLI builds into the managed cache — the cold-start
36
+ * eraser: run it while building a container/sandbox image and runtime turns
37
+ * never download. Downloads run in parallel; failures reject with the first
38
+ * error after all settle (so one flaky download doesn't hide the others).
39
+ */
40
+ export async function runInstall(options: InstallOptions = {}): Promise<InstallResult[]> {
41
+ const provision = options.provision ?? {};
42
+ const provisioner = options.createProvisioner?.(provision) ?? new CliProvisioner(provision);
43
+ const tools = toolsFor(options.harnesses);
44
+ const settled = await Promise.allSettled(tools.map((tool) => provisioner.install(tool)));
45
+ const failure = settled.find((result) => result.status === "rejected");
46
+ if (failure) throw failure.reason;
47
+ return settled.map((result, i) => ({
48
+ tool: tools[i] as CliTool,
49
+ cli: (result as PromiseFulfilledResult<ResolvedCli>).value,
50
+ }));
51
+ }
@@ -0,0 +1,66 @@
1
+ import type { AgentHarness, LifecycleEvent, WireEventEnvelope } from "../protocol/index.ts";
2
+
3
+ export const DEFAULT_BUFFER_SIZE = 4096;
4
+
5
+ /**
6
+ * Per-session monotonic sequence + bounded replay buffer (DESIGN D7). Every
7
+ * outbound event is stamped with `seq` (starting at 1) and kept in a ring
8
+ * buffer so a reconnecting client can `events/replay({fromSeq})`. Eviction is
9
+ * visible to clients via `firstAvailableSeq`.
10
+ *
11
+ * Eviction advances a head index (O(1) amortized) instead of shifting the
12
+ * array; the dead prefix is compacted away once it reaches one full capacity,
13
+ * bounding memory at ~2x `maxBuffer` envelopes.
14
+ */
15
+ export class SessionLog {
16
+ private buffer: WireEventEnvelope[] = [];
17
+ private head = 0;
18
+ private nextSeq: number;
19
+ private readonly maxBuffer: number;
20
+
21
+ constructor(
22
+ readonly sessionId: string,
23
+ /** The harness this session is bound to (first turn wins). */
24
+ readonly harness: AgentHarness,
25
+ maxBuffer = DEFAULT_BUFFER_SIZE,
26
+ /** First seq to assign — a session reused after `session/close` continues
27
+ * where its previous incarnation stopped, so peers never see seq go
28
+ * backwards (a backwards seq reads as a duplicate and masks event loss). */
29
+ startSeq = 1,
30
+ ) {
31
+ // A NaN or non-positive cap would silently disable eviction and grow
32
+ // the buffer without bound — normalize rather than trust the caller.
33
+ this.maxBuffer = Number.isInteger(maxBuffer) && maxBuffer > 0 ? maxBuffer : DEFAULT_BUFFER_SIZE;
34
+ this.nextSeq = Number.isInteger(startSeq) && startSeq > 0 ? startSeq : 1;
35
+ }
36
+
37
+ append(event: LifecycleEvent): WireEventEnvelope {
38
+ const envelope: WireEventEnvelope = { sessionId: this.sessionId, seq: this.nextSeq++, event };
39
+ this.buffer.push(envelope);
40
+ if (this.buffer.length - this.head > this.maxBuffer) {
41
+ this.head += 1;
42
+ if (this.head >= this.maxBuffer) {
43
+ this.buffer = this.buffer.slice(this.head);
44
+ this.head = 0;
45
+ }
46
+ }
47
+ return envelope;
48
+ }
49
+
50
+ /** Buffered envelopes with `seq >= fromSeq` (seq is contiguous — index math). */
51
+ replay(fromSeq: number): WireEventEnvelope[] {
52
+ const first = this.firstAvailableSeq;
53
+ if (first === null) return [];
54
+ return this.buffer.slice(this.head + Math.max(0, fromSeq - first));
55
+ }
56
+
57
+ /** Oldest seq still buffered, or null when nothing has been emitted. */
58
+ get firstAvailableSeq(): number | null {
59
+ return this.buffer[this.head]?.seq ?? null;
60
+ }
61
+
62
+ /** Newest seq assigned, or null when nothing has been emitted. */
63
+ get latestSeq(): number | null {
64
+ return this.nextSeq > 1 ? this.nextSeq - 1 : null;
65
+ }
66
+ }