@huynle/hub 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +20 -4
  3. package/package.json +1 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +625 -395
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/grants-mcp.ts +65 -0
  8. package/packages/cli/src/mcp-operations.ts +49 -0
  9. package/packages/cli/src/mcp.ts +6 -0
  10. package/packages/cli/src/subscriptions-mcp.ts +76 -0
  11. package/packages/cli/src/teams-mcp.ts +5 -3
  12. package/packages/codex/package.json +1 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/src/index.ts +13 -1
  17. package/packages/protocol/src/teams.ts +4 -0
  18. package/packages/protocol/src/voice-recording.ts +39 -2
  19. package/packages/runtime-openshell/image/entry.sh +3 -1
  20. package/packages/runtime-openshell/image/start.sh +3 -1
  21. package/packages/runtime-openshell/package.json +1 -1
  22. package/packages/runtime-sdk/package.json +1 -1
  23. package/packages/sdk/package.json +1 -1
  24. package/packages/sdk/src/client.ts +13 -0
  25. package/packages/sdk/src/teams.ts +2 -0
  26. package/packages/spoke-sdk/package.json +1 -1
  27. package/src/central/agent/decisions.ts +7 -1
  28. package/src/central/agent/delivery.ts +39 -9
  29. package/src/central/agent/grant-routes.ts +14 -1
  30. package/src/central/agent/grants.ts +23 -0
  31. package/src/central/agent/launch-engine.ts +3 -3
  32. package/src/central/agent/message-limits.ts +3 -0
  33. package/src/central/agent/routes.ts +11 -0
  34. package/src/central/agent/subscriptions.ts +3 -1
  35. package/src/central/config.ts +9 -2
  36. package/src/central/http.ts +135 -24
  37. package/src/central/recording/export.ts +57 -3
  38. package/src/central/recording/index.ts +234 -16
  39. package/src/central/recording/ingest.ts +11 -2
  40. package/src/central/recording/routes.ts +61 -7
  41. package/src/central/recording/schema.ts +66 -2
  42. package/src/central/recording/settings.ts +19 -5
  43. package/src/central/recording/store.ts +19 -0
  44. package/src/central/recording/taps.ts +45 -1
  45. package/src/central/speech/openai.ts +4 -4
  46. package/src/central/speech/openrouter.ts +4 -4
  47. package/src/central/speech/provider.ts +32 -1
  48. package/src/central/speech/service.ts +6 -5
  49. package/src/central/teams/hop-stalls.ts +72 -0
  50. package/src/central/teams/policy.ts +10 -0
  51. package/src/central/teams/routes.ts +2 -0
  52. package/src/central/teams/seats.ts +3 -3
  53. package/src/central/teams/staffing.ts +5 -0
  54. package/src/central/teams/store.ts +5 -2
  55. package/src/central/teams/validation.ts +8 -5
  56. package/src/spoke/spoke.ts +19 -0
  57. package/web/dist/assets/{ArtifactPage-Bz992ZX6.js → ArtifactPage-C5MMqXLN.js} +1 -1
  58. package/web/dist/assets/{ArtifactPane-BJZNixJC.js → ArtifactPane-CXSCmhyJ.js} +1 -1
  59. package/web/dist/assets/{ArtifactWorkbench-Dti3VP07.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
  60. package/web/dist/assets/{Diagram-B4HdUxw0.js → Diagram-BGmyRb-R.js} +1 -1
  61. package/web/dist/assets/{Library-CpzD2HDW.js → Library-CFmMbXEa.js} +2 -2
  62. package/web/dist/assets/{ProfilesDestination-DlQeogea.js → ProfilesDestination-7jpOc2HM.js} +1 -1
  63. package/web/dist/assets/{TeamsPage-4xdWCaI-.js → TeamsPage-C1NGrUR-.js} +1 -1
  64. package/web/dist/assets/{Watchtower-CpQdIRns.js → Watchtower-DnvlJaU0.js} +2 -2
  65. package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
  66. package/web/dist/assets/index-Da8SKXMw.js +33 -0
  67. package/web/dist/assets/{index-BC0O1gfR.css → index-Dyq-jBeE.css} +1 -1
  68. package/web/dist/assets/live-BmqLASSo.js +5 -0
  69. package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
  70. package/web/dist/assets/{renderer2d-IaJiloPi.js → renderer2d-B2Nh39UN.js} +1 -1
  71. package/web/dist/assets/{renderer3d-ZiD1uKGW.js → renderer3d-CTYbljHH.js} +1 -1
  72. package/web/dist/assets/{safe-area-C7IAlkgY.js → safe-area-CkhEclGo.js} +1 -1
  73. package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
  74. package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
  75. package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
  76. package/web/dist/index.html +5 -5
  77. package/web/dist/assets/index-BS6lA6fN.js +0 -33
  78. package/web/dist/assets/live-CVUFIdUX.js +0 -5
@@ -58,7 +58,7 @@ import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, rende
58
58
  import { noticeOnly } from "../../../packages/sdk/src/envelope.ts"
59
59
  import { AgentError } from "./errors"
60
60
  import { type ExpiredReason, parseNotAfter } from "./not-after"
61
- import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits, overHopCap } from "./message-limits"
61
+ import { HOP_CAP, HOP_CAP_CEILING, HopTracker, MessageLimiter, type MessageLimits } from "./message-limits"
62
62
  import { parseAddress } from "./names"
