@huynle/hub 0.7.4 → 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 +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  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 +632 -49
  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
@@ -2,17 +2,29 @@
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
+ // and the Concierge watchlist (watchlist_get, watchlist_update, watchlist_watch, watchlist_mute: watchlist.ts).
6
12
  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
13
  import { answeredText, approvedText, DELIVERIES, deniedText, needsUserLines, promptedText, stoppedText } from "./acting"
8
14
  import { UsageError } from "./args"
15
+ import { intentLine, writeIntent } from "./intents"
9
16
  import type { Credentials } from "./credentials"
10
17
  import { logText, oneLine, sessionsText, showText, statusText, whoamiText, whoText } from "./format"
11
18
  import { CliError, withHub } from "./http"
12
- import { askOutcome, declinedText, durationArg, mutedText, repliedText, sentText } from "./messaging"
19
+ import { askOutcome, declinedText, deliveryText, durationArg, mutedText, repliedText, sentText } from "./messaging"
13
20
  import { DECISION_STATES, decisionsText, decisionText, openedText } from "./decisions"
14
21
  import { CLEANUP_POLICIES, LAUNCH_END_WHEN, RUNTIME_KINDS, WORKSPACE_MODES } from "../../protocol/src/launch.ts"
15
22
  import { launchedText, launchesText, launchText, stoppedLaunchText } from "./launches"
23
+ import { artifactTools } from "./artifacts-mcp"
24
+ import { artifactJobTools } from "./artifact-jobs-mcp"
25
+ import { teamIntakeTools } from "./team-intake-mcp"
26
+ import { teamTools } from "./teams-mcp"
27
+ import { watchlistTools } from "./watchlist"
16
28
 
17
29
  /** The protocol revisions the server speaks, newest first: a client's supported one is echoed, else the newest. */
18
30
  export const MCP_PROTOCOL_VERSIONS: readonly string[] = ["2025-06-18", "2025-03-26", "2024-11-05"]
@@ -192,7 +204,8 @@ function inboxText(box: Inbox): string {
192
204
 
193
205
  /** One message (message_show): its state, the message and, once answered or declined, the answer, in envelopes. */
194
206
  function messageText({ message, answer }: Asked): string {
195
- const lines = [NOT_FROM_USER, `${message.kind} ${message.id}: ${message.state}`, envelope(message)]
207
+ // (CON-10) A message dropped for a recipient that ended for good says so.
208
+ const lines = [NOT_FROM_USER, `${message.kind} ${message.id}: ${message.state}${message.delivery === "dropped" ? ` · ${deliveryText("dropped")}` : ""}`, envelope(message)]
196
209
  if (answer) lines.push(`${message.state} by ${answer.id}:`, envelope(answer))
197
210
  return lines.join("\n")
198
211
  }
@@ -205,7 +218,7 @@ const SEND = {
205
218
  urgent: { type: "boolean", description: "Steer into the recipient's running turn." },
206
219
  all: { type: "boolean", description: "Send to every session the address names (each holder of a role)." },
207
220
  } as const
208
- const TO = { type: "string", description: "The recipient: @name, @@role, @name.project, ses_…, or @me (the user)." } as const
221
+ 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
222
  const MSG_ID = { type: "string", description: "The ID (msg_…) of the message you answer." } as const
210
223
  const ADDR = { type: "string", description: "A session: ses_… (or an unambiguous prefix), @name, @@role or @name.project." } as const
211
224
  const WHY = { type: "string", description: "Why: recorded in the agent audit (and for deny, passed to the harness)." } as const
@@ -262,6 +275,34 @@ const archiveInactiveTool: Tool = {
262
275
  },
263
276
  }
264
277
 
278
+ const intentTools: readonly Tool[] = [
279
+ {
280
+ name: "session_intent_get",
281
+ 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.",
282
+ properties: { addr: ADDR },
283
+ required: ["addr"],
284
+ readOnly: true,
285
+ run: async (hub, args, signal) => intentLine(need(args, "addr"), (await hub.intent(need(args, "addr"), { signal })).intent),
286
+ },
287
+ {
288
+ name: "session_intent_set",
289
+ 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.",
290
+ properties: { addr: ADDR, text: STRING, clear: { type: "boolean" }, expectedRevision: { type: "integer", minimum: 0 } },
291
+ required: ["addr"],
292
+ readOnly: false,
293
+ organizes: true,
294
+ idempotent: true,
295
+ run: async (hub, args, signal) => {
296
+ const addr = need(args, "addr")
297
+ const text = optional(args, "text")
298
+ const clear = args.clear === true
299
+ if (clear === (text !== undefined)) throw new ArgError('Give "text" or "clear": true')
300
+ const expectedRevision = optionalInt(args, "expectedRevision", 0, Number.MAX_SAFE_INTEGER)
301
+ return intentLine(addr, await writeIntent(hub, addr, clear ? null : text!, { signal, ...(expectedRevision !== undefined ? { expectedRevision } : {}) }))
302
+ },
303
+ },
304
+ ]
305
+
265
306
  const TARGET = { ...ADDR, description: `${ADDR.description} Required for an app token; a session token defaults to itself.` } as const
