@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,389 @@
1
+ // Reading on /api/agent (design §4): the snapshot and the flat session list, built only from the hub's catalog
2
+ // (Registry) and directory, never from a spoke.
3
+ //
4
+ // Routes (user actors always; session tokens and app tokens holding sessions:read):
5
+ // GET /api/agent/status
6
+ // GET /api/agent/sessions?project=&state=&machine=&label=k=v
7
+ // GET /api/agent/sessions/:addr show: the catalog view plus the spoke's summary (when it serves one)
8
+ // GET /api/agent/sessions/:addr/log?n=20 the newest timeline rows (spoke feature "timeline")
9
+ // GET /api/agent/watch?blocked=1&session=<addr> SSE of compact events (watch.ts)
10
+ // show and log call spokes; a spoke without the feature answers unsupported {feature, harness}.
11
+ // A session a launch started carries that launch's labels (the latest launch's, when several did); others have none.
12
+ import path from "node:path"
13
+ import { featureForOp, type ServerRequest, type SpokeFeature } from "../../protocol"
14
+ import type { CatalogMachine, CatalogSession, LiveStatus, NowActivity, RegistryMessage, SessionView } from "../registry"
15
+ import { unsupportedMessage } from "../actions"
16
+ import { SpokeError } from "../spoke-ws"
17
+ import type { Store } from "../store"
18
+ import type { Directory, DirectoryEntry, RoleView } from "./directory"
19
+ import type { AgentActor } from "./actor"
20
+ import { AgentError } from "./errors"
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"
25
+
26
+ /** Labels per session ID. */
27
+ export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
28
+
29
+ /** What the read routes need from the Registry. */
30
+ export interface ReadRegistry {
31
+ catalog(): CatalogMachine[]
32
+ sessionTenant(sessionID: string): string | undefined
33
+ session(sessionID: string): SessionView | undefined
34
+ owner(sessionID: string): string | undefined
35
+ supports(connID: string, feature: SpokeFeature): boolean
36
+ subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
37
+ }
38
+
39
+ /** Hub → spoke requests (SpokeHub). */
40
+ export interface SpokeRequester {
41
+ request(connID: string, req: ServerRequest): Promise<unknown>
42
+ }
43
+
44
+ export interface ReadDeps {
45
+ readonly store: Pick<Store, "machines">
46
+ readonly directory: Directory
47
+ readonly registry: ReadRegistry
48
+ readonly spokes?: SpokeRequester
49
+ /** Agent messages for the watch stream's message events. */
50
+ readonly feed?: MessageSource
51
+ /** The labels of the user's launched sessions (none without it). */
52
+ 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
57
+ /** Watch stream heartbeat (default WATCH_HEARTBEAT_MS). */
58
+ readonly heartbeatMs?: number
59
+ /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
60
+ readonly draining?: AbortSignal
61
+ readonly now?: () => number
62
+ }
63
+
64
+ /** One session in agent answers (status, sessions). */
65
+ export interface AgentSessionView {
66
+ readonly id: string
67
+ readonly name?: string
68
+ readonly roles: string[]
69
+ readonly title: string
70
+ readonly state: string
71
+ readonly harness: string
72
+ readonly parentID?: string
73
+ readonly busySince?: number
74
+ readonly now?: string
75
+ readonly blockers: { kind: string; id: string; summary: string; sessionID?: string }[]
76
+ readonly children: number
77
+ readonly labels: Record<string, string>
78
+ }
79
+
80
+ export interface AttentionItem {
81
+ readonly kind: string
82
+ readonly sessionID: string
83
+ readonly name?: string
84
+ readonly id?: string
85
+ readonly summary: string
86
+ }
87
+
88
+ /** A session the user owns, where it is reported. */
89
+ interface Row {
90
+ readonly machineID: string
91
+ readonly harness: string
92
+ readonly session: CatalogSession
93
+ }
94
+
95
+ const roleLabel = (r: RoleView) => (r.project !== undefined ? `${r.role}.${r.project}` : r.role)
96
+
97
+ /** "bash: bun test", from the session's latest running activity item. */
98
+ const nowText = (now: NowActivity) => [now.tool ?? (now.kind === "thinking" ? "thinking" : undefined), now.summary].filter(Boolean).join(": ") || now.kind
99
+
100
+ /** The user's sessions in the catalog, once each (the first process reporting one wins). */
101
+ function rows(registry: ReadRegistry, userID: string): Row[] {
102
+ const out = new Map<string, Row>()
103
+ for (const machine of registry.catalog()) {
104
+ for (const process of machine.processes) {
105
+ for (const session of process.sessions) {
106
+ if (out.has(session.id) || registry.sessionTenant(session.id) !== userID) continue
107
+ out.set(session.id, { machineID: machine.id, harness: process.harness, session })
108
+ }
109
+ }
110
+ }
111
+ return [...out.values()]
112
+ }
113
+
114
+ const NO_LABELS: SessionLabels = new Map()
115
+
116
+ function view({ session: s, harness }: Row, entry: DirectoryEntry | undefined, labels: SessionLabels): AgentSessionView {
117
+ const own = s.status.find((e) => e.id === s.id) as LiveStatus | undefined
118
+ return {
119
+ id: s.id,
120
+ ...(entry?.name ? { name: `@${entry.name}` } : {}),
121
+ roles: (entry?.roles ?? []).map(roleLabel),
122
+ title: own?.title ?? s.title,
123
+ state: s.state,
124
+ harness,
125
+ ...(s.parentID ? { parentID: s.parentID } : {}),
126
+ ...(own?.since !== undefined ? { busySince: own.since } : {}),
127
+ ...(s.now ? { now: nowText(s.now) } : {}),
128
+ blockers: s.blockers.map((b) => ({ kind: b.kind, id: b.id, summary: b.summary, ...(b.sessionID !== s.id ? { sessionID: b.sessionID } : {}) })),
129
+ children: s.status.filter((e) => e.parentID === s.id).length,
130
+ labels: { ...labels.get(s.id) },
131
+ }
132
+ }
133
+
134
+ const projectOf = (s: CatalogSession) => {
135
+ const root = s.project || s.directory
136
+ return { root, name: s.projectName || path.basename(root) }
137
+ }
138
+
139
+ function directoryOf(directory: Directory, userID: string): Map<string, DirectoryEntry> {
140
+ return new Map(directory.who(userID).map((e) => [e.sessionID, e]))
141
+ }
142
+
143
+ /** Blockers and errors across the user's sessions, oldest first (by the session's last update). */
144
+ function attention(list: readonly Row[], entries: Map<string, DirectoryEntry>): AttentionItem[] {
145
+ const out: AttentionItem[] = []
146
+ const seen = new Set<string>()
147
+ const named = (sessionID: string) => {
148
+ const name = entries.get(sessionID)?.name
149
+ return name ? { name: `@${name}` } : {}
150
+ }
151
+ for (const { session: s } of [...list].sort((a, b) => a.session.updated - b.session.updated)) {
152
+ for (const b of s.blockers) {
153
+ if (seen.has(b.id)) continue
154
+ seen.add(b.id)
155
+ out.push({ kind: b.kind, sessionID: b.sessionID, ...named(b.sessionID), id: b.id, summary: b.summary })
156
+ }
157
+ if (s.state === "error" && !seen.has(`error:${s.id}`)) {
158
+ seen.add(`error:${s.id}`)
159
+ const own = s.status.find((e) => e.id === s.id)
160
+ out.push({ kind: "error", sessionID: s.id, ...named(s.id), summary: own?.detail ?? "error" })
161
+ }
162
+ }
163
+ return out
164
+ }
165
+
166
+ function you(actor: AgentActor, entries: Map<string, DirectoryEntry>) {
167
+ if (actor.kind === "session") {
168
+ const name = entries.get(actor.sessionID)?.name
169
+ return { kind: "session", sessionID: actor.sessionID, ...(name ? { name: `@${name}` } : {}) }
170
+ }
171
+ if (actor.kind === "app") return { kind: "app", tokenID: actor.tokenID, ...(actor.label ? { label: actor.label } : {}) }
172
+ return { kind: "user" }
173
+ }
174
+
175
+ /** The status snapshot of one user (design §4): machines → projects → sessions, and what needs attention. */
176
+ export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labels: SessionLabels = NO_LABELS) {
177
+ const list = rows(deps.registry, userID)
178
+ const entries = directoryOf(deps.directory, userID)
179
+ const online = new Map(deps.registry.catalog().map((m) => [m.id, m]))
180
+ const machines = deps.store
181
+ .machines()
182
+ .filter((m) => !m.revoked && m.ownerID === userID)
183
+ .map((m) => {
184
+ const live = online.get(m.id)
185
+ const projects = new Map<string, { root: string; name: string; sessions: AgentSessionView[] }>()
186
+ for (const row of list) {
187
+ if (row.machineID !== m.id) continue
188
+ const project = projectOf(row.session)
189
+ const group = projects.get(project.root) ?? { ...project, sessions: [] }
190
+ projects.set(project.root, group)
191
+ group.sessions.push(view(row, entries.get(row.session.id), labels))
192
+ }
193
+ return {
194
+ id: m.id,
195
+ name: m.name,
196
+ online: live !== undefined,
197
+ harnesses: [...new Set((live?.processes ?? []).map((p) => p.harness))],
198
+ projects: [...projects.values()],
199
+ }
200
+ })
201
+ return { at: (deps.now ?? Date.now)(), you: you(actor, entries), machines, attention: attention(list, entries) }
202
+ }
203
+
204
+ /** The session an address names for the user, if the user's spokes report it now (else offline). */
205
+ function located(deps: ReadDeps, userID: string, addr: string) {
206
+ const entry = deps.directory.resolveOne(userID, addr)
207
+ const live = deps.registry.sessionTenant(entry.sessionID) === userID ? deps.registry.session(entry.sessionID) : undefined
208
+ const list = rows(deps.registry, userID)
209
+ const row = list.find((r) => r.session.id === entry.sessionID) ?? (live && list.find((r) => r.session.id === live.status[0]?.id))
210
+ return { entry, live, row }
211
+ }
212
+
213
+ /** The connection to ask for `op` about a session: unsupported when it lacks the op's feature, offline when none. */
214
+ function spokeFor(deps: ReadDeps, sessionID: string, op: ServerRequest["op"], harness: string): string {
215
+ const connID = deps.registry.owner(sessionID)
216
+ if (!connID || !deps.spokes) throw new AgentError("offline", "No spoke reports this session now")
217
+ const feature = featureForOp(op)
218
+ if (feature && !deps.registry.supports(connID, feature)) {
219
+ throw new AgentError("unsupported", unsupportedMessage({ feature, harness }), { extra: { feature, harness } })
220
+ }
221
+ return connID
222
+ }
223
+
224
+ async function ask(deps: ReadDeps, connID: string, req: ServerRequest): Promise<unknown> {
225
+ try {
226
+ return await deps.spokes!.request(connID, req)
227
+ } catch (cause) {
228
+ if (cause instanceof SpokeError && cause.message.includes("disconnected")) throw new AgentError("offline", "The session's spoke disconnected")
229
+ throw cause
230
+ }
231
+ }
232
+
233
+ /** GET /sessions/:addr: the session's catalog view with full blockers, and its summary from the spoke. */
234
+ async function show(deps: ReadDeps, userID: string, addr: string) {
235
+ const { entry, live, row } = located(deps, userID, addr)
236
+ const labels = { ...(await labelsOf(deps, userID)).get(entry.sessionID) }
237
+ const name = entry.name ? { name: `@${entry.name}` } : {}
238
+ const roles = entry.roles.map(roleLabel)
239
+ if (!live) {
240
+ return {
241
+ id: entry.sessionID,
242
+ ...name,
243
+ roles,
244
+ ...(entry.title ? { title: entry.title } : {}),
245
+ state: "offline",
246
+ online: false,
247
+ ...(entry.parentID ? { parentID: entry.parentID } : {}),
248
+ ...(entry.project ? { project: entry.project } : {}),
249
+ ...(entry.machineID ? { machineID: entry.machineID } : {}),
250
+ ...(entry.lastSeen !== undefined ? { lastSeen: entry.lastSeen } : {}),
251
+ blockers: [],
252
+ labels,
253
+ }
254
+ }
255
+ const harness = row?.harness ?? "unknown"
256
+ const own = live.status.find((e) => e.id === live.id) as LiveStatus | undefined
257
+ const base = {
258
+ id: live.id,
259
+ ...name,
260
+ roles,
261
+ title: live.title,
262
+ state: live.state,
263
+ harness,
264
+ online: true,
265
+ ...(row ? { machineID: row.machineID } : {}),
266
+ project: live.projectName || path.basename(live.project || live.directory),
267
+ directory: live.directory,
268
+ ...(live.parentID ? { parentID: live.parentID } : {}),
269
+ ...(own?.since !== undefined ? { busySince: own.since } : {}),
270
+ ...(live.now ? { now: nowText(live.now), activity: live.now } : {}),
271
+ blockers: live.blockers.map(({ sessionTitle: _, ...b }) => b),
272
+ children: live.status.filter((e) => e.parentID === live.id).length,
273
+ labels,
274
+ }
275
+ try {
276
+ const connID = spokeFor(deps, live.id, "summary", harness)
277
+ return { ...base, summary: await ask(deps, connID, { op: "summary", sessionID: live.id }) }
278
+ } catch (cause) {
279
+ if (cause instanceof AgentError && cause.code === "unsupported") return { ...base, unsupported: [cause.options.extra] }
280
+ if (cause instanceof AgentError && cause.code === "offline") return base
281
+ console.error("[hub] agent show: summary failed:", cause)
282
+ return { ...base, failed: [{ feature: "summary" }] }
283
+ }
284
+ }
285
+
286
+ /** GET /sessions/:addr/log: the newest `n` timeline rows, newest first. */
287
+ async function log(deps: ReadDeps, userID: string, addr: string, raw: string | null) {
288
+ const n = raw === null ? 20 : Number(raw)
289
+ if (!Number.isInteger(n) || n < 1 || n > 200) throw new AgentError("invalid", "n must be 1–200")
290
+ const { entry, live, row } = located(deps, userID, addr)
291
+ if (!live) throw new AgentError("offline", "No spoke reports this session now")
292
+ const connID = spokeFor(deps, entry.sessionID, "timeline", row?.harness ?? "unknown")
293
+ const page = (await ask(deps, connID, { op: "timeline", sessionID: entry.sessionID, limit: n, order: "desc" })) as { items?: unknown[]; older?: string }
294
+ return { sessionID: entry.sessionID, rows: page.items ?? [], ...(page.older ? { older: page.older } : {}) }
295
+ }
296
+
297
+ const labelsOf = (deps: ReadDeps, userID: string): Promise<SessionLabels> => deps.labels?.(userID) ?? Promise.resolve(NO_LABELS)
298
+
299
+ const STATES = new Set(["idle", "working", "blocked", "error"])
300
+
301
+ /** The /sessions filters: project (name or root), state (comma list), machine (ID or name), label k=v. */
302
+ function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams): (row: Row, view: AgentSessionView) => boolean {
303
+ const project = params.get("project") || undefined
304
+ const states = params.get("state")?.split(",").filter(Boolean)
305
+ if (states?.some((state) => !STATES.has(state))) throw new AgentError("invalid", "state must be idle, working, blocked or error (comma-separated)")
306
+ const machine = params.get("machine") || undefined
307
+ const machineIDs = machine && new Set(deps.store.machines().filter((m) => m.ownerID === userID && (m.id === machine || m.name === machine)).map((m) => m.id))
308
+ const labels = params.getAll("label").map((label) => {
309
+ const at = label.indexOf("=")
310
+ if (at < 1) throw new AgentError("invalid", "label must be key=value")
311
+ return [label.slice(0, at), label.slice(at + 1)] as const
312
+ })
313
+ return (row, view) => {
314
+ const { root, name } = projectOf(row.session)
315
+ if (project !== undefined && root !== project && name.toLowerCase() !== project.toLowerCase()) return false
316
+ if (states?.length && !states.includes(view.state)) return false
317
+ if (machineIDs && !machineIDs.has(row.machineID)) return false
318
+ return labels.every(([key, value]) => view.labels[key] === value)
319
+ }
320
+ }
321
+
322
+ const NO_STORE = { "cache-control": "no-store" }
323
+ const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
324
+ const READ = { session: true, scope: "sessions:read" } as const
325
+
326
+ export function readRoutes(deps: ReadDeps): AgentRoute[] {
327
+ return [
328
+ {
329
+ method: "GET",
330
+ path: `${AGENT_PREFIX}/status`,
331
+ allow: READ,
332
+ handle: async ({ user, actor }) => ok(snapshot(deps, user.id, actor, await labelsOf(deps, user.id))),
333
+ },
334
+ {
335
+ method: "GET",
336
+ path: `${AGENT_PREFIX}/sessions`,
337
+ allow: READ,
338
+ handle: async ({ user, url }) => {
339
+ const keep = sessionFilter(deps, user.id, url.searchParams)
340
+ const entries = directoryOf(deps.directory, user.id)
341
+ const labels = await labelsOf(deps, user.id)
342
+ const sessions = rows(deps.registry, user.id)
343
+ .map((row) => ({ row, view: view(row, entries.get(row.session.id), labels) }))
344
+ .filter(({ row, view }) => keep(row, view))
345
+ .map(({ row, view }) => ({ ...view, machineID: row.machineID, project: projectOf(row.session).name }))
346
+ return ok({ sessions })
347
+ },
348
+ },
349
+ {
350
+ method: "GET",
351
+ path: `${AGENT_PREFIX}/sessions/:addr`,
352
+ allow: READ,
353
+ handle: async ({ user, params }) => ok(await show(deps, user.id, params.addr!)),
354
+ },
355
+ {
356
+ method: "GET",
357
+ path: `${AGENT_PREFIX}/sessions/:addr/log`,
358
+ allow: READ,
359
+ handle: async ({ user, params, url }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"))),
360
+ },
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
+ },
388
+ ]
389
+ }
@@ -0,0 +1,278 @@
1
+ // The /api/agent framework: its own route table (access "agent" in the hub's table), credential checks and error
2
+ // envelope. http.ts registers it once (agentApi) and sends every /api/agent request here; later tasks add routes
3
+ // to agentRoutes() and verifiers to agentVerifiers() without touching http.ts.
4
+ //
5
+ // Order for each request: cookie + a hub bearer together → 400 invalid (a foreign bearer is ignored); cookie writes need the same origin (bearer
6
+ // requests skip it) → 403 forbidden; credential → 401 unauthorized / token_expired; route → 404 not_found;
7
+ // route class (agentAllows) → 403 forbidden; then the handler, whose throws become the envelope (agentFailure).
8
+ // Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
9
+ import type { DeviceRecord, Store, UserRecord } from "../store"
10
+ import type { Directory } from "./directory"
11
+ import { directoryRoutes } from "./directory-routes"
12
+ import { type ReadDeps, readRoutes, type ReadRegistry, type SpokeRequester } from "./read-routes"
13
+ import { agentAllows, type AgentActor, type AgentAllow, type BearerVerifier, type Credential, type Resolver } from "./actor"
14
+ import { appTokenRoutes, appTokenVerifier } from "./app-tokens"
15
+ import type { Decisions } from "./decisions"
16
+ import type { Delivery } from "./delivery"
17
+ import { MessageFeed, messageRoutes } from "./message-routes"
18
+ import type { SessionTokens } from "./session-tokens"
19
+ import { AgentError, agentFailure } from "./errors"
20
+ import { type AgentAudit, auditRoutes } from "./audit"
21
+ import { grantRoutes } from "./grant-routes"
22
+ import type { GrantRequests } from "./grant-requests"
23
+ import type { Grants } from "./grants"
24
+ import { type ActingSwitch, actingSwitchRoutes } from "./acting-switch"
25
+ import { Acting, type ActingRegistry, type Escalate } from "./acting"
26
+ import { actingRoutes } from "./acting-routes"
27
+ import type { Actions } from "../actions"
28
+ import { type LaunchFeed, launchRoutes, type RouteLaunches } from "./launch-routes"
29
+ import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
30
+
31
+ export const AGENT_PREFIX = "/api/agent"
32
+
33
+ export const isAgentPath = (pathname: string): boolean => pathname === AGENT_PREFIX || pathname.startsWith(`${AGENT_PREFIX}/`)
34
+
35
+ export interface AgentContext {
36
+ readonly request: Request
37
+ readonly url: URL
38
+ readonly params: Record<string, string>
39
+ readonly actor: AgentActor
40
+ readonly credential: Credential
41
+ readonly user: UserRecord
42
+ /** The caller's device (user actors only). */
43
+ readonly device?: DeviceRecord
44
+ }
45
+
46
+ export interface AgentRoute {
47
+ readonly method: string
48
+ /** Express-style path under /api/agent, e.g. /api/agent/sessions/:addr. */
49
+ readonly path: string
50
+ /** Who besides user actors may call it (default: users only, e.g. token management). */
51
+ readonly allow: AgentAllow
52
+ handle(context: AgentContext): Response | Promise<Response>
53
+ }
54
+
55
+ /** What the agent API needs from the hub. */
56
+ export interface AgentDeps {
57
+ readonly store: Store
58
+ /** Hub-minted session tokens (P1-T5). */
59
+ readonly sessionTokens?: SessionTokens
60
+ /** Names, roles and address resolution (P1-T8); its routes are added when given. */
61
+ readonly directory?: Directory
62
+ /** The hub's catalog (P1-T9): the read routes are added when given with the directory. */
63
+ readonly registry?: ReadRegistry & ActingRegistry
64
+ /** Hub → spoke requests (show, log). */
65
+ readonly spokes?: SpokeRequester
66
+ /** Agent messages (P2-T5): the message routes are added when given with the directory. */
67
+ readonly delivery?: Delivery
68
+ /** Decisions (ask @me, P2-T9): with the message routes. */
69
+ readonly decisions?: Decisions
70
+ /** The agent audit (P3-T5): GET /api/agent/audit when given. */
71
+ readonly audit?: AgentAudit
72
+ /** Grants (P3-T2): the /api/agent/grants routes when given. */
73
+ readonly grants?: Grants
74
+ /** Grant requests (P3-T9): POST /api/agent/grants/requests with the grant routes. */
75
+ readonly grantRequests?: GrantRequests
76
+ /** Kill switches (P3-T4): GET/PUT /api/agent/acting when given; the acting verbs check it. */
77
+ readonly acting?: ActingSwitch
78
+ /**
79
+ * The shared action executor (P3-T6): with acting, grants, audit, registry, directory and spokes, the act verbs
80
+ * POST /api/agent/sessions/:addr/{approve,deny,answer,prompt,stop} (P3-T7).
81
+ */
82
+ readonly actions?: Actions
83
+ /**
84
+ * Launches (P4-T8): with grants, acting and audit, the launch routes /api/agent/launches (P4-T9); the read
85
+ * routes show the labels of launched sessions.
86
+ */
87
+ readonly launches?: RouteLaunches
88
+ /** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
89
+ readonly webhooks?: RouteWebhooks
90
+ /** Launch changes: the watch stream's launch events. */
91
+ readonly launchFeed?: Pick<LaunchFeed, "subscribe">
92
+ /** needs_user escalations of the act verbs (P3-T8 opens a Decision); without it needs_user is only answered. */
93
+ readonly escalate?: Escalate
94
+ /** The console's prompt limit, for the user's prompts on the act route. */
95
+ readonly maxPromptChars?: number
96
+ /** Stored messages as they are sent (the hub also streams them to the console); a new feed when absent. */
97
+ readonly feed?: MessageFeed
98
+ /** Heartbeat of GET /api/agent/watch. */
99
+ readonly heartbeatMs?: number
100
+ /** What GET /api/agent/watch reads per user (cluster mode); the registry when absent. */
101
+ readonly watchRegistry?: ReadDeps["watchRegistry"]
102
+ /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
103
+ readonly draining?: AbortSignal
104
+ readonly now?: () => number
105
+ }
106
+
107
+ /**
108
+ * Session and app token verifiers (P1-T5 adds the session token verifier, P1-T7 the app token one). Tried in
109
+ * order after the device lookup; see BearerVerifier.
110
+ */
111
+ export function agentVerifiers(deps: AgentDeps): BearerVerifier[] {
112
+ return [...(deps.sessionTokens ? [deps.sessionTokens.verifier] : []), appTokenVerifier(deps.store)]
113
+ }
114
+
115
+ const userView = (user: UserRecord) => ({
116
+ id: user.id,
117
+ ...(user.name ? { name: user.name } : {}),
118
+ ...(user.email ? { email: user.email } : {}),
119
+ role: user.role,
120
+ })
121
+
122
+ /** The built-in /api/agent routes. */
123
+ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
124
+ const feed = deps.feed ?? new MessageFeed()
125
+ return [
126
+ ...(deps.directory ? directoryRoutes(deps.directory) : []),
127
+ ...(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 } : {}) }) : []),
129
+ {
130
+ method: "GET",
131
+ path: `${AGENT_PREFIX}/whoami`,
132
+ allow: { session: true, app: true },
133
+ handle: ({ actor, user }) => Response.json({ actor, user: userView(user) }, { headers: { "cache-control": "no-store" } }),
134
+ },
135
+ ...appTokenRoutes(deps.store),
136
+ ...(deps.audit ? auditRoutes(deps.audit) : []),
137
+ ...(deps.grants ? grantRoutes(deps.grants, deps.now, deps.grantRequests) : []),
138
+ ...(deps.acting ? actingSwitchRoutes(deps.acting) : []),
139
+ ...actVerbRoutes(deps),
140
+ ...(deps.launches && deps.grants && deps.acting && deps.audit
141
+ ? launchRoutes({ launches: deps.launches, grants: deps.grants, acting: deps.acting, audit: deps.audit, ...(deps.directory ? { directory: deps.directory } : {}) })
142
+ : []),
143
+ ...(deps.webhooks ? webhookRoutes(deps.webhooks, deps.store) : []),
144
+ ]
145
+ }
146
+
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
150
+ return {
151
+ ...(launches
152
+ ? {
153
+ labels: async (userID: string) => {
154
+ const out = new Map<string, Readonly<Record<string, string>>>()
155
+ // Oldest first: a later launch of the same session overrides.
156
+ for (const launch of await launches.list(userID)) if (launch.sessionID !== undefined) out.set(launch.sessionID, launch.spec.labels)
157
+ return out
158
+ },
159
+ }
160
+ : {}),
161
+ ...(launchFeed ? { launches: launchFeed } : {}),
162
+ }
163
+ }
164
+
165
+ /** The act verbs, when every part they need is given. */
166
+ function actVerbRoutes(deps: AgentDeps): AgentRoute[] {
167
+ const { actions, acting, grants, audit, registry, directory, spokes } = deps
168
+ if (!actions || !acting || !grants || !audit || !registry || !directory || !spokes) return []
169
+ return actingRoutes(
170
+ new Acting({
171
+ registry,
172
+ directory,
173
+ actions,
174
+ spokes,
175
+ grants,
176
+ acting,
177
+ audit,
178
+ ...(deps.escalate ? { escalate: deps.escalate } : {}),
179
+ ...(deps.maxPromptChars !== undefined ? { maxPromptChars: deps.maxPromptChars } : {}),
180
+ }),
181
+ )
182
+ }
183
+
184
+ export interface AgentApiOptions {
185
+ readonly resolve: Resolver
186
+ readonly cookieName: string
187
+ /** CSRF guard for cookie writes (the hub's same-origin check). */
188
+ sameOrigin(request: Request): boolean
189
+ /** Records activity of a resolved caller (device and user lastSeen). */
190
+ touch?(user: UserRecord, device?: DeviceRecord): void
191
+ readonly routes: readonly AgentRoute[]
192
+ }
193
+
194
+ export interface AgentApi {
195
+ /** The route table (method, path) for the hub's audit table, as access "agent". */
196
+ readonly routes: readonly { readonly method: string; readonly path: string }[]
197
+ handle(request: Request, url: URL): Promise<Response>
198
+ }
199
+
200
+ interface Compiled {
201
+ readonly route: AgentRoute
202
+ readonly pattern: RegExp
203
+ readonly keys: string[]
204
+ }
205
+
206
+ const MIXED = "Send the device cookie or a bearer token, not both"
207
+
208
+ export function agentApi(options: AgentApiOptions): AgentApi {
209
+ const compiled: Compiled[] = options.routes.map((route) => {
210
+ if (!isAgentPath(route.path) || route.path.includes(":session")) throw new Error(`agent route ${route.path}: must be under ${AGENT_PREFIX} and use :addr, not :session`)
211
+ const keys: string[] = []
212
+ const pattern = new RegExp(`^${route.path.replace(/:([a-z]+)/g, (_, key: string) => (keys.push(key), "([^/]+)"))}$`)
213
+ return { route, pattern, keys }
214
+ })
215
+
216
+ const find = (method: string, pathname: string) => {
217
+ for (const { route, pattern, keys } of compiled) {
218
+ if (route.method !== method) continue
219
+ const match = pattern.exec(pathname)
220
+ if (!match) continue
221
+ const params: Record<string, string> = {}
222
+ keys.forEach((key, i) => {
223
+ try {
224
+ params[key] = decodeURIComponent(match[i + 1]!)
225
+ } catch {
226
+ throw new AgentError("invalid", "Bad path")
227
+ }
228
+ })
229
+ return { route, params }
230
+ }
231
+ return undefined
232
+ }
233
+
234
+ async function handle(request: Request, url: URL): Promise<Response> {
235
+ try {
236
+ const mode = await options.resolve.mode(request)
237
+ if (mode === "mixed") throw new AgentError("invalid", MIXED)
238
+ const method = request.method
239
+ if (mode !== "bearer" && method !== "GET" && method !== "HEAD" && !options.sameOrigin(request)) {
240
+ throw new AgentError("forbidden", "Cross-origin request refused")
241
+ }
242
+ const resolved = await options.resolve(request)
243
+ if (!resolved.ok) {
244
+ if (resolved.reason === "expired") throw new AgentError("token_expired", "Token expired", { hint: "Get a new token" })
245
+ throw new AgentError("unauthorized", resolved.bearer ? "Invalid or revoked token" : "Sign in or send a bearer token")
246
+ }
247
+ options.touch?.(resolved.user, resolved.device)
248
+ const found = find(method, url.pathname)
249
+ if (!found) throw new AgentError("not_found", "Not found")
250
+ if (!agentAllows(found.route.allow, resolved.actor)) throw new AgentError("forbidden", "This credential cannot use this route")
251
+ const { actor, credential, user, device } = resolved
252
+ return await found.route.handle({ request, url, params: found.params, actor, credential, user, ...(device ? { device } : {}) })
253
+ } catch (cause) {
254
+ return agentFailure(cause)
255
+ }
256
+ }
257
+
258
+ return { routes: options.routes.map(({ method, path }) => ({ method, path })), handle }
259
+ }
260
+
261
+ /** Oldest client (native app, CLI) this hub still serves; raised when the API breaks old clients. */
262
+ export const MIN_CLIENT_VERSION = "0.0.0"
263
+
264
+ export interface WellKnownInfo {
265
+ readonly publicURL: string
266
+ readonly version: string
267
+ readonly auth: { readonly mode: "pairing" } | { readonly mode: "oidc"; readonly issuer: string }
268
+ }
269
+
270
+ /** GET /.well-known/hub: public discovery and version gating for apps and CLIs (design §14.5). */
271
+ export function wellKnown(info: WellKnownInfo): Response {
272
+ // Pairing codes also work on an OIDC hub (a signed-in device issues them).
273
+ const auth = info.auth.mode === "oidc" ? { modes: ["oidc", "pairing"], oidc: { issuer: info.auth.issuer } } : { modes: ["pairing"] }
274
+ return Response.json(
275
+ { publicURL: info.publicURL, version: info.version, minClientVersion: MIN_CLIENT_VERSION, auth, features: [] },
276
+ { headers: { "cache-control": "public, max-age=60" } },
277
+ )
278
+ }