@huynle/hub 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +153 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +532 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +69 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent/webhooks.ts +5 -2
  60. package/src/central/agent-data.ts +43 -1
  61. package/src/central/backend/memory-counters.ts +2 -2
  62. package/src/central/backend/types.ts +5 -2
  63. package/src/central/backend/valkey-counters.ts +5 -4
  64. package/src/central/client-address.ts +16 -0
  65. package/src/central/concierge/board-render.ts +52 -1
  66. package/src/central/concierge/board-routes.ts +25 -2
  67. package/src/central/concierge/board.ts +11 -1
  68. package/src/central/concierge/context.ts +29 -8
  69. package/src/central/concierge/grant-request.ts +216 -0
  70. package/src/central/concierge/prompt.ts +49 -2
  71. package/src/central/concierge/session-routes.ts +3 -0
  72. package/src/central/config.ts +88 -5
  73. package/src/central/context-cards/budget.ts +121 -0
  74. package/src/central/context-cards/changes.ts +29 -0
  75. package/src/central/context-cards/config.ts +145 -0
  76. package/src/central/context-cards/engine.ts +379 -0
  77. package/src/central/context-cards/inputs.ts +111 -0
  78. package/src/central/context-cards/pickup.ts +134 -0
  79. package/src/central/context-cards/registry.ts +89 -0
  80. package/src/central/context-cards/routes.ts +37 -0
  81. package/src/central/context-cards/service.ts +367 -0
  82. package/src/central/context-cards/store.ts +67 -0
  83. package/src/central/context-cards/types.ts +88 -0
  84. package/src/central/fetcher.ts +10 -2
  85. package/src/central/front-relays.ts +423 -0
  86. package/src/central/http.ts +517 -56
  87. package/src/central/human-activity.ts +453 -0
  88. package/src/central/llm/chat.ts +133 -0
  89. package/src/central/push-target.ts +11 -2
  90. package/src/central/registry.ts +162 -9
  91. package/src/central/speech/service.ts +9 -0
  92. package/src/central/spoke-ws.ts +13 -0
  93. package/src/central/store.ts +9 -12
  94. package/src/config.ts +30 -13
  95. package/src/index.ts +45 -43
  96. package/src/rpc.ts +33 -4
  97. package/src/spoke/concierge-agent.ts +1 -1
  98. package/src/spoke/conversation.ts +6 -75
  99. package/src/spoke/deliver.ts +18 -8
  100. package/src/spoke/hub-rpc.ts +7 -2
  101. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  102. package/src/spoke/hub-tool-title.ts +89 -0
  103. package/src/spoke/hub-tool.ts +56 -18
  104. package/src/spoke/session-context.ts +10 -2
  105. package/src/spoke/spoke.ts +209 -238
  106. package/src/spoke/summary.ts +36 -1
  107. package/src/spoke/taskboard.ts +5 -6
  108. package/src/spoke/voice-front.ts +323 -30
  109. package/src/spoke/who-teams.ts +51 -2
  110. package/src/tui-messaging.ts +24 -10
  111. package/src/tui.ts +6 -6
  112. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
  113. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  114. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  115. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  116. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  117. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
  118. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  119. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  120. package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
  121. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  122. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  123. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  124. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  125. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  126. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
  127. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  128. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
  129. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  130. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
  131. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  132. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  133. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  134. package/web/dist/index.html +6 -6
  135. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  136. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  137. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  138. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  139. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  140. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  141. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  142. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  143. /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
