@huynle/hub 0.11.0 → 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 (102) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +19 -5
  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 +240 -83
  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/mcp.ts +20 -4
  12. package/packages/cli/src/team-intake-mcp.ts +2 -1
  13. package/packages/cli/src/teams-mcp.ts +7 -3
  14. package/packages/codex/package.json +1 -1
  15. package/packages/codex/src/sessions.ts +14 -3
  16. package/packages/node/package.json +1 -1
  17. package/packages/preset/package.json +1 -1
  18. package/packages/protocol/package.json +1 -1
  19. package/packages/protocol/schema/api/v1/_root.json +119 -0
  20. package/packages/protocol/schema/api/v1/account.json +419 -0
  21. package/packages/protocol/schema/api/v1/decisions.json +635 -0
  22. package/packages/protocol/schema/api/v1/discovery.json +110 -0
  23. package/packages/protocol/schema/api/v1/events.json +679 -0
  24. package/packages/protocol/schema/api/v1/session-actions.json +660 -0
  25. package/packages/protocol/schema/api/v1/sessions.json +2055 -0
  26. package/packages/protocol/schema/api/v1/speech.json +492 -0
  27. package/packages/protocol/schema/api/v1/unspecified.json +282 -0
  28. package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
  29. package/packages/protocol/schema/app-front/v1.schema.json +7 -3
  30. package/packages/protocol/src/api-v1.ts +8 -0
  31. package/packages/protocol/src/index.ts +2 -0
  32. package/packages/protocol/src/teams.ts +13 -1
  33. package/packages/runtime-openshell/package.json +1 -1
  34. package/packages/runtime-sdk/package.json +1 -1
  35. package/packages/sdk/package.json +1 -1
  36. package/packages/sdk/src/app-front.ts +2 -2
  37. package/packages/sdk/src/ask-fields.ts +76 -0
  38. package/packages/sdk/src/client.ts +11 -2
  39. package/packages/sdk/src/envelope.ts +3 -2
  40. package/packages/sdk/src/index.ts +1 -0
  41. package/packages/sdk/src/team-intake.ts +6 -0
  42. package/packages/sdk/src/teams.ts +2 -0
  43. package/packages/sdk/src/types.ts +36 -4
  44. package/packages/sdk/src/wake.ts +9 -4
  45. package/packages/spoke-sdk/package.json +1 -1
  46. package/src/central/agent/agent-limits.ts +185 -0
  47. package/src/central/agent/app-decisions.ts +6 -5
  48. package/src/central/agent/decisions.ts +130 -9
  49. package/src/central/agent/delivery.ts +134 -22
  50. package/src/central/agent/errors.ts +2 -1
  51. package/src/central/agent/events.ts +20 -2
  52. package/src/central/agent/launch-engine.ts +6 -3
  53. package/src/central/agent/message-limits.ts +5 -2
  54. package/src/central/agent/message-routes.ts +33 -6
  55. package/src/central/agent/messages.ts +7 -0
  56. package/src/central/agent/placement.ts +4 -2
  57. package/src/central/agent/read-routes.ts +5 -0
  58. package/src/central/agent/routes.ts +2 -0
  59. package/src/central/agent/subscriptions.ts +13 -4
  60. package/src/central/api-v1.ts +115 -0
  61. package/src/central/artifacts/store.ts +4 -2
  62. package/src/central/concierge/board-results.ts +2 -1
  63. package/src/central/concierge/notifier.ts +4 -2
  64. package/src/central/config.ts +20 -6
  65. package/src/central/health/classify.ts +209 -0
  66. package/src/central/http.ts +285 -23
  67. package/src/central/recording/index.ts +2 -1
  68. package/src/central/recording/schema.ts +2 -1
  69. package/src/central/registry.ts +34 -3
  70. package/src/central/teams/budgets.ts +144 -0
  71. package/src/central/teams/hop-stalls.ts +14 -6
  72. package/src/central/teams/intake.ts +38 -6
  73. package/src/central/teams/involvement.ts +95 -0
  74. package/src/central/teams/item-decisions.ts +60 -0
  75. package/src/central/teams/item-eligibility.ts +35 -0
  76. package/src/central/teams/item-stalls.ts +87 -0
  77. package/src/central/teams/loops.ts +40 -0
  78. package/src/central/teams/progress.ts +154 -0
  79. package/src/central/teams/routes.ts +2 -2
  80. package/src/central/teams/store.ts +21 -5
  81. package/src/central/teams/validation.ts +17 -6
  82. package/src/central/workspaces/coordinator.ts +3 -1
  83. package/src/spoke/hub-tool.ts +26 -11
  84. package/web/dist/assets/{ArtifactPage-C5MMqXLN.js → ArtifactPage-TbFJi59n.js} +1 -1
  85. package/web/dist/assets/{ArtifactPane-CXSCmhyJ.js → ArtifactPane-7D-M-Ich.js} +1 -1
  86. package/web/dist/assets/{ArtifactWorkbench-S0RxNGWy.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
  87. package/web/dist/assets/{Diagram-BGmyRb-R.js → Diagram-PvOPetOI.js} +1 -1
  88. package/web/dist/assets/{Library-CFmMbXEa.js → Library-Cdi83pOV.js} +2 -2
  89. package/web/dist/assets/{ProfilesDestination-7jpOc2HM.js → ProfilesDestination-cryC7P6j.js} +1 -1
  90. package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
  91. package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
  92. package/web/dist/assets/index-DA0gKIzv.js +33 -0
  93. package/web/dist/assets/live-B2IfKm37.js +5 -0
  94. package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
  95. package/web/dist/assets/{renderer2d-B2Nh39UN.js → renderer2d-CsZnF3_o.js} +1 -1
  96. package/web/dist/assets/{renderer3d-CTYbljHH.js → renderer3d-BIMA0wFn.js} +2 -2
  97. package/web/dist/assets/{safe-area-CkhEclGo.js → safe-area-dtrnw9NF.js} +1 -1
  98. package/web/dist/index.html +1 -1
  99. package/web/dist/assets/TeamsPage-C1NGrUR-.js +0 -1
  100. package/web/dist/assets/Watchtower-DnvlJaU0.js +0 -2
  101. package/web/dist/assets/index-Da8SKXMw.js +0 -33
  102. package/web/dist/assets/live-BmqLASSo.js +0 -5
@@ -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"
@@ -101,6 +103,15 @@ import { TeamSeatStore } from "./teams/seats"
101
103
  import { TeamStaffingService } from "./teams/staffing"
102
104
  import { intakeActor, TeamIntakeService } from "./teams/intake"
103
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"
104
115
  import { TeamStore } from "./teams/store"
105
116
  import { ActivityTracker } from "./concierge/activity"
106
117
  import { HumanActivity, withHumanActivity } from "./human-activity"
@@ -110,7 +121,7 @@ import { conciergeBoard } from "./concierge/board-routes"
110
121
  import { renderFrontBoard } from "./concierge/board-render"
111
122
  import { ContextPusher } from "./concierge/context"
112
123
  import { C1_ENTITLEMENTS } from "./concierge/entitlements"
113
- import { ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
124
+ import { cardTriggerOf, ContextCardService, contextCardEvent, type AutoTrigger } from "./context-cards/service"
114
125
  import { TRANSCRIPT_ROWS } from "./context-cards/inputs"
115
126
  import { CONTEXT_CARD_TRIGGERS } from "./../protocol"
116
127
  import type { ChatClient } from "./llm/chat"
@@ -204,6 +215,10 @@ export interface CentralOptions {
204
215
  readonly leaderTtlMs?: number
205
216
  /** How often the decision deadline and message re-wake loops run (see DECISION_TICK_MS, REWAKE_TICK_MS). */
206
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
207
222
  /** drain(): how long it waits for requests in flight, and the bye's retryAfterMs (see DRAIN_TIMEOUT_MS, DRAIN_RETRY_AFTER_MS). */
208
223
  readonly drainTimeoutMs?: number
209
224
  readonly drainRetryAfterMs?: number
@@ -226,6 +241,7 @@ export interface Central {
226
241
  readonly speechSettings: SpeechService
227
242
  /** Backends for new code (state, bus, event log, counters, locks, catalog, kv). */
228
243
  readonly backends: Backends
244
+ readonly agentLimits: AgentLimits
229
245
  /** Agent messages: store and delivery to sessions (the message routes use it). */
230
246
  readonly delivery: Delivery
231
247
  /** Decisions (ask @me): answered by the user on /api/decisions. */
@@ -470,6 +486,11 @@ export function startCentral(options: CentralOptions): Central {
470
486
  /** The bundle for the config (or the one given). Cluster mode never touches the state dir. */
471
487
  const ownBackends = !options.backends
472
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
+ })
473
494
  // Cluster mode: the records are rows in the shared StateStore, cached per pod (loaded on first use; no data
474
495
  // migration runs on start). Otherwise state.json in the state dir.
475
496
  const store = config.cluster
@@ -892,7 +913,7 @@ export function startCentral(options: CentralOptions): Central {
892
913
  // sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
893
914
  // Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
894
915
  let teamSeats: TeamSeatStore | undefined
895
- const teamStore = new TeamStore({ state: backends.state, now, globalHopLimit: config.hopLimit, 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) })
896
917
  const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
897
918
  teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
898
919
  const teamPolicy = new TeamPolicy({
@@ -909,9 +930,7 @@ export function startCentral(options: CentralOptions): Central {
909
930
  const teamHopStalls = new TeamHopStalls({
910
931
  state: backends.state,
911
932
  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") },
933
+ close: async (userID, decisionID, reason) => { await decisions.close(userID, decisionID, reason ?? "A person continued this team's work; renewal is no longer needed") },
915
934
  notice: (userID, teamID, decisionID) => delivery.notifyTeamHopCap(userID, teamID, decisionID),
916
935
  open: async (userID, teamID, sender, recipient, limit) => {
917
936
  const team = await teamStore.require(userID, teamID)
@@ -919,15 +938,149 @@ export function startCentral(options: CentralOptions): Central {
919
938
  const active = intake?.items.find((item) => item.status === "routed" && (item.sessionID === sender || item.sessionID === recipient))
920
939
  const detail = active ? `, intake ${active.id} (${active.title})` : ""
921
940
  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?`,
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?`,
923
942
  options: [{ id: "renew", label: "Renew" }, { id: "leave", label: "Leave stopped", safe: true }],
924
943
  urgency: "high", tier: "tap", about: [recipient],
925
944
  }, { action: { kind: "teamHopRenew", teamID } })
926
945
  return decision.id
927
946
  },
928
947
  })
929
- const delivery = new Delivery({
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
+ },
930
1082
  hopLimit: config.hopLimit,
1083
+ currentHopLimit: () => agentLimits.snapshot().hopLimit,
931
1084
  teams: teamPolicy,
932
1085
  teamHopCap: async (userID, input) => { await teamHopStalls.hit(userID, input.teamID, input.sender, input.recipient, input.limit) },
933
1086
  teamHopNotice: async (userID, teamID) => {
@@ -969,6 +1122,11 @@ export function startCentral(options: CentralOptions): Central {
969
1122
  let conciergeTierHooks: ConciergeTiers | undefined
970
1123
  // Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
971
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
+ },
972
1130
  state: backends.state,
973
1131
  delivery,
974
1132
  now,
@@ -977,6 +1135,7 @@ export function startCentral(options: CentralOptions): Central {
977
1135
  // The console's live Decision cards: opened, answered (here, by timeout), escalated, expired.
978
1136
  changed: (userID, decision) => {
979
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))
980
1139
  conciergeTierHooks?.decisionChanged?.(userID, decision)
981
1140
  // (REC-01) decision.opened / decision.answered of a recorded session (best effort).
982
1141
  recordings?.decisionChanged(userID, decision)
@@ -989,9 +1148,17 @@ export function startCentral(options: CentralOptions): Central {
989
1148
  if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
990
1149
  const teamID = decision.action!.teamID
991
1150
  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 })
1151
+ ? teamInvolvement.renew(userID, teamID, decision.id, { deviceID: by.deviceID, via: by.via }).then(() => "done" as const)
993
1152
  : Promise.resolve("done" as const)
994
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
+ }
995
1162
  if (kind === "act") {
996
1163
  if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
997
1164
  return conciergeTierHooks.act(userID, decision, choice, by)
@@ -1025,6 +1192,7 @@ export function startCentral(options: CentralOptions): Central {
1025
1192
  void revertWindows.observe(tenant, sessionID, committed).catch((cause) => console.error(`[hub] revert window not kept: ${(cause as Error).message}`))
1026
1193
  }
1027
1194
  })
1195
+ // Spoke-origin human-turn evidence retains its existing per-seat reset only; it is not console authentication.
1028
1196
  registry.onHumanTurn((sessionID) => void humanTurn(sessionID))
1029
1197
  // (Session findability, decision iaspcry7) "Touched by me" and the activity meter: per user, per session; the
1030
1198
  // owner's consoles get each change as {type: "activity", sessionID, activity}.
@@ -1101,6 +1269,7 @@ export function startCentral(options: CentralOptions): Central {
1101
1269
  locks: backends.locks,
1102
1270
  owner: `hub-${process.pid}-${randomBytes(4).toString("hex")}`,
1103
1271
  ...(config.launchCaps ? { caps: config.launchCaps } : {}),
1272
+ currentCaps: () => agentLimits.snapshot().launches,
1104
1273
  nodes,
1105
1274
  nodeOps: spokes,
1106
1275
  registry,
@@ -1211,7 +1380,7 @@ export function startCentral(options: CentralOptions): Central {
1211
1380
  log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
1212
1381
  kv: backends.kv,
1213
1382
  now,
1214
- 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))),
1215
1384
  })
1216
1385
  const agentEventWriters = new EventWriters({
1217
1386
  events: agentEvents,
@@ -1235,7 +1404,11 @@ export function startCentral(options: CentralOptions): Central {
1235
1404
  workspaces: artifactWorkspacesFromState(backends.state),
1236
1405
  ...(options.previewRetention?.archiveAfterMs !== undefined ? { previewArchiveAfterMs: options.previewRetention.archiveAfterMs } : {}),
1237
1406
  ...(options.previewRetention?.purgeAfterMs !== undefined ? { previewPurgeAfterMs: options.previewRetention.purgeAfterMs } : {}),
1238
- 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
+ },
1239
1412
  })
1240
1413
  : undefined
1241
1414
  const artifactMaintenance = artifacts ? new ArtifactMaintenance({ artifacts, users: allUsers }) : undefined
@@ -1440,6 +1613,21 @@ export function startCentral(options: CentralOptions): Central {
1440
1613
  // Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
1441
1614
  // message inherits the item's notAfter (H4); queued items past it are cancelled by the team-intake leader's sweep.
1442
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(),
1443
1631
  state: backends.state,
1444
1632
  teams: teamStore,
1445
1633
  seats: teamSeats,
@@ -1492,6 +1680,7 @@ export function startCentral(options: CentralOptions): Central {
1492
1680
  subscriptions = new Subscriptions({
1493
1681
  state: backends.state,
1494
1682
  hopLimit: config.hopLimit,
1683
+ currentHopLimit: () => agentLimits.snapshot().hopLimit,
1495
1684
  events: agentEvents,
1496
1685
  send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
1497
1686
  hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
@@ -1801,7 +1990,7 @@ export function startCentral(options: CentralOptions): Central {
1801
1990
  conversationOf: (sessionID) => registry.conversationOf(sessionID),
1802
1991
  stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
1803
1992
  }
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 } }
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 } }
1805
1994
  const resolveActor = createResolver({
1806
1995
  cookieName: COOKIE,
1807
1996
  findDevice: (token) => deviceOfToken(token),
@@ -1834,9 +2023,28 @@ export function startCentral(options: CentralOptions): Central {
1834
2023
 
1835
2024
  async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
1836
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> {
1837
2034
  const { pathname } = url
1838
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
+ }
1839
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
+ }
1840
2048
  // HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
1841
2049
  if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
1842
2050
  const response = pathname === "/readyz" ? await readyz() : healthz()
@@ -2210,7 +2418,7 @@ export function startCentral(options: CentralOptions): Central {
2210
2418
  registry.setPresence(device.id, params.session!, visible)
2211
2419
  return new Response(null, { status: 204 })
2212
2420
  }),
2213
- 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)),
2214
2422
  route("POST", "/api/sessions/:session/utterance", "session", ({ request, params, device }) => utterance(request, params.session!, device.id), true),
