@huynle/hub 0.6.1 → 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
+ // The cluster view (design §9, decision vkxkqiuf §5): how every pod sees the connections other pods hold.
2
+ // Each pod's Registry and NodeRegistry are the single writers of its own connections and export a slice per tenant
3
+ // (registry: connections and what they report, status boards, pending permissions and forms, presence, running
4
+ // turns; nodes: the tenant's nodes). This pod publishes its slices
5
+ // - to the Catalog: hash of the user, session VIEW_SESSION, one report per pod (connID = podID) with a TTL;
6
+ // - as deltas on the Bus (VIEW_TOPIC: {pod, user, slice | null}), shortly after a change and again every
7
+ // heartbeat (which also refreshes the Catalog TTL);
8
+ // and mirrors the other pods' slices into its Registry and NodeRegistry (setRemote), so their synchronous reads
9
+ // cover the whole cluster. The mirror is rebuilt from the Catalog at start and on resync() (e.g. after a Bus
10
+ // reconnect lost deltas). A pod that stops removes its slices; a pod that dies stops refreshing them: the other pods
11
+ // drop a slice not refreshed within the TTL, and the Catalog filters it out on read.
12
+ import { realTimers } from "../backend/memory-bus"
13
+ import type { Bus, Catalog, Clock, Timers, Unsubscribe } from "../backend/types"
14
+ import type { NodeRegistry, NodeSlice } from "../nodes"
15
+ import type { Registry, RegistrySlice } from "../registry"
16
+
17
+ export const VIEW_TOPIC = "cluster.view"
18
+ /** The Catalog session id the slices are reported under (never a real session id: those start with ses). */
19
+ export const VIEW_SESSION = "_view"
20
+ export const VIEW_HEARTBEAT_MS = 15_000
21
+ export const VIEW_TTL_MS = 45_000
22
+ /** Changes within this window go out together. */
23
+ export const VIEW_PUBLISH_DELAY_MS = 50
24
+
25
+ /** One pod's state of one tenant. */
26
+ export interface ViewSlice {
27
+ readonly registry?: RegistrySlice
28
+ readonly nodes?: NodeSlice
29
+ }
30
+
31
+ interface ViewMessage {
32
+ readonly pod: string
33
+ readonly user: string
34
+ readonly slice: ViewSlice | null
35
+ }
36
+
37
+ type Mirrored<T extends Registry | NodeRegistry> = Pick<T, "onLocalChange" | "clusterTenants" | "clusterSlice" | "setRemote">
38
+
39
+ export interface ClusterViewOptions {
40
+ readonly catalog: Catalog
41
+ readonly bus: Bus
42
+ /** This pod (HUB_POD_ID). */
43
+ readonly podID: string
44
+ readonly registry: Mirrored<Registry>
45
+ readonly nodes: Mirrored<NodeRegistry>
46
+ /** The users whose Catalog entries a rebuild reads. */
47
+ readonly users: () => readonly string[] | Promise<readonly string[]>
48
+ readonly now?: Clock
49
+ readonly timers?: Timers
50
+ readonly heartbeatMs?: number
51
+ readonly ttlMs?: number
52
+ readonly publishDelayMs?: number
53
+ readonly onError?: (error: unknown) => void
54
+ }
55
+
56
+ /** Tenants that are not users (a machine without an owner) are never published. */
57
+ const publishable = (tenant: string | undefined): tenant is string => tenant !== undefined && tenant !== "" && !tenant.startsWith("?")
58
+
59
+ export class ClusterView {
60
+ readonly podID: string
61
+ private readonly options: ClusterViewOptions
62
+ private readonly now: Clock
63
+ private readonly timers: Timers
64
+ private readonly heartbeatMs: number
65
+ private readonly ttlMs: number
66
+ private readonly publishDelayMs: number
67
+ private readonly onError: (error: unknown) => void
68
+ /** Tenants whose slice changed since the last flush; `all`: every tenant. */
69
+ private dirty = new Set<string>()
70
+ private dirtyAll = false
71
+ private flushTimer: unknown
72
+ private heartbeatTimer: unknown
73
+ /** What this pod published last per user (JSON), to skip unchanged slices between heartbeats. */
74
+ private readonly sent = new Map<string, string>()
75
+ /** Other pods' slices applied here: pod → user → when last received (this pod's clock). */
76
+ private readonly received = new Map<string, Map<string, number>>()
77
+ /** Serialises flushes, rebuilds and stop. */
78
+ private queue: Promise<void> = Promise.resolve()
79
+ private readonly offs: Array<() => void> = []
80
+ private unsubscribe?: Unsubscribe
81
+ private running = false
82
+
83
+ constructor(options: ClusterViewOptions) {
84
+ this.options = options
85
+ this.podID = options.podID
86
+ this.now = options.now ?? Date.now
87
+ this.timers = options.timers ?? realTimers
88
+ this.heartbeatMs = options.heartbeatMs ?? VIEW_HEARTBEAT_MS
89
+ this.ttlMs = options.ttlMs ?? VIEW_TTL_MS
90
+ this.publishDelayMs = options.publishDelayMs ?? VIEW_PUBLISH_DELAY_MS
91
+ this.onError = options.onError ?? ((error) => console.error(`[hub] cluster view: ${error instanceof Error ? error.message : String(error)}`))
92
+ }
93
+
94
+ /** Listens for deltas, rebuilds the mirror from the Catalog, publishes this pod's slices and starts the heartbeat. */
95
+ async start(): Promise<void> {
96
+ if (this.running) return
97
+ this.running = true
98
+ const mark = (tenant: string | undefined) => this.mark(tenant)
99
+ this.offs.push(this.options.registry.onLocalChange(mark), this.options.nodes.onLocalChange(mark))
100
+ this.unsubscribe = await this.options.bus.subscribe(VIEW_TOPIC, (message) => this.receive(message as ViewMessage))
101
+ await this.resync()
102
+ this.dirtyAll = true
103
+ await this.flush()
104
+ this.arm()
105
+ }
106
+
107
+ /** Stops publishing and listening, and removes this pod's slices from the Catalog and the other pods. */
108
+ async stop(): Promise<void> {
109
+ if (!this.running) return
110
+ this.running = false
111
+ for (const off of this.offs.splice(0)) off()
112
+ for (const timer of [this.flushTimer, this.heartbeatTimer]) if (timer !== undefined) this.timers.clear(timer)
113
+ this.flushTimer = this.heartbeatTimer = undefined
114
+ const unsubscribe = this.unsubscribe
115
+ this.unsubscribe = undefined
116
+ await this.enqueue(async () => {
117
+ await unsubscribe?.()
118
+ const users = [...this.sent.keys()]
119
+ this.sent.clear()
120
+ await Promise.allSettled(users.map((user) => this.withdraw(user)))
121
+ })
122
+ }
123
+
124
+ /** How many users this pod currently publishes slices for. */
125
+ published(): number {
126
+ return this.sent.size
127
+ }
128
+
129
+ /** Reads every other pod's slices from the Catalog and replaces the mirror with them. */
130
+ resync(): Promise<void> {
131
+ return this.enqueue(async () => {
132
+ const users = await this.options.users()
133
+ const seen = new Set<string>()
134
+ for (const user of users) {
135
+ for (const entry of await this.options.catalog.entries(user)) {
136
+ if (entry.sessionID !== VIEW_SESSION || entry.connID === this.podID) continue
137
+ seen.add(`${entry.connID}\u0000${user}`)
138
+ this.apply(entry.connID, user, entry.value as ViewSlice)
139
+ }
140
+ }
141
+ for (const [pod, slices] of this.received) for (const user of slices.keys()) if (!seen.has(`${pod}\u0000${user}`)) this.apply(pod, user, null)
142
+ })
143
+ }
144
+
145
+ /** A local change of `tenant` (undefined: any): published after the publish delay. */
146
+ private mark(tenant: string | undefined): void {
147
+ if (!this.running) return
148
+ if (tenant === undefined) this.dirtyAll = true
149
+ else if (publishable(tenant)) this.dirty.add(tenant)
150
+ else return
151
+ if (this.flushTimer !== undefined) return
152
+ this.flushTimer = this.timers.set(() => {
153
+ this.flushTimer = undefined
154
+ void this.flush()
155
+ }, this.publishDelayMs)
156
+ }
157
+
158
+ /** Publishes the slices of the dirty tenants (all, after a heartbeat); `force` republishes unchanged ones too. */
159
+ private flush(force = false): Promise<void> {
160
+ return this.enqueue(async () => {
161
+ if (!this.running) return
162
+ const { registry, nodes } = this.options
163
+ const tenants = this.dirtyAll
164
+ ? new Set([...registry.clusterTenants(), ...nodes.clusterTenants(), ...this.sent.keys()])
165
+ : new Set(this.dirty)
166
+ this.dirty = new Set()
167
+ this.dirtyAll = false
168
+ for (const user of tenants) {
169
+ if (!publishable(user)) continue
170
+ const r = registry.clusterSlice(user)
171
+ const n = nodes.clusterSlice(user)
172
+ try {
173
+ if (!r && !n) {
174
+ if (this.sent.delete(user)) await this.withdraw(user)
175
+ continue
176
+ }
177
+ const slice: ViewSlice = { ...(r ? { registry: r } : {}), ...(n ? { nodes: n } : {}) }
178
+ const json = JSON.stringify(slice)
179
+ if (!force && this.sent.get(user) === json) continue
180
+ this.sent.set(user, json)
181
+ await this.options.catalog.set(user, VIEW_SESSION, this.podID, slice as Record<string, unknown>, { ttlMs: this.ttlMs })
182
+ await this.options.bus.publish(VIEW_TOPIC, { pod: this.podID, user, slice } satisfies ViewMessage)
183
+ } catch (error) {
184
+ // The next heartbeat publishes it again.
185
+ this.sent.delete(user)
186
+ this.onError(error)
187
+ }
188
+ }
189
+ })
190
+ }
191
+
192
+ private async withdraw(user: string): Promise<void> {
193
+ await this.options.catalog.delete(user, VIEW_SESSION, this.podID)
194
+ await this.options.bus.publish(VIEW_TOPIC, { pod: this.podID, user, slice: null } satisfies ViewMessage)
195
+ }
196
+
197
+ private receive(message: ViewMessage): void {
198
+ if (!this.running || typeof message?.pod !== "string" || typeof message.user !== "string" || message.pod === this.podID) return
199
+ this.apply(message.pod, message.user, message.slice ?? null)
200
+ }
201
+
202
+ /** Applies (or, null, removes) another pod's slice of a user. */
203
+ private apply(pod: string, user: string, slice: ViewSlice | null): void {
204
+ let users = this.received.get(pod)
205
+ if (slice) {
206
+ if (!users) this.received.set(pod, (users = new Map()))
207
+ users.set(user, this.now())
208
+ } else if (users?.delete(user) && !users.size) this.received.delete(pod)
209
+ this.options.nodes.setRemote(pod, user, slice?.nodes)
210
+ this.options.registry.setRemote(pod, user, slice?.registry)
211
+ }
212
+
213
+ /** Every heartbeat: republish this pod's slices (refreshing their TTL) and drop the ones other pods stopped refreshing. */
214
+ private arm(): void {
215
+ this.heartbeatTimer = this.timers.set(() => {
216
+ this.heartbeatTimer = undefined
217
+ if (!this.running) return
218
+ const t = this.now()
219
+ for (const [pod, users] of [...this.received]) for (const [user, at] of [...users]) if (t - at > this.ttlMs) this.apply(pod, user, null)
220
+ this.dirtyAll = true
221
+ void this.flush(true).finally(() => {
222
+ if (this.running) this.arm()
223
+ })
224
+ }, this.heartbeatMs)
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 run
231
+ }
232
+ }
@@ -2,10 +2,11 @@
2
2
  // optional hub config file (~/.config/hub/hub.json or $HUB_CONFIG) over built-in defaults. The file's speech