@@ -0,0 +1,625 @@
1
+ // Conversation for apps (AF3, docs/plans/2026-11-08-app-front-contracts.md §1.2, §5; Hub decisions zn7oxf4g,
2
+ // nixct956 2): an app holds a conversation with one of ITS sessions through its server.
3
+ //
4
+ // GET /api/agent/sessions/:addr/stream conversation:read SSE of closed-allowlist frames (below)
5
+ // POST /api/agent/sessions/:addr/conversation conversation:write {on, front?} → {on, agent?, front?}
6
+ // POST /api/agent/sessions/:addr/utterance conversation:write {text 1–4000} → 202 {inboxID} (an action)
7
+ // POST /api/agent/sessions/:addr/interrupt conversation:write → 204
8
+ // Typed turns are the existing act prompt (acting-routes.ts, which admits conversation:write too); the log is widened
9
+ // to conversation:read (read-routes.ts). Both use appSessionCheck from here.
10
+ //
11
+ // "The app's sessions" for conversation (§1.2; user decision 2026-11-09): started by a launch whose `by` is the app's
12
+ // party, or covered by an active grant the app holds that allows `prompt` and whose scope covers the session's project
13
+ // (an answer-only or stop-only grant exposes nothing); subagents count with their root. Every other session, a deleted
14
+ // one and the user's Concierge sessions answer 404 not_found exactly like an unknown address.
15
+ // Writes by an app also need a grant allowing `prompt` that covers the session (403 forbidden for a session it only
16
+ // launched), the user's kill
17
+ // switch off (423 paused); utterances count against the grant's maxActionsPerHour (429, Grants.consume: increment
18
+ // first, then check, so parallel writes never pass it). Every app write is in the agent audit as app:<id>.
19
+ // An utterance carries the same hub-prompt provenance as a typed app turn (a deliver of kind "prompt" from
20
+ // app:<id> under the grant, steered into the running turn), never the console's plain steer (the user's own voice).
21
+ //
22
+ // The stream: `ready` first (once subscribed), then `delta` (the session's own text, never a subagent's, never
23
+ // reasoning; rootID dropped), `say`, `heard`, `status` (state + blocker count, never blocker text) and
24
+ // `conversation`; `reset {refresh}` when a resume point is not covered; `end {reason}` last. `id:` lines carry the
25
+ // hub's live event ids (the console stream's), `event: ping` heartbeats carry the latest cursor. Resume with
26
+ // Last-Event-ID (or ?last=): one pod replays from its live event log (it holds streamed text too); in a cluster the
27
+ // shared log never holds streamed text (cluster/live.ts), so a covered resume replays and then always sends
28
+ // reset {log}. A cursor the log does not cover: ready, then reset {log, status, conversation}.
29
+ // Re-checks every recheckMs and on revocation pulses (AppStreams.pulse): the credential (token_expired, revoked,
30
+ // a scope removed: revoked), the session (gone: deleted, AppStreams.deleted on this pod at once, the directory's
31
+ // deletedAt on any pod) and ownership (grant: also a grant narrowed to drop prompt). Caps per hub process: 4 streams per app
32
+ // credential, 20 per user (AppStreams.reserve: a synchronous check-and-count, so parallel connects never pass
33
+ // them). A reader leaving more than bufferMaxBytes unsent gets end {slow}; the hub's fan-out never waits for it.
34
+ // Streamed text is model output and untrusted: sent as it is (JSON-encoded), never marked up.
35
+ import { APP_STREAM_LIMITS, type AppStreamEndReason, type SessionState } from "../../protocol"
36
+ import type { Registry } from "../registry"
37
+ import { type GrantVerb, inProjects } from "./policy"
38
+ import { type AgentActor, type AppActor, readCredentials, type VerifyResult } from "./actor"
39
+ import type { ActingSwitch } from "./acting-switch"
40
+ import { auditOf } from "./acting"
41
+ import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
42
+ import type { Directory } from "./directory"
43
+ import { partyFromActor } from "./envelope"
44
+ import { AgentError } from "./errors"
45
+ import type { Grant, Grants } from "./grants"
46
+ import { AGENT_JSON_BODY_MAX, boundedText } from "./body"
47
+ import type { AgentContext, AgentRoute } from "./routes"
48
+
49
+ // A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
50
+ const PREFIX = "/api/agent/sessions/:addr"
51
+ const NO_STORE = { "cache-control": "no-store" }
52
+ const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
53
+ const RETRY = `retry: ${APP_STREAM_LIMITS.retryMs}\n\n`
54
+ /** Longest utterance (characters), the console's and the apps'. */
55
+ export const MAX_UTTERANCE_CHARS = 4000
56
+ const STATES: ReadonlySet<string> = new Set(["idle", "working", "blocked", "error"])
57
+
58
+ // ---------- the event source (http.ts: the console stream's listeners or the cluster live fan-out) ----------
59
+
60
+ export interface SourceEvent {
61
+ /** The live event id (absent for streamed text in a cluster). */
62
+ readonly id?: string
63
+ readonly message: { readonly type: string; readonly [key: string]: unknown }
64
+ }
65
+
66
+ export interface StreamSource {
67
+ /** Cluster mode: the shared log holds no streamed text, so a resume always ends with reset {log}. */
68
+ readonly clustered: boolean
69
+ /** The user's live events from now on. */
70
+ subscribe(userID: string, handler: (event: SourceEvent) => void): { readonly ready: Promise<void>; close(): void }
71
+ /** The user's events after `lastID`, oldest first; undefined when the log does not cover the gap. */
72
+ since(userID: string, lastID: string): Promise<readonly SourceEvent[] | undefined>
73
+ /** The id of the user's latest event. */
74
+ latest(userID: string): Promise<string>
75
+ }
76
+
77
+ // ---------- caps and revocation pulses ----------
78
+
79
+ /** Deleted session IDs AppStreams remembers. */
80
+ const DELETED_KEPT = 5000
81
+
82
+ /** Open app streams of this hub process: the caps (per credential, per user) and the revocation pulses. */
83
+ export class AppStreams {
84
+ private readonly perCredential = new Map<string, number>()
85
+ private readonly perUser = new Map<string, number>()
86
+ private readonly checks = new Map<string, Set<() => void>>()
87
+ /** Sessions deleted while this process ran (bounded): gone at once, before any directory write lands. */
88
+ private readonly removed = new Set<string>()
89
+
90
+ constructor(private readonly limits: { readonly perApp: number; readonly perUser: number } = { perApp: APP_STREAM_LIMITS.streamsPerApp, perUser: APP_STREAM_LIMITS.streamsPerUser }) {}
91
+
92
+ /**
93
+ * Takes a stream slot of the credential (and its user), or undefined at a cap. Synchronous check-and-count, so
94
+ * parallel connects can never pass a cap. The release is idempotent.
95
+ */
96
+ reserve(userID: string, credential: string): (() => void) | undefined {
97
+ const key = `${userID}\n${credential}`
98
+ const mine = this.perCredential.get(key) ?? 0
99
+ const all = this.perUser.get(userID) ?? 0
100
+ if (mine >= this.limits.perApp || all >= this.limits.perUser) return undefined
101
+ this.perCredential.set(key, mine + 1)
102
+ this.perUser.set(userID, all + 1)
103
+ let released = false
104
+ return () => {
105
+ if (released) return
106
+ released = true
107
+ const left = (this.perCredential.get(key) ?? 1) - 1
108
+ if (left > 0) this.perCredential.set(key, left)
109
+ else this.perCredential.delete(key)
110
+ const userLeft = (this.perUser.get(userID) ?? 1) - 1
111
+ if (userLeft > 0) this.perUser.set(userID, userLeft)
112
+ else this.perUser.delete(userID)
113
+ }
114
+ }
115
+
116
+ /** Runs `check` on every pulse for the user; returns the unsubscribe. */
117
+ watch(userID: string, check: () => void): () => void {
118
+ let set = this.checks.get(userID)
119
+ if (!set) this.checks.set(userID, (set = new Set()))
120
+ set.add(check)
121
+ return () => {
122
+ set.delete(check)
123
+ if (!set.size) this.checks.delete(userID)
124
+ }
125
+ }
126
+
127
+ /** Something that may revoke a stream changed (a token, connection, grant or user): re-check the user's streams now. */
128
+ pulse(userID: string): void {
129
+ for (const check of [...(this.checks.get(userID) ?? [])]) check()
130
+ }
131
+
132
+ /** A session of the user was deleted: its streams end (gone) now. */
133
+ deleted(userID: string, sessionID: string): void {
134
+ this.removed.add(sessionID)
135
+ if (this.removed.size > DELETED_KEPT) this.removed.delete(this.removed.values().next().value!)
136
+ this.pulse(userID)
137
+ }
138
+
139
+ /** Whether this process saw the session deleted. */
140
+ wasDeleted(sessionID: string): boolean {
141
+ return this.removed.has(sessionID)
142
+ }
143
+
144
+ /** Open streams of a user (tests, diagnostics). */
145
+ open(userID: string): number {
146
+ return this.perUser.get(userID) ?? 0
147
+ }
148
+ }
149
+
150
+ // ---------- frames ----------
151
+
152
+ type Frame = Record<string, unknown> & { readonly type: string }
153
+
154
+ /** A live message as an app frame for `sessionID` (whose root is `rootID`), or undefined when it is not one. */
155
+ export function appFrame(message: SourceEvent["message"], sessionID: string, rootID: string): Frame | undefined {
156
+ const m = message as Record<string, unknown>
157
+ switch (m.type) {
158
+ case "delta":
159
+ if (m.sessionID !== sessionID || typeof m.text !== "string") return undefined
160
+ return { type: "delta", sessionID, ...(typeof m.messageID === "string" ? { messageID: m.messageID } : {}), ...(isOrdinal(m.ordinal) ? { ordinal: m.ordinal } : {}), text: m.text }
161
+ case "say":
162
+ if (m.sessionID !== sessionID || typeof m.messageID !== "string" || !isOrdinal(m.ordinal) || typeof m.text !== "string") return undefined
163
+ return { type: "say", sessionID, messageID: m.messageID, ordinal: m.ordinal, ...(isOrdinal(m.seq) ? { seq: m.seq } : {}), text: m.text }
164
+ case "heard":
165
+ if (m.sessionID !== sessionID || typeof m.inboxID !== "string") return undefined
166
+ return { type: "heard", sessionID, inboxID: m.inboxID }
167
+ case "conversation":
168
+ if (m.sessionID !== sessionID || typeof m.on !== "boolean") return undefined
169
+ return { type: "conversation", sessionID, on: m.on, ...(typeof m.front === "boolean" ? { front: m.front } : {}), ...(m.reason === "front_failed" ? { reason: "front_failed" } : {}) }
170
+ case "status": {
171
+ if (m.sessionID !== rootID || !Array.isArray(m.sessions)) return undefined
172
+ const entry = (m.sessions as Array<Record<string, unknown>>).find((s) => s?.id === sessionID)
173
+ if (!entry || typeof entry.state !== "string" || !STATES.has(entry.state)) return undefined
174
+ const blockers = Array.isArray(entry.blockers) ? entry.blockers.length : 0
175
+ return { type: "status", sessionID, state: entry.state, blockers, ...(typeof entry.since === "number" && Number.isFinite(entry.since) ? { since: entry.since } : {}) }
176
+ }
177
+ default:
178
+ return undefined
179
+ }
180
+ }
181
+
182
+ const isOrdinal = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0
183
+
184
+ // ---------- the stream ----------
185
+
186
+ export interface AppStreamOptions {
187
+ readonly userID: string
188
+ readonly sessionID: string
189
+ readonly rootID: string
190
+ readonly source: StreamSource
191
+ readonly signal: AbortSignal
192
+ /** Last-Event-ID or ?last=. */
193
+ readonly lastID?: string
194
+ /** The session now, for `ready`. */
195
+ ready(): { readonly status: SessionState; readonly conversation: { readonly on: boolean; readonly front?: boolean } }
196
+ /** The re-check: a reason ends the stream; undefined keeps it. A throw keeps it (the next one tries again). */
197
+ check(): Promise<AppStreamEndReason | undefined>
198
+ /** Revocation pulses: `run` re-checks at once; returns the unsubscribe. */
199
+ subscribeChecks?(run: () => void): () => void
200
+ /** Once, when the stream ends for any reason (the cap slot). */
201
+ onClose?(): void
202
+ readonly draining?: AbortSignal
203
+ readonly now?: () => number
204
+ readonly heartbeatMs?: number
205
+ readonly recheckMs?: number
206
+ readonly bufferMaxBytes?: number
207
+ }
208
+
209
+ /** The SSE response of one app stream (see the header). */
210
+ export function appStream(options: AppStreamOptions): Response {
211
+ const { userID, sessionID, rootID, source } = options
212
+ if (options.draining?.aborted) {
213
+ options.onClose?.()
214
+ return new Response(RETRY, { headers: SSE_HEADERS })
215
+ }
216
+ const now = options.now ?? Date.now
217
+ const max = options.bufferMaxBytes ?? APP_STREAM_LIMITS.bufferMaxBytes
218
+ const encoder = new TextEncoder()
219
+ let controller!: ReadableStreamDefaultController<Uint8Array>
220
+ let open = true
221
+ let cursor: string | undefined
222
+ let lastStatus: string | undefined
223
+ let subscription: { close(): void } | undefined
224
+ const timers: ReturnType<typeof setInterval>[] = []
225
+ const unsubscribe: (() => void)[] = []
226
+
227
+ const finish = () => {
228
+ if (!open) return
229
+ open = false
230
+ for (const timer of timers) clearInterval(timer)
231
+ subscription?.close()
232
+ for (const off of unsubscribe) off()
233
+ options.onClose?.()
234
+ }
235
+ const close = () => {
236
+ finish()
237
+ try {
238
+ controller.close()
239
+ } catch {}
240
+ }
241
+ const enqueue = (chunk: string): boolean => {
242
+ try {
243
+ controller.enqueue(encoder.encode(chunk))
244
+ return true
245
+ } catch {
246
+ finish()
247
+ return false
248
+ }
249
+ }
250
+ const end = (reason: AppStreamEndReason) => {
251
+ if (!open) return
252
+ enqueue(`data: ${JSON.stringify({ type: "end", at: now(), reason })}\n\n`)
253
+ close()
254
+ }
255
+ const write = (chunk: string) => {
256
+ if (!open || !enqueue(chunk)) return
257
+ // More than the limit waits unsent: the reader is too slow; never queue without bound.
258
+ if ((controller.desiredSize ?? 0) < 0) end("slow")
259
+ }
260
+ const frame = (data: Frame, id?: string) => {
261
+ if (data.type === "status") {
262
+ const key = JSON.stringify(data)
263
+ if (key === lastStatus) return
264
+ lastStatus = key
265
+ }
266
+ write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(data)}\n\n`)
267
+ }
268
+ const replayed = new Set<string>()
269
+ const forward = (event: SourceEvent) => {
270
+ if (!open) return
271
+ if (event.id !== undefined) {
272
+ if (replayed.has(event.id)) return
273
+ cursor = event.id
274
+ }
275
+ const data = appFrame(event.message, sessionID, rootID)
276
+ if (data) frame(data, event.id)
277
+ }
278
+
279
+ let checking = false
280
+ let again = false
281
+ const runCheck = async () => {
282
+ if (!open) return
283
+ if (checking) {
284
+ again = true
285
+ return
286
+ }
287
+ checking = true
288
+ try {
289
+ do {
290
+ again = false
291
+ const reason = await options.check().catch((cause: unknown) => {
292
+ console.error(`[hub] app stream re-check failed: ${(cause as Error).message}`)
293
+ return undefined
294
+ })
295
+ if (reason) return end(reason)
296
+ } while (again && open)
297
+ } finally {
298
+ checking = false
299
+ }
300
+ }
301
+
302
+ const run = async () => {
303
+ let pending: SourceEvent[] | undefined = []
304
+ const subscribed = source.subscribe(userID, (event) => (pending ? pending.push(event) : forward(event)))
305
+ subscription = subscribed
306
+ if (!open) subscribed.close()
307
+ try {
308
+ await subscribed.ready
309
+ const missed = options.lastID ? await source.since(userID, options.lastID) : undefined
310
+ const latest = await source.latest(userID)
311
+ if (!open) return
312
+ const covered = options.lastID !== undefined && missed !== undefined
313
+ cursor = covered ? options.lastID : latest
314
+ const state = options.ready()
315
+ frame({ type: "ready", at: now(), sessionID, status: state.status, conversation: { on: state.conversation.on, ...(state.conversation.front !== undefined ? { front: state.conversation.front } : {}) } }, cursor)
316
+ if (covered) {
317
+ for (const event of missed!) {
318
+ if (event.id !== undefined) {
319
+ replayed.add(event.id)
320
+ cursor = event.id
321
+ }
322
+ const data = appFrame(event.message, sessionID, rootID)
323
+ if (data) frame(data, event.id)
324
+ }
325
+ if (source.clustered) frame({ type: "reset", at: now(), refresh: ["log"] })
326
+ } else if (options.lastID !== undefined) frame({ type: "reset", at: now(), refresh: ["log", "status", "conversation"] })
327
+ } catch (cause) {
328
+ console.error(`[hub] app stream did not start: ${(cause as Error).message}`)
329
+ write(RETRY)
330
+ return close()
331
+ }
332
+ const waiting = pending
333
+ pending = undefined
334
+ for (const event of waiting) forward(event)
335
+ }
336
+
337
+ const stream = new ReadableStream<Uint8Array>(
338
+ {
339
+ start(c) {
340
+ controller = c
341
+ options.signal.addEventListener("abort", () => close(), { once: true })
342
+ if (options.draining) {
343
+ const draining = options.draining
344
+ const onDrain = () => {
345
+ if (!open) return
346
+ enqueue(RETRY)
347
+ close()
348
+ }
349
+ draining.addEventListener("abort", onDrain, { once: true })
350
+ unsubscribe.push(() => draining.removeEventListener("abort", onDrain))
351
+ }
352
+ timers.push(setInterval(() => write(`event: ping\n${cursor ? `id: ${cursor}\n` : ""}data: 0\n\n`), options.heartbeatMs ?? APP_STREAM_LIMITS.heartbeatMs))
353
+ timers.push(setInterval(() => void runCheck(), options.recheckMs ?? APP_STREAM_LIMITS.recheckMs))
354
+ if (options.subscribeChecks) unsubscribe.push(options.subscribeChecks(() => void runCheck()))
355
+ void run()
356
+ },
357
+ cancel() {
358
+ finish()
359
+ },
360
+ },
361
+ { highWaterMark: max, size: (chunk) => chunk?.byteLength ?? 0 },
362
+ )
363
+ return new Response(stream, { headers: SSE_HEADERS })
364
+ }
365
+
366
+ // ---------- "the app's sessions" (§1.2) ----------
367
+
368
+ export interface AppSessionDeps {
369
+ readonly registry: Pick<Registry, "rootOf" | "session" | "sessionTenant">
370
+ readonly directory: Pick<Directory, "resolveOne"> & Partial<Pick<Directory, "deleted">>
371
+ readonly grants: Pick<Grants, "list">
372
+ /** (AF3) Sessions this process saw deleted (AppStreams.wasDeleted). */
373
+ readonly deletedHere?: (sessionID: string) => boolean
374
+ /** Launches: `by` is the launcher's party (`app:<id>`). */
375
+ readonly launches?: { list(userID: string): Promise<readonly { readonly by?: string; readonly sessionID?: string }[]> }
376
+ /** (CON-04) Sessions only the user acts on (the Concierge's): never an app's. */
377
+ readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
378
+ }
379
+
380
+ export interface AppTarget {
381
+ readonly sessionID: string
382
+ readonly rootID: string
383
+ /** The session's directory when a process reports it (or last reported it). */
384
+ readonly directory?: string
385
+ }
386
+
387
+ const notFound = (addr: string) => new AgentError("not_found", `No session ${addr}`)
388
+
389
+ /** The addressed session of the user (not_found otherwise; an ambiguity reads as not_found too: no IDs leak). */
390
+ export function locateSession(deps: Pick<AppSessionDeps, "registry" | "directory" | "deletedHere">, userID: string, addr: string): AppTarget {
391
+ let sessionID: string
392
+ try {
393
+ sessionID = deps.directory.resolveOne(userID, addr).sessionID
394
+ } catch (cause) {
395
+ if (cause instanceof AgentError && (cause.code === "not_found" || cause.code === "ambiguous")) throw notFound(addr)
396
+ throw cause
397
+ }
398
+ const tenant = deps.registry.sessionTenant(sessionID)
399
+ if (tenant !== undefined && tenant !== userID) throw notFound(addr)
400
+ const rootID = deps.registry.rootOf(sessionID) ?? sessionID
401
+ // Deleted (it, or its root): gone, like an unknown session.
402
+ for (const id of new Set([sessionID, rootID])) if (deps.deletedHere?.(id) || deps.directory.deleted?.(userID, id)) throw notFound(addr)
403
+ const directory = deps.registry.session(rootID)?.directory ?? deps.registry.session(sessionID)?.directory
404
+ return { sessionID, rootID, ...(directory ? { directory } : {}) }
405
+ }
406
+
407
+ /**
408
+ * Whether the target is the app's: launched by it, or covered by an active grant it holds that allows one of `verbs`
409
+ * (any verb when none are given: contract §1.2's base rule, e.g. for AF4). Conversation (stream, log, writes) passes
410
+ * CONVERSATION_VERBS (user decision 2026-11-09: only a prompt grant exposes a conversation).
411
+ */
412
+ export async function isAppSession(deps: AppSessionDeps, actor: AppActor, userID: string, target: AppTarget, verbs?: readonly GrantVerb[]): Promise<boolean> {
413
+ if (deps.protects && ((await deps.protects(userID, target.rootID)) || (await deps.protects(userID, target.sessionID)))) return false
414
+ const party = `app:${actor.tokenID}`
415
+ if (target.directory) {
416
+ for (const grant of await deps.grants.list(userID)) {
417
+ if (grant.state !== "active" || grant.holder !== party || !inProjects(target.directory, grant.scope.projects)) continue
418
+ if (!verbs || verbs.some((verb) => grant.allow.includes(verb))) return true
419
+ }
420
+ }
421
+ if (deps.launches) {
422
+ for (const launch of await deps.launches.list(userID)) {
423
+ if (launch.by === party && launch.sessionID !== undefined && (launch.sessionID === target.rootID || launch.sessionID === target.sessionID)) return true
424
+ }
425
+ }
426
+ return false
427
+ }
428
+
429
+ /** The grant verbs that make a session an app's for conversation (stream, log, writes). */
430
+ export const CONVERSATION_VERBS: readonly GrantVerb[] = ["prompt"]
431
+
432
+ /** The addressed session when it is the app's for conversation; not_found otherwise. Users: their own sessions. */
433
+ export async function appSessionCheck(deps: AppSessionDeps, actor: AgentActor, userID: string, addr: string): Promise<AppTarget> {
434
+ const target = locateSession(deps, userID, addr)
435
+ if (actor.kind === "app" && !(await isAppSession(deps, actor, userID, target, CONVERSATION_VERBS))) throw notFound(addr)
436
+ return target
437
+ }
438
+
439
+ // ---------- the routes ----------
440
+
441
+ /** What the conversation routes run on the hub (http.ts: the console's conversation machinery). */
442
+ export interface ConversationPorts {
443
+ /** Conversation mode on/off (front: the voice front), as POST /api/sessions/:id/conversation does. */
444
+ conversation(sessionID: string, on: boolean, front: boolean, options: { readonly taps: boolean }): Promise<{ readonly on: boolean; readonly agent?: string; readonly front?: boolean }>
445
+ /**
446
+ * An utterance to the session (its front while one is active), tracked for `heard`. With `prompt`: delivered as an
447
+ * agent prompt with that provenance (an app's); without: the console's steer (the user's own).
448
+ */
449
+ utterance(sessionID: string, text: string, prompt?: { readonly from: string; readonly to: string; readonly grant: string }): Promise<string>
450
+ /** Barge-in: stops the front's turn (or the session's). */
451
+ interrupt(sessionID: string): Promise<void>
452
+ /** Conversation mode now (front: present once a front was asked for). */
453
+ conversationOf(sessionID: string): { readonly on: boolean; readonly front?: boolean }
454
+ /** The session's state now. */
455
+ stateOf(sessionID: string): SessionState
456
+ }
457
+
458
+ export interface ConversationRouteDeps extends AppSessionDeps {
459
+ readonly ports: ConversationPorts
460
+ readonly source: StreamSource
461
+ readonly streams: AppStreams
462
+ readonly grants: Pick<Grants, "list" | "usable" | "consume">
463
+ readonly acting: Pick<ActingSwitch, "ensureActing">
464
+ readonly audit: Pick<AgentAudit, "record">
465
+ readonly store: { user(id: string): { readonly disabled?: boolean } | undefined }
466
+ /** Verifies a bearer again (the re-check): the hub's bearer verifiers in order. */
467
+ verify(token: string): Promise<VerifyResult>
468
+ readonly cookieName: string
469
+ readonly draining?: AbortSignal
470
+ readonly now?: () => number
471
+ readonly heartbeatMs?: number
472
+ readonly recheckMs?: number
473
+ readonly bufferMaxBytes?: number
474
+ }
475
+
476
+ type Body = Record<string, unknown>
477
+
478
+ async function bodyOf(request: Request, optional = false): Promise<Body> {
479
+ const raw = await boundedText(request, AGENT_JSON_BODY_MAX)
480
+ if (!raw.trim() && optional) return {}
481
+ const value: unknown = JSON.parse(raw)
482
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new AgentError("invalid", "Body must be a JSON object")
483
+ return value as Body
484
+ }
485
+
486
+ /** A port's failure without its text (never pass upstream or internal error text to apps). */
487
+ function portFailure(cause: unknown): unknown {
488
+ if (cause instanceof AgentError) return cause
489
+ const status = (cause as { status?: unknown }).status
490
+ const message = cause instanceof Error ? cause.message : ""
491
+ if (status === 409 || /disconnected|offline|no process|timed out/i.test(message)) return new AgentError("offline", "The session's machine is offline or did not answer")
492
+ console.error(`[hub] conversation write failed: ${message}`)
493
+ return new AgentError("internal", "Internal error")
494
+ }
495
+
496
+ export function conversationRoutes(deps: ConversationRouteDeps): AgentRoute[] {
497
+ const READ = { scope: "conversation:read" } as const
498
+ const WRITE = { scope: "conversation:write" } as const
499
+
500
+ /** The write check order for apps: the app's session (404) → root only → kill switch → prompt grant → hourly cap → kill switch. */
501
+ async function write<T>(context: AgentContext, verb: "conversation" | "utterance" | "interrupt", counts: boolean, run: (target: AppTarget, grant?: Grant) => Promise<T>): Promise<T> {
502
+ const { actor, user, params } = context
503
+ const target = await appSessionCheck(deps, actor, user.id, params.addr!)
504
+ if (target.rootID !== target.sessionID) throw new AgentError("invalid", "A conversation is with a root session, not a subagent session")
505
+ if (!deps.registry.session(target.sessionID)) throw new AgentError("offline", "No process reports this session now")
506
+ if (actor.kind !== "app") return run(target).catch((cause: unknown) => Promise.reject(portFailure(cause)))
507
+ let grant: Grant | undefined
508
+ const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
509
+ deps.audit.record(user.id, { actor: partyFromActor(actor), verb, target: { sessionID: target.sessionID }, ...(grant ? { grant: grant.id } : {}), decision, ...(reason ? { reason } : {}), outcome })
510
+ try {
511
+ deps.acting.ensureActing(user.id)
512
+ const usable = target.directory ? await deps.grants.usable(actor, "prompt", { directory: target.directory }) : []
513
+ if (!usable.length) throw new AgentError("forbidden", "No grant lets this app prompt in this session", { hint: "The user grants prompt on the session's project" })
514
+ grant = usable[0]!
515
+ if (counts) await deps.grants.consume(grant)
516
+ deps.acting.ensureActing(user.id)
517
+ const result = await run(target, grant).catch((cause: unknown) => Promise.reject(portFailure(cause)))
518
+ await record("allow", "ok")
519
+ return result
520
+ } catch (cause) {
521
+ const [decision, outcome] = auditOf(cause)
522
+ await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
523
+ throw cause
524
+ }
525
+ }
526
+
527
+ return [
528
+ {
529
+ method: "GET",
530
+ path: `${PREFIX}/stream`,
531
+ allow: READ,
532
+ async handle(context) {
533
+ const { actor, user, request, url, params } = context
534
+ const target = await appSessionCheck(deps, actor, user.id, params.addr!)
535
+ // Apps by their credential; the user's own devices (they reach every route) by device.
536
+ const credential = actor.kind === "app" ? `app:${actor.tokenID}` : actor.kind === "user" ? `device:${actor.deviceID}` : `session:${actor.sessionID}`
537
+ const release = deps.streams.reserve(user.id, credential)
538
+ if (!release) {
539
+ throw new AgentError("rate_limited", `At most ${APP_STREAM_LIMITS.streamsPerApp} streams per app and ${APP_STREAM_LIMITS.streamsPerUser} per user`, { hint: "Close a stream first", extra: { retryAfter: 5 } })
540
+ }
541
+ const bearer = readCredentials(request, deps.cookieName).bearer
542
+ let directory = target.directory
543
+ const check = async (): Promise<AppStreamEndReason | undefined> => {
544
+ const owner = deps.store.user(user.id)
545
+ if (!owner || owner.disabled) return "revoked"
546
+ let current: AgentActor = actor
547
+ if (actor.kind === "app") {
548
+ const verified = bearer ? await deps.verify(bearer) : undefined
549
+ if (verified === "expired") return "token_expired"
550
+ if (!verified || verified.kind !== "app" || verified.tokenID !== actor.tokenID || verified.userID !== user.id) return "revoked"
551
+ if (!verified.scopes.includes("conversation:read")) return "revoked"
552
+ current = verified
553
+ }
554
+ let now: AppTarget
555
+ try {
556
+ now = locateSession(deps, user.id, target.sessionID)
557
+ } catch (cause) {
558
+ if (cause instanceof AgentError && cause.code === "not_found") return "gone"
559
+ throw cause
560
+ }
561
+ // A session briefly not reported keeps the directory it had (its grant still covers it).
562
+ directory = now.directory ?? directory
563
+ if (current.kind === "app" && !(await isAppSession(deps, current, user.id, { ...now, ...(directory ? { directory } : {}) }, CONVERSATION_VERBS))) return "grant"
564
+ return undefined
565
+ }
566
+ const lastID = request.headers.get("last-event-id") ?? url.searchParams.get("last") ?? undefined
567
+ return appStream({
568
+ userID: user.id,
569
+ sessionID: target.sessionID,
570
+ rootID: target.rootID,
571
+ source: deps.source,
572
+ signal: request.signal,
573
+ ...(lastID ? { lastID } : {}),
574
+ ready: () => ({ status: deps.ports.stateOf(target.sessionID), conversation: deps.ports.conversationOf(target.sessionID) }),
575
+ check,
576
+ subscribeChecks: (run) => deps.streams.watch(user.id, run),
577
+ onClose: release,
578
+ ...(deps.draining ? { draining: deps.draining } : {}),
579
+ ...(deps.now ? { now: deps.now } : {}),
580
+ ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
581
+ ...(deps.recheckMs !== undefined ? { recheckMs: deps.recheckMs } : {}),
582
+ ...(deps.bufferMaxBytes !== undefined ? { bufferMaxBytes: deps.bufferMaxBytes } : {}),
583
+ })
584
+ },
585
+ },
586
+ {
587
+ method: "POST",
588
+ path: `${PREFIX}/conversation`,
589
+ allow: WRITE,
590
+ async handle(context) {
591
+ const body = await bodyOf(context.request)
592
+ const { on, front } = body
593
+ if (typeof on !== "boolean" || (front !== undefined && typeof front !== "boolean")) throw new AgentError("invalid", "Expected {on: boolean, front?: boolean}")
594
+ const result = await write(context, "conversation", false, (target) => deps.ports.conversation(target.sessionID, on, front === true, { taps: context.actor.kind === "user" }))
595
+ return Response.json({ on: result.on, ...(result.agent !== undefined ? { agent: result.agent } : {}), ...(result.front !== undefined ? { front: result.front } : {}) }, { headers: NO_STORE })
596
+ },
597
+ },
598
+ {
599
+ method: "POST",
600
+ path: `${PREFIX}/utterance`,
601
+ allow: WRITE,
602
+ async handle(context) {
603
+ const body = await bodyOf(context.request)
604
+ const text = typeof body.text === "string" ? body.text.trim() : ""
605
+ if (!text) throw new AgentError("invalid", "text must be a non-empty string", { extra: { limit: MAX_UTTERANCE_CHARS } })
606
+ if (text.length > MAX_UTTERANCE_CHARS) throw new AgentError("invalid", `An utterance is at most ${MAX_UTTERANCE_CHARS} characters`, { extra: { limit: MAX_UTTERANCE_CHARS } })
607
+ const { actor, params } = context
608
+ const inboxID = await write(context, "utterance", true, (target, grant) =>
609
+ deps.ports.utterance(target.sessionID, text, actor.kind === "app" && grant ? { from: `app:${actor.tokenID}`, to: params.addr!, grant: grant.id } : undefined),
610
+ )
611
+ return Response.json({ inboxID }, { status: 202, headers: NO_STORE })
612
+ },
613
+ },
614
+ {
615
+ method: "POST",
616
+ path: `${PREFIX}/interrupt`,
617
+ allow: WRITE,
618
+ async handle(context) {
619
+ await bodyOf(context.request, true)
620
+ await write(context, "interrupt", false, (target) => deps.ports.interrupt(target.sessionID))
621
+ return new Response(null, { status: 204, headers: NO_STORE })
622
+ },
623
+ },
624
+ ]
625
+ }