@swarmclawai/swarmclaw 0.8.2 → 0.8.4

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
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.2 curl ... | bash`
151
+ To pin a version: `SWARMCLAW_VERSION=v0.8.4 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.2 style)
704
+ # example minor release (v0.8.4 style)
705
705
  npm version minor
706
706
  git push origin main --follow-tags
707
707
  ```
@@ -711,14 +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.2 Release Readiness Notes
714
+ #### v0.8.4 Release Readiness Notes
715
715
 
716
- Before shipping `v0.8.2`, confirm the following user-facing changes are reflected in docs:
716
+ Before shipping `v0.8.4`, confirm the following user-facing changes are reflected in docs:
717
717
 
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.
718
+ 1. Connector/runtime docs note that the current patch line includes the connector-manager follow-up fix already shipped in the latest app commit.
719
+ 2. Chat/session docs still note that the chat index serves lightweight session summaries instead of full transcript payloads, and full messages are loaded from per-chat endpoints.
720
+ 3. Operator/runtime docs still note that the daemon owns scheduler/queue startup; background services should be described from the daemon controls rather than as unconditional boot behavior.
721
+ 4. Site and README install/version strings are updated to `v0.8.4`, including install snippets, release notes index text, and sidebar/footer labels.
722
722
 
723
723
  ## CLI
724
724
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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": {
@@ -42,7 +42,7 @@
42
42
  "quickstart": "node ./scripts/easy-setup.mjs --start",
43
43
  "quickstart:prod": "node ./scripts/easy-setup.mjs --prod",
44
44
  "update:easy": "node ./scripts/easy-update.mjs",
45
- "dev": "next dev --hostname 0.0.0.0 -p 3456",
45
+ "dev": "next dev --webpack --hostname 0.0.0.0 -p 3456",
46
46
  "dev:webpack": "next dev --webpack --hostname 0.0.0.0 -p 3456",
47
47
  "dev:clean": "rm -rf .next && next dev --hostname 0.0.0.0 -p 3456",
48
48
  "build": "next build",
@@ -3,15 +3,18 @@ import { genId } from '@/lib/id'
3
3
  import { loadAgents, loadSessions, loadUsage, saveAgents, logActivity } from '@/lib/server/storage'
4
4
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
5
5
  import { notify } from '@/lib/server/ws-hub'
6
- import { ensureDaemonStarted } from '@/lib/server/daemon-state'
7
6
  import { getAgentSpendWindows } from '@/lib/server/cost'
8
7
  import { AgentCreateSchema, formatZodError } from '@/lib/validation/schemas'
9
8
  import { z } from 'zod'
10
9
  export const dynamic = 'force-dynamic'
11
10
 
11
+ async function ensureDaemonIfNeeded(source: string) {
12
+ const { ensureDaemonStarted } = await import('@/lib/server/daemon-state')
13
+ ensureDaemonStarted(source)
14
+ }
15
+
12
16
 
13
17
  export async function GET(req: Request) {
14
- ensureDaemonStarted('api/agents:get')
15
18
  const agents = loadAgents()
16
19
  const sessions = loadSessions()
17
20
  const usage = loadUsage()
@@ -44,7 +47,7 @@ export async function GET(req: Request) {
44
47
  }
45
48
 
46
49
  export async function POST(req: Request) {
47
- ensureDaemonStarted('api/agents:post')
50
+ await ensureDaemonIfNeeded('api/agents:post')
48
51
  const raw = await req.json()
49
52
  const parsed = AgentCreateSchema.safeParse(raw)
50
53
  if (!parsed.success) {
@@ -1,7 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { validateAccessKey, isFirstTimeSetup, markSetupComplete } from '@/lib/server/storage'
3
- import { ensureDaemonStarted } from '@/lib/server/daemon-state'
4
3
  import { AUTH_COOKIE_NAME, getCookieValue } from '@/lib/auth'
4
+ import { isProductionRuntime } from '@/lib/runtime-env'
5
5
  export const dynamic = 'force-dynamic'
6
6
 
7
7
  interface AuthAttemptEntry {
@@ -16,6 +16,10 @@ const authRateLimitMap = (
16
16
  const MAX_ATTEMPTS = 5
17
17
  const LOCKOUT_MS = 15 * 60 * 1000
18
18
 
19
+ function isRateLimitEnabled(): boolean {
20
+ return isProductionRuntime()
21
+ }
22
+
19
23
  function getClientIp(req: Request): string {
20
24
  const forwarded = req.headers.get('x-forwarded-for')
21
25
  if (forwarded) {
@@ -59,9 +63,10 @@ export async function GET(req: Request) {
59
63
 
60
64
  /** POST /api/auth — validate an access key */
61
65
  export async function POST(req: Request) {
66
+ const rateLimitEnabled = isRateLimitEnabled()
62
67
  const clientIp = getClientIp(req)
63
- const entry = authRateLimitMap.get(clientIp)
64
- if (entry && entry.lockedUntil > Date.now()) {
68
+ const entry = rateLimitEnabled ? authRateLimitMap.get(clientIp) : undefined
69
+ if (rateLimitEnabled && entry && entry.lockedUntil > Date.now()) {
65
70
  const retryAfter = Math.ceil((entry.lockedUntil - Date.now()) / 1000)
66
71
  return clearAuthCookie(NextResponse.json(
67
72
  { error: 'Too many failed attempts. Try again later.', retryAfter },
@@ -71,26 +76,31 @@ export async function POST(req: Request) {
71
76
 
72
77
  const { key } = await req.json()
73
78
  if (!key || !validateAccessKey(key)) {
74
- const current = authRateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 }
75
- current.count += 1
76
- if (current.count >= MAX_ATTEMPTS) {
77
- current.lockedUntil = Date.now() + LOCKOUT_MS
79
+ let remaining = MAX_ATTEMPTS
80
+ if (rateLimitEnabled) {
81
+ const current = authRateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 }
82
+ current.count += 1
83
+ if (current.count >= MAX_ATTEMPTS) {
84
+ current.lockedUntil = Date.now() + LOCKOUT_MS
85
+ }
86
+ authRateLimitMap.set(clientIp, current)
87
+ remaining = Math.max(0, MAX_ATTEMPTS - current.count)
78
88
  }
79
- authRateLimitMap.set(clientIp, current)
80
89
  return clearAuthCookie(NextResponse.json(
81
90
  { error: 'Invalid access key' },
82
91
  {
83
92
  status: 401,
84
- headers: { 'X-RateLimit-Remaining': String(Math.max(0, MAX_ATTEMPTS - current.count)) },
93
+ headers: { 'X-RateLimit-Remaining': String(remaining) },
85
94
  },
86
95
  ))
87
96
  }
88
97
 
89
- authRateLimitMap.delete(clientIp)
98
+ if (rateLimitEnabled) authRateLimitMap.delete(clientIp)
90
99
  // If this was first-time setup, mark it as claimed
91
100
  if (isFirstTimeSetup()) {
92
101
  markSetupComplete()
93
102
  }
103
+ const { ensureDaemonStarted } = await import('@/lib/server/daemon-state')
94
104
  ensureDaemonStarted('api/auth:post')
95
105
  return setAuthCookie(NextResponse.json({ ok: true }), req, key)
96
106
  }
@@ -5,6 +5,13 @@ import { notFound } from '@/lib/server/collection-helpers'
5
5
  import { resolveDevServerLaunchDir } from '@/lib/server/devserver-launch'
6
6
  import net from 'net'
7
7
 
8
+ interface DevServerStartResult {
9
+ status?: number
10
+ body: Record<string, unknown>
11
+ }
12
+
13
+ const inflightDevServerStarts = new Map<string, Promise<DevServerStartResult>>()
14
+
8
15
  function findFreePort(): Promise<number> {
9
16
  return new Promise((resolve, reject) => {
10
17
  const server = net.createServer()
@@ -24,73 +31,92 @@ function buildDevArgs(framework: string, port: number): string[] {
24
31
  return ['--', '--host', '0.0.0.0', '--port', String(port)]
25
32
  }
26
33
 
27
- export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
28
- const { id } = await params
29
- const sessions = loadSessions()
30
- const session = sessions[id]
31
- if (!session) return notFound()
32
-
33
- const { action } = await req.json()
34
-
35
- if (action === 'start') {
36
- if (devServers.has(id)) {
37
- const ds = devServers.get(id)!
38
- return NextResponse.json({ running: true, url: ds.url })
39
- }
34
+ async function startDevServer(id: string, session: { cwd: string }): Promise<DevServerStartResult> {
35
+ const launch = resolveDevServerLaunchDir(session.cwd)
36
+ const port = await findFreePort()
37
+ const proc = spawn('npm', ['run', 'dev', ...buildDevArgs(launch.framework, port)], {
38
+ cwd: launch.launchDir,
39
+ stdio: ['ignore', 'pipe', 'pipe'],
40
+ env: { ...process.env, FORCE_COLOR: '0', PORT: String(port) },
41
+ })
40
42
 
41
- const launch = resolveDevServerLaunchDir(session.cwd)
42
- const port = await findFreePort()
43
- const proc = spawn('npm', ['run', 'dev', ...buildDevArgs(launch.framework, port)], {
44
- cwd: launch.launchDir,
45
- stdio: ['ignore', 'pipe', 'pipe'],
46
- env: { ...process.env, FORCE_COLOR: '0', PORT: String(port) },
47
- })
43
+ let output = ''
44
+ let detectedUrl: string | null = null
45
+ const urlRe = /https?:\/\/(?:localhost|0\.0\.0\.0|[\d.]+):(\d+)/
48
46
 
49
- let output = ''
50
- let detectedUrl: string | null = null
51
- const urlRe = /https?:\/\/(?:localhost|0\.0\.0\.0|[\d.]+):(\d+)/
52
-
53
- function onData(chunk: Buffer) {
54
- output += chunk.toString()
55
- if (!detectedUrl) {
56
- const match = output.match(urlRe)
57
- if (match) {
58
- const port = match[1]
59
- detectedUrl = `http://${localIP()}:${port}`
60
- const ds = devServers.get(id)
61
- if (ds) ds.url = detectedUrl
62
- }
47
+ function onData(chunk: Buffer) {
48
+ output += chunk.toString()
49
+ if (!detectedUrl) {
50
+ const match = output.match(urlRe)
51
+ if (match) {
52
+ const detectedPort = match[1]
53
+ detectedUrl = `http://${localIP()}:${detectedPort}`
54
+ const ds = devServers.get(id)
55
+ if (ds) ds.url = detectedUrl
63
56
  }
64
57
  }
58
+ }
65
59
 
