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