@huynle/hub 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +800 -0
  2. package/Dockerfile +30 -0
  3. package/README.md +870 -66
  4. package/package.json +45 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +155 -0
  21. package/packages/cli/dist/hub-cli.js +10162 -0
  22. package/packages/codex/package.json +12 -0
  23. package/packages/codex/src/app-server.ts +44 -0
  24. package/packages/codex/src/ids.ts +28 -0
  25. package/packages/codex/src/index.ts +11 -0
  26. package/packages/codex/src/jsonrpc.ts +168 -0
  27. package/packages/codex/src/main.ts +122 -0
  28. package/packages/codex/src/paths.ts +65 -0
  29. package/packages/codex/src/sessions.ts +578 -0
  30. package/packages/codex/src/spoke.ts +249 -0
  31. package/packages/codex/src/stdio.ts +101 -0
  32. package/packages/codex/src/websocket.ts +206 -0
  33. package/packages/codex/src/wrapper.ts +100 -0
  34. package/packages/node/package.json +12 -0
  35. package/packages/node/src/config.ts +184 -0
  36. package/packages/node/src/connection.ts +283 -0
  37. package/packages/node/src/daemon.ts +281 -0
  38. package/packages/node/src/git.ts +107 -0
  39. package/packages/node/src/harnesses.ts +60 -0
  40. package/packages/node/src/host.ts +319 -0
  41. package/packages/node/src/index.ts +17 -0
  42. package/packages/node/src/main.ts +11 -0
  43. package/packages/node/src/openshell.ts +73 -0
  44. package/packages/node/src/run.ts +106 -0
  45. package/packages/preset/package.json +12 -0
  46. package/packages/preset/presets/aider.json +8 -0
  47. package/packages/preset/presets/goose.json +8 -0
  48. package/packages/preset/src/index.ts +2 -0
  49. package/packages/preset/src/main.ts +195 -0
  50. package/packages/preset/src/presets.ts +87 -0
  51. package/packages/preset/src/spoke.ts +251 -0
  52. package/packages/preset/src/tmux.ts +160 -0
  53. package/packages/protocol/package.json +12 -0
  54. package/packages/protocol/src/harness-events.ts +207 -0
  55. package/packages/protocol/src/index.ts +1215 -0
  56. package/packages/protocol/src/launch.ts +172 -0
  57. package/packages/protocol/src/node.ts +409 -0
  58. package/packages/protocol/src/preset.ts +144 -0
  59. package/packages/protocol/src/webhook.ts +62 -0
  60. package/packages/protocol/src/wire.ts +54 -0
  61. package/packages/runtime-openshell/image/Dockerfile +36 -0
  62. package/packages/runtime-openshell/image/README.md +78 -0
  63. package/packages/runtime-openshell/image/build.ts +203 -0
  64. package/packages/runtime-openshell/image/entry.sh +163 -0
  65. package/packages/runtime-openshell/image/image.ts +119 -0
  66. package/packages/runtime-openshell/image/start.sh +24 -0
  67. package/packages/runtime-openshell/package.json +12 -0
  68. package/packages/runtime-openshell/src/cli.ts +584 -0
  69. package/packages/runtime-openshell/src/index.ts +6 -0
  70. package/packages/runtime-openshell/src/policy.ts +155 -0
  71. package/packages/runtime-openshell/src/profiles.ts +181 -0
  72. package/packages/runtime-openshell/src/runtime.ts +475 -0
  73. package/packages/runtime-sdk/package.json +12 -0
  74. package/packages/runtime-sdk/src/index.ts +87 -0
  75. package/packages/sdk/package.json +12 -0
  76. package/packages/sdk/src/address.ts +25 -0
  77. package/packages/sdk/src/client.ts +502 -0
  78. package/packages/sdk/src/envelope.ts +108 -0
  79. package/packages/sdk/src/errors.ts +106 -0
  80. package/packages/sdk/src/index.ts +10 -0
  81. package/packages/sdk/src/sse.ts +67 -0
  82. package/packages/sdk/src/types.ts +458 -0
  83. package/packages/sdk/src/webhook.ts +102 -0
  84. package/packages/spoke-sdk/package.json +12 -0
  85. package/packages/spoke-sdk/src/activity.ts +3 -0
  86. package/packages/spoke-sdk/src/connection.ts +295 -0
  87. package/packages/spoke-sdk/src/delivery.ts +119 -0
  88. package/packages/spoke-sdk/src/enroll.ts +79 -0
  89. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  90. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  91. package/packages/spoke-sdk/src/index.ts +12 -0
  92. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  93. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  94. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  95. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  96. package/scripts/hub-claude-code.service +34 -0
  97. package/scripts/hub-codex-spoke.service +34 -0
  98. package/scripts/hub-node.service +35 -0
  99. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  100. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  101. package/scripts/io.github.huynle.hub-node.plist +52 -0
  102. package/src/central/actions.ts +233 -0
  103. package/src/central/agent/acting-routes.ts +133 -0
  104. package/src/central/agent/acting-switch.ts +93 -0
  105. package/src/central/agent/acting.ts +599 -0
  106. package/src/central/agent/actor.ts +193 -0
  107. package/src/central/agent/app-tokens.ts +142 -0
  108. package/src/central/agent/audit.ts +185 -0
  109. package/src/central/agent/decisions.ts +530 -0
  110. package/src/central/agent/delivery.ts +678 -0
  111. package/src/central/agent/directory-routes.ts +185 -0
  112. package/src/central/agent/directory.ts +473 -0
  113. package/src/central/agent/envelope.ts +51 -0
  114. package/src/central/agent/errors.ts +72 -0
  115. package/src/central/agent/escalation.ts +219 -0
  116. package/src/central/agent/grant-requests.ts +221 -0
  117. package/src/central/agent/grant-routes.ts +70 -0
  118. package/src/central/agent/grants.ts +235 -0
  119. package/src/central/agent/launch-engine.ts +1025 -0
  120. package/src/central/agent/launch-routes.ts +252 -0
  121. package/src/central/agent/launch-spec.ts +289 -0
  122. package/src/central/agent/launch-store.ts +264 -0
  123. package/src/central/agent/launch-tokens.ts +206 -0
  124. package/src/central/agent/message-limits.ts +123 -0
  125. package/src/central/agent/message-routes.ts +385 -0
  126. package/src/central/agent/messages.ts +442 -0
  127. package/src/central/agent/names.ts +86 -0
  128. package/src/central/agent/placement.ts +419 -0
  129. package/src/central/agent/policy.ts +378 -0
  130. package/src/central/agent/read-routes.ts +389 -0
  131. package/src/central/agent/routes.ts +278 -0
  132. package/src/central/agent/session-tokens.ts +227 -0
  133. package/src/central/agent/versioned.ts +26 -0
  134. package/src/central/agent/watch.ts +221 -0
  135. package/src/central/agent/webhook-routes.ts +78 -0
  136. package/src/central/agent/webhooks.ts +449 -0
  137. package/src/central/agent-data.ts +177 -0
  138. package/src/central/auth.ts +224 -121
  139. package/src/central/backend/file-state.ts +240 -0
  140. package/src/central/backend/index.ts +113 -0
  141. package/src/central/backend/memory-bus.ts +93 -0
  142. package/src/central/backend/memory-catalog.ts +90 -0
  143. package/src/central/backend/memory-counters.ts +102 -0
  144. package/src/central/backend/memory-events.ts +45 -0
  145. package/src/central/backend/memory-kv.ts +38 -0
  146. package/src/central/backend/memory-state.ts +77 -0
  147. package/src/central/backend/postgres-migrate.ts +100 -0
  148. package/src/central/backend/postgres-state.ts +197 -0
  149. package/src/central/backend/types.ts +244 -0
  150. package/src/central/backend/valkey-bus.ts +425 -0
  151. package/src/central/backend/valkey-catalog.ts +190 -0
  152. package/src/central/backend/valkey-client.ts +36 -0
  153. package/src/central/backend/valkey-counters.ts +38 -0
  154. package/src/central/backend/valkey-events.ts +168 -0
  155. package/src/central/backend/valkey-kv.ts +54 -0
  156. package/src/central/backend/valkey-locks.ts +92 -0
  157. package/src/central/cluster/directory.ts +141 -0
  158. package/src/central/cluster/feed.ts +84 -0
  159. package/src/central/cluster/leader.ts +232 -0
  160. package/src/central/cluster/live.ts +151 -0
  161. package/src/central/cluster/recommend.ts +59 -0
  162. package/src/central/cluster/rpc.ts +150 -0
  163. package/src/central/cluster/view.ts +232 -0
  164. package/src/central/config.ts +197 -3
  165. package/src/central/done.ts +96 -0
  166. package/src/central/events-log.ts +100 -0
  167. package/src/central/http.ts +1067 -171
  168. package/src/central/import.ts +318 -0
  169. package/src/central/main.ts +211 -12
  170. package/src/central/nodes.ts +314 -0
  171. package/src/central/oidc.ts +34 -14
  172. package/src/central/push-target.ts +118 -18
  173. package/src/central/push.ts +256 -82
  174. package/src/central/registry.ts +902 -92
  175. package/src/central/say-stream.ts +128 -0
  176. package/src/central/session-token-push.ts +233 -0
  177. package/src/central/speech/openai.ts +21 -14
  178. package/src/central/speech/openrouter.ts +27 -10
  179. package/src/central/speech/provider.ts +54 -0
  180. package/src/central/speech/service.ts +22 -7
  181. package/src/central/spoke-ws.ts +522 -44
  182. package/src/central/store-shared.ts +518 -0
  183. package/src/central/store.ts +572 -189
  184. package/src/central/token-keys.ts +165 -0
  185. package/src/config.ts +49 -1
  186. package/src/index.ts +165 -12
  187. package/src/protocol.ts +3 -650
  188. package/src/rpc.ts +27 -2
  189. package/src/spoke/client.ts +3 -272
  190. package/src/spoke/conversation.ts +16 -12
  191. package/src/spoke/deliver.ts +75 -0
  192. package/src/spoke/enroll.ts +2 -79
  193. package/src/spoke/env-inject.ts +160 -0
  194. package/src/spoke/hub-rpc.ts +115 -0
  195. package/src/spoke/hub-tool.ts +409 -0
  196. package/src/spoke/launch-source.ts +22 -0
  197. package/src/spoke/launchers.ts +68 -0
  198. package/src/spoke/locations.ts +3 -2
  199. package/src/spoke/node-roots.ts +61 -0
  200. package/src/spoke/prompt-refs.ts +116 -0
  201. package/src/spoke/session-tokens.ts +2 -0
  202. package/src/spoke/spoke.ts +465 -28
  203. package/src/spoke/taskboard.ts +243 -0
  204. package/src/spoke/timeline.ts +184 -6
  205. package/src/spoke/tracker.ts +79 -4
  206. package/src/status.ts +203 -10
  207. package/src/tui-commands.ts +13 -6
  208. package/src/tui-messaging.ts +93 -0
  209. package/src/tui.ts +15 -0
  210. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  211. package/web/dist/assets/index-D9zMbxSg.css +1 -0
  212. package/web/dist/assets/index-db1z4duA.js +20 -0
  213. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  214. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  215. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  216. package/web/dist/index.html +9 -2
  217. package/web/dist/sw.js +79 -15
  218. package/web/dist/assets/index-DClBvfLL.css +0 -1
  219. package/web/dist/assets/index-Djo_yF0a.js +0 -12