63
63
  import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
64
64
 
@@ -126,10 +126,16 @@ export interface DeliveryDeps {
126
126
  /** Labels of a session sender (default: its @name from the directory). */
127
127
  readonly describe?: (userID: string, sessionID: string) => PartyInfo
128
128
  readonly alert?: (userID: string, alert: DeliveryAlert) => void
129
+ /** The first over-cap message between two seats of a team, once per persistent chain. */
130
+ readonly teamHopCap?: (userID: string, input: { readonly teamID: string; readonly sender: string; readonly recipient: string; readonly limit: number }) => Promise<void>
131
+ /** A quiet, hub-authored note to the team's entry seat; never counts a message hop. */
132
+ readonly teamHopNotice?: (userID: string, teamID: string) => Promise<string | undefined>
129
133
  /** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
130
134
  readonly expired?: (userID: string, message: Message) => void
131
135
  /** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
132
136
  readonly limits?: Partial<MessageLimits>
137
+ /** Configured global agent-only wake hop limit (default 8, absolute ceiling 100). */
138
+ readonly hopLimit?: number
133
139
  /** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
134
140
  readonly relayWindowMs?: number
135
141
  /** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
@@ -141,7 +147,7 @@ export interface DeliveryDeps {
141
147
  /** How often the re-wake loop runs (default REWAKE_TICK_MS). */
142
148
  readonly tickMs?: number
143
149
  /** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
144
- readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
150
+ readonly teams?: Pick<TeamPolicy, "prepare" | "answer" | "hopContext">
145
151
  /** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
146
152
  readonly inbound?: InboundPolicy
147
153
  }
@@ -273,6 +279,7 @@ export class Delivery {
273
279
  private readonly owner: string
274
280
 
275
281
  constructor(deps: DeliveryDeps) {
282
+ if (deps.hopLimit !== undefined && (!Number.isSafeInteger(deps.hopLimit) || deps.hopLimit < 1 || deps.hopLimit > HOP_CAP_CEILING)) throw new Error(`hopLimit must be 1–${HOP_CAP_CEILING}`)
276
283
  this.deps = deps
277
284
  const now = (this.now = deps.now ?? Date.now)
278
285
  this.timers = deps.timers ?? defaultTimers
@@ -348,6 +355,17 @@ export class Delivery {
348
355
  return this.hops.humanTurn(userID, sessionID)
349
356
  }
350
357
 
358
+ async notifyTeamHopCap(userID: string, teamID: string, decisionID: string): Promise<void> {
359
+ const entry = await this.deps.teamHopNotice?.(userID, teamID)
360
+ if (!entry) return
361
+ const id = this.newID()
362
+ await this.request(userID, entry, {
363
+ op: "deliver", sessionID: entry, messageID: id, wake: false,
364
+ text: `[hub] notice · Team messages reached the hop cap. The user has a Renew Decision ${decisionID}; wait for their choice. · ${id}`,
365
+ meta: { kind: "msg", from: "hub", to: entry, delivery: "queue" },
366
+ })
367
+ }
368
+
351
369
  /** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
352
370
  /** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
353
371
  flush(userID: string, sessionID?: string): Promise<void> {
@@ -489,8 +507,12 @@ export class Delivery {
489
507
  await this.hops.received(userID, recipient, hop, from)
490
508
  this.users.add(userID)
491
509
  const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
492
- const skipped = await this.wakeFor(userID, message, unwanted)
493
- if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
510
+ const team = "team" in resolved ? resolved.team : undefined
511
+ const explicitTeamID = typeof team === "object" && team !== null && "teamID" in team && typeof team.teamID === "string" ? team.teamID : undefined
512
+ const context = await this.deps.teams?.hopContext(userID, from, recipient, explicitTeamID)
513
+ const limit = Math.min(context?.limit ?? this.deps.hopLimit ?? HOP_CAP, this.deps.hopLimit ?? HOP_CAP)
514
+ const skipped = await this.wakeFor(userID, message, unwanted, limit, context?.teamID)
515
+ if (kind === "ask" && unwanted === undefined && hop <= limit) {
494
516
  const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
495
517
  await this.deps.state.put(userID, REWAKE, message.id, rewake)
496
518
  }
@@ -612,7 +634,8 @@ export class Delivery {
612
634
  if (message.recipient.kind !== "session") return answered
613
635
  const sessionID = message.recipient.sessionID
614
636
  const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
615
- const skipped = await this.wakeFor(userID, message, unwanted)
637
+ const context = await this.deps.teams?.hopContext(userID, from, message.recipient)
638
+ const skipped = await this.wakeFor(userID, message, unwanted, Math.min(context?.limit ?? this.deps.hopLimit ?? HOP_CAP, this.deps.hopLimit ?? HOP_CAP), context?.teamID)
616
639
  return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
617
640
  }
618
641
 
@@ -620,16 +643,23 @@ export class Delivery {
620
643
  * Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
621
644
  * the hop cap (alerting once at the cap), or no wake budget left.
622
645
  */
623
- private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
646
+ private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined, limit = this.deps.hopLimit ?? HOP_CAP, teamID?: string): Promise<WakeSkip | undefined> {
624
647
  if (unwanted) return unwanted
625
648
  if (message.recipient.kind !== "session") return "wake_false"
626
- if (overHopCap(message.hop)) {
627
- if (message.hop === HOP_CAP + 1) {
649
+ if (message.hop > limit) {
650
+ if (teamID && message.from.kind === "session") {
651
+ try {
652
+ await this.deps.teamHopCap?.(userID, { teamID, sender: message.from.sessionID, recipient: message.recipient.sessionID, limit })
653
+ } catch (cause) {
654
+ // The message has already been stored. A failed Decision must not prevent delivery or wake it.
655
+ console.error(`[hub] team hop-cap alert failed: ${cause instanceof Error ? cause.message : String(cause)}`)
656
+ }
657
+ } else if (message.hop === limit + 1) {
628
658
  this.deps.alert?.(userID, {
629
659
  kind: "hopCap",
630
660
  sessionID: message.recipient.sessionID,
631
661
  messageID: message.id,
632
- text: `Agent messages went ${HOP_CAP} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
662
+ text: `Agent messages went ${limit} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
633
663
  })
634
664
  }
635
665
  return "hop_cap"
@@ -1,6 +1,7 @@
1
1
  // /api/agent/grants (design §5, §7): the user's grants. User actors only (allow {}): the device cookie (same-origin
2
2
  // writes, checked by the agent framework) or a device token. A session or app token never creates, edits, reads
3
- // or revokes a grant (escalation guard). Answers {grant} / {grants}, never cached; the owner is not echoed.
3
+ // or revokes a grant on the admin routes (escalation guard). /mine is holder-scoped; a user has no holder.
4
+ // Answers {grant} / {grants}, never cached; the owner is not echoed.
4
5
  //
5
6
  // POST /api/agent/grants create (201) GET /api/agent/grants list, newest first
6
7
  // GET /api/agent/grants/:id one grant PUT /api/agent/grants/:id replace (holder fixed)
@@ -37,6 +38,18 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
37
38
  } satisfies AgentRoute,
38
39
  ]
39
40
  : []),
41
+ {
42
+ method: "GET", path: `${PREFIX}/mine`, allow: { session: true, scope: "messages:read" },
43
+ handle: async ({ actor }) => Response.json({ grants: (await grants.held(actor)).map((g) => grantView(g, now())) }, { headers: NO_STORE }),
44
+ },
45
+ {
46
+ method: "GET", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:read" },
47
+ handle: async ({ actor, params }) => one(await grants.heldGrant(actor, params.id!)),
48
+ },
49
+ {
50
+ method: "DELETE", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:send" },
51
+ handle: async ({ actor, params }) => one(await grants.revokeHeld(actor, params.id!)),
52
+ },
40
53
  {
41
54
  method: "POST",
42
55
  path: PREFIX,
@@ -136,6 +136,29 @@ export class Grants {
136
136
  return (await this.all(owner)).sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1)).map((g) => ({ ...g, state: grantState(g, at) }))
137
137
  }
