@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/docs/deploy.md ADDED
@@ -0,0 +1,80 @@
1
+ # Deploying agent-server
2
+
3
+ ## CLI provisioning (deterministic agents)
4
+
5
+ The native harnesses spawn real CLIs (`claude` ~220 MB, `codex` ~300 MB
6
+ unpacked). Instead of trusting whatever version a host happens to have, the
7
+ engine can **download the pinned, engine-tested builds** from the npm registry
8
+ into a local cache — sha512-verified, installed atomically, shared across
9
+ processes. Three modes (`--provision`, `$AGENT_SERVER_PROVISION`, or
10
+ `createAgentRuntime({ provision })`):
11
+
12
+ - **`auto`** (default): explicit override → host install → managed cache →
13
+ download. Zero-config: uses what the machine has, self-heals on bare ones.
14
+ - **`pinned`**: override → cache → download. Never trusts host installs —
15
+ every process in a fleet runs the exact tested build.
16
+ - **`system`**: override → host install only. Never downloads (air-gapped).
17
+
18
+ "Host install" means the build the installed SDK itself would run — claude's
19
+ sibling platform package, codex's vendored `@openai/codex` tree (the JS ↔ CLI
20
+ pair from your lockfile) — with PATH as codex's fallback. Overrides are
21
+ operator-level only — `$CLAUDE_CLI_PATH` / `$CODEX_CLI_PATH` (or
22
+ `provision.overrides`), never wire config: a remote client must not choose the
23
+ executable a server spawns. Auth stays host-side either way: managed CLIs read
24
+ the same `~/.claude` / `~/.codex` (or per-turn `config.apiKey` BYOK) as your
25
+ own installs. Every resolution logs one stderr line
26
+ (`claude CLI: … (cache 0.3.168)`) so runs are diagnosable.
27
+
28
+ Pins live in `src/core/provision/pins.ts`: the claude pin tracks the
29
+ lockfile's `@anthropic-ai/claude-agent-sdk` version (test-enforced); the codex
30
+ pin is the version the app-server adapter's protocol was verified against.
31
+
32
+ ## Single binary & sandbox cold starts
33
+
34
+ The server compiles to a self-contained executable — no bun, node, or
35
+ node_modules on the target:
36
+
37
+ ```sh
38
+ bun build --compile packages/agent-server/src/server/bin.ts --outfile dist/agent-server
39
+ # cross-compile from any machine:
40
+ # --target=bun-darwin-arm64 | bun-darwin-x64 | bun-linux-x64 | bun-linux-arm64
41
+ # | bun-linux-x64-musl (Alpine) | bun-linux-arm64-musl
42
+ ```
43
+
44
+ Inside a compiled binary the Claude SDK can't reach its platform package, so
45
+ provisioning is what makes the binary *work*: first turn on a bare machine
46
+ downloads the pinned CLIs (~7 s on datacenter bandwidth, parallel), every
47
+ later boot resolves from cache instantly.
48
+
49
+ For sandboxes (E2B, Modal, Fly, plain Docker), erase even that first-turn cost
50
+ by prefetching **at image build time**:
51
+
52
+ ```dockerfile
53
+ FROM debian:bookworm-slim
54
+ RUN apt-get update && apt-get install -y ca-certificates tar && rm -rf /var/lib/apt/lists/*
55
+ COPY dist/agent-server /usr/local/bin/agent-server
56
+ # bake the pinned CLIs into the image → zero downloads at runtime
57
+ RUN agent-server install
58
+ ENV ANTHROPIC_API_KEY="" # or mount ~/.claude / ~/.codex at runtime
59
+ ENTRYPOINT ["agent-server", "--provision", "pinned", "--listen", "0.0.0.0:4747"]
60
+ ```
61
+
62
+ `agent-server install` respects `--harness a,b` (only fetch what you run) and
63
+ `$AGENT_SERVER_CACHE_DIR` (e.g. point it at a persistent volume instead of
64
+ baking). The cache layout is version-keyed
65
+ (`<cache>/claude/0.3.168-darwin-arm64/…`), so image layers and shared volumes
66
+ dedupe naturally and concurrent cold starts converge on one copy. The cache
67
+ root is created `0o700` and must be owned by the running user — a foreign or
68
+ symlinked root is refused (predictable install paths must not be plantable).
69
+
70
+ Measured in a live E2B sandbox (x86_64 Ubuntu, 2026-07): sandbox create 0.7 s,
71
+ `install --harness claude-code` 3.0 s (71 MB, verified), first real Claude
72
+ turn over the wire (BYOK `config.apiKey`) **2.0 s**. With the CLIs baked into
73
+ the template, boot-to-first-token is effectively sandbox-create + turn time.
74
+
75
+ ## Trust boundary
76
+
77
+ The WebSocket wire carries no built-in authentication or TLS termination.
78
+ Keep it on a trusted channel — localhost, sandbox-internal, or behind your own
79
+ authenticated transport boundary. The stdio wire inherits the trust of
80
+ whoever spawned the process.
@@ -0,0 +1,64 @@
1
+ # Harness support matrix
2
+
3
+ Four harnesses, one event stream. The lifecycle event *structure* is identical
4
+ across harnesses (one generic `EventProcessor` drives it); payloads and config
5
+ support differ only where the backends genuinely do.
6
+
7
+ | Harness | Backend | Streaming | Resume | Model switch |
8
+ | --- | --- | --- | --- | --- |
9
+ | `claude-code` | `@anthropic-ai/claude-agent-sdk` (claude CLI) | token-level | yes (`resume`) | in-session (`setModel`) |
10
+ | `codex-sdk` | `@openai/codex-sdk` (codex CLI exec) | item-level (deltas synthesized) | yes (`resumeThread`) | restart-session |
11
+ | `codex-app-server` | `codex app-server` (JSON-RPC subprocess) | token-level | yes (`thread/resume`) | in-session (per-turn) |
12
+ | `acp` | any ACP v1 agent via `@agentclientprotocol/sdk` | per-agent | yes (`session/resume`) | per-agent |
13
+
14
+ The `acp` harness is opt-in (an operator-supplied launch command, never
15
+ wire-sourced): well-known agents run by name (`--harness=pi`,
16
+ `--harness=gemini`); anything else via `--acp-agent="<launch command>"` /
17
+ `$AGENT_SERVER_ACP_AGENT`, or `CreateRegistryOptions.acp.resolveLaunch` when
18
+ embedding.
19
+
20
+ All harnesses share: one stable logical `sessionId` you assign, a
21
+ harness-native id surfaced via `session.created` (persist it to resume later),
22
+ warm multi-turn reuse, a unified `ThinkingLevel`, and normalized token usage.
23
+
24
+ ## `RunConfig` support per harness
25
+
26
+ | `RunConfig` field | claude-code | codex-sdk | codex-app-server |
27
+ | --- | --- | --- | --- |
28
+ | `model` | ✅ + hot-swap | ✅ (restart on change) | ✅ (per-turn) |
29
+ | `thinkingLevel` | ✅ | ✅ | ✅ |
30
+ | `permissionMode` | ✅ | ✅ → sandbox | ✅ → sandbox |
31
+ | `resumeSessionId` | ✅ | ✅ | ✅ |
32
+ | `systemPromptAppend` | ✅ | ❌ (SDK has no field) | ✅ (`developerInstructions`) |
33
+ | `maxTurns` | ✅ | ❌ | ❌ |
34
+ | `mcpServers` | ✅ | ❌ (capability=false) | ❌ (capability=false) |
35
+ | `apiKey` / `env` | ✅ | ✅ | `env` ✅, `apiKey` via ambient CLI auth |
36
+ | `disableTools` | ✅ | (use read-only sandbox) | (use read-only sandbox) |
37
+ | `permissionRequests` | ✅ (`canUseTool`) | ❌ (sandbox is the gate) | ✅ (`on-request` approvals) |
38
+ | `includeRaw` | ✅ | ✅ | ✅ |
39
+
40
+ Consult `runtime.capabilities(harness)` before relying on a capability.
41
+
42
+ ## Payload notes
43
+
44
+ - A turn ends with a normalized `stopReason`
45
+ (`end_turn | max_tokens | max_turn_requests | refusal | cancelled | error`);
46
+ the raw provider string travels in `finishReason`.
47
+ - `cost` is reported only by Claude; `reasoning` tokens only by Codex.
48
+ - Tool names are provider-native for Claude (`Bash`, `Read`) and normalized
49
+ for Codex (`shell`, `apply_patch`, …) — but the tool `kind`
50
+ (`read`/`edit`/`execute`/…) is normalized for all.
51
+ - `raw` events are an opt-in (`config.includeRaw`) passthrough of the
52
+ harness's unparsed event, for migration/debugging/fixture-recording. `data`
53
+ has **no stability guarantees** and is off by default.
54
+
55
+ ## Known limitations (roadmap)
56
+
57
+ - **MCP servers** are wired for Claude only; Codex MCP passthrough is pending
58
+ an upstream protocol re-verification.
59
+ - The **BYOK proxy** (`/core/proxy`) is a building block, not yet auto-wired
60
+ into the harnesses (they use ambient/explicit keys today).
61
+ - **Hook bridge** (PreToolUse/Stop decisions) is exposed for the claude
62
+ embed tier only (`hooks` factory); there is no wire-level hook surface yet.
63
+ - The WebSocket wire has no built-in auth — see
64
+ [deploy.md](deploy.md#trust-boundary).
@@ -0,0 +1,44 @@
1
+ # RFD 0001 — Deterministic echo ids
2
+
3
+ **Status: adopted** (0.3.0, unpublished — shipped with the consumer-machinery batch).
4
+
5
+ ## Change
6
+
7
+ The user echo's ids are **derived from the turn id** instead of minted as UUIDv7:
8
+
9
+ - echo message id: `echo-<turnId>`
10
+ - echo part ids: `echo-<turnId>-<index>`
11
+
12
+ Exported as `echoMessageId(turnId)` / `echoPartId(turnId, index)`;
13
+ `createUserEchoParts(input, turnId)` stamps them. This is a **documented
14
+ exception to Law 8** (engine-minted UUIDv7).
15
+
16
+ ## Why
17
+
18
+ The echo is not new information — it is the caller's own input played back, and
19
+ one turn has exactly one echo message. Every consumer that renders an
20
+ optimistic prompt bubble was reconciling a look-alike against the echo by
21
+ `turnId` (spec §7.2's old rule) — a swap with visible failure modes (deus lost
22
+ file parts in the swap; agnt double-echoed). With derived ids, a consumer that
23
+ minted the `turnId` predicts the echo **byte-for-byte**: the optimistic bubble
24
+ IS the echo, and it upserts onto itself. Retried turns converge the same way
25
+ (turn admission is idempotent on `{sessionId, turnId}`).
26
+
27
+ ## What ids still are
28
+
29
+ Opaque to peers. Derivation is a producer rule matched by exported consumer
30
+ helpers — not a licence to parse structure out of ids anywhere else.
31
+
32
+ ## Costs (documented, accepted)
33
+
34
+ - Echo ids do not time-sort against UUIDv7 ids. Never order by id; order by
35
+ `outputIndex`/`seq` (which the spec already mandates — Law 5).
36
+ - Echo ids fail UUID-shaped derivations: uuid-typed columns and
37
+ `createdAtFromUUID7`-style timestamp extraction (returns 0). Derive the
38
+ echo's time from the **`turnId` field** the event/row already carries (it is
39
+ UUIDv7) — never by parsing the echo id string; ids stay opaque.
40
+
41
+ ## Spec delta
42
+
43
+ PROTOCOL.md §7.2 (echo reconciliation) and Law 8 (id minting) carry the
44
+ amendment; `docs/consuming.md` describes prediction instead of reconciliation.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvada/agent-server",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Harness-agnostic agent execution engine: run Claude Code, Codex (SDK/CLI + app-server), and any ACP agent behind one interface with a normalized event stream, multi-turn sessions, and resume. Root export is the wire contract; /core, /server, /client are the seats.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,7 @@
25
25
  "engines": {
26
26
  "bun": ">=1.2.0"
27
27
  },
