@huynle/hub 0.7.1 → 0.7.3

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 (194) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/Dockerfile +3 -0
  3. package/README.md +148 -11
  4. package/package.json +3 -1
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/main.ts +4 -4
  7. package/packages/claude-code/src/wrapper.ts +3 -2
  8. package/packages/cli/dist/hub-cli.js +19334 -7849
  9. package/packages/cli/package.json +11 -0
  10. package/packages/cli/src/acting.ts +70 -0
  11. package/packages/cli/src/args.ts +91 -0
  12. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  13. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  14. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  15. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  16. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  17. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  18. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  19. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  20. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  21. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  22. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  23. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  24. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  25. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  26. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  27. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  28. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  29. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  30. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  31. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  32. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  33. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  34. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  35. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  36. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  37. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  38. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  39. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  40. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  41. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  42. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  43. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  44. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  45. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  46. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  47. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  48. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  49. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  50. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  51. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  52. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  53. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  54. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  55. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  56. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  57. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  58. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  59. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  60. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  61. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  62. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  63. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  64. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  65. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  66. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  67. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  68. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  69. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  70. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  71. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  72. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  73. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  74. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  75. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  76. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  77. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  78. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  79. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  80. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  81. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  82. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  83. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  84. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  85. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  86. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  87. package/packages/cli/src/bootstrap/release/README.md +115 -0
  88. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  89. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  90. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  91. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  92. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  93. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  94. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  95. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  96. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  97. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  98. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  99. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  100. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  101. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  102. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  103. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  104. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  105. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  106. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  107. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  108. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  109. package/packages/cli/src/cli.ts +814 -0
  110. package/packages/cli/src/credentials.ts +75 -0
  111. package/packages/cli/src/decisions.ts +106 -0
  112. package/packages/cli/src/format.ts +185 -0
  113. package/packages/cli/src/grants.ts +105 -0
  114. package/packages/cli/src/http.ts +86 -0
  115. package/packages/cli/src/launches.ts +157 -0
  116. package/packages/cli/src/login.ts +160 -0
  117. package/packages/cli/src/main.ts +63 -0
  118. package/packages/cli/src/mcp.ts +879 -0
  119. package/packages/cli/src/messaging.ts +108 -0
  120. package/packages/cli/src/node.ts +279 -0
  121. package/packages/cli/src/openshell.ts +166 -0
  122. package/packages/cli/src/setup/commands.ts +809 -0
  123. package/packages/cli/src/setup/contributors.ts +171 -0
  124. package/packages/cli/src/setup/dispatch.ts +74 -0
  125. package/packages/cli/src/setup/launch-driver.ts +109 -0
  126. package/packages/cli/src/setup/runtime.ts +456 -0
  127. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  128. package/packages/cli/src/setup/state.ts +244 -0
  129. package/packages/cli/src/setup/user-bin.ts +39 -0
  130. package/packages/cli/src/spoke-services.ts +109 -0
  131. package/packages/cli/src/watch.ts +28 -0
  132. package/packages/codex/package.json +1 -1
  133. package/packages/codex/src/main.ts +3 -2
  134. package/packages/codex/src/spoke.ts +4 -1
  135. package/packages/codex/src/wrapper.ts +7 -3
  136. package/packages/node/package.json +1 -1
  137. package/packages/node/src/harnesses.ts +39 -4
  138. package/packages/node/src/run.ts +1 -1
  139. package/packages/preset/package.json +1 -1
  140. package/packages/protocol/package.json +1 -1
  141. package/packages/protocol/src/harness-events.ts +2 -2
  142. package/packages/protocol/src/index.ts +70 -7
  143. package/packages/runtime-openshell/package.json +1 -1
  144. package/packages/runtime-sdk/package.json +1 -1
  145. package/packages/sdk/package.json +1 -1
  146. package/packages/sdk/src/client.ts +55 -4
  147. package/packages/sdk/src/errors.ts +1 -0
  148. package/packages/sdk/src/sse.ts +4 -1
  149. package/packages/sdk/src/types.ts +59 -0
  150. package/packages/spoke-sdk/package.json +1 -1
  151. package/packages/spoke-sdk/src/enroll.ts +26 -4
  152. package/src/central/agent/app-tokens.ts +27 -17
  153. package/src/central/agent/archive-inactive.ts +66 -0
  154. package/src/central/agent/directory-routes.ts +155 -12
  155. package/src/central/agent/directory.ts +1 -1
  156. package/src/central/agent/errors.ts +2 -0
  157. package/src/central/agent/event-cursor.ts +49 -0
  158. package/src/central/agent/event-writers.ts +181 -0
  159. package/src/central/agent/events-routes.ts +148 -0
  160. package/src/central/agent/events.ts +277 -0
  161. package/src/central/agent/names.ts +1 -1
  162. package/src/central/agent/read-routes.ts +32 -36
  163. package/src/central/agent/routes.ts +14 -11
  164. package/src/central/agent/watch.ts +65 -174
  165. package/src/central/agent/webhook-routes.ts +10 -2
  166. package/src/central/agent/webhooks.ts +49 -5
  167. package/src/central/auth.ts +127 -1
  168. package/src/central/backend/index.ts +3 -0
  169. package/src/central/backend/types.ts +2 -0
  170. package/src/central/http.ts +116 -32
  171. package/src/central/mcp-http.ts +116 -0
  172. package/src/central/push.ts +14 -1
  173. package/src/central/registry.ts +34 -5
  174. package/src/central/store.ts +60 -0
  175. package/src/config.ts +70 -7
  176. package/src/index.ts +90 -91
  177. package/src/spoke/launchers.ts +109 -3
  178. package/src/spoke/spoke.ts +8 -4
  179. package/src/spoke/timeline.ts +59 -3
  180. package/src/spoke/tracker.ts +59 -6
  181. package/src/status.ts +97 -6
  182. package/web/dist/apple-touch-icon.png +0 -0
  183. package/web/dist/assets/index-Dm8OmkaO.css +1 -0
  184. package/web/dist/assets/index-FUJbj_Du.js +20 -0
  185. package/web/dist/favicon.ico +0 -0
  186. package/web/dist/icon-192.png +0 -0
  187. package/web/dist/icon-512.png +0 -0
  188. package/web/dist/icon-maskable-512.png +0 -0
  189. package/web/dist/icon.svg +5 -5
  190. package/web/dist/index.html +10 -4
  191. package/web/dist/manifest.webmanifest +2 -2
  192. package/web/dist/sw.js +4 -1
  193. package/web/dist/assets/index-D9zMbxSg.css +0 -1
  194. package/web/dist/assets/index-db1z4duA.js +0 -20
