@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,281 @@
1
+ // The node daemon (design §6.3): one NodeConnection to the hub and the runtimes it offers. It serves the hub's
2
+ // prepare → (start → stop) → collect → cleanup (and cancel) for each launch, reports progress frames, and holds a
3
+ // slot per launch from prepare until cleanup (or a failed or cancelled prepare), sending a capacity frame whenever
4
+ // use changes. prepare goes to the runtime the request names; every later op to the runtime the prepared workspace
5
+ // names (workspace.runtime, absent = host). It announces the "sandbox" feature (start and stop) only when one of its
6
+ // runtimes starts harnesses (openshell); the host runtime answers start and stop with started/stopped false.
7
+ // It keeps launches in memory only: the later ops carry the prepared workspace, so a restarted node can still
8
+ // serve them. There is no remote exec channel: these ops are all the hub can ask.
9
+ import { randomUUID } from "node:crypto"
10
+ import { arch as osArch, platform as osPlatform } from "node:os"
11
+ import {
12
+ NODE_FEATURES,
13
+ NODE_MESSAGE_MAX,
14
+ preparedWorkspaceRuntime,
15
+ type NodeCapacity,
16
+ type NodeHello,
17
+ type NodeLaunchPhase,
18
+ type NodeLaunchReport,
19
+ type NodeRequest,
20
+ type NodeResultFor,
21
+ type NodeRuntimeInfo,
22
+ type PreparedWorkspace,
23
+ } from "../../protocol/src/index.ts"
24
+ import { RuntimeError, type Runtime } from "../../runtime-sdk/src/index.ts"
25
+ import pkg from "../package.json" with { type: "json" }
26
+ import type { NodeConfig, SpokeCredentials } from "./config.ts"
27
+ import { NodeConnection, type NodeConnectionOptions, type NodeConnectionState } from "./connection.ts"
28
+ import { HostRuntime } from "./host.ts"
29
+
30
+ export const NODE_VERSION: string = pkg.version
31
+
32
+ /** What the daemon needs of a connection (NodeConnection, or a fake in tests). */
33
+ export interface ConnectionLike {
34
+ start(): void
35
+ stop(): void
36
+ send(frame: Parameters<NodeConnection["send"]>[0]): boolean
37
+ }
38
+
39
+ export interface NodeDaemonOptions {
40
+ readonly config: NodeConfig
41
+ readonly credentials: SpokeCredentials
42
+ /** Default: the host runtime over the config's roots. */
43
+ readonly runtimes?: readonly Runtime[]
44
+ readonly instanceID?: string
45
+ /** The os and arch labels (default this machine's). */
46
+ readonly platform?: { readonly os: string; readonly arch: string }
47
+ readonly connect?: (options: NodeConnectionOptions) => ConnectionLike
48
+ readonly onState?: (state: NodeConnectionState, error?: string) => void
49
+ /** Backends per runtime kind (e.g. openshell: the gateway's compute drivers), added to the hello's runtimes. */
50
+ readonly backends?: Readonly<Record<string, readonly string[]>>
51
+ /** Launches found at startup (an earlier run's sandboxes): reported in the hello, each holding a slot until cleanup. */
52
+ readonly recovered?: readonly NodeLaunchReport[]
53
+ }
54
+
55
+ interface Tracked {
56
+ phase: NodeLaunchPhase
57
+ directory?: string
58
+ sandbox?: string
59
+ abort?: AbortController
60
+ }
61
+
62
+ /** A message fit for a progress frame: one line of printable text, at most NODE_MESSAGE_MAX characters. */
63
+ export function progressText(text: string): string | undefined {
64
+ const line = text.replace(/[\p{Cc}\p{Cf}\u2028\u2029]+/gu, " ").replace(/\s+/g, " ").trim()
65
+ return line ? line.slice(0, NODE_MESSAGE_MAX) : undefined
66
+ }
67
+
68
+ const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
69
+
70
+ export class NodeDaemon {
71
+ private readonly launches = new Map<string, Tracked>()
72
+ private readonly runtimes: readonly Runtime[]
73
+ private readonly instanceID: string
74
+ private readonly labels: Readonly<Record<string, string>>
75
+ private draining = false
76
+ private connection?: ConnectionLike
77
+
78
+ constructor(private readonly options: NodeDaemonOptions) {
79
+ this.runtimes = options.runtimes ?? [new HostRuntime({ roots: options.config.workspaceRoots })]
80
+ this.instanceID = options.instanceID ?? randomUUID()
81
+ const platform = options.platform ?? { os: osPlatform(), arch: osArch() }
82
+ this.labels = { os: platform.os, arch: platform.arch, ...options.config.labels }
83
+ for (const r of options.recovered ?? [])
84
+ this.launches.set(r.launchID, { phase: r.phase, ...(r.directory ? { directory: r.directory } : {}), ...(r.sandbox ? { sandbox: r.sandbox } : {}) })
85
+ }
86
+
87
+ start(): void {
88
+ if (this.connection) return
89
+ const connectionOptions: NodeConnectionOptions = {
90
+ server: this.options.credentials.server,
91
+ token: this.options.credentials.token,
92
+ hello: () => this.hello(),
93
+ handle: (req) => this.handle(req),
94
+ ...(this.options.onState ? { onState: this.options.onState } : {}),
95
+ }
96
+ this.connection = (this.options.connect ?? ((o) => new NodeConnection(o)))(connectionOptions)
97
+ this.connection.start()
98
+ }
99
+
100
+ stop(): void {
101
+ for (const launch of this.launches.values()) launch.abort?.abort()
102
+ this.connection?.stop()
103
+ this.connection = undefined
104
+ }
105
+
106
+ /** Takes no new launches (running ones finish); the hub learns it from a capacity frame. */
107
+ drain(): void {
108
+ this.draining = true
109
+ this.sendCapacity()
110
+ }
111
+
112
+ hello(): Omit<NodeHello, "t" | "v" | "role"> {
113
+ const features = NODE_FEATURES.filter((f) => f !== "sandbox" || this.servesSandbox())
114
+ return {
115
+ instanceID: this.instanceID,
116
+ nodeVersion: NODE_VERSION,
117
+ ...(features.length ? { features } : {}),
118
+ runtimes: this.runtimes.map((r) => this.runtimeInfo(r)),
119
+ labels: this.labels,
120
+ capacity: this.capacity(),
121
+ used: this.used(),
122
+ workspaceRoots: [...this.options.config.workspaceRoots],
123
+ draining: this.draining,
124
+ launches: [...this.launches].map(
125
+ ([launchID, t]): NodeLaunchReport => ({ launchID, phase: t.phase, ...(t.directory ? { directory: t.directory } : {}), ...(t.sandbox ? { sandbox: t.sandbox } : {}) }),
126
+ ),
127
+ }
128
+ }
129
+
130
+ async handle(req: NodeRequest): Promise<NodeResultFor[NodeRequest["op"]]> {
131
+ switch (req.op) {
132
+ case "prepare":
133
+ return this.prepare(req)
134
+ case "collect": {
135
+ const runtime = this.workspaceRuntime(req.workspace)
136
+ const before = this.launches.get(req.launchID)?.phase
137
+ this.phase(req.launchID, "collecting")
138
+ try {
139
+ const artifacts = await runtime.collect({ launchID: req.launchID, workspace: req.workspace })
140
+ this.phase(req.launchID, "collected")
141
+ return { artifacts }
142
+ } catch (cause) {
143
+ const tracked = this.launches.get(req.launchID)
144
+ if (tracked && before) tracked.phase = before
145
+ throw cause
146
+ }
147
+ }
148
+ case "cleanup": {
149
+ const runtime = this.workspaceRuntime(req.workspace)
150
+ this.phase(req.launchID, "cleaning")
151
+ try {
152
+ const result = await runtime.cleanup({ launchID: req.launchID, workspace: req.workspace }, req.policy)
153
+ this.progress(req.launchID, "cleaned", result.removed ? undefined : result.reason)
154
+ return result
155
+ } finally {
156
+ this.release(req.launchID)
157
+ }
158
+ }
159
+ case "cancel": {
160
+ const tracked = this.launches.get(req.launchID)
161
+ if (!tracked?.abort || tracked.phase !== "preparing") return { cancelled: false }
162
+ tracked.abort.abort()
163
+ return { cancelled: true }
164
+ }
165
+ case "start":
166
+ return this.startLaunch(req)
167
+ case "stop":
168
+ return this.stopLaunch(req)
169
+ }
170
+ }
171
+
172
+ private async startLaunch(req: Extract<NodeRequest, { op: "start" }>): Promise<NodeResultFor["start"]> {
173
+ const runtime = this.workspaceRuntime(req.workspace)
174
+ if (!runtime.start) return { started: false, reason: `the ${runtime.kind} runtime does not start harnesses (its sessions run in the machine's harness service)` }
175
+ const before = this.launches.get(req.launchID)?.phase
176
+ this.phase(req.launchID, "starting")
177
+ try {
178
+ const result = await runtime.start(req.workspace, { launchID: req.launchID, harness: req.harness, hub: req.hub, ...(req.env ? { env: req.env } : {}) }, {
179
+ progress: (message) => this.progress(req.launchID, "starting", message),
180
+ })
181
+ this.phase(req.launchID, "running")
182
+ return { started: result.started, ...(result.reason ? { reason: result.reason } : {}) }
183
+ } catch (cause) {
184
+ const tracked = this.launches.get(req.launchID)
185
+ if (tracked && before) tracked.phase = before
186
+ this.progress(req.launchID, "failed", errorText(cause))
187
+ throw cause
188
+ }
189
+ }
190
+
191
+ private async stopLaunch(req: Extract<NodeRequest, { op: "stop" }>): Promise<NodeResultFor["stop"]> {
192
+ const runtime = this.workspaceRuntime(req.workspace)
193
+ if (!runtime.stop) return { stopped: false, reason: `the ${runtime.kind} runtime runs no harness of its own to stop` }
194
+ const before = this.launches.get(req.launchID)?.phase
195
+ this.phase(req.launchID, "stopping")
196
+ try {
197
+ const result = await runtime.stop({ launchID: req.launchID, workspace: req.workspace }, req.mode ?? "graceful")
198
+ this.phase(req.launchID, result.stopped ? "stopped" : (before ?? "stopped"))
199
+ return { stopped: result.stopped, ...(result.reason ? { reason: result.reason } : {}) }
200
+ } catch (cause) {
201
+ const tracked = this.launches.get(req.launchID)
202
+ if (tracked && before) tracked.phase = before
203
+ throw cause
204
+ }
205
+ }
206
+
207
+ private async prepare(req: Extract<NodeRequest, { op: "prepare" }>): Promise<NodeResultFor["prepare"]> {
208
+ if (this.draining) throw new RuntimeError("this node is draining and takes no new launches")
209
+ if (this.launches.has(req.launchID)) throw new RuntimeError(`launch ${req.launchID} is already on this node`)
210
+ const runtime = this.runtimeFor(req.runtime.kind)
211
+ if (this.used().slots >= this.options.config.slots) throw new RuntimeError(`no free slot on this node (${this.options.config.slots} in use)`)
212
+ const abort = new AbortController()
213
+ this.launches.set(req.launchID, { phase: "preparing", abort })
214
+ this.sendCapacity()
215
+ this.progress(req.launchID, "preparing")
216
+ try {
217
+ const workspace = await runtime.prepare(
218
+ { launchID: req.launchID, harness: req.harness, runtime: req.runtime, workspace: req.workspace },
219
+ { signal: abort.signal, progress: (message) => this.progress(req.launchID, "preparing", message) },
220
+ )
221
+ if (abort.signal.aborted) throw new RuntimeError("prepare cancelled")
222
+ this.launches.set(req.launchID, { phase: "prepared", directory: workspace.directory, ...(workspace.runtime?.sandbox ? { sandbox: workspace.runtime.sandbox } : {}) })
223
+ this.progress(req.launchID, "prepared")
224
+ return { workspace }
225
+ } catch (cause) {
226
+ this.progress(req.launchID, abort.signal.aborted ? "cancelled" : "failed", abort.signal.aborted ? undefined : errorText(cause))
227
+ this.release(req.launchID)
228
+ throw abort.signal.aborted ? new RuntimeError("prepare cancelled") : cause
229
+ }
230
+ }
231
+
232
+ private runtimeFor(kind: string): Runtime {
233
+ const runtime = this.runtimes.find((r) => r.kind === kind)
234
+ if (!runtime) throw new RuntimeError(`runtime ${kind || "(none)"} is not available on this node`)
235
+ return runtime
236
+ }
237
+
238
+ /** The runtime that owns a prepared workspace: the one it names (absent: host). */
239
+ private workspaceRuntime(workspace: PreparedWorkspace): Runtime {
240
+ return this.runtimeFor(preparedWorkspaceRuntime(workspace).kind)
241
+ }
242
+
243
+ /** Whether a runtime here starts and stops harnesses itself (the "sandbox" feature). */
244
+ private servesSandbox(): boolean {
245
+ return this.runtimes.some((r) => typeof r.start === "function" && typeof r.stop === "function")
246
+ }
247
+
248
+ private runtimeInfo(runtime: Runtime): NodeRuntimeInfo {
249
+ const info = runtime.info()
250
+ const backends = this.options.backends?.[runtime.kind]
251
+ return backends?.length ? { ...info, backends: [...backends] } : info
252
+ }
253
+
254
+ private capacity(): NodeCapacity {
255
+ const { slots, memoryMB } = this.options.config
256
+ return { slots, ...(memoryMB !== undefined ? { memoryMB } : {}) }
257
+ }
258
+
259
+ private used(): NodeCapacity {
260
+ return { slots: this.launches.size }
261
+ }
262
+
263
+ private phase(launchID: string, phase: NodeLaunchPhase): void {
264
+ const tracked = this.launches.get(launchID)
265
+ if (tracked) tracked.phase = phase
266
+ this.progress(launchID, phase)
267
+ }
268
+
269
+ private release(launchID: string): void {
270
+ if (this.launches.delete(launchID)) this.sendCapacity()
271
+ }
272
+
273
+ private progress(launchID: string, phase: NodeLaunchPhase, message?: string): void {
274
+ const text = message === undefined ? undefined : progressText(message)
275
+ this.connection?.send({ t: "progress", launchID, phase, ...(text ? { message: text } : {}) })
276
+ }
277
+
278
+ private sendCapacity(): void {
279
+ this.connection?.send({ t: "capacity", capacity: this.capacity(), used: this.used(), draining: this.draining })
280
+ }
281
+ }
@@ -0,0 +1,107 @@
1
+ // The node's only way to run git (decision 10). Never a shell: spawn (execFile semantics, no shell) with an argv the caller builds with `--`
2
+ // before paths and refs. The environment is fixed: no terminal or credential-manager prompts (a daemon has no one to
3
+ // ask), only the https and ssh transports (never ext::, file:: or a local path as a remote), and none of the
4
+ // inherited GIT_* variables that redirect a repository, its config or git's own programs. Every run has a time limit
5
+ // and can be cancelled. node:child_process (not Bun.spawn) so the node also runs bundled for node.
6
+ import { spawn } from "node:child_process"
7
+
8
+ type Env = Readonly<Record<string, string | undefined>>
9
+
10
+ /** Transports git may use (GIT_ALLOW_PROTOCOL). */
11
+ export const ALLOWED_PROTOCOLS = "https:ssh"
12
+ export const GIT_TIMEOUT_MS = 120_000
13
+ const MAX_BUFFER = 16 * 1024 * 1024
14
+ const STDERR_MAX = 600
15
+
16
+ /** Inherited GIT_* names that only choose how ssh runs; every other GIT_* name is dropped. */
17
+ const KEPT_GIT_VARS = new Set(["GIT_SSH", "GIT_SSH_COMMAND", "GIT_SSH_VARIANT"])
18
+
19
+ export class GitError extends Error {
20
+ override readonly name = "GitError"
21
+ }
22
+
23
+ /** `base` without its GIT_* overrides, plus the hardened settings and then `overlay`. */
24
+ export function gitEnv(base: Env = process.env, overlay: Env = {}): Record<string, string> {
25
+ const env: Record<string, string> = {}
26
+ for (const [name, value] of Object.entries(base)) {
27
+ if (value === undefined) continue
28
+ if (name.startsWith("GIT_") && !KEPT_GIT_VARS.has(name)) continue
29
+ env[name] = value
30
+ }
31
+ Object.assign(env, { GIT_TERMINAL_PROMPT: "0", GIT_ALLOW_PROTOCOL: ALLOWED_PROTOCOLS, GCM_INTERACTIVE: "never", LC_ALL: "C" })
32
+ for (const [name, value] of Object.entries(overlay)) if (value !== undefined) env[name] = value
33
+ return env
34
+ }
35
+
36
+ /** Removes credentials (userinfo) from URLs in git output. */
37
+ export function redact(text: string): string {
38
+ return text.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1***@")
39
+ }
40
+
41
+ export interface GitOptions {
42
+ readonly cwd: string
43
+ readonly timeoutMs?: number
44
+ readonly signal?: AbortSignal
45
+ /** The environment to start from (default process.env); GIT_* overrides are dropped from it. */
46
+ readonly baseEnv?: Env
47
+ /** Applied after the hardening (tests use it to allow the file transport). */
48
+ readonly env?: Env
49
+ /** The git executable (default "git" on PATH). */
50
+ readonly binary?: string
51
+ }
52
+
53
+ /** Runs `git -c protocol.ext.allow=never <args>` and resolves with stdout; rejects with a GitError. */
54
+ export function runGit(args: readonly string[], options: GitOptions): Promise<string> {
55
+ const argv = ["-c", "protocol.ext.allow=never", ...args]
56
+ const what = `git ${args.find((a) => !a.startsWith("-")) ?? ""}`.trim()
57
+ return new Promise((resolve, reject) => {
58
+ if (options.signal?.aborted) return reject(new GitError(`${what} cancelled`))
59
+ let settled = false
60
+ const finish = (error?: GitError, stdout?: string) => {
61
+ if (settled) return
62
+ settled = true
63
+ clearTimeout(timer)
64
+ options.signal?.removeEventListener("abort", onAbort)
65
+ if (error) reject(error)
66
+ else resolve(stdout ?? "")
67
+ }
68
+ // Its own process group, so a timeout or cancel also kills what git started (ssh, git-remote-https).
69
+ const child = spawn(options.binary ?? "git", argv, {
70
+ cwd: options.cwd,
71
+ env: gitEnv(options.baseEnv ?? process.env, options.env),
72
+ stdio: ["ignore", "pipe", "pipe"],
73
+ detached: true,
74
+ windowsHide: true,
75
+ })
76
+ const out: Buffer[] = []
77
+ const err: Buffer[] = []
78
+ let size = 0
79
+ const collect = (into: Buffer[]) => (chunk: Buffer) => {
80
+ size += chunk.length
81
+ if (size > MAX_BUFFER) return stop("printed too much output")
82
+ into.push(chunk)
83
+ }
84
+ child.stdout.on("data", collect(out))
85
+ child.stderr.on("data", collect(err))
86
+ child.on("error", (cause) => finish(new GitError(`${what} failed: ${cause.message}`)))
87
+ child.on("close", (code, signal) => {
88
+ if (code === 0) return finish(undefined, Buffer.concat(out).toString("utf8"))
89
+ const detail = redact(Buffer.concat(err).toString("utf8").trim()).slice(0, STDERR_MAX) || (signal ? `killed by ${signal}` : `exit ${code}`)
90
+ finish(new GitError(`${what} failed: ${detail}`))
91
+ })
92
+ // Settles at once: a killed git's children may hold its output pipes open for a while.
93
+ function stop(why: string): void {
94
+ if (settled) return
95
+ try {
96
+ if (child.pid) process.kill(-child.pid, "SIGKILL")
97
+ } catch {
98
+ child.kill("SIGKILL")
99
+ }
100
+ finish(new GitError(`${what} ${why}`))
101
+ }
102
+ const timeoutMs = options.timeoutMs ?? GIT_TIMEOUT_MS
103
+ const timer = setTimeout(() => stop(`timed out after ${Math.round(timeoutMs / 1000) || 1} s`), timeoutMs)
104
+ const onAbort = () => stop("cancelled")
105
+ options.signal?.addEventListener("abort", onAbort, { once: true })
106
+ })
107
+ }
@@ -0,0 +1,60 @@
1
+ // Which harnesses the host runtime offers (the node hello's runtimes[host].harnesses, design §6.3). The host
2
+ // runtime starts nothing itself: a host launch's session is created by the machine's service spoke of its harness
3
+ // (placement also needs that spoke connected with "launch"), so this only says what can run here:
4
+ // - opencode: always (its service spoke is the OpenCode plugin, which the node cannot see);
5
+ // - claude-code: when the Claude Code CLI is found: HUB_CLAUDE_BIN (absolute, or a name on PATH), else `claude` on
6
+ // PATH; the Claude Code spoke daemon (`hub claude-code daemon`) creates its launch sessions;
7
+ // - node.json `harnesses` overrides detection either way (true offers a harness, false withholds it).
8
+ import { accessSync, constants, statSync } from "node:fs"
9
+ import path from "node:path"
10
+
11
+ type Env = Readonly<Record<string, string | undefined>>
12
+
13
+ /** The order harnesses are listed in; others follow in node.json's order. */
14
+ const KNOWN = ["opencode", "claude-code", "codex"]
15
+
16
+ export interface DetectHarnessesOptions {
17
+ /** The environment to look in (PATH, HUB_CLAUDE_BIN); default process.env. */
18
+ readonly env?: Env
19
+ /** node.json `harnesses`. */
20
+ readonly overrides?: Readonly<Record<string, boolean>>
21
+ }
22
+
23
+ function executable(file: string): boolean {
24
+ try {
25
+ if (!statSync(file).isFile()) return false
26
+ accessSync(file, constants.X_OK)
27
+ return true
28
+ } catch {
29
+ return false
30
+ }
31
+ }
32
+
33
+ /** The executable `name` runs as: an absolute path checked as it is, else the first match on PATH. */
34
+ export function findExecutable(name: string, env: Env): string | undefined {
35
+ if (!name) return undefined
36
+ if (path.isAbsolute(name)) return executable(name) ? name : undefined
37
+ if (name.includes("/")) return undefined
38
+ for (const dir of (env.PATH ?? "").split(path.delimiter)) {
39
+ if (!dir || !path.isAbsolute(dir)) continue
40
+ const file = path.join(dir, name)
41
+ if (executable(file)) return file
42
+ }
43
+ return undefined
44
+ }
45
+
46
+ /** The harnesses this machine's host runtime offers. */
47
+ export function detectHarnesses(options: DetectHarnessesOptions = {}): string[] {
48
+ const env = options.env ?? process.env
49
+ const found = new Set<string>(["opencode"])
50
+ if (findExecutable(env.HUB_CLAUDE_BIN || "claude", env)) found.add("claude-code")
51
+ for (const [name, on] of Object.entries(options.overrides ?? {})) {
52
+ if (on) found.add(name)
53
+ else found.delete(name)
54
+ }
55
+ const rank = (name: string) => {
56
+ const i = KNOWN.indexOf(name)
57
+ return i < 0 ? KNOWN.length : i
58
+ }
59
+ return [...found].sort((a, b) => rank(a) - rank(b))
60
+ }