@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.
Files changed (230) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/SECURITY.md +315 -0
  5. package/dist/bin/command.d.ts +97 -0
  6. package/dist/bin/command.js +141 -0
  7. package/dist/bin/config.d.ts +7 -0
  8. package/dist/bin/config.js +90 -0
  9. package/dist/bin/login.d.ts +48 -0
  10. package/dist/bin/login.js +82 -0
  11. package/dist/bin/main.d.ts +33 -0
  12. package/dist/bin/main.js +173 -0
  13. package/dist/bin/pair.d.ts +28 -0
  14. package/dist/bin/pair.js +198 -0
  15. package/dist/bin/periscope.d.ts +3 -0
  16. package/dist/bin/periscope.js +4 -0
  17. package/dist/bin/posture.d.ts +64 -0
  18. package/dist/bin/posture.js +157 -0
  19. package/dist/bin/reconfigure.d.ts +64 -0
  20. package/dist/bin/reconfigure.js +195 -0
  21. package/dist/bin/serve.d.ts +103 -0
  22. package/dist/bin/serve.js +441 -0
  23. package/dist/bin/status.d.ts +11 -0
  24. package/dist/bin/status.js +49 -0
  25. package/dist/bin/workspaces.d.ts +109 -0
  26. package/dist/bin/workspaces.js +215 -0
  27. package/dist/control/backoff.d.ts +25 -0
  28. package/dist/control/backoff.js +20 -0
  29. package/dist/control/codec.d.ts +15 -0
  30. package/dist/control/codec.js +501 -0
  31. package/dist/control/credential.d.ts +23 -0
  32. package/dist/control/credential.js +8 -0
  33. package/dist/control/frames.d.ts +1180 -0
  34. package/dist/control/frames.js +441 -0
  35. package/dist/control/link-state.d.ts +32 -0
  36. package/dist/control/link-state.js +39 -0
  37. package/dist/control/link.d.ts +109 -0
  38. package/dist/control/link.js +736 -0
  39. package/dist/control/queue.d.ts +110 -0
  40. package/dist/control/queue.js +174 -0
  41. package/dist/control/seq.d.ts +49 -0
  42. package/dist/control/seq.js +52 -0
  43. package/dist/control/stream-routing.d.ts +200 -0
  44. package/dist/control/stream-routing.js +167 -0
  45. package/dist/control/stream.d.ts +43 -0
  46. package/dist/control/stream.js +64 -0
  47. package/dist/core/async-queue.d.ts +26 -0
  48. package/dist/core/async-queue.js +63 -0
  49. package/dist/core/failure.d.ts +22 -0
  50. package/dist/core/failure.js +53 -0
  51. package/dist/core/index.d.ts +18 -0
  52. package/dist/core/index.js +7 -0
  53. package/dist/core/keyed-turns.d.ts +28 -0
  54. package/dist/core/keyed-turns.js +44 -0
  55. package/dist/core/paths.d.ts +25 -0
  56. package/dist/core/paths.js +77 -0
  57. package/dist/core/refusal.d.ts +43 -0
  58. package/dist/core/refusal.js +399 -0
  59. package/dist/core/result.d.ts +22 -0
  60. package/dist/core/result.js +16 -0
  61. package/dist/core/time.d.ts +19 -0
  62. package/dist/core/time.js +25 -0
  63. package/dist/core/vocab.d.ts +23 -0
  64. package/dist/core/vocab.js +37 -0
  65. package/dist/core/workspace-id.d.ts +12 -0
  66. package/dist/core/workspace-id.js +12 -0
  67. package/dist/gate/command.d.ts +136 -0
  68. package/dist/gate/command.js +551 -0
  69. package/dist/gate/decision.d.ts +111 -0
  70. package/dist/gate/decision.js +97 -0
  71. package/dist/gate/escalate.d.ts +82 -0
  72. package/dist/gate/escalate.js +92 -0
  73. package/dist/gate/gate.d.ts +173 -0
  74. package/dist/gate/gate.js +417 -0
  75. package/dist/gate/index.d.ts +39 -0
  76. package/dist/gate/index.js +31 -0
  77. package/dist/gate/jail.d.ts +92 -0
  78. package/dist/gate/jail.js +131 -0
  79. package/dist/gate/local.d.ts +69 -0
  80. package/dist/gate/local.js +51 -0
  81. package/dist/gate/outcome.d.ts +72 -0
  82. package/dist/gate/outcome.js +91 -0
  83. package/dist/gate/shell.d.ts +36 -0
  84. package/dist/gate/shell.js +226 -0
  85. package/dist/host/agent-process.d.ts +452 -0
  86. package/dist/host/agent-process.js +382 -0
  87. package/dist/host/bulk-post.d.ts +44 -0
  88. package/dist/host/bulk-post.js +114 -0
  89. package/dist/host/claude-transcripts.d.ts +84 -0
  90. package/dist/host/claude-transcripts.js +339 -0
  91. package/dist/host/config-file.d.ts +60 -0
  92. package/dist/host/config-file.js +216 -0
  93. package/dist/host/git-facts.d.ts +7 -0
  94. package/dist/host/git-facts.js +106 -0
  95. package/dist/host/hooks.d.ts +56 -0
  96. package/dist/host/hooks.js +75 -0
  97. package/dist/host/host.d.ts +356 -0
  98. package/dist/host/host.js +1294 -0
  99. package/dist/host/index.d.ts +35 -0
  100. package/dist/host/index.js +22 -0
  101. package/dist/host/link-state-file.d.ts +18 -0
  102. package/dist/host/link-state-file.js +66 -0
  103. package/dist/host/loopback.d.ts +38 -0
  104. package/dist/host/loopback.js +122 -0
  105. package/dist/host/machine.d.ts +9 -0
  106. package/dist/host/machine.js +19 -0
  107. package/dist/host/mcp-server.d.ts +11 -0
  108. package/dist/host/mcp-server.js +48 -0
  109. package/dist/host/package-facts.d.ts +3 -0
  110. package/dist/host/package-facts.js +26 -0
  111. package/dist/host/paired-credential-store.d.ts +32 -0
  112. package/dist/host/paired-credential-store.js +112 -0
  113. package/dist/host/paths.d.ts +80 -0
  114. package/dist/host/paths.js +165 -0
  115. package/dist/host/repository-read.d.ts +33 -0
  116. package/dist/host/repository-read.js +201 -0
  117. package/dist/host/session-store.d.ts +53 -0
  118. package/dist/host/session-store.js +100 -0
  119. package/dist/host/sign-in.d.ts +90 -0
  120. package/dist/host/sign-in.js +239 -0
  121. package/dist/host/telemetry.d.ts +42 -0
  122. package/dist/host/telemetry.js +74 -0
  123. package/dist/host/token-cache.d.ts +62 -0
  124. package/dist/host/token-cache.js +185 -0
  125. package/dist/host/transcript-fs.d.ts +4 -0
  126. package/dist/host/transcript-fs.js +104 -0
  127. package/dist/host/wire-request.d.ts +80 -0
  128. package/dist/host/wire-request.js +196 -0
  129. package/dist/host/workspace-fs.d.ts +14 -0
  130. package/dist/host/workspace-fs.js +84 -0
  131. package/dist/host/workspace-trust.d.ts +23 -0
  132. package/dist/host/workspace-trust.js +80 -0
  133. package/dist/identity/authorize.d.ts +59 -0
  134. package/dist/identity/authorize.js +72 -0
  135. package/dist/identity/config.d.ts +98 -0
  136. package/dist/identity/config.js +130 -0
  137. package/dist/identity/credential.d.ts +91 -0
  138. package/dist/identity/credential.js +76 -0
  139. package/dist/identity/device-code.d.ts +115 -0
  140. package/dist/identity/device-code.js +134 -0
  141. package/dist/identity/index.d.ts +33 -0
  142. package/dist/identity/index.js +10 -0
  143. package/dist/identity/mode.d.ts +106 -0
  144. package/dist/identity/mode.js +69 -0
  145. package/dist/identity/paired-credential.d.ts +52 -0
  146. package/dist/identity/paired-credential.js +43 -0
  147. package/dist/identity/pkce.d.ts +50 -0
  148. package/dist/identity/pkce.js +94 -0
  149. package/dist/identity/store.d.ts +43 -0
  150. package/dist/identity/store.js +43 -0
  151. package/dist/identity/token.d.ts +58 -0
  152. package/dist/identity/token.js +149 -0
  153. package/dist/index.d.ts +45 -0
  154. package/dist/index.js +53 -0
  155. package/dist/mcp/descriptor.d.ts +59 -0
  156. package/dist/mcp/descriptor.js +14 -0
  157. package/dist/mcp/index.d.ts +14 -0
  158. package/dist/mcp/index.js +4 -0
  159. package/dist/mcp/schema.d.ts +37 -0
  160. package/dist/mcp/schema.js +175 -0
  161. package/dist/mcp/server.d.ts +107 -0
  162. package/dist/mcp/server.js +66 -0
  163. package/dist/persistence/entry.d.ts +74 -0
  164. package/dist/persistence/entry.js +105 -0
  165. package/dist/persistence/index.d.ts +22 -0
  166. package/dist/persistence/index.js +8 -0
  167. package/dist/persistence/key.d.ts +46 -0
  168. package/dist/persistence/key.js +33 -0
  169. package/dist/persistence/mirror.d.ts +71 -0
  170. package/dist/persistence/mirror.js +57 -0
  171. package/dist/persistence/receipt.d.ts +111 -0
  172. package/dist/persistence/receipt.js +85 -0
  173. package/dist/persistence/retention.d.ts +68 -0
  174. package/dist/persistence/retention.js +68 -0
  175. package/dist/persistence/store.d.ts +106 -0
  176. package/dist/persistence/store.js +86 -0
  177. package/dist/persistence/transition-log.d.ts +73 -0
  178. package/dist/persistence/transition-log.js +133 -0
  179. package/dist/protocol.d.ts +27 -0
  180. package/dist/protocol.js +12 -0
  181. package/dist/sessions/index.d.ts +8 -0
  182. package/dist/sessions/index.js +4 -0
  183. package/dist/sessions/registry.d.ts +186 -0
  184. package/dist/sessions/registry.js +190 -0
  185. package/dist/sessions/session.d.ts +178 -0
  186. package/dist/sessions/session.js +288 -0
  187. package/dist/sessions/spawn-env.d.ts +88 -0
  188. package/dist/sessions/spawn-env.js +277 -0
  189. package/dist/state/coverage.d.ts +308 -0
  190. package/dist/state/coverage.js +315 -0
  191. package/dist/state/index.d.ts +12 -0
  192. package/dist/state/index.js +7 -0
  193. package/dist/state/machine.d.ts +112 -0
  194. package/dist/state/machine.js +237 -0
  195. package/dist/state/model.d.ts +233 -0
  196. package/dist/state/model.js +214 -0
  197. package/dist/state/observer.d.ts +55 -0
  198. package/dist/state/observer.js +413 -0
  199. package/dist/state/reporter.d.ts +45 -0
  200. package/dist/state/reporter.js +35 -0
  201. package/dist/state/store.d.ts +56 -0
  202. package/dist/state/store.js +120 -0
  203. package/dist/telemetry/index.d.ts +11 -0
  204. package/dist/telemetry/index.js +2 -0
  205. package/dist/telemetry/usage.d.ts +78 -0
  206. package/dist/telemetry/usage.js +69 -0
  207. package/dist/workspace/git-worktree.d.ts +150 -0
  208. package/dist/workspace/git-worktree.js +417 -0
  209. package/dist/workspace/index.d.ts +9 -0
  210. package/dist/workspace/index.js +4 -0
  211. package/dist/workspace/plain-dir.d.ts +34 -0
  212. package/dist/workspace/plain-dir.js +90 -0
  213. package/dist/workspace/provider.d.ts +152 -0
  214. package/dist/workspace/provider.js +2 -0
  215. package/dist/workspace/worktree-porcelain.d.ts +29 -0
  216. package/dist/workspace/worktree-porcelain.js +100 -0
  217. package/docs/architecture.md +277 -0
  218. package/docs/configuration.md +187 -0
  219. package/docs/gate.md +219 -0
  220. package/docs/identity.md +107 -0
  221. package/docs/protocol.md +501 -0
  222. package/docs/state-machine.md +160 -0
  223. package/examples/README.md +141 -0
  224. package/examples/minimal-controller/controller.ts +38 -0
  225. package/examples/parallel-run-proof/permission-mode-probe.ts +129 -0
  226. package/examples/parallel-run-proof/run.ts +850 -0
  227. package/examples/test-controller/controller.ts +655 -0
  228. package/examples/test-controller/serve.ts +29 -0
  229. package/examples/tsconfig.json +30 -0
  230. package/package.json +98 -0
@@ -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 |