@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,319 @@
1
+ // The host runtime (design §6.4): the session runs in the machine's long-running harness service, which the hub
2
+ // asks to create it in the directory this runtime prepares. So the host runtime only makes, inspects and removes
3
+ // workspaces; it starts nothing.
4
+ //
5
+ // Workspaces: `worktree` (<repo>/.worktrees/<branch>, reused when the branch already has a worktree), `checkout`
6
+ // (the repository itself, switched to the branch) and `clone` (a fresh clone at `repo`). Every path is resolved
7
+ // (realpath, so through symlinks) and must stay under the node's workspace roots. Git runs through runGit only
8
+ // (no shell, `--` before paths, https/ssh only), and every name reaching it passed the protocol's ref and remote
9
+ // checks first, so none can read as an option.
10
+ //
11
+ // Cleanup never forces: uncommitted or unpushed work, a reused workspace, a checkout, or a workspace another launch
12
+ // made (each workspace this runtime makes carries a hub-launch marker in its git directory) keeps it, with a reason.
13
+ // Handles come from the hub, so collect and cleanup re-check everything.
14
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
15
+ import path from "node:path"
16
+ import {
17
+ isAbsolutePath,
18
+ isRefName,
19
+ launchWorkspaceProblem,
20
+ preparedWorkspaceProblem,
21
+ type CleanupPolicy,
22
+ type LaunchArtifacts,
23
+ type NodeRuntimeInfo,
24
+ type PreparedWorkspace,
25
+ } from "../../protocol/src/index.ts"
26
+ import { RuntimeError, type CleanupResult, type PrepareInput, type RunHandle, type Runtime, type RuntimeContext } from "../../runtime-sdk/src/index.ts"
27
+ import { GitError, runGit, type GitOptions } from "./git.ts"
28
+
29
+ /** The file in a workspace's git directory naming the launch that made it. */
30
+ export const LAUNCH_MARKER = "hub-launch"
31
+
32
+ export interface HostRuntimeOptions {
33
+ /** node.json workspaceRoots. */
34
+ readonly roots: readonly string[]
35
+ /** Harnesses whose service runs on this machine (default opencode). */
36
+ readonly harnesses?: readonly string[]
37
+ /** Git settings (environment, binary, time limit); tests use them, the daemon passes none. */
38
+ readonly git?: Omit<GitOptions, "cwd" | "signal">
39
+ }
40
+
41
+ /** `p` with every existing part resolved through symlinks; refuses a dangling symlink on the way. */
42
+ function resolvePath(p: string): string {
43
+ const rest: string[] = []
44
+ let current = p
45
+ for (;;) {
46
+ try {
47
+ return path.join(realpathSync(current), ...rest.reverse())
48
+ } catch (cause) {
49
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw new RuntimeError(`cannot resolve ${p}: ${(cause as Error).message}`)
50
+ let link = false
51
+ try {
52
+ link = lstatSync(current).isSymbolicLink()
53
+ } catch {}
54
+ if (link) throw new RuntimeError(`${p} goes through a dangling symlink`)
55
+ const parent = path.dirname(current)
56
+ if (parent === current) throw new RuntimeError(`cannot resolve ${p}`)
57
+ rest.push(path.basename(current))
58
+ current = parent
59
+ }
60
+ }
61
+ }
62
+
63
+ /** The real path of `p` when it is (or would be) under one of `roots`; throws a RuntimeError otherwise. */
64
+ export function resolveUnderRoots(p: string, roots: readonly string[]): string {
65
+ if (roots.length === 0) throw new RuntimeError("this node has no workspaceRoots in node.json, so it prepares no workspaces")
66
+ if (!isAbsolutePath(p)) throw new RuntimeError(`${p} is not an absolute path`)
67
+ const real = resolvePath(p)
68
+ for (const root of roots) {
69
+ let realRoot: string
70
+ try {
71
+ realRoot = realpathSync(root)
72
+ } catch {
73
+ continue
74
+ }
75
+ if (real === realRoot || real.startsWith(realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep)) return real
76
+ }
77
+ throw new RuntimeError(`${p} is not under this node's workspace roots`)
78
+ }
79
+
80
+ /** A branch name for a launch without one: launch/<launchID>, with characters a ref may not hold replaced. */
81
+ export function launchBranch(launchID: string): string {
82
+ const name = launchID.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").replace(/\.{2,}/g, ".").slice(0, 100) || "launch"
83
+ return `launch/${name.endsWith(".lock") ? `${name}-` : name}`
84
+ }
85
+
86
+ const isDirectory = (p: string) => {
87
+ try {
88
+ return statSync(p).isDirectory()
89
+ } catch {
90
+ return false
91
+ }
92
+ }
93
+
94
+ export class HostRuntime implements Runtime {
95
+ readonly kind = "host"
96
+ private readonly roots: readonly string[]
97
+ private readonly harnesses: readonly string[]
98
+
99
+ constructor(private readonly options: HostRuntimeOptions) {
100
+ this.roots = options.roots
101
+ this.harnesses = options.harnesses ?? ["opencode"]
102
+ }
103
+
104
+ info(): NodeRuntimeInfo {
105
+ return { kind: this.kind, harnesses: [...this.harnesses] }
106
+ }
107
+
108
+ // ---------- prepare ----------
109
+
110
+ async prepare(input: PrepareInput, ctx: RuntimeContext = {}): Promise<PreparedWorkspace> {
111
+ if (ctx.signal?.aborted) throw new RuntimeError("prepare cancelled")
112
+ if (input.runtime?.kind !== this.kind) throw new RuntimeError(`this runtime is host, not ${String(input.runtime?.kind)}`)
113
+ if (!this.harnesses.includes(input.harness)) throw new RuntimeError(`harness ${input.harness} does not run on this node (it has ${this.harnesses.join(", ")})`)
114
+ const problem = launchWorkspaceProblem(input.workspace)
115
+ if (problem) throw new RuntimeError(problem)
116
+ const ws = input.workspace
117
+ if (ws.mode === "clone") return this.prepareClone(input, ctx)
118
+ const repo = await this.repository(ws.repo, ctx)
119
+ return ws.mode === "worktree" ? this.prepareWorktree(input, repo, ctx) : this.prepareCheckout(input, repo, ctx)
120
+ }
121
+
122
+ private async prepareWorktree(input: PrepareInput, repo: string, ctx: RuntimeContext): Promise<PreparedWorkspace> {
123
+ const { base } = input.workspace
124
+ const branch = input.workspace.branch ?? launchBranch(input.launchID)
125
+ await this.git(repo, ["check-ref-format", "--branch", branch], ctx)
126
+ const existing = (await this.worktrees(repo, ctx)).get(`refs/heads/${branch}`)
127
+ if (existing) {
128
+ const directory = resolveUnderRoots(existing, this.roots)
129
+ return this.prepared(repo, directory, "worktree", branch, base, true, ctx)
130
+ }
131
+ const directory = resolveUnderRoots(path.join(repo, ".worktrees", ...branch.split("/")), this.roots)
132
+ if (existsSync(directory)) throw new RuntimeError(`${directory} exists and is not the worktree of ${branch}`)
133
+ if (base) await this.git(repo, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`], ctx, `base ${base} is not a commit in ${repo}`)
134
+ ctx.progress?.(`creating worktree ${branch}`)
135
+ if (await this.hasBranch(repo, branch, ctx)) await this.git(repo, ["worktree", "add", "--", directory, branch], ctx)
136
+ else await this.git(repo, ["worktree", "add", "-b", branch, "--", directory, base ?? "HEAD"], ctx)
137
+ await this.mark(directory, input.launchID, ctx)
138
+ return this.prepared(repo, directory, "worktree", branch, base, false, ctx)
139
+ }
140
+
141
+ private async prepareCheckout(input: PrepareInput, repo: string, ctx: RuntimeContext): Promise<PreparedWorkspace> {
142
+ const { branch, base } = input.workspace
143
+ if (branch) {
144
+ await this.git(repo, ["check-ref-format", "--branch", branch], ctx)
145
+ if (base) await this.git(repo, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`], ctx, `base ${base} is not a commit in ${repo}`)
146
+ ctx.progress?.(`switching to ${branch}`)
147
+ if (await this.hasBranch(repo, branch, ctx)) await this.git(repo, ["switch", branch], ctx)
148
+ else await this.git(repo, ["switch", "-c", branch, base ?? "HEAD"], ctx)
149
+ }
150
+ return this.prepared(repo, repo, "checkout", (await this.currentBranch(repo, ctx)) ?? branch, base, true, ctx)
151
+ }
152
+
153
+ private async prepareClone(input: PrepareInput, ctx: RuntimeContext): Promise<PreparedWorkspace> {
154
+ const { branch, base, remote } = input.workspace
155
+ const target = resolveUnderRoots(input.workspace.repo, this.roots)
156
+ if (existsSync(target) || isLink(target)) throw new RuntimeError(`${target} exists; a clone needs a new directory`)
157
+ const parent = path.dirname(target)
158
+ mkdirSync(parent, { recursive: true })
159
+ // The parent was created through resolved paths, but check again before git writes there.
160
+ if (resolveUnderRoots(parent, this.roots) !== parent) throw new RuntimeError(`${parent} moved while preparing`)
161
+ ctx.progress?.("cloning")
162
+ await this.git(parent, ["clone", "--quiet", ...(base ? ["--branch", base] : []), "--", remote as string, target], ctx)
163
+ if (branch) {
164
+ await this.git(target, ["check-ref-format", "--branch", branch], ctx)
165
+ const current = await this.currentBranch(target, ctx)
166
+ if (current !== branch) {
167
+ const remoteBranch = await this.succeeds(target, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`], ctx)
168
+ await this.git(target, remoteBranch ? ["switch", branch] : ["switch", "-c", branch], ctx)
169
+ }
170
+ }
171
+ await this.mark(target, input.launchID, ctx)
172
+ return this.prepared(target, target, "clone", (await this.currentBranch(target, ctx)) ?? branch, base, false, ctx)
173
+ }
174
+
175
+ /** The real path of a repository's top level under the roots. */
176
+ private async repository(p: string, ctx: RuntimeContext): Promise<string> {
177
+ const repo = resolveUnderRoots(p, this.roots)
178
+ if (!isDirectory(repo)) throw new RuntimeError(`${repo} does not exist`)
179
+ const top = (await this.git(repo, ["rev-parse", "--show-toplevel"], ctx, `${repo} is not a git repository`)).trim()
180
+ if (resolvePath(top) !== repo) throw new RuntimeError(`${repo} must be the repository's top level (${top})`)
181
+ return repo
182
+ }
183
+
184
+ private async prepared(repo: string, directory: string, mode: PreparedWorkspace["mode"], branch: string | undefined, base: string | undefined, reused: boolean, ctx: RuntimeContext): Promise<PreparedWorkspace> {
185
+ const head = (await this.git(directory, ["rev-parse", "HEAD"], ctx)).trim()
186
+ return { repo, directory, mode, ...(branch ? { branch } : {}), ...(base ? { base } : {}), head, reused }
187
+ }
188
+
189
+ // ---------- collect ----------
190
+
191
+ async collect(handle: RunHandle, ctx: RuntimeContext = {}): Promise<LaunchArtifacts> {
192
+ const ws = this.checkHandle(handle)
193
+ const directory = resolveUnderRoots(ws.directory, this.roots)
194
+ if (!isDirectory(directory)) throw new RuntimeError(`${directory} is gone`)
195
+ const branch = await this.currentBranch(directory, ctx)
196
+ const head = (await this.git(directory, ["rev-parse", "HEAD"], ctx)).trim()
197
+ const dirty = await this.isDirty(directory, ctx)
198
+ let commits: number | undefined
199
+ if (ws.base) {
200
+ for (const from of [ws.base, `origin/${ws.base}`]) {
201
+ const out = await this.output(directory, ["rev-list", "--count", `${from}..HEAD`], ctx)
202
+ if (out !== undefined && /^\d+$/.test(out.trim())) {
203
+ commits = Number(out.trim())
204
+ break
205
+ }
206
+ }
207
+ }
208
+ return { ...(branch ? { branch } : {}), ...(ws.base ? { base: ws.base } : {}), head, ...(commits !== undefined ? { commits } : {}), dirty }
209
+ }
210
+
211
+ // ---------- cleanup ----------
212
+
213
+ async cleanup(handle: RunHandle, policy: CleanupPolicy, ctx: RuntimeContext = {}): Promise<CleanupResult> {
214
+ const ws = this.checkHandle(handle)
215
+ if (policy === "keep") return { removed: false, reason: "cleanup policy is keep" }
216
+ if (ws.reused) return { removed: false, reason: "the workspace existed before this launch" }
217
+ if (ws.mode === "checkout") return { removed: false, reason: "a checkout is the repository itself" }
218
+ const directory = resolveUnderRoots(ws.directory, this.roots)
219
+ if (!isDirectory(directory)) return { removed: false, reason: "the workspace is already gone" }
220
+ if (this.roots.some((root) => resolvePath(root) === directory)) return { removed: false, reason: "the workspace is a workspace root" }
221
+ if ((await this.marker(directory, ctx)) !== handle.launchID) return { removed: false, reason: "the workspace was not made by this launch" }
222
+ if (await this.isDirty(directory, ctx)) return { removed: false, reason: "the workspace has uncommitted changes" }
223
+ if (ws.mode === "worktree") {
224
+ const repo = resolveUnderRoots(ws.repo, this.roots)
225
+ if (directory === repo) return { removed: false, reason: "the workspace is the repository's main worktree" }
226
+ ctx.progress?.("removing worktree")
227
+ await this.git(repo, ["worktree", "remove", "--", directory], ctx)
228
+ return { removed: true }
229
+ }
230
+ if (directory !== resolveUnderRoots(ws.repo, this.roots)) return { removed: false, reason: "a clone's directory must be its repo" }
231
+ const unpushed = (await this.output(directory, ["rev-list", "--count", "--branches", "--not", "--remotes"], ctx))?.trim()
232
+ if (unpushed !== "0") return { removed: false, reason: unpushed ? `the clone has ${unpushed} unpushed commits` : "the clone has unpushed commits" }
233
+ ctx.progress?.("removing clone")
234
+ rmSync(directory, { recursive: true })
235
+ return { removed: true }
236
+ }
237
+
238
+ private checkHandle(handle: RunHandle): PreparedWorkspace {
239
+ const problem = preparedWorkspaceProblem(handle?.workspace)
240
+ if (problem) throw new RuntimeError(problem)
241
+ return handle.workspace
242
+ }
243
+
244
+ // ---------- git helpers ----------
245
+
246
+ /** Runs git; a failure becomes a RuntimeError (with `message` instead of git's, when given). */
247
+ private async git(cwd: string, args: readonly string[], ctx: RuntimeContext, message?: string): Promise<string> {
248
+ try {
249
+ return await runGit(args, { ...this.options.git, cwd, ...(ctx.signal ? { signal: ctx.signal } : {}) })
250
+ } catch (cause) {
251
+ if (cause instanceof GitError) throw new RuntimeError(message && !/cancelled|timed out/.test(cause.message) ? message : cause.message)
252
+ throw cause
253
+ }
254
+ }
255
+
256
+ /** stdout, or undefined when git fails (cancellation still throws). */
257
+ private async output(cwd: string, args: readonly string[], ctx: RuntimeContext): Promise<string | undefined> {
258
+ try {
259
+ return await this.git(cwd, args, ctx)
260
+ } catch (cause) {
261
+ if (ctx.signal?.aborted) throw cause
262
+ return undefined
263
+ }
264
+ }
265
+
266
+ private async succeeds(cwd: string, args: readonly string[], ctx: RuntimeContext): Promise<boolean> {
267
+ return (await this.output(cwd, args, ctx)) !== undefined
268
+ }
269
+
270
+ private hasBranch(repo: string, branch: string, ctx: RuntimeContext): Promise<boolean> {
271
+ return this.succeeds(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], ctx)
272
+ }
273
+
274
+ private async currentBranch(dir: string, ctx: RuntimeContext): Promise<string | undefined> {
275
+ const out = (await this.output(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"], ctx))?.trim()
276
+ return out && isRefName(out) ? out : undefined
277
+ }
278
+
279
+ private async isDirty(dir: string, ctx: RuntimeContext): Promise<boolean> {
280
+ return (await this.git(dir, ["status", "--porcelain", "--untracked-files=all"], ctx)).trim().length > 0
281
+ }
282
+
283
+ /** Worktree paths by checked-out ref (refs/heads/<branch>). */
284
+ private async worktrees(repo: string, ctx: RuntimeContext): Promise<Map<string, string>> {
285
+ const out = await this.git(repo, ["worktree", "list", "--porcelain"], ctx)
286
+ const byRef = new Map<string, string>()
287
+ for (const block of out.split(/\n\n+/)) {
288
+ const lines = block.split("\n")
289
+ const dir = lines.find((l) => l.startsWith("worktree "))?.slice("worktree ".length)
290
+ const ref = lines.find((l) => l.startsWith("branch "))?.slice("branch ".length)
291
+ if (dir && ref && !lines.some((l) => l.startsWith("prunable"))) byRef.set(ref, dir)
292
+ }
293
+ return byRef
294
+ }
295
+
296
+ private async markerFile(dir: string, ctx: RuntimeContext): Promise<string> {
297
+ return path.join((await this.git(dir, ["rev-parse", "--absolute-git-dir"], ctx)).trim(), LAUNCH_MARKER)
298
+ }
299
+
300
+ private async mark(dir: string, launchID: string, ctx: RuntimeContext): Promise<void> {
301
+ writeFileSync(await this.markerFile(dir, ctx), `${launchID}\n`, { mode: 0o600 })
302
+ }
303
+
304
+ private async marker(dir: string, ctx: RuntimeContext): Promise<string | undefined> {
305
+ try {
306
+ return readFileSync(await this.markerFile(dir, ctx), "utf8").trim()
307
+ } catch {
308
+ return undefined
309
+ }
310
+ }
311
+ }
312
+
313
+ function isLink(p: string): boolean {
314
+ try {
315
+ return lstatSync(p).isSymbolicLink()
316
+ } catch {
317
+ return false
318
+ }
319
+ }
@@ -0,0 +1,17 @@
1
+ // `hub node` (design §6.3): the per-machine daemon. It connects out to the hub's /spoke/ws with the machine's
2
+ // token and a node hello, and serves prepare → collect → cleanup (and cancel) for each launch through its runtimes.
3
+ // Run it with `bun packages/node/src/main.ts` (or the units in scripts/); embed it with runNode or NodeDaemon.
4
+ export { DEFAULT_SLOTS, NODE_CONFIG_FILE, nodeConfigPath, normalizeNodeConfig, readNodeConfig, readSpokeCredentials } from "./config.ts"
5
+ export type { NodeConfig, NodeConfigResult, OpenShellNodeConfig, SpokeCredentials } from "./config.ts"
6
+ export { NodeConnection, UNSUPPORTED_RETRY_MS, wsURL } from "./connection.ts"
7
+ export type { NodeConnectionOptions, NodeConnectionState } from "./connection.ts"
8
+ export { NODE_VERSION, NodeDaemon, progressText } from "./daemon.ts"
9
+ export type { NodeDaemonOptions } from "./daemon.ts"
10
+ export { ALLOWED_PROTOCOLS, GitError, gitEnv, runGit } from "./git.ts"
11
+ export { detectHarnesses, findExecutable } from "./harnesses.ts"
12
+ export type { DetectHarnessesOptions } from "./harnesses.ts"
13
+ export { HostRuntime, LAUNCH_MARKER, launchBranch, resolveUnderRoots } from "./host.ts"
14
+ export type { HostRuntimeOptions } from "./host.ts"
15
+ export { EXIT_CONFIG, runNode } from "./run.ts"
16
+ export { createOpenShellRuntime, openshellBackends, PROBE_TIMEOUT_MS, sweepSandboxes } from "./openshell.ts"
17
+ export type { CreateOpenShellRuntimeOptions } from "./openshell.ts"
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ // Entry of `hub node`: `bun packages/node/src/main.ts` (what the launchd and systemd units run), or the same file
3
+ // bundled for node. SIGINT and SIGTERM stop the daemon cleanly (exit 0). See run.ts for the program.
4
+ import { runNode } from "./run.ts"
5
+
6
+ const controller = new AbortController()
7
+ for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort())
8
+
9
+ process.exitCode = await runNode({ signal: controller.signal })
10
+ // Nothing else (sockets, timers) may keep a stopped node alive.
11
+ process.exit()
@@ -0,0 +1,73 @@
1
+ // The node's openshell wiring (design §6.3, §6.4): node.json's `openshell` section turned into an OpenShellRuntime,
2
+ // the backends the hello reports for it (the gateway's compute drivers, e.g. "vm"; best effort) and the startup
3
+ // sweep: sandboxes labelled hub.machine=<this machine> in the OpenShell workspace `hub` are launches an earlier run
4
+ // left (a crash, a restart), reported in the hello so the hub reconciles them. Only sandboxes whose name is the
5
+ // deterministic name of their hub.launch label count: anything else (e.g. `demo`) is not the node's.
6
+ import { token } from "../../protocol/src/wire.ts"
7
+ import type { NodeLaunchReport } from "../../protocol/src/index.ts"
8
+ import type { OpenShellClient, Sandbox } from "../../runtime-openshell/src/cli.ts"
9
+ import { labelValue, OpenShellRuntime, sandboxName } from "../../runtime-openshell/src/runtime.ts"
10
+ import type { OpenShellNodeConfig } from "./config.ts"
11
+
12
+ /** How long the startup probes (gateway info, the sweep) may take each. */
13
+ export const PROBE_TIMEOUT_MS = 15_000
14
+ const BACKENDS_MAX = 32
15
+
16
+ export interface CreateOpenShellRuntimeOptions {
17
+ readonly client: OpenShellClient
18
+ readonly roots: readonly string[]
19
+ /** The hub URL as the node knows it (config.json `server`). */
20
+ readonly hubURL: string
21
+ readonly openshell: OpenShellNodeConfig
22
+ readonly machineID?: string
23
+ }
24
+
25
+ export function createOpenShellRuntime(options: CreateOpenShellRuntimeOptions): OpenShellRuntime {
26
+ const { client, roots, hubURL, openshell, machineID } = options
27
+ return new OpenShellRuntime({
28
+ client,
29
+ roots,
30
+ hubURL,
31
+ profiles: openshell.profiles,
32
+ harnesses: ["opencode"],
33
+ ...(openshell.image ? { image: openshell.image } : {}),
34
+ ...(machineID ? { machineID } : {}),
35
+ })
36
+ }
37
+
38
+ const probe = (): { signal: AbortSignal } => ({ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
39
+ const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
40
+
41
+ /** The gateway's compute drivers as hello backends; [] when the client cannot tell or the gateway does not answer. */
42
+ export async function openshellBackends(client: Pick<OpenShellClient, "gatewayInfo">): Promise<string[]> {
43
+ if (typeof client.gatewayInfo !== "function") return []
44
+ try {
45
+ const { computeDrivers } = await client.gatewayInfo(probe())
46
+ return [...new Set(computeDrivers.filter((d) => token(d, 64)))].slice(0, BACKENDS_MAX)
47
+ } catch {
48
+ return []
49
+ }
50
+ }
51
+
52
+ /** A sandbox's launch phase for the hello: running while it is (or is becoming) Ready, else stopped. */
53
+ function launchPhase(sandbox: Sandbox): NodeLaunchReport["phase"] {
54
+ return sandbox.phase === "Ready" || sandbox.phase === "Provisioning" || sandbox.phase === "Starting" ? "running" : "stopped"
55
+ }
56
+
57
+ /** This machine's sandboxes in the hub workspace, as launches; a failing list is a problem line, not an error. */
58
+ export async function sweepSandboxes(client: OpenShellClient, machineID: string): Promise<{ launches: NodeLaunchReport[]; problems: string[] }> {
59
+ let sandboxes: Sandbox[]
60
+ try {
61
+ sandboxes = await client.list({ "hub.machine": labelValue(machineID) }, probe())
62
+ } catch (cause) {
63
+ return { launches: [], problems: [`could not list this machine's OpenShell sandboxes: ${errorText(cause)}`] }
64
+ }
65
+ const launches: NodeLaunchReport[] = []
66
+ for (const sandbox of sandboxes) {
67
+ const launchID = sandbox.labels["hub.launch"]
68
+ if (!launchID || sandboxName(launchID) !== sandbox.name) continue
69
+ if (sandbox.labels["hub.machine"] !== labelValue(machineID) || sandbox.phase === "Deleting") continue
70
+ launches.push({ launchID, phase: launchPhase(sandbox), sandbox: sandbox.name })
71
+ }
72
+ return { launches, problems: [] }
73
+ }
@@ -0,0 +1,106 @@
1
+ // `hub node`'s program: read the machine's credentials from the spoke's config.json (read-only) and the node's
2
+ // settings from node.json, then run the daemon until the signal aborts. With node.json `openshell.enabled` the node
3
+ // also offers the openshell runtime (sandboxes through the openshell CLI): it checks the CLI, asks the gateway for
4
+ // its backends and sweeps this machine's sandboxes from an earlier run into the hello; an unusable CLI is logged and
5
+ // the node runs with the host runtime only. The host runtime offers the harnesses found on this machine
6
+ // (harnesses.ts: opencode, claude-code with its CLI, node.json `harnesses` overriding). Exit codes: 0 after a clean stop, 1 when
7
+ // the machine is not enrolled, EXIT_CONFIG (78, EX_CONFIG) when node.json gives the node nothing to do; the
8
+ // service units do not restart on 78.
9
+ import { homedir } from "node:os"
10
+ import type { NodeLaunchReport } from "../../protocol/src/index.ts"
11
+ import { OpenShellCli } from "../../runtime-openshell/src/cli.ts"
12
+ import type { Runtime } from "../../runtime-sdk/src/index.ts"
13
+ import { nodeConfigPath, readNodeConfig, readSpokeCredentials, type NodeConfig, type SpokeCredentials } from "./config.ts"
14
+ import { NodeDaemon, NODE_VERSION } from "./daemon.ts"
15
+ import { detectHarnesses } from "./harnesses.ts"
16
+ import { HostRuntime } from "./host.ts"
17
+ import { createOpenShellRuntime, openshellBackends, PROBE_TIMEOUT_MS, sweepSandboxes } from "./openshell.ts"
18
+
19
+ export const EXIT_CONFIG = 78
20
+
21
+ type Env = Readonly<Record<string, string | undefined>>
22
+
23
+ export interface RunNodeOptions {
24
+ readonly env?: Env
25
+ readonly home?: string
26
+ /** One line per event (default stderr). Never given the token. */
27
+ readonly log?: (line: string) => void
28
+ /** Aborting stops the daemon; runNode then resolves 0. */
29
+ readonly signal: AbortSignal
30
+ /** The openshell executable (default "openshell" on PATH). */
31
+ readonly openshellBinary?: string
32
+ }
33
+
34
+ interface OpenShellSetup {
35
+ readonly runtime: Runtime
36
+ readonly backends: readonly string[]
37
+ readonly recovered: readonly NodeLaunchReport[]
38
+ }
39
+
40
+ /** The openshell runtime, its backends and the sandboxes an earlier run left; undefined when the CLI is unusable. */
41
+ async function setupOpenShell(config: NodeConfig, credentials: SpokeCredentials, options: RunNodeOptions, env: Env, log: (line: string) => void): Promise<OpenShellSetup | undefined> {
42
+ const openshell = config.openshell!
43
+ const client = new OpenShellCli({ ...(options.openshellBinary ? { binary: options.openshellBinary } : {}), ...(openshell.gateway ? { gateway: openshell.gateway } : {}), env })
44
+ try {
45
+ await client.version({ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
46
+ } catch (cause) {
47
+ log(`openshell is enabled in node.json but the openshell CLI is not available (${(cause as Error).message}); offering the host runtime only`)
48
+ return undefined
49
+ }
50
+ const runtime = createOpenShellRuntime({ client, roots: config.workspaceRoots, hubURL: credentials.server, openshell, ...(credentials.machineID ? { machineID: credentials.machineID } : {}) })
51
+ const backends = await openshellBackends(client)
52
+ let recovered: NodeLaunchReport[] = []
53
+ if (credentials.machineID) {
54
+ const sweep = await sweepSandboxes(client, credentials.machineID)
55
+ for (const problem of sweep.problems) log(problem)
56
+ recovered = sweep.launches
57
+ } else log("config.json has no machine ID, so sandboxes from an earlier run cannot be found")
58
+ log(`openshell: profiles ${runtime.info().profiles?.join(", ")}; backends ${backends.join(", ") || "unknown"}; ${recovered.length} sandbox(es) from an earlier run`)
59
+ return { runtime, backends, recovered }
60
+ }
61
+
62
+ export async function runNode(options: RunNodeOptions): Promise<number> {
63
+ const env = options.env ?? process.env
64
+ const home = options.home ?? homedir()
65
+ const log = options.log ?? ((line: string) => process.stderr.write(`[hub node] ${line}\n`))
66
+
67
+ const credentials = readSpokeCredentials(env, home)
68
+ if (!credentials) {
69
+ log("this machine is not enrolled with a hub (config.json has no machine token); enrol it with the OpenCode plugin's /hub dialog first")
70
+ return 1
71
+ }
72
+ const file = nodeConfigPath(env, home)
73
+ const { config, problems } = readNodeConfig(file)
74
+ for (const problem of problems) log(`${file}: ${problem}`)
75
+ if (config.workspaceRoots.length === 0) {
76
+ log(`${file} lists no workspaceRoots, so this node can prepare no workspaces; add e.g. {"workspaceRoots": ["${home}/projects"]}`)
77
+ return EXIT_CONFIG
78
+ }
79
+
80
+ if (options.signal.aborted) return 0
81
+ const openshell = config.openshell?.enabled ? await setupOpenShell(config, credentials, options, env, log) : undefined
82
+ const harnesses = detectHarnesses({ env, ...(config.harnesses ? { overrides: config.harnesses } : {}) })
83
+ const runtimes: Runtime[] = [new HostRuntime({ roots: config.workspaceRoots, harnesses }), ...(openshell ? [openshell.runtime] : [])]
84
+
85
+ const daemon = new NodeDaemon({
86
+ config,
87
+ credentials,
88
+ runtimes,
89
+ ...(openshell ? { backends: { openshell: openshell.backends }, recovered: openshell.recovered } : {}),
90
+ onState: (state, error) => {
91
+ if (state === "connected") log(`connected to ${credentials.server}`)
92
+ else if (state === "unsupported") log(`${error ?? "the hub does not serve nodes"}; retrying later`)
93
+ else if (state === "revoked") log("this machine was revoked on the hub; stopping")
94
+ else if (state === "error" && error) log(`not connected: ${error}`)
95
+ },
96
+ })
97
+ log(`hub node ${NODE_VERSION}: ${config.slots} slots, roots ${config.workspaceRoots.join(", ")}; host harnesses ${harnesses.join(", ") || "none"}`)
98
+ daemon.start()
99
+ await new Promise<void>((resolve) => {
100
+ if (options.signal.aborted) return resolve()
101
+ options.signal.addEventListener("abort", () => resolve(), { once: true })
102
+ })
103
+ daemon.stop()
104
+ log("stopped")
105
+ return 0
106
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@huynle/hub-preset",
3
+ "version": "0.7.1",
4
+ "private": true,
5
+ "description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (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,8 @@
1
+ {
2
+ "name": "aider",
3
+ "description": "Aider, the terminal pair programmer. No session IDs: it cannot be resumed by ID.",
4
+ "command": ["aider"],
5
+ "nativeID": { "source": "none" },
6
+ "delivery": "paste",
7
+ "evidence": "screen"
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "goose",
3
+ "description": "goose, the open-source agent (interactive session). The hub names the session with --name; resuming needs --resume and --name together, which a preset cannot express yet.",
4
+ "command": ["goose", "session"],
5
+ "nativeID": { "source": "argv", "flag": "--name" },
6
+ "delivery": "paste",
7
+ "evidence": "screen"
8
+ }
@@ -0,0 +1,2 @@
1
+ // @huynle/hub-preset: the preset spoke (design §1.2 preset tier, decision fx04e42c point 6).
2
+ export {}