@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
@@ -1,4 +1,5 @@
1
- // The background service's session listing for the phone's session browser: the most recent root
1
+ // The session listing for the phone's session browser (the background service: every session; a foreground
2
+ // `opencode serve`: those of the locations it loaded, see ListingOptions.scope): the most recent root
2
3
  // sessions (no subagent children, nothing archived) within the history window, plus sessions that must
3
4
  // always be listed (open in a terminal, running, ...), each with the workspace it belongs to.
4
5
  import path from "node:path"
@@ -21,14 +22,19 @@ export interface RawProject {
21
22
  readonly name?: string
22
23
  }
23
24
 
25
+ /** Per-request options (the OpenCode client's RequestOptions): the listing's cancellation. */
26
+ export interface ListingRequest {
27
+ readonly signal?: AbortSignal
28
+ }
29
+
24
30
  /** The parts of the OpenCode client the listing uses. */
25
31
  export interface ListingClient {
26
32
  readonly session: {
27
- list(input: { limit?: number; parentID?: null; cursor?: string }): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
33
+ list(input: { limit?: number; parentID?: null; cursor?: string }, request?: ListingRequest): Promise<{ data: readonly RawSession[]; cursor: { next?: string | null } }>
28
34
  /** One session by ID (for always-included sessions outside the window). */
29
- get?(input: { sessionID: string }): Promise<RawSession>
35
+ get?(input: { sessionID: string }, request?: ListingRequest): Promise<RawSession>
30
36
  }
31
- readonly project: { list(): Promise<readonly RawProject[]> }
37
+ readonly project: { list(request?: ListingRequest): Promise<readonly RawProject[]> }
32
38
  }
33
39
 
