@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
@@ -14,11 +14,23 @@ import { toast } from 'sonner'
14
14
  const ACTIVE_COLUMNS: BoardTaskStatus[] = ['backlog', 'queued', 'running', 'completed', 'failed']
15
15
  type BoardViewMode = 'board' | 'list'
16
16
  type AttentionFilter = 'all' | 'needs-attention' | 'approval' | 'blocked' | 'overdue' | 'failed'
17
+ type TaskScopeFilter = 'user-facing' | 'all' | 'agent'
17
18
 
18
19
  function isTaskOverdue(task: BoardTask): boolean {
19
20
  return !!task.dueAt && task.dueAt < Date.now() && task.status !== 'completed' && task.status !== 'archived'
20
21
  }
21
22
 
23
+ function isInternalAgentTask(task: BoardTask): boolean {
24
+ if (task.sourceType === 'schedule' || task.sourceType === 'delegation') return true
25
+ return Boolean(task.createdByAgentId || task.delegatedByAgentId)
26
+ }
27
+
28
+ function isTaskRelevantToAgent(task: BoardTask, agentId: string): boolean {
29
+ return task.agentId === agentId
30
+ || task.createdByAgentId === agentId
31
+ || task.delegatedByAgentId === agentId
32
+ }
33
+
22
34
  function matchesAttentionFilter(task: BoardTask, filter: AttentionFilter): boolean {
23
35
  const blocked = !!task.blockedBy?.length
24
36
  const pendingApproval = !!task.pendingApproval
@@ -139,6 +151,13 @@ export function TaskBoard() {
139
151
  if (typeof window === 'undefined') return ''
140
152
  return new URLSearchParams(window.location.search).get('tag') || ''
141
153
  })
154
+ const [taskScopeFilter, setTaskScopeFilter] = useState<TaskScopeFilter>(() => {
155
+ if (typeof window === 'undefined') return 'user-facing'
156
+ const params = new URLSearchParams(window.location.search)
157
+ if (params.get('agent')) return 'agent'
158
+ const raw = params.get('taskView')
159
+ return raw === 'all' ? 'all' : 'user-facing'
160
+ })
142
161
  const [viewMode, setViewMode] = useState<BoardViewMode>('board')
143
162
  const [attentionFilter, setAttentionFilter] = useState<AttentionFilter>('all')
144
163
 
@@ -156,13 +175,14 @@ export function TaskBoard() {
156
175
  useEffect(() => {
157
176
  if (typeof window === 'undefined') return
158
177
  const params = new URLSearchParams()
159
- if (filterAgentId) params.set('agent', filterAgentId)
178
+ if (taskScopeFilter === 'agent' && filterAgentId) params.set('agent', filterAgentId)
179
+ else if (taskScopeFilter === 'all') params.set('taskView', 'all')
160
180
  if (filterTag) params.set('tag', filterTag)
161
181
  if (activeProjectFilter) params.set('project', activeProjectFilter)
162
182
  const qs = params.toString()
163
183
  const newUrl = `${window.location.pathname}${qs ? `?${qs}` : ''}`
164
184
  window.history.replaceState(null, '', newUrl)
165
- }, [filterAgentId, filterTag, activeProjectFilter])
185
+ }, [filterAgentId, filterTag, activeProjectFilter, taskScopeFilter])
166
186
 
167
187
  const [loaded, setLoaded] = useState(Object.keys(tasks).length > 0)
168
188
  useEffect(() => { Promise.all([loadTasks(), loadAgents(), loadProjects()]).then(() => setLoaded(true)) }, [])
@@ -173,17 +193,28 @@ export function TaskBoard() {
173
193
 
174
194
  const columns: BoardTaskStatus[] = showArchived ? [...ACTIVE_COLUMNS, 'archived'] : ACTIVE_COLUMNS
175
195
 
176
- const matchesBaseFilters = useCallback((task: BoardTask) => {
196
+ const matchesScopeFilters = useCallback((task: BoardTask) => {
177
197
  if (!showArchived && task.status === 'archived') return false
178
- if (filterAgentId && task.agentId !== filterAgentId) return false
198
+ if (taskScopeFilter === 'user-facing' && isInternalAgentTask(task)) return false
199
+ if (taskScopeFilter === 'agent' && (!filterAgentId || !isTaskRelevantToAgent(task, filterAgentId))) return false
179
200
  if (filterTag && !(task.tags && task.tags.includes(filterTag))) return false
180
201
  if (activeProjectFilter && task.projectId !== activeProjectFilter) return false
202
+ return true
203
+ }, [activeProjectFilter, filterAgentId, filterTag, showArchived, taskScopeFilter])
204
+
205
+ const matchesBaseFilters = useCallback((task: BoardTask) => {
206
+ if (!matchesScopeFilters(task)) return false
181
207
  if (!matchesAttentionFilter(task, attentionFilter)) return false
182
208
  return true
183
- }, [activeProjectFilter, attentionFilter, filterAgentId, filterTag, showArchived])
209
+ }, [attentionFilter, matchesScopeFilters])
210
+
211
+ const scopedTasks = useMemo(
212
+ () => Object.values(tasks).filter(matchesScopeFilters),
213
+ [tasks, matchesScopeFilters],
214
+ )
184
215
 
185
216
  const filteredTasks = useMemo(() => (
186
- Object.values(tasks)
217
+ scopedTasks
187
218
  .filter(matchesBaseFilters)
188
219
  .sort((a, b) => {
189
220
  const rankDiff = attentionRank(a) - attentionRank(b)
@@ -192,7 +223,7 @@ export function TaskBoard() {
192
223
  if (dueDiff !== 0) return dueDiff
193
224
  return b.updatedAt - a.updatedAt
194
225
  })
195
- ), [tasks, matchesBaseFilters])
226
+ ), [scopedTasks, matchesBaseFilters])
196
227
 
