agent-relay-server 0.81.0 → 0.82.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 (155) hide show
  1. package/docs/openapi.json +103 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-ckJ2VVgq.js +3 -0
  4. package/public/assets/MessageBubble-ckJ2VVgq.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-C_Qm7s4q.js → PlanGraphPanel-r3aVT4uc.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-C_Qm7s4q.js.map → PlanGraphPanel-r3aVT4uc.js.map} +1 -1
  7. package/public/assets/{activity-HoO4RRm1.js → activity-DtMjjws8.js} +2 -2
  8. package/public/assets/{activity-HoO4RRm1.js.map → activity-DtMjjws8.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CeoqyRis.js → agent-profiles-eRlq98tp.js} +2 -2
  10. package/public/assets/{agent-profiles-CeoqyRis.js.map → agent-profiles-eRlq98tp.js.map} +1 -1
  11. package/public/assets/agents-DmbjRTA7.js +2 -0
  12. package/public/assets/{agents-CSYMFVpW.js.map → agents-DmbjRTA7.js.map} +1 -1
  13. package/public/assets/{analytics-B6wGSTf3.js → analytics-BW7ik_Ws.js} +2 -2
  14. package/public/assets/{analytics-B6wGSTf3.js.map → analytics-BW7ik_Ws.js.map} +1 -1
  15. package/public/assets/{automation-Bj2AYEw0.js → automation-CxwGXWC7.js} +2 -2
  16. package/public/assets/{automation-Bj2AYEw0.js.map → automation-CxwGXWC7.js.map} +1 -1
  17. package/public/assets/{badge-DGB2nVM0.js → badge-BwaoBnNO.js} +2 -2
  18. package/public/assets/{badge-DGB2nVM0.js.map → badge-BwaoBnNO.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-BSwkl9A8.js → branch-state-badge-CvdKPTlV.js} +2 -2
  20. package/public/assets/{branch-state-badge-BSwkl9A8.js.map → branch-state-badge-CvdKPTlV.js.map} +1 -1
  21. package/public/assets/{button-D_Y8CEP4.js → button-CFIklTDX.js} +2 -2
  22. package/public/assets/{button-D_Y8CEP4.js.map → button-CFIklTDX.js.map} +1 -1
  23. package/public/assets/{card-DTLNgk0R.js → card-Bi94xEmr.js} +2 -2
  24. package/public/assets/{card-DTLNgk0R.js.map → card-Bi94xEmr.js.map} +1 -1
  25. package/public/assets/{channels-BzY-_4Zi.js → channels-D8UY3baa.js} +2 -2
  26. package/public/assets/{channels-BzY-_4Zi.js.map → channels-D8UY3baa.js.map} +1 -1
  27. package/public/assets/chat-CCPukbI9.js +2 -0
  28. package/public/assets/chat-CCPukbI9.js.map +1 -0
  29. package/public/assets/{connectors-empwp8UF.js → connectors-W2MslhQ2.js} +2 -2
  30. package/public/assets/{connectors-empwp8UF.js.map → connectors-W2MslhQ2.js.map} +1 -1
  31. package/public/assets/{copy-button-BpgTft5S.js → copy-button-A0pXLXBy.js} +2 -2
  32. package/public/assets/{copy-button-BpgTft5S.js.map → copy-button-A0pXLXBy.js.map} +1 -1
  33. package/public/assets/display-CWMHll1J.js +3 -0
  34. package/public/assets/display-CWMHll1J.js.map +1 -0
  35. package/public/assets/{dist-DetIq2Rn.js → dist--NYpzm4f.js} +2 -2
  36. package/public/assets/{dist-DetIq2Rn.js.map → dist--NYpzm4f.js.map} +1 -1
  37. package/public/assets/{dist-dffB6laV.js → dist-BODiHbq1.js} +2 -2
  38. package/public/assets/{dist-dffB6laV.js.map → dist-BODiHbq1.js.map} +1 -1
  39. package/public/assets/{dist-B7m1nK9-.js → dist-C4VoXCT-.js} +2 -2
  40. package/public/assets/{dist-B7m1nK9-.js.map → dist-C4VoXCT-.js.map} +1 -1
  41. package/public/assets/dist-D3jA_LvQ.js +2 -0
  42. package/public/assets/{dist-CoqsUXVF.js.map → dist-D3jA_LvQ.js.map} +1 -1
  43. package/public/assets/{dist-C9UjQOV6.js → dist-DVorlUcA.js} +2 -2
  44. package/public/assets/{dist-C9UjQOV6.js.map → dist-DVorlUcA.js.map} +1 -1
  45. package/public/assets/{dist-CrI1Uij-.js → dist-DY3KCMIN.js} +2 -2
  46. package/public/assets/{dist-CrI1Uij-.js.map → dist-DY3KCMIN.js.map} +1 -1
  47. package/public/assets/{dist-CyQcAgI5.js → dist-DhEz6YNy.js} +2 -2
  48. package/public/assets/{dist-CyQcAgI5.js.map → dist-DhEz6YNy.js.map} +1 -1
  49. package/public/assets/dist-HTxIFS7t.js +2 -0
  50. package/public/assets/{dist-DHiVCawj.js.map → dist-HTxIFS7t.js.map} +1 -1
  51. package/public/assets/{dist-CzelKhUn.js → dist-_EunAAFX.js} +2 -2
  52. package/public/assets/{dist-CzelKhUn.js.map → dist-_EunAAFX.js.map} +1 -1
  53. package/public/assets/dist-rLCXIl7x.js +2 -0
  54. package/public/assets/{dist-DRTRN4My.js.map → dist-rLCXIl7x.js.map} +1 -1
  55. package/public/assets/{dist-BvF_x64R.js → dist-yzKYLD10.js} +2 -2
  56. package/public/assets/{dist-BvF_x64R.js.map → dist-yzKYLD10.js.map} +1 -1
  57. package/public/assets/{es2015-CBZid1bm.js → es2015-BDZowz0r.js} +2 -2
  58. package/public/assets/{es2015-CBZid1bm.js.map → es2015-BDZowz0r.js.map} +1 -1
  59. package/public/assets/{formatted-body-impl-CzKd4psz.js → formatted-body-impl-tIjgf9Kr.js} +2 -2
  60. package/public/assets/{formatted-body-impl-CzKd4psz.js.map → formatted-body-impl-tIjgf9Kr.js.map} +1 -1
  61. package/public/assets/index-BfQFX8td.js +22 -0
  62. package/public/assets/{index-DW4YIA7L.js.map → index-BfQFX8td.js.map} +1 -1
  63. package/public/assets/index-D8vc380x.css +2 -0
  64. package/public/assets/{input-C5FrEDmc.js → input-CD-ZcZIA.js} +2 -2
  65. package/public/assets/{input-C5FrEDmc.js.map → input-CD-ZcZIA.js.map} +1 -1
  66. package/public/assets/insights-B93PGmKq.js +2 -0
  67. package/public/assets/{insights-DgGTwAwZ.js.map → insights-B93PGmKq.js.map} +1 -1
  68. package/public/assets/{integrations-4V9lOQ8b.js → integrations-BfWMAznM.js} +2 -2
  69. package/public/assets/{integrations-4V9lOQ8b.js.map → integrations-BfWMAznM.js.map} +1 -1
  70. package/public/assets/{lib-DjewNYeR.js → lib-BqL6WCDu.js} +2 -2
  71. package/public/assets/{lib-DjewNYeR.js.map → lib-BqL6WCDu.js.map} +1 -1
  72. package/public/assets/lucide-react-D78N6SwK.js +9 -0
  73. package/public/assets/lucide-react-D78N6SwK.js.map +1 -0
  74. package/public/assets/{maintenance-CmgIgjo4.js → maintenance-CgRASTD0.js} +2 -2
  75. package/public/assets/{maintenance-CmgIgjo4.js.map → maintenance-CgRASTD0.js.map} +1 -1
  76. package/public/assets/{managed-agents-DRxAGFUB.js → managed-agents-B1AdrEv4.js} +2 -2
  77. package/public/assets/{managed-agents-DRxAGFUB.js.map → managed-agents-B1AdrEv4.js.map} +1 -1
  78. package/public/assets/{markdown-preview-impl-DSquGj2d.js → markdown-preview-impl-D2zEt-i-.js} +2 -2
  79. package/public/assets/{markdown-preview-impl-DSquGj2d.js.map → markdown-preview-impl-D2zEt-i-.js.map} +1 -1
  80. package/public/assets/memory-nUBcij_9.js +2 -0
  81. package/public/assets/{memory-BYq4eEF_.js.map → memory-nUBcij_9.js.map} +1 -1
  82. package/public/assets/{messages-BZt9N6sy.js → messages-BNDs53bW.js} +2 -2
  83. package/public/assets/{messages-BZt9N6sy.js.map → messages-BNDs53bW.js.map} +1 -1
  84. package/public/assets/{orchestrators-DoqpuP2u.js → orchestrators-BouoI90a.js} +2 -2
  85. package/public/assets/{orchestrators-DoqpuP2u.js.map → orchestrators-BouoI90a.js.map} +1 -1
  86. package/public/assets/{overview-CtpqcW4U.js → overview-C8_hAGpC.js} +2 -2
  87. package/public/assets/{overview-CtpqcW4U.js.map → overview-C8_hAGpC.js.map} +1 -1
  88. package/public/assets/{pairs-XoWsTxhe.js → pairs-BcCOB1oO.js} +2 -2
  89. package/public/assets/{pairs-XoWsTxhe.js.map → pairs-BcCOB1oO.js.map} +1 -1
  90. package/public/assets/projects-MZ6CNrYO.js +2 -0
  91. package/public/assets/{projects-Byp_gLoD.js.map → projects-MZ6CNrYO.js.map} +1 -1
  92. package/public/assets/{react-dom--5xRjaey.js → react-dom-CU0WCHqq.js} +2 -2
  93. package/public/assets/{react-dom--5xRjaey.js.map → react-dom-CU0WCHqq.js.map} +1 -1
  94. package/public/assets/{security-B_C5yJi5.js → security-Bh8BGpZR.js} +2 -2
  95. package/public/assets/{security-B_C5yJi5.js.map → security-Bh8BGpZR.js.map} +1 -1
  96. package/public/assets/{select-BBZ8pgCn.js → select-Drdvachg.js} +2 -2
  97. package/public/assets/{select-BBZ8pgCn.js.map → select-Drdvachg.js.map} +1 -1
  98. package/public/assets/settings-IrNBT0hy.js +4 -0
  99. package/public/assets/{settings-DNYEfnw6.js.map → settings-IrNBT0hy.js.map} +1 -1
  100. package/public/assets/store-CqFZmc-B.js +9 -0
  101. package/public/assets/store-CqFZmc-B.js.map +1 -0
  102. package/public/assets/{tasks-Bww7kqYc.js → tasks-heEgx7vT.js} +2 -2
  103. package/public/assets/{tasks-Bww7kqYc.js.map → tasks-heEgx7vT.js.map} +1 -1
  104. package/public/assets/teams-BjETr-_W.js +2 -0
  105. package/public/assets/{teams-C6B038l0.js.map → teams-BjETr-_W.js.map} +1 -1
  106. package/public/assets/{terminal-viewer-impl-CvoK2Byo.js → terminal-viewer-impl-BDqmbWB3.js} +3 -3
  107. package/public/assets/{terminal-viewer-impl-CvoK2Byo.js.map → terminal-viewer-impl-BDqmbWB3.js.map} +1 -1
  108. package/public/assets/{use-keyboard-viewport-vC7NeLD9.js → use-keyboard-viewport-CichJ8Jn.js} +2 -2
  109. package/public/assets/{use-keyboard-viewport-vC7NeLD9.js.map → use-keyboard-viewport-CichJ8Jn.js.map} +1 -1
  110. package/public/assets/{work-queue-w_eT-XFN.js → work-queue-rgQs4Wis.js} +2 -2
  111. package/public/assets/{work-queue-w_eT-XFN.js.map → work-queue-rgQs4Wis.js.map} +1 -1
  112. package/public/assets/workspaces-hIjZicaF.js +3 -0
  113. package/public/assets/{workspaces-Bivhqw2N.js.map → workspaces-hIjZicaF.js.map} +1 -1
  114. package/public/index.html +22 -22
  115. package/src/agent-authz.ts +169 -0
  116. package/src/agent-ref.ts +7 -3
  117. package/src/dashboard-presence.ts +91 -0
  118. package/src/db/agent-ownership.ts +168 -0
  119. package/src/db/index.ts +2 -0
  120. package/src/db/migrations.ts +17 -0
  121. package/src/db/schema.ts +16 -0
  122. package/src/db/users.ts +120 -0
  123. package/src/routes/_shared.ts +75 -1
  124. package/src/routes/agent-sessions.ts +57 -6
  125. package/src/routes/agents.ts +13 -4
  126. package/src/routes/index.ts +6 -1
  127. package/src/routes/settings.ts +33 -8
  128. package/src/routes/users.ts +8 -0
  129. package/src/services/register-agent.ts +11 -1
  130. package/src/services/send-message.ts +7 -1
  131. package/src/settings/registry.ts +63 -0
  132. package/src/sse.ts +7 -0
  133. package/src/user-settings-store.ts +86 -0
  134. package/public/assets/MessageBubble-DcSxvVGQ.js +0 -3
  135. package/public/assets/MessageBubble-DcSxvVGQ.js.map +0 -1
  136. package/public/assets/agents-CSYMFVpW.js +0 -2
  137. package/public/assets/chat-C410UoF1.js +0 -2
  138. package/public/assets/chat-C410UoF1.js.map +0 -1
  139. package/public/assets/display-DHgdn4vN.js +0 -3
  140. package/public/assets/display-DHgdn4vN.js.map +0 -1
  141. package/public/assets/dist-CoqsUXVF.js +0 -2
  142. package/public/assets/dist-DHiVCawj.js +0 -2
  143. package/public/assets/dist-DRTRN4My.js +0 -2
  144. package/public/assets/index-BpsfMZ3T.css +0 -2
  145. package/public/assets/index-DW4YIA7L.js +0 -22
  146. package/public/assets/insights-DgGTwAwZ.js +0 -2
  147. package/public/assets/lucide-react-CNGB8fo1.js +0 -9
  148. package/public/assets/lucide-react-CNGB8fo1.js.map +0 -1
  149. package/public/assets/memory-BYq4eEF_.js +0 -2
  150. package/public/assets/projects-Byp_gLoD.js +0 -2
  151. package/public/assets/settings-DNYEfnw6.js +0 -4
  152. package/public/assets/store-Cjcd0egy.js +0 -9
  153. package/public/assets/store-Cjcd0egy.js.map +0 -1
  154. package/public/assets/teams-C6B038l0.js +0 -2
  155. package/public/assets/workspaces-Bivhqw2N.js +0 -3