3
3
  // settings sit between the phone's saved settings and the env (see speech/settings.ts).
4
4
  import { existsSync, readFileSync, statSync } from "node:fs"
5
- import { homedir } from "node:os"
5
+ import { homedir, hostname } from "node:os"
6
6
  import path from "node:path"
7
7
  import { hubEnv } from "../env"
8
8
  import { isLoopbackHost } from "../loopback"
9
+ import { formatPattern, parsePattern } from "./agent/policy"
9
10
  import { APP_DIR, configBase, legacyPaths, migrateFirst, stateBase } from "../migrate"
10
11
  import { updateSpeech, type StoredSpeech } from "./speech/settings"
11
12
 
@@ -59,6 +60,57 @@ export interface CentralConfig {
59
60
  /** Speech settings from hub.json (below the phone's saved settings, above the env). */
60
61
  readonly speech?: StoredSpeech
61
62
  readonly auth: AuthConfig
63
+ /** hub.json `agents.never`: permission patterns no agent may ever approve, for every user (design §5, §7). */
64
+ readonly agentsNever: readonly string[]
65
+ /** hub.json `agents.launches`: per-user launch caps, only those set (placement fills in its defaults). */
66
+ readonly launchCaps?: HubLaunches
67
+ /** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
68
+ readonly webhooksAllowPrivate?: true
69
+ /** Cluster mode (design §9): set only when HUB_DATABASE_URL and HUB_VALKEY_URL (or hub.json cluster) are. */
70
+ readonly cluster?: ClusterConfig
71
+ }
72
+
73
+ /**
74
+ * Cluster mode: shared state in Postgres, cross-pod messages and short-lived data in Valkey. The URLs hold
75
+ * credentials: they are non-enumerable (never serialized or printed with the config) and never put in messages.
76
+ */
77
+ export interface ClusterConfig {
78
+ /** This pod's id (HUB_POD_ID, default the hostname): RPC topics and leases name it. */
79
+ readonly podID: string
80
+ /** postgres:// or postgresql:// (non-enumerable). */
81
+ readonly databaseURL: string
82
+ /** redis://, rediss://, valkey:// or valkeys:// (non-enumerable). */
83
+ readonly valkeyURL: string
84
+ }
85
+
86
+ /** hub.json `cluster` (env HUB_DATABASE_URL / HUB_VALKEY_URL win). */
87
+ export interface HubCluster {
88
+ readonly database?: string
89
+ readonly valkey?: string
90
+ }
91
+
92
+ /** hub.json `agents.launches`: per-user launch caps (design §6.2, §7). */
93
+ export interface HubLaunches {
94
+ /** Launches waiting to be placed. */
95
+ readonly maxQueued?: number
96
+ /** Launches placed and not ended (placing, provisioning, running). */
97
+ readonly maxActive?: number
98
+ /** New launches per clock hour. */
99
+ readonly perHour?: number
100
+ }
101
+
102
+ /** hub.json `agents`, validated. */
103
+ export interface HubAgents {
104
+ /** Normalised `action[:glob]` patterns (see agent/policy.ts), duplicates dropped. */
105
+ readonly never?: readonly string[]
106
+ readonly launches?: HubLaunches
107
+ readonly webhooks?: HubWebhooks
108
+ }
109
+
110
+ /** hub.json `agents.webhooks` (decision znbg8xa5 #8). */
111
+ export interface HubWebhooks {
112
+ /** Lets webhook URLs use http and private or local targets (self-hosted receivers); off by default (SSRF). */
113
+ readonly allowPrivate?: boolean
62
114
  }
