@huynle/hub 0.9.2 → 0.10.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 +296 -0
- package/Dockerfile +5 -1
- package/README.md +152 -77
- package/package.json +7 -4
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +532 -261
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/cli.ts +36 -1
- package/packages/cli/src/subscriptions.ts +49 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/sessions.ts +10 -6
- package/packages/codex/src/spoke.ts +2 -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/schema/app-delivery/v1.schema.json +7 -5
- package/packages/protocol/schema/app-front/v1.schema.json +410 -0
- package/packages/protocol/src/app-front.ts +293 -0
- package/packages/protocol/src/concierge.ts +10 -1
- package/packages/protocol/src/context-cards.ts +59 -0
- package/packages/protocol/src/credentials.ts +39 -2
- package/packages/protocol/src/index.ts +69 -11
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/app-front.ts +524 -0
- package/packages/sdk/src/client.ts +37 -4
- package/packages/sdk/src/envelope.ts +9 -2
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +39 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/delivery.ts +17 -1
- package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
- package/packages/voice-client/README.md +146 -0
- package/packages/voice-client/src/vad/LICENSE +21 -0
- package/src/central/agent/acting-routes.ts +17 -3
- package/src/central/agent/acting.ts +12 -3
- package/src/central/agent/actor.ts +6 -1
- package/src/central/agent/app-decisions.ts +146 -0
- package/src/central/agent/app-tokens.ts +9 -3
- package/src/central/agent/audit.ts +3 -0
- package/src/central/agent/connect-routes.ts +64 -0
- package/src/central/agent/connect.ts +868 -0
- package/src/central/agent/conversation.ts +625 -0
- package/src/central/agent/decisions.ts +71 -6
- package/src/central/agent/delivery.ts +3 -0
- package/src/central/agent/directory.ts +5 -0
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/grant-requests.ts +46 -9
- package/src/central/agent/grants.ts +13 -2
- package/src/central/agent/message-limits.ts +2 -2
- package/src/central/agent/message-routes.ts +13 -4
- package/src/central/agent/read-routes.ts +16 -2
- package/src/central/agent/routes.ts +108 -6
- package/src/central/agent/speech-routes.ts +316 -0
- package/src/central/agent/subscription-routes.ts +174 -0
- package/src/central/agent/subscriptions.ts +980 -0
- package/src/central/agent-data.ts +43 -1
- package/src/central/backend/memory-counters.ts +2 -2
- package/src/central/backend/types.ts +5 -2
- package/src/central/backend/valkey-counters.ts +5 -4
- package/src/central/client-address.ts +16 -0
- package/src/central/concierge/board-render.ts +52 -1
- package/src/central/concierge/board-routes.ts +25 -2
- package/src/central/concierge/board.ts +11 -1
- package/src/central/concierge/context.ts +29 -8
- package/src/central/concierge/grant-request.ts +216 -0
- package/src/central/concierge/prompt.ts +49 -2
- package/src/central/concierge/session-routes.ts +3 -0
- package/src/central/config.ts +64 -3
- package/src/central/context-cards/budget.ts +121 -0
- package/src/central/context-cards/changes.ts +29 -0
- package/src/central/context-cards/config.ts +145 -0
- package/src/central/context-cards/engine.ts +379 -0
- package/src/central/context-cards/inputs.ts +111 -0
- package/src/central/context-cards/pickup.ts +134 -0
- package/src/central/context-cards/registry.ts +89 -0
- package/src/central/context-cards/routes.ts +37 -0
- package/src/central/context-cards/service.ts +367 -0
- package/src/central/context-cards/store.ts +67 -0
- package/src/central/context-cards/types.ts +88 -0
- package/src/central/fetcher.ts +10 -2
- package/src/central/front-relays.ts +423 -0
- package/src/central/http.ts +516 -56
- package/src/central/human-activity.ts +453 -0
- package/src/central/llm/chat.ts +133 -0
- package/src/central/registry.ts +162 -9
- package/src/central/speech/service.ts +9 -0
- package/src/central/spoke-ws.ts +13 -0
- package/src/central/store.ts +9 -12
- package/src/config.ts +30 -13
- package/src/index.ts +45 -43
- package/src/rpc.ts +33 -4
- package/src/spoke/concierge-agent.ts +1 -1
- package/src/spoke/conversation.ts +6 -75
- package/src/spoke/deliver.ts +18 -8
- package/src/spoke/hub-rpc.ts +7 -2
- package/src/spoke/hub-tool-subscriptions.ts +82 -0
- package/src/spoke/hub-tool-title.ts +89 -0
- package/src/spoke/hub-tool.ts +56 -18
- package/src/spoke/session-context.ts +10 -2
- package/src/spoke/spoke.ts +209 -238
- package/src/spoke/summary.ts +36 -1
- package/src/spoke/taskboard.ts +5 -6
- package/src/spoke/voice-front.ts +323 -30
- package/src/spoke/who-teams.ts +51 -2
- package/src/tui-messaging.ts +24 -10
- package/src/tui.ts +6 -6
- package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
- package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
- package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
- package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
- package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
- package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
- package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
- package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
- package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
- package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
- package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
- package/web/dist/assets/index-BC0O1gfR.css +1 -0
- package/web/dist/assets/index-BS6lA6fN.js +33 -0
- package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
- package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
- package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
- package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
- package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
- package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
- package/web/dist/assets/toast-CXTwTvDw.js +9 -0
- package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
- package/web/dist/index.html +6 -6
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
- package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
- package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
- package/web/dist/assets/index-C3ci5UtY.css +0 -1
- package/web/dist/assets/index-LNMZCqgE.js +0 -36
- package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
- /package/{web/src/voice → packages/voice-client/src}/turn/LICENSE +0 -0
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
// deadline?, onTimeout?, state, at, expiresAt, escalatedAt?, answer?}, per user in the StateStore. The ask
|
|
6
6
|
// message (stored by the delivery engine, recipient the user) carries it: it closes when the Decision is
|
|
7
7
|
// answered, and the Decision expires with it.
|
|
8
|
-
// -
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// - The user answers (never a session, never the asker); `tap` Decisions only from a signed-in console (the device
|
|
9
|
+
// cookie, not a device token). (AF4, contract §6, superseding CON-06's "only the user's device answers" for this
|
|
10
|
+
// case only) an app holding `decisions:answer` may answer a CONFIRM-tier Decision without an action, not
|
|
11
|
+
// Concierge-asked, that one of its own sessions asked (answerByApp; app-decisions.ts), with a once-off option
|
|
12
|
+
// only; recorded answeredBy "app" with its party. The first answer wins across the console and apps (a
|
|
13
|
+
// version-checked write); later ones get `gone`. The answer goes back to the asker as the user's reply to the ask
|
|
14
|
+
// (an app's says which app answered).
|
|
11
15
|
// - Deadlines: at the deadline, `onTimeout` (only an option marked safe) answers it, recorded answeredBy
|
|
12
16
|
// "timeout"; without one the Decision stays open and escalates once (notified again). The loop (start/tick)
|
|
13
17
|
// looks at every user's open Decisions, so deadlines survive restarts.
|
|
@@ -22,7 +26,8 @@
|
|
|
22
26
|
// - Every open and change (answer, timeout, escalation, expiry, close) goes to `changed` (the console's 'decision' event).
|
|
23
27
|
// - (CON-06) `via`: an answer is a tap unless it says voice or text. Voice/text answers only a confirm-tier
|
|
24
28
|
// Decision, and only when the hub's `via` check admits it (the Concierge tiers: armed, presence, the user's
|
|
25
|
-
// device); otherwise forbidden "Answer this decision with a tap". Never a model:
|
|
29
|
+
// device); otherwise forbidden "Answer this decision with a tap". Never a model: the user's device answers (or,
|
|
30
|
+
// AF4, an app for its own sessions' confirm-tier Decisions; never by voice or text: an app's answer has no `via`).
|
|
26
31
|
// - (CON-06) `as: "concierge"`: set when the asker is one of the user's Concierge sessions (current or past), so
|
|
27
32
|
// the console can say "by @@concierge". The `act` action kind is a Concierge proposal (concierge/proposals.ts).
|
|
28
33
|
import type { Clock, StateStore, Timers } from "../backend/types"
|
|
@@ -34,7 +39,7 @@ import { ASK_EXPIRES_MAX_MS, ASK_EXPIRES_MIN_MS, ASK_EXPIRES_MS, type Message, n
|
|
|
34
39
|
import { updateVersioned } from "./versioned"
|
|
35
40
|
|
|
36
41
|
export type Urgency = "low" | "normal" | "high"
|
|
37
|
-
/** confirm: any of the user's signed-in devices; tap: only a signed-in console (cookie). */
|
|
42
|
+
/** confirm: any of the user's signed-in devices (AF4: or an app, for its own sessions' asks); tap: only a signed-in console (cookie). */
|
|
38
43
|
export type Tier = "confirm" | "tap"
|
|
39
44
|
/** closed: ended without an answer because what it asked about went away (answered elsewhere, ended). */
|
|
40
45
|
export type DecisionState = "open" | "answered" | "expired" | "closed"
|
|
@@ -84,12 +89,38 @@ export interface DecisionOption {
|
|
|
84
89
|
export interface DecisionAnswer {
|
|
85
90
|
readonly option?: string
|
|
86
91
|
readonly text?: string
|
|
87
|
-
|
|
92
|
+
/** (AF4) "app": an app holding decisions:answer answered a confirm-tier Decision of its own session. */
|
|
93
|
+
readonly answeredBy: "user" | "timeout" | "app"
|
|
88
94
|
readonly at: number
|
|
89
95
|
/** The device the user answered from. */
|
|
90
96
|
readonly deviceID?: string
|
|
91
97
|
/** (CON-06) voice or text; absent = tap. */
|
|
92
98
|
readonly via?: "voice" | "text"
|
|
99
|
+
/** (AF4) The app that answered (its party, `app:<tokenID>`; answeredBy "app"). */
|
|
100
|
+
readonly app?: string
|
|
101
|
+
/** (AF4) The app's label when it answered (sanitised), for the console's "answered by". */
|
|
102
|
+
readonly appLabel?: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** (AF4) The app answering a Decision: its party (`app:<tokenID>`) and label. */
|
|
106
|
+
export interface AppAnswerer {
|
|
107
|
+
readonly party: string
|
|
108
|
+
readonly label?: string
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** (AF4) The refusal of a Decision only the user answers (tap tier, an action, a Concierge ask). */
|
|
112
|
+
export const NEEDS_THE_USER = "This decision needs the user"
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* (AF4, contract §6) Why an app may not answer this Decision whatever its option, or undefined: only a confirm-tier
|
|
116
|
+
* Decision without an action (no escalated permission, grant request or Concierge proposal) that no Concierge
|
|
117
|
+
* session asked. Ownership (the asker is the app's session) and the state are the caller's checks.
|
|
118
|
+
*/
|
|
119
|
+
export function appAnswerRefusal(decision: Pick<Decision, "tier" | "action" | "as">): AgentError | undefined {
|
|
120
|
+
if (decision.tier !== "confirm" || decision.action !== undefined || decision.as === "concierge") {
|
|
121
|
+
return new AgentError("forbidden", NEEDS_THE_USER, { hint: "The user answers it in the console" })
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
93
124
|
}
|
|
94
125
|
|
|
95
126
|
/** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
|
|
@@ -179,6 +210,8 @@ export interface GrantRequestAction {
|
|
|
179
210
|
readonly requester: string
|
|
180
211
|
/** The grant body (validated when requested; validated again, as the user's, on approval). */
|
|
181
212
|
readonly grant: Readonly<Record<string, unknown>>
|
|
213
|
+
/** The Concierge's own request (concierge/grant-request.ts): created with the managed @@concierge holder. */
|
|
214
|
+
readonly managed?: true
|
|
182
215
|
}
|
|
183
216
|
|
|
184
217
|
export interface Decision {
|
|
@@ -454,6 +487,38 @@ export class Decisions {
|
|
|
454
487
|
return { decision, ...(await this.replyTo(userID, decision, text, by)) }
|
|
455
488
|
}
|
|
456
489
|
|
|
490
|
+
/**
|
|
491
|
+
* (AF4, contract §6) An app answers a Decision of its own session: `owned` (the asker is the app's session; false →
|
|
492
|
+
* not_found, like an unknown ID) first, then open (else gone), appAnswerRefusal (forbidden), the choice (invalid), a
|
|
493
|
+
* once-off option (a lasting one is forbidden), `ready` (the kill switch: paused), and the version-checked settle:
|
|
494
|
+
* the first answer wins across console and apps (a lost race is gone). The asker hears which app answered.
|
|
495
|
+
*/
|
|
496
|
+
async answerByApp(
|
|
497
|
+
userID: string,
|
|
498
|
+
id: string,
|
|
499
|
+
input: { readonly option?: string; readonly text?: string },
|
|
500
|
+
app: AppAnswerer,
|
|
501
|
+
checks: { readonly owned: (decision: Decision) => Promise<boolean>; readonly ready?: (decision: Decision) => void },
|
|
502
|
+
): Promise<{ decision: Decision; reply?: Message }> {
|
|
503
|
+
const current = await this.get(userID, id)
|
|
504
|
+
if (!current || !(await checks.owned(current))) throw notFound()
|
|
505
|
+
if (current.state !== "open") throw gone()
|
|
506
|
+
const refused = appAnswerRefusal(current)
|
|
507
|
+
if (refused) throw refused
|
|
508
|
+
const choice = this.choice(current, input)
|
|
509
|
+
if (lastingOption(current, choice.option)) throw new AgentError("forbidden", NEEDS_THE_USER, { hint: "A lasting option needs the user's tap" })
|
|
510
|
+
checks.ready?.(current)
|
|
511
|
+
const label = app.label === undefined ? undefined : sanitizeLabel(app.label, OPTION_LABEL_MAX) || undefined
|
|
512
|
+
const answer: DecisionAnswer = { ...choice, answeredBy: "app", app: app.party, ...(label ? { appLabel: label } : {}), at: this.now() }
|
|
513
|
+
const decision = await this.settle(userID, id, answer)
|
|
514
|
+
if (!decision) throw gone()
|
|
515
|
+
this.emit(userID, decision)
|
|
516
|
+
const option = decision.options.find((o) => o.id === choice.option)
|
|
517
|
+
const by = `(answered by the app “${label ?? app.party}”)`
|
|
518
|
+
const text = option ? `Decision ${id}: ${option.label} [${option.id}] ${by}${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text} ${by}`
|
|
519
|
+
return { decision, ...(await this.replyTo(userID, decision, text, HUB_USER(userID))) }
|
|
520
|
+
}
|
|
521
|
+
|
|
457
522
|
/**
|
|
458
523
|
* Closes an open Decision without an answer (what it asked about went away: answered elsewhere, ended) and tells
|
|
459
524
|
* the asker why. Undefined (no change) when it is not open, unknown or another user's.
|
|
@@ -55,6 +55,7 @@ import type { TeamPolicy } from "../teams/policy"
|
|
|
55
55
|
import type { AgentActor } from "./actor"
|
|
56
56
|
import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
|
|
57
57
|
import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
|
|
58
|
+
import { noticeOnly } from "../../../packages/sdk/src/envelope.ts"
|
|
58
59
|
import { AgentError } from "./errors"
|
|
59
60
|
import { type ExpiredReason, parseNotAfter } from "./not-after"
|
|
60
61
|
import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits, overHopCap } from "./message-limits"
|
|
@@ -593,6 +594,8 @@ export class Delivery {
|
|
|
593
594
|
const from = this.party(userID, actor)
|
|
594
595
|
const original = await this.messages.get(userID, replyTo)
|
|
595
596
|
const to = original && partyKey(original.recipient) === partyKey(from) ? original.from : undefined
|
|
597
|
+
// Subscription notices are read by no one: a reply would go nowhere (refused before anything is stored).
|
|
598
|
+
if (to && noticeOnly(to)) throw new AgentError("invalid", "Subscription notices take no reply: react in your own turn, or stop them with hub unsubscribe <sub_…>")
|
|
596
599
|
if (original) await this.deps.inbound?.outbound?.(userID, from, kind, original)
|
|
597
600
|
if (to?.kind === "session") {
|
|
598
601
|
await this.deps.inbound?.admit(userID, from, to.sessionID, kind, original)
|
|
@@ -157,6 +157,11 @@ export class Directory {
|
|
|
157
157
|
})
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/** (AF3) Whether the session was deleted (and no process reports it again). */
|
|
161
|
+
deleted(userID: string, sessionID: string): boolean {
|
|
162
|
+
return this.read(userID, (draft) => draft.byID.get(sessionID)?.deletedAt !== undefined)
|
|
163
|
+
}
|
|
164
|
+
|
|
160
165
|
// ---------- primer ----------
|
|
161
166
|
|
|
162
167
|
/** Whether the identity primer reached the session (false for an unknown one). */
|
|
@@ -35,6 +35,8 @@ export const AGENT_ERROR_STATUS = {
|
|
|
35
35
|
rate_limited: 429,
|
|
36
36
|
/** An unexpected failure (details are logged, not sent). */
|
|
37
37
|
internal: 500,
|
|
38
|
+
/** (AF2) The user's speech provider failed (`retryAfter` when it gave one). */
|
|
39
|
+
upstream_failed: 502,
|
|
38
40
|
/** The machine is offline (actions only; messages queue instead). */
|
|
39
41
|
offline: 503,
|
|
40
42
|
/** (FX-07) The hub's state did not answer in time (`retryAfter`, seconds): refused, try again. */
|
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
// count against the message limits.
|
|
19
19
|
// - Audit: the request (grant.request, needs_user, escalated) and its outcome (grant.approve ok / grant.deny
|
|
20
20
|
// refused, as the user).
|
|
21
|
+
// - (Concierge grant requests) requestManaged: the Concierge's own request (concierge/grant-request.ts builds and
|
|
22
|
+
// clamps the grant; holder @@concierge, which only a managed create may name). Keyed by the caller's key (one for
|
|
23
|
+
// all the user's Concierge sessions), with an optional denial cooldown: after the user denied the last one, no new
|
|
24
|
+
// request until it passes (rate_limited). Its action carries managed: true (the hub's, never the asker's), so the
|
|
25
|
+
// approval creates the grant with the managed holder.
|
|
21
26
|
import type { Counters, StateStore } from "../backend/types"
|
|
22
27
|
import type { AgentActor, AppActor, SessionActor, UserActor } from "./actor"
|
|
23
28
|
import type { AgentAudit } from "./audit"
|
|
@@ -25,7 +30,7 @@ import { type ActOutcome, type Decision, type Decisions, WHY_MAX } from "./decis
|
|
|
25
30
|
import type { Directory } from "./directory"
|
|
26
31
|
import { type Party, partyFromActor, partyKey, partyLabel, sanitizeLabel } from "./envelope"
|
|
27
32
|
import { AgentError } from "./errors"
|
|
28
|
-
import type { Grants } from "./grants"
|
|
33
|
+
import type { Grant, Grants } from "./grants"
|
|
29
34
|
import type { Message } from "./messages"
|
|
30
35
|
import { parseAddress } from "./names"
|
|
31
36
|
import { type GrantSpec, validateGrant } from "./policy"
|
|
@@ -57,9 +62,19 @@ export interface GrantRequestsDeps {
|
|
|
57
62
|
readonly describe?: (userID: string, actor: AgentActor) => Party
|
|
58
63
|
/** The Decision's ask was stored (the console's message feed). */
|
|
59
64
|
readonly published?: (userID: string, message: Message) => void
|
|
65
|
+
/** A grant was created on the user's approval (the Concierge's context refresh). Never throws through. */
|
|
66
|
+
readonly granted?: (userID: string, grant: Grant) => void
|
|
60
67
|
readonly now?: () => number
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
/** How requestManaged keys and limits a request. */
|
|
71
|
+
export interface ManagedRequestOptions {
|
|
72
|
+
/** The record key (one open request per key). */
|
|
73
|
+
readonly key: string
|
|
74
|
+
/** After the user denied the key's last request, no new one for this long (rate_limited). */
|
|
75
|
+
readonly cooldownMs?: number
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
const invalid = (message: string) => new AgentError("invalid", message)
|
|
64
79
|
const forbidden = (message: string) => new AgentError("forbidden", message, { hint: "Request a grant held by yourself (the default), or by a reserved role you hold" })
|
|
65
80
|
const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
@@ -84,16 +99,33 @@ export class GrantRequests {
|
|
|
84
99
|
const spec = validateGrant({ ...body.grant, holder: body.grant.holder ?? own })
|
|
85
100
|
const holder = this.holder(userID, actor, spec.holder, own)
|
|
86
101
|
await this.deps.grants.assertHolder?.(userID, holder)
|
|
87
|
-
const grant = canonical({ ...spec, holder })
|
|
88
|
-
|
|
89
102
|
const requester = this.describe(userID, actor)
|
|
90
|
-
|
|
103
|
+
return this.open(userID, actor, requester, spec, holder, why, { key: partyKey(requester) })
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* (Concierge) Opens the Decision for a grant the hub built for the session (holder as given, created managed on
|
|
108
|
+
* approval). The caller validated the session and the why. Throws invalid or rate_limited.
|
|
109
|
+
*/
|
|
110
|
+
async requestManaged(userID: string, actor: SessionActor, input: { readonly grant: Record<string, unknown>; readonly why: string }, options: ManagedRequestOptions): Promise<{ decision: Decision; message: Message }> {
|
|
111
|
+
const spec = validateGrant(input.grant)
|
|
112
|
+
return this.open(userID, actor, this.describe(userID, actor), spec, spec.holder, input.why, options, true)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private async open(userID: string, actor: SessionActor | AppActor, requester: Party, spec: GrantSpec, holder: string, why: string, options: ManagedRequestOptions, managed = false): Promise<{ decision: Decision; message: Message }> {
|
|
116
|
+
const grant = canonical({ ...spec, holder })
|
|
117
|
+
const { key } = options
|
|
91
118
|
const { state, counters, decisions } = this.deps
|
|
92
119
|
const existing = await state.get<RequestRecord>(userID, COLLECTION, key)
|
|
93
120
|
if (existing?.value.decisionID) {
|
|
94
|
-
const
|
|
95
|
-
if (
|
|
96
|
-
throw new AgentError("rate_limited", `Your grant request ${
|
|
121
|
+
const last = await decisions.get(userID, existing.value.decisionID)
|
|
122
|
+
if (last?.state === "open") {
|
|
123
|
+
throw new AgentError("rate_limited", `Your grant request ${last.id} still waits for the user`, { extra: { retryAfter: 60, decisionID: last.id } })
|
|
124
|
+
}
|
|
125
|
+
const answer = last?.state === "answered" ? last.answer : undefined
|
|
126
|
+
if (options.cooldownMs !== undefined && answer?.answeredBy === "user" && answer.option !== "approve") {
|
|
127
|
+
const left = answer.at + options.cooldownMs - this.now()
|
|
128
|
+
if (left > 0) throw new AgentError("rate_limited", "The user denied your last grant request: do not ask again yet", { extra: { retryAfter: Math.ceil(left / 1000), decisionID: last!.id } })
|
|
97
129
|
}
|
|
98
130
|
} else if (existing && this.now() - existing.value.at < RESERVATION_MS) {
|
|
99
131
|
throw new AgentError("rate_limited", "Your grant request is being opened", { extra: { retryAfter: 1 } })
|
|
@@ -119,7 +151,7 @@ export class GrantRequests {
|
|
|
119
151
|
urgency: "high",
|
|
120
152
|
tier: "tap",
|
|
121
153
|
},
|
|
122
|
-
{ action: { kind: "grant", requester: key, grant } },
|
|
154
|
+
{ action: { kind: "grant", requester: key, grant, ...(managed ? { managed: true as const } : {}) } },
|
|
123
155
|
)
|
|
124
156
|
} catch (cause) {
|
|
125
157
|
await state.delete(userID, COLLECTION, key, { ifVersion: reserved.version })
|
|
@@ -143,13 +175,18 @@ export class GrantRequests {
|
|
|
143
175
|
}
|
|
144
176
|
let created
|
|
145
177
|
try {
|
|
146
|
-
created = await this.deps.grants.create(userID, action.grant)
|
|
178
|
+
created = await this.deps.grants.create(userID, action.grant, action.managed ? { managed: true } : {})
|
|
147
179
|
} catch (cause) {
|
|
148
180
|
if (!(cause instanceof AgentError) || cause.code !== "not_found") throw cause
|
|
149
181
|
await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.approve", target: about, decision: "allow", reason, outcome: "gone" })
|
|
150
182
|
return { outcome: "gone", reason: "The requested holder is gone" }
|
|
151
183
|
}
|
|
152
184
|
await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.approve", target: about, grant: created.id, decision: "allow", reason, outcome: "ok" })
|
|
185
|
+
try {
|
|
186
|
+
this.deps.granted?.(userID, created)
|
|
187
|
+
} catch {
|
|
188
|
+
// A listener's failure never fails the approval.
|
|
189
|
+
}
|
|
153
190
|
return { outcome: "done", note: `Grant ${created.id} created (holder ${created.holder}, until ${new Date(created.expiresAt).toISOString()})` }
|
|
154
191
|
}
|
|
155
192
|
|
|
@@ -66,6 +66,10 @@ export interface GrantsDeps {
|
|
|
66
66
|
readonly now?: () => number
|
|
67
67
|
/** (CON-04) The user's Concierge sessions: only the Concierge service (managed) grants to them or to @@concierge. */
|
|
68
68
|
readonly concierge?: (owner: string) => Promise<readonly string[]>
|
|
69
|
+
/** (AF1) Whether the owner has a connected app of this ID (`app:<connection>` holders), besides hand-made tokens. */
|
|
70
|
+
readonly connections?: (owner: string, id: string) => boolean
|
|
71
|
+
/** (AF3) A grant of the owner was created, changed or revoked (open app streams re-check their ownership now). */
|
|
72
|
+
readonly changed?: (owner: string) => void
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
const notFound = () => new AgentError("not_found", "No such grant")
|
|
@@ -114,6 +118,7 @@ export class Grants {
|
|
|
114
118
|
await this.deps.state.put(owner, COLLECTION, grant.id, grant)
|
|
115
119
|
await this.prune(owner, all, at)
|
|
116
120
|
await this.deps.admin?.audit({ actor: owner, action: "grant.create", target: grant.id })
|
|
121
|
+
this.deps.changed?.(owner)
|
|
117
122
|
return grant
|
|
118
123
|
}
|
|
119
124
|
|
|
@@ -165,6 +170,7 @@ export class Grants {
|
|
|
165
170
|
})
|
|
166
171
|
if (missing || !next) throw notFound()
|
|
167
172
|
await this.deps.admin?.audit({ actor: owner, action, target: id })
|
|
173
|
+
this.deps.changed?.(owner)
|
|
168
174
|
return next
|
|
169
175
|
}
|
|
170
176
|
|
|
@@ -203,7 +209,7 @@ export class Grants {
|
|
|
203
209
|
private holds(grant: Grant, actor: SessionActor | AppActor): boolean {
|
|
204
210
|
if (grant.holder.startsWith("app:")) {
|
|
205
211
|
if (actor.kind !== "app" || grant.holder !== `app:${actor.tokenID}`) return false
|
|
206
|
-
return
|
|
212
|
+
return this.appExists(grant.owner, actor.tokenID)
|
|
207
213
|
}
|
|
208
214
|
if (actor.kind !== "session") return false
|
|
209
215
|
if (grant.holderSessionID !== undefined) return grant.holderSessionID === actor.sessionID
|
|
@@ -215,6 +221,11 @@ export class Grants {
|
|
|
215
221
|
}
|
|
216
222
|
}
|
|
217
223
|
|
|
224
|
+
/** An app credential of the owner: a hand-made token or (AF1) a connected app. */
|
|
225
|
+
private appExists(owner: string, id: string): boolean {
|
|
226
|
+
return (this.deps.store.agentData(owner).appTokens ?? []).some((t) => t.id === id) || this.deps.connections?.(owner, id) === true
|
|
227
|
+
}
|
|
228
|
+
|
|
218
229
|
private owner(owner: string): void {
|
|
219
230
|
const user = this.deps.store.user(owner)
|
|
220
231
|
if (!user || user.disabled) throw new AgentError("not_found", "No such user")
|
|
@@ -224,7 +235,7 @@ export class Grants {
|
|
|
224
235
|
private pin(owner: string, holder: string): string | undefined {
|
|
225
236
|
if (holder.startsWith("app:")) {
|
|
226
237
|
const id = holder.slice(4)
|
|
227
|
-
if (!
|
|
238
|
+
if (!this.appExists(owner, id)) throw new AgentError("not_found", `No app token or connected app ${id}`)
|
|
228
239
|
return undefined
|
|
229
240
|
}
|
|
230
241
|
if (holder.startsWith("@@")) return undefined
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// at any pace (the S1 case; an app answering one session with new messages forever is held by the pair limit and
|
|
20
20
|
// the recipient's wake budget instead). A relay loop through an app thus reaches HOP_CAP after ~8 hops. Only
|
|
21
21
|
// session senders count (the user's hop 0 and other apps do not), and the hub's own parties (HUB_APP_TOKENS:
|
|
22
|
-
// app:concierge, the workspace coordinator, team intake) keep none: they send on events and submissions, not on
|
|
22
|
+
// app:concierge, the workspace coordinator, team intake, session subscriptions) keep none: they send on events and submissions, not on
|
|
23
23
|
// the messages they receive (team intake carries the submitter's depth + 1 itself, SendOptions.chainHop). It is
|
|
24
24
|
// not a persistent depth: messages to an app still raise nothing beyond this time-boxed memory.
|
|
25
25
|
// - MessageLimiter: fixed windows aligned to the clock (so retryAfter is exact): messages per sender→recipient
|
|
@@ -41,7 +41,7 @@ export const RELAY_WINDOW_MS = 5 * 60_000
|
|
|
41
41
|
/** Sending sessions remembered per (user, app); the oldest go first. */
|
|
42
42
|
export const RELAY_SOURCES_MAX = 32
|
|
43
43
|
/** The hub's own app parties (app:<tokenID>): no relay depth (see the header). */
|
|
44
|
-
export const HUB_APP_TOKENS: ReadonlySet<string> = new Set(["concierge", "workspace-coordinator", "team-intake"])
|
|
44
|
+
export const HUB_APP_TOKENS: ReadonlySet<string> = new Set(["concierge", "workspace-coordinator", "team-intake", "subscriptions"])
|
|
45
45
|
|
|
46
46
|
/** StateStore collection: partyKey → HopDepth. */
|
|
47
47
|
const HOPS = "message-hops"
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
// POST /api/agent/mute {session} / POST /api/agent/unmute {session} messages:send → {muted}
|
|
11
11
|
// GET /api/agent/messaging / PUT /api/agent/messaging {enabled} user actors only: the per-user switch
|
|
12
12
|
// GET /api/agent/decisions?state= / GET /api/agent/decisions/:id messages:read → {decisions} / {decision}
|
|
13
|
+
// (AF4) GET /api/agent/decisions?answerable=1 is the app's answerable list (decisions:answer; app-decisions.ts)
|
|
13
14
|
//
|
|
14
15
|
// - @me (design §12.1): msg @me is stored for the user; ask @me opens a Decision (./decisions) with the extra
|
|
15
16
|
// fields {options: [{id, label, safe?}], recommendation: {option, why?}, urgency, tier, deadline (seconds),
|
|
16
17
|
// onTimeout, about: [addresses]} → 201 {message, decision, answer?}. Those fields on an ask to anyone else are
|
|
17
|
-
// invalid.
|
|
18
|
-
// replied to or declined here. Decisions read for their asker (and the user); others get not_found.
|
|
18
|
+
// invalid. The user answers a Decision on the console (/api/decisions/:id/answer), or (AF4) an app its own
|
|
19
|
+
// sessions' confirm-tier ones (app-decisions.ts): its ask cannot be replied to or declined here. Decisions read for their asker (and the user); others get not_found.
|
|
19
20
|
//
|
|
20
21
|
// - The sender is only ever the authenticated actor; any from/sender in a body is ignored. A session cannot
|
|
21
22
|
// message itself (invalid). Replies and declines come only from the message's recipient; anyone else, and any
|
|
@@ -73,6 +74,8 @@ export interface MessageDeps {
|
|
|
73
74
|
readonly feed: MessageFeed
|
|
74
75
|
/** Decisions (ask @me); without them an ask to @me is a plain stored ask. */
|
|
75
76
|
readonly decisions?: Decisions
|
|
77
|
+
/** (AF4) GET /api/agent/decisions?answerable=1 (app-decisions.ts); without it `answerable` is ignored. */
|
|
78
|
+
readonly answerable?: (context: AgentContext) => Promise<Response>
|
|
76
79
|
/** Default WAIT_POLL_MS. */
|
|
77
80
|
readonly pollMs?: number
|
|
78
81
|
/** Where msg/ask keep their Idempotency-Key records; without it a key is checked for shape but not remembered. */
|
|
@@ -373,8 +376,14 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
|
|
|
373
376
|
{
|
|
374
377
|
method: "GET",
|
|
375
378
|
path: `${AGENT_PREFIX}/decisions`,
|
|
376
|
-
|
|
377
|
-
|
|
379
|
+
// (AF4) decisions:answer reaches it for ?answerable=1 only.
|
|
380
|
+
allow: deps.answerable ? { ...READ, scopes: ["messages:read", "decisions:answer"] } : READ,
|
|
381
|
+
async handle(context) {
|
|
382
|
+
const { user, actor, url } = context
|
|
383
|
+
if (deps.answerable && url.searchParams.has("answerable")) return deps.answerable(context)
|
|
384
|
+
if (actor.kind === "app" && !actor.scopes.includes("messages:read")) throw new AgentError("forbidden", "This credential cannot use this route")
|
|
385
|
+
return ok({ decisions: await decisionsFor(user.id, actor, decisionStateQuery(url.searchParams.get("state"))) })
|
|
386
|
+
},
|
|
378
387
|
} satisfies AgentRoute,
|
|
379
388
|
{
|
|
380
389
|
method: "GET",
|
|
@@ -69,6 +69,11 @@ export interface ReadDeps {
|
|
|
69
69
|
readonly discovery?: TeamDiscovery
|
|
70
70
|
/** (CON-04) The user's Concierge sessions (current and hidden): their entries carry `concierge: true`. */
|
|
71
71
|
readonly concierge?: (userID: string) => Promise<readonly string[]>
|
|
72
|
+
/**
|
|
73
|
+
* (AF3) Throws not_found unless the addressed session is the app's (contract §1.2): the log admits conversation:read
|
|
74
|
+
* (without sessions:read) for those only.
|
|
75
|
+
*/
|
|
76
|
+
readonly appSession?: (actor: AgentActor, userID: string, addr: string) => Promise<unknown>
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/** One session in agent answers (status, sessions). */
|
|
@@ -380,6 +385,8 @@ function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams):
|
|
|
380
385
|
const NO_STORE = { "cache-control": "no-store" }
|
|
381
386
|
const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
|
|
382
387
|
const READ = { session: true, scope: "sessions:read" } as const
|
|
388
|
+
/** (AF3) The log: sessions:read, or conversation:read for the app's own sessions. */
|
|
389
|
+
const LOG = { session: true, scopes: ["sessions:read", "conversation:read"] } as const
|
|
383
390
|
|
|
384
391
|
export function readRoutes(deps: ReadDeps): AgentRoute[] {
|
|
385
392
|
return [
|
|
@@ -418,8 +425,15 @@ export function readRoutes(deps: ReadDeps): AgentRoute[] {
|
|
|
418
425
|
{
|
|
419
426
|
method: "GET",
|
|
420
427
|
path: `${AGENT_PREFIX}/sessions/:addr/log`,
|
|
421
|
-
allow:
|
|
422
|
-
handle: async ({ user, params, url, actor }) =>
|
|
428
|
+
allow: LOG,
|
|
429
|
+
handle: async ({ user, params, url, actor }) => {
|
|
430
|
+
// (AF3) conversation:read alone: the app's own sessions only.
|
|
431
|
+
if (actor.kind === "app" && !actor.scopes.includes("sessions:read")) {
|
|
432
|
+
if (!deps.appSession) throw new AgentError("forbidden", "This credential cannot use this route")
|
|
433
|
+
await deps.appSession(actor, user.id, params.addr!)
|
|
434
|
+
}
|
|
435
|
+
return ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"), await censusOf(deps, user.id, actor)))
|
|
436
|
+
},
|
|
423
437
|
},
|
|
424
438
|
...(deps.events ? [watchRoute(deps, deps.events)] : []),
|
|
425
439
|
]
|