@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  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/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. 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,52 @@ 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
+
166
+ /** Most files a revert summary or preview lists (`filesTotal` counts them all). */
167
+ export const REVERT_FILES_MAX = 50
168
+ /** Longest file path a revert summary carries. */
169
+ export const REVERT_FILE_MAX_CHARS = 1000
170
+
171
+ /** A file a revert restores (or would restore): its path in the session's directory and, when known, how it changes. */
172
+ export interface RevertFile {
173
+ readonly file: string
174
+ readonly status?: "added" | "deleted" | "modified"
175
+ readonly additions?: number
176
+ readonly deletions?: number
177
+ }
178
+
179
+ /** A staged revert as the spoke reports it (SessionInfo.revert, the revert op's result). */
180
+ export interface SessionRevertInfo {
181
+ /** The reverted user message (it and everything after it are hidden). */
182
+ readonly messageID: string
183
+ /** User messages hidden: the reverted one and those after it. */
184
+ readonly hidden: number
185
+ /** When the reverted message was created (epoch ms), when known. */
186
+ readonly at?: number
187
+ /** Restored files (at most REVERT_FILES_MAX). */
188
+ readonly files: readonly RevertFile[]
189
+ readonly filesTotal: number
190
+ }
191
+
192
+ /** An attachment of a user message, by its position in the message's files (fetched through timelineFile). */
193
+ export interface MessageAttachment {
194
+ readonly index: number
195
+ readonly mime: string
196
+ readonly name?: string
140
197
  }
141
198
 
142
199
  export interface TerminalInfo {
@@ -355,8 +412,12 @@ export const IMPLIED_OPENCODE_FEATURES = [
355
412
  * environment, for its own act verbs only (the OpenCode hub tool).
356
413
  * "launch": its create op takes a launch's `launchID`, `agent` and `model` (see isLaunchCreate); a launch create's
357
414
  * directory may be a new workspace under the machine's node workspace roots rather than one the spoke reported.
415
+ * "fork": it forks a session before one of its user messages (fork op). "revert": it stages, restores and commits
416
+ * a revert of a session from one of its user messages (revertPreview, revert, revertRestore, revertCommit ops) and
417
+ * reports a staged one as SessionInfo.revert. Announced only when the spoke's harness can (OpenCode: a process that
418
+ * reaches its own server).
358
419
  */
359
- const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch"] as const
420
+ const OPTIONAL_SPOKE_FEATURES = [...IMPLIED_OPENCODE_FEATURES, "autoName", "sessionTokens", "deliver", "hubPrompt", "permissionMessage", "actTokens", "launch", "fork", "revert"] as const
360
421
 
361
422
  /**
362
423
  * The core ops. An OpenCode spoke serves them all without announcing them; a spoke of another harness announces
@@ -370,15 +431,17 @@ export type CoreOp = (typeof CORE_OPS)[number]
370
431
  * Every feature a spoke may serve: the core ops, the optional features, and "turnEvents": a spoke of another
371
432
  * harness reports every turn's end as the harness confirms it (session.execution.succeeded, failed or interrupted,
372
433
  * 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.
434
+ * Not in SPOKE_FEATURES: an OpenCode spoke's native evidence already counts. "profileApply" (HWA-15, profiles.ts):
435
+ * the spoke applies a pinned profile version to one session; announced only (never implied); the OpenCode spoke
436
+ * serves it since HWA-16 (src/spoke/profile-apply.ts).
374
437
  */
375
- export const KNOWN_SPOKE_FEATURES = [...CORE_OPS, ...OPTIONAL_SPOKE_FEATURES, "turnEvents"] as const
438
+ export const KNOWN_SPOKE_FEATURES = [...CORE_OPS, ...OPTIONAL_SPOKE_FEATURES, "turnEvents", PROFILE_APPLY_FEATURE, SESSION_CONTEXT_FEATURE] as const
376
439
  export type SpokeFeature = (typeof KNOWN_SPOKE_FEATURES)[number]
377
440
  /**
378
441
  * What this package's OpenCode spoke announces (without "deliver" and "hubPrompt" when its OpenCode has no synthetic
379
442
  * messages). No core op: an OpenCode spoke implies them.
380
443
  */
381
- export const SPOKE_FEATURES: readonly SpokeFeature[] = OPTIONAL_SPOKE_FEATURES
444
+ export const SPOKE_FEATURES: readonly SpokeFeature[] = [...OPTIONAL_SPOKE_FEATURES, PROFILE_APPLY_FEATURE, SESSION_CONTEXT_FEATURE]
382
445
 
383
446
  /**
384
447
  * Features the hub announces in its welcome. "harness": it reads hello `harness`, `harnessVersion` and `evidence`.
@@ -585,6 +648,28 @@ export type ServerRequest =
585
648
  * `meta.from` and `meta.grant` around it, so the session never sees it as the user's prompt.
586
649
  */
587
650
  | { readonly op: "deliver"; readonly sessionID: string; readonly messageID: string; readonly text: string; readonly wake: boolean; readonly meta: DeliverMeta }
651
+ /** Apply a pinned profile version to one session (feature "profileApply", HWA-15; see profiles.ts). */
652
+ | ProfileApplyRequest
653
+ /** A session's per-turn context (feature "sessionContext", CON-04; see concierge.ts). */
654
+ | SessionContextRequest
655
+ /**
656
+ * Fork the session before `messageID`, one of its user messages (feature "fork"): a new session with the
657
+ * conversation before that message. The result names it and carries the message's text and attachments, which
658
+ * the console puts in the new session's composer.
659
+ */
660
+ | { readonly op: "fork"; readonly sessionID: string; readonly messageID: string }
661
+ /** What reverting from `messageID` would hide and which files the session changed since then (feature "revert"). */
662
+ | { readonly op: "revertPreview"; readonly sessionID: string; readonly messageID: string }
663
+ /**
664
+ * Stage a revert from `messageID`, one of the session's user messages (feature "revert"): it and everything after
665
+ * it are hidden and, with `files`, the files the session changed since then are restored. Refused while the
666
+ * session runs. The result carries the message's text and attachments for the composer.
667
+ */
668
+ | { readonly op: "revert"; readonly sessionID: string; readonly messageID: string; readonly files: boolean }
669
+ /** Undo a staged revert: the messages and files come back (feature "revert"). */
670
+ | { readonly op: "revertRestore"; readonly sessionID: string }
671
+ /** Make a staged revert permanent: the hidden messages are removed (feature "revert"). */
672
+ | { readonly op: "revertCommit"; readonly sessionID: string }
588
673
 
589
674
  /**
590
675
  * The feature a spoke must serve (spokeFeatures) for the hub to send `op`: the op itself for a core op (CORE_OPS,
@@ -594,6 +679,10 @@ export function featureForOp(op: ServerRequest["op"]): SpokeFeature {
594
679
  switch (op) {
595
680
  case "setModelPrefs":
596
681
  return "modelPrefs"
682
+ case "revertPreview":
683
+ case "revertRestore":
684
+ case "revertCommit":
685
+ return "revert"
597
686
  default:
598
687
  return op
599
688
  }
@@ -710,6 +799,15 @@ export interface ResultFor {
710
799
  /** As sessionTokens. */
711
800
  actTokens: { readonly applied: readonly string[]; readonly skipped: ReadonlyArray<{ readonly sessionID: string; readonly reason: SessionTokenSkipReason }> }
712
801
  deliver: DeliverResult
802
+ profileApply: ProfileApplyResult
803
+ sessionContext: SessionContextResult
804
+ /** `sessionID`: the new session; `text` and `attachments`: the user message it was forked before. */
805
+ fork: { readonly sessionID: string; readonly title?: string; readonly text: string; readonly attachments: readonly MessageAttachment[] }
806
+ /** `hidden`: user messages a revert would hide; `files`: files the session changed since then (at most REVERT_FILES_MAX). */
807
+ revertPreview: { readonly hidden: number; readonly files: readonly RevertFile[]; readonly filesTotal: number }
808
+ revert: { readonly revert: SessionRevertInfo; readonly text: string; readonly attachments: readonly MessageAttachment[] }
809
+ revertRestore: Record<string, never>
810
+ revertCommit: Record<string, never>
713
811
  }
