@huynle/hub 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,66 @@
|
|
|
1
|
+
// Claude Code tools in the hub's vocabulary (decision fx04e42c point 4). A permission request names the hub action
|
|
2
|
+
// (Bash → shell, Edit/Write/MultiEdit/NotebookEdit → edit, Read → read, mcp__<server>__<tool> → <server>_<tool>,
|
|
3
|
+
// any other tool its lower-case name), what it applies to (the command, the file path, the URL: short resources,
|
|
4
|
+
// never the whole tool input) and the rules an "always" reply would save (Claude's permission_suggestions).
|
|
5
|
+
import { HARNESS_ACTION_MAX, HARNESS_RESOURCE_MAX, HARNESS_RESOURCES_MAX, HARNESS_SUMMARY_MAX, summarizeToolInput } from "../../protocol/src/index.ts"
|
|
6
|
+
|
|
7
|
+
export interface PermissionRequestInfo {
|
|
8
|
+
readonly action: string
|
|
9
|
+
readonly resources: string[]
|
|
10
|
+
/** Rules an "always" reply saves, as Claude Code writes them (`Bash(npm test:*)`). */
|
|
11
|
+
readonly save?: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const EDIT_TOOLS: ReadonlySet<string> = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"])
|
|
15
|
+
const MCP = /^mcp__(.+?)__(.+)$/
|
|
16
|
+
|
|
17
|
+
const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
|
|
18
|
+
const record = (v: unknown): Record<string, unknown> => (typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {})
|
|
19
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v : undefined)
|
|
20
|
+
|
|
21
|
+
/** The hub action for a Claude Code tool name. */
|
|
22
|
+
export function permissionAction(tool: string): string {
|
|
23
|
+
if (tool === "Bash") return "shell"
|
|
24
|
+
if (EDIT_TOOLS.has(tool)) return "edit"
|
|
25
|
+
if (tool === "Read") return "read"
|
|
26
|
+
const mcp = MCP.exec(tool)
|
|
27
|
+
const name = mcp ? `${mcp[1]}_${mcp[2]}` : tool.toLowerCase()
|
|
28
|
+
const clean = name.replace(/[^A-Za-z0-9_.-]/g, "_")
|
|
29
|
+
return clean ? cut(clean, HARNESS_ACTION_MAX) : "unknown"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** What a tool call applies to: its command, file, path, URL or pattern (one short text), else nothing. */
|
|
33
|
+
function resourceOf(tool: string, input: unknown): string | undefined {
|
|
34
|
+
const r = record(input)
|
|
35
|
+
const value = tool === "Bash" ? str(r.command) : (str(r.file_path) ?? str(r.notebook_path) ?? str(r.path) ?? str(r.url) ?? str(r.pattern))
|
|
36
|
+
return value === undefined ? undefined : cut(value, HARNESS_RESOURCE_MAX)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** permission_suggestions' addRules entries as rule texts (`Tool(content)`, or `Tool` alone). */
|
|
40
|
+
function savedRules(suggestions: unknown): string[] {
|
|
41
|
+
const out: string[] = []
|
|
42
|
+
for (const s of Array.isArray(suggestions) ? suggestions : []) {
|
|
43
|
+
const suggestion = record(s)
|
|
44
|
+
if (suggestion.type !== "addRules" || (suggestion.behavior !== undefined && suggestion.behavior !== "allow")) continue
|
|
45
|
+
for (const rule of Array.isArray(suggestion.rules) ? suggestion.rules : []) {
|
|
46
|
+
const { toolName, ruleContent } = record(rule)
|
|
47
|
+
if (typeof toolName !== "string" || !toolName) continue
|
|
48
|
+
const text = typeof ruleContent === "string" && ruleContent ? `${toolName}(${ruleContent})` : toolName
|
|
49
|
+
if (!out.includes(text) && out.length < HARNESS_RESOURCES_MAX) out.push(cut(text, HARNESS_RESOURCE_MAX))
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A PermissionRequest hook input as the hub's permission.asked fields. */
|
|
56
|
+
export function permissionRequest(tool: string, input: unknown, suggestions: unknown): PermissionRequestInfo {
|
|
57
|
+
const resource = resourceOf(tool, input)
|
|
58
|
+
const save = savedRules(suggestions)
|
|
59
|
+
return { action: permissionAction(tool), resources: resource === undefined ? [] : [resource], ...(save.length ? { save } : {}) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A short summary of a tool call (its command or path's first line), or undefined when it has none. */
|
|
63
|
+
export function toolSummary(tool: string, input: unknown): string | undefined {
|
|
64
|
+
const line = (resourceOf(tool, input) ?? summarizeToolInput(input)).trim().split("\n")[0]!
|
|
65
|
+
return line ? cut(line, HARNESS_SUMMARY_MAX) : undefined
|
|
66
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The local protocol between the hook bridge (and the wrapper and the MCP pipe) and the per-user daemon: one JSON
|
|
2
|
+
// object per line over the daemon's Unix socket (in a 0700 directory, the socket itself 0600; no TCP, no HTTP).
|
|
3
|
+
// A client sends one request line; the daemon answers one reply line and closes, except for "mcp", after which both
|
|
4
|
+
// sides exchange JSON-RPC lines until either closes.
|
|
5
|
+
import { Socket } from "node:net"
|
|
6
|
+
|
|
7
|
+
export const WIRE_VERSION = 1
|
|
8
|
+
/** Longest line either side accepts (hook stdin can carry large tool inputs). */
|
|
9
|
+
export const MAX_LINE = 4 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
export type BridgeRequest =
|
|
12
|
+
/** A hook: its stdin JSON, the `hub claude` run it belongs to, and how the hook was registered. */
|
|
13
|
+
| { readonly v: 1; readonly kind: "hook"; readonly input: unknown; readonly launch?: string; readonly rewake?: true; readonly waitMs?: number }
|
|
14
|
+
/** Is the daemon there? → {ok: true, pid, state}. */
|
|
15
|
+
| { readonly v: 1; readonly kind: "ping" }
|
|
16
|
+
/** A `hub claude` run started (its pid, checked for liveness) or ended. */
|
|
17
|
+
| { readonly v: 1; readonly kind: "launch"; readonly launch: string; readonly pid: number }
|
|
18
|
+
| { readonly v: 1; readonly kind: "exit"; readonly launch: string }
|
|
19
|
+
/** The MCP pipe of a `hub claude` run: JSON-RPC lines follow in both directions. */
|
|
20
|
+
| { readonly v: 1; readonly kind: "mcp"; readonly launch?: string }
|
|
21
|
+
|
|
22
|
+
/** Splits a stream into lines; a line longer than MAX_LINE ends the connection. */
|
|
23
|
+
export function onLines(socket: Socket, line: (text: string) => void, max = MAX_LINE): void {
|
|
24
|
+
let buffer = ""
|
|
25
|
+
socket.setEncoding("utf8")
|
|
26
|
+
socket.on("data", (chunk: string) => {
|
|
27
|
+
buffer += chunk
|
|
28
|
+
let end: number
|
|
29
|
+
while ((end = buffer.indexOf("\n")) >= 0) {
|
|
30
|
+
const text = buffer.slice(0, end)
|
|
31
|
+
buffer = buffer.slice(end + 1)
|
|
32
|
+
if (text.trim()) line(text)
|
|
33
|
+
}
|
|
34
|
+
if (buffer.length > max) socket.destroy()
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sends one request and resolves with the daemon's reply (parsed JSON), or undefined when there is no daemon, it
|
|
40
|
+
* closed without a reply, answered something that is not JSON, or took longer than `timeoutMs`. Never rejects.
|
|
41
|
+
*/
|
|
42
|
+
export function ask(socketPath: string, req: BridgeRequest, options: { readonly timeoutMs?: number } = {}): Promise<unknown> {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let done = false
|
|
45
|
+
// Listeners first: Bun reports a missing socket synchronously, inside connect().
|
|
46
|
+
const socket = new Socket()
|
|
47
|
+
const finish = (value: unknown) => {
|
|
48
|
+
if (done) return
|
|
49
|
+
done = true
|
|
50
|
+
clearTimeout(timer)
|
|
51
|
+
socket.destroy()
|
|
52
|
+
resolve(value)
|
|
53
|
+
}
|
|
54
|
+
const timer = options.timeoutMs === undefined ? undefined : setTimeout(() => finish(undefined), options.timeoutMs)
|
|
55
|
+
socket.on("error", () => finish(undefined))
|
|
56
|
+
socket.on("close", () => finish(undefined))
|
|
57
|
+
socket.on("connect", () => socket.write(`${JSON.stringify(req)}\n`))
|
|
58
|
+
onLines(socket, (text) => {
|
|
59
|
+
try {
|
|
60
|
+
finish(JSON.parse(text))
|
|
61
|
+
} catch {
|
|
62
|
+
finish(undefined)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
try {
|
|
66
|
+
socket.connect(socketPath)
|
|
67
|
+
} catch {
|
|
68
|
+
finish(undefined)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// `hub claude [claude args…]` (decision fx04e42c point 4): runs Claude Code with the hub plugin loaded through
|
|
2
|
+
// --plugin-dir, never by editing the user's ~/.claude files. It makes sure the per-user daemon answers on its socket
|
|
3
|
+
// (starting `hub claude-code daemon` in the background when none does), writes the plugin directory for the
|
|
4
|
+
// current settings, registers the run with the daemon (a launch: its pid, so the daemon ends the run's sessions if it
|
|
5
|
+
// dies) and runs `claude` with the user's arguments, its exit code passed through. Claude Code and its hooks get
|
|
6
|
+
// HUB_CLAUDE_LAUNCH and HUB_CLAUDE_SOCKET (and HUB_CLAUDE_REWAKE=off for a -p run without streaming input, where the
|
|
7
|
+
// asyncRewake Stop hook would hold the run until its timeout). On a machine that is not enrolled (no machine token) or when the daemon
|
|
8
|
+
// cannot be started, Claude Code runs without the hub, and the command says why.
|
|
9
|
+
//
|
|
10
|
+
// Settings (environment): HUB_CLAUDE_BIN (default `claude` on PATH), HUB_CLAUDE_PERMISSION_WAIT (seconds a
|
|
11
|
+
// permission request waits for the hub, default 120; 0: report only), HUB_CLAUDE_REWAKE_TIMEOUT (seconds the
|
|
12
|
+
// asyncRewake Stop hook may wait for a message, default 3600).
|
|
13
|
+
import { spawn } from "node:child_process"
|
|
14
|
+
import { randomBytes } from "node:crypto"
|
|
15
|
+
import { closeSync, openSync } from "node:fs"
|
|
16
|
+
import path from "node:path"
|
|
17
|
+
import { configPath } from "../../../src/config.ts"
|
|
18
|
+
import { readSpokeCredentials } from "../../node/src/config.ts"
|
|
19
|
+
import pkg from "../package.json" with { type: "json" }
|
|
20
|
+
import { writePlugin } from "./plugin.ts"
|
|
21
|
+
import { claudeStateDir, ensurePrivateDir, socketPath } from "./paths.ts"
|
|
22
|
+
import { ask, WIRE_VERSION } from "./wire.ts"
|
|
23
|
+
|
|
24
|
+
export const VERSION: string = pkg.version
|
|
25
|
+
export const DEFAULT_PERMISSION_WAIT_S = 120
|
|
26
|
+
export const DEFAULT_REWAKE_TIMEOUT_S = 3600
|
|
27
|
+
const DAEMON_START_MS = 8000
|
|
28
|
+
const ASK_MS = 2000
|
|
29
|
+
|
|
30
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
31
|
+
|
|
32
|
+
export interface Program {
|
|
33
|
+
readonly execPath: string
|
|
34
|
+
readonly entry: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ClaudeIo {
|
|
38
|
+
readonly env: Env
|
|
39
|
+
readonly home: string
|
|
40
|
+
stderr(s: string): void
|
|
41
|
+
/** What runs the hub CLI (the daemon and the hooks run it too). */
|
|
42
|
+
readonly program: Program
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A non-negative number of seconds from the environment, else the default. */
|
|
46
|
+
export function seconds(value: string | undefined, fallback: number): number {
|
|
47
|
+
const n = value === undefined || value.trim() === "" ? NaN : Number(value)
|
|
48
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const pingOk = async (socket: string) => ((await ask(socket, { v: WIRE_VERSION, kind: "ping" }, { timeoutMs: ASK_MS })) as { ok?: unknown } | undefined)?.ok === true
|
|
52
|
+
|
|
53
|
+
/** Starts `hub claude-code daemon` detached, logging to daemon.log in the spoke's state directory. */
|
|
54
|
+
function spawnDaemon(io: ClaudeIo): void {
|
|
55
|
+
const dir = claudeStateDir(io.env, io.home)
|
|
56
|
+
ensurePrivateDir(dir)
|
|
57
|
+
const log = openSync(path.join(dir, "daemon.log"), "a", 0o600)
|
|
58
|
+
try {
|
|
59
|
+
const child = spawn(io.program.execPath, [io.program.entry, "claude-code", "daemon"], { detached: true, stdio: ["ignore", log, log], env: { ...io.env, BUN_BE_BUN: "1" } })
|
|
60
|
+
child.on("error", () => {})
|
|
61
|
+
child.unref()
|
|
62
|
+
} finally {
|
|
63
|
+
closeSync(log)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Writes (once) the hub plugin for these settings and returns its directory (`hub claude` and hub launches load it). */
|
|
68
|
+
export function hubPlugin(env: Env, home: string, program: Program): string {
|
|
69
|
+
return writePlugin(path.join(claudeStateDir(env, home), "plugins"), {
|
|
70
|
+
program,
|
|
71
|
+
permissionWaitS: seconds(env.HUB_CLAUDE_PERMISSION_WAIT, DEFAULT_PERMISSION_WAIT_S),
|
|
72
|
+
rewakeTimeoutS: seconds(env.HUB_CLAUDE_REWAKE_TIMEOUT, DEFAULT_REWAKE_TIMEOUT_S) || DEFAULT_REWAKE_TIMEOUT_S,
|
|
73
|
+
version: VERSION,
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The plugin directory when the hub can be used, else undefined (with the reason on stderr). */
|
|
78
|
+
async function prepare(io: ClaudeIo, socket: string): Promise<string | undefined> {
|
|
79
|
+
if (!readSpokeCredentials(io.env, io.home)) {
|
|
80
|
+
io.stderr(`hub claude: this machine is not enrolled with a hub (no machine token in ${configPath(io.env, io.home)}); running Claude Code without the hub\n`)
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
if (!(await pingOk(socket))) {
|
|
84
|
+
spawnDaemon(io)
|
|
85
|
+
const deadline = Date.now() + DAEMON_START_MS
|
|
86
|
+
let up = false
|
|
87
|
+
while (!up && Date.now() < deadline) {
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
89
|
+
up = await pingOk(socket)
|
|
90
|
+
}
|
|
91
|
+
if (!up) {
|
|
92
|
+
io.stderr(`hub claude: the Claude Code spoke daemon did not start (see ${path.join(claudeStateDir(io.env, io.home), "daemon.log")}); running Claude Code without the hub\n`)
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return hubPlugin(io.env, io.home, io.program)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Runs `bin` with the terminal; resolves with its exit code (128 + signal number when a signal ended it). */
|
|
100
|
+
function spawnClaude(bin: string, args: readonly string[], env: Env, stderr: (s: string) => void): Promise<number> {
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
const child = spawn(bin, args, { stdio: "inherit", env: env as NodeJS.ProcessEnv })
|
|
103
|
+
// The terminal sends Ctrl-C to Claude Code too: it decides what that means; this process keeps waiting.
|
|
104
|
+
const ignore = () => {}
|
|
105
|
+
const forward = () => child.kill("SIGTERM")
|
|
106
|
+
process.on("SIGINT", ignore)
|
|
107
|
+
process.on("SIGTERM", forward)
|
|
108
|
+
const done = (code: number) => {
|
|
109
|
+
process.off("SIGINT", ignore)
|
|
110
|
+
process.off("SIGTERM", forward)
|
|
111
|
+
resolve(code)
|
|
112
|
+
}
|
|
113
|
+
child.on("error", (cause) => {
|
|
114
|
+
stderr(`hub claude: cannot run ${bin}: ${cause.message}\n`)
|
|
115
|
+
done(127)
|
|
116
|
+
})
|
|
117
|
+
child.on("exit", (code, signal) => done(code ?? (signal ? 128 + (signalNumber(signal) ?? 0) : 1)))
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const SIGNALS: Record<string, number> = { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }
|
|
122
|
+
const signalNumber = (signal: string) => SIGNALS[signal]
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A print run (-p/--print) without streaming input: Claude Code runs the asyncRewake Stop hook in the foreground
|
|
126
|
+
* there and exits only when it does (live check P7-T11), so `hub claude -p` would wait out HUB_CLAUDE_REWAKE_TIMEOUT.
|
|
127
|
+
*/
|
|
128
|
+
export function printWithoutStreamingInput(args: readonly string[]): boolean {
|
|
129
|
+
if (!args.includes("-p") && !args.includes("--print")) return false
|
|
130
|
+
const at = args.indexOf("--input-format")
|
|
131
|
+
return !(args.includes("--input-format=stream-json") || (at >= 0 && args[at + 1] === "stream-json"))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function runClaude(args: readonly string[], io: ClaudeIo): Promise<number> {
|
|
135
|
+
const socket = socketPath(io.env, io.home)
|
|
136
|
+
let plugin: string | undefined
|
|
137
|
+
try {
|
|
138
|
+
plugin = await prepare(io, socket)
|
|
139
|
+
} catch (cause) {
|
|
140
|
+
io.stderr(`hub claude: ${cause instanceof Error ? cause.message : String(cause)}; running Claude Code without the hub\n`)
|
|
141
|
+
}
|
|
142
|
+
if (args.includes("--bare") && plugin) io.stderr("hub claude: --bare turns off Claude Code's hooks and plugins: this session will not reach the hub\n")
|
|
143
|
+
const env: Record<string, string | undefined> = { ...io.env }
|
|
144
|
+
delete env.BUN_BE_BUN
|
|
145
|
+
const launch = `cl_${randomBytes(8).toString("hex")}`
|
|
146
|
+
if (plugin) {
|
|
147
|
+
env.HUB_CLAUDE_LAUNCH = launch
|
|
148
|
+
env.HUB_CLAUDE_SOCKET = socket
|
|
149
|
+
if (printWithoutStreamingInput(args)) env.HUB_CLAUDE_REWAKE = "off"
|
|
150
|
+
await ask(socket, { v: WIRE_VERSION, kind: "launch", launch, pid: process.pid }, { timeoutMs: ASK_MS })
|
|
151
|
+
}
|
|
152
|
+
const code = await spawnClaude(io.env.HUB_CLAUDE_BIN || "claude", plugin ? ["--plugin-dir", plugin, ...args] : [...args], env, io.stderr)
|
|
153
|
+
if (plugin) await ask(socket, { v: WIRE_VERSION, kind: "exit", launch }, { timeoutMs: ASK_MS })
|
|
154
|
+
return code
|
|
155
|
+
}
|