@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,986 @@
1
+ // CON-05 event-driven Concierge turns, digests and quiet hours (contract: docs/plans/2026-11-01-concierge-contracts.md
2
+ // "Event-driven turns, digests, quiet hours (CON-05)"). It follows the workspace coordinator's batching model
3
+ // (src/central/workspaces/coordinator.ts, RP-07 #10, RF-05) as its own module, for one recipient per user: the
4
+ // user's CURRENT Concierge session, reached only through the Concierge port `send` (Delivery.send as app:concierge,
5
+ // admitted by the CON-04 inbound rule). Nothing is read through the Concierge's token.
6
+ //
7
+ // What the Concierge hears: the user's journal (AgentEvents.since, cursor in the ledger), filtered to the watchlist
8
+ // (watched roots; subagents roll up into their root) and classed:
9
+ // - priority: a blocker opened, a session in error, an ask to @me (a Decision) about a watched session, a launch
10
+ // ended failed/lost/needs_user/timeout → the next pass, a wake.
11
+ // - notable: a turn done (working → idle), a launch ended idle (goal reached) → a wake after 30 s quiet (2 min max).
12
+ // - routine: presence, resolved blockers, → working, machines, intents, pins → wake:false (admitted for its next
13
+ // turn, never starting one; RF-05) after 2 min quiet (15 min max).
14
+ // Routine and notable lines held when a wake goes out go along with it. Wakes are queued, never steered (`urgent` is
15
+ // never set): a steer would cut into a live voice turn.
16
+ //
17
+ // Lines carry hub IDs, enums and counts only (`! ses_… blocker:permission per_…`). Event values that are not a known
18
+ // enum, or an ID matching its own kind's pattern (ses_…, msg_…, dec_…, mch_…, l_<hex>, a harness blocker ID), are
19
+ // replaced ("other") or left out (an unmatched subject drops the line), never echoed: no titles, intents, prompts, message bodies,
20
+ // blocker summaries, Decision text or machine names, and no @names either (the directory derives them from titles, so
21
+ // they are title text). The Concierge reads details, names included, on its board, where untrusted text is framed. Each message starts `Concierge update b<seq> (attempt n) · board r<rev>`.
22
+ //
23
+ // Self-trigger guard: events about any of the Concierge's sessions (current and every past one, identity), messages
24
+ // from or to it (app:concierge, @@concierge, its sessions), and launches by app:concierge or any of its sessions are
25
+ // ignored, so its own
26
+ // turns and our deliveries never wake it.
27
+ //
28
+ // Cooldown (fold): a priority reason (same root + same blocker kind or state, or the same Decision/launch) it was
29
+ // woken about within foldMs (15 min) is routine. Budgets per user and rolling hour: 12 priority, 12 notable, 30
30
+ // routine; over budget priority falls to notable (its timing), notable to routine, routine waits. Each wake (and
31
+ // digest) is recorded through ConciergeEntitlements.record.
32
+ //
33
+ // Quiet hours (settings.quietHours, in their own zone): only priority lines wake, and only with quietAllowsUrgent;
34
+ // everything else is held and goes out as one wake when they end (digest class: no hourly budget). Digests
35
+ // (settings.digests, settings.timeZone; schedule.ts): one wake per entry per local day, `Concierge digest (<HH:MM>
36
+ // <zone>) …: <counts since the last digest>`, carrying what is held; a digest due in quiet hours goes out when they
37
+ // end. Quiet hours hold retries and stale-claim resends too: a pending batch that is not priority-with-
38
+ // quietAllowsUrgent is put back (its lines, snapshot and digest), and the release says it carries that batch again
39
+ // ("Includes the updates of b<n>, which may already have arrived"). Idle costs nothing: no watched activity and no digest → no send, no context push.
40
+ //
41
+ // Not running: while the state is not `running` nothing is sent; lines are held up to bufferLines (200), beyond
42
+ // that the next batch is a snapshot ("board r<rev> is current; N changes were missed"). A journal reset (cursor no
43
+ // longer covered) is a priority snapshot (quiet hours apply) that cannot count: "some changes were missed; read your
44
+ // board". Disabled: nothing is sent;
45
+ // the ledger is deleted (disabled(), and any pass that finds the Concierge disabled). A relaunched Concierge (a new
46
+ // session) learns again: the ledger's delivered facts and folds start over.
47
+ //
48
+ // Durability and leadership: one ledger per user in the StateStore (collection `concierge-notifier`, key `ledger`:
49
+ // cursor, held lines, delivered facts, alerts, pending batch, attempts, budget window, digest keys). Passes run on
50
+ // the `concierge` leader; isLeader is checked before each user and each send; every write is version-checked. A
51
+ // batch is stored before it is sent and claimed first; a claim unrecorded after claimGraceMs is resent with the SAME
52
+ // batch ID (at-least-once: the text says the attempt and asks to skip a batch already handled); refusals that store
53
+ // nothing are retried; after maxAttempts the batch is abandoned and the next one is a snapshot. A journal position
54
+ // the log no longer covers (expired, standalone restart) is a reset: the next batch is a snapshot.
55
+ //
56
+ // observe(): every journal append on this pod; cheap (a Set lookup) and never throws: on the leader a priority-type
57
+ // append (a blocker, an ask, a launch, a session in error) schedules one early pass (observeDelayMs) instead of
58
+ // waiting for the tick, and a turn done schedules one when its 30 s quiet window ends. After each pass, held notable
59
+ // lines arm a pass for when they come due, so a lone turn done wakes ~30 s after it, not on the tick after that.
60
+ //
61
+ // Timing (CON-09a): a wake's quiet and max windows count from its own lines' event times (Line.t), so routine churn
62
+ // of other watched sessions (which goes along) never holds a turn done back; and a turn done is always news, even
63
+ // when the last state told was idle (the routine working line in between was replaced while held, so a second short
64
+ // turn of a session used to be dropped). A line replacing held ones of its subject keeps the earliest undelivered turn
65
+ // done (Line.t0), so a session flipping faster than the quiet window still wakes once its max window ends (a routine
66
+ // line carrying it is then that turn done). Passes are armed only for a FUTURE due: lines held by quiet hours or a
67
+ // spent budget wait for the tick (no 0 ms timer loop).
68
+ import { CONCIERGE_PARTY, CONCIERGE_ROLE, type ConciergeSettings } from "../../../packages/protocol/src/concierge"
69
+ import { AgentError } from "../agent/errors"
70
+ import type { AgentEvent, AgentEvents, EventRecord, EventsSince } from "../agent/events"
71
+ import { updateVersioned } from "../agent/versioned"
72
+ import { realTimers } from "../backend/memory-bus"
73
+ import type { Clock, StateStore, Timers } from "../backend/types"
74
+ import type { ConciergeNotifier, ConciergePorts } from "./index"
75
+ import { localStamp } from "./board-render"
76
+ import { DIGEST_GRACE_MS, dueDigests, inQuietHours, zoneOrUTC } from "./schedule"
77
+ import { resolveWatchlist, rootsOf } from "./watchlist"
78
+ import { type WatchFactDeps, watchFacts, WatchlistStore } from "./watchlist-routes"
79
+
80
+ export const NOTIFIER_COLLECTION = "concierge-notifier"
81
+ const KEY = "ledger"
82
+ /** The leader's pass interval (digests have minute resolution). */
83
+ export const NOTIFIER_TICK_MS = 30_000
84
+ const HOUR_MS = 3_600_000
85
+ const DAY_MS = 86_400_000
86
+
87
+ export type LineClass = "priority" | "notable" | "routine"
88
+
89
+ export interface NotifierLimits {
90
+ readonly priorityPerHour: number
91
+ readonly notablePerHour: number
92
+ readonly routinePerHour: number
93
+ readonly notableQuietMs: number
94
+ readonly notableMaxMs: number
95
+ readonly routineQuietMs: number
96
+ readonly routineMaxMs: number
97
+ /** A priority reason woken about this recently is routine (RF-05). */
98
+ readonly foldMs: number
99
+ /** Delivered facts and alerts kept. */
100
+ readonly factsMax: number
101
+ /** Lines held; beyond it the next batch is a snapshot. */
102
+ readonly bufferLines: number
103
+ readonly linesPerBatch: number
104
+ readonly maxAttempts: number
105
+ readonly retryMs: readonly number[]
106
+ readonly sendTimeoutMs: number
107
+ readonly claimGraceMs: number
108
+ /** The board push before a wake (ConciergePorts.beforeWake) is awaited at most this long. */
109
+ readonly beforeWakeMs: number
110
+ /** A priority-type append on the leader runs a pass this soon. */
111
+ readonly observeDelayMs: number
112
+ readonly digestGraceMs: number
113
+ readonly historyMax: number
114
+ }
115
+
116
+ export const NOTIFIER_LIMITS: NotifierLimits = {
117
+ priorityPerHour: 12,
118
+ notablePerHour: 12,
119
+ routinePerHour: 30,
120
+ notableQuietMs: 30_000,
121
+ notableMaxMs: 2 * 60_000,
122
+ routineQuietMs: 2 * 60_000,
123
+ routineMaxMs: 15 * 60_000,
124
+ foldMs: 15 * 60_000,
125
+ factsMax: 400,
126
+ bufferLines: 200,
127
+ linesPerBatch: 40,
128
+ maxAttempts: 6,
129
+ retryMs: [30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000],
130
+ sendTimeoutMs: 60_000,
131
+ claimGraceMs: 2 * 60_000,
132
+ beforeWakeMs: 2_000,
133
+ observeDelayMs: 1_000,
134
+ digestGraceMs: DIGEST_GRACE_MS,
135
+ historyMax: 20,
136
+ }
137
+
138
+ /** What the notifier knows about the watchlist at a pass: watched roots and each session's root. */
139
+ export interface WatchedView {
140
+ readonly watched: ReadonlySet<string>
141
+ readonly roots: ReadonlyMap<string, string>
142
+ }
143
+
144
+ /** The notifier's own hub ports (journal, ledger store, watchlist, board revision, leadership, clock). */
145
+ export interface NotifierJournal {
146
+ readonly events: Pick<AgentEvents, "since" | "latest">
147
+ readonly state: StateStore
148
+ readonly watched: (userID: string, hidden: readonly string[]) => Promise<WatchedView>
149
+ /** The board revision the Concierge has (after beforeWake); absent or failing: "r?". */
150
+ readonly boardRevision?: (userID: string, hidden: readonly string[]) => Promise<number>
151
+ /** The Decision an ask to @me opened (its ID and what it is about). */
152
+ readonly decisionOf?: (userID: string, messageID: string) => Promise<{ readonly id: string; readonly about: readonly string[] } | undefined>
153
+ readonly isLeader?: () => boolean
154
+ readonly owner?: string
155
+ readonly now?: Clock
156
+ readonly timers?: Timers
157
+ readonly tickMs?: number
158
+ readonly limits?: Partial<NotifierLimits>
159
+ readonly log?: (message: string) => void
160
+ }
161
+
162
+ // ---- Classing ---------------------------------------------------------------------------------------------
163
+
164
+ export interface Line {
165
+ readonly c: LineClass
166
+ readonly text: string
167
+ /** A state line's subject (later lines replace it while held) and the state it reports. */
168
+ readonly k?: string
169
+ readonly f?: string
170
+ /** Reported once: dropped when the Concierge already has this fact (an open Decision, an ended launch). */
171
+ readonly o?: true
172
+ /** Priority reasons (fold keys). */
173
+ readonly b?: readonly string[]
174
+ /** When its event happened (the journal's time): a wake's quiet and max windows count from its own lines. */
175
+ readonly t?: number
176
+ /**
177
+ * When the subject's first notable (or priority) line still held was: a state line that replaced held ones keeps
178
+ * it, so a session flipping faster than the quiet window still wakes within the max window.
179
+ */
180
+ readonly t0?: number
181
+ }
182
+
183
+ export interface ClassifyContext {
184
+ /** The Concierge's sessions, current and past. */
185
+ readonly hidden: ReadonlySet<string>
186
+ readonly view: WatchedView
187
+ /** For an ask to @me: the Decision it opened. */
188
+ readonly decision?: { readonly id: string; readonly about: readonly string[] }
189
+ }
190
+
191
+ /** Hub and harness IDs by kind: a value that does not match its own kind's pattern never reaches a line. */
192
+ const IDS = {
193
+ session: /^ses_[A-Za-z0-9]{1,64}$/,
194
+ message: /^msg_[A-Za-z0-9]{1,40}$/,
195
+ decision: /^dec_[A-Za-z0-9]{1,40}$/,
196
+ machine: /^mch_[A-Za-z0-9]{1,64}$/,
197
+ launch: /^l_[0-9a-f]{16}$/,
198
+ /** Harness blocker IDs (OpenCode per_…, que_…): a short lowercase prefix. */
199
+ blocker: /^[a-z]{2,4}_[A-Za-z0-9]{1,64}$/,
200
+ } as const
201
+ const BLOCKER_KINDS: ReadonlySet<unknown> = new Set(["permission", "question", "form"])
202
+ const STATES: ReadonlySet<unknown> = new Set(["idle", "working", "blocked", "error", "offline"])
203
+ const INTENT_SOURCES: ReadonlySet<unknown> = new Set(["user", "launch", "self", "prompt", "title"])
204
+ const LAUNCH_PRIORITY: ReadonlySet<unknown> = new Set(["failed", "lost", "needs_user", "timeout"])
205
+ const CONCIERGE_ADDRESS = `@@${CONCIERGE_ROLE}`
206
+
207
+ const idOf = (kind: keyof typeof IDS, v: unknown): string | undefined => (typeof v === "string" && IDS[kind].test(v) ? v : undefined)
208
+ const enumOf = (v: unknown, known: ReadonlySet<unknown>): string => (known.has(v) ? (v as string) : "other")
209
+ const MARK: Record<LineClass, string> = { priority: "!", notable: "+", routine: "-" }
210
+
211
+ /** Whether the record is the Concierge's own doing or about it (any of its sessions, its party, its role). */
212
+ function selfTouched(record: Pick<EventRecord, "event" | "audience">, hidden: ReadonlySet<string>): boolean {
213
+ const s = record.event.subject ?? {}
214
+ const d = record.event.data ?? {}
215
+ if ((s.sessionID && hidden.has(s.sessionID)) || (s.senderID && hidden.has(s.senderID))) return true
216
+ const audience = record.audience
217
+ const by = audience?.by
218
+ // Launches by the Concierge's party or by any of its sessions (however the launcher is written).
219
+ if (by === CONCIERGE_PARTY || (by !== undefined && (hidden.has(by) || (by.startsWith("ses:") && hidden.has(by.slice(4)))))) return true
220
+ for (const party of audience?.parties ?? []) if (party === CONCIERGE_PARTY || (party.startsWith("ses:") && hidden.has(party.slice(4)))) return true
221
+ for (const v of [d.from, d.to]) {
222
+ if (typeof v !== "string") continue
223
+ if (v === CONCIERGE_PARTY || v.startsWith(CONCIERGE_ADDRESS) || hidden.has(v)) return true
224
+ }
225
+ return false
226
+ }
227
+
228
+ /** A watched session as lines name it (`<root>[ (sub <id>)]`), or undefined when not watched. */
229
+ function whoOf(sessionID: unknown, ctx: ClassifyContext): { root: string; id: string; who: string } | undefined {
230
+ const id = idOf("session", sessionID)
231
+ if (!id) return undefined
232
+ const root = ctx.view.roots.get(id) ?? id
233
+ if (!IDS.session.test(root) || ctx.hidden.has(root) || !ctx.view.watched.has(root)) return undefined
234
+ return { root, id, who: `${root}${id !== root ? ` (sub ${id})` : ""}` }
235
+ }
236
+
237
+ const line = (c: LineClass, text: string, extra: Omit<Line, "c" | "text"> = {}): Line => ({ c, text: `${MARK[c]} ${text}`, ...extra })
238
+
239
+ function classify(record: Pick<EventRecord, "event" | "family" | "audience">, ctx: ClassifyContext): Line | undefined {
240
+ const event = record?.event
241
+ if (!event || typeof event.type !== "string" || !event.subject || !event.data) return undefined
242
+ if (selfTouched(record, ctx.hidden)) return undefined
243
+ const s = event.subject
244
+ const d = event.data
245
+ switch (event.type) {
246
+ case "machine.changed": {
247
+ const id = idOf("machine", s.machineID)
248
+ const online = d.online !== false
249
+ return id ? line("routine", `machine ${id} ${online ? "online" : "offline"}`, { k: `m:${id}`, f: online ? "online" : "offline" }) : undefined
250
+ }
251
+ case "message.changed": {
252
+ if (d.kind !== "ask" || d.state !== "open" || d.to !== "@me") return undefined
253
+ const messageID = idOf("message", s.messageID)
254
+ if (!messageID) return undefined
255
+ const about = ctx.decision ? ctx.decision.about : s.senderID ? [s.senderID] : []
256
+ const target = about.map((id) => whoOf(id, ctx)).find((w) => w !== undefined)
257
+ if (!target) return undefined
258
+ const id = idOf("decision", ctx.decision?.id) ?? messageID
259
+ return line("priority", `${target.who} decision ${id}`, { k: `d:${messageID}`, f: "open", o: true, b: [`${target.root} decision:${id}`] })
260
+ }
261
+ case "launch.changed": {
262
+ if (d.state !== "ended") return undefined
263
+ const target = whoOf(s.sessionID, ctx)
264
+ const launchID = idOf("launch", s.launchID)
265
+ if (!target || !launchID) return undefined
266
+ const outcome = d.outcome
267
+ const once = { k: `l:${launchID}`, f: String(outcome), o: true as const }
268
+ if (LAUNCH_PRIORITY.has(outcome)) return line("priority", `${target.who} launch ${launchID} ended:${outcome}`, { ...once, b: [`${target.root} launch:${outcome}`] })
269
+ if (outcome === "idle") return line("notable", `${target.who} launch ${launchID} ended:idle`, once)
270
+ return undefined
271
+ }
272
+ }
273
+ const target = whoOf(s.sessionID, ctx)
274
+ if (!target) return undefined
275
+ const { who, root, id } = target
276
+ switch (event.type) {
277
+ case "blocker.opened": {
278
+ const kind = enumOf(d.kind, BLOCKER_KINDS)
279
+ const blockerID = idOf("blocker", d.blockerID)
280
+ // (CON-13) The fold key names the request: a NEW permission or form on a session woken about minutes ago is
281
+ // news (it waited ~60–75 s for the Concierge's next turn as routine); only the same request again folds.
282
+ return line("priority", `${who} blocker:${kind}${blockerID ? ` ${blockerID}` : ""}`, { b: [`${root} blocker:${kind}${blockerID ? `:${blockerID}` : ""}`] })
283
+ }
284
+ case "blocker.closed": {
285
+ const blockerID = idOf("blocker", d.blockerID)
286
+ return line("routine", `${who} blocker_closed${blockerID ? ` ${blockerID}` : ""}`)
287
+ }
288
+ case "session.changed": {
289
+ const state = enumOf(d.state, STATES)
290
+ const k = `s:${id}`
291
+ if (state === "error") return line("priority", `${who} state:error`, { k, f: state, b: [`${root} state:error`] })
292
+ if (state === "idle" && d.prev === "working") return line("notable", `${who} turn_done`, { k, f: state })
293
+ return line("routine", `${who} state:${state}`, { k, f: state })
294
+ }
295
+ case "session.added":
296
+ case "session.removed": {
297
+ const presence = event.type === "session.added" ? "online" : "offline"
298
+ return line("routine", `${who} ${presence}`, { k: `p:${id}`, f: presence })
299
+ }
300
+ case "session.intent": {
301
+ const revision = Number.isSafeInteger(d.revision) ? ` r${d.revision}` : ""
302
+ const source = enumOf(d.source, INTENT_SOURCES)
303
+ // (CON-11) Source "title": no intent is left (the user cleared it), so the session shows its title again. That
304
+ // is not a title change: say so (the Concierge read "intent:title" as "its title changed").
305
+ if (source === "title") return line("routine", `${who} intent_cleared${revision}`)
306
+ return line("routine", `${who} intent:${source}${revision}`)
307
+ }
308
+ case "session.pinned": {
309
+ const pinned = d.pinned === true ? "pinned" : "unpinned"
310
+ return line("routine", `${who} ${pinned}`, { k: `pin:${id}`, f: pinned })
311
+ }
312
+ default:
313
+ return undefined
314
+ }
315
+ }
316
+
317
+ /** The line a journal record is for the Concierge, or undefined (not watched, its own doing, not news). Never throws. */
318
+ export function classifyEvent(record: Pick<EventRecord, "event" | "family" | "audience">, ctx: ClassifyContext): Line | undefined {
319
+ try {
320
+ return classify(record, ctx)
321
+ } catch {
322
+ return undefined
323
+ }
324
+ }
325
+
326
+ /** Journal types the notifier reads (others are skipped before any lookup). */
327
+ const RELEVANT: ReadonlySet<string> = new Set([
328
+ "blocker.opened",
329
+ "blocker.closed",
330
+ "session.changed",
331
+ "session.added",
332
+ "session.removed",
333
+ "session.intent",
334
+ "session.pinned",
335
+ "machine.changed",
336
+ "message.changed",
337
+ "launch.changed",
338
+ ])
339
+ /** A routine state line carrying an undelivered turn done past the max window: notable, saying so. */
340
+ function overdueTurn(l: Line): Line {
341
+ const body = l.text.slice(2)
342
+ const m = / state:([a-z]+)$/.exec(body)
343
+ return { ...l, c: "notable", text: `${MARK.notable} ${m ? `${body.slice(0, m.index)} turn_done (now ${m[1]})` : body}` }
344
+ }
345
+
346
+ /** Types that may be priority: on the leader, an append of one runs an early pass. */
347
+ const EARLY: ReadonlySet<string> = new Set(["blocker.opened", "message.changed", "launch.changed"])
348
+
349
+ /** Whether an append may be a priority line (an early pass): EARLY types, and a session going into error. */
350
+ const early = (event: AgentEvent): boolean => EARLY.has(event.type) || (event.type === "session.changed" && event.data?.state === "error")
351
+ /** Whether an append may be a notable line (a turn done): a pass when its quiet window ends. */
352
+ const notable = (event: AgentEvent): boolean => event.type === "session.changed" && event.data?.state === "idle" && event.data?.prev === "working"
353
+
354
+ // ---- Watchlist port (hub wiring) --------------------------------------------------------------------------
355
+
356
+ export type WatchedDeps = WatchFactDeps & { readonly state: StateStore }
357
+
358
+ /** The watched view for a pass: the resolved watchlist (CON-01) and every session's root. */
359
+ export async function watchedSessions(deps: WatchedDeps, userID: string, hidden: readonly string[]): Promise<WatchedView> {
360
+ const [config, facts] = await Promise.all([new WatchlistStore(deps.state).get(userID), watchFacts(deps, userID, hidden)])
361
+ return { watched: new Set(resolveWatchlist(config, facts).entries.map((e) => e.sessionID)), roots: rootsOf(facts.sessions) }
362
+ }
363
+
364
+ // ---- Ledger -----------------------------------------------------------------------------------------------
365
+
366
+ type Counts = { priority: number; notable: number; routine: number }
367
+ type SnapshotReason = "reset" | "overflow" | "abandoned"
368
+ type BatchKind = "delta" | "snapshot" | "release" | "digest"
369
+ /** The budget a batch uses: an hourly class, or "digest" (digests and quiet-hours releases: none). */
370
+ type Budget = LineClass | "digest"
371
+
372
+ export interface NotifierBatch {
373
+ readonly id: string
374
+ readonly seq: number
375
+ readonly kind: BatchKind
376
+ readonly budget: Budget
377
+ readonly wake: boolean
378
+ readonly lines: readonly string[]
379
+ readonly more: number
380
+ /** Lines listed per class (the header). */
381
+ readonly counts: Counts
382
+ readonly facts?: Readonly<Record<string, string>>
383
+ readonly alerts?: readonly string[]
384
+ /** A snapshot (or a digest/release carrying one): why, and how many changes were missed. */
385
+ readonly snapshot?: { readonly reason: SnapshotReason; readonly missed: number; readonly unknown?: true }
386
+ readonly digest?: { readonly key?: string; readonly label: string; readonly zone: string; readonly since: number; readonly counts: Counts }
387
+ /** The lines it lists (so a batch deferred by quiet hours can be held again). */
388
+ readonly held?: readonly Line[]
389
+ /** Earlier batches whose lines it carries again (deferred after a send that may have landed). */
390
+ readonly supersedes?: readonly string[]
391
+ readonly cutAt: number
392
+ readonly state: "ready" | "sending" | "retry"
393
+ readonly attempts: number
394
+ readonly nextAt?: number
395
+ readonly claimedBy?: string
396
+ readonly claimedAt?: number
397
+ readonly last?: "uncertain" | "refused"
398
+ }
399
+
400
+ export interface NotifierLedger {
401
+ readonly v: 1
402
+ /** The Concierge session the delivered facts were told to. */
403
+ readonly sessionID?: string
404
+ /** Journal position scanned up to. */
405
+ readonly position: string
406
+ readonly buffer: readonly Line[]
407
+ readonly firstAt?: number
408
+ readonly lastAt?: number
409
+ /** `unknown`: how many were missed is not known (a journal reset). */
410
+ readonly snapshot?: { readonly reason: SnapshotReason; readonly missed: number; readonly unknown?: true; readonly urgent?: true }
411
+ /** Batches deferred by quiet hours after a send attempt (the next batch says it carries their lines). */
412
+ readonly supersedes?: readonly string[]
413
+ /** Lines were held by quiet hours: their end releases one wake. */
414
+ readonly quietHeld?: true
415
+ readonly batch?: NotifierBatch
416
+ readonly window: readonly { readonly at: number; readonly budget: LineClass }[]
417
+ readonly seq: number
418
+ readonly facts: Readonly<Record<string, string>>
419
+ readonly alerts: Readonly<Record<string, number>>
420
+ /** Digest keys (digest:<date>:<i>) → when they came due. */
421
+ readonly digests: Readonly<Record<string, number>>
422
+ readonly owedDigest?: { readonly key: string; readonly label: string; readonly at: number }
423
+ /** Classed lines since the last digest. */
424
+ readonly counts: Counts
425
+ readonly countsSince: number
426
+ readonly history: readonly { readonly id: string; readonly kind: BatchKind; readonly at: number; readonly attempts: number; readonly outcome: "delivered" | "abandoned"; readonly messageID?: string }[]
427
+ }
428
+
429
+ const ZERO: Counts = { priority: 0, notable: 0, routine: 0 }
430
+ /** Refusals that store nothing: retrying cannot duplicate. Anything else may have stored the message. */
431
+ const REFUSED: ReadonlySet<string> = new Set(["rate_limited", "not_found", "unsupported", "forbidden", "invalid", "gone", "conflict"])
432
+
433
+ const without = <T extends object, K extends keyof T>(value: T, ...keys: K[]): Omit<T, K> => {
434
+ const out = { ...value }
435
+ for (const key of keys) delete out[key]
436
+ return out
437
+ }
438
+
439
+ /** Keeps the newest `max` entries of a record (insertion order). */
440
+ function bounded<V>(record: Record<string, V>, max: number): Record<string, V> {
441
+ const keys = Object.keys(record)
442
+ for (const key of keys.slice(0, Math.max(0, keys.length - max))) delete record[key]
443
+ return record
444
+ }
445
+
446
+ function stamp(at: number): string {
447
+ const d = new Date(at)
448
+ const pad = (n: number) => String(n).padStart(2, "0")
449
+ return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}Z`
450
+ }
451
+
452
+ const countsText = (c: Counts, all = false) =>
453
+ (["priority", "notable", "routine"] as const)
454
+ .filter((k) => all || c[k] > 0)
455
+ .map((k) => `${c[k]} ${k}`)
456
+ .join(", ")
457
+
458
+ const SNAPSHOT_WHY: Record<SnapshotReason, string> = {
459
+ reset: "the event journal no longer covers the last position",
460
+ overflow: "too many updates were held",
461
+ abandoned: "an earlier update could not be delivered",
462
+ }
463
+
464
+ /** One wake/queued message's text: IDs, enums and counts only. */
465
+ export function renderBatch(batch: NotifierBatch, revision: number | undefined): string {
466
+ const board = `board r${revision ?? "?"}`
467
+ const attempt = `b${batch.seq} (attempt ${batch.attempts})`
468
+ const auto = "automated by the hub, not the user"
469
+ const missed = batch.snapshot
470
+ ? batch.snapshot.unknown
471
+ ? `${board} is current; some changes were missed (${SNAPSHOT_WHY[batch.snapshot.reason]}); read your board`
472
+ : `${board} is current; ${batch.snapshot.missed} changes were missed (${SNAPSHOT_WHY[batch.snapshot.reason]})`
473
+ : undefined
474
+ const again = batch.supersedes?.length ? `Includes the updates of ${batch.supersedes.join(", ")}, which may already have arrived: skip what you already handled.` : undefined
475
+ let head: string
476
+ if (batch.kind === "digest") {
477
+ const d = batch.digest!
478
+ // (CON-10) "since" in the digest's zone (the user's), never a UTC "Z" time.
479
+ head = `Concierge digest (${d.label} ${d.zone}) ${attempt} · ${board} · since ${localStamp(d.since, d.zone) ?? stamp(d.since)}: ${countsText(d.counts, true)}`
480
+ } else if (batch.kind === "release") {
481
+ head = `Concierge update ${attempt} · ${board} · quiet hours ended: ${batch.lines.length + batch.more} held updates · ${auto}`
482
+ } else if (batch.kind === "snapshot") {
483
+ head = `Concierge update ${attempt} · ${missed} · ${auto}`
484
+ } else {
485
+ head = `Concierge update ${attempt} · ${board} · ${countsText(batch.counts)} · ${auto}`
486
+ }
487
+ const footer = [
488
+ `IDs and states only: details are on your board (hub concierge_board). If you already handled b${batch.seq}, skip it.`,
489
+ ...(batch.wake ? [] : ["Routine: queued for your next turn; no turn was started for it."]),
490
+ ...(batch.kind === "digest" ? ["Summarize for the user if it helps; msg @me only when something needs them."] : []),
491
+ ].join(" ")
492
+ return [
493
+ head,
494
+ ...(missed && batch.kind !== "snapshot" ? [missed] : []),
495
+ ...(again ? [again] : []),
496
+ ...batch.lines,
497
+ ...(batch.more ? [`… ${batch.more} more updates not listed`] : []),
498
+ footer,
499
+ ].join("\n")
500
+ }
501
+
502
+ // ---- The notifier -----------------------------------------------------------------------------------------
503
+
504
+ /** The run-on-the-leader notifier (CONCIERGE_SLOTS.notifier). Inert without `ports.journal`. */
505
+ export class ConciergeNotifierImpl implements ConciergeNotifier {
506
+ readonly limits: NotifierLimits
507
+ private readonly ports: ConciergePorts
508
+ private readonly journal: NotifierJournal | undefined
509
+ private readonly now: Clock
510
+ private readonly timers: Timers
511
+ private readonly owner: string
512
+ private readonly log: (message: string) => void
513
+ private running: Promise<void> | undefined
514
+ private timer: unknown
515
+ private soon: unknown
516
+ /** A pass when held notable lines come due (their quiet or max window ends), sooner than the next tick. */
517
+ private due: unknown
518
+ private dueAt: number | undefined
519
+ private started = false
520
+ private stopped = false
521
+
522
+ constructor(ports: ConciergePorts) {
523
+ this.ports = ports
524
+ this.journal = ports.journal
525
+ this.limits = { ...NOTIFIER_LIMITS, ...this.journal?.limits }
526
+ this.now = this.journal?.now ?? Date.now
527
+ this.timers = this.journal?.timers ?? realTimers
528
+ this.owner = this.journal?.owner ?? `concierge-notifier-${process.pid}`
529
+ this.log = this.journal?.log ?? ((message) => console.error(message))
530
+ }
531
+
532
+ start(): void {
533
+ this.stopped = false
534
+ if (this.started || !this.journal) return
535
+ this.started = true
536
+ this.schedule()
537
+ }
538
+
539
+ stop(): void {
540
+ this.stopped = true
541
+ this.started = false
542
+ if (this.timer !== undefined) this.timers.clear(this.timer)
543
+ if (this.soon !== undefined) this.timers.clear(this.soon)
544
+ if (this.due !== undefined) this.timers.clear(this.due)
545
+ this.timer = this.soon = this.due = this.dueAt = undefined
546
+ }
547
+
548
+ observe(userID: string, event: AgentEvent): void {
549
+ try {
550
+ if (!this.started || typeof userID !== "string" || typeof event?.type !== "string") return
551
+ if (early(event)) {
552
+ if (this.soon !== undefined) return
553
+ this.soon = this.timers.set(() => {
554
+ this.soon = undefined
555
+ this.passSoon()
556
+ }, this.limits.observeDelayMs)
557
+ } else if (notable(event)) this.armDue(this.now() + this.limits.notableQuietMs + this.limits.observeDelayMs)
558
+ } catch {
559
+ // Never throws (called on every journal append, never awaited).
560
+ }
561
+ }
562
+
563
+ async disabled(userID: string): Promise<void> {
564
+ try {
565
+ await this.journal?.state.delete(userID, NOTIFIER_COLLECTION, KEY)
566
+ } catch (cause) {
567
+ this.log(`[hub] concierge notifier: ledger delete failed: ${(cause as Error).message}`)
568
+ }
569
+ }
570
+
571
+ /** One pass over every user (passes never overlap). */
572
+ tick(): Promise<void> {
573
+ if (!this.journal) return Promise.resolve()
574
+ this.running ??= this.pass().finally(() => {
575
+ this.running = undefined
576
+ })
577
+ return this.running
578
+ }
579
+
580
+ /** The user's ledger (tests, diagnostics). */
581
+ async ledger(userID: string): Promise<NotifierLedger | undefined> {
582
+ return (await this.journal?.state.get<NotifierLedger>(userID, NOTIFIER_COLLECTION, KEY))?.value
583
+ }
584
+
585
+ // ---------- internals ----------
586
+
587
+ /** A pass now, after the one running (if any). */
588
+ private passSoon(): void {
589
+ const after = this.running ? this.running.then(() => this.tick()) : this.tick()
590
+ void after.catch(() => undefined)
591
+ }
592
+
593
+ /** One pass at `at` (on the leader loop only), unless one is already due by then. */
594
+ private armDue(at: number): void {
595
+ if (!this.started || at <= this.now() || (this.dueAt !== undefined && this.dueAt <= at)) return
596
+ if (this.due !== undefined) this.timers.clear(this.due)
597
+ this.dueAt = at
598
+ this.due = this.timers.set(() => {
599
+ this.due = this.dueAt = undefined
600
+ this.passSoon()
601
+ }, Math.max(0, at - this.now()))
602
+ }
603
+
604
+ /** When held notable (or demoted priority) lines come due: their quiet window ends, or their max one. */
605
+ private notableDue(ledger: NotifierLedger): number | undefined {
606
+ const timed = ledger.buffer.filter((l) => l.c !== "routine")
607
+ const carried = ledger.buffer.flatMap((l) => (l.c === "routine" && l.t0 !== undefined ? [l.t0] : []))
608
+ if (!timed.length && !carried.length) return undefined
609
+ const firsts = [...timed.map((l) => l.t0 ?? l.t ?? ledger.firstAt ?? this.now()), ...carried]
610
+ const max = Math.min(...firsts) + this.limits.notableMaxMs
611
+ if (!timed.length) return max
612
+ const times = timed.map((l) => l.t ?? ledger.lastAt ?? this.now())
613
+ return Math.min(Math.max(...times) + this.limits.notableQuietMs, max)
614
+ }
615
+
616
+ private leading(): boolean {
617
+ return !this.stopped && (this.journal?.isLeader?.() ?? true)
618
+ }
619
+
620
+ private schedule(): void {
621
+ this.timer = this.timers.set(() => {
622
+ this.timer = undefined
623
+ void this.tick().finally(() => {
624
+ if (this.started) this.schedule()
625
+ })
626
+ }, this.journal?.tickMs ?? NOTIFIER_TICK_MS)
627
+ }
628
+
629
+ private async pass(): Promise<void> {
630
+ if (!this.leading()) return
631
+ let users: string[]
632
+ try {
633
+ users = [...(await this.ports.users())]
634
+ } catch (cause) {
635
+ this.log(`[hub] concierge notifier: users failed: ${(cause as Error).message}`)
636
+ return
637
+ }
638
+ for (const userID of users) {
639
+ if (!this.leading()) return
640
+ try {
641
+ await this.user(userID)
642
+ } catch (cause) {
643
+ this.log(`[hub] concierge notifier: pass failed: ${(cause as Error).message}`)
644
+ }
645
+ }
646
+ }
647
+
648
+ private fresh(position: string, t: number, sessionID: string | undefined): NotifierLedger {
649
+ return { v: 1, ...(sessionID ? { sessionID } : {}), position, buffer: [], window: [], seq: 0, facts: {}, alerts: {}, digests: {}, counts: { ...ZERO }, countsSince: t, history: [] }
650
+ }
651
+
652
+ private async user(userID: string): Promise<void> {
653
+ const journal = this.journal!
654
+ const [settings, status] = await Promise.all([this.ports.settings(userID), this.ports.status(userID)])
655
+ if (!settings.enabled || status.state === "disabled") {
656
+ // Nothing to a disabled Concierge, and nothing kept for it.
657
+ if (await journal.state.get(userID, NOTIFIER_COLLECTION, KEY)) await journal.state.delete(userID, NOTIFIER_COLLECTION, KEY)
658
+ return
659
+ }
660
+ const identity = await this.ports.identity.current(userID)
661
+ const t = this.now()
662
+ const latest = await journal.events.latest(userID)
663
+ const stored = await journal.state.get<NotifierLedger>(userID, NOTIFIER_COLLECTION, KEY)
664
+ let ledger = stored?.value ?? this.fresh(latest, t, identity.sessionID)
665
+ // Someone may be sending it right now (another pod before failover, or a pass in flight).
666
+ if (ledger.batch?.state === "sending" && (ledger.batch.claimedAt ?? 0) + this.limits.claimGraceMs > t) return
667
+ const running = status.state === "running" && identity.sessionID !== undefined
668
+ // A relaunched Concierge is a new session: it knows nothing the old one was told.
669
+ if (running && ledger.sessionID !== identity.sessionID) ledger = { ...ledger, sessionID: identity.sessionID, facts: {}, alerts: {} }
670
+ if (ledger.position !== latest) ledger = await this.scan(userID, ledger, identity.hiddenSessionIDs, t)
671
+ ledger = this.housekeeping(ledger, t)
672
+ ledger = this.digestsDue(ledger, settings, t)
673
+ // Quiet hours also hold retries and stale-claim resends: only a priority batch with quietAllowsUrgent goes now;
674
+ // any other is held again and goes with the release (or the digest) when they end.
675
+ if (ledger.batch && inQuietHours(settings.quietHours, t) && !(ledger.batch.budget === "priority" && settings.quietAllowsUrgent)) ledger = this.defer(ledger, ledger.batch)
676
+ if (!ledger.batch && running) ledger = this.cut(ledger, settings, t)
677
+ // Held notable lines: a pass when they come due, not on the next tick after that. Only a FUTURE due: held lines
678
+ // already due were just declined by cut (quiet hours, a spent budget), which only time or the tick changes.
679
+ if (running && !ledger.batch) {
680
+ const due = this.notableDue(ledger)
681
+ if (due !== undefined && due > t) this.armDue(due)
682
+ }
683
+
684
+ const batch = ledger.batch
685
+ const sendable = running && batch !== undefined && (batch.state === "ready" || batch.state === "sending" || (batch.state === "retry" && (batch.nextAt ?? 0) <= t))
686
+ let claimed: NotifierBatch | undefined
687
+ if (sendable) {
688
+ claimed = { ...batch, state: "sending", attempts: batch.attempts + 1, claimedBy: this.owner, claimedAt: t, ...(batch.state === "sending" ? { last: "uncertain" as const } : {}) }
689
+ ledger = { ...ledger, batch: claimed }
690
+ }
691
+ if (!this.leading()) return
692
+ if (!claimed && stored && JSON.stringify(stored.value) === JSON.stringify(ledger)) return
693
+ const put = await journal.state.put(userID, NOTIFIER_COLLECTION, KEY, ledger, { ifVersion: stored?.version ?? 0 })
694
+ if (!put.ok || !claimed) return
695
+ await this.attempt(userID, identity.hiddenSessionIDs, claimed)
696
+ }
697
+
698
+ /** Reads the journal after the ledger's position into its buffer (or owes a snapshot). */
699
+ private async scan(userID: string, ledger: NotifierLedger, hiddenIDs: readonly string[], t: number): Promise<NotifierLedger> {
700
+ const journal = this.journal!
701
+ const result: EventsSince = await journal.events.since(userID, ledger.position)
702
+ if ("reset" in result) {
703
+ const position = await journal.events.latest(userID)
704
+ // How much the journal dropped is unknown: a priority snapshot (quiet hours still apply) that says so.
705
+ const missed = (ledger.snapshot?.missed ?? 0) + ledger.buffer.length
706
+ return { ...without(ledger, "firstAt", "lastAt"), position, buffer: [], snapshot: { reason: "reset", missed, unknown: true, urgent: true } }
707
+ }
708
+ if (!result.records.length) return ledger
709
+ const hidden = new Set(hiddenIDs)
710
+ let view: WatchedView | undefined
711
+ let { snapshot, firstAt, lastAt } = ledger
712
+ let buffer: Line[] = [...ledger.buffer]
713
+ const counts = { ...ledger.counts }
714
+ for (const record of result.records) {
715
+ if (!RELEVANT.has(record.event.type)) continue
716
+ view ??= await journal.watched(userID, hiddenIDs)
717
+ let decision: ClassifyContext["decision"]
718
+ if (record.event.type === "message.changed" && record.event.data.kind === "ask" && record.event.data.to === "@me" && typeof record.event.subject.messageID === "string") {
719
+ decision = await journal.decisionOf?.(userID, record.event.subject.messageID).catch(() => undefined)
720
+ }
721
+ const raw = classifyEvent(record, { hidden, view, ...(decision ? { decision } : {}) })
722
+ if (!raw) continue
723
+ counts[raw.c]++
724
+ const held = this.fold(raw, ledger.alerts, t)
725
+ if (snapshot) {
726
+ snapshot = { ...snapshot, missed: snapshot.missed + 1, ...(held.c === "priority" ? { urgent: true as const } : {}) }
727
+ continue
728
+ }
729
+ if (held.o && buffer.some((l) => l.k === held.k && l.f === held.f)) continue
730
+ // A state line replaces the routine one held for the same subject, never a held priority line; it keeps the
731
+ // earliest notable time of what it replaced (the max window).
732
+ let t0: number | undefined
733
+ if (held.k !== undefined) {
734
+ for (const l of buffer) {
735
+ if (l.k !== held.k || l.c === "priority") continue
736
+ const first = l.t0 ?? (l.c !== "routine" ? l.t : undefined)
737
+ if (first !== undefined && (t0 === undefined || first < t0)) t0 = first
738
+ }
739
+ buffer = buffer.filter((l) => l.k !== held.k || l.c === "priority")
740
+ }
741
+ if (buffer.length >= this.limits.bufferLines) {
742
+ const urgent = held.c === "priority" || buffer.some((l) => l.c === "priority")
743
+ snapshot = { reason: "overflow", missed: buffer.length + 1, ...(urgent ? { urgent: true as const } : {}) }
744
+ buffer = []
745
+ firstAt = lastAt = undefined
746
+ continue
747
+ }
748
+ buffer.push({ ...held, t: record.event.at, ...(t0 !== undefined ? { t0 } : {}) })
749
+ firstAt ??= record.event.at
750
+ lastAt = record.event.at
751
+ }
752
+ const base = without(ledger, "firstAt", "lastAt", "snapshot")
753
+ return { ...base, position: result.records.at(-1)!.position, buffer, counts, ...(snapshot ? { snapshot } : {}), ...(buffer.length && firstAt !== undefined ? { firstAt, lastAt: lastAt! } : {}) }
754
+ }
755
+
756
+ /** A pending batch held again (quiet hours): its lines, snapshot and digest go back to the ledger, marked quiet-held. */
757
+ private defer(ledger: NotifierLedger, batch: NotifierBatch): NotifierLedger {
758
+ let buffer: Line[] = [...(batch.held ?? []), ...ledger.buffer]
759
+ let snapshot = ledger.snapshot
760
+ if (batch.snapshot) {
761
+ const unknown = batch.snapshot.unknown || snapshot?.unknown
762
+ const urgent = batch.budget === "priority" || snapshot?.urgent
763
+ snapshot = { reason: batch.snapshot.reason, missed: batch.snapshot.missed + (snapshot?.missed ?? 0), ...(unknown ? { unknown: true as const } : {}), ...(urgent ? { urgent: true as const } : {}) }
764
+ }
765
+ if (buffer.length > this.limits.bufferLines) {
766
+ const urgent = snapshot?.urgent || buffer.some((l) => l.c === "priority")
767
+ snapshot = { reason: "overflow", missed: (snapshot?.missed ?? 0) + buffer.length, ...(snapshot?.unknown ? { unknown: true as const } : {}), ...(urgent ? { urgent: true as const } : {}) }
768
+ buffer = []
769
+ }
770
+ const supersedes = batch.attempts > 0 ? [...(ledger.supersedes ?? []), batch.id].slice(-10) : ledger.supersedes
771
+ const d = batch.digest
772
+ const digest = d
773
+ ? {
774
+ owedDigest: ledger.owedDigest ?? { key: d.key ?? `digest:${batch.id}`, label: d.label, at: batch.cutAt },
775
+ counts: { priority: d.counts.priority + ledger.counts.priority, notable: d.counts.notable + ledger.counts.notable, routine: d.counts.routine + ledger.counts.routine },
776
+ countsSince: d.since,
777
+ }
778
+ : {}
779
+ const at = { firstAt: ledger.firstAt ?? batch.cutAt, lastAt: ledger.lastAt ?? batch.cutAt }
780
+ return {
781
+ ...without(ledger, "batch", "snapshot", "firstAt", "lastAt"),
782
+ buffer,
783
+ ...(buffer.length ? at : {}),
784
+ ...(snapshot ? { snapshot } : {}),
785
+ ...(supersedes?.length ? { supersedes } : {}),
786
+ ...digest,
787
+ quietHeld: true,
788
+ }
789
+ }
790
+
791
+ /** A priority line about a reason it was woken about within foldMs: routine instead (RF-05). */
792
+ private fold(line: Line, alerts: Readonly<Record<string, number>>, t: number): Line {
793
+ if (line.c !== "priority" || !line.b?.length) return line
794
+ const known = line.b.every((key) => alerts[key] !== undefined && t - alerts[key]! < this.limits.foldMs)
795
+ return known ? { ...without(line, "b"), c: "routine", text: `-${line.text.slice(1)}` } : line
796
+ }
797
+
798
+ private housekeeping(ledger: NotifierLedger, t: number): NotifierLedger {
799
+ const alerts: Record<string, number> = {}
800
+ for (const [key, at] of Object.entries(ledger.alerts)) if (t - at < this.limits.foldMs) alerts[key] = at
801
+ const digests: Record<string, number> = {}
802
+ for (const [key, at] of Object.entries(ledger.digests)) if (t - at < 8 * DAY_MS) digests[key] = at
803
+ return { ...ledger, window: ledger.window.filter((w) => w.at > t - HOUR_MS), alerts, digests }
804
+ }
805
+
806
+ /** Marks each due digest (once per key) and owes the latest. */
807
+ private digestsDue(ledger: NotifierLedger, settings: ConciergeSettings, t: number): NotifierLedger {
808
+ let next = ledger
809
+ for (const due of dueDigests(settings.digests ?? [], settings.timeZone, t, this.limits.digestGraceMs)) {
810
+ if (next.digests[due.key] !== undefined) continue
811
+ next = { ...next, digests: { ...next.digests, [due.key]: t }, owedDigest: { key: due.key, label: due.label, at: due.occurredAt } }
812
+ }
813
+ return next
814
+ }
815
+
816
+ /** What the Concierge does not know yet: a state line repeating the last delivered state is dropped. */
817
+ private news(ledger: NotifierLedger): Line[] {
818
+ const known = ledger.facts
819
+ const urgentSubjects = new Set(ledger.buffer.flatMap((l) => (l.c === "priority" && l.k !== undefined ? [l.k] : [])))
820
+ return ledger.buffer.filter((l) => {
821
+ if (l.k === undefined) return true
822
+ if (l.o) return known[l.k] !== l.f
823
+ // It carries an undelivered turn done (t0): news even when its state is the one told last.
824
+ if (l.t0 !== undefined) return true
825
+ // A turn done is news even when the last state told was idle: the working in between was never told (a routine
826
+ // line it replaced while held).
827
+ return l.c === "priority" || l.c === "notable" || urgentSubjects.has(l.k) || known[l.k] !== l.f
828
+ })
829
+ }
830
+
831
+ /** Turns held lines, an owed snapshot or digest into the next batch, when due. */
832
+ private cut(ledger: NotifierLedger, settings: ConciergeSettings, t: number): NotifierLedger {
833
+ const L = this.limits
834
+ const used = (b: LineClass) => ledger.window.filter((w) => w.budget === b).length
835
+ const room: Record<LineClass, boolean> = { priority: used("priority") < L.priorityPerHour, notable: used("notable") < L.notablePerHour, routine: used("routine") < L.routinePerHour }
836
+ const quiet = inQuietHours(settings.quietHours, t)
837
+ // A state line that replaced an undelivered turn done (t0) past the max window is that turn done: notable.
838
+ const news = this.news(ledger).map((l) => (l.c === "routine" && l.t0 !== undefined && t - l.t0 >= L.notableMaxMs ? overdueTurn(l) : l))
839
+ const rest = { ...ledger, buffer: news, ...(news.length ? {} : { firstAt: undefined, lastAt: undefined }) } as NotifierLedger
840
+ const clean = (l: NotifierLedger) => (l.buffer.length ? l : (without(l, "firstAt", "lastAt") as NotifierLedger))
841
+
842
+ // A digest (not in quiet hours): everything held goes with it, quiet-hours releases included.
843
+ if (ledger.owedDigest && !quiet) {
844
+ const digest = { key: ledger.owedDigest.key, label: ledger.owedDigest.label, zone: zoneOrUTC(settings.timeZone), since: ledger.countsSince, counts: ledger.counts }
845
+ const next = this.compose({ ...rest, buffer: [] }, news, { kind: "digest", budget: "digest", wake: true, digest, ...(ledger.snapshot ? { snapshot: ledger.snapshot } : {}) }, t)
846
+ return clean({ ...without(next, "owedDigest", "quietHeld", "snapshot"), counts: { ...ZERO }, countsSince: t })
847
+ }
848
+ if (quiet) {
849
+ const priority = news.filter((l) => l.c === "priority")
850
+ const urgent = priority.length > 0 || ledger.snapshot?.urgent === true
851
+ const holding = news.length > 0 || ledger.snapshot !== undefined || ledger.owedDigest !== undefined
852
+ if (settings.quietAllowsUrgent && urgent && room.priority) {
853
+ const others = news.filter((l) => l.c !== "priority")
854
+ const next = this.compose({ ...rest, buffer: others }, priority, { kind: ledger.snapshot?.urgent ? "snapshot" : "delta", budget: "priority", wake: true, ...(ledger.snapshot?.urgent ? { snapshot: ledger.snapshot } : {}) }, t)
855
+ const cleared: NotifierLedger = ledger.snapshot?.urgent ? without(next, "snapshot") : next
856
+ return clean(others.length || cleared.snapshot || cleared.owedDigest ? { ...cleared, quietHeld: true } : cleared)
857
+ }
858
+ return clean(holding ? { ...rest, quietHeld: true } : rest)
859
+ }
860
+ if (ledger.quietHeld) {
861
+ if (!news.length && !ledger.snapshot) return clean(without(rest, "quietHeld"))
862
+ return clean(without(this.compose({ ...rest, buffer: [] }, news, { kind: "release", budget: "digest", wake: true, ...(ledger.snapshot ? { snapshot: ledger.snapshot } : {}) }, t), "quietHeld", "snapshot"))
863
+ }
864
+ if (ledger.snapshot) {
865
+ const urgent = ledger.snapshot.urgent === true
866
+ const budget: LineClass | undefined = urgent ? (room.priority ? "priority" : room.notable ? "notable" : room.routine ? "routine" : undefined) : room.routine ? "routine" : undefined
867
+ if (!budget) return clean(rest)
868
+ return clean(without(this.compose({ ...rest, buffer: [] }, [], { kind: "snapshot", budget, wake: budget !== "routine", snapshot: ledger.snapshot }, t), "snapshot"))
869
+ }
870
+ if (!news.length) return clean(rest)
871
+ const top: LineClass = news.some((l) => l.c === "priority") ? "priority" : news.some((l) => l.c === "notable") ? "notable" : "routine"
872
+ const order: LineClass[] = top === "priority" ? ["priority", "notable", "routine"] : top === "notable" ? ["notable", "routine"] : ["routine"]
873
+ const budget = order.find((b) => room[b])
874
+ if (!budget) return clean(rest)
875
+ if (budget !== "priority") {
876
+ const quietMs = budget === "notable" ? L.notableQuietMs : L.routineQuietMs
877
+ const maxMs = budget === "notable" ? L.notableMaxMs : L.routineMaxMs
878
+ // A wake's windows count from its own (notable and demoted priority) lines: routine churn of other sessions,
879
+ // which goes along, never holds it back.
880
+ const timed = budget === "notable" ? news.filter((l) => l.c !== "routine") : []
881
+ const last = timed.length ? Math.max(...timed.map((l) => l.t ?? ledger.lastAt ?? t)) : (ledger.lastAt ?? t)
882
+ const first = timed.length ? Math.min(...timed.map((l) => l.t0 ?? l.t ?? ledger.firstAt ?? t)) : (ledger.firstAt ?? t)
883
+ const quietEnough = t - last >= quietMs
884
+ const late = t - first >= maxMs
885
+ if (!quietEnough && !late) return clean(rest)
886
+ }
887
+ return clean(this.compose({ ...rest, buffer: [] }, news, { kind: "delta", budget, wake: budget !== "routine" }, t))
888
+ }
889
+
890
+ /** A batch of `lines` (priority first), with the ledger's owed snapshot noted; `ledger.buffer` is what stays. */
891
+ private compose(
892
+ ledger: NotifierLedger,
893
+ lines: readonly Line[],
894
+ options: { kind: BatchKind; budget: Budget; wake: boolean; digest?: NotifierBatch["digest"]; snapshot?: NotifierLedger["snapshot"] },
895
+ t: number,
896
+ ): NotifierLedger {
897
+ const rank: Record<LineClass, number> = { priority: 0, notable: 1, routine: 2 }
898
+ const ordered = [...lines].sort((a, b) => rank[a.c] - rank[b.c])
899
+ const listed = ordered.slice(0, this.limits.linesPerBatch)
900
+ const counts = { ...ZERO }
901
+ for (const l of listed) counts[l.c]++
902
+ const facts = Object.fromEntries(listed.flatMap((l) => (l.k !== undefined && l.f !== undefined ? [[l.k, l.f] as const] : [])))
903
+ const alerts = options.wake ? [...new Set(listed.flatMap((l) => (l.c === "priority" ? (l.b ?? []) : [])))] : []
904
+ const seq = ledger.seq + 1
905
+ const batch: NotifierBatch = {
906
+ id: `b${seq}`,
907
+ seq,
908
+ kind: options.kind,
909
+ budget: options.budget,
910
+ wake: options.wake,
911
+ lines: listed.map((l) => l.text),
912
+ more: ordered.length - listed.length,
913
+ counts,
914
+ facts,
915
+ ...(alerts.length ? { alerts } : {}),
916
+ ...(options.snapshot ? { snapshot: { reason: options.snapshot.reason, missed: options.snapshot.missed, ...(options.snapshot.unknown ? { unknown: true as const } : {}) } } : {}),
917
+ held: listed,
918
+ ...(ledger.supersedes?.length ? { supersedes: ledger.supersedes } : {}),
919
+ ...(options.digest ? { digest: options.digest } : {}),
920
+ cutAt: t,
921
+ state: "ready",
922
+ attempts: 0,
923
+ }
924
+ return { ...without(ledger, "supersedes"), seq, batch }
925
+ }
926
+
927
+ /** Sends a claimed batch and records the outcome (only if the ledger still holds that batch). */
928
+ private async attempt(userID: string, hidden: readonly string[], batch: NotifierBatch): Promise<void> {
929
+ if (!this.leading()) return // the claim stays: after claimGraceMs the next leader resends it
930
+ const journal = this.journal!
931
+ // The board is on the session before a wake (2 s budget); a failed push keeps the last one (its age shows).
932
+ if (batch.wake) await this.within(this.ports.beforeWake(userID), this.limits.beforeWakeMs).catch(() => false)
933
+ const revision = journal.boardRevision ? await this.within(journal.boardRevision(userID, hidden), this.limits.beforeWakeMs).catch(() => undefined) : undefined
934
+ const text = renderBatch(batch, revision)
935
+ let result: { messageID: string; woke?: boolean } | undefined
936
+ let failure: "uncertain" | "refused" | undefined
937
+ try {
938
+ if (!this.leading()) return
939
+ // Queued, never steered: no `urgent`.
940
+ result = await this.within(this.ports.send(userID, text, { wake: batch.wake }), this.limits.sendTimeoutMs)
941
+ } catch (cause) {
942
+ failure = cause instanceof AgentError && REFUSED.has(cause.code) ? "refused" : "uncertain"
943
+ this.log(`[hub] concierge notifier: ${batch.id} attempt ${batch.attempts} ${failure}: ${(cause as Error).message}`)
944
+ }
945
+ const t = this.now()
946
+ const L = this.limits
947
+ const recorded = await updateVersioned<NotifierLedger>(journal.state, userID, NOTIFIER_COLLECTION, KEY, (current) => {
948
+ if (!current?.batch || current.batch.id !== batch.id) return undefined // revoked (disabled) meanwhile
949
+ const window = failure === "refused" || batch.budget === "digest" ? current.window : [...current.window, { at: t, budget: batch.budget }]
950
+ if (!failure) {
951
+ const facts = bounded({ ...(batch.kind === "snapshot" ? {} : current.facts), ...batch.facts }, L.factsMax)
952
+ const woke = batch.wake && result?.woke !== false
953
+ const alerts: Record<string, number> = { ...current.alerts }
954
+ if (woke) for (const key of batch.alerts ?? []) alerts[key] = t
955
+ const history = [...current.history, { id: batch.id, kind: batch.kind, at: t, attempts: batch.attempts, outcome: "delivered" as const, messageID: result!.messageID }].slice(-L.historyMax)
956
+ return { ...without(current, "batch"), window, facts, alerts: bounded(alerts, L.factsMax), history }
957
+ }
958
+ if (batch.attempts >= L.maxAttempts) {
959
+ const urgent = batch.wake || current.buffer.some((l) => l.c === "priority")
960
+ const history = [...current.history, { id: batch.id, kind: batch.kind, at: t, attempts: batch.attempts, outcome: "abandoned" as const }].slice(-L.historyMax)
961
+ const missed = batch.lines.length + batch.more + current.buffer.length + (current.snapshot?.missed ?? 0)
962
+ const unknown = current.snapshot?.unknown || batch.snapshot?.unknown
963
+ return { ...without(current, "batch", "firstAt", "lastAt"), window, buffer: [], facts: {}, snapshot: { reason: "abandoned" as const, missed, ...(unknown ? { unknown: true as const } : {}), ...(urgent ? { urgent: true as const } : {}) }, history }
964
+ }
965
+ const wait = L.retryMs[Math.min(batch.attempts - 1, L.retryMs.length - 1)] ?? 60_000
966
+ return { ...current, window, batch: { ...without(batch, "claimedBy", "claimedAt"), ...(failure ? { last: failure } : {}), state: "retry", nextAt: t + wait } }
967
+ })
968
+ if (!failure && recorded) {
969
+ try {
970
+ if (batch.kind === "digest") this.ports.entitlements.record(userID, { kind: "digest", n: 1 })
971
+ else if (batch.wake && result?.woke !== false) this.ports.entitlements.record(userID, { kind: "wake", n: 1 })
972
+ } catch (cause) {
973
+ this.log(`[hub] concierge notifier: entitlements failed: ${(cause as Error).message}`)
974
+ }
975
+ }
976
+ }
977
+
978
+ /** The promise, or a rejection after `ms` (it may still settle: the caller treats that as uncertain). */
979
+ private within<T>(promise: Promise<T>, ms: number): Promise<T> {
980
+ let handle: unknown
981
+ const timeout = new Promise<never>((_, reject) => {
982
+ handle = this.timers.set(() => reject(new Error(`no answer within ${ms} ms`)), ms)
983
+ })
984
+ return Promise.race([promise, timeout]).finally(() => this.timers.clear(handle))
985
+ }
986
+ }