@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
@@ -0,0 +1,201 @@
1
+ // Coordination workspaces (HWA-02 contract; implemented by HWA-03..13). A "hub workspace" is a durable, owner-
2
+ // tenanted coordination record, distinct from a launch's LaunchWorkspace (a repo/worktree on one machine) and from
3
+ // any directory or Herdr key. It outlives sessions and launches: members, a replaceable master, supervision links,
4
+ // checkpoints and side chats are separate records that reference sessions by ID.
5
+ //
6
+ // Invariants frozen here:
7
+ // - Tenancy: one owner (the hub user). No implicit human sharing; membership grants nothing (grants stay explicit).
8
+ // - Supervision is a separate relationship, never a rewrite of native parentID/rootID.
9
+ // - The master is an ordinary public-API session; replacing it is an explicit mutation (HWA-12).
10
+ // - Events about these records are compact and text-free (IDs, kinds, revisions, counts only).
11
+ import { DESCRIPTION_MAX, isContractID, isExpectedRevision, isLabel, isText, metadataProblem, tagsProblem, type RecordState, type Revisioned } from "./contracts.ts"
12
+ import { isObj } from "./wire.ts"
13
+
14
+ export const WORKSPACE_LIMITS = {
15
+ /** Active (non-archived) workspaces per user. */
16
+ workspacesPerUser: 200,
17
+ /** Members (sessions) per workspace, current and departed generations together. */
18
+ membersPerWorkspace: 200,
19
+ /** Host repository bindings per workspace. */
20
+ bindingsPerWorkspace: 16,
21
+ /** Supervision links per workspace and the maximum supervision depth (master = depth 0). */
22
+ supervisionLinksPerWorkspace: 400,
23
+ supervisionDepth: 6,
24
+ /** Retained checkpoints per supervisor (oldest dropped). */
25
+ checkpointsPerSupervisor: 50,
26
+ /** Side chats per workspace and context items per side chat snapshot. */
27
+ sideChatsPerWorkspace: 100,
28
+ sideChatContextItems: 20,
29
+ /** Bytes of quoted text a side-chat context snapshot may carry, total. */
30
+ sideChatContextBytes: 64 * 1024,
31
+ /** Panes in one split layout. */
32
+ panes: 4,
33
+ } as const
34
+
35
+ /** Scopes for app tokens (registered by HWA-06 together with the routes; existing tokens gain nothing). */
36
+ export const WORKSPACE_SCOPES = ["workspaces:read", "workspaces:manage"] as const
37
+
38
+ export type WorkspaceRole = "master" | "worker" | "observer"
39
+ export const WORKSPACE_ROLES: readonly WorkspaceRole[] = ["master", "worker", "observer"]
40
+
41
+ export interface HubWorkspace extends Revisioned {
42
+ readonly v: 1
43
+ readonly id: string
44
+ readonly title: string
45
+ readonly description?: string
46
+ readonly state: RecordState
47
+ readonly tags?: readonly string[]
48
+ readonly metadata?: Readonly<Record<string, string>>
49
+ /** The current master membership (absent: no master; the workspace still exists). */
50
+ readonly masterMemberID?: string
51
+ readonly createdAt: number
52
+ readonly updatedAt: number
53
+ }
54
+
55
+ /** Binds a workspace to a repository on a machine (explicit; logical IDs never derive from paths). */
56
+ export interface HostRepoBinding extends Revisioned {
57
+ readonly v: 1
58
+ readonly id: string
59
+ readonly workspaceID: string
60
+ readonly machineID: string
61
+ /** Absolute normal repo path on that machine (same checks as LaunchWorkspace.repo). */
62
+ readonly repo: string
63
+ readonly label?: string
64
+ }
65
+
66
+ /** A session's membership. A replaced session keeps its record (`leftAt`); a re-join is a new generation. */
67
+ export interface WorkspaceMember extends Revisioned {
68
+ readonly v: 1
69
+ readonly id: string
70
+ readonly workspaceID: string
71
+ readonly sessionID: string
72
+ readonly role: WorkspaceRole
73
+ /** Increments each time the same logical slot is re-staffed with a new session (master replacement, restart). */
74
+ readonly generation: number
75
+ readonly joinedAt: number
76
+ readonly leftAt?: number
77
+ readonly leftReason?: "removed" | "replaced" | "ended" | "archived"
78
+ }
79
+
80
+ /** A versioned unit of work given to a member (status only; instructions travel as messages). */
81
+ export interface WorkspaceAssignment extends Revisioned {
82
+ readonly v: 1
83
+ readonly id: string
84
+ readonly workspaceID: string
85
+ readonly memberID: string
86
+ readonly title: string
87
+ readonly status: "open" | "in_progress" | "blocked" | "done" | "cancelled"
88
+ /** External reference (e.g. a Brain task ID, a team intake ID); opaque to the hub. */
89
+ readonly ref?: string
90
+ readonly updatedAt: number
91
+ }
92
+
93
+ /** Supervisor → supervised member. Acyclic within a workspace; depth ≤ supervisionDepth. */
94
+ export interface SupervisionLink extends Revisioned {
95
+ readonly v: 1
96
+ readonly id: string
97
+ readonly workspaceID: string
98
+ readonly supervisorMemberID: string
99
+ readonly memberID: string
100
+ /** Sibling order for the dashboard tree (drag reorder); dense integers are not required. */
101
+ readonly order: number
102
+ }
103
+
104
+ /**
105
+ * A supervisor's durable acknowledgment of the events it has processed (HWA-11). `cursor` is the agent-events
106
+ * position; delivery of batched updates resumes after it. Written through (not debounced) in standalone mode.
107
+ */
108
+ export interface SupervisorCheckpoint {
109
+ readonly v: 1
110
+ readonly id: string
111
+ readonly workspaceID: string
112
+ readonly supervisorMemberID: string
113
+ readonly cursor: string
114
+ readonly at: number
115
+ }
116
+
117
+ /**
118
+ * A side chat: a new session started with an explicit, bounded, authorized snapshot of source context. The
119
+ * snapshot is copied at creation (never live access) and only from sources the creator could read.
120
+ */
121
+ export interface SideChat extends Revisioned {
122
+ readonly v: 1
123
+ readonly id: string
124
+ readonly workspaceID: string
125
+ readonly sessionID: string
126
+ readonly sourceSessionID: string
127
+ /** Message IDs / artifact revision IDs the snapshot was taken from (text lives in the snapshot, not events). */
128
+ readonly sources: readonly { readonly kind: "message" | "artifact_revision"; readonly id: string }[]
129
+ readonly createdAt: number
130
+ }
131
+
132
+ /** Pane layout persisted per device (HWA-07/08); a pane names a session or a side chat. */
133
+ export interface PaneLayout {
134
+ readonly v: 1
135
+ readonly split: "single" | "vertical" | "horizontal" | "grid"
136
+ readonly panes: readonly { readonly sessionID: string; readonly sideChatID?: string }[]
137
+ readonly focused: number
138
+ }
139
+
140
+ /** Compact, text-free event types (family "manage"); data carries only IDs, kinds, revisions and counts. */
141
+ export const WORKSPACE_EVENT_TYPES = [
142
+ "workspace.created",
143
+ "workspace.updated",
144
+ "workspace.archived",
145
+ "workspace.member.joined",
146
+ "workspace.member.left",
147
+ "workspace.master.replaced",
148
+ "workspace.supervision.changed",
149
+ "workspace.assignment.changed",
150
+ "workspace.sidechat.created",
151
+ ] as const
152
+ export type WorkspaceEventType = (typeof WORKSPACE_EVENT_TYPES)[number]
153
+
154
+ /** Why a create-workspace body is not acceptable, or undefined. */
155
+ export function workspaceInputProblem(v: unknown): string | undefined {
156
+ if (!isObj(v)) return "body must be an object"
157
+ if (!isLabel(v.title)) return "title must be 1–120 characters of text"
158
+ if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
159
+ return tagsProblem(v.tags) ?? metadataProblem(v.metadata)
160
+ }
161
+
162
+ /** Why a supervision mutation is not acceptable (shape only; cycle/depth checks need the graph, see wouldCycle). */
163
+ export function supervisionInputProblem(v: unknown): string | undefined {
164
+ if (!isObj(v)) return "body must be an object"
165
+ if (!isContractID("member", v.memberID)) return "memberID must be a workspace member ID"
166
+ if (v.supervisorMemberID !== null && !isContractID("member", v.supervisorMemberID)) return "supervisorMemberID must be a member ID or null (detach)"
167
+ if (v.supervisorMemberID === v.memberID) return "a member cannot supervise itself"
168
+ if (!isExpectedRevision(v.expectedRevision)) return "expectedRevision must be the workspace revision you read"
169
+ return undefined
170
+ }
171
+
172
+ /**
173
+ * True when making `supervisor` supervise `member` would create a cycle or exceed `maxDepth`, given the current
174
+ * links (member → supervisor). The graph is small (≤ supervisionLinksPerWorkspace), so a walk is fine.
175
+ */
176
+ export function supervisionViolation(
177
+ links: ReadonlyMap<string, string>,
178
+ member: string,
179
+ supervisor: string,
180
+ maxDepth: number = WORKSPACE_LIMITS.supervisionDepth,
181
+ ): "cycle" | "depth" | undefined {
182
+ const next = new Map(links)
183
+ next.set(member, supervisor)
184
+ const depthOf = (start: string): number | "cycle" => {
185
+ const seen = new Set<string>()
186
+ let depth = 0
187
+ for (let at: string | undefined = start; at !== undefined; at = next.get(at)) {
188
+ if (seen.has(at)) return "cycle"
189
+ seen.add(at)
190
+ if (next.has(at)) depth++
191
+ }
192
+ return depth
193
+ }
194
+ let deepest = 0
195
+ for (const node of next.keys()) {
196
+ const d = depthOf(node)
197
+ if (d === "cycle") return "cycle"
198
+ deepest = Math.max(deepest, d)
199
+ }
200
+ return deepest > maxDepth ? "depth" : undefined
201
+ }
@@ -12,16 +12,27 @@
12
12
  // Modules: harness-events.ts (the forwarded events a non-OpenCode spoke may send, with their shapes), launch.ts
