@swarmclawai/swarmclaw 1.5.53 → 1.5.55

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 (48) hide show
  1. package/README.md +17 -3
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +14 -2
  4. package/src/app/api/agents/agents-route.test.ts +65 -1
  5. package/src/app/api/chatrooms/[id]/chat/route.ts +5 -3
  6. package/src/app/api/chatrooms/route.ts +3 -0
  7. package/src/app/api/missions/[id]/control/route.ts +21 -0
  8. package/src/app/api/missions/templates/[id]/instantiate/route.ts +64 -0
  9. package/src/app/api/missions/templates/route.ts +8 -0
  10. package/src/app/api/tasks/[id]/route.ts +11 -1
  11. package/src/app/api/tasks/tasks-route.test.ts +81 -0
  12. package/src/app/api/webhooks/[id]/route.ts +18 -15
  13. package/src/app/missions/page.tsx +135 -22
  14. package/src/cli/index.js +2 -0
  15. package/src/cli/spec.js +2 -0
  16. package/src/components/missions/mission-edit-sheet.tsx +319 -0
  17. package/src/components/missions/mission-template-gallery.tsx +113 -0
  18. package/src/components/missions/mission-template-install-dialog.tsx +283 -0
  19. package/src/lib/server/agents/agent-service.ts +10 -2
  20. package/src/lib/server/agents/main-agent-loop-advanced.test.ts +36 -0
  21. package/src/lib/server/agents/main-agent-loop.ts +111 -4
  22. package/src/lib/server/chat-execution/chat-turn-preparation.test.ts +253 -0
  23. package/src/lib/server/chat-execution/chat-turn-preparation.ts +46 -26
  24. package/src/lib/server/chat-execution/message-classifier.ts +11 -7
  25. package/src/lib/server/chat-execution/post-stream-finalization.test.ts +85 -0
  26. package/src/lib/server/chat-execution/post-stream-finalization.ts +41 -16
  27. package/src/lib/server/chat-execution/response-completeness.test.ts +2 -1
  28. package/src/lib/server/chat-execution/response-completeness.ts +11 -3
  29. package/src/lib/server/chatrooms/chatroom-agent-signals.test.ts +54 -0
  30. package/src/lib/server/chatrooms/chatroom-agent-signals.ts +105 -9
  31. package/src/lib/server/chats/chat-session-service.ts +11 -0
  32. package/src/lib/server/connectors/email.test.ts +64 -0
  33. package/src/lib/server/connectors/email.ts +35 -6
  34. package/src/lib/server/connectors/response-media.ts +1 -0
  35. package/src/lib/server/daemon/daemon-runtime.ts +31 -19
  36. package/src/lib/server/memory/memory-db.test.ts +8 -0
  37. package/src/lib/server/memory/memory-db.ts +1 -1
  38. package/src/lib/server/missions/mission-service.ts +47 -1
  39. package/src/lib/server/missions/mission-templates.test.ts +208 -0
  40. package/src/lib/server/missions/mission-templates.ts +186 -0
  41. package/src/lib/server/runtime/session-run-manager/drain.ts +16 -0
  42. package/src/lib/server/storage-normalization.ts +6 -0
  43. package/src/lib/server/storage.ts +1 -1
  44. package/src/lib/server/tasks/task-validation.test.ts +30 -0
  45. package/src/lib/server/tasks/task-validation.ts +21 -2
  46. package/src/lib/server/working-state/normalization.ts +5 -1
  47. package/src/lib/validation/schemas.ts +40 -0
  48. package/src/types/mission.ts +27 -0
@@ -5,7 +5,13 @@ import { api } from '@/lib/app/api-client'
5
5
  import { MainContent } from '@/components/layout/main-content'
6
6
  import { HintTip } from '@/components/shared/hint-tip'
7
7
  import { inputClass } from '@/components/shared/form-styles'
8
- import type { Mission, MissionReport, MissionEvent, Session } from '@/types'
8
+ import { MissionTemplateGallery } from '@/components/missions/mission-template-gallery'
9
+ import {
10
+ MissionTemplateInstallDialog,
11
+ type InstantiateInput,
12
+ } from '@/components/missions/mission-template-install-dialog'
13
+ import { MissionEditSheet, isMissionEditable } from '@/components/missions/mission-edit-sheet'
14
+ import type { Mission, MissionReport, MissionEvent, MissionTemplate, Session } from '@/types'
9
15
  import { toast } from 'sonner'
