@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/rpc.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ // Server-side methods the /hub dialog calls. All of them act on this machine's spoke.
4
+ // RPC id "hub"; opencode-voice (<= 0.4) used "voice". The TUI and server plugins ship together in this
5
+ // package, so they always agree; the server also registers the same methods under the legacy id
6
+ // (VoiceRpc) for any other client still built against opencode-voice.
7
+
8
+ const obj = (properties: Record<string, unknown>, required: string[] = []) =>
9
+ ({ type: "object", properties, required, additionalProperties: false }) as const
10
+
11
+ const str = { type: "string" } as const
12
+ const bool = { type: "boolean" } as const
13
+ const num = { type: "number" } as const
14
+ const empty = obj({})
15
+ const errorData = { type: "object" } as const
16
+
17
+ /**
18
+ * not_connected: no live connection · invalid: bad input · unreachable: server could not be reached ·
19
+ * unavailable: spoke not running in this process · server_error: the hub refused the request.
20
+ */
21
+ const errors = {
22
+ not_connected: errorData,
23
+ invalid: errorData,
24
+ unreachable: errorData,
25
+ unavailable: errorData,
26
+ server_error: errorData,
27
+ } as const
28
+
29
+ const methods = {
30
+ status: {
31
+ input: obj({ sessionID: str }),
32
+ output: {
33
+ type: "object",
34
+ properties: {
35
+ server: str,
36
+ mode: { type: "string", enum: ["off", "always", "session"] },
37
+ enrolled: bool,
38
+ connection: { type: "string", enum: ["off", "connecting", "connected", "revoked", "error"] },
39
+ lastError: str,
40
+ sessionExposed: bool,
41
+ conversation: bool,
42
+ enrollment: { type: "string", enum: ["pending", "approved", "denied", "expired", "error"] },
43
+ machineName: str,
44
+ kind: { type: "string", enum: ["service", "standalone"] },
45
+ localHub: str,
46
+ },
47
+ required: ["server", "mode", "enrolled", "connection", "machineName", "kind"],
48
+ additionalProperties: false,
49
+ },
50
+ errors,
51
+ },
52
+ setServer: { input: obj({ server: str }, ["server"]), output: empty, errors },
53
+ /** GET <server>/healthz from this machine: reachable and a Hub? */
54
+ checkServer: { input: obj({ server: str }, ["server"]), output: obj({ ok: bool, version: str, error: str }, ["ok"]), errors },
55
+ /** Start the local hub for the configured loopback server now (Open hub). */
56
+ startLocalHub: { input: empty, output: obj({ status: { type: "string", enum: ["started", "running", "busy"] } }, ["status"]), errors },
57
+ enroll: {
58
+ input: empty,
59
+ output: obj(
60
+ { userCode: str, approveURL: str, autoApproved: bool, localHub: { type: "string", enum: ["started", "running", "busy"] } },
61
+ ["userCode", "approveURL"],
62
+ ),
63
+ errors,
64
+ },
65
+ setMode: { input: obj({ mode: { type: "string", enum: ["off", "always", "session"] } }, ["mode"]), output: empty, errors },
66
+ setSession: { input: obj({ sessionID: str, on: bool }, ["sessionID", "on"]), output: empty, errors },
67
+ pairCode: { input: empty, output: obj({ code: str, url: str, expiresAt: num }, ["code", "url", "expiresAt"]), errors },
68
+ handoff: { input: obj({ sessionID: str }, ["sessionID"]), output: obj({ notified: num }, ["notified"]), errors },
69
+ /** One-time sign-in link: the session's page, or the home page without sessionID. */
70
+ /** voice: land on the session with Voice mode starting (`/s/<id>?voice=1`). */
71
+ openLink: { input: obj({ sessionID: str, voice: bool }), output: obj({ url: str }, ["url"]), errors },
72
+ /**
73
+ * TUI presence: the sessions this TUI shows (route root and open tabs), sent on change and every 15 s.
74
+ * `reporter` identifies the TUI; each report replaces its previous one.
75
+ */
76
+ presence: {
77
+ input: obj({ sessionIDs: { type: "array", items: str, maxItems: 200 }, reporter: str }, ["sessionIDs"]),
78
+ output: empty,
79
+ errors,
80
+ },
81
+ /**
82
+ * Form (question) replies from the hub this server cannot make itself (a standalone server started by a
83
+ * TUI): the TUI polls, carries them out with its client, and the server sees form.replied. Waits up to
84
+ * `waitMs` (at most 25 s) for one.
85
+ */
86
+ formActions: {
87
+ input: obj({ waitMs: num }),
88
+ output: obj({ actions: { type: "array", items: { type: "object" } } }, ["actions"]),
89
+ errors,
90
+ },
91
+ /** Conversation mode for a session in this process (same path as the server's "conversation" op). */
92
+ conversation: { input: obj({ sessionID: str, on: bool }, ["sessionID", "on"]), output: obj({ agent: str }, ["agent"]), errors },
93
+ } as const
94
+
95
+ export const HubRpc = Rpc.define({ id: "hub", methods, events: {} })
96
+
97
+ /** @deprecated The opencode-voice RPC id ("voice"); same methods as HubRpc. */
98
+ export const VoiceRpc = Rpc.define({ id: "voice", methods, events: {} })
package/src/setup.ts ADDED
@@ -0,0 +1,124 @@
1
+ // First-run setup for /hub: whether to show it, which options in which order, the server URL the user
2
+ // types, and whether that server answers like a Hub. Pure decisions; src/tui.ts renders them.
3
+ import { DEFAULT_SERVER, type Mode } from "./config"
4
+ import { isLoopbackURL } from "./loopback"
5
+
6
+ export type { Mode }
7
+
8
+ /** The parts of voice.status that setup depends on. */
9
+ export interface SetupStatus {
10
+ readonly server: string
11
+ readonly enrolled: boolean
12
+ readonly connection: "off" | "connecting" | "connected" | "revoked" | "error"
13
+ readonly mode: Mode
14
+ }
15
+
16
+ /**
17
+ * configured: connect to the (remote) server already in the config · local / local-session: a hub on this
18
+ * computer, sharing every session / only sessions switched on · remote: type a server URL · later: close.
19
+ */
20
+ export type SetupChoice = "configured" | "local" | "local-session" | "remote" | "later"
21
+
22
+ export interface SetupOption {
23
+ readonly value: SetupChoice
24
+ readonly title: string
25
+ readonly description: string
26
+ }
27
+
28
+ export type Parsed = { readonly ok: true; readonly url: string } | { readonly ok: false; readonly error: string }
29
+ export type Health = { readonly ok: true; readonly version?: string } | { readonly ok: false; readonly error: string }
30
+
31
+ /** Setup instead of the regular menu: this machine has no token for its server, or the server revoked it. */
32
+ export function needsSetup(status: SetupStatus): boolean {
33
+ return !status.enrolled || status.connection === "revoked"
34
+ }
35
+
36
+ const hostOf = (server: string) => server.replace(/^https?:\/\//, "")
37
+
38
+ export function setupOptions(status: SetupStatus): SetupOption[] {
39
+ const remote = !isLoopbackURL(status.server)
40
+ const revoked = status.enrolled && status.connection === "revoked"
41
+ const options: SetupOption[] = []
42
+ if (remote) {
43
+ options.push({
44
+ value: "configured",
45
+ title: `Connect to ${hostOf(status.server)}`,
46
+ description: revoked
47
+ ? "This machine was revoked there; ask to be approved again"
48
+ : "Your hub: approve this machine in its web page, then open your sessions from any device",
49
+ })
50
+ }
51
+ options.push(
52
+ {
53
+ value: "local",
54
+ title: "On this computer (local — nothing to install)",
55
+ description: "Starts a hub here (nothing to install), shares every session, and opens it in your browser",
56
+ },
57
+ {
58
+ value: "local-session",
59
+ title: "On this computer, only sessions I choose",
60
+ description: "Same local hub; share a session with /hub → This session",
61
+ },
62
+ {
63
+ value: "remote",
64
+ title: remote ? "Connect to a different hub…" : "Connect to a hub…",
65
+ description: "A hub you or your team host, e.g. https://hub.example.com (needed for phones away from this computer)",
66
+ },
67
+ { value: "later", title: "Not now", description: "Run /hub again any time" },
68
+ )
69
+ return options
70
+ }
71
+
72
+ /** The local hub's URL: a loopback http server already configured (custom port), else the default. */
73
+ export function localServerFor(server: string): string {
74
+ return isLoopbackURL(server) && server.startsWith("http:") ? server : DEFAULT_SERVER
75
+ }
76
+
77
+ /** Every session by default; only chosen sessions for the secondary local choice. */
78
+ export function modeFor(choice: SetupChoice): Mode {
79
+ return choice === "local-session" ? "session" : "always"
80
+ }
81
+
82
+ /** A server URL as typed: http(s) only, a bare host means https; no credentials, query or fragment. */
83
+ export function parseServerInput(raw: string): Parsed {
84
+ const text = raw.trim()
85
+ if (!text) return { ok: false, error: "Enter the hub's address, e.g. https://hub.example.com" }
86
+ if (/\s/.test(text)) return { ok: false, error: "The address cannot contain spaces" }
87
+ const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(text) ? text : `https://${text}`
88
+ let url: URL
89
+ try {
90
+ url = new URL(withScheme)
91
+ } catch {
92
+ return { ok: false, error: `Not a valid address: ${text}` }
93
+ }
94
+ if (url.protocol !== "http:" && url.protocol !== "https:") return { ok: false, error: "Use an http:// or https:// address" }
95
+ if (!url.hostname) return { ok: false, error: `Not a valid address: ${text}` }
96
+ if (url.username || url.password) return { ok: false, error: "Leave user names and passwords out of the address" }
97
+ if (url.search || url.hash || withScheme.includes("#") || withScheme.includes("?")) {
98
+ return { ok: false, error: "Use the server's base address, without ? or #" }
99
+ }
100
+ return { ok: true, url: `${url.origin}${url.pathname}`.replace(/\/+$/, "") }
101
+ }
102
+
103
+ /** GET <server>/healthz: reachable and answering like a Hub ({ok: true}). */
104
+ export async function checkServer(
105
+ server: string,
106
+ options: { fetch?: typeof globalThis.fetch; timeoutMs?: number } = {},
107
+ ): Promise<Health> {
108
+ const doFetch = options.fetch ?? globalThis.fetch
109
+ let res: Response
110
+ try {
111
+ res = await doFetch(`${server}/healthz`, { redirect: "manual", signal: AbortSignal.timeout(options.timeoutMs ?? 8000) })
112
+ } catch (cause) {
113
+ return { ok: false, error: `Could not reach ${server}: ${cause instanceof Error ? cause.message : String(cause)}` }
114
+ }
115
+ if (!res.ok) {
116
+ return {
117
+ ok: false,
118
+ error: `${server}/healthz answered HTTP ${res.status}. If a login proxy protects the server, leave /healthz and /spoke/* reachable.`,
119
+ }
120
+ }
121
+ const body = (await res.json().catch(() => undefined)) as { ok?: unknown; version?: unknown } | undefined
122
+ if (body?.ok !== true) return { ok: false, error: `${server} answered, but it is not a Hub` }
123
+ return { ok: true, ...(typeof body.version === "string" ? { version: body.version } : {}) }
124
+ }
@@ -0,0 +1,35 @@
1
+ /** Turns an agent reply into text that sounds natural when read aloud, capped at `max` characters. */
2
+ export function speakable(text: string, max = Infinity): string {
3
+ const lines = text
4
+ .replace(/```[\s\S]*?```/g, "\n(code omitted)\n")
5
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
6
+ .replace(/https?:\/\/\S+/g, "a link")
7
+ .replace(/`([^`]*)`/g, "$1")
8
+ .replace(/(\*\*|__)(.*?)\1/g, "$2")
9
+ .replace(/(^|[\s(])[*_]([^*_\n]+)[*_](?=[\s).,!?:;]|$)/g, "$1$2")
10
+ .split("\n")
11
+ .map((line) =>
12
+ line
13
+ .replace(/^\s*#{1,6}\s+(.*?)\s*$/, (_m, heading: string) => (/[.!?:]$/.test(heading) ? heading : `${heading}.`))
14
+ .replace(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/, "\u0000$1")
15
+ .trim(),
16
+ )
17
+ .filter(Boolean)
18
+
19
+ let spoken = ""
20
+ for (const line of lines) {
21
+ if (line.startsWith("\u0000")) {
22
+ const item = line.slice(1)
23
+ spoken += spoken && !/[.!?:]$/.test(spoken) ? `. ${item}` : ` ${item}`
24
+ } else spoken += ` ${line}`
25
+ }
26
+ spoken = spoken.replace(/\s+/g, " ").trim()
27
+ return cap(spoken, max)
28
+ }
29
+
30
+ function cap(text: string, max: number): string {
31
+ if (text.length <= max) return text
32
+ const cut = text.slice(0, max)
33
+ const space = cut.lastIndexOf(" ")
34
+ return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`
35
+ }
@@ -0,0 +1,272 @@
1
+ // The spoke's single outbound WebSocket to the central server: hello/welcome handshake, request/
2
+ // response correlation in both directions, and reconnect with jittered exponential backoff.
3
+ import {
4
+ encode,
5
+ MAX_FRAME,
6
+ parseServerMessage,
7
+ PROTOCOL_VERSION,
8
+ type Hello,
9
+ type ResultFor,
10
+ type ServerRequest,
11
+ type SpokeMessage,
12
+ type SpokeRequest,
13
+ type Welcome,
14
+ } from "../protocol"
15
+
16
+ export type ConnectionState = "off" | "connecting" | "connected" | "revoked" | "error"
17
+
18
+ export interface Backoff {
19
+ readonly minMs: number
20
+ readonly maxMs: number
21
+ readonly random?: () => number
22
+ }
23
+
24
+ /** Minimal WebSocket surface used here (Bun's WebSocket accepts `{ headers }` as a 2nd argument). */
25
+ export interface SocketLike {
26
+ readonly readyState: number
27
+ onopen: ((ev: unknown) => void) | null
28
+ onmessage: ((ev: { data: unknown }) => void) | null
29
+ onclose: ((ev: { code?: number; reason?: string }) => void) | null
30
+ onerror: ((ev: unknown) => void) | null
31
+ send(data: string): void
32
+ close(code?: number, reason?: string): void
33
+ }
34
+
35
+ export interface ConnectionOptions {
36
+ readonly server: string
37
+ readonly token: string
38
+ /** Hello fields, computed per attempt so mode/directory are current. */
39
+ readonly hello: () => Omit<Hello, "t" | "v">
40
+ /** Executes a server request; a thrown error becomes `{ ok: false, error }`. */
41
+ readonly handle: (req: ServerRequest) => Promise<unknown>
42
+ readonly onWelcome?: (welcome: Welcome) => void
43
+ readonly onState?: (state: ConnectionState, error?: string) => void
44
+ readonly backoff?: Backoff
45
+ readonly requestTimeoutMs?: number
46
+ readonly socket?: (url: string, headers: Record<string, string>) => SocketLike
47
+ }
48
+
49
+ export function backoffDelay(attempt: number, backoff: Backoff): number {
50
+ const base = Math.min(backoff.maxMs, backoff.minMs * 2 ** Math.min(attempt, 30))
51
+ const random = (backoff.random ?? Math.random)()
52
+ return Math.round(base * (0.5 + 0.5 * random))
53
+ }
54
+
55
+ export function wsURL(server: string): string {
56
+ return `${server.replace(/\/+$/, "").replace(/^http/, "ws")}/spoke/ws`
57
+ }
58
+
59
+ const defaultSocket = (url: string, headers: Record<string, string>): SocketLike =>
60
+ new (WebSocket as unknown as new (url: string, options: { headers: Record<string, string> }) => SocketLike)(url, { headers })
61
+
62
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
63
+
64
+ interface Pending {
65
+ resolve: (value: unknown) => void
66
+ reject: (error: Error) => void
67
+ timer: ReturnType<typeof setTimeout>
68
+ }
69
+
70
+ export class SpokeConnection {
71
+ state: ConnectionState = "off"
72
+ lastError?: string
73
+ publicURL?: string
74
+ machineID?: string
75
+
76
+ private socket?: SocketLike
77
+ private welcomed = false
78
+ private stopped = true
79
+ private attempt = 0
80
+ private retry?: ReturnType<typeof setTimeout>
81
+ private nextID = 0
82
+ private readonly pending = new Map<string, Pending>()
83
+ private readonly backoff: Backoff
84
+
85
+ constructor(private readonly options: ConnectionOptions) {
86
+ this.backoff = options.backoff ?? { minMs: 1000, maxMs: 60_000 }
87
+ }
88
+
89
+ start(): void {
90
+ if (!this.stopped) return
91
+ this.stopped = false
92
+ this.open()
93
+ }
94
+
95
+ stop(): void {
96
+ this.stopped = true
97
+ clearTimeout(this.retry)
98
+ this.drop(1000, "stop")
99
+ this.failPending("disconnected")
100
+ this.setState("off")
101
+ }
102
+
103
+ /** Reconnects at once (e.g. after a local hub was started) instead of waiting out the backoff. */
104
+ retryNow(): void {
105
+ if (this.stopped || this.state === "connected" || this.state === "revoked" || this.socket) return
106
+ clearTimeout(this.retry)
107
+ this.attempt = 0
108
+ this.open()
109
+ }
110
+
111
+ /** Sends a frame once the server has welcomed us. Returns false when it could not be sent. */
112
+ send(frame: SpokeMessage): boolean {
113
+ if (!this.socket || !this.welcomed || this.socket.readyState !== 1) return false
114
+ try {
115
+ let text = encode(frame)
116
+ if (text.length > MAX_FRAME) {
117
+ // The hub drops the connection on an oversize frame; answer a request with an error instead.
118
+ if (frame.t !== "res") return false
119
+ text = encode({ t: "res", id: frame.id, ok: false, error: `Result too large to send (${text.length} characters, limit ${MAX_FRAME})` })
120
+ }
121
+ this.socket.send(text)
122
+ return true
123
+ } catch {
124
+ return false
125
+ }
126
+ }
127
+
128
+ request<R extends SpokeRequest>(req: R): Promise<ResultFor[R["op"]]> {
129
+ const id = `s${++this.nextID}`
130
+ return new Promise((resolve, reject) => {
131
+ if (this.state !== "connected") return reject(new Error("Not connected to the hub"))
132
+ const timer = setTimeout(() => {
133
+ this.pending.delete(id)
134
+ reject(new Error("The hub timed out"))
135
+ }, this.options.requestTimeoutMs ?? 15_000)
136
+ this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, timer })
137
+ if (!this.send({ t: "req", id, req })) {
138
+ clearTimeout(timer)
139
+ this.pending.delete(id)
140
+ reject(new Error("Not connected to the hub"))
141
+ }
142
+ })
143
+ }
144
+
145
+ private open(): void {
146
+ if (this.stopped) return
147
+ this.setState(this.state === "error" ? "error" : "connecting")
148
+ let socket: SocketLike
149
+ try {
150
+ socket = (this.options.socket ?? defaultSocket)(wsURL(this.options.server), {
151
+ Authorization: `Bearer ${this.options.token}`,
152
+ })
153
+ } catch (cause) {
154
+ this.fail(message(cause))
155
+ return
156
+ }
157
+ this.socket = socket
158
+ this.welcomed = false
159
+ socket.onopen = () => {
160
+ try {
161
+ socket.send(encode({ t: "hello", v: PROTOCOL_VERSION, ...this.options.hello() }))
162
+ } catch (cause) {
163
+ this.lastError = message(cause)
164
+ }
165
+ }
166
+ socket.onmessage = (ev) => this.receive(ev.data)
167
+ socket.onerror = () => {
168
+ this.lastError = this.welcomed ? "Connection error" : "Could not connect to the hub"
169
+ }
170
+ socket.onclose = (ev) => {
171
+ if (this.socket !== socket) return
172
+ this.socket = undefined
173
+ const wasWelcomed = this.welcomed
174
+ this.welcomed = false
175
+ this.failPending("disconnected")
176
+ if (this.stopped || this.state === "revoked") return
177
+ this.fail(wasWelcomed ? `Connection closed${ev.reason ? `: ${ev.reason}` : ""}` : (this.lastError ?? "Could not connect"))
178
+ }
179
+ }
180
+
181
+ private receive(data: unknown): void {
182
+ const text = typeof data === "string" ? data : data instanceof ArrayBuffer ? Buffer.from(data).toString("utf8") : String(data)
183
+ const frame = parseServerMessage(text)
184
+ if (!frame) return
185
+ switch (frame.t) {
186
+ case "welcome":
187
+ this.welcomed = true
188
+ this.attempt = 0
189
+ this.publicURL = frame.publicURL
190
+ this.machineID = frame.machineID
191
+ this.lastError = undefined
192
+ this.setState("connected")
193
+ try {
194
+ this.options.onWelcome?.(frame)
195
+ } catch (cause) {
196
+ console.error("[hub] welcome handler failed", cause)
197
+ }
198
+ return
199
+ case "bye":
200
+ if (frame.reason === "revoked") {
201
+ this.stopped = true
202
+ clearTimeout(this.retry)
203
+ this.lastError = "This machine was revoked on the hub"
204
+ this.setState("revoked")
205
+ this.drop(1000, "revoked")
206
+ this.failPending("revoked")
207
+ }
208
+ return
209
+ case "res": {
210
+ const pending = this.pending.get(frame.id)
211
+ if (!pending) return
212
+ this.pending.delete(frame.id)
213
+ clearTimeout(pending.timer)
214
+ if (frame.ok) pending.resolve(frame.result)
215
+ else pending.reject(new Error(frame.error ?? "Request failed"))
216
+ return
217
+ }
218
+ case "req":
219
+ void this.answer(frame.id, frame.req)
220
+ return
221
+ }
222
+ }
223
+
224
+ private async answer(id: string, req: ServerRequest): Promise<void> {
225
+ let reply: SpokeMessage
226
+ try {
227
+ reply = { t: "res", id, ok: true, result: await this.options.handle(req) }
228
+ } catch (cause) {
229
+ reply = { t: "res", id, ok: false, error: message(cause) }
230
+ }
231
+ this.send(reply)
232
+ }
233
+
234
+ private fail(error: string): void {
235
+ this.lastError = error
236
+ this.setState("error")
237
+ if (this.stopped) return
238
+ clearTimeout(this.retry)
239
+ this.retry = setTimeout(() => this.open(), backoffDelay(this.attempt++, this.backoff))
240
+ }
241
+
242
+ private drop(code: number, reason: string): void {
243
+ const socket = this.socket
244
+ this.socket = undefined
245
+ this.welcomed = false
246
+ if (!socket) return
247
+ socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null
248
+ try {
249
+ socket.close(code, reason)
250
+ } catch {
251
+ // already closed
252
+ }
253
+ }
254
+
255
+ private failPending(reason: string): void {
256
+ for (const [id, pending] of this.pending) {
257
+ clearTimeout(pending.timer)
258
+ pending.reject(new Error(`Hub ${reason}`))
259
+ this.pending.delete(id)
260
+ }
261
+ }
262
+
263
+ private setState(state: ConnectionState): void {
264
+ if (state === this.state) return
265
+ this.state = state
266
+ try {
267
+ this.options.onState?.(state, this.lastError)
268
+ } catch {
269
+ // observers must not break the connection
270
+ }
271
+ }
272
+ }
@@ -0,0 +1,93 @@
1
+ // Conversation mode: the session's main agent becomes a voice orchestrator that answers in short
2
+ // spoken sentences and hands real work to background subagents. The plugin registers it as the
3
+ // primary agent "voice"; the delegate agent comes from the per-machine config and is added per
4
+ // request, so changing it needs no agent reload.
5
+
6
+ export const VOICE_AGENT = "voice"
7
+
8
+ /** metadata.source of the prompts the hub sends. opencode-hub (0.5) wrote "opencode-hub", opencode-voice (<= 0.4) "opencode-voice". */
9
+ export const PROMPT_SOURCE = "hub"
10
+ const PROMPT_SOURCES: readonly unknown[] = [PROMPT_SOURCE, "opencode-hub", "opencode-voice"]
11
+ /** True for prompt metadata written by the hub, under either name. */
12
+ export const isHubPrompt = (metadata: unknown): boolean =>
13
+ typeof metadata === "object" && metadata !== null && PROMPT_SOURCES.includes((metadata as { source?: unknown }).source)
14
+
15
+ export const VOICE_AGENT_DESCRIPTION =
16
+ "Voice front-end: short spoken replies; delegates real work to background subagents. Used by Hub conversation mode."
17
+
18
+ export const ORCHESTRATOR_PROMPT = `You are a voice agent: the voice front-end of an OpenCode session. The user is talking to you hands-free from their phone. Everything they send is a live speech-to-text transcript, and everything you write is read aloud to them.
19
+
20
+ Understanding speech:
21
+ - Transcripts contain recognition errors, filler words, false starts and half sentences. Infer the intent; don't comment on the wording.
22
+ - The user thinks out loud. Their speech is cut at pauses, so one thought can arrive as several consecutive messages, and a message can end mid-sentence. Read your recent messages from the user together as one evolving request.
23
+ - The user corrects themselves ("no wait", "actually", "I mean", "scratch that", or simply restating it differently). The latest version wins. If you already started work based on the earlier version, redirect or stop it.
24
+ - If a message is only a fragment and the intent isn't clear yet, don't start any work: reply with at most a few words, like "Go on." or "I'm listening.", and wait for the rest.
25
+ - If something is genuinely ambiguous after the user has finished, ask one short question instead of guessing.
26
+
27
+ How to reply:
28
+ - Speak in one or two short sentences of plain speech. No markdown, lists, tables, code, file paths or URLs unless the user asks for them.
29
+ - Answer quick questions directly.
30
+ - Always say something before you start working, so the user hears that you got it. Write your spoken acknowledgement first, then call tools: for example "Got it, I'm starting a subagent to fix the failing test." The user can see tool activity on their phone, but they need to hear that you're on it.
31
+
32
+ How to work:
33
+ - Answer directly, or make at most two quick, low-risk tool calls yourself. Brain reads or writes, status checks and simple lookups belong on this direct path. If the work grows beyond two calls, acknowledge that and delegate it.
34
+ - Delegate multi-step work. A routine worker handles deterministic MCP work, short research and routine checks on your fast model. A complex worker handles coding, debugging, architecture, risky operations or work requiring substantial judgment on the full work model named below.
35
+ - Call the subagent tool with the delegate agent named in the note below (use "general" if none is named), background: true, and a complete, self-contained prompt with the goal, relevant context, constraints, and what to report back. Require that the worker's final assistant message must contain only one or two plain-spoken sentences that can be read directly to the user. Put technical detail in tool output or progress before that final message.
36
+ - Never run a subagent in the foreground (always pass background: true): a foreground subagent blocks the conversation and the user cannot talk to you until it ends.
37
+ - New user messages can arrive while work is running; treat them as steering. For a correction, status request or related follow-up on the same active task, continue its subagent with the existing sessionID. Start a fresh worker after that task completes or for an unrelated task.
38
+ - When the user asks how it's going, give a one-sentence progress update from what you know.
39
+ - Successful worker results are spoken directly. Do not repeat a completed worker's result. Speak again only if the worker failed, the user must decide something, or a warning or next step genuinely needs explanation.
40
+ - If the user says stop, cancel or never mind, stop: start no new work and confirm briefly.`
41
+
42
+ export function delegateNote(subagent: string): string {
43
+ return `Delegate agent on this machine: when you call the subagent tool, use agent "${subagent}" and always set background: true. Require its final assistant message to contain only one or two plain-spoken sentences for direct speech.`
44
+ }
45
+
46
+ /**
47
+ * In conversation mode the session talks on a fast model. Routine workers inherit it; the voice agent
48
+ * passes the remembered work model explicitly only for complex work.
49
+ */
50
+ export function workModelNote(workModel: string): string {
51
+ return `For a routine worker, omit model so it inherits your fast model. For a complex worker, set model: "${workModel}". Complex means coding, debugging, architecture, risky operations or work requiring substantial judgment.`
52
+ }
53
+
54
+ /** System parts the context hook adds: the delegate note for the voice agent, the whole prompt when conversation mode runs on another agent. */
55
+ export function orchestratorParts(input: { agent: string; conversation: boolean; subagent: string; workModel?: string }): string[] {
56
+ const notes = [delegateNote(input.subagent), ...(input.workModel ? [workModelNote(input.workModel)] : [])]
57
+ if (input.agent === VOICE_AGENT) return notes
58
+ if (input.conversation) return [ORCHESTRATOR_PROMPT, ...notes]
59
+ return []
60
+ }
61
+
62
+ interface AgentLike {
63
+ name: string
64
+ description?: string
65
+ system?: string
66
+ mode: "subagent" | "primary" | "all"
67
+ hidden: boolean
68
+ permissions: unknown[]
69
+ }
70
+
71
+ export interface AgentEditorLike {
72
+ get(id: string): AgentLike | undefined
73
+ update(id: string, update: (agent: AgentLike) => void): void
74
+ }
75
+
76
+ const DEFAULT_AGENT = "build"
77
+
78
+ /**
79
+ * Adds the "voice" agent (the editor's update() upserts). It inherits the default agent's
80
+ * permissions so the user's rules still apply. A "voice" agent the user defined is left alone.
81
+ */
82
+ export function defineVoiceAgent(editor: AgentEditorLike): void {
83
+ if (editor.get(VOICE_AGENT)) return
84
+ const base = editor.get(DEFAULT_AGENT)
85
+ editor.update(VOICE_AGENT, (agent) => {
86
+ agent.name = "Voice"
87
+ agent.description = VOICE_AGENT_DESCRIPTION
88
+ agent.mode = "primary"
89
+ agent.hidden = false
90
+ agent.system = ORCHESTRATOR_PROMPT
91
+ if (base) agent.permissions = structuredClone(base.permissions)
92
+ })
93
+ }