@huynle/hub 0.7.1 → 0.7.2

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 (185) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -58,6 +58,9 @@ export interface ViewPrefs {
58
58
  }
59
59
 
60
60
  export const VIEW_PREF_KEYS = ["hiddenSessions", "hiddenWorkspaces", "pinnedSessions", "pinnedWorkspaces"] as const
61
+ /** Archived sessions accumulate for longer than the other view-preference lists. */
62
+ export const VIEW_PREF_MAX_ARCHIVED = 5000
63
+ export const VIEW_PREF_MAX_PINNED = 500
61
64
 
62
65
  export interface AuditEntry {
63
66
  at: number
@@ -911,6 +914,56 @@ export class Store {
911
914
  })
912
915
  }
913
916
 
917
+ /** Changes one archived session atomically; undefined means the bounded archive list is full. */
918
+ setSessionArchived(id: string, sessionID: string, archived: boolean): Promise<{ changed: boolean } | undefined> {
919
+ return this.mutate((tx) => {
920
+ const user = this.user(id)
921
+ if (!user) return undefined
922
+ const next = this.viewPrefs(id)
923
+ const listed = next.hiddenSessions.includes(sessionID)
924
+ const changed = archived !== listed
925
+ if (!changed) return { changed }
926
+ if (archived && next.hiddenSessions.length >= VIEW_PREF_MAX_ARCHIVED) return undefined
927
+ next.hiddenSessions = archived ? [...next.hiddenSessions, sessionID] : next.hiddenSessions.filter((id) => id !== sessionID)
928
+ user.viewPrefs = next
929
+ tx.touch("users", id)
930
+ return { changed }
931
+ })
932
+ }
933
+
934
+ /** Changes one pinned session atomically while preserving every other preference list. */
935
+ setSessionPinned(id: string, sessionID: string, pinned: boolean): Promise<{ changed: boolean } | undefined> {
936
+ return this.mutate((tx) => {
937
+ const user = this.user(id)
938
+ if (!user) return undefined
939
+ const next = this.viewPrefs(id)
940
+ const listed = next.pinnedSessions.includes(sessionID)
941
+ const changed = pinned !== listed
942
+ if (!changed) return { changed }
943
+ if (pinned && next.pinnedSessions.length >= VIEW_PREF_MAX_PINNED) return undefined
944
+ next.pinnedSessions = pinned ? [...next.pinnedSessions, sessionID] : next.pinnedSessions.filter((id) => id !== sessionID)
945
+ user.viewPrefs = next
946
+ tx.touch("users", id)
947
+ return { changed }
948
+ })
949
+ }
950
+
951
+ /** Archives only while still unpinned, atomically against concurrent preference writes. */
952
+ archiveSessionIfUnpinned(id: string, sessionID: string): Promise<"changed" | "pinned" | "archived" | "full" | "not_found"> {
953
+ return this.mutate((tx) => {
954
+ const user = this.user(id)
955
+ if (!user) return "not_found"
956
+ const next = this.viewPrefs(id)
957
+ if (next.hiddenSessions.includes(sessionID)) return "archived"
958
+ if (next.pinnedSessions.includes(sessionID)) return "pinned"
959
+ if (next.hiddenSessions.length >= VIEW_PREF_MAX_ARCHIVED) return "full"
960
+ next.hiddenSessions = [...next.hiddenSessions, sessionID]
961
+ user.viewPrefs = next
962
+ tx.touch("users", id)
963
+ return "changed"
964
+ })
965
+ }
966
+
914
967
  // ---------- per-user, per-session model preferences ----------
915
968
 
916
969
  modelPrefs(id: string, sessionID: string): SessionModelPrefs {
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
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, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
3
+ import { randomBytes } from "node:crypto"
4
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs"
4
5
  import { homedir } from "node:os"
5
6
  import path from "node:path"
6
7
  import { hubEnv, type Env } from "./env"
@@ -142,13 +143,75 @@ export function readConfig(file: string, machineName: string): VoiceConfig {
142
143
  }
143
144
  }
144
145
 
