@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
@@ -3,7 +3,8 @@
3
3
  // --input-format stream-json --output-format stream-json --verbose [--model m] [--agent a]`, so its hooks reach the
4
4
  // daemon like any `hub claude` run (HUB_CLAUDE_LAUNCH, HUB_CLAUDE_SOCKET), a turn is a user message written to its
5
5
  // stdin, the turn's end is the result line on its stdout, and an interrupt is a control request (Esc has no hook,
6
- // a control request works). Turns are answered in order (Claude Code runs queued user messages one after another).
6
+ // a control request works). A profile's model (HWA-17) is the control request set_model, confirmed by Claude Code's
7
+ // control response. Turns are answered in order (Claude Code runs queued user messages one after another).
7
8
  // When claude exits its open turns fail and onExit reports it; stopAll closes stdin (claude ends after the running
8
9
  // turn) and kills what lingers.
9
10
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
@@ -44,7 +45,8 @@ interface Turn {
44
45
  interface Proc {
45
46
  readonly child: ChildProcessWithoutNullStreams
46
47
  readonly turns: Turn[]
47
- readonly controls: Map<string, () => void>
48
+ /** Open control requests: called with Claude Code's response, or nothing when it exited. */
49
+ readonly controls: Map<string, (response?: Record<string, any>) => void>
48
50
  readonly exited: Promise<void>
49
51
  stderr: string
50
52
  }
@@ -224,14 +226,32 @@ export class ClaudeLauncher {
224
226
  const proc = this.#procs.get(sessionID)
225
227
  if (!proc) return false
226
228
  for (const turn of proc.turns) turn.interrupted = true
227
- const id = `hub_int_${++this.#controlID}`
228
- const answered = new Promise<void>((resolve) => proc.controls.set(id, resolve))
229
- proc.child.stdin.write(`${JSON.stringify({ type: "control_request", request_id: id, request: { subtype: "interrupt" } })}\n`)
230
- await Promise.race([answered, sleep(CONTROL_WAIT_MS)])
231
- proc.controls.delete(id)
229
+ await this.#control(proc, { subtype: "interrupt" }, CONTROL_WAIT_MS)
232
230
  return true
233
231
  }
234
232
 
233
+ /**
234
+ * Changes the session's model (control request set_model, a Claude Code model ID or alias); resolves when Claude
235
+ * Code confirms, rejects with its error, when it does not answer in `waitMs`, or for a session it did not start.
236
+ */
237
+ async setModel(sessionID: string, model: string, waitMs = CONTROL_WAIT_MS): Promise<void> {
238
+ const proc = this.#procs.get(sessionID)
239
+ if (!proc) throw new Error(`Session ${sessionID} was not started by the hub here (only hub-launched Claude Code sessions change model from the hub)`)
240
+ const response = await this.#control(proc, { subtype: "set_model", model }, waitMs)
241
+ if (!response) throw new Error("Claude Code did not confirm the model change")
242
+ if (response.subtype !== "success") throw new Error(typeof response.error === "string" && response.error ? response.error.slice(0, 200) : "Claude Code refused the model change")
243
+ }
244
+
245
+ /** Sends a control request; resolves with Claude Code's response, or undefined (no answer in time, or it exited). */
246
+ async #control(proc: Proc, request: Record<string, unknown>, waitMs: number): Promise<Record<string, any> | undefined> {
247
+ const id = `hub_ctl_${++this.#controlID}`
248
+ const answered = new Promise<Record<string, any> | undefined>((resolve) => proc.controls.set(id, resolve))
249
+ proc.child.stdin.write(`${JSON.stringify({ type: "control_request", request_id: id, request })}\n`)
250
+ const response = await Promise.race([answered, sleep(waitMs).then(() => undefined)])
251
+ proc.controls.delete(id)
252
+ return response
253
+ }
254
+
235
255
  /** Ends every process: stdin closed, then SIGTERM and SIGKILL for what lingers. */
236
256
  async stopAll(): Promise<void> {
237
257
  const procs = [...this.#procs.values()]
@@ -258,8 +278,8 @@ export class ClaudeLauncher {
258
278
  const turn = proc.turns.shift()
259
279
  turn?.resolve(turnResult(line, turn.interrupted))
260
280
  } else if (line.type === "control_response") {
261
- const id = record(line.response).request_id
262
- if (typeof id === "string") proc.controls.get(id)?.()
281
+ const response = record(line.response)
282
+ if (typeof response.request_id === "string") proc.controls.get(response.request_id)?.(response)
263
283
  }
264
284
  }
265
285
  }
@@ -1,18 +1,18 @@
1
1
  // Where the Claude Code spoke keeps its files: <hub state dir>/claude-code (the hub state dir is $HUB_STATE_DIR, else
2
- // ~/.local/state/hub), a private (0700) directory holding the daemon's socket (daemon.sock, 0600), its log and the
2
+ // $XDG_STATE_HOME/hub or ~/.local/state/hub), a private (0700) directory holding the daemon's socket (daemon.sock, 0600), its log and the
3
3
  // plugin directories the wrapper loads with --plugin-dir. $HUB_CLAUDE_SOCKET names another socket (tests, or a
4
4
  // short path when the state dir is too deep for a Unix socket path).
5
5
  import { chmodSync, lstatSync, mkdirSync } from "node:fs"
6
6
  import { homedir } from "node:os"
7
7
  import path from "node:path"
8
8
  import { hubEnv, type Env } from "../../../src/env.ts"
9
- import { APP_DIR, stateBase } from "../../../src/migrate.ts"
9
+ import { resolveHubStateDir } from "../../../src/migrate.ts"
10
10
 
11
11
  /** Longest Unix socket path (macOS sun_path is 104 bytes with the terminating NUL). */
12
12
  export const SOCKET_PATH_MAX = 103
13
13
 
14
14
  export function claudeStateDir(env: Env = process.env, home: string = homedir()): string {
15
- return path.join(hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR), "claude-code")
15
+ return path.join(resolveHubStateDir(env, home).dir, "claude-code")
16
16
  }
17
17
 
18
18
  export function socketPath(env: Env = process.env, home: string = homedir()): string {
@@ -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 ----------