@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,363 @@
1
+ import type {
2
+ AgentCapabilities,
3
+ AgentHarness,
4
+ LifecycleEvent,
5
+ PermissionOption,
6
+ PermissionOutcome,
7
+ PermissionToolCall,
8
+ RunRequest,
9
+ StopReason,
10
+ TokenUsage,
11
+ } from "../../protocol/index.ts";
12
+ import { DEFAULT_TOKEN_USAGE, generateUUIDv7 } from "../../protocol/index.ts";
13
+ import type { AgentExecuteOptions, PermissionDecision } from "../agents/base.ts";
14
+ import {
15
+ type ErrorCategory,
16
+ classifyError,
17
+ isCancellation,
18
+ isRecoverable,
19
+ } from "../agents/error-classifier.ts";
20
+ import type { AgentRegistry } from "../agents/registry.ts";
21
+ import { EventProcessor } from "./event-processor.ts";
22
+ import type { EventSink } from "./event-sink.ts";
23
+
24
+ /** What a completed turn reports back to the caller (in addition to the streamed events). */
25
+ export interface RunSummary {
26
+ sessionId: string;
27
+ turnId: string;
28
+ harness: AgentHarness;
29
+ /** Native session/thread id to persist for a future resume. */
30
+ nativeSessionId?: string;
31
+ /** When the turn requested a resume: whether the harness honored it. */
32
+ resumed?: boolean;
33
+ usage: TokenUsage;
34
+ /** Normalized terminal status (same value as the `turn.ended` event). */
35
+ stopReason: StopReason;
36
+ finishReason?: string;
37
+ cost?: number;
38
+ error?: { category: ErrorCategory; message: string };
39
+ cancelled: boolean;
40
+ }
41
+
42
+ /** The standard options offered for every brokered permission request. */
43
+ const PERMISSION_OPTIONS: PermissionOption[] = [
44
+ { optionId: "allow", name: "Allow", kind: "allow_once" },
45
+ { optionId: "reject", name: "Reject", kind: "reject_once" },
46
+ ];
47
+
48
+ interface PendingPermission {
49
+ sessionId: string;
50
+ /** Resolves the request and emits `permission.resolved`; await the returned
51
+ * promise to order that emit (the turn-end drain does, so it lands before
52
+ * `turn.ended`). */
53
+ settle: (outcome: PermissionOutcome) => void | Promise<void>;
54
+ }
55
+
56
+ /**
57
+ * The engine. Owns no transport and no harness specifics: it routes a
58
+ * RunRequest to the right harness + adapter, drives the per-turn loop, brokers
59
+ * permission round-trips, and streams normalized LifecycleEvents to a sink.
60
+ */
61
+ export class AgentRuntime {
62
+ /** Permission requests awaiting `respondPermission`, across all live turns. */
63
+ private readonly pendingPermissions = new Map<string, PendingPermission>();
64
+ private readonly activeRuns = new Set<Promise<RunSummary>>();
65
+ private shutdownPromise?: Promise<void>;
66
+
67
+ constructor(private readonly registry: AgentRegistry) {}
68
+
69
+ get harnesses(): AgentHarness[] {
70
+ return this.registry.list();
71
+ }
72
+
73
+ capabilities(harness: AgentHarness): AgentCapabilities {
74
+ return this.registry.getAgent(harness).capabilities;
75
+ }
76
+
77
+ /**
78
+ * Answer a pending `permission.requested` event. Returns false when the
79
+ * request is unknown (already resolved, cancelled, or a sessionId mismatch).
80
+ */
81
+ respondPermission(sessionId: string, requestId: string, outcome: PermissionOutcome): boolean {
82
+ const pending = this.pendingPermissions.get(requestId);
83
+ if (!pending || pending.sessionId !== sessionId) return false;
84
+ void pending.settle(outcome);
85
+ return true;
86
+ }
87
+
88
+ run(
89
+ request: RunRequest,
90
+ sink: EventSink,
91
+ opts: { signal?: AbortSignal } = {},
92
+ ): Promise<RunSummary> {
93
+ if (this.shutdownPromise) return Promise.reject(new Error("agent runtime is shutting down"));
94
+ const running = this.executeRun(request, sink, opts);
95
+ this.activeRuns.add(running);
96
+ running.then(
97
+ () => this.activeRuns.delete(running),
98
+ () => this.activeRuns.delete(running),
99
+ );
100
+ return running;
101
+ }
102
+
103
+ private async executeRun(
104
+ request: RunRequest,
105
+ sink: EventSink,
106
+ opts: { signal?: AbortSignal },
107
+ ): Promise<RunSummary> {
108
+ const { sessionId, turnId, input, config } = request;
109
+ const agent = this.registry.getAgent(config.harness);
110
+ const transformer = this.registry.getAdapter(config.harness)({ sessionId });
111
+ const processor = new EventProcessor(sessionId, turnId, {
112
+ harness: config.harness,
113
+ model: config.model,
114
+ });
115
+
116
+ // Isolate sink failures: a misbehaving transport must never break the
117
+ // turn's lifecycle bracketing (turn.started ... turn.ended).
118
+ const emit = async (e: LifecycleEvent) => {
119
+ try {
120
+ await sink.emit(e);
121
+ } catch {
122
+ // swallow — the sink owns its own reliability
123
+ }
124
+ };
125
+
126
+ let nativeSessionId: string | undefined;
127
+ let resumed: boolean | undefined;
128
+ let sessionEmitted = false;
129
+ const flushSession = async () => {
130
+ if (nativeSessionId && !sessionEmitted) {
131
+ sessionEmitted = true;
132
+ await emit({
133
+ type: "session.created",
134
+ sessionId,
135
+ nativeSessionId,
136
+ harness: config.harness,
137
+ model: config.model,
138
+ ...(resumed !== undefined && { resumed }),
139
+ timestamp: Date.now(),
140
+ });
141
+ }
142
+ };
143
+
144
+ // --- permission broker (one scope per turn) ----------------------------
145
+ const turnRequestIds = new Set<string>();
146
+ const requestPermission = async (
147
+ toolCall: PermissionToolCall,
148
+ permOpts?: { signal?: AbortSignal },
149
+ ): Promise<PermissionDecision> => {
150
+ if (permOpts?.signal?.aborted) return { decision: "cancel" };
151
+ const requestId = generateUUIDv7();
152
+ // Register BEFORE emitting so even a sink that answers synchronously
153
+ // from its emit() finds the pending entry.
154
+ let resolveOutcome!: (o: PermissionOutcome) => void;
155
+ const outcomePromise = new Promise<PermissionOutcome>((resolve) => {
156
+ resolveOutcome = resolve;
157
+ });
158
+ const settle = (o: PermissionOutcome): Promise<void> | void => {
159
+ if (!this.pendingPermissions.delete(requestId)) return;
160
+ turnRequestIds.delete(requestId);
161
+ resolveOutcome(o);
162
+ // Returned so the in-run drain can sequence this before turn.ended;
163
+ // external callers (respondPermission/cancel) ignore it.
164
+ return emit({
165
+ type: "permission.resolved",
166
+ sessionId,
167
+ turnId,
168
+ requestId,
169
+ outcome: o,
170
+ timestamp: Date.now(),
171
+ });
172
+ };
173
+ this.pendingPermissions.set(requestId, { sessionId, settle });
174
+ turnRequestIds.add(requestId);
175
+ permOpts?.signal?.addEventListener("abort", () => void settle({ outcome: "cancelled" }), {
176
+ once: true,
177
+ });
178
+ await emit({
179
+ type: "permission.requested",
180
+ sessionId,
181
+ turnId,
182
+ requestId,
183
+ title: toolCall.title ?? `Allow tool: ${toolCall.toolName}`,
184
+ toolCall,
185
+ options: PERMISSION_OPTIONS,
186
+ timestamp: Date.now(),
187
+ });
188
+ const outcome = await outcomePromise;
189
+ if (outcome.outcome === "cancelled") return { decision: "cancel" };
190
+ const selected = PERMISSION_OPTIONS.find((o) => o.optionId === outcome.optionId);
191
+ return selected?.kind === "allow_once" || selected?.kind === "allow_always"
192
+ ? { decision: "allow" }
193
+ : { decision: "deny", reason: "Denied by user" };
194
+ };
195
+ /** Cancellation / turn end resolves everything still pending as cancelled.
196
+ * Awaited so every `permission.resolved` is emitted before `turn.ended`. */
197
+ const drainPermissions = async () => {
198
+ const settles = [...turnRequestIds].map((requestId) =>
199
+ this.pendingPermissions.get(requestId)?.settle({ outcome: "cancelled" }),
200
+ );
201
+ await Promise.all(settles);
202
+ };
203
+
204
+ await emit({ type: "turn.started", turnId, sessionId, timestamp: Date.now() });
205
+
206
+ const options: AgentExecuteOptions = {
207
+ sessionId,
208
+ turnId,
209
+ cwd: config.cwd,
210
+ model: config.model,
211
+ thinkingLevel: config.thinkingLevel,
212
+ permissionMode: config.permissionMode,
213
+ maxTurns: config.maxTurns,
214
+ systemPromptAppend: config.systemPromptAppend,
215
+ resumeSessionId: config.resumeSessionId,
216
+ resumeSessionAt: config.resumeSessionAt,
217
+ additionalDirectories: config.additionalDirectories,
218
+ mcpServers: config.mcpServers,
219
+ env: config.env,
220
+ apiKey: config.apiKey,
221
+ disableTools: config.disableTools,
222
+ signal: opts.signal,
223
+ onNativeSession: (id, info) => {
224
+ // Agents report exactly once per turn (the claude fallback defers its
225
+ // report until the surviving session is known); guard against a
226
+ // misbehaving harness re-reporting after emission. Agents always pass
227
+ // their honest `resumed` judgment — the runtime surfaces it only when
228
+ // this turn actually REQUESTED a resume.
229
+ if (sessionEmitted) return;
230
+ nativeSessionId = id;
231
+ if (config.resumeSessionId && info?.resumed !== undefined) resumed = info.resumed;
232
+ },
233
+ onPermissionRequest: requestPermission,
234
+ };
235
+
236
+ let cancelled = false;
237
+ let errored: { category: ErrorCategory; message: string } | undefined;
238
+
239
+ try {
240
+ for await (const raw of agent.execute(input, options)) {
241
+ await flushSession();
242
+ if (config.includeRaw) {
243
+ await emit({
244
+ type: "raw",
245
+ sessionId,
246
+ turnId,
247
+ harness: config.harness,
248
+ data: raw,
249
+ timestamp: Date.now(),
250
+ });
251
+ }
252
+ for (const ae of transformer.process(raw)) {
253
+ for (const le of processor.handle(ae)) await emit(le);
254
+ }
255
+ }
256
+ await flushSession();
257
+ } catch (err) {
258
+ cancelled = isCancellation(err);
259
+ if (!cancelled) {
260
+ const category = classifyError(err);
261
+ const message = err instanceof Error ? err.message : String(err);
262
+ errored = { category, message };
263
+ await emit({
264
+ type: "error",
265
+ turnId,
266
+ sessionId,
267
+ error: message,
268
+ recoverable: isRecoverable(category),
269
+ code: category,
270
+ stack: err instanceof Error ? err.stack : undefined,
271
+ timestamp: Date.now(),
272
+ });
273
+ }
274
+ } finally {
275
+ await drainPermissions();
276
+ }
277
+
278
+ const result = transformer.finish();
279
+ cancelled = cancelled || Boolean(result.cancelled);
280
+ if (result.error && !errored && !cancelled) {
281
+ errored = { category: classifyError(new Error(result.error)), message: result.error };
282
+ }
283
+ const stopReason: StopReason = cancelled
284
+ ? "cancelled"
285
+ : errored
286
+ ? "error"
287
+ : (result.stopReason ?? "end_turn");
288
+ const terminalResult =
289
+ errored && !result.error ? { ...result, error: errored.message } : result;
290
+ for (const le of processor.finish(terminalResult, stopReason)) await emit(le);
291
+
292
+ return {
293
+ sessionId,
294
+ turnId,
295
+ harness: config.harness,
296
+ nativeSessionId,
297
+ ...(resumed !== undefined && { resumed }),
298
+ usage: result.usage ?? DEFAULT_TOKEN_USAGE,
299
+ stopReason,
300
+ finishReason: result.finishReason,
301
+ cost: result.cost,
302
+ error: errored,
303
+ cancelled,
304
+ };
305
+ }
306
+
307
+ async cancel(harness: AgentHarness, sessionId: string): Promise<void> {
308
+ // Unblock any harness parked on an approval before (and regardless of)
309
+ // the agent-level abort. `settle` removes the entry from the map itself.
310
+ for (const pending of [...this.pendingPermissions.values()]) {
311
+ if (pending.sessionId === sessionId) void pending.settle({ outcome: "cancelled" });
312
+ }
313
+ await this.registry.getAgent(harness).cancel(sessionId);
314
+ }
315
+
316
+ /** Dispose one idle logical session and its harness-native resources. */
317
+ async closeSession(harness: AgentHarness, sessionId: string): Promise<void> {
318
+ await Promise.all(
319
+ [...this.pendingPermissions.values()]
320
+ .filter((pending) => pending.sessionId === sessionId)
321
+ .map((pending) => pending.settle({ outcome: "cancelled" })),
322
+ );
323
+ const agent = this.registry.getAgent(harness);
324
+ if (agent.release) await agent.release(sessionId);
325
+ else await agent.cancel(sessionId);
326
+ }
327
+
328
+ /** Give up on stragglers after this long; the process is exiting anyway. */
329
+ private static readonly DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
330
+
331
+ shutdown(opts: { drainTimeoutMs?: number } = {}): Promise<void> {
332
+ // Memoized: the first caller's timeout wins.
333
+ this.shutdownPromise ??= this.performShutdown(
334
+ opts.drainTimeoutMs ?? AgentRuntime.DEFAULT_DRAIN_TIMEOUT_MS,
335
+ );
336
+ return this.shutdownPromise;
337
+ }
338
+
339
+ private async performShutdown(drainTimeoutMs: number): Promise<void> {
340
+ await Promise.all(
341
+ [...this.pendingPermissions.values()].map((pending) =>
342
+ pending.settle({ outcome: "cancelled" }),
343
+ ),
344
+ );
345
+ this.pendingPermissions.clear();
346
+ let terminationError: unknown;
347
+ try {
348
+ await this.registry.terminateAll();
349
+ } catch (error) {
350
+ terminationError = error;
351
+ }
352
+ // Drain active runs, but don't let a harness that ignores its abort
353
+ // signal hang the process's shutdown forever.
354
+ await Promise.race([
355
+ Promise.allSettled([...this.activeRuns]),
356
+ new Promise<void>((resolve) => {
357
+ const timer = setTimeout(resolve, drainTimeoutMs);
358
+ timer.unref?.();
359
+ }),
360
+ ]);
361
+ if (terminationError !== undefined) throw terminationError;
362
+ }
363
+ }
@@ -0,0 +1,218 @@
1
+ import { generateUUIDv7 } from "../../protocol/index.ts";
2
+ import type { Delta, LifecycleEvent, Part, StopReason } from "../../protocol/index.ts";
3
+ import type { AdapterEvent, TransformResult } from "../agents/types.ts";
4
+
5
+ /** Where a part lives on the wire — fixed at first emission for the whole turn. */
6
+ interface PartAddress {
7
+ messageId: string;
8
+ outputIndex: number;
9
+ partIndex: number;
10
+ }
11
+
12
+ /**
13
+ * Turns the harness-agnostic AdapterEvent stream into wire LifecycleEvents.
14
+ * Owns all id/index bookkeeping: assigns message ids, per-message output
15
+ * indices, and per-part indices, and brackets messages with started/ended.
16
+ *
17
+ * Parts are registered turn-wide: a part keeps the message/indices of its
18
+ * first emission even when updated after that message ended (e.g. a tool
19
+ * completing after the model message that issued it) — consumers upsert by
20
+ * `part.id`. One instance per turn.
21
+ */
22
+ export class EventProcessor {
23
+ private openMessageId?: string;
24
+ /** `parentToolUseId` of the open message (undefined for top-level messages). */
25
+ private openMessageParent?: string;
26
+ private currentOutputIndex = -1;
27
+ private nextOutputIndex = 0;
28
+ private nextPartIndex = 0;
29
+ private readonly partAddressById = new Map<string, PartAddress>();
30
+
31
+ constructor(
32
+ private readonly sessionId: string,
33
+ private readonly turnId: string,
34
+ private readonly meta: { harness?: string; model?: string } = {},
35
+ ) {}
36
+
37
+ *handle(ev: AdapterEvent): Generator<LifecycleEvent> {
38
+ switch (ev.kind) {
39
+ case "message-start":
40
+ yield* this.openMessage(ev.role);
41
+ return;
42
+ case "message-end":
43
+ yield* this.closeMessage();
44
+ return;
45
+ case "part-open":
46
+ case "part-update":
47
+ yield* this.emitPart(ev.part);
48
+ return;
49
+ case "text-delta": {
50
+ const e = this.delta(ev.partId, { type: "text-delta", text: ev.text });
51
+ if (e) yield e;
52
+ return;
53
+ }
54
+ case "reasoning-delta": {
55
+ const e = this.delta(ev.partId, { type: "reasoning-delta", text: ev.text });
56
+ if (e) yield e;
57
+ return;
58
+ }
59
+ case "tool-input-delta": {
60
+ const e = this.delta(ev.partId, {
61
+ type: "tool-input-delta",
62
+ toolCallId: ev.toolCallId,
63
+ toolName: ev.toolName,
64
+ input: ev.input,
65
+ });
66
+ if (e) yield e;
67
+ return;
68
+ }
69
+ case "usage":
70
+ yield {
71
+ type: "session.usage",
72
+ sessionId: this.sessionId,
73
+ turnId: this.turnId,
74
+ used: ev.used,
75
+ ...(ev.size !== undefined && { size: ev.size }),
76
+ ...(ev.cost !== undefined && { cost: ev.cost }),
77
+ timestamp: Date.now(),
78
+ };
79
+ return;
80
+ case "compacted":
81
+ yield {
82
+ type: "session.compacted",
83
+ sessionId: this.sessionId,
84
+ turnId: this.turnId,
85
+ ...(ev.trigger && { trigger: ev.trigger }),
86
+ ...(ev.preTokens !== undefined && { preTokens: ev.preTokens }),
87
+ ...(ev.postTokens !== undefined && { postTokens: ev.postTokens }),
88
+ timestamp: Date.now(),
89
+ };
90
+ return;
91
+ }
92
+ }
93
+
94
+ *finish(result: TransformResult, stopReason: StopReason): Generator<LifecycleEvent> {
95
+ yield* this.closeMessage();
96
+ yield {
97
+ type: "turn.ended",
98
+ turnId: this.turnId,
99
+ sessionId: this.sessionId,
100
+ stopReason,
101
+ finishReason: result.finishReason,
102
+ tokens: result.usage,
103
+ cost: result.cost,
104
+ error:
105
+ result.error && !result.cancelled
106
+ ? { name: "AgentError", message: result.error }
107
+ : undefined,
108
+ timestamp: Date.now(),
109
+ };
110
+ }
111
+
112
+ private *openMessage(
113
+ role: "assistant" | "user",
114
+ parentToolUseId?: string,
115
+ ): Generator<LifecycleEvent> {
116
+ if (this.openMessageId) yield* this.closeMessage();
117
+ const messageId = generateUUIDv7();
118
+ this.openMessageId = messageId;
119
+ this.openMessageParent = parentToolUseId;
120
+ this.currentOutputIndex = this.nextOutputIndex++;
121
+ this.nextPartIndex = 0;
122
+ yield {
123
+ type: "message.started",
124
+ turnId: this.turnId,
125
+ messageId,
126
+ outputIndex: this.currentOutputIndex,
127
+ role,
128
+ // A parented message is a sub-agent's output — nests under its tool call,
129
+ // not a top-level model message (see DESIGN.md D5).
130
+ ...(parentToolUseId && { parentToolUseId }),
131
+ timestamp: Date.now(),
132
+ metadata: {
133
+ sessionId: this.sessionId,
134
+ ...(this.meta.harness && { harness: this.meta.harness }),
135
+ ...(this.meta.model && { model: this.meta.model }),
136
+ },
137
+ };
138
+ }
139
+
140
+ private *closeMessage(): Generator<LifecycleEvent> {
141
+ if (!this.openMessageId) return;
142
+ yield {
143
+ type: "message.ended",
144
+ turnId: this.turnId,
145
+ messageId: this.openMessageId,
146
+ timestamp: Date.now(),
147
+ };
148
+ this.openMessageId = undefined;
149
+ this.openMessageParent = undefined;
150
+ }
151
+
152
+ /** Ensure an open message whose parent matches the incoming part. A part
153
+ * whose `parentToolUseId` differs from the open message (main↔sub-agent, or
154
+ * between sibling sub-agents) starts a new message so sub-agent output is
155
+ * grouped under its own parented message rather than mixed into another. */
156
+ private *ensureMessage(parentToolUseId?: string): Generator<LifecycleEvent> {
157
+ if (this.openMessageId && this.openMessageParent !== parentToolUseId) {
158
+ yield* this.closeMessage();
159
+ }
160
+ if (!this.openMessageId) yield* this.openMessage("assistant", parentToolUseId);
161
+ }
162
+
163
+ /** Resolve (or assign) the wire address of a part. Yields a message.started
164
+ * first when the part is new and no matching message is open. */
165
+ private *addressFor(
166
+ partId: string,
167
+ parentToolUseId?: string,
168
+ ): Generator<LifecycleEvent, PartAddress> {
169
+ const existing = this.partAddressById.get(partId);
170
+ if (existing) return existing;
171
+ yield* this.ensureMessage(parentToolUseId);
172
+ const address: PartAddress = {
173
+ messageId: this.openMessageId as string,
174
+ outputIndex: this.currentOutputIndex,
175
+ partIndex: this.nextPartIndex++,
176
+ };
177
+ this.partAddressById.set(partId, address);
178
+ return address;
179
+ }
180
+
181
+ private *emitPart(part: Part): Generator<LifecycleEvent> {
182
+ const address = yield* this.addressFor(part.id, part.parentToolUseId);
183
+ // Snapshot: adapters mutate their part objects in place across
184
+ // open/update, so we must clone at emit time or buffered consumers would
185
+ // all observe the final state. Stamp ownership so the part is
186
+ // self-describing on the wire.
187
+ const snapshot = structuredClone(part);
188
+ snapshot.sessionId = this.sessionId;
189
+ snapshot.messageId = address.messageId;
190
+ yield {
191
+ type: "message.part",
192
+ turnId: this.turnId,
193
+ messageId: address.messageId,
194
+ outputIndex: address.outputIndex,
195
+ partIndex: address.partIndex,
196
+ part: snapshot,
197
+ ...(snapshot.parentToolUseId && { parentToolUseId: snapshot.parentToolUseId }),
198
+ timestamp: Date.now(),
199
+ };
200
+ }
201
+
202
+ private delta(partId: string, delta: Delta): LifecycleEvent | null {
203
+ // Deltas only ever follow a part-open, so an unknown part id means a
204
+ // misbehaving adapter — drop rather than fabricate an address.
205
+ const address = this.partAddressById.get(partId);
206
+ if (!address) return null;
207
+ return {
208
+ type: "message.part.delta",
209
+ turnId: this.turnId,
210
+ messageId: address.messageId,
211
+ outputIndex: address.outputIndex,
212
+ partIndex: address.partIndex,
213
+ partId,
214
+ delta,
215
+ timestamp: Date.now(),
216
+ };
217
+ }
218
+ }
@@ -0,0 +1,37 @@
1
+ import type { LifecycleEvent } from "../../protocol/index.ts";
2
+
3
+ /**
4
+ * The single output port of the engine. A consumer implements `emit` to forward
5
+ * normalized events wherever they need to go (WebSocket, SSE, stdout, a buffer).
6
+ * The engine never assumes a transport.
7
+ */
8
+ export interface EventSink {
9
+ emit(event: LifecycleEvent): void | Promise<void>;
10
+ }
11
+
12
+ /** Build a sink from a plain callback. */
13
+ export function callbackSink(fn: (event: LifecycleEvent) => void | Promise<void>): EventSink {
14
+ return { emit: fn };
15
+ }
16
+
17
+ /** A sink that accumulates events in memory — handy for tests and batch runs. */
18
+ export class CollectingSink implements EventSink {
19
+ readonly events: LifecycleEvent[] = [];
20
+
21
+ emit(event: LifecycleEvent): void {
22
+ this.events.push(event);
23
+ }
24
+
25
+ byType<T extends LifecycleEvent["type"]>(type: T): Extract<LifecycleEvent, { type: T }>[] {
26
+ return this.events.filter((e): e is Extract<LifecycleEvent, { type: T }> => e.type === type);
27
+ }
28
+ }
29
+
30
+ /** Fan out to several sinks at once. */
31
+ export function teeSink(...sinks: EventSink[]): EventSink {
32
+ return {
33
+ async emit(event) {
34
+ for (const sink of sinks) await sink.emit(event);
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,41 @@
1
+ /** Base class for all errors thrown by the engine. */
2
+ export class AgentServerError extends Error {
3
+ constructor(
4
+ message: string,
5
+ readonly code: string,
6
+ ) {
7
+ super(message);
8
+ this.name = "AgentServerError";
9
+ }
10
+ }
11
+
12
+ export class HarnessNotFoundError extends AgentServerError {
13
+ constructor(harness: string) {
14
+ super(`No agent registered for harness: ${harness}`, "HARNESS_NOT_FOUND");
15
+ this.name = "HarnessNotFoundError";
16
+ }
17
+ }
18
+
19
+ export class AgentExecutionError extends AgentServerError {
20
+ constructor(message: string, options?: { cause?: unknown }) {
21
+ super(message, "AGENT_EXECUTION_ERROR");
22
+ this.name = "AgentExecutionError";
23
+ if (options?.cause !== undefined) this.cause = options.cause;
24
+ }
25
+ }
26
+
27
+ export class CliNotFoundError extends AgentServerError {
28
+ constructor(name: string, hint?: string) {
29
+ super(`Required CLI not found on PATH: ${name}${hint ? ` — ${hint}` : ""}`, "CLI_NOT_FOUND");
30
+ this.name = "CliNotFoundError";
31
+ }
32
+ }
33
+
34
+ /** Managed CLI provisioning failed (download, integrity, or a bad override). */
35
+ export class CliProvisionError extends AgentServerError {
36
+ constructor(message: string, options?: { cause?: unknown }) {
37
+ super(message, "CLI_PROVISION_FAILED");
38
+ this.name = "CliProvisionError";
39
+ if (options?.cause !== undefined) this.cause = options.cause;
40
+ }
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // @zvada/agent-server — the root export is the wire contract (zod-only, safe
2
+ // in any runtime). The seats live behind subpaths: ./core embeds the engine,
3
+ // ./server serves the wire, ./client talks to one.
4
+ export * from "./protocol/index.ts";