@huynle/hub 0.10.1 → 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 +80 -0
- package/README.md +36 -8
- 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 +851 -466
- 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/grants-mcp.ts +65 -0
- package/packages/cli/src/mcp-operations.ts +49 -0
- package/packages/cli/src/mcp.ts +26 -4
- package/packages/cli/src/subscriptions-mcp.ts +76 -0
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +9 -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 +16 -0
- package/packages/protocol/src/voice-recording.ts +39 -2
- package/packages/runtime-openshell/image/entry.sh +3 -1
- package/packages/runtime-openshell/image/start.sh +3 -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 +24 -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 +4 -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 +136 -9
- package/src/central/agent/delivery.ts +164 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/grant-routes.ts +14 -1
- package/src/central/agent/grants.ts +23 -0
- package/src/central/agent/launch-engine.ts +9 -6
- package/src/central/agent/message-limits.ts +6 -0
- 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 +13 -0
- package/src/central/agent/subscriptions.ts +15 -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 +24 -3
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +380 -36
- package/src/central/recording/export.ts +57 -3
- package/src/central/recording/index.ts +236 -17
- package/src/central/recording/ingest.ts +11 -2
- package/src/central/recording/routes.ts +61 -7
- package/src/central/recording/schema.ts +68 -3
- package/src/central/recording/settings.ts +19 -5
- package/src/central/recording/store.ts +19 -0
- package/src/central/recording/taps.ts +45 -1
- package/src/central/registry.ts +34 -3
- package/src/central/speech/openai.ts +4 -4
- package/src/central/speech/openrouter.ts +4 -4
- package/src/central/speech/provider.ts +32 -1
- package/src/central/speech/service.ts +6 -5
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +80 -0
- 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/policy.ts +10 -0
- package/src/central/teams/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -0
- package/src/central/teams/seats.ts +3 -3
- package/src/central/teams/staffing.ts +5 -0
- package/src/central/teams/store.ts +20 -1
- package/src/central/teams/validation.ts +16 -2
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-rv5DGtrm.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.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/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
- package/web/dist/assets/index-DA0gKIzv.js +33 -0
- package/web/dist/assets/{index-rW6LKQj1.css → index-Dyq-jBeE.css} +1 -1
- 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/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
- package/web/dist/assets/{renderer2d-DyANQ1WS.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-B2Bk__FK.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/assets/{silero-worker-e802lB5T.js → silero-worker-CC2etKS3.js} +1 -1
- package/web/dist/assets/{source-C-De8L3s.js → source-GYayxUxS.js} +1 -1
- package/web/dist/assets/{toast-CXTwTvDw.js → toast-q818mjUF.js} +1 -1
- package/web/dist/index.html +5 -5
- package/web/dist/assets/TeamsPage-D_17MkvN.js +0 -1
- package/web/dist/assets/Watchtower-DlrNOJ7X.js +0 -2
- package/web/dist/assets/index-bX8B6NLo.js +0 -33
- package/web/dist/assets/live-BGLA-peO.js +0 -5
|
@@ -79,6 +79,23 @@ export const DECISION_TICK_MS = 30_000
|
|
|
79
79
|
|
|
80
80
|
const OPTION_ID = /^[a-z0-9][a-z0-9_-]{0,31}$/
|
|
81
81
|
|
|
82
|
+
/** (DC-1) Choice groups of a Decision, options per group, and characters of a group's label. */
|
|
83
|
+
export const CHOICES_MAX = 12
|
|
84
|
+
export const CHOICE_OPTIONS_MIN = 2
|
|
85
|
+
export const CHOICE_OPTIONS_MAX = 6
|
|
86
|
+
export const CHOICE_LABEL_MAX = 120
|
|
87
|
+
const CHOICE_ID = /^[A-Za-z0-9_-]{1,8}$/
|
|
88
|
+
/** (DC-1) What an ask is about, for the card's header (absent: a question). */
|
|
89
|
+
export type DecisionKind = "question" | "plan" | "merge" | "release"
|
|
90
|
+
export const DECISION_KINDS: readonly DecisionKind[] = ["question", "plan", "merge", "release"]
|
|
91
|
+
|
|
92
|
+
/** (DC-1) One point of a structured ask: the user picks one of its options. */
|
|
93
|
+
export interface DecisionChoice {
|
|
94
|
+
readonly id: string
|
|
95
|
+
readonly label: string
|
|
96
|
+
readonly options: readonly { readonly id: string; readonly label: string; readonly recommended?: true }[]
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
export interface DecisionOption {
|
|
83
100
|
readonly id: string
|
|
84
101
|
readonly label: string
|
|
@@ -88,6 +105,8 @@ export interface DecisionOption {
|
|
|
88
105
|
|
|
89
106
|
export interface DecisionAnswer {
|
|
90
107
|
readonly option?: string
|
|
108
|
+
/** (DC-1) A choices Decision: the option picked in each group (absent: answered with text only). */
|
|
109
|
+
readonly picks?: Readonly<Record<string, string>>
|
|
91
110
|
readonly text?: string
|
|
92
111
|
/** (AF4) "app": an app holding decisions:answer answered a confirm-tier Decision of its own session. */
|
|
93
112
|
readonly answeredBy: "user" | "timeout" | "app"
|
|
@@ -124,7 +143,22 @@ export function appAnswerRefusal(decision: Pick<Decision, "tier" | "action" | "a
|
|
|
124
143
|
}
|
|
125
144
|
|
|
126
145
|
/** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
|
|
127
|
-
export type DecisionAction = PermissionAction | GrantRequestAction | ActAction
|
|
146
|
+
export type DecisionAction = PermissionAction | GrantRequestAction | ActAction | TeamHopRenewAction | TeamItemAction
|
|
147
|
+
|
|
148
|
+
/** Hub-created, tap-only action: an agent cannot authorize a team's hop budget renewal. */
|
|
149
|
+
export interface TeamHopRenewAction {
|
|
150
|
+
readonly kind: "teamHopRenew"
|
|
151
|
+
readonly teamID: string
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** (TA2-7) Hub-created, tap-only: Extend or Stop one intake item that spent its budget or loops without progress. */
|
|
155
|
+
export interface TeamItemAction {
|
|
156
|
+
readonly kind: "teamItem"
|
|
157
|
+
readonly teamID: string
|
|
158
|
+
readonly item: string
|
|
159
|
+
readonly reason: "budget" | "loop"
|
|
160
|
+
readonly pair?: readonly [string, string]
|
|
161
|
+
}
|
|
128
162
|
|
|
129
163
|
/**
|
|
130
164
|
* (CON-06) A Concierge proposal (concierge/proposals.ts): answering `go` runs the act verb on the target AS THE USER
|
|
@@ -227,6 +261,9 @@ export interface Decision {
|
|
|
227
261
|
readonly question: string
|
|
228
262
|
readonly options: readonly DecisionOption[]
|
|
229
263
|
readonly recommendation?: { readonly option: string; readonly why?: string }
|
|
264
|
+
/** (DC-1) Structured choices (an agent's ask only; never with options or an action). */
|
|
265
|
+
readonly choices?: readonly DecisionChoice[]
|
|
266
|
+
readonly kind?: DecisionKind
|
|
230
267
|
readonly urgency: Urgency
|
|
231
268
|
readonly tier: Tier
|
|
232
269
|
readonly deadline?: number
|
|
@@ -257,6 +294,8 @@ export interface AskInput {
|
|
|
257
294
|
readonly question: string
|
|
258
295
|
readonly options?: readonly { readonly id: string; readonly label: string; readonly safe?: boolean }[]
|
|
259
296
|
readonly recommendation?: { readonly option: string; readonly why?: string }
|
|
297
|
+
readonly choices?: readonly { readonly id: string; readonly label: string; readonly options: readonly { readonly id: string; readonly label: string; readonly recommended?: boolean }[] }[]
|
|
298
|
+
readonly kind?: DecisionKind
|
|
260
299
|
readonly urgency?: Urgency
|
|
261
300
|
readonly tier?: Tier
|
|
262
301
|
/** From now. */
|
|
@@ -271,6 +310,8 @@ export interface AskInput {
|
|
|
271
310
|
export interface AnswerInput {
|
|
272
311
|
readonly option?: string
|
|
273
312
|
readonly text?: string
|
|
313
|
+
/** (DC-1) One option per choice group. */
|
|
314
|
+
readonly picks?: Readonly<Record<string, string>>
|
|
274
315
|
/** (CON-06) Absent = tap. */
|
|
275
316
|
readonly via?: AnswerVia
|
|
276
317
|
}
|
|
@@ -305,6 +346,8 @@ export interface DecisionsDeps {
|
|
|
305
346
|
readonly changed?: (userID: string, decision: Decision) => void
|
|
306
347
|
/** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
|
|
307
348
|
readonly act?: DecisionActor
|
|
349
|
+
/** Only the authenticated user's answer path; never answerByApp or the deadline sweep. */
|
|
350
|
+
readonly answered?: (userID: string, decision: Decision, by: UserActor) => Promise<void>
|
|
308
351
|
/** (CON-06) Admits voice/text answers; without it every voice/text answer is refused. */
|
|
309
352
|
readonly via?: ViaCheck
|
|
310
353
|
/** (CON-06) Whether a session is one of the user's Concierge sessions (current or past): the Decision's `as`. */
|
|
@@ -364,6 +407,9 @@ function validate(input: AskInput) {
|
|
|
364
407
|
if (typeof option !== "string" || !has(option)) throw invalid("The recommendation must name one of the options")
|
|
365
408
|
recommendation = { option, ...(why === undefined ? {} : { why: cleanLabel(why, WHY_MAX, "The recommendation's reason") }) }
|
|
366
409
|
}
|
|
410
|
+
const choices = input.choices === undefined ? undefined : validChoices(input.choices)
|
|
411
|
+
if (choices && options.length) throw invalid("An ask offers either options or choices, not both")
|
|
412
|
+
if (input.kind !== undefined && !DECISION_KINDS.includes(input.kind)) throw invalid(`kind is one of ${DECISION_KINDS.join(", ")}`)
|
|
367
413
|
const urgency = input.urgency ?? "normal"
|
|
368
414
|
if (!URGENCIES.includes(urgency)) throw invalid(`urgency is one of ${URGENCIES.join(", ")}`)
|
|
369
415
|
const tier = input.tier ?? "confirm"
|
|
@@ -378,7 +424,62 @@ function validate(input: AskInput) {
|
|
|
378
424
|
}
|
|
379
425
|
const expiresInMs = input.expiresInMs ?? Math.min(ASK_EXPIRES_MAX_MS, Math.max(ASK_EXPIRES_MS, (deadlineMs ?? 0) + ASK_EXPIRES_MS))
|
|
380
426
|
if (deadlineMs !== undefined && deadlineMs > expiresInMs) throw invalid("The deadline is after the ask expires")
|
|
381
|
-
return { options, recommendation, urgency, tier, deadlineMs, onTimeout, expiresInMs }
|
|
427
|
+
return { options, recommendation, choices, kind: input.kind, urgency, tier, deadlineMs, onTimeout, expiresInMs }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function validChoices(raw: unknown): DecisionChoice[] {
|
|
431
|
+
if (!Array.isArray(raw) || raw.length < 1 || raw.length > CHOICES_MAX) throw invalid(`choices are 1–${CHOICES_MAX} groups`)
|
|
432
|
+
const groups: DecisionChoice[] = []
|
|
433
|
+
for (const group of raw as Record<string, unknown>[]) {
|
|
434
|
+
if (typeof group?.id !== "string" || !CHOICE_ID.test(group.id)) throw invalid("Choice group ids are 1–8 letters, digits, - or _")
|
|
435
|
+
if (groups.some((g) => g.id === group.id)) throw invalid(`Choice group ${group.id} is given twice`)
|
|
436
|
+
const label = cleanLabel(group.label, CHOICE_LABEL_MAX, `Choice group ${group.id}'s label`)
|
|
437
|
+
const list = group.options
|
|
438
|
+
if (!Array.isArray(list) || list.length < CHOICE_OPTIONS_MIN || list.length > CHOICE_OPTIONS_MAX) throw invalid(`Choice group ${group.id} offers ${CHOICE_OPTIONS_MIN}–${CHOICE_OPTIONS_MAX} options`)
|
|
439
|
+
const options: { id: string; label: string; recommended?: true }[] = []
|
|
440
|
+
for (const option of list as Record<string, unknown>[]) {
|
|
441
|
+
if (typeof option?.id !== "string" || !OPTION_ID.test(option.id)) throw invalid("Option ids are 1–32 lowercase letters, digits, - or _")
|
|
442
|
+
if (options.some((o) => o.id === option.id)) throw invalid(`Option ${option.id} is given twice in ${group.id}`)
|
|
443
|
+
if (option.recommended !== undefined && typeof option.recommended !== "boolean") throw invalid("recommended must be true or false")
|
|
444
|
+
options.push({ id: option.id, label: cleanLabel(option.label, OPTION_LABEL_MAX, "An option label"), ...(option.recommended === true ? { recommended: true as const } : {}) })
|
|
445
|
+
}
|
|
446
|
+
if (options.filter((o) => o.recommended).length > 1) throw invalid(`Choice group ${group.id} has more than one recommended option`)
|
|
447
|
+
groups.push({ id: group.id, label, options })
|
|
448
|
+
}
|
|
449
|
+
return groups
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** (DC-1) An answer body's `picks`: undefined when absent, else {group: option} strings (checked against the Decision later). */
|
|
453
|
+
export function answerPicks(raw: unknown): Record<string, string> | undefined {
|
|
454
|
+
if (raw === undefined) return undefined
|
|
455
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw) || Object.keys(raw).length > CHOICES_MAX) throw invalid("picks must be {group: option}")
|
|
456
|
+
// No prototype: a "__proto__" key stays an own key (an unknown group, refused later) instead of vanishing.
|
|
457
|
+
const out: Record<string, string> = Object.create(null)
|
|
458
|
+
for (const [group, option] of Object.entries(raw)) {
|
|
459
|
+
if (typeof option !== "string") throw invalid("picks must be {group: option}")
|
|
460
|
+
out[group] = option
|
|
461
|
+
}
|
|
462
|
+
return out
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The audit detail of an answer: the option, the picks (validated IDs) or "text". */
|
|
466
|
+
export function answerDetail(answer: DecisionAnswer | undefined): string {
|
|
467
|
+
if (answer?.option !== undefined) return answer.option
|
|
468
|
+
if (answer?.picks) return `picks ${Object.entries(answer.picks).map(([g, o]) => `${g}=${o}`).join(",")}`
|
|
469
|
+
return "text"
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** What the asker reads: the option, the picks ("D1: label [a]" per line) or the text, then the note. */
|
|
473
|
+
function answerText(decision: Decision, choice: { option?: string; text?: string; picks?: Readonly<Record<string, string>> }, by?: string): string {
|
|
474
|
+
const head = `Decision ${decision.id}:${by ? ` ${by}` : ""}`
|
|
475
|
+
if (choice.picks && decision.choices) {
|
|
476
|
+
const lines = decision.choices.map((g) => `${g.id}: ${g.options.find((o) => o.id === choice.picks![g.id])?.label ?? choice.picks![g.id]} [${choice.picks![g.id]}]`)
|
|
477
|
+
return `${head}\n${lines.join("\n")}${choice.text ? `\n${choice.text}` : ""}`
|
|
478
|
+
}
|
|
479
|
+
const option = decision.options.find((o) => o.id === choice.option)
|
|
480
|
+
if (option) return `Decision ${decision.id}: ${option.label} [${option.id}]${by ? ` ${by}` : ""}${choice.text ? `\n${choice.text}` : ""}`
|
|
481
|
+
if (decision.choices) return `Decision ${decision.id}: no picks, text: ${choice.text}${by ? ` ${by}` : ""}`
|
|
482
|
+
return `Decision ${decision.id}: ${choice.text}${by ? ` ${by}` : ""}`
|
|
382
483
|
}
|
|
383
484
|
|
|
384
485
|
export class Decisions {
|
|
@@ -404,6 +505,7 @@ export class Decisions {
|
|
|
404
505
|
if (actor.kind === "user") throw invalid("You are @me: the user cannot ask themselves")
|
|
405
506
|
if (typeof input.question !== "string") throw invalid("question must be a string")
|
|
406
507
|
const v = validate(input)
|
|
508
|
+
if (v.choices && extra.action) throw invalid("A hub action never uses choices")
|
|
407
509
|
const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
|
|
408
510
|
const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
|
|
409
511
|
const asConcierge = actor.kind === "session" && (await this.isConcierge(userID, actor.sessionID))
|
|
@@ -416,6 +518,8 @@ export class Decisions {
|
|
|
416
518
|
question: message.text,
|
|
417
519
|
options: v.options,
|
|
418
520
|
...(v.recommendation ? { recommendation: v.recommendation } : {}),
|
|
521
|
+
...(v.choices ? { choices: v.choices } : {}),
|
|
522
|
+
...(v.kind ? { kind: v.kind } : {}),
|
|
419
523
|
urgency: v.urgency,
|
|
420
524
|
tier: v.tier,
|
|
421
525
|
...(v.deadlineMs === undefined ? {} : { deadline: message.at + v.deadlineMs }),
|
|
@@ -480,9 +584,13 @@ export class Decisions {
|
|
|
480
584
|
const decision = await this.settle(userID, id, answer)
|
|
481
585
|
if (!decision) throw gone()
|
|
482
586
|
const note = decision.action ? await this.runAction(userID, decision, choice, by) : undefined
|
|
587
|
+
// The answer is settled and any action has completed. An involvement hook must not undo the user's answer.
|
|
588
|
+
if (this.deps.answered) {
|
|
589
|
+
try { await this.deps.answered(userID, decision, by) }
|
|
590
|
+
catch (cause) { console.error("[hub] user Decision involvement failed:", cause) }
|
|
591
|
+
}
|
|
483
592
|
this.emit(userID, decision)
|
|
484
|
-
const
|
|
485
|
-
const said = option ? `Decision ${id}: ${option.label} [${option.id}]${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text}`
|
|
593
|
+
const said = answerText(decision, choice)
|
|
486
594
|
const text = note ? `${said}\n${note}` : said
|
|
487
595
|
return { decision, ...(await this.replyTo(userID, decision, text, by)) }
|
|
488
596
|
}
|
|
@@ -496,7 +604,7 @@ export class Decisions {
|
|
|
496
604
|
async answerByApp(
|
|
497
605
|
userID: string,
|
|
498
606
|
id: string,
|
|
499
|
-
input: { readonly option?: string; readonly text?: string },
|
|
607
|
+
input: { readonly option?: string; readonly text?: string; readonly picks?: Readonly<Record<string, string>> },
|
|
500
608
|
app: AppAnswerer,
|
|
501
609
|
checks: { readonly owned: (decision: Decision) => Promise<boolean>; readonly ready?: (decision: Decision) => void },
|
|
502
610
|
): Promise<{ decision: Decision; reply?: Message }> {
|
|
@@ -513,9 +621,7 @@ export class Decisions {
|
|
|
513
621
|
const decision = await this.settle(userID, id, answer)
|
|
514
622
|
if (!decision) throw gone()
|
|
515
623
|
this.emit(userID, decision)
|
|
516
|
-
const
|
|
517
|
-
const by = `(answered by the app “${label ?? app.party}”)`
|
|
518
|
-
const text = option ? `Decision ${id}: ${option.label} [${option.id}] ${by}${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text} ${by}`
|
|
624
|
+
const text = answerText(decision, choice, `(answered by the app “${label ?? app.party}”)`)
|
|
519
625
|
return { decision, ...(await this.replyTo(userID, decision, text, HUB_USER(userID))) }
|
|
520
626
|
}
|
|
521
627
|
|
|
@@ -610,8 +716,29 @@ export class Decisions {
|
|
|
610
716
|
return decision.state === "open" && this.now() >= decision.expiresAt ? { ...decision, state: "expired" } : decision
|
|
611
717
|
}
|
|
612
718
|
|
|
613
|
-
private choice(decision: Decision, input: AnswerInput): { option?: string; text?: string } {
|
|
719
|
+
private choice(decision: Decision, input: AnswerInput): { option?: string; text?: string; picks?: Readonly<Record<string, string>> } {
|
|
614
720
|
const text = input.text === undefined ? undefined : cleanText(input.text, ANSWER_MAX, "The answer text")
|
|
721
|
+
if (input.picks !== undefined && !decision.choices) throw invalid("This decision has no choices")
|
|
722
|
+
if (decision.choices) {
|
|
723
|
+
if (input.option !== undefined) throw invalid("This decision has choices: answer with picks")
|
|
724
|
+
// Text alone is the fallback of a console or device that knows no picks.
|
|
725
|
+
if (input.picks === undefined) {
|
|
726
|
+
if (text === undefined) throw invalid(`Pick an option for each of ${decision.choices.map((g) => g.id).join(", ")}`)
|
|
727
|
+
return { text }
|
|
728
|
+
}
|
|
729
|
+
const picks = input.picks as unknown
|
|
730
|
+
if (typeof picks !== "object" || picks === null || Array.isArray(picks)) throw invalid("picks must be {group: option}")
|
|
731
|
+
const given = picks as Record<string, unknown>
|
|
732
|
+
for (const key of Object.keys(given)) if (!decision.choices.some((g) => g.id === key)) throw invalid(`No choice group ${sanitizeLabel(key, 16)}`)
|
|
733
|
+
const out: Record<string, string> = {}
|
|
734
|
+
for (const group of decision.choices) {
|
|
735
|
+
const pick = given[group.id]
|
|
736
|
+
if (pick === undefined) throw invalid(`Pick an option for ${group.id}`)
|
|
737
|
+
if (typeof pick !== "string" || !group.options.some((o) => o.id === pick)) throw invalid(`Pick one of ${group.id}'s options: ${group.options.map((o) => o.id).join(", ")}`)
|
|
738
|
+
out[group.id] = pick
|
|
739
|
+
}
|
|
740
|
+
return { picks: out, ...(text === undefined ? {} : { text }) }
|
|
741
|
+
}
|
|
615
742
|
if (!decision.options.length) {
|
|
616
743
|
if (input.option !== undefined) throw invalid("This decision has no options: answer with text")
|
|
617
744
|
if (text === undefined) throw invalid("This decision needs an answer text")
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
// answers, and nothing sent to an app raises a persistent depth.
|
|
49
49
|
import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
|
|
50
50
|
import type { WakeSkip } from "../../../packages/sdk/src/types.ts"
|
|
51
|
+
import type { ItemBudgets } from "../teams/budgets"
|
|
51
52
|
import { isDeliverResult } from "../../protocol"
|
|
52
53
|
import { randomBytes } from "node:crypto"
|
|
53
54
|
import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
|
|
@@ -58,7 +59,7 @@ import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, rende
|
|
|
58
59
|
import { noticeOnly } from "../../../packages/sdk/src/envelope.ts"
|
|
59
60
|
import { AgentError } from "./errors"
|
|
60
61
|
import { type ExpiredReason, parseNotAfter } from "./not-after"
|
|
61
|
-
import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits
|
|
62
|
+
import { HOP_ABSOLUTE_MAX, HOP_CAP, HopTracker, MessageLimiter, type MessageLimits } from "./message-limits"
|
|
62
63
|
import { parseAddress } from "./names"
|
|
63
64
|
import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
|
|
64
65
|
|
|
@@ -115,6 +116,10 @@ export interface InboundPolicy {
|
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
export interface DeliveryDeps {
|
|
119
|
+
/** Item budgets are consulted only for shared-team exchanges, after the existing wake backstop admits. */
|
|
120
|
+
readonly itemBudgets?: Pick<ItemBudgets, "wake" | "current" | "currents" | "setCurrent" | "chargeChild">
|
|
121
|
+
/** Current team/item eligibility, shared by explicit sends and inherited replies. */
|
|
122
|
+
readonly items?: { eligible(userID: string, item: string, sessionIDs: readonly string[]): Promise<{ readonly ok: true; readonly teamID: string; readonly startedAt?: number; readonly parentItem?: string; readonly child?: boolean } | { readonly ok: false; readonly reason: "closed" | "archived" | "unseated"; readonly sessionID?: string; readonly team?: string }> }
|
|
118
123
|
readonly state: StateStore
|
|
119
124
|
readonly counters: Counters
|
|
120
125
|
readonly registry: DeliveryRegistry
|
|
@@ -126,10 +131,23 @@ export interface DeliveryDeps {
|
|
|
126
131
|
/** Labels of a session sender (default: its @name from the directory). */
|
|
127
132
|
readonly describe?: (userID: string, sessionID: string) => PartyInfo
|
|
128
133
|
readonly alert?: (userID: string, alert: DeliveryAlert) => void
|
|
134
|
+
/** The first over-cap message between two seats of a team, once per persistent chain. */
|
|
135
|
+
readonly teamHopCap?: (userID: string, input: { readonly teamID: string; readonly sender: string; readonly recipient: string; readonly limit: number }) => Promise<void>
|
|
136
|
+
/**
|
|
137
|
+
* (TA2-7) A message tagged with an item between two seats: true when that pair is looping on the item without
|
|
138
|
+
* progress, so the message is delivered without a wake (and charges no wake). Never throws through: a failure wakes.
|
|
139
|
+
*/
|
|
140
|
+
readonly itemLoop?: (userID: string, input: { readonly teamID: string; readonly item: string; readonly from: string; readonly to: string }) => Promise<boolean>
|
|
141
|
+
/** A quiet, hub-authored note to the team's entry seat; never counts a message hop. */
|
|
142
|
+
readonly teamHopNotice?: (userID: string, teamID: string) => Promise<string | undefined>
|
|
129
143
|
/** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
|
|
130
144
|
readonly expired?: (userID: string, message: Message) => void
|
|
131
145
|
/** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
|
|
132
146
|
readonly limits?: Partial<MessageLimits>
|
|
147
|
+
/** Configured global agent-only wake hop limit (default 8; the live ceiling is AgentLimits', the bound HOP_ABSOLUTE_MAX). */
|
|
148
|
+
readonly hopLimit?: number
|
|
149
|
+
/** Latest hub-wide limit, consulted for every send and wake (console settings can change it live). */
|
|
150
|
+
readonly currentHopLimit?: () => number
|
|
133
151
|
/** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
|
|
134
152
|
readonly relayWindowMs?: number
|
|
135
153
|
/** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
|
|
@@ -141,12 +159,14 @@ export interface DeliveryDeps {
|
|
|
141
159
|
/** How often the re-wake loop runs (default REWAKE_TICK_MS). */
|
|
142
160
|
readonly tickMs?: number
|
|
143
161
|
/** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
|
|
144
|
-
readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
|
|
162
|
+
readonly teams?: Pick<TeamPolicy, "prepare" | "answer" | "hopContext">
|
|
145
163
|
/** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
|
|
146
164
|
readonly inbound?: InboundPolicy
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
export interface SendOptions {
|
|
168
|
+
/** Explicit team intake item, already authorized by the agent message route. */
|
|
169
|
+
readonly item?: string
|
|
150
170
|
/** false: admit without starting a turn (reported queued). */
|
|
151
171
|
readonly wake?: boolean
|
|
152
172
|
/** Steer into a running turn, like an ask. */
|
|
@@ -173,6 +193,8 @@ export interface Sent {
|
|
|
173
193
|
readonly woke?: boolean
|
|
174
194
|
/** Why it was not (only when woke is false). */
|
|
175
195
|
readonly wakeSkipped?: WakeSkip
|
|
196
|
+
/** Effective hop cap or wake budget, only with the corresponding wakeSkipped reason. */
|
|
197
|
+
readonly wakeLimit?: number
|
|
176
198
|
}
|
|
177
199
|
|
|
178
200
|
/** A reply or decline as sent, with the message it answered. */
|
|
@@ -273,6 +295,7 @@ export class Delivery {
|
|
|
273
295
|
private readonly owner: string
|
|
274
296
|
|
|
275
297
|
constructor(deps: DeliveryDeps) {
|
|
298
|
+
if (deps.hopLimit !== undefined && (!Number.isSafeInteger(deps.hopLimit) || deps.hopLimit < 1 || deps.hopLimit > HOP_ABSOLUTE_MAX)) throw new Error(`hopLimit must be 1–${HOP_ABSOLUTE_MAX}`)
|
|
276
299
|
this.deps = deps
|
|
277
300
|
const now = (this.now = deps.now ?? Date.now)
|
|
278
301
|
this.timers = deps.timers ?? defaultTimers
|
|
@@ -314,7 +337,9 @@ export class Delivery {
|
|
|
314
337
|
if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
|
|
315
338
|
const from = this.party(userID, actor)
|
|
316
339
|
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
317
|
-
|
|
340
|
+
const picked = this.pick(resolved)
|
|
341
|
+
const itemContext = await this.checkItem(userID, options.item, from, addr, [picked.sessionID])
|
|
342
|
+
return this.sendTo(userID, from, addr, resolved, picked, kind, text, options, itemContext)
|
|
318
343
|
}
|
|
319
344
|
|
|
320
345
|
/** Like send, to every session the address names (each role holder gets its own message). */
|
|
@@ -323,11 +348,22 @@ export class Delivery {
|
|
|
323
348
|
if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
|
|
324
349
|
const from = this.party(userID, actor)
|
|
325
350
|
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
351
|
+
const itemContext = await this.checkItem(userID, options.item, from, addr, resolved.sessions.map((s) => s.sessionID))
|
|
326
352
|
const sent: Sent[] = []
|
|
327
|
-
for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
|
|
353
|
+
for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options, itemContext))
|
|
328
354
|
return sent
|
|
329
355
|
}
|
|
330
356
|
|
|
357
|
+
private async checkItem(userID: string, item: string | undefined, from: Party, addr: string, recipients: readonly string[]): Promise<{ teamID: string; startedAt?: number; parentItem?: string; child?: boolean } | undefined> {
|
|
358
|
+
if (item === undefined) return undefined
|
|
359
|
+
if (from.kind !== "session" || !this.deps.items) throw new AgentError("invalid", "item applies to messages between team members")
|
|
360
|
+
const result = await this.deps.items.eligible(userID, item, [from.sessionID, ...recipients])
|
|
361
|
+
if (result.ok) return { teamID: result.teamID, ...(result.startedAt !== undefined ? { startedAt: result.startedAt } : {}), ...(result.parentItem ? { parentItem: result.parentItem } : {}), ...(result.child ? { child: true } : {}) }
|
|
362
|
+
if (result.reason === "closed") throw new AgentError("invalid", `item ${item} is closed`)
|
|
363
|
+
if (result.reason === "archived") throw new AgentError("invalid", `team ${result.team ?? "of this item"} is archived`)
|
|
364
|
+
throw new AgentError("invalid", `the sender and every recipient of ${addr} must be seated in item ${item}'s team`)
|
|
365
|
+
}
|
|
366
|
+
|
|
331
367
|
/** A notAfter validated against this engine's clock (parseNotAfter): throws invalid or expired. */
|
|
332
368
|
checkNotAfter(value: unknown): number | undefined {
|
|
333
369
|
return parseNotAfter(value, this.now())
|
|
@@ -348,6 +384,30 @@ export class Delivery {
|
|
|
348
384
|
return this.hops.humanTurn(userID, sessionID)
|
|
349
385
|
}
|
|
350
386
|
|
|
387
|
+
/** A quiet hub notice (never a wake, never a hop) to the team's entry seat; false when the team has none. */
|
|
388
|
+
async notifyTeamEntry(userID: string, teamID: string, text: string): Promise<boolean> {
|
|
389
|
+
const entry = await this.deps.teamHopNotice?.(userID, teamID)
|
|
390
|
+
if (!entry) return false
|
|
391
|
+
const id = this.newID()
|
|
392
|
+
await this.request(userID, entry, {
|
|
393
|
+
op: "deliver", sessionID: entry, messageID: id, wake: false,
|
|
394
|
+
text: `[hub] notice · ${text} · ${id}`,
|
|
395
|
+
meta: { kind: "msg", from: "hub", to: entry, delivery: "queue" },
|
|
396
|
+
})
|
|
397
|
+
return true
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async notifyTeamHopCap(userID: string, teamID: string, decisionID: string): Promise<void> {
|
|
401
|
+
const entry = await this.deps.teamHopNotice?.(userID, teamID)
|
|
402
|
+
if (!entry) return
|
|
403
|
+
const id = this.newID()
|
|
404
|
+
await this.request(userID, entry, {
|
|
405
|
+
op: "deliver", sessionID: entry, messageID: id, wake: false,
|
|
406
|
+
text: `[hub] notice · Team messages reached the hop cap. The user has a Renew Decision ${decisionID}; wait for their choice. · ${id}`,
|
|
407
|
+
meta: { kind: "msg", from: "hub", to: entry, delivery: "queue" },
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
351
411
|
/** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
|
|
352
412
|
/** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
|
|
353
413
|
flush(userID: string, sessionID?: string): Promise<void> {
|
|
@@ -466,7 +526,7 @@ export class Delivery {
|
|
|
466
526
|
return { message }
|
|
467
527
|
}
|
|
468
528
|
|
|
469
|
-
private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
|
|
529
|
+
private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions, itemContext?: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean }): Promise<Sent> {
|
|
470
530
|
const sessionID = entry.sessionID
|
|
471
531
|
const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
|
|
472
532
|
await this.deps.inbound?.outbound?.(userID, from, kind)
|
|
@@ -475,6 +535,19 @@ export class Delivery {
|
|
|
475
535
|
await this.limiter.admit(userID, from, recipient)
|
|
476
536
|
const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
|
|
477
537
|
const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
|
|
538
|
+
// Try the sender's latest received/assigned item across teams, not just the default shared hop team:
|
|
539
|
+
// a session may be seated in A and B while its current work belongs to B.
|
|
540
|
+
if (!options.item && from.kind === "session" && this.deps.itemBudgets && this.deps.items) {
|
|
541
|
+
for (const candidate of await this.deps.itemBudgets.currents(userID, from.sessionID)) {
|
|
542
|
+
try {
|
|
543
|
+
const eligible = await this.deps.items.eligible(userID, candidate.item, [from.sessionID, sessionID])
|
|
544
|
+
if (!eligible.ok || eligible.teamID !== candidate.teamID) continue
|
|
545
|
+
itemContext = { teamID: eligible.teamID, ...(eligible.startedAt !== undefined ? { startedAt: eligible.startedAt } : {}), ...(eligible.parentItem ? { parentItem: eligible.parentItem } : {}), ...(eligible.child ? { child: true } : {}) }
|
|
546
|
+
options = { ...options, item: candidate.item }
|
|
547
|
+
break
|
|
548
|
+
} catch (cause) { console.error("[hub] sender item context lookup failed:", cause) }
|
|
549
|
+
}
|
|
550
|
+
}
|
|
478
551
|
const message = await this.messages.send(userID, {
|
|
479
552
|
from,
|
|
480
553
|
to: addr,
|
|
@@ -482,6 +555,7 @@ export class Delivery {
|
|
|
482
555
|
kind,
|
|
483
556
|
text,
|
|
484
557
|
hop,
|
|
558
|
+
...(options.item ? { item: options.item } : {}),
|
|
485
559
|
...(generation === undefined ? {} : { generation }),
|
|
486
560
|
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
487
561
|
...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
|
|
@@ -489,12 +563,22 @@ export class Delivery {
|
|
|
489
563
|
await this.hops.received(userID, recipient, hop, from)
|
|
490
564
|
this.users.add(userID)
|
|
491
565
|
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
|
|
492
|
-
const
|
|
493
|
-
|
|
566
|
+
const team = "team" in resolved ? resolved.team : undefined
|
|
567
|
+
const explicitTeamID = typeof team === "object" && team !== null && "teamID" in team && typeof team.teamID === "string" ? team.teamID : undefined
|
|
568
|
+
const context = await this.deps.teams?.hopContext(userID, from, recipient, explicitTeamID ?? itemContext?.teamID)
|
|
569
|
+
if (context && itemContext?.teamID === context.teamID && this.deps.itemBudgets && message.item) {
|
|
570
|
+
await this.deps.itemBudgets.setCurrent(userID, context.teamID, sessionID, message.item)
|
|
571
|
+
.catch((cause) => console.error("[hub] item context write failed:", cause))
|
|
572
|
+
}
|
|
573
|
+
const global = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP
|
|
574
|
+
const limit = Math.min(context?.limit ?? global, global)
|
|
575
|
+
const skip = await this.wakeFor(userID, message, unwanted, limit, context?.teamID, itemContext?.teamID === context?.teamID ? itemContext : undefined)
|
|
576
|
+
const skipped = skip.reason
|
|
577
|
+
if (kind === "ask" && unwanted === undefined && hop <= limit) {
|
|
494
578
|
const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
|
|
495
579
|
await this.deps.state.put(userID, REWAKE, message.id, rewake)
|
|
496
580
|
}
|
|
497
|
-
return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
|
|
581
|
+
return this.deliver(userID, message, sessionID, skipped, options.urgent === true, skip.wakeLimit ?? (skipped === "hop_cap" ? limit : skipped === "budget" ? this.limiter.wakeLimit : undefined))
|
|
498
582
|
}
|
|
499
583
|
|
|
500
584
|
private schedule(): void {
|
|
@@ -589,7 +673,7 @@ export class Delivery {
|
|
|
589
673
|
replyTo: string,
|
|
590
674
|
options: SendOptions,
|
|
591
675
|
kind: "reply" | "decline",
|
|
592
|
-
store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
|
|
676
|
+
store: (input: { from: Party; replyTo: string; hop: number; generation?: number; item?: string }) => Promise<{ message: Message; original: Message }>,
|
|
593
677
|
): Promise<Replied> {
|
|
594
678
|
const from = this.party(userID, actor)
|
|
595
679
|
const original = await this.messages.get(userID, replyTo)
|
|
@@ -605,36 +689,94 @@ export class Delivery {
|
|
|
605
689
|
if (to) await this.limiter.admit(userID, from, to)
|
|
606
690
|
const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
|
|
607
691
|
const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
|
|
608
|
-
|
|
692
|
+
let item: string | undefined
|
|
693
|
+
let itemContext: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean } | undefined
|
|
694
|
+
if (original?.item && from.kind === "session" && to?.kind === "session") {
|
|
695
|
+
try {
|
|
696
|
+
const eligible = await this.deps.items?.eligible(userID, original.item, [from.sessionID, to.sessionID])
|
|
697
|
+
if (eligible?.ok) { item = original.item; itemContext = { teamID: eligible.teamID, ...(eligible.startedAt !== undefined ? { startedAt: eligible.startedAt } : {}), ...(eligible.parentItem ? { parentItem: eligible.parentItem } : {}), ...(eligible.child ? { child: true } : {}) } }
|
|
698
|
+
} catch (cause) {
|
|
699
|
+
// A reply must still be delivered; uncertainty about item attribution fails closed (no item).
|
|
700
|
+
console.error("[hub] reply item eligibility failed:", cause)
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }), ...(item ? { item } : {}) })
|
|
609
704
|
const { message } = answered
|
|
610
705
|
await this.hops.received(userID, message.recipient, hop, from)
|
|
611
706
|
this.users.add(userID)
|
|
612
707
|
if (message.recipient.kind !== "session") return answered
|
|
613
708
|
const sessionID = message.recipient.sessionID
|
|
614
709
|
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
|
|
615
|
-
const
|
|
616
|
-
|
|
710
|
+
const context = await this.deps.teams?.hopContext(userID, from, message.recipient, itemContext?.teamID)
|
|
711
|
+
if (context && itemContext?.teamID === context.teamID && this.deps.itemBudgets && message.item) {
|
|
712
|
+
await this.deps.itemBudgets.setCurrent(userID, context.teamID, sessionID, message.item)
|
|
713
|
+
.catch((cause) => console.error("[hub] item context write failed:", cause))
|
|
714
|
+
}
|
|
715
|
+
const global = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP
|
|
716
|
+
const limit = Math.min(context?.limit ?? global, global)
|
|
717
|
+
const skip = await this.wakeFor(userID, message, unwanted, limit, context?.teamID, itemContext?.teamID === context?.teamID ? itemContext : undefined)
|
|
718
|
+
const skipped = skip.reason
|
|
719
|
+
return { ...(await this.deliver(userID, message, sessionID, skipped, true, skip.wakeLimit ?? (skipped === "hop_cap" ? limit : skipped === "budget" ? this.limiter.wakeLimit : undefined))), original: answered.original }
|
|
617
720
|
}
|
|
618
721
|
|
|
619
722
|
/**
|
|
620
723
|
* Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
|
|
621
724
|
* the hop cap (alerting once at the cap), or no wake budget left.
|
|
622
725
|
*/
|
|
623
|
-
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip
|
|
624
|
-
if (unwanted) return unwanted
|
|
625
|
-
if (message.recipient.kind !== "session") return "wake_false"
|
|
626
|
-
if (
|
|
627
|
-
if (message.
|
|
726
|
+
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined, limit = this.deps.currentHopLimit?.() ?? this.deps.hopLimit ?? HOP_CAP, teamID?: string, itemContext?: { teamID: string; startedAt?: number; parentItem?: string; child?: boolean }): Promise<{ reason?: WakeSkip; wakeLimit?: number }> {
|
|
727
|
+
if (unwanted) return { reason: unwanted }
|
|
728
|
+
if (message.recipient.kind !== "session") return { reason: "wake_false" }
|
|
729
|
+
if (message.hop > limit) {
|
|
730
|
+
if (teamID && message.from.kind === "session") {
|
|
731
|
+
try {
|
|
732
|
+
await this.deps.teamHopCap?.(userID, { teamID, sender: message.from.sessionID, recipient: message.recipient.sessionID, limit })
|
|
733
|
+
} catch (cause) {
|
|
734
|
+
// The message has already been stored. A failed Decision must not prevent delivery or wake it.
|
|
735
|
+
console.error(`[hub] team hop-cap alert failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
736
|
+
}
|
|
737
|
+
} else if (message.hop === limit + 1) {
|
|
628
738
|
this.deps.alert?.(userID, {
|
|
629
739
|
kind: "hopCap",
|
|
630
740
|
sessionID: message.recipient.sessionID,
|
|
631
741
|
messageID: message.id,
|
|
632
|
-
text: `Agent messages went ${
|
|
742
|
+
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
743
|
})
|
|
634
744
|
}
|
|
635
|
-
return "hop_cap"
|
|
745
|
+
return { reason: "hop_cap" }
|
|
746
|
+
}
|
|
747
|
+
if (teamID && itemContext?.teamID === teamID && message.item && message.from.kind === "session" && this.deps.itemLoop) {
|
|
748
|
+
let looping = false
|
|
749
|
+
try { looping = await this.deps.itemLoop(userID, { teamID, item: message.item, from: message.from.sessionID, to: message.recipient.sessionID }) }
|
|
750
|
+
catch (cause) { console.error("[hub] item loop check failed:", cause) }
|
|
751
|
+
if (looping) return { reason: "item_loop" }
|
|
752
|
+
}
|
|
753
|
+
if (!(await this.limiter.wake(userID, message.recipient))) return { reason: "budget" }
|
|
754
|
+
if (teamID && this.deps.itemBudgets) {
|
|
755
|
+
// A wake is the hub deciding to wake: do not refund for a later spoke failure or offline pending delivery.
|
|
756
|
+
let chargedItem = itemContext && message.item ? (itemContext.child ? itemContext.parentItem : message.item) : undefined
|
|
757
|
+
let chargedStartedAt = chargedItem === message.item ? itemContext?.startedAt : undefined
|
|
758
|
+
if (chargedItem && itemContext?.parentItem) {
|
|
759
|
+
try {
|
|
760
|
+
const root = message.from.kind === "session" && message.recipient.kind === "session" ? await this.deps.items?.eligible(userID, chargedItem, [message.from.sessionID, message.recipient.sessionID]) : undefined
|
|
761
|
+
if (!root?.ok || root.child || root.teamID !== teamID) chargedItem = undefined
|
|
762
|
+
else chargedStartedAt = root.startedAt
|
|
763
|
+
} catch (cause) { console.error("[hub] parent item eligibility failed:", cause); chargedItem = undefined }
|
|
764
|
+
}
|
|
765
|
+
let budget: Awaited<ReturnType<ItemBudgets["wake"]>>
|
|
766
|
+
const seats = message.from.kind === "session" ? [message.from.sessionID, message.recipient.sessionID] : [message.recipient.sessionID]
|
|
767
|
+
try { budget = await this.deps.itemBudgets.wake(userID, teamID, chargedItem, chargedStartedAt, seats) }
|
|
768
|
+
catch (cause) {
|
|
769
|
+
// The message is stored already. A failed budget read/write must never turn it into a send error or a wake.
|
|
770
|
+
console.error("[hub] item budget charge failed; skipping wake:", cause)
|
|
771
|
+
return { reason: "item_budget" }
|
|
772
|
+
}
|
|
773
|
+
if (budget.allowed && itemContext?.child && message.item) {
|
|
774
|
+
try { await this.deps.itemBudgets.chargeChild(userID, teamID, message.item, chargedItem ?? "pool") }
|
|
775
|
+
catch (cause) { console.error("[hub] child item usage failed:", cause) }
|
|
776
|
+
}
|
|
777
|
+
if (!budget.allowed) return { reason: "item_budget", ...(budget.reason === "wakes" ? { wakeLimit: budget.limit } : {}) }
|
|
636
778
|
}
|
|
637
|
-
return
|
|
779
|
+
return {}
|
|
638
780
|
}
|
|
639
781
|
|
|
640
782
|
/** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
|
|
@@ -672,9 +814,9 @@ export class Delivery {
|
|
|
672
814
|
}
|
|
673
815
|
|
|
674
816
|
/** Delivers now, or queues the message as pending when no spoke takes it. */
|
|
675
|
-
private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
|
|
817
|
+
private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean, wakeLimit?: number): Promise<Sent> {
|
|
676
818
|
const wake = skipped === undefined
|
|
677
|
-
const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
|
|
819
|
+
const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped, ...(wakeLimit !== undefined ? { wakeLimit } : {}) }
|
|
678
820
|
// Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
|
|
679
821
|
// one starts then carries them, instead of them arriving inside it.
|
|
680
822
|
if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The /api/agent error envelope (design §8, §14.5): {error: {code, message, hint?, ...extra}}. Only /api/agent/*
|
|
2
|
-
// answers this way; the console routes keep their {error: string, code?} envelope (the web app reads it)
|
|
2
|
+
// answers this way; the console routes keep their {error: string, code?} envelope (the web app reads it), which
|
|
3
|
+
// is rewritten to this one under /api/v1 only (../api-v1.ts).
|
|
3
4
|
|
|
4
5
|
/** HTTP status of each agent error code. */
|
|
5
6
|
export const AGENT_ERROR_STATUS = {
|
|
@@ -244,9 +244,18 @@ export function roleEvent(sessionID: string, held: boolean, role: { role: string
|
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* (SS-1) Whether a session.changed is a change of state, not of health alone (state === prev). Before health fields,
|
|
249
|
+
* every session.changed was a state change; consumers that mean "went into state X" check this.
|
|
250
|
+
*/
|
|
251
|
+
export const stateChanged = (data: Readonly<Record<string, unknown>> | undefined): boolean => data?.prev === undefined || data.prev !== data.state
|
|
252
|
+
|
|
247
253
|
interface Known {
|
|
248
254
|
state: string
|
|
249
255
|
blockers: Set<string>
|
|
256
|
+
/** (SS-1) The health kind and quota reset last journaled (undefined: ok). */
|
|
257
|
+
healthKind?: string
|
|
258
|
+
healthResetAt?: number
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
/**
|
|
@@ -266,8 +275,17 @@ export class StatusDiff {
|
|
|
266
275
|
const open = new Set(entry.blockers.map((b) => b.id))
|
|
267
276
|
for (const b of entry.blockers) if (!known?.blockers.has(b.id)) out.push({ type: "blocker.opened", family: "session", subject, data: { blockerID: b.id, kind: b.kind } })
|
|
268
277
|
for (const id of known?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", family: "session", subject, data: { blockerID: id } })
|
|
269
|
-
|
|
270
|
-
|
|
278
|
+
// (SS-1) Flat health fields (the journal's data is flat): healthKind while not ok and once as "ok" on recovery,
|
|
279
|
+
// healthResetAt for a quota; a change of health alone is a session.changed too. Never the provider's text.
|
|
280
|
+
const health = (entry as { health?: { kind: string; resetAt?: number } }).health
|
|
281
|
+
const healthKind = health && health.kind !== "ok" ? health.kind : undefined
|
|
282
|
+
const healthResetAt = healthKind !== undefined ? health!.resetAt : undefined
|
|
283
|
+
const healthChanged = known?.healthKind !== healthKind || known?.healthResetAt !== healthResetAt
|
|
284
|
+
if (known?.state !== entry.state || healthChanged) {
|
|
285
|
+
const healthData = healthKind !== undefined ? { healthKind, ...(healthResetAt !== undefined ? { healthResetAt } : {}) } : known?.healthKind !== undefined ? { healthKind: "ok" } : {}
|
|
286
|
+
out.push({ type: "session.changed", family: "session", subject, data: { state: entry.state, ...(known ? { prev: known.state } : {}), ...healthData } })
|
|
287
|
+
}
|
|
288
|
+
after.set(entry.id, { state: entry.state, blockers: open, ...(healthKind !== undefined ? { healthKind } : {}), ...(healthResetAt !== undefined ? { healthResetAt } : {}) })
|
|
271
289
|
}
|
|
272
290
|
this.trees.set(rootID, after)
|
|
273
291
|
return out
|