@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,355 @@
1
+ // Workspace supervision graph (HWA-05; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md,
2
+ // packages/protocol/src/hub-workspace.ts SupervisionLink, supervisionInputProblem, supervisionViolation).
3
+ //
4
+ // A supervision link says "member M is supervised by member S" inside one workspace. Each member has at most one
5
+ // direct supervisor; the graph is acyclic and no member is more than `depth` links below a root (the contract's
6
+ // supervisionDepth, master = depth 0). Links name members, and a member is one generation of a session in the
7
+ // workspace, so a link always pins the supervisor's generation: a replacement (new generation, new member ID) only
8
+ // inherits links through an explicit memberDeparted(…, {replacedBy}).
9
+ //
10
+ // Storage: StateStore collection `workspace-supervision`, ONE aggregate per workspace (key: workspace ID) holding
11
+ // every link and the graph `revision` (0 before the first write, +1 per write). The StateStore has no cross-record
12
+ // transactions, so the whole graph is one version-checked write (ifVersion): a cycle or depth check is always made
13
+ // against the graph that is written over, and two concurrent opposing edges can never both land. Mutations name the
14
+ // graph revision they read (`expectedRevision`, required by the HTTP route, optional for internal callers); a
15
+ // mismatch is a 409 conflict with `currentRevision`. Without it, a lost race re-reads and re-checks, so the later
16
+ // edge of an opposing pair is refused as a cycle. Each link also carries its own `revision` (1 on attach, +1 per
17
+ // move or reorder); a detach removes it and a re-attach is a new link ID.
18
+ //
19
+ // Members: both ends must be current members of the workspace (unknown: not_found; departed: invalid), and the
20
+ // workspace's master cannot be given a supervisor (it is the root). Every write reads the supervision aggregate
21
+ // FIRST and the members second: any member a stored link names joined before that link was written, so it is in
22
+ // the later member read unless it was pruned for having departed. Departure is terminal, so each write drops the
23
+ // links whose ends are departed or no longer retained (`pruned`). Reads report such stale links truthfully
24
+ // (`left` / `missing`) rather than hiding them; memberDeparted (HWA-06 calls it after leave / sessionEnded /
25
+ // archive) detaches a departed member's links, or moves them to its replacement when the move stays acyclic and
26
+ // within depth (otherwise detached and counted). Archived workspaces read but refuse changes.
27
+ //
28
+ // Authority: supervision grants nothing and is separate from native session ancestry. This module never reads or
29
+ // writes parent/root session fields, grants, the directory or the registry: the own-tree permission/form ban and
30
+ // the root-only prompt rule of the acting engine are untouched. Events (HWA-06) carry IDs, revisions and counts.
31
+ //
32
+ // Limits: links per workspace (429 rate_limited at the cap; a move is not a new link), depth (400 invalid with
33
+ // extra.violation "depth"), cycles (400 invalid with extra.violation "cycle"), order a finite number within
34
+ // ±ORDER_MAX (fractions allowed so a drag can land between two siblings without renumbering them).
35
+ // Other builds: an aggregate with a newer `v` answers 422 unsupported and is left untouched; a malformed one is a
36
+ // 500. Rollback: older hubs never read this collection, so it stays dormant.
37
+ import { randomBytes } from "node:crypto"
38
+ import {
39
+ CONTRACT_VERSION,
40
+ ID_PREFIXES,
41
+ type IdKind,
42
+ isContractID,
43
+ type SupervisionLink,
44
+ supervisionInputProblem,
45
+ supervisionViolation,
46
+ WORKSPACE_LIMITS,
47
+ type WorkspaceMember,
48
+ } from "../../protocol"
49
+ import { AgentError } from "../agent/errors"
50
+ import type { Clock, StateStore } from "../backend/types"
51
+ import type { MembershipStore } from "./members"
52
+ import type { WorkspaceStore } from "./store"
53
+
54
+ export const WORKSPACE_SUPERVISION = "workspace-supervision"
55
+ /** Largest |order| a link may carry. */
56
+ export const ORDER_MAX = 1e9
57
+ /** Version-checked write attempts before a contended change gives up. */
58
+ const ATTEMPTS = 16
59
+
60
+ export interface SupervisionLimits {
61
+ /** Links per workspace (the contract's supervisionLinksPerWorkspace). */
62
+ readonly linksPerWorkspace: number
63
+ /** Links from the deepest member up to its root (the contract's supervisionDepth). */
64
+ readonly depth: number
65
+ }
66
+
67
+ export const SUPERVISION_LIMITS: SupervisionLimits = {
68
+ linksPerWorkspace: WORKSPACE_LIMITS.supervisionLinksPerWorkspace,
69
+ depth: WORKSPACE_LIMITS.supervisionDepth,
70
+ }
71
+
72
+ export interface SupervisionStoreDeps {
73
+ readonly state: StateStore
74
+ readonly workspaces: Pick<WorkspaceStore, "require">
75
+ readonly members: Pick<MembershipStore, "members">
76
+ readonly now?: Clock
77
+ readonly newID?: (kind: IdKind) => string
78
+ readonly limits?: Partial<SupervisionLimits>
79
+ }
80
+
81
+ /** A link end as the members read it: a current member, a departed one, or no longer retained. */
82
+ export type LinkEnd = "current" | "left" | "missing"
83
+
84
+ export interface SupervisionLinkView {
85
+ readonly link: SupervisionLink
86
+ readonly member: LinkEnd
87
+ readonly supervisor: LinkEnd
88
+ }
89
+
90
+ export interface SupervisionGraph {
91
+ readonly workspaceID: string
92
+ /** The graph revision a mutation names as expectedRevision (0: never written). */
93
+ readonly revision: number
94
+ /** In storage order (stable); the dashboard sorts siblings by `order`. */
95
+ readonly links: readonly SupervisionLinkView[]
96
+ }
97
+
98
+ export type SupervisionChange = "attached" | "moved" | "reordered" | "detached" | "unchanged"
99
+
100
+ export interface SupervisionResult {
101
+ readonly workspaceID: string
102
+ /** The graph revision after the call. */
103
+ readonly revision: number
104
+ readonly change: SupervisionChange
105
+ /** The member's link after the call (absent when it has no supervisor). */
106
+ readonly link?: SupervisionLink
107
+ /** Stale links (departed or missing ends) dropped by this write. */
108
+ readonly pruned: number
109
+ }
110
+
111
+ export interface DepartureResult {
112
+ readonly workspaceID: string
113
+ readonly revision: number
114
+ /** Links naming the departed member that were removed. */
115
+ readonly detached: number
116
+ /** Links naming the departed member moved to its replacement. */
117
+ readonly transferred: number
118
+ /** Other stale links dropped by the same write. */
119
+ readonly pruned: number
120
+ }
121
+
122
+ interface SupervisionDoc {
123
+ readonly v: 1
124
+ readonly workspaceID: string
125
+ readonly revision: number
126
+ readonly links: readonly SupervisionLink[]
127
+ }
128
+
129
+ type Members = ReadonlyMap<string, WorkspaceMember>
130
+
131
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
132
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v)
133
+
134
+ const conflict = (currentRevision: number) =>
135
+ new AgentError("conflict", `The supervision graph changed since you read it (revision ${currentRevision} now)`, {
136
+ hint: "Read it again and retry with its current revision",
137
+ extra: { currentRevision },
138
+ })
139
+
140
+ /** The stored aggregate, or an empty graph; newer versions are unsupported, malformed ones internal. */
141
+ function readDoc(raw: unknown, workspaceID: string): SupervisionDoc {
142
+ if (raw === undefined) return { v: 1, workspaceID, revision: 0, links: [] }
143
+ if (isObj(raw) && isNum(raw.v) && raw.v > CONTRACT_VERSION)
144
+ throw new AgentError("unsupported", `Workspace ${workspaceID} supervision was written by a newer hub`, {
145
+ hint: "Upgrade this hub",
146
+ extra: { feature: WORKSPACE_SUPERVISION },
147
+ })
148
+ if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.workspaceID !== workspaceID || !Number.isSafeInteger(raw.revision) || !Array.isArray(raw.links))
149
+ throw new AgentError("internal", "Stored workspace supervision is malformed")
150
+ return raw as unknown as SupervisionDoc
151
+ }
152
+
153
+ const endOf = (members: Members, id: string): LinkEnd => {
154
+ const member = members.get(id)
155
+ return !member ? "missing" : member.leftAt === undefined ? "current" : "left"
156
+ }
157
+
158
+ const isLive = (members: Members, link: SupervisionLink) => endOf(members, link.memberID) === "current" && endOf(members, link.supervisorMemberID) === "current"
159
+
160
+ const edges = (links: readonly SupervisionLink[]) => new Map(links.map((l) => [l.memberID, l.supervisorMemberID]))
161
+
162
+ function violationError(violation: "cycle" | "depth", depth: number): AgentError {
163
+ return violation === "cycle"
164
+ ? new AgentError("invalid", "That supervisor is supervised by this member (directly or through others): it would make a cycle", {
165
+ hint: "Move the supervisor elsewhere first",
166
+ extra: { violation },
167
+ })
168
+ : new AgentError("invalid", `Supervision is at most ${depth} levels deep`, { hint: "Attach the member (or its subtree) higher up", extra: { violation, depth } })
169
+ }
170
+
171
+ /** A current member in the snapshot: not_found when unknown, invalid when departed. */
172
+ function current(members: Members, workspaceID: string, memberID: string): WorkspaceMember {
173
+ const member = members.get(memberID)
174
+ if (!member) throw new AgentError("not_found", `No member ${memberID} in workspace ${workspaceID}`)
175
+ if (member.leftAt !== undefined) throw new AgentError("invalid", `Member ${memberID} left the workspace`, { hint: "Use a current member" })
176
+ return member
177
+ }
178
+
179
+ export class SupervisionStore {
180
+ readonly limits: SupervisionLimits
181
+ private readonly state: StateStore
182
+ private readonly newID: (kind: IdKind) => string
183
+
184
+ constructor(private readonly deps: SupervisionStoreDeps) {
185
+ this.state = deps.state
186
+ this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
187
+ this.limits = { ...SUPERVISION_LIMITS, ...deps.limits }
188
+ }
189
+
190
+ /** The workspace's links with each end's membership state, and the graph revision. */
191
+ async graph(userID: string, workspaceID: string): Promise<SupervisionGraph> {
192
+ await this.deps.workspaces.require(userID, workspaceID)
193
+ const doc = readDoc((await this.state.get(userID, WORKSPACE_SUPERVISION, workspaceID))?.value, workspaceID)
194
+ const members = await this.memberMap(userID, workspaceID)
195
+ return {
196
+ workspaceID,
197
+ revision: doc.revision,
198
+ links: doc.links.map((link) => ({ link, member: endOf(members, link.memberID), supervisor: endOf(members, link.supervisorMemberID) })),
199
+ }
200
+ }
201
+
202
+ /**
203
+ * PUT /workspaces/:id/supervision: `{memberID, supervisorMemberID | null, order?, expectedRevision}`. Attaches,
204
+ * moves, reorders or (null) detaches the member's one supervision link in a single version-checked write.
205
+ */
206
+ async set(userID: string, workspaceID: string, input: unknown): Promise<SupervisionResult> {
207
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
208
+ const expectedRevision = input.expectedRevision
209
+ const problem = supervisionInputProblem(expectedRevision === undefined ? { ...input, expectedRevision: 0 } : input)
210
+ if (problem) throw new AgentError("invalid", problem)
211
+ const memberID = input.memberID as string
212
+ const supervisorID = input.supervisorMemberID as string | null
213
+ const order = input.order
214
+ if (order !== undefined) {
215
+ if (supervisorID === null) throw new AgentError("invalid", "order applies to an attached member, not a detach")
216
+ if (!isNum(order) || Math.abs(order) > ORDER_MAX) throw new AgentError("invalid", `order must be a finite number within ±${ORDER_MAX}`)
217
+ }
218
+ const workspace = await this.deps.workspaces.require(userID, workspaceID)
219
+ if (workspace.state === "archived") throw new AgentError("invalid", `Workspace ${workspaceID} is archived`, { hint: "Restore it first" })
220
+
221
+ let result: Omit<SupervisionResult, "workspaceID" | "revision" | "pruned"> = { change: "unchanged" }
222
+ const written = await this.mutate(userID, workspaceID, (doc, members) => {
223
+ if (expectedRevision !== undefined && doc.revision !== expectedRevision) throw conflict(doc.revision)
224
+ const live = doc.links.filter((l) => isLive(members, l))
225
+ current(members, workspaceID, memberID)
226
+ if (supervisorID !== null) current(members, workspaceID, supervisorID)
227
+ const existing = live.find((l) => l.memberID === memberID)
228
+ result = { change: "unchanged", ...(existing ? { link: existing } : {}) }
229
+ let links = live
230
+ if (supervisorID === null) {
231
+ if (existing) {
232
+ links = live.filter((l) => l !== existing)
233
+ result = { change: "detached" }
234
+ }
235
+ } else if (existing && existing.supervisorMemberID === supervisorID) {
236
+ if (order !== undefined && order !== existing.order) {
237
+ const link = { ...existing, order: order as number, revision: existing.revision + 1 }
238
+ links = live.map((l) => (l === existing ? link : l))
239
+ result = { change: "reordered", link }
240
+ }
241
+ } else {
242
+ if (memberID === workspace.masterMemberID)
243
+ throw new AgentError("invalid", `Member ${memberID} is the master: the root of supervision has no supervisor`, { hint: "Replace the master first" })
244
+ const violation = supervisionViolation(edges(live), memberID, supervisorID, this.limits.depth)
245
+ if (violation) throw violationError(violation, this.limits.depth)
246
+ if (!existing && live.length >= this.limits.linksPerWorkspace)
247
+ throw new AgentError("rate_limited", `A workspace has at most ${this.limits.linksPerWorkspace} supervision links`, { hint: "Detach some first" })
248
+ const siblings = live.filter((l) => l.supervisorMemberID === supervisorID && l.memberID !== memberID).map((l) => l.order)
249
+ const at = (order as number | undefined) ?? (siblings.length ? Math.max(...siblings) + 1 : 0)
250
+ if (existing) {
251
+ const link = { ...existing, supervisorMemberID: supervisorID, order: at, revision: existing.revision + 1 }
252
+ links = live.map((l) => (l === existing ? link : l))
253
+ result = { change: "moved", link }
254
+ } else {
255
+ const link: SupervisionLink = { v: 1, id: this.newID("supervision"), workspaceID, supervisorMemberID: supervisorID, memberID, order: at, revision: 1 }
256
+ links = [...live, link]
257
+ result = { change: "attached", link }
258
+ }
259
+ }
260
+ return result.change === "unchanged" && live.length === doc.links.length ? undefined : links
261
+ })
262
+ return { workspaceID, ...result, revision: written.doc.revision, pruned: written.pruned }
263
+ }
264
+
265
+ /**
266
+ * A member departed (left, ended, archived or replaced): detaches its links, or with `replacedBy` (a current
267
+ * member, e.g. HWA-12's new master generation) moves them to the replacement where that stays acyclic, within
268
+ * depth and one supervisor per member; the rest are detached. Members it supervised without a transfer become
269
+ * roots. Refuses a current member (invalid): supervision follows membership, never the other way round.
270
+ */
271
+ async memberDeparted(userID: string, workspaceID: string, memberID: string, options: { readonly replacedBy?: string } = {}): Promise<DepartureResult> {
272
+ if (!isContractID("member", memberID)) throw new AgentError("invalid", "memberID must be a workspace member ID")
273
+ const { replacedBy } = options
274
+ if (replacedBy !== undefined && !isContractID("member", replacedBy)) throw new AgentError("invalid", "replacedBy must be a workspace member ID")
275
+ const workspace = await this.deps.workspaces.require(userID, workspaceID)
276
+ let detached = 0
277
+ let transferred = 0
278
+ const written = await this.mutate(userID, workspaceID, (doc, members) => {
279
+ if (members.get(memberID)?.leftAt === undefined && members.has(memberID))
280
+ throw new AgentError("invalid", `Member ${memberID} is still current`, { hint: "Supervision follows membership: remove the member first" })
281
+ if (replacedBy !== undefined) current(members, workspaceID, replacedBy)
282
+ detached = 0
283
+ transferred = 0
284
+ const affected = (l: SupervisionLink) => l.memberID === memberID || l.supervisorMemberID === memberID
285
+ let links = doc.links.filter((l) => !affected(l) && isLive(members, l))
286
+ for (const old of doc.links.filter(affected)) {
287
+ const moved = replacedBy === undefined ? undefined : this.transfer(old, memberID, replacedBy, links, members, workspace.masterMemberID)
288
+ if (moved) {
289
+ links = [...links, moved]
290
+ transferred++
291
+ } else detached++
292
+ }
293
+ return detached + transferred === 0 && links.length === doc.links.length ? undefined : links
294
+ })
295
+ return { workspaceID, revision: written.doc.revision, detached, transferred, pruned: written.pruned - detached - transferred }
296
+ }
297
+
298
+ /** Drops links whose ends departed or are no longer retained (each write does this too). */
299
+ async prune(userID: string, workspaceID: string): Promise<{ workspaceID: string; revision: number; pruned: number }> {
300
+ await this.deps.workspaces.require(userID, workspaceID)
301
+ const written = await this.mutate(userID, workspaceID, (doc, members) => {
302
+ const live = doc.links.filter((l) => isLive(members, l))
303
+ return live.length === doc.links.length ? undefined : live
304
+ })
305
+ return { workspaceID, revision: written.doc.revision, pruned: written.pruned }
306
+ }
307
+
308
+ // ---------- internals ----------
309
+
310
+ /** The old member's link rewritten to its replacement, or undefined when that is not allowed (then detached). */
311
+ private transfer(
312
+ old: SupervisionLink,
313
+ departed: string,
314
+ replacement: string,
315
+ links: readonly SupervisionLink[],
316
+ members: Members,
317
+ masterMemberID: string | undefined,
318
+ ): SupervisionLink | undefined {
319
+ const memberID = old.memberID === departed ? replacement : old.memberID
320
+ const supervisorMemberID = old.supervisorMemberID === departed ? replacement : old.supervisorMemberID
321
+ if (memberID === supervisorMemberID || memberID === masterMemberID) return undefined
322
+ if (endOf(members, memberID) !== "current" || endOf(members, supervisorMemberID) !== "current") return undefined
323
+ if (links.some((l) => l.memberID === memberID)) return undefined
324
+ if (supervisionViolation(edges(links), memberID, supervisorMemberID, this.limits.depth)) return undefined
325
+ return { ...old, memberID, supervisorMemberID, revision: old.revision + 1 }
326
+ }
327
+
328
+ /** The workspace's members (current and departed) by ID. */
329
+ private async memberMap(userID: string, workspaceID: string): Promise<Members> {
330
+ const views = await this.deps.members.members(userID, workspaceID, { includeLeft: true })
331
+ return new Map(views.map((v) => [v.member.id, v.member]))
332
+ }
333
+
334
+ /**
335
+ * One version-checked write of the aggregate: reads it, THEN the members (see the header), and writes the links
336
+ * `change` returns (undefined: nothing to write) at revision + 1. Retried when another writer got there first.
337
+ */
338
+ private async mutate(
339
+ userID: string,
340
+ workspaceID: string,
341
+ change: (doc: SupervisionDoc, members: Members) => readonly SupervisionLink[] | undefined,
342
+ ): Promise<{ doc: SupervisionDoc; pruned: number }> {
343
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
344
+ const stored = await this.state.get(userID, WORKSPACE_SUPERVISION, workspaceID)
345
+ const doc = readDoc(stored?.value, workspaceID)
346
+ const members = await this.memberMap(userID, workspaceID)
347
+ const links = change(doc, members)
348
+ if (links === undefined) return { doc, pruned: 0 }
349
+ const next: SupervisionDoc = { v: 1, workspaceID, revision: doc.revision + 1, links }
350
+ const put = await this.state.put(userID, WORKSPACE_SUPERVISION, workspaceID, next, { ifVersion: stored?.version ?? 0 })
351
+ if (put.ok) return { doc: next, pruned: doc.links.filter((l) => !isLive(members, l)).length }
352
+ }
353
+ throw new Error(`state update of ${WORKSPACE_SUPERVISION}/${workspaceID} kept conflicting`)
354
+ }
355
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@ import { HubRpc, VoiceRpc } from "./rpc"
11
11
  import { jobFromInfo, type JobInfo } from "./protocol"
