@swarmclawai/swarmclaw 0.8.0 → 0.8.2

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 (49) hide show
  1. package/README.md +8 -7
  2. package/package.json +2 -2
  3. package/src/app/api/notifications/route.ts +11 -12
  4. package/src/app/page.tsx +9 -0
  5. package/src/components/chat/chat-list.tsx +10 -9
  6. package/src/components/home/home-view.tsx +13 -2
  7. package/src/components/layout/app-layout.tsx +1 -0
  8. package/src/components/shared/command-palette.tsx +4 -1
  9. package/src/components/shared/notification-center.tsx +7 -1
  10. package/src/components/shared/search-dialog.tsx +10 -2
  11. package/src/lib/local-observability.test.ts +73 -0
  12. package/src/lib/local-observability.ts +47 -0
  13. package/src/lib/notification-utils.test.ts +72 -0
  14. package/src/lib/notification-utils.ts +68 -0
  15. package/src/lib/providers/openclaw.test.ts +21 -1
  16. package/src/lib/providers/openclaw.ts +22 -0
  17. package/src/lib/runtime-loop.ts +1 -1
  18. package/src/lib/server/agent-thread-session.test.ts +41 -0
  19. package/src/lib/server/agent-thread-session.ts +1 -0
  20. package/src/lib/server/chat-execution-advanced.test.ts +7 -0
  21. package/src/lib/server/chat-execution-eval-history.test.ts +111 -0
  22. package/src/lib/server/chat-execution.ts +22 -5
  23. package/src/lib/server/create-notification.test.ts +94 -0
  24. package/src/lib/server/create-notification.ts +31 -25
  25. package/src/lib/server/daemon-state.test.ts +50 -0
  26. package/src/lib/server/daemon-state.ts +121 -38
  27. package/src/lib/server/eval/agent-regression-advanced.test.ts +11 -0
  28. package/src/lib/server/eval/agent-regression.test.ts +13 -1
  29. package/src/lib/server/eval/agent-regression.ts +221 -1
  30. package/src/lib/server/memory-policy.test.ts +32 -0
  31. package/src/lib/server/memory-policy.ts +25 -0
  32. package/src/lib/server/plugins-advanced.test.ts +7 -0
  33. package/src/lib/server/runtime-settings.test.ts +2 -2
  34. package/src/lib/server/session-tools/crud.test.ts +136 -0
  35. package/src/lib/server/session-tools/crud.ts +44 -2
  36. package/src/lib/server/session-tools/delegate-fallback.test.ts +36 -0
  37. package/src/lib/server/session-tools/delegate.ts +30 -0
  38. package/src/lib/server/session-tools/discovery-approvals.test.ts +40 -0
  39. package/src/lib/server/session-tools/discovery.ts +7 -6
  40. package/src/lib/server/session-tools/memory.ts +156 -6
  41. package/src/lib/server/session-tools/session-tools-wiring.test.ts +12 -0
  42. package/src/lib/server/session-tools/subagent.ts +4 -4
  43. package/src/lib/server/storage.ts +14 -1
  44. package/src/lib/server/stream-agent-chat.test.ts +78 -1
  45. package/src/lib/server/stream-agent-chat.ts +225 -22
  46. package/src/lib/server/tool-aliases.ts +1 -1
  47. package/src/lib/server/tool-capability-policy.ts +1 -1
  48. package/src/stores/use-app-store.ts +26 -1
  49. package/src/types/index.ts +4 -0
package/README.md CHANGED
@@ -148,7 +148,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
148
148
  ```
149
149
 
150
150
  The installer resolves the latest stable release tag and installs that version by default.
151
- To pin a version: `SWARMCLAW_VERSION=v0.8.0 curl ... | bash`
151
+ To pin a version: `SWARMCLAW_VERSION=v0.8.2 curl ... | bash`
152
152
 
153
153
  Or run locally from the repo (friendly for non-technical users):
154
154
 
@@ -701,7 +701,7 @@ npm run update:easy # safe update helper for local installs
701
701
  SwarmClaw uses tag-based releases (`vX.Y.Z`) as the stable channel.
702
702
 
703
703
  ```bash
704
- # example minor release (v0.8.0 style)
704
+ # example minor release (v0.8.2 style)
705
705
  npm version minor
706
706
  git push origin main --follow-tags