13
13
  // (launch vocabulary and workspace checks), node.ts (the `hub node` hello, frames and ops on the same socket),
14
14
  // preset.ts (the harness preset schema), webhook.ts (webhook signature format), wire.ts (shared frame primitives).
15
+ // Workspaces/artifacts/teams contracts (HWA-02, docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md):
16
+ // contracts.ts (shared IDs, limits, revisions), hub-workspace.ts, profiles.ts, artifacts.ts, teams.ts, graph.ts.
15
17
  // All are re-exported here.
16
18
 
17
19
  import { featureList, HUB_URL_MAX_CHARS, ID, isByeFrame as isBye, isHubURL, isObj, isResFrame as isRes, isWelcomeFrame, MAX_FRAME, MAX_RETRY_AFTER_MS, nonEmpty, num, parseFrame as parse, str, token, UNSAFE_CHARS } from "./wire.ts"
18
20
  import type { NodeMessage, NodeServerMessage } from "./node.ts"
21
+ import { PROFILE_APPLY_FEATURE, profileApplyRequestProblem, type ProfileApplyRequest, type ProfileApplyResult } from "./profiles.ts"
22
+ import { SESSION_CONTEXT_FEATURE, sessionContextProblem, type SessionContextRequest, type SessionContextResult } from "./concierge.ts"
19
23
 
