@huynle/hub 0.7.1 → 0.7.3

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 (194) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/protocol/src/harness-events.ts +2 -2
  142. package/packages/protocol/src/index.ts +70 -7
  143. package/packages/runtime-openshell/package.json +1 -1
  144. package/packages/runtime-sdk/package.json +1 -1
  145. package/packages/sdk/package.json +1 -1
  146. package/packages/sdk/src/client.ts +55 -4
  147. package/packages/sdk/src/errors.ts +1 -0
  148. package/packages/sdk/src/sse.ts +4 -1
  149. package/packages/sdk/src/types.ts +59 -0
  150. package/packages/spoke-sdk/package.json +1 -1
  151. package/packages/spoke-sdk/src/enroll.ts +26 -4
  152. package/src/central/agent/app-tokens.ts +27 -17
  153. package/src/central/agent/archive-inactive.ts +66 -0
  154. package/src/central/agent/directory-routes.ts +155 -12
  155. package/src/central/agent/directory.ts +1 -1
  156. package/src/central/agent/errors.ts +2 -0
  157. package/src/central/agent/event-cursor.ts +49 -0
  158. package/src/central/agent/event-writers.ts +181 -0
  159. package/src/central/agent/events-routes.ts +148 -0
  160. package/src/central/agent/events.ts +277 -0
  161. package/src/central/agent/names.ts +1 -1
  162. package/src/central/agent/read-routes.ts +32 -36
  163. package/src/central/agent/routes.ts +14 -11
  164. package/src/central/agent/watch.ts +65 -174
  165. package/src/central/agent/webhook-routes.ts +10 -2
  166. package/src/central/agent/webhooks.ts +49 -5
  167. package/src/central/auth.ts +127 -1
  168. package/src/central/backend/index.ts +3 -0
  169. package/src/central/backend/types.ts +2 -0
  170. package/src/central/http.ts +116 -32
  171. package/src/central/mcp-http.ts +116 -0
  172. package/src/central/push.ts +14 -1
  173. package/src/central/registry.ts +34 -5
  174. package/src/central/store.ts +60 -0
  175. package/src/config.ts +70 -7
  176. package/src/index.ts +90 -91
  177. package/src/spoke/launchers.ts +109 -3
  178. package/src/spoke/spoke.ts +8 -4
  179. package/src/spoke/timeline.ts +59 -3
  180. package/src/spoke/tracker.ts +59 -6
  181. package/src/status.ts +97 -6
  182. package/web/dist/apple-touch-icon.png +0 -0
  183. package/web/dist/assets/index-Dm8OmkaO.css +1 -0
  184. package/web/dist/assets/index-FUJbj_Du.js +20 -0
  185. package/web/dist/favicon.ico +0 -0
  186. package/web/dist/icon-192.png +0 -0
  187. package/web/dist/icon-512.png +0 -0
  188. package/web/dist/icon-maskable-512.png +0 -0
  189. package/web/dist/icon.svg +5 -5
  190. package/web/dist/index.html +10 -4
  191. package/web/dist/manifest.webmanifest +2 -2
  192. package/web/dist/sw.js +4 -1
  193. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  194. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -1,220 +1,111 @@
1
- // GET /api/agent/watch (design §4): a lite SSE stream of compact JSON events about one user's sessions, for
2
- // orchestrator loops. Not the console's /api/events (cookie/device keyed, with replay and streamed text): this one
3
- // is per actor, has no replay and never carries text deltas.
1
+ // GET /api/agent/watch (design §4; lifecycle design "Events"): a live SSE view of the canonical agent event journal
2
+ // (events.ts), for orchestrator loops. The same events, visibility and filters as GET /api/agent/events, read with
3
+ // the same read() (events-routes.ts), so the two never define events differently. Not the console's /api/events.
4
4
  //
5
- // ?blocked=1 blocker events only
5
+ // ?blocked=1 blocker events only (blocker.*)
6
6
  // ?session=<addr> only that session's tree (an address resolved for the caller; several for a @@role)
7
+ // ?cursor=… or Last-Event-ID: resume after a cursor this stream (or GET /api/agent/events) gave; a bad one is
8
+ // 400 invalid_cursor before the stream starts
7
9
  //
8
- // Events (each `data:` line one JSON object with `type` and `at`):
9
- // {type: "ready"} first, once subscribed
10
- // {type: "state", sessionID, rootID, state, prev?} a session's own state changed
11
- // {type: "blocker.opened", sessionID, rootID, blocker: {kind, id, summary}}
12
- // {type: "blocker.closed", sessionID, rootID, id} answered, cancelled or gone
13
- // {type: "session.added", sessionID, title} / {type: "session.removed", sessionID} catalog changes
14
- // {type: "message", id, kind, state, from, to, sessionID?, senderID?, replyTo?, delivery?} an agent message was
15
- // stored (no text): sessionID is the recipient session, senderID the sending one. The user sees all of them,
16
- // a session or app (messages:read) only those it sent or received; ?session= matches either session.
17
- // {type: "launch", id, state, reason, outcome?, sessionID?, name?, labels} a launch changed (the user sees all of
18
- // theirs, an app or session those it started); ?session= matches its session.
10
+ // Frames (each `data:` line one JSON object; `id:` lines carry the resumable cursor):
11
+ // {type: "ready", at} first, once subscribed
12
+ // {id: "evt_…", at, type, subject, data} a journal event (no free text)
13
+ // {type: "reset", at, refresh: [...]} the cursor was no longer covered: re-read those
14
+ // event: ping every heartbeat while idle (default 20 s), with the cursor
19
15
  // When the hub drains (shutting down), the stream ends with `retry: 2000` (reconnect, to another pod, after it).