707
707
  ```
@@ -711,13 +711,14 @@ On `v*` tags, GitHub Actions will:
711
711
  2. Create a GitHub Release
712
712
  3. Build and publish Docker images to `ghcr.io/swarmclawai/swarmclaw` (`:vX.Y.Z`, `:latest`, `:sha-*`)
713
713
 
714
- #### v0.8.0 Release Readiness Notes
714
+ #### v0.8.2 Release Readiness Notes
715
715
 
716
- Before shipping `v0.8.0`, confirm the following user-facing changes are reflected in docs:
716
+ Before shipping `v0.8.2`, confirm the following user-facing changes are reflected in docs:
717
717
 
718
- 1. Voice and connector docs mention that outbound voice-note sends now retry with the built-in ElevenLabs fallback voice when a configured default voice is rejected as paid-only.
719
- 2. Release notes call out the Molly-style regression case explicitly: WhatsApp voice-note delivery should keep working even if the saved default voice ID points at a paid library voice.
720
- 3. Site and README install/version strings are updated to `v0.8.0`, including install snippets, release notes index text, and sidebar/footer labels.
718
+ 1. Runtime/defaults docs mention the higher default agent recursion limit so long-running bounded turns get more headroom without custom tuning.
719
+ 2. Memory/tooling docs mention the narrower direct-memory-write routing: remember-and-confirm turns stay on `memory_store`/`memory_update`, bundled related facts should be stored as one canonical write, and same-thread recall should not steal those turns.
720
+ 3. File-output guidance notes that exact bullet-count and titled-section constraints are now treated as hard structure requirements during deliverable follow-through.
721
+ 4. Site and README install/version strings are updated to `v0.8.2`, including install snippets, release notes index text, and sidebar/footer labels.
721
722
 
722
723
  ## CLI
723
724
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "https://github.com/swarmclawai/swarmclaw.git"
12
+ "url": "git+https://github.com/swarmclawai/swarmclaw.git"
13
13
  },
14
14
  "keywords": [
15
15
  "ai",
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { genId } from '@/lib/id'
3
- import { loadNotifications, saveNotification, deleteNotification } from '@/lib/server/storage'
2
+ import { getNotificationActivityAt } from '@/lib/notification-utils'
3
+ import { createNotification } from '@/lib/server/create-notification'
4
+ import { loadNotifications, deleteNotification } from '@/lib/server/storage'
4
5
  import { notify } from '@/lib/server/ws-hub'
5
6
  import type { AppNotification } from '@/types'
6
7
  export const dynamic = 'force-dynamic'
@@ -20,7 +21,7 @@ export async function GET(req: Request) {
20
21
  entries = entries.filter((e) => !e.read)
21
22
  }
22
23
 
23
- entries.sort((a, b) => b.createdAt - a.createdAt)
24
+ entries.sort((a, b) => getNotificationActivityAt(b) - getNotificationActivityAt(a))
24
25
  entries = entries.slice(0, limit)
25
26
 
26
27
  return NextResponse.json(entries)
@@ -30,9 +31,10 @@ export async function POST(req: Request) {
30
31
  const body = (await req.json()) as Record<string, unknown>
31
32
  const actionLabel = typeof body.actionLabel === 'string' ? body.actionLabel : undefined
32
33
  const actionUrl = typeof body.actionUrl === 'string' ? body.actionUrl : undefined
33
- const id = genId()
34
- const notification: AppNotification = {
35
- id,
34
+ const dedupKey = typeof body.dedupKey === 'string' && body.dedupKey.trim()
35
+ ? body.dedupKey.trim()
36
+ : undefined
37
+ const { notification, created } = createNotification({
36
38
  type: (['info', 'success', 'warning', 'error'].includes(body.type as string) ? body.type : 'info') as AppNotification['type'],
37
39
  title: typeof body.title === 'string' ? body.title : 'Notification',
38
40
  message: typeof body.message === 'string' ? body.message : undefined,
@@ -40,13 +42,10 @@ export async function POST(req: Request) {
40
42
  actionUrl,
41
43
  entityType: typeof body.entityType === 'string' ? body.entityType : undefined,
42
44
  entityId: typeof body.entityId === 'string' ? body.entityId : undefined,
43
- read: false,
44
- createdAt: Date.now(),
45
- }
45
+ dedupKey,
46
+ })
46
47
 
47
- saveNotification(id, notification)
48
- notify('notifications')
49
- return NextResponse.json(notification, { status: 201 })
48
+ return NextResponse.json(notification, { status: created ? 201 : 200 })
50
49
  }
51
50
 
52
51
  export async function DELETE(req: Request) {
package/src/app/page.tsx CHANGED
@@ -7,6 +7,7 @@ import { getStoredAccessKey, clearStoredAccessKey, api } from '@/lib/api-client'
7
7
  import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
8
8
  import { connectWs, disconnectWs } from '@/lib/ws-client'
9
9
  import { fetchWithTimeout } from '@/lib/fetch-timeout'
10
+ import { isLocalhostBrowser } from '@/lib/local-observability'
10
11
  import { useWs } from '@/hooks/use-ws'
11
12
  import { AccessKeyGate } from '@/components/auth/access-key-gate'
12
13
  import { UserPicker } from '@/components/auth/user-picker'
@@ -270,6 +271,14 @@ export default function Home() {
270
271
 
271
272
  useWs('sessions', loadSessions, 5000)
272
273
 
274
+ useEffect(() => {
275
+ if (!authenticated || !isLocalhostBrowser()) return
276
+ const pollId = setInterval(() => {
277
+ void loadSessions()
278
+ }, 5000)
279
+ return () => clearInterval(pollId)
280
+ }, [authenticated, loadSessions])
281
+
273
282
  // Auto-select agent's thread on load — resolves a persisted agentId into a session,
274
283
  // or falls back to defaultAgentId from settings, then first agent.
275
284
  const [agentReady, setAgentReady] = useState(false)
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { useChatStore } from '@/stores/use-chat-store'
6
6
  import { ChatCard } from './chat-card'
7
7
  import { fetchMessages } from '@/lib/chats'
8
+ import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
8
9
  import { toast } from 'sonner'
9
10
  import { Skeleton } from '@/components/shared/skeleton'
10
11
  import { EmptyState } from '@/components/shared/empty-state'
@@ -38,6 +39,7 @@ export function ChatList({ inSidebar, onSelect }: Props) {
38
39
  const [typeFilter, setTypeFilter] = useState<SessionFilter>('all')
39
40
  const [sortMode, setSortMode] = useState<SortMode>('lastActive')
40
41
  const [loaded, setLoaded] = useState(Object.keys(sessions).length > 0)
42
+ const [showLocalPlatformSessions, setShowLocalPlatformSessions] = useState(false)
41
43
  const [bulkMenuOpen, setBulkMenuOpen] = useState(false)
42
44
  const [confirmClearIds, setConfirmClearIds] = useState<string[] | null>(null)
43
45
  const [clearing, setClearing] = useState(false)
@@ -50,16 +52,15 @@ export function ChatList({ inSidebar, onSelect }: Props) {
50
52
  void loadConnectors()
51
53
  }, [loadConnectors])
52
54
 
55
+ useEffect(() => {
56
+ setShowLocalPlatformSessions(isLocalhostBrowser())
57
+ }, [])
58
+
53
59
  const allUserSessions = useMemo(() => {
54
- return Object.values(sessions).filter((s) => {
55
- const owner = (s.user || '').toLowerCase()
56
- const isPlatformOwned = owner === 'system' || owner === 'connector' || owner === 'swarm'
57
- const isCurrentUserOwned = !!currentUser && owner === currentUser.toLowerCase()
58
- const isUnownedLegacy = !owner
59
- if (!isCurrentUserOwned && !isPlatformOwned && !isUnownedLegacy) return false
60
- return true
61
- })
62
- }, [sessions, currentUser])
60
+ return Object.values(sessions).filter((s) => isVisibleSessionForViewer(s, currentUser, {
61
+ localhost: showLocalPlatformSessions,
62
+ }))
63
+ }, [sessions, currentUser, showLocalPlatformSessions])
63
64
 
64
65
  const filtered = useMemo(() => {
65
66
  return allUserSessions
@@ -6,6 +6,8 @@ import { useAppStore } from '@/stores/use-app-store'
6
6
  import { useChatStore } from '@/stores/use-chat-store'
7
7
  import { AgentAvatar } from '@/components/agents/agent-avatar'
8
8
  import { api } from '@/lib/api-client'
9
+ import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
10
+ import { getNotificationActivityAt, getNotificationOccurrenceCount } from '@/lib/notification-utils'
9
11
  import type { Agent, Session, ActivityEntry, BoardTask, AppNotification } from '@/types'
10
12
  import { HintTip } from '@/components/shared/hint-tip'
11
13
 
@@ -68,6 +70,7 @@ const PLATFORM_LABELS: Record<string, string> = {
68
70
  export function HomeView() {
69
71
  const agents = useAppStore((s) => s.agents)
70
72
  const sessions = useAppStore((s) => s.sessions)
73
+ const currentUser = useAppStore((s) => s.currentUser)
71
74
  const tasks = useAppStore((s) => s.tasks)
72
75
  const connectors = useAppStore((s) => s.connectors)
73
76
  const schedules = useAppStore((s) => s.schedules)
@@ -95,9 +98,10 @@ export function HomeView() {
95
98
  const recentChats = useMemo(
96
99
  () =>
97
100
  Object.values(sessions)
101
+ .filter((session) => isVisibleSessionForViewer(session, currentUser, { localhost: isLocalhostBrowser() }))
98
102
  .sort((a, b) => (b.lastActiveAt || 0) - (a.lastActiveAt || 0))
99
103
  .slice(0, 5),
100
- [sessions],
104
+ [currentUser, sessions],
101
105
  )
102
106
 
103
107
  // Quick stats
@@ -382,7 +386,14 @@ export function HomeView() {
382
386
  <span className="text-[13px] font-500 text-text">{n.title}</span>
383
387
  {n.message && <p className="text-[11px] text-text-3/60 truncate mt-0.5 m-0">{n.message}</p>}
384
388
  </div>
385
- <span className="text-[10px] text-text-3/40 shrink-0 mt-0.5">{timeAgo(n.createdAt)}</span>
389
+ <div className="flex items-center gap-1.5 shrink-0 mt-0.5">
390
+ {getNotificationOccurrenceCount(n) > 1 && (
391
+ <span className="rounded-full border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[9px] font-600 text-text-3/80">
392
+ x{getNotificationOccurrenceCount(n)}
393
+ </span>
394
+ )}
395
+ <span className="text-[10px] text-text-3/40">{timeAgo(getNotificationActivityAt(n))}</span>
396
+ </div>
386
397
  </button>
387
398
  ))}
388
399
  </div>
@@ -224,6 +224,7 @@ export function AppLayout() {
224
224
  actionUrl: GITHUB_REPO_URL,
225
225
  entityType: 'support',
226
226
  entityId: 'github-star',
227
+ dedupKey: 'support:github-star',
227
228
  }).then(() => {
228
229
  void useAppStore.getState().loadNotifications()
229
230
  }).catch(() => {})
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
+ import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
5
6
  import { toast } from 'sonner'
6
7
 
7
8
  interface CommandItem {
@@ -22,6 +23,7 @@ export function CommandPalette() {
22
23
 
23
24
  const agents = useAppStore((s) => s.agents)
24
25
  const sessions = useAppStore((s) => s.sessions)
26
+ const currentUser = useAppStore((s) => s.currentUser)
25
27
  const tasks = useAppStore((s) => s.tasks)
26
28
  const setCurrentAgent = useAppStore((s) => s.setCurrentAgent)
27
29
  const setCurrentSession = useAppStore((s) => s.setCurrentSession)
@@ -149,6 +151,7 @@ export function CommandPalette() {
149
151
 
150
152
  // Chats (sessions)
151
153
  for (const session of Object.values(sessions)) {
154
+ if (!isVisibleSessionForViewer(session, currentUser, { localhost: isLocalhostBrowser() })) continue
152
155
  const sessionAgent = session.agentId ? agents[session.agentId] : null
153
156
  result.push({
154
157
  id: `chat:${session.id}`,
@@ -174,7 +177,7 @@ export function CommandPalette() {
174
177
  }
175
178
 
176
179
  return result
177
- }, [agents, openSettingsSection, sessions, setActiveView, setCurrentAgent, setCurrentSession, setEditingTaskId, setTaskSheetOpen, tasks])
180
+ }, [agents, currentUser, openSettingsSection, sessions, setActiveView, setCurrentAgent, setCurrentSession, setEditingTaskId, setTaskSheetOpen, tasks])
178
181
 
179
182
  const filtered = useMemo(() => {
180
183
  if (!query.trim()) return items.slice(0, 20)
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
5
5
  import type { CSSProperties } from 'react'
6
6
  import { useAppStore } from '@/stores/use-app-store'
7
7
  import { useWs } from '@/hooks/use-ws'
8
+ import { getNotificationActivityAt, getNotificationOccurrenceCount } from '@/lib/notification-utils'
8
9
  import type { AppNotification } from '@/types'
9
10
 
10
11
  function timeAgo(ts: number): string {
@@ -207,7 +208,12 @@ export function NotificationCenter({
207
208
  <div className="flex-1 min-w-0">
208
209
  <div className="flex items-center gap-2">
209
210
  <span className="text-[12px] font-600 text-text truncate flex-1">{n.title}</span>
210
- <span className="text-[10px] text-text-3/50 shrink-0">{timeAgo(n.createdAt)}</span>
211
+ {getNotificationOccurrenceCount(n) > 1 && (
212
+ <span className="shrink-0 rounded-full border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[9px] font-600 text-text-3/80">
213
+ x{getNotificationOccurrenceCount(n)}
214
+ </span>
215
+ )}
216
+ <span className="text-[10px] text-text-3/50 shrink-0">{timeAgo(getNotificationActivityAt(n))}</span>
211
217
  </div>
212
218
  {n.message && (
213
219
  <p className="text-[11px] text-text-3 mt-0.5 leading-relaxed line-clamp-2 m-0">
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'
4
4
  import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
5
5
  import { useAppStore } from '@/stores/use-app-store'
6
6
  import { api } from '@/lib/api-client'
7
+ import { isLocalhostBrowser, isVisibleSessionForViewer } from '@/lib/local-observability'
7
8
  import type { AppView } from '@/types'
8
9
 
9
10
  interface SearchResult {
@@ -61,6 +62,8 @@ export function SearchDialog() {
61
62
  const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
62
63
  const listRef = useRef<HTMLDivElement>(null)
63
64
 
65
+ const sessions = useAppStore((s) => s.sessions)
66
+ const currentUser = useAppStore((s) => s.currentUser)
64
67
  const setActiveView = useAppStore((s) => s.setActiveView)
65
68
  const setSidebarOpen = useAppStore((s) => s.setSidebarOpen)
66
69
  const setEditingAgentId = useAppStore((s) => s.setEditingAgentId)
@@ -114,14 +117,19 @@ export function SearchDialog() {
114
117
  setLoading(true)
115
118
  try {
116
119
  const data = await api<{ results: SearchResult[] }>('GET', `/search?q=${encodeURIComponent(q)}`)
117
- setResults(data.results)
120
+ setResults(data.results.filter((result) => {
121
+ if (result.type !== 'session' && result.type !== 'message') return true
122
+ const session = sessions[result.id]
123
+ if (!session) return true
124
+ return isVisibleSessionForViewer(session, currentUser, { localhost: isLocalhostBrowser() })
125
+ }))
118
126
  setSelectedIdx(0)
119
127
  } catch {
120
128
  setResults([])
121
129
  } finally {
122
130
  setLoading(false)
123
131
  }
124
- }, [])
132
+ }, [currentUser, sessions])
125
133
 
126
134
  const handleQueryChange = (value: string) => {
127
135
  setQuery(value)
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict'
2
+ import { afterEach, describe, it } from 'node:test'
3
+
4
+ import type { Session } from '@/types'
5
+
6
+ import {
7
+ findLatestObservablePlatformSession,
8
+ isLocalhostBrowser,
9
+ isVisibleSessionForViewer,
10
+ } from './local-observability'
11
+
12
+ const originalWindow = globalThis.window
13
+
14
+ function makeSession(overrides: Partial<Session>): Session {
15
+ return {
16
+ id: overrides.id || 'session-test',
17
+ name: overrides.name || 'Test Session',
18
+ user: overrides.user || 'default',
19
+ messages: overrides.messages || [],
20
+ createdAt: overrides.createdAt || 1,
21
+ updatedAt: overrides.updatedAt || overrides.createdAt || 1,
22
+ lastActiveAt: overrides.lastActiveAt || overrides.updatedAt || overrides.createdAt || 1,
23
+ provider: overrides.provider || 'openai',
24
+ model: overrides.model || 'gpt-test',
25
+ ...overrides,
26
+ } as Session
27
+ }
28
+
29
+ afterEach(() => {
30
+ if (originalWindow === undefined) {
31
+ delete (globalThis as { window?: Window }).window
32
+ return
33
+ }
34
+ globalThis.window = originalWindow
35
+ })
36
+
37
+ describe('local observability', () => {
38
+ it('shows observable platform sessions only on localhost', () => {
39
+ const workbench = makeSession({ id: 'wb-1', user: 'workbench' })
40
+ const swarm = makeSession({ id: 'sw-1', user: 'swarm' })
41
+ const mine = makeSession({ id: 'me-1', user: 'wayde' })
42
+
43
+ assert.equal(isVisibleSessionForViewer(workbench, 'wayde', { localhost: false }), false)
44
+ assert.equal(isVisibleSessionForViewer(workbench, 'wayde', { localhost: true }), true)
45
+ assert.equal(isVisibleSessionForViewer(swarm, 'wayde', { localhost: false }), true)
46
+ assert.equal(isVisibleSessionForViewer(mine, 'wayde', { localhost: false }), true)
47
+ })
48
+
49
+ it('prefers the latest live observable platform session for an agent', () => {
50
+ const sessions: Record<string, Session> = {
51
+ old: makeSession({ id: 'old', agentId: 'agent-1', user: 'workbench', lastActiveAt: 100 }),
52
+ shortcut: makeSession({
53
+ id: 'shortcut',
54
+ agentId: 'agent-1',
55
+ user: 'workbench',
56
+ lastActiveAt: 500,
57
+ shortcutForAgentId: 'agent-1',
58
+ }),
59
+ latest: makeSession({ id: 'latest', agentId: 'agent-1', user: 'comparison-bench', lastActiveAt: 300 }),
60
+ otherAgent: makeSession({ id: 'other', agentId: 'agent-2', user: 'workbench', lastActiveAt: 999 }),
61
+ }
62
+
63
+ assert.equal(findLatestObservablePlatformSession(sessions, 'agent-1')?.id, 'latest')
64
+ })
65
+
66
+ it('detects localhost browser hosts', () => {
67
+ globalThis.window = { location: { hostname: 'localhost' } } as Window
68
+ assert.equal(isLocalhostBrowser(), true)
69
+
70
+ globalThis.window = { location: { hostname: 'swarmclaw.ai' } } as Window
71
+ assert.equal(isLocalhostBrowser(), false)
72
+ })
73
+ })
@@ -0,0 +1,47 @@
1
+ import type { Session } from '@/types'
2
+
3
+ const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0'])
4
+ const OBSERVABLE_PLATFORM_SESSION_OWNERS = new Set(['workbench', 'comparison-bench'])
5
+ const VISIBLE_NON_USER_SESSION_OWNERS = new Set(['system', 'connector', 'swarm'])
6
+
7
+ function normalizeHostname(hostname: string): string {
8
+ return hostname.trim().toLowerCase().replace(/^\[(.*)\]$/, '$1')
9
+ }
10
+
11
+ export function isLocalhostBrowser(): boolean {
12
+ if (typeof window === 'undefined') return false
13
+ return LOCALHOST_HOSTNAMES.has(normalizeHostname(window.location.hostname))
14
+ }
15
+
16
+ export function isObservablePlatformSessionOwner(user: string | null | undefined): boolean {
17
+ const normalized = typeof user === 'string' ? user.trim().toLowerCase() : ''
18
+ return OBSERVABLE_PLATFORM_SESSION_OWNERS.has(normalized)
19
+ }
20
+
21
+ export function isVisibleSessionForViewer(
22
+ session: Session,
23
+ currentUser: string | null | undefined,
24
+ options?: { localhost?: boolean },
25
+ ): boolean {
26
+ const owner = (session.user || '').trim().toLowerCase()
27
+ if (!owner) return true
28
+ if (currentUser && owner === currentUser.trim().toLowerCase()) return true
29
+ if (VISIBLE_NON_USER_SESSION_OWNERS.has(owner)) return true
30
+ return options?.localhost === true && isObservablePlatformSessionOwner(owner)
31
+ }
32
+
33
+ export function findLatestObservablePlatformSession(
34
+ sessions: Record<string, Session>,
35
+ agentId: string,
36
+ ): Session | null {
37
+ let latest: Session | null = null
38
+ for (const session of Object.values(sessions)) {
39
+ if (session.agentId !== agentId) continue
40
+ if (!isObservablePlatformSessionOwner(session.user)) continue
41
+ if (session.shortcutForAgentId) continue
42
+ if (!latest || (session.lastActiveAt || session.createdAt || 0) > (latest.lastActiveAt || latest.createdAt || 0)) {
43
+ latest = session
44
+ }
45
+ }
46
+ return latest
47
+ }
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import {
5
+ getNotificationActivityAt,
6
+ getNotificationOccurrenceCount,
7
+ upsertNotificationRecord,
8
+ } from './notification-utils'
9
+
10
+ describe('notification utils', () => {
11
+ it('creates fresh notifications with a count and activity timestamp', () => {
12
+ const { created, notification } = upsertNotificationRecord(
13
+ null,
14
+ {
15
+ type: 'info',
16
+ title: 'Provider unreachable',
17
+ message: 'Gateway timeout',
18
+ dedupKey: 'provider-down:test',
19
+ },
20
+ {
21
+ now: 1_700_000_000_000,
22
+ createId: () => 'notif_1',
23
+ },
24
+ )
25
+
26
+ assert.equal(created, true)
27
+ assert.equal(notification.id, 'notif_1')
28
+ assert.equal(notification.read, false)
29
+ assert.equal(notification.createdAt, 1_700_000_000_000)
30
+ assert.equal(notification.updatedAt, 1_700_000_000_000)
31
+ assert.equal(notification.occurrenceCount, 1)
32
+ })
33
+
34
+ it('refreshes an existing notification instead of duplicating it', () => {
35
+ const { created, notification } = upsertNotificationRecord(
36
+ {
37
+ id: 'notif_existing',
38
+ type: 'warning',
39
+ title: 'Provider unreachable',
40
+ message: 'Old failure',
41
+ dedupKey: 'provider-down:test',
42
+ read: true,
43
+ createdAt: 1_700_000_000_000,
44
+ },
45
+ {
46
+ type: 'warning',
47
+ title: 'Provider unreachable',
48
+ message: 'Still down',
49
+ dedupKey: 'provider-down:test',
50
+ },
51
+ {
52
+ now: 1_700_000_123_000,
53
+ createId: () => 'unused',
54
+ },
55
+ )
56
+
57
+ assert.equal(created, false)
58
+ assert.equal(notification.id, 'notif_existing')
59
+ assert.equal(notification.read, false)
60
+ assert.equal(notification.createdAt, 1_700_000_000_000)
61
+ assert.equal(notification.updatedAt, 1_700_000_123_000)
62
+ assert.equal(notification.occurrenceCount, 2)
63
+ assert.equal(notification.message, 'Still down')
64
+ })
65
+
66
+ it('prefers updatedAt when sorting and formatting activity', () => {
67
+ assert.equal(getNotificationActivityAt({ createdAt: 100, updatedAt: 250 }), 250)
68
+ assert.equal(getNotificationActivityAt({ createdAt: 100 }), 100)
69
+ assert.equal(getNotificationOccurrenceCount({ occurrenceCount: 4 }), 4)
70
+ assert.equal(getNotificationOccurrenceCount({ occurrenceCount: 0 }), 1)
71
+ })
72
+ })
@@ -0,0 +1,68 @@
1
+ import type { AppNotification } from '@/types'
2
+
3
+ export type NotificationDraft = Pick<
4
+ AppNotification,
5
+ 'type' | 'title' | 'message' | 'actionLabel' | 'actionUrl' | 'entityType' | 'entityId' | 'dedupKey'
6
+ >
7
+
8
+ export function getNotificationActivityAt(
9
+ notification: Pick<AppNotification, 'createdAt' | 'updatedAt'>,
10
+ ): number {
11
+ return typeof notification.updatedAt === 'number' ? notification.updatedAt : notification.createdAt
12
+ }
13
+
14
+ export function getNotificationOccurrenceCount(
15
+ notification: Pick<AppNotification, 'occurrenceCount'>,
16
+ ): number {
17
+ return typeof notification.occurrenceCount === 'number' && notification.occurrenceCount > 1
18
+ ? notification.occurrenceCount
19
+ : 1
20
+ }
21
+
22
+ export function upsertNotificationRecord(
23
+ existing: AppNotification | null | undefined,
24
+ draft: NotificationDraft,
25
+ options: {
26
+ now: number
27
+ createId: () => string
28
+ },
29
+ ): { notification: AppNotification; created: boolean } {
30
+ if (existing) {
31
+ return {
32
+ created: false,
33
+ notification: {
34
+ ...existing,
35
+ type: draft.type,
36
+ title: draft.title,
37
+ message: draft.message,
38
+ actionLabel: draft.actionLabel,
39
+ actionUrl: draft.actionUrl,
40
+ entityType: draft.entityType,
41
+ entityId: draft.entityId,
42
+ dedupKey: draft.dedupKey,
43
+ read: false,
44
+ updatedAt: options.now,
45
+ occurrenceCount: getNotificationOccurrenceCount(existing) + 1,
46
+ },
47
+ }
48
+ }
49
+
50
+ return {
51
+ created: true,
52
+ notification: {
53
+ id: options.createId(),
54
+ type: draft.type,
55
+ title: draft.title,
56
+ message: draft.message,
57
+ actionLabel: draft.actionLabel,
58
+ actionUrl: draft.actionUrl,
59
+ entityType: draft.entityType,
60
+ entityId: draft.entityId,
61
+ dedupKey: draft.dedupKey,
62
+ read: false,
63
+ createdAt: options.now,
64
+ updatedAt: options.now,
65
+ occurrenceCount: 1,
66
+ },
67
+ }
68
+ }
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { afterEach, test } from 'node:test'
3
3
 
4
- import { resolveGatewayAgentId } from './openclaw'
4
+ import { buildOpenClawSessionKey, resolveGatewayAgentId } from './openclaw'
5
5
  import { loadAgents, saveAgents } from '../server/storage'
6
6
  import type { Agent } from '@/types'
7
7
 
@@ -52,3 +52,23 @@ test('resolveGatewayAgentId falls back to the session name when no OpenClaw agen
52
52
 
53
53
  assert.equal(resolved, 'fallback-agent-name')
54
54
  })
55
+
56
+ test('buildOpenClawSessionKey namespaces sessions by agent and local session id', () => {
57
+ const sessionKey = buildOpenClawSessionKey({
58
+ id: 'cmp-session-1',
59
+ agentId: 'openclaw-agent-test',
60
+ name: 'Ignored Name',
61
+ }, 'Research Operator')
62
+
63
+ assert.equal(sessionKey, 'agent:research-operator:swarm:cmp-session-1')
64
+ })
65
+
66
+ test('buildOpenClawSessionKey honors explicit OpenClaw session keys when provided', () => {
67
+ const sessionKey = buildOpenClawSessionKey({
68
+ id: 'cmp-session-2',
69
+ name: 'Ignored Name',
70
+ openclawSessionKey: 'agent:ops:benchmark:fixed-key',
71
+ })
72
+
73
+ assert.equal(sessionKey, 'agent:ops:benchmark:fixed-key')
74
+ })
@@ -302,6 +302,25 @@ export function resolveGatewayAgentId(session: Record<string, unknown> & { id: s
302
302
  return 'main'
303
303
  }
304
304
 
305
+ export function buildOpenClawSessionKey(
306
+ session: Record<string, unknown> & { id: string },
307
+ gatewayAgentId?: string,
308
+ ): string {
309
+ const explicit = typeof (session as { openclawSessionKey?: unknown }).openclawSessionKey === 'string'
310
+ ? String((session as { openclawSessionKey?: unknown }).openclawSessionKey).trim()
311
+ : ''
312
+ if (explicit) return explicit
313
+
314
+ const sessionId = typeof session.id === 'string' && session.id.trim()
315
+ ? session.id.trim()
316
+ : randomUUID()
317
+ const agentId = typeof gatewayAgentId === 'string' && gatewayAgentId.trim()
318
+ ? normalizeOpenClawAgentId(gatewayAgentId)
319
+ : resolveGatewayAgentId(session)
320
+
321
+ return `agent:${agentId}:swarm:${sessionId}`
322
+ }
323
+
305
324
  async function rpcOnConnectedGateway(
306
325
  ws: InstanceType<typeof WebSocket>,
307
326
  method: string,
@@ -425,6 +444,7 @@ export function streamOpenClawChat({ session, message, imagePath, apiKey, write,
425
444
 
426
445
  const ws = result.ws
427
446
  const gatewayAgentId = await resolveConnectedGatewayAgentId(ws, session)
447
+ const sessionKey = buildOpenClawSessionKey(session, gatewayAgentId)
428
448
  const timeout = setTimeout(() => {
429
449
  ws.close()
430
450
  finish('OpenClaw gateway timed out after 120s.')
@@ -445,6 +465,8 @@ export function streamOpenClawChat({ session, message, imagePath, apiKey, write,
445
465
  params: {
446
466
  message: prompt,
447
467
  agentId: gatewayAgentId,
468
+ sessionKey,
469
+ label: typeof session.name === 'string' && session.name.trim() ? session.name.trim() : undefined,
448
470
  timeout: 120,
449
471
  idempotencyKey: randomUUID(),
450
472
  },