@huynle/hub 0.7.3 → 0.8.0

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 (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -6,7 +6,8 @@
6
6
  // - Placement (placement.ts, under the user's fenced lease) moves it to provisioning on a node; the engine then
7
7
  // runs one pipeline per launch: prepare on the node (requestNode) → launch create on the machine's service spoke
8
8
  // (feature "launch": directory, launchID, agent, model) → roles (byUser only for the user's own launches; a refused
9
- // role is noted, not fatal) → the name (taken meanwhile: failed) → the primer (Delivery.prime, so roles and name
9
+ // role is noted, not fatal) → the name (taken meanwhile: failed) → the spec's intent (CON-02, source launch; a
10
+ // failure is noted, not fatal) → the primer (Delivery.prime, so roles and name
10
11
  // are in it) → the prompt (actions.prompt, resetHops false; marked prompted first, so at most once) → running.
11
12
  // A stop between steps ends it stopped; the node going away ends it lost; anything else ends it failed.
12
13
  // - openshell launches (design §6.4, decision lk6gn3u7): after prepare the engine mints the launch's hlt1 token
@@ -119,6 +120,10 @@ export interface LaunchEngineDeps {
119
120
  readonly onLaunchChanged?: (launch: Launch) => void
120
121
  /** Cluster mode: the leader's supervisor, for an engine that does not supervise (see EngineSupervisor). */
121
122
  readonly supervisor?: EngineSupervisor
123
+ /** (HWA-15) Applies a launch's agentProfile before its prompt; returns notes for the reason, never throws. */
124
+ readonly profiles?: { applyAtLaunch(userID: string, sessionID: string, ref: { readonly profileID: string; readonly version: number }): Promise<string[]> }
125
+ /** (CON-02) Stores the spec's intent as the session's intent (source launch) at identify. */
126
+ readonly intents?: { storeLaunch(userID: string, sessionID: string, text: string, by?: string): Promise<void> }
122
127
  }
123
128
 
124
129
  /** Node events of every pod (cluster mode; the same as local ones on a single hub). */
@@ -771,11 +776,20 @@ export class LaunchEngine {
771
776
  }
772
777
  }
773
778
  if (spec.name !== undefined) await directory.setName(userID, sessionID, spec.name)
779
+ if (spec.intent !== undefined && this.deps.intents) {
780
+ try {
781
+ await this.deps.intents.storeLaunch(userID, sessionID, spec.intent, launch.by ?? "user")
782
+ } catch (cause) {
783
+ notes.push(`intent not stored: ${errorText(cause)}`)
784
+ }
785
+ }
774
786
  await delivery.prime(userID, sessionID)
787
+ if (spec.agentProfile) notes.push(...(this.deps.profiles ? await this.deps.profiles.applyAtLaunch(userID, sessionID, spec.agentProfile) : ["agentProfile not applied: this hub does not apply profiles"]))
775
788
  checkpoint()