@@ -5,7 +5,8 @@ import { ValidationError, createActivityEvent, createCallbackDelivery, finishCal
5
5
  import { cleanEpoch, cleanString, optionalEnum } from "../validation";
6
6
  import { emitActivityEvent, emitMessageQueued, emitNewMessage } from "../sse";
7
7
  import { emitRelayEvent } from "../events";
8
- import { errMessage, isRecord } from "agent-relay-sdk";
8
+ import { canonicalHumanAgentId, DEFAULT_USER_ID, errMessage, isRecord, userSinkId } from "agent-relay-sdk";
9
+ import { authorizeAgentForUser, visibleAgentIdsForUser, type AgentAction } from "../agent-authz";
9
10
  import { generateSpawnRequestId } from "../spawn-command";
10
11
  import { getComponentAuth, getIntegrationAuth, isAuthorized, isRequestAuthorizedFor } from "../security";
11
12
  import { readBodyBytes } from "../http-body";
@@ -87,6 +88,79 @@ export function dashboardAttribution(req: Request, surface?: unknown): Record<st
87
88
  return { surface: out };
88
89
  }
89
90
 
91
+ type RequestUserResolver = (req: Request) => string;
92
+
93
+ // The active resolver behind requestUserId. The default returns the seeded admin
94
+ // (`DEFAULT_USER_ID`) and NEVER reads a client header, so the relay stays single-user until
95
+ // auth/sessions (#389) install a real session→users.id resolver via setRequestUserResolver.
96
+ const defaultRequestUserResolver: RequestUserResolver = () => DEFAULT_USER_ID;
97
+ let requestUserResolver: RequestUserResolver = defaultRequestUserResolver;
98
+
99
+ /**
100
+ * The #389 wiring point: install the authenticated-session → `users.id` resolver. ONE call here
101
+ * turns on per-user enforcement + attribution across every route — no route edits — because all
102
+ * three seams already funnel through {@link requestUserId}. Also the seam tests use to exercise
103
+ * the multi-user gates that #389 will switch on (no client header is ever trusted).
104
+ */
105
+ export function setRequestUserResolver(resolver: RequestUserResolver): void {
106
+ requestUserResolver = resolver;
107
+ }
108
+
109
+ /** Restore the default single-user resolver (test teardown / #389 rollback). */
110
+ export function resetRequestUserResolver(): void {
111
+ requestUserResolver = defaultRequestUserResolver;
112
+ }
113
+
114
+ /**
115
+ * THE canonical server-side resolver for "which human is acting in this request" (#388 Wave A
116
+ * integration), returning the relational `users.id`. Settings (#391), authz (#392), and
117
+ * attribution/presence (#390) ALL resolve through this one path, so the three "who is the user"
118
+ * seams can never disagree or drift into different id-spaces.
119
+ *
120
+ * It deliberately does NOT trust the additive `X-Dashboard-User-Id` header. That header is a
121
+ * dormant #389 seam — auth/sessions will wire it to the *authenticated* session; today it is
122
+ * trusted by NOTHING, so a crafted header can forge neither attribution, presence, settings,
123
+ * nor an authz decision. Until #389 calls {@link setRequestUserResolver} this resolves to the
124
+ * seeded admin `DEFAULT_USER_ID` ('default'): authz fail-opens (single-user world), settings
125
+ * read the lone human's row, and attribution stamps the legacy `user` sink — byte-for-byte the
126
+ * pre-Wave-A behavior.
127
+ */
128
+ export function requestUserId(req: Request): string {
129
+ return requestUserResolver(req);
130
+ }
131
+
132
+ /**
133
+ * The canonical human MESSAGE ADDRESS (`user` / `user:<id>`) for the acting user, derived from
134
+ * {@link requestUserId} via the SDK resolver (never a hand-rolled namespace). Use ONLY where
135
+ * message routing/attribution genuinely needs an address — a message `from`, an audit
136
+ * `requestedByUser`, presence — while authz/settings/ownership use the relational
137
+ * {@link requestUserId}. For the default user this collapses to the legacy bare `user` sink, so
138
+ * existing message/operator/presence rows match byte-for-byte.
139
+ */
140
+ export function requestUserAddress(req: Request): string {
141
+ return canonicalHumanAgentId(userSinkId(requestUserId(req)));
142
+ }
143
+
144
+ /**
145
+ * Gate a per-agent route on the acting user's authorization (#392, wired by Wave-A integration).
146
+ * Returns a 403 when the user may not perform `action` on the agent, else null to proceed.
147
+ * Single-user worlds fail-open inside {@link authorizeAgentForUser}, so existing routes are
148
+ * unchanged until #389 upgrades {@link requestUserId} to the real session user.
149
+ */
150
+ export function authorizeAgentRoute(req: Request, agentId: string, action: AgentAction): Response | null {
151
+ return authorizeAgentForUser(requestUserId(req), agentId, action) ? null : error("forbidden", 403);
152
+ }
153
+
154
+ /** True iff the acting user may observe `agentId` (drives detail/chat-read existence-hiding). */
155
+ export function canViewAgentRoute(req: Request, agentId: string): boolean {
156
+ return authorizeAgentForUser(requestUserId(req), agentId, "view");
157
+ }
158
+
159
+ /** Filter `agentIds` to the subset the acting user may observe, preserving input order. */
160
+ export function visibleAgentIdsForRequest(req: Request, agentIds: readonly string[]): Set<string> {
161
+ return new Set(visibleAgentIdsForUser(requestUserId(req), agentIds));
162
+ }
163
+
90
164
  type ParseBodyResult<T> =
