@swarmclawai/swarmclaw 0.5.2 → 0.6.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 (173) hide show
  1. package/README.md +42 -7
  2. package/bin/swarmclaw.js +76 -16
  3. package/next.config.ts +11 -1
  4. package/package.json +4 -2
  5. package/public/screenshots/agents.png +0 -0
  6. package/public/screenshots/dashboard.png +0 -0
  7. package/public/screenshots/providers.png +0 -0
  8. package/public/screenshots/tasks.png +0 -0
  9. package/scripts/postinstall.mjs +18 -0
  10. package/src/app/api/chatrooms/[id]/chat/route.ts +410 -0
  11. package/src/app/api/chatrooms/[id]/members/route.ts +82 -0
  12. package/src/app/api/chatrooms/[id]/pins/route.ts +39 -0
  13. package/src/app/api/chatrooms/[id]/reactions/route.ts +42 -0
  14. package/src/app/api/chatrooms/[id]/route.ts +84 -0
  15. package/src/app/api/chatrooms/route.ts +50 -0
  16. package/src/app/api/credentials/route.ts +2 -3
  17. package/src/app/api/knowledge/[id]/route.ts +13 -2
  18. package/src/app/api/knowledge/route.ts +8 -1
  19. package/src/app/api/memory/route.ts +8 -0
  20. package/src/app/api/notifications/[id]/route.ts +27 -0
  21. package/src/app/api/notifications/route.ts +68 -0
  22. package/src/app/api/orchestrator/run/route.ts +1 -1
  23. package/src/app/api/plugins/install/route.ts +2 -2
  24. package/src/app/api/search/route.ts +155 -0
  25. package/src/app/api/sessions/[id]/chat/route.ts +2 -0
  26. package/src/app/api/sessions/[id]/edit-resend/route.ts +1 -1
  27. package/src/app/api/sessions/[id]/fork/route.ts +1 -1
  28. package/src/app/api/sessions/route.ts +3 -3
  29. package/src/app/api/settings/route.ts +9 -0
  30. package/src/app/api/setup/check-provider/route.ts +3 -16
  31. package/src/app/api/skills/[id]/route.ts +6 -0
  32. package/src/app/api/skills/route.ts +6 -0
  33. package/src/app/api/tasks/[id]/route.ts +20 -0
  34. package/src/app/api/tasks/bulk/route.ts +100 -0
  35. package/src/app/api/tasks/route.ts +1 -0
  36. package/src/app/api/usage/route.ts +45 -0
  37. package/src/app/api/webhooks/[id]/route.ts +15 -1
  38. package/src/app/globals.css +58 -15
  39. package/src/app/page.tsx +142 -13
  40. package/src/cli/index.js +42 -0
  41. package/src/cli/index.test.js +30 -0
  42. package/src/cli/spec.js +32 -0
  43. package/src/components/agents/agent-avatar.tsx +57 -10
  44. package/src/components/agents/agent-card.tsx +48 -15
  45. package/src/components/agents/agent-chat-list.tsx +123 -10
  46. package/src/components/agents/agent-list.tsx +50 -19
  47. package/src/components/agents/agent-sheet.tsx +56 -63
  48. package/src/components/auth/access-key-gate.tsx +10 -3
  49. package/src/components/auth/setup-wizard.tsx +2 -2
  50. package/src/components/auth/user-picker.tsx +31 -3
  51. package/src/components/chat/activity-moment.tsx +169 -0
  52. package/src/components/chat/chat-header.tsx +2 -0
  53. package/src/components/chat/chat-tool-toggles.tsx +1 -1
  54. package/src/components/chat/file-path-chip.tsx +125 -0
  55. package/src/components/chat/markdown-utils.ts +9 -0
  56. package/src/components/chat/message-bubble.tsx +46 -295
  57. package/src/components/chat/message-list.tsx +50 -1
  58. package/src/components/chat/streaming-bubble.tsx +36 -46
  59. package/src/components/chat/suggestions-bar.tsx +1 -1
  60. package/src/components/chat/thinking-indicator.tsx +72 -10
  61. package/src/components/chat/tool-call-bubble.tsx +66 -70
  62. package/src/components/chat/tool-request-banner.tsx +31 -7
  63. package/src/components/chat/transfer-agent-picker.tsx +63 -0
  64. package/src/components/chatrooms/agent-hover-card.tsx +124 -0
  65. package/src/components/chatrooms/chatroom-input.tsx +320 -0
  66. package/src/components/chatrooms/chatroom-list.tsx +123 -0
  67. package/src/components/chatrooms/chatroom-message.tsx +427 -0
  68. package/src/components/chatrooms/chatroom-sheet.tsx +215 -0
  69. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +134 -0
  70. package/src/components/chatrooms/chatroom-typing-bar.tsx +88 -0
  71. package/src/components/chatrooms/chatroom-view.tsx +344 -0
  72. package/src/components/chatrooms/reaction-picker.tsx +273 -0
  73. package/src/components/connectors/connector-sheet.tsx +34 -47
  74. package/src/components/home/home-view.tsx +501 -0
  75. package/src/components/input/chat-input.tsx +79 -41
  76. package/src/components/knowledge/knowledge-list.tsx +31 -1
  77. package/src/components/knowledge/knowledge-sheet.tsx +83 -2
  78. package/src/components/layout/app-layout.tsx +209 -83
  79. package/src/components/layout/mobile-header.tsx +2 -0
  80. package/src/components/layout/update-banner.tsx +2 -2
  81. package/src/components/logs/log-list.tsx +2 -2
  82. package/src/components/mcp-servers/mcp-server-sheet.tsx +1 -1
  83. package/src/components/memory/memory-agent-list.tsx +143 -0
  84. package/src/components/memory/memory-browser.tsx +205 -0
  85. package/src/components/memory/memory-card.tsx +34 -7
  86. package/src/components/memory/memory-detail.tsx +359 -120
  87. package/src/components/memory/memory-sheet.tsx +157 -23
  88. package/src/components/plugins/plugin-list.tsx +1 -1
  89. package/src/components/plugins/plugin-sheet.tsx +1 -1
  90. package/src/components/projects/project-detail.tsx +509 -0
  91. package/src/components/projects/project-list.tsx +195 -59
  92. package/src/components/providers/provider-list.tsx +2 -2
  93. package/src/components/providers/provider-sheet.tsx +3 -3
  94. package/src/components/schedules/schedule-card.tsx +3 -2
  95. package/src/components/schedules/schedule-list.tsx +1 -1
  96. package/src/components/schedules/schedule-sheet.tsx +25 -25
  97. package/src/components/secrets/secret-sheet.tsx +47 -24
  98. package/src/components/secrets/secrets-list.tsx +18 -8
  99. package/src/components/sessions/new-session-sheet.tsx +33 -65
  100. package/src/components/sessions/session-card.tsx +45 -14
  101. package/src/components/sessions/session-list.tsx +35 -18
  102. package/src/components/shared/agent-picker-list.tsx +90 -0
  103. package/src/components/shared/agent-switch-dialog.tsx +156 -0
  104. package/src/components/shared/attachment-chip.tsx +165 -0
  105. package/src/components/shared/avatar.tsx +10 -1
  106. package/src/components/shared/check-icon.tsx +12 -0
  107. package/src/components/shared/confirm-dialog.tsx +1 -1
  108. package/src/components/shared/empty-state.tsx +32 -0
  109. package/src/components/shared/file-preview.tsx +34 -0
  110. package/src/components/shared/form-styles.ts +2 -0
  111. package/src/components/shared/keyboard-shortcuts-dialog.tsx +116 -0
  112. package/src/components/shared/notification-center.tsx +223 -0
  113. package/src/components/shared/profile-sheet.tsx +115 -0
  114. package/src/components/shared/reply-quote.tsx +26 -0
  115. package/src/components/shared/search-dialog.tsx +296 -0
  116. package/src/components/shared/section-label.tsx +12 -0
  117. package/src/components/shared/settings/plugin-manager.tsx +1 -1
  118. package/src/components/shared/settings/section-providers.tsx +1 -1
  119. package/src/components/shared/settings/section-secrets.tsx +1 -1
  120. package/src/components/shared/settings/section-theme.tsx +95 -0
  121. package/src/components/shared/settings/section-user-preferences.tsx +39 -0
  122. package/src/components/shared/settings/settings-page.tsx +180 -27
  123. package/src/components/shared/settings/settings-sheet.tsx +9 -73
  124. package/src/components/shared/sheet-footer.tsx +33 -0
  125. package/src/components/skills/skill-list.tsx +61 -30
  126. package/src/components/skills/skill-sheet.tsx +81 -2
  127. package/src/components/tasks/task-board.tsx +448 -26
  128. package/src/components/tasks/task-card.tsx +46 -9
  129. package/src/components/tasks/task-column.tsx +62 -3
  130. package/src/components/tasks/task-list.tsx +12 -4
  131. package/src/components/tasks/task-sheet.tsx +89 -72
  132. package/src/components/ui/hover-card.tsx +52 -0
  133. package/src/components/usage/metrics-dashboard.tsx +78 -0
  134. package/src/components/usage/usage-list.tsx +1 -1
  135. package/src/components/webhooks/webhook-sheet.tsx +1 -1
  136. package/src/hooks/use-view-router.ts +69 -19
  137. package/src/instrumentation.ts +15 -1
  138. package/src/lib/chat.ts +2 -0
  139. package/src/lib/cron-human.ts +114 -0
  140. package/src/lib/memory.ts +3 -0
  141. package/src/lib/server/chat-execution.ts +24 -4
  142. package/src/lib/server/connectors/manager.ts +11 -0
  143. package/src/lib/server/context-manager.ts +225 -13
  144. package/src/lib/server/create-notification.ts +42 -0
  145. package/src/lib/server/daemon-state.ts +165 -10
  146. package/src/lib/server/execution-log.ts +1 -0
  147. package/src/lib/server/heartbeat-service.ts +40 -5
  148. package/src/lib/server/heartbeat-wake.ts +110 -0
  149. package/src/lib/server/langgraph-checkpoint.ts +1 -0
  150. package/src/lib/server/memory-consolidation.ts +92 -0
  151. package/src/lib/server/memory-db.ts +51 -6
  152. package/src/lib/server/openclaw-gateway.ts +9 -1
  153. package/src/lib/server/provider-health.ts +125 -0
  154. package/src/lib/server/queue.ts +5 -4
  155. package/src/lib/server/scheduler.ts +8 -0
  156. package/src/lib/server/session-run-manager.ts +4 -0
  157. package/src/lib/server/session-tools/chatroom.ts +136 -0
  158. package/src/lib/server/session-tools/context-mgmt.ts +36 -18
  159. package/src/lib/server/session-tools/index.ts +2 -0
  160. package/src/lib/server/session-tools/memory.ts +6 -1
  161. package/src/lib/server/storage.ts +80 -29
  162. package/src/lib/server/stream-agent-chat.ts +153 -47
  163. package/src/lib/server/system-events.ts +49 -0
  164. package/src/lib/server/ws-hub.ts +11 -0
  165. package/src/lib/soul-suggestions.ts +109 -0
  166. package/src/lib/tasks.ts +4 -1
  167. package/src/lib/view-routes.ts +36 -1
  168. package/src/lib/ws-client.ts +14 -4
  169. package/src/proxy.ts +79 -2
  170. package/src/stores/use-app-store.ts +94 -3
  171. package/src/stores/use-chat-store.ts +48 -3
  172. package/src/stores/use-chatroom-store.ts +276 -0
  173. package/src/types/index.ts +69 -2
