@huynle/hub 0.10.0 → 0.11.0
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 +78 -0
- package/README.md +20 -4
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +625 -395
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/grants-mcp.ts +65 -0
- package/packages/cli/src/mcp-operations.ts +49 -0
- package/packages/cli/src/mcp.ts +6 -0
- package/packages/cli/src/subscriptions-mcp.ts +76 -0
- package/packages/cli/src/teams-mcp.ts +5 -3
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +13 -1
- package/packages/protocol/src/teams.ts +4 -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/client.ts +13 -0
- package/packages/sdk/src/teams.ts +2 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/decisions.ts +7 -1
- package/src/central/agent/delivery.ts +39 -9
- package/src/central/agent/grant-routes.ts +14 -1
- package/src/central/agent/grants.ts +23 -0
- package/src/central/agent/launch-engine.ts +3 -3
- package/src/central/agent/message-limits.ts +3 -0
- package/src/central/agent/routes.ts +11 -0
- package/src/central/agent/subscriptions.ts +3 -1
- package/src/central/config.ts +9 -2
- package/src/central/http.ts +135 -24
- package/src/central/recording/export.ts +57 -3
- package/src/central/recording/index.ts +234 -16
- package/src/central/recording/ingest.ts +11 -2
- package/src/central/recording/routes.ts +61 -7
- package/src/central/recording/schema.ts +66 -2
- 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/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/hop-stalls.ts +72 -0
- package/src/central/teams/policy.ts +10 -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 +5 -2
- package/src/central/teams/validation.ts +8 -5
- package/src/spoke/spoke.ts +19 -0
- package/web/dist/assets/{ArtifactPage-Bz992ZX6.js → ArtifactPage-C5MMqXLN.js} +1 -1
- package/web/dist/assets/{ArtifactPane-BJZNixJC.js → ArtifactPane-CXSCmhyJ.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-Dti3VP07.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
- package/web/dist/assets/{Diagram-B4HdUxw0.js → Diagram-BGmyRb-R.js} +1 -1
- package/web/dist/assets/{Library-CpzD2HDW.js → Library-CFmMbXEa.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DlQeogea.js → ProfilesDestination-7jpOc2HM.js} +1 -1
- package/web/dist/assets/{TeamsPage-4xdWCaI-.js → TeamsPage-C1NGrUR-.js} +1 -1
- package/web/dist/assets/{Watchtower-CpQdIRns.js → Watchtower-DnvlJaU0.js} +2 -2
- package/web/dist/assets/{artifacts-But87Jfo.js → artifacts-DlQEYmDl.js} +1 -1
- package/web/dist/assets/index-Da8SKXMw.js +33 -0
- package/web/dist/assets/{index-BC0O1gfR.css → index-Dyq-jBeE.css} +1 -1
- package/web/dist/assets/live-BmqLASSo.js +5 -0
- package/web/dist/assets/{pane-layout-BW0RdX5a.js → pane-layout-BAuu_pA3.js} +1 -1
- package/web/dist/assets/{renderer2d-IaJiloPi.js → renderer2d-B2Nh39UN.js} +1 -1
- package/web/dist/assets/{renderer3d-ZiD1uKGW.js → renderer3d-CTYbljHH.js} +1 -1
- package/web/dist/assets/{safe-area-C7IAlkgY.js → safe-area-CkhEclGo.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/index-BS6lA6fN.js +0 -33
- package/web/dist/assets/live-CVUFIdUX.js +0 -5
|
@@ -58,7 +58,7 @@ import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, rende
|
|
|
58
58
|
import { noticeOnly } from "../../../packages/sdk/src/envelope.ts"
|
|
59
59
|
import { AgentError } from "./errors"
|
|
60
60
|
import { type ExpiredReason, parseNotAfter } from "./not-after"
|
|
61
|
-
import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits
|
|
61
|
+
import { HOP_CAP, HOP_CAP_CEILING, HopTracker, MessageLimiter, type MessageLimits } from "./message-limits"
|
|
62
62
|
import { parseAddress } from "./names"
|
|
63
63
|
import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
|
|
64
64
|
|
|
@@ -126,10 +126,16 @@ export interface DeliveryDeps {
|
|
|
126
126
|
/** Labels of a session sender (default: its @name from the directory). */
|
|
127
127
|
readonly describe?: (userID: string, sessionID: string) => PartyInfo
|
|
128
128
|
readonly alert?: (userID: string, alert: DeliveryAlert) => void
|
|
129
|
+
/** The first over-cap message between two seats of a team, once per persistent chain. */
|
|
130
|
+
readonly teamHopCap?: (userID: string, input: { readonly teamID: string; readonly sender: string; readonly recipient: string; readonly limit: number }) => Promise<void>
|
|
131
|
+
/** A quiet, hub-authored note to the team's entry seat; never counts a message hop. */
|
|
132
|
+
readonly teamHopNotice?: (userID: string, teamID: string) => Promise<string | undefined>
|
|
129
133
|
/** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
|
|
130
134
|
readonly expired?: (userID: string, message: Message) => void
|
|
131
135
|
/** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
|
|
132
136
|
readonly limits?: Partial<MessageLimits>
|
|
137
|
+
/** Configured global agent-only wake hop limit (default 8, absolute ceiling 100). */
|
|
138
|
+
readonly hopLimit?: number
|
|
133
139
|
/** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
|
|
134
140
|
readonly relayWindowMs?: number
|
|
135
141
|
/** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
|
|
@@ -141,7 +147,7 @@ export interface DeliveryDeps {
|
|
|
141
147
|
/** How often the re-wake loop runs (default REWAKE_TICK_MS). */
|
|
142
148
|
readonly tickMs?: number
|
|
143
149
|
/** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
|
|
144
|
-
readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
|
|
150
|
+
readonly teams?: Pick<TeamPolicy, "prepare" | "answer" | "hopContext">
|
|
145
151
|
/** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
|
|
146
152
|
readonly inbound?: InboundPolicy
|
|
147
153
|
}
|
|
@@ -273,6 +279,7 @@ export class Delivery {
|
|
|
273
279
|
private readonly owner: string
|
|
274
280
|
|
|
275
281
|
constructor(deps: DeliveryDeps) {
|
|
282
|
+
if (deps.hopLimit !== undefined && (!Number.isSafeInteger(deps.hopLimit) || deps.hopLimit < 1 || deps.hopLimit > HOP_CAP_CEILING)) throw new Error(`hopLimit must be 1–${HOP_CAP_CEILING}`)
|
|
276
283
|
this.deps = deps
|
|
277
284
|
const now = (this.now = deps.now ?? Date.now)
|
|
278
285
|
this.timers = deps.timers ?? defaultTimers
|
|
@@ -348,6 +355,17 @@ export class Delivery {
|
|
|
348
355
|
return this.hops.humanTurn(userID, sessionID)
|
|
349
356
|
}
|
|
350
357
|
|
|
358
|
+
async notifyTeamHopCap(userID: string, teamID: string, decisionID: string): Promise<void> {
|
|
359
|
+
const entry = await this.deps.teamHopNotice?.(userID, teamID)
|
|
360
|
+
if (!entry) return
|
|
361
|
+
const id = this.newID()
|
|
362
|
+
await this.request(userID, entry, {
|
|
363
|
+
op: "deliver", sessionID: entry, messageID: id, wake: false,
|
|
364
|
+
text: `[hub] notice · Team messages reached the hop cap. The user has a Renew Decision ${decisionID}; wait for their choice. · ${id}`,
|
|
365
|
+
meta: { kind: "msg", from: "hub", to: entry, delivery: "queue" },
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
351
369
|
/** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
|
|
352
370
|
/** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
|
|
353
371
|
flush(userID: string, sessionID?: string): Promise<void> {
|
|
@@ -489,8 +507,12 @@ export class Delivery {
|
|
|
489
507
|
await this.hops.received(userID, recipient, hop, from)
|
|
490
508
|
this.users.add(userID)
|
|
491
509
|
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
|
|
492
|
-
const
|
|
493
|
-
|
|
510
|
+
const team = "team" in resolved ? resolved.team : undefined
|
|
511
|
+
const explicitTeamID = typeof team === "object" && team !== null && "teamID" in team && typeof team.teamID === "string" ? team.teamID : undefined
|
|
512
|
+
const context = await this.deps.teams?.hopContext(userID, from, recipient, explicitTeamID)
|
|
513
|
+
const limit = Math.min(context?.limit ?? this.deps.hopLimit ?? HOP_CAP, this.deps.hopLimit ?? HOP_CAP)
|
|
514
|
+
const skipped = await this.wakeFor(userID, message, unwanted, limit, context?.teamID)
|
|
515
|
+
if (kind === "ask" && unwanted === undefined && hop <= limit) {
|
|
494
516
|
const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
|
|
495
517
|
await this.deps.state.put(userID, REWAKE, message.id, rewake)
|
|
496
518
|
}
|
|
@@ -612,7 +634,8 @@ export class Delivery {
|
|
|
612
634
|
if (message.recipient.kind !== "session") return answered
|
|
613
635
|
const sessionID = message.recipient.sessionID
|
|
614
636
|
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
|
|
615
|
-
const
|
|
637
|
+
const context = await this.deps.teams?.hopContext(userID, from, message.recipient)
|
|
638
|
+
const skipped = await this.wakeFor(userID, message, unwanted, Math.min(context?.limit ?? this.deps.hopLimit ?? HOP_CAP, this.deps.hopLimit ?? HOP_CAP), context?.teamID)
|
|
616
639
|
return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
|
|
617
640
|
}
|
|
618
641
|
|
|
@@ -620,16 +643,23 @@ export class Delivery {
|
|
|
620
643
|
* Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
|
|
621
644
|
* the hop cap (alerting once at the cap), or no wake budget left.
|
|
622
645
|
*/
|
|
623
|
-
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
|
|
646
|
+
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined, limit = this.deps.hopLimit ?? HOP_CAP, teamID?: string): Promise<WakeSkip | undefined> {
|
|
624
647
|
if (unwanted) return unwanted
|
|
625
648
|
if (message.recipient.kind !== "session") return "wake_false"
|
|
626
|
-
if (
|
|
627
|
-
if (message.
|
|
649
|
+
if (message.hop > limit) {
|
|
650
|
+
if (teamID && message.from.kind === "session") {
|
|
651
|
+
try {
|
|
652
|
+
await this.deps.teamHopCap?.(userID, { teamID, sender: message.from.sessionID, recipient: message.recipient.sessionID, limit })
|
|
653
|
+
} catch (cause) {
|
|
654
|
+
// The message has already been stored. A failed Decision must not prevent delivery or wake it.
|
|
655
|
+
console.error(`[hub] team hop-cap alert failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
656
|
+
}
|
|
657
|
+
} else if (message.hop === limit + 1) {
|
|
628
658
|
this.deps.alert?.(userID, {
|
|
629
659
|
kind: "hopCap",
|
|
630
660
|
sessionID: message.recipient.sessionID,
|
|
631
661
|
messageID: message.id,
|
|
632
|
-
text: `Agent messages went ${
|
|
662
|
+
text: `Agent messages went ${limit} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
|
|
633
663
|
})
|
|
634
664
|
}
|
|
635
665
|
return "hop_cap"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// /api/agent/grants (design §5, §7): the user's grants. User actors only (allow {}): the device cookie (same-origin
|
|
2
2
|
// writes, checked by the agent framework) or a device token. A session or app token never creates, edits, reads
|
|
3
|
-
// or revokes a grant (escalation guard).
|
|
3
|
+
// or revokes a grant on the admin routes (escalation guard). /mine is holder-scoped; a user has no holder.
|
|
4
|
+
// Answers {grant} / {grants}, never cached; the owner is not echoed.
|
|
4
5
|
//
|
|
5
6
|
// POST /api/agent/grants create (201) GET /api/agent/grants list, newest first
|
|
6
7
|
// GET /api/agent/grants/:id one grant PUT /api/agent/grants/:id replace (holder fixed)
|
|
@@ -37,6 +38,18 @@ export function grantRoutes(grants: Grants, now: () => number = Date.now, reques
|
|
|
37
38
|
} satisfies AgentRoute,
|
|
38
39
|
]
|
|
39
40
|
: []),
|
|
41
|
+
{
|
|
42
|
+
method: "GET", path: `${PREFIX}/mine`, allow: { session: true, scope: "messages:read" },
|
|
43
|
+
handle: async ({ actor }) => Response.json({ grants: (await grants.held(actor)).map((g) => grantView(g, now())) }, { headers: NO_STORE }),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
method: "GET", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:read" },
|
|
47
|
+
handle: async ({ actor, params }) => one(await grants.heldGrant(actor, params.id!)),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
method: "DELETE", path: `${PREFIX}/mine/:id`, allow: { session: true, scope: "messages:send" },
|
|
51
|
+
handle: async ({ actor, params }) => one(await grants.revokeHeld(actor, params.id!)),
|
|
52
|
+
},
|
|
40
53
|
{
|
|
41
54
|
method: "POST",
|
|
42
55
|
path: PREFIX,
|
|
@@ -136,6 +136,29 @@ export class Grants {
|
|
|
136
136
|
return (await this.all(owner)).sort((a, b) => b.created - a.created || (a.id < b.id ? -1 : 1)).map((g) => ({ ...g, state: grantState(g, at) }))
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** Grants currently held by this credential; a user credential has no session/app holder. */
|
|
140
|
+
async held(actor: AgentActor): Promise<GrantView[]> {
|
|
141
|
+
if (actor.kind === "user") return []
|
|
142
|
+
return (await this.list(actor.userID)).filter((grant) => grant.owner === actor.userID && this.holds(grant, actor))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** A holder cannot inspect another holder's grant (including an unheld role). */
|
|
146
|
+
async heldGrant(actor: AgentActor, id: string): Promise<Grant> {
|
|
147
|
+
if (actor.kind === "user") throw notFound()
|
|
148
|
+
const grant = await this.get(actor.userID, id)
|
|
149
|
+
if (grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
|
|
150
|
+
return grant
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Re-check holder inside the versioned mutation, so a role lost after a read cannot revoke it. */
|
|
154
|
+
async revokeHeld(actor: AgentActor, id: string): Promise<Grant> {
|
|
155
|
+
await this.heldGrant(actor, id)
|
|
156
|
+
return this.change(actor.userID, id, "grant.revoke", (grant, at) => {
|
|
157
|
+
if (actor.kind === "user" || grant.owner !== actor.userID || !this.holds(grant, actor)) throw notFound()
|
|
158
|
+
return { ...grant, revokedAt: at }
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
139
162
|
/**
|
|
140
163
|
* Replaces an active grant with a new spec (its expiry restarts now); id, holder (and its pin) and created
|
|
141
164
|
* stay. Changing the holder is invalid (revoke and create instead); an ended grant is gone.
|
|
@@ -746,7 +746,7 @@ export class LaunchEngine {
|
|
|
746
746
|
const workspace = work?.workspace ?? (launch ? leftWorkspace(launch, report) : undefined)
|
|
747
747
|
if (!workspace) continue
|
|
748
748
|
if (HARNESS_UP.has(report.phase)) await nodeOps.requestNode(node.connID, { op: "stop", launchID: report.launchID, workspace, mode: "kill" })
|
|
749
|
-
await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (workspace.reused ? "keep" : "remove") })
|
|
749
|
+
await nodeOps.requestNode(node.connID, { op: "cleanup", launchID: report.launchID, workspace, policy: work?.cleanup ?? (launch?.spec.end.cleanup ?? (workspace.reused ? "keep" : "remove")) })
|
|
750
750
|
this.cleanupTries.delete(report.launchID)
|
|
751
751
|
this.cleanupLogged.delete(report.launchID)
|
|
752
752
|
} catch (cause) {
|
|
@@ -1122,6 +1122,8 @@ export class LaunchEngine {
|
|
|
1122
1122
|
// A lost launch whose session a spoke still reports keeps its workspace (never removed under a live session).
|
|
1123
1123
|
const sessionLive = outcome === "lost" && work.sessionID !== undefined && this.deps.registry.hasSession(work.sessionID)
|
|
1124
1124
|
const policy: CleanupPolicy = outcome === "needs_user" || sessionLive ? "keep" : !work.prompted ? "remove" : launch.spec.end.cleanup
|
|
1125
|
+
// Reconciliation must replay the policy actually applied, not the launch's original preference.
|
|
1126
|
+
await this.updateWork(userID, id, { cleanup: policy })
|
|
1125
1127
|
const node = launch.machineID !== undefined ? this.deps.nodes.forMachine(launch.machineID) : undefined
|
|
1126
1128
|
if (node) {
|
|
1127
1129
|
if (work.started) {
|
|
@@ -1145,11 +1147,9 @@ export class LaunchEngine {
|
|
|
1145
1147
|
} catch (cause) {
|
|
1146
1148
|
// The node may still hold the launch (and its slot): reconcile retries with this policy.
|
|
1147
1149
|
notes.push(`workspace cleanup failed: ${errorText(cause)}; retried later`)
|
|
1148
|
-
await this.updateWork(userID, id, { cleanup: policy })
|
|
1149
1150
|
}
|
|
1150
1151
|
} else {
|
|
1151
1152
|
notes.push("workspace cleanup waits for the node")
|
|
1152
|
-
await this.updateWork(userID, id, { cleanup: policy })
|
|
1153
1153
|
}
|
|
1154
1154
|
}
|
|
1155
1155
|
try {
|
|
@@ -28,9 +28,12 @@ import type { Clock, Counters, StateStore } from "../backend/types"
|
|
|
28
28
|
import { type Party, partyKey } from "./envelope"
|
|
29
29
|
import { AgentError } from "./errors"
|
|
30
30
|
import { updateVersioned } from "./versioned"
|
|
31
|
+
import { TEAM_LIMITS } from "../../protocol"
|
|
31
32
|
|
|
32
33
|
/** Hops a reply chain may take without a human turn. */
|
|
33
34
|
export const HOP_CAP = 8
|
|
35
|
+
/** Absolute ceiling on user-configured hop limits, independent of hub.json. */
|
|
36
|
+
export const HOP_CAP_CEILING = TEAM_LIMITS.hopLimit
|
|
34
37
|
/** A depth no message refreshed for this long is forgotten. */
|
|
35
38
|
export const HOP_TTL_MS = 24 * 60 * 60_000
|
|
36
39
|
|
|
@@ -67,6 +67,7 @@ import { isConciergeLaunch } from "../concierge/supervisor"
|
|
|
67
67
|
import type { ConciergeService } from "../concierge/service"
|
|
68
68
|
import type { SessionIntent } from "../../protocol"
|
|
69
69
|
import type { SessionIntents } from "./intents"
|
|
70
|
+
import type { NodeRegistry } from "../nodes"
|
|
70
71
|
import { intentRoutes } from "./intent-routes"
|
|
71
72
|
import { contextCardRoutes } from "../context-cards/routes"
|
|
72
73
|
import type { ContextCardService } from "../context-cards/service"
|
|
@@ -98,6 +99,8 @@ export interface AgentRoute {
|
|
|
98
99
|
/** What the agent API needs from the hub. */
|
|
99
100
|
export interface AgentDeps {
|
|
100
101
|
readonly store: Store
|
|
102
|
+
/** Owner's connected launch nodes: read-only user route; never expose connection IDs or launch internals. */
|
|
103
|
+
readonly nodes?: Pick<NodeRegistry, "list">
|
|
101
104
|
/** (AF1) Connect an app: the connected-app token verifier and the app-clients/connections routes when given. */
|
|
102
105
|
readonly connect?: AppConnect
|
|
103
106
|
/** (AF2) Speech for apps: POST /api/agent/speech/{transcribe,speak} (scope speech:use) when given. */
|
|
@@ -238,6 +241,14 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
238
241
|
const feed = deps.feed ?? new MessageFeed()
|
|
239
242
|
const appDecisions = appDecisionParts(deps, feed)
|
|
240
243
|
const routes: AgentRoute[] = [
|
|
244
|
+
...(deps.nodes ? [{
|
|
245
|
+
method: "GET", path: `${AGENT_PREFIX}/launch-nodes`, allow: {},
|
|
246
|
+
handle: ({ user }) => Response.json({ nodes: deps.nodes!.list(user.id).map((node) => ({
|
|
247
|
+
machineID: node.machineID, machineName: node.machineName, workspaceRoots: node.workspaceRoots,
|
|
248
|
+
capacity: node.capacity, used: node.used, free: node.free, runtimes: node.runtimes,
|
|
249
|
+
labels: node.labels, draining: node.draining,
|
|
250
|
+
})) }, { headers: { "cache-control": "no-store" } }),
|
|
251
|
+
} satisfies AgentRoute] : []),
|
|
241
252
|
...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
|
|
242
253
|
...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
|
|
243
254
|
...(deps.directory && deps.contextCards ? contextCardRoutes({ cards: deps.contextCards, directory: deps.directory, ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
|
|
@@ -245,6 +245,8 @@ export interface SubscriptionsDeps {
|
|
|
245
245
|
readonly send: SubscriptionSend
|
|
246
246
|
/** The session's message hop depth (HopTracker.depth). */
|
|
247
247
|
readonly hopDepth: (userID: string, sessionID: string) => Promise<number>
|
|
248
|
+
/** Hub-wide hop ceiling (team notices are not session-to-session messages). */
|
|
249
|
+
readonly hopLimit?: number
|
|
248
250
|
/** Directory facts of these sessions (deleted and unknown ones absent). */
|
|
249
251
|
readonly entries: (userID: string, sessionIDs: readonly string[]) => ReadonlyMap<string, SubscriptionEntry>
|
|
250
252
|
/** The sessions an address names for the subscriber (its visibility); throws not_found / invalid / ambiguous. */
|
|
@@ -858,7 +860,7 @@ export class Subscriptions {
|
|
|
858
860
|
if (scoped && !scoped(subject)) continue
|
|
859
861
|
const cause = isMessage && subject.senderID !== undefined && subject.senderID.startsWith("ses_") ? subject.senderID : (subject.sessionID ?? target)
|
|
860
862
|
const depth = await this.depth(ctx, cause)
|
|
861
|
-
if (depth + 1 > HOP_CAP) {
|
|
863
|
+
if (depth + 1 > (this.deps.hopLimit ?? HOP_CAP)) {
|
|
862
864
|
suppressed++
|
|
863
865
|
continue
|
|
864
866
|
}
|
package/src/central/config.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { readContextCards, type ContextCardsConfig } from "./context-cards/confi
|
|
|
15
15
|
import { checkReleaseBase } from "./install/render"
|
|
16
16
|
import { cspMode, type CspMode } from "./csp"
|
|
17
17
|
import { parseDenyCidrs } from "./fetcher"
|
|
18
|
+
import { HOP_CAP, HOP_CAP_CEILING } from "./agent/message-limits"
|
|
18
19
|
|
|
19
20
|
export const GOOGLE_ISSUER = "https://accounts.google.com"
|
|
20
21
|
|
|
@@ -70,6 +71,8 @@ export interface CentralConfig {
|
|
|
70
71
|
readonly auth: AuthConfig
|
|
71
72
|
/** hub.json `agents.never`: permission patterns no agent may ever approve, for every user (design §5, §7). */
|
|
72
73
|
readonly agentsNever: readonly string[]
|
|
74
|
+
/** hub.json `agents.hopLimit`: global maximum agent-only hops before a wake is skipped. */
|
|
75
|
+
readonly hopLimit: number
|
|
73
76
|
/** hub.json `agents.launches`: per-user launch caps, only those set (placement fills in its defaults). */
|
|
74
77
|
readonly launchCaps?: HubLaunches
|
|
75
78
|
/** hub.json `agents.webhooks.allowPrivate`: webhooks may target http, loopback, private and local hosts (admin opt-in). */
|
|
@@ -138,6 +141,7 @@ export interface HubLaunches {
|
|
|
138
141
|
export interface HubAgents {
|
|
139
142
|
/** Normalised `action[:glob]` patterns (see agent/policy.ts), duplicates dropped. */
|
|
140
143
|
readonly never?: readonly string[]
|
|
144
|
+
readonly hopLimit?: number
|
|
141
145
|
readonly launches?: HubLaunches
|
|
142
146
|
readonly webhooks?: HubWebhooks
|
|
143
147
|
/** (AF2) Per-credential app speech quotas: any subset of SPEECH_RATE_LIMITS. */
|
|
@@ -169,7 +173,7 @@ type Env = Readonly<Record<string, string | undefined>>
|
|
|
169
173
|
|
|
170
174
|
const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "auth", "agents", "cluster", "contextCards"])
|
|
171
175
|
const CLUSTER_KEYS = ["database", "valkey"] as const
|
|
172
|
-
const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas"])
|
|
176
|
+
const AGENTS_KEYS = new Set(["never", "launches", "webhooks", "speechQuotas", "hopLimit"])
|
|
173
177
|
/** The largest speech quota hub.json may set (counts and characters). */
|
|
174
178
|
const SPEECH_QUOTA_MAX = 1_000_000_000
|
|
175
179
|
const WEBHOOK_KEYS = new Set(["allowPrivate", "allowPrivateHosts"])
|
|
@@ -350,11 +354,13 @@ function readHubAgents(raw: unknown, file: string, warnings: string[], fail: (me
|
|
|
350
354
|
if (!isObj(raw)) fail("agents must be an object")
|
|
351
355
|
const input = raw as Record<string, unknown>
|
|
352
356
|
for (const key of Object.keys(input)) if (!AGENTS_KEYS.has(key)) warnings.push(`${file}: unknown key "agents.${key}" ignored`)
|
|
357
|
+
if (input.hopLimit !== undefined && (!Number.isSafeInteger(input.hopLimit) || (input.hopLimit as number) < 1 || (input.hopLimit as number) > HOP_CAP_CEILING))
|
|
358
|
+
fail(`agents.hopLimit must be a whole number from 1 to ${HOP_CAP_CEILING}`)
|
|
353
359
|
const launches = input.launches === undefined ? undefined : readHubLaunches(input.launches, file, warnings, fail)
|
|
354
360
|
const never = input.never === undefined ? undefined : readNever(input.never, fail)
|
|
355
361
|
const webhooks = input.webhooks === undefined ? undefined : readHubWebhooks(input.webhooks, file, warnings, fail)
|
|
356
362
|
const speechQuotas = input.speechQuotas === undefined ? undefined : readSpeechQuotas(input.speechQuotas, file, warnings, fail)
|
|
357
|
-
return { ...(never ? { never } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
|
|
363
|
+
return { ...(never ? { never } : {}), ...(input.hopLimit !== undefined ? { hopLimit: input.hopLimit as number } : {}), ...(launches ? { launches } : {}), ...(webhooks ? { webhooks } : {}), ...(speechQuotas ? { speechQuotas } : {}) }
|
|
358
364
|
}
|
|
359
365
|
|
|
360
366
|
/** (AF2) `agents.speechQuotas`: {transcribe?: {perMinute?, perHour?}, speak?: {perMinute?, perHour?, charsPerHour?}}. */
|
|
@@ -601,6 +607,7 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
|
|
|
601
607
|
...(file.speech ? { speech: file.speech } : {}),
|
|
602
608
|
auth,
|
|
603
609
|
agentsNever: [...(file.agents?.never ?? [])],
|
|
610
|
+
hopLimit: file.agents?.hopLimit ?? HOP_CAP,
|
|
604
611
|
...(file.agents?.launches ? { launchCaps: { ...file.agents.launches } } : {}),
|
|
605
612
|
...(file.agents?.webhooks?.allowPrivate === true ? { webhooksAllowPrivate: true as const } : {}),
|
|
606
613
|
...(file.agents?.webhooks?.allowPrivateHosts?.length ? { webhooksPrivateHosts: [...file.agents.webhooks.allowPrivateHosts] } : {}),
|