91
165
  | { ok: true; body: T | null }
92
166
  | { ok: false; status: number; error: string };
@@ -1,12 +1,13 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agent-sessions.
2
2
  import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
3
- import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
3
+ import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
5
+ import { announcePresence, viewersForAgent } from "../dashboard-presence";
5
6
  import { buildManagedSpawnParams } from "../managed-policy";
6
7
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
7
8
  import { cleanString, optionalEnum } from "../validation";
8
9
  import { createCommand } from "../commands-db";
9
- import { emitAgentStatus, emitNewMessage } from "../sse";
10
+ import { emitAgentStatus, emitDashboardPresence, emitNewMessage } from "../sse";
10
11
  import { getAgentProfile, getSpawnPolicy } from "../config-store";
11
12
  import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
12
13
  import { runnerRuntimeTokenEnv } from "../runtime-tokens";
@@ -208,6 +209,10 @@ export const postAgentAction: Handler = async (req, params) => {
208
209
  if (!action) return error("action required");
209
210
  const agent = getAgent(params.id!);
210
211
  if (!agent) return error("agent not found", 404);
212
+ // #392: the acting user must be allowed to drive this agent (fail-open single-user). Gated
213
+ // before the capability probe so an unauthorized user learns nothing about what the agent supports.
214
+ const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
215
+ if (driveDenied) return driveDenied;
211
216
  if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
212
217
 
213
218
  // Shutdown converges on the shutdownAgent service (epic #342, #347): one #221
@@ -273,6 +278,7 @@ export const postAgentAction: Handler = async (req, params) => {
273
278
  ...(action === "compact" ? compactMemoryParams(agent) : {}),
274
279
  ...(resumeId ? { claudeResumeId: resumeId } : {}),
275
280
  requestedBy: "dashboard",
281
+ requestedByUser: requestUserAddress(req),
276
282
  requestedAt: Date.now(),
277
283
  },
278
284
  });
