@swarmclawai/swarmclaw 0.7.7 → 0.7.8

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 (63) hide show
  1. package/README.md +10 -9
  2. package/package.json +1 -1
  3. package/src/app/api/chats/route.ts +1 -0
  4. package/src/app/api/connectors/[id]/route.ts +20 -2
  5. package/src/app/api/connectors/route.ts +12 -8
  6. package/src/app/api/projects/[id]/route.ts +6 -2
  7. package/src/app/api/projects/route.ts +4 -3
  8. package/src/app/api/secrets/[id]/route.ts +1 -0
  9. package/src/app/api/secrets/route.ts +2 -1
  10. package/src/app/api/settings/route.ts +2 -0
  11. package/src/components/agents/agent-sheet.tsx +184 -14
  12. package/src/components/chat/chat-area.tsx +36 -19
  13. package/src/components/chat/chat-header.tsx +4 -0
  14. package/src/components/chat/delegation-banner.test.ts +14 -1
  15. package/src/components/chat/delegation-banner.tsx +1 -1
  16. package/src/components/layout/app-layout.tsx +40 -23
  17. package/src/components/projects/project-detail.tsx +217 -0
  18. package/src/components/projects/project-sheet.tsx +176 -4
  19. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  20. package/src/components/shared/settings/section-voice.tsx +11 -3
  21. package/src/components/tasks/approvals-panel.tsx +177 -18
  22. package/src/components/tasks/task-board.tsx +137 -23
  23. package/src/components/tasks/task-card.tsx +29 -0
  24. package/src/components/tasks/task-sheet.tsx +16 -4
  25. package/src/lib/server/capability-router.test.ts +22 -0
  26. package/src/lib/server/capability-router.ts +54 -18
  27. package/src/lib/server/chat-execution.ts +25 -1
  28. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  29. package/src/lib/server/connectors/manager.ts +99 -74
  30. package/src/lib/server/daemon-state.ts +83 -46
  31. package/src/lib/server/elevenlabs.test.ts +59 -1
  32. package/src/lib/server/heartbeat-service.ts +5 -1
  33. package/src/lib/server/main-agent-loop.test.ts +260 -0
  34. package/src/lib/server/main-agent-loop.ts +559 -14
  35. package/src/lib/server/orchestrator-lg.ts +1 -0
  36. package/src/lib/server/orchestrator.ts +2 -0
  37. package/src/lib/server/plugins.ts +6 -1
  38. package/src/lib/server/project-context.ts +162 -0
  39. package/src/lib/server/project-utils.ts +150 -0
  40. package/src/lib/server/queue-followups.test.ts +147 -2
  41. package/src/lib/server/queue.ts +234 -7
  42. package/src/lib/server/session-run-manager.ts +31 -0
  43. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  44. package/src/lib/server/session-tools/connector.ts +26 -1
  45. package/src/lib/server/session-tools/context.ts +5 -0
  46. package/src/lib/server/session-tools/crud.ts +265 -76
  47. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  48. package/src/lib/server/session-tools/delegate.ts +38 -2
  49. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  50. package/src/lib/server/session-tools/memory.ts +14 -2
  51. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  52. package/src/lib/server/session-tools/platform.ts +60 -19
  53. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  54. package/src/lib/server/session-tools/web.ts +153 -6
  55. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  56. package/src/lib/server/stream-agent-chat.ts +104 -30
  57. package/src/lib/server/tool-aliases.ts +2 -0
  58. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  59. package/src/lib/server/tool-capability-policy.ts +29 -1
  60. package/src/lib/server/tool-planning.test.ts +44 -0
  61. package/src/lib/server/tool-planning.ts +269 -0
  62. package/src/lib/tool-definitions.ts +2 -1
  63. package/src/types/index.ts +39 -0
@@ -12,6 +12,25 @@ const PROJECT_COLORS = [
12
12
  ]
13
13
 
14
14
  const inputClass = 'w-full px-3 py-2.5 rounded-lg bg-white/[0.06] border border-white/[0.06] text-[13px] text-text-1 placeholder:text-text-3/40 focus:outline-none focus:border-accent/40 transition-colors'