20
24
  export * from "./harness-events.ts"
21
25
  export * from "./launch.ts"
22
26
  export * from "./node.ts"
23
27
  export * from "./preset.ts"
24
28
  export * from "./webhook.ts"
29
+ export * from "./contracts.ts"
30
+ export * from "./hub-workspace.ts"
31
+ export * from "./concierge.ts"
32
+ export * from "./profiles.ts"
33
+ export * from "./artifacts.ts"
34
+ export * from "./teams.ts"
35
+ export * from "./graph.ts"
25
36
  export { HUB_URL_MAX_CHARS, isHubURL, MAX_FRAME, MAX_RETRY_AFTER_MS }
26
37
 
27
38
  export const PROTOCOL_VERSION = 1
@@ -137,6 +148,57 @@ export interface SessionInfo {
137
148
  /** Open in a terminal right now (a Herdr pane or an OpenCode TUI showing it). */
138
149
  readonly open?: boolean
139
150
  readonly terminal?: TerminalInfo
151
+ /**
152
+ * A staged revert (feature "revert"): the session's history from `messageID` on is hidden and the files it changed
153
+ * since then are restored until the revert is restored (cleared) or committed (the next prompt commits it).
154
+ * Older hubs ignore it.
155
+ */
156
+ readonly revert?: SessionRevertInfo
157
+ /**
158
+ * (CON-04, feature "sessionContext") Sessions the hub gave a locked context (the Concierge): the agent the spoke
159
+ * last saw the session run on, and the context revision it holds now (absent: none, e.g. after a restart). The hub
160
+ * re-sends a context the spoke does not hold and suspends a Concierge seen on another agent.
161
+ */
162
+ readonly agent?: string
163
+ readonly contextRevision?: number
164
+ /**
165
+ * (FX-07) The session is locked as a Concierge session on this spoke (sent from the first sessions frame after a
166
+ * restart, before `agent` or `contextRevision` are known). The hub treats it as a Concierge session.
167
+ */
168
+ readonly locked?: true
169
+ }
170
+
171
+ /** Most files a revert summary or preview lists (`filesTotal` counts them all). */
172
+ export const REVERT_FILES_MAX = 50
173
+ /** Longest file path a revert summary carries. */
174
+ export const REVERT_FILE_MAX_CHARS = 1000
175
+
176
+ /** A file a revert restores (or would restore): its path in the session's directory and, when known, how it changes. */
177
+ export interface RevertFile {
178
+ readonly file: string
179
+ readonly status?: "added" | "deleted" | "modified"
180
+ readonly additions?: number
181
+ readonly deletions?: number
182
+ }
183
+
184
+ /** A staged revert as the spoke reports it (SessionInfo.revert, the revert op's result). */
185
+ export interface SessionRevertInfo {
186
+ /** The reverted user message (it and everything after it are hidden). */
187
+ readonly messageID: string
188
+ /** User messages hidden: the reverted one and those after it. */
189
+ readonly hidden: number
190
+ /** When the reverted message was created (epoch ms), when known. */
191
+ readonly at?: number
192
+ /** Restored files (at most REVERT_FILES_MAX). */
193
+ readonly files: readonly RevertFile[]
194
+ readonly filesTotal: number
195
+ }
196
+
197
+ /** An attachment of a user message, by its position in the message's files (fetched through timelineFile). */
198
+ export interface MessageAttachment {
199
+ readonly index: number
200
+ readonly mime: string
201
+ readonly name?: string
140
202
  }
