@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.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- 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.
|
|
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"
|