@naswerks/periscope 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/SECURITY.md +315 -0
- package/dist/bin/command.d.ts +97 -0
- package/dist/bin/command.js +141 -0
- package/dist/bin/config.d.ts +7 -0
- package/dist/bin/config.js +90 -0
- package/dist/bin/login.d.ts +48 -0
- package/dist/bin/login.js +82 -0
- package/dist/bin/main.d.ts +33 -0
- package/dist/bin/main.js +173 -0
- package/dist/bin/pair.d.ts +28 -0
- package/dist/bin/pair.js +198 -0
- package/dist/bin/periscope.d.ts +3 -0
- package/dist/bin/periscope.js +4 -0
- package/dist/bin/posture.d.ts +64 -0
- package/dist/bin/posture.js +157 -0
- package/dist/bin/reconfigure.d.ts +64 -0
- package/dist/bin/reconfigure.js +195 -0
- package/dist/bin/serve.d.ts +103 -0
- package/dist/bin/serve.js +441 -0
- package/dist/bin/status.d.ts +11 -0
- package/dist/bin/status.js +49 -0
- package/dist/bin/workspaces.d.ts +109 -0
- package/dist/bin/workspaces.js +215 -0
- package/dist/control/backoff.d.ts +25 -0
- package/dist/control/backoff.js +20 -0
- package/dist/control/codec.d.ts +15 -0
- package/dist/control/codec.js +501 -0
- package/dist/control/credential.d.ts +23 -0
- package/dist/control/credential.js +8 -0
- package/dist/control/frames.d.ts +1180 -0
- package/dist/control/frames.js +441 -0
- package/dist/control/link-state.d.ts +32 -0
- package/dist/control/link-state.js +39 -0
- package/dist/control/link.d.ts +109 -0
- package/dist/control/link.js +736 -0
- package/dist/control/queue.d.ts +110 -0
- package/dist/control/queue.js +174 -0
- package/dist/control/seq.d.ts +49 -0
- package/dist/control/seq.js +52 -0
- package/dist/control/stream-routing.d.ts +200 -0
- package/dist/control/stream-routing.js +167 -0
- package/dist/control/stream.d.ts +43 -0
- package/dist/control/stream.js +64 -0
- package/dist/core/async-queue.d.ts +26 -0
- package/dist/core/async-queue.js +63 -0
- package/dist/core/failure.d.ts +22 -0
- package/dist/core/failure.js +53 -0
- package/dist/core/index.d.ts +18 -0
- package/dist/core/index.js +7 -0
- package/dist/core/keyed-turns.d.ts +28 -0
- package/dist/core/keyed-turns.js +44 -0
- package/dist/core/paths.d.ts +25 -0
- package/dist/core/paths.js +77 -0
- package/dist/core/refusal.d.ts +43 -0
- package/dist/core/refusal.js +399 -0
- package/dist/core/result.d.ts +22 -0
- package/dist/core/result.js +16 -0
- package/dist/core/time.d.ts +19 -0
- package/dist/core/time.js +25 -0
- package/dist/core/vocab.d.ts +23 -0
- package/dist/core/vocab.js +37 -0
- package/dist/core/workspace-id.d.ts +12 -0
- package/dist/core/workspace-id.js +12 -0
- package/dist/gate/command.d.ts +136 -0
- package/dist/gate/command.js +551 -0
- package/dist/gate/decision.d.ts +111 -0
- package/dist/gate/decision.js +97 -0
- package/dist/gate/escalate.d.ts +82 -0
- package/dist/gate/escalate.js +92 -0
- package/dist/gate/gate.d.ts +173 -0
- package/dist/gate/gate.js +417 -0
- package/dist/gate/index.d.ts +39 -0
- package/dist/gate/index.js +31 -0
- package/dist/gate/jail.d.ts +92 -0
- package/dist/gate/jail.js +131 -0
- package/dist/gate/local.d.ts +69 -0
- package/dist/gate/local.js +51 -0
- package/dist/gate/outcome.d.ts +72 -0
- package/dist/gate/outcome.js +91 -0
- package/dist/gate/shell.d.ts +36 -0
- package/dist/gate/shell.js +226 -0
- package/dist/host/agent-process.d.ts +452 -0
- package/dist/host/agent-process.js +382 -0
- package/dist/host/bulk-post.d.ts +44 -0
- package/dist/host/bulk-post.js +114 -0
- package/dist/host/claude-transcripts.d.ts +84 -0
- package/dist/host/claude-transcripts.js +339 -0
- package/dist/host/config-file.d.ts +60 -0
- package/dist/host/config-file.js +216 -0
- package/dist/host/git-facts.d.ts +7 -0
- package/dist/host/git-facts.js +106 -0
- package/dist/host/hooks.d.ts +56 -0
- package/dist/host/hooks.js +75 -0
- package/dist/host/host.d.ts +356 -0
- package/dist/host/host.js +1294 -0
- package/dist/host/index.d.ts +35 -0
- package/dist/host/index.js +22 -0
- package/dist/host/link-state-file.d.ts +18 -0
- package/dist/host/link-state-file.js +66 -0
- package/dist/host/loopback.d.ts +38 -0
- package/dist/host/loopback.js +122 -0
- package/dist/host/machine.d.ts +9 -0
- package/dist/host/machine.js +19 -0
- package/dist/host/mcp-server.d.ts +11 -0
- package/dist/host/mcp-server.js +48 -0
- package/dist/host/package-facts.d.ts +3 -0
- package/dist/host/package-facts.js +26 -0
- package/dist/host/paired-credential-store.d.ts +32 -0
- package/dist/host/paired-credential-store.js +112 -0
- package/dist/host/paths.d.ts +80 -0
- package/dist/host/paths.js +165 -0
- package/dist/host/repository-read.d.ts +33 -0
- package/dist/host/repository-read.js +201 -0
- package/dist/host/session-store.d.ts +53 -0
- package/dist/host/session-store.js +100 -0
- package/dist/host/sign-in.d.ts +90 -0
- package/dist/host/sign-in.js +239 -0
- package/dist/host/telemetry.d.ts +42 -0
- package/dist/host/telemetry.js +74 -0
- package/dist/host/token-cache.d.ts +62 -0
- package/dist/host/token-cache.js +185 -0
- package/dist/host/transcript-fs.d.ts +4 -0
- package/dist/host/transcript-fs.js +104 -0
- package/dist/host/wire-request.d.ts +80 -0
- package/dist/host/wire-request.js +196 -0
- package/dist/host/workspace-fs.d.ts +14 -0
- package/dist/host/workspace-fs.js +84 -0
- package/dist/host/workspace-trust.d.ts +23 -0
- package/dist/host/workspace-trust.js +80 -0
- package/dist/identity/authorize.d.ts +59 -0
- package/dist/identity/authorize.js +72 -0
- package/dist/identity/config.d.ts +98 -0
- package/dist/identity/config.js +130 -0
- package/dist/identity/credential.d.ts +91 -0
- package/dist/identity/credential.js +76 -0
- package/dist/identity/device-code.d.ts +115 -0
- package/dist/identity/device-code.js +134 -0
- package/dist/identity/index.d.ts +33 -0
- package/dist/identity/index.js +10 -0
- package/dist/identity/mode.d.ts +106 -0
- package/dist/identity/mode.js +69 -0
- package/dist/identity/paired-credential.d.ts +52 -0
- package/dist/identity/paired-credential.js +43 -0
- package/dist/identity/pkce.d.ts +50 -0
- package/dist/identity/pkce.js +94 -0
- package/dist/identity/store.d.ts +43 -0
- package/dist/identity/store.js +43 -0
- package/dist/identity/token.d.ts +58 -0
- package/dist/identity/token.js +149 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +53 -0
- package/dist/mcp/descriptor.d.ts +59 -0
- package/dist/mcp/descriptor.js +14 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +4 -0
- package/dist/mcp/schema.d.ts +37 -0
- package/dist/mcp/schema.js +175 -0
- package/dist/mcp/server.d.ts +107 -0
- package/dist/mcp/server.js +66 -0
- package/dist/persistence/entry.d.ts +74 -0
- package/dist/persistence/entry.js +105 -0
- package/dist/persistence/index.d.ts +22 -0
- package/dist/persistence/index.js +8 -0
- package/dist/persistence/key.d.ts +46 -0
- package/dist/persistence/key.js +33 -0
- package/dist/persistence/mirror.d.ts +71 -0
- package/dist/persistence/mirror.js +57 -0
- package/dist/persistence/receipt.d.ts +111 -0
- package/dist/persistence/receipt.js +85 -0
- package/dist/persistence/retention.d.ts +68 -0
- package/dist/persistence/retention.js +68 -0
- package/dist/persistence/store.d.ts +106 -0
- package/dist/persistence/store.js +86 -0
- package/dist/persistence/transition-log.d.ts +73 -0
- package/dist/persistence/transition-log.js +133 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.js +12 -0
- package/dist/sessions/index.d.ts +8 -0
- package/dist/sessions/index.js +4 -0
- package/dist/sessions/registry.d.ts +186 -0
- package/dist/sessions/registry.js +190 -0
- package/dist/sessions/session.d.ts +178 -0
- package/dist/sessions/session.js +288 -0
- package/dist/sessions/spawn-env.d.ts +88 -0
- package/dist/sessions/spawn-env.js +277 -0
- package/dist/state/coverage.d.ts +308 -0
- package/dist/state/coverage.js +315 -0
- package/dist/state/index.d.ts +12 -0
- package/dist/state/index.js +7 -0
- package/dist/state/machine.d.ts +112 -0
- package/dist/state/machine.js +237 -0
- package/dist/state/model.d.ts +233 -0
- package/dist/state/model.js +214 -0
- package/dist/state/observer.d.ts +55 -0
- package/dist/state/observer.js +413 -0
- package/dist/state/reporter.d.ts +45 -0
- package/dist/state/reporter.js +35 -0
- package/dist/state/store.d.ts +56 -0
- package/dist/state/store.js +120 -0
- package/dist/telemetry/index.d.ts +11 -0
- package/dist/telemetry/index.js +2 -0
- package/dist/telemetry/usage.d.ts +78 -0
- package/dist/telemetry/usage.js +69 -0
- package/dist/workspace/git-worktree.d.ts +150 -0
- package/dist/workspace/git-worktree.js +417 -0
- package/dist/workspace/index.d.ts +9 -0
- package/dist/workspace/index.js +4 -0
- package/dist/workspace/plain-dir.d.ts +34 -0
- package/dist/workspace/plain-dir.js +90 -0
- package/dist/workspace/provider.d.ts +152 -0
- package/dist/workspace/provider.js +2 -0
- package/dist/workspace/worktree-porcelain.d.ts +29 -0
- package/dist/workspace/worktree-porcelain.js +100 -0
- package/docs/architecture.md +277 -0
- package/docs/configuration.md +187 -0
- package/docs/gate.md +219 -0
- package/docs/identity.md +107 -0
- package/docs/protocol.md +501 -0
- package/docs/state-machine.md +160 -0
- package/examples/README.md +141 -0
- package/examples/minimal-controller/controller.ts +38 -0
- package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
- package/examples/parallel-run-proof/run.ts +850 -0
- package/examples/test-controller/controller.ts +655 -0
- package/examples/test-controller/serve.ts +29 -0
- package/examples/tsconfig.json +30 -0
- package/package.json +98 -0
package/docs/protocol.md
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# The wire protocol
|
|
2
|
+
|
|
3
|
+
The contract a controller implements: the frame envelope, the payload kinds, the sequence and
|
|
4
|
+
retention rules, the two lanes, the bulk lane, the host-scoped asks, the refusal vocabulary and the
|
|
5
|
+
handshake. The `./protocol` subpath ships these types and the codec without anything that can reach
|
|
6
|
+
a process or a disk; `contracts/wire-vectors/` is the same contract as bytes, one JSON document per
|
|
7
|
+
case, which a controller in any language can read.
|
|
8
|
+
|
|
9
|
+
The protocol version is `PROTOCOL_VERSION`; the versions a host still speaks run from
|
|
10
|
+
`PROTOCOL_VERSION_MIN` to it. Both are exported and both are mirrored into the vectors.
|
|
11
|
+
|
|
12
|
+
## The envelope
|
|
13
|
+
|
|
14
|
+
Every frame is `{ frame, at, payload }`, with `payload` discriminated on `kind`. Keys are camelCase
|
|
15
|
+
and discriminator values are snake_case. Session frames add `sessionId` and `seq`:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
{ frame: 'session', sessionId: 's-1', seq: 4, at: '2026-08-03T12:00:00.000Z',
|
|
19
|
+
payload: { kind: 'session_update', body: { ... } } }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Control frames (`link_hello`, `link_welcome`, `link_ack`, `link_ping`, `link_pong`, `link_bye`)
|
|
23
|
+
carry no `sessionId` and no `seq`: they describe the link, and a link that dropped has no history
|
|
24
|
+
worth resending.
|
|
25
|
+
|
|
26
|
+
Optionality on the wire is `T | null`, never an absent key: JSON has no `undefined`, and an omitted
|
|
27
|
+
member cannot be told from a member set to nothing after a round trip. Every member a kind declares
|
|
28
|
+
is required; a frame missing one is refused `frame-malformed` by whichever side sees it first.
|
|
29
|
+
|
|
30
|
+
`encode` and `decode` validate the same shapes. A frame that would not decode does not encode
|
|
31
|
+
either, so a malformed frame is refused at its author and never occupies a sequence number.
|
|
32
|
+
|
|
33
|
+
## Sequence numbers
|
|
34
|
+
|
|
35
|
+
1. Per session, per direction. Two sessions on one link count independently; the two directions of
|
|
36
|
+
one session count independently. The first frame is `1`; `0` means "nothing yet" and is refused.
|
|
37
|
+
2. Dense. Each frame is the previous plus one, so the receiver's expected next is always
|
|
38
|
+
`last + 1` and a gap is arithmetic, never a heuristic.
|
|
39
|
+
3. Minted at the first write. A `seq` is assigned when its frame is first written to the socket,
|
|
40
|
+
not when it is queued, so anything refused or dropped before that moment leaves no hole.
|
|
41
|
+
4. Reconnect is idempotent. The wire is at-least-once; the receiver's `SeqTracker` makes it
|
|
42
|
+
exactly-once. A re-delivered frame is a silent duplicate. A missing frame is a gap, reported on
|
|
43
|
+
the wire as a `wire_refusal` with reason `seq-gap` and the `expected` number the sender must
|
|
44
|
+
resume from; a frame below the expected position is `seq-regressed`.
|
|
45
|
+
|
|
46
|
+
A frame is retained by its sender until the receiver acknowledges it with `link_ack`, which carries
|
|
47
|
+
one cursor `{ sessionId, seq }` per session. Being written is not being received: a frame in flight
|
|
48
|
+
when a socket dies is the one replay must produce. On reconnect the host sends `link_hello` with the
|
|
49
|
+
cursors it holds, the controller answers `link_welcome` with the cursors it holds, and each side
|
|
50
|
+
replays what the other has not acknowledged, with the original sequence numbers.
|
|
51
|
+
|
|
52
|
+
The sender's mechanics (`src/control/queue.ts`, `src/control/link.ts`): `send()` is admit then
|
|
53
|
+
drain. A payload is probe-encoded with the widest sequence number a frame can carry, then held
|
|
54
|
+
pending and unnumbered in `BoundedFrameQueue`; the drain stamps the oldest pending entry at the
|
|
55
|
+
moment of the socket write. FIFO through one queue means a later send never overtakes a waiting
|
|
56
|
+
frame. At capacity the ladder is: discard the incoming droppable, displace the oldest pending
|
|
57
|
+
droppable, refuse loudly (`queue-overflow-undroppable`). Written frames are never victims.
|
|
58
|
+
|
|
59
|
+
## The handshake
|
|
60
|
+
|
|
61
|
+
The host dials and sends `link_hello`:
|
|
62
|
+
|
|
63
|
+
| member | meaning |
|
|
64
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
65
|
+
| `protocolVersion` | the newest version the host speaks |
|
|
66
|
+
| `protocolRange` | `{ min, max }`, every version the host speaks, inclusive |
|
|
67
|
+
| `hostId` | the id the host announces; a paired credential's host id when one is presented |
|
|
68
|
+
| `capabilities` | an open list of markers: `bulk-post` always; exactly one `workspace:<mode>` (`workspace:none`, `workspace:plain` or `workspace:git-worktree`) always; `workspace:branch-scheme` when a scheme is set. A controller ignores what it does not know |
|
|
69
|
+
| `cursors` | the host's inbound positions, one per session |
|
|
70
|
+
| `configuration` | the host's effective settings, reported and never negotiated (below) |
|
|
71
|
+
| `pendingRestart` | the setting keys written over the link that apply only at the host's next start |
|
|
72
|
+
|
|
73
|
+
The controller answers `link_welcome` with `protocolVersion` set to the version it chose inside the
|
|
74
|
+
overlap of the two windows, its own `capabilities`, and its inbound `cursors`. The host accepts any
|
|
75
|
+
version inside its own window and refuses the rest, naming both windows. A controller that finds no
|
|
76
|
+
overlap sends no welcome and closes the socket with code 1002 and a reason naming both windows,
|
|
77
|
+
which the host reads as the same refusal (`protocol_version_rejected`, a link cause). The window
|
|
78
|
+
opens at `PROTOCOL_VERSION_MIN`; from the next bump on it is one minor wide, the version before the
|
|
79
|
+
current one staying supported for one release, so a controller and a host one release apart connect
|
|
80
|
+
and either can move first.
|
|
81
|
+
|
|
82
|
+
A welcome does not carry a range. A hello with no range does not decode: `protocolRange` is a
|
|
83
|
+
declared member. A new value in the open `capabilities` list needs no version bump; a new hello
|
|
84
|
+
member does.
|
|
85
|
+
|
|
86
|
+
Two close reasons carry meaning. A close with code 1002 whose reason starts with `seq gap` is a
|
|
87
|
+
replay request: the controller names the position it holds, and the host's next dial replays from
|
|
88
|
+
it (link cause `replay_requested`). Any other 1002 close is read as the version refusal above.
|
|
89
|
+
|
|
90
|
+
`configuration` is a `HostConfiguration`: seven strings, each `T | null`, in this order:
|
|
91
|
+
`repositoryRoot`, `workspaceRoot`, `branchScheme` (the effective scheme, `{repo}/{key}` when none is
|
|
92
|
+
configured), `transcriptsRoot` (derived from the agent home; reported, never settable on its own),
|
|
93
|
+
`controllerUrl`, `decisionUrl`, `agentHome`. Each value is at most `MAX_CONFIGURATION_VALUE_LENGTH`.
|
|
94
|
+
|
|
95
|
+
## The credential on the three transports
|
|
96
|
+
|
|
97
|
+
A host presents one credential, the same on every transport it uses, as an `Authorization` header:
|
|
98
|
+
`Authorization: Bearer <credential>` on the WebSocket upgrade request, on every decision POST and
|
|
99
|
+
on every bulk POST. What the bearer is depends on how the host was set up: a paired machine
|
|
100
|
+
credential (below), a signed-in user's access token, or nothing, in which case the host connects
|
|
101
|
+
with no header and says so at start-up.
|
|
102
|
+
|
|
103
|
+
What a controller does with it:
|
|
104
|
+
|
|
105
|
+
- At the upgrade, answer 401 or 403 to refuse the credential. The host reads either as
|
|
106
|
+
`link-unauthorized`, which is terminal: the process exits non-zero naming the remedy (`periscope
|
|
107
|
+
pair <code>` for a paired host, `periscope login` otherwise), because redialling a door that has
|
|
108
|
+
refused the identity is a loop with no exit. Any other failure to open is retried with backoff.
|
|
109
|
+
- On a decision POST, answer non-2xx to refuse the credential. The host reads it as an outage, and
|
|
110
|
+
an outage refuses the tool (`permission-decision-unavailable`); nothing is retried.
|
|
111
|
+
- On a bulk POST, answer non-2xx to refuse. The host reports `bulk_failed` with
|
|
112
|
+
`bulk-delivery-failed`.
|
|
113
|
+
- A controller MAY bind the hello's `hostId` to the credential it minted and refuse a hello whose
|
|
114
|
+
`hostId` is not the one the bearer speaks for. A paired host announces the id embedded in its
|
|
115
|
+
credential, so the two agree unless the file was tampered with.
|
|
116
|
+
|
|
117
|
+
## Pairing
|
|
118
|
+
|
|
119
|
+
Pairing is how an unattended host gets a durable credential without a user's token expiring under
|
|
120
|
+
it. It is an HTTP exchange the controller serves beside the link; nothing about it rides the wire.
|
|
121
|
+
|
|
122
|
+
1. A signed-in user asks the controller for a short-lived, single-use pair code. How the code is
|
|
123
|
+
minted is the controller's own (the reference controller serves `POST /api/periscope/pair-codes`;
|
|
124
|
+
yours may differ).
|
|
125
|
+
2. The operator runs `periscope pair <code> --controller <origin> --label <name>` on the machine.
|
|
126
|
+
The host POSTs to the redemption route: `--controller`'s origin plus `/api/periscope/pair` when
|
|
127
|
+
the flag is given, else `PERISCOPE_PAIR_URL` verbatim, else the origin of `PERISCOPE_DECISION_URL`
|
|
128
|
+
plus that route. Request: `content-type: application/json`, body `{ "code": string,
|
|
129
|
+
"machineLabel": string }` (`--label`, else `PERISCOPE_MACHINE_LABEL`, else the hostname).
|
|
130
|
+
3. The controller answers 2xx with a JSON object carrying `hostId` (string, non-empty) and
|
|
131
|
+
`hostCredential` (string, the bearer), and optionally `controllerUrl` (`ws:`/`wss:`) and
|
|
132
|
+
`decisionUrl` (`http:`/`https:`): the two addresses this host should dial. When both are present
|
|
133
|
+
the host writes them to its config file, so `serve` needs nothing else. Any non-2xx is read as a
|
|
134
|
+
refused code (unknown, expired and consumed answer identically; the remedy is a fresh code), except
|
|
135
|
+
404 and 405, which the host names as a wrong door.
|
|
136
|
+
4. **The bearer has a shape, and it is an obligation:** `p1.<hostId>.<secret>`, where `<hostId>` is
|
|
137
|
+
exactly the `hostId` in the same answer and `<secret>` is non-empty. The host reads its own id out
|
|
138
|
+
of the credential at every start and refuses a file whose two copies disagree; `pair` refuses an
|
|
139
|
+
answer outside this shape before writing anything. The controller stores a hash of the secret and
|
|
140
|
+
this machine holds the one copy.
|
|
141
|
+
5. From then on the host presents `Authorization: Bearer p1.<hostId>.<secret>` on all three
|
|
142
|
+
transports and announces `<hostId>` in its hello.
|
|
143
|
+
|
|
144
|
+
Revocation is the controller's: refuse the bearer at the upgrade (401/403) and close the link; the
|
|
145
|
+
host exits naming the remedy. Re-pairing mints a new bearer; a controller should invalidate the old
|
|
146
|
+
one when it does.
|
|
147
|
+
|
|
148
|
+
## Heartbeat and close
|
|
149
|
+
|
|
150
|
+
The host sends `link_ping` with a `nonce` every `heartbeatIntervalMs` (default 15 s) and expects
|
|
151
|
+
`link_pong` with the same nonce inside `heartbeatTimeoutMs` (default 45 s); a missed pong tears the
|
|
152
|
+
socket down and re-dials, as does a dial that produces no open inside `connectTimeoutMs` (default
|
|
153
|
+
15 s). The three are `PeriscopeHostOptions.linkTimings`; the defaults are `DEFAULTS` in
|
|
154
|
+
`src/control/link.ts`. The controller may ping too and the host answers. Control frames are written
|
|
155
|
+
ahead of any queued session frame, so a heartbeat is never delayed by a backed-up session lane.
|
|
156
|
+
`link_bye` carries a `cause` and ends the link on purpose.
|
|
157
|
+
|
|
158
|
+
The link's own states are `idle`, `connecting`, `open`, `accepted` (the welcome arrived), `backoff`
|
|
159
|
+
and `closed`; `LINK_CAUSES` (`src/control/link-state.ts`) is the closed list of what moves it. A
|
|
160
|
+
credential the controller refuses at the upgrade is the one cause that ends in `closed` without a
|
|
161
|
+
shutdown having been requested.
|
|
162
|
+
|
|
163
|
+
## The two lanes
|
|
164
|
+
|
|
165
|
+
`session_update` carries facts: an assistant message, a result with its usage, every state
|
|
166
|
+
transition the session machine records, the session's end, and a `wire_refusal`. It is retained
|
|
167
|
+
until acknowledged and replayed after a reconnect.
|
|
168
|
+
|
|
169
|
+
`session_delta` carries fragments something later restates: streamed text, thinking prose,
|
|
170
|
+
progress. It is the only droppable kind (`DROPPABLE_KINDS`): a full outbound queue discards deltas
|
|
171
|
+
and refuses to lose anything else. A delta that has already been numbered is still retransmitted
|
|
172
|
+
after a drop, because a minted number must be accounted for or the receiver holds a gap it can
|
|
173
|
+
never fill. A delta never enters a durable store; the two halves of that rule point opposite ways
|
|
174
|
+
and both are pinned (`stream-replay.test.ts`).
|
|
175
|
+
|
|
176
|
+
`MESSAGE_ROUTING` (`src/control/stream-routing.ts`) names the lane for every SDK message
|
|
177
|
+
discriminator: delta, update or declined with a reason. `forwardSession` puts each message on its
|
|
178
|
+
lane and then the transitions that message caused, message first, so a consumer never sees a state
|
|
179
|
+
change referring to a message it does not have.
|
|
180
|
+
|
|
181
|
+
`session_update.body` is an open JSON object read through typed readers: `readStateTransition`,
|
|
182
|
+
`readAgentMessage`, `readWireRefusal` (`{ refusal, expected, received }`; `expected` is the seq the
|
|
183
|
+
receiver will accept next on the refused party's own outbound lane). `wire_refusal` is a body
|
|
184
|
+
member, not a payload kind, so a refusal rides the same fact lane it refuses. A controller carries
|
|
185
|
+
unknown keys through and must not narrow the body.
|
|
186
|
+
|
|
187
|
+
A consumer folding deltas into rendered state must return a new top-level reference for every real
|
|
188
|
+
change and the same reference for a true no-op. Hosts bind rendered state through a default
|
|
189
|
+
reference-equality check, so a fold that mutates in place and returns the object it was given
|
|
190
|
+
produces no notification at all: mid-turn painting stops, with no error and no missing frame, and
|
|
191
|
+
only resumes when something else replaces the reference. Stated at `SessionDelta` too, because it
|
|
192
|
+
fails silently.
|
|
193
|
+
|
|
194
|
+
Thinking is a per-session knob, and its default is deliberate. `includePartialMessages` is on, so a
|
|
195
|
+
turn can be rendered as it happens; `thinking` is left at the SDK's own default, which fires
|
|
196
|
+
`thinking_delta` events with empty prose. Ask for `{ type: 'adaptive', display: 'summarized' }` in
|
|
197
|
+
`session_new.request.thinking` and real reasoning prose streams (measured against the pinned SDK: 0
|
|
198
|
+
characters by default, 227 over 4 deltas with `summarized`, same prompt). It is opt-in because that
|
|
199
|
+
prose costs tokens on the wire and puts reasoning text into transcripts and mirrors; whether a run is
|
|
200
|
+
watched is not something the host can know, so it offers the knob instead of guessing a policy.
|
|
201
|
+
|
|
202
|
+
## Under load, and overnight
|
|
203
|
+
|
|
204
|
+
| | |
|
|
205
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
206
|
+
| **Backoff** | Exponential and jittered. Without the jitter, a controller restart brings every host back in lockstep and knocks it over again |
|
|
207
|
+
| **Offline queue** | Bounded. What it drops is a decision: `session_delta` is droppable; transitions and receipts are not, and a forced drop of one is a named refusal rather than a silent loss. Only entries awaiting their first write can be dropped; a frame that has touched the wire is retained until acked, and every drop is reported, never counted silently |
|
|
208
|
+
| **Backpressure** | Above the high-water mark the link stops writing and queues. A slow controller slows the host down; it never grows its heap. The drain resumes the flow the moment the buffer empties: delivery never waits for a reconnect, and a later send never overtakes a queued frame |
|
|
209
|
+
| **Control frames bypass backpressure** | Deliberate. `link_ping`, `link_pong` and `link_bye` are written immediately whatever the buffer holds, because they are how the link's own health is judged: a heartbeat queued behind a backed-up session lane does not arrive late, it arrives after the peer has concluded the socket is dead. They are small, bounded, unsequenced and never replayed, so they cannot displace a session frame or leave a gap |
|
|
210
|
+
| **Admission is conservative by 15 bytes** | A payload within 15 bytes of `MAX_FRAME_BYTES` is refused although its real frame would have fitted, because admission probes with the widest `seq` a frame could ever carry. The alternative lets a frame pass admission and then fail to encode at write time, with the caller long gone. A rejection someone can act on beats a hole nobody can see |
|
|
211
|
+
| **Heartbeat** | Both directions. A half-open socket looks alive to TCP and reads as a hung session to a human |
|
|
212
|
+
| **Link state** | Every transition carries a cause from a closed vocabulary (`LINK_CAUSES`). A reconnect nobody can attribute makes an unattended night unreadable afterwards |
|
|
213
|
+
|
|
214
|
+
## Commands to a session
|
|
215
|
+
|
|
216
|
+
| kind | meaning |
|
|
217
|
+
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
218
|
+
| `session_new` | open a session: `cwd` (nullable; the workspace provider decides when null), `workspaceKey` (nullable; the key sessions share a tree under), `correlationId` (opaque, echoed, never interpreted), `gate` (per-session deadlines or null), `request` (the JSON-expressible subset of a session request or null). Every member of `request` is `T | null`. |
|
|
219
|
+
| `session_prompt` | queue a turn |
|
|
220
|
+
| `session_cancel` | interrupt the current turn; never ends the session |
|
|
221
|
+
| `session_configure` | apply the live setters (`model`, `permissionMode`, `thinking`), each null when not asked |
|
|
222
|
+
| `bulk_request` | ask for bulk content (below) |
|
|
223
|
+
|
|
224
|
+
`session_new.request` carries `resume`, `fork`, `settingSources`, `plugins`, `mcpServers`,
|
|
225
|
+
`strictMcpConfig`, `includePartialMessages`, `thinking`, `forwardSubagentText`, `env`, `model`,
|
|
226
|
+
`systemPrompt`, `effort` and `permissionMode` (the SDK's own vocabularies). The narrowing site from
|
|
227
|
+
a wire request to a local one is `src/host/wire-request.ts`; the members of the in-process API that
|
|
228
|
+
have no JSON form are structurally absent.
|
|
229
|
+
|
|
230
|
+
`mcpServers` is how a controller gives a session its tools. It is an object keyed by server name,
|
|
231
|
+
each value in the agent SDK's own `McpServerConfig` shape, passed to the agent as declared (a name
|
|
232
|
+
colliding with a server the host registers itself is refused). The two shapes a controller uses:
|
|
233
|
+
|
|
234
|
+
```json
|
|
235
|
+
{
|
|
236
|
+
"mcpServers": {
|
|
237
|
+
"controller": {
|
|
238
|
+
"type": "http",
|
|
239
|
+
"url": "https://controller.example/mcp",
|
|
240
|
+
"headers": { "Authorization": "Bearer <a per-session token the controller minted>" }
|
|
241
|
+
},
|
|
242
|
+
"linter": { "type": "stdio", "command": "npx", "args": ["-y", "some-mcp-server"] }
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
The HTTP form is how a controller hands a session its own tool surface with a per-session bearer;
|
|
248
|
+
the stdio form runs the named command on the host's machine, as the host's OS user, before the gate
|
|
249
|
+
sees a single tool call (see `SECURITY.md`, the reach a pairing extends). Every tool either server
|
|
250
|
+
adds is decided by the same gate as `Bash`. `strictMcpConfig` is null-for-default and the default is
|
|
251
|
+
on: only what the controller declared exists, and nothing from the machine's `.mcp.json`, user
|
|
252
|
+
settings, plugins or agent frontmatter is loaded. In-process tool descriptors are the embedder's
|
|
253
|
+
(`PeriscopeHostOptions.tools`) and have no wire lane; the `periscope` binary registers none.
|
|
254
|
+
|
|
255
|
+
`env` is `{ extraAllowedKeys, extraDeniedKeys, extraEnv }`, each nullable: names of the host's own
|
|
256
|
+
environment to re-admit into the spawn (the credential-shaped deny list still wins), names to deny,
|
|
257
|
+
and values to set outright after the allow-list has run.
|
|
258
|
+
|
|
259
|
+
Three ids ride a session and are never interchangeable: the frame's `sessionId` is the controller's
|
|
260
|
+
handle and the routing key for the session's whole life; `SessionTransition.sessionId` is the
|
|
261
|
+
agent's own id, a fact, null until the agent reports it; `correlationId` is the controller's opaque
|
|
262
|
+
meaning handle. The host never derives one from another, and a controller that uses one string for
|
|
263
|
+
the first and third must not expect the host to assume it.
|
|
264
|
+
|
|
265
|
+
A `session_new` the host will not honour answers on the wire as a `session_update` carrying a
|
|
266
|
+
transition `spawning` to `ended` with cause kind `refusal` and the reason as its event, `seq: 1`,
|
|
267
|
+
`sessionId: null` in the body and the controller's handle on the frame. Every refusal on the open
|
|
268
|
+
path rides it (`permission-grant-shadows-settings`, `workspace-provision-failed`,
|
|
269
|
+
`resume-cwd-not-honoured`, an unusable key); the consumer's job is to end its record of the session and
|
|
270
|
+
show the cause. A `session_prompt` that arrives while its session is still opening is held, then delivered,
|
|
271
|
+
refused or withdrawn; it is never dropped.
|
|
272
|
+
|
|
273
|
+
## Host-scoped asks
|
|
274
|
+
|
|
275
|
+
These address the host, not a session. Their `sessionId` is a channel the controller mints, and the
|
|
276
|
+
answer rides back on the same channel carrying the ask's `requestId`; the controller's rendezvous
|
|
277
|
+
keys on that id alone. The channel is numbered per link, not persisted: the host's counters for it
|
|
278
|
+
live for the host process, so across a reconnect they continue and across a host restart they begin
|
|
279
|
+
again at 1. A controller therefore numbers its outbound on the channel from the cursor the host
|
|
280
|
+
reports for it in the hello (1 when the hello reports none), seeds its inbound from the first frame
|
|
281
|
+
after each hello rather than from a stored cursor, and never persists either. The reference
|
|
282
|
+
controller does exactly this (`examples/test-controller/controller.ts`), and the end-to-end test
|
|
283
|
+
proves an ask after a reconnect is answered.
|
|
284
|
+
|
|
285
|
+
An answer that would exceed `MAX_FRAME_BYTES` is reported locally as a refusal and never sent, so
|
|
286
|
+
every ask must be bounded by the caller: page a listing, cap a read, and expect no answer at all
|
|
287
|
+
rather than a truncated one when a bound is missed.
|
|
288
|
+
|
|
289
|
+
| ask | answer | refusal reasons the answer may carry |
|
|
290
|
+
| ------------------------ | ------------------------------- | --------------------------------------------------------------------------------------- |
|
|
291
|
+
| `session_list` | `session_list_result` | none; `transcript_failed` on failure |
|
|
292
|
+
| `transcript_list` | `transcript_list_result` | `transcript_failed` |
|
|
293
|
+
| `transcript_tail` | `transcript_tail_result` | `transcript_failed` |
|
|
294
|
+
| `workspace_list` | `workspace_list_result` | `workspace-list-failed` |
|
|
295
|
+
| `workspace_release` | `workspace_release_result` | `workspace-release-failed`, `branch-not-merged` |
|
|
296
|
+
| `workspace_release_bulk` | `workspace_release_bulk_result` | one receipt per entry, in ask order, never aborted |
|
|
297
|
+
| `host_configure` | `host_configure_result` | `config-key-unknown`, `config-value-invalid`, `config-host-busy`, `config-write-failed` |
|
|
298
|
+
| `repository_list` | `repository_list_result` | `repository-path-escape`, `repository-read-failed` |
|
|
299
|
+
| `repository_read` | `repository_read_result` | `repository-path-escape`, `repository-read-failed` |
|
|
300
|
+
|
|
301
|
+
On every result kind that carries `refusal`, `refusal: null` is the good answer. `transcript_failed`
|
|
302
|
+
is the one failure kind for the three transcript asks; a reader discriminates on the echoed
|
|
303
|
+
`requestId`, never on the kind. A `transcript_list_result` entry carries the `cwd` the CLI recorded
|
|
304
|
+
on the transcript, null when its head carries none.
|
|
305
|
+
|
|
306
|
+
Bounds: `TRANSCRIPT_PAGE_SIZE` transcripts per page, `WORKSPACE_PAGE_SIZE` worktrees per page,
|
|
307
|
+
`MAX_BULK_RELEASES` entries per bulk release, `MAX_CONFIGURE_ENTRIES` entries per configure,
|
|
308
|
+
`MAX_REPOSITORY_ENTRIES` names per directory listing, `MAX_REPOSITORY_READ_BYTES` per file head, cut
|
|
309
|
+
on a character boundary with the file's whole size in the answer.
|
|
310
|
+
|
|
311
|
+
`workspace_release` names exactly one of `workspaceKey` or `path`, with `deleteBranch` and `force`;
|
|
312
|
+
the receipt states `directoryRemoved` and `branchDeleted` separately, and released or already
|
|
313
|
+
absent is the same answer. `host_configure` accepts the keys in `WIRE_CONFIGURABLE_KEYS`, exactly
|
|
314
|
+
these six: `PERISCOPE_WORKSPACE_ROOT`, `PERISCOPE_REPOSITORY_ROOT`, `PERISCOPE_BRANCH_SCHEME`,
|
|
315
|
+
`PERISCOPE_AGENT_HOME`, `PERISCOPE_CONTROLLER_URL`, `PERISCOPE_DECISION_URL`. It writes them to the
|
|
316
|
+
host's config file, rebuilds the workspace provider, and answers with the effective values, the
|
|
317
|
+
keys the environment shadows (`overriddenByEnvironment`), and the keys that apply only at the next
|
|
318
|
+
start.
|
|
319
|
+
The two URLs are written but never applied to the live link. A root cannot change while a session
|
|
320
|
+
is open or opening (`config-host-busy`).
|
|
321
|
+
|
|
322
|
+
## The bulk lane
|
|
323
|
+
|
|
324
|
+
A frame is at most `MAX_FRAME_BYTES`, refused on encode and on decode, so bulk content never rides
|
|
325
|
+
the link and a tool result over the cap is refused (`frame-too-large`), never truncated. The
|
|
326
|
+
controller sends `bulk_request` with a `deliveryId`, what it wants and where to POST it; the host
|
|
327
|
+
answers with a streamed HTTP POST and then `bulk_delivered` (with `byteCount` and the source's
|
|
328
|
+
`sizeBytes` and `mtimeMs` at the moment of the read) or `bulk_failed` (a named refusal). The POST
|
|
329
|
+
target's origin must be the controller's own (`bulk-target-not-controller` otherwise; a garbled URL
|
|
330
|
+
is `bulk-target-invalid`), because the host's credential rides on it; the bind is derived from
|
|
331
|
+
`PERISCOPE_CONTROLLER_URL`, never separately configured. A source whose size or modification time
|
|
332
|
+
moved between two reads must be re-read whole from zero, once; still moving, the read is refused.
|
|
333
|
+
The POST body is bounded to the declared `content-length`, never drained to EOF.
|
|
334
|
+
|
|
335
|
+
A frame budgeted against the limit must allow 15 bytes: admission is probed with the widest sequence
|
|
336
|
+
number a frame can carry, so a payload within 15 bytes of the limit is refused although its real
|
|
337
|
+
frame would fit.
|
|
338
|
+
|
|
339
|
+
`bulk_request` carries `deliveryId` (echoed on the receipt), `what` (a locator), `fromOffset` (a
|
|
340
|
+
byte offset to start from) and `postUrl`. The locator namespace this package resolves is the
|
|
341
|
+
agent CLI's own transcripts: `claude-transcript:{projectSlug}/{sessionId}`, where the prefix is
|
|
342
|
+
`TRANSCRIPT_WHAT_PREFIX`, `projectSlug` is the directory name a `transcript_list_result` entry
|
|
343
|
+
reports (opaque; the CLI's flattening of a path is not invertible) and `sessionId` is the entry's
|
|
344
|
+
id. A locator outside the namespace is refused `bulk-target-invalid`. An embedder may add
|
|
345
|
+
resolvers for other namespaces.
|
|
346
|
+
|
|
347
|
+
The POST the host sends:
|
|
348
|
+
|
|
349
|
+
| header | value |
|
|
350
|
+
| ---------------- | ---------------------------------------------------------------------------------- |
|
|
351
|
+
| `content-type` | `application/octet-stream` |
|
|
352
|
+
| `content-length` | the byte count promised, from the source's size at the read minus `fromOffset` |
|
|
353
|
+
| `x-delivery-id` | the `deliveryId` from the request |
|
|
354
|
+
| `Authorization` | the host's bearer, when it has one (the credential on the three transports, above) |
|
|
355
|
+
|
|
356
|
+
The body is the source's bytes from `fromOffset`, streamed, bounded to the declared length. A
|
|
357
|
+
2xx answer is `bulk_delivered`; any other status, or a transport failure, is `bulk_failed` with
|
|
358
|
+
`bulk-delivery-failed`.
|
|
359
|
+
|
|
360
|
+
## Permission decisions are not on the wire
|
|
361
|
+
|
|
362
|
+
A permission decision travels as an HTTP POST from the host to a URL the host is configured with,
|
|
363
|
+
and the decision comes back in the response body. Nothing on the wire announces that URL. A
|
|
364
|
+
controller therefore serves two transports: the WebSocket the host dials and an HTTP endpoint that
|
|
365
|
+
answers decisions. The request and decision shapes (`DecisionRequest`, `Decision`, `Decider`,
|
|
366
|
+
`EscalationTransport`) ship on the protocol subpath so the endpoint can be typed without importing
|
|
367
|
+
the main entry. A `Decision` follows the agent SDK's own permission result: `{ behavior: 'allow',
|
|
368
|
+
updatedInput? }` or `{ behavior: 'deny', message, interrupt? }`; its optional members are omitted
|
|
369
|
+
when absent, never written as null, and an explicit null is read as an unrecognised decision, which
|
|
370
|
+
is never an allow. The host reads the status before the body: a non-2xx, a transport failure or a
|
|
371
|
+
body that is not JSON is an outage, and an outage refuses the tool.
|
|
372
|
+
|
|
373
|
+
The request is `POST <PERISCOPE_DECISION_URL>` with `content-type: application/json`, the host's
|
|
374
|
+
bearer as `Authorization` when it has one, and a JSON body that is a `DecisionRequest`:
|
|
375
|
+
|
|
376
|
+
| member | meaning |
|
|
377
|
+
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
378
|
+
| `toolName` | the tool the agent is calling, as the SDK names it (`Bash`, `Write`, `mcp__{server}__{tool}`) |
|
|
379
|
+
| `toolUseId` | the call's id, unique within the session; the hold the state machine opens is keyed by it |
|
|
380
|
+
| `toolInput` | the call's input, verbatim, whatever the tool takes |
|
|
381
|
+
| `sessionKey` | **the controller's handle**, the same string every wire frame for this session is keyed by. Correlate on this and nothing else |
|
|
382
|
+
| `sessionId` | the agent's own id as the hook input carries it; a fact, not a key. The empty string when the input carries none, so a controller indexing on it fails exactly when a session is in trouble |
|
|
383
|
+
| `cwd` | the directory the call runs in |
|
|
384
|
+
| `agentId` | the subagent this call came from, or null on the main thread |
|
|
385
|
+
| `agentType` | the agent type when one is set, or null; present on the main thread too, so branch on `agentId` |
|
|
386
|
+
|
|
387
|
+
The answer is the `Decision` above, as JSON, within the session's `decisionTimeoutMs`
|
|
388
|
+
(`DEFAULT_DECISION_TIMEOUT_MS`, 50 s); a controller that holds a decision for a person keeps its
|
|
389
|
+
own budget inside that and answers non-2xx when the budget passes. The request carries the turn's
|
|
390
|
+
abort: a cancelled turn aborts an in-flight decision request.
|
|
391
|
+
|
|
392
|
+
An in-process tool call is not on the wire either: the host's own MCP server registers the
|
|
393
|
+
embedder's descriptors, and a controller declares MCP servers, not inline tools, through
|
|
394
|
+
`session_new.request.mcpServers`.
|
|
395
|
+
|
|
396
|
+
## Refusals
|
|
397
|
+
|
|
398
|
+
`REFUSAL_REASONS` (`src/core/refusal.ts`) is a closed vocabulary. The codec decodes tolerantly, so
|
|
399
|
+
an unknown inbound reason survives as information (a `bulk_failed` is a receipt, and making it
|
|
400
|
+
unreadable because the failure had a newer name loses the outcome), and encodes strictly, so this
|
|
401
|
+
package never emits a reason it does not declare, on any kind that carries one, the `wire_refusal`
|
|
402
|
+
body included. Adding a reason is a versioned change: a controller that encodes strictly too must
|
|
403
|
+
learn it before it can answer with it. Count the entries in the file; never carry the number.
|
|
404
|
+
|
|
405
|
+
The reasons a controller meets most, and what each is not:
|
|
406
|
+
|
|
407
|
+
| reason | names |
|
|
408
|
+
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
409
|
+
| `frame-not-json`, `frame-malformed`, `frame-too-large` | the codec's three; the last names the bulk lane |
|
|
410
|
+
| `seq-gap`, `seq-regressed` | the receiver's inbound lane; the refusal's `expected` is where to resume |
|
|
411
|
+
| `link-unauthorized` | the peer refused this host's identity at the door; terminal, never retried. Not `link-send-failed`, which names a transport that faltered |
|
|
412
|
+
| `session-unknown` | a handle that does not and never will exist, or a held turn over the bound |
|
|
413
|
+
| `permission-decision-unavailable`, `permission-decision-unrecognised` | an outage, and a decision this build does not know; neither is an allow |
|
|
414
|
+
| `transcript-path-escape` | a caller-supplied name that failed one of the discovery jail's three layers; the refusal names the layer |
|
|
415
|
+
| `bulk-target-not-controller`, `bulk-target-invalid` | a well-formed target that is not the controller's origin, and a garbled one; a configuration mistake and an exfiltration attempt are kept apart |
|
|
416
|
+
| `resume-cwd-not-honoured` | a resume naming a directory the workspace provider would not honour; the same resume at the repository root runs |
|
|
417
|
+
| `config-key-unknown`, `config-value-invalid`, `config-host-busy`, `config-write-failed` | a key the host does not accept over the link, a value it cannot use, a root change while a session is open, a config file that could not be written (nothing applied) |
|
|
418
|
+
| `workspace-list-failed`, `workspace-release-failed`, `branch-not-merged` | the inventory failed (an empty disk is an empty list, not a refusal); a removal was attempted and failed; a branch the default does not contain, nothing removed, repeat with `force` |
|
|
419
|
+
| `repository-path-escape`, `repository-read-failed` | a path that left the repository root under either containment check; a path inside it that could not be read as text |
|
|
420
|
+
|
|
421
|
+
`protocol_version_rejected` is not a refusal reason but a link cause: the windows did not overlap,
|
|
422
|
+
and the link's state reports it.
|
|
423
|
+
|
|
424
|
+
## The vectors
|
|
425
|
+
|
|
426
|
+
`contracts/wire-vectors/*.json` is the normative byte-level contract. Each file is
|
|
427
|
+
`{ name, protocolVersion, frame, wire, expect, decoded? }`:
|
|
428
|
+
|
|
429
|
+
- `expect: { decode: 'ok' }` with a `frame` and a `wire`: `encode(frame)` must reproduce `wire` byte
|
|
430
|
+
for byte, and `decode(wire)` must give back `frame`, or `decoded` where the codec deliberately
|
|
431
|
+
differs (an unknown key carried through, a defaulted member, a dropped `__proto__`). A vector
|
|
432
|
+
with `frame: null` is an authored wire no frame of this build encodes to: a tolerance case.
|
|
433
|
+
- `expect: { decode: 'refused', reason }` with a `wire` and no frame: `decode(wire)` must refuse with
|
|
434
|
+
that reason.
|
|
435
|
+
- `expect: { encode: 'refused', reason }` with a `frame` and `wire: null`: `encode(frame)` must refuse
|
|
436
|
+
with that reason; nothing reaches the wire.
|
|
437
|
+
|
|
438
|
+
Every vector is recorded at `PROTOCOL_VERSION`; a bump reddens the corpus until it is re-approved
|
|
439
|
+
with `npm run contracts:update`. A second implementer proves its codec against these files, not
|
|
440
|
+
against this document; `pins/wire-vectors.test.ts` is this package's own proof.
|
|
441
|
+
|
|
442
|
+
## The controller's obligations
|
|
443
|
+
|
|
444
|
+
The wire types do not enforce these; a conforming controller does them anyway. Each is one thing to
|
|
445
|
+
build, in the order a first controller meets them.
|
|
446
|
+
|
|
447
|
+
Over HTTP, beside the link:
|
|
448
|
+
|
|
449
|
+
- [ ] Serve the WebSocket upgrade at the address the host dials; read `Authorization` and answer
|
|
450
|
+
401 or 403 to refuse an identity (terminal for the host), anything else to let it in.
|
|
451
|
+
- [ ] Serve the decision endpoint at the address the host is configured with: read a
|
|
452
|
+
`DecisionRequest`, answer a `Decision` as JSON with a 2xx inside the session's
|
|
453
|
+
`decisionTimeoutMs`, or non-2xx to refuse. Correlate on `sessionKey`.
|
|
454
|
+
- [ ] Serve one or more bulk sinks on the controller's own origin: accept a streamed POST with
|
|
455
|
+
`x-delivery-id`, `content-length` and the bearer; answer 2xx once the bytes are stored.
|
|
456
|
+
- [ ] To pair hosts, mint short-lived single-use codes for signed-in users and serve the redemption
|
|
457
|
+
route: read `{ code, machineLabel }`, answer `{ hostId, hostCredential, controllerUrl,
|
|
458
|
+
decisionUrl }` with the bearer shaped `p1.<hostId>.<secret>`; store the secret's hash;
|
|
459
|
+
revoke by refusing the bearer at the upgrade.
|
|
460
|
+
|
|
461
|
+
Over the link:
|
|
462
|
+
|
|
463
|
+
- [ ] Answer `link_hello` with `link_welcome` carrying the chosen protocol version inside the overlap
|
|
464
|
+
of the two windows, or close 1002 naming both windows. Answer every `link_ping` with
|
|
465
|
+
`link_pong`. Send `link_ack` for every session frame received; the host sends no `link_ack`,
|
|
466
|
+
ever, and its retention is released only by the controller's acks.
|
|
467
|
+
- [ ] `link_welcome.cursors` reports the controller's durable inbound positions; a session with no
|
|
468
|
+
stored cursor is omitted, never reported as 0.
|
|
469
|
+
- [ ] `link_hello.cursors` is the host's inbound set: prune outbound retention with it; never seed
|
|
470
|
+
an inbound tracker from it.
|
|
471
|
+
- [ ] Seed the inbound tracker from the first frame when no cursor is held for a session key, keyed
|
|
472
|
+
off absence, never `== 0`.
|
|
473
|
+
- [ ] Persist both directions durably for session channels; the outbound clock must continue across
|
|
474
|
+
a restart, because restarting at 1 reads as duplicates and is silently discarded. The
|
|
475
|
+
host-scoped channel is the exception: number it per link from the cursor the hello reports, and
|
|
476
|
+
seed its inbound from the first frame after each hello.
|
|
477
|
+
- [ ] `wire_refusal.expected` is a resync instruction about the receiver's own outbound lane:
|
|
478
|
+
re-send retained frames from that seq with their original seqs.
|
|
479
|
+
- [ ] Budget frames against `MAX_FRAME_BYTES` minus 15, and bound every host-scoped ask so its answer
|
|
480
|
+
fits.
|
|
481
|
+
- [ ] A `session_delta` produces no durable content row.
|
|
482
|
+
- [ ] The host's `ended` ends the controller's record of the session, the refused open included.
|
|
483
|
+
|
|
484
|
+
## Where things live
|
|
485
|
+
|
|
486
|
+
| Path | What |
|
|
487
|
+
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
488
|
+
| `src/protocol.ts` | The subpath barrel: types and runtime values (`encode`, `decode`, `SeqTracker`, `REFUSAL_REASONS`, `PROTOCOL_VERSION`, `PROTOCOL_VERSION_MIN`, `readDecision`, the permission shapes) |
|
|
489
|
+
| `src/control/frames.ts` | The envelope, the payload union, the sequence semantics, `DROPPABLE_KINDS`, the three ids |
|
|
490
|
+
| `src/control/codec.ts` | The one edge between bytes and types; tolerant decode, strict encode |
|
|
491
|
+
| `src/control/seq.ts` | `SeqTracker`: dense minting, gap and duplicate judgement |
|
|
492
|
+
| `src/control/queue.ts` | `BoundedFrameQueue`: pending and written entries, the eviction ladder |
|
|
493
|
+
| `src/control/link.ts` | `ControllerLink`: the dial-out socket, admit then drain, backoff with jitter, heartbeat, the connect timeout, replay, acks, the version window |
|
|
494
|
+
| `src/control/link-state.ts` | The link's own state machine and `LINK_CAUSES` |
|
|
495
|
+
| `src/control/stream-routing.ts` | `MESSAGE_ROUTING`: the lane for each SDK message |
|
|
496
|
+
| `src/host/bulk-post.ts` | The streamed outbound POST behind `bulk_request` and its origin bind |
|
|
497
|
+
| `src/gate/decision.ts` | The permission types the subpath re-exports |
|
|
498
|
+
| `src/host/wire-request.ts` | The single narrowing from `session_new.request` and `session_configure` to local requests |
|
|
499
|
+
| `contracts/wire-vectors/` | The byte-level contract |
|
|
500
|
+
| `src/pins/wire-vectors.test.ts`, `src/pins/protocol-closure.test.ts` | The corpus check; the proof that the subpath reaches no `host/` file and no `node:` builtin |
|
|
501
|
+
| `examples/minimal-controller/`, `examples/test-controller/` | The smallest controller that accepts a host, and the reference controller that drives every ask |
|