@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/status.ts ADDED
@@ -0,0 +1,232 @@
1
+ // Live status of a voice session and the subagent sessions it spawns, built from OpenCode events.
2
+
3
+ export type SessionState = "idle" | "working" | "blocked" | "error"
4
+
5
+ export interface Blocker {
6
+ readonly kind: "permission" | "form"
7
+ readonly id: string
8
+ readonly summary: string
9
+ /** form: asked by the agent's question tool (metadata.kind "question"). */
10
+ readonly question?: true
11
+ /** form: its fields, bounded, so a phone can render and answer it. */
12
+ readonly fields?: readonly FormFieldSpec[]
13
+ }
14
+
15
+ export interface FormOptionSpec {
16
+ readonly value: string
17
+ readonly label: string
18
+ readonly description?: string
19
+ }
20
+
21
+ /** One field of an OpenCode form (FormField), with only what a client needs to render and validate it. */
22
+ export interface FormFieldSpec {
23
+ readonly key: string
24
+ readonly type: "string" | "number" | "integer" | "boolean" | "multiselect" | "external"
25
+ readonly title?: string
26
+ readonly description?: string
27
+ readonly required?: boolean
28
+ readonly options?: readonly FormOptionSpec[]
29
+ /** string/multiselect with options: free text allowed besides the options. */
30
+ readonly custom?: boolean
31
+ readonly placeholder?: string
32
+ readonly default?: string | number | boolean | readonly string[]
33
+ readonly minimum?: number
34
+ readonly maximum?: number
35
+ readonly minLength?: number
36
+ readonly maxLength?: number
37
+ readonly minItems?: number
38
+ readonly maxItems?: number
39
+ readonly format?: string
40
+ readonly pattern?: string
41
+ /** external: a link the user follows. */
42
+ readonly url?: string
43
+ /** Shown only when these conditions on other fields hold. */
44
+ readonly when?: ReadonlyArray<{ readonly key: string; readonly op: "eq" | "neq"; readonly value: string | number | boolean }>
45
+ }
46
+
47
+ export const FORM_MAX_FIELDS = 20
48
+ export const FORM_MAX_OPTIONS = 50
49
+ const FORM_TEXT_MAX = 2000
50
+ const FORM_LABEL_MAX = 300
51
+ const FIELD_TYPES = new Set<FormFieldSpec["type"]>(["string", "number", "integer", "boolean", "multiselect", "external"])
52
+
53
+ const text = (v: unknown, max: number) => (typeof v === "string" && v ? v.slice(0, max) : undefined)
54
+ const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
55
+
56
+ function optionSpec(raw: any): FormOptionSpec | undefined {
57
+ const value = text(raw?.value, FORM_LABEL_MAX)
58
+ if (value === undefined) return undefined
59
+ const description = text(raw.description, FORM_TEXT_MAX)
60
+ return { value, label: text(raw.label, FORM_LABEL_MAX) ?? value, ...(description ? { description } : {}) }
61
+ }
62
+
63
+ /** The renderable part of an OpenCode form field; undefined for fields a client cannot show. */
64
+ export function formFieldSpec(raw: any): FormFieldSpec | undefined {
65
+ const key = text(raw?.key, FORM_LABEL_MAX)
66
+ if (!key || !FIELD_TYPES.has(raw.type)) return undefined
67
+ const out: Record<string, unknown> = { key, type: raw.type }
68
+ for (const name of ["title", "placeholder", "format", "pattern"] as const) if (text(raw[name], FORM_LABEL_MAX)) out[name] = text(raw[name], FORM_LABEL_MAX)
69
+ if (text(raw.description, FORM_TEXT_MAX)) out.description = text(raw.description, FORM_TEXT_MAX)
70
+ if (raw.type === "external") {
71
+ const url = text(raw.url, FORM_TEXT_MAX)
72
+ if (!url || !/^https?:\/\//.test(url)) return undefined
73
+ out.url = url
74
+ }
75
+ if (raw.required === true) out.required = true
76
+ if (raw.custom === true) out.custom = true
77
+ if (Array.isArray(raw.options)) {
78
+ const options = raw.options.slice(0, FORM_MAX_OPTIONS).map(optionSpec).filter(Boolean)
79
+ if (options.length) out.options = options
80
+ }
81
+ for (const name of ["minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"] as const) if (num(raw[name]) !== undefined) out[name] = raw[name]
82
+ const d = raw.default
83
+ if (typeof d === "boolean" || num(d) !== undefined) out.default = d
84
+ else if (typeof d === "string") out.default = d.slice(0, FORM_TEXT_MAX)
85
+ else if (Array.isArray(d)) out.default = d.filter((x: unknown) => typeof x === "string").slice(0, FORM_MAX_OPTIONS)
86
+ if (Array.isArray(raw.when)) {
87
+ const when = raw.when
88
+ .filter((w: any) => typeof w?.key === "string" && (w.op === "eq" || w.op === "neq") && ["string", "number", "boolean"].includes(typeof w.value))
89
+ .slice(0, 10)
90
+ .map((w: any) => ({ key: w.key, op: w.op, value: w.value }))
91
+ if (when.length) out.when = when
92
+ }
93
+ return out as unknown as FormFieldSpec
94
+ }
95
+
96
+ /** A form.created event's form as a blocker. */
97
+ function formBlocker(form: any): Blocker {
98
+ const fields = Array.isArray(form.fields) ? form.fields.map(formFieldSpec).filter(Boolean).slice(0, FORM_MAX_FIELDS) : []
99
+ return {
100
+ kind: "form",
101
+ id: form.id,
102
+ summary: typeof form.title === "string" && form.title ? form.title.slice(0, FORM_LABEL_MAX) : "question",
103
+ ...(form.metadata?.kind === "question" ? { question: true as const } : {}),
104
+ ...(fields.length ? { fields } : {}),
105
+ }
106
+ }
107
+
108
+ export interface SessionStatus {
109
+ readonly id: string
110
+ readonly parentID?: string
111
+ readonly title: string
112
+ readonly state: SessionState
113
+ readonly detail?: string
114
+ readonly blockers: ReadonlyArray<Blocker>
115
+ }
116
+
117
+ interface Entry {
118
+ id: string
119
+ parentID?: string
120
+ title: string
121
+ running: boolean
122
+ error?: string
123
+ blockers: Blocker[]
124
+ }
125
+
126
+ interface EventLike {
127
+ readonly type: string
128
+ readonly data?: unknown
129
+ }
130
+
131
+ export class StatusBoard {
132
+ private readonly sessions = new Map<string, Entry>()
133
+
134
+ constructor(rootID: string, title: string) {
135
+ this.sessions.set(rootID, { id: rootID, title, running: false, blockers: [] })
136
+ }
137
+
138
+ /** Applies an event. Returns true when the visible status changed. */
139
+ observe(event: EventLike): boolean {
140
+ const data = (event.data ?? {}) as Record<string, any>
141
+ switch (event.type) {
142
+ case "session.created": {
143
+ if (typeof data.parentID !== "string" || !this.sessions.has(data.parentID)) return false
144
+ this.sessions.set(data.sessionID, {
145
+ id: data.sessionID,
146
+ parentID: data.parentID,
147
+ title: data.title ?? data.agent ?? data.slug ?? "subagent",
148
+ running: false,
149
+ blockers: [],
150
+ })
151
+ return true
152
+ }
153
+ case "session.renamed":
154
+ return this.update(data.sessionID, (entry) => (entry.title = data.title))
155
+ case "session.deleted":
156
+ return this.sessions.delete(data.sessionID)
157
+ case "session.execution.started":
158
+ return this.update(data.sessionID, (entry) => {
159
+ entry.running = true
160
+ entry.error = undefined
161
+ })
162
+ case "session.execution.succeeded":
163
+ case "session.execution.interrupted":
164
+ return this.update(data.sessionID, (entry) => (entry.running = false))
165
+ case "session.execution.failed":
166
+ return this.update(data.sessionID, (entry) => {
167
+ entry.running = false
168
+ entry.error = String(data.error?.message ?? "failed")
169
+ })
170
+ case "permission.asked":
171
+ return this.update(data.sessionID, (entry) => {
172
+ if (entry.blockers.some((b) => b.id === data.id)) return
173
+ entry.blockers.push({
174
+ kind: "permission",
175
+ id: data.id,
176
+ summary: [data.action, (data.resources ?? []).join(", ")].filter(Boolean).join(": "),
177
+ })
178
+ })
179
+ case "permission.replied":
180
+ return this.unblock(data.sessionID, data.requestID)
181
+ case "form.created":
182
+ return this.update(data.form?.sessionID, (entry) => {
183
+ if (entry.blockers.some((b) => b.id === data.form.id)) return
184
+ entry.blockers.push(formBlocker(data.form))
185
+ })
186
+ case "form.replied":
187
+ case "form.cancelled":
188
+ return this.unblock(data.sessionID, data.id)
189
+ default:
190
+ return false
191
+ }
192
+ }
193
+
194
+ get(id: string): SessionStatus | undefined {
195
+ const entry = this.sessions.get(id)
196
+ return entry && view(entry)
197
+ }
198
+
199
+ ids(): string[] {
200
+ return [...this.sessions.keys()]
201
+ }
202
+
203
+ snapshot(): { sessions: SessionStatus[] } {
204
+ return { sessions: [...this.sessions.values()].map(view) }
205
+ }
206
+
207
+ private update(id: unknown, change: (entry: Entry) => void): boolean {
208
+ const entry = typeof id === "string" ? this.sessions.get(id) : undefined
209
+ if (!entry) return false
210
+ change(entry)
211
+ return true
212
+ }
213
+
214
+ private unblock(sessionID: unknown, blockerID: unknown): boolean {
215
+ return this.update(sessionID, (entry) => {
216
+ entry.blockers = entry.blockers.filter((blocker) => blocker.id !== blockerID)
217
+ })
218
+ }
219
+ }
220
+
221
+ function view(entry: Entry): SessionStatus {
222
+ const state: SessionState =
223
+ entry.blockers.length > 0 ? "blocked" : entry.error ? "error" : entry.running ? "working" : "idle"
224
+ return {
225
+ id: entry.id,
226
+ ...(entry.parentID ? { parentID: entry.parentID } : {}),
227
+ title: entry.title,
228
+ state,
229
+ ...(entry.error && state === "error" ? { detail: entry.error } : {}),
230
+ blockers: entry.blockers.map((blocker) => structuredClone(blocker)),
231
+ }
232
+ }
@@ -0,0 +1,53 @@
1
+ // Hub's TUI slash commands, one entry per feature. /hub configures this harness's connection to the hub;
2
+ // every other entry is a feature (/voice today). To add a feature, write its flow in its own module
3
+ // (like src/voice-command.ts) and add one HubCommand to the list src/tui.ts passes to hubCommands().
4
+
5
+ /** The palette group of every Hub command. */
6
+ export const COMMAND_GROUP = "Hub"
7
+ /** Command IDs are `hub.<id>`, the plugin's namespace. */
8
+ export const COMMAND_PREFIX = "hub."
9
+
10
+ export interface HubCommand {
11
+ /** Unique within Hub; the command ID is `hub.<id>`. */
12
+ readonly id: string
13
+ /** The slash command, without the "/". */
14
+ readonly slash: string
15
+ /** Shown in the command palette after "Hub: ". */
16
+ readonly title: string
17
+ readonly run: () => Promise<unknown>
18
+ }
19
+
20
+ /** The shape OpenCode's keymap layer takes for one command. */
21
+ export interface KeymapCommand {
22
+ readonly id: string
23
+ readonly title: string
24
+ readonly group: string
25
+ readonly palette: true
26
+ readonly slash: { readonly name: string }
27
+ readonly run: () => void
28
+ }
29
+
30
+ /**
31
+ * Keymap commands for the given features. Throws on a duplicate ID or slash name (a programming error).
32
+ * A failing run is reported through `onError` (a toast), never thrown into the TUI.
33
+ */
34
+ export function hubCommands(features: readonly HubCommand[], onError: (cause: unknown) => void): KeymapCommand[] {
35
+ const ids = new Set<string>()
36
+ const names = new Set<string>()
37
+ return features.map((feature) => {
38
+ if (ids.has(feature.id)) throw new Error(`duplicate Hub command id "${feature.id}"`)
39
+ if (names.has(feature.slash)) throw new Error(`duplicate Hub slash command "/${feature.slash}"`)
40
+ ids.add(feature.id)
41
+ names.add(feature.slash)
42
+ return {
43
+ id: `${COMMAND_PREFIX}${feature.id}`,
44
+ title: `${COMMAND_GROUP}: ${feature.title}`,
45
+ group: COMMAND_GROUP,
46
+ palette: true,
47
+ slash: { name: feature.slash },
48
+ run: () => {
49
+ void feature.run().catch(onError)
50
+ },
51
+ }
52
+ })
53
+ }
@@ -0,0 +1,77 @@
1
+ // TUI side of form (question) replies from the hub: a standalone server started by this TUI cannot call its
2
+ // own HTTP API (its password stays with the TUI), so its spoke queues the replies and the TUI long-polls
3
+ // RPC hub.formActions and carries them out with its client. See Spoke.formActions.
4
+
5
+ export type RelayedFormAction =
6
+ | { readonly sessionID: string; readonly formID: string; readonly answer: Record<string, unknown> }
7
+ | { readonly sessionID: string; readonly formID: string; readonly cancel: true }
8
+
9
+ export interface FormRelayOptions {
10
+ /** RPC hub.formActions: waits up to `waitMs` for queued replies. */
11
+ readonly poll: (waitMs: number) => Promise<{ actions?: readonly unknown[] }>
12
+ /** Carries one out (client.session.form.reply / cancel). Errors are logged and skipped. */
13
+ readonly apply: (action: RelayedFormAction) => Promise<unknown>
14
+ readonly waitMs?: number
15
+ /** Pause after a failed poll (no spoke, older server, disconnected). */
16
+ readonly retryMs?: number
17
+ }
18
+
19
+ function action(raw: unknown): RelayedFormAction | undefined {
20
+ const a = raw as Record<string, unknown> | null
21
+ if (!a || typeof a.sessionID !== "string" || typeof a.formID !== "string") return undefined
22
+ if (a.cancel === true) return { sessionID: a.sessionID, formID: a.formID, cancel: true }
23
+ if (typeof a.answer === "object" && a.answer !== null && !Array.isArray(a.answer)) {
24
+ return { sessionID: a.sessionID, formID: a.formID, answer: a.answer as Record<string, unknown> }
25
+ }
26
+ return undefined
27
+ }
28
+
29
+ export class FormRelay {
30
+ private running = false
31
+ private wake?: () => void
32
+
33
+ constructor(private readonly options: FormRelayOptions) {}
34
+
35
+ /** Polls until stop(); resolves when stopped. */
36
+ async start(): Promise<void> {
37
+ if (this.running) return
38
+ this.running = true
39
+ while (this.running) {
40
+ let actions: readonly unknown[] = []
41
+ try {
42
+ actions = (await this.options.poll(this.options.waitMs ?? 20_000)).actions ?? []
43
+ } catch {
44
+ await this.sleep(this.options.retryMs ?? 10_000)
45
+ continue
46
+ }
47
+ for (const raw of actions) {
48
+ const next = action(raw)
49
+ if (!next) continue
50
+ try {
51
+ await this.options.apply(next)
52
+ } catch (cause) {
53
+ console.error("[hub] could not answer a question from the hub", cause instanceof Error ? cause.message : cause)
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ stop(): void {
60
+ this.running = false
61
+ this.wake?.()
62
+ }
63
+
64
+ private sleep(ms: number): Promise<void> {
65
+ return new Promise((resolve) => {
66
+ const timer = setTimeout(done, ms)
67
+ ;(timer as { unref?: () => void }).unref?.()
68
+ const self = this
69
+ function done() {
70
+ clearTimeout(timer)
71
+ self.wake = undefined
72
+ resolve()
73
+ }
74
+ this.wake = done
75
+ })
76
+ }
77
+ }
@@ -0,0 +1,75 @@
1
+ // TUI presence: the TUI tells its server-side spoke which sessions it shows (the current route's root
2
+ // session and its open tabs) over RPC voice.presence, on change (the route is polled every 2 s) and every
3
+ // 15 s, so the phone can list them as open. See docs/session-view.md.
4
+
5
+ export const PRESENCE_POLL_MS = 2000
6
+ export const PRESENCE_HEARTBEAT_MS = 15_000
7
+
8
+ /** The root of the session the route shows, plus the open tabs (root sessions), deduplicated. */
9
+ export function shownSessions(input: {
10
+ route: { type: string; sessionID?: string }
11
+ root: (sessionID: string) => string
12
+ tabs?: readonly string[]
13
+ }): string[] {
14
+ const out = new Set<string>()
15
+ const current = input.route.type === "session" ? input.route.sessionID : undefined
16
+ if (current) {
17
+ let root = current
18
+ try {
19
+ root = input.root(current) || current
20
+ } catch {
21
+ // not synced yet: the session itself
22
+ }
23
+ out.add(root)
24
+ }
25
+ for (const id of input.tabs ?? []) if (id) out.add(id)
26
+ return [...out]
27
+ }
28
+
29
+ export interface PresenceReporterOptions {
30
+ /** The sessions shown right now. */
31
+ readonly read: () => readonly string[]
32
+ /** Reports them (errors are ignored: the next tick retries). */
33
+ readonly send: (sessionIDs: readonly string[]) => Promise<unknown>
34
+ readonly pollMs?: number
35
+ readonly heartbeatMs?: number
36
+ }
37
+
38
+ export class PresenceReporter {
39
+ private timers: Array<ReturnType<typeof setInterval>> = []
40
+ private last?: string
41
+
42
+ constructor(private readonly options: PresenceReporterOptions) {}
43
+
44
+ start(): void {
45
+ if (this.timers.length) return
46
+ this.tick(true)
47
+ this.every(this.options.pollMs ?? PRESENCE_POLL_MS, () => this.tick(false))
48
+ this.every(this.options.heartbeatMs ?? PRESENCE_HEARTBEAT_MS, () => this.tick(true))
49
+ }
50
+
51
+ stop(): void {
52
+ for (const timer of this.timers) clearInterval(timer)
53
+ this.timers = []
54
+ }
55
+
56
+ /** Reads now and reports when changed (or always, for the heartbeat). */
57
+ tick(always = false): void {
58
+ let ids: readonly string[]
59
+ try {
60
+ ids = this.options.read()
61
+ } catch {
62
+ return
63
+ }
64
+ const key = [...ids].sort().join(" ")
65
+ if (!always && key === this.last) return
66
+ this.last = key
67
+ this.options.send(ids).catch(() => {})
68
+ }
69
+
70
+ private every(ms: number, run: () => void): void {
71
+ const timer = setInterval(run, ms)
72
+ ;(timer as { unref?: () => void }).unref?.()
73
+ this.timers.push(timer)
74
+ }
75
+ }