@huynle/hub 0.9.2 → 0.10.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 (141) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +81 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +547 -58
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +227 -237
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -2,7 +2,7 @@
2
2
  // keeps the WebSocket to the central server, forwards exposed events, and executes the server's
3
3
  // requests (voice turns, permission replies, context reads) through the plugin context.
4
4
  import path from "node:path"
5
- import { defaults, readConfig, setSession, withServer, writeConfig, type LaunchConfig, type Mode, type VoiceConfig, explicitVariant, formatModelRef, parseModelRef, plainModelRef, type ModelRef } from "../config"
5
+ import { defaults, readConfig, setSession, withServer, writeConfig, type LaunchConfig, type Mode, type VoiceConfig, parseModelRef, type ModelRef } from "../config"
6
6
  import { isLoopbackURL } from "../loopback"
7
7
  import {
8
8
  compactActivity,
@@ -12,6 +12,7 @@ import {
12
12
  isLaunchCreate,
13
13
  SEED_JOBS_MAX,
14
14
  SPOKE_FEATURES,
15
+ RELAY_TEXT_MAX,
15
16
  type DeliverRequest,
16
17
  type DeliverResult,
17
18
  type DeliveryMode,
@@ -27,6 +28,7 @@ import {
27
28
  type SessionTokensRequest,
28
29
  type ActTokensRequest,
29
30
  type CreateRequest,
31
+ type ModelSelection,
30
32
  type SessionModelPrefs,
31
33
  type SpokeRequest,
32
34
  type TerminalInfo,
@@ -39,7 +41,7 @@ import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineI
39
41
  import { MediaCache, timelineMedia, type MediaMessages } from "./media"
40
42
  import { BundleCache, previewBundle } from "./preview-bundle"
41
43
  import { CodedError, SpokeConnection, type Backoff, type ConnectionState } from "./client"
42
- import { isHubPrompt, PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
44
+ import { isHubPrompt, LEGACY_VOICE_AGENT, PROMPT_SOURCE } from "./conversation"
43
45
  import { DeliveryLedger, deferredWhileBusy, deliverDescription, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
44
46
  import { pollEnrollment, startEnrollment } from "./enroll"
45
47
  import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
@@ -50,10 +52,10 @@ import { OpenCodeProfiles } from "./profile-apply"
50
52
  import { SessionTokenStore } from "./session-tokens"
51
53
  import { SessionContexts } from "./session-context"
52
54
  import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge.ts"
53
- import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
55
+ import { changeStats, contextUsage, filePatch, mcpServers, sessionModel } from "./summary"
54
56
  import { browserSessions, eventSessionID, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
55
57
  import { renderTaskBoard, TaskBoard } from "./taskboard"
56
- import { FRONT_TITLE_PREFIX, frontTitle, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts } from "./voice-front"
58
+ import { FRONT_RELAY_TOOL, FRONT_TITLE_PREFIX, frontTitle, noThinkingOptions, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts, type FrontMode, type FrontModelInfo } from "./voice-front"
57
59
  import { watchConfig, type ConfigWatcher } from "./watch"
58
60
 
59
61
  export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
@@ -112,6 +114,8 @@ export interface SpokeHost {
112
114
  synthetic?(input: { sessionID: string; id: string; text: string; description?: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
113
115
  context(sessionID: string): Promise<ReadonlyArray<unknown>>
114
116
  listModels?(): Promise<ReadonlyArray<unknown>>
117
+ /** The default model at `directory` (OpenCode's model.default, a catalog entry with providerID and id); undefined: none. */
118
+ defaultModel?(directory: string): Promise<unknown>
115
119
  getSession(sessionID: string): Promise<HostSession>
116
120
  /**
117
121
  * Replies to a pending permission; "gone" when it is not pending here any more (answered, expired).
@@ -161,6 +165,11 @@ export interface SpokeHost {
161
165
  listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
162
166
  /** Creates a session in a directory (the create op: service processes only; voice fronts: any process). */
163
167
  createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
168
+ /**
169
+ * (voice front) The IDs of a session's inbox items still waiting (OpenCode's session.inbox.list, own server only):
170
+ * relays no longer listed were delivered or cancelled while events were missed.
171
+ */
172
+ inboxIDs?(sessionID: string): Promise<string[]>
164
173
  /** Deletes a session (OpenCode's session.remove); a voice front when its conversation ends. */
165
174
  removeSession?(sessionID: string): Promise<unknown>
166
175
  /** MCP servers and their status for a directory (raw OpenCode McpServer list). */
@@ -370,7 +379,7 @@ export class Spoke {
370
379
  * ever gets one. Only actIdentity reads them (the OpenCode hub tool). Never logged, never in any environment.
371
380
  */
372
381
  private readonly actTokens = new SessionTokenStore()
373
- /** The background workers each session started (the voice agent's task board). */
382
+ /** The background workers each session started (the task board its voice front reads). */
374
383
  private readonly tasks = new TaskBoard()
375
384
  private readonly turns = new TurnTracker()
376
385
  /** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
@@ -388,17 +397,15 @@ export class Spoke {
388
397
  /** Who put each inbox item into a session (forwarded with session.inbox.delivered). */
389
398
  private readonly origins = new InboxOrigins()
390
399
  private readonly infoTried = new Map<string, number>()
391
- /** Sessions in conversation mode → the agent to restore when it ends (undefined: the default agent). */
392
- private readonly conversations = new Map<string, { previous?: string; previousModel?: ModelRef; voiceModel?: ModelRef }>()
393
- /** Per-session selections supplied by the hub; the hub persists them per user. */
394
- private readonly modelPrefs = new Map<string, SessionModelPrefs>()
395
- /** Per session with a Voice override applied: the model to restore when it is cleared, and the override. */
396
- private readonly voiceOverrides = new Map<string, { readonly previous?: ModelRef; readonly applied: ModelRef }>()
397
400
  /**
398
- * Session → its current model as last seen (getSession, our switchModel, OpenCode's session.model.selected),
399
- * so an utterance needs no getSession round trip. Cleared when the event stream restarts (events may be lost).
401
+ * Sessions in conversation mode without a voice front (an older hub, no front, or one that could not be made): the
402
+ * hub talks to them directly and their turns are voice turns. Their agent and model are never switched.
400
403
  */
401
- private readonly sessionModels = new Map<string, { readonly model?: ModelRef }>()
404
+ private readonly conversations = new Set<string>()
405
+ /** Sessions checked for the removed "voice" agent (a turn on a missing agent fails in OpenCode 2.2.3). */
406
+ private readonly agentChecked = new Set<string>()
407
+ /** Per-session selections supplied by the hub; the hub persists them per user. */
408
+ private readonly modelPrefs = new Map<string, SessionModelPrefs>()
402
409
  /** Session → callbacks waiting for its next session.renamed event (autoName). */
403
410
  private readonly renameWaiters = new Map<string, Set<(title: string) => void>>()
404
411
  private conversationChain: Promise<unknown> = Promise.resolve()
@@ -424,6 +431,8 @@ export class Spoke {
424
431
  */
425
432
  private readonly voiceTitled = new Map<string, "checking" | "user" | "front" | "hidden">()
426
433
  private readonly frontFallbacks = new Set<string>()
434
+ /** (fix D) The catalog entry of each front model seen: provider package and variants. */
435
+ private readonly frontModelInfo = new Map<string, FrontModelInfo | undefined>()
427
436
  private readonly unlinkedFronts = new Set<string>()
428
437
  private frontWakes = 0
429
438
  private readonly abort = new AbortController()
@@ -549,21 +558,19 @@ export class Spoke {
549
558
  this.fronts.stop()
550
559
  }
551
560
 
552
- /** A voice-styled turn: a hub /turn asked for it, or the session's voice front failed (the hub talks to it now). */
561
+ /**
562
+ * A voice-styled turn: a hub /turn asked for it, or the hub talks to the session directly in conversation mode (no
563
+ * voice front: an older hub, none could be made, or it failed).
564
+ */
553
565
  isVoiceTurn(sessionID: string): boolean {
554
- return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID)
566
+ return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID) || this.conversations.has(sessionID)
555
567
  }
556
568
 
557
- /** Conversation mode is on: the agent swap (or its context-hook fallback), or a voice front (also a failed one). */
569
+ /** Conversation mode is on: with a voice front (also a failed one), or talking to the session directly. */
558
570
  inConversation(sessionID: string): boolean {
559
571
  return this.conversations.has(sessionID) || this.fronts.frontOf(sessionID) !== undefined || this.frontFallbacks.has(sessionID)
560
572
  }
561
573
 
562
- /** The session itself is the voice orchestrator (today's agent swap): the context hook adds its parts. */
563
- orchestrates(sessionID: string): boolean {
564
- return this.conversations.has(sessionID)
565
- }
566
-
567
574
  /**
568
575
  * voice_main_send: relays the user's request from a voice front to its main session, as the user's (hub-sourced)
569
576
  * prompt. "auto": idle starts a turn, busy steers; "queue" waits behind main's running turn.
@@ -576,11 +583,40 @@ export class Spoke {
576
583
  const busy = this.running.has(main) || this.tracker.isBusy(main)
577
584
  const delivery = relayDelivery(input.delivery, busy)
578
585
  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 }
586
+ this.fronts.relayed(main, inbox.id, text, delivery)
587
+ const done = delivery === "queue" ? "Queued for the session; it runs after its current turn." : busy ? "Steered into the session's running turn." : "Sent; the session started working on it."
588
+ return { text: `${done} ${HANDOFF_DONE}`, inboxID: inbox.id }
589
+ }
590
+
591
+ /**
592
+ * (P2) relay_to_session: a Concierge-mode front hands the user's request to one of the user's existing sessions,
593
+ * through the hub (relayToSession), which checks the front, the utterance and the target. Refused here, with no hub
594
+ * request, when the front's latest input is a hub wake (only the user's speech may relay) or the hub cannot relay.
595
+ * A refusal re-opens the front's turn (its next step may say why).
596
+ */
597
+ async frontRelay(callerID: string, input: { session?: unknown; text?: unknown; previous?: unknown; confirm?: unknown }): Promise<{ text: string; sessionID?: string }> {
598
+ const refuse = (text: string) => (this.fronts.handoffFailed(callerID), { text })
599
+ if (this.fronts.mode(callerID) !== "concierge") return { text: "This session is not a Concierge front; nothing was sent." }
600
+ const session = typeof input.session === "string" ? input.session.trim() : ""
601
+ const text = typeof input.text === "string" ? input.text.trim() : ""
602
+ if (!session) return refuse("Name the session: its ID or @name from the board. Nothing was sent.")
603
+ if (!text) return refuse("Pass the request as text. Nothing was sent.")
604
+ if (!this.hubSupports("frontRelay")) return refuse("This hub cannot relay to other sessions: pass the request to the Concierge instead. Nothing was sent.")
605
+ if (this.fronts.lastInput(callerID) !== "user") return refuse(`${FRONT_RELAY_TOOL} is only for what the user just said, never for an update. Nothing was sent.`)
606
+ try {
607
+ const result = await this.ask({ op: "relayToSession", frontID: callerID, session, text: text.slice(0, RELAY_TEXT_MAX), ...(input.previous === true ? { previous: true } : {}), ...(input.confirm === true ? { confirm: true } : {}) })
608
+ const name = result.title?.trim() || "the session"
609
+ const done = result.mode === "steer" ? `Steered into ${name}'s running turn.` : `Sent to ${name}; it started working on it.`
610
+ return { text: `${done} ${HANDOFF_DONE}`, sessionID: result.sessionID }
611
+ } catch (cause) {
612
+ return refuse(`Not sent: ${errorText(cause)}. Do what that says now (you may answer the user), or tell them in one short sentence why nothing was sent.`)
582
613
  }
583
- return { text: busy ? "Steered into the main session's running turn." : "Sent; the main session started working on it.", inboxID: inbox.id }
614
+ }
615
+
616
+ /** (P2) The tools a front keeps this turn (none for any other session); the relay only when the hub relays. */
617
+ frontTools(sessionID: string): string[] {
618
+ const tools = this.fronts.tools(sessionID)
619
+ return this.hubSupports("frontRelay") ? tools : tools.filter((name) => name !== FRONT_RELAY_TOOL)
584
620
  }
585
621
 
586
622
  /** voice_main_stop: interrupts the front's main session and everything running below it (as the stop op). */
@@ -589,7 +625,7 @@ export class Spoke {
589
625
  if (!main) return { text: "This session is not a voice front; nothing was stopped.", stopped: [] }
590
626
  this.fronts.stopRequested(main)
591
627
  const { stopped } = await this.stopTree(main)
592
- return { text: "Stopped the main session's work.", stopped: [...stopped] }
628
+ return { text: `Stopped the session's work. ${HANDOFF_DONE}`, stopped: [...stopped] }
593
629
  }
594
630
 
595
631
  /** The voice front's per-turn note about its main session; undefined for any other session. */
@@ -598,7 +634,37 @@ export class Spoke {
598
634
  if (!main) return undefined
599
635
  const title = this.tracker.snapshot(Date.now()).find((s) => s.id === main)?.title
600
636
  const workers = this.taskBoard(main)
601
- return this.fronts.context(sessionID, { ...(title ? { title } : {}), busy: this.running.has(main) || this.tracker.isBusy(main), ...(workers ? { workers } : {}) })
637
+ const board = this.fronts.mode(sessionID) === "concierge" ? this.contexts.frontBoard(main) : undefined
638
+ return this.fronts.context(sessionID, { ...(title ? { title } : {}), busy: this.running.has(main) || this.tracker.isBusy(main), ...(workers ? { workers } : {}), ...(board ? { board } : {}) })
639
+ }
640
+
641
+ /**
642
+ * (fix D) Request options that turn a front's model thinking off (src/spoke/voice-front.ts noThinkingOptions), from
643
+ * OpenCode's model catalog, cached per model. A catalog that cannot be read gives none.
644
+ */
645
+ async frontModelOptions(model: { providerID: string; id: string }): Promise<Record<string, unknown>> {
646
+ const key = `${model.providerID}/${model.id}`
647
+ if (!this.frontModelInfo.has(key)) {
648
+ const listed = await Promise.resolve(this.host.listModels?.()).catch(() => undefined)
649
+ const info = (listed ?? []).find((m: any) => m?.providerID === model.providerID && m?.id === model.id) as FrontModelInfo | undefined
650
+ if (!listed) return {}
651
+ this.frontModelInfo.set(key, info)
652
+ if (this.frontModelInfo.size > 50) this.frontModelInfo.delete(this.frontModelInfo.keys().next().value!)
653
+ }
654
+ return noThinkingOptions(this.frontModelInfo.get(key))
655
+ }
656
+
657
+ /**
658
+ * Checks the relays the fronts still wait for against their sessions' inboxes (after an event-stream gap): relays
659
+ * no longer listed are dropped. Without an inbox list, relays older than the hold cap count as resolved.
660
+ */
661
+ async reconcileFronts(): Promise<void> {
662
+ const list = this.host.inboxIDs?.bind(this.host)
663
+ if (!list) return
664
+ for (const { main } of this.fronts.pendingRelays()) {
665
+ const ids = await list(main).catch(() => undefined)
666
+ if (ids) this.fronts.reconcile(main, new Set(ids))
667
+ }
602
668
  }
603
669
 
604
670
  /** A hub update for a front (`<system-update>`): admitted as the hub's (never the user's), steered, waking it. */
@@ -616,60 +682,11 @@ export class Spoke {
616
682
  }
617
683
  }
618
684
 
619
- /** The agent the voice orchestrator delegates to (per-machine config). */
620
- /** The model subagents should work on for a session in conversation mode, as "provider/model[#variant]". */
621
- workModelFor(sessionID: string): string | undefined {
622
- const selected = this.modelPrefs.get(sessionID)?.worker
623
- if (selected) return formatModelRef({ providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) })
624
- const entry = this.conversations.get(sessionID)
625
- if (!entry?.voiceModel) return undefined
626
- return this.config.workModel ?? (entry.previousModel ? formatModelRef(entry.previousModel) : undefined)
627
- }
628
-
629
- /** The rendered task board of a session's workers (for the voice agent's context); undefined without any. */
685
+ /** The rendered task board of a session's workers (for its voice front's context); undefined without any. */
630
686
  taskBoard(sessionID: string): string | undefined {
631
687
  return renderTaskBoard(this.tasks.children(sessionID), Date.now())
632
688
  }
633
689
 
634
- /**
635
- * hub_task_stop: interrupts one of `callerID`'s workers (a session below it), or all its running workers, and
636
- * whatever runs below them, deepest first. Never the caller itself, nor a session outside its tree.
637
- */
638
- async stopWorkers(callerID: string, input: { sessionID?: unknown; all?: unknown }): Promise<{ stopped: string[]; text: string }> {
639
- let targets: string[]
640
- if (input.all === true) {
641
- targets = this.tasks
642
- .children(callerID)
643
- .filter((t) => t.state === "working" || t.state === "blocked")
644
- .map((t) => t.id)
645
- if (!targets.length) return { stopped: [], text: "None of your workers is running." }
646
- } else if (typeof input.sessionID === "string" && input.sessionID) {
647
- const id = input.sessionID
648
- if (id === callerID || !this.tracker.lineage(id).slice(1).includes(callerID)) {
649
- return { stopped: [], text: `${id} is not one of your workers; use a sessionID from your task board.` }
650
- }
651
- targets = [id]
652
- } else return { stopped: [], text: "Pass the worker's sessionID from your task board, or all: true for every worker." }
653
- const stopped: string[] = []
654
- for (const target of targets) {
655
- for (const id of [...this.tracker.runningBelow(target), target]) {
656
- if (id === callerID) continue
657
- try {
658
- await this.host.interrupt(id)
659
- stopped.push(id)
660
- } catch {
661
- // already settled
662
- }
663
- }
664
- }
665
- const names = targets.map((id) => `"${this.tasks.get(id)?.title ?? id}"`)
666
- return { stopped, text: `Stopped ${names.join(", ")}.` }
667
- }
668
-
669
- subagent(): string {
670
- return this.config.subagent
671
- }
672
-
673
690
  /**
674
691
  * TUI presence (RPC voice.presence): the sessions a TUI shows right now (its route and open tabs).
675
692
  * `reporter` identifies the TUI; its report replaces its previous one.
@@ -756,10 +773,10 @@ export class Spoke {
756
773
  }
757
774
 
758
775
  /**
759
- * Turns conversation mode on or off for a session in this process. On: switch to the "voice"
760
- * agent (remembering the previous one) or, without that agent, let the context hook add the
761
- * orchestrator prompt. Off: restore the previous agent unless the user picked another meanwhile.
762
- * Calls are serialized so a re-sent "on" cannot race an "off".
776
+ * Turns conversation mode on or off for a session in this process. On with `front`: a hidden voice front answers
777
+ * for the session. Without a front (an older hub, or none can be made) the hub talks to the session directly and
778
+ * its turns are voice turns. The session's agent and model are never switched. Calls are serialized so a re-sent
779
+ * "on" cannot race an "off".
763
780
  */
764
781
  setConversation(sessionID: string, on: boolean, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
765
782
  if (!isExposed(this.config, this.tracker, sessionID)) return Promise.reject(new SpokeError("invalid", NOT_SHARED))
@@ -1113,76 +1130,80 @@ export class Spoke {
1113
1130
  }
1114
1131
 
1115
1132
  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)
1133
+ const session = await this.host.getSession(sessionID)
1118
1134
  // (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
1119
- // never escapes its restricted agent, and the context hook adds no orchestrator parts). It never gets a front.
1135
+ // never escapes its restricted agent). (P2) With `front`, a Concierge-mode front answers in front of it; without
1136
+ // one (an older hub, or none could be made) the Concierge answers itself, as before.
1120
1137
  // A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
1121
1138
  if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
1122
- this.conversations.set(sessionID, {})
1123
- return { agent: session.agent ?? (await this.defaultAgent()) }
1139
+ const agent = session.agent ?? (await this.defaultAgent())
1140
+ const made = front ? await this.frontOn(sessionID, session, frontID, "concierge") : undefined
1141
+ if (made) return { agent, front: { sessionID: made } }
1142
+ this.conversations.add(sessionID)
1143
+ return { agent }
1124
1144
  }
1145
+ const agent = await this.leaveRemovedAgent(sessionID, session.agent)
1125
1146
  // The front failed: the hub talks to main directly and resends on without front (a reconnect). Main keeps its
1126
1147
  // 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))
1148
+ if (!front && this.frontFallbacks.has(sessionID)) return { agent }
1149
+ const made = front ? await this.frontOn(sessionID, session, frontID, "session") : undefined
1150
+ if (made) return { agent, front: { sessionID: made } }
1151
+ // No front: main answers the hub itself, in the voice style. Its agent and model stay as they are.
1152
+ this.conversations.add(sessionID)
1153
+ return { agent }
1154
+ }
1155
+
1156
+ /** The live front of `sessionID`, a re-linked one (`frontID`) or a new one; undefined when none can be made. */
1157
+ private async frontOn(sessionID: string, session: HostSession, frontID: string | undefined, mode: FrontMode): Promise<string | undefined> {
1158
+ const live = this.fronts.frontOf(sessionID)
1159
+ if (live && this.tracker.has(live)) return live
1160
+ // A failed front is replaced (deleted first); the session stops being a fallback.
1161
+ await this.endFront(sessionID)
1162
+ // The hub knows this conversation's front (a resend to a restarted process): re-linked if it still exists.
1163
+ const adopted = frontID ? await this.adoptFront(sessionID, frontID, mode) : undefined
1164
+ const created =
1165
+ adopted ??
1166
+ (await this.createFront(sessionID, session, mode).catch((cause) => {
1167
+ console.error("[hub] could not start a voice front; the hub talks to the session directly", errorText(cause))
1144
1168
  return undefined
1145
- })
1146
- if (created) return { agent: session.agent ?? (await this.defaultAgent()), front: { sessionID: created } }
1147
- }
1148
- this.rememberModel(sessionID, session.model)
1149
- const current = session.agent
1150
- const models = await this.voiceModelOn(sessionID, session, entry)
1151
- if (!(await this.host.hasAgent(VOICE_AGENT))) {
1152
- this.conversations.set(sessionID, { ...entry, ...models })
1153
- return { agent: current ?? (await this.defaultAgent()) }
1169
+ }))
1170
+ if (created) this.conversations.delete(sessionID)
1171
+ return created
1172
+ }
1173
+
1174
+ /**
1175
+ * The session's agent. A session left on older plugins' "voice" agent (a crash mid-conversation) moves to
1176
+ * the default agent: OpenCode 2.2.3 does not fall back, it fails every turn on a missing agent. A "voice" agent the
1177
+ * user defined is theirs and kept.
1178
+ */
1179
+ private async leaveRemovedAgent(sessionID: string, agent: string | undefined): Promise<string> {
1180
+ this.agentChecked.add(sessionID)
1181
+ if (this.agentChecked.size > AGENT_CHECKED_LIMIT) this.agentChecked.delete(this.agentChecked.values().next().value!)
1182
+ if (agent !== LEGACY_VOICE_AGENT) return agent ?? (await this.defaultAgent())
1183
+ if (await this.host.hasAgent(LEGACY_VOICE_AGENT).catch(() => true)) return agent
1184
+ const target = await this.defaultAgent()
1185
+ try {
1186
+ await this.host.switchAgent(sessionID, target)
1187
+ console.warn(`[hub] ${sessionID} was left on the removed voice agent; it now runs ${target}`)
1188
+ return target
1189
+ } catch (cause) {
1190
+ console.error(`[hub] could not move ${sessionID} off the removed voice agent`, errorText(cause))
1191
+ return agent
1154
1192
  }
1155
- const previous = entry ? entry.previous : current === VOICE_AGENT ? undefined : current
1156
- this.conversations.set(sessionID, { ...(previous ? { previous } : {}), ...models })
1157
- if (current !== VOICE_AGENT) await this.host.switchAgent(sessionID, VOICE_AGENT)
1158
- return { agent: VOICE_AGENT }
1159
- }
1160
-
1161
- /** Switches to the configured fast model, remembering the session's model for subagent work and for restoring. */
1162
- private async voiceModelOn(
1163
- sessionID: string,
1164
- session: HostSession,
1165
- entry: { previousModel?: ModelRef; voiceModel?: ModelRef } | undefined,
1166
- ): Promise<{ previousModel?: ModelRef; voiceModel?: ModelRef }> {
1167
- const selected = this.modelPrefs.get(sessionID)?.voice
1168
- const voiceModel = selected
1169
- ? { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
1170
- : this.config.voiceModel
1171
- ? parseModelRef(this.config.voiceModel)
1172
- : undefined
1173
- if (!voiceModel) return {}
1174
- const current = session.model
1175
- const onVoice = current !== undefined && sameModel(current, voiceModel)
1176
- const previousModel = entry?.voiceModel ? entry.previousModel : onVoice || !current ? undefined : plainModelRef(current)
1177
- if (!onVoice) await this.switchModel(sessionID, voiceModel)
1178
- return { voiceModel, ...(previousModel ? { previousModel } : {}) }
1193
+ }
1194
+
1195
+ /** Before the hub prompts a session: moved off the removed voice agent once per session (see leaveRemovedAgent). */
1196
+ private async checkAgent(sessionID: string): Promise<void> {
1197
+ if (this.agentChecked.has(sessionID)) return
1198
+ const session = await this.host.getSession(sessionID).catch(() => undefined)
1199
+ if (session) await this.leaveRemovedAgent(sessionID, session.agent)
1179
1200
  }
1180
1201
 
1181
1202
  /**
1182
1203
  * Creates main's voice front: a root session in main's directory, titled after it, on the voice-front agent and
1183
1204
  * the voice model, reported with voiceFrontOf. Throws (after removing a half-made front) when it cannot.
1184
1205
  */
1185
- private async createFront(main: string, session: HostSession): Promise<string> {
1206
+ private async createFront(main: string, session: HostSession, mode: FrontMode = "session"): Promise<string> {
1186
1207
  const create = this.host.createSession?.bind(this.host)
1187
1208
  if (!create) throw new Error("this OpenCode process cannot create sessions")
1188
1209
  if (!(await this.host.hasAgent(VOICE_FRONT_AGENT))) throw new Error(`no ${VOICE_FRONT_AGENT} agent`)
@@ -1202,7 +1223,7 @@ export class Spoke {
1202
1223
  await this.host.removeSession?.(id).catch(() => undefined)
1203
1224
  throw cause
1204
1225
  }
1205
- this.fronts.link(main, id, await this.lastReply(main))
1226
+ this.fronts.link(main, id, await this.lastReply(main), mode)
1206
1227
  } finally {
1207
1228
  this.frontsCreating--
1208
1229
  if (id !== undefined) this.unlinkedFronts.delete(id)
@@ -1263,11 +1284,11 @@ export class Spoke {
1263
1284
  * Re-links an existing front to main (the hub names it on a resend): only a session on the voice-front agent that
1264
1285
  * this process has not linked to another session. Undefined when it is gone or no front.
1265
1286
  */
1266
- private async adoptFront(main: string, frontID: string): Promise<string | undefined> {
1287
+ private async adoptFront(main: string, frontID: string, mode: FrontMode = "session"): Promise<string | undefined> {
1267
1288
  if (frontID === main || (this.frontMainOf(frontID) ?? main) !== main) return undefined
1268
1289
  const info = await this.host.getSession(frontID).catch(() => undefined)
1269
1290
  if (info?.agent !== VOICE_FRONT_AGENT || (this.frontMainOf(frontID) ?? main) !== main) return undefined
1270
- this.fronts.link(main, frontID, await this.lastReply(main))
1291
+ this.fronts.link(main, frontID, await this.lastReply(main), mode)
1271
1292
  this.voiceTitled.delete(frontID)
1272
1293
  const now = Date.now()
1273
1294
  if (!this.tracker.has(frontID)) this.tracker.observe({ type: "session.created", data: { sessionID: frontID, title: info.title, location: info.location } }, now)
@@ -1328,20 +1349,10 @@ export class Spoke {
1328
1349
 
1329
1350
  private async conversationOff(sessionID: string): Promise<ResultFor["conversation"]> {
1330
1351
  await this.endFront(sessionID)
1331
- const entry = this.conversations.get(sessionID)
1332
1352
  this.conversations.delete(sessionID)
1333
1353
  const session = await this.host.getSession(sessionID)
1334
- this.rememberModel(sessionID, session.model)
1335
- // Restore the model only if the session is still on the voice model (the user may have picked another).
1336
- if (entry?.voiceModel && entry.previousModel && session.model && sameModel(session.model, entry.voiceModel)) {
1337
- await this.switchModel(sessionID, entry.previousModel)
1338
- }
1339
- const current = session.agent
1340
- if (current !== VOICE_AGENT) return { agent: current ?? (await this.defaultAgent()) }
1341
- let target = entry?.previous ?? (await this.defaultAgent())
1342
- if (target === VOICE_AGENT) target = FALLBACK_AGENT
1343
- await this.host.switchAgent(sessionID, target)
1344
- return { agent: target }
1354
+ if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) return { agent: session.agent ?? (await this.defaultAgent()) }
1355
+ return { agent: await this.leaveRemovedAgent(sessionID, session.agent) }
1345
1356
  }
1346
1357
 
1347
1358
  private async defaultAgent(): Promise<string> {
@@ -1401,17 +1412,16 @@ export class Spoke {
1401
1412
  case "modelPrefs":
1402
1413
  return this.modelPrefsResult(req.sessionID)
1403
1414
  case "setModelPrefs": {
1404
- const next: { voice?: any; worker?: any } = { ...this.modelPrefs.get(req.sessionID) }
1405
- for (const key of ["voice", "worker"] as const) {
1406
- if (!(key in req)) continue
1407
- if (req[key] === null) delete next[key]
1408
- else if (req[key] !== undefined) next[key] = req[key]
1409
- }
1410
- if (next.voice || next.worker) this.modelPrefs.set(req.sessionID, next)
1415
+ // The Voice model is the voice front's. `worker` (older hubs' complex-worker model) is ignored.
1416
+ const next: { voice?: SessionModelPrefs["voice"] } = { ...this.modelPrefs.get(req.sessionID) }
1417
+ if ("voice" in req && req.voice === null) delete next.voice
1418
+ else if (req.voice) next.voice = req.voice
1419
+ if (next.voice) this.modelPrefs.set(req.sessionID, next)
1411
1420
  else this.modelPrefs.delete(req.sessionID)
1412
- if (!next.voice && this.voiceOverrides.has(req.sessionID)) await this.applyVoiceModel(req.sessionID)
1413
1421
  return this.modelPrefsResult(req.sessionID)
1414
1422
  }
1423
+ case "setModel":
1424
+ return this.setSessionModel(req.sessionID, req.model)
1415
1425
  case "timeline": {
1416
1426
  // A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly. A
1417
1427
  // staged revert hides its message and everything after it (OpenCode keeps them until it is committed).
@@ -1450,8 +1460,10 @@ export class Spoke {
1450
1460
  directory: this.directoryOf(req.sessionID),
1451
1461
  })) satisfies ResultFor["previewBundle"]
1452
1462
  case "steer": {
1463
+ // (P2) The user's speech reaches a front only through this op (the hub's utterance route).
1464
+ if (this.fronts.isFront(req.sessionID)) this.fronts.heard(req.sessionID, "user")
1453
1465
  const voice = isExposed(this.config, this.tracker, req.sessionID)
1454
- if (voice) await this.applyVoiceModel(req.sessionID)
1466
+ await this.checkAgent(req.sessionID)
1455
1467
  const inbox = await this.host.prompt({
1456
1468
  sessionID: req.sessionID,
1457
1469
  text: req.text,
@@ -1468,6 +1480,8 @@ export class Spoke {
1468
1480
  return this.rename(req.sessionID, req.title)
1469
1481
  case "removeFront":
1470
1482
  return this.removeFront(req.sessionID)
1483
+ case "frontUpdate":
1484
+ return { queued: this.fronts.update(req.sessionID, req.text, req.finished === true) } satisfies ResultFor["frontUpdate"]
1471
1485
  case "dismissError":
1472
1486
  this.unheardFailures.delete(req.sessionID)
1473
1487
  return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
@@ -1862,7 +1876,7 @@ export class Spoke {
1862
1876
  const ref = value ? parseModelRef(value) : undefined
1863
1877
  return ref && { providerID: ref.providerID, modelID: ref.id, ...(ref.variant ? { variant: ref.variant } : {}) }
1864
1878
  }
1865
- return { ...(convert(this.config.voiceModel) ? { voice: convert(this.config.voiceModel)! } : {}), ...(convert(this.config.workModel) ? { worker: convert(this.config.workModel)! } : {}) }
1879
+ return convert(this.config.voiceModel) ? { voice: convert(this.config.voiceModel)! } : {}
1866
1880
  }
1867
1881
 
1868
1882
  private modelPrefsResult(sessionID: string): ResultFor["modelPrefs"] {
@@ -1885,18 +1899,24 @@ export class Spoke {
1885
1899
  }
1886
1900
  }
1887
1901
  const host = this.host
1888
- const [messages, models, mcp, diffs, branch] = await Promise.all([
1902
+ const [messages, models, mcp, diffs, branch, session] = await Promise.all([
1889
1903
  optional(() => this.shownMessages(sessionID)),
1890
1904
  optional(host.listModels && (() => host.listModels!())),
1891
1905
  optional(host.mcpList && (() => host.mcpList!(directory))),
1892
1906
  optional(host.vcsDiff && (() => host.vcsDiff!(directory))),
1893
1907
  optional(host.vcsBranch && (() => host.vcsBranch!(directory))),
1908
+ optional(() => host.getSession(sessionID)),
1894
1909
  ])
1895
1910
  const context = messages && contextUsage(messages, models ?? [])
1911
+ // The session's own model: chosen, else what its latest reply ran on; the default model only before any reply.
1912
+ const latest = sessionModel({ chosen: session?.model, ...(messages ? { messages } : {}), models: models ?? [] })
1913
+ const fallback = latest ? undefined : await optional(host.defaultModel && (() => host.defaultModel!(directory)))
1914
+ const model = latest ?? sessionModel({ fallback, models: models ?? [] })
1896
1915
  return {
1897
1916
  directory,
1898
1917
  ...(branch ? { branch } : {}),
1899
1918
  ...(context ? { context } : {}),
1919
+ ...(model ? { model } : {}),
1900
1920
  ...(mcp ? { mcp: mcpServers(mcp) } : {}),
1901
1921
  ...(diffs ? { changes: changeStats(diffs) } : {}),
1902
1922
  }
@@ -1919,57 +1939,24 @@ export class Spoke {
1919
1939
  return { stopped: order }
1920
1940
  }
1921
1941
 
1922
- /** Applies a saved Voice override at the boundary before the next turn; never changes a running tree. */
1923
- /**
1924
- * A cleared override switches back to the model the session had before the first override, unless the
1925
- * user picked another model meanwhile.
1926
- */
1927
- private async applyVoiceModel(sessionID: string): Promise<void> {
1928
- const selected = this.modelPrefs.get(sessionID)?.voice
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
1932
- if ((!selected && !override) || this.tracker.active().includes(this.tracker.rootOf(sessionID))) return
1933
- const current = await this.currentModel(sessionID)
1934
- const onOverride = override !== undefined && current !== undefined && sameModel(current, override.applied)
1935
- if (!selected) {
1936
- this.voiceOverrides.delete(sessionID)
1937
- if (onOverride && override.previous) await this.switchModel(sessionID, override.previous)
1938
- return
1939
- }
1940
- const target = { providerID: selected.providerID, id: selected.modelID, ...(selected.variant ? { variant: selected.variant } : {}) }
1941
- if (current && sameModel(current, target)) {
1942
- if (override) this.voiceOverrides.set(sessionID, { ...override, applied: target })
1943
- return
1944
- }
1945
- const previous = onOverride ? override.previous : current ? plainModelRef(current) : undefined
1946
- this.voiceOverrides.set(sessionID, { ...(previous ? { previous } : {}), applied: target })
1947
- await this.switchModel(sessionID, target)
1948
- }
1949
-
1950
- /** The session's current model: remembered, else asked from OpenCode (and remembered). */
1951
- private async currentModel(sessionID: string): Promise<ModelRef | undefined> {
1952
- const known = this.sessionModels.get(sessionID)
1953
- if (known) return known.model
1954
- const { model } = await this.host.getSession(sessionID)
1955
- this.rememberModel(sessionID, model)
1956
- return model
1957
- }
1958
-
1959
- private rememberModel(sessionID: string, model: ModelRef | undefined): void {
1960
- this.sessionModels.delete(sessionID)
1961
- this.sessionModels.set(sessionID, model ? { model } : {})
1962
- if (this.sessionModels.size > SESSION_MODELS_LIMIT) this.sessionModels.delete(this.sessionModels.keys().next().value!)
1942
+ private async switchModel(sessionID: string, model: ModelRef): Promise<void> {
1943
+ await this.host.switchModel(sessionID, model)
1963
1944
  }
1964
1945
 
1965
- private async switchModel(sessionID: string, model: ModelRef): Promise<void> {
1966
- try {
1967
- await this.host.switchModel(sessionID, model)
1968
- this.rememberModel(sessionID, model)
1969
- } catch (cause) {
1970
- this.sessionModels.delete(sessionID) // unknown now: ask next time
1971
- throw cause
1972
- }
1946
+ /**
1947
+ * (feature "setModel") The session's own model, as the console picked it: OpenCode's session.switchModel stores it
1948
+ * on the session, so every later turn runs on it whoever prompts (the console, the TUI, a voice relay). Only a
1949
+ * model (and variant) of this OpenCode's catalog; no variant: the model's default.
1950
+ */
1951
+ private async setSessionModel(sessionID: string, model: ModelSelection): Promise<ResultFor["setModel"]> {
1952
+ const catalog = (await this.host.listModels?.()) ?? []
1953
+ const known = catalog.find((value: any) => value?.providerID === model.providerID && (typeof value.id === "string" && value.id ? value.id : value.modelID) === model.modelID) as any
1954
+ const variants: string[] = Array.isArray(known?.variants) ? known.variants.flatMap((v: any) => (typeof v?.id === "string" ? [v.id] : [])) : []
1955
+ const variant = model.variant && model.variant !== "default" ? model.variant : undefined
1956
+ if (!known || (variant && !variants.includes(variant))) throw new Error(`Unknown model ${model.providerID}/${model.modelID}${variant ? `#${variant}` : ""} for this session`)
1957
+ const ref: ModelRef = { providerID: model.providerID, id: model.modelID, ...(variant ? { variant } : {}) }
1958
+ await this.switchModel(sessionID, ref)
1959
+ return { model: sessionModel({ chosen: ref, models: catalog })! }
1973
1960
  }
1974
1961
 
1975
1962
  /** Enables or disables voice for a session this process reported (mode "session" only). */
@@ -2070,7 +2057,7 @@ export class Spoke {
2070
2057
 
2071
2058
  private async turn(sessionID: string, text: string, voiceStyle: boolean, images?: readonly PromptImage[]): Promise<TurnResult> {
2072
2059
  if (this.running.has(sessionID)) throw new Error("A turn is already running for this session")
2073
- if (isExposed(this.config, this.tracker, sessionID)) await this.applyVoiceModel(sessionID)
2060
+ await this.checkAgent(sessionID)
2074
2061
  this.running.add(sessionID)
2075
2062
  if (voiceStyle) this.voiceTurns.set(sessionID, (this.voiceTurns.get(sessionID) ?? 0) + 1)
2076
2063
  try {
@@ -2096,11 +2083,11 @@ export class Spoke {
2096
2083
 
2097
2084
  private async listen(): Promise<void> {
2098
2085
  while (!this.abort.signal.aborted) {
2099
- // Model changes may have been missed while the stream was down: ask OpenCode again.
2100
- this.sessionModels.clear()
2101
2086
  try {
2102
2087
  // Events may have been missed while the stream was down (or before it opened): ask what runs now.
2103
2088
  if (this.connection?.state === "connected") void this.seedActivity()
2089
+ // (voice front) A relay delivered or cancelled in a gap must not hold the front's reports.
2090
+ void this.reconcileFronts()
2104
2091
  for await (const event of this.host.events(this.abort.signal)) this.observe(event)
2105
2092
  } catch (cause) {
2106
2093
  if (this.abort.signal.aborted) return
@@ -2116,7 +2103,6 @@ export class Spoke {
2116
2103
  const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
2117
2104
  if (typeof sessionID === "string") this.timelines.invalidate(sessionID)
2118
2105
  this.turns.observe(event)
2119
- this.observeModel(event)
2120
2106
  this.origins.observe(event)
2121
2107
  if (event.type === "session.deleted" && typeof sessionID === "string") {
2122
2108
  this.tokens.delete(sessionID)
@@ -2140,13 +2126,15 @@ export class Spoke {
2140
2126
  this.trackAsk(event)
2141
2127
  if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
2142
2128
  this.observeFronts(event, sessionID)
2129
+ if (typeof sessionID === "string") this.observeAgent(event, sessionID)
2143
2130
  if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
2144
2131
  if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
2145
2132
  if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
2146
2133
  this.scheduleSeed()
2147
2134
  }
2148
2135
  void this.fetchInfo()
2149
- this.forward(event)
2136
+ // (voice front) A front's text after its hand-off in the same run is never spoken: the turn ends there.
2137
+ if (!this.fronts.dropText(event)) this.forward(event)
2150
2138
  } catch (cause) {
2151
2139
  console.error("[hub] event handling failed", cause)
2152
2140
  }
@@ -2180,6 +2168,18 @@ export class Spoke {
2180
2168
  this.voiceTitled.delete(sessionID)
2181
2169
  }
2182
2170
 
2171
+ /**
2172
+ * The removed "voice" agent: a session switched onto it is checked again before the hub's next prompt, and a turn
2173
+ * that failed on it (typed in the TUI) moves the session to the default agent, so the next one runs.
2174
+ */
2175
+ private observeAgent(event: EventLike, sessionID: string): void {
2176
+ const data = event.data as Record<string, unknown> | undefined
2177
+ if (event.type === "session.agent.selected" && data?.agent === LEGACY_VOICE_AGENT) this.agentChecked.delete(sessionID)
2178
+ if (event.type !== "session.execution.failed") return
2179
+ const message = (data?.error as { message?: unknown } | undefined)?.message
2180
+ if (typeof message === "string" && message.includes(`Agent not found: "${LEGACY_VOICE_AGENT}"`)) void this.leaveRemovedAgent(sessionID, LEGACY_VOICE_AGENT)
2181
+ }
2182
+
2183
2183
  /** The main session of a front, also of a failed one (still reported hidden until deleted). */
2184
2184
  private frontMainOf(sessionID: string): string | undefined {
2185
2185
  const main = this.fronts.mainOf(sessionID)
@@ -2301,24 +2301,15 @@ export class Spoke {
2301
2301
 
2302
2302
  /**
2303
2303
  * Adds who put the item in the inbox (user or hub) to session.inbox.delivered, for hubs that route messages
2304
- * (they reset reply-chain hops on a user's turn); when unknown, the event goes out unchanged.
2304
+ * (they reset reply-chain hops on a user's turn), and `carried` for a user prompt the hub carried (feature
2305
+ * inboxCarried); when unknown, the event goes out unchanged.
2305
2306
  */
2306
2307
  private withOrigin<E extends { type: string; data: Record<string, unknown> }>(event: E | undefined): E | undefined {
2307
2308
  if (event?.type !== "session.inbox.delivered" || typeof event.data.inboxID !== "string") return event
2309
+ const carried = this.origins.carried(event.data.inboxID)
2308
2310
  const origin = this.origins.take(event.data.inboxID)
2309
- return origin && this.hubSupports("messaging") ? { ...event, data: { ...event.data, origin } } : event
2310
- }
2311
-
2312
- /** Keeps the remembered session models current from OpenCode's events. */
2313
- private observeModel(event: EventLike): void {
2314
- const data = (event.data ?? {}) as { sessionID?: unknown; model?: unknown }
2315
- if (typeof data.sessionID !== "string") return
2316
- if (event.type === "session.deleted") this.sessionModels.delete(data.sessionID)
2317
- else if (event.type === "session.model.selected") {
2318
- const model = data.model as Partial<ModelRef> | undefined
2319
- if (model && typeof model.providerID === "string" && typeof model.id === "string") this.rememberModel(data.sessionID, model as ModelRef)
2320
- else this.sessionModels.delete(data.sessionID)
2321
- }
2311
+ // (Session findability) carried: the hub carried this user prompt itself (it is not one typed in the terminal).
2312
+ return origin && this.hubSupports("messaging") ? { ...event, data: { ...event.data, origin, ...(carried ? { carried: true } : {}) } } : event
2322
2313
  }
2323
2314
 
2324
2315
  private async fetchInfo(): Promise<void> {
@@ -2545,9 +2536,8 @@ function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[
2545
2536
  return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
2546
2537
  }
2547
2538
 
2548
- /** Sessions whose current model is remembered. */
2549
- const SESSION_MODELS_LIMIT = 500
2539
+ /** The end of a hand-off tool's result: the front's turn is over (anything it writes after is not spoken anyway). */
2540
+ const HANDOFF_DONE = "Your turn is over: write nothing more now. You will get a <system-update> when there is news."
2550
2541
 
2551
- function sameModel(a: ModelRef, b: ModelRef): boolean {
2552
- return a.providerID === b.providerID && a.id === b.id && (explicitVariant(a) ?? "") === (explicitVariant(b) ?? "")
2553
- }
2542
+ /** Sessions remembered as checked for the removed voice agent. */
2543
+ const AGENT_CHECKED_LIMIT = 500