15
+ const sectionTitleClass = 'block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2'
16
+
17
+ function listToText(values?: string[]) {
18
+ return Array.isArray(values) ? values.join('\n') : ''
19
+ }
20
+
21
+ function textToList(value: string) {
22
+ return value
23
+ .split('\n')
24
+ .map((entry) => entry.trim())
25
+ .filter(Boolean)
26
+ }
27
+
28
+ function parseOptionalInteger(value: string) {
29
+ const trimmed = value.trim()
30
+ if (!trimmed) return undefined
31
+ const parsed = Number.parseInt(trimmed, 10)
32
+ return Number.isFinite(parsed) ? parsed : undefined
33
+ }
15
34
 
16
35
  export function ProjectSheet() {
17
36
  const open = useAppStore((s) => s.projectSheetOpen)
@@ -24,6 +43,15 @@ export function ProjectSheet() {
24
43
  const [name, setName] = useState('')
25
44
  const [description, setDescription] = useState('')
26
45
  const [color, setColor] = useState<string | undefined>(undefined)
46
+ const [objective, setObjective] = useState('')
47
+ const [audience, setAudience] = useState('')
48
+ const [prioritiesText, setPrioritiesText] = useState('')
49
+ const [openObjectivesText, setOpenObjectivesText] = useState('')
50
+ const [capabilityHintsText, setCapabilityHintsText] = useState('')
51
+ const [credentialRequirementsText, setCredentialRequirementsText] = useState('')
52
+ const [successMetricsText, setSuccessMetricsText] = useState('')
53
+ const [heartbeatPrompt, setHeartbeatPrompt] = useState('')
54
+ const [heartbeatIntervalSec, setHeartbeatIntervalSec] = useState('')
27
55
 
28
56
  const editing = editingId ? projects[editingId] : null
29
57
 
@@ -33,10 +61,28 @@ export function ProjectSheet() {
33
61
  setName(editing.name)
34
62
  setDescription(editing.description)
35
63
  setColor(editing.color)
64
+ setObjective(editing.objective || '')
65
+ setAudience(editing.audience || '')
66
+ setPrioritiesText(listToText(editing.priorities))
67
+ setOpenObjectivesText(listToText(editing.openObjectives))
68
+ setCapabilityHintsText(listToText(editing.capabilityHints))
69
+ setCredentialRequirementsText(listToText(editing.credentialRequirements))
70
+ setSuccessMetricsText(listToText(editing.successMetrics))
71
+ setHeartbeatPrompt(editing.heartbeatPrompt || '')
72
+ setHeartbeatIntervalSec(editing.heartbeatIntervalSec ? String(editing.heartbeatIntervalSec) : '')
36
73
  } else {
37
74
  setName('')
38
75
  setDescription('')
39
76
  setColor(PROJECT_COLORS[0])
77
+ setObjective('')
78
+ setAudience('')
79
+ setPrioritiesText('')
80
+ setOpenObjectivesText('')
81
+ setCapabilityHintsText('')
82
+ setCredentialRequirementsText('')
83
+ setSuccessMetricsText('')
84
+ setHeartbeatPrompt('')
85
+ setHeartbeatIntervalSec('')
40
86
  }
41
87
  }
42
88
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -52,6 +98,15 @@ export function ProjectSheet() {
52
98
  name: name.trim() || 'Unnamed Project',
53
99
  description,
54
100
  color,
101
+ objective: objective.trim() || undefined,
102
+ audience: audience.trim() || undefined,
103
+ priorities: textToList(prioritiesText),
104
+ openObjectives: textToList(openObjectivesText),
105
+ capabilityHints: textToList(capabilityHintsText),
106
+ credentialRequirements: textToList(credentialRequirementsText),
107
+ successMetrics: textToList(successMetricsText),
108
+ heartbeatPrompt: heartbeatPrompt.trim() || undefined,
109
+ heartbeatIntervalSec: parseOptionalInteger(heartbeatIntervalSec),
55
110
  }
56
111
  if (editing) {
57
112
  await updateProject(editing.id, data)
@@ -72,10 +127,10 @@ export function ProjectSheet() {
72
127
  }
73
128
 
74
129
  return (
75
- <BottomSheet open={open} onClose={onClose}>
130
+ <BottomSheet open={open} onClose={onClose} wide>
76
131
  <h2 className="font-display text-[18px] font-700 text-text mb-6">{editing ? 'Edit Project' : 'New Project'}</h2>
77
132
  <div className="mb-6">
78
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Name</label>
133
+ <label className={sectionTitleClass}>Name</label>
79
134
  <input
80
135
  type="text"
81
136
  value={name}
@@ -88,7 +143,7 @@ export function ProjectSheet() {
88
143
  </div>
89
144
 
90
145
  <div className="mb-6">
91
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Description</label>
146
+ <label className={sectionTitleClass}>Description</label>
92
147
  <textarea
93
148
  value={description}
94
149
  onChange={(e) => setDescription(e.target.value)}
@@ -99,8 +154,125 @@ export function ProjectSheet() {
99
154
  />
100
155
  </div>
101
156
 
157
+ <div className="grid gap-6 sm:grid-cols-2 mb-6">
158
+ <div>
159
+ <label className={sectionTitleClass}>Objective</label>
160
+ <textarea
161
+ value={objective}
162
+ onChange={(e) => setObjective(e.target.value)}
163
+ placeholder="What durable outcome is this project driving?"
164
+ className={inputClass + ' min-h-[88px] resize-y'}
165
+ style={{ fontFamily: 'inherit' }}
166
+ rows={4}
167
+ />
168
+ </div>
169
+ <div>
170
+ <label className={sectionTitleClass}>Audience</label>
171
+ <textarea
172
+ value={audience}
173
+ onChange={(e) => setAudience(e.target.value)}
174
+ placeholder="Who is this project for?"
175
+ className={inputClass + ' min-h-[88px] resize-y'}
176
+ style={{ fontFamily: 'inherit' }}
177
+ rows={4}
178
+ />
179
+ </div>
180
+ </div>
181
+
182
+ <div className="grid gap-6 sm:grid-cols-2 mb-6">
183
+ <div>
184
+ <label className={sectionTitleClass}>Pilot Priorities</label>
185
+ <textarea
186
+ value={prioritiesText}
187
+ onChange={(e) => setPrioritiesText(e.target.value)}
188
+ placeholder={'One per line\nResearch the market\nBuild the pilot'}
189
+ className={inputClass + ' min-h-[110px] resize-y'}
190
+ style={{ fontFamily: 'inherit' }}
191
+ rows={5}
192
+ />
193
+ <p className="mt-2 text-[11px] text-text-3/45">One priority per line.</p>
194
+ </div>
195
+ <div>
196
+ <label className={sectionTitleClass}>Open Objectives</label>
197
+ <textarea
198
+ value={openObjectivesText}
199
+ onChange={(e) => setOpenObjectivesText(e.target.value)}
200
+ placeholder={'One per line\nDraft the research brief\nPrepare the rollout checklist'}
201
+ className={inputClass + ' min-h-[110px] resize-y'}
202
+ style={{ fontFamily: 'inherit' }}
203
+ rows={5}
204
+ />
205
+ <p className="mt-2 text-[11px] text-text-3/45">Use this for durable next outcomes, not one-off chat prompts.</p>
206
+ </div>
207
+ </div>
208
+
209
+ <div className="grid gap-6 sm:grid-cols-2 mb-6">
210
+ <div>
211
+ <label className={sectionTitleClass}>Capability Hints</label>
212
+ <textarea
213
+ value={capabilityHintsText}
214
+ onChange={(e) => setCapabilityHintsText(e.target.value)}
215
+ placeholder={'One per line\nResearch\nWeb browsing\nInbox automation'}
216
+ className={inputClass + ' min-h-[110px] resize-y'}
217
+ style={{ fontFamily: 'inherit' }}
218
+ rows={5}
219
+ />
220
+ </div>
221
+ <div>
222
+ <label className={sectionTitleClass}>Credential Requirements</label>
223
+ <textarea
224
+ value={credentialRequirementsText}
225
+ onChange={(e) => setCredentialRequirementsText(e.target.value)}
226
+ placeholder={'One per line\nGmail app password\nCRM API token'}
227
+ className={inputClass + ' min-h-[110px] resize-y'}
228
+ style={{ fontFamily: 'inherit' }}
229
+ rows={5}
230
+ />
231
+ </div>
232
+ </div>
233
+
234
+ <div className="grid gap-6 sm:grid-cols-2 mb-6">
235
+ <div>
236
+ <label className={sectionTitleClass}>Success Metrics</label>
237
+ <textarea
238
+ value={successMetricsText}
239
+ onChange={(e) => setSuccessMetricsText(e.target.value)}
240
+ placeholder={'One per line\nReduce response time below 10 minutes\nIncrease qualified replies'}
241
+ className={inputClass + ' min-h-[96px] resize-y'}
242
+ style={{ fontFamily: 'inherit' }}
243
+ rows={4}
244
+ />
245
+ </div>
246
+ <div className="grid gap-4">
247
+ <div>
248
+ <label className={sectionTitleClass}>Heartbeat Prompt</label>
249
+ <textarea
250
+ value={heartbeatPrompt}
251
+ onChange={(e) => setHeartbeatPrompt(e.target.value)}
252
+ placeholder="What should the project heartbeat ask the agent to review?"
253
+ className={inputClass + ' min-h-[72px] resize-y'}
254
+ style={{ fontFamily: 'inherit' }}
255
+ rows={3}
256
+ />
257
+ </div>
258
+ <div>
259
+ <label className={sectionTitleClass}>Heartbeat Interval (seconds)</label>
260
+ <input
261
+ type="number"
262
+ min={0}
263
+ step={60}
264
+ value={heartbeatIntervalSec}
265
+ onChange={(e) => setHeartbeatIntervalSec(e.target.value)}
266
+ placeholder="1800"
267
+ className={inputClass}
268
+ style={{ fontFamily: 'inherit' }}
269
+ />
270
+ </div>
271
+ </div>
272
+ </div>
273
+
102
274
  <div className="mb-8">
103
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Color</label>
275
+ <label className={sectionTitleClass}>Color</label>
104
276
  <div className="flex items-center gap-2">
105
277
  {PROJECT_COLORS.map((c) => (
106
278
  <button
@@ -46,6 +46,44 @@ export function CapabilityPolicySection({ appSettings, patchSettings, inputClass
46
46
  </div>
47
47
 
48
48
  <div className="grid grid-cols-1 gap-4">
49
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
50
+ <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
51
+ <div className="flex items-center justify-between gap-4">
52
+ <div>
53
+ <div className="text-[12px] font-600 text-text-2">Task Management</div>
54
+ <p className="text-[11px] text-text-3/60 mt-1 leading-relaxed">
55
+ Controls the task board and agent access to durable backlog tracking. Internal queue execution still works underneath.
56
+ </p>
57
+ </div>
58
+ <button
59
+ onClick={() => patchSettings({ taskManagementEnabled: !(appSettings.taskManagementEnabled ?? true) })}
60
+ className={`relative w-10 h-[22px] rounded-full transition-colors duration-200 cursor-pointer ${(appSettings.taskManagementEnabled ?? true) ? 'bg-accent' : 'bg-white/[0.12]'}`}
61
+ aria-label="Toggle task management"
62
+ >
63
+ <span className={`absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200 ${(appSettings.taskManagementEnabled ?? true) ? 'translate-x-[18px]' : ''}`} />
64
+ </button>
65
+ </div>
66
+ </div>
67
+
68
+ <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
69
+ <div className="flex items-center justify-between gap-4">
70
+ <div>
71
+ <div className="text-[12px] font-600 text-text-2">Project Management</div>
72
+ <p className="text-[11px] text-text-3/60 mt-1 leading-relaxed">
73
+ Controls the project operating-system UI and agent access to durable project context for objectives, credentials, and heartbeat plans.
74
+ </p>
75
+ </div>
76
+ <button
77
+ onClick={() => patchSettings({ projectManagementEnabled: !(appSettings.projectManagementEnabled ?? true) })}
78
+ className={`relative w-10 h-[22px] rounded-full transition-colors duration-200 cursor-pointer ${(appSettings.projectManagementEnabled ?? true) ? 'bg-accent' : 'bg-white/[0.12]'}`}
79
+ aria-label="Toggle project management"
80
+ >
81
+ <span className={`absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200 ${(appSettings.projectManagementEnabled ?? true) ? 'translate-x-[18px]' : ''}`} />
82
+ </button>
83
+ </div>
84
+ </div>
85
+ </div>
86
+
49
87
  <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-4">
50
88
  <div className="flex items-center justify-between gap-4">
51
89
  <div>
@@ -5,6 +5,8 @@ import type { SettingsSectionProps } from './types'
5
5
  export function VoiceSection({ appSettings, patchSettings, inputClass }: SettingsSectionProps) {
6
6
  const enabled = appSettings.elevenLabsEnabled ?? false
7
7
  const hasApiKey = appSettings.elevenLabsApiKeyConfigured === true
8
+ const defaultVoiceId = typeof appSettings.elevenLabsVoiceId === 'string' ? appSettings.elevenLabsVoiceId.trim() : ''
9
+ const showVoiceConfig = enabled || hasApiKey || Boolean(defaultVoiceId)
8
10
 
9
11
  return (
10
12
  <div className="mb-10">
@@ -12,7 +14,7 @@ export function VoiceSection({ appSettings, patchSettings, inputClass }: Setting
12
14
  Voice
13
15
  </h3>
14
16
  <p className="text-[12px] text-text-3 mb-5">
15
- Configure voice playback (TTS) and speech-to-text input.
17
+ Configure voice playback (TTS), the default ElevenLabs voice, and speech-to-text input.
16
18
  </p>
17
19
  <div className="p-6 rounded-[18px] bg-surface border border-white/[0.06]">
18
20
  {/* ElevenLabs toggle */}
@@ -30,7 +32,7 @@ export function VoiceSection({ appSettings, patchSettings, inputClass }: Setting
30
32
  </button>
31
33
  </div>
32
34
 
33
- {enabled && (
35
+ {showVoiceConfig && (
34
36
  <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-5">
35
37
  <div>
36
38
  <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">API Key</label>
@@ -56,11 +58,17 @@ export function VoiceSection({ appSettings, patchSettings, inputClass }: Setting
56
58
  className={inputClass}
57
59
  style={{ fontFamily: 'inherit' }}
58
60
  />
59
- <p className="text-[11px] text-text-3/60 mt-1.5">Fallback voice when an agent has no override set.</p>
61
+ <p className="text-[11px] text-text-3/60 mt-1.5">Fallback voice when an agent has no override set. Agents can override this in their own create/edit sheet.</p>
60
62
  </div>
61
63
  </div>
62
64
  )}
63
65
 
66
+ {showVoiceConfig && !enabled && (
67
+ <p className="mb-5 rounded-[12px] border border-white/[0.06] bg-white/[0.03] px-3 py-2.5 text-[11px] text-text-3/70">
68
+ ElevenLabs credentials and default voice can be prepared here even while playback is turned off.
69
+ </p>
70
+ )}
71
+
64
72
  <div>
65
73
  <label className="block font-display text-[11px] font-600 text-text-3 uppercase tracking-[0.08em] mb-2">Speech Recognition Language</label>
66
74
  <input
@@ -8,7 +8,7 @@ import { toast } from 'sonner'
8
8
  import { useWs } from '@/hooks/use-ws'
9
9
  import { ExecApprovalCard } from '@/components/chat/exec-approval-card'
10
10
  import { getApprovalPayload, getApprovalTitle } from '@/lib/approval-display'
11
- import type { ApprovalRequest } from '@/types'
11
+ import type { AppSettings, ApprovalCategory, ApprovalRequest } from '@/types'
12
12
 
13
13
  const CATEGORY_LABELS: Record<string, string> = {
14
14
  tool_access: 'Plugin Access',
@@ -28,6 +28,15 @@ const CATEGORY_ICONS: Record<string, string> = {
28
28
 
29
29
  type ApprovalScope = 'all' | 'execution' | 'workflow' | 'task'
30
30
 
31
+ const AUTO_APPROVE_OPTIONS: Array<{ id: ApprovalCategory; label: string; description: string }> = [
32
+ { id: 'tool_access', label: 'Plugin Access', description: 'Auto-enable requested plugins for a chat.' },
33
+ { id: 'plugin_scaffold', label: 'Plugin Scaffold', description: 'Auto-create plugin files requested by agents.' },
34
+ { id: 'plugin_install', label: 'Plugin Install', description: 'Auto-install plugins from approved URLs.' },
35
+ { id: 'human_loop', label: 'Human Approval Requests', description: 'Auto-approve ask-human approval prompts.' },
36
+ { id: 'wallet_transfer', label: 'Wallet Transfers', description: 'Auto-approve wallet send requests. High risk.' },
37
+ { id: 'task_tool', label: 'Task Tool Calls', description: 'Auto-approve task-level tool approvals.' },
38
+ ]
39
+
31
40
  function relativeTime(ts: number): string {
32
41
  const diff = Date.now() - ts
33
42
  if (diff < 60_000) return 'just now'
@@ -39,9 +48,11 @@ function relativeTime(ts: number): string {
39
48
  export function ApprovalsPanel() {
40
49
  const tasks = useAppStore((s) => s.tasks)
41
50
  const agents = useAppStore((s) => s.agents)
51
+ const appSettings = useAppStore((s) => s.appSettings)
42
52
  const serverApprovals = useAppStore((s) => s.approvals)
43
53
  const loadTasks = useAppStore((s) => s.loadTasks)
44
54
  const loadServerApprovals = useAppStore((s) => s.loadApprovals)
55
+ const loadAppSettings = useAppStore((s) => s.loadSettings)
45
56
 
46
57
  const execApprovals = useApprovalStore((s) => s.approvals)
47
58
  const loadExecApprovals = useApprovalStore((s) => s.loadApprovals)
@@ -73,12 +84,17 @@ export function ApprovalsPanel() {
73
84
  const [scope, setScope] = useState<ApprovalScope>('all')
74
85
  const [categoryFilter, setCategoryFilter] = useState('all')
75
86
  const [now, setNow] = useState(() => Date.now())
87
+ const [savingSetting, setSavingSetting] = useState<string | null>(null)
76
88
 
77
89
  useEffect(() => {
78
90
  const intervalId = window.setInterval(() => setNow(Date.now()), 60_000)
79
91
  return () => window.clearInterval(intervalId)
80
92
  }, [])
81
93
 
94
+ useEffect(() => {
95
+ void loadAppSettings()
96
+ }, [loadAppSettings])
97
+
82
98
  const taskApprovals = useMemo(() => {
83
99
  return Object.values(tasks)
84
100
  .filter((t) => t.pendingApproval)
@@ -180,6 +196,23 @@ export function ApprovalsPanel() {
180
196
  },
181
197
  ]
182
198
 
199
+ const autoApproved = useMemo(() => new Set(appSettings.approvalAutoApproveCategories || []), [appSettings.approvalAutoApproveCategories])
200
+ const approvalsEnabled = appSettings.approvalsEnabled ?? true
201
+ const outboundApprovalEnabled = appSettings.safetyRequireApprovalForOutbound ?? false
202
+
203
+ const saveApprovalSettings = async (patch: Partial<AppSettings>, successMessage: string, key: string) => {
204
+ try {
205
+ setSavingSetting(key)
206
+ const settings = await api<AppSettings>('PUT', '/settings', patch)
207
+ useAppStore.setState({ appSettings: settings })
208
+ toast.success(successMessage)
209
+ } catch (err: unknown) {
210
+ toast.error(err instanceof Error ? err.message : 'Failed to update approval settings')
211
+ } finally {
212
+ setSavingSetting((current) => (current === key ? null : current))
213
+ }
214
+ }
215
+
183
216
  const handleDecision = async (req: ApprovalRequest, approved: boolean) => {
184
217
  try {
185
218
  if (req.category === 'task_tool') {
@@ -195,23 +228,6 @@ export function ApprovalsPanel() {
195
228
  }
196
229
  }
197
230
 
198
- if (pendingCount === 0) {
199
- return (
200
- <div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
201
- <div className="w-16 h-16 rounded-[24px] bg-white/[0.02] border border-white/[0.04] flex items-center justify-center mb-6">
202
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/40">
203
- <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/>
204
- <path d="m9 12 2 2 4-4"/>
205
- </svg>
206
- </div>
207
- <h2 className="font-display text-[18px] font-600 text-text-2 mb-2">No pending approvals</h2>
208
- <p className="text-[13px] text-text-3/60 max-w-[320px]">
209
- Your swarm is operating autonomously. Actions requiring oversight will appear here.
210
- </p>
211
- </div>
212
- )
213
- }
214
-
215
231
  return (
216
232
  <div className="flex-1 overflow-y-auto px-6 py-8">
217
233
  <div className="max-w-3xl mx-auto">
@@ -237,6 +253,132 @@ export function ApprovalsPanel() {
237
253
  ))}
238
254
  </div>
239
255
 
256
+ <div className="rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4 mb-6">
257
+ <div className="flex flex-col gap-4">
258
+ <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-3">
259
+ <div>
260
+ <h2 className="text-[13px] font-700 text-text">Approval Controls</h2>
261
+ <p className="text-[12px] text-text-3/70 mt-1 max-w-[640px]">
262
+ Control whether actions queue for review, which approval types auto-run, and whether outbound connector sends need explicit confirmation.
263
+ </p>
264
+ </div>
265
+ <div className={`px-3 py-1.5 rounded-full text-[11px] font-700 ${
266
+ approvalsEnabled
267
+ ? 'bg-amber-500/10 border border-amber-500/20 text-amber-300'
268
+ : 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-300'
269
+ }`}>
270
+ {approvalsEnabled ? 'Manual approvals enabled' : 'Approvals disabled'}
271
+ </div>
272
+ </div>
273
+
274
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
275
+ <div className="rounded-[12px] border border-white/[0.06] bg-black/20 px-4 py-4">
276
+ <div className="flex items-center justify-between gap-4">
277
+ <div>
278
+ <div className="text-[12px] font-600 text-text-2">Platform Approvals</div>
279
+ <p className="text-[11px] text-text-3/60 mt-1 leading-relaxed">
280
+ Turn this off to auto-approve workflow approvals across the app. Audit records are still kept.
281
+ </p>
282
+ </div>
283
+ <button
284
+ type="button"
285
+ disabled={savingSetting === 'approvalsEnabled'}
286
+ onClick={() => {
287
+ const next = !approvalsEnabled
288
+ void saveApprovalSettings(
289
+ { approvalsEnabled: next },
290
+ next ? 'Platform approvals enabled' : 'Platform approvals disabled',
291
+ 'approvalsEnabled',
292
+ )
293
+ }}
294
+ className={`relative w-10 h-[22px] rounded-full transition-colors duration-200 cursor-pointer disabled:opacity-50 ${approvalsEnabled ? 'bg-accent' : 'bg-white/[0.12]'}`}
295
+ aria-label="Toggle platform approvals"
296
+ >
297
+ <span className={`absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200 ${approvalsEnabled ? 'translate-x-[18px]' : ''}`} />
298
+ </button>
299
+ </div>
300
+ </div>
301
+
302
+ <div className="rounded-[12px] border border-white/[0.06] bg-black/20 px-4 py-4">
303
+ <div className="flex items-center justify-between gap-4">
304
+ <div>
305
+ <div className="text-[12px] font-600 text-text-2">Outbound Send Approvals</div>
306
+ <p className="text-[11px] text-text-3/60 mt-1 leading-relaxed">
307
+ Require explicit approval before agents send messages or media over connectors.
308
+ </p>
309
+ </div>
310
+ <button
311
+ type="button"
312
+ disabled={savingSetting === 'safetyRequireApprovalForOutbound'}
313
+ onClick={() => {
314
+ const next = !outboundApprovalEnabled
315
+ void saveApprovalSettings(
316
+ { safetyRequireApprovalForOutbound: next },
317
+ next ? 'Outbound send approvals enabled' : 'Outbound send approvals disabled',
318
+ 'safetyRequireApprovalForOutbound',
319
+ )
320
+ }}
321
+ className={`relative w-10 h-[22px] rounded-full transition-colors duration-200 cursor-pointer disabled:opacity-50 ${outboundApprovalEnabled ? 'bg-accent' : 'bg-white/[0.12]'}`}
322
+ aria-label="Toggle outbound send approvals"
323
+ >
324
+ <span className={`absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white transition-transform duration-200 ${outboundApprovalEnabled ? 'translate-x-[18px]' : ''}`} />
325
+ </button>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ <div>
331
+ <div className="flex items-center justify-between gap-3 mb-2">
332
+ <div className="text-[12px] font-600 text-text-2">Auto-Approve Categories</div>
333
+ <div className="text-[11px] text-text-3/60">
334
+ {autoApproved.size} enabled
335
+ </div>
336
+ </div>
337
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
338
+ {AUTO_APPROVE_OPTIONS.map((option) => {
339
+ const checked = autoApproved.has(option.id)
340
+ return (
341
+ <label
342
+ key={option.id}
343
+ className={`rounded-[12px] border px-3 py-3 cursor-pointer transition-all ${
344
+ checked
345
+ ? 'border-accent-bright/30 bg-accent-soft/60'
346
+ : 'border-white/[0.06] bg-black/20 hover:bg-white/[0.04]'
347
+ }`}
348
+ >
349
+ <div className="flex items-start gap-3">
350
+ <input
351
+ type="checkbox"
352
+ checked={checked}
353
+ disabled={savingSetting === `auto:${option.id}`}
354
+ onChange={(e) => {
355
+ const next = new Set(appSettings.approvalAutoApproveCategories || [])
356
+ if (e.target.checked) next.add(option.id)
357
+ else next.delete(option.id)
358
+ void saveApprovalSettings(
359
+ { approvalAutoApproveCategories: [...next] },
360
+ checked ? `${option.label} now requires approval` : `${option.label} will auto-approve`,
361
+ `auto:${option.id}`,
362
+ )
363
+ }}
364
+ className="mt-0.5"
365
+ />
366
+ <div>
367
+ <div className="text-[12px] font-600 text-text-2">{option.label}</div>
368
+ <p className="text-[11px] text-text-3/60 mt-1 leading-relaxed">{option.description}</p>
369
+ </div>
370
+ </div>
371
+ </label>
372
+ )
373
+ })}
374
+ </div>
375
+ <p className="text-[11px] text-text-3/60 mt-2">
376
+ Use category auto-approval when you still want the approval system on, but you do not want these request types to pause execution.
377
+ </p>
378
+ </div>
379
+ </div>
380
+ </div>
381
+
240
382
  <div className="rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4 mb-6">
241
383
  <div className="flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
242
384
  <div className="flex flex-wrap gap-2">
@@ -411,6 +553,23 @@ export function ApprovalsPanel() {
411
553
  <p className="text-[12px] text-text-3/60">Try clearing the search or switching the queue scope.</p>
412
554
  </div>
413
555
  )}
556
+
557
+ {pendingCount === 0 && (
558
+ <div className="flex flex-col items-center justify-center p-8 text-center">
559
+ <div className="w-16 h-16 rounded-[24px] bg-white/[0.02] border border-white/[0.04] flex items-center justify-center mb-6">
560
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/40">
561
+ <path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/>
562
+ <path d="m9 12 2 2 4-4"/>
563
+ </svg>
564
+ </div>
565
+ <h2 className="font-display text-[18px] font-600 text-text-2 mb-2">No pending approvals</h2>
566
+ <p className="text-[13px] text-text-3/60 max-w-[360px]">
567
+ {approvalsEnabled
568
+ ? 'Your swarm is operating autonomously. Actions requiring oversight will appear here.'
569
+ : 'Approvals are currently disabled, so eligible requests will auto-run instead of queuing here.'}
570
+ </p>
571
+ </div>
572
+ )}
414
573
  </div>
415
574
  </div>
416
575
  )