@huynle/hub 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -4,12 +4,13 @@
4
4
  // out an app token of that user, once.
5
5
  //
6
6
  // Policy (the machine enrollment's, packages/cli/src/bootstrap/enrollment/policy.ts):
7
- // - The hub URL is https (plain http only for a loopback hub); redirects are refused, never followed.
8
- // - The approval link shown is on the hub's own origin (approvalURL).
7
+ // - The hub URL is https (plain http only for a loopback hub, or with allowInsecureHTTP a private-network IP address,
8
+ // with a warning each time); redirects are refused, never followed.
9
+ // - The approval link shown is on the hub's own origin, or a loopback hub's public URL (approvalURL).
9
10
  // - The wait is bounded by the hub's expiry and by maxWaitMs; `signal` cancels it.
10
11
  // - The token is returned to the caller only, never printed. A poll that fails in transit is retried until the
11
12
  // deadline (a reply lost after the hub handed the token out cannot be fetched again: the user revokes that token).
12
- import { approvalURL, checkServerURL, isUserCode } from "./bootstrap/enrollment/policy.ts"
13
+ import { approvalURL, checkServerURL, insecureHTTPRefusal, insecureHTTPWarning, isLoopbackServer, isPlainHTTP, isUserCode, shownURL } from "./bootstrap/enrollment/policy.ts"
13
14
  import { UsageError } from "./args"
14
15
  import { CliError, EXIT } from "./http"
15
16
 
@@ -26,6 +27,10 @@ export interface LoginPrompt {
26
27
  /** The scopes the approval page offers ticked. */
27
28
  readonly scopes: readonly string[]
28
29
  readonly warning?: string
30
+ /** A loopback hub's own link on this machine, when approveURL is its public URL. */
31
+ readonly localURL?: string
32
+ /** Plain http to a private-network hub (--allow-insecure-http): said every time. */
33
+ readonly insecure?: string
29
34
  }
30
35
 
