@swarmclawai/swarmclaw 1.2.1 → 1.2.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 (149) hide show
  1. package/README.md +16 -85
  2. package/bin/server-cmd.js +64 -1
  3. package/package.json +2 -2
  4. package/skills/coding-agent/SKILL.md +111 -0
  5. package/skills/github/SKILL.md +140 -0
  6. package/skills/nano-banana-pro/SKILL.md +62 -0
  7. package/skills/nano-banana-pro/scripts/generate_image.py +235 -0
  8. package/skills/nano-pdf/SKILL.md +53 -0
  9. package/skills/openai-image-gen/SKILL.md +78 -0
  10. package/skills/openai-image-gen/scripts/gen.py +328 -0
  11. package/skills/resourceful-problem-solving/SKILL.md +49 -0
  12. package/skills/skill-creator/SKILL.md +147 -0
  13. package/skills/skill-creator/scripts/init_skill.py +378 -0
  14. package/skills/skill-creator/scripts/quick_validate.py +159 -0
  15. package/skills/summarize/SKILL.md +77 -0
  16. package/src/app/api/auth/route.ts +20 -5
  17. package/src/app/api/chats/[id]/devserver/route.ts +13 -19
  18. package/src/app/api/chats/[id]/messages/route.ts +13 -15
  19. package/src/app/api/chats/[id]/route.ts +9 -10
  20. package/src/app/api/chats/[id]/stop/route.ts +5 -7
  21. package/src/app/api/chats/messages-route.test.ts +8 -6
  22. package/src/app/api/chats/route.ts +9 -10
  23. package/src/app/api/ip/route.ts +2 -2
  24. package/src/app/api/preview-server/route.ts +1 -1
  25. package/src/app/api/projects/[id]/route.ts +7 -46
  26. package/src/cli/server-cmd.test.js +74 -0
  27. package/src/components/chat/chat-area.tsx +45 -23
  28. package/src/components/chat/message-bubble.test.ts +35 -0
  29. package/src/components/chat/message-bubble.tsx +19 -9
  30. package/src/components/chat/message-list.tsx +37 -3
  31. package/src/components/input/chat-input.tsx +34 -14
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +4 -0
  33. package/src/instrumentation.ts +1 -1
  34. package/src/lib/chat/assistant-render-id.ts +3 -0
  35. package/src/lib/chat/chat-streaming-state.test.ts +42 -3
  36. package/src/lib/chat/chat-streaming-state.ts +20 -8
  37. package/src/lib/chat/queued-message-queue.test.ts +23 -1
  38. package/src/lib/chat/queued-message-queue.ts +11 -2
  39. package/src/lib/providers/cli-utils.test.ts +124 -0
  40. package/src/lib/server/activity/activity-log.ts +21 -0
  41. package/src/lib/server/agents/agent-availability.test.ts +10 -5
  42. package/src/lib/server/agents/agent-cascade.ts +79 -59
  43. package/src/lib/server/agents/agent-registry.ts +3 -1
  44. package/src/lib/server/agents/agent-repository.ts +90 -0
  45. package/src/lib/server/agents/delegation-job-repository.ts +53 -0
  46. package/src/lib/server/agents/delegation-jobs.ts +11 -4
  47. package/src/lib/server/agents/guardian-checkpoint-repository.ts +35 -0
  48. package/src/lib/server/agents/guardian.ts +2 -2
  49. package/src/lib/server/agents/main-agent-loop.ts +10 -3
  50. package/src/lib/server/agents/main-loop-state-repository.ts +38 -0
  51. package/src/lib/server/agents/subagent-runtime.ts +9 -6
  52. package/src/lib/server/agents/subagent-swarm.ts +3 -2
  53. package/src/lib/server/agents/task-session.ts +3 -4
  54. package/src/lib/server/approvals/approval-repository.ts +30 -0
  55. package/src/lib/server/autonomy/supervisor-incident-repository.ts +42 -0
  56. package/src/lib/server/chat-execution/chat-execution-types.ts +38 -0
  57. package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
  58. package/src/lib/server/chat-execution/chat-execution.ts +84 -1926
  59. package/src/lib/server/chat-execution/chat-turn-finalization.ts +620 -0
  60. package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +221 -0
  61. package/src/lib/server/chat-execution/chat-turn-preflight.ts +133 -0
  62. package/src/lib/server/chat-execution/chat-turn-preparation.ts +817 -0
  63. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +296 -0
  64. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +5 -5
  65. package/src/lib/server/chat-execution/message-classifier.test.ts +329 -0
  66. package/src/lib/server/chat-execution/post-stream-finalization.ts +1 -1
  67. package/src/lib/server/chat-execution/prompt-builder.ts +11 -0
  68. package/src/lib/server/chat-execution/prompt-sections.ts +5 -6
  69. package/src/lib/server/chat-execution/situational-awareness.ts +12 -7
  70. package/src/lib/server/chat-execution/stream-agent-chat.ts +16 -13
  71. package/src/lib/server/chatrooms/chatroom-repository.ts +32 -0
  72. package/src/lib/server/connectors/connector-repository.ts +58 -0
  73. package/src/lib/server/connectors/runtime-state.test.ts +117 -0
  74. package/src/lib/server/credentials/credential-repository.ts +7 -0
  75. package/src/lib/server/gateways/gateway-profile-repository.ts +4 -0
  76. package/src/lib/server/memory/memory-abstract.test.ts +59 -0
  77. package/src/lib/server/missions/mission-repository.ts +74 -0
  78. package/src/lib/server/missions/mission-service/actions.ts +6 -0
  79. package/src/lib/server/missions/mission-service/bindings.ts +9 -0
  80. package/src/lib/server/missions/mission-service/context.ts +4 -0
  81. package/src/lib/server/missions/mission-service/core.ts +2269 -0
  82. package/src/lib/server/missions/mission-service/queries.ts +12 -0
  83. package/src/lib/server/missions/mission-service/recovery.ts +5 -0
  84. package/src/lib/server/missions/mission-service/ticks.ts +9 -0
  85. package/src/lib/server/missions/mission-service.test.ts +9 -2
  86. package/src/lib/server/missions/mission-service.ts +6 -2266
  87. package/src/lib/server/openclaw/deploy.test.ts +42 -3
  88. package/src/lib/server/openclaw/deploy.ts +26 -12
  89. package/src/lib/server/persistence/repository-utils.ts +154 -0
  90. package/src/lib/server/persistence/storage-context.ts +51 -0
  91. package/src/lib/server/persistence/transaction.ts +1 -0
  92. package/src/lib/server/projects/project-repository.ts +36 -0
  93. package/src/lib/server/projects/project-service.ts +79 -0
  94. package/src/lib/server/protocols/protocol-normalization.test.ts +6 -4
  95. package/src/lib/server/runtime/alert-dispatch.ts +1 -1
  96. package/src/lib/server/runtime/daemon-policy.ts +1 -1
  97. package/src/lib/server/runtime/daemon-state/core.ts +1570 -0
  98. package/src/lib/server/runtime/daemon-state/health.ts +6 -0
  99. package/src/lib/server/runtime/daemon-state/policy.ts +7 -0
  100. package/src/lib/server/runtime/daemon-state/supervisor.ts +6 -0
  101. package/src/lib/server/runtime/daemon-state.test.ts +48 -0
  102. package/src/lib/server/runtime/daemon-state.ts +3 -1470
  103. package/src/lib/server/runtime/estop-repository.ts +4 -0
  104. package/src/lib/server/runtime/estop.ts +3 -1
  105. package/src/lib/server/runtime/heartbeat-service.test.ts +2 -2
  106. package/src/lib/server/runtime/heartbeat-service.ts +55 -34
  107. package/src/lib/server/runtime/heartbeat-wake.ts +6 -4
  108. package/src/lib/server/runtime/idle-window.ts +2 -2
  109. package/src/lib/server/runtime/network.ts +11 -0
  110. package/src/lib/server/runtime/orchestrator-events.ts +2 -2
  111. package/src/lib/server/runtime/queue/claims.ts +4 -0
  112. package/src/lib/server/runtime/queue/core.ts +2079 -0
  113. package/src/lib/server/runtime/queue/execution.ts +7 -0
  114. package/src/lib/server/runtime/queue/followups.ts +4 -0
  115. package/src/lib/server/runtime/queue/queries.ts +12 -0
  116. package/src/lib/server/runtime/queue/recovery.ts +7 -0
  117. package/src/lib/server/runtime/queue-recovery.test.ts +48 -13
  118. package/src/lib/server/runtime/queue-repository.ts +17 -0
  119. package/src/lib/server/runtime/queue.ts +5 -2061
  120. package/src/lib/server/runtime/run-ledger.ts +6 -5
  121. package/src/lib/server/runtime/run-repository.ts +73 -0
  122. package/src/lib/server/runtime/runtime-lock-repository.ts +8 -0
  123. package/src/lib/server/runtime/runtime-settings.ts +1 -1
  124. package/src/lib/server/runtime/runtime-state.ts +99 -0
  125. package/src/lib/server/runtime/scheduler.ts +4 -2
  126. package/src/lib/server/runtime/session-run-manager/cancellation.ts +157 -0
  127. package/src/lib/server/runtime/session-run-manager/drain.ts +246 -0
  128. package/src/lib/server/runtime/session-run-manager/enqueue.ts +287 -0
  129. package/src/lib/server/runtime/session-run-manager/queries.ts +117 -0
  130. package/src/lib/server/runtime/session-run-manager/recovery.ts +238 -0
  131. package/src/lib/server/runtime/session-run-manager/state.ts +441 -0
  132. package/src/lib/server/runtime/session-run-manager/types.ts +74 -0
  133. package/src/lib/server/runtime/session-run-manager.ts +72 -1377
  134. package/src/lib/server/runtime/watch-job-repository.ts +35 -0
  135. package/src/lib/server/runtime/watch-jobs.ts +3 -1
  136. package/src/lib/server/schedules/schedule-repository.ts +42 -0
  137. package/src/lib/server/sessions/session-repository.ts +85 -0
  138. package/src/lib/server/settings/settings-repository.ts +25 -0
  139. package/src/lib/server/skills/skill-discovery.test.ts +2 -2
  140. package/src/lib/server/skills/skill-discovery.ts +2 -2
  141. package/src/lib/server/skills/skill-repository.ts +14 -0
  142. package/src/lib/server/storage.ts +13 -24
  143. package/src/lib/server/tasks/task-repository.ts +54 -0
  144. package/src/lib/server/usage/usage-repository.ts +30 -0
  145. package/src/lib/server/webhooks/webhook-repository.ts +10 -0
  146. package/src/lib/strip-internal-metadata.test.ts +42 -41
  147. package/src/stores/use-chat-store.test.ts +54 -0
  148. package/src/stores/use-chat-store.ts +21 -5
  149. /package/{bundled-skills → skills}/google-workspace/SKILL.md +0 -0