63
115
 
64
116
  /** hub.json, validated. */
@@ -69,11 +121,18 @@ export interface HubFile {
69
121
  readonly stateDir?: string
70
122
  readonly speech?: StoredSpeech
71
123
  readonly auth?: HubAuth
124
+ readonly agents?: HubAgents
125
+ readonly cluster?: HubCluster
72
126
  }
73
127
 
74
128
  type Env = Readonly<Record<string, string | undefined>>
75
129
 
76
- const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth"])
130
+ const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster"])
131
+ const CLUSTER_KEYS = ["database", "valkey"] as const
132
+ const AGENTS_KEYS = new Set(["never", "launches", "webhooks"])
133
+ const WEBHOOK_KEYS = new Set(["allowPrivate"])
134
+ const LAUNCH_CAP_KEYS = ["maxQueued", "maxActive", "perHour"] as const
135
+ const LAUNCH_CAP_MAX = 100_000
77
136
  const AUTH_KEYS = new Set(["mode", "issuer", "clientId", "clientSecret", "allowed", "admins"])
78
137
  const EMAIL = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/
79
138
  const DOMAIN = /^@[^\s@,]+\.[^\s@,]+$/
@@ -161,6 +220,8 @@ export function readHubFile(file: string, home: string = homedir()): { file?: Hu
161
220
  hub.stateDir = expandHome((raw.stateDir as string).trim(), home)
162
221
  }
