@huynle/hub 0.8.2 → 0.8.4

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 (68) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +31 -0
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +35 -7
  6. package/packages/cli/package.json +1 -1
  7. package/packages/codex/package.json +1 -1
  8. package/packages/node/package.json +1 -1
  9. package/packages/preset/package.json +1 -1
  10. package/packages/protocol/package.json +1 -1
  11. package/packages/protocol/src/index.ts +16 -3
  12. package/packages/protocol/src/voice-recording.ts +107 -0
  13. package/packages/runtime-openshell/package.json +1 -1
  14. package/packages/runtime-sdk/package.json +1 -1
  15. package/packages/sdk/package.json +1 -1
  16. package/packages/spoke-sdk/package.json +1 -1
  17. package/src/central/agent/routes.ts +7 -1
  18. package/src/central/artifacts/blob-store.ts +6 -1
  19. package/src/central/artifacts/blob-support.ts +3 -1
  20. package/src/central/concierge/board-render.ts +34 -11
  21. package/src/central/concierge/index.ts +1 -1
  22. package/src/central/concierge/notifier.ts +2 -1
  23. package/src/central/config.ts +4 -0
  24. package/src/central/csp.ts +55 -0
  25. package/src/central/http.ts +85 -9
  26. package/src/central/profiles/content.ts +16 -0
  27. package/src/central/recording/clock.ts +76 -0
  28. package/src/central/recording/concierge-taps.ts +104 -0
  29. package/src/central/recording/export.ts +215 -0
  30. package/src/central/recording/index.ts +658 -0
  31. package/src/central/recording/ingest.ts +209 -0
  32. package/src/central/recording/retention.ts +71 -0
  33. package/src/central/recording/routes.ts +281 -0
  34. package/src/central/recording/schema.ts +232 -0
  35. package/src/central/recording/settings.ts +35 -0
  36. package/src/central/recording/store.ts +316 -0
  37. package/src/central/recording/taps.ts +105 -0
  38. package/src/central/recording/zip.ts +138 -0
  39. package/src/central/registry.ts +161 -25
  40. package/src/central/spoke-ws.ts +2 -0
  41. package/src/index.ts +28 -0
  42. package/src/spoke/session-context.ts +28 -8
  43. package/src/spoke/spoke.ts +153 -10
  44. package/src/spoke/tracker.ts +6 -1
  45. package/src/status.ts +101 -19
  46. package/web/dist/assets/{ArtifactPage-R0ZTpHca.js → ArtifactPage-Cb1DcrQf.js} +1 -1
  47. package/web/dist/assets/{ArtifactPane-C9KrBna8.js → ArtifactPane-XTnwXl-Y.js} +1 -1
  48. package/web/dist/assets/{ArtifactWorkbench-sP2mZccC.js → ArtifactWorkbench-C37tZZ3e.js} +1 -1
  49. package/web/dist/assets/{Library-BXbJvTUS.js → Library-CZSSHvOJ.js} +2 -2
  50. package/web/dist/assets/ProfilesDestination-dZa3vioh.js +1 -0
  51. package/web/dist/assets/{TeamsPage-Dgxt8UI3.js → TeamsPage-DJt-Kk84.js} +1 -1
  52. package/web/dist/assets/Watchtower-BXaUaXO1.js +2 -0
  53. package/web/dist/assets/index-DVSkeAvn.css +1 -0
  54. package/web/dist/assets/index-DifgVuKS.js +34 -0
  55. package/web/dist/assets/{live-OgQWPrSY.js → live-D-RZbeUi.js} +1 -1
  56. package/web/dist/assets/{pane-layout-DWXfDU-1.js → pane-layout-Csk-vPNe.js} +1 -1
  57. package/web/dist/assets/{renderer2d-DjEipyto.js → renderer2d-DILxLFEq.js} +1 -1
  58. package/web/dist/assets/renderer3d-Co5yCKMg.css +1 -0
  59. package/web/dist/assets/{renderer3d-B648jKGO.js → renderer3d-PvN9Qbt2.js} +4 -56
  60. package/web/dist/assets/{safe-area-CNLyboIj.js → safe-area-BS6ii3M7.js} +1 -1
  61. package/web/dist/assets/{source-CDU04kYw.js → source-BFLJQ43_.js} +1 -1
  62. package/web/dist/assets/{ui-Dh1-78Cl.js → ui-Qo2Pd9-O.js} +1 -1
  63. package/web/dist/index.html +5 -16
  64. package/web/dist/theme-boot.js +12 -0
  65. package/web/dist/assets/ProfilesDestination-D7XyLTJ7.js +0 -1
  66. package/web/dist/assets/Watchtower-DJHx6cWw.js +0 -2
  67. package/web/dist/assets/index-BabrNDB3.css +0 -1
  68. package/web/dist/assets/index-g4V9mfSr.js +0 -34
