@huynle/hub 0.7.4 → 0.8.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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- 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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -7,14 +7,22 @@
|
|
|
7
7
|
// {feature: "deliver"} and nothing is stored. No fallback through steer/turn.
|
|
8
8
|
// - Modes: plain msg → delivery "queue" (after the running turn), ask/reply/decline/urgent → "steer" (also the
|
|
9
9
|
// first msg after a primer to an idle session: the two then share one model call, quirk hzmcqwpj); wake
|
|
10
|
-
// unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget
|
|
10
|
+
// unless wake:false, a subagent recipient, the hop cap (alerted once when crossed) or a spent wake budget; Sent
|
|
11
|
+
// says whether it woke (woke) and, when not, why (wakeSkipped: wake_false | subagent | hop_cap | budget).
|
|
11
12
|
// - Roles: one holder, the idle one (by real evidence) else the most recently active, else (all offline) the
|
|
12
13
|
// one seen last; the message records the holder generation. sendAll: every holder, a message each. A session
|
|
13
14
|
// never messages itself: it is left out of a role it holds, and an address naming only it is invalid.
|
|
14
|
-
// - Pending: no deliver-capable connection reports the recipient (offline) or every one failed
|
|
15
|
-
//
|
|
15
|
+
// - Pending: no deliver-capable connection reports the recipient (offline) or every one failed (including an
|
|
16
|
+
// OpenCode spoke refusing a routine wake:false msg while the recipient's turn runs, RF-05) → kept for
|
|
17
|
+
// PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). One
|
|
18
|
+
// still pending after PENDING_MS expires (the loop's tick, or a flush that finds it): delivery "expired", and its
|
|
19
|
+
// sender is told (a session by a quiet hub notice, the user by an alert, everyone through the journal). In a
|
|
16
20
|
// cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
|
|
17
|
-
// entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice.
|
|
21
|
+
// entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice. A flush stops at a
|
|
22
|
+
// session's first failed delivery (its later messages wait, in order, for the next frame: one round trip per busy
|
|
23
|
+
// or unreachable session per frame), and a waking message first flushes only its own session's pending ones.
|
|
24
|
+
// Ordering limit: a waking message to a session that is still busy is admitted (queued after the turn) before the
|
|
25
|
+
// quiet ones pending for it, which follow when the turn ends.
|
|
18
26
|
// OpenCode holds admitted messages itself across forms, interrupts and restarts (P2-T0), so there is no
|
|
19
27
|
// "retained" retry loop on the hub.
|
|
20
28
|
// - Re-wake: an open ask to an idle recipient (Registry.idle: native or hooks evidence) is woken again after
|
|
@@ -32,11 +40,16 @@
|
|
|
32
40
|
// for the user (their stored-only inbox; nothing goes to a spoke); a msg alerts the user ("user"), an ask is a
|
|
33
41
|
// Decision's carrier (./decisions pushes it). The user cannot message @me; `@me.project` is malformed.
|
|
34
42
|
// - Loop protection (./message-limits): pair/user rate limits before storing, hop per message, human turns
|
|
35
|
-
// (console, voice, typed in the harness) reset a session's chain (humanTurn).
|
|
43
|
+
// (console, voice, typed in the harness) reset a session's chain (humanTurn). Apps keep no depth (S1): a new
|
|
44
|
+
// msg/ask from an app is hop 1 plus its relay depth (what OTHER sessions sent it in the last 5 minutes, so a
|
|
45
|
+
// relay loop through an app still reaches the hop cap), an app's reply or decline counts up from the message it
|
|
46
|
+
// answers, and nothing sent to an app raises a persistent depth.
|
|
36
47
|
import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKind } from "../../protocol"
|
|
48
|
+
import type { WakeSkip } from "../../../packages/sdk/src/types.ts"
|
|
37
49
|
import { isDeliverResult } from "../../protocol"
|
|
38
50
|
import { randomBytes } from "node:crypto"
|
|
39
51
|
import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
|
|
52
|
+
import type { TeamPolicy } from "../teams/policy"
|
|
40
53
|
import type { AgentActor } from "./actor"
|
|
41
54
|
import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
|
|
42
55
|
import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
|
|
@@ -74,14 +87,27 @@ export type DeliverSend = (connID: string, req: DeliverRequest) => Promise<Deliv
|
|
|
74
87
|
* ask stayed unanswered by an idle recipient after every re-wake.
|
|
75
88
|
*/
|
|
76
89
|
export interface DeliveryAlert {
|
|
77
|
-
/** user: a session or app sent the user (@me) a message. */
|
|
78
|
-
readonly kind: "hopCap" | "unanswered" | "user"
|
|
90
|
+
/** user: a session or app sent the user (@me) a message; expired: the user's message stayed pending for 24 h. */
|
|
91
|
+
readonly kind: "hopCap" | "unanswered" | "user" | "expired"
|
|
79
92
|
/** The recipient session (user: the sending session, "" for an app). */
|
|
80
93
|
readonly sessionID: string
|
|
81
94
|
readonly messageID: string
|
|
82
95
|
readonly text: string
|
|
83
96
|
}
|
|
84
97
|
|
|
98
|
+
/** What a message to a session is, for the inbound policy. */
|
|
99
|
+
export type InboundKind = "msg" | "ask" | "reply" | "decline"
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* (CON-04) Who may message a session, beyond the existing rules: `admit` throws (forbidden) to refuse before anything
|
|
103
|
+
* is stored. `original`: the message a reply or decline answers. The hub passes the Concierge inbound rule.
|
|
104
|
+
*/
|
|
105
|
+
export interface InboundPolicy {
|
|
106
|
+
admit(userID: string, from: Party, sessionID: string, kind: InboundKind, original?: Message): Promise<void>
|
|
107
|
+
/** Who a sender may reach at all (the Concierge: only @me, and replies to asks addressed to it); throws to refuse. */
|
|
108
|
+
outbound?(userID: string, from: Party, kind: InboundKind, original?: Message): Promise<void>
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
export interface DeliveryDeps {
|
|
86
112
|
readonly state: StateStore
|
|
87
113
|
readonly counters: Counters
|
|
@@ -94,8 +120,12 @@ export interface DeliveryDeps {
|
|
|
94
120
|
/** Labels of a session sender (default: its @name from the directory). */
|
|
95
121
|
readonly describe?: (userID: string, sessionID: string) => PartyInfo
|
|
96
122
|
readonly alert?: (userID: string, alert: DeliveryAlert) => void
|
|
123
|
+
/** A pending message expired undelivered (its sender was told): the hub journals the change. Never throws through. */
|
|
124
|
+
readonly expired?: (userID: string, message: Message) => void
|
|
97
125
|
/** Limiter caps (default PAIR_LIMIT, USER_LIMIT, WAKE_BUDGET). */
|
|
98
126
|
readonly limits?: Partial<MessageLimits>
|
|
127
|
+
/** How long what an app received from a session raises its new messages (message-limits RELAY_WINDOW_MS). */
|
|
128
|
+
readonly relayWindowMs?: number
|
|
99
129
|
/** The users the re-wake loop looks at (every user of the hub; default: the users seen by this process since start). */
|
|
100
130
|
readonly users?: () => Iterable<string> | Promise<Iterable<string>>
|
|
101
131
|
/** Cluster mode: a pending flush holds a lease per (user, session), so two pods never deliver the same message. */
|
|
@@ -104,6 +134,10 @@ export interface DeliveryDeps {
|
|
|
104
134
|
readonly owner?: string
|
|
105
135
|
/** How often the re-wake loop runs (default REWAKE_TICK_MS). */
|
|
106
136
|
readonly tickMs?: number
|
|
137
|
+
/** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
|
|
138
|
+
readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
|
|
139
|
+
/** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
|
|
140
|
+
readonly inbound?: InboundPolicy
|
|
107
141
|
}
|
|
108
142
|
|
|
109
143
|
export interface SendOptions {
|
|
@@ -113,12 +147,21 @@ export interface SendOptions {
|
|
|
113
147
|
readonly urgent?: boolean
|
|
114
148
|
/** Asks only. */
|
|
115
149
|
readonly expiresInMs?: number
|
|
150
|
+
/**
|
|
151
|
+
* Hub-internal, never from a request body: the message continues a chain at least this deep (its hop is the
|
|
152
|
+
* larger of its own and this). Team intake passes the submitting session's depth + 1.
|
|
153
|
+
*/
|
|
154
|
+
readonly chainHop?: number
|
|
116
155
|
}
|
|
117
156
|
|
|
118
157
|
export interface Sent {
|
|
119
158
|
readonly message: Message
|
|
120
159
|
/** How it reached the recipient; undefined when the recipient is not a session (stored only). */
|
|
121
160
|
readonly delivery?: DeliveryState
|
|
161
|
+
/** Whether it was sent to wake its recipient (false: wake:false, a subagent, the hop cap or a spent wake budget). */
|
|
162
|
+
readonly woke?: boolean
|
|
163
|
+
/** Why it was not (only when woke is false). */
|
|
164
|
+
readonly wakeSkipped?: WakeSkip
|
|
122
165
|
}
|
|
123
166
|
|
|
124
167
|
/** A reply or decline as sent, with the message it answered. */
|
|
@@ -202,6 +245,11 @@ export class Delivery {
|
|
|
202
245
|
private readonly newID: () => string
|
|
203
246
|
/** Per user: the flush in progress (flushes run one at a time). */
|
|
204
247
|
private readonly flushing = new Map<string, Promise<void>>()
|
|
248
|
+
/**
|
|
249
|
+
* Pending flush counters since start (no message text): attempts that failed (offline spokes, a busy OpenCode
|
|
250
|
+
* session refusing a routine quiet msg) and messages skipped behind such a failure until the next frame.
|
|
251
|
+
*/
|
|
252
|
+
readonly flushStats = { failed: 0, skipped: 0 }
|
|
205
253
|
/** Per user and session: the primer in flight (one at a time); true once it was admitted. */
|
|
206
254
|
private readonly priming = new Map<string, Promise<boolean>>()
|
|
207
255
|
/** Users seen since start (sent a message, or one of their spokes reported sessions): the loop looks at these. */
|
|
@@ -217,7 +265,7 @@ export class Delivery {
|
|
|
217
265
|
this.newID = deps.newID ?? newMessageID
|
|
218
266
|
this.owner = deps.owner ?? `delivery-${process.pid}-${randomBytes(4).toString("hex")}`
|
|
219
267
|
this.messages = new Messages({ state: deps.state, now, newID: this.newID })
|
|
220
|
-
this.hops = new HopTracker({ state: deps.state, now })
|
|
268
|
+
this.hops = new HopTracker({ state: deps.state, now, ...(deps.relayWindowMs === undefined ? {} : { relayWindowMs: deps.relayWindowMs }) })
|
|
221
269
|
this.limiter = new MessageLimiter({ counters: deps.counters, now, ...(deps.limits ? { limits: deps.limits } : {}) })
|
|
222
270
|
// An explicit (re)name primes again, so the session learns its new address.
|
|
223
271
|
deps.directory.onNamed?.((userID, sessionID) => void this.prime(userID, sessionID, { again: true }))
|
|
@@ -250,7 +298,7 @@ export class Delivery {
|
|
|
250
298
|
async send(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent> {
|
|
251
299
|
if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
|
|
252
300
|
const from = this.party(userID, actor)
|
|
253
|
-
const resolved = this.
|
|
301
|
+
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
254
302
|
return this.sendTo(userID, from, addr, resolved, this.pick(resolved), kind, text, options)
|
|
255
303
|
}
|
|
256
304
|
|
|
@@ -258,7 +306,7 @@ export class Delivery {
|
|
|
258
306
|
async sendAll(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent[]> {
|
|
259
307
|
if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
|
|
260
308
|
const from = this.party(userID, actor)
|
|
261
|
-
const resolved = this.
|
|
309
|
+
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
262
310
|
const sent: Sent[] = []
|
|
263
311
|
for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
|
|
264
312
|
return sent
|
|
@@ -266,12 +314,12 @@ export class Delivery {
|
|
|
266
314
|
|
|
267
315
|
/** Replies to a message, from its recipient to its real sender (never via a role), and delivers the reply. */
|
|
268
316
|
reply(userID: string, actor: AgentActor, replyTo: string, text: string, options: SendOptions = {}): Promise<Replied> {
|
|
269
|
-
return this.answer(userID, actor, replyTo, options, (input) => this.messages.reply(userID, { ...input, text }))
|
|
317
|
+
return this.answer(userID, actor, replyTo, options, "reply", (input) => this.messages.reply(userID, { ...input, text }))
|
|
270
318
|
}
|
|
271
319
|
|
|
272
320
|
/** Declines an ask, with a reason, and delivers the decline to the asker. */
|
|
273
321
|
decline(userID: string, actor: AgentActor, replyTo: string, why: string, options: SendOptions = {}): Promise<Replied> {
|
|
274
|
-
return this.answer(userID, actor, replyTo, options, (input) => this.messages.decline(userID, { ...input, why }))
|
|
322
|
+
return this.answer(userID, actor, replyTo, options, "decline", (input) => this.messages.decline(userID, { ...input, why }))
|
|
275
323
|
}
|
|
276
324
|
|
|
277
325
|
/** A human turn in the session (console prompt or utterance, or typed in the harness): its hop chain starts over. */
|
|
@@ -280,9 +328,10 @@ export class Delivery {
|
|
|
280
328
|
}
|
|
281
329
|
|
|
282
330
|
/** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
|
|
283
|
-
|
|
331
|
+
/** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
|
|
332
|
+
flush(userID: string, sessionID?: string): Promise<void> {
|
|
284
333
|
const previous = this.flushing.get(userID) ?? Promise.resolve()
|
|
285
|
-
const run = previous.then(() => this.flushNow(userID))
|
|
334
|
+
const run = previous.then(() => this.flushNow(userID, sessionID))
|
|
286
335
|
const tracked = run.finally(() => {
|
|
287
336
|
if (this.flushing.get(userID) === tracked) this.flushing.delete(userID)
|
|
288
337
|
})
|
|
@@ -290,6 +339,21 @@ export class Delivery {
|
|
|
290
339
|
return run
|
|
291
340
|
}
|
|
292
341
|
|
|
342
|
+
/**
|
|
343
|
+
* (CON-10) Drops the messages still pending for a session that ended for good (a retired Concierge session): they
|
|
344
|
+
* are never delivered later, and their delivery reads "dropped" (never waiting). Answers how many were dropped.
|
|
345
|
+
*/
|
|
346
|
+
async dropPending(userID: string, sessionID: string): Promise<number> {
|
|
347
|
+
let dropped = 0
|
|
348
|
+
for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
|
|
349
|
+
if (value.sessionID !== sessionID) continue
|
|
350
|
+
if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) continue
|
|
351
|
+
await this.messages.setDelivery(userID, value.messageID, "dropped")
|
|
352
|
+
dropped++
|
|
353
|
+
}
|
|
354
|
+
return dropped
|
|
355
|
+
}
|
|
356
|
+
|
|
293
357
|
/** Registry.onSessions: a spoke reported its sessions (connected, reconnected, or its periodic snapshot). */
|
|
294
358
|
async onSessions(connID: string): Promise<void> {
|
|
295
359
|
const userID = this.deps.registry.connTenant(connID)
|
|
@@ -321,6 +385,11 @@ export class Delivery {
|
|
|
321
385
|
return
|
|
322
386
|
}
|
|
323
387
|
for (const userID of users) {
|
|
388
|
+
try {
|
|
389
|
+
await this.expirePending(userID)
|
|
390
|
+
} catch (cause) {
|
|
391
|
+
console.error(`[hub] pending expiry failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
392
|
+
}
|
|
324
393
|
try {
|
|
325
394
|
await this.rewake(userID)
|
|
326
395
|
} catch (cause) {
|
|
@@ -358,7 +427,7 @@ export class Delivery {
|
|
|
358
427
|
const from = this.party(userID, actor)
|
|
359
428
|
const recipient: Party = { kind: "user" }
|
|
360
429
|
await this.limiter.admit(userID, from, recipient)
|
|
361
|
-
const hop = await this.hops.next(userID, from)
|
|
430
|
+
const hop = await this.hops.next(userID, from, { to: recipient })
|
|
362
431
|
const message = await this.messages.send(userID, {
|
|
363
432
|
from,
|
|
364
433
|
to: USER_ADDRESS,
|
|
@@ -378,10 +447,12 @@ export class Delivery {
|
|
|
378
447
|
private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
|
|
379
448
|
const sessionID = entry.sessionID
|
|
380
449
|
const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
|
|
450
|
+
await this.deps.inbound?.outbound?.(userID, from, kind)
|
|
451
|
+
await this.deps.inbound?.admit(userID, from, sessionID, kind)
|
|
381
452
|
this.gate(userID, sessionID)
|
|
382
453
|
await this.limiter.admit(userID, from, recipient)
|
|
383
|
-
const hop = await this.hops.next(userID, from)
|
|
384
|
-
const generation = resolved.address.kind === "role" ? roleGeneration(entry, resolved.address) : undefined
|
|
454
|
+
const hop = Math.max(await this.hops.next(userID, from, { to: recipient }), options.chainHop ?? 0)
|
|
455
|
+
const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
|
|
385
456
|
const message = await this.messages.send(userID, {
|
|
386
457
|
from,
|
|
387
458
|
to: addr,
|
|
@@ -392,15 +463,15 @@ export class Delivery {
|
|
|
392
463
|
...(generation === undefined ? {} : { generation }),
|
|
393
464
|
...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
|
|
394
465
|
})
|
|
395
|
-
await this.hops.received(userID, recipient, hop)
|
|
466
|
+
await this.hops.received(userID, recipient, hop, from)
|
|
396
467
|
this.users.add(userID)
|
|
397
|
-
const
|
|
398
|
-
const
|
|
399
|
-
if (kind === "ask" &&
|
|
468
|
+
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : entry.parentID !== undefined ? "subagent" : undefined
|
|
469
|
+
const skipped = await this.wakeFor(userID, message, unwanted)
|
|
470
|
+
if (kind === "ask" && unwanted === undefined && !overHopCap(hop)) {
|
|
400
471
|
const rewake: Rewake = { messageID: message.id, sessionID, reminders: 0, nextAt: message.at + backoff(0) }
|
|
401
472
|
await this.deps.state.put(userID, REWAKE, message.id, rewake)
|
|
402
473
|
}
|
|
403
|
-
return this.deliver(userID, message, sessionID,
|
|
474
|
+
return this.deliver(userID, message, sessionID, skipped, options.urgent === true)
|
|
404
475
|
}
|
|
405
476
|
|
|
406
477
|
private schedule(): void {
|
|
@@ -463,6 +534,13 @@ export class Delivery {
|
|
|
463
534
|
return (await this.request(userID, sessionID, req)) !== undefined
|
|
464
535
|
}
|
|
465
536
|
|
|
537
|
+
/** The address's sessions (team-aware, HWA-34) without the sender, narrowed to those team policy permits. */
|
|
538
|
+
private async resolveFor(userID: string, actor: AgentActor, from: Party, addr: string, kind: "msg" | "ask"): Promise<Resolved> {
|
|
539
|
+
const team = await this.deps.teams?.prepare(userID, from, addr)
|
|
540
|
+
const resolved = this.others(actor, team?.resolved ?? this.deps.directory.resolve(userID, addr))
|
|
541
|
+
return team ? { ...resolved, sessions: await team.admit(resolved.sessions, kind, this.limiter) } : resolved
|
|
542
|
+
}
|
|
543
|
+
|
|
466
544
|
/** The resolved sessions without the sending session itself; invalid when only it is left (no self-messages). */
|
|
467
545
|
private others(actor: AgentActor, resolved: Resolved): Resolved {
|
|
468
546
|
if (actor.kind !== "session") return resolved
|
|
@@ -487,40 +565,51 @@ export class Delivery {
|
|
|
487
565
|
actor: AgentActor,
|
|
488
566
|
replyTo: string,
|
|
489
567
|
options: SendOptions,
|
|
568
|
+
kind: "reply" | "decline",
|
|
490
569
|
store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
|
|
491
570
|
): Promise<Replied> {
|
|
492
571
|
const from = this.party(userID, actor)
|
|
493
572
|
const original = await this.messages.get(userID, replyTo)
|
|
494
573
|
const to = original && partyKey(original.recipient) === partyKey(from) ? original.from : undefined
|
|
495
|
-
if (
|
|
574
|
+
if (original) await this.deps.inbound?.outbound?.(userID, from, kind, original)
|
|
575
|
+
if (to?.kind === "session") {
|
|
576
|
+
await this.deps.inbound?.admit(userID, from, to.sessionID, kind, original)
|
|
577
|
+
this.gate(userID, to.sessionID)
|
|
578
|
+
}
|
|
579
|
+
if (to && original) await this.deps.teams?.answer(userID, from, original, to, kind, this.limiter)
|
|
496
580
|
if (to) await this.limiter.admit(userID, from, to)
|
|
497
|
-
const hop = await this.hops.next(userID, from)
|
|
581
|
+
const hop = await this.hops.next(userID, from, original ? { answering: original, ...(to ? { to } : {}) } : {})
|
|
498
582
|
const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
|
|
499
583
|
const answered = await store({ from, replyTo, hop, ...(generation === undefined ? {} : { generation }) })
|
|
500
584
|
const { message } = answered
|
|
501
|
-
await this.hops.received(userID, message.recipient, hop)
|
|
585
|
+
await this.hops.received(userID, message.recipient, hop, from)
|
|
502
586
|
this.users.add(userID)
|
|
503
587
|
if (message.recipient.kind !== "session") return answered
|
|
504
588
|
const sessionID = message.recipient.sessionID
|
|
505
|
-
const
|
|
506
|
-
|
|
589
|
+
const unwanted: WakeSkip | undefined = options.wake === false ? "wake_false" : this.isSubagent(userID, sessionID) ? "subagent" : undefined
|
|
590
|
+
const skipped = await this.wakeFor(userID, message, unwanted)
|
|
591
|
+
return { ...(await this.deliver(userID, message, sessionID, skipped, true)), original: answered.original }
|
|
507
592
|
}
|
|
508
593
|
|
|
509
|
-
/**
|
|
510
|
-
|
|
511
|
-
|
|
594
|
+
/**
|
|
595
|
+
* Why the message may not start a turn (undefined: it may): `unwanted` (wake:false or a subagent recipient), over
|
|
596
|
+
* the hop cap (alerting once at the cap), or no wake budget left.
|
|
597
|
+
*/
|
|
598
|
+
private async wakeFor(userID: string, message: Message, unwanted: WakeSkip | undefined): Promise<WakeSkip | undefined> {
|
|
599
|
+
if (unwanted) return unwanted
|
|
600
|
+
if (message.recipient.kind !== "session") return "wake_false"
|
|
512
601
|
if (overHopCap(message.hop)) {
|
|
513
602
|
if (message.hop === HOP_CAP + 1) {
|
|
514
603
|
this.deps.alert?.(userID, {
|
|
515
604
|
kind: "hopCap",
|
|
516
605
|
sessionID: message.recipient.sessionID,
|
|
517
606
|
messageID: message.id,
|
|
518
|
-
text: `
|
|
607
|
+
text: `Agent messages went ${HOP_CAP} hops without you (latest: ${partyLabel(message.from)} → ${partyLabel(message.recipient)}); further messages in this chain no longer wake anyone`,
|
|
519
608
|
})
|
|
520
609
|
}
|
|
521
|
-
return
|
|
610
|
+
return "hop_cap"
|
|
522
611
|
}
|
|
523
|
-
return this.limiter.wake(userID, message.recipient)
|
|
612
|
+
return (await this.limiter.wake(userID, message.recipient)) ? undefined : "budget"
|
|
524
613
|
}
|
|
525
614
|
|
|
526
615
|
/** The replier's generation of the role the original reached (undefined: not a role, or not held now). */
|
|
@@ -558,13 +647,18 @@ export class Delivery {
|
|
|
558
647
|
}
|
|
559
648
|
|
|
560
649
|
/** Delivers now, or queues the message as pending when no spoke takes it. */
|
|
561
|
-
private async deliver(userID: string, message: Message, sessionID: string,
|
|
650
|
+
private async deliver(userID: string, message: Message, sessionID: string, skipped: WakeSkip | undefined, urgent: boolean): Promise<Sent> {
|
|
651
|
+
const wake = skipped === undefined
|
|
652
|
+
const woke = wake ? { woke: true } : { woke: false, wakeSkipped: skipped }
|
|
653
|
+
// Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
|
|
654
|
+
// one starts then carries them, instead of them arriving inside it.
|
|
655
|
+
if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
|
|
562
656
|
const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
|
|
563
|
-
if (delivered) return delivered
|
|
657
|
+
if (delivered) return { ...delivered, ...woke }
|
|
564
658
|
const at = this.now()
|
|
565
659
|
const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
|
|
566
660
|
await this.deps.state.put(userID, PENDING, message.id, pending)
|
|
567
|
-
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending" }
|
|
661
|
+
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", ...woke }
|
|
568
662
|
}
|
|
569
663
|
|
|
570
664
|
/** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
|
|
@@ -593,13 +687,25 @@ export class Delivery {
|
|
|
593
687
|
return undefined
|
|
594
688
|
}
|
|
595
689
|
|
|
596
|
-
private async flushNow(userID: string): Promise<void> {
|
|
597
|
-
const entries = (await this.deps.state.list<Pending>(userID, PENDING))
|
|
690
|
+
private async flushNow(userID: string, only?: string): Promise<void> {
|
|
691
|
+
const entries = (await this.deps.state.list<Pending>(userID, PENDING))
|
|
692
|
+
.filter((e) => only === undefined || e.value.sessionID === only)
|
|
693
|
+
.sort((a, b) => a.value.at - b.value.at || (a.key < b.key ? -1 : 1))
|
|
598
694
|
/** Cluster mode: the sessions whose lease this flush holds (true) or another process holds (false). */
|
|
599
695
|
const leases = new Map<string, boolean>()
|
|
696
|
+
/** Sessions whose delivery failed in this flush: their later messages wait for the next frame (in order). */
|
|
697
|
+
const failed = new Set<string>()
|
|
600
698
|
try {
|
|
601
699
|
for (const { key, value, version } of entries) {
|
|
602
|
-
|
|
700
|
+
if (failed.has(value.sessionID)) {
|
|
701
|
+
this.flushStats.skipped++
|
|
702
|
+
continue
|
|
703
|
+
}
|
|
704
|
+
if (value.expiresAt <= this.now()) {
|
|
705
|
+
await this.expireEntry(userID, key, value, version)
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
708
|
+
const message = await this.messages.get(userID, value.messageID)
|
|
603
709
|
// Gone, or an ask that closed (expired) while it waited: nothing to deliver.
|
|
604
710
|
if (!message || (message.kind === "ask" && message.state !== "open")) {
|
|
605
711
|
await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })
|
|
@@ -610,6 +716,10 @@ export class Delivery {
|
|
|
610
716
|
// Another process may have delivered it before this one took the lease.
|
|
611
717
|
if (this.deps.locks && !(await this.deps.state.get(userID, PENDING, key))) continue
|
|
612
718
|
if (await this.attempt(userID, message, value.sessionID, value.wake, value.urgent)) await this.deps.state.delete(userID, PENDING, key)
|
|
719
|
+
else {
|
|
720
|
+
failed.add(value.sessionID)
|
|
721
|
+
this.flushStats.failed++
|
|
722
|
+
}
|
|
613
723
|
}
|
|
614
724
|
} finally {
|
|
615
725
|
const { locks } = this.deps
|
|
@@ -619,6 +729,46 @@ export class Delivery {
|
|
|
619
729
|
}
|
|
620
730
|
}
|
|
621
731
|
|
|
732
|
+
/** The loop's sweep: the user's messages pending past PENDING_MS expire (their senders are told). */
|
|
733
|
+
private async expirePending(userID: string): Promise<void> {
|
|
734
|
+
const t = this.now()
|
|
735
|
+
for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
|
|
736
|
+
if (value.expiresAt <= t) await this.expireEntry(userID, key, value, version)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* A pending message that waited PENDING_MS: removed (once: version-checked), marked "expired", and its sender
|
|
742
|
+
* told: a session by a quiet notice (wake:false, never the message text), the user by an alert; the change is
|
|
743
|
+
* journaled (deps.expired) for every sender, an app included. An ask that already closed is dropped silently.
|
|
744
|
+
*/
|
|
745
|
+
private async expireEntry(userID: string, key: string, value: Pending, version: number): Promise<void> {
|
|
746
|
+
if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) return
|
|
747
|
+
const current = await this.messages.get(userID, value.messageID)
|
|
748
|
+
if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
|
|
749
|
+
const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
|
|
750
|
+
const what = `${message.kind} ${message.id} to ${message.to}`
|
|
751
|
+
try {
|
|
752
|
+
this.deps.expired?.(userID, message)
|
|
753
|
+
} catch (cause) {
|
|
754
|
+
console.error(`[hub] pending expiry journal failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
755
|
+
}
|
|
756
|
+
const { from } = message
|
|
757
|
+
if (from.kind === "user") {
|
|
758
|
+
this.deps.alert?.(userID, { kind: "expired", sessionID: value.sessionID, messageID: message.id, text: `Your ${message.kind} to ${message.to} was not delivered: its session stayed offline or busy for 24 h` })
|
|
759
|
+
} else if (from.kind === "session") {
|
|
760
|
+
const id = this.newID()
|
|
761
|
+
await this.request(userID, from.sessionID, {
|
|
762
|
+
op: "deliver",
|
|
763
|
+
sessionID: from.sessionID,
|
|
764
|
+
messageID: id,
|
|
765
|
+
text: `[hub] notice · ${what} was not delivered: its session stayed offline or busy for 24 h · ${id}`,
|
|
766
|
+
wake: false,
|
|
767
|
+
meta: { kind: "msg", from: "hub", to: wireAddress(from), delivery: "queue" },
|
|
768
|
+
})
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
622
772
|
/** Takes the (user, session) flush lease once per flush (always true without locks); false: another process flushes it. */
|
|
623
773
|
private async lease(userID: string, sessionID: string, leases: Map<string, boolean>): Promise<boolean> {
|
|
624
774
|
const { locks } = this.deps
|
|
@@ -635,7 +785,11 @@ export class Delivery {
|
|
|
635
785
|
return held
|
|
636
786
|
}
|
|
637
787
|
|
|
638
|
-
/**
|
|
788
|
+
/**
|
|
789
|
+
* `steer`: urgent, or the first msg after a primer to an idle session (see attempt). A wake:false msg keeps
|
|
790
|
+
* "queue" unless urgent: that marks it routine, and the OpenCode spoke (RF-05) admits it steered without resume,
|
|
791
|
+
* refusing it while a turn runs so it stays pending here until the turn ends (never a model call of its own).
|
|
792
|
+
*/
|
|
639
793
|
private deliverRequest(message: Message, sessionID: string, wake: boolean, steer: boolean): DeliverRequest {
|
|
640
794
|
const delivery: DeliveryMode = message.kind === "msg" && !steer ? "queue" : "steer"
|
|
641
795
|
return {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// and every catalog change syncs the affected users (records, lastSeen, derived names).
|
|
4
4
|
//
|
|
5
5
|
// Routes (user actors always; session tokens may read, and write for their OWN session only):
|
|
6
|
-
// GET /api/agent/who?project=&role=
|
|
6
|
+
// GET /api/agent/who?project=&role= (entries carry a stored `intent`, CON-02)
|
|
7
7
|
// POST /api/agent/name {name, session?}
|
|
8
8
|
// POST /api/agent/roles/claim {role, exclusive?, project?, session?}
|
|
9
9
|
// POST /api/agent/roles/release {role, project?, session?}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// order (kill switch → grant → hourly cap → kill switch), with every outcome in the agent audit (verb roles or
|
|
17
17
|
// roles.release). Reserved roles stay the user's to assign. Each change is in the admin audit and the event
|
|
18
18
|
// journal once (session.named, role.claimed, role.released, session.archived); a no-op is neither.
|
|
19
|
+
import { boundedJson } from "./body"
|
|
19
20
|
import type { ActingSwitch } from "./acting-switch"
|
|
20
21
|
import { auditOf } from "./acting"
|
|
21
22
|
import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
|
|
@@ -31,6 +32,10 @@ import { ArchiveInactive, parseInactiveFor } from "./archive-inactive"
|
|
|
31
32
|
import { slugify } from "./names"
|
|
32
33
|
import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
|
|
33
34
|
import type { CursorKeys } from "./event-cursor"
|
|
35
|
+
import type { TeamDiscovery } from "../teams/discovery"
|
|
36
|
+
import type { SessionIntent } from "../../protocol"
|
|
37
|
+
import { intentField } from "./intents"
|
|
38
|
+
import { conciergeSelf } from "./read-routes"
|
|
34
39
|
|
|
35
40
|
/** What the directory reads from the Registry. */
|
|
36
41
|
export interface DirectoryRegistry {
|
|
@@ -58,10 +63,11 @@ export function registrySource(registry: Pick<DirectoryRegistry, "catalog" | "se
|
|
|
58
63
|
if (out.has(s.id) || registry.sessionTenant(s.id) !== userID) continue
|
|
59
64
|
const project = projectOf(s)
|
|
60
65
|
const scope = project ? { project } : {}
|
|
61
|
-
|
|
66
|
+
const harness = process.harness ? { harness: process.harness } : {}
|
|
67
|
+
out.set(s.id, { sessionID: s.id, title: s.title, machineID: s.machineID, ...(s.parentID ? { parentID: s.parentID } : {}), ...scope, state: s.state, ...harness })
|
|
62
68
|
for (const child of s.status) {
|
|
63
69
|
if (child.id === s.id || out.has(child.id)) continue
|
|
64
|
-
out.set(child.id, { sessionID: child.id, title: child.title, machineID: s.machineID, parentID: child.parentID ?? s.id, ...scope, state: child.state })
|
|
70
|
+
out.set(child.id, { sessionID: child.id, title: child.title, machineID: s.machineID, parentID: child.parentID ?? s.id, ...scope, state: child.state, ...harness })
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
}
|
|
@@ -104,7 +110,7 @@ export function hubDirectory(options: { registry: DirectoryRegistry; store: Pick
|
|
|
104
110
|
type Body = Record<string, unknown>
|
|
105
111
|
|
|
106
112
|
async function readBody(request: Request): Promise<Body> {
|
|
107
|
-
const body = await request
|
|
113
|
+
const body = await boundedJson(request)
|
|
108
114
|
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Expected a JSON object")
|
|
109
115
|
return body as Body
|
|
110
116
|
}
|
|
@@ -152,6 +158,12 @@ export interface DirectoryRoutesDeps {
|
|
|
152
158
|
readonly acting?: Pick<ActingSwitch, "ensureActing">
|
|
153
159
|
readonly audit?: Pick<AgentAudit, "record">
|
|
154
160
|
readonly previewKeys?: CursorKeys
|
|
161
|
+
/** Team-aware who (HWA-40); without it, the owner-wide directory. */
|
|
162
|
+
readonly discovery?: TeamDiscovery
|
|
163
|
+
/** (CON-02) The stored intents of these sessions: who entries carry `intent` when present. */
|
|
164
|
+
readonly intents?: (userID: string, sessionIDs: readonly string[]) => Promise<ReadonlyMap<string, SessionIntent>>
|
|
165
|
+
/** (CON-04) The user's Concierge sessions: who marks them `concierge: true`; archive-inactive never archives them. */
|
|
166
|
+
readonly concierge?: (userID: string) => Promise<readonly string[]>
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
const MANAGE = { session: true, scope: "sessions:manage" } as const
|
|
@@ -202,7 +214,10 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
|
|
|
202
214
|
const inactive = new ArchiveInactive({ secret: deps.previewKeys!.active().secret })
|
|
203
215
|
const body = await readBody(context.request)
|
|
204
216
|
const prefs = store.viewPrefs(context.user.id)
|
|
205
|
-
const
|
|
217
|
+
const concierge = new Set(deps.concierge ? await deps.concierge(context.user.id) : [])
|
|
218
|
+
const entries = (store.agentData(context.user.id).sessions ?? [])
|
|
219
|
+
.filter((s) => !concierge.has(s.sessionID))
|
|
220
|
+
.map((s) => ({ sessionID: s.sessionID, online: !!deps.registry?.session(s.sessionID), ...(s.lastSeen !== undefined ? { lastSeen: s.lastSeen } : {}), roles: [] }))
|
|
206
221
|
if (body.preview === true) {
|
|
207
222
|
const limit = body.limit
|
|
208
223
|
if (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 200) throw new AgentError("invalid", "limit must be a whole number from 1 to 200")
|
|
@@ -261,10 +276,20 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
|
|
|
261
276
|
path: `${AGENT_PREFIX}/who`,
|
|
262
277
|
// Reading the directory: sessions, and app tokens holding sessions:read; writes stay users and sessions.
|
|
263
278
|
allow: { session: true, scope: "sessions:read" },
|
|
264
|
-
handle: ({ url, user }) => {
|
|
279
|
+
handle: async ({ url, user, actor }) => {
|
|
265
280
|
const project = url.searchParams.get("project") || undefined
|
|
266
281
|
const role = url.searchParams.get("role") || undefined
|
|
267
|
-
|
|
282
|
+
const team = url.searchParams.get("team") || undefined
|
|
283
|
+
const answer = deps.discovery
|
|
284
|
+
? deps.discovery.who(await deps.discovery.census(user.id, actor), { ...(project ? { project } : {}), ...(role ? { role } : {}), ...(team ? { team } : {}) })
|
|
285
|
+
: { sessions: directory.who(user.id, { ...(project ? { project } : {}), ...(role ? { role } : {}) }) }
|
|
286
|
+
if (!answer.sessions.length || (!deps.intents && !deps.concierge)) return ok(answer)
|
|
287
|
+
// (CON-09a) The Concierge's own who leaves out its sessions (current and previous).
|
|
288
|
+
const self = await conciergeSelf(deps.concierge, user.id, actor)
|
|
289
|
+
const shown = self.size ? answer.sessions.filter((entry) => !self.has(entry.sessionID)) : answer.sessions
|
|
290
|
+
const intents = deps.intents ? await deps.intents(user.id, shown.map((entry) => entry.sessionID)) : new Map<string, SessionIntent>()
|
|
291
|
+
const concierge = new Set(deps.concierge ? await deps.concierge(user.id) : [])
|
|
292
|
+
return ok({ ...answer, sessions: shown.map((entry) => ({ ...entry, ...intentField(intents.get(entry.sessionID)), ...(concierge.has(entry.sessionID) ? { concierge: true } : {}) })) })
|
|
268
293
|
},
|
|
269
294
|
},
|
|
270
295
|
{
|
|
@@ -291,6 +316,11 @@ export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
|
|
|
291
316
|
const exclusive = flag(body, "exclusive")
|
|
292
317
|
const project = text(body, "project")
|
|
293
318
|
const sessionID = target(directory, context, text(body, "session"))
|
|
319
|
+
// (CON-04) A Concierge session holds @@concierge only (claimed by the hub): no other role, for anyone, so it
|
|
320
|
+
// never inherits another role's grants.
|
|
321
|
+
if (deps.concierge && (await deps.concierge(context.user.id)).includes(sessionID)) {
|
|
322
|
+
throw new AgentError("forbidden", "The Concierge holds no role but @@concierge", { extra: { policy: "concierge" } })
|
|
323
|
+
}
|
|
294
324
|
const claim = await roleWrite(context, "roles", sessionID, async () => {
|
|
295
325
|
const before = directory.resolveOne(context.user.id, sessionID).roles
|
|
296
326
|
const claim = await directory.claimRole(context.user.id, sessionID, role, {
|