@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,173 @@
1
+ // Profile application for Claude Code sessions (HWA-17; contract packages/protocol/src/profiles.ts, SDK
2
+ // spoke-sdk/src/profile-apply.ts). Only the native mechanisms this spoke already uses, nothing written to the
3
+ // user's Claude Code settings, no plugin installed, no permission changed, no session restarted:
4
+ //
5
+ // instructions model skills / commands
6
+ // hub-launched hook context, next prompt set_model control request skipped (note)
7
+ // terminal hook context, next prompt ignored (note: the user's) skipped (note)
8
+ //
9
+ // - Instructions: kept in the daemon's memory for the session only and handed to Claude Code as hook
10
+ // additionalContext at the session's next UserPromptSubmit (and again at SessionStart after a compaction, which
11
+ // drops earlier context). They come after the local CLAUDE.md files, framed by profileInstructionsBlock (local
12
+ // instructions take precedence). A newer version replaces them; one without instructions says the earlier ones
13
+ // no longer apply.
14
+ // - Model: a hub-launched session runs headless under the daemon (launcher.ts), which changes its model with the
15
+ // stream-json control request set_model and waits for Claude Code's answer (anthropic models only). A terminal
16
+ // session's model belongs to the user at the terminal (/model): ignored with a note.
17
+ // - Skills and commands: Claude Code loads them only from plugin and settings directories when a session starts;
18
+ // adding them would mean installing a plugin, writing settings or restarting the session, none of which the hub
19
+ // does on its own. Skipped with a note (partial beside other parts; alone the answer is unsupported). So no
20
+ // command is ever rendered here and `{{secret:NAME}}` references are never resolved.
21
+ // - Unknown and subagent sessions are rejected (a subagent shares its parent's context). Applies to one session run
22
+ // one at a time, in arrival order. A repeat of the applied digest answers the same without re-applying (SDK).
23
+ import type { ProfileApplyRequest, ProfileApplyResult } from "../../protocol/src/index.ts"
24
+ import { profileApplyResult, ProfileApplyHandler, type ProfileApplyOutcome, type ProfileCapabilities, type ProfilePlan } from "../../spoke-sdk/src/index.ts"
25
+ import { launchModel } from "./launcher.ts"
26
+
27
+ /** How the daemon runs a session: started by the hub (headless under the daemon) or by a user in a terminal. */
28
+ export type ClaudeSessionKind = "launched" | "terminal"
29
+
30
+ /** What profile application needs of the daemon. */
31
+ export interface ClaudeProfilePort {
32
+ /** The kind of a root session the daemon reports; undefined for unknown and subagent sessions. */
33
+ kind(sessionID: string): ClaudeSessionKind | undefined
34
+ /** Changes a hub-launched session's model (a Claude Code model ID); throws with Claude Code's reason. */
35
+ setModel(sessionID: string, model: string): Promise<void>
36
+ }
37
+
38
+ export interface ClaudeProfilesOptions {
39
+ /** Sessions whose profile state is kept (default 1000; the oldest are forgotten first). */
40
+ readonly sessionsMax?: number
41
+ }
42
+
43
+ const BASE: Omit<ProfileCapabilities, "model"> = { instructions: true, skills: false, commands: false, live: true, renderAtInvocation: false }
44
+
45
+ /** What Claude Code sessions take, by kind. */
46
+ export const CLAUDE_PROFILE_CAPABILITIES: Readonly<Record<ClaudeSessionKind, ProfileCapabilities>> = {
47
+ launched: { ...BASE, model: true },
48
+ terminal: { ...BASE, model: false },
49
+ }
50
+
51
+ const SESSIONS_MAX = 1000
52
+ const SKILLS_NOTE = "Claude Code loads skills and commands only from plugin or settings directories at session start; the hub installs no plugin, writes no settings and restarts no session"
53
+ const NEXT_PROMPT_NOTE = "instructions reach the model with the session's next prompt (hook context)"
54
+ const TERMINAL_MODEL_NOTE = "model ignored: a terminal session's model is the user's to change (/model)"
55
+
56
+ /** The Claude Code model ID of a profile model "provider/model"; throws unless Claude Code can run it. */
57
+ export function profileModelID(model: string): string {
58
+ const at = model.indexOf("/")
59
+ if (at <= 0 || at === model.length - 1) throw new Error(`model ${JSON.stringify(model)} is not provider/model`)
60
+ return launchModel({ providerID: model.slice(0, at), modelID: model.slice(at + 1) })!
61
+ }
62
+
63
+ interface Staged {
64
+ readonly profileID: string
65
+ readonly version: number
66
+ /** The effective profile's framed instructions (re-sent after a compaction). */
67
+ readonly instructions?: string
68
+ /** What the next prompt carries (the instructions, or a notice that earlier ones no longer apply). */
69
+ pending?: string
70
+ /** Some profile text reached the model already. */
71
+ delivered: boolean
72
+ }
73
+
74
+ const header = (req: Pick<ProfileApplyRequest, "profileID" | "version">) => `<!-- hub profile ${req.profileID} v${req.version} (session-scoped) -->`
75
+
76
+ export class ClaudeProfiles {
77
+ readonly #port: ClaudeProfilePort
78
+ readonly #max: number
79
+ readonly #staged = new Map<string, Staged>()
80
+ readonly #queues = new Map<string, Promise<unknown>>()
81
+ readonly #handlers: Readonly<Record<ClaudeSessionKind, ProfileApplyHandler>>
82
+
83
+ constructor(port: ClaudeProfilePort, options: ClaudeProfilesOptions = {}) {
84
+ this.#port = port
85
+ this.#max = options.sessionsMax ?? SESSIONS_MAX
86
+ const handler = (kind: ClaudeSessionKind) =>
87
+ new ProfileApplyHandler({ capabilities: CLAUDE_PROFILE_CAPABILITIES[kind], apply: (plan, req) => this.#apply(kind, plan, req), remembered: this.#max })
88
+ this.#handlers = { launched: handler("launched"), terminal: handler("terminal") }
89
+ }
90
+
91
+ /** The `profileApply` op handler (HarnessSpoke handlers: announces the feature). */
92
+ readonly handle = (req: ProfileApplyRequest): Promise<ProfileApplyResult> => {
93
+ const previous = this.#queues.get(req.sessionID) ?? Promise.resolve()
94
+ const run = previous.then(() => this.#handle(req))
95
+ const tail = run.catch(() => undefined)
96
+ this.#queues.set(req.sessionID, tail)
97
+ void tail.then(() => {
98
+ if (this.#queues.get(req.sessionID) === tail) this.#queues.delete(req.sessionID)
99
+ })
100
+ return run
101
+ }
102
+
103
+ /**
104
+ * The profile context a hook hands the model now, if any: at UserPromptSubmit what is pending (once); at
105
+ * SessionStart after a compaction (source "compact") the effective instructions again.
106
+ */
107
+ context(sessionID: string, event: "UserPromptSubmit" | "SessionStart", source?: string): string | undefined {
108
+ const s = this.#staged.get(sessionID)
109
+ if (!s) return undefined
110
+ let text: string | undefined
111
+ if (event === "UserPromptSubmit") text = s.pending
112
+ else if (event === "SessionStart" && source === "compact") text = s.instructions
113
+ if (text === undefined) return undefined
114
+ s.pending = undefined
115
+ s.delivered = true
116
+ return text
117
+ }
118
+
119
+ /** The session ended: its profile state goes with it. */
120
+ ended(sessionID: string): void {
121
+ this.#staged.delete(sessionID)
122
+ this.#handlers.launched.forget(sessionID)
123
+ this.#handlers.terminal.forget(sessionID)
124
+ }
125
+
126
+ async #handle(req: ProfileApplyRequest): Promise<ProfileApplyResult> {
127
+ const kind = this.#port.kind(req.sessionID)
128
+ if (!kind) return profileApplyResult(req, "rejected", [`${req.sessionID} is not a Claude Code session this spoke reports (a subagent session follows its parent's context)`])
129
+ return this.#handlers[kind].handle(req)
130
+ }
131
+
132
+ async #apply(kind: ClaudeSessionKind, plan: ProfilePlan, req: ProfileApplyRequest): Promise<ProfileApplyOutcome> {
133
+ const notes: string[] = []
134
+ let modelFailed = false
135
+ if (req.content.skills.length || req.content.commands.length) notes.push(SKILLS_NOTE)
136
+ if (kind === "terminal" && req.content.model !== undefined) notes.push(TERMINAL_MODEL_NOTE)
137
+ if (plan.model !== undefined) {
138
+ let id: string | undefined
139
+ try {
140
+ id = profileModelID(plan.model)
141
+ } catch (cause) {
142
+ modelFailed = true
143
+ notes.push(`model ignored: ${message(cause)}`)
144
+ }
145
+ if (id !== undefined) {
146
+ try {
147
+ await this.#port.setModel(req.sessionID, id)
148
+ } catch (cause) {
149
+ modelFailed = true
150
+ notes.push(`model not changed: ${message(cause)}`)
151
+ }
152
+ }
153
+ }
154
+ if (modelFailed && plan.instructions === undefined) return { status: "rejected", notes }
155
+ this.#stage(req, plan.instructions)
156
+ if (plan.instructions !== undefined) notes.push(NEXT_PROMPT_NOTE)
157
+ return { ...(modelFailed ? { status: "partial" as const } : {}), notes }
158
+ }
159
+
160
+ /** Keeps the version's instructions for the session's next prompt (replacing an earlier version's). */
161
+ #stage(req: ProfileApplyRequest, instructions: string | undefined): void {
162
+ const before = this.#staged.get(req.sessionID)
163
+ const delivered = before?.delivered === true
164
+ let pending: string | undefined
165
+ if (instructions !== undefined) pending = delivered ? instructions.replace(header(req), `${header(req)}\nThis replaces earlier hub profile instructions in this session.`) : instructions
166
+ else if (delivered) pending = `${header(req)}\nHub agent profile ${req.profileID} version ${req.version} now applies to this session without instructions: earlier hub profile instructions in this session no longer apply.`
167
+ this.#staged.delete(req.sessionID)
168
+ this.#staged.set(req.sessionID, { profileID: req.profileID, version: req.version, ...(instructions !== undefined ? { instructions } : {}), ...(pending !== undefined ? { pending } : {}), delivered })
169
+ while (this.#staged.size > this.#max) this.#staged.delete(this.#staged.keys().next().value!)
170
+ }
171
+ }
172
+
173
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
@@ -28,6 +28,9 @@
28
28
  // Hub-launched sessions (launched(): the daemon runs their claude headless, launcher.ts) are reported at create,
29
29
  // before any hook; a prompt the hub writes to one (hubPrompt) is delivered with origin "hub", and the turn's result
30
30
  // line ends a turn the hooks left running (turnEnded: an interrupt fires no Stop hook).
31
+ //
32
+ // Profiles (HWA-17, profile.ts): a session's pending profile instructions ride on its next UserPromptSubmit context
33
+ // (before hub messages), and again on SessionStart after a compaction; a session's end forgets its profile.
31
34
  import { existsSync } from "node:fs"
32
35
  import path from "node:path"
33
36
  import { randomBytes } from "node:crypto"
@@ -65,6 +68,12 @@ export interface HookReply {
65
68
  readonly env?: Record<string, string>
66
69
  }
67
70
 
71
+ /** The session-scoped profile state (ClaudeProfiles). */
72
+ export interface ProfileContextPort {
73
+ context(sessionID: string, event: "UserPromptSubmit" | "SessionStart", source?: string): string | undefined
74
+ ended(sessionID: string): void
75
+ }
76
+
68
77
  export interface SessionsOptions {
69
78
  readonly spoke: SpokePort
70
79
  /** How long SessionStart waits for the hub's session token (default 3 s). */
@@ -76,6 +85,8 @@ export interface SessionsOptions {
76
85
  /** Context for the hub addresses in a user's prompt (design §2.4); undefined when there is none. */
77
86
  readonly references?: (prompt: string, sessionID: string) => Promise<string | undefined>
78
87
  readonly now?: () => number
88
+ /** Profile instructions for the hooks to carry (HWA-17). */
89
+ readonly profiles?: ProfileContextPort
79
90
  }
80
91
 
81
92
  const DEFAULT_TOKEN_WAIT_MS = 3000
@@ -143,6 +154,7 @@ export class ClaudeSessions {
143
154
  readonly #projectOf: (cwd: string) => string | undefined
144
155
  readonly #references?: SessionsOptions["references"]
145
156
  readonly #now: () => number
157
+ readonly #profiles?: ProfileContextPort
146
158
  /** By native session ID. */
147
159
  readonly #sessions = new Map<string, Session>()
148
160
  /** Hub session ID (root or child) → native root session ID. */
@@ -157,6 +169,7 @@ export class ClaudeSessions {
157
169
  this.#projectOf = options.projectOf ?? gitRoot
158
170
  this.#references = options.references
159
171
  this.#now = options.now ?? Date.now
172
+ this.#profiles = options.profiles
160
173
  }
161
174
 
162
175
  /** Handles one hook input; never throws for a valid input. */
@@ -175,7 +188,7 @@ export class ClaudeSessions {
175
188
  if (ctx.rewake) return this.#rewake(s, ctx)
176
189
  switch (event) {
177
190
  case "SessionStart":
178
- return this.#sessionStart(s, created)
191
+ return this.#sessionStart(s, created, input.source)
179
192
  case "UserPromptSubmit":
180
193
  return this.#prompt(s, input)
181
194
  case "PreToolUse":
@@ -330,15 +343,17 @@ export class ClaudeSessions {
330
343
  return { id, title, directory: cwd, updated: this.#now(), owned: true, ...(parentID ? { parentID } : {}), ...(project ? { project } : {}) }
331
344
  }
332
345
 
333
- async #sessionStart(s: Session, _created: boolean): Promise<HookReply> {
346
+ async #sessionStart(s: Session, _created: boolean, source: string | undefined): Promise<HookReply> {
347
+ const profile = this.#profiles?.context(s.hubID, "SessionStart", source)
348
+ const output = profile ? { output: { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: profile } } } : {}
334
349
  const deadline = this.#now() + this.#tokenWaitMs
335
350
  let token = this.#spoke.sessionToken(s.hubID)
336
351
  while (!token && this.#now() < deadline) {
337
352
  await new Promise((resolve) => setTimeout(resolve, TOKEN_POLL_MS))
338
353
  token = this.#spoke.sessionToken(s.hubID)
339
354
  }
340
- if (!token) return {}
341
- return { env: { ...(token.url ? { HUB_URL: token.url } : {}), HUB_SESSION_TOKEN: token.token } }
355
+ if (!token) return output
356
+ return { env: { ...(token.url ? { HUB_URL: token.url } : {}), HUB_SESSION_TOKEN: token.token }, ...output }
342
357
  }
343
358
 
344
359
  async #prompt(s: Session, input: HookInput): Promise<HookReply> {
@@ -361,6 +376,8 @@ export class ClaudeSessions {
361
376
  if (fromUser) this.#emit({ type: "session.inbox.delivered", data: { sessionID: s.hubID, inboxID: `prompt_${turnID}`, origin: "user" } })
362
377
  this.#release(s)
363
378
  const parts: string[] = []
379
+ const profile = this.#profiles?.context(s.hubID, "UserPromptSubmit")
380
+ if (profile) parts.push(profile)
364
381
  if (fromUser && this.#references && prompt.includes("@")) {
365
382
  const note = await this.#references(prompt, s.hubID).catch(() => undefined)
366
383
  if (note) parts.push(note)
@@ -517,6 +534,7 @@ export class ClaudeSessions {
517
534
  this.#sessions.delete(native)
518
535
  if (s.launch && this.#launches.get(s.launch) === s.hubID) this.#launches.delete(s.launch)
519
536
  this.#spoke.endSession(s.hubID)
537
+ this.#profiles?.ended(s.hubID)
520
538
  }
521
539
 
522
540
  // ---------- permissions ----------