@naswerks/periscope 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +2 -2
  3. package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
  4. package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
  5. package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
  6. package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
  7. package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
  8. package/contracts/wire-vectors/bulk_request.full.json +21 -0
  9. package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
  10. package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
  11. package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
  12. package/contracts/wire-vectors/link_ack.cursors.json +25 -0
  13. package/contracts/wire-vectors/link_ack.empty.json +16 -0
  14. package/contracts/wire-vectors/link_bye.cause.json +16 -0
  15. package/contracts/wire-vectors/link_hello.empty.json +33 -0
  16. package/contracts/wire-vectors/link_hello.full.json +44 -0
  17. package/contracts/wire-vectors/link_ping.nonce.json +16 -0
  18. package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
  19. package/contracts/wire-vectors/link_pong.nonce.json +16 -0
  20. package/contracts/wire-vectors/link_welcome.empty.json +18 -0
  21. package/contracts/wire-vectors/link_welcome.full.json +25 -0
  22. package/contracts/wire-vectors/refused.empty.json +10 -0
  23. package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
  24. package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
  25. package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
  26. package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
  27. package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
  28. package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
  29. package/contracts/wire-vectors/refused.missing-member.json +10 -0
  30. package/contracts/wire-vectors/refused.not-json.json +10 -0
  31. package/contracts/wire-vectors/refused.over-limit.json +10 -0
  32. package/contracts/wire-vectors/refused.seq-zero.json +10 -0
  33. package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
  34. package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
  35. package/contracts/wire-vectors/repository_list.root.json +19 -0
  36. package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
  37. package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
  38. package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
  39. package/contracts/wire-vectors/repository_read.head.json +20 -0
  40. package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
  41. package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
  42. package/contracts/wire-vectors/session_cancel.bare.json +17 -0
  43. package/contracts/wire-vectors/session_configure.full.json +22 -0
  44. package/contracts/wire-vectors/session_configure.minimal.json +20 -0
  45. package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
  46. package/contracts/wire-vectors/session_list.request.json +18 -0
  47. package/contracts/wire-vectors/session_list_result.empty.json +21 -0
  48. package/contracts/wire-vectors/session_list_result.full.json +36 -0
  49. package/contracts/wire-vectors/session_new.full.json +72 -0
  50. package/contracts/wire-vectors/session_new.minimal.json +22 -0
  51. package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
  52. package/contracts/wire-vectors/session_prompt.empty.json +18 -0
  53. package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
  54. package/contracts/wire-vectors/session_prompt.text.json +18 -0
  55. package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
  56. package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
  57. package/contracts/wire-vectors/session_update.agent-message.json +26 -0
  58. package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
  59. package/contracts/wire-vectors/session_update.state-transition.json +41 -0
  60. package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
  61. package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
  62. package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
  63. package/contracts/wire-vectors/transcript_list.paged.json +19 -0
  64. package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
  65. package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
  66. package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
  67. package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
  68. package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
  69. package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
  70. package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
  71. package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
  72. package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
  73. package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
  74. package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
  75. package/contracts/wire-vectors/workspace_release.request.json +22 -0
  76. package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
  77. package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
  78. package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
  79. package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
  80. package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
  81. package/package.json +6 -1
  82. package/src/bin/command.ts +198 -0
  83. package/src/bin/config.ts +104 -0
  84. package/src/bin/login.ts +149 -0
  85. package/src/bin/main.ts +204 -0
  86. package/src/bin/pair.ts +246 -0
  87. package/src/bin/periscope.ts +3 -0
  88. package/src/bin/posture.ts +232 -0
  89. package/src/bin/reconfigure.ts +266 -0
  90. package/src/bin/serve.ts +615 -0
  91. package/src/bin/status.ts +61 -0
  92. package/src/bin/workspaces.ts +280 -0
  93. package/src/control/backoff.ts +40 -0
  94. package/src/control/codec.ts +557 -0
  95. package/src/control/credential.ts +33 -0
  96. package/src/control/frames.ts +1586 -0
  97. package/src/control/link-state.ts +67 -0
  98. package/src/control/link.ts +894 -0
  99. package/src/control/queue.ts +248 -0
  100. package/src/control/seq.ts +76 -0
  101. package/src/control/stream-routing.ts +216 -0
  102. package/src/control/stream.ts +149 -0
  103. package/src/core/async-queue.ts +63 -0
  104. package/src/core/failure.ts +61 -0
  105. package/src/core/index.ts +22 -0
  106. package/src/core/keyed-turns.ts +47 -0
  107. package/src/core/paths.ts +84 -0
  108. package/src/core/refusal.ts +430 -0
  109. package/src/core/result.ts +27 -0
  110. package/src/core/time.ts +34 -0
  111. package/src/core/vocab.ts +44 -0
  112. package/src/core/workspace-id.ts +11 -0
  113. package/src/gate/command.ts +617 -0
  114. package/src/gate/decision.ts +173 -0
  115. package/src/gate/escalate.ts +164 -0
  116. package/src/gate/gate.ts +635 -0
  117. package/src/gate/index.ts +53 -0
  118. package/src/gate/jail.ts +210 -0
  119. package/src/gate/local.ts +115 -0
  120. package/src/gate/outcome.ts +164 -0
  121. package/src/gate/shell.ts +295 -0
  122. package/src/host/agent-process.ts +694 -0
  123. package/src/host/bulk-post.ts +166 -0
  124. package/src/host/claude-transcripts.ts +404 -0
  125. package/src/host/config-file.ts +246 -0
  126. package/src/host/git-facts.ts +119 -0
  127. package/src/host/hooks.ts +102 -0
  128. package/src/host/host.ts +1901 -0
  129. package/src/host/index.ts +92 -0
  130. package/src/host/link-state-file.ts +79 -0
  131. package/src/host/loopback.ts +177 -0
  132. package/src/host/machine.ts +27 -0
  133. package/src/host/mcp-server.ts +66 -0
  134. package/src/host/package-facts.ts +25 -0
  135. package/src/host/paired-credential-store.ts +140 -0
  136. package/src/host/paths.ts +175 -0
  137. package/src/host/repository-read.ts +245 -0
  138. package/src/host/session-store.ts +133 -0
  139. package/src/host/sign-in.ts +352 -0
  140. package/src/host/telemetry.ts +105 -0
  141. package/src/host/token-cache.ts +214 -0
  142. package/src/host/transcript-fs.ts +110 -0
  143. package/src/host/wire-request.ts +285 -0
  144. package/src/host/workspace-fs.ts +90 -0
  145. package/src/host/workspace-trust.ts +92 -0
  146. package/src/identity/authorize.ts +127 -0
  147. package/src/identity/config.ts +227 -0
  148. package/src/identity/credential.ts +176 -0
  149. package/src/identity/device-code.ts +217 -0
  150. package/src/identity/index.ts +84 -0
  151. package/src/identity/mode.ts +161 -0
  152. package/src/identity/paired-credential.ts +89 -0
  153. package/src/identity/pkce.ts +113 -0
  154. package/src/identity/store.ts +98 -0
  155. package/src/identity/token.ts +203 -0
  156. package/src/index.ts +140 -0
  157. package/src/mcp/descriptor.ts +75 -0
  158. package/src/mcp/index.ts +23 -0
  159. package/src/mcp/schema.ts +214 -0
  160. package/src/mcp/server.ts +180 -0
  161. package/src/persistence/entry.ts +142 -0
  162. package/src/persistence/index.ts +47 -0
  163. package/src/persistence/key.ts +77 -0
  164. package/src/persistence/mirror.ts +118 -0
  165. package/src/persistence/receipt.ts +182 -0
  166. package/src/persistence/retention.ts +118 -0
  167. package/src/persistence/store.ts +183 -0
  168. package/src/persistence/transition-log.ts +176 -0
  169. package/src/protocol.ts +215 -0
  170. package/src/sessions/index.ts +27 -0
  171. package/src/sessions/registry.ts +392 -0
  172. package/src/sessions/session.ts +440 -0
  173. package/src/sessions/spawn-env.ts +318 -0
  174. package/src/state/coverage.ts +360 -0
  175. package/src/state/index.ts +58 -0
  176. package/src/state/machine.ts +359 -0
  177. package/src/state/model.ts +417 -0
  178. package/src/state/observer.ts +499 -0
  179. package/src/state/reporter.ts +60 -0
  180. package/src/state/store.ts +157 -0
  181. package/src/telemetry/index.ts +10 -0
  182. package/src/telemetry/usage.ts +117 -0
  183. package/src/workspace/git-worktree.ts +551 -0
  184. package/src/workspace/index.ts +17 -0
  185. package/src/workspace/plain-dir.ts +132 -0
  186. package/src/workspace/provider.ts +159 -0
  187. package/src/workspace/worktree-porcelain.ts +118 -0