141
203
 
142
204
  export interface TerminalInfo {
@@ -355,8 +417,12 @@ export const IMPLIED_OPENCODE_FEATURES = [
355
417
  * environment, for its own act verbs only (the OpenCode hub tool).
356
418
  * "launch": its create op takes a launch's `launchID`, `agent` and `model` (see isLaunchCreate); a launch create's
357
419
  * directory may be a new workspace under the machine's node workspace roots rather than one the spoke reported.
420
+ * "fork": it forks a session before one of its user messages (fork op). "revert": it stages, restores and commits
421
+ * a revert of a session from one of its user messages (revertPreview, revert, revertRestore, revertCommit ops) and
422
+ * reports a staged one as SessionInfo.revert. Announced only when the spoke's harness can (OpenCode: a process that
423
+ * reaches its own server).
358
424
  */
359
- const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch"] as const
425
+ const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert"] as const
360
426
 
361
427
  /**
362
428
  * The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
@@ -370,15 +436,17 @@ export type CoreOp = (typeof CORE_OPS)[number]
370
436
  * Every feature a spoke may serve: the core ops, the optional features, and "turnEvents": a spoke of another
371
437
  * harness reports every turn's end as the harness confirms it (session.execution.succeeded, failed or interrupted,
372
438
  * never inferred), so the hub trusts its "rpc" evidence for idle (e.g. a Codex app-server spoke's turn/completed).
373
- * Not in SPOKE_FEATURES: an OpenCode spoke's native evidence already counts.
439
+ * Not in SPOKE_FEATURES: an OpenCode spoke's native evidence already counts. "profileApply" (HWA-15, profiles.ts):
440
+ * the spoke applies a pinned profile version to one session; announced only (never implied); the OpenCode spoke
441
+ * serves it since HWA-16 (src/spoke/profile-apply.ts).
374
442
  */
375
- export const KNOWN_SPOKE_FEATURES = [...CORE_OPS, ...OPTIONAL_SPOKE_FEATURES, "turnEvents"] as const
443
+ export const KNOWN_SPOKE_FEATURES = [...CORE_OPS, ...OPTIONAL_SPOKE_FEATURES, "turnEvents", PROFILE_APPLY_FEATURE, SESSION_CONTEXT_FEATURE] as const
376
444
  export type SpokeFeature = (typeof KNOWN_SPOKE_FEATURES)[number]
377
445
  /**
378
446
  * What this package's OpenCode spoke announces (without "deliver" and "hubPrompt" when its OpenCode has no synthetic
379
447
  * messages). No core op: an OpenCode spoke implies them.
380
448
  */
381
- export const SPOKE_FEATURES: readonly SpokeFeature[] = OPTIONAL_SPOKE_FEATURES
449
+ export const SPOKE_FEATURES: readonly SpokeFeature[] = [...OPTIONAL_SPOKE_FEATURES, PROFILE_APPLY_FEATURE, SESSION_CONTEXT_FEATURE]
382
450
 
383
451
  /**
384
452
  * Features the hub announces in its welcome. "harness": it reads hello `harness`, `harnessVersion` and `evidence`.
@@ -585,6 +653,28 @@ export type ServerRequest =
585
653
  * `meta.from` and `meta.grant` around it, so the session never sees it as the user's prompt.
586
654
  */
587
655
  | { readonly op: "deliver"; readonly sessionID: string; readonly messageID: string; readonly text: string; readonly wake: boolean; readonly meta: DeliverMeta }
656
+ /** Apply a pinned profile version to one session (feature "profileApply", HWA-15; see profiles.ts). */
657
+ | ProfileApplyRequest
658
+ /** A session's per-turn context (feature "sessionContext", CON-04; see concierge.ts). */
659
+ | SessionContextRequest
660
+ /**
661
+ * Fork the session before `messageID`, one of its user messages (feature "fork"): a new session with the
662
+ * conversation before that message. The result names it and carries the message's text and attachments, which
663
+ * the console puts in the new session's composer.
664
+ */
665
+ | { readonly op: "fork"; readonly sessionID: string; readonly messageID: string }
666
+ /** What reverting from `messageID` would hide and which files the session changed since then (feature "revert"). */
667
+ | { readonly op: "revertPreview"; readonly sessionID: string; readonly messageID: string }
668
+ /**
669
+ * Stage a revert from `messageID`, one of the session's user messages (feature "revert"): it and everything after
670
+ * it are hidden and, with `files`, the files the session changed since then are restored. Refused while the
671
+ * session runs. The result carries the message's text and attachments for the composer.
672
+ */
673
+ | { readonly op: "revert"; readonly sessionID: string; readonly messageID: string; readonly files: boolean }
674
+ /** Undo a staged revert: the messages and files come back (feature "revert"). */
675
+ | { readonly op: "revertRestore"; readonly sessionID: string }
676
+ /** Make a staged revert permanent: the hidden messages are removed (feature "revert"). */
677
+ | { readonly op: "revertCommit"; readonly sessionID: string }
588
678
 
589
679
  /**
590
680
  * The feature a spoke must serve (spokeFeatures) for the hub to send `op`: the op itself for a core op (CORE_OPS,
@@ -594,6 +684,10 @@ export function featureForOp(op: ServerRequest["op"]): SpokeFeature {
594
684
  switch (op) {
595
685
  case "setModelPrefs":
596
686
  return "modelPrefs"
687
+ case "revertPreview":
688
+ case "revertRestore":
689
+ case "revertCommit":
690
+ return "revert"
597
691
  default:
598
692
  return op
599
693
  }
@@ -710,6 +804,15 @@ export interface ResultFor {
710
804
  /** As sessionTokens. */
711
805
  actTokens: { readonly applied: readonly string[]; readonly skipped: ReadonlyArray<{ readonly sessionID: string; readonly reason: SessionTokenSkipReason }> }
712
806
  deliver: DeliverResult
807
+ profileApply: ProfileApplyResult
808
+ sessionContext: SessionContextResult
809
+ /** `sessionID`: the new session; `text` and `attachments`: the user message it was forked before. */
810
+ fork: { readonly sessionID: string; readonly title?: string; readonly text: string; readonly attachments: readonly MessageAttachment[] }
811
+ /** `hidden`: user messages a revert would hide; `files`: files the session changed since then (at most REVERT_FILES_MAX). */
812
+ revertPreview: { readonly hidden: number; readonly files: readonly RevertFile[]; readonly filesTotal: number }
813
+ revert: { readonly revert: SessionRevertInfo; readonly text: string; readonly attachments: readonly MessageAttachment[] }
814
+ revertRestore: Record<string, never>
815
+ revertCommit: Record<string, never>
713
816
  }
