@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.
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
@@ -0,0 +1,1212 @@
1
+ // The spoke: one per OpenCode process. Watches the machine config, tracks this process's sessions,
2
+ // keeps the WebSocket to the central server, forwards exposed events, and executes the server's
3
+ // requests (voice turns, permission replies, context reads) through the plugin context.
4
+ import path from "node:path"
5
+ import { readConfig, setSession, withServer, writeConfig, type Mode, type VoiceConfig, explicitVariant, formatModelRef, parseModelRef, plainModelRef, type ModelRef } from "../config"
6
+ import { isLoopbackURL } from "../loopback"
7
+ import {
8
+ compactActivity,
9
+ type HerdrWorkspace,
10
+ type FormAnswer,
11
+ type PermissionDecision,
12
+ type ProcessKind,
13
+ type PromptImage,
14
+ type ResultFor,
15
+ type ServerRequest,
16
+ type SessionModelPrefs,
17
+ type SpokeRequest,
18
+ type TerminalInfo,
19
+ type TurnResult,
20
+ } from "../protocol"
21
+ import { extractReply } from "../reply"
22
+ import { TurnTracker } from "../turns"
23
+ import { boundedTimelinePart, timelineFile, timelineItems } from "./timeline"
24
+ import { SpokeConnection, type Backoff, type ConnectionState } from "./client"
25
+ import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
26
+ import { pollEnrollment, startEnrollment } from "./enroll"
27
+ import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
28
+ import type { LocalHubResult } from "./localhub"
29
+ import { PresenceBook } from "./presence"
30
+ import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
31
+ import { browserSessions, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
32
+ import { watchConfig, type ConfigWatcher } from "./watch"
33
+
34
+ export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
35
+
36
+ export class SpokeError extends Error {
37
+ constructor(
38
+ readonly name: SpokeErrorName,
39
+ message: string,
40
+ ) {
41
+ super(message)
42
+ }
43
+ }
44
+
45
+ /** The shared background service runs `opencode serve --service ...`; anything else is a standalone process. */
46
+ export function detectKind(argv: readonly string[]): ProcessKind {
47
+ return argv.includes("--service") ? "service" : "standalone"
48
+ }
49
+
50
+ export interface HostSession {
51
+ readonly id: string
52
+ readonly title?: string
53
+ readonly parentID?: string
54
+ readonly location: { readonly directory: string }
55
+ readonly time: { readonly updated: number }
56
+ /** The session's selected agent; absent means the default agent. */
57
+ readonly agent?: string
58
+ /** The session's selected model; absent means the default model. */
59
+ readonly model?: ModelRef
60
+ }
61
+
62
+ /** A prompt attachment as OpenCode's prompt input takes it; a `data:` URI is stored inline. */
63
+ export interface PromptFile {
64
+ readonly uri: string
65
+ readonly name?: string
66
+ }
67
+
68
+ /** Prompt images as OpenCode file attachments (base64 data URLs), or nothing without images. */
69
+ export function promptFiles(images: readonly PromptImage[] | undefined): { files?: PromptFile[] } {
70
+ if (!images?.length) return {}
71
+ return { files: images.map((image) => ({ uri: `data:${image.mime};base64,${image.data}`, ...(image.name ? { name: image.name } : {}) })) }
72
+ }
73
+
74
+ /** What the spoke needs from the plugin context (a fake in tests). */
75
+ export interface SpokeHost {
76
+ readonly directory: string
77
+ readonly opencodeVersion: string
78
+ events(signal: AbortSignal): AsyncIterable<EventLike>
79
+ prompt(input: { sessionID: string; text: string; files?: readonly PromptFile[]; metadata: Record<string, unknown>; delivery?: "steer" }): Promise<{ id: string }>
80
+ context(sessionID: string): Promise<ReadonlyArray<unknown>>
81
+ listModels?(): Promise<ReadonlyArray<unknown>>
82
+ getSession(sessionID: string): Promise<HostSession>
83
+ /**
84
+ * Replies to a pending permission; "gone" when it is not pending here any more (answered, expired).
85
+ * `directory` is the session's directory when known (permissions are pending per location).
86
+ */
87
+ replyPermission(input: { sessionID: string; requestID: string; decision: PermissionDecision; directory?: string }): Promise<unknown>
88
+ /**
89
+ * Answers or cancels a pending form through OpenCode's API: "gone" when it is not pending any more,
90
+ * "unavailable" when this process cannot reach its own server (then a TUI relays it, see formActions).
91
+ * Absent: always relayed.
92
+ */
93
+ replyForm?(input: FormAction): Promise<"replied" | "gone" | "unavailable">
94
+ interrupt(sessionID: string): Promise<unknown>
95
+ switchAgent(sessionID: string, agent: string): Promise<unknown>
96
+ switchModel(sessionID: string, model: ModelRef): Promise<unknown>
97
+ hasAgent(id: string): Promise<boolean>
98
+ /** The agent a session without an explicit selection runs with. */
99
+ defaultAgent(): Promise<string | undefined>
100
+ /**
101
+ * Recent root sessions from the local service with their workspace (service processes only): those in
102
+ * the history window (`days`, at most `max`), plus `include` (always listed: open, running, ...).
103
+ */
104
+ listRecent?(options?: { days: number; max: number; include: readonly string[] }): Promise<Array<{ id: string } & SessionDetails>>
105
+ /** Creates a session in a directory (service processes only). */
106
+ createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
107
+ /** MCP servers and their status for a directory (raw OpenCode McpServer list). */
108
+ mcpList?(directory: string): Promise<ReadonlyArray<unknown>>
109
+ /** Working-tree changes of a directory (raw OpenCode FileDiffInfo list). */
110
+ vcsDiff?(directory: string): Promise<ReadonlyArray<unknown>>
111
+ /** Current branch of a directory. */
112
+ vcsBranch?(directory: string): Promise<string | undefined>
113
+ }
114
+
115
+ /** A form reply for OpenCode: an answer, or a cancel. */
116
+ export type FormAction =
117
+ | { readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
118
+ | { readonly sessionID: string; readonly formID: string; readonly cancel: true }
119
+
120
+ /** A TUI that polled formActions within this window can relay form replies. */
121
+ const RELAY_WINDOW_MS = 30_000
122
+ /** How long a relayed form reply may take to show up as form.replied/cancelled. */
123
+ const RELAY_TIMEOUT_MS = 15_000
124
+ const ANSWER_IN_TERMINAL = "This OpenCode process cannot answer questions from the hub; answer it in the terminal"
125
+
126
+ export interface SpokeOptions {
127
+ readonly configFile: string
128
+ readonly machineName: string
129
+ readonly kind: ProcessKind
130
+ readonly processID: string
131
+ readonly pid: number
132
+ readonly pluginVersion: string
133
+ readonly turnTimeoutMs?: number
134
+ readonly snapshotDebounceMs?: number
135
+ readonly snapshotIntervalMs?: number
136
+ readonly seedIntervalMs?: number
137
+ /** Delay before re-listing the service's sessions after a session event (default 2 s). */
138
+ readonly seedDebounceMs?: number
139
+ readonly pollMs?: number
140
+ readonly backoff?: Backoff
141
+ /**
142
+ * Starts a local hub for a loopback server nothing answers on (ensureLocalHub). Without it the spoke
143
+ * never starts one. Used when the connection fails (service process at once, standalone processes
144
+ * after localHubDelayMs) and when enrolling (at once).
145
+ */
146
+ readonly localHub?: (server: string) => Promise<LocalHubResult>
147
+ /** Grace period before a standalone process tries, so the service process goes first (default 8 s). */
148
+ readonly localHubDelayMs?: number
149
+ /** Minimum time between attempts; doubles after each failure up to 15 min (default 30 s). */
150
+ readonly localHubRetryMs?: number
151
+ /**
152
+ * Runs the `herdr` CLI. Only the service process reads Herdr (one reader per machine; standalone
153
+ * processes never do), and only while the machine config's `herdr` is on.
154
+ */
155
+ readonly herdr?: HerdrRunner
156
+ /** Herdr poll intervals (default 5 s agents, 30 s workspaces). */
157
+ readonly herdrPollMs?: { readonly agents?: number; readonly workspaces?: number }
158
+ /** Pause between creating a Herdr tab and starting OpenCode in it (default 500 ms). */
159
+ readonly herdrSettleMs?: number
160
+ /** How often expired TUI presence is checked (default 5 s). */
161
+ readonly presenceCheckMs?: number
162
+ }
163
+
164
+ export type EnrollmentState = "pending" | "approved" | "denied" | "expired" | "error"
165
+
166
+ export interface SpokeStatus {
167
+ readonly server: string
168
+ readonly mode: Mode
169
+ readonly enrolled: boolean
170
+ readonly connection: ConnectionState
171
+ readonly lastError?: string
172
+ readonly sessionExposed?: boolean
173
+ /** Conversation mode is on for the session in this process. */
174
+ readonly conversation?: boolean
175
+ readonly enrollment?: EnrollmentState
176
+ readonly machineName: string
177
+ readonly kind: ProcessKind
178
+ /** Why the local hub could not be started (auto-start), until the connection succeeds. */
179
+ readonly localHub?: string
180
+ }
181
+
182
+ export interface EnrollResult {
183
+ readonly userCode: string
184
+ readonly approveURL: string
185
+ /** The local hub approved this machine without confirmation (first machine). */
186
+ readonly autoApproved?: boolean
187
+ /** A local hub was started (or found starting) for this enrollment. */
188
+ readonly localHub?: "started" | "running" | "busy"
189
+ }
190
+
191
+ const MODES: readonly Mode[] = ["off", "always", "session"]
192
+ const INFO_RETRY_MS = 60_000
193
+ const CONTEXT_LIMIT = 20
194
+
195
+ type Message = { id?: string; type?: string; text?: unknown; content?: ReadonlyArray<{ type?: string; text?: unknown }> | null }
196
+
197
+ function contextMessages(messages: ReadonlyArray<unknown>): ResultFor["context"]["messages"] {
198
+ const out: Array<{ role: "user" | "assistant"; text: string }> = []
199
+ for (const raw of messages) {
200
+ const m = raw as Message
201
+ if (m?.type === "user" && typeof m.text === "string" && m.text.trim()) out.push({ role: "user", text: m.text.trim() })
202
+ if (m?.type === "assistant") {
203
+ const text = (m.content ?? [])
204
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
205
+ .map((p) => p.text as string)
206
+ .join("")
207
+ .trim()
208
+ if (text) out.push({ role: "assistant", text })
209
+ }
210
+ }
211
+ return out.slice(-CONTEXT_LIMIT)
212
+ }
213
+
214
+ const FALLBACK_AGENT = "build"
215
+ const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
216
+ const ENROLL_RETRY_MS = 500
217
+ const NOT_SHARED = "This session is not shared with voice"
218
+ const UNKNOWN_SESSION = "Unknown session"
219
+ /** Events after which the service re-lists its sessions (other processes' sessions appear, change, go). */
220
+ const LISTING_EVENTS = new Set(["session.created", "session.renamed", "session.deleted"])
221
+
222
+ const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
223
+
224
+ export class Spoke {
225
+ private config: VoiceConfig
226
+ private watcher?: ConfigWatcher
227
+ private connection?: SpokeConnection
228
+ private connectionKey = ""
229
+ private readonly tracker = new SessionTracker()
230
+ private readonly turns = new TurnTracker()
231
+ private readonly voiceTurns = new Map<string, number>()
232
+ private readonly running = new Set<string>()
233
+ private readonly infoTried = new Map<string, number>()
234
+ /** Sessions in conversation mode → the agent to restore when it ends (undefined: the default agent). */
235
+ private readonly conversations = new Map<string, { previous?: string; previousModel?: ModelRef; voiceModel?: ModelRef }>()
236
+ /** Per-session selections supplied by the hub; the hub persists them per user. */
237
+ private readonly modelPrefs = new Map<string, SessionModelPrefs>()
238
+ /** Per session with a Voice override applied: the model to restore when it is cleared, and the override. */
239
+ private readonly voiceOverrides = new Map<string, { readonly previous?: ModelRef; readonly applied: ModelRef }>()
240
+ private conversationChain: Promise<unknown> = Promise.resolve()
241
+ private readonly abort = new AbortController()
242
+ private snapshotTimer?: ReturnType<typeof setTimeout>
243
+ private seedTimer?: ReturnType<typeof setTimeout>
244
+ private intervals: Array<ReturnType<typeof setInterval>> = []
245
+ private enrollment?: { state: EnrollmentState; abort: AbortController }
246
+ private started = false
247
+ /** Sessions shown by OpenCode TUIs (voice.presence). */
248
+ private readonly presenceBook = new PresenceBook()
249
+ /** Form replies waiting for a TUI to carry them out (when the plugin cannot reach its own server). */
250
+ private readonly relay = {
251
+ actions: [] as FormAction[],
252
+ polls: new Set<() => void>(),
253
+ lastPoll: -Infinity,
254
+ /** formID → resolves when OpenCode reports the form replied or cancelled. */
255
+ settled: new Map<string, () => void>(),
256
+ }
257
+ /** Herdr reader (service process only, while config.herdr is on). */
258
+ private herdr?: HerdrWatcher
259
+ /** Open sessions as last reported, to notice changes. */
260
+ private openKey = ""
261
+ /** The last terminals frame sent on the current connection. */
262
+ private terminalsKey = ""
263
+ /** Local hub auto-start: one attempt at a time, spaced by a cooldown. */
264
+ private readonly hub: {
265
+ pending: boolean
266
+ timer?: ReturnType<typeof setTimeout>
267
+ nextAt: number
268
+ cooldown: number
269
+ message?: string
270
+ /** A hub was just started while a connection attempt was in flight: retry as soon as that attempt fails. */
271
+ retryOnError?: boolean
272
+ }
273
+
274
+ constructor(
275
+ private readonly host: SpokeHost,
276
+ private readonly options: SpokeOptions,
277
+ ) {
278
+ this.config = readConfig(options.configFile, options.machineName)
279
+ this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
280
+ }
281
+
282
+ start(): void {
283
+ if (this.started) return
284
+ this.started = true
285
+ try {
286
+ this.watcher = watchConfig(this.options.configFile, this.options.machineName, (c) => this.apply(c), { pollMs: this.options.pollMs })
287
+ this.config = this.watcher.current()
288
+ } catch (cause) {
289
+ console.error("[hub] config watch failed", cause)
290
+ }
291
+ void this.listen()
292
+ this.apply(this.config)
293
+ this.every(this.options.snapshotIntervalMs ?? 30_000, () => this.sendSnapshot())
294
+ this.every(this.options.presenceCheckMs ?? 5000, () => this.checkOpen())
295
+ if (this.options.kind === "service" && this.host.listRecent) {
296
+ void this.seed()
297
+ this.every(this.options.seedIntervalMs ?? 60_000, () => void this.seed())
298
+ }
299
+ }
300
+
301
+ stop(): void {
302
+ this.started = false
303
+ this.abort.abort()
304
+ this.watcher?.stop()
305
+ this.enrollment?.abort.abort()
306
+ clearTimeout(this.hub.timer)
307
+ this.hub.pending = false
308
+ for (const interval of this.intervals) clearInterval(interval)
309
+ this.intervals = []
310
+ clearTimeout(this.snapshotTimer)
311
+ clearTimeout(this.seedTimer)
312
+ this.seedTimer = undefined
313
+ this.herdr?.stop()
314
+ this.herdr = undefined
315
+ this.connection?.stop()
316
+ this.connection = undefined
317
+ this.connectionKey = ""
318
+ }
319
+
320
+ isVoiceTurn(sessionID: string): boolean {
321
+ return (this.voiceTurns.get(sessionID) ?? 0) > 0
322
+ }
323
+
324
+ inConversation(sessionID: string): boolean {
325
+ return this.conversations.has(sessionID)
326
+ }
327
+
328
+ /** The agent the voice orchestrator delegates to (per-machine config). */
329
+ /** The model subagents should work on for a session in conversation mode, as "provider/model[#variant]". */
330
+ workModelFor(sessionID: string): string | undefined {
331
+ const selected = this.modelPrefs.get(sessionID)?.worker
332
+ if (selected) return formatModelRef({ providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) })
333
+ const entry = this.conversations.get(sessionID)
334
+ if (!entry?.voiceModel) return undefined
335
+ return this.config.workModel ?? (entry.previousModel ? formatModelRef(entry.previousModel) : undefined)
336
+ }
337
+
338
+ subagent(): string {
339
+ return this.config.subagent
340
+ }
341
+
342
+ /**
343
+ * TUI presence (RPC voice.presence): the sessions a TUI shows right now (its route and open tabs).
344
+ * `reporter` identifies the TUI; its report replaces its previous one.
345
+ */
346
+ presence(sessionIDs: readonly string[], reporter?: string): void {
347
+ this.presenceBook.report(sessionIDs, Date.now(), reporter)
348
+ this.checkOpen()
349
+ }
350
+
351
+ /** Where a session is open right now: a Herdr pane wins over a TUI. */
352
+ private terminal(sessionID: string, tui = this.presenceBook.open(Date.now())): TerminalInfo | undefined {
353
+ return this.herdr?.open().get(sessionID) ?? (tui.has(sessionID) ? { source: "tui" } : undefined)
354
+ }
355
+
356
+ /** Open sessions → where. */
357
+ private openSessions(): Map<string, TerminalInfo> {
358
+ const out = new Map(this.herdr?.open() ?? [])
359
+ for (const id of this.presenceBook.open(Date.now())) if (!out.has(id)) out.set(id, { source: "tui" })
360
+ return out
361
+ }
362
+
363
+ /** Re-reports sessions when what is open changed; brings in open sessions this process does not know yet. */
364
+ private checkOpen(): void {
365
+ const open = this.openSessions()
366
+ const key = JSON.stringify([...open])
367
+ if (key === this.openKey) return
368
+ this.openKey = key
369
+ const unknown = [...open.keys()].filter((id) => !this.tracker.has(id))
370
+ if (unknown.length && this.options.kind === "service" && this.host.listRecent) this.scheduleSeed()
371
+ else if (unknown.length) {
372
+ for (const id of unknown) this.tracker.ensure(id, Date.now())
373
+ void this.fetchInfo()
374
+ }
375
+ this.scheduleSnapshot()
376
+ }
377
+
378
+ /** Sessions the listing must keep regardless of its window. */
379
+ private keep(): Set<string> {
380
+ return new Set([
381
+ ...this.openSessions().keys(),
382
+ ...this.tracker.active(),
383
+ ...this.conversations.keys(),
384
+ ...(this.config.mode === "session" ? this.config.sessions : []),
385
+ ])
386
+ }
387
+
388
+ /** Starts or stops the Herdr reader to match the config (service process with a runner only). */
389
+ private applyHerdr(config: VoiceConfig): void {
390
+ const run = this.options.herdr
391
+ const wanted = this.started && this.options.kind === "service" && run !== undefined && config.herdr
392
+ if (wanted && !this.herdr) {
393
+ this.herdr = new HerdrWatcher({
394
+ run: run!,
395
+ agentsMs: this.options.herdrPollMs?.agents,
396
+ workspacesMs: this.options.herdrPollMs?.workspaces,
397
+ onChange: () => {
398
+ this.checkOpen()
399
+ this.sendTerminals()
400
+ },
401
+ })
402
+ this.herdr.start()
403
+ } else if (!wanted && this.herdr) {
404
+ this.herdr.stop()
405
+ this.herdr = undefined
406
+ this.checkOpen()
407
+ this.sendTerminals()
408
+ }
409
+ }
410
+
411
+ /** The machine's terminal context (service process only); unchanged frames are not re-sent. */
412
+ private sendTerminals(force = false): void {
413
+ if (this.options.kind !== "service" || !this.options.herdr) return
414
+ const workspaces = this.herdr?.workspaces()
415
+ const frame = { t: "terminals" as const, ...(workspaces ? { herdr: { workspaces: limitWorkspaces(workspaces) } } : {}) }
416
+ const key = JSON.stringify(frame)
417
+ if (!force && key === this.terminalsKey) return
418
+ if (this.connection?.state !== "connected") return
419
+ this.terminalsKey = key
420
+ this.connection.send(frame)
421
+ }
422
+
423
+ /**
424
+ * Turns conversation mode on or off for a session in this process. On: switch to the "voice"
425
+ * agent (remembering the previous one) or, without that agent, let the context hook add the
426
+ * orchestrator prompt. Off: restore the previous agent unless the user picked another meanwhile.
427
+ * Calls are serialized so a re-sent "on" cannot race an "off".
428
+ */
429
+ setConversation(sessionID: string, on: boolean): Promise<ResultFor["conversation"]> {
430
+ if (!isExposed(this.config, this.tracker, sessionID)) return Promise.reject(new SpokeError("invalid", NOT_SHARED))
431
+ const run = () => (on ? this.conversationOn(sessionID) : this.conversationOff(sessionID))
432
+ const result = this.conversationChain.then(run, run)
433
+ this.conversationChain = result.catch(() => undefined)
434
+ return result
435
+ }
436
+
437
+ // ---------- RPC-facing API ----------
438
+
439
+ /**
440
+ * RPC hub.formActions (TUI): form replies to carry out through the TUI's client, waiting up to `waitMs`
441
+ * for one. While a TUI polls, the spoke relays form replies it cannot make itself.
442
+ */
443
+ formActions(waitMs: number): Promise<{ actions: FormAction[] }> {
444
+ this.relay.lastPoll = Date.now()
445
+ const take = () => ({ actions: this.relay.actions.splice(0) })
446
+ if (this.relay.actions.length || waitMs <= 0) return Promise.resolve(take())
447
+ return new Promise((resolve) => {
448
+ const done = () => {
449
+ clearTimeout(timer)
450
+ this.relay.polls.delete(done)
451
+ this.relay.lastPoll = Date.now()
452
+ resolve(take())
453
+ }
454
+ const timer = setTimeout(done, waitMs)
455
+ this.relay.polls.add(done)
456
+ })
457
+ }
458
+
459
+ status(sessionID?: string): SpokeStatus {
460
+ const connection = this.connection?.state ?? "off"
461
+ const lastError = this.connection?.lastError
462
+ return {
463
+ server: this.config.server,
464
+ mode: this.config.mode,
465
+ enrolled: Boolean(this.config.token),
466
+ connection,
467
+ ...(lastError && connection !== "connected" ? { lastError } : {}),
468
+ ...(sessionID ? { sessionExposed: isExposed(this.config, this.tracker, sessionID), conversation: this.inConversation(sessionID) } : {}),
469
+ ...(this.enrollment ? { enrollment: this.enrollment.state } : {}),
470
+ machineName: this.config.machineName,
471
+ kind: this.options.kind,
472
+ ...(this.hub.message && connection !== "connected" ? { localHub: this.hub.message } : {}),
473
+ }
474
+ }
475
+
476
+ setServer(server: string): void {
477
+ const trimmed = server.trim().replace(/\/+$/, "")
478
+ if (!/^https?:\/\/[^\s/]+[^\s]*$/.test(trimmed)) throw new SpokeError("invalid", "Server must be an http(s) URL")
479
+ this.write(withServer(this.current(), trimmed))
480
+ }
481
+
482
+ setMode(mode: Mode): void {
483
+ if (!MODES.includes(mode)) throw new SpokeError("invalid", `Mode must be one of ${MODES.join(", ")}`)
484
+ this.write({ ...this.current(), mode })
485
+ }
486
+
487
+ setSession(sessionID: string, on: boolean): void {
488
+ if (!sessionID.startsWith("ses")) throw new SpokeError("invalid", "Not a session ID")
489
+ this.write(setSession(this.current(), sessionID, on))
490
+ }
491
+
492
+ /** Starts device enrollment; polling continues in the background and stores the token on approval. */
493
+ async enroll(): Promise<EnrollResult> {
494
+ const { server, machineName, autoStartLocalHub } = this.current()
495
+ this.enrollment?.abort.abort()
496
+ const abort = new AbortController()
497
+ const unreachable = (cause: unknown) => new SpokeError("unreachable", `Could not reach ${server}: ${errorText(cause)}`)
498
+ let start
499
+ let localHub: EnrollResult["localHub"]
500
+ try {
501
+ start = await startEnrollment(server, machineName, { signal: abort.signal })
502
+ } catch (cause) {
503
+ // Nothing answers on a local server: start the hub (user-initiated, so any process may), then retry.
504
+ const starter = this.options.localHub
505
+ if (!starter || !autoStartLocalHub || !isLoopbackURL(server)) throw unreachable(cause)
506
+ const result = await starter(server)
507
+ if (result.status === "failed") throw new SpokeError("unreachable", `Could not start a local hub for ${server}: ${result.error}`)
508
+ if (result.status === "skipped") throw unreachable(cause)
509
+ localHub = result.status
510
+ start = await this.retryEnrollment(server, machineName, abort.signal, result.status === "busy" ? 30 : 4).catch((again) => {
511
+ throw unreachable(again)
512
+ })
513
+ }
514
+ const enrollment = { state: "pending" as EnrollmentState, abort }
515
+ this.enrollment = enrollment
516
+ void pollEnrollment(server, start, { signal: abort.signal })
517
+ .then((outcome) => {
518
+ if (this.enrollment !== enrollment || outcome.status === "cancelled") return
519
+ if (outcome.status !== "approved") {
520
+ enrollment.state = outcome.status
521
+ return
522
+ }
523
+ const latest = this.current()
524
+ if (latest.server !== server) return
525
+ this.write({ ...latest, token: outcome.machineToken, machineID: outcome.machineID })
526
+ enrollment.state = "approved"
527
+ })
528
+ .catch((cause) => {
529
+ enrollment.state = "error"
530
+ console.error("[hub] enrollment failed", cause)
531
+ })
532
+ return {
533
+ userCode: start.userCode,
534
+ approveURL: start.approveURL,
535
+ ...(start.autoApproved === true ? { autoApproved: true } : {}),
536
+ ...(localHub ? { localHub } : {}),
537
+ }
538
+ }
539
+
540
+ private async retryEnrollment(server: string, machineName: string, signal: AbortSignal, attempts: number) {
541
+ for (let attempt = 1; ; attempt++) {
542
+ try {
543
+ return await startEnrollment(server, machineName, { signal })
544
+ } catch (cause) {
545
+ if (attempt >= attempts || signal.aborted) throw cause
546
+ await new Promise((resolve) => setTimeout(resolve, ENROLL_RETRY_MS))
547
+ }
548
+ }
549
+ }
550
+
551
+ pairCode(): Promise<ResultFor["pairCode"]> {
552
+ return this.ask({ op: "pairCode" })
553
+ }
554
+
555
+ handoff(sessionID: string): Promise<ResultFor["handoff"]> {
556
+ return this.ask({ op: "handoff", sessionID })
557
+ }
558
+
559
+ /** A one-time sign-in link to the session's page, or to the home page without a session. */
560
+ openLink(sessionID?: string, voice = false): Promise<ResultFor["openLink"]> {
561
+ return this.ask(sessionID ? { op: "openLink", sessionID, ...(voice ? { voice: true } : {}) } : { op: "openLink" })
562
+ }
563
+
564
+ /**
565
+ * Starts the local hub for the configured loopback server now (the user asked: "Open hub"), even
566
+ * with autoStartLocalHub off, and reconnects at once when it answers.
567
+ */
568
+ async startLocalHub(): Promise<{ status: "started" | "running" | "busy" }> {
569
+ const { server } = this.current()
570
+ const starter = this.options.localHub
571
+ if (!isLoopbackURL(server)) throw new SpokeError("invalid", `${server} is not a local hub`)
572
+ if (!starter) throw new SpokeError("invalid", "This OpenCode process cannot start a local hub")
573
+ let result: LocalHubResult
574
+ try {
575
+ result = await starter(server)
576
+ } catch (cause) {
577
+ result = { status: "failed", error: errorText(cause) }
578
+ }
579
+ if (result.status === "failed") {
580
+ this.hub.message = result.error
581
+ throw new SpokeError("unreachable", `Could not start a local hub for ${server}: ${result.error}`)
582
+ }
583
+ if (result.status === "skipped") throw new SpokeError("invalid", `Cannot start a local hub for ${server}: ${result.reason}`)
584
+ this.hub.message = undefined
585
+ const connection = this.connection
586
+ if (connection && connection.state !== "connected" && (result.status === "started" || result.status === "running")) {
587
+ this.hub.retryOnError = true // retryNow() is a no-op while an attempt is in flight
588
+ connection.retryNow()
589
+ }
590
+ return { status: result.status }
591
+ }
592
+
593
+ // ---------- internals ----------
594
+
595
+ private async ask<R extends SpokeRequest>(req: R): Promise<ResultFor[R["op"]]> {
596
+ const connection = this.connection
597
+ if (!connection || connection.state !== "connected") {
598
+ const hint = !this.config.token
599
+ ? "This machine is not connected to a hub yet"
600
+ : this.config.mode === "off"
601
+ ? "Voice is off for this machine"
602
+ : `Not connected to ${this.config.server}`
603
+ throw new SpokeError("not_connected", hint)
604
+ }
605
+ return connection.request(req)
606
+ }
607
+
608
+ private current(): VoiceConfig {
609
+ return this.watcher?.refresh() ?? readConfig(this.options.configFile, this.options.machineName)
610
+ }
611
+
612
+ private write(config: VoiceConfig): void {
613
+ writeConfig(this.options.configFile, config)
614
+ if (this.watcher) this.watcher.refresh()
615
+ else this.apply(readConfig(this.options.configFile, this.options.machineName))
616
+ }
617
+
618
+ private apply(config: VoiceConfig): void {
619
+ const previous = this.config
620
+ this.config = config
621
+ if (!this.started) return
622
+ const active = config.mode !== "off" && Boolean(config.token)
623
+ const key = active ? JSON.stringify([config.server, config.token, config.mode]) : ""
624
+ if (key !== this.connectionKey) {
625
+ this.connection?.stop()
626
+ this.connection = undefined
627
+ this.connectionKey = key
628
+ if (active) this.connect(config)
629
+ }
630
+ if (previous.sessions.join() !== config.sessions.join()) this.scheduleSnapshot()
631
+ this.applyHerdr(config)
632
+ if (previous.list.days !== config.list.days || previous.list.max !== config.list.max) this.scheduleSeed()
633
+ }
634
+
635
+ private connect(config: VoiceConfig): void {
636
+ const connection = new SpokeConnection({
637
+ server: config.server,
638
+ token: config.token!,
639
+ backoff: this.options.backoff,
640
+ hello: () => ({
641
+ processID: this.options.processID,
642
+ kind: this.options.kind,
643
+ directory: this.host.directory,
644
+ pid: this.options.pid,
645
+ mode: this.config.mode === "session" ? "session" : "always",
646
+ opencodeVersion: this.host.opencodeVersion,
647
+ pluginVersion: this.options.pluginVersion,
648
+ }),
649
+ handle: (req) => this.handle(req),
650
+ onWelcome: () => {
651
+ this.sendSnapshot()
652
+ this.sendTerminals(true)
653
+ },
654
+ onState: (state, error) => {
655
+ if (state === "revoked") console.error("[hub] this machine was revoked by the hub")
656
+ else if (state === "error" && error) console.error(`[hub] ${error}; retrying`)
657
+ if (state === "error" && this.hub.retryOnError) {
658
+ this.hub.retryOnError = false
659
+ setTimeout(() => this.connection?.retryNow(), 0)
660
+ } else if (state === "error") this.maybeStartLocalHub(config.server)
661
+ if (state === "connected") {
662
+ this.hub.message = undefined
663
+ this.hub.retryOnError = false
664
+ this.hub.cooldown = this.options.localHubRetryMs ?? 30_000
665
+ }
666
+ },
667
+ })
668
+ this.connection = connection
669
+ connection.start()
670
+ }
671
+
672
+ /** After a failed connection to a loopback server: maybe start the local hub (see SpokeOptions.localHub). */
673
+ private maybeStartLocalHub(server: string): void {
674
+ const starter = this.options.localHub
675
+ if (!starter || !this.config.autoStartLocalHub || !isLoopbackURL(server)) return
676
+ if (this.hub.pending || Date.now() < this.hub.nextAt) return
677
+ this.hub.pending = true
678
+ const delay = this.options.kind === "service" ? 0 : (this.options.localHubDelayMs ?? 8000)
679
+ this.hub.timer = setTimeout(() => void this.autoStartHub(server, starter), delay)
680
+ }
681
+
682
+ private async autoStartHub(server: string, starter: (server: string) => Promise<LocalHubResult>): Promise<void> {
683
+ try {
684
+ const connection = this.connection
685
+ if (!this.started || !connection || connection.state === "connected" || this.config.server !== server) return
686
+ let result: LocalHubResult
687
+ try {
688
+ result = await starter(server)
689
+ } catch (cause) {
690
+ result = { status: "failed", error: errorText(cause) }
691
+ }
692
+ // Whatever happened, the next attempt waits for the cooldown, so a hub that answers but refuses
693
+ // this machine (or keeps crashing) is not restarted in a loop. The connection stays in its error
694
+ // state meanwhile (no new state change), so check again once the cooldown is over.
695
+ const wait = this.hub.cooldown
696
+ this.hub.nextAt = Date.now() + wait
697
+ this.hub.timer = setTimeout(() => {
698
+ // The cooldown is over when this fires. Timers can fire slightly before Date.now() reaches
699
+ // nextAt (seen on Linux); re-checking the clock would drop the retry for good, because the
700
+ // connection reports no new state change while it stays in error.
701
+ this.hub.nextAt = 0
702
+ if (this.connection && this.connection.state !== "connected") this.maybeStartLocalHub(server)
703
+ }, wait)
704
+ ;(this.hub.timer as { unref?: () => void }).unref?.()
705
+ if (result.status === "failed") {
706
+ this.hub.message = result.error
707
+ this.hub.cooldown = Math.min(this.hub.cooldown * 2, LOCAL_HUB_MAX_RETRY_MS)
708
+ console.error(`[hub] could not start a local hub for ${server}: ${result.error}`)
709
+ return
710
+ }
711
+ this.hub.message = undefined
712
+ if (result.status === "started") console.log(`[hub] started a local hub for ${server} (pid ${result.pid})`)
713
+ if (result.status === "started" || result.status === "running") this.connection?.retryNow()
714
+ } finally {
715
+ this.hub.pending = false
716
+ }
717
+ }
718
+
719
+ private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
720
+ const entry = this.conversations.get(sessionID)
721
+ const session = await this.host.getSession(sessionID)
722
+ const current = session.agent
723
+ const models = await this.voiceModelOn(sessionID, session, entry)
724
+ if (!(await this.host.hasAgent(VOICE_AGENT))) {
725
+ this.conversations.set(sessionID, { ...entry, ...models })
726
+ return { agent: current ?? (await this.defaultAgent()) }
727
+ }
728
+ const previous = entry ? entry.previous : current === VOICE_AGENT ? undefined : current
729
+ this.conversations.set(sessionID, { ...(previous ? { previous } : {}), ...models })
730
+ if (current !== VOICE_AGENT) await this.host.switchAgent(sessionID, VOICE_AGENT)
731
+ return { agent: VOICE_AGENT }
732
+ }
733
+
734
+ /** Switches to the configured fast model, remembering the session's model for subagent work and for restoring. */
735
+ private async voiceModelOn(
736
+ sessionID: string,
737
+ session: HostSession,
738
+ entry: { previousModel?: ModelRef; voiceModel?: ModelRef } | undefined,
739
+ ): Promise<{ previousModel?: ModelRef; voiceModel?: ModelRef }> {
740
+ const selected = this.modelPrefs.get(sessionID)?.voice
741
+ const voiceModel = selected
742
+ ? { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
743
+ : this.config.voiceModel
744
+ ? parseModelRef(this.config.voiceModel)
745
+ : undefined
746
+ if (!voiceModel) return {}
747
+ const current = session.model
748
+ const onVoice = current !== undefined && sameModel(current, voiceModel)
749
+ const previousModel = entry?.voiceModel ? entry.previousModel : onVoice || !current ? undefined : plainModelRef(current)
750
+ if (!onVoice) await this.host.switchModel(sessionID, voiceModel)
751
+ return { voiceModel, ...(previousModel ? { previousModel } : {}) }
752
+ }
753
+
754
+ private async conversationOff(sessionID: string): Promise<ResultFor["conversation"]> {
755
+ const entry = this.conversations.get(sessionID)
756
+ this.conversations.delete(sessionID)
757
+ const session = await this.host.getSession(sessionID)
758
+ // Restore the model only if the session is still on the voice model (the user may have picked another).
759
+ if (entry?.voiceModel && entry.previousModel && session.model && sameModel(session.model, entry.voiceModel)) {
760
+ await this.host.switchModel(sessionID, entry.previousModel)
761
+ }
762
+ const current = session.agent
763
+ if (current !== VOICE_AGENT) return { agent: current ?? (await this.defaultAgent()) }
764
+ let target = entry?.previous ?? (await this.defaultAgent())
765
+ if (target === VOICE_AGENT) target = FALLBACK_AGENT
766
+ await this.host.switchAgent(sessionID, target)
767
+ return { agent: target }
768
+ }
769
+
770
+ private async defaultAgent(): Promise<string> {
771
+ try {
772
+ return (await this.host.defaultAgent()) ?? FALLBACK_AGENT
773
+ } catch {
774
+ return FALLBACK_AGENT
775
+ }
776
+ }
777
+
778
+ private async handle(req: ServerRequest): Promise<unknown> {
779
+ // Session browser ops are not gated by exposure: they are how a session gets exposed.
780
+ if (req.op === "expose") return this.expose(req.sessionID, req.on)
781
+ if (req.op === "create") return this.create(req.directory, req.title, req.herdrWorkspace)
782
+ // Inspection and control need a session this process knows; Voice (conversation) is gated separately.
783
+ if (!isReported(this.config, this.tracker, req.sessionID) && !(await this.adopt(req.sessionID))) throw new Error(UNKNOWN_SESSION)
784
+ switch (req.op) {
785
+ case "turn":
786
+ return this.turn(req.sessionID, req.text, req.voiceStyle, req.images)
787
+ case "permission": {
788
+ const directory = this.tracker.directoryOf(req.sessionID)
789
+ const outcome = await this.host.replyPermission({ sessionID: req.sessionID, requestID: req.requestID, decision: req.decision, ...(directory ? { directory } : {}) })
790
+ return outcome === "gone" ? { gone: true } : {}
791
+ }
792
+ case "form":
793
+ return this.form("cancel" in req ? { sessionID: req.sessionID, formID: req.formID, cancel: true } : { sessionID: req.sessionID, formID: req.formID, answer: req.answer })
794
+ case "context":
795
+ return { messages: contextMessages(await this.host.context(req.sessionID)) }
796
+ case "models": {
797
+ const raw = await this.host.listModels?.() ?? []
798
+ return {
799
+ models: raw.flatMap((value: any) => {
800
+ // `id` is OpenCode's catalog key (what switchModel takes); `modelID` is the id sent upstream, which
801
+ // differs for aliased models (a gateway's "claude-opus-5-5" → "claude/claude-opus-5-5").
802
+ const id = typeof value?.id === "string" && value.id ? value.id : value?.modelID
803
+ if (!value || typeof value.providerID !== "string" || typeof id !== "string") return []
804
+ return [{
805
+ providerID: value.providerID,
806
+ modelID: id,
807
+ name: typeof value.name === "string" ? value.name : id,
808
+ enabled: value.enabled !== false,
809
+ status: typeof value.status === "string" ? value.status : "active",
810
+ variants: Array.isArray(value.variants) ? value.variants.flatMap((v: any) => typeof v?.id === "string" ? [v.id] : []) : [],
811
+ }]
812
+ }),
813
+ } satisfies ResultFor["models"]
814
+ }
815
+ case "modelPrefs":
816
+ return this.modelPrefsResult(req.sessionID)
817
+ case "setModelPrefs": {
818
+ const next: { voice?: any; worker?: any } = { ...this.modelPrefs.get(req.sessionID) }
819
+ for (const key of ["voice", "worker"] as const) {
820
+ if (!(key in req)) continue
821
+ if (req[key] === null) delete next[key]
822
+ else if (req[key] !== undefined) next[key] = req[key]
823
+ }
824
+ if (next.voice || next.worker) this.modelPrefs.set(req.sessionID, next)
825
+ else this.modelPrefs.delete(req.sessionID)
826
+ if (!next.voice && this.voiceOverrides.has(req.sessionID)) await this.applyVoiceModel(req.sessionID)
827
+ return this.modelPrefsResult(req.sessionID)
828
+ }
829
+ case "timeline": {
830
+ const items = timelineItems(req.sessionID, await this.host.context(req.sessionID))
831
+ const offset = req.cursor === undefined ? 0 : Number(req.cursor)
832
+ if (!Number.isInteger(offset) || offset < 0) throw new Error("Invalid timeline cursor")
833
+ const page = items.slice(offset, offset + (req.limit ?? 50))
834
+ const next = offset + page.length
835
+ return { items: page, ...(next < items.length ? { cursor: String(next) } : {}) }
836
+ }
837
+ case "timelinePart":
838
+ return { detail: boundedTimelinePart(await this.host.context(req.sessionID), req.messageID, req.partID) }
839
+ case "timelineFile":
840
+ return timelineFile(await this.host.context(req.sessionID), req.messageID, req.index) satisfies ResultFor["timelineFile"]
841
+ case "steer": {
842
+ const voice = isExposed(this.config, this.tracker, req.sessionID)
843
+ if (voice) await this.applyVoiceModel(req.sessionID)
844
+ const inbox = await this.host.prompt({
845
+ sessionID: req.sessionID,
846
+ text: req.text,
847
+ ...promptFiles(req.images),
848
+ delivery: "steer",
849
+ metadata: { source: PROMPT_SOURCE, mode: voice ? "conversation" : "remote" },
850
+ })
851
+ return { inboxID: inbox.id } satisfies ResultFor["steer"]
852
+ }
853
+ case "interrupt":
854
+ await this.host.interrupt(req.sessionID)
855
+ return {}
856
+ case "stop":
857
+ return this.stopTree(req.sessionID)
858
+ case "summary":
859
+ return this.summary(req.sessionID)
860
+ case "diff": {
861
+ const diffs = await this.host.vcsDiff?.(this.directoryOf(req.sessionID))
862
+ const patch = diffs && filePatch(diffs, req.file)
863
+ return patch ? { patch } : {}
864
+ }
865
+ case "conversation":
866
+ return this.setConversation(req.sessionID, req.on)
867
+ }
868
+ }
869
+
870
+ /** Answers a pending form through OpenCode, or through a polling TUI when this process cannot. */
871
+ private async form(action: FormAction): Promise<ResultFor["form"]> {
872
+ if (!this.tracker.formPending(action.formID)) return { gone: true }
873
+ const outcome = this.host.replyForm ? await this.host.replyForm(action) : "unavailable"
874
+ if (outcome === "gone") return { gone: true }
875
+ if (outcome !== "unavailable") return {}
876
+ const relaying = this.relay.polls.size > 0 || Date.now() - this.relay.lastPoll <= RELAY_WINDOW_MS
877
+ if (!relaying) throw new Error(ANSWER_IN_TERMINAL)
878
+ await new Promise<void>((resolve, reject) => {
879
+ const timer = setTimeout(() => {
880
+ this.relay.settled.delete(action.formID)
881
+ reject(new Error(`No terminal took the answer in time. ${ANSWER_IN_TERMINAL}`))
882
+ }, RELAY_TIMEOUT_MS)
883
+ this.relay.settled.set(action.formID, () => {
884
+ clearTimeout(timer)
885
+ resolve()
886
+ })
887
+ this.relay.actions.push(action)
888
+ for (const poll of [...this.relay.polls]) poll()
889
+ })
890
+ return {}
891
+ }
892
+
893
+ /**
894
+ * A subagent session this process has not seen yet (e.g. created before it started): known once OpenCode
895
+ * says it descends from a session this process reports. Adds the chain to the tracker.
896
+ */
897
+ private async adopt(sessionID: string): Promise<boolean> {
898
+ if (this.config.mode === "off") return false
899
+ const chain: HostSession[] = []
900
+ let current = sessionID
901
+ for (let depth = 0; depth < 8; depth++) {
902
+ let session: HostSession
903
+ try {
904
+ session = await this.host.getSession(current)
905
+ } catch {
906
+ return false
907
+ }
908
+ chain.push(session)
909
+ const parentID = session.parentID
910
+ if (!parentID || chain.some((s) => s.id === parentID)) return false
911
+ if (!isReported(this.config, this.tracker, parentID)) {
912
+ current = parentID
913
+ continue
914
+ }
915
+ const now = Date.now()
916
+ for (const s of chain) {
917
+ this.tracker.ensure(s.id, now)
918
+ this.tracker.setInfo(s.id, {
919
+ title: s.title ?? "",
920
+ directory: s.location?.directory ?? "",
921
+ ...(s.parentID ? { parentID: s.parentID } : {}),
922
+ updated: s.time?.updated ?? now,
923
+ })
924
+ }
925
+ this.scheduleSnapshot()
926
+ return true
927
+ }
928
+ return false
929
+ }
930
+
931
+ private modelDefaults(): SessionModelPrefs {
932
+ const convert = (value: string | undefined) => {
933
+ const ref = value ? parseModelRef(value) : undefined
934
+ return ref && { providerID: ref.providerID, modelID: ref.id, ...(ref.variant ? { variant: ref.variant } : {}) }
935
+ }
936
+ return { ...(convert(this.config.voiceModel) ? { voice: convert(this.config.voiceModel)! } : {}), ...(convert(this.config.workModel) ? { worker: convert(this.config.workModel)! } : {}) }
937
+ }
938
+
939
+ private modelPrefsResult(sessionID: string): ResultFor["modelPrefs"] {
940
+ return { prefs: structuredClone(this.modelPrefs.get(sessionID) ?? {}), defaults: this.modelDefaults() }
941
+ }
942
+
943
+ private directoryOf(sessionID: string): string {
944
+ return this.tracker.directoryOf(sessionID) || this.host.directory
945
+ }
946
+
947
+ /** Right-rail data; each part is read independently and left out when it fails or is unsupported. */
948
+ private async summary(sessionID: string): Promise<ResultFor["summary"]> {
949
+ const directory = this.directoryOf(sessionID)
950
+ const optional = async <T>(read: (() => Promise<T | undefined>) | undefined): Promise<T | undefined> => {
951
+ if (!read) return undefined
952
+ try {
953
+ return await read()
954
+ } catch {
955
+ return undefined
956
+ }
957
+ }
958
+ const host = this.host
959
+ const [messages, models, mcp, diffs, branch] = await Promise.all([
960
+ optional(() => host.context(sessionID)),
961
+ optional(host.listModels && (() => host.listModels!())),
962
+ optional(host.mcpList && (() => host.mcpList!(directory))),
963
+ optional(host.vcsDiff && (() => host.vcsDiff!(directory))),
964
+ optional(host.vcsBranch && (() => host.vcsBranch!(directory))),
965
+ ])
966
+ const context = messages && contextUsage(messages, models ?? [])
967
+ return {
968
+ directory,
969
+ ...(branch ? { branch } : {}),
970
+ ...(context ? { context } : {}),
971
+ ...(mcp ? { mcp: mcpServers(mcp) } : {}),
972
+ ...(diffs ? { changes: changeStats(diffs) } : {}),
973
+ }
974
+ }
975
+
976
+ /**
977
+ * Stops a whole session tree: running descendants deepest first, then the root (always, so a pending
978
+ * retry or queued run is cancelled too). A descendant that already settled is not an error.
979
+ */
980
+ private async stopTree(sessionID: string): Promise<ResultFor["stop"]> {
981
+ const root = this.tracker.rootOf(sessionID)
982
+ const order = [...this.tracker.runningBelow(root), root]
983
+ for (const id of order) {
984
+ try {
985
+ await this.host.interrupt(id)
986
+ } catch (cause) {
987
+ if (id === root) throw cause
988
+ }
989
+ }
990
+ return { stopped: order }
991
+ }
992
+
993
+ /** Applies a saved Voice override at the boundary before the next turn; never changes a running tree. */
994
+ /**
995
+ * A cleared override switches back to the model the session had before the first override, unless the
996
+ * user picked another model meanwhile.
997
+ */
998
+ private async applyVoiceModel(sessionID: string): Promise<void> {
999
+ const selected = this.modelPrefs.get(sessionID)?.voice
1000
+ const override = this.voiceOverrides.get(sessionID)
1001
+ if ((!selected && !override) || this.tracker.active().includes(this.tracker.rootOf(sessionID))) return
1002
+ const current = (await this.host.getSession(sessionID)).model
1003
+ const onOverride = override !== undefined && current !== undefined && sameModel(current, override.applied)
1004
+ if (!selected) {
1005
+ this.voiceOverrides.delete(sessionID)
1006
+ if (onOverride && override.previous) await this.host.switchModel(sessionID, override.previous)
1007
+ return
1008
+ }
1009
+ const target = { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
1010
+ if (current && sameModel(current, target)) {
1011
+ if (override) this.voiceOverrides.set(sessionID, { ...override, applied: target })
1012
+ return
1013
+ }
1014
+ const previous = onOverride ? override.previous : current ? plainModelRef(current) : undefined
1015
+ this.voiceOverrides.set(sessionID, { ...(previous ? { previous } : {}), applied: target })
1016
+ await this.host.switchModel(sessionID, target)
1017
+ }
1018
+
1019
+ /** Enables or disables voice for a session this process reported (mode "session" only). */
1020
+ private expose(sessionID: string, on: boolean): ResultFor["expose"] {
1021
+ if (!this.tracker.has(sessionID)) throw new Error("Unknown session")
1022
+ if (this.config.mode === "session") this.setSession(sessionID, on)
1023
+ this.sendSnapshot()
1024
+ return { exposed: isExposed(this.config, this.tracker, sessionID) }
1025
+ }
1026
+
1027
+ /**
1028
+ * Creates a session in a workspace or session directory this (service) process reported, or a folder
1029
+ * of a Herdr pane. With `herdrWorkspace`, also opens it in a new tab of that Herdr workspace; if that
1030
+ * fails the session still exists and the result carries a warning instead of `openedIn`.
1031
+ */
1032
+ private async create(directory: string, title?: string, herdrWorkspace?: string): Promise<ResultFor["create"] & { warning?: string }> {
1033
+ if (this.options.kind !== "service" || !this.host.createSession) {
1034
+ throw new Error("Only the background service creates sessions")
1035
+ }
1036
+ const target = path.resolve(directory)
1037
+ const workspace =
1038
+ this.workspaces().get(target) ?? (this.herdr?.hasDirectory(target) ? { project: target, projectName: path.basename(target) || target } : undefined)
1039
+ if (!workspace) throw new Error("unknown workspace")
1040
+ const session = await this.host.createSession({ directory: target, ...(title ? { title } : {}) })
1041
+ const now = Date.now()
1042
+ this.tracker.observe({ type: "session.created", data: { sessionID: session.id, title: title ?? "", location: { directory: target } } }, now)
1043
+ this.tracker.setInfo(session.id, { title: title ?? "", directory: target, updated: now, ...workspace })
1044
+ if (this.config.mode === "session") this.setSession(session.id, true)
1045
+ this.sendSnapshot()
1046
+ if (!herdrWorkspace) return { sessionID: session.id }
1047
+ const warning = await this.openInHerdr(herdrWorkspace, target, session.id, title)
1048
+ return warning ? { sessionID: session.id, warning } : { sessionID: session.id, openedIn: "herdr" }
1049
+ }
1050
+
1051
+ /** Opens a session in a new Herdr tab; returns a warning when that did not work. */
1052
+ private async openInHerdr(workspaceID: string, directory: string, sessionID: string, title?: string): Promise<string | undefined> {
1053
+ const herdr = this.herdr
1054
+ const run = this.options.herdr
1055
+ if (!herdr || !run) return "Herdr is not available on this machine; the session was created but not opened"
1056
+ if (!herdr.hasWorkspace(workspaceID)) return `Herdr workspace ${workspaceID} not found; the session was created but not opened`
1057
+ try {
1058
+ await openInHerdr(run, { workspaceID, directory, sessionID, ...(title ? { title } : {}) }, { settleMs: this.options.herdrSettleMs })
1059
+ void herdr.refresh()
1060
+ return undefined
1061
+ } catch (cause) {
1062
+ console.error(`[hub] could not open ${sessionID} in Herdr`, errorText(cause))
1063
+ return `Could not open the session in Herdr: ${errorText(cause)}`
1064
+ }
1065
+ }
1066
+
1067
+ /** Normalized workspace roots and session directories this process knows → their workspace. */
1068
+ private workspaces(): Map<string, { project: string; projectName: string }> {
1069
+ const out = new Map<string, { project: string; projectName: string }>()
1070
+ for (const s of this.tracker.snapshot(Date.now())) {
1071
+ if (!s.directory.startsWith("/")) continue
1072
+ const project = s.project ?? path.resolve(s.directory)
1073
+ const workspace = { project, projectName: s.projectName ?? path.basename(project) }
1074
+ if (!out.has(project)) out.set(project, workspace)
1075
+ out.set(path.resolve(s.directory), workspace)
1076
+ }
1077
+ return out
1078
+ }
1079
+
1080
+ private async turn(sessionID: string, text: string, voiceStyle: boolean, images?: readonly PromptImage[]): Promise<TurnResult> {
1081
+ if (this.running.has(sessionID)) throw new Error("A turn is already running for this session")
1082
+ if (isExposed(this.config, this.tracker, sessionID)) await this.applyVoiceModel(sessionID)
1083
+ this.running.add(sessionID)
1084
+ if (voiceStyle) this.voiceTurns.set(sessionID, (this.voiceTurns.get(sessionID) ?? 0) + 1)
1085
+ try {
1086
+ const inbox = await this.host.prompt({ sessionID, text, ...promptFiles(images), metadata: { source: PROMPT_SOURCE } })
1087
+ const result = await this.turns.wait(inbox.id, { timeoutMs: this.options.turnTimeoutMs ?? 10 * 60_000 })
1088
+ let reply = ""
1089
+ try {
1090
+ reply = extractReply((await this.host.context(sessionID)) as Parameters<typeof extractReply>[0], inbox.id)
1091
+ } catch {
1092
+ // reply stays empty; the outcome still matters
1093
+ }
1094
+ if (result.outcome === "succeeded" || result.outcome === "timeout") return { outcome: result.outcome, reply }
1095
+ return { outcome: result.outcome, message: result.message, reply }
1096
+ } finally {
1097
+ this.running.delete(sessionID)
1098
+ if (voiceStyle) {
1099
+ const left = (this.voiceTurns.get(sessionID) ?? 1) - 1
1100
+ if (left > 0) this.voiceTurns.set(sessionID, left)
1101
+ else this.voiceTurns.delete(sessionID)
1102
+ }
1103
+ }
1104
+ }
1105
+
1106
+ private async listen(): Promise<void> {
1107
+ while (!this.abort.signal.aborted) {
1108
+ try {
1109
+ for await (const event of this.host.events(this.abort.signal)) this.observe(event)
1110
+ } catch (cause) {
1111
+ if (this.abort.signal.aborted) return
1112
+ console.error("[hub] event stream failed, retrying", cause)
1113
+ }
1114
+ if (this.abort.signal.aborted) return
1115
+ await new Promise((resolve) => setTimeout(resolve, 1000))
1116
+ }
1117
+ }
1118
+
1119
+ private observe(event: EventLike): void {
1120
+ try {
1121
+ this.turns.observe(event)
1122
+ if (event.type === "form.replied" || event.type === "form.cancelled") {
1123
+ const formID = (event.data as Record<string, unknown> | undefined)?.id
1124
+ if (typeof formID === "string") {
1125
+ this.relay.settled.get(formID)?.()
1126
+ this.relay.settled.delete(formID)
1127
+ }
1128
+ }
1129
+ if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
1130
+ if (this.options.kind === "service" && this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
1131
+ this.scheduleSeed()
1132
+ }
1133
+ void this.fetchInfo()
1134
+ if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) {
1135
+ // Activity events go out compact: tool inputs become a short summary, outputs are dropped.
1136
+ const compact = compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> })
1137
+ if (compact) this.connection.send({ t: "event", event: compact })
1138
+ }
1139
+ } catch (cause) {
1140
+ console.error("[hub] event handling failed", cause)
1141
+ }
1142
+ }
1143
+
1144
+ private async fetchInfo(): Promise<void> {
1145
+ const now = Date.now()
1146
+ for (const id of this.tracker.needsInfo()) {
1147
+ const tried = this.infoTried.get(id)
1148
+ if (tried !== undefined && now - tried < INFO_RETRY_MS) continue
1149
+ this.infoTried.set(id, now)
1150
+ try {
1151
+ const session = await this.host.getSession(id)
1152
+ this.tracker.setInfo(id, {
1153
+ title: session.title ?? "",
1154
+ directory: session.location?.directory ?? "",
1155
+ ...(session.parentID ? { parentID: session.parentID } : {}),
1156
+ updated: session.time?.updated ?? now,
1157
+ })
1158
+ this.infoTried.delete(id)
1159
+ this.scheduleSnapshot()
1160
+ } catch {
1161
+ // retried after INFO_RETRY_MS
1162
+ }
1163
+ }
1164
+ }
1165
+
1166
+ private async seed(): Promise<void> {
1167
+ try {
1168
+ const keep = this.keep()
1169
+ const { days, max } = this.config.list
1170
+ const sessions = (await this.host.listRecent?.({ days, max, include: [...keep] })) ?? []
1171
+ if (this.tracker.applyListing(sessions, Date.now(), keep)) this.scheduleSnapshot()
1172
+ } catch (cause) {
1173
+ console.error("[hub] could not list recent sessions", errorText(cause))
1174
+ }
1175
+ }
1176
+
1177
+ private scheduleSeed(): void {
1178
+ if (this.seedTimer || !this.started) return
1179
+ this.seedTimer = setTimeout(() => {
1180
+ this.seedTimer = undefined
1181
+ void this.seed()
1182
+ }, this.options.seedDebounceMs ?? 2000)
1183
+ }
1184
+
1185
+ private scheduleSnapshot(): void {
1186
+ if (this.snapshotTimer) return
1187
+ this.snapshotTimer = setTimeout(() => {
1188
+ this.snapshotTimer = undefined
1189
+ this.sendSnapshot()
1190
+ }, this.options.snapshotDebounceMs ?? 500)
1191
+ }
1192
+
1193
+ private sendSnapshot(): void {
1194
+ const tui = this.presenceBook.open(Date.now())
1195
+ this.connection?.send({ t: "sessions", sessions: browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) }) })
1196
+ }
1197
+
1198
+ private every(ms: number, run: () => void): void {
1199
+ const interval = setInterval(run, ms)
1200
+ ;(interval as { unref?: () => void }).unref?.()
1201
+ this.intervals.push(interval)
1202
+ }
1203
+ }
1204
+
1205
+ /** Keeps the terminals frame within the protocol's limits. */
1206
+ function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[] {
1207
+ return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
1208
+ }
1209
+
1210
+ function sameModel(a: ModelRef, b: ModelRef): boolean {
1211
+ return a.providerID === b.providerID && a.id === b.id && (explicitVariant(a) ?? "") === (explicitVariant(b) ?? "")
1212
+ }