776
789
  if (spec.prompt !== undefined) {
777
790
  await this.updateWork(userID, launch.id, { prompted: true })
778
- const prompted = await actions.prompt({ sessionID, text: spec.prompt, resetHops: false })
791
+ // (CON-10) The launch's prompt is the session's first prompt (its intent), even when the launch named it.
792
+ const prompted = await actions.prompt({ sessionID, text: spec.prompt, resetHops: false, intent: "first" })
779
793
  if (prompted.outcome === "offline") throw new Error("The session went offline before its prompt")
780
794
  if (prompted.outcome === "unsupported") throw new Error(unsupportedMessage(prompted))
781
795
  if (prompted.mode === "steer") this.sawWorking(userID, launch.id)
@@ -13,6 +13,7 @@
13
13
  // → kill switch again → the engine. Apps and sessions see and stop only the launches they started (by); any other
14
14
  // launch, like another user's, is 404 not_found. The engine applies the per-user launch caps (rate_limited).
15
15
  // Every outcome once the body is valid is in the agent audit (verb launch, or launch.stop for DELETE).
16
+ import { boundedJson } from "./body"
16
17
  import type { LaunchInfo, LaunchSpec as WireSpec } from "../../protocol"
17
18
  import { Feed } from "../cluster/feed"
18
19
  import type { ActingSwitch } from "./acting-switch"
@@ -120,7 +121,7 @@ const invalid = (message: string) => new AgentError("invalid", message)
120
121
  const notFound = (id: string) => new AgentError("not_found", `No launch ${id}`)
121
122
 
122
123
  async function bodyOf(request: Request): Promise<unknown> {
123
- return JSON.parse(await request.text())
124
+ return boundedJson(request)
124
125
  }
125
126
 
126
127
  /** ?state=a,b and ?label=k=v (repeated). */
@@ -197,6 +198,10 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
197
198
  async handle({ request, actor, user }) {
198
199
  const raw = await bodyOf(request)
199
200
  const spec = validateLaunchSpec(raw)
201
+ // HWA-15: naming a profile pins it; apps need profiles:assign, sessions never pin profiles.
202
+ if (spec.agentProfile && actor.kind !== "user" && !(actor.kind === "app" && actor.scopes.includes("profiles:assign"))) {
203
+ throw new AgentError("forbidden", "A launch's agentProfile needs scope profiles:assign; sessions cannot pin profiles")
204
+ }
200
205
  const by = launchedBy(actor)
201
206
  const held = spec.key === undefined ? undefined : (await launches.list(user.id)).find((l) => l.spec.key === spec.key)
202
207
  let launchID: string | undefined
@@ -10,7 +10,9 @@
10
10
  // cleanup keep, no timeout.
11
11
  // LaunchSpec is defined here until packages/protocol carries it (P4-T1); keep the two in step.
12
12
 
13
+ import { type LaunchAgentProfile, launchAgentProfileProblem } from "../../protocol"
13
14
  import { AgentError } from "./errors"
15
+ import { intentText } from "./intents"
14
16
  import { isReservedName, parseAddress, validName } from "./names"
15
17
  import { canonicalProject, parseDuration } from "./policy"
16
18
 
@@ -66,9 +68,13 @@ export interface LaunchSpec {
66
68
  readonly runtime: LaunchRuntime
67
69
  readonly placement: LaunchPlacement
68
70
  readonly prompt?: string
71
+ /** (CON-02) Stored as the session's intent (source launch) when it is identified. */
72
+ readonly intent?: string
69
73
  /** Free-form app labels (never interpreted by the hub). */
70
74
  readonly labels: Readonly<Record<string, string>>
71
75
  readonly end: LaunchEnd
76
+ /** (HWA-15) The profile version pinned and applied before the prompt (explicit version; never latest). */
77
+ readonly agentProfile?: LaunchAgentProfile
72
78
  }
73
79
 
74
80
  export const KEY_MAX = 200
@@ -83,7 +89,7 @@ export const MODEL_MAX = 200
83
89
  export const LAUNCH_TIMEOUT_MIN_MS = 60_000
84
90
  export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
85
91
 
86
- const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "labels", "end"]
92
+ const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile"]
87
93
  const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
88
94
  const RUNTIME_FIELDS = ["kind", "profile"]
89
95
  const PLACEMENT_FIELDS = ["labels", "machine", "mode"]
@@ -254,8 +260,13 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
254
260
  if (typeof input.prompt !== "string" || input.prompt.length > PROMPT_MAX || PROMPT_CONTROL.test(input.prompt)) throw invalid(`prompt is text of up to ${PROMPT_MAX} characters`)
255
261
  prompt = input.prompt
256
262
  }
263
+ // (CON-02) What the session is for: untrusted, sanitised to one line, at most INTENT_MAX characters.
264
+ const intent = input.intent === undefined ? undefined : intentText(input.intent)
257
265
  const labels = validLabels(input.labels, "labels")
258
266
  const end = validEnd(input.end)