@@ -0,0 +1,105 @@
1
+ // REC-01 registry taps (contract: Event schema → Hub): turn.started / first.token / turn.ended of the recorded root
2
+ // session, tool rows (summary only, never the output), say.sent per spoken sentence, reply.full, utterance.delivered.
3
+ // The registry calls these only from the session owner's connection; everything here is synchronous and cheap, the
4
+ // writes happen in the recording service (asynchronous, best effort).
5
+ import type { HubEventKind } from "../../protocol"
6
+ import type { RecordingTap } from "../registry"
7
+
8
+ export interface TapSink {
9
+ /** Records a hub event into every active recording of (userID, sessionID); a no-op without one. */
10
+ hub(userID: string, sessionID: string, kind: HubEventKind, fields: Record<string, unknown>): void
11
+ /** False when the user surely has no active recording (a fresh, empty cache); true when unsure. */
12
+ mayRecord(userID: string): boolean
13
+ }
14
+
15
+ const OUTCOMES: Record<string, "succeeded" | "failed" | "interrupted"> = {
16
+ "session.execution.succeeded": "succeeded",
17
+ "session.execution.failed": "failed",
18
+ "session.execution.interrupted": "interrupted",
19
+ }
20
+ const TOOL_STATES: Record<string, "called" | "success" | "failed"> = {
21
+ "session.tool.called": "called",
22
+ "session.tool.success": "success",
23
+ "session.tool.failed": "failed",
24
+ }
25
+
26
+ const remember = <K, V>(map: Map<K, V>, key: K, value: V, max = 1000) => {
27
+ map.delete(key)
28
+ map.set(key, value)
29
+ if (map.size > max) map.delete(map.keys().next().value as K)
30
+ }
31
+
32
+ export class RegistryTaps implements RecordingTap {
33
+ /** Root sessions whose turn started and whose first text delta is not seen yet. */
34
+ private readonly firstToken = new Set<string>()
35
+ /** Tool names by call ID (session.tool.input.started carries the name, the later events do not). */
36
+ private readonly tools = new Map<string, string>()
37
+
38
+ constructor(private readonly sink: TapSink) {}
39
+
40
+ wants(tenant: string, sessionID: string, type: string): boolean {
41
+ if (type === "session.text.delta") return this.firstToken.has(sessionID) && this.sink.mayRecord(tenant)
42
+ if (type === "session.execution.started") return true
43
+ return this.sink.mayRecord(tenant)
44
+ }
45
+
46
+ event(tenant: string, sessionID: string, type: string, data: Record<string, unknown>): void {
47
+ if (type === "session.execution.started") {
48
+ this.firstToken.add(sessionID)
49
+ if (this.firstToken.size > 1000) this.firstToken.delete(this.firstToken.values().next().value as string)
50
+ this.sink.hub(tenant, sessionID, "turn.started", {})
51
+ return
52
+ }
53
+ const outcome = OUTCOMES[type]
54
+ if (outcome) {
55
+ this.firstToken.delete(sessionID)
56
+ this.sink.hub(tenant, sessionID, "turn.ended", { outcome })
57
+ return
58
+ }
59
+ if (type === "session.text.delta") {
60
+ if (!this.firstToken.delete(sessionID)) return
61
+ const { assistantMessageID: messageID, ordinal } = data
62
+ this.sink.hub(tenant, sessionID, "first.token", { ...(typeof messageID === "string" ? { messageID } : {}), ...(typeof ordinal === "number" ? { ordinal } : {}) })
63
+ return
64
+ }
65
+ const callID = typeof data.id === "string" ? data.id : undefined
66
+ if (type === "session.tool.input.started") {
67
+ if (callID && typeof data.name === "string") remember(this.tools, callID, data.name.slice(0, 120))
68
+ return
69
+ }
70
+ const state = TOOL_STATES[type]
71
+ if (!state || !callID) return
72
+ const tool = this.tools.get(callID)
73
+ if (state !== "called") this.tools.delete(callID)
74
+ // The summary only (summarizeToolInput on the spoke): never the input itself, never the output or error.
75
+ const summary = state === "called" && typeof data.summary === "string" ? data.summary : undefined
76
+ this.sink.hub(tenant, sessionID, "tool", { callID, ...(tool ? { tool } : {}), ...(summary !== undefined ? { summary } : {}), state })
77
+ }
78
+
79
+ sentence(tenant: string, sessionID: string, messageID: string, ordinal: number, seq: number, text: string): void {
80
+ this.sink.hub(tenant, sessionID, "say.sent", { messageID, ordinal, seq, text })
81
+ }
82
+
83
+ reply(tenant: string, sessionID: string, messageID: string, ordinal: number, text: string): void {
84
+ if (!text.trim()) return
85
+ this.sink.hub(tenant, sessionID, "reply.full", { messageID, ordinal, text })
86
+ }
87
+
88
+ heard(tenant: string, sessionID: string, inboxID: string): void {
89
+ this.sink.hub(tenant, sessionID, "utterance.delivered", { inboxID })
90
+ }
91
+ }
92
+
93
+ /** A TTS ref header "<messageID>:<ordinal>:<seq>:<i>" (seq may be empty); undefined when malformed. */
94
+ export function parseTtsRef(header: string | null): { messageID: string; ordinal: number; seq?: number; i: number } | undefined {
95
+ if (!header || header.length > 400) return undefined
96
+ const parts = header.split(":")
97
+ if (parts.length < 4) return undefined
98
+ const i = Number(parts.pop())
99
+ const seqText = parts.pop()!
100
+ const ordinal = Number(parts.pop())
101
+ const messageID = parts.join(":")
102
+ const seq = seqText === "" ? undefined : Number(seqText)
103
+ if (!messageID || !Number.isSafeInteger(i) || !Number.isSafeInteger(ordinal) || (seq !== undefined && !Number.isSafeInteger(seq))) return undefined
104
+ return { messageID, ordinal, ...(seq !== undefined ? { seq } : {}), i }
105
+ }
@@ -0,0 +1,138 @@
1
+ // REC-01 streaming zip writer for recording exports (contract: Export response): the STORE method (no
2
+ // compression; WAV hardly compresses), CRC-32 (Bun.hash.crc32), data descriptors (flag bit 3) so nothing is
3
+ // buffered per entry, UTF-8 names (flag bit 11), no ZIP64 (RECORDING_LIMITS.userBytes keeps an export far below 4 GiB).
4
+
5
+ export interface ZipEntry {
6
+ /** Path inside the archive ("manifest.json", "<id>/audio/000001.wav"). */
7
+ readonly name: string
8
+ /** The entry's bytes, read once when the writer reaches it; undefined writes an empty entry. */
9
+ body(): Promise<Uint8Array | ReadableStream<Uint8Array> | undefined>
10
+ }
11
+
12
+ const FLAGS = 0x0008 | 0x0800
13
+ /** 1980-01-01 00:00 in DOS time (the exports carry their own timestamps in the manifest). */
14
+ const DOS_TIME = 0
15
+ const DOS_DATE = (0 << 9) | (1 << 5) | 1
16
+
17
+ interface Written {
18
+ readonly name: Uint8Array
19
+ readonly offset: number
20
+ readonly crc: number
21
+ readonly size: number
22
+ }
23
+
24
+ const le = (size: number) => {
25
+ const bytes = new Uint8Array(size)
26
+ return { bytes, view: new DataView(bytes.buffer) }
27
+ }
28
+
29
+ function localHeader(name: Uint8Array): Uint8Array {
30
+ const { bytes, view } = le(30 + name.length)
31
+ view.setUint32(0, 0x04034b50, true)
32
+ view.setUint16(4, 20, true)
33
+ view.setUint16(6, FLAGS, true)
34
+ view.setUint16(8, 0, true)
35
+ view.setUint16(10, DOS_TIME, true)
36
+ view.setUint16(12, DOS_DATE, true)
37
+ // CRC and sizes follow in the data descriptor.
38
+ view.setUint16(26, name.length, true)
39
+ bytes.set(name, 30)
40
+ return bytes
41
+ }
42
+
43
+ function descriptor(crc: number, size: number): Uint8Array {
44
+ const { bytes, view } = le(16)
45
+ view.setUint32(0, 0x08074b50, true)
46
+ view.setUint32(4, crc, true)
47
+ view.setUint32(8, size, true)
48
+ view.setUint32(12, size, true)
49
+ return bytes
50
+ }
51
+
52
+ function centralEntry(entry: Written): Uint8Array {
53
+ const { bytes, view } = le(46 + entry.name.length)
54
+ view.setUint32(0, 0x02014b50, true)
55
+ view.setUint16(4, 20, true)
56
+ view.setUint16(6, 20, true)
57
+ view.setUint16(8, FLAGS, true)
58
+ view.setUint16(10, 0, true)
59
+ view.setUint16(12, DOS_TIME, true)
60
+ view.setUint16(14, DOS_DATE, true)
61
+ view.setUint32(16, entry.crc, true)
62
+ view.setUint32(20, entry.size, true)
63
+ view.setUint32(24, entry.size, true)
64
+ view.setUint16(28, entry.name.length, true)
65
+ view.setUint32(42, entry.offset, true)
66
+ bytes.set(entry.name, 46)
67
+ return bytes
68
+ }
69
+
70
+ function endOfDirectory(count: number, size: number, offset: number): Uint8Array {
71
+ const { bytes, view } = le(22)
72
+ view.setUint32(0, 0x06054b50, true)
73
+ view.setUint16(8, count, true)
74
+ view.setUint16(10, count, true)
75
+ view.setUint32(12, size, true)
76
+ view.setUint32(16, offset, true)
77
+ return bytes
78
+ }
79
+
80
+ /** The archive of `entries`, streamed: each entry's body is read only when the reader gets to it. */
81
+ export function zipStream(entries: Iterable<ZipEntry> | AsyncIterable<ZipEntry>): ReadableStream<Uint8Array> {
82
+ async function* produce(): AsyncGenerator<Uint8Array> {
83
+ const written: Written[] = []
84
+ let offset = 0
85
+ for await (const entry of entries as AsyncIterable<ZipEntry>) {
86
+ const name = new TextEncoder().encode(entry.name)
87
+ const header = localHeader(name)
88
+ const start = offset
89
+ yield header
90
+ offset += header.length
91
+ let crc = 0
92
+ let size = 0
93
+ const body = await entry.body()
94
+ const take = (chunk: Uint8Array) => {
95
+ crc = Bun.hash.crc32(chunk, crc)
96
+ size += chunk.length
97
+ offset += chunk.length
98
+ }
99
+ if (body instanceof Uint8Array) {
100
+ take(body)
101
+ if (body.length) yield body
102
+ } else if (body) {
103
+ for await (const chunk of body as unknown as AsyncIterable<Uint8Array>) {
104
+ take(chunk)
105
+ if (chunk.length) yield chunk
106
+ }
107
+ }
108
+ crc >>>= 0
109
+ const tail = descriptor(crc, size)
110
+ yield tail
111
+ offset += tail.length
112
+ written.push({ name, offset: start, crc, size })
113
+ }
114
+ const directoryStart = offset
115
+ let directorySize = 0
116
+ for (const entry of written) {
117
+ const bytes = centralEntry(entry)
118
+ directorySize += bytes.length
119
+ yield bytes
120
+ }
121
+ yield endOfDirectory(written.length, directorySize, directoryStart)
122
+ }
123
+ const iterator = produce()
124
+ return new ReadableStream<Uint8Array>({
125
+ async pull(controller) {
126
+ try {
127
+ const { done, value } = await iterator.next()
128
+ if (done) controller.close()
129
+ else controller.enqueue(value)
130
+ } catch (cause) {
131
+ controller.error(cause)
132
+ }
133
+ },
134
+ async cancel() {
135
+ await iterator.return(undefined)
136
+ },
137
+ })
138
+ }
@@ -12,7 +12,7 @@
12
12
  // holds the connection; remote state is never re-exported.
