@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
@@ -1,25 +1,62 @@
1
1
  // Spoke WebSocket endpoint (/spoke/ws): bearer-token authentication, hello/welcome handshake, frame
2
2
  // validation, heartbeats, server→spoke request/response correlation, and spoke→server requests.
3
+ // `hub node` daemons share the endpoint and the machine token but say hello with role "node" (design §6.3):
4
+ // they go to the NodeRegistry, never to the spoke Registry, and get node ops (requestNode) instead of spoke ops.
5
+ // A harness inside a sandbox (phase 5) authenticates with its launch's hlt1 token instead (./agent/launch-tokens.ts):
6
+ // accepted only while the launch is provisioning or running, only with a spoke hello naming that launch, one
7
+ // connection per launch (a reconnect replaces the old one), closed when the launch ends (closeLaunch) or the token
8
+ // expires; such a connection is bound to the launch in the Registry and gets no pairing or login codes.
9
+ // Source addresses (loopback included: a sandbox reaches a loopback hub through its host) are never trusted.
10
+ // Cluster mode (design §9, `cluster` option): each connection is recorded in the ConnDirectory (connID → pod); an op
11
+ // for a connection another pod holds goes there over pod RPC (./cluster/rpc.ts) with the same per-op timeout, and the
12
+ // owning pod re-checks the tenant before sending it. closeMachine / closeLaunch reach every pod. Session-token and
13
+ // act-token pushes are sent only by the pod holding the connection. Close notices are at most once, so every
14
+ // RECHECK_MS each pod also re-reads its sockets' machines and owners (Store.recheck) and closes the sockets of a
15
+ // revoked machine or a disabled owner itself. Without `cluster` everything is local.
16
+ // Draining (design §10, drain()): new upgrades are refused with 503, new requests to this pod's connections fail as
17
+ // offline, and each connection gets bye{reason:"draining", retryAfterMs} once its requests in flight are answered
18
+ // (at the deadline at the latest), so it reconnects (to another pod) after a short jittered delay.
19
+ // Harness events (phase 7): a spoke of a harness other than OpenCode may send only the harness event subset
20
+ // (harnessEventProblem); an event outside it, or with invalid data, is dropped and logged, never fatal. OpenCode
21
+ // spokes' events are not checked this way (their native shapes are the hub's own).
3
22
  import type { Server, ServerWebSocket, WebSocketHandler } from "bun"
4
23
  import {
5
24
  encode,
25
+ harnessEventProblem,
26
+ helloHarness,
27
+ helloRole,
28
+ isOpenCodeHello,
29
+ HUB_FEATURES,
6
30
  MAX_FRAME,
31
+ NODE_ROLE,
32
+ nodeResultProblem,
33
+ parseNodeMessage,
7
34
  parseSpokeMessage,
8
35
  PROTOCOL_VERSION,
36
+ type HelloRole,
37
+ type NodeHello,
38
+ type NodeOp,
39
+ type NodeRequest,
40
+ type NodeResultFor,
41
+ type NodeServerMessage,
9
42
  type Req,
10
43
  type ResultFor,
11
44
  type ServerMessage,
12
45
  type ServerRequest,
13
46
  type SpokeRequest,
14
47
  } from "../protocol"
48
+ import type { LaunchBinding, LaunchVerifyResult } from "./agent/launch-tokens"
49
+ import type { ConnDirectory } from "./cluster/directory"
50
+ import type { CloseNotice, PodRpc, RpcCall } from "./cluster/rpc"
15
51
  import type { Auth } from "./auth"
16
- import type { PushDetail, PushExtra, PushKind } from "./push"
52
+ import type { NodeRegistry } from "./nodes"
53
+ import type { NotificationKind, PushExtra } from "./push"
17
54
  import type { Registry } from "./registry"
18
55
  import { newID, type Store } from "./store"
19
56
 
20
57
  /** Push notifications (implemented by Push in push.ts) to `ownerID`'s devices. Returns the number of devices notified. */
21
58
  export interface Notifier {
22
- notify(kind: PushKind, sessionID: string, title: string, detail?: PushDetail, body?: string, ownerID?: string, extra?: PushExtra): Promise<number>
59
+ notify(kind: NotificationKind, sessionID: string, title: string, body?: string, ownerID?: string, extra?: PushExtra): Promise<number>
23
60
  }
24
61
 
25
62
  /** The spoke answered with an error, or disconnected before answering. */
@@ -30,6 +67,14 @@ export class SpokeError extends Error {
30
67
  }
31
68
  }
32
69
 
70
+ /** A node answered with an error or an invalid result, or disconnected before answering. */
71
+ export class NodeError extends Error {
72
+ constructor(message: string) {
73
+ super(message)
74
+ this.name = "NodeError"
75
+ }
76
+ }
77
+
33
78
  /** A request that would not fit in one protocol frame (the spoke would drop it and never answer). */
