@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
package/src/rpc.ts CHANGED
@@ -15,11 +15,13 @@ const empty = obj({})
15
15
  const errorData = { type: "object" } as const
16
16
 
17
17
  /**
18
- * not_connected: no live connection · invalid: bad input · unreachable: server could not be reached ·
19
- * unavailable: spoke not running in this process · server_error: the hub refused the request.
18
+ * not_connected: no live connection (or the session has no hub token yet) · invalid: bad input ·
19
+ * unreachable: server could not be reached · unavailable: spoke not running in this process ·
20
+ * server_error: the hub refused the request · unsupported: the hub lacks the feature (messaging).
20
21
  */
21
22
  const errors = {
22
23
  not_connected: errorData,
24
+ unsupported: errorData,
23
25
  invalid: errorData,
24
26
  unreachable: errorData,
25
27
  unavailable: errorData,
@@ -90,6 +92,29 @@ const methods = {
90
92
  },
91
93
  /** Conversation mode for a session in this process (same path as the server's "conversation" op). */
92
94
  conversation: { input: obj({ sessionID: str, on: bool }, ["sessionID", "on"]), output: obj({ agent: str }, ["agent"]), errors },
95
+ /**
96
+ * /who: the hub's named and role-holding sessions (optionally one project or one role), asked as the TUI's
97
+ * current session (its own hub token). Labels are sanitised.
98
+ */
99
+ hubWho: {
100
+ input: obj({ sessionID: str, project: str, role: str }, ["sessionID"]),
101
+ output: obj(
102
+ {
103
+ sessions: {
104
+ type: "array",
105
+ items: obj({ sessionID: str, name: str, project: str, state: str, online: bool, roles: { type: "array", items: str } }, ["sessionID", "online", "roles"]),
106
+ },
107
+ },
108
+ ["sessions"],
109
+ ),
110
+ errors,
111
+ },
112
+ /** /msg: sends `text` word for word to `to` as the TUI's current session (its own hub token). */
113
+ hubMsg: {
114
+ input: obj({ sessionID: str, to: str, text: str }, ["sessionID", "to", "text"]),
115
+ output: obj({ id: str, to: str, delivery: str }, ["id", "to"]),
116
+ errors,
117
+ },
93
118
  } as const
94
119
 
95
120
  export const HubRpc = Rpc.define({ id: "hub", methods, events: {} })
