@huynle/hub 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +69 -12
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +278 -35
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/cli.ts +23 -10
  8. package/packages/cli/src/http.ts +1 -0
  9. package/packages/cli/src/launches.ts +5 -1
  10. package/packages/cli/src/mcp.ts +28 -8
  11. package/packages/cli/src/messaging.ts +14 -0
  12. package/packages/cli/src/team-intake-mcp.ts +26 -2
  13. package/packages/codex/package.json +1 -1
  14. package/packages/node/package.json +1 -1
  15. package/packages/preset/package.json +1 -1
  16. package/packages/protocol/package.json +1 -1
  17. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  18. package/packages/protocol/src/artifacts.ts +43 -0
  19. package/packages/protocol/src/credentials.ts +38 -0
  20. package/packages/protocol/src/index.ts +56 -5
  21. package/packages/protocol/src/launch.ts +7 -2
  22. package/packages/protocol/src/markdown.ts +753 -0
  23. package/packages/protocol/src/media-allowlist.ts +240 -0
  24. package/packages/protocol/src/media.ts +120 -0
  25. package/packages/protocol/src/preview-bundle.ts +221 -0
  26. package/packages/protocol/src/voice-recording.ts +8 -0
  27. package/packages/runtime-openshell/package.json +1 -1
  28. package/packages/runtime-sdk/package.json +1 -1
  29. package/packages/sdk/package.json +1 -1
  30. package/packages/sdk/src/client.ts +17 -3
  31. package/packages/sdk/src/errors.ts +1 -0
  32. package/packages/sdk/src/team-intake.ts +8 -0
  33. package/packages/sdk/src/types.ts +16 -2
  34. package/packages/spoke-sdk/package.json +1 -1
  35. package/src/central/agent/delivery.ts +35 -6
  36. package/src/central/agent/errors.ts +2 -0
  37. package/src/central/agent/events.ts +11 -2
  38. package/src/central/agent/launch-engine.ts +65 -4
  39. package/src/central/agent/launch-routes.ts +8 -0
  40. package/src/central/agent/launch-spec.ts +18 -1
  41. package/src/central/agent/launch-store.ts +2 -2
  42. package/src/central/agent/message-routes.ts +49 -11
  43. package/src/central/agent/messages.ts +17 -4
  44. package/src/central/agent/not-after.ts +28 -0
  45. package/src/central/agent/placement.ts +36 -4
  46. package/src/central/agent/routes.ts +3 -1
  47. package/src/central/agent/webhook-routes.ts +21 -9
  48. package/src/central/agent/webhooks.ts +27 -9
  49. package/src/central/artifacts/host.ts +3 -1
  50. package/src/central/artifacts/maintenance.ts +2 -1
  51. package/src/central/artifacts/preview-bundle.ts +81 -0
  52. package/src/central/artifacts/previews.ts +94 -0
  53. package/src/central/artifacts/routes.ts +12 -1
  54. package/src/central/artifacts/store.ts +198 -8
  55. package/src/central/config.ts +34 -0
  56. package/src/central/fetch-budget.ts +59 -0
  57. package/src/central/fetcher.ts +372 -0
  58. package/src/central/http.ts +277 -22
  59. package/src/central/media-remote.ts +248 -0
  60. package/src/central/media-timeline.ts +317 -0
  61. package/src/central/media-unfurl.ts +119 -0
  62. package/src/central/profiles/content.ts +3 -35
  63. package/src/central/recording/routes.ts +2 -2
  64. package/src/central/recording/schema.ts +3 -1
  65. package/src/central/registry.ts +449 -21
  66. package/src/central/spoke-ws.ts +17 -3
  67. package/src/central/store.ts +21 -0
  68. package/src/central/teams/intake-routes.ts +2 -1
  69. package/src/central/teams/intake.ts +200 -15
  70. package/src/central/unfurl-parse.ts +313 -0
  71. package/src/index.ts +68 -8
  72. package/src/speakable.ts +33 -1
  73. package/src/spoke/conversation.ts +7 -4
  74. package/src/spoke/deliver.ts +19 -0
  75. package/src/spoke/media-bytes.ts +171 -0
  76. package/src/spoke/media-policy.ts +394 -0
  77. package/src/spoke/media.ts +189 -0
  78. package/src/spoke/own-server.ts +30 -0
  79. package/src/spoke/preview-bundle.ts +538 -0
  80. package/src/spoke/spoke.ts +387 -17
  81. package/src/spoke/timeline.ts +59 -5
  82. package/src/spoke/voice-front.ts +335 -0
  83. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  84. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  85. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  86. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  87. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  88. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  89. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  90. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  91. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  92. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  93. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  94. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  95. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  96. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  97. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  98. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  99. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  100. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  101. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  102. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  103. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  104. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  105. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  106. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  107. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  108. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  109. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  110. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  111. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  112. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  113. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  114. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  115. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  116. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  117. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  118. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  119. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  120. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  121. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  122. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  123. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  124. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  125. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  126. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  127. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  128. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  129. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  130. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  131. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  132. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  133. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  134. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  135. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  136. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  137. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  138. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  139. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  140. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  141. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  142. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  143. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  144. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  145. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  146. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  147. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  148. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  149. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  150. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  151. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  152. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  153. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  154. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  155. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  156. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  157. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  158. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  159. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  160. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  161. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  162. package/web/dist/assets/source-BWlvaODa.js +1 -0
  163. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  164. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  165. package/web/dist/index.html +8 -6
  166. package/web/src/voice/turn/LICENSE +28 -0
  167. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  168. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  169. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  170. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  171. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  172. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  173. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  174. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  175. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  176. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  177. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  178. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  179. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  180. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  181. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -14,6 +14,7 @@ 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
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
+ import { redactCredentials } from "../../packages/protocol/src/credentials.ts"
17
18
  import { DoneWatch } from "./done"
