@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
@@ -0,0 +1,260 @@
1
+ // CON-06 approval tiers (contract "Approval tiers (CON-06)"): the tiers slot of the Concierge modules (index.ts).
2
+ //
3
+ // | Tier | When | How |
4
+ // | auto | inside the @@concierge grant (the acting engine allows) | it acts; audit as: "concierge" (audit.ts) |
5
+ // | confirm | escalations whose riskTier is confirm; stop/deny proposals | tap, or voice/text in the Concierge panel |
6
+ // | tap | riskTier tap; prompt/answer proposals | console cookie only, never voice |
7
+ //
8
+ // Routes (Concierge-only authority: the user's CURRENT Concierge session through its act token; every other agent
9
+ // credential, app tokens included, is 404; the user is 403 where the route is the Concierge's own):
10
+ //
11
+ // POST /api/agent/concierge/proposals {verb, session, input?, why?, requestID?|formID?} → 201 {decision} (proposals.ts)
12
+ // POST /api/agent/concierge/arm {decisionID | null} the Concierge → {armed | null}
13
+ // GET /api/agent/concierge/arm user; the Concierge → {armed | null}
14
+ // GET /api/agent/concierge/sessions/:addr/forms/:form user; the Concierge → {form: PendingForm} (CON-13)
15
+ //
16
+ // (CON-13) The form read is a pending form's SCHEMA only (title, field keys, types, option values and labels,
17
+ // required, limits; acting.ts form()): never the session's other text. It is checked like the answer verb short of
18
+ // the grant (kill switch, tenancy, the Concierge guard, pending, self-tree), so the Concierge can answer or propose
19
+ // an answer that fits; an answer that does not is 400 with the field's problem (form-check.ts).
20
+ //
21
+ // Arming marks one OPEN CONFIRM Decision of the user as the one under discussion (one per user, replaced by the
22
+ // next, ARM_TTL_MS; tap → 400 invalid; not open → 410 gone; unknown → 404). It is durable (arm.ts) and goes to the
23
+ // console as SSE {type: "concierge", armed}. An answered/closed/expired Decision is disarmed (decisionChanged). When
24
+ // an arm lapses (ARM_TTL_MS) the pod that armed it says so: SSE {type: "concierge", armed: null} (lapse watch below).
25
+ // The board marks the live armed Decision (`armedID`, ConciergeBoardDecision.armed).
26
+ //
27
+ // via (checkVia, Decisions' via hook): a voice/text answer is admitted only when the Decision is confirm tier, open
28
+ // and armed by the CURRENT Concierge session, and the answering device is the user's and has presence on that
29
+ // session (within PRESENCE_WINDOW_MS; the registry's presence, set by POST /api/sessions/:id/presence). Anything
30
+ // else (and a failed read) → 403 "Answer this decision with a tap". The model never answers: the route is the
31
+ // console's (devices only), and matching speech to an option happens on the device.
32
+ import type { ConciergeArmed } from "../../../packages/protocol/src/concierge"
33
+ import type { Counters, StateStore } from "../backend/types"
34
+ import type { Acting } from "../agent/acting"
35
+ import { type AgentActor, canActAsSession, type UserActor } from "../agent/actor"
36
+ import type { AgentAudit } from "../agent/audit"
37
+ import { boundedJson } from "../agent/body"
38
+ import { type ActOutcome, type Decision, type Decisions, TAP_ONLY } from "../agent/decisions"
39
+ import type { Party } from "../agent/envelope"
40
+ import { AgentError } from "../agent/errors"
41
+ import { RouteIdempotency } from "../agent/idempotency"
42
+ import type { Message } from "../agent/messages"
43
+ import type { AgentRoute } from "../agent/routes"
44
+ import { ArmStore, type ArmedDecision } from "./arm"
45
+ import type { ConciergePorts, ConciergeTiers } from "./index"
46
+ import { parseProposal, Proposals } from "./proposals"
47
+
48
+ const PATH = "/api/agent/concierge"
49
+ const NO_STORE = { "cache-control": "no-store" }
50
+ const IDEMPOTENCY_COLLECTION = "concierge-proposal-keys"
51
+ /** How often a live arm's lapse watch looks at the clock (it reads the store only once the arm is due). */
52
+ const LAPSE_POLL_MS = 1_000
53
+
54
+ /** What the tiers need from the hub besides the Concierge ports (http.ts fills it). */
55
+ export interface TierPorts {
56
+ readonly state: StateStore
57
+ readonly counters: Pick<Counters, "incr" | "get">
58
+ readonly decisions: Pick<Decisions, "ask" | "list" | "get">
59
+ readonly acting: Pick<Acting, "preflight" | "stop" | "deny" | "prompt" | "answer" | "form">
60
+ readonly audit?: Pick<AgentAudit, "record">
61
+ readonly protects?: (userID: string, sessionID: string) => Promise<boolean>
62
+ readonly published?: (userID: string, message: Message) => void
63
+ readonly describe?: (userID: string, actor: AgentActor) => Party
64
+ /** Whether the device reported the session visible within the presence window (any pod). */
65
+ readonly present: (deviceID: string, sessionID: string) => boolean
66
+ /** The device's owner (undefined: no such device). */
67
+ readonly deviceOwner: (deviceID: string) => string | undefined
68
+ /** The console's live event {type: "concierge", armed}. Never throws through. */
69
+ readonly armed: (userID: string, armed: ArmedView | null) => void
70
+ readonly now?: () => number
71
+ }
72
+
73
+ /** What clients see of the armed Decision. */
74
+ export type ArmedView = ConciergeArmed
75
+
76
+ const view = (armed: ArmedDecision | undefined): ArmedView | null => (armed ? { decisionID: armed.decisionID, at: armed.at, expiresAt: armed.expiresAt } : null)
77
+ const notFound = () => new AgentError("not_found", "Not found")
78
+ const tapOnly = () => new AgentError("forbidden", TAP_ONLY, { hint: "Tap an option" })
79
+
80
+ /** The tiers module for the Concierge ports; without `ports.tiers` it is the no-op (no routes). */
81
+ export function conciergeTiers(ports: ConciergePorts): ConciergeTiers {
82
+ const hub = ports.tiers
83
+ if (!hub) return { routes: () => [] }
84
+ const { identity } = ports
85
+ const arms = new ArmStore({ state: hub.state, ...(hub.now ? { now: hub.now } : {}) })
86
+ const proposals = new Proposals({
87
+ decisions: hub.decisions,
88
+ acting: hub.acting,
89
+ counters: hub.counters,
90
+ ...(hub.audit ? { audit: hub.audit } : {}),
91
+ ...(hub.protects ? { protects: hub.protects } : {}),
92
+ ...(hub.published ? { published: hub.published } : {}),
93
+ ...(hub.describe ? { describe: hub.describe } : {}),
94
+ })
95
+ const idempotency = new RouteIdempotency({ state: hub.state, collection: IDEMPOTENCY_COLLECTION, ...(hub.now ? { now: hub.now } : {}) })
96
+ const tell = (userID: string, armed: ArmedView | null) => {
97
+ try {
98
+ hub.armed(userID, armed)
99
+ } catch (cause) {
100
+ console.error(`[hub] concierge armed event failed: ${(cause as Error).message}`)
101
+ }
102
+ }
103
+ const clock = hub.now ?? Date.now
104
+
105
+ // Lapse watch: one timer per user for the arm this pod made. It checks the clock every LAPSE_POLL_MS and, once the
106
+ // arm is due, reads the record: replaced by a newer arm (another pod's, or this pod's own watch) → nothing; still
107
+ // live (clock skew) → keep watching; gone or expired → SSE armed: null (harmless if a disarm already said so).
108
+ const lapses = new Map<string, ReturnType<typeof setTimeout>>()
109
+ const unwatch = (userID: string) => {
110
+ const timer = lapses.get(userID)
111
+ if (timer !== undefined) clearTimeout(timer)
112
+ lapses.delete(userID)
113
+ }
114
+ const watchLapse = (userID: string, armed: ArmedDecision) => {
115
+ unwatch(userID)
116
+ const schedule = () => {
117
+ const timer = setTimeout(check, Math.min(LAPSE_POLL_MS, Math.max(0, armed.expiresAt - clock()) + 5))
118
+ ;(timer as { unref?: () => void }).unref?.()
119
+ lapses.set(userID, timer)
120
+ }
121
+ const check = () => {
122
+ if (clock() < armed.expiresAt) return schedule()
123
+ lapses.delete(userID)
124
+ void arms
125
+ .current(userID)
126
+ .then((current) => {
127
+ if (current && (current.at !== armed.at || current.decisionID !== armed.decisionID)) return
128
+ if (current) {
129
+ if (!lapses.has(userID)) schedule()
130
+ return
131
+ }
132
+ tell(userID, null)
133
+ })
134
+ .catch((cause: unknown) => console.error(`[hub] concierge arm lapse check failed: ${(cause as Error).message}`))
135
+ }
136
+ schedule()
137
+ }
138
+
139
+ /** The user's current Concierge session through its act token; else 404 (the user: 403 when `userForbidden`). */
140
+ const conciergeOnly = async (actor: AgentActor, userID: string, userForbidden?: string): Promise<string> => {
141
+ if (actor.kind === "user") {
142
+ if (userForbidden) throw new AgentError("forbidden", userForbidden)
143
+ return ""
144
+ }
145
+ if (actor.kind === "session" && canActAsSession(actor) && actor.userID === userID) {
146
+ const { sessionID } = await identity.current(userID)
147
+ if (sessionID !== undefined && sessionID === actor.sessionID) return sessionID
148
+ }
149
+ throw notFound()
150
+ }
151
+
152
+ /** The armed Decision as of now: open, and armed by the current Concierge session. */
153
+ const live = async (userID: string): Promise<ArmedDecision | undefined> => {
154
+ const armed = await arms.current(userID)
155
+ if (!armed) return undefined
156
+ const [{ sessionID }, decision] = await Promise.all([identity.current(userID), hub.decisions.get(userID, armed.decisionID)])
157
+ return sessionID === armed.sessionID && decision?.state === "open" && decision.tier === "confirm" ? armed : undefined
158
+ }
159
+
160
+ const routes: AgentRoute[] = [
161
+ {
162
+ method: "POST",
163
+ path: `${PATH}/proposals`,
164
+ allow: { session: true, app: true },
165
+ handle: async ({ actor, request, user }) => {
166
+ const sessionID = await conciergeOnly(actor, user.id, "The Concierge proposes; act on the session yourself")
167
+ const body = await boundedJson(request)
168
+ // Keys are per Concierge session: a relaunched Concierge never gets an earlier session's result.
169
+ return idempotency.run(user.id, `POST /concierge/proposals ${sessionID}`, request.headers.get("idempotency-key"), body, async () => {
170
+ const decision = await proposals.propose(user.id, actor, parseProposal(body))
171
+ return { status: 201, body: { decision } }
172
+ })
173
+ },
174
+ },
175
+ {
176
+ method: "GET",
177
+ path: `${PATH}/sessions/:addr/forms/:form`,
178
+ allow: { session: true, app: true },
179
+ handle: async ({ actor, params, user }) => {
180
+ await conciergeOnly(actor, user.id)
181
+ return Response.json({ form: await hub.acting.form({ actor, userID: user.id }, params.addr!, params.form!) }, { headers: NO_STORE })
182
+ },
183
+ },
184
+ {
185
+ method: "GET",
186
+ path: `${PATH}/arm`,
187
+ allow: { session: true, app: true },
188
+ handle: async ({ actor, user }) => {
189
+ await conciergeOnly(actor, user.id)
190
+ return Response.json({ armed: view(await live(user.id)) }, { headers: NO_STORE })
191
+ },
192
+ },
193
+ {
194
+ method: "POST",
195
+ path: `${PATH}/arm`,
196
+ allow: { session: true, app: true },
197
+ handle: async ({ actor, request, user }) => {
198
+ const sessionID = await conciergeOnly(actor, user.id, "Only the Concierge arms a Decision")
199
+ const body = await boundedJson(request)
200
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "Body must be a JSON object")
201
+ const fields = body as Record<string, unknown>
202
+ for (const key of Object.keys(fields)) if (key !== "decisionID") throw new AgentError("invalid", `Unknown field ${key}`)
203
+ const id = fields.decisionID
204
+ if (id === null) {
205
+ unwatch(user.id)
206
+ if (await arms.disarm(user.id)) tell(user.id, null)
207
+ return Response.json({ armed: null }, { headers: NO_STORE })
208
+ }
209
+ if (typeof id !== "string" || !id || id.length > 64) throw new AgentError("invalid", "decisionID is a Decision ID (or null to disarm)")
210
+ const decision = await hub.decisions.get(user.id, id)
211
+ if (!decision) throw new AgentError("not_found", "No such decision")
212
+ if (decision.state !== "open") throw new AgentError("gone", "The decision is already answered or expired")
213
+ if (decision.tier !== "confirm") throw new AgentError("invalid", "A tap-tier decision is answered with a tap in the console: it cannot be armed")
214
+ const record = await arms.arm(user.id, { decisionID: id, sessionID })
215
+ watchLapse(user.id, record)
216
+ const armed = view(record)
217
+ tell(user.id, armed)
218
+ return Response.json({ armed }, { headers: NO_STORE })
219
+ },
220
+ },
221
+ ]
222
+
223
+ return {
224
+ routes: () => routes,
225
+ act: (userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome> => proposals.act(userID, decision, choice, by),
226
+ checkVia: async (userID: string, decision: Decision, _via: "voice" | "text", by: UserActor): Promise<void> => {
227
+ let ok = false
228
+ try {
229
+ const { sessionID } = await identity.current(userID)
230
+ const armed = await arms.current(userID)
231
+ ok =
232
+ sessionID !== undefined &&
233
+ armed !== undefined &&
234
+ armed.decisionID === decision.id &&
235
+ armed.sessionID === sessionID &&
236
+ decision.state === "open" &&
237
+ decision.tier === "confirm" &&
238
+ by.userID === userID &&
239
+ hub.deviceOwner(by.deviceID) === userID &&
240
+ hub.present(by.deviceID, sessionID)
241
+ } catch (cause) {
242
+ console.error(`[hub] concierge via check failed: ${(cause as Error).message}`)
243
+ ok = false
244
+ }
245
+ if (!ok) throw tapOnly()
246
+ },
247
+ decisionChanged: (userID: string, decision: Decision): void => {
248
+ if (decision.state === "open") return
249
+ void arms
250
+ .disarm(userID, decision.id)
251
+ .then((ended) => (ended ? (unwatch(userID), tell(userID, null)) : undefined))
252
+ .catch((cause: unknown) => console.error(`[hub] concierge disarm failed: ${(cause as Error).message}`))
253
+ },
254
+ armedID: async (userID: string) => (await live(userID))?.decisionID,
255
+ stop: () => {
256
+ for (const timer of lapses.values()) clearTimeout(timer)
257
+ lapses.clear()
258
+ },
259
+ }
260
+ }
@@ -0,0 +1,277 @@
1
+ // CON-01 watchlist routes on /api/agent (contract: docs/plans/2026-11-01-concierge-contracts.md, "Watchlist
2
+ // (CON-01)"), the per-user config record and the facts the resolution reads:
3
+ //
4
+ // GET /api/agent/concierge/watchlist user; app sessions:read; the Concierge → {config, entries, truncated}
5
+ // PUT /api/agent/concierge/watchlist {…fields, expectedRevision} user; app sessions:manage → {config}
6
+ // POST /api/agent/concierge/watchlist/{watch,unwatch,mute,unmute} {session: <addr>}
7
+ // user; app sessions:manage; the Concierge → {config, sessionID, changed}
8
+ //
9
+ // The Concierge is admitted only as the user's CURRENT Concierge session through its act token (canActAsSession and
10
+ // ConciergeIdentity.current, CON-04's state record; checked on every call, so a relaunch revokes the old session at
11
+ // once). Every other session credential gets 404 on the routes it is admitted to (the route does not reveal itself)
12
+ // and 403 on PUT (framework route class). The verbs change only `sessions` and `muted` and grant nothing.
13
+ //
14
+ // Storage: StateStore collection `concierge-watchlist`, key `config`, per user (the tenant key). A change bumps
15
+ // `revision` (a no-op does not: verbs are idempotent), appends `concierge.changed {what: "watchlist", revision}`
16
+ // (family manage, no subject, never config contents) and adds an admin audit `{actor: owner, action:
17
+ // "concierge.watchlist"}`. Addresses resolve for the caller's user only: another user's session is 404 like an
18
+ // unknown one. Facts come from the hub's own directory, catalog, Decisions, launch labels and the activity tracker;
19
+ // no spoke is called.
20
+ import type { WatchlistConfig, WatchlistView } from "../../../packages/protocol/src/concierge"
21
+ import { WATCHLIST_LIMITS } from "../../../packages/protocol/src/concierge"
22
+ import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
23
+ import { type AgentActor, canActAsSession } from "../agent/actor"
24
+ import { boundedJson } from "../agent/body"
25
+ import type { Decisions } from "../agent/decisions"
26
+ import type { Directory } from "../agent/directory"
27
+ import { AgentError } from "../agent/errors"
28
+ import type { AgentEvents, EventInput } from "../agent/events"
29
+ import type { ReadRegistry } from "../agent/read-routes"
30
+ import type { AgentContext, AgentRoute } from "../agent/routes"
31
+ import type { Clock, StateStore } from "../backend/types"
32
+ import type { CatalogMachine } from "../registry"
33
+ import type { Store } from "../store"
34
+ import type { ActivityTracker } from "./activity"
35
+ import { defaultWatchlist, parseWatchlistUpdate, resolveWatchlist, type WatchFact, type WatchFacts } from "./watchlist"
36
+
37
+ export const WATCHLIST_COLLECTION = "concierge-watchlist"
38
+ const KEY = "config"
39
+ /** Literal, not from AGENT_PREFIX: routes.ts imports this module (a module-level read would hit the import cycle). */
40
+ const PATH = "/api/agent/concierge/watchlist"
41
+ const NO_STORE = { "cache-control": "no-store" }
42
+ /** Version-checked retries of a contended write: full-jitter backoff from backoffBaseMs, doubling, capped at backoffMaxMs. */
43
+ export const WATCHLIST_STORE_LIMITS = { attempts: 16, backoffBaseMs: 4, backoffMaxMs: 100 } as const
44
+
45
+ const backoff = (attempt: number) => {
46
+ const cap = Math.min(WATCHLIST_STORE_LIMITS.backoffMaxMs, WATCHLIST_STORE_LIMITS.backoffBaseMs * 2 ** attempt)
47
+ return new Promise<void>((resolve) => setTimeout(resolve, Math.random() * cap))
48
+ }
49
+
50
+ /** The user's Concierge sessions (CON-04's state record); absent until CON-04 lands: no Concierge. */
51
+ export interface ConciergeIdentity {
52
+ current(userID: string): Promise<{ readonly sessionID?: string; readonly hiddenSessionIDs: readonly string[] }>
53
+ }
54
+
55
+ export const NO_CONCIERGE: ConciergeIdentity = { current: async () => ({ hiddenSessionIDs: [] }) }
56
+
57
+ export interface WatchlistRouteDeps {
58
+ readonly state: StateStore
59
+ readonly activity: Pick<ActivityTracker, "list">
60
+ readonly directory: Pick<Directory, "resolveOne" | "entries">
61
+ readonly store: Pick<Store, "agentData" | "viewPrefs" | "audit">
62
+ readonly registry: Pick<ReadRegistry, "catalog" | "sessionTenant">
63
+ readonly decisions?: Pick<Decisions, "list">
64
+ /** Launched sessions' labels (the latest launch wins). */
65
+ readonly labels?: (userID: string) => Promise<ReadonlyMap<string, Readonly<Record<string, string>>>>
66
+ readonly identity?: ConciergeIdentity
67
+ readonly events?: Pick<AgentEvents, "append">
68
+ readonly now?: Clock
69
+ }
70
+
71
+ // ---- Config record ---------------------------------------------------------------------------------------
72
+
73
+ const sameConfig = (a: WatchlistConfig, b: WatchlistConfig) => JSON.stringify({ ...a, revision: 0 }) === JSON.stringify({ ...b, revision: 0 })
74
+
75
+ /** The user's watchlist config record: version-checked writes, revision bumped only by a real change. */
76
+ export class WatchlistStore {
77
+ constructor(private readonly state: StateStore) {}
78
+
79
+ async get(userID: string): Promise<WatchlistConfig> {
80
+ return (await this.state.get<WatchlistConfig>(userID, WATCHLIST_COLLECTION, KEY))?.value ?? defaultWatchlist()
81
+ }
82
+
83
+ /**
84
+ * Applies `change` to the current config (re-read on every attempt; a lost race backs off with jitter, at most
85
+ * WATCHLIST_STORE_LIMITS.attempts tries) and stores the result (`change` may throw an AgentError, e.g. a stale revision)
86
+ * with the next revision when it differs. Resolves with the stored config and whether it changed.
87
+ */
88
+ async update(userID: string, change: (current: WatchlistConfig) => WatchlistConfig): Promise<{ config: WatchlistConfig; changed: boolean }> {
89
+ for (let attempt = 0; attempt < WATCHLIST_STORE_LIMITS.attempts; attempt++) {
90
+ if (attempt > 0) await backoff(attempt - 1)
91
+ const stored = await this.state.get<WatchlistConfig>(userID, WATCHLIST_COLLECTION, KEY)
92
+ const current = stored?.value ?? defaultWatchlist()
93
+ const next = change(current)
94
+ if (sameConfig(current, next)) return { config: current, changed: false }
95
+ const config = { ...next, revision: current.revision + 1 }
96
+ const put = await this.state.put(userID, WATCHLIST_COLLECTION, KEY, config, { ifVersion: stored?.version ?? 0 })
97
+ if (put.ok) return { config, changed: true }
98
+ }
99
+ throw new AgentError("conflict", "The watchlist kept changing; retry", { hint: "Retry shortly" })
100
+ }
101
+ }
102
+
103
+ // ---- Facts -----------------------------------------------------------------------------------------------
104
+
105
+ const STATE_RANK: Record<string, number> = { error: 4, blocked: 3, working: 2, idle: 1 }
106
+ const worse = (a: string | undefined, b: string | undefined) => ((STATE_RANK[b ?? ""] ?? 0) > (STATE_RANK[a ?? ""] ?? 0) ? b : a)
107
+
108
+ /** What watchFacts reads (CON-03's board passes the same, with one catalog and one Decisions list per render). */
109
+ export type WatchFactDeps = Pick<WatchlistRouteDeps, "activity" | "registry" | "decisions" | "labels" | "now"> & { readonly store: Pick<Store, "agentData" | "viewPrefs"> }
110
+
111
+ /**
112
+ * The resolution's facts for one user: directory records, this hub's catalog (read here unless the caller already
113
+ * did), open Decisions, labels, activity.
114
+ */
115
+ export async function watchFacts(deps: WatchFactDeps, userID: string, hidden: readonly string[], catalog: readonly CatalogMachine[] = deps.registry.catalog()): Promise<WatchFacts> {
116
+ const facts = new Map<string, { -readonly [K in keyof WatchFact]: WatchFact[K] }>()
117
+ const add = (fact: WatchFact) => {
118
+ const known = facts.get(fact.sessionID)
119
+ if (!known) return void facts.set(fact.sessionID, { ...fact })
120
+ known.state = worse(known.state, fact.state)
121
+ known.blockers = Math.max(known.blockers, fact.blockers)
122
+ known.directory ??= fact.directory
123
+ known.project ??= fact.project
124
+ known.herdrWorkspace ??= fact.herdrWorkspace
125
+ if (fact.archived) known.archived = true
126
+ }
127
+ for (const machine of catalog) {
128
+ for (const process of machine.processes) {
129
+ for (const s of process.sessions) {
130
+ if (deps.registry.sessionTenant(s.id) !== userID) continue
131
+ const herdr = s.open && s.terminal?.source === "herdr" && s.terminal.workspaceID ? { herdrWorkspace: s.terminal.workspaceID } : {}
132
+ const where = { ...(s.directory ? { directory: s.directory } : {}), ...(s.project ? { project: s.project } : {}) }
133
+ add({ sessionID: s.id, ...(s.parentID ? { parentID: s.parentID } : {}), state: s.state, blockers: s.blockers.length, ...where, ...herdr, ...(s.archived ? { archived: true } : {}) })
134
+ for (const child of s.status) {
135
+ if (child.id === s.id) continue
136
+ add({ sessionID: child.id, parentID: child.parentID ?? s.id, state: child.state, blockers: child.blockers.length, ...where })
137
+ }
138
+ }
139
+ }
140
+ }
141
+ const prefs = deps.store.viewPrefs(userID)
142
+ const archived = new Set(prefs.hiddenSessions)
143
+ // (CON-12) Machines that report their sessions now: a session one of them no longer reports went away.
144
+ const reporting = new Set(catalog.filter((m) => m.processes.length > 0).map((m) => m.id))
145
+ for (const record of deps.store.agentData(userID).sessions ?? []) {
146
+ const known = facts.get(record.sessionID)
147
+ if (!known) {
148
+ const gone = record.machineID !== undefined && reporting.has(record.machineID)
149
+ add({ sessionID: record.sessionID, ...(record.parentID ? { parentID: record.parentID } : {}), state: "offline", blockers: 0, ...(gone ? { gone: true } : {}) })
150
+ }
151
+ const fact = facts.get(record.sessionID)!
152
+ if (typeof record.project === "string" && record.project) fact.projectSlug = record.project
153
+ if (record.archivedAt !== undefined) fact.archived = true
154
+ if (record.deletedAt !== undefined) fact.deleted = true
155
+ }
156
+ for (const fact of facts.values()) if (archived.has(fact.sessionID)) fact.archived = true
157
+
158
+ const [activity, labels, open] = await Promise.all([deps.activity.list(userID), deps.labels ? deps.labels(userID) : new Map(), deps.decisions ? deps.decisions.list(userID, { state: "open" }) : []])
159
+ for (const [sessionID, at] of activity) {
160
+ const fact = facts.get(sessionID)
161
+ if (fact) fact.lastActiveAt = at
162
+ }
163
+ for (const [sessionID, set] of labels) {
164
+ const fact = facts.get(sessionID)
165
+ if (fact) fact.labels = set
166
+ }
167
+ const decisions = new Set<string>()
168
+ for (const d of open) {
169
+ for (const id of d.about) decisions.add(id)
170
+ if (d.from.kind === "session") decisions.add(d.from.sessionID)
171
+ }
172
+ return { sessions: [...facts.values()], pins: { sessions: prefs.pinnedSessions, workspaces: prefs.pinnedWorkspaces }, decisions: [...decisions], hidden, now: (deps.now ?? Date.now)() }
173
+ }
174
+
175
+ // ---- Routes ----------------------------------------------------------------------------------------------
176
+
177
+ type ListName = "sessions" | "muted"
178
+ const VERBS: readonly { readonly verb: string; readonly list: ListName; readonly add: boolean }[] = [
179
+ { verb: "watch", list: "sessions", add: true },
180
+ { verb: "unwatch", list: "sessions", add: false },
181
+ { verb: "mute", list: "muted", add: true },
182
+ { verb: "unmute", list: "muted", add: false },
183
+ ]
184
+ const CAP: Record<ListName, number> = { sessions: WATCHLIST_LIMITS.sessions, muted: WATCHLIST_LIMITS.muted }
185
+
186
+ const notFound = () => new AgentError("not_found", "Not found")
187
+
188
+ export function watchlistRoutes(deps: WatchlistRouteDeps): AgentRoute[] {
189
+ const records = new WatchlistStore(deps.state)
190
+ const identity = deps.identity ?? NO_CONCIERGE
191
+
192
+ /** Session callers: only the user's current Concierge, through its act token; anyone else is 404. */
193
+ const admit = async (actor: AgentActor, userID: string) => {
194
+ const current = await identity.current(userID)
195
+ if (actor.kind === "session" && !(canActAsSession(actor) && current.sessionID !== undefined && current.sessionID === actor.sessionID)) throw notFound()
196
+ return current
197
+ }
198
+
199
+ const changed = async (userID: string, config: WatchlistConfig) => {
200
+ const event: EventInput = { type: "concierge.changed", family: "manage", subject: {}, data: { what: "watchlist", revision: config.revision } }
201
+ await deps.events?.append(userID, event).catch((cause) => console.error(`[hub] concierge watchlist event: ${(cause as Error).message}`))
202
+ await deps.store.audit({ actor: userID, action: "concierge.watchlist" })
203
+ }
204
+
205
+ /** The root of a resolved session (subagents roll up into their root). */
206
+ const rootOf = (userID: string, sessionID: string, parentID: string | undefined): string => {
207
+ const seen = new Set([sessionID])
208
+ let id = sessionID
209
+ let parent = parentID
210
+ while (parent !== undefined && !seen.has(parent)) {
211
+ const entry = deps.directory.entries(userID, [parent]).get(parent)
212
+ if (!entry) break
213
+ seen.add(parent)
214
+ id = entry.sessionID
215
+ parent = entry.parentID
216
+ }
217
+ return id
218
+ }
219
+
220
+ const verbRoute = ({ verb, list, add }: (typeof VERBS)[number]): AgentRoute => ({
221
+ method: "POST",
222
+ path: `${PATH}/${verb}`,
223
+ allow: { session: true, scope: "sessions:manage" },
224
+ async handle({ request, actor, user }: AgentContext) {
225
+ await admit(actor, user.id)
226
+ const body = await boundedJson(request)
227
+ if (typeof body !== "object" || body === null || Array.isArray(body)) throw new AgentError("invalid", "The body is {session: <addr>}")
228
+ const extra = Object.keys(body).find((k) => k !== "session")
229
+ if (extra !== undefined) throw new AgentError("invalid", `Unknown field ${extra.slice(0, 40)}`)
230
+ const addr = (body as { session?: unknown }).session
231
+ if (typeof addr !== "string" || !addr.trim()) throw new AgentError("invalid", "session (an address: ses_…, @name, @@role) is required")
232
+ let sessionID: string
233
+ // A listed ID can be removed even when its session is gone from the directory.
234
+ if (!add && isHubSessionID(addr) && (await records.get(user.id))[list].includes(addr)) sessionID = addr
235
+ else {
236
+ const entry = deps.directory.resolveOne(user.id, addr)
237
+ sessionID = rootOf(user.id, entry.sessionID, entry.parentID)
238
+ }
239
+ const result = await records.update(user.id, (current) => {
240
+ const has = current[list].includes(sessionID)
241
+ if (add === has) return current
242
+ if (add && current[list].length >= CAP[list]) throw new AgentError("rate_limited", `The watchlist's ${list} list has at most ${CAP[list]} sessions`, { extra: { limit: CAP[list] } })
243
+ return { ...current, [list]: add ? [...current[list], sessionID] : current[list].filter((id) => id !== sessionID) }
244
+ })
245
+ if (result.changed) await changed(user.id, result.config)
246
+ return Response.json({ config: result.config, sessionID, changed: result.changed }, { headers: NO_STORE })
247
+ },
248
+ })
249
+
250
+ return [
251
+ {
252
+ method: "GET",
253
+ path: PATH,
254
+ allow: { session: true, scope: "sessions:read" },
255
+ async handle({ actor, user }) {
256
+ const current = await admit(actor, user.id)
257
+ const config = await records.get(user.id)
258
+ const facts = await watchFacts(deps, user.id, current.hiddenSessionIDs)
259
+ const { entries, truncated } = resolveWatchlist(config, facts)
260
+ const view: WatchlistView = { config, entries, truncated }
261
+ return Response.json(view, { headers: NO_STORE })
262
+ },
263
+ },
264
+ {
265
+ method: "PUT",
266
+ path: PATH,
267
+ allow: { scope: "sessions:manage" },
268
+ async handle({ request, user }) {
269
+ const body = await boundedJson(request)
270
+ const result = await records.update(user.id, (current) => parseWatchlistUpdate(body, current))
271
+ if (result.changed) await changed(user.id, result.config)
272
+ return Response.json({ config: result.config }, { headers: NO_STORE })
273
+ },
274
+ },
275
+ ...VERBS.map(verbRoute),
276
+ ]
277
+ }