@@ -1,272 +1,3 @@
1
- // The spoke's single outbound WebSocket to the central server: hello/welcome handshake, request/
2
- // response correlation in both directions, and reconnect with jittered exponential backoff.
3
- import {
4
- encode,
5
- MAX_FRAME,
6
- parseServerMessage,
7
- PROTOCOL_VERSION,
8
- type Hello,
9
- type ResultFor,
10
- type ServerRequest,
11
- type SpokeMessage,
12
- type SpokeRequest,
13
- type Welcome,
14
- } from "../protocol"
15
-
16
- export type ConnectionState = "off" | "connecting" | "connected" | "revoked" | "error"
17
-
18
- export interface Backoff {
19
- readonly minMs: number
20
- readonly maxMs: number
21
- readonly random?: () => number
22
- }
23
-
24
- /** Minimal WebSocket surface used here (Bun's WebSocket accepts `{ headers }` as a 2nd argument). */
25
- export interface SocketLike {
26
- readonly readyState: number
27
- onopen: ((ev: unknown) => void) | null
28
- onmessage: ((ev: { data: unknown }) => void) | null
29
- onclose: ((ev: { code?: number; reason?: string }) => void) | null
30
- onerror: ((ev: unknown) => void) | null
31
- send(data: string): void
32
- close(code?: number, reason?: string): void
33
- }
34
-
35
- export interface ConnectionOptions {
36
- readonly server: string
37
- readonly token: string
38
- /** Hello fields, computed per attempt so mode/directory are current. */
39
- readonly hello: () => Omit<Hello, "t" | "v">
40
- /** Executes a server request; a thrown error becomes `{ ok: false, error }`. */
41
- readonly handle: (req: ServerRequest) => Promise<unknown>
42
- readonly onWelcome?: (welcome: Welcome) => void
43
- readonly onState?: (state: ConnectionState, error?: string) => void
44
- readonly backoff?: Backoff
45
- readonly requestTimeoutMs?: number
46
- readonly socket?: (url: string, headers: Record<string, string>) => SocketLike
47
- }
48
-
49
- export function backoffDelay(attempt: number, backoff: Backoff): number {
50
- const base = Math.min(backoff.maxMs, backoff.minMs * 2 ** Math.min(attempt, 30))
51
- const random = (backoff.random ?? Math.random)()
52
- return Math.round(base * (0.5 + 0.5 * random))
53
- }
54
-
55
- export function wsURL(server: string): string {
56
- return `${server.replace(/\/+$/, "").replace(/^http/, "ws")}/spoke/ws`
57
- }
58
-
59
- const defaultSocket = (url: string, headers: Record<string, string>): SocketLike =>
60
- new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => SocketLike)(url, { headers })
61
-
62
- const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
63
-
64
- interface Pending {
65
- resolve: (value: unknown) => void
66
- reject: (error: Error) => void
67
- timer: ReturnType<typeof setTimeout>
68
- }
69
-
70
- export class SpokeConnection {
71
- state: ConnectionState = "off"
72
- lastError?: string
73
- publicURL?: string
74
- machineID?: string
75
-
76
- private socket?: SocketLike
77
- private welcomed = false
78
- private stopped = true
79
- private attempt = 0
80
- private retry?: ReturnType<typeof setTimeout>
81
- private nextID = 0
82
- private readonly pending = new Map<string, Pending>()
83
- private readonly backoff: Backoff
84
-
85
- constructor(private readonly options: ConnectionOptions) {
86
- this.backoff = options.backoff ?? { minMs: 1000, maxMs: 60_000 }
87
- }
88
-
89
- start(): void {
90
- if (!this.stopped) return
91
- this.stopped = false
92
- this.open()
93
- }
94
-
95
- stop(): void {
96
- this.stopped = true
97
- clearTimeout(this.retry)
98
- this.drop(1000, "stop")
99
- this.failPending("disconnected")
100
- this.setState("off")
101
- }
102
-
103
- /** Reconnects at once (e.g. after a local hub was started) instead of waiting out the backoff. */
104
- retryNow(): void {
105
- if (this.stopped || this.state === "connected" || this.state === "revoked" || this.socket) return
106
- clearTimeout(this.retry)
107
- this.attempt = 0
108
- this.open()
109
- }
110
-
111
- /** Sends a frame once the server has welcomed us. Returns false when it could not be sent. */
112
- send(frame: SpokeMessage): boolean {
113
- if (!this.socket || !this.welcomed || this.socket.readyState !== 1) return false
114
- try {
115
- let text = encode(frame)
116
- if (text.length > MAX_FRAME) {
117
- // The hub drops the connection on an oversize frame; answer a request with an error instead.
118
- if (frame.t !== "res") return false
119
- text = encode({ t: "res", id: frame.id, ok: false, error: `Result too large to send (${text.length} characters, limit ${MAX_FRAME})` })
120
- }
121
- this.socket.send(text)
122
- return true
123
- } catch {
124
- return false
125
- }
126
- }
127
-
128
- request<R extends SpokeRequest>(req: R): Promise<ResultFor[R["op"]]> {
129
- const id = `s${++this.nextID}`
130
- return new Promise((resolve, reject) => {
131
- if (this.state !== "connected") return reject(new Error("Not connected to the hub"))
132
- const timer = setTimeout(() => {
133
- this.pending.delete(id)
134
- reject(new Error("The hub timed out"))
135
- }, this.options.requestTimeoutMs ?? 15_000)
136
- this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer })
137
- if (!this.send({ t: "req", id, req })) {
138
- clearTimeout(timer)
139
- this.pending.delete(id)
140
- reject(new Error("Not connected to the hub"))
141
- }
142
- })
143
- }
144
-
145
- private open(): void {
146
- if (this.stopped) return
147
- this.setState(this.state === "error" ? "error" : "connecting")
148
- let socket: SocketLike
149
- try {
150
- socket = (this.options.socket ?? defaultSocket)(wsURL(this.options.server), {
151
- Authorization: `Bearer ${this.options.token}`,
152
- })
153
- } catch (cause) {
154
- this.fail(message(cause))
155
- return
156
- }
157
- this.socket = socket
158
- this.welcomed = false
159
- socket.onopen = () => {
160
- try {
161
- socket.send(encode({ t: "hello", v: PROTOCOL_VERSION, ...this.options.hello() }))
162
- } catch (cause) {
163
- this.lastError = message(cause)
164
- }
165
- }
166
- socket.onmessage = (ev) => this.receive(ev.data)
167
- socket.onerror = () => {
168
- this.lastError = this.welcomed ? "Connection error" : "Could not connect to the hub"
169
- }
170
- socket.onclose = (ev) => {
171
- if (this.socket !== socket) return
172
- this.socket = undefined
173
- const wasWelcomed = this.welcomed
174
- this.welcomed = false
175
- this.failPending("disconnected")
176
- if (this.stopped || this.state === "revoked") return
177
- this.fail(wasWelcomed ? `Connection closed${ev.reason ? `: ${ev.reason}` : ""}` : (this.lastError ?? "Could not connect"))
178
- }
179
- }
180
-
181
- private receive(data: unknown): void {
182
- const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : String(data)
183
- const frame = parseServerMessage(text)
184
- if (!frame) return
185
- switch (frame.t) {
186
- case "welcome":
187
- this.welcomed = true
188
- this.attempt = 0
189
- this.publicURL = frame.publicURL
190
- this.machineID = frame.machineID
191
- this.lastError = undefined
192
- this.setState("connected")
193
- try {
194
- this.options.onWelcome?.(frame)
195
- } catch (cause) {
196
- console.error("[hub] welcome handler failed", cause)
197
- }
198
- return
199
- case "bye":
200
- if (frame.reason === "revoked") {
201
- this.stopped = true
202
- clearTimeout(this.retry)
203
- this.lastError = "This machine was revoked on the hub"
204
- this.setState("revoked")
205
- this.drop(1000, "revoked")
206
- this.failPending("revoked")
207
- }
208
- return
209
- case "res": {
210
- const pending = this.pending.get(frame.id)
211
- if (!pending) return
212
- this.pending.delete(frame.id)
213
- clearTimeout(pending.timer)
214
- if (frame.ok) pending.resolve(frame.result)
215
- else pending.reject(new Error(frame.error ?? "Request failed"))
216
- return
217
- }
218
- case "req":
219
- void this.answer(frame.id, frame.req)
220
- return
221
- }
222
- }
223
-
224
- private async answer(id: string, req: ServerRequest): Promise<void> {
225
- let reply: SpokeMessage
226
- try {
227
- reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
228
- } catch (cause) {
229
- reply = { t: "res", id, ok: false, error: message(cause) }
230
- }
231
- this.send(reply)
232
- }
233
-
234
- private fail(error: string): void {
235
- this.lastError = error
236
- this.setState("error")
237
- if (this.stopped) return
238
- clearTimeout(this.retry)
239
- this.retry = setTimeout(() => this.open(), backoffDelay(this.attempt++, this.backoff))
240
- }
241
-
242
- private drop(code: number, reason: string): void {
243
- const socket = this.socket
244
- this.socket = undefined
245
- this.welcomed = false
246
- if (!socket) return
247
- socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null
248
- try {
249
- socket.close(code, reason)
250
- } catch {
251
- // already closed
252
- }
253
- }
254
-
255
- private failPending(reason: string): void {
256
- for (const [id, pending] of this.pending) {
257
- clearTimeout(pending.timer)
258
- pending.reject(new Error(`Hub ${reason}`))
259
- this.pending.delete(id)
260
- }
261
- }
262
-
263
- private setState(state: ConnectionState): void {
264
- if (state === this.state) return
265
- this.state = state
266
- try {
267
- this.options.onState?.(state, this.lastError)
268
- } catch {
269
- // observers must not break the connection
270
- }
271
- }
272
- }
1
+ // The spoke's connection to the central server lives in the spoke SDK (packages/spoke-sdk); re-exported by relative
2
+ // path so the published package and compiled binaries need no workspace link.
3
+ export * from "../../packages/spoke-sdk/src/connection.ts"
@@ -5,12 +5,8 @@
5
5
 