18
19
  import { SentenceStream } from "./say-stream"
19
20
  import type { ActivityStep, NotificationKind } from "./push"
@@ -82,7 +83,11 @@ export type RegistryMessage =
82
83
  | { readonly type: "delta"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly ordinal?: number; readonly text: string }
83
84
  /** Reasoning text streamed in a root or subagent session. */
84
85
  | { readonly type: "reasoning"; readonly sessionID: string; readonly rootID: string; readonly messageID?: string; readonly delta: string }
85
- | { readonly type: "conversation"; readonly sessionID: string; readonly on: boolean }
86
+ /**
87
+ * Conversation mode changed. `front` (voice front P1): present once the voice front was asked for: whether a front
88
+ * session is in front of this one now; `reason` "front_failed": it failed and utterances go to the session itself.
89
+ */
90
+ | { readonly type: "conversation"; readonly sessionID: string; readonly on: boolean; readonly front?: boolean; readonly reason?: "front_failed" }
86
91
  /**
87
92
  * Assistant text of a root session in conversation mode, to be spoken. Root text is spoken while it streams:
88
93
  * one say per completed sentence, `seq` counting the sentences of the message's text part (`ordinal`) from 0.
@@ -431,6 +436,60 @@ const eventSessionID = (event: ForwardedEvent): string | undefined => {
431
436
  return typeof id === "string" ? id : undefined
432
437
  }
433
438
 
439
+ /** (voice front P1) A front session ID as a spoke answers it (bounded). */
440
+ const FRONT_ID_MAX = 300
441
+ /** (voice front P1) A voice-front session no conversation maps for this long is deleted (through its reporter). */
442
+ export const FRONT_STALE_MS = 60_000
443
+ /** (voice front P1) A mapped front that no process reports (and none links) for this long has failed. */
444
+ export const FRONT_GONE_MS = 10_000
445
+
446
+ /**
447
+ * (voice front P1) What the hub asks a spoke to do about fronts: re-link a conversation's known front through the
448
+ * process that owns main now (its linking process went), or delete a stale front (removeFront).
449
+ */
450
+ export type FrontAction =
451
+ | { readonly kind: "adopt"; readonly mainID: string; readonly connID: string; readonly frontID: string }
452
+ | { readonly kind: "remove"; readonly connID: string; readonly sessionID: string }
453
+
454
+ /** (voice front P1) The front in front of a main session: who created it, and whether a sessions frame listed it yet. */
455
+ interface Front {
456
+ readonly frontID: string
457
+ /** The connection that answered the conversation op, then the last one that reported the front. */
458
+ connID: string
459
+ /** Reported in a sessions frame: from then on, a frame of `connID` without it means it is gone. */
460
+ seen: boolean
461
+ }
462
+
463
+ /** (voice front P1) What a permission lets the session do, as spoken: "run a command", "edit files", "use webfetch". */
464
+ const PERMISSION_PHRASES: Record<string, string> = {
465
+ shell: "run a command",
466
+ bash: "run a command",
467
+ edit: "edit files",
468
+ write: "edit files",
469
+ patch: "edit files",
470
+ read: "read files",
471
+ webfetch: "fetch a web page",
472
+ websearch: "search the web",
473
+ external_directory: "work outside its folder",
474
+ task: "start a worker",
475
+ subagent: "start a worker",
476
+ }
477
+ function permissionPhrase(action: string | undefined): string {
478
+ if (!action) return "go on"
479
+ return PERMISSION_PHRASES[action] ?? `use ${action.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 40)}`
480
+ }
481
+
482
+ /** A question's title as spoken: one line, short, ending with its punctuation (or a period). */
483
+ function spokenTitle(title: string | undefined): string {
484
+ const t = (title ?? "").replace(/\s+/g, " ").trim()
485
+ if (!t || t === "question") return ""
486
+ const short = t.length > ANNOUNCE_REASON_MAX ? `${t.slice(0, ANNOUNCE_REASON_MAX - 1).trimEnd()}…` : t
487
+ return /[.?!…]$/.test(short) ? short : `${short}.`
488
+ }
489
+
490
+ /** (voice front P1) A voice front as a spoke reports it: with voiceFrontOf, or on the voice-front agent (an unlinked one). */
491
+ const isFrontInfo = (s: SessionInfo) => s.voiceFrontOf !== undefined || s.agent === "voice-front"
492
+
434
493
  /** Older spokes reported exposed sessions only, without the field. */
435
494
  const isOn = (s: SessionInfo) => s.exposed !== false
436
495
 