28
- "files": ["src", "README.md", "AGENTS.md", "LICENSE"],
28
+ "files": ["src", "docs", "README.md", "CHANGELOG.md", "LICENSE"],
29
29
  "publishConfig": {
30
30
  "access": "public"
31
31
  },
@@ -41,6 +41,26 @@
41
41
  "types": "./src/protocol/index.ts",
42
42
  "default": "./src/protocol/index.ts"
43
43
  },
44
+ "./protocol/factories": {
45
+ "types": "./src/protocol/factories.ts",
46
+ "default": "./src/protocol/factories.ts"
47
+ },
48
+ "./protocol/guards": {
49
+ "types": "./src/protocol/guards.ts",
50
+ "default": "./src/protocol/guards.ts"
51
+ },
52
+ "./protocol/stop-reasons": {
53
+ "types": "./src/protocol/stop-reasons.ts",
54
+ "default": "./src/protocol/stop-reasons.ts"
55
+ },
56
+ "./protocol/seq-cursor": {
57
+ "types": "./src/protocol/seq-cursor.ts",
58
+ "default": "./src/protocol/seq-cursor.ts"
59
+ },
60
+ "./protocol/selectors": {
61
+ "types": "./src/protocol/selectors.ts",
62
+ "default": "./src/protocol/selectors.ts"
63
+ },
44
64
  "./core": {
45
65
  "types": "./src/core/index.ts",
46
66
  "default": "./src/core/index.ts"
@@ -80,7 +100,7 @@
80
100
  },
