@swarmclawai/swarmclaw 0.6.6 → 0.6.7

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 (80) hide show
  1. package/README.md +57 -27
  2. package/package.json +6 -1
  3. package/src/app/api/agents/[id]/clone/route.ts +40 -0
  4. package/src/app/api/agents/route.ts +39 -14
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +17 -1
  6. package/src/app/api/chatrooms/[id]/moderate/route.ts +150 -0
  7. package/src/app/api/chatrooms/[id]/route.ts +19 -1
  8. package/src/app/api/chatrooms/route.ts +12 -2
  9. package/src/app/api/connectors/[id]/health/route.ts +64 -0
  10. package/src/app/api/connectors/route.ts +17 -2
  11. package/src/app/api/knowledge/route.ts +6 -1
  12. package/src/app/api/openclaw/doctor/route.ts +17 -0
  13. package/src/app/api/sessions/[id]/chat/route.ts +5 -1
  14. package/src/app/api/sessions/route.ts +11 -2
  15. package/src/app/api/tasks/[id]/route.ts +18 -13
  16. package/src/app/api/tasks/route.ts +20 -1
  17. package/src/app/api/usage/route.ts +16 -7
  18. package/src/cli/index.js +5 -0
  19. package/src/cli/index.ts +223 -39
  20. package/src/components/agents/agent-card.tsx +37 -6
  21. package/src/components/agents/agent-chat-list.tsx +78 -2
  22. package/src/components/agents/agent-sheet.tsx +79 -0
  23. package/src/components/auth/setup-wizard.tsx +268 -353
  24. package/src/components/chat/chat-area.tsx +22 -7
  25. package/src/components/chat/message-bubble.tsx +14 -14
  26. package/src/components/chat/message-list.tsx +1 -1
  27. package/src/components/chatrooms/chatroom-message.tsx +164 -22
  28. package/src/components/chatrooms/chatroom-sheet.tsx +288 -3
  29. package/src/components/chatrooms/chatroom-view.tsx +62 -17
  30. package/src/components/connectors/connector-health.tsx +120 -0
  31. package/src/components/connectors/connector-sheet.tsx +9 -0
  32. package/src/components/home/home-view.tsx +23 -2
  33. package/src/components/input/chat-input.tsx +8 -1
  34. package/src/components/layout/app-layout.tsx +17 -1
  35. package/src/components/schedules/schedule-list.tsx +55 -9
  36. package/src/components/schedules/schedule-sheet.tsx +134 -23
  37. package/src/components/shared/command-palette.tsx +237 -0
  38. package/src/components/shared/connector-platform-icon.tsx +1 -0
  39. package/src/components/tasks/task-card.tsx +22 -2
  40. package/src/components/tasks/task-sheet.tsx +91 -16
  41. package/src/components/usage/metrics-dashboard.tsx +13 -25
  42. package/src/hooks/use-swipe.ts +49 -0
  43. package/src/lib/providers/anthropic.ts +16 -2
  44. package/src/lib/providers/claude-cli.ts +7 -1
  45. package/src/lib/providers/index.ts +7 -0
  46. package/src/lib/providers/ollama.ts +16 -2
  47. package/src/lib/providers/openai.ts +7 -2
  48. package/src/lib/providers/openclaw.ts +6 -1
  49. package/src/lib/providers/provider-defaults.ts +7 -0
  50. package/src/lib/schedule-templates.ts +115 -0
  51. package/src/lib/server/alert-dispatch.ts +64 -0
  52. package/src/lib/server/chat-execution.ts +41 -1
  53. package/src/lib/server/chatroom-helpers.ts +22 -1
  54. package/src/lib/server/chatroom-routing.ts +65 -0
  55. package/src/lib/server/connectors/discord.ts +3 -0
  56. package/src/lib/server/connectors/email.ts +267 -0
  57. package/src/lib/server/connectors/manager.ts +159 -3
  58. package/src/lib/server/connectors/openclaw.ts +3 -0
  59. package/src/lib/server/connectors/slack.ts +6 -0
  60. package/src/lib/server/connectors/telegram.ts +18 -0
  61. package/src/lib/server/connectors/types.ts +2 -0
  62. package/src/lib/server/connectors/whatsapp.ts +9 -0
  63. package/src/lib/server/cost.ts +70 -0
  64. package/src/lib/server/create-notification.ts +2 -0
  65. package/src/lib/server/daemon-state.ts +124 -0
  66. package/src/lib/server/dag-validation.ts +115 -0
  67. package/src/lib/server/memory-db.ts +12 -7
  68. package/src/lib/server/openclaw-doctor.ts +48 -0
  69. package/src/lib/server/queue.ts +12 -0
  70. package/src/lib/server/session-run-manager.ts +22 -1
  71. package/src/lib/server/session-tools/index.ts +2 -0
  72. package/src/lib/server/session-tools/memory.ts +22 -3
  73. package/src/lib/server/session-tools/openclaw-workspace.ts +132 -0
  74. package/src/lib/server/storage.ts +120 -6
  75. package/src/lib/setup-defaults.ts +277 -0
  76. package/src/lib/validation/schemas.ts +69 -0
  77. package/src/stores/use-app-store.ts +7 -3
  78. package/src/stores/use-chatroom-store.ts +52 -2
  79. package/src/types/index.ts +38 -1
  80. package/tsconfig.json +2 -1
