@huynle/hub 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/protocol/src/harness-events.ts +2 -2
  142. package/packages/protocol/src/index.ts +70 -7
  143. package/packages/runtime-openshell/package.json +1 -1
  144. package/packages/runtime-sdk/package.json +1 -1
  145. package/packages/sdk/package.json +1 -1
  146. package/packages/sdk/src/client.ts +55 -4
  147. package/packages/sdk/src/errors.ts +1 -0
  148. package/packages/sdk/src/sse.ts +4 -1
  149. package/packages/sdk/src/types.ts +59 -0
  150. package/packages/spoke-sdk/package.json +1 -1
  151. package/packages/spoke-sdk/src/enroll.ts +26 -4
  152. package/src/central/agent/app-tokens.ts +27 -17
  153. package/src/central/agent/archive-inactive.ts +66 -0
  154. package/src/central/agent/directory-routes.ts +155 -12
  155. package/src/central/agent/directory.ts +1 -1
  156. package/src/central/agent/errors.ts +2 -0
  157. package/src/central/agent/event-cursor.ts +49 -0
  158. package/src/central/agent/event-writers.ts +181 -0
  159. package/src/central/agent/events-routes.ts +148 -0
  160. package/src/central/agent/events.ts +277 -0
  161. package/src/central/agent/names.ts +1 -1
  162. package/src/central/agent/read-routes.ts +32 -36
  163. package/src/central/agent/routes.ts +14 -11
  164. package/src/central/agent/watch.ts +65 -174
  165. package/src/central/agent/webhook-routes.ts +10 -2
  166. package/src/central/agent/webhooks.ts +49 -5
  167. package/src/central/auth.ts +127 -1
  168. package/src/central/backend/index.ts +3 -0
  169. package/src/central/backend/types.ts +2 -0
  170. package/src/central/http.ts +116 -32
  171. package/src/central/mcp-http.ts +116 -0
  172. package/src/central/push.ts +14 -1
  173. package/src/central/registry.ts +34 -5
  174. package/src/central/store.ts +60 -0
  175. package/src/config.ts +70 -7
  176. package/src/index.ts +90 -91
  177. package/src/spoke/launchers.ts +109 -3
  178. package/src/spoke/spoke.ts +8 -4
  179. package/src/spoke/timeline.ts +59 -3
  180. package/src/spoke/tracker.ts +59 -6
  181. package/src/status.ts +97 -6
  182. package/web/dist/apple-touch-icon.png +0 -0
  183. package/web/dist/assets/index-Dm8OmkaO.css +1 -0
  184. package/web/dist/assets/index-FUJbj_Du.js +20 -0
  185. package/web/dist/favicon.ico +0 -0
  186. package/web/dist/icon-192.png +0 -0
  187. package/web/dist/icon-512.png +0 -0
  188. package/web/dist/icon-maskable-512.png +0 -0
  189. package/web/dist/icon.svg +5 -5
  190. package/web/dist/index.html +10 -4
  191. package/web/dist/manifest.webmanifest +2 -2
  192. package/web/dist/sw.js +4 -1
  193. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  194. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -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
 
@@ -19,9 +19,8 @@ import type { Directory, DirectoryEntry, RoleView } from "./directory"
19
19
  import type { AgentActor } from "./actor"
20
20
  import { AgentError } from "./errors"
21
21
  import { AGENT_PREFIX, type AgentRoute } from "./routes"
22
- import { partyFromActor, partyKey } from "./envelope"
23
- import { launchedBy } from "./launch-routes"
24
- import { type LaunchSource, watchStream, type MessageSource, type WatchRegistry } from "./watch"
22
+ import { watchStream } from "./watch"
23
+ import type { EventsRouteDeps } from "./events-routes"
25
24
 
26
25
  /** Labels per session ID. */
27
26
  export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
