@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,165 @@
|
|
|
1
|
+
// HMAC signing keys for hub-minted session tokens, kept apart from state.json in `<stateDir>/token-keys.json`
|
|
2
|
+
// (mode 0600, written atomically): {active: kid, keys: [{kid, secret (base64 of 32 random bytes), created}]}.
|
|
3
|
+
// The file is created on first use. rotate() adds a new active key and keeps the older ones so tokens they
|
|
4
|
+
// signed still verify. HUB_TOKEN_KEYS (same JSON shape) overrides the file, e.g. a Kubernetes secret shared by
|
|
5
|
+
// several pods; the file is then never written and rotation happens where the secret is managed.
|
|
6
|
+
// Secrets never appear in errors, logs, JSON or inspect output.
|
|
7
|
+
import { randomBytes } from "node:crypto"
|
|
8
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
9
|
+
import path from "node:path"
|
|
10
|
+
|
|
11
|
+
export const TOKEN_KEYS_FILE = "token-keys.json"
|
|
12
|
+
export const TOKEN_KEYS_ENV = "HUB_TOKEN_KEYS"
|
|
13
|
+
/** Bytes of a signing secret. */
|
|
14
|
+
export const TOKEN_KEY_BYTES = 32
|
|
15
|
+
|
|
16
|
+
export interface StoredTokenKey {
|
|
17
|
+
kid: string
|
|
18
|
+
/** Base64 of TOKEN_KEY_BYTES random bytes. */
|
|
19
|
+
secret: string
|
|
20
|
+
created: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TokenKeyFile {
|
|
24
|
+
active: string
|
|
25
|
+
keys: StoredTokenKey[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TokenKeysOptions {
|
|
29
|
+
readonly now?: () => number
|
|
30
|
+
/** Environment for the HUB_TOKEN_KEYS override (default process.env). */
|
|
31
|
+
readonly env?: Readonly<Record<string, string | undefined>>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Key {
|
|
35
|
+
kid: string
|
|
36
|
+
secret: Buffer
|
|
37
|
+
created: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const newKid = (): string => `k_${randomBytes(6).toString("base64url")}`
|
|
41
|
+
|
|
42
|
+
/** Parsed and validated key data; `source` names the origin in errors (never the content). */
|
|
43
|
+
function parseKeys(text: string, source: string): { active: string; keys: Key[] } {
|
|
44
|
+
const fail = (why: string): never => {
|
|
45
|
+
throw new Error(`Invalid ${source}: ${why}. Fix or move it away.`)
|
|
46
|
+
}
|
|
47
|
+
let raw: unknown
|
|
48
|
+
try {
|
|
49
|
+
raw = JSON.parse(text)
|
|
50
|
+
} catch {
|
|
51
|
+
return fail("not valid JSON")
|
|
52
|
+
}
|
|
53
|
+
if (typeof raw !== "object" || raw === null) return fail("not an object")
|
|
54
|
+
const { active, keys } = raw as Record<string, unknown>
|
|
55
|
+
if (typeof active !== "string" || !Array.isArray(keys)) return fail("expected {active, keys: [...]}")
|
|
56
|
+
const out: Key[] = []
|
|
57
|
+
for (const entry of keys as unknown[]) {
|
|
58
|
+
const { kid, secret, created } = (typeof entry === "object" && entry !== null ? entry : {}) as Record<string, unknown>
|
|
59
|
+
if (typeof kid !== "string" || !kid) return fail("a key has no kid")
|
|
60
|
+
if (out.some((key) => key.kid === kid)) return fail(`duplicate kid ${kid}`)
|
|
61
|
+
const bytes = typeof secret === "string" ? Buffer.from(secret, "base64") : Buffer.alloc(0)
|
|
62
|
+
if (typeof secret !== "string" || bytes.toString("base64") !== secret || bytes.length < TOKEN_KEY_BYTES) {
|
|
63
|
+
return fail(`key ${kid} needs a base64 secret of at least ${TOKEN_KEY_BYTES} bytes`)
|
|
64
|
+
}
|
|
65
|
+
out.push({ kid, secret: bytes, created: typeof created === "number" ? created : 0 })
|
|
66
|
+
}
|
|
67
|
+
if (!out.some((key) => key.kid === active)) return fail("the active kid is not among the keys")
|
|
68
|
+
return { active, keys: out }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class TokenKeys {
|
|
72
|
+
#active = ""
|
|
73
|
+
#keys: Key[] = []
|
|
74
|
+
#loaded = false
|
|
75
|
+
|
|
76
|
+
private constructor(
|
|
77
|
+
private readonly file: string,
|
|
78
|
+
private readonly now: () => number,
|
|
79
|
+
private readonly override: string | undefined,
|
|
80
|
+
) {}
|
|
81
|
+
|
|
82
|
+
/** Nothing is read or written until a key is needed. */
|
|
83
|
+
static open(dir: string, options: TokenKeysOptions = {}): TokenKeys {
|
|
84
|
+
const env = options.env ?? process.env
|
|
85
|
+
const override = env[TOKEN_KEYS_ENV]?.trim() || undefined
|
|
86
|
+
return new TokenKeys(path.join(dir, TOKEN_KEYS_FILE), options.now ?? Date.now, override)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The key new tokens are signed with (created on first use). */
|
|
90
|
+
active(): { kid: string; secret: Buffer } {
|
|
91
|
+
this.load()
|
|
92
|
+
const key = this.#keys.find((k) => k.kid === this.#active)!
|
|
93
|
+
return { kid: key.kid, secret: Buffer.from(key.secret) }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The secret for a kid, to verify a token; undefined when unknown. */
|
|
97
|
+
key(kid: string): Buffer | undefined {
|
|
98
|
+
this.load()
|
|
99
|
+
const key = this.#keys.find((k) => k.kid === kid)
|
|
100
|
+
return key ? Buffer.from(key.secret) : undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every kid, oldest first. */
|
|
104
|
+
kids(): string[] {
|
|
105
|
+
this.load()
|
|
106
|
+
return this.#keys.map((k) => k.kid)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Adds a new active key, keeping the previous ones for verification. Returns its kid. */
|
|
110
|
+
rotate(): string {
|
|
111
|
+
if (this.override) throw new Error(`Signing keys come from ${TOKEN_KEYS_ENV}; rotate them where that is managed`)
|
|
112
|
+
this.load(false)
|
|
113
|
+
let kid = newKid()
|
|
114
|
+
while (this.#keys.some((k) => k.kid === kid)) kid = newKid()
|
|
115
|
+
this.#keys.push({ kid, secret: randomBytes(TOKEN_KEY_BYTES), created: this.now() })
|
|
116
|
+
this.#active = kid
|
|
117
|
+
this.save()
|
|
118
|
+
return kid
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
toJSON(): { file: string; active: string; kids: string[] } {
|
|
122
|
+
return { file: this.file, active: this.#active, kids: this.#keys.map((k) => k.kid) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
toString(): string {
|
|
126
|
+
return `TokenKeys(${this.override ? TOKEN_KEYS_ENV : this.file})`
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
|
130
|
+
return this.toString()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Reads the keys once; with `create`, a missing file gets its first key. */
|
|
134
|
+
private load(create = true): void {
|
|
135
|
+
if (this.#loaded) return
|
|
136
|
+
if (this.override) {
|
|
137
|
+
this.set(parseKeys(this.override, TOKEN_KEYS_ENV))
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (existsSync(this.file)) {
|
|
141
|
+
this.set(parseKeys(readFileSync(this.file, "utf8"), this.file))
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
this.#loaded = true
|
|
145
|
+
if (create) this.rotate()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private set(data: { active: string; keys: Key[] }): void {
|
|
149
|
+
this.#active = data.active
|
|
150
|
+
this.#keys = data.keys
|
|
151
|
+
this.#loaded = true
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private save(): void {
|
|
155
|
+
const data: TokenKeyFile = {
|
|
156
|
+
active: this.#active,
|
|
157
|
+
keys: this.#keys.map((k) => ({ kid: k.kid, secret: k.secret.toString("base64"), created: k.created })),
|
|
158
|
+
}
|
|
159
|
+
mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 })
|
|
160
|
+
const tmp = `${this.file}.${process.pid}.tmp`
|
|
161
|
+
writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 })
|
|
162
|
+
chmodSync(tmp, 0o600)
|
|
163
|
+
renameSync(tmp, this.file)
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Per-machine spoke configuration, shared by every OpenCode process on the machine and edited
|
|
2
2
|
// from the /hub dialog. Holds the machine token, so it is written with mode 0600.
|
|
3
|
-
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
4
4
|
import { homedir } from "node:os"
|
|
5
5
|
import path from "node:path"
|
|
6
6
|
import { hubEnv, type Env } from "./env"
|
|
7
7
|
import { APP_DIR, configBase, legacyPaths, migrateFirst } from "./migrate"
|
|
8
|
+
import { isHubURL } from "./protocol"
|
|
8
9
|
|
|
9
10
|
export type Mode = "off" | "always" | "session"
|
|
10
11
|
|
|
@@ -168,3 +169,50 @@ export function setSession(config: VoiceConfig, sessionID: string, on: boolean):
|
|
|
168
169
|
const sessions = on ? [...new Set([...config.sessions, sessionID])] : config.sessions.filter((id) => id !== sessionID)
|
|
169
170
|
return { ...config, sessions }
|
|
170
171
|
}
|
|
172
|
+
|
|
173
|
+
// ---------- launch-bound mode ----------
|
|
174
|
+
// A spoke inside a launch's sandbox (OpenShell, design §6.4) connects with a credential bound to that launch. The node
|
|
175
|
+
// writes {hubURL, token, launchID} next to node.json before the harness starts; the spoke only reads it: no enrolment,
|
|
176
|
+
// no local hub, no config writes.
|
|
177
|
+
|
|
178
|
+
export interface LaunchConfig {
|
|
179
|
+
readonly hubURL: string
|
|
180
|
+
/** The launch-bound credential (hlt1…); never logged. */
|
|
181
|
+
readonly token: string
|
|
182
|
+
readonly launchID: string
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Where the node puts the launch config inside a sandbox. */
|
|
186
|
+
export const LAUNCH_CONFIG_DEFAULT = "/sandbox/.hub/config.json"
|
|
187
|
+
const LAUNCH_ID = /^[A-Za-z0-9_.:@/+~-]{1,300}$/
|
|
188
|
+
const LAUNCH_TOKEN = /^[\x21-\x7e]{1,4096}$/
|
|
189
|
+
|
|
190
|
+
/** A launch config, or undefined unless every field is present and valid. */
|
|
191
|
+
export function parseLaunchConfig(raw: unknown): LaunchConfig | undefined {
|
|
192
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
|
|
193
|
+
const r = raw as Record<string, unknown>
|
|
194
|
+
if (!isHubURL(r.hubURL)) return undefined
|
|
195
|
+
if (typeof r.token !== "string" || !LAUNCH_TOKEN.test(r.token)) return undefined
|
|
196
|
+
if (typeof r.launchID !== "string" || !LAUNCH_ID.test(r.launchID)) return undefined
|
|
197
|
+
return { hubURL: r.hubURL.replace(/\/+$/, ""), token: r.token, launchID: r.launchID }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** $HUB_LAUNCH_CONFIG, else LAUNCH_CONFIG_DEFAULT when it exists; undefined: not launch-bound. */
|
|
201
|
+
export function launchConfigPath(env: Env = process.env, exists: (file: string) => boolean = existsSync): string | undefined {
|
|
202
|
+
const explicit = env.HUB_LAUNCH_CONFIG
|
|
203
|
+
if (explicit) return explicit
|
|
204
|
+
return exists(LAUNCH_CONFIG_DEFAULT) ? LAUNCH_CONFIG_DEFAULT : undefined
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Reads a launch config; throws (without the token) when it is missing, unparsable or invalid. */
|
|
208
|
+
export function readLaunchConfig(file: string): LaunchConfig {
|
|
209
|
+
let raw: unknown
|
|
210
|
+
try {
|
|
211
|
+
raw = JSON.parse(readFileSync(file, "utf8"))
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
throw new Error(`Cannot read the launch config ${file}: ${cause instanceof SyntaxError ? "not JSON" : (cause as NodeJS.ErrnoException).code ?? "unreadable"}`)
|
|
214
|
+
}
|
|
215
|
+
const config = parseLaunchConfig(raw)
|
|
216
|
+
if (!config) throw new Error(`Invalid launch config ${file}: expected {hubURL, token, launchID}`)
|
|
217
|
+
return config
|
|
218
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
// Hub server plugin: a thin "spoke" that connects this OpenCode process to the hub. The web
|
|
2
2
|
// console, phones and (optional) Voice speech live on the hub; this side only exposes sessions, forwards
|
|
3
3
|
// their events, and runs remote / voice turns and permission replies on request.
|
|
4
|
-
import { readFileSync } from "node:fs"
|
|
4
|
+
import { mkdirSync, readFileSync } from "node:fs"
|
|
5
5
|
import { hostname } from "node:os"
|
|
6
|
+
import { fileURLToPath } from "node:url"
|
|
6
7
|
import { Plugin } from "@opencode/plugin"
|
|
7
8
|
import { DEFAULT_SUBAGENT, prepareConfigPath } from "./config"
|
|
8
9
|
import { HubRpc, VoiceRpc } from "./rpc"
|
|
10
|
+
import { jobFromInfo, type JobInfo } from "./protocol"
|
|
9
11
|
import { checkServer, parseServerInput } from "./setup"
|
|
10
|
-
import { defineVoiceAgent, orchestratorParts } from "./spoke/conversation"
|
|
12
|
+
import { defineVoiceAgent, orchestratorParts, STOP_TOOL, STOP_TOOL_DESCRIPTION } from "./spoke/conversation"
|
|
13
|
+
import { envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
|
|
11
14
|
import { herdrRunner } from "./spoke/herdr"
|
|
15
|
+
import { launchSource } from "./spoke/launch-source"
|
|
16
|
+
import { HubRpcError, hubMsg, hubWho } from "./spoke/hub-rpc"
|
|
17
|
+
import { HUB_TOOL, HUB_TOOL_DESCRIPTION, HUB_TOOL_INPUT, runHubTool } from "./spoke/hub-tool"
|
|
18
|
+
import { cliEntry, installLaunchers } from "./spoke/launchers"
|
|
12
19
|
import { listServiceSessions } from "./spoke/listing"
|
|
13
20
|
import { ensureLocalHub } from "./spoke/localhub"
|
|
21
|
+
import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
|
|
22
|
+
import { PromptReferences } from "./spoke/prompt-refs"
|
|
14
23
|
import { replyPermission, type PermissionLocation } from "./spoke/locations"
|
|
15
24
|
import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
|
|
16
25
|
import { joinProcess, type Membership } from "./spoke/singleton"
|
|
@@ -70,6 +79,45 @@ async function replyForm(kind: "service" | "standalone", action: FormAction): Pr
|
|
|
70
79
|
}
|
|
71
80
|
}
|
|
72
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Has OpenCode regenerate a session's title with its title agent: session.update with an empty title over
|
|
84
|
+
* the own server's HTTP API (the handler runs SessionTitle.generate and returns when it is done). The plugin
|
|
85
|
+
* API's session.update would rename the session to "" instead. "unavailable" when the server cannot be reached.
|
|
86
|
+
*/
|
|
87
|
+
async function generateTitle(kind: "service" | "standalone", sessionID: string): Promise<"done" | "unavailable"> {
|
|
88
|
+
const client = await ownClient(kind).catch(() => undefined)
|
|
89
|
+
if (!client) return "unavailable"
|
|
90
|
+
await client.session.update({ sessionID, title: "" })
|
|
91
|
+
return "done"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What runs now on this process's own server: session.active (sessions with a run in progress) and the running
|
|
96
|
+
* shells of the given directories (shell.list, per location) that belong to a session. Empty when the server
|
|
97
|
+
* cannot be reached.
|
|
98
|
+
*/
|
|
99
|
+
async function activity(kind: "service" | "standalone", directories: readonly string[]) {
|
|
100
|
+
const client = await ownClient(kind).catch(() => undefined)
|
|
101
|
+
if (!client) return { active: [], jobs: [] }
|
|
102
|
+
const active = unwrap<Record<string, unknown>>(await client.session.active(), (v) => v && typeof v === "object" && !("data" in v))
|
|
103
|
+
const jobs: Array<JobInfo & { sessionID: string }> = []
|
|
104
|
+
const seen = new Set<string>()
|
|
105
|
+
for (const directory of directories) {
|
|
106
|
+
try {
|
|
107
|
+
const list = unwrap<unknown[]>(await client.shell.list({ location: { directory } }), Array.isArray)
|
|
108
|
+
for (const raw of Array.isArray(list) ? list : []) {
|
|
109
|
+
const job = jobFromInfo(raw, "shell")
|
|
110
|
+
if (!job?.sessionID || job.status !== "running" || seen.has(job.id)) continue
|
|
111
|
+
seen.add(job.id)
|
|
112
|
+
jobs.push(job as JobInfo & { sessionID: string })
|
|
113
|
+
}
|
|
114
|
+
} catch {
|
|
115
|
+
// a location this server does not serve
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { active: Object.keys(active ?? {}), jobs }
|
|
119
|
+
}
|
|
120
|
+
|
|
73
121
|
/** Plugin API results may arrive bare or wrapped as { location, data }. */
|
|
74
122
|
function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
|
|
75
123
|
const v = value as any
|
|
@@ -97,12 +145,18 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
97
145
|
opencodeVersion: ctx.app?.version ?? "unknown",
|
|
98
146
|
events: (signal) => ctx.event.subscribe({ signal }) as AsyncIterable<any>,
|
|
99
147
|
prompt: (input) => session.prompt(input),
|
|
148
|
+
// OpenCode's native synthetic messages (hub deliveries); without them the spoke does not announce "deliver".
|
|
149
|
+
...(typeof session.synthetic === "function"
|
|
150
|
+
? { synthetic: async (input: Parameters<NonNullable<SpokeHost["synthetic"]>>[0]) => unwrap<{ id?: string }>(await session.synthetic(input), (v) => typeof v.id === "string") }
|
|
151
|
+
: {}),
|
|
100
152
|
context: (sessionID) => session.context({ sessionID }),
|
|
101
153
|
listModels: async () => unwrap<ReadonlyArray<unknown>>(await (ctx.model as any).list({}), Array.isArray),
|
|
102
154
|
getSession: async (sessionID) => unwrap(await session.get({ sessionID }), (v) => typeof v.id === "string"),
|
|
103
155
|
replyPermission: ({ directory, ...input }) => replyPermission(locations(), input, directory),
|
|
104
156
|
replyForm: (action) => replyForm(kind, action),
|
|
105
157
|
interrupt: (sessionID) => session.interrupt({ sessionID }),
|
|
158
|
+
updateSession: (sessionID, input) => session.update({ sessionID, ...input }),
|
|
159
|
+
generateTitle: (sessionID) => generateTitle(kind, sessionID),
|
|
106
160
|
switchAgent: (sessionID, id) => session.switchAgent({ sessionID, agent: id }),
|
|
107
161
|
switchModel: (sessionID, model) => session.switchModel({ sessionID, model }),
|
|
108
162
|
hasAgent: async (id) => {
|
|
@@ -118,6 +172,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
118
172
|
return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
|
|
119
173
|
},
|
|
120
174
|
listRecent,
|
|
175
|
+
activity: (directories) => activity(kind, directories),
|
|
121
176
|
createSession: async ({ directory, title }) =>
|
|
122
177
|
unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
|
|
123
178
|
...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
|
|
@@ -133,24 +188,63 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
133
188
|
}
|
|
134
189
|
}
|
|
135
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Gives every shell the agent runs through the shell tool its session's HUB_URL/HUB_SESSION_TOKEN and puts the hub
|
|
193
|
+
* bin dir first on PATH (src/spoke/env-inject.ts). Known gap: user `!` commands have no tool call, so get no token.
|
|
194
|
+
*/
|
|
195
|
+
async function registerShellEnv(ctx: Context, identity: (sessionID: string) => { token: string; url?: string } | undefined) {
|
|
196
|
+
const binDir = hubBinDir()
|
|
197
|
+
try {
|
|
198
|
+
mkdirSync(binDir, { recursive: true, mode: 0o700 })
|
|
199
|
+
// The `hub`/`hubctl` launchers run the CLI with this runtime (OpenCode's binary as bun; src/spoke/launchers.ts).
|
|
200
|
+
const bun = typeof process.versions.bun === "string"
|
|
201
|
+
const entry = cliEntry(fileURLToPath(new URL("..", import.meta.url)), bun)
|
|
202
|
+
if (entry) installLaunchers({ binDir, execPath: process.execPath, bun, entry })
|
|
203
|
+
else console.error("[hub] no built hub CLI (packages/cli/dist/hub-cli.js): `hub` is not installed")
|
|
204
|
+
} catch (cause) {
|
|
205
|
+
console.error("[hub] could not install the hub CLI launchers", cause)
|
|
206
|
+
}
|
|
207
|
+
const injector = envInjector({ correlator: new ShellCorrelator(), binDir, identity })
|
|
208
|
+
const tool = (ctx as any).tool
|
|
209
|
+
const shell = (ctx as any).shell
|
|
210
|
+
try {
|
|
211
|
+
if (!tool?.hook || !shell?.hook) throw new Error("this OpenCode has no tool/shell hooks")
|
|
212
|
+
await tool.hook("execute.before", (event: any) => injector.toolBefore(event))
|
|
213
|
+
await tool.hook("execute.after", (event: any) => injector.toolAfter(event))
|
|
214
|
+
await shell.hook("create.before", (invocation: any) => injector.shellBefore(invocation))
|
|
215
|
+
} catch (cause) {
|
|
216
|
+
console.error("[hub] could not register the shell environment hooks", cause)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
136
220
|
export default Plugin.define({
|
|
137
221
|
id: "hub",
|
|
138
222
|
async setup(ctx) {
|
|
223
|
+
// An OpenCode started from a session's shell must not act as that session (nor hand its token to its shells).
|
|
224
|
+
stripInheritedHubEnv(process.env)
|
|
139
225
|
const kind = detectKind(process.argv)
|
|
226
|
+
// Inside a launch's sandbox the spoke is launch-bound: the node's read-only launch config and node.json, no
|
|
227
|
+
// machine config, enrolment, local hub or Herdr (src/spoke/launch-source.ts). A broken one leaves no spoke.
|
|
228
|
+
const source = launchSource()
|
|
140
229
|
const membership: Membership<Spoke> = joinProcess(
|
|
141
|
-
(processID) =>
|
|
142
|
-
|
|
230
|
+
(processID) => {
|
|
231
|
+
if (source.kind === "invalid") throw new Error(`${source.error}; the hub spoke is off in this process`)
|
|
232
|
+
const host = hostFrom(ctx, () => membership.locals<PermissionLocation>(), kind)
|
|
233
|
+
const common = { machineName: hostname(), kind, processID, pid: process.pid, pluginVersion: pluginVersion() }
|
|
234
|
+
if (source.kind === "launch") {
|
|
235
|
+
const nodeConfig = source.nodeConfig
|
|
236
|
+
return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig) })
|
|
237
|
+
}
|
|
238
|
+
return new Spoke(host, {
|
|
239
|
+
...common,
|
|
143
240
|
configFile: prepareConfigPath(),
|
|
144
|
-
machineName: hostname(),
|
|
145
|
-
kind,
|
|
146
|
-
processID,
|
|
147
|
-
pid: process.pid,
|
|
148
|
-
pluginVersion: pluginVersion(),
|
|
149
241
|
// Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
|
|
150
242
|
localHub: (server) => ensureLocalHub(server),
|
|
151
|
-
// One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts).
|
|
152
|
-
|
|
153
|
-
|
|
243
|
+
// One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts). Launch creates
|
|
244
|
+
// (service only) need the node workspace roots, read from node.json each time (src/spoke/node-roots.ts).
|
|
245
|
+
...(kind === "service" ? { herdr: herdrRunner(), workspaceRoots: () => readWorkspaceRoots(nodeConfigPath()) } : {}),
|
|
246
|
+
})
|
|
247
|
+
},
|
|
154
248
|
undefined,
|
|
155
249
|
permissionLocation(ctx),
|
|
156
250
|
)
|
|
@@ -170,6 +264,7 @@ export default Plugin.define({
|
|
|
170
264
|
conversation: spoke?.inConversation(event.sessionID) ?? false,
|
|
171
265
|
subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
|
|
172
266
|
workModel: spoke?.workModelFor(event.sessionID),
|
|
267
|
+
taskBoard: spoke?.taskBoard(event.sessionID),
|
|
173
268
|
})
|
|
174
269
|
for (const text of parts) event.system.push({ type: "text", text })
|
|
175
270
|
})
|
|
@@ -177,6 +272,60 @@ export default Plugin.define({
|
|
|
177
272
|
console.error("[hub] could not register the context hook", cause)
|
|
178
273
|
}
|
|
179
274
|
|
|
275
|
+
try {
|
|
276
|
+
// The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.
|
|
277
|
+
await (ctx as any).tool?.transform?.((editor: any) =>
|
|
278
|
+
editor.add({
|
|
279
|
+
name: STOP_TOOL,
|
|
280
|
+
description: STOP_TOOL_DESCRIPTION,
|
|
281
|
+
input: {
|
|
282
|
+
type: "object",
|
|
283
|
+
properties: {
|
|
284
|
+
sessionID: { type: "string", description: "The worker's sessionID, from your task board or the subagent tool's result." },
|
|
285
|
+
all: { type: "boolean", description: "true: stop every worker of yours that is still running." },
|
|
286
|
+
},
|
|
287
|
+
additionalProperties: false,
|
|
288
|
+
},
|
|
289
|
+
options: { codemode: false },
|
|
290
|
+
execute: async (value: unknown, context: { sessionID: string }) => {
|
|
291
|
+
const spoke = membership.active()
|
|
292
|
+
if (!spoke) return { content: "The hub is not running in this OpenCode process; nothing was stopped." }
|
|
293
|
+
const { stopped, text } = await spoke.stopWorkers(context.sessionID, (value ?? {}) as Record<string, unknown>)
|
|
294
|
+
return { content: text, metadata: { stopped } }
|
|
295
|
+
},
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
} catch (cause) {
|
|
299
|
+
console.error("[hub] could not register the task stop tool", cause)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
// Agents message and ask other sessions as themselves (the session's own hub token; src/spoke/hub-tool.ts).
|
|
304
|
+
await (ctx as any).tool?.transform?.((editor: any) =>
|
|
305
|
+
editor.add({
|
|
306
|
+
name: HUB_TOOL,
|
|
307
|
+
description: HUB_TOOL_DESCRIPTION,
|
|
308
|
+
input: HUB_TOOL_INPUT,
|
|
309
|
+
options: { codemode: false },
|
|
310
|
+
execute: (value: unknown, context: { sessionID: string; signal?: AbortSignal }) =>
|
|
311
|
+
runHubTool(() => membership.active(), value, { sessionID: context.sessionID, ...(context.signal ? { signal: context.signal } : {}) }),
|
|
312
|
+
}),
|
|
313
|
+
)
|
|
314
|
+
} catch (cause) {
|
|
315
|
+
console.error("[hub] could not register the hub tool", cause)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
// @name / @@role in a prompt get context from the hub as an attached text file; the text is never changed
|
|
320
|
+
// and the harness's own @ meanings win (src/spoke/prompt-refs.ts, design §2.4).
|
|
321
|
+
const references = new PromptReferences(() => membership.active())
|
|
322
|
+
await ctx.session.hook("prompt", (event) => references.onPrompt(event))
|
|
323
|
+
} catch (cause) {
|
|
324
|
+
console.error("[hub] could not register the prompt hook", cause)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
await registerShellEnv(ctx, (sessionID) => membership.active()?.shellIdentity(sessionID))
|
|
328
|
+
|
|
180
329
|
const run = async <T>(rpc: { error: (...args: any[]) => any }, action: (spoke: Spoke) => T | Promise<T>) => {
|
|
181
330
|
const spoke = membership.active()
|
|
182
331
|
if (!spoke) return rpc.error("unavailable", "The hub spoke is not running in this OpenCode process", {})
|
|
@@ -184,6 +333,7 @@ export default Plugin.define({
|
|
|
184
333
|
return await action(spoke)
|
|
185
334
|
} catch (cause) {
|
|
186
335
|
if (cause instanceof SpokeError) return rpc.error(cause.name, cause.message, {})
|
|
336
|
+
if (cause instanceof HubRpcError) return rpc.error(cause.code, cause.message, {})
|
|
187
337
|
return rpc.error("server_error", cause instanceof Error ? cause.message : String(cause), {})
|
|
188
338
|
}
|
|
189
339
|
}
|
|
@@ -221,6 +371,9 @@ export default Plugin.define({
|
|
|
221
371
|
s.presence(sessionIDs.filter((id): id is string => typeof id === "string").slice(0, 200), typeof reporter === "string" ? reporter.slice(0, 100) : undefined)
|
|
222
372
|
return {}
|
|
223
373
|
}),
|
|
374
|
+
// The TUI's /who and /msg, as its current session (src/spoke/hub-rpc.ts).
|
|
375
|
+
hubWho: (value, rpc) => run(rpc, (s) => hubWho(s, value)),
|
|
376
|
+
hubMsg: (value, rpc) => run(rpc, (s) => hubMsg(s, value)),
|
|
224
377
|
} satisfies Parameters<typeof ctx.rpc.register<typeof HubRpc>>[1]
|
|
225
378
|
// "hub", plus the opencode-voice id "voice" for clients built against the old name (see src/rpc.ts).
|
|
226
379
|
for (const definition of [HubRpc, VoiceRpc]) {
|