@huynle/hub 0.6.0 → 0.7.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.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,51 @@
1
+ // The message envelope (design §3.3): the pure renderer and sanitiser live in the SDK (packages/sdk/src/envelope.ts,
2
+ // shared with the plugin's hub tool and `hub mcp`) and are re-exported here; the hub adds the sender derived only
3
+ // from the authenticated actor (partyFromActor) and the party keys of its indexes.
4
+ import type { Party } from "../../../packages/sdk/src/types.ts"
5
+ import { sanitizeLabel } from "../../../packages/sdk/src/envelope.ts"
6
+ import type { AgentActor } from "./actor"
7
+
8
+ export { LABEL_MAX, partyLabel, renderEnvelope, renderPrimer, sanitizeLabel, sanitizeText, type EnvelopeInput, type EnvelopeKind } from "../../../packages/sdk/src/envelope.ts"
9
+ export type { Party } from "../../../packages/sdk/src/types.ts"
10
+
11
+ /** Directory facts for a session sender's label (never taken from a request body). */
12
+ export interface PartyInfo {
13
+ readonly name?: string
14
+ readonly harness?: string
15
+ readonly machine?: string
16
+ }
17
+
18
+ /** The sender of a request, from its actor; session labels come from the hub's directory, sanitised. */
19
+ export function partyFromActor(actor: AgentActor, info: PartyInfo = {}): Party {
20
+ switch (actor.kind) {
21
+ case "user":
22
+ return { kind: "user" }
23
+ case "app":
24
+ return { kind: "app", tokenID: actor.tokenID, ...labels({ label: actor.label }) }
25
+ case "session":
26
+ return { kind: "session", sessionID: actor.sessionID, ...labels({ name: info.name, harness: info.harness, machine: info.machine }) }
27
+ }
28
+ }
29
+
30
+ /** The given labels, sanitised, without empty ones. */
31
+ function labels<T extends Record<string, string | undefined>>(values: T): Partial<Record<keyof T, string>> {
32
+ const out: Partial<Record<keyof T, string>> = {}
33
+ for (const [key, value] of Object.entries(values) as [keyof T, string | undefined][]) {
34
+ const clean = value === undefined ? "" : sanitizeLabel(value)
35
+ if (clean) out[key] = clean
36
+ }
37
+ return out
38
+ }
39
+
40
+ /** A stable key per party (index and mute keys): `ses:<id>`, `user`, `app:<tokenID>`. */
41
+ export function partyKey(party: Party): string {
42
+ switch (party.kind) {
43
+ case "session":
44
+ return `ses:${party.sessionID}`
45
+ case "user":
46
+ return "user"
47
+ case "app":
48
+ return `app:${party.tokenID}`
49
+ }
50
+ }
51
+
@@ -0,0 +1,72 @@
1
+ // The /api/agent error envelope (design §8, §14.5): {error: {code, message, hint?, ...extra}}. Only /api/agent/*
2
+ // answers this way; the console routes keep their {error: string, code?} envelope (the web app reads it).
3
+
4
+ /** HTTP status of each agent error code. */
5
+ export const AGENT_ERROR_STATUS = {
6
+ /** Malformed request (bad JSON, bad fields, cookie and bearer together). */
7
+ invalid: 400,
8
+ /** No credential, or an unknown, revoked or disabled one. */
9
+ unauthorized: 401,
10
+ /** A session or app token that expired: get a new one. */
11
+ token_expired: 401,
12
+ /** Outside the grant; a recommendation was recorded for the user. */
13
+ needs_user: 403,
14
+ /** No grant or scope, or a credential not admitted on this route. */
15
+ forbidden: 403,
16
+ /** No such thing, or another user's (they answer alike). */
17
+ not_found: 404,
18
+ /** The address matches several sessions (`candidates`). */
19
+ ambiguous: 409,
20
+ /** Explicit name or exclusive role already held. */
21
+ name_taken: 409,
22
+ /** Already answered, expired or ended. */
23
+ gone: 410,
24
+ /** The spoke or node lacks the feature or runtime (`feature`, `harness`). */
25
+ unsupported: 422,
26
+ /** A kill switch is on. */
27
+ paused: 423,
28
+ /** A pair, recipient, user or launch cap (`retryAfter`, seconds). */
29
+ rate_limited: 429,
30
+ /** An unexpected failure (details are logged, not sent). */
31
+ internal: 500,
32
+ /** The machine is offline (actions only; messages queue instead). */
33
+ offline: 503,
34
+ } as const satisfies Record<string, number>
35
+
36
+ export type AgentErrorCode = keyof typeof AGENT_ERROR_STATUS
37
+
38
+ export interface AgentErrorOptions {
39
+ readonly hint?: string
40
+ /** More fields in the envelope, e.g. candidates, retryAfter, feature; never code or message. */
41
+ readonly extra?: Record<string, unknown>
42
+ }
43
+
44
+ /** Throw from an agent handler; answered as the envelope with the code's status. */
45
+ export class AgentError extends Error {
46
+ constructor(
47
+ readonly code: AgentErrorCode,
48
+ message: string,
49
+ readonly options: AgentErrorOptions = {},
50
+ ) {
51
+ super(message)
52
+ }
53
+ }
54
+
55
+ export function agentError(error: AgentError): Response {
56
+ const { hint, extra = {} } = error.options
57
+ const headers: Record<string, string> = { "cache-control": "no-store" }
58
+ const retryAfter = extra.retryAfter
59
+ if (typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter >= 0) headers["retry-after"] = String(Math.ceil(retryAfter))
60
+ return Response.json(
61
+ { error: { ...extra, code: error.code, message: error.message, ...(hint ? { hint } : {}) } },
62
+ { status: AGENT_ERROR_STATUS[error.code], headers },
63
+ )
64
+ }
65
+
66
+ /** The envelope for anything an agent handler threw. */
67
+ export function agentFailure(cause: unknown): Response {
68
+ if (cause instanceof AgentError) return agentError(cause)
69
+ if (cause instanceof SyntaxError) return agentError(new AgentError("invalid", "Invalid JSON"))
70
+ console.error("[hub] agent request failed:", cause)
71
+ return agentError(new AgentError("internal", "Internal error"))
72
+ }
@@ -0,0 +1,219 @@
1
+ // Escalations (design §5, §12.1; decision wvqeuis0 §7; exploration hcw63slg Security): what the acting engine's
2
+ // needs_user becomes. An agent asked to approve a permission request outside its grant; the user decides.
3
+ //
4
+ // - ONE Decision per (holder, request): the first escalation opens it (options approve [always] / deny (safe), the
5
+ // agent's recommendation, the tier the hub set on the evaluation, about the asking session); escalating the same
6
+ // request again updates the open Decision's reason instead (no new push). Concurrent escalations race on a
7
+ // version-checked reservation: one opens the Decision, the other is rate_limited (retry).
8
+ // - The recommendation also shows on the request's blocker (registry.recommend: catalog, status, the push's
9
+ // service worker reads it there).
10
+ // - Answering (decisions.ts runs `act` after the answer wins) replies to the permission AS THE USER through the
11
+ // shared executor: no grant, no kill switch (those bind agents, not the user); tenancy of the tree is checked
12
+ // again; the reply is in the agent audit as the user's. The request gone → the Decision closes (gone); its spoke
13
+ // not serving permission → it closes with the unsupported reason (audited unsupported).
14
+ // - The request answered elsewhere, or ended (registry.onPermissionClosed → closed()): every Decision escalating
15
+ // it closes.
16
+ // - Spam limits: ESCALATIONS_PER_HOLDER_HOUR new Decisions per holder an hour, ESCALATIONS_OPEN_MAX open ones per
17
+ // user (both rate_limited: the needs_user still stands, without a Decision), and pushes coalesce: a Decision
18
+ // opened within ESCALATION_PUSH_COALESCE_MS of the user's last escalation push opens quietly (still listed and
19
+ // live in the console).
20
+ import { type Actions, unsupportedMessage } from "../actions"
21
+ import type { Counters, StateStore } from "../backend/types"
22
+ import type { Registry } from "../registry"
23
+ import type { PermissionDecision } from "../../protocol"
24
+ import type { BlockerRecommendation } from "../../status"
25
+ import type { Escalation } from "./acting"
26
+ import type { AgentActor, UserActor } from "./actor"
27
+ import type { AgentAudit } from "./audit"
28
+ import { type ActOutcome, type Decision, type DecisionActor, type Decisions, WHY_MAX } from "./decisions"
29
+ import { type Party, partyFromActor, partyKey, partyLabel } from "./envelope"
30
+ import { AgentError } from "./errors"
31
+ import type { Message } from "./messages"
32
+
33
+ /** New escalation Decisions a holder opens an hour (their asks to @me also count in its message pair limit). */
34
+ export const ESCALATIONS_PER_HOLDER_HOUR = 20
35
+ /** Open escalation Decisions per user. */
36
+ export const ESCALATIONS_OPEN_MAX = 20
37
+ /** Escalations opened this soon after the user's last escalation push do not push again. */
38
+ export const ESCALATION_PUSH_COALESCE_MS = 60_000
39
+ /** A reservation older than this (its opener crashed) no longer blocks a new escalation. */
40
+ const RESERVATION_MS = 60_000
41
+ const HOUR_MS = 3_600_000
42
+ /** Characters of the question (the request's resources are listed up to it). */
43
+ const QUESTION_MAX = 2_000
44
+ const CLOSED_REASON = "The request was answered elsewhere or ended"
45
+
46
+ const COLLECTION = "escalations"
47
+
48
+ /** (holder, request) → its Decision; without decisionID while it is being opened. */
49
+ interface EscalationRecord {
50
+ readonly holder: string
51
+ readonly requestID: string
52
+ readonly at: number
53
+ readonly decisionID?: string
54
+ }
55
+
56
+ export interface EscalationsDeps {
57
+ readonly state: StateStore
58
+ readonly counters: Counters
59
+ readonly decisions: Pick<Decisions, "ask" | "get" | "list" | "close" | "revise">
60
+ readonly registry: Pick<Registry, "recommend" | "sessionTenant">
61
+ readonly actions: Pick<Actions, "answerPermission">
62
+ readonly audit?: Pick<AgentAudit, "record">
63
+ /** The holder as envelopes name it (a session by its directory name); default: from the actor alone. */
64
+ readonly describe?: (userID: string, actor: AgentActor) => Party
65
+ /** The Decision's ask was stored (the console's message feed). */
66
+ readonly published?: (userID: string, message: Message) => void
67
+ readonly now?: () => number
68
+ }
69
+
70
+ const rateLimited = (message: string, retryAfter: number) => new AgentError("rate_limited", message, { extra: { retryAfter } })
71
+
72
+ export class Escalations {
73
+ private readonly now: () => number
74
+
75
+ constructor(private readonly deps: EscalationsDeps) {
76
+ this.now = deps.now ?? Date.now
77
+ this.act = this.act.bind(this)
78
+ }
79
+
80
+ /** The acting engine's escalate hook: opens or updates the holder's Decision about the request. */
81
+ async escalate(escalation: Escalation): Promise<{ decisionID?: string }> {
82
+ const { userID } = escalation
83
+ const holder = this.describe(userID, escalation.actor)
84
+ const key = `${partyKey(holder)} ${escalation.requestID}`
85
+ const { state } = this.deps
86
+ const existing = await state.get<EscalationRecord>(userID, COLLECTION, key)
87
+ if (existing?.value.decisionID) {
88
+ const decision = await this.deps.decisions.get(userID, existing.value.decisionID)
89
+ if (decision?.state === "open") return this.update(escalation, holder, decision)
90
+ } else if (existing && this.now() - existing.value.at < RESERVATION_MS) {
91
+ throw rateLimited("An escalation of this request is being opened", 1)
92
+ }
93
+ const record: EscalationRecord = { holder: partyKey(holder), requestID: escalation.requestID, at: this.now() }
94
+ const reserved = await state.put(userID, COLLECTION, key, record, { ifVersion: existing?.version ?? 0 })
95
+ if (!reserved.ok) throw rateLimited("An escalation of this request is being opened", 1)
96
+ try {
97
+ const decision = await this.open(escalation, holder)
98
+ await state.put(userID, COLLECTION, key, { ...record, decisionID: decision.id }, { ifVersion: reserved.version })
99
+ return { decisionID: decision.id }
100
+ } catch (cause) {
101
+ await state.delete(userID, COLLECTION, key, { ifVersion: reserved.version })
102
+ throw cause
103
+ }
104
+ }
105
+
106
+ /** Decisions' action runner: replies to the escalated permission as the answering user. */
107
+ async act(userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome> {
108
+ const action = decision.action
109
+ if (!action || action.kind !== "permission") return "gone"
110
+ const reply: PermissionDecision = choice.option === "always" ? "always" : choice.option === "approve" ? "once" : "reject"
111
+ const verb = reply === "reject" ? "deny" : "approve"
112
+ const owned = this.deps.registry.sessionTenant(action.rootID) === by.userID && by.userID === userID
113
+ const answered = owned ? await this.deps.actions.answerPermission({ rootID: action.rootID, requestID: action.requestID, decision: reply }) : { outcome: "gone" as const }
114
+ await this.deps.audit?.record(userID, {
115
+ actor: { kind: "user" },
116
+ verb,
117
+ target: { sessionID: action.sessionID, requestID: action.requestID },
118
+ decision: "allow",
119
+ reason: `Decision ${decision.id}`,
120
+ outcome: answered.outcome === "done" ? "ok" : answered.outcome,
121
+ })
122
+ await this.forget(userID, action.requestID)
123
+ // The spoke holding the request cannot answer permissions: the Decision closes saying so (not "answered elsewhere").
124
+ if (answered.outcome === "unsupported") return { outcome: "gone", reason: unsupportedMessage(answered) }
125
+ return answered.outcome === "done" ? "done" : "gone"
126
+ }
127
+
128
+ /** The request went away (answered elsewhere, ended): closes every Decision escalating it. */
129
+ async closed(userID: string | undefined, requestID: string): Promise<void> {
130
+ if (userID === undefined) return
131
+ for (const { key, value } of await this.deps.state.list<EscalationRecord>(userID, COLLECTION)) {
132
+ if (value.requestID !== requestID) continue
133
+ if (value.decisionID) await this.deps.decisions.close(userID, value.decisionID, CLOSED_REASON)
134
+ await this.deps.state.delete(userID, COLLECTION, key)
135
+ }
136
+ }
137
+
138
+ // ---- internals ----------------------------------------------------------------------------------------
139
+
140
+ private describe(userID: string, actor: AgentActor): Party {
141
+ return this.deps.describe?.(userID, actor) ?? partyFromActor(actor)
142
+ }
143
+
144
+ /** A new Decision, within the spam limits. */
145
+ private async open(escalation: Escalation, holder: Party): Promise<Decision> {
146
+ const { userID } = escalation
147
+ const { decisions, counters } = this.deps
148
+ const open = (await decisions.list(userID, { state: "open" })).filter((d) => d.action?.kind === "permission").length
149
+ if (open >= ESCALATIONS_OPEN_MAX) throw rateLimited(`At most ${ESCALATIONS_OPEN_MAX} escalations wait for the user at once`, 60)
150
+ const opened = await counters.incr(`escalations:${userID}:${partyKey(holder)}`, HOUR_MS)
151
+ if (opened > ESCALATIONS_PER_HOLDER_HOUR) throw rateLimited(`At most ${ESCALATIONS_PER_HOLDER_HOUR} escalations an hour`, 3600)
152
+ const pushes = await counters.incr(`escalation-push:${userID}`, ESCALATION_PUSH_COALESCE_MS)
153
+ const recommend = escalation.always ? "always" : "approve"
154
+ const why = boundedWhy(escalation.why)
155
+ const { message, decision } = await decisions.ask(
156
+ userID,
157
+ escalation.actor,
158
+ {
159
+ question: question(escalation, partyLabel(holder)),
160
+ options: [
161
+ ...(escalation.always ? [{ id: "always", label: "Approve always" }] : []),
162
+ { id: "approve", label: "Approve once" },
163
+ { id: "deny", label: "Deny", safe: true },
164
+ ],
165
+ recommendation: { option: recommend, ...(why ? { why } : {}) },
166
+ urgency: "high",
167
+ tier: escalation.evaluation.tier,
168
+ about: [escalation.sessionID],
169
+ },
170
+ {
171
+ action: { kind: "permission", requestID: escalation.requestID, rootID: escalation.rootID, sessionID: escalation.sessionID, always: escalation.always },
172
+ quiet: pushes > 1,
173
+ },
174
+ )
175
+ this.deps.published?.(userID, message)
176
+ this.showOnBlocker(escalation.requestID, holder, decision)
177
+ return decision
178
+ }
179
+
180
+ /** The same holder escalated the request again: a new reason replaces the recommendation's. */
181
+ private async update(escalation: Escalation, holder: Party, decision: Decision): Promise<{ decisionID: string }> {
182
+ const why = boundedWhy(escalation.why)
183
+ let current = decision
184
+ if (why && why !== decision.recommendation?.why) {
185
+ const option = decision.recommendation?.option ?? (escalation.always ? "always" : "approve")
186
+ current = (await this.deps.decisions.revise(escalation.userID, decision.id, { recommendation: { option, why } })) ?? decision
187
+ }
188
+ this.showOnBlocker(escalation.requestID, holder, current)
189
+ return { decisionID: decision.id }
190
+ }
191
+
192
+ private showOnBlocker(requestID: string, holder: Party, decision: Decision): void {
193
+ const why = decision.recommendation?.why
194
+ const recommendation: BlockerRecommendation = { by: partyLabel(holder), recommends: "approve", ...(why ? { why } : {}), decisionID: decision.id }
195
+ this.deps.registry.recommend(requestID, recommendation)
196
+ }
197
+
198
+ /** Drops the (holder, request) records of an answered request. */
199
+ private async forget(userID: string, requestID: string): Promise<void> {
200
+ for (const { key, value } of await this.deps.state.list<EscalationRecord>(userID, COLLECTION)) {
201
+ if (value.requestID === requestID) await this.deps.state.delete(userID, COLLECTION, key)
202
+ }
203
+ }
204
+ }
205
+
206
+ /** The agent's reason, cut to what a Decision keeps. */
207
+ function boundedWhy(why: string | undefined): string | undefined {
208
+ const trimmed = why?.trim()
209
+ return trimmed ? Array.from(trimmed).slice(0, WHY_MAX).join("") : undefined
210
+ }
211
+
212
+ /** "@orchestrator asks to approve shell: bun test, bun run lint. Outside its grant: …" (bounded). */
213
+ function question(escalation: Escalation, by: string): string {
214
+ const { request, evaluation } = escalation
215
+ const what = request ? `${request.action}${request.resources.length ? `: ${request.resources.join(", ")}` : ""}` : "a permission request (details unknown)"
216
+ const always = escalation.always && request?.save?.length ? ` always (saves ${request.save.join(", ")})` : escalation.always ? " always" : ""
217
+ const text = `${by} asks to approve${always} ${what}. Outside its grant: ${evaluation.reason}`
218
+ return text.length > QUESTION_MAX ? `${text.slice(0, QUESTION_MAX - 1)}…` : text
219
+ }
@@ -0,0 +1,221 @@
1
+ // Grant requests (design §5, §7; P3-T9): an agent asks the user for a grant. Grants are created only by users
2
+ // (grant-routes.ts); a session (any token) or an app token (scope messages:send) may only request one, and the
3
+ // user's tap in the console creates it.
4
+ //
5
+ // - The body is {grant, why}: the grant as POST /api/agent/grants takes it, validated the same way (validateGrant)
6
+ // when requested and again, as the user's, when approved (Grants.create). The holder defaults to the requester
7
+ // (`ses_…`, `app:<token id>`); it may name only the requester itself (`ses_…`, its `@name`: stored as the session
8
+ // id, so a name moving on never moves the grant) or a reserved `@@role` the requesting session holds now.
9
+ // Another session or app is forbidden; a session that is not the user's is not_found.
10
+ // - It opens a Decision to @me (tap tier, high urgency; options approve / deny, deny safe) whose question
11
+ // summarises holder, projects, verbs, permissions allow/never, always, the hourly cap, expiry and the why
12
+ // (sanitised); its action (kind "grant", the canonical grant body) is the hub's, never the asker's.
13
+ // - Approve: the grant is created AS THE USER (owner the user); the requester's reply carries the grant id. The
14
+ // holder gone by then (not_found) closes the Decision instead. Deny: nothing is created; the reply says so.
15
+ // No editing on approval (yet): the user may create an edited grant directly and deny the request.
16
+ // - Limits: one open request per requester (another is rate_limited with the open Decision's id; a version-checked
17
+ // reservation settles races) and GRANT_REQUESTS_PER_HOUR per requester an hour (Counters). Asks to @me also
18
+ // count against the message limits.
19
+ // - Audit: the request (grant.request, needs_user, escalated) and its outcome (grant.approve ok / grant.deny
20
+ // refused, as the user).
21
+ import type { Counters, StateStore } from "../backend/types"
22
+ import type { AgentActor, AppActor, SessionActor, UserActor } from "./actor"
23
+ import type { AgentAudit } from "./audit"
24
+ import { type ActOutcome, type Decision, type Decisions, WHY_MAX } from "./decisions"
25
+ import type { Directory } from "./directory"
26
+ import { type Party, partyFromActor, partyKey, partyLabel, sanitizeLabel } from "./envelope"
27
+ import { AgentError } from "./errors"
28
+ import type { Grants } from "./grants"
29
+ import type { Message } from "./messages"
30
+ import { parseAddress } from "./names"
31
+ import { type GrantSpec, validateGrant } from "./policy"
32
+
33
+ /** Grant requests each requester opens an hour. */
34
+ export const GRANT_REQUESTS_PER_HOUR = 5
35
+ /** Characters of the question (lists are cut to fit). */
36
+ const QUESTION_MAX = 4_000
37
+ /** A reservation older than this (its opener crashed) no longer blocks a new request. */
38
+ const RESERVATION_MS = 60_000
39
+ const HOUR_MS = 3_600_000
40
+ const COLLECTION = "grant-requests"
41
+ const BODY_FIELDS = ["grant", "why"]
42
+
43
+ /** requester → its latest request's Decision (without decisionID while it is being opened). */
44
+ interface RequestRecord {
45
+ readonly at: number
46
+ readonly decisionID?: string
47
+ }
48
+
49
+ export interface GrantRequestsDeps {
50
+ readonly state: StateStore
51
+ readonly counters: Counters
52
+ readonly decisions: Pick<Decisions, "ask" | "get">
53
+ readonly grants: Pick<Grants, "create">
54
+ readonly directory: Pick<Directory, "resolve">
55
+ readonly audit?: Pick<AgentAudit, "record">
56
+ /** The requester as envelopes name it; default: from the actor alone. */
57
+ readonly describe?: (userID: string, actor: AgentActor) => Party
58
+ /** The Decision's ask was stored (the console's message feed). */
59
+ readonly published?: (userID: string, message: Message) => void
60
+ readonly now?: () => number
61
+ }
62
+
63
+ const invalid = (message: string) => new AgentError("invalid", message)
64
+ 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
+ const isObject = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
66
+
67
+ export class GrantRequests {
68
+ private readonly now: () => number
69
+
70
+ constructor(private readonly deps: GrantRequestsDeps) {
71
+ this.now = deps.now ?? Date.now
72
+ this.act = this.act.bind(this)
73
+ }
74
+
75
+ /** Opens the Decision for the agent's request. Throws invalid, forbidden, not_found or rate_limited. */
76
+ async request(userID: string, actor: AgentActor, body: unknown): Promise<{ decision: Decision; message: Message }> {
77
+ if (actor.kind === "user") throw invalid("You are the user: create the grant directly (POST /api/agent/grants)")
78
+ if (!isObject(body)) throw invalid("A grant request is {grant, why}")
79
+ for (const key of Object.keys(body)) if (!BODY_FIELDS.includes(key)) throw invalid(`Unknown grant request field ${key}`)
80
+ if (!isObject(body.grant)) throw invalid("grant is the grant to request (as POST /api/agent/grants takes it)")
81
+ const why = typeof body.why === "string" && body.why.length <= WHY_MAX * 4 ? sanitizeLabel(body.why, WHY_MAX) : ""
82
+ if (!why) throw invalid(`why is the reason the user reads (1–${WHY_MAX} characters)`)
83
+ const own = ownHolder(actor)
84
+ const spec = validateGrant({ ...body.grant, holder: body.grant.holder ?? own })
85
+ const holder = this.holder(userID, actor, spec.holder, own)
86
+ const grant = canonical({ ...spec, holder })
87
+
88
+ const requester = this.describe(userID, actor)
89
+ const key = partyKey(requester)
90
+ const { state, counters, decisions } = this.deps
91
+ const existing = await state.get<RequestRecord>(userID, COLLECTION, key)
92
+ if (existing?.value.decisionID) {
93
+ const open = await decisions.get(userID, existing.value.decisionID)
94
+ if (open?.state === "open") {
95
+ throw new AgentError("rate_limited", `Your grant request ${open.id} still waits for the user`, { extra: { retryAfter: 60, decisionID: open.id } })
96
+ }
97
+ } else if (existing && this.now() - existing.value.at < RESERVATION_MS) {
98
+ throw new AgentError("rate_limited", "Your grant request is being opened", { extra: { retryAfter: 1 } })
99
+ }
100
+ const at = this.now()
101
+ const count = await counters.incr(`grant-requests:${userID}:${key}`, HOUR_MS)
102
+ if (count > GRANT_REQUESTS_PER_HOUR) {
103
+ throw new AgentError("rate_limited", `At most ${GRANT_REQUESTS_PER_HOUR} grant requests an hour`, { extra: { retryAfter: HOUR_MS / 1000 } })
104
+ }
105
+ const reserved = await state.put(userID, COLLECTION, key, { at } satisfies RequestRecord, { ifVersion: existing?.version ?? 0 })
106
+ if (!reserved.ok) throw new AgentError("rate_limited", "Your grant request is being opened", { extra: { retryAfter: 1 } })
107
+ let opened: { decision: Decision; message: Message }
108
+ try {
109
+ opened = await decisions.ask(
110
+ userID,
111
+ actor,
112
+ {
113
+ question: question(partyLabel(requester), spec, holder, why),
114
+ options: [
115
+ { id: "approve", label: "Approve grant" },
116
+ { id: "deny", label: "Deny", safe: true },
117
+ ],
118
+ urgency: "high",
119
+ tier: "tap",
120
+ },
121
+ { action: { kind: "grant", requester: key, grant } },
122
+ )
123
+ } catch (cause) {
124
+ await state.delete(userID, COLLECTION, key, { ifVersion: reserved.version })
125
+ throw cause
126
+ }
127
+ await state.put(userID, COLLECTION, key, { at, decisionID: opened.decision.id } satisfies RequestRecord, { ifVersion: reserved.version })
128
+ this.deps.published?.(userID, opened.message)
129
+ await this.deps.audit?.record(userID, { actor: requester, verb: "grant.request", target: target(actor), decision: "needs_user", reason: why, outcome: "escalated" })
130
+ return opened
131
+ }
132
+
133
+ /** Decisions' action runner: approve creates the grant as the answering user; deny creates nothing. */
134
+ async act(userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome> {
135
+ const action = decision.action
136
+ if (!action || action.kind !== "grant" || by.userID !== userID) return "gone"
137
+ const about = decision.from.kind === "session" ? { sessionID: decision.from.sessionID } : {}
138
+ const reason = `Decision ${decision.id}`
139
+ if (choice.option !== "approve") {
140
+ await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.deny", target: about, decision: "allow", reason, outcome: "refused" })
141
+ return "done"
142
+ }
143
+ let created
144
+ try {
145
+ created = await this.deps.grants.create(userID, action.grant)
146
+ } catch (cause) {
147
+ if (!(cause instanceof AgentError) || cause.code !== "not_found") throw cause
148
+ await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.approve", target: about, decision: "allow", reason, outcome: "gone" })
149
+ return { outcome: "gone", reason: "The requested holder is gone" }
150
+ }
151
+ await this.deps.audit?.record(userID, { actor: { kind: "user" }, verb: "grant.approve", target: about, grant: created.id, decision: "allow", reason, outcome: "ok" })
152
+ return { outcome: "done", note: `Grant ${created.id} created (holder ${created.holder}, until ${new Date(created.expiresAt).toISOString()})` }
153
+ }
154
+
155
+ // ---- internals ----------------------------------------------------------------------------------------
156
+
157
+ private describe(userID: string, actor: AgentActor): Party {
158
+ return this.deps.describe?.(userID, actor) ?? partyFromActor(actor)
159
+ }
160
+
161
+ /** The holder the request may name (see the header), as stored. */
162
+ private holder(userID: string, actor: SessionActor | AppActor, holder: string, own: string): string {
163
+ if (holder === own) return own
164
+ if (holder.startsWith("app:") || actor.kind === "app") throw forbidden("A grant request may name only the requester as its holder")
165
+ const holds = (sessions: readonly { readonly sessionID: string }[]) => sessions.some((s) => s.sessionID === actor.sessionID)
166
+ if (parseAddress(holder)?.kind === "role") {
167
+ let held = false
168
+ try {
169
+ held = holds(this.deps.directory.resolve(userID, holder).sessions)
170
+ } catch {
171
+ held = false
172
+ }
173
+ if (!held) throw forbidden(`You do not hold ${holder}`)
174
+ return holder
175
+ }
176
+ // A session or name that is not the user's is not_found (the resolver's tenancy).
177
+ const { sessions } = this.deps.directory.resolve(userID, holder)
178
+ if (sessions.length !== 1 || !holds(sessions)) throw forbidden("A grant request may name only the requester as its holder")
179
+ return actor.sessionID
180
+ }
181
+ }
182
+
183
+ /** The holder a request defaults to: the session's id, or `app:<token id>`. */
184
+ const ownHolder = (actor: SessionActor | AppActor): string => (actor.kind === "session" ? actor.sessionID : `app:${actor.tokenID}`)
185
+
186
+ const target = (actor: SessionActor | AppActor) => (actor.kind === "session" ? { sessionID: actor.sessionID } : {})
187
+
188
+ /** The spec as the body Grants.create takes (expires in seconds). */
189
+ const canonical = (spec: GrantSpec): Record<string, unknown> => ({
190
+ holder: spec.holder,
191
+ scope: { projects: [...spec.scope.projects] },
192
+ allow: [...spec.allow],
193
+ permissions: { allow: [...spec.permissions.allow], never: [...spec.permissions.never] },
194
+ allowAlways: spec.allowAlways,
195
+ maxActionsPerHour: spec.maxActionsPerHour,
196
+ expires: Math.round(spec.expiresInMs / 1000),
197
+ })
198
+
199
+ /** `90m`, `8h`, `2d`; seconds otherwise. */
200
+ function duration(ms: number): string {
201
+ for (const [unit, size] of [["d", 86_400_000], ["h", 3_600_000], ["m", 60_000]] as const) if (ms % size === 0) return `${ms / size}${unit}`
202
+ return `${Math.round(ms / 1000)}s`
203
+ }
204
+
205
+ /** "@orchestrator requests a grant: holder ses_o; projects /p; verbs approve, deny; …. Why: …" (bounded). */
206
+ function question(by: string, spec: GrantSpec, holder: string, why: string): string {
207
+ const list = (values: readonly string[]) => (values.length ? values.join(", ") : "none")
208
+ const text = [
209
+ `${by} requests a grant: holder ${holder}`,
210
+ `projects ${list(spec.scope.projects)}`,
211
+ `verbs ${list(spec.allow)}`,
212
+ `permissions allow ${list(spec.permissions.allow)}`,
213
+ `never ${list(spec.permissions.never)}`,
214
+ `always ${spec.allowAlways ? "yes" : "no"}`,
215
+ `up to ${spec.maxActionsPerHour} actions an hour`,
216
+ `expires ${duration(spec.expiresInMs)} after approval`,
217
+ ].join("; ")
218
+ const tail = `. Why: ${why}`
219
+ const head = text.length + tail.length > QUESTION_MAX ? `${text.slice(0, Math.max(0, QUESTION_MAX - tail.length - 1))}…` : text
220
+ return `${head}${tail}`
221
+ }
@@ -0,0 +1,70 @@
1
+ // /api/agent/grants (design §5, §7): the user's grants. User actors only (allow {}): the device cookie (same-origin
2
+ // writes, checked by the agent framework) or a device token. A session or app token never creates, edits, reads
3
+ // or revokes a grant (escalation guard). Answers {grant} / {grants}, never cached; the owner is not echoed.
4
+ //
5
+ // POST /api/agent/grants create (201) GET /api/agent/grants list, newest first
6
+ // GET /api/agent/grants/:id one grant PUT /api/agent/grants/:id replace (holder fixed)
7
+ // DELETE /api/agent/grants/:id revoke (gone when already ended)
8
+ //
9
+ // Agents only request one (grant-requests.ts): POST /api/agent/grants/requests {grant, why} from a session (any of
10
+ // its tokens) or an app token with messages:send → 201 {message, decision} (a tap Decision for the user); the user
11
+ // gets invalid (create it directly).
12
+ import type { GrantRequests } from "./grant-requests"
13
+ import { type Grant, type Grants, grantState } from "./grants"
14
+ import type { AgentRoute } from "./routes"
15
+
16
+ // A literal: routes.ts imports this module (AGENT_PREFIX is not initialised yet when it loads).
17
+ const PREFIX = "/api/agent/grants"
18
+ const NO_STORE = { "cache-control": "no-store" }
19
+
20
+ /** A grant as the API shows it: without the owner, with its state now. */
21
+ export function grantView(grant: Grant, at: number) {
22
+ const { owner: _owner, ...rest } = grant
23
+ return { ...rest, state: grantState(grant, at) }
24
+ }
25
+
26
+ export function grantRoutes(grants: Grants, now: () => number = Date.now, requests?: GrantRequests): AgentRoute[] {
27
+ const one = (grant: Grant, status = 200) => Response.json({ grant: grantView(grant, now()) }, { status, headers: NO_STORE })
28
+ return [
29
+ ...(requests
30
+ ? [
31
+ {
32
+ method: "POST",
33
+ path: `${PREFIX}/requests`,
34
+ allow: { session: true, scope: "messages:send" },
35
+ handle: async ({ request, actor, user }) => Response.json(await requests.request(user.id, actor, await request.json()), { status: 201, headers: NO_STORE }),
36
+ } satisfies AgentRoute,
37
+ ]
38
+ : []),
39
+ {
40
+ method: "POST",
41
+ path: PREFIX,
42
+ allow: {},
43
+ handle: async ({ request, user }) => one(await grants.create(user.id, await request.json()), 201),
44
+ },
45
+ {
46
+ method: "GET",
47
+ path: PREFIX,
48
+ allow: {},
49
+ handle: async ({ user }) => Response.json({ grants: (await grants.list(user.id)).map((g) => grantView(g, now())) }, { headers: NO_STORE }),
50
+ },
51
+ {
52
+ method: "GET",
53
+ path: `${PREFIX}/:id`,
54
+ allow: {},
55
+ handle: async ({ params, user }) => one(await grants.get(user.id, params.id!)),
56
+ },
57
+ {
58
+ method: "PUT",
59
+ path: `${PREFIX}/:id`,
60
+ allow: {},
61
+ handle: async ({ params, request, user }) => one(await grants.update(user.id, params.id!, await request.json())),
62
+ },
63
+ {
64
+ method: "DELETE",
65
+ path: `${PREFIX}/:id`,
66
+ allow: {},
67
+ handle: async ({ params, user }) => one(await grants.revoke(user.id, params.id!)),
68
+ },
69
+ ]
70
+ }