66
- proc.stdout!.on('data', onData)
67
- proc.stderr!.on('data', onData)
68
- proc.on('close', () => { devServers.delete(id); console.log(`[${id}] dev server stopped`) })
69
- proc.on('error', () => devServers.delete(id))
60
+ proc.stdout!.on('data', onData)
61
+ proc.stderr!.on('data', onData)
62
+ proc.on('close', () => { devServers.delete(id); console.log(`[${id}] dev server stopped`) })
63
+ proc.on('error', () => devServers.delete(id))
70
64
 
71
- devServers.set(id, { proc, url: `http://${localIP()}:${port}` })
72
- console.log(`[${id}] starting dev server in ${launch.launchDir} (session cwd=${session.cwd})`)
65
+ devServers.set(id, { proc, url: `http://${localIP()}:${port}` })
66
+ console.log(`[${id}] starting dev server in ${launch.launchDir} (session cwd=${session.cwd})`)
73
67
 
74
- // Wait for URL detection
75
- await new Promise(resolve => setTimeout(resolve, 4000))
76
- const ds = devServers.get(id)
77
- if (!ds) {
78
- return NextResponse.json({
68
+ await new Promise((resolve) => setTimeout(resolve, 4000))
69
+ const ds = devServers.get(id)
70
+ if (!ds) {
71
+ return {
72
+ status: 502,
73
+ body: {
79
74
  running: false,
80
75
  error: 'Dev server exited during startup',
81
76
  cwd: launch.launchDir,
82
77
  sessionCwd: session.cwd,
83
78
  framework: launch.framework,
84
79
  output: output.slice(-4000),
85
- }, { status: 502 })
80
+ },
86
81
  }
87
- return NextResponse.json({
82
+ }
83
+
84
+ return {
85
+ body: {
88
86
  running: true,
89
87
  url: ds.url,
90
88
  cwd: launch.launchDir,
91
89
  sessionCwd: session.cwd,
92
90
  framework: launch.framework,
93
- })
91
+ },
92
+ }
93
+ }
94
+
95
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
96
+ const { id } = await params
97
+ const sessions = loadSessions()
98
+ const session = sessions[id]
99
+ if (!session) return notFound()
100
+
101
+ const { action } = await req.json()
102
+
103
+ if (action === 'start') {
104
+ if (devServers.has(id)) {
105
+ const ds = devServers.get(id)!
106
+ return NextResponse.json({ running: true, url: ds.url })
107
+ }
108
+
109
+ let startPromise = inflightDevServerStarts.get(id)
110
+ if (!startPromise) {
111
+ startPromise = startDevServer(id, session).finally(() => {
112
+ if (inflightDevServerStarts.get(id) === startPromise) {
113
+ inflightDevServerStarts.delete(id)
114
+ }
115
+ })
116
+ inflightDevServerStarts.set(id, startPromise)
117
+ }
118
+ const result = await startPromise
119
+ return NextResponse.json(result.body, result.status ? { status: result.status } : undefined)
94
120
 
95
121
  } else if (action === 'stop') {
96
122
  if (devServers.has(id)) {
@@ -1,8 +1,23 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadSessions, saveSessions, deleteSession, active, loadAgents } from '@/lib/server/storage'
2
+ import { loadSessions, saveSessions, deleteSession, active, loadAgents, loadStoredItem } from '@/lib/server/storage'
3
3
  import { notFound } from '@/lib/server/collection-helpers'
4
4
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
5
5
  import { resolvePrimaryAgentRoute } from '@/lib/server/agent-runtime-config'
6
+ import { getSessionRunState } from '@/lib/server/session-run-manager'
7
+ import type { Session } from '@/types'
8
+
9
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
10
+ const { id } = await params
11
+ const session = loadStoredItem('sessions', id) as Session | null
12
+ if (!session) return notFound()
13
+
14
+ const run = getSessionRunState(id)
15
+ session.active = active.has(id) || !!run.runningRunId
16
+ session.queuedCount = run.queueLength
17
+ session.currentRunId = run.runningRunId || null
18
+
19
+ return NextResponse.json(session)
20
+ }
6
21
 
