@huynle/hub 0.7.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +632 -49
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // Management (create, list, revoke) is for user actors only (allow {}): a session or app token cannot mint or
9
9
  // see tokens (escalation guard).
10
+ import { boundedJson } from "./body"
10
11
  import { randomBytes, timingSafeEqual } from "node:crypto"
11
12
  import type { AppToken } from "../agent-data"
12
13
  import { hashToken, type Store } from "../store"
@@ -19,7 +20,7 @@ import type { AgentRoute } from "./routes"
19
20
  * acting on sessions (approve, deny, answer, prompt, stop) and launching sessions (phase 4), each of which still
20
21
  * needs a grant held by the token (with the act verb, or `launch` covering the repo).
21
22
  */
22
- export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe"]
23
+ export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe", "profiles:read", "profiles:manage", "profiles:assign", "workspaces:read", "workspaces:manage", "artifacts:read", "artifacts:write", "artifacts:ai", "teams:submit", "teams:read", "teams:manage"]
23
24
  export const DEFAULT_APP_SCOPES: readonly string[] = ["sessions:read"]
24
25
  /** What `hub login` (the CLI login device code) asks for unless it asks for more: reading sessions and messaging. */
25
26
  export const DEFAULT_CLI_LOGIN_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read"]
@@ -121,7 +122,7 @@ export function appTokenRoutes(store: Store): AgentRoute[] {
121
122
  path: PREFIX,
122
123
  allow: {},
123
124
  async handle({ request, user }) {
124
- const { record, token } = await issueAppToken(store, user.id, parseCreate(await request.json()))
125
+ const { record, token } = await issueAppToken(store, user.id, parseCreate(await boundedJson(request)))
125
126
  return Response.json({ ...view(record), token }, { status: 201, headers: NO_STORE })
126
127
  },
127
128
  },
@@ -10,6 +10,9 @@
10
10
  // fails the record.
11
11
  // - Admins see only a hub-wide summary in the Store's audit (who: the user ID; kind: agent.<verb> and the actor
12
12
  // kind; when), never targets, reasons or text (design §7).
13
+ // - (CON-06) `as: "concierge"`: entries whose actor is one of the user's Concierge sessions (current or past) are
14
+ // marked here, centrally, so every recording route (acting, launch, message, roles) gets it; the console says
15
+ // "by @@concierge". A failed identity read leaves the entry unmarked (it is still recorded).
13
16
  import type { Clock, StateStore } from "../backend/types"
14
17
  import type { AuditEntry } from "../store"
15
18
  import { type Party, sanitizeText } from "./envelope"
@@ -50,6 +53,8 @@ export interface AuditInput {
50
53
  /** The agent's reason (`--why`) or the policy's. */
51
54
  readonly reason?: string
52
55
  readonly outcome: AuditOutcome
56
+ /** (CON-06) Done by one of the user's Concierge sessions (current or past). */
57
+ readonly as?: "concierge"
53
58
  }
54
59
 