10
16
 
11
17
  const POLL_MS = 4_000
@@ -114,11 +120,13 @@ interface ControlsProps {
114
120
  mission: Mission
115
121
  onAction: (action: string, reason?: string) => Promise<void>
116
122
  onForceReport: () => Promise<void>
123
+ onEdit: () => void
117
124
  busy: boolean
118
125
  }
119
126
 
120
- function MissionControls({ mission, onAction, onForceReport, busy }: ControlsProps) {
127
+ function MissionControls({ mission, onAction, onForceReport, onEdit, busy }: ControlsProps) {
121
128
  const btn = 'text-[11px] font-600 px-2.5 py-1 rounded border transition-colors disabled:opacity-40 disabled:cursor-not-allowed'
129
+ const editable = isMissionEditable(mission.status)
122
130
  return (
123
131
  <div className="flex flex-wrap items-center gap-2">
124
132
  {mission.status === 'draft' || mission.status === 'paused' ? (
@@ -148,6 +156,15 @@ function MissionControls({ mission, onAction, onForceReport, busy }: ControlsPro
148
156
  Mark complete
149
157
  </button>
150
158
  ) : null}
159
+ {editable ? (
160
+ <button
161
+ disabled={busy}
162
+ onClick={onEdit}
163
+ className={`${btn} border-white/[0.12] bg-white/[0.04] text-text hover:bg-white/[0.08]`}
164
+ >
165
+ Edit
166
+ </button>
167
+ ) : null}
151
168
  {mission.status !== 'completed' && mission.status !== 'cancelled' ? (
152
169
  <button
153
170
  disabled={busy}
@@ -213,6 +230,10 @@ function CreateMissionDialog({ open, sessions, onClose, onCreate }: CreateDialog
213
230
  const n = Number.parseFloat(s)
214
231
  return Number.isFinite(n) && n > 0 ? n : null
215
232
  }
233
+ const intOrNull = (s: string): number | null => {
234
+ const n = numOrNull(s)
235
+ return n == null ? null : Math.round(n)
236
+ }
216
237
 
217
238
  const submit = async () => {
218
239
  if (!title.trim() || !goal.trim()) {
@@ -237,9 +258,9 @@ function CreateMissionDialog({ open, sessions, onClose, onCreate }: CreateDialog
237
258
  rootSessionId,
238
259
  budget: {
239
260
  maxUsd: numOrNull(maxUsd),
240
- maxTokens: numOrNull(maxTokens),
241
- maxWallclockSec: numOrNull(maxWallclockSec),
242
- maxTurns: numOrNull(maxTurns),
261
+ maxTokens: intOrNull(maxTokens),
262
+ maxWallclockSec: intOrNull(maxWallclockSec),
263
+ maxTurns: intOrNull(maxTurns),
243
264
  },
244
265
  reportSchedule: reportsEnabled
245
266
  ? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
@@ -372,9 +393,10 @@ interface DetailProps {
372
393
  busy: boolean
373
394
  onAction: (action: string, reason?: string) => Promise<void>
374
395
  onForceReport: () => Promise<void>
396
+ onEdit: () => void
375
397
  }
376
398
 
377
- function MissionDetail({ mission, reports, events, busy, onAction, onForceReport }: DetailProps) {
399
+ function MissionDetail({ mission, reports, events, busy, onAction, onForceReport, onEdit }: DetailProps) {
378
400
  const [selectedReport, setSelectedReport] = useState<MissionReport | null>(null)
379
401
  const wallclockCapMs = mission.budget.maxWallclockSec != null ? mission.budget.maxWallclockSec * 1000 : null
380
402
 
@@ -403,7 +425,7 @@ function MissionDetail({ mission, reports, events, busy, onAction, onForceReport
403
425
 
404
426
  <div>
405
427
  <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} />
428
+ <MissionControls mission={mission} onAction={onAction} onForceReport={onForceReport} onEdit={onEdit} busy={busy} />
407
429
  </div>
408
430
 
409
431
  {mission.successCriteria.length > 0 && (
@@ -489,6 +511,10 @@ export default function MissionsPage() {
489
511
  const [createOpen, setCreateOpen] = useState(false)
490
512
  const [busy, setBusy] = useState(false)
491
513
  const [loaded, setLoaded] = useState(false)
514
+ const [templates, setTemplates] = useState<MissionTemplate[]>([])
515
+ const [galleryOpen, setGalleryOpen] = useState(false)
516
+ const [installTemplate, setInstallTemplate] = useState<MissionTemplate | null>(null)
517
+ const [editMission, setEditMission] = useState<Mission | null>(null)
492
518
 
493
519
  const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
494
520
 
@@ -531,10 +557,18 @@ export default function MissionsPage() {
531
557
  }, [selectedId, refreshDetail])
532
558
 
533
559
  useEffect(() => {
534
- api<Session[]>('GET', '/chats').then((s) => {
535
- setSessions(Array.isArray(s) ? s : Object.values(s))
560
+ api<Record<string, Session>>('GET', '/chats').then((s) => {
561
+ setSessions(s ? Object.values(s) : [])
536
562
  }).catch(() => setSessions([]))
537
- }, [createOpen])
563
+ }, [createOpen, galleryOpen, installTemplate])
564
+
565
+ useEffect(() => {
566
+ let cancelled = false
567
+ api<MissionTemplate[]>('GET', '/missions/templates')
568
+ .then((list) => { if (!cancelled) setTemplates(Array.isArray(list) ? list : []) })
569
+ .catch(() => { if (!cancelled) setTemplates([]) })
570
+ return () => { cancelled = true }
571
+ }, [])
538
572
 
539
573
  const handleAction = useCallback(async (action: string, reason?: string) => {
540
574
  if (!selectedId) return
@@ -571,28 +605,65 @@ export default function MissionsPage() {
571
605
  toast.success(`Mission "${created.title}" created`)
572
606
  }, [refreshList])
573
607
 
608
+ const handleMissionSaved = useCallback((updated: Mission) => {
609
+ setMissions((prev) => prev.map((m) => (m.id === updated.id ? updated : m)))
610
+ void refreshList()
611
+ }, [refreshList])
612
+
613
+ const handleInstallTemplate = useCallback(async (template: MissionTemplate, input: InstantiateInput) => {
614
+ const result = await api<{ mission: Mission }>(
615
+ 'POST',
616
+ `/missions/templates/${encodeURIComponent(template.id)}/instantiate`,
617
+ input,
618
+ )
619
+ await refreshList()
620
+ setSelectedId(result.mission.id)
621
+ setGalleryOpen(false)
622
+ toast.success(`Mission "${result.mission.title}" installed`)
623
+ }, [refreshList])
624
+
574
625
  return (
575
626
  <MainContent>
576
627
  <div className="flex-1 flex min-h-0">
577
628
  <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>
629
+ <div className="p-3 border-b border-white/[0.06]">
630
+ <div className="flex items-center justify-between mb-2">
631
+ <div>
632
+ <div className="text-[13px] font-600">Missions</div>
633
+ <div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
634
+ </div>
635
+ <button
636
+ onClick={() => setCreateOpen(true)}
637
+ 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"
638
+ >
639
+ + New
640
+ </button>
582
641
  </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>
642
+ {templates.length > 0 && (
643
+ <button
644
+ onClick={() => setGalleryOpen(true)}
645
+ className="w-full text-left text-[11px] font-600 px-2.5 py-1.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text"
646
+ >
647
+ Browse {templates.length} starter templates →
648
+ </button>
649
+ )}
589
650
  </div>
590
651
  <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1.5">
591
652
  {!loaded ? (
592
653
  <div className="text-[11px] text-text-3 p-3">Loading...</div>
593
654
  ) : 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.
655
+ <div className="flex flex-col gap-2 p-3">
656
+ <div className="text-[11px] text-text-3">
657
+ No missions yet. Start from a template or create one from scratch.
658
+ </div>
659
+ {templates.length > 0 && (
660
+ <button
661
+ onClick={() => setGalleryOpen(true)}
662
+ 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 self-start"
663
+ >
664
+ Open template gallery
665
+ </button>
666
+ )}
596
667
  </div>
597
668
  ) : (
598
669
  missions.map((m) => (
@@ -615,7 +686,15 @@ export default function MissionsPage() {
615
686
  busy={busy}
616
687
  onAction={handleAction}
617
688
  onForceReport={handleForceReport}
689
+ onEdit={() => setEditMission(selected)}
618
690
  />
691
+ ) : loaded && missions.length === 0 && templates.length > 0 ? (
692
+ <div className="p-6">
693
+ <MissionTemplateGallery
694
+ templates={templates}
695
+ onInstall={(t) => setInstallTemplate(t)}
696
+ />
697
+ </div>
619
698
  ) : (
620
699
  <div className="flex items-center justify-center h-full text-text-3 text-[12px]">
621
700
  {loaded && missions.length === 0 ? 'Create a mission to get started.' : 'Select a mission'}
@@ -630,6 +709,40 @@ export default function MissionsPage() {
630
709
  onClose={() => setCreateOpen(false)}
631
710
  onCreate={handleCreate}
632
711
  />
712
+
713
+ {galleryOpen && (
714
+ <div
715
+ className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
716
+ onClick={() => setGalleryOpen(false)}
717
+ >
718
+ <div
719
+ className="w-full max-w-4xl max-h-[90vh] overflow-y-auto rounded-[14px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-6"
720
+ onClick={(e) => e.stopPropagation()}
721
+ >
722
+ <div className="flex items-center justify-between mb-4">
723
+ <div className="text-[15px] font-700 text-text">Mission templates</div>
724
+ <button onClick={() => setGalleryOpen(false)} className="text-text-3 text-[12px] hover:text-text">Close</button>
725
+ </div>
726
+ <MissionTemplateGallery
727
+ templates={templates}
728
+ onInstall={(t) => setInstallTemplate(t)}
729
+ />
730
+ </div>
731
+ </div>
732
+ )}
733
+
734
+ <MissionTemplateInstallDialog
735
+ template={installTemplate}
736
+ sessions={sessions}
737
+ onClose={() => setInstallTemplate(null)}
738
+ onInstall={handleInstallTemplate}
739
+ />
740
+
741
+ <MissionEditSheet
742
+ mission={editMission}
743
+ onClose={() => setEditMission(null)}
744
+ onSaved={handleMissionSaved}
745
+ />
633
746
  </MainContent>
634
747
  )
635
748
  }
package/src/cli/index.js CHANGED
@@ -817,6 +817,8 @@ const COMMAND_GROUPS = [
817
817
  cmd('reports', 'GET', '/missions/:id/reports', 'List mission reports'),
818
818
  cmd('report-now', 'POST', '/missions/:id/reports', 'Force-generate a mission report now'),
819
819
  cmd('events', 'GET', '/missions/:id/events', 'List mission events (use --query sinceAt=..., --query untilAt=...)'),
820
+ cmd('templates', 'GET', '/missions/templates', 'List built-in mission templates'),
821
+ cmd('instantiate', 'POST', '/missions/templates/:id/instantiate', 'Create a mission from a template', { expectsJsonBody: true }),
820
822
  ],
821
823
  },
822
824
  {
package/src/cli/spec.js CHANGED
@@ -571,6 +571,8 @@ const COMMAND_GROUPS = {
571
571
  reports: { description: 'List mission reports', method: 'GET', path: '/missions/:id/reports', params: ['id'] },
572
572
  'report-now': { description: 'Force-generate a mission report now', method: 'POST', path: '/missions/:id/reports', params: ['id'] },
573
573
  events: { description: 'List mission events', method: 'GET', path: '/missions/:id/events', params: ['id'] },
574
+ templates: { description: 'List built-in mission templates', method: 'GET', path: '/missions/templates' },
575
+ instantiate: { description: 'Create a mission from a template', method: 'POST', path: '/missions/templates/:id/instantiate', params: ['id'], body: true },
574
576
  },
575
577
  },
576
578
  }
@@ -0,0 +1,319 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { api } from '@/lib/app/api-client'
5
+ import { HintTip } from '@/components/shared/hint-tip'
6
+ import { inputClass } from '@/components/shared/form-styles'
7
+ import type { Connector, Mission, MissionReportFormat } from '@/types'
8
+ import { toast } from 'sonner'
9
+
10
+ const EDITABLE_STATUSES: Mission['status'][] = ['draft', 'running', 'paused']
11
+ const REPORT_FORMATS: MissionReportFormat[] = ['markdown', 'slack', 'discord', 'email', 'audio']
12
+
13
+ export function isMissionEditable(status: Mission['status']): boolean {
14
+ return EDITABLE_STATUSES.includes(status)
15
+ }
16
+
17
+ interface EditSheetProps {
18
+ mission: Mission | null
19
+ onClose: () => void
20
+ onSaved: (updated: Mission) => void
21
+ }
22
+
23
+ function numOrNull(value: string): number | null {
24
+ const trimmed = value.trim()
25
+ if (!trimmed) return null
26
+ const n = Number.parseFloat(trimmed)
27
+ return Number.isFinite(n) && n > 0 ? n : null
28
+ }
29
+
30
+ function intOrNull(value: string): number | null {
31
+ const n = numOrNull(value)
32
+ return n == null ? null : Math.round(n)
33
+ }
34
+
35
+ function renderCap(value: number | null | undefined): string {
36
+ return value == null ? '' : String(value)
37
+ }
38
+
39
+ export function MissionEditSheet({ mission, onClose, onSaved }: EditSheetProps) {
40
+ const [title, setTitle] = useState('')
41
+ const [goal, setGoal] = useState('')
42
+ const [criteriaText, setCriteriaText] = useState('')
43
+ const [maxUsd, setMaxUsd] = useState('')
44
+ const [maxTokens, setMaxTokens] = useState('')
45
+ const [maxWallclockSec, setMaxWallclockSec] = useState('')
46
+ const [maxTurns, setMaxTurns] = useState('')
47
+ const [maxToolCalls, setMaxToolCalls] = useState('')
48
+ const [reportsEnabled, setReportsEnabled] = useState(false)
49
+ const [reportIntervalMin, setReportIntervalMin] = useState('60')
50
+ const [reportFormat, setReportFormat] = useState<MissionReportFormat>('markdown')
51
+ const [reportConnectorIds, setReportConnectorIds] = useState<string[]>([])
52
+ const [connectors, setConnectors] = useState<Connector[]>([])
53
+ const [busy, setBusy] = useState(false)
54
+
55
+ useEffect(() => {
56
+ if (!mission) return
57
+ setTitle(mission.title)
58
+ setGoal(mission.goal)
59
+ setCriteriaText(mission.successCriteria.join('\n'))
60
+ setMaxUsd(renderCap(mission.budget.maxUsd))
61
+ setMaxTokens(renderCap(mission.budget.maxTokens))
62
+ setMaxWallclockSec(renderCap(mission.budget.maxWallclockSec))
63
+ setMaxTurns(renderCap(mission.budget.maxTurns))
64
+ setMaxToolCalls(renderCap(mission.budget.maxToolCalls))
65
+ const schedule = mission.reportSchedule
66
+ setReportsEnabled(Boolean(schedule?.enabled))
67
+ setReportIntervalMin(schedule ? String(Math.max(1, Math.round(schedule.intervalSec / 60))) : '60')
68
+ setReportFormat(schedule?.format ?? 'markdown')
69
+ setReportConnectorIds(mission.reportConnectorIds)
70
+ }, [mission])
71
+
72
+ useEffect(() => {
73
+ if (!mission) return
74
+ let cancelled = false
75
+ api<Record<string, Connector>>('GET', '/connectors')
76
+ .then((map) => {
77
+ if (!cancelled) setConnectors(map ? Object.values(map) : [])
78
+ })
79
+ .catch(() => { if (!cancelled) setConnectors([]) })
80
+ return () => { cancelled = true }
81
+ }, [mission])
82
+
83
+ const canEdit = useMemo(() => (mission ? isMissionEditable(mission.status) : false), [mission])
84
+
85
+ if (!mission) return null
86
+
87
+ const toggleConnector = (id: string) => {
88
+ setReportConnectorIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
89
+ }
90
+
91
+ const submit = async () => {
92
+ if (!title.trim() || !goal.trim()) {
93
+ toast.error('Title and goal are required')
94
+ return
95
+ }
96
+ const intervalMin = Number.parseFloat(reportIntervalMin)
97
+ if (reportsEnabled && (!Number.isFinite(intervalMin) || intervalMin <= 0)) {
98
+ toast.error('Report interval must be a positive number')
99
+ return
100
+ }
101
+ setBusy(true)
102
+ try {
103
+ const successCriteria = criteriaText
104
+ .split('\n')
105
+ .map((s) => s.trim())
106
+ .filter(Boolean)
107
+ const payload = {
108
+ title: title.trim(),
109
+ goal: goal.trim(),
110
+ successCriteria,
111
+ budget: {
112
+ maxUsd: numOrNull(maxUsd),
113
+ maxTokens: intOrNull(maxTokens),
114
+ maxWallclockSec: intOrNull(maxWallclockSec),
115
+ maxTurns: intOrNull(maxTurns),
116
+ maxToolCalls: intOrNull(maxToolCalls),
117
+ },
118
+ reportSchedule: reportsEnabled
119
+ ? {
120
+ intervalSec: Math.max(60, Math.round(intervalMin * 60)),
121
+ format: reportFormat,
122
+ enabled: true,
123
+ }
124
+ : null,
125
+ reportConnectorIds,
126
+ }
127
+ const updated = await api<Mission>('PUT', `/missions/${mission.id}`, payload)
128
+ onSaved(updated)
129
+ onClose()
130
+ toast.success('Mission updated')
131
+ } catch (error) {
132
+ toast.error(`Update failed: ${error instanceof Error ? error.message : String(error)}`)
133
+ } finally {
134
+ setBusy(false)
135
+ }
136
+ }
137
+
138
+ return (
139
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
140
+ <div
141
+ 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"
142
+ onClick={(e) => e.stopPropagation()}
143
+ >
144
+ <div className="text-[14px] font-600 text-text mb-1">Edit mission</div>
145
+ <div className="text-[11px] text-text-3 mb-4">
146
+ Adjust the goal, budget, or reporting schedule. Changes apply to the next turn.
147
+ </div>
148
+
149
+ {!canEdit && (
150
+ <div className="mb-3 rounded border border-amber-500/30 bg-amber-500/10 text-amber-200 px-3 py-2 text-[11px]">
151
+ This mission is {mission.status}. Only draft, running, or paused missions can be edited.
152
+ </div>
153
+ )}
154
+
155
+ <fieldset disabled={!canEdit || busy} className="flex flex-col gap-3">
156
+ <label className="flex flex-col gap-1">
157
+ <span className="text-[11px] text-text-3">Title</span>
158
+ <input value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
159
+ </label>
160
+
161
+ <label className="flex flex-col gap-1">
162
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
163
+ Goal <HintTip text="The natural-language objective. The team works toward this until budget or success criteria are hit." />
164
+ </span>
165
+ <textarea
166
+ value={goal}
167
+ onChange={(e) => setGoal(e.target.value)}
168
+ rows={3}
169
+ className={`${inputClass} resize-none`}
170
+ />
171
+ </label>
172
+
173
+ <label className="flex flex-col gap-1">
174
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
175
+ Success criteria <HintTip text="One per line. Used in reports and final verification." />
176
+ </span>
177
+ <textarea
178
+ value={criteriaText}
179
+ onChange={(e) => setCriteriaText(e.target.value)}
180
+ rows={4}
181
+ className={`${inputClass} resize-none`}
182
+ />
183
+ </label>
184
+
185
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
186
+ <div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-2">
187
+ Budget <HintTip text="Leave any field blank to remove that cap." />
188
+ </div>
189
+ <div className="grid grid-cols-2 gap-3">
190
+ <label className="flex flex-col gap-1">
191
+ <span className="text-[11px] text-text-3">Max USD</span>
192
+ <input
193
+ value={maxUsd}
194
+ onChange={(e) => setMaxUsd(e.target.value)}
195
+ className={inputClass}
196
+ inputMode="decimal"
197
+ placeholder="No cap"
198
+ />
199
+ </label>
200
+ <label className="flex flex-col gap-1">
201
+ <span className="text-[11px] text-text-3">Max tokens</span>
202
+ <input
203
+ value={maxTokens}
204
+ onChange={(e) => setMaxTokens(e.target.value)}
205
+ className={inputClass}
206
+ inputMode="numeric"
207
+ placeholder="No cap"
208
+ />
209
+ </label>
210
+ <label className="flex flex-col gap-1">
211
+ <span className="text-[11px] text-text-3">Max wallclock (sec)</span>
212
+ <input
213
+ value={maxWallclockSec}
214
+ onChange={(e) => setMaxWallclockSec(e.target.value)}
215
+ className={inputClass}
216
+ inputMode="numeric"
217
+ placeholder="No cap"
218
+ />
219
+ </label>
220
+ <label className="flex flex-col gap-1">
221
+ <span className="text-[11px] text-text-3">Max turns</span>
222
+ <input
223
+ value={maxTurns}
224
+ onChange={(e) => setMaxTurns(e.target.value)}
225
+ className={inputClass}
226
+ inputMode="numeric"
227
+ placeholder="No cap"
228
+ />
229
+ </label>
230
+ <label className="flex flex-col gap-1 col-span-2">
231
+ <span className="text-[11px] text-text-3">Max tool calls</span>
232
+ <input
233
+ value={maxToolCalls}
234
+ onChange={(e) => setMaxToolCalls(e.target.value)}
235
+ className={inputClass}
236
+ inputMode="numeric"
237
+ placeholder="No cap"
238
+ />
239
+ </label>
240
+ </div>
241
+ </div>
242
+
243
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
244
+ <div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-2">Periodic reports</div>
245
+ <label className="flex items-center gap-2 flex-wrap mb-2">
246
+ <input
247
+ type="checkbox"
248
+ checked={reportsEnabled}
249
+ onChange={(e) => setReportsEnabled(e.target.checked)}
250
+ />
251
+ <span className="text-[11px] text-text-3">Send a report every</span>
252
+ <input
253
+ disabled={!reportsEnabled}
254
+ value={reportIntervalMin}
255
+ onChange={(e) => setReportIntervalMin(e.target.value)}
256
+ className={`${inputClass} w-16`}
257
+ inputMode="numeric"
258
+ />
259
+ <span className="text-[11px] text-text-3">minutes in</span>
260
+ <select
261
+ disabled={!reportsEnabled}
262
+ value={reportFormat}
263
+ onChange={(e) => setReportFormat(e.target.value as MissionReportFormat)}
264
+ className={`${inputClass} w-28`}
265
+ >
266
+ {REPORT_FORMATS.map((f) => (
267
+ <option key={f} value={f}>{f}</option>
268
+ ))}
269
+ </select>
270
+ <span className="text-[11px] text-text-3">format</span>
271
+ </label>
272
+
273
+ {reportsEnabled && (
274
+ <div>
275
+ <div className="text-[11px] text-text-3 mb-1 inline-flex items-center gap-1">
276
+ Deliver via connectors <HintTip text="Reports post to the selected connector channels. Leave empty to keep reports in-app only." />
277
+ </div>
278
+ {connectors.length === 0 ? (
279
+ <div className="text-[11px] text-text-3/60">No connectors configured.</div>
280
+ ) : (
281
+ <div className="flex flex-col gap-1 max-h-[160px] overflow-y-auto">
282
+ {connectors.map((c) => (
283
+ <label key={c.id} className="flex items-center gap-2 text-[11px] text-text">
284
+ <input
285
+ type="checkbox"
286
+ checked={reportConnectorIds.includes(c.id)}
287
+ onChange={() => toggleConnector(c.id)}
288
+ />
289
+ <span className="font-600">{c.name}</span>
290
+ <span className="text-text-3/60">({c.platform})</span>
291
+ </label>
292
+ ))}
293
+ </div>
294
+ )}
295
+ </div>
296
+ )}
297
+ </div>
298
+ </fieldset>
299
+
300
+ <div className="mt-5 flex items-center justify-end gap-2">
301
+ <button
302
+ onClick={onClose}
303
+ className="text-[12px] px-3 py-1.5 rounded border border-white/[0.08] hover:bg-white/[0.04]"
304
+ disabled={busy}
305
+ >
306
+ Cancel
307
+ </button>
308
+ <button
309
+ onClick={submit}
310
+ disabled={busy || !canEdit}
311
+ 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"
312
+ >
313
+ {busy ? 'Saving...' : 'Save changes'}
314
+ </button>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ )
319
+ }