@swarmclawai/swarmclaw 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +9 -0
  2. package/package.json +2 -2
  3. package/skills/coding-agent/SKILL.md +111 -0
  4. package/skills/github/SKILL.md +140 -0
  5. package/skills/nano-banana-pro/SKILL.md +62 -0
  6. package/skills/nano-banana-pro/scripts/generate_image.py +235 -0
  7. package/skills/nano-pdf/SKILL.md +53 -0
  8. package/skills/openai-image-gen/SKILL.md +78 -0
  9. package/skills/openai-image-gen/scripts/gen.py +328 -0
  10. package/skills/resourceful-problem-solving/SKILL.md +49 -0
  11. package/skills/skill-creator/SKILL.md +147 -0
  12. package/skills/skill-creator/scripts/init_skill.py +378 -0
  13. package/skills/skill-creator/scripts/quick_validate.py +159 -0
  14. package/skills/summarize/SKILL.md +77 -0
  15. package/src/app/api/auth/route.ts +20 -5
  16. package/src/app/api/chats/[id]/devserver/route.ts +13 -19
  17. package/src/app/api/chats/[id]/messages/route.ts +13 -15
  18. package/src/app/api/chats/[id]/route.ts +9 -10
  19. package/src/app/api/chats/[id]/stop/route.ts +5 -7
  20. package/src/app/api/chats/messages-route.test.ts +8 -6
  21. package/src/app/api/chats/route.ts +9 -10
  22. package/src/app/api/ip/route.ts +2 -2
  23. package/src/app/api/preview-server/route.ts +1 -1
  24. package/src/app/api/projects/[id]/route.ts +7 -46
  25. package/src/components/chat/chat-area.tsx +45 -23
  26. package/src/components/chat/message-bubble.test.ts +35 -0
  27. package/src/components/chat/message-bubble.tsx +19 -9
  28. package/src/components/chat/message-list.tsx +37 -3
  29. package/src/components/input/chat-input.tsx +34 -14
  30. package/src/instrumentation.ts +1 -1
  31. package/src/lib/chat/assistant-render-id.ts +3 -0
  32. package/src/lib/chat/chat-streaming-state.test.ts +42 -3
  33. package/src/lib/chat/chat-streaming-state.ts +20 -8
  34. package/src/lib/chat/queued-message-queue.test.ts +23 -1
  35. package/src/lib/chat/queued-message-queue.ts +11 -2
  36. package/src/lib/providers/cli-utils.test.ts +124 -0
  37. package/src/lib/server/activity/activity-log.ts +21 -0
  38. package/src/lib/server/agents/agent-availability.test.ts +10 -5
  39. package/src/lib/server/agents/agent-cascade.ts +79 -59
  40. package/src/lib/server/agents/agent-registry.ts +3 -1
  41. package/src/lib/server/agents/agent-repository.ts +90 -0
  42. package/src/lib/server/agents/delegation-job-repository.ts +53 -0
  43. package/src/lib/server/agents/delegation-jobs.ts +11 -4
  44. package/src/lib/server/agents/guardian-checkpoint-repository.ts +35 -0
  45. package/src/lib/server/agents/guardian.ts +2 -2
  46. package/src/lib/server/agents/main-agent-loop.ts +10 -3
  47. package/src/lib/server/agents/main-loop-state-repository.ts +38 -0
  48. package/src/lib/server/agents/subagent-runtime.ts +9 -6
  49. package/src/lib/server/agents/subagent-swarm.ts +3 -2
  50. package/src/lib/server/agents/task-session.ts +3 -4
  51. package/src/lib/server/approvals/approval-repository.ts +30 -0
  52. package/src/lib/server/autonomy/supervisor-incident-repository.ts +42 -0
  53. package/src/lib/server/chat-execution/chat-execution-types.ts +38 -0
  54. package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
  55. package/src/lib/server/chat-execution/chat-execution.ts +84 -1926
  56. package/src/lib/server/chat-execution/chat-turn-finalization.ts +620 -0
  57. package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +221 -0
  58. package/src/lib/server/chat-execution/chat-turn-preflight.ts +133 -0
  59. package/src/lib/server/chat-execution/chat-turn-preparation.ts +817 -0
  60. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +296 -0
  61. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +5 -5
  62. package/src/lib/server/chat-execution/message-classifier.test.ts +329 -0
  63. package/src/lib/server/chat-execution/post-stream-finalization.ts +1 -1
  64. package/src/lib/server/chat-execution/prompt-builder.ts +11 -0
  65. package/src/lib/server/chat-execution/prompt-sections.ts +5 -6
  66. package/src/lib/server/chat-execution/situational-awareness.ts +12 -7
  67. package/src/lib/server/chat-execution/stream-agent-chat.ts +16 -13
  68. package/src/lib/server/chatrooms/chatroom-repository.ts +32 -0
  69. package/src/lib/server/connectors/connector-repository.ts +58 -0
  70. package/src/lib/server/connectors/runtime-state.test.ts +117 -0
  71. package/src/lib/server/credentials/credential-repository.ts +7 -0
  72. package/src/lib/server/gateways/gateway-profile-repository.ts +4 -0
  73. package/src/lib/server/memory/memory-abstract.test.ts +59 -0
  74. package/src/lib/server/missions/mission-repository.ts +74 -0
  75. package/src/lib/server/missions/mission-service/actions.ts +6 -0
  76. package/src/lib/server/missions/mission-service/bindings.ts +9 -0
  77. package/src/lib/server/missions/mission-service/context.ts +4 -0
  78. package/src/lib/server/missions/mission-service/core.ts +2269 -0
  79. package/src/lib/server/missions/mission-service/queries.ts +12 -0
  80. package/src/lib/server/missions/mission-service/recovery.ts +5 -0
  81. package/src/lib/server/missions/mission-service/ticks.ts +9 -0
  82. package/src/lib/server/missions/mission-service.test.ts +9 -2
  83. package/src/lib/server/missions/mission-service.ts +6 -2266
  84. package/src/lib/server/persistence/repository-utils.ts +154 -0
  85. package/src/lib/server/persistence/storage-context.ts +51 -0
  86. package/src/lib/server/persistence/transaction.ts +1 -0
  87. package/src/lib/server/projects/project-repository.ts +36 -0
  88. package/src/lib/server/projects/project-service.ts +79 -0
  89. package/src/lib/server/protocols/protocol-normalization.test.ts +6 -4
  90. package/src/lib/server/runtime/alert-dispatch.ts +1 -1
  91. package/src/lib/server/runtime/daemon-policy.ts +1 -1
  92. package/src/lib/server/runtime/daemon-state/core.ts +1570 -0
  93. package/src/lib/server/runtime/daemon-state/health.ts +6 -0
  94. package/src/lib/server/runtime/daemon-state/policy.ts +7 -0
  95. package/src/lib/server/runtime/daemon-state/supervisor.ts +6 -0
  96. package/src/lib/server/runtime/daemon-state.test.ts +48 -0
  97. package/src/lib/server/runtime/daemon-state.ts +3 -1470
  98. package/src/lib/server/runtime/estop-repository.ts +4 -0
  99. package/src/lib/server/runtime/estop.ts +3 -1
  100. package/src/lib/server/runtime/heartbeat-service.test.ts +2 -2
  101. package/src/lib/server/runtime/heartbeat-service.ts +55 -34
  102. package/src/lib/server/runtime/heartbeat-wake.ts +6 -4
  103. package/src/lib/server/runtime/idle-window.ts +2 -2
  104. package/src/lib/server/runtime/network.ts +11 -0
  105. package/src/lib/server/runtime/orchestrator-events.ts +2 -2
  106. package/src/lib/server/runtime/queue/claims.ts +4 -0
  107. package/src/lib/server/runtime/queue/core.ts +2079 -0
  108. package/src/lib/server/runtime/queue/execution.ts +7 -0
  109. package/src/lib/server/runtime/queue/followups.ts +4 -0
  110. package/src/lib/server/runtime/queue/queries.ts +12 -0
  111. package/src/lib/server/runtime/queue/recovery.ts +7 -0
  112. package/src/lib/server/runtime/queue-recovery.test.ts +48 -13
  113. package/src/lib/server/runtime/queue-repository.ts +17 -0
  114. package/src/lib/server/runtime/queue.ts +5 -2061
  115. package/src/lib/server/runtime/run-ledger.ts +6 -5
  116. package/src/lib/server/runtime/run-repository.ts +73 -0
  117. package/src/lib/server/runtime/runtime-lock-repository.ts +8 -0
  118. package/src/lib/server/runtime/runtime-settings.ts +1 -1
  119. package/src/lib/server/runtime/runtime-state.ts +99 -0
  120. package/src/lib/server/runtime/scheduler.ts +4 -2
  121. package/src/lib/server/runtime/session-run-manager/cancellation.ts +157 -0
  122. package/src/lib/server/runtime/session-run-manager/drain.ts +246 -0
  123. package/src/lib/server/runtime/session-run-manager/enqueue.ts +287 -0
  124. package/src/lib/server/runtime/session-run-manager/queries.ts +117 -0
  125. package/src/lib/server/runtime/session-run-manager/recovery.ts +238 -0
  126. package/src/lib/server/runtime/session-run-manager/state.ts +441 -0
  127. package/src/lib/server/runtime/session-run-manager/types.ts +74 -0
  128. package/src/lib/server/runtime/session-run-manager.ts +72 -1377
  129. package/src/lib/server/runtime/watch-job-repository.ts +35 -0
  130. package/src/lib/server/runtime/watch-jobs.ts +3 -1
  131. package/src/lib/server/schedules/schedule-repository.ts +42 -0
  132. package/src/lib/server/sessions/session-repository.ts +85 -0
  133. package/src/lib/server/settings/settings-repository.ts +25 -0
  134. package/src/lib/server/skills/skill-discovery.test.ts +2 -2
  135. package/src/lib/server/skills/skill-discovery.ts +2 -2
  136. package/src/lib/server/skills/skill-repository.ts +14 -0
  137. package/src/lib/server/storage.ts +13 -24
  138. package/src/lib/server/tasks/task-repository.ts +54 -0
  139. package/src/lib/server/usage/usage-repository.ts +30 -0
  140. package/src/lib/server/webhooks/webhook-repository.ts +10 -0
  141. package/src/lib/strip-internal-metadata.test.ts +42 -41
  142. package/src/stores/use-chat-store.test.ts +54 -0
  143. package/src/stores/use-chat-store.ts +21 -5
  144. /package/{bundled-skills → skills}/google-workspace/SKILL.md +0 -0