145
- /** Atomic write with 0600 permissions. */
146
- export function writeConfig(file: string, config: VoiceConfig): void {
146
+ /** The keys VoiceConfig owns in config.json; every other key belongs to someone else and is preserved. */
147
+ const CONFIG_KEYS = ["server", "mode", "sessions", "machineName", "subagent", "autoStartLocalHub", "herdr", "list", "voiceModel", "workModel", "machineID", "token"] as const satisfies readonly (keyof VoiceConfig)[]
148
+
149
+ type RawConfig = Record<string, unknown>
150
+
151
+ const isObject = (v: unknown): v is RawConfig => typeof v === "object" && v !== null && !Array.isArray(v)
152
+
153
+ /** The raw JSON object in `file` (every key, known or not); undefined when the file does not exist. */
154
+ export function readRawConfig(file: string): RawConfig | undefined {
155
+ let text: string
156
+ try {
157
+ text = readFileSync(file, "utf8")
158
+ } catch (cause) {
159
+ if ((cause as NodeJS.ErrnoException).code === "ENOENT") return undefined
160
+ throw cause
161
+ }
162
+ let parsed: unknown
163
+ try {
164
+ parsed = JSON.parse(text)
165
+ } catch {
166
+ parsed = undefined
167
+ }
168
+ if (!isObject(parsed)) throw new Error(`${file} is not a JSON object; fix or remove it first`)
169
+ return parsed
170
+ }
171
+
172
+ /** Writes `content` to `file` atomically (a private temp file in the same directory, fsync, rename), mode 0600. */
173
+ function writePrivate(file: string, content: string): void {
147
174
  mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
148
- const tmp = `${file}.${process.pid}.tmp`
149
- writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
150
- chmodSync(tmp, 0o600)
151
- renameSync(tmp, file)
175
+ const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
176
+ let fd: number | undefined
177
+ try {
178
+ fd = openSync(tmp, "wx", 0o600)
179
+ writeSync(fd, content)
180
+ fsyncSync(fd)
181
+ closeSync(fd)
182
+ fd = undefined
183
+ chmodSync(tmp, 0o600)
184
+ renameSync(tmp, file)
185
+ } catch (cause) {
186
+ if (fd !== undefined) closeSync(fd)
187
+ rmSync(tmp, { force: true })
188
+ throw cause
189
+ }
190
+ }
191
+
192
+ const serialize = (raw: RawConfig) => `${JSON.stringify(raw, null, 2)}\n`
193
+
194
+ /**
195
+ * Lossless read-modify-write of config.json: `update` gets the raw object (every key, {} for a missing file) and
196
+ * returns the next one, written atomically with 0600. Refuses (throws, file untouched) when the file is not a JSON
197
+ * object, so a hand-edited config is never replaced.
198
+ */
199
+ export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig): void {
200
+ writePrivate(file, serialize(update(readRawConfig(file) ?? {})))
201
+ }
202
+
203
+ /**
204
+ * Atomic write with 0600 permissions. Lossless: keys config.json holds that VoiceConfig does not know are kept; the
205
+ * known keys come from `config` (a known key it lacks is removed). A file that is not a JSON object is replaced.
206
+ */
207
+ export function writeConfig(file: string, config: VoiceConfig): void {
208
+ let previous: RawConfig = {}
209
+ try {
210
+ previous = readRawConfig(file) ?? {}
211
+ } catch {}
212
+ const next: RawConfig = { ...previous }
213
+ for (const key of CONFIG_KEYS) delete next[key]
214
+ writePrivate(file, serialize({ ...next, ...config }))
152
215
  }
153
216
 
154
217
  /** Changing the server URL drops the machine credentials, which belong to the old server. */
package/src/index.ts CHANGED
@@ -2,9 +2,10 @@
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
4
  import { mkdirSync, readFileSync } from "node:fs"
5
- import { hostname } from "node:os"
5
+ import { homedir, hostname } from "node:os"
6
6
  import { fileURLToPath } from "node:url"
7
- import { Plugin } from "@opencode/plugin"
7
+ // Types only: @opencode/plugin's runtime entry loads its whole schema graph (~1 s cold) for an identity define().
8
+ import type { Plugin } from "@opencode/plugin"
8
9
  import { DEFAULT_SUBAGENT, prepareConfigPath } from "./config"
9
10
  import { HubRpc, VoiceRpc } from "./rpc"
10
11
  import { jobFromInfo, type JobInfo } from "./protocol"
@@ -15,7 +16,7 @@ import { herdrRunner } from "./spoke/herdr"
15
16
  import { launchSource } from "./spoke/launch-source"
