@huynle/hub 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +36 -8
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/claude-code/src/sessions.ts +4 -1
  6. package/packages/cli/dist/hub-cli.js +851 -466
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +12 -7
  9. package/packages/cli/src/decisions.ts +52 -4
  10. package/packages/cli/src/format.ts +9 -0
  11. package/packages/cli/src/grants-mcp.ts +65 -0
  12. package/packages/cli/src/mcp-operations.ts +49 -0
  13. package/packages/cli/src/mcp.ts +26 -4
  14. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  15. package/packages/cli/src/team-intake-mcp.ts +2 -1
  16. package/packages/cli/src/teams-mcp.ts +9 -3
  17. package/packages/codex/package.json +1 -1
  18. package/packages/codex/src/sessions.ts +14 -3
  19. package/packages/node/package.json +1 -1
  20. package/packages/preset/package.json +1 -1
  21. package/packages/protocol/package.json +1 -1
  22. package/packages/protocol/schema/api/v1/_root.json +119 -0
  23. package/packages/protocol/schema/api/v1/account.json +419 -0
  24. package/packages/protocol/schema/api/v1/decisions.json +635 -0
  25. package/packages/protocol/schema/api/v1/discovery.json +110 -0
  26. package/packages/protocol/schema/api/v1/events.json +679 -0
  27. package/packages/protocol/schema/api/v1/session-actions.json +660 -0
  28. package/packages/protocol/schema/api/v1/sessions.json +2055 -0
  29. package/packages/protocol/schema/api/v1/speech.json +492 -0
  30. package/packages/protocol/schema/api/v1/unspecified.json +282 -0
  31. package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
  32. package/packages/protocol/schema/app-front/v1.schema.json +7 -3
  33. package/packages/protocol/src/api-v1.ts +8 -0
  34. package/packages/protocol/src/index.ts +2 -0
  35. package/packages/protocol/src/teams.ts +16 -0
  36. package/packages/protocol/src/voice-recording.ts +39 -2
  37. package/packages/runtime-openshell/image/entry.sh +3 -1
  38. package/packages/runtime-openshell/image/start.sh +3 -1
  39. package/packages/runtime-openshell/package.json +1 -1
  40. package/packages/runtime-sdk/package.json +1 -1
  41. package/packages/sdk/package.json +1 -1
  42. package/packages/sdk/src/app-front.ts +2 -2
  43. package/packages/sdk/src/ask-fields.ts +76 -0
  44. package/packages/sdk/src/client.ts +24 -2
  45. package/packages/sdk/src/envelope.ts +3 -2
  46. package/packages/sdk/src/index.ts +1 -0
  47. package/packages/sdk/src/team-intake.ts +6 -0
  48. package/packages/sdk/src/teams.ts +4 -0
  49. package/packages/sdk/src/types.ts +36 -4
  50. package/packages/sdk/src/wake.ts +9 -4
  51. package/packages/spoke-sdk/package.json +1 -1
  52. package/src/central/agent/agent-limits.ts +185 -0
  53. package/src/central/agent/app-decisions.ts +6 -5
  54. package/src/central/agent/decisions.ts +136 -9
  55. package/src/central/agent/delivery.ts +164 -22
  56. package/src/central/agent/errors.ts +2 -1
  57. package/src/central/agent/events.ts +20 -2
  58. package/src/central/agent/grant-routes.ts +14 -1
  59. package/src/central/agent/grants.ts +23 -0
  60. package/src/central/agent/launch-engine.ts +9 -6
  61. package/src/central/agent/message-limits.ts +6 -0
  62. package/src/central/agent/message-routes.ts +33 -6
  63. package/src/central/agent/messages.ts +7 -0
  64. package/src/central/agent/placement.ts +4 -2
  65. package/src/central/agent/read-routes.ts +5 -0
  66. package/src/central/agent/routes.ts +13 -0
  67. package/src/central/agent/subscriptions.ts +15 -4
  68. package/src/central/api-v1.ts +115 -0
  69. package/src/central/artifacts/store.ts +4 -2
  70. package/src/central/concierge/board-results.ts +2 -1
  71. package/src/central/concierge/notifier.ts +4 -2
  72. package/src/central/config.ts +24 -3
  73. package/src/central/health/classify.ts +209 -0
  74. package/src/central/http.ts +380 -36
  75. package/src/central/recording/export.ts +57 -3
  76. package/src/central/recording/index.ts +236 -17
  77. package/src/central/recording/ingest.ts +11 -2
  78. package/src/central/recording/routes.ts +61 -7
  79. package/src/central/recording/schema.ts +68 -3
  80. package/src/central/recording/settings.ts +19 -5
  81. package/src/central/recording/store.ts +19 -0
  82. package/src/central/recording/taps.ts +45 -1
  83. package/src/central/registry.ts +34 -3
  84. package/src/central/speech/openai.ts +4 -4
  85. package/src/central/speech/openrouter.ts +4 -4
  86. package/src/central/speech/provider.ts +32 -1
  87. package/src/central/speech/service.ts +6 -5
  88. package/src/central/teams/budgets.ts +144 -0
  89. package/src/central/teams/hop-stalls.ts +80 -0
  90. package/src/central/teams/intake.ts +38 -6
  91. package/src/central/teams/involvement.ts +95 -0
  92. package/src/central/teams/item-decisions.ts +60 -0
  93. package/src/central/teams/item-eligibility.ts +35 -0
  94. package/src/central/teams/item-stalls.ts +87 -0
  95. package/src/central/teams/loops.ts +40 -0
  96. package/src/central/teams/policy.ts +10 -0
  97. package/src/central/teams/progress.ts +154 -0
  98. package/src/central/teams/routes.ts +2 -0
  99. package/src/central/teams/seats.ts +3 -3
  100. package/src/central/teams/staffing.ts +5 -0
  101. package/src/central/teams/store.ts +20 -1
  102. package/src/central/teams/validation.ts +16 -2
  103. package/src/central/workspaces/coordinator.ts +3 -1
  104. package/src/spoke/hub-tool.ts +26 -11
  105. package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
  106. package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
  107. package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
  108. package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
  109. package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
  110. package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-cryC7P6j.js} +1 -1
  111. package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
  112. package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
  113. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  114. package/web/dist/assets/index-DA0gKIzv.js +33 -0
  115. package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
  116. package/web/dist/assets/live-B2IfKm37.js +5 -0
  117. package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
  118. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  119. package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
  120. package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
  121. package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
  122. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  123. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  124. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  125. package/web/dist/index.html +5 -5
  126. package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
  127. package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
  128. package/web/dist/assets/index-bX8B6NLo.js +0 -33
  129. package/web/dist/assets/live-BGLA-peO.js +0 -5