714
817
 
715
818
  // ---------- both directions ----------
@@ -810,7 +913,42 @@ function isSession(v: unknown): v is SessionInfo {
810
913
  (v.projectName === undefined || str(v.projectName)) &&
811
914
  (v.archived === undefined || typeof v.archived === "boolean") &&
812
915
  (v.open === undefined || typeof v.open === "boolean") &&
813
- (v.terminal === undefined || isTerminal(v.terminal))
916
+ (v.terminal === undefined || isTerminal(v.terminal)) &&
917
+ (v.revert === undefined || isSessionRevert(v.revert)) &&
918
+ (v.agent === undefined || (nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX)) &&
919
+ (v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0)) &&
920
+ (v.locked === undefined || v.locked === true)
921
+ )
922
+ }
923
+
924
+ const REVERT_STATUS = new Set(["added", "deleted", "modified"])
925
+ const count = (v: unknown) => Number.isSafeInteger(v) && (v as number) >= 0
926
+
927
+ /** A file of a revert summary or preview. */
928
+ export function isRevertFile(v: unknown): v is RevertFile {
929
+ return (
930
+ isObj(v) &&
931
+ nonEmpty(v.file) &&
932
+ v.file.length <= REVERT_FILE_MAX_CHARS &&
933
+ (v.status === undefined || REVERT_STATUS.has(v.status as string)) &&
934
+ (v.additions === undefined || count(v.additions)) &&
935
+ (v.deletions === undefined || count(v.deletions))
936
+ )
937
+ }
938
+
939
+ /** A staged revert as a spoke reports it. */
940
+ export function isSessionRevert(v: unknown): v is SessionRevertInfo {
941
+ return (
942
+ isObj(v) &&
943
+ nonEmpty(v.messageID) &&
944
+ v.messageID.length <= 300 &&
945
+ count(v.hidden) &&
946
+ (v.at === undefined || num(v.at)) &&
947
+ Array.isArray(v.files) &&
948
+ v.files.length <= REVERT_FILES_MAX &&
949
+ v.files.every(isRevertFile) &&
950
+ count(v.filesTotal) &&
951
+ (v.filesTotal as number) >= v.files.length
814
952
  )
815
953
  }
