@huynle/hub 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/Dockerfile +51 -0
  3. package/LICENSE +21 -0
  4. package/README.md +1029 -2
  5. package/bin/hub.ts +20 -0
  6. package/docker/hub-deps.ts +42 -0
  7. package/package.json +85 -4
  8. package/rpc.ts +2 -0
  9. package/scripts/hub-local.sh +28 -0
  10. package/scripts/io.github.huynle.hub.plist +42 -0
  11. package/server.ts +3 -0
  12. package/src/central/assets.ts +74 -0
  13. package/src/central/auth.ts +271 -0
  14. package/src/central/config.ts +310 -0
  15. package/src/central/http.ts +1247 -0
  16. package/src/central/main.ts +136 -0
  17. package/src/central/oidc.ts +295 -0
  18. package/src/central/push-target.ts +97 -0
  19. package/src/central/push.ts +285 -0
  20. package/src/central/registry.ts +1153 -0
  21. package/src/central/speech/models.ts +89 -0
  22. package/src/central/speech/openai.ts +66 -0
  23. package/src/central/speech/openrouter.ts +71 -0
  24. package/src/central/speech/provider.ts +74 -0
  25. package/src/central/speech/service.ts +168 -0
  26. package/src/central/speech/settings.ts +375 -0
  27. package/src/central/spoke-ws.ts +300 -0
  28. package/src/central/store.ts +653 -0
  29. package/src/config.ts +170 -0
  30. package/src/env.ts +63 -0
  31. package/src/index.ts +236 -0
  32. package/src/key.ts +17 -0
  33. package/src/loopback.ts +41 -0
  34. package/src/migrate.ts +105 -0
  35. package/src/openrouter.ts +3 -0
  36. package/src/options.ts +33 -0
  37. package/src/protocol.ts +650 -0
  38. package/src/reply.ts +27 -0
  39. package/src/rpc.ts +98 -0
  40. package/src/setup.ts +124 -0
  41. package/src/speakable.ts +35 -0
  42. package/src/spoke/client.ts +272 -0
  43. package/src/spoke/conversation.ts +93 -0
  44. package/src/spoke/enroll.ts +79 -0
  45. package/src/spoke/herdr.ts +346 -0
  46. package/src/spoke/listing.ts +108 -0
  47. package/src/spoke/localhub.ts +287 -0
  48. package/src/spoke/locations.ts +34 -0
  49. package/src/spoke/own-server.ts +32 -0
  50. package/src/spoke/presence.ts +45 -0
  51. package/src/spoke/singleton.ts +103 -0
  52. package/src/spoke/spoke.ts +1212 -0
  53. package/src/spoke/summary.ts +117 -0
  54. package/src/spoke/timeline.ts +93 -0
  55. package/src/spoke/tracker.ts +345 -0
  56. package/src/spoke/watch.ts +78 -0
  57. package/src/status.ts +232 -0
  58. package/src/tui-commands.ts +53 -0
  59. package/src/tui-forms.ts +77 -0
  60. package/src/tui-presence.ts +75 -0
  61. package/src/tui.ts +479 -0
  62. package/src/turns.ts +94 -0
  63. package/src/voice-command.ts +65 -0
  64. package/tui.ts +2 -0
  65. package/web/dist/apple-touch-icon.png +0 -0
  66. package/web/dist/assets/index-DClBvfLL.css +1 -0
  67. package/web/dist/assets/index-Djo_yF0a.js +12 -0
  68. package/web/dist/favicon.ico +0 -0
  69. package/web/dist/icon-192.png +0 -0
  70. package/web/dist/icon-512.png +0 -0
  71. package/web/dist/icon-maskable-512.png +0 -0
  72. package/web/dist/icon.svg +18 -0
  73. package/web/dist/index.html +25 -0
  74. package/web/dist/manifest.webmanifest +18 -0
  75. package/web/dist/sw.js +151 -0
  76. package/web/dist/vad-worklet.js +27 -0
