@huynle/hub 0.0.0-stage → 0.6.0

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
package/src/config.ts ADDED
@@ -0,0 +1,170 @@
1
+ // Per-machine spoke configuration, shared by every OpenCode process on the machine and edited
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"
4
+ import { homedir } from "node:os"
5
+ import path from "node:path"
6
+ import { hubEnv, type Env } from "./env"
7
+ import { APP_DIR, configBase, legacyPaths, migrateFirst } from "./migrate"
8
+
9
+ export type Mode = "off" | "always" | "session"
10
+
11
+ export interface VoiceConfig {
12
+ readonly server: string
13
+ readonly mode: Mode
14
+ /** Session IDs exposed when mode is "session". */
15
+ readonly sessions: readonly string[]
16
+ readonly machineName: string
17
+ /** Agent the voice orchestrator delegates work to in conversation mode. */
18
+ readonly subagent: string
19
+ /** Start a local hub in the background when the server is loopback and nothing answers (default true). */
20
+ readonly autoStartLocalHub: boolean
21
+ /** Read open sessions and workspaces from the Herdr CLI (default true; the service process only). */
22
+ readonly herdr: boolean
23
+ /** History window of the session listing: root sessions from the last `days` days, at most `max`. */
24
+ readonly list: ListWindow
25
+ /** Fast model the voice agent talks with in conversation mode ("provider/model[#variant]"). */
26
+ readonly voiceModel?: string
27
+ /** Model subagents work on; defaults to the session's model before conversation mode. */
28
+ readonly workModel?: string
29
+ readonly machineID?: string
30
+ readonly token?: string
31
+ }
32
+
33
+ export interface ListWindow {
34
+ readonly days: number
35
+ readonly max: number
36
+ }
37
+
38
+ export const DEFAULT_LIST: ListWindow = Object.freeze({ days: 30, max: 100 })
39
+ const LIST_LIMITS = { days: 365, max: 500 } as const
40
+
41
+ export const DEFAULT_SERVER = "http://127.0.0.1:4097"
42
+ export const DEFAULT_SUBAGENT = "general"
43
+ const AGENT_ID = /^[A-Za-z0-9_.-]{1,64}$/
44
+ const MODEL_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.:\/-]+(?:#[A-Za-z0-9_.-]+)?$/
45
+
46
+ export interface ModelRef {
47
+ readonly providerID: string
48
+ readonly id: string
49
+ readonly variant?: string
50
+ }
51
+
52
+ /** "provider/model[#variant]"; the model ID may itself contain slashes (e.g. openrouter/anthropic/x). */
53
+ export function parseModelRef(value: string): ModelRef | undefined {
54
+ if (!MODEL_REF.test(value)) return undefined
55
+ const [ref, variant] = value.split("#") as [string, string | undefined]
56
+ const slash = ref.indexOf("/")
57
+ return { providerID: ref.slice(0, slash), id: ref.slice(slash + 1), ...(variant ? { variant } : {}) }
58
+ }
59
+
60
+ /** OpenCode reports a model selected without a variant as variant "default". */
61
+ export function explicitVariant(ref: ModelRef): string | undefined {
62
+ return ref.variant && ref.variant !== "default" ? ref.variant : undefined
63
+ }
64
+
65
+ /** Drops the implicit "default" variant so the ref can be passed back to OpenCode tools. */
66
+ export function plainModelRef(ref: ModelRef): ModelRef {
67
+ const variant = explicitVariant(ref)
68
+ return { providerID: ref.providerID, id: ref.id, ...(variant ? { variant } : {}) }
69
+ }
70
+
71
+ export function formatModelRef(ref: ModelRef): string {
72
+ const variant = explicitVariant(ref)
73
+ return `${ref.providerID}/${ref.id}${variant ? `#${variant}` : ""}`
74
+ }
75
+
76
+ const modelRef = (v: unknown) => (typeof v === "string" && v.length <= 200 && MODEL_REF.test(v) ? v : undefined)
77
+
78
+ /** $HUB_SPOKE_CONFIG (legacy $OPENCODE_HUB_CONFIG, $OPENCODE_VOICE_CONFIG), else ~/.config/hub/config.json. */
79
+ export function configPath(env: Env = process.env, home: string = homedir()): string {
80
+ const explicit = hubEnv(env).HUB_SPOKE_CONFIG
81
+ if (explicit) return explicit
82
+ return path.join(configBase(env, home), APP_DIR, "config.json")
83
+ }
84
+
85
+ /**
86
+ * configPath, after copying the legacy ~/.config/opencode-hub/config.json (else the opencode-voice one) to
87
+ * the default location on first start (never for an explicit path; the old files are kept).
88
+ */
89
+ export function prepareConfigPath(env: Env = process.env, home: string = homedir(), log?: (message: string) => void): string {
90
+ const file = configPath(env, home)
91
+ if (!hubEnv(env).HUB_SPOKE_CONFIG) {
92
+ migrateFirst(legacyPaths(configBase(env, home), "config.json"), file, log ? { log } : {})
93
+ }
94
+ return file
95
+ }
96
+
97
+ export function defaults(machineName: string): VoiceConfig {
98
+ return { server: DEFAULT_SERVER, mode: "off", sessions: [], machineName, subagent: DEFAULT_SUBAGENT, autoStartLocalHub: true, herdr: true, list: { ...DEFAULT_LIST } }
99
+ }
100
+
101
+ /** Normalizes untrusted JSON into a config, falling back to defaults field by field. */
102
+ export function normalize(raw: unknown, machineName: string): VoiceConfig {
103
+ const base = defaults(machineName)
104
+ if (typeof raw !== "object" || raw === null) return base
105
+ const r = raw as Record<string, unknown>
106
+ const server = typeof r.server === "string" && /^https?:\/\/[^\s]+$/.test(r.server) ? r.server.replace(/\/+$/, "") : base.server
107
+ const mode: Mode = r.mode === "always" || r.mode === "session" || r.mode === "off" ? r.mode : base.mode
108
+ const sessions = Array.isArray(r.sessions) ? [...new Set(r.sessions.filter((s): s is string => typeof s === "string" && s.startsWith("ses")))] : []
109
+ return {
110
+ server,
111
+ mode,
112
+ sessions,
113
+ machineName: typeof r.machineName === "string" && r.machineName ? r.machineName : machineName,
114
+ subagent: typeof r.subagent === "string" && AGENT_ID.test(r.subagent) ? r.subagent : base.subagent,
115
+ autoStartLocalHub: r.autoStartLocalHub !== false,
116
+ herdr: r.herdr !== false,
117
+ list: listWindow(r.list),
118
+ ...(modelRef(r.voiceModel) ? { voiceModel: modelRef(r.voiceModel) } : {}),
119
+ ...(modelRef(r.workModel) ? { workModel: modelRef(r.workModel) } : {}),
120
+ ...(typeof r.machineID === "string" && r.machineID ? { machineID: r.machineID } : {}),
121
+ ...(typeof r.token === "string" && r.token ? { token: r.token } : {}),
122
+ }
123
+ }
124
+
125
+ const inRange = (v: unknown, max: number): v is number => Number.isInteger(v) && (v as number) >= 1 && (v as number) <= max
126
+
127
+ /** Each field of the history window falls back to its default when missing or out of range. */
128
+ function listWindow(raw: unknown): ListWindow {
129
+ const r = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
130
+ return {
131
+ days: inRange(r.days, LIST_LIMITS.days) ? r.days : DEFAULT_LIST.days,
132
+ max: inRange(r.max, LIST_LIMITS.max) ? r.max : DEFAULT_LIST.max,
133
+ }
134
+ }
135
+
136
+ export function readConfig(file: string, machineName: string): VoiceConfig {
137
+ try {
138
+ return normalize(JSON.parse(readFileSync(file, "utf8")), machineName)
139
+ } catch {
140
+ return defaults(machineName)
141
+ }
142
+ }
143
+
144
+ /** Atomic write with 0600 permissions. */
145
+ export function writeConfig(file: string, config: VoiceConfig): void {
146
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
147
+ const tmp = `${file}.${process.pid}.tmp`
148
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
149
+ chmodSync(tmp, 0o600)
150
+ renameSync(tmp, file)
151
+ }
152
+
153
+ /** Changing the server URL drops the machine credentials, which belong to the old server. */
154
+ export function withServer(config: VoiceConfig, server: string): VoiceConfig {
155
+ const next = normalize({ ...config, server }, config.machineName)
156
+ if (next.server === config.server) return next
157
+ const { machineID: _id, token: _token, ...rest } = next
158
+ return rest
159
+ }
160
+
161
+ export function isExposed(config: VoiceConfig, sessionID: string): boolean {
162
+ if (config.mode === "always") return true
163
+ if (config.mode === "session") return config.sessions.includes(sessionID)
164
+ return false
165
+ }
166
+
167
+ export function setSession(config: VoiceConfig, sessionID: string, on: boolean): VoiceConfig {
168
+ const sessions = on ? [...new Set([...config.sessions, sessionID])] : config.sessions.filter((id) => id !== sessionID)
169
+ return { ...config, sessions }
170
+ }
package/src/env.ts ADDED
@@ -0,0 +1,63 @@
1
+ // Environment variables of Hub. The project was called opencode-voice, then opencode-hub: their VOICE_*,
2
+ // OPENCODE_VOICE_* and OPENCODE_HUB_* names are still read as fallbacks (the new HUB_* name wins, then the
3
+ // most recent legacy name), with a one-time deprecation warning per legacy name. Code reads only the new names from hubEnv(env).
4
+
5
+ export type Env = Readonly<Record<string, string | undefined>>
6
+
7
+ /** New name → legacy names, most recent first. */
8
+ export const LEGACY_ENV: Readonly<Record<string, readonly string[]>> = Object.freeze({
9
+ HUB_HOST: ["VOICE_HOST"],
10
+ HUB_PORT: ["VOICE_PORT"],
11
+ HUB_PUBLIC_URL: ["VOICE_PUBLIC_URL"],
12
+ HUB_STATE_DIR: ["VOICE_STATE_DIR"],
13
+ HUB_CONFIG: ["VOICE_HUB_CONFIG"],
14
+ HUB_AUTH: ["VOICE_AUTH"],
15
+ HUB_OIDC_ISSUER: ["VOICE_OIDC_ISSUER"],
16
+ HUB_OIDC_CLIENT_ID: ["VOICE_OIDC_CLIENT_ID"],
17
+ HUB_OIDC_CLIENT_SECRET: ["VOICE_OIDC_CLIENT_SECRET"],
18
+ HUB_OIDC_ALLOWED: ["VOICE_OIDC_ALLOWED"],
19
+ HUB_OIDC_INSECURE_ISSUER: ["VOICE_OIDC_INSECURE_ISSUER"],
20
+ HUB_ADMIN_EMAILS: ["VOICE_ADMIN_EMAILS"],
21
+ HUB_STT_MODEL: ["VOICE_STT_MODEL"],
22
+ HUB_TTS_MODEL: ["VOICE_TTS_MODEL"],
23
+ HUB_VOICE: ["VOICE_VOICE"],
24
+ HUB_MAX_AUDIO_BYTES: ["VOICE_MAX_AUDIO_BYTES"],
25
+ HUB_MAX_SPEECH_CHARS: ["VOICE_MAX_SPEECH_CHARS"],
26
+ HUB_TURN_TIMEOUT_MS: ["VOICE_TURN_TIMEOUT_MS"],
27
+ HUB_VAPID_SUBJECT: ["VOICE_VAPID_SUBJECT"],
28
+ /** The spoke (plugin) config file: OPENCODE_HUB_CONFIG in 0.5, OPENCODE_VOICE_CONFIG before. */
29
+ HUB_SPOKE_CONFIG: ["OPENCODE_HUB_CONFIG", "OPENCODE_VOICE_CONFIG"],
30
+ })
31
+
32
+ export interface HubEnvOptions {
33
+ /** Where deprecation warnings go (default console.warn with the [hub] prefix). */
34
+ readonly warn?: (message: string) => void
35
+ /** Legacy names already warned about (default: once per process). */
36
+ readonly seen?: Set<string>
37
+ }
38
+
39
+ const processSeen = new Set<string>()
40
+ const defaultWarn = (message: string) => console.warn(`[hub] warning: ${message}`)
41
+
42
+ /** A copy of `env` with every unset HUB_* name filled from its legacy name. Never echoes values. */
43
+ export function hubEnv(env: Env = process.env, options: HubEnvOptions = {}): Env {
44
+ const warn = options.warn ?? defaultWarn
45
+ const seen = options.seen ?? processSeen
46
+ const out: Record<string, string | undefined> = { ...env }
47
+ for (const [name, legacies] of Object.entries(LEGACY_ENV)) {
48
+ // The name that supplies the value: the new one when set, else the first legacy one set.
49
+ let winner = env[name] !== undefined ? name : undefined
50
+ for (const legacy of legacies) {
51
+ if (env[legacy] === undefined) continue
52
+ const ignored = winner !== undefined
53
+ if (!ignored) {
54
+ winner = legacy
55
+ out[name] = env[legacy]
56
+ }
57
+ if (seen.has(legacy)) continue
58
+ seen.add(legacy)
59
+ warn(ignored ? `${legacy} is ignored because ${winner} is set; remove ${legacy}` : `${legacy} is deprecated; rename it to ${name}`)
60
+ }
61
+ }
62
+ return out
63
+ }
package/src/index.ts ADDED
@@ -0,0 +1,236 @@
1
+ // Hub server plugin: a thin "spoke" that connects this OpenCode process to the hub. The web
2
+ // console, phones and (optional) Voice speech live on the hub; this side only exposes sessions, forwards
3
+ // their events, and runs remote / voice turns and permission replies on request.
4
+ import { readFileSync } from "node:fs"
5
+ import { hostname } from "node:os"
6
+ import { Plugin } from "@opencode/plugin"
7
+ import { DEFAULT_SUBAGENT, prepareConfigPath } from "./config"
8
+ import { HubRpc, VoiceRpc } from "./rpc"
9
+ import { checkServer, parseServerInput } from "./setup"
10
+ import { defineVoiceAgent, orchestratorParts } from "./spoke/conversation"
11
+ import { herdrRunner } from "./spoke/herdr"
12
+ import { listServiceSessions } from "./spoke/listing"
13
+ import { ensureLocalHub } from "./spoke/localhub"
14
+ import { replyPermission, type PermissionLocation } from "./spoke/locations"
15
+ import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
16
+ import { joinProcess, type Membership } from "./spoke/singleton"
17
+ import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
18
+
19
+ type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
20
+
21
+ const VOICE_STYLE =
22
+ "This message came from a voice conversation and your reply will be read aloud. " +
23
+ "Answer in at most three short sentences of plain speech: no markdown, code blocks, tables, or lists. " +
24
+ "Keep using tools as needed; only the final reply must be short."
25
+
26
+ function pluginVersion(): string {
27
+ try {
28
+ return String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "0.0.0")
29
+ } catch {
30
+ return "0.0.0"
31
+ }
32
+ }
33
+
34
+ /**
35
+ * The most recent root sessions of the local OpenCode service with their workspace (used by the
36
+ * service's own spoke for the phone's session browser): the history window plus `include`.
37
+ */
38
+ async function listRecent(options?: { days: number; max: number; include: readonly string[] }) {
39
+ const { Service } = await import("@opencode/client/service")
40
+ const { OpenCode } = await import("@opencode/client")
41
+ const endpoint = await Service.discover()
42
+ if (!endpoint) return []
43
+ const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) ?? {} })
44
+ return listServiceSessions(client, options ?? {})
45
+ }
46
+
47
+ /** A client for this process's own OpenCode server, when it can be reached (see src/spoke/own-server.ts). */
48
+ async function ownClient(kind: "service" | "standalone") {
49
+ const { OpenCode } = await import("@opencode/client")
50
+ if (kind === "service") {
51
+ const { Service } = await import("@opencode/client/service")
52
+ const endpoint = await Service.discover()
53
+ return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) ?? {} }) : undefined
54
+ }
55
+ const endpoint = foregroundEndpoint(process.argv, process.env)
56
+ return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: { authorization: endpoint.authorization } }) : undefined
57
+ }
58
+
59
+ /** Answers or cancels a form through the own server's API (the plugin API has no forms). */
60
+ async function replyForm(kind: "service" | "standalone", action: FormAction): Promise<"replied" | "gone" | "unavailable"> {
61
+ const client = await ownClient(kind).catch(() => undefined)
62
+ if (!client) return "unavailable"
63
+ try {
64
+ if ("cancel" in action) await client.session.form.cancel({ sessionID: action.sessionID, formID: action.formID })
65
+ else await client.session.form.reply({ sessionID: action.sessionID, formID: action.formID, answer: action.answer as Record<string, string | number | boolean | string[]> })
66
+ return "replied"
67
+ } catch (cause) {
68
+ if (formOutcome(cause) === "gone") return "gone"
69
+ throw cause
70
+ }
71
+ }
72
+
73
+ /** Plugin API results may arrive bare or wrapped as { location, data }. */
74
+ function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
75
+ const v = value as any
76
+ return (v && !isBare(v) && v.data !== undefined ? v.data : v) as T
77
+ }
78
+
79
+ /** This instance's location-scoped permission API (OpenCode keeps pending permissions per location). */
80
+ function permissionLocation(ctx: Context): PermissionLocation {
81
+ const permission = ctx.permission as any
82
+ return {
83
+ directory: ctx.location.directory,
84
+ get: (input) => permission.get(input),
85
+ reply: (input) => permission.reply(input),
86
+ }
87
+ }
88
+
89
+ /** `locations`: the permission APIs of every plugin instance in this process (one per location). */
90
+ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "service" | "standalone"): SpokeHost {
91
+ const session = ctx.session as any
92
+ const agent = ctx.agent as any
93
+ const mcp = (ctx as any).mcp
94
+ const vcs = (ctx as any).vcs
95
+ return {
96
+ directory: ctx.location.directory,
97
+ opencodeVersion: ctx.app?.version ?? "unknown",
98
+ events: (signal) => ctx.event.subscribe({ signal }) as AsyncIterable<any>,
99
+ prompt: (input) => session.prompt(input),
100
+ context: (sessionID) => session.context({ sessionID }),
101
+ listModels: async () => unwrap<ReadonlyArray<unknown>>(await (ctx.model as any).list({}), Array.isArray),
102
+ getSession: async (sessionID) => unwrap(await session.get({ sessionID }), (v) => typeof v.id === "string"),
103
+ replyPermission: ({ directory, ...input }) => replyPermission(locations(), input, directory),
104
+ replyForm: (action) => replyForm(kind, action),
105
+ interrupt: (sessionID) => session.interrupt({ sessionID }),
106
+ switchAgent: (sessionID, id) => session.switchAgent({ sessionID, agent: id }),
107
+ switchModel: (sessionID, model) => session.switchModel({ sessionID, model }),
108
+ hasAgent: async (id) => {
109
+ try {
110
+ const info = unwrap<{ id?: string } | undefined>(await agent.get({ agentID: id }), (v) => typeof v.id === "string")
111
+ return info?.id === id
112
+ } catch {
113
+ return false
114
+ }
115
+ },
116
+ defaultAgent: async () => {
117
+ const list = unwrap<Array<{ id: string; mode?: string; hidden?: boolean }>>(await agent.list({}), Array.isArray)
118
+ return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
119
+ },
120
+ listRecent,
121
+ createSession: async ({ directory, title }) =>
122
+ unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
123
+ ...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
124
+ ...(vcs?.diff ? { vcsDiff: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray) } : {}),
125
+ ...(vcs?.get
126
+ ? {
127
+ vcsBranch: async (directory: string) => {
128
+ const info = unwrap<{ branch?: { current?: string } }>(await vcs.get({ location: { directory } }), (v) => "branch" in v)
129
+ return typeof info?.branch?.current === "string" ? info.branch.current : undefined
130
+ },
131
+ }
132
+ : {}),
133
+ }
134
+ }
135
+
136
+ export default Plugin.define({
137
+ id: "hub",
138
+ async setup(ctx) {
139
+ const kind = detectKind(process.argv)
140
+ const membership: Membership<Spoke> = joinProcess(
141
+ (processID) =>
142
+ new Spoke(hostFrom(ctx, () => membership.locals<PermissionLocation>(), kind), {
143
+ configFile: prepareConfigPath(),
144
+ machineName: hostname(),
145
+ kind,
146
+ processID,
147
+ pid: process.pid,
148
+ pluginVersion: pluginVersion(),
149
+ // Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
150
+ 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
+ }),
154
+ undefined,
155
+ permissionLocation(ctx),
156
+ )
157
+
158
+ try {
159
+ await ctx.agent.transform((editor) => defineVoiceAgent(editor as any))
160
+ } catch (cause) {
161
+ console.error("[hub] could not register the voice agent", cause)
162
+ }
163
+
164
+ try {
165
+ await ctx.session.hook("context", (event) => {
166
+ const spoke = membership.active()
167
+ if (spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
168
+ const parts = orchestratorParts({
169
+ agent: event.agent,
170
+ conversation: spoke?.inConversation(event.sessionID) ?? false,
171
+ subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
172
+ workModel: spoke?.workModelFor(event.sessionID),
173
+ })
174
+ for (const text of parts) event.system.push({ type: "text", text })
175
+ })
176
+ } catch (cause) {
177
+ console.error("[hub] could not register the context hook", cause)
178
+ }
179
+
180
+ const run = async <T>(rpc: { error: (...args: any[]) => any }, action: (spoke: Spoke) => T | Promise<T>) => {
181
+ const spoke = membership.active()
182
+ if (!spoke) return rpc.error("unavailable", "The hub spoke is not running in this OpenCode process", {})
183
+ try {
184
+ return await action(spoke)
185
+ } catch (cause) {
186
+ if (cause instanceof SpokeError) return rpc.error(cause.name, cause.message, {})
187
+ return rpc.error("server_error", cause instanceof Error ? cause.message : String(cause), {})
188
+ }
189
+ }
190
+ const input = (value: unknown) => (value ?? {}) as Record<string, any>
191
+
192
+ const handlers = {
193
+ status: (value, rpc) => run(rpc, (s) => s.status(input(value).sessionID)),
194
+ setServer: (value, rpc) => run(rpc, (s) => (s.setServer(String(input(value).server ?? "")), {})),
195
+ checkServer: async (value, rpc) => {
196
+ const parsed = parseServerInput(String(input(value).server ?? ""))
197
+ if (!parsed.ok) return rpc.error("invalid", parsed.error, {})
198
+ return checkServer(parsed.url)
199
+ },
200
+ startLocalHub: (_value, rpc) => run(rpc, (s) => s.startLocalHub()),
201
+ enroll: (_value, rpc) => run(rpc, (s) => s.enroll()),
202
+ setMode: (value, rpc) => run(rpc, (s) => (s.setMode(input(value).mode), {})),
203
+ setSession: (value, rpc) =>
204
+ run(rpc, (s) => (s.setSession(String(input(value).sessionID ?? ""), Boolean(input(value).on)), {})),
205
+ pairCode: (_value, rpc) => run(rpc, (s) => s.pairCode()),
206
+ handoff: (value, rpc) => run(rpc, (s) => s.handoff(String(input(value).sessionID ?? ""))),
207
+ openLink: (value, rpc) =>
208
+ run(rpc, (s) => s.openLink(input(value).sessionID ? String(input(value).sessionID) : undefined, input(value).voice === true)),
209
+ conversation: (value, rpc) =>
210
+ run(rpc, (s) => s.setConversation(String(input(value).sessionID ?? ""), Boolean(input(value).on))),
211
+ formActions: (value, rpc) =>
212
+ run(rpc, (s) => {
213
+ const { waitMs = 0 } = input(value)
214
+ if (typeof waitMs !== "number" || !Number.isFinite(waitMs)) throw new SpokeError("invalid", "Expected {waitMs: number}")
215
+ return s.formActions(Math.max(0, Math.min(waitMs, 25_000))) as Promise<{ actions: Array<Record<string, unknown>> }>
216
+ }),
217
+ presence: (value, rpc) =>
218
+ run(rpc, (s) => {
219
+ const { sessionIDs, reporter } = input(value)
220
+ if (!Array.isArray(sessionIDs)) throw new SpokeError("invalid", "Expected {sessionIDs: string[]}")
221
+ s.presence(sessionIDs.filter((id): id is string => typeof id === "string").slice(0, 200), typeof reporter === "string" ? reporter.slice(0, 100) : undefined)
222
+ return {}
223
+ }),
224
+ } satisfies Parameters<typeof ctx.rpc.register<typeof HubRpc>>[1]
225
+ // "hub", plus the opencode-voice id "voice" for clients built against the old name (see src/rpc.ts).
226
+ for (const definition of [HubRpc, VoiceRpc]) {
227
+ try {
228
+ await ctx.rpc.register(definition, handlers)
229
+ } catch (cause) {
230
+ console.error(`[hub] could not register RPC methods (${definition.id})`, cause)
231
+ }
232
+ }
233
+
234
+ return () => membership.leave()
235
+ },
236
+ })
package/src/key.ts ADDED
@@ -0,0 +1,17 @@
1
+ export interface KeySources {
2
+ active(integrationID: string): Promise<unknown>
3
+ resolve(connection: any): Promise<unknown>
4
+ env: Readonly<Record<string, string | undefined>>
5
+ }
6
+
7
+ /** OpenCode's openrouter integration first, then OPENROUTER_API_KEY, then the plugin option. */
8
+ export async function resolveOpenRouterKey(sources: KeySources, optionKey: string | undefined): Promise<string | undefined> {
9
+ try {
10
+ const connection = await sources.active("openrouter")
11
+ if (connection) {
12
+ const value = (await sources.resolve(connection)) as { type?: unknown; key?: unknown } | undefined
13
+ if (value?.type === "key" && typeof value.key === "string" && value.key) return value.key
14
+ }
15
+ } catch {}
16
+ return sources.env.OPENROUTER_API_KEY || optionKey || undefined
17
+ }
@@ -0,0 +1,41 @@
1
+ // Loopback detection, shared by the hub (CSRF origins for a local hub, first-machine auto-approve) and the
2
+ // spoke (auto-starting a local hub). Only names that can never leave the machine count: localhost,
3
+ // 127.0.0.0/8 and ::1. Request addresses are a hint only, never authentication.
4
+
5
+ /** Hostnames as they appear in URLs (`[::1]`) or in config (`::1`). */
6
+ export function isLoopbackHost(host: string): boolean {
7
+ const h = host.trim().toLowerCase().replace(/^\[(.*)\]$/, "$1")
8
+ if (h === "localhost" || h === "::1") return true
9
+ const ip = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
10
+ return ip !== null && ip.slice(1).every((part) => Number(part) <= 255)
11
+ }
12
+
13
+ /** An http(s) URL whose host is loopback. */
14
+ export function isLoopbackURL(url: string): boolean {
15
+ try {
16
+ const parsed = new URL(url)
17
+ return (parsed.protocol === "http:" || parsed.protocol === "https:") && isLoopbackHost(parsed.hostname)
18
+ } catch {
19
+ return false
20
+ }
21
+ }
22
+
23
+ /** A peer address (e.g. Bun's server.requestIP) on loopback, including IPv4-mapped IPv6. */
24
+ export function isLoopbackAddress(ip: string | undefined | null): boolean {
25
+ if (!ip) return false
26
+ const address = ip.toLowerCase().replace(/^::ffff:/, "")
27
+ return address !== "localhost" && isLoopbackHost(address)
28
+ }
29
+
30
+ const LOOPBACK_NAMES = ["localhost", "127.0.0.1", "[::1]"] as const
31
+
32
+ /**
33
+ * Origins a browser may send for this public URL. A loopback public URL is reachable as localhost,
34
+ * 127.0.0.1 and [::1] (same scheme and port); any other public URL has exactly one origin.
35
+ */
36
+ export function loopbackOrigins(publicURL: string): Set<string> {
37
+ const url = new URL(publicURL)
38
+ if (!isLoopbackHost(url.hostname)) return new Set([url.origin])
39
+ const port = url.port ? `:${url.port}` : ""
40
+ return new Set([url.origin, ...LOOPBACK_NAMES.map((name) => `${url.protocol}//${name}${port}`)])
41
+ }
package/src/migrate.ts ADDED
@@ -0,0 +1,105 @@
1
+ // One-time migration from the legacy locations to the Hub ones. The project was opencode-voice (<= 0.4),
2
+ // then opencode-hub (0.5), and is now Hub. When the new file or directory is missing, the most recent
3
+ // legacy one that exists (opencode-hub first, then opencode-voice) is copied (file modes kept, e.g. 0600 for tokens)
4
+ // through a temporary sibling that is renamed into place, so a partial copy never looks migrated. The
5
+ // legacy copy is never touched or deleted. Callers skip migration for paths set explicitly by env.
6
+ import {
7
+ chmodSync,
8
+ copyFileSync,
9
+ existsSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ readdirSync,
13
+ readlinkSync,
14
+ renameSync,
15
+ rmSync,
16
+ symlinkSync,
17
+ } from "node:fs"
18
+ import path from "node:path"
19
+ import type { Env } from "./env"
20
+
21
+ export const APP_DIR = "hub"
22
+ /** Legacy directory names under ~/.config and ~/.local/state, most recent first. */
23
+ export const LEGACY_APP_DIRS: readonly string[] = Object.freeze(["opencode-hub", "opencode-voice"])
24
+
25
+ /** $XDG_CONFIG_HOME or ~/.config. */
26
+ export const configBase = (env: Env, home: string): string => env.XDG_CONFIG_HOME || path.join(home, ".config")
27
+ /** ~/.local/state. */
28
+ export const stateBase = (home: string): string => path.join(home, ".local", "state")
29
+
30
+ export type MigrationResult = "migrated" | "none" | "failed"
31
+
32
+ export interface MigrateOptions {
33
+ /** One line when something was copied or the copy failed (default console.log / console.warn). */
34
+ readonly log?: (message: string) => void
35
+ /** Top-level entry names of a legacy directory not to copy (e.g. a lock file). */
36
+ readonly skip?: readonly string[]
37
+ }
38
+
39
+ const exists = (p: string) => existsSync(p) || isLink(p)
40
+ function isLink(p: string): boolean {
41
+ try {
42
+ return lstatSync(p).isSymbolicLink()
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ function copyEntry(src: string, dst: string, skip: readonly string[] = []): void {
49
+ const stat = lstatSync(src)
50
+ if (stat.isDirectory()) {
51
+ mkdirSync(dst, { mode: 0o700 })
52
+ for (const name of readdirSync(src)) {
53
+ if (!skip.includes(name)) copyEntry(path.join(src, name), path.join(dst, name))
54
+ }
55
+ chmodSync(dst, stat.mode & 0o7777)
56
+ } else if (stat.isFile()) {
57
+ copyFileSync(src, dst)
58
+ chmodSync(dst, stat.mode & 0o7777)
59
+ } else if (stat.isSymbolicLink()) {
60
+ symlinkSync(readlinkSync(src), dst)
61
+ }
62
+ // sockets, FIFOs and devices are not state worth copying
63
+ }
64
+
65
+ /** Copies `legacy` to `target` when only the legacy one exists. Never throws. */
66
+ export function migrateLegacy(legacy: string, target: string, options: MigrateOptions = {}): MigrationResult {
67
+ if (exists(target) || !exists(legacy)) return "none"
68
+ const tmp = `${target}.migrating-${process.pid}-${Date.now()}`
69
+ try {
70
+ mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 })
71
+ copyEntry(legacy, tmp, options.skip)
72
+ try {
73
+ renameSync(tmp, target)
74
+ } catch (cause) {
75
+ // Another process migrated first: theirs stands.
76
+ if (exists(target)) {
77
+ rmSync(tmp, { recursive: true, force: true })
78
+ return "none"
79
+ }
80
+ throw cause
81
+ }
82
+ } catch (cause) {
83
+ rmSync(tmp, { recursive: true, force: true })
84
+ const message = `could not migrate ${legacy} to ${target}: ${cause instanceof Error ? cause.message : cause}`
85
+ ;(options.log ?? ((m: string) => console.warn(`[hub] warning: ${m}`)))(message)
86
+ return "failed"
87
+ }
88
+ ;(options.log ?? ((m: string) => console.log(`[hub] ${m}`)))(
89
+ `migrated ${legacy} to ${target} (the project is now Hub; the old copy is kept)`,
90
+ )
91
+ return "migrated"
92
+ }
93
+
94
+ /**
95
+ * Copies the first existing of `legacies` (most recent first) to `target` when the target is missing.
96
+ * Never throws; the legacy copies are never touched.
97
+ */
98
+ export function migrateFirst(legacies: readonly string[], target: string, options: MigrateOptions = {}): MigrationResult {
99
+ if (exists(target)) return "none"
100
+ const legacy = legacies.find(exists)
101
+ return legacy === undefined ? "none" : migrateLegacy(legacy, target, options)
102
+ }
103
+
104
+ /** The legacy copies of `<base>/<APP_DIR>/<...rest>`, most recent first. */
105
+ export const legacyPaths = (base: string, ...rest: string[]): string[] => LEGACY_APP_DIRS.map((dir) => path.join(base, dir, ...rest))
@@ -0,0 +1,3 @@
1
+ // Compatibility re-exports: the speech providers live in src/central/speech/.
2
+ export { OpenRouter, OpenRouterError, OPENROUTER_BASE_URL, type OpenRouterOptions } from "./central/speech/openrouter"
3
+ export { audioFormat, SpeechError, type SpeechProvider } from "./central/speech/provider"
package/src/options.ts ADDED
@@ -0,0 +1,33 @@
1
+ export interface VoiceOptions {
2
+ readonly host: string
3
+ readonly port: number
4
+ /** Base URL used in /hub links when the page is reached through a proxy (e.g. tailscale serve). */
5
+ readonly publicURL?: string
6
+ readonly sttModel: string
7
+ readonly ttsModel: string
8
+ readonly voice: string
9
+ /** Fallback when neither the OpenCode openrouter integration nor OPENROUTER_API_KEY is set. */
10
+ readonly apiKey?: string
11
+ readonly maxAudioBytes: number
12
+ readonly maxSpeechChars: number
13
+ readonly turnTimeoutMs: number
14
+ }
15
+
16
+ const string = (value: unknown, fallback: string) => (typeof value === "string" && value ? value : fallback)
17
+ const positive = (value: unknown, fallback: number) =>
18
+ typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback
19
+
20
+ export function parseOptions(raw: Readonly<Record<string, unknown>>): VoiceOptions {
21
+ return {
22
+ host: string(raw.host, "127.0.0.1"),
23
+ port: positive(raw.port, 4097),
24
+ ...(typeof raw.publicURL === "string" && raw.publicURL ? { publicURL: raw.publicURL.replace(/\/+$/, "") } : {}),
25
+ sttModel: string(raw.sttModel, "openai/gpt-4o-mini-transcribe"),
26
+ ttsModel: string(raw.ttsModel, "microsoft/mai-voice-2.1-flash"),
27
+ voice: string(raw.voice, "en-US-Harper:MAI-Voice-2.1-Flash"),
28
+ ...(typeof raw.apiKey === "string" && raw.apiKey ? { apiKey: raw.apiKey } : {}),
29
+ maxAudioBytes: positive(raw.maxAudioBytes, 10 * 1024 * 1024),
30
+ maxSpeechChars: positive(raw.maxSpeechChars, 800),
31
+ turnTimeoutMs: positive(raw.turnTimeoutMs, 10 * 60 * 1000),
32
+ }
33
+ }