55
60
  export interface AgentAuditEntry extends AuditInput {
@@ -80,6 +85,8 @@ export interface AgentAuditDeps {
80
85
  readonly recorded?: (userID: string, entry: AgentAuditEntry) => void
81
86
  /** Ring size (default AGENT_AUDIT_LIMIT). */
82
87
  readonly limit?: number
88
+ /** (CON-06) Whether a session is one of the user's Concierge sessions (current or past). */
89
+ readonly concierge?: (userID: string, sessionID: string) => Promise<boolean>
83
90
  }
84
91
 
85
92
  const ENTRIES = "agent-audit"
@@ -122,6 +129,7 @@ export class AgentAudit {
122
129
  const seq = (await updateVersioned<number>(state, userID, SEQ, SEQ_KEY, (current) => (current ?? 0) + 1))!
123
130
  const grant = cleanID(input.grant)
124
131
  const reason = cleanReason(input.reason)
132
+ const as = input.as === "concierge" || (input.actor.kind === "session" && (await this.isConcierge(userID, input.actor.sessionID))) ? ("concierge" as const) : undefined
125
133
  const entry: AgentAuditEntry = {
126
134
  seq,
127
135
  at: this.now(),
@@ -132,6 +140,7 @@ export class AgentAudit {
132
140
  decision: input.decision,
133
141
  ...(reason ? { reason } : {}),
134
142
  outcome: input.outcome,
143
+ ...(as ? { as } : {}),
135
144
  }
136
145
  await state.put(userID, ENTRIES, keyOf(seq), entry)
137
146
  if (seq > this.limit) await state.delete(userID, ENTRIES, keyOf(seq - this.limit))
@@ -144,6 +153,14 @@ export class AgentAudit {
144
153
  return entry
145
154
  }
146
155
 
156
+ private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
157
+ try {
158
+ return (await this.deps.concierge?.(userID, sessionID)) === true
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
147
164
  /** The user's entries, newest first, a page at a time. */
148
165
  async list(userID: string, options: AuditListOptions = {}): Promise<AuditPage> {
149
166
  const limit = Math.min(Math.max(Math.floor(options.limit ?? AUDIT_PAGE_DEFAULT), 1), AUDIT_PAGE_MAX)
@@ -0,0 +1,163 @@
1
+ // Bounded request bodies for JSON routes (HWA-28 review of HWA-21/24). The server's maxRequestBodySize is ~41 MiB
2
+ // (artifact blob uploads plus drain headroom; Bun 1.3.5 did not even enforce it for a chunked body the handler keeps
3
+ // up with), so every JSON route keeps its own bounds, in bytes AND time:
4
+ // - a declared content-length over the bound is refused without keeping anything (read and discarded within the
5
+ // drain budget below, else unread with Connection: close);
6
+ // - a streamed (chunked) body is kept only up to the bound; the rest is then read and discarded, but at most
7
+ // BODY_LIMITS.drainBytes more within BODY_LIMITS.drainMs. A body that ends within that budget leaves the
8
+ // kept-alive connection clean (cancelling mid-stream made Bun answer the client's NEXT request on it with a bare
9
+ // 400, quirk cmramwb8). One that doesn't (endless, huge or dripping) is cancelled and the answer carries
10
+ // `Connection: close`, so clients and proxies do not reuse the dirty socket;
11
+ // - time is budgeted by RATE, not a fixed deadline: a body may take graceMs + received / minBytesPerSecond
12
+ // (so at most graceMs + max / minBytesPerSecond for the route's byte limit), and no gap between bytes may exceed
13
+ // idleMs. Slow but steady clients succeed (a 1.4 MB body at 20 KiB/s); a drip cannot hold a handler.
14
+ // - the body is read continuously (nothing but the next read is awaited), so Bun never gathers a flood into one
15
+ // huge chunk while JS waits on something else (a full-speed endless body stays at a few MiB of memory).
16
+ // Over the bound answers 400 invalid "The body is larger than N bytes" (extra.limit) before any validation; too slow
17
+ // answers 400 invalid "The body arrived too slowly …" (Connection: close).
18
+ //
19
+ // Limit of the platform (Bun 1.3.5, measured with raw sockets): after a handler answered, Bun keeps reading — and
20
+ // discarding — whatever a client goes on sending on that socket, even with `Connection: close`, a cancelled body or
21
+ // server.timeout(); the handler and its memory are free, but the socket stays open while bytes flow. That holds for
22
+ // every route, including ones that never read a body. Only a front proxy with a request body limit ends it there.
23
+ // Bun 1.4.4 (the pinned runtime) closes the socket after a `Connection: close` answer instead, and enforces the
24
+ // server's maxRequestBodySize on chunked bodies (see its headroom in ../http.ts).
25
+ import { AgentError } from "./errors"
26
+
27
+ /** The default bound of an agent JSON body (messages, launches, grants, workspaces, …). */
28
+ export const AGENT_JSON_BODY_MAX = 1 << 20
29
+ /** Profiles carry versions of up to 512 KiB (PROFILE_LIMITS.versionBytes), JSON-escaped. */
30
+ export const PROFILE_JSON_BODY_MAX = 4 << 20
31
+
32
+ export interface BodyLimits {
33
+ /** At most this many bytes past the bound are read (and discarded) to keep the connection clean. */
34
+ readonly drainBytes: number
35
+ /** …within this long after the bound was crossed. */
36
+ readonly drainMs: number
37
+ /** Time every body gets before the rate counts. */
38
+ readonly graceMs: number
39
+ /** Each byte received earns 1 / minBytesPerSecond seconds more. */
40
+ readonly minBytesPerSecond: number
41
+ /** The longest gap without bytes. */
42
+ readonly idleMs: number
43
+ }
44
+
45
+ export const BODY_LIMITS: BodyLimits = { drainBytes: 8 << 20, drainMs: 2_000, graceMs: 30_000, minBytesPerSecond: 8 * 1024, idleMs: 30_000 }
46
+
47
+ /** Where a body's time budget ends (ms, same clock as the inputs): the rate budget or the idle limit, whichever first. */
48
+ export function bodyDeadline(progress: { readonly startedAt: number; readonly lastByteAt: number; readonly received: number }, limits: Pick<BodyLimits, "graceMs" | "minBytesPerSecond" | "idleMs">): number {
49
+ const earned = progress.startedAt + limits.graceMs + (progress.received * 1000) / limits.minBytesPerSecond
50
+ return Math.min(earned, progress.lastByteAt + limits.idleMs)
51
+ }
52
+
53
+ export type BoundedBody = { readonly ok: true; readonly bytes: Uint8Array } | { readonly ok: false; readonly reason: "too_large" | "timeout"; readonly close: boolean }
54
+
55
+ const TIMEOUT = Symbol("timeout")
56
+ type Read = Awaited<ReturnType<ReadableStreamDefaultReader<Uint8Array>["read"]>>
57
+
58
+ /** reader.read(), or TIMEOUT once `deadline` (performance.now()) has passed. */
59
+ export async function readBefore(reader: ReadableStreamDefaultReader<Uint8Array>, deadline: number): Promise<Read | typeof TIMEOUT> {
60
+ const left = deadline - performance.now()
61
+ if (left <= 0) return TIMEOUT
62
+ let timer: ReturnType<typeof setTimeout> | undefined
63
+ try {
64
+ return await Promise.race([reader.read(), new Promise<typeof TIMEOUT>((resolve) => (timer = setTimeout(() => resolve(TIMEOUT), left)))])
65
+ } finally {
66
+ clearTimeout(timer)
67
+ }
68
+ }
69
+ export const isTimeout = (value: unknown): value is typeof TIMEOUT => value === TIMEOUT
70
+
71
+ const cancel = (reader: ReadableStreamDefaultReader<Uint8Array>) => reader.cancel().catch(() => {})
72
+
73
+ /**
74
+ * Discards what is left of a body within the byte and time budget: true when the body ended (the connection is
75
+ * clean), false when the budget ran out (the reader is cancelled: answer with Connection: close).
76
+ */
77
+ export async function drainBounded(reader: ReadableStreamDefaultReader<Uint8Array>, limits: Partial<BodyLimits> = {}): Promise<boolean> {
78
+ const { drainBytes, drainMs } = { ...BODY_LIMITS, ...limits }
79
+ const deadline = performance.now() + drainMs
80
+ let drained = 0
81
+ try {
82
+ for (;;) {
83
+ const next = await readBefore(reader, deadline)
84
+ if (next === TIMEOUT) break
85
+ if (next.done) return true
86
+ drained += next.value.byteLength
87
+ if (drained > drainBytes) break
88
+ }
89
+ } catch {
90
+ return false // the client went away
91
+ }
92
+ await cancel(reader)
93
+ return false
94
+ }
95
+
96
+ /** The body's bytes, at most `max` of them, within the byte and time limits (see the file comment). */
97
+ export async function readBounded(request: Request, max: number, limits: Partial<BodyLimits> = {}): Promise<BoundedBody> {
98
+ const declared = Number(request.headers.get("content-length") ?? "")
99
+ if (Number.isFinite(declared) && declared > max) {
100
+ // Declared too large: nothing is kept. Bun 1.3.5 does not skip unread declared bytes either (a client reusing the
101
+ // socket got 431 for its next request), so a body within the drain budget is read and discarded; a bigger one
102
+ // is refused unread, with Connection: close.
103
+ if (!request.body) return { ok: false, reason: "too_large", close: false }
104
+ const reader = request.body.getReader()
105
+ const { drainBytes } = { ...BODY_LIMITS, ...limits }
106
+ if (declared - max > drainBytes) {
107
+ await cancel(reader)
108
+ return { ok: false, reason: "too_large", close: true }
109
+ }
110
+ return { ok: false, reason: "too_large", close: !(await drainBounded(reader, { ...limits, drainBytes: declared })) }
111
+ }
112
+ if (!request.body) return { ok: true, bytes: new Uint8Array(0) }
113
+ const budget = { ...BODY_LIMITS, ...limits }
114
+ const startedAt = performance.now()
115
+ let lastByteAt = startedAt
116
+ const reader = request.body.getReader()
117
+ const chunks: Uint8Array[] = []
118
+ let size = 0
119
+ for (;;) {
120
+ let next: Read | typeof TIMEOUT
121
+ try {
122
+ next = await readBefore(reader, bodyDeadline({ startedAt, lastByteAt, received: size }, budget))
123
+ } catch {
124
+ return { ok: false, reason: "timeout", close: true } // the client went away mid-body
125
+ }
126
+ if (next === TIMEOUT) {
127
+ await cancel(reader)
128
+ return { ok: false, reason: "timeout", close: true }
129
+ }
130
+ if (next.done) break
131
+ lastByteAt = performance.now()
132
+ size += next.value.byteLength
133
+ // Past the bound (one oversized chunk included): nothing more is kept, this chunk neither.
134
+ if (size > max) return { ok: false, reason: "too_large", close: !(await drainBounded(reader, limits)) }
135
+ chunks.push(next.value)
136
+ }
137
+ return { ok: true, bytes: Buffer.concat(chunks) }
138
+ }
139
+
140
+ /** Why a body was cut off for time, with the limits in force. */
141
+ export function slowMessage(limits: Partial<BodyLimits> = {}): string {
142
+ const { graceMs, minBytesPerSecond, idleMs } = { ...BODY_LIMITS, ...limits }
143
+ const s = (ms: number) => `${Math.round(ms / 100) / 10} s`
144
+ return `The body arrived too slowly (no bytes for ${s(idleMs)}, or under ${Math.round(minBytesPerSecond / 102.4) / 10} KiB/s after ${s(graceMs)})`
145
+ }
146
+
147
+ /** The AgentError for a refused body (Connection: close when the socket is left dirty). */
148
+ export function bodyError(result: Extract<BoundedBody, { ok: false }>, max: number, limits: Partial<BodyLimits> = {}): AgentError {
149
+ if (result.reason === "timeout") return new AgentError("invalid", slowMessage(limits), { close: true })
150
+ return new AgentError("invalid", `The body is larger than ${max} bytes`, { extra: { limit: max }, ...(result.close ? { close: true } : {}) })
151
+ }
152
+
153
+ /** The body as UTF-8 text of at most `max` bytes ("" without a body); AgentError invalid over the limits. */
154
+ export async function boundedText(request: Request, max: number, limits: Partial<BodyLimits> = {}): Promise<string> {
155
+ const result = await readBounded(request, max, limits)
156
+ if (!result.ok) throw bodyError(result, max, limits)
157
+ return Buffer.from(result.bytes).toString("utf8")
158
+ }
159
+
160
+ /** JSON.parse of a bounded body (a SyntaxError for invalid JSON, as request.json() threw). */
161
+ export async function boundedJson(request: Request, max = AGENT_JSON_BODY_MAX): Promise<unknown> {
162
+ return JSON.parse(await boundedText(request, max))
163
+ }
@@ -20,6 +20,11 @@
20
20
  // - close(): a Decision whose subject went away (a permission answered elsewhere) is closed, not answered; the asker
21
21
  // hears why.
22
22
  // - Every open and change (answer, timeout, escalation, expiry, close) goes to `changed` (the console's 'decision' event).
23
+ // - (CON-06) `via`: an answer is a tap unless it says voice or text. Voice/text answers only a confirm-tier
24
+ // Decision, and only when the hub's `via` check admits it (the Concierge tiers: armed, presence, the user's
25
+ // device); otherwise forbidden "Answer this decision with a tap". Never a model: only the user's device answers.
26
+ // - (CON-06) `as: "concierge"`: set when the asker is one of the user's Concierge sessions (current or past), so
27
+ // the console can say "by @@concierge". The `act` action kind is a Concierge proposal (concierge/proposals.ts).
23
28
  import type { Clock, StateStore, Timers } from "../backend/types"
24
29
  import type { AgentActor, UserActor } from "./actor"
25
30
  import type { Delivery } from "./delivery"
@@ -37,6 +42,22 @@ export type DecisionState = "open" | "answered" | "expired" | "closed"
37
42
  export const URGENCIES: readonly Urgency[] = ["low", "normal", "high"]
38
43
  export const TIERS: readonly Tier[] = ["confirm", "tap"]
39
44
  export const DECISION_STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
45
+ /** (CON-06) How the user answered: tap (the default, absent), or voice/text in the Concierge panel. */
46
+ export type AnswerVia = "tap" | "voice" | "text"
47
+ export const ANSWER_VIAS: readonly AnswerVia[] = ["tap", "voice", "text"]
48
+ /** The refusal of a voice/text answer the hub does not admit. */
49
+ export const TAP_ONLY = "Answer this decision with a tap"
50
+ /** The refusal of a voice/text answer that picks a lasting option (coordinator decision, 93265or7). */
51
+ export const ALWAYS_TAP_ONLY = "Answer 'always' with a tap"
52
+
53
+ /**
54
+ * Whether an option is not once-off: `always` (saves a lasting rule, e.g. an escalated permission approved for
55
+ * good), or approving a grant request (creates a lasting grant). Voice and text pick only once-off options.
56
+ */
57
+ export function lastingOption(decision: Pick<Decision, "action" | "options">, option: string | undefined): boolean {
58
+ if (option === "always") return true
59
+ return decision.action?.kind === "grant" && !decision.options.some((o) => o.id === option && o.safe)
60
+ }
40
61
 
41
62
  /** Options a Decision offers. */
42
63
  export const OPTIONS_MAX = 8
@@ -67,10 +88,25 @@ export interface DecisionAnswer {
67
88
  readonly at: number
68
89
  /** The device the user answered from. */
69
90
  readonly deviceID?: string
91
+ /** (CON-06) voice or text; absent = tap. */
92
+ readonly via?: "voice" | "text"
70
93
  }
71
94
 
72
95
  /** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
73
- export type DecisionAction = PermissionAction | GrantRequestAction
96
+ export type DecisionAction = PermissionAction | GrantRequestAction | ActAction
97
+
98
+ /**
99
+ * (CON-06) A Concierge proposal (concierge/proposals.ts): answering `go` runs the act verb on the target AS THE USER
100
+ * through the acting engine (tenancy re-checked); `hold` does nothing.
101
+ */
102
+ export interface ActAction {
103
+ readonly kind: "act"
104
+ readonly verb: "stop" | "deny" | "prompt" | "answer"
105
+ /** The session ID it was proposed for (resolved when proposed). */
106
+ readonly target: string
107
+ /** The verb's validated input (requestID, formID, answer, text). */
108
+ readonly input: Readonly<Record<string, unknown>>
109
+ }
74
110
 
75
111
  /** An escalated permission request: the answer replies to it as the user. */
76
112
  export interface PermissionAction {
@@ -121,6 +157,8 @@ export interface Decision {
121
157
  readonly closed?: { readonly at: number; readonly reason: string }
122
158
  /** Run (as the answering user) when the user answers; set by the hub only, never by an asker. */
123
159
  readonly action?: DecisionAction
160
+ /** (CON-06) Asked by one of the user's Concierge sessions: the console says "by @@concierge". */
161
+ readonly as?: "concierge"
124
162
  }
125
163
 
126
164
  /** What the hub (never an asker's request) adds to an ask. */
@@ -148,8 +186,13 @@ export interface AskInput {
148
186
  export interface AnswerInput {
149
187
  readonly option?: string
150
188
  readonly text?: string
189
+ /** (CON-06) Absent = tap. */
190
+ readonly via?: AnswerVia
151
191
  }
152
192
 
193
+ /** (CON-06) Admits a voice/text answer (throws forbidden otherwise). */
194
+ export type ViaCheck = (userID: string, decision: Decision, via: "voice" | "text", by: UserActor) => Promise<void>
195
+
153
196
  export type NotifyReason = "opened" | "escalated"
154
197
 
155
198
  /**
@@ -177,6 +220,10 @@ export interface DecisionsDeps {
177
220
  readonly changed?: (userID: string, decision: Decision) => void
178
221
  /** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
179
222
  readonly act?: DecisionActor
223
+ /** (CON-06) Admits voice/text answers; without it every voice/text answer is refused. */
224
+ readonly via?: ViaCheck
225
+ /** (CON-06) Whether a session is one of the user's Concierge sessions (current or past): the Decision's `as`. */
226
+ readonly concierge?: (userID: string, sessionID: string) => Promise<boolean>
180
227
  }
181
228
 
182
229
  const DECISIONS = "decisions"
@@ -274,6 +321,7 @@ export class Decisions {
274
321
  const v = validate(input)
275
322
  const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
276
323
  const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
324
+ const asConcierge = actor.kind === "session" && (await this.isConcierge(userID, actor.sessionID))
277
325
  const decision: Decision = {
278
326
  id: this.newID(),
279
327
  messageID: message.id,
@@ -291,6 +339,7 @@ export class Decisions {
291
339
  at: message.at,
292
340
  expiresAt: message.expiresAt ?? message.at + v.expiresInMs,
293
341
  ...(extra.action ? { action: extra.action } : {}),
342
+ ...(asConcierge ? { as: "concierge" as const } : {}),
294
343
  }
295
344
  const written = await this.deps.state.put(userID, DECISIONS, decision.id, decision, { ifVersion: 0 })
296
345
  if (!written.ok) throw new Error("decision id collision")
@@ -331,9 +380,18 @@ export class Decisions {
331
380
  if (!current || by.userID !== userID) throw notFound()
332
381
  if (current.state !== "open") throw gone()
333
382
  if (current.tier === "tap" && by.via !== "cookie") throw new AgentError("forbidden", "This decision needs a tap in the signed-in console", { hint: "Answer it in the console" })
383
+ const via = input.via ?? "tap"
384
+ if (!ANSWER_VIAS.includes(via)) throw invalid(`via is one of ${ANSWER_VIAS.join(", ")}`)
334
385
  const choice = this.choice(current, input)
386
+ if (via !== "tap") {
387
+ // Voice/text: confirm tier only, and only what the hub's check admits (never without one).
388
+ if (current.tier !== "confirm" || !this.deps.via) throw new AgentError("forbidden", TAP_ONLY, { hint: "Tap an option" })
389
+ // Only once-off options by voice or text: a lasting one (always, a grant) needs a tap.
390
+ if (lastingOption(current, choice.option)) throw new AgentError("forbidden", ALWAYS_TAP_ONLY, { hint: "Tap the option" })
391
+ await this.deps.via(userID, current, via, by)
392
+ }
335
393
  const at = this.now()
336
- const answer: DecisionAnswer = { ...choice, answeredBy: "user", at, deviceID: by.deviceID }
394
+ const answer: DecisionAnswer = { ...choice, answeredBy: "user", at, deviceID: by.deviceID, ...(via !== "tap" ? { via } : {}) }
337
395
  const decision = await this.settle(userID, id, answer)
338
396
  if (!decision) throw gone()
339
397
  const note = decision.action ? await this.runAction(userID, decision, choice, by) : undefined
@@ -399,6 +457,15 @@ export class Decisions {
399
457
 
400
458
  // ---- internals ----------------------------------------------------------------------------------------
401
459
 
460
+ /** Whether the session is one of the user's Concierge sessions; a failed read is no label (never fails the ask). */
461
+ private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
462
+ try {
463
+ return (await this.deps.concierge?.(userID, sessionID)) === true
464
+ } catch {
465
+ return false
466
+ }
467
+ }
468
+
402
469
  /** Tells the changed listener (its failure never fails the caller). */
403
470
  private emit(userID: string, decision: Decision): void {
404
471
  try {
@@ -11,10 +11,15 @@
11
11
  // - Roles: one holder, the idle one (by real evidence) else the most recently active, else (all offline) the
12
12
  // one seen last; the message records the holder generation. sendAll: every holder, a message each. A session
13
13
  // never messages itself: it is left out of a role it holds, and an address naming only it is invalid.
14
- // - Pending: no deliver-capable connection reports the recipient (offline) or every one failed → kept for
14
+ // - Pending: no deliver-capable connection reports the recipient (offline) or every one failed (including an
15
+ // OpenCode spoke refusing a routine wake:false msg while the recipient's turn runs, RF-05) → kept for
15
16
  // PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). In a
16
17
  // cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
17
- // entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice.
18
+ // entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice. A flush stops at a
19
+ // session's first failed delivery (its later messages wait, in order, for the next frame: one round trip per busy
20
+ // or unreachable session per frame), and a waking message first flushes only its own session's pending ones.
21
+ // Ordering limit: a waking message to a session that is still busy is admitted (queued after the turn) before the
22
+ // quiet ones pending for it, which follow when the turn ends.
18
23
  // OpenCode holds admitted messages itself across forms, interrupts and restarts (P2-T0), so there is no
19
24
  // "retained" retry loop on the hub.
20
25
  // - Re-wake: an open ask to an idle recipient (Registry.idle: native or hooks evidence) is woken again after
@@ -37,6 +42,7 @@ import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKi
37
42
  import { isDeliverResult } from "../../protocol"
38
43
  import { randomBytes } from "node:crypto"
39
44
  import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
45
+ import type { TeamPolicy } from "../teams/policy"
40
46
  import type { AgentActor } from "./actor"
41
47
  import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
42
48
  import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
@@ -82,6 +88,19 @@ export interface DeliveryAlert {
82
88
  readonly text: string
83
89
  }
84
90
 
91
+ /** What a message to a session is, for the inbound policy. */
92
+ export type InboundKind = "msg" | "ask" | "reply" | "decline"
93
+
94
+ /**
95
+ * (CON-04) Who may message a session, beyond the existing rules: `admit` throws (forbidden) to refuse before anything
96
+ * is stored. `original`: the message a reply or decline answers. The hub passes the Concierge inbound rule.
97
+ */
98
+ export interface InboundPolicy {
99
+ admit(userID: string, from: Party, sessionID: string, kind: InboundKind, original?: Message): Promise<void>
100
+ /** Who a sender may reach at all (the Concierge: only @me, and replies to asks addressed to it); throws to refuse. */
101
+ outbound?(userID: string, from: Party, kind: InboundKind, original?: Message): Promise<void>
102
+ }
103
+
85
104
  export interface DeliveryDeps {
86
105
  readonly state: StateStore
87
106
  readonly counters: Counters
@@ -104,6 +123,10 @@ export interface DeliveryDeps {
104
123
  readonly owner?: string
105
124
  /** How often the re-wake loop runs (default REWAKE_TICK_MS). */
106
125
  readonly tickMs?: number
126
+ /** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
127
+ readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
128
+ /** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
129
+ readonly inbound?: InboundPolicy
107
130
  }
108
131
 
109
132
  export interface SendOptions {
@@ -119,6 +142,8 @@ export interface Sent {
119
142
  readonly message: Message
120
143
  /** How it reached the recipient; undefined when the recipient is not a session (stored only). */
121
144
  readonly delivery?: DeliveryState
145
+ /** Whether it was sent to wake its recipient (false: wake:false, a subagent, the hop cap or a spent wake budget). */
146
+ readonly woke?: boolean
122
147
  }
123
148
 
124
149
  /** A reply or decline as sent, with the message it answered. */
@@ -202,6 +227,11 @@ export class Delivery {
202
227
  private readonly newID: () => string
203
228
  /** Per user: the flush in progress (flushes run one at a time). */
204
229
  private readonly flushing = new Map<string, Promise<void>>()
230
+ /**
231
+ * Pending flush counters since start (no message text): attempts that failed (offline spokes, a busy OpenCode
232
+ * session refusing a routine quiet msg) and messages skipped behind such a failure until the next frame.
233
+ */
234
+ readonly flushStats = { failed: 0, skipped: 0 }
205
235
  /** Per user and session: the primer in flight (one at a time); true once it was admitted. */
206
236
  private readonly priming = new Map<string, Promise<boolean>>()
207
237
  /** Users seen since start (sent a message, or one of their spokes reported sessions): the loop looks at these. */
@@ -250,7 +280,7 @@ export class Delivery {
250
280
  async send(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent> {
251
281
  if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
252
282
  const from = this.party(userID, actor)
253
- const resolved = this.others(actor, this.deps.directory.resolve(userID, addr))
283
+ const resolved = await this.resolveFor(userID, actor, from, addr, kind)
254
284
  return this.sendTo(userID, from, addr, resolved, this.pick(resolved), kind, text, options)
255
285
  }
256
286
 
@@ -258,7 +288,7 @@ export class Delivery {
258
288
  async sendAll(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent[]> {
259
289
  if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
260
290
  const from = this.party(userID, actor)
261
- const resolved = this.others(actor, this.deps.directory.resolve(userID, addr))
291
+ const resolved = await this.resolveFor(userID, actor, from, addr, kind)
262
292
  const sent: Sent[] = []
263
293
  for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
264
294
  return sent
@@ -266,12 +296,12 @@ export class Delivery {
266
296
 
267
297
  /** Replies to a message, from its recipient to its real sender (never via a role), and delivers the reply. */
268
298
  reply(userID: string, actor: AgentActor, replyTo: string, text: string, options: SendOptions = {}): Promise<Replied> {
269
- return this.answer(userID, actor, replyTo, options, (input) => this.messages.reply(userID, { ...input, text }))
299
+ return this.answer(userID, actor, replyTo, options, "reply", (input) => this.messages.reply(userID, { ...input, text }))
270
300
  }
271
301
 
272
302
  /** Declines an ask, with a reason, and delivers the decline to the asker. */
273
303
  decline(userID: string, actor: AgentActor, replyTo: string, why: string, options: SendOptions = {}): Promise<Replied> {
274
- return this.answer(userID, actor, replyTo, options, (input) => this.messages.decline(userID, { ...input, why }))
304
+ return this.answer(userID, actor, replyTo, options, "decline", (input) => this.messages.decline(userID, { ...input, why }))
275
305
  }
276
306
 
277
307
  /** A human turn in the session (console prompt or utterance, or typed in the harness): its hop chain starts over. */
@@ -280,9 +310,10 @@ export class Delivery {
280
310
  }
281
311
 
282
312
  /** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
283
- flush(userID: string): Promise<void> {
313
+ /** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
314
+ flush(userID: string, sessionID?: string): Promise<void> {
284
315
  const previous = this.flushing.get(userID) ?? Promise.resolve()
285
- const run = previous.then(() => this.flushNow(userID))
316
+ const run = previous.then(() => this.flushNow(userID, sessionID))
286
317
  const tracked = run.finally(() => {
287
318
  if (this.flushing.get(userID) === tracked) this.flushing.delete(userID)
288
319
  })
@@ -290,6 +321,21 @@ export class Delivery {
290
321
  return run
291
322
  }
292
323
 
324
+ /**
325
+ * (CON-10) Drops the messages still pending for a session that ended for good (a retired Concierge session): they
326
+ * are never delivered later, and their delivery reads "dropped" (never waiting). Answers how many were dropped.
327
+ */
328
+ async dropPending(userID: string, sessionID: string): Promise<number> {
329
+ let dropped = 0
330
+ for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
331
+ if (value.sessionID !== sessionID) continue
332
+ if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) continue
333
+ await this.messages.setDelivery(userID, value.messageID, "dropped")
334
+ dropped++
335
+ }
336
+ return dropped
337
+ }
338
+
293
339
  /** Registry.onSessions: a spoke reported its sessions (connected, reconnected, or its periodic snapshot). */
294
340
  async onSessions(connID: string): Promise<void> {
295
341
  const userID = this.deps.registry.connTenant(connID)
@@ -378,10 +424,12 @@ export class Delivery {
378
424
  private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
379
425
  const sessionID = entry.sessionID
380
426
  const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
427
+ await this.deps.inbound?.outbound?.(userID, from, kind)
428
+ await this.deps.inbound?.admit(userID, from, sessionID, kind)
381
429
  this.gate(userID, sessionID)
382
430
  await this.limiter.admit(userID, from, recipient)
383
431
  const hop = await this.hops.next(userID, from)
384
- const generation = resolved.address.kind === "role" ? roleGeneration(entry, resolved.address) : undefined
432
+ const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
385
433
  const message = await this.messages.send(userID, {
386
434
  from,
387
435
  to: addr,
@@ -463,6 +511,13 @@ export class Delivery {
463
511
  return (await this.request(userID, sessionID, req)) !== undefined
464
512
  }
465
513
 
514
+ /** The address's sessions (team-aware, HWA-34) without the sender, narrowed to those team policy permits. */
515
+ private async resolveFor(userID: string, actor: AgentActor, from: Party, addr: string, kind: "msg" | "ask"): Promise<Resolved> {
516
+ const team = await this.deps.teams?.prepare(userID, from, addr)
517
+ const resolved = this.others(actor, team?.resolved ?? this.deps.directory.resolve(userID, addr))
518
+ return team ? { ...resolved, sessions: await team.admit(resolved.sessions, kind, this.limiter) } : resolved
519
+ }
520
+
466
521
  /** The resolved sessions without the sending session itself; invalid when only it is left (no self-messages). */
467
522
  private others(actor: AgentActor, resolved: Resolved): Resolved {
468
523
  if (actor.kind !== "session") return resolved
@@ -487,12 +542,18 @@ export class Delivery {
487
542
  actor: AgentActor,
488
543
  replyTo: string,
489
544
  options: SendOptions,
545
+ kind: "reply" | "decline",
490
546
  store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
491
547
  ): Promise<Replied> {
492
548
  const from = this.party(userID, actor)
493
549
  const original = await this.messages.get(userID, replyTo)
494
550
  const to = original && partyKey(original.recipient) === partyKey(from) ? original.from : undefined
495
- if (to?.kind === "session") this.gate(userID, to.sessionID)
551
+ if (original) await this.deps.inbound?.outbound?.(userID, from, kind, original)
552
+ if (to?.kind === "session") {
553
+ await this.deps.inbound?.admit(userID, from, to.sessionID, kind, original)
554
+ this.gate(userID, to.sessionID)
555
+ }
556
+ if (to && original) await this.deps.teams?.answer(userID, from, original, to, kind, this.limiter)
496
557
  if (to) await this.limiter.admit(userID, from, to)
497
558
  const hop = await this.hops.next(userID, from)
498
559
  const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
@@ -559,12 +620,15 @@ export class Delivery {
559
620
 
560
621
  /** Delivers now, or queues the message as pending when no spoke takes it. */
561
622
  private async deliver(userID: string, message: Message, sessionID: string, wake: boolean, urgent: boolean): Promise<Sent> {
623
+ // Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
624
+ // one starts then carries them, instead of them arriving inside it.
625
+ if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
562
626
  const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
563
- if (delivered) return delivered
627
+ if (delivered) return { ...delivered, woke: wake }
564
628
  const at = this.now()
565
629
  const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
566
630
  await this.deps.state.put(userID, PENDING, message.id, pending)
567
- return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending" }
631
+ return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", woke: wake }
568
632
  }
569
633
 
570
634
  /** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
@@ -593,12 +657,20 @@ export class Delivery {
593
657
  return undefined
594
658
  }
595
659
 
596
- private async flushNow(userID: string): Promise<void> {
597
- const entries = (await this.deps.state.list<Pending>(userID, PENDING)).sort((a, b) => a.value.at - b.value.at || (a.key < b.key ? -1 : 1))
660
+ private async flushNow(userID: string, only?: string): Promise<void> {
661
+ const entries = (await this.deps.state.list<Pending>(userID, PENDING))
662
+ .filter((e) => only === undefined || e.value.sessionID === only)
663
+ .sort((a, b) => a.value.at - b.value.at || (a.key < b.key ? -1 : 1))
598
664
  /** Cluster mode: the sessions whose lease this flush holds (true) or another process holds (false). */
599
665
  const leases = new Map<string, boolean>()
666
+ /** Sessions whose delivery failed in this flush: their later messages wait for the next frame (in order). */
667
+ const failed = new Set<string>()
600
668
  try {
601
669
  for (const { key, value, version } of entries) {
670
+ if (failed.has(value.sessionID)) {
671
+ this.flushStats.skipped++
672
+ continue
673
+ }
602
674
  const message = value.expiresAt > this.now() ? await this.messages.get(userID, value.messageID) : undefined
603
675
  // Gone, or an ask that closed (expired) while it waited: nothing to deliver.
604
676
  if (!message || (message.kind === "ask" && message.state !== "open")) {
@@ -610,6 +682,10 @@ export class Delivery {
610
682
  // Another process may have delivered it before this one took the lease.
611
683
  if (this.deps.locks && !(await this.deps.state.get(userID, PENDING, key))) continue
612
684
  if (await this.attempt(userID, message, value.sessionID, value.wake, value.urgent)) await this.deps.state.delete(userID, PENDING, key)
685
+ else {
686
+ failed.add(value.sessionID)
687
+ this.flushStats.failed++
688
+ }
613
689
  }
614
690
  } finally {
615
691
  const { locks } = this.deps
@@ -635,7 +711,11 @@ export class Delivery {
635
711
  return held
636
712
  }
637
713
 
638
- /** `steer`: urgent, or the first msg after a primer to an idle session (see attempt). */
714
+ /**
715
+ * `steer`: urgent, or the first msg after a primer to an idle session (see attempt). A wake:false msg keeps
716
+ * "queue" unless urgent: that marks it routine, and the OpenCode spoke (RF-05) admits it steered without resume,
717
+ * refusing it while a turn runs so it stays pending here until the turn ends (never a model call of its own).
718
+ */
639
719
  private deliverRequest(message: Message, sessionID: string, wake: boolean, steer: boolean): DeliverRequest {
640
720
  const delivery: DeliveryMode = message.kind === "msg" && !steer ? "queue" : "steer"
641
721
  return {