@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,505 @@
1
+ // Session intents (CON-02; contract: "Session intent (CON-02)" in docs/plans/2026-11-01-concierge-contracts.md):
2
+ // what a session is for, in one untrusted line the hub keeps beside the session (never on AgentSession, so 2000
3
+ // sessions × 280 characters do not bloat the user record).
4
+ //
5
+ // Storage: StateStore collection `session-intents` (tenant = hub user, key = session ID), one record per session
6
+ // holding a LAYER per source. Precedence user > launch > self > prompt: the highest layer present is the intent, so
7
+ // a lower source never replaces a higher one, and clearing a layer (text null) falls back to the next one. `title`
8
+ // is never stored: reads fall back to the session's non-placeholder title (titleIntent).
9
+ // - user: PUT by the user, or an app token holding sessions:manage (`by` tells them apart)
10
+ // - launch: the launch spec's `intent`, stored when the launched session is identified
11
+ // - self: PUT by a session for itself
12
+ // - prompt: derived ONCE, from the first prompt the hub carries for the session (console turn, launch prompt,
13
+ // agent prompt through acting): first line, then first sentence, cut to 280 characters, no model call.
14
+ //
15
+ // `revision` counts changes of the EFFECTIVE intent (text or source), 1 for the first; a write that only changes a
16
+ // layer beneath it bumps nothing. Writers send `expectedRevision` (0 before anything was stored); a stale one is
17
+ // 409 conflict {currentRevision}. Records are version-checked (updateVersioned), so concurrent writers retry.
18
+ // Every change of the effective intent appends `session.intent {source, revision}` (family manage) to the journal:
19
+ // never the text. `source` is "title" once nothing is stored any more.
20
+ //
21
+ // Reads on request paths use many(ids): one get per listed session, never the whole collection (the StateStore has
22
+ // no batch get; the gets run concurrently, pipelined on Postgres). Retention (IntentRetention, one leader
23
+ // "session-intents", first run a minute after election unless this process swept within the interval, then every
24
+ // 6 h) keeps every record of an online session (the registry decides, not the directory, which keeps at most 2000
25
+ // records per user and may have evicted an offline one) and drops records of deleted sessions (deletedAt;
26
+ // Directory.markDeleted is not called today) and of sessions neither seen (directory lastSeen, or a sweep while
27
+ // online) nor written for 30 days. A dropped record's revision starts over at 0, as the contract has it before
28
+ // anything is stored (coordinator decision, round-3 review): a client that held a revision across a 30-day-offline
29
+ // deletion could write over a newer intent at the same revision (ABA). That window is accepted.
30
+ //
31
+ // The text is untrusted data: sanitised (control, ANSI, bidi and zero-width characters go), folded onto one line,
32
+ // at most INTENT_MAX characters. The hub never interprets it.
33
+ import { INTENT_MAX, type IntentSource, type SessionIntent } from "../../protocol"
34
+ import type { Actions } from "../actions"
35
+ import { realTimers } from "../backend/memory-bus"
36
+ import type { StateStore, Timers } from "../backend/types"
37
+ import { sanitizeText } from "./envelope"
38
+ import { AgentError } from "./errors"
39
+ import type { AgentEvents, EventInput } from "./events"
40
+ import { isPlaceholderTitle } from "./names"
41
+ import { updateVersioned } from "./versioned"
42
+
43
+ export const INTENTS = "session-intents"
44
+
45
+ /** The sources the hub stores (title is a read fallback only). */
46
+ export type StoredSource = Exclude<IntentSource, "title">
47
+
48
+ /** Highest precedence first. */
49
+ export const INTENT_ORDER: readonly StoredSource[] = ["user", "launch", "self", "prompt"]
50
+
51
+ /** Raw input longer than this is refused before it is cleaned (bounds the work on hostile input). */
52
+ const RAW_MAX = 4 * 1024
53
+
54
+ interface Layer {
55
+ readonly text: string
56
+ readonly at: number
57
+ readonly by?: string
58
+ }
59
+
60
+ /** A session's stored record. */
61
+ interface StoredIntent {
62
+ readonly sessionID: string
63
+ readonly layers: Partial<Record<StoredSource, Layer>>
64
+ readonly revision: number
65
+ /** The first prompt was seen (it is derived once, even when a higher layer hides it). */
66
+ readonly prompted?: true
67
+ /** The last write (any layer), for retention; older records fall back to their newest layer. */
68
+ readonly touched?: number
69
+ /** The last time a retention sweep saw the session online. */
70
+ readonly seen?: number
71
+ }
72
+
73
+
74
+
75
+ /** When a record was last written. */
76
+ const touchedAt = (record: StoredIntent) => record.touched ?? Math.max(0, ...Object.values(record.layers).map((l) => l?.at ?? 0))
77
+
78
+ /**
79
+ * What retention knows of a session: online from the registry (whatever the directory holds), lastSeen and deletedAt
80
+ * from the directory when it has a record (it keeps at most 2000 per user, so an offline session may have none).
81
+ */
82
+ export interface IntentSessionFacts {
83
+ readonly online: boolean
84
+ readonly lastSeen?: number
85
+ readonly deletedAt?: number
86
+ }
87
+
88
+ /** Records of sessions neither seen (directory, or a sweep while online) nor written this long are dropped. */
89
+ export const INTENT_RETENTION_MS = 30 * 86_400_000
90
+ /** A sweep re-stamps `seen` on an online session's record at most this often. */
91
+ const SEEN_STEP_MS = 86_400_000
92
+
93
+ /** Sanitised, one line, trimmed, cut to `max` characters (code points). */
94
+ function clean(text: string, max = Infinity): string {
95
+ return Array.from(sanitizeText(text).replace(/\s+/g, " ").trim()).slice(0, max).join("").trimEnd()
96
+ }
97
+
98
+ /** A user, app, session or launch intent: sanitised, one line, 1–280 characters; anything else is 400 invalid. */
99
+ export function intentText(input: unknown): string {
100
+ const problem = `intent is text of 1–${INTENT_MAX} characters on one line`
101
+ if (typeof input !== "string" || input.length > RAW_MAX) throw new AgentError("invalid", problem)
102
+ const text = clean(input)
103
+ const length = Array.from(text).length
104
+ if (length === 0 || length > INTENT_MAX) throw new AgentError("invalid", problem)
105
+ return text
106
+ }
107
+
108
+ /** The intent of a first prompt: its first non-blank line, then that line's first sentence, ≤ 280 characters. */
109
+ export function deriveIntent(prompt: string): string | undefined {
110
+ const line = sanitizeText(prompt.slice(0, RAW_MAX))
111
+ .split("\n")
112
+ .map((l) => l.trim())
113
+ .find((l) => l.length > 0)
114
+ if (line === undefined) return undefined
115
+ return clean(firstSentence(line), INTENT_MAX) || undefined
116
+ }
117
+
118
+ /** Common abbreviations a sentence does not end at ("e.g. the docs", "Bun vs. Node"). */
119
+ const ABBREVIATIONS = new Set(["e.g", "i.e", "etc", "vs", "cf", "approx", "incl", "mr", "mrs", "ms", "dr", "st", "u.s", "u.k", "a.m", "p.m"])
120
+
121
+ /** The line up to its first sentence end ([.!?] then a space or the end) that does not close an abbreviation. */
122
+ function firstSentence(line: string): string {
123
+ const end = /[.!?](?=\s|$)/g
124
+ for (let match = end.exec(line); match; match = end.exec(line)) {
125
+ const word = /(\S+)$/.exec(line.slice(0, match.index))?.[1]?.toLowerCase().replace(/^[("'[]+/, "")
126
+ if (match[0] === "." && word !== undefined && ABBREVIATIONS.has(word)) continue
127
+ return line.slice(0, match.index + 1)
128
+ }
129
+ return line
130
+ }
131
+
132
+ /** The read fallback: the session's title when it is not a placeholder, as source "title". */
133
+ export function titleIntent(sessionID: string, title: string | undefined, revision: number, updatedAt: number): SessionIntent | undefined {
134
+ if (isPlaceholderTitle(title)) return undefined
135
+ const text = clean(title!, INTENT_MAX)
136
+ return text ? { sessionID, text, source: "title", revision, updatedAt } : undefined
137
+ }
138
+
139
+ /** The journal event of a changed intent: its source and revision, never the text. */
140
+ export const intentEvent = (sessionID: string, source: IntentSource, revision: number): EventInput => ({
141
+ type: "session.intent",
142
+ family: "manage",
143
+ subject: { sessionID },
144
+ data: { source, revision },
145
+ })
146
+
147
+ /** The effective intent of a record (its highest layer). */
148
+ function effective(record: StoredIntent | undefined): SessionIntent | undefined {
149
+ if (!record) return undefined
150
+ for (const source of INTENT_ORDER) {
151
+ const layer = record.layers[source]
152
+ if (layer) return { sessionID: record.sessionID, text: layer.text, source, revision: record.revision, updatedAt: layer.at, ...(layer.by !== undefined ? { by: layer.by } : {}) }
153
+ }
154
+ return undefined
155
+ }
156
+
157
+ const sameIntent = (a: SessionIntent | undefined, b: SessionIntent | undefined) => a?.text === b?.text && a?.source === b?.source
158
+
159
+ /** (CON-10) A Concierge proposal's prompt the user ran (Do it): its session, text and when it was answered. */
160
+ export interface ProposedPrompt {
161
+ readonly sessionID: string
162
+ readonly text: string
163
+ readonly at: number
164
+ }
165
+
166
+ /** The old build derived a run proposal's prompt in the background, right after the answer: within this window. */
167
+ export const PROPOSAL_DERIVE_WINDOW_MS = 60_000
168
+
169
+ export interface SessionIntentsDeps {
170
+ readonly state: StateStore
171
+ readonly now?: () => number
172
+ /** The journal: `session.intent` on every change of an effective intent. */
173
+ readonly events?: Pick<AgentEvents, "append">
174
+ /** Every change of an effective intent (the console stream: IDs, source and revision only, never the text). */
175
+ readonly onChange?: (userID: string, sessionID: string, source: IntentSource, revision: number) => void
176
+ }
177
+
178
+ /** A write through the API: user, app (as source user) or the session itself (source self). */
179
+ export interface IntentWrite {
180
+ readonly source: StoredSource
181
+ /** Validated by intentText here; null clears this source's layer. */
182
+ readonly text: string | null
183
+ readonly expectedRevision: number
184
+ /** Party key of the writer. */
185
+ readonly by?: string
186
+ }
187
+
188
+ export class SessionIntents {
189
+ private readonly now: () => number
190
+
191
+ constructor(private readonly deps: SessionIntentsDeps) {
192
+ this.now = deps.now ?? Date.now
193
+ }
194
+
195
+ /** The stored effective intent (undefined when nothing is stored: callers fall back to the title). */
196
+ async read(userID: string, sessionID: string): Promise<SessionIntent | undefined> {
197
+ return effective(await this.record(userID, sessionID))
198
+ }
199
+
200
+ /** The record's revision (0 before anything was stored, or after retention dropped it). */
201
+ async revision(userID: string, sessionID: string): Promise<number> {
202
+ return (await this.record(userID, sessionID))?.revision ?? 0
203
+ }
204
+
205
+ /** Every stored effective intent of a user, by session ID (lists the whole collection: not for request paths; use many). */
206
+ async all(userID: string): Promise<Map<string, SessionIntent>> {
207
+ const out = new Map<string, SessionIntent>()
208
+ for (const { key, value } of await this.deps.state.list<StoredIntent>(userID, INTENTS)) {
209
+ const intent = effective({ ...value, sessionID: key })
210
+ if (intent) out.set(key, intent)
211
+ }
212
+ return out
213
+ }
214
+
215
+ /** The stored effective intents of these sessions (one read each, concurrently; never the whole collection). */
216
+ async many(userID: string, sessionIDs: Iterable<string>): Promise<Map<string, SessionIntent>> {
217
+ const ids = [...new Set(sessionIDs)]
218
+ const records = await Promise.all(ids.map((id) => this.record(userID, id)))
219
+ const out = new Map<string, SessionIntent>()
220
+ records.forEach((record, i) => {
221
+ const intent = effective(record)
222
+ if (intent) out.set(ids[i]!, intent)
223
+ })
224
+ return out
225
+ }
226
+
227
+ /**
228
+ * Retention. A record is kept while its session is online (the sweep stamps `seen` then); it is dropped when its
229
+ * session is deleted, or when it was neither seen (directory lastSeen, or a sweep while online) nor written for
230
+ * INTENT_RETENTION_MS. A session the directory has no record of is judged the same way (offline, lastSeen unknown).
231
+ * Deletes and stamps are version-checked (a concurrent write wins); a dropped record's revision starts over at 0.
232
+ * Answers how many records were dropped.
233
+ */
234
+ async sweep(userID: string, facts: (sessionID: string) => IntentSessionFacts, options: { readonly retentionMs?: number } = {}): Promise<number> {
235
+ const retention = options.retentionMs ?? INTENT_RETENTION_MS
236
+ const t = this.now()
237
+ let dropped = 0
238
+ for (const { key, value, version } of await this.deps.state.list<StoredIntent>(userID, INTENTS)) {
239
+ const session = facts(key)
240
+ if (session.online) {
241
+ if (t - (value.seen ?? 0) >= SEEN_STEP_MS) await this.deps.state.put(userID, INTENTS, key, { ...value, seen: t }, { ifVersion: version })
242
+ continue
243
+ }
244
+ const last = Math.max(session.lastSeen ?? 0, touchedAt(value), value.seen ?? 0)
245
+ if (session.deletedAt === undefined && t - last <= retention) continue
246
+ if ((await this.deps.state.delete(userID, INTENTS, key, { ifVersion: version })).ok) dropped++
247
+ }
248
+ return dropped
249
+ }
250
+
251
+ /** Sets (or with null clears) one source's layer under expectedRevision; answers the effective intent. */
252
+ async set(userID: string, sessionID: string, write: IntentWrite): Promise<SessionIntent | undefined> {
253
+ const text = write.text === null ? null : intentText(write.text)
254
+ return this.change(userID, sessionID, (current) => {
255
+ const revision = current?.revision ?? 0
256
+ if (write.expectedRevision !== revision) {
257
+ throw new AgentError("conflict", `The intent changed since you read it (revision ${revision} now)`, { extra: { currentRevision: revision } })
258
+ }
259
+ // (CON-10) Clearing the first-prompt layer is a change the user made even when a higher layer hides it: the
260
+ // revision moves (a stale client must re-read) and it is journaled and audited like any change.
261
+ const bump = write.source === "prompt" && text === null ? ({ bump: true } as const) : {}
262
+ return { layer: write.source, value: text === null ? null : { text, at: this.now(), ...(write.by !== undefined ? { by: write.by } : {}) }, ...bump }
263
+ })
264
+ }
265
+
266
+ /** The launch spec's intent, when its session is identified (validated by the launch spec). */
267
+ async storeLaunch(userID: string, sessionID: string, text: string, by?: string): Promise<void> {
268
+ const clean = intentText(text)
269
+ await this.change(userID, sessionID, () => ({ layer: "launch", value: { text: clean, at: this.now(), ...(by !== undefined ? { by } : {}) } }))
270
+ }
271
+
272
+ /** Derives the prompt layer from the session's first prompt (once); true when it was recorded now. */
273
+ async derive(userID: string, sessionID: string, prompt: string): Promise<boolean> {
274
+ const text = deriveIntent(prompt)
275
+ if (text === undefined) return false
276
+ let recorded = false
277
+ await this.change(userID, sessionID, (current) => {
278
+ if (current?.prompted) return undefined
279
+ recorded = true
280
+ return { layer: "prompt", value: { text, at: this.now() }, prompted: true }
281
+ })
282
+ return recorded
283
+ }
284
+
285
+ /**
286
+ * (CON-10) One-time correction of records the build before CON-09a wrote: it derived a Concierge-proposed prompt
287
+ * (run as the user when they tapped Do it) as the session's first-prompt intent. A prompt layer whose text is the
288
+ * derivation of such a proposal's prompt, written within PROPOSAL_DERIVE_WINDOW_MS after the proposal was answered,
289
+ * is cleared (the session falls back to its title); `prompted` stays, so a later prompt never fills it. Idempotent.
290
+ * Answers how many layers were cleared.
291
+ */
292
+ async correctProposed(userID: string, proposals: readonly ProposedPrompt[]): Promise<number> {
293
+ let cleared = 0
294
+ const bySession = new Map<string, ProposedPrompt[]>()
295
+ for (const p of proposals) bySession.set(p.sessionID, [...(bySession.get(p.sessionID) ?? []), p])
296
+ for (const [sessionID, list] of bySession) {
297
+ const layer = (await this.record(userID, sessionID))?.layers.prompt
298
+ if (!layer) continue
299
+ const derived = list.some((p) => deriveIntent(p.text) === layer.text && layer.at >= p.at && layer.at - p.at <= PROPOSAL_DERIVE_WINDOW_MS)
300
+ if (!derived) continue
301
+ let done = false
302
+ await this.change(userID, sessionID, (current) => {
303
+ if (current?.layers.prompt?.text !== layer.text || current.layers.prompt.at !== layer.at) return undefined
304
+ done = true
305
+ return { layer: "prompt", value: null }
306
+ })
307
+ if (done) cleared++
308
+ }
309
+ return cleared
310
+ }
311
+
312
+ private async record(userID: string, sessionID: string): Promise<StoredIntent | undefined> {
313
+ const value = (await this.deps.state.get<StoredIntent>(userID, INTENTS, sessionID))?.value
314
+ return value && { ...value, sessionID }
315
+ }
316
+
317
+ /**
318
+ * Applies one layer change (value null clears it; `plan` undefined leaves the record alone). The revision grows
319
+ * only when the effective intent changes, and only then is the event appended.
320
+ */
321
+ private async change(
322
+ userID: string,
323
+ sessionID: string,
324
+ plan: (current: StoredIntent | undefined) => { layer: StoredSource; value: Layer | null; prompted?: true; bump?: true } | undefined,
325
+ ): Promise<SessionIntent | undefined> {
326
+ let before: SessionIntent | undefined
327
+ let bumped = false
328
+ const written = await updateVersioned<StoredIntent>(this.deps.state, userID, INTENTS, sessionID, (stored) => {
329
+ const current = stored && { ...stored, sessionID }
330
+ before = effective(current)
331
+ bumped = false
332
+ const step = plan(current)
333
+ if (!step) return undefined
334
+ const layers = { ...current?.layers }
335
+ const old = layers[step.layer]
336
+ if (step.value === null) {
337
+ if (!old) return undefined
338
+ delete layers[step.layer]
339
+ } else {
340
+ if (old?.text === step.value.text && !step.prompted) return undefined
341
+ layers[step.layer] = old?.text === step.value.text ? old : step.value
342
+ }
343
+ const prompted = step.prompted || current?.prompted ? { prompted: true as const } : {}
344
+ const draft: StoredIntent = { sessionID, layers, revision: current?.revision ?? 0, ...prompted, touched: this.now(), ...(current?.seen !== undefined ? { seen: current.seen } : {}) }
345
+ bumped = !sameIntent(before, effective(draft)) || step.bump === true
346
+ return bumped ? { ...draft, revision: draft.revision + 1 } : draft
347
+ })
348
+ const after = written ? effective(written) : before
349
+ if (bumped && written) {
350
+ this.deps.onChange?.(userID, sessionID, after?.source ?? "title", written.revision)
351
+ await this.deps.events
352
+ ?.append(userID, intentEvent(sessionID, after?.source ?? "title", written.revision))
353
+ .catch((cause: unknown) => console.error(`[hub] session.intent event failed: ${(cause as Error).message}`))
354
+ }
355
+ return after
356
+ }
357
+ }
358
+
359
+ /** What reads embed (GET /sessions, /sessions/:addr, /who): the stored intent without writer details. */
360
+ export const intentField = (intent: SessionIntent | undefined): { intent?: { text: string; source: IntentSource; revision: number } } =>
361
+ intent ? { intent: { text: intent.text, source: intent.source, revision: intent.revision } } : {}
362
+
363
+ /**
364
+ * The shared action executor with the first-prompt derivation: the prompts the hub carries as human turns (the
365
+ * user's console turn or prompt, a launch prompt) go through Actions.prompt; once one was carried, the session
366
+ * owner's prompt layer is derived in the background (never delaying or failing the prompt). (CON-09a) Agents'
367
+ * prompts are deliveries (never through here), and a prompt the user only approved (a Concierge proposal, intent:
368
+ * false) is never a source.
369
+ */
370
+ export function withPromptIntents<A extends Actions>(
371
+ actions: A,
372
+ intents: Pick<SessionIntents, "derive">,
373
+ tenant: (sessionID: string) => string | undefined,
374
+ title?: (sessionID: string) => string | undefined,
375
+ ): A {
376
+ return {
377
+ ...actions,
378
+ async prompt(input) {
379
+ // (CON-10) Only the session's own first prompt: a session that already has a real title had its first prompt
380
+ // elsewhere (typed in its terminal, before the hub carried any), so a later hub-carried prompt is not it. Read
381
+ // before the turn runs (the turn itself may generate the title).
382
+ // A launch's prompt (intent "first") is its session's first prompt even though the launch titled it already.
383
+ const first = input.intent === "first" || title === undefined || isPlaceholderTitle(title(input.sessionID))
384
+ const result = await actions.prompt(input)
385
+ const userID = result.outcome === "done" ? tenant(input.sessionID) : undefined
386
+ if (userID !== undefined && first && input.text && input.intent !== false) {
387
+ void intents.derive(userID, input.sessionID, input.text).catch((cause: unknown) => console.error(`[hub] intent derivation failed: ${(cause as Error).message}`))
388
+ }
389
+ return result
390
+ },
391
+ }
392
+ }
393
+
394
+ export interface IntentRetentionOptions {
395
+ readonly intents: Pick<SessionIntents, "sweep"> & Partial<Pick<SessionIntents, "correctProposed">>
396
+ /**
397
+ * (CON-10) The user's run Concierge proposal prompts (answered Do it): the first pass of this process corrects
398
+ * the prompt intents the build before CON-09a derived from them (SessionIntents.correctProposed), once per user.
399
+ */
400
+ readonly proposed?: (userID: string) => Promise<readonly ProposedPrompt[]>
401
+ /** The hub's user IDs (read at every run). */
402
+ readonly users: () => Iterable<string> | Promise<Iterable<string>>
403
+ /** The directory's facts about a user's sessions (read once per user per run). */
404
+ readonly facts: (userID: string) => (sessionID: string) => IntentSessionFacts
405
+ readonly intervalMs?: number
406
+ /** The first run after start (default a minute), or the rest of the interval when this process swept more recently. */
407
+ readonly firstDelayMs?: number
408
+ readonly now?: () => number
409
+ readonly timers?: Timers
410
+ readonly log?: (message: string) => void
411
+ }
412
+
413
+ /** Time between retention runs. */
414
+ export const INTENT_RETENTION_INTERVAL_MS = 6 * 3_600_000
415
+
416
+ /** The retention sweep of every user's session intents, every intervalMs while started (under a leader). */
417
+ export class IntentRetention {
418
+ private readonly intervalMs: number
419
+ private readonly timers: Timers
420
+ private readonly log: (message: string) => void
421
+ private running: Promise<{ users: number; failures: number; dropped: number }> | undefined
422
+ private timer: unknown
423
+ private started = false
424
+ /** When this process last finished a pass (a re-election here does not redo it within the interval). */
425
+ private lastPassAt: number | undefined
426
+ /** (CON-10) Users whose proposal correction ran in this process. */
427
+ private readonly corrected = new Set<string>()
428
+
429
+ constructor(private readonly options: IntentRetentionOptions) {
430
+ this.intervalMs = options.intervalMs ?? INTENT_RETENTION_INTERVAL_MS
431
+ this.timers = options.timers ?? realTimers
432
+ this.log = options.log ?? ((message) => console.error(message))
433
+ }
434
+
435
+ /** One pass over every user (a failure is logged without IDs and the pass goes on); concurrent callers share it. */
436
+ runOnce(): Promise<{ users: number; failures: number; dropped: number }> {
437
+ this.running ??= this.pass().finally(() => {
438
+ this.running = undefined
439
+ })
440
+ return this.running
441
+ }
442
+
443
+ start(): void {
444
+ if (this.started) return
445
+ this.started = true
446
+ const first = this.options.firstDelayMs ?? 60_000
447
+ const sinceLast = this.lastPassAt === undefined ? Infinity : (this.options.now ?? Date.now)() - this.lastPassAt
448
+ this.schedule(Math.max(first, this.intervalMs - sinceLast))
449
+ }
450
+
451
+ stop(): void {
452
+ this.started = false
453
+ if (this.timer !== undefined) this.timers.clear(this.timer)
454
+ this.timer = undefined
455
+ }
456
+
457
+ private schedule(delayMs = this.intervalMs): void {
458
+ this.timer = this.timers.set(() => {
459
+ this.timer = undefined
460
+ void this.runOnce().finally(() => {
461
+ if (this.started) this.schedule()
462
+ })
463
+ }, delayMs)
464
+ }
465
+
466
+ /** (CON-10) The one-time proposal correction of a user (a failure is logged without IDs and retried next pass). */
467
+ private async correct(userID: string): Promise<void> {
468
+ const { proposed } = this.options
469
+ const correctProposed = this.options.intents.correctProposed?.bind(this.options.intents)
470
+ if (!proposed || !correctProposed || this.corrected.has(userID)) return
471
+ try {
472
+ const cleared = await correctProposed(userID, await proposed(userID))
473
+ this.corrected.add(userID)
474
+ if (cleared > 0) this.log(`[hub] session intent correction: cleared ${cleared} intent(s) derived from Concierge proposals`)
475
+ } catch {
476
+ // No error text: it could name sessions or Decisions (retried on the next pass).
477
+ this.log("[hub] session intent correction failed for a user; it is retried on the next pass")
478
+ }
479
+ }
480
+
481
+ private async pass(): Promise<{ users: number; failures: number; dropped: number }> {
482
+ let users = 0
483
+ let failures = 0
484
+ let dropped = 0
485
+ let list: Iterable<string>
486
+ try {
487
+ list = await this.options.users()
488
+ } catch (cause) {
489
+ this.log(`[hub] session intent retention could not list users: ${(cause as Error).message}`)
490
+ return { users, failures: 1, dropped }
491
+ }
492
+ for (const userID of list) {
493
+ users++
494
+ await this.correct(userID)
495
+ try {
496
+ dropped += await this.options.intents.sweep(userID, this.options.facts(userID))
497
+ } catch (cause) {
498
+ failures++
499
+ this.log(`[hub] session intent retention failed for a user: ${(cause as Error).message}`)
500
+ }
501
+ }
502
+ this.lastPassAt = (this.options.now ?? Date.now)()
503
+ return { users, failures, dropped }
504
+ }
505
+ }