138
138
 
139
+ /** Grants currently held by this credential; a user credential has no session/app holder. */
140
+ async held(actor: AgentActor): Promise<GrantView[]> {
141
+ if (actor.kind === "user") return []
142
+ return (await this.list(actor.userID)).filter((grant) => grant.owner === actor.userID && this.holds(grant, actor))
143
+ }
144
+
145
+ /** A holder cannot inspect another holder's grant (including an unheld role). */
146
+ async heldGrant(actor: AgentActor, id: string): Promise<Grant> {
147
+ if (actor.kind === "user") throw notFound()
148
+ const grant = await this.get(actor.userID, id)
149
+ if (grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
150
+ return grant
151
+ }
152
+
153
+ /** Re-check holder inside the versioned mutation, so a role lost after a read cannot revoke it. */
154
+ async revokeHeld(actor: AgentActor, id: string): Promise<Grant> {
155
+ await this.heldGrant(actor, id)
156
+ return this.change(actor.userID, id, "grant.revoke", (grant, at) => {
157
+ if (actor.kind === "user" || grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
158
+ return { ...grant, revokedAt: at }
159
+ })
160
+ }
161
+
139
162
  /**
140
163
  * Replaces an active grant with a new spec (its expiry restarts now); id, holder (and its pin) and created
141
164
  * stay. Changing the holder is invalid (revoke and create instead); an ended grant is gone.
@@ -746,7 +746,7 @@ export class LaunchEngine {
746
746
  const workspace = work?.workspace ?? (launch ? leftWorkspace(launch, report) : undefined)
747
747
  if (!workspace) continue
748
748
  if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
749
- await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (workspace.reused ? "keep" : "remove") })
749
+ await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (launch?.spec.end.cleanup ?? (workspace.reused ? "keep" : "remove")) })
750
750
  this.cleanupTries.delete(report.launchID)
751
751
  this.cleanupLogged.delete(report.launchID)
752
752
  } catch (cause) {
@@ -1122,6 +1122,8 @@ export class LaunchEngine {
1122
1122
  // A lost launch whose session a spoke still reports keeps its workspace (never removed under a live session).
1123
1123
  const sessionLive = outcome === "lost" && work.sessionID !== undefined && this.deps.registry.hasSession(work.sessionID)
1124
1124
  const policy: CleanupPolicy = outcome === "needs_user" || sessionLive ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
1125
+ // Reconciliation must replay the policy actually applied, not the launch's original preference.
1126
+ await this.updateWork(userID, id, { cleanup: policy })
1125
1127
  const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
1126
1128
  if (node) {
1127
1129
  if (work.started) {
@@ -1145,11 +1147,9 @@ export class LaunchEngine {
1145
1147
  } catch (cause) {
1146
1148
  // The node may still hold the launch (and its slot): reconcile retries with this policy.
1147
1149
  notes.push(`workspace cleanup failed: ${errorText(cause)}; retried later`)
1148
- await this.updateWork(userID, id, { cleanup: policy })
1149
1150
  }
1150
1151
  } else {
1151
1152
  notes.push("workspace cleanup waits for the node")
1152
- await this.updateWork(userID, id, { cleanup: policy })
1153
1153
  }
1154
1154
  }
1155
1155
  try {
@@ -28,9 +28,12 @@ import type { Clock, Counters, StateStore } from "../backend/types"
28
28
  import { type Party, partyKey } from "./envelope"
29
29
  import { AgentError } from "./errors"
30
30
  import { updateVersioned } from "./versioned"
31
+ import { TEAM_LIMITS } from "../../protocol"
31
32
 
32
33
  /** Hops a reply chain may take without a human turn. */
33
34
  export const HOP_CAP = 8
35
+ /** Absolute ceiling on user-configured hop limits, independent of hub.json. */
36
+ export const HOP_CAP_CEILING = TEAM_LIMITS.hopLimit
34
37
  /** A depth no message refreshed for this long is forgotten. */
35
38
  export const HOP_TTL_MS = 24 * 60 * 60_000
36
39
 
@@ -67,6 +67,7 @@ import { isConciergeLaunch } from "../concierge/supervisor"
67
67
  import type { ConciergeService } from "../concierge/service"
68
68
  import type { SessionIntent } from "../../protocol"
69
69
  import type { SessionIntents } from "./intents"
70
+ import type { NodeRegistry } from "../nodes"
70
71
  import { intentRoutes } from "./intent-routes"
71
72
  import { contextCardRoutes } from "../context-cards/routes"
72
73
  import type { ContextCardService } from "../context-cards/service"
@@ -98,6 +99,8 @@ export interface AgentRoute {
98
99
  /** What the agent API needs from the hub. */
99
100
  export interface AgentDeps {
100
101
  readonly store: Store
102
+ /** Owner's connected launch nodes: read-only user route; never expose connection IDs or launch internals. */
103
+ readonly nodes?: Pick<NodeRegistry, "list">
101
104
  /** (AF1) Connect an app: the connected-app token verifier and the app-clients/connections routes when given. */
102
105
  readonly connect?: AppConnect
103
106
  /** (AF2) Speech for apps: POST /api/agent/speech/{transcribe,speak} (scope speech:use) when given. */
@@ -238,6 +241,14 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
238
241
  const feed = deps.feed ?? new MessageFeed()
239
242
  const appDecisions = appDecisionParts(deps, feed)
240
243
  const routes: AgentRoute[] = [
244
+ ...(deps.nodes ? [{
245
+ method: "GET", path: `${AGENT_PREFIX}/launch-nodes`, allow: {},
246
+ handle: ({ user }) => Response.json({ nodes: deps.nodes!.list(user.id).map((node) => ({
247
+ machineID: node.machineID, machineName: node.machineName, workspaceRoots: node.workspaceRoots,
248
+ capacity: node.capacity, used: node.used, free: node.free, runtimes: node.runtimes,
249
+ labels: node.labels, draining: node.draining,
250
+ })) }, { headers: { "cache-control": "no-store" } }),
251
+ } satisfies AgentRoute] : []),
241
252
  ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
242
253
  ...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
243
254
  ...(deps.directory && deps.contextCards ? contextCardRoutes({ cards: deps.contextCards, directory: deps.directory, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
@@ -245,6 +245,8 @@ export interface SubscriptionsDeps {
245
245
  readonly send: SubscriptionSend
246
246
  /** The session's message hop depth (HopTracker.depth). */
247
247
  readonly hopDepth: (userID: string, sessionID: string) => Promise<number>
248
+ /** Hub-wide hop ceiling (team notices are not session-to-session messages). */
249
+ readonly hopLimit?: number
248
250
  /** Directory facts of these sessions (deleted and unknown ones absent). */
249
251
  readonly entries: (userID: string, sessionIDs: readonly string[]) => ReadonlyMap<string, SubscriptionEntry>
250
252
  /** The sessions an address names for the subscriber (its visibility); throws not_found / invalid / ambiguous. */
@@ -858,7 +860,7 @@ export class Subscriptions {
858
860
  if (scoped && !scoped(subject)) continue
859
861
  const cause = isMessage && subject.senderID !== undefined && subject.senderID.startsWith("ses_") ? subject.senderID : (subject.sessionID ?? target)
860
862
  const depth = await this.depth(ctx, cause)
861
- if (depth + 1 > HOP_CAP) {
863
+ if (depth + 1 > (this.deps.hopLimit ?? HOP_CAP)) {
862
864
  suppressed++
863
865
  continue
864
866
  }
@@ -15,6 +15,7 @@ import { readContextCards, type ContextCardsConfig } from "./context-cards/confi
15
15
  import { checkReleaseBase } from "./install/render"
16
16
  import { cspMode, type CspMode } from "./csp"
17
17
  import { parseDenyCidrs } from "./fetcher"
18
+ import { HOP_CAP, HOP_CAP_CEILING } from "./agent/message-limits"
18
19
 
19
20
  export const GOOGLE_ISSUER = "https://accounts.google.com"
20
21
 
@@ -70,6 +71,8 @@ export interface CentralConfig {
70
71
  readonly auth: AuthConfig
71
72
  /** hub.json `agents.never`: permission patterns no agent may ever approve, for every user (design §5, §7). */
72
73
  readonly agentsNever: readonly string[]
74
+ /** hub.json `agents.hopLimit`: global maximum agent-only hops before a wake is skipped. */
75
+ readonly hopLimit: number
73
76
  /** hub.json `agents.launches`: per-user launch caps, only those set (placement fills in its defaults). */
74
77
  readonly launchCaps?: HubLaunches
75
78
  /** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
@@ -138,6 +141,7 @@ export interface HubLaunches {
138
141
  export interface HubAgents {
139
142
  /** Normalised `action[:glob]` patterns (see agent/policy.ts), duplicates dropped. */
140
143
  readonly never?: readonly string[]
144
+ readonly hopLimit?: number
141
145
  readonly launches?: HubLaunches
142
146
  readonly webhooks?: HubWebhooks
143
147
  /** (AF2) Per-credential app speech quotas: any subset of SPEECH_RATE_LIMITS. */
@@ -169,7 +173,7 @@ type Env = Readonly<Record<string, string | undefined>>
169
173
 
170
174
  const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster", "contextCards"])
171
175
  const CLUSTER_KEYS = ["database", "valkey"] as const
172
- const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas"])
176
+ const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas", "hopLimit"])
173
177
  /** The largest speech quota hub.json may set (counts and characters). */
174
178
  const SPEECH_QUOTA_MAX = 1_000_000_000
175
179
  const WEBHOOK_KEYS = new Set(["allowPrivate", "allowPrivateHosts"])
@@ -350,11 +354,13 @@ function readHubAgents(raw: unknown, file: string, warnings: string[], fail: (me
350
354
  if (!isObj(raw)) fail("agents must be an object")
351
355
  const input = raw as Record<string, unknown>
352
356
  for (const key of Object.keys(input)) if (!AGENTS_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.${key}" ignored`)
357
+ if (input.hopLimit !== undefined && (!Number.isSafeInteger(input.hopLimit) || (input.hopLimit as number) < 1 || (input.hopLimit as number) > HOP_CAP_CEILING))
358
+ fail(`agents.hopLimit must be a whole number from 1 to ${HOP_CAP_CEILING}`)
353
359
  const launches = input.launches === undefined ? undefined : readHubLaunches(input.launches, file, warnings, fail)
354
360
  const never = input.never === undefined ? undefined : readNever(input.never, fail)
355
361
  const webhooks = input.webhooks === undefined ? undefined : readHubWebhooks(input.webhooks, file, warnings, fail)
356
362
  const speechQuotas = input.speechQuotas === undefined ? undefined : readSpeechQuotas(input.speechQuotas, file, warnings, fail)
357
- return { ...(never ? { never } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
363
+ return { ...(never ? { never } : {}), ...(input.hopLimit !== undefined ? { hopLimit: input.hopLimit as number } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
358
364
  }
359
365
 
360
366
  /** (AF2) `agents.speechQuotas`: {transcribe?: {perMinute?, perHour?}, speak?: {perMinute?, perHour?, charsPerHour?}}. */
@@ -601,6 +607,7 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
601
607
  ...(file.speech ? { speech: file.speech } : {}),
602
608
  auth,
603
609
  agentsNever: [...(file.agents?.never ?? [])],
610
+ hopLimit: file.agents?.hopLimit ?? HOP_CAP,
604
611
  ...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
605
612
  ...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
606
613
  ...(file.agents?.webhooks?.allowPrivateHosts?.length ? { webhooksPrivateHosts: [...file.agents.webhooks.allowPrivateHosts] } : {}),