34
79
  export class RequestTooLargeError extends Error {
35
80
  constructor(op: string) {
@@ -39,8 +84,8 @@ export class RequestTooLargeError extends Error {
39
84
  }
40
85
 
41
86
  export class RequestTimeoutError extends Error {
42
- constructor(op: string) {
43
- super(`OpenCode did not answer the ${op} request in time`)
87
+ constructor(op: string, who = "OpenCode") {
88
+ super(`${who} did not answer the ${op} request in time`)
44
89
  this.name = "RequestTimeoutError"
45
90
  }
46
91
  }
@@ -50,8 +95,20 @@ export interface SpokeSocketData {
50
95
  readonly machineID: string
51
96
  readonly machineName: string
52
97
  ready: boolean
98
+ /** What the hello said this connection is (set with the hello). */
99
+ role?: HelloRole
53
100
  lastSeen: number
54
101
  helloTimer?: ReturnType<typeof setTimeout>
102
+ /** The launch whose launch-bound token authenticated this connection (absent for machine tokens). */
103
+ readonly launch?: LaunchBinding
104
+ /** Closes a launch-bound connection when its token expires. */
105
+ expiryTimer?: ReturnType<typeof setTimeout>
106
+ /** Draining: the bye was sent. */
107
+ drained?: boolean
108
+ /** A non-OpenCode spoke's harness (set with the hello): its events are checked against the harness subset. */
109
+ checkedHarness?: string
110
+ /** Events of a non-OpenCode spoke dropped as outside the harness subset. */
111
+ droppedEvents?: number
55
112
  }
56
113
 
57
114
  type Socket = ServerWebSocket<SpokeSocketData>
@@ -60,6 +117,8 @@ export interface SpokeHubOptions {
60
117
  readonly store: Store
61
118
  readonly auth: Auth
62
119
  readonly registry: Registry
120
+ /** Connected `hub node` daemons (role "node" hellos); nodes never enter `registry`. */
121
+ readonly nodes: NodeRegistry
63
122
  readonly publicURL: string
64
123
  readonly notifier: Notifier
65
124
  /** Sends a message to the SSE subscribers of `tenant` (a user ID). */
@@ -71,11 +130,41 @@ export interface SpokeHubOptions {
71
130
  readonly helloTimeoutMs?: number
72
131
  readonly heartbeatMs?: number
73
132
  readonly staleMs?: number
133
+ /** Per node op request timeouts (defaults: NODE_TIMEOUTS_MS; cancel uses requestTimeoutMs). */
134
+ readonly nodeTimeoutsMs?: Partial<Record<NodeOp, number>>
135
+ /** Cluster mode: how often the sockets' machines and owners are re-checked (default RECHECK_MS). */
136
+ readonly recheckMs?: number
137
+ /** Verifies launch-bound (hlt1) tokens; without it only machine tokens are accepted. */
138
+ readonly launchTokens?: LaunchVerifier
139
+ /** Cluster mode: the shared connection directory and this pod's RPC (absent: every connection is local). */
140
+ readonly cluster?: SpokeCluster
141
+ }
142
+
143
+ export interface SpokeCluster {
144
+ readonly directory: ConnDirectory
145
+ readonly rpc: PodRpc
146
+ }
147
+
148
+ /** Per-request options. */
149
+ export interface RequestOptions {
150
+ /**
151
+ * The user the caller acts for. For a connection on another pod, that pod refuses the op unless the connection's
152
+ * machine belongs to this user (default: the machine's owner as this pod sees it) and the session, if any, is
153
+ * not another user's.
154
+ */
155
+ readonly tenant?: string
156
+ }
157
+
158
+ /** Checks launch-bound tokens (implemented by LaunchTokens). */
159
+ export interface LaunchVerifier {
160
+ verify(token: string): Promise<LaunchVerifyResult>
74
161
  }
75
162
 
76
163
  interface Pending {
77
164
  readonly connID: string
78
165
  readonly op: string
166
+ /** A node request: errors are NodeErrors and the result is checked with nodeResultProblem. */
167
+ readonly node?: boolean
79
168
  readonly resolve: (value: unknown) => void
80
169
  readonly reject: (error: Error) => void
81
170
  readonly timer: ReturnType<typeof setTimeout>
@@ -85,20 +174,67 @@ interface Pending {
85
174
  export const TURN_GRACE_MS = 15_000
86
175
  /** Creating a session and opening it in a new Herdr tab (the spoke allows `herdr agent start` 20 s). */
87
176
  export const CREATE_IN_HERDR_TIMEOUT_MS = 45_000
177
+ /** Auto-name: the spoke waits up to 20 s for OpenCode's generated title, then answers "pending". */
178
+ export const AUTO_NAME_TIMEOUT_MS = 30_000
179
+
180
+ /** Node op timeouts: prepare may clone a repository; collect and cleanup run git in an existing workspace. */
181
+ export const NODE_TIMEOUTS_MS: Readonly<Partial<Record<NodeOp, number>>> = { prepare: 600_000, collect: 60_000, cleanup: 120_000 }
88
182
 
89
183
  const CLOSE_PROTOCOL = 4000
90
184
  const CLOSE_REVOKED = 4001
91
185
  const CLOSE_STALE = 4002
186
+ const CLOSE_REPLACED = 4003
187
+ const CLOSE_LAUNCH_ENDED = 4004
188
+ const CLOSE_LAUNCH_EXPIRED = 4005
189
+ /** Longest delay setTimeout takes; later expiries re-arm. */
190
+ const MAX_TIMER_MS = 2 ** 31 - 1
191
+ const LAUNCH_TOKEN = /^hlt1\./
192
+ /** How long stop() waits for the cluster directory and RPC to be cleaned up. */
193
+ const CLUSTER_STOP_MS = 1_000
194
+ /** How many closed launches are remembered (launch IDs are never reused). */
195
+ const CLOSED_LAUNCHES = 1000
196
+ /** How soon drained spokes and nodes are told to reconnect (they wait a random 0–this; design §10: 0–5 s). */
197
+ export const DRAIN_RETRY_AFTER_MS = 5_000
198
+ /** How long draining waits for requests in flight (k8s terminationGracePeriodSeconds is 60). */
199
+ export const DRAIN_TIMEOUT_MS = 50_000
200
+ /** Close code of a drained connection (a hub code: Bun 1.3.5 reports a server's 1001 to clients as 1000). */
201
+ const CLOSE_DRAINING = 4006
202
+ /** How often draining looks whether the requests in flight are done. */
203
+ const DRAIN_POLL_MS = 20
204
+
205
+ export interface DrainOptions {
206
+ /** Sent in the bye: the peers reconnect after a random 0–retryAfterMs (default DRAIN_RETRY_AFTER_MS). */
207
+ readonly retryAfterMs?: number
208
+ /** How long to wait for requests in flight (default DRAIN_TIMEOUT_MS); the rest fail as offline. */
209
+ readonly timeoutMs?: number
210
+ }
211
+
212
+ /** Cluster mode: how often a pod re-checks that its sockets' machines are not revoked and their owners not disabled. */
213
+ export const RECHECK_MS = 30_000
92
214
 
93
215
  export class SpokeHub {
94
216
  private readonly sockets = new Map<string, Socket>()
95
217
  private readonly pending = new Map<string, Pending>()
218
+ /**
219
+ * Launches closed here, with the reason: a socket whose token was verified just before its launch ended opens
220
+ * after closeLaunch ran, and is turned away then.
221
+ */
222
+ private readonly closedLaunches = new Map<string, string>()
96
223
  private readonly heartbeat: ReturnType<typeof setInterval>
224
+ private readonly recheckTimer?: ReturnType<typeof setInterval>
97
225
  private seq = 0
226
+ /** Set by drain(): the bye's retryAfterMs. */
227
+ private draining?: { readonly retryAfterMs: number }
228
+ private drained?: Promise<void>
229
+ /** Calls this pod made to connections on other pods, not answered yet (draining waits for them). */
230
+ private remoteCalls = 0
231
+ /** Cluster mode: the directory heartbeat, RPC responder and close notices are set up. */
232
+ private readonly started: Promise<void>
98
233
  readonly websocket: WebSocketHandler<SpokeSocketData>
99
234
 
100
235
  constructor(private readonly options: SpokeHubOptions) {
101
236
  const staleMs = options.staleMs ?? 60_000
237
+ this.started = this.startCluster()
102
238
  this.heartbeat = setInterval(() => {
103
239
  const now = Date.now()
104
240
  for (const ws of this.sockets.values()) {
@@ -107,6 +243,10 @@ export class SpokeHub {
107
243
  }
108
244
  }, options.heartbeatMs ?? 20_000)
109
245
  this.heartbeat.unref?.()
246
+ if (options.cluster) {
247
+ this.recheckTimer = setInterval(() => void this.recheck(), options.recheckMs ?? RECHECK_MS)
248
+ this.recheckTimer.unref?.()
249
+ }
110
250
 
111
251
  this.websocket = {
112
252
  open: (ws) => this.open(ws),
@@ -117,71 +257,306 @@ export class SpokeHub {
117
257
  }
118
258
  }
119
259
 
120
- /** Authenticates and upgrades /spoke/ws. Returns a Response on failure, undefined when upgraded. */
121
- upgrade(request: Request, server: Server<SpokeSocketData>): Response | undefined {
260
+ /**
261
+ * Authenticates and upgrades /spoke/ws with a machine token or a launch-bound (hlt1) token. Resolves with a
262
+ * Response on failure, undefined when upgraded.
263
+ */
264
+ async upgrade(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
265
+ if (this.draining) {
266
+ const retryAfter = String(Math.max(1, Math.ceil(this.draining.retryAfterMs / 1000)))
267
+ return Response.json({ error: "This hub server is shutting down; reconnect", code: "draining" }, { status: 503, headers: { "retry-after": retryAfter } })
268
+ }
122
269
  const match = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")
123
- const machine = match?.[1] ? this.options.store.findMachine(match[1]) : undefined
270
+ const token = match?.[1]
271
+ if (token && LAUNCH_TOKEN.test(token)) return this.upgradeLaunch(request, server, token)
272
+ const machine = token ? await this.options.store.findMachine(token) : undefined
273
+ if (machine) await this.options.store.recheck({ users: [machine.ownerID] })
124
274
  const owner = machine && this.options.store.user(machine.ownerID)
125
275
  if (!machine || !owner || owner.disabled) return Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
126
- const data: SpokeSocketData = {
127
- connID: newID("conn"),
128
- machineID: machine.id,
129
- machineName: machine.name,
130
- ready: false,
131
- lastSeen: Date.now(),
276
+ return this.accept(request, server, { connID: newID("conn"), machineID: machine.id, machineName: machine.name, ready: false, lastSeen: Date.now() })
277
+ }
278
+
279
+ private async upgradeLaunch(request: Request, server: Server<SpokeSocketData>, token: string): Promise<Response | undefined> {
280
+ const refused = () => Response.json({ error: "Invalid or expired launch token" }, { status: 401 })
281
+ const verifier = this.options.launchTokens
282
+ if (!verifier) return refused()
283
+ let binding: LaunchVerifyResult
284
+ try {
285
+ binding = await verifier.verify(token)
286
+ } catch {
287
+ return refused()
132
288
  }
289
+ if (!binding || binding === "expired") return refused()
290
+ const machine = this.options.store.machines().find((m) => m.id === binding.machineID)
291
+ if (!machine) return refused()
292
+ return this.accept(request, server, { connID: newID("conn"), machineID: machine.id, machineName: machine.name, ready: false, lastSeen: Date.now(), launch: binding })
293
+ }
294
+
295
+ private accept(request: Request, server: Server<SpokeSocketData>, data: SpokeSocketData): Response | undefined {
133
296
  if (server.upgrade(request, { data })) return undefined
134
297
  return Response.json({ error: "Expected a WebSocket upgrade" }, { status: 426 })
135
298
  }
136
299
 
137
- /** Sends a request to a connected spoke and resolves with its result. */
138
- request<R extends ServerRequest>(connID: string, req: R): Promise<ResultFor[R["op"]]> {
300
+ /** Resolves once cluster mode is set up (at once without it). */
301
+ ready(): Promise<void> {
302
+ return this.started
303
+ }
304
+
305
+ /**
306
+ * Sends a request to a connected spoke and resolves with its result. In cluster mode a connection another pod
307
+ * holds is reached over pod RPC.
308
+ */
309
+ request<R extends ServerRequest>(connID: string, req: R, options: RequestOptions = {}): Promise<ResultFor[R["op"]]> {
139
310
  const ws = this.sockets.get(connID)
140
- if (!ws?.data.ready) return Promise.reject(new SpokeError("OpenCode process disconnected"))
311
+ if (!ws && this.options.cluster) return this.remote({ kind: "spoke", connID, req, tenant: options.tenant ?? "" }) as Promise<ResultFor[R["op"]]>
312
+ if (!ws?.data.ready || ws.data.role !== "spoke" || this.draining) return Promise.reject(new SpokeError("OpenCode process disconnected"))
141
313
  const id = `r${++this.seq}`
142
314
  const frame = encode({ t: "req", id, req })
143
315
  if (frame.length > MAX_FRAME) return Promise.reject(new RequestTooLargeError(req.op))
144
- const base = this.options.requestTimeoutMs ?? 15_000
145
- const timeoutMs =
146
- req.op === "turn"
147
- ? this.options.turnTimeoutMs + (this.options.turnGraceMs ?? TURN_GRACE_MS)
148
- : req.op === "create" && req.herdrWorkspace
149
- ? Math.max(base, CREATE_IN_HERDR_TIMEOUT_MS) // the spoke waits for OpenCode to start in the new tab
150
- : base
316
+ const timeoutMs = this.spokeTimeoutMs(req)
151
317
  return new Promise((resolve, reject) => {
152
318
  const timer = setTimeout(() => {
153
319
  this.pending.delete(id)
154
320
  reject(new RequestTimeoutError(req.op))
321
+ this.released(connID)
155
322
  }, timeoutMs)
156
323
  this.pending.set(id, { connID, op: req.op, resolve: resolve as (v: unknown) => void, reject, timer })
157
324
  ws.send(frame)
158
325
  })
159
326
  }
160
327
 
161
- /** Disconnects every socket of a machine (after revocation). */
328
+ /**
329
+ * Sends an op to a connected node and resolves with its result, checked with nodeResultProblem. In cluster mode a
330
+ * node another pod holds is reached over pod RPC.
331
+ */
332
+ requestNode<R extends NodeRequest>(connID: string, req: R, options: RequestOptions = {}): Promise<NodeResultFor[R["op"]]> {
333
+ const ws = this.sockets.get(connID)
334
+ if (!ws && this.options.cluster) return this.remote({ kind: "node", connID, req, tenant: options.tenant ?? "" }) as Promise<NodeResultFor[R["op"]]>
335
+ if (!ws?.data.ready || ws.data.role !== NODE_ROLE || this.draining) return Promise.reject(new NodeError("The node disconnected"))
336
+ const id = `r${++this.seq}`
337
+ const frame = encode({ t: "req", id, req } satisfies NodeServerMessage)
338
+ if (frame.length > MAX_FRAME) return Promise.reject(new NodeError(`The ${req.op} request is too large to send to the node`))
339
+ const timeoutMs = this.nodeTimeoutMs(req)
340
+ return new Promise((resolve, reject) => {
341
+ const timer = setTimeout(() => {
342
+ this.pending.delete(id)
343
+ reject(new RequestTimeoutError(req.op, "The node"))
344
+ this.released(connID)
345
+ }, timeoutMs)
346
+ this.pending.set(id, { connID, op: req.op, node: true, resolve: resolve as (v: unknown) => void, reject, timer })
347
+ ws.send(frame)
348
+ })
349
+ }
350
+
351
+ /** Disconnects the connections bound to a launch (when it ends), on every pod. */
352
+ closeLaunch(launchID: string, reason = "launch ended"): void {
353
+ this.closeLaunchHere(launchID, reason)
354
+ this.publishClose({ kind: "launch", id: launchID, reason })
355
+ }
356
+
357
+ /** Disconnects every socket of a machine (after revocation), on every pod. */
162
358
  closeMachine(machineID: string, reason: string): void {
163
- for (const ws of this.sockets.values()) if (ws.data.machineID === machineID) this.bye(ws, reason, CLOSE_REVOKED)
359
+ this.closeMachineHere(machineID, reason)
360
+ this.publishClose({ kind: "machine", id: machineID, reason })
361
+ }
362
+
363
+ /**
364
+ * Draining (design §10): refuses new upgrades and requests, says bye{reason:"draining", retryAfterMs} to each
365
+ * connection once its requests in flight are answered, and resolves when they all are (or at the deadline, when the
366
+ * rest fail as offline and every connection gets its bye). Repeated calls share the first one.
367
+ */
368
+ drain(options: DrainOptions = {}): Promise<void> {
369
+ this.drained ??= this.runDrain(options.retryAfterMs ?? DRAIN_RETRY_AFTER_MS, options.timeoutMs ?? DRAIN_TIMEOUT_MS)
370
+ return this.drained
371
+ }
372
+
373
+ /** Whether drain() was called. */
374
+ isDraining(): boolean {
375
+ return this.draining !== undefined
376
+ }
377
+
378
+ private async runDrain(retryAfterMs: number, timeoutMs: number): Promise<void> {
379
+ this.draining = { retryAfterMs }
380
+ for (const ws of [...this.sockets.values()]) this.released(ws.data.connID)
381
+ const deadline = Date.now() + timeoutMs
382
+ while ((this.pending.size > 0 || this.remoteCalls > 0) && Date.now() < deadline) await Bun.sleep(Math.min(DRAIN_POLL_MS, Math.max(1, deadline - Date.now())))
383
+ for (const [id, pending] of this.pending) {
384
+ this.pending.delete(id)
385
+ clearTimeout(pending.timer)
386
+ pending.reject(pending.node ? new NodeError("The node disconnected") : new SpokeError("OpenCode process disconnected"))
387
+ }
388
+ for (const ws of [...this.sockets.values()]) this.byeDraining(ws)
164
389
  }
165
390
 
166
- stop(): void {
391
+ /** Draining: a connection with no request in flight any more gets its bye. */
392
+ private released(connID: string): void {
393
+ if (!this.draining) return
394
+ const ws = this.sockets.get(connID)
395
+ if (!ws) return
396
+ for (const pending of this.pending.values()) if (pending.connID === connID) return
397
+ this.byeDraining(ws)
398
+ }
399
+
400
+ private byeDraining(ws: Socket): void {
401
+ if (!this.draining || ws.data.drained) return
402
+ ws.data.drained = true
403
+ this.send(ws, { t: "bye", reason: "draining", retryAfterMs: this.draining.retryAfterMs })
404
+ ws.close(CLOSE_DRAINING, "draining")
405
+ }
406
+
407
+ async stop(): Promise<void> {
167
408
  clearInterval(this.heartbeat)
168
- for (const ws of this.sockets.values()) this.bye(ws, "server shutting down", 1001)
409
+ clearInterval(this.recheckTimer)
410
+ for (const ws of this.sockets.values()) if (!ws.data.drained) this.bye(ws, "server shutting down", 1001)
411
+ const { cluster } = this.options
412
+ if (!cluster) return
413
+ // Best effort and bounded: the entries expire anyway, and an unreachable Valkey must not hold up shutdown.
414
+ const cleanup = this.started.catch(() => undefined).then(() => Promise.allSettled([cluster.rpc.stop(), cluster.directory.stop()]))
415
+ let timer: ReturnType<typeof setTimeout> | undefined
416
+ await Promise.race([cleanup, new Promise<void>((resolve) => (timer = setTimeout(resolve, CLUSTER_STOP_MS)))])
417
+ clearTimeout(timer)
418
+ }
419
+
420
+ private closeLaunchHere(launchID: string, reason: string): void {
421
+ this.closedLaunches.set(launchID, reason)
422
+ if (this.closedLaunches.size > CLOSED_LAUNCHES) this.closedLaunches.delete(this.closedLaunches.keys().next().value!)
423
+ for (const ws of this.sockets.values()) if (ws.data.launch?.launchID === launchID) this.bye(ws, reason, CLOSE_LAUNCH_ENDED)
424
+ }
425
+
426
+ private closeMachineHere(machineID: string, reason: string): void {
427
+ for (const ws of this.sockets.values()) if (ws.data.machineID === machineID) this.bye(ws, reason, CLOSE_REVOKED)
428
+ }
429
+
430
+ /**
431
+ * Cluster mode: re-reads the machines of this pod's ready sockets and their owners from the database and closes the
432
+ * sockets of a revoked (or removed) machine or a disabled owner, in case the close notice from another pod was lost.
433
+ */
434
+ async recheck(): Promise<void> {
435
+ const machineIDs = [...new Set([...this.sockets.values()].filter((ws) => ws.data.ready).map((ws) => ws.data.machineID))]
436
+ if (!machineIDs.length) return
437
+ const { store } = this.options
438
+ try {
439
+ await store.recheck({ machines: machineIDs, users: machineIDs.map((id) => this.ownerOf(id)) })
440
+ // Ownership may have moved: the current owners too.
441
+ await store.recheck({ users: machineIDs.map((id) => this.ownerOf(id)) })
442
+ } catch (cause) {
443
+ console.error(`[hub] machine re-check failed: ${cause instanceof Error ? cause.message : cause}`)
444
+ return
445
+ }
446
+ for (const machineID of machineIDs) {
447
+ const machine = store.machines().find((m) => m.id === machineID)
448
+ const owner = machine && store.user(machine.ownerID)
449
+ if (!machine || machine.revoked) this.closeMachineHere(machineID, "machine revoked")
450
+ else if (!owner || owner.disabled) this.closeMachineHere(machineID, "account disabled")
451
+ }
452
+ }
453
+
454
+ private publishClose(notice: Omit<CloseNotice, "from">): void {
455
+ this.options.cluster?.rpc.publishClose(notice).catch((cause) => {
456
+ console.error(`[hub] close notice not sent to the other pods: ${cause instanceof Error ? cause.message : cause}`)
457
+ })
458
+ }
459
+
460
+ /** Cluster mode: heartbeat the directory, answer RPC for this pod's connections, apply other pods' close notices. */
461
+ private async startCluster(): Promise<void> {
462
+ const { cluster } = this.options
463
+ if (!cluster) return
464
+ await cluster.directory.start()
465
+ await cluster.rpc.serve((call) => this.serveRpc(call))
466
+ await cluster.rpc.onClose((notice) => {
467
+ if (notice.from === cluster.directory.podID) return
468
+ if (notice.kind === "machine") this.closeMachineHere(notice.id, notice.reason)
469
+ else this.closeLaunchHere(notice.id, notice.reason)
470
+ })
471
+ }
472
+
473
+ /** The hub-side wait for a spoke op. */
474
+ private spokeTimeoutMs(req: ServerRequest): number {
475
+ const base = this.options.requestTimeoutMs ?? 15_000
476
+ return req.op === "turn"
477
+ ? this.options.turnTimeoutMs + (this.options.turnGraceMs ?? TURN_GRACE_MS)
478
+ : req.op === "create" && req.herdrWorkspace
479
+ ? Math.max(base, CREATE_IN_HERDR_TIMEOUT_MS) // the spoke waits for OpenCode to start in the new tab
480
+ : req.op === "autoName"
481
+ ? Math.max(base, AUTO_NAME_TIMEOUT_MS)
482
+ : base
483
+ }
484
+
485
+ private nodeTimeoutMs(req: NodeRequest): number {
486
+ return this.options.nodeTimeoutsMs?.[req.op] ?? NODE_TIMEOUTS_MS[req.op] ?? this.options.requestTimeoutMs ?? 15_000
487
+ }
488
+
489
+ /** An op for a connection this pod does not hold: to the pod the directory names, over RPC. */
490
+ private async remote(call: RpcCall): Promise<unknown> {
491
+ const { directory, rpc } = this.options.cluster!
492
+ if (encode({ t: "req", id: "r0", req: call.req } as ServerMessage).length > MAX_FRAME) {
493
+ throw call.kind === "node" ? new NodeError(`The ${call.req.op} request is too large to send to the node`) : new RequestTooLargeError(call.req.op)
494
+ }
495
+ const entry = await directory.lookup(call.connID)
496
+ if (!entry || entry.pod === directory.podID) throw offlineError(call.kind)
497
+ const tenant = call.tenant || this.ownerOf(entry.machineID)
498
+ if (!tenant) throw offlineError(call.kind)
499
+ const timeoutMs = call.kind === "node" ? this.nodeTimeoutMs(call.req) : this.spokeTimeoutMs(call.req)
500
+ this.remoteCalls++
501
+ try {
502
+ return await rpc.call(entry.pod, { ...call, tenant } as RpcCall, timeoutMs, {
503
+ onGone: () => void directory.forget(call.connID, entry.pod).catch(() => undefined),
504
+ })
505
+ } finally {
506
+ this.remoteCalls--
507
+ }
508
+ }
509
+
510
+ /** An op from another pod for a connection here: re-check the tenant, then send it like a local one. */
511
+ private async serveRpc(call: RpcCall): Promise<unknown> {
512
+ const ws = this.sockets.get(call.connID)
513
+ if (!ws?.data.ready) throw offlineError(call.kind)
514
+ const machine = this.options.store.machines().find((m) => m.id === ws.data.machineID)
515
+ if (!machine || machine.revoked || machine.ownerID !== call.tenant) throw offlineError(call.kind)
516
+ const sessionID = (call.req as { sessionID?: unknown }).sessionID
517
+ if (typeof sessionID === "string") {
518
+ const owner = this.options.registry.sessionTenant(sessionID)
519
+ if (owner !== undefined && owner !== call.tenant) throw new SpokeError("Unknown session")
520
+ }
521
+ return call.kind === "node" ? this.requestNode(call.connID, call.req) : this.request(call.connID, call.req)
169
522
  }
170
523
 
171
524
  private open(ws: Socket): void {
172
525
  this.sockets.set(ws.data.connID, ws)
526
+ // Upgraded just before drain(): send it on its way too.
527
+ if (this.draining) return this.byeDraining(ws)
528
+ if (ws.data.launch) {
529
+ const closed = this.closedLaunches.get(ws.data.launch.launchID)
530
+ if (closed !== undefined) return this.bye(ws, closed, CLOSE_LAUNCH_ENDED)
531
+ this.armExpiry(ws, ws.data.launch.exp)
532
+ }
173
533
  ws.data.helloTimer = setTimeout(() => {
174
534
  if (!ws.data.ready) this.bye(ws, "hello timeout")
175
535
  }, this.options.helloTimeoutMs ?? 10_000)
176
536
  }
177
537
 
538
+ /** Closes a launch-bound connection at its token's expiry. */
539
+ private armExpiry(ws: Socket, exp: number): void {
540
+ const left = exp - Date.now()
541
+ if (left <= 0) return this.bye(ws, "launch token expired", CLOSE_LAUNCH_EXPIRED)
542
+ ws.data.expiryTimer = setTimeout(() => this.armExpiry(ws, exp), Math.min(left, MAX_TIMER_MS))
543
+ ws.data.expiryTimer.unref?.()
544
+ }
545
+
178
546
  private seen(ws: Socket): void {
179
547
  ws.data.lastSeen = Date.now()
180
- if (ws.data.ready) this.options.registry.touch(ws.data.connID)
548
+ if (!ws.data.ready) return
549
+ if (ws.data.role === NODE_ROLE) this.options.nodes.touch(ws.data.connID)
550
+ else this.options.registry.touch(ws.data.connID)
181
551
  }
182
552
 
183
553
  private message(ws: Socket, raw: string | Buffer): void {
184
554
  this.seen(ws)
555
+ if (ws.data.ready ? ws.data.role === NODE_ROLE : helloRole(raw) === NODE_ROLE) {
556
+ // A launch token is for that launch's spoke only.
557
+ if (ws.data.launch) return this.bye(ws, "protocol error")
558
+ return this.nodeMessage(ws, raw)
559
+ }
185
560
  const message = parseSpokeMessage(raw)
186
561
  if (!message) return this.bye(ws, "protocol error")
187
562
  const { registry } = this.options
@@ -192,11 +567,26 @@ export class SpokeHub {
192
567
  if (message.v !== PROTOCOL_VERSION) {
193
568
  return this.bye(ws, `unsupported protocol version ${message.v}; server speaks version ${PROTOCOL_VERSION}`)
194
569
  }
570
+ const { launch } = ws.data
571
+ if (launch) {
572
+ if (message.launchID !== launch.launchID) return this.bye(ws, "the hello does not name the launch of this token")
573
+ // One connection per launch: a reconnect (the old socket may be half-open) replaces the old one.
574
+ const old = registry.launchConn(launch.launchID)
575
+ if (old !== undefined) {
576
+ registry.disconnect(old)
577
+ const oldWs = this.sockets.get(old)
578
+ if (oldWs) this.bye(oldWs, "replaced by a new connection of this launch", CLOSE_REPLACED)
579
+ }
580
+ }
195
581
  clearTimeout(ws.data.helloTimer)
196
582
  ws.data.ready = true
197
- registry.connect(connID, { id: ws.data.machineID, name: ws.data.machineName }, message)
198
- this.options.store.touchMachine(ws.data.machineID)
199
- this.send(ws, { t: "welcome", v: PROTOCOL_VERSION, machineID: ws.data.machineID, publicURL: this.options.publicURL })
583
+ ws.data.role = "spoke"
584
+ if (!isOpenCodeHello(message)) ws.data.checkedHarness = helloHarness(message).harness
585
+ registry.connect(connID, { id: ws.data.machineID, name: ws.data.machineName }, message, launch ? { launchID: launch.launchID } : {})
586
+ this.registerConn(ws)
587
+ void this.options.store.touchMachine(ws.data.machineID)
588
+ // Older spokes ignore `features`; newer ones send a request a feature adds only when it is listed here.
589
+ this.send(ws, { t: "welcome", v: PROTOCOL_VERSION, machineID: ws.data.machineID, publicURL: this.options.publicURL, features: [...HUB_FEATURES] })
200
590
  return
201
591
  }
202
592
 
@@ -208,26 +598,94 @@ export class SpokeHub {
208
598
  return this.resendConversations(connID)
209
599
  case "terminals":
210
600
  return registry.setTerminals(connID, message)
211
- case "event":
601
+ case "event": {
602
+ const problem = ws.data.checkedHarness === undefined ? undefined : harnessEventProblem(message.event)
603
+ if (problem) {
604
+ // Logged for the first few and then every 100th, so a misbehaving spoke cannot flood the log.
605
+ const dropped = (ws.data.droppedEvents = (ws.data.droppedEvents ?? 0) + 1)
606
+ if (dropped <= 5 || dropped % 100 === 0) {
607
+ const type = typeof message.event.type === "string" ? message.event.type.slice(0, 100) : "?"
608
+ console.warn(`[hub] dropped a ${ws.data.checkedHarness} spoke's ${type} event (${connID}, ${dropped} so far): ${problem}`)
609
+ }
610
+ return
611
+ }
212
612
  return registry.observe(connID, message.event)
613
+ }
213
614
  case "req":
214
615
  void this.answer(ws, message)
215
616
  return
216
- case "res": {
217
- const pending = this.pending.get(message.id)
218
- if (!pending || pending.connID !== connID) return
219
- this.pending.delete(message.id)
220
- clearTimeout(pending.timer)
221
- if (message.ok) pending.resolve(message.result)
222
- else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`))
617
+ case "res":
618
+ return this.settle(connID, message)
619
+ case "bye":
620
+ ws.close(1000, "bye")
223
621
  return
224
- }
622
+ }
623
+ }
624
+
625
+ /** A frame from a node (or a node hello). */
626
+ private nodeMessage(ws: Socket, raw: string | Buffer): void {
627
+ const message = parseNodeMessage(raw)
628
+ if (!message) return this.bye(ws, "protocol error")
629
+ const { nodes } = this.options
630
+ const { connID } = ws.data
631
+ if (!ws.data.ready) {
632
+ if (message.t !== "hello") return this.bye(ws, "protocol error")
633
+ return this.nodeHello(ws, message)
634
+ }
635
+ switch (message.t) {
636
+ case "hello":
637
+ return this.bye(ws, "protocol error")
638
+ case "capacity":
639
+ return nodes.setCapacity(connID, message)
640
+ case "progress":
641
+ return nodes.progress(connID, message)
642
+ case "res":
643
+ return this.settle(connID, message)
225
644
  case "bye":
226
645
  ws.close(1000, "bye")
227
646
  return
228
647
  }
229
648
  }
230
649
 
650
+ private nodeHello(ws: Socket, hello: NodeHello): void {
651
+ if (hello.v !== PROTOCOL_VERSION) return this.bye(ws, `unsupported protocol version ${hello.v}; server speaks version ${PROTOCOL_VERSION}`)
652
+ const { nodes } = this.options
653
+ // This pod's node of the machine only: a stale entry of another pod expires with its slice.
654
+ const existing = nodes.localForMachine(ws.data.machineID)
655
+ if (existing) {
656
+ // One node per machine. The same daemon run reconnecting (its old socket may be half-open) replaces it.
657
+ if (existing.instanceID !== hello.instanceID) return this.bye(ws, "another node is already connected for this machine")
658
+ nodes.disconnect(existing.connID)
659
+ const old = this.sockets.get(existing.connID)
660
+ if (old) this.bye(old, "replaced by a new connection of this node", CLOSE_REPLACED)
661
+ }
662
+ clearTimeout(ws.data.helloTimer)
663
+ ws.data.ready = true
664
+ ws.data.role = NODE_ROLE
665
+ nodes.connect(ws.data.connID, { id: ws.data.machineID, name: ws.data.machineName }, hello)
666
+ this.registerConn(ws)
667
+ void this.options.store.touchMachine(ws.data.machineID)
668
+ this.send(ws, { t: "welcome", v: PROTOCOL_VERSION, machineID: ws.data.machineID, publicURL: this.options.publicURL, features: [...HUB_FEATURES] })
669
+ }
670
+
671
+ /** Resolves or rejects the pending request a `res` frame answers. */
672
+ private settle(connID: string, message: { readonly id: string; readonly ok: boolean; readonly result?: unknown; readonly error?: string }): void {
673
+ const pending = this.pending.get(message.id)
674
+ if (!pending || pending.connID !== connID) return
675
+ this.pending.delete(message.id)
676
+ clearTimeout(pending.timer)
677
+ // Draining: once the result is handed on, this connection may get its bye.
678
+ queueMicrotask(() => this.released(connID))
679
+ if (pending.node) {
680
+ if (!message.ok) return pending.reject(new NodeError(message.error || `The node's ${pending.op} request failed`))
681
+ const problem = nodeResultProblem(pending.op as NodeOp, message.result)
682
+ if (problem) return pending.reject(new NodeError(`The node sent an invalid ${pending.op} result: ${problem}`))
683
+ return pending.resolve(message.result)
684
+ }
685
+ if (message.ok) pending.resolve(message.result)
686
+ else pending.reject(new SpokeError(message.error || `OpenCode ${pending.op} request failed`))
687
+ }
688
+
231
689
  private async answer(ws: Socket, frame: Req<SpokeRequest>): Promise<void> {
232
690
  try {
233
691
  const result = await this.handle(ws, frame.req)
@@ -246,6 +704,8 @@ export class SpokeHub {
246
704
  const { auth, publicURL } = this.options
247
705
  const owner = this.ownerOf(ws.data.machineID)
248
706
  if (!owner) throw new Error("This machine is no longer enrolled")
707
+ // A sandboxed harness never gets credentials for the owner's devices or console.
708
+ if (ws.data.launch && (req.op === "pairCode" || req.op === "openLink")) throw new Error("Not available to a launch-bound session")
249
709
  switch (req.op) {
250
710
  case "pairCode": {
251
711
  const { code, expiresAt } = auth.issuePairCode(owner)
@@ -257,7 +717,7 @@ export class SpokeHub {
257
717
  if (tenant !== undefined && tenant !== owner) throw new Error("Unknown session")
258
718
  this.options.broadcast({ type: "handoff", sessionID: req.sessionID }, owner)
259
719
  const title = (tenant && this.options.registry.status(req.sessionID)?.[0]?.title) || req.sessionID
260
- return { notified: await this.options.notifier.notify("handoff", req.sessionID, title, undefined, undefined, owner) }
720
+ return { notified: await this.options.notifier.notify("handoff", req.sessionID, title, undefined, owner) }
261
721
  }
262
722
  case "openLink": {
263
723
  const { code } = auth.issueLoginCode(owner)
@@ -278,15 +738,28 @@ export class SpokeHub {
278
738
 
279
739
  private closed(ws: Socket): void {
280
740
  clearTimeout(ws.data.helloTimer)
741
+ clearTimeout(ws.data.expiryTimer)
281
742
  const { connID } = ws.data
282
743
  this.sockets.delete(connID)
283
744
  for (const [id, pending] of this.pending) {
284
745
  if (pending.connID !== connID) continue
285
746
  this.pending.delete(id)
286
747
  clearTimeout(pending.timer)
287
- pending.reject(new SpokeError("OpenCode process disconnected"))
748
+ pending.reject(pending.node ? new NodeError("The node disconnected") : new SpokeError("OpenCode process disconnected"))
288
749
  }
289
- if (ws.data.ready) this.options.registry.disconnect(connID)
750
+ if (!ws.data.ready) return
751
+ this.options.cluster?.directory.unregister(connID).catch((cause) => {
752
+ console.error(`[hub] directory entry not removed: ${cause instanceof Error ? cause.message : cause}`)
753
+ })
754
+ if (ws.data.role === NODE_ROLE) this.options.nodes.disconnect(connID)
755
+ else this.options.registry.disconnect(connID)
756
+ }
757
+
758
+ /** Cluster mode: records a ready connection in the directory, so other pods route its ops here. */
759
+ private registerConn(ws: Socket): void {
760
+ this.options.cluster?.directory.register(ws.data.connID, ws.data.machineID).catch((cause) => {
761
+ console.error(`[hub] directory entry not written: ${cause instanceof Error ? cause.message : cause}`)
762
+ })
290
763
  }
291
764
 
292
765
  private bye(ws: Socket, reason: string, code = CLOSE_PROTOCOL): void {
@@ -294,7 +767,12 @@ export class SpokeHub {
294
767
  ws.close(code, reason)
295
768
  }
296
769
 
297
- private send(ws: Socket, message: ServerMessage): void {
770
+ private send(ws: Socket, message: ServerMessage | NodeServerMessage): void {
298
771
  ws.send(encode(message))
299
772
  }
300
773
  }
774
+
775
+ /** What a request to a connection that is gone rejects with. */
776
+ function offlineError(kind: RpcCall["kind"]): Error {
777
+ return kind === "node" ? new NodeError("The node disconnected") : new SpokeError("OpenCode process disconnected")
778
+ }