@huynle/hub 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +19 -5
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/sessions.ts +4 -1
- package/packages/cli/dist/hub-cli.js +240 -83
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +12 -7
- package/packages/cli/src/decisions.ts +52 -4
- package/packages/cli/src/format.ts +9 -0
- package/packages/cli/src/mcp.ts +20 -4
- package/packages/cli/src/team-intake-mcp.ts +2 -1
- package/packages/cli/src/teams-mcp.ts +7 -3
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +14 -3
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/api/v1/_root.json +119 -0
- package/packages/protocol/schema/api/v1/account.json +419 -0
- package/packages/protocol/schema/api/v1/decisions.json +635 -0
- package/packages/protocol/schema/api/v1/discovery.json +110 -0
- package/packages/protocol/schema/api/v1/events.json +679 -0
- package/packages/protocol/schema/api/v1/session-actions.json +660 -0
- package/packages/protocol/schema/api/v1/sessions.json +2055 -0
- package/packages/protocol/schema/api/v1/speech.json +492 -0
- package/packages/protocol/schema/api/v1/unspecified.json +282 -0
- package/packages/protocol/schema/app-delivery/v1.schema.json +67 -5
- package/packages/protocol/schema/app-front/v1.schema.json +7 -3
- package/packages/protocol/src/api-v1.ts +8 -0
- package/packages/protocol/src/index.ts +2 -0
- package/packages/protocol/src/teams.ts +13 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +2 -2
- package/packages/sdk/src/ask-fields.ts +76 -0
- package/packages/sdk/src/client.ts +11 -2
- package/packages/sdk/src/envelope.ts +3 -2
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/team-intake.ts +6 -0
- package/packages/sdk/src/teams.ts +2 -0
- package/packages/sdk/src/types.ts +36 -4
- package/packages/sdk/src/wake.ts +9 -4
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/agent-limits.ts +185 -0
- package/src/central/agent/app-decisions.ts +6 -5
- package/src/central/agent/decisions.ts +130 -9
- package/src/central/agent/delivery.ts +134 -22
- package/src/central/agent/errors.ts +2 -1
- package/src/central/agent/events.ts +20 -2
- package/src/central/agent/launch-engine.ts +6 -3
- package/src/central/agent/message-limits.ts +5 -2
- package/src/central/agent/message-routes.ts +33 -6
- package/src/central/agent/messages.ts +7 -0
- package/src/central/agent/placement.ts +4 -2
- package/src/central/agent/read-routes.ts +5 -0
- package/src/central/agent/routes.ts +2 -0
- package/src/central/agent/subscriptions.ts +13 -4
- package/src/central/api-v1.ts +115 -0
- package/src/central/artifacts/store.ts +4 -2
- package/src/central/concierge/board-results.ts +2 -1
- package/src/central/concierge/notifier.ts +4 -2
- package/src/central/config.ts +20 -6
- package/src/central/health/classify.ts +209 -0
- package/src/central/http.ts +285 -23
- package/src/central/recording/index.ts +2 -1
- package/src/central/recording/schema.ts +2 -1
- package/src/central/registry.ts +34 -3
- package/src/central/teams/budgets.ts +144 -0
- package/src/central/teams/hop-stalls.ts +14 -6
- package/src/central/teams/intake.ts +38 -6
- package/src/central/teams/involvement.ts +95 -0
- package/src/central/teams/item-decisions.ts +60 -0
- package/src/central/teams/item-eligibility.ts +35 -0
- package/src/central/teams/item-stalls.ts +87 -0
- package/src/central/teams/loops.ts +40 -0
- package/src/central/teams/progress.ts +154 -0
- package/src/central/teams/routes.ts +2 -2
- package/src/central/teams/store.ts +21 -5
- package/src/central/teams/validation.ts +17 -6
- package/src/central/workspaces/coordinator.ts +3 -1
- package/src/spoke/hub-tool.ts +26 -11
- package/web/dist/assets/{ArtifactPage-C5MMqXLN.js → ArtifactPage-TbFJi59n.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CXSCmhyJ.js → ArtifactPane-7D-M-Ich.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-S0RxNGWy.js → ArtifactWorkbench-DlCLEjut.js} +1 -1
- package/web/dist/assets/{Diagram-BGmyRb-R.js → Diagram-PvOPetOI.js} +1 -1
- package/web/dist/assets/{Library-CFmMbXEa.js → Library-Cdi83pOV.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-7jpOc2HM.js → ProfilesDestination-cryC7P6j.js} +1 -1
- package/web/dist/assets/TeamsPage-B4xrlGhp.js +1 -0
- package/web/dist/assets/Watchtower-60B1JX_J.js +2 -0
- package/web/dist/assets/index-DA0gKIzv.js +33 -0
- package/web/dist/assets/live-B2IfKm37.js +5 -0
- package/web/dist/assets/{live-BUofsQpz.css → live-De-kd3eW.css} +1 -1
- package/web/dist/assets/{renderer2d-B2Nh39UN.js → renderer2d-CsZnF3_o.js} +1 -1
- package/web/dist/assets/{renderer3d-CTYbljHH.js → renderer3d-BIMA0wFn.js} +2 -2
- package/web/dist/assets/{safe-area-CkhEclGo.js → safe-area-dtrnw9NF.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/assets/TeamsPage-C1NGrUR-.js +0 -1
- package/web/dist/assets/Watchtower-DnvlJaU0.js +0 -2
- package/web/dist/assets/index-Da8SKXMw.js +0 -33
- package/web/dist/assets/live-BmqLASSo.js +0 -5
package/packages/cli/src/cli.ts
CHANGED
|
@@ -82,19 +82,22 @@ Identity
|
|
|
82
82
|
hub role release <role[.project]> [--session <addr>]
|
|
83
83
|
|
|
84
84
|
Messaging (text "-" reads it from stdin)
|
|
85
|
-
hub msg <addr> "text" [--all] [--no-wake] [--urgent] [--not-after <ms|ISO time>] [--key K]
|
|
85
|
+
hub msg <addr> "text" [--all] [--no-wake] [--urgent] [--item itk_…] [--not-after <ms|ISO time>] [--key K]
|
|
86
86
|
tell: fire and forget; --not-after: never delivered after that time
|
|
87
|
-
hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--not-after <ms|ISO time>] [--all] [--no-wake] [--urgent] [--key K]
|
|
87
|
+
hub ask <addr> "text" [--wait [secs]] [--expires 2h] [--item itk_…] [--not-after <ms|ISO time>] [--all] [--no-wake] [--urgent] [--key K]
|
|
88
88
|
tracked until answered, declined or expired; --wait alone waits until
|
|
89
89
|
it expires; exit 0 answered, 1 declined or no answer yet, 8 expired;
|
|
90
90
|
--key (Idempotency-Key): a retry with the same key and text answers
|
|
91
|
-
the first send again and sends nothing (24 h, per credential)
|
|
91
|
+
the first send again and sends nothing (24 h, per credential);
|
|
92
|
+
--item: open team intake item, both members seated (older hubs ignore it)
|
|
92
93
|
hub reply <msgID> "text" [--no-wake] goes to the real sender
|
|
93
94
|
hub decline <msgID> --why "reason"
|
|
94
95
|
hub ask @me "question" [--option id=label[:safe]]... [--recommend id [--why "…"]] [--urgency low|normal|high]
|
|
95
96
|
[--deadline 2h [--on-timeout <safe id>]] [--tier confirm|tap] [--about <addr>]...
|
|
97
|
+
[--choice "D1=Label:a=Option*,b=Option"]... [--kind question|plan|merge|release]
|
|
96
98
|
opens a Decision for the user (answered in the console; the answer
|
|
97
|
-
comes back as a reply); --on-timeout picks a safe option at the deadline
|
|
99
|
+
comes back as a reply); --on-timeout picks a safe option at the deadline;
|
|
100
|
+
--choice (instead of --option) asks several points, * marks the recommendation
|
|
98
101
|
hub decisions list [--state open|answered|expired|closed] the Decisions you asked
|
|
99
102
|
hub inbox asks to answer, your open asks, recent messages, mutes
|
|
100
103
|
hub mute <addr> · hub unmute <addr> refuse (or accept again) messages from a session
|
|
@@ -292,15 +295,17 @@ const whyOf = (flags: Parsed["flags"]) => {
|
|
|
292
295
|
return why !== undefined ? { why } : {}
|
|
293
296
|
}
|
|
294
297
|
|
|
295
|
-
/** --no-wake, --urgent, --not-after and --key as SDK send options. */
|
|
298
|
+
/** --no-wake, --urgent, --not-after, --item and --key as SDK send options. */
|
|
296
299
|
const sendFlags = (flags: Parsed["flags"]) => {
|
|
297
300
|
const notAfter = str(flags, "not-after")
|
|
298
301
|
const key = str(flags, "key")
|
|
302
|
+
const item = str(flags, "item")
|
|
299
303
|
return {
|
|
300
304
|
...(flags["no-wake"] === true ? { wake: false } : {}),
|
|
301
305
|
...(flags.urgent === true ? { urgent: true } : {}),
|
|
302
306
|
...(notAfter === undefined ? {} : { notAfter: notAfterArg(notAfter, "--not-after") }),
|
|
303
307
|
...(key !== undefined ? { idempotencyKey: key } : {}),
|
|
308
|
+
...(item !== undefined ? { item } : {}),
|
|
304
309
|
}
|
|
305
310
|
}
|
|
306
311
|
|
|
@@ -422,7 +427,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
422
427
|
},
|
|
423
428
|
msg: {
|
|
424
429
|
args: ["<addr>", "<text>"],
|
|
425
|
-
flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", key: "string" },
|
|
430
|
+
flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", item: "string", key: "string" },
|
|
426
431
|
async handle(context) {
|
|
427
432
|
const { flags, args } = context.parsed
|
|
428
433
|
const text = await textArg(args[1]!, context.io.stdin)
|
|
@@ -433,7 +438,7 @@ const COMMANDS: Record<string, Command> = {
|
|
|
433
438
|
},
|
|
434
439
|
ask: {
|
|
435
440
|
args: ["<addr>", "<text>"],
|
|
436
|
-
flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", key: "string", expires: "string", wait: "optional", ...DECISION_FLAGS },
|
|
441
|
+
flags: { all: "bool", "no-wake": "bool", urgent: "bool", "not-after": "string", item: "string", key: "string", expires: "string", wait: "optional", ...DECISION_FLAGS },
|
|
437
442
|
async handle(context) {
|
|
438
443
|
const { flags, args } = context.parsed
|
|
439
444
|
const decision = decisionOptions(flags, args[0]!)
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Decisions in the CLI (design §12.1): the `hub ask @me` options (--option id=label[:safe], --recommend id --why,
|
|
2
|
-
// --urgency, --deadline, --on-timeout, --tier, --about
|
|
2
|
+
// --urgency, --deadline, --on-timeout, --tier, --about; DC-4: --choice 'D1=Label:a=Yes*,b=No' and --kind) and the
|
|
3
|
+
// text of a Decision (ask @me, `hub decisions list`).
|
|
3
4
|
// The shapes are packages/sdk's types (src/central/agent/decisions.ts).
|
|
4
|
-
import type { AskOptions, Decision, DecisionOption, DecisionState } from "../../sdk/src/index.ts"
|
|
5
|
+
import type { AskOptions, Decision, DecisionChoice, DecisionKind, DecisionOption, DecisionState } from "../../sdk/src/index.ts"
|
|
5
6
|
import { str, UsageError, type FlagKind, type Parsed } from "./args"
|
|
6
7
|
import { oneLine } from "./format"
|
|
7
8
|
import { durationArg, partyText } from "./messaging"
|
|
@@ -16,9 +17,12 @@ export const DECISION_FLAGS: Readonly<Record<string, FlagKind>> = {
|
|
|
16
17
|
"on-timeout": "string",
|
|
17
18
|
tier: "string",
|
|
18
19
|
about: "multi",
|
|
20
|
+
choice: "multi",
|
|
21
|
+
kind: "string",
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
const URGENCIES = ["low", "normal", "high"]
|
|
25
|
+
const KINDS: readonly DecisionKind[] = ["question", "plan", "merge", "release"]
|
|
22
26
|
const TIERS = ["confirm", "tap"]
|
|
23
27
|
const STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
|
|
24
28
|
export const DECISION_STATES = STATES
|
|
@@ -35,6 +39,38 @@ export function optionArg(value: string): DecisionOption {
|
|
|
35
39
|
return { id, label, ...(safe ? { safe: true } : {}) }
|
|
36
40
|
}
|
|
37
41
|
|
|
42
|
+
/**
|
|
43
|
+
* `D1=Label:a=Yes*,b=No`: a choice group, its label (it may contain colons), then its options (`id=label`, comma
|
|
44
|
+
* separated; a trailing `*` marks the recommended one). The hub checks ids and sizes.
|
|
45
|
+
*/
|
|
46
|
+
export function choiceArg(value: string): DecisionChoice {
|
|
47
|
+
const usage = `--choice takes GROUP=Label:a=Option*,b=Option (got "${value}")`
|
|
48
|
+
const eq = value.indexOf("=")
|
|
49
|
+
if (eq <= 0) throw new UsageError(usage)
|
|
50
|
+
const id = value.slice(0, eq)
|
|
51
|
+
const rest = value.slice(eq + 1)
|
|
52
|
+
// The options start after the last colon followed by `id=`.
|
|
53
|
+
let split = -1
|
|
54
|
+
for (let i = rest.lastIndexOf(":"); i >= 0; i = rest.lastIndexOf(":", i - 1)) {
|
|
55
|
+
if (/^[^=,:\s][^=,:]*=/.test(rest.slice(i + 1))) {
|
|
56
|
+
split = i
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (split < 0) throw new UsageError(usage)
|
|
61
|
+
const label = rest.slice(0, split)
|
|
62
|
+
if (!label.trim()) throw new UsageError(`--choice ${id}: the label is empty`)
|
|
63
|
+
const options = rest.slice(split + 1).split(",").map((option) => {
|
|
64
|
+
const at = option.indexOf("=")
|
|
65
|
+
if (at <= 0) throw new UsageError(usage)
|
|
66
|
+
const recommended = option.endsWith("*")
|
|
67
|
+
const text = option.slice(at + 1, recommended ? -1 : undefined)
|
|
68
|
+
if (!text.trim()) throw new UsageError(`--choice ${id}: option ${option.slice(0, at)} has an empty label`)
|
|
69
|
+
return { id: option.slice(0, at), label: text, ...(recommended ? { recommended: true } : {}) }
|
|
70
|
+
})
|
|
71
|
+
return { id, label, options }
|
|
72
|
+
}
|
|
73
|
+
|
|
38
74
|
const oneOf = (value: string | undefined, allowed: readonly string[], flag: string) => {
|
|
39
75
|
if (value !== undefined && !allowed.includes(value)) throw new UsageError(`${flag} is one of ${allowed.join(", ")}`)
|
|
40
76
|
return value
|
|
@@ -54,8 +90,13 @@ export function decisionOptions(flags: Parsed["flags"], to: string): Partial<Ask
|
|
|
54
90
|
const onTimeout = str(flags, "on-timeout")
|
|
55
91
|
const options = flags.option as string[] | undefined
|
|
56
92
|
const about = flags.about as string[] | undefined
|
|
93
|
+
const choices = flags.choice as string[] | undefined
|
|
94
|
+
if (choices && options) throw new UsageError("--choice and --option don't mix: give one or the other")
|
|
95
|
+
const kind = oneOf(str(flags, "kind"), KINDS, "--kind") as DecisionKind | undefined
|
|
57
96
|
return {
|
|
58
97
|
...(options ? { options: options.map(optionArg) } : {}),
|
|
98
|
+
...(choices ? { choices: choices.map(choiceArg) } : {}),
|
|
99
|
+
...(kind ? { kind } : {}),
|
|
59
100
|
...(recommend !== undefined ? { recommendation: { option: recommend, ...(why !== undefined ? { why } : {}) } } : {}),
|
|
60
101
|
...(urgency ? { urgency } : {}),
|
|
61
102
|
...(tier ? { tier } : {}),
|
|
@@ -72,8 +113,14 @@ export function stateArg(value: string | undefined): DecisionState | undefined {
|
|
|
72
113
|
|
|
73
114
|
const iso = (ms: number) => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
74
115
|
|
|
75
|
-
/**
|
|
116
|
+
/**
|
|
117
|
+
* `go Proceed · hold Hold (safe) — recommended: hold (why)`; with choices, one line per group
|
|
118
|
+
* (`D1 Order: a First (recommended) · b Second`); empty for a free-text Decision without one.
|
|
119
|
+
*/
|
|
76
120
|
function choicesText(decision: Decision): string {
|
|
121
|
+
if (decision.choices?.length) {
|
|
122
|
+
return decision.choices.map((g) => `${g.id} ${oneLine(g.label, 120)}: ${g.options.map((o) => `${o.id} ${oneLine(o.label, 80)}${o.recommended ? " (recommended)" : ""}`).join(" · ")}`).join("\n ")
|
|
123
|
+
}
|
|
77
124
|
const options = decision.options.map((o) => `${o.id} ${oneLine(o.label, 80)}${o.safe ? " (safe)" : ""}`).join(" · ")
|
|
78
125
|
const r = decision.recommendation
|
|
79
126
|
const recommended = r ? `recommended: ${r.option}${r.why ? ` (${oneLine(r.why, 200)})` : ""}` : ""
|
|
@@ -83,7 +130,8 @@ function choicesText(decision: Decision): string {
|
|
|
83
130
|
function stateText(decision: Decision): string {
|
|
84
131
|
if (decision.state === "answered") {
|
|
85
132
|
const a = decision.answer
|
|
86
|
-
|
|
133
|
+
const picks = a?.picks ? ` ${Object.entries(a.picks).map(([group, option]) => `${group}=${option}`).join(" ")}` : ""
|
|
134
|
+
return `answered${a?.option ? ` ${a.option}` : ""}${picks}${a?.answeredBy === "timeout" ? " (timeout)" : ""}`
|
|
87
135
|
}
|
|
88
136
|
if (decision.state === "open" && decision.deadline !== undefined) return `open (deadline ${iso(decision.deadline)})`
|
|
89
137
|
return decision.state
|
|
@@ -16,6 +16,8 @@ export interface SessionView {
|
|
|
16
16
|
readonly harness?: string
|
|
17
17
|
readonly now?: string
|
|
18
18
|
readonly blockers?: readonly Blocker[]
|
|
19
|
+
/** (SS-1) Why the session is not making progress; absent: ok (or an older hub). */
|
|
20
|
+
readonly health?: { readonly kind: string; readonly since?: number; readonly resetAt?: number; readonly attempt?: number; readonly detail?: string }
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const at = (name: string) => `@${name.replace(/^@+/, "")}`
|
|
@@ -94,6 +96,13 @@ export function showText(s: Shown): string {
|
|
|
94
96
|
field("parent", s.parentID)
|
|
95
97
|
if (s.roles?.length) field("roles", s.roles.map((r) => `@${at(r)}`).join(" "))
|
|
96
98
|
field("now", s.now)
|
|
99
|
+
if (s.health && s.health.kind !== "ok") {
|
|
100
|
+
const h = s.health
|
|
101
|
+
// A hub's value is data: a resetAt that is no valid date is left out, never thrown on.
|
|
102
|
+
const reset = typeof h.resetAt === "number" && Number.isFinite(h.resetAt) && Math.abs(h.resetAt) <= 8.64e15 ? new Date(h.resetAt).toISOString() : undefined
|
|
103
|
+
const facts = [h.kind, reset ? `resets ${reset}` : undefined, h.attempt !== undefined ? `attempt ${h.attempt}` : undefined].filter(Boolean).join(", ")
|
|
104
|
+
field("health", h.detail ? `${facts} — ${oneLine(h.detail, 200)}` : facts)
|
|
105
|
+
}
|
|
97
106
|
if (s.children) field("subagents", s.children)
|
|
98
107
|
if (s.online === false && s.lastSeen !== undefined) field("last seen", new Date(s.lastSeen).toISOString())
|
|
99
108
|
if (s.blockers?.length) {
|
package/packages/cli/src/mcp.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// the profile lifecycle (profile_show, profile_create, profile_update, profile_version_create, profile_skill_set, …,
|
|
12
12
|
// profile_assign; scopes profiles:read|manage|assign: profiles-mcp.ts), and the Concierge watchlist (watchlist_get,
|
|
13
13
|
// watchlist_update, watchlist_watch, watchlist_mute: watchlist.ts).
|
|
14
|
-
import { renderEnvelope, sanitizeText, WAIT_POLL_MAX_S, type AgentMessage, type Asked, type DecisionState, type FormAnswer, type HubClient, type LaunchSpec, type LaunchState, type PromptDelivery, type Inbox, type Sent, type SentAll } from "../../sdk/src/index.ts"
|
|
14
|
+
import { DECISION_ASK_PROPERTIES, decisionAskFields, renderEnvelope, sanitizeText, WAIT_POLL_MAX_S, type AgentMessage, type Asked, type DecisionState, type FormAnswer, type HubClient, type LaunchSpec, type LaunchState, type PromptDelivery, type Inbox, type Sent, type SentAll } from "../../sdk/src/index.ts"
|
|
15
15
|
import { answeredText, approvedText, DELIVERIES, deniedText, needsUserLines, promptedText, stoppedText } from "./acting"
|
|
16
16
|
import { UsageError } from "./args"
|
|
17
17
|
import { intentLine, writeIntent } from "./intents"
|
|
@@ -126,7 +126,8 @@ const sendOptions = (args: Args) => {
|
|
|
126
126
|
const wake = optionalBool(args, "wake")
|
|
127
127
|
const urgent = optionalBool(args, "urgent")
|
|
128
128
|
const notAfter = optionalNotAfter(args)
|
|
129
|
-
|
|
129
|
+
const item = optional(args, "item")
|
|
130
|
+
return { ...(wake !== undefined ? { wake } : {}), ...(urgent !== undefined ? { urgent } : {}), ...(notAfter !== undefined ? { notAfter } : {}), ...(item !== undefined ? { item } : {}) }
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
/** The why argument as SDK act options. */
|
|
@@ -238,6 +239,7 @@ const DECISIONS_NOTE = "Decisions are asked by sessions: the question and option
|
|
|
238
239
|
|
|
239
240
|
const STRING = { type: "string" } as const
|
|
240
241
|
const SEND = {
|
|
242
|
+
item: { type: "string", description: "msg/ask: optional itk_ ID of an open item; sender and recipient must hold current seats in the item's team (older hubs ignore it)." },
|
|
241
243
|
wake: { type: "boolean", description: "false: admit the message without starting a turn." },
|
|
242
244
|
urgent: { type: "boolean", description: "Steer into the recipient's running turn." },
|
|
243
245
|
notAfter: { type: ["number", "string"], description: "Never deliver after this time: epoch milliseconds or an ISO 8601 date-time (at most 30 days ahead). Already passed: nothing is sent (expired)." },
|
|
@@ -767,6 +769,13 @@ const TOOLS: readonly Tool[] = [
|
|
|
767
769
|
...operationTools,
|
|
768
770
|
...teamTools,
|
|
769
771
|
...watchlistTools,
|
|
772
|
+
{
|
|
773
|
+
name: "agent_limits",
|
|
774
|
+
description: "Read the hub-wide agent hop limit, the hop ceiling (the highest hop limit the hub accepts) and launch caps, their sources and revision. Read-only; only an admin may change them in the console.",
|
|
775
|
+
properties: {},
|
|
776
|
+
readOnly: true,
|
|
777
|
+
run: async (hub, _args, signal) => JSON.stringify(await hub.call("GET", "/api/agent/agent-limits", { signal }), null, 2),
|
|
778
|
+
},
|
|
770
779
|
{
|
|
771
780
|
name: "whoami",
|
|
772
781
|
description: "Who this hub credential is: the app token of `hub login` (or the session of HUB_SESSION_TOKEN) and its user.",
|
|
@@ -846,10 +855,11 @@ const TOOLS: readonly Tool[] = [
|
|
|
846
855
|
},
|
|
847
856
|
{
|
|
848
857
|
name: "ask",
|
|
849
|
-
description: `Ask a session a question, tracked until answered, declined or expired. wait (at most ${WAIT_POLL_MAX_S} s) blocks for the answer; otherwise it shows up in the inbox tool.`,
|
|
858
|
+
description: `Ask a session a question, tracked until answered, declined or expired. wait (at most ${WAIT_POLL_MAX_S} s) blocks for the answer; otherwise it shows up in the inbox tool. To @me it opens a Decision for the user: give options (buttons) with a recommendation, or choices (several points in one ask), and urgency, tier and kind.`,
|
|
850
859
|
properties: {
|
|
851
860
|
to: TO,
|
|
852
861
|
text: { ...STRING, description: "The question." },
|
|
862
|
+
...DECISION_ASK_PROPERTIES,
|
|
853
863
|
wait: { type: "number", minimum: 0, maximum: WAIT_POLL_MAX_S, description: `Seconds to wait for the answer (0–${WAIT_POLL_MAX_S}).` },
|
|
854
864
|
expires: { type: ["number", "string"], description: "When the ask expires: seconds, or 30s, 5m, 2h, 1d (1 minute to 7 days; default 24 h)." },
|
|
855
865
|
...SEND,
|
|
@@ -862,7 +872,13 @@ const TOOLS: readonly Tool[] = [
|
|
|
862
872
|
const text = need(args, "text")
|
|
863
873
|
const wait = optionalWait(args)
|
|
864
874
|
const expires = optionalExpires(args)
|
|
865
|
-
|
|
875
|
+
let decision: ReturnType<typeof decisionAskFields>
|
|
876
|
+
try {
|
|
877
|
+
decision = decisionAskFields(args)
|
|
878
|
+
} catch (cause) {
|
|
879
|
+
throw new ArgError((cause as Error).message)
|
|
880
|
+
}
|
|
881
|
+
const options = { ...sendOptions(args), ...(expires !== undefined ? { expires } : {}), ...idemOption(args), ...decision, signal }
|
|
866
882
|
if (optionalBool(args, "all")) {
|
|
867
883
|
if (wait !== undefined) throw new ArgError(`"wait" cannot be combined with "all"`)
|
|
868
884
|
return sentAllText(await hub.ask(to, text, { ...options, all: true }))
|
|
@@ -79,7 +79,7 @@ function notAfterOf(args: Args): number | undefined {
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
const line = (i: TeamIntake) =>
|
|
82
|
-
`${i.id} ${i.status}${i.progress ? `/${i.progress}` : ""} ${i.priority} r${i.revision} ${sanitizeText(i.title).replace(/\s+/g, " ")}`
|
|
82
|
+
`${i.id} ${i.status}${i.progress ? `/${i.progress}` : ""} ${i.priority} r${i.revision} ${sanitizeText(i.title).replace(/\s+/g, " ")}${i.usage ? ` wakes ${i.usage.wakes}/${i.usage.budget.wakes} · ${i.usage.hours.toFixed(1)}/${i.usage.budget.hours}h` : ""}`
|
|
83
83
|
|
|
84
84
|
function intakeText(i: TeamIntake): string {
|
|
85
85
|
const from = i.source.kind === "user" ? "the user" : i.source.kind === "app" ? `app ${i.source.tokenID}` : `session ${i.source.sessionID}`
|
|
@@ -95,6 +95,7 @@ function intakeText(i: TeamIntake): string {
|
|
|
95
95
|
...(i.ref ? [`ref: ${sanitizeText(i.ref)}`] : []),
|
|
96
96
|
...(i.reason ? [`reason: ${sanitizeText(i.reason)}`] : []),
|
|
97
97
|
...(i.lastError ? [`last routing error: ${i.lastError} (retried)`] : []),
|
|
98
|
+
...(i.usage ? [`usage: ${i.usage.wakes}/${i.usage.budget.wakes} wakes · ${i.usage.hours.toFixed(1)}/${i.usage.budget.hours} hours (paused and blocked time counts)${i.usage.chargedTo ? `; last charged to ${i.usage.chargedTo}` : ""}`] : []),
|
|
98
99
|
].join("\n")
|
|
99
100
|
}
|
|
100
101
|
|
|
@@ -51,7 +51,9 @@ const SPEC_FIELDS = {
|
|
|
51
51
|
title: { ...STRING, description: "1–120 characters." },
|
|
52
52
|
description: STRING,
|
|
53
53
|
mode: { type: "string", enum: ["open", "restricted"], description: "open: every member may msg/ask every other member. restricted: only the drawn paths (edges)." },
|
|
54
|
-
hopLimit: { type: "integer", minimum: 1, maximum:
|
|
54
|
+
hopLimit: { type: "integer", minimum: 1, maximum: 10_000, description: "Agent-only wake hop limit between this team's seats; at most the hub's hop ceiling (agent_limits hopCeiling, default 100). The effective limit is the lower of this and the hub-wide hopLimit. Unset uses the hub-wide limit." },
|
|
55
|
+
itemBudget: { type: "object", properties: { wakes: { type: "integer", minimum: 10, maximum: 5000 }, hours: { type: "integer", minimum: 1, maximum: 720 } }, required: ["wakes", "hours"], description: "User-only per-item budget override: agent wakes and hours in progress." },
|
|
56
|
+
loop: { type: "object", properties: { messages: { type: "integer", minimum: 4, maximum: 200 }, windowMinutes: { type: "integer", minimum: 5, maximum: 240 } }, required: ["messages", "windowMinutes"], description: "User-only no-progress pair-message threshold and window." },
|
|
55
57
|
roles: { type: "array", items: ROLE, description: "1–32 roles." },
|
|
56
58
|
entryRole: { ...STRING, description: "The role that receives submitted work (e.g. pm) and supervises members." },
|
|
57
59
|
edges: { type: "array", items: EDGE, description: "Communication paths between roles (restricted mode)." },
|
|
@@ -178,6 +180,8 @@ function specLines(spec: TeamSpec): string[] {
|
|
|
178
180
|
return [
|
|
179
181
|
`mode: ${spec.mode}${spec.mode === "open" ? " (every member may msg/ask every other)" : " (only the paths below)"}`,
|
|
180
182
|
...(spec.hopLimit !== undefined ? [`hop limit: ${spec.hopLimit}`] : []),
|
|
183
|
+
...(spec.itemBudget ? [`item budget: ${spec.itemBudget.wakes} wakes, ${spec.itemBudget.hours} hours`] : []),
|
|
184
|
+
...(spec.loop ? [`loop: ${spec.loop.messages} messages per ${spec.loop.windowMinutes} minutes`] : []),
|
|
181
185
|
`entry role: ${spec.entryRole}`,
|
|
182
186
|
"roles:",
|
|
183
187
|
...spec.roles.map(role),
|
|
@@ -349,7 +353,7 @@ export const teamTools: readonly TeamTool[] = [
|
|
|
349
353
|
need(args, "name")
|
|
350
354
|
need(args, "title")
|
|
351
355
|
const key = optional(args, "idempotencyKey")
|
|
352
|
-
const spec = picked(args, ["name", "title", "description", "mode", "hopLimit", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as unknown as TeamSpec
|
|
356
|
+
const spec = picked(args, ["name", "title", "description", "mode", "hopLimit", "itemBudget", "loop", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as unknown as TeamSpec
|
|
353
357
|
const { team, created } = await hub.teams.create({ ...spec, edges: spec.edges ?? [] }, { signal, ...(key ? { idempotencyKey: key } : {}) })
|
|
354
358
|
return [`${created ? "created" : "already created (same key)"}: ${teamLine(team)}`, ...specLines(team)].join("\n")
|
|
355
359
|
},
|
|
@@ -364,7 +368,7 @@ export const teamTools: readonly TeamTool[] = [
|
|
|
364
368
|
idempotent: true,
|
|
365
369
|
run: async (hub, args, signal) => {
|
|
366
370
|
const expectedRevision = revision(args)
|
|
367
|
-
const patch = picked(args, ["title", "description", "mode", "hopLimit", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as TeamPatch
|
|
371
|
+
const patch = picked(args, ["title", "description", "mode", "hopLimit", "itemBudget", "loop", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as TeamPatch
|
|
368
372
|
if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
|
|
369
373
|
const id = need(args, "team")
|
|
370
374
|
const { team } = await hub.teams.update(id, patch, expectedRevision, { signal })
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-codex",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -92,7 +92,17 @@ const RECENT_TURNS = 50
|
|
|
92
92
|
|
|
93
93
|
/** Who an input the spoke sent is from: the hub's turn/steer ops are the user's, carried by the hub. */
|
|
94
94
|
type Origin = "carried" | "hub"
|
|
95
|
-
type TurnEnd = { readonly status: string; readonly error?: string }
|
|
95
|
+
type TurnEnd = { readonly status: string; readonly error?: string; readonly errorType?: string }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* (SS-9) A failed turn's codexErrorInfo as error.type "codex.<snake_case>": a camelCase string ("usageLimitExceeded",
|
|
99
|
+
* "other") or a one-key object ({httpConnectionFailed: {httpStatusCode}}). Anything else gives no type.
|
|
100
|
+
*/
|
|
101
|
+
function codexErrorType(info: unknown): string | undefined {
|
|
102
|
+
const name = typeof info === "string" ? info : isObj(info) && Object.keys(info).length === 1 ? Object.keys(info)[0] : undefined
|
|
103
|
+
if (!name || !/^[a-z][A-Za-z0-9]{0,40}$/.test(name)) return undefined
|
|
104
|
+
return `codex.${name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)}`
|
|
105
|
+
}
|
|
96
106
|
|
|
97
107
|
interface TurnWaiter {
|
|
98
108
|
resolve(end: TurnEnd): void
|
|
@@ -529,7 +539,8 @@ export class CodexSessions {
|
|
|
529
539
|
if (!turnID) return
|
|
530
540
|
const status = typeof turn.status === "string" ? turn.status : "completed"
|
|
531
541
|
const error = isObj(turn.error) && typeof turn.error.message === "string" ? cut(turn.error.message, HARNESS_ERROR_MAX) : undefined
|
|
532
|
-
const
|
|
542
|
+
const errorType = isObj(turn.error) ? codexErrorType(turn.error.codexErrorInfo) : undefined
|
|
543
|
+
const end: TurnEnd = { status, ...(error !== undefined ? { error } : {}), ...(errorType !== undefined ? { errorType } : {}) }
|
|
533
544
|
if (thread.turnID === turnID) thread.turnID = undefined
|
|
534
545
|
thread.ended.set(turnID, end)
|
|
535
546
|
while (thread.ended.size > RECENT_TURNS) thread.ended.delete(thread.ended.keys().next().value!)
|
|
@@ -537,7 +548,7 @@ export class CodexSessions {
|
|
|
537
548
|
if (thread.reply) this.#emit({ type: "session.text.ended", data: { sessionID, assistantMessageID: plainID(thread.reply.id), ordinal: 0, text: cut(thread.reply.text, HARNESS_TEXT_MAX) } })
|
|
538
549
|
const data = { sessionID, turnID: plainID(turnID) }
|
|
539
550
|
if (status === "interrupted") this.#emit({ type: "session.execution.interrupted", data })
|
|
540
|
-
else if (status === "failed") this.#emit({ type: "session.execution.failed", data: { ...data, ...(error !== undefined ? { error: { message: error } } : {}) } })
|
|
551
|
+
else if (status === "failed") this.#emit({ type: "session.execution.failed", data: { ...data, ...(error !== undefined ? { error: { message: error, ...(end.errorType ? { type: end.errorType } : {}) } } : {}) } })
|
|
541
552
|
else this.#emit({ type: "session.execution.succeeded", data })
|
|
542
553
|
thread.items.clear()
|
|
543
554
|
const waiter = thread.waiters.get(turnID)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-node",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "`hub node`: the per-machine daemon that prepares, collects and cleans up launch workspaces (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-preset",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The preset spoke: drives a harness without a spoke of its own through a terminal pane (tmux), described by a data-only preset; official presets in presets/ (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.1.0",
|
|
3
|
+
"info": {
|
|
4
|
+
"title": "Hub API",
|
|
5
|
+
"version": "1",
|
|
6
|
+
"description": "The versioned HTTP API of a Hub: every route of the console and agent tables under /api/v1. v1 changes only by addition: clients ignore fields they do not know, treat a value outside an `x-hub-known` list as unknown, and skip a variant of an `x-hub-union` they do not know. Every error is one envelope: {error: {code, message, hint?}}."
|
|
7
|
+
},
|
|
8
|
+
"servers": [
|
|
9
|
+
{
|
|
10
|
+
"url": "https://hub.example",
|
|
11
|
+
"description": "A placeholder: the address of your hub."
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"security": [
|
|
15
|
+
{
|
|
16
|
+
"deviceToken": []
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"components": {
|
|
20
|
+
"securitySchemes": {
|
|
21
|
+
"deviceToken": {
|
|
22
|
+
"type": "http",
|
|
23
|
+
"scheme": "bearer",
|
|
24
|
+
"description": "A device token: what a native app gets when it redeems a link code (POST /api/v1/devices/redeem). A request with a bearer token needs no Origin."
|
|
25
|
+
},
|
|
26
|
+
"deviceCookie": {
|
|
27
|
+
"type": "apiKey",
|
|
28
|
+
"in": "cookie",
|
|
29
|
+
"name": "voice_device",
|
|
30
|
+
"description": "The browser's credential: the same secret as the device token, as an HttpOnly cookie set when a browser pairs or signs in. A write with the cookie must be same-origin. Send the cookie or a bearer token, never both. Declared here for readers and never listed as a requirement: a generated client sends the bearer token only."
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"responses": {
|
|
34
|
+
"Error": {
|
|
35
|
+
"description": "Any error (status 400 and up): the one envelope of /api/v1.",
|
|
36
|
+
"content": {
|
|
37
|
+
"application/json": {
|
|
38
|
+
"schema": {
|
|
39
|
+
"$ref": "#/components/schemas/ErrorBody"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"schemas": {
|
|
46
|
+
"ErrorBody": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"required": ["error"],
|
|
49
|
+
"properties": {
|
|
50
|
+
"error": {
|
|
51
|
+
"$ref": "#/components/schemas/ErrorDetail"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"ErrorDetail": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"description": "What went wrong. A code may come with more fields (for example retryAfter, currentRevision, candidates, feature, limit).",
|
|
58
|
+
"required": ["code", "message"],
|
|
59
|
+
"properties": {
|
|
60
|
+
"code": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "Stable and machine-readable. A route that names no code of its own gets one from the status (400 invalid, 401 unauthorized, 403 forbidden, 404 not_found, 408 and 504 timeout, 409 conflict, 410 gone, 413 too_large, 422 unsupported, 429 rate_limited, 500 internal, 502 upstream_failed, 503 unavailable).",
|
|
63
|
+
"x-hub-known": [
|
|
64
|
+
"ambiguous",
|
|
65
|
+
"bad_answer",
|
|
66
|
+
"bad_bundle",
|
|
67
|
+
"busy",
|
|
68
|
+
"changed",
|
|
69
|
+
"conflict",
|
|
70
|
+
"developer_off",
|
|
71
|
+
"dimensions",
|
|
72
|
+
"expired",
|
|
73
|
+
"forbidden",
|
|
74
|
+
"gone",
|
|
75
|
+
"hidden",
|
|
76
|
+
"integrity",
|
|
77
|
+
"internal",
|
|
78
|
+
"invalid",
|
|
79
|
+
"invalid_cursor",
|
|
80
|
+
"limit",
|
|
81
|
+
"name_taken",
|
|
82
|
+
"needs_user",
|
|
83
|
+
"not_exposed",
|
|
84
|
+
"not_found",
|
|
85
|
+
"offline",
|
|
86
|
+
"path",
|
|
87
|
+
"paused",
|
|
88
|
+
"plugin_outdated",
|
|
89
|
+
"rate_limited",
|
|
90
|
+
"recording_ended",
|
|
91
|
+
"recording_off",
|
|
92
|
+
"refused",
|
|
93
|
+
"timeout",
|
|
94
|
+
"token_expired",
|
|
95
|
+
"too_large",
|
|
96
|
+
"too_old",
|
|
97
|
+
"type",
|
|
98
|
+
"unauthorized",
|
|
99
|
+
"unavailable",
|
|
100
|
+
"unsupported",
|
|
101
|
+
"upstream_failed"
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
"message": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"description": "For people."
|
|
107
|
+
},
|
|
108
|
+
"hint": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"description": "What to do about it, when the hub knows."
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"JsonValue": {
|
|
115
|
+
"description": "Any JSON value: an object, an array, a string, a number, a boolean or null. For data whose shape the hub does not define (a tool's input, a harness's own metadata). Clients map it to their JSON element type."
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|