197
228
  const tasksByStatus = useCallback((status: BoardTaskStatus) =>
198
229
  filteredTasks
@@ -223,7 +254,7 @@ export function TaskBoard() {
223
254
 
224
255
  // Task counts per project (non-archived)
225
256
  const projectTaskCounts: Record<string, number> = {}
226
- for (const t of Object.values(tasks)) {
257
+ for (const t of scopedTasks) {
227
258
  if (t.projectId && t.status !== 'archived') {
228
259
  projectTaskCounts[t.projectId] = (projectTaskCounts[t.projectId] || 0) + 1
229
260
  }
@@ -231,7 +262,7 @@ export function TaskBoard() {
231
262
 
232
263
  // Summary stats
233
264
  const stats = useMemo(() => {
234
- const all = Object.values(tasks).filter((t) => t.status !== 'archived')
265
+ const all = scopedTasks.filter((t) => t.status !== 'archived')
235
266
  return {
236
267
  total: all.length,
237
268
  running: all.filter((t) => t.status === 'running').length,
@@ -242,7 +273,13 @@ export function TaskBoard() {
242
273
  approvals: all.filter((t) => !!t.pendingApproval).length,
243
274
  attention: all.filter((t) => matchesAttentionFilter(t, 'needs-attention')).length,
244
275
  }
245
- }, [tasks])
276
+ }, [scopedTasks])
277
+
278
+ const activeScopeLabel = useMemo(() => {
279
+ if (taskScopeFilter === 'all') return 'All tasks'
280
+ if (taskScopeFilter === 'agent' && filterAgentId && agents[filterAgentId]) return `${agents[filterAgentId].name} activity`
281
+ return 'User-facing tasks'
282
+ }, [agents, filterAgentId, taskScopeFilter])
246
283
 
247
284
  const activeAttentionLabel = useMemo(() => {
248
285
  if (attentionFilter === 'all') return null
@@ -301,6 +338,16 @@ export function TaskBoard() {
301
338
  <p className="text-[13px] text-text-3">
302
339
  {stats.total} task{stats.total !== 1 ? 's' : ''}
303
340
  </p>
341
+ <span className="inline-flex items-center gap-1 rounded-full bg-white/[0.04] px-2 py-1 text-[11px] font-600 text-text-2">
342
+ {taskScopeFilter === 'agent' && filterAgentId && agents[filterAgentId] ? (
343
+ <>
344
+ <AgentAvatar seed={agents[filterAgentId].avatarSeed || null} avatarUrl={agents[filterAgentId].avatarUrl} name={agents[filterAgentId].name} size={14} />
345
+ {activeScopeLabel}
346
+ </>
347
+ ) : (
348
+ activeScopeLabel
349
+ )}
350
+ </span>
304
351
  {stats.running > 0 && (
305
352
  <span className="inline-flex items-center gap-1 text-[11px] font-600 text-blue-400">
306
353
  <span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
@@ -338,41 +385,83 @@ export function TaskBoard() {
338
385
  <button
339
386
  onClick={() => setAgentDropdownOpen(!agentDropdownOpen)}
340
387
  className={`flex items-center gap-2 px-3 py-2 rounded-[10px] text-[13px] font-600 cursor-pointer transition-all border
341
- ${filterAgentId
388
+ ${taskScopeFilter !== 'user-facing'
342
389
  ? 'bg-white/[0.06] border-white/[0.1] text-text-2'
343
390
  : 'bg-transparent border-white/[0.06] text-text-3 hover:bg-white/[0.03]'}`}
344
391
  style={{ fontFamily: 'inherit', minWidth: 130 }}
345
392
  >
346
- {filterAgentId && agents[filterAgentId] ? (
393
+ {taskScopeFilter === 'agent' && filterAgentId && agents[filterAgentId] ? (
347
394
  <>
348
395
  <AgentAvatar seed={agents[filterAgentId].avatarSeed || null} avatarUrl={agents[filterAgentId].avatarUrl} name={agents[filterAgentId].name} size={18} />
349
396
  {agents[filterAgentId].name}
350
397
  </>
351
- ) : 'All Agents'}
398
+ ) : taskScopeFilter === 'all' ? 'All Tasks' : 'User View'}
352
399
  <svg width="10" height="10" viewBox="0 0 10 10" fill="none" className="ml-auto opacity-50">
353
400
  <path d="M2.5 4L5 6.5L7.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
354
401
  </svg>
355
402
  </button>
356
403
  {agentDropdownOpen && (
357
- <div className="absolute top-full right-0 mt-1 min-w-[200px] py-1 rounded-[12px] border border-white/[0.08] bg-surface-2 shadow-lg z-50">
404
+ <div className="absolute top-full right-0 mt-1 min-w-[240px] py-1 rounded-[12px] border border-white/[0.08] bg-surface-2 shadow-lg z-50">
405
+ <button
406
+ onClick={() => {
407
+ setTaskScopeFilter('user-facing')
408
+ setFilterAgentId('')
409
+ setAgentDropdownOpen(false)
410
+ }}
411
+ className={`w-full flex items-start gap-2.5 px-3 py-2.5 text-[13px] font-600 cursor-pointer border-none text-left transition-colors
412
+ ${taskScopeFilter === 'user-facing' ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
413
+ style={{ fontFamily: 'inherit' }}
414
+ >
415
+ <span className="mt-0.5 inline-flex h-5 items-center rounded-full bg-emerald-500/12 px-1.5 text-[10px] font-700 uppercase tracking-[0.08em] text-emerald-400">
416
+ Default
417
+ </span>
418
+ <span className="min-w-0">
419
+ <span className="block">User-facing tasks</span>
420
+ <span className="mt-0.5 block text-[11px] font-500 text-text-3/60">
421
+ Hide scheduled, delegated, and agent-created internal work.
422
+ </span>
423
+ </span>
424
+ </button>
358
425
  <button
359
- onClick={() => { setFilterAgentId(''); setAgentDropdownOpen(false) }}
360
- className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-600 cursor-pointer border-none text-left transition-colors
361
- ${!filterAgentId ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
426
+ onClick={() => {
427
+ setTaskScopeFilter('all')
428
+ setFilterAgentId('')
429
+ setAgentDropdownOpen(false)
430
+ }}
431
+ className={`w-full flex items-start gap-2.5 px-3 py-2.5 text-[13px] font-600 cursor-pointer border-none text-left transition-colors
432
+ ${taskScopeFilter === 'all' ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
362
433
  style={{ fontFamily: 'inherit' }}
363
434
  >
364
- All Agents
435
+ <span className="mt-0.5 inline-flex h-5 items-center rounded-full bg-white/[0.06] px-1.5 text-[10px] font-700 uppercase tracking-[0.08em] text-text-3">
436
+ All
437
+ </span>
438
+ <span className="min-w-0">
439
+ <span className="block">All tasks</span>
440
+ <span className="mt-0.5 block text-[11px] font-500 text-text-3/60">
441
+ Include internal agent execution, schedules, and delegations.
442
+ </span>
443
+ </span>
365
444
  </button>
445
+ <div className="my-1 border-t border-white/[0.06]" />
366
446
  {Object.values(agents).sort((a, b) => a.name.localeCompare(b.name)).map((a) => (
367
447
  <button
368
448
  key={a.id}
369
- onClick={() => { setFilterAgentId(a.id); setAgentDropdownOpen(false) }}
449
+ onClick={() => {
450
+ setTaskScopeFilter('agent')
451
+ setFilterAgentId(a.id)
452
+ setAgentDropdownOpen(false)
453
+ }}
370
454
  className={`w-full flex items-center gap-2.5 px-3 py-2 text-[13px] font-600 cursor-pointer border-none text-left transition-colors
371
- ${filterAgentId === a.id ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
455
+ ${taskScopeFilter === 'agent' && filterAgentId === a.id ? 'bg-white/[0.06] text-text' : 'bg-transparent text-text-3 hover:bg-white/[0.04]'}`}
372
456
  style={{ fontFamily: 'inherit' }}
373
457
  >
374
458
  <AgentAvatar seed={a.avatarSeed || null} avatarUrl={a.avatarUrl} name={a.name} size={20} />
375
- {a.name}
459
+ <span className="min-w-0 flex-1">
460
+ <span className="block truncate">{a.name}</span>
461
+ <span className="mt-0.5 block text-[11px] font-500 text-text-3/60">
462
+ Assigned, created, or delegated by this agent
463
+ </span>
464
+ </span>
376
465
  </button>
377
466
  ))}
378
467
  </div>
@@ -520,8 +609,33 @@ export function TaskBoard() {
520
609
  ))}
521
610
  </div>
522
611
 
523
- {(activeProjectFilter && projects[activeProjectFilter]) || activeAttentionLabel ? (
612
+ {(activeProjectFilter && projects[activeProjectFilter]) || activeAttentionLabel || taskScopeFilter !== 'all' ? (
524
613
  <div className="flex flex-wrap items-center gap-2 px-8 pb-3">
614
+ {taskScopeFilter !== 'all' && (
615
+ <span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] border text-[12px] font-600 ${
616
+ taskScopeFilter === 'agent'
617
+ ? 'bg-accent-soft border-accent-bright/20 text-accent-bright'
618
+ : 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
619
+ }`}>
620
+ {taskScopeFilter === 'agent' && filterAgentId && agents[filterAgentId] ? (
621
+ <>
622
+ <AgentAvatar seed={agents[filterAgentId].avatarSeed || null} avatarUrl={agents[filterAgentId].avatarUrl} name={agents[filterAgentId].name} size={14} />
623
+ {agents[filterAgentId].name} activity
624
+ </>
625
+ ) : (
626
+ 'User-facing tasks'
627
+ )}
628
+ <button
629
+ onClick={() => {
630
+ setTaskScopeFilter('all')
631
+ setFilterAgentId('')
632
+ }}
633
+ className="ml-1 cursor-pointer border-none bg-transparent p-0 text-[14px] leading-none text-current opacity-80 hover:opacity-100"
634
+ >
635
+ &times;
636
+ </button>
637
+ </span>
638
+ )}
525
639
  {activeProjectFilter && projects[activeProjectFilter] && (
526
640
  <span className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[8px] bg-white/[0.04] border border-white/[0.06] text-[12px] font-600 text-text-2">
527
641
  <span className="w-2 h-2 rounded-full" style={{ backgroundColor: projects[activeProjectFilter].color || '#6366F1' }} />
@@ -593,7 +707,7 @@ export function TaskBoard() {
593
707
  ) : filteredTasks.length === 0 ? (
594
708
  <div className="max-w-3xl mx-auto rounded-[16px] border border-dashed border-white/[0.08] px-6 py-14 text-center">
595
709
  <p className="text-[14px] font-600 text-text-2 mb-1">No tasks match this view</p>
596
- <p className="text-[12px] text-text-3/60">Try clearing one of the active filters or switching back to the full board.</p>
710
+ <p className="text-[12px] text-text-3/60">Try clearing one of the active filters or switching back to all tasks.</p>
597
711
  </div>
598
712
  ) : (
599
713
  <div className="max-w-4xl mx-auto">
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
6
  import { updateTask, archiveTask } from '@/lib/tasks'
7
7
  import { ConfirmDialog } from '@/components/shared/confirm-dialog'
8
+ import { AgentAvatar } from '@/components/agents/agent-avatar'
8
9
  import type { BoardTask } from '@/types'
9
10
 
10
11
  function timeAgo(ts: number) {
@@ -46,6 +47,8 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect, index
46
47
  const tasks = useAppStore((s) => s.tasks)
47
48
  const agent = agents[task.agentId]
48
49
  const project = task.projectId ? projects[task.projectId] : null
50
+ const creatorAgent = task.createdByAgentId ? agents[task.createdByAgentId] : null
51
+ const delegatorAgent = task.delegatedByAgentId ? agents[task.delegatedByAgentId] : null
49
52
 
50
53
  const priorityConfig = {
51
54
  critical: { label: 'Critical', cls: 'bg-red-500/10 text-red-400' },
@@ -207,6 +210,32 @@ export function TaskCard({ task, selectionMode, selected, onToggleSelect, index
207
210
  </div>
208
211
  )}
209
212
 
213
+ {(creatorAgent || delegatorAgent || task.sourceType === 'schedule') && (
214
+ <div className="flex flex-wrap gap-1.5 mb-3">
215
+ {delegatorAgent && (
216
+ <span className="inline-flex items-center gap-1.5 rounded-[7px] bg-amber-500/10 px-2 py-1 text-[10px] font-600 text-amber-300">
217
+ <AgentAvatar seed={delegatorAgent.avatarSeed} avatarUrl={delegatorAgent.avatarUrl} name={delegatorAgent.name} size={14} />
218
+ Delegated by {delegatorAgent.name}
219
+ </span>
220
+ )}
221
+ {creatorAgent && creatorAgent.id !== delegatorAgent?.id && (
222
+ <span className="inline-flex items-center gap-1.5 rounded-[7px] bg-white/[0.05] px-2 py-1 text-[10px] font-600 text-text-2">
223
+ <AgentAvatar seed={creatorAgent.avatarSeed} avatarUrl={creatorAgent.avatarUrl} name={creatorAgent.name} size={14} />
224
+ Created by {creatorAgent.name}
225
+ </span>
226
+ )}
227
+ {task.sourceType === 'schedule' && (
228
+ <span className="inline-flex items-center gap-1.5 rounded-[7px] bg-purple-500/10 px-2 py-1 text-[10px] font-600 text-purple-300">
229
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
230
+ <circle cx="12" cy="12" r="8" />
231
+ <path d="M12 8v4l3 2" />
232
+ </svg>
233
+ {task.sourceScheduleName ? `Scheduled via ${task.sourceScheduleName}` : 'Scheduled task'}
234
+ </span>
235
+ )}
236
+ </div>
237
+ )}
238
+
210
239
  <div className="flex items-center gap-2 flex-wrap">
211
240
  {agent && (
212
241
  <span className="px-2 py-1 rounded-[6px] bg-accent-soft text-accent-bright text-[11px] font-600">
@@ -467,7 +467,7 @@ export function TaskSheet() {
467
467
  )}
468
468
 
469
469
  {/* CLI Sessions */}
470
- {(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.cliResumeId) && (
470
+ {(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.geminiResumeId || editing.cliResumeId) && (
471
471
  <div className="mb-8">
472
472
  <SectionLabel>CLI Sessions</SectionLabel>
473
473
  <div className="flex flex-wrap gap-2">
@@ -489,7 +489,13 @@ export function TaskSheet() {
489
489
  <code className="text-[11px] text-text-3 font-mono">{editing.opencodeResumeId}</code>
490
490
  </div>
491
491
  )}
492
- {!(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId) && editing.cliResumeId && (
492
+ {editing.geminiResumeId && (
493
+ <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
494
+ <span className="text-[11px] font-600 text-fuchsia-400">Gemini</span>
495
+ <code className="text-[11px] text-text-3 font-mono">{editing.geminiResumeId}</code>
496
+ </div>
497
+ )}
498
+ {!(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.geminiResumeId) && editing.cliResumeId && (
493
499
  <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
494
500
  <span className="text-[11px] font-600 text-text-2">{editing.cliProvider || 'CLI'}</span>
495
501
  <code className="text-[11px] text-text-3 font-mono">{editing.cliResumeId}</code>
@@ -971,7 +977,7 @@ export function TaskSheet() {
971
977
  </div>
972
978
  )}
973
979
 
974
- {editing && (editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.cliResumeId) && (
980
+ {editing && (editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.geminiResumeId || editing.cliResumeId) && (
975
981
  <div className="mb-8">
976
982
  <SectionLabel>CLI Sessions</SectionLabel>
977
983
  <div className="flex flex-wrap gap-2">
@@ -993,7 +999,13 @@ export function TaskSheet() {
993
999
  <code className="text-[11px] text-text-3 font-mono">{editing.opencodeResumeId}</code>
994
1000
  </div>
995
1001
  )}
996
- {!(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId) && editing.cliResumeId && (
1002
+ {editing.geminiResumeId && (
1003
+ <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
1004
+ <span className="text-[11px] font-600 text-fuchsia-400">Gemini</span>
1005
+ <code className="text-[11px] text-text-3 font-mono">{editing.geminiResumeId}</code>
1006
+ </div>
1007
+ )}
1008
+ {!(editing.claudeResumeId || editing.codexResumeId || editing.opencodeResumeId || editing.geminiResumeId) && editing.cliResumeId && (
997
1009
  <div className="flex items-center gap-2 px-3 py-2 rounded-[10px] border border-white/[0.06] bg-surface">
998
1010
  <span className="text-[11px] font-600 text-text-2">{editing.cliProvider || 'CLI'}</span>
999
1011
  <code className="text-[11px] text-text-3 font-mono">{editing.cliResumeId}</code>
@@ -19,3 +19,25 @@ test('routeTaskIntent keeps coding prompts prioritized over memory keywords', ()
19
19
  )
20
20
  assert.equal(decision.intent, 'coding')
21
21
  })
22
+
23
+ test('routeTaskIntent keeps hybrid research-plus-media prompts in research intent', () => {
24
+ const decision = routeTaskIntent(
25
+ 'Can you tell me more if there is any news related to the US-Iran war, and can you send me some screenshots and give me a summary and maybe send me a voice note about it?',
26
+ ['web_search', 'web_fetch', 'browser', 'manage_connectors'],
27
+ null,
28
+ )
29
+
30
+ assert.equal(decision.intent, 'research')
31
+ assert.deepEqual(decision.preferredTools, ['web_search', 'web_fetch', 'browser', 'connector_message_tool'])
32
+ })
33
+
34
+ test('routeTaskIntent treats direct voice-note delivery as outreach', () => {
35
+ const decision = routeTaskIntent(
36
+ 'Send me a voice note over WhatsApp summarizing what changed.',
37
+ ['manage_connectors'],
38
+ null,
39
+ )
40
+
41
+ assert.equal(decision.intent, 'outreach')
42
+ assert.deepEqual(decision.preferredTools, ['connector_message_tool'])
43
+ })
@@ -1,4 +1,5 @@
1
1
  import type { AppSettings } from '@/types'
2
+ import { getToolsForCapability, matchToolCapabilitiesForMessage, TOOL_CAPABILITY } from './tool-planning'
2
3
 
3
4
  export type TaskIntent =
4
5
  | 'coding'
@@ -27,6 +28,15 @@ function containsAny(text: string, terms: string[]): boolean {
27
28
  return terms.some((term) => text.includes(term))
28
29
  }
29
30
 
31
+ function dedupe(values: string[]): string[] {
32
+ return Array.from(new Set(values.filter(Boolean)))
33
+ }
34
+
35
+ function preferredToolsForCapabilities(enabledPlugins: string[], capabilities: string[], fallback: string[] = []): string[] {
36
+ const preferred = capabilities.flatMap((capability) => getToolsForCapability(enabledPlugins, capability))
37
+ return dedupe(preferred.length > 0 ? preferred : fallback)
38
+ }
39
+
30
40
  function normalizeDelegateOrder(value: unknown): DelegateTool[] {
31
41
  const fallback: DelegateTool[] = [
32
42
  'delegate_to_claude_code',
@@ -59,6 +69,14 @@ export function routeTaskIntent(
59
69
  const text = (message || '').toLowerCase()
60
70
  const url = findFirstUrl(message || '')
61
71
  const delegateOrder = normalizeDelegateOrder(settings?.autonomyPreferredDelegates)
72
+ const matchedCapabilities = matchToolCapabilitiesForMessage(enabledPlugins, message)
73
+ const wantsVoiceNote = matchedCapabilities.has(TOOL_CAPABILITY.deliveryVoiceNote)
74
+ const wantsScreenshots = matchedCapabilities.has(TOOL_CAPABILITY.browserCapture)
75
+ const wantsMediaDelivery = matchedCapabilities.has(TOOL_CAPABILITY.deliveryMedia)
76
+ const wantsChannelDelivery = matchedCapabilities.has(TOOL_CAPABILITY.deliveryMessage)
77
+ const researchLike = matchedCapabilities.has(TOOL_CAPABILITY.researchSearch)
78
+ || matchedCapabilities.has(TOOL_CAPABILITY.researchFetch)
79
+ || !!url
62
80
 
63
81
  const coding = containsAny(text, [
64
82
  'build',
@@ -98,12 +116,20 @@ export function routeTaskIntent(
98
116
  'discord',
99
117
  'notify',
100
118
  'broadcast',
101
- ])
119
+ ]) || (!researchLike && (wantsVoiceNote || wantsMediaDelivery || wantsChannelDelivery))
102
120
  if (outreach) {
103
121
  return {
104
122
  intent: 'outreach',
105
123
  confidence: 0.8,
106
- preferredTools: ['connector_message_tool', 'manage_connectors', 'manage_sessions'],
124
+ preferredTools: preferredToolsForCapabilities(
125
+ enabledPlugins,
126
+ [
127
+ TOOL_CAPABILITY.deliveryVoiceNote,
128
+ TOOL_CAPABILITY.deliveryMedia,
129
+ TOOL_CAPABILITY.deliveryMessage,
130
+ ],
131
+ ['connector_message_tool', 'manage_connectors', 'manage_sessions'],
132
+ ),
107
133
  preferredDelegates: delegateOrder,
108
134
  primaryUrl: url,
109
135
  }
@@ -129,36 +155,46 @@ export function routeTaskIntent(
129
155
  }
130
156
 
131
157
  const browsing = !!url && (
132
- containsAny(text, ['browser', 'click', 'fill form', 'log in', 'screenshot', 'navigate'])
133
- || enabledPlugins.includes('browser')
158
+ matchedCapabilities.has(TOOL_CAPABILITY.browserNavigate)
159
+ || matchedCapabilities.has(TOOL_CAPABILITY.browserCapture)
160
+ || getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.browserNavigate).length > 0
161
+ || getToolsForCapability(enabledPlugins, TOOL_CAPABILITY.browserCapture).length > 0
134
162
  )
135
163
  if (browsing) {
136
164
  return {
137
165
  intent: 'browsing',
138
166
  confidence: 0.7,
139
- preferredTools: ['browser', 'web_fetch'],
167
+ preferredTools: preferredToolsForCapabilities(
168
+ enabledPlugins,
169
+ [
170
+ TOOL_CAPABILITY.browserCapture,
171
+ TOOL_CAPABILITY.browserNavigate,
172
+ TOOL_CAPABILITY.researchFetch,
173
+ ],
174
+ ['browser', 'web_fetch'],
175
+ ),
140
176
  preferredDelegates: delegateOrder,
141
177
  primaryUrl: url,
142
178
  }
143
179
  }
144
180
 
145
- const research = containsAny(text, [
146
- 'research',
147
- 'look up',
148
- 'find out',
149
- 'search for',
150
- 'compare',
151
- 'latest',
152
- 'news',
153
- 'wikipedia',
154
- 'summarize this url',
155
- 'analyze website',
156
- ]) || !!url
181
+ const research = researchLike
157
182
  if (research) {
183
+ const preferred = preferredToolsForCapabilities(
184
+ enabledPlugins,
185
+ [
186
+ TOOL_CAPABILITY.researchSearch,
187
+ TOOL_CAPABILITY.researchFetch,
188
+ ...(wantsScreenshots ? [TOOL_CAPABILITY.browserCapture] : []),
189
+ ...(wantsVoiceNote ? [TOOL_CAPABILITY.deliveryVoiceNote] : []),
190
+ ...(wantsMediaDelivery || wantsChannelDelivery ? [TOOL_CAPABILITY.deliveryMedia, TOOL_CAPABILITY.deliveryMessage] : []),
191
+ ],
192
+ ['web_search', 'web_fetch', 'browser'],
193
+ )
158
194
  return {
159
195
  intent: 'research',
160
196
  confidence: 0.7,
161
- preferredTools: ['web_search', 'web_fetch', 'browser'],
197
+ preferredTools: preferred,
162
198
  preferredDelegates: delegateOrder,
163
199
  primaryUrl: url,
164
200
  }
@@ -18,7 +18,7 @@ import { getProvider } from '@/lib/providers'
18
18
  import { estimateCost, checkAgentBudgetLimits } from './cost'
19
19
  import { log } from './logger'
20
20
  import { logExecution } from './execution-log'
21
- import { streamAgentChat } from './stream-agent-chat'
21
+ import { buildToolDisciplineLines, streamAgentChat } from './stream-agent-chat'
22
22
  import { runLinkUnderstanding } from './link-understanding'
23
23
  import { buildSessionTools } from './session-tools'
24
24
  import type { StructuredToolInterface } from '@langchain/core/tools'
@@ -46,6 +46,7 @@ import { buildIdentityContinuityContext, refreshSessionIdentityState } from './i
46
46
  import { syncSessionArchiveMemory } from './session-archive-memory'
47
47
  import { evaluateSessionFreshness, resetSessionRuntime, resolveSessionResetPolicy } from './session-reset-policy'
48
48
  import { pruneStreamingAssistantArtifacts, upsertStreamingAssistantArtifact } from '@/lib/chat-streaming-state'
49
+ import { resolveActiveProjectContext } from './project-context'
49
50
  type DelegateTool = 'delegate_to_claude_code' | 'delegate_to_codex_cli' | 'delegate_to_opencode_cli' | 'delegate_to_gemini_cli'
50
51
 
51
52
  /** Slice history from the most recent context-clear marker forward */
@@ -191,6 +192,8 @@ function extractDelegateResponse(outputText: string): string | null {
191
192
  const MANAGE_PLATFORM_RESOURCE_TO_TOOL: Record<string, string> = {
192
193
  agent: 'manage_agents',
193
194
  agents: 'manage_agents',
195
+ project: 'manage_projects',
196
+ projects: 'manage_projects',
194
197
  task: 'manage_tasks',
195
198
  tasks: 'manage_tasks',
196
199
  schedule: 'manage_schedules',
@@ -671,6 +674,12 @@ function syncSessionFromAgent(sessionId: string): void {
671
674
  }
672
675
  const isShortcutChat = session.shortcutForAgentId === agent.id || agent.threadSessionId === sessionId
673
676
  if (isShortcutChat) {
677
+ const desiredPlugins = Array.isArray(agent.plugins) ? [...agent.plugins] : []
678
+ const currentPlugins = Array.isArray(session.plugins) ? [...session.plugins] : []
679
+ if (JSON.stringify(currentPlugins) !== JSON.stringify(desiredPlugins)) {
680
+ session.plugins = desiredPlugins
681
+ changed = true
682
+ }
674
683
  if (session.shortcutForAgentId !== agent.id) { session.shortcutForAgentId = agent.id; changed = true }
675
684
  if (session.name !== agent.name) { session.name = agent.name; changed = true }
676
685
  }
@@ -737,6 +746,14 @@ function buildAgentSystemPrompt(session: Session): string | undefined {
737
746
  ]
738
747
  parts.push(thinkingHint.join('\n'))
739
748
 
749
+ const enabledPlugins = Array.isArray(session.plugins) ? session.plugins : (Array.isArray(agent.plugins) ? agent.plugins : [])
750
+ const toolDisciplineLines = buildToolDisciplineLines(enabledPlugins)
751
+ if (toolDisciplineLines.length > 0) parts.push(['## Tool Discipline', ...toolDisciplineLines].join('\n'))
752
+ const operatingGuidance = getPluginManager().collectOperatingGuidance(enabledPlugins)
753
+ if (operatingGuidance.length > 0) parts.push(['## Tool Guidance', ...operatingGuidance].join('\n'))
754
+ const capabilityLines = getPluginManager().collectCapabilityDescriptions(enabledPlugins)
755
+ if (capabilityLines.length > 0) parts.push(['## Tool Capabilities', ...capabilityLines].join('\n'))
756
+
740
757
  // 7. Heartbeat Guidance
741
758
  parts.push([
742
759
  '## Heartbeats',
@@ -1261,12 +1278,18 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1261
1278
  return false
1262
1279
  }
1263
1280
  const agent = session.agentId ? loadAgents()[session.agentId] : null
1281
+ const activeProjectContext = resolveActiveProjectContext(session)
1264
1282
  const { tools, cleanup } = await buildSessionTools(session.cwd, sessionForRun.plugins || sessionForRun.tools || [], {
1265
1283
  agentId: session.agentId || null,
1266
1284
  sessionId,
1267
1285
  platformAssignScope: agent?.platformAssignScope || 'self',
1268
1286
  mcpServerIds: agent?.mcpServerIds,
1269
1287
  mcpDisabledTools: agent?.mcpDisabledTools,
1288
+ projectId: activeProjectContext.projectId,
1289
+ projectRoot: activeProjectContext.projectRoot,
1290
+ projectName: activeProjectContext.project?.name || null,
1291
+ projectDescription: activeProjectContext.project?.description || null,
1292
+ memoryScopeMode: (((session as unknown as Record<string, unknown>).memoryScopeMode as string | null | undefined) ?? agent?.memoryScopeMode ?? null),
1270
1293
  })
1271
1294
  try {
1272
1295
  const directTool = tools.find((t) => t?.name === toolName) as StructuredToolInterface | undefined
@@ -1508,6 +1531,7 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
1508
1531
  claudeCode: normalizeResumeId(sr.claudeCode ?? cr.claudeCode),
1509
1532
  codex: normalizeResumeId(sr.codex ?? cr.codex),
1510
1533
  opencode: normalizeResumeId(sr.opencode ?? cr.opencode),
1534
+ gemini: normalizeResumeId(sr.gemini ?? cr.gemini),
1511
1535
  }
1512
1536
  if (JSON.stringify(currentResume) !== JSON.stringify(nextResume)) {
1513
1537
  current.delegateResumeIds = nextResume
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import {
4
+ advanceConnectorReconnectState,
5
+ createConnectorReconnectState,
6
+ } from './manager'
7
+
8
+ test('advanceConnectorReconnectState applies exponential backoff and exhaustion', () => {
9
+ const policy = {
10
+ initialBackoffMs: 30_000,
11
+ maxBackoffMs: 15 * 60 * 1000,
12
+ maxAttempts: 3,
13
+ }
14
+
15
+ const initial = createConnectorReconnectState({}, policy)
16
+
17
+ const first = advanceConnectorReconnectState(initial, 'boom-1', 1_000, policy)
18
+ assert.equal(first.attempts, 1)
19
+ assert.equal(first.backoffMs, 30_000)
20
+ assert.equal(first.nextRetryAt, 31_000)
21
+ assert.equal(first.exhausted, false)
22
+
23
+ const second = advanceConnectorReconnectState(first, 'boom-2', 31_000, policy)
24
+ assert.equal(second.attempts, 2)
25
+ assert.equal(second.backoffMs, 60_000)
26
+ assert.equal(second.nextRetryAt, 91_000)
27
+ assert.equal(second.exhausted, false)
28
+
29
+ const third = advanceConnectorReconnectState(second, 'boom-3', 91_000, policy)
30
+ assert.equal(third.attempts, 3)
31
+ assert.equal(third.backoffMs, 120_000)
32
+ assert.equal(third.nextRetryAt, 211_000)
33
+ assert.equal(third.exhausted, true)
34
+ })
35
+
36
+ test('createConnectorReconnectState respects custom initial backoff', () => {
37
+ const state = createConnectorReconnectState(
38
+ { error: 'seeded' },
39
+ { initialBackoffMs: 45_000 },
40
+ )
41
+
42
+ assert.equal(state.attempts, 0)
43
+ assert.equal(state.backoffMs, 45_000)
44
+ assert.equal(state.nextRetryAt, 0)
45
+ assert.equal(state.error, 'seeded')
46
+ assert.equal(state.exhausted, false)
47
+ })