@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -126,6 +126,8 @@ export interface PushPrefs {
126
126
  * (once per run), or a retry for a quota, free-tier or auth error. Replaces the root's "failed" for that failure.
127
127
  */
128
128
  providerError: boolean
129
+ /** (CON-04) The Concierge needs you: its questions, permissions, Decisions, messages and spoken replies (opens /concierge). */
130
+ concierge: boolean
129
131
  }
130
132
 
131
133
  export const PUSH_PREF_KEYS = [
@@ -147,6 +149,7 @@ export const PUSH_PREF_KEYS = [
147
149
  "decision",
148
150
  "message",
149
151
  "providerError",
152
+ "concierge",
150
153
  ] as const satisfies readonly (keyof PushPrefs)[]
151
154
 
152
155
  /** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
@@ -170,6 +173,7 @@ export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
170
173
  decision: true,
171
174
  message: true,
172
175
  providerError: true,
176
+ concierge: true,
173
177
  })
174
178
 
175
179
  /** Default and allowed range of a device's activity update interval, in minutes. */
@@ -0,0 +1,34 @@
1
+ // Shared storage shapes of the team modules (HWA-33): the staffing aggregate is read by the definition store (a
2
+ // publish must not drop a staffed role) and owned by seats.ts.
3
+ import { CONTRACT_VERSION, type TeamStaffing } from "../../protocol"
4
+ import { AgentError } from "../agent/errors"
5
+
6
+ /** One aggregate per team (key: team ID): its staffing records, current and ended, and the staffing Idempotency-Keys. */
7
+ export const TEAM_SEATS = "team-seats"
8
+
9
+ export interface SeatKey {
10
+ readonly staffingID: string
11
+ readonly digest: string
12
+ readonly at: number
13
+ }
14
+
15
+ export interface SeatsDoc {
16
+ readonly v: 1
17
+ readonly teamID: string
18
+ readonly seats: readonly TeamStaffing[]
19
+ readonly keys: Readonly<Record<string, SeatKey>>
20
+ }
21
+
22
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
23
+
24
+ /** The stored staffing aggregate (undefined when missing); newer versions are unsupported, malformed ones internal. */
25
+ export function readSeatsDoc(raw: unknown, teamID: string): SeatsDoc | undefined {
26
+ if (raw === undefined) return undefined
27
+ if (isObj(raw) && typeof raw.v === "number" && raw.v > CONTRACT_VERSION)
28
+ throw new AgentError("unsupported", `The staffing of team ${teamID} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: "team-staffing" } })
29
+ if (!isObj(raw) || raw.v !== CONTRACT_VERSION || raw.teamID !== teamID || !Array.isArray(raw.seats)) throw new AgentError("internal", `Stored staffing of team ${teamID} is malformed`)
30
+ return { v: 1, teamID, seats: raw.seats as TeamStaffing[], keys: isObj(raw.keys) ? (raw.keys as Record<string, SeatKey>) : {} }
31
+ }
32
+
33
+ /** Current seats (not ended). */
34
+ export const currentSeats = (doc: SeatsDoc | undefined): TeamStaffing[] => (doc?.seats ?? []).filter((s) => s.until === undefined)
@@ -0,0 +1,346 @@
1
+ // Team-aware discovery (HWA-40; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Teams
2
+ // "Discovery (HWA-40)"; decisions zltiwb4s 2026-10-28 addition, r3vv4eon (+correction), 5etngvgm, qxzrdc6c).
3
+ //
4
+ // Who sees whom, for who / status / sessions / show / log / watch (with or without ?session=) and events:
5
+ // - A session caller holding a seat in at least one ACTIVE team (paused teams count; archived ones do not — the same
6
+ // rule teamDeliveryVerdict uses) is team-scoped: it sees itself, every current member of its teams (grouped by team,
7
+ // with each member's roles there) and, unless the owner turned `teamIsolation` on, sessions in no active team.
8
+ // Sessions seated only in teams the caller is not in are invisible; so are the caller's teammates' seats in teams
9
+ // the caller is not in (never listed). Under isolation, sessions in no team are hidden too.
10
+ // - Sessions in no active team, the user (console/device) and app tokens keep today's owner-wide view (r3vv4eon),
11
+ // whatever teamIsolation says (the frozen contract: isolation hides non-team sessions FROM TEAM MEMBERS only).
12
+ // They may filter by team. Without any team the answers are exactly today's.
13
+ // - An invisible session reads exactly like a foreign or unknown one: not_found "No session <addr>", and address
14
+ // resolution never counts it (no ambiguity, no candidate, no prefix match reveals it): Directory.resolve takes the
15
+ // visibility predicate for that.
16
+ // - Every census reads the teams and seats fresh (no cache), so staffing, unstaffing, replacement generations,
17
+ // pause/archive and the isolation setting apply to the very next read.
18
+ //
19
+ // Directions: `reach` is what the caller may send to a member (`send`) and receive from it (`receive`), each kind
20
+ // asked of teamDeliveryVerdict (never re-derived): per team with that team as the explicit context (the `#team`
21
+ // address), and per entry without one (a plain address). Visibility is not authorization: delivery is still enforced
22
+ // by the TeamPolicy (HWA-34) and acting by grants.
23
+ //
24
+ // Team role addresses (`@@role#team`, `@@role.project#team`) resolve here for reads (show, log, watch): the sessions
25
+ // currently seated in that role of that active team, restricted to what the caller may see.
26
+ //
27
+ // Cost per census: one team list (paged, ≤ TEAM_LIMITS.teamsPerUser) and one current-seat read per active team, plus
28
+ // the settings for team-scoped callers.
29
+ import { parseTeamRoleAddress, teamDeliveryVerdict, type TeamMessageKind, type TeamSeat } from "../../protocol"
30
+ import type { AgentActor } from "../agent/actor"
31
+ import type { Directory, DirectoryEntry, Resolved } from "../agent/directory"
32
+ import { AgentError } from "../agent/errors"
33
+ import type { Registry } from "../registry"
34
+ import type { TeamSeatStore } from "./seats"
35
+ import type { Team, TeamStore } from "./store"
36
+
37
+ const KINDS: readonly TeamMessageKind[] = ["msg", "ask"]
38
+ /** Pages of the team list read per census (teamsPerUser is 100; a page holds the list's default limit). */
39
+ const MAX_TEAM_PAGES = 20
40
+ /** Most candidates listed in an ambiguous error (as the directory). */
41
+ const CANDIDATES_MAX = 20
42
+
43
+ /** What the caller may send to / receive from a session, from teamDeliveryVerdict. */
44
+ export interface Reach {
45
+ readonly send: TeamMessageKind[]
46
+ readonly receive: TeamMessageKind[]
47
+ readonly direction: "two-way" | "outbound" | "inbound" | "none"
48
+ /** The caller can send nothing to it (a restricted path, or outside the caller's teams). */
49
+ readonly unreachable?: true
50
+ }
51
+
52
+ /** One team a listed session is seated in. */
53
+ export interface TeamMembership {
54
+ readonly teamID: string
55
+ readonly team: string
56
+ readonly roles: string[]
57
+ /** Team-scoped callers only, never for themselves: with this team as the explicit context. */
58
+ readonly reach?: Reach
59
+ }
60
+
61
+ export interface TeamGroupMember {
62
+ readonly sessionID: string
63
+ readonly role: string
64
+ readonly generation: number
65
+ }
66
+
67
+ /** A team in a discovery answer, with its listed members. */
68
+ export interface TeamGroup {
69
+ readonly teamID: string
70
+ readonly name: string
71
+ readonly title: string
72
+ readonly mode: "open" | "restricted"
73
+ readonly paused?: true
74
+ /** The caller's own roles there (team-scoped callers). */
75
+ readonly yourRoles?: string[]
76
+ readonly members: TeamGroupMember[]
77
+ }
78
+
79
+ /** A who entry: the directory entry plus additive team fields. */
80
+ export type DiscoveryEntry = DirectoryEntry & {
81
+ readonly harness?: string
82
+ readonly teams?: TeamMembership[]
83
+ /** Team-scoped callers only, never for themselves: what a plain address (no #team) reaches. */
84
+ readonly reach?: Reach
85
+ }
86
+
87
+ export interface WhoAnswer {
88
+ readonly sessions: DiscoveryEntry[]
89
+ /** Present when any listed session is seated in a team (always for team-scoped callers). */
90
+ readonly teams?: TeamGroup[]
91
+ /** "team" when the caller sees its teams only; absent for the owner-wide view. */
92
+ readonly scope?: "team"
93
+ }
94
+
95
+ export interface WhoFilter {
96
+ readonly project?: string
97
+ readonly role?: string
98
+ /** A team name or ID. */
99
+ readonly team?: string
100
+ }
101
+
102
+ interface Seat extends TeamSeat {
103
+ readonly generation: number
104
+ }
105
+
106
+ /** One read of the user's active teams and current seats, and what the caller may see. */
107
+ export interface Census {
108
+ readonly userID: string
109
+ /** Set when the caller is team-scoped. */
110
+ readonly callerID?: string
111
+ readonly callerTeams: ReadonlySet<string>
112
+ readonly isolation: boolean
113
+ readonly teams: ReadonlyMap<string, Team>
114
+ /** Current seats in active teams, per session. */
115
+ readonly seats: ReadonlyMap<string, readonly Seat[]>
116
+ }
117
+
118
+ export interface TeamDiscoveryDeps {
119
+ readonly teams: Pick<TeamStore, "list" | "settings">
120
+ readonly seats: Pick<TeamSeatStore, "list">
121
+ readonly directory: Pick<Directory, "who" | "resolve">
122
+ /** Harness per reported session of the user (who entries carry it when known). */
123
+ readonly harnesses?: (userID: string) => ReadonlyMap<string, string>
124
+ }
125
+
126
+ export const teamScoped = (census: Census): boolean => census.callerTeams.size > 0
127
+
128
+ /** Whether the census's caller may see the session (always, for the owner-wide view). */
129
+ export function visible(census: Census, sessionID: string): boolean {
130
+ if (!teamScoped(census) || sessionID === census.callerID) return true
131
+ const seats = census.seats.get(sessionID) ?? []
132
+ if (seats.some((s) => census.callerTeams.has(s.teamID))) return true
133
+ return !census.isolation && seats.length === 0
134
+ }
135
+
136
+ const missing = (addr: string) => new AgentError("not_found", `No session ${addr}`)
137
+
138
+ /** Journal subject fields that name a session (an event naming any hidden one is hidden as a whole). */
139
+ const SESSION_SUBJECT_FIELDS = ["sessionID", "rootID", "senderID", "recipientID", "sourceSessionID", "parentID"] as const
140
+
141
+ /**
142
+ * The journal events a team-scoped caller may read (unfiltered GET /events and GET /watch, HWA-28): none naming a
143
+ * session it cannot see, nor a team it holds no seat in. Undefined for the owner-wide view (no filtering at all).
144
+ */
145
+ export function eventVisibility(census: Census): ((subject: Readonly<Record<string, string>>) => boolean) | undefined {
146
+ if (!teamScoped(census)) return undefined
147
+ return (subject) => {
148
+ if (subject.teamID !== undefined && !census.callerTeams.has(subject.teamID)) return false
149
+ return SESSION_SUBJECT_FIELDS.every((field) => subject[field] === undefined || visible(census, subject[field]!))
150
+ }
151
+ }
152
+
153
+ export class TeamDiscovery {
154
+ constructor(private readonly deps: TeamDiscoveryDeps) {}
155
+
156
+ /** Reads the user's active teams, their current seats and the caller's scope. */
157
+ async census(userID: string, actor: AgentActor): Promise<Census> {
158
+ const teams = new Map<string, Team>()
159
+ let cursor: string | undefined
160
+ for (let page = 0; page < MAX_TEAM_PAGES; page++) {
161
+ const { items, cursor: next } = await this.deps.teams.list(userID, { state: "active", ...(cursor ? { cursor } : {}) })
162
+ for (const team of items) teams.set(team.id, team)
163
+ if (!next) break
164
+ cursor = next
165
+ }
166
+ const seats = new Map<string, Seat[]>()
167
+ for (const teamID of [...teams.keys()]) {
168
+ let current
169
+ try {
170
+ current = await this.deps.seats.list(userID, teamID, { current: true })
171
+ } catch {
172
+ teams.delete(teamID) // a seat aggregate this build cannot read takes no part (as in delivery)
173
+ continue
174
+ }
175
+ for (const s of current) seats.set(s.sessionID, [...(seats.get(s.sessionID) ?? []), { teamID, role: s.role, generation: s.generation }])
176
+ }
177
+ const callerID = actor.kind === "session" ? actor.sessionID : undefined
178
+ const callerTeams = new Set((callerID !== undefined ? (seats.get(callerID) ?? []) : []).map((s) => s.teamID))
179
+ let isolation = false
180
+ if (callerTeams.size) {
181
+ try {
182
+ isolation = (await this.deps.teams.settings(userID)).teamIsolation
183
+ } catch {
184
+ isolation = true // unreadable settings: show less, never more
185
+ }
186
+ }
187
+ return { userID, ...(callerTeams.size ? { callerID } : {}), callerTeams, isolation, teams, seats }
188
+ }
189
+
190
+ /**
191
+ * The sessions an address names for the caller (Directory.resolve plus `@@role#team`), never an invisible one.
192
+ * not_found when nothing visible matches; ambiguous lists visible candidates only.
193
+ */
194
+ resolve(census: Census, addr: string): Resolved {
195
+ const team = parseTeamRoleAddress(addr)
196
+ if (!team) return this.deps.directory.resolve(census.userID, addr, { visible: (id) => visible(census, id) })
197
+ const record = [...census.teams.values()].find((t) => t.name === team.team)
198
+ if (!record || (teamScoped(census) && !census.callerTeams.has(record.id))) throw missing(addr)
199
+ const sessions: DirectoryEntry[] = []
200
+ for (const [sessionID, seats] of census.seats) {
201
+ if (!seats.some((s) => s.teamID === record.id && s.role === team.role) || !visible(census, sessionID)) continue
202
+ const entry = this.entry(census.userID, sessionID)
203
+ if (entry && (team.project === undefined || entry.project === team.project)) sessions.push(entry)
204
+ }
205
+ if (!sessions.length) throw missing(addr)
206
+ return { address: { kind: "role", role: team.role, ...(team.project ? { project: team.project } : {}) }, sessions }
207
+ }
208
+
209
+ /** Exactly one visible session (a role with several visible holders is ambiguous). */
210
+ resolveOne(census: Census, addr: string): DirectoryEntry {
211
+ const { sessions } = this.resolve(census, addr)
212
+ if (sessions.length > 1) {
213
+ const candidates = sessions.slice(0, CANDIDATES_MAX).map((s) => ({ sessionID: s.sessionID, ...(s.name ? { name: s.name } : {}), ...(s.project ? { project: s.project } : {}) }))
214
+ throw new AgentError("ambiguous", `${addr} matches ${sessions.length} sessions`, { extra: { candidates } })
215
+ }
216
+ return sessions[0]!
217
+ }
218
+
219
+ /** GET /who: the caller's visible sessions, filtered, with team memberships, reach and groups. */
220
+ who(census: Census, filter: WhoFilter = {}): WhoAnswer {
221
+ const { userID } = census
222
+ const project = filter.project?.toLowerCase()
223
+ let role = filter.role?.replace(/^@@/, "")
224
+ let teamName = filter.team
225
+ if (role?.includes("#")) {
226
+ const parsed = parseTeamRoleAddress(`@@${role}`)
227
+ if (!parsed) throw new AgentError("invalid", "A role is 2–40 characters: a-z, 0-9 and -, optionally .project and #team")
228
+ if (teamName !== undefined && teamName !== parsed.team) throw new AgentError("invalid", "role names another team than team")
229
+ role = parsed.project ? `${parsed.role}.${parsed.project}` : parsed.role
230
+ teamName = parsed.team
231
+ }
232
+ const team = teamName !== undefined ? this.team(census, teamName) : undefined
233
+ const [roleName, inlineProject] = role?.split(".") ?? []
234
+ const roleProject = inlineProject?.toLowerCase() ?? project
235
+ // Global role holders (Directory.who validates the role) and the base list.
236
+ const globalHolders = role !== undefined ? new Set(this.deps.directory.who(userID, { ...(project ? { project } : {}), role }).map((e) => e.sessionID)) : undefined
237
+ const listed = new Map(this.deps.directory.who(userID, project ? { project } : {}).map((e) => [e.sessionID, e] as const))
238
+ // Seated members the directory does not list (offline, unnamed): every teammate, and a filtered team's members.
239
+ const extra = new Set(team ? [team.id] : [...census.callerTeams])
240
+ for (const [sessionID, seats] of census.seats) {
241
+ if (listed.has(sessionID) || !seats.some((s) => extra.has(s.teamID))) continue
242
+ const entry = this.entry(userID, sessionID)
243
+ if (entry && !entry.archived && (project === undefined || entry.project === project || entry.roles.some((r) => r.project === project))) listed.set(sessionID, entry)
244
+ }
245
+ const entries = [...listed.values()]
246
+ .filter((e) => visible(census, e.sessionID))
247
+ .filter((e) => !team || this.shown(census, e.sessionID).some((s) => s.teamID === team.id))
248
+ .filter((e) => {
249
+ if (role === undefined) return true
250
+ const seated = this.shown(census, e.sessionID).some((s) => s.role === roleName && (!team || s.teamID === team.id) && (roleProject === undefined || e.project === roleProject))
251
+ return seated || (!team && globalHolders!.has(e.sessionID))
252
+ })
253
+ const harnesses = this.deps.harnesses?.(userID)
254
+ const sessions = entries.map((e) => this.annotate(census, e, harnesses?.get(e.sessionID)))
255
+ const teams = this.groups(census, sessions.map((s) => s.sessionID), team?.id)
256
+ return { sessions, ...(teams.length ? { teams } : {}), ...(teamScoped(census) ? { scope: "team" as const } : {}) }
257
+ }
258
+
259
+ /** Team groups of the listed sessions (the caller's teams only, all of them, when team-scoped), by team name. */
260
+ groups(census: Census, sessionIDs: readonly string[], only?: string): TeamGroup[] {
261
+ const out = new Map<string, TeamGroupMember[]>()
262
+ if (teamScoped(census)) for (const teamID of census.callerTeams) if (only === undefined || only === teamID) out.set(teamID, [])
263
+ for (const id of sessionIDs) {
264
+ for (const s of this.shown(census, id)) {
265
+ if (only !== undefined && s.teamID !== only) continue
266
+ out.set(s.teamID, [...(out.get(s.teamID) ?? []), { sessionID: id, role: s.role, generation: s.generation }])
267
+ }
268
+ }
269
+ return [...out]
270
+ .map(([teamID, members]) => {
271
+ const team = census.teams.get(teamID)!
272
+ const yours = census.callerID !== undefined ? (census.seats.get(census.callerID) ?? []).filter((s) => s.teamID === teamID).map((s) => s.role) : []
273
+ return { teamID, name: team.name, title: team.title, mode: team.mode, ...(team.paused ? { paused: true as const } : {}), ...(yours.length ? { yourRoles: yours } : {}), members }
274
+ })
275
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
276
+ }
277
+
278
+ /** The session's team memberships as the caller may see them (show), each with reach when team-scoped. */
279
+ memberships(census: Census, sessionID: string): TeamMembership[] {
280
+ const byTeam = new Map<string, string[]>()
281
+ for (const s of this.shown(census, sessionID)) byTeam.set(s.teamID, [...(byTeam.get(s.teamID) ?? []), s.role])
282
+ return [...byTeam].map(([teamID, roles]) => {
283
+ const reach = this.reachOf(census, sessionID, teamID)
284
+ return { teamID, team: census.teams.get(teamID)!.name, roles, ...(reach ? { reach } : {}) }
285
+ })
286
+ }
287
+
288
+ /** What a team-scoped caller may send to / receive from the session (undefined otherwise, and for itself). */
289
+ reachOf(census: Census, sessionID: string, teamID?: string): Reach | undefined {
290
+ const caller = census.callerID
291
+ if (!teamScoped(census) || caller === undefined || sessionID === caller) return undefined
292
+ const mine = census.seats.get(caller) ?? []
293
+ const theirs = census.seats.get(sessionID) ?? []
294
+ const allowed = (from: readonly Seat[], to: readonly Seat[], kind: TeamMessageKind) =>
295
+ teamDeliveryVerdict({ kind, toUser: false, replyToPermittedAsk: false, senderSeats: from, recipientSeats: to, ...(teamID !== undefined ? { teamID } : {}), teams: census.teams }).allow
296
+ const send = KINDS.filter((k) => allowed(mine, theirs, k))
297
+ const receive = KINDS.filter((k) => allowed(theirs, mine, k))
298
+ const direction = send.length && receive.length ? "two-way" : send.length ? "outbound" : receive.length ? "inbound" : "none"
299
+ return { send, receive, direction, ...(send.length ? {} : { unreachable: true as const }) }
300
+ }
301
+
302
+ // ---------- internals ----------
303
+
304
+ /** The seats of a session the caller may be told about: in its own teams when team-scoped, else all. */
305
+ private shown(census: Census, sessionID: string): readonly Seat[] {
306
+ const seats = census.seats.get(sessionID) ?? []
307
+ return teamScoped(census) ? seats.filter((s) => census.callerTeams.has(s.teamID)) : seats
308
+ }
309
+
310
+ private annotate(census: Census, entry: DirectoryEntry, harness: string | undefined): DiscoveryEntry {
311
+ const teams = this.memberships(census, entry.sessionID)
312
+ const reach = this.reachOf(census, entry.sessionID)
313
+ return { ...entry, ...(harness ? { harness } : {}), ...(teams.length ? { teams } : {}), ...(reach ? { reach } : {}) }
314
+ }
315
+
316
+ /** A team filter: by name or ID, among the caller's teams when team-scoped (else not_found, like a missing one). */
317
+ private team(census: Census, nameOrID: string): Team {
318
+ const team = census.teams.get(nameOrID) ?? [...census.teams.values()].find((t) => t.name === nameOrID)
319
+ if (!team || (teamScoped(census) && !census.callerTeams.has(team.id))) throw new AgentError("not_found", `No team ${nameOrID}`)
320
+ return team
321
+ }
322
+
323
+ private entry(userID: string, sessionID: string): DirectoryEntry | undefined {
324
+ try {
325
+ return this.deps.directory.resolve(userID, sessionID).sessions.find((e) => e.sessionID === sessionID)
326
+ } catch {
327
+ return undefined
328
+ }
329
+ }
330
+ }
331
+
332
+ /** The harness of each session the user's spokes report now (roots and their subagents), from the catalog. */
333
+ export function catalogHarnesses(registry: Pick<Registry, "catalog" | "sessionTenant">): (userID: string) => ReadonlyMap<string, string> {
334
+ return (userID) => {
335
+ const out = new Map<string, string>()
336
+ for (const machine of registry.catalog()) {
337
+ for (const process of machine.processes) {
338
+ for (const s of process.sessions) {
339
+ if (registry.sessionTenant(s.id) !== userID) continue
340
+ for (const id of [s.id, ...s.status.map((e) => e.id)]) if (!out.has(id)) out.set(id, process.harness)
341
+ }
342
+ }
343
+ }
344
+ return out
345
+ }
346
+ }
@@ -0,0 +1,46 @@
1
+ // Role and policy-edge editing of a team's draft (HWA-37): small edits computed against the current draft spec (or
2
+ // the published one) and saved through TeamStore.saveDraft, which validates the whole spec again. Nothing here
3
+ // publishes: delivery and staffing keep using the published version until an explicit publish.
4
+ //
5
+ // - setRole: adds the role, or replaces the role of that name wholesale (its other fields are dropped).
6
+ // - removeRole: drops the role and every edge touching it; the entry role cannot be removed (name another first).
7
+ // - setEdge: replaces the edge drawn from → to (and, for a two-way edge, the one drawn to → from). An edge drawn the
8
+ // other way that already covers the pair is refused by the spec check (one edge per ordered pair).
9
+ // - removeEdge: drops the edge drawn from → to, or a two-way edge drawn to → from.
10
+ import { AgentError } from "../agent/errors"
11
+ import type { TeamSpec } from "./validation"
12
+
13
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
14
+
15
+ /** A patch replacing the roles (and edges) for saveDraft. */
16
+ export interface DraftEdit {
17
+ readonly roles?: readonly unknown[]
18
+ readonly edges?: readonly unknown[]
19
+ }
20
+
21
+ export function setRole(spec: TeamSpec, name: string, fields: Record<string, unknown>): DraftEdit {
22
+ if (fields.name !== undefined && fields.name !== name) throw new AgentError("invalid", "The role's name is in the path; to rename, add the new role and remove the old one")
23
+ const role = { ...fields, name }
24
+ const at = spec.roles.findIndex((r) => r.name === name)
25
+ const roles = at < 0 ? [...spec.roles, role] : spec.roles.map((r, i) => (i === at ? role : r))
26
+ return { roles }
27
+ }
28
+
29
+ export function removeRole(spec: TeamSpec, name: string): DraftEdit {
30
+ if (!spec.roles.some((r) => r.name === name)) throw new AgentError("not_found", `The draft has no role ${name.slice(0, 40)}`)
31
+ if (spec.entryRole === name) throw new AgentError("invalid", `${name} is the entry role`, { hint: "Name another entry role first (team update entryRole)" })
32
+ return { roles: spec.roles.filter((r) => r.name !== name), edges: spec.edges.filter((e) => e.from !== name && e.to !== name) }
33
+ }
34
+
35
+ export function setEdge(spec: TeamSpec, edge: Record<string, unknown>): DraftEdit {
36
+ if (!isObj(edge) || typeof edge.from !== "string" || typeof edge.to !== "string") throw new AgentError("invalid", "An edge needs from and to (role names)")
37
+ const { from, to } = edge
38
+ const replaced = (e: TeamSpec["edges"][number]) => (e.from === from && e.to === to) || (edge.direction === "two-way" && e.from === to && e.to === from)
39
+ return { edges: [...spec.edges.filter((e) => !replaced(e)), edge] }
40
+ }
41
+
42
+ export function removeEdge(spec: TeamSpec, from: string, to: string): DraftEdit {
43
+ const drop = (e: TeamSpec["edges"][number]) => (e.from === from && e.to === to) || (e.direction === "two-way" && e.from === to && e.to === from)
44
+ if (!spec.edges.some(drop)) throw new AgentError("not_found", `The draft has no edge ${from.slice(0, 40)} → ${to.slice(0, 40)}`)
45
+ return { edges: spec.edges.filter((e) => !drop(e)) }
46
+ }
@@ -0,0 +1,46 @@
1
+ // Compact journal events of teams (HWA-33; contract TEAM_EVENT_TYPES, family `manage`). Allowlisted payloads only:
2
+ // IDs, enums (state, change), revisions, version numbers, generations and flags. Never names, titles, descriptions,
3
+ // role names or labels, responsibilities, tags, metadata, projects or any other definition text. The routes
4
+ // (HWA-37) emit them after a store call succeeds; HWA-34 owns `team.denied`, HWA-36 `team.intake`.
5
+ import type { EventInput } from "../agent/events"
6
+ import type { TeamSeatRecord } from "./seats"
7
+ import type { Team } from "./store"
8
+
9
+ /** What a `team.updated` changed. */
10
+ export type TeamChange = "draft" | "draft_discarded" | "published" | "paused" | "resumed"
11
+
12
+ export const teamCreatedEvent = (team: Team): EventInput => ({
13
+ type: "team.created",
14
+ family: "manage",
15
+ subject: { teamID: team.id },
16
+ data: { revision: team.revision, version: team.version, state: team.state },
17
+ })
18
+
19
+ export const teamUpdatedEvent = (team: Team, change: TeamChange): EventInput => ({
20
+ type: "team.updated",
21
+ family: "manage",
22
+ subject: { teamID: team.id },
23
+ data: { change, revision: team.revision, version: team.version, paused: team.paused === true },
24
+ })
25
+
26
+ /** Archived (`state: archived`) or restored (`state: active`). */
27
+ export const teamArchivedEvent = (team: Team): EventInput => ({
28
+ type: "team.archived",
29
+ family: "manage",
30
+ subject: { teamID: team.id },
31
+ data: { state: team.state, revision: team.revision, version: team.version },
32
+ })
33
+
34
+ export const teamStaffedEvent = (seat: TeamSeatRecord): EventInput => ({
35
+ type: "team.staffed",
36
+ family: "manage",
37
+ subject: { teamID: seat.teamID, staffingID: seat.id, sessionID: seat.sessionID },
38
+ data: { generation: seat.generation, revision: seat.revision, version: seat.teamVersion },
39
+ })
40
+
41
+ export const teamUnstaffedEvent = (seat: TeamSeatRecord): EventInput => ({
42
+ type: "team.unstaffed",
43
+ family: "manage",
44
+ subject: { teamID: seat.teamID, staffingID: seat.id, sessionID: seat.sessionID },
45
+ data: { generation: seat.generation, revision: seat.revision },
46
+ })
@@ -0,0 +1,100 @@
1
+ // Team intake routes on /api/agent (HWA-36; contract: Teams `POST /teams/:id/intake`, scope teams:submit). The rest
2
+ // of the teams surface (definitions, staffing, roster, teams:read|manage) is HWA-37's.
3
+ //
4
+ // POST /teams/:id/intake {title, brief?, priority?, labels?, due?, criteria?, ref?} Idempotency-Key
5
+ // GET /teams/:id/intake?status&limit&cursor → {items, cursor?}
6
+ // GET /teams/:id/intake/:intake → {intake}
7
+ // POST /teams/:id/intake/:intake/status {status, expectedRevision, reason?} → {intake, assignment?}
8
+ //
9
+ // Who (TeamIntakeService decides the details): users; app tokens with teams:submit (submit, read and cancel their
10
+ // own) — reads also accept teams:read/teams:manage once HWA-37 registers them; session tokens (submit under team
11
+ // policy, entry seats report status). Another user's or an unknown team: 404 not_found. Bodies are bounded here.
12
+ import { boundedText } from "../agent/body"
13
+ import type { AgentContext, AgentRoute } from "../agent/routes"
14
+ import { AgentError } from "../agent/errors"
15
+ import { idempotencyKeyOf, storeAnswer } from "../agent/idempotency"
16
+ import type { Store } from "../store"
17
+ import type { TeamIntakeService } from "./intake"
18
+
19
+ const PREFIX = "/api/agent/teams/:id/intake"
20
+ const NO_STORE = { "cache-control": "no-store" }
21
+ /** Request bodies (the brief is ≤ 16 000 characters). */
22
+ export const INTAKE_BODY_MAX = 96 * 1024
23
+
24
+ export interface IntakeRoutesDeps {
25
+ readonly intake: TeamIntakeService
26
+ readonly audit?: Pick<Store, "audit">
27
+ }
28
+
29
+ const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
30
+
31
+ async function bodyOf(request: Request): Promise<Record<string, unknown>> {
32
+ const declared = Number(request.headers.get("content-length") ?? "0")
33
+ if (declared > INTAKE_BODY_MAX) throw new AgentError("invalid", `Body must be at most ${INTAKE_BODY_MAX} bytes`)
34
+ const text = await boundedText(request, INTAKE_BODY_MAX)
35
+ if (text.length > INTAKE_BODY_MAX) throw new AgentError("invalid", `Body must be at most ${INTAKE_BODY_MAX} bytes`)
36
+ let raw: unknown
37
+ try {
38
+ raw = JSON.parse(text === "" ? "{}" : text)
39
+ } catch {
40
+ throw new AgentError("invalid", "Body must be JSON")
41
+ }
42
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
43
+ return raw as Record<string, unknown>
44
+ }
45
+
46
+ function intParam(url: URL, name: string): number | undefined {
47
+ const raw = url.searchParams.get(name)
48
+ return raw === null ? undefined : /^-?[0-9]{1,12}$/.test(raw) ? Number(raw) : Number.NaN
49
+ }
50
+
51
+ export function intakeRoutes(deps: IntakeRoutesDeps): AgentRoute[] {
52
+ const { intake } = deps
53
+ const audited = async (context: AgentContext, action: string, target: string) => {
54
+ await deps.audit?.audit({ actor: context.user.id, action, target, detail: context.actor.kind })
55
+ }
56
+ return [
57
+ {
58
+ method: "POST",
59
+ path: PREFIX,
60
+ allow: { scope: "teams:submit", session: true },
61
+ async handle(context) {
62
+ const body = await bodyOf(context.request)
63
+ const key = idempotencyKeyOf(context.request)
64
+ const { intake: item, created } = await intake.submit(context.user.id, context.actor, context.params.id!, body, key === undefined ? {} : { idempotencyKey: key })
65
+ if (created) await audited(context, "team.intake.submit", item.id)
66
+ return storeAnswer(created, { intake: item })
67
+ },
68
+ },
69
+ {
70
+ method: "GET",
71
+ path: PREFIX,
72
+ allow: { app: true, session: true },
73
+ async handle({ url, user, actor, params }) {
74
+ const limit = intParam(url, "limit")
75
+ const status = url.searchParams.get("status") || undefined
76
+ const cursor = url.searchParams.get("cursor") || undefined
77
+ return ok(await intake.list(user.id, actor, params.id!, { ...(limit !== undefined ? { limit } : {}), ...(status ? { status } : {}), ...(cursor ? { cursor } : {}) }))
78
+ },
79
+ },
80
+ {
81
+ method: "GET",
82
+ path: `${PREFIX}/:intake`,
83
+ allow: { app: true, session: true },
84
+ async handle({ user, actor, params }) {
85
+ return ok({ intake: await intake.get(user.id, actor, params.id!, params.intake!) })
86
+ },
87
+ },
88
+ {
89
+ method: "POST",
90
+ path: `${PREFIX}/:intake/status`,
91
+ allow: { app: true, session: true },
92
+ async handle(context) {
93
+ const body = await bodyOf(context.request)
94
+ const result = await intake.setStatus(context.user.id, context.actor, context.params.id!, context.params.intake!, body)
95
+ await audited(context, `team.intake.${result.intake.status === "routed" ? "progress" : result.intake.status}`, result.intake.id)
96
+ return ok(result)
97
+ },
98
+ },
99
+ ]
100
+ }