@@ -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"
@@ -36,14 +36,16 @@ import { createResolver, type AgentActor, type BearerVerifier, type CredentialMo
36
36
  import { ActingSwitch } from "./agent/acting-switch"
37
37
  import { AgentAudit } from "./agent/audit"
38
38
  import { Grants } from "./agent/grants"
39
- import { ANSWER_VIAS, type AnswerVia, Decisions, type Decision, TAP_ONLY } from "./agent/decisions"
39
+ import { ANSWER_VIAS, answerDetail, answerPicks, type AnswerVia, Decisions, type Decision, TAP_ONLY } from "./agent/decisions"
40
40
  import { Acting, type Escalate } from "./agent/acting"
41
41
  import { Escalations } from "./agent/escalation"
42
42
  import { GrantRequests } from "./agent/grant-requests"
43
43
  import { ConciergeGrantRequests, conciergeGrantRequestRoutes, watchedProjects } from "./concierge/grant-request"
44
44
  import { partyFromActor } from "./agent/envelope"
45
45
  import { Delivery } from "./agent/delivery"
46
+ import { AgentLimits } from "./agent/agent-limits"
46
47
  import { AGENT_ERROR_STATUS, AgentError, type AgentErrorCode } from "./agent/errors"
48
+ import { apiV1Response, unversionedURL } from "./api-v1"
47
49
  import type { Launch } from "./agent/launch-store"
48
50
  import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
49
51
  import { hubDirectory } from "./agent/directory-routes"
@@ -60,7 +62,7 @@ import { BusyError, Registry, RELAY_REASON, RelayRefused, type Timers } from "./
60
62
  import { RELAY_REASON_CONFIRMED, relayMessage, relayQuestionProblem, STATUS_QUESTION_SOFT_REFUSAL } from "./front-relays"
61
63
  import { createActions, type Unsupported, unsupportedMessage } from "./actions"
62
64
  import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
63
- import { audioFormat, SpeechError } from "./speech/provider"
65
+ import { audioFormat, SpeechError, type TranscribeStats } from "./speech/provider"
64
66
  import { SpeechService } from "./speech/service"
65
67
  import { NodeRegistry } from "./nodes"
66
68
  import { ConnDirectory } from "./cluster/directory"
@@ -85,7 +87,7 @@ import { coordinatorActor, SupervisorCoordinator } from "./workspaces/coordinato
85
87
  import { artifactJournalEvent } from "./artifacts/events"
86
88
  import { ArtifactMaintenance } from "./artifacts/maintenance"
87
89
  import { MemoryBlobStore } from "./backend/memory-blobs"
88
- import { RecordingService } from "./recording"
90
+ import { RecordingService, type DevLimits } from "./recording"
89
91
  import { recordingRoutes } from "./recording/routes"
90
92
  import { wrapConciergeSend } from "./recording/concierge-taps"
91
93
  import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
@@ -100,6 +102,16 @@ import { teamDeniedEvent, TeamPolicy } from "./teams/policy"
100
102
  import { TeamSeatStore } from "./teams/seats"
101
103
  import { TeamStaffingService } from "./teams/staffing"
102
104
  import { intakeActor, TeamIntakeService } from "./teams/intake"
105
+ import { TeamHopStalls } from "./teams/hop-stalls"
106
+ import { itemEligibility } from "./teams/item-eligibility"
107
+ import { ItemBudgets } from "./teams/budgets"
108
+ import { TeamInvolvement } from "./teams/involvement"
109
+ /** (TA2-8) The hub itself, for a reset it made on observed progress (no user, device or session acted). */
110
+ const HUB_PROGRESS_PARTY = { kind: "app", tokenID: "team-progress", label: "Hub progress" } as const
111
+ import { LoopDetector, type LoopThreshold } from "./teams/loops"
112
+ import { ItemStalls } from "./teams/item-stalls"
113
+ import { ItemDecisions, itemDecisionQuestion } from "./teams/item-decisions"
114
+ import { DiffSampler, ProgressSignals, type ProgressSignal } from "./teams/progress"
103
115
  import { TeamStore } from "./teams/store"
104
116
  import { ActivityTracker } from "./concierge/activity"
105
117
  import { HumanActivity, withHumanActivity } from "./human-activity"
@@ -109,7 +121,7 @@ import { conciergeBoard } from "./concierge/board-routes"
109
121
  import { renderFrontBoard } from "./concierge/board-render"
110
122
  import { ContextPusher } from "./concierge/context"
111
123
  import { C1_ENTITLEMENTS } from "./concierge/entitlements"
112
- import { ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
124
+ import { cardTriggerOf, ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
113
125
  import { TRANSCRIPT_ROWS } from "./context-cards/inputs"
114
126
  import { CONTEXT_CARD_TRIGGERS } from "./../protocol"
115
127
  import type { ChatClient } from "./llm/chat"
@@ -137,7 +149,8 @@ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEY
137
149
  const HUMAN_ACTIVITY_STOP_MS = 3_000
138
150
 
139
151
  export interface Speech {
140
- transcribe(audio: Uint8Array, format: string): Promise<string>
152
+ /** `stats` (recording mode): the provider fills in its upstream request's status, bytes and time to headers. */
153
+ transcribe(audio: Uint8Array, format: string, stats?: TranscribeStats): Promise<string>
141
154
  speak(text: string): Promise<Uint8Array<ArrayBuffer>>
142
155
  /** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
143
156
  speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
@@ -149,6 +162,8 @@ export interface CentralOptions {
149
162
  readonly previewRetention?: { readonly archiveAfterMs?: number; readonly purgeAfterMs?: number }
150
163
  /** The registry's timers (tests: a fake clock for its delayed checks); real timers by default. */
151
164
  readonly registryTimers?: Timers
165
+ /** Developer mode's recording limits (tests shrink them; default DEV_LIMITS). */
166
+ readonly recordingDevLimits?: Partial<DevLimits>
152
167
  /** 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
168
  readonly webDir?: string
154
169
  readonly web?: WebAssets
@@ -200,6 +215,10 @@ export interface CentralOptions {
200
215
  readonly leaderTtlMs?: number
201
216
  /** How often the decision deadline and message re-wake loops run (see DECISION_TICK_MS, REWAKE_TICK_MS). */
202
217
  readonly loopTickMs?: number
218
+ /** Test-only initial agent-limits retry scheduler; production starts at 1s, doubling to 30s. */
219
+ readonly agentLimitsRetry?: (ms: number) => Promise<void>
220
+ /** Bound requests held for initial agent-limits hydration (default 5s; tests shorten it). */
221
+ readonly agentLimitsInitialWaitMs?: number
203
222
  /** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
204
223
  readonly drainTimeoutMs?: number
205
224
  readonly drainRetryAfterMs?: number
@@ -222,6 +241,7 @@ export interface Central {
222
241
  readonly speechSettings: SpeechService
223
242
  /** Backends for new code (state, bus, event log, counters, locks, catalog, kv). */
224
243
  readonly backends: Backends
244
+ readonly agentLimits: AgentLimits
225
245
  /** Agent messages: store and delivery to sessions (the message routes use it). */
226
246
  readonly delivery: Delivery
227
247
  /** Decisions (ask @me): answered by the user on /api/decisions. */
@@ -466,6 +486,11 @@ export function startCentral(options: CentralOptions): Central {
466
486
  /** The bundle for the config (or the one given). Cluster mode never touches the state dir. */
467
487
  const ownBackends = !options.backends
468
488
  const backends = options.backends ?? hubBackends(config, { now })
489
+ const agentLimits = new AgentLimits(backends.state, { hopLimit: config.hopLimit, hopCeiling: config.hopCeiling, ...(config.launchCaps ? { launchCaps: config.launchCaps } : {}) }, { hopLimit: config.agentLimitsSource === "hub.json", hopCeiling: config.hopCeilingSource === "hub.json", launchCaps: config.agentLimitsSource === "hub.json" }, backends.bus, { now, ...(options.timing?.agentLimitsRetry ? { retry: options.timing.agentLimitsRetry } : {}) })
490
+ // Observe startup failure immediately (no unhandled rejection), but never serve on an unknown cap.
491
+ const agentLimitsReady = agentLimits.start().catch((cause: unknown) => {
492
+ console.error("[hub] agent limits could not load; refusing requests:", cause)
493
+ })
469
494
  // Cluster mode: the records are rows in the shared StateStore, cached per pod (loaded on first use; no data
470
495
  // migration runs on start). Otherwise state.json in the state dir.
471
496
  const store = config.cluster
@@ -888,7 +913,7 @@ export function startCentral(options: CentralOptions): Central {
888
913
  // sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
889
914
  // Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
890
915
  let teamSeats: TeamSeatStore | undefined
891
- const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
916
+ const teamStore = new TeamStore({ state: backends.state, now, hopCeiling: () => agentLimits.snapshot().hopCeiling, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
892
917
  const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
893
918
  teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
894
919
  const teamPolicy = new TeamPolicy({
@@ -901,8 +926,167 @@ export function startCentral(options: CentralOptions): Central {
901
926
  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
927
  },
903
928
  })
904
- const delivery = new Delivery({
929
+ let decisions: Decisions
930
+ const teamHopStalls = new TeamHopStalls({
931
+ state: backends.state,
932
+ now,
933
+ close: async (userID, decisionID, reason) => { await decisions.close(userID, decisionID, reason ?? "A person continued this team's work; renewal is no longer needed") },
934
+ notice: (userID, teamID, decisionID) => delivery.notifyTeamHopCap(userID, teamID, decisionID),
935
+ open: async (userID, teamID, sender, recipient, limit) => {
936
+ const team = await teamStore.require(userID, teamID)
937
+ const intake = await teamIntake?.list(userID, { kind: "user", userID, deviceID: "hub", via: "cookie" }, teamID, { limit: 50 })
938
+ const active = intake?.items.find((item) => item.status === "routed" && (item.sessionID === sender || item.sessionID === recipient))
939
+ const detail = active ? `, intake ${active.id} (${active.title})` : ""
940
+ const { decision } = await decisions.ask(userID, { kind: "app", userID, tokenID: "team-hop", scopes: [], label: "Hub team hop cap" }, {
941
+ question: `Team #${team.name}${detail} reached its effective ${limit}-hop limit (${team.hopLimit !== undefined && team.hopLimit < agentLimits.snapshot().hopLimit ? "team" : `hub-wide ${agentLimits.snapshot().sources.hopLimit}`}; ${sender} → ${recipient}). Renew this team's seats?`,
942
+ options: [{ id: "renew", label: "Renew" }, { id: "leave", label: "Leave stopped", safe: true }],
943
+ urgency: "high", tier: "tap", about: [recipient],
944
+ }, { action: { kind: "teamHopRenew", teamID } })
945
+ return decision.id
946
+ },
947
+ })
948
+ const teamInvolvement = new TeamInvolvement({
949
+ seats: async (userID, teamID) => [...new Set((await teamSeats!.list(userID, teamID, { current: true })).map((seat) => seat.sessionID))],
950
+ seatsOf: (userID, sessionID) => teamSeats!.seatsOf(userID, sessionID),
951
+ reset: (userID, sessionID) => delivery.hops.humanTurn(userID, sessionID),
952
+ stalls: teamHopStalls,
953
+ audit: async ({ userID, teamID, kind, ref, seats, failed, actor }) => { await audit.record(userID, { actor: kind === "progress" ? HUB_PROGRESS_PARTY : { kind: "user" }, ...(actor?.deviceID ? { deviceID: actor.deviceID } : {}), verb: "team.hops.reset", target: {}, decision: "allow", outcome: "ok", reason: `${teamID} ${kind}:${ref}, ${seats.length} seats, ${failed.length} failed${actor?.via ? `, via ${actor.via}` : ""}` }) },
954
+ event: async ({ userID, teamID, kind, ref, seats, failed }) => { await agentEvents.append(userID, { type: "team.hops.reset", family: "manage", subject: { teamID }, data: { kind, ref, seats: seats.length, failed: failed.length } }) },
955
+ })
956
+ const itemBudgets = new ItemBudgets({
957
+ state: backends.state, now,
958
+ limits: async (userID, teamID) => (await teamStore.get(userID, teamID))?.itemBudget ?? agentLimits.snapshot().itemBudget!,
959
+ exhausted: async (userID, teamID, itemID, reason, limit, usage) => {
960
+ await agentEvents.append(userID, { type: "team.item_budget.exhausted", family: "manage", subject: { teamID, ...(itemID ? { intakeID: itemID } : {}) }, data: { scope: itemID ? "item" : "pool", reason, limit, usageWakes: usage.wakes, usageHours: Math.round(usage.hours * 10) / 10 } })
961
+ // (TA2-7) An item opens its one Extend/Stop Decision; the unattributed pool only gets a quiet entry-seat notice.
962
+ if (itemID) await itemStalls.hit(userID, teamID, itemID, "budget")
963
+ else await delivery.notifyTeamEntry(userID, teamID, `This team's unattributed wake pool is spent (${limit} wakes per 24 h). Tag messages with their intake item, or wait; the pool opens no Decision.`)
964
+ },
965
+ })
966
+ /** (TA2-7) The effective item-loop threshold: the published team's override, else the hub-wide setting. */
967
+ const loopThreshold = async (userID: string, teamID: string): Promise<LoopThreshold> => (await teamStore.get(userID, teamID))?.loop ?? agentLimits.snapshot().loop!
968
+ const loopWindowMs = async (userID: string, teamID: string) => (await loopThreshold(userID, teamID)).windowMinutes * 60_000
969
+ const itemLoops = new LoopDetector({ thresholds: () => agentLimits.snapshot().loop!, now })
970
+ const hubReader = (userID: string) => ({ kind: "user" as const, userID, deviceID: "hub", via: "cookie" as const })
971
+ const itemStalls: ItemStalls = new ItemStalls({
972
+ state: backends.state,
973
+ now,
974
+ open: async (userID, teamID, item, reason, pair) => {
975
+ const team = await teamStore.require(userID, teamID)
976
+ const intake = await teamIntake!.get(userID, hubReader(userID), teamID, item)
977
+ const usage = await itemBudgets.usage(userID, teamID, item, intake.startedAt)
978
+ const question = itemDecisionQuestion({ userID, teamID, item, title: intake.title, reason, decisionID: "", ...(pair ? { pair } : {}), usage: { wakes: usage.wakes, hours: usage.hours }, limit: usage.budget })!
979
+ const about = pair ? [...pair] : intake.sessionID ? [intake.sessionID] : undefined
980
+ const { decision } = await decisions.ask(userID, { kind: "app", userID, tokenID: "team-item", scopes: [], label: "Hub team item budget" }, {
981
+ question: `Team #${team.name}: ${question}`,
982
+ options: [{ id: "extend", label: "Extend" }, { id: "stop", label: "Stop", safe: true }],
983
+ urgency: "high", tier: "tap", ...(about ? { about } : {}),
984
+ }, { action: { kind: "teamItem", teamID, item, reason, ...(pair ? { pair } : {}) } })
985
+ return decision.id
986
+ },
987
+ notice: async (userID, teamID, item, decisionID) => {
988
+ await delivery.notifyTeamEntry(userID, teamID, `Item ${item} is paused for the user (Decision ${decisionID}: Extend or Stop). Its messages still arrive without waking anyone; other items keep flowing.`)
989
+ },
990
+ })
991
+ /**
992
+ * (TA2-7) An item's Extend/Stop Decision ended unanswered. Closed by the hub: clear its stall. Expired: if the item is
993
+ * still open and still paused (budget still spent, or the pair still stopped), re-open ONE fresh Decision, once; after
994
+ * that the item stays paused with its stall recorded until the user acts. Otherwise the stall is cleared.
995
+ */
996
+ const itemDecisionEnded = async (userID: string, decision: Decision) => {
997
+ const action = decision.action
998
+ if (action?.kind !== "teamItem" || (decision.state !== "expired" && decision.state !== "closed")) return
999
+ const { teamID, item, reason, pair } = action
1000
+ // Lifting a stall also forgets the stopped pair's window, or its next message would stop it again at once.
1001
+ const lift = async () => {
1002
+ if (await itemStalls.clearFor(userID, teamID, item, decision.id) && pair) itemLoops.clear({ userID, teamID, item, from: pair[0], to: pair[1] })
1003
+ }
1004
+ if (decision.state === "closed") { await lift(); return }
1005
+ const intake = await teamIntake!.get(userID, hubReader(userID), teamID, item).catch(() => undefined)
1006
+ const open = intake !== undefined && (intake.status === "queued" || intake.status === "routed")
1007
+ let paused = open && reason === "loop"
1008
+ if (open && reason === "budget") {
1009
+ const usage = await itemBudgets.usage(userID, teamID, item, intake.startedAt)
1010
+ paused = usage.wakes >= usage.budget.wakes || usage.hours >= usage.budget.hours
1011
+ }
1012
+ if (!paused) { await lift(); return }
1013
+ await itemStalls.reopen(userID, teamID, item, decision.id)
1014
+ }
1015
+ /** (TA2-7) The Extend/Stop ports for one answer: Stop fails the intake as the user who tapped it (their device). */
1016
+ const itemDecisionsFor = (teamID: string, by: { readonly userID: string; readonly deviceID: string }) => new ItemDecisions({
1017
+ matches: async (userID, team, item, decisionID) => (await itemStalls.get(userID, team, item))?.decisionID === decisionID,
1018
+ clearStall: async (userID, item, decisionID) => {
1019
+ if ((await itemStalls.get(userID, teamID, item))?.decisionID === decisionID) await itemStalls.clear(userID, teamID, item)
1020
+ },
1021
+ extend: (userID, team, item, decisionID) => itemBudgets.extend(userID, team, item, decisionID),
1022
+ clearLoop: async (userID, item, pair) => { itemLoops.clear({ userID, teamID, item, from: pair[0], to: pair[1] }) },
1023
+ involved: (userID, act) => teamInvolvement.involved(userID, act),
1024
+ stop: async (userID, team, item, reason) => {
1025
+ const actor = { kind: "user" as const, userID, deviceID: by.deviceID, via: "cookie" as const }
1026
+ const current = await teamIntake!.get(userID, actor, team, item)
1027
+ if (current.status === "failed" || current.status === "cancelled") return
1028
+ // A still-queued item can only be cancelled (intake rule); a routed one fails. Either way, as the user, with the reason.
1029
+ await teamIntake!.setStatus(userID, actor, team, item, { status: current.status === "queued" ? "cancelled" : "failed", expectedRevision: current.revision, reason })
1030
+ },
1031
+ audit: async ({ userID, actor, deviceID, verb, reason }) => { await audit.record(userID, { actor, deviceID, verb, target: {}, decision: "allow", outcome: "ok", reason }) },
1032
+ })
1033
+ /**
1034
+ * (TA2-5 → TA2-7/8, decision 90utvfcb) Every qualifying progress signal restarts the item's loop windows, so real work
1035
+ * isn't flagged as a loop. Hop depth is different: the hub sees THAT something was written, not whether it was useful,
1036
+ * so only the seat's first move of an item into in_progress (once per item) resets the charged seats' depth; an
1037
+ * artifact, a diff or a console answer never does (a console act already resets the whole team, phase 1).
1038
+ * Cluster caveat: loop windows live in this pod's memory (best-effort across restarts and pods); the item budget is
1039
+ * stored under CAS and is the authoritative bound.
1040
+ */
1041
+ const onProgress = async (signal: ProgressSignal) => {
1042
+ itemLoops.progress(signal)
1043
+ if (signal.kind !== "intake" || !signal.item) return
1044
+ try {
1045
+ const charged = await itemBudgets.charged(signal.userID, signal.teamID, signal.item, await loopWindowMs(signal.userID, signal.teamID))
1046
+ await teamInvolvement.progress(signal.userID, signal.teamID, signal.item, charged)
1047
+ } catch (cause) { console.error("[hub] item progress reset failed:", cause) }
1048
+ }
1049
+ const seatContext = async (userID: string, sessionID: string) => {
1050
+ const teams: { teamID: string; item?: string }[] = []
1051
+ for (const { teamID } of await teamSeats!.seatsOf(userID, sessionID)) {
1052
+ const item = await itemBudgets.current(userID, teamID, sessionID).catch(() => undefined)
1053
+ teams.push({ teamID, ...(item ? { item } : {}) })
1054
+ }
1055
+ return { teams }
1056
+ }
1057
+ /** (TA2-5) A seat's turn ended (the journal's working → idle|error, the context cards' definition): sample its diff. */
1058
+ const turnEndedDiff = async (userID: string, event: Parameters<typeof cardTriggerOf>[0]) => {
1059
+ const trigger = cardTriggerOf(event)
1060
+ if (trigger?.trigger === "turn-end") await diffSampler.turnEnded(userID, trigger.sessionID).catch((cause) => console.error("[hub] diff progress failed:", cause))
1061
+ }
1062
+ const progressSignals = new ProgressSignals({ seatContext, emit: onProgress, now, loopWindowMs: (userID, teamID) => loopWindowMs(userID, teamID) })
1063
+ const diffSampler = new DiffSampler({
1064
+ seats: async (userID, sessionID) => (await seatContext(userID, sessionID)).teams,
1065
+ summary: async (userID, sessionID) => (registry.sessionTenant(sessionID) === userID ? ((await spokes.request(ownerOf(sessionID), { op: "summary", sessionID })) as SessionSummary) : undefined),
1066
+ emit: onProgress,
1067
+ now,
1068
+ loopWindowMs: (userID, teamID) => loopWindowMs(userID, teamID),
1069
+ })
1070
+ const itemChecks = itemEligibility({ seats: teamSeats!, teams: teamStore, intake: { get: (userID, actor, teamID, itemID) => teamIntake!.get(userID, actor, teamID, itemID) } as Pick<TeamIntakeService, "get"> })
1071
+ const delivery: Delivery = new Delivery({
1072
+ itemBudgets,
1073
+ items: itemChecks,
1074
+ // (TA2-7) Every item-tagged pair message counts against the effective threshold; a stop opens the item's Decision.
1075
+ itemLoop: async (userID, { teamID, item, from, to }) => {
1076
+ const stall = await itemStalls.get(userID, teamID, item).catch(() => undefined)
1077
+ if (stall?.reason === "loop" && stall.pair?.includes(from) && stall.pair.includes(to)) return true
1078
+ if (!itemLoops.message({ userID, teamID, item, from, to }, await loopThreshold(userID, teamID))) return false
1079
+ await itemStalls.hit(userID, teamID, item, "loop", [from, to].sort() as [string, string]).catch((cause) => console.error("[hub] item loop Decision failed:", cause))
1080
+ return true
1081
+ },
1082
+ hopLimit: config.hopLimit,
1083
+ currentHopLimit: () => agentLimits.snapshot().hopLimit,
905
1084
  teams: teamPolicy,
1085
+ teamHopCap: async (userID, input) => { await teamHopStalls.hit(userID, input.teamID, input.sender, input.recipient, input.limit) },
1086
+ teamHopNotice: async (userID, teamID) => {
1087
+ const team = await teamStore.get(userID, teamID)
1088
+ return (await teamSeats!.list(userID, teamID, { current: true })).find((seat) => seat.role === team?.entryRole)?.sessionID
1089
+ },
906
1090
  // CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
907
1091
  inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
908
1092
  state: backends.state,
@@ -937,7 +1121,12 @@ export function startCentral(options: CentralOptions): Central {
937
1121
  /** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
938
1122
  let conciergeTierHooks: ConciergeTiers | undefined
939
1123
  // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
940
- const decisions = new Decisions({
1124
+ decisions = new Decisions({
1125
+ answered: async (userID, decision, by) => {
1126
+ if (by.via === "cookie" && decision.from.kind === "session") await progressSignals.consoleAnswered(userID, { sessionID: decision.from.sessionID, via: by.via, ref: decision.id, kind: "decision" }).catch((cause) => console.error("[hub] decision progress failed:", cause))
1127
+ if (by.via === "cookie" && decision.action?.kind !== "teamHopRenew" && decision.action?.kind !== "teamItem" && decision.from.kind === "session")
1128
+ await teamInvolvement.involved(userID, { kind: "decision", sessionID: decision.from.sessionID, ref: decision.id, actor: { deviceID: by.deviceID, via: by.via } })
1129
+ },
941
1130
  state: backends.state,
942
1131
  delivery,
943
1132
  now,
@@ -946,6 +1135,7 @@ export function startCentral(options: CentralOptions): Central {
946
1135
  // The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
947
1136
  changed: (userID, decision) => {
948
1137
  broadcast({ type: "decision", decision }, { tenant: userID })
1138
+ if (decision.action?.kind === "teamItem") void itemDecisionEnded(userID, decision).catch((cause) => console.error("[hub] item Decision end failed:", cause))
949
1139
  conciergeTierHooks?.decisionChanged?.(userID, decision)
950
1140
  // (REC-01) decision.opened / decision.answered of a recorded session (best effort).
951
1141
  recordings?.decisionChanged(userID, decision)
@@ -954,6 +1144,21 @@ export function startCentral(options: CentralOptions): Central {
954
1144
  // (CON-06) Concierge proposals run their verb as the user (all set up below).
955
1145
  act: (userID, decision, choice, by) => {
956
1146
  const kind = decision.action?.kind
1147
+ if (kind === "teamHopRenew") {
1148
+ if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
1149
+ const teamID = decision.action!.teamID
1150
+ return choice.option === "renew"
1151
+ ? teamInvolvement.renew(userID, teamID, decision.id, { deviceID: by.deviceID, via: by.via }).then(() => "done" as const)
1152
+ : Promise.resolve("done" as const)
1153
+ }
1154
+ if (kind === "teamItem") {
1155
+ const action = decision.action as Extract<NonNullable<typeof decision.action>, { kind: "teamItem" }>
1156
+ if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
1157
+ return (async () => {
1158
+ const usage = await itemBudgets.usage(userID, action.teamID, action.item).catch(() => undefined)
1159
+ return itemDecisionsFor(action.teamID, by).answer({ userID, teamID: action.teamID, item: action.item, title: "", reason: action.reason, decisionID: decision.id, ...(action.pair ? { pair: action.pair } : {}), usage: { wakes: usage?.wakes ?? 0, hours: usage?.hours ?? 0 }, limit: usage?.budget ?? { wakes: 0, hours: 0 } }, choice.option, by)
1160
+ })()
1161
+ }
957
1162
  if (kind === "act") {
958
1163
  if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
959
1164
  return conciergeTierHooks.act(userID, decision, choice, by)
@@ -975,6 +1180,7 @@ export function startCentral(options: CentralOptions): Central {
975
1180
  const tenant = registry.sessionTenant(sessionID)
976
1181
  if (tenant === undefined) return
977
1182
  await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
1183
+ await teamHopStalls.clearForHuman(tenant, sessionID).catch((cause) => console.error(`[hub] team hop reset failed: ${(cause as Error).message}`))
978
1184
  }
979
1185
  registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
980
1186
  // Committed revert windows (./revert-windows): the hub message rows of a removed history stay hidden for good.
@@ -986,6 +1192,7 @@ export function startCentral(options: CentralOptions): Central {
986
1192
  void revertWindows.observe(tenant, sessionID, committed).catch((cause) => console.error(`[hub] revert window not kept: ${(cause as Error).message}`))
987
1193
  }
988
1194
  })
1195
+ // Spoke-origin human-turn evidence retains its existing per-seat reset only; it is not console authentication.
989
1196
  registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
990
1197
  // (Session findability, decision iaspcry7) "Touched by me" and the activity meter: per user, per session; the
991
1198
  // owner's consoles get each change as {type: "activity", sessionID, activity}.
@@ -1062,6 +1269,7 @@ export function startCentral(options: CentralOptions): Central {
1062
1269
  locks: backends.locks,
1063
1270
  owner: `hub-${process.pid}-${randomBytes(4).toString("hex")}`,
1064
1271
  ...(config.launchCaps ? { caps: config.launchCaps } : {}),
1272
+ currentCaps: () => agentLimits.snapshot().launches,
1065
1273
  nodes,
1066
1274
  nodeOps: spokes,
1067
1275
  registry,
@@ -1172,7 +1380,7 @@ export function startCentral(options: CentralOptions): Central {
1172
1380
  log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
1173
1381
  kv: backends.kv,
1174
1382
  now,
1175
- onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), contextCards?.onEvent(userID, record.event), subscriptions?.observe(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
1383
+ onAppend: (userID, record) => (void teamIntake?.onEvent(userID, record.event), void turnEndedDiff(userID, record.event), contextCards?.onEvent(userID, record.event), subscriptions?.observe(userID, record.event), conciergeOnAppend?.(userID, record.event), void conciergeActivity.observe(userID, record.event).catch(() => {}), void conciergeResults.observe(userID, record.event).catch(() => {}), webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))),
1176
1384
  })
1177
1385
  const agentEventWriters = new EventWriters({
1178
1386
  events: agentEvents,
@@ -1196,7 +1404,11 @@ export function startCentral(options: CentralOptions): Central {
1196
1404
  workspaces: artifactWorkspacesFromState(backends.state),
1197
1405
  ...(options.previewRetention?.archiveAfterMs !== undefined ? { previewArchiveAfterMs: options.previewRetention.archiveAfterMs } : {}),
1198
1406
  ...(options.previewRetention?.purgeAfterMs !== undefined ? { previewPurgeAfterMs: options.previewRetention.purgeAfterMs } : {}),
1199
- onEvent: (userID, event) => void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`)),
1407
+ onEvent: (userID, event) => {
1408
+ void agentEvents.append(userID, artifactJournalEvent(event)).catch((cause) => console.error(`[hub] artifact event failed: ${(cause as Error).message}`))
1409
+ if ((event.type === "artifact.created" || event.type === "artifact.revised") && event.authorSessionID)
1410
+ void progressSignals.artifactChanged(userID, event.artifactID, event.revision, event.authorSessionID).catch((cause) => console.error("[hub] artifact progress failed:", cause))
1411
+ },
1200
1412
  })
1201
1413
  : undefined
1202
1414
  const artifactMaintenance = artifacts ? new ArtifactMaintenance({ artifacts, users: allUsers }) : undefined
@@ -1313,6 +1525,23 @@ export function startCentral(options: CentralOptions): Central {
1313
1525
  audit: (entry) => store.audit(entry),
1314
1526
  users: allUsers,
1315
1527
  log: (message) => console.error(message),
1528
+ pod: config.cluster?.podID ?? "hub",
1529
+ // (Developer mode) What ran a turn: the session's model and context size from its spoke (summary), counts only.
1530
+ turnFacts: async (_userID, sessionID) => {
1531
+ const summary = (await spokes.request(ownerOf(sessionID), { op: "summary", sessionID })) as SessionSummary
1532
+ const model = summary?.model
1533
+ const context = summary?.context
1534
+ const agent = registry.session(sessionID)?.agent
1535
+ return {
1536
+ ...(model?.providerID ? { provider: model.providerID } : {}),
1537
+ ...(model?.modelID ? { model: model.modelID } : context?.model ? { model: context.model } : {}),
1538
+ ...(model?.variant ? { variant: model.variant } : {}),
1539
+ ...(agent ? { agent } : {}),
1540
+ ...(typeof context?.used === "number" ? { contextTokens: context.used } : {}),
1541
+ ...(typeof context?.limit === "number" ? { contextLimit: context.limit } : {}),
1542
+ }
1543
+ },
1544
+ ...(options.recordingDevLimits ? { devLimits: options.recordingDevLimits } : {}),
1316
1545
  })
1317
1546
  recordings = recordingService
1318
1547
  registry.recordingTap = recordingService.registryTap
@@ -1378,12 +1607,27 @@ export function startCentral(options: CentralOptions): Central {
1378
1607
  const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
1379
1608
  // Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
1380
1609
  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) })
1610
+ 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
1611
  registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
1383
1612
  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
1613
  // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
1385
1614
  // message inherits the item's notAfter (H4); queued items past it are cancelled by the team-intake leader's sweep.
1386
1615
  teamIntake = new TeamIntakeService({
1616
+ budgets: itemBudgets,
1617
+ parentOf: async (userID, teamID, sessionID) => {
1618
+ const current = await itemBudgets.current(userID, teamID, sessionID)
1619
+ if (!current) return undefined
1620
+ const eligible = await itemChecks.eligible(userID, current, [sessionID])
1621
+ if (!eligible.ok || eligible.teamID !== teamID) return undefined
1622
+ const root = eligible.parentItem ?? (eligible.child ? undefined : current)
1623
+ if (!root) return undefined
1624
+ if (root === current) return root
1625
+ const parent = await itemChecks.eligible(userID, root, [sessionID])
1626
+ return parent.ok && !parent.child && parent.teamID === teamID ? root : undefined
1627
+ },
1628
+ routed: (userID, teamID, intakeID, sessionID) => itemBudgets.setCurrent(userID, teamID, sessionID, intakeID),
1629
+ progressChanged: ({ userID, teamID, item, sessionID, revision, status, actor }) => progressSignals.intake({ userID, teamID, item, revision, status, actor: actor.kind === "session" ? { kind: "session", sessionID: actor.sessionID } : { kind: actor.kind }, ...(sessionID ? { sessionID } : {}) }),
1630
+ submittedByUser: (userID, teamID, intakeID, actor) => actor.via === "cookie" ? teamInvolvement.involved(userID, { kind: "intake", teamIDs: [teamID], ref: intakeID, actor: { deviceID: actor.deviceID, via: actor.via } }) : Promise.resolve(),
1387
1631
  state: backends.state,
1388
1632
  teams: teamStore,
1389
1633
  seats: teamSeats,
@@ -1435,6 +1679,8 @@ export function startCentral(options: CentralOptions): Central {
1435
1679
  const subscriptionLeader = leader("subscriptions", { onElected: () => subscriptions?.start(), onDeposed: () => subscriptions?.stop() })
1436
1680
  subscriptions = new Subscriptions({
1437
1681
  state: backends.state,
1682
+ hopLimit: config.hopLimit,
1683
+ currentHopLimit: () => agentLimits.snapshot().hopLimit,
1438
1684
  events: agentEvents,
1439
1685
  send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
1440
1686
  hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
@@ -1744,7 +1990,7 @@ export function startCentral(options: CentralOptions): Central {
1744
1990
  conversationOf: (sessionID) => registry.conversationOf(sessionID),
1745
1991
  stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
1746
1992
  }
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 } }
1993
+ const agentDeps = { nodes, agentLimits, 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
1994
  const resolveActor = createResolver({
1749
1995
  cookieName: COOKIE,
1750
1996
  findDevice: (token) => deviceOfToken(token),
@@ -1777,9 +2023,28 @@ export function startCentral(options: CentralOptions): Central {
1777
2023
 
1778
2024
  async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
1779
2025
  const url = new URL(request.url)
2026
+ // /api/v1/<rest> is /api/<rest> of the two route tables, its errors in one envelope (./api-v1.ts). The handlers
2027
+ // get the unversioned URL and the request itself (server.timeout and the body belong to that object).
2028
+ const unversioned = unversionedURL(url)
2029
+ if (!unversioned) return await respond(request, server, url, false)
2030
+ return await apiV1Response((await respond(request, server, unversioned, true)) ?? error(404, "Not found"))
2031
+ }
2032
+
2033
+ async function respond(request: Request, server: Server<SpokeSocketData>, url: URL, v1: boolean): Promise<Response | undefined> {
1780
2034
  const { pathname } = url
1781
2035
  const method = request.method
2036
+ if (!agentLimits.initialLoaded() && !((method === "GET" || method === "HEAD") && (pathname === "/healthz" || pathname === "/readyz"))) {
2037
+ let timer: ReturnType<typeof setTimeout> | undefined
2038
+ await Promise.race([agentLimitsReady, new Promise<void>((resolve) => { timer = setTimeout(resolve, options.timing?.agentLimitsInitialWaitMs ?? 5_000) })])
2039
+ if (timer) clearTimeout(timer)
2040
+ if (!agentLimits.initialLoaded()) return Response.json({ error: "Agent limits unavailable", code: "busy" }, { status: 503, headers: { ...NO_STORE, "retry-after": String(agentLimits.retryAfter()) } })
2041
+ }
1782
2042
  try {
2043
+ // A /api/v1 request reaches the two route tables only: none of the endpoints below and never a static file.
2044
+ if (v1) {
2045
+ await store.ready()
2046
+ return isAgentPath(pathname) ? await agent.handle(request, url) : await api(request, server, url)
2047
+ }
1783
2048
  // HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
1784
2049
  if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
1785
2050
  const response = pathname === "/readyz" ? await readyz() : healthz()
@@ -2026,16 +2291,18 @@ export function startCentral(options: CentralOptions): Central {
2026
2291
  await store.removeDevice(device.id)
2027
2292
  return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
2028
2293
  }),
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
- }),
2294
+ route("POST", "/api/transcribe", "user", ({ request, user, device }) => requestLined(user.id, device.id, request, "transcribe", () => transcribe(request, speechFor(user.id), user.id, device.id))),
2295
+ route("POST", "/api/speak", "user", ({ request, user, device }) =>
2296
+ requestLined(user.id, device.id, request, "speak", async () => {
2297
+ const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
2298
+ if (!text) return error(400, "Nothing to speak")
2299
+ const speech = speechFor(user.id)
2300
+ // (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; in Developer mode the audio is kept too.
2301
+ const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
2302
+ const audio = await speakAudio(speech, text, request.signal)
2303
+ return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
2304
+ }),
2305
+ ),
2039
2306
  // (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
2040
2307
  ...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
2308
  route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
@@ -2151,7 +2418,7 @@ export function startCentral(options: CentralOptions): Central {
2151
2418
  registry.setPresence(device.id, params.session!, visible)
2152
2419
  return new Response(null, { status: 204 })
2153
2420
  }),
2154
- route("POST", "/api/sessions/:session/turn", "session", ({ request, server, params }) => turn(request, server, params.session!)),
2421
+ route("POST", "/api/sessions/:session/turn", "session", ({ request, server, params, device }) => turn(request, server, params.session!, device.id)),
2155
2422
  route("POST", "/api/sessions/:session/utterance", "session", ({ request, params, device }) => utterance(request, params.session!, device.id), true),
2156
2423
  route(
2157
2424
  "POST",
@@ -2261,8 +2528,8 @@ export function startCentral(options: CentralOptions): Central {
2261
2528
  "session",
2262
2529
  async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
2263
2530
  ),
2264
- route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user }) => permission(request, params.session!, user.id)),
2265
- route("POST", "/api/sessions/:session/form", "session", ({ request, params, user }) => form(request, params.session!, user.id)),
2531
+ route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user, device }) => permission(request, params.session!, user.id, device.id)),
2532
+ route("POST", "/api/sessions/:session/form", "session", ({ request, params, user, device }) => form(request, params.session!, user.id, device.id)),
2266
2533
  route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
2267
2534
  Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
2268
2535
  // Context cards (docs/context-cards.md): a session's cards, and a provider's manual refresh.
@@ -2336,6 +2603,13 @@ export function startCentral(options: CentralOptions): Central {
2336
2603
  await store.audit({ actor: user.id, action: enabled ? "agents.acting.resume" : "agents.acting.pause" })
2337
2604
  return Response.json({ enabled }, { headers: NO_STORE })
2338
2605
  }),
2606
+ // RB-1 (plan sjaqp2ay): a team's hop depths, and the user's Renew button. Renew is cookie only (a device token
2607
+ // gets 403; session, app and Concierge tokens never reach console routes) and strict: a failed reset → 500.
2608
+ route("GET", "/api/teams/:id/hops", "user", async ({ params, user }) => teamHopsAnswer(user.id, params.id!)),
2609
+ route("POST", "/api/teams/:id/renew", "user", async ({ request, params, user, device }) => {
2610
+ if ((await resolveActor.mode(request)) !== "cookie") return error(403, "Renew a team from the console", "forbidden")
2611
+ return teamHopsAnswer(user.id, params.id!, (teamID) => teamInvolvement.renewManual(user.id, teamID, { deviceID: device.id, via: "cookie" }))
2612
+ }),
2339
2613
  // Decisions (asks to @me): the user's own; answered only here (tap: the console cookie, not a device token).
2340
2614
  route("GET", "/api/decisions", "user", async ({ url, user }) => {
2341
2615
  try {
@@ -2356,13 +2630,28 @@ export function startCentral(options: CentralOptions): Central {
2356
2630
  try {
2357
2631
  const answered = await decisions.answer(user.id, params.id!, decisionAnswer(input), { kind: "user", userID: user.id, deviceID: device.id, via })
2358
2632
  if (answered.reply) feed.publish(user.id, answered.reply)
2359
- await store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: answered.decision.answer?.option ?? "text" })
2633
+ await store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: answerDetail(answered.decision.answer) })
2360
2634
  return Response.json(answered, { headers: NO_STORE })
2361
2635
  } catch (cause) {
2362
2636
  return decisionFailure(cause)
2363
2637
  }
2364
2638
  }),
2365
2639
  route("GET", "/api/settings/speech/default", "admin", () => Response.json(speechSettings.view(), { headers: NO_STORE })),
2640
+ route("GET", "/api/settings/agent-limits", "admin", async () => Response.json(await agentLimits.view(), { headers: NO_STORE })),
2641
+ route("PUT", "/api/settings/agent-limits", "admin", async ({ request, user }) => {
2642
+ // (HC-1) The safety limits change only from the signed-in console (the cookie), never a device token.
2643
+ if ((await resolveActor.mode(request)) !== "cookie") return error(403, "Agent limits change only in the signed-in console", "forbidden", NO_STORE)
2644
+ if (!agentLimits.initialLoaded()) return Response.json({ error: "Agent limits unavailable", code: "busy" }, { status: 503, headers: { ...NO_STORE, "retry-after": String(agentLimits.retryAfter()) } })
2645
+ const before = await agentLimits.view()
2646
+ try {
2647
+ const after = await agentLimits.update(await jsonBody(request))
2648
+ await store.audit({ actor: user.id, action: "agents.limits.set", detail: JSON.stringify({ before: before.invalidStored ? "invalid" : before, after }) })
2649
+ return Response.json(after, { headers: NO_STORE })
2650
+ } catch (cause) {
2651
+ if (cause instanceof AgentError) return error(AGENT_ERROR_STATUS[cause.code], cause.message, cause.code, NO_STORE)
2652
+ throw cause
2653
+ }
2654
+ }),
2366
2655
  route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
2367
2656
  const view = await speechSettings.update(await jsonBody(request))
2368
2657
  await store.audit({ actor: user.id, action: "speech.default" })
@@ -2435,6 +2724,22 @@ export function startCentral(options: CentralOptions): Central {
2435
2724
  return new Response(null, { status: 204 })
2436
2725
  }
2437
2726
 
2727
+ /** The team's effective hop limit, its seats' depths and whether a hop-cap Decision is open (after `before`). */
2728
+ async function teamHopsAnswer(userID: string, id: string, before?: (teamID: string) => Promise<void>): Promise<Response> {
2729
+ try {
2730
+ const team = (await teamStore.get(userID, id)) ?? (await teamStore.byName(userID, id))
2731
+ if (!team) return error(404, "No such team", "not_found", NO_STORE)
2732
+ await before?.(team.id)
2733
+ const global = agentLimits.snapshot().hopLimit
2734
+ const current = await teamSeats!.list(userID, team.id, { current: true })
2735
+ const seats = await Promise.all(current.map(async (seat) => ({ sessionID: seat.sessionID, role: seat.role, depth: await delivery.hops.depth(userID, { kind: "session", sessionID: seat.sessionID }) })))
2736
+ return Response.json({ limit: Math.min(team.hopLimit ?? global, global), maxDepth: Math.max(0, ...seats.map((seat) => seat.depth)), seats, stalled: await teamHopStalls.open(userID, team.id) }, { headers: NO_STORE })
2737
+ } catch (cause) {
2738
+ if (cause instanceof AgentError) return error(AGENT_ERROR_STATUS[cause.code], cause.message, cause.code, NO_STORE)
2739
+ throw cause
2740
+ }
2741
+ }
2742
+
2438
2743
  /** A connect-flow handler's AgentError as the console's error envelope. */
2439
2744
  async function connectAnswer(run: () => Promise<Response>): Promise<Response> {
2440
2745
  try {
@@ -2526,7 +2831,7 @@ export function startCentral(options: CentralOptions): Central {
2526
2831
  return Response.json(userRow(store.user(id)!))
2527
2832
  }
2528
2833
 
2529
- async function turn(request: Request, server: Server<SpokeSocketData>, sessionID: string): Promise<Response> {
2834
+ async function turn(request: Request, server: Server<SpokeSocketData>, sessionID: string, deviceID: string): Promise<Response> {
2530
2835
  const input = await body(request)
2531
2836
  const raw = input.text
2532
2837
  const text = typeof raw === "string" ? raw.trim() : ""
@@ -2545,6 +2850,8 @@ export function startCentral(options: CentralOptions): Central {
2545
2850
  const started = await actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
2546
2851
  if (started.outcome === "offline") throw new HttpError(409, NO_OWNER)
2547
2852
  if (started.outcome === "unsupported") throw unsupportedError(started)
2853
+ const tenant = registry.sessionTenant(sessionID)
2854
+ if (tenant !== undefined && (await resolveActor.mode(request)) === "cookie") await teamInvolvement.involved(tenant, { kind: "prompt", sessionID, ref: "console-turn", actor: { deviceID, via: "cookie" } })
2548
2855
  if (started.mode === "steer") return Response.json({ mode: "steer", inboxID: started.inboxID }, { status: 202 })
2549
2856
  const result = started.result
2550
2857
  if (!/(^|[\s,])respond-async\b/i.test(request.headers.get("prefer") ?? "")) return Response.json(await result)
@@ -2555,7 +2862,6 @@ export function startCentral(options: CentralOptions): Central {
2555
2862
  if ("cause" in early) throw early.cause
2556
2863
  return Response.json(early.value)
2557
2864
  }
2558
- const tenant = registry.sessionTenant(sessionID)
2559
2865
  void result.then(
2560
2866
  (value) => broadcast({ type: "turn", sessionID, ...value }, { tenant }),
2561
2867
  (cause: unknown) => {
@@ -2837,7 +3143,10 @@ export function startCentral(options: CentralOptions): Central {
2837
3143
  const text = typeof raw === "string" ? raw.trim() : ""
2838
3144
  if (!text) return error(400, "Empty utterance")
2839
3145
  if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
2840
- return Response.json({ inboxID: await sendUtterance(sessionID, text, undefined, deviceID) }, { status: 202 })
3146
+ const inboxID = await sendUtterance(sessionID, text, undefined, deviceID)
3147
+ const tenant = registry.sessionTenant(sessionID)
3148
+ if (tenant !== undefined && (await resolveActor.mode(request)) === "cookie") await teamInvolvement.involved(tenant, { kind: "prompt", sessionID, ref: "utterance", actor: { ...(deviceID ? { deviceID } : {}), via: "cookie" } })
3149
+ return Response.json({ inboxID }, { status: 202 })
2841
3150
  }
2842
3151
 
2843
3152
  /**
@@ -3120,7 +3429,7 @@ export function startCentral(options: CentralOptions): Central {
3120
3429
  const auditUserAnswer = (userID: string, verb: "approve" | "deny" | "answer", target: { sessionID: string; requestID?: string; formID?: string }) =>
3121
3430
  void audit.record(userID, { actor: { kind: "user" }, verb, target, decision: "allow", outcome: "ok" }).catch((cause: unknown) => console.error(`[hub] audit of a console answer failed: ${(cause as Error).message}`))
3122
3431
 
3123
- async function permission(request: Request, rootID: string, userID: string): Promise<Response> {
3432
+ async function permission(request: Request, rootID: string, userID: string, deviceID: string): Promise<Response> {
3124
3433
  const input = await body(request)
3125
3434
  const decision = input.decision as PermissionDecision
3126
3435
  if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
@@ -3128,12 +3437,16 @@ export function startCentral(options: CentralOptions): Central {
3128
3437
  const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
3129
3438
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
3130
3439
  if (answered.outcome === "done") auditUserAnswer(userID, decision === "reject" ? "deny" : "approve", { sessionID: asking, requestID: input.requestID })
3440
+ if (answered.outcome === "done" && (await resolveActor.mode(request)) === "cookie") {
3441
+ await teamInvolvement.involved(userID, { kind: "permission", sessionID: asking, ref: input.requestID, actor: { deviceID, via: "cookie" } })
3442
+ await progressSignals.consoleAnswered(userID, { sessionID: asking, via: "cookie", ref: `permission:${input.requestID}`, kind: "approval" }).catch((cause) => console.error("[hub] approval progress failed:", cause))
3443
+ }
3131
3444
  if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
3132
3445
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
3133
3446
  }
3134
3447
 
3135
3448
  /** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
3136
- async function form(request: Request, rootID: string, userID: string): Promise<Response> {
3449
+ async function form(request: Request, rootID: string, userID: string, deviceID: string): Promise<Response> {
3137
3450
  const input = await body(request)
3138
3451
  if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
3139
3452
  const cancel = input.cancel === true
@@ -3148,10 +3461,36 @@ export function startCentral(options: CentralOptions): Central {
3148
3461
  )
3149
3462
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
3150
3463
  if (answered.outcome === "done") auditUserAnswer(userID, "answer", { sessionID: asking, formID: input.formID })
3464
+ if (answered.outcome === "done" && (await resolveActor.mode(request)) === "cookie") {
3465
+ await teamInvolvement.involved(userID, { kind: "form", sessionID: asking, ref: input.formID, actor: { deviceID, via: "cookie" } })
3466
+ await progressSignals.consoleAnswered(userID, { sessionID: asking, via: "cookie", ref: `form:${input.formID}`, kind: "approval" }).catch((cause) => console.error("[hub] approval progress failed:", cause))
3467
+ }
3151
3468
  if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
3152
3469
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
3153
3470
  }
3154
3471
 
3472
+ /**
3473
+ * (Developer mode) A voice request naming a recording (x-hub-recording): its route, status and time are recorded
3474
+ * as a request line in that recording when it is a developer one (recordingService.requestLine). Never a body.
3475
+ */
3476
+ async function requestLined(userID: string, deviceID: string, request: Request, route: "transcribe" | "speak", handle: () => Promise<Response>): Promise<Response> {
3477
+ const recordingID = request.headers.get(RECORDING_HEADER)
3478
+ if (recordingID === null) return handle()
3479
+ const started = performance.now()
3480
+ let status = 500
3481
+ try {
3482
+ const res = await handle()
3483
+ status = res.status
3484
+ return res
3485
+ } catch (cause) {
3486
+ const s = (cause as { status?: unknown }).status
3487
+ status = cause instanceof SpeechError ? 502 : typeof s === "number" ? s : 500
3488
+ throw cause
3489
+ } finally {
3490
+ void recordingService.requestLine(userID, deviceID, recordingID, { route, method: "POST", status, ms: performance.now() - started }).catch(() => undefined)
3491
+ }
3492
+ }
3493
+
3155
3494
  async function transcribe(request: Request, speech: Speech, userID: string, deviceID: string): Promise<Response> {
3156
3495
  const format = audioFormat(request.headers.get("content-type"))
3157
3496
  if (!format) return error(415, `Unsupported audio type: ${request.headers.get("content-type")}`)
@@ -3163,9 +3502,10 @@ export function startCentral(options: CentralOptions): Central {
3163
3502
  // (REC-01) With x-hub-recording + x-hub-segment: the segment is kept for the recording (no second upload).
3164
3503
  const tap = await recordingService.transcribeTap(userID, deviceID, request.headers, audio, format).catch(() => undefined)
3165
3504
  const started = performance.now()
3166
- const text = await speech.transcribe(audio, format)
3505
+ const stats: TranscribeStats = {}
3506
+ const text = await (tap ? speech.transcribe(audio, format, stats) : speech.transcribe(audio, format))
3167
3507
  if (!tap) return Response.json({ text })
3168
- const result = await tap.finish(text, Math.round(performance.now() - started)).catch(() => "failed" as const)
3508
+ const result = await tap.finish(text, Math.round(performance.now() - started), stats).catch(() => "failed" as const)
3169
3509
  return Response.json({ text }, { headers: { [RECORDING_AUDIO_RESULT_HEADER]: result } })
3170
3510
  }
3171
3511
 
@@ -3294,6 +3634,7 @@ export function startCentral(options: CentralOptions): Central {
3294
3634
  }
3295
3635
 
3296
3636
  async function readyz(): Promise<Response> {
3637
+ if (!agentLimits.initialLoaded()) return Response.json({ ok: false, error: "Agent limits unavailable" }, { status: 503, headers: NO_STORE })
3297
3638
  if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
3298
3639
  if (config.cluster) {
3299
3640
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -3392,6 +3733,7 @@ export function startCentral(options: CentralOptions): Central {
3392
3733
  push,
3393
3734
  speechSettings,
3394
3735
  backends,
3736
+ agentLimits,
3395
3737
  delivery,
3396
3738
  decisions,
3397
3739
  acting,
@@ -3414,6 +3756,7 @@ export function startCentral(options: CentralOptions): Central {
3414
3756
  return draining
3415
3757
  },
3416
3758
  async stop() {
3759
+ await agentLimits.stop()
3417
3760
  // Step down first (releasing the leases), so another pod takes the timed jobs over at once.
3418
3761
  await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
3419
3762
  launches.shutdown()
@@ -3451,12 +3794,13 @@ const optionalState = (url: URL): { state?: Decision["state"] } => {
3451
3794
  }
3452
3795
 
3453
3796
  /** {option?, text?, via?} of a Decision answer (types only; Decisions validates). */
3454
- function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string; via?: AnswerVia } {
3797
+ function decisionAnswer(input: Record<string, unknown>): { option?: string; text?: string; picks?: Record<string, string>; via?: AnswerVia } {
3455
3798
  for (const key of ["option", "text"] as const) {
3456
3799
  if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
3457
3800
  }
3458
3801
  if (input.via !== undefined && !(ANSWER_VIAS as readonly unknown[]).includes(input.via)) throw new AgentError("invalid", `via is one of ${ANSWER_VIAS.join(", ")}`)
3459
- return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
3802
+ const picks = answerPicks(input.picks)
3803
+ return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(picks ? { picks } : {}), ...(input.via !== undefined ? { via: input.via as AnswerVia } : {}) }
3460
3804
  }
3461
3805
 
3462
3806
  /**