12
12
  import { checkServer, parseServerInput } from "./setup"
13
13
  import { defineVoiceAgent, orchestratorParts, STOP_TOOL, STOP_TOOL_DESCRIPTION } from "./spoke/conversation"
14
+ import { defineConciergeAgent } from "./spoke/concierge-agent"
15
+
16
+ import { CONCIERGE_AGENT, CONCIERGE_TOOLS } from "../packages/protocol/src/concierge.ts"
14
17
  import { envInjector, hubBinDir, ShellCorrelator, stripInheritedHubEnv } from "./spoke/env-inject"
15
18
  import { herdrRunner } from "./spoke/herdr"
16
19
  import { launchSource } from "./spoke/launch-source"
@@ -18,14 +21,19 @@ import { HubRpcError, hubMsg, hubWho } from "./spoke/hub-rpc"
18
21
  import { HUB_TOOL, HUB_TOOL_DESCRIPTION, HUB_TOOL_INPUT, runHubTool } from "./spoke/hub-tool"
19
22
  import { cliEntry, installLaunchers, linkUserBin, standaloneHubctl } from "./spoke/launchers"
20
23
  import { listServiceSessions } from "./spoke/listing"
21
- import { ensureLocalHub } from "./spoke/localhub"
24
+ import { defaultStateDir, ensureLocalHub } from "./spoke/localhub"
25
+ import path from "node:path"
22
26
  import { nodeConfigPath, readWorkspaceRoots } from "./spoke/node-roots"
