@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,232 @@
1
+ // Leader loops (design §9, decision vkxkqiuf §7): a timed job (decision deadlines, message re-wakes, the webhook
2
+ // sender, the launch supervisor) runs on one pod at a time, the holder of a fenced lease `leader:<name>` in the
3
+ // shared Locks. The single-process hub runs the same code on in-memory locks: it is always the leader.
4
+ // - start(): tries to take the lease at once, then every renewMs: the leader renews it, the others try to take it.
5
+ // Taking it calls onElected (with the lease: its fence grows with every new leader); losing it (taken, expired, or
6
+ // a renewal failing too close to its expiry) calls onDeposed, so a leader stops before its lease can run out.
7
+ // - stop(): steps down, releases the lease and tells the other pods (LEADER topic `.released`), which try at once:
8
+ // a graceful failover takes one bus hop; a dead leader is replaced within ttlMs (+ one renewMs tick).
9
+ // - kick(message): any pod nudges the leader (onKick runs there only), e.g. "a launch was queued".
10
+ // - request(message): any pod asks the leader (onRequest), wherever it runs (Bus RPC on leader.<name>.<owner>.rpc);
11
+ // NoLeaderError when nobody holds the lease or the holder no longer answers.
12
+ import { realTimers } from "../backend/memory-bus"
13
+ import { BusNoResponderError, type Bus, type Clock, type Data, type Lease, type Locks, type RequestOptions, type Timers, type Unsubscribe } from "../backend/types"
14
+
15
+ /** How long a leader lease lasts without renewal (a dead leader is replaced after this). */
16
+ export const LEADER_TTL_MS = 15_000
17
+
18
+ /** The Locks key of a job's leader lease. */
19
+ export const leaderKey = (name: string) => `leader:${name}`
20
+ const kickTopic = (name: string) => `leader.${name}.kick`
21
+ const releasedTopic = (name: string) => `leader.${name}.released`
22
+ const requestTopic = (name: string, owner: string) => `leader.${name}.${owner}.rpc`
23
+
24
+ /** Nobody leads the job (or its leader went away): the request cannot be answered now. */
25
+ export class NoLeaderError extends Error {
26
+ constructor(name: string) {
27
+ super(`No leader runs ${name} right now`)
28
+ this.name = "NoLeaderError"
29
+ }
30
+ }
31
+
32
+ export interface LeaderLoopOptions {
33
+ readonly locks: Locks
34
+ readonly bus: Bus
35
+ /** The job (lease key leader:<name>, topics leader.<name>.*). */
36
+ readonly name: string
37
+ /** This pod's process (unique per process: a restarted pod is a new owner). */
38
+ readonly owner: string
39
+ /** Lease time (default LEADER_TTL_MS). */
40
+ readonly ttlMs?: number
41
+ /** How often the leader renews and the others try (default a third of ttlMs). */
42
+ readonly renewMs?: number
43
+ readonly timers?: Timers
44
+ readonly now?: Clock
45
+ /** This pod leads now: start the job. */
46
+ readonly onElected?: (lease: Lease) => void | Promise<void>
47
+ /** This pod no longer leads: stop the job. */
48
+ readonly onDeposed?: () => void | Promise<void>
49
+ readonly onKick?: (message: Data) => void
50
+ readonly onRequest?: (message: Data) => Data | Promise<Data>
51
+ readonly onError?: (error: unknown) => void
52
+ }
53
+
54
+ export class LeaderLoop {
55
+ readonly name: string
56
+ private readonly options: LeaderLoopOptions
57
+ private readonly ttlMs: number
58
+ private readonly renewMs: number
59
+ private readonly timers: Timers
60
+ private readonly now: Clock
61
+ private readonly onError: (error: unknown) => void
62
+ private lease: Lease | undefined
63
+ /** Until when (this pod's clock) the lease surely holds: the time the last successful take or renewal started + ttl. */
64
+ private validUntil = 0
65
+ private running = false
66
+ private timer: unknown
67
+ private queue: Promise<void> = Promise.resolve()
68
+ private readonly subscriptions: Unsubscribe[] = []
69
+ private serving: Unsubscribe | undefined
70
+
71
+ constructor(options: LeaderLoopOptions) {
72
+ this.options = options
73
+ this.name = options.name
74
+ this.ttlMs = options.ttlMs ?? LEADER_TTL_MS
75
+ this.renewMs = options.renewMs ?? Math.max(1, Math.floor(this.ttlMs / 3))
76
+ this.timers = options.timers ?? realTimers
77
+ this.now = options.now ?? Date.now
78
+ this.onError = options.onError ?? ((error) => console.error(`[hub] leader ${options.name}: ${error instanceof Error ? error.message : String(error)}`))
79
+ }
80
+
81
+ /** Whether this pod leads the job now. */
82
+ isLeader(): boolean {
83
+ return this.lease !== undefined
84
+ }
85
+
86
+ /** The current lease's fence (undefined when not leading). */
87
+ fence(): number | undefined {
88
+ return this.lease?.fence
89
+ }
90
+
91
+ /** Listens for kicks and releases, tries to take the lease and keeps trying (or renewing) every renewMs. */
92
+ async start(): Promise<void> {
93
+ if (this.running) return
94
+ this.running = true
95
+ const { bus, name } = this.options
96
+ try {
97
+ this.subscriptions.push(
98
+ await bus.subscribe(kickTopic(name), (message) => {
99
+ if (this.lease) this.options.onKick?.(message)
100
+ }),
101
+ await bus.subscribe(releasedTopic(name), () => {
102
+ if (this.running && !this.lease) void this.enqueue(() => this.attempt())
103
+ }),
104
+ )
105
+ } catch (error) {
106
+ this.onError(error)
107
+ }
108
+ await this.enqueue(() => this.attempt())
109
+ this.schedule()
110
+ }
111
+
112
+ /** Steps down (releasing the lease and telling the other pods) and stops trying. */
113
+ async stop(): Promise<void> {
114
+ if (!this.running) return
115
+ this.running = false
116
+ if (this.timer !== undefined) this.timers.clear(this.timer)
117
+ this.timer = undefined
118
+ const subscriptions = this.subscriptions.splice(0)
119
+ await this.enqueue(async () => {
120
+ if (this.lease) {
121
+ await this.depose()
122
+ await this.options.locks.release(leaderKey(this.name), this.options.owner).catch((error) => this.onError(error))
123
+ await this.options.bus.publish(releasedTopic(this.name), { owner: this.options.owner }).catch((error) => this.onError(error))
124
+ }
125
+ await Promise.allSettled(subscriptions.map((unsubscribe) => unsubscribe()))
126
+ })
127
+ }
128
+
129
+ /** Nudges the leader (wherever it runs). */
130
+ async kick(message: Data = {}): Promise<void> {
131
+ await this.options.bus.publish(kickTopic(this.name), message)
132
+ }
133
+
134
+ /** Asks the leader (here or on another pod); NoLeaderError when there is none. */
135
+ async request<T = Data>(message: Data, options: RequestOptions): Promise<T> {
136
+ const holder = await this.options.locks.holder(leaderKey(this.name))
137
+ if (!holder) throw new NoLeaderError(this.name)
138
+ if (holder.owner === this.options.owner && this.lease) {
139
+ if (!this.options.onRequest) throw new NoLeaderError(this.name)
140
+ return (await this.options.onRequest(structuredClone(message))) as T
141
+ }
142
+ try {
143
+ return await this.options.bus.request<T>(requestTopic(this.name, holder.owner), message, options)
144
+ } catch (error) {
145
+ if (error instanceof BusNoResponderError) throw new NoLeaderError(this.name)
146
+ throw error
147
+ }
148
+ }
149
+
150
+ // ---- internals ----------------------------------------------------------------------------------------
151
+
152
+ private schedule(): void {
153
+ if (!this.running) return
154
+ this.timer = this.timers.set(() => {
155
+ this.timer = undefined
156
+ void this.enqueue(() => this.attempt()).finally(() => this.schedule())
157
+ }, this.renewMs)
158
+ }
159
+
160
+ /** Renews the lease (leader) or tries to take it (the others). */
161
+ private async attempt(): Promise<void> {
162
+ if (!this.running) return
163
+ const { locks, owner } = this.options
164
+ const key = leaderKey(this.name)
165
+ const started = this.now()
166
+ if (this.lease) {
167
+ let renewed: Lease | undefined
168
+ try {
169
+ renewed = await locks.renew(key, owner, this.ttlMs)
170
+ } catch (error) {
171
+ this.onError(error)
172
+ // Not sure whether it still holds: step down while the lease surely lasts one more tick.
173
+ if (this.now() + this.renewMs >= this.validUntil) await this.depose()
174
+ return
175
+ }
176
+ if (!renewed) {
177
+ await this.depose()
178
+ return
179
+ }
180
+ this.lease = renewed
181
+ this.validUntil = started + this.ttlMs
182
+ return
183
+ }
184
+ let taken: Lease | undefined
185
+ try {
186
+ taken = await locks.acquire(key, owner, this.ttlMs)
187
+ } catch (error) {
188
+ this.onError(error)
189
+ return
190
+ }
191
+ if (!taken) return
192
+ this.lease = taken
193
+ this.validUntil = started + this.ttlMs
194
+ await this.elect(taken)
195
+ }
196
+
197
+ private async elect(lease: Lease): Promise<void> {
198
+ const { bus, name, owner } = this.options
199
+ try {
200
+ this.serving = await bus.serve(requestTopic(name, owner), async (message) => {
201
+ if (!this.lease || !this.options.onRequest) throw new NoLeaderError(name)
202
+ return this.options.onRequest(message)
203
+ })
204
+ } catch (error) {
205
+ this.onError(error)
206
+ }
207
+ try {
208
+ await this.options.onElected?.(lease)
209
+ } catch (error) {
210
+ this.onError(error)
211
+ }
212
+ }
213
+
214
+ private async depose(): Promise<void> {
215
+ if (!this.lease) return
216
+ this.lease = undefined
217
+ const serving = this.serving
218
+ this.serving = undefined
219
+ await serving?.().catch((error) => this.onError(error))
220
+ try {
221
+ await this.options.onDeposed?.()
222
+ } catch (error) {
223
+ this.onError(error)
224
+ }
225
+ }
226
+
227
+ private enqueue(task: () => Promise<void>): Promise<void> {
228
+ const run = this.queue.then(task, task)
229
+ this.queue = run.catch((error) => this.onError(error))
230
+ return this.queue
231
+ }
232
+ }
@@ -0,0 +1,151 @@
1
+ // The console live stream across pods (design §9, decision vkxkqiuf §6). A user's events go to every pod over the
2
+ // Bus topic user.<id>.live, so a console connected to any pod sees what happens on the pod holding the spoke (status,
3
+ // activity, streamed text, messages, decisions, launches, agent audit). Every event except streamed text (delta,
4
+ // reasoning) is appended to the shared EventLog first and carries its id, so a console that reconnects to another
5
+ // pod resumes by Last-Event-ID; streamed text is broadcast only. Events of one user are sent in order from a pod.
6
+ // Catalog changes are not user events here: each pod rebuilds its consoles' catalog from its own (cluster-wide)
7
+ // registry; publishCatalog() only tells every pod that the stored machines changed.
8
+ import type { Bus, EventLog, EventMessage, Unsubscribe } from "../backend/types"
9
+
10
+ export const liveTopic = (userID: string): string => `user.${userID}.live`
11
+ /** Stored machines changed (approved, revoked, moved): every pod sends its consoles a fresh catalog. */
12
+ export const CATALOG_TOPIC = "live.catalog"
13
+ /** Broadcast only, never logged (high volume; a resumed console refetches the transcript). */
14
+ export const BROADCAST_ONLY: ReadonlySet<string> = new Set(["delta", "reasoning"])
15
+
16
+ /** One user event as it travels the Bus. */
17
+ export interface LiveEvent {
18
+ /** The pod that sent it. */
19
+ readonly pod: string
20
+ readonly message: EventMessage
21
+ /** Its EventLog id (absent for streamed text). */
22
+ readonly id?: string
23
+ /** Not for this device (the one that made the change). */
24
+ readonly exceptDevice?: string
25
+ }
26
+
27
+ export interface LiveSubscription {
28
+ /** Resolves once the pod listens on the user's topic. */
29
+ readonly ready: Promise<void>
30
+ close(): void
31
+ }
32
+
33
+ export interface LiveFanoutOptions {
34
+ readonly bus: Bus
35
+ readonly events: EventLog
36
+ /** This pod (HUB_POD_ID). */
37
+ readonly podID: string
38
+ readonly onError?: (error: unknown) => void
39
+ }
40
+
41
+ interface UserTopic {
42
+ readonly handlers: Set<(event: LiveEvent) => void>
43
+ readonly subscribed: Promise<Unsubscribe | undefined>
44
+ }
45
+
46
+ const isLiveEvent = (value: unknown): value is LiveEvent => {
47
+ const event = value as LiveEvent | null
48
+ return typeof event === "object" && event !== null && typeof event.message === "object" && event.message !== null && typeof event.message.type === "string"
49
+ }
50
+
51
+ export class LiveFanout {
52
+ readonly podID: string
53
+ private readonly options: LiveFanoutOptions
54
+ private readonly onError: (error: unknown) => void
55
+ /** Per user: the last send in flight (sends of one user go out in order). */
56
+ private readonly sending = new Map<string, Promise<void>>()
57
+ /** Users this pod listens to, with their local handlers. */
58
+ private readonly topics = new Map<string, UserTopic>()
59
+ private readonly catalogHandlers = new Set<() => void>()
60
+ private catalogSubscription?: Promise<Unsubscribe>
61
+
62
+ constructor(options: LiveFanoutOptions) {
63
+ this.options = options
64
+ this.podID = options.podID
65
+ this.onError = options.onError ?? ((error) => console.error(`[hub] live fan-out failed: ${error instanceof Error ? error.message : String(error)}`))
66
+ }
67
+
68
+ /** Listens for catalog announcements. */
69
+ async start(): Promise<void> {
70
+ this.catalogSubscription ??= this.options.bus.subscribe(CATALOG_TOPIC, () => {
71
+ for (const handler of [...this.catalogHandlers]) this.guard(handler)
72
+ })
73
+ await this.catalogSubscription
74
+ }
75
+
76
+ /** Drops this pod's subscriptions (once they are made: a Bus that never connected must not hold up a stop). */
77
+ async stop(): Promise<void> {
78
+ const catalog = this.catalogSubscription
79
+ this.catalogSubscription = undefined
80
+ const topics = [...this.topics.values()]
81
+ this.topics.clear()
82
+ for (const pending of [catalog, ...topics.map((topic) => topic.subscribed)]) void pending?.then((off) => off?.()).catch(this.onError)
83
+ }
84
+
85
+ /** Logs (unless streamed text) and publishes a user's event; resolves once it is on the Bus. */
86
+ send(userID: string, message: EventMessage, exceptDevice?: string): Promise<void> {
87
+ const previous = this.sending.get(userID) ?? Promise.resolve()
88
+ const next = previous.then(async () => {
89
+ const id = BROADCAST_ONLY.has(message.type) ? undefined : await this.options.events.append(userID, message, exceptDevice ? { exceptDevice } : {})
90
+ const event: LiveEvent = { pod: this.podID, message, ...(id ? { id } : {}), ...(exceptDevice ? { exceptDevice } : {}) }
91
+ await this.options.bus.publish(liveTopic(userID), event)
92
+ })
93
+ const tail = next.catch(this.onError)
94
+ this.sending.set(userID, tail)
95
+ void tail.then(() => {
96
+ if (this.sending.get(userID) === tail) this.sending.delete(userID)
97
+ })
98
+ return next
99
+ }
100
+
101
+ /** The user's events from every pod (this one included). */
102
+ subscribe(userID: string, handler: (event: LiveEvent) => void): LiveSubscription {
103
+ let topic = this.topics.get(userID)
104
+ if (!topic) {
105
+ const handlers = new Set<(event: LiveEvent) => void>()
106
+ const subscribed = this.options.bus
107
+ .subscribe(liveTopic(userID), (event) => {
108
+ if (!isLiveEvent(event)) return
109
+ for (const h of [...handlers]) this.guard(() => h(event))
110
+ })
111
+ .catch((error: unknown) => {
112
+ this.onError(error)
113
+ return undefined
114
+ })
115
+ topic = { handlers, subscribed }
116
+ this.topics.set(userID, topic)
117
+ }
118
+ const current = topic
119
+ current.handlers.add(handler)
120
+ let closed = false
121
+ return {
122
+ ready: current.subscribed.then(() => {}),
123
+ close: () => {
124
+ if (closed) return
125
+ closed = true
126
+ current.handlers.delete(handler)
127
+ if (current.handlers.size || this.topics.get(userID) !== current) return
128
+ this.topics.delete(userID)
129
+ void current.subscribed.then((off) => off?.()).catch(this.onError)
130
+ },
131
+ }
132
+ }
133
+
134
+ /** Tells every pod (this one included) that the stored machines changed. */
135
+ async publishCatalog(): Promise<void> {
136
+ await this.options.bus.publish(CATALOG_TOPIC, { pod: this.podID })
137
+ }
138
+
139
+ onCatalog(handler: () => void): () => void {
140
+ this.catalogHandlers.add(handler)
141
+ return () => void this.catalogHandlers.delete(handler)
142
+ }
143
+
144
+ private guard(fn: () => void): void {
145
+ try {
146
+ fn()
147
+ } catch (error) {
148
+ this.onError(error)
149
+ }
150
+ }
151
+ }
@@ -0,0 +1,59 @@
1
+ // Recommendations on permissions held by another pod (design §5, §9; decision vkxkqiuf §5). An escalation shows the
2
+ // agent's recommendation on the request's blocker (Registry.recommend), and only the pod holding the request (its
3
+ // spoke) can change that blocker: the other pods see it through the cluster view. recommend() applies it here when
4
+ // this pod holds the request, else publishes it on RECOMMEND_TOPIC, where every pod tries it on its own registry (only
5
+ // the holder has it). Closing escalations needs no routing: the holder's permission-closed listener closes their
6
+ // Decisions in the shared state.
7
+ import type { Bus, Unsubscribe } from "../backend/types"
8
+ import type { BlockerRecommendation } from "../../status"
9
+
10
+ export const RECOMMEND_TOPIC = "cluster.recommend"
11
+
12
+ interface RecommendMessage {
13
+ readonly from: string
14
+ readonly requestID: string
15
+ readonly recommendation: BlockerRecommendation
16
+ }
17
+
18
+ export interface ClusterRecommendationsOptions {
19
+ readonly bus: Bus
20
+ /** This pod (HUB_POD_ID). */
21
+ readonly podID: string
22
+ readonly registry: { recommend(requestID: string, recommendation: BlockerRecommendation): boolean }
23
+ readonly onError?: (error: unknown) => void
24
+ }
25
+
26
+ export class ClusterRecommendations {
27
+ private readonly options: ClusterRecommendationsOptions
28
+ private readonly onError: (error: unknown) => void
29
+ private unsubscribe: Unsubscribe | undefined
30
+
31
+ constructor(options: ClusterRecommendationsOptions) {
32
+ this.options = options
33
+ this.onError = options.onError ?? ((error) => console.error(`[hub] cluster recommendation: ${error instanceof Error ? error.message : String(error)}`))
34
+ }
35
+
36
+ /** Applies other pods' recommendations for requests this pod holds. */
37
+ async start(): Promise<void> {
38
+ if (this.unsubscribe) return
39
+ this.unsubscribe = await this.options.bus.subscribe(RECOMMEND_TOPIC, (message) => {
40
+ const m = message as Partial<RecommendMessage> | undefined
41
+ if (m?.from === this.options.podID || typeof m?.requestID !== "string" || typeof m.recommendation !== "object" || m.recommendation === null) return
42
+ this.options.registry.recommend(m.requestID, m.recommendation)
43
+ })
44
+ }
45
+
46
+ async stop(): Promise<void> {
47
+ const unsubscribe = this.unsubscribe
48
+ this.unsubscribe = undefined
49
+ await unsubscribe?.()
50
+ }
51
+
52
+ /** Shows the recommendation on the request's blocker: here, or (true: sent) on the pod holding it. */
53
+ recommend(requestID: string, recommendation: BlockerRecommendation): boolean {
54
+ if (this.options.registry.recommend(requestID, recommendation)) return true
55
+ const message: RecommendMessage = { from: this.options.podID, requestID, recommendation }
56
+ void this.options.bus.publish(RECOMMEND_TOPIC, message).catch((error) => this.onError(error))
57
+ return true
58
+ }
59
+ }
@@ -0,0 +1,150 @@
1
+ // Pod RPC (design §9, decision vkxkqiuf §5): ops for a spoke or node connection held by another pod travel as a
2
+ // Bus request on pod.<podID>.rpc. The serving pod runs the op on its own socket (with the same per-op timeout as a
3
+ // local request and a tenant re-check, see SpokeHub) and replies {ok, result} or {ok:false, error:{name, message}};
4
+ // the calling pod turns that back into SpokeError / NodeError / RequestTimeoutError / RequestTooLargeError, so
5
+ // callers cannot tell a remote connection from a local one. The Bus waits RPC_SLACK_MS longer than the op so the
6
+ // serving pod's own timeout answers first; a pod with no responder is gone (the connection is offline).
7
+ // Session-token and act-token pushes (LOCAL_ONLY_OPS) never cross the Bus: refused on both ends.
8
+ // Close notices (machine revoked, launch ended) are published to every pod, the sender included.
9
+ import type { NodeRequest, ServerRequest } from "../../protocol"
10
+ import { BusNoResponderError, BusTimeoutError, type Bus, type Unsubscribe } from "../backend/types"
11
+ import { NodeError, RequestTimeoutError, RequestTooLargeError, SpokeError } from "../spoke-ws"
12
+
13
+ /** How much longer the calling pod waits than the op's own timeout. */
14
+ export const RPC_SLACK_MS = 2_000
15
+ /** Ops that carry credentials for the spoke's shells and agents: sent only by the pod holding the connection. */
16
+ export const LOCAL_ONLY_OPS: ReadonlySet<string> = new Set(["sessionTokens", "actTokens"])
17
+ /** The topic a pod answers ops for its connections on. */
18
+ export const rpcTopic = (podID: string) => `pod.${podID}.rpc`
19
+ /** Close notices for every pod. */
20
+ export const CLOSE_TOPIC = "cluster.close"
21
+
22
+ /** An op for a connection on another pod; `tenant` is the user the calling pod acts for (checked there). */
23
+ export type RpcCall =
24
+ | { readonly kind: "spoke"; readonly connID: string; readonly tenant: string; readonly req: ServerRequest }
25
+ | { readonly kind: "node"; readonly connID: string; readonly tenant: string; readonly req: NodeRequest }
26
+
27
+ /** Disconnect a machine's sockets (revoked) or a launch's (ended) on every pod. */
28
+ export interface CloseNotice {
29
+ readonly kind: "machine" | "launch"
30
+ readonly id: string
31
+ readonly reason: string
32
+ /** The pod that published it. */
33
+ readonly from: string
34
+ }
35
+
36
+ interface WireError {
37
+ readonly name: string
38
+ readonly message: string
39
+ }
40
+
41
+ type RpcReply = { readonly ok: true; readonly result?: unknown } | { readonly ok: false; readonly error: WireError }
42
+
43
+ export interface PodRpcOptions {
44
+ readonly bus: Bus
45
+ /** This pod (HUB_POD_ID). */
46
+ readonly podID: string
47
+ }
48
+
49
+ export class PodRpc {
50
+ readonly podID: string
51
+ private readonly bus: Bus
52
+ private readonly unsubscribes: Unsubscribe[] = []
53
+
54
+ constructor(options: PodRpcOptions) {
55
+ this.bus = options.bus
56
+ this.podID = options.podID
57
+ }
58
+
59
+ /** Answers ops for this pod's connections with `handler` (its errors go back to the caller). */
60
+ async serve(handler: (call: RpcCall) => Promise<unknown>): Promise<void> {
61
+ const unsubscribe = await this.bus.serve(rpcTopic(this.podID), async (message): Promise<RpcReply> => {
62
+ const call = message as RpcCall
63
+ try {
64
+ if (!isCall(call)) throw new SpokeError("Invalid pod RPC call")
65
+ if (LOCAL_ONLY_OPS.has(call.req.op)) throw new SpokeError(`The ${call.req.op} op is not sent across pods`)
66
+ return { ok: true, result: await handler(call) }
67
+ } catch (cause) {
68
+ const error = cause instanceof Error ? cause : new Error(String(cause))
69
+ return { ok: false, error: { name: error.name, message: error.message } }
70
+ }
71
+ })
72
+ this.unsubscribes.push(unsubscribe)
73
+ }
74
+
75
+ /**
76
+ * Runs an op on `pod`'s connection and resolves with its result. `timeoutMs` is the op's own timeout; `onGone`
77
+ * is told when the pod has no responder (it is gone: forget its directory entry).
78
+ */
79
+ async call(pod: string, call: RpcCall, timeoutMs: number, options: { readonly onGone?: () => void } = {}): Promise<unknown> {
80
+ if (LOCAL_ONLY_OPS.has(call.req.op)) throw new SpokeError(`The ${call.req.op} op is not sent across pods`)
81
+ let reply: RpcReply
82
+ try {
83
+ reply = await this.bus.request<RpcReply>(rpcTopic(pod), call, { timeoutMs: timeoutMs + RPC_SLACK_MS })
84
+ } catch (cause) {
85
+ if (cause instanceof BusNoResponderError) {
86
+ options.onGone?.()
87
+ throw offline(call)
88
+ }
89
+ if (cause instanceof BusTimeoutError) throw timeout(call)
90
+ throw failed(call, cause instanceof Error ? cause.message : String(cause))
91
+ }
92
+ if (reply?.ok === true) return reply.result
93
+ if (reply?.ok === false && typeof reply.error?.message === "string") throw revive(call, reply.error)
94
+ throw failed(call, "Invalid pod RPC reply")
95
+ }
96
+
97
+ /** Tells every pod (this one included) to close a machine's or a launch's sockets. */
98
+ async publishClose(notice: Omit<CloseNotice, "from">): Promise<void> {
99
+ await this.bus.publish(CLOSE_TOPIC, { ...notice, from: this.podID } satisfies CloseNotice)
100
+ }
101
+
102
+ async onClose(handler: (notice: CloseNotice) => void): Promise<void> {
103
+ this.unsubscribes.push(
104
+ await this.bus.subscribe(CLOSE_TOPIC, (message) => {
105
+ const notice = message as CloseNotice
106
+ if ((notice?.kind === "machine" || notice?.kind === "launch") && typeof notice.id === "string") handler(notice)
107
+ }),
108
+ )
109
+ }
110
+
111
+ async stop(): Promise<void> {
112
+ await Promise.allSettled(this.unsubscribes.splice(0).map((unsubscribe) => unsubscribe()))
113
+ }
114
+ }
115
+
116
+ function isCall(call: RpcCall | undefined): call is RpcCall {
117
+ return (
118
+ (call?.kind === "spoke" || call?.kind === "node") &&
119
+ typeof call.connID === "string" &&
120
+ typeof call.tenant === "string" &&
121
+ typeof (call.req as { op?: unknown } | undefined)?.op === "string"
122
+ )
123
+ }
124
+
125
+ const offline = (call: RpcCall): Error => (call.kind === "node" ? new NodeError("The node disconnected") : new SpokeError("OpenCode process disconnected"))
126
+ const timeout = (call: RpcCall): Error => (call.kind === "node" ? new RequestTimeoutError(call.req.op, "The node") : new RequestTimeoutError(call.req.op))
127
+ const failed = (call: RpcCall, message: string): Error => (call.kind === "node" ? new NodeError(message) : new SpokeError(message))
128
+
129
+ /** The serving pod's error as the class the caller would have got locally. */
130
+ function revive(call: RpcCall, wire: WireError): Error {
131
+ let error: Error
132
+ switch (wire.name) {
133
+ case "RequestTimeoutError":
134
+ error = new RequestTimeoutError(call.req.op)
135
+ break
136
+ case "RequestTooLargeError":
137
+ error = new RequestTooLargeError(call.req.op)
138
+ break
139
+ case "SpokeError":
140
+ error = new SpokeError(wire.message)
141
+ break
142
+ case "NodeError":
143
+ error = new NodeError(wire.message)
144
+ break
145
+ default:
146
+ error = failed(call, wire.message)
147
+ }
148
+ error.message = wire.message
149
+ return error
150
+ }