@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,298 @@
1
+ // The OpenCode profile application adapter (HWA-16; contract packages/protocol/src/profiles.ts, SDK rules in
2
+ // packages/spoke-sdk/src/profile-apply.ts). A hub profile becomes a session-scoped layer held in this process's
3
+ // memory only; nothing is written to disk and no OpenCode configuration (global or project) is touched:
4
+ //
5
+ // - Instructions: the plugin's `context` hook appends them to the session's system prompt after OpenCode's own
6
+ // parts (which carry AGENTS.md and the user's config), framed by profileInstructionsBlock: local instructions win.
7
+ // - Skills and commands: listed in that system part and served by the `hub_profile` tool (registered once per
8
+ // process like the `hub` tool; the context hook hides it from sessions without a profile, and it answers only
9
+ // the calling session's layer). OpenCode's skill/command registries are location-wide, so they are not used.
10
+ // Same-named local skills/commands win (partial). Commands that reference `{{secret:NAME}}` are skipped: a
11
+ // rendered command becomes a persisted prompt or tool result in OpenCode, and resolved secrets must never be
12
+ // written (renderAtInvocation false). The model preference is ignored (the user's model is never switched).
13
+ // - Next-turn boundary: an idle session's layer is replaced at once (its next turn reads it). For a busy session
14
+ // the new layer is staged, the running turn keeps the previous one, and the answer waits for the turn to end
15
+ // (Spoke.observe calls settle; a poll backs it up). The answer echoes exactly the version that became
16
+ // effective. A wait past boundaryWaitMs fails: the hub, which times out first, keeps the pin pending and sends it
17
+ // again on the spoke's next sessions frame, which is answered from the then-effective layer. A newer version
18
+ // staged before the boundary supersedes the older one, which is answered `rejected`.
19
+ // - Rollback: a failed or unsupported apply leaves the effective layer as it was; re-pinning an older version
20
+ // replaces the layer with it. There is no remove op in v1 (an empty version clears the layer).
21
+ // - Bounded: at most maxSessions layers (oldest dropped; the hub re-sends after a reconnect), notes bounded by the
22
+ // SDK. Lost on process restart: the new connection's sessions frame makes the hub send the pin again.
23
+ import { isObj } from "../../packages/protocol/src/wire.ts"
24
+ import { type ProfileApplyRequest, type ProfileApplyResult, type ProfileCommand, type ProfileSkill, profileTemplateParts } from "../../packages/protocol/src/index.ts"
25
+ import { boundedNotes, planProfileApplication, profileApplyResult, profileDigest, type ProfileLocalNames, renderProfileCommand } from "../../packages/spoke-sdk/src/profile-apply.ts"
26
+
27
+ export const PROFILE_TOOL = "hub_profile"
28
+
29
+ export const PROFILE_TOOL_DESCRIPTION =
30
+ "Use this session's hub agent profile: {skill: name} returns a profile skill's instructions; " +
31
+ "{command: name, params: {name: value}} returns a profile command's text with its parameters filled in, to follow as the user's request. " +
32
+ "Only the skills and commands listed in the hub profile section of your instructions exist."
33
+
34
+ export const PROFILE_TOOL_INPUT = {
35
+ type: "object",
36
+ properties: {
37
+ skill: { type: "string", description: "A profile skill's name." },
38
+ command: { type: "string", description: "A profile command's name." },
39
+ params: { type: "object", additionalProperties: { type: "string" }, description: "The command's parameters (each {{name}} in it; `arguments` for free text)." },
40
+ },
41
+ additionalProperties: false,
42
+ } as const
43
+
44
+ export interface OpenCodeProfilesOptions {
45
+ /** The session has a turn running (OpenCode busy, or a hub turn in progress). */
46
+ readonly isBusy: (sessionID: string) => boolean
47
+ /** The session's local skill and command names; absent: skills and commands are not applied. */
48
+ readonly localNames?: (sessionID: string) => Promise<ProfileLocalNames>
49
+ /** The hub_profile tool is available in this OpenCode. */
50
+ readonly tool: () => boolean
51
+ /** How long a busy session's answer waits for the boundary (default 120 s). */
52
+ readonly boundaryWaitMs?: number
53
+ /** How often a waiting answer checks the session (default 1 s). */
54
+ readonly pollMs?: number
55
+ /** Sessions with a layer kept (default 1000). */
56
+ readonly maxSessions?: number
57
+ }
58
+
59
+ export interface ProfileEffectiveLayer {
60
+ readonly profileID: string
61
+ readonly version: number
62
+ readonly digest: string
63
+ readonly status: ProfileApplyResult["status"]
64
+ }
65
+
66
+ interface Layer {
67
+ readonly result: ProfileApplyResult
68
+ readonly instructions?: string
69
+ readonly skills: readonly ProfileSkill[]
70
+ readonly commands: readonly ProfileCommand[]
71
+ }
72
+
73
+ interface Waiter {
74
+ readonly resolve: (result: ProfileApplyResult) => void
75
+ readonly reject: (cause: Error) => void
76
+ }
77
+
78
+ interface Staged {
79
+ readonly layer: Layer
80
+ readonly waiters: Set<Waiter>
81
+ poll?: ReturnType<typeof setInterval>
82
+ }
83
+
84
+ /** A final answer, or one waiting for the boundary (its waiter is registered before staging returns). */
85
+ type Step = { readonly result: ProfileApplyResult } | { readonly pending: Promise<ProfileApplyResult> }
86
+
87
+ const sameVersion = (a: ProfileApplyResult, req: ProfileApplyRequest) => a.profileID === req.profileID && a.version === req.version && a.digest === req.digest
88
+
89
+ export class OpenCodeProfiles {
90
+ private readonly layers = new Map<string, Layer>()
91
+ private readonly staged = new Map<string, Staged>()
92
+ private readonly locks = new Map<string, Promise<void>>()
93
+
94
+ constructor(private readonly options: OpenCodeProfilesOptions) {}
95
+
96
+ /** The profileApply op: answers once the version is effective in the session (see the header). */
97
+ async handle(req: ProfileApplyRequest): Promise<ProfileApplyResult> {
98
+ if (profileDigest(req.content) !== req.digest) return profileApplyResult(req, "rejected", ["digest does not match the content; nothing applied"])
99
+ const step = await this.serialize(req.sessionID, () => this.stage(req))
100
+ return "pending" in step ? step.pending : step.result
101
+ }
102
+
103
+ /** A turn boundary may have passed: makes a staged layer effective when the session is not busy. */
104
+ settle(sessionID: string): void {
105
+ const staged = this.staged.get(sessionID)
106
+ if (!staged || this.options.isBusy(sessionID)) return
107
+ this.dropStaged(sessionID, staged)
108
+ this.setLayer(sessionID, staged.layer)
109
+ for (const waiter of staged.waiters) waiter.resolve(staged.layer.result)
110
+ }
111
+
112
+ /** The version the session runs now, if any. */
113
+ effective(sessionID: string): ProfileEffectiveLayer | undefined {
114
+ const layer = this.layers.get(sessionID)
115
+ if (!layer) return undefined
116
+ const { profileID, version, digest, status } = layer.result
117
+ return { profileID, version, digest, status }
118
+ }
119
+
120
+ /** System prompt parts for the session's next model request (after local instructions). */
121
+ systemParts(sessionID: string): string[] {
122
+ const layer = this.layers.get(sessionID)
123
+ if (!layer) return []
124
+ const parts: string[] = []
125
+ if (layer.instructions !== undefined) parts.push(layer.instructions)
126
+ if (layer.skills.length || layer.commands.length) parts.push(bindingsText(layer))
127
+ return parts
128
+ }
129
+
130
+ /** Whether the session's model sees the hub_profile tool. */
131
+ toolVisible(sessionID: string): boolean {
132
+ const layer = this.layers.get(sessionID)
133
+ return layer !== undefined && (layer.skills.length > 0 || layer.commands.length > 0)
134
+ }
135
+
136
+ /** The hub_profile tool for the calling session: a skill's body or a command's rendered text, or why not. */
137
+ runTool(sessionID: string, input: unknown): string {
138
+ const value = isObj(input) ? input : {}
139
+ const skill = typeof value.skill === "string" ? value.skill : undefined
140
+ const command = typeof value.command === "string" ? value.command : undefined
141
+ if (skill === undefined && command === undefined) return "Give a skill or command name of this session's hub profile."
142
+ const layer = this.layers.get(sessionID)
143
+ if (!layer) return "This session has no hub profile."
144
+ if (skill !== undefined) {
145
+ const found = layer.skills.find((s) => s.name === skill)
146
+ return found ? `Skill ${found.name} (hub profile ${layer.result.profileID} v${layer.result.version}):\n\n${found.body}` : `This session has no hub profile skill named ${skill}.`
147
+ }
148
+ const found = layer.commands.find((c) => c.name === command)
149
+ if (!found) return `This session has no hub profile command named ${command}.`
150
+ const params = isObj(value.params) ? value.params : {}
151
+ if (!Object.values(params).every((v) => typeof v === "string")) return `Command ${found.name}: every parameter must be text.`
152
+ try {
153
+ // Commands with secret references are never in a layer, so no secret is ever resolved here.
154
+ return renderProfileCommand(found.template, params as Record<string, string>, () => undefined)
155
+ } catch (cause) {
156
+ return `Command ${found.name} could not be run: ${cause instanceof Error ? cause.message : String(cause)}`
157
+ }
158
+ }
159
+
160
+ /** The session ended: drops its layer and fails an answer still waiting for its boundary. */
161
+ forget(sessionID: string): void {
162
+ this.layers.delete(sessionID)
163
+ const staged = this.staged.get(sessionID)
164
+ if (!staged) return
165
+ this.dropStaged(sessionID, staged)
166
+ for (const waiter of staged.waiters) waiter.reject(new Error("the session ended before the profile took effect"))
167
+ }
168
+
169
+ // ---------- internals ----------
170
+
171
+ private async stage(req: ProfileApplyRequest): Promise<Step> {
172
+ const { sessionID } = req
173
+ const staged = this.staged.get(sessionID)
174
+ if (staged && sameVersion(staged.layer.result, req)) return { pending: this.wait(sessionID, staged) }
175
+ const current = this.layers.get(sessionID)
176
+ if (current && sameVersion(current.result, req)) {
177
+ this.supersede(sessionID, req)
178
+ return { result: current.result }
179
+ }
180
+ let local: ProfileLocalNames | undefined
181
+ if (this.options.localNames) {
182
+ try {
183
+ local = await this.options.localNames(sessionID)
184
+ } catch {
185
+ return { result: profileApplyResult(req, "rejected", ["could not read the session's local skills and commands (they take precedence); nothing applied"]) }
186
+ }
187
+ }
188
+ const bindings = this.options.tool() && local !== undefined
189
+ const plan = planProfileApplication(req, {
190
+ capabilities: { instructions: true, skills: bindings, commands: bindings, model: false, live: true, renderAtInvocation: false },
191
+ ...(local ? { local } : {}),
192
+ })
193
+ const result = profileApplyResult(req, plan.status, plan.notes)
194
+ if (plan.status === "unsupported") return { result }
195
+ const layer: Layer = { result, ...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}), skills: plan.skills, commands: plan.commands }
196
+ this.supersede(sessionID, req)
197
+ if (!this.options.isBusy(sessionID)) {
198
+ this.setLayer(sessionID, layer)
199
+ return { result }
200
+ }
201
+ const next: Staged = { layer, waiters: new Set() }
202
+ this.staged.set(sessionID, next)
203
+ return { pending: this.wait(sessionID, next) }
204
+ }
205
+
206
+ /** Waits for the staged layer's boundary, at most boundaryWaitMs. */
207
+ private wait(sessionID: string, staged: Staged): Promise<ProfileApplyResult> {
208
+ const pending = new Promise<ProfileApplyResult>((resolve, reject) => {
209
+ const timer = setTimeout(() => {
210
+ staged.waiters.delete(waiter)
211
+ if (!staged.waiters.size) this.stopPoll(staged)
212
+ const { profileID, version } = staged.layer.result
213
+ reject(new Error(`the session is still busy; profile ${profileID} v${version} is staged and takes effect at its next turn boundary`))
214
+ }, this.options.boundaryWaitMs ?? 120_000)
215
+ unref(timer)
216
+ const waiter: Waiter = {
217
+ resolve: (result) => (clearTimeout(timer), resolve(result)),
218
+ reject: (cause) => (clearTimeout(timer), reject(cause)),
219
+ }
220
+ staged.waiters.add(waiter)
221
+ if (!staged.poll) {
222
+ staged.poll = setInterval(() => this.settle(sessionID), this.options.pollMs ?? 1000)
223
+ unref(staged.poll)
224
+ }
225
+ })
226
+ pending.catch(() => {}) // handled by handle(); the waiter may fail before it is awaited
227
+ return pending
228
+ }
229
+
230
+ /** A staged layer for another version is answered rejected: `req` replaces it before it took effect. */
231
+ private supersede(sessionID: string, req: ProfileApplyRequest): void {
232
+ const staged = this.staged.get(sessionID)
233
+ if (!staged) return
234
+ this.dropStaged(sessionID, staged)
235
+ const { profileID, version, digest } = staged.layer.result
236
+ const note = `superseded by profile ${req.profileID} v${req.version} before it took effect at the next turn boundary`
237
+ const result: ProfileApplyResult = { sessionID, profileID, version, digest, status: "rejected", notes: boundedNotes([note]) }
238
+ for (const waiter of staged.waiters) waiter.resolve(result)
239
+ }
240
+
241
+ private dropStaged(sessionID: string, staged: Staged): void {
242
+ if (this.staged.get(sessionID) === staged) this.staged.delete(sessionID)
243
+ this.stopPoll(staged)
244
+ }
245
+
246
+ private stopPoll(staged: Staged): void {
247
+ if (staged.poll) clearInterval(staged.poll)
248
+ staged.poll = undefined
249
+ }
250
+
251
+ private setLayer(sessionID: string, layer: Layer): void {
252
+ this.layers.delete(sessionID)
253
+ this.layers.set(sessionID, layer)
254
+ while (this.layers.size > (this.options.maxSessions ?? 1000)) this.layers.delete(this.layers.keys().next().value!)
255
+ }
256
+
257
+ /** Runs one session's staging steps one at a time (waiting for a boundary is outside). */
258
+ private serialize<T>(sessionID: string, run: () => Promise<T>): Promise<T> {
259
+ const previous = this.locks.get(sessionID) ?? Promise.resolve()
260
+ const next = previous.then(run)
261
+ const tail = next.then(
262
+ () => undefined,
263
+ () => undefined,
264
+ )
265
+ this.locks.set(sessionID, tail)
266
+ void tail.then(() => {
267
+ if (this.locks.get(sessionID) === tail) this.locks.delete(sessionID)
268
+ })
269
+ return next
270
+ }
271
+ }
272
+
273
+ /** The system part that lists the session's profile skills and commands (names, descriptions, parameters). */
274
+ function bindingsText(layer: Layer): string {
275
+ const { profileID, version } = layer.result
276
+ const lines = [
277
+ `Hub profile ${profileID} v${version} adds skills and commands to this session only, through the ${PROFILE_TOOL} tool. Local skills and commands of the same name take precedence and are not listed.`,
278
+ ]
279
+ if (layer.skills.length) {
280
+ lines.push("", `Profile skills (load one with ${PROFILE_TOOL} {"skill": name} before following it):`)
281
+ for (const skill of layer.skills) lines.push(`- ${skill.name}: ${oneLine(skill.description)}`)
282
+ }
283
+ if (layer.commands.length) {
284
+ lines.push("", `Profile commands (when the user asks for /name or the command by name, call ${PROFILE_TOOL} {"command": name, "params": {...}} and follow the text it returns):`)
285
+ for (const command of layer.commands) {
286
+ const parts = profileTemplateParts(command.template)
287
+ const params = typeof parts === "string" ? [] : [...new Set(parts.flatMap((p) => ("param" in p ? [p.param] : [])))]
288
+ lines.push(`- ${command.name}${params.length ? ` (params: ${params.join(", ")})` : ""}: ${oneLine(command.description)}`)
289
+ }
290
+ }
291
+ return lines.join("\n")
292
+ }
293
+
294
+ const oneLine = (text: string) => text.replace(/\s+/g, " ").trim()
295
+
296
+ function unref(timer: ReturnType<typeof setTimeout>): void {
297
+ ;(timer as { unref?: () => void }).unref?.()
298
+ }
@@ -0,0 +1,176 @@
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 { mkdirSync, readFileSync, renameSync, statSync, 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
+ }
47
+
48
+ export class SessionContexts {
49
+ private readonly contexts = new Map<string, Context>()
50
+ /** The last cleared revision per session, so a stale set cannot resurrect a cleared context. */
51
+ private readonly cleared = new Map<string, number>()
52
+ private readonly locks: Set<string>
53
+ /** The lock file's mtime when last read (another process may add locks: re-read on change). */
54
+ private lockStamp = ""
55
+ private readonly agents = new Map<string, string>()
56
+ private readonly maxSessions: number
57
+
58
+ constructor(private readonly options: SessionContextsOptions = {}) {
59
+ this.maxSessions = options.maxSessions ?? 200
60
+ this.locks = new Set(this.load())
61
+ }
62
+
63
+ /** Adds the locks another process wrote since the last read (locks are never removed). */
64
+ private refresh(): void {
65
+ const file = this.options.lockFile
66
+ if (!file) return
67
+ let stamp: string
68
+ try {
69
+ stamp = stampOf(file)
70
+ } catch {
71
+ return
72
+ }
73
+ if (stamp === this.lockStamp) return
74
+ this.lockStamp = stamp
75
+ for (const id of this.load()) this.locks.add(id)
76
+ }
77
+
78
+ private load(): string[] {
79
+ if (!this.options.lockFile) return []
80
+ try {
81
+ const raw = JSON.parse(readFileSync(this.options.lockFile, "utf8")) as { v?: unknown; sessions?: unknown }
82
+ return raw.v === 1 && Array.isArray(raw.sessions) ? raw.sessions.filter((id): id is string => typeof id === "string" && id.length > 0 && id.length <= 200) : []
83
+ } catch {
84
+ return []
85
+ }
86
+ }
87
+
88
+ private lock(sessionID: string): void {
89
+ this.refresh()
90
+ if (this.locks.has(sessionID)) return
91
+ // Merge with what other processes wrote: never drop their locks.
92
+ for (const id of this.load()) this.locks.add(id)
93
+ this.locks.add(sessionID)
94
+ while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
95
+ const file = this.options.lockFile
96
+ if (!file) return
97
+ try {
98
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
99
+ const tmp = `${file}.${process.pid}.tmp`
100
+ writeFileSync(tmp, `${JSON.stringify({ v: 1, sessions: [...this.locks] })}\n`, { mode: 0o600 })
101
+ renameSync(tmp, file)
102
+ this.lockStamp = stampOf(file)
103
+ } catch (cause) {
104
+ // The lock still holds in this process; the hub re-sends the context after a restart.
105
+ console.error(`[hub] could not persist the Concierge session lock: ${(cause as Error).message}`)
106
+ }
107
+ }
108
+
109
+ /** Applies a hub request (validated by the protocol parser); throws "stale: …" for an older revision. */
110
+ handle(req: SessionContextRequest): SessionContextResult {
111
+ const effective = this.contexts.get(req.sessionID)?.revision ?? this.cleared.get(req.sessionID)
112
+ if (effective !== undefined && req.revision < effective) throw new Error(`stale: revision ${req.revision} is older than the effective ${effective}`)
113
+ this.contexts.delete(req.sessionID)
114
+ if (req.clear) {
115
+ this.cleared.set(req.sessionID, req.revision)
116
+ this.bound(this.cleared)
117
+ return { revision: req.revision }
118
+ }
119
+ this.cleared.delete(req.sessionID)
120
+ if (req.lockAgent) this.lock(req.sessionID)
121
+ const byKey = new Map(req.parts.map((p) => [p.key, p.text]))
122
+ const parts = SESSION_CONTEXT_KEYS.flatMap((key) => (byKey.has(key) ? [byKey.get(key)!] : []))
123
+ const allow = req.tools?.allow
124
+ this.contexts.set(req.sessionID, { revision: req.revision, parts, deny: [...(req.tools?.deny ?? [])], ...(allow ? { allow: new Set(allow) } : {}) })
125
+ this.bound(this.contexts)
126
+ return { revision: req.revision }
127
+ }
128
+
129
+ private bound(map: Map<string, unknown>): void {
130
+ while (map.size > this.maxSessions) map.delete(map.keys().next().value as string)
131
+ }
132
+
133
+ /** The session's parts for its system prompt (none without a context). */
134
+ systemParts(sessionID: string): string[] {
135
+ return [...(this.contexts.get(sessionID)?.parts ?? [])]
136
+ }
137
+
138
+ /** A Concierge session: only CONCIERGE_TOOLS, its agent and model kept, no conversation mode. */
139
+ locked(sessionID: string): boolean {
140
+ if (!this.locks.has(sessionID)) this.refresh()
141
+ return this.locks.has(sessionID)
142
+ }
143
+
144
+ lockedSessions(): string[] {
145
+ return [...this.locks]
146
+ }
147
+
148
+ /** Whether the hub took this tool from the session (a locked session's non-Concierge tool, off its allow-list, or denied). */
149
+ denied(sessionID: string, tool: string): boolean {
150
+ if (this.locks.has(sessionID) && !LOCKED_TOOLS.has(tool)) return true
151
+ const context = this.contexts.get(sessionID)
152
+ if (!context) return false
153
+ return (context.allow !== undefined && !context.allow.has(tool)) || toolDenied(tool, context.deny)
154
+ }
155
+
156
+ /** Deletes the tools the hub took from the session from a context event's tools. */
157
+ filterTools(sessionID: string, tools: Record<string, unknown>): void {
158
+ if (!this.contexts.has(sessionID) && !this.locked(sessionID)) return
159
+ for (const name of Object.keys(tools)) if (this.denied(sessionID, name)) delete tools[name]
160
+ }
161
+
162
+ /** Records the agent a locked session runs on; true when it changed (a new report is due). */
163
+ seenAgent(sessionID: string, agent: string | undefined): boolean {
164
+ if (!this.locks.has(sessionID) || !agent || INTERNAL_AGENTS.has(agent) || this.agents.get(sessionID) === agent) return false
165
+ this.agents.set(sessionID, agent)
166
+ return true
167
+ }
168
+
169
+ /** What the sessions frame says about the session (locked ones only): its agent and context revision. */
170
+ report(sessionID: string): { agent?: string; contextRevision?: number } {
171
+ if (!this.locks.has(sessionID)) return {}
172
+ const agent = this.agents.get(sessionID)
173
+ const revision = this.contexts.get(sessionID)?.revision
174
+ return { ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
175
+ }
176
+ }