23
27
  import { PromptReferences } from "./spoke/prompt-refs"
28
+ import { PROFILE_TOOL, PROFILE_TOOL_DESCRIPTION, PROFILE_TOOL_INPUT } from "./spoke/profile-apply"
24
29
  import { replyPermission, type PermissionLocation } from "./spoke/locations"
25
30
  import { foregroundEndpoint, formOutcome } from "./spoke/own-server"
26
31
  import { joinProcess, type Membership } from "./spoke/singleton"
27
32
  import { detectKind, Spoke, SpokeError, type FormAction, type SpokeHost } from "./spoke/spoke"
28
33
 
34
+ /** Where the spoke keeps the IDs of Concierge sessions (always restricted to the Concierge's tools). */
35
+ const conciergeLockFile = () => path.join(defaultStateDir(), "concierge-sessions.json")
36
+
29
37
  type Context = Parameters<Parameters<typeof Plugin.define>[0]["setup"]>[0]
30
38
 
31
39
  /** Plugin.define without importing it (it returns its argument). */
@@ -54,18 +62,28 @@ function pluginVersion(): string {
54
62
  }
55
63
 
56
64
  /**
57
- * The most recent root sessions of the local OpenCode service with their workspace (used by the
58
- * service's own spoke for the phone's session browser): the history window plus `include`.
65
+ * The most recent root sessions with their workspace (the spoke's session listing): the history window plus
66
+ * `include`. The background service lists every session of its server; a foreground `opencode serve` lists, from
67
+ * its own server, only the sessions of the locations it loaded (`scope`), so idle sessions of a loaded project are
68
+ * online without having run in this process, and nothing of other projects is claimed.
59
69
  */