16
17
  import { HubRpcError, hubMsg, hubWho } from "./spoke/hub-rpc"
17
18
  import { HUB_TOOL, HUB_TOOL_DESCRIPTION, HUB_TOOL_INPUT, runHubTool } from "./spoke/hub-tool"
18
- import { cliEntry, installLaunchers } from "./spoke/launchers"
19
+ import { cliEntry, installLaunchers, linkUserBin, standaloneHubctl } from "./spoke/launchers"
19
20
  import { listServiceSessions } from "./spoke/listing"
20
21
  import { ensureLocalHub } from "./spoke/localhub"
21
22
  import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
@@ -27,6 +28,18 @@ import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "
27
28
 
28
29
  type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
29
30
 
31
+ /** Plugin.define without importing it (it returns its argument). */
32
+ const definePlugin = (plugin: Plugin.Plugin): Plugin.Plugin => plugin
33
+
34
+ /** One registration with OpenCode; a failure is logged and never stops the others. */
35
+ async function attempt(what: string, register: () => Promise<unknown> | undefined): Promise<void> {
36
+ try {
37
+ await register()
38
+ } catch (cause) {
39
+ console.error(`[hub] could not ${what}`, cause)
40
+ }
41
+ }
42
+
30
43
  const VOICE_STYLE =
31
44
  "This message came from a voice conversation and your reply will be read aloud. " +
32
45
  "Answer in at most three short sentences of plain speech: no markdown, code blocks, tables, or lists. " +
@@ -199,25 +212,29 @@ async function registerShellEnv(ctx: Context, identity: (sessionID: string) => {
199
212
  // The `hub`/`hubctl` launchers run the CLI with this runtime (OpenCode's binary as bun; src/spoke/launchers.ts).
200
213
  const bun = typeof process.versions.bun === "string"
201
214
  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")
215
+ if (entry) {
216
+ installLaunchers({ binDir, execPath: process.execPath, bun, entry, standalone: standaloneHubctl(homedir()) })
217
+ // `hub` in the user's own terminals too (~/.local/bin/hub, never over anything of theirs).
218
+ const { hint } = linkUserBin({ home: homedir(), binDir, path: process.env.PATH })
219
+ if (hint) console.error(`[hub] ${hint}`)
220
+ } else console.error("[hub] no built hub CLI (packages/cli/dist/hub-cli.js): `hub` is not installed")
204
221
  } catch (cause) {
205
222
  console.error("[hub] could not install the hub CLI launchers", cause)
206
223
  }
207
224
  const injector = envInjector({ correlator: new ShellCorrelator(), binDir, identity })
208
225
  const tool = (ctx as any).tool
209
226
  const shell = (ctx as any).shell
210
- try {
227
+ await attempt("register the shell environment hooks", async () => {
211
228
  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
- }
229
+ await Promise.all([
230
+ tool.hook("execute.before", (event: any) => injector.toolBefore(event)),
231
+ tool.hook("execute.after", (event: any) => injector.toolAfter(event)),
232
+ shell.hook("create.before", (invocation: any) => injector.shellBefore(invocation)),
233
+ ])
234
+ })
218
235
  }
219
236
 
