agent-relay-server 0.82.0 → 0.83.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/docs/openapi.json +367 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-BTBQr8uG.js +3 -0
  4. package/public/assets/MessageBubble-BTBQr8uG.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-r3aVT4uc.js → PlanGraphPanel-CoWJJVyD.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-r3aVT4uc.js.map → PlanGraphPanel-CoWJJVyD.js.map} +1 -1
  7. package/public/assets/activity-XqRFu_9T.js +2 -0
  8. package/public/assets/{activity-DtMjjws8.js.map → activity-XqRFu_9T.js.map} +1 -1
  9. package/public/assets/{agent-profiles-eRlq98tp.js → agent-profiles-RFCLAgnC.js} +2 -2
  10. package/public/assets/{agent-profiles-eRlq98tp.js.map → agent-profiles-RFCLAgnC.js.map} +1 -1
  11. package/public/assets/{agents-DmbjRTA7.js → agents-BWmmWj4Q.js} +2 -2
  12. package/public/assets/{agents-DmbjRTA7.js.map → agents-BWmmWj4Q.js.map} +1 -1
  13. package/public/assets/{analytics-BW7ik_Ws.js → analytics-2H2z2UNS.js} +3 -3
  14. package/public/assets/{analytics-BW7ik_Ws.js.map → analytics-2H2z2UNS.js.map} +1 -1
  15. package/public/assets/{automation-CxwGXWC7.js → automation-DRtOENs0.js} +2 -2
  16. package/public/assets/{automation-CxwGXWC7.js.map → automation-DRtOENs0.js.map} +1 -1
  17. package/public/assets/{badge-BwaoBnNO.js → badge-CK6ejiOh.js} +2 -2
  18. package/public/assets/{badge-BwaoBnNO.js.map → badge-CK6ejiOh.js.map} +1 -1
  19. package/public/assets/branch-state-badge-DxwNcWr8.js +2 -0
  20. package/public/assets/{branch-state-badge-CvdKPTlV.js.map → branch-state-badge-DxwNcWr8.js.map} +1 -1
  21. package/public/assets/{button-CFIklTDX.js → button-BAwKuRIq.js} +2 -2
  22. package/public/assets/{button-CFIklTDX.js.map → button-BAwKuRIq.js.map} +1 -1
  23. package/public/assets/{card-Bi94xEmr.js → card-DDxxo-Fo.js} +2 -2
  24. package/public/assets/{card-Bi94xEmr.js.map → card-DDxxo-Fo.js.map} +1 -1
  25. package/public/assets/{channels-D8UY3baa.js → channels-Sh_jdB7y.js} +2 -2
  26. package/public/assets/{channels-D8UY3baa.js.map → channels-Sh_jdB7y.js.map} +1 -1
  27. package/public/assets/chat-gfUxbTus.js +2 -0
  28. package/public/assets/{chat-CCPukbI9.js.map → chat-gfUxbTus.js.map} +1 -1
  29. package/public/assets/{connectors-W2MslhQ2.js → connectors-C3C4i4Gz.js} +2 -2
  30. package/public/assets/{connectors-W2MslhQ2.js.map → connectors-C3C4i4Gz.js.map} +1 -1
  31. package/public/assets/{copy-button-A0pXLXBy.js → copy-button-D3B_s7J7.js} +2 -2
  32. package/public/assets/{copy-button-A0pXLXBy.js.map → copy-button-D3B_s7J7.js.map} +1 -1
  33. package/public/assets/display-CWMHll1J.js.map +1 -1
  34. package/public/assets/{dist--NYpzm4f.js → dist-CAFUEpK2.js} +2 -2
  35. package/public/assets/{dist--NYpzm4f.js.map → dist-CAFUEpK2.js.map} +1 -1
  36. package/public/assets/dist-CIbvPDQV.js +2 -0
  37. package/public/assets/{dist-D3jA_LvQ.js.map → dist-CIbvPDQV.js.map} +1 -1
  38. package/public/assets/{dist-DY3KCMIN.js → dist-CL2npVDe.js} +2 -2
  39. package/public/assets/{dist-DY3KCMIN.js.map → dist-CL2npVDe.js.map} +1 -1
  40. package/public/assets/{dist-DVorlUcA.js → dist-CnRv0y2x.js} +2 -2
  41. package/public/assets/{dist-DVorlUcA.js.map → dist-CnRv0y2x.js.map} +1 -1
  42. package/public/assets/dist-CoRABSO3.js +2 -0
  43. package/public/assets/{dist-rLCXIl7x.js.map → dist-CoRABSO3.js.map} +1 -1
  44. package/public/assets/{dist-yzKYLD10.js → dist-DLsSqk6h.js} +2 -2
  45. package/public/assets/{dist-yzKYLD10.js.map → dist-DLsSqk6h.js.map} +1 -1
  46. package/public/assets/{dist-DhEz6YNy.js → dist-Dmiv1tSO.js} +2 -2
  47. package/public/assets/{dist-DhEz6YNy.js.map → dist-Dmiv1tSO.js.map} +1 -1
  48. package/public/assets/dist-DyqEeyBI.js +2 -0
  49. package/public/assets/{dist-HTxIFS7t.js.map → dist-DyqEeyBI.js.map} +1 -1
  50. package/public/assets/{dist-_EunAAFX.js → dist-ERYvDMdt.js} +2 -2
  51. package/public/assets/{dist-_EunAAFX.js.map → dist-ERYvDMdt.js.map} +1 -1
  52. package/public/assets/{dist-C4VoXCT-.js → dist-YQVQrtnc.js} +2 -2
  53. package/public/assets/{dist-C4VoXCT-.js.map → dist-YQVQrtnc.js.map} +1 -1
  54. package/public/assets/{dist-BODiHbq1.js → dist-YjP8Gd1C.js} +2 -2
  55. package/public/assets/{dist-BODiHbq1.js.map → dist-YjP8Gd1C.js.map} +1 -1
  56. package/public/assets/{es2015-BDZowz0r.js → es2015-Yto-4EBw.js} +2 -2
  57. package/public/assets/{es2015-BDZowz0r.js.map → es2015-Yto-4EBw.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-tIjgf9Kr.js → formatted-body-impl-DpNHGXXQ.js} +2 -2
  59. package/public/assets/{formatted-body-impl-tIjgf9Kr.js.map → formatted-body-impl-DpNHGXXQ.js.map} +1 -1
  60. package/public/assets/index-D8TVUdlC.js +22 -0
  61. package/public/assets/index-D8TVUdlC.js.map +1 -0
  62. package/public/assets/index-DgZLDL0K.css +2 -0
  63. package/public/assets/{input-CD-ZcZIA.js → input-CLW2gDUM.js} +2 -2
  64. package/public/assets/{input-CD-ZcZIA.js.map → input-CLW2gDUM.js.map} +1 -1
  65. package/public/assets/{insights-B93PGmKq.js → insights-DPaJ5Hme.js} +2 -2
  66. package/public/assets/{insights-B93PGmKq.js.map → insights-DPaJ5Hme.js.map} +1 -1
  67. package/public/assets/{integrations-BfWMAznM.js → integrations-PqWMrPJa.js} +2 -2
  68. package/public/assets/{integrations-BfWMAznM.js.map → integrations-PqWMrPJa.js.map} +1 -1
  69. package/public/assets/{lib-BqL6WCDu.js → lib-DdCM7GfN.js} +2 -2
  70. package/public/assets/{lib-BqL6WCDu.js.map → lib-DdCM7GfN.js.map} +1 -1
  71. package/public/assets/lucide-react-CsPXvXMC.js +9 -0
  72. package/public/assets/{lucide-react-D78N6SwK.js.map → lucide-react-CsPXvXMC.js.map} +1 -1
  73. package/public/assets/{maintenance-CgRASTD0.js → maintenance-DsszLb92.js} +2 -2
  74. package/public/assets/{maintenance-CgRASTD0.js.map → maintenance-DsszLb92.js.map} +1 -1
  75. package/public/assets/{managed-agents-B1AdrEv4.js → managed-agents-DqWPxFE0.js} +2 -2
  76. package/public/assets/{managed-agents-B1AdrEv4.js.map → managed-agents-DqWPxFE0.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-D2zEt-i-.js → markdown-preview-impl-CWR4ILHK.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-D2zEt-i-.js.map → markdown-preview-impl-CWR4ILHK.js.map} +1 -1
  79. package/public/assets/memory-C2s9bJ38.js +2 -0
  80. package/public/assets/{memory-nUBcij_9.js.map → memory-C2s9bJ38.js.map} +1 -1
  81. package/public/assets/{messages-BNDs53bW.js → messages-DHRJNAvy.js} +2 -2
  82. package/public/assets/{messages-BNDs53bW.js.map → messages-DHRJNAvy.js.map} +1 -1
  83. package/public/assets/{orchestrators-BouoI90a.js → orchestrators-BGNNllJc.js} +2 -2
  84. package/public/assets/{orchestrators-BouoI90a.js.map → orchestrators-BGNNllJc.js.map} +1 -1
  85. package/public/assets/{overview-C8_hAGpC.js → overview-C1vZfyhW.js} +2 -2
  86. package/public/assets/{overview-C8_hAGpC.js.map → overview-C1vZfyhW.js.map} +1 -1
  87. package/public/assets/{pairs-BcCOB1oO.js → pairs-C1uzo8x0.js} +2 -2
  88. package/public/assets/{pairs-BcCOB1oO.js.map → pairs-C1uzo8x0.js.map} +1 -1
  89. package/public/assets/{projects-MZ6CNrYO.js → projects-B2-8Eu0e.js} +2 -2
  90. package/public/assets/{projects-MZ6CNrYO.js.map → projects-B2-8Eu0e.js.map} +1 -1
  91. package/public/assets/{react-dom-CU0WCHqq.js → react-dom-CEl58oxP.js} +2 -2
  92. package/public/assets/{react-dom-CU0WCHqq.js.map → react-dom-CEl58oxP.js.map} +1 -1
  93. package/public/assets/{security-Bh8BGpZR.js → security-XhAAP6q3.js} +2 -2
  94. package/public/assets/{security-Bh8BGpZR.js.map → security-XhAAP6q3.js.map} +1 -1
  95. package/public/assets/{select-Drdvachg.js → select-DEGUFEjx.js} +2 -2
  96. package/public/assets/{select-Drdvachg.js.map → select-DEGUFEjx.js.map} +1 -1
  97. package/public/assets/settings-DTbVUdlZ.js +4 -0
  98. package/public/assets/{settings-IrNBT0hy.js.map → settings-DTbVUdlZ.js.map} +1 -1
  99. package/public/assets/{store-CqFZmc-B.js → store-DYaQAXRv.js} +5 -5
  100. package/public/assets/{store-CqFZmc-B.js.map → store-DYaQAXRv.js.map} +1 -1
  101. package/public/assets/{tasks-heEgx7vT.js → tasks-zwGo7hon.js} +2 -2
  102. package/public/assets/{tasks-heEgx7vT.js.map → tasks-zwGo7hon.js.map} +1 -1
  103. package/public/assets/{teams-BjETr-_W.js → teams-Brc5D8ox.js} +2 -2
  104. package/public/assets/{teams-BjETr-_W.js.map → teams-Brc5D8ox.js.map} +1 -1
  105. package/public/assets/{terminal-viewer-impl-BDqmbWB3.js → terminal-viewer-impl-C6yUAL8G.js} +3 -3
  106. package/public/assets/{terminal-viewer-impl-BDqmbWB3.js.map → terminal-viewer-impl-C6yUAL8G.js.map} +1 -1
  107. package/public/assets/{use-keyboard-viewport-CichJ8Jn.js → use-keyboard-viewport-DABeIMEJ.js} +2 -2
  108. package/public/assets/{use-keyboard-viewport-CichJ8Jn.js.map → use-keyboard-viewport-DABeIMEJ.js.map} +1 -1
  109. package/public/assets/user-avatar-Bw1R011j.js +2 -0
  110. package/public/assets/user-avatar-Bw1R011j.js.map +1 -0
  111. package/public/assets/{work-queue-rgQs4Wis.js → work-queue-ge4R7keo.js} +2 -2
  112. package/public/assets/{work-queue-rgQs4Wis.js.map → work-queue-ge4R7keo.js.map} +1 -1
  113. package/public/assets/workspaces-BhblC-Nd.js +3 -0
  114. package/public/assets/{workspaces-hIjZicaF.js.map → workspaces-BhblC-Nd.js.map} +1 -1
  115. package/public/index.html +21 -21
  116. package/src/agent-drive-arbiter.ts +112 -0
  117. package/src/db/artifacts.ts +2 -3
  118. package/src/db/drive-leases.ts +165 -0
  119. package/src/db/index.ts +1 -0
  120. package/src/db/migrations.ts +6 -0
  121. package/src/db/users.ts +26 -0
  122. package/src/maintenance/jobs.ts +11 -0
  123. package/src/routes/_shared.ts +1 -1
  124. package/src/routes/agent-sessions.ts +97 -1
  125. package/src/routes/artifacts.ts +7 -3
  126. package/src/routes/index.ts +13 -2
  127. package/src/routes/users.ts +152 -6
  128. package/src/security.ts +2 -0
  129. package/src/services/user-profile.ts +69 -0
  130. package/src/upgrade-install-verify.ts +160 -0
  131. package/src/upgrade.ts +40 -11
  132. package/public/assets/MessageBubble-ckJ2VVgq.js +0 -3
  133. package/public/assets/MessageBubble-ckJ2VVgq.js.map +0 -1
  134. package/public/assets/activity-DtMjjws8.js +0 -2
  135. package/public/assets/branch-state-badge-CvdKPTlV.js +0 -2
  136. package/public/assets/chat-CCPukbI9.js +0 -2
  137. package/public/assets/dist-D3jA_LvQ.js +0 -2
  138. package/public/assets/dist-HTxIFS7t.js +0 -2
  139. package/public/assets/dist-rLCXIl7x.js +0 -2
  140. package/public/assets/index-BfQFX8td.js +0 -22
  141. package/public/assets/index-BfQFX8td.js.map +0 -1
  142. package/public/assets/index-D8vc380x.css +0 -2
  143. package/public/assets/lucide-react-D78N6SwK.js +0 -9
  144. package/public/assets/memory-nUBcij_9.js +0 -2
  145. package/public/assets/settings-IrNBT0hy.js +0 -4
  146. package/public/assets/workspaces-hIjZicaF.js +0 -3
