@huynle/hub 0.7.4 → 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 +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  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 +632 -49
  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,273 @@
1
+ // CON-01 watchlist (contract: docs/plans/2026-11-01-concierge-contracts.md, "Watchlist (CON-01)"): which of a user's
2
+ // sessions the Concierge watches. Pure parts only: the resolution (resolveWatchlist) over facts gathered elsewhere
3
+ // (watchlist-routes.ts), the default config and the validation of an update. Nothing here reads a store or a spoke.
4
+ //
5
+ // Resolution: root sessions only (subagents roll up into their root: state, blockers, Decisions, activity, and the
6
+ // IDs in pins, explicit sessions and mutes). Included by reason:
7
+ // active the tree is working, blocked or in error, has an open blocker or an open Decision about it, or was
8
+ // active within activeWithinHours (autoActive)
9
+ // pinned viewPrefs.pinnedSessions (usePins)
10
+ // pinned_project viewPrefs.pinnedWorkspaces: `herdr:<id>` = the terminal workspace, else a folder matched as a
11
+ // project/directory prefix on path boundaries (inProjects) (usePins)
12
+ // explicit config.sessions
13
+ // project config.projects (inProjects)
14
+ // label a config.labels filter whose pairs all equal the launch's labels
15
+ // minus muted / mutedProjects (an offline session without a known directory: by its project slug), archived, deleted or
16
+ // gone sessions (CON-12: its machine is connected but no longer reports it) and every hidden (Concierge) session. Ordered by priority
17
+ // (error, blocked, open Decision, working, then most recent activity, then ID) and cut at WATCHLIST_LIMITS.entries.
18
+ // Notification mutes (store mutedSessions) are not inherited: they silence pushes, they do not mean "don't track".
19
+ import { WATCHLIST_DEFAULTS, WATCHLIST_LIMITS, type WatchlistConfig, type WatchlistEntry, type WatchReason } from "../../../packages/protocol/src/concierge"
20
+ import { isHubSessionID } from "../../../packages/protocol/src/harness-events"
21
+ import { AgentError } from "../agent/errors"
22
+ import { LABEL_KEY_MAX, LABEL_VALUE_MAX, LABELS_MAX } from "../agent/launch-spec"
23
+ import { canonicalProject, inProjects } from "../agent/policy"
24
+ import { slugify } from "../agent/names"
25
+ import path from "node:path"
26
+
27
+ const HOUR_MS = 3_600_000
28
+
29
+ /** One session of the user (root or subagent) as the resolution sees it. */
30
+ export interface WatchFact {
31
+ readonly sessionID: string
32
+ readonly parentID?: string
33
+ /** The session's own state (idle, working, blocked, error, offline, …). */
34
+ readonly state?: string
35
+ /** Open blockers (permissions, questions) of this session. */
36
+ readonly blockers: number
37
+ readonly directory?: string
38
+ /** The project (workspace) root directory, when it differs from the directory. */
39
+ readonly project?: string
40
+ /**
41
+ * The directory's project slug (AgentSession.project), known offline too: when no directory is known, mutedProjects
42
+ * match on it (a basename slug, so it may over-mute; it never includes anything).
43
+ */
44
+ readonly projectSlug?: string
45
+ /** The Herdr workspace it is open in. */
46
+ readonly herdrWorkspace?: string
47
+ readonly archived?: boolean
48
+ readonly deleted?: boolean
49
+ /**
50
+ * (CON-12) Gone: its machine is connected and no longer reports it (deleted or closed there; the console no longer
51
+ * lists it). A session of an offline machine is only offline (it may come back) and stays watchable.
52
+ */
53
+ readonly gone?: boolean
54
+ readonly lastActiveAt?: number
55
+ /** Labels of the launch that started it. */
56
+ readonly labels?: Readonly<Record<string, string>>
57
+ }
58
+
59
+ export interface WatchFacts {
60
+ readonly sessions: readonly WatchFact[]
61
+ /** The console's pins (viewPrefs.pinnedSessions / pinnedWorkspaces). */
62
+ readonly pins: { readonly sessions: readonly string[]; readonly workspaces: readonly string[] }
63
+ /** Sessions an open Decision is about (or was asked by). */
64
+ readonly decisions: readonly string[]
65
+ /** The Concierge's own sessions (current and previous): never watched. */
66
+ readonly hidden: readonly string[]
67
+ readonly now: number
68
+ }
69
+
70
+ export interface ResolvedWatchlist {
71
+ readonly entries: WatchlistEntry[]
72
+ readonly truncated: boolean
73
+ }
74
+
75
+ export function defaultWatchlist(): WatchlistConfig {
76
+ return { ...WATCHLIST_DEFAULTS, sessions: [], projects: [], labels: [], muted: [], mutedProjects: [], revision: 0 }
77
+ }
78
+
79
+ const REASON_ORDER: readonly WatchReason[] = ["active", "pinned", "pinned_project", "explicit", "project", "label"]
80
+
81
+ interface Tree {
82
+ readonly root: WatchFact
83
+ error: boolean
84
+ blocked: boolean
85
+ decision: boolean
86
+ working: boolean
87
+ lastActiveAt?: number
88
+ }
89
+
90
+ /** The root of each session: parents followed while known (a cycle stops at the first repeat). */
91
+ export function rootsOf(sessions: readonly WatchFact[]): Map<string, string> {
92
+ const byID = new Map(sessions.map((s) => [s.sessionID, s]))
93
+ const roots = new Map<string, string>()
94
+ for (const s of sessions) {
95
+ const seen = new Set<string>([s.sessionID])
96
+ let current = s
97
+ while (current.parentID !== undefined) {
98
+ const parent = byID.get(current.parentID)
99
+ if (!parent || seen.has(parent.sessionID)) break
100
+ seen.add(parent.sessionID)
101
+ current = parent
102
+ }
103
+ roots.set(s.sessionID, current.sessionID)
104
+ }
105
+ return roots
106
+ }
107
+
108
+ const dirsOf = (s: WatchFact): string[] => [s.project, s.directory].filter((d): d is string => typeof d === "string" && d.length > 0)
109
+ const inAny = (s: WatchFact, projects: readonly string[]) => projects.length > 0 && dirsOf(s).some((dir) => inProjects(dir, projects))
110
+
111
+ /** The project slug of a directory, as the directory derives it (slug of the folder name, else lowercased). */
112
+ const slugOf = (dir: string) => {
113
+ const name = path.posix.basename(dir)
114
+ return name ? (slugify(name) ?? name.toLowerCase()) : undefined
115
+ }
116
+
117
+ /** Muted by project: a known directory decides; without one (offline), the project slug matches a muted folder's. */
118
+ const inMutedProject = (s: WatchFact, muted: readonly string[]) => {
119
+ if (dirsOf(s).length > 0) return inAny(s, muted)
120
+ return s.projectSlug !== undefined && muted.some((dir) => slugOf(dir) === s.projectSlug)
121
+ }
122
+
123
+ function inPinnedWorkspace(s: WatchFact, workspaces: readonly string[]): boolean {
124
+ const folders: string[] = []
125
+ for (const key of workspaces) {
126
+ if (key.startsWith("herdr:")) {
127
+ if (s.herdrWorkspace !== undefined && key.slice("herdr:".length) === s.herdrWorkspace) return true
128
+ } else folders.push(key)
129
+ }
130
+ return inAny(s, folders)
131
+ }
132
+
133
+ const labelsMatch = (labels: Readonly<Record<string, string>> | undefined, filter: Readonly<Record<string, string>>) => {
134
+ const pairs = Object.entries(filter)
135
+ return labels !== undefined && pairs.length > 0 && pairs.every(([k, v]) => Object.hasOwn(labels, k) && labels[k] === v)
136
+ }
137
+
138
+ const rank = (t: Tree) => (t.error ? 0 : t.blocked ? 1 : t.decision ? 2 : t.working ? 3 : 4)
139
+
140
+ export function resolveWatchlist(config: WatchlistConfig, facts: WatchFacts): ResolvedWatchlist {
141
+ const roots = rootsOf(facts.sessions)
142
+ const rootID = (id: string) => roots.get(id) ?? id
143
+ const decisions = new Set(facts.decisions)
144
+ const trees = new Map<string, Tree>()
145
+ const byID = new Map(facts.sessions.map((s) => [s.sessionID, s]))
146
+ for (const s of facts.sessions) {
147
+ const id = rootID(s.sessionID)
148
+ let tree = trees.get(id)
149
+ if (!tree) trees.set(id, (tree = { root: byID.get(id)!, error: false, blocked: false, decision: false, working: false }))
150
+ if (s.state === "error") tree.error = true
151
+ if (s.state === "blocked" || s.blockers > 0) tree.blocked = true
152
+ if (s.state === "working") tree.working = true
153
+ if (decisions.has(s.sessionID)) tree.decision = true
154
+ if (s.lastActiveAt !== undefined && (tree.lastActiveAt === undefined || s.lastActiveAt > tree.lastActiveAt)) tree.lastActiveAt = s.lastActiveAt
155
+ }
156
+ const rootSet = (ids: readonly string[]) => new Set(ids.map(rootID))
157
+ const hidden = rootSet(facts.hidden)
158
+ const muted = rootSet(config.muted)
159
+ const explicit = rootSet(config.sessions)
160
+ const pinned = config.usePins ? rootSet(facts.pins.sessions) : new Set<string>()
161
+ const since = facts.now - config.activeWithinHours * HOUR_MS
162
+
163
+ const out: { tree: Tree; entry: WatchlistEntry }[] = []
164
+ for (const [id, tree] of trees) {
165
+ const root = tree.root
166
+ if (root.archived || root.deleted || root.gone || hidden.has(id) || muted.has(id) || inMutedProject(root, config.mutedProjects)) continue
167
+ const reasons = new Set<WatchReason>()
168
+ const recent = tree.lastActiveAt !== undefined && tree.lastActiveAt >= since
169
+ if (config.autoActive && (tree.error || tree.blocked || tree.decision || tree.working || recent)) reasons.add("active")
170
+ if (pinned.has(id)) reasons.add("pinned")
171
+ if (config.usePins && inPinnedWorkspace(root, facts.pins.workspaces)) reasons.add("pinned_project")
172
+ if (explicit.has(id)) reasons.add("explicit")
173
+ if (inAny(root, config.projects)) reasons.add("project")
174
+ if (config.labels.some((filter) => labelsMatch(root.labels, filter))) reasons.add("label")
175
+ if (reasons.size === 0) continue
176
+ out.push({
177
+ tree,
178
+ entry: { sessionID: id, reasons: REASON_ORDER.filter((r) => reasons.has(r)), ...(tree.lastActiveAt !== undefined ? { lastActiveAt: tree.lastActiveAt } : {}) },
179
+ })
180
+ }
181
+ out.sort((a, b) => rank(a.tree) - rank(b.tree) || (b.tree.lastActiveAt ?? -1) - (a.tree.lastActiveAt ?? -1) || (a.entry.sessionID < b.entry.sessionID ? -1 : a.entry.sessionID > b.entry.sessionID ? 1 : 0))
182
+ return { entries: out.slice(0, WATCHLIST_LIMITS.entries).map((o) => o.entry), truncated: out.length > WATCHLIST_LIMITS.entries }
183
+ }
184
+
185
+ // ---- Update validation ----------------------------------------------------------------------------------------
186
+
187
+ const FIELDS = new Set(["autoActive", "activeWithinHours", "usePins", "sessions", "projects", "labels", "muted", "mutedProjects", "revision", "expectedRevision"])
188
+ const LABEL_KEY = /^[A-Za-z0-9](?:[A-Za-z0-9._/-]*[A-Za-z0-9])?$/
189
+ /** C0 (tab and newline included), DEL, C1, Arabic letter mark, zero-width and bidi marks, separators, embeddings and overrides, word joiner, invisible operators, bidi isolates, BOM. */
190
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/
191
+
192
+ const invalid = (message: string) => new AgentError("invalid", message)
193
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
194
+ const isRevision = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
195
+
196
+ function boolField(value: unknown, name: string): boolean {
197
+ if (typeof value !== "boolean") throw invalid(`${name} is true or false`)
198
+ return value
199
+ }
200
+
201
+ function stringList(value: unknown, name: string): string[] {
202
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw invalid(`${name} is a list of strings`)
203
+ return value
204
+ }
205
+
206
+ function capped<T>(list: T[], max: number, name: string): T[] {
207
+ if (list.length > max) throw invalid(`${name} has at most ${max} entries`)
208
+ return list
209
+ }
210
+
211
+ function sessionList(value: unknown, name: string, max: number): string[] {
212
+ const list = stringList(value, name)
213
+ for (const id of list) if (!isHubSessionID(id)) throw invalid(`${name}: ${String(id).slice(0, 40)} is not a session ID (ses_…)`)
214
+ return capped([...new Set(list)], max, name)
215
+ }
216
+
217
+ function projectList(value: unknown, name: string): string[] {
218
+ const out: string[] = []
219
+ for (const dir of stringList(value, name)) {
220
+ if (UNSAFE.test(dir)) throw invalid(`${name}: a directory has control, bidi or zero-width characters`)
221
+ const canonical = canonicalProject(dir)
222
+ if (canonical === undefined) throw invalid(`${name}: ${dir.slice(0, 80)} is not an absolute project directory`)
223
+ out.push(canonical)
224
+ }
225
+ return capped([...new Set(out)], WATCHLIST_LIMITS.projects, name)
226
+ }
227
+
228
+ function labelFilters(value: unknown): Record<string, string>[] {
229
+ if (!Array.isArray(value)) throw invalid("labels is a list of {key: value} filters")
230
+ capped(value, WATCHLIST_LIMITS.labels, "labels")
231
+ return value.map((filter, i) => {
232
+ if (!isObject(filter)) throw invalid(`labels[${i}] is a {key: value} filter`)
233
+ const pairs = Object.entries(filter)
234
+ if (pairs.length === 0 || pairs.length > LABELS_MAX) throw invalid(`labels[${i}] has 1–${LABELS_MAX} pairs`)
235
+ const out: Record<string, string> = {}
236
+ for (const [key, text] of pairs) {
237
+ if (UNSAFE.test(key)) throw invalid(`labels[${i}]: a key has control, bidi or zero-width characters`)
238
+ if (key.length > LABEL_KEY_MAX || !LABEL_KEY.test(key)) throw invalid(`labels[${i}]: ${key.slice(0, LABEL_KEY_MAX)} is not a label key (letters, digits, . _ / -; up to ${LABEL_KEY_MAX})`)
239
+ if (typeof text !== "string" || text.length > LABEL_VALUE_MAX || UNSAFE.test(text)) throw invalid(`labels[${i}].${key} is a string of up to ${LABEL_VALUE_MAX} characters`)
240
+ out[key] = text
241
+ }
242
+ return out
243
+ })
244
+ }
245
+
246
+ /**
247
+ * The config a PUT body asks for: any subset of the config fields plus the required expectedRevision (the
248
+ * `revision` read earlier may be echoed back, but must agree). Unknown fields, wrong types and caps are 400 invalid;
249
+ * a stale expectedRevision is 409 conflict {currentRevision}. The answer keeps `current.revision` (the store bumps it).
250
+ */
251
+ export function parseWatchlistUpdate(body: unknown, current: WatchlistConfig): WatchlistConfig {
252
+ if (!isObject(body)) throw invalid("The body is a JSON object: watchlist fields and expectedRevision")
253
+ const unknown = Object.keys(body).find((k) => !FIELDS.has(k))
254
+ if (unknown !== undefined) throw invalid(`Unknown field ${unknown.slice(0, 40)}`)
255
+ const expected = body.expectedRevision
256
+ if (!isRevision(expected)) throw invalid("expectedRevision (the revision you read) is required")
257
+ if (body.revision !== undefined && body.revision !== expected) throw invalid("revision and expectedRevision disagree: send the revision you read as expectedRevision")
258
+ const next: { -readonly [K in keyof WatchlistConfig]: WatchlistConfig[K] } = { ...current }
259
+ if (body.autoActive !== undefined) next.autoActive = boolField(body.autoActive, "autoActive")
260
+ if (body.usePins !== undefined) next.usePins = boolField(body.usePins, "usePins")
261
+ if (body.activeWithinHours !== undefined) {
262
+ const hours = body.activeWithinHours
263
+ if (typeof hours !== "number" || !Number.isInteger(hours) || hours < 1 || hours > 24) throw invalid("activeWithinHours is a whole number of hours, 1–24")
264
+ next.activeWithinHours = hours
265
+ }
266
+ if (body.sessions !== undefined) next.sessions = sessionList(body.sessions, "sessions", WATCHLIST_LIMITS.sessions)
267
+ if (body.muted !== undefined) next.muted = sessionList(body.muted, "muted", WATCHLIST_LIMITS.muted)
268
+ if (body.projects !== undefined) next.projects = projectList(body.projects, "projects")
269
+ if (body.mutedProjects !== undefined) next.mutedProjects = projectList(body.mutedProjects, "mutedProjects")
270
+ if (body.labels !== undefined) next.labels = labelFilters(body.labels)
271
+ if (expected !== current.revision) throw new AgentError("conflict", `The watchlist changed since you read it (revision ${current.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: current.revision } })
272
+ return next
273
+ }
@@ -81,6 +81,8 @@ export interface ClusterConfig {
81
81
  readonly databaseURL: string
82
82
  /** redis://, rediss://, valkey:// or valkeys:// (non-enumerable). */
83
83
  readonly valkeyURL: string
84
+ /** HUB_DATABASE_STATEMENT_TIMEOUT_MS: Postgres statement_timeout in ms, 0 off (unset: the stores' 30 s default). */
85
+ readonly statementTimeoutMs?: number
84
86
  }
85
87
 
86
88
  /** hub.json `cluster` (env HUB_DATABASE_URL / HUB_VALKEY_URL win). */
@@ -433,7 +435,11 @@ function clusterConfig(env: Env, auth: AuthConfig, file: HubCluster = {}): Clust
433
435
  if (!POD_ID.test(podID)) {
434
436
  throw new Error(`HUB_POD_ID must be 1-128 letters, digits, '.', '_' or '-' (starting with a letter or digit), got ${JSON.stringify(podID)}`)
435
437
  }
436
- const cluster = { podID }
438
+ const rawTimeout = env.HUB_DATABASE_STATEMENT_TIMEOUT_MS?.trim()
439
+ if (rawTimeout && !/^\d{1,9}$/.test(rawTimeout)) {
440
+ throw new Error(`HUB_DATABASE_STATEMENT_TIMEOUT_MS must be a whole number of milliseconds (0 turns it off), got ${JSON.stringify(rawTimeout)}`)
441
+ }
442
+ const cluster = { podID, ...(rawTimeout ? { statementTimeoutMs: Number(rawTimeout) } : {}) }
437
443
  // Getters: printed as nothing, skipped by JSON.stringify.
438
444
  Object.defineProperty(cluster, "databaseURL", { get: () => databaseURL, enumerable: false })
439
445
  Object.defineProperty(cluster, "valkeyURL", { get: () => valkeyURL, enumerable: false })