81
101
  "devDependencies": {
82
102
  "@agentclientprotocol/sdk": "^1.2.1",
83
- "@anthropic-ai/claude-agent-sdk": "^0.3.168",
103
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
84
104
  "@openai/codex-sdk": "^0.146.1",
85
105
  "@types/bun": "^1.2.0"
86
106
  }
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  AsyncQueue,
3
- type EventsReplayResult,
4
- EventsReplayResultSchema,
3
+ type DecodedEventsReplayResult,
4
+ type DecodedLifecycleEvent,
5
+ type DecodedWireEventEnvelope,
5
6
  type InitializeResult,
6
7
  InitializeResultSchema,
7
8
  type JsonRpcId,
8
- type LifecycleEvent,
9
9
  type PermissionOutcome,
10
10
  PermissionRespondResultSchema,
11
11
  SessionCloseResultSchema,
@@ -15,16 +15,19 @@ import {
15
15
  type TurnEndedEvent,
16
16
  type TurnStartParams,
17
17
  TurnStartResultSchema,
18
+ type UnknownEvent,
18
19
  WIRE_ERROR_CODES,
19
20
  WIRE_METHODS,
20
21
  WIRE_PROTOCOL_VERSION,
21
- type WireEventEnvelope,
22
- WireEventEnvelopeSchema,
23
22
  type WireImplementationInfo,
24
23
  type WireTransport,
24
+ createSeqCursor,
25
+ decodeEventsReplayResult,
26
+ decodeWireEventEnvelope,
25
27
  decodeWireMessage,
26
28
  encodeRequest,
27
29
  generateUUIDv7,
30
+ isUnknownEvent,
28
31
  } from "../protocol/index.ts";
