@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
|
@@ -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
|
+
}
|