@huynle/hub 0.10.1 → 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 +54 -0
- package/README.md +17 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +622 -394
- 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/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 +102 -20
- 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/web/dist/assets/{ArtifactPage-CsPjam8D.js → ArtifactPage-C5MMqXLN.js} +1 -1
- package/web/dist/assets/{ArtifactPane-B9weO8L6.js → ArtifactPane-CXSCmhyJ.js} +1 -1
- package/web/dist/assets/{ArtifactWorkbench-DzKhMs3l.js → ArtifactWorkbench-S0RxNGWy.js} +1 -1
- package/web/dist/assets/{Diagram-CuRIffka.js → Diagram-BGmyRb-R.js} +1 -1
- package/web/dist/assets/{Library-rv5DGtrm.js → Library-CFmMbXEa.js} +2 -2
- package/web/dist/assets/{ProfilesDestination-DUuQ9jmd.js → ProfilesDestination-7jpOc2HM.js} +1 -1
- package/web/dist/assets/{TeamsPage-D_17MkvN.js → TeamsPage-C1NGrUR-.js} +1 -1
- package/web/dist/assets/{Watchtower-DlrNOJ7X.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-rW6LKQj1.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-DyANQ1WS.js → renderer2d-B2Nh39UN.js} +1 -1
- package/web/dist/assets/{renderer3d-vYYKFAJq.js → renderer3d-CTYbljHH.js} +1 -1
- package/web/dist/assets/{safe-area-B2Bk__FK.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-bX8B6NLo.js +0 -33
- package/web/dist/assets/live-BGLA-peO.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] } : {}),
|
package/src/central/http.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { diskAssets, type WebAssets } from "./assets"
|
|
|
8
8
|
import path from "node:path"
|
|
9
9
|
import { promisify } from "node:util"
|
|
10
10
|
import { brotliCompress, constants as zlib, gzip } from "node:zlib"
|
|
11
|
-
import { CONNECT_PATHS, formAnswerProblem, isDeliverResult, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type ResultFor, type SessionModelPrefs } from "../protocol"
|
|
11
|
+
import { CONNECT_PATHS, formAnswerProblem, isDeliverResult, isPreviewPath, parseMediaAllowlist, parseMediaRef, PREVIEW_BUNDLE_LIMITS, NOT_A_USER_MESSAGE, RECORDING_AUDIO_RESULT_HEADER, RECORDING_HEADER, REVERT_BUSY, REVERT_END_BUSY, PROMPT_IMAGE_MIMES, WORKSPACE_LIMITS, promptImagesProblem, redactEmbeddedMedia, type AvailableModel, type CoreOp, type EnrollStart, type FormAnswer, type ModelSelection, type PermissionDecision, type PromptImage, type ResultFor, type SessionModelPrefs, type SessionSummary } from "../protocol"
|
|
12
12
|
import { isLoopbackAddress, isLoopbackHost, isLoopbackURL, loopbackOrigins } from "../loopback"
|
|
13
13
|
import { speakable } from "../speakable"
|
|
14
14
|
import { Auth, RateLimitError, ValidationError } from "./auth"
|
|
@@ -60,7 +60,7 @@ import { BusyError, Registry, RELAY_REASON, RelayRefused, type Timers } from "./
|
|
|
60
60
|
import { RELAY_REASON_CONFIRMED, relayMessage, relayQuestionProblem, STATUS_QUESTION_SOFT_REFUSAL } from "./front-relays"
|
|
61
61
|
import { createActions, type Unsupported, unsupportedMessage } from "./actions"
|
|
62
62
|
import { IntentRetention, SessionIntents, withPromptIntents } from "./agent/intents"
|
|
63
|
-
import { audioFormat, SpeechError } from "./speech/provider"
|
|
63
|
+
import { audioFormat, SpeechError, type TranscribeStats } from "./speech/provider"
|
|
64
64
|
import { SpeechService } from "./speech/service"
|
|
65
65
|
import { NodeRegistry } from "./nodes"
|
|
66
66
|
import { ConnDirectory } from "./cluster/directory"
|
|
@@ -85,7 +85,7 @@ import { coordinatorActor, SupervisorCoordinator } from "./workspaces/coordinato
|
|
|
85
85
|
import { artifactJournalEvent } from "./artifacts/events"
|
|
86
86
|
import { ArtifactMaintenance } from "./artifacts/maintenance"
|
|
87
87
|
import { MemoryBlobStore } from "./backend/memory-blobs"
|
|
88
|
-
import { RecordingService } from "./recording"
|
|
88
|
+
import { RecordingService, type DevLimits } from "./recording"
|
|
89
89
|
import { recordingRoutes } from "./recording/routes"
|
|
90
90
|
import { wrapConciergeSend } from "./recording/concierge-taps"
|
|
91
91
|
import { ARTIFACT_UPLOAD_LIMITS } from "./artifacts/routes"
|
|
@@ -100,6 +100,7 @@ import { teamDeniedEvent, TeamPolicy } from "./teams/policy"
|
|
|
100
100
|
import { TeamSeatStore } from "./teams/seats"
|
|
101
101
|
import { TeamStaffingService } from "./teams/staffing"
|
|
102
102
|
import { intakeActor, TeamIntakeService } from "./teams/intake"
|
|
103
|
+
import { TeamHopStalls } from "./teams/hop-stalls"
|
|
103
104
|
import { TeamStore } from "./teams/store"
|
|
104
105
|
import { ActivityTracker } from "./concierge/activity"
|
|
105
106
|
import { HumanActivity, withHumanActivity } from "./human-activity"
|
|
@@ -137,7 +138,8 @@ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEY
|
|
|
137
138
|
const HUMAN_ACTIVITY_STOP_MS = 3_000
|
|
138
139
|
|
|
139
140
|
export interface Speech {
|
|
140
|
-
|
|
141
|
+
/** `stats` (recording mode): the provider fills in its upstream request's status, bytes and time to headers. */
|
|
142
|
+
transcribe(audio: Uint8Array, format: string, stats?: TranscribeStats): Promise<string>
|
|
141
143
|
speak(text: string): Promise<Uint8Array<ArrayBuffer>>
|
|
142
144
|
/** Streams the audio as it is synthesized (the built-in providers do; an override may only buffer). */
|
|
143
145
|
speakStream?(text: string, signal?: AbortSignal): Promise<ReadableStream<Uint8Array>>
|
|
@@ -149,6 +151,8 @@ export interface CentralOptions {
|
|
|
149
151
|
readonly previewRetention?: { readonly archiveAfterMs?: number; readonly purgeAfterMs?: number }
|
|
150
152
|
/** The registry's timers (tests: a fake clock for its delayed checks); real timers by default. */
|
|
151
153
|
readonly registryTimers?: Timers
|
|
154
|
+
/** Developer mode's recording limits (tests shrink them; default DEV_LIMITS). */
|
|
155
|
+
readonly recordingDevLimits?: Partial<DevLimits>
|
|
152
156
|
/** The built web app (web/dist, see web/vite.config.ts) on disk; or pass `web` (e.g. files embedded in a compiled binary). */
|
|
153
157
|
readonly webDir?: string
|
|
154
158
|
readonly web?: WebAssets
|
|
@@ -888,7 +892,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
888
892
|
// sessions frame; human turns (console turn/utterance, prompts typed in the harness) reset hop chains.
|
|
889
893
|
// Teams (HWA-33) and their communication policy in delivery (HWA-34): a denial is audited and journaled (IDs only).
|
|
890
894
|
let teamSeats: TeamSeatStore | undefined
|
|
891
|
-
const teamStore = new TeamStore({ state: backends.state, now, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
|
|
895
|
+
const teamStore = new TeamStore({ state: backends.state, now, globalHopLimit: config.hopLimit, pinnedVersions: (userID, teamID) => teamSeats!.pinnedVersions(userID, teamID) })
|
|
892
896
|
const teamPresence = directoryPresence(directory, (userID) => store.agentData(userID).sessions ?? [])
|
|
893
897
|
teamSeats = new TeamSeatStore({ state: backends.state, teams: teamStore, now, knowsSession: async (userID, sessionID) => !["unknown", "deleted"].includes(await teamPresence(userID, sessionID)) })
|
|
894
898
|
const teamPolicy = new TeamPolicy({
|
|
@@ -901,8 +905,35 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
901
905
|
await Promise.all([audit.record(userID, { actor: { kind: "session", sessionID: denial.senderID }, verb: "team.denied", target, decision: "forbidden", reason, outcome: "refused" }), agentEvents.append(userID, teamDeniedEvent(denial))])
|
|
902
906
|
},
|
|
903
907
|
})
|
|
908
|
+
let decisions: Decisions
|
|
909
|
+
const teamHopStalls = new TeamHopStalls({
|
|
910
|
+
state: backends.state,
|
|
911
|
+
now,
|
|
912
|
+
seats: async (userID, teamID) => [...new Set((await teamSeats!.list(userID, teamID, { current: true })).map((seat) => seat.sessionID))],
|
|
913
|
+
reset: (userID, sessionID) => delivery.hops.humanTurn(userID, sessionID),
|
|
914
|
+
close: async (userID, decisionID) => { await decisions.close(userID, decisionID, "A person continued this team's work; renewal is no longer needed") },
|
|
915
|
+
notice: (userID, teamID, decisionID) => delivery.notifyTeamHopCap(userID, teamID, decisionID),
|
|
916
|
+
open: async (userID, teamID, sender, recipient, limit) => {
|
|
917
|
+
const team = await teamStore.require(userID, teamID)
|
|
918
|
+
const intake = await teamIntake?.list(userID, { kind: "user", userID, deviceID: "hub", via: "cookie" }, teamID, { limit: 50 })
|
|
919
|
+
const active = intake?.items.find((item) => item.status === "routed" && (item.sessionID === sender || item.sessionID === recipient))
|
|
920
|
+
const detail = active ? `, intake ${active.id} (${active.title})` : ""
|
|
921
|
+
const { decision } = await decisions.ask(userID, { kind: "app", userID, tokenID: "team-hop", scopes: [], label: "Hub team hop cap" }, {
|
|
922
|
+
question: `Team #${team.name}${detail} reached its ${limit}-hop limit (${sender} → ${recipient}). Renew this team's seats?`,
|
|
923
|
+
options: [{ id: "renew", label: "Renew" }, { id: "leave", label: "Leave stopped", safe: true }],
|
|
924
|
+
urgency: "high", tier: "tap", about: [recipient],
|
|
925
|
+
}, { action: { kind: "teamHopRenew", teamID } })
|
|
926
|
+
return decision.id
|
|
927
|
+
},
|
|
928
|
+
})
|
|
904
929
|
const delivery = new Delivery({
|
|
930
|
+
hopLimit: config.hopLimit,
|
|
905
931
|
teams: teamPolicy,
|
|
932
|
+
teamHopCap: async (userID, input) => { await teamHopStalls.hit(userID, input.teamID, input.sender, input.recipient, input.limit) },
|
|
933
|
+
teamHopNotice: async (userID, teamID) => {
|
|
934
|
+
const team = await teamStore.get(userID, teamID)
|
|
935
|
+
return (await teamSeats!.list(userID, teamID, { current: true })).find((seat) => seat.role === team?.entryRole)?.sessionID
|
|
936
|
+
},
|
|
906
937
|
// CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
|
|
907
938
|
inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
|
|
908
939
|
state: backends.state,
|
|
@@ -937,7 +968,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
937
968
|
/** (CON-06) The Concierge tiers (set below, once the Concierge modules exist): proposals' act, via, disarm. */
|
|
938
969
|
let conciergeTierHooks: ConciergeTiers | undefined
|
|
939
970
|
// Decisions (design §12.1): asks to @me; deadlines run over every user's Decisions (survive restarts).
|
|
940
|
-
|
|
971
|
+
decisions = new Decisions({
|
|
941
972
|
state: backends.state,
|
|
942
973
|
delivery,
|
|
943
974
|
now,
|
|
@@ -954,6 +985,13 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
954
985
|
// (CON-06) Concierge proposals run their verb as the user (all set up below).
|
|
955
986
|
act: (userID, decision, choice, by) => {
|
|
956
987
|
const kind = decision.action?.kind
|
|
988
|
+
if (kind === "teamHopRenew") {
|
|
989
|
+
if (by.userID !== userID || by.via !== "cookie") return Promise.resolve("gone" as const)
|
|
990
|
+
const teamID = decision.action!.teamID
|
|
991
|
+
return choice.option === "renew"
|
|
992
|
+
? teamHopStalls.renew(userID, teamID, decision.id).then(async () => { await audit.record(userID, { actor: { kind: "user" }, verb: "team.hop.renew", target: {}, decision: "allow", outcome: "ok" }); return "done" as const })
|
|
993
|
+
: Promise.resolve("done" as const)
|
|
994
|
+
}
|
|
957
995
|
if (kind === "act") {
|
|
958
996
|
if (!conciergeTierHooks?.act) throw new Error("This decision's action cannot run on this hub")
|
|
959
997
|
return conciergeTierHooks.act(userID, decision, choice, by)
|
|
@@ -975,6 +1013,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
975
1013
|
const tenant = registry.sessionTenant(sessionID)
|
|
976
1014
|
if (tenant === undefined) return
|
|
977
1015
|
await delivery.humanTurn(tenant, sessionID).catch((cause) => console.error(`[hub] hop reset failed: ${(cause as Error).message}`))
|
|
1016
|
+
await teamHopStalls.clearForHuman(tenant, sessionID).catch((cause) => console.error(`[hub] team hop reset failed: ${(cause as Error).message}`))
|
|
978
1017
|
}
|
|
979
1018
|
registry.onSessions((connID) => void delivery.onSessions(connID).catch((cause) => console.error(`[hub] message flush failed: ${(cause as Error).message}`)))
|
|
980
1019
|
// Committed revert windows (./revert-windows): the hub message rows of a removed history stay hidden for good.
|
|
@@ -1313,6 +1352,23 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1313
1352
|
audit: (entry) => store.audit(entry),
|
|
1314
1353
|
users: allUsers,
|
|
1315
1354
|
log: (message) => console.error(message),
|
|
1355
|
+
pod: config.cluster?.podID ?? "hub",
|
|
1356
|
+
// (Developer mode) What ran a turn: the session's model and context size from its spoke (summary), counts only.
|
|
1357
|
+
turnFacts: async (_userID, sessionID) => {
|
|
1358
|
+
const summary = (await spokes.request(ownerOf(sessionID), { op: "summary", sessionID })) as SessionSummary
|
|
1359
|
+
const model = summary?.model
|
|
1360
|
+
const context = summary?.context
|
|
1361
|
+
const agent = registry.session(sessionID)?.agent
|
|
1362
|
+
return {
|
|
1363
|
+
...(model?.providerID ? { provider: model.providerID } : {}),
|
|
1364
|
+
...(model?.modelID ? { model: model.modelID } : context?.model ? { model: context.model } : {}),
|
|
1365
|
+
...(model?.variant ? { variant: model.variant } : {}),
|
|
1366
|
+
...(agent ? { agent } : {}),
|
|
1367
|
+
...(typeof context?.used === "number" ? { contextTokens: context.used } : {}),
|
|
1368
|
+
...(typeof context?.limit === "number" ? { contextLimit: context.limit } : {}),
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
...(options.recordingDevLimits ? { devLimits: options.recordingDevLimits } : {}),
|
|
1316
1372
|
})
|
|
1317
1373
|
recordings = recordingService
|
|
1318
1374
|
registry.recordingTap = recordingService.registryTap
|
|
@@ -1378,7 +1434,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1378
1434
|
const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
|
|
1379
1435
|
// Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
|
|
1380
1436
|
const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
|
|
1381
|
-
const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
|
|
1437
|
+
const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, hopStalls: teamHopStalls, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
|
|
1382
1438
|
registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
|
|
1383
1439
|
launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
|
|
1384
1440
|
// Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter). The
|
|
@@ -1435,6 +1491,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1435
1491
|
const subscriptionLeader = leader("subscriptions", { onElected: () => subscriptions?.start(), onDeposed: () => subscriptions?.stop() })
|
|
1436
1492
|
subscriptions = new Subscriptions({
|
|
1437
1493
|
state: backends.state,
|
|
1494
|
+
hopLimit: config.hopLimit,
|
|
1438
1495
|
events: agentEvents,
|
|
1439
1496
|
send: async (userID, sessionID, text, options) => void (await delivery.send(userID, subscriptionActor(userID), sessionID, "msg", text, options)),
|
|
1440
1497
|
hopDepth: (userID, sessionID) => delivery.hops.depth(userID, { kind: "session", sessionID }),
|
|
@@ -1744,7 +1801,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1744
1801
|
conversationOf: (sessionID) => registry.conversationOf(sessionID),
|
|
1745
1802
|
stateOf: (sessionID) => registry.status(sessionID)?.find((s) => s.id === sessionID)?.state ?? "idle",
|
|
1746
1803
|
}
|
|
1747
|
-
const agentDeps = { contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, subscriptions, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => [...conciergeParts.tiers.routes(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
|
|
1804
|
+
const agentDeps = { nodes, contextCards: cards, conversation: { ports: conversationPorts, source: appStreamSource, streams: appStreams, cookieName: COOKIE, ...options.appStreams }, connect: appConnect, speech: speechDeps, recording: { call: (userID: string, sessionID: string, fields: Parameters<RecordingService["conciergeCall"]>[2]) => recordingService.conciergeCall(userID, sessionID, fields) }, intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, messageState: backends.state, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, subscriptions, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => [...conciergeParts.tiers.routes(), ...conciergeGrantRequestRoutes({ requests: conciergeGrantRequests, identity: conciergeTimedIdentity })], armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage, projectGrant: conciergeProjectGrant } }
|
|
1748
1805
|
const resolveActor = createResolver({
|
|
1749
1806
|
cookieName: COOKIE,
|
|
1750
1807
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -2026,16 +2083,18 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2026
2083
|
await store.removeDevice(device.id)
|
|
2027
2084
|
return new Response(null, { status: 204, headers: { "set-cookie": cookie("", 0) } })
|
|
2028
2085
|
}),
|
|
2029
|
-
route("POST", "/api/transcribe", "user", ({ request, user, device }) => transcribe(request, speechFor(user.id), user.id, device.id)),
|
|
2030
|
-
route("POST", "/api/speak", "user",
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2086
|
+
route("POST", "/api/transcribe", "user", ({ request, user, device }) => requestLined(user.id, device.id, request, "transcribe", () => transcribe(request, speechFor(user.id), user.id, device.id))),
|
|
2087
|
+
route("POST", "/api/speak", "user", ({ request, user, device }) =>
|
|
2088
|
+
requestLined(user.id, device.id, request, "speak", async () => {
|
|
2089
|
+
const text = speakable(String((await body(request)).text ?? ""), config.maxSpeechChars)
|
|
2090
|
+
if (!text) return error(400, "Nothing to speak")
|
|
2091
|
+
const speech = speechFor(user.id)
|
|
2092
|
+
// (REC-01) x-hub-recording (+ x-hub-tts-ref): tts.synth timing; in Developer mode the audio is kept too.
|
|
2093
|
+
const tap = await recordingService.speakTap(user.id, device.id, request.headers, text.length).catch(() => undefined)
|
|
2094
|
+
const audio = await speakAudio(speech, text, request.signal)
|
|
2095
|
+
return new Response(tap ? tap.wrap(audio) : audio, { headers: { "content-type": "audio/mpeg" } })
|
|
2096
|
+
}),
|
|
2097
|
+
),
|
|
2039
2098
|
// (REC-01) Recording routes: console only ("user": the user's cookie or device token), see docs/voice-recording.md.
|
|
2040
2099
|
...recordingRoutes(recordingService, { version: options.version ?? "0.0.0", now, audit: (entry) => store.audit(entry) }).map((r) => route(r.method, r.path, "user", (context) => r.handle(context))),
|
|
2041
2100
|
route("GET", "/api/push/key", "user", async () => Response.json({ publicKey: await push.publicKey() })),
|
|
@@ -3152,6 +3211,28 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3152
3211
|
return answered.outcome === "done" ? new Response(null, { status: 204 }) : error(409, PERMISSION_GONE, "gone")
|
|
3153
3212
|
}
|
|
3154
3213
|
|
|
3214
|
+
/**
|
|
3215
|
+
* (Developer mode) A voice request naming a recording (x-hub-recording): its route, status and time are recorded
|
|
3216
|
+
* as a request line in that recording when it is a developer one (recordingService.requestLine). Never a body.
|
|
3217
|
+
*/
|
|
3218
|
+
async function requestLined(userID: string, deviceID: string, request: Request, route: "transcribe" | "speak", handle: () => Promise<Response>): Promise<Response> {
|
|
3219
|
+
const recordingID = request.headers.get(RECORDING_HEADER)
|
|
3220
|
+
if (recordingID === null) return handle()
|
|
3221
|
+
const started = performance.now()
|
|
3222
|
+
let status = 500
|
|
3223
|
+
try {
|
|
3224
|
+
const res = await handle()
|
|
3225
|
+
status = res.status
|
|
3226
|
+
return res
|
|
3227
|
+
} catch (cause) {
|
|
3228
|
+
const s = (cause as { status?: unknown }).status
|
|
3229
|
+
status = cause instanceof SpeechError ? 502 : typeof s === "number" ? s : 500
|
|
3230
|
+
throw cause
|
|
3231
|
+
} finally {
|
|
3232
|
+
void recordingService.requestLine(userID, deviceID, recordingID, { route, method: "POST", status, ms: performance.now() - started }).catch(() => undefined)
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3155
3236
|
async function transcribe(request: Request, speech: Speech, userID: string, deviceID: string): Promise<Response> {
|
|
3156
3237
|
const format = audioFormat(request.headers.get("content-type"))
|
|
3157
3238
|
if (!format) return error(415, `Unsupported audio type: ${request.headers.get("content-type")}`)
|
|
@@ -3163,9 +3244,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
3163
3244
|
// (REC-01) With x-hub-recording + x-hub-segment: the segment is kept for the recording (no second upload).
|
|
3164
3245
|
const tap = await recordingService.transcribeTap(userID, deviceID, request.headers, audio, format).catch(() => undefined)
|
|
3165
3246
|
const started = performance.now()
|
|
3166
|
-
const
|
|
3247
|
+
const stats: TranscribeStats = {}
|
|
3248
|
+
const text = await (tap ? speech.transcribe(audio, format, stats) : speech.transcribe(audio, format))
|
|
3167
3249
|
if (!tap) return Response.json({ text })
|
|
3168
|
-
const result = await tap.finish(text, Math.round(performance.now() - started)).catch(() => "failed" as const)
|
|
3250
|
+
const result = await tap.finish(text, Math.round(performance.now() - started), stats).catch(() => "failed" as const)
|
|
3169
3251
|
return Response.json({ text }, { headers: { [RECORDING_AUDIO_RESULT_HEADER]: result } })
|
|
3170
3252
|
}
|
|
3171
3253
|
|