@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/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
|
+
}
|
package/src/speakable.ts
ADDED
|
@@ -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
|
+
}
|