@swarmclawai/swarmclaw 0.8.2 → 0.8.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 (45) hide show
  1. package/README.md +8 -8
  2. package/package.json +2 -2
  3. package/src/app/api/agents/route.ts +6 -3
  4. package/src/app/api/auth/route.ts +20 -10
  5. package/src/app/api/chats/[id]/devserver/route.ts +74 -48
  6. package/src/app/api/chats/[id]/route.ts +16 -1
  7. package/src/app/api/chats/route.ts +14 -6
  8. package/src/app/api/daemon/route.ts +4 -3
  9. package/src/app/api/openclaw/approvals/route.ts +3 -3
  10. package/src/app/api/wallets/[id]/route.ts +18 -4
  11. package/src/app/page.tsx +19 -23
  12. package/src/cli/index.js +1 -1
  13. package/src/cli/spec.js +1 -1
  14. package/src/components/auth/access-key-gate.tsx +5 -3
  15. package/src/components/chat/chat-area.tsx +50 -29
  16. package/src/components/chat/chat-card.tsx +4 -7
  17. package/src/components/chat/chat-header.tsx +19 -13
  18. package/src/components/chat/chat-list.tsx +11 -9
  19. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  20. package/src/components/home/home-view.tsx +6 -2
  21. package/src/components/layout/app-layout.tsx +2 -3
  22. package/src/hooks/use-ws.ts +33 -7
  23. package/src/instrumentation.ts +21 -11
  24. package/src/lib/api-client.test.ts +49 -0
  25. package/src/lib/api-client.ts +53 -30
  26. package/src/lib/chats.ts +3 -0
  27. package/src/lib/runtime-env.test.ts +28 -0
  28. package/src/lib/runtime-env.ts +13 -0
  29. package/src/lib/server/chat-execution.ts +1 -1
  30. package/src/lib/server/connectors/manager.ts +4 -2
  31. package/src/lib/server/daemon-state.test.ts +23 -0
  32. package/src/lib/server/daemon-state.ts +34 -16
  33. package/src/lib/server/heartbeat-service.ts +61 -8
  34. package/src/lib/server/plugins.ts +12 -9
  35. package/src/lib/server/queue.ts +6 -1
  36. package/src/lib/server/storage.ts +100 -8
  37. package/src/lib/server/wallet-portfolio.ts +6 -0
  38. package/src/lib/session-summary.test.ts +49 -0
  39. package/src/lib/session-summary.ts +59 -0
  40. package/src/lib/ws-client.ts +1 -2
  41. package/src/proxy.test.ts +40 -0
  42. package/src/proxy.ts +23 -17
  43. package/src/stores/use-app-store.ts +66 -22
  44. package/src/stores/use-chat-store.ts +2 -2
  45. package/src/types/index.ts +4 -0
@@ -11,6 +11,58 @@ import { normalizeRuntimeSettingFields } from '@/lib/runtime-loop'
11
11
  import type { AppNotification, ExternalAgentRuntime, GatewayProfile, Message } from '@/types'
12
12
  export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
13
13
 
