@swarmclawai/swarmclaw 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +81 -22
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +36 -7
  5. package/src/app/api/agents/route.ts +12 -1
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  9. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  10. package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
  11. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  12. package/src/app/api/chats/[id]/route.ts +18 -0
  13. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  14. package/src/app/api/chats/route.ts +16 -0
  15. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  16. package/src/app/api/connectors/doctor/route.ts +13 -0
  17. package/src/app/api/files/open/route.ts +16 -14
  18. package/src/app/api/memory/maintenance/route.ts +11 -1
  19. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  20. package/src/app/api/openclaw/skills/route.ts +11 -3
  21. package/src/app/api/plugins/dependencies/route.ts +24 -0
  22. package/src/app/api/plugins/install/route.ts +15 -92
  23. package/src/app/api/plugins/route.ts +3 -26
  24. package/src/app/api/plugins/settings/route.ts +17 -12
  25. package/src/app/api/plugins/ui/route.ts +1 -0
  26. package/src/app/api/settings/route.ts +49 -7
  27. package/src/app/api/tasks/[id]/route.ts +15 -6
  28. package/src/app/api/tasks/bulk/route.ts +2 -2
  29. package/src/app/api/tasks/route.ts +9 -4
  30. package/src/app/api/webhooks/[id]/route.ts +8 -1
  31. package/src/app/page.tsx +9 -2
  32. package/src/cli/index.js +4 -0
  33. package/src/cli/index.ts +3 -10
  34. package/src/components/agents/agent-card.tsx +15 -12
  35. package/src/components/agents/agent-chat-list.tsx +101 -1
  36. package/src/components/agents/agent-list.tsx +46 -9
  37. package/src/components/agents/agent-sheet.tsx +207 -16
  38. package/src/components/agents/inspector-panel.tsx +108 -48
  39. package/src/components/auth/access-key-gate.tsx +36 -97
  40. package/src/components/chat/chat-area.tsx +29 -13
  41. package/src/components/chat/chat-card.tsx +4 -20
  42. package/src/components/chat/chat-header.tsx +255 -353
  43. package/src/components/chat/chat-list.tsx +7 -9
  44. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  45. package/src/components/chat/message-list.tsx +3 -1
  46. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  47. package/src/components/connectors/connector-list.tsx +265 -127
  48. package/src/components/connectors/connector-sheet.tsx +217 -0
  49. package/src/components/home/home-view.tsx +128 -4
  50. package/src/components/layout/app-layout.tsx +383 -194
  51. package/src/components/layout/mobile-header.tsx +26 -8
  52. package/src/components/plugins/plugin-list.tsx +15 -3
  53. package/src/components/plugins/plugin-sheet.tsx +118 -9
  54. package/src/components/projects/project-detail.tsx +183 -0
  55. package/src/components/shared/agent-picker-list.tsx +2 -2
  56. package/src/components/shared/command-palette.tsx +111 -24
  57. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  58. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  59. package/src/components/shared/settings/section-heartbeat.tsx +77 -0
  60. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  61. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  62. package/src/components/shared/settings/section-secrets.tsx +6 -6
  63. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  64. package/src/components/shared/settings/section-voice.tsx +5 -1
  65. package/src/components/shared/settings/section-web-search.tsx +10 -2
  66. package/src/components/shared/settings/settings-page.tsx +245 -46
  67. package/src/components/tasks/approvals-panel.tsx +205 -18
  68. package/src/components/tasks/task-board.tsx +242 -46
  69. package/src/components/usage/metrics-dashboard.tsx +74 -1
  70. package/src/components/wallets/wallet-panel.tsx +17 -5
  71. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  72. package/src/lib/auth.ts +17 -0
  73. package/src/lib/chat-streaming-state.test.ts +108 -0
  74. package/src/lib/chat-streaming-state.ts +108 -0
  75. package/src/lib/openclaw-agent-id.test.ts +14 -0
  76. package/src/lib/openclaw-agent-id.ts +31 -0
  77. package/src/lib/server/agent-assignment.test.ts +112 -0
  78. package/src/lib/server/agent-assignment.ts +169 -0
  79. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  80. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  81. package/src/lib/server/approvals.ts +483 -75
  82. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  83. package/src/lib/server/browser-state.test.ts +118 -0
  84. package/src/lib/server/browser-state.ts +123 -0
  85. package/src/lib/server/build-llm.test.ts +36 -0
  86. package/src/lib/server/build-llm.ts +11 -4
  87. package/src/lib/server/builtin-plugins.ts +34 -0
  88. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  89. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  90. package/src/lib/server/chat-execution.ts +250 -61
  91. package/src/lib/server/chatroom-health.test.ts +26 -0
  92. package/src/lib/server/chatroom-health.ts +2 -3
  93. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  94. package/src/lib/server/chatroom-helpers.ts +45 -5
  95. package/src/lib/server/connectors/discord.ts +175 -11
  96. package/src/lib/server/connectors/doctor.test.ts +80 -0
  97. package/src/lib/server/connectors/doctor.ts +116 -0
  98. package/src/lib/server/connectors/manager.ts +946 -110
  99. package/src/lib/server/connectors/policy.test.ts +222 -0
  100. package/src/lib/server/connectors/policy.ts +452 -0
  101. package/src/lib/server/connectors/slack.ts +188 -9
  102. package/src/lib/server/connectors/telegram.ts +65 -15
  103. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  104. package/src/lib/server/connectors/thread-context.ts +72 -0
  105. package/src/lib/server/connectors/types.ts +41 -11
  106. package/src/lib/server/daemon-state.ts +59 -1
  107. package/src/lib/server/data-dir.ts +13 -0
  108. package/src/lib/server/delegation-jobs.test.ts +140 -0
  109. package/src/lib/server/delegation-jobs.ts +248 -0
  110. package/src/lib/server/document-utils.test.ts +47 -0
  111. package/src/lib/server/document-utils.ts +397 -0
  112. package/src/lib/server/heartbeat-service.ts +13 -39
  113. package/src/lib/server/heartbeat-source.test.ts +22 -0
  114. package/src/lib/server/heartbeat-source.ts +7 -0
  115. package/src/lib/server/identity-continuity.test.ts +77 -0
  116. package/src/lib/server/identity-continuity.ts +127 -0
  117. package/src/lib/server/mailbox-utils.ts +347 -0
  118. package/src/lib/server/main-agent-loop.ts +27 -967
  119. package/src/lib/server/memory-db.ts +4 -6
  120. package/src/lib/server/memory-tiers.ts +40 -0
  121. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  122. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  123. package/src/lib/server/openclaw-exec-config.ts +5 -6
  124. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  125. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  126. package/src/lib/server/openclaw-sync.ts +3 -2
  127. package/src/lib/server/orchestrator-lg.ts +17 -6
  128. package/src/lib/server/orchestrator.ts +2 -2
  129. package/src/lib/server/playwright-proxy.mjs +27 -3
  130. package/src/lib/server/plugins.test.ts +207 -0
  131. package/src/lib/server/plugins.ts +822 -69
  132. package/src/lib/server/provider-health.ts +33 -3
  133. package/src/lib/server/queue.ts +3 -20
  134. package/src/lib/server/scheduler.ts +2 -0
  135. package/src/lib/server/session-archive-memory.test.ts +85 -0
  136. package/src/lib/server/session-archive-memory.ts +230 -0
  137. package/src/lib/server/session-mailbox.ts +8 -18
  138. package/src/lib/server/session-reset-policy.test.ts +99 -0
  139. package/src/lib/server/session-reset-policy.ts +311 -0
  140. package/src/lib/server/session-run-manager.ts +33 -80
  141. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  142. package/src/lib/server/session-tools/calendar.ts +2 -12
  143. package/src/lib/server/session-tools/connector.ts +109 -8
  144. package/src/lib/server/session-tools/context.ts +14 -2
  145. package/src/lib/server/session-tools/crawl.ts +447 -0
  146. package/src/lib/server/session-tools/crud.ts +70 -32
  147. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  148. package/src/lib/server/session-tools/delegate.ts +406 -20
  149. package/src/lib/server/session-tools/discovery.ts +22 -4
  150. package/src/lib/server/session-tools/document.ts +283 -0
  151. package/src/lib/server/session-tools/email.ts +1 -3
  152. package/src/lib/server/session-tools/extract.ts +137 -0
  153. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  154. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  155. package/src/lib/server/session-tools/file.ts +237 -24
  156. package/src/lib/server/session-tools/human-loop.ts +227 -0
  157. package/src/lib/server/session-tools/image-gen.ts +1 -3
  158. package/src/lib/server/session-tools/index.ts +56 -1
  159. package/src/lib/server/session-tools/mailbox.ts +276 -0
  160. package/src/lib/server/session-tools/memory.ts +35 -3
  161. package/src/lib/server/session-tools/monitor.ts +150 -7
  162. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  163. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  164. package/src/lib/server/session-tools/platform.ts +142 -4
  165. package/src/lib/server/session-tools/plugin-creator.ts +86 -23
  166. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  167. package/src/lib/server/session-tools/replicate.ts +1 -3
  168. package/src/lib/server/session-tools/schedule.ts +20 -10
  169. package/src/lib/server/session-tools/session-info.ts +36 -3
  170. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  171. package/src/lib/server/session-tools/subagent.ts +193 -27
  172. package/src/lib/server/session-tools/table.ts +587 -0
  173. package/src/lib/server/session-tools/wallet.ts +13 -10
  174. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  175. package/src/lib/server/session-tools/web.ts +896 -100
  176. package/src/lib/server/storage.ts +226 -7
  177. package/src/lib/server/stream-agent-chat.ts +46 -21
  178. package/src/lib/server/structured-extract.test.ts +72 -0
  179. package/src/lib/server/structured-extract.ts +373 -0
  180. package/src/lib/server/task-mention.test.ts +16 -2
  181. package/src/lib/server/task-mention.ts +61 -10
  182. package/src/lib/server/tool-aliases.ts +44 -7
  183. package/src/lib/server/tool-capability-policy.ts +6 -0
  184. package/src/lib/server/tool-retry.ts +2 -0
  185. package/src/lib/server/watch-jobs.test.ts +173 -0
  186. package/src/lib/server/watch-jobs.ts +532 -0
  187. package/src/lib/server/ws-hub.ts +5 -3
  188. package/src/lib/validation/schemas.test.ts +26 -0
  189. package/src/lib/validation/schemas.ts +7 -0
  190. package/src/lib/ws-client.ts +14 -12
  191. package/src/proxy.ts +5 -5
  192. package/src/stores/use-app-store.ts +0 -6
  193. package/src/stores/use-chat-store.ts +31 -2
  194. package/src/types/index.ts +287 -44
  195. package/src/components/chat/new-chat-sheet.tsx +0 -253
  196. package/src/lib/server/main-session.ts +0 -17
  197. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadSettings, saveSettings } from '@/lib/server/storage'
