@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
@@ -16,6 +16,8 @@ import type {
16
16
  CreatedAppToken,
17
17
  Decision,
18
18
  DecisionAnswered,
19
+ DecisionChoice,
20
+ DecisionKind,
19
21
  DecisionOption,
20
22
  DecisionState,
21
23
  DecisionTier,
@@ -287,6 +289,8 @@ export interface RoleOptions extends TargetOptions {
287
289
  }
288
290
 
289
291
  export interface MsgOptions extends CallOptions {
292
+ /** Explicit team intake item (itk_ ID), allowed only to a currently seated session. */
293
+ readonly item?: string
290
294
  /** false: admit without starting a turn. */
291
295
  readonly wake?: boolean
292
296
  /** Steer into a running turn. */
@@ -319,12 +323,16 @@ export interface AskOptions extends MsgOptions {
319
323
  readonly onTimeout?: string
320
324
  /** Addresses of the sessions it concerns. */
321
325
  readonly about?: readonly string[]
326
+ /** (DC-1) Structured choices instead of options: the user picks one option in each group (1–12 groups of 2–6). */
327
+ readonly choices?: readonly DecisionChoice[]
328
+ /** (DC-1) What it is about (default question). */
329
+ readonly kind?: DecisionKind
322
330
  }
323
331
 
324
332
  /** The Decision fields of AskOptions, when given. */
325
333
  const decisionFields = (options: AskOptions) => {
326
334
  const out: Record<string, unknown> = {}
327
- for (const key of ["options", "recommendation", "urgency", "tier", "deadline", "onTimeout", "about"] as const) if (options[key] !== undefined) out[key] = options[key]
335
+ for (const key of ["options", "recommendation", "choices", "kind", "urgency", "tier", "deadline", "onTimeout", "about"] as const) if (options[key] !== undefined) out[key] = options[key]
328
336
  return out
329
337
  }
330
338
 
@@ -355,6 +363,7 @@ const signalOf = (options: CallOptions) => (options.signal ? { signal: options.s
355
363
  const idempotent = (options: IdempotentOptions) => ({ ...signalOf(options), ...(options.idempotencyKey ? { headers: { "idempotency-key": options.idempotencyKey } } : {}) })
356
364
  /** The wake and urgent fields of a send, when given. */
357
365
  const sendFields = (options: MsgOptions) => ({
366
+ ...(options.item !== undefined ? { item: options.item } : {}),
358
367
  ...(options.wake !== undefined ? { wake: options.wake } : {}),
359
368
  ...(options.urgent !== undefined ? { urgent: options.urgent } : {}),
360
369
  ...(options.notAfter !== undefined ? { notAfter: options.notAfter } : {}),
@@ -727,7 +736,7 @@ export class HubClient {
727
736
  }
728
737
 
729
738
  /** Answers a Decision as the user (a device token; tap Decisions need the console): first answer wins, later ones are gone. */
730
- answerDecision(id: string, answer: CallOptions & { readonly option?: string; readonly text?: string }): Promise<DecisionAnswered> {
739
+ answerDecision(id: string, answer: CallOptions & { readonly option?: string; readonly text?: string; readonly picks?: Readonly<Record<string, string>> }): Promise<DecisionAnswered> {
731
740
  const { signal: _signal, ...body } = answer
732
741
  return this.call("POST", `/api/decisions/${segment(id)}/answer`, { body, ...signalOf(answer) })
733
742
  }
@@ -35,9 +35,10 @@ const ANSI =
35
35
  /**
36
36
  * C0 except tab/newline, DEL, C1, Arabic letter mark, zero-width and directional marks (U+200B-200F), line and
37
37
  * paragraph separators and bidi embeddings/overrides (U+2028-202E), word joiner, invisible operators and bidi
38
- * isolates (U+2060-206F), BOM.
38
+ * isolates (U+2060-206F), BOM, and every other format character (\p{Cf}: soft hyphen, Unicode TAG characters
39
+ * U+E0000-E007F, …).
39
40
  */
40
- const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g
41
+ const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff\p{Cf}]/gu
41
42
 
42
43
  /** Message text: newlines normalised, escape sequences and invisible/control characters removed. */
43
44
  export function sanitizeText(text: string): string {
@@ -2,6 +2,7 @@
2
2
  // errors (HubError, HubTransportError), the answer types, the SSE parser of the watch stream, and the pure
3
3
  // envelope renderer/sanitiser and address parser shared with the hub, launches, and webhook verification.
4
4
  export * from "./address.ts"
5
+ export * from "./ask-fields.ts"
5
6
  export * from "./app-front.ts"
6
7
  export * from "./artifacts.ts"
7
8
  export * from "./artifact-jobs.ts"
@@ -41,6 +41,12 @@ export interface TeamIntake {
41
41
  readonly lastError?: string
42
42
  readonly updatedAt: number
43
43
  readonly routedAt?: number
44
+ /** First active work (routed or in progress); blocked time still counts. */
45
+ readonly startedAt?: number
46
+ /** Session-submitted child; its budget is charged to this root item. */
47
+ readonly parentItem?: string
48
+ /** Per-item usage at the time of this read (hubs with team item budgets). */
49
+ readonly usage?: { readonly wakes: number; readonly hours: number; readonly generation: number; readonly budget: { readonly wakes: number; readonly hours: number }; readonly chargedTo?: string }
44
50
  readonly closedAt?: number
45
51
  }
46
52
 
@@ -54,6 +54,8 @@ export interface TeamSpec {
54
54
  readonly description?: string
55
55
  readonly mode: TeamMode
56
56
  readonly hopLimit?: number
57
+ readonly itemBudget?: { readonly wakes: number; readonly hours: number }
58
+ readonly loop?: { readonly messages: number; readonly windowMinutes: number }
57
59
  readonly roles: readonly TeamRole[]
58
60
  /** The intake role (e.g. the project manager): receives submitted work and supervises members. */
59
61
  readonly entryRole: string
@@ -43,6 +43,8 @@ export interface SessionView {
43
43
  readonly harness?: string
44
44
  readonly parentID?: string
45
45
  readonly busySince?: number
46
+ /** (SS-1, hub ≥ this release) Why the session is not making progress; absent: ok (or an older hub). */
47
+ readonly health?: SessionHealth
46
48
  readonly now?: string
47
49
  readonly blockers?: readonly Blocker[]
48
50
  readonly children?: number
@@ -54,6 +56,18 @@ export interface SessionView {
54
56
  readonly intent?: IntentField
55
57
  }
56
58
 
59
+ /**
60
+ * (SS-1) A session's health next to its state (a session retrying a provider request stays "working"). Kinds a client
61
+ * does not know should be shown as-is. `detail` is the provider's message, sanitised (≤ 200 characters).
62
+ */
63
+ export interface SessionHealth {
64
+ readonly kind: "ok" | "retrying" | "stalled" | "quota" | "auth" | "model_unavailable" | "context_overflow" | "network" | "failed" | (string & {})
65
+ readonly since?: number
66
+ readonly resetAt?: number
67
+ readonly attempt?: number
68
+ readonly detail?: string
69
+ }
70
+
57
71
  /** (CON-02) Who set a session's intent, highest precedence first. */
58
72
  export type IntentSource = "user" | "launch" | "self" | "prompt" | "title"
59
73
 
@@ -291,6 +305,8 @@ export interface AgentMessage {
291
305
  /** The role holder generation it reached (role addresses). */
292
306
  readonly generation?: number
293
307
  readonly text: string
308
+ /** The explicitly attributed team intake item (when the sender supplied one). */
309
+ readonly item?: string
294
310
  readonly replyTo?: string
295
311
  readonly state: MessageState
296
312
  readonly at: number
@@ -308,10 +324,11 @@ export interface AgentMessage {
308
324
 
309
325
  /**
310
326
  * Why a message to a session did not start a turn: the sender asked wake:false, the recipient is a subagent
311
- * (never woken), the reply chain went past the hop cap (8 hops without a human turn), or the recipient's wake
312
- * budget (30 per 10 minutes) is spent. The message is still delivered.
327
+ * (never woken), the reply chain passed the hub's effective hop cap (agents.hopLimit under Settings → Admin,
328
+ * optionally narrowed by the team), or the recipient's wake budget was spent.
329
+ * The message is still delivered; wakeLimit carries the effective cap or budget when the hub knows it.
313
330
  */
314
- export type WakeSkip = "wake_false" | "subagent" | "hop_cap" | "budget"
331
+ export type WakeSkip = "wake_false" | "subagent" | "hop_cap" | "budget" | "item_budget" | "item_loop"
315
332
 
316
333
  /** POST /api/agent/msg and /ask: the stored message and how it was delivered (none: stored only). */
317
334
  export interface Sent {
@@ -321,6 +338,8 @@ export interface Sent {
321
338
  readonly woke?: boolean
322
339
  /** When woke is false: why. */
323
340
  readonly wakeSkipped?: WakeSkip
341
+ /** Effective hop cap or per-10-minute wake budget, with wakeSkipped hop_cap or budget (absent on older hubs). */
342
+ readonly wakeLimit?: number
324
343
  }
325
344
 
326
345
  /** POST /api/agent/msg or /ask with all: one per session. */
@@ -342,6 +361,17 @@ export interface DecisionOption {
342
361
  readonly safe?: boolean
343
362
  }
344
363
 
364
+ /** (DC-1) One point of a structured ask: the user picks one of its 2–6 options (at most one recommended). */
365
+ export interface DecisionChoice {
366
+ /** 1–8 letters, digits, - or _ (e.g. D1). */
367
+ readonly id: string
368
+ readonly label: string
369
+ readonly options: readonly { readonly id: string; readonly label: string; readonly recommended?: boolean }[]
370
+ }
371
+
372
+ /** (DC-1) What an ask is about (absent: a question). */
373
+ export type DecisionKind = "question" | "plan" | "merge" | "release"
374
+
345
375
  export type DecisionUrgency = "low" | "normal" | "high"
346
376
  /** confirm: any of the user's devices; tap: only the signed-in console. */
347
377
  export type DecisionTier = "confirm" | "tap"
@@ -360,6 +390,8 @@ export interface Decision {
360
390
  readonly question: string
361
391
  readonly options: readonly DecisionOption[]
362
392
  readonly recommendation?: { readonly option: string; readonly why?: string }
393
+ readonly choices?: readonly DecisionChoice[]
394
+ readonly kind?: DecisionKind
363
395
  readonly urgency: DecisionUrgency
364
396
  readonly tier: DecisionTier
365
397
  readonly deadline?: number
@@ -369,7 +401,7 @@ export interface Decision {
369
401
  readonly expiresAt: number
370
402
  readonly escalatedAt?: number
371
403
  /** answeredBy "app": an app answered it (`app`: its party `app:<id>`, `appLabel`: its label then). */
372
- readonly answer?: { readonly option?: string; readonly text?: string; readonly answeredBy: "user" | "timeout" | "app"; readonly at: number; readonly deviceID?: string; readonly app?: string; readonly appLabel?: string }
404
+ readonly answer?: { readonly option?: string; readonly picks?: Readonly<Record<string, string>>; readonly text?: string; readonly via?: "voice" | "text"; readonly answeredBy: "user" | "timeout" | "app"; readonly at: number; readonly deviceID?: string; readonly app?: string; readonly appLabel?: string }
373
405
  /** Why it closed without an answer (state closed). */
374
406
  readonly closed?: { readonly at: number; readonly reason: string }
375
407
  }
@@ -6,15 +6,20 @@ import type { Sent, WakeSkip } from "./types.ts"
6
6
  export const WAKE_SKIP_TEXT: Readonly<Record<WakeSkip, string>> = {
7
7
  wake_false: "sent without a wake",
8
8
  subagent: "subagents are never woken",
9
- hop_cap: "hop limit reached (8 agent messages without a human turn)",
10
- budget: "wake budget spent (30 per 10 minutes)",
9
+ hop_cap: "hop limit reached (too many agent messages without a human turn)",
10
+ budget: "wake budget spent",
11
+ item_budget: "item wake budget spent",
12
+ item_loop: "this pair is looping on the item without progress; the user decides",
11
13
  }
12
14
 
13
15
  /**
14
16
  * ` — wake skipped: <reason>` when the hub did not wake the recipient and the message waits for a later turn; empty
15
17
  * when it woke, went into a running turn (steer) or started one, or the hub did not say (hubs before 0.8.1).
16
18
  */
17
- export function wakeSkipNote({ delivery, woke, wakeSkipped }: Pick<Sent, "delivery" | "woke" | "wakeSkipped">): string {
19
+ export function wakeSkipNote({ delivery, woke, wakeSkipped, wakeLimit }: Pick<Sent, "delivery" | "woke" | "wakeSkipped" | "wakeLimit">): string {
18
20
  if (woke !== false || !wakeSkipped || delivery === "steer" || delivery === "turn") return ""
19
- return ` — wake skipped: ${WAKE_SKIP_TEXT[wakeSkipped] ?? wakeSkipped}`
21
+ const detail = wakeLimit !== undefined && Number.isFinite(wakeLimit) && wakeLimit >= 1
22
+ ? wakeSkipped === "hop_cap" ? `hop limit reached (${wakeLimit} agent messages without a human turn)` : wakeSkipped === "budget" ? `wake budget spent (${wakeLimit} per 10 minutes)` : wakeSkipped === "item_budget" ? `item wake budget spent (${wakeLimit} wakes)` : undefined
23
+ : undefined
24
+ return ` — wake skipped: ${detail ?? WAKE_SKIP_TEXT[wakeSkipped] ?? wakeSkipped}`
20
25
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -0,0 +1,185 @@
1
+ // Hub-wide safety limits: one versioned record, shared across users and pods. Console writes override hub.json.
2
+ // (HC-1) hopCeiling: the highest hub-wide or team hop limit accepted (1–HOP_ABSOLUTE_MAX, default 100). A hub-wide
3
+ // hopLimit above it is refused, never rewritten; raising it needs `confirm: true`; lowering needs none. A record
4
+ // stored before the ceiling existed reads with the configured (hub.json or default) ceiling.
5
+ import type { Bus, StateStore, Unsubscribe } from "../backend/types"
6
+ import type { HubLaunches } from "../config"
7
+ import { DEFAULT_LAUNCH_CAPS } from "./placement"
8
+ import { AgentError } from "./errors"
9
+ import { HOP_ABSOLUTE_MAX } from "./message-limits"
10
+ import { HOP_CEILING_DEFAULT } from "../../protocol"
11
+
12
+ const COLLECTION = "agent-limits"
13
+ const OWNER = "hub"
14
+ const KEY = "current"
15
+ const POLL_MS = 30_000
16
+ export const DEFAULT_ITEM_BUDGET = { wakes: 250, hours: 48 } as const
17
+ export const DEFAULT_ITEM_LOOP = { messages: 12, windowMinutes: 30 } as const
18
+ export interface ItemBudgetLimits { readonly wakes: number; readonly hours: number }
19
+ export interface ItemLoopLimits { readonly messages: number; readonly windowMinutes: number }
20
+
21
+ export interface AgentLimitsValue {
22
+ readonly hopLimit: number
23
+ /** (HC-1) Absent in records stored before the ceiling existed: the configured ceiling applies. */
24
+ readonly hopCeiling?: number
25
+ readonly launches: { readonly maxQueued: number; readonly maxActive: number; readonly perHour: number; readonly lostAfterMinutes?: number | false }
26
+ readonly itemBudget?: ItemBudgetLimits
27
+ readonly loop?: ItemLoopLimits
28
+ readonly sources?: { readonly hopLimit: AgentLimitsSource; readonly launches: AgentLimitsSource; readonly itemBudget?: AgentLimitsSource; readonly loop?: AgentLimitsSource; readonly hopCeiling?: AgentLimitsSource }
29
+ }
30
+ export type AgentLimitsSource = "default" | "hub.json" | "console"
31
+ export interface AgentLimitsView extends AgentLimitsValue {
32
+ readonly hopCeiling: number
33
+ readonly revision: number
34
+ /** A stored record failed validation; revision still identifies it so an admin can replace it. */
35
+ readonly invalidStored?: boolean
36
+ readonly source: AgentLimitsSource
37
+ readonly sources: { readonly hopLimit: AgentLimitsSource; readonly launches: AgentLimitsSource; readonly itemBudget: AgentLimitsSource; readonly loop: AgentLimitsSource; readonly hopCeiling: AgentLimitsSource }
38
+ }
39
+
40
+ export class AgentLimits {
41
+ /** The ceiling (and its source) a record without one reads with. */
42
+ private readonly configuredCeiling: { readonly value: number; readonly source: AgentLimitsSource }
43
+ private current: AgentLimitsView
44
+ private off?: Unsubscribe
45
+ private poll?: ReturnType<typeof setInterval>
46
+ private stopped = false
47
+ private loaded = false
48
+ private retryTimer?: ReturnType<typeof setTimeout>
49
+ private resolveRetry?: () => void
50
+ private nextRetryAt?: number
51
+ constructor(private readonly state: StateStore, defaults: { readonly hopLimit: number; readonly hopCeiling?: number; readonly launchCaps?: HubLaunches }, sources: { readonly hopLimit?: boolean; readonly hopCeiling?: boolean; readonly launchCaps?: boolean } = {}, private readonly bus?: Bus, private readonly options: { readonly pollMs?: number; readonly retry?: (ms: number) => Promise<void>; readonly now?: () => number } = {}) {
52
+ this.configuredCeiling = { value: defaults.hopCeiling ?? HOP_CEILING_DEFAULT, source: sources.hopCeiling ? "hub.json" : "default" }
53
+ const provenance = { hopLimit: sources.hopLimit ? "hub.json" : "default", launches: sources.launchCaps ? "hub.json" : "default", itemBudget: "default", loop: "default", hopCeiling: this.configuredCeiling.source } as const
54
+ this.current = { hopLimit: defaults.hopLimit, hopCeiling: this.configuredCeiling.value, launches: { ...DEFAULT_LAUNCH_CAPS, ...defaults.launchCaps }, itemBudget: DEFAULT_ITEM_BUDGET, loop: DEFAULT_ITEM_LOOP, sources: provenance, source: provenance.hopLimit, revision: 0 }
55
+ }
56
+
57
+ snapshot(): AgentLimitsView { return this.current }
58
+ /** True only after the initial persisted limits were read; a stopped retry loop never counts as loaded. */
59
+ initialLoaded(): boolean { return this.loaded }
60
+ retryAfter(): number { return Math.max(1, Math.ceil(((this.nextRetryAt ?? (this.options.now ?? Date.now)() + 1_000) - (this.options.now ?? Date.now)()) / 1_000)) }
61
+
62
+ async start(): Promise<void> {
63
+ this.stopped = false
64
+ this.loaded = false
65
+ if (this.bus) {
66
+ try { this.off = await this.bus.subscribe("agent-limits", () => { void this.view().catch((cause) => console.error(`[hub] agent limits refresh failed: ${(cause as Error).message}`)) }) }
67
+ catch (cause) { console.error(`[hub] agent limits bus subscribe failed; initial read will retry: ${cause instanceof Error ? cause.message : String(cause)}`) }
68
+ }
69
+ let delay = 1_000
70
+ while (!this.stopped) {
71
+ try { await this.view(); if (!this.stopped) this.loaded = true; this.nextRetryAt = undefined; break }
72
+ catch (cause) {
73
+ console.error(`[hub] agent limits initial load failed; retrying in ${delay}ms: ${cause instanceof Error ? cause.message : String(cause)}`)
74
+ this.nextRetryAt = (this.options.now ?? Date.now)() + delay
75
+ await (this.options.retry ?? ((ms) => new Promise<void>((resolve) => {
76
+ this.resolveRetry = resolve
77
+ this.retryTimer = setTimeout(() => { this.retryTimer = undefined; this.resolveRetry = undefined; resolve() }, ms)
78
+ })))(delay)
79
+ delay = Math.min(delay * 2, 30_000)
80
+ }
81
+ }
82
+ if (this.stopped) return
83
+ this.poll = setInterval(() => { void this.view().catch((cause) => console.error(`[hub] agent limits refresh failed: ${(cause as Error).message}`)) }, this.options.pollMs ?? POLL_MS)
84
+ }
85
+
86
+ async stop(): Promise<void> {
87
+ this.stopped = true
88
+ this.nextRetryAt = undefined
89
+ if (this.retryTimer) clearTimeout(this.retryTimer)
90
+ this.retryTimer = undefined
91
+ this.resolveRetry?.()
92
+ this.resolveRetry = undefined
93
+ if (this.poll) clearInterval(this.poll)
94
+ this.poll = undefined
95
+ await this.off?.()
96
+ this.off = undefined
97
+ }
98
+
99
+ async view(): Promise<AgentLimitsView> {
100
+ const stored = await this.state.get<AgentLimitsValue>(OWNER, COLLECTION, KEY)
101
+ if (stored && stored.version >= this.current.revision) {
102
+ if (validValue(stored.value, this.configuredCeiling.value)) {
103
+ const ceiling = stored.value.hopCeiling !== undefined ? { value: stored.value.hopCeiling, source: stored.value.sources?.hopCeiling ?? "console" } : this.configuredCeiling
104
+ const sources = { hopLimit: stored.value.sources?.hopLimit ?? "console", launches: stored.value.sources?.launches ?? "console", itemBudget: stored.value.sources?.itemBudget ?? "default", loop: stored.value.sources?.loop ?? "default", hopCeiling: ceiling.source } as const
105
+ this.current = { ...stored.value, hopCeiling: ceiling.value, itemBudget: stored.value.itemBudget ?? DEFAULT_ITEM_BUDGET, loop: stored.value.loop ?? DEFAULT_ITEM_LOOP, sources, revision: stored.version, source: sources.hopLimit, invalidStored: false }
106
+ } else {
107
+ this.current = { ...this.current, revision: stored.version, invalidStored: true }
108
+ }
109
+ }
110
+ return this.current
111
+ }
112
+
113
+ async update(input: unknown): Promise<AgentLimitsView> {
114
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new AgentError("invalid", "Agent limits must be an object")
115
+ const x = input as Record<string, unknown>
116
+ if (!Number.isSafeInteger(x.revision) || (x.revision as number) < 0) throw new AgentError("invalid", "revision must be a nonnegative whole number")
117
+ if (x.hopCeiling !== undefined && (!Number.isSafeInteger(x.hopCeiling) || (x.hopCeiling as number) < 1 || (x.hopCeiling as number) > HOP_ABSOLUTE_MAX)) throw new AgentError("invalid", `hopCeiling must be 1–${HOP_ABSOLUTE_MAX}`)
118
+ if (x.confirm !== undefined && typeof x.confirm !== "boolean") throw new AgentError("invalid", "confirm is true or false")
119
+ if (!Number.isSafeInteger(x.hopLimit) || (x.hopLimit as number) < 1) throw new AgentError("invalid", "hopLimit must be a whole number of at least 1")
120
+ const l = x.launches
121
+ if (!l || typeof l !== "object" || Array.isArray(l)) throw new AgentError("invalid", "launches must be an object")
122
+ const launches = l as Record<string, unknown>
123
+ for (const key of ["maxQueued", "maxActive", "perHour"]) if (!Number.isSafeInteger(launches[key]) || (launches[key] as number) < 1 || (launches[key] as number) > 100_000) throw new AgentError("invalid", `launches.${key} must be 1–100000`)
124
+ const lost = launches.lostAfterMinutes
125
+ if (lost !== undefined && lost !== false && (!Number.isSafeInteger(lost) || (lost as number) < 0 || (lost as number) > 1440)) throw new AgentError("invalid", "launches.lostAfterMinutes must be 0–1440 or false")
126
+ const nextLaunches = { maxQueued: launches.maxQueued as number, maxActive: launches.maxActive as number, perHour: launches.perHour as number, ...(lost !== undefined ? { lostAfterMinutes: lost as number | false } : {}) }
127
+ // Compare against the persisted revision, not a possibly stale pod cache.
128
+ const before = await this.view()
129
+ const ceiling = (x.hopCeiling as number | undefined) ?? before.hopCeiling
130
+ if ((x.hopLimit as number) > ceiling) throw new AgentError("invalid", `hopLimit ${x.hopLimit} is above the hop ceiling ${ceiling}: lower the hop limit first, or raise the ceiling in the same save`)
131
+ // A script or a stale page cannot raise the ceiling silently.
132
+ if (ceiling > before.hopCeiling && x.confirm !== true) throw new AgentError("invalid", `Raising the hop ceiling from ${before.hopCeiling} to ${ceiling} needs confirm: true`)
133
+ const itemBudget = budgetOf(x.itemBudget === undefined ? before.itemBudget ?? DEFAULT_ITEM_BUDGET : x.itemBudget)
134
+ const loop = loopOf(x.loop === undefined ? before.loop ?? DEFAULT_ITEM_LOOP : x.loop)
135
+ const value: AgentLimitsValue = { hopLimit: x.hopLimit as number, hopCeiling: ceiling, launches: nextLaunches, itemBudget, loop, sources: {
136
+ hopLimit: x.hopLimit === before.hopLimit && !before.invalidStored ? before.sources.hopLimit : "console",
137
+ launches: JSON.stringify(nextLaunches) === JSON.stringify(before.launches) && !before.invalidStored ? before.sources.launches : "console",
138
+ itemBudget: x.itemBudget === undefined || JSON.stringify(itemBudget) === JSON.stringify(before.itemBudget) && !before.invalidStored ? before.sources.itemBudget : "console",
139
+ loop: x.loop === undefined || JSON.stringify(loop) === JSON.stringify(before.loop) && !before.invalidStored ? before.sources.loop : "console",
140
+ hopCeiling: ceiling === before.hopCeiling && !before.invalidStored ? before.sources.hopCeiling : "console",
141
+ } }
142
+ const result = await this.state.put(OWNER, COLLECTION, KEY, value, { ifVersion: x.revision as number })
143
+ if (!result.ok) throw new AgentError("conflict", "Agent limits changed; reload before saving", { extra: { currentRevision: result.version } })
144
+ this.current = { ...value, hopCeiling: ceiling, revision: result.version, source: value.sources!.hopLimit, sources: value.sources! as AgentLimitsView["sources"], invalidStored: false }
145
+ try { await this.bus?.publish("agent-limits", { revision: result.version }) }
146
+ catch (cause) { console.error(`[hub] agent limits notification failed; other pods will catch up on poll: ${(cause as Error).message}`) }
147
+ return this.current
148
+ }
149
+ }
150
+
151
+ /** `configuredCeiling`: the ceiling a record stored without one reads with (its hop limit must fit under it). */
152
+ function validValue(value: unknown, configuredCeiling: number): value is AgentLimitsValue {
153
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
154
+ const v = value as Record<string, unknown>
155
+ const range = (n: unknown, min: number, max: number) => Number.isSafeInteger(n) && (n as number) >= min && (n as number) <= max
156
+ if (v.hopCeiling !== undefined && !range(v.hopCeiling, 1, HOP_ABSOLUTE_MAX)) return false
157
+ // A record stored without a ceiling must fit under the configured one (else it reads invalid; the admin replaces it).
158
+ const ceiling = (v.hopCeiling as number | undefined) ?? configuredCeiling
159
+ if (!range(v.hopLimit, 1, ceiling) || !v.launches || typeof v.launches !== "object" || Array.isArray(v.launches)) return false
160
+ const launches = v.launches as Record<string, unknown>
161
+ if (!["maxQueued", "maxActive", "perHour"].every((key) => range(launches[key], 1, 100_000))) return false
162
+ const lost = launches.lostAfterMinutes
163
+ if (lost !== undefined && lost !== false && !range(lost, 0, 1440)) return false
164
+ if (v.itemBudget !== undefined && !validBudget(v.itemBudget)) return false
165
+ if (v.loop !== undefined && !validLoop(v.loop)) return false
166
+ const sources = v.sources
167
+ return sources === undefined || (typeof sources === "object" && sources !== null && !Array.isArray(sources) && ["hopLimit", "launches"].every((key) => ["default", "hub.json", "console"].includes((sources as Record<string, string>)[key] ?? "")) && ["itemBudget", "loop"].every((key) => (sources as Record<string, string>)[key] === undefined || ["default", "console"].includes((sources as Record<string, string>)[key]!)) && ((sources as Record<string, string>).hopCeiling === undefined || ["default", "hub.json", "console"].includes((sources as Record<string, string>).hopCeiling!)))
168
+ }
169
+
170
+ const validBudget = (value: unknown): value is ItemBudgetLimits => isPair(value, "wakes", 10, 5_000, "hours", 1, 720)
171
+ const validLoop = (value: unknown): value is ItemLoopLimits => isPair(value, "messages", 4, 200, "windowMinutes", 5, 240)
172
+ function isPair(value: unknown, first: string, min: number, max: number, second: string, secondMin: number, secondMax: number): boolean {
173
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false
174
+ const v = value as Record<string, unknown>
175
+ const inRange = (n: unknown, low: number, high: number) => Number.isSafeInteger(n) && (n as number) >= low && (n as number) <= high
176
+ return Object.keys(v).length === 2 && inRange(v[first], min, max) && inRange(v[second], secondMin, secondMax)
177
+ }
178
+ function budgetOf(value: unknown): ItemBudgetLimits {
179
+ if (!validBudget(value)) throw new AgentError("invalid", "itemBudget requires wakes 10–5000 and hours 1–720")
180
+ return value
181
+ }
182
+ function loopOf(value: unknown): ItemLoopLimits {
183
+ if (!validLoop(value)) throw new AgentError("invalid", "loop requires messages 4–200 and windowMinutes 5–240")
184
+ return value
185
+ }
@@ -25,7 +25,7 @@ import type { ActingSwitch } from "./acting-switch"
25
25
  import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
26
26
  import { AGENT_JSON_BODY_MAX, boundedText } from "./body"
27
27
  import { type AppSessionDeps, isAppSession, locateSession } from "./conversation"
28
- import { DECISION_STATES, type Decision, type DecisionState, type Decisions } from "./decisions"
28
+ import { answerDetail, answerPicks, DECISION_STATES, type Decision, type DecisionState, type Decisions } from "./decisions"
29
29
  import { partyFromActor } from "./envelope"
30
30
  import { AgentError } from "./errors"
31
31
  import type { Message } from "./messages"
@@ -73,16 +73,17 @@ function ownership(deps: AppSessionDeps, actor: AppActor, userID: string) {
73
73
  }
74
74
  }
75
75
 
76
- async function answerBody(request: Request): Promise<{ option?: string; text?: string }> {
76
+ async function answerBody(request: Request): Promise<{ option?: string; text?: string; picks?: Record<string, string> }> {
77
77
  const raw = await boundedText(request, AGENT_JSON_BODY_MAX)
78
78
  const body: unknown = JSON.parse(raw)
79
79
  if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Body must be a JSON object")
80
80
  const input = body as Record<string, unknown>
81
- if ("via" in input) throw new AgentError("invalid", "An app's answer has no via", { hint: "Send {option} or {text}" })
81
+ if ("via" in input) throw new AgentError("invalid", "An app's answer has no via", { hint: "Send {option}, {picks} or {text}" })
82
82
  for (const key of ["option", "text"] as const) {
83
83
  if (input[key] !== undefined && typeof input[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
84
84
  }
85
- return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}) }
85
+ const picks = answerPicks(input.picks)
86
+ return { ...(typeof input.option === "string" ? { option: input.option } : {}), ...(typeof input.text === "string" ? { text: input.text } : {}), ...(picks ? { picks } : {}) }
86
87
  }
87
88
 
88
89
  function stateQuery(raw: string | null): DecisionState | undefined {
@@ -129,7 +130,7 @@ export function appDecisionRoutes(deps: AppDecisionDeps): { readonly answerable:
129
130
  })
130
131
  if (answered.reply) deps.publish?.(user.id, answered.reply)
131
132
  await record("allow", "ok", `Decision ${params.id!}`)
132
- await deps.store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: `app:${actor.tokenID} ${answered.decision.answer?.option ?? "text"}` })
133
+ await deps.store.audit({ actor: user.id, action: "decision.answer", target: params.id!, detail: `app:${actor.tokenID} ${answerDetail(answered.decision.answer)}` })
133
134
  return Response.json({ decision: answered.decision }, { headers: NO_STORE })
134
135
  } catch (cause) {
135
136
  // Only attempts on the app's own sessions' Decisions (unknown or foreign IDs are not audited).