@@ -1,65 +1,26 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadProjects, saveProjects, deleteProject, loadAgents, saveAgents, loadTasks, saveTasks, loadSchedules, saveSchedules, loadSkills, saveSkills, loadSecrets, saveSecrets } from '@/lib/server/storage'
3
- import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
4
- import { ensureProjectWorkspace, normalizeProjectPatchInput } from '@/lib/server/project-utils'
5
- import { notify } from '@/lib/server/ws-hub'
2
+ import { notFound } from '@/lib/server/collection-helpers'
3
+ import { deleteProjectAndDetachReferences, getProject, updateProject } from '@/lib/server/projects/project-service'
6
4
  import { safeParseBody } from '@/lib/server/safe-parse-body'
7
5
 
8
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
- const ops: CollectionOps<any> = { load: loadProjects, save: saveProjects, deleteFn: deleteProject, topic: 'projects' }
10
-
11
6
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
12
7
  const { id } = await params
13
- const projects = loadProjects()
14
- if (!projects[id]) return notFound()
15
- return NextResponse.json(projects[id])
8
+ const project = getProject(id)
9
+ if (!project) return notFound()
10
+ return NextResponse.json(project)
16
11
  }
17
12
 
18
13
  export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
19
14
  const { id } = await params
20
15
  const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