60
- async function listRecent(options?: { days: number; max: number; include: readonly string[] }) {
61
- const { Service } = await import("@opencode/client/service")
62
- const { OpenCode } = await import("@opencode/client")
63
- const endpoint = await Service.discover()
64
- if (!endpoint) return []
65
- const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) ?? {} })
66
- return listServiceSessions(client, options ?? {})
70
+ async function listRecent(kind: "service" | "standalone", scope: () => string[], options?: { days: number; max: number; include: readonly string[] }) {
71
+ if (kind === "service") {
72
+ const { Service } = await import("@opencode/client/service")
73
+ const { OpenCode } = await import("@opencode/client")
74
+ const endpoint = await Service.discover()
75
+ if (!endpoint) return []
76
+ const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) ?? {} })
77
+ return listServiceSessions(client, options ?? {})
78
+ }
79
+ const client = await ownClient(kind)
80
+ if (!client) return []
81
+ return listServiceSessions(client, { ...(options ?? {}), scope: scope() })
67
82
  }
68
83
 
84
+ /** This process can list sessions: the background service, or a foreground `opencode serve` that knows its password. */
85
+ const canList = (kind: "service" | "standalone") => kind === "service" || foregroundEndpoint(process.argv, process.env) !== undefined
86
+
69
87
  /** A client for this process's own OpenCode server, when it can be reached (see src/spoke/own-server.ts). */
