@swarmclawai/swarmclaw 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,7 @@ Inspired by [OpenClaw](https://github.com/openclaw).
20
20
  - Always use the access key authentication (generated on first run)
21
21
  - Never expose port 3456 without a reverse proxy + TLS
22
22
  - Review agent system prompts before giving them shell or browser tools
23
+ - Repeated failed access key attempts are rate-limited to slow brute-force attacks
23
24
 
24
25
  ## Features
25
26
 
@@ -40,6 +41,8 @@ Inspired by [OpenClaw](https://github.com/openclaw).
40
41
  - **Session Run Queue** — Per-session queued runs with followup/steer/collect modes, collect coalescing for bursty inputs, and run-state APIs
41
42
  - **Chat Iteration Workflow** — Edit-and-resend user turns, fork a new session from any message, bookmark key messages, use contextual follow-up suggestion chips, and auto-continue after tool access grants
42
43
  - **Live Chat Telemetry** — Thinking/tool/responding stream phases, live main-loop status badges, connector activity presence, tone indicator, and optional sound notifications
44
+ - **Global Search Palette** — `Cmd/Ctrl+K` search across agents, tasks, sessions, schedules, webhooks, and skills from anywhere in the app
45
+ - **Notification Center** — Real-time in-app notifications for task/schedule/daemon events with unread tracking and quick actions
43
46
  - **Preview-Rich Chat UI** — Side preview panel for tool outputs (image/browser/html/code), inline code/PDF previews for attachments, and image lightbox support
44
47
  - **Voice Settings** — Per-instance ElevenLabs API key + voice ID for TTS replies, plus configurable speech recognition language for chat input
45
48
  - **Chat Connectors** — Bridge agents to Discord, Slack, Telegram, WhatsApp, BlueBubbles (iMessage), Signal, Microsoft Teams, Google Chat, Matrix, and OpenClaw with media-aware inbound handling
@@ -48,6 +51,7 @@ Inspired by [OpenClaw](https://github.com/openclaw).
48
51
  - **Context Management** — Auto-compaction of conversation history when approaching context limits, with manual `context_status` and `context_summarize` tools for agents
49
52
  - **Memory** — Per-agent and per-session memory with hybrid FTS5 + vector embeddings search, relevance-based memory recall injected into runs, and periodic auto-journaling for durable execution context
50
53
  - **Cost Tracking** — Per-message token counting and cost estimation displayed in the chat header
54
+ - **Provider Health Metrics** — Usage dashboard surfaces provider request volume, success rates, models used, and last-used timestamps
51
55
  - **Model Failover** — Automatic key rotation on rate limits and auth errors with configurable fallback credentials
52
56
  - **Plugin System** — Extend agent behavior with JS plugins (hooks: beforeAgentStart, afterAgentComplete, beforeToolExec, afterToolExec, onMessage)
53
57
  - **Secrets Vault** — Encrypted storage for API keys and service tokens
@@ -81,7 +85,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
81
85
  ```
82
86
 
83
87
  The installer resolves the latest stable release tag and installs that version by default.
84
- To pin a version: `SWARMCLAW_VERSION=v0.5.2 curl ... | bash`
88
+ To pin a version: `SWARMCLAW_VERSION=v0.5.3 curl ... | bash`
85
89
 
86
90
  Or run locally from the repo (friendly for non-technical users):
87
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,27 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { loadNotifications, markNotificationRead, deleteNotification } from '@/lib/server/storage'
3
+ import { notify } from '@/lib/server/ws-hub'
4
+
5
+ export async function PUT(_req: Request, { params }: { params: Promise<{ id: string }> }) {
6
+ const { id } = await params
7
+ const all = loadNotifications()
8
+ if (!all[id]) {
9
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
10
+ }
11
+
12
+ markNotificationRead(id)
13
+ notify('notifications')
14
+ return NextResponse.json({ ok: true })
15
+ }
16
+
17
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
18
+ const { id } = await params
19
+ const all = loadNotifications()
20
+ if (!all[id]) {
21
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
22
+ }
23
+
24
+ deleteNotification(id)
25
+ notify('notifications')
26
+ return NextResponse.json({ ok: true })
27
+ }
@@ -0,0 +1,64 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { genId } from '@/lib/id'
3
+ import { loadNotifications, saveNotification, deleteNotification } from '@/lib/server/storage'
4
+ import { notify } from '@/lib/server/ws-hub'
5
+ import type { AppNotification } from '@/types'
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export async function GET(req: Request) {
9
+ const { searchParams } = new URL(req.url)
10
+ const unreadOnly = searchParams.get('unreadOnly') === 'true'
11
+ const limit = Math.min(200, Math.max(1, Number(searchParams.get('limit')) || 100))
12
+
13
+ const all = loadNotifications()
14
+ let entries = Object.values(all) as AppNotification[]
15
+
16
+ if (unreadOnly) {
17
+ entries = entries.filter((e) => !e.read)
18
+ }
19
+
20
+ entries.sort((a, b) => b.createdAt - a.createdAt)
21
+ entries = entries.slice(0, limit)
22
+
23
+ return NextResponse.json(entries)
24
+ }
25
+
26
+ export async function POST(req: Request) {
27
+ const body = (await req.json()) as Record<string, unknown>
28
+ const id = genId()
29
+ const notification: AppNotification = {
30
+ id,
31
+ type: (['info', 'success', 'warning', 'error'].includes(body.type as string) ? body.type : 'info') as AppNotification['type'],
32
+ title: typeof body.title === 'string' ? body.title : 'Notification',
33
+ message: typeof body.message === 'string' ? body.message : undefined,
34
+ entityType: typeof body.entityType === 'string' ? body.entityType : undefined,
35
+ entityId: typeof body.entityId === 'string' ? body.entityId : undefined,
36
+ read: false,
37
+ createdAt: Date.now(),
38
+ }
39
+
40
+ saveNotification(id, notification)
41
+ notify('notifications')
42
+ return NextResponse.json(notification, { status: 201 })
43
+ }
44
+
45
+ export async function DELETE(req: Request) {
46
+ const { searchParams } = new URL(req.url)
47
+ const specificId = searchParams.get('id')
48
+
49
+ if (specificId) {
50
+ deleteNotification(specificId)
51
+ } else {
52
+ // Clear all read notifications
53
+ const all = loadNotifications()
54
+ for (const raw of Object.values(all)) {
55
+ const entry = raw as AppNotification
56
+ if (entry.read) {
57
+ deleteNotification(entry.id)
58
+ }
59
+ }
60
+ }
61
+
62
+ notify('notifications')
63
+ return NextResponse.json({ ok: true })
64
+ }
@@ -0,0 +1,105 @@
1
+ import { NextResponse } from 'next/server'
2
+ import {
3
+ loadTasks,
4
+ loadAgents,
5
+ loadSessions,
6
+ loadSchedules,
7
+ loadWebhooks,
8
+ loadSkills,
9
+ } from '@/lib/server/storage'
10
+
11
+ interface SearchResult {
12
+ type: 'task' | 'agent' | 'session' | 'schedule' | 'webhook' | 'skill'
13
+ id: string
14
+ title: string
15
+ description?: string
16
+ status?: string
17
+ }
18
+
19
+ const MAX_RESULTS = 20
20
+
21
+ function matches(haystack: string | undefined | null, needle: string): boolean {
22
+ if (!haystack) return false
23
+ return haystack.toLowerCase().includes(needle)
24
+ }
25
+
26
+ function searchCollection(
27
+ collection: Record<string, Record<string, unknown>>,
28
+ type: SearchResult['type'],
29
+ needle: string,
30
+ titleKey: string,
31
+ descKey: string,
32
+ statusKey?: string,
33
+ ): SearchResult[] {
34
+ const results: SearchResult[] = []
35
+ for (const [id, item] of Object.entries(collection)) {
36
+ const title = item[titleKey] as string | undefined
37
+ const desc = item[descKey] as string | undefined
38
+ const idStr = typeof item.id === 'string' ? item.id : id
39
+ if (matches(title, needle) || matches(desc, needle) || matches(idStr, needle)) {
40
+ results.push({
41
+ type,
42
+ id,
43
+ title: title || idStr || id,
44
+ description: desc ? desc.slice(0, 120) : undefined,
45
+ status: statusKey ? (item[statusKey] as string | undefined) : undefined,
46
+ })
47
+ }
48
+ }
49
+ return results
50
+ }
51
+
52
+ export async function GET(req: Request) {
53
+ const { searchParams } = new URL(req.url)
54
+ const q = (searchParams.get('q') || '').trim().toLowerCase()
55
+
56
+ if (q.length < 2) {
57
+ return NextResponse.json({ results: [] })
58
+ }
59
+
60
+ const tasks = loadTasks() as Record<string, Record<string, unknown>>
61
+ const agents = loadAgents() as Record<string, Record<string, unknown>>
62
+ const sessions = loadSessions() as Record<string, Record<string, unknown>>
63
+ const schedules = loadSchedules() as Record<string, Record<string, unknown>>
64
+ const webhooks = loadWebhooks() as Record<string, Record<string, unknown>>
65
+ const skills = loadSkills() as Record<string, Record<string, unknown>>
66
+
67
+ const buckets: SearchResult[][] = [
68
+ searchCollection(agents, 'agent', q, 'name', 'description'),
69
+ searchCollection(tasks, 'task', q, 'title', 'description', 'status'),
70
+ searchCollection(sessions, 'session', q, 'name', 'cwd'),
71
+ searchCollection(schedules, 'schedule', q, 'name', 'taskPrompt', 'status'),
72
+ searchCollection(webhooks, 'webhook', q, 'name', 'source'),
73
+ searchCollection(skills, 'skill', q, 'name', 'description'),
74
+ ]
75
+
76
+ // Proportional allocation across types
77
+ const totalRaw = buckets.reduce((s, b) => s + b.length, 0)
78
+ if (totalRaw === 0) {
79
+ return NextResponse.json({ results: [] })
80
+ }
81
+
82
+ const results: SearchResult[] = []
83
+ if (totalRaw <= MAX_RESULTS) {
84
+ for (const bucket of buckets) results.push(...bucket)
85
+ } else {
86
+ // Give each bucket a fair share, round-robin leftover slots
87
+ const perBucket = Math.floor(MAX_RESULTS / buckets.length)
88
+ let remaining = MAX_RESULTS
89
+ for (const bucket of buckets) {
90
+ const take = Math.min(bucket.length, perBucket)
91
+ results.push(...bucket.slice(0, take))
92
+ remaining -= take
93
+ }
94
+ // Fill remaining slots from buckets that had more
95
+ for (const bucket of buckets) {
96
+ if (remaining <= 0) break
97
+ const alreadyTaken = Math.min(bucket.length, perBucket)
98
+ const extra = bucket.slice(alreadyTaken, alreadyTaken + remaining)
99
+ results.push(...extra)
100
+ remaining -= extra.length
101
+ }
102
+ }
103
+
104
+ return NextResponse.json({ results })
105
+ }
@@ -7,6 +7,7 @@ import { ensureTaskCompletionReport } from '@/lib/server/task-reports'
7
7
  import { formatValidationFailure, validateTaskCompletion } from '@/lib/server/task-validation'
8
8
  import { pushMainLoopEventToMainSessions } from '@/lib/server/main-agent-loop'
9
9
  import { notify } from '@/lib/server/ws-hub'
10
+ import { createNotification } from '@/lib/server/create-notification'
10
11
 
11
12
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
12
13
  // Keep completed queue integrity even if daemon is not running.
@@ -76,6 +77,13 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
76
77
  // If task is manually transitioned to a terminal status, disable session heartbeat.
77
78
  if (prevStatus !== tasks[id].status && (tasks[id].status === 'completed' || tasks[id].status === 'failed')) {
78
79
  disableSessionHeartbeat(tasks[id].sessionId)
80
+ createNotification({
81
+ type: tasks[id].status === 'completed' ? 'success' : 'error',
82
+ title: `Task ${tasks[id].status}: "${tasks[id].title}"`,
83
+ message: tasks[id].status === 'failed' ? tasks[id].error?.slice(0, 200) : undefined,
84
+ entityType: 'task',
85
+ entityId: id,
86
+ })
79
87
  }
80
88
 
81
89
  // Dependency check: cannot queue a task if any blocker is incomplete
@@ -78,6 +78,50 @@ export async function GET(req: Request) {
78
78
  .sort(([a], [b]) => a.localeCompare(b))
79
79
  .map(([bucket, data]) => ({ bucket, tokens: data.tokens, cost: Math.round(data.cost * 10000) / 10000 }))
80
80
 
81
+ // Provider health stats
82
+ const healthAccum: Record<string, {
83
+ totalRequests: number
84
+ successCount: number
85
+ errorCount: number
86
+ lastUsed: number
87
+ models: Set<string>
88
+ }> = {}
89
+
90
+ for (const r of records) {
91
+ const prov = r.provider || 'unknown'
92
+ if (!healthAccum[prov]) {
93
+ healthAccum[prov] = { totalRequests: 0, successCount: 0, errorCount: 0, lastUsed: 0, models: new Set() }
94
+ }
95
+ const h = healthAccum[prov]
96
+ h.totalRequests += 1
97
+ // UsageRecord has no error/status field — logged records are successful completions
98
+ h.successCount += 1
99
+ if ((r.timestamp || 0) > h.lastUsed) h.lastUsed = r.timestamp || 0
100
+ if (r.model) h.models.add(r.model)
101
+ }
102
+
103
+ const providerHealth: Record<string, {
104
+ totalRequests: number
105
+ successCount: number
106
+ errorCount: number
107
+ errorRate: number
108
+ avgLatencyMs: number
109
+ lastUsed: number
110
+ models: string[]
111
+ }> = {}
112
+
113
+ for (const [prov, h] of Object.entries(healthAccum)) {
114
+ providerHealth[prov] = {
115
+ totalRequests: h.totalRequests,
116
+ successCount: h.successCount,
117
+ errorCount: h.errorCount,
118
+ errorRate: h.totalRequests > 0 ? h.errorCount / h.totalRequests : 0,
119
+ avgLatencyMs: 0, // UsageRecord does not track latency
120
+ lastUsed: h.lastUsed,
121
+ models: Array.from(h.models),
122
+ }
123
+ }
124
+
81
125
  return NextResponse.json({
82
126
  records,
83
127
  totalTokens,
@@ -85,5 +129,6 @@ export async function GET(req: Request) {
85
129
  byAgent,
86
130
  byProvider,
87
131
  timeSeries,
132
+ providerHealth,
88
133
  })
89
134
  }
package/src/cli/index.js CHANGED
@@ -183,6 +183,17 @@ const COMMAND_GROUPS = [
183
183
  cmd('get', 'GET', '/memory-images/:filename', 'Download memory image by filename', { responseType: 'binary' }),
184
184
  ],
185
185
  },
186
+ {
187
+ name: 'notifications',
188
+ description: 'Manage in-app notifications',
189
+ commands: [
190
+ cmd('list', 'GET', '/notifications', 'List notifications (use --query unreadOnly=true --query limit=100)'),
191
+ cmd('create', 'POST', '/notifications', 'Create notification', { expectsJsonBody: true }),
192
+ cmd('clear', 'DELETE', '/notifications', 'Clear read notifications'),
193
+ cmd('mark-read', 'PUT', '/notifications/:id', 'Mark notification as read'),
194
+ cmd('delete', 'DELETE', '/notifications/:id', 'Delete notification by id'),
195
+ ],
196
+ },
186
197
  {
187
198
  name: 'mcp-servers',
188
199
  description: 'Manage MCP server configurations',
@@ -293,6 +304,13 @@ const COMMAND_GROUPS = [
293
304
  cmd('models-clear', 'DELETE', '/providers/:id/models', 'Clear provider model overrides'),
294
305
  ],
295
306
  },
307
+ {
308
+ name: 'search',
309
+ description: 'Global search across app resources',
310
+ commands: [
311
+ cmd('query', 'GET', '/search', 'Search agents/tasks/sessions/schedules/webhooks/skills (use --query q=term)'),
312
+ ],
313
+ },
296
314
  {
297
315
  name: 'runs',
298
316
  description: 'Session run queue/history',
package/src/cli/spec.js CHANGED
@@ -130,6 +130,16 @@ const COMMAND_GROUPS = {
130
130
  get: { description: 'Download memory image by filename', method: 'GET', path: '/memory-images/:filename', params: ['filename'], binary: true },
131
131
  },
132
132
  },
133
+ notifications: {
134
+ description: 'In-app notification center',
135
+ commands: {
136
+ list: { description: 'List notifications (supports --query unreadOnly=true,limit=100)', method: 'GET', path: '/notifications' },
137
+ create: { description: 'Create notification', method: 'POST', path: '/notifications' },
138
+ clear: { description: 'Clear read notifications', method: 'DELETE', path: '/notifications' },
139
+ 'mark-read': { description: 'Mark notification as read', method: 'PUT', path: '/notifications/:id', params: ['id'] },
140
+ delete: { description: 'Delete notification by id', method: 'DELETE', path: '/notifications/:id', params: ['id'] },
141
+ },
142
+ },
133
143
  orchestrator: {
134
144
  description: 'Orchestrator runs and run-state APIs',
135
145
  commands: {
@@ -198,6 +208,12 @@ const COMMAND_GROUPS = {
198
208
  'models-reset': { description: 'Delete provider model overrides', method: 'DELETE', path: '/providers/:id/models', params: ['id'] },
199
209
  },
200
210
  },
211
+ search: {
212
+ description: 'Global search across app resources',
213
+ commands: {
214
+ query: { description: 'Search agents/tasks/sessions/schedules/webhooks/skills (supports --query q=term)', method: 'GET', path: '/search' },
215
+ },
216
+ },
201
217
  schedules: {
202
218
  description: 'Scheduled task automation',
203
219
  commands: {
@@ -40,10 +40,12 @@ import { ActivityFeed } from '@/components/activity/activity-feed'
40
40
  import { MetricsDashboard } from '@/components/usage/metrics-dashboard'
41
41
  import { ProjectList } from '@/components/projects/project-list'
42
42
  import { ProjectSheet } from '@/components/projects/project-sheet'
43
+ import { SearchDialog } from '@/components/shared/search-dialog'
43
44
  import { NetworkBanner } from './network-banner'
44
45
  import { UpdateBanner } from './update-banner'
45
46
  import { MobileHeader } from './mobile-header'
46
47
  import { DaemonIndicator } from './daemon-indicator'
48
+ import { NotificationCenter } from '@/components/shared/notification-center'
47
49
  import { ChatArea } from '@/components/chat/chat-area'
48
50
  import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
49
51
  import type { AppView } from '@/types'
@@ -238,6 +240,37 @@ export function AppLayout() {
238
240
  </RailTooltip>
239
241
  )}
240
242
 
243
+ {/* Search */}
244
+ {railExpanded ? (
245
+ <div className="px-3 mb-2">
246
+ <button
247
+ onClick={() => window.dispatchEvent(new CustomEvent('swarmclaw:open-search'))}
248
+ className="w-full flex items-center gap-2.5 px-3 py-2 rounded-[10px] text-[13px] font-500 cursor-pointer transition-all
249
+ bg-transparent text-text-3 hover:text-text hover:bg-white/[0.04] border-none"
250
+ style={{ fontFamily: 'inherit' }}
251
+ >
252
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
253
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
254
+ </svg>
255
+ Search
256
+ <kbd className="ml-auto px-1.5 py-0.5 rounded-[5px] bg-white/[0.06] border border-white/[0.08] text-[10px] font-mono text-text-3">
257
+ ⌘K
258
+ </kbd>
259
+ </button>
260
+ </div>
261
+ ) : (
262
+ <RailTooltip label="Search" description="Search across all entities (⌘K)">
263
+ <button
264
+ onClick={() => window.dispatchEvent(new CustomEvent('swarmclaw:open-search'))}
265
+ className="rail-btn self-center mb-2"
266
+ >
267
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
268
+ <circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
269
+ </svg>
270
+ </button>
271
+ </RailTooltip>
272
+ )}
273
+
241
274
  {/* Nav items */}
242
275
  <div className={`flex flex-col gap-0.5 ${railExpanded ? 'px-3' : 'items-center'}`}>
243
276
  <NavItem view="agents" label="Agents" expanded={railExpanded} active={activeView} sidebarOpen={sidebarOpen} onClick={() => handleNavClick('agents')}>
@@ -363,6 +396,18 @@ export function AppLayout() {
363
396
  </RailTooltip>
364
397
  )}
365
398
  {railExpanded && <DaemonIndicator />}
399
+ {railExpanded ? (
400
+ <div className="flex items-center gap-1 px-3 py-1">
401
+ <span className="text-[12px] font-500 text-text-3 flex-1">Alerts</span>
402
+ <NotificationCenter />
403
+ </div>
404
+ ) : (
405
+ <RailTooltip label="Notifications" description="View system notifications">
406
+ <div className="rail-btn flex items-center justify-center">
407
+ <NotificationCenter />
408
+ </div>
409
+ </RailTooltip>
410
+ )}
366
411
  {railExpanded ? (
367
412
  <button
368
413
  onClick={() => handleNavClick('settings')}
@@ -644,6 +689,7 @@ export function AppLayout() {
644
689
  </div>
645
690
  </ErrorBoundary>
646
691
 
692
+ <SearchDialog />
647
693
  <AgentSheet />
648
694
  <ScheduleSheet />
649
695
  <MemorySheet />
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useAppStore } from '@/stores/use-app-store'
4
4
  import { IconButton } from '@/components/shared/icon-button'
5
+ import { NotificationCenter } from '@/components/shared/notification-center'
5
6
 
6
7
  export function MobileHeader() {
7
8
  const toggleSidebar = useAppStore((s) => s.toggleSidebar)
@@ -26,6 +27,7 @@ export function MobileHeader() {
26
27
  <span className="font-700">SwarmClaw</span>
27
28
  )}
28
29
  </h1>
30
+ <NotificationCenter />
29
31
  </header>
30
32
  )
31
33
  }
@@ -3,6 +3,7 @@
3
3
  import type { Schedule } from '@/types'
4
4
  import { useAppStore } from '@/stores/use-app-store'
5
5
  import { api } from '@/lib/api-client'
6
+ import { cronToHuman } from '@/lib/cron-human'
6
7
 
7
8
  const STATUS_COLORS: Record<string, string> = {
8
9
  active: 'text-emerald-400 bg-emerald-400/[0.08]',
@@ -95,7 +96,7 @@ export function ScheduleCard({ schedule, inSidebar }: Props) {
95
96
  <div className="text-[12px] text-text-3/70 mt-1.5 truncate">
96
97
  {agent?.name || 'Unknown agent'} &middot; {schedule.scheduleType}
97
98
  {!inSidebar && schedule.scheduleType === 'cron' && schedule.cron && (
98
- <span className="font-mono text-text-3/50 ml-1">({schedule.cron})</span>
99
+ <span className="text-text-3/50 ml-1" title={schedule.cron}>({cronToHuman(schedule.cron)})</span>
99
100
  )}
100
101
  {!inSidebar && schedule.scheduleType === 'interval' && schedule.intervalMs && (
101
102
  <span className="text-text-3/50 ml-1">