@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,655 @@
1
+ /**
2
+ * The reference controller: the smallest complete other end of a Periscope host, and the proof that
3
+ * this package is usable by somebody who did not write it.
4
+ *
5
+ * It accepts the outbound connection, negotiates the version, answers the heartbeat, acks every
6
+ * frame, answers permission escalations over HTTP, serves a couple of tools, receives bulk posts,
7
+ * mints and redeems pair codes and checks the paired bearer at the upgrade, and sends every
8
+ * host-scoped ask the wire has through an HTTP door with a requestId rendezvous. What it keeps is
9
+ * one host row and the frames it saw, in memory. There is no orchestration in it and none belongs
10
+ * in it: what a session means is the embedder's, and this file only carries what happened.
11
+ *
12
+ * It is also the genericness proof, which is why it imports the way it does. Everything below
13
+ * comes through the published `@naswerks/periscope/protocol` subpath, by package name, the same resolution a
14
+ * consumer gets from `npm install`. Not one deep relative import, because a deep import would prove
15
+ * the files exist rather than that the package's own entry points are sufficient.
16
+ */
17
+ import { randomBytes, randomUUID } from 'node:crypto';
18
+ import { createServer } from 'node:http';
19
+ import type { IncomingMessage, Server, ServerResponse } from 'node:http';
20
+ import type { AddressInfo } from 'node:net';
21
+ import { WebSocketServer } from 'ws';
22
+ import type { WebSocket } from 'ws';
23
+
24
+ import type {
25
+ ControlPayload,
26
+ Decision,
27
+ DecisionRequest,
28
+ Frame,
29
+ SessionCursor,
30
+ SessionFrame,
31
+ SessionPayload,
32
+ SessionTransition,
33
+ } from '@naswerks/periscope/protocol';
34
+ import {
35
+ PROTOCOL_VERSION,
36
+ PROTOCOL_VERSION_MIN,
37
+ decode,
38
+ encode,
39
+ readAgentMessage,
40
+ readStateTransition,
41
+ } from '@naswerks/periscope/protocol';
42
+
43
+ /**
44
+ * How this controller decides. Returning a promise that resolves late IS a hold.
45
+ *
46
+ * A permission escalation does not ride the link: the host asks over an ordinary HTTP POST whose
47
+ * body is a `DecisionRequest`, and the answer is a `Decision`, both exported from the
48
+ * `@naswerks/periscope/protocol` subpath so a controller types its second transport without the
49
+ * privileged entry point. `sessionKey` on the request is the controller's own handle for the
50
+ * session; correlate on it, never on the agent's `sessionId`.
51
+ */
52
+ export type Policy = (ask: DecisionRequest) => Decision | Promise<Decision>;
53
+
54
+ /** A tool this controller offers. The host validates arguments; this only answers. */
55
+ export type ToolHandler = (args: Record<string, unknown>, sessionId: string | null) => string;
56
+
57
+ /**
58
+ * The host-scoped asks, as the wire spells them. Each is an HTTP door at `POST /asks/<kind>` whose
59
+ * JSON body is the ask's members without `requestId`; the answer is the result payload, whole.
60
+ */
61
+ export const ASK_KINDS = [
62
+ 'session_list',
63
+ 'transcript_list',
64
+ 'transcript_tail',
65
+ 'workspace_list',
66
+ 'workspace_release',
67
+ 'workspace_release_bulk',
68
+ 'host_configure',
69
+ 'repository_list',
70
+ 'repository_read',
71
+ ] as const;
72
+ export type AskKind = (typeof ASK_KINDS)[number];
73
+
74
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
75
+
76
+ /** An ask without its `requestId`: this controller mints that, and matches the answer on it. */
77
+ export type Ask = DistributiveOmit<Extract<SessionPayload, { readonly kind: AskKind }>, 'requestId'>;
78
+
79
+ /** A machine that redeemed a pair code. The credential is the key it is looked up by at the upgrade. */
80
+ export interface PairedHost {
81
+ readonly hostId: string;
82
+ readonly label: string;
83
+ readonly pairedAt: string;
84
+ }
85
+
86
+ export interface TestControllerOptions {
87
+ /** Default: allow everything. Supply one that denies and holds to exercise the gate. */
88
+ readonly policy?: Policy;
89
+ readonly tools?: Readonly<Record<string, ToolHandler>>;
90
+ /** Every rendered line. Default: stdout. */
91
+ readonly render?: (line: string) => void;
92
+ /** How long an ask waits for its answer before the door reports the host did not answer. */
93
+ readonly askTimeoutMs?: number;
94
+ /** How long a minted pair code stays redeemable. */
95
+ readonly codeTtlMs?: number;
96
+ }
97
+
98
+ /** One thing this controller saw. The proof reads these instead of parsing the log back. */
99
+ export interface Seen {
100
+ readonly at: string;
101
+ readonly kind: 'frame' | 'ask' | 'answer' | 'tool' | 'bulk' | 'link' | 'door' | 'pair';
102
+ readonly sessionKey: string | null;
103
+ readonly text: string;
104
+ readonly transition?: SessionTransition;
105
+ readonly seq?: number;
106
+ /** The whole payload, kept only for a frame that is neither a transition nor an agent message. */
107
+ readonly payload?: SessionFrame['payload'];
108
+ }
109
+
110
+ interface PendingAsk {
111
+ readonly kind: AskKind;
112
+ readonly resolve: (payload: SessionPayload) => void;
113
+ readonly reject: (error: Error) => void;
114
+ readonly timer: NodeJS.Timeout;
115
+ }
116
+
117
+ const isAskKind = (value: string): value is AskKind => (ASK_KINDS as readonly string[]).includes(value);
118
+
119
+ export class TestController {
120
+ readonly seen: Seen[] = [];
121
+ readonly #policy: Policy;
122
+ readonly #tools: Readonly<Record<string, ToolHandler>>;
123
+ readonly #render: (line: string) => void;
124
+ readonly #askTimeoutMs: number;
125
+ readonly #codeTtlMs: number;
126
+ /** The last seq accepted per session: this controller's half of the dense-seq contract. */
127
+ readonly #cursors = new Map<string, number>();
128
+ readonly #outbound = new Map<string, number>();
129
+ readonly #duplicates: string[] = [];
130
+ readonly #gaps: string[] = [];
131
+ /** Asks in flight, by the requestId the answer must echo. */
132
+ readonly #pending = new Map<string, PendingAsk>();
133
+ /** Minted pair codes and when each stops being redeemable. */
134
+ readonly #codes = new Map<string, number>();
135
+ /** Paired machines, by credential: the lookup the upgrade makes. */
136
+ readonly #paired = new Map<string, PairedHost>();
137
+
138
+ #wss: WebSocketServer | null = null;
139
+ #http: Server | null = null;
140
+ #socket: WebSocket | null = null;
141
+ #hostId: string | null = null;
142
+ #wsPort = 0;
143
+ #httpPort = 0;
144
+ #spendUsd = 0;
145
+
146
+ constructor(options: TestControllerOptions = {}) {
147
+ this.#policy = options.policy ?? (() => ({ behavior: 'allow' }));
148
+ this.#tools = options.tools ?? {};
149
+ this.#render = options.render ?? ((line) => process.stdout.write(`${line}\n`));
150
+ this.#askTimeoutMs = options.askTimeoutMs ?? 5_000;
151
+ this.#codeTtlMs = options.codeTtlMs ?? 10 * 60_000;
152
+ }
153
+
154
+ get controllerUrl(): string {
155
+ return `ws://127.0.0.1:${this.#wsPort}`;
156
+ }
157
+
158
+ /** The HTTP origin: the decision endpoint, the tool and bulk routes, the doors and the pair routes live under it. */
159
+ get origin(): string {
160
+ return `http://127.0.0.1:${this.#httpPort}`;
161
+ }
162
+
163
+ get decisionUrl(): string {
164
+ return `${this.origin}/decisions`;
165
+ }
166
+
167
+ get toolUrl(): string {
168
+ return `${this.origin}/tools`;
169
+ }
170
+
171
+ get connected(): boolean {
172
+ return this.#socket !== null;
173
+ }
174
+
175
+ /** The id the linked host announced at its hello, or null before one. */
176
+ get hostId(): string | null {
177
+ return this.#hostId;
178
+ }
179
+
180
+ /** What every turn this controller saw actually cost, summed from the agent's own results. */
181
+ get spendUsd(): number {
182
+ return this.#spendUsd;
183
+ }
184
+
185
+ /** Seq faults this controller detected. Empty is the assertion; the contents are the diagnosis. */
186
+ get faults(): { duplicates: readonly string[]; gaps: readonly string[] } {
187
+ return { duplicates: this.#duplicates, gaps: this.#gaps };
188
+ }
189
+
190
+ /** Every transition it received, in arrival order. */
191
+ transitions(): SessionTransition[] {
192
+ return this.seen.flatMap((one) => (one.transition === undefined ? [] : [one.transition]));
193
+ }
194
+
195
+ /** The machines that have redeemed a pair code, in pairing order. */
196
+ hosts(): PairedHost[] {
197
+ return [...this.#paired.values()];
198
+ }
199
+
200
+ async start(): Promise<void> {
201
+ await this.#listen(0, 0, 'a host connected');
202
+ }
203
+
204
+ /** Close everything. The seq accounting stays, so a host's reconnect is judged against it. */
205
+ async stop(): Promise<void> {
206
+ for (const [requestId, pending] of this.#pending) {
207
+ clearTimeout(pending.timer);
208
+ pending.reject(new Error(`the controller stopped before ${pending.kind} ${requestId} was answered`));
209
+ }
210
+ this.#pending.clear();
211
+ this.#socket?.close();
212
+ this.#socket = null;
213
+ await new Promise<void>((resolve) => (this.#wss === null ? resolve() : this.#wss.close(() => resolve())));
214
+ await new Promise<void>((resolve) =>
215
+ this.#http === null ? resolve() : this.#http.close(() => resolve()),
216
+ );
217
+ this.#wss = null;
218
+ this.#http = null;
219
+ }
220
+
221
+ /** Bring the sockets back on the SAME ports, so a host's reconnect finds them. */
222
+ async restart(): Promise<void> {
223
+ const wsPort = this.#wsPort;
224
+ const httpPort = this.#httpPort;
225
+ await this.stop();
226
+ await this.#listen(wsPort, httpPort, 'a host reconnected');
227
+ }
228
+
229
+ /** Send a command. The controller mints the routing key; the session does not exist yet. */
230
+ send(sessionKey: string, payload: SessionFrame['payload']): void {
231
+ const problem = this.#writeSession(sessionKey, payload);
232
+ if (problem !== null) this.#note('link', sessionKey, `could not encode a ${payload.kind}: ${problem}`);
233
+ }
234
+
235
+ /**
236
+ * Send a host-scoped ask on the linked host's discovery channel and resolve with the answer that
237
+ * echoes its requestId. Rejects when no host is linked, when the ask does not encode (the codec
238
+ * refuses a malformed ask here, before a sequence number is spent), and when the host does not
239
+ * answer inside the timeout.
240
+ */
241
+ ask(ask: Ask): Promise<SessionPayload> {
242
+ const hostId = this.#hostId;
243
+ if (this.#socket === null || hostId === null) {
244
+ return Promise.reject(new Error('no host is linked'));
245
+ }
246
+ const requestId = randomUUID();
247
+ const payload = { ...ask, requestId } as SessionPayload;
248
+ const channel = `discovery:${hostId}`;
249
+ return new Promise((resolve, reject) => {
250
+ const timer = setTimeout(() => {
251
+ this.#pending.delete(requestId);
252
+ reject(new Error(`the host did not answer ${ask.kind} ${requestId} within ${this.#askTimeoutMs} ms`));
253
+ }, this.#askTimeoutMs);
254
+ this.#pending.set(requestId, { kind: ask.kind, resolve, reject, timer });
255
+ const problem = this.#writeSession(channel, payload);
256
+ if (problem !== null) {
257
+ clearTimeout(timer);
258
+ this.#pending.delete(requestId);
259
+ reject(new Error(`the ${ask.kind} does not encode: ${problem}`));
260
+ return;
261
+ }
262
+ this.#note('door', channel, `${ask.kind} ${requestId}`);
263
+ });
264
+ }
265
+
266
+ // -------------------------------------------------------------------------
267
+
268
+ async #listen(wsPort: number, httpPort: number, onConnection: string): Promise<void> {
269
+ this.#http = createServer((request, response) => void this.#serve(request, response));
270
+ await new Promise<void>((resolve) => this.#http?.listen(httpPort, '127.0.0.1', resolve));
271
+ this.#httpPort = (this.#http.address() as AddressInfo).port;
272
+
273
+ this.#wss = new WebSocketServer({
274
+ host: '127.0.0.1',
275
+ port: wsPort,
276
+ verifyClient: (info, done) => this.#verifyUpgrade(info.req, done),
277
+ });
278
+ await new Promise<void>((resolve) => this.#wss?.once('listening', resolve));
279
+ this.#wsPort = (this.#wss.address() as AddressInfo).port;
280
+
281
+ this.#wss.on('connection', (socket: WebSocket) => {
282
+ this.#socket = socket;
283
+ this.#note('link', null, onConnection);
284
+ socket.on('message', (data: unknown) => this.#onFrame(String(data)));
285
+ socket.on('close', () => {
286
+ if (this.#socket === socket) this.#socket = null;
287
+ this.#note('link', null, 'the host disconnected');
288
+ });
289
+ socket.on('error', () => undefined);
290
+ });
291
+ }
292
+
293
+ /**
294
+ * The bearer at the upgrade. Until a machine has paired, the door is open, so a host on a sign-in
295
+ * token can link to this reference (it cannot validate such a token without an identity provider;
296
+ * a real controller does). Once one has, every upgrade presents a paired credential or is refused
297
+ * at the door with 401, which the host reads as `link-unauthorized` and does not retry.
298
+ */
299
+ #verifyUpgrade(
300
+ request: IncomingMessage,
301
+ done: (ok: boolean, code?: number, message?: string) => void,
302
+ ): void {
303
+ if (this.#paired.size === 0) {
304
+ done(true);
305
+ return;
306
+ }
307
+ const header = request.headers.authorization ?? '';
308
+ const credential = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
309
+ const host = this.#paired.get(credential);
310
+ if (host === undefined) {
311
+ this.#note('link', null, 'refused an upgrade: no paired credential presented');
312
+ done(false, 401, 'Unauthorized');
313
+ return;
314
+ }
315
+ this.#note('link', host.hostId, `the upgrade presented the paired credential of ${host.label}`);
316
+ done(true);
317
+ }
318
+
319
+ /** Encode with the next seq and write; the seq is spent only when the frame is written. */
320
+ #writeSession(sessionKey: string, payload: SessionFrame['payload']): string | null {
321
+ const seq = (this.#outbound.get(sessionKey) ?? 0) + 1;
322
+ const encoded = encode({
323
+ frame: 'session',
324
+ sessionId: sessionKey,
325
+ seq,
326
+ at: new Date().toISOString(),
327
+ payload,
328
+ });
329
+ if (!encoded.ok) return encoded.refusal.detail;
330
+ this.#outbound.set(sessionKey, seq);
331
+ this.#socket?.send(encoded.value);
332
+ return null;
333
+ }
334
+
335
+ #control(payload: ControlPayload): void {
336
+ const encoded = encode({ frame: 'control', at: new Date().toISOString(), payload });
337
+ if (encoded.ok) this.#socket?.send(encoded.value);
338
+ }
339
+
340
+ #onFrame(raw: string): void {
341
+ const decoded = decode(raw);
342
+ if (!decoded.ok) {
343
+ this.#note('link', null, `undecodable frame: ${decoded.refusal.reason}`);
344
+ return;
345
+ }
346
+
347
+ const frame: Frame = decoded.value;
348
+ if (frame.frame === 'control') {
349
+ this.#onControl(frame.payload);
350
+ return;
351
+ }
352
+
353
+ // The dense-seq contract, from the receiving side: expected is always last + 1. A duplicate is
354
+ // a replay after a reconnect and is expected; a gap is not, and both are recorded rather than
355
+ // silently tolerated, because "lost nothing and duplicated nothing" is an assertion someone
356
+ // has to be able to check.
357
+ // Keyed off absence, never `== 0`: a channel with no held cursor is seeded from its first frame,
358
+ // which is how a host-scoped channel is picked up again after a hello whatever the host's own
359
+ // counter did across the reconnect.
360
+ const last = this.#cursors.get(frame.sessionId);
361
+ if (last !== undefined) {
362
+ if (frame.seq <= last) {
363
+ this.#duplicates.push(`${frame.sessionId}/${frame.seq} (already had ${last})`);
364
+ return;
365
+ }
366
+ if (frame.seq > last + 1) this.#gaps.push(`${frame.sessionId}: expected ${last + 1}, got ${frame.seq}`);
367
+ }
368
+ this.#cursors.set(frame.sessionId, frame.seq);
369
+
370
+ this.#renderSessionFrame(frame);
371
+ this.#control({ kind: 'link_ack', cursors: this.#cursorList() });
372
+ }
373
+
374
+ #renderSessionFrame(frame: SessionFrame): void {
375
+ const payload = frame.payload;
376
+ if (payload.kind === 'session_update' || payload.kind === 'session_delta') {
377
+ const body = (payload as unknown as { body: Record<string, unknown> }).body;
378
+
379
+ const transition = readStateTransition(body as never);
380
+ if (transition !== null) {
381
+ this.seen.push({
382
+ at: new Date().toISOString(),
383
+ kind: 'frame',
384
+ sessionKey: frame.sessionId,
385
+ seq: frame.seq,
386
+ transition,
387
+ text:
388
+ `${transition.from} -> ${transition.to} ` +
389
+ `(${transition.cause.kind}/${transition.cause.event})` +
390
+ `${transition.activity === null ? '' : ` [${transition.activity.kind}:${transition.activity.name}]`}` +
391
+ ` — ${transition.cause.detail}`,
392
+ });
393
+ this.#render(
394
+ ` ${frame.sessionId}/${frame.seq} STATE ${this.seen[this.seen.length - 1]?.text ?? ''}`,
395
+ );
396
+ return;
397
+ }
398
+
399
+ const message = readAgentMessage(body as never);
400
+ if (message !== null) {
401
+ const kind = (message as { type?: unknown }).type;
402
+ const type = typeof kind === 'string' ? kind : '?';
403
+ // A `result` carries what the turn cost. Read off the agent's own message, never computed
404
+ // from a price table: the host does not compute it either, and a controller that did would
405
+ // be guessing at numbers it is being handed.
406
+ if (type === 'result') {
407
+ const cost = (message as { total_cost_usd?: unknown }).total_cost_usd;
408
+ if (typeof cost === 'number') this.#spendUsd += cost;
409
+ }
410
+ this.#note(
411
+ 'frame',
412
+ frame.sessionId,
413
+ `${payload.kind === 'session_delta' ? 'delta' : 'message'} ${type}`,
414
+ frame.seq,
415
+ );
416
+ return;
417
+ }
418
+ }
419
+
420
+ // Kept whole: a host-scoped answer is checkable only by its body, and a reader of the log still
421
+ // gets the one-line rendering.
422
+ this.seen.push({
423
+ at: new Date().toISOString(),
424
+ kind: 'frame',
425
+ sessionKey: frame.sessionId,
426
+ seq: frame.seq,
427
+ text: payload.kind,
428
+ payload,
429
+ });
430
+ this.#render(` ${frame.sessionId}/${frame.seq} FRAME ${payload.kind}`);
431
+
432
+ // The rendezvous: an answer carries the ask's requestId, and that is the whole key. The kind is
433
+ // not consulted, because the three transcript asks share one failure kind.
434
+ const requestId = (payload as { readonly requestId?: unknown }).requestId;
435
+ const pending = typeof requestId === 'string' ? this.#pending.get(requestId) : undefined;
436
+ if (pending !== undefined && typeof requestId === 'string') {
437
+ clearTimeout(pending.timer);
438
+ this.#pending.delete(requestId);
439
+ pending.resolve(payload);
440
+ }
441
+ }
442
+
443
+ #onControl(payload: ControlPayload): void {
444
+ switch (payload.kind) {
445
+ case 'link_hello': {
446
+ this.#note('link', null, `hello from ${payload.hostId} (v${payload.protocolVersion})`);
447
+ // The highest version both windows contain; a host outside this controller's window gets no
448
+ // welcome and a protocol close naming both windows, which the host reads as the refusal.
449
+ const chosen = Math.min(payload.protocolRange.max, PROTOCOL_VERSION);
450
+ if (chosen < Math.max(payload.protocolRange.min, PROTOCOL_VERSION_MIN)) {
451
+ this.#note('link', null, `refusing v${payload.protocolRange.min}-v${payload.protocolRange.max}`);
452
+ this.#socket?.close(
453
+ 1002,
454
+ `host speaks v${payload.protocolRange.min}-v${payload.protocolRange.max}; this controller speaks v${PROTOCOL_VERSION_MIN}-v${PROTOCOL_VERSION}`,
455
+ );
456
+ return;
457
+ }
458
+ this.#hostId = payload.hostId;
459
+ // The host-scoped channel is numbered per link: this side's outbound continues from the
460
+ // cursor the host reports for it in the hello (a fresh host process reports none and is
461
+ // asked from 1), and this side's inbound is seeded from the first frame after the hello,
462
+ // because the host's own counter for the channel is per process and may continue or
463
+ // restart. The welcome's cursors do not name it.
464
+ const channel = `discovery:${payload.hostId}`;
465
+ const reported = payload.cursors.find((cursor) => cursor.sessionId === channel);
466
+ this.#cursors.delete(channel);
467
+ if (reported === undefined) this.#outbound.delete(channel);
468
+ else this.#outbound.set(channel, reported.seq);
469
+ // What it already holds, so the host replays past it.
470
+ this.#control({
471
+ kind: 'link_welcome',
472
+ protocolVersion: chosen,
473
+ capabilities: ['bulk-post'],
474
+ cursors: this.#cursorList(),
475
+ });
476
+ return;
477
+ }
478
+ case 'link_ping':
479
+ this.#control({ kind: 'link_pong', nonce: payload.nonce });
480
+ return;
481
+ case 'link_bye':
482
+ this.#note('link', null, `the host said bye: ${payload.cause}`);
483
+ return;
484
+ default:
485
+ return;
486
+ }
487
+ }
488
+
489
+ #cursorList(): SessionCursor[] {
490
+ return [...this.#cursors.entries()].map(([sessionId, seq]) => ({ sessionId, seq }));
491
+ }
492
+
493
+ async #serve(request: IncomingMessage, response: ServerResponse): Promise<void> {
494
+ const url = request.url ?? '/';
495
+ const body = await readBody(request);
496
+
497
+ if (url === '/decisions') {
498
+ const ask = JSON.parse(body) as DecisionRequest;
499
+ this.#note('ask', ask.sessionId, `${ask.toolName} ${short(ask.toolInput)}`);
500
+ let answer: Decision;
501
+ try {
502
+ answer = await this.#policy(ask);
503
+ } catch (error) {
504
+ // A controller that fails must answer with a status, never with a body that parses. The
505
+ // host discriminates the status before reading anything, which is what stops an outage
506
+ // from impersonating a human "no".
507
+ response.writeHead(500, { 'content-type': 'text/plain' });
508
+ response.end(String(error));
509
+ return;
510
+ }
511
+ this.#note('answer', ask.sessionId, `${ask.toolName} -> ${answer.behavior}`);
512
+ json(response, 200, answer);
513
+ return;
514
+ }
515
+
516
+ if (url.startsWith('/tools/')) {
517
+ const name = url.slice('/tools/'.length);
518
+ const call = JSON.parse(body) as { arguments: Record<string, unknown>; sessionId: string | null };
519
+ const handler = this.#tools[name];
520
+ this.#note('tool', call.sessionId, `${name}(${short(call.arguments)})`);
521
+ json(
522
+ response,
523
+ 200,
524
+ handler === undefined
525
+ ? { text: `no tool named ${name}`, isError: true }
526
+ : { text: handler(call.arguments, call.sessionId) },
527
+ );
528
+ return;
529
+ }
530
+
531
+ if (url.startsWith('/bulk/')) {
532
+ this.#note('bulk', null, `${url.slice('/bulk/'.length)} received ${body.length} bytes`);
533
+ response.writeHead(204);
534
+ response.end();
535
+ return;
536
+ }
537
+
538
+ if (url.startsWith('/asks/')) {
539
+ await this.#serveDoor(url.slice('/asks/'.length), body, response);
540
+ return;
541
+ }
542
+
543
+ if (url === '/api/periscope/pair-codes' && request.method === 'POST') {
544
+ const code = randomBytes(6).toString('base64url');
545
+ const expiresAt = Date.now() + this.#codeTtlMs;
546
+ this.#codes.set(code, expiresAt);
547
+ this.#note('pair', null, `minted a pair code, redeemable until ${new Date(expiresAt).toISOString()}`);
548
+ json(response, 201, {
549
+ code,
550
+ expiresAt: new Date(expiresAt).toISOString(),
551
+ controllerOrigin: this.origin,
552
+ });
553
+ return;
554
+ }
555
+
556
+ if (url === '/api/periscope/pair' && request.method === 'POST') {
557
+ this.#redeem(body, response);
558
+ return;
559
+ }
560
+
561
+ response.writeHead(404);
562
+ response.end();
563
+ }
564
+
565
+ /** `POST /asks/<kind>`: the body is the ask's members; the answer is the result payload, whole. */
566
+ async #serveDoor(kind: string, body: string, response: ServerResponse): Promise<void> {
567
+ if (!isAskKind(kind)) {
568
+ json(response, 404, { error: `no ask named ${kind}; the doors are ${ASK_KINDS.join(', ')}` });
569
+ return;
570
+ }
571
+ let members: Record<string, unknown>;
572
+ try {
573
+ members = body === '' ? {} : (JSON.parse(body) as Record<string, unknown>);
574
+ } catch {
575
+ json(response, 400, { error: 'the body is not JSON' });
576
+ return;
577
+ }
578
+ if (this.#socket === null || this.#hostId === null) {
579
+ json(response, 503, { error: 'no host is linked' });
580
+ return;
581
+ }
582
+ try {
583
+ json(response, 200, await this.ask({ ...members, kind } as Ask));
584
+ } catch (error) {
585
+ const message = error instanceof Error ? error.message : String(error);
586
+ json(response, message.startsWith('the host did not answer') ? 504 : 400, { error: message });
587
+ }
588
+ }
589
+
590
+ /**
591
+ * `POST /api/periscope/pair` with `{ code, machineLabel }`. Unknown, expired and consumed codes
592
+ * answer identically, because the fix is the same for all three: mint a fresh one. The answer
593
+ * names the host id this controller assigns, the credential the host will present, and where
594
+ * to dial; the `pair` verb writes all three.
595
+ */
596
+ #redeem(body: string, response: ServerResponse): void {
597
+ let asked: { code?: unknown; machineLabel?: unknown };
598
+ try {
599
+ asked = JSON.parse(body) as { code?: unknown; machineLabel?: unknown };
600
+ } catch {
601
+ json(response, 400, { error: 'the body is not JSON' });
602
+ return;
603
+ }
604
+ const code = typeof asked.code === 'string' ? asked.code : '';
605
+ const expiresAt = this.#codes.get(code);
606
+ this.#codes.delete(code);
607
+ if (expiresAt === undefined || expiresAt < Date.now()) {
608
+ this.#note('pair', null, 'refused a pair code');
609
+ json(response, 404, { error: 'unknown, expired or consumed pair code' });
610
+ return;
611
+ }
612
+ const hostId = `ph-${randomBytes(4).toString('hex')}`;
613
+ const hostCredential = `p1.${hostId}.${randomBytes(24).toString('base64url')}`;
614
+ const label =
615
+ typeof asked.machineLabel === 'string' && asked.machineLabel !== '' ? asked.machineLabel : hostId;
616
+ this.#paired.set(hostCredential, { hostId, label, pairedAt: new Date().toISOString() });
617
+ this.#note('pair', hostId, `paired ${label}`);
618
+ json(response, 200, {
619
+ hostId,
620
+ hostCredential,
621
+ controllerUrl: this.controllerUrl,
622
+ decisionUrl: this.decisionUrl,
623
+ });
624
+ }
625
+
626
+ #note(kind: Seen['kind'], sessionKey: string | null, text: string, seq?: number): void {
627
+ const entry: Seen = {
628
+ at: new Date().toISOString(),
629
+ kind,
630
+ sessionKey,
631
+ text,
632
+ ...(seq === undefined ? {} : { seq }),
633
+ };
634
+ this.seen.push(entry);
635
+ this.#render(` ${sessionKey ?? '-'} ${kind.toUpperCase()} ${text}`);
636
+ }
637
+ }
638
+
639
+ function json(response: ServerResponse, status: number, body: unknown): void {
640
+ response.writeHead(status, { 'content-type': 'application/json' });
641
+ response.end(JSON.stringify(body));
642
+ }
643
+
644
+ function readBody(request: IncomingMessage): Promise<string> {
645
+ return new Promise((resolve) => {
646
+ let body = '';
647
+ request.on('data', (chunk: unknown) => (body += String(chunk)));
648
+ request.on('end', () => resolve(body));
649
+ });
650
+ }
651
+
652
+ function short(value: unknown): string {
653
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
654
+ return text === undefined ? '?' : text.length > 120 ? `${text.slice(0, 117)}...` : text;
655
+ }
@@ -0,0 +1,29 @@
1
+ // Run the reference controller as a process, so a host can be paired against it from a terminal:
2
+ // the local rehearsal of the pairing walk-through before a real controller is involved.
3
+ //
4
+ // node examples/test-controller/serve.ts
5
+ //
6
+ // It prints the two addresses a host dials, mints one pair code and prints the exact `periscope
7
+ // pair` line that redeems it, then stays up and renders every frame it sees. Ports are ephemeral;
8
+ // re-run to get new ones. Every tool call is allowed (the default policy); pass nothing else.
9
+ import { TestController } from './controller.ts';
10
+
11
+ const controller = new TestController();
12
+ await controller.start();
13
+
14
+ const minted = await fetch(`${controller.origin}/api/periscope/pair-codes`, { method: 'POST' });
15
+ const { code } = (await minted.json()) as { code: string };
16
+
17
+ console.log(`controller link: ${controller.controllerUrl}`);
18
+ console.log(`decision endpoint: ${controller.decisionUrl}`);
19
+ console.log('');
20
+ console.log('pair a host with (the code is single-use and expires in ten minutes):');
21
+ console.log(` periscope pair ${code} --controller ${controller.origin} --label rehearsal`);
22
+ console.log('then start it with `periscope` and read `periscope status`; frames render below.');
23
+ console.log('');
24
+
25
+ const stop = (): void => {
26
+ void controller.stop().then(() => process.exit(0));
27
+ };
28
+ process.on('SIGINT', stop);
29
+ process.on('SIGTERM', stop);