2
+ import { loadPublicSettings, loadSettings, saveSettings } from '@/lib/server/storage'
3
3
  import { DEFAULT_DELEGATION_MAX_DEPTH } from '@/lib/runtime-loop'
4
4
  export const dynamic = 'force-dynamic'
5
5
 
@@ -20,6 +20,9 @@ const TASK_QG_MIN_RESULT_MIN = 10
20
20
  const TASK_QG_MIN_RESULT_MAX = 2000
21
21
  const TASK_QG_MIN_EVIDENCE_MIN = 0
22
22
  const TASK_QG_MIN_EVIDENCE_MAX = 8
23
+ const SESSION_RESET_TIMEOUT_MIN = 0
24
+ const SESSION_RESET_TIMEOUT_MAX = 365 * 24 * 60 * 60
25
+ const SECRET_SETTING_KEYS = ['elevenLabsApiKey', 'tavilyApiKey', 'braveApiKey'] as const
23
26
 
24
27
  function parseIntSetting(value: unknown, fallback: number, min: number, max: number): number {
25
28
  const parsed = typeof value === 'number'
@@ -42,13 +45,25 @@ function parseBoolSetting(value: unknown, fallback: boolean): boolean {
42
45
  }
43
46
 
44
47
  export async function GET(_req: Request) {
45
- return NextResponse.json(loadSettings())
48
+ return NextResponse.json(loadPublicSettings())
46
49
  }
47
50
 
48
51
  export async function PUT(req: Request) {
49
- const body = await req.json()
52
+ const body = await req.json() as Record<string, unknown>
53
+ const sanitizedBody: Record<string, unknown> = { ...body }
54
+
55
+ delete sanitizedBody.__encryptedAppSettings
56
+
57
+ for (const key of SECRET_SETTING_KEYS) {
58
+ const configuredKey = `${key}Configured`
59
+ if (sanitizedBody[key] === null && sanitizedBody[configuredKey] === true) {
60
+ delete sanitizedBody[key]
61
+ }
62
+ delete sanitizedBody[configuredKey]
63
+ }
64
+
50
65
  const settings = loadSettings()
51
- Object.assign(settings, body)
66
+ Object.assign(settings, sanitizedBody)
52
67
 
53
68
  const nextDepth = parseIntSetting(
54
69
  settings.memoryReferenceDepth ?? settings.memoryMaxDepth,
@@ -116,16 +131,43 @@ export async function PUT(req: Request) {
116
131
  settings.taskQualityGateRequireArtifact = parseBoolSetting(settings.taskQualityGateRequireArtifact, false)
117
132
  settings.taskQualityGateRequireReport = parseBoolSetting(settings.taskQualityGateRequireReport, false)
118
133
  settings.integrityMonitorEnabled = parseBoolSetting(settings.integrityMonitorEnabled, true)
134
+ settings.sessionResetMode = settings.sessionResetMode === 'daily' ? 'daily' : settings.sessionResetMode === 'idle' ? 'idle' : null
135
+ settings.sessionIdleTimeoutSec = parseIntSetting(
136
+ settings.sessionIdleTimeoutSec,
137
+ 12 * 60 * 60,
138
+ SESSION_RESET_TIMEOUT_MIN,
139
+ SESSION_RESET_TIMEOUT_MAX,
140
+ )
141
+ settings.sessionMaxAgeSec = parseIntSetting(
142
+ settings.sessionMaxAgeSec,
143
+ 7 * 24 * 60 * 60,
144
+ SESSION_RESET_TIMEOUT_MIN,
145
+ SESSION_RESET_TIMEOUT_MAX,
146
+ )
147
+ if (typeof settings.sessionDailyResetAt === 'string') settings.sessionDailyResetAt = settings.sessionDailyResetAt.trim() || null
148
+ if (typeof settings.sessionResetTimezone === 'string') settings.sessionResetTimezone = settings.sessionResetTimezone.trim() || null
119
149
 
120
150
  saveSettings(settings)
121
151
 
122
152
  // Restart heartbeat service when heartbeat-related settings change
123
- const heartbeatKeys = ['heartbeatIntervalSec', 'heartbeatInterval', 'heartbeatPrompt', 'heartbeatEnabled', 'heartbeatActiveStart', 'heartbeatActiveEnd']
124
- if (heartbeatKeys.some((k) => k in body)) {
153
+ const heartbeatKeys = [
154
+ 'heartbeatIntervalSec',
155
+ 'heartbeatInterval',
156
+ 'heartbeatPrompt',
157
+ 'heartbeatEnabled',
158
+ 'heartbeatActiveStart',
159
+ 'heartbeatActiveEnd',
160
+ 'sessionResetMode',
161
+ 'sessionIdleTimeoutSec',
162
+ 'sessionMaxAgeSec',
163
+ 'sessionDailyResetAt',
164
+ 'sessionResetTimezone',
165
+ ]
166
+ if (heartbeatKeys.some((k) => k in sanitizedBody)) {
125
167
  import('@/lib/server/heartbeat-service').then(({ restartHeartbeatService }) => {
126
168
  restartHeartbeatService()
127
169
  }).catch(() => { /* heartbeat service may not be initialized yet */ })
128
170
  }
129
171
 
130
- return NextResponse.json(settings)
172
+ return NextResponse.json(loadPublicSettings())
131
173
  }
@@ -1,6 +1,6 @@
1
1
  import { genId } from '@/lib/id'
2
2
  import { NextResponse } from 'next/server'
3
- import { loadTasks, saveTasks, logActivity, loadSettings } from '@/lib/server/storage'
3
+ import { loadAgents, loadSettings, loadTasks, logActivity, upsertStoredItems, upsertTask } from '@/lib/server/storage'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
  import { disableSessionHeartbeat, enqueueTask, recoverStalledRunningTasks, validateCompletedTasksQueue } from '@/lib/server/queue'
6
6
  import { ensureTaskCompletionReport } from '@/lib/server/task-reports'
@@ -13,6 +13,7 @@ import { requestHeartbeatNow } from '@/lib/server/heartbeat-wake'
13
13
  import { validateDag, cascadeUnblock } from '@/lib/server/dag-validation'
14
14
  import { getPluginManager } from '@/lib/server/plugins'
15
15
  import { normalizeTaskQualityGate } from '@/lib/server/task-quality-gate'
16
+ import '@/lib/server/builtin-plugins'
16
17
 
17
18
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
18
19
  // Keep completed queue integrity even if daemon is not running.
@@ -90,7 +91,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
90
91
  }
91
92
  }
92
93
 
93
- saveTasks(tasks)
94
+ upsertTask(id, tasks[id])
94
95
  logActivity({ entityType: 'task', entityId: id, action: 'updated', actor: 'user', summary: `Task updated: "${tasks[id].title}" (${prevStatus} → ${tasks[id].status})` })
95
96
  if (prevStatus !== tasks[id].status) {
96
97
  pushMainLoopEventToMainSessions({
@@ -111,7 +112,12 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
111
112
  })
112
113
 
113
114
  if (tasks[id].status === 'completed') {
114
- getPluginManager().runHook('onTaskComplete', { taskId: id, result: tasks[id].result })
115
+ const agentPlugins = tasks[id].agentId ? (loadAgents()[tasks[id].agentId]?.plugins || []) : []
116
+ getPluginManager().runHook(
117
+ 'onTaskComplete',
118
+ { taskId: id, result: tasks[id].result },
119
+ { enabledIds: agentPlugins },
120
+ )
115
121
  }
116
122
 
117
123
  // Enqueue system event + heartbeat wake
@@ -131,7 +137,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
131
137
  // Revert status change and reject
132
138
  tasks[id].status = prevStatus
133
139
  tasks[id].updatedAt = Date.now()
134
- saveTasks(tasks)
140
+ upsertTask(id, tasks[id])
135
141
  return NextResponse.json(
136
142
  { error: 'Cannot queue: blocked by incomplete tasks', blockedBy: incompleteBlocker },
137
143
  { status: 409 },
@@ -143,7 +149,10 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
143
149
  if (tasks[id].status === 'completed') {
144
150
  const unblockedIds = cascadeUnblock(tasks, id)
145
151
  if (unblockedIds.length > 0) {
146
- saveTasks(tasks)
152
+ upsertStoredItems('tasks', [
153
+ [id, tasks[id]],
154
+ ...unblockedIds.map((uid) => [uid, tasks[uid]] as [string, any]),
155
+ ])
147
156
  for (const uid of unblockedIds) {
148
157
  enqueueTask(uid)
149
158
  }
@@ -168,7 +177,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
168
177
  tasks[id].status = 'archived'
169
178
  tasks[id].archivedAt = Date.now()
170
179
  tasks[id].updatedAt = Date.now()
171
- saveTasks(tasks)
180
+ upsertTask(id, tasks[id])
172
181
  logActivity({ entityType: 'task', entityId: id, action: 'deleted', actor: 'user', summary: `Task archived: "${tasks[id].title}"` })
173
182
  pushMainLoopEventToMainSessions({
174
183
  type: 'task_archived',
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadTasks, saveTasks, logActivity } from '@/lib/server/storage'
2
+ import { loadTasks, logActivity, upsertStoredItems } from '@/lib/server/storage'
3
3
  import { enqueueTask, disableSessionHeartbeat } from '@/lib/server/queue'
4
4
  import { pushMainLoopEventToMainSessions } from '@/lib/server/main-agent-loop'
5
5
  import { notify } from '@/lib/server/ws-hub'
@@ -82,7 +82,7 @@ export async function POST(req: Request) {
82
82
  }
83
83
  }
84
84
 
85
- saveTasks(tasks)
85
+ upsertStoredItems('tasks', results.map((id) => [id, tasks[id]] as [string, any]))
86
86
 
87
87
  if (updated > 0) {
88
88
  const action = body.status
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
- import { loadTasks, saveTasks, loadSettings, loadAgents, logActivity } from '@/lib/server/storage'
3
+ import { deleteTask, loadAgents, loadSettings, loadTasks, logActivity, upsertTask } from '@/lib/server/storage'
4
4
  import { TaskCreateSchema, formatZodError } from '@/lib/validation/schemas'
5
5
  import { z } from 'zod'
6
6
  import { enqueueTask, recoverStalledRunningTasks, validateCompletedTasksQueue } from '@/lib/server/queue'
@@ -13,6 +13,7 @@ import { resolveTaskAgentFromDescription } from '@/lib/server/task-mention'
13
13
  import { validateDag } from '@/lib/server/dag-validation'
14
14
  import { getPluginManager } from '@/lib/server/plugins'
15
15
  import { normalizeTaskQualityGate } from '@/lib/server/task-quality-gate'
16
+ import '@/lib/server/builtin-plugins'
16
17
 
17
18
  export async function GET(req: Request) {
18
19
  // Keep completed queue integrity even if daemon is not running.
@@ -49,7 +50,6 @@ export async function DELETE(req: Request) {
49
50
  (filter === 'done' && (task.status === 'completed' || task.status === 'failed')) ||
50
51
  (!filter && task.status === 'archived')
51
52
 
52
- const { deleteTask } = await import('@/lib/server/storage')
53
53
  for (const [id, task] of Object.entries(tasks)) {
54
54
  if (shouldRemove(task as { status: string; sourceType?: string })) {
55
55
  deleteTask(id)
@@ -169,7 +169,12 @@ export async function POST(req: Request) {
169
169
  if (validation.ok) {
170
170
  tasks[id].completedAt = Date.now()
171
171
  tasks[id].error = null
172
- getPluginManager().runHook('onTaskComplete', { taskId: id, result: tasks[id].result })
172
+ const agentPlugins = resolvedAgentId ? (loadAgents()[resolvedAgentId]?.plugins || []) : []
173
+ getPluginManager().runHook(
174
+ 'onTaskComplete',
175
+ { taskId: id, result: tasks[id].result },
176
+ { enabledIds: agentPlugins },
177
+ )
173
178
  } else {
174
179
  tasks[id].status = 'failed'
175
180
  tasks[id].completedAt = null
@@ -177,7 +182,7 @@ export async function POST(req: Request) {
177
182
  }
178
183
  }
179
184
 
180
- saveTasks(tasks)
185
+ upsertTask(id, tasks[id])
181
186
  logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Task created: "${tasks[id].title}"` })
182
187
  pushMainLoopEventToMainSessions({
183
188
  type: 'task_created',
@@ -8,6 +8,7 @@ import { enqueueSystemEvent } from '@/lib/server/system-events'
8
8
  import { requestHeartbeatNow } from '@/lib/server/heartbeat-wake'
9
9
  import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
10
10
  import type { WebhookRetryEntry } from '@/types'
11
+ import { triggerWebhookWatchJobs } from '@/lib/server/watch-jobs'
11
12
 
12
13
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
14
  const ops: CollectionOps<any> = { load: loadWebhooks, save: saveWebhooks }
@@ -126,6 +127,12 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
126
127
  })
127
128
  }
128
129
 
130
+ triggerWebhookWatchJobs({
131
+ webhookId: id,
132
+ event: incomingEvent,
133
+ payloadPreview: rawBody,
134
+ })
135
+
129
136
  const agents = loadAgents()
130
137
  const agent = webhook.agentId ? agents[webhook.agentId] : null
131
138
  if (!agent) {
@@ -165,7 +172,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
165
172
  messages: [],
166
173
  createdAt: now,
167
174
  lastActiveAt: now,
168
- sessionType: 'orchestrated',
175
+ sessionType: 'human',
169
176
  agentId: agent.id,
170
177
  parentSessionId: null,
171
178
  tools: agent.tools || [],
package/src/app/page.tsx CHANGED
@@ -158,8 +158,15 @@ export default function Home() {
158
158
  const checkAuth = useCallback(async () => {
159
159
  const key = getStoredAccessKey()
160
160
  if (!key) {
161
- setAuthChecked(true)
162
- setAuthenticated(false)
161
+ try {
162
+ const res = await fetchWithTimeout('/api/auth', {}, AUTH_CHECK_TIMEOUT_MS)
163
+ const data = await res.json().catch(() => ({}))
164
+ setAuthenticated(data?.authenticated === true)
165
+ } catch {
166
+ setAuthenticated(false)
167
+ } finally {
168
+ setAuthChecked(true)
169
+ }
163
170
  return
164
171
  }
165
172
 
package/src/cli/index.js CHANGED
@@ -120,6 +120,9 @@ const COMMAND_GROUPS = [
120
120
  defaultBody: { action: 'repair' },
121
121
  }),
122
122
  cmd('health', 'GET', '/connectors/:id/health', 'Get connector health status'),
123
+ cmd('doctor', 'GET', '/connectors/:id/doctor', 'Get connector doctor diagnostics'),
124
+ cmd('doctor-preview', 'POST', '/connectors/:id/doctor', 'Preview connector doctor diagnostics with temporary overrides', { expectsJsonBody: true }),
125
+ cmd('doctor-draft', 'POST', '/connectors/doctor', 'Preview connector doctor diagnostics before saving a connector', { expectsJsonBody: true }),
123
126
  ],
124
127
  },
125
128
  {
@@ -344,6 +347,7 @@ const COMMAND_GROUPS = [
344
347
  cmd('delete', 'DELETE', '/plugins', 'Delete an external plugin (use --query filename=plugin.js)'),
345
348
  cmd('update', 'PATCH', '/plugins', 'Update a plugin (use --query id=plugin.js or --query all=true)'),
346
349
  cmd('install', 'POST', '/plugins/install', 'Install plugin from URL', { expectsJsonBody: true }),
350
+ cmd('install-deps', 'POST', '/plugins/dependencies', 'Install or refresh plugin workspace dependencies', { expectsJsonBody: true }),
347
351
  cmd('marketplace', 'GET', '/plugins/marketplace', 'Get marketplace catalog'),
348
352
  cmd('settings-get', 'GET', '/plugins/settings', 'Get plugin settings (use --query pluginId=plugin_name)'),
349
353
  cmd('settings-set', 'PUT', '/plugins/settings', 'Set plugin settings (use --query pluginId=plugin_name and --data JSON)', { expectsJsonBody: true }),
package/src/cli/index.ts CHANGED
@@ -17,7 +17,6 @@ interface CliContext {
17
17
 
18
18
  interface SetupAuthStatus {
19
19
  firstTime?: boolean
20
- key?: string
21
20
  }
22
21
 
23
22
  interface SetupProviderCheckResponse {
@@ -208,19 +207,13 @@ async function resolveSetupAccessKey(ctx: CliContext): Promise<{
208
207
  }
209
208
 
210
209
  const status = await apiRequestWithAccessKey<SetupAuthStatus>(ctx, 'GET', '/auth', undefined)
211
- const discoveredKey = typeof status?.key === 'string' ? status.key.trim() : ''
212
210
  const firstTime = status?.firstTime === true
213
211
 
214
- if (!firstTime || !discoveredKey) {
215
- throw new Error('No access key provided. Pass --key (or SWARMCLAW_ACCESS_KEY), or run setup on a fresh first-time instance.')
212
+ if (firstTime) {
213
+ throw new Error('No access key provided. Read the generated key from the launch terminal or .env.local, then pass --key (or SWARMCLAW_ACCESS_KEY).')
216
214
  }
217
215
 
218
- await apiRequestWithAccessKey(ctx, 'POST', '/auth', discoveredKey, { key: discoveredKey })
219
- return {
220
- accessKey: discoveredKey,
221
- firstTime: true,
222
- autoDiscovered: true,
223
- }
216
+ throw new Error('No access key provided. Pass --key (or SWARMCLAW_ACCESS_KEY).')
224
217
  }
225
218
 
226
219
  function printResult(value: unknown, rawOutput: boolean): void {
@@ -37,6 +37,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
37
37
  const setCurrentSession = useAppStore((s) => s.setCurrentSession)
38
38
  const setActiveView = useAppStore((s) => s.setActiveView)
39
39
  const setMessages = useChatStore((s) => s.setMessages)
40
+ const sendMessage = useChatStore((s) => s.sendMessage)
40
41
  const togglePinAgent = useAppStore((s) => s.togglePinAgent)
41
42
  const [running, setRunning] = useState(false)
42
43
  const [dialogOpen, setDialogOpen] = useState(false)
@@ -61,6 +62,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
61
62
  budget: typeof agent.dailyBudget === 'number' && agent.dailyBudget > 0 ? agent.dailyBudget : null,
62
63
  },
63
64
  ].filter((entry) => entry.budget !== null)
65
+ const canDelegateToAgents = agent.platformAssignScope === 'all'
64
66
  useWs(`heartbeat:agent:${agent.id}`, () => {
65
67
  setHeartbeatPulse(true)
66
68
  setTimeout(() => setHeartbeatPulse(false), 1500)
@@ -78,19 +80,20 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
78
80
  }
79
81
 
80
82
  const handleConfirmRun = async () => {
81
- if (!taskInput.trim()) return
83
+ const task = taskInput.trim()
84
+ if (!task) return
82
85
  setDialogOpen(false)
83
86
  setRunning(true)
84
87
  try {
85
- const result = await api<{ ok: boolean; sessionId: string }>('POST', '/orchestrator/run', { agentId: agent.id, task: taskInput })
86
- if (result.sessionId) {
87
- await loadSessions()
88
- setMessages([])
89
- setCurrentSession(result.sessionId)
90
- setActiveView('agents')
91
- }
88
+ const session = await api<{ id: string }>('POST', `/agents/${agent.id}/thread`, { user: 'default' })
89
+ if (!session?.id) throw new Error('Agent thread not available')
90
+ await loadSessions()
91
+ setMessages([])
92
+ setCurrentSession(session.id)
93
+ setActiveView('agents')
94
+ await sendMessage(task)
92
95
  } catch (err) {
93
- console.error('Orchestrator run failed:', err)
96
+ console.error('Agent task run failed:', err)
94
97
  }
95
98
  setRunning(false)
96
99
  }
@@ -199,7 +202,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
199
202
  default
200
203
  </span>
201
204
  )}
202
- {agent.isOrchestrator && (
205
+ {canDelegateToAgents && (
203
206
  <button
204
207
  onClick={handleRunClick}
205
208
  disabled={running}
@@ -210,7 +213,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
210
213
  {running ? '...' : 'Run'}
211
214
  </button>
212
215
  )}
213
- {agent.isOrchestrator && (
216
+ {canDelegateToAgents && (
214
217
  <span className="shrink-0 text-[10px] font-600 uppercase tracking-wider text-amber-400/80 bg-amber-400/[0.08] px-2 py-0.5 rounded-[6px] flex items-center gap-1">
215
218
  <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"><path d="M16 3h5v5"/><path d="M21 3l-7 7"/><path d="M8 21H3v-5"/><path d="M3 21l7-7"/></svg>
216
219
  delegates
@@ -299,7 +302,7 @@ export function AgentCard({ agent, isDefault, isRunning, isOnline, isSelected, o
299
302
  <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
300
303
  <DialogContent className="sm:max-w-[420px]">
301
304
  <DialogHeader>
302
- <DialogTitle>Run Orchestrator</DialogTitle>
305
+ <DialogTitle>Run Agent</DialogTitle>
303
306
  </DialogHeader>
304
307
  <div className="py-3">
305
308
  <label className="block text-[12px] font-600 text-text-3 mb-2">Task for {agent.name}</label>
@@ -135,6 +135,17 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
135
135
  })
136
136
  }, [sortedAgents, chatFilter, sessions, runningAgentIds, streamingSessionId, chatroomActiveAgentIds])
137
137
 
138
+ const defaultAgent = useMemo(() => {
139
+ const id = appSettings.defaultAgentId
140
+ return id ? agents[id] || null : null
141
+ }, [appSettings.defaultAgentId, agents])
142
+
143
+ const defaultAgentVisible = !!defaultAgent && filteredAgents.some((agent) => agent.id === defaultAgent.id)
144
+ const listAgents = useMemo(
145
+ () => (defaultAgentVisible ? filteredAgents.filter((agent) => agent.id !== defaultAgent?.id) : filteredAgents),
146
+ [defaultAgent?.id, defaultAgentVisible, filteredAgents],
147
+ )
148
+
138
149
  // FLIP: animate row position changes
139
150
  useLayoutEffect(() => {
140
151
  const prevTop = previousTopRef.current
@@ -258,7 +269,91 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
258
269
  </div>
259
270
  )}
260
271
  <div className="flex flex-col gap-0.5 px-2 pb-4">
261
- {filteredAgents.map((agent) => {
272
+ {defaultAgentVisible && defaultAgent && (() => {
273
+ const threadSession = defaultAgent.threadSessionId ? sessions[defaultAgent.threadSessionId] as Session | undefined : undefined
274
+ const lastMsg = threadSession?.messages?.at(-1)
275
+ const heartbeatOn = defaultAgent.heartbeatEnabled === true && (defaultAgent.plugins?.length ?? 0) > 0
276
+ const recentlyActive = (threadSession?.lastActiveAt ?? 0) > Date.now() - 30 * 60 * 1000
277
+ const isWorking = runningAgentIds.has(defaultAgent.id) || (threadSession?.active ?? false) || heartbeatOn || recentlyActive || chatroomActiveAgentIds.has(defaultAgent.id)
278
+ const isTyping = streamingSessionId === defaultAgent.threadSessionId
279
+ const preview = lastMsg?.text?.slice(0, 100)?.replace(/\n/g, ' ') || 'Your primary shortcut chat.'
280
+ const isActive = currentAgentId === defaultAgent.id
281
+
282
+ return (
283
+ <div className="mb-2 px-2">
284
+ <div className="px-2 pb-1 text-[10px] font-700 uppercase tracking-[0.12em] text-accent-bright/65">
285
+ Default Agent
286
+ </div>
287
+ <div
288
+ className={`group/row relative w-full text-left py-3.5 px-4 rounded-[14px] cursor-pointer transition-all duration-150 border
289
+ ${isActive
290
+ ? 'bg-accent-soft border-accent-bright/25'
291
+ : 'bg-accent-soft/40 border-accent-bright/15 hover:bg-accent-soft/55'}`}
292
+ onClick={() => bulkMode ? toggleSelected(defaultAgent.id) : handleSelect(defaultAgent)}
293
+ >
294
+ <div className="flex items-center gap-3">
295
+ {bulkMode && (
296
+ <div className={`w-5 h-5 rounded-[6px] border-2 flex items-center justify-center shrink-0 transition-colors
297
+ ${selectedIds.has(defaultAgent.id) ? 'bg-accent-bright border-accent-bright' : 'border-white/20 bg-transparent'}`}>
298
+ {selectedIds.has(defaultAgent.id) && (
299
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
300
+ <polyline points="20 6 9 17 4 12" />
301
+ </svg>
302
+ )}
303
+ </div>
304
+ )}
305
+ <div className="relative shrink-0">
306
+ <AgentAvatar seed={defaultAgent.avatarSeed || null} avatarUrl={defaultAgent.avatarUrl} name={defaultAgent.name} size={38} />
307
+ <div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-bg ${
308
+ isWorking ? 'bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)]' : 'bg-text-3/30'
309
+ }`} />
310
+ </div>
311
+ <div className="flex-1 min-w-0">
312
+ <div className="flex items-center gap-2">
313
+ <span className="font-display text-[14px] font-700 truncate text-text tracking-[-0.01em]">
314
+ {defaultAgent.name}
315
+ </span>
316
+ <span className="px-1.5 py-0.5 rounded-[6px] bg-accent-bright/12 text-accent-bright text-[9px] font-700 uppercase tracking-[0.08em]">
317
+ Shortcut
318
+ </span>
319
+ </div>
320
+ {isTyping ? (
321
+ <div className="text-[12px] text-accent-bright/80 mt-1 flex items-center gap-1.5">
322
+ <span className="flex gap-0.5">
323
+ <span className="w-1 h-1 rounded-full bg-accent-bright/70 animate-bounce [animation-delay:0ms]" />
324
+ <span className="w-1 h-1 rounded-full bg-accent-bright/70 animate-bounce [animation-delay:150ms]" />
325
+ <span className="w-1 h-1 rounded-full bg-accent-bright/70 animate-bounce [animation-delay:300ms]" />
326
+ </span>
327
+ Typing...
328
+ </div>
329
+ ) : (
330
+ <div className="text-[12px] text-text-3/70 mt-1 truncate">
331
+ {preview}
332
+ </div>
333
+ )}
334
+ </div>
335
+ <button
336
+ onClick={async (e) => {
337
+ e.stopPropagation()
338
+ await updateSettings({ defaultAgentId: null })
339
+ toast.success('Default agent cleared')
340
+ }}
341
+ aria-label="Remove as default agent"
342
+ title="Default agent — click to clear"
343
+ className="shrink-0 p-1 rounded-[6px] transition-all bg-transparent border-none cursor-pointer hover:bg-white/[0.06] text-accent-bright"
344
+ style={{ fontFamily: 'inherit' }}
345
+ >
346
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
347
+ <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
348
+ <path d="M9 22V12h6v10" fill="rgba(0,0,0,0.3)" stroke="none" />
349
+ </svg>
350
+ </button>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ )
355
+ })()}
356
+ {listAgents.map((agent) => {
262
357
  const threadSession = agent.threadSessionId ? sessions[agent.threadSessionId] as Session | undefined : undefined
263
358
  const lastMsg = threadSession?.messages?.at(-1)
264
359
  const isActive = currentAgentId === agent.id
@@ -300,6 +395,11 @@ export function AgentChatList({ inSidebar, onSelect }: Props) {
300
395
  <span className="font-display text-[13.5px] font-600 truncate flex-1 tracking-[-0.01em]">
301
396
  {agent.name}
302
397
  </span>
398
+ {appSettings.defaultAgentId === agent.id && (
399
+ <span className="px-1.5 py-0.5 rounded-[6px] bg-accent-bright/10 text-accent-bright text-[9px] font-700 uppercase tracking-[0.08em] shrink-0">
400
+ Default
401
+ </span>
402
+ )}
303
403
  <span className="text-[10px] text-text-3/60 font-mono shrink-0">
304
404
  {(threadSession?.model || agent.model)
305
405
  ? (threadSession?.model || agent.model)!.split('/').pop()?.split(':')[0]
@@ -25,7 +25,7 @@ export function AgentList({ inSidebar }: Props) {
25
25
  const currentSessionId = useAppStore((s) => s.currentSessionId)
26
26
  const approvals = useApprovalStore((s) => s.approvals)
27
27
  const [search, setSearch] = useState('')
28
- const [filter, setFilter] = useState<'all' | 'orchestrator' | 'agent'>('all')
28
+ const [filter, setFilter] = useState<'all' | 'delegating' | 'solo'>('all')
29
29
 
30
30
  // FLIP animation refs
31
31
  const flipPositions = useRef<Map<string, number>>(new Map())
@@ -88,12 +88,22 @@ export function AgentList({ inSidebar }: Props) {
88
88
  return counts
89
89
  }, [approvals])
90
90
 
91
+ const delegatingCount = useMemo(
92
+ () => Object.values(agents).filter((agent) => agent.platformAssignScope === 'all' && !agent.trashedAt).length,
93
+ [agents],
94
+ )
95
+ const soloCount = useMemo(
96
+ () => Object.values(agents).filter((agent) => agent.platformAssignScope !== 'all' && !agent.trashedAt).length,
97
+ [agents],
98
+ )
99
+
91
100
  const filtered = useMemo(() => {
92
101
  return Object.values(agents)
93
102
  .filter((p) => {
94
103
  if (search && !p.name.toLowerCase().includes(search.toLowerCase())) return false
95
- if (filter === 'orchestrator' && !p.isOrchestrator) return false
96
- if (filter === 'agent' && p.isOrchestrator) return false
104
+ const canDelegateToAgents = p.platformAssignScope === 'all'
105
+ if (filter === 'delegating' && !canDelegateToAgents) return false
106
+ if (filter === 'solo' && canDelegateToAgents) return false
97
107
  if (activeProjectFilter && p.projectId !== activeProjectFilter) return false
98
108
  // Fleet filter
99
109
  if (fleetFilter === 'running' && !runningAgentIds.has(p.id)) return false
@@ -171,7 +181,7 @@ export function AgentList({ inSidebar }: Props) {
171
181
  </svg>
172
182
  }
173
183
  title="No agents yet"
174
- subtitle="Create AI agents and orchestrators"
184
+ subtitle="Create AI agents and enable delegation where needed"
175
185
  action={!inSidebar ? { label: '+ New Agent', onClick: () => setAgentSheetOpen(true) } : undefined}
176
186
  />
177
187
  )
@@ -212,15 +222,19 @@ export function AgentList({ inSidebar }: Props) {
212
222
  })}
213
223
  </div>
214
224
  <div className="flex gap-1 px-4 pb-2 items-center">
215
- {(['all', 'orchestrator', 'agent'] as const).map((f) => (
225
+ {([
226
+ ['all', `all (${delegatingCount + soloCount})`],
227
+ ['delegating', `delegating (${delegatingCount})`],
228
+ ['solo', `solo (${soloCount})`],
229
+ ] as const).map(([value, label]) => (
216
230
  <button
217
- key={f}
218
- onClick={() => setFilter(f)}
231
+ key={value}
232
+ onClick={() => setFilter(value)}
219
233
  className={`px-3 py-1.5 rounded-[8px] text-[11px] font-600 capitalize cursor-pointer transition-all
220
- ${filter === f ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2'}`}
234
+ ${filter === value ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2'}`}
221
235
  style={{ fontFamily: 'inherit' }}
222
236
  >
223
- {f}
237
+ {label}
224
238
  </button>
225
239
  ))}
226
240
  <div className="flex-1" />
@@ -235,6 +249,29 @@ export function AgentList({ inSidebar }: Props) {
235
249
  </svg>
236
250
  </button>
237
251
  </div>
252
+ {!inSidebar && (
253
+ <div className="mx-4 mb-3 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-3">
254
+ <div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
255
+ <div>
256
+ <h3 className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/60">Fleet Roles</h3>
257
+ <p className="text-[12px] text-text-3/65 mt-1">
258
+ Delegating agents can hand work to other agents. Solo agents stay on their own thread and tools.
259
+ </p>
260
+ </div>
261
+ <div className="flex flex-wrap gap-2">
262
+ <span className="px-2.5 py-1 rounded-[8px] bg-white/[0.04] text-[11px] font-600 text-text-2">
263
+ Default: {agents[defaultAgentId]?.name || 'Unset'}
264
+ </span>
265
+ <span className="px-2.5 py-1 rounded-[8px] bg-sky-500/10 text-[11px] font-600 text-sky-400">
266
+ {delegatingCount} delegating
267
+ </span>
268
+ <span className="px-2.5 py-1 rounded-[8px] bg-white/[0.04] text-[11px] font-600 text-text-2">
269
+ {soloCount} solo
270
+ </span>
271
+ </div>
272
+ </div>
273
+ </div>
274
+ )}
238
275
  <div className="flex flex-col gap-1 px-2 pb-4">
239
276
  {filtered.map((p) => (
240
277
  <div key={p.id} ref={(el) => { if (el) cardRefs.current.set(p.id, el); else cardRefs.current.delete(p.id) }}>