@huynle/hub 0.6.1 → 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,449 @@
1
+ // Webhooks (P4-T10; design §6.1, §12.2, §12.4; decision znbg8xa5 #8): the hub POSTs events to URLs that users and
2
+ // app tokens register.
3
+ //
4
+ // - Registrations per user in the StateStore collection "webhooks" (key: the id `wh_<16 hex>`): url, events
5
+ // (exact types or `launch.*`), an optional label, `by` (`user`, or `app:<token id>` for an app token's own) and
6
+ // the signing secret `whsec_…`, returned only by register. Users see and manage all of theirs; an app token only
7
+ // its own. An app registration gets only events of what that app started (`by` of the event), and is dropped
8
+ // once its token is revoked or loses the scope (appAlive).
9
+ // - URLs: https on a public host (publicURLProblem), checked at registration and again, re-resolved, right before
10
+ // every send (publicFetch: no redirects, timeout, bounded response read). hub.json agents.webhooks.allowPrivate
11
+ // admits http and private targets (self-hosted receivers).
12
+ // - Durable outbox in the StateStore collection "webhook-outbox", key `<webhook id>.<event id>`: at least once,
13
+ // deduplicated by event id (the first write wins, ifVersion 0), retried with backoff (backoffMs; then failed),
14
+ // one sender per delivery under a lease (Locks), so several hubs can drain the same store. Finished
15
+ // deliveries stay keepMs (GET …/:id shows them), then are pruned.
16
+ // - Each POST: the event JSON, Hub-Signature `t=<unix s>,v1=<hex HMAC-SHA256 of "<t>.<body>">`, Hub-Event (its
17
+ // type) and Hub-Delivery (stable across retries), per packages/protocol/src/webhook.ts.
18
+ import { createHash, createHmac, randomBytes } from "node:crypto"
19
+ import {
20
+ formatWebhookSignature,
21
+ WEBHOOK_DELIVERY_HEADER,
22
+ WEBHOOK_EVENT_HEADER,
23
+ WEBHOOK_SIGNATURE_ALGORITHM,
24
+ WEBHOOK_SIGNATURE_HEADER,
25
+ webhookSignedPayload,
26
+ } from "../../protocol"
27
+ import type { Clock, Locks, StateStore, Timers } from "../backend/types"
28
+ import { publicFetch, PublicURLError, publicURLProblem, type ResolveHost } from "../push-target"
29
+ import { AgentError } from "./errors"
30
+ import { LAUNCH_STATES, type Launch } from "./launch-store"
31
+
32
+ export const WEBHOOKS = "webhooks"
33
+ export const WEBHOOK_OUTBOX = "webhook-outbox"
34
+ /** 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 DEFAULT_WEBHOOK_EVENTS: readonly string[] = ["launch.*"]
37
+ export const MAX_WEBHOOKS = 20
38
+ export const MAX_WEBHOOK_URL = 2048
39
+ export const MAX_WEBHOOK_LABEL = 100
40
+ export const MAX_WEBHOOK_EVENTS = 20
41
+ /** Waits between attempts: 7 attempts over about 8.5 hours. */
42
+ export const WEBHOOK_BACKOFF_MS: readonly number[] = [10_000, 60_000, 300_000, 1_800_000, 7_200_000, 21_600_000]
43
+ export const WEBHOOK_TIMEOUT_MS = 10_000
44
+ /** Bytes of a receiver's response the hub reads. */
45
+ export const WEBHOOK_RESPONSE_MAX = 64 * 1024
46
+ export const WEBHOOK_KEEP_MS = 86_400_000
47
+ /** Deliveries per user in the outbox; past it new events are dropped (logged). */
48
+ export const WEBHOOK_OUTBOX_MAX = 5000
49
+ const POLL_MS = 5000
50
+ const CONCURRENCY = 8
51
+
52
+ /** An event as webhooks carry it (CloudEvents-shaped, design §12.2). */
53
+ export interface WebhookEvent {
54
+ readonly id: string
55
+ readonly type: string
56
+ /** The hub's public URL. */
57
+ readonly source: string
58
+ readonly subject: string
59
+ /** ISO 8601. */
60
+ readonly time: string
61
+ readonly user: string
62
+ readonly schemaVersion: 1
63
+ readonly data: unknown
64
+ }
65
+
66
+ /** Who a registration belongs to: the user, or one of their app tokens. */
67
+ export type WebhookOwner = "user" | `app:${string}`
68
+
69
+ interface Registration {
70
+ readonly id: string
71
+ readonly url: string
72
+ readonly events: readonly string[]
73
+ readonly by: WebhookOwner
74
+ readonly label?: string
75
+ readonly created: number
76
+ readonly secret: string
77
+ }
78
+
79
+ /** A registration as the API shows it (never its secret). */
80
+ export type WebhookView = Omit<Registration, "secret">
81
+
82
+ export type DeliveryState = "pending" | "delivered" | "failed"
83
+
84
+ export interface DeliveryAttempt {
85
+ readonly at: number
86
+ readonly status?: number
87
+ readonly error?: string
88
+ }
89
+
90
+ interface OutboxEntry {
91
+ /** The Hub-Delivery header. */
92
+ readonly id: string
93
+ readonly webhook: string
94
+ readonly event: WebhookEvent
95
+ readonly state: DeliveryState
96
+ readonly attempts: number
97
+ readonly created: number
98
+ readonly nextAt: number
99
+ readonly finishedAt?: number
100
+ readonly last?: DeliveryAttempt
101
+ }
102
+
103
+ export interface DeliveryView {
104
+ readonly id: string
105
+ readonly event: string
106
+ readonly type: string
107
+ readonly state: DeliveryState
108
+ readonly attempts: number
109
+ readonly created: number
110
+ readonly nextAt?: number
111
+ readonly last?: DeliveryAttempt
112
+ }
113
+
114
+ export interface RegisterInput {
115
+ readonly url?: unknown
116
+ readonly events?: unknown
117
+ readonly label?: unknown
118
+ }
119
+
120
+ export interface WebhooksDeps {
121
+ readonly state: StateStore
122
+ /** One sender per delivery across hubs; without it, this process is the only sender. */
123
+ readonly locks?: Locks
124
+ readonly now?: Clock
125
+ readonly timers?: Timers
126
+ /** The events' source (the hub's public URL). */
127
+ readonly source: string
128
+ /** Every user ID (the sender drains each user's outbox). */
129
+ readonly users: () => readonly string[]
130
+ readonly resolve: ResolveHost
131
+ /** hub.json agents.webhooks.allowPrivate. */
132
+ readonly allowPrivate?: boolean
133
+ readonly fetch?: typeof globalThis.fetch
134
+ /** Whether an app token (by its id) may still have webhooks; its registrations are dropped when not. */
135
+ readonly appAlive?: (userID: string, tokenID: string) => boolean
136
+ readonly backoffMs?: readonly number[]
137
+ readonly timeoutMs?: number
138
+ readonly maxResponseBytes?: number
139
+ readonly keepMs?: number
140
+ readonly maxWebhooks?: number
141
+ readonly maxOutbox?: number
142
+ readonly pollMs?: number
143
+ /** The lease owner (unique per hub process). */
144
+ readonly owner?: string
145
+ readonly userAgent?: string
146
+ }
147
+
148
+ const defaultTimers: Timers = {
149
+ set: (fn, ms) => {
150
+ const handle = setTimeout(fn, ms)
151
+ ;(handle as { unref?: () => void }).unref?.()
152
+ return handle
153
+ },
154
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
155
+ }
156
+
157
+ const hex = (text: string, length: number) => createHash("sha256").update(text).digest("hex").slice(0, length)
158
+ const invalid = (message: string, hint?: string) => new AgentError("invalid", message, hint ? { hint } : {})
159
+
160
+ /** Whether a registration's pattern (an exact type or `<prefix>.*`) covers an event type. */
161
+ function matches(pattern: string, type: string): boolean {
162
+ return pattern.endsWith(".*") ? type.startsWith(pattern.slice(0, -1)) : pattern === type
163
+ }
164
+
165
+ function eventsOf(raw: unknown): string[] {
166
+ if (raw === undefined) return [...DEFAULT_WEBHOOK_EVENTS]
167
+ 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.*"]`)
169
+ }
170
+ 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(", ")}`)
172
+ }
173
+ return [...new Set(raw)]
174
+ }
175
+
176
+ const view = ({ secret: _secret, ...rest }: Registration): WebhookView => rest
177
+
178
+ const deliveryView = (entry: OutboxEntry): DeliveryView => ({
179
+ id: entry.id,
180
+ event: entry.event.id,
181
+ type: entry.event.type,
182
+ state: entry.state,
183
+ attempts: entry.attempts,
184
+ created: entry.created,
185
+ ...(entry.state === "pending" ? { nextAt: entry.nextAt } : {}),
186
+ ...(entry.last ? { last: entry.last } : {}),
187
+ })
188
+
189
+ /** The webhook event for a launch change: `launch.<state>`, its id stable per step (launch, state, time). */
190
+ export function launchEvent(source: string, launch: Launch, data: unknown): WebhookEvent {
191
+ return {
192
+ id: `evt_${hex(`${launch.id}\u0000${launch.state}\u0000${launch.at}`, 24)}`,
193
+ type: `launch.${launch.state}`,
194
+ source,
195
+ subject: launch.id,
196
+ time: new Date(launch.at).toISOString(),
197
+ user: launch.owner,
198
+ schemaVersion: 1,
199
+ data: { launch: data },
200
+ }
201
+ }
202
+
203
+ /** The hex HMAC-SHA256 over `<t>.<body>` (Hub-Signature v1). */
204
+ export function signWebhook(secret: string, t: number, body: string): string {
205
+ return createHmac(WEBHOOK_SIGNATURE_ALGORITHM, secret).update(webhookSignedPayload(t, body)).digest("hex")
206
+ }
207
+
208
+ export class Webhooks {
209
+ private readonly now: Clock
210
+ private readonly timers: Timers
211
+ private readonly backoffMs: readonly number[]
212
+ private readonly owner: string
213
+ private running: Promise<void> | undefined
214
+ private again = false
215
+ private started = false
216
+ private poll: unknown
217
+ private kickTimer: unknown
218
+
219
+ constructor(private readonly deps: WebhooksDeps) {
220
+ this.now = deps.now ?? Date.now
221
+ this.timers = deps.timers ?? defaultTimers
222
+ this.backoffMs = deps.backoffMs ?? WEBHOOK_BACKOFF_MS
223
+ this.owner = deps.owner ?? `hub-${process.pid}-${randomBytes(4).toString("hex")}`
224
+ }
225
+
226
+ async register(userID: string, by: WebhookOwner, input: RegisterInput): Promise<{ webhook: WebhookView; secret: string }> {
227
+ const { url } = input
228
+ if (typeof url !== "string" || url.length === 0) throw invalid("url must be a string")
229
+ if (url.length > MAX_WEBHOOK_URL) throw invalid(`url must be at most ${MAX_WEBHOOK_URL} characters`)
230
+ const problem = await publicURLProblem(url, { resolve: this.deps.resolve, label: "Webhook URL", allowPrivate: this.deps.allowPrivate === true })
231
+ if (problem) throw invalid(problem, this.deps.allowPrivate ? undefined : "Webhooks need an https URL on a public host")
232
+ const events = eventsOf(input.events)
233
+ let label: string | undefined
234
+ if (input.label !== undefined && input.label !== null) {
235
+ if (typeof input.label !== "string") throw invalid("label must be a string")
236
+ label = input.label.trim() || undefined
237
+ if (label && label.length > MAX_WEBHOOK_LABEL) throw invalid(`label must be at most ${MAX_WEBHOOK_LABEL} characters`)
238
+ }
239
+ const max = this.deps.maxWebhooks ?? MAX_WEBHOOKS
240
+ if ((await this.deps.state.list(userID, WEBHOOKS)).length >= max) throw new AgentError("rate_limited", `At most ${max} webhooks per user`, { hint: "Delete unused webhooks" })
241
+ const registration: Registration = {
242
+ id: `wh_${randomBytes(8).toString("hex")}`,
243
+ url,
244
+ events,
245
+ by,
246
+ ...(label ? { label } : {}),
247
+ created: this.now(),
248
+ secret: `whsec_${randomBytes(32).toString("base64url")}`,
249
+ }
250
+ const written = await this.deps.state.put(userID, WEBHOOKS, registration.id, registration, { ifVersion: 0 })
251
+ if (!written.ok) throw new Error("webhook id collision")
252
+ return { webhook: view(registration), secret: registration.secret }
253
+ }
254
+
255
+ /** The user's registrations (by: only that owner's), by id. */
256
+ async list(userID: string, by?: WebhookOwner): Promise<WebhookView[]> {
257
+ return (await this.registrations(userID)).filter((r) => by === undefined || r.by === by).map(view)
258
+ }
259
+
260
+ async get(userID: string, id: string, by?: WebhookOwner): Promise<WebhookView | undefined> {
261
+ const registration = await this.registration(userID, id, by)
262
+ return registration && view(registration)
263
+ }
264
+
265
+ /** Deletes a registration and its outbox; false when it is not the caller's. */
266
+ async remove(userID: string, id: string, by?: WebhookOwner): Promise<boolean> {
267
+ if (!(await this.registration(userID, id, by))) return false
268
+ await this.deps.state.delete(userID, WEBHOOKS, id)
269
+ for (const entry of await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX)) {
270
+ if (entry.value.webhook === id) await this.deps.state.delete(userID, WEBHOOK_OUTBOX, entry.key)
271
+ }
272
+ return true
273
+ }
274
+
275
+ /** A registration's deliveries still kept, oldest first. */
276
+ async deliveries(userID: string, id: string): Promise<DeliveryView[]> {
277
+ return (await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX))
278
+ .map((e) => e.value)
279
+ .filter((e) => e.webhook === id)
280
+ .sort((a, b) => a.created - b.created || a.id.localeCompare(b.id))
281
+ .map(deliveryView)
282
+ }
283
+
284
+ /**
285
+ * Queues an event for every matching registration of the user: user registrations always, app registrations
286
+ * only when `by` (who caused it) is that app. Resolves with how many deliveries were newly queued (an event
287
+ * already queued for a registration is not queued again).
288
+ */
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)))
291
+ if (!targets.length) return 0
292
+ const max = this.deps.maxOutbox ?? WEBHOOK_OUTBOX_MAX
293
+ let size = (await this.deps.state.list(userID, WEBHOOK_OUTBOX)).length
294
+ let queued = 0
295
+ for (const registration of targets) {
296
+ if (size >= max) {
297
+ console.error(`[hub] webhook outbox of ${userID} is full; event ${event.id} dropped for ${registration.id}`)
298
+ break
299
+ }
300
+ const key = `${registration.id}.${event.id}`
301
+ const entry: OutboxEntry = { id: `whd_${hex(key, 24)}`, webhook: registration.id, event, state: "pending", attempts: 0, created: this.now(), nextAt: this.now() }
302
+ if ((await this.deps.state.put(userID, WEBHOOK_OUTBOX, key, entry, { ifVersion: 0 })).ok) {
303
+ queued++
304
+ size++
305
+ }
306
+ }
307
+ if (queued) this.kick()
308
+ return queued
309
+ }
310
+
311
+ /** A launch change (the LaunchFeed): `data` is the launch as the API shows it. */
312
+ async onLaunch(userID: string, launch: Launch, data: unknown): Promise<number> {
313
+ return this.publish(userID, launchEvent(this.deps.source, launch, data), launch.by !== undefined ? { by: launch.by } : {})
314
+ }
315
+
316
+ /** Starts the sender: drains now and every pollMs, and soon after each publish. */
317
+ start(): void {
318
+ if (this.started) return
319
+ this.started = true
320
+ const tick = () => {
321
+ this.poll = this.timers.set(() => {
322
+ void this.drain()
323
+ .catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`))
324
+ .finally(() => this.started && tick())
325
+ }, this.deps.pollMs ?? POLL_MS)
326
+ }
327
+ this.kick()
328
+ tick()
329
+ }
330
+
331
+ stop(): void {
332
+ this.started = false
333
+ if (this.poll !== undefined) this.timers.clear(this.poll)
334
+ if (this.kickTimer !== undefined) this.timers.clear(this.kickTimer)
335
+ this.poll = this.kickTimer = undefined
336
+ }
337
+
338
+ /** Sends every due delivery once (and prunes finished ones); concurrent calls share one run. */
339
+ drain(): Promise<void> {
340
+ if (this.running) {
341
+ this.again = true
342
+ return this.running
343
+ }
344
+ this.running = (async () => {
345
+ do {
346
+ this.again = false
347
+ for (const userID of this.deps.users()) await this.drainUser(userID)
348
+ } while (this.again)
349
+ })().finally(() => {
350
+ this.running = undefined
351
+ })
352
+ return this.running
353
+ }
354
+
355
+ private kick(): void {
356
+ if (!this.started || this.kickTimer !== undefined) return
357
+ this.kickTimer = this.timers.set(() => {
358
+ this.kickTimer = undefined
359
+ void this.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`))
360
+ }, 0)
361
+ }
362
+
363
+ private async registrations(userID: string): Promise<Registration[]> {
364
+ return (await this.deps.state.list<Registration>(userID, WEBHOOKS)).map((e) => e.value).sort((a, b) => a.id.localeCompare(b.id))
365
+ }
366
+
367
+ private async registration(userID: string, id: string, by?: WebhookOwner): Promise<Registration | undefined> {
368
+ const found = (await this.deps.state.get<Registration>(userID, WEBHOOKS, id))?.value
369
+ return found && (by === undefined || found.by === by) ? found : undefined
370
+ }
371
+
372
+ private async drainUser(userID: string): Promise<void> {
373
+ const now = this.now()
374
+ const keep = this.deps.keepMs ?? WEBHOOK_KEEP_MS
375
+ const due: string[] = []
376
+ for (const { key, value, version } of await this.deps.state.list<OutboxEntry>(userID, WEBHOOK_OUTBOX)) {
377
+ if (value.state !== "pending") {
378
+ if (now - (value.finishedAt ?? value.created) > keep) await this.deps.state.delete(userID, WEBHOOK_OUTBOX, key, { ifVersion: version })
379
+ } else if (value.nextAt <= now) due.push(key)
380
+ }
381
+ for (let i = 0; i < due.length; i += CONCURRENCY) await Promise.all(due.slice(i, i + CONCURRENCY).map((key) => this.attempt(userID, key)))
382
+ }
383
+
384
+ /** One attempt at one delivery, under its lease. */
385
+ private async attempt(userID: string, key: string): Promise<void> {
386
+ const timeoutMs = this.deps.timeoutMs ?? WEBHOOK_TIMEOUT_MS
387
+ const lockKey = `webhook:${userID}:${key}`
388
+ const { locks } = this.deps
389
+ if (locks && !(await locks.acquire(lockKey, this.owner, timeoutMs + 30_000))) return
390
+ try {
391
+ const current = await this.deps.state.get<OutboxEntry>(userID, WEBHOOK_OUTBOX, key)
392
+ const now = this.now()
393
+ if (!current || current.value.state !== "pending" || current.value.nextAt > now) return
394
+ const entry = current.value
395
+ const registration = await this.registration(userID, entry.webhook)
396
+ if (!registration) {
397
+ await this.deps.state.delete(userID, WEBHOOK_OUTBOX, key)
398
+ return
399
+ }
400
+ if (registration.by !== "user" && this.deps.appAlive && !this.deps.appAlive(userID, registration.by.slice("app:".length))) {
401
+ await this.remove(userID, registration.id)
402
+ return
403
+ }
404
+ const { last, final } = await this.send(registration, entry, now, timeoutMs)
405
+ const attempts = entry.attempts + 1
406
+ const delivered = last.status !== undefined && last.status >= 200 && last.status < 300
407
+ const done = delivered || final || attempts > this.backoffMs.length
408
+ const next: OutboxEntry = done
409
+ ? { ...entry, attempts, last, state: delivered ? "delivered" : "failed", finishedAt: this.now() }
410
+ : { ...entry, attempts, last, nextAt: now + this.backoffMs[attempts - 1]! }
411
+ await this.deps.state.put(userID, WEBHOOK_OUTBOX, key, next, { ifVersion: current.version })
412
+ } finally {
413
+ if (locks) await locks.release(lockKey, this.owner)
414
+ }
415
+ }
416
+
417
+ private async send(registration: Registration, entry: OutboxEntry, now: number, timeoutMs: number): Promise<{ last: DeliveryAttempt; final: boolean }> {
418
+ const body = JSON.stringify(entry.event)
419
+ const t = Math.floor(now / 1000)
420
+ try {
421
+ const result = await publicFetch(
422
+ registration.url,
423
+ {
424
+ method: "POST",
425
+ headers: {
426
+ "content-type": "application/json",
427
+ "user-agent": this.deps.userAgent ?? "hub-webhooks",
428
+ [WEBHOOK_SIGNATURE_HEADER]: formatWebhookSignature(t, [signWebhook(registration.secret, t, body)]),
429
+ [WEBHOOK_EVENT_HEADER]: entry.event.type,
430
+ [WEBHOOK_DELIVERY_HEADER]: entry.id,
431
+ },
432
+ body,
433
+ },
434
+ {
435
+ resolve: this.deps.resolve,
436
+ label: "Webhook URL",
437
+ allowPrivate: this.deps.allowPrivate === true,
438
+ timeoutMs,
439
+ maxBytes: this.deps.maxResponseBytes ?? WEBHOOK_RESPONSE_MAX,
440
+ ...(this.deps.fetch ? { fetch: this.deps.fetch } : {}),
441
+ },
442
+ )
443
+ return { last: { at: now, status: result.status }, final: false }
444
+ } catch (cause) {
445
+ const error = (cause instanceof Error ? cause.message : String(cause)).slice(0, 300)
446
+ return { last: { at: now, error }, final: cause instanceof PublicURLError }
447
+ }
448
+ }
449
+ }
@@ -0,0 +1,177 @@
1
+ // Per-user agent control plane data stored on UserRecord.agent in state.json: session names, roles, session
2
+ // records and app/agent tokens (hashes only). Storage only: validation, normalisation and merging. Unknown
3
+ // fields (on the object and on each entry) are kept, so data written by a newer hub survives this one.
4
+ // It lives inside `users`, which older hubs persist as-is, so a downgrade keeps it.
5
+ // Lifecycle rules (release, alias expiry, offline role loss) are evaluated lazily by agent/directory.ts.
6
+ import { withSuffix } from "./agent/names"
7
+
8
+ /** `@name` → session. Unique per user by name (current names and aliases share one namespace). */
9
+ export interface AgentName {
10
+ name: string
11
+ sessionID: string
12
+ /** When it was set (ms). */
13
+ created?: number
14
+ /** Set on an alias (the name before a rename): it is released at this time (ms). */
15
+ expiresAt?: number
16
+ [field: string]: unknown
17
+ }
18
+
19
+ /** `@@role` holder. Unique per user by (role, project, sessionID). */
20
+ export interface AgentRole {
21
+ role: string
22
+ sessionID: string
23
+ /** Project scope (`role.project`); absent for an unscoped claim. */
24
+ project?: string
25
+ /** Holder generation: every new holder of (role, project) gets the next one. */
26
+ generation?: number
27
+ /** Claimed with --exclusive: nobody else may hold (role, project) while this holder is live. */
28
+ exclusive?: boolean
29
+ claimedAt?: number
30
+ [field: string]: unknown
31
+ }
32
+
33
+ /** A session the control plane knows about. Unique per user by sessionID. */
34
+ export interface AgentSession {
35
+ sessionID: string
36
+ machineID?: string
37
+ parentID?: string
38
+ /** Project name (lowercased) used by `.project` scopes. */
39
+ project?: string
40
+ title?: string
41
+ firstSeen?: number
42
+ /** Last time a spoke reported it (ms; persisted at minute granularity while it stays online). */
43
+ lastSeen?: number
44
+ /** When the hub saw it archived (the console's Archive, viewPrefs.hiddenSessions). */
45
+ archivedAt?: number
46
+ deletedAt?: number
47
+ /** Name derivation already happened (derived once, from the first non-placeholder title). */
48
+ named?: boolean
49
+ /** When the one-time identity primer reached the session (ms); absent: not primed yet. */
50
+ primed?: number
51
+ [field: string]: unknown
52
+ }
53
+
54
+ /** A user-created app/agent token: only the sha256 hash is stored. Unique per user by id. */
55
+ export interface AppToken {
56
+ id: string
57
+ tokenHash: string
58
+ label?: string
59
+ scopes?: string[]
60
+ created?: number
61
+ [field: string]: unknown
62
+ }
63
+
64
+ export interface AgentData {
65
+ names?: AgentName[]
66
+ roles?: AgentRole[]
67
+ sessions?: AgentSession[]
68
+ appTokens?: AppToken[]
69
+ /** Last holder generation handed out per roleKey(role, project). */
70
+ generations?: Record<string, number>
71
+ /** The user's agent kill switch (agent/acting-switch.ts): every agent action answers paused. */
72
+ actingPaused?: true
73
+ [field: string]: unknown
74
+ }
75
+
76
+ /**
77
+ * Key of a role and its project scope (for generations and exclusivity): `role` or `role.project` (a role never
78
+ * contains a dot). Never a NUL: a Postgres jsonb string cannot hold one (cluster mode).
79
+ */
80
+ export const roleKey = (role: string, project?: string): string => (project ? `${role}.${project}` : role)
81
+
82
+ /** Generation keys of older hubs: role NUL project. */
83
+ const LEGACY_ROLE_KEY = "\u0000"
84
+
85
+ type Entry = Record<string, unknown>
86
+
87
+ /** Each list: the string fields an entry needs, and its identity (entries with the same one are duplicates). */
88
+ const LISTS: Record<"names" | "roles" | "sessions" | "appTokens", { required: readonly string[]; identity: (e: Entry) => string }> = {
89
+ names: { required: ["name", "sessionID"], identity: (e) => e.name as string },
90
+ roles: { required: ["role", "sessionID"], identity: (e) => `${roleKey(e.role as string, typeof e.project === "string" ? e.project : undefined)}\u0000${e.sessionID}` },
91
+ sessions: { required: ["sessionID"], identity: (e) => e.sessionID as string },
92
+ appTokens: { required: ["id", "tokenHash"], identity: (e) => e.id as string },
93
+ }
94
+
95
+ const isPlain = (v: unknown): v is Entry => typeof v === "object" && v !== null && !Array.isArray(v)
96
+ const isList = (key: string): key is keyof typeof LISTS => Object.hasOwn(LISTS, key)
97
+
98
+ /** Valid entries of a list, first of each identity kept. */
99
+ function normaliseList(key: keyof typeof LISTS, value: readonly unknown[]): Entry[] {
100
+ const { required, identity } = LISTS[key]
101
+ const seen = new Set<string>()
102
+ const out: Entry[] = []
103
+ for (const entry of value) {
104
+ if (!isPlain(entry) || !required.every((field) => typeof entry[field] === "string" && entry[field] !== "")) continue
105
+ const id = identity(entry)
106
+ if (seen.has(id)) continue
107
+ seen.add(id)
108
+ out.push(entry)
109
+ }
110
+ return out
111
+ }
112
+
113
+ /** Generation counters: non-negative safe integers only. */
114
+ function normaliseGenerations(value: Entry): Record<string, number> {
115
+ const out: Record<string, number> = {}
116
+ for (const [raw, n] of Object.entries(value)) {
117
+ if (!validEpoch(n)) continue
118
+ const legacy = raw.indexOf(LEGACY_ROLE_KEY)
119
+ const key = legacy < 0 ? raw : roleKey(raw.slice(0, legacy), raw.slice(legacy + 1))
120
+ out[key] = Math.max(out[key] ?? 0, n)
121
+ }
122
+ return out
123
+ }
124
+
125
+ /**
126
+ * Agent data from a stored or updated value: undefined when it is not an object; a known list that is not an
127
+ * array is dropped; invalid and duplicate entries are dropped. Idempotent; key order is kept.
128
+ */
129
+ export function normaliseAgentData(value: unknown): AgentData | undefined {
130
+ if (!isPlain(value)) return undefined
131
+ const out: Entry = {}
132
+ for (const [key, item] of Object.entries(value)) {
133
+ if (key === "generations") {
134
+ if (isPlain(item)) out[key] = normaliseGenerations(item)
135
+ } else if (!isList(key)) out[key] = item
136
+ else if (Array.isArray(item)) out[key] = normaliseList(key, item)
137
+ }
138
+ return out as AgentData
139
+ }
140
+
141
+ /** `from`'s names after `have`: a name `have` holds for another session gets a suffix (-2, -3, …). */
142
+ function mergeNames(have: Entry[], from: readonly unknown[]): Entry[] {
143
+ const out = normaliseList("names", have)
144
+ const owner = new Map(out.map((e) => [e.name as string, e.sessionID as string]))
145
+ for (const entry of normaliseList("names", from)) {
146
+ const held = owner.get(entry.name as string)
147
+ if (held === entry.sessionID) continue
148
+ const name = held === undefined ? (entry.name as string) : withSuffix(entry.name as string, (n) => owner.has(n))
149
+ owner.set(name, entry.sessionID as string)
150
+ out.push({ ...entry, name })
151
+ }
152
+ return out
153
+ }
154
+
155
+ /**
156
+ * `into` plus what `from` adds: list entries with a new identity, other fields `into` lacks. A name of
157
+ * `from` that `into` holds for a different session is kept with a suffix; generations take the higher counter.
158
+ */
159
+ export function mergeAgentData(into: AgentData, from: AgentData): AgentData {
160
+ const out: Entry = { ...into }
161
+ for (const [key, item] of Object.entries(from)) {
162
+ if (key === "names" && Array.isArray(item)) {
163
+ out[key] = mergeNames(Array.isArray(out[key]) ? (out[key] as Entry[]) : [], item)
164
+ } else if (key === "generations" && isPlain(item)) {
165
+ const merged = normaliseGenerations(isPlain(out[key]) ? (out[key] as Entry) : {})
166
+ for (const [k, n] of Object.entries(normaliseGenerations(item))) merged[k] = Math.max(merged[k] ?? 0, n)
167
+ out[key] = merged
168
+ } else if (isList(key) && Array.isArray(item)) {
169
+ const have = Array.isArray(out[key]) ? (out[key] as Entry[]) : []
170
+ out[key] = normaliseList(key, [...have, ...item])
171
+ } else if (!(key in out)) out[key] = item
172
+ }
173
+ return out as AgentData
174
+ }
175
+
176
+ /** A stored token epoch: a non-negative safe integer, else absent (read as 0). */
177
+ export const validEpoch = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0