21
16
  if (error) return error
22
- const result = mutateItem(ops, id, (project) => {
23
- const patch = normalizeProjectPatchInput(body && typeof body === 'object' ? body as Record<string, unknown> : {})
24
- Object.assign(project, patch, { updatedAt: Date.now() })
25
- delete (project as Record<string, unknown>).id
26
- project.id = id
27
- return project
28
- })
17
+ const result = updateProject(id, body && typeof body === 'object' ? body : {})
29
18
  if (!result) return notFound()
30
- ensureProjectWorkspace(id, result.name)
31
19
  return NextResponse.json(result)
32
20
  }
33
21
 
34
22
  export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
35
23
  const { id } = await params
36
- if (!deleteItem(ops, id)) return notFound()
37
-
38
- // Clear projectId from referencing entities
39
- const clearProjectId = (load: () => Record<string, Record<string, unknown>>, save: (d: Record<string, Record<string, unknown>>) => void, topic: string) => {
40
- const items = load()
41
- let changed = false
42
- for (const item of Object.values(items)) {
43
- if (item.projectId === id) {
44
- item.projectId = undefined
45
- changed = true
46
- }
47
- }
48
- if (changed) {
49
- save(items)
50
- notify(topic)
51
- }
52
- }
53
-
54
- clearProjectId(
55
- loadAgents as unknown as () => Record<string, Record<string, unknown>>,
56
- saveAgents as unknown as (data: Record<string, Record<string, unknown>>) => void,
57
- 'agents',
58
- )
59
- clearProjectId(loadTasks as unknown as () => Record<string, Record<string, unknown>>, saveTasks as unknown as (data: Record<string, Record<string, unknown>>) => void, 'tasks')
60
- clearProjectId(loadSchedules as unknown as () => Record<string, Record<string, unknown>>, saveSchedules as unknown as (data: Record<string, Record<string, unknown>>) => void, 'schedules')
61
- clearProjectId(loadSkills as unknown as () => Record<string, Record<string, unknown>>, saveSkills as unknown as (data: Record<string, Record<string, unknown>>) => void, 'skills')
62
- clearProjectId(loadSecrets as unknown as () => Record<string, Record<string, unknown>>, saveSecrets as unknown as (data: Record<string, Record<string, unknown>>) => void, 'secrets')
63
-
24
+ if (!deleteProjectAndDetachReferences(id)) return notFound()
64
25
  return NextResponse.json({ ok: true })
65
26
  }
@@ -136,6 +136,80 @@ test('prepareBuildWorkspace copies the package tree and links node_modules outsi
136
136
  fs.rmSync(externalNodeModules, { recursive: true, force: true })
137
137
  })
138
138
 
