@huynle/hub 0.7.1 → 0.7.2

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 (185) hide show
  1. package/CHANGELOG.md +70 -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/runtime-openshell/package.json +1 -1
  142. package/packages/runtime-sdk/package.json +1 -1
  143. package/packages/sdk/package.json +1 -1
  144. package/packages/sdk/src/client.ts +55 -4
  145. package/packages/sdk/src/errors.ts +1 -0
  146. package/packages/sdk/src/sse.ts +4 -1
  147. package/packages/sdk/src/types.ts +59 -0
  148. package/packages/spoke-sdk/package.json +1 -1
  149. package/packages/spoke-sdk/src/enroll.ts +26 -4
  150. package/src/central/agent/app-tokens.ts +27 -17
  151. package/src/central/agent/archive-inactive.ts +66 -0
  152. package/src/central/agent/directory-routes.ts +155 -12
  153. package/src/central/agent/directory.ts +1 -1
  154. package/src/central/agent/errors.ts +2 -0
  155. package/src/central/agent/event-cursor.ts +49 -0
  156. package/src/central/agent/event-writers.ts +181 -0
  157. package/src/central/agent/events-routes.ts +148 -0
  158. package/src/central/agent/events.ts +277 -0
  159. package/src/central/agent/names.ts +1 -1
  160. package/src/central/agent/read-routes.ts +32 -36
  161. package/src/central/agent/routes.ts +14 -11
  162. package/src/central/agent/watch.ts +65 -174
  163. package/src/central/agent/webhook-routes.ts +10 -2
  164. package/src/central/agent/webhooks.ts +49 -5
  165. package/src/central/auth.ts +127 -1
  166. package/src/central/backend/index.ts +3 -0
  167. package/src/central/backend/types.ts +2 -0
  168. package/src/central/http.ts +115 -32
  169. package/src/central/mcp-http.ts +116 -0
  170. package/src/central/store.ts +53 -0
  171. package/src/config.ts +70 -7
  172. package/src/index.ts +90 -91
  173. package/src/spoke/launchers.ts +109 -3
  174. package/web/dist/apple-touch-icon.png +0 -0
  175. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  176. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  177. package/web/dist/favicon.ico +0 -0
  178. package/web/dist/icon-192.png +0 -0
  179. package/web/dist/icon-512.png +0 -0
  180. package/web/dist/icon-maskable-512.png +0 -0
  181. package/web/dist/icon.svg +5 -5
  182. package/web/dist/index.html +10 -4
  183. package/web/dist/manifest.webmanifest +2 -2
  184. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  185. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -0,0 +1,49 @@