20
- // A `ping` event every heartbeat (default 20 s). Only status messages of the caller's tenant are read; catalog
21
- // changes are diffed against the caller's own sessions.
22
- import type { LiveStatus, RegistryMessage } from "../registry"
23
- import { partyKey } from "./envelope"
24
- import { addressOf, type Message } from "./messages"
25
- import type { Launch } from "./launch-store"
26
- import type { ReadRegistry } from "./read-routes"
16
+ import type { AgentActor } from "./actor"
17
+ import type { AgentEvent } from "./events"
18
+ import { read, RESET_REFRESH, type EventsPage, type EventsRouteDeps } from "./events-routes"
27
19
 
28
20
  export const WATCH_HEARTBEAT_MS = 20_000
29
21
  /** Draining: the `retry:` (ms) an ending watch stream sends. */
30
22
  export const WATCH_RETRY_MS = 2_000
31
23
  const RETRY = `retry: ${WATCH_RETRY_MS}\n\n`
32
24
  const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
33
-
34
- export interface WatchRegistry extends Pick<ReadRegistry, "catalog" | "sessionTenant"> {
35
- subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
36
- }
37
-
38
- /** Stored agent messages as they are sent (MessageFeed). */
39
- export interface MessageSource {
40
- subscribe(listener: (userID: string, message: Message) => void): () => void
41
- }
42
-
43
- /** Launch changes as the engine makes them (LaunchFeed). */
44
- export interface LaunchSource {
45
- subscribe(listener: (userID: string, launch: Launch) => void): () => void
46
- }
47
-
48
- /** The compact watch event of a launch change (never its prompt). */
49
- function launchEvent(launch: Launch): WatchEvent {
50
- return {
51
- type: "launch",
52
- id: launch.id,
53
- state: launch.state,
54
- reason: launch.reason,
55
- ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
56
- ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
57
- ...(launch.name !== undefined ? { name: launch.name } : {}),
58
- labels: launch.spec.labels,
59
- }
60
- }
25
+ const PAGE = 200
61
26
 
62
27
  export interface WatchOptions {
63
- readonly registry: WatchRegistry
28
+ readonly events: EventsRouteDeps
64
29
  readonly userID: string
30
+ readonly actor: AgentActor
65
31
  readonly signal: AbortSignal
66
- /** Only blocker events. */
67
- readonly blocked?: boolean
32
+ /** Resume after this cursor (none: from now). */
33
+ readonly cursor?: string
34
+ /** Only these event types (exact, or `blocker.*`). */
35
+ readonly types?: readonly string[]
68
36
  /** Only events of these sessions or of trees rooted at them. */
69
37
  readonly sessions?: ReadonlySet<string>
70
- /** Message events: from this feed, only those of the party key (undefined: all of the user's). */
71
- readonly messages?: { readonly feed: MessageSource; readonly party?: string }
72
- /** Launch events: from this feed, only those launched by `by` (undefined: all of the user's). */
73
- readonly launches?: { readonly feed: LaunchSource; readonly by?: string }
74
38
  readonly heartbeatMs?: number
75
39
  readonly now?: () => number
76
40
  /** Aborted when the hub drains: the stream ends with a `retry:` hint. */
77
41
  readonly draining?: AbortSignal
78
42
  }
79
43
 
