@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,172 @@
1
+ // Launch vocabulary (design §6.1, §6.4), shared by the launch API, the hub's launch engine, `hub node` and the
2
+ // spoke. Wire-level checks only: the launch API's own rules (idempotency, grants, placement) live in the hub.
3
+ //
4
+ // Git hardening starts here: refs that `git check-ref-format` would accept and that never read as an option,
5
+ // https/ssh remotes only, absolute normal repo paths. Nodes and the hub still run git with execFile argv and `--`.
6
+ import { isObj, UNSAFE_CHARS } from "./wire.ts"
7
+
8
+ /** A launch's state; each change carries a reason. */
9
+ export const LAUNCH_STATES = ["queued", "placing", "provisioning", "running", "ended"] as const
10
+ export type LaunchState = (typeof LAUNCH_STATES)[number]
11
+
12
+ /** How an ended launch ended. */
13
+ export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost"] as const
14
+ export type LaunchOutcome = (typeof LAUNCH_OUTCOMES)[number]
15
+
16
+ /** `worktree`: `<repo>/.worktrees/<branch>` (reused when present); `checkout`: the repo itself; `clone`: a fresh clone at `repo`. */
17
+ export const WORKSPACE_MODES = ["worktree", "checkout", "clone"] as const
18
+ export type WorkspaceMode = (typeof WORKSPACE_MODES)[number]
19
+
20
+ export const RUNTIME_KINDS = ["host", "openshell"] as const
21
+ export type RuntimeKind = (typeof RUNTIME_KINDS)[number]
22
+
23
+ export const PLACEMENT_MODES = ["require", "prefer"] as const
24
+ export type PlacementMode = (typeof PLACEMENT_MODES)[number]
25
+
26
+ /** `idle`: idle by real evidence with nothing open for 30 s; `asked`: ended with a question open (needs_user); `never`: ended by the app or user. */
27
+ export const LAUNCH_END_WHEN = ["idle", "asked", "never"] as const
28
+ export type LaunchEndWhen = (typeof LAUNCH_END_WHEN)[number]
29
+
30
+ export const CLEANUP_POLICIES = ["keep", "remove"] as const
31
+ export type CleanupPolicy = (typeof CLEANUP_POLICIES)[number]
32
+
33
+ export interface LaunchWorkspace {
34
+ /** Absolute path: the repository (worktree, checkout) or where the clone goes (clone). */
35
+ readonly repo: string
36
+ readonly mode: WorkspaceMode
37
+ readonly branch?: string
38
+ readonly base?: string
39
+ /** Required for clone: an https or ssh remote. */
40
+ readonly remote?: string
41
+ }
42
+
43
+ export interface LaunchRuntime {
44
+ readonly kind: RuntimeKind
45
+ readonly profile?: string
46
+ }
47
+
48
+ export interface LaunchPlacement {
49
+ readonly labels?: Readonly<Record<string, string>>
50
+ /** A machine name or ID. */
51
+ readonly machine?: string
52
+ readonly mode?: PlacementMode
53
+ }
54
+
55
+ export interface LaunchEnd {
56
+ readonly when?: LaunchEndWhen
57
+ /** A duration such as "4h" or "90m". */
58
+ readonly timeout?: string
59
+ readonly cleanup?: CleanupPolicy
60
+ }
61
+
62
+ /** A launch request body (POST /api/agent/launches). Phase 4 has no `grant` field: a launch gets no powers beyond its launcher's. */
63
+ export interface LaunchSpec {
64
+ /** Idempotency key: the same key and body return the same launch. */
65
+ readonly key?: string
66
+ readonly name?: string
67
+ readonly roles?: readonly string[]
68
+ readonly harness?: string
69
+ readonly agent?: string
70
+ /** "provider/model" as the launcher wrote it. */
71
+ readonly model?: string
72
+ readonly workspace: LaunchWorkspace
73
+ readonly runtime?: LaunchRuntime
74
+ readonly placement?: LaunchPlacement
75
+ readonly prompt?: string
76
+ /** Free-form; never interpreted by the hub. */
77
+ readonly labels?: Readonly<Record<string, string>>
78
+ readonly end?: LaunchEnd
79
+ }
80
+
81
+ /** What a node collected from a launch's workspace. */
82
+ export interface LaunchArtifacts {
83
+ readonly branch?: string
84
+ readonly base?: string
85
+ /** The workspace's HEAD commit. */
86
+ readonly head?: string
87
+ /** Commits on `branch` since `base`. */
88
+ readonly commits?: number
89
+ /** Uncommitted changes were left in the workspace. */
90
+ readonly dirty?: boolean
91
+ }
92
+
93
+ /** A launch as the API reports it. */
94
+ export interface LaunchInfo {
95
+ readonly id: string
96
+ readonly state: LaunchState
97
+ readonly reason: string
98
+ readonly spec: LaunchSpec
99
+ /** ms epoch */
100
+ readonly created: number
101
+ readonly updated: number
102
+ readonly machineID?: string
103
+ readonly directory?: string
104
+ readonly sessionID?: string
105
+ readonly name?: string
106
+ readonly outcome?: LaunchOutcome
107
+ readonly artifacts?: LaunchArtifacts
108
+ }
109
+
110
+ // ---------- checks ----------
111
+
112
+
113
+ export const PATH_MAX = 4096
114
+ export const REF_MAX = 200
115
+ export const REMOTE_MAX = 2048
116
+ export const LABELS_MAX = 32
117
+ export const LABEL_VALUE_MAX = 256
118
+ const LABEL_KEY = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,62}$/
119
+
120
+ /** An absolute path of at most PATH_MAX characters without `.`/`..` segments, empty segments or unsafe characters. */
121
+ export function isAbsolutePath(v: unknown): v is string {
122
+ if (typeof v !== "string" || !v.startsWith("/") || v.length > PATH_MAX || UNSAFE_CHARS.test(v)) return false
123
+ if (v === "/") return true
124
+ return v
125
+ .slice(1)
126
+ .replace(/\/$/, "")
127
+ .split("/")
128
+ .every((part) => part !== "" && part !== "." && part !== "..")
129
+ }
130
+
131
+ /** A branch name `git check-ref-format --branch` accepts, from a conservative character set, never starting with `-`. */
132
+ export function isRefName(v: unknown): v is string {
133
+ if (typeof v !== "string" || v.length === 0 || v.length > REF_MAX) return false
134
+ if (!/^[A-Za-z0-9._/-]+$/.test(v) || v.startsWith("-") || v.endsWith(".") || v.includes("..")) return false
135
+ return v.split("/").every((part) => part !== "" && !part.startsWith(".") && !part.endsWith(".lock"))
136
+ }
137
+
138
+ /** An https or ssh URL, or scp-style `user@host:path`, of at most REMOTE_MAX characters; never an option. */
139
+ export function isGitRemote(v: unknown): v is string {
140
+ if (typeof v !== "string" || v.length === 0 || v.length > REMOTE_MAX || v.startsWith("-") || /[\s\p{Cc}\p{Cf}]/u.test(v)) return false
141
+ if (/^[A-Za-z0-9][A-Za-z0-9._-]*@[A-Za-z0-9][A-Za-z0-9.-]*:[^-:][^:]*$/.test(v)) return true
142
+ try {
143
+ const url = new URL(v)
144
+ return (url.protocol === "https:" || url.protocol === "ssh:") && url.hostname.length > 0 && !url.hostname.startsWith("-") && url.pathname.length > 1
145
+ } catch {
146
+ return false
147
+ }
148
+ }
149
+
150
+ /** Why `v` is not an acceptable launch workspace, or undefined when it is. */
151
+ export function launchWorkspaceProblem(v: unknown): string | undefined {
152
+ if (!isObj(v)) return "workspace must be an object"
153
+ if (!isAbsolutePath(v.repo)) return `workspace.repo must be an absolute path of at most ${PATH_MAX} characters without . or .. segments`
154
+ if (!(WORKSPACE_MODES as readonly unknown[]).includes(v.mode)) return `workspace.mode must be one of ${WORKSPACE_MODES.join(", ")}`
155
+ if (v.branch !== undefined && !isRefName(v.branch)) return "workspace.branch must be a valid branch name (letters, digits, . _ / -; not starting with -)"
156
+ if (v.base !== undefined && !isRefName(v.base)) return "workspace.base must be a valid branch name (letters, digits, . _ / -; not starting with -)"
157
+ if (v.remote !== undefined && !isGitRemote(v.remote)) return "workspace.remote must be an https or ssh git remote"
158
+ if (v.mode === "clone" && v.remote === undefined) return "workspace.remote is required for a clone"
159
+ return undefined
160
+ }
161
+
162
+ /** Why `v` is not an acceptable label map, or undefined when it is. */
163
+ export function labelsProblem(v: unknown): string | undefined {
164
+ if (!isObj(v)) return "labels must be an object"
165
+ const entries = Object.entries(v)
166
+ if (entries.length > LABELS_MAX) return `at most ${LABELS_MAX} labels`
167
+ for (const [key, value] of entries) {
168
+ if (!LABEL_KEY.test(key)) return `label key ${JSON.stringify(key.slice(0, 80))} must be 1–63 letters, digits, . _ / - (starting with a letter or digit)`
169
+ if (typeof value !== "string" || value.length > LABEL_VALUE_MAX || UNSAFE_CHARS.test(value)) return `label ${key} must be text of at most ${LABEL_VALUE_MAX} printable characters`
170
+ }
171
+ return undefined
172
+ }
@@ -0,0 +1,409 @@
1
+ // Hub ↔ node protocol (design §6.3). `hub node` is a daemon per machine: it connects out to /spoke/ws with the
2
+ // machine's token (spoke and node share one machine record) and a hello with role "node", never a spoke hello, so
3
+ // a node never appears as a process. It reports runtimes, labels, capacity and the launches it carries, and serves
4
+ // the hub's prepare → collect → cleanup ops (plus cancel) for each launch; with the "sandbox" feature also start and
5
+ // stop (it runs the launch's harness itself, e.g. in an OpenShell sandbox, whose spoke connects back with a launch
6
+ // token). It reports only launch progress and capacity; session state always comes from the session's own spoke.
7
+ // There is no remote exec channel.
8
+ //
9
+ // Compatibility: a hub that predates nodes rejects the node hello (it lacks the spoke hello fields) and so never
10
+ // sees a node frame. A node treats a welcome without the "nodes" feature (hubServesNodes) as an old hub and backs
11
+ // off. Frames share the spoke protocol's `t` discriminator, `req`/`res` envelope, MAX_FRAME and `bye`.
12
+ import type { Bye, Req, Res, Welcome } from "./index.ts"
13
+ import {
14
+ isAbsolutePath,
15
+ labelsProblem,
16
+ launchWorkspaceProblem,
17
+ isRefName,
18
+ CLEANUP_POLICIES,
19
+ RUNTIME_KINDS,
20
+ WORKSPACE_MODES,
21
+ type CleanupPolicy,
22
+ type LaunchArtifacts,
23
+ type LaunchRuntime,
24
+ type LaunchWorkspace,
25
+ type RuntimeKind,
26
+ type WorkspaceMode,
27
+ } from "./launch.ts"
28
+ import { featureList, ID, isByeFrame, isHubURL, isObj, isResFrame, isWelcomeFrame, nonEmpty, num, parseFrame, str, token, UNSAFE_CHARS } from "./wire.ts"
29
+
30
+ export const NODE_ROLE = "node"
31
+ /** The role a hello announces: "node", or "spoke" (a hello without `role`, or with role "spoke"). */
32
+ export type HelloRole = typeof NODE_ROLE | "spoke"
33
+
34
+ /** A runtime the node offers: its kind, and the harnesses, profiles or backends it has. */
35
+ export interface NodeRuntimeInfo {
36
+ readonly kind: string
37
+ readonly harnesses?: readonly string[]
38
+ readonly profiles?: readonly string[]
39
+ readonly backends?: readonly string[]
40
+ }
41
+
42
+ /** Slots (concurrent launches) and memory. */
43
+ export interface NodeCapacity {
44
+ readonly slots: number
45
+ readonly memoryMB?: number
46
+ }
47
+
48
+ /** Where the node is with one launch (starting … stopped: a harness the node runs, e.g. in a sandbox; see "sandbox"). */
49
+ export const NODE_LAUNCH_PHASES = ["preparing", "prepared", "starting", "running", "stopping", "stopped", "collecting", "collected", "cleaning", "cleaned", "cancelled", "failed"] as const
50
+ export type NodeLaunchPhase = (typeof NODE_LAUNCH_PHASES)[number]
51
+
52
+ /** A launch the node carries (in its hello, so a reconnecting node tells the hub what survived). */
53
+ export interface NodeLaunchReport {
54
+ readonly launchID: string
55
+ readonly phase: NodeLaunchPhase
56
+ readonly directory?: string
57
+ /** The launch's OpenShell sandbox, when it has one. */
58
+ readonly sandbox?: string
59
+ }
60
+
61
+ /**
62
+ * Optional node features (announced in the node hello). "sandbox": the node serves start and stop (it runs a
63
+ * launch's harness itself, e.g. in an OpenShell sandbox) and reports prepared workspaces with their runtime.
64
+ */
65
+ export const KNOWN_NODE_FEATURES = ["sandbox"] as const
66
+ export type NodeFeature = (typeof KNOWN_NODE_FEATURES)[number]
67
+ /** What this package's `hub node` can announce; "sandbox" only while it offers a runtime that starts harnesses
68
+ * (node.json `openshell.enabled`). */
69
+ export const NODE_FEATURES: readonly NodeFeature[] = ["sandbox"]
70
+
71
+ /** The features a node announced in its hello. */
72
+ export function nodeFeatures(hello: Pick<NodeHello, "features">): ReadonlySet<string> {
73
+ return new Set(hello.features ?? [])
74
+ }
75
+
76
+ // ---------- node → hub ----------
77
+
78
+ export interface NodeHello {
79
+ readonly t: "hello"
80
+ readonly v: number
81
+ readonly role: typeof NODE_ROLE
82
+ /** This daemon run (a new one per start), so the hub can tell a reconnect from a second node. */
83
+ readonly instanceID: string
84
+ readonly nodeVersion: string
85
+ /** Optional node features (see KNOWN_NODE_FEATURES); phase 4 nodes send none. */
86
+ readonly features?: readonly string[]
87
+ readonly runtimes: readonly NodeRuntimeInfo[]
88
+ readonly labels: Readonly<Record<string, string>>
89
+ readonly capacity: NodeCapacity
90
+ readonly used: NodeCapacity
91
+ /** Workspaces must be under one of these (node.json roots). */
92
+ readonly workspaceRoots: readonly string[]
93
+ /** A draining node takes no new launches. */
94
+ readonly draining: boolean
95
+ readonly launches: readonly NodeLaunchReport[]
96
+ }
97
+
98
+ /** The node's capacity, use, draining flag and (when given) labels now; replaces what its hello said. */
99
+ export interface NodeCapacityFrame {
100
+ readonly t: "capacity"
101
+ readonly capacity: NodeCapacity
102
+ readonly used: NodeCapacity
103
+ readonly draining: boolean
104
+ readonly labels?: Readonly<Record<string, string>>
105
+ }
106
+
107
+ /** A launch's progress on the node: its phase and a short human message (e.g. "cloning …"). */
108
+ export interface NodeProgress {
109
+ readonly t: "progress"
110
+ readonly launchID: string
111
+ readonly phase: NodeLaunchPhase
112
+ readonly message?: string
113
+ }
114
+
115
+ export type NodeMessage = NodeHello | NodeCapacityFrame | NodeProgress | Res | Bye
116
+
117
+ // ---------- hub → node ----------
118
+
119
+ /** Where a prepared workspace lives: on the host, or in an OpenShell sandbox (named by the node at prepare). */
120
+ export interface PreparedRuntime {
121
+ readonly kind: RuntimeKind
122
+ /** The OpenShell sandbox (a lowercase DNS label, see isSandboxName); only for kind "openshell". */
123
+ readonly sandbox?: string
124
+ /** The hub profile the sandbox was prepared with. */
125
+ readonly profile?: string
126
+ }
127
+
128
+ /** A workspace the node prepared; collect and cleanup carry it back, so the node needs no state of its own. */
129
+ export interface PreparedWorkspace {
130
+ readonly repo: string
131
+ /** The session's directory: the worktree, the checkout or the clone. */
132
+ readonly directory: string
133
+ readonly mode: WorkspaceMode
134
+ readonly branch?: string
135
+ readonly base?: string
136
+ readonly head?: string
137
+ /** The worktree or clone existed before this launch; cleanup never removes a reused workspace. */
138
+ readonly reused: boolean
139
+ /** Where the workspace lives; absent (phase 4 nodes) means the host. */
140
+ readonly runtime?: PreparedRuntime
141
+ }
142
+
143
+ /** A prepared workspace's runtime: its `runtime`, or the host when it has none. */
144
+ export function preparedWorkspaceRuntime(workspace: PreparedWorkspace): PreparedRuntime {
145
+ return workspace.runtime ?? { kind: "host" }
146
+ }
147
+
148
+ /** OpenShell sandbox names are lowercase DNS labels: 1–63 letters, digits and `-`, not starting or ending with `-`. */
149
+ const SANDBOX_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
150
+
151
+ /** Whether `v` is an acceptable OpenShell sandbox name. */
152
+ export function isSandboxName(v: unknown): v is string {
153
+ return typeof v === "string" && SANDBOX_NAME.test(v)
154
+ }
155
+
156
+ export type NodeRequest =
157
+ /** Prepare the launch's workspace (worktree, checkout or clone) under the node's roots. */
158
+ | { readonly op: "prepare"; readonly launchID: string; readonly runtime: LaunchRuntime; readonly harness: string; readonly workspace: LaunchWorkspace }
159
+ /** Collect artifacts (branch, head, commits since base, dirty) from a prepared workspace. */
160
+ | { readonly op: "collect"; readonly launchID: string; readonly workspace: PreparedWorkspace }
161
+ /** Keep or remove a prepared workspace; removal never forces (uncommitted work keeps it, with a reason). */
162
+ | { readonly op: "cleanup"; readonly launchID: string; readonly workspace: PreparedWorkspace; readonly policy: CleanupPolicy }
163
+ /** Stop whatever the node is doing for the launch (a running prepare). */
164
+ | { readonly op: "cancel"; readonly launchID: string }
165
+ /** Start the launch's harness in its prepared workspace (feature "sandbox"); it connects back to `hub`. */
166
+ | StartRequest
167
+ /** Stop the launch's harness (feature "sandbox"); `graceful` (the default) lets it finish its turn first. */
168
+ | { readonly op: "stop"; readonly launchID: string; readonly workspace: PreparedWorkspace; readonly mode?: StopMode }
169
+
170
+ export type NodeOp = NodeRequest["op"]
171
+
172
+ export const STOP_MODES = ["graceful", "kill"] as const
173
+ export type StopMode = (typeof STOP_MODES)[number]
174
+
175
+ /** Most environment variables a start op sets. */
176
+ export const START_ENV_MAX = 64
177
+ /** Longest environment value, and longest launch token, a start op carries. */
178
+ export const START_VALUE_MAX = 4096
179
+
180
+ export interface StartRequest {
181
+ readonly op: "start"
182
+ readonly launchID: string
183
+ readonly workspace: PreparedWorkspace
184
+ readonly harness: string
185
+ /** Where and how the harness's spoke connects back: the hub URL it can reach and a token bound to this launch. */
186
+ readonly hub: { readonly url: string; readonly token: string }
187
+ /** Extra environment for the harness (upper-case names); never secrets the node holds itself. */
188
+ readonly env?: Readonly<Record<string, string>>
189
+ }
190
+
191
+ /** The node feature a request needs, or undefined for the phase 4 ops every node serves. */
192
+ export function nodeRequestFeature(req: Pick<NodeRequest, "op">): NodeFeature | undefined {
193
+ return req.op === "start" || req.op === "stop" ? "sandbox" : undefined
194
+ }
195
+
196
+ /** Result payloads, keyed by node op. */
197
+ export interface NodeResultFor {
198
+ prepare: { readonly workspace: PreparedWorkspace }
199
+ collect: { readonly artifacts: LaunchArtifacts }
200
+ /** `removed: false` with a `reason` when the workspace was kept (policy keep, reused, uncommitted work). */
201
+ cleanup: { readonly removed: boolean; readonly reason?: string }
202
+ /** `cancelled: false` when the node had nothing running for the launch. */
203
+ cancel: { readonly cancelled: boolean }
204
+ /** `started: false` with a `reason` when the harness was already running (a repeated start). */
205
+ start: { readonly started: boolean; readonly reason?: string }
206
+ /** `stopped: false` with a `reason` when nothing was running for the launch. */
207
+ stop: { readonly stopped: boolean; readonly reason?: string }
208
+ }
209
+
210
+ export type NodeServerMessage = Welcome | Req<NodeRequest> | Res | Bye
211
+
212
+ /** Whether a hub's welcome announces nodes (an older hub never does; a node then backs off). */
213
+ export function hubServesNodes(welcome: Welcome): boolean {
214
+ return welcome.features?.includes("nodes") === true
215
+ }
216
+
217
+ // ---------- validation ----------
218
+
219
+ /** Longest progress message or cleanup reason. */
220
+ export const NODE_MESSAGE_MAX = 500
221
+ export const NODE_RUNTIMES_MAX = 8
222
+ export const NODE_ROOTS_MAX = 32
223
+ export const NODE_LAUNCHES_MAX = 1000
224
+ export const NODE_SLOTS_MAX = 10_000
225
+ const LIST_MAX = 32
226
+
227
+ const launchID = (v: unknown): v is string => str(v) && ID.test(v)
228
+ const whole = (v: unknown, max: number): v is number => num(v) && Number.isInteger(v) && v >= 0 && v <= max
229
+ const tokenList = (v: unknown): boolean => Array.isArray(v) && v.length <= LIST_MAX && v.every((t) => token(t, 64))
230
+ const message = (v: unknown): v is string => nonEmpty(v) && v.length <= NODE_MESSAGE_MAX && !UNSAFE_CHARS.test(v)
231
+ const phase = (v: unknown): v is NodeLaunchPhase => (NODE_LAUNCH_PHASES as readonly unknown[]).includes(v)
232
+ const COMMIT = /^[0-9a-f]{7,64}$/
233
+
234
+ const isCapacity = (v: unknown): v is NodeCapacity => isObj(v) && whole(v.slots, NODE_SLOTS_MAX) && (v.memoryMB === undefined || whole(v.memoryMB, 2 ** 31))
235
+
236
+ const isRuntimeInfo = (v: unknown): v is NodeRuntimeInfo =>
237
+ isObj(v) &&
238
+ token(v.kind, 64) &&
239
+ (v.harnesses === undefined || tokenList(v.harnesses)) &&
240
+ (v.profiles === undefined || tokenList(v.profiles)) &&
241
+ (v.backends === undefined || tokenList(v.backends))
242
+
243
+ const isLaunchReport = (v: unknown): v is NodeLaunchReport =>
244
+ isObj(v) && launchID(v.launchID) && phase(v.phase) && (v.directory === undefined || isAbsolutePath(v.directory)) && (v.sandbox === undefined || isSandboxName(v.sandbox))
245
+
246
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/
247
+ const TOKEN_TEXT = new RegExp(`^[\\x21-\\x7e]{1,${START_VALUE_MAX}}$`)
248
+
249
+ const isStartEnv = (v: unknown): boolean =>
250
+ isObj(v) &&
251
+ Object.keys(v).length <= START_ENV_MAX &&
252
+ Object.entries(v).every(([name, value]) => ENV_NAME.test(name) && str(value) && value.length <= START_VALUE_MAX && !UNSAFE_CHARS.test(value))
253
+
254
+ const isHubConnection = (v: unknown): boolean => isObj(v) && isHubURL(v.url) && str(v.token) && TOKEN_TEXT.test(v.token)
255
+
256
+ function isNodeHello(m: Record<string, unknown>): boolean {
257
+ return (
258
+ m.role === NODE_ROLE &&
259
+ num(m.v) &&
260
+ launchID(m.instanceID) &&
261
+ str(m.nodeVersion) &&
262
+ m.nodeVersion.length <= 100 &&
263
+ (m.features === undefined || featureList(m.features)) &&
264
+ Array.isArray(m.runtimes) &&
265
+ m.runtimes.length <= NODE_RUNTIMES_MAX &&
266
+ m.runtimes.every(isRuntimeInfo) &&
267
+ labelsProblem(m.labels) === undefined &&
268
+ isCapacity(m.capacity) &&
269
+ isCapacity(m.used) &&
270
+ Array.isArray(m.workspaceRoots) &&
271
+ m.workspaceRoots.length <= NODE_ROOTS_MAX &&
272
+ m.workspaceRoots.every(isAbsolutePath) &&
273
+ typeof m.draining === "boolean" &&
274
+ Array.isArray(m.launches) &&
275
+ m.launches.length <= NODE_LAUNCHES_MAX &&
276
+ m.launches.every(isLaunchReport)
277
+ )
278
+ }
279
+
280
+ /** Why `v` is not an acceptable prepared workspace, or undefined when it is. */
281
+ export function preparedWorkspaceProblem(v: unknown): string | undefined {
282
+ if (!isObj(v)) return "workspace must be an object"
283
+ if (!isAbsolutePath(v.repo)) return "workspace.repo must be an absolute path"
284
+ if (!isAbsolutePath(v.directory)) return "workspace.directory must be an absolute path"
285
+ if (!(WORKSPACE_MODES as readonly unknown[]).includes(v.mode)) return `workspace.mode must be one of ${WORKSPACE_MODES.join(", ")}`
286
+ if (v.branch !== undefined && !isRefName(v.branch)) return "workspace.branch must be a valid branch name"
287
+ if (v.base !== undefined && !isRefName(v.base)) return "workspace.base must be a valid branch name"
288
+ if (v.head !== undefined && !(str(v.head) && COMMIT.test(v.head))) return "workspace.head must be a commit id (7–64 hex digits)"
289
+ if (typeof v.reused !== "boolean") return "workspace.reused must be true or false"
290
+ if (v.runtime !== undefined) return preparedRuntimeProblem(v.runtime)
291
+ return undefined
292
+ }
293
+
294
+ function preparedRuntimeProblem(v: unknown): string | undefined {
295
+ if (!isObj(v)) return "workspace.runtime must be an object"
296
+ if (!(RUNTIME_KINDS as readonly unknown[]).includes(v.kind)) return `workspace.runtime.kind must be one of ${RUNTIME_KINDS.join(", ")}`
297
+ if (v.profile !== undefined && !token(v.profile, 64)) return "workspace.runtime.profile must be a short lowercase name"
298
+ if (v.sandbox === undefined) return undefined
299
+ if (v.kind !== "openshell") return "workspace.runtime.sandbox is only for an openshell workspace"
300
+ return isSandboxName(v.sandbox) ? undefined : "workspace.runtime.sandbox must be a lowercase DNS label (1–63 letters, digits and -)"
301
+ }
302
+
303
+ function artifactsProblem(v: unknown): string | undefined {
304
+ if (!isObj(v)) return "artifacts must be an object"
305
+ if (v.branch !== undefined && !isRefName(v.branch)) return "artifacts.branch must be a valid branch name"
306
+ if (v.base !== undefined && !isRefName(v.base)) return "artifacts.base must be a valid branch name"
307
+ if (v.head !== undefined && !(str(v.head) && COMMIT.test(v.head))) return "artifacts.head must be a commit id (7–64 hex digits)"
308
+ if (v.commits !== undefined && !whole(v.commits, Number.MAX_SAFE_INTEGER)) return "artifacts.commits must be a whole number"
309
+ if (v.dirty !== undefined && typeof v.dirty !== "boolean") return "artifacts.dirty must be true or false"
310
+ return undefined
311
+ }
312
+
313
+ function isNodeRequest(v: unknown): v is NodeRequest {
314
+ if (!isObj(v) || !launchID(v.launchID)) return false
315
+ switch (v.op) {
316
+ case "prepare":
317
+ return (
318
+ isObj(v.runtime) &&
319
+ (RUNTIME_KINDS as readonly unknown[]).includes(v.runtime.kind) &&
320
+ (v.runtime.profile === undefined || token(v.runtime.profile, 64)) &&
321
+ token(v.harness, 64) &&
322
+ launchWorkspaceProblem(v.workspace) === undefined
323
+ )
324
+ case "collect":
325
+ return preparedWorkspaceProblem(v.workspace) === undefined
326
+ case "cleanup":
327
+ return preparedWorkspaceProblem(v.workspace) === undefined && (CLEANUP_POLICIES as readonly unknown[]).includes(v.policy)
328
+ case "cancel":
329
+ return true
330
+ case "start":
331
+ return preparedWorkspaceProblem(v.workspace) === undefined && token(v.harness, 64) && isHubConnection(v.hub) && (v.env === undefined || isStartEnv(v.env))
332
+ case "stop":
333
+ return preparedWorkspaceProblem(v.workspace) === undefined && (v.mode === undefined || (STOP_MODES as readonly unknown[]).includes(v.mode))
334
+ default:
335
+ return false
336
+ }
337
+ }
338
+
339
+ /** Why `v` is not an acceptable result of node op `op`, or undefined when it is (the hub checks every result). */
340
+ export function nodeResultProblem(op: NodeOp, v: unknown): string | undefined {
341
+ if (!isObj(v)) return "result must be an object"
342
+ switch (op) {
343
+ case "prepare":
344
+ return preparedWorkspaceProblem(v.workspace)
345
+ case "collect":
346
+ return artifactsProblem(v.artifacts)
347
+ case "cleanup":
348
+ return flagProblem(v, "removed")
349
+ case "cancel":
350
+ return typeof v.cancelled === "boolean" ? undefined : "cancelled must be true or false"
351
+ case "start":
352
+ return flagProblem(v, "started")
353
+ case "stop":
354
+ return flagProblem(v, "stopped")
355
+ }
356
+ }
357
+
358
+ /** A `{flag: boolean, reason?: message}` result's problem. */
359
+ function flagProblem(v: Record<string, unknown>, flag: string): string | undefined {
360
+ if (typeof v[flag] !== "boolean") return `${flag} must be true or false`
361
+ return v.reason === undefined || message(v.reason) ? undefined : `reason must be 1–${NODE_MESSAGE_MAX} printable characters`
362
+ }
363
+
364
+ /** The role of a frame's hello ("node" or "spoke"), or undefined when the frame is no hello. Does not validate the hello. */
365
+ export function helloRole(raw: string | Buffer): HelloRole | undefined {
366
+ const m = parseFrame(raw)
367
+ if (!m || m.t !== "hello") return undefined
368
+ return m.role === NODE_ROLE ? NODE_ROLE : "spoke"
369
+ }
370
+
371
+ /** Parses a frame the hub received from a node. Returns undefined for anything invalid. */
372
+ export function parseNodeMessage(raw: string | Buffer): NodeMessage | undefined {
373
+ const m = parseFrame(raw)
374
+ if (!m) return undefined
375
+ switch (m.t) {
376
+ case "hello":
377
+ return isNodeHello(m) ? (m as unknown as NodeHello) : undefined
378
+ case "capacity":
379
+ return isCapacity(m.capacity) && isCapacity(m.used) && typeof m.draining === "boolean" && (m.labels === undefined || labelsProblem(m.labels) === undefined)
380
+ ? (m as unknown as NodeCapacityFrame)
381
+ : undefined
382
+ case "progress":
383
+ return launchID(m.launchID) && phase(m.phase) && (m.message === undefined || message(m.message)) ? (m as unknown as NodeProgress) : undefined
384
+ case "res":
385
+ return isResFrame(m) ? (m as unknown as Res) : undefined
386
+ case "bye":
387
+ return isByeFrame(m) ? (m as unknown as Bye) : undefined
388
+ default:
389
+ return undefined
390
+ }
391
+ }
392
+
393
+ /** Parses a frame a node received from the hub. Returns undefined for anything invalid. */
394
+ export function parseNodeServerMessage(raw: string | Buffer): NodeServerMessage | undefined {
395
+ const m = parseFrame(raw)
396
+ if (!m) return undefined
397
+ switch (m.t) {
398
+ case "welcome":
399
+ return isWelcomeFrame(m) ? (m as unknown as Welcome) : undefined
400
+ case "req":
401
+ return nonEmpty(m.id) && isNodeRequest(m.req) ? (m as unknown as Req<NodeRequest>) : undefined
402
+ case "res":
403
+ return isResFrame(m) ? (m as unknown as Res) : undefined
404
+ case "bye":
405
+ return isByeFrame(m) ? (m as unknown as Bye) : undefined
406
+ default:
407
+ return undefined
408
+ }
409
+ }