816
954
 
@@ -1008,6 +1146,18 @@ function isServerRequest(v: unknown): v is ServerRequest {
1008
1146
  return Array.isArray(v.tokens) && v.tokens.length <= SESSION_TOKENS_MAX && v.tokens.every(isSessionToken) && !("hubURL" in v)
1009
1147
  case "deliver":
1010
1148
  return deliverProblem(v) === undefined
1149
+ case "profileApply":
1150
+ return profileApplyRequestProblem(v) === undefined
1151
+ case "sessionContext":
1152
+ return sessionContextProblem(v) === undefined
1153
+ case "fork":
1154
+ case "revertPreview":
1155
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300
1156
+ case "revert":
1157
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && typeof v.files === "boolean"
1158
+ case "revertRestore":
1159
+ case "revertCommit":
1160
+ return nonEmpty(v.sessionID)
1011
1161
  default:
1012
1162
  return false
1013
1163
  }
@@ -1102,15 +1252,34 @@ export const ACTIVITY_SUMMARY_MAX = 160
1102
1252
  /** Longest reasoning delta forwarded; longer deltas are cut (they are normally a few tokens). */
1103
1253
  export const REASONING_DELTA_MAX = 2000
1104
1254
 
1255
+ /** The hub tool's input fields that name its target (never `text`, `why` or answers: those are free text). */
1256
+ const HUB_TARGET_KEYS = ["propose", "to", "session", "id", "name", "role"] as const
1257
+ /** At most ACTIVITY_SUMMARY_MAX UTF-16 units, cut on a code point boundary (CON-10: never half an emoji). */
1258
+ function clipLine(line: string): string {
1259
+ if (line.length <= ACTIVITY_SUMMARY_MAX) return line
1260
+ let out = ""
1261
+ for (const ch of line) {
1262
+ if (out.length + ch.length > ACTIVITY_SUMMARY_MAX - 1) break
1263
+ out += ch
1264
+ }
1265
+ // A joiner or variation selector left dangling would glue the ellipsis to half a sequence.
1266
+ return `${out.replace(/[\u200d\ufe0f]+$/u, "")}…`
1267
+ }
1268
+
1105
1269
  /** Short human summary of a tool input, e.g. the shell command or file path. Never the full input. */
