@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
|
@@ -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
|
+
}
|