@@ -46,14 +45,10 @@ export interface ReadDeps {
46
45
  readonly directory: Directory
47
46
  readonly registry: ReadRegistry
48
47
  readonly spokes?: SpokeRequester
49
- /** Agent messages for the watch stream's message events. */
50
- readonly feed?: MessageSource
51
48
  /** The labels of the user's launched sessions (none without it). */
52
49
  readonly labels?: (userID: string) => Promise<SessionLabels>
53
- /** Launch changes for the watch stream's launch events. */
54
- readonly launches?: LaunchSource
55
- /** What the watch stream of a user reads (cluster mode: status from every pod); the registry when absent. */
56
- readonly watchRegistry?: (userID: string) => WatchRegistry
50
+ /** The journal GET /api/agent/watch streams (no watch route without it). */
51
+ readonly events?: EventsRouteDeps
57
52
  /** Watch stream heartbeat (default WATCH_HEARTBEAT_MS). */
58
53
  readonly heartbeatMs?: number
59
54
  /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
@@ -358,32 +353,33 @@ export function readRoutes(deps: ReadDeps): AgentRoute[] {
358
353
  allow: READ,
359
354
  handle: async ({ user, params, url }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"))),
360
355
  },
361
- {
362
- method: "GET",
363
- path: `${AGENT_PREFIX}/watch`,
364
- allow: READ,
365
- handle: ({ user, url, request, actor }) => {
366
- const addr = url.searchParams.get("session") || undefined
367
- // Message events: the user sees all; a session, or an app with messages:read, those it is a party to.
368
- const messageReader = actor.kind === "user" || actor.kind === "session" || actor.scopes.includes("messages:read")
369
- const messages = deps.feed && messageReader ? { feed: deps.feed, ...(actor.kind === "user" ? {} : { party: partyKey(partyFromActor(actor)) }) } : undefined
370
- const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
371
- const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
372
- // Launch events: the user sees all; an app or session those it started.
373
- const launches = deps.launches ? { feed: deps.launches, ...(actor.kind === "user" ? {} : { by: launchedBy(actor) }) } : undefined
374
- return watchStream({
375
- registry: deps.watchRegistry?.(user.id) ?? deps.registry,
376
- userID: user.id,
377
- signal: request.signal,
378
- blocked,
379
- ...(sessions ? { sessions } : {}),
380
- ...(messages ? { messages } : {}),
381
- ...(launches ? { launches } : {}),
382
- ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
383
- ...(deps.now ? { now: deps.now } : {}),
384
- ...(deps.draining ? { draining: deps.draining } : {}),
385
- })
386
- },
387
- },
356
+ ...(deps.events ? [watchRoute(deps, deps.events)] : []),
388
357
  ]
389
358
  }
359
+
360
+ /** GET /api/agent/watch: the journal's events live (watch.ts), with the events route's visibility and filters. */
361
+ function watchRoute(deps: ReadDeps, events: EventsRouteDeps): AgentRoute {
362
+ return {
363
+ method: "GET",
364
+ path: `${AGENT_PREFIX}/watch`,
365
+ allow: READ,
366
+ handle: ({ user, url, request, actor }) => {
367
+ const addr = url.searchParams.get("session") || undefined
368
+ const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
369
+ const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
370
+ const cursor = url.searchParams.get("cursor") || request.headers.get("last-event-id") || undefined
371
+ return watchStream({
372
+ events,
373
+ userID: user.id,
374
+ actor,
375
+ signal: request.signal,
376
+ ...(blocked ? { types: ["blocker.*"] } : {}),
377
+ ...(sessions ? { sessions } : {}),
378
+ ...(cursor ? { cursor } : {}),
379
+ ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
380
+ ...(deps.now ? { now: deps.now } : {}),
381
+ ...(deps.draining ? { draining: deps.draining } : {}),
382
+ })
383
+ },
384
+ }
385
+ }
@@ -6,6 +6,9 @@
6
6
  // requests skip it) → 403 forbidden; credential → 401 unauthorized / token_expired; route → 404 not_found;