6
6
  export const VOICE_AGENT = "voice"
7
7
 
8
- /** metadata.source of the prompts the hub sends. opencode-hub (0.5) wrote "opencode-hub", opencode-voice (<= 0.4) "opencode-voice". */
9
- export const PROMPT_SOURCE = "hub"
10
- const PROMPT_SOURCES: readonly unknown[] = [PROMPT_SOURCE, "opencode-hub", "opencode-voice"]
11
- /** True for prompt metadata written by the hub, under either name. */
12
- export const isHubPrompt = (metadata: unknown): boolean =>
13
- typeof metadata === "object" && metadata !== null && PROMPT_SOURCES.includes((metadata as { source?: unknown }).source)
8
+ // PROMPT_SOURCE and isHubPrompt live in the spoke SDK (a harness-neutral marker for the hub's prompts).
9
+ export { PROMPT_SOURCE, isHubPrompt } from "../../packages/spoke-sdk/src/prompt-source.ts"
14
10
 
15
11
  export const VOICE_AGENT_DESCRIPTION =
16
12
  "Voice front-end: short spoken replies; delegates real work to background subagents. Used by Hub conversation mode."
@@ -34,11 +30,19 @@ How to work:
34
30
  - Delegate multi-step work. A routine worker handles deterministic MCP work, short research and routine checks on your fast model. A complex worker handles coding, debugging, architecture, risky operations or work requiring substantial judgment on the full work model named below.
