@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,441 @@
1
+ import { isRefusalReason } from '../core/refusal.js';
2
+ /**
3
+ * Bumped when a change is not backward-compatible. Exchanged in the hello handshake.
4
+ *
5
+ * The rule: a new payload kind, a new member on an existing kind, a member whose nullability
6
+ * changed, or a new hello member is a bump, even when the change is additive in practice. The
7
+ * handshake's job is to let a peer refuse a version it does not know rather than to guess whether
8
+ * a difference happened to be survivable: every declared member is required on the wire, so a
9
+ * peer one version behind refuses the whole frame, and because refusals are not acknowledged that
10
+ * refusal would be a permanent silent wedge on the session lane. The handshake refuses by version
11
+ * instead, by name, once. A new value in the open `capabilities` list is not a bump; a new
12
+ * `RefusalReason` is one, because a strict encoder on the other side must learn it first.
13
+ *
14
+ * Every bump re-approves `contracts/wire-vectors/` (`npm run contracts:update`) and regenerates
15
+ * any consumer's readers.
16
+ */
17
+ export const PROTOCOL_VERSION = 9;
18
+ /**
19
+ * The oldest protocol version this build still speaks. A hello advertises the window
20
+ * `[PROTOCOL_VERSION_MIN, PROTOCOL_VERSION]` beside `protocolVersion`; the controller answers with
21
+ * its choice inside the overlap and the host accepts any version in its own window. The window
22
+ * opens at the first negotiated version; from the next bump on it is one minor wide, the version
23
+ * before the current one staying supported for one release. A hello with no range does not
24
+ * decode, so a version older than the first negotiated one cannot be inside the window.
25
+ */
26
+ export const PROTOCOL_VERSION_MIN = 9;
27
+ /**
28
+ * A frame larger than this is refused by the codec.
29
+ *
30
+ * This is the mechanical half of "commands only, never payloads": bulk bytes cannot ride the link
31
+ * even by accident, because a frame carrying them will not encode. The refusal names the bulk lane
32
+ * so the failure teaches the fix rather than just reporting a size.
33
+ */
34
+ export const MAX_FRAME_BYTES = 64 * 1024;
35
+ /** Build the update carrying a transition. The only supported way to put one on the wire. */
36
+ export function stateTransitionUpdate(transition) {
37
+ const body = { update: 'state_transition', transition };
38
+ return { kind: 'session_update', body: body };
39
+ }
40
+ /**
41
+ * Read a transition back out, or null when the body is some other kind of update.
42
+ *
43
+ * Null rather than a throw: an older host receiving a body it does not model must keep going, and
44
+ * a consumer that gets null knows to leave the frame alone rather than to treat it as corrupt.
45
+ */
46
+ export function readStateTransition(body) {
47
+ const update = body;
48
+ if (update.update !== 'state_transition')
49
+ return null;
50
+ const transition = update.transition;
51
+ if (transition === undefined || typeof transition.seq !== 'number' || transition.cause === undefined) {
52
+ return null;
53
+ }
54
+ return transition;
55
+ }
56
+ /** Build the update carrying an agent message. The only supported way to put one on the wire. */
57
+ export function agentMessageUpdate(message) {
58
+ const body = { update: 'agent_message', message };
59
+ return { kind: 'session_update', body: body };
60
+ }
61
+ /** Read a forwarded message back out, or null when the body is some other kind of update. */
62
+ export function readAgentMessage(body) {
63
+ const update = body;
64
+ if (update.update !== 'agent_message')
65
+ return null;
66
+ const message = update.message;
67
+ if (typeof message !== 'object' || message === null || Array.isArray(message))
68
+ return null;
69
+ return message;
70
+ }
71
+ /** Build the update carrying a refusal about the peer's own traffic. The only supported way. */
72
+ export function wireRefusalUpdate(refusal, expected, received) {
73
+ const body = { update: 'wire_refusal', refusal, expected, received };
74
+ return { kind: 'session_update', body: body };
75
+ }
76
+ /**
77
+ * Read a wire refusal back out, or null when the body is some other kind of update.
78
+ *
79
+ * Null rather than a throw, for the same reason `readStateTransition` returns null: a peer that does
80
+ * not model this member must keep going rather than treat the frame as corrupt. A v1 controller
81
+ * reaching this body gets null from all three readers and leaves the frame alone — which is a
82
+ * degraded outcome, not a broken one, and is the honest cost the version bump exists to announce.
83
+ */
84
+ export function readWireRefusal(body) {
85
+ const update = body;
86
+ if (update.update !== 'wire_refusal')
87
+ return null;
88
+ if (typeof update.expected !== 'number' || typeof update.received !== 'number')
89
+ return null;
90
+ const refusal = update.refusal;
91
+ if (refusal === undefined || typeof refusal.reason !== 'string' || typeof refusal.detail !== 'string') {
92
+ return null;
93
+ }
94
+ return {
95
+ update: 'wire_refusal',
96
+ refusal: { reason: refusal.reason, detail: refusal.detail },
97
+ expected: update.expected,
98
+ received: update.received,
99
+ };
100
+ }
101
+ /**
102
+ * Build a delta carrying one message verbatim.
103
+ *
104
+ * The body is the same shape a `session_update` carries, deliberately: the lanes differ in what
105
+ * losing a frame costs, not in what a frame says, so `readAgentMessage` reads either. A consumer
106
+ * that had to parse two shapes for one idea would have been handed the drift this package's
107
+ * vocabulary rules exist to prevent.
108
+ */
109
+ export function agentMessageDelta(message) {
110
+ const body = { update: 'agent_message', message };
111
+ return { kind: 'session_delta', body: body };
112
+ }
113
+ /**
114
+ * Build a `session_new`. The only supported way to put one on the wire.
115
+ *
116
+ * It exists for the reason `stateTransitionUpdate` does, and for one more. The wire's rule is that
117
+ * absent is `null` and never an omitted property, but a hand-written literal makes that a
118
+ * discipline every author has to remember, and the moment one is forgotten the frame carries
119
+ * `undefined`, which `JSON.stringify` deletes. The field then arrives absent, and "absent" and
120
+ * "explicitly unset" are the two states this file spends its opening paragraph refusing to
121
+ * conflate. Passing `asked` as a partial and filling every remaining key with `null` here makes
122
+ * the rule a property of construction instead of a rule in a comment.
123
+ *
124
+ * The argument is a partial of the wire type; what comes out is fully populated. So a caller writes
125
+ * only what it means, and no caller can produce a frame that says nothing where it meant nothing.
126
+ */
127
+ export function sessionNew(cwd, asked = {}) {
128
+ return {
129
+ kind: 'session_new',
130
+ cwd,
131
+ workspaceKey: asked.workspaceKey ?? null,
132
+ correlationId: asked.correlationId ?? null,
133
+ request: asked.request ?? null,
134
+ gate: asked.gate ?? null,
135
+ };
136
+ }
137
+ /**
138
+ * Build a `session_new.request`. Same argument as `sessionNew`: every unstated key becomes `null`.
139
+ *
140
+ * The partial is the convenience and the full object is the contract. Nothing about the wire is
141
+ * relaxed here: a controller in another language builds the whole object, and this is the shortcut
142
+ * for the one that happens to be written in this one.
143
+ */
144
+ export function sessionNewRequest(asked = {}) {
145
+ return {
146
+ resume: asked.resume ?? null,
147
+ fork: asked.fork ?? null,
148
+ settingSources: asked.settingSources ?? null,
149
+ plugins: asked.plugins ?? null,
150
+ mcpServers: asked.mcpServers ?? null,
151
+ strictMcpConfig: asked.strictMcpConfig ?? null,
152
+ includePartialMessages: asked.includePartialMessages ?? null,
153
+ thinking: asked.thinking ?? null,
154
+ forwardSubagentText: asked.forwardSubagentText ?? null,
155
+ env: asked.env ?? null,
156
+ model: asked.model ?? null,
157
+ systemPrompt: asked.systemPrompt ?? null,
158
+ effort: asked.effort ?? null,
159
+ permissionMode: asked.permissionMode ?? null,
160
+ };
161
+ }
162
+ /**
163
+ * Every key a `session_new.request` carries, as data, so a check can enumerate them at run time.
164
+ *
165
+ * It exists so a pin cannot quietly stop covering something. The egress pin walks a populated
166
+ * request looking for a field that could name a transcript destination, and a walk is only as
167
+ * complete as the object it is handed: a hand-built fixture silently stops being full the moment a
168
+ * later change adds a key, and the pin then passes while covering less, with nothing to say so.
169
+ *
170
+ * `satisfies Record<keyof SessionNewRequest, true>` makes the compiler the enforcer: adding a field
171
+ * to the type without adding it here does not build. So the pin's fixture is checked against a list
172
+ * that cannot fall behind the type it describes.
173
+ */
174
+ export const SESSION_NEW_REQUEST_KEYS = {
175
+ resume: true,
176
+ fork: true,
177
+ settingSources: true,
178
+ plugins: true,
179
+ mcpServers: true,
180
+ strictMcpConfig: true,
181
+ includePartialMessages: true,
182
+ thinking: true,
183
+ forwardSubagentText: true,
184
+ env: true,
185
+ model: true,
186
+ systemPrompt: true,
187
+ effort: true,
188
+ permissionMode: true,
189
+ };
190
+ /** The same, for the payload itself. Same argument, one level up. */
191
+ export const SESSION_NEW_KEYS = {
192
+ kind: true,
193
+ cwd: true,
194
+ workspaceKey: true,
195
+ correlationId: true,
196
+ request: true,
197
+ gate: true,
198
+ };
199
+ /** Build a `bulk_delivered`. Fills the stat pair with null so an absent value is stated, not deleted. */
200
+ export function bulkDelivered(deliveryId, byteCount, stat = {}) {
201
+ return {
202
+ kind: 'bulk_delivered',
203
+ deliveryId,
204
+ byteCount,
205
+ sizeBytes: stat.sizeBytes ?? null,
206
+ mtimeMs: stat.mtimeMs ?? null,
207
+ };
208
+ }
209
+ /**
210
+ * The most releases one `workspace_release_bulk` may carry: one inventory page, so a controller
211
+ * can clean what it was just shown in one ask, and an answer of that size stays well under the
212
+ * frame cap.
213
+ */
214
+ export const MAX_BULK_RELEASES = 25;
215
+ /** Build a `workspace_release`. A string target is a key; `{ path }` addresses by path. */
216
+ export function workspaceRelease(requestId, target, flags = {}) {
217
+ return {
218
+ kind: 'workspace_release',
219
+ requestId,
220
+ workspaceKey: typeof target === 'string' ? target : null,
221
+ path: typeof target === 'string' ? null : target.path,
222
+ deleteBranch: flags.deleteBranch ?? false,
223
+ force: flags.force ?? false,
224
+ };
225
+ }
226
+ /** Build a `workspace_release_result`. Every omitted member takes the released-or-absent value. */
227
+ export function workspaceReleaseResult(requestId, outcome = {}) {
228
+ return { kind: 'workspace_release_result', requestId, ...workspaceReleaseEntryResult(outcome) };
229
+ }
230
+ /** Build one entry result with the released-or-absent defaults. */
231
+ export function workspaceReleaseEntryResult(outcome = {}) {
232
+ return {
233
+ workspaceKey: outcome.workspaceKey ?? null,
234
+ path: outcome.path ?? null,
235
+ directoryRemoved: outcome.directoryRemoved ?? false,
236
+ branchDeleted: outcome.branchDeleted ?? false,
237
+ refusal: outcome.refusal ?? null,
238
+ };
239
+ }
240
+ /** Build a `workspace_release_bulk`. */
241
+ export function workspaceReleaseBulk(requestId, releases) {
242
+ return { kind: 'workspace_release_bulk', requestId, releases };
243
+ }
244
+ /** Build a `workspace_release_bulk_result`. */
245
+ export function workspaceReleaseBulkResult(requestId, results) {
246
+ return { kind: 'workspace_release_bulk_result', requestId, results };
247
+ }
248
+ /**
249
+ * The most entries one `host_configure` may carry: the closed config key set is smaller than this,
250
+ * so a legal ask never meets the bound and an ask past it is malformed rather than large.
251
+ */
252
+ export const MAX_CONFIGURE_ENTRIES = 8;
253
+ /**
254
+ * How many worktrees one `workspace_list_result` carries. A path is bounded at 1000 characters, a
255
+ * branch at 400 and a key at 200, so a full page stays under the frame cap with the same margin
256
+ * the transcript page keeps.
257
+ */
258
+ export const WORKSPACE_PAGE_SIZE = 25;
259
+ /**
260
+ * How many entries one `transcript_list_result` carries.
261
+ *
262
+ * Bounded so the frame stays well under `MAX_FRAME_BYTES` (64 KiB): an entry is a slug (measured
263
+ * up to about 80 characters on one real 139-slug corpus), a 36-character session id and two
264
+ * integers, comfortably under 300 bytes of JSON each, so 100 entries is under half the cap even
265
+ * with slugs twice as long as any measured one.
266
+ */
267
+ export const TRANSCRIPT_PAGE_SIZE = 100;
268
+ /**
269
+ * The bulk-lane locator namespace for a transcript read: `claude-transcript:{projectSlug}/{sessionId}`.
270
+ * A `bulk_request.what` that a host resolves to the agent CLI's own transcript files starts with
271
+ * this prefix; the slug is the CLI's flattened project directory name and is opaque.
272
+ */
273
+ export const TRANSCRIPT_WHAT_PREFIX = 'claude-transcript:';
274
+ /** Build a `workspace_list`. The only supported way to put one on the wire. */
275
+ export function workspaceList(requestId, fromIndex = 0) {
276
+ return { kind: 'workspace_list', requestId, fromIndex };
277
+ }
278
+ /** Build a `workspace_list_result`. Fills `refusal` with null, the answered page. */
279
+ export function workspaceListResult(requestId, entries, page, refusal) {
280
+ return {
281
+ kind: 'workspace_list_result',
282
+ requestId,
283
+ entries,
284
+ totalCount: page.totalCount,
285
+ nextIndex: page.nextIndex ?? null,
286
+ defaultBranch: page.defaultBranch ?? null,
287
+ refusal: refusal ?? null,
288
+ };
289
+ }
290
+ /** Build a `host_configure`. The only supported way to put one on the wire. */
291
+ export function hostConfigure(requestId, entries) {
292
+ return { kind: 'host_configure', requestId, entries };
293
+ }
294
+ /** Build a `host_configure_result`. Fills `refusal` with null, the applied answer. */
295
+ export function hostConfigureResult(requestId, configuration, overriddenByEnvironment, refusal, pendingRestart = []) {
296
+ return {
297
+ kind: 'host_configure_result',
298
+ requestId,
299
+ configuration,
300
+ overriddenByEnvironment,
301
+ pendingRestart,
302
+ refusal: refusal ?? null,
303
+ };
304
+ }
305
+ /** Build a `session_list`. The only supported way to put one on the wire. */
306
+ export function sessionList(requestId) {
307
+ return { kind: 'session_list', requestId };
308
+ }
309
+ /** Build a `session_list_result`. */
310
+ export function sessionListResult(requestId, sessions, counts) {
311
+ return {
312
+ kind: 'session_list_result',
313
+ requestId,
314
+ sessions,
315
+ liveCount: counts.liveCount,
316
+ provisioningCount: counts.provisioningCount,
317
+ };
318
+ }
319
+ /** Build a `transcript_list`. `fromIndex` defaults to the start. */
320
+ export function transcriptList(requestId, fromIndex = 0) {
321
+ return { kind: 'transcript_list', requestId, fromIndex };
322
+ }
323
+ /** Build a `transcript_list_result`. Fills `nextIndex` with null so absence is stated, not deleted. */
324
+ export function transcriptListResult(requestId, entries, totals) {
325
+ return {
326
+ kind: 'transcript_list_result',
327
+ requestId,
328
+ entries,
329
+ totalCount: totals.totalCount,
330
+ nextIndex: totals.nextIndex ?? null,
331
+ };
332
+ }
333
+ /** Build a `transcript_tail`. Fills `needle` with null, meaning "any user-text entry". */
334
+ export function transcriptTail(requestId, projectSlug, sessionId, fromOffset, needle) {
335
+ return { kind: 'transcript_tail', requestId, projectSlug, sessionId, fromOffset, needle: needle ?? null };
336
+ }
337
+ /** Build a `transcript_tail_result`. Fills the stat pair with null so absence is stated. */
338
+ export function transcriptTailResult(requestId, answer) {
339
+ return {
340
+ kind: 'transcript_tail_result',
341
+ requestId,
342
+ found: answer.found,
343
+ absent: answer.absent,
344
+ newOffset: answer.newOffset,
345
+ sizeBytes: answer.sizeBytes ?? null,
346
+ mtimeMs: answer.mtimeMs ?? null,
347
+ };
348
+ }
349
+ /** Build a `transcript_failed`. */
350
+ export function transcriptFailed(requestId, refusal) {
351
+ return { kind: 'transcript_failed', requestId, refusal };
352
+ }
353
+ // ---------------------------------------------------------------------------
354
+ // The repository read: a controller reading the operator's checkout, jailed and bounded.
355
+ // ---------------------------------------------------------------------------
356
+ /**
357
+ * How many entries one `repository_list_result` carries. A directory with more than this many
358
+ * children is listed to the cap and marked `truncated`; names are bounded by the filesystem, so a
359
+ * full page stays well under the frame cap.
360
+ */
361
+ export const MAX_REPOSITORY_ENTRIES = 500;
362
+ /**
363
+ * The most text one `repository_read_result` carries. The frame cap is 64 KiB and the envelope plus
364
+ * JSON escaping must fit inside it, so the text is bounded below the cap with margin; a longer file
365
+ * is answered to this many bytes and marked `truncated`.
366
+ */
367
+ export const MAX_REPOSITORY_READ_BYTES = 48 * 1024;
368
+ /** Build a `repository_list`. The only supported way to put one on the wire. */
369
+ export function repositoryList(requestId, path = '') {
370
+ return { kind: 'repository_list', requestId, path };
371
+ }
372
+ /** Build a `repository_list_result`. Fills `refusal` with null, the listed answer. */
373
+ export function repositoryListResult(requestId, entries, truncated = false, refusal) {
374
+ return { kind: 'repository_list_result', requestId, entries, truncated, refusal: refusal ?? null };
375
+ }
376
+ /** Build a `repository_read`. Fills `maxBytes` with the cap. */
377
+ export function repositoryRead(requestId, path, maxBytes = MAX_REPOSITORY_READ_BYTES) {
378
+ return { kind: 'repository_read', requestId, path, maxBytes };
379
+ }
380
+ /** Build a `repository_read_result`. Fills `refusal` with null, the read answer. */
381
+ export function repositoryReadResult(requestId, read, refusal) {
382
+ return {
383
+ kind: 'repository_read_result',
384
+ requestId,
385
+ text: read.text,
386
+ sizeBytes: read.sizeBytes,
387
+ truncated: read.truncated,
388
+ refusal: refusal ?? null,
389
+ };
390
+ }
391
+ export function readRefusal(wire) {
392
+ if (isRefusalReason(wire.reason)) {
393
+ return { recognised: true, refusal: { reason: wire.reason, detail: wire.detail } };
394
+ }
395
+ return { recognised: false, raw: wire.reason, detail: wire.detail };
396
+ }
397
+ /**
398
+ * What may be dropped when the offline queue is full, stated as data so the decision lives in one
399
+ * place instead of inside a queue method. Everything absent from this list is a transition or a
400
+ * receipt, and losing one of those is a lie about what happened.
401
+ */
402
+ export const DROPPABLE_KINDS = ['session_delta'];
403
+ export function isDroppable(kind) {
404
+ return DROPPABLE_KINDS.includes(kind);
405
+ }
406
+ // ---------------------------------------------------------------------------
407
+ // Control payloads — the link's own lifecycle. Never sequenced, never replayed.
408
+ // ---------------------------------------------------------------------------
409
+ /**
410
+ * The longest value `HostConfiguration` carries. Paths and URLs are host-controlled text on a
411
+ * frame the controller must accept before anything else, so each is bounded here rather than
412
+ * trusted; a controller stores them under the same cap.
413
+ */
414
+ export const MAX_CONFIGURATION_VALUE_LENGTH = 1000;
415
+ /** A `HostConfiguration` with nothing set: what a host composed without one reports. */
416
+ export function unsetHostConfiguration() {
417
+ return {
418
+ repositoryRoot: null,
419
+ workspaceRoot: null,
420
+ branchScheme: null,
421
+ transcriptsRoot: null,
422
+ controllerUrl: null,
423
+ decisionUrl: null,
424
+ agentHome: null,
425
+ };
426
+ }
427
+ export function isSessionFrame(frame) {
428
+ return frame.frame === 'session';
429
+ }
430
+ export function isControlFrame(frame) {
431
+ return frame.frame === 'control';
432
+ }
433
+ /**
434
+ * A frame's identity, and the reason `seq` is shaped this way: this string is exactly what an SSE
435
+ * `Last-Event-ID` header carries. Moving to one stream per session later needs no change to the
436
+ * frame; the cursor set decomposes into per-stream ids one for one.
437
+ */
438
+ export function frameId(frame) {
439
+ return `${frame.sessionId}/${frame.seq}`;
440
+ }
441
+ //# sourceMappingURL=frames.js.map
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The link's own state, as transitions that carry why.
3
+ *
4
+ * Reconnects are the most common thing that happens to an unattended run, so the trace has to show
5
+ * them — and a state change without a cause is exactly as useful as no state change at all when
6
+ * you are reading back a night nobody watched. `cause` is required by the type, so a transition
7
+ * without one does not compile.
8
+ */
9
+ /**
10
+ * `open` is the socket; `accepted` is the controller's welcome. They are two states because they are
11
+ * two facts an operator asks about separately: a link that opens and is never welcomed is a version
12
+ * or credential problem, not a network one, and a reporter that saw only `open` could not tell them
13
+ * apart.
14
+ */
15
+ export type LinkState = 'idle' | 'connecting' | 'open' | 'accepted' | 'backoff' | 'closed';
16
+ export declare const LINK_CAUSES: readonly ["start_requested", "socket_connected", "hello_completed", "socket_error", "socket_closed", "heartbeat_timeout", "connect_timeout", "protocol_version_rejected", "replay_requested", "credential_unavailable", "credential_rejected", "retry_scheduled", "shutdown_requested"];
17
+ export type LinkCause = (typeof LINK_CAUSES)[number];
18
+ export interface LinkTransition {
19
+ readonly from: LinkState;
20
+ readonly to: LinkState;
21
+ readonly cause: LinkCause;
22
+ readonly at: string;
23
+ /** Free text for a human reading a log. Never branched on. */
24
+ readonly detail: string | null;
25
+ }
26
+ export declare class LinkStateMachine {
27
+ #private;
28
+ get state(): LinkState;
29
+ /** Moves, and returns the transition. Returns null when already in `to` — no self-loops. */
30
+ to(next: LinkState, cause: LinkCause, at: string, detail?: string | null): LinkTransition | null;
31
+ }
32
+ //# sourceMappingURL=link-state.d.ts.map
@@ -0,0 +1,39 @@
1
+ export const LINK_CAUSES = [
2
+ 'start_requested',
3
+ 'socket_connected',
4
+ 'hello_completed',
5
+ 'socket_error',
6
+ 'socket_closed',
7
+ 'heartbeat_timeout',
8
+ // The dial never completed its upgrade inside `connectTimeoutMs`; abandoned and retried.
9
+ 'connect_timeout',
10
+ 'protocol_version_rejected',
11
+ // The controller closed the socket because a session's seq jumped past what it holds; the next
12
+ // dial replays from the cursor it reports. Distinct from a version rejection, which a retry cannot heal.
13
+ 'replay_requested',
14
+ 'credential_unavailable',
15
+ // Not the same event as `credential_unavailable`; the difference is whether a retry can help.
16
+ // `credential_unavailable` means this attempt had no header to present and the next one may;
17
+ // `credential_rejected` means the identity provider has refused the material outright and will
18
+ // keep refusing until a person signs in. The first belongs in `backoff`. The second is the only
19
+ // cause on this list that ends in `closed` without a shutdown having been requested, because
20
+ // retrying it is a loop with no exit that reports success when the process finally drains.
21
+ 'credential_rejected',
22
+ 'retry_scheduled',
23
+ 'shutdown_requested',
24
+ ];
25
+ export class LinkStateMachine {
26
+ #state = 'idle';
27
+ get state() {
28
+ return this.#state;
29
+ }
30
+ /** Moves, and returns the transition. Returns null when already in `to` — no self-loops. */
31
+ to(next, cause, at, detail = null) {
32
+ if (this.#state === next)
33
+ return null;
34
+ const transition = { from: this.#state, to: next, cause, at, detail };
35
+ this.#state = next;
36
+ return transition;
37
+ }
38
+ }
39
+ //# sourceMappingURL=link-state.js.map
@@ -0,0 +1,109 @@
1
+ import type { Clock, Ticker } from '../core/time.js';
2
+ import type { Refusal } from '../core/refusal.js';
3
+ import type { Result } from '../core/result.js';
4
+ import type { ControllerCredential } from './credential.js';
5
+ import type { HostConfiguration, SessionCursor, SessionFrame, SessionPayload } from './frames.js';
6
+ import type { BackoffOptions } from './backoff.js';
7
+ import type { LinkTransition } from './link-state.js';
8
+ export interface LinkHandlers {
9
+ /** Every state change, with its cause. */
10
+ onTransition(transition: LinkTransition): void;
11
+ /** An in-order session frame from the controller. Duplicates never reach here. */
12
+ onSessionFrame(frame: SessionFrame): void;
13
+ /** Frames were lost between the two ends. Loud by design; replay exists to make this not happen. */
14
+ onGap(sessionId: string, expected: number, received: number): void;
15
+ /** Anything the link declined to do, named. */
16
+ onRefusal(refused: Refusal): void;
17
+ }
18
+ export interface LinkOptions {
19
+ readonly url: string;
20
+ readonly hostId: string;
21
+ readonly handlers: LinkHandlers;
22
+ readonly credential?: ControllerCredential;
23
+ readonly backoff?: BackoffOptions;
24
+ readonly clock?: Clock;
25
+ readonly ticker?: Ticker;
26
+ readonly random?: () => number;
27
+ /** Frames held while the link is down. */
28
+ readonly queueCapacity?: number;
29
+ /**
30
+ * Extra capability markers for the `link_hello`, beside the built-in `bulk-post`.
31
+ * Values in an open string list: a peer that does not know one ignores it, so declaring a new
32
+ * marker is not a protocol change. The list is declarative: the controller is not obliged to act
33
+ * on any of it (see `LinkWelcome`; negotiation is declared on both sides, honoured by neither).
34
+ */
35
+ readonly capabilities?: readonly string[];
36
+ /**
37
+ * The values this host runs with, reported on every `link_hello`. Omitted, the hello
38
+ * reports every member as null: a link composed without one has nothing to say, and says so
39
+ * rather than guessing.
40
+ */
41
+ readonly configuration?: HostConfiguration;
42
+ /** The keys whose file value is not in effect. Omitted, the hello reports none. */
43
+ readonly pendingRestart?: readonly string[];
44
+ readonly heartbeatIntervalMs?: number;
45
+ /** No pong inside this window and the socket is declared dead, however alive TCP thinks it is. */
46
+ readonly heartbeatTimeoutMs?: number;
47
+ /** Above this many buffered bytes the link stops writing and queues instead. */
48
+ readonly highWaterMarkBytes?: number;
49
+ /**
50
+ * How long an ended session's written-but-unacked frames are held. See `ENDED_RETENTION_MS`.
51
+ *
52
+ * An option for the same reason the heartbeat's two timings are: the default is the decision, and
53
+ * a test that had to wait a real minute to observe a bound would be a test nobody runs.
54
+ */
55
+ readonly endedRetentionMs?: number;
56
+ /**
57
+ * How long a dial may sit in `connecting` before it is abandoned and retried. Without it a
58
+ * controller that accepts the TCP handshake but never completes the upgrade (an API mid-restart)
59
+ * holds this link in `connecting` for minutes, until the OS gives up, while sessions wait.
60
+ */
61
+ readonly connectTimeoutMs?: number;
62
+ }
63
+ export declare class ControllerLink {
64
+ #private;
65
+ constructor(options: LinkOptions);
66
+ /**
67
+ * Replace what the next `link_hello` declares. A host that reconfigured itself dials the same
68
+ * controller with the same identity; only the description moves, and it moves at the next
69
+ * hello rather than on a frame of its own, because the hello is where a controller reads it.
70
+ */
71
+ announce(capabilities: readonly string[], configuration: HostConfiguration, pendingRestart?: readonly string[]): void;
72
+ get state(): import("./link-state.js").LinkState;
73
+ /** The version the controller chose at the last accepted handshake; null before one. */
74
+ get negotiatedVersion(): number | null;
75
+ get queueStats(): import("./queue.js").QueueStats;
76
+ /** Cursors of what this side has received: sent to the controller so it replays the right frames. */
77
+ cursors(): SessionCursor[];
78
+ start(): void;
79
+ /** Graceful close. Idempotent, and clears every timer: a stopped link holds no handles. */
80
+ stop(detail?: string): void;
81
+ /**
82
+ * Accept a payload for delivery: retained until acked, written as soon as the link allows.
83
+ *
84
+ * A `seq` is minted only when the frame is first written to the wire, so nothing refused or
85
+ * discarded before that point ever had one — the wire's numbering stays dense whatever happens
86
+ * here. `ok` means accepted and retained; a refusal names why nothing was accepted, and it is
87
+ * the caller's pressure signal — a dropped delta and a refused transition are different events
88
+ * and only the caller knows which one it can live with.
89
+ */
90
+ send(sessionId: string, payload: SessionPayload): Result<void>;
91
+ /**
92
+ * Release what this session holds. Called at session end, always.
93
+ *
94
+ * It does not discard frames the controller has not acked; see `BoundedFrameQueue.forget`.
95
+ * A session's last frames are its most important ones and are exactly the ones still unacked if
96
+ * the link is down when it ends. What is released here is everything that cannot still be owed:
97
+ * pending entries, which never had a seq, and the inbound cursor, which is about what the
98
+ * controller sent this side rather than what this side owes it.
99
+ *
100
+ * The outbound counter outlives the call while anything is still retained, and that is not an
101
+ * oversight. Resetting it while stamped frames for the same key are awaiting replay would let a
102
+ * later frame on that key be numbered below one already on the wire: a seq regression, which the
103
+ * receiver reads as a hole it can never fill. The counter is released by the ack that empties the
104
+ * retention instead, so the bound this method exists to keep is kept a moment later rather than
105
+ * abandoned.
106
+ */
107
+ forgetSession(sessionId: string): void;
108
+ }
109
+ //# sourceMappingURL=link.d.ts.map