@@ -0,0 +1,1153 @@
1
+ // Connected spokes and what they report: a catalog of machines → processes → sessions, one live
2
+ // StatusBoard per root session (fed by forwarded events from any process), owner selection for turns,
3
+ // a per-session turn lock, permission routing back to the process that asked, and conversation mode
4
+ // (which sessions speak their replies on the phone).
5
+ import path from "node:path"
6
+ import type { ForwardedEvent, Hello, HerdrWorkspace, ProcessKind, SessionInfo, SpokeMode, TerminalInfo, Terminals } from "../protocol"
7
+ import { StatusBoard, type Blocker, type SessionStatus } from "../status"
8
+
9
+ export class BusyError extends Error {
10
+ constructor() {
11
+ super("A turn is already running for this session")
12
+ this.name = "BusyError"
13
+ }
14
+ }
15
+
16
+ export interface MachineRef {
17
+ readonly id: string
18
+ readonly name: string
19
+ }
20
+
21
+ /** A status tree entry as the hub reports it: working sessions carry when their current run started. */
22
+ export type LiveStatus = SessionStatus & { readonly since?: number }
23
+
24
+ /** Timer functions (injectable for tests). */
25
+ export interface Timers {
26
+ set(fn: () => void, ms: number): unknown
27
+ clear(handle: unknown): void
28
+ }
29
+
30
+ const realTimers: Timers = {
31
+ set: (fn, ms) => {
32
+ const handle = setTimeout(fn, ms)
33
+ ;(handle as { unref?: () => void }).unref?.()
34
+ return handle
35
+ },
36
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
37
+ }
38
+
39
+ /**
40
+ * Streamed text and reasoning are sent to phones at most once per this interval per session: the first
41
+ * delta goes out at once, later ones within the window are joined and sent when it ends.
42
+ */
43
+ export const STREAM_INTERVAL_MS = 100
44
+
45
+ export type RegistryMessage =
46
+ | { readonly type: "catalog" }
47
+ | { readonly type: "status"; readonly sessionID: string; readonly sessions: LiveStatus[] }
48
+ /** Assistant text streamed in a root or subagent session (rootID: its root session). */
49
+ | { readonly type: "delta"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly text: string }
50
+ /** Reasoning text streamed in a root or subagent session. */
51
+ | { readonly type: "reasoning"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly delta: string }
52
+ | { readonly type: "conversation"; readonly sessionID: string; readonly on: boolean }
53
+ /** A finished piece of assistant text in a root session in conversation mode, to be spoken. */
54
+ | { readonly type: "say"; readonly sessionID: string; readonly messageID: string; readonly ordinal: number; readonly text: string }
55
+ /** An utterance sent from the phone reached the agent. */
56
+ | { readonly type: "heard"; readonly sessionID: string; readonly inboxID: string }
57
+ /** An activity item was created or changed state (sessionID: where it happened; rootID: its root). */
58
+ | { readonly type: "activity"; readonly sessionID: string; readonly rootID: string; readonly item: ActivityItem }
59
+
60
+ export type ActivityState = "running" | "done" | "failed"
61
+
62
+ /** One entry of a root session's activity feed: a tool call or a stretch of reasoning. */
63
+ export interface ActivityItem {
64
+ /** Tool call ID, or `thinking:<assistantMessageID>`. */
65
+ readonly id: string
66
+ /** Session the activity happened in (the root or one of its subagent sessions). */
67
+ readonly sessionID: string
68
+ readonly kind: "tool" | "thinking"
69
+ readonly tool?: string
70
+ /** Short summary of the tool input (a command, a path); never the full input. */
71
+ readonly summary?: string
72
+ /** The tool was started in the background (e.g. a background subagent). */
73
+ readonly background?: true
74
+ readonly state: ActivityState
75
+ readonly error?: string
76
+ /** ms epoch of creation, then of the last state change. */
77
+ readonly at: number
78
+ }
79
+
80
+ /** Activity items kept per root session. */
81
+ export const ACTIVITY_LIMIT = 30
82
+ /** Root sessions whose activity is remembered. */
83
+ const ACTIVITY_ROOTS = 200
84
+
85
+ /** A session counts as watched while a device reported it visible within this window. */
86
+ export const PRESENCE_WINDOW_MS = 45_000
87
+ /** Child completion text is useful only while its matching run completion is still nearby. */
88
+ export const WORKER_SPEECH_TTL_MS = 5 * 60_000
89
+
90
+ /** Push-worthy transitions of a root session. Kept off the SSE stream. */
91
+ export interface Alert {
92
+ readonly kind: "blocked" | "finished" | "say"
93
+ /** Root session of the tree the event happened in. */
94
+ readonly sessionID: string
95
+ /** Root session title. */
96
+ readonly title: string
97
+ readonly detail?: "permission" | "form" | "succeeded" | "failed"
98
+ /** say: the spoken text. */
99
+ readonly text?: string
100
+ /** blocked permission: the request ID. */
101
+ readonly requestID?: string
102
+ /** Tenant (user ID) that owns the session; push goes to this user's devices only. */
103
+ readonly owner?: string
104
+ }
105
+
106
+ /** Machine ID → tenant (owning user ID). Every machine belongs to exactly one tenant. */
107
+ export type TenantOf = (machineID: string) => string | undefined
108
+
109
+ /** A pending permission or question somewhere in a session's tree, with where it was asked. */
110
+ export interface TreeBlocker extends Blocker {
111
+ readonly sessionID: string
112
+ readonly sessionTitle: string
113
+ }
114
+
115
+ /** What a session is doing right now: its latest running activity item. */
116
+ export interface NowActivity {
117
+ readonly kind: ActivityItem["kind"]
118
+ readonly tool?: string
119
+ readonly summary?: string
120
+ }
121
+
122
+ /** Session browser fields, computed for a session and the subagent sessions below it. */
123
+ interface BrowserFields {
124
+ /** Voice is enabled for the session. Inspection and control do not depend on it; speech does. */
125
+ readonly exposed: boolean
126
+ /** blocked if anything in the tree waits for the user, else error/working/idle. */
127
+ readonly state: SessionStatus["state"]
128
+ readonly blockers: TreeBlocker[]
129
+ readonly now?: NowActivity
130
+ }
131
+
132
+ export interface CatalogSession extends Omit<SessionInfo, "exposed">, BrowserFields {
133
+ readonly processID: string
134
+ readonly machineID: string
135
+ /** The root session's status tree (root first, then subagent sessions). */
136
+ readonly status: SessionStatus[]
137
+ readonly busy: boolean
138
+ /** Conversation mode is on: assistant text is spoken on the phone. */
139
+ readonly conversation: boolean
140
+ }
141
+
142
+ /** One session as seen by the phone (GET /api/sessions/:id). */
143
+ export interface SessionView extends BrowserFields {
144
+ readonly id: string
145
+ readonly title: string
146
+ readonly directory: string
147
+ readonly parentID?: string
148
+ readonly project?: string
149
+ readonly projectName?: string
150
+ readonly updated: number
151
+ /** Open in a terminal (any reporting process says so); where. */
152
+ readonly open?: boolean
153
+ readonly terminal?: TerminalInfo
154
+ readonly busy: boolean
155
+ readonly conversation: boolean
156
+ /** Status tree of the root session that contains this session. */
157
+ readonly status: SessionStatus[]
158
+ /** Recent activity of the root session and its subagent sessions, newest last. */
159
+ readonly activity: ActivityItem[]
160
+ }
161
+
162
+ export interface CatalogProcess {
163
+ readonly id: string
164
+ readonly kind: ProcessKind
165
+ readonly directory: string
166
+ readonly pid: number
167
+ readonly mode: SpokeMode
168
+ readonly opencodeVersion: string
169
+ readonly pluginVersion: string
170
+ readonly connected: number
171
+ readonly lastSeen: number
172
+ readonly sessions: CatalogSession[]
173
+ }
174
+
175
+ /** A machine's terminal context: its Herdr workspaces (latest terminals frame of the machine). */
176
+ export interface MachineTerminals {
177
+ readonly herdr?: { readonly workspaces: readonly HerdrWorkspace[] }
178
+ }
179
+
180
+ export interface CatalogMachine {
181
+ readonly id: string
182
+ readonly name: string
183
+ readonly processes: CatalogProcess[]
184
+ readonly terminals?: MachineTerminals
185
+ }
186
+
187
+ interface Conn {
188
+ readonly id: string
189
+ readonly machine: MachineRef
190
+ readonly hello: Hello
191
+ readonly connected: number
192
+ readonly seq: number
193
+ lastSeen: number
194
+ sessions: Map<string, SessionInfo>
195
+ /** The last sessions frame as JSON, to skip unchanged snapshots. */
196
+ sessionsKey: string
197
+ /** sessionID → activity sequence number of the last event this process sent for it. */
198
+ activity: Map<string, number>
199
+ }
200
+
201
+ /** Streamed text of one session waiting for its next send window. */
202
+ interface Stream {
203
+ last: number
204
+ timer?: unknown
205
+ pending: Array<{ kind: "delta" | "reasoning"; rootID: string; messageID?: string; text: string }>
206
+ }
207
+
208
+ /** Latest assistant message of a child run, paired with its successful completion for direct speech. */
209
+ interface WorkerSpeech {
210
+ readonly rootID: string
211
+ succeeded: boolean
212
+ messageID?: string
213
+ readonly parts: Map<number, string>
214
+ at: number
215
+ timer?: unknown
216
+ }
217
+
218
+ /** How many spoken texts, utterances and early deliveries are remembered for dedupe and matching. */
219
+ const MEMORY = 1000
220
+
221
+ /** Adds to a bounded insertion-ordered map, dropping the oldest entry when full. */
222
+ function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void {
223
+ map.set(key, value)
224
+ if (map.size > limit) map.delete(map.keys().next().value!)
225
+ }
226
+
227
+ const eventSessionID = (event: ForwardedEvent): string | undefined => {
228
+ const data = event.data as Record<string, any>
229
+ const id = event.type === "form.created" ? data.form?.sessionID : data.sessionID
230
+ return typeof id === "string" ? id : undefined
231
+ }
232
+
233
+ /** Older spokes reported exposed sessions only, without the field. */
234
+ const isOn = (s: SessionInfo) => s.exposed !== false
235
+
236
+ export class Registry {
237
+ private readonly conns = new Map<string, Conn>()
238
+ private readonly boards = new Map<string, StatusBoard>()
239
+ /**
240
+ * permission requestID → its session and every connection that forwarded it, first first. Usually one
241
+ * process holds it, but several may report the session; a reply tries them in order.
242
+ */
243
+ private readonly permissions = new Map<string, { connIDs: string[]; sessionID: string }>()
244
+ /** Sessions with a hub turn in flight, with how to end the waiting request early (stop/interrupt). */
245
+ private readonly active = new Map<string, { readonly cancel?: () => void }>()
246
+ private readonly listeners = new Set<(message: RegistryMessage, tenant?: string) => void>()
247
+ private readonly alertListeners = new Set<(alert: Alert) => void>()
248
+ /** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
249
+ private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
250
+ /** Form IDs already alerted (forms may be forwarded by several processes). */
251
+ private readonly alertedForms = new Set<string>()
252
+ /** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
253
+ private readonly conversations = new Map<string, string | undefined>()
254
+ /** `messageID ordinal` of texts already sent as say events (several processes forward the same text). */
255
+ private readonly spoken = new Map<string, true>()
256
+ /** Child session → latest text and completion state, while its root is in conversation mode. */
257
+ private readonly workerSpeech = new Map<string, WorkerSpeech>()
258
+ /** Utterance inboxID → session, until OpenCode reports it delivered. */
259
+ private readonly utterances = new Map<string, string>()
260
+ /** Deliveries not (yet) matched to an utterance: the steer result can arrive after the event. */
261
+ private readonly deliveries = new Map<string, true>()
262
+ /** Root session → its activity feed, oldest first. */
263
+ private readonly feeds = new Map<string, ActivityItem[]>()
264
+ /** `deviceID sessionID` → last visible report of a session page. */
265
+ private readonly presence = new Map<string, { sessionID: string; at: number }>()
266
+ /** Session → start time of its current run (session.execution.started). */
267
+ private readonly since = new Map<string, number>()
268
+ /** Session → streamed text waiting for its send window. */
269
+ private readonly streams = new Map<string, Stream>()
270
+ /** Machine → its latest terminals frame and the connection that sent it. */
271
+ private readonly machineTerminals = new Map<string, { connID: string; terminals: MachineTerminals; key: string }>()
272
+ private seq = 0
273
+
274
+ constructor(
275
+ private readonly now: () => number = Date.now,
276
+ private readonly timers: Timers = realTimers,
277
+ /** Single tenant unless given (pairing mode, tests). */
278
+ private readonly tenantOf: TenantOf = () => "",
279
+ ) {}
280
+
281
+ /**
282
+ * Listens to registry messages. `tenant` is the user that owns the message's session (undefined for
283
+ * catalog changes, which concern everyone, and for sessions nobody reports).
284
+ */
285
+ subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void {
286
+ this.listeners.add(listener)
287
+ return () => this.listeners.delete(listener)
288
+ }
289
+
290
+ subscribeAlerts(listener: (alert: Alert) => void): () => void {
291
+ this.alertListeners.add(listener)
292
+ return () => this.alertListeners.delete(listener)
293
+ }
294
+
295
+ connections(): number {
296
+ return this.conns.size
297
+ }
298
+
299
+ connectionsFor(machineID: string): string[] {
300
+ return [...this.conns.values()].filter((c) => c.machine.id === machineID).map((c) => c.id)
301
+ }
302
+
303
+ // ---------- tenancy ----------
304
+ // A session belongs to the tenant of the earliest connected process reporting it. Processes of other
305
+ // tenants reporting the same ID are ignored for it (no listing, routing or events), so a spoke cannot
306
+ // claim, read or inject into another user's session by reusing its ID.
307
+
308
+ private tenant(conn: Conn): string {
309
+ return this.tenantOf(conn.machine.id) ?? `?${conn.machine.id}`
310
+ }
311
+
312
+ /** Tenant of a reported session, or of the status board that contains it; undefined when unknown. */
313
+ sessionTenant(sessionID: string): string | undefined {
314
+ let first: Conn | undefined
315
+ for (const conn of this.conns.values()) if (conn.sessions.has(sessionID) && (!first || conn.seq < first.seq)) first = conn
316
+ if (first) return this.tenant(first)
317
+ const rootID = this.rootOf(sessionID)
318
+ return rootID && rootID !== sessionID ? this.sessionTenant(rootID) : undefined
319
+ }
320
+
321
+ /** The session as `conn` reports it, unless another tenant owns that ID. */
322
+ private reports(conn: Conn, sessionID: string): SessionInfo | undefined {
323
+ const info = conn.sessions.get(sessionID)
324
+ return info && this.sessionTenant(sessionID) === this.tenant(conn) ? info : undefined
325
+ }
326
+
327
+ /** Sessions `conn` reports that belong to its tenant. */
328
+ private visible(conn: Conn): SessionInfo[] {
329
+ return [...conn.sessions.values()].filter((s) => this.reports(conn, s.id))
330
+ }
331
+
332
+ /** True when the tenant owns the session (reported by one of its processes, or a subagent of such a session). */
333
+ owns(tenant: string, sessionID: string): boolean {
334
+ return this.sessionTenant(sessionID) === tenant
335
+ }
336
+
337
+ /** Whether the connection's process belongs to the tenant. */
338
+ connTenant(connID: string): string | undefined {
339
+ const conn = this.conns.get(connID)
340
+ return conn && this.tenant(conn)
341
+ }
342
+
343
+ connect(connID: string, machine: MachineRef, hello: Hello): void {
344
+ const t = this.now()
345
+ this.conns.set(connID, {
346
+ id: connID,
347
+ machine,
348
+ hello,
349
+ connected: t,
350
+ seq: ++this.seq,
351
+ lastSeen: t,
352
+ sessions: new Map(),
353
+ sessionsKey: "[]",
354
+ activity: new Map(),
355
+ })
356
+ this.emit({ type: "catalog" })
357
+ }
358
+
359
+ disconnect(connID: string): void {
360
+ const conn = this.conns.get(connID)
361
+ if (!conn || !this.conns.delete(connID)) return
362
+ if (this.machineTerminals.get(conn.machine.id)?.connID === connID) this.machineTerminals.delete(conn.machine.id)
363
+ // Nobody can answer permissions asked by a process that is gone.
364
+ for (const [requestID, origin] of this.permissions) {
365
+ if (!origin.connIDs.includes(connID)) continue
366
+ origin.connIDs = origin.connIDs.filter((id) => id !== connID)
367
+ if (origin.connIDs.length) continue
368
+ this.permissions.delete(requestID)
369
+ this.observeBoards({ type: "permission.replied", data: { sessionID: origin.sessionID, requestID } })
370
+ }
371
+ for (const [formID, origin] of this.forms) {
372
+ if (!origin.connIDs.includes(connID)) continue
373
+ origin.connIDs = origin.connIDs.filter((id) => id !== connID)
374
+ if (origin.connIDs.length) continue
375
+ this.forms.delete(formID)
376
+ this.observeBoards({ type: "form.cancelled", data: { sessionID: origin.sessionID, id: formID } })
377
+ }
378
+ this.syncBoards()
379
+ this.emit({ type: "catalog" })
380
+ }
381
+
382
+ touch(connID: string): void {
383
+ const conn = this.conns.get(connID)
384
+ if (conn) conn.lastSeen = this.now()
385
+ }
386
+
387
+ setSessions(connID: string, sessions: readonly SessionInfo[]): void {
388
+ const conn = this.conns.get(connID)
389
+ if (!conn) return
390
+ conn.lastSeen = this.now()
391
+ // Spokes re-send their snapshot periodically; only changes reach the phones.
392
+ const key = JSON.stringify(sessions)
393
+ if (key === conn.sessionsKey) return
394
+ conn.sessionsKey = key
395
+ conn.sessions = new Map(sessions.map((s) => [s.id, s]))
396
+ this.syncBoards()
397
+ this.emit({ type: "catalog" })
398
+ }
399
+
400
+ /** Records a machine's terminal context (sent by one process per machine); catalog on change. */
401
+ setTerminals(connID: string, frame: Terminals): void {
402
+ const conn = this.conns.get(connID)
403
+ if (!conn) return
404
+ conn.lastSeen = this.now()
405
+ const terminals: MachineTerminals = frame.herdr ? { herdr: { workspaces: frame.herdr.workspaces.map((w) => ({ id: w.id, label: w.label, directories: [...w.directories] })) } } : {}
406
+ const key = JSON.stringify(terminals)
407
+ const previous = this.machineTerminals.get(conn.machine.id)
408
+ this.machineTerminals.set(conn.machine.id, { connID, terminals, key })
409
+ if (previous?.key !== key) this.emit({ type: "catalog" })
410
+ }
411
+
412
+ /** The machine's terminal context, while the process that sent it is connected. */
413
+ terminals(machineID: string): MachineTerminals | undefined {
414
+ return this.machineTerminals.get(machineID)?.terminals
415
+ }
416
+
417
+ observe(connID: string, event: ForwardedEvent): void {
418
+ const conn = this.conns.get(connID)
419
+ if (!conn) return
420
+ conn.lastSeen = this.now()
421
+ const sessionID = eventSessionID(event)
422
+ const data = event.data as Record<string, unknown>
423
+ // Events about another tenant's session (or nesting under one) are dropped.
424
+ const tenant = this.tenant(conn)
425
+ for (const id of [sessionID, data.parentID]) {
426
+ if (typeof id !== "string") continue
427
+ const owner = this.sessionTenant(id)
428
+ if (owner !== undefined && owner !== tenant) return
429
+ }
430
+ if (sessionID) conn.activity.set(sessionID, ++this.seq)
431
+ const streamed = event.type === "session.text.delta" || event.type === "session.reasoning.delta"
432
+ // Anything else in a session (text ended → say, run ended → status) goes out after its streamed text.
433
+ if (sessionID && !streamed) this.flushStream(this.streams.get(sessionID), sessionID)
434
+
435
+ if (event.type === "permission.asked" && typeof data.id === "string" && sessionID) {
436
+ const known = this.permissions.get(data.id)
437
+ if (known) {
438
+ // The same request forwarded by another process: one more place to answer it.
439
+ if (!known.connIDs.includes(connID)) known.connIDs.push(connID)
440
+ return
441
+ }
442
+ this.permissions.set(data.id, { connIDs: [connID], sessionID })
443
+ this.alert("blocked", sessionID, "permission", undefined, data.id)
444
+ }
445
+ if (event.type === "form.created" && sessionID) {
446
+ const formID = (data.form as Record<string, unknown> | undefined)?.id
447
+ if (typeof formID === "string") {
448
+ const known = this.forms.get(formID)
449
+ if (known && !known.connIDs.includes(connID)) known.connIDs.push(connID)
450
+ else if (!known) remember(this.forms, formID, { connIDs: [connID], sessionID })
451
+ }
452
+ if (typeof formID === "string" && !this.alertedForms.has(formID)) {
453
+ this.alertedForms.add(formID)
454
+ if (this.alertedForms.size > 1000) this.alertedForms.delete(this.alertedForms.values().next().value!)
455
+ this.alert("blocked", sessionID, "form")
456
+ }
457
+ }
458
+ if (
459
+ (event.type === "session.execution.succeeded" || event.type === "session.execution.failed") &&
460
+ sessionID &&
461
+ this.boards.has(sessionID) &&
462
+ !this.active.has(sessionID) &&
463
+ !(this.conversations.has(sessionID) && this.watched(sessionID))
464
+ ) {
465
+ this.alert("finished", sessionID, event.type.endsWith("succeeded") ? "succeeded" : "failed")
466
+ }
467
+ if (event.type === "permission.replied" && typeof data.requestID === "string") {
468
+ this.permissions.delete(data.requestID)
469
+ }
470
+ if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") {
471
+ this.forms.delete(data.id)
472
+ }
473
+
474
+ if (sessionID) this.trackRun(event.type, sessionID)
475
+ this.observeBoards(event, tenant)
476
+ if (sessionID) this.observeActivity(event, sessionID, data)
477
+ if (sessionID) this.observeWorkerSpeech(event.type, sessionID, data)
478
+
479
+ if (streamed && sessionID) {
480
+ const rootID = this.rootOf(sessionID)
481
+ if (rootID) this.stream(sessionID, rootID, event.type === "session.text.delta" ? "delta" : "reasoning", data)
482
+ }
483
+ if (event.type === "session.text.ended" && sessionID) this.say(sessionID, data)
484
+ if (event.type === "session.inbox.delivered" && typeof data.inboxID === "string") this.delivered(data.inboxID)
485
+ if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
486
+ }
487
+
488
+ /** Records when a session's run started (kept by the first process to report it while it runs). */
489
+ private trackRun(type: string, sessionID: string): void {
490
+ if (type === "session.execution.started") {
491
+ const running = this.status(sessionID)?.find((s) => s.id === sessionID)?.state === "working"
492
+ if (!running || !this.since.has(sessionID)) remember(this.since, sessionID, this.now())
493
+ } else if (type.startsWith("session.execution.") || type === "session.deleted") {
494
+ this.since.delete(sessionID)
495
+ }
496
+ }
497
+
498
+ /** Queues streamed text; sends at once when the session's window is free, else when it ends. */
499
+ private stream(sessionID: string, rootID: string, kind: "delta" | "reasoning", data: Record<string, unknown>): void {
500
+ const text = typeof data.delta === "string" ? data.delta : ""
501
+ if (!text) return
502
+ const messageID = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined
503
+ let stream = this.streams.get(sessionID)
504
+ if (!stream) remember(this.streams, sessionID, (stream = { last: -Infinity, pending: [] }))
505
+ const tail = stream.pending.at(-1)
506
+ if (tail && tail.kind === kind && tail.messageID === messageID && tail.rootID === rootID) tail.text += text
507
+ else stream.pending.push({ kind, rootID, messageID, text })
508
+ if (stream.timer !== undefined) return
509
+ const wait = stream.last + STREAM_INTERVAL_MS - this.now()
510
+ if (wait <= 0) return this.flushStream(stream, sessionID)
511
+ const held = stream
512
+ held.timer = this.timers.set(() => {
513
+ held.timer = undefined
514
+ this.flushStream(held, sessionID)
515
+ }, wait)
516
+ }
517
+
518
+ /** Sends a session's held text now, in arrival order. */
519
+ private flushStream(stream: Stream | undefined, sessionID: string): void {
520
+ if (!stream?.pending.length) return
521
+ if (stream.timer !== undefined) this.timers.clear(stream.timer)
522
+ stream.timer = undefined
523
+ const chunks = stream.pending
524
+ stream.pending = []
525
+ stream.last = this.now()
526
+ for (const { kind, rootID, messageID, text } of chunks) {
527
+ const message = { sessionID, rootID, ...(messageID ? { messageID } : {}) }
528
+ this.emit(kind === "delta" ? { type: "delta", ...message, text } : { type: "reasoning", ...message, delta: text })
529
+ }
530
+ }
531
+
532
+ /** Remembers an utterance's inbox item so its delivery is reported as a heard event. */
533
+ trackUtterance(sessionID: string, inboxID: string): void {
534
+ if (this.deliveries.delete(inboxID)) this.emit({ type: "heard", sessionID, inboxID })
535
+ else remember(this.utterances, inboxID, sessionID)
536
+ }
537
+
538
+ /**
539
+ * Sessions in conversation mode that `connID` reports and owns but was not told about yet (it
540
+ * reconnected or took over ownership). Marks them as told.
541
+ */
542
+ conversationResends(connID: string): string[] {
543
+ const conn = this.conns.get(connID)
544
+ if (!conn) return []
545
+ const resend: string[] = []
546
+ for (const [sessionID, told] of this.conversations) {
547
+ if (told === connID || !this.reports(conn, sessionID) || this.owner(sessionID) !== connID) continue
548
+ this.conversations.set(sessionID, connID)
549
+ resend.push(sessionID)
550
+ }
551
+ return resend
552
+ }
553
+
554
+ private delivered(inboxID: string): void {
555
+ const sessionID = this.utterances.get(inboxID)
556
+ if (sessionID === undefined) return remember(this.deliveries, inboxID, true)
557
+ this.utterances.delete(inboxID)
558
+ this.emit({ type: "heard", sessionID, inboxID })
559
+ }
560
+
561
+ private say(sessionID: string, data: Record<string, unknown>): void {
562
+ if (this.rootOf(sessionID) !== sessionID) return
563
+ this.emitSay(sessionID, data)
564
+ }
565
+
566
+ /** Emits one root-session speech part, deduped across processes. */
567
+ private emitSay(sessionID: string, data: Record<string, unknown>): void {
568
+ const { assistantMessageID: messageID, ordinal, text } = data
569
+ if (!this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
570
+ if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof text !== "string" || !text.trim()) return
571
+ const key = `${messageID} ${ordinal}`
572
+ if (this.spoken.has(key)) return
573
+ remember(this.spoken, key, true)
574
+ this.emit({ type: "say", sessionID, messageID, ordinal, text })
575
+ // Nobody is listening on a phone: push the reply so it reaches a locked screen.
576
+ if (!this.watched(sessionID)) this.alert("say", sessionID, undefined, text)
577
+ }
578
+
579
+ /** Pairs a child session's latest assistant message with successful execution and speaks it once. */
580
+ private observeWorkerSpeech(type: string, sessionID: string, data: Record<string, unknown>): void {
581
+ const now = this.now()
582
+ for (const [childID, state] of this.workerSpeech) {
583
+ if (now - state.at > WORKER_SPEECH_TTL_MS) this.clearWorkerSpeech(childID)
584
+ }
585
+ const rootID = this.rootOf(sessionID)
586
+ if (!rootID || rootID === sessionID || !this.conversations.has(rootID)) {
587
+ this.clearWorkerSpeech(sessionID)
588
+ return
589
+ }
590
+ if (type === "session.execution.started") {
591
+ this.clearWorkerSpeech(sessionID)
592
+ remember(this.workerSpeech, sessionID, { rootID, succeeded: false, parts: new Map(), at: now })
593
+ return
594
+ }
595
+ if (type === "session.execution.failed" || type === "session.execution.interrupted" || type === "session.deleted") {
596
+ this.clearWorkerSpeech(sessionID)
597
+ return
598
+ }
599
+ if (type !== "session.execution.succeeded" && type !== "session.text.ended") return
600
+
601
+ let state = this.workerSpeech.get(sessionID)
602
+ if (!state || state.rootID !== rootID) {
603
+ state = { rootID, succeeded: false, parts: new Map(), at: now }
604
+ remember(this.workerSpeech, sessionID, state)
605
+ }
606
+ state.at = now
607
+ if (type === "session.execution.succeeded") state.succeeded = true
608
+ else {
609
+ const { assistantMessageID, ordinal, text } = data
610
+ if (typeof assistantMessageID !== "string" || typeof ordinal !== "number" || typeof text !== "string" || !text.trim()) return
611
+ if (state.messageID !== assistantMessageID) {
612
+ state.messageID = assistantMessageID
613
+ state.parts.clear()
614
+ }
615
+ state.parts.set(ordinal, text)
616
+ }
617
+ if (!state.succeeded || !state.messageID || state.parts.size === 0) return
618
+ // Success normally follows every text event, so flush immediately. If another process reports
619
+ // success first, briefly debounce late text so an earlier assistant message is not spoken.
620
+ if (type === "session.text.ended") {
621
+ if (state.timer !== undefined) this.timers.clear(state.timer)
622
+ const held = state
623
+ state.timer = this.timers.set(() => {
624
+ held.timer = undefined
625
+ if (this.workerSpeech.get(sessionID) === held) this.flushWorkerSpeech(held)
626
+ }, STREAM_INTERVAL_MS)
627
+ return
628
+ }
629
+ this.flushWorkerSpeech(state)
630
+ }
631
+
632
+ private flushWorkerSpeech(state: WorkerSpeech): void {
633
+ if (!state.succeeded || !state.messageID || state.parts.size === 0 || !this.conversations.has(state.rootID)) return
634
+ for (const [ordinal, text] of [...state.parts].sort(([a], [b]) => a - b)) {
635
+ this.emitSay(state.rootID, { assistantMessageID: state.messageID, ordinal, text })
636
+ }
637
+ state.parts.clear()
638
+ }
639
+
640
+ private clearWorkerSpeech(sessionID: string): void {
641
+ const state = this.workerSpeech.get(sessionID)
642
+ if (state?.timer !== undefined) this.timers.clear(state.timer)
643
+ this.workerSpeech.delete(sessionID)
644
+ }
645
+
646
+ /** Records whether a device shows a session page (reported on visibility changes and every 30 s). */
647
+ setPresence(deviceID: string, sessionID: string, visible: boolean): void {
648
+ const key = `${deviceID} ${sessionID}`
649
+ if (visible) remember(this.presence, key, { sessionID, at: this.now() })
650
+ else this.presence.delete(key)
651
+ }
652
+
653
+ /** True while some device reported the session's tree visible within PRESENCE_WINDOW_MS. */
654
+ watched(sessionID: string): boolean {
655
+ const rootID = this.rootOf(sessionID) ?? sessionID
656
+ const t = this.now()
657
+ for (const entry of this.presence.values()) {
658
+ if (t - entry.at <= PRESENCE_WINDOW_MS && (this.rootOf(entry.sessionID) ?? entry.sessionID) === rootID) return true
659
+ }
660
+ return false
661
+ }
662
+
663
+ /** Whether any process exposes the session to voice; undefined when no process reports it. */
664
+ exposed(sessionID: string): boolean | undefined {
665
+ let reported = false
666
+ for (const conn of this.conns.values()) {
667
+ const s = this.reports(conn, sessionID)
668
+ if (!s) continue
669
+ if (isOn(s)) return true
670
+ reported = true
671
+ }
672
+ return reported ? false : undefined
673
+ }
674
+
675
+ /** Process to change a session's exposure: a reporting service process, else the most recent reporter. */
676
+ exposeTarget(sessionID: string): string | undefined {
677
+ const reporters = [...this.conns.values()].filter((c) => this.reports(c, sessionID))
678
+ const services = reporters.filter((c) => c.hello.kind === "service")
679
+ return this.recent(services.length ? services : reporters, sessionID)
680
+ }
681
+
682
+ /** The machine's (most recently connected) background service process. */
683
+ serviceFor(machineID: string): string | undefined {
684
+ const services = [...this.conns.values()].filter((c) => c.machine.id === machineID && c.hello.kind === "service")
685
+ return services.sort((a, b) => b.seq - a.seq)[0]?.id
686
+ }
687
+
688
+ /**
689
+ * True when the process reported a session in, or a workspace root equal to, the directory, or its
690
+ * machine has a Herdr pane in it.
691
+ */
692
+ reportsWorkspace(connID: string, directory: string): boolean {
693
+ const conn = this.conns.get(connID)
694
+ if (!conn || !directory.startsWith("/")) return false
695
+ const target = path.posix.resolve(directory)
696
+ const herdr = this.terminals(conn.machine.id)?.herdr
697
+ if (herdr?.workspaces.some((w) => w.directories.some((d) => d.startsWith("/") && path.posix.resolve(d) === target))) return true
698
+ for (const s of this.visible(conn)) {
699
+ if (s.project?.startsWith("/") && path.posix.resolve(s.project) === target) return true
700
+ if (s.directory.startsWith("/") && path.posix.resolve(s.directory) === target) return true
701
+ }
702
+ return false
703
+ }
704
+
705
+ hasSession(sessionID: string): boolean {
706
+ for (const conn of this.conns.values()) if (conn.sessions.has(sessionID)) return true
707
+ return false
708
+ }
709
+
710
+ /** Something in the session's tree is working or waiting for the user (typed input steers then). */
711
+ running(sessionID: string): boolean {
712
+ const state = this.browserFields(sessionID).state
713
+ return state === "working" || state === "blocked"
714
+ }
715
+
716
+ /** Status tree of the root session that contains `sessionID`. */
717
+ status(sessionID: string): LiveStatus[] | undefined {
718
+ const board = this.boards.get(sessionID) ?? [...this.boards.values()].find((b) => b.get(sessionID))
719
+ return board && this.live(board.snapshot().sessions)
720
+ }
721
+
722
+ /** Adds `since` (run start) to working entries. */
723
+ private live(sessions: SessionStatus[]): LiveStatus[] {
724
+ return sessions.map((s) => {
725
+ const since = s.state === "working" ? this.since.get(s.id) : undefined
726
+ return since === undefined ? s : { ...s, since }
727
+ })
728
+ }
729
+
730
+ catalog(): CatalogMachine[] {
731
+ const machines = new Map<string, { id: string; name: string; processes: CatalogProcess[] }>()
732
+ for (const conn of [...this.conns.values()].sort((a, b) => a.seq - b.seq)) {
733
+ const machine = machines.get(conn.machine.id) ?? { id: conn.machine.id, name: conn.machine.name, processes: [] }
734
+ machines.set(conn.machine.id, machine)
735
+ const terminals = this.terminals(conn.machine.id)
736
+ if (terminals) Object.assign(machine, { terminals })
737
+ const { processID, kind, directory, pid, mode, opencodeVersion, pluginVersion } = conn.hello
738
+ machine.processes.push({
739
+ id: processID,
740
+ kind,
741
+ directory,
742
+ pid,
743
+ mode,
744
+ opencodeVersion,
745
+ pluginVersion,
746
+ connected: conn.connected,
747
+ lastSeen: conn.lastSeen,
748
+ sessions: this.visible(conn).map((s) => {
749
+ const fields = this.browserFields(s.id)
750
+ return {
751
+ ...s,
752
+ ...fields,
753
+ processID,
754
+ machineID: conn.machine.id,
755
+ status: this.status(s.id) ?? [],
756
+ busy: this.active.has(s.id),
757
+ conversation: this.conversations.has(s.id),
758
+ }
759
+ }),
760
+ })
761
+ }
762
+ return [...machines.values()]
763
+ }
764
+
765
+ /** The session as reported by any process plus its live status; undefined when nobody reports it. */
766
+ session(sessionID: string): SessionView | undefined {
767
+ let info: SessionInfo | undefined
768
+ /** Open from any reporting process; a Herdr pane wins over a TUI. */
769
+ let terminal: TerminalInfo | undefined
770
+ for (const conn of this.conns.values()) {
771
+ const s = this.reports(conn, sessionID)
772
+ if (s && (!info || s.updated > info.updated)) info = s
773
+ if (s?.open && s.terminal && (!terminal || (terminal.source !== "herdr" && s.terminal.source === "herdr"))) terminal = s.terminal
774
+ }
775
+ if (!info) return this.childView(sessionID)
776
+ const fields = this.browserFields(sessionID)
777
+ const status = this.status(sessionID) ?? []
778
+ const own = status.find((s) => s.id === sessionID)
779
+ return {
780
+ id: info.id,
781
+ title: own?.title ?? info.title,
782
+ directory: info.directory,
783
+ ...(info.parentID ? { parentID: info.parentID } : {}),
784
+ ...(info.project ? { project: info.project } : {}),
785
+ ...(info.projectName ? { projectName: info.projectName } : {}),
786
+ updated: info.updated,
787
+ ...(terminal ? { open: true, terminal } : {}),
788
+ ...fields,
789
+ busy: this.active.has(sessionID),
790
+ conversation: this.conversations.has(sessionID),
791
+ status,
792
+ activity: this.activity(sessionID),
793
+ }
794
+ }
795
+
796
+ /**
797
+ * A subagent session no process lists (it is known from its root's status tree, e.g. created by a run
798
+ * the hub watched): its view, with the root's directory and workspace. Undefined for anything else.
799
+ */
800
+ private childView(sessionID: string): SessionView | undefined {
801
+ const rootID = this.rootOf(sessionID)
802
+ if (!rootID || rootID === sessionID) return undefined
803
+ const root = this.session(rootID)
804
+ const own = root?.status.find((s) => s.id === sessionID)
805
+ if (!root || !own) return undefined
806
+ return {
807
+ id: sessionID,
808
+ title: own.title,
809
+ directory: root.directory,
810
+ ...(own.parentID ? { parentID: own.parentID } : {}),
811
+ ...(root.project ? { project: root.project } : {}),
812
+ ...(root.projectName ? { projectName: root.projectName } : {}),
813
+ updated: root.updated,
814
+ ...this.browserFields(sessionID),
815
+ busy: this.active.has(sessionID),
816
+ conversation: this.conversations.has(sessionID),
817
+ status: root.status,
818
+ activity: root.activity,
819
+ }
820
+ }
821
+
822
+ /** Exposure, overall state, pending blockers and current activity of a session and its subtree. */
823
+ private browserFields(sessionID: string): BrowserFields {
824
+ const exposed = this.exposed(sessionID) === true
825
+ const status = this.status(sessionID) ?? []
826
+ const tree = new Set([sessionID])
827
+ for (let grew = true; grew; ) {
828
+ grew = false
829
+ for (const entry of status) {
830
+ if (entry.parentID && tree.has(entry.parentID) && !tree.has(entry.id)) {
831
+ tree.add(entry.id)
832
+ grew = true
833
+ }
834
+ }
835
+ }
836
+ const entries = status.filter((entry) => tree.has(entry.id))
837
+ const own = entries.find((entry) => entry.id === sessionID)
838
+ const state: SessionStatus["state"] = entries.some((e) => e.state === "blocked")
839
+ ? "blocked"
840
+ : own?.state === "error"
841
+ ? "error"
842
+ : entries.some((e) => e.state === "working")
843
+ ? "working"
844
+ : "idle"
845
+ const blockers = entries.flatMap((e) => e.blockers.map((b) => ({ ...b, sessionID: e.id, sessionTitle: e.title })))
846
+ const rootID = this.rootOf(sessionID)
847
+ const item = [...((rootID && this.feeds.get(rootID)) || [])].reverse().find((i) => i.state === "running" && tree.has(i.sessionID))
848
+ const now = item && { kind: item.kind, ...(item.tool ? { tool: item.tool } : {}), ...(item.summary ? { summary: item.summary } : {}) }
849
+ return { exposed, state, blockers, ...(now ? { now } : {}) }
850
+ }
851
+
852
+ /** Recent activity of the root session that contains `sessionID`, newest last. */
853
+ activity(sessionID: string): ActivityItem[] {
854
+ const rootID = this.rootOf(sessionID)
855
+ return rootID ? (this.feeds.get(rootID) ?? []).map((item) => ({ ...item })) : []
856
+ }
857
+
858
+ /** Root session of the status board that contains `sessionID`. */
859
+ private rootOf(sessionID: string): string | undefined {
860
+ if (this.boards.has(sessionID)) return sessionID
861
+ for (const [rootID, board] of this.boards) if (board.get(sessionID)) return rootID
862
+ return undefined
863
+ }
864
+
865
+ /**
866
+ * Builds the activity feed from compact activity events. Idempotent: several processes may forward
867
+ * the same events, so only creations and actual changes are emitted.
868
+ */
869
+ private observeActivity(event: ForwardedEvent, sessionID: string, data: Record<string, unknown>): void {
870
+ const rootID = this.rootOf(sessionID)
871
+ if (!rootID) return
872
+ let feed = this.feeds.get(rootID)
873
+ const find = (id: unknown) => (typeof id === "string" ? feed?.find((item) => item.id === id) : undefined)
874
+ const add = (item: ActivityItem) => {
875
+ if (!feed) remember(this.feeds, rootID, (feed = []), ACTIVITY_ROOTS)
876
+ this.closeThinking(rootID, sessionID)
877
+ feed.push(item)
878
+ if (feed.length > ACTIVITY_LIMIT) feed.shift()
879
+ this.emitActivity(rootID, item)
880
+ }
881
+ const text = (v: unknown) => (typeof v === "string" && v ? v : undefined)
882
+ const at = this.now()
883
+
884
+ switch (event.type) {
885
+ case "session.tool.input.started": {
886
+ const id = text(data.id)
887
+ const tool = text(data.name)
888
+ if (!id || find(id)) return
889
+ return add({ id, sessionID, kind: "tool", ...(tool ? { tool } : {}), state: "running", at })
890
+ }
891
+ case "session.tool.called": {
892
+ const id = text(data.id)
893
+ if (!id) return
894
+ const summary = text(data.summary)
895
+ const details = { ...(summary ? { summary } : {}), ...(data.background === true ? { background: true as const } : {}) }
896
+ const existing = find(id)
897
+ if (!existing) return add({ id, sessionID, kind: "tool", ...details, state: "running", at })
898
+ if (existing.summary === details.summary && existing.background === details.background) return
899
+ return this.replace(rootID, existing, { ...existing, ...details })
900
+ }
901
+ case "session.tool.success":
902
+ case "session.tool.failed": {
903
+ const existing = find(data.id)
904
+ if (!existing || existing.state !== "running") return
905
+ const failed = event.type === "session.tool.failed"
906
+ const error = failed ? (text(data.error) ?? "failed") : undefined
907
+ return this.replace(rootID, existing, { ...existing, state: failed ? "failed" : "done", ...(error ? { error } : {}), at })
908
+ }
909
+ case "session.reasoning.started":
910
+ case "session.reasoning.delta": {
911
+ // A delta opens the item too, in case the start was missed; a finished one is not reopened.
912
+ const messageID = text(data.assistantMessageID)
913
+ if (!messageID || find(`thinking:${messageID}`)) return
914
+ return add({ id: `thinking:${messageID}`, sessionID, kind: "thinking", state: "running", at })
915
+ }
916
+ case "session.reasoning.ended": {
917
+ const existing = find(`thinking:${text(data.assistantMessageID)}`)
918
+ if (!existing || existing.state !== "running") return
919
+ return this.replace(rootID, existing, { ...existing, state: "done", at })
920
+ }
921
+ case "session.execution.succeeded":
922
+ case "session.execution.failed":
923
+ case "session.execution.interrupted":
924
+ return this.closeThinking(rootID, sessionID)
925
+ }
926
+ }
927
+
928
+ /** Thinking also ends when something else happens in the same session (older spokes send no reasoning.ended). */
929
+ private closeThinking(rootID: string, sessionID: string): void {
930
+ for (const item of this.feeds.get(rootID) ?? []) {
931
+ if (item.kind === "thinking" && item.sessionID === sessionID && item.state === "running") {
932
+ this.replace(rootID, item, { ...item, state: "done", at: this.now() })
933
+ }
934
+ }
935
+ }
936
+
937
+ private replace(rootID: string, previous: ActivityItem, next: ActivityItem): void {
938
+ const feed = this.feeds.get(rootID)
939
+ const index = feed?.indexOf(previous) ?? -1
940
+ if (!feed || index < 0) return
941
+ feed[index] = next
942
+ this.emitActivity(rootID, next)
943
+ }
944
+
945
+ private emitActivity(rootID: string, item: ActivityItem): void {
946
+ this.emit({ type: "activity", sessionID: item.sessionID, rootID, item: { ...item } })
947
+ }
948
+
949
+ conversation(sessionID: string): boolean {
950
+ return this.conversations.has(sessionID)
951
+ }
952
+
953
+ /** Records conversation mode for a session; `connID` is the process that was just told. */
954
+ setConversation(sessionID: string, on: boolean, connID?: string): void {
955
+ const changed = on !== this.conversations.has(sessionID)
956
+ if (on) this.conversations.set(sessionID, connID)
957
+ else {
958
+ this.conversations.delete(sessionID)
959
+ for (const [childID, state] of this.workerSpeech) if (state.rootID === sessionID) this.clearWorkerSpeech(childID)
960
+ }
961
+ if (!changed) return
962
+ this.emit({ type: "conversation", sessionID, on })
963
+ this.emit({ type: "catalog" })
964
+ }
965
+
966
+ /**
967
+ * Connection that should run a turn for the session:
968
+ * 1. a process reporting it as owned, most recent activity first;
969
+ * 2. else a service process that reports it;
970
+ * 3. else the service process of a machine that reports it;
971
+ * 4. else any process that reports it, most recent activity first;
972
+ * 5. else none.
973
+ */
974
+ owner(sessionID: string): string | undefined {
975
+ const tenant = this.sessionTenant(sessionID)
976
+ const all = [...this.conns.values()].filter((c) => this.tenant(c) === tenant)
977
+ const reporters = all.filter((c) => this.reports(c, sessionID))
978
+ if (reporters.length === 0) {
979
+ // A subagent session no process lists runs in the process that runs its root.
980
+ const rootID = this.rootOf(sessionID)
981
+ return rootID && rootID !== sessionID ? this.owner(rootID) : undefined
982
+ }
983
+ const recent = (list: Conn[]) => this.recent(list, sessionID)
984
+ const owned = reporters.filter((c) => this.reports(c, sessionID)?.owned)
985
+ if (owned.length) return recent(owned)
986
+ const services = reporters.filter((c) => c.hello.kind === "service")
987
+ if (services.length) return recent(services)
988
+ const machines = new Set(reporters.map((c) => c.machine.id))
989
+ return recent(all.filter((c) => c.hello.kind === "service" && machines.has(c.machine.id))) ?? recent(reporters)
990
+ }
991
+
992
+ /** The connection with the most recent activity for the session, then the most recently connected. */
993
+ private recent(list: Conn[], sessionID: string): string | undefined {
994
+ return [...list].sort((a, b) => (b.activity.get(sessionID) ?? 0) - (a.activity.get(sessionID) ?? 0) || b.seq - a.seq)[0]?.id
995
+ }
996
+
997
+ /** Where to send a reply for a permission that is pending in the session's status tree (the first place). */
998
+ permissionTarget(rootID: string, requestID: string): { connID: string; sessionID: string } | undefined {
999
+ return this.permissionTargets(rootID, requestID)[0]
1000
+ }
1001
+
1002
+ /** Every connected process of the session's tenant that forwarded the pending permission, first first. */
1003
+ permissionTargets(rootID: string, requestID: string): Array<{ connID: string; sessionID: string }> {
1004
+ const pending = this.status(rootID)?.some((s) =>
1005
+ s.blockers.some((b) => b.kind === "permission" && b.id === requestID),
1006
+ )
1007
+ const origin = this.permissions.get(requestID)
1008
+ if (!pending || !origin) return []
1009
+ const tenant = this.sessionTenant(rootID)
1010
+ return origin.connIDs
1011
+ .filter((connID) => this.conns.has(connID) && this.connTenant(connID) === tenant)
1012
+ .map((connID) => ({ connID, sessionID: origin.sessionID }))
1013
+ }
1014
+
1015
+ /** Every connected process of the session's tenant that forwarded the pending form, first first. */
1016
+ formTargets(rootID: string, formID: string): Array<{ connID: string; sessionID: string }> {
1017
+ const pending = this.status(rootID)?.some((s) => s.blockers.some((b) => b.kind === "form" && b.id === formID))
1018
+ const origin = this.forms.get(formID)
1019
+ if (!pending || !origin) return []
1020
+ const tenant = this.sessionTenant(rootID)
1021
+ return origin.connIDs
1022
+ .filter((connID) => this.conns.has(connID) && this.connTenant(connID) === tenant)
1023
+ .map((connID) => ({ connID, sessionID: origin.sessionID }))
1024
+ }
1025
+
1026
+ /** A form no process holds any more: drop it and its blocker. */
1027
+ dropForm(formID: string): void {
1028
+ const origin = this.forms.get(formID)
1029
+ if (!origin) return
1030
+ this.forms.delete(formID)
1031
+ this.observeBoards({ type: "form.cancelled", data: { sessionID: origin.sessionID, id: formID } })
1032
+ }
1033
+
1034
+ /** A permission no process holds any more (answered elsewhere, expired): drop it and its blocker. */
1035
+ dropPermission(requestID: string): void {
1036
+ const origin = this.permissions.get(requestID)
1037
+ if (!origin) return
1038
+ this.permissions.delete(requestID)
1039
+ this.observeBoards({ type: "permission.replied", data: { sessionID: origin.sessionID, requestID } })
1040
+ }
1041
+
1042
+ /**
1043
+ * Takes the session's turn lock; call the returned function to release it. `onCancel` runs when a stop or
1044
+ * interrupt releases the lock first (see cancelTurns), so the waiting request can end.
1045
+ */
1046
+ beginTurn(sessionID: string, onCancel?: () => void): () => void {
1047
+ if (this.active.has(sessionID)) throw new BusyError()
1048
+ const turn = { cancel: onCancel }
1049
+ this.active.set(sessionID, turn)
1050
+ this.emit({ type: "catalog" })
1051
+ return () => {
1052
+ if (this.active.get(sessionID) !== turn) return // already released, or cancelled and taken by a newer turn
1053
+ this.active.delete(sessionID)
1054
+ this.emit({ type: "catalog" })
1055
+ }
1056
+ }
1057
+
1058
+ /** After a stop or interrupt: releases the turn locks in the session's tree and ends their waiting requests. */
1059
+ cancelTurns(sessionID: string): void {
1060
+ const rootID = this.rootOf(sessionID) ?? sessionID
1061
+ const ended = [...this.active].filter(([id]) => id === sessionID || (this.rootOf(id) ?? id) === rootID)
1062
+ if (!ended.length) return
1063
+ for (const [id] of ended) this.active.delete(id)
1064
+ this.emit({ type: "catalog" })
1065
+ for (const [, turn] of ended) {
1066
+ try {
1067
+ turn.cancel?.()
1068
+ } catch {
1069
+ // a waiting request that cannot be told still lost its lock
1070
+ }
1071
+ }
1072
+ }
1073
+
1074
+ /** Feeds an event to the status boards; with a tenant, only to that tenant's boards. */
1075
+ private observeBoards(event: ForwardedEvent, tenant?: string): void {
1076
+ for (const [rootID, board] of this.boards) {
1077
+ if (tenant !== undefined && this.sessionTenant(rootID) !== tenant) continue
1078
+ if (board.observe(event)) this.emit({ type: "status", sessionID: rootID, sessions: this.live(board.snapshot().sessions) })
1079
+ }
1080
+ }
1081
+
1082
+ /** Creates boards for newly reported root sessions, nests reported children, drops boards nobody reports. */
1083
+ private syncBoards(): void {
1084
+ // Every reported session gets a status board, Voice or not: spokes forward events for all of them.
1085
+ const reported = new Map<string, SessionInfo>()
1086
+ for (const conn of this.conns.values()) for (const s of this.visible(conn)) reported.set(s.id, s)
1087
+
1088
+ const rootOf = (id: string): string => {
1089
+ const seen = new Set<string>()
1090
+ let current = id
1091
+ for (;;) {
1092
+ const parent = reported.get(current)?.parentID
1093
+ // Never nest a session under another tenant's session.
1094
+ if (!parent || !reported.has(parent) || seen.has(parent) || this.sessionTenant(parent) !== this.sessionTenant(current)) return current
1095
+ seen.add(current)
1096
+ current = parent
1097
+ }
1098
+ }
1099
+
1100
+ for (const id of this.boards.keys()) if (!reported.has(id) || rootOf(id) !== id) this.boards.delete(id)
1101
+
1102
+ const children: SessionInfo[] = []
1103
+ for (const s of reported.values()) {
1104
+ if (rootOf(s.id) !== s.id) {
1105
+ children.push(s)
1106
+ continue
1107
+ }
1108
+ const board = this.boards.get(s.id)
1109
+ if (!board) this.boards.set(s.id, new StatusBoard(s.id, s.title))
1110
+ else if (board.get(s.id)?.title !== s.title) {
1111
+ board.observe({ type: "session.renamed", data: { sessionID: s.id, title: s.title } })
1112
+ }
1113
+ }
1114
+
1115
+ // Nest children parent-first; repeat until no more can be placed.
1116
+ let pending = children
1117
+ while (pending.length) {
1118
+ const next: SessionInfo[] = []
1119
+ for (const child of pending) {
1120
+ const board = this.boards.get(rootOf(child.id))
1121
+ if (!board || board.get(child.id)) continue
1122
+ const placed = board.observe({
1123
+ type: "session.created",
1124
+ data: { sessionID: child.id, parentID: child.parentID, title: child.title },
1125
+ })
1126
+ if (!placed) next.push(child)
1127
+ }
1128
+ if (next.length === pending.length) break
1129
+ pending = next
1130
+ }
1131
+ }
1132
+
1133
+ private alert(kind: Alert["kind"], sessionID: string, detail: Alert["detail"], text?: string, requestID?: string): void {
1134
+ const entry = [...this.boards].find(([, board]) => board.get(sessionID))
1135
+ if (!entry) return
1136
+ const [rootID, board] = entry
1137
+ const alert: Alert = {
1138
+ kind,
1139
+ sessionID: rootID,
1140
+ title: board.get(rootID)?.title ?? rootID,
1141
+ ...(detail ? { detail } : {}),
1142
+ ...(text !== undefined ? { text } : {}),
1143
+ ...(requestID !== undefined ? { requestID } : {}),
1144
+ }
1145
+ const owner = this.sessionTenant(rootID)
1146
+ for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
1147
+ }
1148
+
1149
+ private emit(message: RegistryMessage): void {
1150
+ const tenant = message.type === "catalog" ? undefined : this.sessionTenant("rootID" in message ? message.rootID : message.sessionID)
1151
+ for (const listener of this.listeners) listener(message, tenant)
1152
+ }
1153
+ }