@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
package/src/migrate.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  symlinkSync,
17
17
  } from "node:fs"
18
18
  import path from "node:path"
19
- import type { Env } from "./env"
19
+ import { hubEnv, type Env } from "./env"
20
20
 
21
21
  export const APP_DIR = "hub"
22
22
  /** Legacy directory names under ~/.config and ~/.local/state, most recent first. */
@@ -24,8 +24,81 @@ export const LEGACY_APP_DIRS: readonly string[] = Object.freeze(["opencode-hub",
24
24
 
25
25
  /** $XDG_CONFIG_HOME or ~/.config. */
26
26
  export const configBase = (env: Env, home: string): string => env.XDG_CONFIG_HOME || path.join(home, ".config")
27
- /** ~/.local/state. */
28
- export const stateBase = (home: string): string => path.join(home, ".local", "state")
27
+ /** $XDG_STATE_HOME when it is an absolute path (the XDG spec: a relative one is invalid and ignored), else undefined. */
28
+ export const xdgStateHome = (env: Env): string | undefined => (env.XDG_STATE_HOME && path.isAbsolute(env.XDG_STATE_HOME) ? env.XDG_STATE_HOME : undefined)
29
+
30
+ /** $XDG_STATE_HOME or ~/.local/state. The hub's own state dir is resolveHubStateDir (sticky), not this. */
31
+ export const stateBase = (env: Env, home: string): string => xdgStateHome(env) ?? path.join(home, ".local", "state")
32
+
33
+ export interface HubStateDir {
34
+ readonly dir: string
35
+ /** env: HUB_STATE_DIR; xdg: an existing $XDG_STATE_HOME/hub; legacy: an existing ~/.local/state/hub kept although
36
+ * XDG_STATE_HOME is set; fresh: a new $XDG_STATE_HOME/hub; default: ~/.local/state/hub without XDG_STATE_HOME. */
37
+ readonly source: "env" | "xdg" | "legacy" | "fresh" | "default"
38
+ /** legacy only: one line for the log at startup (XDG_STATE_HOME is set, the existing dir is used, how to switch). */
39
+ readonly hint?: string
40
+ /** `dir`, then the other default places (~/.local/state/hub, $XDG_STATE_HOME/hub) it is not: the Concierge lock is read from all. */
41
+ readonly candidates: readonly string[]
42
+ }
43
+
44
+ /**
45
+ * What the hub writes in its state dir (the hub server's state.json, token-keys.json, agent/ and blobs/; the plugin's
46
+ * launchers bin/ and Concierge lock; the local hub's hub.log / hub.pid; the Claude Code and Codex spokes' dirs). A
47
+ * dir holding none of them (empty, or only `hubctl setup`'s bootstrap/) does not count as an existing state dir.
48
+ */
49
+ export const HUB_STATE_MARKERS: readonly string[] = Object.freeze(["state.json", "token-keys.json", "agent", "blobs", "concierge-sessions.json", "bin", "hub.log", "hub.pid", "claude-code", "codex"])
50
+
51
+ /** Whether `dir` holds hub state (any of HUB_STATE_MARKERS). */
52
+ export const holdsHubState = (dir: string, exists: (p: string) => boolean = existsSync): boolean => HUB_STATE_MARKERS.some((marker) => exists(path.join(dir, marker)))
53
+
54
+ const shellQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
55
+
56
+ /**
57
+ * The hub state dir, "sticky" (FX-08): $HUB_STATE_DIR; else $XDG_STATE_HOME/hub when XDG_STATE_HOME is set and that
58
+ * dir holds hub state (HUB_STATE_MARKERS; `exists` is asked about each marker); else an existing ~/.local/state/hub (kept, never copied or moved; with a hint when XDG_STATE_HOME is
59
+ * set); else $XDG_STATE_HOME/hub when it is set (fresh installs); else ~/.local/state/hub. Every part of hub (plugin,
60
+ * local hub, hub server, the Claude Code and Codex spokes) uses this, so a shell and a service unit agree unless one
61
+ * sets HUB_STATE_DIR.
62
+ */
63
+ export function resolveHubStateDir(env: Env, home: string, exists: (p: string) => boolean = existsSync): HubStateDir {
64
+ const legacy = path.join(home, ".local", "state", APP_DIR)
65
+ const xdg = xdgStateHome(env)
66
+ const xdgHub = xdg ? path.join(xdg, APP_DIR) : undefined
67
+ const defaults = [legacy, ...(xdgHub && xdgHub !== legacy ? [xdgHub] : [])]
68
+ const result = (dir: string, source: HubStateDir["source"], hint?: string): HubStateDir => ({
69
+ dir,
70
+ source,
71
+ ...(hint ? { hint } : {}),
72
+ candidates: [dir, ...defaults.filter((d) => d !== dir)],
73
+ })
74
+ const explicit = hubEnv(env).HUB_STATE_DIR
75
+ if (explicit) return result(explicit, "env")
76
+ const holdsState = (dir: string) => holdsHubState(dir, exists)
77
+ if (xdgHub && holdsState(xdgHub)) return result(xdgHub, "xdg")
78
+ if (holdsState(legacy)) {
79
+ const hint =
80
+ xdgHub && xdgHub !== legacy
81
+ ? `XDG_STATE_HOME is set, but the existing ${legacy} is used as the hub state dir; to switch, stop the hub and OpenCode, then: mv ${shellQuote(legacy)} ${shellQuote(xdgHub)}`
82
+ : undefined
83
+ return result(legacy, "legacy", hint)
84
+ }
85
+ return xdgHub ? result(xdgHub, "fresh") : result(legacy, "default")
86
+ }
87
+
88
+ let hinted = false
89
+ /** Logs the resolver's hint once per process (at startup). */
90
+ export function hintStateDirOnce(resolved: HubStateDir, log: (line: string) => void = console.warn): void {
91
+ if (!resolved.hint || hinted) return
92
+ hinted = true
93
+ log(`[hub] ${resolved.hint}`)
94
+ }
95
+
96
+ /** The legacy (opencode-hub, opencode-voice) state dirs, most recent first: under $XDG_STATE_HOME, then ~/.local/state. */
97
+ export function stateLegacyPaths(env: Env, home: string): string[] {
98
+ const xdg = xdgStateHome(env)
99
+ const bases = [...(xdg ? [xdg] : []), path.join(home, ".local", "state")]
100
+ return [...new Set(bases.flatMap((base) => legacyPaths(base)))]
101
+ }
29
102
 
30
103
  export type MigrationResult = "migrated" | "none" | "failed"
31
104
 
@@ -103,3 +176,36 @@ export function migrateFirst(legacies: readonly string[], target: string, option
103
176
 
104
177
  /** The legacy copies of `<base>/<APP_DIR>/<...rest>`, most recent first. */
105
178
  export const legacyPaths = (base: string, ...rest: string[]): string[] => LEGACY_APP_DIRS.map((dir) => path.join(base, dir, ...rest))
179
+
180
+ /**
181
+ * migrateFirst for the hub state dir: a target that holds no hub state (missing, empty, or only `hubctl setup`'s
182
+ * bootstrap/) counts as missing. The newest existing legacy dir is copied through a temporary sibling, then each of
183
+ * its top-level entries is renamed into the target unless the target already has one by that name (bootstrap/ is
184
+ * never overwritten). Never throws; the legacy copies are never touched.
185
+ */
186
+ export function migrateStateDir(legacies: readonly string[], target: string, options: MigrateOptions = {}): MigrationResult {
187
+ if (!exists(target)) return migrateFirst(legacies, target, options)
188
+ if (holdsHubState(target, exists)) return "none"
189
+ const legacy = legacies.find(exists)
190
+ if (legacy === undefined) return "none"
191
+ const tmp = `${target}.migrating-${process.pid}-${Date.now()}`
192
+ let moved = 0
193
+ try {
194
+ const present = new Set(readdirSync(target))
195
+ copyEntry(legacy, tmp, [...(options.skip ?? []), ...present])
196
+ for (const name of readdirSync(tmp)) {
197
+ if (exists(path.join(target, name))) continue
198
+ renameSync(path.join(tmp, name), path.join(target, name))
199
+ moved++
200
+ }
201
+ rmSync(tmp, { recursive: true, force: true })
202
+ } catch (cause) {
203
+ rmSync(tmp, { recursive: true, force: true })
204
+ ;(options.log ?? ((m: string) => console.warn(`[hub] warning: ${m}`)))(`could not migrate ${legacy} to ${target}: ${cause instanceof Error ? cause.message : cause}`)
205
+ return "failed"
206
+ }
207
+ // Everything was there already (an earlier migration of a legacy dir without hub-state markers): nothing to say.
208
+ if (moved === 0) return "none"
209
+ ;(options.log ?? ((m: string) => console.log(`[hub] ${m}`)))(`migrated ${legacy} to ${target} (the project is now Hub; the old copy is kept)`)
210
+ return "migrated"
211
+ }
@@ -0,0 +1,33 @@
1
+ // CON-04 the OpenCode agent the Concierge session runs on (contract "Agent"): registered by the plugin like the voice
2
+ // agent (conversation.ts defineVoiceAgent), mode primary, hidden. Its rules are an ALLOW-LIST: deny everything
3
+ // ("*"), then allow exactly CONCIERGE_TOOLS (hub, read, glob, grep, question). OpenCode evaluates the last matching
4
+ // rule, so nothing else (subagent/task, execute, webfetch/websearch, shell, edit/write/patch, todo, skill,
5
+ // external_directory, MCP or plugin tools, future tools) is allowed, even with no hub context. The hub also strips
6
+ // every tool off that list from each turn (sessionContext tools.allow): defence in depth. The default agent's rules
7
+ // are not inherited. Its instructions come from the hub with every turn (sessionContext), not from here.
8
+ import { CONCIERGE_AGENT, CONCIERGE_TOOLS } from "../../packages/protocol/src/concierge.ts"
9
+ import type { AgentEditorLike } from "./conversation"
10
+
11
+ export const CONCIERGE_AGENT_NAME = "Concierge"
12
+ export const CONCIERGE_AGENT_DESCRIPTION = "Hub Concierge: watches your sessions and acts only inside your grant. Hub-managed; not for direct use."
13
+ const CONCIERGE_SYSTEM = "You are the hub's Concierge. Your instructions, situation board and notes arrive from the hub with every turn."
14
+
15
+ export const CONCIERGE_RULES: ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" }> = [
16
+ { action: "*", resource: "*", effect: "deny" },
17
+ ...CONCIERGE_TOOLS.map((action) => ({ action, resource: "*", effect: "allow" as const })),
18
+ ]
19
+
20
+ /** Adds (or refreshes) the hub's "concierge" agent; a "concierge" agent the user defined is left alone. */
21
+ export function defineConciergeAgent(editor: AgentEditorLike): void {
22
+ // The description marks the hub's own agent, which is refreshed; a user's own "concierge" agent is left alone.
23
+ const existing = editor.get(CONCIERGE_AGENT)
24
+ if (existing && existing.description !== CONCIERGE_AGENT_DESCRIPTION) return
25
+ editor.update(CONCIERGE_AGENT, (agent) => {
26
+ agent.name = CONCIERGE_AGENT_NAME
27
+ agent.description = CONCIERGE_AGENT_DESCRIPTION
28
+ agent.mode = "primary"
29
+ agent.hidden = true
30
+ agent.system = CONCIERGE_SYSTEM
31
+ agent.permissions = CONCIERGE_RULES.map((r) => ({ ...r }))
32
+ })
33
+ }
@@ -34,13 +34,17 @@ export interface RecipientState {
34
34
  * (quirk hzmcqwpj), a steered one joins them in one call. With a form open OpenCode holds the message until the
35
35
  * form is answered ("retained").
36
36
  *
37
- * `wake:false` and subagent sessions are admitted without starting a turn ("queued"). They default to steer: a
38
- * queued item gets its own model call when a turn ends (that would wake the session), while a steered one
39
- * without resume waits for the next turn and arrives before its prompt (or joins a turn already running).
37
+ * `wake:false` and subagent sessions are admitted without starting a turn ("queued"), always steered (also over
38
+ * the hub's "queue", RF-05): a queued item gets its own model call when a turn ends, resume or not (that would wake
39
+ * the session), while a steered one without resume waits for the next turn and arrives before its prompt. A
40
+ * steered one admitted into a running turn costs a model call when the turn would have ended, so routine ones are
41
+ * refused while the session is busy (deferredWhileBusy).
40
42
  */
41
43
  export function planDelivery(req: { readonly wake: boolean; readonly meta: DeliverMeta }, state: RecipientState): DeliveryPlan {
42
44
  const resume = req.wake && !state.subagent
43
- const chosen = req.meta.delivery ?? (!resume || req.meta.kind === "ask" ? "steer" : "queue")
45
+ // Not waking: always steer. OpenCode gives a queued item its own model call once a turn ends (resume or not),
46
+ // so a hub-chosen "queue" would wake the session after all; a steered one waits for the next turn.
47
+ const chosen = !resume ? "steer" : (req.meta.delivery ?? (req.meta.kind === "ask" ? "steer" : "queue"))
44
48
  // Idle: no running turn to queue behind; steered, the wake shares its model call with what already waits.
45
49
  const delivery = resume && !state.busy && !state.formOpen ? "steer" : chosen
46
50
  return { delivery, resume, delivered: deliveredAs(delivery, resume, state) }
@@ -71,5 +75,19 @@ export function deliverMetadata(req: { readonly messageID: string; readonly meta
71
75
  return { source: PROMPT_SOURCE, messageID: req.messageID, kind, from, to, ...(replyTo !== undefined ? { replyTo } : {}), ...(grant !== undefined ? { grant } : {}) }
72
76
  }
73
77
 
78
+ /** The refusal of a routine quiet message to a busy session (the hub keeps it pending and retries). */
79
+ export const QUIET_WHILE_BUSY = "The session is busy: a message that does not wake it is admitted after the running turn"
80
+
81
+ /**
82
+ * Whether a message waits for the running turn to end (refused while busy, RF-05): a routine `wake:false` msg (not
83
+ * urgent: the hub sends routine msgs with delivery "queue") to a top-level session. OpenCode adds anything admitted
84
+ * into a running turn with a model call of its own when the turn would have ended. Urgent messages, asks, replies,
85
+ * primers and anything for a subagent (never woken; it may not be idle before its parent needs the answer) go in at
86
+ * once and accept that call.
87
+ */
88
+ export function deferredWhileBusy(req: { readonly wake: boolean; readonly meta: DeliverMeta }, subagent: boolean): boolean {
89
+ return !req.wake && !subagent && req.meta.kind === "msg" && req.meta.delivery !== "steer"
90
+ }
91
+
74
92
  // The prompt envelope, InboxOrigins and DeliveryLedger live in the spoke SDK (harness-neutral).
75
93
  export { deliverText, DeliveryLedger, InboxOrigins, renderPromptEnvelope, type Admission } from "../../packages/spoke-sdk/src/delivery.ts"
@@ -14,6 +14,7 @@
14
14
  // they get only the stripped env and PATH, no token.
15
15
  import { homedir } from "node:os"
16
16
  import path from "node:path"
17
+ import { hubEnv } from "../env"
17
18
  import { defaultStateDir } from "./localhub"
18
19
 
19
20
  type Env = Record<string, string | undefined>
@@ -124,11 +125,17 @@ export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: st
124
125
  env.PATH = [binDir, ...rest].join(path.delimiter)
125
126
  }
126
127
 
127
- /** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR or ~/.local/state/hub). */
128
+ /** Where the `hub`/`hubctl` launchers live: bin under the hub state dir ($HUB_STATE_DIR, else $XDG_STATE_HOME/hub, else ~/.local/state/hub). */
128
129
  export function hubBinDir(env: Env = process.env, home: string = homedir()): string {
129
130
  return path.join(defaultStateDir(env, home), "bin")
130
131
  }
131
132
 
133
+ /** Where ~/.local/bin/hub may point: the default (resolved) hub bin dir; none with HUB_STATE_DIR (a custom state). */
134
+ export function defaultHubBinDir(env: Env = process.env, home: string = homedir()): string | undefined {
135
+ if (hubEnv(env).HUB_STATE_DIR) return undefined
136
+ return path.join(defaultStateDir(env, home), "bin")
137
+ }
138
+
132
139
  /** The hook handlers: tool execute.before/after and shell create.before. They never throw. */
133
140
  export function envInjector(options: { correlator: ShellCorrelator; binDir: string; identity: (sessionID: string) => Identity | undefined }) {
134
141
  const { correlator, binDir, identity } = options
@@ -0,0 +1,142 @@
1
+ // Fork and revert (features "fork" and "revert"): the pure parts of the spoke's fork, revertPreview, revert,
2
+ // revertRestore and revertCommit ops. OpenCode semantics (checked against `opencode serve` 2.2.3, see
3
+ // test/spoke-fork-revert.it.test.ts):
4
+ // - session.fork({sessionID, before}) copies the conversation BEFORE `before` into a new root session (allowed
5
+ // while the source runs).
6
+ // - session.revert.stage({sessionID, messageID, files: true}) marks the session reverted from that message and
7
+ // restores the files the session changed since then; OpenCode refuses it while the session runs
8
+ // (SessionBusyError). The messages stay in the session's history (message list and context): the CLIENT hides
9
+ // them, from the reverted message on. The session's `revert` field and session.revert.staged carry it.
10
+ // - session.revert.clear brings the files back (session.revert.cleared); session.revert.commit removes the hidden
11
+ // messages (session.revert.committed). The next prompt commits a staged revert by itself.
12
+ import { REVERT_FILE_MAX_CHARS, REVERT_FILES_MAX, type MessageAttachment, type RevertFile, type SessionRevertInfo } from "../protocol"
13
+
14
+ const record = (value: unknown): Record<string, any> => (value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, any>) : {})
15
+ const STATUS = new Set(["added", "deleted", "modified"])
16
+ const count = (value: unknown): number | undefined => (Number.isSafeInteger(value) && (value as number) >= 0 ? (value as number) : undefined)
17
+
18
+ export const NOT_A_USER_MESSAGE = "Not a user message of this session"
19
+ export const REVERT_BUSY = "The session is working; wait for it to finish (or stop it) before reverting"
20
+
21
+ /** Epoch ms of an OpenCode timestamp (number or ISO string). */
22
+ function millis(value: unknown): number | undefined {
23
+ if (typeof value === "number") return Number.isFinite(value) ? value : undefined
24
+ if (typeof value !== "string") return undefined
25
+ const ms = Date.parse(value)
26
+ return Number.isNaN(ms) ? undefined : ms
27
+ }
28
+
29
+ /** The index of the user message `messageID` in the session's messages (oldest first), or -1. */
30
+ export function userIndex(messages: readonly unknown[], messageID: string): number {
31
+ return messages.findIndex((raw) => {
32
+ const m = record(raw)
33
+ return m.id === messageID && m.type === "user"
34
+ })
35
+ }
36
+
37
+ /** The text and attachments (metadata, never bytes) of a user message, for the composer. */
38
+ export function userContent(message: unknown): { text: string; attachments: MessageAttachment[] } {
39
+ const m = record(message)
40
+ const text =
41
+ typeof m.text === "string"
42
+ ? m.text
43
+ : Array.isArray(m.content)
44
+ ? m.content.filter((p: any) => p?.type === "text" && typeof p.text === "string").map((p: any) => p.text as string).join("")
45
+ : ""
46
+ const attachments: MessageAttachment[] = []
47
+ if (Array.isArray(m.files)) {
48
+ m.files.forEach((raw: unknown, index: number) => {
49
+ const f = record(raw)
50
+ if (typeof f.mime !== "string") return
51
+ attachments.push({ index, mime: f.mime, ...(typeof f.name === "string" && f.name ? { name: f.name.slice(0, 200) } : {}) })
52
+ })
53
+ }
54
+ return { text, attachments }
55
+ }
56
+
57
+ /** User messages from `index` on (the reverted one and those after it). */
58
+ function usersFrom(messages: readonly unknown[], index: number): number {
59
+ let n = 0
60
+ for (let i = Math.max(0, index); i < messages.length; i++) if (record(messages[i]).type === "user") n++
61
+ return n
62
+ }
63
+
64
+ /** At most REVERT_FILES_MAX files, unique by path, with the total. */
65
+ function bounded(files: readonly RevertFile[]): { files: RevertFile[]; filesTotal: number } {
66
+ const seen = new Set<string>()
67
+ const unique = files.filter((f) => {
68
+ if (!f.file || f.file.length > REVERT_FILE_MAX_CHARS || seen.has(f.file)) return false
69
+ seen.add(f.file)
70
+ return true
71
+ })
72
+ return { files: unique.slice(0, REVERT_FILES_MAX), filesTotal: unique.length }
73
+ }
74
+
75
+ /** OpenCode's FileDiffInfo list as revert files (no patches). */
76
+ function diffFiles(raw: unknown): RevertFile[] {
77
+ if (!Array.isArray(raw)) return []
78
+ return raw.flatMap((entry) => {
79
+ const f = record(entry)
80
+ if (typeof f.file !== "string" || !f.file) return []
81
+ const additions = count(f.additions)
82
+ const deletions = count(f.deletions)
83
+ return [
84
+ {
85
+ file: f.file,
86
+ ...(STATUS.has(f.status) ? { status: f.status as RevertFile["status"] } : {}),
87
+ ...(additions === undefined ? {} : { additions }),
88
+ ...(deletions === undefined ? {} : { deletions }),
89
+ },
90
+ ]
91
+ })
92
+ }
93
+
94
+ /**
95
+ * What reverting from the user message at `index` would hide, and the files the session's assistant messages
96
+ * changed from there on (their snapshots' file lists): what a revert with files restores.
97
+ */
98
+ export function revertPreview(messages: readonly unknown[], index: number): { hidden: number; files: RevertFile[]; filesTotal: number } {
99
+ const files: RevertFile[] = []
100
+ for (let i = Math.max(0, index); i < messages.length; i++) {
101
+ const m = record(messages[i])
102
+ if (m.type !== "assistant") continue
103
+ for (const file of Array.isArray(m.snapshot?.files) ? m.snapshot.files : []) if (typeof file === "string" && file) files.push({ file })
104
+ }
105
+ return { hidden: usersFrom(messages, index), ...bounded(files) }
106
+ }
107
+
108
+ /**
109
+ * The summary of OpenCode's staged revert (session.revert, or session.revert.staged's data.revert) over the
110
+ * session's messages; undefined when nothing is staged.
111
+ */
112
+ export function revertSummary(raw: unknown, messages: readonly unknown[]): SessionRevertInfo | undefined {
113
+ const revert = record(raw)
114
+ if (typeof revert.messageID !== "string" || !revert.messageID || revert.messageID.length > 300) return undefined
115
+ const index = messages.findIndex((m) => record(m).id === revert.messageID)
116
+ const at = index < 0 ? undefined : millis(record(messages[index]).time?.created)
117
+ return {
118
+ messageID: revert.messageID,
119
+ hidden: index < 0 ? 0 : usersFrom(messages, index),
120
+ ...(at === undefined ? {} : { at }),
121
+ ...bounded(diffFiles(revert.files)),
122
+ }
123
+ }
124
+
125
+ /** The messages a client shows while a revert is staged from `messageID`: those before it (all when unknown). */
126
+ export function visibleMessages<T>(messages: readonly T[], messageID: string | undefined): readonly T[] {
127
+ if (!messageID) return messages
128
+ const index = messages.findIndex((m) => record(m).id === messageID)
129
+ return index < 0 ? messages : messages.slice(0, index)
130
+ }
131
+
132
+ /** Whether an OpenCode error says the session is busy (a revert refused while it runs). */
133
+ export function busyError(cause: unknown): boolean {
134
+ const c = record(cause)
135
+ const text = `${c._tag ?? ""} ${c.name ?? ""} ${cause instanceof Error ? cause.message : String(cause ?? "")}`
136
+ return /SessionBusy|Session is busy/i.test(text)
137
+ }
138
+
139
+ /** Same revert summaries (so an unchanged one sends no snapshot). */
140
+ export function sameRevert(a: SessionRevertInfo | undefined, b: SessionRevertInfo | undefined): boolean {
141
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
142
+ }
@@ -4,6 +4,7 @@
4
4
  // result or an error (redacted should the hub echo it). Labels are sanitised here, so the TUI shows them as is.
5
5
  import { HubClient, HubError, HubTransportError, sanitizeLabel, type AgentMessage, type DirectoryEntry } from "../../packages/sdk/src/index.ts"
6
6
  import type { HubToolSpoke } from "./hub-tool"
7
+ import type { WhoReach, WhoTeamGroup, WhoTeamRef } from "./who-teams"
7
8
 
8
9
  /** The RPC error a failure answers with (src/rpc.ts lists them). */
9
10
  export type HubRpcErrorCode = "invalid" | "not_connected" | "unsupported" | "unreachable" | "server_error"
@@ -25,6 +26,19 @@ export interface WhoRow {
25
26
  readonly state?: string
26
27
  readonly online: boolean
27
28
  readonly roles: string[]
29
+ /** HWA-40 (additive): where it runs, its teams (roles there, the caller's reach) and whether it is the caller. */
30
+ readonly machineID?: string
31
+ readonly harness?: string
32
+ readonly teams?: WhoTeamRef[]
33
+ readonly reach?: WhoReach
34
+ readonly self?: true
35
+ }
36
+
37
+ /** /who's answer: the rows, and the team groups when the hub sent them (HWA-40). */
38
+ export interface WhoResult {
39
+ readonly sessions: WhoRow[]
40
+ readonly teams?: WhoTeamGroup[]
41
+ readonly scope?: "team"
28
42
  }
29
43
 
30
44
  export interface MsgResult {
@@ -57,16 +71,23 @@ export async function hubMsg(spoke: HubToolSpoke, value: unknown): Promise<MsgRe
57
71
  })
58
72
  }
59
73
 
60
- /** /who: the named and role-holding sessions (optionally one project or one role), as session `sessionID`. */
61
- export async function hubWho(spoke: HubToolSpoke, value: unknown): Promise<{ sessions: WhoRow[] }> {
74
+ /**
75
+ * /who: the named and role-holding sessions (optionally one project, role or team), as session `sessionID`. A bare
76
+ * `project` that names one of the caller's teams (listed in a team member's answer) is taken as that team (/who <team>).
77
+ */
78
+ export async function hubWho(spoke: HubToolSpoke, value: unknown): Promise<WhoResult> {
62
79
  const input = inputOf(value)
63
80
  const sessionID = optStr(input, "sessionID")
64
81
  if (!sessionID) throw new HubRpcError("invalid", "Open a session first: /who asks the hub as the current session.")
65
82
  const project = optStr(input, "project")
66
83
  const role = optStr(input, "role")
84
+ const team = optStr(input, "team")
67
85
  return withClient(spoke, sessionID, undefined, async (client, signal) => {
68
- const { sessions } = await client.who({ ...(project ? { project } : {}), ...(role ? { role } : {}), signal })
69
- return { sessions: sessions.map(whoRow) }
86
+ let answer = await client.who({ ...(project ? { project } : {}), ...(role ? { role } : {}), ...(team ? { team } : {}), signal })
87
+ // A team member's answer lists all its teams; a bare word naming one of them is /who <team>.
88
+ if (project && !role && !team && answer.teams?.some((t) => t.name === project)) answer = await client.who({ team: project, signal })
89
+ const teams = (answer.teams ?? []).map((t) => ({ name: sanitizeLabel(t.name), mode: t.mode, ...(t.paused ? { paused: true } : {}), ...(t.yourRoles?.length ? { yourRoles: t.yourRoles.map((r) => sanitizeLabel(r)) } : {}), members: t.members.map((m) => ({ sessionID: sanitizeLabel(m.sessionID), role: sanitizeLabel(m.role) })) }))
90
+ return { sessions: answer.sessions.map((e) => whoRow(e, sessionID)), ...(teams.length ? { teams } : {}), ...(answer.scope === "team" ? { scope: "team" as const } : {}) }
70
91
  })
71
92
  }
72
93
 
@@ -93,10 +114,24 @@ async function withClient<T>(
93
114
  }
94
115
  }
95
116
 
96
- function whoRow(entry: DirectoryEntry): WhoRow {
117
+ const reachRow = (r: DirectoryEntry["reach"]): WhoReach | undefined => (r ? { send: [...r.send], receive: [...r.receive], direction: r.direction, ...(r.unreachable ? { unreachable: true } : {}) } : undefined)
118
+
119
+ function whoRow(entry: DirectoryEntry, caller?: string): WhoRow {
97
120
  const name = entry.name ? sanitizeLabel(entry.name) : ""
98
121
  const project = entry.project ? sanitizeLabel(entry.project) : ""
99
122
  const state = entry.state ? sanitizeLabel(entry.state) : ""
123
+ const teams = (entry.teams ?? []).map((t) => {
124
+ const reach = reachRow(t.reach)
125
+ return { team: sanitizeLabel(t.team), roles: t.roles.map((r) => sanitizeLabel(r)), ...(reach ? { reach } : {}) }
126
+ })
127
+ const reach = reachRow(entry.reach)
128
+ const extra = {
129
+ ...(entry.machineID ? { machineID: sanitizeLabel(entry.machineID) } : {}),
130
+ ...(entry.harness ? { harness: sanitizeLabel(entry.harness) } : {}),
131
+ ...(teams.length ? { teams } : {}),
132
+ ...(reach ? { reach } : {}),
133
+ ...(caller !== undefined && entry.sessionID === caller && teams.length ? { self: true as const } : {}),
134
+ }
100
135
  return {
101
136
  sessionID: sanitizeLabel(entry.sessionID),
102
137
  ...(name ? { name } : {}),
@@ -104,6 +139,7 @@ function whoRow(entry: DirectoryEntry): WhoRow {
104
139
  ...(state ? { state } : {}),
105
140
  online: entry.online === true,
106
141
  roles: (entry.roles ?? []).map((r) => sanitizeLabel(r.role)).filter(Boolean),
142
+ ...extra,
107
143
  }
108
144
  }
109
145