14
+ // --- TTL Cache (read-through with write-through invalidation) ---
15
+
16
+ interface TTLEntry<T> {
17
+ value: T
18
+ expiresAt: number
19
+ }
20
+
21
+ /**
22
+ * Simple TTL cache for hot-path reads that rarely change.
23
+ * Stored on globalThis so HMR doesn't reset it.
24
+ */
25
+ class TTLCache<T> {
26
+ private entry: TTLEntry<T> | null = null
27
+ constructor(private readonly ttlMs: number) {}
28
+
29
+ get(): T | undefined {
30
+ if (!this.entry) return undefined
31
+ if (Date.now() > this.entry.expiresAt) {
32
+ this.entry = null
33
+ return undefined
34
+ }
35
+ return this.entry.value
36
+ }
37
+
38
+ set(value: T): void {
39
+ this.entry = { value, expiresAt: Date.now() + this.ttlMs }
40
+ }
41
+
42
+ invalidate(): void {
43
+ this.entry = null
44
+ }
45
+ }
46
+
47
+ const ttlCacheKey = '__swarmclaw_ttl_caches__' as const
48
+ type TTLCacheStore = {
49
+ settings?: TTLCache<Record<string, unknown>>
50
+ credentials?: TTLCache<Record<string, unknown>>
51
+ connectors?: TTLCache<Record<string, unknown>>
52
+ gatewayProfiles?: TTLCache<Record<string, unknown>>
53
+ agents?: TTLCache<Record<string, unknown>>
54
+ }
55
+ type TTLGlobals = typeof globalThis & { [ttlCacheKey]?: TTLCacheStore }
56
+ const ttlGlobals = globalThis as TTLGlobals
57
+ const ttlCaches: TTLCacheStore = ttlGlobals[ttlCacheKey] ?? (ttlGlobals[ttlCacheKey] = {})
58
+
59
+ // Lazily initialize each cache with its TTL
60
+ function getSettingsCache() { return ttlCaches.settings ?? (ttlCaches.settings = new TTLCache(60_000)) }
61
+ function getCredentialsCache() { return ttlCaches.credentials ?? (ttlCaches.credentials = new TTLCache(90_000)) }
62
+ function getConnectorsCache() { return ttlCaches.connectors ?? (ttlCaches.connectors = new TTLCache(30_000)) }
63
+ function getGatewayProfilesCache() { return ttlCaches.gatewayProfiles ?? (ttlCaches.gatewayProfiles = new TTLCache(300_000)) }
64
+ function getAgentsCache() { return ttlCaches.agents ?? (ttlCaches.agents = new TTLCache(15_000)) }
65
+
14
66
  // --- LRU Cache ---
15
67
 
16
68
  const DEFAULT_LRU_CAPACITY = 5000
