@huynle/hub 0.8.7 → 0.9.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 (213) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +75 -16
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/wrapper.ts +2 -2
  6. package/packages/cli/dist/hub-cli.js +1413 -523
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
  9. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
  10. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
  11. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
  12. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
  13. package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
  14. package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
  15. package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
  16. package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
  17. package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
  18. package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
  19. package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
  20. package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
  21. package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
  22. package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
  23. package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
  24. package/packages/cli/src/bootstrap/release/assets.ts +4 -1
  25. package/packages/cli/src/cli.ts +23 -10
  26. package/packages/cli/src/http.ts +1 -0
  27. package/packages/cli/src/launches.ts +5 -1
  28. package/packages/cli/src/mcp.ts +28 -8
  29. package/packages/cli/src/messaging.ts +14 -0
  30. package/packages/cli/src/node.ts +94 -12
  31. package/packages/cli/src/setup/commands.ts +27 -7
  32. package/packages/cli/src/setup/runtime.ts +31 -4
  33. package/packages/cli/src/team-intake-mcp.ts +26 -2
  34. package/packages/codex/package.json +1 -1
  35. package/packages/codex/src/wrapper.ts +2 -2
  36. package/packages/node/package.json +1 -1
  37. package/packages/node/src/config.ts +49 -17
  38. package/packages/node/src/git.ts +16 -2
  39. package/packages/node/src/harnesses.ts +54 -9
  40. package/packages/node/src/host.ts +61 -15
  41. package/packages/node/src/opencode-service.ts +111 -0
  42. package/packages/node/src/opencode-warm.ts +11 -5
  43. package/packages/node/src/run.ts +50 -5
  44. package/packages/preset/package.json +1 -1
  45. package/packages/protocol/package.json +1 -1
  46. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  47. package/packages/protocol/src/artifacts.ts +43 -0
  48. package/packages/protocol/src/credentials.ts +38 -0
  49. package/packages/protocol/src/index.ts +59 -6
  50. package/packages/protocol/src/launch.ts +46 -6
  51. package/packages/protocol/src/markdown.ts +753 -0
  52. package/packages/protocol/src/media-allowlist.ts +240 -0
  53. package/packages/protocol/src/media.ts +120 -0
  54. package/packages/protocol/src/preview-bundle.ts +221 -0
  55. package/packages/protocol/src/voice-recording.ts +8 -0
  56. package/packages/runtime-openshell/package.json +1 -1
  57. package/packages/runtime-sdk/package.json +1 -1
  58. package/packages/sdk/package.json +1 -1
  59. package/packages/sdk/src/client.ts +17 -3
  60. package/packages/sdk/src/errors.ts +1 -0
  61. package/packages/sdk/src/team-intake.ts +8 -0
  62. package/packages/sdk/src/types.ts +16 -2
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/src/central/agent/delivery.ts +35 -6
  65. package/src/central/agent/errors.ts +2 -0
  66. package/src/central/agent/events.ts +11 -2
  67. package/src/central/agent/launch-engine.ts +65 -4
  68. package/src/central/agent/launch-routes.ts +8 -0
  69. package/src/central/agent/launch-spec.ts +22 -3
  70. package/src/central/agent/launch-store.ts +2 -2
  71. package/src/central/agent/message-routes.ts +49 -11
  72. package/src/central/agent/messages.ts +17 -4
  73. package/src/central/agent/not-after.ts +28 -0
  74. package/src/central/agent/placement.ts +36 -4
  75. package/src/central/agent/routes.ts +3 -1
  76. package/src/central/agent/webhook-routes.ts +21 -9
  77. package/src/central/agent/webhooks.ts +27 -9
  78. package/src/central/artifacts/host.ts +3 -1
  79. package/src/central/artifacts/maintenance.ts +2 -1
  80. package/src/central/artifacts/preview-bundle.ts +81 -0
  81. package/src/central/artifacts/previews.ts +94 -0
  82. package/src/central/artifacts/routes.ts +12 -1
  83. package/src/central/artifacts/store.ts +198 -8
  84. package/src/central/config.ts +34 -0
  85. package/src/central/fetch-budget.ts +59 -0
  86. package/src/central/fetcher.ts +372 -0
  87. package/src/central/http.ts +277 -22
  88. package/src/central/install/CONTRACT.md +4 -4
  89. package/src/central/install/install.ps1 +37 -5
  90. package/src/central/install/render.ts +13 -8
  91. package/src/central/media-remote.ts +248 -0
  92. package/src/central/media-timeline.ts +317 -0
  93. package/src/central/media-unfurl.ts +119 -0
  94. package/src/central/profiles/content.ts +3 -35
  95. package/src/central/recording/routes.ts +2 -2
  96. package/src/central/recording/schema.ts +3 -1
  97. package/src/central/registry.ts +449 -21
  98. package/src/central/spoke-ws.ts +17 -3
  99. package/src/central/store.ts +21 -0
  100. package/src/central/teams/intake-routes.ts +2 -1
  101. package/src/central/teams/intake.ts +200 -15
  102. package/src/central/unfurl-parse.ts +313 -0
  103. package/src/index.ts +68 -8
  104. package/src/speakable.ts +33 -1
  105. package/src/spoke/conversation.ts +7 -4
  106. package/src/spoke/deliver.ts +19 -0
  107. package/src/spoke/media-bytes.ts +171 -0
  108. package/src/spoke/media-policy.ts +394 -0
  109. package/src/spoke/media.ts +189 -0
  110. package/src/spoke/own-server.ts +30 -0
  111. package/src/spoke/preview-bundle.ts +538 -0
  112. package/src/spoke/spoke.ts +387 -17
  113. package/src/spoke/timeline.ts +59 -5
  114. package/src/spoke/voice-front.ts +335 -0
  115. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  116. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  117. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  118. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  119. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  120. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  121. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  122. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  123. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  124. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  125. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  126. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  127. package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
  128. package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
  129. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
  130. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  131. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  132. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  133. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  134. package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
  135. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  136. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  137. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  138. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  139. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  140. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  141. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  142. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  143. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  144. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  145. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  146. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  147. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  148. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  149. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  150. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  151. package/web/dist/assets/Library-CcjZXsfF.js +2 -0
  152. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  153. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  154. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  155. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  156. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  157. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  158. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  159. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  160. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  161. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  162. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  163. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
  164. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  165. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  166. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  167. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  168. package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
  169. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  170. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  171. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  172. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  173. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  174. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  175. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  176. package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
  177. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  178. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  179. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  180. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  181. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  182. package/web/dist/assets/index-LNMZCqgE.js +36 -0
  183. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  184. package/web/dist/assets/live-ycZ_aQZO.js +5 -0
  185. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  186. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  187. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  188. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
  189. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
  190. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
  191. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  192. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  193. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  194. package/web/dist/assets/source-BWlvaODa.js +1 -0
  195. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  196. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  197. package/web/dist/index.html +8 -6
  198. package/web/src/voice/turn/LICENSE +28 -0
  199. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  200. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  201. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  202. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  203. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  204. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  205. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  206. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  207. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  208. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  209. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  210. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  211. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  212. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  213. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -36,9 +36,11 @@ import { extractReply } from "../reply"
