@huynle/hub 0.6.0 → 0.7.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 (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,102 @@
1
+ // Webhook verification for receivers (design §6.1; decision znbg8xa5 #8). The hub signs every webhook POST with
2
+ // Hub-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
3
+ // (format: packages/protocol/src/webhook.ts). verifyWebhook checks it against the receiver's secret(s) in constant
4
+ // time and refuses timestamps outside the tolerance (replay). Verify the raw body bytes as received, before parsing.
5
+ import { createHmac, timingSafeEqual } from "node:crypto"
6
+
7
+ // The format, as in packages/protocol/src/webhook.ts (the SDK imports nothing outside its package; the tests check
8
+ // the two agree).
9
+ export const WEBHOOK_SIGNATURE_HEADER = "Hub-Signature"
10
+ export const WEBHOOK_EVENT_HEADER = "Hub-Event"
11
+ export const WEBHOOK_DELIVERY_HEADER = "Hub-Delivery"
12
+ /** Largest gap, either way, between a signature's timestamp and the receiver's clock. */
13
+ export const WEBHOOK_TOLERANCE_SECONDS = 300
14
+ const SCHEME = "v1"
15
+ const ALGORITHM = "sha256"
16
+ const HEADER_MAX = 2048
17
+ const HEX_SHA256 = /^[0-9a-f]{64}$/
18
+
19
+ /** A Hub-Signature header's timestamp (unix seconds) and v1 signatures, or undefined when it is malformed. */
20
+ function parseSignature(value: string): { t: number; v1: string[] } | undefined {
21
+ if (value.length > HEADER_MAX) return undefined
22
+ let t: number | undefined
23
+ const v1: string[] = []
24
+ for (const part of value.split(",")) {
25
+ const eq = part.indexOf("=")
26
+ if (eq < 0) return undefined
27
+ const key = part.slice(0, eq).trim()
28
+ const v = part.slice(eq + 1).trim()
29
+ if (key === "t") {
30
+ if (t !== undefined || !/^\d{1,12}$/.test(v)) return undefined
31
+ t = Number(v)
32
+ } else if (key === SCHEME) {
33
+ if (!HEX_SHA256.test(v)) return undefined
34
+ v1.push(v)
35
+ }
36
+ }
37
+ return t !== undefined && v1.length > 0 ? { t, v1 } : undefined
38
+ }
39
+
40
+ /** Request headers: a Headers, or a plain object such as node's IncomingMessage.headers (any key case). */
41
+ export type WebhookHeaders = Headers | Readonly<Record<string, string | readonly string[] | undefined>>
42
+
43
+ export interface VerifyWebhookInput {
44
+ /** The raw request body, exactly as received. */
45
+ readonly body: string | Uint8Array
46
+ /** The Hub-Signature header value (or give `headers`). */
47
+ readonly signature?: string | null
48
+ /** The request's headers: Hub-Signature, and Hub-Event / Hub-Delivery for the answer. */
49
+ readonly headers?: WebhookHeaders
50
+ /** The registration's secret; several while one rotates (any may match). */
51
+ readonly secret: string | readonly string[]
52
+ /** The receiver's clock in ms (default Date.now()). */
53
+ readonly now?: number
54
+ /** Seconds a timestamp may be off either way (default WEBHOOK_TOLERANCE_SECONDS). */
55
+ readonly tolerance?: number
56
+ }
57
+
58
+ export type WebhookVerification =
59
+ | { readonly ok: true; readonly timestamp: number; readonly event?: string; readonly delivery?: string }
60
+ | { readonly ok: false; readonly reason: "missing" | "malformed" | "stale" | "mismatch" }
61
+
62
+ function header(headers: WebhookHeaders | undefined, name: string): string | undefined {
63
+ if (!headers) return undefined
64
+ if (typeof (headers as Headers).get === "function") return (headers as Headers).get(name) ?? undefined
65
+ const wanted = name.toLowerCase()
66
+ for (const [key, value] of Object.entries(headers as Record<string, string | readonly string[] | undefined>)) {
67
+ if (key.toLowerCase() !== wanted || value === undefined) continue
68
+ return typeof value === "string" ? value : value[0]
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ const text = (body: string | Uint8Array) => (typeof body === "string" ? body : new TextDecoder().decode(body))
74
+
75
+ const hmac = (secret: string, t: number, body: string): Buffer => createHmac(ALGORITHM, secret).update(`${t}.${body}`).digest()
76
+
77
+ /** Whether a Hub-Signature matches the body under one of the secrets, and is fresh. */
78
+ export function verifyWebhook(input: VerifyWebhookInput): WebhookVerification {
79
+ const raw = input.signature ?? header(input.headers, WEBHOOK_SIGNATURE_HEADER)
80
+ if (raw === undefined || raw === null || raw === "") return { ok: false, reason: "missing" }
81
+ const parsed = parseSignature(raw)
82
+ if (!parsed) return { ok: false, reason: "malformed" }
83
+ if (Math.abs((input.now ?? Date.now()) / 1000 - parsed.t) > (input.tolerance ?? WEBHOOK_TOLERANCE_SECONDS)) return { ok: false, reason: "stale" }
84
+ const body = text(input.body)
85
+ const secrets = (typeof input.secret === "string" ? [input.secret] : input.secret).filter((s) => s.length > 0)
86
+ const given = parsed.v1.map((hex) => Buffer.from(hex, "hex"))
87
+ // Every comparison runs (no early exit), each in constant time.
88
+ let match = false
89
+ for (const secret of secrets) {
90
+ const expected = hmac(secret, parsed.t, body)
91
+ for (const signature of given) if (signature.length === expected.length && timingSafeEqual(signature, expected)) match = true
92
+ }
93
+ if (!match) return { ok: false, reason: "mismatch" }
94
+ const event = header(input.headers, WEBHOOK_EVENT_HEADER)
95
+ const delivery = header(input.headers, WEBHOOK_DELIVERY_HEADER)
96
+ return { ok: true, timestamp: parsed.t, ...(event !== undefined ? { event } : {}), ...(delivery !== undefined ? { delivery } : {}) }
97
+ }
98
+
99
+ /** The Hub-Signature header the hub would send for `body` (for testing receivers); `t` in unix seconds, default now. */
100
+ export function signWebhook(body: string | Uint8Array, secret: string, t: number = Math.floor(Date.now() / 1000)): string {
101
+ return `t=${t},${SCHEME}=${hmac(secret, t, text(body)).toString("hex")}`
102
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@huynle/hub-spoke-sdk",
3
+ "version": "0.7.1",
4
+ "private": true,
5
+ "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./package.json": "./package.json"
11
+ }
12
+ }
@@ -0,0 +1,3 @@
1
+ // Activity compaction (tool inputs cut to short summaries, no outputs) is part of the wire format and lives in the
2
+ // protocol package; the spoke SDK re-exports it so a spoke needs one import.
3
+ export { compactActivity, compactStatus, jobFromInfo, summarizeToolInput } from "../../protocol/src/index.ts"
@@ -0,0 +1,295 @@
1
+ // The spoke's single outbound WebSocket to the central server: hello/welcome handshake, request/
2
+ // response correlation in both directions, and reconnect with jittered exponential backoff. A bye carrying
3
+ // retryAfterMs (a hub pod draining, design §10) is followed by one short jittered reconnect (0–retryAfterMs) instead.
4
+ import {
5
+ encode,
6
+ MAX_FRAME,
7
+ parseServerMessage,
8
+ PROTOCOL_VERSION,
9
+ type Hello,
10
+ type ResultFor,
11
+ type ServerRequest,
12
+ type SpokeMessage,
13
+ type SpokeRequest,
14
+ type Welcome,
15
+ } from "../../protocol/src/index.ts"
16
+
17
+ export type ConnectionState = "off" | "connecting" | "connected" | "revoked" | "error"
18
+
19
+ export interface Backoff {
20
+ readonly minMs: number
21
+ readonly maxMs: number
22
+ readonly random?: () => number
23
+ }
24
+
25
+ /** Minimal WebSocket surface used here (Bun's WebSocket accepts `{ headers }` as a 2nd argument). */
26
+ export interface SocketLike {
27
+ readonly readyState: number
28
+ onopen: ((ev: unknown) => void) | null
29
+ onmessage: ((ev: { data: unknown }) => void) | null
30
+ onclose: ((ev: { code?: number; reason?: string }) => void) | null
31
+ onerror: ((ev: unknown) => void) | null
32
+ send(data: string): void
33
+ close(code?: number, reason?: string): void
34
+ }
35
+
36
+ export interface ConnectionOptions {
37
+ readonly server: string
38
+ readonly token: string
39
+ /** Hello fields, computed per attempt so mode/directory are current. */
40
+ readonly hello: () => Omit<Hello, "t" | "v">
41
+ /** Executes a server request; a thrown error becomes `{ ok: false, error }`. */
42
+ readonly handle: (req: ServerRequest) => Promise<unknown>
43
+ readonly onWelcome?: (welcome: Welcome) => void
44
+ readonly onState?: (state: ConnectionState, error?: string) => void
45
+ readonly backoff?: Backoff
46
+ readonly requestTimeoutMs?: number
47
+ readonly socket?: (url: string, headers: Record<string, string>) => SocketLike
48
+ }
49
+
50
+ export function backoffDelay(attempt: number, backoff: Backoff): number {
51
+ const base = Math.min(backoff.maxMs, backoff.minMs * 2 ** Math.min(attempt, 30))
52
+ const random = (backoff.random ?? Math.random)()
53
+ return Math.round(base * (0.5 + 0.5 * random))
54
+ }
55
+
56
+ /** The reconnect delay a bye's retryAfterMs asks for: uniformly jittered over 0–retryAfterMs, so peers spread out. */
57
+ export function retryAfterDelay(retryAfterMs: number, random: () => number = Math.random): number {
58
+ return Math.round(retryAfterMs * random())
59
+ }
60
+
61
+ export function wsURL(server: string): string {
62
+ return `${server.replace(/\/+$/, "").replace(/^http/, "ws")}/spoke/ws`
63
+ }
64
+
65
+ const defaultSocket = (url: string, headers: Record<string, string>): SocketLike =>
66
+ new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => SocketLike)(url, { headers })
67
+
68
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
69
+
70
+ interface Pending {
71
+ resolve: (value: unknown) => void
72
+ reject: (error: Error) => void
73
+ timer: ReturnType<typeof setTimeout>
74
+ }
75
+
76
+ export class SpokeConnection {
77
+ state: ConnectionState = "off"
78
+ lastError?: string
79
+ publicURL?: string
80
+ machineID?: string
81
+
82
+ private socket?: SocketLike
83
+ private welcomed = false
84
+ /** Features the hub listed in its welcome on the current socket (none before a welcome or from an old hub). */
85
+ private hubFeatures: ReadonlySet<string> = new Set()
86
+ private stopped = true
87
+ private attempt = 0
88
+ private retry?: ReturnType<typeof setTimeout>
89
+ /** The last bye's retryAfterMs (a draining hub): the next reconnect waits about this instead of the backoff. */
90
+ private retryAfterMs?: number
91
+ private nextID = 0
92
+ private readonly pending = new Map<string, Pending>()
93
+ private readonly backoff: Backoff
94
+
95
+ constructor(private readonly options: ConnectionOptions) {
96
+ this.backoff = options.backoff ?? { minMs: 1000, maxMs: 60_000 }
97
+ }
98
+
99
+ start(): void {
100
+ if (!this.stopped) return
101
+ this.stopped = false
102
+ this.open()
103
+ }
104
+
105
+ stop(): void {
106
+ this.stopped = true
107
+ clearTimeout(this.retry)
108
+ this.drop(1000, "stop")
109
+ this.failPending("disconnected")
110
+ this.setState("off")
111
+ }
112
+
113
+ /** Whether the connected hub announced `feature`; send a request a newer hub added only when it did. */
114
+ hubSupports(feature: string): boolean {
115
+ return this.hubFeatures.has(feature)
116
+ }
117
+
118
+ /** Reconnects at once (e.g. after a local hub was started) instead of waiting out the backoff. */
119
+ retryNow(): void {
120
+ if (this.stopped || this.state === "connected" || this.state === "revoked" || this.socket) return
121
+ clearTimeout(this.retry)
122
+ this.attempt = 0
123
+ this.open()
124
+ }
125
+
126
+ /** Sends a frame once the server has welcomed us. Returns false when it could not be sent. */
127
+ send(frame: SpokeMessage): boolean {
128
+ if (!this.socket || !this.welcomed || this.socket.readyState !== 1) return false
129
+ try {
130
+ let text = encode(frame)
131
+ if (text.length > MAX_FRAME) {
132
+ // The hub drops the connection on an oversize frame; answer a request with an error instead.
133
+ if (frame.t !== "res") return false
134
+ text = encode({ t: "res", id: frame.id, ok: false, error: `Result too large to send (${text.length} characters, limit ${MAX_FRAME})` })
135
+ }
136
+ this.socket.send(text)
137
+ return true
138
+ } catch {
139
+ return false
140
+ }
141
+ }
142
+
143
+ request<R extends SpokeRequest>(req: R): Promise<ResultFor[R["op"]]> {
144
+ const id = `s${++this.nextID}`
145
+ return new Promise((resolve, reject) => {
146
+ if (this.state !== "connected") return reject(new Error("Not connected to the hub"))
147
+ const timer = setTimeout(() => {
148
+ this.pending.delete(id)
149
+ reject(new Error("The hub timed out"))
150
+ }, this.options.requestTimeoutMs ?? 15_000)
151
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer })
152
+ if (!this.send({ t: "req", id, req })) {
153
+ clearTimeout(timer)
154
+ this.pending.delete(id)
155
+ reject(new Error("Not connected to the hub"))
156
+ }
157
+ })
158
+ }
159
+
160
+ private open(): void {
161
+ if (this.stopped) return
162
+ this.setState(this.state === "error" ? "error" : "connecting")
163
+ let socket: SocketLike
164
+ try {
165
+ socket = (this.options.socket ?? defaultSocket)(wsURL(this.options.server), {
166
+ Authorization: `Bearer ${this.options.token}`,
167
+ })
168
+ } catch (cause) {
169
+ this.fail(message(cause))
170
+ return
171
+ }
172
+ this.socket = socket
173
+ this.welcomed = false
174
+ socket.onopen = () => {
175
+ try {
176
+ socket.send(encode({ t: "hello", v: PROTOCOL_VERSION, ...this.options.hello() }))
177
+ } catch (cause) {
178
+ this.lastError = message(cause)
179
+ }
180
+ }
181
+ socket.onmessage = (ev) => this.receive(ev.data)
182
+ socket.onerror = () => {
183
+ this.lastError = this.welcomed ? "Connection error" : "Could not connect to the hub"
184
+ }
185
+ socket.onclose = (ev) => {
186
+ if (this.socket !== socket) return
187
+ this.socket = undefined
188
+ const wasWelcomed = this.welcomed
189
+ this.welcomed = false
190
+ this.hubFeatures = new Set()
191
+ this.failPending("disconnected")
192
+ if (this.stopped || this.state === "revoked") return
193
+ this.fail(wasWelcomed ? `Connection closed${ev.reason ? `: ${ev.reason}` : ""}` : (this.lastError ?? "Could not connect"))
194
+ }
195
+ }
196
+
197
+ private receive(data: unknown): void {
198
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : String(data)
199
+ const frame = parseServerMessage(text)
200
+ if (!frame) return
201
+ switch (frame.t) {
202
+ case "welcome":
203
+ this.welcomed = true
204
+ this.hubFeatures = new Set(frame.features ?? [])
205
+ this.attempt = 0
206
+ this.publicURL = frame.publicURL
207
+ this.machineID = frame.machineID
208
+ this.lastError = undefined
209
+ this.setState("connected")
210
+ try {
211
+ this.options.onWelcome?.(frame)
212
+ } catch (cause) {
213
+ console.error("[hub] welcome handler failed", cause)
214
+ }
215
+ return
216
+ case "bye":
217
+ if (frame.reason === "revoked") {
218
+ this.stopped = true
219
+ clearTimeout(this.retry)
220
+ this.lastError = "This machine was revoked on the hub"
221
+ this.setState("revoked")
222
+ this.drop(1000, "revoked")
223
+ this.failPending("revoked")
224
+ } else if (frame.retryAfterMs !== undefined) {
225
+ this.retryAfterMs = frame.retryAfterMs
226
+ }
227
+ return
228
+ case "res": {
229
+ const pending = this.pending.get(frame.id)
230
+ if (!pending) return
231
+ this.pending.delete(frame.id)
232
+ clearTimeout(pending.timer)
233
+ if (frame.ok) pending.resolve(frame.result)
234
+ else pending.reject(new Error(frame.error ?? "Request failed"))
235
+ return
236
+ }
237
+ case "req":
238
+ void this.answer(frame.id, frame.req)
239
+ return
240
+ }
241
+ }
242
+
243
+ private async answer(id: string, req: ServerRequest): Promise<void> {
244
+ let reply: SpokeMessage
245
+ try {
246
+ reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
247
+ } catch (cause) {
248
+ reply = { t: "res", id, ok: false, error: message(cause) }
249
+ }
250
+ this.send(reply)
251
+ }
252
+
253
+ private fail(error: string): void {
254
+ this.lastError = error
255
+ this.setState("error")
256
+ if (this.stopped) return
257
+ clearTimeout(this.retry)
258
+ const retryAfterMs = this.retryAfterMs
259
+ this.retryAfterMs = undefined
260
+ const delay = retryAfterMs !== undefined ? retryAfterDelay(retryAfterMs, this.backoff.random) : backoffDelay(this.attempt++, this.backoff)
261
+ this.retry = setTimeout(() => this.open(), delay)
262
+ }
263
+
264
+ private drop(code: number, reason: string): void {
265
+ const socket = this.socket
266
+ this.socket = undefined
267
+ this.welcomed = false
268
+ this.hubFeatures = new Set()
269
+ if (!socket) return
270
+ socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null
271
+ try {
272
+ socket.close(code, reason)
273
+ } catch {
274
+ // already closed
275
+ }
276
+ }
277
+
278
+ private failPending(reason: string): void {
279
+ for (const [id, pending] of this.pending) {
280
+ clearTimeout(pending.timer)
281
+ pending.reject(new Error(`Hub ${reason}`))
282
+ this.pending.delete(id)
283
+ }
284
+ }
285
+
286
+ private setState(state: ConnectionState): void {
287
+ if (state === this.state) return
288
+ this.state = state
289
+ try {
290
+ this.options.onState?.(state, this.lastError)
291
+ } catch {
292
+ // observers must not break the connection
293
+ }
294
+ }
295
+ }
@@ -0,0 +1,119 @@
1
+ // Harness-neutral delivery helpers (the deliver op): the provenance envelope an agent's prompt (kind "prompt") is
2
+ // rendered in, who put each inbox item into a session, and applying each hub message once (DeliveryLedger: the hub
3
+ // retries a deliver with the same messageID; a harness may not deduplicate it, so the spoke must).
4
+ import { sanitizeLabel, sanitizeText } from "../../sdk/src/envelope.ts"
5
+ import type { DeliverMeta, DeliverResult, InboxOrigin } from "../../protocol/src/index.ts"
6
+ import { PROMPT_SOURCE } from "./prompt-source.ts"
7
+
8
+ /** Body lines that could pass for a header or boundary (or an escaped one): escaped with a leading backslash. */
9
+ const FORGEABLE = /^\s*\\*(?:\[hub\]|-{3,}|↩)/i
10
+
11
+ /**
12
+ * The text a session sees for an agent's prompt (kind "prompt"): a provenance header naming the sender and the
13
+ * grant, then the sanitised body between two boundaries (the same shape as the hub's message envelope, so
14
+ * timelines read the body back). The header comes from the hub's meta, never from the body.
15
+ */
16
+ export function renderPromptEnvelope(input: { readonly messageID: string; readonly from: string; readonly grant: string; readonly text: string }): string {
17
+ const id = sanitizeLabel(input.messageID)
18
+ const boundary = `----- ${id} -----`
19
+ const body = sanitizeText(input.text)
20
+ .split("\n")
21
+ .map((line) => (FORGEABLE.test(line) ? `\\${line}` : line))
22
+ .join("\n")
23
+ return [`[hub] prompt from ${sanitizeLabel(input.from)} · grant ${sanitizeLabel(input.grant)} · ${id}`, boundary, body, boundary].join("\n")
24
+ }
25
+
26
+ /** What OpenCode gets for a deliver: a prompt wrapped in its provenance envelope, other kinds as the hub rendered them. */
27
+ export function deliverText(req: { readonly messageID: string; readonly text: string; readonly meta: DeliverMeta }): string {
28
+ if (req.meta.kind !== "prompt") return req.text
29
+ return renderPromptEnvelope({ messageID: req.messageID, from: req.meta.from, grant: req.meta.grant ?? "", text: req.text })
30
+ }
31
+
32
+ const record = (v: unknown): Record<string, any> => (typeof v === "object" && v !== null ? (v as Record<string, any>) : {})
33
+
34
+ /** A bounded insertion-ordered map: the oldest entry goes when it is full. */
35
+ class Bounded<V> {
36
+ private readonly map = new Map<string, V>()
37
+ constructor(private readonly max: number) {}
38
+ get(key: string): V | undefined {
39
+ return this.map.get(key)
40
+ }
41
+ set(key: string, value: V): void {
42
+ this.map.delete(key)
43
+ this.map.set(key, value)
44
+ if (this.map.size > this.max) this.map.delete(this.map.keys().next().value!)
45
+ }
46
+ delete(key: string): void {
47
+ this.map.delete(key)
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Who put each inbox item into a session, from OpenCode's session.inbox.enqueued events and the spoke's own
53
+ * deliveries: a user item is the user's (typed, or sent from the phone), a synthetic item with hub metadata is
54
+ * the hub's; anything else (OpenCode's own notes, compaction) has no origin.
55
+ */
56
+ export class InboxOrigins {
57
+ private readonly origins: Bounded<InboxOrigin>
58
+ constructor(max = 1000) {
59
+ this.origins = new Bounded(max)
60
+ }
61
+
62
+ observe(event: { readonly type: string; readonly data?: unknown }): void {
63
+ if (event.type !== "session.inbox.enqueued") return
64
+ const data = record(event.data)
65
+ if (typeof data.inboxID !== "string" || this.origins.get(data.inboxID) === "hub") return
66
+ const item = record(data.item)
67
+ const metadata = record(record(item.payload).metadata)
68
+ // A hub message (hub metadata with its messageID) is never the user's, whatever item type OpenCode gives it.
69
+ if (metadata.source === PROMPT_SOURCE && typeof metadata.messageID === "string" && (item.type === "synthetic" || item.type === "user")) this.origins.set(data.inboxID, "hub")
70
+ else if (item.type === "user") this.origins.set(data.inboxID, "user")
71
+ }
72
+
73
+ /** Marks an inbox item the spoke delivered for the hub. */
74
+ hub(inboxID: string): void {
75
+ this.origins.set(inboxID, "hub")
76
+ }
77
+
78
+ /** The item's origin, when known; forgets it (an item is delivered once). */
79
+ take(inboxID: string): InboxOrigin | undefined {
80
+ const origin = this.origins.get(inboxID)
81
+ this.origins.delete(inboxID)
82
+ return origin
83
+ }
84
+ }
85
+
86
+ export interface Admission {
87
+ readonly result: DeliverResult
88
+ /** Whether it was sent with resume (could start a turn). */
89
+ readonly resumed: boolean
90
+ }
91
+
92
+ /**
93
+ * Applies each hub message once: a duplicate messageID (the hub's retry, in flight or done) gets the first
94
+ * result without another admission. Only a retry that asks to wake a message first admitted without waking is
95
+ * sent again (OpenCode keeps one message for the id and wakes the session). Failures are not remembered.
96
+ */
97
+ export class DeliveryLedger {
98
+ private readonly entries: Bounded<Promise<Admission>>
99
+ constructor(max = 1000) {
100
+ this.entries = new Bounded(max)
101
+ }
102
+
103
+ async run(messageID: string, wake: boolean, send: () => Promise<Admission>): Promise<DeliverResult> {
104
+ const known = this.entries.get(messageID)
105
+ if (known) {
106
+ const admission = await known.catch(() => undefined)
107
+ if (admission && (!wake || admission.resumed)) return admission.result
108
+ if (this.entries.get(messageID) !== known) return this.run(messageID, wake, send)
109
+ }
110
+ const attempt = send()
111
+ this.entries.set(messageID, attempt)
112
+ try {
113
+ return (await attempt).result
114
+ } catch (cause) {
115
+ if (this.entries.get(messageID) === attempt) this.entries.delete(messageID)
116
+ throw cause
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,79 @@
1
+ // Device-code enrollment of this machine with the central server (before it has a token).
2
+ // POST /spoke/enroll → EnrollStart; then POST /spoke/enroll/poll until the user approves it.
3
+ import type { EnrollPoll, EnrollStart } from "../../protocol/src/index.ts"
4
+
5
+ export type EnrollOutcome =
6
+ | { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
7
+ | { readonly status: "denied" | "expired" | "cancelled" }
8
+
9
+ export interface EnrollOptions {
10
+ readonly signal?: AbortSignal
11
+ readonly fetch?: typeof fetch
12
+ readonly now?: () => number
13
+ }
14
+
15
+ const REQUEST_TIMEOUT_MS = 15_000
16
+
17
+ async function post(server: string, path: string, body: unknown, options: EnrollOptions): Promise<unknown> {
18
+ const doFetch = options.fetch ?? fetch
19
+ const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
20
+ const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
21
+ method: "POST",
22
+ headers: { "content-type": "application/json" },
23
+ body: JSON.stringify(body),
24
+ signal: AbortSignal.any(signals),
25
+ })
26
+ if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
27
+ return response.json()
28
+ }
29
+
30
+ function isStart(v: any): v is EnrollStart {
31
+ return (
32
+ typeof v === "object" &&
33
+ v !== null &&
34
+ typeof v.userCode === "string" &&
35
+ typeof v.deviceCode === "string" &&
36
+ typeof v.approveURL === "string" &&
37
+ typeof v.intervalMs === "number" &&
38
+ typeof v.expiresAt === "number"
39
+ )
40
+ }
41
+
42
+ export async function startEnrollment(server: string, machineName: string, options: EnrollOptions = {}): Promise<EnrollStart> {
43
+ const body = await post(server, "/spoke/enroll", { machineName }, options)
44
+ if (!isStart(body)) throw new Error("Invalid enrollment reply from the hub")
45
+ return body
46
+ }
47
+
48
+ const sleep = (ms: number, signal?: AbortSignal) =>
49
+ new Promise<void>((resolve) => {
50
+ const timer = setTimeout(done, ms)
51
+ function done() {
52
+ clearTimeout(timer)
53
+ signal?.removeEventListener("abort", done)
54
+ resolve()
55
+ }
56
+ signal?.addEventListener("abort", done, { once: true })
57
+ })
58
+
59
+ /** Polls until approved, denied, expired, or aborted. Transient errors are retried until expiry. */
60
+ export async function pollEnrollment(server: string, start: EnrollStart, options: EnrollOptions = {}): Promise<EnrollOutcome> {
61
+ const now = options.now ?? Date.now
62
+ const interval = Math.max(1, start.intervalMs)
63
+ for (;;) {
64
+ if (options.signal?.aborted) return { status: "cancelled" }
65
+ if (now() > start.expiresAt) return { status: "expired" }
66
+ await sleep(interval, options.signal)
67
+ if (options.signal?.aborted) return { status: "cancelled" }
68
+ let reply: Partial<EnrollPoll> & Record<string, unknown>
69
+ try {
70
+ reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options)) as typeof reply
71
+ } catch {
72
+ continue
73
+ }
74
+ if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
75
+ return { status: "approved", machineID: reply.machineID, machineToken: reply.machineToken }
76
+ }
77
+ if (reply?.status === "denied" || reply?.status === "expired") return { status: reply.status }
78
+ }
79
+ }