@@ -670,11 +722,18 @@ export function disableAllSessionHeartbeats(): number {
670
722
 
671
723
  // --- Credentials ---
672
724
  export function loadCredentials(): Record<string, any> {
673
- return loadCollection('credentials')
725
+ const cache = getCredentialsCache()
726
+ const cached = cache.get()
727
+ if (cached) return structuredClone(cached) as Record<string, unknown>
728
+
729
+ const result = loadCollection('credentials')
730
+ cache.set(result)
731
+ return result
674
732
  }
675
733
 
676
734
  export function saveCredentials(c: Record<string, any>) {
677
735
  saveCollection('credentials', c)
736
+ getCredentialsCache().invalidate()
678
737
  }
679
738
 
680
739
  export function encryptKey(plaintext: string): string {
@@ -716,14 +775,25 @@ function migrateAgentPlugins(agents: Record<string, Record<string, unknown>>): b
716
775
  }
717
776
 
718
777
  export function loadAgents(opts?: { includeTrashed?: boolean }): Record<string, any> {
778
+ // Cache the full (non-trashed) agent set; includeTrashed bypasses cache
779
+ if (opts?.includeTrashed) {
780
+ const all = loadCollection('agents')
781
+ if (migrateAgentPlugins(all)) saveCollection('agents', all)
782
+ return all
783
+ }
784
+
785
+ const cache = getAgentsCache()
786
+ const cached = cache.get()
787
+ if (cached) return structuredClone(cached) as Record<string, unknown>
788
+
719
789
  const all = loadCollection('agents')
720
790
  if (migrateAgentPlugins(all)) saveCollection('agents', all)
721
- if (opts?.includeTrashed) return all
722
791
  const result: Record<string, any> = {}
723
792
  for (const [id, agent] of Object.entries(all)) {
724
793
  if (!agent.trashedAt) result[id] = agent
725
794
  }
726
- return result
795
+ cache.set(result)
796
+ return structuredClone(result) as Record<string, unknown>
727
797
  }
728
798
 
729
799
  export function loadTrashedAgents(): Record<string, any> {
@@ -737,6 +807,7 @@ export function loadTrashedAgents(): Record<string, any> {
737
807
 
738
808
  export function saveAgents(p: Record<string, any>) {
739
809
  saveCollection('agents', p)
810
+ getAgentsCache().invalidate()
740
811
  }
741
812
 
742
813
  // --- Schedules ---
@@ -771,7 +842,7 @@ export function upsertTask(id: string, task: unknown) {
771
842
  }
772
843
  export function deleteTask(id: string) { deleteCollectionItem('tasks', id) }
773
844
  export function deleteSession(id: string) { deleteCollectionItem('sessions', id) }
774
- export function deleteAgent(id: string) { deleteCollectionItem('agents', id) }
845
+ export function deleteAgent(id: string) { deleteCollectionItem('agents', id); getAgentsCache().invalidate() }
775
846
  export function deleteSchedule(id: string) { deleteCollectionItem('schedules', id) }
776
847
  export function deleteSkill(id: string) { deleteCollectionItem('skills', id) }
777
848
 
@@ -798,7 +869,7 @@ type PersistedSettingsRecord = Record<string, any> & {
798
869
  }
799
870
 
800
871
  function cloneRecord<T extends Record<string, any>>(value: T): T {
801
- return JSON.parse(JSON.stringify(value || {})) as T
872
+ return structuredClone(value || {}) as T
802
873
  }
803
874
 
804
875
  function isPlainRecord(value: unknown): value is Record<string, any> {
@@ -870,17 +941,24 @@ function resolveSettingsSecrets(settings: PersistedSettingsRecord): Record<strin
870
941
  }
871
942
 
872
943
  export function loadSettings(): Record<string, any> {
944
+ const cache = getSettingsCache()
945
+ const cached = cache.get()
946
+ if (cached) return structuredClone(cached) as Record<string, unknown>
947
+
873
948
  const persisted = loadSingleton('settings', {}) as PersistedSettingsRecord
874
949
  const normalized = buildPersistedSettings(persisted, persisted)
875
950
  if (JSON.stringify(persisted) !== JSON.stringify(normalized)) {
876
951
  saveSingleton('settings', normalized)
877
952
  }
878
- return resolveSettingsSecrets(normalized)
953
+ const resolved = resolveSettingsSecrets(normalized)
954
+ cache.set(resolved)
955
+ return structuredClone(resolved) as Record<string, unknown>
879
956
  }
880
957
 
881
958
  export function saveSettings(s: Record<string, any>) {
882
959
  const existing = loadSingleton('settings', {}) as PersistedSettingsRecord
883
960
  saveSingleton('settings', buildPersistedSettings(s, existing))
961
+ getSettingsCache().invalidate()
884
962
  }
885
963
 
886
964
  export function loadPublicSettings(): Record<string, any> {
@@ -966,11 +1044,18 @@ export function saveProviderConfigs(p: Record<string, any>) {
966
1044
 
967
1045
  // --- Gateway Profiles ---
968
1046
  export function loadGatewayProfiles(): Record<string, any> {
969
- return loadCollection('gateway_profiles') as Record<string, GatewayProfile>
1047
+ const cache = getGatewayProfilesCache()
1048
+ const cached = cache.get()
1049
+ if (cached) return structuredClone(cached) as Record<string, unknown>
1050
+
1051
+ const result = loadCollection('gateway_profiles') as Record<string, GatewayProfile>
1052
+ cache.set(result)
1053
+ return result
970
1054
  }
971
1055
 
972
1056
  export function saveGatewayProfiles(g: Record<string, GatewayProfile>) {
973
1057
  saveCollection('gateway_profiles', g)
1058
+ getGatewayProfilesCache().invalidate()
974
1059
  }
975
1060
 
976
1061
  // --- Model Overrides (user-added models for built-in providers) ---
@@ -1044,11 +1129,18 @@ export function appendUsage(sessionId: string, record: unknown) {
1044
1129
 
1045
1130
  // --- Connectors ---
1046
1131
  export function loadConnectors(): Record<string, any> {
1047
- return loadCollection('connectors')
1132
+ const cache = getConnectorsCache()
1133
+ const cached = cache.get()
1134
+ if (cached) return structuredClone(cached) as Record<string, unknown>
1135
+
1136
+ const result = loadCollection('connectors')
1137
+ cache.set(result)
1138
+ return result
1048
1139
  }
1049
1140
 
1050
1141
  export function saveConnectors(c: Record<string, any>) {
1051
1142
  saveCollection('connectors', c)
1143
+ getConnectorsCache().invalidate()
1052
1144
  }
1053
1145
 
1054
1146
  // --- Chatrooms ---
@@ -317,6 +317,12 @@ function getLatestCachedPortfolioEntry(walletId: string): WalletPortfolioCacheEn
317
317
  return latest
318
318
  }
319
319
 
320
+ export function getCachedWalletPortfolio(wallet: Pick<AgentWallet, 'id' | 'updatedAt'>): WalletPortfolio | null {
321
+ return getCurrentCachedPortfolioEntry(wallet)?.portfolio
322
+ || getLatestCachedPortfolioEntry(wallet.id)?.portfolio
323
+ || null
324
+ }
325
+
320
326
  async function withWalletPortfolioTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
321
327
  let timer: ReturnType<typeof setTimeout> | null = null
322
328
  try {
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import type { Session } from '@/types'
5
+ import {
6
+ buildSessionListSummary,
7
+ getSessionLastAssistantAt,
8
+ getSessionLastMessage,
9
+ getSessionMessageCount,
10
+ } from './session-summary'
11
+
12
+ function makeSession(): Session {
13
+ return {
14
+ id: 'session-1',
15
+ name: 'Test Session',
16
+ cwd: '/tmp',
17
+ user: 'default',
18
+ provider: 'openai',
19
+ model: 'gpt-4.1',
20
+ claudeSessionId: null,
21
+ messages: [
22
+ { role: 'user', text: 'hello', time: 1 },
23
+ { role: 'assistant', text: 'world', time: 2, toolEvents: [{ name: 'shell', input: '{}', output: 'ok' }] },
24
+ ],
25
+ createdAt: 1,
26
+ lastActiveAt: 2,
27
+ }
28
+ }
29
+
30
+ describe('session summary helpers', () => {
31
+ it('builds lightweight list summaries without full message history', () => {
32
+ const session = makeSession()
33
+ const summary = buildSessionListSummary(session)
34
+
35
+ assert.equal(summary.messages.length, 0)
36
+ assert.equal(summary.messageCount, 2)
37
+ assert.equal(summary.lastAssistantAt, 2)
38
+ assert.equal(summary.lastMessageSummary?.text, 'world')
39
+ assert.equal(summary.lastMessageSummary?.toolEvents, undefined)
40
+ })
41
+
42
+ it('reads summary metadata when available', () => {
43
+ const summary = buildSessionListSummary(makeSession())
44
+
45
+ assert.equal(getSessionMessageCount(summary), 2)
46
+ assert.equal(getSessionLastAssistantAt(summary), 2)
47
+ assert.equal(getSessionLastMessage(summary)?.text, 'world')
48
+ })
49
+ })
@@ -0,0 +1,59 @@
1
+ import type { Message, Session } from '@/types'
2
+
3
+ const MAX_SESSION_SUMMARY_TEXT = 280
4
+
5
+ type SessionSummaryLike = Pick<Session, 'messages'> & Partial<Pick<Session, 'messageCount' | 'lastMessageSummary' | 'lastAssistantAt'>>
6
+
7
+ export function getSessionMessageCount(session: SessionSummaryLike): number {
8
+ if (typeof session.messageCount === 'number' && Number.isFinite(session.messageCount)) {
9
+ return session.messageCount
10
+ }
11
+ return Array.isArray(session.messages) ? session.messages.length : 0
12
+ }
13
+
14
+ export function getSessionLastMessage(session: SessionSummaryLike): Message | null {
15
+ if (session.lastMessageSummary) return session.lastMessageSummary
16
+ return Array.isArray(session.messages) && session.messages.length > 0
17
+ ? session.messages[session.messages.length - 1]
18
+ : null
19
+ }
20
+
21
+ export function getSessionLastAssistantAt(session: SessionSummaryLike): number | null {
22
+ if (typeof session.lastAssistantAt === 'number' && Number.isFinite(session.lastAssistantAt)) {
23
+ return session.lastAssistantAt
24
+ }
25
+ if (!Array.isArray(session.messages)) return null
26
+ for (let index = session.messages.length - 1; index >= 0; index--) {
27
+ const message = session.messages[index]
28
+ if (message?.role === 'assistant' && typeof message.time === 'number') {
29
+ return message.time
30
+ }
31
+ }
32
+ return null
33
+ }
34
+
35
+ function summarizeMessage(message: Message | null): Message | null {
36
+ if (!message) return null
37
+ return {
38
+ role: message.role,
39
+ text: typeof message.text === 'string'
40
+ ? message.text.slice(0, MAX_SESSION_SUMMARY_TEXT)
41
+ : '',
42
+ time: message.time,
43
+ kind: message.kind,
44
+ source: message.source,
45
+ suppressed: message.suppressed,
46
+ streaming: message.streaming,
47
+ bookmarked: message.bookmarked,
48
+ }
49
+ }
50
+
51
+ export function buildSessionListSummary(session: Session): Session {
52
+ return {
53
+ ...session,
54
+ messages: [],
55
+ messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
56
+ lastAssistantAt: getSessionLastAssistantAt(session),
57
+ lastMessageSummary: summarizeMessage(getSessionLastMessage(session)),
58
+ }
59
+ }
@@ -84,8 +84,7 @@ function connect() {
84
84
  }
85
85
  }
86
86
 
87
- export function connectWs(key: string) {
88
- void key
87
+ export function connectWs() {
89
88
  wsEnabled = true
90
89
  reconnectDelay = 1000
91
90
  connect()
package/src/proxy.test.ts CHANGED
@@ -28,4 +28,44 @@ describe('proxy', () => {
28
28
  assert.equal(response.headers.get('access-control-allow-origin'), 'https://swarmclaw.ai')
29
29
  assert.equal(response.headers.get('vary'), 'Origin')
30
30
  })
31
+
32
+ it('prefers the auth cookie over a stale access-key header', () => {
33
+ process.env.ACCESS_KEY = 'top-secret'
34
+
35
+ const request = new NextRequest('http://localhost/api/agents', {
36
+ headers: {
37
+ cookie: 'sc_auth=top-secret',
38
+ 'x-access-key': 'stale-key',
39
+ },
40
+ })
41
+
42
+ const response = proxy(request)
43
+ assert.equal(response.status, 200)
44
+ })
45
+
46
+ it('does not lock out invalid requests in development', () => {
47
+ process.env.ACCESS_KEY = 'top-secret'
48
+ const originalNodeEnv = process.env.NODE_ENV
49
+ process.env.NODE_ENV = 'development'
50
+
51
+ try {
52
+ for (let i = 0; i < 6; i++) {
53
+ const response = proxy(new NextRequest('http://localhost/api/agents', {
54
+ headers: {
55
+ 'x-access-key': 'bad-key',
56
+ },
57
+ }))
58
+ assert.equal(response.status, 401)
59
+ }
60
+ const finalResponse = proxy(new NextRequest('http://localhost/api/agents', {
61
+ headers: {
62
+ 'x-access-key': 'bad-key',
63
+ },
64
+ }))
65
+ assert.equal(finalResponse.status, 401)
66
+ } finally {
67
+ if (originalNodeEnv === undefined) delete process.env.NODE_ENV
68
+ else process.env.NODE_ENV = originalNodeEnv
69
+ }
70
+ })
31
71
  })
package/src/proxy.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  isPluginInstallCorsPath,
7
7
  resolvePluginInstallCorsOrigin,
8
8
  } from '@/lib/plugin-install-cors'
9
+ import { isProductionRuntime } from '@/lib/runtime-env'
9
10
 
10
11
  /* ------------------------------------------------------------------ */
11
12
  /* Rate-limit state — HMR-safe via globalThis */
@@ -24,6 +25,10 @@ const MAX_ATTEMPTS = 5
24
25
  const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
25
26
  const PRUNE_THRESHOLD = 1000
26
27
 
28
+ function isRateLimitEnabled(): boolean {
29
+ return isProductionRuntime()
30
+ }
31
+
27
32
  /** Prune expired entries when the map grows too large. */
28
33
  function pruneRateLimitMap() {
29
34
  if (rateLimitMap.size <= PRUNE_THRESHOLD) return
@@ -65,6 +70,7 @@ function withPluginInstallCorsHeaders(pathname: string, origin: string | null, h
65
70
  * After 5 failed attempts from a single IP the client is locked out for 15 minutes.
66
71
  */
67
72
  export function proxy(request: NextRequest) {
73
+ const rateLimitEnabled = isRateLimitEnabled()
68
74
  const { pathname } = request.nextUrl
69
75
  const corsOrigin = resolvePluginInstallCorsOrigin(request.headers.get('origin'))
70
76
  const isWebhookTrigger = request.method === 'POST'
@@ -99,13 +105,13 @@ export function proxy(request: NextRequest) {
99
105
  }
100
106
 
101
107
  // --- Rate-limit housekeeping ---
102
- pruneRateLimitMap()
108
+ if (rateLimitEnabled) pruneRateLimitMap()
103
109
 
104
110
  const clientIp = getClientIp(request)
105
- const entry = rateLimitMap.get(clientIp)
111
+ const entry = rateLimitEnabled ? rateLimitMap.get(clientIp) : undefined
106
112
 
107
113
  // Check lockout before even validating the key
108
- if (entry && entry.lockedUntil > Date.now()) {
114
+ if (rateLimitEnabled && entry && entry.lockedUntil > Date.now()) {
109
115
  const retryAfter = Math.ceil((entry.lockedUntil - Date.now()) / 1000)
110
116
  return NextResponse.json(
111
117
  { error: 'Too many failed attempts. Try again later.', retryAfter },
@@ -116,23 +122,23 @@ export function proxy(request: NextRequest) {
116
122
  )
117
123
  }
118
124
 
119
- const providedKey =
120
- request.headers.get('x-access-key')?.trim()
121
- || request.cookies.get(AUTH_COOKIE_NAME)?.value?.trim()
122
- || ''
125
+ const cookieKey = request.cookies.get(AUTH_COOKIE_NAME)?.value?.trim() || ''
126
+ const headerKey = request.headers.get('x-access-key')?.trim() || ''
127
+ const providedKey = cookieKey || headerKey
123
128
 
124
129
  if (providedKey !== accessKey) {
125
- // Record the failed attempt
126
- const current = rateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 }
127
- current.count += 1
130
+ let remaining = MAX_ATTEMPTS
131
+ if (rateLimitEnabled) {
132
+ const current = rateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 }
133
+ current.count += 1
128
134
 
129
- if (current.count >= MAX_ATTEMPTS) {
130
- current.lockedUntil = Date.now() + LOCKOUT_MS
131
- }
135
+ if (current.count >= MAX_ATTEMPTS) {
136
+ current.lockedUntil = Date.now() + LOCKOUT_MS
137
+ }
132
138
 
133
- rateLimitMap.set(clientIp, current)
134
-
135
- const remaining = Math.max(0, MAX_ATTEMPTS - current.count)
139
+ rateLimitMap.set(clientIp, current)
140
+ remaining = Math.max(0, MAX_ATTEMPTS - current.count)
141
+ }
136
142
  return NextResponse.json(
137
143
  { error: 'Unauthorized' },
138
144
  {
@@ -143,7 +149,7 @@ export function proxy(request: NextRequest) {
143
149
  }
144
150
 
145
151
  // Successful auth — clear any prior failed-attempt tracking for this IP
146
- if (entry) {
152
+ if (rateLimitEnabled && entry) {
147
153
  rateLimitMap.delete(clientIp)
148
154
  }
149
155
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { create } from 'zustand'
4
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, ApprovalRequest, GatewayProfile, ExternalAgentRuntime } from '../types'
5
- import { fetchChats, fetchDirs, fetchProviders, fetchCredentials } from '../lib/chats'
5
+ import { fetchChat, fetchChats, fetchDirs, fetchProviders, fetchCredentials } from '../lib/chats'
6
6
  import { fetchAgents } from '../lib/agents'
7
7
  import { fetchSchedules } from '../lib/schedules'
8
8
  import { fetchTasks } from '../lib/tasks'
@@ -10,6 +10,9 @@ import { findLatestObservablePlatformSession, isLocalhostBrowser } from '../lib/
10
10
  import { api } from '../lib/api-client'
11
11
  import { safeStorageGet, safeStorageGetJson, safeStorageRemove, safeStorageSet } from '../lib/safe-storage'
12
12
 
13
+ const inflightAgentThreadLoads = new Map<string, Promise<void>>()
14
+ const inflightSessionRefreshes = new Map<string, Promise<void>>()
15
+
13
16
  interface AppState {
14
17
  currentUser: string | null
15
18
  _hydrated: boolean
@@ -19,6 +22,7 @@ interface AppState {
19
22
  sessions: Sessions
20
23
  currentSessionId: string | null
21
24
  loadSessions: () => Promise<void>
25
+ refreshSession: (id: string) => Promise<void>
22
26
  setCurrentSession: (id: string | null) => void
23
27
  removeSession: (id: string) => void
24
28
  clearSessions: (ids: string[]) => Promise<void>
@@ -257,6 +261,36 @@ export const useAppStore = create<AppState>((set, get) => ({
257
261
  // ignore
258
262
  }
259
263
  },
264
+ refreshSession: async (id) => {
265
+ if (!id) return
266
+ const existing = inflightSessionRefreshes.get(id)
267
+ if (existing) {
268
+ await existing
269
+ return
270
+ }
271
+
272
+ const refreshPromise = (async () => {
273
+ try {
274
+ const session = await fetchChat(id)
275
+ const currentSessionId = get().currentSessionId
276
+ set({
277
+ sessions: { ...get().sessions, [id]: session },
278
+ currentSessionId: currentSessionId && currentSessionId === id ? id : currentSessionId,
279
+ })
280
+ } catch {
281
+ // ignore
282
+ }
283
+ })()
284
+
285
+ inflightSessionRefreshes.set(id, refreshPromise)
286
+ try {
287
+ await refreshPromise
288
+ } finally {
289
+ if (inflightSessionRefreshes.get(id) === refreshPromise) {
290
+ inflightSessionRefreshes.delete(id)
291
+ }
292
+ }
293
+ },
260
294
  setCurrentSession: (id) => set({ currentSessionId: id }),
261
295
  removeSession: (id) => {
262
296
  const sessions = { ...get().sessions }
@@ -351,37 +385,47 @@ export const useAppStore = create<AppState>((set, get) => ({
351
385
  safeStorageRemove('sc_agent')
352
386
  return
353
387
  }
388
+ const currentState = get()
389
+ const currentSession = currentState.currentSessionId ? currentState.sessions[currentState.currentSessionId] : null
390
+ if (currentState.currentAgentId === id && currentSession?.agentId === id) {
391
+ return
392
+ }
354
393
  set({ currentAgentId: id })
355
394
  safeStorageSet('sc_agent', id)
356
395
  if (isLocalhostBrowser()) {
357
- let livePlatformSession = findLatestObservablePlatformSession(get().sessions, id)
358
- if (!livePlatformSession) {
359
- try {
360
- const refreshedSessions = await fetchChats()
361
- const currentSessionId = get().currentSessionId
362
- set({
363
- sessions: refreshedSessions,
364
- currentSessionId: currentSessionId && refreshedSessions[currentSessionId] ? currentSessionId : null,
365
- })
366
- livePlatformSession = findLatestObservablePlatformSession(refreshedSessions, id)
367
- } catch {
368
- // ignore and fall back to the normal thread path below
369
- }
370
- }
396
+ const livePlatformSession = findLatestObservablePlatformSession(get().sessions, id)
371
397
  if (livePlatformSession?.id) {
372
398
  set({ currentSessionId: livePlatformSession.id })
373
399
  return
374
400
  }
375
401
  }
402
+
403
+ const existingLoad = inflightAgentThreadLoads.get(id)
404
+ if (existingLoad) {
405
+ await existingLoad
406
+ return
407
+ }
408
+
409
+ const loadPromise = (async () => {
410
+ try {
411
+ const user = get().currentUser || 'default'
412
+ const session = await api<Session>('POST', `/agents/${id}/thread`, { user })
413
+ if (session?.id) {
414
+ const sessions = { ...get().sessions, [session.id]: session }
415
+ set({ sessions, currentSessionId: session.id })
416
+ }
417
+ } catch {
418
+ // ignore — thread creation failed
419
+ }
420
+ })()
421
+
422
+ inflightAgentThreadLoads.set(id, loadPromise)
376
423
  try {
377
- const user = get().currentUser || 'default'
378
- const session = await api<Session>('POST', `/agents/${id}/thread`, { user })
379
- if (session?.id) {
380
- const sessions = { ...get().sessions, [session.id]: session }
381
- set({ sessions, currentSessionId: session.id })
424
+ await loadPromise
425
+ } finally {
426
+ if (inflightAgentThreadLoads.get(id) === loadPromise) {
427
+ inflightAgentThreadLoads.delete(id)
382
428
  }
383
- } catch {
384
- // ignore — thread creation failed
385
429
  }
386
430
  },
387
431
 
@@ -431,7 +431,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
431
431
  set({ streaming: false, streamingSessionId: null, streamText: '', displayText: '', streamPhase: 'thinking' as const, streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
432
432
  }
433
433
 
434
- useAppStore.getState().loadSessions()
434
+ void useAppStore.getState().refreshSession(sessionId)
435
435
 
436
436
  // Auto-dequeue: if there are queued messages, send the next one
437
437
  const nextQueued = get().shiftQueuedMessage()
@@ -579,7 +579,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
579
579
  }
580
580
 
581
581
  set((s) => ({ messages: [...s.messages, assistantMsg] }))
582
- useAppStore.getState().loadSessions()
582
+ void useAppStore.getState().refreshSession(sessionId)
583
583
  },
584
584
 
585
585
  clearContext: async () => {
@@ -156,6 +156,10 @@ export interface Session {
156
156
  gemini?: string | null
157
157
  }
158
158
  messages: Message[]
159
+ /** Lightweight summary fields used by list/index APIs to avoid shipping full transcripts. */
160
+ messageCount?: number
161
+ lastMessageSummary?: Message | null
162
+ lastAssistantAt?: number | null
159
163
  createdAt: number
160
164
  updatedAt?: number | null
161
165
  lastActiveAt: number