@huynle/hub 0.0.0-stage → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +269 -0
- package/Dockerfile +51 -0
- package/LICENSE +21 -0
- package/README.md +1029 -2
- package/bin/hub.ts +20 -0
- package/docker/hub-deps.ts +42 -0
- package/package.json +85 -4
- package/rpc.ts +2 -0
- package/scripts/hub-local.sh +28 -0
- package/scripts/io.github.huynle.hub.plist +42 -0
- package/server.ts +3 -0
- package/src/central/assets.ts +74 -0
- package/src/central/auth.ts +271 -0
- package/src/central/config.ts +310 -0
- package/src/central/http.ts +1247 -0
- package/src/central/main.ts +136 -0
- package/src/central/oidc.ts +295 -0
- package/src/central/push-target.ts +97 -0
- package/src/central/push.ts +285 -0
- package/src/central/registry.ts +1153 -0
- package/src/central/speech/models.ts +89 -0
- package/src/central/speech/openai.ts +66 -0
- package/src/central/speech/openrouter.ts +71 -0
- package/src/central/speech/provider.ts +74 -0
- package/src/central/speech/service.ts +168 -0
- package/src/central/speech/settings.ts +375 -0
- package/src/central/spoke-ws.ts +300 -0
- package/src/central/store.ts +653 -0
- package/src/config.ts +170 -0
- package/src/env.ts +63 -0
- package/src/index.ts +236 -0
- package/src/key.ts +17 -0
- package/src/loopback.ts +41 -0
- package/src/migrate.ts +105 -0
- package/src/openrouter.ts +3 -0
- package/src/options.ts +33 -0
- package/src/protocol.ts +650 -0
- package/src/reply.ts +27 -0
- package/src/rpc.ts +98 -0
- package/src/setup.ts +124 -0
- package/src/speakable.ts +35 -0
- package/src/spoke/client.ts +272 -0
- package/src/spoke/conversation.ts +93 -0
- package/src/spoke/enroll.ts +79 -0
- package/src/spoke/herdr.ts +346 -0
- package/src/spoke/listing.ts +108 -0
- package/src/spoke/localhub.ts +287 -0
- package/src/spoke/locations.ts +34 -0
- package/src/spoke/own-server.ts +32 -0
- package/src/spoke/presence.ts +45 -0
- package/src/spoke/singleton.ts +103 -0
- package/src/spoke/spoke.ts +1212 -0
- package/src/spoke/summary.ts +117 -0
- package/src/spoke/timeline.ts +93 -0
- package/src/spoke/tracker.ts +345 -0
- package/src/spoke/watch.ts +78 -0
- package/src/status.ts +232 -0
- package/src/tui-commands.ts +53 -0
- package/src/tui-forms.ts +77 -0
- package/src/tui-presence.ts +75 -0
- package/src/tui.ts +479 -0
- package/src/turns.ts +94 -0
- package/src/voice-command.ts +65 -0
- package/tui.ts +2 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DClBvfLL.css +1 -0
- package/web/dist/assets/index-Djo_yF0a.js +12 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +18 -0
- package/web/dist/index.html +25 -0
- package/web/dist/manifest.webmanifest +18 -0
- package/web/dist/sw.js +151 -0
- 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
|
Binary file
|