@huynle/hub 0.7.4 → 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.
- package/CHANGELOG.md +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +632 -49
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Hub: a harness-agnostic central hub your AI agent harnesses connect to (OpenCode first). Follow, steer and stop sessions from your phone or browser, with notifications, optional hands-free Voice and a self-hostable server.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Huy Le",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"packages/codex"
|
|
100
100
|
],
|
|
101
101
|
"engines": {
|
|
102
|
-
"bun": ">=1.
|
|
102
|
+
"bun": ">=1.4.4"
|
|
103
103
|
},
|
|
104
104
|
"publishConfig": {
|
|
105
105
|
"access": "public"
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
"build:web": "vite build --config web/vite.config.ts",
|
|
111
111
|
"build:cli": "bun build packages/cli/src/main.ts --target=node --outfile=packages/cli/dist/hub-cli.js",
|
|
112
112
|
"dev:web": "vite --config web/vite.config.ts",
|
|
113
|
-
"test:e2e": "
|
|
113
|
+
"test:e2e": "bash scripts/test-e2e.sh",
|
|
114
114
|
"prepack": "npm run build:web && npm run build:cli",
|
|
115
115
|
"hub": "bun bin/hub.ts"
|
|
116
116
|
},
|
|
@@ -121,7 +121,9 @@
|
|
|
121
121
|
},
|
|
122
122
|
"devDependencies": {
|
|
123
123
|
"@types/bun": "^1.4.2",
|
|
124
|
+
"@types/three": "0.186.0",
|
|
124
125
|
"@types/web-push": "^3.6.4",
|
|
126
|
+
"3d-force-graph": "1.80.1",
|
|
125
127
|
"onnxruntime-web": "1.23.2",
|
|
126
128
|
"playwright-core": "^1.64.1",
|
|
127
129
|
"solid-js": "^1.9.17",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@huynle/hub-claude-code",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "The Claude Code hooks spoke: per-user daemon, hook bridge, plugin dir, `hub claude` wrapper and daemon-backed MCP server (internal; shipped inside @huynle/hub, not published on its own).",
|
|
6
6
|
"license": "MIT",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// once and runs its claude headless (launcher.ts) with the hub plugin, so its hooks come back here like any other
|
|
10
10
|
// session's. turn and interrupt reach only those sessions (stdin user messages, control requests); a terminal
|
|
11
11
|
// session refuses them. stop interrupts a launched session's running turn at once, besides the hook-level stop.
|
|
12
|
-
// Stopping the daemon ends the claude processes it started.
|
|
12
|
+
// Stopping the daemon ends the claude processes it started. profileApply (HWA-17, profile.ts) is served either way.
|
|
13
13
|
//
|
|
14
14
|
// A `hub claude` run registers its pid ("launch"); the daemon checks it now and then and ends the run's sessions
|
|
15
15
|
// when the process is gone (Claude Code killed without SessionEnd), and at once when the wrapper reports its exit.
|
|
@@ -23,6 +23,7 @@ import { parseHookInput } from "./hook-input.ts"
|
|
|
23
23
|
import { ensurePrivateDir, SOCKET_PATH_MAX } from "./paths.ts"
|
|
24
24
|
import { createMcpRouter, MCP_END, type McpRouter } from "./mcp.ts"
|
|
25
25
|
import { ClaudeLauncher, launchAgent, launchDirectory, launchModel } from "./launcher.ts"
|
|
26
|
+
import { ClaudeProfiles } from "./profile.ts"
|
|
26
27
|
import { hubReferences } from "./references.ts"
|
|
27
28
|
import { ClaudeSessions, type SessionsOptions } from "./sessions.ts"
|
|
28
29
|
import { ask, onLines, WIRE_VERSION } from "./wire.ts"
|
|
@@ -167,9 +168,14 @@ export async function startDaemon(options: DaemonOptions): Promise<ClaudeDaemon>
|
|
|
167
168
|
return { stopped: stopped.stopped.length ? stopped.stopped : [req.sessionID] }
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
const profiles = new ClaudeProfiles({
|
|
172
|
+
kind: (id, native = sessions.nativeOf(id)) => (native === undefined ? undefined : launchKeys.has(native) ? "launched" : "terminal"),
|
|
173
|
+
setModel: (id, model) => launcher!.setModel(sessions.nativeOf(id)!, model),
|
|
174
|
+
})
|
|
170
175
|
const handlers: OpHandlers = {
|
|
171
176
|
permission: (req) => sessions.permission(req),
|
|
172
177
|
stop,
|
|
178
|
+
profileApply: profiles.handle,
|
|
173
179
|
...(launcher ? { create, turn, interrupt } : {}),
|
|
174
180
|
}
|
|
175
181
|
const features: ModifierFeature[] = ["permissionMessage", "hubPrompt", ...(launcher ? (["launch"] as const) : [])]
|
|
@@ -195,6 +201,7 @@ export async function startDaemon(options: DaemonOptions): Promise<ClaudeDaemon>
|
|
|
195
201
|
...(options.tokenWaitMs !== undefined ? { tokenWaitMs: options.tokenWaitMs } : {}),
|
|
196
202
|
...(options.permissionWaitMs !== undefined ? { permissionWaitMs: options.permissionWaitMs } : {}),
|
|
197
203
|
references: options.references ?? hubReferences((id) => spoke.sessionToken(id)),
|
|
204
|
+
profiles,
|
|
198
205
|
})
|
|
199
206
|
|
|
200
207
|
const launches = new Map<string, number>()
|
|
@@ -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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
262
|
-
if (typeof
|
|
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
|
}
|
|
@@ -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 ----------
|