@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
@@ -32,8 +32,8 @@ import { TEAM_LIMITS } from "../../protocol"
32
32
 
33
33
  /** Hops a reply chain may take without a human turn. */
34
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
35
+ /** (HC-1) The absolute bound of any configured hop limit or ceiling (the live ceiling is in AgentLimits). */
36
+ export const HOP_ABSOLUTE_MAX = TEAM_LIMITS.hopLimit
37
37
  /** A depth no message refreshed for this long is forgotten. */
38
38
  export const HOP_TTL_MS = 24 * 60 * 60_000
39
39
 
@@ -163,6 +163,9 @@ export class MessageLimiter {
163
163
  private readonly now: Clock
164
164
  private readonly limits: MessageLimits
165
165
 
166
+ /** The configured recipient wake cap for the current 10-minute window. */
167
+ get wakeLimit(): number { return this.limits.wake }
168
+
166
169
  constructor(deps: { readonly counters: Counters; readonly now?: Clock; readonly limits?: Partial<MessageLimits> }) {
167
170
  this.counters = deps.counters
168
171
  this.now = deps.now ?? Date.now
@@ -43,6 +43,7 @@ import type { AgentActor } from "./actor"
43
43
  import { type AskInput, DECISION_STATES, type DecisionState, type Decisions, type Tier, type Urgency } from "./decisions"
44
44
  import type { Delivery, SendOptions, Sent } from "./delivery"
45
45
  import type { Directory } from "./directory"
46
+ import { isContractID } from "../../protocol"
46
47
  import { partyFromActor, partyKey } from "./envelope"
47
48
  import { AgentError } from "./errors"
48
49
  import { idempotencyKeyOf, REPLAYED_HEADER, RouteIdempotency } from "./idempotency"
@@ -136,7 +137,7 @@ const sendOptions = (body: Body, delivery: Pick<Delivery, "checkNotAfter">): Sen
136
137
  /** The user's address (also the malformed `@me.project`, which the delivery engine refuses). */
137
138
  const isMe = (to: string) => to === "@me" || to.startsWith("@me.")
138
139
 
139
- const DECISION_FIELDS = ["options", "recommendation", "urgency", "tier", "deadline", "onTimeout", "about"] as const
140
+ const DECISION_FIELDS = ["options", "recommendation", "choices", "kind", "urgency", "tier", "deadline", "onTimeout", "about"] as const
140
141
 
141
142
  /** The state query parameter of a Decision listing, or undefined. */
142
143
  export function decisionStateQuery(raw: string | null): DecisionState | undefined {
@@ -162,7 +163,21 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
162
163
  if (typeof r !== "object" || r === null || Array.isArray(r)) throw new AgentError("invalid", "recommendation must be {option, why?}")
163
164
  out.recommendation = { option: r.option, ...(r.why === undefined ? {} : { why: r.why }) }
164
165
  }
165
- for (const key of ["urgency", "tier", "onTimeout"] as const) {
166
+ if (body.choices !== undefined) {
167
+ const shape = "choices must be a list of {id, label, options: [{id, label, recommended?}]}"
168
+ if (!Array.isArray(body.choices)) throw new AgentError("invalid", shape)
169
+ out.choices = body.choices.map((group: unknown) => {
170
+ if (typeof group !== "object" || group === null || Array.isArray(group)) throw new AgentError("invalid", shape)
171
+ const { id, label, options } = group as Record<string, unknown>
172
+ if (!Array.isArray(options)) throw new AgentError("invalid", shape)
173
+ return { id, label, options: options.map((option: unknown) => {
174
+ if (typeof option !== "object" || option === null || Array.isArray(option)) throw new AgentError("invalid", shape)
175
+ const o = option as Record<string, unknown>
176
+ return { id: o.id, label: o.label, ...(o.recommended === undefined ? {} : { recommended: o.recommended }) }
177
+ }) }
178
+ })
179
+ }
180
+ for (const key of ["urgency", "tier", "onTimeout", "kind"] as const) {
166
181
  if (body[key] === undefined) continue
167
182
  if (typeof body[key] !== "string") throw new AgentError("invalid", `${key} must be a string`)
168
183
  out[key] = body[key]
@@ -177,11 +192,12 @@ function decisionFields(body: Body): Omit<AskInput, "question" | "about" | "expi
177
192
  }
178
193
 
179
194
  /** The stored message, how it was delivered and, for a session recipient, whether it woke it (and why not). */
180
- const sentView = ({ message, delivery, woke, wakeSkipped }: Sent) => ({
195
+ const sentView = ({ message, delivery, woke, wakeSkipped, wakeLimit }: Sent) => ({
181
196
  message,
182
197
  ...(delivery ? { delivery } : {}),
183
198
  ...(woke === undefined ? {} : { woke }),
184
199
  ...(wakeSkipped ? { wakeSkipped } : {}),
200
+ ...(wakeLimit !== undefined ? { wakeLimit } : {}),
185
201
  })
186
202
 
187
203
  export function messageRoutes(deps: MessageDeps): AgentRoute[] {
@@ -271,6 +287,13 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
271
287
  return sent
272
288
  }
273
289
 
290
+ async function explicitItem(userID: string, actor: AgentActor, to: string, raw: unknown): Promise<string | undefined> {
291
+ if (raw === undefined) return undefined
292
+ if (!isContractID("intake", raw)) throw new AgentError("invalid", "item must be an itk_ ID")
293
+ if (isMe(to) || actor.kind !== "session") throw new AgentError("invalid", "item applies to messages between team members")
294
+ return raw
295
+ }
296
+
274
297
  return [
275
298
  {
276
299
  method: "POST",
@@ -281,7 +304,8 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
281
304
  const to = string(body, "to")
282
305
  const text = string(body, "text", TEXT_MAX)
283
306
  const all = flag(body, "all")
284
- const sent = await send(user.id, actor, to, "msg", text, sendOptions(body, delivery), all)
307
+ const item = await explicitItem(user.id, actor, to, body.item)
308
+ const sent = await send(user.id, actor, to, "msg", text, { ...sendOptions(body, delivery), ...(item ? { item } : {}) }, all)
285
309
  return ok(all ? { sent: sent.map(sentView) } : sentView(sent[0]!), 201)
286
310
  }),
287
311
  },
@@ -293,11 +317,12 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
293
317
  const body = await bodyOf(request)
294
318
  const to = string(body, "to")
295
319
  const text = string(body, "text", TEXT_MAX)
320
+ const item = await explicitItem(user.id, actor, to, body.item)
296
321
  const all = flag(body, "all")
297
322
  const expiresIn = seconds(body.expiresIn, "expiresIn")
298
323
  const wait = seconds(body.wait, "wait", WAIT_MAX_S)
299
324
  if (all && wait !== undefined) throw new AgentError("invalid", "wait cannot be combined with all")
300
- const options: SendOptions = { ...sendOptions(body, delivery), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
325
+ const options: SendOptions = { ...sendOptions(body, delivery), ...(item ? { item } : {}), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
301
326
  const decisionAsked = DECISION_FIELDS.some((key) => body[key] !== undefined)
302
327
  if (isMe(to) && deps.decisions) {
303
328
  if (all) throw new AgentError("invalid", "all cannot be combined with @me")
@@ -314,7 +339,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
314
339
  const done = await settled(user.id, message, wait, request.signal)
315
340
  return ok({ ...done, decision: (await deps.decisions.get(user.id, decision.id)) ?? decision }, 201)
316
341
  }
317
- if (decisionAsked) throw new AgentError("invalid", "options, recommendation, urgency, tier, deadline, onTimeout and about are for asks to @me")
342
+ if (decisionAsked) throw new AgentError("invalid", "options, recommendation, choices, kind, urgency, tier, deadline, onTimeout and about are for asks to @me")
318
343
  const sent = await send(user.id, actor, to, "ask", text, options, all)
319
344
  if (all) return ok({ sent: sent.map(sentView) }, 201)
320
345
  return ok({ ...sentView(sent[0]!), ...(await settled(user.id, sent[0]!.message, wait, request.signal)) }, 201)
@@ -326,6 +351,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
326
351
  allow: SEND,
327
352
  async handle({ request, user, actor }) {
328
353
  const body = await bodyOf(request)
354
+ if (body.item !== undefined) throw new AgentError("invalid", "item is not accepted on replies", { hint: "replies inherit the item of the message they answer" })
329
355
  const replyTo = string(body, "replyTo")
330
356
  const text = string(body, "text", TEXT_MAX)
331
357
  const wake = flag(body, "wake")
@@ -342,6 +368,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
342
368
  allow: SEND,
343
369
  async handle({ request, user, actor }) {
344
370
  const body = await bodyOf(request)
371
+ if (body.item !== undefined) throw new AgentError("invalid", "item is not accepted on declines", { hint: "replies inherit the item of the message they answer" })
345
372
  const replyTo = string(body, "replyTo")
346
373
  const why = string(body, "why", WHY_MAX)
347
374
  ensureOn(user.id)
@@ -59,6 +59,8 @@ export interface Message {
59
59
  /** The role holder generation it reached (role addresses). */
60
60
  readonly generation?: number
61
61
  readonly text: string
62
+ /** Explicit intake item attribution, checked against the sender's current team seat. */
63
+ readonly item?: string
62
64
  /** What a reply or decline answers. */
63
65
  readonly replyTo?: string
64
66
  readonly state: MessageState
@@ -90,6 +92,7 @@ export interface SendInput {
90
92
  readonly recipient: Party
91
93
  readonly kind: "msg" | "ask"
92
94
  readonly text: string
95
+ readonly item?: string
93
96
  readonly generation?: number
94
97
  /** From HopTracker.next (default 0). */
95
98
  readonly hop?: number
@@ -106,6 +109,8 @@ export interface AnswerInput {
106
109
  /** The replier's current role holder generation, when it holds the role the original reached. */
107
110
  readonly generation?: number
108
111
  readonly hop?: number
112
+ /** Only when both parties still hold seats and the answered item's team is active/open. */
113
+ readonly item?: string
109
114
  }
110
115
 
111
116
  export interface Answered {
@@ -228,6 +233,7 @@ export class Messages {
228
233
  recipient: input.recipient,
229
234
  ...(input.generation === undefined ? {} : { generation: input.generation }),
230
235
  text,
236
+ ...(input.item ? { item: input.item } : {}),
231
237
  state: input.kind === "ask" ? "open" : "sent",
232
238
  at,
233
239
  hop: input.hop ?? 0,
@@ -377,6 +383,7 @@ export class Messages {
377
383
  recipient: original.from,
378
384
  text,
379
385
  replyTo: original.id,
386
+ ...(input.item ? { item: input.item } : {}),
380
387
  state: "sent",
381
388
  at,
382
389
  hop: input.hop ?? 0,
@@ -203,6 +203,7 @@ export interface PlacementDeps {
203
203
  /** This process (pod) as lease owner. */
204
204
  readonly owner: string
205
205
  readonly caps?: HubLaunches
206
+ readonly currentCaps?: () => LaunchCaps
206
207
  readonly now?: Clock
207
208
  readonly leaseMs?: number
208
209
  /** Revoked launchers (flagged in the active wait); without it none is. */
@@ -244,12 +245,13 @@ export const pastNotAfter = (launch: Pick<Launch, "spec">, now: number): boolean
244
245
  export const placementLeaseKey = (userID: string) => `placement:${userID}`
245
246
 
246
247
  export class Placement {
247
- readonly caps: LaunchCaps
248
+ get caps(): LaunchCaps { return this.deps.currentCaps?.() ?? this.initialCaps }
249
+ private readonly initialCaps: LaunchCaps
248
250
  private readonly now: Clock
249
251
  private readonly leaseMs: number
250
252
 
251
253
  constructor(private readonly deps: PlacementDeps) {
252
- this.caps = { ...DEFAULT_LAUNCH_CAPS, ...deps.caps }
254
+ this.initialCaps = { ...DEFAULT_LAUNCH_CAPS, ...deps.caps }
253
255
  this.now = deps.now ?? Date.now
254
256
  this.leaseMs = deps.leaseMs ?? PLACEMENT_LEASE_MS
255
257
  }
@@ -26,6 +26,7 @@ import type { EventsRouteDeps } from "./events-routes"
26
26
  import { type Census, type TeamDiscovery, visible } from "../teams/discovery"
27
27
  import type { SessionIntent } from "../../protocol"
28
28
  import { intentField } from "./intents"
29
+ import type { SessionHealth } from "../health/classify"
29
30
 
30
31
  /** Labels per session ID. */
31
32
  export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
@@ -86,6 +87,8 @@ export interface AgentSessionView {
86
87
  readonly harness: string
87
88
  readonly parentID?: string
88
89
  readonly busySince?: number
90
+ /** (SS-1) Why it is not making progress (provider retry, quota, auth, …); absent: ok. */
91
+ readonly health?: SessionHealth
89
92
  readonly now?: string
90
93
  readonly blockers: { kind: string; id: string; summary: string; sessionID?: string }[]
91
94
  readonly children: number
@@ -139,6 +142,7 @@ function view({ session: s, harness }: Row, entry: DirectoryEntry | undefined, l
139
142
  harness,
140
143
  ...(s.parentID ? { parentID: s.parentID } : {}),
141
144
  ...(own?.since !== undefined ? { busySince: own.since } : {}),
145
+ ...(own?.health ? { health: own.health } : {}),
142
146
  ...(s.now ? { now: nowText(s.now) } : {}),
143
147
  blockers: s.blockers.map((b) => ({ kind: b.kind, id: b.id, summary: b.summary, ...(b.sessionID !== s.id ? { sessionID: b.sessionID } : {}) })),
144
148
  children: s.status.filter((e) => e.parentID === s.id).length,
@@ -290,6 +294,7 @@ async function show(deps: ReadDeps, userID: string, addr: string, census?: Censu
290
294
  directory: live.directory,
291
295
  ...(live.parentID ? { parentID: live.parentID } : {}),
292
296
  ...(own?.since !== undefined ? { busySince: own.since } : {}),
297
+ ...(own?.health ? { health: own.health } : {}),
293
298
  ...(live.now ? { now: nowText(live.now), activity: live.now } : {}),
294
299
  blockers: live.blockers.map(({ sessionTitle: _, ...b }) => b),
295
300
  children: live.status.filter((e) => e.parentID === live.id).length,
@@ -98,6 +98,7 @@ export interface AgentRoute {
98
98
 
99
99
  /** What the agent API needs from the hub. */
100
100
  export interface AgentDeps {
101
+ readonly agentLimits?: { view(): Promise<unknown> }
101
102
  readonly store: Store
102
103
  /** Owner's connected launch nodes: read-only user route; never expose connection IDs or launch internals. */
103
104
  readonly nodes?: Pick<NodeRegistry, "list">
@@ -249,6 +250,7 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
249
250
  labels: node.labels, draining: node.draining,
250
251
  })) }, { headers: { "cache-control": "no-store" } }),
251
252
  } satisfies AgentRoute] : []),
253
+ ...(deps.agentLimits ? [{ method: "GET", path: `${AGENT_PREFIX}/agent-limits`, allow: { session: true, app: true }, handle: async () => Response.json(await deps.agentLimits!.view(), { headers: { "cache-control": "no-store" } }) }] : []),
252
254
  ...(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) }) : []),
253
255
  ...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
254
256
  ...(deps.directory && deps.contextCards ? contextCardRoutes({ cards: deps.contextCards, directory: deps.directory, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
@@ -54,7 +54,7 @@ import type { AgentActor, AppActor, SessionActor } from "./actor"
54
54
  import { sanitizeLabel } from "./envelope"
55
55
  import { AgentError } from "./errors"
56
56
  import { parsePosition } from "./event-cursor"
57
- import type { AgentEvent, AgentEvents, EventInput, EventRecord } from "./events"
57
+ import { stateChanged, type AgentEvent, type AgentEvents, type EventInput, type EventRecord } from "./events"
58
58
  import { visible as actorSees } from "./events-routes"
59
59
  import { HOP_CAP } from "./message-limits"
60
60
  import { parseAddress } from "./names"
@@ -247,6 +247,7 @@ export interface SubscriptionsDeps {
247
247
  readonly hopDepth: (userID: string, sessionID: string) => Promise<number>
248
248
  /** Hub-wide hop ceiling (team notices are not session-to-session messages). */
249
249
  readonly hopLimit?: number
250
+ readonly currentHopLimit?: () => number
250
251
  /** Directory facts of these sessions (deleted and unknown ones absent). */
251
252
  readonly entries: (userID: string, sessionIDs: readonly string[]) => ReadonlyMap<string, SubscriptionEntry>
252
253
  /** The sessions an address names for the subscriber (its visibility); throws not_found / invalid / ambiguous. */
@@ -309,7 +310,7 @@ export function typeMatches(patterns: readonly string[], event: Pick<AgentEvent,
309
310
  return patterns.some((p) => {
310
311
  if (p === "*") return true
311
312
  if (Object.hasOwn(SUBSCRIPTION_FAMILIES, p)) return SUBSCRIPTION_FAMILIES[p]!.includes(type)
312
- if (p.startsWith("state:")) return type === "session.changed" && event.data.state === p.slice(6)
313
+ if (p.startsWith("state:")) return type === "session.changed" && event.data.state === p.slice(6) && stateChanged(event.data)
313
314
  return p.endsWith(".*") ? type.startsWith(p.slice(0, -1)) : p === type
314
315
  })
315
316
  }
@@ -320,12 +321,20 @@ export function typeMatches(patterns: readonly string[], event: Pick<AgentEvent,
320
321
  const tok = (v: unknown): string => (typeof v === "string" && /^[A-Za-z0-9_.:@#-]{1,80}$/.test(v) ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : "other")
321
322
  const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
322
323
 
324
+ /** (SS-1) " (quota until 2026-11-18 10:00 UTC)", " (retrying)", " (ok)": the health a session.changed carries, else "". */
325
+ function healthNote(d: Readonly<Record<string, unknown>>): string {
326
+ if (d.healthKind === undefined) return ""
327
+ const at = typeof d.healthResetAt === "number" && Number.isFinite(d.healthResetAt) ? new Date(d.healthResetAt) : undefined
328
+ const until = at && !Number.isNaN(at.getTime()) ? ` until ${at.toISOString().slice(0, 16).replace("T", " ")} UTC` : ""
329
+ return ` (${tok(d.healthKind)}${until})`
330
+ }
331
+
323
332
  function describe(event: AgentEvent): string {
324
333
  const d = event.data
325
334
  const s = event.subject
326
335
  switch (event.type) {
327
336
  case "session.changed":
328
- return `${tok(d.state)}${d.state === "idle" && d.prev === "working" ? " (turn done)" : ""}`
337
+ return `${tok(d.state)}${d.state === "idle" && d.prev === "working" ? " (turn done)" : ""}${healthNote(d)}`
329
338
  case "session.added":
330
339
  return "online"
331
340
  case "session.removed":
@@ -860,7 +869,7 @@ export class Subscriptions {
860
869
  if (scoped && !scoped(subject)) continue
861
870
  const cause = isMessage && subject.senderID !== undefined && subject.senderID.startsWith("ses_") ? subject.senderID : (subject.sessionID ?? target)
862
871
  const depth = await this.depth(ctx, cause)
863
- if (depth + 1 > (this.deps.hopLimit ?? HOP_CAP)) {
872
+ if (depth + 1 > (this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP)) {
864
873
  suppressed++
865
874
  continue
866
875
  }
@@ -0,0 +1,115 @@
1
+ // The /api/v1 boundary (docs/plans/2026-11-12-api-v1-contract-and-native-app-design.md §1.1, §1.2), without hub
2
+ // state. /api/v1/<rest> is an alias of /api/<rest> in the console and agent route tables: handle() in http.ts gives
3
+ // the handlers a copy of the URL on the unversioned path (never another Request), so routes, access checks and
4
+ // idempotency keys are the unversioned ones. Every error answered under /api/v1 is the agent API's envelope
5
+ // {error: {code, message, ...extra}}: console handlers keep answering {error: string, code?, ...extra} (the web
6
+ // console reads it on the unversioned paths) and apiV1Response rewrites that on the way out. Answers below 400 go
7
+ // out as they are, unread: the live stream and streamed audio are never buffered.
8
+ import { API_V1_PREFIX } from "../protocol"
9
+
10
+ const API_PREFIX = "/api"
11
+
12
+ /**
13
+ * The unversioned path a /api/v1 path is an alias of, or undefined outside /api/v1. One prefix only: /api/v1/v1/me
14
+ * is /api/v1/me, which no table has; the bare prefix is /api or /api/, which no table has either.
15
+ */
16
+ export function unversionedPath(pathname: string): string | undefined {
17
+ if (pathname !== API_V1_PREFIX && !pathname.startsWith(`${API_V1_PREFIX}/`)) return undefined
18
+ return `${API_PREFIX}${pathname.slice(API_V1_PREFIX.length)}`
19
+ }
20
+
21
+ /** A copy of a /api/v1 URL on its unversioned path (query string kept), or undefined outside /api/v1. */
22
+ export function unversionedURL(url: URL): URL | undefined {
23
+ const pathname = unversionedPath(url.pathname)
24
+ if (pathname === undefined) return undefined
25
+ const copy = new URL(url)
26
+ copy.pathname = pathname
27
+ return copy
28
+ }
29
+
30
+ /** The code of a console error that names none, by status (design §1.2). */
31
+ const STATUS_CODES: Readonly<Record<number, string>> = {
32
+ 400: "invalid",
33
+ 401: "unauthorized",
34
+ 403: "forbidden",
35
+ 404: "not_found",
36
+ 408: "timeout",
37
+ 409: "conflict",
38
+ 410: "gone",
39
+ 413: "too_large",
40
+ 422: "unsupported",
41
+ 429: "rate_limited",
42
+ 500: "internal",
43
+ 502: "upstream_failed",
44
+ 503: "unavailable",
45
+ 504: "timeout",
46
+ }
47
+
48
+ export const apiV1ErrorCode = (status: number): string => STATUS_CODES[status] ?? (status >= 500 ? "internal" : "invalid")
49
+
50
+ /** The message of an error whose answer had none to keep, by code. */
51
+ const MESSAGES: Readonly<Record<string, string>> = {
52
+ invalid: "Bad request",
53
+ unauthorized: "Unauthorized",
54
+ forbidden: "Forbidden",
55
+ not_found: "Not found",
56
+ timeout: "Timed out",
57
+ conflict: "Conflict",
58
+ gone: "Gone",
59
+ too_large: "Too large",
60
+ unsupported: "Unsupported",
61
+ rate_limited: "Too many requests",
62
+ internal: "Internal error",
63
+ upstream_failed: "Upstream failed",
64
+ unavailable: "Unavailable",
65
+ }
66
+
67
+ /** A text/* error body longer than this is not a message (a page, a trace). */
68
+ const TEXT_MESSAGE_MAX_CHARS = 300
69
+
70
+ export interface ApiV1Error {
71
+ readonly error: { readonly code: string; readonly message: string; readonly [extra: string]: unknown }
72
+ }
73
+
74
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
75
+
76
+ /**
77
+ * The v1 envelope of an error answer: `body` is its parsed JSON (if it was JSON), `text` its text/* body. An
78
+ * envelope is returned as it is (the same object); a console error keeps its message, its code (else the
79
+ * status's) and moves its other fields inside `error`; anything else gets the status's code and a generic message.
80
+ */
81
+ export function apiV1ErrorBody(status: number, body: unknown, text?: string): ApiV1Error {
82
+ if (isRecord(body)) {
83
+ const { error, code, ...extra } = body
84
+ if (isRecord(error) && typeof error.code === "string" && typeof error.message === "string") return body as unknown as ApiV1Error
85
+ if (typeof error === "string") return { error: { ...extra, code: typeof code === "string" && code ? code : apiV1ErrorCode(status), message: error } }
86
+ }
87
+ const code = apiV1ErrorCode(status)
88
+ const short = text?.trim()
89
+ return { error: { code, message: short && short.length <= TEXT_MESSAGE_MAX_CHARS ? short : MESSAGES[code] ?? "Request failed" } }
90
+ }
91
+
92
+ /** An answer on its way out of /api/v1: errors (400 and up) in the v1 envelope, same status and headers. */
93
+ export async function apiV1Response(response: Response): Promise<Response> {
94
+ if (response.status < 400) return response
95
+ const type = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase()
96
+ const json = type === "application/json" || type.endsWith("+json")
97
+ // An event stream has no end to read to.
98
+ const text = type.startsWith("text/") && type !== "text/event-stream"
99
+ // A copy is read, so an answer that is already the envelope (every agent route's) goes out as its handler made it.
100
+ const raw = json || text ? await response.clone().text() : undefined
101
+ let parsed: unknown
102
+ if (json) {
103
+ try {
104
+ parsed = JSON.parse(raw!)
105
+ } catch {}
106
+ }
107
+ const body = apiV1ErrorBody(response.status, parsed, text ? raw : undefined)
108
+ if (body === parsed) return response
109
+ void response.body?.cancel().catch(() => {})
110
+ const bytes = new TextEncoder().encode(JSON.stringify(body))
111
+ const headers = new Headers(response.headers)
112
+ headers.set("content-type", "application/json;charset=utf-8")
113
+ headers.set("content-length", String(bytes.length))
114
+ return new Response(bytes, { status: response.status, statusText: response.statusText, headers })
115
+ }
@@ -95,6 +95,8 @@ export interface ArtifactEvent {
95
95
  readonly number?: number
96
96
  /** artifact.archived: the state it moved to (archive and unarchive share the event). */
97
97
  readonly state?: RecordState
98
+ /** created/revised by a session (in-process only: never journaled; TA2-5 progress credit). */
99
+ readonly authorSessionID?: string
98
100
  }
99
101
 
100
102
  export interface ArtifactStoreOptions {
@@ -393,7 +395,7 @@ export class ArtifactStore {
393
395
  if (!written.ok) throw conflict("Artifact ID collision; retry", undefined)
394
396
  return artifact
395
397
  })
396
- this.emit(userID, { type: "artifact.created", artifactID, kind: published.artifact.kind, revision: 1, revisionID: published.revision.id, number: 1 })
398
+ this.emit(userID, { type: "artifact.created", artifactID, kind: published.artifact.kind, revision: 1, revisionID: published.revision.id, number: 1, ...(published.revision.author.kind === "session" ? { authorSessionID: published.revision.author.sessionID } : {}) })
397
399
  return published
398
400
  }
399
401
 
@@ -426,7 +428,7 @@ export class ArtifactStore {
426
428
  }
427
429
  throw conflict("The artifact kept changing; retry", undefined)
428
430
  })
429
- this.emit(userID, { type: "artifact.revised", artifactID: id, kind: published.artifact.kind, revision: published.artifact.revision, revisionID: published.revision.id, number })
431
+ this.emit(userID, { type: "artifact.revised", artifactID: id, kind: published.artifact.kind, revision: published.artifact.revision, revisionID: published.revision.id, number, ...(published.revision.author.kind === "session" ? { authorSessionID: published.revision.author.sessionID } : {}) })
430
432
  return published
431
433
  }
432
434
 
@@ -17,7 +17,7 @@
17
17
  // stalled store further results are dropped. observe() never throws or rejects (it is called with `void`).
18
18
  import type { ConciergeResultOutcome } from "../../../packages/protocol/src/concierge"
19
19
  import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
20
- import type { AgentEvent } from "../agent/events"
20
+ import { stateChanged, type AgentEvent } from "../agent/events"
21
21
  import { updateVersioned } from "../agent/versioned"
22
22
  import type { Clock, StateStore } from "../backend/types"
23
23
  import type { LastResult } from "./board"
@@ -56,6 +56,7 @@ export function resultOf(event: Pick<AgentEvent, "type" | "subject" | "data"> |
56
56
  if (!isHubSessionID(sessionID)) return undefined
57
57
  if (event.type === "session.changed") {
58
58
  if (subject.rootID !== undefined && subject.rootID !== sessionID) return undefined
59
+ if (!stateChanged(data)) return undefined
59
60
  if (data.state === "error") return { sessionID, outcome: "error" }
60
61
  if (data.state === "idle" && (data.prev === "working" || data.prev === "blocked")) return { sessionID, outcome: "turn_done" }
61
62
  return undefined
@@ -67,7 +67,7 @@
67
67
  // spent budget wait for the tick (no 0 ms timer loop).
68
68
  import { CONCIERGE_PARTY, CONCIERGE_ROLE, type ConciergeSettings } from "../../../packages/protocol/src/concierge"
69
69
  import { AgentError } from "../agent/errors"
70
- import type { AgentEvent, AgentEvents, EventRecord, EventsSince } from "../agent/events"
70
+ import { stateChanged, type AgentEvent, type AgentEvents, type EventRecord, type EventsSince } from "../agent/events"
71
71
  import { updateVersioned } from "../agent/versioned"
72
72
  import { realTimers } from "../backend/memory-bus"
73
73
  import type { Clock, StateStore, Timers } from "../backend/types"
@@ -286,6 +286,8 @@ function classify(record: Pick<EventRecord, "event" | "family" | "audience">, ct
286
286
  return line("routine", `${who} blocker_closed${blockerID ? ` ${blockerID}` : ""}`)
287
287
  }
288
288
  case "session.changed": {
289
+ // (SS-1) A change of health alone is no new state line (SS-4 adds the health line).
290
+ if (!stateChanged(d)) return undefined
289
291
  const state = enumOf(d.state, STATES)
290
292
  const k = `s:${id}`
291
293
  if (state === "error") return line("priority", `${who} state:error`, { k, f: state, b: [`${root} state:error`] })
@@ -347,7 +349,7 @@ function overdueTurn(l: Line): Line {
347
349
  const EARLY: ReadonlySet<string> = new Set(["blocker.opened", "message.changed", "launch.changed"])
348
350
 
349
351
  /** Whether an append may be a priority line (an early pass): EARLY types, and a session going into error. */
350
- const early = (event: AgentEvent): boolean => EARLY.has(event.type) || (event.type === "session.changed" && event.data?.state === "error")
352
+ const early = (event: AgentEvent): boolean => EARLY.has(event.type) || (event.type === "session.changed" && event.data?.state === "error" && stateChanged(event.data))
351
353
  /** Whether an append may be a notable line (a turn done): a pass when its quiet window ends. */
352
354
  const notable = (event: AgentEvent): boolean => event.type === "session.changed" && event.data?.state === "idle" && event.data?.prev === "working"
353
355
 
@@ -8,14 +8,14 @@ import { hubEnv } from "../env"
8
8
  import { isLoopbackHost } from "../loopback"
9
9
  import { formatPattern, parsePattern } from "./agent/policy"
10
10
  import type { SpeechQuotaOverrides } from "./agent/speech-routes"
11
- import { SPEECH_RATE_LIMITS } from "../protocol"
11
+ import { HOP_CEILING_DEFAULT, SPEECH_RATE_LIMITS } from "../protocol"
12
12
  import { APP_DIR, configBase, legacyPaths, migrateFirst, migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
13
13
  import { updateSpeech, type StoredSpeech } from "./speech/settings"
14
14
  import { readContextCards, type ContextCardsConfig } from "./context-cards/config"
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
+ import { HOP_ABSOLUTE_MAX, HOP_CAP } from "./agent/message-limits"
19
19
 
20
20
  export const GOOGLE_ISSUER = "https://accounts.google.com"
21
21
 
@@ -73,6 +73,12 @@ export interface CentralConfig {
73
73
  readonly agentsNever: readonly string[]
74
74
  /** hub.json `agents.hopLimit`: global maximum agent-only hops before a wake is skipped. */
75
75
  readonly hopLimit: number
76
+ /** (HC-1) hub.json `agents.hopCeiling` (default 100): the highest hop limit accepted. */
77
+ readonly hopCeiling: number
78
+ /** (HC-1) Whether hub.json set the ceiling (else the default). */
79
+ readonly hopCeilingSource?: "default" | "hub.json"
80
+ /** Which source supplied the hub-wide agent limits before console overrides. */
81
+ readonly agentLimitsSource?: "default" | "hub.json"
76
82
  /** hub.json `agents.launches`: per-user launch caps, only those set (placement fills in its defaults). */
77
83
  readonly launchCaps?: HubLaunches
78
84
  /** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
@@ -142,6 +148,8 @@ export interface HubAgents {
142
148
  /** Normalised `action[:glob]` patterns (see agent/policy.ts), duplicates dropped. */
143
149
  readonly never?: readonly string[]
144
150
  readonly hopLimit?: number
151
+ /** (HC-1) The highest hop limit the hub accepts (1–10,000, default 100); the console may override it. */
152
+ readonly hopCeiling?: number
145
153
  readonly launches?: HubLaunches
146
154
  readonly webhooks?: HubWebhooks
147
155
  /** (AF2) Per-credential app speech quotas: any subset of SPEECH_RATE_LIMITS. */
@@ -173,7 +181,7 @@ type Env = Readonly<Record<string, string | undefined>>
173
181
 
174
182
  const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster", "contextCards"])
175
183
  const CLUSTER_KEYS = ["database", "valkey"] as const
176
- const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas", "hopLimit"])
184
+ const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas", "hopLimit", "hopCeiling"])
177
185
  /** The largest speech quota hub.json may set (counts and characters). */
178
186
  const SPEECH_QUOTA_MAX = 1_000_000_000
179
187
  const WEBHOOK_KEYS = new Set(["allowPrivate", "allowPrivateHosts"])
@@ -354,13 +362,16 @@ function readHubAgents(raw: unknown, file: string, warnings: string[], fail: (me
354
362
  if (!isObj(raw)) fail("agents must be an object")
355
363
  const input = raw as Record<string, unknown>
356
364
  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}`)
365
+ if (input.hopCeiling !== undefined && (!Number.isSafeInteger(input.hopCeiling) || (input.hopCeiling as number) < 1 || (input.hopCeiling as number) > HOP_ABSOLUTE_MAX))
366
+ fail(`agents.hopCeiling must be a whole number from 1 to ${HOP_ABSOLUTE_MAX}`)
367
+ const ceiling = (input.hopCeiling as number | undefined) ?? HOP_CEILING_DEFAULT
368
+ if (input.hopLimit !== undefined && (!Number.isSafeInteger(input.hopLimit) || (input.hopLimit as number) < 1 || (input.hopLimit as number) > ceiling))
369
+ fail(`agents.hopLimit must be a whole number from 1 to the hop ceiling (${ceiling}; agents.hopCeiling raises it)`)
359
370
  const launches = input.launches === undefined ? undefined : readHubLaunches(input.launches, file, warnings, fail)
360
371
  const never = input.never === undefined ? undefined : readNever(input.never, fail)
361
372
  const webhooks = input.webhooks === undefined ? undefined : readHubWebhooks(input.webhooks, file, warnings, fail)
362
373
  const speechQuotas = input.speechQuotas === undefined ? undefined : readSpeechQuotas(input.speechQuotas, file, warnings, fail)
363
- return { ...(never ? { never } : {}), ...(input.hopLimit !== undefined ? { hopLimit: input.hopLimit as number } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
374
+ return { ...(never ? { never } : {}), ...(input.hopLimit !== undefined ? { hopLimit: input.hopLimit as number } : {}), ...(input.hopCeiling !== undefined ? { hopCeiling: input.hopCeiling as number } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
364
375
  }
365
376
 
366
377
  /** (AF2) `agents.speechQuotas`: {transcribe?: {perMinute?, perHour?}, speak?: {perMinute?, perHour?, charsPerHour?}}. */
@@ -608,6 +619,9 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
608
619
  auth,
609
620
  agentsNever: [...(file.agents?.never ?? [])],
610
621
  hopLimit: file.agents?.hopLimit ?? HOP_CAP,
622
+ hopCeiling: file.agents?.hopCeiling ?? HOP_CEILING_DEFAULT,
623
+ hopCeilingSource: file.agents?.hopCeiling !== undefined ? "hub.json" : "default",
624
+ agentLimitsSource: file.agents?.hopLimit !== undefined || file.agents?.launches !== undefined ? "hub.json" : "default",
611
625
  ...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
612
626
  ...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
613
627
  ...(file.agents?.webhooks?.allowPrivateHosts?.length ? { webhooksPrivateHosts: [...file.agents.webhooks.allowPrivateHosts] } : {}),