163
222
  if (raw.auth !== undefined) hub.auth = readHubAuth(raw.auth, file, warnings, fail)
223
+ if (raw.agents !== undefined) hub.agents = readHubAgents(raw.agents, file, warnings, fail)
224
+ if (raw.cluster !== undefined) hub.cluster = readHubCluster(raw.cluster, file, warnings, fail)
164
225
  if (raw.speech !== undefined) {
165
226
  if (!isObj(raw.speech)) fail("speech must be an object with stt and/or tts")
166
227
  const speech = { ...(raw.speech as Record<string, unknown>) }
@@ -206,6 +267,86 @@ function readHubAuth(raw: unknown, file: string, warnings: string[], fail: (mess
206
267
  return auth
207
268
  }
208
269
 
270
+ /** hub.json `cluster`. Values are never echoed; URLs with a password are dropped from a file others can read. */
271
+ function readHubCluster(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubCluster {
272
+ if (!isObj(raw)) fail("cluster must be an object with database and valkey URLs")
273
+ const input = raw as Record<string, unknown>
274
+ for (const key of Object.keys(input)) {
275
+ if (!(CLUSTER_KEYS as readonly string[]).includes(key)) warnings.push(`${file}: unknown key "cluster.${key}" ignored`)
276
+ }
277
+ const shared = (statSync(file).mode & 0o044) !== 0
278
+ const cluster: { -readonly [K in keyof HubCluster]: HubCluster[K] } = {}
279
+ for (const key of CLUSTER_KEYS) {
280
+ const value = input[key]
281
+ if (value === undefined) continue
282
+ if (typeof value !== "string" || !value.trim()) fail(`cluster.${key} must be a non-empty URL string`)
283
+ const url = (value as string).trim()
284
+ if (shared && hasPassword(url)) {
285
+ warnings.push(`${file} is readable by other users, so its cluster.${key} is ignored (it holds a password). Fix it with: chmod 600 ${file}`)
286
+ continue
287
+ }
288
+ cluster[key] = url
289
+ }
290
+ return cluster
291
+ }
292
+
293
+ function hasPassword(url: string): boolean {
294
+ try {
295
+ return new URL(url).password !== ""
296
+ } catch {
297
+ // Unparseable: assume the worst; centralConfig rejects it anyway.
298
+ return true
299
+ }
300
+ }
301
+
302
+ function readHubAgents(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubAgents {
303
+ if (!isObj(raw)) fail("agents must be an object")
304
+ const input = raw as Record<string, unknown>
305
+ for (const key of Object.keys(input)) if (!AGENTS_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.${key}" ignored`)
306
+ const launches = input.launches === undefined ? undefined : readHubLaunches(input.launches, file, warnings, fail)
307
+ const never = input.never === undefined ? undefined : readNever(input.never, fail)
308
+ const webhooks = input.webhooks === undefined ? undefined : readHubWebhooks(input.webhooks, file, warnings, fail)
309
+ return { ...(never ? { never } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}) }
310
+ }
311
+
312
+ function readHubWebhooks(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubWebhooks {
313
+ if (!isObj(raw)) fail("agents.webhooks must be an object")
314
+ const input = raw as Record<string, unknown>
315
+ for (const key of Object.keys(input)) if (!WEBHOOK_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.webhooks.${key}" ignored`)
316
+ if (input.allowPrivate !== undefined && typeof input.allowPrivate !== "boolean") fail("agents.webhooks.allowPrivate must be true or false")
317
+ return input.allowPrivate === undefined ? {} : { allowPrivate: input.allowPrivate as boolean }
318
+ }
319
+
320
+ function readHubLaunches(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubLaunches {
321
+ if (!isObj(raw)) fail("agents.launches must be an object")
322
+ const input = raw as Record<string, unknown>
323
+ const caps: Record<string, number> = {}
324
+ for (const key of Object.keys(input)) {
325
+ if (!(LAUNCH_CAP_KEYS as readonly string[]).includes(key)) {
326
+ warnings.push(`${file}: unknown key "agents.launches.${key}" ignored`)
327
+ continue
328
+ }
329
+ const value = input[key]
330
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > LAUNCH_CAP_MAX) {
331
+ fail(`agents.launches.${key} must be a whole number from 1 to ${LAUNCH_CAP_MAX}`)
332
+ }
333
+ caps[key] = value as number
334
+ }
335
+ return caps
336
+ }
337
+
338
+ function readNever(raw: unknown, fail: (message: string) => never): string[] {
339
+ if (!Array.isArray(raw)) fail('agents.never must be a list of patterns ("action" or "action:glob")')
340
+ const never: string[] = []
341
+ for (const item of raw as unknown[]) {
342
+ const pattern = typeof item === "string" ? parsePattern(item) : undefined
343
+ if (!pattern) fail(`agents.never: ${JSON.stringify(item)} is not a pattern ("action" or "action:glob")`)
344
+ const text = formatPattern(pattern!)
345
+ if (!never.includes(text)) never.push(text)
346
+ }
347
+ return never
348
+ }
349
+
209
350
  /** Validates the OIDC issuer: https, or plain http on loopback when the dev flag is set. No trailing slash. */
210
351
  function issuerURL(raw: string, insecure: boolean): string {
211
352
  let url: URL
@@ -252,6 +393,53 @@ function authConfig(env: Env, file: HubAuth = {}): AuthConfig {
252
393
  return auth as unknown as OidcConfig
253
394
  }
254
395
 
396
+ const POD_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
397
+ const DATABASE_KEY = "HUB_DATABASE_URL (cluster.database)"
398
+ const VALKEY_KEY = "HUB_VALKEY_URL (cluster.valkey)"
399
+
400
+ /** The URL when its scheme is one of `protocols`; errors name the key, never the value (it holds credentials). */
401
+ function serviceURL(raw: string, name: string, protocols: readonly string[]): string {
402
+ let protocol: string
403
+ try {
404
+ protocol = new URL(raw).protocol
405
+ } catch {
406
+ throw new Error(`${name} is not a valid URL`)
407
+ }
408
+ if (!protocols.includes(protocol)) throw new Error(`${name} must be a ${protocols.map((p) => `${p}//`).join(" or ")} URL`)
409
+ return raw
410
+ }
411
+
412
+ /**
413
+ * Cluster settings from env over hub.json, or undefined (single process). Both URLs or neither; cluster mode
414
+ * requires HUB_TOKEN_KEYS (keys every pod shares) and OIDC sign-in (pairing codes and the local owner are
415
+ * single-process). Throws without echoing the URLs.
416
+ */
417
+ function clusterConfig(env: Env, auth: AuthConfig, file: HubCluster = {}): ClusterConfig | undefined {
418
+ const database = env.HUB_DATABASE_URL?.trim() || file.database
419
+ const valkey = env.HUB_VALKEY_URL?.trim() || file.valkey
420
+ if (!database && !valkey) return undefined
421
+ if (!database || !valkey) {
422
+ throw new Error(`${DATABASE_KEY} and ${VALKEY_KEY} must be set together for cluster mode; only ${database ? DATABASE_KEY : VALKEY_KEY} is set`)
423
+ }
424
+ const databaseURL = serviceURL(database, DATABASE_KEY, ["postgres:", "postgresql:"])
425
+ const valkeyURL = serviceURL(valkey, VALKEY_KEY, ["redis:", "rediss:", "valkey:", "valkeys:"])
426
+ if (!env.HUB_TOKEN_KEYS?.trim()) {
427
+ throw new Error("Cluster mode requires HUB_TOKEN_KEYS: token signing keys every pod shares (the token-keys.json shape)")
428
+ }
429
+ if (auth.mode !== "oidc") {
430
+ throw new Error("Cluster mode requires HUB_AUTH=oidc: pairing mode (pairing codes, the local owner) works in a single process only")
431
+ }
432
+ const podID = env.HUB_POD_ID?.trim() || hostname()
433
+ if (!POD_ID.test(podID)) {
434
+ throw new Error(`HUB_POD_ID must be 1-128 letters, digits, '.', '_' or '-' (starting with a letter or digit), got ${JSON.stringify(podID)}`)
435
+ }
436
+ const cluster = { podID }
437
+ // Getters: printed as nothing, skipped by JSON.stringify.
438
+ Object.defineProperty(cluster, "databaseURL", { get: () => databaseURL, enumerable: false })
439
+ Object.defineProperty(cluster, "valkeyURL", { get: () => valkeyURL, enumerable: false })
440
+ return cluster as ClusterConfig
441
+ }
442
+
255
443
  export function centralConfig(rawEnv: Env = process.env, home: string = homedir(), file: HubFile = {}): CentralConfig {
256
444
  const env = hubEnv(rawEnv)
257
445
  const host = env.HUB_HOST || file.host || "127.0.0.1"
@@ -262,6 +450,8 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
262
450
  if (!/^https?:\/\/[^\s/]+$/.test(publicURL)) {
263
451
  throw new Error(`HUB_PUBLIC_URL must be an http(s) origin without a path, got ${JSON.stringify(publicURL)}`)
264
452
  }
453
+ const auth = authConfig(env, file.auth)
454
+ const cluster = clusterConfig(env, auth, file.cluster)
265
455
  return {
266
456
  host,
267
457
  port,
@@ -277,7 +467,11 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
277
467
  // Push services want a contact: an https URL or mailto. The public URL works for hosted hubs.
278
468
  vapidSubject: env.HUB_VAPID_SUBJECT || (publicURL.startsWith("https://") ? publicURL : "mailto:hub@localhost"),
279
469
  ...(file.speech ? { speech: file.speech } : {}),
280
- auth: authConfig(env, file.auth),
470
+ auth,
471
+ agentsNever: [...(file.agents?.never ?? [])],
472
+ ...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
473
+ ...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
474
+ ...(cluster ? { cluster } : {}),
281
475
  }
282
476
  }
283
477
 
@@ -0,0 +1,96 @@
1
+ // "All done": one notification when a root session and its whole tree settle, instead of one per root turn.
2
+ //
3
+ // In OpenCode v2 a root session runs many short turns: each background subagent that completes injects a synthetic
4
+ // message into its parent, which starts a new turn, and Voice mode and steering do the same. A notification per
5
+ // `session.execution.succeeded` of the root therefore fired while other subagents and jobs were still running.
6
+ // A run is armed when anything in the tree starts; once the root's last turn succeeded and the tree is settled
7
+ // (StatusBoard.settled: nothing runs, waits for an answer or has a running shell; no hub turn) for DONE_SETTLE_MS
8
+ // without anything starting again, it fires once and disarms.
9
+ import type { Timers } from "./registry"
10
+
11
+ /** How long a tree must stay settled before "all done" (a continuation turn starts well within it). */
12
+ export const DONE_SETTLE_MS = 15_000
13
+
14
+ /** Root runs tracked at once (oldest idle ones are forgotten first). */
15
+ const RUNS_LIMIT = 1000
16
+
17
+ type Outcome = "succeeded" | "failed" | "interrupted"
18
+
19
+ interface Run {
20
+ /** Something ran since the last "all done". */
21
+ armed: boolean
22
+ /** How the root's last turn ended (undefined while it runs). */
23
+ outcome?: Outcome
24
+ timer?: unknown
25
+ }
26
+
27
+ export class DoneWatch {
28
+ private readonly runs = new Map<string, Run>()
29
+
30
+ constructor(
31
+ private readonly timers: Timers,
32
+ /** The tree of `rootID` is settled now. */
33
+ private readonly settled: (rootID: string) => boolean,
34
+ private readonly fire: (rootID: string) => void,
35
+ private readonly delay = DONE_SETTLE_MS,
36
+ ) {}
37
+
38
+ /** A session of the tree started a run (`root`: the root itself). */
39
+ started(rootID: string, root: boolean): void {
40
+ const run = this.run(rootID)
41
+ run.armed = true
42
+ if (root) run.outcome = undefined
43
+ this.cancel(run)
44
+ }
45
+
46
+ /** The root's turn ended; `wasRunning`: it was known to run (a duplicate or stray end does not arm). */
47
+ rootEnded(rootID: string, outcome: Outcome, wasRunning: boolean): void {
48
+ const run = this.runs.get(rootID) ?? (wasRunning ? this.run(rootID) : undefined)
49
+ if (!run) return
50
+ if (wasRunning) run.armed = true
51
+ run.outcome = outcome
52
+ this.schedule(rootID, run)
53
+ }
54
+
55
+ /** Something else changed in the tree (a subagent or job ended, an answer, a hub turn released). */
56
+ poke(rootID: string): void {
57
+ const run = this.runs.get(rootID)
58
+ if (run?.armed) this.schedule(rootID, run)
59
+ }
60
+
61
+ forget(rootID: string): void {
62
+ const run = this.runs.get(rootID)
63
+ if (run) this.cancel(run)
64
+ this.runs.delete(rootID)
65
+ }
66
+
67
+ private run(rootID: string): Run {
68
+ let run = this.runs.get(rootID)
69
+ if (run) return run
70
+ this.runs.set(rootID, (run = { armed: false }))
71
+ if (this.runs.size > RUNS_LIMIT) {
72
+ for (const [id, old] of this.runs) {
73
+ if (old.timer !== undefined) continue
74
+ this.runs.delete(id)
75
+ break
76
+ }
77
+ }
78
+ return run
79
+ }
80
+
81
+ private schedule(rootID: string, run: Run): void {
82
+ if (!run.armed) return
83
+ this.cancel(run)
84
+ run.timer = this.timers.set(() => {
85
+ run.timer = undefined
86
+ if (!run.armed || run.outcome !== "succeeded" || !this.settled(rootID)) return
87
+ run.armed = false
88
+ this.fire(rootID)
89
+ }, this.delay)
90
+ }
91
+
92
+ private cancel(run: Run): void {
93
+ if (run.timer !== undefined) this.timers.clear(run.timer)
94
+ run.timer = undefined
95
+ }
96
+ }