@huynle/hub 0.6.1 → 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,442 @@
1
+ // The agent message store (design §3.1, §3.2), over the StateStore interface with an injected clock. No routes:
2
+ // the message routes (P2-T6) and the delivery engine (P2-T5) call it.
3
+ //
4
+ // - Stored form: {id, kind, from, to, recipient, generation?, text, replyTo?, state, at, hop, expiresAt?,
5
+ // delivery?, deliveredAt?, answer?}. `from` is the authenticated sender (partyFromActor); `to` keeps the
6
+ // address as written (`@@reviewer`), `recipient` the party it reached; text and `to` are sanitised.
7
+ // - Caps: each party (session, the user, an app) keeps its latest SESSION_MESSAGES; a message no party keeps
8
+ // is deleted. Per user, at most USER_MESSAGES messages / USER_BYTES; over either, the oldest go (down to
9
+ // 90%). Eviction keeps open asks while anything else can go.
10
+ // - Asks: open → answered (a reply) | declined | expired (at expiresAt, seen on read; expire() persists it).
11
+ // The first answer wins (version-checked write); later ones get `gone`.
12
+ // - Replies and declines come only from the recipient (anyone else: not_found, like a foreign id) and go to
13
+ // the real sender, never via a role; a reply from another role-holder generation is `gone`.
14
+ // - Mutes: a party refuses msg/ask from a session; answers to the muter's own asks still go through.
15
+ import { randomBytes } from "node:crypto"
16
+ import type { Clock, StateStore } from "../backend/types"
17
+ import { type Party, partyKey, sanitizeLabel, sanitizeText } from "./envelope"
18
+ import { AgentError } from "./errors"
19
+ import { updateVersioned } from "./versioned"
20
+
21
+ /** Messages each party keeps. */
22
+ export const SESSION_MESSAGES = 200
23
+ /** Messages per user. */
24
+ export const USER_MESSAGES = 10_000
25
+ /** Stored bytes per user. */
26
+ export const USER_BYTES = 20 * 1024 * 1024
27
+ /** Characters of message text. */
28
+ /** Body limit: the rendered envelope (escaped lines, header, boundaries, hint) must fit DELIVER_TEXT_MAX (16000). */
29
+ export const TEXT_MAX = 10_000
30
+ /** Characters of a decline reason. */
31
+ export const WHY_MAX = 1_000
32
+ /** Default and bounds of an ask's lifetime. */
33
+ export const ASK_EXPIRES_MS = 24 * 60 * 60_000
34
+ export const ASK_EXPIRES_MIN_MS = 60_000
35
+ export const ASK_EXPIRES_MAX_MS = 7 * 24 * 60 * 60_000
36
+ /** Received messages an inbox shows. */
37
+ export const INBOX_RECENT = 50
38
+
39
+ export type MessageKind = "msg" | "ask" | "reply" | "decline"
40
+ /** `sent` for msg/reply/decline; asks are open until answered, declined or expired. */
41
+ export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
42
+ /** How a message reached its recipient (design §3.3), recorded by the delivery engine. */
43
+ export type Delivery = "steer" | "turn" | "queued" | "retained" | "pending"
44
+
45
+ export interface Message {
46
+ readonly id: string
47
+ readonly kind: MessageKind
48
+ readonly from: Party
49
+ /** The address as the sender wrote it. */
50
+ readonly to: string
51
+ /** The party it reached. */
52
+ readonly recipient: Party
53
+ /** The role holder generation it reached (role addresses). */
54
+ readonly generation?: number
55
+ readonly text: string
56
+ /** What a reply or decline answers. */
57
+ readonly replyTo?: string
58
+ readonly state: MessageState
59
+ readonly at: number
60
+ /** Loop protection: hops since a human turn (see HopTracker). */
61
+ readonly hop: number
62
+ /** Asks only. */
63
+ readonly expiresAt?: number
64
+ readonly delivery?: Delivery
65
+ /** When it was first delivered (steer, turn or queued). */
66
+ readonly deliveredAt?: number
67
+ /** The reply or decline that closed an ask. */
68
+ readonly answer?: { readonly id: string; readonly at: number }
69
+ }
70
+
71
+ export interface MessageLimitsConfig {
72
+ readonly session: number
73
+ readonly userMessages: number
74
+ readonly userBytes: number
75
+ }
76
+
77
+ export interface SendInput {
78
+ readonly from: Party
79
+ readonly to: string
80
+ readonly recipient: Party
81
+ readonly kind: "msg" | "ask"
82
+ readonly text: string
83
+ readonly generation?: number
84
+ /** From HopTracker.next (default 0). */
85
+ readonly hop?: number
86
+ /** Asks only; default ASK_EXPIRES_MS. */
87
+ readonly expiresInMs?: number
88
+ }
89
+
90
+ export interface AnswerInput {
91
+ /** The authenticated replier. */
92
+ readonly from: Party
93
+ readonly replyTo: string
94
+ /** The replier's current role holder generation, when it holds the role the original reached. */
95
+ readonly generation?: number
96
+ readonly hop?: number
97
+ }
98
+
99
+ export interface Answered {
100
+ /** The reply or decline. */
101
+ readonly message: Message
102
+ /** What it answered (an ask now closed). */
103
+ readonly original: Message
104
+ }
105
+
106
+ export interface Inbox {
107
+ /** Open asks to the party, newest first. */
108
+ readonly asks: Message[]
109
+ /** The party's own open asks, newest first. */
110
+ readonly waiting: Message[]
111
+ /** Recent messages to the party, newest first. */
112
+ readonly messages: Message[]
113
+ }
114
+
115
+ export interface Usage {
116
+ readonly count: number
117
+ readonly bytes: number
118
+ }
119
+
120
+ const MESSAGES = "messages"
121
+ const INDEX = "message-index"
122
+ const USAGE = "message-usage"
123
+ const MUTES = "message-mutes"
124
+ const USAGE_KEY = "total"
125
+ /** Version-checked attempts to detach a contended message. */
126
+ const DETACH_ATTEMPTS = 16
127
+
128
+ interface StoredMessage {
129
+ readonly message: Message
130
+ /** Party keys whose index lists it. */
131
+ readonly indexed: string[]
132
+ readonly bytes: number
133
+ }
134
+
135
+ interface Index {
136
+ /** Oldest first. */
137
+ readonly ids: string[]
138
+ }
139
+
140
+ interface MuteList {
141
+ /** Muted session id → when. */
142
+ readonly sessions: Record<string, number>
143
+ }
144
+
145
+ const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
146
+
147
+ /** `msg_` + 22 base62 characters from the CSPRNG (~131 bits, unbiased). */
148
+ export function newMessageID(): string {
149
+ let id = ""
150
+ while (id.length < 22) for (const byte of randomBytes(32)) if (byte < 248 && id.length < 22) id += BASE62[byte % 62]
151
+ return `msg_${id}`
152
+ }
153
+
154
+ /** How a reply addresses a party: `@name`, `ses_…`, `@me`, `app:<tokenID>`. */
155
+ export function addressOf(party: Party): string {
156
+ switch (party.kind) {
157
+ case "session":
158
+ return party.name ? `@${party.name}` : party.sessionID
159
+ case "user":
160
+ return "@me"
161
+ case "app":
162
+ return `app:${party.tokenID}`
163
+ }
164
+ }
165
+
166
+ const isOpenAsk = (message: Message) => message.kind === "ask" && message.state === "open"
167
+
168
+ const notFound = () => new AgentError("not_found", "No such message")
169
+ const gone = (message = "The ask is already answered, declined or expired") => new AgentError("gone", message)
170
+
171
+ /** Sanitised text; invalid when too long or empty. */
172
+ function cleanText(text: string, max: number, what: string): string {
173
+ if (typeof text !== "string" || text.length > max) throw new AgentError("invalid", `${what} is longer than ${max} characters`)
174
+ const clean = sanitizeText(text)
175
+ if (!clean.trim()) throw new AgentError("invalid", `${what} is empty`)
176
+ return clean
177
+ }
178
+
179
+ export class Messages {
180
+ private readonly state: StateStore
181
+ private readonly now: Clock
182
+ private readonly newID: () => string
183
+ private readonly limits: MessageLimitsConfig
184
+
185
+ constructor(deps: { readonly state: StateStore; readonly now?: Clock; readonly newID?: () => string; readonly limits?: Partial<MessageLimitsConfig> }) {
186
+ this.state = deps.state
187
+ this.now = deps.now ?? Date.now
188
+ this.newID = deps.newID ?? newMessageID
189
+ this.limits = { session: SESSION_MESSAGES, userMessages: USER_MESSAGES, userBytes: USER_BYTES, ...deps.limits }
190
+ }
191
+
192
+ /** Stores a new msg or ask. Throws invalid (text, expiry) or forbidden (the recipient muted the sender). */
193
+ async send(userID: string, input: SendInput): Promise<Message> {
194
+ const text = cleanText(input.text, TEXT_MAX, "Message text")
195
+ if (input.kind !== "ask" && input.expiresInMs !== undefined) throw new AgentError("invalid", "Only asks expire")
196
+ const expiresIn = input.expiresInMs ?? ASK_EXPIRES_MS
197
+ if (input.kind === "ask" && !(expiresIn >= ASK_EXPIRES_MIN_MS && expiresIn <= ASK_EXPIRES_MAX_MS)) {
198
+ throw new AgentError("invalid", `An ask expires after ${ASK_EXPIRES_MIN_MS / 60_000} minute to ${ASK_EXPIRES_MAX_MS / 86_400_000} days`)
199
+ }
200
+ if (input.from.kind === "session" && (await this.muted(userID, input.recipient)).includes(input.from.sessionID)) {
201
+ throw new AgentError("forbidden", "The recipient muted this session")
202
+ }
203
+ const at = this.now()
204
+ const message: Message = {
205
+ id: this.newID(),
206
+ kind: input.kind,
207
+ from: input.from,
208
+ to: sanitizeLabel(input.to),
209
+ recipient: input.recipient,
210
+ ...(input.generation === undefined ? {} : { generation: input.generation }),
211
+ text,
212
+ state: input.kind === "ask" ? "open" : "sent",
213
+ at,
214
+ hop: input.hop ?? 0,
215
+ ...(input.kind === "ask" ? { expiresAt: at + expiresIn } : {}),
216
+ }
217
+ await this.store(userID, message)
218
+ return message
219
+ }
220
+
221
+ /** Replies to a message (answering an ask), from its recipient to its real sender. */
222
+ async reply(userID: string, input: AnswerInput & { readonly text: string }): Promise<Answered> {
223
+ const original = await this.answerable(userID, input)
224
+ const text = cleanText(input.text, TEXT_MAX, "Reply text")
225
+ return this.answer(userID, original, input, "reply", text)
226
+ }
227
+
228
+ /** Declines an ask, with a reason. */
229
+ async decline(userID: string, input: AnswerInput & { readonly why: string }): Promise<Answered> {
230
+ const original = await this.answerable(userID, input)
231
+ if (original.kind !== "ask") throw new AgentError("invalid", "Only asks can be declined")
232
+ const why = cleanText(input.why, WHY_MAX, "The reason")
233
+ return this.answer(userID, original, input, "decline", why)
234
+ }
235
+
236
+ async get(userID: string, id: string): Promise<Message | undefined> {
237
+ const stored = await this.state.get<StoredMessage>(userID, MESSAGES, id)
238
+ return stored && this.view(stored.value.message)
239
+ }
240
+
241
+ /** Messages the party sent or received, newest first. */
242
+ async list(userID: string, party: Party, options: { readonly limit?: number } = {}): Promise<Message[]> {
243
+ const ids = (await this.state.get<Index>(userID, INDEX, partyKey(party)))?.value.ids ?? []
244
+ const wanted = ids.slice().reverse().slice(0, options.limit ?? this.limits.session)
245
+ const messages = await Promise.all(wanted.map((id) => this.get(userID, id)))
246
+ return messages.filter((message): message is Message => message !== undefined)
247
+ }
248
+
249
+ async inbox(userID: string, party: Party): Promise<Inbox> {
250
+ const key = partyKey(party)
251
+ const all = await this.list(userID, party)
252
+ const toMe = all.filter((message) => partyKey(message.recipient) === key)
253
+ return {
254
+ asks: toMe.filter(isOpenAsk),
255
+ waiting: all.filter((message) => isOpenAsk(message) && partyKey(message.from) === key),
256
+ messages: toMe.slice(0, INBOX_RECENT),
257
+ }
258
+ }
259
+
260
+ async usage(userID: string): Promise<Usage> {
261
+ return (await this.state.get<Usage>(userID, USAGE, USAGE_KEY))?.value ?? { count: 0, bytes: 0 }
262
+ }
263
+
264
+ /** Records how the message was delivered; undefined when there is no such message. */
265
+ async setDelivery(userID: string, id: string, delivery: Delivery): Promise<Message | undefined> {
266
+ const at = this.now()
267
+ const delivered = delivery === "steer" || delivery === "turn" || delivery === "queued"
268
+ const stored = await updateVersioned<StoredMessage>(this.state, userID, MESSAGES, id, (current) =>
269
+ current && {
270
+ ...current,
271
+ message: { ...current.message, delivery, ...(current.message.deliveredAt === undefined && delivered ? { deliveredAt: at } : {}) },
272
+ },
273
+ )
274
+ return stored && this.view(stored.message)
275
+ }
276
+
277
+ /** Closes the user's asks that are past expiresAt and returns them (to tell their senders). */
278
+ async expire(userID: string): Promise<Message[]> {
279
+ const expired: Message[] = []
280
+ for (const entry of await this.state.list<StoredMessage>(userID, MESSAGES)) {
281
+ if (!this.isDue(entry.value.message)) continue
282
+ const stored = await updateVersioned<StoredMessage>(this.state, userID, MESSAGES, entry.key, (current) =>
283
+ current && this.isDue(current.message) ? { ...current, message: { ...current.message, state: "expired" } } : undefined,
284
+ )
285
+ if (stored) expired.push(stored.message)
286
+ }
287
+ return expired
288
+ }
289
+
290
+ /** `by` refuses msg/ask from the session. */
291
+ async mute(userID: string, by: Party, sessionID: string): Promise<void> {
292
+ const at = this.now()
293
+ await updateVersioned<MuteList>(this.state, userID, MUTES, partyKey(by), (current) =>
294
+ current?.sessions[sessionID] !== undefined ? undefined : { sessions: { ...current?.sessions, [sessionID]: at } },
295
+ )
296
+ }
297
+
298
+ async unmute(userID: string, by: Party, sessionID: string): Promise<void> {
299
+ await updateVersioned<MuteList>(this.state, userID, MUTES, partyKey(by), (current) => {
300
+ if (current?.sessions[sessionID] === undefined) return undefined
301
+ const { [sessionID]: _, ...sessions } = current.sessions
302
+ return { sessions }
303
+ })
304
+ }
305
+
306
+ /** The sessions `by` muted, sorted. */
307
+ async muted(userID: string, by: Party): Promise<string[]> {
308
+ const list = (await this.state.get<MuteList>(userID, MUTES, partyKey(by)))?.value
309
+ return Object.keys(list?.sessions ?? {}).sort()
310
+ }
311
+
312
+ // ---- internals ----------------------------------------------------------------------------------------
313
+
314
+ /** The message as of now (an open ask past expiresAt reads expired). */
315
+ private view(message: Message): Message {
316
+ return this.isDue(message) ? { ...message, state: "expired" } : message
317
+ }
318
+
319
+ private isDue(message: Message): boolean {
320
+ return isOpenAsk(message) && message.expiresAt !== undefined && this.now() >= message.expiresAt
321
+ }
322
+
323
+ /** The message `input` answers, when `input.from` is its recipient; otherwise not_found (or gone for another generation). */
324
+ private async answerable(userID: string, input: AnswerInput): Promise<Message> {
325
+ const original = await this.get(userID, input.replyTo)
326
+ if (!original || partyKey(original.recipient) !== partyKey(input.from)) throw notFound()
327
+ if (input.generation !== undefined && original.generation !== undefined && input.generation !== original.generation) {
328
+ throw gone("The role changed hands since this message arrived")
329
+ }
330
+ return original
331
+ }
332
+
333
+ private async answer(userID: string, original: Message, input: AnswerInput, kind: "reply" | "decline", text: string): Promise<Answered> {
334
+ const at = this.now()
335
+ const message: Message = {
336
+ id: this.newID(),
337
+ kind,
338
+ from: input.from,
339
+ to: addressOf(original.from),
340
+ recipient: original.from,
341
+ text,
342
+ replyTo: original.id,
343
+ state: "sent",
344
+ at,
345
+ hop: input.hop ?? 0,
346
+ }
347
+ let closed = original
348
+ if (original.kind === "ask") {
349
+ if (original.state !== "open") throw gone()
350
+ const state = kind === "reply" ? "answered" : "declined"
351
+ const stored = await updateVersioned<StoredMessage>(this.state, userID, MESSAGES, original.id, (current) =>
352
+ current && !this.isDue(current.message) && isOpenAsk(current.message) ? { ...current, message: { ...current.message, state, answer: { id: message.id, at } } } : undefined,
353
+ )
354
+ if (!stored) throw gone()
355
+ closed = stored.message
356
+ }
357
+ await this.store(userID, message)
358
+ return { message, original: closed }
359
+ }
360
+
361
+ /** Writes a new message, indexes it for its parties and enforces the caps. */
362
+ private async store(userID: string, message: Message): Promise<void> {
363
+ const indexed = [...new Set([partyKey(message.from), partyKey(message.recipient)])]
364
+ const bytes = Buffer.byteLength(JSON.stringify(message))
365
+ const written = await this.state.put<StoredMessage>(userID, MESSAGES, message.id, { message, indexed, bytes }, { ifVersion: 0 })
366
+ if (!written.ok) throw new Error("message id collision")
367
+ await this.addUsage(userID, 1, bytes)
368
+ for (const key of indexed) {
369
+ await updateVersioned<Index>(this.state, userID, INDEX, key, (current) => ({ ids: [...(current?.ids ?? []), message.id] }))
370
+ await this.trimIndex(userID, key)
371
+ }
372
+ await this.trimUser(userID)
373
+ }
374
+
375
+ private async addUsage(userID: string, count: number, bytes: number): Promise<void> {
376
+ await updateVersioned<Usage>(this.state, userID, USAGE, USAGE_KEY, (current) => ({
377
+ count: Math.max(0, (current?.count ?? 0) + count),
378
+ bytes: Math.max(0, (current?.bytes ?? 0) + bytes),
379
+ }))
380
+ }
381
+
382
+ /** Drops a party's oldest messages over the cap (open asks last). */
383
+ private async trimIndex(userID: string, key: string): Promise<void> {
384
+ const ids = (await this.state.get<Index>(userID, INDEX, key))?.value.ids ?? []
385
+ const excess = ids.length - this.limits.session
386
+ if (excess <= 0) return
387
+ const victims = await this.oldest(userID, ids, excess)
388
+ const drop = new Set(victims)
389
+ await updateVersioned<Index>(this.state, userID, INDEX, key, (current) => current && { ids: current.ids.filter((id) => !drop.has(id)) })
390
+ for (const id of victims) await this.detach(userID, id, [key])
391
+ }
392
+
393
+ /** Drops the user's oldest messages (open asks last) to 90% of a cap they exceed. */
394
+ private async trimUser(userID: string): Promise<void> {
395
+ const usage = await this.usage(userID)
396
+ if (usage.count <= this.limits.userMessages && usage.bytes <= this.limits.userBytes) return
397
+ const entries = (await this.state.list<StoredMessage>(userID, MESSAGES)).sort((a, b) => rank(a.value.message) - rank(b.value.message) || a.value.message.at - b.value.message.at)
398
+ let { count, bytes } = usage
399
+ const countTarget = Math.floor(this.limits.userMessages * 0.9)
400
+ const bytesTarget = Math.floor(this.limits.userBytes * 0.9)
401
+ for (const entry of entries) {
402
+ if (count <= countTarget && bytes <= bytesTarget) break
403
+ count -= 1
404
+ bytes -= entry.value.bytes
405
+ for (const key of entry.value.indexed) {
406
+ await updateVersioned<Index>(this.state, userID, INDEX, key, (current) => current && { ids: current.ids.filter((id) => id !== entry.key) })
407
+ }
408
+ await this.detach(userID, entry.key, entry.value.indexed)
409
+ }
410
+ }
411
+
412
+ /** The `count` oldest of `ids` (oldest first), passing over open asks while others remain. */
413
+ private async oldest(userID: string, ids: string[], count: number): Promise<string[]> {
414
+ const closed: string[] = []
415
+ const open: string[] = []
416
+ for (const id of ids) {
417
+ if (closed.length >= count) break
418
+ const message = await this.get(userID, id)
419
+ ;(message && isOpenAsk(message) ? open : closed).push(id)
420
+ }
421
+ return [...closed, ...open].slice(0, count)
422
+ }
423
+
424
+ /** Removes party keys from a message; deletes it (and its usage) when no party keeps it. */
425
+ private async detach(userID: string, id: string, keys: string[]): Promise<void> {
426
+ for (let attempt = 0; attempt < DETACH_ATTEMPTS; attempt++) {
427
+ const current = await this.state.get<StoredMessage>(userID, MESSAGES, id)
428
+ if (!current) return
429
+ const indexed = current.value.indexed.filter((key) => !keys.includes(key))
430
+ if (indexed.length) {
431
+ if ((await this.state.put<StoredMessage>(userID, MESSAGES, id, { ...current.value, indexed }, { ifVersion: current.version })).ok) return
432
+ } else if ((await this.state.delete(userID, MESSAGES, id, { ifVersion: current.version })).ok) {
433
+ await this.addUsage(userID, -1, -current.value.bytes)
434
+ return
435
+ }
436
+ }
437
+ throw new Error(`detaching message ${id} kept conflicting`)
438
+ }
439
+ }
440
+
441
+ /** Eviction order: closed messages before open asks. */
442
+ const rank = (message: Message) => (isOpenAsk(message) ? 1 : 0)
@@ -0,0 +1,86 @@
1
+ // Pure helpers for @name / @@role addressing (design §2.1): OpenCode's placeholder titles, slugs, derived names,
2
+ // conflict suffixes, the reserved name and reserved roles. Name syntax and address parsing live in the SDK (packages/sdk/src/address.ts)
3
+ // and are re-exported here.
4
+
5
+ import { NAME_MAX, validName } from "../../../packages/sdk/src/address.ts"
6
+
7
+ export { NAME_MAX, parseAddress, validName, type Address } from "../../../packages/sdk/src/address.ts"
8
+
9
+ /**
10
+ * OpenCode's fallback titles: "New session" / "Child session" (display labels) and the historical
11
+ * timestamped "New session - <ISO>" (see @opencode/util session-title-fallback). Spokes list an untitled
12
+ * session with an empty title.
13
+ */
14
+ const FALLBACK = /^(New session|Child session)( - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)?$/
15
+
16
+ /** A title the hub must not derive a name from (missing, blank or an OpenCode fallback). */
17
+ export const isPlaceholderTitle = (title: string | undefined): boolean => title === undefined || title.trim() === "" || FALLBACK.test(title.trim())
18
+
19
+ /** The slug of a title for a derived name: undefined when fewer than 2 usable characters remain. */
20
+ export function slugify(title: string): string | undefined {
21
+ const slug = title
22
+ .normalize("NFKD")
23
+ .replace(/[\u0300-\u036f]/g, "")
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9]+/g, "-")
26
+ .replace(/^-+|-+$/g, "")
27
+ .slice(0, NAME_MAX)
28
+ .replace(/-+$/, "")
29
+ return validName(slug) ? slug : undefined
30
+ }
31
+
32
+ /** `base` when free, else `base-2`, `base-3`, … (the base cut so the result stays a valid name). */
33
+ export function withSuffix(base: string, taken: (name: string) => boolean): string {
34
+ if (!taken(base)) return base
35
+ for (let n = 2; ; n++) {
36
+ const suffix = `-${n}`
37
+ const name = `${base.slice(0, NAME_MAX - suffix.length).replace(/-+$/, "")}${suffix}`
38
+ if (!taken(name)) return name
39
+ }
40
+ }
41
+
42
+ /** `me` always addresses the user (@me), so no session may hold it, explicitly or derived. Only the exact name. */
43
+ export const isReservedName = (name: string): boolean => name === "me"
44
+
45
+ /** Roles only the user may claim in phase 1 (grants including `roles` come in phase 3). */
46
+ const RESERVED_ROLES = ["orchestrator", "concierge"]
47
+
48
+ export const isReservedRole = (role: string): boolean => RESERVED_ROLES.some((prefix) => role.startsWith(prefix))
49
+
50
+ /** The longest derived name: a few words, short enough to type and read in an address (@send-hello-to-session). */
51
+ export const DERIVED_NAME_MAX = 24
52
+ /** At most this many words of the title make a derived name. */
53
+ const DERIVED_WORDS_MAX = 4
54
+
55
+ /** Tokens that identify rather than describe (dropped from derived names): ses_…/msg_… IDs, UUIDs, hashes, long letter+digit runs. */
56
+ const ID_LIKE = [
57
+ /^[a-z]{2,5}_[a-z0-9]{6,}$/i,
58
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
59
+ /^(?=[a-f]*\d)[0-9a-f]{8,}$/i,
60
+ /^(?=.*\d)(?=.*[a-z])[a-z0-9]{10,}$/i,
61
+ ]
62
+ const isIDLike = (token: string): boolean => ID_LIKE.some((re) => re.test(token))
63
+
64
+ /**
65
+ * The name derived from a session title: its first few meaningful words (ID-like tokens dropped), at most
66
+ * DERIVED_WORDS_MAX words and DERIVED_NAME_MAX characters, cut at a word boundary (a single longer word is cut).
67
+ * Undefined when fewer than 2 usable characters remain. Derived once per session (see directory.ts), so later
68
+ * title changes never rename it.
69
+ */
70
+ export function deriveName(title: string): string | undefined {
71
+ const words = title
72
+ .split(/\s+/)
73
+ .map((token) => token.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
74
+ .filter((token) => token && !isIDLike(token))
75
+ .flatMap((token) => slugify(token)?.split("-") ?? (/^[a-z0-9]$/i.test(token) ? [token.toLowerCase()] : []))
76
+ let name = ""
77
+ for (const word of words.slice(0, DERIVED_WORDS_MAX)) {
78
+ const next = name ? `${name}-${word}` : word
79
+ if (next.length > DERIVED_NAME_MAX) {
80
+ if (!name) name = word.slice(0, DERIVED_NAME_MAX)
81
+ break
82
+ }
83
+ name = next
84
+ }
85
+ return validName(name) ? name : undefined
86
+ }