714
812
 
715
813
  // ---------- both directions ----------
@@ -810,7 +908,41 @@ function isSession(v: unknown): v is SessionInfo {
810
908
  (v.projectName === undefined || str(v.projectName)) &&
811
909
  (v.archived === undefined || typeof v.archived === "boolean") &&
812
910
  (v.open === undefined || typeof v.open === "boolean") &&
813
- (v.terminal === undefined || isTerminal(v.terminal))
911
+ (v.terminal === undefined || isTerminal(v.terminal)) &&
912
+ (v.revert === undefined || isSessionRevert(v.revert)) &&
913
+ (v.agent === undefined || (nonEmpty(v.agent) && v.agent.length <= AGENT_NAME_MAX)) &&
914
+ (v.contextRevision === undefined || (Number.isSafeInteger(v.contextRevision) && (v.contextRevision as number) >= 0))
915
+ )
916
+ }
917
+
918
+ const REVERT_STATUS = new Set(["added", "deleted", "modified"])
919
+ const count = (v: unknown) => Number.isSafeInteger(v) && (v as number) >= 0
920
+
921
+ /** A file of a revert summary or preview. */
922
+ export function isRevertFile(v: unknown): v is RevertFile {
923
+ return (
924
+ isObj(v) &&
925
+ nonEmpty(v.file) &&
926
+ v.file.length <= REVERT_FILE_MAX_CHARS &&
927
+ (v.status === undefined || REVERT_STATUS.has(v.status as string)) &&
928
+ (v.additions === undefined || count(v.additions)) &&
929
+ (v.deletions === undefined || count(v.deletions))
930
+ )
931
+ }
932
+
933
+ /** A staged revert as a spoke reports it. */
934
+ export function isSessionRevert(v: unknown): v is SessionRevertInfo {
935
+ return (
936
+ isObj(v) &&
937
+ nonEmpty(v.messageID) &&
938
+ v.messageID.length <= 300 &&
939
+ count(v.hidden) &&
940
+ (v.at === undefined || num(v.at)) &&
941
+ Array.isArray(v.files) &&
942
+ v.files.length <= REVERT_FILES_MAX &&
943
+ v.files.every(isRevertFile) &&
944
+ count(v.filesTotal) &&
945
+ (v.filesTotal as number) >= v.files.length
814
946
  )
815
947
  }