34
40
  export interface ListedSession {
@@ -51,16 +57,33 @@ export interface ListingOptions {
51
57
  readonly now?: number
52
58
  /** Session IDs listed even outside the window or over `max` (fetched one by one when missing). */
53
59
  readonly include?: readonly string[]
60
+ /**
61
+ * Directories of the locations this process has loaded (a foreground `opencode serve`): only sessions of their
62
+ * projects, or in a directory under one of them, are listed. Absent: every session (the background service).
63
+ */
64
+ readonly scope?: readonly string[]
65
+ /** Cancels the listing: passed to every request, and no request starts once it aborted (the listing rejects). */
66
+ readonly signal?: AbortSignal
54
67
  }
55
68
 
56
69
  /** Most always-included sessions fetched one by one per listing. */
57
70
  export const INCLUDE_FETCH_MAX = 50
58
71
  const DAY_MS = 86_400_000
59
72
 
73
+ /**
74
+ * OpenCode's global project (directories outside any repository with commits). Its canonical directory is "/" in
75
+ * older versions and, in 2.2.x, a real directory (the first such directory registered): never a workspace root.
76
+ */
77
+ export const GLOBAL_PROJECT_ID = "global"
78
+
79
+ /** A project that is a real workspace root (not the global project). */
80
+ const isRealProject = (project: RawProject | undefined): project is RawProject =>
81
+ project !== undefined && project.id !== GLOBAL_PROJECT_ID && !!project.canonical && project.canonical !== "/"
82
+
60
83
  /** Workspace root and name of a session: its project, else its directory minus its subpath. */
61
84
  export function workspaceOf(session: RawSession, projects: ReadonlyMap<string, RawProject>): { project: string; projectName: string } {
62
85
  const known = session.projectID ? projects.get(session.projectID) : undefined
63
- if (known && known.canonical && known.canonical !== "/") {
86
+ if (isRealProject(known)) {
64
87
  return { project: known.canonical, projectName: known.name || path.basename(known.canonical) }
65
88
  }
66
89
  const directory = session.location.directory
@@ -69,16 +92,46 @@ export function workspaceOf(session: RawSession, projects: ReadonlyMap<string, R
69
92
  return { project: root, projectName: path.basename(root) || root }
70
93
  }
71
94
 
95
+ const trimSlash = (dir: string) => dir.replace(/\/+$/, "") || "/"
96
+ /** `dir` is `root` or inside it. */
97
+ const within = (dir: string, root: string) => {
98
+ const r = trimSlash(root)
99
+ return dir === r || dir.startsWith(r === "/" ? "/" : `${r}/`)
100
+ }
101
+
102
+ /**
103
+ * Whether a session belongs to a loaded location: its directory is under one, or its project (a real one, not the
104
+ * global project) is the project of one (the project whose root holds the location, the deepest one).
105
+ */
106
+ export function inScope(session: RawSession, scope: readonly string[], projects: ReadonlyMap<string, RawProject>): boolean {
107
+ const directory = trimSlash(session.location.directory)
108
+ const dirs = scope.map(trimSlash)
109
+ if (dirs.some((dir) => within(directory, dir))) return true
110
+ const own = session.projectID ? projects.get(session.projectID) : undefined
111
+ if (!isRealProject(own)) return false
112
+ const roots = [...projects.values()].filter(isRealProject).map((p) => p.canonical)
113
+ return dirs.some((dir) => {
114
+ const holder = roots.filter((root) => within(dir, root)).sort((a, b) => b.length - a.length)[0]
115
+ return holder !== undefined && trimSlash(holder) === trimSlash(own.canonical)
116
+ })
117
+ }
118
+
72
119
  export async function listServiceSessions(client: ListingClient, options: ListingOptions = {}): Promise<ListedSession[]> {
73
120
  const max = options.max ?? 500
121
+ if (options.scope?.length === 0) return []
74
122
  const pageSize = options.pageSize ?? 100
75
123
  const maxPages = options.maxPages ?? 20
124
+ const { signal } = options
125
+ const request: ListingRequest = signal ? { signal } : {}
126
+ signal?.throwIfAborted()
76
127
  const projects = new Map<string, RawProject>()
77
128
  try {
78
- for (const project of await client.project.list()) projects.set(project.id, project)
129
+ for (const project of await client.project.list(request)) projects.set(project.id, project)
79
130
  } catch {
80
131
  // workspaces fall back to directories
81
132
  }
133
+ const scope = options.scope
134
+ const wanted = (s: RawSession) => scope === undefined || inScope(s, scope, projects)
82
135
  const cutoff = options.days !== undefined ? (options.now ?? Date.now()) - options.days * DAY_MS : -Infinity
83
136
  const listed = (s: RawSession): ListedSession => ({ id: s.id, title: s.title ?? "", directory: s.location.directory, updated: s.time.updated, ...workspaceOf(s, projects) })
84
137
  const out: ListedSession[] = []
@@ -86,14 +139,15 @@ export async function listServiceSessions(client: ListingClient, options: Listin
86
139
  // Sessions come newest first: the first one older than the window ends the listing.
87
140
  let ended = false
88
141
  for (let page = 0; page < maxPages && out.length < max && !ended; page++) {
89
- const result = await client.session.list({ limit: pageSize, parentID: null, ...(cursor ? { cursor } : {}) })
142
+ signal?.throwIfAborted()
143
+ const result = await client.session.list({ limit: pageSize, parentID: null, ...(cursor ? { cursor } : {}) }, request)
90
144
  for (const s of result.data) {
91
145
  if (s.parentID || s.time.archived || out.length >= max) continue
92
146
  if (s.time.updated < cutoff) {
93
147
  ended = true
94
148
  break
95
149
  }
96
- out.push(listed(s))
150
+ if (wanted(s)) out.push(listed(s))
97
151
  }
98
152
  cursor = result.cursor.next ?? undefined
99
153
  if (!cursor || result.data.length === 0) break
@@ -101,8 +155,9 @@ export async function listServiceSessions(client: ListingClient, options: Listin
101
155
  const have = new Set(out.map((s) => s.id))
102
156
  const missing = [...new Set(options.include ?? [])].filter((id) => !have.has(id)).slice(0, INCLUDE_FETCH_MAX)
103
157
  if (missing.length && client.session.get) {
104
- const fetched = await Promise.all(missing.map((sessionID) => client.session.get!({ sessionID }).catch(() => undefined)))
105
- for (const s of fetched) if (s && s.id && !s.parentID && !s.time?.archived && s.location?.directory) out.push(listed(s))
158
+ signal?.throwIfAborted()
159
+ const fetched = await Promise.all(missing.map((sessionID) => client.session.get!({ sessionID }, request).catch(() => undefined)))
160
+ for (const s of fetched) if (s && s.id && !s.parentID && !s.time?.archived && s.location?.directory && wanted(s)) out.push(listed(s))
106
161
  }
107
162
  return out
108
163
  }
@@ -10,7 +10,9 @@ import path from "node:path"
10
10
  import { fileURLToPath } from "node:url"
11
11
  import { hubEnv } from "../env"
12
12
  import { isLoopbackURL } from "../loopback"
13
- import { APP_DIR, legacyPaths, migrateFirst, stateBase } from "../migrate"
13
+ import { migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
14
+
15
+ const CONCIERGE_LOCK = "concierge-sessions.json"
14
16
 
15
17
  export interface ChildLike {
16
18
  readonly pid?: number
@@ -23,7 +25,7 @@ export type SpawnLike = (command: string, args: readonly string[], options: obje
23
25
 
24
26
  export interface LocalHubOptions {
25
27
  /**
26
- * Where hub.pid and hub.log live (default $HUB_STATE_DIR or ~/.local/state/hub, migrated from
28
+ * Where hub.pid and hub.log live (default $HUB_STATE_DIR, else $XDG_STATE_HOME/hub or ~/.local/state/hub, migrated from
27
29
  * ~/.local/state/opencode-hub or opencode-voice first so the hub does not find a fresh, empty state directory).
28
30
  */
29
31
  readonly stateDir?: string
@@ -86,7 +88,15 @@ export function hubScript(root: string = repoRoot()): string {
86
88
  }
87
89
 
88
90
  export function defaultStateDir(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): string {
89
- return hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR)
91
+ return resolveHubStateDir(env, home).dir
92
+ }
93
+
94
+ /** The Concierge lock file (written in the state dir) and the other default places' lock files (read too: fail closed). */
95
+ export function conciergeLockFiles(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): { file: string; read: string[] } {
96
+ const { candidates, source } = resolveHubStateDir(env, home)
97
+ const [dir, ...others] = candidates
98
+ // An explicit HUB_STATE_DIR is an isolated instance (a preview or a test): it never reads the user's default dirs.
99
+ return { file: path.join(dir!, CONCIERGE_LOCK), read: source === "env" ? [] : others.map((d) => path.join(d, CONCIERGE_LOCK)) }
90
100
  }
91
101
 
92
102
  /** The runtime for this process: probes the real executable, PATH and home directory. */
@@ -152,7 +162,7 @@ export async function ensureLocalHub(server: string, options: LocalHubOptions =
152
162
  if (!(options.webBuilt ?? hasWebBuild)(root)) return { status: "failed", error: WEB_NOT_BUILT }
153
163
 
154
164
  if (options.stateDir === undefined && !hubEnv(env).HUB_STATE_DIR) {
155
- migrateFirst(legacyPaths(stateBase(home)), stateDir, { skip: ["hub.pid"], ...(options.log ? { log: options.log } : {}) })
165
+ migrateStateDir(stateLegacyPaths(env, home), stateDir, { skip: ["hub.pid"], ...(options.log ? { log: options.log } : {}) })
156
166
  }
157
167
  mkdirSync(stateDir, { recursive: true, mode: 0o700 })
158
168
 
@@ -10,6 +10,7 @@ export interface PermissionLocation {
10
10
  get(input: { sessionID: string; requestID: string }): Promise<unknown>
11
11
  /** `message`: a reject's reason (OpenCode's reply message). */
12
12
  reply(input: { sessionID: string; requestID: string; decision: PermissionDecision; message?: string }): Promise<unknown>
13
+ vcs?: { diff(): Promise<ReadonlyArray<unknown>>; branch(): Promise<string | undefined> }
13
14
  }
14
15
 
15
16
  /**
@@ -33,3 +34,42 @@ export async function replyPermission(
33
34
  }
34
35
  return "gone"
35
36
  }
37
+
38
+ /** A directory's branch and working-copy changes (undefined: nothing could read them). */
39
+ export interface VcsReader {
40
+ diff(directory: string): Promise<ReadonlyArray<unknown> | undefined>
41
+ branch(directory: string): Promise<string | undefined>
42
+ }
43
+
44
+ /**
45
+ * Reads a directory's VCS state through the plugin instance whose location is that directory, else the own
46
+ * server's API (which honours the location), else not at all. OpenCode's plugin vcs API answers for the
47
+ * instance's own location whatever location is passed, so the first instance (the background service's is
48
+ * its $HOME) would report another directory's branch and changes. A read that takes longer than `timeoutMs` (a hung
49
+ * server) answers undefined, so the session summary goes on without branch and changes.
50
+ */
51
+ /** How long a branch or changes read may take before the summary goes on without it (a hung server). */
52
+ export const VCS_READ_TIMEOUT_MS = 5_000
53
+
54
+ /** The value, or undefined once `ms` passed (the read is left to finish on its own). */
55
+ function within<T>(read: Promise<T | undefined>, ms: number): Promise<T | undefined> {
56
+ let timer: ReturnType<typeof setTimeout> | undefined
57
+ const late = new Promise<undefined>((resolve) => {
58
+ timer = setTimeout(() => resolve(undefined), ms)
59
+ })
60
+ return Promise.race([read.catch(() => undefined), late]).finally(() => clearTimeout(timer))
61
+ }
62
+
63
+ export function vcsReader(locations: () => readonly PermissionLocation[], server: () => Promise<VcsReader | undefined>, timeoutMs: number = VCS_READ_TIMEOUT_MS): VcsReader {
64
+ const local = (directory: string) => locations().find((l) => l.directory === directory && l.vcs)?.vcs
65
+ return {
66
+ diff: async (directory) => {
67
+ const vcs = local(directory)
68
+ return within(vcs ? vcs.diff() : server().then((reader) => reader?.diff(directory)), timeoutMs)
69
+ },
70
+ branch: async (directory) => {
71
+ const vcs = local(directory)
72
+ return within(vcs ? vcs.branch() : server().then((reader) => reader?.branch(directory)), timeoutMs)
73
+ },
74
+ }
75
+ }
@@ -0,0 +1,298 @@
1
+ // The OpenCode profile application adapter (HWA-16; contract packages/protocol/src/profiles.ts, SDK rules in
2
+ // packages/spoke-sdk/src/profile-apply.ts). A hub profile becomes a session-scoped layer held in this process's
3
+ // memory only; nothing is written to disk and no OpenCode configuration (global or project) is touched:
4
+ //
5
+ // - Instructions: the plugin's `context` hook appends them to the session's system prompt after OpenCode's own
6
+ // parts (which carry AGENTS.md and the user's config), framed by profileInstructionsBlock: local instructions win.
7
+ // - Skills and commands: listed in that system part and served by the `hub_profile` tool (registered once per
8
+ // process like the `hub` tool; the context hook hides it from sessions without a profile, and it answers only
9
+ // the calling session's layer). OpenCode's skill/command registries are location-wide, so they are not used.
10
+ // Same-named local skills/commands win (partial). Commands that reference `{{secret:NAME}}` are skipped: a
11
+ // rendered command becomes a persisted prompt or tool result in OpenCode, and resolved secrets must never be
12
+ // written (renderAtInvocation false). The model preference is ignored (the user's model is never switched).
13
+ // - Next-turn boundary: an idle session's layer is replaced at once (its next turn reads it). For a busy session
14
+ // the new layer is staged, the running turn keeps the previous one, and the answer waits for the turn to end
15
+ // (Spoke.observe calls settle; a poll backs it up). The answer echoes exactly the version that became
16
+ // effective. A wait past boundaryWaitMs fails: the hub, which times out first, keeps the pin pending and sends it
17
+ // again on the spoke's next sessions frame, which is answered from the then-effective layer. A newer version
18
+ // staged before the boundary supersedes the older one, which is answered `rejected`.
19
+ // - Rollback: a failed or unsupported apply leaves the effective layer as it was; re-pinning an older version
20
+ // replaces the layer with it. There is no remove op in v1 (an empty version clears the layer).
21
+ // - Bounded: at most maxSessions layers (oldest dropped; the hub re-sends after a reconnect), notes bounded by the
22
+ // SDK. Lost on process restart: the new connection's sessions frame makes the hub send the pin again.
23
+ import { isObj } from "../../packages/protocol/src/wire.ts"
24
+ import { type ProfileApplyRequest, type ProfileApplyResult, type ProfileCommand, type ProfileSkill, profileTemplateParts } from "../../packages/protocol/src/index.ts"
25
+ import { boundedNotes, planProfileApplication, profileApplyResult, profileDigest, type ProfileLocalNames, renderProfileCommand } from "../../packages/spoke-sdk/src/profile-apply.ts"
26
+
27
+ export const PROFILE_TOOL = "hub_profile"
28
+
29
+ export const PROFILE_TOOL_DESCRIPTION =
30
+ "Use this session's hub agent profile: {skill: name} returns a profile skill's instructions; " +
31
+ "{command: name, params: {name: value}} returns a profile command's text with its parameters filled in, to follow as the user's request. " +
32
+ "Only the skills and commands listed in the hub profile section of your instructions exist."
33
+
34
+ export const PROFILE_TOOL_INPUT = {
35
+ type: "object",
36
+ properties: {
37
+ skill: { type: "string", description: "A profile skill's name." },
38
+ command: { type: "string", description: "A profile command's name." },
39
+ params: { type: "object", additionalProperties: { type: "string" }, description: "The command's parameters (each {{name}} in it; `arguments` for free text)." },
40
+ },
41
+ additionalProperties: false,
42
+ } as const
43
+
44
+ export interface OpenCodeProfilesOptions {
45
+ /** The session has a turn running (OpenCode busy, or a hub turn in progress). */
46
+ readonly isBusy: (sessionID: string) => boolean
47
+ /** The session's local skill and command names; absent: skills and commands are not applied. */
48
+ readonly localNames?: (sessionID: string) => Promise<ProfileLocalNames>
49
+ /** The hub_profile tool is available in this OpenCode. */
50
+ readonly tool: () => boolean
51
+ /** How long a busy session's answer waits for the boundary (default 120 s). */
52
+ readonly boundaryWaitMs?: number
53
+ /** How often a waiting answer checks the session (default 1 s). */
54
+ readonly pollMs?: number
55
+ /** Sessions with a layer kept (default 1000). */
56
+ readonly maxSessions?: number
57
+ }
58
+
59
+ export interface ProfileEffectiveLayer {
60
+ readonly profileID: string
61
+ readonly version: number
62
+ readonly digest: string
63
+ readonly status: ProfileApplyResult["status"]
64
+ }
65
+
66
+ interface Layer {
67
+ readonly result: ProfileApplyResult
68
+ readonly instructions?: string
69
+ readonly skills: readonly ProfileSkill[]
70
+ readonly commands: readonly ProfileCommand[]
71
+ }
72
+
73
+ interface Waiter {
74
+ readonly resolve: (result: ProfileApplyResult) => void
75
+ readonly reject: (cause: Error) => void
76
+ }
77
+
78
+ interface Staged {
79
+ readonly layer: Layer
80
+ readonly waiters: Set<Waiter>
81
+ poll?: ReturnType<typeof setInterval>
82
+ }
83
+
84
+ /** A final answer, or one waiting for the boundary (its waiter is registered before staging returns). */
85
+ type Step = { readonly result: ProfileApplyResult } | { readonly pending: Promise<ProfileApplyResult> }
86
+
87
+ const sameVersion = (a: ProfileApplyResult, req: ProfileApplyRequest) => a.profileID === req.profileID && a.version === req.version && a.digest === req.digest
88
+
89
+ export class OpenCodeProfiles {
90
+ private readonly layers = new Map<string, Layer>()
91
+ private readonly staged = new Map<string, Staged>()
92
+ private readonly locks = new Map<string, Promise<void>>()
93
+
94
+ constructor(private readonly options: OpenCodeProfilesOptions) {}
95
+
96
+ /** The profileApply op: answers once the version is effective in the session (see the header). */
97
+ async handle(req: ProfileApplyRequest): Promise<ProfileApplyResult> {
98
+ if (profileDigest(req.content) !== req.digest) return profileApplyResult(req, "rejected", ["digest does not match the content; nothing applied"])
99
+ const step = await this.serialize(req.sessionID, () => this.stage(req))
100
+ return "pending" in step ? step.pending : step.result
101
+ }
102
+
103
+ /** A turn boundary may have passed: makes a staged layer effective when the session is not busy. */
104
+ settle(sessionID: string): void {
105
+ const staged = this.staged.get(sessionID)
106
+ if (!staged || this.options.isBusy(sessionID)) return
107
+ this.dropStaged(sessionID, staged)
108
+ this.setLayer(sessionID, staged.layer)
109
+ for (const waiter of staged.waiters) waiter.resolve(staged.layer.result)
110
+ }
111
+
112
+ /** The version the session runs now, if any. */
113
+ effective(sessionID: string): ProfileEffectiveLayer | undefined {
114
+ const layer = this.layers.get(sessionID)
115
+ if (!layer) return undefined
116
+ const { profileID, version, digest, status } = layer.result
117
+ return { profileID, version, digest, status }
118
+ }
119
+
120
+ /** System prompt parts for the session's next model request (after local instructions). */
121
+ systemParts(sessionID: string): string[] {
122
+ const layer = this.layers.get(sessionID)
123
+ if (!layer) return []
124
+ const parts: string[] = []
125
+ if (layer.instructions !== undefined) parts.push(layer.instructions)
126
+ if (layer.skills.length || layer.commands.length) parts.push(bindingsText(layer))
127
+ return parts
128
+ }
129
+
130
+ /** Whether the session's model sees the hub_profile tool. */
131
+ toolVisible(sessionID: string): boolean {
132
+ const layer = this.layers.get(sessionID)
133
+ return layer !== undefined && (layer.skills.length > 0 || layer.commands.length > 0)
134
+ }
135
+
136
+ /** The hub_profile tool for the calling session: a skill's body or a command's rendered text, or why not. */
137
+ runTool(sessionID: string, input: unknown): string {
138
+ const value = isObj(input) ? input : {}
139
+ const skill = typeof value.skill === "string" ? value.skill : undefined
140
+ const command = typeof value.command === "string" ? value.command : undefined
141
+ if (skill === undefined && command === undefined) return "Give a skill or command name of this session's hub profile."
142
+ const layer = this.layers.get(sessionID)
143
+ if (!layer) return "This session has no hub profile."
144
+ if (skill !== undefined) {
145
+ const found = layer.skills.find((s) => s.name === skill)
146
+ return found ? `Skill ${found.name} (hub profile ${layer.result.profileID} v${layer.result.version}):\n\n${found.body}` : `This session has no hub profile skill named ${skill}.`
147
+ }
148
+ const found = layer.commands.find((c) => c.name === command)
149
+ if (!found) return `This session has no hub profile command named ${command}.`
150
+ const params = isObj(value.params) ? value.params : {}
151
+ if (!Object.values(params).every((v) => typeof v === "string")) return `Command ${found.name}: every parameter must be text.`
152
+ try {
153
+ // Commands with secret references are never in a layer, so no secret is ever resolved here.
154
+ return renderProfileCommand(found.template, params as Record<string, string>, () => undefined)
155
+ } catch (cause) {
156
+ return `Command ${found.name} could not be run: ${cause instanceof Error ? cause.message : String(cause)}`
157
+ }
158
+ }
159
+
160
+ /** The session ended: drops its layer and fails an answer still waiting for its boundary. */
161
+ forget(sessionID: string): void {
162
+ this.layers.delete(sessionID)
163
+ const staged = this.staged.get(sessionID)
164
+ if (!staged) return
165
+ this.dropStaged(sessionID, staged)
166
+ for (const waiter of staged.waiters) waiter.reject(new Error("the session ended before the profile took effect"))
167
+ }
168
+
169
+ // ---------- internals ----------
170
+
171
+ private async stage(req: ProfileApplyRequest): Promise<Step> {
172
+ const { sessionID } = req
173
+ const staged = this.staged.get(sessionID)
174
+ if (staged && sameVersion(staged.layer.result, req)) return { pending: this.wait(sessionID, staged) }
175
+ const current = this.layers.get(sessionID)
176
+ if (current && sameVersion(current.result, req)) {
177
+ this.supersede(sessionID, req)
178
+ return { result: current.result }
179
+ }
180
+ let local: ProfileLocalNames | undefined
181
+ if (this.options.localNames) {
182
+ try {
183
+ local = await this.options.localNames(sessionID)
184
+ } catch {
185
+ return { result: profileApplyResult(req, "rejected", ["could not read the session's local skills and commands (they take precedence); nothing applied"]) }
186
+ }
187
+ }
188
+ const bindings = this.options.tool() && local !== undefined
189
+ const plan = planProfileApplication(req, {
190
+ capabilities: { instructions: true, skills: bindings, commands: bindings, model: false, live: true, renderAtInvocation: false },
191
+ ...(local ? { local } : {}),
192
+ })
193
+ const result = profileApplyResult(req, plan.status, plan.notes)
194
+ if (plan.status === "unsupported") return { result }
195
+ const layer: Layer = { result, ...(plan.instructions !== undefined ? { instructions: plan.instructions } : {}), skills: plan.skills, commands: plan.commands }
196
+ this.supersede(sessionID, req)
197
+ if (!this.options.isBusy(sessionID)) {
198
+ this.setLayer(sessionID, layer)
199
+ return { result }
200
+ }
201
+ const next: Staged = { layer, waiters: new Set() }
202
+ this.staged.set(sessionID, next)
203
+ return { pending: this.wait(sessionID, next) }
204
+ }
205
+
206
+ /** Waits for the staged layer's boundary, at most boundaryWaitMs. */
207
+ private wait(sessionID: string, staged: Staged): Promise<ProfileApplyResult> {
208
+ const pending = new Promise<ProfileApplyResult>((resolve, reject) => {
209
+ const timer = setTimeout(() => {
210
+ staged.waiters.delete(waiter)
211
+ if (!staged.waiters.size) this.stopPoll(staged)
212
+ const { profileID, version } = staged.layer.result
213
+ reject(new Error(`the session is still busy; profile ${profileID} v${version} is staged and takes effect at its next turn boundary`))
214
+ }, this.options.boundaryWaitMs ?? 120_000)
215
+ unref(timer)
216
+ const waiter: Waiter = {
217
+ resolve: (result) => (clearTimeout(timer), resolve(result)),
218
+ reject: (cause) => (clearTimeout(timer), reject(cause)),
219
+ }
220
+ staged.waiters.add(waiter)
221
+ if (!staged.poll) {
222
+ staged.poll = setInterval(() => this.settle(sessionID), this.options.pollMs ?? 1000)
223
+ unref(staged.poll)
224
+ }
225
+ })
226
+ pending.catch(() => {}) // handled by handle(); the waiter may fail before it is awaited
227
+ return pending
228
+ }
229
+
230
+ /** A staged layer for another version is answered rejected: `req` replaces it before it took effect. */
231
+ private supersede(sessionID: string, req: ProfileApplyRequest): void {
232
+ const staged = this.staged.get(sessionID)
233
+ if (!staged) return
234
+ this.dropStaged(sessionID, staged)
235
+ const { profileID, version, digest } = staged.layer.result
236
+ const note = `superseded by profile ${req.profileID} v${req.version} before it took effect at the next turn boundary`
237
+ const result: ProfileApplyResult = { sessionID, profileID, version, digest, status: "rejected", notes: boundedNotes([note]) }
238
+ for (const waiter of staged.waiters) waiter.resolve(result)
239
+ }
240
+
241
+ private dropStaged(sessionID: string, staged: Staged): void {
242
+ if (this.staged.get(sessionID) === staged) this.staged.delete(sessionID)
243
+ this.stopPoll(staged)
244
+ }
245
+
246
+ private stopPoll(staged: Staged): void {
247
+ if (staged.poll) clearInterval(staged.poll)
248
+ staged.poll = undefined
249
+ }
250
+
251
+ private setLayer(sessionID: string, layer: Layer): void {
252
+ this.layers.delete(sessionID)
253
+ this.layers.set(sessionID, layer)
254
+ while (this.layers.size > (this.options.maxSessions ?? 1000)) this.layers.delete(this.layers.keys().next().value!)
255
+ }
256
+
257
+ /** Runs one session's staging steps one at a time (waiting for a boundary is outside). */
258
+ private serialize<T>(sessionID: string, run: () => Promise<T>): Promise<T> {
259
+ const previous = this.locks.get(sessionID) ?? Promise.resolve()
260
+ const next = previous.then(run)
261
+ const tail = next.then(
262
+ () => undefined,
263
+ () => undefined,
264
+ )
265
+ this.locks.set(sessionID, tail)
266
+ void tail.then(() => {
267
+ if (this.locks.get(sessionID) === tail) this.locks.delete(sessionID)
268
+ })
269
+ return next
270
+ }
271
+ }
272
+
273
+ /** The system part that lists the session's profile skills and commands (names, descriptions, parameters). */
274
+ function bindingsText(layer: Layer): string {
275
+ const { profileID, version } = layer.result
276
+ const lines = [
277
+ `Hub profile ${profileID} v${version} adds skills and commands to this session only, through the ${PROFILE_TOOL} tool. Local skills and commands of the same name take precedence and are not listed.`,
278
+ ]
279
+ if (layer.skills.length) {
280
+ lines.push("", `Profile skills (load one with ${PROFILE_TOOL} {"skill": name} before following it):`)
281
+ for (const skill of layer.skills) lines.push(`- ${skill.name}: ${oneLine(skill.description)}`)
282
+ }
283
+ if (layer.commands.length) {
284
+ lines.push("", `Profile commands (when the user asks for /name or the command by name, call ${PROFILE_TOOL} {"command": name, "params": {...}} and follow the text it returns):`)
285
+ for (const command of layer.commands) {
286
+ const parts = profileTemplateParts(command.template)
287
+ const params = typeof parts === "string" ? [] : [...new Set(parts.flatMap((p) => ("param" in p ? [p.param] : [])))]
288
+ lines.push(`- ${command.name}${params.length ? ` (params: ${params.join(", ")})` : ""}: ${oneLine(command.description)}`)
289
+ }
290
+ }
291
+ return lines.join("\n")
292
+ }
293
+
294
+ const oneLine = (text: string) => text.replace(/\s+/g, " ").trim()
295
+
296
+ function unref(timer: ReturnType<typeof setTimeout>): void {
297
+ ;(timer as { unref?: () => void }).unref?.()
298
+ }