@huynle/hub 0.0.0-stage → 0.6.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 (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. package/web/dist/vad-worklet.js +27 -0
@@ -0,0 +1,287 @@
1
+ // Auto-start of a local hub for a loopback hub URL. When nothing answers on the configured
2
+ // http://127.0.0.1:<port> (or localhost / [::1]), start `bun <this repo>/bin/hub.ts` as a detached
3
+ // background process bound to that address, with output appended to hub.log. Without a bun install it
4
+ // runs the hub with the current executable (OpenCode is a compiled Bun binary) and BUN_BE_BUN=1. A lockfile (hub.pid)
5
+ // makes sure only one process on the machine starts it. Never used for non-loopback servers.
6
+ import { spawn as nodeSpawn } from "node:child_process"
7
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"
8
+ import { homedir } from "node:os"
9
+ import path from "node:path"
10
+ import { fileURLToPath } from "node:url"
11
+ import { hubEnv } from "../env"
12
+ import { isLoopbackURL } from "../loopback"
13
+ import { APP_DIR, legacyPaths, migrateFirst, stateBase } from "../migrate"
14
+
15
+ export interface ChildLike {
16
+ readonly pid?: number
17
+ unref(): void
18
+ on(event: "exit", listener: (code: number | null, signal: string | null) => void): unknown
19
+ on(event: "error", listener: (error: Error) => void): unknown
20
+ }
21
+
22
+ export type SpawnLike = (command: string, args: readonly string[], options: object) => ChildLike
23
+
24
+ export interface LocalHubOptions {
25
+ /**
26
+ * Where hub.pid and hub.log live (default $HUB_STATE_DIR or ~/.local/state/hub, migrated from
27
+ * ~/.local/state/opencode-hub or opencode-voice first so the hub does not find a fresh, empty state directory).
28
+ */
29
+ readonly stateDir?: string
30
+ /** Home directory for the default state directory (default os.homedir()). */
31
+ readonly home?: string
32
+ /** Where a one-time migration notice goes (default console). */
33
+ readonly log?: (message: string) => void
34
+ /** This plugin's repository (default: resolved from this file). */
35
+ readonly repoRoot?: string
36
+ /** bun executable; shorthand for `runtime: {command: bun, env: {}}`. */
37
+ readonly bun?: string
38
+ /** How to run bin/hub.ts (default: resolveHubRuntime for this process). */
39
+ readonly runtime?: HubRuntime
40
+ readonly spawn?: SpawnLike
41
+ readonly fetch?: typeof globalThis.fetch
42
+ readonly isAlive?: (pid: number) => boolean
43
+ readonly now?: () => number
44
+ readonly sleep?: (ms: number) => Promise<void>
45
+ /** How long to wait for the started hub to answer (default 15 s). */
46
+ readonly waitMs?: number
47
+ readonly env?: Readonly<Record<string, string | undefined>>
48
+ /** This process's pid (written to the lock while starting). */
49
+ readonly pid?: number
50
+ /** Whether the repository has the built web app (default: web/dist/index.html exists). */
51
+ readonly webBuilt?: (root: string) => boolean
52
+ }
53
+
54
+ /** Why a local hub is not started from an install without web/dist (e.g. the plugin installed from GitHub). */
55
+ export const WEB_NOT_BUILT =
56
+ "this Hub install has no built web app (web/dist), so its pages would only show an error. " +
57
+ "Install the plugin from npm (`opencode plugin add @huynle/hub`) or use a release binary, " +
58
+ "or in a source checkout run `npm install && npm run build:web` first."
59
+
60
+ const hasWebBuild = (root: string): boolean => existsSync(path.join(root, "web", "dist", "index.html"))
61
+
62
+ export type LocalHubResult =
63
+ | { readonly status: "skipped"; readonly reason: string }
64
+ | { readonly status: "running" }
65
+ | { readonly status: "started"; readonly pid: number }
66
+ | { readonly status: "busy"; readonly pid: number }
67
+ | { readonly status: "failed"; readonly error: string }
68
+
69
+ interface Lock {
70
+ readonly pid: number
71
+ readonly port: number
72
+ readonly started: number
73
+ }
74
+
75
+ /** A lock whose hub has not answered for this long is stale, even if its pid is alive (pid reuse). */
76
+ export const LOCK_STALE_MS = 60_000
77
+ const PROBE_TIMEOUT_MS = 1500
78
+ const POLL_MS = 250
79
+
80
+ export function repoRoot(): string {
81
+ return fileURLToPath(new URL("../..", import.meta.url)).replace(/\/+$/, "")
82
+ }
83
+
84
+ export function hubScript(root: string = repoRoot()): string {
85
+ return path.join(root, "bin", "hub.ts")
86
+ }
87
+
88
+ export function defaultStateDir(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): string {
89
+ return hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR)
90
+ }
91
+
92
+ /** The runtime for this process: probes the real executable, PATH and home directory. */
93
+ export function currentHubRuntime(env: Readonly<Record<string, string | undefined>> = process.env): HubRuntime {
94
+ const isBun = typeof Bun !== "undefined"
95
+ return resolveHubRuntime({
96
+ execPath: process.execPath,
97
+ home: homedir(),
98
+ isBun,
99
+ which: (name) => (isBun ? Bun.which(name, env.PATH ? { PATH: env.PATH } : {}) : null),
100
+ exists: existsSync,
101
+ })
102
+ }
103
+
104
+ function pidAlive(pid: number): boolean {
105
+ try {
106
+ process.kill(pid, 0)
107
+ return true
108
+ } catch (cause) {
109
+ return (cause as NodeJS.ErrnoException).code === "EPERM"
110
+ }
111
+ }
112
+
113
+ /** The listen address and port the hub needs to serve `server`. */
114
+ function bindFor(url: URL): { host: string; port: number; probe: string } {
115
+ const name = url.hostname.replace(/^\[(.*)\]$/, "$1")
116
+ const host = name === "localhost" ? "127.0.0.1" : name
117
+ const port = Number(url.port || 80)
118
+ return { host, port, probe: `http://${host.includes(":") ? `[${host}]` : host}:${port}` }
119
+ }
120
+
121
+ /**
122
+ * Makes sure a hub answers on the loopback `server`, starting one if nothing does and no other process
123
+ * is already starting it. Resolves once the hub answers, or with why it did not.
124
+ */
125
+ export async function ensureLocalHub(server: string, options: LocalHubOptions = {}): Promise<LocalHubResult> {
126
+ if (!isLoopbackURL(server)) return { status: "skipped", reason: "not a loopback server" }
127
+ const url = new URL(server)
128
+ if (url.protocol !== "http:") return { status: "skipped", reason: "a local hub serves http only" }
129
+
130
+ const env = options.env ?? process.env
131
+ const doFetch = options.fetch ?? globalThis.fetch
132
+ const now = options.now ?? Date.now
133
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))
134
+ const alive = options.isAlive ?? pidAlive
135
+ const home = options.home ?? homedir()
136
+ const stateDir = options.stateDir ?? defaultStateDir(env, home)
137
+ const lockFile = path.join(stateDir, "hub.pid")
138
+ const logFile = path.join(stateDir, "hub.log")
139
+ const bind = bindFor(url)
140
+
141
+ const answers = async (origin: string) => {
142
+ try {
143
+ await doFetch(`${origin}/healthz`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) })
144
+ return true // any HTTP answer: something listens there, so never start a second server
145
+ } catch {
146
+ return false
147
+ }
148
+ }
149
+
150
+ if (await answers(url.origin)) return { status: "running" }
151
+ const root = options.repoRoot ?? repoRoot()
152
+ if (!(options.webBuilt ?? hasWebBuild)(root)) return { status: "failed", error: WEB_NOT_BUILT }
153
+
154
+ if (options.stateDir === undefined && !hubEnv(env).HUB_STATE_DIR) {
155
+ migrateFirst(legacyPaths(stateBase(home)), stateDir, { skip: ["hub.pid"], ...(options.log ? { log: options.log } : {}) })
156
+ }
157
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 })
158
+
159
+ // ---- the lock ----
160
+ const existing = readLock(lockFile)
161
+ if (existing === "unreadable") {
162
+ if (now() - statSync(lockFile).mtimeMs < LOCK_STALE_MS) return { status: "busy", pid: 0 }
163
+ removeLock(lockFile)
164
+ } else if (existing) {
165
+ if (alive(existing.pid)) {
166
+ if (existing.port !== bind.port && (await answers(`http://127.0.0.1:${existing.port}`))) {
167
+ return {
168
+ status: "failed",
169
+ error:
170
+ `A local hub (pid ${existing.pid}) already runs on port ${existing.port}. ` +
171
+ `Point /hub → Server at http://127.0.0.1:${existing.port}, or stop that hub first.`,
172
+ }
173
+ }
174
+ if (now() - existing.started < LOCK_STALE_MS) return { status: "busy", pid: existing.pid }
175
+ }
176
+ removeLock(lockFile)
177
+ }
178
+ const own = options.pid ?? process.pid
179
+ try {
180
+ writeFileSync(lockFile, JSON.stringify({ pid: own, port: bind.port, started: now() } satisfies Lock), { flag: "wx", mode: 0o600 })
181
+ } catch {
182
+ const holder = readLock(lockFile)
183
+ return { status: "busy", pid: holder && holder !== "unreadable" ? holder.pid : 0 }
184
+ }
185
+
186
+ // ---- start ----
187
+ let child: ChildLike
188
+ let exited: string | undefined
189
+ let log: number | undefined
190
+ try {
191
+ log = openSync(logFile, "a", 0o600)
192
+ chmodSync(logFile, 0o600)
193
+ const childEnv: Record<string, string> = {}
194
+ for (const [key, value] of Object.entries(env)) {
195
+ // The hub needs none of OpenCode's own settings or credentials.
196
+ if (value !== undefined && !key.startsWith("OPENCODE_")) childEnv[key] = value
197
+ }
198
+ const runtime = options.runtime ?? (options.bun ? { command: options.bun, env: {} } : currentHubRuntime(env))
199
+ delete childEnv.BUN_BE_BUN // only the runtime decides whether its executable acts as bun
200
+ // The address this hub must serve; legacy names would only lose to these and warn.
201
+ for (const legacy of ["VOICE_HOST", "VOICE_PORT", "VOICE_PUBLIC_URL"]) delete childEnv[legacy]
202
+ Object.assign(childEnv, runtime.env, { HUB_HOST: bind.host, HUB_PORT: String(bind.port), HUB_PUBLIC_URL: url.origin })
203
+ child = (options.spawn ?? (nodeSpawn as unknown as SpawnLike))(runtime.command, [hubScript(root)], {
204
+ cwd: root,
205
+ detached: true,
206
+ stdio: ["ignore", log, log],
207
+ env: childEnv,
208
+ })
209
+ child.on("error", (error) => {
210
+ exited = error.message
211
+ })
212
+ child.on("exit", (code, signal) => {
213
+ exited = `exited with ${signal ?? `code ${code}`}`
214
+ })
215
+ child.unref()
216
+ } catch (cause) {
217
+ removeLock(lockFile)
218
+ return { status: "failed", error: `Could not start the local hub: ${cause instanceof Error ? cause.message : cause}` }
219
+ } finally {
220
+ if (log !== undefined) closeSync(log)
221
+ }
222
+ const pid = child.pid ?? own
223
+ writeFileSync(lockFile, JSON.stringify({ pid, port: bind.port, started: now() } satisfies Lock), { mode: 0o600 })
224
+
225
+ // ---- wait until it answers ----
226
+ const deadline = now() + (options.waitMs ?? 15_000)
227
+ for (;;) {
228
+ if (exited !== undefined) {
229
+ const lock = readLock(lockFile)
230
+ if (lock && lock !== "unreadable" && lock.pid === pid) removeLock(lockFile)
231
+ return { status: "failed", error: `The local hub ${exited}; see ${logFile}` }
232
+ }
233
+ if (await answers(bind.probe)) return { status: "started", pid }
234
+ if (now() >= deadline) {
235
+ return { status: "failed", error: `The local hub (pid ${pid}) did not answer on ${bind.probe} yet; see ${logFile}` }
236
+ }
237
+ await sleep(POLL_MS)
238
+ }
239
+ }
240
+
241
+ function readLock(file: string): Lock | "unreadable" | undefined {
242
+ if (!existsSync(file)) return undefined
243
+ try {
244
+ const raw = JSON.parse(readFileSync(file, "utf8")) as Partial<Lock>
245
+ if (Number.isInteger(raw.pid) && Number.isInteger(raw.port) && typeof raw.started === "number") return raw as Lock
246
+ } catch {
247
+ // fall through
248
+ }
249
+ return "unreadable"
250
+ }
251
+
252
+ function removeLock(file: string): void {
253
+ try {
254
+ unlinkSync(file)
255
+ } catch {
256
+ // already gone
257
+ }
258
+ }
259
+
260
+ export interface HubRuntime {
261
+ readonly command: string
262
+ readonly env: Readonly<Record<string, string>>
263
+ }
264
+
265
+ export interface RuntimeProbe {
266
+ readonly execPath: string
267
+ readonly home: string
268
+ readonly isBun: boolean
269
+ readonly which: (name: string) => string | null
270
+ readonly exists: (file: string) => boolean
271
+ }
272
+
273
+ /**
274
+ * How to run bin/hub.ts: this runtime when it is bun, else bun on PATH, else ~/.bun/bin/bun, else this
275
+ * executable with BUN_BE_BUN=1 when it is a Bun runtime (inside OpenCode, process.execPath is the
276
+ * compiled `opencode` binary, which then runs scripts as bun), else plain `bun`.
277
+ */
278
+ export function resolveHubRuntime(probe: RuntimeProbe): HubRuntime {
279
+ const base = probe.execPath.split(/[\\/]/).pop() ?? ""
280
+ if (/^bun(\.exe)?$/i.test(base)) return { command: probe.execPath, env: {} }
281
+ const found = probe.which("bun")
282
+ if (found) return { command: found, env: {} }
283
+ const home = path.join(probe.home, ".bun", "bin", "bun")
284
+ if (probe.exists(home)) return { command: home, env: {} }
285
+ if (probe.isBun) return { command: probe.execPath, env: { BUN_BE_BUN: "1" } }
286
+ return { command: "bun", env: {} }
287
+ }
@@ -0,0 +1,34 @@
1
+ // OpenCode keeps pending permission requests per location (the directory a session runs in), and the
2
+ // plugin's ctx.permission only sees its own location. One plugin instance per location joins the
3
+ // process (see singleton.ts), but only the first runs the spoke. So a reply has to go through the
4
+ // instance of the location that holds the request.
5
+ import type { PermissionDecision } from "../protocol"
6
+
7
+ /** ctx.permission of one plugin instance, with its location's directory. */
8
+ export interface PermissionLocation {
9
+ readonly directory: string
10
+ get(input: { sessionID: string; requestID: string }): Promise<unknown>
11
+ reply(input: { sessionID: string; requestID: string; decision: PermissionDecision }): Promise<unknown>
12
+ }
13
+
14
+ /**
15
+ * Replies to a pending permission through the location that holds it (the session's directory first).
16
+ * "gone" when no location has it pending (already answered, expired, or asked in another process).
17
+ */
18
+ export async function replyPermission(
19
+ locations: readonly PermissionLocation[],
20
+ input: { sessionID: string; requestID: string; decision: PermissionDecision },
21
+ directory?: string,
22
+ ): Promise<"replied" | "gone"> {
23
+ const ordered = [...locations.filter((l) => l.directory === directory), ...locations.filter((l) => l.directory !== directory)]
24
+ for (const location of ordered) {
25
+ try {
26
+ await location.get({ sessionID: input.sessionID, requestID: input.requestID })
27
+ } catch {
28
+ continue // not pending in this location
29
+ }
30
+ await location.reply(input)
31
+ return "replied"
32
+ }
33
+ return "gone"
34
+ }
@@ -0,0 +1,32 @@
1
+ // Reaching this process's own OpenCode server over HTTP, for what the plugin API does not offer (answering
2
+ // forms). The background service is found with Service.discover; a foreground `opencode serve` from its
3
+ // command line and password variable. A standalone server started by a TUI (`serve --stdio --port 0`)
4
+ // keeps its password to itself: its forms are relayed through the TUI instead (Spoke.formActions).
5
+
6
+ /** URL and Authorization header of a foreground `opencode serve` this process is, when knowable. */
7
+ export function foregroundEndpoint(
8
+ argv: readonly string[],
9
+ env: Readonly<Record<string, string | undefined>>,
10
+ ): { url: string; authorization: string } | undefined {
11
+ if (!argv.includes("serve") || argv.includes("--stdio") || argv.includes("--service")) return undefined
12
+ const option = (name: string) => {
13
+ const index = argv.indexOf(name)
14
+ if (index >= 0) return argv[index + 1]
15
+ return argv.find((a) => a.startsWith(`${name}=`))?.slice(name.length + 1)
16
+ }
17
+ const port = Number(option("--port"))
18
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined
19
+ const password = env.OPENCODE_PASSWORD || env.OPENCODE_SERVER_PASSWORD
20
+ if (!password) return undefined
21
+ const host = option("--hostname")
22
+ const reachable = !host || host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host
23
+ return { url: `http://${reachable}:${port}`, authorization: `Basic ${btoa(`opencode:${password}`)}` }
24
+ }
25
+
26
+ /** "gone" for OpenCode's form-not-found / already-settled errors; undefined for anything else. */
27
+ export function formOutcome(cause: unknown): "gone" | undefined {
28
+ const tag = (cause as { _tag?: unknown } | null)?._tag
29
+ if (tag === "FormNotFoundError" || tag === "FormAlreadySettledError") return "gone"
30
+ const message = cause instanceof Error ? cause.message : String(cause)
31
+ return /form (not found|already settled)/i.test(message) ? "gone" : undefined
32
+ }
@@ -0,0 +1,45 @@
1
+ // TUI presence on the spoke: which sessions an OpenCode TUI shows (its route and open tabs), reported over
2
+ // RPC voice.presence on change and every 15 s. A session is open while some TUI reported it within the
3
+ // window. Each TUI's report replaces its previous one, so a closed tab stops counting at once.
4
+ export const TUI_PRESENCE_WINDOW_MS = 45_000
5
+ /** Sessions taken from one report, and TUIs remembered. */
6
+ const REPORT_MAX = 200
7
+ const REPORTERS_MAX = 100
8
+ const SESSION_ID = /^ses[A-Za-z0-9_-]{1,200}$/
9
+
10
+ export class PresenceBook {
11
+ /** reporter → its sessions and when it reported them. Anonymous reports keep a time per session. */
12
+ private readonly byReporter = new Map<string, { at: number; sessions: Set<string> }>()
13
+ private readonly anonymous = new Map<string, number>()
14
+
15
+ report(sessionIDs: readonly string[], now: number, reporter?: string): void {
16
+ const ids = [...new Set(sessionIDs.filter((id) => typeof id === "string" && SESSION_ID.test(id)))].slice(0, REPORT_MAX)
17
+ if (!reporter) {
18
+ for (const id of ids) this.anonymous.set(id, now)
19
+ this.prune(now)
20
+ return
21
+ }
22
+ this.byReporter.delete(reporter)
23
+ this.byReporter.set(reporter, { at: now, sessions: new Set(ids) })
24
+ this.prune(now)
25
+ }
26
+
27
+ /** Sessions some TUI reported within the window. */
28
+ open(now: number): Set<string> {
29
+ const out = new Set<string>()
30
+ for (const { at, sessions } of this.byReporter.values()) if (now - at < TUI_PRESENCE_WINDOW_MS) for (const id of sessions) out.add(id)
31
+ for (const [id, at] of this.anonymous) if (now - at < TUI_PRESENCE_WINDOW_MS) out.add(id)
32
+ return out
33
+ }
34
+
35
+ reporters(): number {
36
+ return this.byReporter.size
37
+ }
38
+
39
+ private prune(now: number): void {
40
+ for (const [key, entry] of this.byReporter) if (now - entry.at >= TUI_PRESENCE_WINDOW_MS) this.byReporter.delete(key)
41
+ while (this.byReporter.size > REPORTERS_MAX) this.byReporter.delete(this.byReporter.keys().next().value!)
42
+ for (const [id, at] of this.anonymous) if (now - at >= TUI_PRESENCE_WINDOW_MS) this.anonymous.delete(id)
43
+ while (this.anonymous.size > REPORT_MAX) this.anonymous.delete(this.anonymous.keys().next().value!)
44
+ }
45
+ }
@@ -0,0 +1,103 @@
1
+ // Exactly one active spoke per OpenCode process. Every plugin instance in the process (one per
2
+ // location, plus reloads) joins a registry on globalThis; the first one creates the spoke, the
3
+ // others stay passive and delegate to it. When the active instance is disposed, the next
4
+ // instance takes over with its own factory, so a reloaded module replaces stale code.
5
+ import { randomBytes } from "node:crypto"
6
+
7
+ export interface Startable {
8
+ start(): void
9
+ stop(): void
10
+ }
11
+
12
+ interface Instance {
13
+ readonly id: symbol
14
+ readonly create: (processID: string) => Startable
15
+ /** Per-instance value (e.g. the instance's location-scoped API), see Membership.locals. */
16
+ readonly local?: unknown
17
+ }
18
+
19
+ interface Registry {
20
+ pid: number
21
+ processID: string
22
+ instances: Instance[]
23
+ spoke?: Startable
24
+ owner?: symbol
25
+ }
26
+
27
+ export interface Membership<S> {
28
+ readonly processID: string
29
+ active(): S | undefined
30
+ isActive(): boolean
31
+ /** The local values of every instance in the process (the active one first). */
32
+ locals<L = unknown>(): L[]
33
+ leave(): void
34
+ }
35
+
36
+ // Kept from opencode-voice so a process that loads both the old and the renamed package runs one spoke.
37
+ export const SPOKE_KEY = Symbol.for("opencode-voice.spoke")
38
+
39
+ function registry(key: symbol): Registry {
40
+ const store = globalThis as Record<symbol, Registry | undefined>
41
+ let reg = store[key]
42
+ if (!reg || reg.pid !== process.pid || !Array.isArray(reg.instances)) {
43
+ reg = { pid: process.pid, processID: `proc_${randomBytes(8).toString("hex")}`, instances: [] }
44
+ store[key] = reg
45
+ }
46
+ return reg
47
+ }
48
+
49
+ function promote(reg: Registry): void {
50
+ for (const instance of reg.instances) {
51
+ try {
52
+ const spoke = instance.create(reg.processID)
53
+ spoke.start()
54
+ reg.spoke = spoke
55
+ reg.owner = instance.id
56
+ return
57
+ } catch (cause) {
58
+ console.error("[hub] could not start the hub spoke", cause)
59
+ }
60
+ }
61
+ reg.spoke = undefined
62
+ reg.owner = undefined
63
+ }
64
+
65
+ /** `create` receives the stable per-process ID; `local` is this instance's value for Membership.locals. */
66
+ export function joinProcess<S extends Startable>(create: (processID: string) => S, key: symbol = SPOKE_KEY, local?: unknown): Membership<S> {
67
+ const reg = registry(key)
68
+ const id = Symbol("hub.instance")
69
+ reg.instances.push({ id, create, ...(local !== undefined ? { local } : {}) })
70
+ if (!reg.spoke) {
71
+ try {
72
+ const spoke = create(reg.processID)
73
+ spoke.start()
74
+ reg.spoke = spoke
75
+ reg.owner = id
76
+ } catch (cause) {
77
+ console.error("[hub] could not start the hub spoke", cause)
78
+ }
79
+ }
80
+ return {
81
+ processID: reg.processID,
82
+ active: () => registry(key).spoke as S | undefined,
83
+ isActive: () => registry(key).owner === id,
84
+ locals: <L>() => {
85
+ const current = registry(key)
86
+ const ordered = [...current.instances].sort((a, b) => Number(b.id === current.owner) - Number(a.id === current.owner))
87
+ return ordered.flatMap((i) => (i.local === undefined ? [] : [i.local as L]))
88
+ },
89
+ leave: () => {
90
+ const current = registry(key)
91
+ const index = current.instances.findIndex((i) => i.id === id)
92
+ if (index === -1) return
93
+ current.instances.splice(index, 1)
94
+ if (current.owner !== id) return
95
+ try {
96
+ current.spoke?.stop()
97
+ } catch (cause) {
98
+ console.error("[hub] spoke stop failed", cause)
99
+ }
100
+ promote(current)
101
+ },
102
+ }
103
+ }