1106
1270
  export function summarizeToolInput(input: unknown): string {
1107
1271
  if (typeof input !== "object" || input === null) return ""
1108
1272
  const record = input as Record<string, unknown>
1273
+ // (CON-09b) The hub tool: its verb and target, e.g. "msg @me", "approve @engineer per_1", "concierge_board".
1274
+ if (typeof record.verb === "string" && /^[a-z][a-z_]{0,39}$/.test(record.verb)) {
1275
+ const target = HUB_TARGET_KEYS.map((key) => record[key]).filter((v): v is string => typeof v === "string" && v.trim() !== "" && !/\s/.test(v.trim()))
1276
+ return clipLine([record.verb, ...target.map((v) => v.trim())].join(" "))
1277
+ }
1109
1278
  for (const key of SUMMARY_KEYS) {
1110
1279
  const value = record[key]
1111
1280
  if (typeof value === "string" && value.trim()) {
1112
1281
  const line = value.trim().split("\n")[0]!
1113
- return line.length > ACTIVITY_SUMMARY_MAX ? `${line.slice(0, ACTIVITY_SUMMARY_MAX - 1)}…` : line
1282
+ return clipLine(line)
1114
1283
  }
1115
1284
  }
1116
1285
  return ""
@@ -59,6 +59,15 @@ export interface LaunchEnd {
59
59
  readonly cleanup?: CleanupPolicy
60
60
  }
61
61
 
62
+ /**
63
+ * (HWA-15, additive) The agent profile version a launched session gets before its first prompt. The version is
64
+ * explicit: a launch never follows a profile's latest version silently.
65
+ */
66
+ export interface LaunchAgentProfile {
67
+ readonly profileID: string
68
+ readonly version: number
69
+ }
70
+
62
71
  /** A launch request body (POST /api/agent/launches). Phase 4 has no `grant` field: a launch gets no powers beyond its launcher's. */
63
72
  export interface LaunchSpec {
64
73
  /** Idempotency key: the same key and body return the same launch. */
@@ -73,9 +82,13 @@ export interface LaunchSpec {
73
82
  readonly runtime?: LaunchRuntime
74
83
  readonly placement?: LaunchPlacement
75
84
  readonly prompt?: string
85
+ /** (CON-02) What the session is for: one line, at most 280 characters, untrusted; stored as its intent (source "launch"). */
86
+ readonly intent?: string
76
87
  /** Free-form; never interpreted by the hub. */
77
88
  readonly labels?: Readonly<Record<string, string>>
78
89
  readonly end?: LaunchEnd
90
+ /** (HWA-15) Pin and apply this profile version to the session before its prompt (spokes that announce profileApply). */
91
+ readonly agentProfile?: LaunchAgentProfile
79
92
  }
80
93
 
81
94
  /** What a node collected from a launch's workspace. */
@@ -90,6 +103,14 @@ export interface LaunchArtifacts {
90
103
  readonly dirty?: boolean
91
104
  }
92
105
 
106
+ /** Why a queued launch waits (FX-02): capacity (a fitting node is full), no_match (no node fits), active (the per-user cap). */
107
+ export interface LaunchWaitingInfo {
108
+ readonly wait: "capacity" | "no_match" | "active"
109
+ readonly reason: string
110
+ /** ms epoch: since when it has waited for this kind of reason. */
111
+ readonly since: number
112
+ }
113
+
93
114
  /** A launch as the API reports it. */
94
115
  export interface LaunchInfo {
95
116
  readonly id: string
@@ -105,6 +126,12 @@ export interface LaunchInfo {
105
126
  readonly name?: string
106
127
  readonly outcome?: LaunchOutcome
107
128
  readonly artifacts?: LaunchArtifacts
129
+ /** Only while queued: why it waits (`reason` is the same text). */
130
+ readonly waiting?: LaunchWaitingInfo
131
+ /** The launcher's label when it launched (an app token's label). */
132
+ readonly byLabel?: string
133
+ /** The app token that launched it was revoked: it still runs and counts toward the cap until stopped. */
134
+ readonly launcherRevoked?: boolean
108
135
  }
109
136
 
110
137
  // ---------- checks ----------
@@ -170,3 +197,12 @@ export function labelsProblem(v: unknown): string | undefined {
170
197
  }
171
198
  return undefined
172
199
  }
200
+
201
+ /** Why `v` is not an acceptable launch agentProfile ({profileID, version}), or undefined. */
202
+ export function launchAgentProfileProblem(v: unknown): string | undefined {
203
+ if (!isObj(v)) return "agentProfile must be {profileID, version}"
204
+ if (Object.keys(v).some((k) => k !== "profileID" && k !== "version")) return "agentProfile holds only profileID and version"
205
+ if (typeof v.profileID !== "string" || !/^[A-Za-z0-9_.:-]{1,100}$/.test(v.profileID)) return "agentProfile.profileID must be a profile ID"
206
+ if (typeof v.version !== "number" || !Number.isSafeInteger(v.version) || v.version < 1) return "agentProfile.version must be a version number (explicit; never latest)"
207
+ return undefined
208
+ }