@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,256 @@
1
+ // CON-04 session contexts (spoke feature "sessionContext"; contract packages/protocol/src/concierge.ts). The hub pushes
2
+ // a session's per-turn context; the spoke keeps it in this process's memory (like profile layers, profile-apply.ts):
3
+ //
4
+ // - parts: appended by the plugin's context hook after the profile parts, in key order (instructions, board,
5
+ // notes). The whole set replaces the previous one.
6
+ // - tools.allow: the exact tool names the session keeps; the hook deletes every other tool on every turn (unknown
7
+ // future tools included: fail closed). tools.deny: globs of tool names deleted as well.
8
+ // - Revisions: an older revision than the effective one is refused ("stale"); the same one is answered again.
9
+ // `clear` drops the parts. At most maxSessions contexts are kept (the least recently set dropped); lost on a
10
+ // process restart, after which the hub sends them again (the session reports no contextRevision).
11
+ //
12
+ // LOCKED sessions (the Concierge's, R1 of the CON-04 verify): a context with lockAgent locks its session for good.
13
+ // A locked session, WHATEVER agent it runs on and with or without a context (cleared, or not re-sent yet after a
14
+ // restart), keeps only CONCIERGE_TOOLS (intersected with its context's allow-list), never gets conversation mode,
15
+ // voice style or orchestrator parts. Locks survive `clear`, the context bound and a restart: they are persisted in
16
+ // the plugin's state dir (lockFile), so a restarted OpenCode locks the session before the hub reconnects. The agent
17
+ // a locked session was last seen on is reported to the hub (SessionInfo.agent), which suspends a Concierge found on
18
+ // another agent.
19
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
20
+ import path from "node:path"
21
+ import { CONCIERGE_TOOLS, SESSION_CONTEXT_KEYS, type SessionContextRequest, type SessionContextResult, toolDenied } from "../../packages/protocol/src/concierge.ts"
22
+
23
+ interface Context {
24
+ readonly revision: number
25
+ readonly parts: readonly string[]
26
+ readonly deny: readonly string[]
27
+ readonly allow?: ReadonlySet<string>
28
+ }
29
+
30
+ /** Locked sessions kept at most (oldest dropped; the hub keeps 20 Concierge sessions per user). */
31
+ const LOCKS_MAX = 1000
32
+ const LOCKED_TOOLS: ReadonlySet<string> = new Set(CONCIERGE_TOOLS)
33
+ /** OpenCode's internal agents (title generation, compaction, summaries): never the session's own agent. */
34
+ /** A file version: atomic writes rename a new inode in, so inode + mtime + size changes with every write. */
35
+ const stampOf = (file: string) => {
36
+ const st = statSync(file)
37
+ return `${st.ino}:${st.mtimeMs}:${st.size}`
38
+ }
39
+
40
+ export const INTERNAL_AGENTS: ReadonlySet<string> = new Set(["title", "compaction", "summary"])
41
+
42
+ export interface SessionContextsOptions {
43
+ readonly maxSessions?: number
44
+ /** Where locked session IDs persist (the plugin's state dir); in memory only without it. */
45
+ readonly lockFile?: string
46
+ /** (FX-07) How long a write waits for the other processes' exclusive lock before it writes anyway. */
47
+ readonly lockWaitMs?: number
48
+ /**
49
+ * Other lock files read (never written): the other default state dir places (FX-08: a lock written before an
50
+ * upgrade moved the state dir still locks the session; fail closed).
51
+ */
52
+ readonly readLockFiles?: readonly string[]
53
+ }
54
+
55
+ /** (FX-07) A `<lockFile>.lock` older than this belongs to a process that died while writing: it is broken. */
56
+ const WRITE_LOCK_STALE_MS = 10_000
57
+ const WRITE_LOCK_WAIT_MS = 2_000
58
+ const pause = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
59
+
60
+ /**
61
+ * (FX-07) Runs `write` holding `<file>.lock` (created exclusively), so the read-merge-write of every OpenCode process
62
+ * on the machine is serialized and none drops another's locks. A stale lock (its holder died) is broken; after
63
+ * `waitMs` the write runs anyway (the lock still holds in this process, and the write merges what it reads).
64
+ */
65
+ function underWriteLock(file: string, waitMs: number, write: () => void): void {
66
+ const lock = `${file}.lock`
67
+ const deadline = Date.now() + waitMs
68
+ let held = false
69
+ for (;;) {
70
+ try {
71
+ closeSync(openSync(lock, "wx", 0o600))
72
+ held = true
73
+ break
74
+ } catch (cause) {
75
+ if ((cause as NodeJS.ErrnoException).code !== "EEXIST") break
76
+ }
77
+ try {
78
+ if (Date.now() - statSync(lock).mtimeMs > WRITE_LOCK_STALE_MS) {
79
+ unlinkSync(lock)
80
+ continue
81
+ }
82
+ } catch {
83
+ continue // released meanwhile
84
+ }
85
+ if (Date.now() >= deadline) {
86
+ console.error("[hub] the Concierge session lock file stayed busy: writing without it")
87
+ break
88
+ }
89
+ pause(2 + Math.floor(Math.random() * 5))
90
+ }
91
+ try {
92
+ write()
93
+ } finally {
94
+ if (held) {
95
+ try {
96
+ unlinkSync(lock)
97
+ } catch {
98
+ // already gone
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ export class SessionContexts {
105
+ private readonly contexts = new Map<string, Context>()
106
+ /** The last cleared revision per session, so a stale set cannot resurrect a cleared context. */
107
+ private readonly cleared = new Map<string, number>()
108
+ private readonly locks: Set<string>
109
+ /** The lock file's mtime when last read (another process may add locks: re-read on change). */
110
+ private lockStamp = ""
111
+ private readonly agents = new Map<string, string>()
112
+ private readonly maxSessions: number
113
+
114
+ constructor(private readonly options: SessionContextsOptions = {}) {
115
+ this.maxSessions = options.maxSessions ?? 200
116
+ this.locks = new Set(this.load())
117
+ }
118
+
119
+ /** Every lock file read: the written one first, then the read-only ones. */
120
+ private files(): string[] {
121
+ return [...(this.options.lockFile ? [this.options.lockFile] : []), ...(this.options.readLockFiles ?? [])]
122
+ }
123
+
124
+ /** Adds the locks another process wrote since the last read (locks are never removed). */
125
+ private refresh(): void {
126
+ const files = this.files()
127
+ if (!files.length) return
128
+ const stamp = files
129
+ .map((file) => {
130
+ try {
131
+ return stampOf(file)
132
+ } catch {
133
+ return "-"
134
+ }
135
+ })
136
+ .join("|")
137
+ if (stamp === this.lockStamp) return
138
+ this.lockStamp = stamp
139
+ for (const id of this.load()) this.locks.add(id)
140
+ }
141
+
142
+ /** The union of the lock files' sessions (a missing or corrupt file adds none). */
143
+ private load(): string[] {
144
+ return this.files().flatMap((file) => {
145
+ try {
146
+ const raw = JSON.parse(readFileSync(file, "utf8")) as { v?: unknown; sessions?: unknown }
147
+ return raw.v === 1 && Array.isArray(raw.sessions) ? raw.sessions.filter((id): id is string => typeof id === "string" && id.length > 0 && id.length <= 200) : []
148
+ } catch {
149
+ return []
150
+ }
151
+ })
152
+ }
153
+
154
+ private lock(sessionID: string): void {
155
+ this.refresh()
156
+ if (this.locks.has(sessionID)) return
157
+ this.locks.add(sessionID)
158
+ const file = this.options.lockFile
159
+ if (!file) return this.boundLocks()
160
+ try {
161
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
162
+ underWriteLock(file, this.options.lockWaitMs ?? WRITE_LOCK_WAIT_MS, () => {
163
+ // Merge with what other processes wrote (read under the write lock): never drop their locks.
164
+ for (const id of this.load()) this.locks.add(id)
165
+ // The newest lock last, so the bound never drops it.
166
+ this.locks.delete(sessionID)
167
+ this.locks.add(sessionID)
168
+ this.boundLocks()
169
+ const tmp = `${file}.${process.pid}.tmp`
170
+ writeFileSync(tmp, `${JSON.stringify({ v: 1, sessions: [...this.locks] })}\n`, { mode: 0o600 })
171
+ renameSync(tmp, file)
172
+ })
173
+ this.lockStamp = ""
174
+ this.refresh()
175
+ } catch (cause) {
176
+ // The lock still holds in this process; the hub re-sends the context after a restart.
177
+ console.error(`[hub] could not persist the Concierge session lock: ${(cause as Error).message}`)
178
+ }
179
+ }
180
+
181
+ /** Applies a hub request (validated by the protocol parser); throws "stale: …" for an older revision. */
182
+ handle(req: SessionContextRequest): SessionContextResult {
183
+ const effective = this.contexts.get(req.sessionID)?.revision ?? this.cleared.get(req.sessionID)
184
+ if (effective !== undefined && req.revision < effective) throw new Error(`stale: revision ${req.revision} is older than the effective ${effective}`)
185
+ this.contexts.delete(req.sessionID)
186
+ if (req.clear) {
187
+ this.cleared.set(req.sessionID, req.revision)
188
+ this.bound(this.cleared)
189
+ return { revision: req.revision }
190
+ }
191
+ this.cleared.delete(req.sessionID)
192
+ if (req.lockAgent) this.lock(req.sessionID)
193
+ const byKey = new Map(req.parts.map((p) => [p.key, p.text]))
194
+ const parts = SESSION_CONTEXT_KEYS.flatMap((key) => (byKey.has(key) ? [byKey.get(key)!] : []))
195
+ const allow = req.tools?.allow
196
+ this.contexts.set(req.sessionID, { revision: req.revision, parts, deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}) })
197
+ this.bound(this.contexts)
198
+ return { revision: req.revision }
199
+ }
200
+
201
+ /** At most LOCKS_MAX locks (the oldest dropped). */
202
+ private boundLocks(): void {
203
+ while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
204
+ }
205
+
206
+ private bound(map: Map<string, unknown>): void {
207
+ while (map.size > this.maxSessions) map.delete(map.keys().next().value as string)
208
+ }
209
+
210
+ /** The session's parts for its system prompt (none without a context). */
211
+ systemParts(sessionID: string): string[] {
212
+ return [...(this.contexts.get(sessionID)?.parts ?? [])]
213
+ }
214
+
215
+ /** A Concierge session: only CONCIERGE_TOOLS, its agent and model kept, no conversation mode. */
216
+ locked(sessionID: string): boolean {
217
+ if (!this.locks.has(sessionID)) this.refresh()
218
+ return this.locks.has(sessionID)
219
+ }
220
+
221
+ lockedSessions(): string[] {
222
+ return [...this.locks]
223
+ }
224
+
225
+ /** Whether the hub took this tool from the session (a locked session's non-Concierge tool, off its allow-list, or denied). */
226
+ denied(sessionID: string, tool: string): boolean {
227
+ if (this.locks.has(sessionID) && !LOCKED_TOOLS.has(tool)) return true
228
+ const context = this.contexts.get(sessionID)
229
+ if (!context) return false
230
+ return (context.allow !== undefined && !context.allow.has(tool)) || toolDenied(tool, context.deny)
231
+ }
232
+
233
+ /** Deletes the tools the hub took from the session from a context event's tools. */
234
+ filterTools(sessionID: string, tools: Record<string, unknown>): void {
235
+ if (!this.contexts.has(sessionID) && !this.locked(sessionID)) return
236
+ for (const name of Object.keys(tools)) if (this.denied(sessionID, name)) delete tools[name]
237
+ }
238
+
239
+ /** Records the agent a locked session runs on; true when it changed (a new report is due). */
240
+ seenAgent(sessionID: string, agent: string | undefined): boolean {
241
+ if (!this.locks.has(sessionID) || !agent || INTERNAL_AGENTS.has(agent) || this.agents.get(sessionID) === agent) return false
242
+ this.agents.set(sessionID, agent)
243
+ return true
244
+ }
245
+
246
+ /**
247
+ * What the sessions frame says about the session (locked ones only): locked (FX-07: from the very first frame, even
248
+ * after a restart, before its agent is read or the hub re-sends a context), its agent and context revision.
249
+ */
250
+ report(sessionID: string): { locked?: true; agent?: string; contextRevision?: number } {
251
+ if (!this.locks.has(sessionID)) return {}
252
+ const agent = this.agents.get(sessionID)
253
+ const revision = this.contexts.get(sessionID)?.revision
254
+ return { locked: true, ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
255
+ }
256
+ }