@huynle/hub 0.10.1 → 0.11.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 (76) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +17 -3
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +622 -394
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/grants-mcp.ts +65 -0
  8. package/packages/cli/src/mcp-operations.ts +49 -0
  9. package/packages/cli/src/mcp.ts +6 -0
  10. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  11. package/packages/cli/src/teams-mcp.ts +5 -3
  12. package/packages/codex/package.json +1 -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/src/teams.ts +4 -0
  17. package/packages/protocol/src/voice-recording.ts +39 -2
  18. package/packages/runtime-openshell/image/entry.sh +3 -1
  19. package/packages/runtime-openshell/image/start.sh +3 -1
  20. package/packages/runtime-openshell/package.json +1 -1
  21. package/packages/runtime-sdk/package.json +1 -1
  22. package/packages/sdk/package.json +1 -1
  23. package/packages/sdk/src/client.ts +13 -0
  24. package/packages/sdk/src/teams.ts +2 -0
  25. package/packages/spoke-sdk/package.json +1 -1
  26. package/src/central/agent/decisions.ts +7 -1
  27. package/src/central/agent/delivery.ts +39 -9
  28. package/src/central/agent/grant-routes.ts +14 -1
  29. package/src/central/agent/grants.ts +23 -0
  30. package/src/central/agent/launch-engine.ts +3 -3
  31. package/src/central/agent/message-limits.ts +3 -0
  32. package/src/central/agent/routes.ts +11 -0
  33. package/src/central/agent/subscriptions.ts +3 -1
  34. package/src/central/config.ts +9 -2
  35. package/src/central/http.ts +102 -20
  36. package/src/central/recording/export.ts +57 -3
  37. package/src/central/recording/index.ts +234 -16
  38. package/src/central/recording/ingest.ts +11 -2
  39. package/src/central/recording/routes.ts +61 -7
  40. package/src/central/recording/schema.ts +66 -2
  41. package/src/central/recording/settings.ts +19 -5
  42. package/src/central/recording/store.ts +19 -0
  43. package/src/central/recording/taps.ts +45 -1
  44. package/src/central/speech/openai.ts +4 -4
  45. package/src/central/speech/openrouter.ts +4 -4
  46. package/src/central/speech/provider.ts +32 -1
  47. package/src/central/speech/service.ts +6 -5
  48. package/src/central/teams/hop-stalls.ts +72 -0
  49. package/src/central/teams/policy.ts +10 -0
  50. package/src/central/teams/routes.ts +2 -0
  51. package/src/central/teams/seats.ts +3 -3
  52. package/src/central/teams/staffing.ts +5 -0
  53. package/src/central/teams/store.ts +5 -2
  54. package/src/central/teams/validation.ts +8 -5
  55. package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-C5MMqXLN.js} +1 -1
  56. package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-CXSCmhyJ.js} +1 -1
  57. package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
  58. package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-BGmyRb-R.js} +1 -1
  59. package/web/dist/assets/{Library-rv5DGtrm.js → Library-CFmMbXEa.js} +2 -2
  60. package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-7jpOc2HM.js} +1 -1
  61. package/web/dist/assets/{TeamsPage-D_17MkvN.js → TeamsPage-C1NGrUR-.js} +1 -1
  62. package/web/dist/assets/{Watchtower-DlrNOJ7X.js → Watchtower-DnvlJaU0.js} +2 -2
  63. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  64. package/web/dist/assets/index-Da8SKXMw.js +33 -0
  65. package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
  66. package/web/dist/assets/live-BmqLASSo.js +5 -0
  67. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  68. package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-B2Nh39UN.js} +1 -1
  69. package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-CTYbljHH.js} +1 -1
  70. package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-CkhEclGo.js} +1 -1
  71. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  72. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  73. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  74. package/web/dist/index.html +5 -5
  75. package/web/dist/assets/index-bX8B6NLo.js +0 -33
  76. package/web/dist/assets/live-BGLA-peO.js +0 -5
@@ -58,7 +58,7 @@ import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, rende
58
58
  import { noticeOnly } from "../../../packages/sdk/src/envelope.ts"
59
59
  import { AgentError } from "./errors"
60
60
  import { type ExpiredReason, parseNotAfter } from "./not-after"
61
- import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits, overHopCap } from "./message-limits"
61
+ import { HOP_CAP, HOP_CAP_CEILING, HopTracker, MessageLimiter, type MessageLimits } from "./message-limits"
62
62
  import { parseAddress } from "./names"
63
63
  import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
64
64
 