267
+ const profileProblem = input.agentProfile === undefined ? undefined : launchAgentProfileProblem(input.agentProfile)
268
+ if (profileProblem) throw invalid(profileProblem)
269
+ const agentProfile = input.agentProfile === undefined ? undefined : { profileID: (input.agentProfile as LaunchAgentProfile).profileID, version: (input.agentProfile as LaunchAgentProfile).version }
259
270
  return {
260
271
  ...(key !== undefined ? { key } : {}),
261
272
  ...(name !== undefined ? { name } : {}),
@@ -267,8 +278,10 @@ export function validateLaunchSpec(input: unknown): LaunchSpec {
267
278
  runtime,
268
279
  placement,
269
280
  ...(prompt !== undefined ? { prompt } : {}),
281
+ ...(intent !== undefined ? { intent } : {}),
270
282
  labels,
271
283
  end,
284
+ ...(agentProfile ? { agentProfile } : {}),
272
285
  }
273
286
  }
274
287
 
@@ -25,6 +25,7 @@
25
25
  // user, who owns every party); others get not_found.
26
26
  // - Messaging off (UserRecord.agent.messagingPaused): msg, ask, reply and decline answer paused; reading works.
27
27
  // - Every stored message is published on the MessageFeed (GET /api/agent/watch 'message' events, waiters).
28
+ import { boundedJson } from "./body"
28
29
  import { Feed } from "../cluster/feed"
29
30
  import type { Store } from "../store"
30
31
  import type { AgentActor } from "./actor"
@@ -70,7 +71,7 @@ const READ = { session: true, scope: "messages:read" } as const
70
71
  type Body = Record<string, unknown>
71
72
 
72
73
  async function bodyOf(request: Request): Promise<Body> {
73
- const body = await request.json()
74
+ const body = await boundedJson(request)
74
75
  if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Body must be a JSON object")
75
76
  return body as Body
76
77
  }
@@ -7,6 +7,7 @@
7
7
  // - Caps: each party (session, the user, an app) keeps its latest SESSION_MESSAGES; a message no party keeps
8
8
  // is deleted. Per user, at most USER_MESSAGES messages / USER_BYTES; over either, the oldest go (down to
9
9
  // 90%). Eviction keeps open asks while anything else can go.
10
+ // Deleting a message records the newest evicted `at` (retention(): the graph's `retention.truncated`, HWA-31).
10
11
  // - Asks: open → answered (a reply) | declined | expired (at expiresAt, seen on read; expire() persists it).
11
12
  // The first answer wins (version-checked write); later ones get `gone`.
12
13
  // - Replies and declines come only from the recipient (anyone else: not_found, like a foreign id) and go to
@@ -39,8 +40,11 @@ export const INBOX_RECENT = 50
39
40
  export type MessageKind = "msg" | "ask" | "reply" | "decline"
40
41
  /** `sent` for msg/reply/decline; asks are open until answered, declined or expired. */
41
42
  export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
42
- /** How a message reached its recipient (design §3.3), recorded by the delivery engine. */
43
- export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending"
43
+ /**
44
+ * How a message reached its recipient (design §3.3), recorded by the delivery engine. (CON-10) "dropped": it was
45
+ * pending for a session that ended for good (a retired Concierge session) and will never be delivered.
46
+ */
47
+ export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped"
44
48
 