70
88
  async function ownClient(kind: "service" | "standalone") {
71
89
  const { OpenCode } = await import("@opencode/client")
@@ -78,6 +96,13 @@ async function ownClient(kind: "service" | "standalone") {
78
96
  return endpoint ? OpenCode.make({ baseUrl: endpoint.url, headers: { authorization: endpoint.authorization } }) : undefined
79
97
  }
80
98
 
99
+ /** The own server's client, or an error saying it cannot be reached. */
100
+ async function requireOwnClient(kind: "service" | "standalone") {
101
+ const client = await ownClient(kind).catch(() => undefined)
102
+ if (!client) throw new Error("This OpenCode process cannot reach its own server")
103
+ return client
104
+ }
105
+
81
106
  /** Answers or cancels a form through the own server's API (the plugin API has no forms). */
82
107
  async function replyForm(kind: "service" | "standalone", action: FormAction): Promise<"replied" | "gone" | "unavailable"> {
83
108
  const client = await ownClient(kind).catch(() => undefined)
@@ -153,6 +178,9 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
153
178
  const agent = ctx.agent as any
154
179
  const mcp = (ctx as any).mcp
155
180
  const vcs = (ctx as any).vcs
181
+ const skill = (ctx as any).skill
182
+ const command = (ctx as any).command
183
+ const names = (value: unknown) => (unwrap<ReadonlyArray<{ name?: unknown }>>(value, Array.isArray) ?? []).flatMap((item) => (typeof item?.name === "string" ? [item.name] : []))
156
184
  return {
157
185
  directory: ctx.location.directory,
158
186
  opencodeVersion: ctx.app?.version ?? "unknown",
@@ -184,11 +212,30 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
184
212
  const list = unwrap<Array<{ id: string; mode?: string; hidden?: boolean }>>(await agent.list({}), Array.isArray)
185
213
  return list?.find((a) => a.mode !== "subagent" && !a.hidden)?.id
186
214
  },
187
- listRecent,
215
+ ...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[] }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
188
216
  activity: (directories) => activity(kind, directories),
217
+ // Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
218
+ ...(canList(kind)
219
+ ? {
220
+ forkSession: async ({ sessionID, before }: { sessionID: string; before: string }) => (await requireOwnClient(kind)).session.fork({ sessionID, before }),
221
+ revertStage: async (input: { sessionID: string; messageID: string; files: boolean }) => (await requireOwnClient(kind)).session.revert.stage(input),
222
+ revertClear: async (sessionID: string) => (await requireOwnClient(kind)).session.revert.clear({ sessionID }),
223
+ revertCommit: async (sessionID: string) => (await requireOwnClient(kind)).session.revert.commit({ sessionID }),
224
+ }
225
+ : {}),
189
226
  createSession: async ({ directory, title }) =>
190
227
  unwrap<{ id: string }>(await session.create({ location: { directory }, ...(title ? { title } : {}) }), (v) => typeof v.id === "string"),
191
228
  ...(mcp?.list ? { mcpList: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await mcp.list({ location: { directory } }), Array.isArray) } : {}),
