@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.
- package/CHANGELOG.md +26 -0
- package/README.md +19 -5
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/sessions.ts +4 -1
- package/packages/cli/dist/hub-cli.js +240 -83
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +12 -7
- package/packages/cli/src/decisions.ts +52 -4
- package/packages/cli/src/format.ts +9 -0
- package/packages/cli/src/mcp.ts +20 -4
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +7 -3
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +14 -3
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/api/v1/_root.json +119 -0
- package/packages/protocol/schema/api/v1/account.json +419 -0
- package/packages/protocol/schema/api/v1/decisions.json +635 -0
- package/packages/protocol/schema/api/v1/discovery.json +110 -0
- package/packages/protocol/schema/api/v1/events.json +679 -0
- package/packages/protocol/schema/api/v1/session-actions.json +660 -0
- package/packages/protocol/schema/api/v1/sessions.json +2055 -0
- package/packages/protocol/schema/api/v1/speech.json +492 -0
- package/packages/protocol/schema/api/v1/unspecified.json +282 -0
- package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
- package/packages/protocol/schema/app-front/v1.schema.json +7 -3
- package/packages/protocol/src/api-v1.ts +8 -0
- package/packages/protocol/src/index.ts +2 -0
- package/packages/protocol/src/teams.ts +13 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +2 -2
- package/packages/sdk/src/ask-fields.ts +76 -0
- package/packages/sdk/src/client.ts +11 -2
- package/packages/sdk/src/envelope.ts +3 -2
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/team-intake.ts +6 -0
- package/packages/sdk/src/teams.ts +2 -0
- package/packages/sdk/src/types.ts +36 -4
- package/packages/sdk/src/wake.ts +9 -4
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/agent-limits.ts +185 -0
- package/src/central/agent/app-decisions.ts +6 -5
- package/src/central/agent/decisions.ts +130 -9
- package/src/central/agent/delivery.ts +134 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/launch-engine.ts +6 -3
- package/src/central/agent/message-limits.ts +5 -2
- package/src/central/agent/message-routes.ts +33 -6
- package/src/central/agent/messages.ts +7 -0
- package/src/central/agent/placement.ts +4 -2
- package/src/central/agent/read-routes.ts +5 -0
- package/src/central/agent/routes.ts +2 -0
- package/src/central/agent/subscriptions.ts +13 -4
- package/src/central/api-v1.ts +115 -0
- package/src/central/artifacts/store.ts +4 -2
- package/src/central/concierge/board-results.ts +2 -1
- package/src/central/concierge/notifier.ts +4 -2
- package/src/central/config.ts +20 -6
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +285 -23
- package/src/central/recording/index.ts +2 -1
- package/src/central/recording/schema.ts +2 -1
- package/src/central/registry.ts +34 -3
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +14 -6
- package/src/central/teams/intake.ts +38 -6
- package/src/central/teams/involvement.ts +95 -0
- package/src/central/teams/item-decisions.ts +60 -0
- package/src/central/teams/item-eligibility.ts +35 -0
- package/src/central/teams/item-stalls.ts +87 -0
- package/src/central/teams/loops.ts +40 -0
- package/src/central/teams/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -2
- package/src/central/teams/store.ts +21 -5
- package/src/central/teams/validation.ts +17 -6
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-C5MMqXLN.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CXSCmhyJ.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-S0RxNGWy.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-BGmyRb-R.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-CFmMbXEa.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-7jpOc2HM.js → ProfilesDestination-cryC7P6j.js} +1 -1
- package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
- package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
- package/web/dist/assets/index-DA0gKIzv.js +33 -0
- package/web/dist/assets/live-B2IfKm37.js +5 -0
- package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
- package/web/dist/assets/{renderer2d-B2Nh39UN.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-CTYbljHH.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-CkhEclGo.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/assets/TeamsPage-C1NGrUR-.js +0 -1
- package/web/dist/assets/Watchtower-DnvlJaU0.js +0 -2
- package/web/dist/assets/index-Da8SKXMw.js +0 -33
- 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
|
-
/**
|
|
36
|
-
export const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/src/central/config.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
358
|
-
fail(`agents.
|
|
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] } : {}),
|