35
31
  - Call the subagent tool with the delegate agent named in the note below (use "general" if none is named), background: true, and a complete, self-contained prompt with the goal, relevant context, constraints, and what to report back. Require that the worker's final assistant message must contain only one or two plain-spoken sentences that can be read directly to the user. Put technical detail in tool output or progress before that final message.
36
32
  - Never run a subagent in the foreground (always pass background: true): a foreground subagent blocks the conversation and the user cannot talk to you until it ends.
37
- - New user messages can arrive while work is running; treat them as steering. For a correction, status request or related follow-up on the same active task, continue its subagent with the existing sessionID. Start a fresh worker after that task completes or for an unrelated task.
38
- - When the user asks how it's going, give a one-sentence progress update from what you know.
39
- - Successful worker results are spoken directly. Do not repeat a completed worker's result. Speak again only if the worker failed, the user must decide something, or a warning or next step genuinely needs explanation.
33
+ - Give every worker a short description of two to four words, like "Deploy check" or "Changelog update": it is the task's name on your task board and in spoken announcements.
34
+ - Parallel work: start one worker per independent task. When the user asks for several separate things, launch all their workers at once with several subagent tool calls in the same reply, each with background: true and its own self-contained prompt. Don't bundle unrelated tasks into one worker and don't wait for one to finish before starting the next. Example: "Check if the deploy went through and also update the changelog" → say "On it, two workers: one checks the deploy, one updates the changelog." and make two subagent calls in that reply.
35
+ - New user messages can arrive while work is running; treat them as steering. For a correction, status request or related follow-up on the same active task, continue its subagent with the existing sessionID instead of starting another one. Example: while "Deploy check" runs, the user says "check staging too" → continue that worker: call the subagent tool with its sessionID and the new instruction. Start a fresh worker after that task completes or for an unrelated task.
36
+ - When the user asks how it's going or what's running, answer in a sentence from the task board below (your workers, their state, last activity and results); it is live, so don't call a tool to check.
37
+ - Successful worker results are spoken directly, and the hub also announces when a worker fails, needs a permission or asks a question (with the task's name when several run). Do not repeat a completed worker's result or those announcements. Speak again only if the user must decide something, or a warning or next step genuinely needs explanation.
38
+ - To stop one task ("cancel the deploy one", "stop the changelog worker"), call hub_task_stop with that worker's sessionID from the task board; the other workers keep going. For "stop all the workers" or "pause everything", call it with all: true. It never stops you, and you can confirm in a few words. The stopped worker's cancellation notice arrives as a message afterwards: don't reply to it, you already confirmed.
40
39
  - If the user says stop, cancel or never mind, stop: start no new work and confirm briefly.`
41
40
 
41
+ /** The tool the plugin adds for stopping one background worker (or all of them) by voice. */
42
+ export const STOP_TOOL = "hub_task_stop"
43
+ export const STOP_TOOL_DESCRIPTION =
44
+ "Stop a background worker (subagent session) you started, e.g. when the user says \"cancel the deploy one\" or \"stop that task\". Pass its sessionID from your task board, or all: true to stop all your running workers. It never stops you or sessions you did not start."
45
+
42
46
  export function delegateNote(subagent: string): string {
43
47
  return `Delegate agent on this machine: when you call the subagent tool, use agent "${subagent}" and always set background: true. Require its final assistant message to contain only one or two plain-spoken sentences for direct speech.`
44
48
  }
@@ -51,9 +55,9 @@ export function workModelNote(workModel: string): string {
51
55
  return `For a routine worker, omit model so it inherits your fast model. For a complex worker, set model: "${workModel}". Complex means coding, debugging, architecture, risky operations or work requiring substantial judgment.`
52
56
  }
53
57
 
54
- /** System parts the context hook adds: the delegate note for the voice agent, the whole prompt when conversation mode runs on another agent. */
55
- export function orchestratorParts(input: { agent: string; conversation: boolean; subagent: string; workModel?: string }): string[] {
56
- const notes = [delegateNote(input.subagent), ...(input.workModel ? [workModelNote(input.workModel)] : [])]
58
+ /** System parts the context hook adds: the delegate note (and the task board) for the voice agent, the whole prompt first when conversation mode runs on another agent. */
59
+ export function orchestratorParts(input: { agent: string; conversation: boolean; subagent: string; workModel?: string; taskBoard?: string }): string[] {
60
+ const notes = [delegateNote(input.subagent), ...(input.workModel ? [workModelNote(input.workModel)] : []), ...(input.taskBoard ? [input.taskBoard] : [])]
57
61
  if (input.agent === VOICE_AGENT) return notes
58
62
  if (input.conversation) return [ORCHESTRATOR_PROMPT, ...notes]
59
63
  return []
@@ -0,0 +1,75 @@
1
+ // Hub message delivery (the deliver op): how a message is admitted into an OpenCode session, the id OpenCode
2
+ // gets for it, the metadata it carries, who put each inbox item there, and applying each message once.
3
+ //
4
+ // Messages go in through OpenCode's native session.synthetic with a client id (decision 52aqsopo): OpenCode
5
+ // keeps them in the session's inbox (busy sessions, open forms, interrupts) and deduplicates the id, even across
6
+ // restarts. The model sees only the text (the hub-rendered envelope), never the metadata. An agent's prompt (kind
7
+ // "prompt") gets its provenance envelope here, from the hub's meta, and is the hub's in the inbox (never a human turn).
8
+ import { createHash } from "node:crypto"
9
+ import type { DeliverMeta, Delivered, DeliveryMode } from "../protocol"
10
+ import { PROMPT_SOURCE } from "./conversation"
11
+
12
+ export interface DeliveryPlan {
13
+ /** How OpenCode admits it into a busy session: after the running turn (queue) or into it (steer). */
14
+ readonly delivery: DeliveryMode
15
+ /** Whether OpenCode starts a turn for it when the session is idle. */
16
+ readonly resume: boolean
17
+ /** What the spoke reports to the hub. */
18
+ readonly delivered: Delivered
19
+ }
20
+
21
+ export interface RecipientState {
22
+ /** A turn runs (or OpenCode reports the session busy). */
23
+ readonly busy: boolean
24
+ /** A form (question) is open in the session. */
25
+ readonly formOpen: boolean
26
+ /** The session is a subagent's (it has a parent). */
27
+ readonly subagent: boolean
28
+ }
29
+
30
+ /**
31
+ * How to admit a message. Busy recipients get plain messages after the running turn and asks into it, unless
32
+ * the hub chose (`meta.delivery`, e.g. urgent: steer). Idle recipients get a turn, always steered: a queued wake
33
+ * would come after the reply to items already waiting (a primer, wake:false messages) with a model call of its own
34
+ * (quirk hzmcqwpj), a steered one joins them in one call. With a form open OpenCode holds the message until the
35
+ * form is answered ("retained").
36
+ *
37
+ * `wake:false` and subagent sessions are admitted without starting a turn ("queued"). They default to steer: a
38
+ * queued item gets its own model call when a turn ends (that would wake the session), while a steered one
39
+ * without resume waits for the next turn and arrives before its prompt (or joins a turn already running).
40
+ */
41
+ export function planDelivery(req: { readonly wake: boolean; readonly meta: DeliverMeta }, state: RecipientState): DeliveryPlan {
42
+ const resume = req.wake && !state.subagent
43
+ const chosen = req.meta.delivery ?? (!resume || req.meta.kind === "ask" ? "steer" : "queue")
44
+ // Idle: no running turn to queue behind; steered, the wake shares its model call with what already waits.
45
+ const delivery = resume && !state.busy && !state.formOpen ? "steer" : chosen
46
+ return { delivery, resume, delivered: deliveredAs(delivery, resume, state) }
47
+ }
48
+
49
+ function deliveredAs(delivery: DeliveryMode, resume: boolean, state: RecipientState): Delivered {
50
+ if (!resume) return "queued"
51
+ if (state.formOpen) return "retained"
52
+ if (state.busy) return delivery === "steer" ? "steer" : "queued"
53
+ return "turn"
54
+ }
55
+
56
+ /** OpenCode message ids: "msg_" and id characters. */
57
+ const OPENCODE_ID = /^msg_[A-Za-z0-9]{1,200}$/
58
+
59
+ /**
60
+ * The id OpenCode gets for a hub message: the hub's own `msg_…` id, else a stable `msg_hub…` id derived from it,
61
+ * so every retry of the same message reuses one id (OpenCode applies it once).
62
+ */
63
+ export function harnessMessageID(messageID: string): string {
64
+ if (OPENCODE_ID.test(messageID)) return messageID
65
+ return `msg_hub${createHash("sha256").update(messageID).digest("hex").slice(0, 40)}`
66
+ }
67
+
68
+ /** metadata stored with a delivered message (for timelines; the model does not see it). */
69
+ export function deliverMetadata(req: { readonly messageID: string; readonly meta: DeliverMeta }): Record<string, unknown> {
70
+ const { kind, from, to, replyTo, grant } = req.meta
71
+ return { source: PROMPT_SOURCE, messageID: req.messageID, kind, from, to, ...(replyTo !== undefined ? { replyTo } : {}), ...(grant !== undefined ? { grant } : {}) }
72
+ }
73
+
74
+ // The prompt envelope, InboxOrigins and DeliveryLedger live in the spoke SDK (harness-neutral).
75
+ export { deliverText, DeliveryLedger, InboxOrigins, renderPromptEnvelope, type Admission } from "../../packages/spoke-sdk/src/delivery.ts"
@@ -1,79 +1,2 @@
1
- // Device-code enrollment of this machine with the central server (before it has a token).
2
- // POST /spoke/enroll → EnrollStart; then POST /spoke/enroll/poll until the user approves it.
3
- import type { EnrollPoll, EnrollStart } from "../protocol"
4
-
5
- export type EnrollOutcome =
6
- | { readonly status: "approved"; readonly machineID: string; readonly machineToken: string }
7
- | { readonly status: "denied" | "expired" | "cancelled" }
8
-
9
- export interface EnrollOptions {
10
- readonly signal?: AbortSignal
11
- readonly fetch?: typeof fetch
12
- readonly now?: () => number
13
- }
14
-
15
- const REQUEST_TIMEOUT_MS = 15_000
16
-
17
- async function post(server: string, path: string, body: unknown, options: EnrollOptions): Promise<unknown> {
18
- const doFetch = options.fetch ?? fetch
19
- const signals = [AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...(options.signal ? [options.signal] : [])]
20
- const response = await doFetch(`${server.replace(/\/+$/, "")}${path}`, {
21
- method: "POST",
22
- headers: { "content-type": "application/json" },
23
- body: JSON.stringify(body),
24
- signal: AbortSignal.any(signals),
25
- })
26
- if (!response.ok) throw new Error(`${path} failed: HTTP ${response.status}`)
27
- return response.json()
28
- }
29
-
30
- function isStart(v: any): v is EnrollStart {
31
- return (
32
- typeof v === "object" &&
33
- v !== null &&
34
- typeof v.userCode === "string" &&
35
- typeof v.deviceCode === "string" &&
36
- typeof v.approveURL === "string" &&
37
- typeof v.intervalMs === "number" &&
38
- typeof v.expiresAt === "number"
39
- )
40
- }
41
-
42
- export async function startEnrollment(server: string, machineName: string, options: EnrollOptions = {}): Promise<EnrollStart> {
43
- const body = await post(server, "/spoke/enroll", { machineName }, options)
44
- if (!isStart(body)) throw new Error("Invalid enrollment reply from the hub")
45
- return body
46
- }
47
-
48
- const sleep = (ms: number, signal?: AbortSignal) =>
49
- new Promise<void>((resolve) => {
50
- const timer = setTimeout(done, ms)
51
- function done() {
52
- clearTimeout(timer)
53
- signal?.removeEventListener("abort", done)
54
- resolve()
55
- }
56
- signal?.addEventListener("abort", done, { once: true })
57
- })
58
-
59
- /** Polls until approved, denied, expired, or aborted. Transient errors are retried until expiry. */
60
- export async function pollEnrollment(server: string, start: EnrollStart, options: EnrollOptions = {}): Promise<EnrollOutcome> {
61
- const now = options.now ?? Date.now
62
- const interval = Math.max(1, start.intervalMs)
63
- for (;;) {
64
- if (options.signal?.aborted) return { status: "cancelled" }
65
- if (now() > start.expiresAt) return { status: "expired" }
66
- await sleep(interval, options.signal)
67
- if (options.signal?.aborted) return { status: "cancelled" }
68
- let reply: Partial<EnrollPoll> & Record<string, unknown>
69
- try {
70
- reply = (await post(server, "/spoke/enroll/poll", { deviceCode: start.deviceCode }, options)) as typeof reply
71
- } catch {
72
- continue
73
- }
74
- if (reply?.status === "approved" && typeof reply.machineID === "string" && typeof reply.machineToken === "string") {
75
- return { status: "approved", machineID: reply.machineID, machineToken: reply.machineToken }
76
- }
77
- if (reply?.status === "denied" || reply?.status === "expired") return { status: reply.status }
78
- }
79
- }
1
+ // Device-code enrollment lives in the spoke SDK (packages/spoke-sdk); re-exported by relative path.
2
+ export * from "../../packages/spoke-sdk/src/enroll.ts"