@@ -126,10 +126,16 @@ export interface DeliveryDeps {
126
126
  /** Labels of a session sender (default: its @name from the directory). */
127
127
  readonly describe?: (userID: string, sessionID: string) => PartyInfo
128
128
  readonly alert?: (userID: string, alert: DeliveryAlert) => void
129
+ /** The first over-cap message between two seats of a team, once per persistent chain. */
130
+ readonly teamHopCap?: (userID: string, input: { readonly teamID: string; readonly sender: string; readonly recipient: string; readonly limit: number }) => Promise<void>
131
+ /** A quiet, hub-authored note to the team's entry seat; never counts a message hop. */
132
+ readonly teamHopNotice?: (userID: string, teamID: string) => Promise<string | undefined>
129
133
  /** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
130
134
  readonly expired?: (userID: string, message: Message) => void
131
135
  /** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
132
136
  readonly limits?: Partial<MessageLimits>
137
+ /** Configured global agent-only wake hop limit (default 8, absolute ceiling 100). */
138
+ readonly hopLimit?: number
133
139
  /** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
134
140
  readonly relayWindowMs?: number
135
141
  /** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
@@ -141,7 +147,7 @@ export interface DeliveryDeps {
141
147
  /** How often the re-wake loop runs (default REWAKE_TICK_MS). */
142
148
  readonly tickMs?: number
143
149
  /** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
144
- readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
150
+ readonly teams?: Pick<TeamPolicy, "prepare" | "answer" | "hopContext">
145
151
  /** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
146
152
  readonly inbound?: InboundPolicy
147
153
  }
@@ -273,6 +279,7 @@ export class Delivery {
273
279
  private readonly owner: string
274
280
 
275
281
  constructor(deps: DeliveryDeps) {
282
+ if (deps.hopLimit !== undefined && (!Number.isSafeInteger(deps.hopLimit) || deps.hopLimit < 1 || deps.hopLimit > HOP_CAP_CEILING)) throw new Error(`hopLimit must be 1–${HOP_CAP_CEILING}`)
276
283
  this.deps = deps
277
284
  const now = (this.now = deps.now ?? Date.now)
278
285
  this.timers = deps.timers ?? defaultTimers
@@ -348,6 +355,17 @@ export class Delivery {
348
355
  return this.hops.humanTurn(userID, sessionID)
349
356
  }
350
357
 
358
+ async notifyTeamHopCap(userID: string, teamID: string, decisionID: string): Promise<void> {
359
+ const entry = await this.deps.teamHopNotice?.(userID, teamID)
360
+ if (!entry) return
361
+ const id = this.newID()
362
+ await this.request(userID, entry, {
363
+ op: "deliver", sessionID: entry, messageID: id, wake: false,
364
+ text: `[hub] notice · Team messages reached the hop cap. The user has a Renew Decision ${decisionID}; wait for their choice. · ${id}`,
365
+ meta: { kind: "msg", from: "hub", to: entry, delivery: "queue" },
366
+ })
367
+ }
368
+
351
369
  /** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
352
370
  /** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
353
371
  flush(userID: string, sessionID?: string): Promise<void> {
@@ -489,8 +507,12 @@ export class Delivery {
489
507
  await this.hops.received(userID, recipient, hop, from)
490
508
  this.users.add(userID)
491
509
  const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
492
- const skipped = await this.wakeFor(userID, message, unwanted)
493
- if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
510
+ const team = "team" in resolved ? resolved.team : undefined
511
+ const explicitTeamID = typeof team === "object" && team !== null && "teamID" in team && typeof team.teamID === "string" ? team.teamID : undefined
512
+ const context = await this.deps.teams?.hopContext(userID, from, recipient, explicitTeamID)
513
+ const limit = Math.min(context?.limit ?? this.deps.hopLimit ?? HOP_CAP, this.deps.hopLimit ?? HOP_CAP)
514
+ const skipped = await this.wakeFor(userID, message, unwanted, limit, context?.teamID)
515
+ if (kind === "ask" && unwanted === undefined && hop <= limit) {
494
516
  const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
495
517
  await this.deps.state.put(userID, REWAKE, message.id, rewake)
496
518
  }
@@ -612,7 +634,8 @@ export class Delivery {
612
634
  if (message.recipient.kind !== "session") return answered
613
635
  const sessionID = message.recipient.sessionID
614
636
  const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
615
- const skipped = await this.wakeFor(userID, message, unwanted)
637
+ const context = await this.deps.teams?.hopContext(userID, from, message.recipient)
638
+ const skipped = await this.wakeFor(userID, message, unwanted, Math.min(context?.limit ?? this.deps.hopLimit ?? HOP_CAP, this.deps.hopLimit ?? HOP_CAP), context?.teamID)
616
639
  return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
617
640
  }
618
641
 
@@ -620,16 +643,23 @@ export class Delivery {
620
643
  * Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
621
644
  * the hop cap (alerting once at the cap), or no wake budget left.
622
645
  */
623
- private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
646
+ private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined, limit = this.deps.hopLimit ?? HOP_CAP, teamID?: string): Promise<WakeSkip | undefined> {
624
647
  if (unwanted) return unwanted
625
648
  if (message.recipient.kind !== "session") return "wake_false"
626
- if (overHopCap(message.hop)) {
627
- if (message.hop === HOP_CAP + 1) {
649
+ if (message.hop > limit) {
650
+ if (teamID && message.from.kind === "session") {
651
+ try {
652
+ await this.deps.teamHopCap?.(userID, { teamID, sender: message.from.sessionID, recipient: message.recipient.sessionID, limit })
653
+ } catch (cause) {
654
+ // The message has already been stored. A failed Decision must not prevent delivery or wake it.
655
+ console.error(`[hub] team hop-cap alert failed: ${cause instanceof Error ? cause.message : String(cause)}`)
656
+ }
657
+ } else if (message.hop === limit + 1) {
628
658
  this.deps.alert?.(userID, {
629
659
  kind: "hopCap",
630
660
  sessionID: message.recipient.sessionID,
631
661
  messageID: message.id,
632
- text: `Agent messages went ${HOP_CAP} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
662
+ text: `Agent messages went ${limit} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
633
663
  })
634
664
  }
635
665
  return "hop_cap"
@@ -1,6 +1,7 @@
1
1
  // /api/agent/grants (design §5, §7): the user's grants. User actors only (allow {}): the device cookie (same-origin
2
2
  // writes, checked by the agent framework) or a device token. A session or app token never creates, edits, reads
3
- // or revokes a grant (escalation guard). Answers {grant} / {grants}, never cached; the owner is not echoed.
3
+ // or revokes a grant on the admin routes (escalation guard). /mine is holder-scoped; a user has no holder.
4
+ // Answers {grant} / {grants}, never cached; the owner is not echoed.
4
5
  //
5
6
  // POST /api/agent/grants create (201) GET /api/agent/grants list, newest first
6
7
  // GET /api/agent/grants/:id one grant PUT /api/agent/grants/:id replace (holder fixed)
@@ -37,6 +38,18 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
37
38
  } satisfies AgentRoute,
38
39
  ]
39
40
  : []),
41
+ {
42
+ method: "GET", path: `${PREFIX}/mine`, allow: { session: true, scope: "messages:read" },
43
+ handle: async ({ actor }) => Response.json({ grants: (await grants.held(actor)).map((g) => grantView(g, now())) }, { headers: NO_STORE }),
44
+ },
45
+ {
46
+ method: "GET", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:read" },
47
+ handle: async ({ actor, params }) => one(await grants.heldGrant(actor, params.id!)),
48
+ },
49
+ {
50
+ method: "DELETE", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:send" },
51
+ handle: async ({ actor, params }) => one(await grants.revokeHeld(actor, params.id!)),
52
+ },
40
53
  {
41
54
  method: "POST",
42
55
  path: PREFIX,
@@ -136,6 +136,29 @@ export class Grants {
136
136
  return (await this.all(owner)).sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1)).map((g) => ({ ...g, state: grantState(g, at) }))
137
137
  }
