@huynle/hub 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +153 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +532 -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 +69 -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/webhooks.ts +5 -2
  60. package/src/central/agent-data.ts +43 -1
  61. package/src/central/backend/memory-counters.ts +2 -2
  62. package/src/central/backend/types.ts +5 -2
  63. package/src/central/backend/valkey-counters.ts +5 -4
  64. package/src/central/client-address.ts +16 -0
  65. package/src/central/concierge/board-render.ts +52 -1
  66. package/src/central/concierge/board-routes.ts +25 -2
  67. package/src/central/concierge/board.ts +11 -1
  68. package/src/central/concierge/context.ts +29 -8
  69. package/src/central/concierge/grant-request.ts +216 -0
  70. package/src/central/concierge/prompt.ts +49 -2
  71. package/src/central/concierge/session-routes.ts +3 -0
  72. package/src/central/config.ts +88 -5
  73. package/src/central/context-cards/budget.ts +121 -0
  74. package/src/central/context-cards/changes.ts +29 -0
  75. package/src/central/context-cards/config.ts +145 -0
  76. package/src/central/context-cards/engine.ts +379 -0
  77. package/src/central/context-cards/inputs.ts +111 -0
  78. package/src/central/context-cards/pickup.ts +134 -0
  79. package/src/central/context-cards/registry.ts +89 -0
  80. package/src/central/context-cards/routes.ts +37 -0
  81. package/src/central/context-cards/service.ts +367 -0
  82. package/src/central/context-cards/store.ts +67 -0
  83. package/src/central/context-cards/types.ts +88 -0
  84. package/src/central/fetcher.ts +10 -2
  85. package/src/central/front-relays.ts +423 -0
  86. package/src/central/http.ts +517 -56
  87. package/src/central/human-activity.ts +453 -0
  88. package/src/central/llm/chat.ts +133 -0
  89. package/src/central/push-target.ts +11 -2
  90. package/src/central/registry.ts +162 -9
  91. package/src/central/speech/service.ts +9 -0
  92. package/src/central/spoke-ws.ts +13 -0
  93. package/src/central/store.ts +9 -12
  94. package/src/config.ts +30 -13
  95. package/src/index.ts +45 -43
  96. package/src/rpc.ts +33 -4
  97. package/src/spoke/concierge-agent.ts +1 -1
  98. package/src/spoke/conversation.ts +6 -75
  99. package/src/spoke/deliver.ts +18 -8
  100. package/src/spoke/hub-rpc.ts +7 -2
  101. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  102. package/src/spoke/hub-tool-title.ts +89 -0
  103. package/src/spoke/hub-tool.ts +56 -18
  104. package/src/spoke/session-context.ts +10 -2
  105. package/src/spoke/spoke.ts +209 -238
  106. package/src/spoke/summary.ts +36 -1
  107. package/src/spoke/taskboard.ts +5 -6
  108. package/src/spoke/voice-front.ts +323 -30
  109. package/src/spoke/who-teams.ts +51 -2
  110. package/src/tui-messaging.ts +24 -10
  111. package/src/tui.ts +6 -6
  112. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
  113. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  114. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  115. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  116. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  117. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
  118. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  119. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  120. package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
  121. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  122. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  123. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  124. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  125. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  126. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
  127. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  128. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
  129. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  130. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
  131. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  132. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  133. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  134. package/web/dist/index.html +6 -6
  135. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  136. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  137. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  138. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  139. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  140. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  141. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  142. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  143. /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,
@@ -39,7 +40,7 @@ import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineI
39
40
  import { MediaCache, timelineMedia, type MediaMessages } from "./media"
40
41
  import { BundleCache, previewBundle } from "./preview-bundle"
41
42
  import { CodedError, SpokeConnection, type Backoff, type ConnectionState } from "./client"
