@swarmclawai/swarmclaw 1.5.47 → 1.5.49

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 (31) hide show
  1. package/README.md +15 -0
  2. package/package.json +1 -1
  3. package/skills/swarmclaw/SKILL.md +8 -0
  4. package/src/app/api/missions/[id]/control/route.ts +57 -0
  5. package/src/app/api/missions/[id]/events/route.ts +21 -0
  6. package/src/app/api/missions/[id]/reports/route.ts +33 -0
  7. package/src/app/api/missions/[id]/route.ts +82 -0
  8. package/src/app/api/missions/route.test.ts +170 -0
  9. package/src/app/api/missions/route.ts +58 -0
  10. package/src/app/missions/page.tsx +635 -0
  11. package/src/cli/index.js +15 -0
  12. package/src/cli/spec.js +14 -0
  13. package/src/components/layout/sidebar-rail.tsx +8 -0
  14. package/src/components/mcp-servers/mcp-server-sheet.tsx +22 -0
  15. package/src/lib/app/navigation.ts +1 -0
  16. package/src/lib/app/view-constants.ts +10 -1
  17. package/src/lib/server/missions/mission-budget-hook.ts +38 -0
  18. package/src/lib/server/missions/mission-report-builder.test.ts +106 -0
  19. package/src/lib/server/missions/mission-report-builder.ts +158 -0
  20. package/src/lib/server/missions/mission-repository.test.ts +171 -0
  21. package/src/lib/server/missions/mission-repository.ts +137 -0
  22. package/src/lib/server/missions/mission-scheduler.ts +107 -0
  23. package/src/lib/server/missions/mission-service.test.ts +201 -0
  24. package/src/lib/server/missions/mission-service.ts +299 -0
  25. package/src/lib/server/runtime/heartbeat-service.ts +5 -0
  26. package/src/lib/server/runtime/session-run-manager/enqueue.ts +9 -0
  27. package/src/lib/server/storage-normalization.ts +145 -1
  28. package/src/lib/server/storage.ts +29 -0
  29. package/src/types/index.ts +1 -0
  30. package/src/types/mission.ts +115 -0
  31. package/src/types/session.ts +3 -1