@@ -292,7 +298,7 @@ export const postAgentAction: Handler = async (req, params) => {
292
298
  icon: agentControlActionIcon(action),
293
299
  view: "agents",
294
300
  agentId: agent.id,
295
- metadata: { action, commandId: command.id, ...(orchestrator ? { orchestratorId: orchestrator.id } : {}), ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
301
+ metadata: { action, commandId: command.id, requestedByUser: requestUserAddress(req), ...(orchestrator ? { orchestratorId: orchestrator.id } : {}), ...authAuditMetadata(req), ...dashboardAttribution(req, parsed.body.surface) },
296
302
  });
297
303
  return json({ ok: true, action, command }, 202);
298
304
  } catch (e) {
@@ -310,14 +316,22 @@ export const postAgentPrompt: Handler = async (req, params) => {
310
316
  const attachments = cleanAttachmentRefs(parsed.body.attachments ?? parsed.body.artifacts, "attachments");
311
317
  const agent = getAgent(params.id!);
312
318
  if (!agent) return error("agent not found", 404);
319
+ // #392: only a user authorized to drive this agent may prompt it (fail-open single-user).
320
+ const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
321
+ if (driveDenied) return driveDenied;
313
322
  if (agent.status === "offline") return error("agent is offline", 422);
314
323
  const denied = authorizeRoute(req, {
315
324
  scope: "message:send",
316
325
  resource: { target: agent.id, agentId: "user" },
317
326
  });
318
327
  if (denied) return denied;
328
+ // Attribute the prompt to the acting human, derived from the TRUSTED requestUserId — never
329
+ // an echo of the client `X-Dashboard-User-Id` header (#390 forgery fix). Defaults to the
330
+ // canonical `user` sink, so today's single operator routes/renders exactly as before; once
331
+ // auth (#389) sets the real session user, the bubble carries the real `user:<id>`.
332
+ const fromUser = requestUserAddress(req);
319
333
  const message = sendMessage({
320
- from: "user",
334
+ from: fromUser,
321
335
  to: agent.id,
322
336
  kind: "session",
323
337
  body,
@@ -336,7 +350,7 @@ export const postAgentPrompt: Handler = async (req, params) => {
336
350
  kind: "message",
337
351
  title: "Prompt injected",
338
352
  body,
339
- meta: `user -> ${agent.id}`,
353
+ meta: `${fromUser} -> ${agent.id}`,
340
354
  icon: "ti-message-bolt",
341
355
  view: "messages",
342
356
  messageId: message.id,
@@ -371,6 +385,9 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
371
385
  }
372
386
  const agent = getAgent(params.id!);
373
387
  if (!agent) return error("agent not found", 404);
388
+ // #392: approving/denying a permission is driving the agent (fail-open single-user).
389
+ const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
390
+ if (driveDenied) return driveDenied;
374
391
  if (!agentIsControlEligible(agent)) return error("agent cannot receive permission decisions", 400);
375
392
  const pendingApproval = isRecord(agent.meta?.providerState) && isRecord(agent.meta.providerState.pendingApproval)
376
393
  ? agent.meta.providerState.pendingApproval
@@ -397,6 +414,7 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
397
414
  ...(reason ? { reason } : {}),
398
415
  ...(answers ? { answers } : {}),
399
416
  requestedBy: "dashboard",
417
+ requestedByUser: requestUserAddress(req),
400
418
  requestedAt: Date.now(),
401
419
  },
402
420
  });
@@ -410,7 +428,7 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
410
428
  icon: decision === "deny" || decision === "abort" ? "ti-circle-x" : "ti-circle-check",
411
429
  view: "chat",
412
430
  agentId: agent.id,
413
- metadata: { decision, approvalId, commandId: command.id, ...authAuditMetadata(req) },
431
+ metadata: { decision, approvalId, commandId: command.id, requestedByUser: requestUserAddress(req), ...authAuditMetadata(req) },
414
432
  });
415
433
  return json({ ok: true, decision, command }, 202);