2215
2423
  route(
2216
2424
  "POST",
@@ -2320,8 +2528,8 @@ export function startCentral(options: CentralOptions): Central {
2320
2528
  "session",
2321
2529
  async ({ params }) => Response.json(await spokes.request(ownerOf(params.session!), { op: "context", sessionID: params.session! })),
2322
2530
  ),
2323
- route("POST", "/api/sessions/:session/permission", "session", ({ request, params, user }) => permission(request, params.session!, user.id)),
2324
- 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)),
2325
2533
  route("GET", "/api/sessions/:session/summary", "session", async ({ params }) =>
2326
2534
  Response.json(await spokes.request(ownerOf(params.session!), { op: "summary", sessionID: params.session! }), { headers: NO_STORE })),
2327
2535
  // Context cards (docs/context-cards.md): a session's cards, and a provider's manual refresh.
@@ -2395,6 +2603,13 @@ export function startCentral(options: CentralOptions): Central {
2395
2603
  await store.audit({ actor: user.id, action: enabled ? "agents.acting.resume" : "agents.acting.pause" })
2396
2604
  return Response.json({ enabled }, { headers: NO_STORE })
2397
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
+ }),
2398
2613
  // Decisions (asks to @me): the user's own; answered only here (tap: the console cookie, not a device token).
