@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.
Files changed (141) hide show
  1. package/CHANGELOG.md +296 -0
  2. package/Dockerfile +5 -1
  3. package/README.md +152 -77
  4. package/package.json +7 -4
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/cli/dist/hub-cli.js +532 -261
  7. package/packages/cli/package.json +1 -1
  8. package/packages/cli/src/cli.ts +36 -1
  9. package/packages/cli/src/subscriptions.ts +49 -0
  10. package/packages/codex/package.json +1 -1
  11. package/packages/codex/src/sessions.ts +10 -6
  12. package/packages/codex/src/spoke.ts +2 -1
  13. package/packages/node/package.json +1 -1
  14. package/packages/preset/package.json +1 -1
  15. package/packages/protocol/package.json +1 -1
  16. package/packages/protocol/schema/app-delivery/v1.schema.json +7 -5
  17. package/packages/protocol/schema/app-front/v1.schema.json +410 -0
  18. package/packages/protocol/src/app-front.ts +293 -0
  19. package/packages/protocol/src/concierge.ts +10 -1
  20. package/packages/protocol/src/context-cards.ts +59 -0
  21. package/packages/protocol/src/credentials.ts +39 -2
  22. package/packages/protocol/src/index.ts +69 -11
  23. package/packages/runtime-openshell/package.json +1 -1
  24. package/packages/runtime-sdk/package.json +1 -1
  25. package/packages/sdk/package.json +1 -1
  26. package/packages/sdk/src/app-front.ts +524 -0
  27. package/packages/sdk/src/client.ts +37 -4
  28. package/packages/sdk/src/envelope.ts +9 -2
  29. package/packages/sdk/src/errors.ts +1 -0
  30. package/packages/sdk/src/index.ts +1 -0
  31. package/packages/sdk/src/types.ts +39 -1
  32. package/packages/spoke-sdk/package.json +1 -1
  33. package/packages/spoke-sdk/src/delivery.ts +17 -1
  34. package/packages/spoke-sdk/src/harness-spoke.ts +4 -3
  35. package/packages/voice-client/README.md +146 -0
  36. package/packages/voice-client/src/vad/LICENSE +21 -0
  37. package/src/central/agent/acting-routes.ts +17 -3
  38. package/src/central/agent/acting.ts +12 -3
  39. package/src/central/agent/actor.ts +6 -1
  40. package/src/central/agent/app-decisions.ts +146 -0
  41. package/src/central/agent/app-tokens.ts +9 -3
  42. package/src/central/agent/audit.ts +3 -0
  43. package/src/central/agent/connect-routes.ts +64 -0
  44. package/src/central/agent/connect.ts +868 -0
  45. package/src/central/agent/conversation.ts +625 -0
  46. package/src/central/agent/decisions.ts +71 -6
  47. package/src/central/agent/delivery.ts +3 -0
  48. package/src/central/agent/directory.ts +5 -0
  49. package/src/central/agent/errors.ts +2 -0
  50. package/src/central/agent/grant-requests.ts +46 -9
  51. package/src/central/agent/grants.ts +13 -2
  52. package/src/central/agent/message-limits.ts +2 -2
  53. package/src/central/agent/message-routes.ts +13 -4
  54. package/src/central/agent/read-routes.ts +16 -2
  55. package/src/central/agent/routes.ts +108 -6
  56. package/src/central/agent/speech-routes.ts +316 -0
  57. package/src/central/agent/subscription-routes.ts +174 -0
  58. package/src/central/agent/subscriptions.ts +980 -0
  59. package/src/central/agent-data.ts +43 -1
  60. package/src/central/backend/memory-counters.ts +2 -2
  61. package/src/central/backend/types.ts +5 -2
  62. package/src/central/backend/valkey-counters.ts +5 -4
  63. package/src/central/client-address.ts +16 -0
  64. package/src/central/concierge/board-render.ts +52 -1
  65. package/src/central/concierge/board-routes.ts +25 -2
  66. package/src/central/concierge/board.ts +11 -1
  67. package/src/central/concierge/context.ts +29 -8
  68. package/src/central/concierge/grant-request.ts +216 -0
  69. package/src/central/concierge/prompt.ts +49 -2
  70. package/src/central/concierge/session-routes.ts +3 -0
  71. package/src/central/config.ts +64 -3
  72. package/src/central/context-cards/budget.ts +121 -0
  73. package/src/central/context-cards/changes.ts +29 -0
  74. package/src/central/context-cards/config.ts +145 -0
  75. package/src/central/context-cards/engine.ts +379 -0
  76. package/src/central/context-cards/inputs.ts +111 -0
  77. package/src/central/context-cards/pickup.ts +134 -0
  78. package/src/central/context-cards/registry.ts +89 -0
  79. package/src/central/context-cards/routes.ts +37 -0
  80. package/src/central/context-cards/service.ts +367 -0
  81. package/src/central/context-cards/store.ts +67 -0
  82. package/src/central/context-cards/types.ts +88 -0
  83. package/src/central/fetcher.ts +10 -2
  84. package/src/central/front-relays.ts +423 -0
  85. package/src/central/http.ts +516 -56
  86. package/src/central/human-activity.ts +453 -0
  87. package/src/central/llm/chat.ts +133 -0
  88. package/src/central/registry.ts +162 -9
  89. package/src/central/speech/service.ts +9 -0
  90. package/src/central/spoke-ws.ts +13 -0
  91. package/src/central/store.ts +9 -12
  92. package/src/config.ts +30 -13
  93. package/src/index.ts +45 -43
  94. package/src/rpc.ts +33 -4
  95. package/src/spoke/concierge-agent.ts +1 -1
  96. package/src/spoke/conversation.ts +6 -75
  97. package/src/spoke/deliver.ts +18 -8
  98. package/src/spoke/hub-rpc.ts +7 -2
  99. package/src/spoke/hub-tool-subscriptions.ts +82 -0
  100. package/src/spoke/hub-tool-title.ts +89 -0
  101. package/src/spoke/hub-tool.ts +56 -18
  102. package/src/spoke/session-context.ts +10 -2
  103. package/src/spoke/spoke.ts +209 -238
  104. package/src/spoke/summary.ts +36 -1
  105. package/src/spoke/taskboard.ts +5 -6
  106. package/src/spoke/voice-front.ts +323 -30
  107. package/src/spoke/who-teams.ts +51 -2
  108. package/src/tui-messaging.ts +24 -10
  109. package/src/tui.ts +6 -6
  110. package/web/dist/assets/{ArtifactPage-BKISN-Qj.js → ArtifactPage-Bz992ZX6.js} +1 -1
  111. package/web/dist/assets/{ArtifactPane-CKOiAUNH.js → ArtifactPane-BJZNixJC.js} +1 -1
  112. package/web/dist/assets/ArtifactWorkbench-Dti3VP07.js +6 -0
  113. package/web/dist/assets/{Diagram-vdpmp3xu.css → Diagram-7y8kg3og.css} +1 -1
  114. package/web/dist/assets/Diagram-B4HdUxw0.js +1 -0
  115. package/web/dist/assets/{Library-CcjZXsfF.js → Library-CpzD2HDW.js} +2 -2
  116. package/web/dist/assets/Lightbox-BlbivCkd.js +1 -0
  117. package/web/dist/assets/MediaImage-Ch3ryIdR.js +4 -0
  118. package/web/dist/assets/ProfilesDestination-DlQeogea.js +1 -0
  119. package/web/dist/assets/{TeamsPage-CDyjrK-I.js → TeamsPage-4xdWCaI-.js} +1 -1
  120. package/web/dist/assets/{Watchtower-DYwoUWME.js → Watchtower-CpQdIRns.js} +2 -2
  121. package/web/dist/assets/{artifacts-DU9uRgs1.js → artifacts-But87Jfo.js} +1 -1
  122. package/web/dist/assets/index-BC0O1gfR.css +1 -0
  123. package/web/dist/assets/index-BS6lA6fN.js +33 -0
  124. package/web/dist/assets/{live-ycZ_aQZO.js → live-CVUFIdUX.js} +2 -2
  125. package/web/dist/assets/{pane-layout-0RrLHGDr.js → pane-layout-BW0RdX5a.js} +1 -1
  126. package/web/dist/assets/{renderer2d-BIwbgRoX.js → renderer2d-IaJiloPi.js} +1 -1
  127. package/web/dist/assets/{renderer3d-C1a7eYGQ.js → renderer3d-ZiD1uKGW.js} +1 -1
  128. package/web/dist/assets/{safe-area-DHhPy61Q.js → safe-area-C7IAlkgY.js} +1 -1
  129. package/web/dist/assets/{source-BWlvaODa.js → source-C-De8L3s.js} +1 -1
  130. package/web/dist/assets/toast-CXTwTvDw.js +9 -0
  131. package/web/dist/assets/vad-worklet-BfxJhrXy.js +27 -0
  132. package/web/dist/index.html +6 -6
  133. package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +0 -6
  134. package/web/dist/assets/Diagram-azw2B_QH.js +0 -1
  135. package/web/dist/assets/Lightbox-Du56MXGX.js +0 -1
  136. package/web/dist/assets/MediaImage-Ds_JUx3a.js +0 -4
  137. package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +0 -1
  138. package/web/dist/assets/index-C3ci5UtY.css +0 -1
  139. package/web/dist/assets/index-LNMZCqgE.js +0 -36
  140. package/web/dist/assets/toast-Cuu2K0hx.js +0 -1
  141. /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
