@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,12 @@
1
+ {
2
+ "name": "@huynle/hub-codex",
3
+ "version": "0.7.1",
4
+ "private": true,
5
+ "description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (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
+ }
@@ -0,0 +1,44 @@
1
+ // One initialized connection to a Codex app-server: JSON-RPC over a channel, the initialize handshake (with the
2
+ // experimental API, which thread/inject_items and turn/steer's clientUserMessageId need) and the client's name.
3
+ import { JsonRpcPeer, type Channel, type PeerHandlers, type RpcID } from "./jsonrpc.ts"
4
+ import type { AppServerPort } from "./sessions.ts"
5
+
6
+ export interface AppServerConnection extends AppServerPort {
7
+ readonly peer: JsonRpcPeer
8
+ /** Resolves with the reason when the connection closed. */
9
+ readonly closed: Promise<string>
10
+ readonly isClosed: boolean
11
+ close(): void
12
+ }
13
+
14
+ export interface OpenOptions extends PeerHandlers {
15
+ /** The connection's name (keeps its JSON-RPC request IDs apart from another connection's). */
16
+ readonly name: string
17
+ readonly clientVersion: string
18
+ readonly timeoutMs?: number
19
+ }
20
+
21
+ const INIT_TIMEOUT_MS = 20_000
22
+
23
+ /** Opens the connection: initialize, then the initialized notification. Closes the channel when that fails. */
24
+ export async function openAppServer(channel: Channel, options: OpenOptions): Promise<AppServerConnection> {
25
+ const peer = new JsonRpcPeer(channel, options)
26
+ try {
27
+ await peer.request("initialize", { clientInfo: { name: "hub", title: "Hub", version: options.clientVersion }, capabilities: { experimentalApi: true } }, { timeoutMs: options.timeoutMs ?? INIT_TIMEOUT_MS })
28
+ } catch (cause) {
29
+ peer.close()
30
+ throw cause
31
+ }
32
+ peer.notify("initialized")
33
+ return {
34
+ name: options.name,
35
+ peer,
36
+ closed: peer.closed,
37
+ get isClosed() {
38
+ return peer.isClosed
39
+ },
40
+ request: (method: string, params?: unknown) => peer.request(method, params),
41
+ respond: (id: RpcID, result: unknown) => peer.respond(id, result),
42
+ close: () => peer.close(),
43
+ }
44
+ }
@@ -0,0 +1,28 @@
1
+ // Hub IDs for Codex's native IDs (decision fx04e42c point 2). A thread ID (UUIDv7) maps to the reversible
2
+ // ses_cx<32 hex> (spoke-sdk hubSessionID, namespace "cx"). Other IDs the hub sees (approval requests, items, turns,
3
+ // inbox entries) are plain IDs (protocol wire.ts ID).
4
+ import { randomBytes } from "node:crypto"
5
+ import { hubSessionID, nativeSessionID } from "../../spoke-sdk/src/index.ts"
6
+
7
+ /** The session namespace of Codex in hub session IDs. */
8
+ export const CODEX_NAMESPACE = "cx"
9
+
10
+ const PLAIN_MAX = 300
11
+
12
+ /** The hub session ID of a Codex thread. */
13
+ export const codexSessionID = (threadID: string): string => hubSessionID(CODEX_NAMESPACE, threadID)
14
+
15
+ /** The Codex thread a hub session ID stands for; undefined when it is not a Codex one. */
16
+ export const codexThreadID = (sessionID: string): string | undefined => nativeSessionID(CODEX_NAMESPACE, sessionID)
17
+
18
+ /** `raw` as a plain hub ID (letters, digits and _.:@/+~- only, at most 300 characters); a fresh one when empty. */
19
+ export function plainID(raw: string, prefix = "id"): string {
20
+ const clean = (typeof raw === "string" ? raw : "").replace(/[^A-Za-z0-9_.:@/+~-]/g, "_").slice(0, PLAIN_MAX)
21
+ return clean || `${prefix}_${randomBytes(8).toString("hex")}`
22
+ }
23
+
24
+ /**
25
+ * The hub's ID for an app-server request to clients (an approval): the connection's name and the JSON-RPC id, typed
26
+ * (n: number, s: string) so 0 and "0" stay apart. JSON-RPC ids are per connection.
27
+ */
28
+ export const requestKey = (connection: string, id: number | string): string => plainID(`per_${connection}_${typeof id === "number" ? "n" : "s"}${id}`)
@@ -0,0 +1,11 @@
1
+ // @huynle/hub-codex: the Codex app-server spoke (design §1.2, §13.3; decision fx04e42c point 5 as corrected by the
2
+ // P7-T0 spike, report v0hsdqhm). The spoke attaches to Codex's app-server as one of its clients: its own
3
+ // `codex app-server` over stdio for the sessions it starts, and the shared daemon's socket when present (the TUI's
4
+ // threads live there). `hub codex` runs the Codex TUI so its threads land on the daemon.
5
+ export * from "./ids.ts"
6
+ export * from "./jsonrpc.ts"
7
+ export * from "./stdio.ts"
8
+ export * from "./websocket.ts"
9
+ export * from "./sessions.ts"
10
+ export * from "./app-server.ts"
11
+ export * from "./spoke.ts"
@@ -0,0 +1,168 @@
1
+ // JSON-RPC as Codex's app-server speaks it (codex app-server, P7-T0 spike v0hsdqhm): one JSON object per message,
2
+ // without a "jsonrpc" member. Requests both ways: the client calls methods (thread/start, turn/start …) and the
3
+ // server asks its clients (approvals), each side numbering its own ids. Notifications carry the thread's events.
4
+ // The channel is any transport of whole text messages: lines over a child's stdio (stdio.ts) or WebSocket text frames
5
+ // over the shared daemon's Unix socket (websocket.ts).
6
+
7
+ /** A transport of whole text messages. */
8
+ export interface Channel {
9
+ send(text: string): void
10
+ close(): void
11
+ onMessage(cb: (text: string) => void): void
12
+ /** Called once, when the channel closed (either side). */
13
+ onClose(cb: (reason: string) => void): void
14
+ }
15
+
16
+ export type RpcID = number | string
17
+
18
+ export class RpcError extends Error {
19
+ constructor(
20
+ message: string,
21
+ readonly code: number,
22
+ readonly method?: string,
23
+ ) {
24
+ super(message)
25
+ this.name = "RpcError"
26
+ }
27
+ }
28
+
29
+ /** JSON-RPC's code for a request that could not be sent or answered (this side's own failures). */
30
+ export const LOCAL_ERROR = -32000
31
+
32
+ export interface PeerHandlers {
33
+ onNotification?(method: string, params: unknown): void
34
+ onRequest?(id: RpcID, method: string, params: unknown): void
35
+ }
36
+
37
+ const DEFAULT_TIMEOUT_MS = 60_000
38
+
39
+ interface Pending {
40
+ readonly method: string
41
+ resolve(value: unknown): void
42
+ reject(error: Error): void
43
+ readonly timer: ReturnType<typeof setTimeout>
44
+ }
45
+
46
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
47
+ const isID = (v: unknown): v is RpcID => typeof v === "number" || typeof v === "string"
48
+
49
+ export class JsonRpcPeer {
50
+ readonly closed: Promise<string>
51
+ #closedReason: string | undefined
52
+ #next = 0
53
+ readonly #pending = new Map<RpcID, Pending>()
54
+ readonly #channel: Channel
55
+ readonly #handlers: PeerHandlers
56
+
57
+ constructor(channel: Channel, handlers: PeerHandlers = {}) {
58
+ this.#channel = channel
59
+ this.#handlers = handlers
60
+ let done!: (reason: string) => void
61
+ this.closed = new Promise((resolve) => (done = resolve))
62
+ channel.onMessage((text) => this.#receive(text))
63
+ channel.onClose((reason) => {
64
+ if (this.#closedReason !== undefined) return
65
+ this.#closedReason = reason || "closed"
66
+ for (const [id, call] of this.#pending) {
67
+ clearTimeout(call.timer)
68
+ call.reject(new RpcError(`The app-server connection closed before it answered ${call.method} (${this.#closedReason})`, LOCAL_ERROR, call.method))
69
+ this.#pending.delete(id)
70
+ }
71
+ done(this.#closedReason)
72
+ })
73
+ }
74
+
75
+ get isClosed(): boolean {
76
+ return this.#closedReason !== undefined
77
+ }
78
+
79
+ request(method: string, params?: unknown, options: { readonly timeoutMs?: number } = {}): Promise<any> {
80
+ if (this.#closedReason !== undefined) return Promise.reject(new RpcError(`The app-server connection is closed (${this.#closedReason})`, LOCAL_ERROR, method))
81
+ const id = ++this.#next
82
+ return new Promise((resolve, reject) => {
83
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
84
+ const timer = setTimeout(() => {
85
+ this.#pending.delete(id)
86
+ reject(new RpcError(`${method}: the app-server did not answer within ${timeoutMs} ms`, LOCAL_ERROR, method))
87
+ }, timeoutMs)
88
+ this.#pending.set(id, { method, resolve, reject, timer })
89
+ this.#send(params === undefined ? { id, method } : { id, method, params })
90
+ })
91
+ }
92
+
93
+ notify(method: string, params?: unknown): void {
94
+ this.#send(params === undefined ? { method } : { method, params })
95
+ }
96
+
97
+ /** Answers a server request. */
98
+ respond(id: RpcID, result: unknown): void {
99
+ this.#send({ id, result })
100
+ }
101
+
102
+ respondError(id: RpcID, code: number, message: string): void {
103
+ this.#send({ id, error: { code, message } })
104
+ }
105
+
106
+ close(): void {
107
+ this.#channel.close()
108
+ }
109
+
110
+ #send(message: object): void {
111
+ if (this.#closedReason !== undefined) return
112
+ try {
113
+ this.#channel.send(JSON.stringify(message))
114
+ } catch {
115
+ // The channel reports its own close; a request then fails there.
116
+ }
117
+ }
118
+
119
+ #receive(text: string): void {
120
+ let message: unknown
121
+ try {
122
+ message = JSON.parse(text)
123
+ } catch {
124
+ return
125
+ }
126
+ if (!isObj(message)) return
127
+ if (typeof message.method === "string") {
128
+ if (isID(message.id)) this.#handlers.onRequest?.(message.id, message.method, message.params)
129
+ else this.#handlers.onNotification?.(message.method, message.params)
130
+ return
131
+ }
132
+ if (!isID(message.id)) return
133
+ const call = this.#pending.get(message.id)
134
+ if (!call) return
135
+ this.#pending.delete(message.id)
136
+ clearTimeout(call.timer)
137
+ if (isObj(message.error)) {
138
+ const code = typeof message.error.code === "number" ? message.error.code : LOCAL_ERROR
139
+ call.reject(new RpcError(`${call.method}: ${typeof message.error.message === "string" ? message.error.message : "error"}`, code, call.method))
140
+ } else call.resolve(message.result)
141
+ }
142
+ }
143
+
144
+ /** Two connected in-memory channels (tests, and in-process fakes). */
145
+ export function memoryChannelPair(): [Channel, Channel] {
146
+ type End = { listeners: ((t: string) => void)[]; closers: ((r: string) => void)[]; closed: boolean }
147
+ const ends: [End, End] = [
148
+ { listeners: [], closers: [], closed: false },
149
+ { listeners: [], closers: [], closed: false },
150
+ ]
151
+ const close = (reason: string) => {
152
+ for (const end of ends) {
153
+ if (end.closed) continue
154
+ end.closed = true
155
+ for (const cb of end.closers) cb(reason)
156
+ }
157
+ }
158
+ const channel = (self: End, other: End): Channel => ({
159
+ send(text) {
160
+ if (self.closed) throw new Error("channel closed")
161
+ for (const cb of other.listeners) cb(text)
162
+ },
163
+ close: () => close("closed"),
164
+ onMessage: (cb) => void self.listeners.push(cb),
165
+ onClose: (cb) => void self.closers.push(cb),
166
+ })
167
+ return [channel(ends[0], ends[1]), channel(ends[1], ends[0])]
168
+ }
@@ -0,0 +1,122 @@
1
+ // `hub codex-spoke serve`: the per-user Codex spoke service until SIGINT/SIGTERM (main.ts aborts io.signal), started
2
+ // in the background by `hub codex` and never typed by hand in normal use. It needs an enrolled machine (the machine
3
+ // token in the spoke's config.json, read-only), keeps a pid file in <hub state dir>/codex/spoke.pid (one service per
4
+ // user: a second one refuses while the first runs) and attaches to the shared Codex daemon's socket when present.
5
+ //
6
+ // Settings (environment): HUB_CODEX_BIN (default `codex` on PATH), HUB_CODEX_DAEMON_SOCKET (another socket, or
7
+ // "off"), HUB_CODEX_CONFIG (Codex config overrides for the app-server the spoke starts, one key=value per line,
8
+ // passed as -c; Codex's files are never written).
9
+ import { spawnSync } from "node:child_process"
10
+ import { openSync, closeSync, readFileSync, rmSync, writeSync } from "node:fs"
11
+ import { configPath } from "../../../src/config.ts"
12
+ import { readSpokeCredentials } from "../../node/src/config.ts"
13
+ import pkg from "../package.json" with { type: "json" }
14
+ import { codexStateDir, ensurePrivateDir, daemonSocketPath, pidFile, readServicePid } from "./paths.ts"
15
+ import { startCodexSpoke } from "./spoke.ts"
16
+
17
+ export const VERSION: string = pkg.version
18
+
19
+ export interface CodexMainIo {
20
+ readonly env: Readonly<Record<string, string | undefined>>
21
+ readonly home: string
22
+ stdout(s: string): void
23
+ stderr(s: string): void
24
+ readonly signal?: AbortSignal
25
+ }
26
+
27
+ export const CODEX_SPOKE_USAGE = "Usage: hub codex-spoke serve | install [--dry-run] | uninstall [--dry-run] | status"
28
+
29
+ /** `codex --version` → "0.160.0", or "" when it cannot be told. */
30
+ export function codexVersion(bin: string, env: CodexMainIo["env"]): string {
31
+ try {
32
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000, env: env as NodeJS.ProcessEnv })
33
+ return /\d+\.\d+\.\d+[\w.+-]*/.exec(r.stdout ?? "")?.[0]?.slice(0, 100) ?? ""
34
+ } catch {
35
+ return ""
36
+ }
37
+ }
38
+
39
+ /** HUB_CODEX_CONFIG's lines as `-c key=value` arguments. */
40
+ export function configOverrides(value: string | undefined): string[] {
41
+ return (value ?? "")
42
+ .split("\n")
43
+ .map((line) => line.trim())
44
+ .filter((line) => line !== "" && line.includes("="))
45
+ .flatMap((line) => ["-c", line])
46
+ }
47
+
48
+ /** Claims the pid file for this process; false when another live service holds it. */
49
+ function claimPidFile(file: string, env: CodexMainIo["env"], home: string): boolean {
50
+ for (let attempt = 0; attempt < 2; attempt++) {
51
+ try {
52
+ const fd = openSync(file, "wx", 0o600)
53
+ try {
54
+ writeSync(fd, `${process.pid}\n`)
55
+ } finally {
56
+ closeSync(fd)
57
+ }
58
+ return true
59
+ } catch (cause) {
60
+ if ((cause as NodeJS.ErrnoException).code !== "EEXIST") throw cause
61
+ if (readServicePid(env, home) !== undefined) return false
62
+ rmSync(file, { force: true }) // a stale file: its process is gone
63
+ }
64
+ }
65
+ return false
66
+ }
67
+
68
+ function releasePidFile(file: string): void {
69
+ try {
70
+ if (readFileSync(file, "utf8").trim() === String(process.pid)) rmSync(file, { force: true })
71
+ } catch {}
72
+ }
73
+
74
+ async function serve(io: CodexMainIo): Promise<number> {
75
+ const credentials = readSpokeCredentials(io.env, io.home)
76
+ if (!credentials) {
77
+ io.stderr(`hub codex-spoke serve: this machine is not enrolled with a hub (no machine token in ${configPath(io.env, io.home)})\n`)
78
+ return 1
79
+ }
80
+ const log = (line: string) => io.stderr(`[hub codex-spoke] ${new Date().toISOString()} ${line}\n`)
81
+ ensurePrivateDir(codexStateDir(io.env, io.home))
82
+ const file = pidFile(io.env, io.home)
83
+ if (!claimPidFile(file, io.env, io.home)) {
84
+ io.stderr(`hub codex-spoke serve: already running (pid ${readServicePid(io.env, io.home) ?? "?"})\n`)
85
+ return 1
86
+ }
87
+ try {
88
+ const codexBin = io.env.HUB_CODEX_BIN || "codex"
89
+ const env: Record<string, string | undefined> = { ...io.env }
90
+ delete env.BUN_BE_BUN
91
+ const daemonSocket = daemonSocketPath(io.env, io.home)
92
+ const running = startCodexSpoke({
93
+ server: credentials.server,
94
+ token: credentials.token,
95
+ harnessVersion: codexVersion(codexBin, env),
96
+ pluginVersion: VERSION,
97
+ directory: io.home,
98
+ codexBin,
99
+ codexArgs: configOverrides(io.env.HUB_CODEX_CONFIG),
100
+ env,
101
+ ...(daemonSocket ? { daemonSocket } : {}),
102
+ log,
103
+ })
104
+ log(daemonSocket ? `serving; the Codex daemon socket is ${daemonSocket}` : "serving; not attaching to a Codex daemon")
105
+ await new Promise<void>((resolve) => {
106
+ if (!io.signal) return
107
+ if (io.signal.aborted) return resolve()
108
+ io.signal.addEventListener("abort", () => resolve(), { once: true })
109
+ })
110
+ await running.stop()
111
+ return 0
112
+ } finally {
113
+ releasePidFile(file)
114
+ }
115
+ }
116
+
117
+ export async function codexMain(argv: readonly string[], io: CodexMainIo): Promise<number> {
118
+ const [command] = argv
119
+ if (command === "serve") return serve(io)
120
+ io.stderr(`${CODEX_SPOKE_USAGE}\n`)
121
+ return 2
122
+ }
@@ -0,0 +1,65 @@
1
+ // Where the Codex spoke looks and keeps its files. It only reads Codex's home (CODEX_HOME, default ~/.codex) to find
2
+ // the shared daemon's socket; its own files live in <hub state dir>/codex (the hub state dir is $HUB_STATE_DIR, else
3
+ // ~/.local/state/hub), a private (0700) directory holding the service's pid file (spoke.pid) and its log.
4
+ // $HUB_CODEX_DAEMON_SOCKET names another daemon socket, or "off" for none.
5
+ import { chmodSync, lstatSync, mkdirSync, readFileSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import path from "node:path"
8
+ import { hubEnv, type Env } from "../../../src/env.ts"
9
+ import { APP_DIR, stateBase } from "../../../src/migrate.ts"
10
+
11
+ export function codexHome(env: Env = process.env, home: string = homedir()): string {
12
+ return env.CODEX_HOME || path.join(home, ".codex")
13
+ }
14
+
15
+ /** The shared daemon's control socket, or undefined when attaching to it is turned off. */
16
+ export function daemonSocketPath(env: Env = process.env, home: string = homedir()): string | undefined {
17
+ const override = env.HUB_CODEX_DAEMON_SOCKET
18
+ if (override === "off") return undefined
19
+ return override || path.join(codexHome(env, home), "app-server-control", "app-server-control.sock")
20
+ }
21
+
22
+ export function codexStateDir(env: Env = process.env, home: string = homedir()): string {
23
+ return path.join(hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR), "codex")
24
+ }
25
+
26
+ export const pidFile = (env: Env = process.env, home: string = homedir()) => path.join(codexStateDir(env, home), "spoke.pid")
27
+
28
+ /** A usable process ID: an integer above 1 (0 and negative IDs signal process groups or every process). */
29
+ export const isPid = (n: unknown): n is number => typeof n === "number" && Number.isInteger(n) && n > 1
30
+
31
+ export function isAlive(pid: number): boolean {
32
+ if (!isPid(pid)) return false
33
+ try {
34
+ process.kill(pid, 0)
35
+ return true
36
+ } catch (cause) {
37
+ return (cause as NodeJS.ErrnoException).code === "EPERM"
38
+ }
39
+ }
40
+
41
+ /** The running service's pid from its pid file, or undefined when there is none or that process is gone. */
42
+ export function readServicePid(env: Env = process.env, home: string = homedir()): number | undefined {
43
+ let text: string
44
+ try {
45
+ text = readFileSync(pidFile(env, home), "utf8").trim()
46
+ } catch {
47
+ return undefined
48
+ }
49
+ const pid = /^\d+$/.test(text) ? Number(text) : NaN
50
+ return isPid(pid) && isAlive(pid) ? pid : undefined
51
+ }
52
+
53
+ /**
54
+ * Makes `dir` (and its parents) and checks it is a real directory of this user that nobody else can reach: a
55
+ * symlink or someone else's directory is refused, group or other permissions are removed (0700).
56
+ */
57
+ export function ensurePrivateDir(dir: string): void {
58
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
59
+ const stat = lstatSync(dir)
60
+ if (stat.isSymbolicLink()) throw new Error(`${dir} is a symlink; the Codex spoke needs a real directory there`)
61
+ if (!stat.isDirectory()) throw new Error(`${dir} is not a directory`)
62
+ const uid = process.getuid?.()
63
+ if (uid !== undefined && stat.uid !== uid) throw new Error(`${dir} belongs to another user`)
64
+ if ((stat.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
65
+ }