@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/tui.ts ADDED
@@ -0,0 +1,479 @@
1
+ import { spawn } from "node:child_process"
2
+ import { Plugin } from "@opencode/plugin/tui"
3
+ import { isLoopbackURL } from "./loopback"
4
+ import { HubRpc } from "./rpc"
5
+ import { localServerFor, modeFor, needsSetup, parseServerInput, setupOptions, type SetupChoice } from "./setup"
6
+ import { hubCommands } from "./tui-commands"
7
+ import { FormRelay } from "./tui-forms"
8
+ import { PresenceReporter, shownSessions } from "./tui-presence"
9
+ import { startVoice } from "./voice-command"
10
+
11
+ // Hub's TUI plugin. /hub configures this harness's hub connection; the other commands are features (see
12
+ // src/tui-commands.ts to add one).
13
+ // /hub: the hub connection — first-run setup (a hub on this computer, or your own hub), then one dialog for
14
+ // the server, its connection and approval, what this machine shares, pairing phones and opening the hub.
15
+ // /voice: no menu — connect (setting up first when needed), share this session, and open it in the browser
16
+ // with Voice mode starting (src/voice-command.ts). Links are shown here, never posted into the session, so
17
+ // they do not reach the model. Decisions live in src/setup.ts and src/voice-command.ts (tested).
18
+
19
+ type Mode = "off" | "always" | "session"
20
+ interface Status {
21
+ server: string
22
+ mode: Mode
23
+ enrolled: boolean
24
+ connection: "off" | "connecting" | "connected" | "revoked" | "error"
25
+ lastError?: string
26
+ sessionExposed?: boolean
27
+ conversation?: boolean
28
+ enrollment?: "pending" | "approved" | "denied" | "expired" | "error"
29
+ machineName: string
30
+ /** Why the local hub could not be started. */
31
+ localHub?: string
32
+ }
33
+
34
+ interface Enrolled {
35
+ userCode: string
36
+ approveURL: string
37
+ autoApproved?: boolean
38
+ localHub?: "started" | "running" | "busy"
39
+ }
40
+
41
+ type Choice = "ui" | "start" | "approval" | "send" | "conversation" | "toggle" | "mode" | "pair" | "server"
42
+
43
+ const MODE_LABEL: Record<Mode, string> = { always: "Always", session: "This session only", off: "Off" }
44
+
45
+ const HUB_LOG = "~/.local/state/hub/hub.log"
46
+ const host = (server: string) => server.replace(/^https?:\/\//, "")
47
+
48
+ function describeStatus(status: Status): string {
49
+ const where = host(status.server)
50
+ const state = !status.enrolled
51
+ ? `not connected to ${where}`
52
+ : status.connection === "connected"
53
+ ? `connected to ${status.server}`
54
+ : status.connection === "connecting"
55
+ ? `connecting to ${where}…`
56
+ : status.connection === "revoked"
57
+ ? `revoked by ${where}`
58
+ : status.connection === "error"
59
+ ? `cannot reach ${where}`
60
+ : `${where}`
61
+ return `Hub · ${state} · mode: ${MODE_LABEL[status.mode]}`
62
+ }
63
+
64
+ export default Plugin.define({
65
+ id: "hub.tui",
66
+ setup(context) {
67
+ const hub = context.client.rpc(HubRpc) as unknown as Record<string, (input: unknown, options: unknown) => Promise<unknown>>
68
+ const call = async <T>(method: string, input: Record<string, unknown> = {}) =>
69
+ (await hub[method]!(input, { location: context.location ?? context.data.location.default() })) as T
70
+ const toast = (message: string, variant: "info" | "success" | "warning" | "error" = "info") =>
71
+ context.ui.toast.show({ title: "Hub", message, variant })
72
+ const currentSession = () => {
73
+ const route = context.ui.router.current() as { type: string; sessionID?: string }
74
+ return route.type === "session" ? route.sessionID : undefined
75
+ }
76
+ const status = (sessionID = currentSession()) => call<Status>("status", sessionID ? { sessionID } : {})
77
+
78
+ /** Polls status until `done` holds; undefined when the time runs out. */
79
+ const waitFor = async (done: (s: Status) => boolean, ms: number, every = 300) => {
80
+ const end = Date.now() + ms
81
+ while (Date.now() < end) {
82
+ const s = await status().catch(() => undefined)
83
+ if (s && done(s)) return s
84
+ await Bun.sleep(every)
85
+ }
86
+ return undefined
87
+ }
88
+
89
+ /** Opens a one-time sign-in link in the browser; shows it when no browser could be opened. */
90
+ const showLink = async (url: string, what: string) => {
91
+ const opened = openURL(url)
92
+ const copied = copyToClipboard(url)
93
+ if (opened) return toast(`Opened ${what} in your browser${copied ? " (link copied)" : ""}`, "success")
94
+ await context.ui.dialog.alert({ title: "Hub", message: `Open this link${copied ? " (copied)" : ""}:\n\n${url}\n\nKeep it private.` })
95
+ }
96
+
97
+ /** Connects to the configured hub (starting a local one when nothing answers) and waits for it. */
98
+ const ensureConnected = async (sessionID?: string): Promise<Status> => {
99
+ let s = await status(sessionID)
100
+ if (s.connection === "connected") return s
101
+ if (!s.enrolled) throw new Error("This machine is not connected to a hub yet. Run /hub to set it up.")
102
+ if (s.mode === "off") throw new Error("Sharing is off for this machine. Run /hub → Mode first.")
103
+ if (isLoopbackURL(s.server)) {
104
+ const { status: started } = await call<{ status: string }>("startLocalHub")
105
+ if (started === "started") toast(`Started the local hub (log: ${HUB_LOG})`)
106
+ }
107
+ toast(`Connecting to ${host(s.server)}…`)
108
+ if (!(await waitFor((x) => x.connection === "connected", 20_000))) {
109
+ s = await status(sessionID)
110
+ throw new Error(`Could not connect to ${s.server}${s.localHub ? `: ${s.localHub}` : s.lastError ? `: ${s.lastError}` : ""}`)
111
+ }
112
+ return status(sessionID)
113
+ }
114
+
115
+ /**
116
+ * The hub page (the session's when it is shared, else the home page). A local hub is started first
117
+ * when nothing answers; the hub lands on speech setup while no speech provider is usable.
118
+ */
119
+ const openUI = async (sessionID?: string) => {
120
+ const s = await ensureConnected(sessionID)
121
+ const shared = sessionID && s.sessionExposed ? sessionID : undefined
122
+ const { url } = await call<{ url: string }>("openLink", shared ? { sessionID: shared } : {})
123
+ await showLink(url, "the hub")
124
+ }
125
+
126
+ const sendToPhone = async (sessionID: string) => {
127
+ const { notified } = await call<{ notified: number }>("handoff", { sessionID })
128
+ if (notified > 0) toast(`Notified ${notified} device${notified === 1 ? "" : "s"}`, "success")
129
+ else toast("No phone is paired yet. Use /hub → Pair a phone first.", "warning")
130
+ }
131
+
132
+ const toggleConversation = async (sessionID: string, s: Status) => {
133
+ const on = !s.conversation
134
+ const { agent } = await call<{ agent: string }>("conversation", { sessionID, on })
135
+ if (!on) return toast(`Conversation mode off (agent: ${agent})`, "success")
136
+ const connected = s.connection === "connected"
137
+ const go = await context.ui.dialog.confirm({
138
+ title: "Conversation mode on",
139
+ message:
140
+ `This session now runs the ${agent} agent: short spoken replies, real work handed to background subagents. ` +
141
+ `Open this session on your phone to talk${connected ? "" : " once this machine is connected"}.`,
142
+ label: { confirm: connected ? "Send to phone" : "OK", cancel: "Later" },
143
+ })
144
+ if (go && connected) await sendToPhone(sessionID)
145
+ }
146
+
147
+ /** Sets the mode; "This session only" switches the current session on. Returns a note for the toast. */
148
+ const applyMode = async (mode: Mode) => {
149
+ await call("setMode", { mode })
150
+ const sessionID = currentSession()
151
+ if (mode === "session" && sessionID) await call("setSession", { sessionID, on: true })
152
+ return mode === "session" && sessionID ? " (this session is on)" : ""
153
+ }
154
+
155
+ const chooseMode = async (s: Status, title = "Which sessions can the hub see?") => {
156
+ const mode = await context.ui.dialog.select<Mode>({
157
+ title,
158
+ current: s.mode === "off" ? "always" : s.mode,
159
+ options: [
160
+ { title: "Always", value: "always", description: "Every session in this OpenCode process" },
161
+ { title: "This session only", value: "session", description: "Only sessions you switch on with /hub" },
162
+ { title: "Off", value: "off", description: "Disconnect this process from the hub" },
163
+ ],
164
+ })
165
+ if (!mode || mode === s.mode) return
166
+ const note = await applyMode(mode)
167
+ toast(`Hub mode: ${MODE_LABEL[mode]}${note}`, "success")
168
+ }
169
+
170
+ const pairPhone = async () => {
171
+ const { code, url, expiresAt } = await call<{ code: string; url: string; expiresAt: number }>("pairCode")
172
+ const minutes = Math.max(1, Math.round((expiresAt - Date.now()) / 60_000))
173
+ copyToClipboard(url)
174
+ await context.ui.dialog.alert({
175
+ title: "Pair a phone",
176
+ message: `On your phone, open:\n\n${url}\n\nand enter the code ${code} (valid ~${minutes} min).`,
177
+ })
178
+ }
179
+
180
+ /** Waits in the background for the server's decision, then asks which sessions to share. */
181
+ const watchEnrollment = async () => {
182
+ const failed = (s: Status) => s.enrollment === "denied" || s.enrollment === "expired" || s.enrollment === "error"
183
+ const done = await waitFor((s) => failed(s) || s.enrolled, 15 * 60_000, 2000)
184
+ if (!done) return
185
+ if (failed(done)) return toast(`Approval ${done.enrollment}. Run /hub to try again.`, "error")
186
+ if (done.mode !== "off") return toast(`Approved by ${host(done.server)}. The hub is set up.`, "success")
187
+ toast(`Approved by ${host(done.server)}.`, "success")
188
+ await chooseMode(done, "Approved. Which sessions can the hub see?")
189
+ const after = await status().catch(() => undefined)
190
+ if (after && after.mode !== "off") toast("The hub is set up. /voice talks to this session; /hub → Open hub in browser.", "success")
191
+ }
192
+
193
+ /** Device-code approval: open the approve page, say what to look for, and watch for the result. */
194
+ const awaitApproval = async (enrolled: Enrolled, s: Status) => {
195
+ const opened = openURL(enrolled.approveURL)
196
+ copyToClipboard(enrolled.approveURL)
197
+ void watchEnrollment()
198
+ toast(`Approve "${s.machineName}" (code ${enrolled.userCode}) in the browser`)
199
+ await context.ui.dialog.alert({
200
+ title: `Approve "${s.machineName}"`,
201
+ message:
202
+ `${opened ? "Opened in your browser" : "Open"}:\n\n${enrolled.approveURL}\n\n` +
203
+ `Check the code ${enrolled.userCode} and approve. A browser that is not paired with ${host(s.server)} yet asks for a ` +
204
+ `pairing code first: /hub → Pair a phone on a connected computer, or the code in the hub's log for the very first device. ` +
205
+ `You'll get a notice here once it is approved.`,
206
+ })
207
+ }
208
+
209
+ // ---------- first-run setup ----------
210
+
211
+ /** A hub on this computer: start it, enroll (a fresh local hub approves at once), share, open the page. */
212
+ const setupLocal = async (s: Status, choice: SetupChoice, open = true) => {
213
+ const server = localServerFor(s.server)
214
+ if (server !== s.server) await call("setServer", { server })
215
+ toast("Starting the local hub…")
216
+ const enrolled = await call<Enrolled>("enroll")
217
+ if (enrolled.localHub === "started") toast(`Started the local hub (log: ${HUB_LOG})`)
218
+ if (!enrolled.autoApproved) {
219
+ // This hub already knows other machines or devices: approve it like a remote server.
220
+ await applyMode(modeFor(choice))
221
+ return awaitApproval(enrolled, { ...s, server })
222
+ }
223
+ if (!(await waitFor((x) => x.enrolled, 15_000))) {
224
+ throw new Error(`The local hub approved this machine, but it did not connect. See ${HUB_LOG}`)
225
+ }
226
+ const note = await applyMode(modeFor(choice))
227
+ toast(`Connected to the local hub · mode: ${MODE_LABEL[modeFor(choice)]}${note}`, "success")
228
+ if (open) await openUI(currentSession())
229
+ }
230
+
231
+ /** Your own hub: check that it answers, save it, enroll, approve in the browser, then pick a mode. */
232
+ const setupRemote = async (s: Status, server: string) => {
233
+ const health = await call<{ ok: boolean; version?: string; error?: string }>("checkServer", { server })
234
+ if (!health.ok) {
235
+ await context.ui.dialog.alert({ title: "Hub not reachable", message: health.error ?? `Could not reach ${server}` })
236
+ return
237
+ }
238
+ if (server !== s.server) await call("setServer", { server })
239
+ const enrolled = await call<Enrolled>("enroll")
240
+ await awaitApproval(enrolled, { ...s, server })
241
+ }
242
+
243
+ const promptServer = async (s: Status): Promise<string | undefined> => {
244
+ let value = isLoopbackURL(s.server) ? "" : s.server
245
+ for (;;) {
246
+ const raw = await context.ui.dialog.prompt({
247
+ title: "Hub URL",
248
+ description: "The address of a hub you or your team host. Changing it disconnects this machine from the current server.",
249
+ value,
250
+ placeholder: "https://hub.example.com",
251
+ })
252
+ if (raw === undefined || raw === null) return undefined
253
+ const parsed = parseServerInput(raw)
254
+ if (parsed.ok) return parsed.url
255
+ toast(parsed.error, "error")
256
+ value = raw
257
+ }
258
+ }
259
+
260
+ /** First-run setup; `open: false` leaves opening the browser to the caller (/voice). */
261
+ const runSetup = async (s: Status, { open = true }: { open?: boolean } = {}) => {
262
+ const choice = await context.ui.dialog.select<SetupChoice>({
263
+ title: s.enrolled && s.connection === "revoked" ? `Set up Hub · revoked by ${host(s.server)}` : "Set up Hub",
264
+ options: setupOptions(s).map((o) => ({ title: o.title, value: o.value, description: o.description })),
265
+ })
266
+ switch (choice) {
267
+ case "local":
268
+ case "local-session":
269
+ return setupLocal(s, choice, open)
270
+ case "configured":
271
+ return setupRemote(s, s.server)
272
+ case "remote": {
273
+ const server = await promptServer(s)
274
+ if (server) await setupRemote(s, server)
275
+ return
276
+ }
277
+ }
278
+ }
279
+
280
+ // ---------- regular menu ----------
281
+
282
+ /** Server: keep it, type another hub URL (checked before switching), or use a hub on this computer. */
283
+ const changeServer = async (s: Status) => {
284
+ const pick = await context.ui.dialog.select<"current" | "url" | "local" | "setup">({
285
+ title: `Hub: ${s.server}`,
286
+ current: "current",
287
+ options: [
288
+ { title: `Keep ${host(s.server)}`, value: "current", description: "No change" },
289
+ { title: "Change server URL…", value: "url", description: "A hub you or your team host; checked before switching (disconnects this machine from the current one)" },
290
+ ...(isLoopbackURL(s.server)
291
+ ? []
292
+ : [{ title: "Use a hub on this computer", value: "local" as const, description: "Starts a local hub and connects to it (disconnects this machine from the current one)" }]),
293
+ { title: "Set up again…", value: "setup", description: "The first-run choices: this computer or another hub" },
294
+ ],
295
+ })
296
+ switch (pick) {
297
+ case "url": {
298
+ const server = await promptServer(s)
299
+ if (server) await setupRemote(s, server)
300
+ return
301
+ }
302
+ case "local":
303
+ return setupLocal(s, "local")
304
+ case "setup":
305
+ return runSetup({ ...s, enrolled: false })
306
+ }
307
+ }
308
+
309
+ const approvalLabel = (s: Status) =>
310
+ s.connection === "revoked"
311
+ ? "revoked"
312
+ : s.enrolled
313
+ ? "approved"
314
+ : s.enrollment === "pending"
315
+ ? "waiting for approval"
316
+ : s.enrollment
317
+ ? s.enrollment
318
+ : "not approved"
319
+
320
+ /** /hub: the hub connection. */
321
+ const open = async () => {
322
+ const sessionID = currentSession()
323
+ const s = await status(sessionID)
324
+ if (needsSetup(s) && s.enrollment !== "pending") return runSetup(s)
325
+ const connected = s.connection === "connected"
326
+ const local = isLoopbackURL(s.server)
327
+ const options: Array<{ title: string; value: Choice; description?: string }> = []
328
+
329
+ options.push({ title: `Server: ${s.server}`, value: "server", description: "Change the server URL, or use a hub on this computer" })
330
+ if (local && !connected && s.enrolled && s.mode !== "off") {
331
+ options.push({ title: "Start local hub", value: "start", description: "Start the hub on this computer and connect" })
332
+ }
333
+ options.push({
334
+ title: `This machine (${s.machineName}): ${approvalLabel(s)}`,
335
+ value: "approval",
336
+ description: s.enrolled ? "Approved by the hub" : "Approve this machine in the hub's web page",
337
+ })
338
+ options.push({ title: `Mode: ${MODE_LABEL[s.mode]}`, value: "mode", description: "Always / This session only / Off" })
339
+ if (s.mode === "session" && sessionID) {
340
+ options.push({
341
+ title: `This session: ${s.sessionExposed ? "on" : "off"}`,
342
+ value: "toggle",
343
+ description: s.sessionExposed ? "Stop sharing this session with the hub" : "Share this session with the hub",
344
+ })
345
+ }
346
+ if (connected) options.push({ title: "Pair a phone", value: "pair", description: "Show a pairing code for a new device" })
347
+ if (s.mode !== "off" && (connected || local) && s.enrolled) {
348
+ options.push({ title: "Open hub in browser", value: "ui", description: "Every session, plus settings (speech provider and keys)" })
349
+ }
350
+ if (connected && sessionID && s.sessionExposed) {
351
+ options.push({ title: "Send to phone", value: "send", description: "Notify your paired phones about this session" })
352
+ options.push({
353
+ title: `Conversation mode: ${s.conversation ? "on" : "off"}`,
354
+ value: "conversation",
355
+ description: s.conversation
356
+ ? "Switch this session back to its previous agent"
357
+ : "Voice orchestrator for this session (/voice also opens it in the browser)",
358
+ })
359
+ }
360
+
361
+ let title = describeStatus(s)
362
+ if (s.connection === "error" && s.localHub) title += ` (local hub: ${s.localHub})`
363
+ else if (s.connection === "error" && s.lastError) title += ` (${s.lastError})`
364
+ else if (s.mode === "off") title += " · choose a mode to start"
365
+ else if (connected && sessionID && !s.sessionExposed) title += " · this session is not shared"
366
+
367
+ const choice = await context.ui.dialog.select<Choice>({ title, options })
368
+ switch (choice) {
369
+ case "server":
370
+ return changeServer(s)
371
+ case "start": {
372
+ await ensureConnected(sessionID)
373
+ return toast(`Connected to ${host(s.server)}`, "success")
374
+ }
375
+ case "approval":
376
+ if (s.enrolled && s.connection !== "revoked") return toast(`This machine is approved by ${host(s.server)}`, "success")
377
+ if (s.enrollment === "pending") return toast("Waiting for approval in the hub's web page. You'll get a notice here.", "info")
378
+ return runSetup(s)
379
+ case "mode":
380
+ return chooseMode(s)
381
+ case "toggle":
382
+ await call("setSession", { sessionID, on: !s.sessionExposed })
383
+ return toast(`This session is ${s.sessionExposed ? "no longer shared" : "shared"} with the hub`, "success")
384
+ case "pair":
385
+ return pairPhone()
386
+ case "ui":
387
+ return openUI(sessionID)
388
+ case "send":
389
+ return sendToPhone(sessionID!)
390
+ case "conversation":
391
+ return toggleConversation(sessionID!, s)
392
+ }
393
+ }
394
+
395
+ /** /voice: no menu; connect (set up first when needed), share this session, open it with Voice mode on. */
396
+ const voice = () =>
397
+ startVoice({
398
+ sessionID: currentSession,
399
+ status: (sessionID) => status(sessionID),
400
+ setup: (s) => runSetup(s as Status, { open: false }),
401
+ chooseMode: (s) => chooseMode(s as Status, "Which sessions can the hub see?"),
402
+ connect: (s) => ensureConnected(currentSession()).then((x) => x ?? s),
403
+ expose: async (sessionID) => {
404
+ await call("setSession", { sessionID, on: true })
405
+ },
406
+ openLink: (sessionID, voice) => call<{ url: string }>("openLink", { sessionID, voice }),
407
+ showLink,
408
+ toast,
409
+ })
410
+
411
+ // Presence: tell the server-side spoke which sessions this TUI shows, so the phone lists them as open.
412
+ const reporter = `tui_${Math.random().toString(36).slice(2, 12)}`
413
+ const presence = new PresenceReporter({
414
+ read: () =>
415
+ shownSessions({
416
+ route: context.ui.router.current() as { type: string; sessionID?: string },
417
+ root: (id) => context.data.session.root(id),
418
+ tabs: context.ui.tabs.enabled() ? context.ui.tabs.list().map((t) => t.sessionID) : [],
419
+ }),
420
+ send: (sessionIDs) => call("presence", { sessionIDs: [...sessionIDs], reporter }),
421
+ })
422
+ presence.start()
423
+
424
+ // Questions answered on the hub that this TUI's (standalone) server cannot answer itself.
425
+ const forms = new FormRelay({
426
+ poll: (waitMs) => call<{ actions: unknown[] }>("formActions", { waitMs }),
427
+ apply: (action) =>
428
+ "cancel" in action
429
+ ? context.client.session.form.cancel({ sessionID: action.sessionID, formID: action.formID })
430
+ : context.client.session.form.reply({ sessionID: action.sessionID, formID: action.formID, answer: action.answer as Record<string, string | number | boolean | string[]> }),
431
+ })
432
+ void forms.start()
433
+
434
+ // One entry per command; a new /<feature> is one more entry (src/tui-commands.ts).
435
+ const commands = hubCommands(
436
+ [
437
+ { id: "open", slash: "hub", title: "server, connection, sharing and phones", run: open },
438
+ { id: "voice", slash: "voice", title: "talk to this session (Voice mode in the browser)", run: voice },
439
+ ],
440
+ (cause) => toast(errorMessage(cause), "error"),
441
+ )
442
+ context.keymap.layer(() => ({ mode: "global", commands }))
443
+ return () => {
444
+ presence.stop()
445
+ forms.stop()
446
+ }
447
+ },
448
+ })
449
+
450
+ // RPC failures arrive as plain objects ({ type, message, data }), not Error instances.
451
+ function errorMessage(cause: unknown): string {
452
+ if (cause && typeof cause === "object" && "message" in cause && typeof cause.message === "string") return cause.message
453
+ return String(cause)
454
+ }
455
+
456
+ function openURL(url: string): boolean {
457
+ const command = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : process.platform === "win32" ? "explorer" : undefined
458
+ if (!command) return false
459
+ try {
460
+ const child = spawn(command, [url], { stdio: "ignore", detached: true })
461
+ child.on("error", () => {})
462
+ child.unref()
463
+ return true
464
+ } catch {
465
+ return false
466
+ }
467
+ }
468
+
469
+ function copyToClipboard(text: string): boolean {
470
+ if (process.platform !== "darwin") return false
471
+ try {
472
+ const child = spawn("pbcopy", { stdio: ["pipe", "ignore", "ignore"] })
473
+ child.on("error", () => {})
474
+ child.stdin.end(text)
475
+ return true
476
+ } catch {
477
+ return false
478
+ }
479
+ }
package/src/turns.ts ADDED
@@ -0,0 +1,94 @@
1
+ // Tracks when a prompt we submitted has been fully handled.
2
+ //
3
+ // OpenCode reports a prompt's lifecycle as: session.inbox.delivered { inboxID } followed by
4
+ // session.execution.{succeeded|failed|interrupted} for that session. session.wait() can return
5
+ // before delivery, so it cannot be used to wait for the reply.
6
+
7
+ export type TurnResult =
8
+ | { outcome: "succeeded" }
9
+ | { outcome: "failed" | "interrupted"; message: string }
10
+ | { outcome: "timeout" }
11
+
12
+ interface EventLike {
13
+ readonly type: string
14
+ readonly data?: unknown
15
+ }
16
+
17
+ const ENDINGS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"])
18
+
19
+ export class TurnTracker {
20
+ private readonly pending = new Map<string, Set<string>>()
21
+ private readonly finished = new Map<string, TurnResult>()
22
+ private readonly waiters = new Map<string, Set<(result: TurnResult) => void>>()
23
+ private readonly historyLimit: number
24
+
25
+ constructor(options: { historyLimit?: number } = {}) {
26
+ this.historyLimit = options.historyLimit ?? 200
27
+ }
28
+
29
+ observe(event: EventLike): void {
30
+ const data = (event.data ?? {}) as Record<string, unknown>
31
+ const sessionID = typeof data.sessionID === "string" ? data.sessionID : undefined
32
+ if (!sessionID) return
33
+
34
+ if (event.type === "session.inbox.delivered" && typeof data.inboxID === "string") {
35
+ const ids = this.pending.get(sessionID) ?? new Set<string>()
36
+ ids.add(data.inboxID)
37
+ this.pending.set(sessionID, ids)
38
+ return
39
+ }
40
+
41
+ if (!ENDINGS.has(event.type)) return
42
+ const ids = this.pending.get(sessionID)
43
+ if (!ids) return
44
+ this.pending.delete(sessionID)
45
+ const result = resultOf(event.type, data)
46
+ for (const id of ids) this.finish(id, result)
47
+ }
48
+
49
+ wait(inboxID: string, options: { timeoutMs?: number } = {}): Promise<TurnResult> {
50
+ const done = this.finished.get(inboxID)
51
+ if (done) return Promise.resolve(done)
52
+ return new Promise((resolve) => {
53
+ const waiters = this.waiters.get(inboxID) ?? new Set()
54
+ const timer =
55
+ options.timeoutMs === undefined
56
+ ? undefined
57
+ : setTimeout(() => {
58
+ waiters.delete(settle)
59
+ resolve({ outcome: "timeout" })
60
+ }, options.timeoutMs)
61
+ const settle = (result: TurnResult) => {
62
+ if (timer) clearTimeout(timer)
63
+ resolve(result)
64
+ }
65
+ waiters.add(settle)
66
+ this.waiters.set(inboxID, waiters)
67
+ })
68
+ }
69
+
70
+ finishedCount(): number {
71
+ return this.finished.size
72
+ }
73
+
74
+ private finish(inboxID: string, result: TurnResult): void {
75
+ this.finished.set(inboxID, result)
76
+ while (this.finished.size > this.historyLimit) {
77
+ const oldest = this.finished.keys().next().value
78
+ if (oldest === undefined) break
79
+ this.finished.delete(oldest)
80
+ }
81
+ const waiters = this.waiters.get(inboxID)
82
+ this.waiters.delete(inboxID)
83
+ for (const settle of waiters ?? []) settle(result)
84
+ }
85
+ }
86
+
87
+ function resultOf(type: string, data: Record<string, unknown>): TurnResult {
88
+ if (type === "session.execution.succeeded") return { outcome: "succeeded" }
89
+ if (type === "session.execution.interrupted") {
90
+ return { outcome: "interrupted", message: String(data.reason ?? "interrupted") }
91
+ }
92
+ const error = data.error as { message?: unknown } | undefined
93
+ return { outcome: "failed", message: String(error?.message ?? "execution failed") }
94
+ }
@@ -0,0 +1,65 @@
1
+ // /voice: no menu. Makes sure the hub is set up and connected (running the /hub setup first when it is not),
2
+ // shares the session on screen for Voice, and opens it in the browser with Voice mode starting
3
+ // (`/s/<id>?voice=1` through a one-time sign-in link). The TUI (src/tui.ts) supplies the dialogs and RPCs.
4
+ import { needsSetup, type Mode } from "./setup"
5
+
6
+ /** The parts of hub.status the flow reads. */
7
+ export interface VoiceStatus {
8
+ readonly server: string
9
+ readonly mode: Mode
10
+ readonly enrolled: boolean
11
+ readonly connection: "off" | "connecting" | "connected" | "revoked" | "error"
12
+ readonly sessionExposed?: boolean
13
+ readonly localHub?: string
14
+ readonly lastError?: string
15
+ }
16
+
17
+ export interface VoiceCommandDeps {
18
+ /** The session on screen. */
19
+ sessionID(): string | undefined
20
+ status(sessionID: string): Promise<VoiceStatus>
21
+ /** The /hub first-run setup (without opening the browser); resolves when the user finished or left it. */
22
+ setup(status: VoiceStatus): Promise<void>
23
+ /** /hub → Mode, for a machine whose sharing is off. */
24
+ chooseMode(status: VoiceStatus): Promise<void>
25
+ /** Connects (starting a local hub when needed) and waits; throws with the reason when it can't. */
26
+ connect(status: VoiceStatus): Promise<VoiceStatus>
27
+ /** Shares the session for Voice (this session on). */
28
+ expose(sessionID: string): Promise<void>
29
+ openLink(sessionID: string, voice: true): Promise<{ url: string }>
30
+ /** Opens the link in the browser (and says so), or shows it. */
31
+ showLink(url: string, what: string): Promise<void>
32
+ toast(message: string, variant: "info" | "success" | "warning" | "error"): void
33
+ }
34
+
35
+ export type VoiceOutcome = "opened" | "no-session" | "not-set-up" | "off"
36
+
37
+ export async function startVoice(deps: VoiceCommandDeps): Promise<VoiceOutcome> {
38
+ const sessionID = deps.sessionID()
39
+ if (!sessionID) {
40
+ deps.toast("Open a session first, then run /voice to talk to it in the browser.", "warning")
41
+ return "no-session"
42
+ }
43
+ let s = await deps.status(sessionID)
44
+ if (needsSetup(s)) {
45
+ await deps.setup(s)
46
+ s = await deps.status(sessionID)
47
+ if (needsSetup(s)) {
48
+ deps.toast("Finish setting up the hub (approve this machine if asked), then run /voice again.", "warning")
49
+ return "not-set-up"
50
+ }
51
+ }
52
+ if (s.mode === "off") {
53
+ await deps.chooseMode(s)
54
+ s = await deps.status(sessionID)
55
+ if (s.mode === "off") {
56
+ deps.toast("Sharing with the hub is off for this machine. Choose a mode in /hub, then run /voice again.", "warning")
57
+ return "off"
58
+ }
59
+ }
60
+ if (s.connection !== "connected") s = await deps.connect(s)
61
+ if (!s.sessionExposed) await deps.expose(sessionID)
62
+ const { url } = await deps.openLink(sessionID, true)
63
+ await deps.showLink(url, "Voice mode for this session")
64
+ return "opened"
65
+ }
package/tui.ts ADDED
@@ -0,0 +1,2 @@
1
+ // OpenCode resolves a local plugin directory's TUI entry as <dir>/tui.
2
+ export { default } from "./src/tui.ts"
Binary file