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