@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,366 @@
1
+ // Concierge (C1) shared types: one hub-managed agent per user that watches their sessions, answers status
2
+ // questions and acts under a grant (contract: docs/plans/2026-11-01-concierge-contracts.md).
3
+
4
+ export const CONCIERGE_ROLE = "concierge"
5
+ export const CONCIERGE_PARTY = "app:concierge"
6
+ /** OpenCode agent the plugin registers for the Concierge session (CON-04). */
7
+ export const CONCIERGE_AGENT = "concierge"
8
+ /**
9
+ * The only tools a Concierge session is offered (CON-04): an exact allow-list, enforced by the agent's rules and by
10
+ * the per-turn strip. No subagent/task, execute (Code Mode), web, shell, edit, MCP or plugin tools.
11
+ */
12
+ export const CONCIERGE_TOOLS = ["hub", "read", "glob", "grep", "question"] as const
13
+ /** Spoke feature for the per-turn session context push (CON-04). */
14
+ export const SESSION_CONTEXT_FEATURE = "sessionContext"
15
+
16
+ export type ConciergeState = "disabled" | "starting" | "running" | "waiting_machine" | "backoff" | "failed" | "stopping"
17
+
18
+ export interface ConciergeStatus {
19
+ readonly state: ConciergeState
20
+ /** At most 200 characters, written by the hub, never model text. */
21
+ readonly reason?: string
22
+ /** The current session (running or starting). */
23
+ readonly sessionID?: string
24
+ /** Current and previous Concierge sessions (at most 20), hidden from session lists. */
25
+ readonly hiddenSessionIDs: readonly string[]
26
+ readonly launchID?: string
27
+ readonly machineID?: string
28
+ /** The @@concierge holder generation. */
29
+ readonly generation: number
30
+ readonly since: number
31
+ /** When the next relaunch is attempted (backoff). */
32
+ readonly nextAttemptAt?: number
33
+ }
34
+
35
+ /** Grant verbs the Concierge may hold in C1 (no launch, no roles). */
36
+ export type ConciergeGrantVerb = "approve" | "deny" | "answer" | "prompt" | "stop"
37
+
38
+ /** What Settings edits; it becomes a real grant whose holder is @@concierge. */
39
+ export interface ConciergeGrantSpec {
40
+ readonly allow: readonly ConciergeGrantVerb[]
41
+ /** 1–20 canonical project directories. */
42
+ readonly projects: readonly string[]
43
+ readonly permissions: { readonly allow: readonly string[]; readonly never: readonly string[] }
44
+ readonly allowAlways: boolean
45
+ /** "1h".."30d", default "30d". */
46
+ readonly expires: string
47
+ /** 1–1000, default 60. */
48
+ readonly maxActionsPerHour: number
49
+ }
50
+
51
+ /** HH:MM in an IANA time zone. */
52
+ export interface ConciergeQuietHours {
53
+ readonly start: string
54
+ readonly end: string
55
+ readonly timeZone: string
56
+ }
57
+
58
+ /** HH:MM in the settings' time zone. */
59
+ export interface ConciergeDigest {
60
+ readonly at: string
61
+ readonly days?: readonly (0 | 1 | 2 | 3 | 4 | 5 | 6)[]
62
+ }
63
+
64
+ export interface ConciergeSettings {
65
+ readonly enabled: boolean
66
+ /** "hosted" is reserved for C2. */
67
+ readonly where: "machine"
68
+ /** Required to enable. */
69
+ readonly machineID?: string
70
+ /** Absolute git repo on that machine under its node roots; a checkout, never a branch switch. */
71
+ readonly directory?: string
72
+ /** provider/model; absent means the machine default. */
73
+ readonly model?: string
74
+ /** Absent means no grant: the Concierge reads and notifies only. */
75
+ readonly grant?: ConciergeGrantSpec
76
+ /** Hub-managed, read-only to clients. */
77
+ readonly grantID?: string
78
+ readonly connectors: { readonly brain: boolean }
79
+ /** IANA, default from the device that enabled it. */
80
+ readonly timeZone: string
81
+ readonly quietHours?: ConciergeQuietHours
82
+ /** Default true: priority events still wake it during quiet hours. */
83
+ readonly quietAllowsUrgent: boolean
84
+ /** At most 4. */
85
+ readonly digests: readonly ConciergeDigest[]
86
+ readonly revision: number
87
+ readonly updatedAt?: number
88
+ }
89
+
90
+ export const CONCIERGE_LIMITS = {
91
+ hiddenHistory: 20,
92
+ notesBytes: 32_768,
93
+ digests: 4,
94
+ relaunchBackoffMs: [15_000, 60_000, 300_000, 900_000, 1_800_000],
95
+ relaunchesPerHour: 6,
96
+ healthyAfterMs: 600_000,
97
+ } as const
98
+
99
+ // ---- Session context (CON-04, spoke feature "sessionContext") ----
100
+
101
+ /** The keys of the parts a session context carries, appended to the session's system prompt in this order. */
102
+ export const SESSION_CONTEXT_KEYS = ["instructions", "board", "notes"] as const
103
+ export type SessionContextKey = (typeof SESSION_CONTEXT_KEYS)[number]
104
+
105
+ export const SESSION_CONTEXT_LIMITS = { partChars: 40_000, denyGlobs: 50, globChars: 100, allowTools: 50 } as const
106
+
107
+ /**
108
+ * Hub → spoke: a session's per-turn context (feature "sessionContext"). The whole set replaces the previous one;
109
+ * `clear` drops it (no parts, tools or lock then). The spoke keeps it in memory per session and answers the
110
+ * revision now effective; an older revision than the effective one is refused ("stale"). `tools.allow`: the exact
111
+ * tool names the session keeps on every turn (every other tool, including unknown future ones, is removed: fail
112
+ * closed). `tools.deny`: globs of tool names removed as well. `lockAgent`: conversation mode keeps the session's
113
+ * agent and model and adds no orchestrator parts.
114
+ */
115
+ export interface SessionContextRequest {
116
+ readonly op: "sessionContext"
117
+ readonly sessionID: string
118
+ readonly revision: number
119
+ readonly parts: readonly { readonly key: SessionContextKey; readonly text: string }[]
120
+ readonly tools?: { readonly allow?: readonly string[]; readonly deny?: readonly string[] }
121
+ readonly lockAgent?: boolean
122
+ readonly clear?: true
123
+ }
124
+
125
+ export interface SessionContextResult {
126
+ readonly revision: number
127
+ }
128
+
129
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
130
+ const TOOL_GLOB = /^[A-Za-z0-9_.*-]+$/
131
+ const TOOL_NAME = /^[A-Za-z0-9_.-]+$/
132
+ const REQUEST_FIELDS = new Set(["op", "sessionID", "revision", "parts", "tools", "lockAgent", "clear"])
133
+
134
+ /** Why `v` is not an acceptable sessionContext request, or undefined. Exactly the known fields. */
135
+ export function sessionContextProblem(v: unknown): string | undefined {
136
+ if (!isRecord(v) || v.op !== "sessionContext") return "op must be sessionContext"
137
+ for (const key of Object.keys(v)) if (!REQUEST_FIELDS.has(key)) return `unknown field ${key.slice(0, 40)}`
138
+ if (typeof v.sessionID !== "string" || !v.sessionID || v.sessionID.length > 200) return "sessionID is required"
139
+ if (!Number.isSafeInteger(v.revision) || (v.revision as number) < 0) return "revision is a whole number ≥ 0"
140
+ if (!Array.isArray(v.parts) || v.parts.length > SESSION_CONTEXT_KEYS.length) return "parts lists at most one part per key"
141
+ const seen = new Set<string>()
142
+ for (const part of v.parts) {
143
+ if (!isRecord(part) || Object.keys(part).some((k) => k !== "key" && k !== "text")) return "a part is {key, text}"
144
+ if (!(SESSION_CONTEXT_KEYS as readonly unknown[]).includes(part.key)) return `part key is ${SESSION_CONTEXT_KEYS.join(", ")}`
145
+ if (seen.has(part.key as string)) return "one part per key"
146
+ seen.add(part.key as string)
147
+ if (typeof part.text !== "string" || part.text.length > SESSION_CONTEXT_LIMITS.partChars) return `part text is at most ${SESSION_CONTEXT_LIMITS.partChars} characters`
148
+ }
149
+ if (v.tools !== undefined) {
150
+ if (!isRecord(v.tools) || Object.keys(v.tools).some((k) => k !== "deny" && k !== "allow")) return "tools is {allow?, deny?}"
151
+ const { deny, allow } = v.tools
152
+ if (deny !== undefined) {
153
+ if (!Array.isArray(deny) || deny.length > SESSION_CONTEXT_LIMITS.denyGlobs) return `tools.deny lists at most ${SESSION_CONTEXT_LIMITS.denyGlobs} globs`
154
+ for (const glob of deny) if (typeof glob !== "string" || glob.length > SESSION_CONTEXT_LIMITS.globChars || !TOOL_GLOB.test(glob)) return "tools.deny holds tool name globs"
155
+ }
156
+ if (allow !== undefined) {
157
+ if (!Array.isArray(allow) || allow.length > SESSION_CONTEXT_LIMITS.allowTools) return `tools.allow lists at most ${SESSION_CONTEXT_LIMITS.allowTools} tool names`
158
+ for (const name of allow) if (typeof name !== "string" || name.length > SESSION_CONTEXT_LIMITS.globChars || !TOOL_NAME.test(name)) return "tools.allow holds exact tool names (no globs)"
159
+ }
160
+ if (deny === undefined && allow === undefined) return "tools is {allow?, deny?}"
161
+ }
162
+ if (v.lockAgent !== undefined && typeof v.lockAgent !== "boolean") return "lockAgent is true or false"
163
+ if (v.clear !== undefined) {
164
+ if (v.clear !== true) return "clear is true when present"
165
+ if (v.parts.length || v.tools !== undefined || v.lockAgent !== undefined) return "clear carries no parts, tools or lockAgent"
166
+ }
167
+ return undefined
168
+ }
169
+
170
+ /** Whether tool `name` matches a deny glob (`*` matches any run of characters). */
171
+ export function toolDenied(name: string, deny: readonly string[]): boolean {
172
+ return deny.some((glob) => (glob.includes("*") ? new RegExp(`^${glob.replace(/[.+?^${}()|[\]\\-]/g, "\\$&").replace(/\*/g, ".*")}$`).test(name) : glob === name))
173
+ }
174
+
175
+ // ---- Watchlist (CON-01) ----
176
+
177
+ /** What the Concierge watches: auto-active sessions, pins, explicit sessions, projects and label filters, minus mutes. */
178
+ export interface WatchlistConfig {
179
+ /** Default true: sessions working, blocked, in error, with an open blocker or Decision, or active lately. */
180
+ readonly autoActive: boolean
181
+ /** 1–24, default 8 (bounded by the 24 h journal). */
182
+ readonly activeWithinHours: number
183
+ /** Default true: the console's viewPrefs.pinnedSessions and pinnedWorkspaces. */
184
+ readonly usePins: boolean
185
+ /** Explicit ses_… IDs (at most 200): Concierge-only pins, never the console's. */
186
+ readonly sessions: readonly string[]
187
+ /** Canonical absolute project directories (at most 50). */
188
+ readonly projects: readonly string[]
189
+ /** At most 20 filters; one matches a session when all its pairs equal its launch's labels. */
190
+ readonly labels: readonly Readonly<Record<string, string>>[]
191
+ /** ses_… IDs (at most 500); beats every include. */
192
+ readonly muted: readonly string[]
193
+ /** Canonical absolute project directories (at most 50); beats every include. */
194
+ readonly mutedProjects: readonly string[]
195
+ readonly revision: number
196
+ }
197
+
198
+ export type WatchReason = "active" | "pinned" | "pinned_project" | "explicit" | "project" | "label"
199
+
200
+ export interface WatchlistEntry {
201
+ readonly sessionID: string
202
+ readonly reasons: readonly WatchReason[]
203
+ readonly lastActiveAt?: number
204
+ }
205
+
206
+ /** GET /api/agent/concierge/watchlist. */
207
+ export interface WatchlistView {
208
+ readonly config: WatchlistConfig
209
+ readonly entries: readonly WatchlistEntry[]
210
+ readonly truncated: boolean
211
+ }
212
+
213
+ export const WATCHLIST_LIMITS = { entries: 200, sessions: 200, projects: 50, labels: 20, muted: 500 } as const
214
+
215
+ export const WATCHLIST_DEFAULTS = { autoActive: true, activeWithinHours: 8, usePins: true } as const
216
+
217
+ // ---- Session intent (CON-02) ----
218
+
219
+ /** Who set a session's intent, highest precedence first: user > launch > self > prompt > title. */
220
+ export type IntentSource = "user" | "launch" | "self" | "prompt" | "title"
221
+
222
+ /** What a session is for: untrusted text (sanitised, one line), never interpreted as instructions. */
223
+ export interface SessionIntent {
224
+ readonly sessionID: string
225
+ /** Sanitised, one line, 1–280 characters (INTENT_MAX). */
226
+ readonly text: string
227
+ readonly source: IntentSource
228
+ /** 0 before the hub stored anything for the session (a title fallback). */
229
+ readonly revision: number
230
+ readonly updatedAt: number
231
+ /** Party key of the writer (user, app:…, ses_…); absent for derived intents. */
232
+ readonly by?: string
233
+ }
234
+
235
+ export const INTENT_MAX = 280
236
+
237
+ // ---- Situation board (CON-03) ----
238
+
239
+ /** A board section, as on the workspace board: ok, omitted (scope), unsupported (not wired) or failed (its source). */
240
+ export type ConciergeBoardSection<T> =
241
+ | { readonly status: "ok"; readonly items: readonly T[]; readonly truncated: boolean }
242
+ | { readonly status: "omitted"; readonly reason: "scope"; readonly scope: string }
243
+ | { readonly status: "unsupported"; readonly feature: string }
244
+ | { readonly status: "failed" }
245
+
246
+ export type ConciergeBoardState = "idle" | "working" | "blocked" | "error" | "offline"
247
+
248
+ export type ConciergeResultOutcome = "turn_done" | "error" | "launch_failed" | "launch_needs_user" | "launch_idle"
249
+
250
+ /**
251
+ * (CON-13) A pending blocker the Concierge may act on, by ID (at most CONCIERGE_BOARD_PENDING_MAX per session): a
252
+ * permission with its action (an enum-like label, "other" otherwise) and `inGrant`, which the hub computes with the
253
+ * acting engine's own check (would approve once succeed under the @@concierge grant): the model never decides it;
254
+ * `auto` (CON-13 decision): inGrant and not the tap risk tier, the only ones the Concierge approves itself. A
255
+ * form with its ID (concierge_form reads its fields). Never summaries, commands or paths.
256
+ */
257
+ export type ConciergeBoardBlocker =
258
+ | { readonly kind: "permission"; readonly id: string; readonly action: string; readonly inGrant: boolean; readonly auto: boolean }
259
+ | { readonly kind: "form"; readonly id: string }
260
+
261
+ export const CONCIERGE_BOARD_PENDING_MAX = 6
262
+
263
+ /** One watched root session: IDs, enums, counts and times; `intent`, `title`, `machine`, `project` are untrusted. */
264
+ export interface ConciergeBoardSession {
265
+ readonly sessionID: string
266
+ /** The @name slug (with "@"). */
267
+ readonly name?: string
268
+ /** Project label (lowercased slug, as the directory has it). */
269
+ readonly project?: string
270
+ /** Machine name (sanitised label). */
271
+ readonly machine?: string
272
+ readonly state: ConciergeBoardState
273
+ /** When the current state began, as far as the hub knows (activity, last result, or lastSeen when offline). */
274
+ readonly since?: number
275
+ /** reported: false when this hub's catalog does not report the session now (never "none"). */
276
+ readonly blockers: { readonly reported: false } | { readonly reported: true; readonly count: number; readonly kinds: readonly string[]; readonly pending?: readonly ConciergeBoardBlocker[] }
277
+ /** Open Decision IDs about this tree (at most 8). */
278
+ readonly decisions: readonly string[]
279
+ /** The stored intent (at most 280 characters), untrusted. */
280
+ readonly intent?: { readonly text: string; readonly source: IntentSource }
281
+ /** The non-placeholder title (at most 80 characters), untrusted; only when there is no intent. */
282
+ readonly title?: string
283
+ readonly lastResult?: { readonly at: number; readonly outcome: ConciergeResultOutcome }
284
+ readonly reasons: readonly WatchReason[]
285
+ }
286
+
287
+ export interface ConciergeBoardDecision {
288
+ readonly id: string
289
+ /** Party label (sanitised; "@@concierge" for the Concierge's own sessions). */
290
+ readonly from: string
291
+ /** Session IDs it concerns. */
292
+ readonly about: readonly string[]
293
+ readonly urgency: "low" | "normal" | "high"
294
+ readonly tier: "confirm" | "tap"
295
+ readonly at: number
296
+ readonly deadline?: number
297
+ /** Under discussion by voice (CON-06). */
298
+ readonly armed?: true
299
+ }
300
+
301
+ export interface ConciergeBoardMachine {
302
+ readonly id: string
303
+ readonly name: string
304
+ readonly online: boolean
305
+ }
306
+
307
+ export interface ConciergeBoard {
308
+ /** Monotonic per user; changes only when the content (everything but `at`) changes. */
309
+ readonly revision: number
310
+ readonly at: number
311
+ /** At most CONCIERGE_BOARD_LIMITS.sessions, in watchlist order. */
312
+ readonly sessions: ConciergeBoardSection<ConciergeBoardSession>
313
+ /** Open, at most CONCIERGE_BOARD_LIMITS.decisions, newest first. */
314
+ readonly decisions: ConciergeBoardSection<ConciergeBoardDecision>
315
+ readonly machines: ConciergeBoardSection<ConciergeBoardMachine>
316
+ readonly counts: { readonly watched: number; readonly working: number; readonly blocked: number; readonly error: number; readonly openDecisions: number }
317
+ }
318
+
319
+ /** GET /api/agent/concierge/board. */
320
+ export interface ConciergeBoardView {
321
+ readonly board: ConciergeBoard
322
+ /** renderBoard(board): the text the Concierge gets, at most CONCIERGE_BOARD_LIMITS.renderedChars. */
323
+ readonly rendered: string
324
+ }
325
+
326
+ export const CONCIERGE_BOARD_LIMITS = { sessions: 40, decisions: 20, renderedChars: 12_000, titleChars: 80, decisionsPerSession: 8, machines: 50 } as const
327
+
328
+
329
+ // ---- Event-driven turns (CON-05) ----
330
+
331
+ // ---- Approval tiers (CON-06) ----
332
+
333
+ /** The verbs a Concierge proposal covers (POST /api/agent/concierge/proposals). */
334
+ export type ConciergeProposalVerb = "stop" | "deny" | "prompt" | "answer"
335
+
336
+ /** POST /api/agent/concierge/proposals (the current Concierge's act token only) → 201 {decision}. */
337
+ export interface ConciergeProposalBody {
338
+ readonly verb: ConciergeProposalVerb
339
+ /** @name, @@role, ses_… (resolved for the user). */
340
+ readonly session: string
341
+ /** stop/deny: none; prompt: {text}; answer: {answer} | {cancel: true}. */
342
+ readonly input?: { readonly text?: string; readonly answer?: Readonly<Record<string, string | number | boolean | readonly string[]>>; readonly cancel?: true }
343
+ /** The Concierge's reason (≤ whyChars): the recommendation's with `recommend`, else shown after the question. */
344
+ readonly why?: string
345
+ /** (CON-09a) The Concierge recommends Do it. Absent/false: no recommendation (the user only asked it to propose). */
346
+ readonly recommend?: boolean
347
+ /** deny only. */
348
+ readonly requestID?: string
349
+ /** answer only. */
350
+ readonly formID?: string
351
+ }
352
+
353
+ /** The hub's tier per proposed verb (never the Concierge's): free text only by a tap. */
354
+ export const CONCIERGE_PROPOSAL_TIERS = { stop: "confirm", deny: "confirm", prompt: "tap", answer: "tap" } as const satisfies Record<ConciergeProposalVerb, "confirm" | "tap">
355
+
356
+ /** POST/GET /api/agent/concierge/arm → {armed: ConciergeArmed | null}; SSE {type: "concierge", armed}. */
357
+ export interface ConciergeArmed {
358
+ readonly decisionID: string
359
+ readonly at: number
360
+ readonly expiresAt: number
361
+ }
362
+
363
+ /** How the user answered a Decision (POST /api/decisions/:id/answer `via`; absent = tap). */
364
+ export type DecisionAnswerVia = "tap" | "voice" | "text"
365
+
366
+ export const CONCIERGE_TIER_LIMITS = { proposalsOpen: 10, proposalsPerHour: 30, armedTtlMs: 120_000, promptChars: 8_000, answerJsonChars: 4_000, whyChars: 1_000 } as const
@@ -0,0 +1,104 @@
1
+ // Shared vocabulary of the workspaces/artifacts/teams contracts (HWA-02, docs/plans/2026-10-30-hub-workspaces-
2
+ // artifacts-contracts.md). Pure: IDs, slugs, bounded text and the expected-revision / idempotency rules every new
3
+ // record follows. Domain modules (hub-workspace.ts, profiles.ts, artifacts.ts, teams.ts, graph.ts) build on it.
4
+ import { UNSAFE_CHARS } from "./wire.ts"
5
+
6
+ /** Record-schema version of every new record; readers reject other versions rather than guess. */
7
+ export const CONTRACT_VERSION = 1
8
+
9
+ /** ID prefixes of the new records. An ID is `<prefix>_<16–32 base32/alnum chars>`, minted by the hub. */
10
+ export const ID_PREFIXES = {
11
+ workspace: "ws",
12
+ binding: "wsb",
13
+ member: "wsm",
14
+ assignment: "asg",
15
+ supervision: "sup",
16
+ checkpoint: "chk",
17
+ sideChat: "chat",
18
+ profile: "prof",
19
+ artifact: "art",
20
+ revision: "rev",
21
+ blob: "blob",
22
+ aiJob: "job",
23
+ team: "team",
24
+ staffing: "stf",
25
+ intake: "itk",
26
+ } as const
27
+ export type IdKind = keyof typeof ID_PREFIXES
28
+
29
+ const ID_BODY = /^[A-Za-z0-9]{16,32}$/
30
+
31
+ /** True when `v` is a well-formed ID of that kind. */
32
+ export function isContractID(kind: IdKind, v: unknown): v is string {
33
+ if (typeof v !== "string") return false
34
+ const prefix = `${ID_PREFIXES[kind]}_`
35
+ return v.startsWith(prefix) && ID_BODY.test(v.slice(prefix.length))
36
+ }
37
+
38
+ /** Slugs (team names, role names, profile names): the @name syntax, lowercase letters, digits, dashes, 2–40. */
39
+ export const SLUG = /^[a-z0-9-]{2,40}$/
40
+ export const isSlug = (v: unknown): v is string => typeof v === "string" && SLUG.test(v)
41
+
42
+ /** One-line labels (titles, role labels): 1–120 characters, no control or format characters. */
43
+ export const LABEL_MAX = 120
44
+ export const isLabel = (v: unknown, max = LABEL_MAX): v is string =>
45
+ typeof v === "string" && v.trim().length > 0 && v.length <= max && !UNSAFE_CHARS.test(v)
46
+
47
+ /** Free-form descriptions/instructions: up to `max` characters (newlines allowed, other controls not). */
48
+ export const DESCRIPTION_MAX = 4_000
49
+ const UNSAFE_MULTILINE = /[\u0000-\u0009\u000b-\u001f\u007f\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069]/
50
+ export const isText = (v: unknown, max = DESCRIPTION_MAX): v is string => typeof v === "string" && v.length <= max && !UNSAFE_MULTILINE.test(v)
51
+
52
+ /** Tags and metadata (teams, artifacts, workspaces): bounded, filterable, never secrets. */
53
+ export const TAGS_MAX = 32
54
+ export const METADATA_MAX_KEYS = 32
55
+ export const METADATA_VALUE_MAX = 200
56
+ export function tagsProblem(v: unknown): string | undefined {
57
+ if (v === undefined) return undefined
58
+ if (!Array.isArray(v) || v.length > TAGS_MAX) return `tags must be a list of at most ${TAGS_MAX}`
59
+ if (!v.every(isSlug)) return "each tag must be 2–40 lowercase letters, digits or dashes"
60
+ if (new Set(v).size !== v.length) return "tags must be unique"
61
+ return undefined
62
+ }
63
+ export function metadataProblem(v: unknown): string | undefined {
64
+ if (v === undefined) return undefined
65
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return "metadata must be an object"
66
+ const entries = Object.entries(v)
67
+ if (entries.length > METADATA_MAX_KEYS) return `metadata has more than ${METADATA_MAX_KEYS} keys`
68
+ for (const [key, value] of entries) {
69
+ if (!isSlug(key)) return `metadata key ${JSON.stringify(String(key).slice(0, 50))} must be a slug`
70
+ if (!isLabel(value, METADATA_VALUE_MAX)) return `metadata.${key} must be 1–${METADATA_VALUE_MAX} characters of text`
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ /**
76
+ * Optimistic concurrency for every mutable record: each record carries `revision` (1 on create, +1 per change).
77
+ * A mutation names `expectedRevision`; a mismatch is answered 409 `conflict` with `extra.currentRevision`.
78
+ * Creates take `expectedRevision: 0` implicitly. Revisions are never reused, even after delete.
79
+ */
80
+ export interface Revisioned {
81
+ readonly revision: number
82
+ }
83
+ export const isExpectedRevision = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
84
+
85
+ /**
86
+ * Idempotency: creating mutations (workspace, artifact, revision, AI job, intake, staffing launch) accept an
87
+ * `Idempotency-Key` header (1–200 of [A-Za-z0-9_.:-]); the same key + same body within IDEMPOTENCY_TTL_MS returns
88
+ * the original result, a different body under the same key is 409 `conflict`. Keys are per user and per route.
89
+ */
90
+ export const IDEMPOTENCY_KEY = /^[A-Za-z0-9_.:-]{1,200}$/
91
+ export const IDEMPOTENCY_TTL_MS = 24 * 60 * 60_000
92
+
93
+ /** Record lifecycle shared by workspaces, teams, profiles and artifacts: archive is reversible, delete is not. */
94
+ export const RECORD_STATES = ["active", "archived"] as const
95
+ export type RecordState = (typeof RECORD_STATES)[number]
96
+
97
+ /** Bounded list reads: `limit` 1–LIST_LIMIT_MAX (default LIST_LIMIT_DEFAULT) and an opaque `cursor`. */
98
+ export const LIST_LIMIT_DEFAULT = 50
99
+ export const LIST_LIMIT_MAX = 200
100
+ export interface Page<T> {
101
+ readonly items: readonly T[]
102
+ /** Absent on the last page. */
103
+ readonly cursor?: string
104
+ }
@@ -0,0 +1,70 @@
1
+ // Interaction graph projection for the Watchtower (HWA-02 contract; API HWA-31, 3D page HWA-32, 2D/teams
2
+ // HWA-38/39). Decisions 5e4l4u8v (page), 50slcj1x (3d-force-graph + force-graph).
3
+ //
4
+ // GET /api/agent/graph?since=&until=&machine=&harness=&team=&tag=&project=&limit= returns a bounded snapshot over
5
+ // the existing registry, message store, Decisions and canonical events. It carries counts and IDs, never message
6
+ // text: edge detail is read through the existing authorized message reads. Quiet machines and sessions are
7
+ // included. Live updates arrive as compact `graph.invalidated` hints; the client re-fetches.
8
+ //
9
+ // History is bounded by message retention (200 per party; 10,000 / 20 MiB per user): `retention.truncated`
10
+ // tells the UI to disclose that older interactions were evicted.
11
+
12
+ export const GRAPH_LIMITS = {
13
+ nodes: 2_000,
14
+ edges: 5_000,
15
+ /** Longest window a snapshot may cover. */
16
+ windowMs: 7 * 24 * 60 * 60_000,
17
+ defaultWindowMs: 24 * 60 * 60_000,
18
+ } as const
19
+
20
+ export type GraphNodeKind = "hub" | "machine" | "session" | "user" | "team"
21
+
22
+ export interface GraphNode {
23
+ readonly id: string
24
+ readonly kind: GraphNodeKind
25
+ /** Display label: an @name, machine name or team title. Never a prompt or message. */
26
+ readonly label: string
27
+ readonly machineID?: string
28
+ /**
29
+ * The harness kind: the reporting process's, else (offline) the last one the hub saw for the session, else its
30
+ * launch's, else its machine's when the user's processes there all run one harness (RF-02). Absent: unknown.
31
+ */
32
+ readonly harness?: string
33
+ /** Native ancestry (parentID); distinct from supervision. */
34
+ readonly parentID?: string
35
+ readonly supervisorID?: string
36
+ readonly teamIDs?: readonly string[]
37
+ readonly status?: "idle" | "working" | "blocked" | "error" | "offline" | "ended"
38
+ readonly tags?: readonly string[]
39
+ readonly project?: string
40
+ /** (CON-04) The user's Concierge session (current or previous): hidden from session lists, shown here. */
41
+ readonly concierge?: true
42
+ }
43
+
44
+ export interface GraphEdge {
45
+ readonly id: string
46
+ readonly from: string
47
+ readonly to: string
48
+ readonly kind: "msg" | "ask" | "reply" | "decision" | "launch" | "policy"
49
+ readonly count: number
50
+ readonly lastAt: number
51
+ /** For kind "policy": a permitted team path that may have no traffic (policy-vs-actual overlay). */
52
+ readonly teamID?: string
53
+ readonly denied?: number
54
+ }
55
+
56
+ export interface GraphSnapshot {
57
+ readonly v: 1
58
+ readonly at: number
59
+ readonly window: { readonly since: number; readonly until: number }
60
+ readonly nodes: readonly GraphNode[]
61
+ readonly edges: readonly GraphEdge[]
62
+ readonly retention: { readonly truncated: boolean; readonly oldestAt?: number }
63
+ /** True when limits cut the snapshot (narrow the filters). */
64
+ readonly capped: boolean
65
+ }
66
+
67
+ /** Budget for the lazily loaded Watchtower chunk (three + 3d-force-graph + force-graph), gzip bytes. */
68
+ export const WATCHTOWER_CHUNK_BUDGET = 700 * 1024
69
+ /** @deprecated The page was renamed Watchtower; kept for API stability. Use WATCHTOWER_CHUNK_BUDGET. */
70
+ export const BIRDS_EYE_CHUNK_BUDGET = WATCHTOWER_CHUNK_BUDGET