@huynle/hub 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,460 @@
1
+ // MCP tools for persistent teams (HWA-37): assemble a team (roles, one entry role, open or restricted communication
2
+ // with one-way/two-way paths), edit it as a draft and publish it, staff its roles with sessions, and operate it
3
+ // (status, pause/resume, archive/restore). Work is given to a team with team_submit (team-intake-mcp.ts, HWA-36).
4
+ // `team` is the team's ID (team_…) or its name.
5
+ //
6
+ // Annotations for gateway review (packages/cli/src/mcp.ts maps them): reads are readOnlyHint; draft edits, create and
7
+ // pause/resume only reorganize (destructiveHint false: nothing applies until publish, and they are reversible);
8
+ // publish (changes the enforced communication policy), discard (drops unpublished edits), archive/restore (turn
9
+ // policy enforcement off/on) and staffing (seats confer the team's policy on a session; launches start sessions) keep
10
+ // the default destructiveHint. Changes of existing records send expectedRevision, so repeating one is idempotent.
11
+ // Decision answers and grant administration stay console-only: there is no tool for them here.
12
+ import { sanitizeText } from "../../sdk/src/envelope.ts"
13
+ import type { LaunchSpec } from "../../sdk/src/types.ts"
14
+ import type { Team, TeamDraft, TeamEdge, TeamPatch, TeamRole, TeamRoster, Teams, TeamSpec } from "../../sdk/src/teams.ts"
15
+
16
+ type Args = Record<string, unknown>
17
+
18
+ /** The shape of an MCP tool (as packages/cli/src/mcp.ts registers them). */
19
+ export interface TeamTool {
20
+ readonly name: string
21
+ readonly description: string
22
+ readonly properties: Record<string, unknown>
23
+ readonly required?: readonly string[]
24
+ readonly readOnly: boolean
25
+ readonly organizes?: boolean
26
+ readonly idempotent?: boolean
27
+ run(hub: { readonly teams: Teams }, args: Args, signal: AbortSignal): Promise<string>
28
+ }
29
+
30
+ const STRING = { type: "string" } as const
31
+ const STRINGS = { type: "array", items: STRING } as const
32
+ const TEAM = { type: "string", description: "The team: its ID (team_…) or its name." } as const
33
+ const REVISION = { type: "integer", minimum: 0, description: "The team's revision you read (team_show); a stale one answers conflict with the current revision." } as const
34
+ const IDEMPOTENCY = { type: "string", description: "Idempotency-Key: the same key and arguments within 24 h answer the first result again." } as const
35
+ const KINDS = { type: "array", items: { type: "string", enum: ["msg", "ask"] }, description: "What the path permits: msg and/or ask (replies to a permitted ask never need a path)." } as const
36
+ const DIRECTION = { type: "string", enum: ["one-way", "two-way"], description: "two-way: `to` may also contact `from` with the same kinds." } as const
37
+ const ROLE_FIELDS = {
38
+ label: { ...STRING, description: "A display label (confers no authority)." },
39
+ description: STRING,
40
+ externalContact: { type: "boolean", description: "Holders may contact sessions outside all their teams (default false)." },
41
+ responsibilities: { ...STRINGS, description: "Short lines (at most 16)." },
42
+ profile: { type: "object", properties: { profileID: STRING, version: { type: "integer", minimum: 1 } }, required: ["profileID"], description: "The agent profile members should run (reported, never pinned by staffing)." },
43
+ prefs: { type: "object", properties: { harness: STRING, model: STRING, machineID: STRING }, description: "Placement preferences for launched members (advisory)." },
44
+ minMembers: { type: "integer", minimum: 0, maximum: 16 },
45
+ maxMembers: { type: "integer", minimum: 1, maximum: 16 },
46
+ } as const
47
+ const ROLE = { type: "object", properties: { name: { ...STRING, description: "A slug: lowercase letters, digits, dashes." }, ...ROLE_FIELDS }, required: ["name"] } as const
48
+ const EDGE = { type: "object", properties: { from: STRING, to: STRING, kinds: KINDS, direction: DIRECTION }, required: ["from", "to", "kinds", "direction"] } as const
49
+ const SPEC_FIELDS = {
50
+ title: { ...STRING, description: "1–120 characters." },
51
+ description: STRING,
52
+ mode: { type: "string", enum: ["open", "restricted"], description: "open: every member may msg/ask every other member. restricted: only the drawn paths (edges)." },
53
+ roles: { type: "array", items: ROLE, description: "1–32 roles." },
54
+ entryRole: { ...STRING, description: "The role that receives submitted work (e.g. pm) and supervises members." },
55
+ edges: { type: "array", items: EDGE, description: "Communication paths between roles (restricted mode)." },
56
+ tags: { ...STRINGS, description: "Slugs." },
57
+ metadata: { type: "object", additionalProperties: STRING, description: "String key/value pairs (keys are slugs)." },
58
+ projects: { ...STRINGS, description: "Project names the team works on." },
59
+ } as const
60
+ // (FX-03, l3vynemo) The launch spec as validateLaunchSpec (src/central/agent/launch-spec.ts) takes it: NESTED sections,
61
+ // unlike the plain launch tool's flat fields. Keep the two in step. No additionalProperties: false, so a flat field
62
+ // reaches the hub, whose answer names the nested place it belongs (a client-side schema refusal would not).
63
+ const LAUNCH = {
64
+ type: "object",
65
+ description:
66
+ "A launch spec (nested, as POST /api/agent/launches takes; unlike the launch tool's flat fields): workspace{repo, mode, branch, base, remote} is required; placement{machine, labels, mode}, runtime{kind, profile}, end{when, timeout, cleanup} and the top-level fields are optional; key is set by staffing (send idempotencyKey instead). The role's prefs fill harness, model and placement.machine, and its profile fills agentProfile, when not given. A flat field (repo, machine, cleanup at the top) is refused with the nested place it belongs.",
67
+ properties: {
68
+ workspace: {
69
+ type: "object",
70
+ description: "Where the member works on its machine.",
71
+ properties: {
72
+ repo: { ...STRING, description: "The repository's absolute path on the machine." },
73
+ mode: { type: "string", enum: ["worktree", "checkout", "clone"], description: "worktree (default; needs branch), checkout, or clone (needs remote)." },
74
+ branch: { ...STRING, description: "The branch to create or check out (a safe git branch name; required for worktree)." },
75
+ base: { ...STRING, description: "What a new branch starts from." },
76
+ remote: { ...STRING, description: "An https or ssh git remote, without credentials (required for clone)." },
77
+ },
78
+ required: ["repo"],
79
+ },
80
+ placement: {
81
+ type: "object",
82
+ description: "Which machine runs it.",
83
+ properties: {
84
+ machine: { ...STRING, description: "A machine ID or name." },
85
+ labels: { type: "object", additionalProperties: STRING, description: "Node labels to match." },
86
+ mode: { type: "string", enum: ["require", "prefer"], description: "require (default) or prefer the machine/labels." },
87
+ },
88
+ },
89
+ runtime: {
90
+ type: "object",
91
+ properties: {
92
+ kind: { type: "string", enum: ["host", "openshell"], description: "host (default) or openshell (clone workspaces only)." },
93
+ profile: { ...STRING, description: "An openshell sandbox profile (openshell only)." },
94
+ },
95
+ },
96
+ end: {
97
+ type: "object",
98
+ description: "When the launch ends.",
99
+ properties: {
100
+ when: { type: "string", enum: ["idle", "asked", "never"], description: "idle (default), asked or never." },
101
+ timeout: { type: ["string", "number"], description: "1 minute to 30 days: seconds, or 30m, 4h, 2d." },
102
+ cleanup: { type: "string", enum: ["keep", "remove"], description: "keep (default) or remove the workspace at the end." },
103
+ },
104
+ },
105
+ name: { ...STRING, description: "The session's @name, without the @." },
106
+ roles: { ...STRINGS, description: "Roles the session claims (up to 10)." },
107
+ harness: { ...STRING, description: "The harness (default opencode)." },
108
+ agent: { ...STRING, description: "The harness agent." },
109
+ model: { ...STRING, description: "provider/model." },
110
+ prompt: { ...STRING, description: "The first prompt (up to 10000 characters)." },
111
+ intent: { ...STRING, description: "What the session is for (one line)." },
112
+ labels: { type: "object", additionalProperties: STRING, description: "Free-form app labels." },
113
+ agentProfile: { type: "object", properties: { profileID: STRING, version: { type: "integer", minimum: 1 } }, required: ["profileID", "version"], description: "The profile version to pin (needs profiles:assign for an app)." },
114
+ },
115
+ required: ["workspace"],
116
+ } as const
117
+ const SCOPES = "App tokens need scope teams:read (reads) or teams:manage (changes); seats, status and staffing also need sessions:read. Session tokens read only teams they are seated in and never change definitions. Team roles confer no authority."
118
+
119
+ class ArgError extends Error {}
120
+
121
+ const need = (args: Args, key: string): string => {
122
+ const value = args[key]
123
+ if (typeof value !== "string" || !value.trim()) throw new ArgError(`"${key}" is required (a non-empty string)`)
124
+ return value
125
+ }
126
+ const optional = (args: Args, key: string): string | undefined => {
127
+ const value = args[key]
128
+ if (value === undefined || value === null) return undefined
129
+ if (typeof value !== "string") throw new ArgError(`"${key}" must be a string`)
130
+ return value.trim() ? value : undefined
131
+ }
132
+ const strings = (args: Args, key: string): string[] | undefined => {
133
+ const value = args[key]
134
+ if (value === undefined || value === null) return undefined
135
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new ArgError(`"${key}" must be a list of strings`)
136
+ return value
137
+ }
138
+ const revision = (args: Args): number => {
139
+ const value = args.expectedRevision
140
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ArgError(`"expectedRevision" is required: the team's revision you read (team_show)`)
141
+ return value
142
+ }
143
+ const int = (args: Args, key: string, min: number, max: number): number | undefined => {
144
+ const value = args[key]
145
+ if (value === undefined || value === null) return undefined
146
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new ArgError(`"${key}" must be a whole number from ${min} to ${max}`)
147
+ return value
148
+ }
149
+ const record = (args: Args, key: string): Record<string, string> | undefined => {
150
+ const value = args[key]
151
+ if (value === undefined || value === null) return undefined
152
+ if (typeof value !== "object" || Array.isArray(value) || !Object.values(value).every((v) => typeof v === "string")) throw new ArgError(`"${key}" must be an object of strings`)
153
+ return value as Record<string, string>
154
+ }
155
+ const launchOf = (args: Args): LaunchSpec | undefined => {
156
+ const value = args.launch
157
+ if (value === undefined || value === null) return undefined
158
+ if (typeof value !== "object" || Array.isArray(value)) throw new ArgError(`"launch" must be a launch spec object (as the launch tool takes)`)
159
+ return value as LaunchSpec
160
+ }
161
+ /** The given fields of `keys` (undefined ones dropped). */
162
+ const picked = (args: Args, keys: readonly string[]): Record<string, unknown> => Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
163
+
164
+ const one = (text: string) => sanitizeText(text).replace(/\s+/g, " ")
165
+ const when = (ms: number) => new Date(ms).toISOString()
166
+
167
+ /** `team_… name state[ paused] vN rN[ draft pending] title` */
168
+ export const teamLine = (t: Team): string =>
169
+ `${t.id} ${t.name} ${t.state}${t.paused ? " paused" : ""} v${t.version} r${t.revision}${t.draft ? " (draft pending)" : ""} ${one(t.title)}`
170
+
171
+ const edgeText = (e: TeamEdge) => `${e.from} ${e.direction === "two-way" ? "⇄" : "→"} ${e.to} (${e.kinds.join(", ")})`
172
+
173
+ function specLines(spec: TeamSpec): string[] {
174
+ const role = (r: TeamRole) =>
175
+ ` ${r.name}${r.name === spec.entryRole ? " (entry)" : ""}${r.label ? ` "${one(r.label)}"` : ""}${r.externalContact ? " external contact" : ""}${r.minMembers !== undefined || r.maxMembers !== undefined ? ` members ${r.minMembers ?? 0}–${r.maxMembers ?? 16}` : ""}${r.profile ? ` profile ${r.profile.profileID}${r.profile.version ? `@${r.profile.version}` : ""}` : ""}`
176
+ return [
177
+ `mode: ${spec.mode}${spec.mode === "open" ? " (every member may msg/ask every other)" : " (only the paths below)"}`,
178
+ `entry role: ${spec.entryRole}`,
179
+ "roles:",
180
+ ...spec.roles.map(role),
181
+ ...(spec.edges.length ? ["paths:", ...spec.edges.map((e) => ` ${edgeText(e)}`)] : ["paths: none"]),
182
+ ...(spec.tags?.length ? [`tags: ${spec.tags.join(", ")}`] : []),
183
+ ...(spec.projects?.length ? [`projects: ${spec.projects.map(one).join(", ")}`] : []),
184
+ ...(spec.metadata && Object.keys(spec.metadata).length ? [`metadata: ${Object.entries(spec.metadata).map(([k, v]) => `${k}=${one(v)}`).join(", ")}`] : []),
185
+ ...(spec.description ? [`description: ${one(spec.description)}`] : []),
186
+ ]
187
+ }
188
+
189
+ const draftText = (prefix: string, team: Team, draft: TeamDraft | undefined) =>
190
+ [`${prefix}: ${teamLine(team)}`, ...(draft ? [`draft (on v${draft.baseVersion}, saved ${when(draft.savedAt)}; publish with team_publish):`, ...specLines(draft.spec)] : [])].join("\n")
191
+
192
+ function rosterLines(roster: TeamRoster): string[] {
193
+ const lines = [`roster (v${roster.version}${roster.paused ? ", paused" : ""})${roster.workspace ? ` workspace ${roster.workspace.id} ${roster.workspace.state}` : ""}:`]
194
+ for (const r of roster.roles) {
195
+ const flags = [r.vacant ? "vacant" : "", r.understaffed ? `understaffed (min ${r.minMembers})` : "", r.launching ? `${r.launching} launching` : ""].filter(Boolean)
196
+ lines.push(` ${r.role}${r.entry ? " (entry)" : ""} ${r.seats.length}/${r.maxMembers} ${r.online} online${flags.length ? ` ${flags.join(", ")}` : ""}`)
197
+ for (const s of r.seats)
198
+ lines.push(` ${s.staffing.id} ${s.staffing.sessionID} ${s.presence} gen ${s.staffing.generation} r${s.staffing.revision}${s.profile ? ` profile ${s.profile.status}${s.profile.matches ? "" : " (mismatch)"}` : ""}`)
199
+ }
200
+ const failed = roster.requests.filter((q) => q.state !== "staffed").slice(-5)
201
+ for (const q of failed) lines.push(` launch ${q.id} ${q.role} ${q.state}${q.reason ? `: ${one(q.reason)}` : ""}`)
202
+ return lines
203
+ }
204
+
205
+ const statusTool = (verb: "pause" | "resume" | "archive" | "restore", description: string, organizes: boolean): TeamTool => ({
206
+ name: `team_${verb}`,
207
+ description: `${description} ${SCOPES}`,
208
+ properties: { team: TEAM, expectedRevision: REVISION },
209
+ required: ["team", "expectedRevision"],
210
+ readOnly: false,
211
+ ...(organizes ? { organizes: true } : {}),
212
+ idempotent: true,
213
+ run: async (hub, args, signal) => {
214
+ const expectedRevision = revision(args)
215
+ const { team } = await hub.teams[verb](need(args, "team"), expectedRevision, { signal })
216
+ return `${team.revision === expectedRevision ? "unchanged" : { pause: "paused", resume: "resumed", archive: "archived", restore: "restored" }[verb]}: ${teamLine(team)}`
217
+ },
218
+ })
219
+
220
+ const draftTool = (name: string, description: string, properties: Record<string, unknown>, required: readonly string[], edit: (teams: Teams, team: string, args: Args, expectedRevision: number, signal: AbortSignal) => Promise<{ team: Team; draft: TeamDraft }>): TeamTool => ({
221
+ name,
222
+ description: `${description} Changes the draft only: nothing applies until team_publish. ${SCOPES}`,
223
+ properties: { team: TEAM, ...properties, expectedRevision: REVISION },
224
+ required: ["team", ...required, "expectedRevision"],
225
+ readOnly: false,
226
+ organizes: true,
227
+ idempotent: true,
228
+ run: async (hub, args, signal) => {
229
+ const expectedRevision = revision(args)
230
+ const { team, draft } = await edit(hub.teams, need(args, "team"), args, expectedRevision, signal)
231
+ return draftText("draft saved", team, draft)
232
+ },
233
+ })
234
+
235
+ export const teamTools: readonly TeamTool[] = [
236
+ // ---------- reads ----------
237
+ {
238
+ name: "teams",
239
+ description: `List the user's teams (id, name, state, version, revision, title), filtered by tags (all of them), project, metadata pairs, state and pause; paged. ${SCOPES}`,
240
+ properties: {
241
+ state: { type: "string", enum: ["active", "archived", "all"] },
242
+ tags: STRINGS,
243
+ project: STRING,
244
+ metadata: { type: "object", additionalProperties: STRING },
245
+ paused: { type: "boolean" },
246
+ limit: { type: "integer", minimum: 1, maximum: 200 },
247
+ cursor: STRING,
248
+ },
249
+ readOnly: true,
250
+ run: async (hub, args, signal) => {
251
+ const state = optional(args, "state")
252
+ if (state !== undefined && !["active", "archived", "all"].includes(state)) throw new ArgError(`"state" is one of active, archived, all`)
253
+ const tags = strings(args, "tags")
254
+ const project = optional(args, "project")
255
+ const metadata = record(args, "metadata")
256
+ const limit = int(args, "limit", 1, 200)
257
+ const cursor = optional(args, "cursor")
258
+ if (args.paused !== undefined && typeof args.paused !== "boolean") throw new ArgError(`"paused" must be true or false`)
259
+ const page = await hub.teams.list({
260
+ signal,
261
+ ...(state ? { state: state as "active" | "archived" | "all" } : {}),
262
+ ...(tags ? { tags } : {}),
263
+ ...(project ? { project } : {}),
264
+ ...(metadata ? { metadata } : {}),
265
+ ...(typeof args.paused === "boolean" ? { paused: args.paused } : {}),
266
+ ...(limit !== undefined ? { limit } : {}),
267
+ ...(cursor ? { cursor } : {}),
268
+ })
269
+ if (page.items.length === 0) return "no teams"
270
+ return [...page.items.map(teamLine), ...(page.cursor ? [`more: cursor ${page.cursor}`] : [])].join("\n")
271
+ },
272
+ },
273
+ {
274
+ name: "team_show",
275
+ description: `One team: its published definition (mode, entry role, roles, communication paths, tags, projects, metadata), revision, and the pending draft if any. ${SCOPES}`,
276
+ properties: { team: TEAM },
277
+ required: ["team"],
278
+ readOnly: true,
279
+ run: async (hub, args, signal) => {
280
+ const { team, draft } = await hub.teams.get(need(args, "team"), { signal })
281
+ return [teamLine(team), ...specLines(team), ...(draft ? [`draft pending (on v${draft.baseVersion}, saved ${when(draft.savedAt)}): see team_diff`] : [])].join("\n")
282
+ },
283
+ },
284
+ {
285
+ name: "team_status",
286
+ description: `A team's state, roster (each role's seats with session, presence, generation and revision; vacant/understaffed roles; launches) and intake counts. ${SCOPES}`,
287
+ properties: { team: TEAM },
288
+ required: ["team"],
289
+ readOnly: true,
290
+ run: async (hub, args, signal) => {
291
+ const { team, roster, intake } = await hub.teams.status(need(args, "team"), { signal })
292
+ const lines = [teamLine(team), ...rosterLines(roster)]
293
+ if (intake)
294
+ lines.push(
295
+ `intake: ${intake.queued} queued, ${intake.routed} routed (${intake.open} open, ${intake.inProgress} in progress, ${intake.blocked} blocked), ${intake.done} done, ${intake.failed} failed, ${intake.cancelled} cancelled${intake.oldestQueuedAt !== undefined ? `; oldest queued ${when(intake.oldestQueuedAt)}` : ""}`,
296
+ )
297
+ return lines.join("\n")
298
+ },
299
+ },
300
+ {
301
+ name: "team_history",
302
+ description: `A team's retained published versions, newest first (version, digest, when). ${SCOPES}`,
303
+ properties: { team: TEAM, cursor: STRING },
304
+ required: ["team"],
305
+ readOnly: true,
306
+ run: async (hub, args, signal) => {
307
+ const cursor = optional(args, "cursor")
308
+ const page = await hub.teams.versions(need(args, "team"), { signal, ...(cursor ? { cursor } : {}) })
309
+ return [...page.items.map((v) => `v${v.version} ${v.digest.slice(0, 12)} ${when(v.publishedAt)}`), ...(page.cursor ? [`more: cursor ${page.cursor}`] : [])].join("\n")
310
+ },
311
+ },
312
+ {
313
+ name: "team_diff",
314
+ description: `What changed between two versions, or from a version (default: the published one) to the draft (default). Names and keys only. ${SCOPES}`,
315
+ properties: { team: TEAM, from: { type: "integer", minimum: 1 }, to: { description: "A version number or \"draft\" (default)." } },
316
+ required: ["team"],
317
+ readOnly: true,
318
+ run: async (hub, args, signal) => {
319
+ const from = int(args, "from", 1, 1e9)
320
+ const to = args.to === undefined || args.to === null || args.to === "draft" ? "draft" : int(args, "to", 1, 1e9)!
321
+ const { from: a, to: b, diff } = await hub.teams.diff(need(args, "team"), { signal, ...(from !== undefined ? { from } : {}), to })
322
+ const part = (label: string, d: { added?: readonly string[]; removed?: readonly string[]; changed?: readonly string[] }) =>
323
+ (["added", "removed", "changed"] as const).flatMap((k) => (d[k]?.length ? [`${label} ${k}: ${d[k]!.join(", ")}`] : []))
324
+ const lines = [
325
+ ...(diff.fields.length ? [`fields changed: ${diff.fields.join(", ")}`] : []),
326
+ ...part("roles", diff.roles),
327
+ ...part("paths", diff.edges),
328
+ ...part("tags", diff.tags),
329
+ ...part("metadata", diff.metadata),
330
+ ...part("projects", diff.projects),
331
+ ]
332
+ return [`v${a} → ${b === "draft" ? "draft" : `v${b}`}`, ...(lines.length ? lines : ["no changes"])].join("\n")
333
+ },
334
+ },
335
+
336
+ // ---------- definitions ----------
337
+ {
338
+ name: "team_create",
339
+ description: `Assemble a persistent team: roles (one is the entry role that receives submitted work, e.g. a project manager), open or restricted communication with one-way/two-way paths, tags, projects and metadata. Published as version 1 at once; the name is the team's address (@@role#name) and never changes. ${SCOPES}`,
340
+ properties: { name: { ...STRING, description: "A slug (2–40 lowercase letters, digits, dashes), unique per user." }, ...SPEC_FIELDS, idempotencyKey: IDEMPOTENCY },
341
+ required: ["name", "title", "mode", "roles", "entryRole"],
342
+ readOnly: false,
343
+ organizes: true,
344
+ idempotent: true,
345
+ run: async (hub, args, signal) => {
346
+ need(args, "name")
347
+ need(args, "title")
348
+ const key = optional(args, "idempotencyKey")
349
+ const spec = picked(args, ["name", "title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as unknown as TeamSpec
350
+ const { team, created } = await hub.teams.create({ ...spec, edges: spec.edges ?? [] }, { signal, ...(key ? { idempotencyKey: key } : {}) })
351
+ return [`${created ? "created" : "already created (same key)"}: ${teamLine(team)}`, ...specLines(team)].join("\n")
352
+ },
353
+ },
354
+ {
355
+ name: "team_update",
356
+ description: `Save changes to a team's draft: title, description, mode, entry role, roles and paths (wholesale lists), tags, metadata, projects (null clears an optional field). Nothing applies until team_publish. ${SCOPES}`,
357
+ properties: { team: TEAM, ...SPEC_FIELDS, expectedRevision: REVISION },
358
+ required: ["team", "expectedRevision"],
359
+ readOnly: false,
360
+ organizes: true,
361
+ idempotent: true,
362
+ run: async (hub, args, signal) => {
363
+ const expectedRevision = revision(args)
364
+ const patch = picked(args, ["title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as TeamPatch
365
+ if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
366
+ const id = need(args, "team")
367
+ const { team } = await hub.teams.update(id, patch, expectedRevision, { signal })
368
+ return draftText("draft saved", team, (await hub.teams.draft(team.id, { signal })).draft)
369
+ },
370
+ },
371
+ draftTool("team_role_set", "Add a role to a team's draft, or replace the role of that name (all its fields).", { role: { ...STRING, description: "The role's name (a slug)." }, ...ROLE_FIELDS }, ["role"], (teams, team, args, rev, signal) =>
372
+ teams.setRole(team, { name: need(args, "role"), ...picked(args, Object.keys(ROLE_FIELDS)) } as TeamRole, rev, { signal }),
373
+ ),
374
+ draftTool("team_role_remove", "Remove a role (not the entry role) and every path touching it from a team's draft.", { role: STRING }, ["role"], (teams, team, args, rev, signal) => teams.removeRole(team, need(args, "role"), rev, { signal })),
375
+ draftTool("team_edge_set", "Draw a communication path from one role to another in a team's draft (replaces the path drawn between them).", { from: STRING, to: STRING, kinds: KINDS, direction: DIRECTION }, ["from", "to", "kinds", "direction"], (teams, team, args, rev, signal) => {
376
+ const kinds = strings(args, "kinds")
377
+ if (!kinds?.length) throw new ArgError(`"kinds" is a list of msg and/or ask`)
378
+ const direction = need(args, "direction")
379
+ return teams.setEdge(team, { from: need(args, "from"), to: need(args, "to"), kinds: kinds as TeamEdge["kinds"], direction: direction as TeamEdge["direction"] }, rev, { signal })
380
+ }),
381
+ draftTool("team_edge_remove", "Remove the path drawn from one role to another (or a two-way path between them) from a team's draft.", { from: STRING, to: STRING }, ["from", "to"], (teams, team, args, rev, signal) => teams.removeEdge(team, need(args, "from"), need(args, "to"), rev, { signal })),
382
+ {
383
+ name: "team_discard_draft",
384
+ description: `Drop a team's pending draft (unpublished edits are lost; the published version stays). ${SCOPES}`,
385
+ properties: { team: TEAM, expectedRevision: REVISION },
386
+ required: ["team", "expectedRevision"],
387
+ readOnly: false,
388
+ idempotent: true,
389
+ run: async (hub, args, signal) => `draft discarded: ${teamLine((await hub.teams.discardDraft(need(args, "team"), revision(args), { signal })).team)}`,
390
+ },
391
+ {
392
+ name: "team_publish",
393
+ description: `Publish a team's draft as its next version: delivery enforces the new communication policy from the next message, and staffing uses the new roles. Refused while sessions are staffed in a role the draft removes. ${SCOPES}`,
394
+ properties: { team: TEAM, expectedRevision: REVISION },
395
+ required: ["team", "expectedRevision"],
396
+ readOnly: false,
397
+ idempotent: true,
398
+ run: async (hub, args, signal) => {
399
+ const { team, published } = await hub.teams.publish(need(args, "team"), revision(args), { signal })
400
+ return `${published ? `published version ${team.version}` : "the draft equalled the published version; dropped"}: ${teamLine(team)}`
401
+ },
402
+ },
403
+ statusTool("pause", "Pause a team: it takes no new intake and no new members; its communication policy still applies.", true),
404
+ statusTool("resume", "Resume a paused team (queued intake is routed again).", true),
405
+ statusTool("archive", "Archive a team: its policy no longer applies and it takes no work; seats are kept; reversible with team_restore.", false),
406
+ statusTool("restore", "Restore an archived team: its policy applies again.", false),
407
+
408
+ // ---------- staffing ----------
409
+ {
410
+ name: "team_staff",
411
+ description: `Staff a role: attach an existing session (sessionID, any machine) or launch a new member (launch: a launch spec as the launch tool takes). An agent needs the user's grants (roles for the session's project; launch for a repo) or the hub answers needs_user; staffing never issues grants. ${SCOPES}`,
412
+ properties: { team: TEAM, role: STRING, sessionID: { ...STRING, description: "An existing session (ses_…)." }, launch: LAUNCH, idempotencyKey: IDEMPOTENCY },
413
+ required: ["team", "role"],
414
+ readOnly: false,
415
+ run: async (hub, args, signal) => {
416
+ const team = need(args, "team")
417
+ const role = need(args, "role")
418
+ const sessionID = optional(args, "sessionID")
419
+ const launch = launchOf(args)
420
+ if ((sessionID === undefined) === (launch === undefined)) throw new ArgError(`give exactly one of sessionID or launch`)
421
+ const key = optional(args, "idempotencyKey")
422
+ const options = { signal, ...(key ? { idempotencyKey: key } : {}) }
423
+ if (sessionID !== undefined) {
424
+ const r = await hub.teams.staff(team, { role, sessionID }, options)
425
+ return `${r.created ? "staffed" : "already staffed"} ${r.staffing.sessionID} as ${r.staffing.role} (${r.staffing.id}, generation ${r.staffing.generation}, r${r.staffing.revision})${r.workspace ? `; workspace ${r.workspace.id}` : ""}`
426
+ }
427
+ const r = await hub.teams.launchMember(team, { role, launch: launch! }, options)
428
+ return `${r.created ? "launching" : "already launching"} a ${role} member: request ${r.request.id} ${r.request.state}, launch ${r.launch.id} ${r.launch.state}`
429
+ },
430
+ },
431
+ {
432
+ name: "team_unstaff",
433
+ description: `End a seat (the session leaves the role and loses the team's policy rights). Send the seat's revision (team_status). ${SCOPES}`,
434
+ properties: { team: TEAM, staffingID: { ...STRING, description: "The seat (stf_…)." }, expectedRevision: { ...REVISION, description: "The seat's revision you read (team_status)." } },
435
+ required: ["team", "staffingID", "expectedRevision"],
436
+ readOnly: false,
437
+ idempotent: true,
438
+ run: async (hub, args, signal) => {
439
+ const { staffing } = await hub.teams.unstaff(need(args, "team"), need(args, "staffingID"), revision(args), { signal })
440
+ return `unstaffed ${staffing.id} (${staffing.sessionID} as ${staffing.role})`
441
+ },
442
+ },
443
+ {
444
+ name: "team_replace",
445
+ description: `Replace a seat by an existing session (sessionID) or a new launch (launch): the old seat ends at once and the new member gets the slot's next generation. Send the seat's revision (team_status). ${SCOPES}`,
446
+ properties: { team: TEAM, staffingID: STRING, sessionID: STRING, launch: LAUNCH, expectedRevision: { ...REVISION, description: "The seat's revision you read (team_status)." } },
447
+ required: ["team", "staffingID", "expectedRevision"],
448
+ readOnly: false,
449
+ run: async (hub, args, signal) => {
450
+ const expectedRevision = revision(args)
451
+ const sessionID = optional(args, "sessionID")
452
+ const launch = launchOf(args)
453
+ if ((sessionID === undefined) === (launch === undefined)) throw new ArgError(`give exactly one of sessionID or launch`)
454
+ const input = sessionID !== undefined ? { expectedRevision, sessionID } : { expectedRevision, launch: launch! }
455
+ const r = await hub.teams.replace(need(args, "team"), need(args, "staffingID"), input, { signal })
456
+ const next = r.staffing ? `${r.staffing.sessionID} (${r.staffing.id}, generation ${r.staffing.generation})` : `launch ${r.launch?.id ?? "?"} (request ${r.request?.id ?? "?"})`
457
+ return `replaced ${r.ended.sessionID} in ${r.ended.role} by ${next}`
458
+ },
459
+ },
460
+ ]
@@ -0,0 +1,135 @@
1
+ // `hub watchlist [show|watch|unwatch|mute|unmute] <addr>` and the MCP tools watchlist_get, watchlist_update,
2
+ // watchlist_watch and watchlist_mute (CON-01): the Concierge's watchlist over /api/agent/concierge/watchlist. Users and
3
+ // app tokens (sessions:read to show, sessions:manage to change); the user's current Concierge session through its
4
+ // act token (show and the verbs). Other session tokens get not found (exit 3).
5
+ import { sanitizeLabel } from "../../sdk/src/envelope.ts"
6
+ import type { Concierge, WatchlistChanged, WatchlistConfig, WatchlistUpdate, WatchlistView } from "../../sdk/src/index.ts"
7
+
8
+ type Args = Record<string, unknown>
9
+
10
+ /** The shape of an MCP tool (as packages/cli/src/mcp.ts registers them). */
11
+ export interface WatchlistTool {
12
+ readonly name: string
13
+ readonly description: string
14
+ readonly properties: Record<string, unknown>
15
+ readonly required?: readonly string[]
16
+ readonly readOnly: boolean
17
+ readonly organizes?: boolean
18
+ readonly idempotent?: boolean
19
+ run(hub: { readonly concierge: Concierge }, args: Args, signal: AbortSignal): Promise<string>
20
+ }
21
+
22
+ /** One value from the hub, safe to print: escapes, control, bidi and zero-width characters removed, one line. */
23
+ const safe = (value: unknown) => sanitizeLabel(String(value), 1024)
24
+ const list = (items: readonly string[]) => (items.length ? items.map(safe).join(", ") : "none")
25
+
26
+ export function watchlistConfigText(c: WatchlistConfig): string {
27
+ return [
28
+ `revision ${safe(c.revision)}`,
29
+ `auto-active: ${c.autoActive ? `on (within ${safe(c.activeWithinHours)} h)` : "off"} pins: ${c.usePins ? "on" : "off"}`,
30
+ `sessions: ${list(c.sessions)}`,
31
+ `projects: ${list(c.projects)}`,
32
+ `labels: ${c.labels.length ? c.labels.map((l) => Object.entries(l).map(([k, v]) => `${safe(k)}=${safe(v)}`).join(",")).join("; ") : "none"}`,
33
+ `muted: ${list(c.muted)}`,
34
+ `muted projects: ${list(c.mutedProjects)}`,
35
+ ].join("\n")
36
+ }
37
+
38
+ export function watchlistText(view: WatchlistView): string {
39
+ const rows = view.entries.map((e) => `${safe(e.sessionID)} ${e.reasons.map(safe).join(",")}${e.lastActiveAt !== undefined ? ` active ${new Date(e.lastActiveAt).toISOString()}` : ""}`)
40
+ return [watchlistConfigText(view.config), "", view.entries.length ? `watching ${view.entries.length}${view.truncated ? " (truncated)" : ""}:` : "watching nothing", ...rows].join("\n")
41
+ }
42
+
43
+ export function watchlistChangedText(verb: string, r: WatchlistChanged): string {
44
+ return `${r.changed ? `${verb}` : `already ${verb}`}: ${safe(r.sessionID)} (revision ${safe(r.config.revision)})`
45
+ }
46
+
47
+ const PAST: Record<string, string> = { watch: "watching", unwatch: "not watching", mute: "muted", unmute: "unmuted" }
48
+ export const watchlistVerbText = (verb: "watch" | "unwatch" | "mute" | "unmute", r: WatchlistChanged) => watchlistChangedText(PAST[verb]!, r)
49
+
50
+ const ADDR = { type: "string", description: "Session address: ses_… (or a unique prefix), @name, @@role, @name.project." } as const
51
+ const STRINGS = { type: "array", items: { type: "string" } } as const
52
+
53
+ const need = (args: Args, key: string): string => {
54
+ const value = args[key]
55
+ if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
56
+ return value
57
+ }
58
+ const flag = (args: Args, key: string, fallback: boolean): boolean => {
59
+ const value = args[key]
60
+ if (value === undefined || value === null) return fallback
61
+ if (typeof value !== "boolean") throw new Error(`"${key}" must be true or false`)
62
+ return value
63
+ }
64
+
65
+ const UPDATE_FIELDS = ["autoActive", "activeWithinHours", "usePins", "sessions", "projects", "labels", "muted", "mutedProjects"] as const
66
+
67
+ export const watchlistTools: readonly WatchlistTool[] = [
68
+ {
69
+ name: "watchlist_get",
70
+ description: "The Concierge's watchlist: its config (revision) and the sessions it watches now, with why (active, pinned, pinned_project, explicit, project, label). Needs sessions:read for an app token.",
71
+ properties: {},
72
+ readOnly: true,
73
+ run: async (hub, _args, signal) => watchlistText(await hub.concierge.watchlist.get({ signal })),
74
+ },
75
+ {
76
+ name: "watchlist_update",
77
+ description:
78
+ "Change the watchlist config under the revision you read (expectedRevision; a stale one is a conflict). Give only the fields to change: autoActive, activeWithinHours (1–24), usePins, sessions (ses_… IDs), projects (absolute dirs), labels ([{key: value}]), muted, mutedProjects. Users and app tokens with sessions:manage.",
79
+ properties: {
80
+ expectedRevision: { type: "integer", minimum: 0 },
81
+ autoActive: { type: "boolean" },
82
+ activeWithinHours: { type: "integer", minimum: 1, maximum: 24 },
83
+ usePins: { type: "boolean" },
84
+ sessions: STRINGS,
85
+ projects: STRINGS,
86
+ labels: { type: "array", items: { type: "object", additionalProperties: { type: "string" } } },
87
+ muted: STRINGS,
88
+ mutedProjects: STRINGS,
89
+ },
90
+ required: ["expectedRevision"],
91
+ readOnly: false,
92
+ organizes: true,
93
+ run: async (hub, args, signal) => {
94
+ const expected = args.expectedRevision
95
+ if (typeof expected !== "number" || !Number.isSafeInteger(expected) || expected < 0) throw new Error(`"expectedRevision" is required (the revision you read)`)
96
+ const unknown = Object.keys(args).filter((k) => k !== "expectedRevision" && !(UPDATE_FIELDS as readonly string[]).includes(k))
97
+ if (unknown.length) throw new Error(`Unknown argument${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${safe(k).slice(0, 40)}"`).join(", ")}: use ${UPDATE_FIELDS.join(", ")} and expectedRevision`)
98
+ const patch: Record<string, unknown> = {}
99
+ for (const field of UPDATE_FIELDS) if (args[field] !== undefined && args[field] !== null) patch[field] = args[field]
100
+ const r = await hub.concierge.watchlist.update(patch as WatchlistUpdate, expected, { signal })
101
+ const head = r.config.revision === expected ? `No change (revision ${safe(expected)})` : `Updated the watchlist (revision ${safe(expected)} → ${safe(r.config.revision)})`
102
+ return `${head}\n${watchlistConfigText(r.config)}`
103
+ },
104
+ },
105
+ {
106
+ name: "watchlist_watch",
107
+ description: "Watch a session (watch: false stops watching it). A subagent watches its root. Idempotent; grants nothing.",
108
+ properties: { addr: ADDR, watch: { type: "boolean", description: "Default true; false unwatches." } },
109
+ required: ["addr"],
110
+ readOnly: false,
111
+ organizes: true,
112
+ idempotent: true,
113
+ run: async (hub, args, signal) => {
114
+ const on = flag(args, "watch", true)
115
+ const addr = need(args, "addr")
116
+ const r = on ? await hub.concierge.watchlist.watch(addr, { signal }) : await hub.concierge.watchlist.unwatch(addr, { signal })
117
+ return watchlistVerbText(on ? "watch" : "unwatch", r)
118
+ },
119
+ },
120
+ {
121
+ name: "watchlist_mute",
122
+ description: "Mute a session in the watchlist (mute: false unmutes it): a muted session is never watched, whatever includes it. Not a notification mute. Idempotent.",
123
+ properties: { addr: ADDR, mute: { type: "boolean", description: "Default true; false unmutes." } },
124
+ required: ["addr"],
125
+ readOnly: false,
126
+ organizes: true,
127
+ idempotent: true,
128
+ run: async (hub, args, signal) => {
129
+ const on = flag(args, "mute", true)
130
+ const addr = need(args, "addr")
131
+ const r = on ? await hub.concierge.watchlist.mute(addr, { signal }) : await hub.concierge.watchlist.unmute(addr, { signal })
132
+ return watchlistVerbText(on ? "mute" : "unmute", r)
133
+ },
134
+ },
135
+ ]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-codex",
3
- "version": "0.7.4",
3
+ "version": "0.8.1",
4
4
  "private": true,
5
5
  "description": "The Codex app-server spoke: attaches to Codex's app-server (its own over stdio, and the shared daemon's socket when present), and the `hub codex` wrapper (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,12 +1,12 @@
1
1
  // Where the Codex spoke looks and keeps its files. It only reads Codex's home (CODEX_HOME, default ~/.codex) to find
2
2
  // the shared daemon's socket; its own files live in <hub state dir>/codex (the hub state dir is $HUB_STATE_DIR, else
3
- // ~/.local/state/hub), a private (0700) directory holding the service's pid file (spoke.pid) and its log.
3
+ // $XDG_STATE_HOME/hub or ~/.local/state/hub), a private (0700) directory holding the service's pid file (spoke.pid) and its log.
4
4
  // $HUB_CODEX_DAEMON_SOCKET names another daemon socket, or "off" for none.
5
5
  import { chmodSync, lstatSync, mkdirSync, readFileSync } from "node:fs"
6
6
  import { homedir } from "node:os"
7
7
  import path from "node:path"
8
8
  import { hubEnv, type Env } from "../../../src/env.ts"
9
- import { APP_DIR, stateBase } from "../../../src/migrate.ts"
9
+ import { resolveHubStateDir } from "../../../src/migrate.ts"
10
10
 
11
11
  export function codexHome(env: Env = process.env, home: string = homedir()): string {
12
12
  return env.CODEX_HOME || path.join(home, ".codex")
@@ -20,7 +20,7 @@ export function daemonSocketPath(env: Env = process.env, home: string = homedir(
20
20
  }
21
21
 
22
22
  export function codexStateDir(env: Env = process.env, home: string = homedir()): string {
23
- return path.join(hubEnv(env).HUB_STATE_DIR || path.join(stateBase(home), APP_DIR), "codex")
23
+ return path.join(resolveHubStateDir(env, home).dir, "codex")
24
24
  }
25
25
 
26
26
  export const pidFile = (env: Env = process.env, home: string = homedir()) => path.join(codexStateDir(env, home), "spoke.pid")