1
+ // Cursors of GET /api/agent/events (lifecycle design, frozen "Events route and tool"): opaque, versioned,
2
+ // authenticated, tenant-bound strings over a journal position (`<epoch>.<n>`).
3
+ //
4
+ // hev1_<base64url(kid "\n" position)>.<base64url(mac)>
5
+ //
6
+ // mac is HMAC-SHA256 under the token signing key `kid` (token-keys.ts) of a domain-separated string that also binds
7
+ // the user, truncated to 16 bytes; another user's cursor, a changed byte, an unknown key or another version all fail
8
+ // to decode (400 invalid_cursor), never reset. The user never appears in the cursor itself.
9
+ import { createHmac, timingSafeEqual } from "node:crypto"
10
+
11
+ export const CURSOR_PREFIX = "hev1_"
12
+ const DOMAIN = "hub-agent-events-cursor/v1"
13
+ const MAC_BYTES = 16
14
+ const POSITION = /^([a-z0-9]{1,40})\.(\d{1,15})$/
15
+ const KID = /^[A-Za-z0-9_-]{1,64}$/
16
+ const B64URL = /^[A-Za-z0-9_-]+$/
17
+
18
+ export interface CursorKeys {
19
+ active(): { kid: string; secret: Buffer }
20
+ key(kid: string): Buffer | undefined
21
+ }
22
+
23
+ /** A journal position: its epoch and n. */
24
+ export function parsePosition(position: string): { epoch: string; n: number } | undefined {
25
+ const match = POSITION.exec(position)
26
+ return match ? { epoch: match[1]!, n: Number(match[2]) } : undefined
27
+ }
28
+
29
+ const mac = (secret: Buffer, userID: string, kid: string, position: string) => createHmac("sha256", secret).update(`${DOMAIN}\n${userID}\n${kid}\n${position}`).digest().subarray(0, MAC_BYTES)
30
+
31
+ export function encodeCursor(keys: CursorKeys, userID: string, position: string): string {
32
+ const { kid, secret } = keys.active()
33
+ const body = Buffer.from(`${kid}\n${position}`).toString("base64url")
34
+ return `${CURSOR_PREFIX}${body}.${mac(secret, userID, kid, position).toString("base64url")}`
35
+ }
36
+
37
+ /** The position of a cursor this hub gave this user; undefined for anything else. */
38
+ export function decodeCursor(keys: CursorKeys, userID: string, cursor: string): string | undefined {
39
+ if (cursor.length > 300 || !cursor.startsWith(CURSOR_PREFIX)) return undefined
40
+ const [body, tag, ...rest] = cursor.slice(CURSOR_PREFIX.length).split(".")
41
+ if (rest.length || !body || !tag || !B64URL.test(body) || !B64URL.test(tag)) return undefined
42
+ const [kid, position, ...extra] = Buffer.from(body, "base64url").toString("utf8").split("\n")
43
+ if (extra.length || !kid || !position || !KID.test(kid) || !parsePosition(position)) return undefined
44
+ const secret = keys.key(kid)
45
+ if (!secret) return undefined
46
+ const given = Buffer.from(tag, "base64url")
47
+ const expected = mac(secret, userID, kid, position)
48
+ return given.length === expected.length && timingSafeEqual(given, expected) ? position : undefined
49
+ }
@@ -0,0 +1,181 @@
1
+ // Writers of the canonical agent event journal (events.ts; lifecycle design, Event rollout stage 1). Each change
2
+ // is appended exactly once across a cluster, by the one place that sees it first:
3
+ //
4
+ // - state and blockers: the pod holding the spoke (the registry's status messages are local to it), diffed per tree;
5
+ // - session and machine presence: the "agent-events" leader, diffing the cluster-wide catalog per user against a
6
+ // snapshot it keeps in the Kv, so a new leader continues where the last one stopped (none: everything present
7
+ // is new);
8
+ // - messages and launches: the pod that published them (Feed origin listeners);
9
+ // - archive changes: the route that made them (directory-routes).
10
+ //
11
+ // Appends of one writer are serialized, so a user's events keep their order. Failures are reported, never thrown.
12
+ import type { Kv } from "../backend/types"
13
+ import type { CatalogMachine, RegistryMessage } from "../registry"
14
+ import { AGENT_EVENTS_AGE_MS, type AgentEvents, type EventInput, launchEvent, messageEvent, presenceEvents, type PresenceView, StatusDiff } from "./events"
15
+ import type { Launch } from "./launch-store"
16
+ import type { Message } from "./messages"
17
+
18
+ interface Subscribable<T> {
19
+ subscribe(listener: (userID: string, item: T) => void, options?: { readonly origin?: boolean }): () => void
20
+ }
21
+
22
+ export interface EventWriterRegistry {
23
+ subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
24
+ catalog(): CatalogMachine[]
25
+ sessionTenant(sessionID: string): string | undefined
26
+ }
27
+
28
+ export interface PresenceLeader {
29
+ isLeader(): boolean
30
+ }
31
+
32
+ export interface EventWritersOptions {
33
+ readonly events: AgentEvents
34
+ readonly kv: Kv
35
+ readonly registry: EventWriterRegistry
36
+ /** The owner of a stored machine (undefined: not stored, e.g. the local machine). */
37
+ readonly machineOwner: (machineID: string) => string | undefined
38
+ /** Every stored user (a new leader loads their presence snapshots). */
39
+ readonly users: () => Promise<string[]>
40
+ readonly messages?: Subscribable<Message>
41
+ readonly launches?: Subscribable<Launch>
42
+ readonly report?: (cause: unknown) => void
43
+ }
44
+
45
+ const snapshotKey = (userID: string) => `aev:presence:${userID}`
46
+
47
+ const EMPTY: PresenceView = { sessions: new Set(), machines: new Map() }
48
+
49
+ /** Presence of every user in a catalog. */
50
+ export function presenceOf(machines: readonly CatalogMachine[], tenant: (sessionID: string) => string | undefined, owner: (machineID: string) => string | undefined): Map<string, PresenceView> {
51
+ const out = new Map<string, { sessions: Set<string>; machines: Map<string, string> }>()
52
+ const view = (userID: string) => {
53
+ let v = out.get(userID)
54
+ if (!v) out.set(userID, (v = { sessions: new Set(), machines: new Map() }))
55
+ return v
56
+ }
57
+ for (const machine of machines) {
58
+ const users = new Set<string>()
59
+ const stored = owner(machine.id)
60
+ if (stored !== undefined) users.add(stored)
61
+ for (const process of machine.processes) {
62
+ for (const s of process.sessions) {
63
+ const userID = tenant(s.id)
64
+ if (userID === undefined) continue
65
+ view(userID).sessions.add(s.id)
66
+ if (stored === undefined) users.add(userID)
67
+ }
68
+ }
69
+ if (machine.processes.length > 0) for (const userID of users) view(userID).machines.set(machine.id, machine.name)
70
+ }
71
+ return out
72
+ }
73
+
74
+ export class EventWriters {
75
+ private readonly options: EventWritersOptions
76
+ private readonly diff = new StatusDiff()
77
+ private readonly report: (cause: unknown) => void
78
+ private queue: Promise<void> = Promise.resolve()
79
+ /** Presence as last journaled, per user (leader only; undefined when not leading). */
80
+ private presence: Map<string, PresenceView> | undefined
81
+ private readonly offs: (() => void)[] = []
82
+
83
+ constructor(options: EventWritersOptions) {
84
+ this.options = options
85
+ this.report = options.report ?? ((cause) => console.error(`[hub] agent event write failed: ${cause instanceof Error ? cause.message : String(cause)}`))
86
+ }
87
+
88
+ start(): void {
89
+ const { registry, messages, launches } = this.options
90
+ this.offs.push(
91
+ registry.subscribe((message, tenant) => {
92
+ if (message.type === "status" && tenant !== undefined) this.write(tenant, this.diff.events(message.sessionID, message.sessions))
93
+ else if (message.type === "catalog") {
94
+ this.forgetGone()
95
+ if (this.presence) this.enqueue(() => this.presenceChanged())
96
+ }
97
+ }),
98
+ )
99
+ if (messages) this.offs.push(messages.subscribe((userID, message) => this.write(userID, [messageEvent(message)]), { origin: true }))
100
+ if (launches) this.offs.push(launches.subscribe((userID, launch) => this.write(userID, [launchEvent(launch)]), { origin: true }))
101
+ }
102
+
103
+ stop(): void {
104
+ for (const off of this.offs.splice(0)) off()
105
+ this.presence = undefined
106
+ }
107
+
108
+ /** Became the presence leader: load the last leader's snapshots, then journal what changed since. */
109
+ elected(): void {
110
+ this.enqueue(async () => {
111
+ const presence = new Map<string, PresenceView>()
112
+ for (const userID of await this.options.users()) {
113
+ const raw = await this.options.kv.get(snapshotKey(userID))
114
+ if (raw) presence.set(userID, parseSnapshot(raw))
115
+ }
116
+ this.presence = presence
117
+ await this.presenceChanged()
118
+ })
119
+ }
120
+
121
+ deposed(): void {
122
+ this.enqueue(async () => {
123
+ this.presence = undefined
124
+ })
125
+ }
126
+
127
+ /** Resolves once every queued append is done (tests, drain). */
128
+ settled(): Promise<void> {
129
+ return this.queue
130
+ }
131
+
132
+ private write(userID: string, events: readonly EventInput[]): void {
133
+ if (events.length === 0) return
134
+ this.enqueue(async () => {
135
+ for (const event of events) await this.options.events.append(userID, event)
136
+ })
137
+ }
138
+
139
+ private enqueue(job: () => Promise<void>): void {
140
+ this.queue = this.queue.then(job).catch(this.report)
141
+ }
142
+
143
+ private async presenceChanged(): Promise<void> {
144
+ const before = this.presence
145
+ if (!before) return
146
+ const { registry, machineOwner, events, kv } = this.options
147
+ const after = presenceOf(registry.catalog(), (id) => registry.sessionTenant(id), machineOwner)
148
+ for (const userID of new Set([...before.keys(), ...after.keys()])) {
149
+ const prev = before.get(userID) ?? EMPTY
150
+ const next = after.get(userID) ?? EMPTY
151
+ const changes = presenceEvents(prev, next)
152
+ if (changes.length === 0) continue
153
+ for (const event of changes) await events.append(userID, event)
154
+ if (next.sessions.size === 0 && next.machines.size === 0) {
155
+ before.delete(userID)
156
+ await kv.del(snapshotKey(userID))
157
+ } else {
158
+ before.set(userID, next)
159
+ await kv.set(snapshotKey(userID), JSON.stringify({ sessions: [...next.sessions], machines: [...next.machines] }), { px: AGENT_EVENTS_AGE_MS })
160
+ }
161
+ }
162
+ }
163
+
164
+ /** Drops the status diffs of trees no longer in the catalog. */
165
+ private forgetGone(): void {
166
+ const present = new Set<string>()
167
+ for (const machine of this.options.registry.catalog()) for (const process of machine.processes) for (const s of process.sessions) present.add(s.id)
168
+ for (const rootID of this.diff.roots()) if (!present.has(rootID)) this.diff.forget(rootID)
169
+ }
170
+ }
171
+
172
+ function parseSnapshot(raw: string): PresenceView {
173
+ try {
174
+ const value = JSON.parse(raw) as { sessions?: unknown; machines?: unknown }
175
+ const sessions = Array.isArray(value.sessions) ? value.sessions.filter((id): id is string => typeof id === "string") : []
176
+ const machines = Array.isArray(value.machines) ? value.machines.filter((m): m is [string, string] => Array.isArray(m) && typeof m[0] === "string" && typeof m[1] === "string") : []
177
+ return { sessions: new Set(sessions), machines: new Map(machines) }
178
+ } catch {
179
+ return EMPTY
180
+ }
181
+ }
@@ -0,0 +1,148 @@
1
+ // GET /api/agent/events?cursor=&wait=&limit=&type=&session= (lifecycle design, frozen "Events route and tool"):
2
+ // the canonical journal (events.ts) read with an opaque cursor (event-cursor.ts).
3
+ //
4
+ // - No cursor: no events and a cursor at the latest record (a long poll then waits for what comes next).
5
+ // - wait: seconds 0–60 to hold the request while nothing matches (local appends wake it at once; appends on other
6
+ // pods are rechecked every RECHECK_MS). limit: 1–200 (default 50). type and session may repeat (≤ 20 each);
7
+ // a type ending in `.*` matches a family of types.
8
+ // - At most INSPECT_MAX records are inspected per request. Filters advance the cursor over every inspected record;
9
+ // `more` means records remain unread, matching or not.
10
+ // - A cursor the journal no longer covers (expired, trimmed, another epoch after a restart) answers a typed reset
11
+ // with a fresh cursor and the reads to refresh; a malformed, forged, future, unsupported-version or another
12
+ // user's cursor answers 400 invalid_cursor.
13
+ //
14
+ // Who sees what: the user everything. App tokens need events:subscribe for the route and, per family, sessions:read
15
+ // (session), sessions:manage (manage), messages:read and being a party (message), sessions:launch and having started
16
+ // it (launch). Session tokens see session and manage events, messages they are a party to and launches they started.
17
+ // Reader-only fields (family, audience, position) never leave the hub.
18
+ import type { AgentActor } from "./actor"
19
+ import type { Directory } from "./directory"
20
+ import { decodeCursor, encodeCursor, parsePosition, type CursorKeys } from "./event-cursor"
21
+ import type { AgentEvent, AgentEvents, EventRecord } from "./events"
22
+ import { partyFromActor, partyKey } from "./envelope"
23
+ import { AgentError } from "./errors"
24
+ import { launchedBy } from "./launch-routes"
25
+ import { AGENT_PREFIX, type AgentRoute } from "./routes"
26
+
27
+ export const EVENTS_WAIT_MAX_S = 60
28
+ export const EVENTS_LIMIT_MAX = 200
29
+ export const EVENTS_LIMIT_DEFAULT = 50
30
+ export const INSPECT_MAX = 1000
31
+ export const RECHECK_MS = 1000
32
+ export const RESET_REFRESH = ["status", "sessions", "inbox", "decisions", "launches"] as const
33
+ const FILTER_MAX = 20
34
+ const TYPE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*(\.\*)?$/
35
+
36
+ const NO_STORE = { "cache-control": "no-store" }
37
+
38
+ export interface EventsRouteDeps {
39
+ readonly events: Pick<AgentEvents, "latest" | "since" | "appended">
40
+ readonly keys: CursorKeys
41
+ readonly directory: Pick<Directory, "resolve">
42
+ }
43
+
44
+ export interface EventsPage {
45
+ readonly events: AgentEvent[]
46
+ readonly cursor: string
47
+ readonly more: boolean
48
+ readonly reset: boolean
49
+ readonly refresh?: readonly string[]
50
+ }
51
+
52
+ const invalid = (message: string) => new AgentError("invalid", message)
53
+ const badCursor = () => new AgentError("invalid_cursor", "This cursor is not valid here: start again without one", { hint: `GET ${AGENT_PREFIX}/events without cursor` })
54
+
55
+ function intParam(url: URL, key: string, min: number, max: number, fallback: number): number {
56
+ const raw = url.searchParams.get(key)
57
+ if (raw === null || raw === "") return fallback
58
+ if (!/^\d+$/.test(raw) || Number(raw) < min || Number(raw) > max) throw invalid(`${key} must be a whole number from ${min} to ${max}`)
59
+ return Number(raw)
60
+ }
61
+
62
+ /** Whether an actor may read a record. */
63
+ export function visible(record: EventRecord, actor: AgentActor): boolean {
64
+ if (actor.kind === "user") return true
65
+ const party = partyKey(partyFromActor(actor))
66
+ const has = (scope: string) => actor.kind === "session" || actor.scopes.includes(scope)
67
+ switch (record.family) {
68
+ case "session":
69
+ return has("sessions:read")
70
+ case "manage":
71
+ return has("sessions:manage")
72
+ case "message":
73
+ return has("messages:read") && (record.audience?.parties ?? []).includes(party)
74
+ case "launch":
75
+ return has("sessions:launch") && record.audience?.by === launchedBy(actor)
76
+ }
77
+ }
78
+
79
+ export function eventsRoutes(deps: EventsRouteDeps): AgentRoute[] {
80
+ return [
81
+ {
82
+ method: "GET",
83
+ path: `${AGENT_PREFIX}/events`,
84
+ allow: { session: true, scope: "events:subscribe" },
85
+ handle: async ({ user, url, actor, request }) => {
86
+ const wait = intParam(url, "wait", 0, EVENTS_WAIT_MAX_S, 0)
87
+ const limit = intParam(url, "limit", 1, EVENTS_LIMIT_MAX, EVENTS_LIMIT_DEFAULT)
88
+ const types = url.searchParams.getAll("type").filter(Boolean)
89
+ const addrs = url.searchParams.getAll("session").filter(Boolean)
90
+ if (types.length > FILTER_MAX || addrs.length > FILTER_MAX) throw invalid(`At most ${FILTER_MAX} type and ${FILTER_MAX} session filters`)
91
+ if (types.some((t) => t.length > 100 || !TYPE.test(t))) throw invalid("type must be an event type such as session.changed or launch.*")
92
+ const sessions = addrs.length ? new Set(addrs.flatMap((addr) => deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID))) : undefined
93
+ const page = await read(deps, user.id, actor, { cursor: url.searchParams.get("cursor") ?? undefined, wait, limit, types, sessions }, request.signal)
94
+ return Response.json(page, { headers: NO_STORE })
95
+ },
96
+ },
97
+ ]
98
+ }
99
+
100
+ interface ReadOptions {
101
+ readonly cursor?: string
102
+ readonly wait: number
103
+ readonly limit: number
104
+ readonly types: readonly string[]
105
+ readonly sessions?: ReadonlySet<string>
106
+ }
107
+
108
+ const typeMatches = (types: readonly string[], type: string) => types.length === 0 || types.some((t) => (t.endsWith(".*") ? type.startsWith(t.slice(0, -1)) : t === type))
109
+
110
+ const sessionMatches = (sessions: ReadonlySet<string> | undefined, event: AgentEvent) =>
111
+ !sessions || [event.subject.sessionID, event.subject.rootID, event.subject.senderID].some((id) => id !== undefined && sessions.has(id))
112
+
113
+ export async function read(deps: EventsRouteDeps, userID: string, actor: AgentActor, options: ReadOptions, signal?: AbortSignal): Promise<EventsPage> {
114
+ const { events, keys } = deps
115
+ const page = (position: string, found: AgentEvent[], more: boolean): EventsPage => ({ events: found, cursor: encodeCursor(keys, userID, position), more, reset: false })
116
+ let position: string
117
+ if (options.cursor === undefined || options.cursor === "") {
118
+ position = await events.latest(userID)
119
+ if (options.wait === 0) return page(position, [], false)
120
+ } else {
121
+ const decoded = decodeCursor(keys, userID, options.cursor)
122
+ const held = decoded ? parsePosition(decoded) : undefined
123
+ if (!decoded || !held) throw badCursor()
124
+ const latest = parsePosition(await events.latest(userID))
125
+ if (latest && latest.epoch === held.epoch && held.n > latest.n) throw badCursor()
126
+ position = decoded
127
+ }
128
+ const deadline = performance.now() + options.wait * 1000
129
+ for (;;) {
130
+ const result = await events.since(userID, position)
131
+ if ("reset" in result) return { events: [], cursor: encodeCursor(keys, userID, await events.latest(userID)), more: false, reset: true, refresh: RESET_REFRESH }
132
+ const found: AgentEvent[] = []
133
+ let more = result.records.length > INSPECT_MAX
134
+ for (const record of result.records.slice(0, INSPECT_MAX)) {
135
+ if (visible(record, actor) && typeMatches(options.types, record.event.type) && sessionMatches(options.sessions, record.event)) {
136
+ if (found.length === options.limit) {
137
+ more = true
138
+ break
139
+ }
140
+ found.push(record.event)
141
+ }
142
+ position = record.position
143
+ }
144
+ const left = deadline - performance.now()
145
+ if (found.length > 0 || more || left <= 0 || signal?.aborted) return page(position, found, more)
146
+ await events.appended(userID, Math.min(RECHECK_MS, left), signal)
147
+ }
148
+ }
@@ -0,0 +1,277 @@
1
+ // The canonical agent event journal (lifecycle design: Events; decision xwzersmm). One bounded journal per user of
2
+ // compact events that GET /api/agent/events, MCP `events` and /api/agent/watch read:
3
+ //
4
+ // {id: "evt_…", at, type, subject: {sessionID?, rootID?, messageID?, senderID?, launchID?, machineID?}, data}
5
+ //
6
+ // Payloads are built from allowlists here and nowhere else: never prompts, message bodies, titles, blocker
7
+ // summaries, launch reasons or labels, credentials, or connection/pod/spoke IDs. Each record also keeps, for the
8
+ // reader only, its scope family (session, manage, message, launch) and its audience (the parties of a message, who
9
+ // started a launch), so an app or session token sees only what its scopes and identity allow.
10
+ //
11
+ // Storage is an EventLog (a Valkey stream per user in a cluster, process memory otherwise) bounded to
12
+ // AGENT_EVENTS_MAX events and AGENT_EVENTS_AGE_MS. An event id is assigned once, before any cross-pod fan-out;
13
+ // append claims it in the Kv first (SET NX), so appending the same id twice stores it once.
14
+ import { randomBytes } from "node:crypto"
15
+ import type { Clock, EventLog, Kv } from "../backend/types"
16
+ import type { LiveStatus } from "../registry"
17
+ import { partyKey } from "./envelope"
18
+ import type { Launch } from "./launch-store"
19
+ import { addressOf, type Message } from "./messages"
20
+
21
+ export const AGENT_EVENTS_MAX = 10_000
22
+ export const AGENT_EVENTS_AGE_MS = 86_400_000
23
+ /** Record format version (readers reject others rather than skip them). */
24
+ export const AGENT_EVENT_VERSION = 1
25
+
26
+ /** The scope family a reader needs: sessions:read, sessions:manage, messages:read, sessions:launch. */
27
+ export type EventFamily = "session" | "manage" | "message" | "launch"
28
+
29
+ export type EventSubject = Readonly<Record<string, string>>
30
+ export type EventData = Readonly<Record<string, string | number | boolean>>
31
+
32
+ /** An event as readers receive it. */
33
+ export interface AgentEvent {
34
+ readonly id: string
35
+ readonly at: number
36
+ readonly type: string
37
+ readonly subject: EventSubject
38
+ readonly data: EventData
39
+ }
40
+
41
+ /** Who else may read an event beyond the user: the party keys of a message, who started a launch. */
42
+ export interface EventAudience {
43
+ readonly parties?: readonly string[]
44
+ readonly by?: string
45
+ }
46
+
47
+ /** What a writer appends. */
48
+ export interface EventInput {
49
+ /** A stable id (assigned before fan-out); a new one when absent. */
50
+ readonly id?: string
51
+ readonly type: string
52
+ readonly family: EventFamily
53
+ readonly subject: EventSubject
54
+ readonly data: EventData
55
+ readonly audience?: EventAudience
56
+ }
57
+
58
+ /** A journal record: the event, its reader-only scope fields, and its position in the user's log. */
59
+ export interface EventRecord {
60
+ readonly position: string
61
+ readonly event: AgentEvent
62
+ readonly family: EventFamily
63
+ readonly audience?: EventAudience
64
+ }
65
+
66
+ /** Records after a position, or `reset` when the journal no longer covers it (expired, trimmed, restarted). */
67
+ export type EventsSince = { readonly records: EventRecord[] } | { readonly reset: true }
68
+
69
+ const FAMILIES: ReadonlySet<string> = new Set<EventFamily>(["session", "manage", "message", "launch"])
70
+ const EVENT_ID = /^evt_[0-9a-f]{20}$/
71
+
72
+ export const newEventID = (): string => `evt_${randomBytes(10).toString("hex")}`
73
+
74
+ export interface AgentEventsOptions {
75
+ readonly log: EventLog
76
+ readonly kv: Kv
77
+ readonly now?: Clock
78
+ readonly onAppend?: (userID: string, record: Pick<EventRecord, "event" | "family" | "audience">) => void | Promise<void>
79
+ }
80
+
81
+ export class AgentEvents {
82
+ private readonly log: EventLog
83
+ private readonly kv: Kv
84
+ private readonly now: Clock
85
+ private readonly onAppend?: AgentEventsOptions["onAppend"]
86
+ /** Readers waiting for this process's next append, per user (long polling; other pods' appends are rechecked). */
87
+ private readonly waiters = new Map<string, Set<() => void>>()
88
+
89
+ constructor(options: AgentEventsOptions) {
90
+ this.log = options.log
91
+ this.kv = options.kv
92
+ this.now = options.now ?? Date.now
93
+ this.onAppend = options.onAppend
94
+ }
95
+
96
+ /** Stores an event once per id; returns its id. */
97
+ async append(userID: string, input: EventInput): Promise<string> {
98
+ const id = input.id ?? newEventID()
99
+ if (!EVENT_ID.test(id)) throw new Error("Invalid event id")
100
+ const claim = `aev:${userID}:${id}`
101
+ if (!(await this.kv.set(claim, "1", { nx: true, px: AGENT_EVENTS_AGE_MS }))) return id
102
+ const record = {
103
+ type: input.type,
104
+ v: AGENT_EVENT_VERSION,
105
+ id,
106
+ at: this.now(),
107
+ family: input.family,
108
+ subject: input.subject,
109
+ data: input.data,
110
+ ...(input.audience ? { audience: input.audience } : {}),
111
+ }
112
+ try {
113
+ await this.log.append(userID, record)
114
+ } catch (cause) {
115
+ // Not stored: a retry with the same id must be able to store it.
116
+ await this.kv.del(claim).catch(() => {})
117
+ throw cause
118
+ }
119
+ await this.onAppend?.(userID, {
120
+ event: { id, at: record.at, type: input.type, subject: input.subject, data: input.data },
121
+ family: input.family,
122
+ ...(input.audience ? { audience: input.audience } : {}),
123
+ })
124
+ const waiting = this.waiters.get(userID)
125
+ if (waiting) for (const wake of [...waiting]) wake()
126
+ return id
127
+ }
128
+
129
+ /** Resolves on this process's next append for the user, after `ms`, or when `signal` aborts. */
130
+ appended(userID: string, ms: number, signal?: AbortSignal): Promise<void> {
131
+ return new Promise((resolve) => {
132
+ let set = this.waiters.get(userID)
133
+ if (!set) this.waiters.set(userID, (set = new Set()))
134
+ const done = () => {
135
+ clearTimeout(timer)
136
+ signal?.removeEventListener("abort", done)
137
+ set!.delete(done)
138
+ if (set!.size === 0 && this.waiters.get(userID) === set) this.waiters.delete(userID)
139
+ resolve()
140
+ }
141
+ const timer = setTimeout(done, ms)
142
+ set.add(done)
143
+ if (signal?.aborted) done()
144
+ else signal?.addEventListener("abort", done, { once: true })
145
+ })
146
+ }
147
+
148
+ /** The position of the user's latest record; a reader holding it gets every later one. */
149
+ latest(userID: string): Promise<string> {
150
+ return this.log.latest(userID)
151
+ }
152
+
153
+ async since(userID: string, position: string): Promise<EventsSince> {
154
+ const result = await this.log.since(userID, position)
155
+ if ("resync" in result) return { reset: true }
156
+ const records: EventRecord[] = []
157
+ for (const logged of result.events) {
158
+ const m = logged.message as Record<string, unknown>
159
+ // A record this version cannot read is a reset, never a silent skip.
160
+ if (m.v !== AGENT_EVENT_VERSION || typeof m.id !== "string" || typeof m.at !== "number" || !FAMILIES.has(m.family as string)) return { reset: true }
161
+ records.push({
162
+ position: logged.id,
163
+ event: { id: m.id, at: m.at, type: String(m.type), subject: (m.subject ?? {}) as EventSubject, data: (m.data ?? {}) as EventData },
164
+ family: m.family as EventFamily,
165
+ ...(m.audience ? { audience: m.audience as EventAudience } : {}),
166
+ })
167
+ }
168
+ return { records }
169
+ }
170
+ }
171
+
172
+ // ---- Compact builders (allowlists) ------------------------------------------------------------------------
173
+
174
+ /** A stored agent message changed (sent, delivered, answered…): never its text. */
175
+ export function messageEvent(message: Message): EventInput {
176
+ const { id, kind, state, from, recipient, replyTo, delivery } = message
177
+ return {
178
+ type: "message.changed",
179
+ family: "message",
180
+ subject: {
181
+ messageID: id,
182
+ ...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
183
+ ...(from.kind === "session" ? { senderID: from.sessionID } : {}),
184
+ },
185
+ data: { kind, state, from: addressOf(from), to: message.to, ...(replyTo ? { replyTo } : {}), ...(delivery ? { delivery } : {}) },
186
+ audience: { parties: [partyKey(from), partyKey(recipient)] },
187
+ }
188
+ }
189
+
190
+ /** A launch changed: never its prompt, reason or labels. */
191
+ export function launchEvent(launch: Launch): EventInput {
192
+ return {
193
+ type: "launch.changed",
194
+ family: "launch",
195
+ subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
196
+ data: { state: launch.state, ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}), ...(launch.name !== undefined ? { name: launch.name } : {}) },
197
+ ...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
198
+ }
199
+ }
200
+
201
+ /** A session's archive state changed. */
202
+ export const archiveEvent = (sessionID: string, archived: boolean): EventInput => ({ type: "session.archived", family: "manage", subject: { sessionID }, data: { archived } })
203
+
204
+ export const pinnedEvent = (sessionID: string, pinned: boolean): EventInput => ({ type: "session.pinned", family: "manage", subject: { sessionID }, data: { pinned } })
205
+
206
+ /** A session was explicitly named (names are validated slugs: a-z, 0-9 and -). */
207
+ export const namedEvent = (sessionID: string, name: string): EventInput => ({ type: "session.named", family: "manage", subject: { sessionID }, data: { name } })
208
+
209
+ /** A session claimed (held) or released a role; role and project are validated slugs. */
210
+ export function roleEvent(sessionID: string, held: boolean, role: { role: string; project?: string; generation?: number; exclusive?: boolean }): EventInput {
211
+ return {
212
+ type: held ? "role.claimed" : "role.released",
213
+ family: "manage",
214
+ subject: { sessionID },
215
+ data: {
216
+ role: role.role,
217
+ ...(role.project !== undefined ? { project: role.project } : {}),
218
+ ...(held && role.generation !== undefined ? { generation: role.generation } : {}),
219
+ ...(held && role.exclusive ? { exclusive: true } : {}),
220
+ },
221
+ }
222
+ }
223
+
224
+ interface Known {
225
+ state: string
226
+ blockers: Set<string>
227
+ }
228
+
229
+ /**
230
+ * State and blocker changes of status trees, against what was seen before (one per writer). Bounded by the live
231
+ * trees: a tree's entry is replaced by each status of it and dropped by forget().
232
+ */
233
+ export class StatusDiff {
234
+ private readonly trees = new Map<string, Map<string, Known>>()
235
+
236
+ events(rootID: string, sessions: readonly LiveStatus[]): EventInput[] {
237
+ const before = this.trees.get(rootID) ?? new Map<string, Known>()
238
+ const after = new Map<string, Known>()
239
+ const out: EventInput[] = []
240
+ for (const entry of sessions) {
241
+ const known = before.get(entry.id)
242
+ const subject = { sessionID: entry.id, rootID }
243
+ const open = new Set(entry.blockers.map((b) => b.id))
244
+ for (const b of entry.blockers) if (!known?.blockers.has(b.id)) out.push({ type: "blocker.opened", family: "session", subject, data: { blockerID: b.id, kind: b.kind } })
245
+ for (const id of known?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", family: "session", subject, data: { blockerID: id } })
246
+ if (known?.state !== entry.state) out.push({ type: "session.changed", family: "session", subject, data: { state: entry.state, ...(known ? { prev: known.state } : {}) } })
247
+ after.set(entry.id, { state: entry.state, blockers: open })
248
+ }
249
+ this.trees.set(rootID, after)
250
+ return out
251
+ }
252
+
253
+ /** Drops a tree that is gone. */
254
+ forget(rootID: string): void {
255
+ this.trees.delete(rootID)
256
+ }
257
+
258
+ roots(): string[] {
259
+ return [...this.trees.keys()]
260
+ }
261
+ }
262
+
263
+ /** Presence per user (sessions and machines) diffed against the previous view (the catalog writer's). */
264
+ export interface PresenceView {
265
+ readonly sessions: ReadonlySet<string>
266
+ /** Machine id → name, for the machines with a connected process. */
267
+ readonly machines: ReadonlyMap<string, string>
268
+ }
269
+
270
+ export function presenceEvents(before: PresenceView, after: PresenceView): EventInput[] {
271
+ const out: EventInput[] = []
272
+ for (const id of after.sessions) if (!before.sessions.has(id)) out.push({ type: "session.added", family: "session", subject: { sessionID: id }, data: {} })
273
+ for (const id of before.sessions) if (!after.sessions.has(id)) out.push({ type: "session.removed", family: "session", subject: { sessionID: id }, data: {} })
274
+ for (const [id, name] of after.machines) if (!before.machines.has(id)) out.push({ type: "machine.changed", family: "session", subject: { machineID: id }, data: { name, online: true } })
275
+ for (const [id, name] of before.machines) if (!after.machines.has(id)) out.push({ type: "machine.changed", family: "session", subject: { machineID: id }, data: { name, online: false } })
276
+ return out
277
+ }
@@ -43,7 +43,7 @@ export function withSuffix(base: string, taken: (name: string) => boolean): stri
43
43
  export const isReservedName = (name: string): boolean => name === "me"
44
44
 
45
45
  /** Roles only the user may claim in phase 1 (grants including `roles` come in phase 3). */
46
- const RESERVED_ROLES = ["orchestrator", "concierge"]
46
+ const RESERVED_ROLES = ["orchestrator"]
47
47
 
48
48
  export const isReservedRole = (role: string): boolean => RESERVED_ROLES.some((prefix) => role.startsWith(prefix))
49
49