@@ -0,0 +1,557 @@
1
+ /**
2
+ * The wire edge. Bytes become types here and nowhere else.
3
+ *
4
+ * Two properties this file exists to hold:
5
+ * - Nothing inward of `decode` has seen an unvalidated object, and `decode` never throws — a
6
+ * malformed frame from a version-skewed stranger is a named refusal, not a crash.
7
+ * - Unknown fields survive. A newer peer's extra key is preserved rather than stripped, so an
8
+ * older host relaying a frame does not quietly destroy information it did not understand.
9
+ */
10
+ import { z } from 'zod';
11
+
12
+ import type { Result } from '../core/result.js';
13
+ import { ok, refuse } from '../core/result.js';
14
+ import { isRefusalReason } from '../core/refusal.js';
15
+ import type { Frame, JsonValue, WireRefusal } from './frames.js';
16
+ import {
17
+ MAX_BULK_RELEASES,
18
+ MAX_CONFIGURATION_VALUE_LENGTH,
19
+ MAX_CONFIGURE_ENTRIES,
20
+ MAX_FRAME_BYTES,
21
+ MAX_REPOSITORY_ENTRIES,
22
+ MAX_REPOSITORY_READ_BYTES,
23
+ } from './frames.js';
24
+ import { MAX_WORKSPACE_ID_LENGTH } from '../core/workspace-id.js';
25
+
26
+ /**
27
+ * UTF-8 byte length, the web-standard way.
28
+ *
29
+ * Not `Buffer.byteLength`: `Buffer` is a Node-only global. This module sits inside the
30
+ * `periscope/protocol` closure, whose claim is that a controller can import the wire contract
31
+ * without acquiring a runtime, and the pin guarding that claim reads imports, which a global is
32
+ * invisible to. `TextEncoder` is WHATWG and present in Node, browsers, Deno and Bun.
33
+ *
34
+ * One instance, module-level: `encode` runs per frame and a fresh encoder per call is pure waste.
35
+ */
36
+ const UTF8 = new TextEncoder();
37
+
38
+ function utf8ByteLength(text: string): number {
39
+ return UTF8.encode(text).length;
40
+ }
41
+
42
+ const jsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
43
+ z.union([
44
+ z.string(),
45
+ z.number(),
46
+ z.boolean(),
47
+ z.null(),
48
+ z.array(jsonValueSchema),
49
+ z.record(z.string(), jsonValueSchema),
50
+ ]),
51
+ );
52
+
53
+ const jsonObjectSchema = z.record(z.string(), jsonValueSchema);
54
+
55
+ /**
56
+ * The tolerance is on the decode side only, and it is not a widening of the vocabulary.
57
+ *
58
+ * `reason` is a plain string here rather than `z.enum(REFUSAL_REASONS)`: a closed enum would make
59
+ * a newer peer's added reason fatal, failing the whole `bulk_failed` frame as `frame-malformed`,
60
+ * so a delivery receipt would become unreadable because the failure had a name this build did not
61
+ * know. That would contradict this file's own header: unknown keys survive, and an unknown value
62
+ * of a known key is the same information one level in.
63
+ *
64
+ * What may be written is enforced below in `encode`: this host cannot emit an
65
+ * undeclared reason, checked at the moment a frame becomes bytes rather than trusted to a type. So
66
+ * the pair is strict out, tolerant in. Read the result with `readRefusal`, which narrows a known
67
+ * reason and preserves the raw string for one it has never seen.
68
+ */
69
+ const refusalSchema = z.looseObject({
70
+ reason: z.string().min(1),
71
+ detail: z.string(),
72
+ });
73
+
74
+ const sessionCursorSchema = z.looseObject({
75
+ sessionId: z.string().min(1),
76
+ seq: z.number().int().nonnegative(),
77
+ });
78
+
79
+ // Each value is host-controlled text on the one frame a controller reads before trusting anything,
80
+ // so the cap is enforced at the wire edge rather than left to the reader.
81
+ const configurationValueSchema = z.string().max(MAX_CONFIGURATION_VALUE_LENGTH).nullable();
82
+
83
+ const hostConfigurationSchema = z.looseObject({
84
+ repositoryRoot: configurationValueSchema,
85
+ workspaceRoot: configurationValueSchema,
86
+ branchScheme: configurationValueSchema,
87
+ transcriptsRoot: configurationValueSchema,
88
+ controllerUrl: configurationValueSchema,
89
+ decisionUrl: configurationValueSchema,
90
+ agentHome: configurationValueSchema,
91
+ });
92
+
93
+ /** The key names a hello or a configure result lists as pending: config keys, so short and few. */
94
+ const pendingRestartSchema = z.array(z.string().min(1).max(64)).max(MAX_CONFIGURE_ENTRIES);
95
+
96
+ const protocolRangeSchema = z.looseObject({
97
+ min: z.number().int().min(1),
98
+ max: z.number().int().min(1),
99
+ });
100
+
101
+ // `looseObject` throughout: unknown keys are carried, not stripped and not fatal.
102
+ /** The members of one release ask, shared by the single kind and the bulk entries. */
103
+ const workspaceReleaseEntryShape = {
104
+ workspaceKey: z.string().min(1).nullable(),
105
+ path: z.string().min(1).max(MAX_CONFIGURATION_VALUE_LENGTH).nullable(),
106
+ deleteBranch: z.boolean(),
107
+ force: z.boolean(),
108
+ };
109
+
110
+ /** The members of one release receipt, shared by the single result and the bulk entries. */
111
+ const workspaceReleaseEntryResultShape = {
112
+ workspaceKey: z.string().nullable(),
113
+ path: z.string().nullable(),
114
+ directoryRemoved: z.boolean(),
115
+ branchDeleted: z.boolean(),
116
+ refusal: refusalSchema.nullable(),
117
+ };
118
+
119
+ const sessionPayloadSchema = z.discriminatedUnion('kind', [
120
+ // The body is validated as JSON rather than against the transition's shape: this edge exists to
121
+ // keep malformed bytes out, and a newer peer's richer body must survive the crossing rather than
122
+ // be rejected here. The typed union is what a consumer reads it back as.
123
+ z.looseObject({ kind: z.literal('session_update'), body: jsonObjectSchema }),
124
+ z.looseObject({ kind: z.literal('session_delta'), body: jsonObjectSchema }),
125
+ // Every added field is `.nullable()`, never `.optional()`, and that mirrors the type on purpose.
126
+ // JSON has no `undefined`, so an optional field makes "the controller said nothing" and "the
127
+ // controller said unset" the same value after a round trip, the distinction this whole file
128
+ // exists to keep. `looseObject` still carries a newer peer's extra keys across intact.
129
+ //
130
+ // What is not in this schema matters as much: `sessionStore`, `sessionStoreFlush`, `spawn`,
131
+ // `onStderr` and `hooks` have no member here and cannot acquire one, because none of them has a
132
+ // JSON form.
133
+ z.looseObject({
134
+ kind: z.literal('session_new'),
135
+ // Nullable like every other member; null is "the provider decides" (protocol v3). A controller
136
+ // with no cwd to ask for (its host provisions worktrees) sends `null`, and a non-nullable field
137
+ // here would lose the whole frame to a silent decode refusal.
138
+ cwd: z.string().nullable(),
139
+ // Required-and-nullable like every other member, and deliberately not `.default(null)`: a v4
140
+ // frame omitting it must refuse, which is exactly what the v5 version bump announces. Null is
141
+ // "use the session key"; see `SessionNew.workspaceKey` for what a non-null key means.
142
+ workspaceKey: z.string().nullable(),
143
+ correlationId: z.string().nullable(),
144
+ gate: z
145
+ .looseObject({
146
+ decisionTimeoutMs: z.number().int().positive().nullable(),
147
+ holdAfterMs: z.number().int().nonnegative().nullable(),
148
+ matcherTimeoutSeconds: z.number().int().positive().nullable(),
149
+ })
150
+ .nullable(),
151
+ request: z
152
+ .looseObject({
153
+ resume: z.string().min(1).nullable(),
154
+ fork: z.boolean().nullable(),
155
+ settingSources: z.array(z.string()).nullable(),
156
+ plugins: z
157
+ .array(
158
+ z.looseObject({
159
+ type: z.string().min(1),
160
+ path: z.string().min(1),
161
+ skipMcpDiscovery: z.boolean().nullable(),
162
+ }),
163
+ )
164
+ .nullable(),
165
+ // Opaque here and narrowed by the host: the server-config union belongs to the SDK, which
166
+ // this file is structurally forbidden to reach. Keeping it a JSON object is what lets the
167
+ // wire edge stay runtime-agnostic while the host still refuses a shape it cannot use.
168
+ mcpServers: jsonObjectSchema.nullable(),
169
+ strictMcpConfig: z.boolean().nullable(),
170
+ includePartialMessages: z.boolean().nullable(),
171
+ thinking: jsonObjectSchema.nullable(),
172
+ // v6: a plain string each; the host narrows to the SDK's vocabulary and refuses by name.
173
+ effort: z.string().min(1).nullable(),
174
+ permissionMode: z.string().min(1).nullable(),
175
+ forwardSubagentText: z.boolean().nullable(),
176
+ env: z
177
+ .looseObject({
178
+ extraAllowedKeys: z.array(z.string()).nullable(),
179
+ extraDeniedKeys: z.array(z.string()).nullable(),
180
+ extraEnv: z.record(z.string(), z.string()).nullable(),
181
+ })
182
+ .nullable(),
183
+ model: z.string().min(1).nullable(),
184
+ // A string, a list of strings, or a preset object — all three are what the SDK accepts, so
185
+ // the wire carries any JSON value and the host hands it straight through.
186
+ systemPrompt: jsonValueSchema.nullable(),
187
+ })
188
+ .nullable(),
189
+ }),
190
+ z.looseObject({ kind: z.literal('session_prompt'), text: z.string() }),
191
+ z.looseObject({ kind: z.literal('session_cancel') }),
192
+ // v6: live model / permission mode / thinking. Every member nullable — null is "not asked".
193
+ z.looseObject({
194
+ kind: z.literal('session_configure'),
195
+ model: z.string().min(1).nullable(),
196
+ permissionMode: z.string().min(1).nullable(),
197
+ thinking: jsonObjectSchema.nullable(),
198
+ }),
199
+ z.looseObject({
200
+ kind: z.literal('bulk_request'),
201
+ deliveryId: z.string().min(1),
202
+ what: z.string(),
203
+ fromOffset: z.number().int().nonnegative(),
204
+ postUrl: z.string().min(1),
205
+ }),
206
+ z.looseObject({
207
+ kind: z.literal('bulk_delivered'),
208
+ deliveryId: z.string().min(1),
209
+ byteCount: z.number().int().nonnegative(),
210
+ // The stat pair for rewrite detection. `.nullable()` never `.optional()`, but absent is
211
+ // also tolerated here (`.default(null)`), because a v3 peer's receipt legitimately omits keys
212
+ // it has never heard of, and destroying a delivery receipt over two unknowns would repeat the
213
+ // exact failure the refusal-reason tolerance above exists to prevent.
214
+ sizeBytes: z.number().int().nonnegative().nullable().default(null),
215
+ mtimeMs: z.number().int().nonnegative().nullable().default(null),
216
+ }),
217
+ z.looseObject({
218
+ kind: z.literal('bulk_failed'),
219
+ deliveryId: z.string().min(1),
220
+ refusal: refusalSchema,
221
+ }),
222
+ // Discovery. Every added optional is `.nullable()`, never `.optional()`, per the rule above.
223
+ z.looseObject({
224
+ kind: z.literal('session_list'),
225
+ requestId: z.string().min(1),
226
+ }),
227
+ z.looseObject({
228
+ kind: z.literal('session_list_result'),
229
+ requestId: z.string().min(1),
230
+ sessions: z.array(
231
+ z.looseObject({
232
+ sessionKey: z.string().min(1),
233
+ sessionId: z.string().nullable(),
234
+ state: z.string().min(1),
235
+ cwd: z.string().nullable(),
236
+ startedAt: z.string().nullable(),
237
+ }),
238
+ ),
239
+ liveCount: z.number().int().nonnegative(),
240
+ provisioningCount: z.number().int().nonnegative(),
241
+ }),
242
+ z.looseObject({
243
+ kind: z.literal('transcript_list'),
244
+ requestId: z.string().min(1),
245
+ fromIndex: z.number().int().nonnegative(),
246
+ }),
247
+ z.looseObject({
248
+ kind: z.literal('transcript_list_result'),
249
+ requestId: z.string().min(1),
250
+ entries: z.array(
251
+ z.looseObject({
252
+ projectSlug: z.string().min(1),
253
+ sessionId: z.string().min(1),
254
+ sizeBytes: z.number().int().nonnegative(),
255
+ mtimeMs: z.number().int().nonnegative(),
256
+ cwd: z.string().nullable().default(null),
257
+ }),
258
+ ),
259
+ totalCount: z.number().int().nonnegative(),
260
+ nextIndex: z.number().int().nonnegative().nullable(),
261
+ }),
262
+ z.looseObject({
263
+ kind: z.literal('transcript_tail'),
264
+ requestId: z.string().min(1),
265
+ projectSlug: z.string().min(1),
266
+ sessionId: z.string().min(1),
267
+ fromOffset: z.number().int().nonnegative(),
268
+ needle: z.string().nullable(),
269
+ }),
270
+ z.looseObject({
271
+ kind: z.literal('transcript_tail_result'),
272
+ requestId: z.string().min(1),
273
+ found: z.boolean(),
274
+ absent: z.boolean(),
275
+ newOffset: z.number().int().nonnegative(),
276
+ sizeBytes: z.number().int().nonnegative().nullable(),
277
+ mtimeMs: z.number().int().nonnegative().nullable(),
278
+ }),
279
+ z.looseObject({
280
+ kind: z.literal('transcript_failed'),
281
+ requestId: z.string().min(1),
282
+ refusal: refusalSchema,
283
+ }),
284
+ // The reap (v5; the flags, the path address and the receipt from v7). Host-scoped like the
285
+ // discovery asks; the result's `refusal` is nullable because null is the released answer: one
286
+ // kind for every exit, see `WorkspaceReleaseResult`. Key-or-path is the HOST's screen, not the
287
+ // codec's: both or neither is a well-formed frame refused by name, never a malformed one.
288
+ z.looseObject({
289
+ kind: z.literal('workspace_release'),
290
+ requestId: z.string().min(1),
291
+ ...workspaceReleaseEntryShape,
292
+ }),
293
+ z.looseObject({
294
+ kind: z.literal('workspace_release_result'),
295
+ requestId: z.string().min(1),
296
+ ...workspaceReleaseEntryResultShape,
297
+ }),
298
+ z.looseObject({
299
+ kind: z.literal('workspace_release_bulk'),
300
+ requestId: z.string().min(1),
301
+ releases: z.array(z.looseObject(workspaceReleaseEntryShape)).min(1).max(MAX_BULK_RELEASES),
302
+ }),
303
+ z.looseObject({
304
+ kind: z.literal('workspace_release_bulk_result'),
305
+ requestId: z.string().min(1),
306
+ results: z.array(z.looseObject(workspaceReleaseEntryResultShape)).max(MAX_BULK_RELEASES),
307
+ }),
308
+ // The configure pair. Host-scoped like the reap; the result's `refusal` is nullable because
309
+ // null is the applied answer, and the effective configuration rides every exit.
310
+ z.looseObject({
311
+ kind: z.literal('host_configure'),
312
+ requestId: z.string().min(1),
313
+ entries: z
314
+ .array(z.looseObject({ key: z.string().min(1).max(64), value: configurationValueSchema }))
315
+ .min(1)
316
+ .max(MAX_CONFIGURE_ENTRIES),
317
+ }),
318
+ z.looseObject({
319
+ kind: z.literal('host_configure_result'),
320
+ requestId: z.string().min(1),
321
+ configuration: hostConfigurationSchema,
322
+ overriddenByEnvironment: z.array(z.string()),
323
+ pendingRestart: pendingRestartSchema,
324
+ refusal: refusalSchema.nullable(),
325
+ }),
326
+ // The inventory. Host-scoped and paged like `transcript_list`; the result carries the
327
+ // refusal so every exit is one kind.
328
+ z.looseObject({
329
+ kind: z.literal('workspace_list'),
330
+ requestId: z.string().min(1),
331
+ fromIndex: z.number().int().nonnegative(),
332
+ }),
333
+ z.looseObject({
334
+ kind: z.literal('workspace_list_result'),
335
+ requestId: z.string().min(1),
336
+ entries: z.array(
337
+ z.looseObject({
338
+ key: z.string().min(1).max(MAX_WORKSPACE_ID_LENGTH),
339
+ path: z.string().min(1).max(MAX_CONFIGURATION_VALUE_LENGTH),
340
+ branch: z.string().max(400).nullable(),
341
+ head: z.string().max(64).nullable(),
342
+ detached: z.boolean(),
343
+ locked: z.boolean(),
344
+ prunable: z.boolean(),
345
+ merged: z.boolean().nullable(),
346
+ aheadCount: z.number().int().nonnegative().nullable(),
347
+ lastCommitAt: z.string().nullable(),
348
+ }),
349
+ ),
350
+ totalCount: z.number().int().nonnegative(),
351
+ nextIndex: z.number().int().nonnegative().nullable(),
352
+ defaultBranch: z.string().max(400).nullable(),
353
+ refusal: refusalSchema.nullable(),
354
+ }),
355
+ // The repository read. Host-scoped like `transcript_list`; both results carry the refusal so
356
+ // every exit is one kind. The path is relative text the HOST jails; the codec bounds its length.
357
+ z.looseObject({
358
+ kind: z.literal('repository_list'),
359
+ requestId: z.string().min(1),
360
+ path: z.string().max(MAX_CONFIGURATION_VALUE_LENGTH),
361
+ }),
362
+ z.looseObject({
363
+ kind: z.literal('repository_list_result'),
364
+ requestId: z.string().min(1),
365
+ entries: z
366
+ .array(
367
+ z.looseObject({
368
+ name: z.string().min(1).max(255),
369
+ directory: z.boolean(),
370
+ sizeBytes: z.number().int().nonnegative(),
371
+ mtimeMs: z.number().int().nonnegative(),
372
+ }),
373
+ )
374
+ .max(MAX_REPOSITORY_ENTRIES),
375
+ truncated: z.boolean(),
376
+ refusal: refusalSchema.nullable(),
377
+ }),
378
+ z.looseObject({
379
+ kind: z.literal('repository_read'),
380
+ requestId: z.string().min(1),
381
+ path: z.string().min(1).max(MAX_CONFIGURATION_VALUE_LENGTH),
382
+ maxBytes: z.number().int().min(1).max(MAX_REPOSITORY_READ_BYTES),
383
+ }),
384
+ z.looseObject({
385
+ kind: z.literal('repository_read_result'),
386
+ requestId: z.string().min(1),
387
+ text: z.string().nullable(),
388
+ sizeBytes: z.number().int().nonnegative(),
389
+ truncated: z.boolean(),
390
+ refusal: refusalSchema.nullable(),
391
+ }),
392
+ ]);
393
+
394
+ const controlPayloadSchema = z.discriminatedUnion('kind', [
395
+ z.looseObject({
396
+ kind: z.literal('link_hello'),
397
+ protocolVersion: z.number().int(),
398
+ hostId: z.string().min(1),
399
+ capabilities: z.array(z.string()),
400
+ cursors: z.array(sessionCursorSchema),
401
+ configuration: hostConfigurationSchema,
402
+ pendingRestart: pendingRestartSchema,
403
+ protocolRange: protocolRangeSchema,
404
+ }),
405
+ z.looseObject({
406
+ kind: z.literal('link_welcome'),
407
+ protocolVersion: z.number().int(),
408
+ capabilities: z.array(z.string()),
409
+ cursors: z.array(sessionCursorSchema),
410
+ }),
411
+ z.looseObject({ kind: z.literal('link_ack'), cursors: z.array(sessionCursorSchema) }),
412
+ z.looseObject({ kind: z.literal('link_ping'), nonce: z.string() }),
413
+ z.looseObject({ kind: z.literal('link_pong'), nonce: z.string() }),
414
+ z.looseObject({ kind: z.literal('link_bye'), cause: z.string() }),
415
+ ]);
416
+
417
+ const frameSchema = z.discriminatedUnion('frame', [
418
+ z.looseObject({
419
+ frame: z.literal('session'),
420
+ sessionId: z.string().min(1),
421
+ // Dense from 1. Zero would make "no frames yet" and "the first frame" the same value.
422
+ seq: z.number().int().positive(),
423
+ at: z.string().min(1),
424
+ payload: sessionPayloadSchema,
425
+ }),
426
+ z.looseObject({
427
+ frame: z.literal('control'),
428
+ at: z.string().min(1),
429
+ payload: controlPayloadSchema,
430
+ }),
431
+ ]);
432
+
433
+ /**
434
+ * A frame to a wire string.
435
+ *
436
+ * Refuses a frame that does not match the shape `decode` accepts, with the same per-kind schema, so
437
+ * a malformed frame is refused at the caller and never minted a sequence number; a frame that only
438
+ * the peer refused would burn a number the receiver then waits on. Refuses anything past
439
+ * MAX_FRAME_BYTES, naming the bulk lane — this is the enforcement point for "commands only, never
440
+ * payloads", so the check is here rather than at each call site that might forget it.
441
+ */
442
+ export function encode(frame: Frame): Result<string> {
443
+ // The producer-closed half, enforced here rather than by a type. `refusal()` takes a
444
+ // `RefusalReason`, which is a compile-time guarantee a single `as` defeats. This is the one
445
+ // place a frame becomes bytes, so checking here makes "nothing this host emits carries an
446
+ // undeclared reason" true by construction. Two mechanisms, one invariant.
447
+ const undeclared = undeclaredRefusalReason(frame);
448
+ if (undeclared !== null) {
449
+ return refuse(
450
+ 'frame-malformed',
451
+ `refusal reason "${undeclared}" is not one this host declares — the wire tolerates an ` +
452
+ `unknown reason on the way IN, but this host may not invent one on the way OUT; add it to ` +
453
+ `REFUSAL_REASONS`,
454
+ );
455
+ }
456
+
457
+ const shape = frameSchema.safeParse(frame);
458
+ if (!shape.success) {
459
+ return refuse(
460
+ 'frame-malformed',
461
+ `frame does not match its declared shape: ${shape.error.issues.map(describeIssue).join('; ')}`,
462
+ );
463
+ }
464
+
465
+ let text: string;
466
+ try {
467
+ text = JSON.stringify(frame);
468
+ } catch (error) {
469
+ return refuse('frame-malformed', `frame is not serializable: ${describe(error)}`);
470
+ }
471
+
472
+ const byteCount = utf8ByteLength(text);
473
+ if (byteCount > MAX_FRAME_BYTES) {
474
+ return refuse(
475
+ 'frame-too-large',
476
+ `frame is ${byteCount} bytes, over the ${MAX_FRAME_BYTES} limit — bulk content does not ride ` +
477
+ `the link; request it with bulk_request and answer on the bulk-post lane`,
478
+ );
479
+ }
480
+
481
+ return ok(text);
482
+ }
483
+
484
+ /** A wire string to a frame. Never throws: every failure path returns a named refusal. */
485
+ export function decode(raw: string): Result<Frame> {
486
+ const byteCount = utf8ByteLength(raw);
487
+ if (byteCount > MAX_FRAME_BYTES) {
488
+ return refuse(
489
+ 'frame-too-large',
490
+ `received ${byteCount} bytes, over the ${MAX_FRAME_BYTES} limit — bulk content does not ride ` +
491
+ `the link; request it with bulk_request and answer on the bulk-post lane`,
492
+ );
493
+ }
494
+
495
+ let parsed: unknown;
496
+ try {
497
+ parsed = JSON.parse(raw);
498
+ } catch (error) {
499
+ return refuse('frame-not-json', `not JSON: ${describe(error)}`);
500
+ }
501
+
502
+ const result = frameSchema.safeParse(parsed);
503
+ if (!result.success) {
504
+ return refuse('frame-malformed', result.error.issues.map(describeIssue).join('; '));
505
+ }
506
+
507
+ return ok(result.data as Frame);
508
+ }
509
+
510
+ /**
511
+ * The reason on an outgoing refusal-carrying payload when this host does not declare it, else null.
512
+ * Covers every kind that carries one: `bulk_failed`, `transcript_failed`, the result kinds whose
513
+ * `refusal` is non-null, every entry of a bulk release answer, and the `wire_refusal` member of a
514
+ * `session_update` body (opaque JSON to the schema, so it is read here by hand) — so no door can
515
+ * invent a word on the way out.
516
+ */
517
+ function undeclaredRefusalReason(frame: Frame): string | null {
518
+ if (frame.frame !== 'session') return null;
519
+ const payload = frame.payload;
520
+ let carried: readonly (WireRefusal | null)[];
521
+ if (payload.kind === 'session_update') {
522
+ const body = payload.body as { update?: unknown; refusal?: unknown };
523
+ if (body.update !== 'wire_refusal' || typeof body.refusal !== 'object' || body.refusal === null)
524
+ return null;
525
+ const reason = (body.refusal as { reason?: unknown }).reason;
526
+ return typeof reason === 'string' && !isRefusalReason(reason) ? reason : null;
527
+ }
528
+ switch (payload.kind) {
529
+ case 'bulk_failed':
530
+ case 'transcript_failed':
531
+ case 'workspace_release_result':
532
+ case 'host_configure_result':
533
+ case 'workspace_list_result':
534
+ case 'repository_list_result':
535
+ case 'repository_read_result':
536
+ carried = [payload.refusal];
537
+ break;
538
+ case 'workspace_release_bulk_result':
539
+ carried = payload.results.map((result) => result.refusal);
540
+ break;
541
+ default:
542
+ return null;
543
+ }
544
+ for (const refusal of carried) {
545
+ if (refusal !== null && !isRefusalReason(refusal.reason)) return refusal.reason;
546
+ }
547
+ return null;
548
+ }
549
+
550
+ function describeIssue(issue: z.core.$ZodIssue): string {
551
+ const path = issue.path.length > 0 ? issue.path.join('.') : '(root)';
552
+ return `${path}: ${issue.message}`;
553
+ }
554
+
555
+ function describe(error: unknown): string {
556
+ return error instanceof Error ? error.message : String(error);
557
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The credential seam — deliberately empty.
3
+ *
4
+ * Identity is implemented in `identity/`, not here. This interface exists so the link has a shape
5
+ * to call and the composition root has a slot to fill, and the only implementation here refuses by
6
+ * name. It does
7
+ * not read a worker key, invent a token format, or default to unauthenticated: a placeholder that
8
+ * silently succeeds is how a scheme nobody designed ends up in production.
9
+ */
10
+ import type { Result } from '../core/result.js';
11
+ import { refuse } from '../core/result.js';
12
+
13
+ /** What the link puts on the connect request, when there is something to put. */
14
+ export interface Authorization {
15
+ readonly header: string;
16
+ readonly value: string;
17
+ }
18
+
19
+ export interface ControllerCredential {
20
+ authorize(): Promise<Result<Authorization>>;
21
+ }
22
+
23
+ /** The only implementation in this module. Refuses, and says which named way. */
24
+ export class UnconfiguredCredential implements ControllerCredential {
25
+ authorize(): Promise<Result<Authorization>> {
26
+ return Promise.resolve(
27
+ refuse<Authorization>(
28
+ 'credential-unavailable',
29
+ 'no credential is configured; identity is not implemented in this layer',
30
+ ),
31
+ );
32
+ }
33
+ }