2399
2614
  route("GET", "/api/decisions", "user", async ({ url, user }) => {
2400
2615
  try {
@@ -2415,13 +2630,28 @@ export function startCentral(options: CentralOptions): Central {
2415
2630
  try {
2416
2631
  const answered = await decisions.answer(user.id, params.id!, decisionAnswer(input), { kind: "user", userID: user.id, deviceID: device.id, via })
2417
2632
  if (answered.reply) feed.publish(user.id, answered.reply)
2418
- 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) })
2419
2634
  return Response.json(answered, { headers: NO_STORE })
2420
2635
  } catch (cause) {
2421
2636
  return decisionFailure(cause)
2422
2637
  }
2423
2638
  }),
2424
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
+ }),
2425
2655
  route("PUT", "/api/settings/speech/default", "admin", async ({ request, user }) => {
2426
2656
  const view = await speechSettings.update(await jsonBody(request))
2427
2657
  await store.audit({ actor: user.id, action: "speech.default" })
@@ -2494,6 +2724,22 @@ export function startCentral(options: CentralOptions): Central {
2494
2724
  return new Response(null, { status: 204 })
2495
2725
  }
2496
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
+
2497
2743
  /** A connect-flow handler's AgentError as the console's error envelope. */
2498
2744
  async function connectAnswer(run: () => Promise<Response>): Promise<Response> {
2499
2745
  try {
@@ -2585,7 +2831,7 @@ export function startCentral(options: CentralOptions): Central {
2585
2831
  return Response.json(userRow(store.user(id)!))
2586
2832
  }
2587
2833
 
2588
- 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> {
2589
2835
  const input = await body(request)
2590
2836
  const raw = input.text
2591
2837
  const text = typeof raw === "string" ? raw.trim() : ""
@@ -2604,6 +2850,8 @@ export function startCentral(options: CentralOptions): Central {
2604
2850
  const started = await actions.prompt({ sessionID, text, ...(images ? { images } : {}), resetHops: true })
2605
2851
  if (started.outcome === "offline") throw new HttpError(409, NO_OWNER)
2606
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" } })
2607
2855
  if (started.mode === "steer") return Response.json({ mode: "steer", inboxID: started.inboxID }, { status: 202 })
2608
2856
  const result = started.result
2609
2857
  if (!/(^|[\s,])respond-async\b/i.test(request.headers.get("prefer") ?? "")) return Response.json(await result)
@@ -2614,7 +2862,6 @@ export function startCentral(options: CentralOptions): Central {
2614
2862
  if ("cause" in early) throw early.cause
2615
2863
  return Response.json(early.value)
2616
2864
  }
2617
- const tenant = registry.sessionTenant(sessionID)
2618
2865
  void result.then(
2619
2866
  (value) => broadcast({ type: "turn", sessionID, ...value }, { tenant }),
2620
2867
  (cause: unknown) => {
@@ -2896,7 +3143,10 @@ export function startCentral(options: CentralOptions): Central {
2896
3143
  const text = typeof raw === "string" ? raw.trim() : ""
2897
3144
  if (!text) return error(400, "Empty utterance")
2898
3145
  if (text.length > MAX_UTTERANCE_CHARS) return error(400, `Utterance longer than ${MAX_UTTERANCE_CHARS} characters`)
2899
- 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 })
2900
3150
  }
2901
3151
 
2902
3152
  /**
@@ -3179,7 +3429,7 @@ export function startCentral(options: CentralOptions): Central {
3179
3429
  const auditUserAnswer = (userID: string, verb: "approve" | "deny" | "answer", target: { sessionID: string; requestID?: string; formID?: string }) =>
3180
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}`))
3181
3431
 
3182
- 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> {
3183
3433
  const input = await body(request)
3184
3434
  const decision = input.decision as PermissionDecision
3185
3435
  if (typeof input.requestID !== "string" || !DECISIONS.has(decision)) return error(400, "Invalid decision")
@@ -3187,12 +3437,16 @@ export function startCentral(options: CentralOptions): Central {
3187
3437
  const answered = await actions.answerPermission({ rootID, requestID: input.requestID, decision })
3188
3438
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
3189
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
+ }
3190
3444
  if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
3191
3445
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
3192
3446
  }
3193
3447
 
3194
3448
  /** Answers or cancels a pending form (question) in the session's tree, through a process that holds it. */
3195
- 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> {
3196
3450
  const input = await body(request)
3197
3451
  if (typeof input.formID !== "string" || !input.formID || input.formID.length > 300) return error(400, "Expected {formID, answer} or {formID, cancel: true}")
3198
3452
  const cancel = input.cancel === true
@@ -3207,6 +3461,10 @@ export function startCentral(options: CentralOptions): Central {
3207
3461
  )
3208
3462
  if (answered.outcome === "unsupported") throw unsupportedError(answered)
3209
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
+ }
3210
3468
  if (answered.outcome === "done") humanActivity.human(userID, registry.rootOf(rootID) ?? rootID, "answer")
3211
3469
  return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
3212
3470
  }
@@ -3376,6 +3634,7 @@ export function startCentral(options: CentralOptions): Central {
3376
3634
  }
3377
3635
 
3378
3636
  async function readyz(): Promise<Response> {
3637
+ if (!agentLimits.initialLoaded()) return Response.json({ ok: false, error: "Agent limits unavailable" }, { status: 503, headers: NO_STORE })
3379
3638
  if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
3380
3639
  if (config.cluster) {
3381
3640
  let timer: ReturnType<typeof setTimeout> | undefined
@@ -3474,6 +3733,7 @@ export function startCentral(options: CentralOptions): Central {
3474
3733
  push,
3475
3734
  speechSettings,
3476
3735
  backends,
3736
+ agentLimits,
3477
3737
  delivery,
3478
3738
  decisions,
3479
3739
  acting,
@@ -3496,6 +3756,7 @@ export function startCentral(options: CentralOptions): Central {
3496
3756
  return draining
3497
3757
  },
3498
3758
  async stop() {
3759
+ await agentLimits.stop()
3499
3760
  // Step down first (releasing the leases), so another pod takes the timed jobs over at once.
3500
3761
  await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
3501
3762
  launches.shutdown()
@@ -3533,12 +3794,13 @@ const optionalState = (url: URL): { state?: Decision["state"] } => {
3533
3794
  }
3534
3795
 
3535
3796
  /** {option?, text?, via?} of a Decision answer (types only; Decisions validates). */
3536
- 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 } {
3537
3798
  for (const key of ["option", "text"] as const) {
3538
3799
  if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
3539
3800
  }
3540
3801
  if (input.via !== undefined && !(ANSWER_VIAS as readonly unknown[]).includes(input.via)) throw new AgentError("invalid", `via is one of ${ANSWER_VIAS.join(", ")}`)
3541
- 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 } : {}) }
3542
3804
  }
3543
3805
 
3544
3806
  /**
@@ -735,7 +735,8 @@ export class RecordingService {
735
735
  if (this.opened.size > 2000) this.opened.delete(this.opened.values().next().value as string)
736
736
  this.hub(userID, sessionID, "decision.opened", { decisionID: decision.id, from: partyKey(from), tier: decision.tier, urgency: decision.urgency, options: decision.options.map((o) => o.id), text: decision.question })
737
737
  } else if (decision.state === "answered" && decision.answer?.answeredBy === "user") {
738
- this.hub(userID, sessionID, "decision.answered", { decisionID: decision.id, ...(decision.answer.option !== undefined ? { option: decision.answer.option } : {}), via: decision.answer.via ?? "tap" })
738
+ const picks = decision.answer.picks && Object.entries(decision.answer.picks).map(([group, option]) => ({ group, option }))
739
+ this.hub(userID, sessionID, "decision.answered", { decisionID: decision.id, ...(decision.answer.option !== undefined ? { option: decision.answer.option } : {}), ...(picks ? { picks } : {}), via: decision.answer.via ?? "tap" })
739
740
  }
740
741
  }
741
742
 
@@ -134,7 +134,8 @@ export const CONSOLE_FIELDS: Readonly<Record<ConsoleEventKind, Fields>> = {
134
134
  },
135
135
  "resource.timing": { route: oneOf("transcribe", "speak", "events", "utterance", "recording"), n: int, medianMs: num, maxMs: num, "medianTtfbMs?": num, "bytes?": int },
136
136
  "console.log": { level: oneOf("error", "warn", "unhandled"), message: text(L.messageChars), "count?": int, "source?": label },
137
- "decision.voice": { decisionID: id, option: id, outcome: oneOf("answered", "refused", "failed") },
137
+ // DC-1: a choices Decision is answered with picks (one per group) instead of an option.
138
+ "decision.voice": { decisionID: id, "option?": id, "picks?": list(object({ group: label, option: label }), 12), outcome: oneOf("answered", "refused", "failed") },
138
139
  "clock.sync": { t0: num, t1: num, hubAt: num },
139
140
  error: { where: oneOf("stt", "tts", "send", "mic", "upload", "audio"), message: text(L.messageChars) },
140
141
  }