- // - Only the user answers (never a session or app, never the asker); `tap` Decisions only from a signed-in
9
- // console (the device cookie, not a device token). The first answer wins (a version-checked write); later
10
- // ones get `gone`. The answer goes back to the asker as the user's reply to the ask.
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: only the user's device answers.
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
- readonly answeredBy: "user" | "timeout"
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
- const key = partyKey(requester)
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 open = await decisions.get(userID, existing.value.decisionID)
95
- if (open?.state === "open") {
96
- throw new AgentError("rate_limited", `Your grant request ${open.id} still waits for the user`, { extra: { retryAfter: 60, decisionID: open.id } })
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 (this.deps.store.agentData(grant.owner).appTokens ?? []).some((t) => t.id === actor.tokenID)
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 (!(this.deps.store.agentData(owner).appTokens ?? []).some((t) => t.id === id)) throw new AgentError("not_found", `No app token ${id}`)
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. Only the user answers a Decision, on the console (/api/decisions/:id/answer): its ask cannot be
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
- allow: READ,
377
- handle: async ({ user, actor, url }) => ok({ decisions: await decisionsFor(user.id, actor, decisionStateQuery(url.searchParams.get("state"))) }),
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: READ,
422
- handle: async ({ user, params, url, actor }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"), await censusOf(deps, user.id, 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
  ]