@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
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) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// MCP grant tools: the server owns authority. Requests use the existing tap Decision route; /mine never widens
|
|
2
|
+
// the user-only grant administration routes. A user credential may create directly but has no /mine holder.
|
|
3
|
+
import type { HubClient } from "../../sdk/src/client.ts"
|
|
4
|
+
import type { GrantRequestSpec, GrantSpec } from "../../sdk/src/types.ts"
|
|
5
|
+
import { grantError, grantText, grantsText, GRANT_USER_HINT, requestedText } from "./grants"
|
|
6
|
+
import { CliError, EXIT } from "./http"
|
|
7
|
+
import { ToolArgError } from "./tool-args"
|
|
8
|
+
|
|
9
|
+
type Args = Record<string, unknown>
|
|
10
|
+
export interface GrantTool {
|
|
11
|
+
readonly name: string
|
|
12
|
+
readonly description: string
|
|
13
|
+
readonly properties: Record<string, unknown>
|
|
14
|
+
readonly required?: readonly string[]
|
|
15
|
+
readonly readOnly: boolean
|
|
16
|
+
readonly organizes?: boolean
|
|
17
|
+
run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STRING = { type: "string" } as const
|
|
21
|
+
const GRANT = { type: "object", description: "The grant spec accepted by POST /api/agent/grants: holder (optional for a request), scope.projects, allow, permissions, allowAlways, maxActionsPerHour and expires. The server validates all fields.", additionalProperties: true } as const
|
|
22
|
+
const need = (args: Args, key: string): string => {
|
|
23
|
+
if (typeof args[key] !== "string" || !(args[key] as string).trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
24
|
+
return args[key] as string
|
|
25
|
+
}
|
|
26
|
+
const spec = (args: Args): Record<string, unknown> => {
|
|
27
|
+
if (typeof args.grant !== "object" || args.grant === null || Array.isArray(args.grant)) throw new ToolArgError('"grant" is required (an object)')
|
|
28
|
+
return args.grant as Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const grantTools: readonly GrantTool[] = [
|
|
32
|
+
{
|
|
33
|
+
name: "grant_request", description: "Ask the user for a grant: opens one tap Decision for @me (approve or deny). Sessions and apps with messages:send only; one open request and at most five per hour. Only the user's answer creates authority.",
|
|
34
|
+
properties: { grant: GRANT, why: STRING }, required: ["grant", "why"], readOnly: false,
|
|
35
|
+
run: async (hub, args, signal) => requestedText(await hub.requestGrant(spec(args) as GrantRequestSpec, need(args, "why"), { signal })),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "grant_list", description: "List grants (active, expired or revoked) whose holder is this session or app, including grants for roles it holds now. Apps need messages:read. A user credential has no holder: use the console or hub grant list for administration.",
|
|
39
|
+
properties: {}, readOnly: true,
|
|
40
|
+
run: async (hub, _args, signal) => grantsText((await hub.heldGrants({ signal })).grants),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: "grant_show", description: "Show one grant only if this credential currently holds it. Apps need messages:read; another holder's ID answers not_found.",
|
|
44
|
+
properties: { id: STRING }, required: ["id"], readOnly: true,
|
|
45
|
+
run: async (hub, args, signal) => grantText((await hub.heldGrant(need(args, "id"), { signal })).grant),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "grant_revoke", description: "Irreversibly revoke a grant held by this session or app (including a held role shared with other holders). Apps need messages:send; another holder's ID answers not_found.",
|
|
49
|
+
properties: { id: STRING }, required: ["id"], readOnly: false,
|
|
50
|
+
run: async (hub, args, signal) => grantText((await hub.revokeHeldGrant(need(args, "id"), { signal })).grant),
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "grant_create", description: `Create a grant directly ONLY with the user's device credential. Sessions and apps must use grant_request and wait for the user's tap. ${GRANT_USER_HINT}.`,
|
|
54
|
+
properties: { grant: GRANT }, required: ["grant"], readOnly: false,
|
|
55
|
+
run: async (hub, args, signal) => {
|
|
56
|
+
const actor = (await hub.whoami({ signal })).actor
|
|
57
|
+
if (actor.kind !== "user") throw new CliError(EXIT.forbidden, "only the user can create a grant, not a session or app", GRANT_USER_HINT)
|
|
58
|
+
try {
|
|
59
|
+
return grantText((await hub.createGrant(spec(args) as unknown as GrantSpec, { signal })).grant)
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
throw grantError(cause)
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// User-only MCP access to the existing console session-model route and owner-scoped machine/node snapshots.
|
|
2
|
+
// No agent or app model-setting authority: the server's model route remains console user-only.
|
|
3
|
+
import type { HubClient } from "../../sdk/src/client.ts"
|
|
4
|
+
import { CliError, EXIT } from "./http"
|
|
5
|
+
import { ToolArgError } from "./tool-args"
|
|
6
|
+
|
|
7
|
+
type Args = Record<string, unknown>
|
|
8
|
+
interface Tool {
|
|
9
|
+
readonly name: string
|
|
10
|
+
readonly description: string
|
|
11
|
+
readonly properties: Record<string, unknown>
|
|
12
|
+
readonly required?: readonly string[]
|
|
13
|
+
readonly readOnly: boolean
|
|
14
|
+
run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
|
|
15
|
+
}
|
|
16
|
+
const STRING = { type: "string" } as const
|
|
17
|
+
const need = (args: Args, key: string) => {
|
|
18
|
+
const v = args[key]
|
|
19
|
+
if (typeof v !== "string" || !v.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
20
|
+
return v
|
|
21
|
+
}
|
|
22
|
+
const userOnly = async (hub: HubClient, signal: AbortSignal) => {
|
|
23
|
+
if ((await hub.whoami({ signal })).actor.kind !== "user") throw new CliError(EXIT.forbidden, "only the user can use this tool", "Use the user's device credential")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const operationTools: readonly Tool[] = [
|
|
27
|
+
{
|
|
28
|
+
name: "session_model_set", description: "Set one existing session's model (providerID, modelID and optional variant), exactly as the console Model chip does. User device credential only; the console route checks ownership, catalog, availability and audit. An app or session cannot set it.",
|
|
29
|
+
properties: { session: STRING, model: { type: "object", properties: { providerID: STRING, modelID: STRING, variant: STRING }, required: ["providerID", "modelID"] } },
|
|
30
|
+
required: ["session", "model"], readOnly: false,
|
|
31
|
+
run: async (hub, args, signal) => {
|
|
32
|
+
await userOnly(hub, signal)
|
|
33
|
+
const model = args.model
|
|
34
|
+
if (typeof model !== "object" || model === null || Array.isArray(model)) throw new ToolArgError('"model" is required (providerID, modelID, variant?)')
|
|
35
|
+
const result = await hub.call<{ model: unknown }>("PUT", `/api/sessions/${encodeURIComponent(need(args, "session"))}/model`, { body: { model }, signal })
|
|
36
|
+
return JSON.stringify(result)
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "machines", description: "The user's own enrolled machines, online status and processes. User device credential only; wraps GET /api/machines. No machine tokens or secrets.",
|
|
41
|
+
properties: {}, readOnly: true,
|
|
42
|
+
run: async (hub, _args, signal) => { await userOnly(hub, signal); return JSON.stringify(await hub.call("GET", "/api/machines", { signal })) },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "launch_nodes", description: "The user's connected launch nodes: machine, workspace roots, capacity/used/free slots, runtimes (harnesses) and labels. User device credential only; no tokens, connection IDs or mutation.",
|
|
46
|
+
properties: {}, readOnly: true,
|
|
47
|
+
run: async (hub, _args, signal) => { await userOnly(hub, signal); return JSON.stringify(await hub.call("GET", "/api/agent/launch-nodes", { signal })) },
|
|
48
|
+
},
|
|
49
|
+
]
|
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"
|
|
@@ -25,8 +25,11 @@ import { launchedText, launchesText, launchText, stoppedLaunchText } from "./lau
|
|
|
25
25
|
import { artifactTools } from "./artifacts-mcp"
|
|
26
26
|
import { artifactJobTools } from "./artifact-jobs-mcp"
|
|
27
27
|
import { teamIntakeTools } from "./team-intake-mcp"
|
|
28
|
+
import { subscriptionTools } from "./subscriptions-mcp"
|
|
29
|
+
import { operationTools } from "./mcp-operations"
|
|
28
30
|
import { teamTools } from "./teams-mcp"
|
|
29
31
|
import { profileLifecycleTools } from "./profiles-mcp"
|
|
32
|
+
import { grantTools } from "./grants-mcp"
|
|
30
33
|
import { ToolArgError } from "./tool-args"
|
|
31
34
|
import { watchlistTools } from "./watchlist"
|
|
32
35
|
|
|
@@ -123,7 +126,8 @@ const sendOptions = (args: Args) => {
|
|
|
123
126
|
const wake = optionalBool(args, "wake")
|
|
124
127
|
const urgent = optionalBool(args, "urgent")
|
|
125
128
|
const notAfter = optionalNotAfter(args)
|
|
126
|
-
|
|
129
|
+
const item = optional(args, "item")
|
|
130
|
+
return { ...(wake !== undefined ? { wake } : {}), ...(urgent !== undefined ? { urgent } : {}), ...(notAfter !== undefined ? { notAfter } : {}), ...(item !== undefined ? { item } : {}) }
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
/** The why argument as SDK act options. */
|
|
@@ -235,6 +239,7 @@ const DECISIONS_NOTE = "Decisions are asked by sessions: the question and option
|
|
|
235
239
|
|
|
236
240
|
const STRING = { type: "string" } as const
|
|
237
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)." },
|
|
238
243
|
wake: { type: "boolean", description: "false: admit the message without starting a turn." },
|
|
239
244
|
urgent: { type: "boolean", description: "Steer into the recipient's running turn." },
|
|
240
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)." },
|
|
@@ -755,12 +760,22 @@ const TOOLS: readonly Tool[] = [
|
|
|
755
760
|
...webhookTools,
|
|
756
761
|
...profileTools,
|
|
757
762
|
...profileLifecycleTools,
|
|
763
|
+
...grantTools,
|
|
758
764
|
...workspaceTools,
|
|
759
765
|
...artifactTools,
|
|
760
766
|
...artifactJobTools,
|
|
761
767
|
...teamIntakeTools,
|
|
768
|
+
...subscriptionTools,
|
|
769
|
+
...operationTools,
|
|
762
770
|
...teamTools,
|
|
763
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
|
+
},
|
|
764
779
|
{
|
|
765
780
|
name: "whoami",
|
|
766
781
|
description: "Who this hub credential is: the app token of `hub login` (or the session of HUB_SESSION_TOKEN) and its user.",
|
|
@@ -840,10 +855,11 @@ const TOOLS: readonly Tool[] = [
|
|
|
840
855
|
},
|
|
841
856
|
{
|
|
842
857
|
name: "ask",
|
|
843
|
-
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.`,
|
|
844
859
|
properties: {
|
|
845
860
|
to: TO,
|
|
846
861
|
text: { ...STRING, description: "The question." },
|
|
862
|
+
...DECISION_ASK_PROPERTIES,
|
|
847
863
|
wait: { type: "number", minimum: 0, maximum: WAIT_POLL_MAX_S, description: `Seconds to wait for the answer (0–${WAIT_POLL_MAX_S}).` },
|
|
848
864
|
expires: { type: ["number", "string"], description: "When the ask expires: seconds, or 30s, 5m, 2h, 1d (1 minute to 7 days; default 24 h)." },
|
|
849
865
|
...SEND,
|
|
@@ -856,7 +872,13 @@ const TOOLS: readonly Tool[] = [
|
|
|
856
872
|
const text = need(args, "text")
|
|
857
873
|
const wait = optionalWait(args)
|
|
858
874
|
const expires = optionalExpires(args)
|
|
859
|
-
|
|
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 }
|
|
860
882
|
if (optionalBool(args, "all")) {
|
|
861
883
|
if (wait !== undefined) throw new ArgError(`"wait" cannot be combined with "all"`)
|
|
862
884
|
return sentAllText(await hub.ask(to, text, { ...options, all: true }))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// MCP wrappers over the existing subscription routes; authorization and delivery caps remain on the server.
|
|
2
|
+
import type { HubClient } from "../../sdk/src/client.ts"
|
|
3
|
+
import type { SubscribeInput } from "../../sdk/src/types.ts"
|
|
4
|
+
import { subscribedText, subscriptionsText } from "./subscriptions"
|
|
5
|
+
import { ToolArgError } from "./tool-args"
|
|
6
|
+
|
|
7
|
+
type Args = Record<string, unknown>
|
|
8
|
+
export interface SubscriptionTool {
|
|
9
|
+
readonly name: string
|
|
10
|
+
readonly description: string
|
|
11
|
+
readonly properties: Record<string, unknown>
|
|
12
|
+
readonly required?: readonly string[]
|
|
13
|
+
readonly readOnly: boolean
|
|
14
|
+
readonly organizes?: boolean
|
|
15
|
+
run(hub: HubClient, args: Args, signal: AbortSignal): Promise<string>
|
|
16
|
+
}
|
|
17
|
+
const STRING = { type: "string" } as const
|
|
18
|
+
const need = (args: Args, key: string): string => {
|
|
19
|
+
const value = args[key]
|
|
20
|
+
if (typeof value !== "string" || !value.trim()) throw new ToolArgError(`"${key}" is required (a non-empty string)`)
|
|
21
|
+
return value
|
|
22
|
+
}
|
|
23
|
+
const optional = (args: Args, key: string): string | undefined => {
|
|
24
|
+
const value = args[key]
|
|
25
|
+
if (value === undefined || value === null) return undefined
|
|
26
|
+
if (typeof value !== "string") throw new ToolArgError(`"${key}" must be a string`)
|
|
27
|
+
return value.trim() || undefined
|
|
28
|
+
}
|
|
29
|
+
const array = (args: Args, key: string): string[] | undefined => {
|
|
30
|
+
const value = args[key]
|
|
31
|
+
if (value === undefined || value === null) return undefined
|
|
32
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new ToolArgError(`"${key}" must be a list of strings`)
|
|
33
|
+
return value
|
|
34
|
+
}
|
|
35
|
+
const number = (args: Args, key: string, min: number, max: number): number | undefined => {
|
|
36
|
+
const value = args[key]
|
|
37
|
+
if (value === undefined || value === null) return undefined
|
|
38
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new ToolArgError(`"${key}" must be a whole number from ${min} to ${max}`)
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const subscriptionTools: readonly SubscriptionTool[] = [
|
|
43
|
+
{
|
|
44
|
+
name: "subscribe", description: "Follow journal events for a subscriber session. A session subscribes itself; users and apps name subscriber. Apps need events:subscribe, sessions:read, relevant type scopes and messages:send unless silent. Hub checks visibility, rates and loops.",
|
|
45
|
+
properties: {
|
|
46
|
+
to: { type: ["string", "array"], items: STRING, description: "One or more session/role/team addresses, or children." },
|
|
47
|
+
subscriber: STRING, types: { type: "array", items: STRING }, delivery: { type: "string", enum: ["queue", "steer", "silent"] },
|
|
48
|
+
batch: { type: "number", description: "Seconds per batch (1–3600)." }, expires: { type: "number", description: "Seconds until expiry." },
|
|
49
|
+
maxPerHour: { type: "integer", minimum: 1, maximum: 120 }, note: STRING,
|
|
50
|
+
}, required: ["to"], readOnly: false,
|
|
51
|
+
run: async (hub, args, signal) => {
|
|
52
|
+
const to = typeof args.to === "string" && args.to.trim() ? args.to : array(args, "to")
|
|
53
|
+
if (!to || (Array.isArray(to) && !to.length)) throw new ToolArgError('"to" needs at least one address')
|
|
54
|
+
const delivery = optional(args, "delivery")
|
|
55
|
+
if (delivery && !["queue", "steer", "silent"].includes(delivery)) throw new ToolArgError('"delivery" is queue, steer or silent')
|
|
56
|
+
const subscriber = optional(args, "subscriber")
|
|
57
|
+
const types = array(args, "types")
|
|
58
|
+
const batch = number(args, "batch", 1, 3600)
|
|
59
|
+
const expires = number(args, "expires", 1, 30 * 86400)
|
|
60
|
+
const maxPerHour = number(args, "maxPerHour", 1, 120)
|
|
61
|
+
const note = optional(args, "note")
|
|
62
|
+
const input: SubscribeInput = { to, ...(subscriber ? { subscriber } : {}), ...(types ? { types } : {}), ...(delivery ? { delivery: delivery as SubscribeInput["delivery"] } : {}), ...(batch ? { batch } : {}), ...(expires ? { expires } : {}), ...(maxPerHour ? { maxPerHour } : {}), ...(note ? { note } : {}) }
|
|
63
|
+
return subscribedText(await hub.subscribe(input, { signal }))
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "subscriptions", description: "List subscriptions visible to this credential (session: its own; app: created by it; user: all). Optional session filter; route enforces scopes and tenancy.",
|
|
68
|
+
properties: { session: STRING }, readOnly: true,
|
|
69
|
+
run: async (hub, args, signal) => subscriptionsText(await hub.subscriptions({ ...(optional(args, "session") ? { session: optional(args, "session") } : {}), signal })),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "unsubscribe", description: "Remove a subscription this credential owns. An unknown or another caller's ID answers not_found.",
|
|
73
|
+
properties: { id: STRING }, required: ["id"], readOnly: false, organizes: true,
|
|
74
|
+
run: async (hub, args, signal) => { const id = need(args, "id"); await hub.unsubscribe(id, { signal }); return `unsubscribed ${id}` },
|
|
75
|
+
},
|
|
76
|
+
]
|
|
@@ -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,6 +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: 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." },
|
|
54
57
|
roles: { type: "array", items: ROLE, description: "1–32 roles." },
|
|
55
58
|
entryRole: { ...STRING, description: "The role that receives submitted work (e.g. pm) and supervises members." },
|
|
56
59
|
edges: { type: "array", items: EDGE, description: "Communication paths between roles (restricted mode)." },
|
|
@@ -176,6 +179,9 @@ function specLines(spec: TeamSpec): string[] {
|
|
|
176
179
|
` ${r.name}${r.name === spec.entryRole ? " (entry)" : ""}${r.label ? ` "${one(r.label)}"` : ""}${r.externalContact ? " external contact" : ""}${r.minMembers !== undefined || r.maxMembers !== undefined ? ` members ${r.minMembers ?? 0}–${r.maxMembers ?? 16}` : ""}${r.profile ? ` profile ${r.profile.profileID}${r.profile.version ? `@${r.profile.version}` : ""}` : ""}`
|
|
177
180
|
return [
|
|
178
181
|
`mode: ${spec.mode}${spec.mode === "open" ? " (every member may msg/ask every other)" : " (only the paths below)"}`,
|
|
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`] : []),
|
|
179
185
|
`entry role: ${spec.entryRole}`,
|
|
180
186
|
"roles:",
|
|
181
187
|
...spec.roles.map(role),
|
|
@@ -196,7 +202,7 @@ function rosterLines(roster: TeamRoster): string[] {
|
|
|
196
202
|
const flags = [r.vacant ? "vacant" : "", r.understaffed ? `understaffed (min ${r.minMembers})` : "", r.launching ? `${r.launching} launching` : ""].filter(Boolean)
|
|
197
203
|
lines.push(` ${r.role}${r.entry ? " (entry)" : ""} ${r.seats.length}/${r.maxMembers} ${r.online} online${flags.length ? ` ${flags.join(", ")}` : ""}`)
|
|
198
204
|
for (const s of r.seats)
|
|
199
|
-
lines.push(` ${s.staffing.id} ${s.staffing.sessionID} ${s.presence} gen ${s.staffing.generation} r${s.staffing.revision}${s.profile ? ` profile ${s.profile.status}${s.profile.matches ? "" : " (mismatch)"}` : ""}`)
|
|
205
|
+
lines.push(` ${s.staffing.id} ${s.staffing.sessionID} ${s.presence}${s.stalled ? ` stalled: ${s.stalled}` : ""} gen ${s.staffing.generation} r${s.staffing.revision}${s.profile ? ` profile ${s.profile.status}${s.profile.matches ? "" : " (mismatch)"}` : ""}`)
|
|
200
206
|
}
|
|
201
207
|
const failed = roster.requests.filter((q) => q.state !== "staffed").slice(-5)
|
|
202
208
|
for (const q of failed) lines.push(` launch ${q.id} ${q.role} ${q.state}${q.reason ? `: ${one(q.reason)}` : ""}`)
|
|
@@ -347,7 +353,7 @@ export const teamTools: readonly TeamTool[] = [
|
|
|
347
353
|
need(args, "name")
|
|
348
354
|
need(args, "title")
|
|
349
355
|
const key = optional(args, "idempotencyKey")
|
|
350
|
-
const spec = picked(args, ["name", "title", "description", "mode", "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
|
|
351
357
|
const { team, created } = await hub.teams.create({ ...spec, edges: spec.edges ?? [] }, { signal, ...(key ? { idempotencyKey: key } : {}) })
|
|
352
358
|
return [`${created ? "created" : "already created (same key)"}: ${teamLine(team)}`, ...specLines(team)].join("\n")
|
|
353
359
|
},
|
|
@@ -362,7 +368,7 @@ export const teamTools: readonly TeamTool[] = [
|
|
|
362
368
|
idempotent: true,
|
|
363
369
|
run: async (hub, args, signal) => {
|
|
364
370
|
const expectedRevision = revision(args)
|
|
365
|
-
const patch = picked(args, ["title", "description", "mode", "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
|
|
366
372
|
if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
|
|
367
373
|
const id = need(args, "team")
|
|
368
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.
|
|
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.
|
|
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.
|
|
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",
|