13
13
  import path from "node:path"
14
14
  import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type RevertCommitted, type SessionRevertInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
15
- import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
15
+ import { BLOCKER_ID_MAX, StatusBoard, askBlocker, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
16
16
  import { sanitizeLabel } from "./agent/envelope"
17
17
  import { DoneWatch } from "./done"
18
18
  import { SentenceStream } from "./say-stream"
@@ -20,6 +20,23 @@ import type { ActivityStep, NotificationKind } from "./push"
20
20
 
21
21
  export { ACTIVITY_INTERVAL_MINUTES } from "./store"
22
22
  /** A tool call that took at least this long is named in activity updates (shorter ones are only counted). */
23
+ /**
24
+ * (REC-01) Voice recording taps: what the hub sees of a recorded root session (docs/voice-recording.md). Called
25
+ * only from the session owner's connection (deduped like speakDelta), never changing behaviour; the tap writes
26
+ * only into recordings of `tenant` (the session's owner) and answers at once (its writes are asynchronous).
27
+ */
28
+ export interface RecordingTap {
29
+ /** Cheap pre-check before an event is tapped (e.g. a text delta only while a first token is awaited). */
30
+ wants(tenant: string, sessionID: string, type: string): boolean
31
+ event(tenant: string, sessionID: string, type: string, data: Record<string, unknown>): void
32
+ sentence(tenant: string, sessionID: string, messageID: string, ordinal: number, seq: number, text: string): void
33
+ reply(tenant: string, sessionID: string, messageID: string, ordinal: number, text: string): void
34
+ heard(tenant: string, sessionID: string, inboxID: string): void
35
+ }
36
+
37
+ /** Spoke events a RecordingTap sees (root sessions only). */
38
+ const TAPPED = new Set(["session.execution.started", "session.execution.succeeded", "session.execution.failed", "session.execution.interrupted", "session.tool.input.started", "session.tool.called", "session.tool.success", "session.tool.failed", "session.text.delta"])
39
+
23
40
  export const LONG_TOOL_MS = 30_000
24
41
 
25
42
  export class BusyError extends Error {
@@ -170,6 +187,17 @@ interface PendingPermission {
170
187
  readonly tenant?: string
171
188
  readonly request?: PermissionRequest
172
189
  recommendation?: BlockerRecommendation
190
+ /** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
191
+ readonly blocker?: Blocker
192
+ }
193
+
194
+ /** A pending form and the processes that forwarded it. */
195
+ interface PendingForm {
196
+ connIDs: string[]
197
+ readonly sessionID: string
198
+ readonly tenant?: string
199
+ /** Its blocker (bounded, askBlocker), to put back on a status board that lost it (see restoreBlockers). */
200
+ readonly blocker?: Blocker
173
201
  }
174
202
 
175
203
  /** Characters of a recommendation's author and reason on a blocker. */
@@ -382,6 +410,8 @@ interface WorkerSpeech {
382
410
 
383
411
  /** How many spoken texts, utterances and early deliveries are remembered for dedupe and matching. */
384
412
  const MEMORY = 1000
413
+ /** Pending permissions, and pending forms, kept per tenant (oldest dropped). */
414
+ export const PENDING_PER_TENANT = 200
385
415
 
386
416
  /** Adds to a bounded insertion-ordered map, dropping the oldest entry when full. */
387
417
  function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void {
@@ -389,6 +419,12 @@ function remember<K, V>(map: Map<K, V>, key: K, value: V, limit = MEMORY): void
389
419
  if (map.size > limit) map.delete(map.keys().next().value!)
390
420
  }
391
421
 
422
+ /** Adds an ID to a bounded set of IDs (oldest dropped). */
423
+ function rememberID(set: Set<string>, id: string, limit = 1000): void {
424
+ set.add(id)
425
+ if (set.size > limit) set.delete(set.values().next().value!)
426
+ }
427
+
392
428
  const eventSessionID = (event: ForwardedEvent): string | undefined => {
393
429
  const data = event.data as Record<string, any>
394
430
  const id = event.type === "form.created" ? data.form?.sessionID : data.sessionID
@@ -399,6 +435,9 @@ const eventSessionID = (event: ForwardedEvent): string | undefined => {
399
435
  const isOn = (s: SessionInfo) => s.exposed !== false
400
436
 
401
437
  export class Registry {
438
+ /** (REC-01) Voice recording taps; optional, best effort. */
439
+ recordingTap?: RecordingTap
440
+
402
441
  private readonly conns = new Map<string, Conn>()
403
442
  private readonly boards = new Map<string, StatusBoard>()
404
443
  /**
@@ -420,9 +459,11 @@ export class Registry {
420
459
  private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
421
460
  private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
422
461
  /** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
423
- private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
424
- /** Form IDs already alerted (forms may be forwarded by several processes). */
462
+ private readonly forms = new Map<string, PendingForm>()
463
+ /** Form IDs already alerted (forms may be forwarded by several processes, and re-announced after a reconnect). */
425
464
  private readonly alertedForms = new Set<string>()
465
+ /** Permission IDs already alerted (a reconnecting spoke re-announces what is still pending). */
466
+ private readonly alertedPermissions = new Set<string>()
426
467
  /** Sessions whose current run raised a providerError notification (once per run; cleared when a run starts). */
427
468
  private readonly providerAlerted = new Set<string>()
428
469
  /** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
@@ -699,6 +740,8 @@ export class Registry {
699
740
  const owner = this.sessionTenant(id)
700
741
  if (owner !== undefined && owner !== tenant) return
701
742
  }
743
+ // A question or permission whose ID no card can carry is never kept (no card, no answer; unbounded memory).
744
+ if (oversizedBlockerID(event.type, data)) return
702
745
  if (sessionID) conn.activity.set(sessionID, this.tick())
703
746
  if (sessionID && event.type.startsWith("session.execution.")) {
704
747
  if (event.type === "session.execution.interrupted" && data.inferred === true) remember(this.inferredEnds, sessionID, this.now())
@@ -713,37 +756,50 @@ export class Registry {
713
756
  if (event.type === "permission.asked" && typeof data.id === "string" && sessionID) {
714
757
  const known = this.permissions.get(data.id)
715
758
  if (known) {
716
- // The same request forwarded by another process: one more place to answer it.
759
+ // The same request forwarded by another process (or re-announced): one more place to answer it, and its
760
+ // blocker back on a status board that lost it.
717
761
  if (!known.connIDs.includes(connID)) known.connIDs.push(connID)
762
+ this.observeBoards(event, tenant)
718
763
  return
719
764
  }
720
765
  const request = permissionRequest(data)
721
- this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}) })
722
- this.alert("permission", sessionID, { requestID: data.id })
766
+ const blocker = askBlocker(event)
767
+ this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}), ...(blocker ? { blocker } : {}) })
768
+ this.capPending(this.permissions, tenant, (id) => this.dropPermission(id))
769
+ if (!this.alertedPermissions.has(data.id) && this.alert("permission", sessionID, { requestID: data.id })) rememberID(this.alertedPermissions, data.id)
723
770
  }
724
771
  if (event.type === "form.created" && sessionID) {
725
772
  const formID = (data.form as Record<string, unknown> | undefined)?.id
726
773
  if (typeof formID === "string") {
727
774
  const known = this.forms.get(formID)
728
775
  if (known && !known.connIDs.includes(connID)) known.connIDs.push(connID)
729
- else if (!known) remember(this.forms, formID, { connIDs: [connID], sessionID })
730
- }
731
- if (typeof formID === "string" && !this.alertedForms.has(formID)) {
732
- this.alertedForms.add(formID)
733
- if (this.alertedForms.size > 1000) this.alertedForms.delete(this.alertedForms.values().next().value!)
734
- this.alert("question", sessionID)
776
+ else if (!known) {
777
+ const blocker = askBlocker(event)
778
+ this.forms.set(formID, { connIDs: [connID], sessionID, tenant, ...(blocker ? { blocker } : {}) })
779
+ this.capPending(this.forms, tenant, (id) => this.dropForm(id))
780
+ }
735
781
  }
782
+ if (typeof formID === "string" && !this.alertedForms.has(formID) && this.alert("question", sessionID)) rememberID(this.alertedForms, formID)
736
783
  }
737
784
  let closedPermission: [string, PendingPermission] | undefined
738
- if (event.type === "permission.replied" && typeof data.requestID === "string") {
739
- const origin = this.permissions.get(data.requestID)
740
- if (origin && this.permissions.delete(data.requestID)) closedPermission = [data.requestID, origin]
741
- }
742
- if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") {
743
- this.forms.delete(data.id)
785
+ const permissionClose = event.type === "permission.replied" && typeof data.requestID === "string"
786
+ const formClose = (event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string"
787
+ if (permissionClose || formClose) {
788
+ const id = (permissionClose ? data.requestID : data.id) as string
789
+ const map: Map<string, PendingPermission | PendingForm> = permissionClose ? this.permissions : this.forms
790
+ const origin = map.get(id)
791
+ // Only the tenant that asked closes it (IDs are not secret enough to let another tenant end it).
792
+ const own = origin !== undefined && (origin.tenant === undefined || origin.tenant === tenant)
793
+ if (own && data.unlisted === true) {
794
+ // A spoke found it no longer listed by its own harness: only that process stops holding it.
795
+ origin.connIDs = origin.connIDs.filter((c) => c !== connID)
796
+ if (origin.connIDs.length) return
797
+ }
798
+ if (own && map.delete(id) && permissionClose) closedPermission = [id, origin as PendingPermission]
744
799
  }
745
800
 
746
801
  if (sessionID) this.trackRun(event.type, sessionID)
802
+ if (sessionID && this.recordingTap && TAPPED.has(event.type)) this.tap(connID, tenant, sessionID, event.type, data)
747
803
  const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
748
804
  this.observeBoards(event, tenant)
749
805
  if (sessionID) this.activitySteps(event.type, sessionID, data, before)
@@ -769,6 +825,31 @@ export class Registry {
769
825
  if (closedPermission) this.permissionClosed(...closedPermission)
770
826
  }
771
827
 
828
+ /** (REC-01) Hands a root session's event to the recording tap once: from the session's owner connection only. */
829
+ private tap(connID: string, tenant: string | undefined, sessionID: string, type: string, data: Record<string, unknown>): void {
830
+ const tap = this.recordingTap
831
+ if (!tap || tenant === undefined) return
832
+ try {
833
+ if (!tap.wants(tenant, sessionID, type) || this.rootOf(sessionID) !== sessionID || this.owner(sessionID) !== connID) return
834
+ tap.event(tenant, sessionID, type, data)
835
+ } catch (cause) {
836
+ console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
837
+ }
838
+ }
839
+
840
+ /** (REC-01) A tap call that never throws into the registry. */
841
+ private tapSafely(call: (tap: RecordingTap, tenant: string) => void, sessionID: string): void {
842
+ const tap = this.recordingTap
843
+ if (!tap) return
844
+ const tenant = this.sessionTenant(sessionID)
845
+ if (tenant === undefined) return
846
+ try {
847
+ call(tap, tenant)
848
+ } catch (cause) {
849
+ console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
850
+ }
851
+ }
852
+
772
853
  /** Records when a session's run started (kept by the first process to report it while it runs). */
773
854
  private trackRun(type: string, sessionID: string): void {
774
855
  if (type === "session.execution.started") {
@@ -816,8 +897,10 @@ export class Registry {
816
897
 
817
898
  /** Remembers an utterance's inbox item so its delivery is reported as a heard event. */
818
899
  trackUtterance(sessionID: string, inboxID: string): void {
819
- if (this.deliveries.delete(inboxID)) this.emit({ type: "heard", sessionID, inboxID })
820
- else remember(this.utterances, inboxID, sessionID)
900
+ if (this.deliveries.delete(inboxID)) {
901
+ this.emit({ type: "heard", sessionID, inboxID })
902
+ this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
903
+ } else remember(this.utterances, inboxID, sessionID)
821
904
  }
822
905
 
823
906
  /**
@@ -841,6 +924,7 @@ export class Registry {
841
924
  if (sessionID === undefined) return remember(this.deliveries, inboxID, true)
842
925
  this.utterances.delete(inboxID)
843
926
  this.emit({ type: "heard", sessionID, inboxID })
927
+ this.tapSafely((tap, tenant) => tap.heard(tenant, sessionID, inboxID), sessionID)
844
928
  }
845
929
 
846
930
  /** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
@@ -872,13 +956,16 @@ export class Registry {
872
956
  const state = this.rootSpeech.get(key) ?? { sessionID, connID: "", stream: new SentenceStream(), seq: 0 }
873
957
  this.rootSpeech.delete(key)
874
958
  for (const sentence of state.stream.end(text)) this.emitSentence(state, messageID, ordinal, sentence)
959
+ this.tapSafely((tap, tenant) => tap.reply(tenant, sessionID, messageID, ordinal, text), sessionID)
875
960
  // Nobody is listening on a phone: push the whole reply so it reaches a locked screen.
876
961
  if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
877
962
  }
878
963
 
879
964
  private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
880
965
  this.heardFrom(state.sessionID, text)
881
- this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq: state.seq++, text })
966
+ const seq = state.seq++
967
+ this.emit({ type: "say", sessionID: state.sessionID, messageID, ordinal, seq, text })
968
+ this.tapSafely((tap, tenant) => tap.sentence(tenant, state.sessionID, messageID, ordinal, seq, text), state.sessionID)
882
969
  }
883
970
 
884
971
  /** The run stopped or conversation mode ended: the rest of its text parts is not spoken. */
@@ -1808,6 +1895,19 @@ export class Registry {
1808
1895
  }
1809
1896
  }
1810
1897
 
1898
+ /**
1899
+ * The user dismissed the session's last run failure: its status board stops showing it (consoles and the Concierge
1900
+ * see the state leave "error") until it fails again. True when there was one to clear.
1901
+ */
1902
+ dismissError(sessionID: string): boolean {
1903
+ const rootID = this.rootOf(sessionID)
1904
+ const board = rootID === undefined ? undefined : this.boards.get(rootID)
1905
+ if (rootID === undefined || !board?.observe({ type: "session.error.dismissed", data: { sessionID } })) return false
1906
+ this.changed(this.sessionTenant(rootID))
1907
+ this.emit({ type: "status", sessionID: rootID, sessions: this.live(board.snapshot().sessions) })
1908
+ return true
1909
+ }
1910
+
1811
1911
  /** After a stop or interrupt: releases the turn locks in the session's tree and ends their waiting requests. */
1812
1912
  cancelTurns(sessionID: string): void {
1813
1913
  const rootID = this.rootOf(sessionID) ?? sessionID
@@ -1886,6 +1986,33 @@ export class Registry {
1886
1986
  if (next.length === pending.length) break
1887
1987
  pending = next
1888
1988
  }
1989
+ this.restoreBlockers()
1990
+ }
1991
+
1992
+ /** Keeps at most PENDING_PER_TENANT entries of the tenant in a pending map: the oldest are dropped (with their blocker). */
1993
+ private capPending(map: Map<string, PendingPermission | PendingForm>, tenant: string, drop: (id: string) => void): void {
1994
+ const own = [...map].filter(([, origin]) => (origin.tenant ?? this.sessionTenant(origin.sessionID)) === tenant)
1995
+ for (const [id] of own.slice(0, Math.max(0, own.length - PENDING_PER_TENANT))) drop(id)
1996
+ }
1997
+
1998
+ /**
1999
+ * Puts pending permissions and forms back on the status boards that lost them: a board recreated after its
2000
+ * session left the reported sessions for a moment, or one created after the question was asked (events reach
2001
+ * only existing boards). OpenCode still waits for them; only a reply, a cancel or their processes going away
2002
+ * end them (production 2026-11-05: a question card vanished while the session still waited).
2003
+ */
2004
+ private restoreBlockers(): void {
2005
+ const restored = new Set<string>()
2006
+ const restore = ({ sessionID, tenant, blocker }: PendingPermission | PendingForm) => {
2007
+ if (!blocker) return
2008
+ const rootID = this.rootOf(sessionID)
2009
+ const board = rootID === undefined ? undefined : this.boards.get(rootID)
2010
+ if (!rootID || !board || (tenant !== undefined && this.sessionTenant(rootID) !== tenant)) return
2011
+ if (board.restoreBlocker(sessionID, blocker)) restored.add(rootID)
2012
+ }
2013
+ for (const origin of this.permissions.values()) restore(origin)
2014
+ for (const origin of this.forms.values()) restore(origin)
2015
+ for (const rootID of restored) this.emit({ type: "status", sessionID: rootID, sessions: this.live(this.boards.get(rootID)!.snapshot().sessions) })
1889
2016
  }
1890
2017
 
1891
2018
  // ---------- cluster (design §9) ----------
@@ -1913,7 +2040,7 @@ export class Registry {
1913
2040
  const add = (tenant: string | undefined) => tenant !== undefined && tenants.add(tenant)
1914
2041
  for (const rootID of this.boards.keys()) add(this.sessionTenant(rootID))
1915
2042
  for (const origin of this.permissions.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
1916
- for (const origin of this.forms.values()) add(this.sessionTenant(origin.sessionID))
2043
+ for (const origin of this.forms.values()) add(origin.tenant ?? this.sessionTenant(origin.sessionID))
1917
2044
  for (const entry of this.presence.values()) add(this.sessionTenant(entry.sessionID))
1918
2045
  for (const sessionID of this.active.keys()) add(this.sessionTenant(sessionID))
1919
2046
  return [...tenants]
@@ -2048,11 +2175,12 @@ export class Registry {
2048
2175
  * Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
2049
2176
  * session some device shows (spoken replies check that themselves: the watching device speaks them).
2050
2177
  */
2051
- private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): void {
2178
+ /** True when it was raised (or skipped because a device shows the session); false for a session on no board. */
2179
+ private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): boolean {
2052
2180
  const entry = [...this.boards].find(([, board]) => board.get(sessionID))
2053
- if (!entry) return
2181
+ if (!entry) return false
2054
2182
  const [rootID, board] = entry
2055
- if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return
2183
+ if (!ALWAYS_ALERT.has(kind) && this.watched(rootID)) return true
2056
2184
  const alert: Alert = {
2057
2185
  kind,
2058
2186
  sessionID: rootID,
@@ -2065,6 +2193,7 @@ export class Registry {
2065
2193
  }
2066
2194
  const owner = this.sessionTenant(rootID)
2067
2195
  for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
2196
+ return true
2068
2197
  }
2069
2198
 
2070
2199
  private emit(message: RegistryMessage): void {
@@ -2072,3 +2201,10 @@ export class Registry {
2072
2201
  for (const listener of this.listeners) listener(message, tenant)
2073
2202
  }
2074
2203
  }
2204
+
2205
+ /** A permission.asked / form.created (or their closes) with an ID longer than any card keeps. */
2206
+ function oversizedBlockerID(type: string, data: Record<string, unknown>): boolean {
2207
+ const ids =
2208
+ type === "permission.asked" ? [data.id] : type === "form.created" ? [(data.form as Record<string, unknown> | undefined)?.id] : []
2209
+ return ids.some((id) => typeof id === "string" && id.length > BLOCKER_ID_MAX)
2210
+ }
@@ -521,6 +521,8 @@ export class SpokeHub {
521
521
  const owner = this.options.registry.sessionTenant(sessionID)
522
522
  if (owner !== undefined && owner !== call.tenant) throw new SpokeError("Unknown session")
523
523
  }
524
+ // A dismissed error: the session's status board is this pod's (its connection is here), not the calling pod's.
525
+ if (call.kind === "spoke" && call.req.op === "dismissError") this.options.registry.dismissError(call.req.sessionID)
524
526
  return call.kind === "node" ? this.requestNode(call.connID, call.req) : this.request(call.connID, call.req)
525
527
  }
526
528
 
package/src/index.ts CHANGED
@@ -162,6 +162,33 @@ async function activity(kind: "service" | "standalone", directories: readonly st
162
162
  return { active: Object.keys(active ?? {}), jobs }
163
163
  }
164
164
 
165
+ /**
166
+ * What OpenCode waits for in each session on this process's own server: its pending forms (session.form.list) and
167
+ * permission requests (session.permission.list). A session that cannot be read is left out; nothing when the
168
+ * server cannot be reached.
169
+ */
170
+ async function pending(kind: "service" | "standalone", sessionIDs: readonly string[]) {
171
+ const client = await ownClient(kind).catch(() => undefined)
172
+ if (!client) return []
173
+ const out: Array<{ sessionID: string; forms: unknown[]; permissions: unknown[] }> = []
174
+ // One budget for the whole listing (the spoke gives up after 5 s too): a hung server stops being asked.
175
+ const signal = AbortSignal.timeout(PENDING_LIST_TIMEOUT_MS)
176
+ for (const sessionID of sessionIDs) {
177
+ if (signal.aborted) break
178
+ try {
179
+ const [forms, permissions] = await Promise.all([client.session.form.list({ sessionID }, { signal }), client.permission.list({ sessionID }, { signal })])
180
+ const list = (v: unknown) => unwrap<unknown[]>(v, Array.isArray)
181
+ out.push({ sessionID, forms: Array.isArray(list(forms)) ? list(forms) : [], permissions: Array.isArray(list(permissions)) ? list(permissions) : [] })
182
+ } catch {
183
+ // a session this server does not have (any more)
184
+ }
185
+ }
186
+ return out
187
+ }
188
+
189
+ /** How long one listing of pending forms and permissions may take on the own server. */
190
+ const PENDING_LIST_TIMEOUT_MS = 5000
191
+
165
192
  /** Plugin API results may arrive bare or wrapped as { location, data }. */
166
193
  function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
167
194
  const v = value as any
@@ -253,6 +280,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
253
280
  },
254
281
  ...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
255
282
  activity: (directories) => activity(kind, directories),
283
+ pending: (sessionIDs) => pending(kind, sessionIDs),
256
284
  // Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
257
285
  ...(canList(kind)
258
286
  ? {