@@ -16,6 +16,10 @@ import { authPage, cookieValue, deviceName, OIDC_COOKIE, OidcClient, OidcError }
16
16
  import { isNotificationKind, Push, type PushSender } from "./push"
17
17
  import { pushEndpointProblem, resolveHost as systemResolveHost, type ResolveHost } from "./push-target"
18
18
  import { Webhooks } from "./agent/webhooks"
19
+ import { issueAppToken } from "./agent/app-tokens"
20
+ import { AGENT_EVENTS_AGE_MS, AGENT_EVENTS_MAX, AgentEvents, archiveEvent } from "./agent/events"
21
+ import { EventWriters } from "./agent/event-writers"
22
+ import { MemoryEventLog } from "./backend/memory-events"
19
23
  import { WEBHOOK_SCOPE } from "./agent/webhook-routes"
20
24
  import { hubBackends, type Backends } from "./backend"
21
25
  import { createResolver, type AgentActor, type BearerVerifier, type CredentialMode } from "./agent/actor"
@@ -33,11 +37,12 @@ import type { Launch } from "./agent/launch-store"
33
37
  import { decisionStateQuery, MessageFeed } from "./agent/message-routes"
34
38
  import { hubDirectory } from "./agent/directory-routes"
35
39
  import { agentApi, agentRoutes, agentVerifiers, isAgentPath, wellKnown, type AgentRoute } from "./agent/routes"
40
+ import { MCP_PATH, mcpHttp } from "./mcp-http"
36
41
  import { SessionTokens } from "./agent/session-tokens"
37
42
  import { SessionTokenPush } from "./session-token-push"
38
43
  import { TokenKeys } from "./token-keys"
39
44
  import { EventLog } from "./events-log"
40
- import { BusyError, Registry, type RegistryMessage } from "./registry"
45
+ import { BusyError, Registry } from "./registry"
41
46
  import { createActions, type Unsupported, unsupportedMessage } from "./actions"
42
47
  import { audioFormat, SpeechError } from "./speech/provider"
43
48
  import { SpeechService } from "./speech/service"
@@ -52,9 +57,8 @@ import { randomBytes, randomUUID } from "node:crypto"
52
57
  import { LaunchEngine } from "./agent/launch-engine"
53
58
  import { LaunchTokens } from "./agent/launch-tokens"
54
59
  import { LaunchFeed, launchInfo } from "./agent/launch-routes"
55
- import type { WatchRegistry } from "./agent/watch"
56
60
  import { DRAIN_RETRY_AFTER_MS, DRAIN_TIMEOUT_MS, RequestTimeoutError, RequestTooLargeError, SpokeError, SpokeHub, type DrainOptions, type Notifier, type SpokeHubOptions, type SpokeSocketData } from "./spoke-ws"
57
- import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
61
+ import { ACTIVITY_INTERVAL_MAX, ACTIVITY_INTERVAL_MIN, LOCAL_USER, PUSH_PREF_KEYS, quietHoursInput, Store, VIEW_PREF_KEYS, VIEW_PREF_MAX_ARCHIVED, type DeviceRecord, type LegacyPushPrefs, type PushPrefs, type UserRecord, type ViewPrefs } from "./store"
58
62
 
59
63
  export interface Speech {
60
64
  transcribe(audio: Uint8Array, format: string): Promise<string>
@@ -137,6 +141,10 @@ export interface Central {
137
141
  readonly launchTokens: LaunchTokens
138
142
  /** Webhooks (P4-T10): registrations and the outbox sender; launch changes are published to it. */
139
143
  readonly webhooks: Webhooks
144
+ /** The canonical agent event journal (GET /api/agent/events, MCP events): compact events per user, 24 h / 10,000. */
145
+ readonly agentEvents: AgentEvents
146
+ /** The journal's writers (state, blockers, presence, messages, launches); settled() waits for queued appends. */
147
+ readonly agentEventWriters: EventWriters
140
148
  /** The JSON API's route table (method, path, access), for audits and tests. */
141
149
  readonly routes: readonly RouteInfo[]
142
150
  /**
@@ -232,8 +240,6 @@ const MAX_TITLE_CHARS = 200
232
240
  const MAX_HERDR_WORKSPACE_CHARS = 100
233
241
  /** View preferences: entries per list and characters per entry. */
234
242
  const VIEW_PREF_MAX_ENTRIES = 500
235
- /** hiddenSessions holds archived sessions (Archive in the console), which add up over time. */
236
- const VIEW_PREF_MAX_ARCHIVED = 5000
237
243
  const VIEW_PREF_MAX_CHARS = 4096
238
244
  const DECISIONS = new Set<PermissionDecision>(["once", "always", "reject"])
239
245
  /** Longest utterance accepted for conversation mode (characters, after trimming). */
@@ -535,6 +541,7 @@ export function startCentral(options: CentralOptions): Central {
535
541
  ...(alert.requestID ? { requestID: alert.requestID } : {}),
536
542
  ...(alert.subject ? { subject: alert.subject } : {}),
537
543
  ...(alert.key ? { key: alert.key } : {}),
544
+ ...(alert.unlessPref ? { unlessPref: alert.unlessPref } : {}),
538
545
  }
539
546
  notifier.notify(alert.kind, alert.sessionID, alert.title, alert.text, alert.owner, extra).catch((cause) => {
540
547
  console.error("[hub] push notification failed:", cause instanceof Error ? cause.message : cause)
@@ -822,20 +829,33 @@ export function startCentral(options: CentralOptions): Central {
822
829
  resolve: options.resolveHost ?? systemResolveHost,
823
830
  ...(config.webhooksAllowPrivate ? { allowPrivate: true } : {}),
824
831
  appAlive: (userID, tokenID) => store.agentData(userID).appTokens?.some((t) => t.id === tokenID && Array.isArray(t.scopes) && t.scopes.includes(WEBHOOK_SCOPE)) === true,
832
+ appScopes: (userID, tokenID) => store.agentData(userID).appTokens?.find((t) => t.id === tokenID)?.scopes,
825
833
  userAgent: `hub/${options.version ?? "0.0.0"}`,
826
834
  })
827
- launchFeed.subscribe((userID, launch) => {
828
- void webhooks
829
- .onLaunch(userID, launch, launchInfo(launch))
830
- // Queued here, sent by the webhooks leader (kicked when that is another pod).
831
- .then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined))
832
- .catch((cause) => console.error(`[hub] webhook queue failed: ${(cause as Error).message}`))
833
- }, { origin: true })
834
835
  const webhookLeader = leader("webhooks", {
835
836
  onElected: () => webhooks.start(),
836
837
  onDeposed: () => webhooks.stop(),
837
838
  onKick: () => void webhooks.drain().catch((cause) => console.error(`[hub] webhook delivery failed: ${(cause as Error).message}`)),
838
839
  })
840
+ // The canonical agent event journal (lifecycle design, Events): each change appended once across the cluster;
841
+ // presence by the agent-events leader, everything else where it happens (event-writers.ts).
842
+ const agentEvents = new AgentEvents({
843
+ log: backends.agentEvents ?? new MemoryEventLog({ now, max: AGENT_EVENTS_MAX, maxAgeMs: AGENT_EVENTS_AGE_MS }),
844
+ kv: backends.kv,
845
+ now,
846
+ onAppend: (userID, record) => webhooks.publishCanonical(userID, record.event, record.family, record.audience).then((queued) => (queued && !webhookLeader.isLeader() ? webhookLeader.kick() : undefined)),
847
+ })
848
+ const agentEventWriters = new EventWriters({
849
+ events: agentEvents,
850
+ kv: backends.kv,
851
+ registry,
852
+ machineOwner: (machineID) => store.machines().find((m) => m.id === machineID)?.ownerID,
853
+ users: allUsers,
854
+ messages: feed,
855
+ launches: launchFeed,
856
+ })
857
+ agentEventWriters.start()
858
+ leader("agent-events", { onElected: () => agentEventWriters.elected(), onDeposed: () => agentEventWriters.deposed() })
839
859
  for (const loop of leaders) void loop.start()
840
860
 
841
861
  // One credential resolver for every /api request: device cookie, device token, session and app tokens.
@@ -889,26 +909,7 @@ export function startCentral(options: CentralOptions): Central {
889
909
  void escalations.closed(tenant, requestID).catch((cause) => console.error(`[hub] escalation close failed: ${(cause as Error).message}`))
890
910
  })