29
32
  import {
30
33
  type SpawnServerOptions,
@@ -59,11 +62,18 @@ export class EventGapError extends Error {
59
62
  override readonly name = "EventGapError";
60
63
  }
61
64
 
62
- /** A running turn: consume `events` (ends after `turn.ended`) or await `result`. */
65
+ /**
66
+ * A running turn: consume `events` (ends after `turn.ended`) or await `result`.
67
+ *
68
+ * `events` yields `UnknownEvent`s too (Law 6): a newer server's event type is
69
+ * delivered in order rather than dropped, so consumers persist and forward it.
70
+ * Narrow with `isUnknownEvent` before switching on a known `type`;
71
+ * `reduceConversation` already accepts the union as-is.
72
+ */
63
73
  export interface TurnHandle {
64
74
  sessionId: string;
65
75
  turnId: string;
66
- events: AsyncIterableIterator<LifecycleEvent>;
76
+ events: AsyncIterableIterator<DecodedLifecycleEvent | UnknownEvent>;
67
77
  result: Promise<TurnEndedEvent>;
68
78
  }
69
79
 
@@ -94,7 +104,7 @@ interface PendingRequest {
94
104
 
95
105
  interface InternalTurn {
96
106
  turnId: string;
97
- queue: AsyncQueue<LifecycleEvent>;
107
+ queue: AsyncQueue<DecodedLifecycleEvent | UnknownEvent>;
98
108
  resolveResult: (event: TurnEndedEvent) => void;
99
109
  rejectResult: (error: unknown) => void;
100
110
  }
@@ -103,6 +113,22 @@ interface ResultSchema<T> {
103
113
  safeParse(raw: unknown): { success: true; data: T } | { success: false };
104
114
  }
105
115
 
116
+ /**
117
+ * `events/replay` decoded the Law-6 way. Using `EventsReplayResultSchema`
118
+ * here would embed the closed event union in the very path that heals a gap,
119
+ * so a single unknown event type in the buffer would fail the replay, retry,
120
+ * and take the turn handle down with an `EventGapError`.
121
+ */
122
+ const REPLAY_RESULT: ResultSchema<DecodedEventsReplayResult> = {
123
+ safeParse(raw) {
124
+ try {
125
+ return { success: true, data: decodeEventsReplayResult(raw) };
126
+ } catch {
127
+ return { success: false };
128
+ }
129
+ },
130
+ };
131
+
106
132
  type TransportFactory = () => Promise<WireTransport> | WireTransport;
107
133
 
108
134
  /**
@@ -117,10 +143,13 @@ export class AgentServerClient {
117
143
  private transport: WireTransport | null = null;
118
144
  private nextId = 1;
119
145
  private readonly pending = new Map<JsonRpcId, PendingRequest>();
120
- private readonly lastSeq = new Map<string, number>();
121
- private readonly heldBack = new Map<string, WireEventEnvelope[]>();
146
+ /** Per-session seq discipline — the shared `/protocol` implementation. */
147
+ private readonly cursor = createSeqCursor();
148
+ private readonly heldBack = new Map<string, DecodedWireEventEnvelope[]>();
149
+ /** The server process behind the transport, learned at `initialize`. */
150
+ private serverInstanceId: string | undefined;
122
151
  private readonly syncing = new Set<string>();
123
- private readonly eventHandlers = new Set<(envelope: WireEventEnvelope) => void>();
152
+ private readonly eventHandlers = new Set<(envelope: DecodedWireEventEnvelope) => void>();
124
153
  private readonly turnBySession = new Map<string, InternalTurn>();
125
154
  private initializePromise: Promise<InitializeResult> | null = null;
126
155
  private closed = false;
@@ -210,6 +239,13 @@ export class AgentServerClient {
210
239
  { supported: [WIRE_PROTOCOL_VERSION] },
211
240
  );
212
241
  }
242
+ // The restart question, answered by the handshake instead of
243
+ // inferred from seq patterns: a changed instanceId means every
244
+ // session log we were tracking belongs to a dead process.
245
+ if (this.serverInstanceId !== undefined && this.serverInstanceId !== result.instanceId) {
246
+ this.adoptFreshLogs();
247
+ }
248
+ this.serverInstanceId = result.instanceId;
213
249
  return result;
214
250
  })
