@huynle/hub 0.9.2 → 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 (141) hide show
  1. package/CHANGELOG.md +296 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +152 -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-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 +516 -56
  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 +209 -238
  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-Bz992ZX6.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.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-DlQeogea.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  123. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.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-IaJiloPi.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.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
@@ -16,6 +16,7 @@ import { BLOCKER_ID_MAX, StatusBoard, askBlocker, errorTypeLabel, permissionRequ
16
16
  import { sanitizeLabel } from "./agent/envelope"
17
17
  import { redactCredentials } from "../../packages/protocol/src/credentials.ts"
18
18
  import { DoneWatch } from "./done"
19
+ import { FrontRelays, RelayRefused } from "./front-relays"
19
20
  import { SentenceStream } from "./say-stream"
20
21
  import type { ActivityStep, NotificationKind } from "./push"
21
22
 
@@ -53,7 +54,8 @@ export interface MachineRef {
53
54
  }
54
55
 
55
56
  /** A status tree entry as the hub reports it: working sessions carry when their current run started. */
56
- export type LiveStatus = SessionStatus & { readonly since?: number }
57
+ /** `since`: a working entry's run start; `blockedSince`: when a blocked entry's oldest open blocker was first seen. */
58
+ export type LiveStatus = SessionStatus & { readonly since?: number; readonly blockedSince?: number }
57
59
 
58
60
  /** Timer functions (injectable for tests). */
59
61
  export interface Timers {
@@ -125,6 +127,13 @@ export interface ActivityItem {
125
127
  export const ACTIVITY_LIMIT = 30
126
128
  /** Root sessions whose activity is remembered. */
127
129
  const ACTIVITY_ROOTS = 200
130
+ /**
131
+ * (Session findability) Harnesses whose spokes report every prompt the hub carried as origin "hub" (Claude Code: a
132
+ * UserPromptSubmit after the hub's hubPrompt), so each of their user items was typed in the terminal.
133
+ */
134
+ const HUB_PROMPTS_ORIGIN_HUB: ReadonlySet<string> = new Set(["claude-code"])
135
+ /** Blocker first sightings kept (the oldest dropped first). */
136
+ const BLOCKERS_SEEN = 5000
128
137
 
129
138
  /** A session counts as watched while a device reported it visible within this window. */
130
139
  export const PRESENCE_WINDOW_MS = 45_000
@@ -450,6 +459,10 @@ export const FRONT_GONE_MS = 10_000
450
459
  export type FrontAction =
451
460
  | { readonly kind: "adopt"; readonly mainID: string; readonly connID: string; readonly frontID: string }
452
461
  | { readonly kind: "remove"; readonly connID: string; readonly sessionID: string }
462
+ /** (P2) An update about a session the Concierge front relayed to: frontUpdate through the front's linking process. */
463
+ | { readonly kind: "update"; readonly connID: string; readonly mainID: string; readonly text: string; readonly finished?: boolean }
464
+
465
+ export { RELAY_REASON, RELAYS_PER_UTTERANCE, RelayRefused } from "./front-relays"
453
466
 
454
467
  /** (voice front P1) The front in front of a main session: who created it, and whether a sessions frame listed it yet. */
455
468
  interface Front {
@@ -493,6 +506,10 @@ const isFrontInfo = (s: SessionInfo) => s.voiceFrontOf !== undefined || s.agent
493
506
  /** Older spokes reported exposed sessions only, without the field. */
494
507
  const isOn = (s: SessionInfo) => s.exposed !== false
495
508
 
509
+ /** Recent terminal prompt inbox IDs remembered per root (replay dedupe), and roots remembered. */
510
+ const TERMINAL_PROMPT_IDS = 256
511
+ const TERMINAL_PROMPT_ROOTS = 2_000
512
+
496
513
  export class Registry {
497
514
  /** (REC-01) Voice recording taps; optional, best effort. */
498
515
  recordingTap?: RecordingTap
@@ -516,6 +533,10 @@ export class Registry {
516
533
  private readonly sessionsListeners = new Set<(connID: string) => void>()
517
534
  /** Called when the user's own prompt reached a session's inbox (typed or spoken in the harness). */
518
535
  private readonly humanTurnListeners = new Set<(sessionID: string, tenant?: string) => void>()
536
+ private readonly agentWorkListeners = new Set<(rootID: string, tenant: string, what: "turn" | "tool") => void>()
537
+ private readonly terminalPromptListeners = new Set<(rootID: string, tenant: string) => void>()
538
+ /** Root → its recently counted terminal prompt inbox IDs (an exact replay is counted once); bounded both ways. */
539
+ private readonly terminalPromptSeen = new Map<string, Set<string>>()
519
540
  private readonly sessionDeletedListeners = new Set<(sessionID: string, tenant: string) => void>()
520
541
  /** Pending form ID → its session and every connection that forwarded it, first first (like permissions). */
521
542
  private readonly forms = new Map<string, PendingForm>()
@@ -567,6 +588,8 @@ export class Registry {
567
588
  private readonly presence = new Map<string, { sessionID: string; at: number }>()
568
589
  /** Session → start time of its current run (session.execution.started). */
569
590
  private readonly since = new Map<string, number>()
591
+ /** (Session findability) Blocker ID → when this process first saw it (the "waiting 12m" of Needs you). */
592
+ private readonly blockerSeen = new Map<string, number>()
570
593
  /** Session → streamed text waiting for its send window. */
571
594
  private readonly streams = new Map<string, Stream>()
572
595
  /** Machine → its latest terminals frame and the connection that sent it. */
@@ -592,6 +615,63 @@ export class Registry {
592
615
  private readonly tenantOf: TenantOf = () => "",
593
616
  ) {
594
617
  this.doneWatch = new DoneWatch(timers, (rootID) => this.settledTree(rootID), (rootID) => this.alert("done", rootID))
618
+ this.relays = new FrontRelays({ now, timers, send: (update) => this.relayUpdate(update) })
619
+ }
620
+
621
+ /** (voice front P2) The Concierge front's relays: utterance bindings and the sessions it relayed to. */
622
+ private readonly relays: FrontRelays
623
+
624
+ /** (P2) The hub steered the user's utterance (from `deviceID`) into the active front of `mainID`. */
625
+ frontUtterance(mainID: string, inboxID: string, deviceID?: string, text?: string): void {
626
+ const front = this.fronts.get(mainID)
627
+ if (front) this.relays.utterance(front.frontID, inboxID, deviceID, text)
628
+ }
629
+
630
+ /**
631
+ * (P2) A relay the front `frontID` asks for over `connID`: the front's main and the utterance's device, with one
632
+ * relay taken from that utterance (`release` gives it back). RelayRefused unless `connID` links the active front
633
+ * and the front's latest input is a recent utterance of the user's with relays left.
634
+ */
635
+ takeRelay(connID: string, frontID: string): { readonly mainID: string } & ReturnType<FrontRelays["take"]> {
636
+ const mainID = this.frontMain.get(frontID)
637
+ const front = mainID === undefined ? undefined : this.fronts.get(mainID)
638
+ if (mainID === undefined || front?.frontID !== frontID) throw new RelayRefused("Not an active voice front")
639
+ if ((this.linker(front) ?? front.connID) !== connID) throw new RelayRefused("Only the front's own process relays for it")
640
+ return { mainID, ...this.relays.take(frontID) }
641
+ }
642
+
643
+ /** (P2) A relay reached `targetID`: its results go to the front of `mainID`. */
644
+ noteRelay(mainID: string, targetID: string, title: string, inboxID?: string): void {
645
+ this.relays.relayed(mainID, targetID, title, inboxID)
646
+ }
647
+
648
+ /** (P2) Sends an update about a relayed session to the front's linking process. */
649
+ private relayUpdate(update: { mainID: string; text: string; finished?: boolean }): void {
650
+ const front = this.fronts.get(update.mainID)
651
+ const connID = front ? (this.linker(front) ?? this.owner(front.frontID)) : undefined
652
+ if (connID === undefined) return
653
+ this.frontAction({ kind: "update", connID, mainID: update.mainID, text: update.text, ...(update.finished ? { finished: true } : {}) })
654
+ }
655
+
656
+ /**
657
+ * (P2) A session the Concierge front relayed to: its permission, question and failure are announced once as the
658
+ * Concierge's speech ("Hub API needs your permission to run a command."), and its root's events (from the process
659
+ * that owns it) feed the front's updates.
660
+ */
661
+ private observeRelayed(connID: string, sessionID: string, type: string, data: Record<string, unknown>): void {
662
+ const rootID = this.rootOf(sessionID) ?? sessionID
663
+ if (rootID === sessionID && (this.owner(rootID) ?? connID) === connID) this.relays.observe(rootID, type, data)
664
+ const target = this.relays.target(rootID)
665
+ if (!target || !this.fronts.has(target.mainID)) return
666
+ if (type === "permission.asked" || type === "form.created") return this.announceOwnAsk(type, sessionID, data, target.mainID, target.title)
667
+ if (type !== "session.execution.failed") return
668
+ const at = this.announcedFailures.get(sessionID)
669
+ if (at !== undefined && this.now() - at <= FAILURE_DEDUPE_MS) return
670
+ remember(this.announcedFailures, sessionID, this.now())
671
+ const message = (data.error as { message?: unknown } | undefined)?.message
672
+ const reason = redactCredentials(typeof message === "string" ? message : "").text.replace(/\s+/g, " ").trim().replace(/[.!]+$/, "").slice(0, ANNOUNCE_REASON_MAX)
673
+ const who = sessionID === rootID ? target.title : `A worker of ${target.title}`
674
+ this.announce(target.mainID, `announce:failed:${sessionID}`, reason ? `${who} failed: ${reason}.` : `${who} failed.`, true)
595
675
  }
596
676
 
597
677
  /**
@@ -753,6 +833,27 @@ export class Registry {
753
833
  return () => this.humanTurnListeners.delete(listener)
754
834
  }
755
835
 
836
+ /**
837
+ * (Session findability) Listens to the agent's work: a turn started (session.execution.started) or a tool called
838
+ * (session.tool.called), on the root session of the tree (subagents count for their root).
839
+ */
840
+ onAgentWork(listener: (rootID: string, tenant: string, what: "turn" | "tool") => void): () => void {
841
+ this.agentWorkListeners.add(listener)
842
+ return () => this.agentWorkListeners.delete(listener)
843
+ }
844
+
845
+ /**
846
+ * (Session findability) Listens to prompts the user typed in a session's terminal, on the root of the tree: a user
847
+ * inbox item the hub did not carry. Exact, never guessed: a spoke announcing inboxCarried (OpenCode, Codex) marks
848
+ * what the hub carried (`carried`); Claude Code reports the hub's prompts as origin "hub" (HUB_PROMPTS_ORIGIN_HUB).
849
+ * Any other spoke (an older OpenCode or Codex spoke) cannot tell them apart, so its user items are not reported
850
+ * here. Counted from the root's owner process only.
851
+ */
852
+ onTerminalPrompt(listener: (rootID: string, tenant: string) => void): () => void {
853
+ this.terminalPromptListeners.add(listener)
854
+ return () => this.terminalPromptListeners.delete(listener)
855
+ }
856
+
756
857
  /** Listens to sessions a spoke reports deleted (session.deleted, seen once by the pod holding the spoke). */
757
858
  onSessionDeleted(listener: (sessionID: string, tenant: string) => void): () => void {
758
859
  this.sessionDeletedListeners.add(listener)
@@ -901,6 +1002,7 @@ export class Registry {
901
1002
  this.observeBoards(event, tenant)
902
1003
  return
903
1004
  }
1005
+ this.blockerFirstSeen(data.id)
904
1006
  const request = permissionRequest(data)
905
1007
  const blocker = askBlocker(event)
906
1008
  this.permissions.set(data.id, { connIDs: [connID], sessionID, tenant, ...(request ? { request } : {}), ...(blocker ? { blocker } : {}) })
@@ -910,6 +1012,7 @@ export class Registry {
910
1012
  if (event.type === "form.created" && sessionID) {
911
1013
  const formID = (data.form as Record<string, unknown> | undefined)?.id
912
1014
  if (typeof formID === "string") {
1015
+ this.blockerFirstSeen(formID)
913
1016
  const known = this.forms.get(formID)
914
1017
  if (known && !known.connIDs.includes(connID)) known.connIDs.push(connID)
915
1018
  else if (!known) {
@@ -947,6 +1050,7 @@ export class Registry {
947
1050
  if (sessionID) this.observeActivity(event, sessionID, data)
948
1051
  if (sessionID) this.observeWorkerSpeech(event.type, sessionID, data)
949
1052
  if (sessionID) this.observeWorkerBlockers(event.type, sessionID, data)
1053
+ if (sessionID) this.observeRelayed(connID, sessionID, event.type, data)
950
1054
 
951
1055
  if (streamed && sessionID) {
952
1056
  const rootID = this.rootOf(sessionID)
@@ -960,11 +1064,38 @@ export class Registry {
960
1064
  const tenant = this.sessionTenant(sessionID)
961
1065
  for (const listener of [...this.humanTurnListeners]) listener(sessionID, tenant)
962
1066
  }
1067
+ if (event.type === "session.inbox.delivered" && data.origin === "user" && data.carried !== true && sessionID && tenant !== undefined && this.terminalPromptListeners.size) {
1068
+ const exact = conn.features.has("inboxCarried") || HUB_PROMPTS_ORIGIN_HUB.has(helloHarness(conn.hello).harness)
1069
+ const rootID = this.rootOf(sessionID) ?? sessionID
1070
+ const owner = this.owner(rootID)
1071
+ if (exact && (owner === undefined || owner === connID) && this.firstTerminalPrompt(rootID, data.inboxID)) for (const listener of [...this.terminalPromptListeners]) listener(rootID, tenant)
1072
+ }
1073
+ if ((event.type === "session.execution.started" || event.type === "session.tool.called") && sessionID && tenant !== undefined && this.agentWorkListeners.size) {
1074
+ const rootID = this.rootOf(sessionID) ?? sessionID
1075
+ // Several processes may forward the same events: counted from the root's owner only (any, when none is known).
1076
+ const owner = this.owner(rootID)
1077
+ if (owner === undefined || owner === connID) for (const listener of [...this.agentWorkListeners]) listener(rootID, tenant, event.type === "session.tool.called" ? "tool" : "turn")
1078
+ }
963
1079
  if (event.type === "session.deleted" && sessionID) this.setConversation(sessionID, false)
1080
+ if (event.type === "session.deleted" && sessionID) this.terminalPromptSeen.delete(sessionID)
964
1081
  if (event.type === "session.deleted" && sessionID) for (const listener of [...this.sessionDeletedListeners]) listener(sessionID, tenant)
965
1082
  if (closedPermission) this.permissionClosed(...closedPermission)
966
1083
  }
967
1084
 
1085
+ /** A terminal prompt not counted before for this root (by inboxID; one without an ID always counts). */
1086
+ private firstTerminalPrompt(rootID: string, inboxID: unknown): boolean {
1087
+ if (typeof inboxID !== "string" || !inboxID) return true
1088
+ let seen = this.terminalPromptSeen.get(rootID)
1089
+ if (seen?.has(inboxID)) return false
1090
+ if (seen) this.terminalPromptSeen.delete(rootID)
1091
+ else seen = new Set()
1092
+ this.terminalPromptSeen.set(rootID, seen)
1093
+ seen.add(inboxID)
1094
+ if (seen.size > TERMINAL_PROMPT_IDS) seen.delete(seen.values().next().value!)
1095
+ if (this.terminalPromptSeen.size > TERMINAL_PROMPT_ROOTS) this.terminalPromptSeen.delete(this.terminalPromptSeen.keys().next().value!)
1096
+ return true
1097
+ }
1098
+
968
1099
  /** (REC-01) Hands a root session's event to the recording tap once: from the session's owner connection only. */
969
1100
  private tap(connID: string, tenant: string | undefined, sessionID: string, type: string, data: Record<string, unknown>): void {
970
1101
  const tap = this.recordingTap
@@ -1219,22 +1350,22 @@ export class Registry {
1219
1350
  * (voice front P1) Main's own permission or question while its front is active, once per request: "The session
1220
1351
  * needs your permission to run a command." / "The session has a question: Which branch?" (no command line, as push).
1221
1352
  */
1222
- private announceOwnAsk(type: string, sessionID: string, data: Record<string, unknown>): void {
1353
+ private announceOwnAsk(type: string, sessionID: string, data: Record<string, unknown>, speakAs = sessionID, who = "The session"): void {
1223
1354
  const blocker = askBlocker({ type, data })
1224
1355
  if (!blocker) return
1225
1356
  const key = `${type} ${blocker.id}`
1226
1357
  if (this.announcedBlockers.has(key)) return
1227
1358
  remember(this.announcedBlockers, key, true)
1228
- if (blocker.kind === "permission") return this.announce(sessionID, `announce:permission:${blocker.id}`, `The session needs your permission to ${permissionPhrase(blocker.action)}.`, false)
1359
+ if (blocker.kind === "permission") return this.announce(speakAs, `announce:permission:${blocker.id}`, `${who} needs your permission to ${permissionPhrase(blocker.action)}.`, false)
1229
1360
  // OpenCode's question tool titles its form "Questions": one question is read as itself (a field's description is
1230
1361
  // the question, its title the short header), several are counted.
1231
1362
  const fields = blocker.fields ?? []
1232
- if (blocker.question && fields.length > 1) return this.announce(sessionID, `announce:form:${blocker.id}`, `The session has ${fields.length} questions.`, false)
1363
+ if (blocker.question && fields.length > 1) return this.announce(speakAs, `announce:form:${blocker.id}`, `${who} has ${fields.length} questions.`, false)
1233
1364
  // The blocker may drop descriptions to stay small: the event's own field has it.
1234
1365
  const raw = (data.form as { fields?: Array<{ description?: unknown }> } | undefined)?.fields?.[0]
1235
1366
  const question = fields.length === 1 ? (typeof raw?.description === "string" && raw.description.trim() ? raw.description : fields[0]!.description || fields[0]!.title) : undefined
1236
1367
  const title = spokenTitle(question || (blocker.question ? undefined : blocker.summary))
1237
- this.announce(sessionID, `announce:form:${blocker.id}`, title ? `The session has a question: ${title}` : "The session has a question.", false)
1368
+ this.announce(speakAs, `announce:form:${blocker.id}`, title ? `${who} has a question: ${title}` : `${who} has a question.`, false)
1238
1369
  }
1239
1370
 
1240
1371
  /** Pairs a child session's latest assistant message with successful execution and speaks it once. */
@@ -1491,10 +1622,21 @@ export class Registry {
1491
1622
  return this.active.has(sessionID) || this.remoteBusy.has(sessionID)
1492
1623
  }
1493
1624
 
1494
- /** Adds `since` (run start) to working entries and agents' recommendations to permission blockers. */
1625
+ /** When a blocker was first seen (now, the first time it is asked about). */
1626
+ private blockerFirstSeen(id: string): number {
1627
+ let at = this.blockerSeen.get(id)
1628
+ if (at === undefined) remember(this.blockerSeen, id, (at = this.now()), BLOCKERS_SEEN)
1629
+ return at
1630
+ }
1631
+
1632
+ /**
1633
+ * Adds `since` (run start) to working entries, `blockedSince` (when the oldest open blocker was first seen) to
1634
+ * blocked ones, and agents' recommendations to permission blockers.
1635
+ */
1495
1636
  private live(sessions: SessionStatus[]): LiveStatus[] {
1496
1637
  return sessions.map((s) => {
1497
1638
  const since = s.state === "working" ? this.since.get(s.id) : undefined
1639
+ const blockedSince = s.state === "blocked" && s.blockers.length ? Math.min(...s.blockers.map((b) => this.blockerFirstSeen(b.id))) : undefined
1498
1640
  const recommended = s.blockers.some((b) => b.kind === "permission" && this.permissions.get(b.id)?.recommendation)
1499
1641
  const blockers = recommended
1500
1642
  ? s.blockers.map((b) => {
@@ -1502,8 +1644,8 @@ export class Registry {
1502
1644
  return recommendation ? { ...b, recommendation } : b
1503
1645
  })
1504
1646
  : s.blockers
1505
- const entry = recommended ? { ...s, blockers } : s
1506
- return since === undefined ? entry : { ...entry, since }
1647
+ const entry: LiveStatus = recommended ? { ...s, blockers } : s
1648
+ return since === undefined && blockedSince === undefined ? entry : { ...entry, ...(since !== undefined ? { since } : {}), ...(blockedSince !== undefined ? { blockedSince } : {}) }
1507
1649
  })
1508
1650
  }
1509
1651
 
@@ -1909,6 +2051,12 @@ export class Registry {
1909
2051
  this.emit({ type: "catalog" })
1910
2052
  }
1911
2053
 
2054
+ /** (AF3) Conversation mode of a session now; `front` once a front was asked for (as its conversation messages). */
2055
+ conversationOf(sessionID: string): { on: boolean; front?: boolean } {
2056
+ const on = this.conversations.has(sessionID)
2057
+ return { on, ...(on && this.frontWanted.has(sessionID) ? { front: this.fronts.has(sessionID) } : {}) }
2058
+ }
2059
+
1912
2060
  /** (voice front P1) The active front of a main session, if any. */
1913
2061
  frontOf(sessionID: string): string | undefined {
1914
2062
  return this.fronts.get(sessionID)?.frontID
@@ -2050,6 +2198,7 @@ export class Registry {
2050
2198
  private unmapFront(mainID: string): void {
2051
2199
  const front = this.fronts.get(mainID)
2052
2200
  if (!front) return
2201
+ this.relays.forget(mainID, front.frontID)
2053
2202
  this.fronts.delete(mainID)
2054
2203
  if (this.frontMain.get(front.frontID) === mainID) this.frontMain.delete(front.frontID)
2055
2204
  }
@@ -2132,7 +2281,11 @@ export class Registry {
2132
2281
  case "session.deleted":
2133
2282
  return this.frontFailed(mainID)
2134
2283
  case "session.inbox.delivered":
2135
- if (typeof data.inboxID === "string") this.delivered(data.inboxID)
2284
+ if (typeof data.inboxID === "string") {
2285
+ this.delivered(data.inboxID)
2286
+ // (P2) The front's latest input: an utterance of the user's may relay, a wake never.
2287
+ this.relays.frontDelivered(frontID, data.inboxID)
2288
+ }
2136
2289
  return
2137
2290
  }
2138
2291
  }
@@ -8,11 +8,13 @@ import {
8
8
  lowerLayers,
9
9
  resolveStt,
10
10
  resolveTts,
11
+ speechReady,
11
12
  speechView,
12
13
  updateSpeech,
13
14
  userSpeechView,
14
15
  type Resolved,
15
16
  type SpeechDefaults,
17
+ type SpeechReady,
16
18
  type SpeechView,
17
19
  type StoredSpeech,
18
20
  type UserSpeechView,
@@ -146,6 +148,13 @@ export class SpeechService {
146
148
  return openrouter ? withCurrent(parseModels(body), resolved.model) : speechModelList(kind, body, resolved.model)
147
149
  }
148
150
 
151
+ /** Whether each direction can work at all for the scope (a user's settings over the default; see speechReady). */
152
+ ready(userID?: string): SpeechReady {
153
+ const settings = this.options.store.speechSettings()
154
+ const user = this.userSettings(userID)
155
+ return speechReady(resolveStt(settings, this.options.defaults, user), resolveTts(settings, this.options.defaults, user))
156
+ }
157
+
149
158
  private userSettings(userID: string | undefined): StoredSpeech | undefined {
150
159
  return userID === undefined ? undefined : (this.options.store.userSpeech?.(userID) ?? {})
151
160
  }
@@ -234,6 +234,11 @@ export class SpokeHub {
234
234
  /** Cluster mode: the directory heartbeat, RPC responder and close notices are set up. */
235
235
  private readonly started: Promise<void>
236
236
  readonly websocket: WebSocketHandler<SpokeSocketData>
237
+ /**
238
+ * (voice front P2) Serves relayToSession for `owner` (the machine's owner) from `connID`: the hub's checks and the
239
+ * prompt (set by the hub once its acting engine exists). Unset: refused.
240
+ */
241
+ relayToSession?: (owner: string, connID: string, req: Extract<SpokeRequest, { op: "relayToSession" }>) => Promise<ResultFor["relayToSession"]>
237
242
 
238
243
  constructor(private readonly options: SpokeHubOptions) {
239
244
  const staleMs = options.staleMs ?? 60_000
@@ -731,6 +736,10 @@ export class SpokeHub {
731
736
  const next = req.sessionID ? `/s/${encodeURIComponent(req.sessionID)}${req.voice ? "?voice=1" : ""}` : "/"
732
737
  return { url: `${publicURL}/login?code=${code}&next=${next}` }
733
738
  }
739
+ case "relayToSession":
740
+ // A sandboxed (launch-bound) harness never relays as the user.
741
+ if (ws.data.launch || !this.relayToSession) throw new Error("This hub does not relay for this process")
742
+ return this.relayToSession(owner, ws.data.connID, req)
734
743
  }
735
744
  }
736
745
 
@@ -739,6 +748,10 @@ export class SpokeHub {
739
748
  const { registry } = this.options
740
749
  const failed = (cause: unknown) => console.error("[hub] voice front request failed:", cause instanceof Error ? cause.message : cause)
741
750
  if (action.kind === "remove") return void this.request(action.connID, { op: "removeFront", sessionID: action.sessionID }).catch(failed)
751
+ if (action.kind === "update") {
752
+ if (!registry.supports(action.connID, "conciergeFront")) return
753
+ return void this.request(action.connID, { op: "frontUpdate", sessionID: action.mainID, text: action.text, ...(action.finished ? { finished: true } : {}) }).catch(failed)
754
+ }
742
755
  this.request(action.connID, { op: "conversation", sessionID: action.mainID, on: true, front: true, frontID: action.frontID }).then((result) => registry.frontAnswer(action.mainID, action.connID, result.front?.sessionID), failed)
743
756
  }
744
757
 
@@ -39,7 +39,7 @@ export interface UserRecord {
39
39
  viewPrefs?: ViewPrefs
40
40
  /** (RF2) Hosts and `*.suffix`es whose web images the console loads at once (normalised; absent = none). */
41
41
  mediaAllowlist?: string[]
42
- /** Session ID → per-session Voice and complex-worker model overrides, oldest first. */
42
+ /** Session ID → per-session Voice model (the voice front's), oldest first. An older hub's worker entry is ignored. */
43
43
  modelPrefs?: Record<string, SessionModelPrefs>
44
44
  /** Session ID → when the user last read it (ms), least recently marked first. */
45
45
  reads?: Record<string, number>
@@ -998,25 +998,22 @@ export class Store {
998
998
 
999
999
  // ---------- per-user, per-session model preferences ----------
1000
1000
 
1001
+ /** A session's Voice model (the voice front's); an obsolete Worker model stored by older hubs is left out. */
1001
1002
  modelPrefs(id: string, sessionID: string): SessionModelPrefs {
1002
- const prefs = this.user(id)?.modelPrefs?.[sessionID]
1003
- return prefs ? structuredClone(prefs) : {}
1003
+ const voice = this.user(id)?.modelPrefs?.[sessionID]?.voice
1004
+ return voice ? { voice: structuredClone(voice) } : {}
1004
1005
  }
1005
1006
 
1006
- setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null; worker?: ModelSelection | null }): Promise<SessionModelPrefs> {
1007
+ setModelPrefs(id: string, sessionID: string, change: { voice?: ModelSelection | null }): Promise<SessionModelPrefs> {
1007
1008
  return this.mutate((tx) => {
1008
1009
  const user = this.user(id)
1009
1010
  if (!user) return {}
1010
1011
  const all = user.modelPrefs ?? (user.modelPrefs = {})
1011
- const next: { voice?: ModelSelection; worker?: ModelSelection } = this.modelPrefs(id, sessionID)
1012
- for (const key of ["voice", "worker"] as const) {
1013
- if (!(key in change)) continue
1014
- const value = change[key]
1015
- if (value === null) delete next[key]
1016
- else if (value !== undefined) next[key] = structuredClone(value)
1017
- }
1012
+ const next: { voice?: ModelSelection } = this.modelPrefs(id, sessionID)
1013
+ if (change.voice === null) delete next.voice
1014
+ else if (change.voice !== undefined) next.voice = structuredClone(change.voice)
1018
1015
  delete all[sessionID]
1019
- if (next.voice || next.worker) all[sessionID] = next
1016
+ if (next.voice) all[sessionID] = next
1020
1017
  while (Object.keys(all).length > MODEL_PREFS_LIMIT) delete all[Object.keys(all)[0]!]
1021
1018
  tx.touch("users", id)
1022
1019
  return this.modelPrefs(id, sessionID)
package/src/config.ts CHANGED
@@ -17,18 +17,17 @@ export interface VoiceConfig {
17
17
  /** Session IDs exposed when mode is "session". */
18
18
  readonly sessions: readonly string[]
19
19
  readonly machineName: string
20
- /** Agent the voice orchestrator delegates work to in conversation mode. */
21
- readonly subagent: string
22
20
  /** Start a local hub in the background when the server is loopback and nothing answers (default true). */
23
21
  readonly autoStartLocalHub: boolean
24
22
  /** Read open sessions and workspaces from the Herdr CLI (default true; the service process only). */
25
23
  readonly herdr: boolean
26
24
  /** History window of the session listing: root sessions from the last `days` days, at most `max`. */
27
25
  readonly list: ListWindow
28
- /** Fast model the voice agent talks with in conversation mode ("provider/model[#variant]"). */
26
+ /**
27
+ * Fast model the voice front talks with in Voice mode ("provider/model[#variant]"); a session's Voice model in the
28
+ * console wins. Pick a fast paid model: free ones run out of quota (the front then falls back to the session).
29
+ */
29
30
  readonly voiceModel?: string
30
- /** Model subagents work on; defaults to the session's model before conversation mode. */
31
- readonly workModel?: string
32
31
  readonly machineID?: string
33
32
  readonly token?: string
34
33
  }
@@ -42,8 +41,26 @@ export const DEFAULT_LIST: ListWindow = Object.freeze({ days: 30, max: 100 })
42
41
  const LIST_LIMITS = { days: 365, max: 500 } as const
43
42
 
44
43
  export const DEFAULT_SERVER = "http://127.0.0.1:4097"
45
- export const DEFAULT_SUBAGENT = "general"
46
- const AGENT_ID = /^[A-Za-z0-9_.-]{1,64}$/
44
+ /**
45
+ * Keys older plugins read and wrote for the removed "voice" agent: `subagent` (the agent it delegated to) and
46
+ * `workModel` (its workers' model). Read without error, ignored, never written; logged once per process.
47
+ */
48
+ export const OBSOLETE_CONFIG_KEYS = ["subagent", "workModel"] as const
49
+ const OBSOLETE_WHY: Record<(typeof OBSOLETE_CONFIG_KEYS)[number], string> = {
50
+ subagent: "the voice agent that delegated to it is gone",
51
+ workModel: "the voice agent whose workers used it is gone",
52
+ }
53
+ const warnedKeys = new Set<string>()
54
+
55
+ /** Logs each obsolete key `raw` holds, once per `seen` (the process by default). */
56
+ export function warnObsoleteKeys(raw: unknown, log: (line: string) => void = console.warn, seen: Set<string> = warnedKeys): void {
57
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return
58
+ for (const key of OBSOLETE_CONFIG_KEYS) {
59
+ if (!(key in raw) || seen.has(key)) continue
60
+ seen.add(key)
61
+ log(`[hub] config.json: "${key}" is obsolete (${OBSOLETE_WHY[key]}: Voice mode always uses the voice front now); it is ignored and removed on the next save`)
62
+ }
63
+ }
47
64
  const MODEL_REF = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.:\/-]+(?:#[A-Za-z0-9_.-]+)?$/
48
65
 
49
66
  export interface ModelRef {
@@ -98,7 +115,7 @@ export function prepareConfigPath(env: Env = process.env, home: string = homedir
98
115
  }
99
116
 
100
117
  export function defaults(machineName: string): VoiceConfig {
101
- return { server: DEFAULT_SERVER, mode: "off", sessions: [], machineName, subagent: DEFAULT_SUBAGENT, autoStartLocalHub: true, herdr: true, list: { ...DEFAULT_LIST } }
118
+ return { server: DEFAULT_SERVER, mode: "off", sessions: [], machineName, autoStartLocalHub: true, herdr: true, list: { ...DEFAULT_LIST } }
102
119
  }
103
120
 
104
121
  /** Normalizes untrusted JSON into a config, falling back to defaults field by field. */
@@ -114,12 +131,10 @@ export function normalize(raw: unknown, machineName: string): VoiceConfig {
114
131
  mode,
115
132
  sessions,
116
133
  machineName: typeof r.machineName === "string" && r.machineName ? r.machineName : machineName,
117
- subagent: typeof r.subagent === "string" && AGENT_ID.test(r.subagent) ? r.subagent : base.subagent,
118
134
  autoStartLocalHub: r.autoStartLocalHub !== false,
119
135
  herdr: r.herdr !== false,
120
136
  list: listWindow(r.list),
121
137
  ...(modelRef(r.voiceModel) ? { voiceModel: modelRef(r.voiceModel) } : {}),
122
- ...(modelRef(r.workModel) ? { workModel: modelRef(r.workModel) } : {}),
123
138
  ...(typeof r.machineID === "string" && r.machineID ? { machineID: r.machineID } : {}),
124
139
  ...(typeof r.token === "string" && r.token ? { token: r.token } : {}),
125
140
  }
@@ -138,14 +153,16 @@ function listWindow(raw: unknown): ListWindow {
138
153
 
139
154
  export function readConfig(file: string, machineName: string): VoiceConfig {
140
155
  try {
141
- return normalize(JSON.parse(readFileSync(file, "utf8")), machineName)
156
+ const raw = JSON.parse(readFileSync(file, "utf8"))
157
+ warnObsoleteKeys(raw)
158
+ return normalize(raw, machineName)
142
159
  } catch {
143
160
  return defaults(machineName)
144
161
  }
145
162
  }
146
163
 
147
164
  /** The keys VoiceConfig owns in config.json; every other key belongs to someone else and is preserved. */
148
- const CONFIG_KEYS = ["server", "mode", "sessions", "machineName", "subagent", "autoStartLocalHub", "herdr", "list", "voiceModel", "workModel", "machineID", "token"] as const satisfies readonly (keyof VoiceConfig)[]
165
+ const CONFIG_KEYS = ["server", "mode", "sessions", "machineName", "autoStartLocalHub", "herdr", "list", "voiceModel", "machineID", "token"] as const satisfies readonly (keyof VoiceConfig)[]
149
166
 
150
167
  type RawConfig = Record<string, unknown>
151
168
 
@@ -215,7 +232,7 @@ export function writeConfig(file: string, config: VoiceConfig, options?: SecretF
215
232
  previous = readRawConfig(file) ?? {}
216
233
  } catch {}
217
234
  const next: RawConfig = { ...previous }
218
- for (const key of CONFIG_KEYS) delete next[key]
235
+ for (const key of [...CONFIG_KEYS, ...OBSOLETE_CONFIG_KEYS]) delete next[key]
219
236
  writePrivate(file, serialize({ ...next, ...config }), options)
220
237
  }
221
238