@huynle/hub 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -2,9 +2,25 @@
2
2
  // StatusBoard per root session (fed by forwarded events from any process), owner selection for turns,
3
3
  // a per-session turn lock, permission routing back to the process that asked, and conversation mode
4
4
  // (which sessions speak their replies on the phone).
5
+ //
6
+ // Cluster mode (design §9, decision vkxkqiuf §5): the Registry is the single writer of this pod's connections and
7
+ // exports, per tenant, a RegistrySlice of them (connections and what they report, status boards with their activity,
8
+ // pending permissions and forms, presence, sessions with a turn running); ./cluster/view.ts publishes it to the
9
+ // Catalog and the Bus and applies other pods' slices here (setRemote). The synchronous reads (sessionTenant,
10
+ // owner, catalog, permissionTargets, …) answer over local and remote state, so a route on any pod finds a spoke on
11
+ // another one and reaches it through SpokeHub.request (pod RPC). Events, alerts and speech stay with the pod that
12
+ // holds the connection; remote state is never re-exported.
5
13
  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"
14
+ import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
15
+ import { StatusBoard, permissionRequest, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
16
+ import { sanitizeLabel } from "./agent/envelope"
17
+ import { DoneWatch } from "./done"
18
+ import { SentenceStream } from "./say-stream"
19
+ import type { ActivityStep, NotificationKind } from "./push"
20
+
21
+ export { ACTIVITY_INTERVAL_MINUTES } from "./store"
22
+ /** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
23
+ export const LONG_TOOL_MS = 30_000
8
24
 
9
25
  export class BusyError extends Error {
10
26
  constructor() {
@@ -45,13 +61,17 @@ export const STREAM_INTERVAL_MS = 100
45
61
  export type RegistryMessage =
46
62
  | { readonly type: "catalog" }
47
63
  | { 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 }
64
+ /** Assistant text streamed in a root or subagent session (rootID: its root session; ordinal: its text part). */
65
+ | { readonly type: "delta"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly ordinal?: number; readonly text: string }
50
66
  /** Reasoning text streamed in a root or subagent session. */
51
67
  | { readonly type: "reasoning"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly delta: string }
52
68
  | { 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 }
69
+ /**
70
+ * Assistant text of a root session in conversation mode, to be spoken. Root text is spoken while it streams:
71
+ * one say per completed sentence, `seq` counting the sentences of the message's text part (`ordinal`) from 0.
72
+ * A subagent's final result is one say for the whole text part (no `seq`).
73
+ */
74
+ | { readonly type: "say"; readonly sessionID: string; readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly text: string }
55
75
  /** An utterance sent from the phone reached the agent. */
56
76
  | { readonly type: "heard"; readonly sessionID: string; readonly inboxID: string }
57
77
  /** An activity item was created or changed state (sessionID: where it happened; rootID: its root). */
@@ -71,6 +91,8 @@ export interface ActivityItem {
71
91
  readonly summary?: string
72
92
  /** The tool was started in the background (e.g. a background subagent). */
73
93
  readonly background?: true
94
+ /** A subagent tool: the child session it runs (once the tool reported it). */
95
+ readonly childSessionID?: string
74
96
  readonly state: ActivityState
75
97
  readonly error?: string
76
98
  /** ms epoch of creation, then of the last state change. */
@@ -86,26 +108,73 @@ const ACTIVITY_ROOTS = 200
86
108
  export const PRESENCE_WINDOW_MS = 45_000
87
109
  /** Child completion text is useful only while its matching run completion is still nearby. */
88
110
  export const WORKER_SPEECH_TTL_MS = 5 * 60_000
111
+ /** A worker result is prefixed with its task's title while another worker runs or was announced within this window. */
112
+ export const ANNOUNCE_GROUP_MS = 60_000
113
+ /** Root speech within this window counts as heard: a worker result it already contained is not read again. */
114
+ export const HEARD_WINDOW_MS = 2 * 60_000
115
+ /**
116
+ * How long a session whose turn ended by an inferred interruption (session.execution.interrupted `inferred`: the
117
+ * harness reported none, the spoke found it later) waits before it counts as idle (Registry.idle).
118
+ */
119
+ export const INFERRED_IDLE_GRACE_MS = 5_000
120
+ /** Another process forwarding the same worker failure within this window is not announced again. */
121
+ const FAILURE_DEDUPE_MS = 30_000
122
+ const ANNOUNCE_REASON_MAX = 150
123
+
124
+ /** Lowercase words only, for "was this already said" checks. */
125
+ const spokenForm = (text: string) =>
126
+ text
127
+ .toLowerCase()
128
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
129
+ .trim()
89
130
 
90
131
  /** Push-worthy transitions of a root session. Kept off the SSE stream. */
91
132
  export interface Alert {
92
- readonly kind: "blocked" | "finished" | "say"
133
+ /**
134
+ * The notification kind (docs/notifications.md); activity: a step of interest for activity updates (`step`), the
135
+ * push layer collects and throttles them.
136
+ */
137
+ readonly kind: NotificationKind | "activity"
93
138
  /** Root session of the tree the event happened in. */
94
139
  readonly sessionID: string
95
140
  /** Root session title. */
96
141
  readonly title: string
97
- readonly detail?: "permission" | "form" | "succeeded" | "failed"
98
- /** say: the spoken text. */
142
+ /** The body: the spoken text (say), a reason (failed, stopped, retry), a command (scripts), the parent session. */
99
143
  readonly text?: string
100
- /** blocked permission: the request ID. */
144
+ /** permission: the request ID. */
101
145
  readonly requestID?: string
146
+ /** What it is about, for the title: a subagent's title, a job's outcome ("exited 1"), a duration ("45s"). */
147
+ readonly subject?: string
148
+ /** Identity of the thing that ended (subagent session, job, tool call): one notification each, whatever the cooldown. */
149
+ readonly key?: string
102
150
  /** Tenant (user ID) that owns the session; push goes to this user's devices only. */
103
151
  readonly owner?: string
152
+ readonly step?: ActivityStep
104
153
  }
105
154
 
155
+ /** Notifications that reach a session even while some device shows it. */
156
+ const ALWAYS_ALERT: ReadonlySet<NotificationKind> = new Set(["handoff", "question", "permission", "say"])
157
+
158
+ /** Interruptions worth a "stopped" notification (a user stop or a newer turn superseding it are not). */
159
+ const STOP_REASONS: Readonly<Record<string, string>> = { shutdown: "OpenCode shut down", inactivity: "Stopped after inactivity" }
160
+
106
161
  /** Machine ID → tenant (owning user ID). Every machine belongs to exactly one tenant. */
107
162
  export type TenantOf = (machineID: string) => string | undefined
108
163
 
164
+ /** A pending permission: where it was asked, every connection that forwarded it, what it asks. */
165
+ interface PendingPermission {
166
+ connIDs: string[]
167
+ readonly sessionID: string
168
+ readonly tenant?: string
169
+ readonly request?: PermissionRequest
170
+ recommendation?: BlockerRecommendation
171
+ }
172
+
173
+ /** Characters of a recommendation's author and reason on a blocker. */
174
+ const RECOMMENDATION_BY_MAX = 200
175
+ const RECOMMENDATION_WHY_MAX = 1_000
176
+ const DECISION_ID = /^dec_[A-Za-z0-9]{1,64}$/
177
+
109
178
  /** A pending permission or question somewhere in a session's tree, with where it was asked. */
110
179
  export interface TreeBlocker extends Blocker {
111
180
  readonly sessionID: string
@@ -165,8 +234,15 @@ export interface CatalogProcess {
165
234
  readonly directory: string
166
235
  readonly pid: number
167
236
  readonly mode: SpokeMode
237
+ /** OpenCode's version as the hello sent it (kept for older consoles; see harnessVersion). */
168
238
  readonly opencodeVersion: string
169
239
  readonly pluginVersion: string
240
+ /** Harness id ("opencode" for hellos that predate harness), its version and the declared evidence level. */
241
+ readonly harness: string
242
+ readonly harnessVersion: string
243
+ readonly evidence: EvidenceLevel | "unknown"
244
+ /** What the process serves (spokeFeatures: core ops and optional features), sorted; consoles gate controls on it. */
245
+ readonly features: readonly string[]
170
246
  readonly connected: number
171
247
  readonly lastSeen: number
172
248
  readonly sessions: CatalogSession[]
@@ -188,6 +264,8 @@ interface Conn {
188
264
  readonly id: string
189
265
  readonly machine: MachineRef
190
266
  readonly hello: Hello
267
+ /** What the process serves: announced features ∪ the implied v1 set for an old OpenCode hello. */
268
+ readonly features: ReadonlySet<string>
191
269
  readonly connected: number
192
270
  readonly seq: number
193
271
  lastSeen: number
@@ -196,16 +274,88 @@ interface Conn {
196
274
  sessionsKey: string
197
275
  /** sessionID → activity sequence number of the last event this process sent for it. */
198
276
  activity: Map<string, number>
277
+ /**
278
+ * The launch this connection is bound to (it authenticated with that launch's token, phase 5). Such a
279
+ * connection is never the machine's service nor an exposure target.
280
+ */
281
+ readonly launchID?: string
282
+ /** A connection another pod holds (from its slice): its tenant as that pod saw it. */
283
+ readonly remote?: { readonly pod: string; readonly tenant: string }
284
+ }
285
+
286
+ /** A connection in a RegistrySlice. */
287
+ export interface SliceConn {
288
+ readonly id: string
289
+ readonly machine: MachineRef
290
+ readonly hello: Hello
291
+ readonly connected: number
292
+ readonly seq: number
293
+ readonly lastSeen: number
294
+ readonly sessions: SessionInfo[]
295
+ /** sessionID → recency (comparable across pods: clock-based). */
296
+ readonly activity: Array<[string, number]>
297
+ readonly launchID?: string
298
+ /** The machine's terminal context, when this connection sent the latest one. */
299
+ readonly terminals?: MachineTerminals
300
+ }
301
+
302
+ /** A root session's status tree (live: run starts and recommendations included) and activity feed. */
303
+ export interface SliceBoard {
304
+ readonly rootID: string
305
+ readonly sessions: LiveStatus[]
306
+ readonly activity: ActivityItem[]
307
+ }
308
+
309
+ export interface SlicePermission {
310
+ readonly requestID: string
311
+ readonly sessionID: string
312
+ readonly connIDs: string[]
313
+ readonly request?: PermissionRequest
314
+ readonly recommendation?: BlockerRecommendation
315
+ }
316
+
317
+ export interface SliceForm {
318
+ readonly formID: string
319
+ readonly sessionID: string
320
+ readonly connIDs: string[]
321
+ }
322
+
323
+ /** One tenant's state on one pod, as other pods mirror it (JSON-compatible). */
324
+ export interface RegistrySlice {
325
+ readonly conns: SliceConn[]
326
+ readonly boards: SliceBoard[]
327
+ readonly permissions: SlicePermission[]
328
+ readonly forms: SliceForm[]
329
+ readonly presence: Array<{ readonly deviceID: string; readonly sessionID: string; readonly at: number }>
330
+ /** Sessions with a hub turn running on that pod. */
331
+ readonly busy: string[]
332
+ }
333
+
334
+ /** How a connection was authenticated, beyond its machine. */
335
+ export interface ConnectOptions {
336
+ /** The launch whose launch-bound token the connection presented (checked by /spoke/ws). */
337
+ readonly launchID?: string
199
338
  }
200
339
 
201
340
  /** Streamed text of one session waiting for its next send window. */
202
341
  interface Stream {
203
342
  last: number
204
343
  timer?: unknown
205
- pending: Array<{ kind: "delta" | "reasoning"; rootID: string; messageID?: string; text: string }>
344
+ pending: Array<{ kind: "delta" | "reasoning"; rootID: string; messageID?: string; ordinal?: number; text: string }>
206
345
  }
207
346
 
208
347
  /** Latest assistant message of a child run, paired with its successful completion for direct speech. */
348
+ /** A root text part being spoken while it streams (deltas from the first process that streamed it). */
349
+ interface RootSpeech {
350
+ readonly sessionID: string
351
+ readonly connID: string
352
+ readonly stream: SentenceStream
353
+ seq: number
354
+ }
355
+
356
+ /** Root text parts being spoken at once (more are dropped oldest first). */
357
+ const ROOT_SPEECH_LIMIT = 100
358
+
209
359
  interface WorkerSpeech {
210
360
  readonly rootID: string
211
361
  succeeded: boolean
@@ -240,11 +390,19 @@ export class Registry {
240
390
  * permission requestID → its session and every connection that forwarded it, first first. Usually one
241
391
  * process holds it, but several may report the session; a reply tries them in order.
242
392
  */
243
- private readonly permissions = new Map<string, { connIDs: string[]; sessionID: string }>()
393
+ private readonly permissions = new Map<string, PendingPermission>()
394
+ /** When a session's latest turn ended by an inferred interruption (see INFERRED_IDLE_GRACE_MS). */
395
+ private readonly inferredEnds = new Map<string, number>()
396
+ /** Told when a pending permission goes away (replied, dropped, its processes gone). */
397
+ private readonly permissionClosedListeners = new Set<(requestID: string, sessionID: string, tenant: string | undefined) => void>()
244
398
  /** Sessions with a hub turn in flight, with how to end the waiting request early (stop/interrupt). */
245
399
  private readonly active = new Map<string, { readonly cancel?: () => void }>()
246
400
  private readonly listeners = new Set<(message: RegistryMessage, tenant?: string) => void>()
247
401
  private readonly alertListeners = new Set<(alert: Alert) => void>()
402
+ /** Called after every sessions frame (unchanged snapshots too), e.g. to push session tokens. */
403
+ private readonly sessionsListeners = new Set<(connID: string) => void>()
404
+ /** Called when the user's own prompt reached a session's inbox (typed or spoken in the harness). */
405
+ private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
248
406
  /** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
249
407
  private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
250
408
  /** Form IDs already alerted (forms may be forwarded by several processes). */
@@ -253,8 +411,17 @@ export class Registry {
253
411
  private readonly conversations = new Map<string, string | undefined>()
254
412
  /** `messageID ordinal` of texts already sent as say events (several processes forward the same text). */
255
413
  private readonly spoken = new Map<string, true>()
414
+ /** `messageID ordinal` → a root text part being spoken sentence by sentence as it streams. */
415
+ private readonly rootSpeech = new Map<string, RootSpeech>()
256
416
  /** Child session → latest text and completion state, while its root is in conversation mode. */
257
417
  private readonly workerSpeech = new Map<string, WorkerSpeech>()
418
+ /** Root → what it said lately (spokenForm), so a worker result the voice agent already relayed is skipped. */
419
+ private readonly rootHeard = new Map<string, Array<{ text: string; at: number }>>()
420
+ /** Root → when a worker announcement of its tree was last spoken. */
421
+ private readonly lastAnnounced = new Map<string, number>()
422
+ /** Worker → when its failure was announced; permission/form ID → announced. */
423
+ private readonly announcedFailures = new Map<string, number>()
424
+ private readonly announcedBlockers = new Map<string, true>()
258
425
  /** Utterance inboxID → session, until OpenCode reports it delivered. */
259
426
  private readonly utterances = new Map<string, string>()
260
427
  /** Deliveries not (yet) matched to an utterance: the steer result can arrive after the event. */
@@ -270,13 +437,27 @@ export class Registry {
270
437
  /** Machine → its latest terminals frame and the connection that sent it. */
271
438
  private readonly machineTerminals = new Map<string, { connID: string; terminals: MachineTerminals; key: string }>()
272
439
  private seq = 0
440
+ private readonly doneWatch: DoneWatch
441
+ /** Told when this pod's state of a tenant changed (undefined: any tenant). */
442
+ private readonly localChangeListeners = new Set<(tenant: string | undefined) => void>()
443
+ /** Other pods' slices: pod → tenant → slice; the maps below index them (see reindex). */
444
+ private readonly remoteSlices = new Map<string, Map<string, RegistrySlice>>()
445
+ private remoteConns = new Map<string, Conn>()
446
+ private remoteBoards = new Map<string, SliceBoard>()
447
+ private remotePermissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
448
+ private remoteForms = new Map<string, { sessionID: string; connIDs: string[] }>()
449
+ private remotePresence: Array<{ sessionID: string; at: number }> = []
450
+ private remoteBusy = new Set<string>()
451
+ private remoteTerminals = new Map<string, MachineTerminals>()
273
452
 
274
453
  constructor(
275
454
  private readonly now: () => number = Date.now,
276
455
  private readonly timers: Timers = realTimers,
277
456
  /** Single tenant unless given (pairing mode, tests). */
278
457
  private readonly tenantOf: TenantOf = () => "",
279
- ) {}
458
+ ) {
459
+ this.doneWatch = new DoneWatch(timers, (rootID) => this.settledTree(rootID), (rootID) => this.alert("done", rootID))
460
+ }
280
461
 
281
462
  /**
282
463
  * Listens to registry messages. `tenant` is the user that owns the message's session (undefined for
@@ -306,13 +487,29 @@ export class Registry {
306
487
  // claim, read or inject into another user's session by reusing its ID.
307
488
 
308
489
  private tenant(conn: Conn): string {
309
- return this.tenantOf(conn.machine.id) ?? `?${conn.machine.id}`
490
+ return conn.remote ? conn.remote.tenant : (this.tenantOf(conn.machine.id) ?? `?${conn.machine.id}`)
491
+ }
492
+
493
+ /** A connection of this pod or (remote) of another one. */
494
+ private conn(connID: string): Conn | undefined {
495
+ return this.conns.get(connID) ?? this.remoteConns.get(connID)
496
+ }
497
+
498
+ /** Every connection: this pod's, then other pods'. */
499
+ private all(): Conn[] {
500
+ return this.remoteConns.size ? [...this.conns.values(), ...this.remoteConns.values()] : [...this.conns.values()]
501
+ }
502
+
503
+ /** A sequence number that also orders across pods (clock-based, strictly increasing here). */
504
+ private tick(): number {
505
+ this.seq = Math.max(this.seq + 1, this.now())
506
+ return this.seq
310
507
  }
311
508
 
312
509
  /** Tenant of a reported session, or of the status board that contains it; undefined when unknown. */
313
510
  sessionTenant(sessionID: string): string | undefined {
314
511
  let first: Conn | undefined
315
- for (const conn of this.conns.values()) if (conn.sessions.has(sessionID) && (!first || conn.seq < first.seq)) first = conn
512
+ for (const conn of this.all()) if (conn.sessions.has(sessionID) && (!first || conn.seq < first.seq)) first = conn
316
513
  if (first) return this.tenant(first)
317
514
  const rootID = this.rootOf(sessionID)
318
515
  return rootID && rootID !== sessionID ? this.sessionTenant(rootID) : undefined
@@ -336,23 +533,58 @@ export class Registry {
336
533
 
337
534
  /** Whether the connection's process belongs to the tenant. */
338
535
  connTenant(connID: string): string | undefined {
339
- const conn = this.conns.get(connID)
536
+ const conn = this.conn(connID)
340
537
  return conn && this.tenant(conn)
341
538
  }
342
539
 
343
- connect(connID: string, machine: MachineRef, hello: Hello): void {
540
+ /** IDs of the sessions the connection reports for its own tenant (subagents included); empty when unknown. */
541
+ reportedSessions(connID: string): string[] {
542
+ const conn = this.conn(connID)
543
+ return conn ? this.visible(conn).map((s) => s.id) : []
544
+ }
545
+
546
+ /** The machine of a connected process. */
547
+ connMachine(connID: string): string | undefined {
548
+ return this.conn(connID)?.machine.id
549
+ }
550
+
551
+ /** Whether a connected process of the machine reports the session for the machine's tenant. */
552
+ machineReports(machineID: string, sessionID: string): boolean {
553
+ for (const conn of this.all()) if (conn.machine.id === machineID && this.reports(conn, sessionID)) return true
554
+ return false
555
+ }
556
+
557
+ /** Listens to every sessions frame (after it is applied, unchanged snapshots included). */
558
+ onSessions(listener: (connID: string) => void): () => void {
559
+ this.sessionsListeners.add(listener)
560
+ return () => this.sessionsListeners.delete(listener)
561
+ }
562
+
563
+ /**
564
+ * Listens to human turns the spokes report: a prompt the user put into a session's inbox
565
+ * (session.inbox.delivered with origin "user"); `tenant` owns the session.
566
+ */
567
+ onHumanTurn(listener: (sessionID: string, tenant?: string) => void): () => void {
568
+ this.humanTurnListeners.add(listener)
569
+ return () => this.humanTurnListeners.delete(listener)
570
+ }
571
+
572
+ connect(connID: string, machine: MachineRef, hello: Hello, options: ConnectOptions = {}): void {
344
573
  const t = this.now()
345
574
  this.conns.set(connID, {
575
+ ...(options.launchID !== undefined ? { launchID: options.launchID } : {}),
346
576
  id: connID,
347
577
  machine,
348
578
  hello,
579
+ features: spokeFeatures(hello),
349
580
  connected: t,
350
- seq: ++this.seq,
581
+ seq: this.tick(),
351
582
  lastSeen: t,
352
583
  sessions: new Map(),
353
584
  sessionsKey: "[]",
354
585
  activity: new Map(),
355
586
  })
587
+ this.changed(this.tenant(this.conns.get(connID)!))
356
588
  this.emit({ type: "catalog" })
357
589
  }
358
590
 
@@ -367,6 +599,7 @@ export class Registry {
367
599
  if (origin.connIDs.length) continue
368
600
  this.permissions.delete(requestID)
369
601
  this.observeBoards({ type: "permission.replied", data: { sessionID: origin.sessionID, requestID } })
602
+ this.permissionClosed(requestID, origin)
370
603
  }
371
604
  for (const [formID, origin] of this.forms) {
372
605
  if (!origin.connIDs.includes(connID)) continue
@@ -376,6 +609,7 @@ export class Registry {
376
609
  this.observeBoards({ type: "form.cancelled", data: { sessionID: origin.sessionID, id: formID } })
377
610
  }
378
611
  this.syncBoards()
612
+ this.changed(this.tenant(conn))
379
613
  this.emit({ type: "catalog" })
380
614
  }
381
615
 
@@ -390,11 +624,14 @@ export class Registry {
390
624
  conn.lastSeen = this.now()
391
625
  // Spokes re-send their snapshot periodically; only changes reach the phones.
392
626
  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" })
627
+ if (key !== conn.sessionsKey) {
628
+ conn.sessionsKey = key
629
+ conn.sessions = new Map(sessions.map((s) => [s.id, s]))
630
+ this.syncBoards()
631
+ this.changed(this.tenant(conn))
632
+ this.emit({ type: "catalog" })
633
+ }
634
+ for (const listener of [...this.sessionsListeners]) listener(connID)
398
635
  }
399
636
 
400
637
  /** Records a machine's terminal context (sent by one process per machine); catalog on change. */
@@ -406,12 +643,14 @@ export class Registry {
406
643
  const key = JSON.stringify(terminals)
407
644
  const previous = this.machineTerminals.get(conn.machine.id)
408
645
  this.machineTerminals.set(conn.machine.id, { connID, terminals, key })
409
- if (previous?.key !== key) this.emit({ type: "catalog" })
646
+ if (previous?.key === key) return
647
+ this.changed(this.tenant(conn))
648
+ this.emit({ type: "catalog" })
410
649
  }
411
650
 
412
651
  /** The machine's terminal context, while the process that sent it is connected. */
413
652
  terminals(machineID: string): MachineTerminals | undefined {
414
- return this.machineTerminals.get(machineID)?.terminals
653
+ return this.machineTerminals.get(machineID)?.terminals ?? this.remoteTerminals.get(machineID)
415
654
  }
416
655
 
417
656
  observe(connID: string, event: ForwardedEvent): void {
@@ -427,8 +666,14 @@ export class Registry {
427
666
  const owner = this.sessionTenant(id)
428
667
  if (owner !== undefined && owner !== tenant) return
429
668
  }
430
- if (sessionID) conn.activity.set(sessionID, ++this.seq)
669
+ if (sessionID) conn.activity.set(sessionID, this.tick())
670
+ if (sessionID && event.type.startsWith("session.execution.")) {
671
+ if (event.type === "session.execution.interrupted" && data.inferred === true) remember(this.inferredEnds, sessionID, this.now())
672
+ else this.inferredEnds.delete(sessionID)
673
+ }
431
674
  const streamed = event.type === "session.text.delta" || event.type === "session.reasoning.delta"
675
+ // Streamed text changes nothing other pods mirror (its recency goes out with the next change).
676
+ if (!streamed) this.changed(tenant)
432
677
  // Anything else in a session (text ended → say, run ended → status) goes out after its streamed text.
433
678
  if (sessionID && !streamed) this.flushStream(this.streams.get(sessionID), sessionID)
434
679
 
@@ -439,8 +684,9 @@ export class Registry {
439
684
  if (!known.connIDs.includes(connID)) known.connIDs.push(connID)
440
685
  return
441
686
  }
442
- this.permissions.set(data.id, { connIDs: [connID], sessionID })
443
- this.alert("blocked", sessionID, "permission", undefined, data.id)
687
+ const request = permissionRequest(data)
688
+ this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}) })
689
+ this.alert("permission", sessionID, { requestID: data.id })
444
690
  }
445
691
  if (event.type === "form.created" && sessionID) {
446
692
  const formID = (data.form as Record<string, unknown> | undefined)?.id
@@ -452,37 +698,41 @@ export class Registry {
452
698
  if (typeof formID === "string" && !this.alertedForms.has(formID)) {
453
699
  this.alertedForms.add(formID)
454
700
  if (this.alertedForms.size > 1000) this.alertedForms.delete(this.alertedForms.values().next().value!)
455
- this.alert("blocked", sessionID, "form")
701
+ this.alert("question", sessionID)
456
702
  }
457
703
  }
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
- }
704
+ let closedPermission: [string, PendingPermission] | undefined
467
705
  if (event.type === "permission.replied" && typeof data.requestID === "string") {
468
- this.permissions.delete(data.requestID)
706
+ const origin = this.permissions.get(data.requestID)
707
+ if (origin && this.permissions.delete(data.requestID)) closedPermission = [data.requestID, origin]
469
708
  }
470
709
  if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") {
471
710
  this.forms.delete(data.id)
472
711
  }
473
712
 
474
713
  if (sessionID) this.trackRun(event.type, sessionID)
714
+ const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
475
715
  this.observeBoards(event, tenant)
716
+ if (sessionID) this.activitySteps(event.type, sessionID, data, before)
717
+ if (sessionID) this.notifications(event.type, sessionID, data, before)
476
718
  if (sessionID) this.observeActivity(event, sessionID, data)
477
719
  if (sessionID) this.observeWorkerSpeech(event.type, sessionID, data)
720
+ if (sessionID) this.observeWorkerBlockers(event.type, sessionID, data)
478
721
 
479
722
  if (streamed && sessionID) {
480
723
  const rootID = this.rootOf(sessionID)
481
724
  if (rootID) this.stream(sessionID, rootID, event.type === "session.text.delta" ? "delta" : "reasoning", data)
482
725
  }
726
+ if (event.type === "session.text.delta" && sessionID) this.speakDelta(connID, sessionID, data)
483
727
  if (event.type === "session.text.ended" && sessionID) this.say(sessionID, data)
728
+ if ((event.type === "session.execution.interrupted" || event.type === "session.execution.failed") && sessionID) this.dropRootSpeech(sessionID)
484
729
  if (event.type === "session.inbox.delivered" && typeof data.inboxID === "string") this.delivered(data.inboxID)
730
+ if (event.type === "session.inbox.delivered" && data.origin === "user" && sessionID) {
731
+ const tenant = this.sessionTenant(sessionID)
732
+ for (const listener of [...this.humanTurnListeners]) listener(sessionID, tenant)
733
+ }
485
734
  if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
735
+ if (closedPermission) this.permissionClosed(...closedPermission)
486
736
  }
487
737
 
488
738
  /** Records when a session's run started (kept by the first process to report it while it runs). */
@@ -500,11 +750,12 @@ export class Registry {
500
750
  const text = typeof data.delta === "string" ? data.delta : ""
501
751
  if (!text) return
502
752
  const messageID = typeof data.assistantMessageID === "string" ? data.assistantMessageID : undefined
753
+ const ordinal = kind === "delta" && Number.isInteger(data.ordinal) && (data.ordinal as number) >= 0 ? (data.ordinal as number) : undefined
503
754
  let stream = this.streams.get(sessionID)
504
755
  if (!stream) remember(this.streams, sessionID, (stream = { last: -Infinity, pending: [] }))
505
756
  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 })
757
+ if (tail && tail.kind === kind && tail.messageID === messageID && tail.ordinal === ordinal && tail.rootID === rootID) tail.text += text
758
+ else stream.pending.push({ kind, rootID, messageID, ...(ordinal === undefined ? {} : { ordinal }), text })
508
759
  if (stream.timer !== undefined) return
509
760
  const wait = stream.last + STREAM_INTERVAL_MS - this.now()
510
761
  if (wait <= 0) return this.flushStream(stream, sessionID)
@@ -523,9 +774,9 @@ export class Registry {
523
774
  const chunks = stream.pending
524
775
  stream.pending = []
525
776
  stream.last = this.now()
526
- for (const { kind, rootID, messageID, text } of chunks) {
777
+ for (const { kind, rootID, messageID, ordinal, text } of chunks) {
527
778
  const message = { sessionID, rootID, ...(messageID ? { messageID } : {}) }
528
- this.emit(kind === "delta" ? { type: "delta", ...message, text } : { type: "reasoning", ...message, delta: text })
779
+ this.emit(kind === "delta" ? { type: "delta", ...message, ...(ordinal === undefined ? {} : { ordinal }), text } : { type: "reasoning", ...message, delta: text })
529
780
  }
530
781
  }
531
782
 
@@ -558,12 +809,54 @@ export class Registry {
558
809
  this.emit({ type: "heard", sessionID, inboxID })
559
810
  }
560
811
 
812
+ /** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
813
+ private speakDelta(connID: string, sessionID: string, data: Record<string, unknown>): void {
814
+ if (this.rootOf(sessionID) !== sessionID || !this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
815
+ const { assistantMessageID: messageID, ordinal, delta } = data
816
+ if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof delta !== "string") return
817
+ const key = `${messageID} ${ordinal}`
818
+ if (this.spoken.has(key)) return
819
+ let state = this.rootSpeech.get(key)
820
+ if (!state) remember(this.rootSpeech, key, (state = { sessionID, connID, stream: new SentenceStream(), seq: 0 }), ROOT_SPEECH_LIMIT)
821
+ // Other processes forward the same deltas: one stream per text part.
822
+ if (state.connID !== connID) return
823
+ const sentences = state.stream.push(delta)
824
+ // The sentence's own text goes out first, so a say never overtakes its bubble.
825
+ if (sentences.length) this.flushStream(this.streams.get(sessionID), sessionID)
826
+ for (const text of sentences) this.emitSentence(state, messageID, ordinal, text)
827
+ }
828
+
829
+ /** A root text part ended: speaks what the deltas did not (the ended text is authoritative), once. */
561
830
  private say(sessionID: string, data: Record<string, unknown>): void {
562
831
  if (this.rootOf(sessionID) !== sessionID) return
563
- this.emitSay(sessionID, data)
832
+ const { assistantMessageID: messageID, ordinal, text } = data
833
+ if (!this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
834
+ if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof text !== "string") return
835
+ const key = `${messageID} ${ordinal}`
836
+ if (this.spoken.has(key)) return
837
+ remember(this.spoken, key, true)
838
+ const state = this.rootSpeech.get(key) ?? { sessionID, connID: "", stream: new SentenceStream(), seq: 0 }
839
+ this.rootSpeech.delete(key)
840
+ for (const sentence of state.stream.end(text)) this.emitSentence(state, messageID, ordinal, sentence)
841
+ // Nobody is listening on a phone: push the whole reply so it reaches a locked screen.
842
+ if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
843
+ }
844
+
845
+ private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
846
+ this.heardFrom(state.sessionID, text)
847
+ this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq: state.seq++, text })
848
+ }
849
+
850
+ /** The run stopped or conversation mode ended: the rest of its text parts is not spoken. */
851
+ private dropRootSpeech(sessionID: string): void {
852
+ for (const [key, state] of this.rootSpeech) {
853
+ if (state.sessionID !== sessionID) continue
854
+ this.rootSpeech.delete(key)
855
+ remember(this.spoken, key, true)
856
+ }
564
857
  }
565
858
 
566
- /** Emits one root-session speech part, deduped across processes. */
859
+ /** Emits one subagent result part through its root, deduped across processes. */
567
860
  private emitSay(sessionID: string, data: Record<string, unknown>): void {
568
861
  const { assistantMessageID: messageID, ordinal, text } = data
569
862
  if (!this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
@@ -573,7 +866,74 @@ export class Registry {
573
866
  remember(this.spoken, key, true)
574
867
  this.emit({ type: "say", sessionID, messageID, ordinal, text })
575
868
  // 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)
869
+ if (!this.watched(sessionID)) this.alert("say", sessionID, { text })
870
+ }
871
+
872
+ /** Records root speech for `alreadyHeard`, keeping the last few minutes. */
873
+ private heardFrom(rootID: string, text: string): void {
874
+ const now = this.now()
875
+ const recent = (this.rootHeard.get(rootID) ?? []).filter((entry) => now - entry.at <= HEARD_WINDOW_MS).slice(-30)
876
+ recent.push({ text: spokenForm(text), at: now })
877
+ remember(this.rootHeard, rootID, recent, ACTIVITY_ROOTS)
878
+ }
879
+
880
+ /** True when the root's recent speech already contained this text (the voice agent relayed the result). */
881
+ private alreadyHeard(rootID: string, text: string): boolean {
882
+ const wanted = spokenForm(text)
883
+ if (wanted.length < 8) return false
884
+ const now = this.now()
885
+ const said = (this.rootHeard.get(rootID) ?? []).filter((entry) => now - entry.at <= HEARD_WINDOW_MS).map((entry) => entry.text)
886
+ return said.join(" ").includes(wanted)
887
+ }
888
+
889
+ /** A worker's task title (its session title on the root's status board). */
890
+ private workerTitle(rootID: string, childID: string): string {
891
+ return this.status(rootID)?.find((s) => s.id === childID)?.title?.trim() || "A worker"
892
+ }
893
+
894
+ /** Several workers are in play: another one runs (or waits) now, or one was announced a moment ago. */
895
+ private severalWorkers(rootID: string, childID: string): boolean {
896
+ const others = (this.status(rootID) ?? []).filter((s) => s.id !== rootID && s.id !== childID)
897
+ if (others.some((s) => s.state === "working" || s.state === "blocked")) return true
898
+ const last = this.lastAnnounced.get(rootID)
899
+ return last !== undefined && this.now() - last <= ANNOUNCE_GROUP_MS
900
+ }
901
+
902
+ /** Speaks a hub-made announcement through the conversation root (pushed when nobody watches, if asked). */
903
+ private announce(rootID: string, messageID: string, text: string, push: boolean): void {
904
+ remember(this.lastAnnounced, rootID, this.now(), ACTIVITY_ROOTS)
905
+ this.emit({ type: "say", sessionID: rootID, messageID, ordinal: 0, text })
906
+ if (push && !this.watched(rootID)) this.alert("say", rootID, { text })
907
+ }
908
+
909
+ /** The conversation root of a worker session, if it is one. */
910
+ private conversationRootOf(sessionID: string): string | undefined {
911
+ const rootID = this.rootOf(sessionID)
912
+ return rootID && rootID !== sessionID && this.conversations.has(rootID) && this.boards.has(rootID) ? rootID : undefined
913
+ }
914
+
915
+ /** A worker that fails, needs a permission or asks a question is announced once ("Deploy check failed: …"). */
916
+ private observeWorkerBlockers(type: string, sessionID: string, data: Record<string, unknown>): void {
917
+ if (type !== "session.execution.failed" && type !== "permission.asked" && type !== "form.created") return
918
+ const rootID = this.conversationRootOf(sessionID)
919
+ if (!rootID) return
920
+ const title = this.workerTitle(rootID, sessionID)
921
+ if (type === "session.execution.failed") {
922
+ const at = this.announcedFailures.get(sessionID)
923
+ if (at !== undefined && this.now() - at <= FAILURE_DEDUPE_MS) return
924
+ remember(this.announcedFailures, sessionID, this.now())
925
+ const message = (data.error as { message?: unknown } | undefined)?.message
926
+ const reason = (typeof message === "string" ? message : "").replace(/\s+/g, " ").trim().replace(/[.!]+$/, "").slice(0, ANNOUNCE_REASON_MAX)
927
+ return this.announce(rootID, `announce:failed:${sessionID}`, reason ? `${title} failed: ${reason}.` : `${title} failed.`, true)
928
+ }
929
+ const id = type === "permission.asked" ? data.id : (data.form as { id?: unknown } | undefined)?.id
930
+ if (typeof id !== "string") return
931
+ const key = `${type} ${id}`
932
+ if (this.announcedBlockers.has(key)) return
933
+ remember(this.announcedBlockers, key, true)
934
+ // "blocked" is pushed already; this is for the ears of whoever is in Voice mode.
935
+ if (type === "permission.asked") this.announce(rootID, `announce:permission:${id}`, `${title} needs your permission.`, false)
936
+ else this.announce(rootID, `announce:form:${id}`, `${title} has a question.`, false)
577
937
  }
578
938
 
579
939
  /** Pairs a child session's latest assistant message with successful execution and speaks it once. */
@@ -622,19 +982,31 @@ export class Registry {
622
982
  const held = state
623
983
  state.timer = this.timers.set(() => {
624
984
  held.timer = undefined
625
- if (this.workerSpeech.get(sessionID) === held) this.flushWorkerSpeech(held)
985
+ if (this.workerSpeech.get(sessionID) === held) this.flushWorkerSpeech(sessionID, held)
626
986
  }, STREAM_INTERVAL_MS)
627
987
  return
628
988
  }
629
- this.flushWorkerSpeech(state)
989
+ this.flushWorkerSpeech(sessionID, state)
630
990
  }
631
991
 
632
- private flushWorkerSpeech(state: WorkerSpeech): void {
992
+ /**
993
+ * Speaks a successful worker's result: prefixed with its task's title when several workers are in play, not at
994
+ * all when the voice agent already said it.
995
+ */
996
+ private flushWorkerSpeech(childID: string, state: WorkerSpeech): void {
633
997
  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
- }
998
+ const parts = [...state.parts].sort(([a], [b]) => a - b).filter(([ordinal]) => !this.spoken.has(`${state.messageID} ${ordinal}`))
637
999
  state.parts.clear()
1000
+ if (!parts.length) return
1001
+ if (this.alreadyHeard(state.rootID, parts.map(([, text]) => text).join(" "))) {
1002
+ for (const [ordinal] of parts) remember(this.spoken, `${state.messageID} ${ordinal}`, true)
1003
+ return
1004
+ }
1005
+ const prefix = this.severalWorkers(state.rootID, childID) ? `${this.workerTitle(state.rootID, childID)} finished: ` : ""
1006
+ parts.forEach(([ordinal, text], index) => {
1007
+ this.emitSay(state.rootID, { assistantMessageID: state.messageID, ordinal, text: index === 0 ? prefix + text : text })
1008
+ })
1009
+ remember(this.lastAnnounced, state.rootID, this.now(), ACTIVITY_ROOTS)
638
1010
  }
639
1011
 
640
1012
  private clearWorkerSpeech(sessionID: string): void {
@@ -647,14 +1019,15 @@ export class Registry {
647
1019
  setPresence(deviceID: string, sessionID: string, visible: boolean): void {
648
1020
  const key = `${deviceID} ${sessionID}`
649
1021
  if (visible) remember(this.presence, key, { sessionID, at: this.now() })
650
- else this.presence.delete(key)
1022
+ else if (!this.presence.delete(key)) return
1023
+ this.changed(this.sessionTenant(sessionID))
651
1024
  }
652
1025
 
653
1026
  /** True while some device reported the session's tree visible within PRESENCE_WINDOW_MS. */
654
1027
  watched(sessionID: string): boolean {
655
1028
  const rootID = this.rootOf(sessionID) ?? sessionID
656
1029
  const t = this.now()
657
- for (const entry of this.presence.values()) {
1030
+ for (const entry of [...this.presence.values(), ...this.remotePresence]) {
658
1031
  if (t - entry.at <= PRESENCE_WINDOW_MS && (this.rootOf(entry.sessionID) ?? entry.sessionID) === rootID) return true
659
1032
  }
660
1033
  return false
@@ -663,7 +1036,7 @@ export class Registry {
663
1036
  /** Whether any process exposes the session to voice; undefined when no process reports it. */
664
1037
  exposed(sessionID: string): boolean | undefined {
665
1038
  let reported = false
666
- for (const conn of this.conns.values()) {
1039
+ for (const conn of this.all()) {
667
1040
  const s = this.reports(conn, sessionID)
668
1041
  if (!s) continue
669
1042
  if (isOn(s)) return true
@@ -672,25 +1045,55 @@ export class Registry {
672
1045
  return reported ? false : undefined
673
1046
  }
674
1047
 
675
- /** Process to change a session's exposure: a reporting service process, else the most recent reporter. */
1048
+ /** Process to change a session's exposure: a reporting service process, else the most recent reporter (never launch-bound). */
676
1049
  exposeTarget(sessionID: string): string | undefined {
677
- const reporters = [...this.conns.values()].filter((c) => this.reports(c, sessionID))
1050
+ const reporters = this.all().filter((c) => c.launchID === undefined && this.reports(c, sessionID))
678
1051
  const services = reporters.filter((c) => c.hello.kind === "service")
679
1052
  return this.recent(services.length ? services : reporters, sessionID)
680
1053
  }
681
1054
 
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")
1055
+ /** The launch a connection is bound to (undefined for ordinary connections and unknown ones). */
1056
+ launchOf(connID: string): string | undefined {
1057
+ return this.conn(connID)?.launchID
1058
+ }
1059
+
1060
+ /** The (most recent) connection bound to a launch. */
1061
+ launchConn(launchID: string): string | undefined {
1062
+ let found: Conn | undefined
1063
+ for (const conn of this.all()) if (conn.launchID === launchID && (!found || conn.seq > found.seq)) found = conn
1064
+ return found?.id
1065
+ }
1066
+
1067
+ /**
1068
+ * The machine's (most recently connected) background service process of a harness (default OpenCode); never a
1069
+ * launch-bound connection. Each harness has its own service: a Claude Code service never hides the OpenCode one.
1070
+ */
1071
+ serviceFor(machineID: string, harness: string = HARNESS_OPENCODE): string | undefined {
1072
+ const services = this.services(machineID).filter((c) => helloHarness(c.hello).harness === harness)
685
1073
  return services.sort((a, b) => b.seq - a.seq)[0]?.id
686
1074
  }
687
1075
 
1076
+ /** The harnesses that have a background service process (serviceFor) on the machine. */
1077
+ serviceHarnesses(machineID: string): string[] {
1078
+ return [...new Set(this.services(machineID).map((c) => helloHarness(c.hello).harness))]
1079
+ }
1080
+
1081
+ /** A connection's harness ("opencode" for hellos that predate harness); undefined for an unknown connection. */
1082
+ harnessOf(connID: string): string | undefined {
1083
+ const conn = this.conn(connID)
1084
+ return conn && helloHarness(conn.hello).harness
1085
+ }
1086
+
1087
+ private services(machineID: string): Conn[] {
1088
+ return this.all().filter((c) => c.machine.id === machineID && c.hello.kind === "service" && c.launchID === undefined)
1089
+ }
1090
+
688
1091
  /**
689
1092
  * True when the process reported a session in, or a workspace root equal to, the directory, or its
690
1093
  * machine has a Herdr pane in it.
691
1094
  */
692
1095
  reportsWorkspace(connID: string, directory: string): boolean {
693
- const conn = this.conns.get(connID)
1096
+ const conn = this.conn(connID)
694
1097
  if (!conn || !directory.startsWith("/")) return false
695
1098
  const target = path.posix.resolve(directory)
696
1099
  const herdr = this.terminals(conn.machine.id)?.herdr
@@ -703,10 +1106,36 @@ export class Registry {
703
1106
  }
704
1107
 
705
1108
  hasSession(sessionID: string): boolean {
706
- for (const conn of this.conns.values()) if (conn.sessions.has(sessionID)) return true
1109
+ for (const conn of this.all()) if (conn.sessions.has(sessionID)) return true
707
1110
  return false
708
1111
  }
709
1112
 
1113
+ /**
1114
+ * Idle by real evidence (message re-wakes): the owner process observes the harness natively or through hooks, or
1115
+ * through a harness RPC when it announces "turnEvents" (confirmed turn completion); screen and unknown evidence
1116
+ * never count. Nothing in the session's tree is working or blocked, no hub turn holds it, and a turn that ended by
1117
+ * an inferred interruption ended at least INFERRED_IDLE_GRACE_MS ago (the harness reported none, so the spoke's
1118
+ * guess may be followed at once by the next prompt's start).
1119
+ */
1120
+ idle(sessionID: string): boolean {
1121
+ const owner = this.owner(sessionID)
1122
+ const conn = owner === undefined ? undefined : this.conn(owner)
1123
+ if (!conn) return false
1124
+ const { evidence } = helloHarness(conn.hello)
1125
+ const trusted = evidence === "native" || evidence === "hooks" || (evidence === "rpc" && conn.features.has("turnEvents"))
1126
+ if (!trusted) return false
1127
+ const inferred = this.inferredEnds.get(sessionID)
1128
+ if (inferred !== undefined && this.now() - inferred < INFERRED_IDLE_GRACE_MS) return false
1129
+ return !this.busy(sessionID) && this.browserFields(sessionID).state === "idle"
1130
+ }
1131
+
1132
+ /** How recently a process sent an event for the session (higher: more recent; 0: never). */
1133
+ recency(sessionID: string): number {
1134
+ let latest = 0
1135
+ for (const conn of this.all()) latest = Math.max(latest, conn.activity.get(sessionID) ?? 0)
1136
+ return latest
1137
+ }
1138
+
710
1139
  /** Something in the session's tree is working or waiting for the user (typed input steers then). */
711
1140
  running(sessionID: string): boolean {
712
1141
  const state = this.browserFields(sessionID).state
@@ -716,20 +1145,48 @@ export class Registry {
716
1145
  /** Status tree of the root session that contains `sessionID`. */
717
1146
  status(sessionID: string): LiveStatus[] | undefined {
718
1147
  const board = this.boards.get(sessionID) ?? [...this.boards.values()].find((b) => b.get(sessionID))
719
- return board && this.live(board.snapshot().sessions)
1148
+ if (board) return this.live(board.snapshot().sessions)
1149
+ return this.remoteBoard(sessionID)?.sessions.map((s) => structuredClone(s))
1150
+ }
1151
+
1152
+ /** Another pod's status board that contains the session. */
1153
+ private remoteBoard(sessionID: string): SliceBoard | undefined {
1154
+ if (!this.remoteBoards.size) return undefined
1155
+ const board = this.remoteBoards.get(sessionID)
1156
+ if (board) return board
1157
+ for (const candidate of this.remoteBoards.values()) if (candidate.sessions.some((s) => s.id === sessionID)) return candidate
1158
+ return undefined
1159
+ }
1160
+
1161
+ /** A root's activity feed, here or (remote) on another pod. */
1162
+ private feed(rootID: string): readonly ActivityItem[] | undefined {
1163
+ return this.feeds.get(rootID) ?? (this.boards.has(rootID) ? undefined : this.remoteBoards.get(rootID)?.activity)
1164
+ }
1165
+
1166
+ /** A hub turn runs for the session, on this pod or another. */
1167
+ private busy(sessionID: string): boolean {
1168
+ return this.active.has(sessionID) || this.remoteBusy.has(sessionID)
720
1169
  }
721
1170
 
722
- /** Adds `since` (run start) to working entries. */
1171
+ /** Adds `since` (run start) to working entries and agents' recommendations to permission blockers. */
723
1172
  private live(sessions: SessionStatus[]): LiveStatus[] {
724
1173
  return sessions.map((s) => {
725
1174
  const since = s.state === "working" ? this.since.get(s.id) : undefined
726
- return since === undefined ? s : { ...s, since }
1175
+ const recommended = s.blockers.some((b) => b.kind === "permission" && this.permissions.get(b.id)?.recommendation)
1176
+ const blockers = recommended
1177
+ ? s.blockers.map((b) => {
1178
+ const recommendation = b.kind === "permission" ? this.permissions.get(b.id)?.recommendation : undefined
1179
+ return recommendation ? { ...b, recommendation } : b
1180
+ })
1181
+ : s.blockers
1182
+ const entry = recommended ? { ...s, blockers } : s
1183
+ return since === undefined ? entry : { ...entry, since }
727
1184
  })
728
1185
  }
729
1186
 
730
1187
  catalog(): CatalogMachine[] {
731
1188
  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)) {
1189
+ for (const conn of this.all().sort((a, b) => a.seq - b.seq)) {
733
1190
  const machine = machines.get(conn.machine.id) ?? { id: conn.machine.id, name: conn.machine.name, processes: [] }
734
1191
  machines.set(conn.machine.id, machine)
735
1192
  const terminals = this.terminals(conn.machine.id)
@@ -741,8 +1198,10 @@ export class Registry {
741
1198
  directory,
742
1199
  pid,
743
1200
  mode,
744
- opencodeVersion,
1201
+ opencodeVersion: opencodeVersion ?? "",
745
1202
  pluginVersion,
1203
+ ...helloHarness(conn.hello),
1204
+ features: [...conn.features].sort(),
746
1205
  connected: conn.connected,
747
1206
  lastSeen: conn.lastSeen,
748
1207
  sessions: this.visible(conn).map((s) => {
@@ -753,7 +1212,7 @@ export class Registry {
753
1212
  processID,
754
1213
  machineID: conn.machine.id,
755
1214
  status: this.status(s.id) ?? [],
756
- busy: this.active.has(s.id),
1215
+ busy: this.busy(s.id),
757
1216
  conversation: this.conversations.has(s.id),
758
1217
  }
759
1218
  }),
@@ -767,7 +1226,7 @@ export class Registry {
767
1226
  let info: SessionInfo | undefined
768
1227
  /** Open from any reporting process; a Herdr pane wins over a TUI. */
769
1228
  let terminal: TerminalInfo | undefined
770
- for (const conn of this.conns.values()) {
1229
+ for (const conn of this.all()) {
771
1230
  const s = this.reports(conn, sessionID)
772
1231
  if (s && (!info || s.updated > info.updated)) info = s
773
1232
  if (s?.open && s.terminal && (!terminal || (terminal.source !== "herdr" && s.terminal.source === "herdr"))) terminal = s.terminal
@@ -786,7 +1245,7 @@ export class Registry {
786
1245
  updated: info.updated,
787
1246
  ...(terminal ? { open: true, terminal } : {}),
788
1247
  ...fields,
789
- busy: this.active.has(sessionID),
1248
+ busy: this.busy(sessionID),
790
1249
  conversation: this.conversations.has(sessionID),
791
1250
  status,
792
1251
  activity: this.activity(sessionID),
@@ -812,7 +1271,7 @@ export class Registry {
812
1271
  ...(root.projectName ? { projectName: root.projectName } : {}),
813
1272
  updated: root.updated,
814
1273
  ...this.browserFields(sessionID),
815
- busy: this.active.has(sessionID),
1274
+ busy: this.busy(sessionID),
816
1275
  conversation: this.conversations.has(sessionID),
817
1276
  status: root.status,
818
1277
  activity: root.activity,
@@ -844,7 +1303,7 @@ export class Registry {
844
1303
  : "idle"
845
1304
  const blockers = entries.flatMap((e) => e.blockers.map((b) => ({ ...b, sessionID: e.id, sessionTitle: e.title })))
846
1305
  const rootID = this.rootOf(sessionID)
847
- const item = [...((rootID && this.feeds.get(rootID)) || [])].reverse().find((i) => i.state === "running" && tree.has(i.sessionID))
1306
+ const item = [...((rootID && this.feed(rootID)) || [])].reverse().find((i) => i.state === "running" && tree.has(i.sessionID))
848
1307
  const now = item && { kind: item.kind, ...(item.tool ? { tool: item.tool } : {}), ...(item.summary ? { summary: item.summary } : {}) }
849
1308
  return { exposed, state, blockers, ...(now ? { now } : {}) }
850
1309
  }
@@ -852,14 +1311,14 @@ export class Registry {
852
1311
  /** Recent activity of the root session that contains `sessionID`, newest last. */
853
1312
  activity(sessionID: string): ActivityItem[] {
854
1313
  const rootID = this.rootOf(sessionID)
855
- return rootID ? (this.feeds.get(rootID) ?? []).map((item) => ({ ...item })) : []
1314
+ return rootID ? (this.feed(rootID) ?? []).map((item) => ({ ...item })) : []
856
1315
  }
857
1316
 
858
1317
  /** Root session of the status board that contains `sessionID`. */
859
- private rootOf(sessionID: string): string | undefined {
1318
+ rootOf(sessionID: string): string | undefined {
860
1319
  if (this.boards.has(sessionID)) return sessionID
861
1320
  for (const [rootID, board] of this.boards) if (board.get(sessionID)) return rootID
862
- return undefined
1321
+ return this.remoteBoard(sessionID)?.rootID
863
1322
  }
864
1323
 
865
1324
  /**
@@ -892,11 +1351,16 @@ export class Registry {
892
1351
  const id = text(data.id)
893
1352
  if (!id) return
894
1353
  const summary = text(data.summary)
895
- const details = { ...(summary ? { summary } : {}), ...(data.background === true ? { background: true as const } : {}) }
1354
+ const childSessionID = text(data.childSessionID)
1355
+ // A compact progress event (childSessionID only) adds the child to the item without touching the rest.
1356
+ const details = childSessionID && !("summary" in data)
1357
+ ? { childSessionID }
1358
+ : { ...(summary ? { summary } : {}), ...(data.background === true ? { background: true as const } : {}), ...(childSessionID ? { childSessionID } : {}) }
896
1359
  const existing = find(id)
897
1360
  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 })
1361
+ const next: ActivityItem = { ...existing, ...details }
1362
+ if (next.summary === existing.summary && next.background === existing.background && next.childSessionID === existing.childSessionID) return
1363
+ return this.replace(rootID, existing, next)
900
1364
  }
901
1365
  case "session.tool.success":
902
1366
  case "session.tool.failed": {
@@ -904,7 +1368,15 @@ export class Registry {
904
1368
  if (!existing || existing.state !== "running") return
905
1369
  const failed = event.type === "session.tool.failed"
906
1370
  const error = failed ? (text(data.error) ?? "failed") : undefined
907
- return this.replace(rootID, existing, { ...existing, state: failed ? "failed" : "done", ...(error ? { error } : {}), at })
1371
+ const childSessionID = existing.childSessionID ?? text(data.childSessionID)
1372
+ const took = at - existing.at
1373
+ const name = existing.summary ?? existing.tool ?? "tool"
1374
+ if (took >= LONG_TOOL_MS) {
1375
+ this.step(rootID, "long", `${name} ${failed ? "failed" : "done"} (${Math.round(took / 1000)}s)`)
1376
+ // A subagent's tool call ends with the subagent (its own kinds).
1377
+ if (!childSessionID) this.alert("longTool", rootID, { subject: `${Math.round(took / 1000)}s`, text: `${name} ${failed ? "failed" : "done"}`, key: existing.id })
1378
+ } else this.step(rootID, "tool", name)
1379
+ return this.replace(rootID, existing, { ...existing, state: failed ? "failed" : "done", ...(error ? { error } : {}), ...(childSessionID ? { childSessionID } : {}), at })
908
1380
  }
909
1381
  case "session.reasoning.started":
910
1382
  case "session.reasoning.delta": {
@@ -925,6 +1397,96 @@ export class Registry {
925
1397
  }
926
1398
  }
927
1399
 
1400
+ /**
1401
+ * Steps of interest for activity updates: a subagent starting or ending, a background job ending (with how), the
1402
+ * root's run ending. `before` is the session's status entry before the event (several processes forward the
1403
+ * same events: only actual changes count). Tool calls are reported from observeActivity.
1404
+ */
1405
+ private activitySteps(type: string, sessionID: string, data: Record<string, unknown>, before: LiveStatus | undefined): void {
1406
+ const rootID = this.rootOf(sessionID)
1407
+ if (!rootID || !before) return
1408
+ const child = rootID !== sessionID
1409
+ const title = before.title?.trim() || "A subagent"
1410
+ if (type === "session.execution.started" && child && before.state !== "working") return this.step(rootID, "subagent", `${title} started`)
1411
+ if (type.startsWith("session.execution.") && type !== "session.execution.started") {
1412
+ const ended = { succeeded: "finished", failed: "failed", interrupted: "stopped" }[type.slice("session.execution.".length)]
1413
+ if (!ended || before.state !== "working") return
1414
+ if (child) return this.step(rootID, "subagent", `${title} ${ended}`)
1415
+ return this.step(rootID, "idle", ended === "finished" ? "Finished" : ended === "failed" ? "Failed" : "Stopped")
1416
+ }
1417
+ if (type === "shell.exited" || type === "session.shell.ended" || type === "pty.exited") {
1418
+ const job = before.jobs?.find((j) => j.id === data.id)
1419
+ if (!job || job.status !== "running") return
1420
+ const exit = typeof data.exit === "number" ? data.exit : undefined
1421
+ const how = data.status === "killed" ? "was killed" : data.status === "timeout" ? "timed out" : exit !== undefined ? `exited ${exit}` : "exited"
1422
+ return this.step(rootID, "job", `${job.command ?? (job.kind === "pty" ? "terminal" : "shell")} ${how}`)
1423
+ }
1424
+ }
1425
+
1426
+ /**
1427
+ * Notifications from run, subagent, job and status events (docs/notifications.md). `before` is the session's status
1428
+ * entry before the event: several processes forward the same events, so subagent and job notifications need an
1429
+ * actual change; root outcomes are deduped by the push cooldown.
1430
+ */
1431
+ private notifications(type: string, sessionID: string, data: Record<string, unknown>, before: LiveStatus | undefined): void {
1432
+ const rootID = this.rootOf(sessionID)
1433
+ if (!rootID) return
1434
+ const root = rootID === sessionID
1435
+ const wasRunning = before?.state === "working" || before?.state === "blocked"
1436
+ if (type === "session.execution.started") return this.doneWatch.started(rootID, root)
1437
+ if (type === "session.text.delta" || type === "session.reasoning.delta") return
1438
+ const ended = type === "session.execution.succeeded" ? "succeeded" : type === "session.execution.failed" ? "failed" : type === "session.execution.interrupted" ? "interrupted" : undefined
1439
+ const reason = (data.error as { message?: unknown } | undefined)?.message
1440
+ const error = typeof reason === "string" && reason.trim() ? reason : undefined
1441
+ if (ended && root) {
1442
+ this.doneWatch.rootEnded(rootID, ended, wasRunning)
1443
+ if (ended === "succeeded" && wasRunning && !this.active.has(rootID)) this.alert("turn", rootID)
1444
+ if (ended === "failed") this.alert("failed", rootID, error ? { text: error } : {})
1445
+ const stopped = ended === "interrupted" ? STOP_REASONS[String(data.reason)] : undefined
1446
+ if (stopped) this.alert("stopped", rootID, { text: stopped })
1447
+ return
1448
+ }
1449
+ this.doneWatch.poke(rootID)
1450
+ if (ended && wasRunning && ended !== "interrupted") {
1451
+ const subject = before!.title?.trim() || "subagent"
1452
+ const parent = (before!.parentID && this.status(rootID)?.find((s) => s.id === before!.parentID)?.title?.trim()) || "the session"
1453
+ if (ended === "succeeded") this.alert("subagentDone", rootID, { subject, text: `In ${parent}`, key: sessionID })
1454
+ else this.alert("subagentFailed", rootID, { subject, text: error ?? `In ${parent}`, key: sessionID })
1455
+ return
1456
+ }
1457
+ if (type === "session.status" && root && !before?.retry) {
1458
+ const retry = this.status(rootID)?.find((s) => s.id === rootID)?.retry
1459
+ if (retry) this.alert("retry", rootID, { text: [retry.attempt !== undefined ? `Attempt ${retry.attempt}` : "", retry.message ?? ""].filter(Boolean).join(": ") || "Retrying" })
1460
+ return
1461
+ }
1462
+ if (type === "shell.exited" || type === "session.shell.ended" || type === "pty.exited") {
1463
+ const job = before?.jobs?.find((j) => j.id === data.id)
1464
+ if (!job || job.status !== "running") return
1465
+ const exit = typeof data.exit === "number" ? data.exit : undefined
1466
+ const how = data.status === "killed" ? "was killed" : data.status === "timeout" ? "timed out" : exit !== undefined ? `exited ${exit}` : "exited"
1467
+ const failed = data.status === "killed" || data.status === "timeout" || (exit !== undefined && exit !== 0)
1468
+ this.alert(failed ? "jobFailed" : "jobDone", rootID, { subject: how, text: job.command ?? (job.kind === "pty" ? "terminal" : "shell"), key: job.id })
1469
+ }
1470
+ }
1471
+
1472
+ /** Fires when a root's tree stayed settled after its run (DoneWatch). */
1473
+ private settledTree(rootID: string): boolean {
1474
+ const board = this.boards.get(rootID)
1475
+ if (!board?.settled()) return false
1476
+ for (const id of this.active.keys()) if ((this.rootOf(id) ?? id) === rootID) return false
1477
+ return true
1478
+ }
1479
+
1480
+ /** Reports an activity step of a root session (not while a device shows it). */
1481
+ private step(rootID: string, kind: ActivityStep["kind"], text: string): void {
1482
+ if (this.watched(rootID)) return
1483
+ const board = this.boards.get(rootID)
1484
+ if (!board) return
1485
+ const owner = this.sessionTenant(rootID)
1486
+ const alert: Alert = { kind: "activity", sessionID: rootID, title: board.get(rootID)?.title ?? rootID, step: { kind, text } }
1487
+ for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
1488
+ }
1489
+
928
1490
  /** Thinking also ends when something else happens in the same session (older spokes send no reasoning.ended). */
929
1491
  private closeThinking(rootID: string, sessionID: string): void {
930
1492
  for (const item of this.feeds.get(rootID) ?? []) {
@@ -957,12 +1519,23 @@ export class Registry {
957
1519
  else {
958
1520
  this.conversations.delete(sessionID)
959
1521
  for (const [childID, state] of this.workerSpeech) if (state.rootID === sessionID) this.clearWorkerSpeech(childID)
1522
+ this.dropRootSpeech(sessionID)
960
1523
  }
961
1524
  if (!changed) return
962
1525
  this.emit({ type: "conversation", sessionID, on })
963
1526
  this.emit({ type: "catalog" })
964
1527
  }
965
1528
 
1529
+ /**
1530
+ * Whether a connected process serves a feature (spokeFeatures): a core op (CORE_OPS: turn, steer, interrupt,
1531
+ * stop, permission, form), which an OpenCode spoke implies and a spoke of another harness serves only when it
1532
+ * announced it, or an optional feature, which it announced in its hello (or, for an OpenCode spoke that predates
1533
+ * harness, one in IMPLIED_OPENCODE_FEATURES). Callers answer unsupported {feature, harness} (harnessOf) without it.
1534
+ */
1535
+ supports(connID: string, feature: SpokeFeature): boolean {
1536
+ return this.conn(connID)?.features.has(feature) === true
1537
+ }
1538
+
966
1539
  /**
967
1540
  * Connection that should run a turn for the session:
968
1541
  * 1. a process reporting it as owned, most recent activity first;
@@ -971,9 +1544,10 @@ export class Registry {
971
1544
  * 4. else any process that reports it, most recent activity first;
972
1545
  * 5. else none.
973
1546
  */
1547
+
974
1548
  owner(sessionID: string): string | undefined {
975
1549
  const tenant = this.sessionTenant(sessionID)
976
- const all = [...this.conns.values()].filter((c) => this.tenant(c) === tenant)
1550
+ const all = this.all().filter((c) => this.tenant(c) === tenant)
977
1551
  const reporters = all.filter((c) => this.reports(c, sessionID))
978
1552
  if (reporters.length === 0) {
979
1553
  // A subagent session no process lists runs in the process that runs its root.
@@ -989,6 +1563,20 @@ export class Registry {
989
1563
  return recent(all.filter((c) => c.hello.kind === "service" && machines.has(c.machine.id))) ?? recent(reporters)
990
1564
  }
991
1565
 
1566
+ /**
1567
+ * Connections to try for a session op that any reporter can carry out (e.g. rename): the owner first, then
1568
+ * the other processes of the session's tenant that report it, most recent activity first.
1569
+ */
1570
+ sessionTargets(sessionID: string): string[] {
1571
+ const owner = this.owner(sessionID)
1572
+ if (!owner) return []
1573
+ const tenant = this.sessionTenant(sessionID)
1574
+ const others = this.all()
1575
+ .filter((c) => c.id !== owner && this.tenant(c) === tenant && this.reports(c, sessionID))
1576
+ .sort((a, b) => (b.activity.get(sessionID) ?? 0) - (a.activity.get(sessionID) ?? 0) || b.seq - a.seq)
1577
+ return [owner, ...others.map((c) => c.id)]
1578
+ }
1579
+
992
1580
  /** The connection with the most recent activity for the session, then the most recently connected. */
993
1581
  private recent(list: Conn[], sessionID: string): string | undefined {
994
1582
  return [...list].sort((a, b) => (b.activity.get(sessionID) ?? 0) - (a.activity.get(sessionID) ?? 0) || b.seq - a.seq)[0]?.id
@@ -1004,22 +1592,36 @@ export class Registry {
1004
1592
  const pending = this.status(rootID)?.some((s) =>
1005
1593
  s.blockers.some((b) => b.kind === "permission" && b.id === requestID),
1006
1594
  )
1007
- const origin = this.permissions.get(requestID)
1595
+ const local = this.permissions.get(requestID)
1596
+ const remote = this.remotePermissions.get(requestID)
1597
+ const origin = local ?? remote
1008
1598
  if (!pending || !origin) return []
1009
1599
  const tenant = this.sessionTenant(rootID)
1010
- return origin.connIDs
1011
- .filter((connID) => this.conns.has(connID) && this.connTenant(connID) === tenant)
1600
+ return [...new Set([...(local?.connIDs ?? []), ...(remote?.connIDs ?? [])])]
1601
+ .filter((connID) => this.conn(connID) !== undefined && this.connTenant(connID) === tenant)
1012
1602
  .map((connID) => ({ connID, sessionID: origin.sessionID }))
1013
1603
  }
1014
1604
 
1605
+ /**
1606
+ * A pending permission: the session that asked and what it asks (bounded, without metadata; absent when the
1607
+ * request had no usable action). Callers check tenancy (owns) and that it is still pending in the tree.
1608
+ */
1609
+ permissionRequest(requestID: string): { sessionID: string; request?: PermissionRequest } | undefined {
1610
+ const origin = this.permissions.get(requestID) ?? this.remotePermissions.get(requestID)
1611
+ if (!origin) return undefined
1612
+ return { sessionID: origin.sessionID, ...(origin.request ? { request: structuredClone(origin.request) } : {}) }
1613
+ }
1614
+
1015
1615
  /** Every connected process of the session's tenant that forwarded the pending form, first first. */
1016
1616
  formTargets(rootID: string, formID: string): Array<{ connID: string; sessionID: string }> {
1017
1617
  const pending = this.status(rootID)?.some((s) => s.blockers.some((b) => b.kind === "form" && b.id === formID))
1018
- const origin = this.forms.get(formID)
1618
+ const local = this.forms.get(formID)
1619
+ const remote = this.remoteForms.get(formID)
1620
+ const origin = local ?? remote
1019
1621
  if (!pending || !origin) return []
1020
1622
  const tenant = this.sessionTenant(rootID)
1021
- return origin.connIDs
1022
- .filter((connID) => this.conns.has(connID) && this.connTenant(connID) === tenant)
1623
+ return [...new Set([...(local?.connIDs ?? []), ...(remote?.connIDs ?? [])])]
1624
+ .filter((connID) => this.conn(connID) !== undefined && this.connTenant(connID) === tenant)
1023
1625
  .map((connID) => ({ connID, sessionID: origin.sessionID }))
1024
1626
  }
1025
1627
 
@@ -1029,6 +1631,7 @@ export class Registry {
1029
1631
  if (!origin) return
1030
1632
  this.forms.delete(formID)
1031
1633
  this.observeBoards({ type: "form.cancelled", data: { sessionID: origin.sessionID, id: formID } })
1634
+ this.changed(this.sessionTenant(origin.sessionID))
1032
1635
  }
1033
1636
 
1034
1637
  /** A permission no process holds any more (answered elsewhere, expired): drop it and its blocker. */
@@ -1037,6 +1640,45 @@ export class Registry {
1037
1640
  if (!origin) return
1038
1641
  this.permissions.delete(requestID)
1039
1642
  this.observeBoards({ type: "permission.replied", data: { sessionID: origin.sessionID, requestID } })
1643
+ this.changed(origin.tenant ?? this.sessionTenant(origin.sessionID))
1644
+ this.permissionClosed(requestID, origin)
1645
+ }
1646
+
1647
+ /** Listens for pending permissions going away (replied, dropped, every process holding one gone). */
1648
+ onPermissionClosed(listener: (requestID: string, sessionID: string, tenant: string | undefined) => void): () => void {
1649
+ this.permissionClosedListeners.add(listener)
1650
+ return () => this.permissionClosedListeners.delete(listener)
1651
+ }
1652
+
1653
+ /**
1654
+ * Shows an agent's recommendation on a pending permission's blocker (sanitised; the status goes out again);
1655
+ * false when no such request is pending. It goes with the request.
1656
+ */
1657
+ recommend(requestID: string, recommendation: BlockerRecommendation): boolean {
1658
+ const origin = this.permissions.get(requestID)
1659
+ if (!origin) return false
1660
+ const why = recommendation.why === undefined ? "" : sanitizeLabel(recommendation.why, RECOMMENDATION_WHY_MAX)
1661
+ origin.recommendation = {
1662
+ by: sanitizeLabel(recommendation.by, RECOMMENDATION_BY_MAX) || "An agent",
1663
+ recommends: "approve",
1664
+ ...(why ? { why } : {}),
1665
+ ...(recommendation.decisionID && DECISION_ID.test(recommendation.decisionID) ? { decisionID: recommendation.decisionID } : {}),
1666
+ }
1667
+ const rootID = this.rootOf(origin.sessionID)
1668
+ const board = rootID === undefined ? undefined : this.boards.get(rootID)
1669
+ if (rootID !== undefined && board) this.emit({ type: "status", sessionID: rootID, sessions: this.live(board.snapshot().sessions) })
1670
+ this.changed(origin.tenant ?? this.sessionTenant(origin.sessionID))
1671
+ return true
1672
+ }
1673
+
1674
+ private permissionClosed(requestID: string, origin: PendingPermission): void {
1675
+ for (const listener of [...this.permissionClosedListeners]) {
1676
+ try {
1677
+ listener(requestID, origin.sessionID, origin.tenant)
1678
+ } catch (cause) {
1679
+ console.error(`[hub] permission ${requestID}: closed listener failed: ${cause instanceof Error ? cause.message : String(cause)}`)
1680
+ }
1681
+ }
1040
1682
  }
1041
1683
 
1042
1684
  /**
@@ -1044,14 +1686,17 @@ export class Registry {
1044
1686
  * interrupt releases the lock first (see cancelTurns), so the waiting request can end.
1045
1687
  */
1046
1688
  beginTurn(sessionID: string, onCancel?: () => void): () => void {
1047
- if (this.active.has(sessionID)) throw new BusyError()
1689
+ if (this.busy(sessionID)) throw new BusyError()
1048
1690
  const turn = { cancel: onCancel }
1049
1691
  this.active.set(sessionID, turn)
1692
+ this.changed(this.sessionTenant(sessionID))
1050
1693
  this.emit({ type: "catalog" })
1051
1694
  return () => {
1052
1695
  if (this.active.get(sessionID) !== turn) return // already released, or cancelled and taken by a newer turn
1053
1696
  this.active.delete(sessionID)
1697
+ this.changed(this.sessionTenant(sessionID))
1054
1698
  this.emit({ type: "catalog" })
1699
+ this.doneWatch.poke(this.rootOf(sessionID) ?? sessionID)
1055
1700
  }
1056
1701
  }
1057
1702
 
@@ -1061,6 +1706,7 @@ export class Registry {
1061
1706
  const ended = [...this.active].filter(([id]) => id === sessionID || (this.rootOf(id) ?? id) === rootID)
1062
1707
  if (!ended.length) return
1063
1708
  for (const [id] of ended) this.active.delete(id)
1709
+ this.changed(this.sessionTenant(sessionID))
1064
1710
  this.emit({ type: "catalog" })
1065
1711
  for (const [, turn] of ended) {
1066
1712
  try {
@@ -1097,7 +1743,11 @@ export class Registry {
1097
1743
  }
1098
1744
  }
1099
1745
 
1100
- for (const id of this.boards.keys()) if (!reported.has(id) || rootOf(id) !== id) this.boards.delete(id)
1746
+ for (const id of this.boards.keys()) {
1747
+ if (reported.has(id) && rootOf(id) === id) continue
1748
+ this.boards.delete(id)
1749
+ this.doneWatch.forget(id)
1750
+ }
1101
1751
 
1102
1752
  const children: SessionInfo[] = []
1103
1753
  for (const s of reported.values()) {
@@ -1130,17 +1780,177 @@ export class Registry {
1130
1780
  }
1131
1781
  }
1132
1782
 
1133
- private alert(kind: Alert["kind"], sessionID: string, detail: Alert["detail"], text?: string, requestID?: string): void {
1783
+ // ---------- cluster (design §9) ----------
1784
+
1785
+ /** Listens for changes to this pod's state of a tenant (undefined: any tenant may have changed). */
1786
+ onLocalChange(listener: (tenant: string | undefined) => void): () => void {
1787
+ this.localChangeListeners.add(listener)
1788
+ return () => this.localChangeListeners.delete(listener)
1789
+ }
1790
+
1791
+ private changed(tenant: string | undefined): void {
1792
+ for (const listener of [...this.localChangeListeners]) {
1793
+ try {
1794
+ listener(tenant)
1795
+ } catch (cause) {
1796
+ console.error(`[hub] registry change listener failed: ${cause instanceof Error ? cause.message : String(cause)}`)
1797
+ }
1798
+ }
1799
+ }
1800
+
1801
+ /** Tenants this pod holds state of (connections, boards, pending permissions and forms, presence, turns). */
1802
+ clusterTenants(): string[] {
1803
+ const tenants = new Set<string>()
1804
+ for (const conn of this.conns.values()) tenants.add(this.tenant(conn))
1805
+ const add = (tenant: string | undefined) => tenant !== undefined && tenants.add(tenant)
1806
+ for (const rootID of this.boards.keys()) add(this.sessionTenant(rootID))
1807
+ for (const origin of this.permissions.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
1808
+ for (const origin of this.forms.values()) add(this.sessionTenant(origin.sessionID))
1809
+ for (const entry of this.presence.values()) add(this.sessionTenant(entry.sessionID))
1810
+ for (const sessionID of this.active.keys()) add(this.sessionTenant(sessionID))
1811
+ return [...tenants]
1812
+ }
1813
+
1814
+ /** This pod's state of a tenant for other pods (undefined when there is none). Remote state is not included. */
1815
+ clusterSlice(tenant: string): RegistrySlice | undefined {
1816
+ const conns: SliceConn[] = [...this.conns.values()]
1817
+ .filter((conn) => this.tenant(conn) === tenant)
1818
+ .map((conn) => {
1819
+ const terminals = this.machineTerminals.get(conn.machine.id)
1820
+ return {
1821
+ id: conn.id,
1822
+ machine: { id: conn.machine.id, name: conn.machine.name },
1823
+ hello: conn.hello,
1824
+ connected: conn.connected,
1825
+ seq: conn.seq,
1826
+ lastSeen: conn.lastSeen,
1827
+ sessions: [...conn.sessions.values()],
1828
+ activity: [...conn.activity],
1829
+ ...(conn.launchID !== undefined ? { launchID: conn.launchID } : {}),
1830
+ ...(terminals?.connID === conn.id ? { terminals: terminals.terminals } : {}),
1831
+ }
1832
+ })
1833
+ const boards: SliceBoard[] = [...this.boards]
1834
+ .filter(([rootID]) => this.sessionTenant(rootID) === tenant)
1835
+ .map(([rootID, board]) => ({ rootID, sessions: this.live(board.snapshot().sessions), activity: this.feeds.get(rootID) ?? [] }))
1836
+ const permissions: SlicePermission[] = [...this.permissions]
1837
+ .filter(([, origin]) => (origin.tenant ?? this.sessionTenant(origin.sessionID)) === tenant)
1838
+ .map(([requestID, origin]) => ({
1839
+ requestID,
1840
+ sessionID: origin.sessionID,
1841
+ connIDs: [...origin.connIDs],
1842
+ ...(origin.request ? { request: origin.request } : {}),
1843
+ ...(origin.recommendation ? { recommendation: origin.recommendation } : {}),
1844
+ }))
1845
+ const forms: SliceForm[] = [...this.forms]
1846
+ .filter(([, origin]) => this.sessionTenant(origin.sessionID) === tenant)
1847
+ .map(([formID, origin]) => ({ formID, sessionID: origin.sessionID, connIDs: [...origin.connIDs] }))
1848
+ const t = this.now()
1849
+ const presence = [...this.presence]
1850
+ .filter(([, entry]) => t - entry.at <= PRESENCE_WINDOW_MS && this.sessionTenant(entry.sessionID) === tenant)
1851
+ .map(([key, entry]) => ({ deviceID: key.slice(0, key.lastIndexOf(` ${entry.sessionID}`)), sessionID: entry.sessionID, at: entry.at }))
1852
+ const busy = [...this.active.keys()].filter((sessionID) => this.sessionTenant(sessionID) === tenant)
1853
+ if (!conns.length && !boards.length && !permissions.length && !forms.length && !presence.length && !busy.length) return undefined
1854
+ return structuredClone({ conns, boards, permissions, forms, presence, busy })
1855
+ }
1856
+
1857
+ /**
1858
+ * Applies another pod's slice of a tenant (undefined: it has none any more, e.g. the pod is gone). Remote
1859
+ * connections answer the reads like local ones; listeners get a catalog message.
1860
+ */
1861
+ setRemote(pod: string, tenant: string, slice: RegistrySlice | undefined): void {
1862
+ let slices = this.remoteSlices.get(pod)
1863
+ if (slice) {
1864
+ if (!slices) this.remoteSlices.set(pod, (slices = new Map()))
1865
+ slices.set(tenant, slice)
1866
+ } else {
1867
+ if (!slices?.delete(tenant)) return
1868
+ if (!slices.size) this.remoteSlices.delete(pod)
1869
+ }
1870
+ this.reindex()
1871
+ this.emit({ type: "catalog" })
1872
+ }
1873
+
1874
+ /** The pods whose slices are applied here, with their tenants. */
1875
+ remotePods(): Map<string, string[]> {
1876
+ return new Map([...this.remoteSlices].map(([pod, slices]) => [pod, [...slices.keys()]]))
1877
+ }
1878
+
1879
+ /** Rebuilds the indexes of the remote slices. */
1880
+ private reindex(): void {
1881
+ const conns = new Map<string, Conn>()
1882
+ const boards = new Map<string, SliceBoard>()
1883
+ const permissions = new Map<string, { sessionID: string; tenant: string; connIDs: string[]; request?: PermissionRequest; recommendation?: BlockerRecommendation }>()
1884
+ const forms = new Map<string, { sessionID: string; connIDs: string[] }>()
1885
+ const presence: Array<{ sessionID: string; at: number }> = []
1886
+ const busy = new Set<string>()
1887
+ const terminals = new Map<string, MachineTerminals>()
1888
+ for (const [pod, slices] of this.remoteSlices) {
1889
+ for (const [tenant, slice] of slices) {
1890
+ for (const c of slice.conns ?? []) {
1891
+ if (this.conns.has(c.id)) continue
1892
+ conns.set(c.id, {
1893
+ id: c.id,
1894
+ machine: c.machine,
1895
+ hello: c.hello,
1896
+ features: spokeFeatures(c.hello),
1897
+ connected: c.connected,
1898
+ seq: c.seq,
1899
+ lastSeen: c.lastSeen,
1900
+ sessions: new Map((c.sessions ?? []).map((s) => [s.id, s])),
1901
+ sessionsKey: "",
1902
+ activity: new Map(c.activity ?? []),
1903
+ ...(c.launchID !== undefined ? { launchID: c.launchID } : {}),
1904
+ remote: { pod, tenant },
1905
+ })
1906
+ if (c.terminals) terminals.set(c.machine.id, c.terminals)
1907
+ }
1908
+ for (const board of slice.boards ?? []) boards.set(board.rootID, board)
1909
+ for (const p of slice.permissions ?? []) {
1910
+ const known = permissions.get(p.requestID)
1911
+ if (known) known.connIDs.push(...p.connIDs.filter((id) => !known.connIDs.includes(id)))
1912
+ else permissions.set(p.requestID, { sessionID: p.sessionID, tenant, connIDs: [...p.connIDs], ...(p.request ? { request: p.request } : {}), ...(p.recommendation ? { recommendation: p.recommendation } : {}) })
1913
+ }
1914
+ for (const f of slice.forms ?? []) {
1915
+ const known = forms.get(f.formID)
1916
+ if (known) known.connIDs.push(...f.connIDs.filter((id) => !known.connIDs.includes(id)))
1917
+ else forms.set(f.formID, { sessionID: f.sessionID, connIDs: [...f.connIDs] })
1918
+ }
1919
+ for (const entry of slice.presence ?? []) presence.push({ sessionID: entry.sessionID, at: entry.at })
1920
+ for (const sessionID of slice.busy ?? []) busy.add(sessionID)
1921
+ }
1922
+ }
1923
+ this.remoteConns = conns
1924
+ this.remoteBoards = boards
1925
+ this.remotePermissions = permissions
1926
+ this.remoteForms = forms
1927
+ this.remotePresence = presence
1928
+ this.remoteBusy = busy
1929
+ this.remoteTerminals = terminals
1930
+ }
1931
+
1932
+ /** Raises a notification about the session for its user (e.g. the delivery engine's unanswered asks). */
1933
+ alertUser(kind: NotificationKind, sessionID: string, fields: { text?: string; subject?: string; key?: string } = {}): void {
1934
+ this.alert(kind, sessionID, fields)
1935
+ }
1936
+
1937
+ /**
1938
+ * Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
1939
+ * session some device shows (spoken replies check that themselves: the watching device speaks them).
1940
+ */
1941
+ private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string } = {}): void {
1134
1942
  const entry = [...this.boards].find(([, board]) => board.get(sessionID))
1135
1943
  if (!entry) return
1136
1944
  const [rootID, board] = entry
1945
+ if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return
1137
1946
  const alert: Alert = {
1138
1947
  kind,
1139
1948
  sessionID: rootID,
1140
1949
  title: board.get(rootID)?.title ?? rootID,
1141
- ...(detail ? { detail } : {}),
1142
- ...(text !== undefined ? { text } : {}),
1143
- ...(requestID !== undefined ? { requestID } : {}),
1950
+ ...(fields.subject !== undefined ? { subject: fields.subject } : {}),
1951
+ ...(fields.text !== undefined ? { text: fields.text } : {}),
1952
+ ...(fields.requestID !== undefined ? { requestID: fields.requestID } : {}),
1953
+ ...(fields.key !== undefined ? { key: fields.key } : {}),
1144
1954
  }
1145
1955
  const owner = this.sessionTenant(rootID)
1146
1956
  for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)