@@ -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>
@@ -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
+ }
@@ -43,16 +43,17 @@ describe('chat-streaming-state', () => {
43
43
  localStreaming: true,
44
44
  hasLiveArtifacts: true,
45
45
  assistantRenderId: 'render-1',
46
+ showLiveRow: true,
47
+ syntheticAssistant: messages[1],
46
48
  } as StreamingAwareMessageListOptions),
47
49
  [
48
50
  { role: 'user', text: 'hello', time: 1 },
49
51
  {
50
52
  role: 'assistant',
51
- text: 'final answer',
52
- time: 0,
53
+ text: 'partial',
54
+ time: 2,
53
55
  kind: 'chat',
54
56
  streaming: true,
55
- thinking: 'working...',
56
57
  clientRenderId: 'render-1',
57
58
  },
58
59
  ],
@@ -69,6 +70,7 @@ describe('chat-streaming-state', () => {
69
70
  localStreaming: true,
70
71
  hasLiveArtifacts: true,
71
72
  assistantRenderId: 'render-2',
73
+ showLiveRow: true,
72
74
  } as StreamingAwareMessageListOptions),
73
75
  [
74
76
  { role: 'user', text: 'hello', time: 1 },
@@ -84,6 +86,43 @@ describe('chat-streaming-state', () => {
84
86
  )
85
87
  })
