@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
@@ -0,0 +1,293 @@
1
+ // The app-front contract (AF0, docs/plans/2026-11-08-app-front-contracts.md; schema
2
+ // packages/protocol/schema/app-front/v1.schema.json): what an external app (a server-side BFF) uses to make Hub its
3
+ // AI layer — connect with consent (AF1), speech (AF2), a conversation stream (AF3), answering its own sessions'
4
+ // confirm-tier Decisions (AF4) and the voice-client transport (AF5/AF6). Pure: names, units, limits, codes and the
5
+ // few rules both the hub and apps need (redirect URIs, the scope parameter, PKCE shapes, token formats). Nothing
6
+ // here is specific to any one app (Hub decision kvtvodrn); user answers in Hub decisions w6lfbfaq and nixct956.
7
+
8
+ /** Version of the app-front contract and its schema; additive changes keep 1. */
9
+ export const APP_FRONT_VERSION = 1
10
+
11
+ // ---------- scopes and features ----------
12
+
13
+ /** The scopes the app front adds to APP_SCOPES (src/central/agent/app-tokens.ts), each shipped by its AF task. */
14
+ export const APP_FRONT_SCOPES = ["speech:use", "conversation:read", "conversation:write", "decisions:answer"] as const
15
+ export type AppFrontScope = (typeof APP_FRONT_SCOPES)[number]
16
+
17
+ /** `/.well-known/hub` `features` flags: a hub lists one only once the task that ships it is merged. */
18
+ export const APP_FRONT_FEATURES = ["app-connect", "app-speech", "app-conversation", "app-decisions"] as const
19
+ export type AppFrontFeature = (typeof APP_FRONT_FEATURES)[number]
20
+
21
+ /** The task that ships each flag, and the scope that flag brings (app-connect brings none: it is the flow). */
22
+ export const APP_FRONT_FEATURE_TASK: Readonly<Record<AppFrontFeature, { readonly task: string; readonly scopes: readonly AppFrontScope[] }>> = {
23
+ "app-connect": { task: "AF1", scopes: [] },
24
+ "app-speech": { task: "AF2", scopes: ["speech:use"] },
25
+ "app-conversation": { task: "AF3", scopes: ["conversation:read", "conversation:write"] },
26
+ "app-decisions": { task: "AF4", scopes: ["decisions:answer"] },
27
+ }
28
+
29
+ /**
30
+ * What this source tree ships. Each AF task flips its flag in the same commit that ships it (the schema test then
31
+ * expects the hub to advertise it and its routes to answer); until then the feature is contract-only.
32
+ */
33
+ export const APP_FRONT_SHIPPED: Readonly<Record<AppFrontFeature, boolean>> = {
34
+ "app-connect": true,
35
+ "app-speech": true,
36
+ "app-conversation": true,
37
+ "app-decisions": true,
38
+ }
39
+
40
+ // ---------- connect (AF1): OAuth 2.1-style authorization code + PKCE ----------
41
+
42
+ /** Endpoints of the connect flow. `/connect/*` and the metadata are top level (never behind the console gate). */
43
+ export const CONNECT_PATHS = {
44
+ /** RFC 8414 authorization server metadata (public). */
45
+ metadata: "/.well-known/oauth-authorization-server",
46
+ /** GET, browser: validates the request, opens a flow, redirects to the consent page. */
47
+ authorize: "/connect/authorize",
48
+ /** The console's consent page (signed-in user). */
49
+ consentPage: "/connect/consent/:flow",
50
+ /** POST, app server (form-encoded, client authentication): code → tokens, refresh → tokens. */
51
+ token: "/connect/token",
52
+ /** POST, app server (RFC 7009): ends the connection the token belongs to. */
53
+ revoke: "/connect/revoke",
54
+ /** Console routes behind the gate (cookie, same origin): read, approve, deny a flow. */
55
+ flow: "/api/connect/flows/:flow",
56
+ flowApprove: "/api/connect/flows/:flow/approve",
57
+ flowDeny: "/api/connect/flows/:flow/deny",
58
+ /** /api/agent, user actors only: registered clients (admins write) and the user's connections. */
59
+ clients: "/api/agent/app-clients",
60
+ client: "/api/agent/app-clients/:id",
61
+ clientSecret: "/api/agent/app-clients/:id/secret",
62
+ connections: "/api/agent/connections",
63
+ connection: "/api/agent/connections/:id",
64
+ } as const
65
+
66
+ /** Token and ID prefixes ("hc": hub connect). Hand-made app tokens keep `hat_` (unchanged, never expire). */
67
+ export const CONNECT_PREFIXES = {
68
+ clientID: "hcl_",
69
+ clientSecret: "hcs_",
70
+ flow: "hcf_",
71
+ code: "hco_",
72
+ access: "hca_",
73
+ refresh: "hcr_",
74
+ } as const
75
+
76
+ /** Anchored formats; secrets are 32 random bytes in base64url (43 characters). Connection IDs are 16 hex. */
77
+ export const CONNECT_TOKEN_PATTERNS = {
78
+ clientID: /^hcl_[0-9a-f]{16}$/,
79
+ clientSecret: /^hcs_[A-Za-z0-9_-]{43}$/,
80
+ flow: /^hcf_[0-9a-f]{24}$/,
81
+ code: /^hco_[A-Za-z0-9_-]{43}$/,
82
+ access: /^hca_([0-9a-f]{16})_[A-Za-z0-9_-]{43}$/,
83
+ refresh: /^hcr_([0-9a-f]{16})_[A-Za-z0-9_-]{43}$/,
84
+ connectionID: /^[0-9a-f]{16}$/,
85
+ } as const
86
+
87
+ /** Lifetimes (ms). */
88
+ export const ACCESS_TOKEN_TTL_MS = 60 * 60_000
89
+ /** Sliding: every refresh issues a refresh token valid this long from then; unused this long, the connection lapses. */
90
+ export const REFRESH_TOKEN_IDLE_MS = 30 * 24 * 60 * 60_000
91
+ export const AUTHORIZATION_CODE_TTL_MS = 60_000
92
+ /**
93
+ * After a refresh, presenting the just-rotated refresh token again this long answers the same new pair (a lost answer
94
+ * can be retried); after it, that is reuse and revokes the connection (Hub decision nixct956 3).
95
+ */
96
+ export const REFRESH_GRACE_MS = 30_000
97
+ /** From authorize to approve/deny (like CLI_LOGIN_TTL_MS). */
98
+ export const CONNECT_FLOW_TTL_MS = 10 * 60_000
99
+
100
+ export const CONNECT_LIMITS = {
101
+ /** Hub-wide (admin-registered) clients per hub. */
102
+ clients: 50,
103
+ /** Personal clients per user (decision nixct956 5). */
104
+ personalClientsPerUser: 10,
105
+ redirectURIsPerClient: 10,
106
+ clientNameMax: 100,
107
+ /** `state` is opaque to the hub and echoed back. */
108
+ stateMax: 1024,
109
+ /** Connections per user (one per client). */
110
+ connectionsPerUser: 50,
111
+ /** Authorize requests per client and client address a minute (an open flow is cheap but bounded). */
112
+ authorizePerMinute: 20,
113
+ /** Authenticated token-endpoint requests per client and minute. */
114
+ tokenPerMinute: 60,
115
+ /**
116
+ * Failed client authentications per client and client address a minute; beyond, that address's failures get 429
117
+ * (a caller with the right secret is never refused for them).
118
+ */
119
+ failedClientAuthPerMinute: 10,
120
+ } as const
121
+
122
+ /**
123
+ * Who may use a registered client (Hub decision nixct956 5): `hub` (hub-wide, admin-registered: every user of the hub
124
+ * may connect) or `user` (personal: only the user who registered it sees and uses it).
125
+ */
126
+ export const CLIENT_AUDIENCES = ["hub", "user"] as const
127
+ export type ClientAudience = (typeof CLIENT_AUDIENCES)[number]
128
+
129
+ export const CODE_CHALLENGE_METHODS = ["S256"] as const
130
+ export const RESPONSE_TYPES = ["code"] as const
131
+ export const GRANT_TYPES = ["authorization_code", "refresh_token"] as const
132
+ export const TOKEN_ENDPOINT_AUTH_METHODS = ["client_secret_basic", "client_secret_post"] as const
133
+
134
+ /** RFC 6749 §4.1.2.1 errors the authorize endpoint redirects with (only once client_id and redirect_uri are valid). */
135
+ export const AUTHORIZE_ERRORS = ["invalid_request", "unauthorized_client", "access_denied", "unsupported_response_type", "invalid_scope", "server_error", "temporarily_unavailable"] as const
136
+ /** RFC 6749 §5.2 errors of the token endpoint, with their HTTP status (`temporarily_unavailable` 429: rate limit). */
137
+ export const TOKEN_ERROR_STATUS = {
138
+ invalid_request: 400,
139
+ invalid_client: 401,
140
+ invalid_grant: 400,
141
+ unauthorized_client: 400,
142
+ unsupported_grant_type: 400,
143
+ invalid_scope: 400,
144
+ temporarily_unavailable: 429,
145
+ } as const
146
+ export type TokenError = keyof typeof TOKEN_ERROR_STATUS
147
+
148
+ // ---------- speech (AF2) ----------
149
+
150
+ export const SPEECH_PATHS = { transcribe: "/api/agent/speech/transcribe", speak: "/api/agent/speech/speak" } as const
151
+
152
+ /** Per app credential (a connection, or a hand-made token): 429 rate_limited with retryAfter beyond these. */
153
+ export const SPEECH_RATE_LIMITS = {
154
+ transcribe: { perMinute: 20, perHour: 300 },
155
+ speak: { perMinute: 60, perHour: 1000, charsPerHour: 100_000 },
156
+ } as const
157
+
158
+ // ---------- conversation (AF3) ----------
159
+
160
+ export const CONVERSATION_PATHS = {
161
+ stream: "/api/agent/sessions/:addr/stream",
162
+ conversation: "/api/agent/sessions/:addr/conversation",
163
+ utterance: "/api/agent/sessions/:addr/utterance",
164
+ interrupt: "/api/agent/sessions/:addr/interrupt",
165
+ /** Typed turns: the existing act prompt, also admitted with conversation:write. */
166
+ turn: "/api/agent/sessions/:addr/prompt",
167
+ /** Re-read after a reset: the existing log, also admitted with conversation:read for the app's sessions. */
168
+ log: "/api/agent/sessions/:addr/log",
169
+ } as const
170
+
171
+ export const APP_STREAM_FRAME_TYPES = ["ready", "delta", "say", "heard", "status", "conversation", "reset", "end"] as const
172
+ export type AppStreamFrameType = (typeof APP_STREAM_FRAME_TYPES)[number]
173
+
174
+ /** Why the hub ended an app stream (the last frame, `end`). */
175
+ export const APP_STREAM_END_REASONS = ["token_expired", "revoked", "grant", "gone", "slow"] as const
176
+ export type AppStreamEndReason = (typeof APP_STREAM_END_REASONS)[number]
177
+
178
+ /** What a `reset` frame asks the app to re-read. */
179
+ export const APP_STREAM_REFRESH = ["log", "status", "conversation", "decisions"] as const
180
+
181
+ export const APP_STREAM_LIMITS = {
182
+ heartbeatMs: 20_000,
183
+ /** Draining: `retry:` sent before the stream ends. */
184
+ retryMs: 2_000,
185
+ /** How often an open stream re-checks its credential, grant and session (and ends with `end`). */
186
+ recheckMs: 30_000,
187
+ /** Unsent bytes a slow reader may hold before the hub ends the stream (`end` reason slow). */
188
+ bufferMaxBytes: 1 << 20,
189
+ /** Concurrent streams per app credential, and per user over all apps (429 rate_limited beyond). */
190
+ streamsPerApp: 4,
191
+ streamsPerUser: 20,
192
+ } as const
193
+
194
+ // ---------- decisions (AF4) ----------
195
+
196
+ export const DECISION_PATHS = { list: "/api/agent/decisions", answer: "/api/agent/decisions/:id/answer" } as const
197
+ /** The only tier an app answers (decision w6lfbfaq 1). */
198
+ export const APP_ANSWERABLE_TIERS = ["confirm"] as const
199
+
200
+ // ---------- voice-client transport (AF5 interface, AF6 app adapter) ----------
201
+
202
+ /** What the voice loop hears about its session (the app stream's frames it uses). */
203
+ export type VoiceEvent =
204
+ | { readonly type: "say"; readonly sessionID: string; readonly messageID: string; readonly ordinal: number; readonly seq?: number; readonly text: string }
205
+ | { readonly type: "heard"; readonly sessionID: string; readonly inboxID: string }
206
+ | { readonly type: "conversation"; readonly sessionID: string; readonly on: boolean; readonly front?: boolean; readonly reason?: "front_failed" }
207
+ | { readonly type: "status"; readonly sessionID: string; readonly state: "idle" | "working" | "blocked" | "error" }
208
+ | { readonly type: "reset" }
209
+
210
+ /**
211
+ * The transport the voice loop (packages/voice-client) talks through. The console implements it with its cookie
212
+ * routes; an app implements it against its own server, which calls the AF2/AF3 routes with its token. The package
213
+ * never holds a hub token and never calls the hub itself.
214
+ */
215
+ export interface VoiceTransport {
216
+ /** Conversation mode on/off for the session (front: the voice front). */
217
+ conversation(on: boolean, options?: { readonly front?: boolean }): Promise<{ readonly on: boolean; readonly front?: boolean }>
218
+ /** A final transcript, to the session (or its front). */
219
+ utterance(text: string): Promise<{ readonly inboxID: string }>
220
+ /** Barge-in: stops the front (or the session's turn). */
221
+ interrupt(): Promise<void>
222
+ transcribe(audio: Blob, options?: { readonly signal?: AbortSignal }): Promise<{ readonly text: string }>
223
+ /** audio/mpeg, streamed when the provider streams. */
224
+ speak(text: string, options?: { readonly signal?: AbortSignal }): Promise<ReadableStream<Uint8Array>>
225
+ /** Subscribes to the session's voice events; returns the unsubscribe. */
226
+ events(listener: (event: VoiceEvent) => void): () => void
227
+ }
228
+
229
+ // ---------- rules ----------
230
+
231
+ export const REDIRECT_URI_MAX = 2048
232
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "[::1]", "localhost"])
233
+
234
+ /**
235
+ * Why `uri` cannot be a registered redirect URI, or undefined: an absolute https URL (http only on a loopback host),
236
+ * at most REDIRECT_URI_MAX characters, without user info, fragment, wildcard or surrounding whitespace.
237
+ */
238
+ export function redirectUriProblem(uri: unknown): string | undefined {
239
+ if (typeof uri !== "string" || uri === "") return "a redirect URI is a non-empty string"
240
+ if (uri.length > REDIRECT_URI_MAX) return `a redirect URI is at most ${REDIRECT_URI_MAX} characters`
241
+ if (uri.trim() !== uri || /\s/.test(uri)) return "a redirect URI has no whitespace"
242
+ if (uri.includes("*")) return "a redirect URI has no wildcard"
243
+ let url: URL
244
+ try {
245
+ url = new URL(uri)
246
+ } catch {
247
+ return "a redirect URI is an absolute URL"
248
+ }
249
+ if (url.protocol !== "https:" && url.protocol !== "http:") return "a redirect URI uses https (http only on a loopback host)"
250
+ if (url.protocol === "http:" && !LOOPBACK_HOSTS.has(url.hostname)) return "http is allowed only on a loopback host (127.0.0.1, [::1], localhost)"
251
+ if (url.username || url.password) return "a redirect URI has no user info"
252
+ if (uri.includes("#")) return "a redirect URI has no fragment"
253
+ return undefined
254
+ }
255
+
256
+ /** Exact string comparison against the registered URIs (no normalisation of any kind). */
257
+ export function redirectUriMatches(registered: readonly string[], uri: unknown): boolean {
258
+ return typeof uri === "string" && registered.includes(uri)
259
+ }
260
+
261
+ /** The OAuth `scope` parameter: scopes separated by single spaces, each known; duplicates dropped, order kept. */
262
+ export function parseScopeParam(value: unknown, known: readonly string[]): { scopes: string[] } | { problem: string } {
263
+ if (typeof value !== "string" || value === "") return { problem: "scope lists at least one scope" }
264
+ const parts = value.split(" ")
265
+ if (parts.some((part) => part === "")) return { problem: "scope separates scopes with single spaces" }
266
+ const unknown = parts.find((part) => !known.includes(part))
267
+ if (unknown !== undefined) return { problem: `unknown scope: ${unknown.slice(0, 64)}` }
268
+ return { scopes: [...new Set(parts)] }
269
+ }
270
+
271
+ const VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/
272
+ const CHALLENGE = /^[A-Za-z0-9_-]{43}$/
273
+
274
+ /** RFC 7636 §4.1: 43–128 unreserved characters. */
275
+ export function pkceVerifierProblem(verifier: unknown): string | undefined {
276
+ return typeof verifier === "string" && VERIFIER.test(verifier) ? undefined : "code_verifier is 43–128 characters of A-Z a-z 0-9 - . _ ~"
277
+ }
278
+
279
+ /** S256 only: the challenge is base64url(SHA-256(verifier)) without padding (43 characters). */
280
+ export function pkceChallengeProblem(challenge: unknown, method: unknown): string | undefined {
281
+ if (method !== "S256") return "code_challenge_method must be S256"
282
+ return typeof challenge === "string" && CHALLENGE.test(challenge) ? undefined : "code_challenge is 43 base64url characters"
283
+ }
284
+
285
+ /** A connected app's access or refresh token, with the connection it belongs to; anything else undefined. */
286
+ export function parseConnectToken(token: unknown): { kind: "access" | "refresh"; connectionID: string } | undefined {
287
+ if (typeof token !== "string") return undefined
288
+ const access = CONNECT_TOKEN_PATTERNS.access.exec(token)
289
+ if (access) return { kind: "access", connectionID: access[1]! }
290
+ const refresh = CONNECT_TOKEN_PATTERNS.refresh.exec(token)
291
+ if (refresh) return { kind: "refresh", connectionID: refresh[1]! }
292
+ return undefined
293
+ }
@@ -101,8 +101,10 @@ export const CONCIERGE_LIMITS = {
101
101
  /**
102
102
  * The keys of the parts a session context carries. The spoke appends instructions then notes to the session's system
103
103
  * prompt (stable between turns) and sends the board after the conversation (it changes: V-01, the prompt cache).
104
+ * "frontBoard" (voice front P2, sent only to spokes announcing "conciergeFront"): the compact board for the
105
+ * Concierge's voice front; never part of the Concierge's own prompt.
104
106
  */
105
- export const SESSION_CONTEXT_KEYS = ["instructions", "board", "notes"] as const
107
+ export const SESSION_CONTEXT_KEYS = ["instructions", "board", "notes", "frontBoard"] as const
106
108
  export type SessionContextKey = (typeof SESSION_CONTEXT_KEYS)[number]
107
109
 
108
110
  export const SESSION_CONTEXT_LIMITS = { partChars: 40_000, denyGlobs: 50, globChars: 100, allowTools: 50 } as const
@@ -272,6 +274,11 @@ export interface ConciergeBoardSession {
272
274
  readonly project?: string
273
275
  /** Machine name (sanitised label). */
274
276
  readonly machine?: string
277
+ /**
278
+ * (Concierge grant requests) The verbs an active @@concierge grant allows in this session's project folder (the
279
+ * acting engine's grant lookup for the current Concierge; [] = none). Absent: unknown (no Concierge, no folder).
280
+ */
281
+ readonly projectGrant?: readonly string[]
275
282
  readonly state: ConciergeBoardState
276
283
  /** When the current state began, as far as the hub knows (activity, last result, or lastSeen when offline). */
277
284
  readonly since?: number
@@ -283,6 +290,8 @@ export interface ConciergeBoardSession {
283
290
  readonly intent?: { readonly text: string; readonly source: IntentSource }
284
291
  /** The non-placeholder title (at most 80 characters), untrusted; only when there is no intent. */
285
292
  readonly title?: string
293
+ /** The primary context card's headline (at most 90 characters), untrusted: where the work stands (docs/context-cards.md). */
294
+ readonly pickup?: string
286
295
  readonly lastResult?: { readonly at: number; readonly outcome: ConciergeResultOutcome }
287
296
  readonly reasons: readonly WatchReason[]
288
297
  }
@@ -0,0 +1,59 @@
1
+ // Context cards (decision 9604b0a1, docs/context-cards.md): short, auto-updated cards beside a session that help
2
+ // the user and the Concierge pick its work back up. Each card comes from a PROVIDER (the hub's registry, e.g.
3
+ // `pickup`, `changes`); one provider is primary: its headline is the session list's second line. Shared by the
4
+ // hub (src/central/context-cards) and the console (web/src/core/context-cards-core.ts).
5
+
6
+ /** What makes a provider run: a turn ended, the session went blocked, the user came back, the leader's sweep, a refresh. */
7
+ export const CONTEXT_CARD_TRIGGERS = ["turn-end", "blocked", "user-return", "sweep", "manual"] as const
8
+ export type ContextCardTrigger = (typeof CONTEXT_CARD_TRIGGERS)[number]
9
+
10
+ /** Bounds of a stored card (characters, code points). */
11
+ export const CONTEXT_CARD_LIMITS = {
12
+ title: 60,
13
+ headline: 90,
14
+ body: 400,
15
+ fields: 6,
16
+ fieldLabel: 24,
17
+ fieldValue: 120,
18
+ model: 100,
19
+ } as const
20
+
21
+ export interface ContextCardField {
22
+ readonly label: string
23
+ readonly value: string
24
+ }
25
+
26
+ export interface ContextCard {
27
+ /** The provider's id (`pickup`, `changes`, …). */
28
+ readonly provider: string
29
+ readonly sessionID: string
30
+ /** The card's title ("Pick up where you left off"). */
31
+ readonly title: string
32
+ /** One line (≤ 90 characters): the primary card's is the session list's second line. */
33
+ readonly headline: string
34
+ /** Markdown-lite text (≤ 400 characters; may be empty). Untrusted: render as text. */
35
+ readonly body: string
36
+ readonly fields?: readonly ContextCardField[]
37
+ /** Epoch ms. */
38
+ readonly generatedAt: number
39
+ /** The model that wrote it (model providers only). */
40
+ readonly model?: string
41
+ /** Hash of the inputs it was made from (unchanged inputs are not regenerated). */
42
+ readonly inputsHash: string
43
+ /** The provider's version when it was made. */
44
+ readonly version: number
45
+ readonly trigger: ContextCardTrigger
46
+ /** The inputs it was made from (an offline spoke's weaker inputs never replace a richer card). */
47
+ readonly basis?: readonly string[]
48
+ /** The primary card (its headline is the list's second line). */
49
+ readonly primary?: true
50
+ }
51
+
52
+ /** A provider as clients see it. */
53
+ export interface ContextCardProviderInfo {
54
+ readonly id: string
55
+ readonly label: string
56
+ readonly primary: boolean
57
+ /** It calls the context card model (needs it configured, counts against the daily budget). */
58
+ readonly usesModel: boolean
59
+ }
@@ -1,9 +1,16 @@
1
1
  // Literal credentials in free text (tokens, keys, private keys): found, or replaced with "[redacted: <kind>]". Voice
2
2
  // recordings scrub every text field with it; spoken and pushed failure reasons and voice-front wakes do too.
3
3
 
4
+ /**
5
+ * A private key block of any type (PEM, OpenSSH, encrypted, PGP): BEGIN through END; without an END (a cut text), the
6
+ * header, the base64 chunks after it on the same line (after spaces or a literal "\n", as in JSON or env strings) and
7
+ * the key-material lines after it. Prose after a bare header ("… KEY----- and …") is kept.
8
+ */
9
+ const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----(?:[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----|(?:(?:[ \t]+|\\n)[A-Za-z0-9+/=]{8,})*(?:\r?\n[A-Za-z0-9+/=:,. -]*)*)/
10
+
4
11
  /** Distinctive credential formats; prose about secrets ("set API_KEY=…") is not matched. */
5
12
  export const CREDENTIALS: readonly (readonly [string, RegExp])[] = [
6
- ["private key", /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
13
+ ["private key", PRIVATE_KEY],
7
14
  ["hub app token", /\bhat_[0-9a-f]{16}_[A-Za-z0-9_-]{43,}/],
8
15
  ["github token", /\bgh[pousr]_[A-Za-z0-9]{36,}/],
9
16
  ["github token", /\bgithub_pat_[A-Za-z0-9_]{22,}/],
@@ -15,6 +22,31 @@ export const CREDENTIALS: readonly (readonly [string, RegExp])[] = [
15
22
  ["bearer token", /\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{20,}/],
16
23
  ]
17
24
 
25
+ /** Names whose value is a secret: ending in password, passwd, pwd, passphrase, secret, token, api key, or AWS key names. */
26
+ const NAME = String.raw`[A-Za-z0-9_.-]*(?:password|passwd|pwd|passphrase|secret|token|api[_-]?key|secret_?access_?key|access_?key_?id)`
27
+ /** Between a name and its value: an optional closing quote, then = or :. */
28
+ const SEP = String.raw`["']?\s*[:=]\s*`
29
+ /** Placeholder values in docs and prose ("API_KEY=your-key-here", "<token>", "…", "xxx", "changeme") are not secrets. */
30
+ const PLACEHOLDER = String.raw`(?!(?:your[-_ ]|<|\.\.\.|…|x{3,}(?![A-Za-z0-9])|\*{3,}|changeme|example|placeholder)[^\s"']*(?:["'\s&;,<>]|$))`
31
+
32
+ /**
33
+ * Secrets by context, redacted by redactCredentials after CREDENTIALS (the name stays, the value goes):
34
+ * aws secret key: a 40-character key after aws_secret_access_key / aws_secret_key / secret_access_key (any case)
35
+ * url password: scheme://user:<password>@host (the user may be empty; the password may hold "/"; a port is not one)
36
+ * authorization: the credentials after Authorization: Basic | Bearer | Token | Digest
37
+ * secret: <name>=value, <name>: value and "<name>": "value" (NAME, any case, e.g. GITHUB_TOKEN, client_secret,
38
+ * OPENAI_API_KEY, apiKey, SecretAccessKey, SessionToken, AccessKeyId); a quoted value goes whole
39
+ * (spaces, commas, semicolons included), an unquoted one runs to whitespace, a quote or & ; , < >;
40
+ * placeholders (your-…, <…>, …, xxx, ***, changeme, example, placeholder) are left alone
41
+ */
42
+ const CONTEXTUAL: readonly (readonly [string, RegExp, string])[] = [
43
+ ["aws secret key", /\b((?:aws_)?secret(?:_access)?_key["']?\s*[:=]\s*["']?)(?!\[redacted)[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])/gi, "$1[redacted: aws secret key]"],
44
+ ["url password", /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]*:)(?!\[redacted)(?!\d+(?:[/?#\s]|$))[^\s@]+(@)/gi, "$1[redacted: url password]$2"],
45
+ ["authorization", /\b(authorization["']?\s*[:=]\s*["']?(?:basic|bearer|token|digest)\s+)(?!\[redacted)[^\s"',;]+/gi, "$1[redacted: authorization]"],
46
+ ["secret", new RegExp(String.raw`\b(${NAME}${SEP})(["'])(?!\[redacted)${PLACEHOLDER}(?:(?!\2)[^\\\n]|\\.)*\2`, "gi"), "$1$2[redacted: secret]$2"],
47
+ ["secret", new RegExp(String.raw`\b(${NAME}${SEP})(?!["'\[])${PLACEHOLDER}(?=[^\s"'&;,<>]*[A-Za-z0-9])[^\s"'&;,<>]+`, "gi"), "$1[redacted: secret]"],
48
+ ]
49
+
18
50
  /** The kind of literal credential in a text, or undefined. */
19
51
  export function credentialFinding(text: string): string | undefined {
20
52
  for (const [kind, pattern] of CREDENTIALS) if (pattern.test(text)) return kind
@@ -22,7 +54,7 @@ export function credentialFinding(text: string): string | undefined {
22
54
  }
23
55
 
24
56
  /**
25
- * (REC-01) Replaces every literal credential in a text with `[redacted: <kind>]` (the CREDENTIALS table, applied
57
+ * (REC-01) Replaces every literal credential (CREDENTIALS, then the CONTEXTUAL secrets: names kept, values replaced) in a text with `[redacted: <kind>]` (the CREDENTIALS table, applied
26
58
  * globally) and lists the kinds removed, each once, in table order. Voice recordings scrub every text field with it.
27
59
  */
28
60
  export function redactCredentials(text: string): { text: string; kinds: string[] } {
@@ -34,5 +66,10 @@ export function redactCredentials(text: string): { text: string; kinds: string[]
34
66
  if (next !== out && !kinds.includes(kind)) kinds.push(kind)
35
67
  out = next
36
68
  }
69
+ for (const [kind, pattern, replacement] of CONTEXTUAL) {
70
+ const next = out.replace(pattern, replacement)
71
+ if (next !== out && !kinds.includes(kind)) kinds.push(kind)
72
+ out = next
73
+ }
37
74
  return { text: out, kinds }
38
75
  }
@@ -34,6 +34,7 @@ export * from "./hub-workspace.ts"
34
34
  export * from "./concierge.ts"
35
35
  export * from "./profiles.ts"
36
36
  export * from "./artifacts.ts"
37
+ export * from "./context-cards.ts"
37
38
  export * from "./embedded-media.ts"
38
39
  export * from "./media.ts"
39
40
  export * from "./media-allowlist.ts"
@@ -41,6 +42,7 @@ export * from "./preview-bundle.ts"
41
42
  export * from "./teams.ts"
42
43
  export * from "./graph.ts"
43
44
  export * from "./voice-recording.ts"
45
+ export * from "./app-front.ts"
44
46
  export { HUB_URL_MAX_CHARS, isHubURL, MAX_FRAME, MAX_RETRY_AFTER_MS }
45
47
 
46
48
  export const PROTOCOL_VERSION = 1
@@ -84,7 +86,9 @@ export interface ModelSelection {
84
86
  }
85
87
 
86
88
  export interface SessionModelPrefs {
89
+ /** The voice front's model for this session. */
87
90
  readonly voice?: ModelSelection
91
+ /** Obsolete (the removed voice agent's worker model): older hubs and spokes still send it; it is ignored. */
88
92
  readonly worker?: ModelSelection
89
93
  }
90
94
 
@@ -323,11 +327,16 @@ export const FORWARDED_EVENTS: ReadonlySet<string> = new Set([
323
327
  export const INBOX_ORIGINS = ["user", "hub"] as const
324
328
  export type InboxOrigin = (typeof INBOX_ORIGINS)[number]
325
329
 
326
- /** `session.inbox.delivered` data as forwarded. `origin` is optional: older spokes do not send it. */
330
+ /**
331
+ * `session.inbox.delivered` data as forwarded. `origin` is optional: older spokes do not send it. `carried` (feature
332
+ * "inboxCarried"): a user item the hub carried itself (a console turn or steer, a Voice utterance, a launch's prompt),
333
+ * never a prompt typed in the terminal.
334
+ */
327
335
  export interface InboxDeliveredData {
328
336
  readonly sessionID: string
329
337
  readonly inboxID: string
330
338
  readonly origin?: InboxOrigin
339
+ readonly carried?: true
331
340
  }
332
341
 
333
342
  /**
@@ -466,8 +475,13 @@ export const IMPLIED_OPENCODE_FEATURES = [
466
475
  * carry media refs (user files' `ref`, tool and assistant rows' `media`).
467
476
  * "previewBundle": it answers previewBundle (preview-bundle.ts): a local HTML page an assistant message names and the
468
477
  * files it refers to, under the media path policy, paged under the frame limit.
478
+ * "conciergeFront" (voice front P2): it puts a voice front in front of a locked (Concierge) session when the hub asks
479
+ * with `front`, serves frontUpdate, and takes the session-context part "frontBoard" (the front's compact board).
480
+ * "inboxCarried": its session.inbox.delivered marks a user item the hub carried (`carried: true`), so every other
481
+ * user item is a prompt typed in the terminal. Spokes of harnesses that report the hub's prompts as origin "hub"
482
+ * need not announce it.
469
483
  */
470
- const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert", "dismissError", "timelineMedia", PREVIEW_BUNDLE_FEATURE, "voiceFront"] as const
484
+ const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert", "dismissError", "timelineMedia", PREVIEW_BUNDLE_FEATURE, "voiceFront", "conciergeFront", "inboxCarried"] as const
471
485
 
472
486
  /**
473
487
  * The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
@@ -503,11 +517,12 @@ export const SPOKE_FEATURES: readonly SpokeFeature[] = [...OPTIONAL_SPOKE_FEATUR
503
517
  * "pendingReplay": the hub takes a re-announced permission.asked / form.created (after a spoke reconnects) without
504
518
  * notifying again, and honours `unlisted: true` on form.cancelled / permission.replied (only that process stops
505
519
  * holding it). Spokes re-send permissions the hub was told already only to a hub that announces it.
520
+ * "frontRelay" (voice front P2): it serves the spoke request relayToSession (a Concierge front's relay_to_session).
506
521
  */
507
- export const KNOWN_HUB_FEATURES = ["harness", "messaging", "nodes", "launches", "pendingReplay"] as const
522
+ export const KNOWN_HUB_FEATURES = ["harness", "messaging", "nodes", "launches", "pendingReplay", "frontRelay"] as const
508
523
  export type HubFeature = (typeof KNOWN_HUB_FEATURES)[number]
509
524
  /** What this hub announces ("launches": the /api/agent/launches routes). */
510
- export const HUB_FEATURES: readonly HubFeature[] = ["harness", "messaging", "nodes", "launches", "pendingReplay"]
525
+ export const HUB_FEATURES: readonly HubFeature[] = ["harness", "messaging", "nodes", "launches", "pendingReplay", "frontRelay"]
511
526
 
512
527
  /** A spoke's harness, version and evidence level; a hello without `harness` is an OpenCode native spoke. */
513
528
  export function helloHarness(hello: Hello): { readonly harness: string; readonly harnessVersion: string; readonly evidence: EvidenceLevel | "unknown" } {
@@ -548,6 +563,21 @@ export type SpokeRequest =
548
563
  /** A one-time sign-in link to a session's page, or to the home page without a session. */
549
564
  /** `voice`: land on the session with Voice mode starting (`?voice=1`). */
550
565
  | { readonly op: "openLink"; readonly sessionID?: string; readonly voice?: boolean }
566
+ /**
567
+ * (hub feature "frontRelay", voice front P2) A Concierge front's relay_to_session: the user's spoken request, as a
568
+ * message to `session` (@name or session ID), one of the user's own sessions. The hub checks the front, the
569
+ * utterance it answers and the target; a refusal is ok:false with the reason the front reads. The hub delivers the
570
+ * user's own words (its record of the utterance), never `text`: `text` is the front's short note, sent along only
571
+ * when every content word in it is one the user said. `previous` (a redirect, "no, the other one"): the user's
572
+ * utterance before this one goes first. `confirm` (guard tuning): one retry after the hub's soft refusal of a question
573
+ * with a work verb, for the same utterance and session.
574
+ */
575
+ | { readonly op: "relayToSession"; readonly frontID: string; readonly session: string; readonly text: string; readonly previous?: boolean; readonly confirm?: boolean }
576
+
577
+ /** The longest relay text (as an utterance). */
578
+ export const RELAY_TEXT_MAX = 4000
579
+ /** The longest front update text. */
580
+ export const FRONT_UPDATE_MAX = 8000
551
581
 
552
582
  // ---------- server → spoke ----------
553
583
 
@@ -659,17 +689,16 @@ export type ServerRequest =
659
689
  /** One changed file's patch in the session's directory. */
660
690
  | { readonly op: "diff"; readonly sessionID: string; readonly file: string }
661
691
  /**
662
- * Switch the session to the voice orchestrator agent (on) or back to its previous agent (off). `front` (feature
663
- * "voiceFront", sent only to spokes that list it): leave the session's agent and model untouched and put a hidden
664
- * voice-front session in front of it instead (docs/plans/2026-11-06-voice-front-p1-contract.md).
665
- */
666
- /**
667
- * `front` (feature "voiceFront"): put a hidden voice front in front of the session. `frontID`: the front the hub
668
- * knows for it (a resend to a restarted spoke): re-linked if it still exists, instead of creating another.
692
+ * Conversation (Voice) mode on or off. The session's agent and model are never switched (older spokes switched it
693
+ * to a "voice" agent). `front` (feature "voiceFront", sent only to spokes that list it): put a hidden voice front in
694
+ * front of the session (docs/plans/2026-11-06-voice-front-p1-contract.md); without it, or when no front can be made,
695
+ * the hub talks to the session itself and its turns are voice-styled. `frontID`: the front the hub knows for it (a
696
+ * resend to a restarted spoke): re-linked if it still exists, instead of creating another.
669
697
  */
670
698
  | { readonly op: "conversation"; readonly sessionID: string; readonly on: boolean; readonly front?: boolean; readonly frontID?: string }
671
699
  | { readonly op: "models"; readonly sessionID: string }
672
700
  | { readonly op: "modelPrefs"; readonly sessionID: string }
701
+ /** `worker` is obsolete: older hubs send it, current spokes ignore it. */
673
702
  | { readonly op: "setModelPrefs"; readonly sessionID: string; readonly voice?: ModelSelection | null; readonly worker?: ModelSelection | null }
674
703
  | { readonly op: "timeline"; readonly sessionID: string; readonly limit?: number; readonly cursor?: string; readonly order?: "asc" | "desc"; readonly before?: string }
675
704
  | { readonly op: "timelinePart"; readonly sessionID: string; readonly messageID: string; readonly partID: string }
@@ -750,6 +779,11 @@ export type ServerRequest =
750
779
  | { readonly op: "removeFront"; readonly sessionID: string }
751
780
  /** Forget the session's last run failure: the user dismissed it (feature "dismissError"). */
752
781
  | { readonly op: "dismissError"; readonly sessionID: string }
782
+ /**
783
+ * (feature "conciergeFront", voice front P2) An update for the voice front of `sessionID` (the Concierge session)
784
+ * about a session it relayed to: queued as its next wake. `finished`: a successful turn end.
785
+ */
786
+ | { readonly op: "frontUpdate"; readonly sessionID: string; readonly text: string; readonly finished?: boolean }
753
787
 
754
788
  /**
755
789
  * The feature a spoke must serve (spokeFeatures) for the hub to send `op`: the op itself for a core op (CORE_OPS,
@@ -765,6 +799,8 @@ export function featureForOp(op: ServerRequest["op"]): SpokeFeature {
765
799
  return "revert"
766
800
  case "removeFront":
767
801
  return "voiceFront"
802
+ case "frontUpdate":
803
+ return "conciergeFront"
768
804
  default:
769
805
  return op
770
806
  }
@@ -831,10 +867,23 @@ export interface FilePatch extends ChangedFile {
831
867
  }
832
868
 
833
869
  /** Right-rail data for a session; each part is absent when OpenCode could not provide it. */
870
+ /**
871
+ * The model a session runs on, as its spoke knows it (the console's model chip). Voice never changes it: the voice
872
+ * front is a separate session. `name` from the model catalog; `variant` only when explicit. `source`: `chosen`, the
873
+ * model selected for the session; `reply`, nothing selected, the model its latest reply ran on (the default resolved);
874
+ * `default`, nothing selected and no reply yet, the default model.
875
+ */
876
+ export interface SessionModel extends ModelSelection {
877
+ readonly name?: string
878
+ readonly source: "chosen" | "reply" | "default"
879
+ }
880
+
834
881
  export interface SessionSummary {
835
882
  readonly directory: string
836
883
  readonly branch?: string
837
884
  readonly context?: ContextUsage
885
+ /** The model the session runs on (absent when nothing is known, or from older spokes). */
886
+ readonly model?: SessionModel
838
887
  readonly mcp?: readonly McpStatus[]
839
888
  readonly changes?: SessionChanges
840
889
  }
@@ -896,6 +945,10 @@ export interface ResultFor {
896
945
  /** `dismissed`: the spoke had a failure of the session to forget. */
897
946
  dismissError: { readonly dismissed: boolean }
898
947
  removeFront: { readonly removed: boolean }
948
+ /** `queued`: false when the session has no linked front in this process. */
949
+ frontUpdate: { readonly queued: boolean }
950
+ /** The session the relay reached, its title, and how: steered into its running turn, or a new turn. */
951
+ relayToSession: { readonly sessionID: string; readonly title?: string; readonly mode: "steer" | "turn" }
899
952
  }
900
953
 
901
954
  // ---------- both directions ----------
@@ -1120,6 +1173,9 @@ function isSpokeRequest(v: unknown): v is SpokeRequest {
1120
1173
  if (v.op === "pairCode") return true
1121
1174
  if (v.op === "handoff") return nonEmpty(v.sessionID)
1122
1175
  if (v.op === "openLink") return (v.sessionID === undefined || nonEmpty(v.sessionID)) && (v.voice === undefined || typeof v.voice === "boolean")
1176
+ if (v.op === "relayToSession") {
1177
+ return nonEmpty(v.frontID) && v.frontID.length <= 300 && nonEmpty(v.session) && v.session.length <= 300 && str(v.text) && v.text.trim().length > 0 && v.text.length <= RELAY_TEXT_MAX && (v.previous === undefined || typeof v.previous === "boolean") && (v.confirm === undefined || typeof v.confirm === "boolean")
1178
+ }
1123
1179
  return false
1124
1180
  }
1125
1181
 
@@ -1272,6 +1328,8 @@ function isServerRequest(v: unknown): v is ServerRequest {
1272
1328
  case "dismissError":
1273
1329
  case "removeFront":
1274
1330
  return nonEmpty(v.sessionID)
1331
+ case "frontUpdate":
1332
+ return nonEmpty(v.sessionID) && str(v.text) && v.text.trim().length > 0 && v.text.length <= FRONT_UPDATE_MAX && (v.finished === undefined || typeof v.finished === "boolean")
1275
1333
  default:
1276
1334
  return false
1277
1335
  }