@@ -0,0 +1,635 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { api } from '@/lib/app/api-client'
5
+ import { MainContent } from '@/components/layout/main-content'
6
+ import { HintTip } from '@/components/shared/hint-tip'
7
+ import { inputClass } from '@/components/shared/form-styles'
8
+ import type { Mission, MissionReport, MissionEvent, Session } from '@/types'
9
+ import { toast } from 'sonner'
10
+
11
+ const POLL_MS = 4_000
12
+
13
+ const STATUS_BADGE: Record<Mission['status'], { label: string; cls: string }> = {
14
+ draft: { label: 'Draft', cls: 'bg-white/[0.05] text-text-3' },
15
+ running: { label: 'Running', cls: 'bg-emerald-500/15 text-emerald-300 border border-emerald-500/30' },
16
+ paused: { label: 'Paused', cls: 'bg-amber-500/15 text-amber-300 border border-amber-500/30' },
17
+ completed: { label: 'Completed', cls: 'bg-indigo-500/15 text-indigo-300 border border-indigo-500/30' },
18
+ failed: { label: 'Failed', cls: 'bg-rose-500/15 text-rose-300 border border-rose-500/30' },
19
+ cancelled: { label: 'Cancelled', cls: 'bg-white/[0.06] text-text-3' },
20
+ budget_exhausted: { label: 'Budget exhausted', cls: 'bg-orange-500/15 text-orange-300 border border-orange-500/30' },
21
+ }
22
+
23
+ function formatUsd(n: number): string {
24
+ return `$${n.toFixed(n < 0.01 ? 4 : 2)}`
25
+ }
26
+
27
+ function formatDuration(ms: number): string {
28
+ if (ms < 1000) return `${ms}ms`
29
+ const sec = Math.round(ms / 1000)
30
+ if (sec < 60) return `${sec}s`
31
+ const min = Math.round(sec / 60)
32
+ if (min < 60) return `${min}m`
33
+ return `${Math.round((min / 60) * 10) / 10}h`
34
+ }
35
+
36
+ function formatTimestamp(at: number | null | undefined): string {
37
+ if (!at) return ''
38
+ try {
39
+ const d = new Date(at)
40
+ return d.toLocaleString()
41
+ } catch {
42
+ return String(at)
43
+ }
44
+ }
45
+
46
+ interface BudgetBarProps {
47
+ label: string
48
+ used: number
49
+ cap: number | null | undefined
50
+ format: (n: number) => string
51
+ hint?: string
52
+ }
53
+
54
+ function BudgetBar({ label, used, cap, format, hint }: BudgetBarProps) {
55
+ const pct = cap && cap > 0 ? Math.min(100, (used / cap) * 100) : 0
56
+ const barCls = pct >= 95 ? 'bg-rose-500' : pct >= 80 ? 'bg-amber-500' : 'bg-emerald-500'
57
+ return (
58
+ <div className="flex flex-col gap-1">
59
+ <div className="flex items-center justify-between text-[11px] text-text-3">
60
+ <span className="inline-flex items-center gap-1">
61
+ {label}
62
+ {hint && <HintTip text={hint} />}
63
+ </span>
64
+ <span>
65
+ {format(used)}
66
+ {cap != null ? ` / ${format(cap)}` : ' (no cap)'}
67
+ </span>
68
+ </div>
69
+ <div className="relative h-1.5 w-full rounded-full bg-white/[0.04] overflow-hidden">
70
+ <div className={`absolute inset-y-0 left-0 ${barCls} transition-all`} style={{ width: `${pct}%` }} />
71
+ </div>
72
+ </div>
73
+ )
74
+ }
75
+
76
+ interface MissionCardProps {
77
+ mission: Mission
78
+ isSelected: boolean
79
+ onSelect: () => void
80
+ }
81
+
82
+ function MissionCard({ mission, isSelected, onSelect }: MissionCardProps) {
83
+ const badge = STATUS_BADGE[mission.status]
84
+ const lastMilestone = mission.milestones.at(-1)
85
+ return (
86
+ <button
87
+ onClick={onSelect}
88
+ className={`text-left w-full rounded-[10px] border transition-all px-4 py-3
89
+ ${isSelected ? 'border-white/[0.16] bg-raised' : 'border-white/[0.06] hover:border-white/[0.12] hover:bg-white/[0.02]'}`}
90
+ >
91
+ <div className="flex items-start justify-between gap-2">
92
+ <div className="min-w-0 flex-1">
93
+ <div className="text-[13px] font-600 text-text truncate">{mission.title}</div>
94
+ <div className="text-[11px] text-text-3 mt-0.5 line-clamp-2">{mission.goal}</div>
95
+ </div>
96
+ <span className={`text-[10px] font-600 uppercase tracking-wide px-1.5 py-0.5 rounded ${badge.cls} shrink-0`}>
97
+ {badge.label}
98
+ </span>
99
+ </div>
100
+ <div className="mt-2 flex items-center gap-3 text-[10px] text-text-3/70">
101
+ <span>{mission.usage.turnsRun} turns</span>
102
+ {mission.usage.usdSpent > 0 && <span>{formatUsd(mission.usage.usdSpent)}</span>}
103
+ {lastMilestone && (
104
+ <span className="truncate">
105
+ Last: {lastMilestone.summary.slice(0, 60)}
106
+ </span>
107
+ )}
108
+ </div>
109
+ </button>
110
+ )
111
+ }
112
+
113
+ interface ControlsProps {
114
+ mission: Mission
115
+ onAction: (action: string, reason?: string) => Promise<void>
116
+ onForceReport: () => Promise<void>
117
+ busy: boolean
118
+ }
119
+
120
+ function MissionControls({ mission, onAction, onForceReport, busy }: ControlsProps) {
121
+ const btn = 'text-[11px] font-600 px-2.5 py-1 rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed'
122
+ return (
123
+ <div className="flex flex-wrap items-center gap-2">
124
+ {mission.status === 'draft' || mission.status === 'paused' ? (
125
+ <button
126
+ disabled={busy}
127
+ onClick={() => onAction('start')}
128
+ className={`${btn} border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15`}
129
+ >
130
+ {mission.status === 'paused' ? 'Resume' : 'Start'}
131
+ </button>
132
+ ) : null}
133
+ {mission.status === 'running' ? (
134
+ <button
135
+ disabled={busy}
136
+ onClick={() => onAction('pause')}
137
+ className={`${btn} border-amber-500/30 bg-amber-500/10 text-amber-300 hover:bg-amber-500/15`}
138
+ >
139
+ Pause
140
+ </button>
141
+ ) : null}
142
+ {mission.status === 'running' || mission.status === 'paused' ? (
143
+ <button
144
+ disabled={busy}
145
+ onClick={() => onAction('complete', 'User marked complete')}
146
+ className={`${btn} border-indigo-500/30 bg-indigo-500/10 text-indigo-300 hover:bg-indigo-500/15`}
147
+ >
148
+ Mark complete
149
+ </button>
150
+ ) : null}
151
+ {mission.status !== 'completed' && mission.status !== 'cancelled' ? (
152
+ <button
153
+ disabled={busy}
154
+ onClick={() => {
155
+ const reason = window.prompt('Cancel reason (optional)') || undefined
156
+ void onAction('cancel', reason)
157
+ }}
158
+ className={`${btn} border-rose-500/20 bg-rose-500/5 text-rose-300 hover:bg-rose-500/10`}
159
+ >
160
+ Cancel
161
+ </button>
162
+ ) : null}
163
+ <button
164
+ disabled={busy}
165
+ onClick={onForceReport}
166
+ className={`${btn} border-white/[0.08] bg-white/[0.03] text-text-3 hover:bg-white/[0.06]`}
167
+ >
168
+ Generate report now
169
+ </button>
170
+ </div>
171
+ )
172
+ }
173
+
174
+ interface CreateDialogProps {
175
+ open: boolean
176
+ sessions: Session[]
177
+ onClose: () => void
178
+ onCreate: (input: {
179
+ title: string
180
+ goal: string
181
+ successCriteria: string[]
182
+ rootSessionId: string
183
+ budget: {
184
+ maxUsd?: number | null
185
+ maxTokens?: number | null
186
+ maxWallclockSec?: number | null
187
+ maxTurns?: number | null
188
+ }
189
+ reportSchedule: { intervalSec: number; format: 'markdown'; enabled: boolean } | null
190
+ }) => Promise<void>
191
+ }
192
+
193
+ function CreateMissionDialog({ open, sessions, onClose, onCreate }: CreateDialogProps) {
194
+ const [title, setTitle] = useState('Autonomous mission')
195
+ const [goal, setGoal] = useState('')
196
+ const [criteriaText, setCriteriaText] = useState('')
197
+ const [rootSessionId, setRootSessionId] = useState('')
198
+ const [maxUsd, setMaxUsd] = useState<string>('2')
199
+ const [maxTokens, setMaxTokens] = useState<string>('50000')
200
+ const [maxWallclockSec, setMaxWallclockSec] = useState<string>('28800')
201
+ const [maxTurns, setMaxTurns] = useState<string>('200')
202
+ const [reportsEnabled, setReportsEnabled] = useState(true)
203
+ const [reportIntervalMin, setReportIntervalMin] = useState<string>('60')
204
+ const [busy, setBusy] = useState(false)
205
+
206
+ useEffect(() => {
207
+ if (!rootSessionId && sessions.length > 0) setRootSessionId(sessions[0].id)
208
+ }, [sessions, rootSessionId])
209
+
210
+ if (!open) return null
211
+
212
+ const numOrNull = (s: string): number | null => {
213
+ const n = Number.parseFloat(s)
214
+ return Number.isFinite(n) && n > 0 ? n : null
215
+ }
216
+
217
+ const submit = async () => {
218
+ if (!title.trim() || !goal.trim()) {
219
+ toast.error('Title and goal are required')
220
+ return
221
+ }
222
+ if (!rootSessionId) {
223
+ toast.error('Pick a session to drive this mission')
224
+ return
225
+ }
226
+ setBusy(true)
227
+ try {
228
+ const successCriteria = criteriaText
229
+ .split('\n')
230
+ .map((s) => s.trim())
231
+ .filter(Boolean)
232
+ const intervalMin = numOrNull(reportIntervalMin) ?? 60
233
+ await onCreate({
234
+ title: title.trim(),
235
+ goal: goal.trim(),
236
+ successCriteria,
237
+ rootSessionId,
238
+ budget: {
239
+ maxUsd: numOrNull(maxUsd),
240
+ maxTokens: numOrNull(maxTokens),
241
+ maxWallclockSec: numOrNull(maxWallclockSec),
242
+ maxTurns: numOrNull(maxTurns),
243
+ },
244
+ reportSchedule: reportsEnabled
245
+ ? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
246
+ : null,
247
+ })
248
+ onClose()
249
+ } catch (error) {
250
+ toast.error(`Create failed: ${error instanceof Error ? error.message : String(error)}`)
251
+ } finally {
252
+ setBusy(false)
253
+ }
254
+ }
255
+
256
+ return (
257
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
258
+ <div
259
+ className="w-full max-w-lg rounded-[12px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-5 max-h-[90vh] overflow-y-auto"
260
+ onClick={(e) => e.stopPropagation()}
261
+ >
262
+ <div className="text-[14px] font-600 text-text mb-1">New autonomous mission</div>
263
+ <div className="text-[11px] text-text-3 mb-4">Hand your agent team a goal. They run through heartbeats until done or budget hits.</div>
264
+ <div className="flex flex-col gap-3">
265
+ <label className="flex flex-col gap-1">
266
+ <span className="text-[11px] text-text-3">Title</span>
267
+ <input value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
268
+ </label>
269
+ <label className="flex flex-col gap-1">
270
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
271
+ Goal <HintTip text="A natural-language objective. The team will work on this until budget or success criteria are hit." />
272
+ </span>
273
+ <textarea
274
+ value={goal}
275
+ onChange={(e) => setGoal(e.target.value)}
276
+ rows={3}
277
+ className={`${inputClass} resize-none`}
278
+ placeholder="e.g., Research the top 3 open-source note-taking apps and draft a comparison doc"
279
+ />
280
+ </label>
281
+ <label className="flex flex-col gap-1">
282
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
283
+ Success criteria <HintTip text="One per line. Used in reports and final verification." />
284
+ </span>
285
+ <textarea
286
+ value={criteriaText}
287
+ onChange={(e) => setCriteriaText(e.target.value)}
288
+ rows={3}
289
+ className={`${inputClass} resize-none`}
290
+ placeholder="File comparison.md exists\nEach app has pros/cons\nSource links are cited"
291
+ />
292
+ </label>
293
+ <label className="flex flex-col gap-1">
294
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
295
+ Root session <HintTip text="The session whose heartbeat drives this mission. Usually an agent thread." />
296
+ </span>
297
+ <select value={rootSessionId} onChange={(e) => setRootSessionId(e.target.value)} className={inputClass}>
298
+ {sessions.length === 0 && <option value="">No sessions available</option>}
299
+ {sessions.map((s) => (
300
+ <option key={s.id} value={s.id}>
301
+ {s.name || s.id}
302
+ </option>
303
+ ))}
304
+ </select>
305
+ </label>
306
+
307
+ <div className="grid grid-cols-2 gap-3">
308
+ <label className="flex flex-col gap-1">
309
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
310
+ Max USD <HintTip text="Hard spend cap. Leave blank for no limit." />
311
+ </span>
312
+ <input value={maxUsd} onChange={(e) => setMaxUsd(e.target.value)} className={inputClass} inputMode="decimal" />
313
+ </label>
314
+ <label className="flex flex-col gap-1">
315
+ <span className="text-[11px] text-text-3">Max tokens</span>
316
+ <input value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} className={inputClass} inputMode="numeric" />
317
+ </label>
318
+ <label className="flex flex-col gap-1">
319
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
320
+ Max wallclock (sec) <HintTip text="Scheduler aborts the mission after this many seconds of elapsed wallclock time." />
321
+ </span>
322
+ <input value={maxWallclockSec} onChange={(e) => setMaxWallclockSec(e.target.value)} className={inputClass} inputMode="numeric" />
323
+ </label>
324
+ <label className="flex flex-col gap-1">
325
+ <span className="text-[11px] text-text-3">Max turns</span>
326
+ <input value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} className={inputClass} inputMode="numeric" />
327
+ </label>
328
+ </div>
329
+
330
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
331
+ <div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-1.5">Periodic reports</div>
332
+ <label className="flex items-center gap-2 flex-wrap">
333
+ <input type="checkbox" checked={reportsEnabled} onChange={(e) => setReportsEnabled(e.target.checked)} />
334
+ <span className="text-[11px] text-text-3">Send a markdown progress report every</span>
335
+ <input
336
+ disabled={!reportsEnabled}
337
+ value={reportIntervalMin}
338
+ onChange={(e) => setReportIntervalMin(e.target.value)}
339
+ className={`${inputClass} w-16`}
340
+ inputMode="numeric"
341
+ />
342
+ <span className="text-[11px] text-text-3">minutes</span>
343
+ </label>
344
+ </div>
345
+ </div>
346
+
347
+ <div className="mt-5 flex items-center justify-end gap-2">
348
+ <button
349
+ onClick={onClose}
350
+ className="text-[12px] px-3 py-1.5 rounded border border-white/[0.08] hover:bg-white/[0.04]"
351
+ disabled={busy}
352
+ >
353
+ Cancel
354
+ </button>
355
+ <button
356
+ onClick={submit}
357
+ disabled={busy}
358
+ className="text-[12px] font-600 px-3 py-1.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/30 hover:bg-emerald-500/25 disabled:opacity-40"
359
+ >
360
+ {busy ? 'Creating...' : 'Create mission'}
361
+ </button>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ )
366
+ }
367
+
368
+ interface DetailProps {
369
+ mission: Mission
370
+ reports: MissionReport[]
371
+ events: MissionEvent[]
372
+ busy: boolean
373
+ onAction: (action: string, reason?: string) => Promise<void>
374
+ onForceReport: () => Promise<void>
375
+ }
376
+
377
+ function MissionDetail({ mission, reports, events, busy, onAction, onForceReport }: DetailProps) {
378
+ const [selectedReport, setSelectedReport] = useState<MissionReport | null>(null)
379
+ const wallclockCapMs = mission.budget.maxWallclockSec != null ? mission.budget.maxWallclockSec * 1000 : null
380
+
381
+ return (
382
+ <div className="flex flex-col gap-4 p-4">
383
+ <div>
384
+ <div className="flex items-center gap-2 mb-1">
385
+ <span className={`text-[10px] font-600 uppercase tracking-wide px-1.5 py-0.5 rounded ${STATUS_BADGE[mission.status].cls}`}>
386
+ {STATUS_BADGE[mission.status].label}
387
+ </span>
388
+ <span className="text-[10px] text-text-3/60">Created {formatTimestamp(mission.createdAt)}</span>
389
+ {mission.endedAt && <span className="text-[10px] text-text-3/60">Ended {formatTimestamp(mission.endedAt)}</span>}
390
+ </div>
391
+ <h2 className="text-[15px] font-600 text-text">{mission.title}</h2>
392
+ <p className="text-[12px] text-text-3 mt-1">{mission.goal}</p>
393
+ {mission.endReason && <p className="text-[11px] text-rose-300/80 mt-2">End reason: {mission.endReason}</p>}
394
+ </div>
395
+
396
+ <div className="rounded-[10px] border border-white/[0.06] p-4 flex flex-col gap-3">
397
+ <div className="text-[11px] font-600 uppercase tracking-wide text-text-3">Budget</div>
398
+ <BudgetBar label="USD" used={mission.usage.usdSpent} cap={mission.budget.maxUsd} format={formatUsd} />
399
+ <BudgetBar label="Tokens" used={mission.usage.tokensUsed} cap={mission.budget.maxTokens} format={(n) => `${Math.round(n).toLocaleString()}`} />
400
+ <BudgetBar label="Turns" used={mission.usage.turnsRun} cap={mission.budget.maxTurns} format={(n) => String(Math.round(n))} />
401
+ <BudgetBar label="Wallclock" used={mission.usage.wallclockMsElapsed} cap={wallclockCapMs} format={formatDuration} hint="Hard time budget enforced by the scheduler." />
402
+ </div>
403
+
404
+ <div>
405
+ <div className="text-[11px] font-600 uppercase tracking-wide text-text-3 mb-2">Controls</div>
406
+ <MissionControls mission={mission} onAction={onAction} onForceReport={onForceReport} busy={busy} />
407
+ </div>
408
+
409
+ {mission.successCriteria.length > 0 && (
410
+ <div>
411
+ <div className="text-[11px] font-600 uppercase tracking-wide text-text-3 mb-2">Success criteria</div>
412
+ <ul className="flex flex-col gap-1">
413
+ {mission.successCriteria.map((c, i) => (
414
+ <li key={i} className="text-[12px] text-text flex items-start gap-2">
415
+ <span className="text-text-3/50 mt-[2px]">-</span>
416
+ <span>{c}</span>
417
+ </li>
418
+ ))}
419
+ </ul>
420
+ </div>
421
+ )}
422
+
423
+ <div>
424
+ <div className="text-[11px] font-600 uppercase tracking-wide text-text-3 mb-2">Timeline</div>
425
+ {mission.milestones.length === 0 ? (
426
+ <div className="text-[11px] text-text-3/60">No milestones yet.</div>
427
+ ) : (
428
+ <div className="flex flex-col gap-1.5 max-h-[240px] overflow-y-auto">
429
+ {[...mission.milestones].reverse().map((ms) => (
430
+ <div key={ms.id} className="text-[11px] flex items-start gap-2">
431
+ <span className="text-text-3/50 font-mono">{new Date(ms.at).toLocaleTimeString()}</span>
432
+ <span className="text-text-3 font-600">{ms.kind}</span>
433
+ <span className="text-text">{ms.summary}</span>
434
+ </div>
435
+ ))}
436
+ </div>
437
+ )}
438
+ {events.length > 0 && (
439
+ <div className="text-[10px] text-text-3/50 mt-2">{events.length} total events in log</div>
440
+ )}
441
+ </div>
442
+
443
+ <div>
444
+ <div className="flex items-center justify-between mb-2">
445
+ <div className="text-[11px] font-600 uppercase tracking-wide text-text-3">Reports ({reports.length})</div>
446
+ </div>
447
+ {reports.length === 0 ? (
448
+ <div className="text-[11px] text-text-3/60">No reports yet. Click &quot;Generate report now&quot; to produce one.</div>
449
+ ) : (
450
+ <div className="flex flex-col gap-1">
451
+ {reports.map((r) => (
452
+ <button
453
+ key={r.id}
454
+ onClick={() => setSelectedReport(r)}
455
+ className="text-left text-[11px] text-text-3 px-2 py-1.5 rounded border border-white/[0.04] hover:border-white/[0.12] hover:bg-white/[0.02]"
456
+ >
457
+ <span className="text-text">{r.title}</span>
458
+ <span className="text-text-3/60 ml-2">{formatTimestamp(r.generatedAt)}</span>
459
+ </button>
460
+ ))}
461
+ </div>
462
+ )}
463
+ </div>
464
+
465
+ {selectedReport && (
466
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setSelectedReport(null)}>
467
+ <div
468
+ className="w-full max-w-2xl max-h-[80vh] overflow-y-auto rounded-[12px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-5"
469
+ onClick={(e) => e.stopPropagation()}
470
+ >
471
+ <div className="flex items-center justify-between mb-3">
472
+ <div className="text-[13px] font-600 text-text">{selectedReport.title}</div>
473
+ <button onClick={() => setSelectedReport(null)} className="text-text-3 text-[12px] hover:text-text">Close</button>
474
+ </div>
475
+ <pre className="text-[12px] text-text-3 whitespace-pre-wrap font-mono leading-relaxed">{selectedReport.body}</pre>
476
+ </div>
477
+ </div>
478
+ )}
479
+ </div>
480
+ )
481
+ }
482
+
483
+ export default function MissionsPage() {
484
+ const [missions, setMissions] = useState<Mission[]>([])
485
+ const [selectedId, setSelectedId] = useState<string | null>(null)
486
+ const [reports, setReports] = useState<MissionReport[]>([])
487
+ const [events, setEvents] = useState<MissionEvent[]>([])
488
+ const [sessions, setSessions] = useState<Session[]>([])
489
+ const [createOpen, setCreateOpen] = useState(false)
490
+ const [busy, setBusy] = useState(false)
491
+ const [loaded, setLoaded] = useState(false)
492
+
493
+ const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
494
+
495
+ const refreshList = useCallback(async () => {
496
+ try {
497
+ const list = await api<Mission[]>('GET', '/missions')
498
+ setMissions(list)
499
+ if (!selectedId && list.length > 0) setSelectedId(list[0].id)
500
+ } catch {
501
+ // swallow poll errors
502
+ } finally {
503
+ setLoaded(true)
504
+ }
505
+ }, [selectedId])
506
+
507
+ const refreshDetail = useCallback(async (id: string) => {
508
+ try {
509
+ const [r, e] = await Promise.all([
510
+ api<MissionReport[]>('GET', `/missions/${id}/reports`),
511
+ api<MissionEvent[]>('GET', `/missions/${id}/events`),
512
+ ])
513
+ setReports(r)
514
+ setEvents(e)
515
+ } catch {
516
+ // swallow
517
+ }
518
+ }, [])
519
+
520
+ useEffect(() => {
521
+ void refreshList()
522
+ const timer = setInterval(() => void refreshList(), POLL_MS)
523
+ return () => clearInterval(timer)
524
+ }, [refreshList])
525
+
526
+ useEffect(() => {
527
+ if (!selectedId) return
528
+ void refreshDetail(selectedId)
529
+ const timer = setInterval(() => void refreshDetail(selectedId), POLL_MS)
530
+ return () => clearInterval(timer)
531
+ }, [selectedId, refreshDetail])
532
+
533
+ useEffect(() => {
534
+ api<Session[]>('GET', '/chats').then((s) => {
535
+ setSessions(Array.isArray(s) ? s : Object.values(s))
536
+ }).catch(() => setSessions([]))
537
+ }, [createOpen])
538
+
539
+ const handleAction = useCallback(async (action: string, reason?: string) => {
540
+ if (!selectedId) return
541
+ setBusy(true)
542
+ try {
543
+ await api('POST', `/missions/${selectedId}/control`, reason ? { action, reason } : { action })
544
+ await refreshList()
545
+ await refreshDetail(selectedId)
546
+ } catch (error) {
547
+ toast.error(`Action failed: ${error instanceof Error ? error.message : String(error)}`)
548
+ } finally {
549
+ setBusy(false)
550
+ }
551
+ }, [selectedId, refreshList, refreshDetail])
552
+
553
+ const handleForceReport = useCallback(async () => {
554
+ if (!selectedId) return
555
+ setBusy(true)
556
+ try {
557
+ await api('POST', `/missions/${selectedId}/reports`)
558
+ await refreshDetail(selectedId)
559
+ toast.success('Report generated')
560
+ } catch (error) {
561
+ toast.error(`Report failed: ${error instanceof Error ? error.message : String(error)}`)
562
+ } finally {
563
+ setBusy(false)
564
+ }
565
+ }, [selectedId, refreshDetail])
566
+
567
+ const handleCreate = useCallback(async (input: Parameters<CreateDialogProps['onCreate']>[0]) => {
568
+ const created = await api<Mission>('POST', '/missions', input)
569
+ await refreshList()
570
+ setSelectedId(created.id)
571
+ toast.success(`Mission "${created.title}" created`)
572
+ }, [refreshList])
573
+
574
+ return (
575
+ <MainContent>
576
+ <div className="flex-1 flex min-h-0">
577
+ <div className="w-[340px] shrink-0 border-r border-white/[0.06] flex flex-col min-h-0">
578
+ <div className="p-3 border-b border-white/[0.06] flex items-center justify-between">
579
+ <div>
580
+ <div className="text-[13px] font-600">Missions</div>
581
+ <div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
582
+ </div>
583
+ <button
584
+ onClick={() => setCreateOpen(true)}
585
+ className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15"
586
+ >
587
+ + New
588
+ </button>
589
+ </div>
590
+ <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1.5">
591
+ {!loaded ? (
592
+ <div className="text-[11px] text-text-3 p-3">Loading...</div>
593
+ ) : missions.length === 0 ? (
594
+ <div className="text-[11px] text-text-3 p-3">
595
+ No missions yet. Click <span className="text-emerald-300 font-600">+ New</span> to start an autonomous run.
596
+ </div>
597
+ ) : (
598
+ missions.map((m) => (
599
+ <MissionCard
600
+ key={m.id}
601
+ mission={m}
602
+ isSelected={selectedId === m.id}
603
+ onSelect={() => setSelectedId(m.id)}
604
+ />
605
+ ))
606
+ )}
607
+ </div>
608
+ </div>
609
+ <div className="flex-1 overflow-y-auto min-h-0">
610
+ {selected ? (
611
+ <MissionDetail
612
+ mission={selected}
613
+ reports={reports}
614
+ events={events}
615
+ busy={busy}
616
+ onAction={handleAction}
617
+ onForceReport={handleForceReport}
618
+ />
619
+ ) : (
620
+ <div className="flex items-center justify-center h-full text-text-3 text-[12px]">
621
+ {loaded && missions.length === 0 ? 'Create a mission to get started.' : 'Select a mission'}
622
+ </div>
623
+ )}
624
+ </div>
625
+ </div>
626
+
627
+ <CreateMissionDialog
628
+ open={createOpen}
629
+ sessions={sessions}
630
+ onClose={() => setCreateOpen(false)}
631
+ onCreate={handleCreate}
632
+ />
633
+ </MainContent>
634
+ )
635
+ }
package/src/cli/index.js CHANGED
@@ -803,6 +803,21 @@ const COMMAND_GROUPS = [
803
803
  cmd('delete', 'DELETE', '/goals/:id', 'Delete a goal'),
804
804
  ],