139
+ test('syncStandaloneRuntimeAssets copies .next/static and public into a direct standalone runtime', () => {
140
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
141
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
142
+ const serverCmd = loadServerCmdForHome(homeDir)
143
+ const runtimeDir = path.join(pkgRoot, '.next', 'standalone')
144
+
145
+ fs.mkdirSync(path.join(pkgRoot, '.next', 'static', 'chunks'), { recursive: true })
146
+ fs.mkdirSync(path.join(pkgRoot, 'public', 'branding'), { recursive: true })
147
+ fs.writeFileSync(path.join(pkgRoot, '.next', 'static', 'chunks', 'app.js'), 'chunk\n', 'utf8')
148
+ fs.writeFileSync(path.join(pkgRoot, 'public', 'branding', 'logo.svg'), '<svg />\n', 'utf8')
149
+
150
+ const result = serverCmd.syncStandaloneRuntimeAssets({
151
+ sourceRoot: pkgRoot,
152
+ runtimeDir,
153
+ force: true,
154
+ })
155
+
156
+ assert.deepEqual(result, { staticCopied: true, publicCopied: true })
157
+ assert.equal(fs.readFileSync(path.join(runtimeDir, '.next', 'static', 'chunks', 'app.js'), 'utf8'), 'chunk\n')
158
+ assert.equal(fs.readFileSync(path.join(runtimeDir, 'public', 'branding', 'logo.svg'), 'utf8'), '<svg />\n')
159
+
160
+ fs.rmSync(homeDir, { recursive: true, force: true })
161
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
162
+ })
163
+
164
+ test('syncStandaloneRuntimeAssets targets the resolved nested runtime directory', () => {
165
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
166
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
167
+ const serverCmd = loadServerCmdForHome(homeDir)
168
+ const serverJs = path.join(pkgRoot, '.next', 'standalone', 'Users', 'wayde', 'Dev', 'swarmclaw', 'server.js')
169
+ const runtimeDir = serverCmd.resolveStandaloneRuntimeDir(serverJs)
170
+
171
+ fs.mkdirSync(path.dirname(serverJs), { recursive: true })
172
+ fs.writeFileSync(serverJs, 'console.log("ok")\n', 'utf8')
173
+ fs.mkdirSync(path.join(pkgRoot, '.next', 'static', 'css'), { recursive: true })
174
+ fs.writeFileSync(path.join(pkgRoot, '.next', 'static', 'css', 'app.css'), 'body{}\n', 'utf8')
175
+
176
+ const result = serverCmd.syncStandaloneRuntimeAssets({
177
+ sourceRoot: pkgRoot,
178
+ runtimeDir,
179
+ })
180
+
181
+ assert.deepEqual(result, { staticCopied: true, publicCopied: false })
182
+ assert.equal(fs.readFileSync(path.join(runtimeDir, '.next', 'static', 'css', 'app.css'), 'utf8'), 'body{}\n')
183
+ assert.equal(fs.existsSync(path.join(runtimeDir, 'public')), false)
184
+
185
+ fs.rmSync(homeDir, { recursive: true, force: true })
186
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
187
+ })
188
+
189
+ test('syncStandaloneRuntimeAssets repairs missing assets without overwriting an existing target by default', () => {
190
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
191
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
192
+ const serverCmd = loadServerCmdForHome(homeDir)
193
+ const runtimeDir = path.join(pkgRoot, '.next', 'standalone')
194
+
195
+ fs.mkdirSync(path.join(pkgRoot, '.next', 'static', 'chunks'), { recursive: true })
196
+ fs.writeFileSync(path.join(pkgRoot, '.next', 'static', 'chunks', 'main.js'), 'fresh\n', 'utf8')
197
+ fs.mkdirSync(path.join(runtimeDir, 'public'), { recursive: true })
198
+ fs.writeFileSync(path.join(runtimeDir, 'public', 'keep.txt'), 'keep\n', 'utf8')
199
+
200
+ const result = serverCmd.syncStandaloneRuntimeAssets({
201
+ sourceRoot: pkgRoot,
202
+ runtimeDir,
203
+ })
204
+
205
+ assert.deepEqual(result, { staticCopied: true, publicCopied: false })
206
+ assert.equal(fs.readFileSync(path.join(runtimeDir, '.next', 'static', 'chunks', 'main.js'), 'utf8'), 'fresh\n')
207
+ assert.equal(fs.readFileSync(path.join(runtimeDir, 'public', 'keep.txt'), 'utf8'), 'keep\n')
208
+
209
+ fs.rmSync(homeDir, { recursive: true, force: true })
210
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
211
+ })
212
+
139
213
  test('resolveReadyCheckHost maps wildcard bind hosts to loopback', () => {
140
214
  const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
141
215
  const serverCmd = loadServerCmdForHome(homeDir)
@@ -27,6 +27,7 @@ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
27
27
  import { speak } from '@/lib/tts'
28
28
  import { api } from '@/lib/app/api-client'
29
29
  import { messagesDiffer } from '@/lib/chat/chat-streaming-state'
30
+ import { createAssistantRenderId } from '@/lib/chat/assistant-render-id'
30
31
  import { getSessionLastMessage } from '@/lib/chat/session-summary'
31
32
  import { getEnabledCapabilityIds, getEnabledToolIds } from '@/lib/capability-selection'
32
33
 
@@ -71,6 +72,36 @@ export function ChatArea() {
71
72
  const setPreviewContent = useChatStore((s) => s.setPreviewContent)
72
73
  const isDesktop = useMediaQuery('(min-width: 768px)')
73
74
 
75
+ const markSessionLocallyIdle = useCallback((targetSessionId: string) => {
76
+ const appState = useAppStore.getState()
77
+ const existing = appState.sessions[targetSessionId]
78
+ if (!existing) return
79
+ appState.updateSessionInStore({
80
+ ...existing,
81
+ active: false,
82
+ currentRunId: null,
83
+ })
84
+ }, [])
85
+
86
+ const startServerStreamingPlaceholder = useCallback((targetSessionId: string, phase: 'queued' | 'thinking' | 'connecting' = 'thinking') => {
87
+ useChatStore.setState((state) => {
88
+ const sameServerStream = state.streaming
89
+ && state.streamSource === 'server'
90
+ && state.streamingSessionId === targetSessionId
91
+
92
+ return {
93
+ streaming: true,
94
+ streamingSessionId: targetSessionId,
95
+ streamSource: 'server',
96
+ streamPhase: sameServerStream ? state.streamPhase : phase,
97
+ streamText: '',
98
+ displayText: '',
99
+ assistantRenderId: sameServerStream && state.assistantRenderId ? state.assistantRenderId : createAssistantRenderId(),
100
+ thinkingStartTime: sameServerStream && state.thinkingStartTime > 0 ? state.thinkingStartTime : Date.now(),
101
+ }
102
+ })
103
+ }, [])
104
+
74
105
  const currentAgent = useAppStore((s) => {
75
106
  const agentId = session?.agentId
76
107
  return agentId ? s.agents[agentId] ?? null : null
@@ -178,14 +209,12 @@ export function ChatArea() {
178
209
  })
179
210
 
180
211
  const sessionAtLoad = useAppStore.getState().sessions[requestedSessionId]
181
- if (sessionAtLoad?.active) {
182
- useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamSource: 'server', streamText: '' })
183
- }
212
+ if (sessionAtLoad?.active) startServerStreamingPlaceholder(requestedSessionId)
184
213
 
185
214
  return () => {
186
215
  cancelled = true
187
216
  }
188
- }, [loadQueuedMessages, refreshSession, sessionId, setDevServer, setMessages])
217
+ }, [loadQueuedMessages, refreshSession, sessionId, setDevServer, setMessages, startServerStreamingPlaceholder])
189
218
 