@@ -0,0 +1,109 @@
1
+ /** Pool of short personality/soul suggestions for new agents */
2
+ export const SOUL_SUGGESTIONS = [
3
+ 'You speak concisely and directly. You have a dry sense of humor.',
4
+ 'You are warm and encouraging, always finding something positive to highlight before giving constructive feedback.',
5
+ 'You think out loud, walking through your reasoning step by step. You admit uncertainty openly.',
6
+ 'You are blunt and efficient. No fluff, no pleasantries — just answers.',
7
+ 'You have a playful, curious personality. You love asking "what if" questions and exploring edge cases.',
8
+ 'You speak like a patient mentor. You explain complex things using simple analogies.',
9
+ 'You are methodical and thorough. You always consider what could go wrong before recommending a path forward.',
10
+ 'You communicate with quiet confidence. You prefer showing over telling.',
11
+ 'You are energetic and enthusiastic. You get genuinely excited about clever solutions.',
12
+ 'You are skeptical by nature. You challenge assumptions and ask for evidence.',
13
+ 'You speak in short, punchy sentences. You value clarity above all.',
14
+ 'You are diplomatic and measured. You present multiple perspectives before offering your own.',
15
+ 'You have a sardonic wit. You make sharp observations but never at someone\'s expense.',
16
+ 'You are deeply empathetic. You always consider the human impact of technical decisions.',
17
+ 'You think like a systems designer. You always zoom out to see the bigger picture.',
18
+ 'You are pragmatic to the core. You prefer "good enough now" over "perfect someday."',
19
+ 'You have an academic tone — precise, well-cited, and thorough. You qualify your claims carefully.',
20
+ 'You are a storyteller. You explain concepts through narratives and real-world examples.',
21
+ 'You are crisp and formal. You structure your responses with clear headings and bullet points.',
22
+ 'You are casual and approachable. You write like you\'re talking to a friend over coffee.',
23
+ 'You are relentlessly practical. Every suggestion comes with a concrete next step.',
24
+ 'You have a gentle, Socratic style. You guide through questions rather than giving direct answers.',
25
+ 'You are bold and opinionated. You take clear stances and defend them with reasoning.',
26
+ 'You speak with the calm authority of someone who has seen it all before.',
27
+ 'You are detail-oriented to a fault. You catch edge cases everyone else misses.',
28
+ 'You are minimalist in communication. You say what needs to be said and nothing more.',
29
+ 'You have a teacher\'s patience. You never make someone feel bad for not knowing something.',
30
+ 'You are a creative thinker. You approach problems from unexpected angles.',
31
+ 'You are data-driven. You always back claims with numbers, benchmarks, or citations.',
32
+ 'You speak with precision. You choose every word deliberately and avoid ambiguity.',
33
+ 'You are honest to a fault — you\'ll tell someone their idea won\'t work, then help them find one that will.',
34
+ 'You are collaborative. You build on others\' ideas rather than replacing them.',
35
+ 'You have a hacker mentality. You love finding clever shortcuts and unconventional solutions.',
36
+ 'You are calm under pressure. The bigger the problem, the more composed you become.',
37
+ 'You are a devil\'s advocate. You stress-test ideas by arguing the opposing position.',
38
+ 'You are nurturing and supportive, but you don\'t sugarcoat hard truths.',
39
+ 'You think in systems and trade-offs. Every solution has a cost, and you name it.',
40
+ 'You have an infectious optimism. You genuinely believe most problems are solvable.',
41
+ 'You are concise but thorough — you cover all the bases in as few words as possible.',
42
+ 'You speak with quiet humor. Your wit is subtle, never forced.',
43
+ 'You are fiercely independent in your thinking. You form your own opinions from first principles.',
44
+ 'You are a connector. You notice patterns across domains and draw surprising parallels.',
45
+ 'You are patient and deliberate. You\'d rather take time to get it right than rush to be first.',
46
+ 'You have a coach\'s mindset. You push people to be better while making them feel supported.',
47
+ 'You are whimsical and creative, but you know when to be serious.',
48
+ 'You speak like a seasoned engineer — no buzzwords, just clear technical communication.',
49
+ 'You are thoughtful and reflective. You often pause to reconsider before committing to an answer.',
50
+ 'You are action-oriented. You bias toward doing over discussing.',
51
+ 'You are naturally curious. You ask follow-up questions that reveal hidden complexity.',
52
+ 'You are a straight shooter. You say exactly what you mean without hedging.',
53
+ 'You have a philosopher\'s temperament. You question the question before answering it.',
54
+ 'You are structured and organized. You break complex problems into numbered steps.',
55
+ 'You lead with empathy. You always acknowledge the person before addressing the problem.',
56
+ 'You are witty and quick. You make technical topics entertaining without dumbing them down.',
57
+ 'You speak with understated expertise. You share deep knowledge without being condescending.',
58
+ 'You have a tinkerer\'s spirit. You love experimenting, prototyping, and iterating.',
59
+ 'You are a realist with high standards. You accept imperfection but always push for better.',
60
+ 'You communicate with military precision — clear, structured, and decisive.',
61
+ 'You are warm but direct. You combine kindness with candor effortlessly.',
62
+ 'You are naturally inquisitive. You treat every conversation as a chance to learn something.',
63
+ 'You have a zen-like calm. You simplify complexity rather than adding to it.',
64
+ 'You are enthusiastic about elegance. You appreciate when something is done beautifully, not just correctly.',
65
+ 'You have a journalist\'s instinct. You ask probing questions to get to the real story.',
66
+ 'You are reliable and steady. You under-promise and over-deliver.',
67
+ 'You are irreverent but insightful. You challenge convention with a smile.',
68
+ 'You speak like a craftsperson — you care about the details because you take pride in the work.',
69
+ 'You are a generalist who thinks broadly. You pull insights from diverse fields.',
70
+ 'You are intense and focused. When you care about something, it shows.',
71
+ 'You have a dry, deadpan delivery. Your humor catches people off guard.',
72
+ 'You are generous with your knowledge. You teach as you work.',
73
+ 'You are strategic. You always think two steps ahead.',
74
+ 'You have a pirate\'s spirit — resourceful, bold, and a little unconventional.',
75
+ 'You are grounded and sensible. You cut through hype to find what actually works.',
76
+ 'You speak thoughtfully, weighing each word. When you say something, people listen.',
77
+ 'You are adaptable. You match your communication style to what the situation needs.',
78
+ 'You have a scientist\'s rigor. You form hypotheses, test them, and revise.',
79
+ 'You are gently persistent. You don\'t give up easily, but you never push too hard.',
80
+ 'You are refreshingly honest. You admit what you don\'t know as readily as what you do.',
81
+ 'You have a builder\'s mindset. You\'re always thinking about what to create next.',
82
+ 'You are observant and perceptive. You notice things others overlook.',
83
+ 'You are efficient and no-nonsense, but you always make time for the human side.',
84
+ 'You speak with the easy confidence of someone who has nothing to prove.',
85
+ 'You are a deep thinker who surfaces insights others miss.',
86
+ 'You have an explorer\'s curiosity. You love venturing into unfamiliar territory.',
87
+ 'You are meticulous. You treat every detail as if it matters — because it usually does.',
88
+ 'You are lighthearted and fun, but you take your work seriously.',
89
+ 'You have a gardener\'s patience. You nurture ideas and let them grow.',
90
+ 'You are decisive. You gather enough information to act, then act.',
91
+ 'You have a poet\'s sensitivity to language. You choose words that resonate.',
92
+ 'You are stubbornly curious. You keep digging until you truly understand.',
93
+ 'You are wry and observant. You see the absurdity in things and gently point it out.',
94
+ 'You are practical and hands-on. You\'d rather build a prototype than write a spec.',
95
+ 'You have a designer\'s eye. You care about how things feel, not just how they function.',
96
+ 'You are straightforward and trustworthy. People know exactly where they stand with you.',
97
+ 'You think like an economist — always considering incentives, trade-offs, and unintended consequences.',
98
+ 'You are kind but not soft. You hold high standards with a warm touch.',
99
+ 'You are a problem solver at heart. You see obstacles as puzzles to crack.',
100
+ 'You have a naturalist\'s attention to patterns. You notice subtle signals others miss.',
101
+ 'You are scrappy and resourceful. You make the most of whatever you have.',
102
+ 'You are calmly assertive. You state your position clearly without being aggressive.',
103
+ 'You have a librarian\'s love of knowledge and a bartender\'s skill at conversation.',
104
+ ]
105
+
106
+ /** Pick a random soul suggestion */
107
+ export function randomSoul(): string {
108
+ return SOUL_SUGGESTIONS[Math.floor(Math.random() * SOUL_SUGGESTIONS.length)]
109
+ }
package/src/lib/tasks.ts CHANGED
@@ -4,7 +4,7 @@ import type { BoardTask } from '../types'
4
4
  export const fetchTasks = (includeArchived = false) =>