@@ -4,6 +4,7 @@ import { useState, useCallback } from 'react'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
6
  import { updateTask, archiveTask } from '@/lib/tasks'
7
+ import { ConfirmDialog } from '@/components/shared/confirm-dialog'
7
8
  import type { BoardTask } from '@/types'
8
9
 
9
10
  function timeAgo(ts: number) {
@@ -30,7 +31,9 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: Task
30
31
  const setCurrentSession = useAppStore((s) => s.setCurrentSession)
31
32
  const setActiveView = useAppStore((s) => s.setActiveView)
32
33
  const [dragging, setDragging] = useState(false)
34
+ const [confirmArchive, setConfirmArchive] = useState(false)
33
35
 
36
+ const tasks = useAppStore((s) => s.tasks)
34
37
  const agent = agents[task.agentId]
35
38
  const project = task.projectId ? projects[task.projectId] : null
36
39
 
@@ -117,7 +120,7 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: Task
117
120
  )}
118
121
  {isBlocked && (
119
122
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-rose-400 shrink-0 mt-0.5">
120
- <title>{`Blocked by ${task.blockedBy?.length} task(s)`}</title>
123
+ <title>{`Blocked by: ${(task.blockedBy || []).map((bid) => tasks[bid]?.title || bid).join(', ')}`}</title>
121
124
  <rect x="3" y="11" width="18" height="11" rx="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
122
125
  </svg>
123
126
  )}
@@ -211,6 +214,14 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: Task
211
214
  {task.comments.length}
212
215
  </span>
213
216
  )}
217
+ {Array.isArray(task.blocks) && task.blocks.length > 0 && (
218
+ <span
219
+ className="px-1.5 py-0.5 rounded-[5px] bg-amber-500/10 text-amber-400 text-[10px] font-600"
220
+ title={`Blocks: ${task.blocks.map((bid) => tasks[bid]?.title || bid).join(', ')}`}
221
+ >
222
+ blocks {task.blocks.length}
223
+ </span>
224
+ )}
214
225
 
215
226
  {task.status === 'backlog' && (
216
227
  <button
@@ -236,7 +247,8 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: Task
236
247
 
237
248
  {(task.status === 'completed' || task.status === 'failed') && !task.sessionId && (
238
249
  <button
239
- onClick={handleArchive}
250
+ onClick={(e) => { e.stopPropagation(); setConfirmArchive(true) }}
251
+ aria-label="Archive task"
240
252
  className="ml-auto px-2.5 py-1 rounded-[8px] text-[11px] font-600 bg-white/[0.04] text-text-3 border-none cursor-pointer
241
253
  opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white/[0.08]"
242
254
  style={{ fontFamily: 'inherit' }}
@@ -304,6 +316,14 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect }: Task
304
316
  ))}
305
317
  </div>
306
318
  )}
319
+ <ConfirmDialog
320
+ open={confirmArchive}
321
+ title="Archive Task"
322
+ message={`Archive "${task.title}"? You can view archived tasks later.`}
323
+ confirmLabel="Archive"
324
+ onConfirm={() => { setConfirmArchive(false); handleArchive({ stopPropagation: () => {} } as React.MouseEvent) }}
325
+ onCancel={() => setConfirmArchive(false)}
326
+ />
307
327
  </div>
308
328
  )
309
329
  }
