@zvada/agent-server 0.2.2 → 0.3.1

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 (51) hide show
  1. package/CHANGELOG.md +317 -0
  2. package/README.md +20 -4
  3. package/docs/consuming.md +269 -0
  4. package/docs/deploy.md +80 -0
  5. package/docs/harnesses.md +64 -0
  6. package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
  7. package/package.json +23 -3
  8. package/src/client/client.ts +143 -50
  9. package/src/core/agents/acp/acp-agent.ts +9 -0
  10. package/src/core/agents/acp/mappings.ts +3 -3
  11. package/src/core/agents/base.ts +9 -1
  12. package/src/core/agents/claude-code/adapter.ts +116 -26
  13. package/src/core/agents/claude-code/claude-agent.ts +31 -3
  14. package/src/core/agents/claude-code/generator-session.ts +16 -5
  15. package/src/core/agents/claude-code/options.ts +13 -3
  16. package/src/core/agents/claude-code/session-manager.ts +9 -4
  17. package/src/core/agents/codex-app-server/codex-app-server-agent.ts +17 -3
  18. package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
  19. package/src/core/agents/types.ts +1 -1
  20. package/src/core/diagnostics.ts +59 -0
  21. package/src/core/index.ts +3 -1
  22. package/src/core/presets.ts +15 -2
  23. package/src/core/provision/pins.ts +5 -1
  24. package/src/core/proxy/anthropic-proxy.ts +43 -2
  25. package/src/core/proxy/api-key-store.ts +37 -5
  26. package/src/core/proxy/index.ts +8 -1
  27. package/src/core/runtime/agent-runtime.ts +66 -18
  28. package/src/core/runtime/event-processor.ts +51 -26
  29. package/src/protocol/config.ts +8 -6
  30. package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
  31. package/src/protocol/factories.ts +106 -10
  32. package/src/protocol/guards.ts +53 -0
  33. package/src/protocol/index.ts +10 -0
  34. package/src/protocol/lifecycle.ts +289 -112
  35. package/src/protocol/meta.ts +14 -0
  36. package/src/protocol/part-input.ts +56 -7
  37. package/src/protocol/parts.ts +125 -10
  38. package/src/protocol/reduce.ts +749 -0
  39. package/src/protocol/selectors.ts +162 -0
  40. package/src/protocol/seq-cursor.ts +87 -0
  41. package/src/protocol/stop-reasons.ts +45 -0
  42. package/src/protocol/time.ts +23 -0
  43. package/src/protocol/tokens.ts +23 -0
  44. package/src/protocol/tool-state.ts +85 -25
  45. package/src/protocol/verify.ts +440 -0
  46. package/src/protocol/vocabulary.ts +18 -0
  47. package/src/protocol/wire.ts +81 -13
  48. package/src/server/acp/binding.ts +23 -2
  49. package/src/server/acp/translate.ts +51 -14
  50. package/src/server/agent-server.ts +85 -6
  51. package/AGENTS.md +0 -21
