@huynle/hub 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +309 -0
- package/Dockerfile +5 -1
- package/README.md +153 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +69 -11
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent/webhooks.ts +5 -2
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +88 -5
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +517 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/push-target.ts +11 -2
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +209 -238
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /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",
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
663
|
-
* "voiceFront", sent only to spokes that list it):
|
|
664
|
-
*
|
|
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
|
}
|