816
948
 
@@ -1008,6 +1140,18 @@ function isServerRequest(v: unknown): v is ServerRequest {
1008
1140
  return Array.isArray(v.tokens) && v.tokens.length <= SESSION_TOKENS_MAX && v.tokens.every(isSessionToken) && !("hubURL" in v)
1009
1141
  case "deliver":
1010
1142
  return deliverProblem(v) === undefined
1143
+ case "profileApply":
1144
+ return profileApplyRequestProblem(v) === undefined
1145
+ case "sessionContext":
1146
+ return sessionContextProblem(v) === undefined
1147
+ case "fork":
1148
+ case "revertPreview":
1149
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300
1150
+ case "revert":
1151
+ return nonEmpty(v.sessionID) && nonEmpty(v.messageID) && v.messageID.length <= 300 && typeof v.files === "boolean"
1152
+ case "revertRestore":
1153
+ case "revertCommit":
1154
+ return nonEmpty(v.sessionID)
1011
1155
  default:
1012
1156
  return false
1013
1157
  }
@@ -1102,15 +1246,34 @@ export const ACTIVITY_SUMMARY_MAX = 160
1102
1246
  /** Longest reasoning delta forwarded; longer deltas are cut (they are normally a few tokens). */
1103
1247
  export const REASONING_DELTA_MAX = 2000
1104
1248
 
1249
+ /** The hub tool's input fields that name its target (never `text`, `why` or answers: those are free text). */
1250
+ const HUB_TARGET_KEYS = ["propose", "to", "session", "id", "name", "role"] as const
1251
+ /** At most ACTIVITY_SUMMARY_MAX UTF-16 units, cut on a code point boundary (CON-10: never half an emoji). */
1252
+ function clipLine(line: string): string {
1253
+ if (line.length <= ACTIVITY_SUMMARY_MAX) return line
1254
+ let out = ""
1255
+ for (const ch of line) {
1256
+ if (out.length + ch.length > ACTIVITY_SUMMARY_MAX - 1) break
1257
+ out += ch
1258
+ }
1259
+ // A joiner or variation selector left dangling would glue the ellipsis to half a sequence.
1260
+ return `${out.replace(/[\u200d\ufe0f]+$/u, "")}…`
1261
+ }
1262
+
1105
1263
  /** Short human summary of a tool input, e.g. the shell command or file path. Never the full input. */
1106
1264
  export function summarizeToolInput(input: unknown): string {
1107
1265
  if (typeof input !== "object" || input === null) return ""
1108
1266
  const record = input as Record<string, unknown>
1267
+ // (CON-09b) The hub tool: its verb and target, e.g. "msg @me", "approve @engineer per_1", "concierge_board".
1268
+ if (typeof record.verb === "string" && /^[a-z][a-z_]{0,39}$/.test(record.verb)) {
1269
+ const target = HUB_TARGET_KEYS.map((key) => record[key]).filter((v): v is string => typeof v === "string" && v.trim() !== "" && !/\s/.test(v.trim()))
1270
+ return clipLine([record.verb, ...target.map((v) => v.trim())].join(" "))
1271
+ }
1109
1272
  for (const key of SUMMARY_KEYS) {
1110
1273
  const value = record[key]
1111
1274
  if (typeof value === "string" && value.trim()) {
1112
1275
  const line = value.trim().split("\n")[0]!
1113
- return line.length > ACTIVITY_SUMMARY_MAX ? `${line.slice(0, ACTIVITY_SUMMARY_MAX - 1)}…` : line
1276
+ return clipLine(line)
1114
1277
  }
1115
1278
  }
1116
1279
  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. */
@@ -170,3 +183,12 @@ export function labelsProblem(v: unknown): string | undefined {
170
183
  }
171
184
  return undefined
172
185
  }
186
+
187
+ /** Why `v` is not an acceptable launch agentProfile ({profileID, version}), or undefined. */
188
+ export function launchAgentProfileProblem(v: unknown): string | undefined {
189
+ if (!isObj(v)) return "agentProfile must be {profileID, version}"
190
+ if (Object.keys(v).some((k) => k !== "profileID" && k !== "version")) return "agentProfile holds only profileID and version"
191
+ if (typeof v.profileID !== "string" || !/^[A-Za-z0-9_.:-]{1,100}$/.test(v.profileID)) return "agentProfile.profileID must be a profile ID"
192
+ if (typeof v.version !== "number" || !Number.isSafeInteger(v.version) || v.version < 1) return "agentProfile.version must be a version number (explicit; never latest)"
193
+ return undefined
194
+ }