891
911
  const escalate: Escalate = (escalation) => escalations.escalate(escalation)
892
- /** Cluster mode: what GET /api/agent/watch reads; status of sessions on any pod comes from the user's live events. */
893
- const watchRegistry = live
894
- ? (userID: string): WatchRegistry => ({
895
- catalog: () => registry.catalog(),
896
- sessionTenant: (sessionID) => registry.sessionTenant(sessionID),
897
- subscribe: (listener) => {
898
- const offCatalog = registry.subscribe((message) => {
899
- if (message.type === "catalog") listener(message)
900
- })
901
- const subscription = live.subscribe(userID, (event) => {
902
- if (event.message.type === "status") listener(event.message as unknown as RegistryMessage, userID)
903
- })
904
- return () => {
905
- offCatalog()
906
- subscription.close()
907
- }
908
- },
909
- })
910
- : undefined
911
- const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, launchFeed, webhooks, maxPromptChars: MAX_PROMPT_CHARS, ...(watchRegistry ? { watchRegistry } : {}) }
912
+ const agentDeps = { draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS }
912
913
  const resolveActor = createResolver({
913
914
  cookieName: COOKIE,
914
915
  findDevice: (token) => deviceOfToken(token),
@@ -927,6 +928,16 @@ export function startCentral(options: CentralOptions): Central {
927
928
  routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
928
929
  })
929
930
 
931
+ // POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
932
+ // to this handler in process.
933
+ const mcp = mcpHttp({
934
+ publicURL: () => publicURL,
935
+ isOwnOrigin: (origin) => origins.has(origin),
936
+ verify: async (token) => (await resolveActor(new Request(`${url}${MCP_PATH}`, { headers: { authorization: `Bearer ${token}` } }))).ok,
937
+ dispatch: async (request) => (await handle(request, server)) ?? error(500, "No response"),
938
+ version: options.version ?? "0.0.0",
939
+ })
940
+
930
941
  async function handle(request: Request, server: Server<SpokeSocketData>): Promise<Response | undefined> {
931
942
  const url = new URL(request.url)
932
943
  const { pathname } = url
@@ -963,12 +974,20 @@ export function startCentral(options: CentralOptions): Central {
963
974
  if (poll.status === "approved") broadcast({ type: "catalog" })
964
975
  return Response.json(poll)
965
976
  }
977
+ // `hub login` without a token (CLI login device code): no credential; the device code is the poller's secret.
978
+ if (method === "POST" && pathname === "/cli/login") {
979
+ const input = await body(request)
980
+ return Response.json(await auth.startCliLogin(input.clientName, input.scopes), { headers: NO_STORE })
981
+ }
982
+ if (method === "POST" && pathname === "/cli/login/poll") return await cliLoginPoll((await body(request)).deviceCode)
983
+ if (method === "GET" && pathname === "/spoke/presence") return await spokePresence(request)
966
984
  if (method === "GET" && pathname === "/.well-known/hub") {
967
985
  return wellKnown({ publicURL, version: options.version ?? "0.0.0", auth: config.auth.mode === "oidc" ? { mode: "oidc", issuer: config.auth.issuer } : { mode: "pairing" } })
968
986
  }
969
987
  if (method === "GET" && pathname === "/login") return login(request, url)
970
988
  if (oidc && method === "GET" && pathname === "/auth/login") return await authLogin(url)
971
989
  if (oidc && method === "GET" && pathname === "/auth/callback") return await authCallback(request, url)
990
+ if (pathname === MCP_PATH) return await mcp(request)
972
991
  if (isAgentPath(pathname)) return await agent.handle(request, url)
973
992
  if (pathname.startsWith("/api/")) return await api(request, server, url)
974
993
  if (method === "GET") return await staticFile(pathname, request.headers.get("accept-encoding"))
@@ -978,6 +997,31 @@ export function startCentral(options: CentralOptions): Central {
978
997
  }
979
998
  }
980
999
 
1000
+ /**
1001
+ * GET /spoke/presence: which harness spokes of the caller's own machine are connected (machine token only). The
1002
+ * one read-only query a machine credential can make about itself, for `hubctl setup verify` (spoke-connected
1003
+ * readiness): harness, evidence level and features only, never sessions, directories, pids or other machines.
1004
+ */
1005
+ async function spokePresence(request: Request): Promise<Response> {
1006
+ const refused = () => Response.json({ error: "Invalid or revoked machine token" }, { status: 401 })
1007
+ const token = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1]
1008
+ if (!token || token.startsWith("hlt1.")) return refused()
1009
+ const machine = await store.findMachine(token)
1010
+ if (machine) await store.recheck({ users: [machine.ownerID] })
1011
+ const owner = machine && store.user(machine.ownerID)
1012
+ if (!machine || machine.revoked || !owner || owner.disabled) return refused()
1013
+ const processes = registry.catalog().find((m) => m.id === machine.id)?.processes ?? []
1014
+ const seen = new Set<string>()
1015
+ const spokes = processes.flatMap((p) => {
1016
+ const entry = { harness: p.harness, evidence: p.evidence, features: [...p.features] }
1017
+ const key = JSON.stringify(entry)
1018
+ if (seen.has(key)) return []
1019
+ seen.add(key)
1020
+ return [entry]
1021
+ })
1022
+ return Response.json({ machineID: machine.id, spokes })
1023
+ }
1024
+
981
1025
  async function login(request: Request, url: URL): Promise<Response> {
982
1026
  const next = url.searchParams.get("next") ?? "/"
983
1027
  if (!LOGIN_NEXT.test(next)) return error(400, "Invalid next path")
@@ -1060,12 +1104,24 @@ export function startCentral(options: CentralOptions): Central {
1060
1104
  // The phone's session view (hidden/pinned sessions and workspaces), per user, synced to their other devices.
1061
1105
  route("GET", "/api/prefs/view", "user", ({ user }) => Response.json({ prefs: store.viewPrefs(user.id) }, { headers: NO_STORE })),
1062
1106
  route("PUT", "/api/prefs/view", "user", async ({ request, user, device }) => {
1107
+ const before = new Set(store.viewPrefs(user.id).hiddenSessions)
1063
1108
  const prefs = await store.setViewPrefs(user.id, viewPrefsInput(await request.json()))
1064
1109
  // The user's other devices only: never another user, and not the device that made the change.
1065
1110
  toUser(user.id, { type: "prefs", prefs }, device.id)
1111
+ // Archive changes made in the console go to the agent event journal like the agent API's.
1112
+ const after = new Set(prefs.hiddenSessions)
1113
+ const changes = [...[...after].filter((id) => !before.has(id)).map((id) => archiveEvent(id, true)), ...[...before].filter((id) => !after.has(id)).map((id) => archiveEvent(id, false))]
1114
+ for (const change of changes) await agentEvents.append(user.id, change).catch((cause) => console.error(`[hub] archive event failed: ${(cause as Error).message}`))
1066
1115
  return Response.json({ prefs }, { headers: NO_STORE })
1067
1116
  }),
1068
1117
  route("GET", "/api/reads", "user", ({ user }) => Response.json({ reads: store.reads(user.id) }, { headers: NO_STORE })),
1118
+ // A CLI login (`hub login`): the user approves it as themselves, with the scopes they tick, or denies it.
1119
+ route("GET", "/api/cli-logins/:code", "user", async ({ params }) => {
1120
+ const login = await auth.cliLogin(params.code!)
1121
+ return login ? Response.json({ login }, { headers: NO_STORE }) : error(404, "No pending CLI login with that code")
1122
+ }),
1123
+ route("POST", "/api/cli-logins/:code/approve", "user", async ({ request, params, user }) => decideCliLogin(params.code!, "approved", user, (await body(request)).scopes)),
1124
+ route("POST", "/api/cli-logins/:code/deny", "user", ({ params, user }) => decideCliLogin(params.code!, "denied", user)),
1069
1125
  route("POST", "/api/enrollments/:code/approve", "user", ({ params, user }) => decideEnrollment(params.code!, "approved", user)),
1070
1126
  route("POST", "/api/enrollments/:code/deny", "user", ({ params, user }) => decideEnrollment(params.code!, "denied", user)),
1071
1127
  route("GET", "/api/devices", "user", ({ device, user }) =>
@@ -1431,6 +1487,31 @@ export function startCentral(options: CentralOptions): Central {
1431
1487
  return new Response(null, { status: 204 })
1432
1488
  }
1433
1489
 
1490
+ async function decideCliLogin(code: string, status: "approved" | "denied", user: UserRecord, scopes?: unknown): Promise<Response> {
1491
+ const clientName = await auth.decideCliLogin(code, status, user.id, scopes)
1492
+ if (clientName === undefined) return error(404, "No pending CLI login with that code")
1493
+ await store.audit({ actor: user.id, action: status === "approved" ? "cli-login.approve" : "cli-login.deny", detail: clientName })
1494
+ return new Response(null, { status: 204 })
1495
+ }
1496
+
1497
+ /**
1498
+ * The decision on a CLI login, once. Approved: a new app token of the approver (still active) with the scopes
1499
+ * they chose, in this response only (no-store, never logged).
1500
+ */
1501
+ async function cliLoginPoll(deviceCode: unknown): Promise<Response> {
1502
+ const poll = await auth.pollCliLogin(deviceCode)
1503
+ if (poll.status !== "approved") return Response.json({ status: poll.status }, { headers: NO_STORE })
1504
+ const owner = store.user(poll.ownerID)
1505
+ if (!owner || owner.disabled) return Response.json({ status: "denied" }, { headers: NO_STORE })
1506
+ try {
1507
+ const { record, token } = await issueAppToken(store, owner.id, { label: `CLI login: ${poll.clientName}`, scopes: poll.scopes })
1508
+ return Response.json({ status: "approved", token, tokenID: record.id, scopes: record.scopes }, { headers: NO_STORE })
1509
+ } catch (cause) {
1510
+ if (cause instanceof AgentError && cause.code === "rate_limited") return error(429, cause.message, "rate_limited")
1511
+ throw cause
1512
+ }
1513
+ }
1514
+
1434
1515
  async function revokeMachine(id: string, user: UserRecord): Promise<Response> {
1435
1516
  if (!await store.revokeMachine(id)) return error(404, "No such machine")
1436
1517
  await store.bumpMachineEpoch(id)
@@ -1930,6 +2011,8 @@ export function startCentral(options: CentralOptions): Central {
1930
2011
  launchFeed,
1931
2012
  launchTokens,
1932
2013
  webhooks,
2014
+ agentEvents,
2015
+ agentEventWriters,
1933
2016
  routes: [...routes.map(({ method, path, access }) => ({ method, path, access })), ...agent.routes.map(({ method, path }) => ({ method, path, access: "agent" as const }))],
1934
2017
  drain(drainOptions: DrainOptions = {}) {
1935
2018
  draining ??= runDrain(drainOptions)
@@ -1940,6 +2023,7 @@ export function startCentral(options: CentralOptions): Central {
1940
2023
  await Promise.allSettled([...leaders.map((loop) => loop.stop()), recommendations?.stop()])
1941
2024
  launches.shutdown()
1942
2025
  webhooks.stop()
2026
+ agentEventWriters.stop()
1943
2027
  tokenPush.stop()
1944
2028
  delivery.stop()
1945
2029
  decisions.stop()
@@ -0,0 +1,116 @@
1
+ // POST /mcp: the hub's MCP tools over MCP Streamable HTTP, for MCP clients and gateways (Brain decision txznll2f).
2
+ // Stateless: one JSON-RPC message per POST, answered as application/json (no SSE stream, no Mcp-Session-Id);
3
+ // notifications and client responses get 202. Batches are refused (-32600, as in protocol 2025-06-18).
4
+ //
5
+ // Auth: `Authorization: Bearer <token>`, any credential the agent API accepts (app, device, session and act
6
+ // tokens), checked up front with the agent API's resolver: none or invalid → 401 with `WWW-Authenticate: Bearer
7
+ // realm="hub"` (no OAuth resource metadata, so gateways detect plain bearer auth). The tools are `hub mcp`'s
8
+ // (packages/cli/src/mcp.ts) and call /api/agent with the caller's bearer, dispatched in process: scopes, grants,
9
+ // tenancy, kill switches and the audit are exactly the HTTP API's. A browser Origin other than the hub's own is
10
+ // refused (DNS rebinding); requests without one (servers, gateways) are fine. Tokens are never echoed.
11
+ import { createMcpServer, MCP_PROTOCOL_VERSIONS, NOT_FROM_USER, RPC, rpcError, type RpcResponse } from "../../packages/cli/src/mcp"
12
+ import { readCredentials } from "./agent/actor"
13
+
14
+ export const MCP_PATH = "/mcp"
15
+
16
+ /** Largest request body (bytes). */
17
+ export const MCP_MAX_BODY_BYTES = 1 << 20
18
+
19
+ export const MCP_INSTRUCTIONS =
20
+ "Hub: see and steer your AI agent sessions. Tools read sessions (status, sessions, show, log, who), message them (msg, ask, reply, decline, inbox), act within your grants (approve, deny, answer, prompt, stop) and launch sessions (launch, launches, launch_show, launch_stop), as this bearer token. " +
21
+ NOT_FROM_USER
22
+
23
+ export interface McpHttpDeps {
24
+ /** The hub's public URL (the tools' base URL; requests never leave the process). */
25
+ publicURL(): string
26
+ /** Whether a request's Origin is the hub's own. */
27
+ isOwnOrigin(origin: string): boolean
28
+ /** Whether the agent API accepts this bearer token. */
29
+ verify(token: string): Promise<boolean>
30
+ /** The hub's own request handler (the tools' /api/agent calls). */
31
+ dispatch(request: Request): Promise<Response>
32
+ readonly version: string
33
+ readonly maxBodyBytes?: number
34
+ }
35
+
36
+ const NO_STORE = { "cache-control": "no-store" }
37
+
38
+ const failure = (status: number, code: string, message: string, headers: Record<string, string> = {}) =>
39
+ Response.json({ error: { code, message } }, { status, headers: { ...NO_STORE, ...headers } })
40
+
41
+ const unauthorized = (message: string) => failure(401, "unauthorized", message, { "www-authenticate": 'Bearer realm="hub"' })
42
+
43
+ const answer = (response: RpcResponse) =>
44
+ // A message the server could not take (parse error, not a request, a batch) carries no id: HTTP 400.
45
+ Response.json(response, { status: response.error && response.id === null ? 400 : 200, headers: NO_STORE })
46
+
47
+ /** The body as text, or undefined when it is over `max` bytes (read no further). */
48
+ async function readCapped(request: Request, max: number): Promise<string | undefined> {
49
+ const length = Number(request.headers.get("content-length") ?? "")
50
+ if (Number.isFinite(length) && length > max) return undefined
51
+ if (!request.body) return ""
52
+ const reader = request.body.getReader()
53
+ const chunks: Uint8Array[] = []
54
+ let size = 0
55
+ for (;;) {
56
+ const { done, value } = await reader.read()
57
+ if (done) break
58
+ size += value.byteLength
59
+ if (size > max) {
60
+ void reader.cancel().catch(() => {})
61
+ return undefined
62
+ }
63
+ chunks.push(value)
64
+ }
65
+ return new TextDecoder().decode(Buffer.concat(chunks))
66
+ }
67
+
68
+ const acceptsJSON = (accept: string | null) => {
69
+ if (accept === null || !accept.trim()) return true
70
+ return accept.split(",").some((part) => {
71
+ const type = part.split(";")[0]!.trim().toLowerCase()
72
+ return type === "application/json" || type === "*/*" || type === "application/*"
73
+ })
74
+ }
75
+
76
+ /** Answers a request to /mcp (any method). */
77
+ export function mcpHttp(deps: McpHttpDeps): (request: Request) => Promise<Response> {
78
+ const max = deps.maxBodyBytes ?? MCP_MAX_BODY_BYTES
79
+ return async (request) => {
80
+ if (request.method !== "POST") return failure(405, "method_not_allowed", "POST JSON-RPC messages to /mcp (no SSE stream, no sessions)", { allow: "POST" })
81
+ const origin = request.headers.get("origin")
82
+ if (origin !== null && !deps.isOwnOrigin(origin)) return failure(403, "forbidden", "Cross-origin request refused")
83
+ // The bearer only: a cookie never authenticates /mcp.
84
+ const token = readCredentials(request, "").bearer
85
+ if (token === undefined) return unauthorized("Send Authorization: Bearer <hub token>")
86
+ if (!token || !(await deps.verify(token))) return unauthorized("Invalid, expired or revoked token")
87
+ const version = request.headers.get("mcp-protocol-version")
88
+ if (version !== null && !MCP_PROTOCOL_VERSIONS.includes(version)) return failure(400, "invalid", `Unsupported MCP-Protocol-Version (supported: ${MCP_PROTOCOL_VERSIONS.join(", ")})`)
89
+ const type = (request.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase()
90
+ if (type !== "application/json") return failure(415, "invalid", "Content-Type must be application/json")
91
+ if (!acceptsJSON(request.headers.get("accept"))) return failure(406, "invalid", "Accept must allow application/json")
92
+ const text = await readCapped(request, max)
93
+ if (text === undefined) return failure(413, "invalid", `Request body over ${max} bytes`)
94
+ let message: unknown
95
+ try {
96
+ message = JSON.parse(text)
97
+ } catch {
98
+ return answer(rpcError(null, RPC.parse, "parse error: not JSON"))
99
+ }
100
+ const server = createMcpServer({
101
+ credentials: () => ({ url: deps.publicURL(), token, source: "http", fetch: (input, init) => deps.dispatch(new Request(input, init)) }),
102
+ version: deps.version,
103
+ instructions: MCP_INSTRUCTIONS,
104
+ })
105
+ // The client went away: its tool calls are aborted.
106
+ const close = () => server.close()
107
+ request.signal.addEventListener("abort", close, { once: true })
108
+ try {
109
+ const response = await server.handle(message)
110
+ return response ? answer(response) : new Response(null, { status: 202, headers: NO_STORE })
111
+ } finally {
112
+ request.signal.removeEventListener("abort", close)
113
+ server.close()
114
+ }
115
+ }
116
+ }
@@ -32,6 +32,7 @@ export const NOTIFICATION_LABELS: Readonly<Record<NotificationKind, string>> = O
32
32
  activity: "Activity",
33
33
  decision: "Decision",
34
34
  message: "Message",
35
+ providerError: "Model provider error",
35
36
  })
36
37
 
37
38
  /** True for a known notification kind (API input). */
@@ -47,6 +48,8 @@ export interface PushExtra {
47
48
  readonly key?: string
48
49
  /** decision: the Decision, so a tap opens its card. */
49
50
  readonly decisionID?: string
51
+ /** Skip devices that have this kind on (they were notified through it instead). Not sent. */
52
+ readonly unlessPref?: NotificationKind
50
53
  }
51
54
 
52
55
  export interface PushPayload {
@@ -58,6 +61,8 @@ export interface PushPayload {
58
61
  readonly decisionID?: string
59
62
  /** A test notification shaped like `kind`. */
60
63
  readonly test?: true
64
+ /** Per-session kinds (providerError, subagentFailed): the session it is about, so the service worker tags each apart. */
65
+ readonly tagKey?: string
61
66
  }
62
67
 
63
68
  const minutes = (clock: string): number => Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5))
@@ -188,6 +193,8 @@ function kindTitle(kind: NotificationKind, subject: string | undefined): string
188
193
  }
189
194
 
190
195
  const OPEN = "Tap to open the session."
196
+ /** A session ID: the only key a payload may carry (job and tool IDs stay on the hub). */
197
+ const SESSION_ID = /^ses_[A-Za-z0-9]{1,64}$/
191
198
 
192
199
  export function pushPayload(kind: NotificationKind, sessionID: string, sessionTitle: string, text?: string, extra: PushExtra = {}): PushPayload {
193
200
  const name = shorten(sessionTitle)
@@ -213,6 +220,9 @@ export function pushPayload(kind: NotificationKind, sessionID: string, sessionTi
213
220
  return { kind, sessionID, title, body: detail || "Nothing is running or waiting. Tap to open." }
214
221
  case "activity":
215
222
  return { kind, sessionID, title, body: detail || "New activity." }
223
+ case "providerError":
224
+ case "subagentFailed":
225
+ return { kind, sessionID, title, body: detail || OPEN, ...(extra.key && SESSION_ID.test(extra.key) ? { tagKey: extra.key } : {}) }
216
226
  default:
217
227
  return { kind, sessionID, title, body: detail || OPEN }
218
228
  }
@@ -229,6 +239,7 @@ export function sendOptionsFor(kind: NotificationKind | "test"): { TTL: number;
229
239
  case "failed":
230
240
  case "decision":
231
241
  case "message":
242
+ case "providerError":
232
243
  return { TTL: URGENT_TTL_SECONDS, urgency: "high" }
233
244
  case "done":
234
245
  case "stopped":
@@ -385,7 +396,9 @@ export class Push {
385
396
  const targets = this.store.pushSubscriptions().filter((s) => {
386
397
  const owner = owners.get(s.deviceID)
387
398
  if (ownerID !== undefined && owner !== ownerID) return false
388
- if (!this.store.pushPrefs(s.deviceID)[precise]) return false
399
+ const prefs = this.store.pushPrefs(s.deviceID)
400
+ if (!prefs[precise]) return false
401
+ if (extra?.unlessPref && prefs[extra.unlessPref]) return false
389
402
  if (precise === "handoff") return true
390
403
  const quiet = this.store.quietHours(s.deviceID)
391
404
  return !isMuted(owner) && !(quiet && inQuietHours(quiet, at))
@@ -12,7 +12,7 @@
12
12
  // holds the connection; remote state is never re-exported.
13
13
  import path from "node:path"
14
14
  import { HARNESS_OPENCODE, helloHarness, spokeFeatures, type EvidenceLevel, type ForwardedEvent, type Hello, type HerdrWorkspace, type ProcessKind, type SessionInfo, type SpokeFeature, type SpokeMode, type TerminalInfo, type Terminals } from "../protocol"
15
- import { StatusBoard, permissionRequest, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
15
+ import { StatusBoard, errorTypeLabel, permissionRequest, providerFailureNeedsUser, providerRetryNeedsUser, type Blocker, type BlockerRecommendation, type PermissionRequest, type SessionStatus } from "../status"
16
16
  import { sanitizeLabel } from "./agent/envelope"
17
17
  import { DoneWatch } from "./done"
18
18
  import { SentenceStream } from "./say-stream"
@@ -150,6 +150,8 @@ export interface Alert {
150
150
  /** Tenant (user ID) that owns the session; push goes to this user's devices only. */
151
151
  readonly owner?: string
152
152
  readonly step?: ActivityStep
153
+ /** Skip devices that have this kind on (they were told through it instead). */
154
+ readonly unlessPref?: NotificationKind
153
155
  }
154
156
 
155
157
  /** Notifications that reach a session even while some device shows it. */
@@ -407,6 +409,8 @@ export class Registry {
407
409
  private readonly forms = new Map<string, { connIDs: string[]; sessionID: string }>()
408
410
  /** Form IDs already alerted (forms may be forwarded by several processes). */
409
411
  private readonly alertedForms = new Set<string>()
412
+ /** Sessions whose current run raised a providerError notification (once per run; cleared when a run starts). */
413
+ private readonly providerAlerted = new Set<string>()
410
414
  /** Sessions in conversation mode → the connection last told so (re-sent when another one takes over). */
411
415
  private readonly conversations = new Map<string, string | undefined>()
412
416
  /** `messageID ordinal` of texts already sent as say events (several processes forward the same text). */
@@ -1433,15 +1437,27 @@ export class Registry {
1433
1437
  if (!rootID) return
1434
1438
  const root = rootID === sessionID
1435
1439
  const wasRunning = before?.state === "working" || before?.state === "blocked"
1436
- if (type === "session.execution.started") return this.doneWatch.started(rootID, root)
1440
+ if (type === "session.execution.started") {
1441
+ this.providerAlerted.delete(sessionID)
1442
+ return this.doneWatch.started(rootID, root)
1443
+ }
1437
1444
  if (type === "session.text.delta" || type === "session.reasoning.delta") return
1438
1445
  const ended = type === "session.execution.succeeded" ? "succeeded" : type === "session.execution.failed" ? "failed" : type === "session.execution.interrupted" ? "interrupted" : undefined
1439
1446
  const reason = (data.error as { message?: unknown } | undefined)?.message
1440
1447
  const error = typeof reason === "string" && reason.trim() ? reason : undefined
1448
+ const errorType = (data.error as { type?: unknown } | undefined)?.type
1449
+ // A provider error the user must act on replaces the root's "failed" (raised once per run).
1450
+ const providerFailure = ended === "failed" && typeof errorType === "string" && providerFailureNeedsUser(errorType)
1451
+ if (providerFailure) this.providerError(rootID, sessionID, error ?? errorTypeLabel(errorType) ?? "Model provider error")
1452
+ if (type === "session.status") {
1453
+ const retry = this.status(rootID)?.find((s) => s.id === sessionID)?.retry
1454
+ if (retry && providerRetryNeedsUser(retry.errorType)) this.providerError(rootID, sessionID, retry.message ?? errorTypeLabel(retry.errorType)!)
1455
+ }
1441
1456
  if (ended && root) {
1442
1457
  this.doneWatch.rootEnded(rootID, ended, wasRunning)
1443
1458
  if (ended === "succeeded" && wasRunning && !this.active.has(rootID)) this.alert("turn", rootID)
1444
- if (ended === "failed") this.alert("failed", rootID, error ? { text: error } : {})
1459
+ // Devices with providerError on got that instead; the rest still hear about the failure.
1460
+ if (ended === "failed") this.alert("failed", rootID, { ...(error ? { text: error } : {}), ...(providerFailure ? { unlessPref: "providerError" as const } : {}) })
1445
1461
  const stopped = ended === "interrupted" ? STOP_REASONS[String(data.reason)] : undefined
1446
1462
  if (stopped) this.alert("stopped", rootID, { text: stopped })
1447
1463
  return
@@ -1456,7 +1472,10 @@ export class Registry {
1456
1472
  }
1457
1473
  if (type === "session.status" && root && !before?.retry) {
1458
1474
  const retry = this.status(rootID)?.find((s) => s.id === rootID)?.retry
1459
- if (retry) this.alert("retry", rootID, { text: [retry.attempt !== undefined ? `Attempt ${retry.attempt}` : "", retry.message ?? ""].filter(Boolean).join(": ") || "Retrying" })
1475
+ if (retry) {
1476
+ const why = [retry.attempt !== undefined ? `Attempt ${retry.attempt}` : "", errorTypeLabel(retry.errorType) ?? ""].filter(Boolean).join(" · ")
1477
+ this.alert("retry", rootID, { text: [why, retry.message ?? ""].filter(Boolean).join(": ") || "Retrying" })
1478
+ }
1460
1479
  return
1461
1480
  }
1462
1481
  if (type === "shell.exited" || type === "session.shell.ended" || type === "pty.exited") {
@@ -1469,6 +1488,15 @@ export class Registry {
1469
1488
  }
1470
1489
  }
1471
1490
 
1491
+ /** A model provider error of `sessionID` (root or subagent) needs the user: "<session title>: <message>", once per run. */
1492
+ private providerError(rootID: string, sessionID: string, message: string): void {
1493
+ if (this.providerAlerted.has(sessionID)) return
1494
+ this.providerAlerted.add(sessionID)
1495
+ if (this.providerAlerted.size > 1000) this.providerAlerted.delete(this.providerAlerted.values().next().value!)
1496
+ const title = this.status(rootID)?.find((s) => s.id === sessionID)?.title?.trim() || "Session"
1497
+ this.alert("providerError", rootID, { text: `${title}: ${message}`, key: sessionID })
1498
+ }
1499
+
1472
1500
  /** Fires when a root's tree stayed settled after its run (DoneWatch). */
1473
1501
  private settledTree(rootID: string): boolean {
1474
1502
  const board = this.boards.get(rootID)
@@ -1938,7 +1966,7 @@ export class Registry {
1938
1966
  * Raises a notification for the root of `sessionID`'s tree. Only hand-offs, questions and permissions reach a
1939
1967
  * session some device shows (spoken replies check that themselves: the watching device speaks them).
1940
1968
  */
1941
- private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string } = {}): void {
1969
+ private alert(kind: NotificationKind, sessionID: string, fields: { text?: string; requestID?: string; subject?: string; key?: string; unlessPref?: NotificationKind } = {}): void {
1942
1970
  const entry = [...this.boards].find(([, board]) => board.get(sessionID))
1943
1971
  if (!entry) return
1944
1972
  const [rootID, board] = entry
@@ -1951,6 +1979,7 @@ export class Registry {
1951
1979
  ...(fields.text !== undefined ? { text: fields.text } : {}),
1952
1980
  ...(fields.requestID !== undefined ? { requestID: fields.requestID } : {}),
1953
1981
  ...(fields.key !== undefined ? { key: fields.key } : {}),
1982
+ ...(fields.unlessPref !== undefined ? { unlessPref: fields.unlessPref } : {}),
1954
1983
  }
1955
1984
  const owner = this.sessionTenant(rootID)
1956
1985
  for (const listener of this.alertListeners) listener(owner ? { ...alert, owner } : alert)
@@ -58,6 +58,9 @@ export interface ViewPrefs {
58
58
  }
59
59
 
60
60
  export const VIEW_PREF_KEYS = ["hiddenSessions", "hiddenWorkspaces", "pinnedSessions", "pinnedWorkspaces"] as const
61
+ /** Archived sessions accumulate for longer than the other view-preference lists. */
62
+ export const VIEW_PREF_MAX_ARCHIVED = 5000
63
+ export const VIEW_PREF_MAX_PINNED = 500
61
64
 
62
65
  export interface AuditEntry {
63
66
  at: number
@@ -118,6 +121,11 @@ export interface PushPrefs {
118
121
  decision: boolean
119
122
  /** Agent messaging needs you: a message to @me, an ask left unanswered after every re-wake, or a message loop. */
120
123
  message: boolean
124
+ /**
125
+ * A model provider error needs you: a quota, free-tier, budget, auth or no-route failure of any session in the tree
126
+ * (once per run), or a retry for a quota, free-tier or auth error. Replaces the root's "failed" for that failure.
127
+ */
128
+ providerError: boolean
121
129
  }
122
130
 
123
131
  export const PUSH_PREF_KEYS = [
@@ -138,6 +146,7 @@ export const PUSH_PREF_KEYS = [
138
146
  "activity",
139
147
  "decision",
140
148
  "message",
149
+ "providerError",
141
150
  ] as const satisfies readonly (keyof PushPrefs)[]
142
151
 
143
152
  /** Defaults: anything that needs you, all done, failed, hand-offs and spoken replies; the finer kinds are opt-in. */
@@ -160,6 +169,7 @@ export const DEFAULT_PUSH_PREFS: Readonly<PushPrefs> = Object.freeze({
160
169
  activity: false,
161
170
  decision: true,
162
171
  message: true,
172
+ providerError: true,
163
173
  })
164
174
 
165
175
  /** Default and allowed range of a device's activity update interval, in minutes. */
@@ -911,6 +921,56 @@ export class Store {
911
921
  })
912
922
  }
913
923
 
924
+ /** Changes one archived session atomically; undefined means the bounded archive list is full. */
925
+ setSessionArchived(id: string, sessionID: string, archived: boolean): Promise<{ changed: boolean } | undefined> {
926
+ return this.mutate((tx) => {
927
+ const user = this.user(id)
928
+ if (!user) return undefined
929
+ const next = this.viewPrefs(id)
930
+ const listed = next.hiddenSessions.includes(sessionID)
931
+ const changed = archived !== listed
932
+ if (!changed) return { changed }
933
+ if (archived && next.hiddenSessions.length >= VIEW_PREF_MAX_ARCHIVED) return undefined
934
+ next.hiddenSessions = archived ? [...next.hiddenSessions, sessionID] : next.hiddenSessions.filter((id) => id !== sessionID)
935
+ user.viewPrefs = next
936
+ tx.touch("users", id)
937
+ return { changed }
938
+ })
939
+ }
940
+
941
+ /** Changes one pinned session atomically while preserving every other preference list. */
942
+ setSessionPinned(id: string, sessionID: string, pinned: boolean): Promise<{ changed: boolean } | undefined> {
943
+ return this.mutate((tx) => {
944
+ const user = this.user(id)
945
+ if (!user) return undefined
946
+ const next = this.viewPrefs(id)
947
+ const listed = next.pinnedSessions.includes(sessionID)
948
+ const changed = pinned !== listed
949
+ if (!changed) return { changed }
950
+ if (pinned && next.pinnedSessions.length >= VIEW_PREF_MAX_PINNED) return undefined
951
+ next.pinnedSessions = pinned ? [...next.pinnedSessions, sessionID] : next.pinnedSessions.filter((id) => id !== sessionID)
952
+ user.viewPrefs = next
953
+ tx.touch("users", id)
954
+ return { changed }
955
+ })
956
+ }
957
+
958
+ /** Archives only while still unpinned, atomically against concurrent preference writes. */
959
+ archiveSessionIfUnpinned(id: string, sessionID: string): Promise<"changed" | "pinned" | "archived" | "full" | "not_found"> {
960
+ return this.mutate((tx) => {
961
+ const user = this.user(id)
962
+ if (!user) return "not_found"
963
+ const next = this.viewPrefs(id)
964
+ if (next.hiddenSessions.includes(sessionID)) return "archived"
965
+ if (next.pinnedSessions.includes(sessionID)) return "pinned"
966
+ if (next.hiddenSessions.length >= VIEW_PREF_MAX_ARCHIVED) return "full"
967
+ next.hiddenSessions = [...next.hiddenSessions, sessionID]
968
+ user.viewPrefs = next
969
+ tx.touch("users", id)
970
+ return "changed"
971
+ })
972
+ }
973
+
914
974
  // ---------- per-user, per-session model preferences ----------
915
975
 
916
976
  modelPrefs(id: string, sessionID: string): SessionModelPrefs {