5
5
  api<Record<string, BoardTask>>('GET', `/tasks${includeArchived ? '?includeArchived=true' : ''}`)
6
6
 
7
- export const createTask = (data: { title: string; description: string; agentId: string }) =>
7
+ export const createTask = (data: { title: string; description: string; agentId: string; status?: string }) =>
8
8
  api<BoardTask>('POST', '/tasks', data)
9
9
 
10
10
  export const updateTask = (id: string, data: Partial<BoardTask>) =>
@@ -18,3 +18,6 @@ export const archiveTask = (id: string) =>
18
18
 
19
19
  export const unarchiveTask = (id: string) =>
20
20
  api<BoardTask>('PUT', `/tasks/${id}`, { status: 'backlog' })
21
+
22
+ export const bulkUpdateTasks = (ids: string[], data: { status?: string; agentId?: string | null; projectId?: string | null }) =>
23
+ api<{ updated: number; ids: string[] }>('POST', '/tasks/bulk', { ids, ...data })
@@ -1,9 +1,11 @@
1
1
  import type { AppView } from '@/types'
2
2
 
3
- export const DEFAULT_VIEW: AppView = 'agents'
3
+ export const DEFAULT_VIEW: AppView = 'home'
4
4
 
5
5
  export const VIEW_TO_PATH: Record<AppView, string> = {
6
+ home: '/',
6
7
  agents: '/agents',
8
+ chatrooms: '/chatrooms',
7
9
  schedules: '/schedules',
8
10
  memory: '/memory',
9
11
  tasks: '/tasks',
@@ -27,3 +29,36 @@ const entries = Object.entries(VIEW_TO_PATH) as [AppView, string][]
27
29
  export const PATH_TO_VIEW: Record<string, AppView> = Object.fromEntries(
28
30
  entries.map(([view, path]) => [path, view]),
29
31
  ) as Record<string, AppView>
32
+
33
+ /** Views that support deep-linking to a specific entity by ID */
34
+ const VIEWS_WITH_ID = new Set<AppView>(['agents', 'chatrooms'])
35
+
36
+ // Sorted longest-first so "/mcp-servers" matches before "/" etc.
37
+ const sortedPaths = entries
38
+ .map(([view, path]) => ({ view, path }))
39
+ .sort((a, b) => b.path.length - a.path.length)
40
+
41
+ /** Parse a pathname into { view, id }. Returns null for unknown paths. */
42
+ export function parsePath(pathname: string): { view: AppView; id: string | null } | null {
43
+ // Exact match first (no trailing ID)
44
+ const exact = PATH_TO_VIEW[pathname]
45
+ if (exact) return { view: exact, id: null }
46
+
47
+ // Prefix match: "/agents/abc123" → view=agents, id=abc123
48
+ for (const { view, path } of sortedPaths) {
49
+ if (pathname.startsWith(path + '/')) {
50
+ const rest = pathname.slice(path.length + 1)
51
+ if (rest && !rest.includes('/') && VIEWS_WITH_ID.has(view)) {
52
+ return { view, id: decodeURIComponent(rest) }
53
+ }
54
+ }
55
+ }
56
+ return null
57
+ }
58
+
59
+ /** Build a URL path for a view, optionally with an entity ID. */
60
+ export function buildPath(view: AppView, id?: string | null): string {
61
+ const base = VIEW_TO_PATH[view]
62
+ if (id && VIEWS_WITH_ID.has(view)) return `${base}/${encodeURIComponent(id)}`
63
+ return base
64
+ }
@@ -9,10 +9,20 @@ const listeners = new Map<string, Set<WsCallback>>()
9
9
  let connected = false
10
10
 
11
11
  function getWsUrl(key: string): string {
12
- const host = typeof window !== 'undefined' ? window.location.hostname : 'localhost'
13
- const port = process.env.NEXT_PUBLIC_WS_PORT || '3457'
14
- const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss' : 'ws'
15
- return `${protocol}://${host}:${port}/ws?key=${encodeURIComponent(key)}`
12
+ if (typeof window === 'undefined') return `ws://localhost:3457/ws?key=${encodeURIComponent(key)}`
13
+
14
+ const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
15
+ const pagePort = window.location.port
16
+ const buildPort = process.env.NEXT_PUBLIC_WS_PORT || '3457'
17
+
18
+ // If the page was loaded on a standard HTTP port (80/443/empty) or a port
19
+ // that doesn't match the expected app port, we're likely behind a reverse
20
+ // proxy. Use the page's host directly so the proxy can route /ws traffic.
21
+ const appPort = String((Number(buildPort) || 3457) - 1) // e.g. 3456
22
+ const behindProxy = !pagePort || pagePort === '80' || pagePort === '443' || pagePort !== appPort
23
+ const wsHost = behindProxy ? window.location.host : `${window.location.hostname}:${buildPort}`
24
+
25
+ return `${protocol}://${wsHost}/ws?key=${encodeURIComponent(key)}`
16
26
  }
17
27
 
18
28
  function handleMessage(event: MessageEvent) {
package/src/proxy.ts CHANGED
@@ -1,9 +1,52 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import type { NextRequest } from 'next/server'
3
3
 
4
- /** Simple access key auth proxy.
4
+ /* ------------------------------------------------------------------ */
5
+ /* Rate-limit state — HMR-safe via globalThis */
6
+ /* ------------------------------------------------------------------ */
7
+
8
+ interface RateLimitEntry {
9
+ count: number
10
+ lockedUntil: number
11
+ }
12
+
13
+ const rateLimitMap = (
14
+ (globalThis as Record<string, unknown>).__swarmclaw_rate_limit__ ??= new Map()
15
+ ) as Map<string, RateLimitEntry>
16
+
17
+ const MAX_ATTEMPTS = 5
18
+ const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
19
+ const PRUNE_THRESHOLD = 1000
20
+
21
+ /** Prune expired entries when the map grows too large. */
22
+ function pruneRateLimitMap() {
23
+ if (rateLimitMap.size <= PRUNE_THRESHOLD) return
24
+ const now = Date.now()
25
+ rateLimitMap.forEach((entry, ip) => {
26
+ if (entry.lockedUntil < now && entry.count < MAX_ATTEMPTS) {
27
+ rateLimitMap.delete(ip)
28
+ }
29
+ })
30
+ }
31
+
32
+ /** Extract client IP from the request. */
33
+ function getClientIp(request: NextRequest): string {
34
+ const forwarded = request.headers.get('x-forwarded-for')
35
+ if (forwarded) {
36
+ const first = forwarded.split(',')[0]?.trim()
37
+ if (first) return first
38
+ }
39
+ return (request as unknown as { ip?: string }).ip ?? 'unknown'
40
+ }
41
+
42
+ /* ------------------------------------------------------------------ */
43
+ /* Proxy */
44
+ /* ------------------------------------------------------------------ */
45
+
46
+ /** Access key auth proxy with brute-force rate limiting.
5
47
  * Checks X-Access-Key header or ?key= param on all /api/ routes except /api/auth.
6
48
  * The key is validated against the ACCESS_KEY env var.
49
+ * After 5 failed attempts from a single IP the client is locked out for 15 minutes.
7
50
  */
8
51
  export function proxy(request: NextRequest) {
9
52
  const { pathname } = request.nextUrl
@@ -29,13 +72,47 @@ export function proxy(request: NextRequest) {
29
72
  return NextResponse.next()
30
73
  }
31
74
 
75
+ // --- Rate-limit housekeeping ---
76
+ pruneRateLimitMap()
77
+
78
+ const clientIp = getClientIp(request)
79
+ const entry = rateLimitMap.get(clientIp)
80
+
81
+ // Check lockout before even validating the key
82
+ if (entry && entry.lockedUntil > Date.now()) {
83
+ const retryAfter = Math.ceil((entry.lockedUntil - Date.now()) / 1000)
84
+ return NextResponse.json(
85
+ { error: 'Too many failed attempts. Try again later.', retryAfter },
86
+ { status: 429, headers: { 'Retry-After': String(retryAfter) } },
87
+ )
88
+ }
89
+
32
90
  const providedKey =
33
91
  request.headers.get('x-access-key')
34
92
  || request.nextUrl.searchParams.get('key')
35
93
  || ''
36
94
 
37
95
  if (providedKey !== accessKey) {
38
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
96
+ // Record the failed attempt
97
+ const current = rateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 }
98
+ current.count += 1
99
+
100
+ if (current.count >= MAX_ATTEMPTS) {
101
+ current.lockedUntil = Date.now() + LOCKOUT_MS
102
+ }
103
+
104
+ rateLimitMap.set(clientIp, current)
105
+
106
+ const remaining = Math.max(0, MAX_ATTEMPTS - current.count)
107
+ return NextResponse.json(
108
+ { error: 'Unauthorized' },
109
+ { status: 401, headers: { 'X-RateLimit-Remaining': String(remaining) } },
110
+ )
111
+ }
112
+
113
+ // Successful auth — clear any prior failed-attempt tracking for this IP
114
+ if (entry) {
115
+ rateLimitMap.delete(clientIp)
39
116
  }
40
117
 
41
118
  return NextResponse.next()
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { create } from 'zustand'
4
- import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter, ActivityEntry } from '../types'
4
+ import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter, ActivityEntry, AppNotification } from '../types'
5
5
  import { fetchSessions, fetchDirs, fetchProviders, fetchCredentials } from '../lib/sessions'