190
219
  useEffect(() => {
191
220
  if (!sessionId || messagesLoading) return
@@ -195,9 +224,7 @@ export function ChatArea() {
195
224
  void refreshSession(requestedSessionId).then(() => {
196
225
  if (cancelled || selectActiveSessionId(useAppStore.getState()) !== requestedSessionId) return
197
226
  const refreshed = useAppStore.getState().sessions[requestedSessionId]
198
- if (refreshed?.active) {
199
- useChatStore.setState({ streaming: true, streamingSessionId: requestedSessionId, streamSource: 'server', streamText: '' })
200
- }
227
+ if (refreshed?.active) startServerStreamingPlaceholder(requestedSessionId)
201
228
  }).catch((err) => console.error('Failed to refresh session:', err))
202
229
 
203
230
  void devServer(requestedSessionId, 'status').then((r) => {
@@ -213,7 +240,7 @@ export function ChatArea() {
213
240
  cancelled = true
214
241
  window.clearTimeout(timer)
215
242
  }
216
- }, [messagesLoading, refreshSession, sessionId, setDevServer])
243
+ }, [messagesLoading, refreshSession, sessionId, setDevServer, startServerStreamingPlaceholder])
217
244
 
218
245
  useEffect(() => {
219
246
  if (!sessionId || messagesLoading) return
@@ -292,19 +319,12 @@ export function ChatArea() {
292
319
  && !chatState.streaming
293
320
  && chatState.streamingSessionId !== sessionId
294
321
  ) {
295
- useChatStore.setState({
296
- streaming: true,
297
- streamingSessionId: sessionId,
298
- streamSource: 'server',
299
- streamPhase: 'thinking',
300
- streamText: '',
301
- thinkingStartTime: Date.now(),
302
- })
322
+ startServerStreamingPlaceholder(sessionId)
303
323
  }
304
324
  } catch (err) {
305
325
  console.error('Failed to refresh queue:', err)
306
326
  }
307
- }, [loadQueuedMessages, sessionId])
327
+ }, [loadQueuedMessages, sessionId, startServerStreamingPlaceholder])
308
328
 
309
329
  // Subscribe to WS messages for this session — always subscribe when session exists,
310
330
  // only enable fallback polling when actively needed
@@ -316,7 +336,7 @@ export function ChatArea() {
316
336
  useWs(
317
337
  sessionId ? 'runs' : '',
318
338
  refreshQueue,
319
- sessionId && (isServerActive || queuedCount > 0) ? 10_000 : undefined,
339
+ sessionId && (isServerActive || queuedCount > 0) ? 2_500 : undefined,
320
340
  )
321
341
 
322
342
  // Listen for stream-end signal from the server — clears streaming state
@@ -325,11 +345,12 @@ export function ChatArea() {
325
345
  if (!sessionId) return
326
346
  const state = useChatStore.getState()
327
347
  if (state.streamSource === 'server' && state.streamingSessionId === sessionId) {
328
- useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', displayText: '', streamPhase: 'thinking', streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
348
+ markSessionLocallyIdle(sessionId)
349
+ useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', displayText: '', assistantRenderId: null, streamPhase: 'thinking', streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
329
350
  void refreshMessages()
330
351
  void refreshSession(sessionId)
331
352
  }
332
- }, [sessionId, refreshMessages, refreshSession])
353
+ }, [markSessionLocallyIdle, sessionId, refreshMessages, refreshSession])
333
354
  useWs(sessionId ? `stream-end:${sessionId}` : '', handleStreamEnd)
334
355
 
335
356
  // Keep the local typing indicator aligned with the server's active state
@@ -338,7 +359,7 @@ export function ChatArea() {
338
359
  const state = useChatStore.getState()
339
360
  if (isServerActive) {
340
361
  if (!state.streaming && !state.streamText) {
341
- useChatStore.setState({ streaming: true, streamingSessionId: sessionId, streamSource: 'server', streamText: '' })
362
+ startServerStreamingPlaceholder(sessionId)
342
363
  }
343
364
  return
344
365
  }
@@ -349,9 +370,10 @@ export function ChatArea() {
349
370
  ) {
350
371
  // Server finished — clear all streaming state and fetch final messages
351
372
  fetchMessages(sessionId).then(setMessages).catch(() => {})
352
- useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', displayText: '', streamPhase: 'thinking', streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
373
+ markSessionLocallyIdle(sessionId)
374
+ useChatStore.setState({ streaming: false, streamingSessionId: null, streamSource: null, streamText: '', displayText: '', assistantRenderId: null, streamPhase: 'thinking', streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
353
375
  }
354
- }, [isServerActive, sessionId, setMessages])
376
+ }, [isServerActive, markSessionLocallyIdle, sessionId, setMessages, startServerStreamingPlaceholder])
355
377
 
356
378
  // Poll browser status while session has browser tools
357
379
  const hasBrowserTool = getEnabledToolIds(session).includes('browser')
@@ -37,6 +37,41 @@ describe('MessageBubble', () => {
37
37
  assert.doesNotMatch(html, /streaming-cursor/)
38
38
  })
