@huynle/hub 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,482 @@
1
+ // Context-linked side chats (HWA-13; contract: Workspaces `POST /workspaces/:id/side-chats` in docs/plans/2026-10-30-
2
+ // hub-workspaces-artifacts-contracts.md, protocol SideChat and WORKSPACE_LIMITS). This module is the record store
3
+ // and the pure snapshot logic; sidechats-routes.ts is the HTTP side, which reads the source and launches the
4
+ // side-chat session through the existing /api/agent routes (so their grants, scopes and audits apply unchanged).
5
+ //
6
+ // A side chat is a NEW session launched through the launch API, started with a bounded copy of some of the source
7
+ // session's transcript. The link to the source is this record only: the side-chat session's native
8
+ // parentID/rootID and any supervision are untouched (a launched session is a root session).
9
+ //
10
+ // Snapshot rules (snapshotOf):
11
+ // - Sources are message IDs of the source session's transcript (timeline rows grouped by messageID), at most
12
+ // sideChatContextItems, read from the newest SNAPSHOT_SCAN_ROWS rows; no sources = the newest messages.
13
+ // `artifact_revision` sources are not supported in v1 (unsupported, nothing created).
14
+ // - What is stored is exactly what is delivered: the rendered context fits SIDECHAT_DELIVERY_MAX characters (the
15
+ // launch prompt and message text limits), and its quoted text stays within sideChatContextBytes. When it does
16
+ // not fit, the oldest items are omitted (counted in `omitted`) and a single oversized item is clipped (`clipped`).
17
+ // - Each item keeps its time and ordering references (`at`, `pos`); the snapshot keeps `takenAt`, its
18
+ // `generation` (1 at creation, +1 per explicit refresh) and the newest source row time it saw.
19
+ // - Refresh re-reads the same sources (or, for a "recent" snapshot, the newest messages again). An item whose
20
+ // source message can no longer be read keeps its previous text marked `stale: "missing"`; an item whose text
21
+ // changed is marked `changed`.
22
+ //
23
+ // Storage (StateStore, tenant = hub user): `workspace-sidechats` (one record per side chat, by ID) and
24
+ // `workspace-sidechat-index` (one aggregate per workspace: the IDs, to cap sideChatsPerWorkspace atomically; a
25
+ // reservation without a record is dropped after RESERVATION_MS). Records survive session and launch expiry.
26
+ // Events are compact and text-free (IDs, revisions, counts).
27
+ import { createHash, randomBytes } from "node:crypto"
28
+ import { ID_PREFIXES, LIST_LIMIT_DEFAULT, LIST_LIMIT_MAX, WORKSPACE_LIMITS, type Page, type SideChat } from "../../protocol"
29
+ import type { Clock, StateStore } from "../backend/types"
30
+ import type { EventInput } from "../agent/events"
31
+ import { AgentError } from "../agent/errors"
32
+ import { PROMPT_MAX } from "../agent/launch-spec"
33
+ import { TEXT_MAX } from "../agent/messages"
34
+ import { updateVersioned } from "../agent/versioned"
35
+ import { RESERVATION_MS } from "./store"
36
+
37
+ export const WORKSPACE_SIDECHATS = "workspace-sidechats"
38
+ export const WORKSPACE_SIDECHAT_INDEX = "workspace-sidechat-index"
39
+
40
+ /** The rendered context (launch prompt, refresh message) never exceeds the launch prompt or message text limit. */
41
+ export const SIDECHAT_DELIVERY_MAX = Math.min(PROMPT_MAX, TEXT_MAX)
42
+ /** Newest transcript rows a snapshot looks at (the log route's maximum page). */
43
+ export const SNAPSHOT_SCAN_ROWS = 200
44
+ /** Longest source message ID accepted. */
45
+ export const SOURCE_ID_MAX = 128
46
+
47
+ export interface SideChatLimits {
48
+ readonly sideChatsPerWorkspace: number
49
+ readonly sideChatContextItems: number
50
+ readonly sideChatContextBytes: number
51
+ readonly deliveryMax: number
52
+ }
53
+
54
+ export const SIDECHAT_LIMITS: SideChatLimits = {
55
+ sideChatsPerWorkspace: WORKSPACE_LIMITS.sideChatsPerWorkspace,
56
+ sideChatContextItems: WORKSPACE_LIMITS.sideChatContextItems,
57
+ sideChatContextBytes: WORKSPACE_LIMITS.sideChatContextBytes,
58
+ deliveryMax: SIDECHAT_DELIVERY_MAX,
59
+ }
60
+
61
+ export type SnapshotRole = "user" | "assistant" | "message"
62
+
63
+ export interface SnapshotItem {
64
+ readonly kind: "message"
65
+ /** The source message ID. */
66
+ readonly id: string
67
+ readonly role: SnapshotRole
68
+ /** When the source message was written (ms), when the spoke reported it. */
69
+ readonly at?: number
70
+ /** The source's ordering position, when reported. */
71
+ readonly pos?: number
72
+ readonly text: string
73
+ /** UTF-8 bytes of `text`. */
74
+ readonly bytes: number
75
+ readonly clipped?: true
76
+ /** Refresh markers: the source message changed since the previous snapshot, or can't be read any more. */
77
+ readonly changed?: true
78
+ readonly stale?: "missing"
79
+ }
80
+
81
+ export interface SideChatSnapshot {
82
+ readonly generation: number
83
+ readonly takenAt: number
84
+ /** selected: the caller named the messages; recent: the newest messages at the time. */
85
+ readonly mode: "selected" | "recent"
86
+ readonly items: readonly SnapshotItem[]
87
+ readonly bytes: number
88
+ /** Selected or newest messages left out to stay within the bounds. */
89
+ readonly omitted: number
90
+ /** The newest source row time seen when the snapshot was taken (the "as of" reference). */
91
+ readonly sourceNewestAt?: number
92
+ }
93
+
94
+ /**
95
+ * The stored record: the frozen SideChat plus additive fields. `sessionID` is set once the launch reports the
96
+ * side-chat session (absent while it launches, or when the launch never produced one).
97
+ */
98
+ export interface StoredSideChat extends Omit<SideChat, "sessionID"> {
99
+ readonly sessionID?: string
100
+ readonly launchID: string
101
+ /** Who created it: `user` or `app:<token id>` (as launches record it). */
102
+ readonly by: string
103
+ readonly bindingID: string
104
+ readonly machineID: string
105
+ readonly harness: string
106
+ readonly snapshot: SideChatSnapshot
107
+ readonly updatedAt: number
108
+ }
109
+
110
+ interface IndexEntry {
111
+ readonly id: string
112
+ readonly at: number
113
+ }
114
+ interface IndexRecord {
115
+ readonly v: 1
116
+ readonly entries: readonly IndexEntry[]
117
+ }
118
+
119
+ // ---------- the snapshot (pure) ----------
120
+
121
+ /** A transcript row as the spoke's timeline reports it (src/spoke/timeline.ts TimelineItem); unknown fields ignored. */
122
+ interface Row {
123
+ readonly messageID: string
124
+ readonly kind: string
125
+ readonly text?: string
126
+ readonly name?: string
127
+ readonly state?: string
128
+ readonly at?: number
129
+ readonly pos?: number
130
+ }
131
+
132
+ type Message = Omit<SnapshotItem, "bytes">
133
+
134
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
135
+ const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
136
+
137
+ function rowOf(raw: unknown): Row | undefined {
138
+ if (!isObj(raw) || typeof raw.messageID !== "string" || !raw.messageID || typeof raw.kind !== "string") return undefined
139
+ const at = num(raw.at)
140
+ const pos = num(raw.pos)
141
+ return {
142
+ messageID: raw.messageID,
143
+ kind: raw.kind,
144
+ ...(typeof raw.text === "string" ? { text: raw.text } : {}),
145
+ ...(typeof raw.name === "string" ? { name: raw.name } : {}),
146
+ ...(typeof raw.state === "string" ? { state: raw.state } : {}),
147
+ ...(at !== undefined ? { at } : {}),
148
+ ...(pos !== undefined ? { pos } : {}),
149
+ }
150
+ }
151
+
152
+ /** Control characters the launch prompt and message routes refuse (tab, newline and CR stay). */
153
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/g
154
+ const clean = (text: string) => text.replace(CONTROL, "")
155
+ const utf8 = (text: string) => Buffer.byteLength(text, "utf8")
156
+
157
+ /** The first `max` UTF-8 bytes of `text`, never splitting a character. */
158
+ export function clipBytes(text: string, max: number): string {
159
+ if (utf8(text) <= max) return text
160
+ return Buffer.from(text, "utf8").subarray(0, Math.max(0, max)).toString("utf8").replace(/\uFFFD+$/, "")
161
+ }
162
+
163
+ /** Transcript order: by position, else time, else as reported. */
164
+ const byOrder = (a: { pos?: number; at?: number; i: number }, b: { pos?: number; at?: number; i: number }) =>
165
+ a.pos !== undefined && b.pos !== undefined ? a.pos - b.pos : a.at !== undefined && b.at !== undefined ? a.at - b.at : a.i - b.i
166
+
167
+ const defined = (values: readonly (number | undefined)[]) => values.filter((v): v is number => v !== undefined)
168
+
169
+ /** One message's rows as snapshot text: user, assistant and hub-message text; tools as one-line markers. */
170
+ function messageOf(rows: readonly Row[]): Message | undefined {
171
+ const parts: string[] = []
172
+ let role: SnapshotRole | undefined
173
+ for (const row of rows) {
174
+ if (row.kind === "user" || row.kind === "assistant" || row.kind === "message") {
175
+ role ??= row.kind
176
+ if (row.text) parts.push(clean(row.text))
177
+ } else if (row.kind === "tool") parts.push(`[tool ${clean(row.name ?? "call").slice(0, 80)}${row.state ? ` ${row.state}` : ""}]`)
178
+ // Reasoning rows stay out of the copy.
179
+ }
180
+ if (!role) return undefined
181
+ const at = defined(rows.map((r) => r.at))
182
+ const pos = defined(rows.map((r) => r.pos))
183
+ return {
184
+ kind: "message",
185
+ id: rows[0]!.messageID,
186
+ role,
187
+ ...(at.length ? { at: Math.min(...at) } : {}),
188
+ ...(pos.length ? { pos: Math.min(...pos) } : {}),
189
+ text: parts.join("\n").trim(),
190
+ }
191
+ }
192
+
193
+ /** The source's messages in transcript order, and the newest row time. */
194
+ export function messagesOf(rawRows: readonly unknown[]): { messages: Message[]; newestAt?: number } {
195
+ const rows = rawRows.map(rowOf).filter((r): r is Row => r !== undefined)
196
+ const ordered = rows.map((row, i) => ({ ...row, i })).sort(byOrder)
197
+ const byMessage = new Map<string, Row[]>()
198
+ for (const row of ordered) byMessage.set(row.messageID, [...(byMessage.get(row.messageID) ?? []), row])
199
+ const messages = [...byMessage.values()].map(messageOf).filter((m): m is Message => m !== undefined)
200
+ const times = defined(rows.map((r) => r.at))
201
+ return { messages, ...(times.length ? { newestAt: Math.max(...times) } : {}) }
202
+ }
203
+
204
+ export interface SnapshotRequest {
205
+ /** Named source messages (in any order); empty = the newest messages. */
206
+ readonly ids: readonly string[]
207
+ readonly limits?: Partial<SideChatLimits>
208
+ /** For refresh: the previous snapshot (its mode, changed / missing markers). */
209
+ readonly previous?: SideChatSnapshot
210
+ }
211
+
212
+ export interface RenderInfo {
213
+ readonly sideChatID: string
214
+ readonly workspaceID: string
215
+ readonly sourceSessionID: string
216
+ }
217
+
218
+ const isoOf = (ms: number | undefined) => (ms === undefined ? "time unknown" : new Date(ms).toISOString())
219
+
220
+ /** The context a side-chat session gets (the launch prompt; a refresh's message): exactly the snapshot's items. */
221
+ export function renderContext(info: RenderInfo, snapshot: SideChatSnapshot): string {
222
+ const intro =
223
+ snapshot.generation === 1
224
+ ? `[Hub side chat ${info.sideChatID}] You are a side conversation started from session ${info.sourceSessionID} (hub workspace ${info.workspaceID}). ` +
225
+ `Below is a read-only copy of ${snapshot.items.length} message(s) from that session, taken ${isoOf(snapshot.takenAt)}. ` +
226
+ "It does not update by itself and you cannot act in that session from here. Briefly acknowledge the context, then answer the user's questions in this conversation."
227
+ : `[Hub side chat ${info.sideChatID}] The user refreshed the context from session ${info.sourceSessionID} (${isoOf(snapshot.takenAt)}, refresh ${snapshot.generation - 1}). ` +
228
+ "It replaces the earlier copy; items marked changed or missing differ from it."
229
+ const lines = [intro]
230
+ if (snapshot.omitted > 0) lines.push(`(${snapshot.omitted} older message(s) were left out to stay within the side-chat context bounds.)`)
231
+ lines.push("--- context ---")
232
+ snapshot.items.forEach((item, i) => {
233
+ const marks = [item.clipped ? "clipped" : "", item.changed ? "changed" : "", item.stale === "missing" ? "missing at source (previous copy)" : ""].filter(Boolean)
234
+ lines.push(`[${i + 1}] ${item.role} · ${isoOf(item.at)} · ${item.id}${marks.length ? ` · ${marks.join(", ")}` : ""}`, item.text, "")
235
+ })
236
+ lines.push("--- end of context ---")
237
+ return lines.join("\n")
238
+ }
239
+
240
+ /** The messages a snapshot quotes, before bounds: the newest ones, or the named ones (previous copies for missing). */
241
+ function pick(messages: readonly Message[], request: SnapshotRequest, mode: SideChatSnapshot["mode"], items: number): { picked: Message[]; omitted: number } {
242
+ if (mode === "recent") {
243
+ const withText = messages.filter((m) => m.text)
244
+ const picked = withText.slice(-items)
245
+ return { picked, omitted: withText.length - picked.length }
246
+ }
247
+ const byID = new Map(messages.map((m) => [m.id, m]))
248
+ const before = new Map((request.previous?.items ?? []).map((item) => [item.id, item]))
249
+ const missing = request.ids.filter((id) => !byID.has(id) && !before.has(id))
250
+ if (missing.length) {
251
+ throw new AgentError("invalid", `${missing.length} source message(s) are not among the source session's newest ${SNAPSHOT_SCAN_ROWS} transcript rows`, { extra: { missing: missing.slice(0, 20) } })
252
+ }
253
+ const picked: Message[] = request.ids.map((id) => {
254
+ const found = byID.get(id)
255
+ if (found) return found
256
+ const { bytes: _bytes, changed: _changed, ...old } = before.get(id)!
257
+ return { ...old, stale: "missing" as const }
258
+ })
259
+ return { picked: picked.map((m, i) => ({ ...m, i })).sort(byOrder).map(({ i: _i, ...m }) => m), omitted: 0 }
260
+ }
261
+
262
+ /** Marks items whose text differs from the previous snapshot's (refresh). */
263
+ function marked(picked: readonly Message[], previous: SideChatSnapshot | undefined): Message[] {
264
+ if (!previous) return [...picked]
265
+ const before = new Map(previous.items.map((item) => [item.id, item]))
266
+ return picked.map((item) => {
267
+ const old = before.get(item.id)
268
+ const { changed: _changed, ...rest } = item
269
+ return item.stale !== "missing" && old && old.stale !== "missing" && !old.clipped && old.text !== item.text ? { ...rest, changed: true as const } : rest
270
+ })
271
+ }
272
+
273
+ /**
274
+ * Builds a bounded snapshot from the source rows. Throws invalid when named messages are neither among the rows nor
275
+ * in the previous snapshot (a refresh keeps the previous copy of a message it can no longer read, marked missing).
276
+ */
277
+ export function snapshotOf(rawRows: readonly unknown[], request: SnapshotRequest, info: RenderInfo, takenAt: number): SideChatSnapshot {
278
+ const limits = { ...SIDECHAT_LIMITS, ...request.limits }
279
+ const { messages, newestAt } = messagesOf(rawRows)
280
+ const mode: SideChatSnapshot["mode"] = request.previous?.mode ?? (request.ids.length ? "selected" : "recent")
281
+ const chosen = pick(messages, request, mode, limits.sideChatContextItems)
282
+ let omitted = chosen.omitted
283
+ let items: SnapshotItem[] = marked(chosen.picked, request.previous).map((m) => {
284
+ const text = clipBytes(m.text, limits.sideChatContextBytes)
285
+ return { ...m, text, bytes: utf8(text), ...(text !== m.text ? { clipped: true as const } : {}) }
286
+ })
287
+ const snapshot = (): SideChatSnapshot => ({
288
+ generation: (request.previous?.generation ?? 0) + 1,
289
+ takenAt,
290
+ mode,
291
+ items,
292
+ bytes: items.reduce((n, item) => n + item.bytes, 0),
293
+ omitted,
294
+ ...(newestAt !== undefined ? { sourceNewestAt: newestAt } : {}),
295
+ })
296
+ // The newest items win: drop the oldest until the quoted text and the whole render fit.
297
+ const tooBig = () => {
298
+ const s = snapshot()
299
+ return s.bytes > limits.sideChatContextBytes || renderContext(info, s).length > limits.deliveryMax
300
+ }
301
+ while (items.length > 1 && tooBig()) {
302
+ items = items.slice(1)
303
+ omitted++
304
+ }
305
+ const over = renderContext(info, snapshot()).length - limits.deliveryMax
306
+ if (items.length === 1 && over > 0) {
307
+ const only = items[0]!
308
+ // Characters over the limit, as bytes (≥ 1 byte per character), and a margin for the clipped marker.
309
+ const text = clipBytes(only.text, Math.max(0, only.bytes - over * 4 - 32))
310
+ items = [{ ...only, text, bytes: utf8(text), clipped: true }]
311
+ }
312
+ return snapshot()
313
+ }
314
+
315
+ // ---------- events (family manage, text-free) ----------
316
+
317
+ export const sideChatCreatedEvent = (chat: StoredSideChat): EventInput => ({
318
+ type: "workspace.sidechat.created",
319
+ family: "manage",
320
+ subject: { workspaceID: chat.workspaceID, sideChatID: chat.id, sourceSessionID: chat.sourceSessionID, launchID: chat.launchID, machineID: chat.machineID },
321
+ data: { revision: chat.revision, generation: chat.snapshot.generation, items: chat.snapshot.items.length, bytes: chat.snapshot.bytes, omitted: chat.snapshot.omitted },
322
+ })
323
+
324
+ /** The launch reported the side-chat session (linked) or the context was refreshed. */
325
+ export function sideChatChangedEvent(chat: StoredSideChat, change: "sidechat_linked" | "sidechat_refreshed"): EventInput {
326
+ const items = chat.snapshot.items
327
+ const counts: Record<string, number> = change === "sidechat_refreshed" ? { generation: chat.snapshot.generation, items: items.length, changed: items.filter((i) => i.changed).length, missing: items.filter((i) => i.stale).length } : {}
328
+ return {
329
+ type: "workspace.updated",
330
+ family: "manage",
331
+ subject: { workspaceID: chat.workspaceID, sideChatID: chat.id, ...(chat.sessionID ? { sessionID: chat.sessionID } : {}) },
332
+ data: { change, sideChatRevision: chat.revision, ...counts },
333
+ }
334
+ }
335
+
336
+ // ---------- the store ----------
337
+
338
+ export interface SideChatStoreDeps {
339
+ readonly state: StateStore
340
+ readonly now?: Clock
341
+ readonly limits?: Partial<SideChatLimits>
342
+ }
343
+
344
+ export interface NewSideChat {
345
+ readonly id: string
346
+ readonly workspaceID: string
347
+ readonly sourceSessionID: string
348
+ readonly launchID: string
349
+ readonly sessionID?: string
350
+ readonly by: string
351
+ readonly bindingID: string
352
+ readonly machineID: string
353
+ readonly harness: string
354
+ readonly snapshot: SideChatSnapshot
355
+ }
356
+
357
+ /** A side-chat ID; derived from a seed (an Idempotency-Key) so a retried create reuses the same launch key. */
358
+ export function sideChatID(seed?: string): string {
359
+ const body = seed === undefined ? randomBytes(12).toString("hex") : createHash("sha256").update(seed).digest("hex").slice(0, 24)
360
+ return `${ID_PREFIXES.sideChat}_${body}`
361
+ }
362
+
363
+ const sourcesOf = (snapshot: SideChatSnapshot) => snapshot.items.map((item) => ({ kind: "message" as const, id: item.id }))
364
+
365
+ export class SideChatStore {
366
+ readonly state: StateStore
367
+ readonly limits: SideChatLimits
368
+ private readonly now: Clock
369
+
370
+ constructor(deps: SideChatStoreDeps) {
371
+ this.state = deps.state
372
+ this.now = deps.now ?? Date.now
373
+ this.limits = { ...SIDECHAT_LIMITS, ...deps.limits }
374
+ }
375
+
376
+ /** Reserves a slot for `id` in the workspace (rate_limited at the cap). Reserving an ID again is a no-op. */
377
+ async reserve(userID: string, workspaceID: string, id: string): Promise<void> {
378
+ const at = this.now()
379
+ const stored = await this.storedIDs(userID)
380
+ let full = false
381
+ await updateVersioned<IndexRecord>(this.state, userID, WORKSPACE_SIDECHAT_INDEX, workspaceID, (current) => {
382
+ full = false
383
+ const entries = (current?.entries ?? []).filter((e) => stored.has(e.id) || at - e.at < RESERVATION_MS)
384
+ if (entries.some((e) => e.id === id)) return undefined
385
+ if (entries.length >= this.limits.sideChatsPerWorkspace) {
386
+ full = true
387
+ return undefined
388
+ }
389
+ return { v: 1, entries: [...entries, { id, at }] }
390
+ })
391
+ if (full) throw new AgentError("rate_limited", `A workspace has at most ${this.limits.sideChatsPerWorkspace} side chats`, { extra: { limit: this.limits.sideChatsPerWorkspace, retryAfter: 3600 } })
392
+ }
393
+
394
+ /** Frees a reservation whose create failed before its record was written (a written record keeps its slot). */
395
+ async release(userID: string, workspaceID: string, id: string): Promise<void> {
396
+ if (await this.get(userID, id)) return
397
+ await updateVersioned<IndexRecord>(this.state, userID, WORKSPACE_SIDECHAT_INDEX, workspaceID, (current) =>
398
+ current?.entries.some((e) => e.id === id) ? { v: 1, entries: current.entries.filter((e) => e.id !== id) } : undefined,
399
+ )
400
+ }
401
+
402
+ /** Writes the record once (a repeat with the same ID answers the existing one, created false). */
403
+ async insert(userID: string, input: NewSideChat): Promise<{ sideChat: StoredSideChat; created: boolean }> {
404
+ const at = this.now()
405
+ const chat: StoredSideChat = {
406
+ v: 1,
407
+ id: input.id,
408
+ workspaceID: input.workspaceID,
409
+ sourceSessionID: input.sourceSessionID,
410
+ ...(input.sessionID ? { sessionID: input.sessionID } : {}),
411
+ sources: sourcesOf(input.snapshot),
412
+ launchID: input.launchID,
413
+ by: input.by,
414
+ bindingID: input.bindingID,
415
+ machineID: input.machineID,
416
+ harness: input.harness,
417
+ snapshot: input.snapshot,
418
+ revision: 1,
419
+ createdAt: at,
420
+ updatedAt: at,
421
+ }
422
+ const put = await this.state.put(userID, WORKSPACE_SIDECHATS, chat.id, chat, { ifVersion: 0 })
423
+ if (put.ok) return { sideChat: chat, created: true }
424
+ const existing = await this.get(userID, chat.id)
425
+ if (!existing) throw new Error(`side chat ${chat.id} could not be written`)
426
+ return { sideChat: existing, created: false }
427
+ }
428
+
429
+ async get(userID: string, id: string): Promise<StoredSideChat | undefined> {
430
+ const stored = await this.state.get<StoredSideChat>(userID, WORKSPACE_SIDECHATS, id)
431
+ return stored?.value.v === 1 ? stored.value : undefined
432
+ }
433
+
434
+ /** The side chat (in that workspace, when named), or not_found (also another user's). */
435
+ async require(userID: string, workspaceID: string | undefined, id: string): Promise<StoredSideChat> {
436
+ const chat = await this.get(userID, id)
437
+ if (!chat || (workspaceID !== undefined && chat.workspaceID !== workspaceID)) throw new AgentError("not_found", `No side chat ${id}`)
438
+ return chat
439
+ }
440
+
441
+ /** The workspace's side chats, oldest first, paged. */
442
+ async list(userID: string, workspaceID: string, query: { readonly limit?: number; readonly cursor?: string } = {}): Promise<Page<StoredSideChat>> {
443
+ const limit = query.limit ?? LIST_LIMIT_DEFAULT
444
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
445
+ const all = (await this.state.list<StoredSideChat>(userID, WORKSPACE_SIDECHATS))
446
+ .map((e) => e.value)
447
+ .filter((c) => c.v === 1 && c.workspaceID === workspaceID)
448
+ .sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id))
449
+ let start = 0
450
+ if (query.cursor !== undefined) {
451
+ start = all.findIndex((c) => c.id === query.cursor) + 1
452
+ if (start === 0) throw new AgentError("invalid", "Unknown cursor")
453
+ }
454
+ const items = all.slice(start, start + limit)
455
+ return start + limit < all.length ? { items, cursor: items[items.length - 1]!.id } : { items }
456
+ }
457
+
458
+ /** Records the side-chat session the launch reported (once; a later, different session is ignored). */
459
+ async link(userID: string, id: string, sessionID: string): Promise<StoredSideChat | undefined> {
460
+ return updateVersioned<StoredSideChat>(this.state, userID, WORKSPACE_SIDECHATS, id, (current) =>
461
+ current && current.sessionID === undefined ? { ...current, sessionID, revision: current.revision + 1, updatedAt: this.now() } : undefined,
462
+ )
463
+ }
464
+
465
+ /** Replaces the snapshot under the expected revision (409 conflict with currentRevision when stale). */
466
+ async refresh(userID: string, workspaceID: string, id: string, expectedRevision: number, snapshot: SideChatSnapshot): Promise<StoredSideChat> {
467
+ let current: StoredSideChat | undefined
468
+ const next = await updateVersioned<StoredSideChat>(this.state, userID, WORKSPACE_SIDECHATS, id, (value) => {
469
+ current = value
470
+ if (!value || value.workspaceID !== workspaceID || value.revision !== expectedRevision) return undefined
471
+ return { ...value, snapshot, sources: sourcesOf(snapshot), revision: value.revision + 1, updatedAt: this.now() }
472
+ })
473
+ if (next) return next
474
+ if (!current || current.workspaceID !== workspaceID) throw new AgentError("not_found", `No side chat ${id}`)
475
+ throw new AgentError("conflict", "The side chat changed since you read it", { extra: { currentRevision: current.revision } })
476
+ }
477
+
478
+ /** IDs of every stored side chat of the user (their reservations never expire). */
479
+ private async storedIDs(userID: string): Promise<Set<string>> {
480
+ return new Set((await this.state.list<StoredSideChat>(userID, WORKSPACE_SIDECHATS)).map((e) => e.key))
481
+ }
482
+ }