@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
5
5
  "license": "MIT",
6
6
  "author": "Huy Le",
@@ -45,16 +45,57 @@
45
45
  "tui.ts",
46
46
  "rpc.ts",
47
47
  "src",
48
+ "packages/protocol/src",
49
+ "packages/protocol/package.json",
50
+ "packages/sdk/src",
51
+ "packages/sdk/package.json",
52
+ "packages/cli/dist/hub-cli.js",
53
+ "packages/runtime-sdk/src",
54
+ "packages/runtime-sdk/package.json",
55
+ "packages/node/src",
56
+ "packages/node/package.json",
57
+ "packages/runtime-openshell/src",
58
+ "packages/runtime-openshell/package.json",
59
+ "packages/runtime-openshell/image",
60
+ "packages/spoke-sdk/src",
61
+ "packages/spoke-sdk/package.json",
62
+ "packages/claude-code/src",
63
+ "packages/claude-code/package.json",
64
+ "packages/preset/src",
65
+ "packages/preset/presets",
66
+ "packages/preset/package.json",
67
+ "packages/codex/src",
68
+ "packages/codex/package.json",
48
69
  "bin",
49
70
  "web/dist",
50
71
  "docker",
51
72
  "Dockerfile",
52
73
  "scripts/hub-local.sh",
53
74
  "scripts/io.github.huynle.hub.plist",
75
+ "scripts/io.github.huynle.hub-node.plist",
76
+ "scripts/hub-node.service",
77
+ "scripts/io.github.huynle.hub-claude-code.plist",
78
+ "scripts/hub-claude-code.service",
79
+ "scripts/io.github.huynle.hub-codex-spoke.plist",
80
+ "scripts/hub-codex-spoke.service",
54
81
  "README.md",
55
82
  "CHANGELOG.md",
56
83
  "LICENSE"
57
84
  ],
85
+ "workspaces": [
86
+ "packages/protocol",
87
+ "packages/sdk",
88
+ "packages/cli",
89
+ "packages/runtime-sdk",
90
+ "packages/node",
91
+ "packages/runtime-openshell",
92
+ "packages/spoke-sdk",
93
+ "packages/testkit",
94
+ "packages/claude-code",
95
+ "packages/conformance",
96
+ "packages/preset",
97
+ "packages/codex"
98
+ ],
58
99
  "engines": {
59
100
  "bun": ">=1.3"
60
101
  },
@@ -65,9 +106,10 @@
65
106
  "test": "bun test",
66
107
  "typecheck": "tsc --noEmit && tsc --noEmit -p web",
67
108
  "build:web": "vite build --config web/vite.config.ts",
109
+ "build:cli": "bun build packages/cli/src/main.ts --target=node --outfile=packages/cli/dist/hub-cli.js",
68
110
  "dev:web": "vite --config web/vite.config.ts",
69
111
  "test:e2e": "bun web/e2e/smoke.ts",
70
- "prepack": "npm run build:web",
112
+ "prepack": "npm run build:web && npm run build:cli",
71
113
  "hub": "bun bin/hub.ts"
72
114
  },