220
- export default Plugin.define({
237
+ export default definePlugin({
221
238
  id: "hub",
222
239
  async setup(ctx) {
223
240
  // An OpenCode started from a session's shell must not act as that session (nor hand its token to its shells).
@@ -249,82 +266,69 @@ export default Plugin.define({
249
266
  permissionLocation(ctx),
250
267
  )
251
268
 
252
- try {
253
- await ctx.agent.transform((editor) => defineVoiceAgent(editor as any))
254
- } catch (cause) {
255
- console.error("[hub] could not register the voice agent", cause)
256
- }
257
-
258
- try {
259
- await ctx.session.hook("context", (event) => {
260
- const spoke = membership.active()
261
- if (spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
262
- const parts = orchestratorParts({
263
- agent: event.agent,
264
- conversation: spoke?.inConversation(event.sessionID) ?? false,
265
- subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
266
- workModel: spoke?.workModelFor(event.sessionID),
267
- taskBoard: spoke?.taskBoard(event.sessionID),
268
- })
269
- for (const text of parts) event.system.push({ type: "text", text })
270
- })
271
- } catch (cause) {
272
- console.error("[hub] could not register the context hook", cause)
273
- }
274
-
275
- try {
269
+ // Every registration is independent: they run together, so OpenCode's plugin activation (bounded at ~5 s on a
270
+ // cold start) waits for about one of them, not their sum.
271
+ const registrations: Promise<void>[] = [
272
+ attempt("register the voice agent", () => ctx.agent.transform((editor) => defineVoiceAgent(editor as any))),
273
+ attempt("register the context hook", () =>
274
+ ctx.session.hook("context", (event) => {
275
+ const spoke = membership.active()
276
+ if (spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
277
+ const parts = orchestratorParts({
278
+ agent: event.agent,
279
+ conversation: spoke?.inConversation(event.sessionID) ?? false,
280
+ subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
281
+ workModel: spoke?.workModelFor(event.sessionID),
282
+ taskBoard: spoke?.taskBoard(event.sessionID),
283
+ })
284
+ for (const text of parts) event.system.push({ type: "text", text })
285
+ }),
286
+ ),
276
287
  // 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." },
288
+ attempt("register the task stop tool", () =>
289
+ (ctx as any).tool?.transform?.((editor: any) =>
290
+ editor.add({
291
+ name: STOP_TOOL,
292
+ description: STOP_TOOL_DESCRIPTION,
293
+ input: {
294
+ type: "object",
295
+ properties: {
296
+ sessionID: { type: "string", description: "The worker's sessionID, from your task board or the subagent tool's result." },
297
+ all: { type: "boolean", description: "true: stop every worker of yours that is still running." },
298
+ },
299
+ additionalProperties: false,
286
300
  },
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 {
301
+ options: { codemode: false },
302
+ execute: async (value: unknown, context: { sessionID: string }) => {
303
+ const spoke = membership.active()
304
+ if (!spoke) return { content: "The hub is not running in this OpenCode process; nothing was stopped." }
305
+ const { stopped, text } = await spoke.stopWorkers(context.sessionID, (value ?? {}) as Record<string, unknown>)
306
+ return { content: text, metadata: { stopped } }
307
+ },
308
+ }),
309
+ ),
310
+ ),
303
311
  // 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 {
312
+ attempt("register the hub tool", () =>
313
+ (ctx as any).tool?.transform?.((editor: any) =>
314
+ editor.add({
315
+ name: HUB_TOOL,
316
+ description: HUB_TOOL_DESCRIPTION,
317
+ input: HUB_TOOL_INPUT,
318
+ options: { codemode: false },
319
+ execute: (value: unknown, context: { sessionID: string; signal?: AbortSignal }) =>
320
+ runHubTool(() => membership.active(), value, { sessionID: context.sessionID, ...(context.signal ? { signal: context.signal } : {}) }),
321
+ }),
322
+ ),
323
+ ),
319
324
  // @name / @@role in a prompt get context from the hub as an attached text file; the text is never changed
320
325
  // 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))
326
+ attempt("register the prompt hook", () => {
327
+ const references = new PromptReferences(() => membership.active())
328
+ return ctx.session.hook("prompt", (event) => references.onPrompt(event))
329
+ }),
330
+ registerShellEnv(ctx, (sessionID) => membership.active()?.shellIdentity(sessionID)),
331
+ ]
328
332
 
329
333
  const run = async <T>(rpc: { error: (...args: any[]) => any }, action: (spoke: Spoke) => T | Promise<T>) => {
330
334
  const spoke = membership.active()
@@ -376,13 +380,8 @@ export default Plugin.define({
376
380
  hubMsg: (value, rpc) => run(rpc, (s) => hubMsg(s, value)),
377
381
  } satisfies Parameters<typeof ctx.rpc.register<typeof HubRpc>>[1]
378
382
  // "hub", plus the opencode-voice id "voice" for clients built against the old name (see src/rpc.ts).
379
- for (const definition of [HubRpc, VoiceRpc]) {
380
- try {
381
- await ctx.rpc.register(definition, handlers)
382
- } catch (cause) {
383
- console.error(`[hub] could not register RPC methods (${definition.id})`, cause)
384
- }
385
- }
383
+ for (const definition of [HubRpc, VoiceRpc]) registrations.push(attempt(`register RPC methods (${definition.id})`, () => ctx.rpc.register(definition, handlers)))
384
+ await Promise.all(registrations)
386
385
 
387
386
  return () => membership.leave()
388
387
  },
@@ -3,8 +3,19 @@
3
3
  // bundle shipped in this package with the runtime running the plugin: OpenCode is a Bun-compiled binary that acts
4
4
  // as bun under BUN_BE_BUN=1, so no `bun` (or node) needs to be on PATH. Without Bun the bundle runs on `node`.
5
5
  // The CLI deletes BUN_BE_BUN from its own env at start, so nothing it starts inherits it.
6
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
6
+ //
7
+ // A standalone hubctl at user-bin:hubctl (~/.local/bin/hubctl, what `hubctl setup` installs) wins: the launchers exec
8
+ // it when it is there (checked at run time), so shells, services and MCP clients all run that one stable authority
9
+ // rather than the bundle in the plugin's install (cache) directory.
10
+ //
11
+ // For the user's own terminals (a plugin-only install, where `hubctl setup` never ran), linkUserBin links
12
+ // ~/.local/bin/hub to the `hub` launcher under the setup's user-bin rule (packages/cli/src/setup/user-bin.ts): only
13
+ // when nothing is there or it is hub's own link, and never when another `hub` is on PATH. ~/.local/bin/hubctl is left
14
+ // to `hubctl setup`: it is the standalone hubctl's install path (a link there would make that install refuse to write
15
+ // through it, and the launchers would exec themselves).
16
+ import { accessSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
7
17
  import path from "node:path"
18
+ import { userBinLinkDecision, type UserBinEntry } from "../../packages/cli/src/setup/user-bin.ts"
8
19
 
9
20
  export const LAUNCHERS = ["hub", "hubctl"] as const
10
21
 
@@ -20,14 +31,22 @@ export interface LauncherOptions {
20
31
  readonly bun: boolean
21
32
  /** The CLI entry the runtime runs. */
22
33
  readonly entry: string
34
+ /** The standalone hubctl to prefer when it exists (standaloneHubctl(home)). */
35
+ readonly standalone?: string
23
36
  }
24
37
 
38
+ /** Where `hubctl setup` installs the standalone hubctl (contract user-bin:hubctl). */
39
+ export const standaloneHubctl = (home: string): string => path.join(home, ".local", "bin", "hubctl")
40
+
25
41
  /** A word quoted for sh. */
26
42
  const quote = (word: string) => `'${word.replaceAll("'", `'\\''`)}'`
27
43
 
28
- export function launcherScript({ execPath, bun, entry }: LauncherOptions): string {
44
+ const LAUNCHER_HEADER = "#!/bin/sh\n# hub CLI launcher"
45
+
46
+ export function launcherScript({ execPath, bun, entry, standalone }: LauncherOptions): string {
29
47
  const command = bun ? `exec env BUN_BE_BUN=1 ${quote(execPath)} ${quote(entry)} "$@"` : `exec node ${quote(entry)} "$@"`
30
- return `#!/bin/sh\n# hub CLI launcher, written by the hub OpenCode plugin at start (edits are overwritten).\n${command}\n`
48
+ const prefer = standalone ? `if [ -x ${quote(standalone)} ]; then exec ${quote(standalone)} "$@"; fi\n` : ""
49
+ return `${LAUNCHER_HEADER}, written by the hub OpenCode plugin at start (edits are overwritten).\n${prefer}${command}\n`
31
50
  }
32
51
 
33
52
  /** The entry to run from the package at `root`: the bundle, else (Bun only) the TypeScript source. */
@@ -66,3 +85,90 @@ export function installLaunchers(options: LauncherOptions & { readonly binDir: s
66
85
  }
67
86
  return { written }
68
87
  }
88
+
89
+ /** The user's own bin dir (contract root user-bin). */
90
+ export const userBinDir = (home: string): string => path.join(home, ".local", "bin")
91
+
92
+ function entryOf(file: string): UserBinEntry {
93
+ try {
94
+ const info = lstatSync(file)
95
+ return info.isSymbolicLink() ? "symlink" : info.isFile() ? "file" : info.isDirectory() ? "directory" : "other"
96
+ } catch {
97
+ return "missing"
98
+ }
99
+ }
100
+
101
+ /** A file that starts like the launchers hub writes. */
102
+ function isLauncher(file: string): boolean {
103
+ let fd: number | undefined
104
+ try {
105
+ fd = openSync(file, "r")
106
+ const buffer = Buffer.alloc(LAUNCHER_HEADER.length)
107
+ return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") === LAUNCHER_HEADER
108
+ } catch {
109
+ return false
110
+ } finally {
111
+ if (fd !== undefined) closeSync(fd)
112
+ }
113
+ }
114
+
115
+ /** The first executable `name` on `searchPath`, outside `skip`, as found (not followed). */
116
+ function onPath(name: string, searchPath: string, skip: string): string | undefined {
117
+ for (const dir of searchPath.split(path.delimiter)) {
118
+ if (!dir || !path.isAbsolute(dir) || path.resolve(dir) === path.resolve(skip)) continue
119
+ const file = path.join(dir, name)
120
+ try {
121
+ if (!statSync(file).isFile()) continue
122
+ accessSync(file, constants.X_OK)
123
+ return file
124
+ } catch {}
125
+ }
126
+ return undefined
127
+ }
128
+
129
+ /**
130
+ * Links ~/.local/bin/hub to `binDir`/hub (see the header). Returns what it linked, and one hint when the user's
131
+ * terminal will not find it (~/.local/bin not on `path`, or another `hub` comes first). Small synchronous calls,
132
+ * nothing when converged (the plugin runs this at start).
133
+ */
134
+ export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined }): { linked: string[]; hint?: string } {
135
+ // Only the default hub bin dir (~/.local/state/hub/bin): a custom HUB_STATE_DIR (tests, containers, a throwaway
136
+ // state) must never leave ~/.local/bin pointing into it.
137
+ if (path.resolve(options.binDir) !== path.join(options.home, ".local", "state", "hub", "bin")) return { linked: [] }
138
+ const userBin = userBinDir(options.home)
139
+ const linkPath = path.join(userBin, "hub")
140
+ const target = path.join(options.binDir, "hub")
141
+ const entry = entryOf(linkPath)
142
+ let pointsTo: string | undefined
143
+ if (entry === "symlink") {
144
+ try {
145
+ pointsTo = path.resolve(userBin, readlinkSync(linkPath))
146
+ } catch {}
147
+ }
148
+ const searchPath = options.path ?? ""
149
+ const onUserPath = searchPath.split(path.delimiter).some((dir) => dir && path.resolve(dir) === path.resolve(userBin))
150
+ const pathHint = onUserPath ? undefined : `${userBin} is not on PATH: add it to use \`hub\` in your terminal (export PATH="$HOME/.local/bin:$PATH")`
151
+ // `hubctl setup` linked hub to the standalone hubctl: that is the better hub, keep it.
152
+ if (pointsTo === standaloneHubctl(options.home)) return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
153
+ const foundOnPath = onPath("hub", searchPath, options.binDir)
154
+ const decision = userBinLinkDecision({ entry, ...(pointsTo !== undefined ? { pointsTo } : {}), target, linkPath, hubOwned: isLauncher, ...(foundOnPath !== undefined ? { foundOnPath } : {}) })
155
+ if (decision === "foreign") return { linked: [] }
156
+ if (decision === "shadowed") return { linked: [], hint: `\`hub\` on your PATH is ${foundOnPath}, not hub's CLI; hub's is ${target}` }
157
+ if (decision === "in-place") return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
158
+ mkdirSync(userBin, { recursive: true, mode: 0o755 })
159
+ // A new link never replaces what may have appeared meanwhile (EEXIST); hub's own is re-pointed atomically.
160
+ if (decision === "create") {
161
+ symlinkSync(target, linkPath)
162
+ return { linked: ["hub"], ...(pathHint ? { hint: pathHint } : {}) }
163
+ }
164
+ const tmp = `${linkPath}.${process.pid}.tmp`
165
+ rmSync(tmp, { force: true })
166
+ symlinkSync(target, tmp)
167
+ try {
168
+ renameSync(tmp, linkPath)
169
+ } catch (cause) {
170
+ rmSync(tmp, { force: true })
171
+ throw cause
172
+ }
173
+ return { linked: ["hub"], ...(pathHint ? { hint: pathHint } : {}) }
174
+ }
Binary file