@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,249 @@
1
+ // The Codex app-server spoke (P7-T7a; decision fx04e42c point 5 as corrected by spike v0hsdqhm): one HarnessSpoke
2
+ // (harness "codex", evidence "rpc", announcing turnEvents: every turn's end is Codex's own turn/completed) over
3
+ // CodexSessions and up to two app-server connections, each as one client among others:
4
+ //
5
+ // - "own": `codex app-server --listen stdio://`, started on the first create, for the threads the hub starts
6
+ // (thread/start). It is a child of the spoke: when the spoke stops, so do those threads.
7
+ // - "daemon": the shared daemon's Unix socket (CODEX_HOME/app-server-control/app-server-control.sock, WebSocket
8
+ // framing), when present. The TUI and the desktop app keep their threads there; the spoke lists the loaded threads
9
+ // every pollMs (and on thread/started) and subscribes to each new one (thread/resume, excludeTurns). The spoke
10
+ // never starts the daemon: it connects when the socket answers and tries again every pollMs when it does not.
11
+ //
12
+ // Nothing here writes Codex's files (CODEX_HOME): configuration goes in as -c overrides (codexArgs) and environment.
13
+ import { HarnessSpoke, type HarnessSpokeOptions } from "../../spoke-sdk/src/index.ts"
14
+ import { openAppServer, type AppServerConnection } from "./app-server.ts"
15
+ import type { RpcID } from "./jsonrpc.ts"
16
+ import { CodexSessions, type ThreadDescription } from "./sessions.ts"
17
+ import { spawnStdioChannel } from "./stdio.ts"
18
+ import { connectUnixWebSocket } from "./websocket.ts"
19
+
20
+ export const HARNESS = "codex"
21
+ export const DEFAULT_POLL_MS = 2000
22
+ const LIST_PAGES_MAX = 20
23
+ const METHOD_NOT_FOUND = -32601
24
+
25
+ export interface CodexSpokeOptions {
26
+ readonly server: string
27
+ /** The machine token (never logged). */
28
+ readonly token: string
29
+ readonly harnessVersion: string
30
+ readonly pluginVersion: string
31
+ readonly directory: string
32
+ /** The codex executable (default "codex" on PATH, see main.ts). */
33
+ readonly codexBin: string
34
+ /** Arguments before `app-server` (tests: the fake app-server's script). */
35
+ readonly codexBinArgs?: readonly string[]
36
+ /** Arguments after `app-server --listen stdio://` (e.g. -c overrides). */
37
+ readonly codexArgs?: readonly string[]
38
+ /** The own app-server's whole environment. */
39
+ readonly env: Readonly<Record<string, string | undefined>>
40
+ /** The shared daemon's socket; absent: no daemon attach. */
41
+ readonly daemonSocket?: string
42
+ readonly pollMs?: number
43
+ readonly threadDefaults?: Readonly<Record<string, unknown>>
44
+ readonly turnTimeoutMs?: number
45
+ readonly log?: (line: string) => void
46
+ readonly socket?: HarnessSpokeOptions["socket"]
47
+ readonly backoff?: HarnessSpokeOptions["backoff"]
48
+ }
49
+
50
+ export interface CodexSpoke {
51
+ readonly spoke: HarnessSpoke
52
+ readonly sessions: CodexSessions
53
+ readonly daemonConnected: boolean
54
+ stop(): Promise<void>
55
+ }
56
+
57
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
58
+ const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v)
59
+
60
+ export function startCodexSpoke(options: CodexSpokeOptions): CodexSpoke {
61
+ const log = options.log ?? (() => {})
62
+ const pollMs = options.pollMs ?? DEFAULT_POLL_MS
63
+ let stopped = false
64
+ let own: Promise<AppServerConnection> | undefined
65
+ let daemon: AppServerConnection | undefined
66
+ let connecting = false
67
+ let discovering = false
68
+ const resuming = new Set<string>()
69
+
70
+ const sessions: CodexSessions = new CodexSessions({
71
+ spoke: { upsertSession: (info) => spoke.upsertSession(info), endSession: (id) => spoke.endSession(id), emit: (event) => spoke.emit(event) },
72
+ createServer: () => ownServer(),
73
+ ...(options.threadDefaults ? { threadDefaults: options.threadDefaults } : {}),
74
+ ...(options.turnTimeoutMs !== undefined ? { turnTimeoutMs: options.turnTimeoutMs } : {}),
75
+ log,
76
+ })
77
+
78
+ const spoke = new HarnessSpoke({
79
+ server: options.server,
80
+ token: options.token,
81
+ harness: HARNESS,
82
+ harnessVersion: options.harnessVersion,
83
+ evidence: "rpc",
84
+ pluginVersion: options.pluginVersion,
85
+ directory: options.directory,
86
+ kind: "service",
87
+ mode: "always",
88
+ handlers: {
89
+ turn: (req) => sessions.turn(req),
90
+ steer: (req) => sessions.steer(req),
91
+ interrupt: (req) => sessions.interrupt(req),
92
+ stop: (req) => sessions.stop(req),
93
+ permission: (req) => sessions.permission(req),
94
+ create: (req) => sessions.create(req),
95
+ },
96
+ deliver: (req) => sessions.deliver(req),
97
+ features: ["hubPrompt", "launch", "turnEvents"],
98
+ onState: (state, error) => log(`hub connection ${state}${error ? `: ${error}` : ""}`),
99
+ ...(options.socket ? { socket: options.socket } : {}),
100
+ ...(options.backoff ? { backoff: options.backoff } : {}),
101
+ })
102
+
103
+ /** Routes a connection's notifications and server requests (`conn` is set once initialize answered). */
104
+ function handlers(get: () => AppServerConnection | undefined, onThreadStarted?: (thread: unknown) => void) {
105
+ return {
106
+ onNotification(method: string, params: unknown) {
107
+ const conn = get()
108
+ if (!conn) return
109
+ if (method === "thread/started" && onThreadStarted) onThreadStarted(isObj(params) ? params.thread : undefined)
110
+ sessions.notification(conn, method, params)
111
+ },
112
+ onRequest(id: RpcID, method: string, params: unknown) {
113
+ const conn = get()
114
+ if (!conn) return
115
+ if (sessions.serverRequest(conn, id, method, params)) return
116
+ // On its own app-server the spoke is the only client: nobody else would answer.
117
+ if (conn.name === "own") conn.peer.respondError(id, METHOD_NOT_FOUND, `The hub's Codex spoke does not handle ${method}`)
118
+ },
119
+ }
120
+ }
121
+
122
+ function ownServer(): Promise<AppServerConnection> {
123
+ if (stopped) return Promise.reject(new Error("The Codex spoke is stopping"))
124
+ own ??= (async () => {
125
+ const child = spawnStdioChannel({
126
+ command: options.codexBin,
127
+ args: [...(options.codexBinArgs ?? []), "app-server", "--listen", "stdio://", ...(options.codexArgs ?? [])],
128
+ env: options.env,
129
+ log: (line) => log(`codex app-server: ${line}`),
130
+ })
131
+ let conn: AppServerConnection | undefined
132
+ try {
133
+ conn = await openAppServer(child.channel, { name: "own", clientVersion: options.pluginVersion, ...handlers(() => conn) })
134
+ } catch (cause) {
135
+ own = undefined
136
+ child.channel.close()
137
+ throw new Error(`Cannot start codex app-server (${options.codexBin}): ${message(cause)}`)
138
+ }
139
+ const opened = conn
140
+ void opened.closed.then((reason) => {
141
+ log(`own app-server closed: ${reason}`)
142
+ sessions.connectionClosed(opened)
143
+ own = undefined
144
+ })
145
+ return opened
146
+ })()
147
+ return own
148
+ }
149
+
150
+ async function resume(conn: AppServerConnection, threadID: string): Promise<void> {
151
+ if (resuming.has(threadID) || sessions.hasThread(threadID)) return
152
+ resuming.add(threadID)
153
+ try {
154
+ const res = await conn.request("thread/resume", { threadId: threadID, excludeTurns: true })
155
+ const thread = isObj(res) && isObj(res.thread) ? (res.thread as unknown as ThreadDescription) : undefined
156
+ if (thread && typeof thread.id === "string" && !conn.isClosed && daemon === conn) sessions.attach(conn, { ...thread, cwd: thread.cwd ?? (isObj(res) ? res.cwd : undefined) })
157
+ } catch (cause) {
158
+ log(`thread/resume ${threadID}: ${message(cause)}`)
159
+ } finally {
160
+ resuming.delete(threadID)
161
+ }
162
+ }
163
+
164
+ async function discover(conn: AppServerConnection): Promise<void> {
165
+ if (discovering) return
166
+ discovering = true
167
+ try {
168
+ let cursor: string | undefined
169
+ for (let page = 0; page < LIST_PAGES_MAX; page++) {
170
+ const res = await conn.request("thread/loaded/list", cursor ? { cursor } : {})
171
+ const ids: unknown[] = isObj(res) && Array.isArray(res.data) ? res.data : []
172
+ await Promise.all(ids.filter((id): id is string => typeof id === "string").map((id) => resume(conn, id)))
173
+ cursor = isObj(res) && typeof res.nextCursor === "string" ? res.nextCursor : undefined
174
+ if (!cursor) break
175
+ }
176
+ } catch (cause) {
177
+ if (!conn.isClosed) log(`thread/loaded/list: ${message(cause)}`)
178
+ } finally {
179
+ discovering = false
180
+ }
181
+ }
182
+
183
+ async function connectDaemon(socketPath: string): Promise<void> {
184
+ if (daemon || connecting || stopped) return
185
+ connecting = true
186
+ try {
187
+ const channel = await connectUnixWebSocket(socketPath, { timeoutMs: Math.max(1000, pollMs) })
188
+ let conn: AppServerConnection | undefined
189
+ conn = await openAppServer(channel, {
190
+ name: "daemon",
191
+ clientVersion: options.pluginVersion,
192
+ ...handlers(
193
+ () => conn,
194
+ (thread) => {
195
+ if (conn && isObj(thread) && typeof thread.id === "string") void resume(conn, thread.id)
196
+ },
197
+ ),
198
+ })
199
+ if (stopped) return conn.close()
200
+ const opened = conn
201
+ daemon = opened
202
+ log(`attached to the Codex daemon at ${socketPath}`)
203
+ void opened.closed.then((reason) => {
204
+ log(`Codex daemon connection closed: ${reason}`)
205
+ sessions.connectionClosed(opened)
206
+ if (daemon === opened) daemon = undefined
207
+ })
208
+ } catch {
209
+ // No daemon (yet): try again at the next poll.
210
+ } finally {
211
+ connecting = false
212
+ }
213
+ }
214
+
215
+ let timer: ReturnType<typeof setInterval> | undefined
216
+ if (options.daemonSocket) {
217
+ const socketPath = options.daemonSocket
218
+ const tick = async () => {
219
+ await connectDaemon(socketPath)
220
+ if (daemon && !stopped) await discover(daemon)
221
+ }
222
+ timer = setInterval(() => void tick(), pollMs)
223
+ timer.unref?.()
224
+ void tick()
225
+ }
226
+
227
+ spoke.start()
228
+ return {
229
+ spoke,
230
+ sessions,
231
+ get daemonConnected() {
232
+ return daemon !== undefined
233
+ },
234
+ async stop() {
235
+ if (stopped) return
236
+ stopped = true
237
+ if (timer) clearInterval(timer)
238
+ spoke.stop()
239
+ daemon?.close()
240
+ const pending = own
241
+ own = undefined
242
+ if (pending) {
243
+ const conn = await pending.catch(() => undefined)
244
+ conn?.close()
245
+ if (conn) await Promise.race([conn.closed, new Promise((r) => setTimeout(r, 3000))])
246
+ }
247
+ },
248
+ }
249
+ }
@@ -0,0 +1,101 @@
1
+ // A child process's stdio as a channel of lines: `codex app-server --listen stdio://` reads one JSON message per line
2
+ // on stdin and writes one per line on stdout. Its stderr goes to `log` line by line (never parsed); its exit (or a
3
+ // failure to start) closes the channel; closing the channel ends the child (SIGTERM, then SIGKILL after a grace).
4
+ import { spawn } from "node:child_process"
5
+ import type { Channel } from "./jsonrpc.ts"
6
+
7
+ /** Longest stdout line accepted (an app-server message); a longer one ends the child. */
8
+ export const STDIO_MAX_LINE = 16 * 1024 * 1024
9
+ const KILL_GRACE_MS = 2000
10
+ const LOG_LINE_MAX = 2000
11
+
12
+ export interface StdioOptions {
13
+ readonly command: string
14
+ readonly args: readonly string[]
15
+ /** The child's whole environment (nothing is inherited implicitly). */
16
+ readonly env: Readonly<Record<string, string | undefined>>
17
+ readonly cwd?: string
18
+ readonly log?: (line: string) => void
19
+ }
20
+
21
+ export interface StdioChild {
22
+ readonly channel: Channel
23
+ /** 0 when the child could not start. */
24
+ readonly pid: number
25
+ readonly exited: Promise<{ code: number | null; signal: string | null }>
26
+ }
27
+
28
+ export function spawnStdioChannel(options: StdioOptions): StdioChild {
29
+ const listeners: ((text: string) => void)[] = []
30
+ const closers: ((reason: string) => void)[] = []
31
+ let closed = false
32
+ const finish = (reason: string) => {
33
+ if (closed) return
34
+ closed = true
35
+ for (const cb of closers) cb(reason)
36
+ }
37
+ const env = Object.fromEntries(Object.entries(options.env).filter(([, v]) => v !== undefined)) as Record<string, string>
38
+ const child = spawn(options.command, [...options.args], { cwd: options.cwd, env, stdio: ["pipe", "pipe", "pipe"] })
39
+ let exitDone!: (v: { code: number | null; signal: string | null }) => void
40
+ const exited = new Promise<{ code: number | null; signal: string | null }>((resolve) => (exitDone = resolve))
41
+ child.on("error", (error) => {
42
+ finish(`cannot run ${options.command}: ${error.message}`)
43
+ exitDone({ code: null, signal: null })
44
+ })
45
+ child.on("exit", (code, signal) => {
46
+ finish(`${options.command} exited (code ${code ?? "none"}${signal ? `, signal ${signal}` : ""})`)
47
+ exitDone({ code, signal })
48
+ })
49
+ child.stdin.on("error", () => {})
50
+
51
+ let out = ""
52
+ child.stdout.setEncoding("utf8")
53
+ child.stdout.on("data", (chunk: string) => {
54
+ out += chunk
55
+ let end: number
56
+ while ((end = out.indexOf("\n")) >= 0) {
57
+ const line = out.slice(0, end).replace(/\r$/, "")
58
+ out = out.slice(end + 1)
59
+ if (line.trim()) for (const cb of listeners) cb(line)
60
+ }
61
+ if (out.length > STDIO_MAX_LINE) child.kill("SIGKILL")
62
+ })
63
+ let err = ""
64
+ child.stderr.setEncoding("utf8")
65
+ child.stderr.on("data", (chunk: string) => {
66
+ err += chunk
67
+ let end: number
68
+ while ((end = err.indexOf("\n")) >= 0) {
69
+ const line = err.slice(0, end)
70
+ err = err.slice(end + 1)
71
+ if (line.trim()) options.log?.(line.slice(0, LOG_LINE_MAX))
72
+ }
73
+ if (err.length > LOG_LINE_MAX) err = ""
74
+ })
75
+
76
+ const stop = () => {
77
+ if (child.exitCode !== null || child.signalCode !== null || !child.pid) return
78
+ child.kill("SIGTERM")
79
+ const timer = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS)
80
+ timer.unref?.()
81
+ void exited.then(() => clearTimeout(timer))
82
+ }
83
+
84
+ return {
85
+ pid: child.pid ?? 0,
86
+ exited,
87
+ channel: {
88
+ send(text) {
89
+ if (closed) throw new Error("the app-server process has exited")
90
+ child.stdin.write(`${text}\n`)
91
+ },
92
+ close() {
93
+ child.stdin.end()
94
+ stop()
95
+ finish("closed")
96
+ },
97
+ onMessage: (cb) => void listeners.push(cb),
98
+ onClose: (cb) => void closers.push(cb),
99
+ },
100
+ }
101
+ }
@@ -0,0 +1,206 @@
1
+ // A minimal WebSocket client (RFC 6455) over a Unix socket. The shared Codex daemon listens on
2
+ // CODEX_HOME/app-server-control/app-server-control.sock and speaks WebSocket there (HTTP Upgrade → 101, then frames;
3
+ // spike v0hsdqhm), so the spoke does the handshake and the framing itself: text messages only, client frames masked,
4
+ // fragmented messages reassembled, pings answered, a close frame (or the socket ending) closes the channel.
5
+ import { createHash, randomBytes } from "node:crypto"
6
+ import { Socket } from "node:net"
7
+ import type { Channel } from "./jsonrpc.ts"
8
+
9
+ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
10
+ /** Largest message this client accepts (the daemon announces 16 MiB unfragmented). */
11
+ export const WS_MAX_MESSAGE = 16 * 1024 * 1024
12
+ const HEAD_MAX = 16 * 1024
13
+
14
+ const OP_CONT = 0x0
15
+ const OP_TEXT = 0x1
16
+ const OP_BINARY = 0x2
17
+ const OP_CLOSE = 0x8
18
+ const OP_PING = 0x9
19
+ const OP_PONG = 0xa
20
+
21
+ /** One client frame (FIN set, masked). */
22
+ export function encodeFrame(opcode: number, payload: Buffer): Buffer {
23
+ const length = payload.length
24
+ const head = length < 126 ? Buffer.alloc(2) : length < 65_536 ? Buffer.alloc(4) : Buffer.alloc(10)
25
+ head[0] = 0x80 | opcode
26
+ if (length < 126) head[1] = 0x80 | length
27
+ else if (length < 65_536) {
28
+ head[1] = 0x80 | 126
29
+ head.writeUInt16BE(length, 2)
30
+ } else {
31
+ head[1] = 0x80 | 127
32
+ head.writeBigUInt64BE(BigInt(length), 2)
33
+ }
34
+ const mask = randomBytes(4)
35
+ const body = Buffer.alloc(length)
36
+ for (let i = 0; i < length; i++) body[i] = payload[i]! ^ mask[i & 3]!
37
+ return Buffer.concat([head, mask, body])
38
+ }
39
+
40
+ export interface WebSocketOptions {
41
+ /** For the connection and the upgrade (default 5 s). */
42
+ readonly timeoutMs?: number
43
+ /** The request path (default "/"). */
44
+ readonly path?: string
45
+ }
46
+
47
+ /** Connects and upgrades; resolves with a channel of text messages, rejects when either fails. */
48
+ export function connectUnixWebSocket(socketPath: string, options: WebSocketOptions = {}): Promise<Channel> {
49
+ return new Promise((resolve, reject) => {
50
+ const key = randomBytes(16).toString("base64")
51
+ const accept = createHash("sha1").update(key + GUID).digest("base64")
52
+ // Listeners first: Bun reports a missing socket synchronously, inside connect() (quirk awi4wb9g).
53
+ const socket = new Socket()
54
+ let upgraded = false
55
+ let head = Buffer.alloc(0)
56
+ const fail = (error: Error) => {
57
+ if (upgraded) return
58
+ clearTimeout(timer)
59
+ socket.destroy()
60
+ reject(error)
61
+ }
62
+ const timer = setTimeout(() => fail(new Error(`No WebSocket upgrade from ${socketPath} within ${options.timeoutMs ?? 5000} ms`)), options.timeoutMs ?? 5000)
63
+ socket.on("error", (error) => fail(error))
64
+ socket.on("close", () => fail(new Error(`${socketPath} closed before the WebSocket upgrade`)))
65
+ socket.on("connect", () => {
66
+ socket.write(
67
+ [`GET ${options.path ?? "/"} HTTP/1.1`, "Host: localhost", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Key: ${key}`, "Sec-WebSocket-Version: 13", "", ""].join("\r\n"),
68
+ )
69
+ })
70
+ const onHead = (chunk: Buffer) => {
71
+ head = Buffer.concat([head, chunk])
72
+ const end = head.indexOf("\r\n\r\n")
73
+ if (end < 0) {
74
+ if (head.length > HEAD_MAX) fail(new Error("The WebSocket upgrade response is too long"))
75
+ return
76
+ }
77
+ const lines = head.subarray(0, end).toString("latin1").split("\r\n")
78
+ const status = /^HTTP\/1\.[01] (\d{3})/.exec(lines[0] ?? "")?.[1]
79
+ const headers = new Map(lines.slice(1).map((l) => [l.slice(0, l.indexOf(":")).trim().toLowerCase(), l.slice(l.indexOf(":") + 1).trim()] as const))
80
+ if (status !== "101") return fail(new Error(`No WebSocket upgrade from ${socketPath} (HTTP ${status ?? "?"})`))
81
+ if (headers.get("sec-websocket-accept") !== accept) return fail(new Error(`Bad Sec-WebSocket-Accept from ${socketPath}`))
82
+ upgraded = true
83
+ clearTimeout(timer)
84
+ socket.off("data", onHead)
85
+ socket.removeAllListeners("close")
86
+ socket.removeAllListeners("error")
87
+ resolve(frameChannel(socket, head.subarray(end + 4)))
88
+ }
89
+ socket.on("data", onHead)
90
+ try {
91
+ socket.connect(socketPath)
92
+ } catch (error) {
93
+ fail(error as Error)
94
+ }
95
+ })
96
+ }
97
+
98
+ /** The channel over an upgraded socket; `rest` is what arrived after the upgrade response. */
99
+ function frameChannel(socket: Socket, rest: Buffer): Channel {
100
+ const listeners: ((text: string) => void)[] = []
101
+ const closers: ((reason: string) => void)[] = []
102
+ let closed = false
103
+ let buffer: Buffer = Buffer.from(rest)
104
+ let fragments: Buffer[] = []
105
+ let fragmentsLength = 0
106
+ let fragmentText = false
107
+
108
+ const finish = (reason: string) => {
109
+ if (closed) return
110
+ closed = true
111
+ socket.destroy()
112
+ for (const cb of closers) cb(reason)
113
+ }
114
+ const write = (opcode: number, payload: Buffer) => {
115
+ if (!closed && !socket.destroyed) socket.write(encodeFrame(opcode, payload))
116
+ }
117
+
118
+ const parse = () => {
119
+ for (;;) {
120
+ if (buffer.length < 2) return
121
+ const fin = (buffer[0]! & 0x80) !== 0
122
+ const opcode = buffer[0]! & 0x0f
123
+ const masked = (buffer[1]! & 0x80) !== 0
124
+ let length = buffer[1]! & 0x7f
125
+ let offset = 2
126
+ if (length === 126) {
127
+ if (buffer.length < 4) return
128
+ length = buffer.readUInt16BE(2)
129
+ offset = 4
130
+ } else if (length === 127) {
131
+ if (buffer.length < 10) return
132
+ const big = buffer.readBigUInt64BE(2)
133
+ if (big > BigInt(WS_MAX_MESSAGE)) return finish("message too large")
134
+ length = Number(big)
135
+ offset = 10
136
+ }
137
+ if (length > WS_MAX_MESSAGE) return finish("message too large")
138
+ const maskAt = offset
139
+ if (masked) offset += 4
140
+ if (buffer.length < offset + length) return
141
+ let payload = buffer.subarray(offset, offset + length)
142
+ if (masked) {
143
+ const mask = buffer.subarray(maskAt, maskAt + 4)
144
+ payload = Buffer.from(payload.map((b, i) => b ^ mask[i & 3]!))
145
+ }
146
+ buffer = buffer.subarray(offset + length)
147
+ switch (opcode) {
148
+ case OP_PING:
149
+ write(OP_PONG, Buffer.from(payload))
150
+ break
151
+ case OP_PONG:
152
+ break
153
+ case OP_CLOSE:
154
+ write(OP_CLOSE, Buffer.from(payload.subarray(0, 2)))
155
+ return finish(payload.length > 2 ? payload.subarray(2).toString("utf8") : "closed by the server")
156
+ case OP_TEXT:
157
+ case OP_BINARY:
158
+ case OP_CONT: {
159
+ if (opcode !== OP_CONT) {
160
+ fragments = []
161
+ fragmentsLength = 0
162
+ fragmentText = opcode === OP_TEXT
163
+ }
164
+ fragments.push(Buffer.from(payload))
165
+ fragmentsLength += payload.length
166
+ if (fragmentsLength > WS_MAX_MESSAGE) return finish("message too large")
167
+ if (fin) {
168
+ const message = Buffer.concat(fragments)
169
+ fragments = []
170
+ fragmentsLength = 0
171
+ if (fragmentText) for (const cb of listeners) cb(message.toString("utf8"))
172
+ }
173
+ break
174
+ }
175
+ default:
176
+ return finish(`unknown WebSocket opcode ${opcode}`)
177
+ }
178
+ }
179
+ }
180
+
181
+ socket.on("data", (chunk: Buffer) => {
182
+ buffer = buffer.length ? Buffer.concat([buffer, chunk]) : chunk
183
+ parse()
184
+ })
185
+ socket.on("close", () => finish("connection closed"))
186
+ socket.on("error", (error) => finish(error.message))
187
+
188
+ return {
189
+ send(text) {
190
+ if (closed) throw new Error("WebSocket closed")
191
+ write(OP_TEXT, Buffer.from(text, "utf8"))
192
+ },
193
+ close() {
194
+ if (closed) return
195
+ write(OP_CLOSE, Buffer.from([0x03, 0xe8]))
196
+ socket.end()
197
+ finish("closed")
198
+ },
199
+ onMessage(cb) {
200
+ listeners.push(cb)
201
+ // Frames that came with the upgrade response wait for the first listener.
202
+ if (listeners.length === 1 && buffer.length) parse()
203
+ },
204
+ onClose: (cb) => void closers.push(cb),
205
+ }
206
+ }
@@ -0,0 +1,100 @@
1
+ // `hub codex [codex args…]`: runs Codex so the hub's Codex spoke can see its threads, without touching Codex's files
2
+ // (CODEX_HOME, default ~/.codex). It makes sure the per-user spoke service runs (starting `hub codex-spoke serve` in
3
+ // the background when none does), then runs codex with the shared app-server daemon turned on for this run
4
+ // (`--enable daemon_auto_start`: the TUI's threads live on the daemon, where the spoke attaches) and the user's
5
+ // arguments, its exit code passed through. On a machine that is not enrolled (no machine token) or when the service
6
+ // cannot be started, Codex runs as usual without the hub, and the command says why.
7
+ //
8
+ // Settings (environment): HUB_CODEX_BIN (default `codex` on PATH); see main.ts for the service's.
9
+ import { spawn } from "node:child_process"
10
+ import { closeSync, openSync } from "node:fs"
11
+ import path from "node:path"
12
+ import { configPath } from "../../../src/config.ts"
13
+ import { readSpokeCredentials } from "../../node/src/config.ts"
14
+ import { codexStateDir, ensurePrivateDir, readServicePid } from "./paths.ts"
15
+
16
+ const SERVICE_START_MS = 8000
17
+ /** Codex's own flag (a feature toggle for this run only; nothing is written). */
18
+ export const DAEMON_ARGS = ["--enable", "daemon_auto_start"] as const
19
+
20
+ type Env = Readonly<Record<string, string | undefined>>
21
+
22
+ export interface Program {
23
+ readonly execPath: string
24
+ readonly entry: string
25
+ }
26
+
27
+ export interface CodexIo {
28
+ readonly env: Env
29
+ readonly home: string
30
+ stderr(s: string): void
31
+ /** What runs the hub CLI (the service runs it too). */
32
+ readonly program: Program
33
+ }
34
+
35
+ /** Starts `hub codex-spoke serve` detached, logging to spoke.log in the spoke's state directory. */
36
+ function spawnService(io: CodexIo): void {
37
+ const dir = codexStateDir(io.env, io.home)
38
+ ensurePrivateDir(dir)
39
+ const log = openSync(path.join(dir, "spoke.log"), "a", 0o600)
40
+ try {
41
+ const child = spawn(io.program.execPath, [io.program.entry, "codex-spoke", "serve"], { detached: true, stdio: ["ignore", log, log], env: { ...io.env, BUN_BE_BUN: "1" } })
42
+ child.on("error", () => {})
43
+ child.unref()
44
+ } finally {
45
+ closeSync(log)
46
+ }
47
+ }
48
+
49
+ /** True when the spoke service runs (started here when needed); else false with the reason on stderr. */
50
+ async function ensureService(io: CodexIo): Promise<boolean> {
51
+ if (!readSpokeCredentials(io.env, io.home)) {
52
+ io.stderr(`hub codex: this machine is not enrolled with a hub (no machine token in ${configPath(io.env, io.home)}); running Codex without the hub\n`)
53
+ return false
54
+ }
55
+ if (readServicePid(io.env, io.home) !== undefined) return true
56
+ spawnService(io)
57
+ const deadline = Date.now() + SERVICE_START_MS
58
+ while (Date.now() < deadline) {
59
+ await new Promise((resolve) => setTimeout(resolve, 100))
60
+ if (readServicePid(io.env, io.home) !== undefined) return true
61
+ }
62
+ io.stderr(`hub codex: the Codex spoke did not start (see ${path.join(codexStateDir(io.env, io.home), "spoke.log")}); running Codex without the hub\n`)
63
+ return false
64
+ }
65
+
66
+ const SIGNALS: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }
67
+
68
+ /** Runs `bin` with the terminal; resolves with its exit code (128 + signal number when a signal ended it). */
69
+ function spawnCodex(bin: string, args: readonly string[], env: Env, stderr: (s: string) => void): Promise<number> {
70
+ return new Promise((resolve) => {
71
+ const child = spawn(bin, args, { stdio: "inherit", env: env as NodeJS.ProcessEnv })
72
+ // The terminal sends Ctrl-C to Codex too: it decides what that means; this process keeps waiting.
73
+ const ignore = () => {}
74
+ const forward = () => child.kill("SIGTERM")
75
+ process.on("SIGINT", ignore)
76
+ process.on("SIGTERM", forward)
77
+ const done = (code: number) => {
78
+ process.off("SIGINT", ignore)
79
+ process.off("SIGTERM", forward)
80
+ resolve(code)
81
+ }
82
+ child.on("error", (cause) => {
83
+ stderr(`hub codex: cannot run ${bin}: ${cause.message}\n`)
84
+ done(127)
85
+ })
86
+ child.on("exit", (code, signal) => done(code ?? (signal ? 128 + (SIGNALS[signal] ?? 0) : 1)))
87
+ })
88
+ }
89
+
90
+ export async function runCodex(args: readonly string[], io: CodexIo): Promise<number> {
91
+ let hub = false
92
+ try {
93
+ hub = await ensureService(io)
94
+ } catch (cause) {
95
+ io.stderr(`hub codex: ${cause instanceof Error ? cause.message : String(cause)}; running Codex without the hub\n`)
96
+ }
97
+ const env: Record<string, string | undefined> = { ...io.env }
98
+ delete env.BUN_BE_BUN
99
+ return spawnCodex(io.env.HUB_CODEX_BIN || "codex", hub ? [...DAEMON_ARGS, ...args] : [...args], env, io.stderr)
100
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@huynle/hub-node",
3
+ "version": "0.7.1",
4
+ "private": true,
5
+ "description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./package.json": "./package.json"
11
+ }
12
+ }