@huynle/hub 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +320 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +154 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +534 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +81 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +547 -58
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +227 -237
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-CsPjam8D.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-B9weO8L6.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-DzKhMs3l.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-CuRIffka.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-rv5DGtrm.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DUuQ9jmd.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-D_17MkvN.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-DlrNOJ7X.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-bX8B6NLo.js +33 -0
  123. package/web/dist/assets/index-rW6LKQj1.css +1 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-BGLA-peO.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-DyANQ1WS.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-vYYKFAJq.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-B2Bk__FK.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -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
  }