73
115
  "dependencies": {
@@ -78,6 +120,7 @@
78
120
  "devDependencies": {
79
121
  "@types/bun": "^1.4.2",
80
122
  "@types/web-push": "^3.6.4",
123
+ "onnxruntime-web": "1.23.2",
81
124
  "playwright-core": "^1.64.1",
82
125
  "solid-js": "^1.9.17",
83
126
  "typescript": "^5.9.3",
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@huynle/hub-claude-code",
3
+ "version": "0.7.1",
4
+ "private": true,
5
+ "description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (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,79 @@
1
+ // The hook bridge: the command Claude Code runs for every hook of the plugin (`hub claude-code hook [--rewake]
2
+ // [--wait <seconds>]`, an absolute path in hooks.json). It reads the hook's stdin JSON, sends it to the per-user
3
+ // daemon over its Unix socket, and does what the daemon answers: print the hook output JSON, write the session's
4
+ // env file (SessionStart's CLAUDE_ENV_FILE, mode 0600), print stderr and exit 2 (the asyncRewake Stop hook only:
5
+ // exit 2 from any other hook would block Claude Code; HUB_CLAUDE_REWAKE=off turns that hook into a no-op). It fails open: no daemon, a bad or late reply, any error →
6
+ // exit 0 without output, as if the hook were not there.
7
+ import { chmodSync, writeFileSync } from "node:fs"
8
+ import path from "node:path"
9
+ import { parseHookInput } from "./hook-input.ts"
10
+ import { ask, WIRE_VERSION, type BridgeRequest } from "./wire.ts"
11
+
12
+ export interface HookIo {
13
+ readonly argv: readonly string[]
14
+ readonly stdin: string
15
+ readonly env: Readonly<Record<string, string | undefined>>
16
+ readonly socketPath: string
17
+ stdout(s: string): void
18
+ stderr(s: string): void
19
+ /** Give up on the daemon after this long (default: never; Claude Code's hook timeout ends the hook). */
20
+ readonly timeoutMs?: number
21
+ }
22
+
23
+ const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/
24
+ /** Hook input fields the daemon never needs (a tool's whole output). */
25
+ const DROPPED = ["tool_response"] as const
26
+
27
+ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
28
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
29
+
30
+ /** The hook's flags: --rewake, --wait <seconds>. */
31
+ function flags(argv: readonly string[]): { rewake: boolean; waitMs?: number } {
32
+ const rewake = argv.includes("--rewake")
33
+ const at = argv.indexOf("--wait")
34
+ const seconds = at >= 0 ? Number(argv[at + 1]) : NaN
35
+ return { rewake, ...(Number.isFinite(seconds) && seconds >= 0 ? { waitMs: Math.round(seconds * 1000) } : {}) }
36
+ }
37
+
38
+ /** Writes `export NAME='value'` lines for the valid names to the env file (0600). */
39
+ function writeEnvFile(file: string | undefined, env: Record<string, unknown>): void {
40
+ if (!file || !path.isAbsolute(file)) return
41
+ const lines = Object.entries(env)
42
+ .filter(([name, value]) => ENV_NAME.test(name) && typeof value === "string" && !value.includes("\n"))
43
+ .map(([name, value]) => `export ${name}=${quote(value as string)}\n`)
44
+ if (!lines.length) return
45
+ writeFileSync(file, lines.join(""), { mode: 0o600 })
46
+ chmodSync(file, 0o600)
47
+ }
48
+
49
+ /** Runs one hook; resolves with its exit code (0 unless a rewake reply says 2). Never throws. */
50
+ export async function runHook(io: HookIo): Promise<number> {
51
+ try {
52
+ let raw: unknown
53
+ try {
54
+ raw = JSON.parse(io.stdin)
55
+ } catch {
56
+ return 0
57
+ }
58
+ const input = parseHookInput(raw)
59
+ if (!input) return 0
60
+ const sent: Record<string, unknown> = { ...input }
61
+ for (const key of DROPPED) delete sent[key]
62
+ const { rewake, waitMs } = flags(io.argv)
63
+ // A print run without streaming input (`hub claude -p`) waits on an asyncRewake hook until it ends: nothing to wake.
64
+ if (rewake && io.env.HUB_CLAUDE_REWAKE === "off") return 0
65
+ const launch = io.env.HUB_CLAUDE_LAUNCH
66
+ const req: BridgeRequest = { v: WIRE_VERSION, kind: "hook", input: sent, ...(launch ? { launch } : {}), ...(rewake ? { rewake: true as const } : {}), ...(waitMs !== undefined ? { waitMs } : {}) }
67
+ const reply = await ask(io.socketPath, req, io.timeoutMs !== undefined ? { timeoutMs: io.timeoutMs } : {})
68
+ if (!isObject(reply)) return 0
69
+ if (isObject(reply.env)) writeEnvFile(io.env.CLAUDE_ENV_FILE, reply.env)
70
+ if (rewake && reply.exit === 2) {
71
+ if (typeof reply.stderr === "string") io.stderr(reply.stderr)
72
+ return 2
73
+ }
74
+ if (isObject(reply.output)) io.stdout(`${JSON.stringify(reply.output)}\n`)
75
+ return 0
76
+ } catch {
77
+ return 0
78
+ }
79
+ }
@@ -0,0 +1,339 @@
1
+ // The per-user Claude Code spoke daemon (decision fx04e42c point 4): one process per user, connected to the hub with
2
+ // the machine token (read-only from the spoke's config.json) as the machine's claude-code hooks service (evidence
3
+ // "hooks", kind "service"), serving the hook bridges, the wrapper and the MCP pipes of every Claude Code session on
4
+ // the machine over a Unix socket in a private directory. ClaudeSessions holds the sessions' state; this file wires
5
+ // it to the hub (HarnessSpoke: ops permission and stop, deliver, prompt messages, reject messages) and to the socket.
6
+ //
7
+ // With `launch` options it also starts Claude Code for hub launches (features create, launch, turn, interrupt):
8
+ // a create's directory must be under the node's workspace roots (node.json); the daemon reports the new session at
9
+ // once and runs its claude headless (launcher.ts) with the hub plugin, so its hooks come back here like any other
10
+ // session's. turn and interrupt reach only those sessions (stdin user messages, control requests); a terminal
11
+ // session refuses them. stop interrupts a launched session's running turn at once, besides the hook-level stop.
12
+ // Stopping the daemon ends the claude processes it started.
13
+ //
14
+ // A `hub claude` run registers its pid ("launch"); the daemon checks it now and then and ends the run's sessions
15
+ // when the process is gone (Claude Code killed without SessionEnd), and at once when the wrapper reports its exit.
16
+ import { randomBytes, randomUUID } from "node:crypto"
17
+ import { chmodSync, lstatSync, unlinkSync } from "node:fs"
18
+ import { createServer, type Server, type Socket } from "node:net"
19
+ import path from "node:path"
20
+ import type { ResultFor, ServerRequest } from "../../protocol/src/index.ts"
21
+ import { HarnessSpoke, type HarnessSpokeOptions, type ModifierFeature, type OpHandlers } from "../../spoke-sdk/src/index.ts"
22
+ import { parseHookInput } from "./hook-input.ts"
23
+ import { ensurePrivateDir, SOCKET_PATH_MAX } from "./paths.ts"
24
+ import { createMcpRouter, MCP_END, type McpRouter } from "./mcp.ts"
25
+ import { ClaudeLauncher, launchAgent, launchDirectory, launchModel } from "./launcher.ts"
26
+ import { hubReferences } from "./references.ts"
27
+ import { ClaudeSessions, type SessionsOptions } from "./sessions.ts"
28
+ import { ask, onLines, WIRE_VERSION } from "./wire.ts"
29
+
30
+ export const HARNESS = "claude-code"
31
+ const DEFAULT_LIVENESS_MS = 30_000
32
+ const LAUNCHES_MAX = 1000
33
+
34
+ /** What the daemon needs to start Claude Code for hub launches (features create, launch, turn, interrupt). */
35
+ export interface DaemonLaunchOptions {
36
+ /** The claude executable. */
37
+ readonly bin: string
38
+ /** The hub plugin directory launched sessions load (written on demand). */
39
+ readonly pluginDir: () => string
40
+ /** The node's workspace roots (node.json): a create's directory must be under one. */
41
+ readonly roots: () => readonly string[]
42
+ /** The environment claude starts with (default the daemon's). */
43
+ readonly env?: Readonly<Record<string, string | undefined>>
44
+ }
45
+
46
+ export interface DaemonOptions {
47
+ readonly socketPath: string
48
+ readonly server: string
49
+ /** The machine token (never logged). */
50
+ readonly token: string
51
+ readonly harnessVersion: string
52
+ readonly pluginVersion: string
53
+ readonly directory: string
54
+ readonly tokenWaitMs?: number
55
+ readonly permissionWaitMs?: number
56
+ /** How often launches' processes are checked (default 30 s). */
57
+ readonly livenessIntervalMs?: number
58
+ /** Prompt references (default: looked up at the hub with the session's token). */
59
+ readonly references?: SessionsOptions["references"]
60
+ readonly log?: (line: string) => void
61
+ readonly socket?: HarnessSpokeOptions["socket"]
62
+ readonly backoff?: HarnessSpokeOptions["backoff"]
63
+ /** Start Claude Code for hub launches; absent: the daemon only follows sessions started in terminals. */
64
+ readonly launch?: DaemonLaunchOptions
65
+ }
66
+
67
+ export interface ClaudeDaemon {
68
+ readonly state: HarnessSpoke["state"]
69
+ readonly spoke: HarnessSpoke
70
+ readonly sessions: ClaudeSessions
71
+ stop(): Promise<void>
72
+ }
73
+
74
+ const alive = (pid: number) => {
75
+ try {
76
+ process.kill(pid, 0)
77
+ return true
78
+ } catch (cause) {
79
+ return (cause as NodeJS.ErrnoException).code === "EPERM"
80
+ }
81
+ }
82
+
83
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
84
+
85
+ /** Takes the socket path: refuses when a daemon answers there, removes a stale socket file. */
86
+ async function claimSocket(socketPath: string): Promise<void> {
87
+ if (Buffer.byteLength(socketPath) > SOCKET_PATH_MAX) throw new Error(`The socket path ${socketPath} is too long for a Unix socket (at most ${SOCKET_PATH_MAX} bytes); set HUB_CLAUDE_SOCKET to a shorter one`)
88
+ ensurePrivateDir(path.dirname(socketPath))
89
+ let exists = false
90
+ try {
91
+ lstatSync(socketPath)
92
+ exists = true
93
+ } catch {}
94
+ if (!exists) return
95
+ const reply = (await ask(socketPath, { v: WIRE_VERSION, kind: "ping" }, { timeoutMs: 1000 })) as { ok?: unknown } | undefined
96
+ if (reply?.ok === true) throw new Error(`A Claude Code spoke daemon is already running on ${socketPath}`)
97
+ unlinkSync(socketPath)
98
+ }
99
+
100
+ export async function startDaemon(options: DaemonOptions): Promise<ClaudeDaemon> {
101
+ await claimSocket(options.socketPath)
102
+ const log = options.log ?? (() => {})
103
+ let sessions!: ClaudeSessions
104
+ /** Launch key (HUB_CLAUDE_LAUNCH) of each session the daemon started, by its native ID. */
105
+ const launchKeys = new Map<string, string>()
106
+ const launch = options.launch
107
+ const launcher = launch
108
+ ? new ClaudeLauncher({
109
+ bin: launch.bin,
110
+ env: launch.env ?? process.env,
111
+ log,
112
+ onExit: (native) => {
113
+ const key = launchKeys.get(native)
114
+ launchKeys.delete(native)
115
+ if (key) sessions.endLaunch(key)
116
+ },
117
+ })
118
+ : undefined
119
+
120
+ /** The native ID of a session the daemon launched (and still runs); throws for any other. */
121
+ const launchedNative = (sessionID: string): string => {
122
+ const native = sessions.nativeOf(sessionID)
123
+ if (native === undefined || !launcher?.has(native)) throw new Error(`Session ${sessionID} runs in a terminal, not launched by the hub: Claude Code takes hub turns and interrupts only in sessions the hub launched (send it a message instead)`)
124
+ return native
125
+ }
126
+
127
+ async function create(req: Extract<ServerRequest, { op: "create" }>): Promise<ResultFor["create"]> {
128
+ if (!launch || !launcher) throw new Error("This Claude Code daemon does not start sessions")
129
+ if (req.herdrWorkspace !== undefined) throw new Error("Claude Code sessions cannot be opened in a Herdr workspace yet")
130
+ const directory = launchDirectory(req.directory, launch.roots())
131
+ const agent = launchAgent(req.agent)
132
+ const model = launchModel(req.model)
133
+ const native = randomUUID()
134
+ const key = `cl_${randomBytes(8).toString("hex")}`
135
+ const sessionID = sessions.launched({ sessionID: native, cwd: directory, launch: key, ...(req.title ? { title: req.title } : {}) })
136
+ launchKeys.set(native, key)
137
+ try {
138
+ await launcher.start({ sessionID: native, directory, launch: key, pluginDir: launch.pluginDir(), socketPath: options.socketPath, ...(model ? { model } : {}), ...(agent ? { agent } : {}) })
139
+ } catch (cause) {
140
+ launchKeys.delete(native)
141
+ sessions.endLaunch(key)
142
+ throw cause
143
+ }
144
+ log(`launched claude for ${sessionID}${req.launchID ? ` (launch ${req.launchID})` : ""} in ${directory}`)
145
+ return { sessionID }
146
+ }
147
+
148
+ async function turn(req: Extract<ServerRequest, { op: "turn" }>): Promise<ResultFor["turn"]> {
149
+ const native = launchedNative(req.sessionID)
150
+ sessions.hubPrompt(native)
151
+ const result = await launcher!.turn(native, req.text, req.images ?? [])
152
+ sessions.turnEnded(native, result.outcome, "message" in result ? result.message : undefined)
153
+ return result
154
+ }
155
+
156
+ async function interrupt(req: Extract<ServerRequest, { op: "interrupt" }>): Promise<ResultFor["interrupt"]> {
157
+ await launcher!.interrupt(launchedNative(req.sessionID))
158
+ return {}
159
+ }
160
+
161
+ async function stop(req: Extract<ServerRequest, { op: "stop" }>): Promise<ResultFor["stop"]> {
162
+ const native = sessions.nativeOf(req.sessionID)
163
+ const running = native !== undefined && launcher?.running(native) === true
164
+ const stopped = sessions.stop(req)
165
+ if (!running) return stopped
166
+ await launcher!.interrupt(native!)
167
+ return { stopped: stopped.stopped.length ? stopped.stopped : [req.sessionID] }
168
+ }
169
+
170
+ const handlers: OpHandlers = {
171
+ permission: (req) => sessions.permission(req),
172
+ stop,
173
+ ...(launcher ? { create, turn, interrupt } : {}),
174
+ }
175
+ const features: ModifierFeature[] = ["permissionMessage", "hubPrompt", ...(launcher ? (["launch"] as const) : [])]
176
+ const spoke = new HarnessSpoke({
177
+ server: options.server,
178
+ token: options.token,
179
+ harness: HARNESS,
180
+ harnessVersion: options.harnessVersion,
181
+ evidence: "hooks",
182
+ pluginVersion: options.pluginVersion,
183
+ directory: options.directory,
184
+ kind: "service",
185
+ mode: "always",
186
+ handlers,
187
+ deliver: (req) => sessions.deliver(req),
188
+ features,
189
+ onState: (state, error) => log(`hub connection ${state}${error ? `: ${error}` : ""}`),
190
+ ...(options.socket ? { socket: options.socket } : {}),
191
+ ...(options.backoff ? { backoff: options.backoff } : {}),
192
+ })
193
+ sessions = new ClaudeSessions({
194
+ spoke,
195
+ ...(options.tokenWaitMs !== undefined ? { tokenWaitMs: options.tokenWaitMs } : {}),
196
+ ...(options.permissionWaitMs !== undefined ? { permissionWaitMs: options.permissionWaitMs } : {}),
197
+ references: options.references ?? hubReferences((id) => spoke.sessionToken(id)),
198
+ })
199
+
200
+ const launches = new Map<string, number>()
201
+ const connections = new Set<Socket>()
202
+ const reply = (conn: Socket, body: unknown) => {
203
+ if (!conn.destroyed) conn.end(`${JSON.stringify(body)}\n`)
204
+ }
205
+
206
+ async function request(conn: Socket, req: Record<string, unknown>, signal: AbortSignal): Promise<void> {
207
+ switch (req.kind) {
208
+ case "ping":
209
+ return reply(conn, { ok: true, pid: process.pid, state: spoke.state })
210
+ case "hook": {
211
+ const input = parseHookInput(req.input)
212
+ if (!input) return reply(conn, {})
213
+ const launch = str(req.launch)
214
+ const waitMs = typeof req.waitMs === "number" && Number.isFinite(req.waitMs) && req.waitMs >= 0 ? req.waitMs : undefined
215
+ const answer = await sessions.hook(input, { ...(launch ? { launch } : {}), ...(req.rewake === true ? { rewake: true } : {}), ...(waitMs !== undefined ? { waitMs } : {}), signal })
216
+ return reply(conn, answer)
217
+ }
218
+ case "launch": {
219
+ const launch = str(req.launch)
220
+ if (!launch || !Number.isInteger(req.pid) || (req.pid as number) <= 0) return reply(conn, { ok: false, error: "launch needs a launch ID and a pid" })
221
+ launches.delete(launch)
222
+ launches.set(launch, req.pid as number)
223
+ while (launches.size > LAUNCHES_MAX) launches.delete(launches.keys().next().value!)
224
+ return reply(conn, { ok: true })
225
+ }
226
+ case "exit": {
227
+ const launch = str(req.launch)
228
+ if (launch) {
229
+ sessions.endLaunch(launch)
230
+ launches.delete(launch)
231
+ }
232
+ return reply(conn, { ok: true })
233
+ }
234
+ default:
235
+ return reply(conn, { ok: false, error: "unknown request" })
236
+ }
237
+ }
238
+
239
+ /** An MCP pipe: JSON-RPC lines both ways, as the launch's current session; at the pipe's end line, answers then closes. */
240
+ function mcp(conn: Socket, launch: string | undefined): (text: string) => void {
241
+ const router: McpRouter = createMcpRouter({
242
+ version: options.pluginVersion,
243
+ sessionID: () => (launch ? sessions.sessionForLaunch(launch) : undefined),
244
+ sessionToken: (id) => spoke.sessionToken(id),
245
+ actToken: (id) => spoke.actToken(id),
246
+ })
247
+ const pending = new Set<Promise<void>>()
248
+ conn.on("close", () => router.close())
249
+ return (text) => {
250
+ let message: unknown
251
+ try {
252
+ message = JSON.parse(text)
253
+ } catch {
254
+ if (!conn.destroyed) conn.write(`${JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error: not JSON" } })}\n`)
255
+ return
256
+ }
257
+ if (typeof message === "object" && message !== null && (message as { hub?: unknown }).hub === MCP_END.hub) {
258
+ void Promise.allSettled([...pending]).then(() => conn.end())
259
+ return
260
+ }
261
+ const task = router
262
+ .handle(message)
263
+ .then((answer) => {
264
+ if (answer && !conn.destroyed) conn.write(`${JSON.stringify(answer)}\n`)
265
+ })
266
+ .catch((cause) => log(`mcp: ${cause instanceof Error ? cause.message : String(cause)}`))
267
+ .finally(() => pending.delete(task))
268
+ pending.add(task)
269
+ }
270
+ }
271
+
272
+ const server: Server = createServer((conn) => {
273
+ connections.add(conn)
274
+ const abort = new AbortController()
275
+ conn.on("close", () => {
276
+ connections.delete(conn)
277
+ abort.abort()
278
+ })
279
+ conn.on("error", () => {})
280
+ let first = true
281
+ let rpc: ((text: string) => void) | undefined
282
+ onLines(conn, (text) => {
283
+ if (rpc) return rpc(text)
284
+ if (!first) return
285
+ first = false
286
+ let req: unknown
287
+ try {
288
+ req = JSON.parse(text)
289
+ } catch {
290
+ return reply(conn, { ok: false, error: "not JSON" })
291
+ }
292
+ if (typeof req !== "object" || req === null || (req as { v?: unknown }).v !== WIRE_VERSION) return reply(conn, { ok: false, error: "unsupported request" })
293
+ if ((req as { kind?: unknown }).kind === "mcp") {
294
+ rpc = mcp(conn, str((req as { launch?: unknown }).launch))
295
+ return
296
+ }
297
+ request(conn, req as Record<string, unknown>, abort.signal).catch((cause) => {
298
+ log(`request failed: ${cause instanceof Error ? cause.message : String(cause)}`)
299
+ reply(conn, {})
300
+ })
301
+ })
302
+ })
303
+ await new Promise<void>((resolve, reject) => {
304
+ server.once("error", reject)
305
+ server.listen(options.socketPath, () => {
306
+ server.off("error", reject)
307
+ resolve()
308
+ })
309
+ })
310
+ chmodSync(options.socketPath, 0o600)
311
+
312
+ // A launch whose process is gone: its sessions end (checked again each round; Claude Code may report late).
313
+ const liveness = setInterval(() => {
314
+ for (const [launch, pid] of launches) if (!alive(pid)) sessions.endLaunch(launch)
315
+ }, options.livenessIntervalMs ?? DEFAULT_LIVENESS_MS)
316
+ liveness.unref?.()
317
+
318
+ spoke.start()
319
+ let stopped = false
320
+ return {
321
+ get state() {
322
+ return spoke.state
323
+ },
324
+ spoke,
325
+ sessions,
326
+ async stop() {
327
+ if (stopped) return
328
+ stopped = true
329
+ clearInterval(liveness)
330
+ await launcher?.stopAll()
331
+ spoke.stop()
332
+ for (const conn of connections) conn.destroy()
333
+ await new Promise<void>((resolve) => server.close(() => resolve()))
334
+ try {
335
+ unlinkSync(options.socketPath)
336
+ } catch {}
337
+ },
338
+ }
339
+ }
@@ -0,0 +1,44 @@
1
+ // The stdin JSON of a Claude Code hook (shapes recorded by the P7-T0 spike, Brain report v0hsdqhm). Every input
2
+ // carries session_id (a UUID), transcript_path, cwd and hook_event_name; the rest depends on the event. Input that is
3
+ // not a hook input of a Claude Code session is refused before anything uses it.
4
+
5
+ /** Claude Code hook input: the common fields checked, the event's own fields kept as they came. */
6
+ export interface HookInput {
7
+ readonly session_id: string
8
+ readonly hook_event_name: string
9
+ readonly cwd?: string
10
+ readonly transcript_path?: string
11
+ readonly prompt_id?: string
12
+ readonly prompt?: string
13
+ readonly source?: string
14
+ readonly reason?: string
15
+ readonly tool_name?: string
16
+ readonly tool_input?: unknown
17
+ readonly tool_use_id?: string
18
+ readonly tool_response?: unknown
19
+ readonly permission_suggestions?: unknown
20
+ readonly notification_type?: string
21
+ readonly message?: string
22
+ readonly stop_hook_active?: boolean
23
+ readonly last_assistant_message?: string
24
+ readonly error?: unknown
25
+ readonly error_details?: unknown
26
+ readonly agent_id?: string
27
+ readonly agent_type?: string
28
+ readonly [key: string]: unknown
29
+ }
30
+
31
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
32
+ const EVENT = /^[A-Za-z]{1,64}$/
33
+ const OPTIONAL_STRINGS = ["cwd", "transcript_path", "prompt_id", "prompt", "source", "reason", "tool_name", "tool_use_id", "notification_type", "message", "last_assistant_message", "agent_id", "agent_type"] as const
34
+
35
+ /** The hook input, or undefined when `raw` is not one. */
36
+ export function parseHookInput(raw: unknown): HookInput | undefined {
37
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
38
+ const r = raw as Record<string, unknown>
39
+ if (typeof r.session_id !== "string" || !UUID.test(r.session_id)) return undefined
40
+ if (typeof r.hook_event_name !== "string" || !EVENT.test(r.hook_event_name)) return undefined
41
+ for (const key of OPTIONAL_STRINGS) if (r[key] !== undefined && typeof r[key] !== "string") return undefined
42
+ if (r.stop_hook_active !== undefined && typeof r.stop_hook_active !== "boolean") return undefined
43
+ return r as HookInput
44
+ }
@@ -0,0 +1,27 @@
1
+ // Hub IDs for Claude Code's native IDs (decision fx04e42c point 2). A session's UUID maps to the reversible
2
+ // ses_cc<32 hex> (spoke-sdk hubSessionID, namespace "cc"); a subagent (SubagentStart's agent_id inside a session)
3
+ // gets a child session of its own, stable for (session, agent). Other IDs the hub sees (tool calls, requests) are
4
+ // plain IDs (protocol wire.ts ID).
5
+ import { createHash, randomBytes } from "node:crypto"
6
+ import { MAX_NATIVE_BYTES, hubSessionID } from "../../spoke-sdk/src/index.ts"
7
+
8
+ /** The session namespace of Claude Code in hub session IDs. */
9
+ export const CLAUDE_NAMESPACE = "cc"
10
+
11
+ const PLAIN_MAX = 300
12
+
13
+ /** The hub session ID of a Claude Code session. */
14
+ export const claudeSessionID = (sessionID: string): string => hubSessionID(CLAUDE_NAMESPACE, sessionID)
15
+
16
+ /** The hub session ID of a subagent of a Claude Code session: `<session>/<agent>`, hashed when that is too long. */
17
+ export function childSessionID(sessionID: string, agentID: string): string {
18
+ const native = `${sessionID.toLowerCase()}/${agentID}`
19
+ if (Buffer.byteLength(native, "utf8") <= MAX_NATIVE_BYTES) return hubSessionID(CLAUDE_NAMESPACE, native)
20
+ return hubSessionID(CLAUDE_NAMESPACE, `${sessionID.toLowerCase()}/#${createHash("sha256").update(agentID).digest("hex").slice(0, 32)}`)
21
+ }
22
+
23
+ /** `raw` as a plain hub ID (letters, digits and _.:@/+~- only, at most 300 characters); a fresh one when empty. */
24
+ export function plainID(raw: string, prefix = "id"): string {
25
+ const clean = (typeof raw === "string" ? raw : "").replace(/[^A-Za-z0-9_.:@/+~-]/g, "_").slice(0, PLAIN_MAX)
26
+ return clean || `${prefix}_${randomBytes(8).toString("hex")}`
27
+ }
@@ -0,0 +1,18 @@
1
+ // @huynle/hub-claude-code: the Claude Code hooks spoke (design §1.2, §13.3; decision fx04e42c point 4, report
2
+ // v0hsdqhm). `hub claude` runs Claude Code with the hub plugin (wrapper.ts); the plugin's hooks run the bridge
3
+ // (bridge.ts), which talks to the per-user daemon (daemon.ts: HarnessSpoke + ClaudeSessions) over a private Unix
4
+ // socket; the plugin's MCP server is a pipe to the daemon (mcp.ts).
5
+ export * from "./bridge.ts"
6
+ export * from "./daemon.ts"
7
+ export * from "./hook-input.ts"
8
+ export * from "./ids.ts"
9
+ export * from "./launcher.ts"
10
+ export * from "./main.ts"
11
+ export * from "./mcp.ts"
12
+ export * from "./paths.ts"
13
+ export * from "./plugin.ts"
14
+ export * from "./references.ts"
15
+ export * from "./sessions.ts"
16
+ export * from "./tools.ts"
17
+ export * from "./wire.ts"
18
+ export * from "./wrapper.ts"