@@ -54,6 +54,8 @@ export function TaskSheet() {
54
54
  const [tags, setTags] = useState<string[]>([])
55
55
  const [tagInput, setTagInput] = useState('')
56
56
  const [blockedBy, setBlockedBy] = useState<string[]>([])
57
+ const [depSearch, setDepSearch] = useState('')
58
+ const [depError, setDepError] = useState<string | null>(null)
57
59
  const [dueAt, setDueAt] = useState<string>('')
58
60
  const [customFields, setCustomFields] = useState<Record<string, string | number | boolean>>({})
59
61
  const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'critical' | ''>('')
@@ -76,6 +78,8 @@ export function TaskSheet() {
76
78
  setFile(editing.file || null)
77
79
  setTags(editing.tags || [])
78
80
  setBlockedBy(editing.blockedBy || [])
81
+ setDepSearch('')
82
+ setDepError(null)
79
83
  setDueAt(editing.dueAt ? new Date(editing.dueAt).toISOString().slice(0, 10) : '')
80
84
  setCustomFields(editing.customFields || {})
81
85
  setPriority(editing.priority || '')
@@ -89,6 +93,8 @@ export function TaskSheet() {
89
93
  setFile(null)
90
94
  setTags([])
91
95
  setBlockedBy([])
96
+ setDepSearch('')
97
+ setDepError(null)
92
98
  setDueAt('')
93
99
  setCustomFields({})
94
100
  setPriority('')
@@ -119,11 +125,25 @@ export function TaskSheet() {
119
125
  customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
120
126
  priority: priority || undefined,
121
127
  } as Partial<BoardTask> & { title: string; description: string; agentId: string }
122
- if (editing) {
123
- await updateTask(editing.id, payload)
124
- } else {
125
- await createTask(payload)
128
+ try {
129
+ if (editing) {
130
+ const res = await updateTask(editing.id, payload)
131
+ if (res && typeof res === 'object' && 'error' in res) {
132
+ setDepError(String((res as unknown as Record<string, unknown>).error))
133
+ return
134
+ }
135
+ } else {
136
+ const res = await createTask(payload)
137
+ if (res && typeof res === 'object' && 'error' in res) {
138
+ setDepError(String((res as unknown as Record<string, unknown>).error))
139
+ return
140
+ }
141
+ }
142
+ } catch (err: unknown) {
143
+ setDepError(err instanceof Error ? err.message : String(err))
144
+ return
126
145
  }
146
+ setDepError(null)
127
147
  await loadTasks()
128
148
  onClose()
129
149
  }
@@ -683,18 +703,73 @@ export function TaskSheet() {
683
703
  {/* Dependencies */}
684
704
  <div className="mb-8">
685
705
  <SectionLabel>Blocked By <span className="normal-case tracking-normal font-normal text-text-3">(tasks that must complete first)</span></SectionLabel>
686
- <select
687
- multiple
688
- aria-label="Assign agents"
689
- value={blockedBy}
690
- onChange={(e) => setBlockedBy(Array.from(e.target.selectedOptions, (o) => o.value))}
691
- className="w-full px-4 py-3 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[13px] outline-none min-h-[80px] focus-glow"
692
- style={{ fontFamily: 'inherit' }}
693
- >
694
- {Object.values(tasks)
695
- .filter((t) => t.id !== editingId && t.status !== 'archived')
696
- .map((t) => <option key={t.id} value={t.id}>{t.title} ({t.status})</option>)}
697
- </select>
706
+ {/* Selected blockers as removable chips */}
707
+ {blockedBy.length > 0 && (
708
+ <div className="flex flex-wrap gap-1.5 mb-3">
709
+ {blockedBy.map((bid) => {
710
+ const bt = tasks[bid]
711
+ return (
712
+ <span key={bid} className="inline-flex items-center gap-1 px-2.5 py-1 rounded-[8px] bg-rose-500/10 text-rose-400 text-[12px] font-600">
713
+ {bt ? bt.title : bid}
714
+ <button
715
+ onClick={() => setBlockedBy((prev) => prev.filter((b) => b !== bid))}
716
+ className="text-rose-400/60 hover:text-rose-400 cursor-pointer border-none bg-transparent p-0 text-[14px] leading-none"
717
+ >
718
+ &times;
719
+ </button>
720
+ </span>
721
+ )
722
+ })}
723
+ </div>
724
+ )}
725
+ {/* Searchable dropdown for adding dependencies */}
726
+ <div className="relative">
727
+ <input
728
+ type="text"
729
+ value={depSearch}
730
+ onChange={(e) => setDepSearch(e.target.value)}
731
+ placeholder="Search tasks to add as dependency..."
732
+ className={inputClass}
733
+ style={{ fontFamily: 'inherit' }}
734
+ />
735
+ {depSearch.trim() && (
736
+ <div className="absolute z-20 top-full left-0 right-0 mt-1 max-h-[200px] overflow-y-auto rounded-[12px] border border-white/[0.08] bg-surface shadow-xl">
737
+ {Object.values(tasks)
738
+ .filter((t) =>
739
+ t.id !== editingId &&
740
+ t.status !== 'archived' &&
741
+ !blockedBy.includes(t.id) &&
742
+ t.title.toLowerCase().includes(depSearch.toLowerCase())
743
+ )
744
+ .slice(0, 10)
745
+ .map((t) => (
746
+ <button
747
+ key={t.id}
748
+ onClick={() => {
749
+ setBlockedBy((prev) => [...prev, t.id])
750
+ setDepSearch('')
751
+ }}
752
+ className="w-full text-left px-4 py-2.5 text-[13px] text-text-2 hover:bg-surface-2 cursor-pointer border-none bg-transparent transition-colors flex items-center gap-2"
753
+ style={{ fontFamily: 'inherit' }}
754
+ >
755
+ <span className="flex-1 truncate">{t.title}</span>
756
+ <span className="text-[10px] text-text-3 shrink-0">({t.status})</span>
757
+ </button>
758
+ ))}
759
+ {Object.values(tasks).filter((t) =>
760
+ t.id !== editingId &&
761
+ t.status !== 'archived' &&
762
+ !blockedBy.includes(t.id) &&
763
+ t.title.toLowerCase().includes(depSearch.toLowerCase())
764
+ ).length === 0 && (
765
+ <div className="px-4 py-3 text-[13px] text-text-3">No matching tasks</div>
766
+ )}
767
+ </div>
768
+ )}
769
+ </div>
770
+ {depError && (
771
+ <p className="mt-2 text-[12px] text-red-400 font-600">{depError}</p>
772
+ )}
698
773
  {editing && Array.isArray(editing.blocks) && editing.blocks.length > 0 && (
699
774
  <div className="mt-3">
700
775
  <span className="text-[11px] font-600 text-text-3 uppercase tracking-[0.06em]">Blocks:</span>
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useEffect, useState, useCallback } from 'react'
4
4
  import {
5
- LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
5
+ LineChart, Line, BarChart, Bar, Cell,
6
6
  XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
7
7
  } from 'recharts'
8
8
  import { useAppStore } from '@/stores/use-app-store'
@@ -32,7 +32,7 @@ interface UsageResponse {
32
32
  records: unknown[]
33
33
  totalTokens: number
34
34
  totalCost: number
35
- byAgent: Record<string, { tokens: number; cost: number }>
35
+ byAgent: Record<string, { name: string; cost: number; tokens: number; count: number }>
36
36
  byProvider: Record<string, { tokens: number; cost: number }>
37
37
  timeSeries: TimePoint[]
38
38
  providerHealth?: Record<string, ProviderHealthEntry>
@@ -171,8 +171,8 @@ export function MetricsDashboard() {
171
171
  const agentData = Object.entries(data?.byAgent ?? {})
172
172
  .sort((a, b) => b[1].cost - a[1].cost)
173
173
  .slice(0, 10)
174
- .map(([name, v]) => ({
175
- name: name.length > 12 ? name.slice(0, 12) + '…' : name,
174
+ .map(([_id, v]) => ({
175
+ name: v.name.length > 16 ? v.name.slice(0, 16) + '…' : v.name,
176
176
  cost: Math.round(v.cost * 10000) / 10000,
177
177
  }))
178
178
 
@@ -270,32 +270,20 @@ export function MetricsDashboard() {
270
270
  )}
271
271
  </ChartCard>
272
272
 
273
- <ChartCard title="Cost by Session">
273
+ <ChartCard title="Agent Breakdown">
274
274
  {agentData.length > 0 ? (
275
275
  <ResponsiveContainer width="100%" height={280}>
276
- <PieChart>
277
- <Pie
278
- data={agentData}
279
- cx="50%"
280
- cy="50%"
281
- innerRadius={60}
282
- outerRadius={100}
283
- paddingAngle={2}
284
- dataKey="cost"
285
- nameKey="name"
286
- >
276
+ <BarChart data={agentData} layout="vertical" margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
277
+ <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.06)" horizontal={false} />
278
+ <XAxis type="number" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `$${v}`} />
279
+ <YAxis type="category" dataKey="name" tick={{ fill: '#888', fontSize: 11 }} axisLine={false} tickLine={false} width={100} />
280
+ <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatCost(value ?? 0), 'Cost']} />
281
+ <Bar dataKey="cost" radius={[0, 4, 4, 0]}>
287
282
  {agentData.map((_entry, i) => (
288
283
  <Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
289
284
  ))}
290
- </Pie>
291
- <Tooltip {...tooltipStyle} formatter={(value: number | undefined) => [formatCost(value ?? 0), 'Cost']} />
292
- <Legend
293
- verticalAlign="bottom"
294
- iconType="circle"
295
- iconSize={8}
296
- formatter={(value: string) => <span style={{ color: '#a0a0b0', fontSize: 11 }}>{value}</span>}
297
- />
298
- </PieChart>
285
+ </Bar>
286
+ </BarChart>
299
287
  </ResponsiveContainer>
300
288
  ) : (
301
289
  <EmptyChart />
@@ -0,0 +1,49 @@
1
+ import { useRef, useCallback } from 'react'
2
+
3
+ interface UseSwipeOptions {
4
+ onSwipe: (direction: 'left' | 'right') => void
5
+ /** Only trigger right-swipe from this many pixels from the left edge */
6
+ edgeWidth?: number
7
+ /** Minimum horizontal distance to count as a swipe */
8
+ threshold?: number
9
+ /** Whether left-swipe is currently allowed (e.g. sidebar is open) */
10
+ leftSwipeEnabled?: boolean
11
+ }
12
+
13
+ export function useSwipe({
14
+ onSwipe,
15
+ edgeWidth = 40,
16
+ threshold = 50,
17
+ leftSwipeEnabled = false,
18
+ }: UseSwipeOptions) {
19
+ const startX = useRef(0)
20
+ const startY = useRef(0)
21
+ const isEdge = useRef(false)
22
+
23
+ const onTouchStart = useCallback((e: React.TouchEvent) => {
24
+ const touch = e.touches[0]
25
+ startX.current = touch.clientX
26
+ startY.current = touch.clientY
27
+ isEdge.current = touch.clientX <= edgeWidth
28
+ }, [edgeWidth])
29
+
30
+ // No-op — we only evaluate on touchend, but callers may wire this for consistency
31
+ const onTouchMove = useCallback(() => {}, [])
32
+
33
+ const onTouchEnd = useCallback((e: React.TouchEvent) => {
34
+ const touch = e.changedTouches[0]
35
+ const dx = touch.clientX - startX.current
36
+ const dy = touch.clientY - startY.current
37
+ // Ignore if vertical movement dominates
38
+ if (Math.abs(dy) > Math.abs(dx)) return
39
+ if (Math.abs(dx) < threshold) return
40
+
41
+ if (dx > 0 && isEdge.current) {
42
+ onSwipe('right')
43
+ } else if (dx < 0 && leftSwipeEnabled) {
44
+ onSwipe('left')
45
+ }
46
+ }, [threshold, leftSwipeEnabled, onSwipe])
47
+
48
+ return { onTouchStart, onTouchMove, onTouchEnd }
49
+ }
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs'
2
2
  import https from 'https'
3
3
  import type { StreamChatOptions } from './index'
4
+ import { PROVIDER_DEFAULTS } from './provider-defaults'
4
5
 
5
6
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
6
7
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
@@ -23,7 +24,7 @@ function fileToContentBlocks(filePath: string): any[] {
23
24
  return [{ type: 'text', text: `[Attached file: ${filePath.split('/').pop()}]` }]
24
25
  }
25
26
 
26
- export function streamAnthropicChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
27
+ export function streamAnthropicChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
27
28
  return new Promise((resolve) => {
28
29
  const messages = buildMessages(session, message, imagePath, loadHistory, imageUrl)
29
30
  const model = session.model || 'claude-sonnet-4-6'
@@ -43,9 +44,21 @@ export function streamAnthropicChat({ session, message, imagePath, imageUrl, api
43
44
  const payload = JSON.stringify(body)
44
45
  const abortController = { aborted: false }
45
46
  let fullResponse = ''
47
+ let apiReqRef: ReturnType<typeof https.request> | null = null
48
+
49
+ if (signal) {
50
+ if (signal.aborted) {
51
+ abortController.aborted = true
52
+ } else {
53
+ signal.addEventListener('abort', () => {
54
+ abortController.aborted = true
55
+ apiReqRef?.destroy()
56
+ }, { once: true })
57
+ }
58
+ }
46
59
 
47
60
  const apiReq = https.request({
48
- hostname: 'api.anthropic.com',
61
+ hostname: PROVIDER_DEFAULTS.anthropic,
49
62
  path: '/v1/messages',
50
63
  method: 'POST',
51
64
  headers: {
@@ -109,6 +122,7 @@ export function streamAnthropicChat({ session, message, imagePath, imageUrl, api
109
122
  })
110
123
  })
111
124
 
125
+ apiReqRef = apiReq
112
126
  active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
113
127
 
114
128
  apiReq.on('error', (e) => {
@@ -24,7 +24,7 @@ function findClaude(): string {
24
24
 
25
25
  const CLAUDE = findClaude()
26
26
 
27
- export function streamClaudeCliChat({ session, message, imagePath, systemPrompt, write, active }: StreamChatOptions): Promise<string> {
27
+ export function streamClaudeCliChat({ session, message, imagePath, systemPrompt, write, active, signal }: StreamChatOptions): Promise<string> {
28
28
  const processTimeoutMs = loadRuntimeSettings().cliProcessTimeoutMs
29
29
  let prompt = message
30
30
  if (imagePath) {
@@ -108,6 +108,12 @@ export function streamClaudeCliChat({ session, message, imagePath, systemPrompt,
108
108
  proc.stdin!.end()
109
109
 
110
110
  active.set(session.id, proc)
111
+
112
+ if (signal) {
113
+ if (signal.aborted) { proc.kill(); }
114
+ else signal.addEventListener('abort', () => { proc.kill() }, { once: true })
115
+ }
116
+
111
117
  let fullResponse = ''
112
118
  let buf = ''
113
119
  let eventCount = 0
@@ -29,6 +29,8 @@ export interface StreamChatOptions {
29
29
  active: Map<string, any>
30
30
  loadHistory: (sessionId: string) => any[]
31
31
  onUsage?: (usage: StreamChatUsage) => void
32
+ /** Abort signal from the caller — providers should use this to cancel in-flight requests. */
33
+ signal?: AbortSignal
32
34
  }
33
35
 
34
36
  interface BuiltinProviderConfig extends ProviderInfo {
@@ -351,6 +353,11 @@ export async function streamChatWithFailover(
351
353
  t: 'md',
352
354
  text: JSON.stringify({ failover: { from: credId, reason: err.message?.slice(0, 100) } }),
353
355
  })}\n\n`)
356
+ // Exponential backoff for rate-limit / server errors (skip for auth rotation)
357
+ if (statusCode !== 401) {
358
+ const delay = Math.min(500 * Math.pow(2, i), 8000)
359
+ await new Promise((r) => setTimeout(r, delay))
360
+ }
354
361
  continue
355
362
  }
356
363
  throw err
@@ -2,16 +2,17 @@ import fs from 'fs'
2
2
  import http from 'http'
3
3
  import https from 'https'
4
4
  import type { StreamChatOptions } from './index'
5
+ import { PROVIDER_DEFAULTS } from './provider-defaults'
5
6
 
6
7
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
7
8
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
8
9
 
9
- export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
10
+ export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
10
11
  return new Promise((resolve) => {
11
12
  const messages = buildMessages(session, message, imagePath, loadHistory)
12
13
  const model = session.model || 'llama3'
13
14
  // Cloud: no endpoint but API key present → use Ollama cloud
14
- const endpoint = session.apiEndpoint || (apiKey ? 'https://ollama.com' : 'http://localhost:11434')
15
+ const endpoint = session.apiEndpoint || (apiKey ? PROVIDER_DEFAULTS.ollamaCloud : PROVIDER_DEFAULTS.ollama)
15
16
 
16
17
  const parsed = new URL(endpoint)
17
18
  const isHttps = parsed.protocol === 'https:'
@@ -26,6 +27,18 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
26
27
 
27
28
  const abortController = { aborted: false }
28
29
  let fullResponse = ''
30
+ let apiReqRef: ReturnType<typeof http.request> | null = null
31
+
32
+ if (signal) {
33
+ if (signal.aborted) {
34
+ abortController.aborted = true
35
+ } else {
36
+ signal.addEventListener('abort', () => {
37
+ abortController.aborted = true
38
+ apiReqRef?.destroy()
39
+ }, { once: true })
40
+ }
41
+ }
29
42
 
30
43
  const headers: Record<string, string> = {
31
44
  'Content-Type': 'application/json',
@@ -87,6 +100,7 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
87
100
  })
88
101
  })
89
102
 
103
+ apiReqRef = apiReq
90
104
  active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
91
105
 
92
106
  apiReq.on('error', (e: NodeJS.ErrnoException) => {
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs'
2
2
  import type { StreamChatOptions } from './index'
3
+ import { PROVIDER_DEFAULTS } from './provider-defaults'
3
4
 
4
5
  const IMAGE_EXTS = /\.(png|jpg|jpeg|gif|webp|bmp)$/i
5
6
  const TEXT_EXTS = /\.(txt|md|csv|json|xml|html|js|ts|tsx|jsx|py|go|rs|java|c|cpp|h|yml|yaml|toml|env|log|sh|sql|css|scss)$/i
@@ -43,7 +44,7 @@ async function fileToContentParts(filePath: string): Promise<any[]> {
43
44
  return [{ type: 'text', text: `[Attached file: ${name}]` }]
44
45
  }
45
46
 
46
- export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage }: StreamChatOptions): Promise<string> {
47
+ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey, systemPrompt, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
47
48
  return new Promise(async (resolve) => {
48
49
  const messages = await buildMessages(session, message, imagePath, systemPrompt, loadHistory, imageUrl)
49
50
  const model = session.model || 'gpt-4o'
@@ -58,7 +59,7 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
58
59
  let fullResponse = ''
59
60
 
60
61
  // Support custom base URLs for custom providers
61
- const baseUrl = session.apiEndpoint || 'https://api.openai.com/v1'
62
+ const baseUrl = session.apiEndpoint || PROVIDER_DEFAULTS.openai
62
63
  const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`
63
64
 
64
65
  // OpenClaw endpoints behind Hostinger's proxy use express.json() middleware
@@ -67,6 +68,10 @@ export function streamOpenAiChat({ session, message, imagePath, imageUrl, apiKey
67
68
  const contentType = session.contentType || 'application/json'
68
69
 
69
70
  const abortController = new AbortController()
71
+ if (signal) {
72
+ if (signal.aborted) abortController.abort()
73
+ else signal.addEventListener('abort', () => abortController.abort(), { once: true })
74
+ }
70
75
  active.set(session.id, { kill: () => abortController.abort() })
71
76
 
72
77
  try {
@@ -279,7 +279,7 @@ async function connectToGateway(
279
279
 
280
280
  // --- Provider ---
281
281
 
282
- export function streamOpenClawChat({ session, message, imagePath, apiKey, write, active }: StreamChatOptions): Promise<string> {
282
+ export function streamOpenClawChat({ session, message, imagePath, apiKey, write, active, signal }: StreamChatOptions): Promise<string> {
283
283
  let prompt = message
284
284
  if (imagePath) {
285
285
  prompt = `[The user has shared an image at: ${imagePath}]\n\n${message}`
@@ -316,6 +316,11 @@ export function streamOpenClawChat({ session, message, imagePath, apiKey, write,
316
316
 
317
317
  active.set(session.id, { kill: () => { ws.close(); clearTimeout(timeout); finish('Aborted.') } })
318
318
 
319
+ if (signal) {
320
+ if (signal.aborted) { ws.close(); clearTimeout(timeout); finish('Aborted.'); return }
321
+ signal.addEventListener('abort', () => { ws.close(); clearTimeout(timeout); finish('Aborted.') }, { once: true })
322
+ }
323
+
319
324
  const agentReqId = randomUUID()
320
325
  ws.send(JSON.stringify({
321
326
  type: 'req',
@@ -0,0 +1,7 @@
1
+ /** Default base URLs for built-in LLM providers */
2
+ export const PROVIDER_DEFAULTS = {
3
+ openai: 'https://api.openai.com/v1',
4
+ anthropic: 'api.anthropic.com',
5
+ ollama: 'http://localhost:11434',
6
+ ollamaCloud: 'https://ollama.com',
7
+ } as const
@@ -0,0 +1,115 @@
1
+ export interface ScheduleTemplate {
2
+ id: string
3
+ name: string
4
+ description: string
5
+ icon: string
6
+ category: 'monitoring' | 'reporting' | 'maintenance' | 'content'
7
+ defaults: {
8
+ taskPrompt: string
9
+ scheduleType: 'cron' | 'interval'
10
+ cron?: string
11
+ intervalMs?: number
12
+ }
13
+ }
14
+
15
+ export const SCHEDULE_TEMPLATES: ScheduleTemplate[] = [
16
+ {
17
+ id: 'daily-digest',
18
+ name: 'Daily Digest',
19
+ description: 'Summarize activity from the past 24 hours each morning',
20
+ icon: 'Newspaper',
21
+ category: 'reporting',
22
+ defaults: {
23
+ taskPrompt: 'Summarize all notable activity, events, and updates from the past 24 hours. Highlight key metrics, completed tasks, and anything that needs attention.',
24
+ scheduleType: 'cron',
25
+ cron: '0 9 * * *',
26
+ },
27
+ },
28
+ {
29
+ id: 'weekly-report',
30
+ name: 'Weekly Report',
31
+ description: 'Generate a weekly metrics and progress report every Monday',
32
+ icon: 'BarChart3',
33
+ category: 'reporting',
34
+ defaults: {
35
+ taskPrompt: 'Generate a comprehensive weekly report covering key metrics, completed tasks, ongoing work, blockers, and recommendations for the coming week.',
36
+ scheduleType: 'cron',
37
+ cron: '0 10 * * 1',
38
+ },
39
+ },
40
+ {
41
+ id: 'health-monitor',
42
+ name: 'Health Monitor',
43
+ description: 'Check system health and service status every 5 minutes',
44
+ icon: 'HeartPulse',
45
+ category: 'monitoring',
46
+ defaults: {
47
+ taskPrompt: 'Perform a system health check. Verify all services are running, check resource usage (CPU, memory, disk), and report any anomalies or degraded performance.',
48
+ scheduleType: 'interval',
49
+ intervalMs: 300000,
50
+ },
51
+ },
52
+ {
53
+ id: 'content-generation',
54
+ name: 'Content Generation',
55
+ description: 'Generate daily content such as posts, summaries, or briefs',
56
+ icon: 'PenLine',
57
+ category: 'content',
58
+ defaults: {
59
+ taskPrompt: 'Generate fresh content based on current trends and recent activity. Create a well-structured draft ready for review and publishing.',
60
+ scheduleType: 'cron',
61
+ cron: '0 8 * * *',
62
+ },
63
+ },
64
+ {
65
+ id: 'data-cleanup',
66
+ name: 'Data Cleanup',
67
+ description: 'Run weekly cleanup of stale data and temporary files',
68
+ icon: 'Trash2',
69
+ category: 'maintenance',
70
+ defaults: {
71
+ taskPrompt: 'Identify and clean up stale data, expired records, orphaned files, and temporary resources. Log what was removed and any issues encountered.',
72
+ scheduleType: 'cron',
73
+ cron: '0 2 * * 0',
74
+ },
75
+ },
76
+ {
77
+ id: 'metric-snapshot',
78
+ name: 'Metric Snapshot',
79
+ description: 'Capture an hourly snapshot of key metrics and KPIs',
80
+ icon: 'Activity',
81
+ category: 'monitoring',
82
+ defaults: {
83
+ taskPrompt: 'Capture a snapshot of all key metrics and KPIs. Record current values, compare against previous snapshots, and flag any significant changes or threshold breaches.',
84
+ scheduleType: 'interval',
85
+ intervalMs: 3600000,
86
+ },
87
+ },
88
+ {
89
+ id: 'security-audit',
90
+ name: 'Security Audit',
91
+ description: 'Run a daily security scan and vulnerability check',
92
+ icon: 'ShieldCheck',
93
+ category: 'monitoring',
94
+ defaults: {
95
+ taskPrompt: 'Perform a security audit. Check for unusual access patterns, review authentication logs, scan for known vulnerabilities, and report any security concerns.',
96
+ scheduleType: 'cron',
97
+ cron: '0 0 * * *',
98
+ },
99
+ },
100
+ {
101
+ id: 'backup-check',
102
+ name: 'Backup Check',
103
+ description: 'Verify backup integrity and completeness daily',
104
+ icon: 'DatabaseBackup',
105
+ category: 'maintenance',
106
+ defaults: {
107
+ taskPrompt: 'Verify that all scheduled backups completed successfully. Check backup integrity, storage usage, and retention policy compliance. Alert on any failures.',
108
+ scheduleType: 'cron',
109
+ cron: '0 3 * * *',
110
+ },
111
+ },
112
+ ]
113
+
114
+ /** Subset of templates to feature in the empty state */
115
+ export const FEATURED_TEMPLATE_IDS = ['daily-digest', 'health-monitor', 'content-generation'] as const