229
+ // Hub profiles (src/spoke/profile-apply.ts): local skills/commands win over a profile's; its own need the tool.
230
+ ...(skill?.list && command?.list
231
+ ? {
232
+ localNames: async (directory: string) => {
233
+ const [skills, commands] = await Promise.all([skill.list({ location: { directory } }), command.list({ location: { directory } })])
234
+ return { skills: names(skills), commands: names(commands) }
235
+ },
236
+ }
237
+ : {}),
238
+ profileTool: typeof (ctx as any).tool?.transform === "function",
192
239
  ...(vcs?.diff ? { vcsDiff: async (directory: string) => unwrap<ReadonlyArray<unknown>>(await vcs.diff({ mode: "working", location: { directory } }), Array.isArray) } : {}),
193
240
  ...(vcs?.get
194
241
  ? {
@@ -250,11 +297,13 @@ export default definePlugin({
250
297
  const common = { machineName: hostname(), kind, processID, pid: process.pid, pluginVersion: pluginVersion() }
251
298
  if (source.kind === "launch") {
252
299
  const nodeConfig = source.nodeConfig
253
- return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig) })
300
+ return new Spoke(host, { ...common, configFile: source.file, launch: source.launch, workspaceRoots: () => readWorkspaceRoots(nodeConfig), conciergeLockFile: conciergeLockFile() })
254
301
  }
255
302
  return new Spoke(host, {
256
303
  ...common,
257
304
  configFile: prepareConfigPath(),
305
+ // Concierge session locks survive a restart before the hub reconnects (src/spoke/session-context.ts).
306
+ conciergeLockFile: conciergeLockFile(),
258
307
  // Loopback servers only, and only when autoStartLocalHub is on (see src/spoke/localhub.ts).
259
308
  localHub: (server) => ensureLocalHub(server),
260
309
  // One Herdr reader per machine: the background service's spoke (see src/spoke/herdr.ts). Launch creates
@@ -269,12 +318,21 @@ export default definePlugin({
269
318
  // Every registration is independent: they run together, so OpenCode's plugin activation (bounded at ~5 s on a
270
319
  // cold start) waits for about one of them, not their sum.
271
320
  const registrations: Promise<void>[] = [
272
- attempt("register the voice agent", () => ctx.agent.transform((editor) => defineVoiceAgent(editor as any))),
321
+ attempt("register the voice and concierge agents", () =>
322
+ ctx.agent.transform((editor) => {
323
+ defineVoiceAgent(editor as any)
324
+ defineConciergeAgent(editor as any)
325
+ }),
326
+ ),
273
327
  attempt("register the context hook", () =>
274
328
  ctx.session.hook("context", (event) => {
275
329
  const spoke = membership.active()
276
- if (spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
277
- const parts = orchestratorParts({
330
+ // (CON-04) A locked session (the Concierge) keeps its agent: no voice style, no orchestrator parts.
331
+ const locked = spoke?.contexts.locked(event.sessionID) ?? false
332
+ // The hub hears which agent a Concierge session runs on (it suspends one moved to another agent).
333
+ if (locked) spoke?.noteAgent(event.sessionID, event.agent)
334
+ if (!locked && spoke?.isVoiceTurn(event.sessionID)) event.system.push({ type: "text", text: VOICE_STYLE })
335
+ const parts = locked ? [] : orchestratorParts({
278
336
  agent: event.agent,
279
337
  conversation: spoke?.inConversation(event.sessionID) ?? false,
280
338
  subagent: spoke?.subagent() ?? DEFAULT_SUBAGENT,
@@ -282,6 +340,15 @@ export default definePlugin({
282
340
  taskBoard: spoke?.taskBoard(event.sessionID),
283
341
  })
284
342
  for (const text of parts) event.system.push({ type: "text", text })
343
+ // A hub profile: after OpenCode's own parts (local instructions win), for this session only.
344
+ for (const text of spoke?.profiles.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
345
+ if (event.tools && !spoke?.profiles.toolVisible(event.sessionID)) delete event.tools[PROFILE_TOOL]
346
+ // (CON-04) The hub's session context: its parts after the profile's, its denied tools removed.
347
+ for (const text of spoke?.contexts.systemParts(event.sessionID) ?? []) event.system.push({ type: "text", text })
348
+ if (event.tools) spoke?.contexts.filterTools(event.sessionID, event.tools as Record<string, unknown>)
349
+ // The concierge agent keeps exactly CONCIERGE_TOOLS even without a hub context (a spoke that restarted before
350
+ // the hub re-sent it): OpenCode 2.2.3 offers `skill` whatever the agent's rules say.
351
+ if (event.tools && event.agent === CONCIERGE_AGENT) for (const name of Object.keys(event.tools)) if (!(CONCIERGE_TOOLS as readonly string[]).includes(name)) delete event.tools[name]
285
352
  }),
286
353
  ),
287
354
  // The voice agent stops one worker ("cancel the deploy one") without stopping itself or the others.
@@ -321,6 +388,21 @@ export default definePlugin({
321
388
  }),
322
389
  ),
323
390
  ),
391
+ // A hub profile's skills and commands, for the calling session only (src/spoke/profile-apply.ts).
392
+ attempt("register the hub profile tool", () =>
393
+ (ctx as any).tool?.transform?.((editor: any) =>
394
+ editor.add({
395
+ name: PROFILE_TOOL,
396
+ description: PROFILE_TOOL_DESCRIPTION,
397
+ input: PROFILE_TOOL_INPUT,
398
+ options: { codemode: false },
399
+ execute: async (value: unknown, context: { sessionID: string }) => {
400
+ const spoke = membership.active()
401
+ return { content: spoke ? spoke.profiles.runTool(context.sessionID, value) : "The hub is not running in this OpenCode process; no hub profile is available." }
402
+ },
403
+ }),
404
+ ),
405
+ ),
324
406
  // @name / @@role in a prompt get context from the hub as an attached text file; the text is never changed
325
407
  // and the harness's own @ meanings win (src/spoke/prompt-refs.ts, design §2.4).
326
408
  attempt("register the prompt hook", () => {
@@ -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"