805
805
  },
806
+ {
807
+ name: 'missions',
808
+ description: 'Manage autonomous missions',
809
+ commands: [
810
+ cmd('list', 'GET', '/missions', 'List autonomous missions'),
811
+ cmd('get', 'GET', '/missions/:id', 'Get a mission by id'),
812
+ cmd('create', 'POST', '/missions', 'Create an autonomous mission', { expectsJsonBody: true }),
813
+ cmd('update', 'PUT', '/missions/:id', 'Update a mission', { expectsJsonBody: true }),
814
+ cmd('delete', 'DELETE', '/missions/:id', 'Delete a mission'),
815
+ cmd('control', 'POST', '/missions/:id/control', 'Start, pause, resume, cancel, complete, or fail a mission', { expectsJsonBody: true }),
816
+ cmd('reports', 'GET', '/missions/:id/reports', 'List mission reports'),
817
+ cmd('report-now', 'POST', '/missions/:id/reports', 'Force-generate a mission report now'),
818
+ cmd('events', 'GET', '/missions/:id/events', 'List mission events (use --query sinceAt=..., --query untilAt=...)'),
819
+ ],
820
+ },
806
821
  {
807
822
  name: 'swarmfeed',
808
823
  description: 'SwarmFeed social network',
package/src/cli/spec.js CHANGED
@@ -559,6 +559,20 @@ const COMMAND_GROUPS = {
559
559
  delete: { description: 'Delete a goal', method: 'DELETE', path: '/goals/:id', params: ['id'] },
560
560
  },
561
561
  },
