@huynle/hub 0.6.1 → 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,265 @@
1
+ // ClaudeLauncher: the Claude Code processes the daemon starts for hub launches (feature "launch", report v0hsdqhm).
2
+ // Each runs headless in the launch's workspace: `claude --plugin-dir <hub plugin> --session-id <uuid> -p
3
+ // --input-format stream-json --output-format stream-json --verbose [--model m] [--agent a]`, so its hooks reach the
4
+ // daemon like any `hub claude` run (HUB_CLAUDE_LAUNCH, HUB_CLAUDE_SOCKET), a turn is a user message written to its
5
+ // stdin, the turn's end is the result line on its stdout, and an interrupt is a control request (Esc has no hook,
6
+ // a control request works). Turns are answered in order (Claude Code runs queued user messages one after another).
7
+ // When claude exits its open turns fail and onExit reports it; stopAll closes stdin (claude ends after the running
8
+ // turn) and kills what lingers.
9
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
10
+ import { realpathSync, statSync } from "node:fs"
11
+ import path from "node:path"
12
+ import type { ModelSelection, PromptImage, TurnResult } from "../../protocol/src/index.ts"
13
+
14
+ type Env = Readonly<Record<string, string | undefined>>
15
+
16
+ export interface LauncherOptions {
17
+ /** The claude executable. */
18
+ readonly bin: string
19
+ /** The environment claude starts with (the daemon's), before the hub's variables. */
20
+ readonly env: Env
21
+ /** A launched claude exited (its sessions end). */
22
+ readonly onExit?: (sessionID: string, code: number | null) => void
23
+ readonly log?: (line: string) => void
24
+ }
25
+
26
+ export interface LaunchInput {
27
+ /** The Claude Code session UUID (--session-id). */
28
+ readonly sessionID: string
29
+ /** The prepared workspace (claude's cwd). */
30
+ readonly directory: string
31
+ /** The launch key its hooks report (HUB_CLAUDE_LAUNCH). */
32
+ readonly launch: string
33
+ readonly pluginDir: string
34
+ readonly socketPath: string
35
+ readonly model?: string
36
+ readonly agent?: string
37
+ }
38
+
39
+ interface Turn {
40
+ resolve(result: TurnResult): void
41
+ interrupted: boolean
42
+ }
43
+
44
+ interface Proc {
45
+ readonly child: ChildProcessWithoutNullStreams
46
+ readonly turns: Turn[]
47
+ readonly controls: Map<string, () => void>
48
+ readonly exited: Promise<void>
49
+ stderr: string
50
+ }
51
+
52
+ const INTERRUPTED = "Interrupted from the hub"
53
+ const CONTROL_WAIT_MS = 5000
54
+ const STOP_GRACE_MS = 2000
55
+ const KILL_GRACE_MS = 1000
56
+ const LINE_MAX = 16 * 1024 * 1024
57
+ const STDERR_KEEP = 2000
58
+
59
+ const record = (v: unknown): Record<string, any> => (typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, any>) : {})
60
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
61
+
62
+ const AGENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
63
+ const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:@/\[\]-]{0,299}$/
64
+ /** The providers whose models Claude Code runs (its own model IDs or aliases). */
65
+ const PROVIDERS = new Set(["anthropic", "claude-code"])
66
+
67
+ /**
68
+ * The real path of a launch's directory: an existing directory under one of the node's workspace roots (both
69
+ * resolved through symlinks). Throws with the reason otherwise.
70
+ */
71
+ export function launchDirectory(directory: string, roots: readonly string[]): string {
72
+ if (typeof directory !== "string" || !path.isAbsolute(directory) || directory.split("/").some((part) => part === "." || part === "..")) {
73
+ throw new Error(`${JSON.stringify(directory)} is not an absolute path without . or .. segments`)
74
+ }
75
+ let real: string
76
+ try {
77
+ real = realpathSync(directory)
78
+ } catch {
79
+ throw new Error(`${directory} does not exist`)
80
+ }
81
+ if (!statSync(real).isDirectory()) throw new Error(`${directory} is not a directory`)
82
+ for (const root of roots) {
83
+ let base: string
84
+ try {
85
+ base = realpathSync(root)
86
+ } catch {
87
+ continue
88
+ }
89
+ if (real === base || real.startsWith(base.endsWith(path.sep) ? base : base + path.sep)) return real
90
+ }
91
+ throw new Error(`${directory} is not under this machine's node workspace roots (node.json workspaceRoots${roots.length ? `: ${roots.join(", ")}` : ", none set"})`)
92
+ }
93
+
94
+ /** The --agent value for a launch's agent; throws for a name Claude Code could read as an option. */
95
+ export function launchAgent(agent: string | undefined): string | undefined {
96
+ if (agent === undefined) return undefined
97
+ if (!AGENT.test(agent)) throw new Error(`Claude Code cannot take agent ${JSON.stringify(agent)} (letters, digits, ".", "_" and "-")`)
98
+ return agent
99
+ }
100
+
101
+ /** The --model value for a launch's model; throws for another provider's model or a name that is not a model ID. */
102
+ export function launchModel(model: ModelSelection | undefined): string | undefined {
103
+ if (model === undefined) return undefined
104
+ if (!PROVIDERS.has(model.providerID)) throw new Error(`Claude Code cannot run model ${model.providerID}/${model.modelID} (only anthropic models)`)
105
+ if (!MODEL.test(model.modelID)) throw new Error(`Claude Code cannot take model ${JSON.stringify(model.modelID)}`)
106
+ return model.modelID
107
+ }
108
+
109
+ /** The claude arguments of a headless launch. */
110
+ export function launchArgs(input: LaunchInput): string[] {
111
+ return [
112
+ "--plugin-dir",
113
+ input.pluginDir,
114
+ "--session-id",
115
+ input.sessionID,
116
+ "-p",
117
+ "--input-format",
118
+ "stream-json",
119
+ "--output-format",
120
+ "stream-json",
121
+ "--verbose",
122
+ ...(input.model ? ["--model", input.model] : []),
123
+ ...(input.agent ? ["--agent", input.agent] : []),
124
+ ]
125
+ }
126
+
127
+ /** A stream-json user message. */
128
+ export function userMessage(sessionID: string, text: string, images: readonly PromptImage[] = []): Record<string, unknown> {
129
+ const content = images.length
130
+ ? [...(text ? [{ type: "text", text }] : []), ...images.map((image) => ({ type: "image", source: { type: "base64", media_type: image.mime, data: image.data } }))]
131
+ : text
132
+ return { type: "user", message: { role: "user", content }, parent_tool_use_id: null, session_id: sessionID }
133
+ }
134
+
135
+ /** The turn's result for a stream-json result line. */
136
+ export function turnResult(line: Record<string, any>, interrupted: boolean): TurnResult {
137
+ const reply = typeof line.result === "string" ? line.result : ""
138
+ if (line.subtype === "success" && line.is_error !== true) return { outcome: "succeeded", reply }
139
+ if (interrupted) return { outcome: "interrupted", message: INTERRUPTED, reply: "" }
140
+ const errors = Array.isArray(line.errors) ? line.errors.filter((e: unknown): e is string => typeof e === "string") : []
141
+ return { outcome: "failed", message: reply || errors.join("; ") || String(line.subtype ?? "the turn failed"), reply: "" }
142
+ }
143
+
144
+ export class ClaudeLauncher {
145
+ readonly #options: LauncherOptions
146
+ readonly #procs = new Map<string, Proc>()
147
+ #controlID = 0
148
+
149
+ constructor(options: LauncherOptions) {
150
+ this.#options = options
151
+ }
152
+
153
+ /** Starts claude for a launch; rejects when it cannot run (or the session already runs). */
154
+ async start(input: LaunchInput): Promise<void> {
155
+ if (this.#procs.has(input.sessionID)) throw new Error(`Session ${input.sessionID} is already running`)
156
+ const env: Record<string, string | undefined> = { ...this.#options.env, HUB_CLAUDE_LAUNCH: input.launch, HUB_CLAUDE_SOCKET: input.socketPath }
157
+ delete env.BUN_BE_BUN
158
+ let child: ChildProcessWithoutNullStreams
159
+ try {
160
+ child = spawn(this.#options.bin, launchArgs(input), { cwd: input.directory, env: env as NodeJS.ProcessEnv, stdio: ["pipe", "pipe", "pipe"] })
161
+ } catch (cause) {
162
+ throw new Error(`cannot run ${this.#options.bin}: ${(cause as Error).message}`)
163
+ }
164
+ await new Promise<void>((resolve, reject) => {
165
+ child.once("spawn", () => resolve())
166
+ child.once("error", (cause) => reject(new Error(`cannot run ${this.#options.bin}: ${cause.message}`)))
167
+ })
168
+ let exited!: () => void
169
+ const proc: Proc = { child, turns: [], controls: new Map(), exited: new Promise<void>((resolve) => (exited = resolve)), stderr: "" }
170
+ this.#procs.set(input.sessionID, proc)
171
+ child.on("error", () => {})
172
+ child.stdin.on("error", () => {})
173
+ child.stderr.setEncoding("utf8")
174
+ child.stderr.on("data", (chunk: string) => {
175
+ proc.stderr = (proc.stderr + chunk).slice(-STDERR_KEEP)
176
+ })
177
+ let buffer = ""
178
+ child.stdout.setEncoding("utf8")
179
+ child.stdout.on("data", (chunk: string) => {
180
+ buffer += chunk
181
+ let end: number
182
+ while ((end = buffer.indexOf("\n")) >= 0) {
183
+ const text = buffer.slice(0, end)
184
+ buffer = buffer.slice(end + 1)
185
+ if (text.trim()) this.#line(proc, text)
186
+ }
187
+ if (buffer.length > LINE_MAX) buffer = ""
188
+ })
189
+ child.once("exit", (code) => {
190
+ if (this.#procs.get(input.sessionID) === proc) this.#procs.delete(input.sessionID)
191
+ const detail = proc.stderr.trim().split("\n").pop()
192
+ const message = `Claude Code exited (code ${code ?? "none"})${detail ? `: ${detail.slice(0, 300)}` : ""}`
193
+ for (const turn of proc.turns.splice(0)) turn.resolve(turn.interrupted ? { outcome: "interrupted", message: INTERRUPTED, reply: "" } : { outcome: "failed", message, reply: "" })
194
+ for (const done of proc.controls.values()) done()
195
+ proc.controls.clear()
196
+ this.#options.log?.(`claude for ${input.sessionID} exited (code ${code ?? "none"})`)
197
+ exited()
198
+ this.#options.onExit?.(input.sessionID, code)
199
+ })
200
+ }
201
+
202
+ /** Whether the hub started this session here (and it still runs). */
203
+ has(sessionID: string): boolean {
204
+ return this.#procs.has(sessionID)
205
+ }
206
+
207
+ /** Whether a turn is running (or queued) in it. */
208
+ running(sessionID: string): boolean {
209
+ return (this.#procs.get(sessionID)?.turns.length ?? 0) > 0
210
+ }
211
+
212
+ /** Writes a user message; resolves with the turn's result. */
213
+ turn(sessionID: string, text: string, images: readonly PromptImage[] = []): Promise<TurnResult> {
214
+ const proc = this.#procs.get(sessionID)
215
+ if (!proc) return Promise.reject(new Error(`Session ${sessionID} was not started by the hub here (turns reach only hub-launched Claude Code sessions)`))
216
+ return new Promise<TurnResult>((resolve) => {
217
+ proc.turns.push({ resolve, interrupted: false })
218
+ proc.child.stdin.write(`${JSON.stringify(userMessage(sessionID, text, images))}\n`)
219
+ })
220
+ }
221
+
222
+ /** Interrupts the running turn (a control request); false when the hub did not start the session. */
223
+ async interrupt(sessionID: string): Promise<boolean> {
224
+ const proc = this.#procs.get(sessionID)
225
+ if (!proc) return false
226
+ for (const turn of proc.turns) turn.interrupted = true
227
+ const id = `hub_int_${++this.#controlID}`
228
+ const answered = new Promise<void>((resolve) => proc.controls.set(id, resolve))
229
+ proc.child.stdin.write(`${JSON.stringify({ type: "control_request", request_id: id, request: { subtype: "interrupt" } })}\n`)
230
+ await Promise.race([answered, sleep(CONTROL_WAIT_MS)])
231
+ proc.controls.delete(id)
232
+ return true
233
+ }
234
+
235
+ /** Ends every process: stdin closed, then SIGTERM and SIGKILL for what lingers. */
236
+ async stopAll(): Promise<void> {
237
+ const procs = [...this.#procs.values()]
238
+ for (const proc of procs) proc.child.stdin.end()
239
+ await Promise.all(
240
+ procs.map(async (proc) => {
241
+ if (await Promise.race([proc.exited.then(() => true), sleep(STOP_GRACE_MS).then(() => false)])) return
242
+ proc.child.kill("SIGTERM")
243
+ if (await Promise.race([proc.exited.then(() => true), sleep(KILL_GRACE_MS).then(() => false)])) return
244
+ proc.child.kill("SIGKILL")
245
+ await proc.exited
246
+ }),
247
+ )
248
+ }
249
+
250
+ #line(proc: Proc, text: string): void {
251
+ let line: Record<string, any>
252
+ try {
253
+ line = record(JSON.parse(text))
254
+ } catch {
255
+ return
256
+ }
257
+ if (line.type === "result") {
258
+ const turn = proc.turns.shift()
259
+ turn?.resolve(turnResult(line, turn.interrupted))
260
+ } else if (line.type === "control_response") {
261
+ const id = record(line.response).request_id
262
+ if (typeof id === "string") proc.controls.get(id)?.()
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,127 @@
1
+ // `hub claude-code <daemon | hook | mcp>`: the Claude Code spoke's own processes, run by `hub claude` (the daemon)
2
+ // and by the plugin (the hook bridge, the MCP pipe), never typed by hand in normal use.
3
+ // daemon the per-user daemon until SIGINT/SIGTERM (needs an enrolled machine: the machine token in the spoke's
4
+ // config.json, read-only); with the Claude Code CLI found it also starts Claude Code for hub launches
5
+ // (daemonLaunchOptions)
6
+ // hook the hook bridge (stdin: the hook's JSON; --rewake, --wait <seconds>)
7
+ // mcp the MCP pipe on stdio
8
+ import { spawnSync } from "node:child_process"
9
+ import { realpathSync } from "node:fs"
10
+ import { configPath } from "../../../src/config.ts"
11
+ import { nodeConfigPath, readNodeConfig, readSpokeCredentials } from "../../node/src/config.ts"
12
+ import { findExecutable } from "../../node/src/harnesses.ts"
13
+ import { runHook } from "./bridge.ts"
14
+ import { startDaemon, type DaemonLaunchOptions } from "./daemon.ts"
15
+ import { runMcpPipe } from "./mcp.ts"
16
+ import { socketPath } from "./paths.ts"
17
+ import { hubPlugin, VERSION, type Program } from "./wrapper.ts"
18
+
19
+ export interface ClaudeCodeIo {
20
+ readonly env: Readonly<Record<string, string | undefined>>
21
+ readonly home: string
22
+ stdout(s: string): void
23
+ stderr(s: string): void
24
+ readonly stdin?: () => Promise<string>
25
+ readonly input?: AsyncIterable<Uint8Array | string>
26
+ readonly signal?: AbortSignal
27
+ /** What runs the hub CLI (the plugin's hooks run it); default this process (the runtime and its entry). */
28
+ readonly program?: Program
29
+ }
30
+
31
+ export const CLAUDE_CODE_USAGE = "Usage: hub claude-code daemon | hook [--rewake] [--wait <seconds>] | mcp | install [--dry-run] | uninstall [--dry-run] | status"
32
+
33
+ /** `claude --version` → "2.1.296", or "" when it cannot be told. */
34
+ export function claudeVersion(bin: string, env: ClaudeCodeIo["env"]): string {
35
+ try {
36
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 5000, env: env as NodeJS.ProcessEnv })
37
+ return /\d+\.\d+\.\d+[\w.+-]*/.exec(r.stdout ?? "")?.[0]?.slice(0, 100) ?? ""
38
+ } catch {
39
+ return ""
40
+ }
41
+ }
42
+
43
+ /**
44
+ * How the daemon starts Claude Code for hub launches, or undefined when it does not: the Claude Code CLI must be
45
+ * found (HUB_CLAUDE_BIN, else `claude` on PATH) and node.json must not withhold claude-code (harnesses). Launch
46
+ * directories must be under node.json's workspaceRoots (read again at each create); launched sessions load the hub
47
+ * plugin `hub claude` loads (written when the first launch needs it).
48
+ */
49
+ export function daemonLaunchOptions(io: { readonly env: ClaudeCodeIo["env"]; readonly home: string; readonly program: Program }): DaemonLaunchOptions | undefined {
50
+ const bin = findExecutable(io.env.HUB_CLAUDE_BIN || "claude", io.env)
51
+ if (!bin) return undefined
52
+ const file = nodeConfigPath(io.env, io.home)
53
+ if (readNodeConfig(file).config.harnesses?.["claude-code"] === false) return undefined
54
+ return {
55
+ bin,
56
+ roots: () => readNodeConfig(file).config.workspaceRoots,
57
+ pluginDir: () => hubPlugin(io.env, io.home, io.program),
58
+ env: io.env,
59
+ }
60
+ }
61
+
62
+ /** This process as the hub CLI (its runtime and entry, resolved), as `hub claude` started the daemon. */
63
+ function currentProgram(): Program {
64
+ const entry = process.argv[1] ?? ""
65
+ try {
66
+ return { execPath: process.execPath, entry: realpathSync(entry) }
67
+ } catch {
68
+ return { execPath: process.execPath, entry }
69
+ }
70
+ }
71
+
72
+ async function daemon(io: ClaudeCodeIo): Promise<number> {
73
+ const credentials = readSpokeCredentials(io.env, io.home)
74
+ if (!credentials) {
75
+ io.stderr(`hub claude-code daemon: this machine is not enrolled with a hub (no machine token in ${configPath(io.env, io.home)})\n`)
76
+ return 1
77
+ }
78
+ const log = (line: string) => io.stderr(`[hub claude-code] ${new Date().toISOString()} ${line}\n`)
79
+ const launch = daemonLaunchOptions({ env: io.env, home: io.home, program: io.program ?? currentProgram() })
80
+ let running
81
+ try {
82
+ running = await startDaemon({
83
+ socketPath: socketPath(io.env, io.home),
84
+ server: credentials.server,
85
+ token: credentials.token,
86
+ harnessVersion: claudeVersion(io.env.HUB_CLAUDE_BIN || "claude", io.env),
87
+ pluginVersion: VERSION,
88
+ directory: io.home,
89
+ log,
90
+ ...(launch ? { launch } : {}),
91
+ })
92
+ } catch (cause) {
93
+ io.stderr(`hub claude-code daemon: ${cause instanceof Error ? cause.message : String(cause)}\n`)
94
+ return 1
95
+ }
96
+ log(`listening on ${socketPath(io.env, io.home)}; ${launch ? `starts ${launch.bin} for hub launches` : "no hub launches (Claude Code CLI not found, or node.json withholds claude-code)"}`)
97
+ await new Promise<void>((resolve) => {
98
+ if (!io.signal) return
99
+ if (io.signal.aborted) return resolve()
100
+ io.signal.addEventListener("abort", () => resolve(), { once: true })
101
+ })
102
+ await running.stop()
103
+ return 0
104
+ }
105
+
106
+ export async function claudeCodeMain(argv: readonly string[], io: ClaudeCodeIo): Promise<number> {
107
+ const [command, ...rest] = argv
108
+ switch (command) {
109
+ case "hook": {
110
+ const stdin = io.stdin ? await io.stdin().catch(() => "") : ""
111
+ return runHook({ argv: rest, stdin, env: io.env, socketPath: socketPath(io.env, io.home), stdout: io.stdout, stderr: io.stderr })
112
+ }
113
+ case "mcp":
114
+ if (!io.input) {
115
+ io.stderr("hub claude-code mcp: needs stdin\n")
116
+ return 2
117
+ }
118
+ await runMcpPipe({ input: io.input, write: io.stdout, socketPath: socketPath(io.env, io.home), env: io.env, version: VERSION, ...(io.signal ? { signal: io.signal } : {}) })
119
+ return 0
120
+ case "daemon":
121
+ return daemon(io)
122
+ default:
123
+ io.stderr(`${CLAUDE_CODE_USAGE}\n`)
124
+ return 2
125
+ }
126
+ }
127
+
@@ -0,0 +1,109 @@
1
+ // The daemon-backed MCP server (decision fx04e42c point 4): the plugin's .mcp.json runs `hub claude-code mcp`, a pipe
2
+ // between Claude Code's stdio and the daemon. The daemon answers with the hub CLI's MCP tools (packages/cli mcp.ts)
3
+ // as the session the `hub claude` run is in (HUB_CLAUDE_LAUNCH → its current session): act verbs (approve, deny,
4
+ // answer, prompt, stop) with the session's act token, every other verb with its session token. Both tokens stay in
5
+ // the daemon's memory: the MCP process never sees one. Without a daemon the pipe still speaks MCP and answers every
6
+ // tool call with an error, so Claude Code's MCP client does not hang or retry.
7
+ import { Socket } from "node:net"
8
+ import { createMcpServer, serveMcp, type McpServer } from "../../cli/src/mcp.ts"
9
+ import { CliError, EXIT } from "../../cli/src/http.ts"
10
+ import type { Credentials } from "../../cli/src/credentials.ts"
11
+ import { onLines, WIRE_VERSION, type BridgeRequest } from "./wire.ts"
12
+
13
+ type Token = { readonly token: string; readonly url?: string } | undefined
14
+
15
+ /** The pipe's last line: stdin ended (not JSON-RPC: it has no jsonrpc field). */
16
+ export const MCP_END = { hub: "end" } as const
17
+
18
+ /** Tools that act on another session: they need the act token (HUB_TOOL_ACT_VERBS in the OpenCode spoke). */
19
+ export const ACT_TOOLS: ReadonlySet<string> = new Set(["approve", "deny", "answer", "prompt", "stop"])
20
+
21
+ export interface McpRouterOptions {
22
+ readonly version: string
23
+ /** The hub session the MCP client belongs to, when known. */
24
+ sessionID(): string | undefined
25
+ sessionToken(sessionID: string): Token
26
+ actToken(sessionID: string): Token
27
+ }
28
+
29
+ export interface McpRouter {
30
+ handle(message: unknown): Promise<unknown>
31
+ close(): void
32
+ }
33
+
34
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
35
+
36
+ /** One MCP connection's handler in the daemon: two hub CLI MCP servers, one per credential, picked per tool call. */
37
+ export function createMcpRouter(options: McpRouterOptions): McpRouter {
38
+ const credentials = (pick: (id: string) => Token, what: string) => (): Credentials => {
39
+ const sessionID = options.sessionID()
40
+ const token = sessionID === undefined ? undefined : pick(sessionID)
41
+ if (!token?.url) throw new CliError(EXIT.unauthorized, `no ${what} for this Claude Code session: it is not a hub session (yet)`, "Start Claude Code with `hub claude` on an enrolled machine")
42
+ return { url: token.url, token: token.token, source: "env" }
43
+ }
44
+ const shell: McpServer = createMcpServer({ version: options.version, credentials: credentials(options.sessionToken, "hub session token") })
45
+ const act: McpServer = createMcpServer({ version: options.version, credentials: credentials(options.actToken, "hub session act token") })
46
+ return {
47
+ async handle(message) {
48
+ if (isObject(message) && message.method === "tools/call" && isObject(message.params) && ACT_TOOLS.has(message.params.name as string)) return act.handle(message)
49
+ if (isObject(message) && !("id" in message)) void act.handle(message) // notifications (cancellations) reach both
50
+ return shell.handle(message)
51
+ },
52
+ close() {
53
+ shell.close()
54
+ act.close()
55
+ },
56
+ }
57
+ }
58
+
59
+ export interface McpPipeOptions {
60
+ readonly input: AsyncIterable<Uint8Array | string>
61
+ write(s: string): void
62
+ readonly socketPath: string
63
+ readonly env: Readonly<Record<string, string | undefined>>
64
+ readonly version: string
65
+ readonly signal?: AbortSignal
66
+ }
67
+
68
+ function connectTo(socketPath: string): Promise<Socket | undefined> {
69
+ return new Promise((resolve) => {
70
+ const socket = new Socket()
71
+ socket.once("error", () => resolve(undefined))
72
+ socket.once("connect", () => resolve(socket))
73
+ try {
74
+ socket.connect(socketPath)
75
+ } catch {
76
+ resolve(undefined)
77
+ }
78
+ })
79
+ }
80
+
81
+ /** `hub claude-code mcp`: pipes stdio to the daemon until stdin ends (and the daemon answered) or the signal aborts. */
82
+ export async function runMcpPipe(options: McpPipeOptions): Promise<void> {
83
+ const socket = await connectTo(options.socketPath)
84
+ if (!socket) {
85
+ const offline = (): Credentials => {
86
+ throw new CliError(EXIT.unavailable, "the Claude Code spoke daemon is not running", "Start Claude Code with `hub claude` (it starts the daemon)")
87
+ }
88
+ await serveMcp({ input: options.input, write: options.write, log: () => {}, credentials: offline, version: options.version, ...(options.signal ? { signal: options.signal } : {}) })
89
+ return
90
+ }
91
+ const closed = new Promise<void>((resolve) => socket.once("close", () => resolve()))
92
+ socket.on("error", () => {})
93
+ const launch = options.env.HUB_CLAUDE_LAUNCH
94
+ const hello: BridgeRequest = { v: WIRE_VERSION, kind: "mcp", ...(launch ? { launch } : {}) }
95
+ socket.write(`${JSON.stringify(hello)}\n`)
96
+ onLines(socket, (line) => options.write(`${line}\n`))
97
+ options.signal?.addEventListener("abort", () => socket.destroy(), { once: true })
98
+ const iterator = options.input[Symbol.asyncIterator]()
99
+ const stopped = new Promise<"stopped">((resolve) => socket.once("close", () => resolve("stopped")))
100
+ for (;;) {
101
+ const next = await Promise.race([iterator.next(), stopped])
102
+ if (next === "stopped" || next.done) break
103
+ if (!socket.destroyed) socket.write(next.value)
104
+ }
105
+ // Bun closes a half-closed Unix socket at once, so the end of stdin is a line of its own: the daemon answers the
106
+ // calls in flight, then closes.
107
+ if (!socket.destroyed) socket.write(`${JSON.stringify(MCP_END)}\n`)
108
+ await closed
109
+ }
@@ -0,0 +1,34 @@
1
+ // Where the Claude Code spoke keeps its files: <hub state dir>/claude-code (the hub state dir is $HUB_STATE_DIR, else
2
+ // ~/.local/state/hub), a private (0700) directory holding the daemon's socket (daemon.sock, 0600), its log and the
3
+ // plugin directories the wrapper loads with --plugin-dir. $HUB_CLAUDE_SOCKET names another socket (tests, or a
4
+ // short path when the state dir is too deep for a Unix socket path).
5
+ import { chmodSync, lstatSync, mkdirSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import path from "node:path"
8
+ import { hubEnv, type Env } from "../../../src/env.ts"
9
+ import { APP_DIR, stateBase } from "../../../src/migrate.ts"
10
+
11
+ /** Longest Unix socket path (macOS sun_path is 104 bytes with the terminating NUL). */
12
+ export const SOCKET_PATH_MAX = 103
13
+
14
+ export function claudeStateDir(env: Env = process.env, home: string = homedir()): string {
15
+ return path.join(hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR), "claude-code")
16
+ }
17
+
18
+ export function socketPath(env: Env = process.env, home: string = homedir()): string {
19
+ return env.HUB_CLAUDE_SOCKET || path.join(claudeStateDir(env, home), "daemon.sock")
20
+ }
21
+
22
+ /**
23
+ * Makes `dir` (and its parents) and checks it is a real directory of this user that nobody else can reach: a
24
+ * symlink or someone else's directory is refused, group or other permissions are removed (0700).
25
+ */
26
+ export function ensurePrivateDir(dir: string): void {
27
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
28
+ const stat = lstatSync(dir)
29
+ if (stat.isSymbolicLink()) throw new Error(`${dir} is a symlink; the Claude Code spoke needs a real directory there`)
30
+ if (!stat.isDirectory()) throw new Error(`${dir} is not a directory`)
31
+ const uid = process.getuid?.()
32
+ if (uid !== undefined && stat.uid !== uid) throw new Error(`${dir} belongs to another user`)
33
+ if ((stat.mode & 0o777) !== 0o700) chmodSync(dir, 0o700)
34
+ }
@@ -0,0 +1,99 @@
1
+ // The plugin directory `hub claude` loads with --plugin-dir (decision fx04e42c point 4: never the user's ~/.claude
2
+ // files). hooks/hooks.json runs the hook bridge (`hub claude-code hook`) for every event the spoke uses, with the
3
+ // absolute runtime and CLI entry quoted for sh and nothing else from outside in the command; PermissionRequest
4
+ // passes the permission wait (--wait <s>) under a hook timeout 2 s above it (the spike: wait ≤ hook timeout − 1 s);
5
+ // Stop also runs an asyncRewake bridge (--rewake) that blocks until the daemon has a message to wake the idle
6
+ // session with (exit 2). .mcp.json runs the daemon-backed MCP pipe. Each configuration gets its own directory
7
+ // (plugin-<hash>), written once.
8
+ import { createHash } from "node:crypto"
9
+ import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs"
10
+ import path from "node:path"
11
+ import { ensurePrivateDir } from "./paths.ts"
12
+
13
+ export interface PluginOptions {
14
+ /** What runs the hub CLI: the runtime and the CLI's entry file (absolute paths). */
15
+ readonly program: { readonly execPath: string; readonly entry: string }
16
+ /** How long a PermissionRequest waits for the hub (0: report only, the local dialog decides). */
17
+ readonly permissionWaitS: number
18
+ /** How long the asyncRewake Stop hook may wait for a message. */
19
+ readonly rewakeTimeoutS: number
20
+ readonly version: string
21
+ }
22
+
23
+ /** The hook events the spoke registers. */
24
+ export const HOOK_EVENTS: readonly string[] = [
25
+ "SessionStart",
26
+ "UserPromptSubmit",
27
+ "PreToolUse",
28
+ "PostToolUse",
29
+ "PostToolUseFailure",
30
+ "PermissionRequest",
31
+ "PermissionDenied",
32
+ "Notification",
33
+ "Stop",
34
+ "StopFailure",
35
+ "SubagentStart",
36
+ "SubagentStop",
37
+ "SessionEnd",
38
+ ]
39
+ /** Events with a tool matcher. */
40
+ const TOOL_EVENTS: ReadonlySet<string> = new Set(["PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest", "PermissionDenied"])
41
+ /** Timeout of the hooks that only report (seconds): the daemon answers at once. SessionStart waits for a token. */
42
+ const REPORT_TIMEOUT_S = 10
43
+ const SESSION_START_TIMEOUT_S = 15
44
+ const PERMISSION_SLACK_S = 2
45
+
46
+ /** An absolute path without quotes or control characters (safe single-quoted for sh); refused otherwise. */
47
+ function checked(p: string): string {
48
+ if (!path.isAbsolute(p) || /['\u0000-\u001f\u007f]/.test(p)) throw new Error(`Cannot use ${JSON.stringify(p)} in a hook command: it must be an absolute path without quotes or control characters`)
49
+ return p
50
+ }
51
+
52
+ const quoted = (p: string) => `'${checked(p)}'`
53
+
54
+ const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`
55
+
56
+ /** The plugin's files (relative path → content). */
57
+ export function pluginFiles(options: PluginOptions): Record<string, string> {
58
+ const { execPath, entry } = options.program
59
+ const bridge = `BUN_BE_BUN=1 ${quoted(execPath)} ${quoted(entry)} claude-code hook`
60
+ const wait = Math.max(0, Math.round(options.permissionWaitS))
61
+ const command = (cmd: string, timeout: number, extra: Record<string, unknown> = {}) => ({ type: "command", command: cmd, ...extra, timeout })
62
+ const hooks: Record<string, unknown[]> = {}
63
+ for (const event of HOOK_EVENTS) {
64
+ const list =
65
+ event === "PermissionRequest"
66
+ ? [command(`${bridge} --wait ${wait}`, wait > 0 ? wait + PERMISSION_SLACK_S : REPORT_TIMEOUT_S)]
67
+ : event === "Stop"
68
+ ? [command(bridge, REPORT_TIMEOUT_S), command(`${bridge} --rewake`, Math.max(1, Math.round(options.rewakeTimeoutS)), { asyncRewake: true })]
69
+ : [command(bridge, event === "SessionStart" ? SESSION_START_TIMEOUT_S : REPORT_TIMEOUT_S)]
70
+ hooks[event] = [{ ...(TOOL_EVENTS.has(event) ? { matcher: "*" } : {}), hooks: list }]
71
+ }
72
+ return {
73
+ ".claude-plugin/plugin.json": json({ name: "hub", version: options.version, description: "Hub: follow, message and steer this Claude Code session from the hub (hooks spoke)" }),
74
+ "hooks/hooks.json": json({ hooks }),
75
+ ".mcp.json": json({ mcpServers: { hub: { command: checked(execPath), args: [checked(entry), "claude-code", "mcp"], env: { BUN_BE_BUN: "1" } } } }),
76
+ }
77
+ }
78
+
79
+ /** Writes the plugin for `options` under `base` (once: plugin-<hash>) and returns its directory. */
80
+ export function writePlugin(base: string, options: PluginOptions): string {
81
+ const files = pluginFiles(options)
82
+ const hash = createHash("sha256").update(JSON.stringify(files)).digest("hex").slice(0, 12)
83
+ ensurePrivateDir(base)
84
+ const dir = path.join(base, `plugin-${hash}`)
85
+ if (existsSync(path.join(dir, "hooks", "hooks.json"))) return dir
86
+ const tmp = `${dir}.tmp-${process.pid}-${Date.now()}`
87
+ for (const [file, content] of Object.entries(files)) {
88
+ mkdirSync(path.dirname(path.join(tmp, file)), { recursive: true, mode: 0o700 })
89
+ writeFileSync(path.join(tmp, file), content, { mode: 0o600 })
90
+ }
91
+ try {
92
+ renameSync(tmp, dir)
93
+ } catch (cause) {
94
+ rmSync(tmp, { recursive: true, force: true })
95
+ if (!existsSync(dir)) throw cause // another run wrote it first otherwise
96
+ }
97
+ ensurePrivateDir(dir)
98
+ return dir
99
+ }