86
88
 
89
+ it('can show a synthetic live row for server-driven runs using the latest persisted streaming artifact', () => {
90
+ const messages: Message[] = [
91
+ { role: 'user', text: 'queued follow-up', time: 1 },
92
+ {
93
+ role: 'assistant',
94
+ text: 'Drafting the handoff now.',
95
+ time: 2,
96
+ streaming: true,
97
+ thinking: 'Collecting the completion details',
98
+ toolEvents: [{ name: 'send_file', input: '{}', output: '/api/uploads/deck.pdf' }],
99
+ },
100
+ ]
101
+
102
+ assert.deepEqual(
103
+ buildStreamingAwareMessageList(messages, {
104
+ localStreaming: true,
105
+ hasLiveArtifacts: false,
106
+ assistantRenderId: 'render-server',
107
+ showLiveRow: true,
108
+ syntheticAssistant: messages[1],
109
+ }),
110
+ [
111
+ { role: 'user', text: 'queued follow-up', time: 1 },
112
+ {
113
+ role: 'assistant',
114
+ text: 'Drafting the handoff now.',
115
+ time: 2,
116
+ kind: 'chat',
117
+ streaming: true,
118
+ thinking: 'Collecting the completion details',
119
+ toolEvents: [{ name: 'send_file', input: '{}', output: '/api/uploads/deck.pdf' }],
120
+ clientRenderId: 'render-server',
121
+ },
122
+ ],
123
+ )
124
+ })
125
+
87
126
  it('replaces trailing streaming assistant messages with the completed assistant message', () => {
88
127
  const messages: Message[] = [
89
128
  { role: 'user', text: 'hello', time: 1 },
@@ -10,6 +10,8 @@ export interface StreamingAwareMessageListOptions {
10
10
  localStreaming: boolean
11
11
  hasLiveArtifacts: boolean
12
12
  assistantRenderId?: string | null
13
+ showLiveRow?: boolean
14
+ syntheticAssistant?: Partial<Message> | null
13
15
  }
14
16
 
15
17
  function isStreamingAssistantMessage(
@@ -27,13 +29,13 @@ function isStreamingAssistantMessage(
27
29
 
28
30
  export function shouldHidePersistedStreamingAssistantMessage(
29
31
  message: Message,
30
- opts: { localStreaming: boolean; hasLiveArtifacts: boolean },
32
+ opts: { localStreaming: boolean; hasLiveArtifacts: boolean; showLiveRow?: boolean },
31
33
  ): boolean {
34
+ const showLiveRow = opts.showLiveRow ?? (opts.localStreaming && opts.hasLiveArtifacts)
32
35
  return (
33
- opts.localStreaming
36
+ showLiveRow
34
37
  && message.role === 'assistant'
35
38
  && message.streaming === true
36
- && opts.hasLiveArtifacts
37
39
  )
38
40
  }
39
41
 
@@ -41,22 +43,32 @@ export function buildStreamingAwareMessageList(
41
43
  messages: Message[],
42
44
  opts: StreamingAwareMessageListOptions,
43
45
  ): Message[] {
44
- const nextMessages = opts.localStreaming && opts.hasLiveArtifacts
46
+ const showLiveRow = opts.showLiveRow ?? (opts.localStreaming && opts.hasLiveArtifacts)
47
+ const nextMessages = showLiveRow
45
48
  ? messages.filter((message) => !shouldHidePersistedStreamingAssistantMessage(message, opts))
46
49
  : messages
47
50
 
48
- if (!opts.localStreaming || !opts.hasLiveArtifacts || !opts.assistantRenderId) {
51
+ if (!showLiveRow || !opts.assistantRenderId) {
49
52
  return nextMessages
50
53
  }
51
54
 
52
55
  const syntheticAssistantMessage: Message = {
53
56
  role: 'assistant',
54
- text: '',
55
- time: 0,
56
- kind: 'chat',
57
+ text: typeof opts.syntheticAssistant?.text === 'string' ? opts.syntheticAssistant.text : '',
58
+ time: typeof opts.syntheticAssistant?.time === 'number' ? opts.syntheticAssistant.time : 0,
59
+ kind: opts.syntheticAssistant?.kind || 'chat',
57
60
  streaming: true,
58
61
  clientRenderId: opts.assistantRenderId,
59
62
  }
63
+ if (typeof opts.syntheticAssistant?.thinking === 'string') {
64
+ syntheticAssistantMessage.thinking = opts.syntheticAssistant.thinking
65
+ }
66
+ if (Array.isArray(opts.syntheticAssistant?.toolEvents)) {
67
+ syntheticAssistantMessage.toolEvents = opts.syntheticAssistant.toolEvents
68
+ }
69
+ if (opts.syntheticAssistant?.source) {
70
+ syntheticAssistantMessage.source = opts.syntheticAssistant.source
71
+ }
60
72
 
61
73
  return [
62
74
  ...nextMessages,
@@ -27,7 +27,7 @@ describe('queued-message-queue', () => {
27
27
  it('replaces queued items only for the requested session', () => {
28
28
  const replaced = replaceQueuedMessagesForSession(queue, 'session-a', [
29
29
  { runId: 'q4', sessionId: 'session-a', text: 'replacement', queuedAt: 4, position: 1 },
30
- ])
30
+ ], { activeRunId: null })
31
31
  assert.deepEqual(
32
32
  listQueuedMessagesForSession(replaced, 'session-a').map((item) => item.runId),
33
33
  ['q4'],
@@ -38,6 +38,28 @@ describe('queued-message-queue', () => {
38
38
  )
39
39
  })
40
40
 
41
+ it('keeps only the newly active run as a sending placeholder when it disappears from the queue snapshot', () => {
42
+ const replaced = replaceQueuedMessagesForSession(queue, 'session-a', [
43
+ { runId: 'q3', sessionId: 'session-a', text: 'second a', queuedAt: 3, position: 1 },
44
+ ], { activeRunId: 'q1' })
45
+
46
+ assert.deepEqual(
47
+ listQueuedMessagesForSession(replaced, 'session-a').map((item) => [item.runId, item.sending === true]),
48
+ [['q1', true], ['q3', false]],
49
+ )
50
+ })
51
+
52
+ it('drops missing stale queue rows that are not the active run', () => {
53
+ const replaced = replaceQueuedMessagesForSession(queue, 'session-a', [
54
+ { runId: 'q3', sessionId: 'session-a', text: 'second a', queuedAt: 3, position: 1 },
55
+ ], { activeRunId: 'run-other' })
56
+
57
+ assert.deepEqual(
58
+ listQueuedMessagesForSession(replaced, 'session-a').map((item) => item.runId),
59
+ ['q3'],
60
+ )
61
+ })
62
+
41
63
  it('removes queued items by stable id', () => {
42
64
  assert.deepEqual(removeQueuedMessageById(queue, 'q2').map((item) => item.runId), ['q1', 'q3'])
43
65
  })
@@ -41,18 +41,27 @@ export function snapshotToQueuedMessages(snapshot: SessionQueueSnapshot): Queued
41
41
  return snapshot.items.map((item) => ({ ...item }))
42
42
  }
43
43
 
44
+ interface ReplaceQueuedMessagesOptions {
45
+ activeRunId?: string | null
46
+ }
47
+
44
48
  export function replaceQueuedMessagesForSession(
45
49
  queue: QueuedSessionMessage[],
46
50
  sessionId: string,
47
51
  nextItems: QueuedSessionMessage[],
52
+ options: ReplaceQueuedMessagesOptions = {},
48
53
  ): QueuedSessionMessage[] {
49
54
  const otherSessions = queue.filter((item) => item.sessionId !== sessionId)
50
55
  const previousForSession = queue.filter((item) => item.sessionId === sessionId && !item.sending)
51
56
  // Detect consumed messages: items in local state but not in server snapshot.
52
- // Keep them visible as "sending" so they don't vanish from the UI.
57
+ // Keep only the run that actually became active visible as "sending" so it
58
+ // doesn't vanish from the UI before the transcript refresh catches up.
53
59
  const nextRunIds = new Set(nextItems.map((item) => item.runId))
60
+ const activeRunId = typeof options.activeRunId === 'string' && options.activeRunId.trim()
61
+ ? options.activeRunId
62
+ : null
54
63
  const consumed = previousForSession
55
- .filter((item) => !item.optimistic && !nextRunIds.has(item.runId))
64
+ .filter((item) => !item.optimistic && !nextRunIds.has(item.runId) && activeRunId === item.runId)
56
65
  .map((item) => ({ ...item, sending: true }))
57
66
  return [
58
67
  ...otherSessions,
@@ -0,0 +1,124 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+
4
+ import { isStderrNoise, buildCliEnv, isCliProvider, CLI_PROVIDER_CAPABILITIES } from './cli-utils'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // isStderrNoise
8
+ // ---------------------------------------------------------------------------
9
+
10
+ describe('isStderrNoise', () => {
11
+ it('returns true for MallocStackLogging lines', () => {
12
+ assert.equal(isStderrNoise('MallocStackLogging: could not tag MSL'), true)
13
+ })
14
+
15
+ it('returns true for blank/whitespace lines', () => {
16
+ assert.equal(isStderrNoise(''), true)
17
+ assert.equal(isStderrNoise(' '), true)
18
+ assert.equal(isStderrNoise('\t'), true)
19
+ })
20
+
21
+ it('returns false for real error text', () => {
22
+ assert.equal(isStderrNoise('Error: connection refused'), false)
23
+ assert.equal(isStderrNoise('FATAL: segfault'), false)
24
+ assert.equal(isStderrNoise('Permission denied'), false)
25
+ })
26
+ })
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // buildCliEnv
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('buildCliEnv', () => {
33
+ it('strips SWARMCLAW_ prefixed vars from env', () => {
34
+ const orig = process.env.SWARMCLAW_TEST_VAR
35
+ process.env.SWARMCLAW_TEST_VAR = 'should_be_stripped'
36
+ try {
37
+ const env = buildCliEnv()
38
+ assert.equal(env.SWARMCLAW_TEST_VAR, undefined)
39
+ } finally {
40
+ if (orig === undefined) delete process.env.SWARMCLAW_TEST_VAR
41
+ else process.env.SWARMCLAW_TEST_VAR = orig
42
+ }
43
+ })
44
+
45
+ it('deletes MallocStackLogging', () => {
46
+ const orig = process.env.MallocStackLogging
47
+ process.env.MallocStackLogging = '1'
48
+ try {
49
+ const env = buildCliEnv()
50
+ assert.equal(env.MallocStackLogging, undefined)
51
+ } finally {
52
+ if (orig === undefined) delete process.env.MallocStackLogging
53
+ else process.env.MallocStackLogging = orig
54
+ }
55
+ })
56
+
57
+ it('injects provided key-value pairs', () => {
58
+ const env = buildCliEnv({ inject: { MY_CUSTOM_KEY: 'hello' } })
59
+ assert.equal(env.MY_CUSTOM_KEY, 'hello')
60
+ })
61
+
62
+ it('preserves unrelated user env vars', () => {
63
+ const env = buildCliEnv()
64
+ assert.equal(env.PATH, process.env.PATH)
65
+ })
66
+
67
+ it('supports custom stripPrefixes', () => {
68
+ const orig = process.env.CUSTOM_PREFIX_VAR
69
+ process.env.CUSTOM_PREFIX_VAR = 'should_be_stripped'
70
+ try {
71
+ const env = buildCliEnv({ stripPrefixes: ['CUSTOM_PREFIX_'] })
72
+ assert.equal(env.CUSTOM_PREFIX_VAR, undefined)
73
+ } finally {
74
+ if (orig === undefined) delete process.env.CUSTOM_PREFIX_VAR
75
+ else process.env.CUSTOM_PREFIX_VAR = orig
76
+ }
77
+ })
78
+
79
+ it('sets TERM=dumb and NO_COLOR=1', () => {
80
+ const env = buildCliEnv()
81
+ assert.equal(env.TERM, 'dumb')
82
+ assert.equal(env.NO_COLOR, '1')
83
+ })
84
+ })
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // isCliProvider
88
+ // ---------------------------------------------------------------------------
89
+
90
+ describe('isCliProvider', () => {
91
+ it('returns true for known CLI providers', () => {
92
+ assert.equal(isCliProvider('claude-cli'), true)
93
+ assert.equal(isCliProvider('codex-cli'), true)
94
+ assert.equal(isCliProvider('opencode-cli'), true)
95
+ assert.equal(isCliProvider('gemini-cli'), true)
96
+ })
97
+
98
+ it('returns false for non-CLI providers', () => {
99
+ assert.equal(isCliProvider('openai'), false)
100
+ assert.equal(isCliProvider('anthropic'), false)
101
+ assert.equal(isCliProvider(''), false)
102
+ assert.equal(isCliProvider('google'), false)
103
+ })
104
+ })
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // CLI_PROVIDER_CAPABILITIES
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('CLI_PROVIDER_CAPABILITIES', () => {
111
+ it('has entries for all 4 CLI providers', () => {
112
+ assert.ok('claude-cli' in CLI_PROVIDER_CAPABILITIES)
113
+ assert.ok('codex-cli' in CLI_PROVIDER_CAPABILITIES)
114
+ assert.ok('opencode-cli' in CLI_PROVIDER_CAPABILITIES)
115
+ assert.ok('gemini-cli' in CLI_PROVIDER_CAPABILITIES)
116
+ })
117
+
118
+ it('each entry is a non-empty string', () => {
119
+ for (const [key, value] of Object.entries(CLI_PROVIDER_CAPABILITIES)) {
120
+ assert.equal(typeof value, 'string', `${key} should be a string`)
121
+ assert.ok(value.length > 0, `${key} should be non-empty`)
122
+ }
123
+ })
124
+ })
@@ -0,0 +1,21 @@
1
+ import { loadActivity as loadStoredActivity, logActivity as writeActivityLog } from '@/lib/server/storage'
2
+ import { perf } from '@/lib/server/runtime/perf'
3
+
4
+ export function loadActivity() {
5
+ return perf.measureSync('repository', 'activity.list', () => loadStoredActivity())
6
+ }
7
+
8
+ export function logActivity(entry: {
9
+ entityType: string
10
+ entityId: string
11
+ action: string
12
+ actor: string
13
+ actorId?: string
14
+ summary: string
15
+ detail?: Record<string, unknown>
16
+ }) {
17
+ perf.measureSync('repository', 'activity.log', () => writeActivityLog(entry), {
18
+ entityType: entry.entityType,
19
+ action: entry.action,
20
+ })
21
+ }
@@ -1,20 +1,25 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { describe, it } from 'node:test'
3
+ import type { Agent, ProviderType } from '@/types'
3
4
  import { isWorkerOnlyAgent, buildWorkerOnlyAgentMessage } from './agent-availability'
4
5
 
5
6
  describe('isWorkerOnlyAgent', () => {
6
- const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'openclaw']
7
- const NON_CLI_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'groq', 'custom']
7
+ const CLI_PROVIDERS = ['claude-cli', 'codex-cli', 'opencode-cli', 'gemini-cli', 'openclaw'] satisfies ProviderType[]
8
+ const NON_CLI_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'groq', 'together'] satisfies ProviderType[]
9
+
10
+ function withProvider(provider: unknown): Pick<Agent, 'provider'> {
11
+ return { provider } as Pick<Agent, 'provider'>
12
+ }
8
13
 
9
14
  for (const provider of CLI_PROVIDERS) {
10
15
  it(`returns true for ${provider}`, () => {
11
- assert.equal(isWorkerOnlyAgent({ provider }), true)
16
+ assert.equal(isWorkerOnlyAgent(withProvider(provider)), true)
12
17
  })
13
18
  }
14
19
 
15
20
  for (const provider of NON_CLI_PROVIDERS) {
16
21
  it(`returns false for ${provider}`, () => {
17
- assert.equal(isWorkerOnlyAgent({ provider }), false)
22
+ assert.equal(isWorkerOnlyAgent(withProvider(provider)), false)
18
23
  })
19
24
  }
20
25
 
@@ -27,7 +32,7 @@ describe('isWorkerOnlyAgent', () => {
27
32
  })
28
33
 
29
34
  it('returns false for empty provider string', () => {
30
- assert.equal(isWorkerOnlyAgent({ provider: '' }), false)
35
+ assert.equal(isWorkerOnlyAgent(withProvider('')), false)
31
36
  })
32
37
  })
33
38