@@ -0,0 +1,530 @@
1
+ // Decisions (design §12.1; exploration iw6kfmxg Security): what an ask to the user (`@me`) opens. No routes: the
2
+ // agent message routes (ask @me) and the console routes (/api/decisions) call it.
3
+ //
4
+ // - Stored form: {id, messageID, from, about, sessionID?, question, options, recommendation?, urgency, tier,
5
+ // deadline?, onTimeout?, state, at, expiresAt, escalatedAt?, answer?}, per user in the StateStore. The ask
6
+ // message (stored by the delivery engine, recipient the user) carries it: it closes when the Decision is
7
+ // answered, and the Decision expires with it.
8
+ // - Only the user answers (never a session or app, never the asker); `tap` Decisions only from a signed-in
9
+ // console (the device cookie, not a device token). The first answer wins (a version-checked write); later
10
+ // ones get `gone`. The answer goes back to the asker as the user's reply to the ask.
11
+ // - Deadlines: at the deadline, `onTimeout` (only an option marked safe) answers it, recorded answeredBy
12
+ // "timeout"; without one the Decision stays open and escalates once (notified again). The loop (start/tick)
13
+ // looks at every user's open Decisions, so deadlines survive restarts.
14
+ // - Text is sanitised (question by the message store; labels, ids and reasons here). Notifications carry only
15
+ // the Decision (the push layer sends a generic body).
16
+ // - Actions (P3-T8, P3-T9): the hub (never an asker) may attach an action (an escalated permission request, a grant
17
+ // request); the user's answer runs it through `act` as that user after the answer wins (its note, e.g. the grant
18
+ // id, joins the asker's reply). Its subject gone → the Decision closes and the answer is gone; it fails → the
19
+ // Decision opens again.
20
+ // - close(): a Decision whose subject went away (a permission answered elsewhere) is closed, not answered; the asker
21
+ // hears why.
22
+ // - Every open and change (answer, timeout, escalation, expiry, close) goes to `changed` (the console's 'decision' event).
23
+ import type { Clock, StateStore, Timers } from "../backend/types"
24
+ import type { AgentActor, UserActor } from "./actor"
25
+ import type { Delivery } from "./delivery"
26
+ import { type Party, partyKey, sanitizeLabel, sanitizeText } from "./envelope"
27
+ import { AgentError } from "./errors"
28
+ import { ASK_EXPIRES_MAX_MS, ASK_EXPIRES_MIN_MS, ASK_EXPIRES_MS, type Message, newMessageID } from "./messages"
29
+ import { updateVersioned } from "./versioned"
30
+
31
+ export type Urgency = "low" | "normal" | "high"
32
+ /** confirm: any of the user's signed-in devices; tap: only a signed-in console (cookie). */
33
+ export type Tier = "confirm" | "tap"
34
+ /** closed: ended without an answer because what it asked about went away (answered elsewhere, ended). */
35
+ export type DecisionState = "open" | "answered" | "expired" | "closed"
36
+
37
+ export const URGENCIES: readonly Urgency[] = ["low", "normal", "high"]
38
+ export const TIERS: readonly Tier[] = ["confirm", "tap"]
39
+ export const DECISION_STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
40
+
41
+ /** Options a Decision offers. */
42
+ export const OPTIONS_MAX = 8
43
+ /** Characters of an option label. */
44
+ export const OPTION_LABEL_MAX = 80
45
+ /** Characters of a recommendation's reason. */
46
+ export const WHY_MAX = 1_000
47
+ /** Characters of the user's answer text (a note with an option, or the free-text answer). */
48
+ export const ANSWER_MAX = 4_000
49
+ /** Decisions each user keeps (the oldest closed ones go first). */
50
+ export const DECISIONS_MAX = 500
51
+ /** How often the loop looks for deadlines. */
52
+ export const DECISION_TICK_MS = 30_000
53
+
54
+ const OPTION_ID = /^[a-z0-9][a-z0-9_-]{0,31}$/
55
+
56
+ export interface DecisionOption {
57
+ readonly id: string
58
+ readonly label: string
59
+ /** onTimeout may pick only a safe option. */
60
+ readonly safe?: true
61
+ }
62
+
63
+ export interface DecisionAnswer {
64
+ readonly option?: string
65
+ readonly text?: string
66
+ readonly answeredBy: "user" | "timeout"
67
+ readonly at: number
68
+ /** The device the user answered from. */
69
+ readonly deviceID?: string
70
+ }
71
+
72
+ /** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
73
+ export type DecisionAction = PermissionAction | GrantRequestAction
74
+
75
+ /** An escalated permission request: the answer replies to it as the user. */
76
+ export interface PermissionAction {
77
+ readonly kind: "permission"
78
+ readonly requestID: string
79
+ /** The tree the request is pending in. */
80
+ readonly rootID: string
81
+ /** The session that asked. */
82
+ readonly sessionID: string
83
+ /** The agent asked to approve it always. */
84
+ readonly always: boolean
85
+ }
86
+
87
+ /** A grant an agent requested (grant-requests.ts): approving creates it as the user. */
88
+ export interface GrantRequestAction {
89
+ readonly kind: "grant"
90
+ /** The requester (partyKey). */
91
+ readonly requester: string
92
+ /** The grant body (validated when requested; validated again, as the user's, on approval). */
93
+ readonly grant: Readonly<Record<string, unknown>>
94
+ }
95
+
96
+ export interface Decision {
97
+ readonly id: string
98
+ /** The ask (to @me) that carries it. */
99
+ readonly messageID: string
100
+ /** The asker (a session or an app). */
101
+ readonly from: Party
102
+ /** Session IDs it concerns (default: the asking session). */
103
+ readonly about: readonly string[]
104
+ /** The session a notification opens (about[0]). */
105
+ readonly sessionID?: string
106
+ readonly question: string
107
+ readonly options: readonly DecisionOption[]
108
+ readonly recommendation?: { readonly option: string; readonly why?: string }
109
+ readonly urgency: Urgency
110
+ readonly tier: Tier
111
+ readonly deadline?: number
112
+ readonly onTimeout?: string
113
+ readonly state: DecisionState
114
+ readonly at: number
115
+ /** When its ask expires (the Decision with it). */
116
+ readonly expiresAt: number
117
+ /** When it passed its deadline without a safe onTimeout (notified again). */
118
+ readonly escalatedAt?: number
119
+ readonly answer?: DecisionAnswer
120
+ /** Why it closed without an answer (state closed). */
121
+ readonly closed?: { readonly at: number; readonly reason: string }
122
+ /** Run (as the answering user) when the user answers; set by the hub only, never by an asker. */
123
+ readonly action?: DecisionAction
124
+ }
125
+
126
+ /** What the hub (never an asker's request) adds to an ask. */
127
+ export interface AskExtra {
128
+ readonly action?: DecisionAction
129
+ /** Open it without the "opened" notification (coalesced pushes). */
130
+ readonly quiet?: boolean
131
+ }
132
+
133
+ export interface AskInput {
134
+ readonly question: string
135
+ readonly options?: readonly { readonly id: string; readonly label: string; readonly safe?: boolean }[]
136
+ readonly recommendation?: { readonly option: string; readonly why?: string }
137
+ readonly urgency?: Urgency
138
+ readonly tier?: Tier
139
+ /** From now. */
140
+ readonly deadlineMs?: number
141
+ readonly onTimeout?: string
142
+ /** Session IDs (resolved by the caller, the user's own). */
143
+ readonly about?: readonly string[]
144
+ /** The ask's lifetime (default: a day, or the deadline plus a day; at most ASK_EXPIRES_MAX_MS). */
145
+ readonly expiresInMs?: number
146
+ }
147
+
148
+ export interface AnswerInput {
149
+ readonly option?: string
150
+ readonly text?: string
151
+ }
152
+
153
+ export type NotifyReason = "opened" | "escalated"
154
+
155
+ /**
156
+ * What running an action came to: done (optionally with a note appended to the asker's reply), or gone when its
157
+ * subject went away first (optionally with the reason the Decision closes with).
158
+ */
159
+ export type ActOutcome = "done" | "gone" | { readonly outcome: "done"; readonly note: string } | { readonly outcome: "gone"; readonly reason: string }
160
+
161
+ /** Runs a Decision's action for the user's choice. */
162
+ export type DecisionActor = (userID: string, decision: Decision, choice: { readonly option?: string; readonly text?: string }, by: UserActor) => Promise<ActOutcome>
163
+
164
+ export interface DecisionsDeps {
165
+ readonly state: StateStore
166
+ readonly delivery: Pick<Delivery, "send" | "reply">
167
+ readonly now?: Clock
168
+ readonly timers?: Timers
169
+ readonly newID?: () => string
170
+ /** The users whose Decisions the deadline loop looks at. */
171
+ readonly users?: () => Iterable<string>
172
+ /** How often the deadline loop runs (default DECISION_TICK_MS). */
173
+ readonly tickMs?: number
174
+ /** A Decision the user should see (opened, or past its deadline without a safe onTimeout). */
175
+ readonly notify?: (userID: string, decision: Decision, reason: NotifyReason) => void
176
+ /** A Decision opened or changed (answered, timed out, escalated, expired): the console's live event. Never throws through. */
177
+ readonly changed?: (userID: string, decision: Decision) => void
178
+ /** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
179
+ readonly act?: DecisionActor
180
+ }
181
+
182
+ const DECISIONS = "decisions"
183
+ const BY_MESSAGE = "decision-by-message"
184
+
185
+ const defaultTimers: Timers = {
186
+ set: (fn, ms) => {
187
+ const handle = setTimeout(fn, ms)
188
+ ;(handle as { unref?: () => void }).unref?.()
189
+ return handle
190
+ },
191
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
192
+ }
193
+
194
+ /** `dec_` + 22 base62 characters from the CSPRNG. */
195
+ export const newDecisionID = (): string => `dec_${newMessageID().slice(4)}`
196
+
197
+ /** The hub replying for the user (timeouts, closes). */
198
+ const HUB_USER = (userID: string): UserActor => ({ kind: "user", userID, deviceID: "hub", via: "cookie" })
199
+
200
+ const invalid = (message: string) => new AgentError("invalid", message)
201
+ const gone = () => new AgentError("gone", "The decision is already answered or expired")
202
+ const notFound = () => new AgentError("not_found", "No such decision")
203
+
204
+ function cleanLabel(value: unknown, max: number, what: string): string {
205
+ if (typeof value !== "string" || value.length > max * 4) throw invalid(`${what} must be a string of at most ${max} characters`)
206
+ const clean = sanitizeLabel(value, max)
207
+ if (!clean) throw invalid(`${what} is empty`)
208
+ return clean
209
+ }
210
+
211
+ function cleanText(value: unknown, max: number, what: string): string {
212
+ if (typeof value !== "string" || value.length > max) throw invalid(`${what} must be a string of at most ${max} characters`)
213
+ const clean = sanitizeText(value).trim()
214
+ if (!clean) throw invalid(`${what} is empty`)
215
+ return clean
216
+ }
217
+
218
+ /** The validated, sanitised parts of a Decision (everything but what the ask message gives). */
219
+ function validate(input: AskInput) {
220
+ const raw = input.options ?? []
221
+ if (!Array.isArray(raw) || raw.length > OPTIONS_MAX) throw invalid(`A decision offers at most ${OPTIONS_MAX} options`)
222
+ const options: DecisionOption[] = []
223
+ for (const option of raw) {
224
+ if (typeof option?.id !== "string" || !OPTION_ID.test(option.id)) throw invalid("Option ids are 1–32 lowercase letters, digits, - or _")
225
+ if (options.some((o) => o.id === option.id)) throw invalid(`Option ${option.id} is given twice`)
226
+ options.push({ id: option.id, label: cleanLabel(option.label, OPTION_LABEL_MAX, "An option label"), ...(option.safe === true ? { safe: true as const } : {}) })
227
+ }
228
+ const has = (id: string) => options.some((o) => o.id === id)
229
+ let recommendation: Decision["recommendation"]
230
+ if (input.recommendation !== undefined) {
231
+ const { option, why } = input.recommendation
232
+ if (typeof option !== "string" || !has(option)) throw invalid("The recommendation must name one of the options")
233
+ recommendation = { option, ...(why === undefined ? {} : { why: cleanLabel(why, WHY_MAX, "The recommendation's reason") }) }
234
+ }
235
+ const urgency = input.urgency ?? "normal"
236
+ if (!URGENCIES.includes(urgency)) throw invalid(`urgency is one of ${URGENCIES.join(", ")}`)
237
+ const tier = input.tier ?? "confirm"
238
+ if (!TIERS.includes(tier)) throw invalid(`tier is one of ${TIERS.join(", ")}`)
239
+ const { deadlineMs, onTimeout } = input
240
+ if (deadlineMs !== undefined && !(Number.isFinite(deadlineMs) && deadlineMs >= ASK_EXPIRES_MIN_MS && deadlineMs <= ASK_EXPIRES_MAX_MS)) {
241
+ throw invalid(`A deadline is 1 minute to ${ASK_EXPIRES_MAX_MS / 86_400_000} days away`)
242
+ }
243
+ if (onTimeout !== undefined) {
244
+ if (deadlineMs === undefined) throw invalid("onTimeout needs a deadline")
245
+ if (!options.some((o) => o.id === onTimeout && o.safe)) throw invalid("onTimeout may pick only an option marked safe")
246
+ }
247
+ const expiresInMs = input.expiresInMs ?? Math.min(ASK_EXPIRES_MAX_MS, Math.max(ASK_EXPIRES_MS, (deadlineMs ?? 0) + ASK_EXPIRES_MS))
248
+ if (deadlineMs !== undefined && deadlineMs > expiresInMs) throw invalid("The deadline is after the ask expires")
249
+ return { options, recommendation, urgency, tier, deadlineMs, onTimeout, expiresInMs }
250
+ }
251
+
252
+ export class Decisions {
253
+ private readonly deps: DecisionsDeps
254
+ private readonly now: Clock
255
+ private readonly timers: Timers
256
+ private readonly newID: () => string
257
+ private timer: unknown
258
+ private running = false
259
+
260
+ constructor(deps: DecisionsDeps) {
261
+ this.deps = deps
262
+ this.now = deps.now ?? Date.now
263
+ this.timers = deps.timers ?? defaultTimers
264
+ this.newID = deps.newID ?? newDecisionID
265
+ }
266
+
267
+ /**
268
+ * Opens a Decision: stores the ask to @me (through the delivery engine: rate limits, sanitising) and the
269
+ * Decision, then notifies. Throws invalid (input; the user as asker), rate_limited, forbidden.
270
+ */
271
+ async ask(userID: string, actor: AgentActor, input: AskInput, extra: AskExtra = {}): Promise<{ message: Message; decision: Decision }> {
272
+ if (actor.kind === "user") throw invalid("You are @me: the user cannot ask themselves")
273
+ if (typeof input.question !== "string") throw invalid("question must be a string")
274
+ const v = validate(input)
275
+ const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
276
+ const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
277
+ const decision: Decision = {
278
+ id: this.newID(),
279
+ messageID: message.id,
280
+ from: message.from,
281
+ about,
282
+ ...(about[0] ? { sessionID: about[0] } : {}),
283
+ question: message.text,
284
+ options: v.options,
285
+ ...(v.recommendation ? { recommendation: v.recommendation } : {}),
286
+ urgency: v.urgency,
287
+ tier: v.tier,
288
+ ...(v.deadlineMs === undefined ? {} : { deadline: message.at + v.deadlineMs }),
289
+ ...(v.onTimeout === undefined ? {} : { onTimeout: v.onTimeout }),
290
+ state: "open",
291
+ at: message.at,
292
+ expiresAt: message.expiresAt ?? message.at + v.expiresInMs,
293
+ ...(extra.action ? { action: extra.action } : {}),
294
+ }
295
+ const written = await this.deps.state.put(userID, DECISIONS, decision.id, decision, { ifVersion: 0 })
296
+ if (!written.ok) throw new Error("decision id collision")
297
+ await this.deps.state.put(userID, BY_MESSAGE, message.id, decision.id)
298
+ await this.trim(userID)
299
+ if (!extra.quiet) this.deps.notify?.(userID, decision, "opened")
300
+ this.emit(userID, decision)
301
+ return { message, decision }
302
+ }
303
+
304
+ async get(userID: string, id: string): Promise<Decision | undefined> {
305
+ const stored = await this.deps.state.get<Decision>(userID, DECISIONS, id)
306
+ return stored && this.view(stored.value)
307
+ }
308
+
309
+ /** The Decision an ask to @me carries. */
310
+ async byMessage(userID: string, messageID: string): Promise<Decision | undefined> {
311
+ const id = (await this.deps.state.get<string>(userID, BY_MESSAGE, messageID))?.value
312
+ return id === undefined ? undefined : this.get(userID, id)
313
+ }
314
+
315
+ /** The user's Decisions, newest first; `from`: only those this party asked. */
316
+ async list(userID: string, filter: { readonly state?: DecisionState; readonly from?: Party } = {}): Promise<Decision[]> {
317
+ const from = filter.from && partyKey(filter.from)
318
+ return (await this.deps.state.list<Decision>(userID, DECISIONS))
319
+ .map((entry) => this.view(entry.value))
320
+ .filter((d) => (filter.state === undefined || d.state === filter.state) && (from === undefined || partyKey(d.from) === from))
321
+ .sort((a, b) => b.at - a.at || (a.id < b.id ? 1 : -1))
322
+ }
323
+
324
+ /**
325
+ * The user answers: an option (with an optional note), or text for a Decision without options. The first
326
+ * answer wins; then the asker gets the user's reply. Throws not_found, gone, forbidden (tap without the
327
+ * console cookie) or invalid.
328
+ */
329
+ async answer(userID: string, id: string, input: AnswerInput, by: UserActor): Promise<{ decision: Decision; reply?: Message }> {
330
+ const current = await this.get(userID, id)
331
+ if (!current || by.userID !== userID) throw notFound()
332
+ if (current.state !== "open") throw gone()
333
+ if (current.tier === "tap" && by.via !== "cookie") throw new AgentError("forbidden", "This decision needs a tap in the signed-in console", { hint: "Answer it in the console" })
334
+ const choice = this.choice(current, input)
335
+ const at = this.now()
336
+ const answer: DecisionAnswer = { ...choice, answeredBy: "user", at, deviceID: by.deviceID }
337
+ const decision = await this.settle(userID, id, answer)
338
+ if (!decision) throw gone()
339
+ const note = decision.action ? await this.runAction(userID, decision, choice, by) : undefined
340
+ this.emit(userID, decision)
341
+ const option = decision.options.find((o) => o.id === choice.option)
342
+ const said = option ? `Decision ${id}: ${option.label} [${option.id}]${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text}`
343
+ const text = note ? `${said}\n${note}` : said
344
+ return { decision, ...(await this.replyTo(userID, decision, text, by)) }
345
+ }
346
+
347
+ /**
348
+ * Closes an open Decision without an answer (what it asked about went away: answered elsewhere, ended) and tells
349
+ * the asker why. Undefined (no change) when it is not open, unknown or another user's.
350
+ */
351
+ async close(userID: string, id: string, reason: string): Promise<Decision | undefined> {
352
+ const why = sanitizeLabel(reason, WHY_MAX) || "Closed"
353
+ const closed = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, id, (current) =>
354
+ current && this.view(current).state === "open" ? { ...current, state: "closed", closed: { at: this.now(), reason: why } } : undefined,
355
+ )
356
+ if (!closed) return undefined
357
+ this.emit(userID, closed)
358
+ await this.replyTo(userID, closed, `Decision ${id} closed: ${why}`, HUB_USER(userID))
359
+ return closed
360
+ }
361
+
362
+ /** Replaces an open Decision's recommendation (one of its options); undefined when it is not open. */
363
+ async revise(userID: string, id: string, input: { readonly recommendation: { readonly option: string; readonly why?: string } }): Promise<Decision | undefined> {
364
+ const current = await this.get(userID, id)
365
+ if (!current || current.state !== "open") return undefined
366
+ const { option, why } = input.recommendation
367
+ if (typeof option !== "string" || !current.options.some((o) => o.id === option)) throw invalid("The recommendation must name one of the options")
368
+ const recommendation = { option, ...(why === undefined ? {} : { why: cleanLabel(why, WHY_MAX, "The recommendation's reason") }) }
369
+ const revised = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, id, (stored) =>
370
+ stored && this.view(stored).state === "open" ? { ...stored, recommendation } : undefined,
371
+ )
372
+ if (revised) this.emit(userID, revised)
373
+ return revised
374
+ }
375
+
376
+ /** Starts the deadline loop (every DECISION_TICK_MS). */
377
+ start(): void {
378
+ if (this.running) return
379
+ this.running = true
380
+ this.schedule()
381
+ }
382
+
383
+ stop(): void {
384
+ this.running = false
385
+ if (this.timer !== undefined) this.timers.clear(this.timer)
386
+ this.timer = undefined
387
+ }
388
+
389
+ /** One pass of the deadline loop over every user (the loop calls it; tests may too). */
390
+ async tick(): Promise<void> {
391
+ for (const userID of this.deps.users?.() ?? []) {
392
+ try {
393
+ await this.due(userID)
394
+ } catch (cause) {
395
+ console.error(`[hub] decision deadlines failed: ${cause instanceof Error ? cause.message : String(cause)}`)
396
+ }
397
+ }
398
+ }
399
+
400
+ // ---- internals ----------------------------------------------------------------------------------------
401
+
402
+ /** Tells the changed listener (its failure never fails the caller). */
403
+ private emit(userID: string, decision: Decision): void {
404
+ try {
405
+ this.deps.changed?.(userID, decision)
406
+ } catch (cause) {
407
+ console.error(`[hub] decision ${decision.id}: change listener failed: ${cause instanceof Error ? cause.message : String(cause)}`)
408
+ }
409
+ }
410
+
411
+ private schedule(): void {
412
+ this.timer = this.timers.set(() => {
413
+ this.timer = undefined
414
+ void this.tick().finally(() => {
415
+ if (this.running && this.timer === undefined) this.schedule()
416
+ })
417
+ }, this.deps.tickMs ?? DECISION_TICK_MS)
418
+ }
419
+
420
+ /** The Decision as of now (an open one past its ask's expiry reads expired). */
421
+ private view(decision: Decision): Decision {
422
+ return decision.state === "open" && this.now() >= decision.expiresAt ? { ...decision, state: "expired" } : decision
423
+ }
424
+
425
+ private choice(decision: Decision, input: AnswerInput): { option?: string; text?: string } {
426
+ const text = input.text === undefined ? undefined : cleanText(input.text, ANSWER_MAX, "The answer text")
427
+ if (!decision.options.length) {
428
+ if (input.option !== undefined) throw invalid("This decision has no options: answer with text")
429
+ if (text === undefined) throw invalid("This decision needs an answer text")
430
+ return { text }
431
+ }
432
+ if (typeof input.option !== "string" || !decision.options.some((o) => o.id === input.option)) {
433
+ throw invalid(`Pick one of the options: ${decision.options.map((o) => o.id).join(", ")}`)
434
+ }
435
+ return { option: input.option, ...(text === undefined ? {} : { text }) }
436
+ }
437
+
438
+ /**
439
+ * Runs the answered Decision's action; answers its note (sanitised) for the asker's reply. Its subject gone: the
440
+ * Decision closes instead (throws gone). It fails: the Decision opens again (the user may retry) and the error
441
+ * goes through.
442
+ */
443
+ private async runAction(userID: string, decision: Decision, choice: { option?: string; text?: string }, by: UserActor): Promise<string | undefined> {
444
+ let outcome: ActOutcome
445
+ try {
446
+ if (!this.deps.act) throw new Error("This decision's action cannot run on this hub")
447
+ outcome = await this.deps.act(userID, decision, choice, by)
448
+ } catch (cause) {
449
+ await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, decision.id, (current) => {
450
+ if (current?.state !== "answered" || current.answer?.at !== decision.answer?.at) return undefined
451
+ const { answer: _answer, ...rest } = current
452
+ return { ...rest, state: "open" }
453
+ })
454
+ throw cause
455
+ }
456
+ if (outcome === "done") return undefined
457
+ if (typeof outcome === "object" && outcome.outcome === "done") return sanitizeLabel(outcome.note, WHY_MAX) || undefined
458
+ const why = (typeof outcome === "object" && sanitizeLabel(outcome.reason, WHY_MAX)) || "The request was answered elsewhere or ended"
459
+ const closed = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, decision.id, (current) => {
460
+ if (current?.state !== "answered") return undefined
461
+ const { answer: _answer, ...rest } = current
462
+ return { ...rest, state: "closed", closed: { at: this.now(), reason: why } }
463
+ })
464
+ if (closed) {
465
+ this.emit(userID, closed)
466
+ await this.replyTo(userID, closed, `Decision ${closed.id} closed: ${why}`, HUB_USER(userID))
467
+ }
468
+ throw gone()
469
+ }
470
+
471
+ /** Answers the Decision if it is still open (version-checked); undefined when it is not. */
472
+ private settle(userID: string, id: string, answer: DecisionAnswer): Promise<Decision | undefined> {
473
+ return updateVersioned<Decision>(this.deps.state, userID, DECISIONS, id, (current) =>
474
+ current && this.view(current).state === "open" ? { ...current, state: "answered", answer } : undefined,
475
+ )
476
+ }
477
+
478
+ /** The user's reply to the ask (the asker's answer); a failure is logged, the Decision stays answered. */
479
+ private async replyTo(userID: string, decision: Decision, text: string, by: UserActor): Promise<{ reply?: Message }> {
480
+ try {
481
+ return { reply: (await this.deps.delivery.reply(userID, by, decision.messageID, text)).message }
482
+ } catch (cause) {
483
+ console.error(`[hub] decision ${decision.id}: reply to the asker failed: ${cause instanceof Error ? cause.message : String(cause)}`)
484
+ return {}
485
+ }
486
+ }
487
+
488
+ /** Expires, times out or escalates the user's open Decisions that are due. */
489
+ private async due(userID: string): Promise<void> {
490
+ const t = this.now()
491
+ for (const { value } of await this.deps.state.list<Decision>(userID, DECISIONS)) {
492
+ if (value.state !== "open") continue
493
+ if (t >= value.expiresAt) {
494
+ const expired = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, value.id, (current) => (current?.state === "open" ? { ...current, state: "expired" } : undefined))
495
+ if (expired) this.emit(userID, expired)
496
+ continue
497
+ }
498
+ if (value.deadline === undefined || t < value.deadline) continue
499
+ const option = value.onTimeout === undefined ? undefined : value.options.find((o) => o.id === value.onTimeout && o.safe)
500
+ if (option) {
501
+ const decision = await this.settle(userID, value.id, { option: option.id, answeredBy: "timeout", at: t })
502
+ if (decision) {
503
+ this.emit(userID, decision)
504
+ await this.replyTo(userID, decision, `Decision ${decision.id}: no answer by the deadline; took the safe option ${option.label} [${option.id}]`, HUB_USER(userID))
505
+ }
506
+ continue
507
+ }
508
+ if (value.escalatedAt !== undefined) continue
509
+ const escalated = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, value.id, (current) =>
510
+ current?.state === "open" && current.escalatedAt === undefined ? { ...current, escalatedAt: t } : undefined,
511
+ )
512
+ if (escalated) {
513
+ this.deps.notify?.(userID, escalated, "escalated")
514
+ this.emit(userID, escalated)
515
+ }
516
+ }
517
+ }
518
+
519
+ /** Drops the oldest closed Decisions over DECISIONS_MAX. */
520
+ private async trim(userID: string): Promise<void> {
521
+ const all = await this.deps.state.list<Decision>(userID, DECISIONS)
522
+ const excess = all.length - DECISIONS_MAX
523
+ if (excess <= 0) return
524
+ const closed = all.filter((e) => this.view(e.value).state !== "open").sort((a, b) => a.value.at - b.value.at)
525
+ for (const entry of closed.slice(0, excess)) {
526
+ await this.deps.state.delete(userID, DECISIONS, entry.key, { ifVersion: entry.version })
527
+ await this.deps.state.delete(userID, BY_MESSAGE, entry.value.messageID)
528
+ }
529
+ }
530
+ }