31
36
  export interface DeviceLoginOptions {
@@ -34,6 +39,8 @@ export interface DeviceLoginOptions {
34
39
  readonly clientName: string
35
40
  /** Asked for beyond the hub's defaults (sessions:read, messages:send, messages:read). */
36
41
  readonly scopes?: readonly string[]
42
+ /** --allow-insecure-http: also plain http to a private-network IP address (with a warning). Never stored. */
43
+ readonly allowInsecureHTTP?: boolean
37
44
  readonly fetch?: typeof fetch
38
45
  readonly signal?: AbortSignal
39
46
  readonly now?: () => number
@@ -90,8 +97,13 @@ const hubMessage = (body: unknown): string | undefined => (isRecord(body) && isR
90
97
 
91
98
  /** Runs the device-code login: the approved app token, or a CliError (nothing to store). */
92
99
  export async function deviceLogin(options: DeviceLoginOptions): Promise<DeviceLogin> {
93
- const server = checkServerURL(options.server)
94
- if (!server) throw new UsageError(`the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${String(options.server).slice(0, 200)}`)
100
+ const allow = { allowInsecureHTTP: options.allowInsecureHTTP === true }
101
+ const server = checkServerURL(options.server, allow)
102
+ if (!server) {
103
+ const why = insecureHTTPRefusal(options.server, allow)
104
+ throw new UsageError(why ? `${why}: ${shownURL(String(options.server))}` : `the hub URL must be https (plain http only for a loopback hub), without user info, query or fragment: ${shownURL(String(options.server))}`)
105
+ }
106
+ const insecure = isPlainHTTP(server) && !isLoopbackServer(server) ? insecureHTTPWarning(server) : undefined
95
107
  const fetcher = options.fetch ?? fetch
96
108
  const now = options.now ?? Date.now
97
109
  const { signal } = options
@@ -106,7 +118,7 @@ export async function deviceLogin(options: DeviceLoginOptions): Promise<DeviceLo
106
118
  }
107
119
  const link = approvalURL(server, start.approveURL, start.userCode)
108
120
  const expiresAt = Math.min(start.expiresAt as number, now() + (options.maxWaitMs ?? LOGIN_MAX_WAIT_MS))
109
- options.onPrompt?.({ userCode: start.userCode, approveURL: link.url, expiresAt, scopes: stringList(start.scopes) ?? [], ...(link.warning ? { warning: link.warning } : {}) })
121
+ options.onPrompt?.({ userCode: start.userCode, approveURL: link.url, expiresAt, scopes: stringList(start.scopes) ?? [], ...(link.warning ? { warning: link.warning } : {}), ...(link.localURL ? { localURL: link.localURL } : {}), ...(insecure ? { insecure } : {}) })
110
122
 
111
123
  const interval = options.intervalMs ?? Math.min(Math.max(start.intervalMs as number, MIN_INTERVAL_MS), MAX_INTERVAL_MS)
112
124
  for (;;) {
@@ -140,8 +152,10 @@ export async function deviceLogin(options: DeviceLoginOptions): Promise<DeviceLo
140
152
  export function promptText(prompt: LoginPrompt): string {
141
153
  const minutes = Math.max(1, Math.round((prompt.expiresAt - Date.now()) / 60_000))
142
154
  return [
155
+ ...(prompt.insecure ? [`hub: warning: ${prompt.insecure}`] : []),
143
156
  ...(prompt.warning ? [`hub: warning: ${prompt.warning}`] : []),
144
157
  `To log in, open ${prompt.approveURL}`,
158
+ ...(prompt.localURL ? [`(on this machine: ${prompt.localURL})`] : []),
145
159
  `and approve code ${prompt.userCode} as yourself (expires in ${minutes} min; Ctrl-C cancels).`,
146
160
  ...(prompt.scopes.length ? [`Requested scopes: ${prompt.scopes.join(", ")} (you choose on the page).`] : []),
147
161
  "Waiting for approval…",
@@ -2,17 +2,32 @@
2
2
  // the CLI keeps no dependencies. It answers initialize, ping, tools/list and tools/call; notifications get no answer.
3
3
  // The tools are the CLI's verbs: reading, messaging, the act verbs (approve, deny, answer, prompt, stop; an app
4
4
  // token needs scope sessions:act and a grant) and launches (launch, launches, launch_show, launch_stop; an app
5
- // token needs scope sessions:launch and a launch grant).
5
+ // token needs scope sessions:launch and a launch grant), and artifacts (artifacts, artifact_show, artifact_publish,
6
+ // artifact_revise; scopes artifacts:read|write, by reference: packages/cli/src/artifacts-mcp.ts), artifact AI jobs
7
+ // (artifact_job_request, artifact_jobs, artifact_job, artifact_job_context, artifact_job_result, artifact_job_apply,
8
+ // artifact_job_cancel; scope artifacts:ai: packages/cli/src/artifact-jobs-mcp.ts), and team intake
9
+ // (team_submit, team_intake, team_intake_update; scope teams:submit: packages/cli/src/team-intake-mcp.ts), and teams
10
+ // (teams, team_show, team_status, team_create, team_publish, team_staff, …; scopes teams:read|manage: teams-mcp.ts),
11
+ // the profile lifecycle (profile_show, profile_create, profile_update, profile_version_create, profile_skill_set, …,
12
+ // profile_assign; scopes profiles:read|manage|assign: profiles-mcp.ts), and the Concierge watchlist (watchlist_get,
13
+ // watchlist_update, watchlist_watch, watchlist_mute: watchlist.ts).
6
14
  import { renderEnvelope, sanitizeText, WAIT_POLL_MAX_S, type AgentMessage, type Asked, type DecisionState, type FormAnswer, type HubClient, type LaunchSpec, type LaunchState, type PromptDelivery, type Inbox, type Sent, type SentAll } from "../../sdk/src/index.ts"
7
15
  import { answeredText, approvedText, DELIVERIES, deniedText, needsUserLines, promptedText, stoppedText } from "./acting"
8
16
  import { UsageError } from "./args"
17
+ import { intentLine, writeIntent } from "./intents"
9
18
  import type { Credentials } from "./credentials"
10
19
  import { logText, oneLine, sessionsText, showText, statusText, whoamiText, whoText } from "./format"
11
20
  import { CliError, withHub } from "./http"
12
- import { askOutcome, declinedText, durationArg, mutedText, repliedText, sentText } from "./messaging"
21
+ import { askOutcome, declinedText, deliveryText, durationArg, mutedText, repliedText, sentText } from "./messaging"
13
22
  import { DECISION_STATES, decisionsText, decisionText, openedText } from "./decisions"
14
23
  import { CLEANUP_POLICIES, LAUNCH_END_WHEN, RUNTIME_KINDS, WORKSPACE_MODES } from "../../protocol/src/launch.ts"
15
24
  import { launchedText, launchesText, launchText, stoppedLaunchText } from "./launches"
25
+ import { artifactTools } from "./artifacts-mcp"
26
+ import { artifactJobTools } from "./artifact-jobs-mcp"
27
+ import { teamIntakeTools } from "./team-intake-mcp"
28
+ import { teamTools } from "./teams-mcp"
29
+ import { ProfileArgError, profileLifecycleTools } from "./profiles-mcp"
30
+ import { watchlistTools } from "./watchlist"
16
31
 
17
32
  /** The protocol revisions the server speaks, newest first: a client's supported one is echoed, else the newest. */
18
33
  export const MCP_PROTOCOL_VERSIONS: readonly string[] = ["2025-06-18", "2025-03-26", "2024-11-05"]
@@ -192,7 +207,8 @@ function inboxText(box: Inbox): string {
192
207
 
193
208
  /** One message (message_show): its state, the message and, once answered or declined, the answer, in envelopes. */
194
209
  function messageText({ message, answer }: Asked): string {
195
- const lines = [NOT_FROM_USER, `${message.kind} ${message.id}: ${message.state}`, envelope(message)]
210
+ // (CON-10) A message dropped for a recipient that ended for good says so.
211
+ const lines = [NOT_FROM_USER, `${message.kind} ${message.id}: ${message.state}${message.delivery === "dropped" || message.delivery === "expired" ? ` · ${deliveryText(message.delivery)}` : ""}`, envelope(message)]
196
212
  if (answer) lines.push(`${message.state} by ${answer.id}:`, envelope(answer))
197
213
  return lines.join("\n")
198
214
  }
@@ -205,7 +221,7 @@ const SEND = {
205
221
  urgent: { type: "boolean", description: "Steer into the recipient's running turn." },
206
222
  all: { type: "boolean", description: "Send to every session the address names (each holder of a role)." },
207
223
  } as const
208
- const TO = { type: "string", description: "The recipient: @name, @@role, @name.project, ses_…, or @me (the user)." } as const
224
+ const TO = { type: "string", description: "The recipient: @name, @@role, @@role#team (a role of one of your teams), @name.project, ses_…, or @me (the user)." } as const
209
225
  const MSG_ID = { type: "string", description: "The ID (msg_…) of the message you answer." } as const
210
226
  const ADDR = { type: "string", description: "A session: ses_… (or an unambiguous prefix), @name, @@role or @name.project." } as const
211
227
  const WHY = { type: "string", description: "Why: recorded in the agent audit (and for deny, passed to the harness)." } as const
@@ -262,6 +278,34 @@ const archiveInactiveTool: Tool = {
262
278
  },
263
279
  }
264
280
 
281
+ const intentTools: readonly Tool[] = [
282
+ {
283
+ name: "session_intent_get",
284
+ description: "What a session is for: its intent (user, launch, self, first prompt; else its title), as untrusted one-line text. Needs sessions:read for an app token.",
285
+ properties: { addr: ADDR },
286
+ required: ["addr"],
287
+ readOnly: true,
288
+ run: async (hub, args, signal) => intentLine(need(args, "addr"), (await hub.intent(need(args, "addr"), { signal })).intent),
289
+ },
290
+ {
291
+ name: "session_intent_set",
292
+ description: "Set (text, one line, at most 280 characters) or clear (clear: true) a session's intent. Users and apps (sessions:manage) set the user's intent; a session token only its own session's. expectedRevision: the revision read (read first when absent); a stale one is a conflict.",
293
+ properties: { addr: ADDR, text: STRING, clear: { type: "boolean" }, expectedRevision: { type: "integer", minimum: 0 } },
294
+ required: ["addr"],
295
+ readOnly: false,
296
+ organizes: true,
297
+ idempotent: true,
298
+ run: async (hub, args, signal) => {
299
+ const addr = need(args, "addr")
300
+ const text = optional(args, "text")
301
+ const clear = args.clear === true
302
+ if (clear === (text !== undefined)) throw new ArgError('Give "text" or "clear": true')
303
+ const expectedRevision = optionalInt(args, "expectedRevision", 0, Number.MAX_SAFE_INTEGER)
304
+ return intentLine(addr, await writeIntent(hub, addr, clear ? null : text!, { signal, ...(expectedRevision !== undefined ? { expectedRevision } : {}) }))
305
+ },
306
+ },
307
+ ]
308
+
265
309
  const TARGET = { ...ADDR, description: `${ADDR.description} Required for an app token; a session token defaults to itself.` } as const
266
310
  const ROLE = { ...STRING, description: "A role (reviewer, @@reviewer or reviewer.project)." } as const
267
311
  const ROLE_PROJECT = { ...STRING, description: "Scope the role to this project (as role.project)." } as const
@@ -271,7 +315,7 @@ const roleText = (r: { role: string; project?: string }) => `@@${r.role}${r.proj
271
315
  const nameTools: readonly Tool[] = [
272
316
  {
273
317
  name: "name",
274
- description: `Name a session (@name); its previous name stays an alias for 24 h. A taken name fails (name_taken). ${MANAGE_NOTE}.`,
318
+ description: `Name a session (@name); as a session, leave addr out to name yourself (a session token names only its own session). Its previous name stays an alias for 24 h. A taken name fails (name_taken); @me is reserved. ${MANAGE_NOTE}.`,
275
319
  properties: { addr: TARGET, name: { ...STRING, description: "The name: 2–40 characters a-z, 0-9 and - (a leading @ is fine)." } },
276
320
  required: ["name"],
277
321
  readOnly: false,
@@ -285,7 +329,7 @@ const nameTools: readonly Tool[] = [
285
329
  },
286
330
  {
287
331
  name: "role_claim",
288
- description: `Claim a role for a session; exclusive fails while another session holds it. Reserved roles (orchestrator…) are the user's to assign. ${MANAGE_NOTE}, plus a grant allowing roles on the session's project.`,
332
+ description: `Claim a role for a session (as a session, leave addr out to claim it yourself so @@role reaches you); exclusive fails while another session holds it. Reserved roles (orchestrator…) are the user's to assign. ${MANAGE_NOTE}, plus a grant allowing roles on the session's project.`,
289
333
  properties: { addr: TARGET, role: ROLE, project: ROLE_PROJECT, exclusive: { type: "boolean", description: "Be the only holder." } },
290
334
  required: ["role"],
291
335
  readOnly: false,
@@ -366,15 +410,338 @@ const webhookTools: readonly Tool[] = [
366
410
  },
367
411
  ]
368
412
 
413
+ /** The session a profile tool reads: `addr`, else the caller's own session (session tokens). */
414
+ async function profileTarget(hub: HubClient, args: Args, signal: AbortSignal): Promise<string> {
415
+ const addr = optional(args, "addr")
416
+ if (addr) return addr
417
+ const { actor } = await hub.whoami({ signal })
418
+ if (actor.kind !== "session") throw new ArgError(`"addr" is required unless this credential is a session's`)
419
+ return actor.sessionID
420
+ }
421
+ const optionalParams = (args: Args): Record<string, string> => optionalLabels(args, "params") ?? {}
422
+ const profileTools: readonly Tool[] = [
423
+ {
424
+ name: "profile",
425
+ description: "The profile pinned to a session (your own by default): instructions present, skill and command names with descriptions, never bodies. Load a skill with profile_skill. A profile confers no authority.",
426
+ properties: { addr: ADDR }, readOnly: true,
427
+ run: async (hub, args, signal) => {
428
+ const sessionID = await profileTarget(hub, args, signal)
429
+ const r = await hub.profiles.ofSession(sessionID, { signal })
430
+ if (!r.assignment) return `${sessionID} has no profile`
431
+ const v = r.version
432
+ const lines = [`${r.assignment.sessionID} pins ${r.assignment.profileID} v${r.assignment.version}${r.profile ? ` (${r.profile.name}: ${oneLine(sanitizeText(r.profile.title))})` : ""}${r.assignment.ack ? `, ack ${r.assignment.ack.status}` : ""}`]
433
+ if (v) {
434
+ lines.push(`instructions: ${v.instructions ? "yes" : "no"}`)
435
+ if (v.skills.length) lines.push("skills:", ...v.skills.map((k) => ` ${k.name}: ${oneLine(sanitizeText(k.description))}`))
436
+ if (v.commands.length) lines.push("commands:", ...v.commands.map((c) => ` ${c.name}(${c.params.join(", ")}): ${oneLine(sanitizeText(c.description))}`))
437
+ if (v.secretRefs.length) lines.push(`secret references: ${v.secretRefs.join(", ")}`)
438
+ } else lines.push("the pinned version is no longer retained")
439
+ return lines.join("\n")
440
+ },
441
+ },
442
+ {
443
+ name: "profile_skill",
444
+ description: "Load one skill's full text from the profile pinned to a session (your own by default), on demand.",
445
+ properties: { name: STRING, addr: ADDR }, required: ["name"], readOnly: true,
446
+ run: async (hub, args, signal) => {
447
+ const r = await hub.profiles.sessionSkill(await profileTarget(hub, args, signal), need(args, "name"), { signal })
448
+ return `skill ${r.skill.name} (${r.profileID} v${r.version}):\n${sanitizeText(r.skill.body)}`
449
+ },
450
+ },
451
+ {
452
+ name: "profile_command",
453
+ description: "Render one command of the profile pinned to a session (your own by default) with its {{name}} parameters. Returns the text only: nothing is sent. {{secret:NAME}} references stay unresolved.",
454
+ properties: { name: STRING, params: { type: "object", additionalProperties: STRING, description: "Parameter name → text value." }, addr: ADDR }, required: ["name"], readOnly: true,
455
+ run: async (hub, args, signal) => {
456
+ const r = await hub.profiles.sessionRender(await profileTarget(hub, args, signal), need(args, "name"), optionalParams(args), { signal })
457
+ return r.prompt
458
+ },
459
+ },
460
+ {
461
+ name: "profiles",
462
+ description: "List the user's profiles (id, name, latest version, state). Needs scope profiles:read for an app token; session tokens read only their own pinned profile (tool profile).",
463
+ properties: { state: { type: "string", enum: ["active", "archived", "all"] } }, readOnly: true,
464
+ run: async (hub, args, signal) => {
465
+ const state = optionalEnum(args, "state", ["active", "archived", "all"] as const)
466
+ const r = await hub.profiles.list({ ...(state ? { state } : {}), limit: 200, signal })
467
+ return r.items.length ? r.items.map((p) => `${p.id} ${p.name} v${p.latestVersion} ${p.state} ${oneLine(sanitizeText(p.title))}`).join("\n") : "no profiles"
468
+ },
469
+ },
470
+ {
471
+ name: "profile_usage",
472
+ description:
473
+ "Where one profile is used: pinned sessions (requested vs running version, ack, presence, harness), team roles naming it, launches that named it and the sessions' workspaces, with counts. An app token needs profiles:read, plus sessions:read / teams:read / sessions:launch (its own launches) / workspaces:read for each part; parts it may not read are listed as hidden.",
474
+ properties: { id: STRING, limit: { type: "integer", minimum: 1, maximum: 200, description: "Sessions per page (default 50)." }, cursor: STRING }, required: ["id"], readOnly: true,
475
+ run: async (hub, args, signal) => {
476
+ const limit = optionalInt(args, "limit", 1, 200)
477
+ const cursor = optional(args, "cursor")
478
+ const u = await hub.profiles.usage(need(args, "id"), { ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}), signal })
479
+ const c = u.counts
480
+ const plural = (n: number, word: string, many = `${word}s`) => `${n} ${n === 1 ? word : many}`
481
+ const lines = [`${u.profileID} (latest v${u.latestVersion})`]
482
+ const summary = [
483
+ c.sessions !== undefined ? `${plural(c.sessions, "session")} (${c.online ?? 0} online, ${c.olderVersion ?? 0} on older versions)` : "",
484
+ c.teamRoles !== undefined ? `${plural(c.teamRoles, "team role")}${c.staffedSeats !== undefined ? ` (${c.staffedSeats} staffed seats)` : ""}` : "",
485
+ c.launches !== undefined ? `${plural(c.launches, "launch", "launches")} (${c.liveLaunches ?? 0} live)` : "",
486
+ c.workspaces !== undefined ? plural(c.workspaces, "workspace") : "",
487
+ ].filter(Boolean)
488
+ if (summary.length) lines.push(summary.join(" · "))
489
+ for (const s of u.sessions?.items ?? []) {
490
+ const running = s.effective && s.effective.version !== s.version ? `, running v${s.effective.version}` : ""
491
+ const who = s.name ? ` @${s.name}` : ""
492
+ lines.push(` session ${s.sessionID}${who} v${s.version}${s.olderVersion ? " (older)" : ""}${running}${s.ack ? `, ack ${s.ack.status}` : ""}, ${s.online ? "online" : "offline"}${s.harness ? `, ${s.harness}` : ""}${s.machineID ? ` on ${s.machineID}` : ""}`)
493
+ }
494
+ if (u.sessions?.cursor) lines.push(` more sessions: cursor ${u.sessions.cursor}`)
495
+ for (const r of u.teamRoles ?? []) lines.push(` team ${r.teamName} role ${r.role}${r.version !== undefined ? ` v${r.version}` : " (latest)"}${r.staffed !== undefined ? `: ${r.staffed} staffed` : ""}`)
496
+ for (const l of u.launches ?? []) lines.push(` launch ${l.id} ${l.state}${l.outcome ? ` (${l.outcome})` : ""} v${l.version}${l.sessionID ? ` → ${l.sessionID}` : ""}`)
497
+ for (const w of u.workspaces ?? []) lines.push(` workspace ${w.workspaceID}${w.title ? ` ${oneLine(sanitizeText(w.title))}` : ""}: ${plural(w.sessions, "session")}`)
498
+ if (u.projects?.length) lines.push(`projects: ${u.projects.map((p) => oneLine(sanitizeText(p))).join(", ")}`)
499
+ if (u.hidden?.length) lines.push(`hidden: ${u.hidden.join(", ")}`)
500
+ if (u.truncated?.length) lines.push(`truncated: ${u.truncated.join(", ")}`)
501
+ return lines.join("\n")
502
+ },
503
+ },
504
+ ]
505
+
506
+ /** A required whole-number argument (revisions). */
507
+ const needRevision = (args: Args, key = "expectedRevision"): number => {
508
+ const value = optionalInt(args, key, 0, Number.MAX_SAFE_INTEGER)
509
+ if (value === undefined) throw new ArgError(`"${key}" is required: the revision you read`)
510
+ return value
511
+ }
512
+ const idemOption = (args: Args) => {
513
+ const key = optional(args, "idempotencyKey")
514
+ return key !== undefined ? { idempotencyKey: key } : {}
515
+ }
516
+ const WORKSPACE = { type: "string", description: "The workspace's ID (ws_…)." } as const
517
+ const REVISION = { type: "integer", minimum: 0, description: "The revision you read (a stale one answers conflict with the current revision)." } as const
518
+ const IDEMPOTENCY = { type: "string", description: "Idempotency-Key: the same key and arguments within 24 h answer the first result again." } as const
519
+ const MEMBER_ROLES = ["worker", "observer"] as const
520
+ const ASSIGNMENT_STATUSES = ["open", "in_progress", "blocked", "done", "cancelled"] as const
521
+ const WS_SCOPES = "Needs scope workspaces:read (reads) or workspaces:manage (changes) for an app token; member and assignment tools also need sessions:read. Session tokens are refused. Membership and supervision confer no authority."
522
+ const wsLine = (w: { id: string; state: string; revision: number; title: string }) => `${w.id} ${w.state} r${w.revision} ${oneLine(sanitizeText(w.title))}`
523
+ const workspaceTools: readonly Tool[] = [
524
+ {
525
+ name: "workspaces",
526
+ description: `List the user's coordination workspaces (id, state, revision, title). ${WS_SCOPES}`,
527
+ properties: { state: { type: "string", enum: ["active", "archived"] } }, readOnly: true,
528
+ run: async (hub, args, signal) => {
529
+ const state = optionalEnum(args, "state", ["active", "archived"] as const)
530
+ const r = await hub.workspaces.list({ ...(state ? { state } : {}), limit: 200, signal })
531
+ return r.items.length ? r.items.map(wsLine).join("\n") : "no workspaces"
532
+ },
533
+ },
534
+ {
535
+ name: "workspace_show",
536
+ description: `One workspace: its record, repo bindings, current members (session, role, presence) and supervision links with the graph revision. ${WS_SCOPES}`,
537
+ properties: { workspace: WORKSPACE }, required: ["workspace"], readOnly: true,
538
+ run: async (hub, args, signal) => {
539
+ const id = need(args, "workspace")
540
+ const [{ workspace, bindings }, { members }, graph] = await Promise.all([hub.workspaces.get(id, { signal }), hub.workspaces.members(id, { signal }), hub.workspaces.supervision(id, { signal })])
541
+ const lines = [wsLine(workspace)]
542
+ if (workspace.masterMemberID) lines.push(`master: ${workspace.masterMemberID}`)
543
+ if (bindings.length) lines.push("bindings:", ...bindings.map((b) => ` ${b.id} ${b.machineID}:${b.repo} r${b.revision}`))
544
+ lines.push(members.length ? "members:" : "no current members", ...members.map((m) => ` ${m.member.id} ${m.member.sessionID} ${m.member.role} ${m.presence} r${m.member.revision}`))
545
+ lines.push(`supervision revision ${graph.revision}`, ...graph.links.map((l) => ` ${l.link.memberID} ← ${l.link.supervisorMemberID} order ${l.link.order}${l.member !== "current" || l.supervisor !== "current" ? " (stale)" : ""}`))
546
+ return lines.join("\n")
547
+ },
548
+ },
549
+ {
550
+ name: "workspace_create",
551
+ description: `Create a coordination workspace. ${WS_SCOPES}`,
552
+ properties: { title: STRING, description: STRING, tags: { type: "array", items: STRING }, idempotencyKey: IDEMPOTENCY }, required: ["title"], readOnly: false,
553
+ run: async (hub, args, signal) => {
554
+ const description = optional(args, "description")
555
+ const tags = optionalStrings(args, "tags")
556
+ const { workspace } = await hub.workspaces.create({ title: need(args, "title"), ...(description ? { description } : {}), ...(tags ? { tags } : {}) }, { ...idemOption(args), signal })
557
+ return `created ${wsLine(workspace)}`
558
+ },
559
+ },
560
+ {
561
+ name: "workspace_update",
562
+ description: `Change a workspace's title, description or tags under its revision. ${WS_SCOPES}`,
563
+ properties: { workspace: WORKSPACE, expectedRevision: REVISION, title: STRING, description: STRING, tags: { type: "array", items: STRING } }, required: ["workspace", "expectedRevision"], readOnly: false,
564
+ run: async (hub, args, signal) => {
565
+ const patch = definedFields({ title: optional(args, "title"), description: optional(args, "description"), tags: optionalStrings(args, "tags") })
566
+ if (!patch) throw new ArgError(`give "title", "description" or "tags"`)
567
+ const { workspace } = await hub.workspaces.update(need(args, "workspace"), { expectedRevision: needRevision(args), ...patch }, { signal })
568
+ return `updated ${wsLine(workspace)}`
569
+ },
570
+ },
571
+ ...(["archive", "restore"] as const).map((verb): Tool => ({
572
+ name: `workspace_${verb}`,
573
+ description: verb === "archive" ? `Archive a workspace under its revision: current members depart and their supervision links are detached. ${WS_SCOPES}` : `Restore an archived workspace under its revision (nobody re-joins). ${WS_SCOPES}`,
574
+ properties: { workspace: WORKSPACE, expectedRevision: REVISION }, required: ["workspace", "expectedRevision"], readOnly: false, idempotent: true,
575
+ run: async (hub, args, signal) => {
576
+ const id = need(args, "workspace")
577
+ if (verb === "archive") {
578
+ const r = await hub.workspaces.archive(id, needRevision(args), { signal })
579
+ return `archived ${wsLine(r.workspace)}; ${r.departed} member(s) departed, ${r.supervision.detached} supervision link(s) detached`
580
+ }
581
+ return `restored ${wsLine((await hub.workspaces.restore(id, needRevision(args), { signal })).workspace)}`
582
+ },
583
+ })),
584
+ {
585
+ name: "workspace_bind",
586
+ description: `Bind a workspace to a repository (absolute path) on one of the user's machines. ${WS_SCOPES}`,
587
+ properties: { workspace: WORKSPACE, machineID: STRING, repo: STRING, label: STRING, idempotencyKey: IDEMPOTENCY }, required: ["workspace", "machineID", "repo"], readOnly: false, idempotent: true,
588
+ run: async (hub, args, signal) => {
589
+ const label = optional(args, "label")
590
+ const r = await hub.workspaces.bind(need(args, "workspace"), { machineID: need(args, "machineID"), repo: need(args, "repo"), ...(label ? { label } : {}) }, { ...idemOption(args), signal })
591
+ return `${r.created ? "bound" : "already bound"} ${r.binding.id} ${r.binding.machineID}:${r.binding.repo} r${r.binding.revision}`
592
+ },
593
+ },
594
+ {
595
+ name: "workspace_unbind",
596
+ description: `Remove a repository binding under the binding's revision. ${WS_SCOPES}`,
597
+ properties: { workspace: WORKSPACE, bindingID: STRING, expectedRevision: REVISION }, required: ["workspace", "bindingID", "expectedRevision"], readOnly: false,
598
+ run: async (hub, args, signal) => {
599
+ await hub.workspaces.unbind(need(args, "workspace"), need(args, "bindingID"), needRevision(args), { signal })
600
+ return `unbound ${need(args, "bindingID")}`
601
+ },
602
+ },
603
+ {
604
+ name: "workspace_join",
605
+ description: `Add a session (ses_…) to a workspace as worker or observer (the master is set by explicit replacement). ${WS_SCOPES}`,
606
+ properties: { workspace: WORKSPACE, sessionID: STRING, role: { type: "string", enum: [...MEMBER_ROLES] }, idempotencyKey: IDEMPOTENCY }, required: ["workspace", "sessionID", "role"], readOnly: false, idempotent: true,
607
+ run: async (hub, args, signal) => {
608
+ const role = optionalEnum(args, "role", MEMBER_ROLES)
609
+ if (!role) throw new ArgError(`"role" is one of ${MEMBER_ROLES.join(", ")}`)
610
+ const { member, created } = await hub.workspaces.join(need(args, "workspace"), { sessionID: need(args, "sessionID"), role }, { ...idemOption(args), signal })
611
+ return `member ${member.id} (${member.sessionID}, ${member.role}, generation ${member.generation}, r${member.revision}) ${created ? "joined" : "was already a member"}`
612
+ },
613
+ },
614
+ {
615
+ name: "workspace_leave",
616
+ description: `Remove a member under its revision; its supervision links are detached. ${WS_SCOPES}`,
617
+ properties: { workspace: WORKSPACE, memberID: STRING, expectedRevision: REVISION }, required: ["workspace", "memberID", "expectedRevision"], readOnly: false, idempotent: true,
618
+ run: async (hub, args, signal) => {
619
+ const r = await hub.workspaces.leave(need(args, "workspace"), need(args, "memberID"), needRevision(args), { signal })
620
+ return `member ${r.member.id} left (${r.member.leftReason ?? "removed"}); ${r.supervision.detached} supervision link(s) detached`
621
+ },
622
+ },
623
+ {
624
+ name: "workspace_assign",
625
+ description: `Give a current member an assignment (title and optional outcome criteria; instructions travel as messages). ${WS_SCOPES}`,
626
+ properties: { workspace: WORKSPACE, memberID: STRING, title: STRING, criteria: { type: "array", items: STRING }, ref: STRING, idempotencyKey: IDEMPOTENCY }, required: ["workspace", "memberID", "title"], readOnly: false,
627
+ run: async (hub, args, signal) => {
628
+ const criteria = optionalStrings(args, "criteria")
629
+ const ref = optional(args, "ref")
630
+ const { assignment } = await hub.workspaces.assign(need(args, "workspace"), { memberID: need(args, "memberID"), title: need(args, "title"), ...(criteria ? { criteria } : {}), ...(ref ? { ref } : {}) }, { ...idemOption(args), signal })
631
+ return `assignment ${assignment.id} → ${assignment.memberID}: ${assignment.status}, r${assignment.revision}`
632
+ },
633
+ },
634
+ {
635
+ name: "workspace_assignments",
636
+ description: `A workspace's assignments (id, member, status, revision, title), optionally one member's or one status. ${WS_SCOPES}`,
637
+ properties: { workspace: WORKSPACE, memberID: STRING, status: { type: "string", enum: [...ASSIGNMENT_STATUSES] } }, required: ["workspace"], readOnly: true,
638
+ run: async (hub, args, signal) => {
639
+ const memberID = optional(args, "memberID")
640
+ const status = optionalEnum(args, "status", ASSIGNMENT_STATUSES)
641
+ const r = await hub.workspaces.assignments(need(args, "workspace"), { ...(memberID ? { memberID } : {}), ...(status ? { status } : {}), limit: 200, signal })
642
+ return r.items.length ? r.items.map((v) => `${v.assignment.id} ${v.assignment.memberID} (${v.member}, ${v.presence}) ${v.assignment.status} r${v.assignment.revision} ${oneLine(sanitizeText(v.assignment.title))}`).join("\n") : "no assignments"
643
+ },
644
+ },
645
+ {
646
+ name: "workspace_assignment_update",
647
+ description: `Set an assignment's status (done and cancelled are final) or move it to another current member, under its revision. ${WS_SCOPES}`,
648
+ properties: { workspace: WORKSPACE, assignmentID: STRING, expectedRevision: REVISION, status: { type: "string", enum: [...ASSIGNMENT_STATUSES] }, memberID: STRING }, required: ["workspace", "assignmentID", "expectedRevision"], readOnly: false,
649
+ run: async (hub, args, signal) => {
650
+ const status = optionalEnum(args, "status", ASSIGNMENT_STATUSES)
651
+ const memberID = optional(args, "memberID")
652
+ if ((status === undefined) === (memberID === undefined)) throw new ArgError(`give exactly one of "status" or "memberID"`)
653
+ const id = need(args, "workspace")
654
+ const assignmentID = need(args, "assignmentID")
655
+ const { assignment } = status !== undefined
656
+ ? await hub.workspaces.setAssignmentStatus(id, assignmentID, status, needRevision(args), { signal })
657
+ : await hub.workspaces.reassign(id, assignmentID, memberID!, needRevision(args), { signal })
658
+ return `assignment ${assignment.id} → ${assignment.memberID}: ${assignment.status}, r${assignment.revision}`
659
+ },
660
+ },
661
+ {
662
+ name: "workspace_supervise",
663
+ description: `Set who supervises a member (supervisor null detaches), under the supervision graph's revision (workspace_show). Acyclic, at most 6 levels. ${WS_SCOPES}`,
664
+ properties: { workspace: WORKSPACE, memberID: STRING, supervisor: { type: ["string", "null"], description: "The supervising member's ID, or null to detach." }, order: { type: "number" }, expectedRevision: REVISION }, required: ["workspace", "memberID", "supervisor", "expectedRevision"], readOnly: false,
665
+ run: async (hub, args, signal) => {
666
+ const supervisor = args.supervisor === null ? null : need(args, "supervisor")
667
+ const order = args.order
668
+ if (order !== undefined && (typeof order !== "number" || !Number.isFinite(order))) throw new ArgError(`"order" must be a number`)
669
+ const r = await hub.workspaces.supervise(need(args, "workspace"), { memberID: need(args, "memberID"), supervisorMemberID: supervisor, ...(order !== undefined ? { order } : {}), expectedRevision: needRevision(args) }, { signal })
670
+ return `${r.change}: ${need(args, "memberID")}${r.link ? ` ← ${r.link.supervisorMemberID} (order ${r.link.order})` : ""}; supervision revision ${r.revision}${r.pruned ? `, ${r.pruned} stale link(s) dropped` : ""}`
671
+ },
672
+ },
673
+ {
674
+ name: "workspace_master",
675
+ description: `A workspace's master: the current master member (session, generation, presence), earlier masters and a replacement in flight. ${WS_SCOPES}`,
676
+ properties: { workspace: WORKSPACE }, required: ["workspace"], readOnly: true,
677
+ run: async (hub, args, signal) => {
678
+ const v = await hub.workspaces.master(need(args, "workspace"), { signal })
679
+ const line = (m: { member: { id: string; sessionID: string; generation: number; leftReason?: string }; presence: string }) => `${m.member.id} ${m.member.sessionID} generation ${m.member.generation} ${m.presence}${m.member.leftReason ? ` (${m.member.leftReason})` : ""}`
680
+ return [
681
+ `workspace ${v.workspaceID} r${v.revision}`,
682
+ v.master ? `master: ${line(v.master)}` : "no master",
683
+ ...(v.pending ? [`replacement in flight: ${v.pending.phase}${v.pending.stale ? " (stale)" : ""}`] : []),
684
+ ...(v.previous.length ? ["earlier masters:", ...v.previous.map((p) => ` ${line(p)}`)] : []),
685
+ ].join("\n")
686
+ },
687
+ },
688
+ {
689
+ name: "workspace_master_replace",
690
+ description: `Replace (or first set) a workspace's master under the workspace revision: a session (ses_…) or a running launch's session (launchID). The old master departs as replaced; its supervised members move to the new one. No grants move: the new master's session needs its own grants. ${WS_SCOPES}`,
691
+ properties: { workspace: WORKSPACE, sessionID: STRING, launchID: STRING, expectedRevision: REVISION, idempotencyKey: IDEMPOTENCY }, required: ["workspace", "expectedRevision"], readOnly: false, idempotent: true,
692
+ run: async (hub, args, signal) => {
693
+ const sessionID = optional(args, "sessionID")
694
+ const launchID = optional(args, "launchID")
695
+ if ((sessionID === undefined) === (launchID === undefined)) throw new ArgError(`give exactly one of "sessionID" or "launchID"`)
696
+ const target = sessionID !== undefined ? { sessionID } : { launchID: launchID! }
697
+ const r = await hub.workspaces.replaceMaster(need(args, "workspace"), { ...target, expectedRevision: needRevision(args) }, { ...idemOption(args), signal })
698
+ if (!r.changed) return `${r.master.sessionID} already is the master (${r.master.id}); nothing changed`
699
+ return [
700
+ `master ${r.master.id} (${r.master.sessionID}, generation ${r.master.generation}, ${r.presence}); workspace r${r.workspace.revision}`,
701
+ ...(r.previous ? [`replaced ${r.previous.id} (${r.previous.sessionID}); ${r.supervision?.transferred ?? 0} supervision link(s) moved, ${r.supervision?.detached ?? 0} detached`] : []),
702
+ r.grants.note,
703
+ ].join("\n")
704
+ },
705
+ },
706
+ {
707
+ name: "workspace_board",
708
+ description: `The workspace's situation board: members with presence, session state and blocker references, supervision, assignments with their explicit status, open Decisions concerning members and artifact references, with stale markers. Bounded and read-only; details (blocker summaries, Decision questions, criteria) stay behind show, decision_show and workspace_assignments. ${WS_SCOPES} The board needs sessions:read; Decisions need messages:read and artifacts artifacts:read, else they are omitted.`,
709
+ properties: { workspace: WORKSPACE }, required: ["workspace"], readOnly: true,
710
+ run: async (hub, args, signal) => {
711
+ const b = await hub.workspaces.board(need(args, "workspace"), { signal })
712
+ const more = (truncated: boolean) => (truncated ? " (more not shown)" : "")
713
+ type Section<T> = { status: "ok"; items: readonly T[]; truncated: boolean } | { status: "omitted"; scope: string } | { status: "unsupported" | "failed" }
714
+ const section = <T,>(name: string, s: Section<T>, line: (item: T) => string): string[] =>
715
+ s.status === "ok" ? [`${name}:${more(s.truncated)}`, ...s.items.map((i) => ` ${line(i)}`)] : [`${name}: ${s.status}${s.status === "omitted" ? ` (needs ${s.scope})` : ""}`]
716
+ const lines = [wsLine(b.workspace), ...(b.workspace.masterMemberID ? [`master: ${b.workspace.masterMemberID}`] : [])]
717
+ lines.push(`members (${b.members.total}):${more(b.members.truncated)}`, ...b.members.items.map((m) => ` ${m.memberID} ${m.sessionID} ${m.role} ${m.presence} ${m.session?.state ?? "?"} blockers ${m.blockers.reported ? m.blockers.count : "not reported"}${m.stale ? ` (stale: ${m.stale})` : ""}`))
718
+ lines.push(`supervision revision ${b.supervision.revision}`, ...b.supervision.links.map((l) => ` ${l.memberID} ← ${l.supervisorMemberID} order ${l.order}`))
719
+ lines.push(`assignments:${more(b.assignments.truncated)}`, ...b.assignments.items.map((a) => ` ${a.id} ${a.memberID} ${a.status} r${a.revision} ${oneLine(sanitizeText(a.title))}${a.stale ? ` (stale: ${a.stale.join(", ")})` : ""}`))
720
+ lines.push(...section("decisions", b.decisions, (d) => `${d.id} ${d.urgency} ${d.memberIDs.join(",")}${d.stale ? ` (${d.stale})` : ""}`))
721
+ lines.push(...section("artifacts", b.artifacts, (a) => `${a.id} ${a.kind} ${a.latestRevisionID} r${a.revision}`))
722
+ return lines.join("\n")
723
+ },
724
+ },
725
+ ]
726
+
369
727
  const TOOLS: readonly Tool[] = [
370
728
  ...archiveTools(true),
371
729
  ...archiveTools(false),
372
730
  ...pinTools(true),
373
731
  ...pinTools(false),
374
732
  archiveInactiveTool,
733
+ ...intentTools,
375
734
  ...nameTools,
376
735
  eventsTool,
377
736
  ...webhookTools,
737
+ ...profileTools,
738
+ ...profileLifecycleTools,
739
+ ...workspaceTools,
740
+ ...artifactTools,
741
+ ...artifactJobTools,
742
+ ...teamIntakeTools,
743
+ ...teamTools,
744
+ ...watchlistTools,
378
745
  {
379
746
  name: "whoami",
380
747
  description: "Who this hub credential is: the app token of `hub login` (or the session of HUB_SESSION_TOKEN) and its user.",
@@ -426,10 +793,17 @@ const TOOLS: readonly Tool[] = [
426
793
  },
427
794
  {
428
795
  name: "who",
429
- description: "Named and role-holding sessions.",
430
- properties: { project: { ...STRING, description: "Only this project." }, role: { ...STRING, description: "Only holders of this role (reviewer or @@reviewer)." } },
796
+ description: "Named and role-holding sessions; a team member sees its teams, grouped by team with its reach to each.",
797
+ properties: {
798
+ project: { ...STRING, description: "Only this project." },
799
+ role: { ...STRING, description: "Only holders of this role (reviewer, @@reviewer or reviewer#team)." },
800
+ team: { ...STRING, description: "Only this team (name or ID)." },
801
+ },
431
802
  readOnly: true,
432
- run: async (hub, args, signal) => whoText((await hub.who({ project: optional(args, "project"), role: optional(args, "role"), signal })).sessions ?? []),
803
+ run: async (hub, args, signal) => {
804
+ const body = await hub.who({ project: optional(args, "project"), role: optional(args, "role"), team: optional(args, "team"), signal })
805
+ return whoText(body.sessions ?? [], body)
806
+ },
433
807
  },
434
808
  {
435
809
  name: "msg",
@@ -710,6 +1084,7 @@ function errorText(error: CliError): string {
710
1084
  if (typeof envelope?.feature === "string") lines.push(`missing feature: ${envelope.feature}${typeof envelope.harness === "string" ? ` (${envelope.harness})` : ""}`)
711
1085
  if (envelope?.code === "needs_user") lines.push(...needsUserLines(envelope))
712
1086
  if (typeof envelope?.retryAfter === "number") lines.push(`retry after ${Math.ceil(envelope.retryAfter)} s`)
1087
+ if (typeof envelope?.currentRevision === "number") lines.push(`current revision ${envelope.currentRevision}`)
713
1088
  if (error.hint) lines.push(`hint: ${error.hint}`)
714
1089
  return lines.join("\n")
715
1090
  }
@@ -768,7 +1143,7 @@ export function createMcpServer(options: McpServerOptions): McpServer {
768
1143
  return controller.signal.aborted ? undefined : ok(id, toolResult(clean(text, token), false))
769
1144
  } catch (cause) {
770
1145
  if (controller.signal.aborted) return undefined // cancelled: no answer (MCP cancellation)
771
- const text = cause instanceof ArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`
1146
+ const text = cause instanceof ArgError || cause instanceof ProfileArgError ? `invalid: ${cause.message}` : cause instanceof CliError ? errorText(cause) : `${tool.name} failed: ${cause instanceof Error ? cause.message : String(cause)}`
772
1147
  return ok(id, toolResult(clean(text, token), true))
773
1148
  } finally {
774
1149
  if (inFlight.get(key(id)) === controller) inFlight.delete(key(id))
@@ -1,6 +1,7 @@
1
1
  // The messaging verbs' helpers (design §3): text arguments (or "-" for stdin) and the short text of what the hub
2
2
  // answers to msg, ask, reply, decline, inbox and mute. The shapes are packages/sdk's types (message-routes.ts).
3
3
  import type { AgentMessage, Asked, Inbox, MessageDelivery, Party, Replied, Sent } from "../../sdk/src/index.ts"
4
+ import { wakeSkipNote } from "../../sdk/src/wake.ts"
4
5
  import { UsageError } from "./args"
5
6
  import { oneLine } from "./format"
6
7
 
@@ -27,7 +28,10 @@ const DELIVERY: Record<MessageDelivery, string> = {
27
28
  steer: "delivered (into the running turn)",
28
29
  queued: "queued (read at its next turn)",
29
30
  retained: "retained (held until its open question is answered)",
30
- pending: "pending (offline; delivered when it reconnects, for 24 h)",
31
+ // Offline recipients, and a --no-wake message to a busy OpenCode session (RF-05); the hub's answer does not say which.
32
+ pending: "pending (offline or busy; delivered when it reconnects or its turn ends, for 24 h)",
33
+ dropped: "not delivered (its recipient session ended)",
34
+ expired: "not delivered (its recipient stayed offline or busy for 24 h)",
31
35
  }
32
36
 
33
37
  export const deliveryText = (delivery: MessageDelivery | undefined): string => (delivery ? (DELIVERY[delivery] ?? delivery) : "stored (in its inbox)")
@@ -38,8 +42,8 @@ function toText(message: AgentMessage): string {
38
42
  return message.to === recipient || message.to === (message.recipient as { sessionID?: string }).sessionID ? recipient : `${message.to} → ${recipient}`
39
43
  }
40
44
 
41
- /** `msg_… to @web-work: delivered (started a turn)` */
42
- export const sentText = ({ message, delivery }: Sent): string => `${message.kind} ${message.id} to ${toText(message)}: ${deliveryText(delivery)}`
45
+ /** `msg_… to @web-work: delivered (started a turn)`; `…: queued (read at its next turn) — wake skipped: …` */
46
+ export const sentText = (sent: Sent): string => `${sent.message.kind} ${sent.message.id} to ${toText(sent.message)}: ${deliveryText(sent.delivery)}${wakeSkipNote(sent)}`
43
47
 
44
48
  /** How long an ask stays open when --expires is not given (the hub's default). */
45
49
  export const ASK_EXPIRES_DEFAULT_S = 24 * 60 * 60