80
- /** The compact watch event of a message (never its text). */
81
- function messageEvent(message: Message): WatchEvent {
82
- const { id, kind, state, from, recipient, replyTo, delivery } = message
83
- return {
84
- type: "message",
85
- id,
86
- kind,
87
- state,
88
- from: addressOf(from),
89
- to: message.to,
90
- ...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
91
- ...(from.kind === "session" ? { senderID: from.sessionID } : {}),
92
- ...(replyTo ? { replyTo } : {}),
93
- ...(delivery ? { delivery } : {}),
94
- }
95
- }
96
-
97
- interface Known {
98
- state: string
99
- blockers: Set<string>
100
- }
101
-
102
- type WatchEvent = { type: string; sessionID?: string; rootID?: string; senderID?: string } & Record<string, unknown>
103
-
104
- export function watchStream(options: WatchOptions): Response {
44
+ /** The watch stream; throws invalid_cursor (before streaming) for a cursor this hub did not give this user. */
45
+ export async function watchStream(options: WatchOptions): Promise<Response> {
105
46
  if (options.draining?.aborted) return new Response(RETRY, { headers: SSE_HEADERS })
106
- const { registry, userID } = options
47
+ const { userID, actor } = options
107
48
  const now = options.now ?? Date.now
49
+ const heartbeatMs = options.heartbeatMs ?? WATCH_HEARTBEAT_MS
50
+ const filters = { limit: PAGE, types: options.types ?? [], ...(options.sessions ? { sessions: options.sessions } : {}) }
51
+ const first = await read(options.events, userID, actor, { ...filters, ...(options.cursor ? { cursor: options.cursor } : {}), wait: 0 })
108
52
  const encoder = new TextEncoder()
109
- /** Per session (roots and subagents): its own state and open blocker IDs. */
110
- const known = new Map<string, Known>()
111
- /** Catalog sessions of the user, ID → title. */
112
- let catalog = new Map<string, string>()
53
+ const stop = new AbortController()
113
54
 
114
- const own = () => {
115
- const out = new Map<string, { title: string; status: LiveStatus[] }>()
116
- for (const machine of registry.catalog()) {
117
- for (const process of machine.processes) {
118
- for (const s of process.sessions) {
119
- if (!out.has(s.id) && registry.sessionTenant(s.id) === userID) out.set(s.id, { title: s.title, status: s.status })
120
- }
121
- }
122
- }
123
- return out
124
- }
125
- const remember = (entry: LiveStatus) => known.set(entry.id, { state: entry.state, blockers: new Set(entry.blockers.map((b) => b.id)) })
126
- const seed = () => {
127
- const sessions = own()
128
- for (const { status } of sessions.values()) for (const entry of status) if (!known.has(entry.id)) remember(entry)
129
- return sessions
130
- }
131
- catalog = new Map([...seed()].map(([id, s]) => [id, s.title]))
132
-
133
- const wanted = (event: WatchEvent) => {
134
- if (options.blocked && !event.type.startsWith("blocker.")) return false
135
- if (!options.sessions) return true
136
- return [event.sessionID, event.rootID, event.senderID].some((id) => id !== undefined && options.sessions!.has(id))
137
- }
138
-
139
- /** Events for a status tree (root first), against what was known. */
140
- const statusEvents = (rootID: string, sessions: readonly LiveStatus[]): WatchEvent[] => {
141
- const out: WatchEvent[] = []
142
- for (const entry of sessions) {
143
- const before = known.get(entry.id)
144
- const open = new Set(entry.blockers.map((b) => b.id))
145
- for (const b of entry.blockers) {
146
- if (!before?.blockers.has(b.id)) out.push({ type: "blocker.opened", sessionID: entry.id, rootID, blocker: { kind: b.kind, id: b.id, summary: b.summary } })
147
- }
148
- for (const id of before?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", sessionID: entry.id, rootID, id })
149
- if (before?.state !== entry.state) out.push({ type: "state", sessionID: entry.id, rootID, state: entry.state, ...(before ? { prev: before.state } : {}) })
150
- remember(entry)
151
- }
152
- return out
153
- }
154
-
155
- const catalogEvents = (): WatchEvent[] => {
156
- const sessions = seed()
157
- const out: WatchEvent[] = []
158
- for (const [id, s] of sessions) if (!catalog.has(id)) out.push({ type: "session.added", sessionID: id, title: s.title })
159
- for (const id of catalog.keys()) if (!sessions.has(id)) out.push({ type: "session.removed", sessionID: id })
160
- catalog = new Map([...sessions].map(([id, s]) => [id, s.title]))
161
- return out
162
- }
163
-
164
- let cleanup = () => {}
165
55
  const stream = new ReadableStream<Uint8Array>({
166
- start(controller) {
56
+ async start(controller) {
57
+ let open = true
58
+ const close = () => {
59
+ if (!open) return
60
+ open = false
61
+ stop.abort()
62
+ try {
63
+ controller.close()
64
+ } catch {}
65
+ }
167
66
  const write = (chunk: string) => {
67
+ if (!open) return
168
68
  try {
169
69
  controller.enqueue(encoder.encode(chunk))
170
70
  } catch {
171
- cleanup()
71
+ close()
172
72
  }
173
73
  }
174
- const send = (event: WatchEvent) => write(`data: ${JSON.stringify({ ...event, at: now() })}\n\n`)
175
- const unsubscribe = registry.subscribe((message, tenant) => {
176
- let events: WatchEvent[] = []
177
- if (message.type === "catalog") events = catalogEvents()
178
- else if (message.type === "status" && tenant === userID) events = statusEvents(message.sessionID, message.sessions)
179
- for (const event of events) if (wanted(event)) send(event)
180
- })
181
- const messages = options.messages
182
- const unsubscribeMessages = messages?.feed.subscribe((user, message) => {
183
- if (user !== userID) return
184
- if (messages.party !== undefined && partyKey(message.from) !== messages.party && partyKey(message.recipient) !== messages.party) return
185
- const event = messageEvent(message)
186
- if (wanted(event)) send(event)
187
- })
188
- const launches = options.launches
189
- const unsubscribeLaunches = launches?.feed.subscribe((user, launch) => {
190
- if (user !== userID || (launches.by !== undefined && launch.by !== launches.by)) return
191
- const event = launchEvent(launch)
192
- if (wanted(event)) send(event)
193
- })
194
- // A named event (not a comment) so clients can tell a live stream from a dead one.
195
- const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), options.heartbeatMs ?? WATCH_HEARTBEAT_MS)
196
- cleanup = () => {
197
- clearInterval(heartbeat)
198
- unsubscribe()
199
- unsubscribeMessages?.()
200
- unsubscribeLaunches?.()
74
+ const frame = (data: object, id?: string) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(data)}\n\n`)
75
+ const emit = (page: EventsPage) => {
76
+ if (page.reset) return frame({ type: "reset", at: now(), refresh: page.refresh ?? RESET_REFRESH }, page.cursor)
77
+ if (page.events.length === 0) return write(`event: ping\nid: ${page.cursor}\ndata: 0\n\n`)
78
+ page.events.forEach((event: AgentEvent, i) => frame(event, i === page.events.length - 1 ? page.cursor : undefined))
201
79
  }
202
- options.signal.addEventListener("abort", () => cleanup(), { once: true })
203
- send({ type: "ready" })
80
+ options.signal.addEventListener("abort", () => close(), { once: true })
204
81
  options.draining?.addEventListener(
205
82
  "abort",
206
83
  () => {
207
84
  write(RETRY)
208
- cleanup()
209
- try {
210
- controller.close()
211
- } catch {}
85
+ close()
212
86
  },
213
87
  { once: true },
214
88
  )
89
+ frame({ type: "ready", at: now() }, options.cursor && !first.reset ? options.cursor : first.cursor)
90
+ if (first.reset || first.events.length) emit(first)
91
+ let cursor = first.cursor
92
+ while (open && !stop.signal.aborted) {
93
+ let page: EventsPage
94
+ try {
95
+ page = await read(options.events, userID, actor, { ...filters, cursor, wait: heartbeatMs / 1000 }, stop.signal)
96
+ } catch (cause) {
97
+ console.error(`[hub] agent watch read failed: ${(cause as Error).message}`)
98
+ write(RETRY)
99
+ return close()
100
+ }
101
+ if (stop.signal.aborted) break
102
+ emit(page)
103
+ cursor = page.cursor
104
+ }
105
+ close()
215
106
  },
216
107
  cancel() {
217
- cleanup()
108
+ stop.abort()
218
109
  },
219
110
  })
220
111
  return new Response(stream, { headers: SSE_HEADERS })
@@ -13,13 +13,13 @@ import type { Store } from "../store"
13
13
  import type { AgentActor } from "./actor"
14
14
  import { AgentError } from "./errors"
15
15
  import type { AgentRoute } from "./routes"
16
- import type { WebhookOwner, Webhooks } from "./webhooks"
16
+ import { webhookEventScopes, type WebhookOwner, type Webhooks } from "./webhooks"
17
17
 
18
18
  // A literal: routes.ts imports this module.
19
19
  const PATH = "/api/agent/webhooks"
20
20
  const NO_STORE = { "cache-control": "no-store" }
21
21
  /** Users; apps with sessions:launch. */
22
- export const WEBHOOK_SCOPE = "sessions:launch"
22
+ export const WEBHOOK_SCOPE = "events:subscribe"
23
23
  const ALLOW = { scope: WEBHOOK_SCOPE } as const
24
24
 
25
25
  export type RouteWebhooks = Pick<Webhooks, "register" | "list" | "get" | "remove" | "deliveries">
@@ -29,6 +29,13 @@ const ownerOf = (actor: AgentActor): WebhookOwner | undefined => (actor.kind ===
29
29
 
30
30
  const notFound = (id: string) => new AgentError("not_found", `No webhook ${id}`)
31
31
 
32
+ const requireEventScopes = (actor: AgentActor, events: unknown) => {
33
+ if (actor.kind !== "app") return
34
+ const patterns = events === undefined ? ["launch.*"] : Array.isArray(events) && events.every((e) => typeof e === "string") ? events : []
35
+ const missing = webhookEventScopes(patterns).filter((scope) => !actor.scopes.includes(scope))
36
+ if (missing.length) throw new AgentError("forbidden", `Webhook events need scope ${missing.join(", ")}`)
37
+ }
38
+
32
39
  async function bodyOf(request: Request): Promise<Record<string, unknown>> {
33
40
  const raw = JSON.parse(await request.text()) as unknown
34
41
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
@@ -43,6 +50,7 @@ export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audi
43
50
  allow: ALLOW,
44
51
  async handle({ request, actor, user }) {
45
52
  const input = await bodyOf(request)
53
+ requireEventScopes(actor, input.events)
46
54
  const result = await webhooks.register(user.id, ownerOf(actor) ?? "user", { url: input.url, events: input.events, label: input.label })
47
55
  await store?.audit({ actor: user.id, action: "webhook.create", target: result.webhook.id, detail: new URL(result.webhook.url).host })
48
56
  return Response.json(result, { status: 201, headers: NO_STORE })
@@ -27,12 +27,20 @@ import {
27
27
  import type { Clock, Locks, StateStore, Timers } from "../backend/types"
28
28
  import { publicFetch, PublicURLError, publicURLProblem, type ResolveHost } from "../push-target"
29
29
  import { AgentError } from "./errors"
30
+ import type { AgentEvent, EventAudience, EventFamily } from "./events"
30
31
  import { LAUNCH_STATES, type Launch } from "./launch-store"
31
32
 
32
33
  export const WEBHOOKS = "webhooks"
33
34
  export const WEBHOOK_OUTBOX = "webhook-outbox"
34
35
  /** Every event type a webhook can receive (phase 4: launch changes). */
35
- export const WEBHOOK_EVENT_TYPES: readonly string[] = LAUNCH_STATES.map((state) => `launch.${state}`)
36
+ export const CANONICAL_WEBHOOK_EVENT_TYPES: readonly string[] = [
37
+ "session.added", "session.removed", "session.changed", "session.archived", "session.named",
38
+ "session.pinned",
39
+ "blocker.opened", "blocker.closed", "machine.changed", "role.claimed", "role.released",
40
+ "message.changed", "launch.changed",
41
+ ]
42
+ /** Includes persisted pre-canonical launch registrations for rolling rollback compatibility. */
43
+ export const WEBHOOK_EVENT_TYPES: readonly string[] = [...CANONICAL_WEBHOOK_EVENT_TYPES, ...LAUNCH_STATES.map((state) => `launch.${state}`)]
36
44
  export const DEFAULT_WEBHOOK_EVENTS: readonly string[] = ["launch.*"]
37
45
  export const MAX_WEBHOOKS = 20
38
46
  export const MAX_WEBHOOK_URL = 2048
@@ -133,6 +141,8 @@ export interface WebhooksDeps {
133
141
  readonly fetch?: typeof globalThis.fetch
134
142
  /** Whether an app token (by its id) may still have webhooks; its registrations are dropped when not. */
135
143
  readonly appAlive?: (userID: string, tokenID: string) => boolean
144
+ /** Current scopes of an app token; absent preserves the legacy liveness-only check. */
145
+ readonly appScopes?: (userID: string, tokenID: string) => readonly string[] | undefined
136
146
  readonly backoffMs?: readonly number[]
137
147
  readonly timeoutMs?: number
138
148
  readonly maxResponseBytes?: number
@@ -165,14 +175,30 @@ function matches(pattern: string, type: string): boolean {
165
175
  function eventsOf(raw: unknown): string[] {
166
176
  if (raw === undefined) return [...DEFAULT_WEBHOOK_EVENTS]
167
177
  if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_WEBHOOK_EVENTS || !raw.every((e): e is string => typeof e === "string")) {
168
- throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*"]`)
178
+ throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*", "message.changed"]`)
169
179
  }
170
180
  for (const pattern of raw) {
171
- if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`, `Use launch.* or one of ${WEBHOOK_EVENT_TYPES.join(", ")}`)
181
+ if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`, "Use a canonical event type or family wildcard")
172
182
  }
173
183
  return [...new Set(raw)]
174
184
  }
175
185
 
186
+ export function webhookEventScopes(patterns: readonly string[]): string[] {
187
+ const scopes = new Set<string>()
188
+ if (patterns.some((pattern) => LAUNCH_STATES.some((state) => matches(pattern, `launch.${state}`)))) scopes.add("sessions:launch")
189
+ for (const pattern of patterns) for (const type of CANONICAL_WEBHOOK_EVENT_TYPES) if (matches(pattern, type)) {
190
+ if (type === "message.changed") scopes.add("messages:read")
191
+ else if (type === "launch.changed") scopes.add("sessions:launch")
192
+ else if (["session.archived", "session.named", "session.pinned", "role.claimed", "role.released"].includes(type)) scopes.add("sessions:manage")
193
+ else scopes.add("sessions:read")
194
+ }
195
+ return [...scopes]
196
+ }
197
+
198
+ export function canonicalWebhookEvent(source: string, userID: string, event: AgentEvent): WebhookEvent {
199
+ return { id: event.id, type: event.type, source, subject: event.subject.launchID ?? event.subject.messageID ?? event.subject.sessionID ?? event.subject.machineID ?? event.id, time: new Date(event.at).toISOString(), user: userID, schemaVersion: 1, data: { subject: event.subject, data: event.data } }
200
+ }
201
+
176
202
  const view = ({ secret: _secret, ...rest }: Registration): WebhookView => rest
177
203
 
178
204
  const deliveryView = (entry: OutboxEntry): DeliveryView => ({
@@ -286,8 +312,9 @@ export class Webhooks {
286
312
  * only when `by` (who caused it) is that app. Resolves with how many deliveries were newly queued (an event
287
313
  * already queued for a registration is not queued again).
288
314
  */
289
- async publish(userID: string, event: WebhookEvent, options: { readonly by?: string } = {}): Promise<number> {
290
- const targets = (await this.registrations(userID)).filter((r) => (r.by === "user" || r.by === options.by) && r.events.some((p) => matches(p, event.type)))
315
+ async publish(userID: string, event: WebhookEvent, options: { readonly by?: string; readonly owners?: readonly string[]; readonly matchTypes?: readonly string[] } = {}): Promise<number> {
316
+ const matchTypes = options.matchTypes ?? [event.type]
317
+ const targets = (await this.registrations(userID)).filter((r) => (r.by === "user" || r.by === options.by || options.owners?.includes(r.by)) && r.events.some((p) => matchTypes.some((type) => matches(p, type))))
291
318
  if (!targets.length) return 0
292
319
  const max = this.deps.maxOutbox ?? WEBHOOK_OUTBOX_MAX
293
320
  let size = (await this.deps.state.list(userID, WEBHOOK_OUTBOX)).length
@@ -308,6 +335,15 @@ export class Webhooks {
308
335
  return queued
309
336
  }
310
337
 
338
+ publishCanonical(userID: string, event: AgentEvent, _family: EventFamily, audience?: EventAudience): Promise<number> {
339
+ const legacyLaunchType = event.type === "launch.changed" && typeof event.data.state === "string" ? `launch.${event.data.state}` : undefined
340
+ return this.publish(userID, canonicalWebhookEvent(this.deps.source, userID, event), {
341
+ ...(audience?.by ? { by: audience.by } : {}),
342
+ ...(audience?.parties ? { owners: audience.parties.filter((party) => party.startsWith("app:")) } : {}),
343
+ ...(legacyLaunchType ? { matchTypes: [event.type, legacyLaunchType] } : {}),
344
+ })
345
+ }
346
+
311
347
  /** A launch change (the LaunchFeed): `data` is the launch as the API shows it. */
312
348
  async onLaunch(userID: string, launch: Launch, data: unknown): Promise<number> {
313
349
  return this.publish(userID, launchEvent(this.deps.source, launch, data), launch.by !== undefined ? { by: launch.by } : {})
@@ -401,6 +437,14 @@ export class Webhooks {
401
437
  await this.remove(userID, registration.id)
402
438
  return
403
439
  }
440
+ if (registration.by !== "user" && this.deps.appScopes) {
441
+ const scopes = this.deps.appScopes(userID, registration.by.slice("app:".length))
442
+ const required = ["events:subscribe", ...webhookEventScopes(registration.events)]
443
+ if (!scopes || required.some((scope) => !scopes.includes(scope))) {
444
+ await this.remove(userID, registration.id)
445
+ return
446
+ }
447
+ }
404
448
  const { last, final } = await this.send(registration, entry, now, timeoutMs)
405
449
  const attempts = entry.attempts + 1
406
450
  const delivered = last.status !== undefined && last.status >= 200 && last.status < 300
@@ -3,11 +3,14 @@
3
3
  // - device pairing: short single-use numeric codes, shown in the /hub dialog or logged at startup
4
4
  // - login links: long single-use codes opened from the terminal to sign a browser in
5
5
  // - app link codes: long single-use codes (QR / hubapp:// deep link) a native app redeems for a device token
6
+ // - CLI login (device-code flow, `hub login`): a terminal asks, a signed-in user approves it as themselves with the
7
+ // scopes they choose, the terminal polls for the decision (the caller then issues an app token for that user)
6
8
  // Pending codes, enrollments and the limiters live in the Kv / Counters backends (process memory by default, so a
7
9
  // restart invalidates them; Valkey in a cluster, so a code issued on one pod is redeemed on any). Codes are spent
8
10
  // with GETDEL: exactly one redeem wins, on any pod.
9
11
  import { randomBytes, randomInt } from "node:crypto"
10
12
  import type { EnrollPoll, EnrollStart } from "../protocol"
13
+ import { APP_SCOPES, DEFAULT_CLI_LOGIN_SCOPES } from "./agent/app-tokens"
11
14
  import { MemoryCounters } from "./backend/memory-counters"
12
15
  import { MemoryKv } from "./backend/memory-kv"
13
16
  import type { Counters, Kv } from "./backend/types"
@@ -32,6 +35,7 @@ export const ENROLL_INTERVAL_MS = 2000
32
35
  export const PAIR_TTL_MS = 5 * 60_000
33
36
  export const LOGIN_TTL_MS = 2 * 60_000
34
37
  export const LINK_TTL_MS = 5 * 60_000
38
+ export const CLI_LOGIN_TTL_MS = 10 * 60_000
35
39
  /** Longest device public key accepted at link time (characters). */
36
40
  export const MAX_PUBLIC_KEY = 4096
37
41
 
@@ -63,6 +67,15 @@ function deviceLabel(value: unknown, fallback: string): string {
63
67
  return text ? [...text].slice(0, 64).join("").trim() : fallback
64
68
  }
65
69
 
70
+ /** App token scopes from a client: a list of known scopes (deduplicated, in order); absent is none when `optional`. */
71
+ function scopeList(value: unknown, optional: boolean): string[] {
72
+ if (value === undefined && optional) return []
73
+ if (!Array.isArray(value) || !value.every((s): s is string => typeof s === "string")) throw new ValidationError("scopes must be a list of scope names")
74
+ const unknown = value.find((s) => !APP_SCOPES.includes(s))
75
+ if (unknown !== undefined) throw new ValidationError(`Unknown scope: ${unknown.slice(0, 64)}`)
76
+ return [...new Set(value)]
77
+ }
78
+
66
79
  const PEM = /^-----BEGIN PUBLIC KEY-----([\s\S]*)-----END PUBLIC KEY-----$/
67
80
  const KEY_BODY = /^[A-Za-z0-9+/_-]{16,}={0,2}$/
68
81
 
@@ -87,6 +100,45 @@ interface Enrollment {
87
100
  readonly ownerID?: string
88
101
  }
89
102
 
103
+ interface CliLogin {
104
+ readonly userCode: string
105
+ readonly deviceCode: string
106
+ readonly clientName: string
107
+ /** The scopes the terminal asked for (shown on the approval page, ticked by default). */
108
+ readonly scopes: readonly string[]
109
+ readonly created: number
110
+ readonly expiresAt: number
111
+ readonly status: "pending" | "approved" | "denied"
112
+ /** The approver: the user the app token is issued for. */
113
+ readonly ownerID?: string
114
+ /** The scopes the approver chose. */
115
+ readonly approved?: readonly string[]
116
+ }
117
+
118
+ /** POST /cli/login → a code to approve in the console (never a credential). */
119
+ export interface CliLoginStart {
120
+ readonly userCode: string
121
+ readonly deviceCode: string
122
+ readonly approveURL: string
123
+ readonly intervalMs: number
124
+ readonly expiresAt: number
125
+ readonly scopes: readonly string[]
126
+ }
127
+
128
+ /** A pending CLI login as the approval page shows it. */
129
+ export interface PendingCliLogin {
130
+ readonly userCode: string
131
+ readonly clientName: string
132
+ readonly scopes: readonly string[]
133
+ readonly created: number
134
+ readonly expiresAt: number
135
+ }
136
+
137
+ /** The decision on a CLI login, delivered once. `approved` carries who approved it and the scopes they chose. */
138
+ export type CliLoginPoll =
139
+ | { readonly status: "pending" | "denied" | "expired" }
140
+ | { readonly status: "approved"; readonly ownerID: string; readonly clientName: string; readonly scopes: readonly string[] }
141
+
90
142
  /** A pending pairing code, login link or app link code and the user its device will belong to. */
91
143
  interface Grant {
92
144
  readonly expiresAt: number
@@ -135,6 +187,10 @@ const KEYS = {
135
187
  enrollSlot: (slot: number) => `auth:enroll-slot:${slot}`,
136
188
  enrollSeq: "auth:enroll-seq",
137
189
  enrollRate: "auth:enroll-rate",
190
+ cliLogin: (deviceCode: string) => `auth:cli-login:${deviceCode}`,
191
+ cliLoginCode: (userCode: string) => `auth:cli-login-code:${userCode}`,
192
+ cliLoginDecided: (deviceCode: string) => `auth:cli-login-decided:${deviceCode}`,
193
+ cliLoginRate: "auth:cli-login-rate",
138
194
  pairFailures: "auth:pair-failures",
139
195
  code: (kind: CodeKind, code: string) => `auth:${kind}:${code}`,
140
196
  }
@@ -181,7 +237,8 @@ export class Auth {
181
237
  const px = ENROLL_TTL_MS + 1
182
238
  const deviceCode = newToken()
183
239
  let code = userCode()
184
- while (!(await this.kv.set(KEYS.enrollCode(code), deviceCode, { px, nx: true }))) code = userCode()
240
+ // A user code names one request: never one a CLI login holds.
241
+ while ((await this.kv.get(KEYS.cliLoginCode(code))) !== undefined || !(await this.kv.set(KEYS.enrollCode(code), deviceCode, { px, nx: true }))) code = userCode()
185
242
  const enrollment: Enrollment = { userCode: code, deviceCode, machineName: machine, created, expiresAt, status: "pending" }
186
243
  await this.kv.set(KEYS.enroll(deviceCode), JSON.stringify(enrollment), { px })
187
244
  const slot = await this.counters.incr(KEYS.enrollSeq, ENROLL_SEQ_TTL_MS)
@@ -256,6 +313,75 @@ export class Auth {
256
313
  return expiresAt - this.now() + 1
257
314
  }
258
315
 
316
+ // ---------- CLI login ----------
317
+
318
+ /**
319
+ * Starts a CLI login for `clientName` (the terminal's machine name), asking for the default scopes plus
320
+ * `scopes`. Rate limited like enrollment. The device code is the poller's secret; the user code is what the
321
+ * approver confirms.
322
+ */
323
+ async startCliLogin(clientName: unknown, scopes: unknown): Promise<CliLoginStart> {
324
+ const client = name(clientName, "client name")
325
+ const requested = [...new Set([...DEFAULT_CLI_LOGIN_SCOPES, ...scopeList(scopes, true)])]
326
+ if ((await this.counters.incr(KEYS.cliLoginRate, ENROLL_WINDOW_MS)) > ENROLL_MAX) throw new RateLimitError("Too many login requests; try again in a minute")
327
+ const created = this.now()
328
+ const expiresAt = created + CLI_LOGIN_TTL_MS
329
+ const px = CLI_LOGIN_TTL_MS + 1
330
+ const deviceCode = newToken()
331
+ let code = userCode()
332
+ while ((await this.kv.get(KEYS.enrollCode(code))) !== undefined || !(await this.kv.set(KEYS.cliLoginCode(code), deviceCode, { px, nx: true }))) code = userCode()
333
+ const login: CliLogin = { userCode: code, deviceCode, clientName: client, scopes: requested, created, expiresAt, status: "pending" }
334
+ await this.kv.set(KEYS.cliLogin(deviceCode), JSON.stringify(login), { px })
335
+ return { userCode: code, deviceCode, approveURL: `${this.publicURL}/approve?code=${code}`, intervalMs: ENROLL_INTERVAL_MS, expiresAt, scopes: requested }
336
+ }
337
+
338
+ /** The pending CLI login of a user code, for the approval page; undefined once decided or expired. */
339
+ async cliLogin(code: string): Promise<PendingCliLogin | undefined> {
340
+ const login = await this.cliLoginOf(code)
341
+ if (!login || login.status !== "pending") return undefined
342
+ const { userCode, clientName, scopes, created, expiresAt } = login
343
+ return { userCode, clientName, scopes: [...scopes], created, expiresAt }
344
+ }
345
+
346
+ /**
347
+ * Approves (as `ownerID`, with the `scopes` they chose: at least one known scope) or denies a pending CLI login:
348
+ * its client name, or undefined when no such code is pending. The first decision wins (SET NX), on any pod.
349
+ */
350
+ async decideCliLogin(code: string, status: "approved" | "denied", ownerID: string, scopes?: unknown): Promise<string | undefined> {
351
+ const approved = status === "approved" ? scopeList(scopes, false) : undefined
352
+ if (approved && approved.length === 0) throw new ValidationError("Choose at least one scope")
353
+ const login = await this.cliLoginOf(code)
354
+ if (!login || login.status !== "pending") return undefined
355
+ const px = this.remaining(login.expiresAt)
356
+ if (!(await this.kv.set(KEYS.cliLoginDecided(login.deviceCode), status, { px, nx: true }))) return undefined
357
+ const decided: CliLogin = { ...login, status, ownerID, ...(approved ? { approved } : {}) }
358
+ await this.kv.set(KEYS.cliLogin(login.deviceCode), JSON.stringify(decided), { px })
359
+ return login.clientName
360
+ }
361
+
362
+ /** The decision on a CLI login, delivered once (GETDEL); afterwards the code reads as expired. */
363
+ async pollCliLogin(deviceCode: unknown): Promise<CliLoginPoll> {
364
+ const login = typeof deviceCode === "string" && deviceCode ? await this.pendingLogin(deviceCode) : undefined
365
+ if (!login) return { status: "expired" }
366
+ if (login.status === "pending") return { status: "pending" }
367
+ const taken = parse<CliLogin>(await this.kv.getdel(KEYS.cliLogin(login.deviceCode)))
368
+ if (!taken || taken.status === "pending") return { status: "expired" }
369
+ await this.kv.del(KEYS.cliLoginCode(taken.userCode))
370
+ if (taken.status === "denied" || !taken.ownerID || !taken.approved) return { status: "denied" }
371
+ return { status: "approved", ownerID: taken.ownerID, clientName: taken.clientName, scopes: [...taken.approved] }
372
+ }
373
+
374
+ private async cliLoginOf(code: string): Promise<CliLogin | undefined> {
375
+ const deviceCode = await this.kv.get(KEYS.cliLoginCode(normalizeUserCode(code)))
376
+ return deviceCode === undefined ? undefined : await this.pendingLogin(deviceCode)
377
+ }
378
+
379
+ /** The CLI login of a device code, unless it expired. */
380
+ private async pendingLogin(deviceCode: string): Promise<CliLogin | undefined> {
381
+ const login = parse<CliLogin>(await this.kv.get(KEYS.cliLogin(deviceCode)))
382
+ return login && this.remaining(login.expiresAt) > 0 ? login : undefined
383
+ }
384
+
259
385
  // ---------- device pairing ----------
260
386
 
261
387
  /** A pairing code; the device it pairs belongs to `ownerID`. */
@@ -6,6 +6,7 @@ import { FileStateStore, type FileStateStoreOptions } from "./file-state"
6
6
  import { MemoryBus } from "./memory-bus"
7
7
  import { MemoryCatalog } from "./memory-catalog"
8
8
  import { MemoryCounters, MemoryLocks } from "./memory-counters"
9
+ import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX } from "../agent/events"
9
10
  import { MemoryEventLog } from "./memory-events"
10
11
  import { MemoryKv } from "./memory-kv"
11
12
  import { MemoryStateStore } from "./memory-state"
@@ -37,6 +38,7 @@ export function memoryBackends(options: MemoryBackendsOptions = {}): Backends {
37
38
  state: new MemoryStateStore({ now }),
38
39
  bus: new MemoryBus(options.timers ? { timers: options.timers } : {}),
39
40
  events: new MemoryEventLog({ now }),
41
+ agentEvents: new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
40
42
  counters: new MemoryCounters({ now }),
41
43
  locks: new MemoryLocks({ now }),
42
44
  catalog: new MemoryCatalog({ now }),
@@ -95,6 +97,7 @@ export function clusterBackends(options: ClusterBackendsOptions): Backends {
95
97
  state,
96
98
  bus,
97
99
  events: new ValkeyEventLog(shared, now ? { now } : {}),
100
+ agentEvents: new ValkeyEventLog(shared, { ...(now ? { now } : {}), max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS, prefix: "hub:agent:" }),
98
101
  counters: new ValkeyCounters(shared),
99
102
  locks: new ValkeyLocks(shared, now ? { now } : {}),
100
103
  catalog: new ValkeyCatalog(shared, now ? { now } : {}),
@@ -235,6 +235,8 @@ export interface Backends {
235
235
  readonly state: StateStore
236
236
  readonly bus: Bus
237
237
  readonly events: EventLog
238
+ /** The canonical agent event journal's log (24 h / 10,000 per user); the hub makes a memory one when absent. */
239
+ readonly agentEvents?: EventLog
238
240
  readonly counters: Counters
239
241
  readonly locks: Locks
240
242
  readonly catalog: Catalog