@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.
- package/CHANGELOG.md +317 -0
- package/README.md +20 -4
- package/docs/consuming.md +269 -0
- package/docs/deploy.md +80 -0
- package/docs/harnesses.md +64 -0
- package/docs/rfds/0001-deterministic-echo-ids.md +44 -0
- package/package.json +23 -3
- package/src/client/client.ts +143 -50
- package/src/core/agents/acp/acp-agent.ts +9 -0
- package/src/core/agents/acp/mappings.ts +3 -3
- package/src/core/agents/base.ts +9 -1
- package/src/core/agents/claude-code/adapter.ts +116 -26
- package/src/core/agents/claude-code/claude-agent.ts +31 -3
- package/src/core/agents/claude-code/generator-session.ts +16 -5
- package/src/core/agents/claude-code/options.ts +13 -3
- package/src/core/agents/claude-code/session-manager.ts +9 -4
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +17 -3
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +3 -3
- package/src/core/agents/types.ts +1 -1
- package/src/core/diagnostics.ts +59 -0
- package/src/core/index.ts +3 -1
- package/src/core/presets.ts +15 -2
- package/src/core/provision/pins.ts +5 -1
- package/src/core/proxy/anthropic-proxy.ts +43 -2
- package/src/core/proxy/api-key-store.ts +37 -5
- package/src/core/proxy/index.ts +8 -1
- package/src/core/runtime/agent-runtime.ts +66 -18
- package/src/core/runtime/event-processor.ts +51 -26
- package/src/protocol/config.ts +8 -6
- package/src/{core/agents/error-classifier.ts → protocol/errors.ts} +33 -7
- package/src/protocol/factories.ts +106 -10
- package/src/protocol/guards.ts +53 -0
- package/src/protocol/index.ts +10 -0
- package/src/protocol/lifecycle.ts +289 -112
- package/src/protocol/meta.ts +14 -0
- package/src/protocol/part-input.ts +56 -7
- package/src/protocol/parts.ts +125 -10
- package/src/protocol/reduce.ts +749 -0
- package/src/protocol/selectors.ts +162 -0
- package/src/protocol/seq-cursor.ts +87 -0
- package/src/protocol/stop-reasons.ts +45 -0
- package/src/protocol/time.ts +23 -0
- package/src/protocol/tokens.ts +23 -0
- package/src/protocol/tool-state.ts +85 -25
- package/src/protocol/verify.ts +440 -0
- package/src/protocol/vocabulary.ts +18 -0
- package/src/protocol/wire.ts +81 -13
- package/src/server/acp/binding.ts +23 -2
- package/src/server/acp/translate.ts +51 -14
- package/src/server/agent-server.ts +85 -6
- 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 {
|
|
21
|
+
import { callbackSink, createAgentRuntime } from "@zvada/agent-server/core";
|
|
22
22
|
|
|
23
|
-
const runtime =
|
|
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
|
-
-
|
|
81
|
-
[
|
|
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).
|