39
39
 
40
+ it('falls back to persisted streaming content when the live stream payload is temporarily empty', async () => {
41
+ const messageBubbleModule = await import('./message-bubble') as Record<string, unknown>
42
+ const MessageBubble = (
43
+ messageBubbleModule.MessageBubble
44
+ || (messageBubbleModule.default as { MessageBubble?: unknown } | undefined)?.MessageBubble
45
+ || (messageBubbleModule['module.exports'] as { MessageBubble?: unknown } | undefined)?.MessageBubble
46
+ ) as typeof import('./message-bubble').MessageBubble | undefined
47
+ assert.ok(MessageBubble)
48
+
49
+ const html = renderToStaticMarkup(
50
+ React.createElement(MessageBubble, {
51
+ message: {
52
+ role: 'assistant',
53
+ text: 'Recovered persisted partial text',
54
+ time: Date.now(),
55
+ kind: 'chat',
56
+ streaming: true,
57
+ },
58
+ assistantName: 'Hal2k-3',
59
+ agentName: 'Hal2k-3',
60
+ liveStream: {
61
+ active: true,
62
+ phase: 'responding',
63
+ toolName: '',
64
+ text: '',
65
+ thinking: '',
66
+ toolEvents: [],
67
+ },
68
+ }),
69
+ )
70
+
71
+ assert.match(html, /Recovered persisted partial text/)
72
+ assert.match(html, /streaming-cursor/)
73
+ })
74
+
40
75
  it('renders upload-linked screenshots inline without duplicating them at the bottom', async () => {
41
76
  const messageBubbleModule = await import('./message-bubble') as Record<string, unknown>
42
77
  const MessageBubble = (
@@ -367,12 +367,14 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
367
367
  const toolEvents = message.toolEvents ?? emptyToolEvents
368
368
  const toolEventsForMedia = useMemo(
369
369
  () => (liveStreamActive
370
- ? liveToolEvents.map((event) => ({
371
- name: event.name,
372
- input: event.input,
373
- output: event.output,
374
- error: event.status === 'error' || undefined,
375
- }))
370
+ ? (liveToolEvents.length > 0
371
+ ? liveToolEvents.map((event) => ({
372
+ name: event.name,
373
+ input: event.input,
374
+ output: event.output,
375
+ error: event.status === 'error' || undefined,
376
+ }))
377
+ : toolEvents)
376
378
  : toolEvents),
377
379
  [liveStreamActive, liveToolEvents, toolEvents],
378
380
  )
@@ -383,7 +385,15 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
383
385
  )
384
386
  const displayToolEvents = useMemo(
385
387
  () => (liveStreamActive
386
- ? liveToolEvents.filter((ev) => ev.name !== 'send_file' || ev.status === 'error')
388
+ ? (liveToolEvents.length > 0
389
+ ? liveToolEvents.filter((ev) => ev.name !== 'send_file' || ev.status === 'error')
390
+ : persistedToolEvents.map((ev, i) => ({
391
+ id: ev.toolCallId || `${message.time}-${ev.name}-${i}`,
392
+ name: ev.name,
393
+ input: ev.input,
394
+ output: ev.output,
395
+ status: ev.error ? 'error' as const : 'done' as const,
396
+ })))
387
397
  : persistedToolEvents.map((ev, i) => ({
388
398
  id: ev.toolCallId || `${message.time}-${ev.name}-${i}`,
389
399
  name: ev.name,
@@ -419,11 +429,11 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
419
429
  ? (liveStreamActive ? (liveStream?.thinking?.trim() ? liveStream.thinking : undefined) : message.thinking)
420
430
  : undefined
421
431
 
422
- const sourceText = liveStreamActive ? (liveStream?.text || '') : message.text
432
+ const sourceText = liveStreamActive ? (liveStream?.text || message.text || '') : message.text
423
433
  const connectorDeliveryTranscript = !isUser && message.kind === 'connector-delivery'
424
434
  ? (message.source?.deliveryTranscript?.trim() || '')
425
435
  : ''
426
- const copySourceText = connectorDeliveryTranscript || (liveStreamActive ? (liveStream?.text || '') : message.text)
436
+ const copySourceText = connectorDeliveryTranscript || (liveStreamActive ? (liveStream?.text || message.text || '') : message.text)
427
437
 
428
438
  // Extract ALL media from ALL tool events for inline display after the message text.
429
439
  // Covers send_file, browser screenshots, file tool outputs — everything.
@@ -188,6 +188,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
188
188
  const snapUntilRef = useRef(0)
189
189
  const prevSessionIdRef = useRef<string | null>(null)
190
190
  const assistantRenderId = useChatStore((s) => s.assistantRenderId)
191
+ const thinkingStartTime = useChatStore((s) => s.thinkingStartTime)
191
192
  const hasLiveArtifacts = useChatStore(selectHasLiveArtifacts)
192
193
  const setMessages = useChatStore((s) => s.setMessages)
193
194
  const retryLastMessage = useChatStore((s) => s.retryLastMessage)
@@ -297,13 +298,46 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
297
298
  return dedupeMessagesForDisplay(displayedMessages)
298
299
  }, [messages, showAlerts, showOk])
299
300
 
301
+ const latestPersistedStreamingMessage = useMemo(() => {
302
+ for (let i = baseDisplayedMessages.length - 1; i >= 0; i -= 1) {
303
+ const candidate = baseDisplayedMessages[i]
304
+ if (candidate.role === 'assistant' && candidate.streaming === true) {
305
+ return candidate
306
+ }
307
+ }
308
+ return null
309
+ }, [baseDisplayedMessages])
310
+
311
+ const currentRunHasCompletedAssistant = useMemo(
312
+ () => (
313
+ streaming
314
+ && thinkingStartTime > 0
315
+ && baseDisplayedMessages.some((message) => (
316
+ message.role === 'assistant'
317
+ && message.streaming !== true
318
+ && message.kind !== 'system'
319
+ && message.kind !== 'heartbeat'
320
+ && typeof message.time === 'number'
321
+ && message.time >= thinkingStartTime
322
+ ))
323
+ ),
324
+ [baseDisplayedMessages, streaming, thinkingStartTime],
325
+ )
326
+
327
+ const showLiveStreamRow = streaming
328
+ && !!assistantRenderId
329
+ && !currentRunHasCompletedAssistant
330
+ && (hasLiveArtifacts || !!latestPersistedStreamingMessage)
331
+
300
332
  const streamingAwareMessages = useMemo(() => (
301
333
  buildStreamingAwareMessageList(baseDisplayedMessages, {
302
334
  localStreaming: streaming,
303
335
  hasLiveArtifacts,
304
336
  assistantRenderId,
337
+ showLiveRow: showLiveStreamRow,
338
+ syntheticAssistant: latestPersistedStreamingMessage,
305
339
  })
306
- ), [assistantRenderId, baseDisplayedMessages, hasLiveArtifacts, streaming])
340
+ ), [assistantRenderId, baseDisplayedMessages, hasLiveArtifacts, latestPersistedStreamingMessage, showLiveStreamRow, streaming])
307
341
 
308
342
  const filteredMessages = useMemo(() => {
309
343
  let nextMessages = bookmarkFilter
@@ -623,7 +657,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
623
657
  }, [searchOpen])
624
658
 
625
659
  return (
626
- <div className="relative flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden" data-testid="message-list">
660
+ <div className="relative flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden isolate" data-testid="message-list">
627
661
  <div className="shrink-0 px-4 md:px-12 lg:px-16 pt-3">
628
662
  <div className="flex flex-wrap items-center gap-2 rounded-[14px] border border-white/[0.06] bg-surface/55 px-3 py-2 backdrop-blur-sm">
629
663
  <button
@@ -853,7 +887,7 @@ export function MessageList({ messages, streaming, connectorFilter = null, loadi
853
887
  {transcriptNodes}
854
888
  <ApprovalCards agentId={agent?.id} />
855
889
  <LiveThinkingLane
856
- show={streaming && !hasLiveArtifacts && !hasVisiblePersistedStreamingMessage}
890
+ show={streaming && !showLiveStreamRow && !hasLiveArtifacts && !hasVisiblePersistedStreamingMessage}
857
891
  assistantName={assistantName}
858
892
  agentAvatarSeed={agent?.avatarSeed}
859
893
  agentAvatarUrl={agent?.avatarUrl}
@@ -50,6 +50,10 @@ export function ChatInput({ streaming, busy, onSend, onStop, extensionChatAction
50
50
  const streamPhase = useChatStore((s) => s.streamPhase)
51
51
  const streamToolName = useChatStore((s) => s.streamToolName)
52
52
  const visibleQueuedMessages = listQueuedMessagesForSession(queuedMessages, sessionId)
53
+ const sendingQueuedMessages = visibleQueuedMessages.filter((item) => item.sending)
54
+ const pendingQueuedMessages = visibleQueuedMessages.filter((item) => !item.sending)
55
+ const displayedQueuedMessages = [...sendingQueuedMessages, ...pendingQueuedMessages]
56
+ const nextPendingRunId = pendingQueuedMessages[0]?.runId ?? null
53
57
  const shouldQueue = !!sessionId && (busy || visibleQueuedMessages.length > 0)
54
58
 
55
59
  useEffect(() => {
@@ -178,6 +182,8 @@ export function ChatInput({ streaming, busy, onSend, onStop, extensionChatAction
178
182
  const hasContent = value.trim().length > 0 || pendingFiles.length > 0
179
183
  const queueStatusLabel = !busy
180
184
  ? 'Queue ready'
185
+ : sendingQueuedMessages.length > 0 && pendingQueuedMessages.length === 0
186
+ ? 'Sending now'
181
187
  : streamPhase === 'queued'
182
188
  ? 'Queued'
183
189
  : streamPhase === 'tool' && streamToolName
@@ -191,6 +197,8 @@ export function ChatInput({ streaming, busy, onSend, onStop, extensionChatAction
191
197
  : 'Working'
192
198
  const queueStatusDetail = !busy
193
199
  ? 'Queued messages are ready and will dispatch automatically.'
200
+ : sendingQueuedMessages.length > 0 && pendingQueuedMessages.length === 0
201
+ ? 'The queued message has been accepted and should appear in the transcript shortly.'
194
202
  : 'Queued messages will send automatically when the current turn finishes.'
195
203
 
196
204
  return (
@@ -229,9 +237,16 @@ export function ChatInput({ streaming, busy, onSend, onStop, extensionChatAction
229
237
  <span className={`relative inline-flex h-2.5 w-2.5 rounded-full ${busy ? 'bg-amber-300' : 'bg-white/[0.45]'}`} />
230
238
  </span>
231
239
  <span className="label-mono text-amber-300/80">Message queue</span>
232
- <span className="rounded-pill border border-amber-400/15 bg-amber-400/10 px-2 py-0.5 text-[10px] font-600 text-amber-200">
233
- {visibleQueuedMessages.length}
234
- </span>
240
+ {pendingQueuedMessages.length > 0 && (
241
+ <span className="rounded-pill border border-amber-400/15 bg-amber-400/10 px-2 py-0.5 text-[10px] font-600 text-amber-200">
242
+ {pendingQueuedMessages.length}
243
+ </span>
244
+ )}
245
+ {sendingQueuedMessages.length > 0 && (
246
+ <span className="rounded-pill border border-sky-300/15 bg-sky-300/10 px-2 py-0.5 text-[10px] font-600 text-sky-200">
247
+ {sendingQueuedMessages.length} sending
248
+ </span>
249
+ )}
235
250
  <span className={`rounded-pill border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.12em] ${
236
251
  busy
237
252
  ? 'border-amber-300/20 bg-amber-300/10 text-amber-100'
@@ -256,7 +271,7 @@ export function ChatInput({ streaming, busy, onSend, onStop, extensionChatAction
256
271
  Stop
257
272
  </button>
258
273
  )}
259
- {sessionId && visibleQueuedMessages.length > 1 && (
274
+ {sessionId && pendingQueuedMessages.length > 0 && (
260
275
  <button
261
276
  type="button"
262
277
  onClick={() => { void clearQueuedMessagesForSession(sessionId) }}
@@ -268,21 +283,26 @@ export function ChatInput({ streaming, busy, onSend, onStop, extensionChatAction
268
283
  </div>
269
284
  </div>
270
285
  <div className="max-h-[184px] space-y-1.5 overflow-y-auto px-2.5 py-2.5">
271
- {visibleQueuedMessages.map((item, index) => (
286
+ {displayedQueuedMessages.map((item, index) => (
272
287
  <div
273
288
  key={item.runId}
274
289
  className={`group flex items-start gap-3 rounded-[12px] border px-3 py-2.5 transition-all ${
275
- index === 0
276
- ? 'border-amber-300/20 bg-amber-300/[0.07]'
277
- : 'border-white/[0.05] bg-white/[0.02]'
290
+ item.sending
291
+ ? 'border-sky-300/15 bg-sky-300/[0.06]'
292
+ : item.runId === nextPendingRunId
293
+ ? 'border-amber-300/20 bg-amber-300/[0.07]'
294
+ : 'border-white/[0.05] bg-white/[0.02]'
278
295
  }`}
279
296
  >
280
297
  <div className={`mt-0.5 flex h-6 min-w-6 items-center justify-center rounded-[8px] px-2 text-[10px] font-700 ${
281
- index === 0
282
- ? 'bg-amber-300/15 text-amber-100'
283
- : 'bg-white/[0.06] text-text-3'
284
- }`}>
285
- {index + 1}
298
+ item.sending
299
+ ? 'bg-sky-300/15 text-sky-100'
300
+ : item.runId === nextPendingRunId
301
+ ? 'bg-amber-300/15 text-amber-100'
302
+ : 'border-white/[0.05] bg-white/[0.02]'
303
+ }`}
304
+ >
305
+ {item.sending ? '>' : pendingQueuedMessages.findIndex((candidate) => candidate.runId === item.runId) + 1}
286
306
  </div>
287
307
  <div className="min-w-0 flex-1">
288
308
  <div className="flex flex-wrap items-center gap-2">
@@ -290,7 +310,7 @@ export function ChatInput({ streaming, busy, onSend, onStop, extensionChatAction
290
310
  <span className="rounded-pill border border-sky-300/15 bg-sky-300/10 px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.12em] text-sky-200 animate-pulse">
291
311
  Sending
292
312
  </span>
293
- ) : index === 0 && (
313
+ ) : item.runId === nextPendingRunId && (
294
314
  <span className="rounded-pill border border-amber-300/15 bg-amber-300/10 px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.12em] text-amber-100">
295
315
  Next
296
316
  </span>
@@ -851,6 +851,8 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
851
851
  <label className="block text-[11px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Port</label>
852
852
  <input
853
853
  type="number"
854
+ min={1024}
855
+ max={65535}
854
856
  value={localPort}
855
857
  onChange={(e) => setLocalPort(Number.parseInt(e.target.value, 10) || 18789)}
856
858
  className="w-full rounded-[12px] border border-white/[0.08] bg-bg px-3 py-3 text-[13px] text-text outline-none focus:border-accent-bright/30"
@@ -1168,6 +1170,8 @@ export function OpenClawDeployPanel(props: OpenClawDeployPanelProps) {
1168
1170
  />
1169
1171
  <input
1170
1172
  type="number"
1173
+ min={1}
1174
+ max={65535}
1171
1175
  value={sshPort}
1172
1176
  onChange={(e) => setSshPort(Number.parseInt(e.target.value, 10) || 22)}
1173
1177
  placeholder="22"
@@ -1,10 +1,10 @@
1
1
  import { hmrSingleton } from '@/lib/shared-utils'
2
- import { log } from '@/lib/server/logger'
3
2
 
4
3
  const TAG = 'instrumentation'
5
4
 
6
5
  export async function register() {
7
6
  if (process.env.NEXT_RUNTIME === 'nodejs') {
7
+ const { log } = await import('@/lib/server/logger')
8
8
  const isWorkerOnly = process.env.SWARMCLAW_WORKER_ONLY === '1'
9
9
  const { initWsServer, closeWsServer } = await import('./lib/server/ws-hub')
10
10
  const { ensureDaemonStarted } = await import('@/lib/server/runtime/daemon-state')
@@ -0,0 +1,3 @@
1
+ export function createAssistantRenderId(): string {
2
+ return `assistant-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
3
+ }