package/CHANGELOG.md ADDED
@@ -0,0 +1,317 @@
1
+ # Changelog
2
+
3
+ ## 0.3.0
4
+
5
+ **Breaking.** `WIRE_PROTOCOL_VERSION` 1 → 2. The protocol batch that folds the
6
+ nine-harness survey into one canonical layer: one spelling per concept, ACP's
7
+ vocabulary wherever the concepts overlap, `_meta` everywhere, unknowns
8
+ preserved instead of dropped, and a casing test that fails the build on drift.
9
+ There is no compatibility shim — decode the rename table below.
10
+
11
+ Also in this batch:
12
+
13
+ - `verifyStreamContract` (exported from the package root): machine-checkable
14
+ stream-contract verification — ordering rules plus projection agreement
15
+ (the reducer's final state must match the authoritative snapshots). The
16
+ CLI grows `run --verify` (exit 1 on violation); every live harness/wire
17
+ test is contract-checked.
18
+ - Honest cancels in the pre-first-yield window: a `turn/cancel` that lands
19
+ between the `turn/start` quick-ack and the harness registering its
20
+ abortable turn now answers `{outcome: "unconfirmed"}` (the abort landed on
21
+ nothing and the turn may run to completion) instead of a clean `cancelled`.
22
+ - `session/close` broadcasts `session.ended {reason: "released"}` on the
23
+ stream (consuming one seq) — subscribers learn the terminal fact from the
24
+ stream itself, not from the closer's RPC result.
25
+ - Reasoning parts carry text again on Fable-class models: the claude harness
26
+ opts into thinking summaries (`--thinking-display summarized` via
27
+ `extraArgs`; operator `extraArgs` still wins per-key), and the adapter keeps
28
+ the `estimated_tokens` placeholder as
29
+ `providerMetadata.estimatedThinkingTokens` when text is withheld. The
30
+ claude pin holds at 0.3.220 — CLI 2.1.233 ignores the display flag and
31
+ streams token-estimate placeholders instead; bump only after re-verifying
32
+ summaries flow.
33
+
34
+ ### The rename table (old → new)
35
+
36
+ - `parentToolUseId` → `parentToolCallId` (on parts and `message.started`;
37
+ REMOVED entirely from `message.part` / `message.part.delta` events — the
38
+ part carries it)
39
+ - Delta types: `"text-delta"` → `"text"`, `"reasoning-delta"` → `"reasoning"`,
40
+ `"tool-input-delta"` → `"tool_input"`
41
+ - Event `"session.compacted"` → `"session.compaction"` (now an upsert entity:
42
+ REQUIRED new fields `compactionId: string`,
43
+ `status: "in_progress"|"completed"|"failed"|"cancelled"`; optional `summary`,
44
+ `abstractsIds`)
45
+ - `error` event: `{error: string, code?: string}` →
46
+ `{category: ErrorCategory, message: string}` (`recoverable`/`stack`
47
+ unchanged)
48
+ - `turn.ended.error`: `{name, message}` → `{category, message}`
49
+ - `PermissionMode` values: `"acceptEdits"` → `"accept_edits"`, `"dontAsk"` →
50
+ `"dont_ask"`, `"bypassPermissions"` → `"bypass_permissions"` (engine-side
51
+ values; SDK-facing values in adapters stay camelCase — the adapters map)
52
+ - `PartInput`/parts: `mediaType` → `mimeType`
53
+ - `TurnCancelResult`: `{cancelled: boolean, confirmed?, activeTurnId?}` →
54
+ discriminated union `{outcome:"cancelled",turnId}` |
55
+ `{outcome:"unconfirmed",turnId}` | `{outcome:"no_active_turn",activeTurnId?}`
56
+ - `ToolState`: new fifth status `"cancelled"` `{status,input,time{start,end}}`;
57
+ `completed.title` now OPTIONAL (no more default-to-`toolName`); `completed`
58
+ gains optional `content?: ToolResultContent[]` and `metadata?`
59
+ - `TOOL_STATUSES` → **`RUNTIME_TOOL_STATUSES`**, `ToolStatus` →
60
+ **`RuntimeToolStatus`** (`ToolStatusSchema` → `RuntimeToolStatusSchema`).
61
+ The rename marks a deliberate deviation from §2, which lists `ToolStatus`
62
+ among the OPEN vocabularies: this union is **closed**. The status is a
63
+ STRUCTURAL discriminator here — each status carries its own payload
64
+ (`pending.partialInput`, `in_progress.time{start}`, `completed.output`,
65
+ `failed.error`, `cancelled.time{start,end}`) — so an unknown status has no
66
+ decodable body and would only ever be a shape nothing can render. New
67
+ statuses are protocol additions (a new arm), not producer extensions; the
68
+ `Runtime` prefix says which set you are importing. Raised against the spec
69
+ rather than silently diverging — if §2 wins, the fix is an
70
+ `UnknownToolState {status, input?, _meta?}` arm, not a re-opened union.
71
+ - `classifyError` fallback category renamed `"unknown"` → `"internal"`
72
+ - ALL `message.*` events now carry `sessionId`; `message.started` gained
73
+ `model?`, lost its `metadata` bag
74
+ - Every event / part / tool state has optional `_meta`
75
+ - Reducer: `ConversationState.messages`/`compactions` replaced by a unified
76
+ `timeline: Array<ConversationMessage|ConversationCompaction>`
77
+ (kind-discriminated: `kind:"message"`/`"compaction"`); helper
78
+ `conversationMessages(state)`; new state fields `ended?`, `unknownEvents`;
79
+ `reduceConversation` also accepts an `UnknownEvent` and preserves it
80
+
81
+ ### New
82
+
83
+ - **The user echo (§7.2).** Every turn now opens with the submitted input
84
+ echoed back as a complete user message —
85
+ `message.started{role:"user", outputIndex:0}` → one `message.part` per input
86
+ part → `message.ended` — before any harness output. Assistant messages now
87
+ start at `outputIndex: 1`. The stream is a complete transcript: multi-client
88
+ attach, replay, and persistence no longer need the client's own copy of the
89
+ prompt. The echo's ids are DERIVED from the turn id (`echo-${turnId}` — see
90
+ Consumer machinery below), so a consumer that minted the turnId predicts
91
+ the whole echo instead of reconciling against it.
92
+ - **`session.ended {reason}`** — `idle` | `replaced` | `released` |
93
+ `shutdown` (OPEN). `ConversationState.ended` records it.
94
+ - **`session.compaction` as a positional, ID-addressed entity.** First
95
+ appearance anchors its place in the timeline; later upserts advance
96
+ `status`/`summary` in place without moving or restamping it. A failed
97
+ compaction is representable instead of a stream that goes quiet.
98
+ - **Structured tool output (three audiences).** `output` (string) stays the
99
+ model-facing record and rendering fallback; `content?: ToolResultContent[]`
100
+ (`text` | `image` | `diff` | `terminal`) is the display-grade view;
101
+ `metadata?` is the machine channel. The claude-code adapter now populates
102
+ `content` for text and image result blocks instead of only flattening.
103
+ - **Subagent metadata.** `Task`-style spawns populate
104
+ `subagent {type, description, model, agentId?}` from the tool's input, and
105
+ `kind: "task"` is derived from its PRESENCE — no more tool-name allowlists.
106
+ - **`reasoning.time` and `providerMetadata` are real.** The claude-code adapter
107
+ stamps `time.start`/`time.end` across a thinking block and marks
108
+ `redacted_thinking` with `providerMetadata: {redacted: true}` instead of
109
+ collapsing it into plain reasoning.
110
+ - **Unknown preservation (Law 6).** `decodeLifecycleEvent` keeps an unknown
111
+ event type in order as `{type, raw}` rather than dropping it; the reducer
112
+ collects them in `state.unknownEvents`. Known types with malformed bodies
113
+ still fail loudly.
114
+ - **Cancellation closure.** At `turn.ended{stopReason:"cancelled"}` the reducer
115
+ transitions the turn's non-terminal tool parts to `status:"cancelled"` — a
116
+ cancelled turn can no longer strand tools as `in_progress` forever.
117
+ - **One casing test (Law 9).** `test/protocol/casing.test.ts` walks every enum,
118
+ discriminator and field name in the vocabulary and fails on a Law-2
119
+ violation. The two documented kebab exceptions (`AGENT_HARNESSES`,
120
+ `ModelSwitchMode`) are excluded explicitly.
121
+ - **Permissions actually offer what the schema advertises (§6.4).** The broker
122
+ now offers all four option kinds (`allow_once`, `allow_always`,
123
+ `reject_once`, `reject_always`), and an `*_always` answer becomes a standing
124
+ per-session rule: later calls to that tool are answered without a round-trip
125
+ and without synthesizing a prompt event that never happened. Rules are
126
+ cleared by `session/close`.
127
+ - **`permission.resolved.outcome.updatedInput` is honoured.** An edited input
128
+ travels `PermissionDecision {decision:"allow", updatedInput?}` into the
129
+ claude-code harness's `canUseTool` return, so what was approved is what runs.
130
+ Codex and ACP have no slot for it in their approval replies — documented at
131
+ both call sites rather than silently dropped.
132
+ - **Law 6 on the paths that carry traffic.** `decodeLifecycleEvent` is now what
133
+ the wire client uses: an unknown event type is DELIVERED (advancing `seq`,
134
+ so it can never be mistaken for a gap) as `UnknownEvent`, and `events/replay`
135
+ decodes envelope-by-envelope so one unknown event cannot poison the buffer
136
+ that heals a gap. New `decodePart` gives `message.part` a lenient part
137
+ decoder — an unknown part type is preserved as
138
+ `UnknownPart {type, id, sessionId, messageId, parentToolCallId?, raw}` in its
139
+ message instead of failing the event. **Breaking for consumers**:
140
+ `TurnHandle.events`, `onEvent` and `client.replay()` are typed
141
+ `DecodedLifecycleEvent | UnknownEvent` — narrow with `isUnknownEvent` /
142
+ `isUnknownPart` (`reduceConversation` already accepts the union).
143
+ - **Law 5 is enforced, not just documented.** One `EpochMsSchema`
144
+ (integer, non-negative, rejects `-0`) types every event `timestamp` and every
145
+ nested `time.start`/`time.end` on tool states and reasoning parts. A
146
+ seconds-based or fractional stamp is now a parse error instead of a value
147
+ that sorts wrong in production.
148
+ - Open vocabularies reject blank values (`""`, `" "`) while still accepting
149
+ unknown ones — one `openVocabulary()` helper instead of six casts. New
150
+ `COMPACTION_TRIGGERS` (`auto` | `manual`, OPEN) types
151
+ `session.compaction.trigger`, and `ConversationState.harness` is the closed
152
+ `AgentHarness` again instead of a widened `string`.
153
+ - Image and file parts now ENFORCE §4's "exactly one of `data` | `url`" on the
154
+ output side, as the input side already did.
155
+ - Cancellation closure preserves a pending tool's streamed JSON under
156
+ `_meta["agent-server/partialInput"]` (`PARTIAL_INPUT_META_KEY`) instead of
157
+ dropping what the call was about; `emptyConversation().totals` now uses the
158
+ same zero shape as `DEFAULT_TOKEN_USAGE`.
159
+ - `verifyStreamContract` fixes two false positives that failed LEGAL streams:
160
+ a cancelled turn's trailing deltas (a snapshot is a prefix of the fold, not
161
+ a ceiling) and multi-turn sessions (`session.created` is per turn, since the
162
+ engine re-announces it on every turn's first yield). Its vacuous
163
+ "refold the same stream" determinism check is replaced by a real re-delivery
164
+ probe: every upsert-shaped event delivered twice must leave the timeline,
165
+ per-message part counts, resolved permissions and billing totals unchanged.
166
+ - The CLI renderer now folds the stream with `reduceConversation` instead of
167
+ hand-rolling two switch statements over the event union — the reference
168
+ implementation is dogfooded, as `docs/consuming.md` tells consumers to do.
169
+ - Deliberately absent under Law 7 (advertise nothing you don't deliver): §8's
170
+ `InitializeResult.experimental` (no experimental capability to gate yet) and
171
+ §12's `docs/rfds/` (no RFD process while the protocol has one implementation).
172
+
173
+ ### Consumer machinery
174
+
175
+ - **Restart detection is a handshake fact, not an inference.** `initialize`
176
+ returns a per-process `instanceId`; `AgentServerClient` re-initializes on
177
+ every reconnect (also failing loudly on a version mismatch instead of
178
+ silently talking to a different build) and adopts the fresh session logs
179
+ when the id changed. This replaced four separate seq-pattern heuristics
180
+ (live seq-1 reset with drain choreography, a content compare at the
181
+ watermark, replay-side `latestSeq` adoption) with one stated fact; a
182
+ minimal seq-layer `reset` arm remains as defense-in-depth for
183
+ handshake-less consumers.
184
+
185
+ The machinery every consumer of this stream wrote for itself. Each item below
186
+ replaces a **counted** duplication in a shipping product — three drifting
187
+ copies of one predicate, two hand-rolled seq cursors, a transport attached
188
+ purely to sniff its own wire — not a hypothetical need (Law 7). Together they
189
+ retire ~570 lines of product code plus their tests.
190
+
191
+ - **`isCleanStop` / `isFailureStopReason` / `CLEAN_STOP_REASONS`.** Did the
192
+ turn finish the way it was supposed to? `cancelled` is CLEAN (the user asked
193
+ for the stop); `error` is not; and because `StopReason` is OPEN, anything
194
+ this build does not recognize — a newer engine's value, an adapter's
195
+ `_`-prefixed extension, `undefined` — classifies as a FAILURE. That
196
+ direction is deliberate: a failure affordance on an outcome we cannot
197
+ interpret is recoverable, an unknown outcome rendered as success is not.
198
+ The three copies in the wild disagreed on `max_turn_requests`, on `refusal`,
199
+ and — in opposite directions — on what to do with an unknown value.
200
+ - **`createSeqCursor()`** (`/protocol`): per-session `seq` bookkeeping as a
201
+ pure, dependency-free object — `advance(sessionId, seq)` →
202
+ `"deliver" | "duplicate" | "gap" | "reset"`, plus `reset`, `seek`, `last`,
203
+ `sessions`. `AgentServerClient` is now implemented ON it (one
204
+ implementation, not two), so a consumer that folds envelopes forwarded over
205
+ its own transport gets the client's exact semantics, including the
206
+ restart-at-1 `reset` verdict that keeps a post-restart stream from being
207
+ dropped as duplicates.
208
+ - **`parseAgentInput(value)`** (`/protocol`): decode an untrusted value into
209
+ an `AgentInput`, or throw `invalid agent input: <path>: <why>`. The schema
210
+ was always the contract; the hand-rolled decoders in front of it were where
211
+ tolerance for shapes the engine never accepted crept in.
212
+ - **`AgentServer.onEvent(cb)`**: observe every broadcast envelope in-process,
213
+ already sequenced and appended to the session log, returning an
214
+ unsubscribe. Synchronous, and an observer that throws is isolated exactly
215
+ like a faulty transport. Replaces attaching a fake transport to the server
216
+ in order to parse one's own NDJSON back into objects.
217
+ - **zod-free subpaths**: `@zvada/agent-server/protocol/factories`,
218
+ `@zvada/agent-server/protocol/guards`, `…/protocol/stop-reasons`,
219
+ `…/protocol/seq-cursor` and `…/protocol/selectors`. `PART_TYPES`,
220
+ `LIFECYCLE_EVENT_TYPES`, `isKnownPartType`, `isUnknownPart`,
221
+ `isKnownLifecycleEventType` and `isUnknownEvent` moved into
222
+ `src/protocol/guards.ts` — **still exported from the root and `/protocol`**,
223
+ nothing moved for existing importers. The subpaths let a browser bundle
224
+ import a part constructor or a membership test without pulling zod and the
225
+ whole contract in behind it; a test walks their transitive runtime import
226
+ graph and fails if a package ever appears in it.
227
+ - **Selectors** (`/protocol`), pure functions over `ConversationState`:
228
+ `groupIntoTurns` (consecutive same-role, same-turn runs of the timeline,
229
+ with `isLatest` true for the final group ONLY — the guard that stops a
230
+ finished turn from re-entering streaming UI when the next prompt lands),
231
+ `subagentGroups` (parented messages keyed by their spawning `toolCallId`),
232
+ `agentActivity` (`thinking` | `generating` | `tool_running` |
233
+ `tool_failed` | `idle`, from the last part of the last top-level assistant
234
+ message of the last active turn), and `toolResultText` (completed →
235
+ `output`, failed → `error`, otherwise `""`).
236
+ - **Deterministic echo ids — BREAKING for anyone reading structure into them.**
237
+ The user echo's message id is now `echo-${turnId}` (`echoMessageId`) and its
238
+ part ids `echo-${turnId}-${index}` (`echoPartId`), instead of freshly minted
239
+ UUIDv7s. `createUserEchoParts(input, turnId)` takes the turn id and stamps
240
+ them. A documented exception to Law 8: the echo is not new information, it
241
+ is the caller's own input played back, exactly once per turn — so a consumer
242
+ that minted the `turnId` can predict the echo byte-for-byte and render its
243
+ optimistic bubble AS the echo (same ids, same parts) instead of reconciling
244
+ a look-alike and swapping it. Ids remain opaque to peers; this is a producer
245
+ rule with a matching consumer helper, not a licence to parse ids elsewhere.
246
+ - **`reduceConversationWithChanges(state, event)`** →
247
+ `{state, changes: ConversationChange[]}` (`/protocol`): the same fold, plus
248
+ the list of what it touched, for sinks that must WRITE rather than
249
+ re-render — which rows to upsert, which queries to invalidate.
250
+ `ConversationChange` (zod-typed, exported) is a discriminated union of
251
+ `message-upserted` · `part-upserted` · `message-ended` · `delta-buffered` ·
252
+ `turn-updated` · `compaction-upserted` · `permission-updated` ·
253
+ `usage-updated` · `session-meta-updated` · `session-ended` ·
254
+ `unknown-event-recorded`. The changes are RECORDED by the fold, not diffed
255
+ from its output: `reduceConversation` is this function with the recorder
256
+ thrown away, so the two cannot drift. A fold that changes nothing (a
257
+ replayed duplicate) reports nothing; the cancellation closure reports one
258
+ `part-upserted` per tool it closed.
259
+
260
+ ## 0.2.3
261
+
262
+ - **`reduceConversation`** (root / `./protocol`): the canonical
263
+ LifecycleEvent → conversation-state reducer. Pure, copy-on-write fold that
264
+ encodes the stream's consumption rules — part upsert by `part.id`
265
+ (including late tool completions landing in their original message),
266
+ authoritative snapshots over streamed deltas, exactly-once permission
267
+ resolution, the gauge/billing-totals distinction. Ships with
268
+ `emptyConversation()` and `pendingPermissions()`. Replaces the fold every
269
+ consumer had been writing by hand.
270
+ - **`onSessionEnd`** (`ClaudeCodeAgentOptions`): fires exactly once when a
271
+ live session's subprocess ends, with why — `idle` | `replaced` |
272
+ `released` | `shutdown`. The seam for host-managed per-session resources
273
+ (BYOK proxy keys, recorders).
274
+ - **`onDiagnostic`** (`CreateRegistryOptions`, `AgentRuntime` options,
275
+ `createAnthropicProxy` options): operational signals the engine previously
276
+ swallowed — `interruptTimeout`, `resumeFallback`, `sinkError`,
277
+ `proxyUpstreamAuth`. Handler errors can never break a turn.
278
+
279
+ ## 0.2.2
280
+
281
+ - **Idempotent turn admission.** `AgentRuntime.run` converges a duplicate
282
+ `{sessionId, turnId}` with identical input onto the in-flight promise or
283
+ the memoized summary (bounded per-session window, released by
284
+ `session/close`) — a retried RPC can never double-execute a turn. Same ids
285
+ with different input throw `TurnConflictError`. `runtime.admission()` is
286
+ the pure probe; `turn/start` delegates (`deduplicated: true` acks, wire
287
+ error `turnConflict`). Registration precedes execution start, so even a
288
+ synchronous reentrant sink converges.
289
+ - **Confirmed, turn-stamped cancels.** `Agent.cancel` returns
290
+ `{confirmed, hadTurn}`; `interruptTurn()` reports the SDK round-trip
291
+ outcome instead of swallowing it; `turn/cancel` accepts a `turnId` stamp
292
+ and refuses a stale one with `activeTurnId` instead of killing the
293
+ successor turn. Claude aborts before interrupting, fixing a race where a
294
+ clean cancel could surface as `error`.
295
+ - **Bun-proof BYOK proxy.** SSE bodies pipe through a fresh stream
296
+ (downstream disconnects propagate upstream — no leaked sockets); non-SSE
297
+ responses drop stale `content-encoding`/`content-length`/
298
+ `transfer-encoding`; structured 502 on unreachable upstreams; injectable
299
+ `fetch`. Media-type detection parses instead of substring-matching.
300
+ - Turn-end ordering hardened: `turn.ended` never starts before any
301
+ `permission.resolved` emission completed, regardless of who settled it.
302
+ - Empty-string session/turn ids are rejected at the wire schema.
303
+ - `toolPolicy` receives the SDK's full `canUseTool` context (signal,
304
+ suggestions, blockedPath, decisionReason).
305
+
306
+ ## 0.2.1
307
+
308
+ - codex-app-server: an `item/completed` carrying `text: ""` no longer
309
+ clobbers reasoning text accumulated from `summaryTextDelta`s — thoughts
310
+ persist in the terminal snapshot consumers store.
311
+
312
+ ## 0.2.0
313
+
314
+ - The four packages (`@zvada/agent-server-{protocol,core,server,client}`)
315
+ consolidated into **one package** with subpath seats: root = the wire
316
+ contract, `./core` (+ `./core/proxy`), `./server` (ships the
317
+ `agent-server` bin), `./client`. The old four are deprecated on npm.
package/README.md CHANGED
@@ -18,9 +18,9 @@ One package, four seats — pick the subpath for how you consume it:
18
18
  ## Embed the engine
19
19
 
20
20
  ```ts
21
- import { AgentRuntime, callbackSink, createAgentRegistry } from "@zvada/agent-server/core";
21
+ import { callbackSink, createAgentRuntime } from "@zvada/agent-server/core";
22
22
 
23
- const runtime = new AgentRuntime(createAgentRegistry({ provision: { mode: "pinned" } }));
23
+ const runtime = createAgentRuntime({ provision: { mode: "pinned" } });
24
24
  await runtime.run(
25
25
  {
26
26
  sessionId: "s1",
@@ -69,6 +69,19 @@ npx --yes agentic-feedback@latest --to agent-server --category <api|cli|docs|oth
69
69
  Continue a thread with `--resume <threadId>` (printed after each submission).
70
70
  `DO_NOT_TRACK=1` strips auto-detected context. Only specific, actionable reports.
71
71
 
72
+ ## Consume the stream
73
+
74
+ Fold events with the shipped canonical reducer instead of hand-rolling a
75
+ `switch` — it encodes the upsert/delta/permission rules consumers get wrong
76
+ first (see [docs/consuming.md](docs/consuming.md) for the full recipe):
77
+
78
+ ```ts
79
+ import { emptyConversation, reduceConversation } from "@zvada/agent-server";
80
+
81
+ let conversation = emptyConversation();
82
+ for await (const event of turn.events) conversation = reduceConversation(conversation, event);
83
+ ```
84
+
72
85
  ## Notes
73
86
 
74
87
  - Ships as TypeScript source (Bun-first; the client and stdio/dial server
@@ -77,8 +90,11 @@ Continue a thread with `--resume <threadId>` (printed after each submission).
77
90
  - The wire vocabulary tracks the
78
91
  [Agent Client Protocol](https://agentclientprotocol.com) where concepts
79
92
  overlap.
80
- - Full docs, architecture, and the live test harness live in the
81
- [repository](https://github.com/zvadaadam/agent-server).
93
+ - Shipped guides: [docs/consuming.md](docs/consuming.md) (integration recipe),
94
+ [docs/harnesses.md](docs/harnesses.md) (per-harness support matrix),
95
+ [docs/deploy.md](docs/deploy.md) (provisioning, single binary, sandboxes),
96
+ [CHANGELOG.md](CHANGELOG.md). Architecture and the live test harness live in
97
+ the [repository](https://github.com/zvadaadam/agent-server).
82
98
  - Supersedes the four split packages `@zvada/agent-server-{protocol,core,server,client}`
83
99
  (now deprecated); the subpaths above are their one-to-one replacements.
84
100
 
@@ -0,0 +1,269 @@
1
+ # Consuming the agent-server stream
2
+
3
+ The integration recipe, written down once — every rule here was re-derived
4
+ independently by at least one real consumer before it landed in this file.
5
+
6
+ ## Fold events with the shipped reducer
7
+
8
+ Do not hand-roll a `switch (event.type)` state fold. The package ships the
9
+ canonical one:
10
+
11
+ ```ts
12
+ import { emptyConversation, reduceConversation, pendingPermissions } from "@zvada/agent-server";
13
+
14
+ let conversation = emptyConversation();
15
+ for await (const event of turn.events) {
16
+ conversation = reduceConversation(conversation, event); // pure, copy-on-write
17
+ render(conversation); // React-friendly identities
18
+ for (const request of pendingPermissions(conversation)) prompt(request);
19
+ }
20
+ ```
21
+
22
+ The reducer encodes the rules consumers get wrong first:
23
+
24
+ - **Every turn starts with your own prompt echoed back.** See below — this is
25
+ the rule that surprises consumers upgrading to 0.3.0.
26
+
27
+ - **Upsert parts by `part.id`.** A tool part can complete AFTER its message —
28
+ or its turn — ended; the event's `messageId` names the original message and
29
+ the upsert lands there. If you persist per-event, your writes must be
30
+ upserts, never inserts.
31
+ - **Snapshots are authoritative; deltas are streaming sugar.** `message.part`
32
+ replaces whatever `message.part.delta`s built. Never append a snapshot's
33
+ text onto delta-accumulated text.
34
+ - **`turn.ended.tokens` ≠ `session.usage`.** The first is the turn's billing
35
+ total (sum for spend); the second is the live context-window gauge (render
36
+ for "how full"). Different numbers, both kept by the reducer.
37
+ - **Permissions resolve exactly once.** Turn end or cancel resolves pending
38
+ requests as `cancelled`; you will always observe a `permission.resolved`
39
+ before that turn's `turn.ended`.
40
+
41
+ Branch on the outcome with `isCleanStop` / `isFailureStopReason` rather than
42
+ your own list: `cancelled` is clean (the user asked for the stop), `error` is
43
+ not, and because `StopReason` is OPEN, anything neither this build nor yours
44
+ recognizes counts as a failure — the safe direction.
45
+
46
+ ## Fold with changes when you WRITE
47
+
48
+ Rendering needs the state; persisting needs to know what moved. Use
49
+ `reduceConversationWithChanges` and let the fold tell you:
50
+
51
+ ```ts
52
+ const { state, changes } = reduceConversationWithChanges(previous, event);
53
+ for (const change of changes) {
54
+ switch (change.kind) {
55
+ case "message-upserted": upsertMessageRow(state, change.messageId); break;
56
+ case "part-upserted": upsertPartRow(state, change.messageId, change.partId); break;
57
+ case "turn-updated": upsertTurnRow(state, change.turnId); break;
58
+ // …message-ended · delta-buffered · compaction-upserted · permission-updated
59
+ // usage-updated · session-meta-updated · session-ended · unknown-event-recorded
60
+ }
61
+ }
62
+ ```
63
+
64
+ The changes are recorded BY the fold, not diffed from its output —
65
+ `reduceConversation` is the same function with the recorder discarded, so
66
+ there is no second implementation to drift. A fold that changed nothing (a
67
+ replayed duplicate) yields `[]`, which is your cue to skip the write
68
+ entirely. SQL semantics stay yours: `ON CONFLICT` vs `REPLACE`, `COALESCE`
69
+ guards, and which columns are yours to own are schema knowledge, not fold
70
+ knowledge.
71
+
72
+ ## Project the state with the shipped selectors
73
+
74
+ Four pure functions over `ConversationState`, so a UI does not re-derive them:
75
+
76
+ - `groupIntoTurns(state)` — turn cards: consecutive entries of the same turn
77
+ and the same speaker. `isLatest` is true for the FINAL group only; render
78
+ streaming affordances on it and nothing else, or the moment the next
79
+ prompt's echo lands the previous — completed — answer flips back into
80
+ "working".
81
+ - `subagentGroups(state)` — parented messages keyed by the `toolCallId` that
82
+ spawned them, to nest under that tool card (and to filter out of the main
83
+ flow, or the subagent's work prints twice).
84
+ - `agentActivity(state)` — `thinking` | `generating` | `tool_running` |
85
+ `tool_failed` | `idle`, from the last part of the last top-level assistant
86
+ message of the last ACTIVE turn. `idle` during an active turn means
87
+ "between observable activities", not "finished" — combine it with your own
88
+ busy flag.
89
+ - `toolResultText(toolPart.state)` — the display fallback: completed →
90
+ `output`, failed → `error`, everything else → `""` (deliberately empty, not
91
+ invented placeholder prose).
92
+
93
+ ## Track `seq` with the shared cursor
94
+
95
+ If you fold envelopes yourself — forwarded over your own WebSocket, replayed
96
+ from a log — use `createSeqCursor()` instead of comparing numbers by hand:
97
+
98
+ ```ts
99
+ const cursor = createSeqCursor();
100
+ switch (cursor.advance(envelope.sessionId, envelope.seq)) {
101
+ case "deliver": return fold(envelope.event);
102
+ case "duplicate": return; // already folded
103
+ case "gap": return holdAndReplay(envelope); // request events/replay
104
+ case "reset": return dropLocalStateAndFold(envelope); // the log restarted at 1
105
+ }
106
+ ```
107
+
108
+ `reset` is the one that is easy to get wrong: a restarted server begins the
109
+ session log at 1 again, and treating that as a duplicate silently discards the
110
+ entire post-restart stream. `duplicate` and `gap` leave the cursor untouched,
111
+ so healed events still land in order. `AgentServerClient` uses this exact
112
+ object — there is no second implementation.
113
+
114
+ Restarts themselves are not guessed from seq patterns on the wire: `initialize`
115
+ returns a per-process `instanceId`, the client re-handshakes on every
116
+ reconnect, and a changed id adopts the fresh logs outright (cursors reset,
117
+ sessions resynced from seq 1). The `reset` verdict remains for consumers that
118
+ fold envelopes without a handshake of their own.
119
+
120
+ ## Render by role — the turn opens with the user echo
121
+
122
+ Every turn now begins with the submitted input echoed back as a complete user
123
+ message, before any harness output:
124
+
125
+ ```text
126
+ turn.started
127
+ message.started {role: "user", outputIndex: 0} ← the echo
128
+ message.part* ← one per input part, state "done"
129
+ message.ended
130
+ message.started {role: "assistant", outputIndex: 1} ← the model's reply
131
+ ...
132
+ turn.ended
133
+ ```
134
+
135
+ Consequences, in the order they bite:
136
+
137
+ - **A consumer that renders every `message.part` as model output now prints the
138
+ user's own prompt.** Filter by the role of the part's message — never by
139
+ position. `conversationMessages(state)` gives the timeline's messages in
140
+ order, each with its `role`, so `messages.filter(m => m.role === "assistant")`
141
+ is the whole fix.
142
+ - **`outputIndex: 0` is the echo; assistant messages start at 1.** Anything
143
+ keyed on "the first message of the turn is the model's" needs re-keying.
144
+ - **The echo is predictable — don't reconcile, PREDICT.** Its ids are derived
145
+ from the turn id you minted:
146
+
147
+ ```ts
148
+ const turnId = generateUUIDv7();
149
+ renderOptimistically({
150
+ messageId: echoMessageId(turnId), // `echo-${turnId}`
151
+ parts: createUserEchoParts(input, turnId), // ids `echo-${turnId}-${index}`
152
+ });
153
+ await client.runTurn({ turnId, input, config });
154
+ ```
155
+
156
+ The echo that arrives upserts onto your optimistic bubble by id: same
157
+ message, same parts, byte for byte. No placeholder to swap, no window where
158
+ the prompt renders twice, and multimodal parts survive (the swap-a-look-alike
159
+ approach is where file and image parts got dropped). These two are the
160
+ documented exception to engine-minted UUIDv7 ids — everything else on the
161
+ wire stays opaque, and nothing may parse structure out of an id.
162
+ - The stream is now a complete transcript. Multi-client attach, replay and
163
+ persistence no longer need the client's private copy of the prompt — which
164
+ is the point: a second UI attaching mid-turn sees what was asked.
165
+
166
+ `session.created` is unrelated to this ordering and lands **mid-turn** (it
167
+ carries the harness-native id, which does not exist until the harness's first
168
+ yield). Persist it whenever it arrives.
169
+
170
+ ## Tolerate the unknown
171
+
172
+ The client delivers events it does not recognize instead of dropping them: a
173
+ newer server's event type arrives as `UnknownEvent {type, sessionId?, raw}`,
174
+ and an unknown part type inside a known `message.part` as
175
+ `UnknownPart {type, id, sessionId, messageId, raw}`. Both keep their place in
176
+ the order, and the unknown event still advances the per-session `seq` — a
177
+ dropped event would read as an unfillable gap and kill the turn handle.
178
+
179
+ `reduceConversation` accepts the union as-is (unknown events land in
180
+ `state.unknownEvents`, unknown parts in their message's `parts`). In your own
181
+ code, narrow with `isUnknownEvent` / `isUnknownPart` before switching on a
182
+ known `type`, and **store and forward what you don't understand** — a
183
+ round trip through your persistence layer must not be where a newer server's
184
+ data disappears. A known type with a malformed body still fails loudly.
185
+
186
+ ## Persist for resume
187
+
188
+ Store `state.nativeSessionId` (from `session.created`) keyed by your logical
189
+ `sessionId`, and pass it back as `config.resumeSessionId` to continue a
190
+ conversation later — across processes and machines. Check
191
+ `session.created.resumed` on resume turns: `false` means the harness fell
192
+ back to a fresh session (context lost) — surface that, never swallow it.
193
+ Compare with the flag, not ids: a successful Claude resume mints a NEW native
194
+ id.
195
+
196
+ ## Retry safely
197
+
198
+ Turn admission is idempotent on `{sessionId, turnId}`: mint the `turnId`
199
+ client-side, and a retried `turn/start` (or embedded `runtime.run`) with the
200
+ identical request converges on the original execution — the wire acks it with
201
+ `deduplicated: true` and `events/replay` fills anything you missed. The same
202
+ `turnId` with different input fails loudly (`turnConflict` /
203
+ `TurnConflictError`). This is what makes at-least-once RPC layers (Durable
204
+ Object retries, queue redelivery) safe over the engine.
205
+
206
+ ## Cancel honestly
207
+
208
+ `turn/cancel` (and `runtime.cancel`) accept a `turnId` stamp — always pass
209
+ the id of the turn you mean, so a late cancel can never kill its successor
210
+ (a stale stamp returns `{outcome: "no_active_turn", activeTurnId}`). The
211
+ result is a single-outcome union: `cancelled` means the harness confirmed the
212
+ interrupt; `unconfirmed` means it did not land — including the window between
213
+ the `turn/start` quick-ack and the harness registering its abortable turn —
214
+ and the agent may still be running. On `unconfirmed`, report "stopping…" and
215
+ treat `turn.ended` as the source of truth, not the cancel response.
216
+
217
+ ## Verify the stream
218
+
219
+ `verifyStreamContract(events, {seqs?})` machine-checks a recorded stream
220
+ against the ordering/delivery contract — echo-first, bracket pairing,
221
+ upsert-address stability, delta-follows-snapshot, permissions resolve-once,
222
+ seq monotonicity — then folds it twice: once to assert the reducer's final
223
+ state agrees with the authoritative snapshots, and once with every upsert
224
+ delivered TWICE to assert re-delivery changes nothing (the property a replay
225
+ overlap or a reconnect resync depends on). Run it in CI over recorded fixtures
226
+ and in staging over live turns (`agent-server run "…" --verify` exits 1 on any
227
+ violation); a product pipeline (a Durable Object, a persistence shim) can run
228
+ it on replay to fail loudly instead of storing a corrupt projection.
229
+
230
+ It takes **one session's** stream, of any length: `session.created` is expected
231
+ once per turn (not once per stream), deltas that trail the last snapshot of a
232
+ cancelled turn are legal (a snapshot is a prefix of the fold, not a ceiling),
233
+ and unknown event/part types are forward-compat rather than violations. Pass
234
+ `{expectEcho: false}` for a partial capture that begins mid-turn.
235
+
236
+ ## Own your session resources
237
+
238
+ Register `onSessionEnd` (claude options) to release per-session resources —
239
+ BYOK proxy keys, recorders — instead of re-deriving termination from side
240
+ effects. Reasons: `idle` (idle-timeout eviction), `replaced` (config change
241
+ restarted the subprocess — usually respawns immediately with context kept),
242
+ `released` (explicit close), `shutdown`. On the wire, call `session/close`
243
+ when a logical session will not be resumed: it frees the replay buffer and
244
+ the harness-native state (until then, memory cost ≈ `bufferSize` events per
245
+ session).
246
+
247
+ `session/close` also broadcasts `session.ended {reason: "released"}` — but
248
+ only to subscribers **connected at that moment**, because the replay log is
249
+ retired in the same operation. A subscriber that was detached does not get the
250
+ event later: its `events/replay` answers `unknownSession`, and that answer *is*
251
+ the terminal signal (the session is gone; nothing more will be appended to it).
252
+ Treat `unknownSession` on replay as "session over", not as an error to retry.
253
+
254
+ ## Listen to the engine's diagnostics
255
+
256
+ Pass `onDiagnostic` (registry/runtime/proxy options) and log what arrives:
257
+ `interruptTimeout`, `resumeFallback`, `sinkError`, `proxyUpstreamAuth`.
258
+ These are the signals the engine deliberately does not fail turns over — a
259
+ product that doesn't surface them debugs blind.
260
+
261
+ ## Wire transports
262
+
263
+ `spawn` (stdio subprocess), `connect` (dial a `--listen` server; reconnects
264
+ and replays by default), `attach` (an accepted socket, e.g. the server dialed
265
+ OUT to you via `--dial`). The client dedupes by per-session `seq` and heals
266
+ gaps via `events/replay`; an unfillable gap raises `EventGapError` instead of
267
+ silently losing events. The WebSocket wire itself carries no auth — keep it
268
+ on a trusted channel (localhost, sandbox-internal, or behind your own
269
+ authenticated boundary).