416
434
  } catch (e) {
@@ -419,6 +437,39 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
419
437
  }
420
438
  };
421
439
 
440
+ // Dashboard viewer presence heartbeat (#390): "I am viewing/typing in this agent's chat".
441
+ // The client re-announces on an interval and on a `leaving` beacon. Keyed by the existing
442
+ // X-Dashboard-Client-Id so multiple tabs of one browser collapse to one viewer; the acting
443
+ // human comes from the TRUSTED requestUserAddress (NOT the client X-Dashboard-User-Id header),
444
+ // so a crafted header can't make presence show you as someone else (#390 forgery fix). We
445
+ // broadcast the live viewer set so every open dashboard updates without polling. No auth gate —
446
+ // these are non-secret opaque ids and the view-state of a chat already streams over the same SSE
447
+ // channel.
448
+ export const postAgentPresence: Handler = async (req, params) => {
449
+ const parsed = await parseBody<unknown>(req);
450
+ if (!parsed.ok) return error(parsed.error, parsed.status);
451
+ const agent = getAgent(params.id!);
452
+ if (!agent) return error("agent not found", 404);
453
+ const clientId = req.headers.get("x-dashboard-client-id")?.trim();
454
+ if (!clientId) return error("X-Dashboard-Client-Id required");
455
+ const body = isRecord(parsed.body) ? parsed.body : {};
456
+ const viewers = announcePresence({
457
+ agentId: agent.id,
458
+ clientId,
459
+ userId: requestUserAddress(req),
460
+ typing: body.typing === true,
461
+ leaving: body.leaving === true,
462
+ });
463
+ emitDashboardPresence(agent.id, viewers);
464
+ return json({ ok: true, agentId: agent.id, viewers });
465
+ };
466
+
467
+ export const getAgentPresence: Handler = (_req, params) => {
468
+ const agent = getAgent(params.id!);
469
+ if (!agent) return error("agent not found", 404);
470
+ return json({ agentId: agent.id, viewers: viewersForAgent(agent.id) });
471
+ };
472
+
422
473
  export const postAgentTerminalSession: Handler = async (req, params) => {
423
474
  try {
424
475
  const agent = getAgent(params.id!);
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents.
2
- import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, type Handler } from "./_shared";
2
+ import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
3
3
  import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
4
4
  import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
5
  import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
@@ -45,7 +45,11 @@ export const getAgents: Handler = (req) => {
45
45
  const tag = url.searchParams.get("tag") ?? undefined;
46
46
  const machine = url.searchParams.get("machine") ?? undefined;
47
47
  const status = url.searchParams.get("status") ?? undefined;
48
- return json(attachIssueRepos(attachBranchStates(listAgents({ tag, machine, status }))));
48
+ // #392: hide agents the acting user may not see. Fail-open in the single-user world
49
+ // (visibleAgentIdsForRequest returns every id), so today's list is byte-for-byte unchanged.
50
+ const agents = listAgents({ tag, machine, status });
51
+ const visible = visibleAgentIdsForRequest(req, agents.map((a) => a.id));
52
+ return json(attachIssueRepos(attachBranchStates(agents.filter((a) => visible.has(a.id)))));
49
53
  };
50
54
 
51
55
  export const findAgents: Handler = (req) => {
@@ -56,9 +60,12 @@ export const findAgents: Handler = (req) => {
56
60
  return json(searchAgents({ capability, status: includeAll ? "all" : "running" }));
57
61
  };
58
62
 
59
- export const getAgentById: Handler = (_req, params) => {
63
+ export const getAgentById: Handler = (req, params) => {
60
64
  const agent = getAgent(params.id!);
61
- return agent ? json(attachIssueRepo(attachBranchState(agent))) : error("agent not found", 404);
65
+ if (!agent) return error("agent not found", 404);
66
+ // #392: a non-visible agent must look indistinguishable from a missing one (no existence leak).
67
+ if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
68
+ return json(attachIssueRepo(attachBranchState(agent)));
62
69
  };
63
70
 
64
71
  export const getAgentReplyObligations: Handler = (_req, params) => {
@@ -69,6 +76,8 @@ export const getAgentReplyObligations: Handler = (_req, params) => {
69
76
  export const getAgentChatHistory: Handler = (req, params) => {
70
77
  const agent = getAgent(params.id!);
71
78
  if (!agent) return error("agent not found", 404);
79
+ // #392: chat history is only readable for agents the user may see (existence-hiding 404).
80
+ if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
72
81
  const url = new URL(req.url);
73
82
  const beforeRaw = parseQueryInt(url.searchParams.get("before"), { min: 1, max: Number.MAX_SAFE_INTEGER });
74
83
  if (Number.isNaN(beforeRaw)) return error("before must be a positive integer");
@@ -1,7 +1,8 @@
1
1
  // Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
2
2
  import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
3
3
  import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, putAgentProfileRoute } from "./agent-profiles";
4
- import { deleteAgentTerminalSession, postAgentAction, postAgentPermissionDecision, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
4
+ import { deleteAgentTerminalSession, getAgentPresence, postAgentAction, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
5
+ import { getUsers } from "./users";
5
6
  import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
6
7
  import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
7
8
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
@@ -85,6 +86,8 @@ const routes: Route[] = [
85
86
  route("PATCH", "/api/memories/:id", patchMemory),
86
87
  route("DELETE", "/api/memories/:id", deleteMemoryRoute),
87
88
 
89
+ route("GET", "/api/users", getUsers),
90
+
88
91
  route("POST", "/api/agents", postAgent),
89
92
  route("POST", "/api/agents/spawn", postAgentSpawn),
90
93
  route("GET", "/api/agents", getAgents),
@@ -111,6 +114,8 @@ const routes: Route[] = [
111
114
  route("POST", "/api/agents/:id/actions", postAgentAction),
112
115
  route("POST", "/api/agents/:id/prompt", postAgentPrompt),
113
116
  route("POST", "/api/agents/:id/permission-decision", postAgentPermissionDecision),
117
+ route("GET", "/api/agents/:id/presence", getAgentPresence),
118
+ route("POST", "/api/agents/:id/presence", postAgentPresence),
114
119
  route("POST", "/api/agents/:id/terminal-session", postAgentTerminalSession),
115
120
  route("DELETE", "/api/agents/:id/terminal-session/:session", deleteAgentTerminalSession),
116
121
  route("DELETE", "/api/agents/:id", deleteAgentById),
@@ -1,16 +1,23 @@
1
1
  // Auto-split from routes.ts (#299). Domain: schema-driven settings.
2
2
  import { ValidationError } from "../db";
3
3
  import { getConfig, setConfig } from "../config-store";
4
+ import { getUserSetting, setUserSetting } from "../user-settings-store";
4
5
  import { ensureProviderConfigDescriptors, migrateLocalProviderConfigs } from "../provider-config-store";
5
6
  import { getSettingDescriptor, listSettingDescriptors, validateSettingValue, PROVIDER_CONFIG_NAMESPACE } from "../settings/registry";
6
7
  import { isRequestAuthorizedFor } from "../security";
7
8
  import { cleanString } from "../validation";
8
9
  import { emitConfigChanged } from "../sse";
9
- import { error, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
10
- import { isRecord, type ConfigEntry, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
10
+ import { error, json, normalizeConfigPathParam, parseBody, requestUserId, type Handler } from "./_shared";
11
+ import { DEFAULT_USER_ID, isRecord, type ConfigEntry, type SettingDescriptor, type SettingEntry } from "agent-relay-sdk";
11
12
 
12
13
  type SettingEnvelope = SettingEntry;
13
14
 
15
+ // Which human a `tier:"user"` (#391) read/write resolves to: the ONE canonical
16
+ // `requestUserId` resolver (#388 Wave A integration), shared with authz (#392) and
17
+ // attribution (#390) so the three seams never diverge. Until sessions (#389) upgrade it,
18
+ // every request resolves to the seeded admin `user:default` (#393), so prefs read back exactly
19
+ // as before the migration (back-compat). Server-runtime/bootstrap/browser tiers ignore this.
20
+
14
21
  export const getSettingsRoute: Handler = (req) => {
15
22
  migrateLocalProviderConfigs();
16
23
  ensureProviderConfigDescriptors();
@@ -42,7 +49,9 @@ export const putSettingRoute: Handler = (req, params) => putSetting(req, params.
42
49
  export function getRegisteredSettingEntry(namespace: string, key: string): ConfigEntry | null {
43
50
  ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
44
51
  const descriptor = getSettingDescriptor(namespace, key);
45
- return descriptor ? currentSettingEntry(descriptor) : null;
52
+ // Programmatic (no request) reads have no session, so a user-tier descriptor resolves to the
53
+ // default human (#393) — the single human today; matches the HTTP path's back-compat default.
54
+ return descriptor ? currentSettingEntry(descriptor, DEFAULT_USER_ID) : null;
46
55
  }
47
56
 
48
57
  export function setRegisteredSettingValue(namespace: string, key: string, value: unknown, updatedBy?: string): ConfigEntry {
@@ -52,6 +61,7 @@ export function setRegisteredSettingValue(namespace: string, key: string, value:
52
61
  const merged = mergeSettingDefaults(descriptor.defaults, value);
53
62
  const validation = validateSettingValue(namespace, key, merged);
54
63
  if (!validation.valid) throw new ValidationError(validation.errors.join("; "));
64
+ if (descriptor.tier === "user") return setUserSetting(DEFAULT_USER_ID, namespace, key, merged, updatedBy);
55
65
  return setConfig(namespace, key, merged, updatedBy);
56
66
  }
57
67
 
@@ -68,11 +78,17 @@ async function putSetting(req: Request, namespaceRaw: string | undefined, keyRaw
68
78
  ensureProviderConfigDescriptors(namespace === PROVIDER_CONFIG_NAMESPACE ? [key] : []);
69
79
  const descriptor = getSettingDescriptor(namespace, key);
70
80
  if (!descriptor) return error("setting not found", 404);
71
- if (descriptor.tier !== "server-runtime") return error("setting is read-only", 403);
81
+ if (descriptor.tier !== "server-runtime" && descriptor.tier !== "user") return error("setting is read-only", 403);
72
82
  if (!canAccessSetting(req, descriptor.scope.write)) return error("forbidden", 403);
73
83
  const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
74
84
  const validation = validateSettingValue(namespace, key, parsed.body.value);
75
85
  if (!validation.valid) return json({ error: "invalid setting value", details: validation.errors }, 400);
86
+ if (descriptor.tier === "user") {
87
+ const userId = requestUserId(req);
88
+ const userEntry = setUserSetting(userId, namespace, key, parsed.body.value, updatedBy);
89
+ emitConfigChanged(userEntry.namespace, userEntry.key, userEntry.version);
90
+ return json(settingEnvelope(req, descriptor, userEntry), userEntry.version === 1 ? 201 : 200);
91
+ }
76
92
  const entry = setConfig(namespace, key, parsed.body.value, updatedBy);
77
93
  emitConfigChanged(entry.namespace, entry.key, entry.version);
78
94
  return json(settingEnvelope(req, descriptor, entry), entry.version === 1 ? 201 : 200);
@@ -88,7 +104,12 @@ function listVisibleSettings(req: Request): SettingEnvelope[] {
88
104
  .filter((setting) => setting.readable);
89
105
  }
90
106
 
91
- function settingEnvelope(req: Request, descriptor: SettingDescriptor, entry = currentSettingEntry(descriptor)): SettingEnvelope {
107
+ function settingEnvelope(
108
+ req: Request,
109
+ descriptor: SettingDescriptor,
110
+ entry = currentSettingEntry(descriptor, requestUserId(req)),
111
+ ): SettingEnvelope {
112
+ const mutableTier = descriptor.tier === "server-runtime" || descriptor.tier === "user";
92
113
  return {
93
114
  ...descriptor,
94
115
  value: entry.value,
@@ -96,12 +117,16 @@ function settingEnvelope(req: Request, descriptor: SettingDescriptor, entry = cu
96
117
  updatedAt: entry.updatedAt,
97
118
  updatedBy: entry.updatedBy,
98
119
  readable: canAccessSetting(req, descriptor.scope.read),
99
- writable: descriptor.tier === "server-runtime" && canAccessSetting(req, descriptor.scope.write),
120
+ writable: mutableTier && canAccessSetting(req, descriptor.scope.write),
100
121
  };
101
122
  }
102
123
 
103
- function currentSettingEntry(descriptor: SettingDescriptor): ConfigEntry {
104
- const entry = getConfig(descriptor.namespace, descriptor.key);
124
+ // Resolve the current stored value for a descriptor. `tier:"user"` (#391) reads the per-user row
125
+ // for `userId`; every other tier reads the instance-global `config` table. Unset → descriptor defaults.
126
+ function currentSettingEntry(descriptor: SettingDescriptor, userId: string): ConfigEntry {
127
+ const entry = descriptor.tier === "user"
128
+ ? getUserSetting(userId, descriptor.namespace, descriptor.key)
129
+ : getConfig(descriptor.namespace, descriptor.key);
105
130
  if (entry) return entry;
106
131
  return {
107
132
  namespace: descriptor.namespace,
@@ -0,0 +1,8 @@
1
+ // Public user directory for dashboard attribution & presentation (#390). Surfaces the
2
+ // keystone `users` table (#393) so the dashboard can resolve a `user:<id>` message address
3
+ // to a human name/avatar. Read-only; the user row holds no secrets (auth credentials land
4
+ // separately in #389), so the whole row is dashboard-safe to return.
5
+ import { listUsers } from "../db";
6
+ import { json, type Handler } from "./_shared";
7
+
8
+ export const getUsers: Handler = () => json(listUsers());
@@ -9,7 +9,8 @@
9
9
  // managed came-up-running reconcile (delegated to managed-running.ts), the single
10
10
  // status broadcast, the single timeline note, the spawned-child parent-wake, and
11
11
  // the first-registration audit row.
12
- import { createActivityEvent, getAgent, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
12
+ import { isReservedAgentId } from "agent-relay-sdk";
13
+ import { ValidationError, createActivityEvent, getAgent, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
13
14
  import { emitAgentStatus, emitNewMessage } from "../sse";
14
15
  import { noteAgentTimelineEvent } from "../compaction-watch";
15
16
  import { notifyAgentReady } from "../agent-lifecycle-events";
@@ -24,6 +25,15 @@ function metaStr(meta: Record<string, unknown> | undefined, key: string): string
24
25
  }
25
26
 
26
27
  export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): AgentCard {
28
+ // Reserved built-in identities — the `system` lifecycle sender and the whole `user:` human
29
+ // namespace (legacy bare `'user'`, `user:default`, every `user:<id>`) — are mechanical sinks,
30
+ // never registrable agents. THIS is the one external-registration boundary (HTTP `postAgent`
31
+ // and bus `handleRegister` both land here); rejecting them closes the spoof where a provider
32
+ // token squats a human address to receive human-addressed mail (#393). Internal built-in
33
+ // seeding writes straight through `upsertAgent`, bypassing this gate, so it stays unaffected.
34
+ if (isReservedAgentId(input.id)) {
35
+ throw new ValidationError(`agent id "${input.id}" is reserved and cannot be registered`);
36
+ }
27
37
  // Lineage is authoritative from the registering token's signed constraints — never
28
38
  // the client-sent body (a child can't forge its own parent). Set by relay at spawn.
29
39
  const lineage = resolveSpawnLineage(ctx.constraints);
@@ -21,7 +21,7 @@
21
21
  //
22
22
  // OUT OF SCOPE (transport-edge, excluded from the parity facet): automatic memory injection
23
23
  // (needs request-derived context) and per-transport audit (HTTP domain row vs MCP tool-call row).
24
- import { isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
24
+ import { canonicalHumanAgentId, isHumanAgentId, isMechanicalMessageKind, isRecord, isReservedAgentId } from "agent-relay-sdk";
25
25
  import {
26
26
  ValidationError,
27
27
  getMessage,
@@ -159,6 +159,12 @@ export function sendMessageService(
159
159
  ): SendMessageResult {
160
160
  applyReplyRouting(input);
161
161
  if (!input.to) throw new ValidationError("to is required (or provide replyTo to auto-route)");
162
+ // De-singletoned human addressing (#393): collapse a human address to its stored/canonical
163
+ // form so `user:default` is a fully WORKING alias of the legacy bare `'user'` sink — it routes
164
+ // to the same agent row + inbox + thread, with zero live-byte movement. Per-user `user:<id>`
165
+ // addresses pass through unchanged for the later #388 waves; non-human ids are untouched.
166
+ if (isHumanAgentId(input.to)) input.to = canonicalHumanAgentId(input.to);
167
+ if (input.from && isHumanAgentId(input.from)) input.from = canonicalHumanAgentId(input.from);
162
168
  // The reserved-sink observability lane (#284) governs both `from` resolution (wire `from` is
163
169
  // authoritative — the runner reports an agent's turn) and authorization (drop the recipient
164
170
  // predicate). Compute once, after reply-routing has settled `to`.
@@ -241,6 +241,41 @@ const LANDING_SCHEMA: JSONSchema = {
241
241
  },
242
242
  };
243
243
 
244
+ // Per-user preference schemas (#391, tier:"user"). These follow the human across devices via the
245
+ // `user_settings` table (keyed by `users.id`), replacing per-browser localStorage for genuinely
246
+ // user-level prefs. Field names mirror the dashboard store (dashboard/src/store) so the client
247
+ // migration is a lift, not a rename. Truly device-local view state stays in the browser.
248
+ const DISPLAY_PREFS_SCHEMA: JSONSchema = {
249
+ type: "object",
250
+ additionalProperties: false,
251
+ required: ["theme", "showReasoning"],
252
+ properties: {
253
+ theme: { type: "string", enum: ["light", "dark", "midnight", "nord", "ember", "parchment"] },
254
+ showReasoning: { type: "boolean" },
255
+ },
256
+ };
257
+
258
+ const VOICE_PREFS_SCHEMA: JSONSchema = {
259
+ type: "object",
260
+ additionalProperties: false,
261
+ required: [
262
+ "voiceTtsEnabled",
263
+ "voiceTtsLang",
264
+ "voiceTtsMode",
265
+ "voiceTtsKokoroVoice",
266
+ "voiceTtsBrowserVoice",
267
+ "voiceInputMode",
268
+ ],
269
+ properties: {
270
+ voiceTtsEnabled: { type: "boolean" },
271
+ voiceTtsLang: { type: "string", maxLength: 35 },
272
+ voiceTtsMode: { type: "string", enum: ["kokoro", "browser"] },
273
+ voiceTtsKokoroVoice: { type: "string", maxLength: 80 },
274
+ voiceTtsBrowserVoice: { type: "string", maxLength: 200 },
275
+ voiceInputMode: { type: "string", enum: ["compose", "autosend", "handsfree"] },
276
+ },
277
+ };
278
+
244
279
  // Provider chat/reasoning capture toggles (source: runner/src/adapter.ts ProviderConfig).
245
280
  // NOTE (#383 coordination): provider config storage is being centralized in #383;
246
281
  // these are seeded instance-global under namespace "provider". Per-provider
@@ -393,6 +428,34 @@ function seedBuiltInSettings(): void {
393
428
  tier: "server-runtime",
394
429
  display: { group: "Providers", label: "Reasoning capture", description: "Stream reasoning/tool steps into chat.", order: 51 },
395
430
  });
431
+
432
+ // Per-user prefs (#391) — tier:"user", stored per `users.id` so they follow the human across devices.
433
+ registerSetting({
434
+ namespace: "display",
435
+ key: "default",
436
+ schema: DISPLAY_PREFS_SCHEMA,
437
+ defaults: { theme: "dark", showReasoning: false },
438
+ scope: { read: "settings:read:display", write: "settings:write:display" },
439
+ tier: "user",
440
+ display: { group: "Preferences", label: "Display", description: "Per-user theme and reasoning visibility (follows you across devices).", order: 70 },
441
+ });
442
+
443
+ registerSetting({
444
+ namespace: "voice",
445
+ key: "default",
446
+ schema: VOICE_PREFS_SCHEMA,
447
+ defaults: {
448
+ voiceTtsEnabled: false,
449
+ voiceTtsLang: "en-US",
450
+ voiceTtsMode: "kokoro",
451
+ voiceTtsKokoroVoice: "am_michael",
452
+ voiceTtsBrowserVoice: "",
453
+ voiceInputMode: "compose",
454
+ },
455
+ scope: { read: "settings:read:voice", write: "settings:write:voice" },
456
+ tier: "user",
457
+ display: { group: "Preferences", label: "Voice", description: "Per-user text-to-speech and voice-input preferences.", order: 71 },
458
+ });
396
459
  }
397
460
 
398
461
  export function registerProviderConfigSetting(input: {
package/src/sse.ts CHANGED
@@ -233,6 +233,13 @@ export function getConnectionCount(): number {
233
233
  return connections.size;
234
234
  }
235
235
 
236
+ // Dashboard viewer presence for an agent thread (#390): broadcast the live set of humans
237
+ // currently viewing/typing so every open dashboard can show "X is viewing". Ephemeral — the
238
+ // viewer list comes from the in-memory presence registry, not the DB.
239
+ export function emitDashboardPresence(agentId: string, viewers: unknown) {
240
+ emitRelayEvent({ type: "dashboard.presence", source: "server", subject: agentId, data: { agentId, viewers } });
241
+ }
242
+
236
243
  export function emitOrchestratorStatus(orchestratorId: string) {
237
244
  const orch = getOrchestrator(orchestratorId);
238
245
  const data = orch ?? { id: orchestratorId, status: "offline" };
@@ -0,0 +1,86 @@
1
+ // Per-user settings storage (#391, epic #388). The user dimension the global `config`
2
+ // table lacks: `tier:"user"` descriptors (settings/registry.ts) resolve the SAME
3
+ // `(namespace, key)` address to a per-user value, keyed by the resolved `users.id` (#393),
4
+ // so a human's pref follows them across devices. This is the storage seam ONLY — schema
5
+ // validation + scope enforcement stay in the registry/route; this module just persists the
6
+ // per-user JSON value and mirrors the `config`-table ConfigEntry shape so the settings API
7
+ // treats user-tier and server-runtime values uniformly.
8
+ //
9
+ // Deliberately NOT folded into config-store.ts: the global `config` table keeps its exact
10
+ // `(namespace, key)` PK + history semantics untouched (#391 back-compat), and the
11
+ // transport-thinness/file-size ratchets keep config-store as the instance-global home. Per-user
12
+ // prefs are low-stakes display state, so no config_history mirror — last-write-wins with a
13
+ // monotonic version is enough.
14
+
15
+ import { getDb } from "./db";
16
+ import type { ConfigEntry } from "agent-relay-sdk";
17
+
18
+ interface UserSettingRow {
19
+ user_id: string;
20
+ namespace: string;
21
+ key: string;
22
+ value: string;
23
+ version: number;
24
+ updated_at: string;
25
+ updated_by: string | null;
26
+ }
27
+
28
+ function rowToConfigEntry<T>(row: UserSettingRow): ConfigEntry<T> {
29
+ return {
30
+ namespace: row.namespace,
31
+ key: row.key,
32
+ value: JSON.parse(row.value) as T,
33
+ version: row.version,
34
+ updatedAt: row.updated_at,
35
+ updatedBy: row.updated_by ?? undefined,
36
+ };
37
+ }
38
+
39
+ /** A single user's stored value for a `tier:"user"` descriptor, or null when unset (→ defaults). */
40
+ export function getUserSetting<T = unknown>(userId: string, namespace: string, key: string): ConfigEntry<T> | null {
41
+ const row = getDb()
42
+ .query("SELECT * FROM user_settings WHERE user_id = ? AND namespace = ? AND key = ?")
43
+ .get(userId, namespace, key) as UserSettingRow | undefined;
44
+ return row ? rowToConfigEntry<T>(row) : null;
45
+ }
46
+
47
+ /** All of a user's stored values in a namespace (ascending key). */
48
+ export function listUserSettings<T = unknown>(userId: string, namespace: string): ConfigEntry<T>[] {
49
+ const rows = getDb()
50
+ .query("SELECT * FROM user_settings WHERE user_id = ? AND namespace = ? ORDER BY key ASC")
51
+ .all(userId, namespace) as UserSettingRow[];
52
+ return rows.map(rowToConfigEntry<T>);
53
+ }
54
+
55
+ /** Upsert a user's value for a descriptor. Value is persisted verbatim — validate via the registry first. */
56
+ export function setUserSetting<T = unknown>(
57
+ userId: string,
58
+ namespace: string,
59
+ key: string,
60
+ value: T,
61
+ updatedBy?: string,
62
+ ): ConfigEntry<T> {
63
+ const existing = getUserSetting(userId, namespace, key);
64
+ const now = new Date().toISOString();
65
+ const nextVersion = existing ? existing.version + 1 : 1;
66
+ getDb()
67
+ .query(`
68
+ INSERT INTO user_settings (user_id, namespace, key, value, version, updated_at, updated_by)
69
+ VALUES (?, ?, ?, ?, ?, ?, ?)
70
+ ON CONFLICT(user_id, namespace, key) DO UPDATE SET
71
+ value = excluded.value,
72
+ version = excluded.version,
73
+ updated_at = excluded.updated_at,
74
+ updated_by = excluded.updated_by
75
+ `)
76
+ .run(userId, namespace, key, JSON.stringify(value), nextVersion, now, updatedBy ?? null);
77
+ return getUserSetting<T>(userId, namespace, key)!;
78
+ }
79
+
80
+ /** Drop a user's stored value (reverts to descriptor defaults). Returns false when nothing was stored. */
81
+ export function deleteUserSetting(userId: string, namespace: string, key: string): boolean {
82
+ const existing = getUserSetting(userId, namespace, key);
83
+ if (!existing) return false;
84
+ getDb().query("DELETE FROM user_settings WHERE user_id = ? AND namespace = ? AND key = ?").run(userId, namespace, key);
85
+ return true;
86
+ }