266
307
  const ROLE = { ...STRING, description: "A role (reviewer, @@reviewer or reviewer.project)." } as const
267
308
  const ROLE_PROJECT = { ...STRING, description: "Scope the role to this project (as role.project)." } as const
@@ -271,7 +312,7 @@ const roleText = (r: { role: string; project?: string }) => `@@${r.role}${r.proj
271
312
  const nameTools: readonly Tool[] = [
272
313
  {
273
314
  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}.`,
315
+ 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
316
  properties: { addr: TARGET, name: { ...STRING, description: "The name: 2–40 characters a-z, 0-9 and - (a leading @ is fine)." } },
276
317
  required: ["name"],
277
318
  readOnly: false,
@@ -285,7 +326,7 @@ const nameTools: readonly Tool[] = [
285
326
  },
286
327
  {
287
328
  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.`,
329
+ 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
330
  properties: { addr: TARGET, role: ROLE, project: ROLE_PROJECT, exclusive: { type: "boolean", description: "Be the only holder." } },
290
331
  required: ["role"],
291
332
  readOnly: false,
@@ -366,15 +407,337 @@ const webhookTools: readonly Tool[] = [
366
407
  },
367
408
  ]
368
409
 
410
+ /** The session a profile tool reads: `addr`, else the caller's own session (session tokens). */
411
+ async function profileTarget(hub: HubClient, args: Args, signal: AbortSignal): Promise<string> {
412
+ const addr = optional(args, "addr")
413
+ if (addr) return addr
414
+ const { actor } = await hub.whoami({ signal })
415
+ if (actor.kind !== "session") throw new ArgError(`"addr" is required unless this credential is a session's`)
416
+ return actor.sessionID
417
+ }
418
+ const optionalParams = (args: Args): Record<string, string> => optionalLabels(args, "params") ?? {}
419
+ const profileTools: readonly Tool[] = [
420
+ {
421
+ name: "profile",
422
+ 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.",
423
+ properties: { addr: ADDR }, readOnly: true,
424
+ run: async (hub, args, signal) => {
425
+ const sessionID = await profileTarget(hub, args, signal)
426
+ const r = await hub.profiles.ofSession(sessionID, { signal })
427
+ if (!r.assignment) return `${sessionID} has no profile`
428
+ const v = r.version
429
+ 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}` : ""}`]
430
+ if (v) {
431
+ lines.push(`instructions: ${v.instructions ? "yes" : "no"}`)
432
+ if (v.skills.length) lines.push("skills:", ...v.skills.map((k) => ` ${k.name}: ${oneLine(sanitizeText(k.description))}`))
433
+ if (v.commands.length) lines.push("commands:", ...v.commands.map((c) => ` ${c.name}(${c.params.join(", ")}): ${oneLine(sanitizeText(c.description))}`))
434
+ if (v.secretRefs.length) lines.push(`secret references: ${v.secretRefs.join(", ")}`)
435
+ } else lines.push("the pinned version is no longer retained")
436
+ return lines.join("\n")
437
+ },
438
+ },
439
+ {
440
+ name: "profile_skill",
441
+ description: "Load one skill's full text from the profile pinned to a session (your own by default), on demand.",
442
+ properties: { name: STRING, addr: ADDR }, required: ["name"], readOnly: true,
443
+ run: async (hub, args, signal) => {
444
+ const r = await hub.profiles.sessionSkill(await profileTarget(hub, args, signal), need(args, "name"), { signal })
445
+ return `skill ${r.skill.name} (${r.profileID} v${r.version}):\n${sanitizeText(r.skill.body)}`
446
+ },
447
+ },
448
+ {
449
+ name: "profile_command",
450
+ 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.",
451
+ properties: { name: STRING, params: { type: "object", additionalProperties: STRING, description: "Parameter name → text value." }, addr: ADDR }, required: ["name"], readOnly: true,
452
+ run: async (hub, args, signal) => {
453
+ const r = await hub.profiles.sessionRender(await profileTarget(hub, args, signal), need(args, "name"), optionalParams(args), { signal })
454
+ return r.prompt
455
+ },
456
+ },
457
+ {
458
+ name: "profiles",
459
+ 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).",
460
+ properties: { state: { type: "string", enum: ["active", "archived", "all"] } }, readOnly: true,
461
+ run: async (hub, args, signal) => {
462
+ const state = optionalEnum(args, "state", ["active", "archived", "all"] as const)
463
+ const r = await hub.profiles.list({ ...(state ? { state } : {}), limit: 200, signal })
464
+ 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"
465
+ },
466
+ },
467
+ {
468
+ name: "profile_usage",
469
+ description:
470
+ "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.",
471
+ properties: { id: STRING, limit: { type: "integer", minimum: 1, maximum: 200, description: "Sessions per page (default 50)." }, cursor: STRING }, required: ["id"], readOnly: true,
472
+ run: async (hub, args, signal) => {
473
+ const limit = optionalInt(args, "limit", 1, 200)
474
+ const cursor = optional(args, "cursor")
475
+ const u = await hub.profiles.usage(need(args, "id"), { ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}), signal })
476
+ const c = u.counts
477
+ const plural = (n: number, word: string, many = `${word}s`) => `${n} ${n === 1 ? word : many}`
478
+ const lines = [`${u.profileID} (latest v${u.latestVersion})`]
479
+ const summary = [
480
+ c.sessions !== undefined ? `${plural(c.sessions, "session")} (${c.online ?? 0} online, ${c.olderVersion ?? 0} on older versions)` : "",
481
+ c.teamRoles !== undefined ? `${plural(c.teamRoles, "team role")}${c.staffedSeats !== undefined ? ` (${c.staffedSeats} staffed seats)` : ""}` : "",
482
+ c.launches !== undefined ? `${plural(c.launches, "launch", "launches")} (${c.liveLaunches ?? 0} live)` : "",
483
+ c.workspaces !== undefined ? plural(c.workspaces, "workspace") : "",
484
+ ].filter(Boolean)
485
+ if (summary.length) lines.push(summary.join(" · "))
486
+ for (const s of u.sessions?.items ?? []) {
487
+ const running = s.effective && s.effective.version !== s.version ? `, running v${s.effective.version}` : ""
488
+ const who = s.name ? ` @${s.name}` : ""
489
+ 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}` : ""}`)
490
+ }
491
+ if (u.sessions?.cursor) lines.push(` more sessions: cursor ${u.sessions.cursor}`)
492
+ 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` : ""}`)
493
+ for (const l of u.launches ?? []) lines.push(` launch ${l.id} ${l.state}${l.outcome ? ` (${l.outcome})` : ""} v${l.version}${l.sessionID ? ` → ${l.sessionID}` : ""}`)
494
+ for (const w of u.workspaces ?? []) lines.push(` workspace ${w.workspaceID}${w.title ? ` ${oneLine(sanitizeText(w.title))}` : ""}: ${plural(w.sessions, "session")}`)
495
+ if (u.projects?.length) lines.push(`projects: ${u.projects.map((p) => oneLine(sanitizeText(p))).join(", ")}`)
496
+ if (u.hidden?.length) lines.push(`hidden: ${u.hidden.join(", ")}`)
497
+ if (u.truncated?.length) lines.push(`truncated: ${u.truncated.join(", ")}`)
498
+ return lines.join("\n")
499
+ },
500
+ },
501
+ ]
502
+
503
+ /** A required whole-number argument (revisions). */
504
+ const needRevision = (args: Args, key = "expectedRevision"): number => {
505
+ const value = optionalInt(args, key, 0, Number.MAX_SAFE_INTEGER)
506
+ if (value === undefined) throw new ArgError(`"${key}" is required: the revision you read`)
507
+ return value
508
+ }
509
+ const idemOption = (args: Args) => {
510
+ const key = optional(args, "idempotencyKey")
511
+ return key !== undefined ? { idempotencyKey: key } : {}
512
+ }
513
+ const WORKSPACE = { type: "string", description: "The workspace's ID (ws_…)." } as const
514
+ const REVISION = { type: "integer", minimum: 0, description: "The revision you read (a stale one answers conflict with the current revision)." } as const
515
+ const IDEMPOTENCY = { type: "string", description: "Idempotency-Key: the same key and arguments within 24 h answer the first result again." } as const
516
+ const MEMBER_ROLES = ["worker", "observer"] as const
517
+ const ASSIGNMENT_STATUSES = ["open", "in_progress", "blocked", "done", "cancelled"] as const
518
+ 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."
519
+ const wsLine = (w: { id: string; state: string; revision: number; title: string }) => `${w.id} ${w.state} r${w.revision} ${oneLine(sanitizeText(w.title))}`
520
+ const workspaceTools: readonly Tool[] = [
521
+ {
522
+ name: "workspaces",
523
+ description: `List the user's coordination workspaces (id, state, revision, title). ${WS_SCOPES}`,
524
+ properties: { state: { type: "string", enum: ["active", "archived"] } }, readOnly: true,
525
+ run: async (hub, args, signal) => {
526
+ const state = optionalEnum(args, "state", ["active", "archived"] as const)
527
+ const r = await hub.workspaces.list({ ...(state ? { state } : {}), limit: 200, signal })
528
+ return r.items.length ? r.items.map(wsLine).join("\n") : "no workspaces"
529
+ },
530
+ },
531
+ {
532
+ name: "workspace_show",
533
+ description: `One workspace: its record, repo bindings, current members (session, role, presence) and supervision links with the graph revision. ${WS_SCOPES}`,
534
+ properties: { workspace: WORKSPACE }, required: ["workspace"], readOnly: true,
535
+ run: async (hub, args, signal) => {
536
+ const id = need(args, "workspace")
537
+ const [{ workspace, bindings }, { members }, graph] = await Promise.all([hub.workspaces.get(id, { signal }), hub.workspaces.members(id, { signal }), hub.workspaces.supervision(id, { signal })])
538
+ const lines = [wsLine(workspace)]
539
+ if (workspace.masterMemberID) lines.push(`master: ${workspace.masterMemberID}`)
540
+ if (bindings.length) lines.push("bindings:", ...bindings.map((b) => ` ${b.id} ${b.machineID}:${b.repo} r${b.revision}`))
541
+ 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}`))
542
+ 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)" : ""}`))
543
+ return lines.join("\n")
544
+ },
545
+ },
546
+ {
547
+ name: "workspace_create",
548
+ description: `Create a coordination workspace. ${WS_SCOPES}`,
549
+ properties: { title: STRING, description: STRING, tags: { type: "array", items: STRING }, idempotencyKey: IDEMPOTENCY }, required: ["title"], readOnly: false,
550
+ run: async (hub, args, signal) => {
551
+ const description = optional(args, "description")
552
+ const tags = optionalStrings(args, "tags")
553
+ const { workspace } = await hub.workspaces.create({ title: need(args, "title"), ...(description ? { description } : {}), ...(tags ? { tags } : {}) }, { ...idemOption(args), signal })
554
+ return `created ${wsLine(workspace)}`
555
+ },
556
+ },
557
+ {
558
+ name: "workspace_update",
559
+ description: `Change a workspace's title, description or tags under its revision. ${WS_SCOPES}`,
560
+ properties: { workspace: WORKSPACE, expectedRevision: REVISION, title: STRING, description: STRING, tags: { type: "array", items: STRING } }, required: ["workspace", "expectedRevision"], readOnly: false,
561
+ run: async (hub, args, signal) => {
562
+ const patch = definedFields({ title: optional(args, "title"), description: optional(args, "description"), tags: optionalStrings(args, "tags") })
563
+ if (!patch) throw new ArgError(`give "title", "description" or "tags"`)
564
+ const { workspace } = await hub.workspaces.update(need(args, "workspace"), { expectedRevision: needRevision(args), ...patch }, { signal })
565
+ return `updated ${wsLine(workspace)}`
566
+ },
567
+ },
568
+ ...(["archive", "restore"] as const).map((verb): Tool => ({
569
+ name: `workspace_${verb}`,
570
+ 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}`,
571
+ properties: { workspace: WORKSPACE, expectedRevision: REVISION }, required: ["workspace", "expectedRevision"], readOnly: false, idempotent: true,
572
+ run: async (hub, args, signal) => {
573
+ const id = need(args, "workspace")
574
+ if (verb === "archive") {
575
+ const r = await hub.workspaces.archive(id, needRevision(args), { signal })
576
+ return `archived ${wsLine(r.workspace)}; ${r.departed} member(s) departed, ${r.supervision.detached} supervision link(s) detached`
577
+ }
578
+ return `restored ${wsLine((await hub.workspaces.restore(id, needRevision(args), { signal })).workspace)}`
579
+ },
580
+ })),
581
+ {
582
+ name: "workspace_bind",
583
+ description: `Bind a workspace to a repository (absolute path) on one of the user's machines. ${WS_SCOPES}`,
584
+ properties: { workspace: WORKSPACE, machineID: STRING, repo: STRING, label: STRING, idempotencyKey: IDEMPOTENCY }, required: ["workspace", "machineID", "repo"], readOnly: false, idempotent: true,
585
+ run: async (hub, args, signal) => {
586
+ const label = optional(args, "label")
587
+ const r = await hub.workspaces.bind(need(args, "workspace"), { machineID: need(args, "machineID"), repo: need(args, "repo"), ...(label ? { label } : {}) }, { ...idemOption(args), signal })
588
+ return `${r.created ? "bound" : "already bound"} ${r.binding.id} ${r.binding.machineID}:${r.binding.repo} r${r.binding.revision}`
589
+ },
590
+ },
591
+ {
592
+ name: "workspace_unbind",
593
+ description: `Remove a repository binding under the binding's revision. ${WS_SCOPES}`,
594
+ properties: { workspace: WORKSPACE, bindingID: STRING, expectedRevision: REVISION }, required: ["workspace", "bindingID", "expectedRevision"], readOnly: false,
595
+ run: async (hub, args, signal) => {
596
+ await hub.workspaces.unbind(need(args, "workspace"), need(args, "bindingID"), needRevision(args), { signal })
597
+ return `unbound ${need(args, "bindingID")}`
598
+ },
599
+ },
600
+ {
601
+ name: "workspace_join",
602
+ description: `Add a session (ses_…) to a workspace as worker or observer (the master is set by explicit replacement). ${WS_SCOPES}`,
603
+ properties: { workspace: WORKSPACE, sessionID: STRING, role: { type: "string", enum: [...MEMBER_ROLES] }, idempotencyKey: IDEMPOTENCY }, required: ["workspace", "sessionID", "role"], readOnly: false, idempotent: true,
604
+ run: async (hub, args, signal) => {
605
+ const role = optionalEnum(args, "role", MEMBER_ROLES)
606
+ if (!role) throw new ArgError(`"role" is one of ${MEMBER_ROLES.join(", ")}`)
607
+ const { member, created } = await hub.workspaces.join(need(args, "workspace"), { sessionID: need(args, "sessionID"), role }, { ...idemOption(args), signal })
608
+ return `member ${member.id} (${member.sessionID}, ${member.role}, generation ${member.generation}, r${member.revision}) ${created ? "joined" : "was already a member"}`
609
+ },
610
+ },
611
+ {
612
+ name: "workspace_leave",
613
+ description: `Remove a member under its revision; its supervision links are detached. ${WS_SCOPES}`,
614
+ properties: { workspace: WORKSPACE, memberID: STRING, expectedRevision: REVISION }, required: ["workspace", "memberID", "expectedRevision"], readOnly: false, idempotent: true,
615
+ run: async (hub, args, signal) => {
616
+ const r = await hub.workspaces.leave(need(args, "workspace"), need(args, "memberID"), needRevision(args), { signal })
617
+ return `member ${r.member.id} left (${r.member.leftReason ?? "removed"}); ${r.supervision.detached} supervision link(s) detached`
618
+ },
619
+ },
620
+ {
621
+ name: "workspace_assign",
622
+ description: `Give a current member an assignment (title and optional outcome criteria; instructions travel as messages). ${WS_SCOPES}`,
623
+ properties: { workspace: WORKSPACE, memberID: STRING, title: STRING, criteria: { type: "array", items: STRING }, ref: STRING, idempotencyKey: IDEMPOTENCY }, required: ["workspace", "memberID", "title"], readOnly: false,
624
+ run: async (hub, args, signal) => {
625
+ const criteria = optionalStrings(args, "criteria")
626
+ const ref = optional(args, "ref")
627
+ const { assignment } = await hub.workspaces.assign(need(args, "workspace"), { memberID: need(args, "memberID"), title: need(args, "title"), ...(criteria ? { criteria } : {}), ...(ref ? { ref } : {}) }, { ...idemOption(args), signal })
628
+ return `assignment ${assignment.id} → ${assignment.memberID}: ${assignment.status}, r${assignment.revision}`
629
+ },
630
+ },
631
+ {
632
+ name: "workspace_assignments",
633
+ description: `A workspace's assignments (id, member, status, revision, title), optionally one member's or one status. ${WS_SCOPES}`,
634
+ properties: { workspace: WORKSPACE, memberID: STRING, status: { type: "string", enum: [...ASSIGNMENT_STATUSES] } }, required: ["workspace"], readOnly: true,
635
+ run: async (hub, args, signal) => {
636
+ const memberID = optional(args, "memberID")
637
+ const status = optionalEnum(args, "status", ASSIGNMENT_STATUSES)
638
+ const r = await hub.workspaces.assignments(need(args, "workspace"), { ...(memberID ? { memberID } : {}), ...(status ? { status } : {}), limit: 200, signal })
639
+ 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"
640
+ },
641
+ },
642
+ {
643
+ name: "workspace_assignment_update",
644
+ description: `Set an assignment's status (done and cancelled are final) or move it to another current member, under its revision. ${WS_SCOPES}`,
645
+ properties: { workspace: WORKSPACE, assignmentID: STRING, expectedRevision: REVISION, status: { type: "string", enum: [...ASSIGNMENT_STATUSES] }, memberID: STRING }, required: ["workspace", "assignmentID", "expectedRevision"], readOnly: false,
646
+ run: async (hub, args, signal) => {
647
+ const status = optionalEnum(args, "status", ASSIGNMENT_STATUSES)
648
+ const memberID = optional(args, "memberID")
649
+ if ((status === undefined) === (memberID === undefined)) throw new ArgError(`give exactly one of "status" or "memberID"`)
650
+ const id = need(args, "workspace")
651
+ const assignmentID = need(args, "assignmentID")
652
+ const { assignment } = status !== undefined
653
+ ? await hub.workspaces.setAssignmentStatus(id, assignmentID, status, needRevision(args), { signal })
654
+ : await hub.workspaces.reassign(id, assignmentID, memberID!, needRevision(args), { signal })
655
+ return `assignment ${assignment.id} → ${assignment.memberID}: ${assignment.status}, r${assignment.revision}`
656
+ },
657
+ },
658
+ {
659
+ name: "workspace_supervise",
660
+ description: `Set who supervises a member (supervisor null detaches), under the supervision graph's revision (workspace_show). Acyclic, at most 6 levels. ${WS_SCOPES}`,
661
+ 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,
662
+ run: async (hub, args, signal) => {
663
+ const supervisor = args.supervisor === null ? null : need(args, "supervisor")
664
+ const order = args.order
665
+ if (order !== undefined && (typeof order !== "number" || !Number.isFinite(order))) throw new ArgError(`"order" must be a number`)
666
+ const r = await hub.workspaces.supervise(need(args, "workspace"), { memberID: need(args, "memberID"), supervisorMemberID: supervisor, ...(order !== undefined ? { order } : {}), expectedRevision: needRevision(args) }, { signal })
667
+ 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` : ""}`
668
+ },
669
+ },
670
+ {
671
+ name: "workspace_master",
672
+ description: `A workspace's master: the current master member (session, generation, presence), earlier masters and a replacement in flight. ${WS_SCOPES}`,
673
+ properties: { workspace: WORKSPACE }, required: ["workspace"], readOnly: true,
674
+ run: async (hub, args, signal) => {
675
+ const v = await hub.workspaces.master(need(args, "workspace"), { signal })
676
+ 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})` : ""}`
677
+ return [
678
+ `workspace ${v.workspaceID} r${v.revision}`,
679
+ v.master ? `master: ${line(v.master)}` : "no master",
680
+ ...(v.pending ? [`replacement in flight: ${v.pending.phase}${v.pending.stale ? " (stale)" : ""}`] : []),
681
+ ...(v.previous.length ? ["earlier masters:", ...v.previous.map((p) => ` ${line(p)}`)] : []),
682
+ ].join("\n")
683
+ },
684
+ },
685
+ {
686
+ name: "workspace_master_replace",
687
+ 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}`,
688
+ properties: { workspace: WORKSPACE, sessionID: STRING, launchID: STRING, expectedRevision: REVISION, idempotencyKey: IDEMPOTENCY }, required: ["workspace", "expectedRevision"], readOnly: false, idempotent: true,
689
+ run: async (hub, args, signal) => {
690
+ const sessionID = optional(args, "sessionID")
691
+ const launchID = optional(args, "launchID")
692
+ if ((sessionID === undefined) === (launchID === undefined)) throw new ArgError(`give exactly one of "sessionID" or "launchID"`)
693
+ const target = sessionID !== undefined ? { sessionID } : { launchID: launchID! }
694
+ const r = await hub.workspaces.replaceMaster(need(args, "workspace"), { ...target, expectedRevision: needRevision(args) }, { ...idemOption(args), signal })
695
+ if (!r.changed) return `${r.master.sessionID} already is the master (${r.master.id}); nothing changed`
696
+ return [
697
+ `master ${r.master.id} (${r.master.sessionID}, generation ${r.master.generation}, ${r.presence}); workspace r${r.workspace.revision}`,
698
+ ...(r.previous ? [`replaced ${r.previous.id} (${r.previous.sessionID}); ${r.supervision?.transferred ?? 0} supervision link(s) moved, ${r.supervision?.detached ?? 0} detached`] : []),
699
+ r.grants.note,
700
+ ].join("\n")
701
+ },
702
+ },
703
+ {
704
+ name: "workspace_board",
705
+ 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.`,
706
+ properties: { workspace: WORKSPACE }, required: ["workspace"], readOnly: true,
707
+ run: async (hub, args, signal) => {
708
+ const b = await hub.workspaces.board(need(args, "workspace"), { signal })
709
+ const more = (truncated: boolean) => (truncated ? " (more not shown)" : "")
710
+ type Section<T> = { status: "ok"; items: readonly T[]; truncated: boolean } | { status: "omitted"; scope: string } | { status: "unsupported" | "failed" }
711
+ const section = <T,>(name: string, s: Section<T>, line: (item: T) => string): string[] =>
712
+ s.status === "ok" ? [`${name}:${more(s.truncated)}`, ...s.items.map((i) => ` ${line(i)}`)] : [`${name}: ${s.status}${s.status === "omitted" ? ` (needs ${s.scope})` : ""}`]
713
+ const lines = [wsLine(b.workspace), ...(b.workspace.masterMemberID ? [`master: ${b.workspace.masterMemberID}`] : [])]
714
+ 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})` : ""}`))
715
+ lines.push(`supervision revision ${b.supervision.revision}`, ...b.supervision.links.map((l) => ` ${l.memberID} ← ${l.supervisorMemberID} order ${l.order}`))
716
+ 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(", ")})` : ""}`))
717
+ lines.push(...section("decisions", b.decisions, (d) => `${d.id} ${d.urgency} ${d.memberIDs.join(",")}${d.stale ? ` (${d.stale})` : ""}`))
718
+ lines.push(...section("artifacts", b.artifacts, (a) => `${a.id} ${a.kind} ${a.latestRevisionID} r${a.revision}`))
719
+ return lines.join("\n")
720
+ },
721
+ },
722
+ ]
723
+
369
724
  const TOOLS: readonly Tool[] = [
370
725
  ...archiveTools(true),
371
726
  ...archiveTools(false),
372
727
  ...pinTools(true),
373
728
  ...pinTools(false),
374
729
  archiveInactiveTool,
730
+ ...intentTools,
375
731
  ...nameTools,
376
732
  eventsTool,
377
733
  ...webhookTools,
734
+ ...profileTools,
735
+ ...workspaceTools,
736
+ ...artifactTools,
737
+ ...artifactJobTools,
738
+ ...teamIntakeTools,
739
+ ...teamTools,
740
+ ...watchlistTools,
378
741
  {
379
742
  name: "whoami",
380
743
  description: "Who this hub credential is: the app token of `hub login` (or the session of HUB_SESSION_TOKEN) and its user.",
@@ -426,10 +789,17 @@ const TOOLS: readonly Tool[] = [
426
789
  },
427
790
  {
428
791
  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)." } },
792
+ description: "Named and role-holding sessions; a team member sees its teams, grouped by team with its reach to each.",
793
+ properties: {
794
+ project: { ...STRING, description: "Only this project." },
795
+ role: { ...STRING, description: "Only holders of this role (reviewer, @@reviewer or reviewer#team)." },
796
+ team: { ...STRING, description: "Only this team (name or ID)." },
797
+ },
431
798
  readOnly: true,
432
- run: async (hub, args, signal) => whoText((await hub.who({ project: optional(args, "project"), role: optional(args, "role"), signal })).sessions ?? []),
799
+ run: async (hub, args, signal) => {
800
+ const body = await hub.who({ project: optional(args, "project"), role: optional(args, "role"), team: optional(args, "team"), signal })
801
+ return whoText(body.sessions ?? [], body)
802
+ },
433
803
  },
434
804
  {
435
805
  name: "msg",
@@ -710,6 +1080,7 @@ function errorText(error: CliError): string {
710
1080
  if (typeof envelope?.feature === "string") lines.push(`missing feature: ${envelope.feature}${typeof envelope.harness === "string" ? ` (${envelope.harness})` : ""}`)
711
1081
  if (envelope?.code === "needs_user") lines.push(...needsUserLines(envelope))
712
1082
  if (typeof envelope?.retryAfter === "number") lines.push(`retry after ${Math.ceil(envelope.retryAfter)} s`)
1083
+ if (typeof envelope?.currentRevision === "number") lines.push(`current revision ${envelope.currentRevision}`)
713
1084
  if (error.hint) lines.push(`hint: ${error.hint}`)
714
1085
  return lines.join("\n")
715
1086
  }
@@ -27,7 +27,9 @@ const DELIVERY: Record<MessageDelivery, string> = {
27
27
  steer: "delivered (into the running turn)",
28
28
  queued: "queued (read at its next turn)",
29
29
  retained: "retained (held until its open question is answered)",
30
- pending: "pending (offline; delivered when it reconnects, for 24 h)",
30
+ // Offline recipients, and a --no-wake message to a busy OpenCode session (RF-05); the hub's answer does not say which.
31
+ pending: "pending (offline or busy; delivered when it reconnects or its turn ends, for 24 h)",
32
+ dropped: "not delivered (its recipient session ended)",
31
33
  }
32
34
 
33
35
  export const deliveryText = (delivery: MessageDelivery | undefined): string => (delivery ? (DELIVERY[delivery] ?? delivery) : "stored (in its inbox)")
@@ -94,7 +94,7 @@ const CONSENT_FLAGS = { escalate: "bool", "confirm-activation": "bool", "allow-d
94
94
 
95
95
  /** The flags of each command (args.ts CommandSpec). */
96
96
  export const BOOTSTRAP_FLAGS: Readonly<Record<string, Readonly<Record<string, FlagKind>>>> = {
97
- "machine enroll": { server: "string", name: "string", reenroll: "bool", "no-verify": "bool" },
97
+ "machine enroll": { server: "string", name: "string", reenroll: "bool", "no-verify": "bool", "allow-insecure-http": "bool" },
98
98
  "machine status": { "no-verify": "bool" },
99
99
  "setup discover": {},
100
100
  "setup preview": { profile: "string", manifest: "string", out: "string", enrollment: "string", "workspace-root": "multi", "verify-launch": "string", server: "string", name: "string", ...CONSENT_FLAGS },
@@ -327,9 +327,10 @@ async function machine(verb: string, flags: Parsed["flags"], io: SetupIo, json:
327
327
  return printMachine(io, json, r.envelope, r.exit)
328
328
  }
329
329
  const server = str(flags, "server")
330
- if (!server) throw new UsageError("needs --server <hub URL> (https; plain http only for a loopback hub)")
330
+ if (!server) throw new UsageError("needs --server <hub URL> (https; plain http only for a loopback hub, or a private-network IP address with --allow-insecure-http)")
331
331
  const r = await machineEnroll({
332
332
  server: server.replace(/\/+$/, ""),
333
+ ...(flags["allow-insecure-http"] === true ? { allowInsecureHTTP: true } : {}),
333
334
  ...(str(flags, "name") ? { machineName: str(flags, "name")! } : {}),
334
335
  store: credentialStoreFor(io.env, io.home, { migrate: true }),
335
336
  ...(flags.reenroll === true ? { reenroll: true } : {}),
@@ -447,8 +447,9 @@ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
447
447
 
448
448
  /** What `machine enroll` (and an inline enrollment) shows while it waits; never a credential. */
449
449
  export function enrollPromptText(p: EnrollPrompt): string {
450
- if (p.autoApproved) return "hubctl: the local hub approved this machine by itself\n"
451
- return `hubctl: approve this machine on the hub: ${p.approveURL}\nhubctl: and check it shows the code ${p.userCode} (it expires at ${new Date(p.expiresAt).toISOString()})\n${p.warning ? `hubctl: warning: ${p.warning}\n` : ""}`
450
+ const insecure = p.insecure ? `hubctl: warning: ${p.insecure}\n` : ""
451
+ if (p.autoApproved) return `${insecure}hubctl: the local hub approved this machine by itself\n`
452
+ return `${insecure}hubctl: approve this machine on the hub: ${p.approveURL}\n${p.localURL ? `hubctl: (on this machine: ${p.localURL})\n` : ""}hubctl: and check it shows the code ${p.userCode} (it expires at ${new Date(p.expiresAt).toISOString()})\n${p.warning ? `hubctl: warning: ${p.warning}\n` : ""}`
452
453
  }
453
454
 
454
455
  export function launchReportText(r: LaunchReport): string {