@@ -0,0 +1,112 @@
1
+ // Concurrency arbitration — "two humans driving one agent safely" (#388 users wave 5, #394).
2
+ //
3
+ // THE single decision home for "may this user drive this agent right now?". It composes three
4
+ // things the routes must never re-derive piecemeal:
5
+ // 1. the single-vs-multi-user switch (`isMultiUserWorld`) — single-user is a COMPLETE no-op,
6
+ // 2. the per-agent drive lease (`src/db/drive-leases.ts`) — the soft-lock / "X is driving",
7
+ // 3. role (`resolveViewer`) — an admin overrides a held wheel.
8
+ //
9
+ // The drive routes (prompt submit, interrupt, plan-approve/answer) and the explicit handoff
10
+ // endpoint both call `arbitrateDrive`, so the lock can never be enforced in one seam and skipped
11
+ // in another.
12
+ //
13
+ // Back-compat (load-bearing): with a single user `arbitrateDrive` returns `allowed` WITHOUT
14
+ // touching the lease table — the solo operator always holds the wheel, prompt/interrupt/approve
15
+ // flow exactly as before, and no expiry can ever strand them out of their own agent. Arbitration
16
+ // only engages once a second user exists.
17
+ import { canonicalHumanAgentId, userSinkId } from "agent-relay-sdk";
18
+ import { isMultiUserWorld } from "./db/agent-ownership.ts";
19
+ import { resolveViewer } from "./agent-authz.ts";
20
+ import { getUser } from "./db/users.ts";
21
+ import {
22
+ acquireDriveLease,
23
+ getDriveLease,
24
+ releaseDriveLease,
25
+ takeOverDriveLease,
26
+ type DriveLeaseRecord,
27
+ } from "./db/drive-leases.ts";
28
+
29
+ /** Who holds (or held) the wheel, resolved for human-readable "X is driving" messaging. */
30
+ interface DriveHolder {
31
+ /** Relational `users.id`. */
32
+ userId: string;
33
+ /** Canonical human message address (`user` / `user:<id>`). */
34
+ address: string;
35
+ /** Friendly name when the user row resolves. */
36
+ displayName?: string;
37
+ }
38
+
39
+ /**
40
+ * The outcome of an arbitration. `mode` records WHY it was allowed so the route can audit a steal
41
+ * distinctly from an ordinary acquire:
42
+ * - `solo` — single-user world, no lease written (back-compat no-op).
43
+ * - `holder` — caller already held the wheel; lease refreshed.
44
+ * - `acquired` — wheel was free; caller took it.
45
+ * - `override` — caller is an admin who stole a live lease from another driver.
46
+ */
47
+ export type DriveArbitration =
48
+ | { allowed: true; mode: "solo" | "holder" | "acquired" | "override"; lease: DriveLeaseRecord | null; previousHolder?: DriveHolder }
49
+ | { allowed: false; heldBy: DriveHolder; lease: DriveLeaseRecord };
50
+
51
+ /** Resolve a `users.id` into a displayable drive holder. */
52
+ function driveHolder(userId: string): DriveHolder {
53
+ const user = getUser(userId);
54
+ const holder: DriveHolder = { userId, address: canonicalHumanAgentId(userSinkId(userId)) };
55
+ const name = user?.displayName ?? user?.handle;
56
+ if (name) holder.displayName = name;
57
+ return holder;
58
+ }
59
+
60
+ /** A short "X is driving this agent" phrase for the 409 body / UI. */
61
+ export function drivingMessage(holder: DriveHolder): string {
62
+ return `${holder.displayName ?? holder.address} is driving this agent`;
63
+ }
64
+
65
+ /**
66
+ * Decide whether `userId` may drive `agentId` now, taking/refreshing the lease as a side effect on
67
+ * success. An admin always wins (stealing a live lease → `override`); a non-admin wins only when
68
+ * the wheel is free or already theirs, otherwise it is `denied` with who holds it.
69
+ *
70
+ * Callers pass an already-`drive`-authorized user (the #392 gate runs first); arbitration is the
71
+ * second, concurrency layer on top.
72
+ */
73
+ export function arbitrateDrive(userId: string, agentId: string, opts: { now?: number } = {}): DriveArbitration {
74
+ // Single-user world → no-op. No lease row is written, so a vanished/expired lease can NEVER
75
+ // strand the solo user out of their own agent (the load-bearing back-compat invariant).
76
+ if (!isMultiUserWorld()) return { allowed: true, mode: "solo", lease: null };
77
+
78
+ const now = opts.now;
79
+ const before = getDriveLease(agentId, now);
80
+ const acq = acquireDriveLease({ agentId, userId, now });
81
+ if (acq.ok) {
82
+ return { allowed: true, mode: before?.holderUserId === userId ? "holder" : "acquired", lease: acq.lease };
83
+ }
84
+
85
+ // Blocked by another live holder. An admin overrides and steals the wheel; everyone else waits.
86
+ if (resolveViewer(userId)?.role === "admin") {
87
+ const { lease, previousHolderUserId } = takeOverDriveLease({ agentId, userId, now });
88
+ const result: DriveArbitration = { allowed: true, mode: "override", lease };
89
+ if (previousHolderUserId) result.previousHolder = driveHolder(previousHolderUserId);
90
+ return result;
91
+ }
92
+ return { allowed: false, heldBy: driveHolder(acq.lease.holderUserId), lease: acq.lease };
93
+ }
94
+
95
+ /**
96
+ * Explicit release of the wheel ("let go"). A holder releases their own lease; an admin may force
97
+ * a reset (no `userId` guard). Single-user is a no-op. Returns the holder that was released, if any.
98
+ */
99
+ export function releaseDrive(userId: string, agentId: string, opts: { force?: boolean } = {}): { released: boolean; previousHolder?: DriveHolder } {
100
+ if (!isMultiUserWorld()) return { released: false };
101
+ const current = getDriveLease(agentId);
102
+ const isAdmin = resolveViewer(userId)?.role === "admin";
103
+ const released = releaseDriveLease(opts.force && isAdmin ? { agentId } : { agentId, userId });
104
+ return released && current ? { released, previousHolder: driveHolder(current.holderUserId) } : { released };
105
+ }
106
+
107
+ /** Current driver of an agent (null = free, or single-user world where it is implicitly self). */
108
+ export function currentDriver(agentId: string, opts: { now?: number } = {}): DriveHolder | null {
109
+ if (!isMultiUserWorld()) return null;
110
+ const lease = getDriveLease(agentId, opts.now);
111
+ return lease ? driveHolder(lease.holderUserId) : null;
112
+ }
@@ -81,10 +81,10 @@ import type {
81
81
 
82
82
  // --- Artifacts ---
83
83
 
84
- export const VALID_ARTIFACT_KINDS = new Set<ArtifactKind>(["image", "audio", "video", "document", "archive", "other"]);
84
+ export const VALID_ARTIFACT_KINDS = new Set<ArtifactKind>(["avatar", "image", "audio", "video", "document", "archive", "other"]);
85
85
  const VALID_ARTIFACT_VISIBILITIES = new Set<ArtifactVisibility>(["private", "project", "org"]);
86
86
  export const VALID_ARTIFACT_SENSITIVITIES = new Set<ArtifactSensitivity>(["public", "normal", "sensitive", "secret"]);
87
- const VALID_ARTIFACT_LINK_ENTITY_TYPES = new Set<ArtifactLink["entityType"]>(["message", "task", "recipeRun", "recipeStep", "channelEvent"]);
87
+ const VALID_ARTIFACT_LINK_ENTITY_TYPES = new Set<ArtifactLink["entityType"]>(["message", "task", "recipeRun", "recipeStep", "channelEvent", "user"]);
88
88
  export const VALID_ARTIFACT_ROLES = new Set<NonNullable<ArtifactLink["role"]>>(["media", "patch", "report", "log", "output", "input"]);
89
89
 
90
90
  export function artifactId(): string {
@@ -339,4 +339,3 @@ export function linkAttachmentRefs(entityType: ArtifactLink["entityType"], entit
339
339
  }
340
340
 
341
341
  // --- Messages ---
342
-
@@ -0,0 +1,165 @@
1
+ // Agent drive lease — the soft-lock behind "X is driving this agent" (#388 users wave 5, #394).
2
+ //
3
+ // More than one human can now target the same agent (#392 ownership lets two operators share a
4
+ // worker). This is the per-agent, single-writer hold that keeps two drivers from corrupting one
5
+ // turn: prompt submit, interrupt, and plan-approve/answer all funnel through it so only the lease
6
+ // HOLDER (or an admin override) acts. It is a *leased* hold — refreshed on every drive action and
7
+ // expiring after `CLAIM_LEASE_MS` of silence — so a vanished driver never locks the wheel forever
8
+ // (the same vanish-safety the message claim lease gives, reused here rather than re-invented).
9
+ //
10
+ // This module owns PERSISTENCE only. The arbitration decision (single-user no-op, holder/refresh,
11
+ // admin override, blocked) lives in `src/agent-drive-arbiter.ts`, the one home both the drive
12
+ // routes and the explicit handoff endpoint call.
13
+ //
14
+ // Back-compat: the arbiter never even calls acquire while the relay is single-user, so this table
15
+ // stays empty and the solo operator can never be stranded out of their own agent by an expiry.
16
+ import { CLAIM_LEASE_MS } from "../config";
17
+ import { getDb } from "./connection.ts";
18
+
19
+ export interface DriveLeaseRecord {
20
+ agentId: string;
21
+ /** The relational `users.id` currently holding the wheel. */
22
+ holderUserId: string;
23
+ acquiredAt: number;
24
+ updatedAt: number;
25
+ expiresAt: number;
26
+ }
27
+
28
+ interface DriveLeaseRow {
29
+ agent_id: string;
30
+ holder_user_id: string;
31
+ acquired_at: number;
32
+ updated_at: number;
33
+ expires_at: number;
34
+ }
35
+
36
+ function rowToDriveLease(row: DriveLeaseRow): DriveLeaseRecord {
37
+ return {
38
+ agentId: row.agent_id,
39
+ holderUserId: row.holder_user_id,
40
+ acquiredAt: row.acquired_at,
41
+ updatedAt: row.updated_at,
42
+ expiresAt: row.expires_at,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Single DDL home for the `agent_drive_leases` table (#394). Called from applyMigrations after the
48
+ * `agents` table exists. ON DELETE CASCADE drops the lease when its agent is removed, so a hold
49
+ * never outlives the agent (no need to release on shutdown).
50
+ */
51
+ export function ensureDriveLeaseSchema(): void {
52
+ getDb().run(`
53
+ CREATE TABLE IF NOT EXISTS agent_drive_leases (
54
+ agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
55
+ holder_user_id TEXT NOT NULL,
56
+ acquired_at INTEGER NOT NULL,
57
+ updated_at INTEGER NOT NULL,
58
+ expires_at INTEGER NOT NULL
59
+ )
60
+ `);
61
+ }
62
+
63
+ /** The LIVE lease for an agent, or null. Expired rows read as null (lazy expiry — a driver who
64
+ * went silent past the lease has already let go of the wheel, the next acquire overwrites). */
65
+ export function getDriveLease(agentId: string, now: number = Date.now()): DriveLeaseRecord | null {
66
+ const row = getDb()
67
+ .query("SELECT * FROM agent_drive_leases WHERE agent_id = ? AND expires_at > ?")
68
+ .get(agentId, now) as DriveLeaseRow | null;
69
+ return row ? rowToDriveLease(row) : null;
70
+ }
71
+
72
+ /** All live drive leases (for status/observability). */
73
+ export function listDriveLeases(now: number = Date.now()): DriveLeaseRecord[] {
74
+ return (getDb()
75
+ .query("SELECT * FROM agent_drive_leases WHERE expires_at > ? ORDER BY acquired_at DESC")
76
+ .all(now) as DriveLeaseRow[]).map(rowToDriveLease);
77
+ }
78
+
79
+ /**
80
+ * Atomically take or refresh the wheel. Wins iff the wheel is free/expired OR already held by this
81
+ * same user (a refresh, preserving the original `acquired_at`). Loses to a live lease held by
82
+ * someone else — returned so the caller can say who is driving. Serialized in a transaction so two
83
+ * concurrent first-drivers can't both win (the concurrent-drive race).
84
+ */
85
+ export function acquireDriveLease(input: {
86
+ agentId: string;
87
+ userId: string;
88
+ ttlMs?: number;
89
+ now?: number;
90
+ }): { ok: true; lease: DriveLeaseRecord } | { ok: false; lease: DriveLeaseRecord } {
91
+ return getDb().transaction(() => {
92
+ const now = input.now ?? Date.now();
93
+ const existing = getDriveLease(input.agentId, now);
94
+ if (existing && existing.holderUserId !== input.userId) return { ok: false as const, lease: existing };
95
+ return { ok: true as const, lease: writeLease(input.agentId, input.userId, existing, now, input.ttlMs) };
96
+ })();
97
+ }
98
+
99
+ /**
100
+ * Force-grab the wheel regardless of the current holder — the admin override / explicit take-over.
101
+ * Always succeeds; returns the displaced holder (if any) so the caller can make the handoff
102
+ * visible. A self take-over is just a refresh (keeps `acquired_at`).
103
+ */
104
+ export function takeOverDriveLease(input: {
105
+ agentId: string;
106
+ userId: string;
107
+ ttlMs?: number;
108
+ now?: number;
109
+ }): { lease: DriveLeaseRecord; previousHolderUserId?: string } {
110
+ return getDb().transaction(() => {
111
+ const now = input.now ?? Date.now();
112
+ const existing = getDriveLease(input.agentId, now);
113
+ const previousHolderUserId = existing && existing.holderUserId !== input.userId ? existing.holderUserId : undefined;
114
+ const lease = writeLease(input.agentId, input.userId, existing, now, input.ttlMs);
115
+ return previousHolderUserId ? { lease, previousHolderUserId } : { lease };
116
+ })();
117
+ }
118
+
119
+ /** Shared upsert. A same-holder write refreshes the lease while preserving the first `acquired_at`. */
120
+ function writeLease(
121
+ agentId: string,
122
+ userId: string,
123
+ existing: DriveLeaseRecord | null,
124
+ now: number,
125
+ ttlMs?: number,
126
+ ): DriveLeaseRecord {
127
+ const acquiredAt = existing?.holderUserId === userId ? existing.acquiredAt : now;
128
+ const expiresAt = now + (ttlMs ?? CLAIM_LEASE_MS);
129
+ getDb()
130
+ .query(
131
+ `INSERT INTO agent_drive_leases (agent_id, holder_user_id, acquired_at, updated_at, expires_at)
132
+ VALUES (?, ?, ?, ?, ?)
133
+ ON CONFLICT(agent_id) DO UPDATE SET
134
+ holder_user_id = excluded.holder_user_id,
135
+ acquired_at = excluded.acquired_at,
136
+ updated_at = excluded.updated_at,
137
+ expires_at = excluded.expires_at`,
138
+ )
139
+ .run(agentId, userId, acquiredAt, now, expiresAt);
140
+ return getDriveLease(agentId, now)!;
141
+ }
142
+
143
+ /**
144
+ * Explicit release ("let go of the wheel"). Guarded by `userId` so a stale release can't drop a
145
+ * newer holder's lease — only the current holder clears their own. Pass no `userId` to force-clear
146
+ * (admin reset). Returns whether a row was removed.
147
+ */
148
+ export function releaseDriveLease(opts: { agentId: string; userId?: string }): boolean {
149
+ if (opts.userId) {
150
+ return getDb()
151
+ .query("DELETE FROM agent_drive_leases WHERE agent_id = ? AND holder_user_id = ?")
152
+ .run(opts.agentId, opts.userId).changes > 0;
153
+ }
154
+ return getDb().query("DELETE FROM agent_drive_leases WHERE agent_id = ?").run(opts.agentId).changes > 0;
155
+ }
156
+
157
+ /** Sweep expired leases (maintenance tidy-up; correctness already comes from lazy expiry). */
158
+ export function releaseExpiredDriveLeases(now: number = Date.now()): string[] {
159
+ const expired = getDb()
160
+ .query("SELECT agent_id FROM agent_drive_leases WHERE expires_at <= ?")
161
+ .all(now) as Array<{ agent_id: string }>;
162
+ if (!expired.length) return [];
163
+ getDb().query("DELETE FROM agent_drive_leases WHERE expires_at <= ?").run(now);
164
+ return expired.map((r) => r.agent_id);
165
+ }
package/src/db/index.ts CHANGED
@@ -18,6 +18,7 @@ export * from "./message-reads.ts";
18
18
  export * from "./inbox.ts";
19
19
  export * from "./users.ts";
20
20
  export * from "./agent-ownership.ts";
21
+ export * from "./drive-leases.ts";
21
22
  export * from "./activity.ts";
22
23
  export * from "./stats.ts";
23
24
  export * from "./orchestrators.ts";
@@ -17,6 +17,7 @@ import { matchAgents } from "../agent-ref";
17
17
  import { getDb } from "./connection.ts";
18
18
  import { ensureUsersSchema, seedDefaultUser } from "./users.ts";
19
19
  import { ensureAgentOwnershipSchema, seedDefaultAgentOwnership } from "./agent-ownership.ts";
20
+ import { ensureDriveLeaseSchema } from "./drive-leases.ts";
20
21
  import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
21
22
  import { normalizeReactionEmoji } from "./mappers.ts";
22
23
  import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
@@ -804,6 +805,11 @@ export function applyMigrations(): void {
804
805
  // agents are in, so they can be excluded from ownership.
805
806
  ensureAgentOwnershipSchema();
806
807
 
808
+ // Per-agent drive lease — the "X is driving this agent" soft-lock (#394). FK-references agents,
809
+ // so it ensures after the agents table exists. Empty in a single-user world (the arbiter never
810
+ // writes it), so it adds nothing to the legacy flow.
811
+ ensureDriveLeaseSchema();
812
+
807
813
  // Built-in agents — registered unconditionally so sends from these ids
808
814
  // pass the sendMessage validation. The reaper exempts these by checking
809
815
  // meta.builtin (or by id for "user").
package/src/db/users.ts CHANGED
@@ -51,6 +51,7 @@ function rowToUser(row: UserRow): User {
51
51
  status: row.status as UserStatus,
52
52
  tenantId: row.tenant_id,
53
53
  avatarArtifactId: row.avatar_artifact_id,
54
+ avatarUrl: row.avatar_artifact_id ? `/users/${encodeURIComponent(row.id)}/avatar` : null,
54
55
  createdAt: row.created_at,
55
56
  updatedAt: row.updated_at,
56
57
  };
@@ -98,6 +99,31 @@ export function createUser(input: CreateUserInput): User {
98
99
  return created;
99
100
  }
100
101
 
102
+ export function updateUserProfile(input: {
103
+ id: string;
104
+ displayName?: string;
105
+ avatarArtifactId?: string | null;
106
+ }): User {
107
+ const current = getUser(input.id);
108
+ if (!current) throw new ValidationError("user not found");
109
+ const updates: string[] = [];
110
+ const params: Array<string | number | null> = [];
111
+ if (input.displayName !== undefined) {
112
+ if (!input.displayName.trim()) throw new ValidationError("displayName required");
113
+ updates.push("display_name = ?");
114
+ params.push(input.displayName.trim());
115
+ }
116
+ if (input.avatarArtifactId !== undefined) {
117
+ updates.push("avatar_artifact_id = ?");
118
+ params.push(input.avatarArtifactId);
119
+ }
120
+ if (!updates.length) return current;
121
+ updates.push("updated_at = ?");
122
+ params.push(Date.now(), input.id);
123
+ getDb().query(`UPDATE users SET ${updates.join(", ")} WHERE id = ?`).run(...params);
124
+ return getUser(input.id)!;
125
+ }
126
+
101
127
  /**
102
128
  * Seed the admin-level default user mapped to the existing single human (#393 P0).
103
129
  * Idempotent (ON CONFLICT DO NOTHING) so it runs safely on every boot/migration — the
@@ -13,6 +13,7 @@ import {
13
13
  reapStaleAgents,
14
14
  reapStaleOrchestrators,
15
15
  releaseExpiredClaims,
16
+ releaseExpiredDriveLeases,
16
17
  releaseOrphanedTasks,
17
18
  runDbMaintenance,
18
19
  sweepArtifacts,
@@ -87,6 +88,16 @@ export const definitions: MaintenanceJobDefinition[] = [
87
88
  },
88
89
  },
89
90
  MERGE_PAUSE_MAINTENANCE_JOB,
91
+ {
92
+ id: "drive-lease-expiration",
93
+ title: "Drive lease expiration",
94
+ description: "Release expired \"X is driving this agent\" soft-locks (#394).",
95
+ intervalMs: REAP_INTERVAL_MS,
96
+ runOnStart: true,
97
+ handler() {
98
+ return { releasedDriveLeaseAgentIds: releaseExpiredDriveLeases() };
99
+ },
100
+ },
90
101
  {
91
102
  id: "task-orphan-release",
92
103
  title: "Orphaned task release",
@@ -205,7 +205,7 @@ export const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact
205
205
 
206
206
  export const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
207
207
 
208
- export const VALID_ARTIFACT_KINDS = ["image", "audio", "video", "document", "archive", "other"] as const;
208
+ export const VALID_ARTIFACT_KINDS = ["avatar", "image", "audio", "video", "document", "archive", "other"] as const;
209
209
 
210
210
  export const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
211
211
 
@@ -1,6 +1,7 @@
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, authorizeAgentRoute, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
3
+ import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, canViewAgentRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, requestUserId, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
+ import { arbitrateDrive, currentDriver, drivingMessage, releaseDrive, type DriveArbitration } from "../agent-drive-arbiter";
4
5
  import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
5
6
  import { announcePresence, viewersForAgent } from "../dashboard-presence";
6
7
  import { buildManagedSpawnParams } from "../managed-policy";
@@ -19,6 +20,12 @@ import { relayToken } from "../config";
19
20
 
20
21
  const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
21
22
 
23
+ // 409 when a second human tries to drive an agent another user is already driving (#394). The
24
+ // blocked driver learns who holds the wheel so the UI can show "X is driving" + offer "request".
25
+ function driveBlockedResponse(arb: Extract<DriveArbitration, { allowed: false }>): Response {
26
+ return json({ error: drivingMessage(arb.heldBy), code: "agent-being-driven", heldBy: arb.heldBy }, 409);
27
+ }
28
+
22
29
  function metaStringArray(meta: Record<string, unknown> | undefined, key: string): string[] {
23
30
  const value = meta?.[key];
24
31
  return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
@@ -214,6 +221,13 @@ export const postAgentAction: Handler = async (req, params) => {
214
221
  const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
215
222
  if (driveDenied) return driveDenied;
216
223
  if (!agentCanReceiveControlAction(agent, action)) return error(`agent does not support ${action}`, 400);
224
+ // #394: interrupt is a turn-drive action — only the lease holder (or an admin override) may cut
225
+ // the active turn. Lifecycle actions (restart/shutdown/compact/clearContext/resume) stay on the
226
+ // #392 authz gate only; they manage the agent rather than driving its turn.
227
+ if (action === "interrupt") {
228
+ const arb = arbitrateDrive(requestUserId(req), agent.id);
229
+ if (!arb.allowed) return driveBlockedResponse(arb);
230
+ }
217
231
 
218
232
  // Shutdown converges on the shutdownAgent service (epic #342, #347): one #221
219
233
  // parent→child gate + canonical orchestrator resolution + payload, shared with the
@@ -319,6 +333,11 @@ export const postAgentPrompt: Handler = async (req, params) => {
319
333
  // #392: only a user authorized to drive this agent may prompt it (fail-open single-user).
320
334
  const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
321
335
  if (driveDenied) return driveDenied;
336
+ // #394: submitting a prompt takes/refreshes the drive lease. A second human driving the same
337
+ // agent is blocked here (single-user is a no-op), so two operators can't double-submit into one
338
+ // turn. The holder's own re-submit just refreshes the lease — busy-state handling is unchanged.
339
+ const arb = arbitrateDrive(requestUserId(req), agent.id);
340
+ if (!arb.allowed) return driveBlockedResponse(arb);
322
341
  if (agent.status === "offline") return error("agent is offline", 422);
323
342
  const denied = authorizeRoute(req, {
324
343
  scope: "message:send",
@@ -388,6 +407,10 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
388
407
  // #392: approving/denying a permission is driving the agent (fail-open single-user).
389
408
  const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
390
409
  if (driveDenied) return driveDenied;
410
+ // #394: a plan-approve / AskUserQuestion answer drives the turn — only the lease holder (or an
411
+ // admin override) may answer, so two humans can't race competing decisions into one prompt.
412
+ const arb = arbitrateDrive(requestUserId(req), agent.id);
413
+ if (!arb.allowed) return driveBlockedResponse(arb);
391
414
  if (!agentIsControlEligible(agent)) return error("agent cannot receive permission decisions", 400);
392
415
  const pendingApproval = isRecord(agent.meta?.providerState) && isRecord(agent.meta.providerState.pendingApproval)
393
416
  ? agent.meta.providerState.pendingApproval
@@ -470,6 +493,79 @@ export const getAgentPresence: Handler = (_req, params) => {
470
493
  return json({ agentId: agent.id, viewers: viewersForAgent(agent.id) });
471
494
  };
472
495
 
496
+ // Drive lease status (#394): who currently holds the wheel on this agent. `driver: null` means the
497
+ // wheel is free, or the relay is single-user (the lone operator implicitly always holds it).
498
+ export const getAgentDrive: Handler = (req, params) => {
499
+ const agent = getAgent(params.id!);
500
+ if (!agent) return error("agent not found", 404);
501
+ if (!canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
502
+ const driver = currentDriver(agent.id);
503
+ const me = requestUserId(req);
504
+ return json({ agentId: agent.id, driver, isHolder: driver ? driver.userId === me : true });
505
+ };
506
+
507
+ // Explicit, visible handoff of the wheel (#394): `takeover` (take/refresh the lease — admins steal
508
+ // a held one, others succeed only when it is free), `release` (let go), `request` (ask the current
509
+ // holder to release). Single-user is a no-op throughout, so the solo operator is never affected.
510
+ export const postAgentDrive: Handler = async (req, params) => {
511
+ const parsed = await parseBody<unknown>(req);
512
+ if (!parsed.ok) return error(parsed.error, parsed.status);
513
+ try {
514
+ if (!isRecord(parsed.body)) return error("action required");
515
+ const action = optionalEnum(parsed.body.action, "action", ["takeover", "release", "request"] as const);
516
+ if (!action) return error("action required");
517
+ const agent = getAgent(params.id!);
518
+ if (!agent) return error("agent not found", 404);
519
+ // Driving the wheel requires the same #392 drive authorization the prompt/interrupt routes use.
520
+ const driveDenied = authorizeAgentRoute(req, agent.id, "drive");
521
+ if (driveDenied) return driveDenied;
522
+ const userId = requestUserId(req);
523
+
524
+ if (action === "release") {
525
+ const result = releaseDrive(userId, agent.id);
526
+ auditDriveEvent(req, agent.id, "release", { released: result.released });
527
+ return json({ ok: true, action, released: result.released, driver: currentDriver(agent.id) }, 200);
528
+ }
529
+
530
+ if (action === "request") {
531
+ const holder = currentDriver(agent.id);
532
+ auditDriveEvent(req, agent.id, "request", { heldBy: holder?.address });
533
+ return json({ ok: true, action, requested: true, driver: holder }, 202);
534
+ }
535
+
536
+ // takeover
537
+ const arb = arbitrateDrive(userId, agent.id);
538
+ if (!arb.allowed) return driveBlockedResponse(arb);
539
+ auditDriveEvent(req, agent.id, arb.mode === "override" ? "override" : "takeover", {
540
+ ...(arb.previousHolder ? { from: arb.previousHolder.address } : {}),
541
+ });
542
+ return json({ ok: true, action, mode: arb.mode, driver: currentDriver(agent.id) }, 200);
543
+ } catch (e) {
544
+ if (e instanceof ValidationError) return error(e.message, 400);
545
+ throw e;
546
+ }
547
+ };
548
+
549
+ function auditDriveEvent(req: Request, agentId: string, kind: "takeover" | "override" | "release" | "request", extra: Record<string, unknown>): void {
550
+ const titles: Record<typeof kind, string> = {
551
+ takeover: "Agent wheel taken",
552
+ override: "Agent wheel overridden (admin)",
553
+ release: "Agent wheel released",
554
+ request: "Agent wheel requested",
555
+ };
556
+ auditEvent({
557
+ clientId: `server-agent-${agentId}-drive-${kind}-${Date.now()}`,
558
+ kind: "state",
559
+ title: titles[kind],
560
+ body: kind,
561
+ meta: agentId,
562
+ icon: kind === "release" ? "ti-steering-wheel-off" : "ti-steering-wheel",
563
+ view: "chat",
564
+ agentId,
565
+ metadata: { drive: kind, requestedByUser: requestUserAddress(req), ...extra, ...authAuditMetadata(req) },
566
+ });
567
+ }
568
+
473
569
  export const postAgentTerminalSession: Handler = async (req, params) => {
474
570
  try {
475
571
  const agent = getAgent(params.id!);
@@ -18,7 +18,7 @@ function artifactActor(req: Request): string {
18
18
  return "server";
19
19
  }
20
20
 
21
- function inferArtifactKind(mediaType: string, filename?: string): ArtifactKind {
21
+ export function inferArtifactKind(mediaType: string, filename?: string): ArtifactKind {
22
22
  const lowerName = filename?.toLowerCase() ?? "";
23
23
  if (mediaType.startsWith("image/")) return "image";
24
24
  if (mediaType.startsWith("audio/")) return "audio";
@@ -32,10 +32,14 @@ function inferArtifactKind(mediaType: string, filename?: string): ArtifactKind {
32
32
  return "other";
33
33
  }
34
34
 
35
- function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string): string {
35
+ export function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string): string {
36
36
  if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
37
37
  if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
38
38
  if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return "image/gif";
39
+ if (
40
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
41
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
42
+ ) return "image/webp";
39
43
  if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return "application/pdf";
40
44
  if (bytes[0] === 0x50 && bytes[1] === 0x4b) return "application/zip";
41
45
  const lower = filename?.toLowerCase() ?? "";
@@ -52,7 +56,7 @@ function safeDispositionFilename(filename: string | undefined): string {
52
56
  return cleaned || "artifact";
53
57
  }
54
58
 
55
- function artifactContentDisposition(filename: string | undefined, mediaType: string): string {
59
+ export function artifactContentDisposition(filename: string | undefined, mediaType: string): string {
56
60
  const disposition = mediaType.startsWith("image/") && mediaType !== "image/svg+xml" ? "inline" : "attachment";
57
61
  return `${disposition}; filename="${safeDispositionFilename(filename)}"`;
58
62
  }
@@ -1,8 +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, getAgentPresence, postAgentAction, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
5
- import { getUsers } from "./users";
4
+ import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
5
+ import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
6
6
  import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
7
7
  import { deleteAutomationById, getAutomationById, getAutomationRuns, getAutomations, patchAutomation, postAutomation, postAutomationRun } from "./automations";
8
8
  import { deleteCommandById, deleteProviderModelRoute, getProviderConfigRoute, getProviderConfigsRoute, getProvidersRoute, patchProviderModelRoute, postProviderConfigTestRoute, putProviderConfigRoute, putProviderModelRoute } from "./provider-config";
@@ -87,6 +87,15 @@ const routes: Route[] = [
87
87
  route("DELETE", "/api/memories/:id", deleteMemoryRoute),
88
88
 
89
89
  route("GET", "/api/users", getUsers),
90
+ route("GET", "/api/users/me", getMe),
91
+ route("PATCH", "/api/users/me", patchMe),
92
+ route("POST", "/api/users/me/avatar", postMyAvatar),
93
+ route("DELETE", "/api/users/me/avatar", deleteMyAvatar),
94
+ route("GET", "/api/users/:id/avatar", getUserAvatar),
95
+ route("HEAD", "/api/users/:id/avatar", headUserAvatar),
96
+ route("PATCH", "/api/users/:id", patchUserById),
97
+ route("POST", "/api/users/:id/avatar", postUserAvatarById),
98
+ route("DELETE", "/api/users/:id/avatar", deleteUserAvatarById),
90
99
 
91
100
  route("POST", "/api/agents", postAgent),
92
101
  route("POST", "/api/agents/spawn", postAgentSpawn),
@@ -116,6 +125,8 @@ const routes: Route[] = [
116
125
  route("POST", "/api/agents/:id/permission-decision", postAgentPermissionDecision),
117
126
  route("GET", "/api/agents/:id/presence", getAgentPresence),
118
127
  route("POST", "/api/agents/:id/presence", postAgentPresence),
128
+ route("GET", "/api/agents/:id/drive", getAgentDrive),
129
+ route("POST", "/api/agents/:id/drive", postAgentDrive),
119
130
  route("POST", "/api/agents/:id/terminal-session", postAgentTerminalSession),
120
131
  route("DELETE", "/api/agents/:id/terminal-session/:session", deleteAgentTerminalSession),
121
132
  route("DELETE", "/api/agents/:id", deleteAgentById),