215
251
  .catch((err) => {
@@ -219,6 +255,29 @@ export class AgentServerClient {
219
255
  return this.initializePromise;
220
256
  }
221
257
 
258
+ /**
259
+ * The server process we were talking to is gone; its logs died with it.
260
+ * Drop every per-session cursor and queue so the reconnect resync replays
261
+ * each fresh log from seq 1. Local views may be stale relative to the fresh
262
+ * logs; consumers with durable state resnapshot on their side.
263
+ */
264
+ private adoptFreshLogs(): void {
265
+ // Active turns first: their logs died with the old process, and no fresh
266
+ // log will ever emit THEIR turn.ended — an un-failed handle hangs forever
267
+ // (a session id the fresh process happens to reuse would even feed it
268
+ // someone else's events).
269
+ for (const sessionId of [...this.turnBySession.keys()]) {
270
+ this.failTurn(
271
+ sessionId,
272
+ new EventGapError(
273
+ `server instance changed; the turn's session log died with the old process`,
274
+ ),
275
+ );
276
+ }
277
+ for (const sessionId of this.cursor.sessions()) this.cursor.reset(sessionId);
278
+ this.heldBack.clear();
279
+ }
280
+
222
281
  /**
223
282
  * Start a turn and stream it. Ids are minted client-side when omitted so
224
283
  * events arriving around the ack are never dropped.
@@ -230,7 +289,7 @@ export class AgentServerClient {
230
289
  if (this.turnBySession.has(sessionId)) {
231
290
  throw new Error(`session ${sessionId} already has an active turn`);
232
291
  }
233
- const queue = new AsyncQueue<LifecycleEvent>();
292
+ const queue = new AsyncQueue<DecodedLifecycleEvent | UnknownEvent>();
234
293
  let resolveResult!: (event: TurnEndedEvent) => void;
235
294
  let rejectResult!: (error: unknown) => void;
236
295
  const result = new Promise<TurnEndedEvent>((resolve, reject) => {
@@ -278,10 +337,10 @@ export class AgentServerClient {
278
337
  /**
279
338
  * Cancel the session's active turn; resolves once cancellation propagated.
280
339
  * Pass `turnId` to make the cancel turn-stamped: a late cancel meant for a
281
- * finished turn then returns `{cancelled: false, activeTurnId}` instead of
282
- * killing the turn that replaced it. `confirmed: false` on the result means
283
- * the harness did not acknowledge the interrupt — the agent may still be
284
- * running; `turn.ended` remains the source of truth.
340
+ * finished turn then returns `{outcome: "no_active_turn", activeTurnId}`
341
+ * instead of killing the turn that replaced it. `{outcome: "unconfirmed"}`
342
+ * means the harness did not acknowledge the interrupt — the agent may still
343
+ * be running; `turn.ended` remains the source of truth.
285
344
  */
286
345
  cancelTurn(sessionId: string, turnId?: string): Promise<TurnCancelResult> {
287
346
  return this.request(
@@ -311,16 +370,12 @@ export class AgentServerClient {
311
370
  }
312
371
 
313
372
  /** Fetch buffered events (`seq >= fromSeq`) without touching seq tracking. */
314
- replay(sessionId: string, fromSeq: number): Promise<EventsReplayResult> {
315
- return this.request(
316
- WIRE_METHODS.eventsReplay,
317
- { sessionId, fromSeq },
318
- EventsReplayResultSchema,
319
- );
373
+ replay(sessionId: string, fromSeq: number): Promise<DecodedEventsReplayResult> {
374
+ return this.request(WIRE_METHODS.eventsReplay, { sessionId, fromSeq }, REPLAY_RESULT);
320
375
  }
321
376
 
322
377
  /** Firehose of sequenced events across all sessions (post-dedupe, in order). */
323
- onEvent(handler: (envelope: WireEventEnvelope) => void): () => void {
378
+ onEvent(handler: (envelope: DecodedWireEventEnvelope) => void): () => void {
324
379
  this.eventHandlers.add(handler);
325
380
  return () => this.eventHandlers.delete(handler);
326
381
  }
@@ -381,10 +436,17 @@ export class AgentServerClient {
381
436
  try {
382
437
  await this.openTransport();
383
438
  if (this.closed || !this.transport) continue;
439
+ // Re-handshake: the transport may have reconnected to a DIFFERENT
440
+ // process (or a different build). A version mismatch fails loudly
441
+ // here instead of silently mixing dialects, and a changed
442
+ // instanceId adopts the fresh logs before any resync runs.
443
+ this.initializePromise = null;
444
+ await this.initialize();
445
+ if (this.closed || !this.transport) continue;
384
446
  // Heal every tracked session — including turns that saw no event
385
447
  // before the drop (or none after it): without a resync they would
386
448
  // never receive their turn.ended and would hang forever.
387
- const sessionIds = new Set([...this.lastSeq.keys(), ...this.turnBySession.keys()]);
449
+ const sessionIds = new Set([...this.cursor.sessions(), ...this.turnBySession.keys()]);
388
450
  for (const sessionId of sessionIds) void this.syncSession(sessionId);
389
451
  if (!this.transport) continue;
390
452
  return;
@@ -417,33 +479,55 @@ export class AgentServerClient {
417
479
  return;
418
480
  }
419
481
  if (msg.kind === "notification" && msg.method === WIRE_METHODS.event) {
420
- const parsed = WireEventEnvelopeSchema.safeParse(msg.params);
421
- if (parsed.success) this.handleEnvelope(parsed.data);
482
+ try {
483
+ // Law 6: an unknown event TYPE is preserved and still advances seq.
484
+ // Only a malformed envelope or a malformed KNOWN event lands here —
485
+ // a server bug, which must not be papered over by advancing past it.
486
+ this.handleEnvelope(decodeWireEventEnvelope(msg.params));
487
+ } catch {
488
+ // undeliverable; the next event's seq gap raises it honestly
489
+ }
422
490
  }
423
491
  }
424
492
 
425
493
  /**
426
494
  * Seq discipline: drop duplicates, deliver contiguous, hold back and heal
427
495
  * gaps via replay. Events are always delivered in seq order per session.
496
+ * The verdicts come from the shared `SeqCursor`; this method owns only what
497
+ * a client does about them (queue, replay, deliver).
428
498
  */
429
- private handleEnvelope(envelope: WireEventEnvelope): void {
499
+ private handleEnvelope(envelope: DecodedWireEventEnvelope): void {
430
500
  const sessionId = envelope.sessionId;
431
- const last = this.lastSeq.get(sessionId) ?? 0;
432
- if (envelope.seq <= last) return; // duplicate (e.g. replay overlap)
501
+ // While a replay is in flight, every envelope queues: the drain re-runs
502
+ // the cursor over the merged (replayed + live) backlog in seq order.
433
503
  if (this.syncing.has(sessionId)) {
434
504
  this.holdBack(sessionId, envelope);
435
505
  return;
436
506
  }
437
- if (envelope.seq === last + 1) {
438
- this.deliver(envelope);
439
- this.drainHeld(sessionId);
440
- } else {
441
- this.holdBack(sessionId, envelope);
442
- void this.syncSession(sessionId);
507
+ switch (this.cursor.advance(sessionId, envelope.seq)) {
508
+ case "duplicate":
509
+ return; // e.g. a replay overlap
510
+ case "reset":
511
+ // Defense-in-depth. Real restarts are detected by the handshake
512
+ // (`instanceId` at re-initialize) before any envelope flows — this
513
+ // arm fires only if a fresh log appears with NO transport drop,
514
+ // which no current topology produces. If it does: the queue is
515
+ // dead-log state, the fresh frame is authoritative.
516
+ this.heldBack.delete(sessionId);
517
+ this.deliver(envelope);
518
+ return;
519
+ case "deliver":
520
+ this.deliver(envelope);
521
+ this.drainHeld(sessionId);
522
+ return;
523
+ case "gap":
524
+ this.holdBack(sessionId, envelope);
525
+ void this.syncSession(sessionId);
526
+ return;
443
527
  }
444
528
  }
445
529
 
446
- private holdBack(sessionId: string, envelope: WireEventEnvelope): void {
530
+ private holdBack(sessionId: string, envelope: DecodedWireEventEnvelope): void {
447
531
  const held = this.heldBack.get(sessionId) ?? [];
448
532
  held.push(envelope);
449
533
  this.heldBack.set(sessionId, held);
@@ -454,13 +538,21 @@ export class AgentServerClient {
454
538
  if (!held?.length) return;
455
539
  held.sort((a, b) => a.seq - b.seq);
456
540
  while (held.length) {
457
- const next = held[0] as WireEventEnvelope;
458
- const last = this.lastSeq.get(sessionId) ?? 0;
459
- if (next.seq <= last) held.shift();
460
- else if (next.seq === last + 1) {
461
- held.shift();
541
+ const next = held[0] as DecodedWireEventEnvelope;
542
+ // `gap` leaves the cursor untouched, so breaking here is safe: the
543
+ // envelope stays queued for the replay that fills the hole.
544
+ const verdict = this.cursor.advance(sessionId, next.seq);
545
+ if (verdict === "gap") break;
546
+ held.shift();
547
+ if (verdict === "reset") {
548
+ // Defense-in-depth only (see the reset arm in handleEnvelope):
549
+ // drop the queue, deliver the fresh frame, resync for its tail.
550
+ held.length = 0;
462
551
  this.deliver(next);
463
- } else break;
552
+ void this.syncSession(sessionId);
553
+ break;
554
+ }
555
+ if (verdict !== "duplicate") this.deliver(next);
464
556
  }
465
557
  if (!held.length) this.heldBack.delete(sessionId);
466
558
  }
@@ -472,7 +564,7 @@ export class AgentServerClient {
472
564
  const maxAttempts = Math.max(1, this.options.maxReplayAttempts ?? 3);
473
565
  const retryDelay = this.options.replayRetryDelayMs ?? this.options.reconnectDelayMs ?? 250;
474
566
  for (let attempt = 1; attempt <= maxAttempts && !this.closed && this.transport; attempt++) {
475
- const fromSeq = (this.lastSeq.get(sessionId) ?? 0) + 1;
567
+ const fromSeq = this.cursor.last(sessionId) + 1;
476
568
  try {
477
569
  const replayed = await this.replay(sessionId, fromSeq);
478
570
  if (replayed.firstAvailableSeq !== null && replayed.firstAvailableSeq > fromSeq) {
@@ -484,7 +576,7 @@ export class AgentServerClient {
484
576
  `events ${fromSeq}..${replayed.firstAvailableSeq - 1} for session ${sessionId} were evicted`,
485
577
  ),
486
578
  );
487
- this.lastSeq.set(sessionId, replayed.firstAvailableSeq - 1);
579
+ this.cursor.seek(sessionId, replayed.firstAvailableSeq - 1);
488
580
  }
489
581
  for (const envelope of replayed.events) this.holdBack(sessionId, envelope);
490
582
  break;
@@ -494,7 +586,7 @@ export class AgentServerClient {
494
586
  sessionId,
495
587
  new EventGapError(`session ${sessionId} is unknown to the server (restarted?)`),
496
588
  );
497
- this.lastSeq.delete(sessionId);
589
+ this.cursor.reset(sessionId);
498
590
  this.heldBack.delete(sessionId);
499
591
  break;
500
592
  }
@@ -518,8 +610,8 @@ export class AgentServerClient {
518
610
  this.drainHeld(sessionId);
519
611
  }
520
612
 
521
- private deliver(envelope: WireEventEnvelope): void {
522
- this.lastSeq.set(envelope.sessionId, envelope.seq);
613
+ /** Dispatch an envelope the cursor already accepted (it owns the watermark). */
614
+ private deliver(envelope: DecodedWireEventEnvelope): void {
523
615
  for (const handler of [...this.eventHandlers]) {
524
616
  try {
525
617
  handler(envelope);
@@ -529,10 +621,11 @@ export class AgentServerClient {
529
621
  }
530
622
  const turn = this.turnBySession.get(envelope.sessionId);
531
623
  if (!turn) return;
532
- turn.queue.push(envelope.event);
533
- if (envelope.event.type === "turn.ended" && envelope.event.turnId === turn.turnId) {
624
+ const event = envelope.event;
625
+ turn.queue.push(event);
626
+ if (!isUnknownEvent(event) && event.type === "turn.ended" && event.turnId === turn.turnId) {
534
627
  this.turnBySession.delete(envelope.sessionId);
535
- turn.resolveResult(envelope.event);
628
+ turn.resolveResult(event);
536
629
  turn.queue.end();
537
630
  }
538
631
  }
@@ -114,6 +114,15 @@ export async function answerPermission(
114
114
  if (!turn?.broker || !asks) return autoDecide(request, turn?.mode);
115
115
  const decision = await turn.broker(permissionToolCall(request), { signal: turn.signal });
116
116
  if (decision.decision === "cancel") return CANCELLED_OUTCOME;
117
+ // `decision.updatedInput` is unrepresentable here: ACP v1's outcome carries
118
+ // an optionId and nothing else, so an edited input cannot be honoured — and
119
+ // silently running the ORIGINAL is exactly what the field exists to prevent.
120
+ // An edited approval therefore DENIES: the broker authorized a call this
121
+ // protocol cannot deliver. (The claude-code harness, via canUseTool, is the
122
+ // one that can execute the edit.)
123
+ if (decision.decision === "allow" && decision.updatedInput !== undefined) {
124
+ return chooseOutcome(request.options, false);
125
+ }
117
126
  return chooseOutcome(request.options, decision.decision === "allow");
118
127
  }
119
128
 
@@ -69,7 +69,7 @@ export function toPromptBlocks(input: AgentInput, imagesSupported: boolean): Con
69
69
  blocks.push({ type: "text", text: part.text });
70
70
  } else if (part.type === "image" && imagesSupported) {
71
71
  if (part.data) {
72
- blocks.push({ type: "image", data: part.data, mimeType: part.mediaType });
72
+ blocks.push({ type: "image", data: part.data, mimeType: part.mimeType });
73
73
  } else if (part.url) {
74
74
  blocks.push({ type: "resource_link", uri: part.url, name: part.url });
75
75
  }
@@ -125,8 +125,8 @@ export function autoDecide(
125
125
  ): RequestPermissionResponse {
126
126
  const kind = request.toolCall.kind;
127
127
  const allowed =
128
- mode === "bypassPermissions" ||
129
- (mode === "acceptEdits" && (kind === "edit" || kind === "read"));
128
+ mode === "bypass_permissions" ||
129
+ (mode === "accept_edits" && (kind === "edit" || kind === "read"));
130
130
  return chooseOutcome(request.options, allowed);
131
131
  }
132
132
 
@@ -14,7 +14,15 @@ import type {
14
14
  * `allow`/`deny`, codex `accept`/`decline`/`cancel`).
15
15
  */
16
16
  export type PermissionDecision =
17
- | { decision: "allow" }
17
+ | {
18
+ decision: "allow";
19
+ /**
20
+ * Allow-with-modified-input (`permission.resolved.outcome.updatedInput`):
21
+ * the harness MUST execute this input, not the original — what was
22
+ * approved is what runs. Absent = the original input was approved as-is.
23
+ */
24
+ updatedInput?: Record<string, unknown>;
25
+ }
18
26
  | { decision: "deny"; reason?: string }
19
27
  /** The turn is being cancelled — abort rather than merely skip the tool. */
20
28
  | { decision: "cancel" };