@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,319 @@
|
|
|
1
|
+
// The host runtime (design §6.4): the session runs in the machine's long-running harness service, which the hub
|
|
2
|
+
// asks to create it in the directory this runtime prepares. So the host runtime only makes, inspects and removes
|
|
3
|
+
// workspaces; it starts nothing.
|
|
4
|
+
//
|
|
5
|
+
// Workspaces: `worktree` (<repo>/.worktrees/<branch>, reused when the branch already has a worktree), `checkout`
|
|
6
|
+
// (the repository itself, switched to the branch) and `clone` (a fresh clone at `repo`). Every path is resolved
|
|
7
|
+
// (realpath, so through symlinks) and must stay under the node's workspace roots. Git runs through runGit only
|
|
8
|
+
// (no shell, `--` before paths, https/ssh only), and every name reaching it passed the protocol's ref and remote
|
|
9
|
+
// checks first, so none can read as an option.
|
|
10
|
+
//
|
|
11
|
+
// Cleanup never forces: uncommitted or unpushed work, a reused workspace, a checkout, or a workspace another launch
|
|
12
|
+
// made (each workspace this runtime makes carries a hub-launch marker in its git directory) keeps it, with a reason.
|
|
13
|
+
// Handles come from the hub, so collect and cleanup re-check everything.
|
|
14
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
15
|
+
import path from "node:path"
|
|
16
|
+
import {
|
|
17
|
+
isAbsolutePath,
|
|
18
|
+
isRefName,
|
|
19
|
+
launchWorkspaceProblem,
|
|
20
|
+
preparedWorkspaceProblem,
|
|
21
|
+
type CleanupPolicy,
|
|
22
|
+
type LaunchArtifacts,
|
|
23
|
+
type NodeRuntimeInfo,
|
|
24
|
+
type PreparedWorkspace,
|
|
25
|
+
} from "../../protocol/src/index.ts"
|
|
26
|
+
import { RuntimeError, type CleanupResult, type PrepareInput, type RunHandle, type Runtime, type RuntimeContext } from "../../runtime-sdk/src/index.ts"
|
|
27
|
+
import { GitError, runGit, type GitOptions } from "./git.ts"
|
|
28
|
+
|
|
29
|
+
/** The file in a workspace's git directory naming the launch that made it. */
|
|
30
|
+
export const LAUNCH_MARKER = "hub-launch"
|
|
31
|
+
|
|
32
|
+
export interface HostRuntimeOptions {
|
|
33
|
+
/** node.json workspaceRoots. */
|
|
34
|
+
readonly roots: readonly string[]
|
|
35
|
+
/** Harnesses whose service runs on this machine (default opencode). */
|
|
36
|
+
readonly harnesses?: readonly string[]
|
|
37
|
+
/** Git settings (environment, binary, time limit); tests use them, the daemon passes none. */
|
|
38
|
+
readonly git?: Omit<GitOptions, "cwd" | "signal">
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** `p` with every existing part resolved through symlinks; refuses a dangling symlink on the way. */
|
|
42
|
+
function resolvePath(p: string): string {
|
|
43
|
+
const rest: string[] = []
|
|
44
|
+
let current = p
|
|
45
|
+
for (;;) {
|
|
46
|
+
try {
|
|
47
|
+
return path.join(realpathSync(current), ...rest.reverse())
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw new RuntimeError(`cannot resolve ${p}: ${(cause as Error).message}`)
|
|
50
|
+
let link = false
|
|
51
|
+
try {
|
|
52
|
+
link = lstatSync(current).isSymbolicLink()
|
|
53
|
+
} catch {}
|
|
54
|
+
if (link) throw new RuntimeError(`${p} goes through a dangling symlink`)
|
|
55
|
+
const parent = path.dirname(current)
|
|
56
|
+
if (parent === current) throw new RuntimeError(`cannot resolve ${p}`)
|
|
57
|
+
rest.push(path.basename(current))
|
|
58
|
+
current = parent
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The real path of `p` when it is (or would be) under one of `roots`; throws a RuntimeError otherwise. */
|
|
64
|
+
export function resolveUnderRoots(p: string, roots: readonly string[]): string {
|
|
65
|
+
if (roots.length === 0) throw new RuntimeError("this node has no workspaceRoots in node.json, so it prepares no workspaces")
|
|
66
|
+
if (!isAbsolutePath(p)) throw new RuntimeError(`${p} is not an absolute path`)
|
|
67
|
+
const real = resolvePath(p)
|
|
68
|
+
for (const root of roots) {
|
|
69
|
+
let realRoot: string
|
|
70
|
+
try {
|
|
71
|
+
realRoot = realpathSync(root)
|
|
72
|
+
} catch {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (real === realRoot || real.startsWith(realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep)) return real
|
|
76
|
+
}
|
|
77
|
+
throw new RuntimeError(`${p} is not under this node's workspace roots`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A branch name for a launch without one: launch/<launchID>, with characters a ref may not hold replaced. */
|
|
81
|
+
export function launchBranch(launchID: string): string {
|
|
82
|
+
const name = launchID.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-.]+|[-.]+$/g, "").replace(/\.{2,}/g, ".").slice(0, 100) || "launch"
|
|
83
|
+
return `launch/${name.endsWith(".lock") ? `${name}-` : name}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const isDirectory = (p: string) => {
|
|
87
|
+
try {
|
|
88
|
+
return statSync(p).isDirectory()
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export class HostRuntime implements Runtime {
|
|
95
|
+
readonly kind = "host"
|
|
96
|
+
private readonly roots: readonly string[]
|
|
97
|
+
private readonly harnesses: readonly string[]
|
|
98
|
+
|
|
99
|
+
constructor(private readonly options: HostRuntimeOptions) {
|
|
100
|
+
this.roots = options.roots
|
|
101
|
+
this.harnesses = options.harnesses ?? ["opencode"]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
info(): NodeRuntimeInfo {
|
|
105
|
+
return { kind: this.kind, harnesses: [...this.harnesses] }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- prepare ----------
|
|
109
|
+
|
|
110
|
+
async prepare(input: PrepareInput, ctx: RuntimeContext = {}): Promise<PreparedWorkspace> {
|
|
111
|
+
if (ctx.signal?.aborted) throw new RuntimeError("prepare cancelled")
|
|
112
|
+
if (input.runtime?.kind !== this.kind) throw new RuntimeError(`this runtime is host, not ${String(input.runtime?.kind)}`)
|
|
113
|
+
if (!this.harnesses.includes(input.harness)) throw new RuntimeError(`harness ${input.harness} does not run on this node (it has ${this.harnesses.join(", ")})`)
|
|
114
|
+
const problem = launchWorkspaceProblem(input.workspace)
|
|
115
|
+
if (problem) throw new RuntimeError(problem)
|
|
116
|
+
const ws = input.workspace
|
|
117
|
+
if (ws.mode === "clone") return this.prepareClone(input, ctx)
|
|
118
|
+
const repo = await this.repository(ws.repo, ctx)
|
|
119
|
+
return ws.mode === "worktree" ? this.prepareWorktree(input, repo, ctx) : this.prepareCheckout(input, repo, ctx)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async prepareWorktree(input: PrepareInput, repo: string, ctx: RuntimeContext): Promise<PreparedWorkspace> {
|
|
123
|
+
const { base } = input.workspace
|
|
124
|
+
const branch = input.workspace.branch ?? launchBranch(input.launchID)
|
|
125
|
+
await this.git(repo, ["check-ref-format", "--branch", branch], ctx)
|
|
126
|
+
const existing = (await this.worktrees(repo, ctx)).get(`refs/heads/${branch}`)
|
|
127
|
+
if (existing) {
|
|
128
|
+
const directory = resolveUnderRoots(existing, this.roots)
|
|
129
|
+
return this.prepared(repo, directory, "worktree", branch, base, true, ctx)
|
|
130
|
+
}
|
|
131
|
+
const directory = resolveUnderRoots(path.join(repo, ".worktrees", ...branch.split("/")), this.roots)
|
|
132
|
+
if (existsSync(directory)) throw new RuntimeError(`${directory} exists and is not the worktree of ${branch}`)
|
|
133
|
+
if (base) await this.git(repo, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`], ctx, `base ${base} is not a commit in ${repo}`)
|
|
134
|
+
ctx.progress?.(`creating worktree ${branch}`)
|
|
135
|
+
if (await this.hasBranch(repo, branch, ctx)) await this.git(repo, ["worktree", "add", "--", directory, branch], ctx)
|
|
136
|
+
else await this.git(repo, ["worktree", "add", "-b", branch, "--", directory, base ?? "HEAD"], ctx)
|
|
137
|
+
await this.mark(directory, input.launchID, ctx)
|
|
138
|
+
return this.prepared(repo, directory, "worktree", branch, base, false, ctx)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async prepareCheckout(input: PrepareInput, repo: string, ctx: RuntimeContext): Promise<PreparedWorkspace> {
|
|
142
|
+
const { branch, base } = input.workspace
|
|
143
|
+
if (branch) {
|
|
144
|
+
await this.git(repo, ["check-ref-format", "--branch", branch], ctx)
|
|
145
|
+
if (base) await this.git(repo, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`], ctx, `base ${base} is not a commit in ${repo}`)
|
|
146
|
+
ctx.progress?.(`switching to ${branch}`)
|
|
147
|
+
if (await this.hasBranch(repo, branch, ctx)) await this.git(repo, ["switch", branch], ctx)
|
|
148
|
+
else await this.git(repo, ["switch", "-c", branch, base ?? "HEAD"], ctx)
|
|
149
|
+
}
|
|
150
|
+
return this.prepared(repo, repo, "checkout", (await this.currentBranch(repo, ctx)) ?? branch, base, true, ctx)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async prepareClone(input: PrepareInput, ctx: RuntimeContext): Promise<PreparedWorkspace> {
|
|
154
|
+
const { branch, base, remote } = input.workspace
|
|
155
|
+
const target = resolveUnderRoots(input.workspace.repo, this.roots)
|
|
156
|
+
if (existsSync(target) || isLink(target)) throw new RuntimeError(`${target} exists; a clone needs a new directory`)
|
|
157
|
+
const parent = path.dirname(target)
|
|
158
|
+
mkdirSync(parent, { recursive: true })
|
|
159
|
+
// The parent was created through resolved paths, but check again before git writes there.
|
|
160
|
+
if (resolveUnderRoots(parent, this.roots) !== parent) throw new RuntimeError(`${parent} moved while preparing`)
|
|
161
|
+
ctx.progress?.("cloning")
|
|
162
|
+
await this.git(parent, ["clone", "--quiet", ...(base ? ["--branch", base] : []), "--", remote as string, target], ctx)
|
|
163
|
+
if (branch) {
|
|
164
|
+
await this.git(target, ["check-ref-format", "--branch", branch], ctx)
|
|
165
|
+
const current = await this.currentBranch(target, ctx)
|
|
166
|
+
if (current !== branch) {
|
|
167
|
+
const remoteBranch = await this.succeeds(target, ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`], ctx)
|
|
168
|
+
await this.git(target, remoteBranch ? ["switch", branch] : ["switch", "-c", branch], ctx)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
await this.mark(target, input.launchID, ctx)
|
|
172
|
+
return this.prepared(target, target, "clone", (await this.currentBranch(target, ctx)) ?? branch, base, false, ctx)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The real path of a repository's top level under the roots. */
|
|
176
|
+
private async repository(p: string, ctx: RuntimeContext): Promise<string> {
|
|
177
|
+
const repo = resolveUnderRoots(p, this.roots)
|
|
178
|
+
if (!isDirectory(repo)) throw new RuntimeError(`${repo} does not exist`)
|
|
179
|
+
const top = (await this.git(repo, ["rev-parse", "--show-toplevel"], ctx, `${repo} is not a git repository`)).trim()
|
|
180
|
+
if (resolvePath(top) !== repo) throw new RuntimeError(`${repo} must be the repository's top level (${top})`)
|
|
181
|
+
return repo
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private async prepared(repo: string, directory: string, mode: PreparedWorkspace["mode"], branch: string | undefined, base: string | undefined, reused: boolean, ctx: RuntimeContext): Promise<PreparedWorkspace> {
|
|
185
|
+
const head = (await this.git(directory, ["rev-parse", "HEAD"], ctx)).trim()
|
|
186
|
+
return { repo, directory, mode, ...(branch ? { branch } : {}), ...(base ? { base } : {}), head, reused }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------- collect ----------
|
|
190
|
+
|
|
191
|
+
async collect(handle: RunHandle, ctx: RuntimeContext = {}): Promise<LaunchArtifacts> {
|
|
192
|
+
const ws = this.checkHandle(handle)
|
|
193
|
+
const directory = resolveUnderRoots(ws.directory, this.roots)
|
|
194
|
+
if (!isDirectory(directory)) throw new RuntimeError(`${directory} is gone`)
|
|
195
|
+
const branch = await this.currentBranch(directory, ctx)
|
|
196
|
+
const head = (await this.git(directory, ["rev-parse", "HEAD"], ctx)).trim()
|
|
197
|
+
const dirty = await this.isDirty(directory, ctx)
|
|
198
|
+
let commits: number | undefined
|
|
199
|
+
if (ws.base) {
|
|
200
|
+
for (const from of [ws.base, `origin/${ws.base}`]) {
|
|
201
|
+
const out = await this.output(directory, ["rev-list", "--count", `${from}..HEAD`], ctx)
|
|
202
|
+
if (out !== undefined && /^\d+$/.test(out.trim())) {
|
|
203
|
+
commits = Number(out.trim())
|
|
204
|
+
break
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { ...(branch ? { branch } : {}), ...(ws.base ? { base: ws.base } : {}), head, ...(commits !== undefined ? { commits } : {}), dirty }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------- cleanup ----------
|
|
212
|
+
|
|
213
|
+
async cleanup(handle: RunHandle, policy: CleanupPolicy, ctx: RuntimeContext = {}): Promise<CleanupResult> {
|
|
214
|
+
const ws = this.checkHandle(handle)
|
|
215
|
+
if (policy === "keep") return { removed: false, reason: "cleanup policy is keep" }
|
|
216
|
+
if (ws.reused) return { removed: false, reason: "the workspace existed before this launch" }
|
|
217
|
+
if (ws.mode === "checkout") return { removed: false, reason: "a checkout is the repository itself" }
|
|
218
|
+
const directory = resolveUnderRoots(ws.directory, this.roots)
|
|
219
|
+
if (!isDirectory(directory)) return { removed: false, reason: "the workspace is already gone" }
|
|
220
|
+
if (this.roots.some((root) => resolvePath(root) === directory)) return { removed: false, reason: "the workspace is a workspace root" }
|
|
221
|
+
if ((await this.marker(directory, ctx)) !== handle.launchID) return { removed: false, reason: "the workspace was not made by this launch" }
|
|
222
|
+
if (await this.isDirty(directory, ctx)) return { removed: false, reason: "the workspace has uncommitted changes" }
|
|
223
|
+
if (ws.mode === "worktree") {
|
|
224
|
+
const repo = resolveUnderRoots(ws.repo, this.roots)
|
|
225
|
+
if (directory === repo) return { removed: false, reason: "the workspace is the repository's main worktree" }
|
|
226
|
+
ctx.progress?.("removing worktree")
|
|
227
|
+
await this.git(repo, ["worktree", "remove", "--", directory], ctx)
|
|
228
|
+
return { removed: true }
|
|
229
|
+
}
|
|
230
|
+
if (directory !== resolveUnderRoots(ws.repo, this.roots)) return { removed: false, reason: "a clone's directory must be its repo" }
|
|
231
|
+
const unpushed = (await this.output(directory, ["rev-list", "--count", "--branches", "--not", "--remotes"], ctx))?.trim()
|
|
232
|
+
if (unpushed !== "0") return { removed: false, reason: unpushed ? `the clone has ${unpushed} unpushed commits` : "the clone has unpushed commits" }
|
|
233
|
+
ctx.progress?.("removing clone")
|
|
234
|
+
rmSync(directory, { recursive: true })
|
|
235
|
+
return { removed: true }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private checkHandle(handle: RunHandle): PreparedWorkspace {
|
|
239
|
+
const problem = preparedWorkspaceProblem(handle?.workspace)
|
|
240
|
+
if (problem) throw new RuntimeError(problem)
|
|
241
|
+
return handle.workspace
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---------- git helpers ----------
|
|
245
|
+
|
|
246
|
+
/** Runs git; a failure becomes a RuntimeError (with `message` instead of git's, when given). */
|
|
247
|
+
private async git(cwd: string, args: readonly string[], ctx: RuntimeContext, message?: string): Promise<string> {
|
|
248
|
+
try {
|
|
249
|
+
return await runGit(args, { ...this.options.git, cwd, ...(ctx.signal ? { signal: ctx.signal } : {}) })
|
|
250
|
+
} catch (cause) {
|
|
251
|
+
if (cause instanceof GitError) throw new RuntimeError(message && !/cancelled|timed out/.test(cause.message) ? message : cause.message)
|
|
252
|
+
throw cause
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** stdout, or undefined when git fails (cancellation still throws). */
|
|
257
|
+
private async output(cwd: string, args: readonly string[], ctx: RuntimeContext): Promise<string | undefined> {
|
|
258
|
+
try {
|
|
259
|
+
return await this.git(cwd, args, ctx)
|
|
260
|
+
} catch (cause) {
|
|
261
|
+
if (ctx.signal?.aborted) throw cause
|
|
262
|
+
return undefined
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private async succeeds(cwd: string, args: readonly string[], ctx: RuntimeContext): Promise<boolean> {
|
|
267
|
+
return (await this.output(cwd, args, ctx)) !== undefined
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private hasBranch(repo: string, branch: string, ctx: RuntimeContext): Promise<boolean> {
|
|
271
|
+
return this.succeeds(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], ctx)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async currentBranch(dir: string, ctx: RuntimeContext): Promise<string | undefined> {
|
|
275
|
+
const out = (await this.output(dir, ["symbolic-ref", "--quiet", "--short", "HEAD"], ctx))?.trim()
|
|
276
|
+
return out && isRefName(out) ? out : undefined
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private async isDirty(dir: string, ctx: RuntimeContext): Promise<boolean> {
|
|
280
|
+
return (await this.git(dir, ["status", "--porcelain", "--untracked-files=all"], ctx)).trim().length > 0
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Worktree paths by checked-out ref (refs/heads/<branch>). */
|
|
284
|
+
private async worktrees(repo: string, ctx: RuntimeContext): Promise<Map<string, string>> {
|
|
285
|
+
const out = await this.git(repo, ["worktree", "list", "--porcelain"], ctx)
|
|
286
|
+
const byRef = new Map<string, string>()
|
|
287
|
+
for (const block of out.split(/\n\n+/)) {
|
|
288
|
+
const lines = block.split("\n")
|
|
289
|
+
const dir = lines.find((l) => l.startsWith("worktree "))?.slice("worktree ".length)
|
|
290
|
+
const ref = lines.find((l) => l.startsWith("branch "))?.slice("branch ".length)
|
|
291
|
+
if (dir && ref && !lines.some((l) => l.startsWith("prunable"))) byRef.set(ref, dir)
|
|
292
|
+
}
|
|
293
|
+
return byRef
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async markerFile(dir: string, ctx: RuntimeContext): Promise<string> {
|
|
297
|
+
return path.join((await this.git(dir, ["rev-parse", "--absolute-git-dir"], ctx)).trim(), LAUNCH_MARKER)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private async mark(dir: string, launchID: string, ctx: RuntimeContext): Promise<void> {
|
|
301
|
+
writeFileSync(await this.markerFile(dir, ctx), `${launchID}\n`, { mode: 0o600 })
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private async marker(dir: string, ctx: RuntimeContext): Promise<string | undefined> {
|
|
305
|
+
try {
|
|
306
|
+
return readFileSync(await this.markerFile(dir, ctx), "utf8").trim()
|
|
307
|
+
} catch {
|
|
308
|
+
return undefined
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isLink(p: string): boolean {
|
|
314
|
+
try {
|
|
315
|
+
return lstatSync(p).isSymbolicLink()
|
|
316
|
+
} catch {
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// `hub node` (design §6.3): the per-machine daemon. It connects out to the hub's /spoke/ws with the machine's
|
|
2
|
+
// token and a node hello, and serves prepare → collect → cleanup (and cancel) for each launch through its runtimes.
|
|
3
|
+
// Run it with `bun packages/node/src/main.ts` (or the units in scripts/); embed it with runNode or NodeDaemon.
|
|
4
|
+
export { DEFAULT_SLOTS, NODE_CONFIG_FILE, nodeConfigPath, normalizeNodeConfig, readNodeConfig, readSpokeCredentials } from "./config.ts"
|
|
5
|
+
export type { NodeConfig, NodeConfigResult, OpenShellNodeConfig, SpokeCredentials } from "./config.ts"
|
|
6
|
+
export { NodeConnection, UNSUPPORTED_RETRY_MS, wsURL } from "./connection.ts"
|
|
7
|
+
export type { NodeConnectionOptions, NodeConnectionState } from "./connection.ts"
|
|
8
|
+
export { NODE_VERSION, NodeDaemon, progressText } from "./daemon.ts"
|
|
9
|
+
export type { NodeDaemonOptions } from "./daemon.ts"
|
|
10
|
+
export { ALLOWED_PROTOCOLS, GitError, gitEnv, runGit } from "./git.ts"
|
|
11
|
+
export { detectHarnesses, findExecutable } from "./harnesses.ts"
|
|
12
|
+
export type { DetectHarnessesOptions } from "./harnesses.ts"
|
|
13
|
+
export { HostRuntime, LAUNCH_MARKER, launchBranch, resolveUnderRoots } from "./host.ts"
|
|
14
|
+
export type { HostRuntimeOptions } from "./host.ts"
|
|
15
|
+
export { EXIT_CONFIG, runNode } from "./run.ts"
|
|
16
|
+
export { createOpenShellRuntime, openshellBackends, PROBE_TIMEOUT_MS, sweepSandboxes } from "./openshell.ts"
|
|
17
|
+
export type { CreateOpenShellRuntimeOptions } from "./openshell.ts"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Entry of `hub node`: `bun packages/node/src/main.ts` (what the launchd and systemd units run), or the same file
|
|
3
|
+
// bundled for node. SIGINT and SIGTERM stop the daemon cleanly (exit 0). See run.ts for the program.
|
|
4
|
+
import { runNode } from "./run.ts"
|
|
5
|
+
|
|
6
|
+
const controller = new AbortController()
|
|
7
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => controller.abort())
|
|
8
|
+
|
|
9
|
+
process.exitCode = await runNode({ signal: controller.signal })
|
|
10
|
+
// Nothing else (sockets, timers) may keep a stopped node alive.
|
|
11
|
+
process.exit()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The node's openshell wiring (design §6.3, §6.4): node.json's `openshell` section turned into an OpenShellRuntime,
|
|
2
|
+
// the backends the hello reports for it (the gateway's compute drivers, e.g. "vm"; best effort) and the startup
|
|
3
|
+
// sweep: sandboxes labelled hub.machine=<this machine> in the OpenShell workspace `hub` are launches an earlier run
|
|
4
|
+
// left (a crash, a restart), reported in the hello so the hub reconciles them. Only sandboxes whose name is the
|
|
5
|
+
// deterministic name of their hub.launch label count: anything else (e.g. `demo`) is not the node's.
|
|
6
|
+
import { token } from "../../protocol/src/wire.ts"
|
|
7
|
+
import type { NodeLaunchReport } from "../../protocol/src/index.ts"
|
|
8
|
+
import type { OpenShellClient, Sandbox } from "../../runtime-openshell/src/cli.ts"
|
|
9
|
+
import { labelValue, OpenShellRuntime, sandboxName } from "../../runtime-openshell/src/runtime.ts"
|
|
10
|
+
import type { OpenShellNodeConfig } from "./config.ts"
|
|
11
|
+
|
|
12
|
+
/** How long the startup probes (gateway info, the sweep) may take each. */
|
|
13
|
+
export const PROBE_TIMEOUT_MS = 15_000
|
|
14
|
+
const BACKENDS_MAX = 32
|
|
15
|
+
|
|
16
|
+
export interface CreateOpenShellRuntimeOptions {
|
|
17
|
+
readonly client: OpenShellClient
|
|
18
|
+
readonly roots: readonly string[]
|
|
19
|
+
/** The hub URL as the node knows it (config.json `server`). */
|
|
20
|
+
readonly hubURL: string
|
|
21
|
+
readonly openshell: OpenShellNodeConfig
|
|
22
|
+
readonly machineID?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createOpenShellRuntime(options: CreateOpenShellRuntimeOptions): OpenShellRuntime {
|
|
26
|
+
const { client, roots, hubURL, openshell, machineID } = options
|
|
27
|
+
return new OpenShellRuntime({
|
|
28
|
+
client,
|
|
29
|
+
roots,
|
|
30
|
+
hubURL,
|
|
31
|
+
profiles: openshell.profiles,
|
|
32
|
+
harnesses: ["opencode"],
|
|
33
|
+
...(openshell.image ? { image: openshell.image } : {}),
|
|
34
|
+
...(machineID ? { machineID } : {}),
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const probe = (): { signal: AbortSignal } => ({ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
39
|
+
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
40
|
+
|
|
41
|
+
/** The gateway's compute drivers as hello backends; [] when the client cannot tell or the gateway does not answer. */
|
|
42
|
+
export async function openshellBackends(client: Pick<OpenShellClient, "gatewayInfo">): Promise<string[]> {
|
|
43
|
+
if (typeof client.gatewayInfo !== "function") return []
|
|
44
|
+
try {
|
|
45
|
+
const { computeDrivers } = await client.gatewayInfo(probe())
|
|
46
|
+
return [...new Set(computeDrivers.filter((d) => token(d, 64)))].slice(0, BACKENDS_MAX)
|
|
47
|
+
} catch {
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A sandbox's launch phase for the hello: running while it is (or is becoming) Ready, else stopped. */
|
|
53
|
+
function launchPhase(sandbox: Sandbox): NodeLaunchReport["phase"] {
|
|
54
|
+
return sandbox.phase === "Ready" || sandbox.phase === "Provisioning" || sandbox.phase === "Starting" ? "running" : "stopped"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** This machine's sandboxes in the hub workspace, as launches; a failing list is a problem line, not an error. */
|
|
58
|
+
export async function sweepSandboxes(client: OpenShellClient, machineID: string): Promise<{ launches: NodeLaunchReport[]; problems: string[] }> {
|
|
59
|
+
let sandboxes: Sandbox[]
|
|
60
|
+
try {
|
|
61
|
+
sandboxes = await client.list({ "hub.machine": labelValue(machineID) }, probe())
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
return { launches: [], problems: [`could not list this machine's OpenShell sandboxes: ${errorText(cause)}`] }
|
|
64
|
+
}
|
|
65
|
+
const launches: NodeLaunchReport[] = []
|
|
66
|
+
for (const sandbox of sandboxes) {
|
|
67
|
+
const launchID = sandbox.labels["hub.launch"]
|
|
68
|
+
if (!launchID || sandboxName(launchID) !== sandbox.name) continue
|
|
69
|
+
if (sandbox.labels["hub.machine"] !== labelValue(machineID) || sandbox.phase === "Deleting") continue
|
|
70
|
+
launches.push({ launchID, phase: launchPhase(sandbox), sandbox: sandbox.name })
|
|
71
|
+
}
|
|
72
|
+
return { launches, problems: [] }
|
|
73
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// `hub node`'s program: read the machine's credentials from the spoke's config.json (read-only) and the node's
|
|
2
|
+
// settings from node.json, then run the daemon until the signal aborts. With node.json `openshell.enabled` the node
|
|
3
|
+
// also offers the openshell runtime (sandboxes through the openshell CLI): it checks the CLI, asks the gateway for
|
|
4
|
+
// its backends and sweeps this machine's sandboxes from an earlier run into the hello; an unusable CLI is logged and
|
|
5
|
+
// the node runs with the host runtime only. The host runtime offers the harnesses found on this machine
|
|
6
|
+
// (harnesses.ts: opencode, claude-code with its CLI, node.json `harnesses` overriding). Exit codes: 0 after a clean stop, 1 when
|
|
7
|
+
// the machine is not enrolled, EXIT_CONFIG (78, EX_CONFIG) when node.json gives the node nothing to do; the
|
|
8
|
+
// service units do not restart on 78.
|
|
9
|
+
import { homedir } from "node:os"
|
|
10
|
+
import type { NodeLaunchReport } from "../../protocol/src/index.ts"
|
|
11
|
+
import { OpenShellCli } from "../../runtime-openshell/src/cli.ts"
|
|
12
|
+
import type { Runtime } from "../../runtime-sdk/src/index.ts"
|
|
13
|
+
import { nodeConfigPath, readNodeConfig, readSpokeCredentials, type NodeConfig, type SpokeCredentials } from "./config.ts"
|
|
14
|
+
import { NodeDaemon, NODE_VERSION } from "./daemon.ts"
|
|
15
|
+
import { detectHarnesses } from "./harnesses.ts"
|
|
16
|
+
import { HostRuntime } from "./host.ts"
|
|
17
|
+
import { createOpenShellRuntime, openshellBackends, PROBE_TIMEOUT_MS, sweepSandboxes } from "./openshell.ts"
|
|
18
|
+
|
|
19
|
+
export const EXIT_CONFIG = 78
|
|
20
|
+
|
|
21
|
+
type Env = Readonly<Record<string, string | undefined>>
|
|
22
|
+
|
|
23
|
+
export interface RunNodeOptions {
|
|
24
|
+
readonly env?: Env
|
|
25
|
+
readonly home?: string
|
|
26
|
+
/** One line per event (default stderr). Never given the token. */
|
|
27
|
+
readonly log?: (line: string) => void
|
|
28
|
+
/** Aborting stops the daemon; runNode then resolves 0. */
|
|
29
|
+
readonly signal: AbortSignal
|
|
30
|
+
/** The openshell executable (default "openshell" on PATH). */
|
|
31
|
+
readonly openshellBinary?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface OpenShellSetup {
|
|
35
|
+
readonly runtime: Runtime
|
|
36
|
+
readonly backends: readonly string[]
|
|
37
|
+
readonly recovered: readonly NodeLaunchReport[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The openshell runtime, its backends and the sandboxes an earlier run left; undefined when the CLI is unusable. */
|
|
41
|
+
async function setupOpenShell(config: NodeConfig, credentials: SpokeCredentials, options: RunNodeOptions, env: Env, log: (line: string) => void): Promise<OpenShellSetup | undefined> {
|
|
42
|
+
const openshell = config.openshell!
|
|
43
|
+
const client = new OpenShellCli({ ...(options.openshellBinary ? { binary: options.openshellBinary } : {}), ...(openshell.gateway ? { gateway: openshell.gateway } : {}), env })
|
|
44
|
+
try {
|
|
45
|
+
await client.version({ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
|
|
46
|
+
} catch (cause) {
|
|
47
|
+
log(`openshell is enabled in node.json but the openshell CLI is not available (${(cause as Error).message}); offering the host runtime only`)
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
const runtime = createOpenShellRuntime({ client, roots: config.workspaceRoots, hubURL: credentials.server, openshell, ...(credentials.machineID ? { machineID: credentials.machineID } : {}) })
|
|
51
|
+
const backends = await openshellBackends(client)
|
|
52
|
+
let recovered: NodeLaunchReport[] = []
|
|
53
|
+
if (credentials.machineID) {
|
|
54
|
+
const sweep = await sweepSandboxes(client, credentials.machineID)
|
|
55
|
+
for (const problem of sweep.problems) log(problem)
|
|
56
|
+
recovered = sweep.launches
|
|
57
|
+
} else log("config.json has no machine ID, so sandboxes from an earlier run cannot be found")
|
|
58
|
+
log(`openshell: profiles ${runtime.info().profiles?.join(", ")}; backends ${backends.join(", ") || "unknown"}; ${recovered.length} sandbox(es) from an earlier run`)
|
|
59
|
+
return { runtime, backends, recovered }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runNode(options: RunNodeOptions): Promise<number> {
|
|
63
|
+
const env = options.env ?? process.env
|
|
64
|
+
const home = options.home ?? homedir()
|
|
65
|
+
const log = options.log ?? ((line: string) => process.stderr.write(`[hub node] ${line}\n`))
|
|
66
|
+
|
|
67
|
+
const credentials = readSpokeCredentials(env, home)
|
|
68
|
+
if (!credentials) {
|
|
69
|
+
log("this machine is not enrolled with a hub (config.json has no machine token); enrol it with the OpenCode plugin's /hub dialog first")
|
|
70
|
+
return 1
|
|
71
|
+
}
|
|
72
|
+
const file = nodeConfigPath(env, home)
|
|
73
|
+
const { config, problems } = readNodeConfig(file)
|
|
74
|
+
for (const problem of problems) log(`${file}: ${problem}`)
|
|
75
|
+
if (config.workspaceRoots.length === 0) {
|
|
76
|
+
log(`${file} lists no workspaceRoots, so this node can prepare no workspaces; add e.g. {"workspaceRoots": ["${home}/projects"]}`)
|
|
77
|
+
return EXIT_CONFIG
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (options.signal.aborted) return 0
|
|
81
|
+
const openshell = config.openshell?.enabled ? await setupOpenShell(config, credentials, options, env, log) : undefined
|
|
82
|
+
const harnesses = detectHarnesses({ env, ...(config.harnesses ? { overrides: config.harnesses } : {}) })
|
|
83
|
+
const runtimes: Runtime[] = [new HostRuntime({ roots: config.workspaceRoots, harnesses }), ...(openshell ? [openshell.runtime] : [])]
|
|
84
|
+
|
|
85
|
+
const daemon = new NodeDaemon({
|
|
86
|
+
config,
|
|
87
|
+
credentials,
|
|
88
|
+
runtimes,
|
|
89
|
+
...(openshell ? { backends: { openshell: openshell.backends }, recovered: openshell.recovered } : {}),
|
|
90
|
+
onState: (state, error) => {
|
|
91
|
+
if (state === "connected") log(`connected to ${credentials.server}`)
|
|
92
|
+
else if (state === "unsupported") log(`${error ?? "the hub does not serve nodes"}; retrying later`)
|
|
93
|
+
else if (state === "revoked") log("this machine was revoked on the hub; stopping")
|
|
94
|
+
else if (state === "error" && error) log(`not connected: ${error}`)
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
log(`hub node ${NODE_VERSION}: ${config.slots} slots, roots ${config.workspaceRoots.join(", ")}; host harnesses ${harnesses.join(", ") || "none"}`)
|
|
98
|
+
daemon.start()
|
|
99
|
+
await new Promise<void>((resolve) => {
|
|
100
|
+
if (options.signal.aborted) return resolve()
|
|
101
|
+
options.signal.addEventListener("abort", () => resolve(), { once: true })
|
|
102
|
+
})
|
|
103
|
+
daemon.stop()
|
|
104
|
+
log("stopped")
|
|
105
|
+
return 0
|
|
106
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huynle/hub-preset",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (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,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "goose",
|
|
3
|
+
"description": "goose, the open-source agent (interactive session). The hub names the session with --name; resuming needs --resume and --name together, which a preset cannot express yet.",
|
|
4
|
+
"command": ["goose", "session"],
|
|
5
|
+
"nativeID": { "source": "argv", "flag": "--name" },
|
|
6
|
+
"delivery": "paste",
|
|
7
|
+
"evidence": "screen"
|
|
8
|
+
}
|