6
6
  import { fetchAgents } from '../lib/agents'
7
7
  import { fetchSchedules } from '../lib/schedules'
@@ -53,6 +53,7 @@ interface AppState {
53
53
 
54
54
  agents: Record<string, Agent>
55
55
  loadAgents: () => Promise<void>
56
+ togglePinAgent: (id: string) => void
56
57
 
57
58
  schedules: Record<string, Schedule>
58
59
  loadSchedules: () => Promise<void>
@@ -180,10 +181,26 @@ interface AppState {
180
181
  fleetFilter: FleetFilter
181
182
  setFleetFilter: (filter: FleetFilter) => void
182
183
 
184
+ // Chat list filter
185
+ chatFilter: 'all' | 'active' | 'recent'
186
+ setChatFilter: (filter: 'all' | 'active' | 'recent') => void
187
+
183
188
  // Activity / Audit Trail
184
189
  activityEntries: ActivityEntry[]
185
190
  loadActivity: (filters?: { entityType?: string; limit?: number }) => Promise<void>
186
191
 
192
+ // Unread tracking (localStorage-backed)
193
+ lastReadTimestamps: Record<string, number>
194
+ markChatRead: (id: string) => void
195
+
196
+ // Notifications
197
+ notifications: AppNotification[]
198
+ unreadNotificationCount: number
199
+ loadNotifications: () => Promise<void>
200
+ markNotificationRead: (id: string) => Promise<void>
201
+ markAllNotificationsRead: () => Promise<void>
202
+ clearReadNotifications: () => Promise<void>
203
+
187
204
  }
188
205
 
189
206
  export const useAppStore = create<AppState>((set, get) => ({
@@ -192,7 +209,8 @@ export const useAppStore = create<AppState>((set, get) => ({
192
209
  hydrate: () => {
193
210
  if (typeof window === 'undefined') return
194
211
  const user = localStorage.getItem('sc_user')
195
- set({ currentUser: user, _hydrated: true })
212
+ const savedAgentId = localStorage.getItem('sc_agent')
213
+ set({ currentUser: user, currentAgentId: savedAgentId, _hydrated: true })
196
214
  },
197
215
  setUser: (user) => {
198
216
  if (user) localStorage.setItem('sc_user', user)
@@ -297,16 +315,18 @@ export const useAppStore = create<AppState>((set, get) => ({
297
315
  newSessionOpen: false,
298
316
  setNewSessionOpen: (open) => set({ newSessionOpen: open }),
299
317
 
300
- activeView: 'agents',
318
+ activeView: 'home',
301
319
  setActiveView: (view) => set({ activeView: view }),
302
320
 
303
321
  currentAgentId: null,
304
322
  setCurrentAgent: async (id) => {
305
323
  if (!id) {
306
324
  set({ currentAgentId: null })
325
+ if (typeof window !== 'undefined') localStorage.removeItem('sc_agent')
307
326
  return
308
327
  }
309
328
  set({ currentAgentId: id })
329
+ if (typeof window !== 'undefined') localStorage.setItem('sc_agent', id)
310
330
  try {
311
331
  const user = get().currentUser || 'default'
312
332
  const session = await api<Session>('POST', `/agents/${id}/thread`, { user })
@@ -328,6 +348,14 @@ export const useAppStore = create<AppState>((set, get) => ({
328
348
  // ignore
329
349
  }
330
350
  },
351
+ togglePinAgent: (id) => {
352
+ const agents = { ...get().agents }
353
+ if (agents[id]) {
354
+ agents[id] = { ...agents[id], pinned: !agents[id].pinned }
355
+ set({ agents })
356
+ void api('PUT', `/agents/${id}`, { pinned: agents[id].pinned })
357
+ }
358
+ },
331
359
 
332
360
  schedules: {},
333
361
  loadSchedules: async () => {
@@ -576,6 +604,10 @@ export const useAppStore = create<AppState>((set, get) => ({
576
604
  fleetFilter: 'all',
577
605
  setFleetFilter: (filter) => set({ fleetFilter: filter }),
578
606
 
607
+ // Chat list filter
608
+ chatFilter: 'all' as const,
609
+ setChatFilter: (filter) => set({ chatFilter: filter }),
610
+
579
611
  // Activity / Audit Trail
580
612
  activityEntries: [],
581
613
  loadActivity: async (filters) => {
@@ -591,4 +623,63 @@ export const useAppStore = create<AppState>((set, get) => ({
591
623
  }
592
624
  },
593
625
 
626
+ // Unread tracking
627
+ lastReadTimestamps: typeof window !== 'undefined'
628
+ ? (() => { try { return JSON.parse(localStorage.getItem('sc_last_read') || '{}') } catch { return {} } })()
629
+ : {},
630
+ markChatRead: (id) => {
631
+ const ts = { ...get().lastReadTimestamps, [id]: Date.now() }
632
+ set({ lastReadTimestamps: ts })
633
+ try { localStorage.setItem('sc_last_read', JSON.stringify(ts)) } catch { /* ignore */ }
634
+ },
635
+
636
+ // Notifications
637
+ notifications: [],
638
+ unreadNotificationCount: 0,
639
+ loadNotifications: async () => {
640
+ try {
641
+ const notifications = await api<AppNotification[]>('GET', '/notifications')
642
+ set({
643
+ notifications,
644
+ unreadNotificationCount: notifications.filter((n) => !n.read).length,
645
+ })
646
+ } catch {
647
+ // ignore
648
+ }
649
+ },
650
+ markNotificationRead: async (id) => {
651
+ const notifications = get().notifications.map((n) =>
652
+ n.id === id ? { ...n, read: true } : n,
653
+ )
654
+ set({
655
+ notifications,
656
+ unreadNotificationCount: notifications.filter((n) => !n.read).length,
657
+ })
658
+ try {
659
+ await api('PUT', `/notifications/${id}`, { read: true })
660
+ } catch {
661
+ // ignore
662
+ }
663
+ },
664
+ markAllNotificationsRead: async () => {
665
+ const notifications = get().notifications.map((n) => ({ ...n, read: true }))
666
+ set({ notifications, unreadNotificationCount: 0 })
667
+ try {
668
+ await Promise.all(
669
+ get().notifications.filter((n) => !n.read).map((n) => api('PUT', `/notifications/${n.id}`, { read: true })),
670
+ )
671
+ } catch {
672
+ // ignore
673
+ }
674
+ },
675
+ clearReadNotifications: async () => {
676
+ const notifications = get().notifications.filter((n) => !n.read)
677
+ set({ notifications, unreadNotificationCount: notifications.length })
678
+ try {
679
+ await api('DELETE', '/notifications')
680
+ } catch {
681
+ // ignore
682
+ }
683
+ },
684
+
594
685
  }))
@@ -68,6 +68,10 @@ interface ChatState {
68
68
  pendingImage: PendingFile | null
69
69
  setPendingImage: (img: PendingFile | null) => void
70
70
 
71
+ // Reply-to
72
+ replyingTo: { message: Message; index: number } | null
73
+ setReplyingTo: (reply: { message: Message; index: number } | null) => void
74
+
71
75
  devServer: DevServerStatus | null
72
76
  setDevServer: (ds: DevServerStatus | null) => void
73
77
 
@@ -83,12 +87,22 @@ interface ChatState {
83
87
  sendHeartbeat: (sessionId: string) => Promise<void>
84
88
  stopStreaming: () => void
85
89
 
90
+ // Thinking/reasoning text during streaming
91
+ thinkingText: string
92
+ thinkingStartTime: number
93
+
86
94
  // Rich trace blocks during streaming (F13)
87
95
  streamTraces: ChatTraceBlock[]
88
96
 
89
97
  // Voice conversation
90
98
  voiceConversationActive: boolean
91
99
  onStreamEvent: ((event: { t: string; text?: string }) => void) | null
100
+
101
+ // Message queue (send while streaming)
102
+ queuedMessages: string[]
103
+ addQueuedMessage: (text: string) => void
104
+ removeQueuedMessage: (index: number) => void
105
+ shiftQueuedMessage: () => string | undefined
92
106
  }
93
107
 
94
108
  // Module-level cadence interval (not in state to avoid re-renders)
@@ -128,9 +142,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
128
142
  setSoundEnabled(next)
129
143
  set({ soundEnabled: next })
130
144
  },
145
+ thinkingText: '',
146
+ thinkingStartTime: 0,
131
147
  streamTraces: [],
132
148
  voiceConversationActive: false,
133
149
  onStreamEvent: null,
150
+ queuedMessages: [],
151
+ addQueuedMessage: (text) => set((s) => ({ queuedMessages: [...s.queuedMessages, text] })),
152
+ removeQueuedMessage: (index) => set((s) => ({ queuedMessages: s.queuedMessages.filter((_, i) => i !== index) })),
153
+ shiftQueuedMessage: () => {
154
+ const q = get().queuedMessages
155
+ if (!q.length) return undefined
156
+ const next = q[0]
157
+ set({ queuedMessages: q.slice(1) })
158
+ return next
159
+ },
134
160
 
135
161
  pendingFiles: [],
136
162
  addPendingFile: (f) => set((s) => ({ pendingFiles: [...s.pendingFiles, f] })),
@@ -141,6 +167,10 @@ export const useChatStore = create<ChatState>((set, get) => ({
141
167
  get pendingImage() { const files = get().pendingFiles; return files.length ? files[0] : null },
142
168
  setPendingImage: (img) => set({ pendingFiles: img ? [img] : [] }),
143
169
 
170
+ // Reply-to
171
+ replyingTo: null,
172
+ setReplyingTo: (reply) => set({ replyingTo: reply }),
173
+
144
174
  previewContent: null,
145
175
  setPreviewContent: (content) => set({ previewContent: content }),
146
176
 
@@ -150,7 +180,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
150
180
  setDebugOpen: (open) => set({ debugOpen: open }),
151
181
 
152
182
  sendMessage: async (text: string) => {
153
- const { pendingFiles } = get()
183
+ const { pendingFiles, replyingTo } = get()
154
184
  if ((!text.trim() && !pendingFiles.length) || get().streaming) return
155
185
  const sessionId = useAppStore.getState().currentSessionId
156
186
  if (!sessionId) return
@@ -162,6 +192,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
162
192
  const attachedFiles = pendingFiles.length > 1
163
193
  ? pendingFiles.map((f) => f.path)
164
194
  : undefined
195
+ const replyToId = replyingTo?.message?.replyToId ? undefined : replyingTo?.message ? `msg-${replyingTo.index}` : undefined
165
196
 
166
197
  const userMsg: Message = {
167
198
  role: 'user',
@@ -170,6 +201,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
170
201
  imagePath,
171
202
  imageUrl,
172
203
  attachedFiles,
204
+ ...(replyToId ? { replyToId } : {}),
173
205
  }
174
206
  clearCadence()
175
207
  set((s) => ({
@@ -180,8 +212,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
180
212
  streamToolName: '',
181
213
  displayText: '',
182
214
  agentStatus: null,
215
+ thinkingText: '',
216
+ thinkingStartTime: Date.now(),
183
217
  messages: [...s.messages, userMsg],
184
218
  pendingFiles: [],
219
+ replyingTo: null,
185
220
  toolEvents: [],
186
221
  lastUsage: null,
187
222
  }))
@@ -296,6 +331,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
296
331
  set({ streamText: fullText })
297
332
  if (get().soundEnabled) playError()
298
333
  }
334
+ } else if (event.t === 'thinking') {
335
+ set((s) => ({ thinkingText: s.thinkingText + (event.text || '') }))
299
336
  } else if (event.t === 'status') {
300
337
  try {
301
338
  const parsed = JSON.parse(event.text || '{}')
@@ -306,7 +343,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
306
343
  } else if (event.t === 'done') {
307
344
  // done
308
345
  }
309
- }, attachedFiles)
346
+ }, attachedFiles, { replyToId })
310
347
 
311
348
  clearCadence()
312
349
  if (get().soundEnabled && soundFiredStart) playStreamEnd()
@@ -333,13 +370,21 @@ export const useChatStore = create<ChatState>((set, get) => ({
333
370
  displayText: '',
334
371
  streamPhase: 'thinking' as const,
335
372
  streamToolName: '',
373
+ thinkingText: '',
374
+ thinkingStartTime: 0,
336
375
  }))
337
376
  if (get().ttsEnabled && !get().voiceConversationActive) speak(fullText)
338
377
  } else {
339
- set({ streaming: false, streamingSessionId: null, streamText: '', displayText: '', streamPhase: 'thinking' as const, streamToolName: '' })
378
+ set({ streaming: false, streamingSessionId: null, streamText: '', displayText: '', streamPhase: 'thinking' as const, streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
340
379
  }
341
380
 
342
381
  useAppStore.getState().loadSessions()
382
+
383
+ // Auto-dequeue: if there are queued messages, send the next one
384
+ const nextQueued = get().shiftQueuedMessage()
385
+ if (nextQueued) {
386
+ setTimeout(() => get().sendMessage(nextQueued), 100)
387
+ }
343
388
  },
344
389
 
345
390
  editAndResend: async (messageIndex: number, newText: string) => {