42
- import { isHubPrompt, PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
43
+ import { isHubPrompt, LEGACY_VOICE_AGENT, PROMPT_SOURCE } from "./conversation"
43
44
  import { DeliveryLedger, deferredWhileBusy, deliverDescription, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY, REVERT_STAGED } from "./deliver"
44
45
  import { pollEnrollment, startEnrollment } from "./enroll"
45
46
  import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
@@ -50,10 +51,10 @@ import { OpenCodeProfiles } from "./profile-apply"
50
51
  import { SessionTokenStore } from "./session-tokens"
51
52
  import { SessionContexts } from "./session-context"
52
53
  import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge.ts"
53
- import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
54
+ import { changeStats, contextUsage, filePatch, mcpServers, sessionModel } from "./summary"
54
55
  import { browserSessions, eventSessionID, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
55
56
  import { renderTaskBoard, TaskBoard } from "./taskboard"
56
- import { FRONT_TITLE_PREFIX, frontTitle, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts } from "./voice-front"
57
+ import { FRONT_RELAY_TOOL, FRONT_TITLE_PREFIX, frontTitle, noThinkingOptions, relayDelivery, trimText, VOICE_FRONT_AGENT, VoiceFronts, type FrontMode, type FrontModelInfo } from "./voice-front"
57
58
  import { watchConfig, type ConfigWatcher } from "./watch"
58
59
 
59
60
  export type SpokeErrorName = "not_connected" | "not_enrolled" | "invalid" | "unreachable"
@@ -112,6 +113,8 @@ export interface SpokeHost {
112
113
  synthetic?(input: { sessionID: string; id: string; text: string; description?: string; metadata: Record<string, unknown>; delivery: DeliveryMode; resume: boolean }): Promise<{ id?: string }>
113
114
  context(sessionID: string): Promise<ReadonlyArray<unknown>>
114
115
  listModels?(): Promise<ReadonlyArray<unknown>>
116
+ /** The default model at `directory` (OpenCode's model.default, a catalog entry with providerID and id); undefined: none. */
117
+ defaultModel?(directory: string): Promise<unknown>
115
118
  getSession(sessionID: string): Promise<HostSession>
116
119
  /**
117
120
  * Replies to a pending permission; "gone" when it is not pending here any more (answered, expired).
@@ -161,6 +164,11 @@ export interface SpokeHost {
161
164
  listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
162
165
  /** Creates a session in a directory (the create op: service processes only; voice fronts: any process). */
163
166
  createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
167
+ /**
168
+ * (voice front) The IDs of a session's inbox items still waiting (OpenCode's session.inbox.list, own server only):
169
+ * relays no longer listed were delivered or cancelled while events were missed.
170
+ */
171
+ inboxIDs?(sessionID: string): Promise<string[]>
164
172
  /** Deletes a session (OpenCode's session.remove); a voice front when its conversation ends. */
165
173
  removeSession?(sessionID: string): Promise<unknown>
166
174
  /** MCP servers and their status for a directory (raw OpenCode McpServer list). */
@@ -370,7 +378,7 @@ export class Spoke {
370
378
  * ever gets one. Only actIdentity reads them (the OpenCode hub tool). Never logged, never in any environment.
371
379
  */
372
380
  private readonly actTokens = new SessionTokenStore()
373
- /** The background workers each session started (the voice agent's task board). */
381
+ /** The background workers each session started (the task board its voice front reads). */
374
382
  private readonly tasks = new TaskBoard()
375
383
  private readonly turns = new TurnTracker()
376
384
  /** Timeline rows of sessions being paged through (30 s, dropped on any event of the session). */
@@ -388,17 +396,15 @@ export class Spoke {
388
396
  /** Who put each inbox item into a session (forwarded with session.inbox.delivered). */
389
397
  private readonly origins = new InboxOrigins()
390
398
  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
399
  /**
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).
400
+ * Sessions in conversation mode without a voice front (an older hub, no front, or one that could not be made): the
401
+ * hub talks to them directly and their turns are voice turns. Their agent and model are never switched.
400
402
  */
401
- private readonly sessionModels = new Map<string, { readonly model?: ModelRef }>()
403
+ private readonly conversations = new Set<string>()
404
+ /** Sessions checked for the removed "voice" agent (a turn on a missing agent fails in OpenCode 2.2.3). */
405
+ private readonly agentChecked = new Set<string>()
406
+ /** Per-session selections supplied by the hub; the hub persists them per user. */
407
+ private readonly modelPrefs = new Map<string, SessionModelPrefs>()
402
408
  /** Session → callbacks waiting for its next session.renamed event (autoName). */
403
409
  private readonly renameWaiters = new Map<string, Set<(title: string) => void>>()
404
410
  private conversationChain: Promise<unknown> = Promise.resolve()
@@ -424,6 +430,8 @@ export class Spoke {
424
430
  */
425
431
  private readonly voiceTitled = new Map<string, "checking" | "user" | "front" | "hidden">()
426
432
  private readonly frontFallbacks = new Set<string>()
433
+ /** (fix D) The catalog entry of each front model seen: provider package and variants. */
434
+ private readonly frontModelInfo = new Map<string, FrontModelInfo | undefined>()
427
435
  private readonly unlinkedFronts = new Set<string>()
428
436
  private frontWakes = 0
429
437
  private readonly abort = new AbortController()
@@ -549,21 +557,19 @@ export class Spoke {
549
557
  this.fronts.stop()
550
558
  }
551
559
 
552
- /** A voice-styled turn: a hub /turn asked for it, or the session's voice front failed (the hub talks to it now). */
560
+ /**
561
+ * A voice-styled turn: a hub /turn asked for it, or the hub talks to the session directly in conversation mode (no
562
+ * voice front: an older hub, none could be made, or it failed).
563
+ */
553
564
  isVoiceTurn(sessionID: string): boolean {
554
- return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID)
565
+ return (this.voiceTurns.get(sessionID) ?? 0) > 0 || this.frontFallbacks.has(sessionID) || this.conversations.has(sessionID)
555
566
  }
556
567
 
557
- /** Conversation mode is on: the agent swap (or its context-hook fallback), or a voice front (also a failed one). */
568
+ /** Conversation mode is on: with a voice front (also a failed one), or talking to the session directly. */
558
569
  inConversation(sessionID: string): boolean {
559
570
  return this.conversations.has(sessionID) || this.fronts.frontOf(sessionID) !== undefined || this.frontFallbacks.has(sessionID)
560
571
  }
561
572
 
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
573
  /**
568
574
  * voice_main_send: relays the user's request from a voice front to its main session, as the user's (hub-sourced)
569
575
  * prompt. "auto": idle starts a turn, busy steers; "queue" waits behind main's running turn.
@@ -576,11 +582,40 @@ export class Spoke {
576
582
  const busy = this.running.has(main) || this.tracker.isBusy(main)
577
583
  const delivery = relayDelivery(input.delivery, busy)
578
584
  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 }
585
+ this.fronts.relayed(main, inbox.id, text, delivery)
586
+ 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."
587
+ return { text: `${done} ${HANDOFF_DONE}`, inboxID: inbox.id }
588
+ }
589
+
590
+ /**
591
+ * (P2) relay_to_session: a Concierge-mode front hands the user's request to one of the user's existing sessions,
592
+ * through the hub (relayToSession), which checks the front, the utterance and the target. Refused here, with no hub
593
+ * request, when the front's latest input is a hub wake (only the user's speech may relay) or the hub cannot relay.
594
+ * A refusal re-opens the front's turn (its next step may say why).
595
+ */
596
+ async frontRelay(callerID: string, input: { session?: unknown; text?: unknown; previous?: unknown; confirm?: unknown }): Promise<{ text: string; sessionID?: string }> {
597
+ const refuse = (text: string) => (this.fronts.handoffFailed(callerID), { text })
598
+ if (this.fronts.mode(callerID) !== "concierge") return { text: "This session is not a Concierge front; nothing was sent." }
599
+ const session = typeof input.session === "string" ? input.session.trim() : ""
600
+ const text = typeof input.text === "string" ? input.text.trim() : ""
601
+ if (!session) return refuse("Name the session: its ID or @name from the board. Nothing was sent.")
602
+ if (!text) return refuse("Pass the request as text. Nothing was sent.")
603
+ if (!this.hubSupports("frontRelay")) return refuse("This hub cannot relay to other sessions: pass the request to the Concierge instead. Nothing was sent.")
604
+ 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.`)
605
+ try {
606
+ 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 } : {}) })
607
+ const name = result.title?.trim() || "the session"
608
+ const done = result.mode === "steer" ? `Steered into ${name}'s running turn.` : `Sent to ${name}; it started working on it.`
609
+ return { text: `${done} ${HANDOFF_DONE}`, sessionID: result.sessionID }
610
+ } catch (cause) {
611
+ 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
612
  }
583
- return { text: busy ? "Steered into the main session's running turn." : "Sent; the main session started working on it.", inboxID: inbox.id }
613
+ }
614
+
615
+ /** (P2) The tools a front keeps this turn (none for any other session); the relay only when the hub relays. */
616
+ frontTools(sessionID: string): string[] {
617
+ const tools = this.fronts.tools(sessionID)
618
+ return this.hubSupports("frontRelay") ? tools : tools.filter((name) => name !== FRONT_RELAY_TOOL)
584
619
  }
585
620
 
586
621
  /** voice_main_stop: interrupts the front's main session and everything running below it (as the stop op). */
@@ -589,7 +624,7 @@ export class Spoke {
589
624
  if (!main) return { text: "This session is not a voice front; nothing was stopped.", stopped: [] }
590
625
  this.fronts.stopRequested(main)
591
626
  const { stopped } = await this.stopTree(main)
592
- return { text: "Stopped the main session's work.", stopped: [...stopped] }
627
+ return { text: `Stopped the session's work. ${HANDOFF_DONE}`, stopped: [...stopped] }
593
628
  }
594
629
 
595
630
  /** The voice front's per-turn note about its main session; undefined for any other session. */
@@ -598,7 +633,37 @@ export class Spoke {
598
633
  if (!main) return undefined
599
634
  const title = this.tracker.snapshot(Date.now()).find((s) => s.id === main)?.title
600
635
  const workers = this.taskBoard(main)
601
- return this.fronts.context(sessionID, { ...(title ? { title } : {}), busy: this.running.has(main) || this.tracker.isBusy(main), ...(workers ? { workers } : {}) })
636
+ const board = this.fronts.mode(sessionID) === "concierge" ? this.contexts.frontBoard(main) : undefined
637
+ return this.fronts.context(sessionID, { ...(title ? { title } : {}), busy: this.running.has(main) || this.tracker.isBusy(main), ...(workers ? { workers } : {}), ...(board ? { board } : {}) })
638
+ }
639
+
640
+ /**
641
+ * (fix D) Request options that turn a front's model thinking off (src/spoke/voice-front.ts noThinkingOptions), from
642
+ * OpenCode's model catalog, cached per model. A catalog that cannot be read gives none.
643
+ */
644
+ async frontModelOptions(model: { providerID: string; id: string }): Promise<Record<string, unknown>> {
645
+ const key = `${model.providerID}/${model.id}`
646
+ if (!this.frontModelInfo.has(key)) {
647
+ const listed = await Promise.resolve(this.host.listModels?.()).catch(() => undefined)
648
+ const info = (listed ?? []).find((m: any) => m?.providerID === model.providerID && m?.id === model.id) as FrontModelInfo | undefined
649
+ if (!listed) return {}
650
+ this.frontModelInfo.set(key, info)
651
+ if (this.frontModelInfo.size > 50) this.frontModelInfo.delete(this.frontModelInfo.keys().next().value!)
652
+ }
653
+ return noThinkingOptions(this.frontModelInfo.get(key))
654
+ }
655
+
656
+ /**
657
+ * Checks the relays the fronts still wait for against their sessions' inboxes (after an event-stream gap): relays
658
+ * no longer listed are dropped. Without an inbox list, relays older than the hold cap count as resolved.
659
+ */
660
+ async reconcileFronts(): Promise<void> {
661
+ const list = this.host.inboxIDs?.bind(this.host)
662
+ if (!list) return
663
+ for (const { main } of this.fronts.pendingRelays()) {
664
+ const ids = await list(main).catch(() => undefined)
665
+ if (ids) this.fronts.reconcile(main, new Set(ids))
666
+ }
602
667
  }
603
668
 
604
669
  /** A hub update for a front (`<system-update>`): admitted as the hub's (never the user's), steered, waking it. */
@@ -616,60 +681,11 @@ export class Spoke {
616
681
  }
617
682
  }
618
683
 
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. */
684
+ /** The rendered task board of a session's workers (for its voice front's context); undefined without any. */
630
685
  taskBoard(sessionID: string): string | undefined {
631
686
  return renderTaskBoard(this.tasks.children(sessionID), Date.now())
632
687
  }
633
688
 
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
689
  /**
674
690
  * TUI presence (RPC voice.presence): the sessions a TUI shows right now (its route and open tabs).
675
691
  * `reporter` identifies the TUI; its report replaces its previous one.
@@ -756,10 +772,10 @@ export class Spoke {
756
772
  }
757
773
 
758
774
  /**
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".
775
+ * Turns conversation mode on or off for a session in this process. On with `front`: a hidden voice front answers
776
+ * for the session. Without a front (an older hub, or none can be made) the hub talks to the session directly and
777
+ * its turns are voice turns. The session's agent and model are never switched. Calls are serialized so a re-sent
778
+ * "on" cannot race an "off".
763
779
  */
764
780
  setConversation(sessionID: string, on: boolean, front = false, frontID?: string): Promise<ResultFor["conversation"]> {
765
781
  if (!isExposed(this.config, this.tracker, sessionID)) return Promise.reject(new SpokeError("invalid", NOT_SHARED))
@@ -1113,76 +1129,80 @@ export class Spoke {
1113
1129
  }
1114
1130
 
1115
1131
  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)
1132
+ const session = await this.host.getSession(sessionID)
1118
1133
  // (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.
1134
+ // never escapes its restricted agent). (P2) With `front`, a Concierge-mode front answers in front of it; without
1135
+ // one (an older hub, or none could be made) the Concierge answers itself, as before.
1120
1136
  // A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
1121
1137
  if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
1122
- this.conversations.set(sessionID, {})
1123
- return { agent: session.agent ?? (await this.defaultAgent()) }
1138
+ const agent = session.agent ?? (await this.defaultAgent())
1139
+ const made = front ? await this.frontOn(sessionID, session, frontID, "concierge") : undefined
1140
+ if (made) return { agent, front: { sessionID: made } }
1141
+ this.conversations.add(sessionID)
1142
+ return { agent }
1124
1143
  }
1144
+ const agent = await this.leaveRemovedAgent(sessionID, session.agent)
1125
1145
  // The front failed: the hub talks to main directly and resends on without front (a reconnect). Main keeps its
1126
1146
  // 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))
1147
+ if (!front && this.frontFallbacks.has(sessionID)) return { agent }
1148
+ const made = front ? await this.frontOn(sessionID, session, frontID, "session") : undefined
1149
+ if (made) return { agent, front: { sessionID: made } }
1150
+ // No front: main answers the hub itself, in the voice style. Its agent and model stay as they are.
1151
+ this.conversations.add(sessionID)
1152
+ return { agent }
1153
+ }
1154
+
1155
+ /** The live front of `sessionID`, a re-linked one (`frontID`) or a new one; undefined when none can be made. */
1156
+ private async frontOn(sessionID: string, session: HostSession, frontID: string | undefined, mode: FrontMode): Promise<string | undefined> {
1157
+ const live = this.fronts.frontOf(sessionID)
1158
+ if (live && this.tracker.has(live)) return live
1159
+ // A failed front is replaced (deleted first); the session stops being a fallback.
1160
+ await this.endFront(sessionID)
1161
+ // The hub knows this conversation's front (a resend to a restarted process): re-linked if it still exists.
1162
+ const adopted = frontID ? await this.adoptFront(sessionID, frontID, mode) : undefined
1163
+ const created =
1164
+ adopted ??
1165
+ (await this.createFront(sessionID, session, mode).catch((cause) => {
1166
+ console.error("[hub] could not start a voice front; the hub talks to the session directly", errorText(cause))
1144
1167
  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()) }
1168
+ }))
1169
+ if (created) this.conversations.delete(sessionID)
1170
+ return created
1171
+ }
1172
+
1173
+ /**
1174
+ * The session's agent. A session left on older plugins' "voice" agent (a crash mid-conversation) moves to
1175
+ * the default agent: OpenCode 2.2.3 does not fall back, it fails every turn on a missing agent. A "voice" agent the
1176
+ * user defined is theirs and kept.
1177
+ */
1178
+ private async leaveRemovedAgent(sessionID: string, agent: string | undefined): Promise<string> {
1179
+ this.agentChecked.add(sessionID)
1180
+ if (this.agentChecked.size > AGENT_CHECKED_LIMIT) this.agentChecked.delete(this.agentChecked.values().next().value!)
1181
+ if (agent !== LEGACY_VOICE_AGENT) return agent ?? (await this.defaultAgent())
1182
+ if (await this.host.hasAgent(LEGACY_VOICE_AGENT).catch(() => true)) return agent
1183
+ const target = await this.defaultAgent()
1184
+ try {
1185
+ await this.host.switchAgent(sessionID, target)
1186
+ console.warn(`[hub] ${sessionID} was left on the removed voice agent; it now runs ${target}`)
1187
+ return target
1188
+ } catch (cause) {
1189
+ console.error(`[hub] could not move ${sessionID} off the removed voice agent`, errorText(cause))
1190
+ return agent
1154
1191
  }
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 } : {}) }
1192
+ }
1193
+
1194
+ /** Before the hub prompts a session: moved off the removed voice agent once per session (see leaveRemovedAgent). */
1195
+ private async checkAgent(sessionID: string): Promise<void> {
1196
+ if (this.agentChecked.has(sessionID)) return
1197
+ const session = await this.host.getSession(sessionID).catch(() => undefined)
1198
+ if (session) await this.leaveRemovedAgent(sessionID, session.agent)
1179
1199
  }