@@ -468,6 +527,23 @@ export class Registry {
468
527
  private readonly providerAlerted = new Set<string>()
469
528
  /** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
470
529
  private readonly conversations = new Map<string, string | undefined>()
530
+ /** (voice front P1) Sessions whose conversation asked for a voice front (a reconnect resend asks again). */
531
+ private readonly frontWanted = new Set<string>()
532
+ /** (voice front P1) Main session → its active front; front → main (the front's events speak as main). */
533
+ private readonly fronts = new Map<string, Front>()
534
+ private readonly frontMain = new Map<string, string>()
535
+ /** (voice front P1) Fronts that failed: never mapped again (a later report or resend answer naming one is ignored). */
536
+ private readonly failedFronts = new Set<string>()
537
+ /**
538
+ * (voice front P1) Main sessions whose front failed: until the user turns the conversation off or on again, no
539
+ * front is asked for (reconnect resends) or mapped (sessions frames); the session talks directly.
540
+ */
541
+ private readonly frontFellBack = new Set<string>()
542
+ /** (voice front P1) Unmapped voice-front sessions → when first reported so (removed after FRONT_STALE_MS). */
543
+ private readonly staleFronts = new Map<string, number>()
544
+ private readonly frontActionListeners = new Set<(action: FrontAction) => void>()
545
+ /** (voice front P1) Connection → front ops under way on it, and the sessions it reported when the first began. */
546
+ private readonly frontOps = new Map<string, { count: number; readonly known: ReadonlySet<string>; held: boolean }>()
471
547
  /** `messageID ordinal` of texts already sent as say events (several processes forward the same text). */
472
548
  private readonly spoken = new Map<string, true>()
473
549
  /** `messageID ordinal` → a root text part being spoken sentence by sentence as it streams. */
@@ -582,12 +658,55 @@ export class Registry {
582
658
 
583
659
  /** Sessions `conn` reports that belong to its tenant. */
584
660
  private visible(conn: Conn): SessionInfo[] {
585
- return [...conn.sessions.values()].filter((s) => this.reports(conn, s.id))
661
+ return [...conn.sessions.values()].filter((s) => !isFrontInfo(s) && !this.frontMain.has(s.id) && !this.heldFront(conn.id, s) && this.reports(conn, s.id))
586
662
  }
587
663
 
588
- /** True when the tenant owns the session (reported by one of its processes, or a subagent of such a session). */
664
+ /**
665
+ * (voice front P1) While a front op runs on a connection, a "Voice: " session it newly reports may be the front
666
+ * before the spoke linked it (an older spoke snapshots OpenCode's session.created first): held back until it answers.
667
+ */
668
+ private heldFront(connID: string, s: SessionInfo): boolean {
669
+ const op = this.frontOps.get(connID)
670
+ if (!op || op.known.has(s.id) || s.voiceFrontOf !== undefined || !(s.title ?? "").startsWith("Voice: ")) return false
671
+ op.held = true
672
+ return true
673
+ }
674
+
675
+ /** (voice front P1) A conversation op asking `connID` for a front starts: see heldFront. */
676
+ frontOpStarted(connID: string): void {
677
+ const op = this.frontOps.get(connID)
678
+ if (op) op.count++
679
+ else this.frontOps.set(connID, { count: 1, known: new Set(this.conns.get(connID)?.sessions.keys() ?? []), held: false })
680
+ }
681
+
682
+ /** (voice front P1) That op answered (or failed): what it held back is listed again, unless it is the front now. */
683
+ frontOpEnded(connID: string): void {
684
+ const op = this.frontOps.get(connID)
685
+ if (!op || --op.count > 0) return
686
+ this.frontOps.delete(connID)
687
+ if (op.held) this.emit({ type: "catalog" })
688
+ }
689
+
690
+ /**
691
+ * True when the tenant owns the session (reported by one of its processes, or a subagent of such a session). A
692
+ * hidden voice front belongs to nobody here: no route, list or agent reaches it.
693
+ */
589
694
  owns(tenant: string, sessionID: string): boolean {
590
- return this.sessionTenant(sessionID) === tenant
695
+ return this.sessionTenant(sessionID) === tenant && !this.hidden(sessionID)
696
+ }
697
+
698
+ /**
699
+ * (voice front P1) A hidden voice-front session: one its tenant's processes report with `voiceFrontOf`, or the
700
+ * active front of a main session (before any sessions frame lists it). Never listed, boarded, pushed or named.
701
+ */
702
+ hidden(sessionID: string): boolean {
703
+ if (this.frontMain.has(sessionID)) return true
704
+ for (const conn of this.all()) {
705
+ const s = conn.sessions.get(sessionID)
706
+ if (s && isFrontInfo(s) && this.tenant(conn) === this.sessionTenant(sessionID)) return true
707
+ if (s && this.heldFront(conn.id, s)) return true
708
+ }
709
+ return false
591
710
  }
592
711
 
593
712
  /** Whether the connection's process belongs to the tenant. */
@@ -681,10 +800,27 @@ export class Registry {
681
800
  this.observeBoards({ type: "form.cancelled", data: { sessionID: origin.sessionID, id: formID } })
682
801
  }
683
802
  this.syncBoards()
803
+ this.frontLinkerGone(connID)
684
804
  this.changed(this.tenant(conn))
685
805
  this.emit({ type: "catalog" })
686
806
  }
687
807
 
808
+ /**
809
+ * (voice front P1) The process that linked fronts went: each one's main owner now (another process on the same
810
+ * data) re-links the same front; with nobody to, or if it is gone, the conversation fails over after FRONT_GONE_MS.
811
+ */
812
+ private frontLinkerGone(connID: string): void {
813
+ for (const [mainID, front] of this.fronts) {
814
+ if (front.connID !== connID || !this.conversations.has(mainID)) continue
815
+ const target = this.owner(mainID)
816
+ if (target !== undefined && this.frontWanted.has(mainID) && !this.frontFellBack.has(mainID) && this.supports(target, "voiceFront")) {
817
+ this.conversations.set(mainID, target)
818
+ this.frontAction({ kind: "adopt", mainID, connID: target, frontID: front.frontID })
819
+ }
820
+ this.watchFrontGone(mainID)
821
+ }
822
+ }
823
+
688
824
  touch(connID: string): void {
689
825
  const conn = this.conns.get(connID)
690
826
  if (conn) conn.lastSeen = this.now()
@@ -702,6 +838,7 @@ export class Registry {
702
838
  conn.sessionsKey = key
703
839
  conn.sessions = new Map(sessions.map((s) => [s.id, s]))
704
840
  this.syncBoards()
841
+ this.syncFronts(conn)
705
842
  this.changed(this.tenant(conn))
706
843
  this.emit({ type: "catalog" })
707
844
  }
@@ -740,6 +877,8 @@ export class Registry {
740
877
  const owner = this.sessionTenant(id)
741
878
  if (owner !== undefined && owner !== tenant) return
742
879
  }
880
+ // (voice front P1) A hidden front's events only speak, hear and record as its main session.
881
+ if (sessionID && this.hidden(sessionID)) return this.observeFront(connID, tenant, sessionID, event.type, data)
743
882
  // A question or permission whose ID no card can carry is never kept (no card, no answer; unbounded memory).
744
883
  if (oversizedBlockerID(event.type, data)) return
745
884
  if (sessionID) conn.activity.set(sessionID, this.tick())
@@ -799,7 +938,8 @@ export class Registry {
799
938
  }
800
939
 
801
940
  if (sessionID) this.trackRun(event.type, sessionID)
802
- if (sessionID && this.recordingTap && TAPPED.has(event.type)) this.tap(connID, tenant, sessionID, event.type, data)
941
+ // While a front is active, the recording follows the front (tapped as main): main's own turn is not tapped.
942
+ if (sessionID && this.recordingTap && TAPPED.has(event.type) && !this.fronts.has(sessionID)) this.tap(connID, tenant, sessionID, event.type, data)
803
943
  const before = sessionID ? this.status(sessionID)?.find((s) => s.id === sessionID) : undefined
804
944
  this.observeBoards(event, tenant)
805
945
  if (sessionID) this.activitySteps(event.type, sessionID, data, before)
@@ -913,6 +1053,9 @@ export class Registry {
913
1053
  const resend: string[] = []
914
1054
  for (const [sessionID, told] of this.conversations) {
915
1055
  if (told === connID || !this.reports(conn, sessionID) || this.owner(sessionID) !== connID) continue
1056
+ // (voice front P1) The process that links the front is still here: it keeps serving the conversation.
1057
+ const front = this.fronts.get(sessionID)
1058
+ if (front && this.linker(front) !== undefined && front.connID !== connID) continue
916
1059
  this.conversations.set(sessionID, connID)
917
1060
  resend.push(sessionID)
918
1061
  }
@@ -928,8 +1071,8 @@ export class Registry {
928
1071
  }
929
1072
 
930
1073
  /** Root text streaming in conversation mode: each sentence is spoken as soon as it is complete. */
931
- private speakDelta(connID: string, sessionID: string, data: Record<string, unknown>): void {
932
- if (this.rootOf(sessionID) !== sessionID || !this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
1074
+ private speakDelta(connID: string, sessionID: string, data: Record<string, unknown>, fromFront = false): void {
1075
+ if (!this.speaks(sessionID, fromFront)) return
933
1076
  const { assistantMessageID: messageID, ordinal, delta } = data
934
1077
  if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof delta !== "string") return
935
1078
  const key = `${messageID} ${ordinal}`
@@ -945,10 +1088,9 @@ export class Registry {
945
1088
  }
946
1089
 
947
1090
  /** A root text part ended: speaks what the deltas did not (the ended text is authoritative), once. */
948
- private say(sessionID: string, data: Record<string, unknown>): void {
949
- if (this.rootOf(sessionID) !== sessionID) return
1091
+ private say(sessionID: string, data: Record<string, unknown>, fromFront = false): void {
1092
+ if (!this.speaks(sessionID, fromFront)) return
950
1093
  const { assistantMessageID: messageID, ordinal, text } = data
951
- if (!this.conversations.has(sessionID) || !this.boards.has(sessionID)) return
952
1094
  if (typeof messageID !== "string" || typeof ordinal !== "number" || typeof text !== "string") return
953
1095
  const key = `${messageID} ${ordinal}`
954
1096
  if (this.spoken.has(key)) return
@@ -961,6 +1103,15 @@ export class Registry {
961
1103
  if (text.trim() && !this.watched(sessionID)) this.alert("say", sessionID, { text })
962
1104
  }
963
1105
 
1106
+ /**
1107
+ * Whether root text of `sessionID` is spoken: a root session in conversation mode. While a voice front is active,
1108
+ * only the front's text is (fromFront, spoken as main); main's own is not, the front reports it.
1109
+ */
1110
+ private speaks(sessionID: string, fromFront: boolean): boolean {
1111
+ if (this.rootOf(sessionID) !== sessionID || !this.conversations.has(sessionID) || !this.boards.has(sessionID)) return false
1112
+ return fromFront === this.fronts.has(sessionID)
1113
+ }
1114
+
964
1115
  private emitSentence(state: RootSpeech, messageID: string, ordinal: number, text: string): void {
965
1116
  this.heardFrom(state.sessionID, text)
966
1117
  const seq = state.seq++
@@ -1033,18 +1184,25 @@ export class Registry {
1033
1184
  return rootID && rootID !== sessionID && this.conversations.has(rootID) && this.boards.has(rootID) ? rootID : undefined
1034
1185
  }
1035
1186
 
1036
- /** A worker that fails, needs a permission or asks a question is announced once ("Deploy check failed: …"). */
1187
+ /**
1188
+ * A worker that fails, needs a permission or asks a question is announced once ("Deploy check failed: …"). While a
1189
+ * voice front is active, main's own failure, permission or question is too ("The session failed: …"): main is the
1190
+ * one doing the work then.
1191
+ */
1037
1192
  private observeWorkerBlockers(type: string, sessionID: string, data: Record<string, unknown>): void {
1038
1193
  if (type !== "session.execution.failed" && type !== "permission.asked" && type !== "form.created") return
1039
- const rootID = this.conversationRootOf(sessionID)
1194
+ const own = this.fronts.has(sessionID) && this.speaks(sessionID, true)
1195
+ if (own && type !== "session.execution.failed") return this.announceOwnAsk(type, sessionID, data)
1196
+ const rootID = own ? sessionID : this.conversationRootOf(sessionID)
1040
1197
  if (!rootID) return
1041
- const title = this.workerTitle(rootID, sessionID)
1198
+ const title = own ? "The session" : this.workerTitle(rootID, sessionID)
1042
1199
  if (type === "session.execution.failed") {
1043
1200
  const at = this.announcedFailures.get(sessionID)
1044
1201
  if (at !== undefined && this.now() - at <= FAILURE_DEDUPE_MS) return
1045
1202
  remember(this.announcedFailures, sessionID, this.now())
1046
1203
  const message = (data.error as { message?: unknown } | undefined)?.message
1047
- const reason = (typeof message === "string" ? message : "").replace(/\s+/g, " ").trim().replace(/[.!]+$/, "").slice(0, ANNOUNCE_REASON_MAX)
1204
+ // Spoken (and pushed): never a credential the error text carries (the recordings' scrubber).
1205
+ const reason = redactCredentials(typeof message === "string" ? message : "").text.replace(/\s+/g, " ").trim().replace(/[.!]+$/, "").slice(0, ANNOUNCE_REASON_MAX)
1048
1206
  return this.announce(rootID, `announce:failed:${sessionID}`, reason ? `${title} failed: ${reason}.` : `${title} failed.`, true)
1049
1207
  }
1050
1208
  const id = type === "permission.asked" ? data.id : (data.form as { id?: unknown } | undefined)?.id
@@ -1057,6 +1215,28 @@ export class Registry {
1057
1215
  else this.announce(rootID, `announce:form:${id}`, `${title} has a question.`, false)
1058
1216
  }
1059
1217
 
1218
+ /**
1219
+ * (voice front P1) Main's own permission or question while its front is active, once per request: "The session
1220
+ * needs your permission to run a command." / "The session has a question: Which branch?" (no command line, as push).
1221
+ */
1222
+ private announceOwnAsk(type: string, sessionID: string, data: Record<string, unknown>): void {
1223
+ const blocker = askBlocker({ type, data })
1224
+ if (!blocker) return
1225
+ const key = `${type} ${blocker.id}`
1226
+ if (this.announcedBlockers.has(key)) return
1227
+ remember(this.announcedBlockers, key, true)
1228
+ if (blocker.kind === "permission") return this.announce(sessionID, `announce:permission:${blocker.id}`, `The session needs your permission to ${permissionPhrase(blocker.action)}.`, false)
1229
+ // OpenCode's question tool titles its form "Questions": one question is read as itself (a field's description is
1230
+ // the question, its title the short header), several are counted.
1231
+ const fields = blocker.fields ?? []
1232
+ if (blocker.question && fields.length > 1) return this.announce(sessionID, `announce:form:${blocker.id}`, `The session has ${fields.length} questions.`, false)
1233
+ // The blocker may drop descriptions to stay small: the event's own field has it.
1234
+ const raw = (data.form as { fields?: Array<{ description?: unknown }> } | undefined)?.fields?.[0]
1235
+ const question = fields.length === 1 ? (typeof raw?.description === "string" && raw.description.trim() ? raw.description : fields[0]!.description || fields[0]!.title) : undefined
1236
+ const title = spokenTitle(question || (blocker.question ? undefined : blocker.summary))
1237
+ this.announce(sessionID, `announce:form:${blocker.id}`, title ? `The session has a question: ${title}` : "The session has a question.", false)
1238
+ }
1239
+
1060
1240
  /** Pairs a child session's latest assistant message with successful execution and speaks it once. */
1061
1241
  private observeWorkerSpeech(type: string, sessionID: string, data: Record<string, unknown>): void {
1062
1242
  const now = this.now()
@@ -1116,6 +1296,8 @@ export class Registry {
1116
1296
  */
1117
1297
  private flushWorkerSpeech(childID: string, state: WorkerSpeech): void {
1118
1298
  if (!state.succeeded || !state.messageID || state.parts.size === 0 || !this.conversations.has(state.rootID)) return
1299
+ // (voice front P1) The front reports worker results itself.
1300
+ if (this.fronts.has(state.rootID)) return state.parts.clear()
1119
1301
  const parts = [...state.parts].sort(([a], [b]) => a - b).filter(([ordinal]) => !this.spoken.has(`${state.messageID} ${ordinal}`))
1120
1302
  state.parts.clear()
1121
1303
  if (!parts.length) return
@@ -1378,6 +1560,7 @@ export class Registry {
1378
1560
 
1379
1561
  /** The session as reported by any process plus its live status; undefined when nobody reports it. */
1380
1562
  session(sessionID: string): SessionView | undefined {
1563
+ if (this.hidden(sessionID)) return undefined
1381
1564
  let info: SessionInfo | undefined
1382
1565
  /** Open from any reporting process; a Herdr pane wins over a TUI. */
1383
1566
  let terminal: TerminalInfo | undefined
@@ -1599,7 +1782,8 @@ export class Registry {
1599
1782
  if (type === "session.text.delta" || type === "session.reasoning.delta") return
1600
1783
  const ended = type === "session.execution.succeeded" ? "succeeded" : type === "session.execution.failed" ? "failed" : type === "session.execution.interrupted" ? "interrupted" : undefined
1601
1784
  const reason = (data.error as { message?: unknown } | undefined)?.message
1602
- const error = typeof reason === "string" && reason.trim() ? reason : undefined
1785
+ // Pushed: never a credential the error text carries (the recordings' scrubber).
1786
+ const error = typeof reason === "string" && reason.trim() ? redactCredentials(reason).text : undefined
1603
1787
  const errorType = (data.error as { type?: unknown } | undefined)?.type
1604
1788
  // A provider error the user must act on replaces the root's "failed" (raised once per run).
1605
1789
  const providerFailure = ended === "failed" && typeof errorType === "string" && providerFailureNeedsUser(errorType)
@@ -1695,20 +1879,264 @@ export class Registry {
1695
1879
  return this.conversations.has(sessionID)
1696
1880
  }
1697
1881
 
1698
- /** Records conversation mode for a session; `connID` is the process that was just told. */
1699
- setConversation(sessionID: string, on: boolean, connID?: string): void {
1700
- const changed = on !== this.conversations.has(sessionID)
1701
- if (on) this.conversations.set(sessionID, connID)
1702
- else {
1882
+ /**
1883
+ * Records conversation mode for a session; `connID` is the process that was just told. `front` (voice front P1):
1884
+ * undefined when no front was asked for (today's mode), else the front session the spoke answered (null: none, it
1885
+ * fell back). Its messages carry `front` once one was asked for.
1886
+ */
1887
+ setConversation(sessionID: string, on: boolean, connID?: string, front?: string | null): void {
1888
+ const was = this.conversations.has(sessionID)
1889
+ const wanted = this.frontWanted.has(sessionID)
1890
+ const had = this.fronts.has(sessionID)
1891
+ this.frontFellBack.delete(sessionID)
1892
+ if (on) {
1893
+ this.conversations.set(sessionID, connID)
1894
+ if (front === undefined) this.frontWanted.delete(sessionID)
1895
+ else this.frontWanted.add(sessionID)
1896
+ if (front && connID !== undefined) this.mapFront(sessionID, front, connID)
1897
+ else this.unmapFront(sessionID)
1898
+ } else {
1703
1899
  this.conversations.delete(sessionID)
1900
+ this.frontWanted.delete(sessionID)
1901
+ this.unmapFront(sessionID)
1704
1902
  for (const [childID, state] of this.workerSpeech) if (state.rootID === sessionID) this.clearWorkerSpeech(childID)
1705
1903
  this.dropRootSpeech(sessionID)
1706
1904
  }
1905
+ const report = front !== undefined || wanted
1906
+ const changed = on !== was || (front !== undefined && !wanted) || (front === undefined && wanted) || had !== this.fronts.has(sessionID)
1707
1907
  if (!changed) return
1708
- this.emit({ type: "conversation", sessionID, on })
1908
+ this.emit({ type: "conversation", sessionID, on, ...(report ? { front: this.fronts.has(sessionID) } : {}) })
1709
1909
  this.emit({ type: "catalog" })
1710
1910
  }
1711
1911
 
1912
+ /** (voice front P1) The active front of a main session, if any. */
1913
+ frontOf(sessionID: string): string | undefined {
1914
+ return this.fronts.get(sessionID)?.frontID
1915
+ }
1916
+
1917
+ /**
1918
+ * (voice front P1) Where the main session's utterances and interrupts go while its front is active: the front and
1919
+ * its owner process (the one that created it, while no sessions frame lists it yet).
1920
+ */
1921
+ frontRoute(sessionID: string): { sessionID: string; connID: string } | undefined {
1922
+ const front = this.fronts.get(sessionID)
1923
+ if (!front) return undefined
1924
+ const connID = this.linker(front) ?? this.owner(front.frontID)
1925
+ return connID === undefined ? undefined : { sessionID: front.frontID, connID }
1926
+ }
1927
+
1928
+ /** (voice front P1) The connected process that links the front (it relays and wakes it), if it is still here. */
1929
+ private linker(front: Front): string | undefined {
1930
+ return this.conns.has(front.connID) ? front.connID : undefined
1931
+ }
1932
+
1933
+ /** (voice front P1) The front known for a conversation, named in a resend so a restarted spoke re-links it. */
1934
+ resendFrontID(sessionID: string): string | undefined {
1935
+ return this.fronts.get(sessionID)?.frontID
1936
+ }
1937
+
1938
+ /** (voice front P1) The front failed while in use (e.g. a steer into it failed): the session talks directly. */
1939
+ frontLost(mainID: string): void {
1940
+ this.frontFailed(mainID)
1941
+ }
1942
+
1943
+ /** (voice front P1) Listens to what the hub asks spokes to do about fronts (the SpokeHub sends it). */
1944
+ subscribeFrontActions(listener: (action: FrontAction) => void): () => void {
1945
+ this.frontActionListeners.add(listener)
1946
+ return () => this.frontActionListeners.delete(listener)
1947
+ }
1948
+
1949
+ private frontAction(action: FrontAction): void {
1950
+ for (const listener of this.frontActionListeners) {
1951
+ try {
1952
+ listener(action)
1953
+ } catch (cause) {
1954
+ console.error(`[hub] front action failed: ${(cause as Error).message}`)
1955
+ }
1956
+ }
1957
+ }
1958
+
1959
+ /** (voice front P1) Whether any connected process of the main's tenant reports the session. */
1960
+ private frontReported(frontID: string): boolean {
1961
+ return this.all().some((c) => c.sessions.has(frontID))
1962
+ }
1963
+
1964
+ /** (voice front P1) After FRONT_GONE_MS: a mapped front nobody links or reports has failed (front_failed). */
1965
+ private watchFrontGone(mainID: string): void {
1966
+ const front = this.fronts.get(mainID)
1967
+ if (!front) return
1968
+ this.timers.set(() => {
1969
+ if (this.fronts.get(mainID) !== front || this.linker(front) || this.frontReported(front.frontID)) return
1970
+ this.frontFailed(mainID)
1971
+ }, FRONT_GONE_MS)
1972
+ }
1973
+
1974
+ /**
1975
+ * (voice front P1) Voice-front sessions a frame reports that no conversation maps (another process's leftover, or
1976
+ * one from before a restart): after FRONT_STALE_MS still unmapped, its reporter is asked to delete it. A mapped front,
1977
+ * or one naming a main in conversation (its spoke links it), is never touched; user sessions are no fronts.
1978
+ */
1979
+ private noteStaleFronts(conn: Conn): void {
1980
+ for (const s of conn.sessions.values()) {
1981
+ if (!this.unusedFront(s)) continue
1982
+ // A front that failed is never used again: no wait (its process forgot it, e.g. after a restart), also when it
1983
+ // was noted as unmapped before (a new front is reported before its conversation maps it).
1984
+ const failed = this.failedFronts.has(s.id)
1985
+ const due = failed ? this.now() - FRONT_STALE_MS : this.now()
1986
+ const since = this.staleFronts.get(s.id)
1987
+ if (since !== undefined && since <= due) continue
1988
+ this.staleFronts.set(s.id, due)
1989
+ this.timers.set(() => this.sweepStaleFront(s.id), failed ? 0 : FRONT_STALE_MS)
1990
+ }
1991
+ }
1992
+
1993
+ private unusedFront(s: SessionInfo): boolean {
1994
+ return isFrontInfo(s) && !this.frontMain.has(s.id) && !(s.voiceFrontOf !== undefined && this.conversations.has(s.voiceFrontOf))
1995
+ }
1996
+
1997
+ private sweepStaleFront(sessionID: string): void {
1998
+ const since = this.staleFronts.get(sessionID)
1999
+ if (since === undefined) return
2000
+ const reporter = this.all().find((c) => {
2001
+ const s = c.sessions.get(sessionID)
2002
+ return s !== undefined && this.unusedFront(s) && this.supports(c.id, "voiceFront")
2003
+ })
2004
+ if (!reporter) return void this.staleFronts.delete(sessionID)
2005
+ const left = since + FRONT_STALE_MS - this.now()
2006
+ if (left > 0) return void this.timers.set(() => this.sweepStaleFront(sessionID), left)
2007
+ this.staleFronts.delete(sessionID)
2008
+ console.warn(`[hub] asking for a stale voice front to be deleted: ${sessionID}`)
2009
+ this.frontAction({ kind: "remove", connID: reporter.id, sessionID })
2010
+ }
2011
+
2012
+ /** (voice front P1) Whether the session's conversation asked for a voice front. */
2013
+ wantsFront(sessionID: string): boolean {
2014
+ return this.frontWanted.has(sessionID)
2015
+ }
2016
+
2017
+ /** (voice front P1) Whether a conversation resend to `connID` asks for the front again (it must serve "voiceFront"). */
2018
+ resendFront(sessionID: string, connID: string): boolean {
2019
+ return this.frontWanted.has(sessionID) && !this.frontFellBack.has(sessionID) && this.supports(connID, "voiceFront")
2020
+ }
2021
+
2022
+ /**
2023
+ * (voice front P1) A resent conversation op's answer: the front it names (a restarted spoke may name a new one),
2024
+ * or none (an older spoke, or creating it failed): the session talks directly then.
2025
+ */
2026
+ frontAnswer(sessionID: string, connID: string, frontID: unknown): void {
2027
+ if (!this.conversations.has(sessionID) || !this.frontWanted.has(sessionID) || this.frontFellBack.has(sessionID)) return
2028
+ const had = this.fronts.has(sessionID)
2029
+ if (typeof frontID === "string" && frontID) this.mapFront(sessionID, frontID, connID)
2030
+ else this.unmapFront(sessionID)
2031
+ if (had === this.fronts.has(sessionID)) return
2032
+ this.emit({ type: "conversation", sessionID, on: true, front: !had })
2033
+ this.emit({ type: "catalog" })
2034
+ }
2035
+
2036
+ /** Maps main → front, unless the ID is unusable, failed before, or another tenant's session. */
2037
+ private mapFront(mainID: string, frontID: string, connID: string): void {
2038
+ const current = this.fronts.get(mainID)
2039
+ if (current?.frontID === frontID) return void (current.connID = connID)
2040
+ const tenant = this.sessionTenant(frontID)
2041
+ const usable = frontID.length <= FRONT_ID_MAX && frontID !== mainID && !this.failedFronts.has(frontID) && (tenant === undefined || tenant === this.sessionTenant(mainID))
2042
+ const owner = this.frontMain.get(frontID)
2043
+ if (!usable || (owner !== undefined && owner !== mainID)) return this.unmapFront(mainID)
2044
+ this.unmapFront(mainID)
2045
+ const seen = this.all().some((c) => c.sessions.get(frontID)?.voiceFrontOf === mainID)
2046
+ this.fronts.set(mainID, { frontID, connID, seen })
2047
+ this.frontMain.set(frontID, mainID)
2048
+ }
2049
+
2050
+ private unmapFront(mainID: string): void {
2051
+ const front = this.fronts.get(mainID)
2052
+ if (!front) return
2053
+ this.fronts.delete(mainID)
2054
+ if (this.frontMain.get(front.frontID) === mainID) this.frontMain.delete(front.frontID)
2055
+ }
2056
+
2057
+ /**
2058
+ * (voice front P1) A sessions frame: a front it lists for one of its tenant's main sessions (voiceFrontOf) becomes
2059
+ * that session's front when it has none that is still listed (this covers reconnects); a listed front this
2060
+ * process no longer lists is gone (the fallback).
2061
+ */
2062
+ private syncFronts(conn: Conn): void {
2063
+ this.noteStaleFronts(conn)
2064
+ const tenant = this.tenant(conn)
2065
+ for (const s of conn.sessions.values()) {
2066
+ const mainID = s.voiceFrontOf
2067
+ if (typeof mainID !== "string" || mainID === s.id || this.failedFronts.has(s.id)) continue
2068
+ if (!this.conversations.has(mainID) || !this.frontWanted.has(mainID) || this.frontFellBack.has(mainID)) continue
2069
+ if (this.sessionTenant(s.id) !== tenant || this.sessionTenant(mainID) !== tenant) continue
2070
+ const current = this.fronts.get(mainID)
2071
+ if (current?.frontID === s.id) {
2072
+ current.seen = true
2073
+ current.connID = conn.id
2074
+ continue
2075
+ }
2076
+ // A front just created (not listed yet), or one another process still lists, stays.
2077
+ if (current && (!current.seen || this.all().some((c) => c.sessions.has(current.frontID)))) continue
2078
+ const had = current !== undefined
2079
+ this.mapFront(mainID, s.id, conn.id)
2080
+ const mapped = this.fronts.get(mainID)
2081
+ if (mapped) mapped.seen = true
2082
+ if (!had && mapped) {
2083
+ this.emit({ type: "conversation", sessionID: mainID, on: true, front: true })
2084
+ this.emit({ type: "catalog" })
2085
+ }
2086
+ }
2087
+ for (const [mainID, front] of [...this.fronts]) {
2088
+ if (front.seen && front.connID === conn.id && !conn.sessions.has(front.frontID)) this.frontFailed(mainID)
2089
+ }
2090
+ }
2091
+
2092
+ /** (voice front P1) The front failed or is gone while active: the session talks directly (SSE front_failed). */
2093
+ private frontFailed(mainID: string): void {
2094
+ const front = this.fronts.get(mainID)
2095
+ if (!front) return
2096
+ rememberID(this.failedFronts, front.frontID)
2097
+ this.frontFellBack.add(mainID)
2098
+ this.unmapFront(mainID)
2099
+ this.dropRootSpeech(mainID)
2100
+ console.warn(`[hub] voice front of ${mainID} failed: talking to the session directly`)
2101
+ if (!this.conversations.has(mainID)) return
2102
+ this.emit({ type: "conversation", sessionID: mainID, on: true, front: false, reason: "front_failed" })
2103
+ this.emit({ type: "catalog" })
2104
+ }
2105
+
2106
+ /**
2107
+ * (voice front P1) An event of a hidden front: from the process that runs it only (several may forward it), its
2108
+ * text is spoken as main's say, its inbox deliveries are heard for main and its turn is recorded as main's; a
2109
+ * failed or deleted front falls back. Anything else of it (and of an unmapped front) is dropped.
2110
+ */
2111
+ private observeFront(connID: string, tenant: string, frontID: string, type: string, data: Record<string, unknown>): void {
2112
+ const mainID = this.frontMain.get(frontID)
2113
+ const front = mainID === undefined ? undefined : this.fronts.get(mainID)
2114
+ if (mainID === undefined || front?.frontID !== frontID) return
2115
+ // From the linking process only (others forward the same events); a deletion from any of them.
2116
+ if (type !== "session.deleted" && (this.linker(front) ?? this.owner(frontID) ?? front.connID) !== connID) return
2117
+ if (this.recordingTap && TAPPED.has(type)) {
2118
+ try {
2119
+ if (this.recordingTap.wants(tenant, mainID, type)) this.recordingTap.event(tenant, mainID, type, data)
2120
+ } catch (cause) {
2121
+ console.error(`[hub] recording tap failed: ${(cause as Error).message}`)
2122
+ }
2123
+ }
2124
+ switch (type) {
2125
+ case "session.text.delta":
2126
+ return this.speakDelta(connID, mainID, data, true)
2127
+ case "session.text.ended":
2128
+ return this.say(mainID, data, true)
2129
+ case "session.execution.interrupted":
2130
+ return this.dropRootSpeech(mainID)
2131
+ case "session.execution.failed":
2132
+ case "session.deleted":
2133
+ return this.frontFailed(mainID)
2134
+ case "session.inbox.delivered":
2135
+ if (typeof data.inboxID === "string") this.delivered(data.inboxID)
2136
+ return
2137
+ }
2138
+ }
2139
+
1712
2140
  /**
1713
2141
  * Whether a connected process serves a feature (spokeFeatures): a core op (CORE_OPS: turn, steer, interrupt,
1714
2142
  * stop, permission, form), which an OpenCode spoke implies and a spoke of another harness serves only when it