36
36
  import { copiesHubIdentity, forkIdentityNote, notAUserMessage, opError, REVERT_BUSY, REVERT_END_BUSY, revertPreview, revertSummary, userContent, userIndex, visibleMessages, withStagedAt } from "./fork-revert"
37
37
  import { TurnTracker } from "../turns"
38
38
  import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineItems } from "./timeline"
39
+ import { MediaCache, timelineMedia, type MediaMessages } from "./media"
40
+ import { BundleCache, previewBundle } from "./preview-bundle"
39
41
  import { CodedError, SpokeConnection, type Backoff, type ConnectionState } from "./client"
40
- import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
41
- import { DeliveryLedger, deferredWhileBusy, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
42
+ import { isHubPrompt, PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
43
+ import { DeliveryLedger, deferredWhileBusy, deliverDescription, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
42
44
  import { pollEnrollment, startEnrollment } from "./enroll"
43
45
  import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
44
46
  import type { LocalHubResult } from "./localhub"
@@ -51,6 +53,7 @@ import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge.ts"
51
53
  import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
52
54
  import { browserSessions, eventSessionID, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
53
55
  import { renderTaskBoard, TaskBoard } from "./taskboard"
56
+ import { FRONT_TITLE_PREFIX, frontTitle, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts } from "./voice-front"
54
57
  import { watchConfig, type ConfigWatcher } from "./watch"
55
58
 
56
59
  export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
@@ -103,9 +106,10 @@ export interface SpokeHost {
103
106
  /**
104
107
  * Admits text as a synthetic user message (OpenCode's session.synthetic): the model sees only `text`; the
105
108
  * prompt hook does not run. `id` (msg_…) makes it idempotent; `resume: false` admits without waking.
109
+ * `description`: the one-line notice the TUI shows for it (the model does not see it).
106
110
  * Absent: this OpenCode cannot take hub messages (no "deliver" feature).
107
111
  */
108
- synthetic?(input: { sessionID: string; id: string; text: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
112
+ synthetic?(input: { sessionID: string; id: string; text: string; description?: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
109
113
  context(sessionID: string): Promise<ReadonlyArray<unknown>>
110
114
  listModels?(): Promise<ReadonlyArray<unknown>>
111
115
  getSession(sessionID: string): Promise<HostSession>
@@ -155,8 +159,10 @@ export interface SpokeHost {
155
159
  */
156
160
  /** `signal` aborts when the spoke abandons the listing because it timed out (listingTimeoutMs): stop asking the server. */
157
161
  listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
158
- /** Creates a session in a directory (service processes only). */
162
+ /** Creates a session in a directory (the create op: service processes only; voice fronts: any process). */
159
163
  createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
164
+ /** Deletes a session (OpenCode's session.remove); a voice front when its conversation ends. */
165
+ removeSession?(sessionID: string): Promise<unknown>
160
166
  /** MCP servers and their status for a directory (raw OpenCode McpServer list). */
161
167
  mcpList?(directory: string): Promise<ReadonlyArray<unknown>>
162
168
  /** Working-tree changes of a directory (raw OpenCode FileDiffInfo list); undefined when nothing can read that directory. */
@@ -181,6 +187,11 @@ export interface SpokeHost {
181
187
  revertClear?(sessionID: string): Promise<unknown>
182
188
  /** Makes a staged revert permanent (session.revert.commit). Absent with the two above: no "revert". */
183
189
  revertCommit?(sessionID: string): Promise<unknown>
190
+ /**
191
+ * (S4 M2) An item of OpenCode's media store (`media:<sha256>` URIs of tool output), read from the own server;
192
+ * undefined when it has none or cannot be reached. Absent: media: items are not served.
193
+ */
194
+ fetchMedia?(hash: string): Promise<{ bytes: Uint8Array; mime?: string } | undefined>
184
195
  }
185
196
 
186
197
  /** A session's pending forms and permission requests as OpenCode lists them (raw, see SpokeHost.pending). */
@@ -262,6 +273,8 @@ export interface SpokeOptions {
262
273
  * spoke never enrols, starts a local hub or reads Herdr.
263
274
  */
264
275
  readonly launch?: LaunchConfig
276
+ /** Voice-front wakes within this window are merged into one (default 1.5 s). */
277
+ readonly frontMergeMs?: number
265
278
  }
266
279
 
267
280
  export type EnrollmentState = "pending" | "approved" | "denied" | "expired" | "error"
@@ -362,6 +375,10 @@ export class Spoke {
362
375
  private readonly turns = new TurnTracker()
363
376
  /** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
364
377
  private readonly timelines = new TimelineCache()
378
+ /** (S4 M2) Decoded timeline images, so later chunks do not read the history again; dropped on any revert. */
379
+ private readonly media = new MediaCache()
380
+ /** (Session media §3 4b) Walked HTML preview bundles, for the chunks of their larger files. */
381
+ private readonly bundles = new BundleCache()
365
382
  /** Per session: a counter of revert events (a staged event's late read must not undo a later one). */
366
383
  private readonly revertSeq = new Map<string, number>()
367
384
  private readonly voiceTurns = new Map<string, number>()
@@ -385,6 +402,30 @@ export class Spoke {
385
402
  /** Session → callbacks waiting for its next session.renamed event (autoName). */
386
403
  private readonly renameWaiters = new Map<string, Set<(title: string) => void>>()
387
404
  private conversationChain: Promise<unknown> = Promise.resolve()
405
+ /** (feature "voiceFront") Main sessions ↔ their hidden voice-front sessions, and what each front knows of its main. */
406
+ readonly fronts: VoiceFronts
407
+ /** Fronts whose deletion failed: forgotten as fronts and never reported again. */
408
+ private readonly retiredFronts = new Set<string>()
409
+ /**
410
+ * Front creations under way, and the sessions they made that are not linked yet: OpenCode announces a new session
411
+ * before create() returns, so until linked, a "Voice: " session (or the made one) is left out of snapshots.
412
+ */
413
+ private frontsCreating = 0
414
+ /**
415
+ * Main → its front that failed (an execution error): no longer linked or woken, still reported hidden, deleted on
416
+ * off. `frontFallbacks`: mains whose front failed or vanished; the hub talks to them directly, as voice turns.
417
+ */
418
+ private readonly failedFronts = new Map<string, string>()
419
+ /**
420
+ * "Voice: " sessions this process has not linked, by what a check found (see checkVoiceTitled): "checking" and
421
+ * "hidden" are not reported, "user" is reported as usual, "front" (on the voice-front agent: another process's
422
+ * front, or one an earlier process left) is reported with agent voice-front and no voiceFrontOf: the hub decides
423
+ * whether it is stale and asks for its removal (removeFront). The spoke never deletes one on its own.
424
+ */
425
+ private readonly voiceTitled = new Map<string, "checking" | "user" | "front" | "hidden">()
426
+ private readonly frontFallbacks = new Set<string>()
427
+ private readonly unlinkedFronts = new Set<string>()
428
+ private frontWakes = 0
388
429
  private readonly abort = new AbortController()
389
430
  private snapshotTimer?: ReturnType<typeof setTimeout>
390
431
  /** Sessions the last snapshot sent listed (the hub keeps status only for sessions it was told about). */
@@ -446,6 +487,11 @@ export class Spoke {
446
487
  ) {
447
488
  this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
448
489
  this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
490
+ this.fronts = new VoiceFronts({
491
+ ...(options.frontMergeMs !== undefined ? { mergeMs: options.frontMergeMs } : {}),
492
+ send: (front, text) => void this.wakeFront(front, text),
493
+ frontBusy: (front) => this.running.has(front) || this.tracker.isBusy(front),
494
+ })
449
495
  this.contexts = new SessionContexts({
450
496
  ...(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {}),
451
497
  ...(options.conciergeLockReadFiles?.length ? { readLockFiles: options.conciergeLockReadFiles } : {}),
@@ -500,16 +546,76 @@ export class Spoke {
500
546
  this.connectionKey = ""
501
547
  this.tokens.clear()
502
548
  this.actTokens.clear()
549
+ this.fronts.stop()
503
550
  }
504
551
 
552
+ /** A voice-styled turn: a hub /turn asked for it, or the session's voice front failed (the hub talks to it now). */
505
553
  isVoiceTurn(sessionID: string): boolean {
506
- return (this.voiceTurns.get(sessionID) ?? 0) > 0
554
+ return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID)
507
555
  }
508
556
 
557
+ /** Conversation mode is on: the agent swap (or its context-hook fallback), or a voice front (also a failed one). */
509
558
  inConversation(sessionID: string): boolean {
559
+ return this.conversations.has(sessionID) || this.fronts.frontOf(sessionID) !== undefined || this.frontFallbacks.has(sessionID)
560
+ }
561
+
562
+ /** The session itself is the voice orchestrator (today's agent swap): the context hook adds its parts. */
563
+ orchestrates(sessionID: string): boolean {
510
564
  return this.conversations.has(sessionID)
511
565
  }
512
566
 
567
+ /**
568
+ * voice_main_send: relays the user's request from a voice front to its main session, as the user's (hub-sourced)
569
+ * prompt. "auto": idle starts a turn, busy steers; "queue" waits behind main's running turn.
570
+ */
571
+ async frontSend(callerID: string, input: { text?: unknown; delivery?: unknown }): Promise<{ text: string; inboxID?: string }> {
572
+ const main = this.fronts.mainOf(callerID)
573
+ if (!main) return { text: "This session is not a voice front; nothing was sent." }
574
+ const text = typeof input.text === "string" ? input.text.trim() : ""
575
+ if (!text) return { text: "Pass the request as text; nothing was sent." }
576
+ const busy = this.running.has(main) || this.tracker.isBusy(main)
577
+ const delivery = relayDelivery(input.delivery, busy)
578
+ const inbox = await this.host.prompt({ sessionID: main, text, delivery, metadata: { source: PROMPT_SOURCE, kind: "voice-front" } })
579
+ if (delivery === "queue") {
580
+ this.fronts.queued(main, inbox.id, text)
581
+ return { text: "Queued for the main session; it runs after its current turn.", inboxID: inbox.id }
582
+ }
583
+ return { text: busy ? "Steered into the main session's running turn." : "Sent; the main session started working on it.", inboxID: inbox.id }
584
+ }
585
+
586
+ /** voice_main_stop: interrupts the front's main session and everything running below it (as the stop op). */
587
+ async frontStop(callerID: string): Promise<{ text: string; stopped: string[] }> {
588
+ const main = this.fronts.mainOf(callerID)
589
+ if (!main) return { text: "This session is not a voice front; nothing was stopped.", stopped: [] }
590
+ this.fronts.stopRequested(main)
591
+ const { stopped } = await this.stopTree(main)
592
+ return { text: "Stopped the main session's work.", stopped: [...stopped] }
593
+ }
594
+
595
+ /** The voice front's per-turn note about its main session; undefined for any other session. */
596
+ frontContext(sessionID: string): string | undefined {
597
+ const main = this.fronts.mainOf(sessionID)
598
+ if (!main) return undefined
599
+ const title = this.tracker.snapshot(Date.now()).find((s) => s.id === main)?.title
600
+ const workers = this.taskBoard(main)
601
+ return this.fronts.context(sessionID, { ...(title ? { title } : {}), busy: this.running.has(main) || this.tracker.isBusy(main), ...(workers ? { workers } : {}) })
602
+ }
603
+
604
+ /** A hub update for a front (`<system-update>`): admitted as the hub's (never the user's), steered, waking it. */
605
+ private async wakeFront(front: string, text: string): Promise<void> {
606
+ const metadata = { source: PROMPT_SOURCE, kind: "voice-front-update" }
607
+ try {
608
+ const synthetic = this.host.synthetic?.bind(this.host)
609
+ if (synthetic) {
610
+ const id = harnessMessageID(`voice-front:${front}:${Date.now()}:${++this.frontWakes}`)
611
+ this.origins.hub(id)
612
+ await synthetic({ sessionID: front, id, text, metadata: { ...metadata, messageID: id }, delivery: "steer", resume: true })
613
+ } else await this.host.prompt({ sessionID: front, text, delivery: "steer", metadata })
614
+ } catch (cause) {
615
+ console.error("[hub] could not wake the voice front", errorText(cause))
616
+ }
617
+ }
618
+
513
619
  /** The agent the voice orchestrator delegates to (per-machine config). */
514
620
  /** The model subagents should work on for a session in conversation mode, as "provider/model[#variant]". */
515
621
  workModelFor(sessionID: string): string | undefined {
@@ -606,6 +712,10 @@ export class Spoke {
606
712
  ...this.openSessions().keys(),
607
713
  ...this.tracker.active(),
608
714
  ...this.conversations.keys(),
715
+ ...this.fronts.mainIDs(),
716
+ ...this.fronts.mainIDs().map((id) => this.fronts.frontOf(id)!),
717
+ ...this.frontFallbacks,
718
+ ...this.failedFronts.values(),
609
719
  ...(this.config.mode === "session" ? this.config.sessions : []),
610
720
  ])
611
721
  }
@@ -651,9 +761,9 @@ export class Spoke {
651
761
  * orchestrator prompt. Off: restore the previous agent unless the user picked another meanwhile.
652
762
  * Calls are serialized so a re-sent "on" cannot race an "off".
653
763
  */
654
- setConversation(sessionID: string, on: boolean): Promise<ResultFor["conversation"]> {
764
+ setConversation(sessionID: string, on: boolean, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
655
765
  if (!isExposed(this.config, this.tracker, sessionID)) return Promise.reject(new SpokeError("invalid", NOT_SHARED))
656
- const run = () => (on ? this.conversationOn(sessionID) : this.conversationOff(sessionID))
766
+ const run = () => (on ? this.conversationOn(sessionID, front, frontID) : this.conversationOff(sessionID))
657
767
  const result = this.conversationChain.then(run, run)
658
768
  this.conversationChain = result.catch(() => undefined)
659
769
  return result
@@ -915,7 +1025,8 @@ export class Spoke {
915
1025
  opencodeVersion: this.host.opencodeVersion,
916
1026
  pluginVersion: this.options.pluginVersion,
917
1027
  // Hub messages and prompts need OpenCode's synthetic messages.
918
- // Fork and revert need OpenCode's own server (the plugin API has neither).
1028
+ // Fork and revert need OpenCode's own server (the plugin API has neither). "voiceFront" is always listed: a
1029
+ // process that cannot create the front answers the conversation op without `front` (today's agent swap).
919
1030
  features: SPOKE_FEATURES.filter(
920
1031
  (f) =>
921
1032
  ((f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined) &&
@@ -1001,16 +1112,39 @@ export class Spoke {
1001
1112
  }
1002
1113
  }
1003
1114
 
1004
- private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
1005
- const entry = this.conversations.get(sessionID)
1006
- const session = await this.host.getSession(sessionID)
1115
+ private async conversationOn(sessionID: string, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
1116
+ let entry = this.conversations.get(sessionID)
1117
+ let session = await this.host.getSession(sessionID)
1007
1118
  // (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
1008
- // never escapes its restricted agent, and the context hook adds no orchestrator parts).
1119
+ // never escapes its restricted agent, and the context hook adds no orchestrator parts). It never gets a front.
1009
1120
  // A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
1010
1121
  if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
1011
1122
  this.conversations.set(sessionID, {})
1012
1123
  return { agent: session.agent ?? (await this.defaultAgent()) }
1013
1124
  }
1125
+ // The front failed: the hub talks to main directly and resends on without front (a reconnect). Main keeps its
1126
+ // agent and model; its turns stay voice turns (frontFallbacks) until off.
1127
+ if (!front && this.frontFallbacks.has(sessionID)) return { agent: session.agent ?? (await this.defaultAgent()) }
1128
+ if (front) {
1129
+ const live = this.fronts.frontOf(sessionID)
1130
+ if (live && this.tracker.has(live)) return { agent: session.agent ?? (await this.defaultAgent()), front: { sessionID: live } }
1131
+ // A failed front is replaced (deleted first); the session stops being a fallback.
1132
+ await this.endFront(sessionID)
1133
+ // Moving from the agent swap to a front: main gets its own agent and model back first.
1134
+ if (entry) {
1135
+ await this.conversationOff(sessionID)
1136
+ entry = undefined
1137
+ session = await this.host.getSession(sessionID)
1138
+ }
1139
+ // The hub knows this conversation's front (a resend to a restarted process): re-linked if it still exists.
1140
+ const adopted = frontID ? await this.adoptFront(sessionID, frontID) : undefined
1141
+ if (adopted) return { agent: session.agent ?? (await this.defaultAgent()), front: { sessionID: adopted } }
1142
+ const created = await this.createFront(sessionID, session).catch((cause) => {
1143
+ console.error("[hub] could not start a voice front; using the voice agent", errorText(cause))
1144
+ return undefined
1145
+ })
1146
+ if (created) return { agent: session.agent ?? (await this.defaultAgent()), front: { sessionID: created } }
1147
+ }
1014
1148
  this.rememberModel(sessionID, session.model)
1015
1149
  const current = session.agent
1016
1150
  const models = await this.voiceModelOn(sessionID, session, entry)
@@ -1044,7 +1178,156 @@ export class Spoke {
1044
1178
  return { voiceModel, ...(previousModel ? { previousModel } : {}) }
1045
1179
  }
1046
1180
 
1181
+ /**
1182
+ * Creates main's voice front: a root session in main's directory, titled after it, on the voice-front agent and
1183
+ * the voice model, reported with voiceFrontOf. Throws (after removing a half-made front) when it cannot.
1184
+ */
1185
+ private async createFront(main: string, session: HostSession): Promise<string> {
1186
+ const create = this.host.createSession?.bind(this.host)
1187
+ if (!create) throw new Error("this OpenCode process cannot create sessions")
1188
+ if (!(await this.host.hasAgent(VOICE_FRONT_AGENT))) throw new Error(`no ${VOICE_FRONT_AGENT} agent`)
1189
+ const directory = this.tracker.directoryOf(main) ?? session.location?.directory ?? this.host.directory
1190
+ const title = frontTitle(session.title)
1191
+ this.frontsCreating++
1192
+ let id: string | undefined
1193
+ try {
1194
+ id = (await create({ directory, title })).id
1195
+ this.unlinkedFronts.add(id)
1196
+ try {
1197
+ // Agent first: switching the agent may reset the model.
1198
+ await this.host.switchAgent(id, VOICE_FRONT_AGENT)
1199
+ const voice = this.voiceModelFor(main)
1200
+ if (voice) await this.switchModel(id, voice)
1201
+ } catch (cause) {
1202
+ await this.host.removeSession?.(id).catch(() => undefined)
1203
+ throw cause
1204
+ }
1205
+ this.fronts.link(main, id, await this.lastReply(main))
1206
+ } finally {
1207
+ this.frontsCreating--
1208
+ if (id !== undefined) this.unlinkedFronts.delete(id)
1209
+ }
1210
+ const now = Date.now()
1211
+ const source = this.tracker.snapshot(now).find((s) => s.id === main)
1212
+ this.tracker.observe({ type: "session.created", data: { sessionID: id, title, location: { directory } } }, now)
1213
+ this.tracker.setInfo(id, { title, directory, updated: now, ...(source?.project ? { project: source.project } : {}), ...(source?.projectName ? { projectName: source.projectName } : {}) })
1214
+ this.sendSnapshot()
1215
+ return id
1216
+ }
1217
+
1218
+ /** A session that is (or may be) a front still being created: never reported as an ordinary session. */
1219
+ private frontInMaking(s: { id: string; title?: string }): boolean {
1220
+ if (this.frontMainOf(s.id)) return false
1221
+ return this.unlinkedFronts.has(s.id) || (this.frontsCreating > 0 && (s.title ?? "").startsWith(FRONT_TITLE_PREFIX))
1222
+ }
1223
+
1224
+ /**
1225
+ * A "Voice: " session this process did not link, as checked (checkVoiceTitled): "checking" or "hidden" are held
1226
+ * back from snapshots; "front" and "user" are reported.
1227
+ */
1228
+ private unlinkedFront(s: { id: string; title?: string }): boolean {
1229
+ if (!(s.title ?? "").startsWith(FRONT_TITLE_PREFIX) || this.frontMainOf(s.id)) return false
1230
+ const state = this.voiceTitled.get(s.id)
1231
+ if (state === undefined) void this.checkVoiceTitled(s.id)
1232
+ return state === undefined || state === "checking" || state === "hidden"
1233
+ }
1234
+
1235
+ /** Whether a session this process did not link is a voice front (checked as "front"); see checkVoiceTitled. */
1236
+ private unlinkedVoiceFront(sessionID: string): boolean {
1237
+ return !this.frontMainOf(sessionID) && this.voiceTitled.get(sessionID) === "front"
1238
+ }
1239
+
1240
+ /**
1241
+ * Classifies an unlinked "Voice: " session. A front runs on the voice-front agent (OpenCode's session record names
1242
+ * it): "front". Another agent or the default: "user". If the record cannot be read, a session without any message
1243
+ * from a human (only the hub's prompts) is "hidden" (not reported, never deleted); one with a human's is "user".
1244
+ */
1245
+ private async checkVoiceTitled(sessionID: string): Promise<void> {
1246
+ this.voiceTitled.set(sessionID, "checking")
1247
+ let state: "user" | "front" | "hidden"
1248
+ try {
1249
+ state = (await this.host.getSession(sessionID)).agent === VOICE_FRONT_AGENT ? "front" : "user"
1250
+ } catch {
1251
+ state = await this.host
1252
+ .context(sessionID)
1253
+ .then((messages) => (messages.some((m) => (m as { type?: unknown }).type === "user" && !isHubPrompt((m as { metadata?: unknown }).metadata)) ? "user" : "hidden") as "user" | "hidden")
1254
+ .catch(() => "user" as const)
1255
+ }
1256
+ // Linked meanwhile (a front this process is creating): checked again later if it is ever unlinked.
1257
+ if (this.frontMainOf(sessionID) || this.unlinkedFronts.has(sessionID) || this.frontsCreating > 0) return void this.voiceTitled.delete(sessionID)
1258
+ this.voiceTitled.set(sessionID, state)
1259
+ if (state !== "hidden") this.scheduleSnapshot()
1260
+ }
1261
+
1262
+ /**
1263
+ * Re-links an existing front to main (the hub names it on a resend): only a session on the voice-front agent that
1264
+ * this process has not linked to another session. Undefined when it is gone or no front.
1265
+ */
1266
+ private async adoptFront(main: string, frontID: string): Promise<string | undefined> {
1267
+ if (frontID === main || (this.frontMainOf(frontID) ?? main) !== main) return undefined
1268
+ const info = await this.host.getSession(frontID).catch(() => undefined)
1269
+ if (info?.agent !== VOICE_FRONT_AGENT || (this.frontMainOf(frontID) ?? main) !== main) return undefined
1270
+ this.fronts.link(main, frontID, await this.lastReply(main))
1271
+ this.voiceTitled.delete(frontID)
1272
+ const now = Date.now()
1273
+ if (!this.tracker.has(frontID)) this.tracker.observe({ type: "session.created", data: { sessionID: frontID, title: info.title, location: info.location } }, now)
1274
+ console.warn(`[hub] re-linked the voice front ${frontID} of ${main}`)
1275
+ this.sendSnapshot()
1276
+ return frontID
1277
+ }
1278
+
1279
+ /** Main's latest assistant text, to seed its front's context. */
1280
+ private async lastReply(main: string): Promise<{ lastText?: string }> {
1281
+ const lastText = await this.host
1282
+ .context(main)
1283
+ .then((messages) => contextMessages(messages).filter((m) => m.role === "assistant").at(-1)?.text)
1284
+ .catch(() => undefined)
1285
+ return lastText ? { lastText: trimText(lastText) } : {}
1286
+ }
1287
+
1288
+ /**
1289
+ * removeFront: the hub found a voice front no conversation uses. Deleted only when it is on the voice-front agent
1290
+ * and this process has not linked it (as a front, a failed front, or one being created).
1291
+ */
1292
+ private async removeFront(sessionID: string): Promise<ResultFor["removeFront"]> {
1293
+ if (this.frontMainOf(sessionID) || this.unlinkedFronts.has(sessionID) || !this.host.removeSession) return { removed: false }
1294
+ const agent = await this.host.getSession(sessionID).then((s) => s.agent, () => undefined)
1295
+ if (agent !== VOICE_FRONT_AGENT || this.frontMainOf(sessionID) || this.unlinkedFronts.has(sessionID)) return { removed: false }
1296
+ console.warn(`[hub] deleting a stale voice front the hub found: ${sessionID}`)
1297
+ await this.host.interrupt(sessionID).catch(() => undefined)
1298
+ await this.host.removeSession(sessionID)
1299
+ return { removed: true }
1300
+ }
1301
+
1302
+ /** The model a session's voice runs on: its voice selection, else the machine's voiceModel; undefined: default. */
1303
+ private voiceModelFor(sessionID: string): ModelRef | undefined {
1304
+ const selected = this.modelPrefs.get(sessionID)?.voice
1305
+ if (selected) return { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
1306
+ return this.config.voiceModel ? parseModelRef(this.config.voiceModel) : undefined
1307
+ }
1308
+
1309
+ /**
1310
+ * Ends main's voice front: interrupted, then deleted. If it cannot be deleted it is forgotten as a front and never
1311
+ * reported again (OpenCode has no archive call). Main is untouched.
1312
+ */
1313
+ private async endFront(main: string): Promise<void> {
1314
+ const front = this.fronts.unlink(main) ?? this.failedFronts.get(main)
1315
+ this.failedFronts.delete(main)
1316
+ this.frontFallbacks.delete(main)
1317
+ if (!front) return
1318
+ await this.host.interrupt(front).catch(() => undefined)
1319
+ try {
1320
+ if (!this.host.removeSession) throw new Error("this OpenCode process cannot delete sessions")
1321
+ await this.host.removeSession(front)
1322
+ } catch (cause) {
1323
+ console.error("[hub] could not delete a voice front; it is no longer reported", errorText(cause))
1324
+ this.retiredFronts.add(front)
1325
+ }
1326
+ if (this.tracker.observe({ type: "session.deleted", data: { sessionID: front } }, Date.now())) this.sendSnapshot()
1327
+ }
1328
+
1047
1329
  private async conversationOff(sessionID: string): Promise<ResultFor["conversation"]> {
1330
+ await this.endFront(sessionID)
1048
1331
  const entry = this.conversations.get(sessionID)
1049
1332
  this.conversations.delete(sessionID)
1050
1333
  const session = await this.host.getSession(sessionID)
@@ -1145,6 +1428,27 @@ export class Spoke {
1145
1428
  return { detail: boundedTimelinePart(await this.host.context(req.sessionID), req.messageID, req.partID) }
1146
1429
  case "timelineFile":
1147
1430
  return timelineFile(await this.host.context(req.sessionID), req.messageID, req.index) satisfies ResultFor["timelineFile"]
1431
+ case "timelineMedia":
1432
+ return (await timelineMedia({
1433
+ sessionID: req.sessionID,
1434
+ messageID: req.messageID,
1435
+ ref: req.ref,
1436
+ ...(req.offset === undefined ? {} : { offset: req.offset }),
1437
+ cache: this.media,
1438
+ load: () => this.mediaMessages(req.sessionID),
1439
+ directory: this.directoryOf(req.sessionID),
1440
+ ...(this.host.fetchMedia ? { fetchMedia: (hash: string) => this.host.fetchMedia!(hash) } : {}),
1441
+ })) satisfies ResultFor["timelineMedia"]
1442
+ case "previewBundle":
1443
+ return (await previewBundle({
1444
+ sessionID: req.sessionID,
1445
+ messageID: req.messageID,
1446
+ path: req.path,
1447
+ ...(req.bundle === undefined ? {} : { bundle: req.bundle, file: req.file!, offset: req.offset! }),
1448
+ cache: this.bundles,
1449
+ load: () => this.mediaMessages(req.sessionID),
1450
+ directory: this.directoryOf(req.sessionID),
1451
+ })) satisfies ResultFor["previewBundle"]
1148
1452
  case "steer": {
1149
1453
  const voice = isExposed(this.config, this.tracker, req.sessionID)
1150
1454
  if (voice) await this.applyVoiceModel(req.sessionID)
@@ -1162,6 +1466,8 @@ export class Spoke {
1162
1466
  return {}
1163
1467
  case "rename":
1164
1468
  return this.rename(req.sessionID, req.title)
1469
+ case "removeFront":
1470
+ return this.removeFront(req.sessionID)
1165
1471
  case "dismissError":
1166
1472
  this.unheardFailures.delete(req.sessionID)
1167
1473
  return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
@@ -1177,7 +1483,7 @@ export class Spoke {
1177
1483
  return patch ? { patch } : {}
1178
1484
  }
1179
1485
  case "conversation":
1180
- return this.setConversation(req.sessionID, req.on)
1486
+ return this.setConversation(req.sessionID, req.on, req.front === true, req.front === true ? req.frontID : undefined)
1181
1487
  case "deliver":
1182
1488
  return this.deliver(req)
1183
1489
  case "sessionContext": {
@@ -1302,6 +1608,8 @@ export class Spoke {
1302
1608
  const staged = await this.host.revertStage!({ sessionID, messageID, files })
1303
1609
  const revert = withStagedAt(revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!, this.tracker.revertOf(sessionID), Date.now())
1304
1610
  this.timelines.invalidate(sessionID)
1611
+ this.media.invalidate(sessionID)
1612
+ this.bundles.invalidate(sessionID)
1305
1613
  // Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
1306
1614
  this.tracker.setRevert(sessionID, revert)
1307
1615
  this.sendSnapshot()
@@ -1311,6 +1619,8 @@ export class Spoke {
1311
1619
  /** After a restore or commit: no revert is staged; reported at once. */
1312
1620
  private revertDone(sessionID: string): Record<string, never> {
1313
1621
  this.timelines.invalidate(sessionID)
1622
+ this.media.invalidate(sessionID)
1623
+ this.bundles.invalidate(sessionID)
1314
1624
  this.tracker.setRevert(sessionID, undefined)
1315
1625
  this.sendSnapshot()
1316
1626
  return {}
@@ -1321,17 +1631,27 @@ export class Spoke {
1321
1631
  * revert is read from OpenCode's session (so one staged before this process saw it counts) and reported.
1322
1632
  */
1323
1633
  private async shownMessages(sessionID: string): Promise<readonly unknown[]> {
1634
+ return (await this.mediaMessages(sessionID)).shown
1635
+ }
1636
+
1637
+ /**
1638
+ * The session's messages, those a timeline shows (shownMessages) and the message of a committed revert (it and
1639
+ * those after it are gone): what timelineMedia checks a message against.
1640
+ */
1641
+ private async mediaMessages(sessionID: string): Promise<MediaMessages> {
1642
+ const committed = this.tracker.committedOf(sessionID)?.messageID
1324
1643
  const [messages, session] = await Promise.all([
1325
1644
  this.host.context(sessionID),
1326
1645
  this.canRevert() ? this.host.getSession(sessionID).catch(() => undefined) : Promise.resolve(undefined),
1327
1646
  ])
1328
- if (!this.canRevert()) return messages
1647
+ const base = { all: messages, ...(committed ? { committed } : {}) }
1648
+ if (!this.canRevert()) return { ...base, shown: messages }
1329
1649
  const raw = session ? (session as { revert?: unknown }).revert : undefined
1330
1650
  const known = this.tracker.revertOf(sessionID)
1331
1651
  const read = session ? revertSummary(raw, messages) : known
1332
1652
  const revert = session && read ? withStagedAt(read, known, Date.now()) : read
1333
1653
  if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
1334
- return visibleMessages(messages, revert?.messageID)
1654
+ return { ...base, shown: visibleMessages(messages, revert?.messageID) }
1335
1655
  }
1336
1656
 
1337
1657
  /**
@@ -1354,6 +1674,8 @@ export class Spoke {
1354
1674
  * late read only fills in the commit's window start.
1355
1675
  */
1356
1676
  private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
1677
+ this.media.invalidate(sessionID)
1678
+ this.bundles.invalidate(sessionID)
1357
1679
  if (!this.tracker.has(sessionID)) return
1358
1680
  const seq = (this.revertSeq.get(sessionID) ?? 0) + 1
1359
1681
  this.revertSeq.set(sessionID, seq)
@@ -1398,7 +1720,7 @@ export class Spoke {
1398
1720
  const id = harnessMessageID(req.messageID)
1399
1721
  // Marked before sending: OpenCode may report it delivered before the call returns.
1400
1722
  this.origins.hub(id)
1401
- const admitted = await synthetic({ sessionID: req.sessionID, id, text: deliverText(req), metadata: deliverMetadata(req), delivery: plan.delivery, resume: plan.resume })
1723
+ const admitted = await synthetic({ sessionID: req.sessionID, id, text: deliverText(req), description: deliverDescription(req), metadata: deliverMetadata(req), delivery: plan.delivery, resume: plan.resume })
1402
1724
  this.timelines.invalidate(req.sessionID)
1403
1725
  return { result: { delivered: plan.delivered, inboxID: typeof admitted?.id === "string" && admitted.id ? admitted.id : id }, resumed: plan.resume }
1404
1726
  })
@@ -1605,6 +1927,8 @@ export class Spoke {
1605
1927
  private async applyVoiceModel(sessionID: string): Promise<void> {
1606
1928
  const selected = this.modelPrefs.get(sessionID)?.voice
1607
1929
  const override = this.voiceOverrides.get(sessionID)
1930
+ // A session with a voice front keeps its model: the voice selection is the front's.
1931
+ if (this.fronts.frontOf(sessionID)) return
1608
1932
  if ((!selected && !override) || this.tracker.active().includes(this.tracker.rootOf(sessionID))) return
1609
1933
  const current = await this.currentModel(sessionID)
1610
1934
  const onOverride = override !== undefined && current !== undefined && sameModel(current, override.applied)
@@ -1798,6 +2122,8 @@ export class Spoke {
1798
2122
  this.tokens.delete(sessionID)
1799
2123
  this.actTokens.delete(sessionID)
1800
2124
  this.profiles.forget(sessionID)
2125
+ this.media.invalidate(sessionID)
2126
+ this.bundles.invalidate(sessionID)
1801
2127
  }
1802
2128
  if (event.type === "form.replied" || event.type === "form.cancelled") {
1803
2129
  const formID = (event.data as Record<string, unknown> | undefined)?.id
@@ -1813,6 +2139,7 @@ export class Spoke {
1813
2139
  }
1814
2140
  this.trackAsk(event)
1815
2141
  if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
2142
+ this.observeFronts(event, sessionID)
1816
2143
  if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
1817
2144
  if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
1818
2145
  if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
@@ -1825,6 +2152,42 @@ export class Spoke {
1825
2152
  }
1826
2153
  }
1827
2154
 
2155
+ /**
2156
+ * Feeds the voice fronts. A deleted main ends its front. A front that fails (as the hub sees it: front_failed) or is
2157
+ * deleted is unlinked, never woken again, and its main falls back to direct voice turns until off.
2158
+ */
2159
+ private observeFronts(event: EventLike, sessionID: unknown): void {
2160
+ this.fronts.observe(event, (id) => this.tracker.rootOf(id))
2161
+ if (typeof sessionID !== "string") return
2162
+ if (event.type === "session.execution.failed") {
2163
+ const main = this.fronts.mainOf(sessionID)
2164
+ if (!main) return
2165
+ this.fronts.unlink(main)
2166
+ this.failedFronts.set(main, sessionID)
2167
+ this.frontFallbacks.add(main)
2168
+ console.warn(`[hub] voice front of ${main} failed; the session talks directly until voice ends`)
2169
+ return
2170
+ }
2171
+ if (event.type !== "session.deleted") return
2172
+ if (this.fronts.frontOf(sessionID) || this.failedFronts.has(sessionID)) void this.endFront(sessionID)
2173
+ const main = this.fronts.mainOf(sessionID)
2174
+ if (main) {
2175
+ this.fronts.unlink(main)
2176
+ this.frontFallbacks.add(main)
2177
+ }
2178
+ for (const [mainID, front] of this.failedFronts) if (front === sessionID) this.failedFronts.delete(mainID)
2179
+ this.retiredFronts.delete(sessionID)
2180
+ this.voiceTitled.delete(sessionID)
2181
+ }
2182
+
2183
+ /** The main session of a front, also of a failed one (still reported hidden until deleted). */
2184
+ private frontMainOf(sessionID: string): string | undefined {
2185
+ const main = this.fronts.mainOf(sessionID)
2186
+ if (main) return main
2187
+ for (const [mainID, front] of this.failedFronts) if (front === sessionID) return mainID
2188
+ return undefined
2189
+ }
2190
+
1828
2191
  /** Sends an OpenCode event to the hub (compact), when connected and the hub may see its session. */
1829
2192
  private forward(event: EventLike): void {
1830
2193
  if (this.connection?.state !== "connected" || (!FORWARDED_EVENTS.has(event.type) && !COMPACTED_EVENTS.has(event.type))) return
@@ -2108,7 +2471,14 @@ export class Spoke {
2108
2471
  if (this.snapshotTimer) clearTimeout(this.snapshotTimer)
2109
2472
  this.snapshotTimer = undefined
2110
2473
  const tui = this.presenceBook.open(Date.now())
2111
- const sessions = browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) }).map((s) => ({ ...s, ...this.contexts.report(s.id) }))
2474
+ const sessions = browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) })
2475
+ .filter((s) => !this.retiredFronts.has(s.id) && !this.frontInMaking(s) && !this.unlinkedFront(s))
2476
+ .map((s) => {
2477
+ // A voice front is reported with its main (the hub routes through it and never lists it), always exposed.
2478
+ const main = this.frontMainOf(s.id)
2479
+ const front = main ? { voiceFrontOf: main, exposed: true, agent: VOICE_FRONT_AGENT } : this.unlinkedVoiceFront(s.id) ? { agent: VOICE_FRONT_AGENT } : {}
2480
+ return { ...s, ...this.contexts.report(s.id), ...front }
2481
+ })
2112
2482
  this.connection?.send({ t: "sessions", sessions })
2113
2483
  this.announced = new Set(sessions.map((s) => s.id))
2114
2484
  this.resendUnheardFailures()