7
7
  // route class (agentAllows) → 403 forbidden; then the handler, whose throws become the envelope (agentFailure).
8
8
  // Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
9
+ import type { AgentEvents } from "./events"
10
+ import type { CursorKeys } from "./event-cursor"
11
+ import { eventsRoutes } from "./events-routes"
9
12
  import type { DeviceRecord, Store, UserRecord } from "../store"
10
13
  import type { Directory } from "./directory"
11
14
  import { directoryRoutes } from "./directory-routes"
@@ -25,7 +28,7 @@ import { type ActingSwitch, actingSwitchRoutes } from "./acting-switch"
25
28
  import { Acting, type ActingRegistry, type Escalate } from "./acting"
26
29
  import { actingRoutes } from "./acting-routes"
27
30
  import type { Actions } from "../actions"
28
- import { type LaunchFeed, launchRoutes, type RouteLaunches } from "./launch-routes"
31
+ import { launchRoutes, type RouteLaunches } from "./launch-routes"
29
32
  import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
30
33
 
31
34
  export const AGENT_PREFIX = "/api/agent"
@@ -87,8 +90,10 @@ export interface AgentDeps {
87
90
  readonly launches?: RouteLaunches
88
91
  /** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
89
92
  readonly webhooks?: RouteWebhooks
90
- /** Launch changes: the watch stream's launch events. */
91
- readonly launchFeed?: Pick<LaunchFeed, "subscribe">
93
+ /** The canonical agent event journal: archive changes are appended to it. */
94
+ readonly agentEvents?: Pick<AgentEvents, "append" | "latest" | "since" | "appended">
95
+ /** Signing keys of GET /api/agent/events cursors; the events route is served when given with agentEvents. */
96
+ readonly eventCursorKeys?: CursorKeys
92
97
  /** needs_user escalations of the act verbs (P3-T8 opens a Decision); without it needs_user is only answered. */
93
98
  readonly escalate?: Escalate
94
99
  /** The console's prompt limit, for the user's prompts on the act route. */
@@ -97,8 +102,6 @@ export interface AgentDeps {
97
102
  readonly feed?: MessageFeed
98
103
  /** Heartbeat of GET /api/agent/watch. */
99
104
  readonly heartbeatMs?: number
100
- /** What GET /api/agent/watch reads per user (cluster mode); the registry when absent. */
101
- readonly watchRegistry?: ReadDeps["watchRegistry"]
102
105
  /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
103
106
  readonly draining?: AbortSignal
104
107
  readonly now?: () => number
@@ -123,9 +126,10 @@ const userView = (user: UserRecord) => ({
123
126
  export function agentRoutes(deps: AgentDeps): AgentRoute[] {
124
127
  const feed = deps.feed ?? new MessageFeed()
125
128
  return [
126
- ...(deps.directory ? directoryRoutes(deps.directory) : []),
129
+ ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}) }) : []),
130
+ ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory }) : []),
127
131
  ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
128
- ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.delivery ? { feed } : {}), ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.watchRegistry ? { watchRegistry: deps.watchRegistry } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
132
+ ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
129
133
  {
130
134
  method: "GET",
131
135
  path: `${AGENT_PREFIX}/whoami`,
@@ -144,9 +148,9 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
144
148
  ]
145
149
  }
146
150
 
147
- /** What the read routes show of launches: launched sessions' labels (the latest launch wins) and launch events. */
148
- function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels" | "launches"> {
149
- const { launches, launchFeed } = deps
151
+ /** What the read routes show of launches: launched sessions' labels (the latest launch wins). */
152
+ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"> {
153
+ const { launches } = deps
150
154
  return {
151
155
  ...(launches
152
156
  ? {
@@ -158,7 +162,6 @@ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels" | "launches"> {
158
162
  },
159
163
  }
160
164
  : {}),
161
- ...(launchFeed ? { launches: launchFeed } : {}),
162
165
  }
163
166
  }
164
167