@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,185 @@
1
+ // The directory on /api/agent (design §2.2) and its wiring to the hub: the Registry is the SessionSource
2
+ // (sessions reported now, per tenant), the console's Archive (viewPrefs.hiddenSessions) the archive source,
3
+ // and every catalog change syncs the affected users (records, lastSeen, derived names).
4
+ //
5
+ // Routes (user actors always; session tokens may read, and write for their OWN session only):
6
+ // GET /api/agent/who?project=&role=
7
+ // POST /api/agent/name {name, session?}
8
+ // POST /api/agent/roles/claim {role, exclusive?, project?, session?}
9
+ // POST /api/agent/roles/release {role, project?, session?}
10
+ // `session` is an address (ses_…, ID prefix, @name, …) resolved for the caller; a session token defaults to
11
+ // its own session, a user must name one.
12
+ import path from "node:path"
13
+ import type { CatalogMachine, RegistryMessage } from "../registry"
14
+ import type { Store } from "../store"
15
+ import { Directory, type ReportedSession, type SessionSource } from "./directory"
16
+ import { AgentError } from "./errors"
17
+ import { slugify } from "./names"
18
+ import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
19
+
20
+ /** What the directory reads from the Registry. */
21
+ export interface DirectoryRegistry {
22
+ catalog(): CatalogMachine[]
23
+ sessionTenant(sessionID: string): string | undefined
24
+ subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
25
+ }
26
+
27
+ type CatalogSession = CatalogMachine["processes"][number]["sessions"][number]
28
+
29
+ /** The `.project` scope of a session: its project name (else folder name), as a slug. */
30
+ function projectOf(s: Pick<CatalogSession, "projectName" | "project" | "directory">): string | undefined {
31
+ const name = s.projectName || path.basename(s.project || s.directory || "")
32
+ return name ? (slugify(name) ?? name.toLowerCase()) : undefined
33
+ }
34
+
35
+ /** Sessions the Registry reports for a user (subagents from the status trees too), and their Archive. */
36
+ export function registrySource(registry: Pick<DirectoryRegistry, "catalog" | "sessionTenant">, store: Pick<Store, "viewPrefs">): SessionSource {
37
+ return {
38
+ reported(userID) {
39
+ const out = new Map<string, ReportedSession>()
40
+ for (const machine of registry.catalog()) {
41
+ for (const process of machine.processes) {
42
+ for (const s of process.sessions) {
43
+ if (out.has(s.id) || registry.sessionTenant(s.id) !== userID) continue
44
+ const project = projectOf(s)
45
+ const scope = project ? { project } : {}
46
+ out.set(s.id, { sessionID: s.id, title: s.title, machineID: s.machineID, ...(s.parentID ? { parentID: s.parentID } : {}), ...scope, state: s.state })
47
+ for (const child of s.status) {
48
+ if (child.id === s.id || out.has(child.id)) continue
49
+ out.set(child.id, { sessionID: child.id, title: child.title, machineID: s.machineID, parentID: child.parentID ?? s.id, ...scope, state: child.state })
50
+ }
51
+ }
52
+ }
53
+ }
54
+ return [...out.values()]
55
+ },
56
+ archived: (userID) => new Set(store.viewPrefs(userID).hiddenSessions),
57
+ }
58
+ }
59
+
60
+ /** The hub's directory: fed by the Registry, synced for the affected users on every catalog change. */
61
+ export function hubDirectory(options: { registry: DirectoryRegistry; store: Pick<Store, "viewPrefs" | "updateAgentData" | "agentData" | "user">; now?: () => number }): Directory {
62
+ const { registry, store } = options
63
+ const directory = new Directory({ store, source: registrySource(registry, store), ...(options.now ? { now: options.now } : {}) })
64
+ /** Users with reported sessions at the last catalog change (they need a sync when theirs go away). */
65
+ let online = new Set<string>()
66
+ registry.subscribe((message) => {
67
+ if (message.type !== "catalog") return
68
+ const now = new Set<string>()
69
+ for (const machine of registry.catalog()) {
70
+ for (const process of machine.processes) {
71
+ for (const s of process.sessions) {
72
+ const tenant = registry.sessionTenant(s.id)
73
+ if (tenant !== undefined) now.add(tenant)
74
+ }
75
+ }
76
+ }
77
+ for (const userID of new Set([...online, ...now])) {
78
+ directory.sync(userID).catch((cause: unknown) => {
79
+ if (!(cause instanceof AgentError)) console.error("[hub] directory sync failed:", cause)
80
+ })
81
+ }
82
+ online = now
83
+ })
84
+ return directory
85
+ }
86
+
87
+ // ---------- routes ----------
88
+
89
+ type Body = Record<string, unknown>
90
+
91
+ async function readBody(request: Request): Promise<Body> {
92
+ const body = await request.json()
93
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Expected a JSON object")
94
+ return body as Body
95
+ }
96
+
97
+ function text(body: Body, field: string, required: true): string
98
+ function text(body: Body, field: string, required?: false): string | undefined
99
+ function text(body: Body, field: string, required = false): string | undefined {
100
+ const value = body[field]
101
+ if (value === undefined && !required) return undefined
102
+ if (typeof value !== "string" || !value || value.length > 300) throw new AgentError("invalid", `${field} must be a string`)
103
+ return value
104
+ }
105
+
106
+ function flag(body: Body, field: string): boolean | undefined {
107
+ const value = body[field]
108
+ if (value !== undefined && typeof value !== "boolean") throw new AgentError("invalid", `${field} must be true or false`)
109
+ return value
110
+ }
111
+
112
+ /**
113
+ * The session a write targets: a session token acts on its own session only (forbidden otherwise); a user
114
+ * names any of their sessions by address.
115
+ */
116
+ function target(directory: Directory, { actor, user }: AgentContext, addr: string | undefined): string {
117
+ if (actor.kind === "session") {
118
+ if (addr === undefined) return actor.sessionID
119
+ const sessionID = directory.resolveOne(user.id, addr).sessionID
120
+ if (sessionID !== actor.sessionID) throw new AgentError("forbidden", "A session token can only act for its own session")
121
+ return sessionID
122
+ }
123
+ if (addr === undefined) throw new AgentError("invalid", "session is required (ses_…, @name)")
124
+ return directory.resolveOne(user.id, addr).sessionID
125
+ }
126
+
127
+ const NO_STORE = { "cache-control": "no-store" }
128
+ const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
129
+
130
+ export function directoryRoutes(directory: Directory): AgentRoute[] {
131
+ return [
132
+ {
133
+ method: "GET",
134
+ path: `${AGENT_PREFIX}/who`,
135
+ // Reading the directory: sessions, and app tokens holding sessions:read; writes stay users and sessions.
136
+ allow: { session: true, scope: "sessions:read" },
137
+ handle: ({ url, user }) => {
138
+ const project = url.searchParams.get("project") || undefined
139
+ const role = url.searchParams.get("role") || undefined
140
+ return ok({ sessions: directory.who(user.id, { ...(project ? { project } : {}), ...(role ? { role } : {}) }) })
141
+ },
142
+ },
143
+ {
144
+ method: "POST",
145
+ path: `${AGENT_PREFIX}/name`,
146
+ allow: { session: true },
147
+ handle: async (context) => {
148
+ const body = await readBody(context.request)
149
+ const name = text(body, "name", true)
150
+ const sessionID = target(directory, context, text(body, "session"))
151
+ return ok(await directory.setName(context.user.id, sessionID, name))
152
+ },
153
+ },
154
+ {
155
+ method: "POST",
156
+ path: `${AGENT_PREFIX}/roles/claim`,
157
+ allow: { session: true },
158
+ handle: async (context) => {
159
+ const body = await readBody(context.request)
160
+ const role = text(body, "role", true)
161
+ const exclusive = flag(body, "exclusive")
162
+ const project = text(body, "project")
163
+ const sessionID = target(directory, context, text(body, "session"))
164
+ const claim = await directory.claimRole(context.user.id, sessionID, role, {
165
+ ...(exclusive !== undefined ? { exclusive } : {}),
166
+ ...(project !== undefined ? { project } : {}),
167
+ byUser: context.actor.kind === "user",
168
+ })
169
+ return ok({ sessionID, ...claim })
170
+ },
171
+ },
172
+ {
173
+ method: "POST",
174
+ path: `${AGENT_PREFIX}/roles/release`,
175
+ allow: { session: true },
176
+ handle: async (context) => {
177
+ const body = await readBody(context.request)
178
+ const role = text(body, "role", true)
179
+ const project = text(body, "project")
180
+ const sessionID = target(directory, context, text(body, "session"))
181
+ return ok({ sessionID, ...(await directory.releaseRole(context.user.id, sessionID, role, project)), released: true })
182
+ },
183
+ },
184
+ ]
185
+ }
@@ -0,0 +1,473 @@
1
+ // The agent directory (design §2, §7): who is who among one user's sessions. Persisted per user in
2
+ // UserRecord.agent (agent-data.ts): session records, @names (with rename aliases) and @@role holders.
3
+ //
4
+ // Every rule is evaluated lazily against the clock when the directory is used (no timers):
5
+ // - A session record is created when a spoke reports the session; lastSeen is saved at minute granularity
6
+ // while it stays online (LAST_SEEN_STEP_MS), and exactly when it stops being reported.
7
+ // - Archive source: the console's Archive (viewPrefs.hiddenSessions). Spokes never report archived OpenCode
8
+ // sessions (the listing drops them), so SessionInfo.archived never reaches the hub. archivedAt is the first
9
+ // time the directory sees the session in that list; unarchiving clears it. deletedAt comes from markDeleted.
10
+ // - Names: derived once (names.ts deriveName), from the first non-placeholder title, for root sessions only; a taken derived name
11
+ // gets a suffix, an explicit taken one fails (name_taken). A name is released NAME_RELEASE_MS after its
12
+ // session was archived or deleted; a rename keeps the old name as an alias for ALIAS_MS.
13
+ // - Roles: see claimRole. A holder offline (not reported) for ROLE_OFFLINE_MS loses the role, except during
14
+ // the RECONNECT_WINDOW_MS after the hub started, while spokes reconnect.
15
+ // - At most SESSION_LIMIT records per user: archived/deleted ones are dropped first, then the least recently
16
+ // seen, with their names and roles.
17
+ // - Primer (design §2.3): a session record remembers when the identity primer reached it (primed); explicit
18
+ // names are announced to onNamed listeners (the delivery engine primes on them), derived names never are.
19
+ //
20
+ // Tenancy: everything is per user; another user's sessions, names and roles do not exist here (not_found),
21
+ // and ambiguity candidates only ever list the caller's own sessions.
22
+ // Reads (resolve, who, primed) are synchronous: they evaluate the rules on a copy of the stored data and save
23
+ // nothing. Writes (sync, names, roles, primer and deletion marks) are async: they run inside one store update and
24
+ // resolve once it is saved (their errors reject). Uniqueness holds across pods: on a shared (cluster) store an update
25
+ // that lost a race with another pod's write runs again on the fresh data before it resolves.
26
+ // Cluster mode: the SessionSource reports every pod's sessions (the Registry's cluster view), so a sync on one pod
27
+ // never takes another pod's sessions for offline.
28
+ import { roleKey, type AgentData, type AgentName, type AgentRole, type AgentSession } from "../agent-data"
29
+ import type { Store } from "../store"
30
+ import { AgentError } from "./errors"
31
+ import { deriveName, isPlaceholderTitle, isReservedName, isReservedRole, parseAddress, validName, withSuffix, type Address } from "./names"
32
+
33
+ const MINUTE = 60_000
34
+ const HOUR = 60 * MINUTE
35
+ const DAY = 24 * HOUR
36
+
37
+ export const SESSION_LIMIT = 2000
38
+ export const NAME_RELEASE_MS = 7 * DAY
39
+ export const ALIAS_MS = 24 * HOUR
40
+ export const ROLE_OFFLINE_MS = 30 * MINUTE
41
+ export const RECONNECT_WINDOW_MS = 2 * MINUTE
42
+ export const LAST_SEEN_STEP_MS = MINUTE
43
+ /** Most candidates listed in an ambiguous error. */
44
+ const CANDIDATES_MAX = 20
45
+
46
+ /** A session a spoke of the user reports now. */
47
+ export interface ReportedSession {
48
+ readonly sessionID: string
49
+ readonly title: string
50
+ readonly machineID?: string
51
+ readonly parentID?: string
52
+ /** Project name, lowercased (the `.project` scope). */
53
+ readonly project?: string
54
+ readonly state?: string
55
+ }
56
+
57
+ /** What the directory needs to know about the user's live sessions. */
58
+ export interface SessionSource {
59
+ /** The user's sessions reported now (roots and subagents), only ones the user owns. */
60
+ reported(userID: string): readonly ReportedSession[]
61
+ /** IDs of the sessions the user archived. */
62
+ archived(userID: string): ReadonlySet<string>
63
+ }
64
+
65
+ export type DirectoryStore = Pick<Store, "updateAgentData" | "agentData" | "user">
66
+
67
+ export interface DirectoryOptions {
68
+ readonly store: DirectoryStore
69
+ readonly source: SessionSource
70
+ readonly now?: () => number
71
+ }
72
+
73
+ export interface RoleView {
74
+ readonly role: string
75
+ readonly project?: string
76
+ readonly generation: number
77
+ readonly exclusive?: boolean
78
+ }
79
+
80
+ /** One session in directory answers. */
81
+ export interface DirectoryEntry {
82
+ readonly sessionID: string
83
+ readonly name?: string
84
+ readonly aliases?: string[]
85
+ readonly machineID?: string
86
+ readonly parentID?: string
87
+ readonly project?: string
88
+ readonly title?: string
89
+ readonly state?: string
90
+ /** A spoke reports it now. */
91
+ readonly online: boolean
92
+ readonly lastSeen?: number
93
+ readonly archived?: boolean
94
+ readonly roles: RoleView[]
95
+ }
96
+
97
+ /** Hears an explicit name change (setName); derived names are never announced. */
98
+ export type NamedListener = (userID: string, sessionID: string, name: string) => void
99
+
100
+ export interface Resolved {
101
+ readonly address: Address
102
+ /** One session for an ID or @name; every live holder for a @@role. */
103
+ readonly sessions: DirectoryEntry[]
104
+ }
105
+
106
+ /** The working copy of one user's data inside an update. */
107
+ interface Draft {
108
+ readonly data: AgentData
109
+ readonly sessions: AgentSession[]
110
+ readonly byID: Map<string, AgentSession>
111
+ readonly reported: Map<string, ReportedSession>
112
+ names: AgentName[]
113
+ roles: AgentRole[]
114
+ }
115
+
116
+ const ended = (s: AgentSession | undefined): number | undefined => {
117
+ const times = [s?.archivedAt, s?.deletedAt].filter((t): t is number => typeof t === "number")
118
+ return times.length ? Math.min(...times) : undefined
119
+ }
120
+
121
+ export class Directory {
122
+ private readonly store: DirectoryStore
123
+ private readonly source: SessionSource
124
+ private readonly now: () => number
125
+ private readonly startedAt: number
126
+ /** Per user: the sessions reported at the last update (to stamp lastSeen when they go away). */
127
+ private readonly online = new Map<string, Set<string>>()
128
+ private readonly named = new Set<NamedListener>()
129
+
130
+ constructor(options: DirectoryOptions) {
131
+ this.store = options.store
132
+ this.source = options.source
133
+ this.now = options.now ?? Date.now
134
+ this.startedAt = this.now()
135
+ }
136
+
137
+ /** Applies what the spokes report and the clock to the user's records (saves only on change). */
138
+ sync(userID: string): Promise<void> {
139
+ return this.update(userID, () => undefined)
140
+ }
141
+
142
+ /** Records that a session was deleted (its name is released NAME_RELEASE_MS later). */
143
+ markDeleted(userID: string, sessionID: string): Promise<void> {
144
+ return this.update(userID, (draft) => {
145
+ const record = draft.byID.get(sessionID)
146
+ if (record && record.deletedAt === undefined) record.deletedAt = this.now()
147
+ })
148
+ }
149
+
150
+ // ---------- primer ----------
151
+
152
+ /** Whether the identity primer reached the session (false for an unknown one). */
153
+ primed(userID: string, sessionID: string): boolean {
154
+ return this.read(userID, (draft) => draft.byID.get(sessionID)?.primed !== undefined)
155
+ }
156
+
157
+ /** Records that the identity primer reached the session (an unknown session is ignored). */
158
+ markPrimed(userID: string, sessionID: string): Promise<void> {
159
+ return this.update(userID, (draft) => {
160
+ const record = draft.byID.get(sessionID)
161
+ if (record && record.primed === undefined) record.primed = this.now()
162
+ })
163
+ }
164
+
165
+ /** Listens for explicit names (setName); returns the unsubscribe. */
166
+ onNamed(listener: NamedListener): () => void {
167
+ this.named.add(listener)
168
+ return () => void this.named.delete(listener)
169
+ }
170
+
171
+ // ---------- names ----------
172
+
173
+ /** Names a session (explicit: a taken name fails); the previous name stays an alias for ALIAS_MS. Tells onNamed listeners. */
174
+ async setName(userID: string, sessionID: string, requested: string): Promise<{ sessionID: string; name: string; alias?: string }> {
175
+ const result = await this.assignName(userID, sessionID, requested)
176
+ for (const listener of this.named) {
177
+ try {
178
+ listener(userID, sessionID, result.name)
179
+ } catch (cause) {
180
+ console.error("[hub] name listener failed:", cause)
181
+ }
182
+ }
183
+ return result
184
+ }
185
+
186
+ private async assignName(userID: string, sessionID: string, requested: string): Promise<{ sessionID: string; name: string; alias?: string }> {
187
+ const name = requested.replace(/^@/, "")
188
+ if (!validName(name)) throw new AgentError("invalid", "A name is 2–40 characters: a-z, 0-9 and -")
189
+ if (isReservedName(name)) throw new AgentError("invalid", "@me is reserved: it always means the user")
190
+ return this.update(userID, (draft) => {
191
+ const record = this.known(draft, sessionID)
192
+ const held = draft.names.find((n) => n.name === name)
193
+ if (held && held.sessionID !== sessionID) throw new AgentError("name_taken", `@${name} is taken`)
194
+ record.named = true
195
+ const current = draft.names.find((n) => n.sessionID === sessionID && n.expiresAt === undefined)
196
+ if (current?.name === name) return { sessionID, name }
197
+ const t = this.now()
198
+ if (held) draft.names = draft.names.filter((n) => n !== held)
199
+ if (current) current.expiresAt = t + ALIAS_MS
200
+ draft.names.push({ name, sessionID, created: t })
201
+ return { sessionID, name, ...(current ? { alias: current.name } : {}) }
202
+ })
203
+ }
204
+
205
+ // ---------- roles ----------
206
+
207
+ /**
208
+ * Claims `role` (or `role.project`) for a session. Each new holder gets the next holder generation of
209
+ * (role, project). An exclusive claim fails (name_taken) while another live holder exists, and nobody else
210
+ * can claim while an exclusive holder is live. Reserved roles (orchestrator…, concierge…) need `byUser`
211
+ * (grants with `roles` come in phase 3). Exclusivity and generations are per (role, project scope).
212
+ */
213
+ async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean } = {}): Promise<RoleView> {
214
+ const { role, project } = parseRole(requested, options.project)
215
+ if (isReservedRole(role) && !options.byUser) throw new AgentError("forbidden", `@@${role} is reserved: only the user can assign it`)
216
+ return this.update(userID, (draft) => {
217
+ this.known(draft, sessionID)
218
+ const same = draft.roles.filter((r) => r.role === role && r.project === project)
219
+ const mine = same.find((r) => r.sessionID === sessionID)
220
+ const others = same.filter((r) => r !== mine)
221
+ if (others.some((r) => r.exclusive) || (options.exclusive && others.length)) {
222
+ throw new AgentError("name_taken", `@@${scoped(role, project)} is held${others.some((r) => r.exclusive) ? " exclusively" : ""} by another session`)
223
+ }
224
+ if (mine) {
225
+ if (options.exclusive) mine.exclusive = true
226
+ return roleView(mine)
227
+ }
228
+ const key = roleKey(role, project)
229
+ const generations = { ...(draft.data.generations ?? {}) }
230
+ const generation = (generations[key] ?? 0) + 1
231
+ generations[key] = generation
232
+ draft.data.generations = generations
233
+ const claim: AgentRole = { role, sessionID, ...(project !== undefined ? { project } : {}), generation, ...(options.exclusive ? { exclusive: true } : {}), claimedAt: this.now() }
234
+ draft.roles.push(claim)
235
+ return roleView(claim)
236
+ })
237
+ }
238
+
239
+ /** Gives a role up; not_found when the session does not hold it. */
240
+ async releaseRole(userID: string, sessionID: string, requested: string, scope?: string): Promise<{ role: string; project?: string }> {
241
+ const { role, project } = parseRole(requested, scope)
242
+ return this.update(userID, (draft) => {
243
+ const held = draft.roles.find((r) => r.sessionID === sessionID && r.role === role && r.project === project)
244
+ if (!held) throw new AgentError("not_found", `Not holding @@${scoped(role, project)}`)
245
+ draft.roles = draft.roles.filter((r) => r !== held)
246
+ return project !== undefined ? { role, project } : { role }
247
+ })
248
+ }
249
+
250
+ // ---------- directory ----------
251
+
252
+ /** The user's named, role-holding or online sessions (not archived or deleted), by project and role. */
253
+ who(userID: string, filter: { project?: string; role?: string } = {}): DirectoryEntry[] {
254
+ const project = filter.project?.toLowerCase()
255
+ const role = filter.role !== undefined ? parseRole(filter.role, project) : undefined
256
+ return this.read(userID, (draft) =>
257
+ draft.sessions
258
+ .filter((s) => s.archivedAt === undefined && s.deletedAt === undefined)
259
+ .map((s) => this.entry(draft, s.sessionID))
260
+ .filter((e) => e.online || e.name !== undefined || e.roles.length > 0)
261
+ .filter((e) => project === undefined || e.project === project || e.roles.some((r) => r.project === project))
262
+ .filter((e) => !role || e.roles.some((r) => r.role === role.role && inScope(r.project, e.project, role.project))),
263
+ )
264
+ }
265
+
266
+ // ---------- resolution ----------
267
+
268
+ /**
269
+ * The sessions an address names, for this user only: `ses_…` (an ID or a unique ID prefix), `@name[.project]`
270
+ * or `@@role[.project]` (every live holder). Throws invalid (malformed), not_found or ambiguous {candidates}.
271
+ */
272
+ resolve(userID: string, addr: string): Resolved {
273
+ const address = parseAddress(addr)
274
+ if (!address) throw new AgentError("invalid", `Not an address: use ses_…, @name or @@role`)
275
+ return this.read(userID, (draft) => ({ address, sessions: this.lookup(draft, address, addr) }))
276
+ }
277
+
278
+ /** Exactly one session; several (a role with several holders) is ambiguous. */
279
+ resolveOne(userID: string, addr: string): DirectoryEntry {
280
+ const { sessions } = this.resolve(userID, addr)
281
+ if (sessions.length > 1) throw ambiguous(addr, sessions)
282
+ return sessions[0]!
283
+ }
284
+
285
+ private lookup(draft: Draft, address: Address, addr: string): DirectoryEntry[] {
286
+ const missing = () => new AgentError("not_found", `No session ${addr}`)
287
+ if (address.kind === "id") {
288
+ if (draft.byID.has(address.id)) return [this.entry(draft, address.id)]
289
+ const matches = draft.sessions.filter((s) => s.sessionID.startsWith(address.id)).map((s) => this.entry(draft, s.sessionID))
290
+ if (matches.length > 1) throw ambiguous(addr, matches)
291
+ if (!matches.length) throw missing()
292
+ return matches
293
+ }
294
+ if (address.kind === "name") {
295
+ const held = draft.names.find((n) => n.name === address.name)
296
+ const entry = held && this.entry(draft, held.sessionID)
297
+ if (!entry || (address.project !== undefined && entry.project !== address.project)) throw missing()
298
+ return [entry]
299
+ }
300
+ const holders = draft.roles.filter((r) => r.role === address.role && inScope(r.project, draft.byID.get(r.sessionID)?.project, address.project))
301
+ const ids = [...new Set(holders.map((r) => r.sessionID))]
302
+ if (!ids.length) throw missing()
303
+ return ids.map((id) => this.entry(draft, id))
304
+ }
305
+
306
+ // ---------- internals ----------
307
+
308
+ private known(draft: Draft, sessionID: string): AgentSession {
309
+ const record = draft.byID.get(sessionID)
310
+ if (!record) throw new AgentError("not_found", `No session ${sessionID}`)
311
+ return record
312
+ }
313
+
314
+ private entry(draft: Draft, sessionID: string): DirectoryEntry {
315
+ const record = draft.byID.get(sessionID)
316
+ const live = draft.reported.get(sessionID)
317
+ const own = draft.names.filter((n) => n.sessionID === sessionID)
318
+ const name = own.find((n) => n.expiresAt === undefined)?.name
319
+ const aliases = own.filter((n) => n.expiresAt !== undefined).map((n) => n.name)
320
+ const roles = draft.roles.filter((r) => r.sessionID === sessionID).map(roleView)
321
+ return {
322
+ sessionID,
323
+ ...(name ? { name } : {}),
324
+ ...(aliases.length ? { aliases } : {}),
325
+ ...(record?.machineID ? { machineID: record.machineID } : {}),
326
+ ...(record?.parentID ? { parentID: record.parentID } : {}),
327
+ ...(record?.project ? { project: record.project } : {}),
328
+ ...(record?.title ? { title: record.title } : {}),
329
+ ...(live?.state ? { state: live.state } : {}),
330
+ online: live !== undefined,
331
+ ...(record?.lastSeen !== undefined ? { lastSeen: record.lastSeen } : {}),
332
+ ...(record?.archivedAt !== undefined ? { archived: true } : {}),
333
+ roles,
334
+ }
335
+ }
336
+
337
+ /** Runs `look` on a refreshed copy of the user's data, saving nothing; not_found for an unknown user. */
338
+ private read<T>(userID: string, look: (draft: Draft) => T): T {
339
+ if (!this.store.user(userID)) throw new AgentError("not_found", "No such user")
340
+ const reported = new Map(this.source.reported(userID).map((s) => [s.sessionID, s]))
341
+ return look(this.refresh(userID, this.store.agentData(userID), reported, this.source.archived(userID)))
342
+ }
343
+
344
+ /**
345
+ * Runs `change` on the user's refreshed data in one store update and resolves once it is saved; not_found for an
346
+ * unknown user. On a shared store the change may run again (on fresh data, after another pod's write).
347
+ */
348
+ private async update<T>(userID: string, change: (draft: Draft) => T): Promise<T> {
349
+ const reported = new Map(this.source.reported(userID).map((s) => [s.sessionID, s]))
350
+ const archived = this.source.archived(userID)
351
+ let done = false
352
+ let result: T | undefined
353
+ await this.store.updateAgentData(userID, (data) => {
354
+ const draft = this.refresh(userID, data, reported, archived)
355
+ result = change(draft)
356
+ this.write(draft)
357
+ done = true
358
+ })
359
+ if (!done) throw new AgentError("not_found", "No such user")
360
+ this.online.set(userID, new Set(reported.keys()))
361
+ return result as T
362
+ }
363
+
364
+ private write(draft: Draft): void {
365
+ const { data } = draft
366
+ if (draft.sessions.length || data.sessions) data.sessions = draft.sessions
367
+ if (draft.names.length || data.names) data.names = draft.names
368
+ if (draft.roles.length || data.roles) data.roles = draft.roles
369
+ }
370
+
371
+ /** Records, archive marks, releases, derivations and the cap, as of now. */
372
+ private refresh(userID: string, data: AgentData, reported: Map<string, ReportedSession>, archived: ReadonlySet<string>): Draft {
373
+ const t = this.now()
374
+ const sessions = data.sessions ?? []
375
+ const byID = new Map(sessions.map((s) => [s.sessionID, s]))
376
+ for (const live of reported.values()) {
377
+ let record = byID.get(live.sessionID)
378
+ if (!record) {
379
+ record = { sessionID: live.sessionID, firstSeen: t }
380
+ sessions.push(record)
381
+ byID.set(live.sessionID, record)
382
+ }
383
+ for (const field of ["machineID", "parentID", "project", "title"] as const) {
384
+ const value = live[field]
385
+ if (value !== undefined && value !== "" && record[field] !== value) record[field] = value
386
+ }
387
+ if (record.lastSeen === undefined || t - record.lastSeen >= LAST_SEEN_STEP_MS) record.lastSeen = t
388
+ delete record.deletedAt
389
+ }
390
+ for (const id of this.online.get(userID) ?? []) {
391
+ const record = byID.get(id)
392
+ if (record && !reported.has(id)) record.lastSeen = t
393
+ }
394
+ for (const record of sessions) {
395
+ if (archived.has(record.sessionID)) record.archivedAt ??= t
396
+ else delete record.archivedAt
397
+ }
398
+
399
+ const draft: Draft = { data, sessions, byID, reported, names: data.names ?? [], roles: data.roles ?? [] }
400
+ draft.names = draft.names.filter((n) => !this.nameReleased(n, byID, t))
401
+ draft.roles = draft.roles.filter((r) => this.holding(draft, r.sessionID, t))
402
+ this.derive(draft, t)
403
+ this.cap(draft)
404
+ return draft
405
+ }
406
+
407
+ private nameReleased(name: AgentName, byID: Map<string, AgentSession>, t: number): boolean {
408
+ if (name.expiresAt !== undefined && t >= name.expiresAt) return true
409
+ const end = ended(byID.get(name.sessionID))
410
+ return end !== undefined && t - end >= NAME_RELEASE_MS
411
+ }
412
+
413
+ /** A holder keeps its roles while reported, or offline for less than ROLE_OFFLINE_MS, or (any) during the reconnect window; never once deleted. */
414
+ private holding(draft: Draft, sessionID: string, t: number): boolean {
415
+ const record = draft.byID.get(sessionID)
416
+ if (record?.deletedAt !== undefined) return false
417
+ if (draft.reported.has(sessionID) || t - this.startedAt < RECONNECT_WINDOW_MS) return true
418
+ return record?.lastSeen !== undefined && t - record.lastSeen < ROLE_OFFLINE_MS
419
+ }
420
+
421
+ /** Names root sessions once, from their first non-placeholder title. */
422
+ private derive(draft: Draft, t: number): void {
423
+ for (const live of draft.reported.values()) {
424
+ const record = draft.byID.get(live.sessionID)!
425
+ if (live.parentID || record.named || isPlaceholderTitle(live.title)) continue
426
+ record.named = true
427
+ const slug = deriveName(live.title)
428
+ if (!slug || draft.names.some((n) => n.sessionID === live.sessionID && n.expiresAt === undefined)) continue
429
+ const taken = new Set(draft.names.map((n) => n.name))
430
+ draft.names.push({ name: withSuffix(slug, (n) => taken.has(n) || isReservedName(n)), sessionID: live.sessionID, created: t })
431
+ }
432
+ }
433
+
434
+ /** Drops records beyond SESSION_LIMIT (never a reported one), with their names and roles. */
435
+ private cap(draft: Draft): void {
436
+ const excess = draft.sessions.length - SESSION_LIMIT
437
+ if (excess <= 0) return
438
+ const seen = (s: AgentSession) => s.lastSeen ?? s.firstSeen ?? 0
439
+ const victims = draft.sessions
440
+ .filter((s) => !draft.reported.has(s.sessionID))
441
+ .sort((a, b) => Number(ended(b) !== undefined) - Number(ended(a) !== undefined) || seen(a) - seen(b))
442
+ .slice(0, excess)
443
+ const gone = new Set(victims.map((s) => s.sessionID))
444
+ const kept = draft.sessions.filter((s) => !gone.has(s.sessionID))
445
+ draft.sessions.splice(0, draft.sessions.length, ...kept)
446
+ for (const id of gone) draft.byID.delete(id)
447
+ draft.names = draft.names.filter((n) => !gone.has(n.sessionID))
448
+ draft.roles = draft.roles.filter((r) => !gone.has(r.sessionID))
449
+ }
450
+ }
451
+
452
+ /** `role`, `@@role` or `role.project` (an explicit scope wins); invalid when malformed. */
453
+ function parseRole(requested: string, scope?: string): { role: string; project?: string } {
454
+ const [role = "", inline, ...rest] = requested.replace(/^@@/, "").split(".")
455
+ const project = (scope ?? inline)?.toLowerCase()
456
+ if (!validName(role) || rest.length || (project !== undefined && !/^[^./\s]+$/.test(project))) {
457
+ throw new AgentError("invalid", "A role is 2–40 characters: a-z, 0-9 and -, optionally .project")
458
+ }
459
+ return project !== undefined ? { role, project } : { role }
460
+ }
461
+
462
+ const scoped = (role: string, project?: string) => (project !== undefined ? `${role}.${project}` : role)
463
+
464
+ /** A claim scoped to `claimProject` (or unscoped, in a session of `sessionProject`) counts for `wanted`. */
465
+ const inScope = (claimProject: string | undefined, sessionProject: string | undefined, wanted: string | undefined) =>
466
+ wanted === undefined || claimProject === wanted || (claimProject === undefined && sessionProject === wanted)
467
+
468
+ const roleView = (r: AgentRole): RoleView => ({ role: r.role, ...(r.project !== undefined ? { project: r.project } : {}), generation: r.generation ?? 0, ...(r.exclusive ? { exclusive: true } : {}) })
469
+
470
+ function ambiguous(addr: string, sessions: readonly DirectoryEntry[]): AgentError {
471
+ const candidates = sessions.slice(0, CANDIDATES_MAX).map((s) => ({ sessionID: s.sessionID, ...(s.name ? { name: s.name } : {}), ...(s.project ? { project: s.project } : {}) }))
472
+ return new AgentError("ambiguous", `${addr} matches ${sessions.length} sessions`, { extra: { candidates } })
473
+ }