@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.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. 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
- new Spoke(hostFrom(ctx, () => membership.locals<PermissionLocation>(), kind), {
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
- ...(kind === "service" ? { herdr: herdrRunner() } : {}),
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]) {