@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/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.
|
|
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", "
|
|
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": "
|
|
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
|
}
|
package/src/client/client.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AsyncQueue,
|
|
3
|
-
type
|
|
4
|
-
|
|
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
|
-
/**
|
|
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<
|
|
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<
|
|
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
|
-
|
|
121
|
-
private readonly
|
|
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:
|
|
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<
|
|
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 `{
|
|
282
|
-
* killing the turn that replaced it. `
|
|
283
|
-
* the harness did not acknowledge the interrupt — the agent may still
|
|
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<
|
|
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:
|
|
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.
|
|
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
|
-
|
|
421
|
-
|
|
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:
|
|
499
|
+
private handleEnvelope(envelope: DecodedWireEventEnvelope): void {
|
|
430
500
|
const sessionId = envelope.sessionId;
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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:
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
533
|
-
|
|
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(
|
|
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.
|
|
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 === "
|
|
129
|
-
(mode === "
|
|
128
|
+
mode === "bypass_permissions" ||
|
|
129
|
+
(mode === "accept_edits" && (kind === "edit" || kind === "read"));
|
|
130
130
|
return chooseOutcome(request.options, allowed);
|
|
131
131
|
}
|
|
132
132
|
|
package/src/core/agents/base.ts
CHANGED
|
@@ -14,7 +14,15 @@ import type {
|
|
|
14
14
|
* `allow`/`deny`, codex `accept`/`decline`/`cancel`).
|
|
15
15
|
*/
|
|
16
16
|
export type PermissionDecision =
|
|
17
|
-
| {
|
|
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" };
|