562
+ missions: {
563
+ description: 'Manage autonomous missions',
564
+ commands: {
565
+ list: { description: 'List autonomous missions', method: 'GET', path: '/missions' },
566
+ get: { description: 'Get a mission by id', method: 'GET', path: '/missions/:id', params: ['id'] },
567
+ create: { description: 'Create an autonomous mission', method: 'POST', path: '/missions' },
568
+ update: { description: 'Update a mission', method: 'PUT', path: '/missions/:id', params: ['id'], body: true },
569
+ delete: { description: 'Delete a mission', method: 'DELETE', path: '/missions/:id', params: ['id'] },
570
+ control: { description: 'Start, pause, resume, cancel, complete, or fail a mission', method: 'POST', path: '/missions/:id/control', params: ['id'] },
571
+ reports: { description: 'List mission reports', method: 'GET', path: '/missions/:id/reports', params: ['id'] },
572
+ 'report-now': { description: 'Force-generate a mission report now', method: 'POST', path: '/missions/:id/reports', params: ['id'] },
573
+ events: { description: 'List mission events', method: 'GET', path: '/missions/:id/events', params: ['id'] },
574
+ },
575
+ },
562
576
  }
563
577
 
564
578
  const GROUP_NAMES = Object.keys(COMMAND_GROUPS)