1180
1200
 
1181
1201
  /**
1182
1202
  * Creates main's voice front: a root session in main's directory, titled after it, on the voice-front agent and
1183
1203
  * the voice model, reported with voiceFrontOf. Throws (after removing a half-made front) when it cannot.
1184
1204
  */
1185
- private async createFront(main: string, session: HostSession): Promise<string> {
1205
+ private async createFront(main: string, session: HostSession, mode: FrontMode = "session"): Promise<string> {
1186
1206
  const create = this.host.createSession?.bind(this.host)
1187
1207
  if (!create) throw new Error("this OpenCode process cannot create sessions")
1188
1208
  if (!(await this.host.hasAgent(VOICE_FRONT_AGENT))) throw new Error(`no ${VOICE_FRONT_AGENT} agent`)
@@ -1202,7 +1222,7 @@ export class Spoke {
1202
1222
  await this.host.removeSession?.(id).catch(() => undefined)
1203
1223
  throw cause
1204
1224
  }
1205
- this.fronts.link(main, id, await this.lastReply(main))
1225
+ this.fronts.link(main, id, await this.lastReply(main), mode)
1206
1226
  } finally {
1207
1227
  this.frontsCreating--
1208
1228
  if (id !== undefined) this.unlinkedFronts.delete(id)
@@ -1263,11 +1283,11 @@ export class Spoke {
1263
1283
  * Re-links an existing front to main (the hub names it on a resend): only a session on the voice-front agent that
1264
1284
  * this process has not linked to another session. Undefined when it is gone or no front.
1265
1285
  */
1266
- private async adoptFront(main: string, frontID: string): Promise<string | undefined> {
1286
+ private async adoptFront(main: string, frontID: string, mode: FrontMode = "session"): Promise<string | undefined> {
1267
1287
  if (frontID === main || (this.frontMainOf(frontID) ?? main) !== main) return undefined
1268
1288
  const info = await this.host.getSession(frontID).catch(() => undefined)
1269
1289
  if (info?.agent !== VOICE_FRONT_AGENT || (this.frontMainOf(frontID) ?? main) !== main) return undefined
1270
- this.fronts.link(main, frontID, await this.lastReply(main))
1290
+ this.fronts.link(main, frontID, await this.lastReply(main), mode)
1271
1291
  this.voiceTitled.delete(frontID)
1272
1292
  const now = Date.now()
1273
1293
  if (!this.tracker.has(frontID)) this.tracker.observe({ type: "session.created", data: { sessionID: frontID, title: info.title, location: info.location } }, now)
@@ -1328,20 +1348,10 @@ export class Spoke {
1328
1348
 
1329
1349
  private async conversationOff(sessionID: string): Promise<ResultFor["conversation"]> {
1330
1350
  await this.endFront(sessionID)
1331
- const entry = this.conversations.get(sessionID)
1332
1351
  this.conversations.delete(sessionID)
1333
1352
  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 }
1353
+ if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) return { agent: session.agent ?? (await this.defaultAgent()) }
1354
+ return { agent: await this.leaveRemovedAgent(sessionID, session.agent) }
1345
1355
  }
1346
1356
 
1347
1357
  private async defaultAgent(): Promise<string> {
@@ -1401,15 +1411,12 @@ export class Spoke {
1401
1411
  case "modelPrefs":
1402
1412
  return this.modelPrefsResult(req.sessionID)
1403
1413
  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)
1414
+ // The Voice model is the voice front's. `worker` (older hubs' complex-worker model) is ignored.
1415
+ const next: { voice?: SessionModelPrefs["voice"] } = { ...this.modelPrefs.get(req.sessionID) }
1416
+ if ("voice" in req && req.voice === null) delete next.voice
1417
+ else if (req.voice) next.voice = req.voice
1418
+ if (next.voice) this.modelPrefs.set(req.sessionID, next)
1411
1419
  else this.modelPrefs.delete(req.sessionID)
1412
- if (!next.voice && this.voiceOverrides.has(req.sessionID)) await this.applyVoiceModel(req.sessionID)
1413
1420
  return this.modelPrefsResult(req.sessionID)
1414
1421
  }
1415
1422
  case "timeline": {
@@ -1450,8 +1457,10 @@ export class Spoke {
1450
1457
  directory: this.directoryOf(req.sessionID),
1451
1458
  })) satisfies ResultFor["previewBundle"]
1452
1459
  case "steer": {
1460
+ // (P2) The user's speech reaches a front only through this op (the hub's utterance route).
1461
+ if (this.fronts.isFront(req.sessionID)) this.fronts.heard(req.sessionID, "user")
1453
1462
  const voice = isExposed(this.config, this.tracker, req.sessionID)
1454
- if (voice) await this.applyVoiceModel(req.sessionID)
1463
+ await this.checkAgent(req.sessionID)
1455
1464
  const inbox = await this.host.prompt({
1456
1465
  sessionID: req.sessionID,
1457
1466
  text: req.text,
@@ -1468,6 +1477,8 @@ export class Spoke {
1468
1477
  return this.rename(req.sessionID, req.title)
1469
1478
  case "removeFront":
1470
1479
  return this.removeFront(req.sessionID)
1480
+ case "frontUpdate":
1481
+ return { queued: this.fronts.update(req.sessionID, req.text, req.finished === true) } satisfies ResultFor["frontUpdate"]
1471
1482
  case "dismissError":
1472
1483
  this.unheardFailures.delete(req.sessionID)
1473
1484
  return { dismissed: this.tracker.dismissError(req.sessionID) } satisfies ResultFor["dismissError"]
@@ -1862,7 +1873,7 @@ export class Spoke {
1862
1873
  const ref = value ? parseModelRef(value) : undefined
1863
1874
  return ref && { providerID: ref.providerID, modelID: ref.id, ...(ref.variant ? { variant: ref.variant } : {}) }
1864
1875
  }
1865
- return { ...(convert(this.config.voiceModel) ? { voice: convert(this.config.voiceModel)! } : {}), ...(convert(this.config.workModel) ? { worker: convert(this.config.workModel)! } : {}) }
1876
+ return convert(this.config.voiceModel) ? { voice: convert(this.config.voiceModel)! } : {}
1866
1877
  }
1867
1878
 
1868
1879
  private modelPrefsResult(sessionID: string): ResultFor["modelPrefs"] {
@@ -1885,18 +1896,24 @@ export class Spoke {
1885
1896
  }
1886
1897
  }
1887
1898
  const host = this.host
1888
- const [messages, models, mcp, diffs, branch] = await Promise.all([
1899
+ const [messages, models, mcp, diffs, branch, session] = await Promise.all([
1889
1900
  optional(() => this.shownMessages(sessionID)),
1890
1901
  optional(host.listModels && (() => host.listModels!())),
1891
1902
  optional(host.mcpList && (() => host.mcpList!(directory))),
1892
1903
  optional(host.vcsDiff && (() => host.vcsDiff!(directory))),
1893
1904
  optional(host.vcsBranch && (() => host.vcsBranch!(directory))),
1905
+ optional(() => host.getSession(sessionID)),
1894
1906
  ])
1895
1907
  const context = messages && contextUsage(messages, models ?? [])
1908
+ // The session's own model: chosen, else what its latest reply ran on; the default model only before any reply.
1909
+ const latest = sessionModel({ chosen: session?.model, ...(messages ? { messages } : {}), models: models ?? [] })
1910
+ const fallback = latest ? undefined : await optional(host.defaultModel && (() => host.defaultModel!(directory)))
1911
+ const model = latest ?? sessionModel({ fallback, models: models ?? [] })
1896
1912
  return {
1897
1913
  directory,
1898
1914
  ...(branch ? { branch } : {}),
1899
1915
  ...(context ? { context } : {}),
1916
+ ...(model ? { model } : {}),
1900
1917
  ...(mcp ? { mcp: mcpServers(mcp) } : {}),
1901
1918
  ...(diffs ? { changes: changeStats(diffs) } : {}),
1902
1919
  }
@@ -1919,57 +1936,8 @@ export class Spoke {
1919
1936
  return { stopped: order }
1920
1937
  }
1921
1938
 
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!)
1963
- }
1964
-
1965
1939
  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
- }
1940
+ await this.host.switchModel(sessionID, model)
1973
1941
  }
1974
1942
 
1975
1943
  /** Enables or disables voice for a session this process reported (mode "session" only). */
@@ -2070,7 +2038,7 @@ export class Spoke {
2070
2038
 
2071
2039
  private async turn(sessionID: string, text: string, voiceStyle: boolean, images?: readonly PromptImage[]): Promise<TurnResult> {
2072
2040
  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)
2041
+ await this.checkAgent(sessionID)
2074
2042
  this.running.add(sessionID)
2075
2043
  if (voiceStyle) this.voiceTurns.set(sessionID, (this.voiceTurns.get(sessionID) ?? 0) + 1)
2076
2044
  try {
@@ -2096,11 +2064,11 @@ export class Spoke {
2096
2064
 
2097
2065
  private async listen(): Promise<void> {
2098
2066
  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
2067
  try {
2102
2068
  // Events may have been missed while the stream was down (or before it opened): ask what runs now.
2103
2069
  if (this.connection?.state === "connected") void this.seedActivity()
2070
+ // (voice front) A relay delivered or cancelled in a gap must not hold the front's reports.
2071
+ void this.reconcileFronts()
2104
2072
  for await (const event of this.host.events(this.abort.signal)) this.observe(event)
2105
2073
  } catch (cause) {
2106
2074
  if (this.abort.signal.aborted) return
@@ -2116,7 +2084,6 @@ export class Spoke {
2116
2084
  const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
2117
2085
  if (typeof sessionID === "string") this.timelines.invalidate(sessionID)
2118
2086
  this.turns.observe(event)
2119
- this.observeModel(event)
2120
2087
  this.origins.observe(event)
2121
2088
  if (event.type === "session.deleted" && typeof sessionID === "string") {
2122
2089
  this.tokens.delete(sessionID)
@@ -2140,13 +2107,15 @@ export class Spoke {
2140
2107
  this.trackAsk(event)
2141
2108
  if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
2142
2109
  this.observeFronts(event, sessionID)
2110
+ if (typeof sessionID === "string") this.observeAgent(event, sessionID)
2143
2111
  if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
2144
2112
  if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
2145
2113
  if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
2146
2114
  this.scheduleSeed()
2147
2115
  }
2148
2116
  void this.fetchInfo()
2149
- this.forward(event)
2117
+ // (voice front) A front's text after its hand-off in the same run is never spoken: the turn ends there.
2118
+ if (!this.fronts.dropText(event)) this.forward(event)
2150
2119
  } catch (cause) {
2151
2120
  console.error("[hub] event handling failed", cause)
2152
2121
  }
@@ -2180,6 +2149,18 @@ export class Spoke {
2180
2149
  this.voiceTitled.delete(sessionID)
2181
2150
  }
2182
2151
 
2152
+ /**
2153
+ * The removed "voice" agent: a session switched onto it is checked again before the hub's next prompt, and a turn
2154
+ * that failed on it (typed in the TUI) moves the session to the default agent, so the next one runs.
2155
+ */
2156
+ private observeAgent(event: EventLike, sessionID: string): void {
2157
+ const data = event.data as Record<string, unknown> | undefined
2158
+ if (event.type === "session.agent.selected" && data?.agent === LEGACY_VOICE_AGENT) this.agentChecked.delete(sessionID)
2159
+ if (event.type !== "session.execution.failed") return
2160
+ const message = (data?.error as { message?: unknown } | undefined)?.message
2161
+ if (typeof message === "string" && message.includes(`Agent not found: "${LEGACY_VOICE_AGENT}"`)) void this.leaveRemovedAgent(sessionID, LEGACY_VOICE_AGENT)
2162
+ }
2163
+
2183
2164
  /** The main session of a front, also of a failed one (still reported hidden until deleted). */
2184
2165
  private frontMainOf(sessionID: string): string | undefined {
2185
2166
  const main = this.fronts.mainOf(sessionID)
@@ -2301,24 +2282,15 @@ export class Spoke {
2301
2282
 
2302
2283
  /**
2303
2284
  * 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.
2285
+ * (they reset reply-chain hops on a user's turn), and `carried` for a user prompt the hub carried (feature
2286
+ * inboxCarried); when unknown, the event goes out unchanged.
2305
2287
  */
2306
2288
  private withOrigin<E extends { type: string; data: Record<string, unknown> }>(event: E | undefined): E | undefined {
2307
2289
  if (event?.type !== "session.inbox.delivered" || typeof event.data.inboxID !== "string") return event
2290
+ const carried = this.origins.carried(event.data.inboxID)
2308
2291
  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
- }
2292
+ // (Session findability) carried: the hub carried this user prompt itself (it is not one typed in the terminal).
2293
+ return origin && this.hubSupports("messaging") ? { ...event, data: { ...event.data, origin, ...(carried ? { carried: true } : {}) } } : event
2322
2294
  }
2323
2295
 
2324
2296
  private async fetchInfo(): Promise<void> {
@@ -2545,9 +2517,8 @@ function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[
2545
2517
  return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
2546
2518
  }
2547
2519
 
2548
- /** Sessions whose current model is remembered. */
2549
- const SESSION_MODELS_LIMIT = 500
2520
+ /** The end of a hand-off tool's result: the front's turn is over (anything it writes after is not spoken anyway). */
2521
+ const HANDOFF_DONE = "Your turn is over: write nothing more now. You will get a <system-update> when there is news."
2550
2522
 
2551
- function sameModel(a: ModelRef, b: ModelRef): boolean {
2552
- return a.providerID === b.providerID && a.id === b.id && (explicitVariant(a) ?? "") === (explicitVariant(b) ?? "")
2553
- }
2523
+ /** Sessions remembered as checked for the removed voice agent. */
2524
+ const AGENT_CHECKED_LIMIT = 500