45
49
  export interface Message {
46
50
  readonly id: string
@@ -117,11 +121,18 @@ export interface Usage {
117
121
  readonly bytes: number
118
122
  }
119
123
 
124
+ /** What the store retains (the interaction graph's retention disclosure, HWA-31). */
125
+ export interface Retention extends Usage {
126
+ /** The newest `at` of a message the caps deleted; absent while nothing was evicted. */
127
+ readonly evictedAt?: number
128
+ }
129
+
120
130
  const MESSAGES = "messages"
121
131
  const INDEX = "message-index"
122
132
  const USAGE = "message-usage"
123
133
  const MUTES = "message-mutes"
124
134
  const USAGE_KEY = "total"
135
+ const EVICTED_KEY = "evicted"
125
136
  /** Version-checked attempts to detach a contended message. */
126
137
  const DETACH_ATTEMPTS = 16
127
138
 
@@ -261,6 +272,18 @@ export class Messages {
261
272
  return (await this.state.get<Usage>(userID, USAGE, USAGE_KEY))?.value ?? { count: 0, bytes: 0 }
262
273
  }
263
274
 
275
+ /** Every retained message of the user, oldest first, as of now: one scan, for whole-user projections. */
276
+ async all(userID: string): Promise<Message[]> {
277
+ const entries = await this.state.list<StoredMessage>(userID, MESSAGES)
278
+ return entries.map((entry) => this.view(entry.value.message)).sort((a, b) => a.at - b.at || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
279
+ }
280
+
281
+ /** Usage and the newest evicted message's time (older interactions may be missing). */
282
+ async retention(userID: string): Promise<Retention> {
283
+ const [usage, evicted] = await Promise.all([this.usage(userID), this.state.get<number>(userID, USAGE, EVICTED_KEY)])
284
+ return { ...usage, ...(evicted ? { evictedAt: evicted.value } : {}) }
285
+ }
286
+
264
287
  /** Records how the message was delivered; undefined when there is no such message. */
265
288
  async setDelivery(userID: string, id: string, delivery: Delivery): Promise<Message | undefined> {
266
289
  const at = this.now()
@@ -431,6 +454,8 @@ export class Messages {
431
454
  if ((await this.state.put<StoredMessage>(userID, MESSAGES, id, { ...current.value, indexed }, { ifVersion: current.version })).ok) return
432
455
  } else if ((await this.state.delete(userID, MESSAGES, id, { ifVersion: current.version })).ok) {
433
456
  await this.addUsage(userID, -1, -current.value.bytes)
457
+ const at = current.value.message.at
458
+ await updateVersioned<number>(this.state, userID, USAGE, EVICTED_KEY, (marked) => (marked !== undefined && marked >= at ? undefined : at))
434
459
  return
435
460
  }
436
461
  }
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { NAME_MAX, validName } from "../../../packages/sdk/src/address.ts"
6
6
 
7
- export { NAME_MAX, parseAddress, validName, type Address } from "../../../packages/sdk/src/address.ts"
7
+ export { addressProblem, NAME_MAX, parseAddress, validName, type Address, type AddressProblem } from "../../../packages/sdk/src/address.ts"
8
8
 
9
9
  /**
10
10
  * OpenCode's fallback titles: "New session" / "Child session" (display labels) and the historical
@@ -43,10 +43,20 @@ 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"]
46
+ const RESERVED_ROLES = ["orchestrator", "concierge"]
47
47
 
48
48
  export const isReservedRole = (role: string): boolean => RESERVED_ROLES.some((prefix) => role.startsWith(prefix))
49
49
 
50
+ /**
51
+ * (CON-04) Roles only the hub itself claims and releases (reserved too): not the user, a session, an app under a
52
+ * `roles` grant or a launch spec. `concierge`: the Concierge service holds it for the user's current Concierge.
53
+ */
54
+ const MANAGED_ROLES = ["concierge"]
55
+
56
+ export const isManagedRole = (role: string): boolean => MANAGED_ROLES.some((prefix) => role.startsWith(prefix))
57
+
58
+ export const MANAGED_ROLE_MESSAGE = (role: string) => `@@${role} is managed by the hub: use Settings → Concierge`
59
+
50
60
  /** The longest derived name: a few words, short enough to type and read in an address (@send-hello-to-session). */
51
61
  export const DERIVED_NAME_MAX = 24
52
62
  /** At most this many words of the title make a derived name. */
@@ -3,12 +3,14 @@
3
3
  //
4
4
  // Routes (user actors always; session tokens and app tokens holding sessions:read):
5
5
  // GET /api/agent/status
6
- // GET /api/agent/sessions?project=&state=&machine=&label=k=v
6
+ // GET /api/agent/sessions?project=&state=&machine=&label=k=v&q= (CON-11) q: every word in the title or @name
7
7
  // GET /api/agent/sessions/:addr show: the catalog view plus the spoke's summary (when it serves one)
8
8
  // GET /api/agent/sessions/:addr/log?n=20 the newest timeline rows (spoke feature "timeline")
9
9
  // GET /api/agent/watch?blocked=1&session=<addr> SSE of compact events (watch.ts)
10
10
  // show and log call spokes; a spoke without the feature answers unsupported {feature, harness}.
11
11
  // A session a launch started carries that launch's labels (the latest launch's, when several did); others have none.
12
+ // (CON-02) /sessions and /sessions/:addr entries carry `intent {text, source, revision}` when one is stored (untrusted
13
+ // text; the title fallback is only on GET /sessions/:addr/intent, intent-routes.ts).
12
14
  import path from "node:path"
13
15
  import { featureForOp, type ServerRequest, type SpokeFeature } from "../../protocol"
14
16
  import type { CatalogMachine, CatalogSession, LiveStatus, NowActivity, RegistryMessage, SessionView } from "../registry"
@@ -21,9 +23,14 @@ import { AgentError } from "./errors"
21
23
  import { AGENT_PREFIX, type AgentRoute } from "./routes"
22
24
  import { watchStream } from "./watch"
23
25
  import type { EventsRouteDeps } from "./events-routes"
26
+ import { type Census, type TeamDiscovery, visible } from "../teams/discovery"
27
+ import type { SessionIntent } from "../../protocol"
28
+ import { intentField } from "./intents"
24
29
 
25
30
  /** Labels per session ID. */
26
31
  export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
32
+ /** (CON-02) Stored intents per session ID. */
33
+ export type SessionIntentMap = ReadonlyMap<string, SessionIntent>
27
34
 
28
35
  /** What the read routes need from the Registry. */
29
36
  export interface ReadRegistry {
@@ -32,6 +39,8 @@ export interface ReadRegistry {
32
39
  session(sessionID: string): SessionView | undefined
33
40
  owner(sessionID: string): string | undefined
34
41
  supports(connID: string, feature: SpokeFeature): boolean
42
+ /** (RP-08) A connection's harness (Registry implements it). */
43
+ harnessOf?(connID: string): string | undefined
35
44
  subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
36
45
  }
37
46
 
@@ -47,6 +56,8 @@ export interface ReadDeps {
47
56
  readonly spokes?: SpokeRequester
48
57
  /** The labels of the user's launched sessions (none without it). */
49
58
  readonly labels?: (userID: string) => Promise<SessionLabels>
59
+ /** (CON-02) The stored intents of these sessions: GET /sessions and /sessions/:addr carry `intent` when present. */
60
+ readonly intents?: (userID: string, sessionIDs: readonly string[]) => Promise<SessionIntentMap>
50
61
  /** The journal GET /api/agent/watch streams (no watch route without it). */
51
62
  readonly events?: EventsRouteDeps
52
63
  /** Watch stream heartbeat (default WATCH_HEARTBEAT_MS). */
@@ -54,6 +65,10 @@ export interface ReadDeps {
54
65
  /** Aborted when the hub drains: watch streams end with a `retry:` hint. */
55
66
  readonly draining?: AbortSignal
56
67
  readonly now?: () => number
68
+ /** Team-aware discovery (HWA-40): team members see their teams; invisible sessions read as unknown. */
69
+ readonly discovery?: TeamDiscovery
70
+ /** (CON-04) The user's Concierge sessions (current and hidden): their entries carry `concierge: true`. */
71
+ readonly concierge?: (userID: string) => Promise<readonly string[]>
57
72
  }
58
73
 
59
74
  /** One session in agent answers (status, sessions). */
@@ -168,8 +183,9 @@ function you(actor: AgentActor, entries: Map<string, DirectoryEntry>) {
168
183
  }
169
184
 
170
185
  /** The status snapshot of one user (design §4): machines → projects → sessions, and what needs attention. */
171
- export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labels: SessionLabels = NO_LABELS) {
172
- const list = rows(deps.registry, userID)
186
+ export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labels: SessionLabels = NO_LABELS, census?: Census, exclude: ReadonlySet<string> = NONE) {
187
+ const list = rows(deps.registry, userID).filter((r) => (!census || visible(census, r.session.id)) && !exclude.has(r.session.id))
188
+ const teams = census && deps.discovery ? deps.discovery.groups(census, list.map((r) => r.session.id)) : []
173
189
  const entries = directoryOf(deps.directory, userID)
174
190
  const online = new Map(deps.registry.catalog().map((m) => [m.id, m]))
175
191
  const machines = deps.store
@@ -193,12 +209,12 @@ export function snapshot(deps: ReadDeps, userID: string, actor: AgentActor, labe
193
209
  projects: [...projects.values()],
194
210
  }
195
211
  })
196
- return { at: (deps.now ?? Date.now)(), you: you(actor, entries), machines, attention: attention(list, entries) }
212
+ return { at: (deps.now ?? Date.now)(), you: you(actor, entries), machines, attention: attention(list, entries), ...(teams.length ? { teams } : {}) }
197
213
  }
198
214
 
199
215
  /** The session an address names for the user, if the user's spokes report it now (else offline). */
200
- function located(deps: ReadDeps, userID: string, addr: string) {
201
- const entry = deps.directory.resolveOne(userID, addr)
216
+ function located(deps: ReadDeps, userID: string, addr: string, census?: Census) {
217
+ const entry = census && deps.discovery ? deps.discovery.resolveOne(census, addr) : deps.directory.resolveOne(userID, addr)
202
218
  const live = deps.registry.sessionTenant(entry.sessionID) === userID ? deps.registry.session(entry.sessionID) : undefined
203
219
  const list = rows(deps.registry, userID)
204
220
  const row = list.find((r) => r.session.id === entry.sessionID) ?? (live && list.find((r) => r.session.id === live.status[0]?.id))
@@ -226,9 +242,13 @@ async function ask(deps: ReadDeps, connID: string, req: ServerRequest): Promise<
226
242
  }
227
243
 
228
244
  /** GET /sessions/:addr: the session's catalog view with full blockers, and its summary from the spoke. */
229
- async function show(deps: ReadDeps, userID: string, addr: string) {
230
- const { entry, live, row } = located(deps, userID, addr)
245
+ async function show(deps: ReadDeps, userID: string, addr: string, census?: Census) {
246
+ const { entry, live, row } = located(deps, userID, addr, census)
247
+ const memberships = census && deps.discovery ? deps.discovery.memberships(census, entry.sessionID) : []
248
+ const teams = memberships.length ? { teams: memberships } : {}
231
249
  const labels = { ...(await labelsOf(deps, userID)).get(entry.sessionID) }
250
+ const intent = intentField((await intentsOf(deps, userID, [entry.sessionID])).get(entry.sessionID))
251
+ const marker = (await conciergeOf(deps, userID)).has(entry.sessionID) ? { concierge: true } : {}
232
252
  const name = entry.name ? { name: `@${entry.name}` } : {}
233
253
  const roles = entry.roles.map(roleLabel)
234
254
  if (!live) {
@@ -245,6 +265,9 @@ async function show(deps: ReadDeps, userID: string, addr: string) {
245
265
  ...(entry.lastSeen !== undefined ? { lastSeen: entry.lastSeen } : {}),
246
266
  blockers: [],
247
267
  labels,
268
+ ...intent,
269
+ ...marker,
270
+ ...teams,
248
271
  }
249
272
  }
250
273
  const harness = row?.harness ?? "unknown"
@@ -266,6 +289,9 @@ async function show(deps: ReadDeps, userID: string, addr: string) {
266
289
  blockers: live.blockers.map(({ sessionTitle: _, ...b }) => b),
267
290
  children: live.status.filter((e) => e.parentID === live.id).length,
268
291
  labels,
292
+ ...intent,
293
+ ...marker,
294
+ ...teams,
269
295
  }
270
296
  try {
271
297
  const connID = spokeFor(deps, live.id, "summary", harness)
@@ -279,23 +305,53 @@ async function show(deps: ReadDeps, userID: string, addr: string) {
279
305
  }
280
306
 
281
307
  /** GET /sessions/:addr/log: the newest `n` timeline rows, newest first. */
282
- async function log(deps: ReadDeps, userID: string, addr: string, raw: string | null) {
308
+ async function log(deps: ReadDeps, userID: string, addr: string, raw: string | null, census?: Census) {
283
309
  const n = raw === null ? 20 : Number(raw)
284
310
  if (!Number.isInteger(n) || n < 1 || n > 200) throw new AgentError("invalid", "n must be 1–200")
285
- const { entry, live, row } = located(deps, userID, addr)
311
+ const { entry, live, row } = located(deps, userID, addr, census)
286
312
  if (!live) throw new AgentError("offline", "No spoke reports this session now")
287
313
  const connID = spokeFor(deps, entry.sessionID, "timeline", row?.harness ?? "unknown")
288
314
  const page = (await ask(deps, connID, { op: "timeline", sessionID: entry.sessionID, limit: n, order: "desc" })) as { items?: unknown[]; older?: string }
289
315
  return { sessionID: entry.sessionID, rows: page.items ?? [], ...(page.older ? { older: page.older } : {}) }
290
316
  }
291
317
 
318
+ const censusOf = (deps: ReadDeps, userID: string, actor: AgentActor): Promise<Census | undefined> => deps.discovery?.census(userID, actor) ?? Promise.resolve(undefined)
319
+
292
320
  const labelsOf = (deps: ReadDeps, userID: string): Promise<SessionLabels> => deps.labels?.(userID) ?? Promise.resolve(NO_LABELS)
293
321
 
322
+ const NO_INTENTS: SessionIntentMap = new Map()
323
+ /** (CON-04) The user's Concierge sessions (none without the dep). */
324
+ async function conciergeOf(deps: ReadDeps, userID: string): Promise<ReadonlySet<string>> {
325
+ return new Set(deps.concierge ? await deps.concierge(userID) : [])
326
+ }
327
+
328
+ const NONE: ReadonlySet<string> = new Set()
329
+ /**
330
+ * (CON-09a) What a Concierge session's own reads leave out: every Concierge session (current and previous), so it
331
+ * never reports itself as one of the user's sessions ("Greeting is working"). Anyone else: nothing.
332
+ */
333
+ export async function conciergeSelf(concierge: ((userID: string) => Promise<readonly string[]>) | undefined, userID: string, actor: AgentActor): Promise<ReadonlySet<string>> {
334
+ if (actor.kind !== "session" || !concierge) return NONE
335
+ const all = new Set(await concierge(userID))
336
+ return all.has(actor.sessionID) ? all : NONE
337
+ }
338
+
339
+ const intentsOf = (deps: ReadDeps, userID: string, sessionIDs: readonly string[]): Promise<SessionIntentMap> =>
340
+ deps.intents && sessionIDs.length ? deps.intents(userID, sessionIDs) : Promise.resolve(NO_INTENTS)
341
+
294
342
  const STATES = new Set(["idle", "working", "blocked", "error"])
295
343
 
296
344
  /** The /sessions filters: project (name or root), state (comma list), machine (ID or name), label k=v. */
345
+ /** (CON-11) The longest `q` (characters). */
346
+ const QUERY_MAX = 200
347
+ /** Search words: lower case, letters and digits only (an @, dashes and punctuation separate words). */
348
+ const words = (text: string) => text.toLowerCase().normalize("NFKD").replace(/\p{M}/gu, "").split(/[^\p{L}\p{N}]+/u).filter(Boolean)
349
+
297
350
  function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams): (row: Row, view: AgentSessionView) => boolean {
298
351
  const project = params.get("project") || undefined
352
+ const q = params.get("q") ?? ""
353
+ if (q.length > QUERY_MAX) throw new AgentError("invalid", `q is at most ${QUERY_MAX} characters`)
354
+ const wanted = words(q)
299
355
  const states = params.get("state")?.split(",").filter(Boolean)
300
356
  if (states?.some((state) => !STATES.has(state))) throw new AgentError("invalid", "state must be idle, working, blocked or error (comma-separated)")
301
357
  const machine = params.get("machine") || undefined
@@ -310,6 +366,10 @@ function sessionFilter(deps: ReadDeps, userID: string, params: URLSearchParams):
310
366
  if (project !== undefined && root !== project && name.toLowerCase() !== project.toLowerCase()) return false
311
367
  if (states?.length && !states.includes(view.state)) return false
312
368
  if (machineIDs && !machineIDs.has(row.machineID)) return false
369
+ if (wanted.length) {
370
+ const have = [...words(view.title ?? ""), ...words(view.name ?? "")]
371
+ if (!wanted.every((w) => have.some((h) => h.includes(w)))) return false
372
+ }
313
373
  return labels.every(([key, value]) => view.labels[key] === value)
314
374
  }
315
375
  }
@@ -324,20 +384,25 @@ export function readRoutes(deps: ReadDeps): AgentRoute[] {
324
384
  method: "GET",
325
385
  path: `${AGENT_PREFIX}/status`,
326
386
  allow: READ,
327
- handle: async ({ user, actor }) => ok(snapshot(deps, user.id, actor, await labelsOf(deps, user.id))),
387
+ handle: async ({ user, actor }) => ok(snapshot(deps, user.id, actor, await labelsOf(deps, user.id), await censusOf(deps, user.id, actor), await conciergeSelf(deps.concierge, user.id, actor))),
328
388
  },
329
389
  {
330
390
  method: "GET",
331
391
  path: `${AGENT_PREFIX}/sessions`,
332
392
  allow: READ,
333
- handle: async ({ user, url }) => {
393
+ handle: async ({ user, url, actor }) => {
334
394
  const keep = sessionFilter(deps, user.id, url.searchParams)
395
+ const census = await censusOf(deps, user.id, actor)
335
396
  const entries = directoryOf(deps.directory, user.id)
336
397
  const labels = await labelsOf(deps, user.id)
337
- const sessions = rows(deps.registry, user.id)
398
+ const self = await conciergeSelf(deps.concierge, user.id, actor)
399
+ const listed = rows(deps.registry, user.id)
338
400
  .map((row) => ({ row, view: view(row, entries.get(row.session.id), labels) }))
339
- .filter(({ row, view }) => keep(row, view))
340
- .map(({ row, view }) => ({ ...view, machineID: row.machineID, project: projectOf(row.session).name }))
401
+ .filter(({ row, view }) => keep(row, view) && (!census || visible(census, row.session.id)) && !self.has(row.session.id))
402
+ // Only the listed sessions' intents are read (never the whole collection).
403
+ const intents = await intentsOf(deps, user.id, listed.map(({ row }) => row.session.id))
404
+ const concierge = await conciergeOf(deps, user.id)
405
+ const sessions = listed.map(({ row, view }) => ({ ...view, machineID: row.machineID, project: projectOf(row.session).name, ...intentField(intents.get(row.session.id)), ...(concierge.has(row.session.id) ? { concierge: true } : {}) }))
341
406
  return ok({ sessions })
342
407
  },
343
408
  },
@@ -345,13 +410,13 @@ export function readRoutes(deps: ReadDeps): AgentRoute[] {
345
410
  method: "GET",
346
411
  path: `${AGENT_PREFIX}/sessions/:addr`,
347
412
  allow: READ,
348
- handle: async ({ user, params }) => ok(await show(deps, user.id, params.addr!)),
413
+ handle: async ({ user, params, actor }) => ok(await show(deps, user.id, params.addr!, await censusOf(deps, user.id, actor))),
349
414
  },
350
415
  {
351
416
  method: "GET",
352
417
  path: `${AGENT_PREFIX}/sessions/:addr/log`,
353
418
  allow: READ,
354
- handle: async ({ user, params, url }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"))),
419
+ handle: async ({ user, params, url, actor }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"), await censusOf(deps, user.id, actor))),
355
420
  },
356
421
  ...(deps.events ? [watchRoute(deps, deps.events)] : []),
357
422
  ]
@@ -363,9 +428,11 @@ function watchRoute(deps: ReadDeps, events: EventsRouteDeps): AgentRoute {
363
428
  method: "GET",
364
429
  path: `${AGENT_PREFIX}/watch`,
365
430
  allow: READ,
366
- handle: ({ user, url, request, actor }) => {
431
+ handle: async ({ user, url, request, actor }) => {
367
432
  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
433
+ const census = addr ? await censusOf(deps, user.id, actor) : undefined
434
+ const resolved = addr ? (census && deps.discovery ? deps.discovery.resolve(census, addr) : deps.directory.resolve(user.id, addr)) : undefined
435
+ const sessions = resolved ? new Set(resolved.sessions.map((s) => s.sessionID)) : undefined
369
436
  const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
370
437
  const cursor = url.searchParams.get("cursor") || request.headers.get("last-event-id") || undefined
371
438
  return watchStream({