138
138
 
139
+ /** Grants currently held by this credential; a user credential has no session/app holder. */
140
+ async held(actor: AgentActor): Promise<GrantView[]> {
141
+ if (actor.kind === "user") return []
142
+ return (await this.list(actor.userID)).filter((grant) => grant.owner === actor.userID && this.holds(grant, actor))
143
+ }
144
+
145
+ /** A holder cannot inspect another holder's grant (including an unheld role). */
146
+ async heldGrant(actor: AgentActor, id: string): Promise<Grant> {
147
+ if (actor.kind === "user") throw notFound()
148
+ const grant = await this.get(actor.userID, id)
149
+ if (grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
150
+ return grant
151
+ }
152
+
153
+ /** Re-check holder inside the versioned mutation, so a role lost after a read cannot revoke it. */
154
+ async revokeHeld(actor: AgentActor, id: string): Promise<Grant> {
155
+ await this.heldGrant(actor, id)
156
+ return this.change(actor.userID, id, "grant.revoke", (grant, at) => {
157
+ if (actor.kind === "user" || grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
158
+ return { ...grant, revokedAt: at }
159
+ })
160
+ }
161
+
139
162
  /**
140
163
  * Replaces an active grant with a new spec (its expiry restarts now); id, holder (and its pin) and created
141
164
  * stay. Changing the holder is invalid (revoke and create instead); an ended grant is gone.
@@ -746,7 +746,7 @@ export class LaunchEngine {
746
746
  const workspace = work?.workspace ?? (launch ? leftWorkspace(launch, report) : undefined)
747
747
  if (!workspace) continue
748
748
  if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
749
- await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (workspace.reused ? "keep" : "remove") })
749
+ await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (launch?.spec.end.cleanup ?? (workspace.reused ? "keep" : "remove")) })
750
750
  this.cleanupTries.delete(report.launchID)
751
751
  this.cleanupLogged.delete(report.launchID)
752
752
  } catch (cause) {
@@ -1122,6 +1122,8 @@ export class LaunchEngine {
1122
1122
  // A lost launch whose session a spoke still reports keeps its workspace (never removed under a live session).
1123
1123
  const sessionLive = outcome === "lost" && work.sessionID !== undefined && this.deps.registry.hasSession(work.sessionID)
1124
1124
  const policy: CleanupPolicy = outcome === "needs_user" || sessionLive ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
1125
+ // Reconciliation must replay the policy actually applied, not the launch's original preference.
1126
+ await this.updateWork(userID, id, { cleanup: policy })
1125
1127
  const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
1126
1128
  if (node) {
1127
1129
  if (work.started) {
@@ -1145,11 +1147,9 @@ export class LaunchEngine {
1145
1147
  } catch (cause) {
1146
1148
  // The node may still hold the launch (and its slot): reconcile retries with this policy.
1147
1149
  notes.push(`workspace cleanup failed: ${errorText(cause)}; retried later`)
1148
- await this.updateWork(userID, id, { cleanup: policy })
1149
1150
  }
1150
1151
  } else {
1151
1152
  notes.push("workspace cleanup waits for the node")
1152
- await this.updateWork(userID, id, { cleanup: policy })
1153
1153
  }
1154
1154
  }
1155
1155
  try {
@@ -28,9 +28,12 @@ import type { Clock, Counters, StateStore } from "../backend/types"
28
28
  import { type Party, partyKey } from "./envelope"
29
29
  import { AgentError } from "./errors"
30
30
  import { updateVersioned } from "./versioned"
31
+ import { TEAM_LIMITS } from "../../protocol"
31
32
 
32
33
  /** Hops a reply chain may take without a human turn. */
33
34
  export const HOP_CAP = 8
35
+ /** Absolute ceiling on user-configured hop limits, independent of hub.json. */
36
+ export const HOP_CAP_CEILING = TEAM_LIMITS.hopLimit
34
37
  /** A depth no message refreshed for this long is forgotten. */
35
38
  export const HOP_TTL_MS = 24 * 60 * 60_000
36
39
 
@@ -67,6 +67,7 @@ import { isConciergeLaunch } from "../concierge/supervisor"
67
67
  import type { ConciergeService } from "../concierge/service"
68
68
  import type { SessionIntent } from "../../protocol"
69
69
  import type { SessionIntents } from "./intents"
70
+ import type { NodeRegistry } from "../nodes"
70
71
  import { intentRoutes } from "./intent-routes"
71
72
  import { contextCardRoutes } from "../context-cards/routes"
72
73
  import type { ContextCardService } from "../context-cards/service"
@@ -98,6 +99,8 @@ export interface AgentRoute {
98
99
  /** What the agent API needs from the hub. */
99
100
  export interface AgentDeps {
100
101
  readonly store: Store
102
+ /** Owner's connected launch nodes: read-only user route; never expose connection IDs or launch internals. */
103
+ readonly nodes?: Pick<NodeRegistry, "list">
101
104
  /** (AF1) Connect an app: the connected-app token verifier and the app-clients/connections routes when given. */
102
105
  readonly connect?: AppConnect
103
106
  /** (AF2) Speech for apps: POST /api/agent/speech/{transcribe,speak} (scope speech:use) when given. */
@@ -238,6 +241,14 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
238
241
  const feed = deps.feed ?? new MessageFeed()
239
242
  const appDecisions = appDecisionParts(deps, feed)
240
243
  const routes: AgentRoute[] = [
244
+ ...(deps.nodes ? [{
245
+ method: "GET", path: `${AGENT_PREFIX}/launch-nodes`, allow: {},
246
+ handle: ({ user }) => Response.json({ nodes: deps.nodes!.list(user.id).map((node) => ({
247
+ machineID: node.machineID, machineName: node.machineName, workspaceRoots: node.workspaceRoots,
248
+ capacity: node.capacity, used: node.used, free: node.free, runtimes: node.runtimes,
249
+ labels: node.labels, draining: node.draining,
250
+ })) }, { headers: { "cache-control": "no-store" } }),
251
+ } satisfies AgentRoute] : []),
241
252
  ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
242
253
  ...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
243
254
  ...(deps.directory && deps.contextCards ? contextCardRoutes({ cards: deps.contextCards, directory: deps.directory, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
@@ -245,6 +245,8 @@ export interface SubscriptionsDeps {
245
245
  readonly send: SubscriptionSend
246
246
  /** The session's message hop depth (HopTracker.depth). */
247
247
  readonly hopDepth: (userID: string, sessionID: string) => Promise<number>
248
+ /** Hub-wide hop ceiling (team notices are not session-to-session messages). */
249
+ readonly hopLimit?: number
248
250
  /** Directory facts of these sessions (deleted and unknown ones absent). */
249
251
  readonly entries: (userID: string, sessionIDs: readonly string[]) => ReadonlyMap<string, SubscriptionEntry>
250
252
  /** The sessions an address names for the subscriber (its visibility); throws not_found / invalid / ambiguous. */
@@ -858,7 +860,7 @@ export class Subscriptions {
858
860
  if (scoped && !scoped(subject)) continue
859
861
  const cause = isMessage && subject.senderID !== undefined && subject.senderID.startsWith("ses_") ? subject.senderID : (subject.sessionID ?? target)
860
862
  const depth = await this.depth(ctx, cause)
861
- if (depth + 1 > HOP_CAP) {
863
+ if (depth + 1 > (this.deps.hopLimit ?? HOP_CAP)) {
862
864
  suppressed++
863
865
  continue
864
866
  }
@@ -15,6 +15,7 @@ import { readContextCards, type ContextCardsConfig } from "./context-cards/confi
15
15
  import { checkReleaseBase } from "./install/render"
16
16
  import { cspMode, type CspMode } from "./csp"
17
17
  import { parseDenyCidrs } from "./fetcher"
18
+ import { HOP_CAP, HOP_CAP_CEILING } from "./agent/message-limits"
18
19
 
19
20
  export const GOOGLE_ISSUER = "https://accounts.google.com"
20
21
 
@@ -70,6 +71,8 @@ export interface CentralConfig {
70
71
  readonly auth: AuthConfig
71
72
  /** hub.json `agents.never`: permission patterns no agent may ever approve, for every user (design §5, §7). */
72
73
  readonly agentsNever: readonly string[]
74
+ /** hub.json `agents.hopLimit`: global maximum agent-only hops before a wake is skipped. */
75
+ readonly hopLimit: number
73
76
  /** hub.json `agents.launches`: per-user launch caps, only those set (placement fills in its defaults). */
74
77
  readonly launchCaps?: HubLaunches
75
78
  /** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
@@ -138,6 +141,7 @@ export interface HubLaunches {
138
141
  export interface HubAgents {
139
142
  /** Normalised `action[:glob]` patterns (see agent/policy.ts), duplicates dropped. */
140
143
  readonly never?: readonly string[]
144
+ readonly hopLimit?: number
141
145
  readonly launches?: HubLaunches
142
146
  readonly webhooks?: HubWebhooks
143
147
  /** (AF2) Per-credential app speech quotas: any subset of SPEECH_RATE_LIMITS. */
@@ -169,7 +173,7 @@ type Env = Readonly<Record<string, string | undefined>>
169
173
 
170
174
  const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster", "contextCards"])
171
175
  const CLUSTER_KEYS = ["database", "valkey"] as const
172
- const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas"])
176
+ const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas", "hopLimit"])
173
177
  /** The largest speech quota hub.json may set (counts and characters). */
174
178
  const SPEECH_QUOTA_MAX = 1_000_000_000
175
179
  const WEBHOOK_KEYS = new Set(["allowPrivate", "allowPrivateHosts"])
@@ -350,11 +354,13 @@ function readHubAgents(raw: unknown, file: string, warnings: string[], fail: (me
350
354
  if (!isObj(raw)) fail("agents must be an object")
351
355
  const input = raw as Record<string, unknown>
352
356
  for (const key of Object.keys(input)) if (!AGENTS_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.${key}" ignored`)
357
+ if (input.hopLimit !== undefined && (!Number.isSafeInteger(input.hopLimit) || (input.hopLimit as number) < 1 || (input.hopLimit as number) > HOP_CAP_CEILING))
358
+ fail(`agents.hopLimit must be a whole number from 1 to ${HOP_CAP_CEILING}`)
353
359
  const launches = input.launches === undefined ? undefined : readHubLaunches(input.launches, file, warnings, fail)
354
360
  const never = input.never === undefined ? undefined : readNever(input.never, fail)
355
361
  const webhooks = input.webhooks === undefined ? undefined : readHubWebhooks(input.webhooks, file, warnings, fail)
356
362
  const speechQuotas = input.speechQuotas === undefined ? undefined : readSpeechQuotas(input.speechQuotas, file, warnings, fail)
357
- return { ...(never ? { never } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
363
+ return { ...(never ? { never } : {}), ...(input.hopLimit !== undefined ? { hopLimit: input.hopLimit as number } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
358
364
  }
359
365
 
360
366
  /** (AF2) `agents.speechQuotas`: {transcribe?: {perMinute?, perHour?}, speak?: {perMinute?, perHour?, charsPerHour?}}. */
@@ -601,6 +607,7 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
601
607
  ...(file.speech ? { speech: file.speech } : {}),
602
608
  auth,
603
609
  agentsNever: [...(file.agents?.never ?? [])],
610
+ hopLimit: file.agents?.hopLimit ?? HOP_CAP,
604
611
  ...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
605
612
  ...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
606
613
  ...(file.agents?.webhooks?.allowPrivateHosts?.length ? { webhooksPrivateHosts: [...file.agents.webhooks.allowPrivateHosts] } : {}),
@@ -8,7 +8,7 @@ import { diskAssets, type WebAssets } from "./assets"
8
8
  import path from "node:path"
9
9
  import { promisify } from "node:util"
10
10
  import { brotliCompress, constants as zlib, gzip } from "node:zlib"
11
- import { CONNECT_PATHS, formAnswerProblem, isDeliverResult, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type ResultFor, type SessionModelPrefs } from "../protocol"
11
+ import { CONNECT_PATHS, formAnswerProblem, isDeliverResult, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, RECORDING_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type ResultFor, type SessionModelPrefs, type SessionSummary } from "../protocol"
12
12
  import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
13
13
  import { speakable } from "../speakable"
14
14
  import { Auth, RateLimitError, ValidationError } from "./auth"
@@ -60,7 +60,7 @@ import { BusyError, Registry, RELAY_REASON, RelayRefused, type Timers } from "./
60
60
  import { RELAY_REASON_CONFIRMED, relayMessage, relayQuestionProblem, STATUS_QUESTION_SOFT_REFUSAL } from "./front-relays"
61
61
  import { createActions, type Unsupported, unsupportedMessage } from "./actions"
62
62
  import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
63
- import { audioFormat, SpeechError } from "./speech/provider"
63
+ import { audioFormat, SpeechError, type TranscribeStats } from "./speech/provider"
64
64
  import { SpeechService } from "./speech/service"
65
65
  import { NodeRegistry } from "./nodes"
66
66
  import { ConnDirectory } from "./cluster/directory"
@@ -85,7 +85,7 @@ import { coordinatorActor, SupervisorCoordinator } from "./workspaces/coordinato
85
85
  import { artifactJournalEvent } from "./artifacts/events"
86
86
  import { ArtifactMaintenance } from "./artifacts/maintenance"
87
87
  import { MemoryBlobStore } from "./backend/memory-blobs"
88
- import { RecordingService } from "./recording"
88
+ import { RecordingService, type DevLimits } from "./recording"
89
89
  import { recordingRoutes } from "./recording/routes"
90
90
  import { wrapConciergeSend } from "./recording/concierge-taps"
91
91
  import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
@@ -100,6 +100,7 @@ import { teamDeniedEvent, TeamPolicy } from "./teams/policy"
100
100
  import { TeamSeatStore } from "./teams/seats"
101
101
  import { TeamStaffingService } from "./teams/staffing"
102
102
  import { intakeActor, TeamIntakeService } from "./teams/intake"
103
+ import { TeamHopStalls } from "./teams/hop-stalls"
103
104
  import { TeamStore } from "./teams/store"
104
105
  import { ActivityTracker } from "./concierge/activity"
105
106
  import { HumanActivity, withHumanActivity } from "./human-activity"
@@ -137,7 +138,8 @@ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEY
137
138
  const HUMAN_ACTIVITY_STOP_MS = 3_000
138
139
 
139
140
  export interface Speech {
140
- transcribe(audio: Uint8Array, format: string): Promise<string>
141
+ /** `stats` (recording mode): the provider fills in its upstream request's status, bytes and time to headers. */
142
+ transcribe(audio: Uint8Array, format: string, stats?: TranscribeStats): Promise<string>
141
143
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
142
144
  /** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
143
145
  speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
@@ -149,6 +151,8 @@ export interface CentralOptions {
149
151
  readonly previewRetention?: { readonly archiveAfterMs?: number; readonly purgeAfterMs?: number }
150
152
  /** The registry's timers (tests: a fake clock for its delayed checks); real timers by default. */
151
153
  readonly registryTimers?: Timers
154
+ /** Developer mode's recording limits (tests shrink them; default DEV_LIMITS). */
155
+ readonly recordingDevLimits?: Partial<DevLimits>
152
156
  /** The built web app (web/dist, see web/vite.config.ts) on disk; or pass `web` (e.g. files embedded in a compiled binary). */
153
157
  readonly webDir?: string
154
158
  readonly web?: WebAssets
@@ -888,7 +892,7 @@ export function startCentral(options: CentralOptions): Central {
888
892
  // sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
889
893
  // Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
890
894
  let teamSeats: TeamSeatStore | undefined
891
- const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
895
+ const teamStore = new TeamStore({ state: backends.state, now, globalHopLimit: config.hopLimit, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
892
896
  const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
893
897
  teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
894
898
  const teamPolicy = new TeamPolicy({
@@ -901,8 +905,35 @@ export function startCentral(options: CentralOptions): Central {
901
905
  await Promise.all([audit.record(userID, { actor: { kind: "session", sessionID: denial.senderID }, verb: "team.denied", target, decision: "forbidden", reason, outcome: "refused" }), agentEvents.append(userID, teamDeniedEvent(denial))])
902
906
  },
903
907
  })
908
+ let decisions: Decisions
909
+ const teamHopStalls = new TeamHopStalls({
910
+ state: backends.state,
911
+ now,
912
+ seats: async (userID, teamID) => [...new Set((await teamSeats!.list(userID, teamID, { current: true })).map((seat) => seat.sessionID))],
913
+ reset: (userID, sessionID) => delivery.hops.humanTurn(userID, sessionID),
914
+ close: async (userID, decisionID) => { await decisions.close(userID, decisionID, "A person continued this team's work; renewal is no longer needed") },
915
+ notice: (userID, teamID, decisionID) => delivery.notifyTeamHopCap(userID, teamID, decisionID),
916
+ open: async (userID, teamID, sender, recipient, limit) => {
917
+ const team = await teamStore.require(userID, teamID)
918
+ const intake = await teamIntake?.list(userID, { kind: "user", userID, deviceID: "hub", via: "cookie" }, teamID, { limit: 50 })
919
+ const active = intake?.items.find((item) => item.status === "routed" && (item.sessionID === sender || item.sessionID === recipient))
920
+ const detail = active ? `, intake ${active.id} (${active.title})` : ""
921
+ const { decision } = await decisions.ask(userID, { kind: "app", userID, tokenID: "team-hop", scopes: [], label: "Hub team hop cap" }, {
922
+ question: `Team #${team.name}${detail} reached its ${limit}-hop limit (${sender} → ${recipient}). Renew this team's seats?`,
923
+ options: [{ id: "renew", label: "Renew" }, { id: "leave", label: "Leave stopped", safe: true }],
924
+ urgency: "high", tier: "tap", about: [recipient],
925
+ }, { action: { kind: "teamHopRenew", teamID } })
926
+ return decision.id
927
+ },
928
+ })
904
929
  const delivery = new Delivery({
930
+ hopLimit: config.hopLimit,
905
931
  teams: teamPolicy,
932
+ teamHopCap: async (userID, input) => { await teamHopStalls.hit(userID, input.teamID, input.sender, input.recipient, input.limit) },
933
+ teamHopNotice: async (userID, teamID) => {
934
+ const team = await teamStore.get(userID, teamID)
935
+ return (await teamSeats!.list(userID, teamID, { current: true })).find((seat) => seat.role === team?.entryRole)?.sessionID
936
+ },
906
937
  // CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
907
938
  inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
908
939
  state: backends.state,
@@ -937,7 +968,7 @@ export function startCentral(options: CentralOptions): Central {
937
968
  /** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
938
969
  let conciergeTierHooks: ConciergeTiers | undefined
939
970
  // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
940
- const decisions = new Decisions({
971
+ decisions = new Decisions({
941
972
  state: backends.state,
942
973
  delivery,
943
974
  now,
@@ -954,6 +985,13 @@ export function startCentral(options: CentralOptions): Central {
954
985
  // (CON-06) Concierge proposals run their verb as the user (all set up below).
955
986
  act: (userID, decision, choice, by) => {
956
987
  const kind = decision.action?.kind
988
+ if (kind === "teamHopRenew") {
989
+ if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
990
+ const teamID = decision.action!.teamID
991
+ return choice.option === "renew"
992
+ ? teamHopStalls.renew(userID, teamID, decision.id).then(async () => { await audit.record(userID, { actor: { kind: "user" }, verb: "team.hop.renew", target: {}, decision: "allow", outcome: "ok" }); return "done" as const })
993
+ : Promise.resolve("done" as const)
994
+ }
957
995
  if (kind === "act") {
958
996
  if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
959
997
  return conciergeTierHooks.act(userID, decision, choice, by)
@@ -975,6 +1013,7 @@ export function startCentral(options: CentralOptions): Central {
975
1013
  const tenant = registry.sessionTenant(sessionID)
976
1014
  if (tenant === undefined) return
977
1015
  await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
1016
+ await teamHopStalls.clearForHuman(tenant, sessionID).catch((cause) => console.error(`[hub] team hop reset failed: ${(cause as Error).message}`))
978
1017
  }
979
1018
  registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
980
1019
  // Committed revert windows (./revert-windows): the hub message rows of a removed history stay hidden for good.
@@ -1313,6 +1352,23 @@ export function startCentral(options: CentralOptions): Central {
1313
1352
  audit: (entry) => store.audit(entry),
1314
1353
  users: allUsers,
1315
1354
  log: (message) => console.error(message),
1355
+ pod: config.cluster?.podID ?? "hub",
1356
+ // (Developer mode) What ran a turn: the session's model and context size from its spoke (summary), counts only.
1357
+ turnFacts: async (_userID, sessionID) => {
1358
+ const summary = (await spokes.request(ownerOf(sessionID), { op: "summary", sessionID })) as SessionSummary
1359
+ const model = summary?.model
1360
+ const context = summary?.context
1361
+ const agent = registry.session(sessionID)?.agent
1362
+ return {
1363
+ ...(model?.providerID ? { provider: model.providerID } : {}),
1364
+ ...(model?.modelID ? { model: model.modelID } : context?.model ? { model: context.model } : {}),
1365
+ ...(model?.variant ? { variant: model.variant } : {}),
1366
+ ...(agent ? { agent } : {}),
1367
+ ...(typeof context?.used === "number" ? { contextTokens: context.used } : {}),
1368
+ ...(typeof context?.limit === "number" ? { contextLimit: context.limit } : {}),
1369
+ }
1370
+ },
1371
+ ...(options.recordingDevLimits ? { devLimits: options.recordingDevLimits } : {}),
1316
1372
  })
1317
1373
  recordings = recordingService
1318
1374
  registry.recordingTap = recordingService.registryTap
@@ -1378,7 +1434,7 @@ export function startCentral(options: CentralOptions): Central {
1378
1434
  const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
1379
1435
  // Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
1380
1436
  const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
1381
- const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
1437
+ const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, hopStalls: teamHopStalls, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
1382
1438
  registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
1383
1439
  launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
1384
1440
  // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
@@ -1435,6 +1491,7 @@ export function startCentral(options: CentralOptions): Central {
1435
1491
  const subscriptionLeader = leader("subscriptions", { onElected: () => subscriptions?.start(), onDeposed: () => subscriptions?.stop() })
1436
1492
  subscriptions = new Subscriptions({
1437
1493
  state: backends.state,
1494
+ hopLimit: config.hopLimit,
1438
1495
  events: agentEvents,
1439
1496
  send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
1440
1497
  hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
@@ -1744,7 +1801,7 @@ export function startCentral(options: CentralOptions): Central {
1744
1801
  conversationOf: (sessionID) => registry.conversationOf(sessionID),
1745
1802
  stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
1746
1803
  }
1747
- const agentDeps = { contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, subscriptions, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => [...conciergeParts.tiers.routes(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
1804
+ const agentDeps = { nodes, contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, subscriptions, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => [...conciergeParts.tiers.routes(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
1748
1805
  const resolveActor = createResolver({
1749
1806
  cookieName: COOKIE,
1750
1807
  findDevice: (token) => deviceOfToken(token),
@@ -2026,16 +2083,18 @@ export function startCentral(options: CentralOptions): Central {
2026
2083
  await store.removeDevice(device.id)
2027
2084
  return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
2028
2085
  }),
2029
- route("POST", "/api/transcribe", "user", ({ request, user, device }) => transcribe(request, speechFor(user.id), user.id, device.id)),
2030
- route("POST", "/api/speak", "user", async ({ request, user, device }) => {
2031
- const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
2032
- if (!text) return error(400, "Nothing to speak")
2033
- const speech = speechFor(user.id)
2034
- // (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; best effort, the audio is never stored.
2035
- const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
2036
- const audio = await speakAudio(speech, text, request.signal)
2037
- return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
2038
- }),
2086
+ route("POST", "/api/transcribe", "user", ({ request, user, device }) => requestLined(user.id, device.id, request, "transcribe", () => transcribe(request, speechFor(user.id), user.id, device.id))),
2087
+ route("POST", "/api/speak", "user", ({ request, user, device }) =>
2088
+ requestLined(user.id, device.id, request, "speak", async () => {
2089
+ const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
2090
+ if (!text) return error(400, "Nothing to speak")
2091
+ const speech = speechFor(user.id)
2092
+ // (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; in Developer mode the audio is kept too.
2093
+ const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
2094
+ const audio = await speakAudio(speech, text, request.signal)
2095
+ return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
2096
+ }),
2097
+ ),
2039
2098
  // (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
2040
2099
  ...recordingRoutes(recordingService, { version: options.version ?? "0.0.0", now, audit: (entry) => store.audit(entry) }).map((r) => route(r.method, r.path, "user", (context) => r.handle(context))),
2041
2100
  route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
@@ -3152,6 +3211,28 @@ export function startCentral(options: CentralOptions): Central {
3152
3211
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
3153
3212
  }
3154
3213
 
3214
+ /**
3215
+ * (Developer mode) A voice request naming a recording (x-hub-recording): its route, status and time are recorded
3216
+ * as a request line in that recording when it is a developer one (recordingService.requestLine). Never a body.
3217
+ */
3218
+ async function requestLined(userID: string, deviceID: string, request: Request, route: "transcribe" | "speak", handle: () => Promise<Response>): Promise<Response> {
3219
+ const recordingID = request.headers.get(RECORDING_HEADER)
3220
+ if (recordingID === null) return handle()
3221
+ const started = performance.now()
3222
+ let status = 500
3223
+ try {
3224
+ const res = await handle()
3225
+ status = res.status
3226
+ return res
3227
+ } catch (cause) {
3228
+ const s = (cause as { status?: unknown }).status
3229
+ status = cause instanceof SpeechError ? 502 : typeof s === "number" ? s : 500
3230
+ throw cause
3231
+ } finally {
3232
+ void recordingService.requestLine(userID, deviceID, recordingID, { route, method: "POST", status, ms: performance.now() - started }).catch(() => undefined)
3233
+ }
3234
+ }
3235
+
3155
3236
  async function transcribe(request: Request, speech: Speech, userID: string, deviceID: string): Promise<Response> {
3156
3237
  const format = audioFormat(request.headers.get("content-type"))
3157
3238
  if (!format) return error(415, `Unsupported audio type: ${request.headers.get("content-type")}`)
@@ -3163,9 +3244,10 @@ export function startCentral(options: CentralOptions): Central {
3163
3244
  // (REC-01) With x-hub-recording + x-hub-segment: the segment is kept for the recording (no second upload).
3164
3245
  const tap = await recordingService.transcribeTap(userID, deviceID, request.headers, audio, format).catch(() => undefined)
3165
3246
  const started = performance.now()
3166
- const text = await speech.transcribe(audio, format)
3247
+ const stats: TranscribeStats = {}
3248
+ const text = await (tap ? speech.transcribe(audio, format, stats) : speech.transcribe(audio, format))
3167
3249
  if (!tap) return Response.json({ text })
3168
- const result = await tap.finish(text, Math.round(performance.now() - started)).catch(() => "failed" as const)
3250
+ const result = await tap.finish(text, Math.round(performance.now() - started), stats).catch(() => "failed" as const)
3169
3251
  return Response.json({ text }, { headers: { [RECORDING_AUDIO_RESULT_HEADER]: result } })
3170
3252
  }
3171
3253