7
22
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
8
23
  const { id } = await params
@@ -10,12 +10,16 @@ import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
10
10
  import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agent-runtime-config'
11
11
  import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agent-availability'
12
12
  import { materializeStreamingAssistantArtifacts } from '@/lib/chat-streaming-state'
13
- import { ensureDaemonStarted } from '@/lib/server/daemon-state'
13
+ import { buildSessionListSummary } from '@/lib/session-summary'
14
14
  export const dynamic = 'force-dynamic'
15
15
 
16
+ async function ensureDaemonIfNeeded(source: string) {
17
+ const { ensureDaemonStarted } = await import('@/lib/server/daemon-state')
18
+ ensureDaemonStarted(source)
19
+ }
20
+
16
21
 
17
22
  export async function GET(req: Request) {
18
- ensureDaemonStarted('api/chats:get')
19
23
  const sessions = loadSessions()
20
24
  const changedSessionIds: string[] = []
21
25
  for (const id of Object.keys(sessions)) {
@@ -35,19 +39,23 @@ export async function GET(req: Request) {
35
39
  upsertStoredItem('sessions', id, persisted)
36
40
  }
37
41
 
42
+ const summarized = Object.fromEntries(
43
+ Object.entries(sessions).map(([id, session]) => [id, buildSessionListSummary(session)]),
44
+ )
45
+
38
46
  const { searchParams } = new URL(req.url)
39
47
  const limitParam = searchParams.get('limit')
40
- if (!limitParam) return NextResponse.json(sessions)
48
+ if (!limitParam) return NextResponse.json(summarized)
41
49
 
42
50
  const limit = Math.max(1, Number(limitParam) || 50)
43
51
  const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
44
- const all = Object.values(sessions).sort((a, b) => (b.lastActiveAt ?? b.createdAt) - (a.lastActiveAt ?? a.createdAt))
52
+ const all = Object.values(summarized).sort((a, b) => (b.lastActiveAt ?? b.createdAt) - (a.lastActiveAt ?? a.createdAt))
45
53
  const items = all.slice(offset, offset + limit)
46
54
  return NextResponse.json({ items, total: all.length, hasMore: offset + limit < all.length })
47
55
  }
48
56
 
49
57
  export async function DELETE(req: Request) {
50
- ensureDaemonStarted('api/chats:delete')
58
+ await ensureDaemonIfNeeded('api/chats:delete')
51
59
  const { ids } = await req.json().catch(() => ({ ids: [] })) as { ids: string[] }
52
60
  if (!Array.isArray(ids) || !ids.length) {
53
61
  return new NextResponse('Missing ids', { status: 400 })
@@ -68,7 +76,7 @@ export async function DELETE(req: Request) {
68
76
  }
69
77
 
70
78
  export async function POST(req: Request) {
71
- ensureDaemonStarted('api/chats:post')
79
+ await ensureDaemonIfNeeded('api/chats:post')
72
80
  const body = await req.json().catch(() => ({}))
73
81
  let cwd = (body.cwd || '').trim()
74
82
  if (cwd.startsWith('~/')) cwd = path.join(os.homedir(), cwd.slice(2))
@@ -1,11 +1,10 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { ensureDaemonStarted, getDaemonStatus, startDaemon, stopDaemon } from '@/lib/server/daemon-state'
3
2
  import { notify } from '@/lib/server/ws-hub'
4
3
  export const dynamic = 'force-dynamic'
5
4
 
6
5
 
7
- export async function GET(_req: Request) {
8
- ensureDaemonStarted('api/daemon:get')
6
+ export async function GET() {
7
+ const { getDaemonStatus } = await import('@/lib/server/daemon-state')
9
8
  return NextResponse.json(getDaemonStatus())
10
9
  }
11
10
 
@@ -14,10 +13,12 @@ export async function POST(req: Request) {
14
13
  const action = body.action
15
14
 
16
15
  if (action === 'start') {
16
+ const { startDaemon } = await import('@/lib/server/daemon-state')
17
17
  startDaemon({ source: 'api/daemon:post:start', manualStart: true })
18
18
  notify('daemon')
19
19
  return NextResponse.json({ ok: true, status: 'running' })
20
20
  } else if (action === 'stop') {
21
+ const { stopDaemon } = await import('@/lib/server/daemon-state')
21
22
  stopDaemon({ source: 'api/daemon:post:stop', manualStop: true })
22
23
  notify('daemon')
23
24
  return NextResponse.json({ ok: true, status: 'stopped' })
@@ -1,11 +1,11 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { ensureGatewayConnected } from '@/lib/server/openclaw-gateway'
2
+ import { ensureGatewayConnected, getGateway } from '@/lib/server/openclaw-gateway'
3
3
  import type { PendingExecApproval, ExecApprovalDecision } from '@/types'
4
4
 
5
5
  /** GET — fetch pending execution approvals from gateway */
6
6
  export async function GET() {
7
- const gw = await ensureGatewayConnected()
8
- if (!gw) {
7
+ const gw = getGateway()
8
+ if (!gw?.connected) {
9
9
  return NextResponse.json([], { status: 200 })
10
10
  }
11
11
 
@@ -3,7 +3,7 @@ import { loadWallets, upsertWallet, deleteWallet as deleteWalletFromStore, loadA
3
3
  import { notify } from '@/lib/server/ws-hub'
4
4
  import { getWalletLimitAtomic, normalizeAtomicString } from '@/lib/wallet'
5
5
  import type { AgentWallet, WalletAssetBalance, WalletPortfolioSummary } from '@/types'
6
- import { buildEmptyWalletPortfolio } from '@/lib/server/wallet-portfolio'
6
+ import { buildEmptyWalletPortfolio, getCachedWalletPortfolio } from '@/lib/server/wallet-portfolio'
7
7
  import {
8
8
  getAgentActiveWalletId,
9
9
  getWalletPortfolioSnapshot,
@@ -43,12 +43,28 @@ function withPortfolio(
43
43
  }
44
44
  }
45
45
 
46
- export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
46
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
47
47
  const { id } = await params
48
48
  const wallets = loadWallets() as Record<string, AgentWallet>
49
49
  const wallet = wallets[id]
50
50
  if (!wallet) return NextResponse.json({ error: 'Wallet not found' }, { status: 404 })
51
51
 
52
+ const url = new URL(req.url)
53
+ const cachedOnly = url.searchParams.get('cached') === '1'
54
+ const agents = loadAgents()
55
+ const isActive = getAgentActiveWalletId(agents[wallet.agentId]) === wallet.id
56
+
57
+ if (cachedOnly) {
58
+ const cached = getCachedWalletPortfolio(wallet)
59
+ if (!cached) {
60
+ return NextResponse.json({
61
+ ...stripWalletPrivateKey(wallet as unknown as Record<string, unknown>),
62
+ isActive,
63
+ })
64
+ }
65
+ return NextResponse.json(withPortfolio(wallet, cached, isActive))
66
+ }
67
+
52
68
  let portfolio = buildEmptyWalletPortfolio(wallet)
53
69
  try {
54
70
  portfolio = await getWalletPortfolioSnapshot(wallet, {
@@ -59,8 +75,6 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
59
75
  // RPC failure — return 0
60
76
  }
61
77
 
62
- const agents = loadAgents()
63
- const isActive = getAgentActiveWalletId(agents[wallet.agentId]) === wallet.id
64
78
  return NextResponse.json(withPortfolio(wallet, portfolio, isActive))
65
79
  }
66
80
 
package/src/app/page.tsx CHANGED
@@ -7,7 +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
+ import { isDevelopmentLikeRuntime } from '@/lib/runtime-env'
11
11
  import { useWs } from '@/hooks/use-ws'
12
12
  import { AccessKeyGate } from '@/components/auth/access-key-gate'
13
13
  import { UserPicker } from '@/components/auth/user-picker'
@@ -16,8 +16,8 @@ import { AppLayout } from '@/components/layout/app-layout'
16
16
  import { useViewRouter } from '@/hooks/use-view-router'
17
17
  import type { Agent } from '@/types'
18
18
 
19
- const AUTH_CHECK_TIMEOUT_MS = 8_000
20
- const POST_AUTH_BOOTSTRAP_TIMEOUT_MS = 8_000
19
+ const AUTH_CHECK_TIMEOUT_MS = isDevelopmentLikeRuntime() ? 20_000 : 8_000
20
+ const POST_AUTH_BOOTSTRAP_TIMEOUT_MS = isDevelopmentLikeRuntime() ? 20_000 : 8_000
21
21
 
22
22
  function FullScreenLoader(props: {
23
23
  stage?: string | null
@@ -202,17 +202,22 @@ export default function Home() {
202
202
  })
203
203
 
204
204
  const checkAuth = useCallback(async () => {
205
- const key = getStoredAccessKey()
206
- if (!key) {
207
- try {
208
- const res = await fetchWithTimeout('/api/auth', {}, AUTH_CHECK_TIMEOUT_MS)
209
- const data = await res.json().catch(() => ({}))
210
- setAuthenticated(data?.authenticated === true)
211
- } catch {
212
- setAuthenticated(false)
213
- } finally {
205
+ try {
206
+ const res = await fetchWithTimeout('/api/auth', {}, AUTH_CHECK_TIMEOUT_MS)
207
+ const data = await res.json().catch(() => ({}))
208
+ if (data?.authenticated === true) {
209
+ setAuthenticated(true)
214
210
  setAuthChecked(true)
211
+ return
215
212
  }
213
+ } catch {
214
+ // Fall back to stored-key bootstrap below if the auth probe is unavailable.
215
+ }
216
+
217
+ const key = getStoredAccessKey()
218
+ if (!key) {
219
+ setAuthenticated(false)
220
+ setAuthChecked(true)
216
221
  return
217
222
  }
218
223
 
@@ -260,8 +265,7 @@ export default function Home() {
260
265
 
261
266
  useEffect(() => {
262
267
  if (!authenticated) return
263
- const key = getStoredAccessKey()
264
- if (key) connectWs(key)
268
+ connectWs()
265
269
  syncUserFromServer()
266
270
  loadNetworkInfo()
267
271
  loadSettings()
@@ -269,15 +273,7 @@ export default function Home() {
269
273
  return () => { disconnectWs() }
270
274
  }, [authenticated])
271
275
 
272
- useWs('sessions', loadSessions, 5000)
273
-
274
- useEffect(() => {
275
- if (!authenticated || !isLocalhostBrowser()) return
276
- const pollId = setInterval(() => {
277
- void loadSessions()
278
- }, 5000)
279
- return () => clearInterval(pollId)
280
- }, [authenticated, loadSessions])
276
+ useWs('sessions', loadSessions, 15000)
281
277
 
282
278
  // Auto-select agent's thread on load — resolves a persisted agentId into a session,
283
279
  // or falls back to defaultAgentId from settings, then first agent.
package/src/cli/index.js CHANGED
@@ -490,7 +490,7 @@ const COMMAND_GROUPS = [
490
490
  description: 'Manage agent chats and runtime controls',
491
491
  commands: [
492
492
  cmd('list', 'GET', '/chats', 'List chats'),
493
- cmd('get', 'GET', '/chats/:id', 'Get chat by id', { virtual: true, clientGetRoute: '/chats' }),
493
+ cmd('get', 'GET', '/chats/:id', 'Get chat by id'),
494
494
  cmd('create', 'POST', '/chats', 'Create chat', { expectsJsonBody: true }),
495
495
  cmd('update', 'PUT', '/chats/:id', 'Update chat', { expectsJsonBody: true }),
496
496
  cmd('delete', 'DELETE', '/chats/:id', 'Delete chat'),
package/src/cli/spec.js CHANGED
@@ -379,7 +379,7 @@ const COMMAND_GROUPS = {
379
379
  commands: {
380
380
  list: { description: 'List chats', method: 'GET', path: '/chats' },
381
381
  create: { description: 'Create chat', method: 'POST', path: '/chats' },
382
- get: { description: 'Get chat by id (from list)', virtualGet: true, collectionPath: '/chats', params: ['id'] },
382
+ get: { description: 'Get chat by id', method: 'GET', path: '/chats/:id', params: ['id'] },
383
383
  update: { description: 'Update chat fields', method: 'PUT', path: '/chats/:id', params: ['id'] },
384
384
  delete: { description: 'Delete one chat', method: 'DELETE', path: '/chats/:id', params: ['id'] },
385
385
  'delete-many': { description: 'Delete multiple chats (body: {"ids":[...]})', method: 'DELETE', path: '/chats' },
@@ -55,11 +55,13 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
55
55
  setStoredAccessKey(trimmed)
56
56
  onAuthenticated()
57
57
  } else {
58
- setError('Invalid access key')
58
+ const payload = await res.json().catch(() => null) as { error?: unknown } | null
59
+ setError(typeof payload?.error === 'string' && payload.error.trim() ? payload.error : 'Invalid access key')
59
60
  setKey('')
60
61
  }
61
- } catch {
62
- setError('Connection failed')
62
+ } catch (err) {
63
+ const message = err instanceof Error && err.message.trim() ? err.message : 'Connection failed'
64
+ setError(message)
63
65
  } finally {
64
66
  setLoading(false)
65
67
  }