@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,227 @@
1
+ // Hub-minted session tokens (design §1.3): a bearer credential for one session of one machine, which the hub
2
+ // pushes to the spoke reporting the session (sessionTokens op, see ../session-token-push.ts) and the spoke hands
3
+ // to the session's shells.
4
+ //
5
+ // Format: `hst1.<b64url(payload JSON)>.<b64url(HMAC-SHA256(secret of kid, "hst1." + payload part))>`, where the
6
+ // payload is {typ, uid, mid, sid, exp (ms since the epoch), kid, ue (user epoch), me (machine epoch), aud (the
7
+ // hub's public URL)}. One algorithm, one prefix; anything else is not a session token.
8
+ //
9
+ // typ "session" is the shell token (HUB_SESSION_TOKEN; any code in the session's shells can read it): its actor
10
+ // is via "shell" and can never act. typ "act" (phase 3, decision #1) is the act token, pushed only to spokes
11
+ // announcing "actTokens" (actTokens op), which never put it in any environment; its actor is via "act", the only
12
+ // session actor act verbs may admit (canActAsSession). The signature covers typ, so one cannot be turned into the other.
13
+ //
14
+ // A token is accepted while all of this holds: signature (constant-time) under a known kid, audience, expiry,
15
+ // the user's and machine's current token epochs, the machine exists, is not revoked and still belongs to uid,
16
+ // the user exists (the resolver refuses disabled users), and that machine still reports the session for uid.
17
+ // Tokens and secrets are never logged and never appear in this object's JSON or inspect output.
18
+ import { createHmac, timingSafeEqual } from "node:crypto"
19
+ import { SESSION_TOKEN_MAX_CHARS } from "../../protocol"
20
+ import type { Store } from "../store"
21
+ import type { TokenKeys } from "../token-keys"
22
+ import type { BearerVerifier, SessionActor, VerifyResult } from "./actor"
23
+
24
+ export const SESSION_TOKEN_PREFIX = "hst1"
25
+ /** How long a session token is valid. */
26
+ export const SESSION_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000
27
+
28
+ /** What the verifier needs to know about live sessions (implemented by the registry). */
29
+ export interface SessionLiveness {
30
+ /** Tenant (user ID) of a reported session (Registry.sessionTenant). */
31
+ sessionTenant(sessionID: string): string | undefined
32
+ /** Whether a connected process of the machine reports the session (for the machine's tenant). */
33
+ machineReports(machineID: string, sessionID: string): boolean
34
+ }
35
+
36
+ export interface SessionTokensOptions {
37
+ readonly keys: TokenKeys
38
+ readonly store: Store
39
+ readonly sessions: SessionLiveness
40
+ /** The hub's public URL: the tokens' audience. */
41
+ readonly publicURL: string
42
+ readonly now?: () => number
43
+ readonly lifetimeMs?: number
44
+ }
45
+
46
+ export interface MintedSessionToken {
47
+ readonly token: string
48
+ /** Expiry, ms since the epoch. */
49
+ readonly exp: number
50
+ /** The machine's stamp when minted (see SessionTokens.stamp). */
51
+ readonly stamp: string
52
+ }
53
+
54
+ /** Token types: "session" for shells, "act" for act verbs (the hub tool). */
55
+ export type SessionTokenType = "session" | "act"
56
+
57
+ interface Claims {
58
+ readonly typ: SessionTokenType
59
+ readonly uid: string
60
+ readonly mid: string
61
+ readonly sid: string
62
+ readonly exp: number
63
+ readonly kid: string
64
+ readonly ue: number
65
+ readonly me: number
66
+ readonly aud: string
67
+ }
68
+
69
+ const CLAIM_KEYS = ["typ", "uid", "mid", "sid", "exp", "kid", "ue", "me", "aud"].sort().join()
70
+ const B64URL = /^[A-Za-z0-9_-]+$/
71
+ /** HMAC-SHA256 output length. */
72
+ const SIG_BYTES = 32
73
+
74
+ const text = (v: unknown): v is string => typeof v === "string" && v.length > 0
75
+ const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
76
+
77
+ /** Canonical base64url only (no padding, no other alphabet, no non-zero trailing bits). */
78
+ function decode(part: string): Buffer | undefined {
79
+ if (!B64URL.test(part)) return undefined
80
+ const bytes = Buffer.from(part, "base64url")
81
+ return bytes.toString("base64url") === part ? bytes : undefined
82
+ }
83
+
84
+ function parseClaims(bytes: Buffer): Claims | undefined {
85
+ let raw: unknown
86
+ try {
87
+ raw = JSON.parse(bytes.toString("utf8"))
88
+ } catch {
89
+ return undefined
90
+ }
91
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
92
+ const c = raw as Record<string, unknown>
93
+ if (Object.keys(c).sort().join() !== CLAIM_KEYS) return undefined
94
+ if ((c.typ !== "session" && c.typ !== "act") || !text(c.uid) || !text(c.mid) || !text(c.sid) || !text(c.kid) || !text(c.aud)) return undefined
95
+ if (!count(c.exp) || !count(c.ue) || !count(c.me)) return undefined
96
+ return c as unknown as Claims
97
+ }
98
+
99
+ const sign = (secret: Buffer, payload: string): Buffer => createHmac("sha256", secret).update(`${SESSION_TOKEN_PREFIX}.${payload}`).digest()
100
+
101
+ export class SessionTokens {
102
+ readonly lifetimeMs: number
103
+ readonly verifier: BearerVerifier
104
+ readonly #keys: TokenKeys
105
+ readonly #store: Store
106
+ readonly #sessions: SessionLiveness
107
+ readonly #aud: string
108
+ readonly #now: () => number
109
+
110
+ constructor(options: SessionTokensOptions) {
111
+ this.#keys = options.keys
112
+ this.#store = options.store
113
+ this.#sessions = options.sessions
114
+ this.#aud = options.publicURL
115
+ this.#now = options.now ?? Date.now
116
+ this.lifetimeMs = options.lifetimeMs ?? SESSION_TOKEN_LIFETIME_MS
117
+ this.verifier = { kind: "session", verify: (token) => this.verifyFresh(token) }
118
+ }
119
+
120
+ /** The owner of an enrolled, unrevoked machine whose user exists and is enabled. */
121
+ #owner(machineID: string): string | undefined {
122
+ const machine = this.#store.machines().find((m) => m.id === machineID)
123
+ if (!machine || machine.revoked) return undefined
124
+ const user = this.#store.user(machine.ownerID)
125
+ return user && !user.disabled ? user.id : undefined
126
+ }
127
+
128
+ /**
129
+ * What a machine's tokens depend on besides the session: owner, active kid, user and machine epochs. Tokens
130
+ * minted under another stamp are stale (resend them); undefined when the machine cannot get tokens.
131
+ */
132
+ stamp(machineID: string): string | undefined {
133
+ const uid = this.#owner(machineID)
134
+ if (!uid) return undefined
135
+ return JSON.stringify([uid, this.#keys.active().kid, this.#store.userEpoch(uid), this.#store.machineEpoch(machineID)])
136
+ }
137
+
138
+ /**
139
+ * A token for a session of the machine's owner (undefined when the machine is unknown, revoked or its owner
140
+ * disabled, or the session belongs to another tenant or to nobody).
141
+ */
142
+ mint(machineID: string, sessionID: string): MintedSessionToken | undefined {
143
+ return this.#mint("session", machineID, sessionID)
144
+ }
145
+
146
+ /** An act token (typ "act") for the session, under the same rules as mint. Only for the actTokens op. */
147
+ mintAct(machineID: string, sessionID: string): MintedSessionToken | undefined {
148
+ return this.#mint("act", machineID, sessionID)
149
+ }
150
+
151
+ #mint(typ: SessionTokenType, machineID: string, sessionID: string): MintedSessionToken | undefined {
152
+ const uid = this.#owner(machineID)
153
+ if (!uid || this.#sessions.sessionTenant(sessionID) !== uid) return undefined
154
+ const { kid, secret } = this.#keys.active()
155
+ const exp = this.#now() + this.lifetimeMs
156
+ const claims: Claims = {
157
+ typ,
158
+ uid,
159
+ mid: machineID,
160
+ sid: sessionID,
161
+ exp,
162
+ kid,
163
+ ue: this.#store.userEpoch(uid),
164
+ me: this.#store.machineEpoch(machineID),
165
+ aud: this.#aud,
166
+ }
167
+ const payload = Buffer.from(JSON.stringify(claims)).toString("base64url")
168
+ const stamp = JSON.stringify([uid, kid, claims.ue, claims.me])
169
+ return { token: `${SESSION_TOKEN_PREFIX}.${payload}.${sign(secret, payload).toString("base64url")}`, exp, stamp }
170
+ }
171
+
172
+ /** The session actor of a valid token, "expired" for a genuine token past its expiry, else undefined (from memory). */
173
+ verify(token: string): VerifyResult {
174
+ const claims = this.#genuine(token)
175
+ return claims && claims !== "expired" ? this.#current(claims) : claims
176
+ }
177
+
178
+ /**
179
+ * verify, after reading the token's user and machine again (a shared store's security re-check: a revocation,
180
+ * epoch bump or disabled user on another pod applies at once). The bearer verifier uses this.
181
+ */
182
+ async verifyFresh(token: string): Promise<VerifyResult> {
183
+ const claims = this.#genuine(token)
184
+ if (!claims || claims === "expired") return claims
185
+ await this.#store.recheck({ users: [claims.uid], machines: [claims.mid] })
186
+ return this.#current(claims)
187
+ }
188
+
189
+ /** The claims of a token signed with a known key for this hub, "expired" past its expiry, else undefined. */
190
+ #genuine(token: string): Claims | "expired" | undefined {
191
+ if (typeof token !== "string" || token.length > SESSION_TOKEN_MAX_CHARS) return undefined
192
+ const parts = token.split(".")
193
+ if (parts.length !== 3 || parts[0] !== SESSION_TOKEN_PREFIX) return undefined
194
+ const [, payload, sigPart] = parts as [string, string, string]
195
+ const body = decode(payload)
196
+ const sig = decode(sigPart)
197
+ if (!body || !sig || sig.length !== SIG_BYTES) return undefined
198
+ const claims = parseClaims(body)
199
+ if (!claims) return undefined
200
+ const secret = this.#keys.key(claims.kid)
201
+ if (!secret) return undefined
202
+ const expected = sign(secret, payload)
203
+ if (expected.length !== sig.length || !timingSafeEqual(expected, sig)) return undefined
204
+ if (claims.aud !== this.#aud) return undefined
205
+ if (this.#now() >= claims.exp) return "expired"
206
+ return claims
207
+ }
208
+
209
+ /** The session actor while the token's epochs, machine, user and session are all still current. */
210
+ #current(claims: Claims): VerifyResult {
211
+ if (claims.ue !== this.#store.userEpoch(claims.uid) || claims.me !== this.#store.machineEpoch(claims.mid)) return undefined
212
+ const machine = this.#store.machines().find((m) => m.id === claims.mid)
213
+ if (!machine || machine.revoked || machine.ownerID !== claims.uid) return undefined
214
+ if (!this.#store.user(claims.uid)) return undefined
215
+ if (this.#sessions.sessionTenant(claims.sid) !== claims.uid || !this.#sessions.machineReports(claims.mid, claims.sid)) return undefined
216
+ const actor: SessionActor = { kind: "session", userID: claims.uid, machineID: claims.mid, sessionID: claims.sid, via: claims.typ === "act" ? "act" : "shell" }
217
+ return actor
218
+ }
219
+
220
+ toJSON(): { audience: string; lifetimeMs: number } {
221
+ return { audience: this.#aud, lifetimeMs: this.lifetimeMs }
222
+ }
223
+
224
+ [Symbol.for("nodejs.util.inspect.custom")](): string {
225
+ return `SessionTokens(${this.#aud})`
226
+ }
227
+ }
@@ -0,0 +1,26 @@
1
+ // Read-modify-write over a StateStore key with optimistic versions: retried when another writer got there first.
2
+ import type { StateStore } from "../backend/types"
3
+
4
+ /** Attempts before a contended update gives up (each one re-reads the key). */
5
+ const ATTEMPTS = 16
6
+
7
+ /**
8
+ * Applies `change` to the key's current value (undefined: missing) and writes the result with ifVersion.
9
+ * `change` returning undefined leaves the key alone. Resolves with the written value (or undefined).
10
+ */
11
+ export async function updateVersioned<T>(
12
+ state: StateStore,
13
+ userID: string,
14
+ collection: string,
15
+ key: string,
16
+ change: (current: T | undefined) => T | undefined,
17
+ ): Promise<T | undefined> {
18
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
19
+ const current = await state.get<T>(userID, collection, key)
20
+ const next = change(current?.value)
21
+ if (next === undefined) return undefined
22
+ const result = await state.put(userID, collection, key, next, { ifVersion: current?.version ?? 0 })
23
+ if (result.ok) return next
24
+ }
25
+ throw new Error(`state update of ${collection}/${key} kept conflicting`)
26
+ }
@@ -0,0 +1,221 @@
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.
4
+ //
5
+ // ?blocked=1 blocker events only
6
+ // ?session=<addr> only that session's tree (an address resolved for the caller; several for a @@role)
7
+ //
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.
19
+ // 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"
27
+
28
+ export const WATCH_HEARTBEAT_MS = 20_000
29
+ /** Draining: the `retry:` (ms) an ending watch stream sends. */
30
+ export const WATCH_RETRY_MS = 2_000
31
+ const RETRY = `retry: ${WATCH_RETRY_MS}\n\n`
32
+ 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
+ }
61
+
62
+ export interface WatchOptions {
63
+ readonly registry: WatchRegistry
64
+ readonly userID: string
65
+ readonly signal: AbortSignal
66
+ /** Only blocker events. */
67
+ readonly blocked?: boolean
68
+ /** Only events of these sessions or of trees rooted at them. */
69
+ 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
+ readonly heartbeatMs?: number
75
+ readonly now?: () => number
76
+ /** Aborted when the hub drains: the stream ends with a `retry:` hint. */
77
+ readonly draining?: AbortSignal
78
+ }
79
+
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 {
105
+ if (options.draining?.aborted) return new Response(RETRY, { headers: SSE_HEADERS })
106
+ const { registry, userID } = options
107
+ const now = options.now ?? Date.now
108
+ 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>()
113
+
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
+ const stream = new ReadableStream<Uint8Array>({
166
+ start(controller) {
167
+ const write = (chunk: string) => {
168
+ try {
169
+ controller.enqueue(encoder.encode(chunk))
170
+ } catch {
171
+ cleanup()
172
+ }
173
+ }
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?.()
201
+ }
202
+ options.signal.addEventListener("abort", () => cleanup(), { once: true })
203
+ send({ type: "ready" })
204
+ options.draining?.addEventListener(
205
+ "abort",
206
+ () => {
207
+ write(RETRY)
208
+ cleanup()
209
+ try {
210
+ controller.close()
211
+ } catch {}
212
+ },
213
+ { once: true },
214
+ )
215
+ },
216
+ cancel() {
217
+ cleanup()
218
+ },
219
+ })
220
+ return new Response(stream, { headers: SSE_HEADERS })
221
+ }
@@ -0,0 +1,78 @@
1
+ // The webhook API on /api/agent (P4-T10; decision znbg8xa5 #8), over Webhooks (webhooks.ts):
2
+ //
3
+ // POST /api/agent/webhooks {url, events?, label?} → 201 {webhook, secret} (the secret only here)
4
+ // GET /api/agent/webhooks → {webhooks}
5
+ // GET /api/agent/webhooks/:id → {webhook, deliveries} (recent deliveries, oldest first)
6
+ // DELETE /api/agent/webhooks/:id → 204 (its pending deliveries are dropped)
7
+ //
8
+ // Who: users (all of their registrations, their own and their app tokens'); app tokens with sessions:launch (the
9
+ // scope of the only events so far, launch.*), which see and delete only the registrations they made and get only
10
+ // the events of launches they started. Sessions cannot register webhooks. Another user's registration, or (apps)
11
+ // one the token did not make, is 404 not_found.
12
+ import type { Store } from "../store"
13
+ import type { AgentActor } from "./actor"
14
+ import { AgentError } from "./errors"
15
+ import type { AgentRoute } from "./routes"
16
+ import type { WebhookOwner, Webhooks } from "./webhooks"
17
+
18
+ // A literal: routes.ts imports this module.
19
+ const PATH = "/api/agent/webhooks"
20
+ const NO_STORE = { "cache-control": "no-store" }
21
+ /** Users; apps with sessions:launch. */
22
+ export const WEBHOOK_SCOPE = "sessions:launch"
23
+ const ALLOW = { scope: WEBHOOK_SCOPE } as const
24
+
25
+ export type RouteWebhooks = Pick<Webhooks, "register" | "list" | "get" | "remove" | "deliveries">
26
+
27
+ /** The registrations an actor manages: an app token its own, a user all (undefined). */
28
+ const ownerOf = (actor: AgentActor): WebhookOwner | undefined => (actor.kind === "app" ? `app:${actor.tokenID}` : undefined)
29
+
30
+ const notFound = (id: string) => new AgentError("not_found", `No webhook ${id}`)
31
+
32
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
33
+ const raw = JSON.parse(await request.text()) as unknown
34
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
35
+ return raw as Record<string, unknown>
36
+ }
37
+
38
+ export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audit">): AgentRoute[] {
39
+ return [
40
+ {
41
+ method: "POST",
42
+ path: PATH,
43
+ allow: ALLOW,
44
+ async handle({ request, actor, user }) {
45
+ const input = await bodyOf(request)
46
+ const result = await webhooks.register(user.id, ownerOf(actor) ?? "user", { url: input.url, events: input.events, label: input.label })
47
+ await store?.audit({ actor: user.id, action: "webhook.create", target: result.webhook.id, detail: new URL(result.webhook.url).host })
48
+ return Response.json(result, { status: 201, headers: NO_STORE })
49
+ },
50
+ },
51
+ {
52
+ method: "GET",
53
+ path: PATH,
54
+ allow: ALLOW,
55
+ handle: async ({ actor, user }) => Response.json({ webhooks: await webhooks.list(user.id, ownerOf(actor)) }, { headers: NO_STORE }),
56
+ },
57
+ {
58
+ method: "GET",
59
+ path: `${PATH}/:id`,
60
+ allow: ALLOW,
61
+ async handle({ params, actor, user }) {
62
+ const webhook = await webhooks.get(user.id, params.id!, ownerOf(actor))
63
+ if (!webhook) throw notFound(params.id!)
64
+ return Response.json({ webhook, deliveries: await webhooks.deliveries(user.id, webhook.id) }, { headers: NO_STORE })
65
+ },
66
+ },
67
+ {
68
+ method: "DELETE",
69
+ path: `${PATH}/:id`,
70
+ allow: ALLOW,
71
+ async handle({ params, actor, user }) {
72
+ if (!(await webhooks.remove(user.id, params.id!, ownerOf(actor)))) throw notFound(params.id!)
73
+ await store?.audit({ actor: user.id, action: "webhook.delete", target: params.id! })
74
+ return new Response(null, { status: 204, headers: NO_STORE })
75
+ },
76
+ },
77
+ ]
78
+ }