@huynle/hub 0.7.4 → 0.8.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 (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  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/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,113 @@
1
+ // CON-05 schedule (contract: "Event-driven turns, digests, quiet hours (CON-05)"): wall-clock time in IANA zones for
2
+ // the Concierge's quiet hours (their own `timeZone`) and digests (`settings.timeZone`). Pure; Intl only.
3
+ //
4
+ // - An invalid or unknown zone (settings validate zones, but the runtime's zone data can change) is UTC.
5
+ // - A wall time in a spring-forward gap (02:30 on the night clocks jump 02:00 → 03:00) moves forward by the gap
6
+ // (03:30), as cron and Temporal's "compatible" do; one in a fall-back overlap is its first occurrence.
7
+ // - A digest is due from its minute for DIGEST_GRACE_MS (the leader ticks every 30 s; a failover or an enable after
8
+ // the minute does not send it late), keyed `digest:<local date>:<entry index>`: the notifier's ledger sends each
9
+ // key once, so a fall-back day that repeats the minute still sends it once.
10
+ import type { ConciergeDigest, ConciergeQuietHours } from "../../../packages/protocol/src/concierge"
11
+
12
+ const MIN = 60_000
13
+ const DAY = 86_400_000
14
+ /** How long after its minute a digest may still go out. */
15
+ export const DIGEST_GRACE_MS = 60 * MIN
16
+
17
+ export interface LocalTime {
18
+ /** YYYY-MM-DD in the zone. */
19
+ readonly date: string
20
+ /** Minutes since local midnight (0–1439). */
21
+ readonly minutes: number
22
+ /** 0 = Sunday … 6 = Saturday. */
23
+ readonly weekday: number
24
+ }
25
+
26
+ export interface DueDigest {
27
+ readonly key: string
28
+ readonly index: number
29
+ /** The entry's HH:MM. */
30
+ readonly label: string
31
+ readonly occurredAt: number
32
+ }
33
+
34
+ const formats = new Map<string, Intl.DateTimeFormat>()
35
+ const FORMATS_MAX = 200
36
+
37
+ function format(zone: string): Intl.DateTimeFormat | undefined {
38
+ let f = formats.get(zone)
39
+ if (f) return f
40
+ try {
41
+ f = new Intl.DateTimeFormat("en-US", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })
42
+ } catch {
43
+ return undefined
44
+ }
45
+ if (formats.size >= FORMATS_MAX) formats.delete(formats.keys().next().value as string)
46
+ formats.set(zone, f)
47
+ return f
48
+ }
49
+
50
+ /** The zone, or UTC when it is not a zone this runtime knows. */
51
+ export function zoneOrUTC(zone: string): string {
52
+ return typeof zone === "string" && zone.length > 0 && zone.length <= 64 && format(zone) ? zone : "UTC"
53
+ }
54
+
55
+ /** The wall-clock fields at `at` in `zone`, as a UTC timestamp of the same fields (minute resolution). */
56
+ function wall(at: number, zone: string): number {
57
+ const parts: Record<string, number> = {}
58
+ for (const p of format(zone)!.formatToParts(at)) if (p.type !== "literal") parts[p.type] = Number(p.value)
59
+ return Date.UTC(parts.year!, parts.month! - 1, parts.day!, parts.hour! % 24, parts.minute!)
60
+ }
61
+
62
+ const pad = (n: number) => String(n).padStart(2, "0")
63
+ const dateOf = (utcFields: number) => {
64
+ const d = new Date(utcFields)
65
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
66
+ }
67
+
68
+ export function localTime(at: number, zone: string): LocalTime {
69
+ const fields = wall(at, zoneOrUTC(zone))
70
+ const d = new Date(fields)
71
+ return { date: dateOf(fields), minutes: d.getUTCHours() * 60 + d.getUTCMinutes(), weekday: d.getUTCDay() }
72
+ }
73
+
74
+ const clockMinutes = (hhmm: string) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3, 5))
75
+
76
+ /** The instant of wall time `minutes` on local `date` (YYYY-MM-DD) in `zone` (gaps forward, overlaps first). */
77
+ export function zonedInstant(date: string, minutes: number, zone: string): number {
78
+ const z = zoneOrUTC(zone)
79
+ const [y, m, d] = date.split("-").map(Number) as [number, number, number]
80
+ const target = Date.UTC(y, m - 1, d, 0, minutes)
81
+ const offset = (t: number) => wall(t, z) - Math.floor(t / MIN) * MIN
82
+ // The offsets a day either side: at most one transition lies between them.
83
+ const candidates = [...new Set([target - offset(target - DAY), target - offset(target + DAY)])]
84
+ const valid = candidates.filter((t) => wall(t, z) === target)
85
+ return valid.length ? Math.min(...valid) : Math.max(...candidates)
86
+ }
87
+
88
+ /** The digests due at `at` (from their minute, for `graceMs`), oldest local date first per entry. */
89
+ export function dueDigests(digests: readonly ConciergeDigest[], zone: string, at: number, graceMs: number = DIGEST_GRACE_MS): DueDigest[] {
90
+ const z = zoneOrUTC(zone)
91
+ const dates = [...new Set([localTime(at - graceMs, z).date, localTime(at, z).date])]
92
+ const out: DueDigest[] = []
93
+ digests.forEach((digest, index) => {
94
+ for (const date of dates) {
95
+ const [y, m, d] = date.split("-").map(Number) as [number, number, number]
96
+ const weekday = new Date(Date.UTC(y, m - 1, d)).getUTCDay() as 0 | 1 | 2 | 3 | 4 | 5 | 6
97
+ if (digest.days && !digest.days.includes(weekday)) continue
98
+ const occurredAt = zonedInstant(date, clockMinutes(digest.at), z)
99
+ if (occurredAt <= at && at < occurredAt + graceMs) out.push({ key: `digest:${date}:${index}`, index, label: digest.at, occurredAt })
100
+ }
101
+ })
102
+ return out
103
+ }
104
+
105
+ /** Whether `at` falls in the quiet hours (start inclusive, end exclusive; across midnight when start > end). */
106
+ export function inQuietHours(quiet: ConciergeQuietHours | undefined, at: number): boolean {
107
+ if (!quiet) return false
108
+ const start = clockMinutes(quiet.start)
109
+ const end = clockMinutes(quiet.end)
110
+ if (start === end) return false
111
+ const { minutes } = localTime(at, quiet.timeZone)
112
+ return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end
113
+ }
@@ -0,0 +1,243 @@
1
+ // CON-04 the Concierge service (contract "Concierge service: settings, launch, supervise, hide, context"): what the
2
+ // Settings routes do, on top of the settings and notes records, the supervisor (state record, launch, relaunch,
3
+ // disable) and the grant. Users only (routes.ts); every operation is keyed by the user (the tenant).
4
+ //
5
+ // - Machine eligibility (eligibilityOf, pure): one of the user's machines with a hub node (host runtime with OpenCode,
6
+ // workspace roots, not draining) and a connected OpenCode service spoke announcing `launch` and `sessionContext`.
7
+ // Each "no" has a reason Settings shows. An offline machine is not eligible now but may be enabled: the state is
8
+ // waiting_machine until it is back.
9
+ // - Enable: entitlements.check (403 with its reason), machineID and directory required (400), the machine must be
10
+ // the user's (404 otherwise, like anything of another user), an online machine must qualify and the directory be
11
+ // under its node's workspace roots (422 unsupported {feature: "conciergeMachine", reason}). Then the grant (when
12
+ // settings.grant: Grants.create/update, holder @@concierge), the settings (enabled, version-checked), and the
13
+ // supervisor starts it. A grant created for a refused enable is revoked again.
14
+ // - A settings change that picks a machine that is not the user's (unknown or another user's, alike): 404 (CON-09b).
15
+ // - A settings change while enabled: a new or removed grant applies at once (update restarts its expiry: Renew);
16
+ // another machine, directory or model restarts the Concierge.
17
+ // - Disable: settings first (enabled false, version-checked), then the supervisor's idempotent steps.
18
+ import type { ConciergeSettings, ConciergeStatus } from "../../../packages/protocol/src/concierge"
19
+ import type { NodeRuntimeInfo } from "../../protocol"
20
+ import { AgentError } from "../agent/errors"
21
+ import type { Grants } from "../agent/grants"
22
+ import type { ConciergeEntitlements } from "./entitlements"
23
+ import type { ConciergeNotes, NotesStore } from "./notes"
24
+ import { grantInput, parseSettingsPatch, type SettingsStore, validTimeZone } from "./settings"
25
+ import type { Eligibility, Supervisor } from "./supervisor"
26
+
27
+ /** What the hub knows about one of the user's machines (http.ts gathers it from the store, nodes and registry). */
28
+ export interface MachineFacts {
29
+ readonly id: string
30
+ readonly name: string
31
+ /** A process of the machine is connected (spoke or node). */
32
+ readonly online: boolean
33
+ readonly node?: { readonly runtimes: readonly NodeRuntimeInfo[]; readonly workspaceRoots: readonly string[]; readonly draining: boolean }
34
+ /** The machine's OpenCode service spoke and what it announces. */
35
+ readonly service?: { readonly launch: boolean; readonly sessionContext: boolean }
36
+ }
37
+
38
+ export interface MachineView extends Eligibility {
39
+ readonly id: string
40
+ readonly name: string
41
+ /** The node's workspace roots (the directory picker's start points). */
42
+ readonly roots: readonly string[]
43
+ }
44
+
45
+ /** Whether a machine can host the Concierge now, and why not. */
46
+ export function eligibilityOf(facts: Omit<MachineFacts, "id" | "name">): Eligibility {
47
+ const no = (reason: string): Eligibility => ({ eligible: false, online: facts.online, reason })
48
+ if (!facts.node && !facts.online) return no("Offline")
49
+ if (!facts.node) return no("No hub node runs on it: start `hub node` there with a workspace root")
50
+ if (facts.node.draining) return no("Its hub node is draining")
51
+ if (!facts.node.runtimes.some((r) => r.kind === "host" && r.harnesses?.includes("opencode"))) return no("Its hub node has no host runtime with OpenCode")
52
+ if (!facts.node.workspaceRoots.length) return no("Its hub node has no workspace roots")
53
+ if (!facts.service) return no("No OpenCode service runs on it (the hub plugin's background service)")
54
+ if (!facts.service.launch) return no("Its OpenCode plugin cannot launch sessions: update the hub plugin")
55
+ if (!facts.service.sessionContext) return no("Its OpenCode plugin is too old for the Concierge: update the hub plugin")
56
+ return { eligible: true, online: true }
57
+ }
58
+
59
+ const underRoot = (dir: string, root: string) => {
60
+ const base = root.replace(/\/+$/, "")
61
+ return dir === base || dir.startsWith(`${base}/`)
62
+ }
63
+
64
+ export interface ConciergeView {
65
+ readonly settings: ConciergeSettings
66
+ readonly status: ConciergeStatus
67
+ readonly machines: readonly MachineView[]
68
+ }
69
+
70
+ export interface ServiceDeps {
71
+ readonly settings: SettingsStore
72
+ readonly notes: NotesStore
73
+ readonly supervisor: Supervisor
74
+ readonly grants: Pick<Grants, "create" | "update" | "revoke">
75
+ /** The user's machines (not revoked) with their facts. */
76
+ readonly machines: (userID: string) => readonly MachineFacts[]
77
+ readonly entitlements: ConciergeEntitlements
78
+ /** The admin audit: {actor: owner, action: concierge.*} only. */
79
+ readonly audit: (userID: string, action: string) => Promise<void>
80
+ /**
81
+ * Makes buffered state writes durable (StateStore.flush; a write-through store has none). Awaited before every
82
+ * security-relevant answer (settings, enable, disable, restart): a 200 is never lost to a crash.
83
+ */
84
+ readonly flush?: () => Promise<void>
85
+ }
86
+
87
+ const ignorable = (cause: unknown) => cause instanceof AgentError && (cause.code === "gone" || cause.code === "not_found")
88
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
89
+
90
+ function revisionOf(body: unknown, allowed: readonly string[] = []): number {
91
+ if (!isObject(body)) throw new AgentError("invalid", "The body is {expectedRevision}")
92
+ for (const key of Object.keys(body)) if (key !== "expectedRevision" && !allowed.includes(key)) throw new AgentError("invalid", `Unknown field ${key.slice(0, 40)}`)
93
+ if (!Number.isSafeInteger(body.expectedRevision)) throw new AgentError("invalid", "expectedRevision is required")
94
+ return body.expectedRevision as number
95
+ }
96
+
97
+ const checkRevision = (settings: ConciergeSettings, expected: number) => {
98
+ if (settings.revision !== expected) throw new AgentError("conflict", "The Concierge settings changed meanwhile", { extra: { currentRevision: settings.revision } })
99
+ }
100
+
101
+ export class ConciergeService {
102
+ constructor(private readonly deps: ServiceDeps) {}
103
+
104
+ get supervisor(): Supervisor {
105
+ return this.deps.supervisor
106
+ }
107
+
108
+ /** The user's machines with their eligibility. */
109
+ machineViews(userID: string): MachineView[] {
110
+ return this.deps.machines(userID).map((m) => ({ id: m.id, name: m.name, ...eligibilityOf(m), roots: m.node ? [...m.node.workspaceRoots] : [] }))
111
+ }
112
+
113
+ /** The supervisor's eligibility port. */
114
+ eligibility(userID: string, machineID: string): Eligibility {
115
+ const facts = this.deps.machines(userID).find((m) => m.id === machineID)
116
+ return facts ? eligibilityOf(facts) : { eligible: false, online: false, reason: "Not one of your machines" }
117
+ }
118
+
119
+ async view(userID: string): Promise<ConciergeView> {
120
+ const [settings, status] = await Promise.all([this.deps.settings.get(userID), this.deps.supervisor.status(userID)])
121
+ return { settings, status, machines: this.machineViews(userID) }
122
+ }
123
+
124
+ /** Refuses a machine or directory that cannot host the Concierge (an offline machine is fine). */
125
+ private checkPlacement(userID: string, settings: ConciergeSettings): void {
126
+ if (settings.machineID === undefined || settings.directory === undefined) throw new AgentError("invalid", "Pick a machine and a directory (a git repo on it) first")
127
+ const facts = this.deps.machines(userID).find((m) => m.id === settings.machineID)
128
+ if (!facts) throw new AgentError("not_found", "No such machine")
129
+ const unsupported = (reason: string) => new AgentError("unsupported", `This machine cannot host the Concierge: ${reason}`, { extra: { feature: "conciergeMachine", reason } })
130
+ const verdict = eligibilityOf(facts)
131
+ if (!verdict.eligible && verdict.online) throw unsupported(verdict.reason ?? "not eligible")
132
+ if (facts.node && !facts.node.workspaceRoots.some((root) => underRoot(settings.directory!, root))) throw unsupported("The directory is not under the machine's workspace roots")
133
+ }
134
+
135
+ /** Creates, renews or revokes the @@concierge grant for the settings; the grant ID (undefined: none). */
136
+ private async applyGrant(userID: string, settings: ConciergeSettings): Promise<string | undefined> {
137
+ if (!settings.grant) {
138
+ if (settings.grantID !== undefined) await this.deps.grants.revoke(userID, settings.grantID).catch((cause) => (ignorable(cause) ? undefined : Promise.reject(cause)))
139
+ return undefined
140
+ }
141
+ if (settings.grantID !== undefined) {
142
+ try {
143
+ await this.deps.grants.update(userID, settings.grantID, grantInput(settings.grant), { managed: true })
144
+ return settings.grantID
145
+ } catch (cause) {
146
+ if (!ignorable(cause)) throw cause
147
+ }
148
+ }
149
+ return (await this.deps.grants.create(userID, grantInput(settings.grant), { managed: true })).id
150
+ }
151
+
152
+ private async withGrantID(userID: string, expected: number, grantID: string | undefined, change: (s: ConciergeSettings) => ConciergeSettings): Promise<ConciergeSettings> {
153
+ return this.deps.settings.update(userID, (current) => {
154
+ checkRevision(current, expected)
155
+ const next = change(current)
156
+ const { grantID: _old, ...rest } = next
157
+ return grantID !== undefined ? { ...rest, grantID } : rest
158
+ })
159
+ }
160
+
161
+ async updateSettings(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
162
+ const current = await this.deps.settings.get(userID)
163
+ const next = parseSettingsPatch(body, current)
164
+ // CON-09b: a machine that is not the user's is refused when it is saved, not only at enable. An unknown ID and
165
+ // another user's answer alike (no oracle). A machine saved earlier and removed since is only checked at enable.
166
+ if (next.machineID !== undefined && next.machineID !== current.machineID && !this.deps.machines(userID).some((m) => m.id === next.machineID)) {
167
+ throw new AgentError("not_found", "That machine is not one of yours: pick one of your machines")
168
+ }
169
+ const placementChanged = next.machineID !== current.machineID || next.directory !== current.directory || next.model !== current.model
170
+ if (current.enabled && placementChanged) this.checkPlacement(userID, next)
171
+ const grantTouched = isObject(body) && "grant" in body
172
+ const grantID = current.enabled && grantTouched ? await this.applyGrant(userID, next) : current.grantID
173
+ const settings = await this.withGrantID(userID, current.revision, grantID, () => next)
174
+ await this.deps.audit(userID, "concierge.settings")
175
+ const status = current.enabled && placementChanged ? await this.deps.supervisor.restart(userID) : await this.deps.supervisor.status(userID)
176
+ await this.durable()
177
+ return { settings, status }
178
+ }
179
+
180
+ async enable(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
181
+ const expected = revisionOf(body, ["timeZone"])
182
+ const timeZone = (body as { timeZone?: unknown }).timeZone
183
+ if (timeZone !== undefined && !validTimeZone(timeZone)) throw new AgentError("invalid", "timeZone is an IANA time zone")
184
+ const entitled = await this.deps.entitlements.check(userID)
185
+ if (!entitled.ok) throw new AgentError("forbidden", entitled.reason)
186
+ const current = await this.deps.settings.get(userID)
187
+ checkRevision(current, expected)
188
+ this.checkPlacement(userID, current)
189
+ const grantID = await this.applyGrant(userID, current)
190
+ let settings: ConciergeSettings
191
+ try {
192
+ settings = await this.withGrantID(userID, expected, grantID, (s) => ({ ...s, enabled: true, ...(typeof timeZone === "string" && s.updatedAt === undefined ? { timeZone } : {}) }))
193
+ } catch (cause) {
194
+ if (grantID !== undefined && grantID !== current.grantID) await this.deps.grants.revoke(userID, grantID).catch(() => undefined)
195
+ throw cause
196
+ }
197
+ await this.deps.audit(userID, "concierge.enable")
198
+ const status = await this.deps.supervisor.enable(userID)
199
+ await this.durable()
200
+ return { settings, status }
201
+ }
202
+
203
+ async disable(userID: string, body: unknown): Promise<{ settings: ConciergeSettings; status: ConciergeStatus }> {
204
+ const expected = revisionOf(body)
205
+ const current = await this.deps.settings.get(userID)
206
+ checkRevision(current, expected)
207
+ if (current.enabled) await this.withGrantID(userID, expected, current.grantID, (s) => ({ ...s, enabled: false }))
208
+ const status = await this.deps.supervisor.disable(userID)
209
+ await this.durable()
210
+ return { settings: await this.deps.settings.get(userID), status }
211
+ }
212
+
213
+ async restart(userID: string): Promise<{ status: ConciergeStatus }> {
214
+ const settings = await this.deps.settings.get(userID)
215
+ if (!settings.enabled) throw new AgentError("conflict", "The Concierge is disabled: enable it first")
216
+ const entitled = await this.deps.entitlements.check(userID)
217
+ if (!entitled.ok) throw new AgentError("forbidden", entitled.reason)
218
+ // A suspended Concierge lost its grant: the user's Restart grants it again (the standing setting).
219
+ if (settings.grant && settings.grantID === undefined) {
220
+ const grantID = await this.applyGrant(userID, settings)
221
+ if (grantID !== undefined) await this.deps.settings.update(userID, (s) => ({ ...s, grantID }))
222
+ }
223
+ const status = await this.deps.supervisor.restart(userID)
224
+ await this.durable()
225
+ return { status }
226
+ }
227
+
228
+ /** Flushes the state store (see ServiceDeps.flush). */
229
+ private async durable(): Promise<void> {
230
+ await this.deps.flush?.()
231
+ }
232
+
233
+ notes(userID: string): Promise<ConciergeNotes> {
234
+ return this.deps.notes.get(userID)
235
+ }
236
+
237
+ async setNotes(userID: string, body: unknown): Promise<ConciergeNotes> {
238
+ const expected = revisionOf(body, ["text"])
239
+ const text = (body as { text?: unknown }).text
240
+ if (typeof text !== "string") throw new AgentError("invalid", "text is required (a string, at most 32 KiB)")
241
+ return this.deps.notes.put(userID, text, expected)
242
+ }
243
+ }
@@ -0,0 +1,125 @@
1
+ // CON-04 the routes a Concierge session may call (round-3 verify): its session tokens (act AND shell, the current
2
+ // session and every previous one) reach ONLY the routes listed here; every other /api/agent route answers 403
3
+ // forbidden {policy: "concierge"} before its handler runs. The check is central (agentApi's gate), so a route added
4
+ // later is refused until it is listed here (fail closed).
5
+ //
6
+ // What the list keeps: reads (who, status, sessions, its inbox and messages), messages to @me and replies/declines
7
+ // to asks addressed to it (the outbound rule narrows msg/ask/reply/decline further, inbound.ts), the grant-gated act
8
+ // verbs, its own notes, its board and Decision reads, and the watchlist verbs. Nothing that puts its text in front
9
+ // of another session (intake, artifacts, workspaces, side chats, profiles, names, intents, webhooks, teams…).
10
+ // CON-06's own routes are added through the tiers slot (ConciergeTiers.routes), which are Concierge routes by design.
11
+ import type { AgentActor } from "../agent/actor"
12
+ import { AgentError } from "../agent/errors"
13
+ import type { AgentRoute } from "../agent/routes"
14
+ import type { ConciergeIdentity } from "./watchlist-routes"
15
+
16
+ const A = "/api/agent"
17
+ const C = `${A}/concierge`
18
+
19
+ export const CONCIERGE_SESSION_ROUTES: readonly { readonly method: string; readonly path: string }[] = [
20
+ { method: "GET", path: `${A}/whoami` },
21
+ { method: "GET", path: `${A}/who` },
22
+ { method: "GET", path: `${A}/status` },
23
+ { method: "GET", path: `${A}/sessions` },
24
+ { method: "GET", path: `${A}/sessions/:addr` },
25
+ { method: "POST", path: `${A}/msg` },
26
+ { method: "POST", path: `${A}/ask` },
27
+ { method: "POST", path: `${A}/reply` },
28
+ { method: "POST", path: `${A}/decline` },
29
+ { method: "GET", path: `${A}/inbox` },
30
+ { method: "GET", path: `${A}/messages/:id` },
31
+ ...["approve", "deny", "answer", "prompt", "stop"].map((verb) => ({ method: "POST", path: `${A}/sessions/:addr/${verb}` })),
32
+ { method: "GET", path: `${C}/notes` },
33
+ { method: "PUT", path: `${C}/notes` },
34
+ { method: "GET", path: `${C}/board` },
35
+ { method: "GET", path: `${C}/decisions` },
36
+ { method: "GET", path: `${C}/decisions/:id` },
37
+ { method: "GET", path: `${C}/watchlist` },
38
+ ...["watch", "unwatch", "mute", "unmute"].map((verb) => ({ method: "POST", path: `${C}/watchlist/${verb}` })),
39
+ // CON-06 (tiers.ts): proposals and arming. Proposals put no Concierge text in front of another session: free text
40
+ // (prompt, form answers) runs only after the user's tap.
41
+ { method: "POST", path: `${C}/proposals` },
42
+ { method: "GET", path: `${C}/arm` },
43
+ { method: "POST", path: `${C}/arm` },
44
+ // CON-13 (tiers.ts): a pending form's schema (fields, options), so its answers fit the form.
45
+ { method: "GET", path: `${C}/sessions/:addr/forms/:form` },
46
+ ]
47
+
48
+ const key = (method: string, path: string) => `${method} ${path}`
49
+
50
+ /**
51
+ * (FX-07) How long a Concierge identity check waits for the state store (the route gate, the acting guard, the inbound
52
+ * and outbound rules, team staffing): past it the check fails closed with `busy` (503).
53
+ */
54
+ export const IDENTITY_TIMEOUT_MS = 5_000
55
+
56
+ /** (FX-07) The refusal while the hub's state does not answer: neutral (no Concierge wording), retried by clients. */
57
+ export const stateBusy = () => new AgentError("busy", "Hub state is busy, try again", { extra: { retryAfter: Math.ceil(IDENTITY_TIMEOUT_MS / 1000) } })
58
+
59
+ /** `read`, or a `busy` rejection once `ms` passed (the timer never outlives the answer; a late answer is ignored). */
60
+ function within<T>(read: Promise<T>, ms: number): Promise<T> {
61
+ let timer: ReturnType<typeof setTimeout> | undefined
62
+ const late = new Promise<never>((_, reject) => {
63
+ timer = setTimeout(() => reject(stateBusy()), ms)
64
+ })
65
+ read.catch(() => {}) // a late rejection is not unhandled
66
+ return Promise.race([read, late]).finally(() => clearTimeout(timer))
67
+ }
68
+
69
+ const TIMED = Symbol("concierge.timedIdentity")
70
+
71
+ /**
72
+ * (FX-07) The identity with a time limit on each read: past it, the read rejects `busy`. A user whose read timed out
73
+ * is answered `busy` at once for the next `ms` (no new store read: a stuck store is not asked again per check, so
74
+ * the checks of one request share one wait and reads do not pile up). Wrapping a timed identity returns it as is.
75
+ */
76
+ export function timedIdentity(identity: ConciergeIdentity, ms: number = IDENTITY_TIMEOUT_MS, now: () => number = Date.now): ConciergeIdentity {
77
+ if ((identity as { [TIMED]?: true })[TIMED]) return identity
78
+ const stuckUntil = new Map<string, number>()
79
+ const timed = {
80
+ [TIMED]: true as const,
81
+ async current(userID: string) {
82
+ const until = stuckUntil.get(userID)
83
+ if (until !== undefined) {
84
+ if (now() < until) throw stateBusy()
85
+ stuckUntil.delete(userID)
86
+ }
87
+ try {
88
+ return await within(identity.current(userID), ms)
89
+ } catch (cause) {
90
+ if (cause instanceof AgentError && cause.code === "busy") stuckUntil.set(userID, now() + ms)
91
+ throw cause
92
+ }
93
+ },
94
+ }
95
+ return timed
96
+ }
97
+
98
+ /**
99
+ * The gate agentApi runs for every session actor before the route's own checks: a Concierge session (current or
100
+ * previous) may call only the allow-list and `extra` (the tiers slot's routes). (FX-07) An identity read that fails or
101
+ * has not answered within `timeoutMs` refuses every other route with a neutral 503 `busy` (fail closed, without
102
+ * telling an ordinary session it is the Concierge); the allow-list still passes.
103
+ */
104
+ export function conciergeSessionGate(
105
+ identity: ConciergeIdentity,
106
+ extra: () => readonly AgentRoute[] = () => [],
107
+ options: { readonly timeoutMs?: number } = {},
108
+ ): (actor: AgentActor, route: Pick<AgentRoute, "method" | "path">) => Promise<void> {
109
+ const allowed = new Set(CONCIERGE_SESSION_ROUTES.map((r) => key(r.method, r.path)))
110
+ const timed = timedIdentity(identity, options.timeoutMs ?? IDENTITY_TIMEOUT_MS)
111
+ return async (actor, route) => {
112
+ if (actor.kind !== "session") return
113
+ const listed = () => allowed.has(key(route.method, route.path)) || extra().some((r) => r.method === route.method && r.path === route.path)
114
+ let concierge: boolean
115
+ try {
116
+ const current = await timed.current(actor.userID)
117
+ concierge = current.sessionID === actor.sessionID || current.hiddenSessionIDs.includes(actor.sessionID)
118
+ } catch {
119
+ if (listed()) return
120
+ throw stateBusy()
121
+ }
122
+ if (!concierge || listed()) return
123
+ throw new AgentError("forbidden", "The Concierge cannot use this route", { extra: { policy: "concierge" } })
124
+ }
125
+ }