@swarmclawai/swarmclaw 1.5.53 → 1.5.54

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
@@ -399,13 +399,17 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
- ### v1.5.53 Highlights
402
+ ### v1.5.54 Highlights
403
403
 
404
404
  - **Mission templates library**: the `/missions` page now opens with a curated gallery of starter missions. Each template pre-wires a goal, success criteria, USD / token / turn / wallclock budgets, and a report cadence, so non-technical users can install a working autonomous run in one click. Initial lineup: Daily News Digest, Inbox Triage, Competitor Watch, Weekly Research Report, Social Listener, and Customer Support Triage. Setup notes flag any connector or permission prerequisites before installation. Power-user overrides (budget caps, success criteria, report cadence) live behind a collapsed **Advanced Settings** panel so the default install flow stays one click.
405
405
  - **New API routes `GET /api/missions/templates` and `POST /api/missions/templates/:id/instantiate`** with matching CLI commands `swarmclaw missions templates` and `swarmclaw missions instantiate`. Installed missions persist a `templateId` so the origin is traceable for future template-update flows; legacy missions normalize to `templateId: null` on load, no data migration required.
406
- - **Fix: switching a session's model now sticks in the UI** ([#50](https://github.com/swarmclawai/swarmclaw/pull/50)). The **Switch Model** panel in the agent inspector was reading from `agent.provider` / `agent.model` (the agent's defaults) instead of `session.provider` / `session.model`, so after saving a model switch the collapsed pill still showed the agent default, the combobox reset to the default when reopened, and `selectedProvider` reverted on every save. `ModelSwitcherInline` now uses `session.provider || agent.provider` and `session.model || agent.model` as the source of truth, and its `useEffect` syncs to `session.provider` changes so a successful save updates the panel immediately.
406
+ - **Fix: user-selected provider and model now survive the chat execution pipeline** ([#51](https://github.com/swarmclawai/swarmclaw/pull/51), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). Switching provider or model via the inspector panel mid-session was being reverted on every turn because the agent's configured route was unconditionally reapplied in three places. `syncSessionFromAgent` now only syncs credentials / endpoint / fallbacks when the session's provider still matches the route provider, `prepareChatTurn` preserves the user's chosen model after applying the route, and `updateChatSession` auto-resolves a stored credential for the new provider (and clears the stale `apiEndpoint`) when provider changes without an explicit `credentialId`. Restores reliable switching between Copilot CLI, Codex CLI, Groq, and OpenAI-compatible providers.
407
+
408
+ > **Note:** v1.5.53 release notes described the mission templates library, but the feature commit landed after the v1.5.53 tag was cut. v1.5.54 is the release that actually ships it.
409
+
410
+ ### v1.5.53 Highlights
407
411
 
408
- Thanks to [@borislavnnikolov](https://github.com/borislavnnikolov) for the contribution.
412
+ - **Fix: switching a session's model now sticks in the UI** ([#50](https://github.com/swarmclawai/swarmclaw/pull/50), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). The **Switch Model** panel in the agent inspector was reading from `agent.provider` / `agent.model` (the agent's defaults) instead of `session.provider` / `session.model`, so after saving a model switch the collapsed pill still showed the agent default, the combobox reset to the default when reopened, and `selectedProvider` reverted on every save. `ModelSwitcherInline` now uses `session.provider || agent.provider` and `session.model || agent.model` as the source of truth, and its `useEffect` syncs to `session.provider` changes so a successful save updates the panel immediately.
409
413
 
410
414
  ### v1.5.52 Highlights
411
415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.53",
3
+ "version": "1.5.54",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -0,0 +1,64 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
4
+ import { formatZodError } from '@/lib/validation/schemas'
5
+ import { notFound } from '@/lib/server/collection-helpers'
6
+ import { createMissionFromTemplate } from '@/lib/server/missions/mission-service'
7
+ import { patchSession } from '@/lib/server/sessions/session-repository'
8
+
9
+ export const dynamic = 'force-dynamic'
10
+
11
+ const BudgetOverrideSchema = z.object({
12
+ maxUsd: z.number().positive().nullable().optional(),
13
+ maxTokens: z.number().positive().int().nullable().optional(),
14
+ maxToolCalls: z.number().positive().int().nullable().optional(),
15
+ maxWallclockSec: z.number().positive().int().nullable().optional(),
16
+ maxTurns: z.number().positive().int().nullable().optional(),
17
+ warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
18
+ }).partial()
19
+
20
+ const ReportScheduleSchema = z.object({
21
+ intervalSec: z.number().int().min(30),
22
+ format: z.enum(['markdown', 'slack', 'discord', 'email', 'audio']),
23
+ enabled: z.boolean().default(true),
24
+ lastReportAt: z.number().nullable().optional(),
25
+ }).strict()
26
+
27
+ const InstantiateSchema = z.object({
28
+ rootSessionId: z.string().min(1, 'rootSessionId is required'),
29
+ overrides: z.object({
30
+ title: z.string().min(1).max(200).optional(),
31
+ goal: z.string().min(1).max(4000).optional(),
32
+ successCriteria: z.array(z.string().min(1)).max(32).optional(),
33
+ budget: BudgetOverrideSchema.optional(),
34
+ reportSchedule: ReportScheduleSchema.nullable().optional(),
35
+ agentIds: z.array(z.string().min(1)).max(32).optional(),
36
+ reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
37
+ }).optional(),
38
+ }).strict()
39
+
40
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
41
+ const { id } = await params
42
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
43
+ if (error) return error
44
+ const parsed = InstantiateSchema.safeParse(body)
45
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
46
+
47
+ const result = createMissionFromTemplate({
48
+ templateId: id,
49
+ rootSessionId: parsed.data.rootSessionId,
50
+ overrides: parsed.data.overrides,
51
+ })
52
+ if (!result) return notFound()
53
+
54
+ try {
55
+ patchSession(result.mission.rootSessionId, (current) => {
56
+ if (!current) return null
57
+ return { ...current, missionId: result.mission.id }
58
+ })
59
+ } catch {
60
+ // Session may not exist yet; budget hook falls back to service map.
61
+ }
62
+
63
+ return NextResponse.json({ mission: result.mission, template: result.template })
64
+ }
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { listMissionTemplates } from '@/lib/server/missions/mission-templates'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ export async function GET() {
7
+ return NextResponse.json(listMissionTemplates())
8
+ }
@@ -5,7 +5,12 @@ 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 type { Mission, MissionReport, MissionEvent, MissionTemplate, Session } from '@/types'
9
14
  import { toast } from 'sonner'
10
15
 
11
16
  const POLL_MS = 4_000
@@ -489,6 +494,9 @@ export default function MissionsPage() {
489
494
  const [createOpen, setCreateOpen] = useState(false)
490
495
  const [busy, setBusy] = useState(false)
491
496
  const [loaded, setLoaded] = useState(false)
497
+ const [templates, setTemplates] = useState<MissionTemplate[]>([])
498
+ const [galleryOpen, setGalleryOpen] = useState(false)
499
+ const [installTemplate, setInstallTemplate] = useState<MissionTemplate | null>(null)
492
500
 
493
501
  const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
494
502
 
@@ -534,7 +542,15 @@ export default function MissionsPage() {
534
542
  api<Session[]>('GET', '/chats').then((s) => {
535
543
  setSessions(Array.isArray(s) ? s : Object.values(s))
536
544
  }).catch(() => setSessions([]))
537
- }, [createOpen])
545
+ }, [createOpen, galleryOpen, installTemplate])
546
+
547
+ useEffect(() => {
548
+ let cancelled = false
549
+ api<MissionTemplate[]>('GET', '/missions/templates')
550
+ .then((list) => { if (!cancelled) setTemplates(Array.isArray(list) ? list : []) })
551
+ .catch(() => { if (!cancelled) setTemplates([]) })
552
+ return () => { cancelled = true }
553
+ }, [])
538
554
 
539
555
  const handleAction = useCallback(async (action: string, reason?: string) => {
540
556
  if (!selectedId) return
@@ -571,28 +587,60 @@ export default function MissionsPage() {
571
587
  toast.success(`Mission "${created.title}" created`)
572
588
  }, [refreshList])
573
589
 
590
+ const handleInstallTemplate = useCallback(async (template: MissionTemplate, input: InstantiateInput) => {
591
+ const result = await api<{ mission: Mission }>(
592
+ 'POST',
593
+ `/missions/templates/${encodeURIComponent(template.id)}/instantiate`,
594
+ input,
595
+ )
596
+ await refreshList()
597
+ setSelectedId(result.mission.id)
598
+ setGalleryOpen(false)
599
+ toast.success(`Mission "${result.mission.title}" installed`)
600
+ }, [refreshList])
601
+
574
602
  return (
575
603
  <MainContent>
576
604
  <div className="flex-1 flex min-h-0">
577
605
  <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>
606
+ <div className="p-3 border-b border-white/[0.06]">
607
+ <div className="flex items-center justify-between mb-2">
608
+ <div>
609
+ <div className="text-[13px] font-600">Missions</div>
610
+ <div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
611
+ </div>
612
+ <button
613
+ onClick={() => setCreateOpen(true)}
614
+ 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"
615
+ >
616
+ + New
617
+ </button>
582
618
  </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>
619
+ {templates.length > 0 && (
620
+ <button
621
+ onClick={() => setGalleryOpen(true)}
622
+ 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"
623
+ >
624
+ Browse {templates.length} starter templates →
625
+ </button>
626
+ )}
589
627
  </div>
590
628
  <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1.5">
591
629
  {!loaded ? (
592
630
  <div className="text-[11px] text-text-3 p-3">Loading...</div>
593
631
  ) : 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.
632
+ <div className="flex flex-col gap-2 p-3">
633
+ <div className="text-[11px] text-text-3">
634
+ No missions yet. Start from a template or create one from scratch.
635
+ </div>
636
+ {templates.length > 0 && (
637
+ <button
638
+ onClick={() => setGalleryOpen(true)}
639
+ 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"
640
+ >
641
+ Open template gallery
642
+ </button>
643
+ )}
596
644
  </div>
597
645
  ) : (
598
646
  missions.map((m) => (
@@ -616,6 +664,13 @@ export default function MissionsPage() {
616
664
  onAction={handleAction}
617
665
  onForceReport={handleForceReport}
618
666
  />
667
+ ) : loaded && missions.length === 0 && templates.length > 0 ? (
668
+ <div className="p-6">
669
+ <MissionTemplateGallery
670
+ templates={templates}
671
+ onInstall={(t) => setInstallTemplate(t)}
672
+ />
673
+ </div>
619
674
  ) : (
620
675
  <div className="flex items-center justify-center h-full text-text-3 text-[12px]">
621
676
  {loaded && missions.length === 0 ? 'Create a mission to get started.' : 'Select a mission'}
@@ -630,6 +685,34 @@ export default function MissionsPage() {
630
685
  onClose={() => setCreateOpen(false)}
631
686
  onCreate={handleCreate}
632
687
  />
688
+
689
+ {galleryOpen && (
690
+ <div
691
+ className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
692
+ onClick={() => setGalleryOpen(false)}
693
+ >
694
+ <div
695
+ 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"
696
+ onClick={(e) => e.stopPropagation()}
697
+ >
698
+ <div className="flex items-center justify-between mb-4">
699
+ <div className="text-[15px] font-700 text-text">Mission templates</div>
700
+ <button onClick={() => setGalleryOpen(false)} className="text-text-3 text-[12px] hover:text-text">Close</button>
701
+ </div>
702
+ <MissionTemplateGallery
703
+ templates={templates}
704
+ onInstall={(t) => setInstallTemplate(t)}
705
+ />
706
+ </div>
707
+ </div>
708
+ )}
709
+
710
+ <MissionTemplateInstallDialog
711
+ template={installTemplate}
712
+ sessions={sessions}
713
+ onClose={() => setInstallTemplate(null)}
714
+ onInstall={handleInstallTemplate}
715
+ />
633
716
  </MainContent>
634
717
  )
635
718
  }
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,113 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import type { MissionTemplate, MissionTemplateCategory } from '@/types'
5
+
6
+ const CATEGORY_LABELS: Record<MissionTemplateCategory, string> = {
7
+ research: 'Research',
8
+ communication: 'Communication',
9
+ monitoring: 'Monitoring',
10
+ productivity: 'Productivity',
11
+ support: 'Support',
12
+ }
13
+
14
+ interface Props {
15
+ templates: MissionTemplate[]
16
+ onInstall: (template: MissionTemplate) => void
17
+ }
18
+
19
+ export function MissionTemplateGallery({ templates, onInstall }: Props) {
20
+ const [category, setCategory] = useState<'all' | MissionTemplateCategory>('all')
21
+
22
+ const categories = useMemo(() => {
23
+ const seen = new Set<MissionTemplateCategory>()
24
+ for (const template of templates) seen.add(template.category)
25
+ return Array.from(seen)
26
+ }, [templates])
27
+
28
+ const filtered = useMemo(() => {
29
+ if (category === 'all') return templates
30
+ return templates.filter((t) => t.category === category)
31
+ }, [templates, category])
32
+
33
+ return (
34
+ <div className="flex flex-col gap-4">
35
+ <div>
36
+ <h2 className="text-[15px] font-700 text-text">Start from a template</h2>
37
+ <p className="text-[12px] text-text-3 mt-1">
38
+ Pre-wired goals, budgets, and report schedules. Tweak anything before you install.
39
+ </p>
40
+ </div>
41
+
42
+ <div className="flex flex-wrap gap-1.5">
43
+ <FilterChip active={category === 'all'} onClick={() => setCategory('all')} label="All" />
44
+ {categories.map((c) => (
45
+ <FilterChip
46
+ key={c}
47
+ active={category === c}
48
+ onClick={() => setCategory(c)}
49
+ label={CATEGORY_LABELS[c]}
50
+ />
51
+ ))}
52
+ </div>
53
+
54
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
55
+ {filtered.map((template) => (
56
+ <TemplateCard key={template.id} template={template} onInstall={() => onInstall(template)} />
57
+ ))}
58
+ </div>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ function FilterChip({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
64
+ return (
65
+ <button
66
+ type="button"
67
+ onClick={onClick}
68
+ className={`text-[11px] font-600 px-2.5 py-1 rounded-full border transition-colors ${
69
+ active
70
+ ? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-200'
71
+ : 'border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text'
72
+ }`}
73
+ >
74
+ {label}
75
+ </button>
76
+ )
77
+ }
78
+
79
+ function TemplateCard({ template, onInstall }: { template: MissionTemplate; onInstall: () => void }) {
80
+ return (
81
+ <button
82
+ type="button"
83
+ onClick={onInstall}
84
+ className="text-left group flex flex-col gap-3 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4 transition-all hover:border-white/[0.16] hover:bg-white/[0.04]"
85
+ >
86
+ <div className="flex items-start gap-3">
87
+ <span className="text-[22px] leading-none" aria-hidden>{template.icon}</span>
88
+ <div className="flex-1 min-w-0">
89
+ <div className="text-[13px] font-700 text-text">{template.name}</div>
90
+ <div className="text-[10px] uppercase tracking-wide text-text-3/70 mt-0.5">
91
+ {CATEGORY_LABELS[template.category]}
92
+ </div>
93
+ </div>
94
+ </div>
95
+ <div className="text-[12px] text-text-3 leading-[1.55] line-clamp-3">{template.description}</div>
96
+ <div className="flex items-center justify-between mt-auto">
97
+ <div className="flex flex-wrap gap-1">
98
+ {template.tags.slice(0, 3).map((tag) => (
99
+ <span
100
+ key={tag}
101
+ className="text-[10px] text-text-3/70 px-1.5 py-0.5 rounded border border-white/[0.06] bg-white/[0.02]"
102
+ >
103
+ {tag}
104
+ </span>
105
+ ))}
106
+ </div>
107
+ <span className="text-[11px] font-600 text-emerald-300 group-hover:text-emerald-200">
108
+ Install →
109
+ </span>
110
+ </div>
111
+ </button>
112
+ )
113
+ }
@@ -0,0 +1,278 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { HintTip } from '@/components/shared/hint-tip'
5
+ import { AdvancedSettingsSection } from '@/components/shared/advanced-settings-section'
6
+ import { inputClass } from '@/components/shared/form-styles'
7
+ import type { MissionTemplate, Session } from '@/types'
8
+ import { toast } from 'sonner'
9
+
10
+ export interface InstantiateInput {
11
+ rootSessionId: string
12
+ overrides: {
13
+ title: string
14
+ goal: string
15
+ successCriteria: string[]
16
+ budget: {
17
+ maxUsd: number | null
18
+ maxTokens: number | null
19
+ maxWallclockSec: number | null
20
+ maxTurns: number | null
21
+ }
22
+ reportSchedule: { intervalSec: number; format: 'markdown'; enabled: boolean } | null
23
+ }
24
+ }
25
+
26
+ interface Props {
27
+ template: MissionTemplate | null
28
+ sessions: Session[]
29
+ onClose: () => void
30
+ onInstall: (template: MissionTemplate, input: InstantiateInput) => Promise<void>
31
+ }
32
+
33
+ function formatDuration(sec: number | null | undefined): string {
34
+ if (sec == null) return '-'
35
+ if (sec < 60) return `${sec}s`
36
+ if (sec < 3600) return `${Math.round(sec / 60)}m`
37
+ if (sec < 86_400) return `${Math.round(sec / 3600)}h`
38
+ return `${Math.round(sec / 86_400)}d`
39
+ }
40
+
41
+ function numOrNull(s: string): number | null {
42
+ const n = Number.parseFloat(s)
43
+ return Number.isFinite(n) && n > 0 ? n : null
44
+ }
45
+
46
+ export function MissionTemplateInstallDialog({ template, sessions, onClose, onInstall }: Props) {
47
+ const [title, setTitle] = useState('')
48
+ const [goal, setGoal] = useState('')
49
+ const [criteriaText, setCriteriaText] = useState('')
50
+ const [rootSessionId, setRootSessionId] = useState('')
51
+ const [advancedOpen, setAdvancedOpen] = useState(false)
52
+ const [maxUsd, setMaxUsd] = useState('')
53
+ const [maxTokens, setMaxTokens] = useState('')
54
+ const [maxWallclockSec, setMaxWallclockSec] = useState('')
55
+ const [maxTurns, setMaxTurns] = useState('')
56
+ const [reportsEnabled, setReportsEnabled] = useState(true)
57
+ const [reportIntervalMin, setReportIntervalMin] = useState('')
58
+ const [busy, setBusy] = useState(false)
59
+
60
+ useEffect(() => {
61
+ if (!template) return
62
+ setTitle(template.defaults.title)
63
+ setGoal(template.defaults.goal)
64
+ setCriteriaText(template.defaults.successCriteria.join('\n'))
65
+ setMaxUsd(template.defaults.budget.maxUsd != null ? String(template.defaults.budget.maxUsd) : '')
66
+ setMaxTokens(template.defaults.budget.maxTokens != null ? String(template.defaults.budget.maxTokens) : '')
67
+ setMaxWallclockSec(template.defaults.budget.maxWallclockSec != null ? String(template.defaults.budget.maxWallclockSec) : '')
68
+ setMaxTurns(template.defaults.budget.maxTurns != null ? String(template.defaults.budget.maxTurns) : '')
69
+ setReportsEnabled(template.defaults.reportSchedule?.enabled ?? false)
70
+ setReportIntervalMin(
71
+ template.defaults.reportSchedule?.intervalSec != null
72
+ ? String(Math.round(template.defaults.reportSchedule.intervalSec / 60))
73
+ : '60',
74
+ )
75
+ setAdvancedOpen(false)
76
+ }, [template])
77
+
78
+ useEffect(() => {
79
+ if (!rootSessionId && sessions.length > 0) setRootSessionId(sessions[0].id)
80
+ }, [sessions, rootSessionId])
81
+
82
+ const badges = useMemo(() => {
83
+ if (!template) return []
84
+ const out: string[] = []
85
+ if (template.defaults.budget.maxUsd != null) out.push(`$${template.defaults.budget.maxUsd} cap`)
86
+ if (template.defaults.budget.maxTurns != null) out.push(`${template.defaults.budget.maxTurns} turns`)
87
+ if (template.defaults.budget.maxWallclockSec != null) out.push(formatDuration(template.defaults.budget.maxWallclockSec))
88
+ if (template.defaults.reportSchedule) out.push(`Reports every ${formatDuration(template.defaults.reportSchedule.intervalSec)}`)
89
+ return out
90
+ }, [template])
91
+
92
+ if (!template) return null
93
+
94
+ const submit = async () => {
95
+ if (!rootSessionId) {
96
+ toast.error('Pick a session to drive this mission')
97
+ return
98
+ }
99
+ if (!title.trim() || !goal.trim()) {
100
+ toast.error('Title and goal are required')
101
+ return
102
+ }
103
+ setBusy(true)
104
+ try {
105
+ const successCriteria = criteriaText.split('\n').map((s) => s.trim()).filter(Boolean)
106
+ const intervalMin = numOrNull(reportIntervalMin) ?? 60
107
+ await onInstall(template, {
108
+ rootSessionId,
109
+ overrides: {
110
+ title: title.trim(),
111
+ goal: goal.trim(),
112
+ successCriteria,
113
+ budget: {
114
+ maxUsd: numOrNull(maxUsd),
115
+ maxTokens: numOrNull(maxTokens),
116
+ maxWallclockSec: numOrNull(maxWallclockSec),
117
+ maxTurns: numOrNull(maxTurns),
118
+ },
119
+ reportSchedule: reportsEnabled
120
+ ? { intervalSec: Math.round(intervalMin * 60), format: 'markdown', enabled: true }
121
+ : null,
122
+ },
123
+ })
124
+ onClose()
125
+ } catch (error) {
126
+ toast.error(`Install failed: ${error instanceof Error ? error.message : String(error)}`)
127
+ } finally {
128
+ setBusy(false)
129
+ }
130
+ }
131
+
132
+ return (
133
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
134
+ <div
135
+ className="w-full max-w-xl rounded-[14px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-5 max-h-[92vh] overflow-y-auto"
136
+ onClick={(e) => e.stopPropagation()}
137
+ >
138
+ <div className="flex items-start gap-3 mb-4">
139
+ <span className="text-[28px] leading-none" aria-hidden>{template.icon}</span>
140
+ <div className="min-w-0 flex-1">
141
+ <div className="text-[15px] font-700 text-text">{template.name}</div>
142
+ <div className="text-[12px] text-text-3 leading-[1.5] mt-1">{template.description}</div>
143
+ {badges.length > 0 && (
144
+ <div className="mt-2.5 flex flex-wrap gap-1.5">
145
+ {badges.map((badge) => (
146
+ <span
147
+ key={badge}
148
+ className="text-[10px] font-600 px-1.5 py-0.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3"
149
+ >
150
+ {badge}
151
+ </span>
152
+ ))}
153
+ </div>
154
+ )}
155
+ </div>
156
+ </div>
157
+
158
+ {template.setupNote && (
159
+ <div className="mb-4 text-[11px] text-amber-200/90 bg-amber-500/10 border border-amber-500/20 rounded-[10px] px-3 py-2 leading-[1.5]">
160
+ <span className="font-700">Setup: </span>
161
+ {template.setupNote}
162
+ </div>
163
+ )}
164
+
165
+ <div className="flex flex-col gap-3">
166
+ <label className="flex flex-col gap-1">
167
+ <span className="text-[11px] text-text-3">Title</span>
168
+ <input value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
169
+ </label>
170
+
171
+ <label className="flex flex-col gap-1">
172
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
173
+ Goal <HintTip text="The natural-language objective your team will work on." />
174
+ </span>
175
+ <textarea
176
+ value={goal}
177
+ onChange={(e) => setGoal(e.target.value)}
178
+ rows={4}
179
+ className={`${inputClass} resize-none`}
180
+ />
181
+ </label>
182
+
183
+ <label className="flex flex-col gap-1">
184
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
185
+ Root session <HintTip text="The session whose heartbeat drives this mission." />
186
+ </span>
187
+ <select value={rootSessionId} onChange={(e) => setRootSessionId(e.target.value)} className={inputClass}>
188
+ {sessions.length === 0 && <option value="">No sessions available. Create a chat first.</option>}
189
+ {sessions.map((s) => (
190
+ <option key={s.id} value={s.id}>
191
+ {s.name || s.id}
192
+ </option>
193
+ ))}
194
+ </select>
195
+ </label>
196
+ </div>
197
+
198
+ <div className="mt-4">
199
+ <AdvancedSettingsSection
200
+ open={advancedOpen}
201
+ onToggle={() => setAdvancedOpen((o) => !o)}
202
+ summary="Budgets, criteria, reports"
203
+ >
204
+ <div className="flex flex-col gap-4">
205
+ <label className="flex flex-col gap-1">
206
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
207
+ Success criteria <HintTip text="One per line. Used in reports and final verification." />
208
+ </span>
209
+ <textarea
210
+ value={criteriaText}
211
+ onChange={(e) => setCriteriaText(e.target.value)}
212
+ rows={4}
213
+ className={`${inputClass} resize-none`}
214
+ />
215
+ </label>
216
+
217
+ <div className="grid grid-cols-2 gap-3">
218
+ <label className="flex flex-col gap-1">
219
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
220
+ Max USD <HintTip text="Hard spend cap. Leave blank for no limit." />
221
+ </span>
222
+ <input value={maxUsd} onChange={(e) => setMaxUsd(e.target.value)} className={inputClass} inputMode="decimal" />
223
+ </label>
224
+ <label className="flex flex-col gap-1">
225
+ <span className="text-[11px] text-text-3">Max tokens</span>
226
+ <input value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} className={inputClass} inputMode="numeric" />
227
+ </label>
228
+ <label className="flex flex-col gap-1">
229
+ <span className="text-[11px] text-text-3 inline-flex items-center gap-1">
230
+ Max wallclock (sec) <HintTip text="Scheduler aborts after this many seconds of elapsed wallclock." />
231
+ </span>
232
+ <input value={maxWallclockSec} onChange={(e) => setMaxWallclockSec(e.target.value)} className={inputClass} inputMode="numeric" />
233
+ </label>
234
+ <label className="flex flex-col gap-1">
235
+ <span className="text-[11px] text-text-3">Max turns</span>
236
+ <input value={maxTurns} onChange={(e) => setMaxTurns(e.target.value)} className={inputClass} inputMode="numeric" />
237
+ </label>
238
+ </div>
239
+
240
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.02] px-3 py-2.5">
241
+ <div className="text-[11px] font-600 text-text-3 uppercase tracking-wide mb-1.5">Periodic reports</div>
242
+ <label className="flex items-center gap-2 flex-wrap">
243
+ <input type="checkbox" checked={reportsEnabled} onChange={(e) => setReportsEnabled(e.target.checked)} />
244
+ <span className="text-[11px] text-text-3">Send a markdown progress report every</span>
245
+ <input
246
+ disabled={!reportsEnabled}
247
+ value={reportIntervalMin}
248
+ onChange={(e) => setReportIntervalMin(e.target.value)}
249
+ className={`${inputClass} w-16`}
250
+ inputMode="numeric"
251
+ />
252
+ <span className="text-[11px] text-text-3">minutes</span>
253
+ </label>
254
+ </div>
255
+ </div>
256
+ </AdvancedSettingsSection>
257
+ </div>
258
+
259
+ <div className="mt-5 flex items-center justify-end gap-2">
260
+ <button
261
+ onClick={onClose}
262
+ className="text-[12px] px-3 py-1.5 rounded border border-white/[0.08] hover:bg-white/[0.04]"
263
+ disabled={busy}
264
+ >
265
+ Cancel
266
+ </button>
267
+ <button
268
+ onClick={submit}
269
+ disabled={busy}
270
+ 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"
271
+ >
272
+ {busy ? 'Installing…' : 'Install mission'}
273
+ </button>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ )
278
+ }
@@ -208,19 +208,24 @@ function syncSessionFromAgent(sessionId: string): void {
208
208
  }
209
209
  if (route) {
210
210
  const resolved = applyResolvedRoute({ ...session }, route)
211
- if (session.provider !== resolved.provider) { session.provider = resolved.provider; changed = true }
212
- if (session.model !== resolved.model) { session.model = resolved.model; changed = true }
213
- if ((session.credentialId || null) !== (resolved.credentialId || null)) {
214
- session.credentialId = resolved.credentialId ?? null
215
- changed = true
216
- }
217
- if (JSON.stringify(session.fallbackCredentialIds || []) !== JSON.stringify(resolved.fallbackCredentialIds || [])) {
218
- session.fallbackCredentialIds = [...(resolved.fallbackCredentialIds || [])]
219
- changed = true
220
- }
221
- if ((session.apiEndpoint || null) !== (resolved.apiEndpoint || null)) {
222
- session.apiEndpoint = resolved.apiEndpoint ?? null
223
- changed = true
211
+ // Do NOT sync provider/model from the route here the user may have manually
212
+ // switched the session model, and we must preserve that choice.
213
+ // Provider/model are initialized from the route at session-creation time only.
214
+ // Only sync credentials/endpoint when the session's provider still matches the
215
+ // route's provider — if the user switched providers, leave their credential alone.
216
+ if (session.provider === resolved.provider) {
217
+ if ((session.credentialId || null) !== (resolved.credentialId || null)) {
218
+ session.credentialId = resolved.credentialId ?? null
219
+ changed = true
220
+ }
221
+ if (JSON.stringify(session.fallbackCredentialIds || []) !== JSON.stringify(resolved.fallbackCredentialIds || [])) {
222
+ session.fallbackCredentialIds = [...(resolved.fallbackCredentialIds || [])]
223
+ changed = true
224
+ }
225
+ if ((session.apiEndpoint || null) !== (resolved.apiEndpoint || null)) {
226
+ session.apiEndpoint = resolved.apiEndpoint ?? null
227
+ changed = true
228
+ }
224
229
  }
225
230
  if ((session.gatewayProfileId || null) !== (resolved.gatewayProfileId || null)) {
226
231
  session.gatewayProfileId = resolved.gatewayProfileId ?? null
@@ -624,9 +629,15 @@ export async function prepareChatTurn(input: ExecuteChatTurnInput): Promise<Prep
624
629
  preferredGatewayTags: session.routePreferredGatewayTags || [],
625
630
  preferredGatewayUseCase: session.routePreferredGatewayUseCase || null,
626
631
  })
627
- if (preferredRoute) {
632
+ if (preferredRoute && sessionForRun.provider === preferredRoute.provider) {
633
+ // Apply route for credentials/endpoint/gateway, but preserve the user's
634
+ // manually-selected model — only sync infra, not the model choice.
635
+ const savedModel = sessionForRun.model
628
636
  sessionForRun = applyResolvedRoute({ ...sessionForRun }, preferredRoute)
637
+ sessionForRun = { ...sessionForRun, model: savedModel }
629
638
  }
639
+ // If the user has manually switched to a different provider, skip the route
640
+ // entirely — the session already has the correct provider/model/credential.
630
641
  }
631
642
  let effectiveMessage = message
632
643
 
@@ -7,6 +7,7 @@ import { buildAgentDisabledMessage, isAgentDisabled } from '@/lib/server/agents/
7
7
  import { loadAgent } from '@/lib/server/agents/agent-repository'
8
8
  import { clearMainLoopStateForSession } from '@/lib/server/agents/main-agent-loop'
9
9
  import { applyResolvedRoute, resolvePrimaryAgentRoute } from '@/lib/server/agents/agent-runtime-config'
10
+ import { loadCredentials } from '@/lib/server/credentials/credential-repository'
10
11
  import { cleanupSessionProcesses } from '@/lib/server/runtime/process-manager'
11
12
  import { stopActiveSessionProcess } from '@/lib/server/runtime/runtime-state'
12
13
  import {
@@ -235,6 +236,13 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
235
236
  if (updates.credentialId !== undefined) session.credentialId = updates.credentialId
236
237
  else if (agentIdUpdateProvided && linkedRoute) session.credentialId = linkedRoute.credentialId ?? null
237
238
  else if (agentIdUpdateProvided && linkedAgent) session.credentialId = linkedAgent.credentialId ?? null
239
+ else if (updates.provider !== undefined && updates.provider !== session.provider) {
240
+ // Provider changed without an explicit credentialId — find a stored credential
241
+ // for the new provider so API-key-based providers (Groq, OpenAI, …) work.
242
+ const allCreds = loadCredentials()
243
+ const providerCred = Object.values(allCreds).find(c => c.provider === updates.provider)
244
+ session.credentialId = providerCred?.id ?? null
245
+ }
238
246
  if (updates.fallbackCredentialIds !== undefined) session.fallbackCredentialIds = updates.fallbackCredentialIds
239
247
  else if (agentIdUpdateProvided && linkedRoute) session.fallbackCredentialIds = [...linkedRoute.fallbackCredentialIds]
240
248
  if (updates.gatewayProfileId !== undefined) session.gatewayProfileId = updates.gatewayProfileId
@@ -264,6 +272,9 @@ export function updateChatSession(sessionId: string, updates: Record<string, unk
264
272
  session.apiEndpoint = linkedRoute.apiEndpoint ?? null
265
273
  } else if (agentIdUpdateProvided && linkedAgent) {
266
274
  session.apiEndpoint = normalizeProviderEndpoint(linkedAgent.provider, linkedAgent.apiEndpoint ?? null)
275
+ } else if (updates.provider !== undefined && updates.provider !== session.provider) {
276
+ // Provider changed — clear stale endpoint so the new provider uses its own default.
277
+ session.apiEndpoint = null
267
278
  }
268
279
  if (updates.heartbeatEnabled !== undefined) session.heartbeatEnabled = updates.heartbeatEnabled
269
280
  if (updates.heartbeatIntervalSec !== undefined) session.heartbeatIntervalSec = updates.heartbeatIntervalSec
@@ -1,5 +1,5 @@
1
1
  import crypto from 'crypto'
2
- import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus } from '@/types'
2
+ import type { Mission, MissionBudget, MissionReportSchedule, MissionStatus, MissionTemplate } from '@/types'
3
3
  import { DEFAULT_MISSION_WARN_FRACTIONS } from '@/types'
4
4
  import { hmrSingleton } from '@/lib/shared-utils'
5
5
  import { log } from '@/lib/server/logger'
@@ -11,6 +11,7 @@ import {
11
11
  patchMission,
12
12
  upsertMission,
13
13
  } from './mission-repository'
14
+ import { getMissionTemplate } from './mission-templates'
14
15
 
15
16
  const TAG = 'mission-service'
16
17
 
@@ -59,6 +60,7 @@ export interface CreateMissionInput {
59
60
  budget?: Partial<MissionBudget>
60
61
  reportSchedule?: MissionReportSchedule | null
61
62
  reportConnectorIds?: string[]
63
+ templateId?: string | null
62
64
  }
63
65
 
64
66
  function newMissionId(): string {
@@ -82,6 +84,9 @@ function sanitizeBudget(input: Partial<MissionBudget> = {}): MissionBudget {
82
84
 
83
85
  export function createMission(input: CreateMissionInput): Mission {
84
86
  const now = Date.now()
87
+ const templateId = typeof input.templateId === 'string' && input.templateId.trim()
88
+ ? input.templateId.trim().slice(0, 64)
89
+ : null
85
90
  const mission: Mission = {
86
91
  id: newMissionId(),
87
92
  title: input.title.trim(),
@@ -106,12 +111,53 @@ export function createMission(input: CreateMissionInput): Mission {
106
111
  reportConnectorIds: input.reportConnectorIds ?? [],
107
112
  createdAt: now,
108
113
  updatedAt: now,
114
+ templateId,
109
115
  }
110
116
  upsertMission(mission)
111
117
  log.info(TAG, `Created mission ${mission.id} (goal: ${mission.goal.slice(0, 80)})`)
112
118
  return mission
113
119
  }
114
120
 
121
+ export interface CreateMissionFromTemplateInput {
122
+ templateId: string
123
+ rootSessionId: string
124
+ overrides?: {
125
+ title?: string
126
+ goal?: string
127
+ successCriteria?: string[]
128
+ budget?: Partial<MissionBudget>
129
+ reportSchedule?: MissionReportSchedule | null
130
+ agentIds?: string[]
131
+ reportConnectorIds?: string[]
132
+ }
133
+ }
134
+
135
+ export interface CreateMissionFromTemplateResult {
136
+ mission: Mission
137
+ template: MissionTemplate
138
+ }
139
+
140
+ export function createMissionFromTemplate(input: CreateMissionFromTemplateInput): CreateMissionFromTemplateResult | null {
141
+ const template = getMissionTemplate(input.templateId)
142
+ if (!template) return null
143
+ const overrides = input.overrides ?? {}
144
+ const mergedBudget: Partial<MissionBudget> = { ...template.defaults.budget, ...(overrides.budget ?? {}) }
145
+ const mission = createMission({
146
+ title: overrides.title?.trim() || template.defaults.title,
147
+ goal: overrides.goal?.trim() || template.defaults.goal,
148
+ successCriteria: overrides.successCriteria ?? template.defaults.successCriteria,
149
+ rootSessionId: input.rootSessionId,
150
+ agentIds: overrides.agentIds ?? [],
151
+ budget: mergedBudget,
152
+ reportSchedule: overrides.reportSchedule === undefined
153
+ ? template.defaults.reportSchedule
154
+ : overrides.reportSchedule,
155
+ reportConnectorIds: overrides.reportConnectorIds ?? [],
156
+ templateId: template.id,
157
+ })
158
+ return { mission, template }
159
+ }
160
+
115
161
  function applyStatusTransition(
116
162
  id: string,
117
163
  next: MissionStatus,
@@ -0,0 +1,208 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { after, before, describe, it } from 'node:test'
6
+
7
+ const originalEnv = {
8
+ DATA_DIR: process.env.DATA_DIR,
9
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
10
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
11
+ }
12
+
13
+ let tempDir = ''
14
+ let templates: typeof import('./mission-templates')
15
+ let service: typeof import('./mission-service')
16
+
17
+ before(async () => {
18
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-mission-tpl-'))
19
+ process.env.DATA_DIR = path.join(tempDir, 'data')
20
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
21
+ process.env.SWARMCLAW_BUILD_MODE = '1'
22
+ templates = await import('./mission-templates')
23
+ service = await import('./mission-service')
24
+ })
25
+
26
+ after(() => {
27
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
28
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
29
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
30
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
31
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
32
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
33
+ fs.rmSync(tempDir, { recursive: true, force: true })
34
+ })
35
+
36
+ describe('mission-templates: registry', () => {
37
+ it('ships at least 6 built-in templates', () => {
38
+ const list = templates.listMissionTemplates()
39
+ assert.ok(list.length >= 6, `expected 6+ templates, got ${list.length}`)
40
+ })
41
+
42
+ it('has no duplicate template ids', () => {
43
+ const list = templates.listMissionTemplates()
44
+ const ids = new Set<string>()
45
+ for (const t of list) {
46
+ assert.ok(!ids.has(t.id), `duplicate template id: ${t.id}`)
47
+ ids.add(t.id)
48
+ }
49
+ })
50
+
51
+ it('every template has required fields populated', () => {
52
+ const list = templates.listMissionTemplates()
53
+ for (const t of list) {
54
+ assert.ok(t.id, `missing id`)
55
+ assert.ok(t.name, `${t.id} missing name`)
56
+ assert.ok(t.description, `${t.id} missing description`)
57
+ assert.ok(t.icon, `${t.id} missing icon`)
58
+ assert.ok(t.category, `${t.id} missing category`)
59
+ assert.ok(Array.isArray(t.tags), `${t.id} tags not array`)
60
+ assert.ok(t.defaults.title, `${t.id} missing defaults.title`)
61
+ assert.ok(t.defaults.goal, `${t.id} missing defaults.goal`)
62
+ assert.ok(Array.isArray(t.defaults.successCriteria), `${t.id} successCriteria not array`)
63
+ assert.ok(t.defaults.budget, `${t.id} missing budget`)
64
+ assert.ok(Array.isArray(t.defaults.budget.warnAtFractions), `${t.id} budget.warnAtFractions missing`)
65
+ }
66
+ })
67
+
68
+ it('getMissionTemplate resolves known ids', () => {
69
+ const list = templates.listMissionTemplates()
70
+ const first = list[0]
71
+ const resolved = templates.getMissionTemplate(first.id)
72
+ assert.equal(resolved?.id, first.id)
73
+ })
74
+
75
+ it('getMissionTemplate returns null for unknown ids', () => {
76
+ assert.equal(templates.getMissionTemplate('nope-does-not-exist'), null)
77
+ assert.equal(templates.getMissionTemplate(''), null)
78
+ assert.equal(templates.getMissionTemplate(null), null)
79
+ assert.equal(templates.getMissionTemplate(undefined), null)
80
+ })
81
+ })
82
+
83
+ describe('mission-service: createMissionFromTemplate', () => {
84
+ it('materializes a mission using template defaults', () => {
85
+ const list = templates.listMissionTemplates()
86
+ const tpl = list[0]
87
+ const result = service.createMissionFromTemplate({
88
+ templateId: tpl.id,
89
+ rootSessionId: 'sess_test_1',
90
+ })
91
+ assert.ok(result, 'expected result')
92
+ assert.equal(result!.mission.templateId, tpl.id)
93
+ assert.equal(result!.mission.goal, tpl.defaults.goal)
94
+ assert.equal(result!.mission.title, tpl.defaults.title)
95
+ assert.equal(result!.mission.rootSessionId, 'sess_test_1')
96
+ assert.deepEqual(result!.mission.successCriteria, tpl.defaults.successCriteria)
97
+ assert.equal(result!.template.id, tpl.id)
98
+ })
99
+
100
+ it('applies overrides without dropping unspecified defaults', () => {
101
+ const tpl = templates.listMissionTemplates()[0]
102
+ const result = service.createMissionFromTemplate({
103
+ templateId: tpl.id,
104
+ rootSessionId: 'sess_test_2',
105
+ overrides: {
106
+ title: 'Custom title',
107
+ budget: { maxUsd: 99 },
108
+ },
109
+ })
110
+ assert.ok(result)
111
+ assert.equal(result!.mission.title, 'Custom title')
112
+ assert.equal(result!.mission.budget.maxUsd, 99)
113
+ // Token cap from template should persist when not overridden
114
+ assert.equal(result!.mission.budget.maxTokens, tpl.defaults.budget.maxTokens)
115
+ // Goal unchanged
116
+ assert.equal(result!.mission.goal, tpl.defaults.goal)
117
+ })
118
+
119
+ it('returns null for unknown template id', () => {
120
+ const result = service.createMissionFromTemplate({
121
+ templateId: 'no-such-template',
122
+ rootSessionId: 'sess_test_3',
123
+ })
124
+ assert.equal(result, null)
125
+ })
126
+
127
+ it('allows overriding reportSchedule to null', () => {
128
+ const tpl = templates.listMissionTemplates()[0]
129
+ const result = service.createMissionFromTemplate({
130
+ templateId: tpl.id,
131
+ rootSessionId: 'sess_test_4',
132
+ overrides: { reportSchedule: null },
133
+ })
134
+ assert.ok(result)
135
+ assert.equal(result!.mission.reportSchedule, null)
136
+ })
137
+ })
138
+
139
+ describe('mission-service: templateId persistence', () => {
140
+ it('createMission persists an explicit templateId', () => {
141
+ const mission = service.createMission({
142
+ title: 'Direct',
143
+ goal: 'no template',
144
+ rootSessionId: 'sess_direct',
145
+ templateId: 'some-template-id',
146
+ })
147
+ assert.equal(mission.templateId, 'some-template-id')
148
+ })
149
+
150
+ it('createMission defaults templateId to null when omitted', () => {
151
+ const mission = service.createMission({
152
+ title: 'Direct',
153
+ goal: 'no template',
154
+ rootSessionId: 'sess_direct_2',
155
+ })
156
+ assert.equal(mission.templateId, null)
157
+ })
158
+ })
159
+
160
+ // Normalization test: legacy mission JSON without templateId should get templateId: null after load.
161
+ describe('mission normalization: templateId default', () => {
162
+ const loadItem = () => null
163
+
164
+ it('legacy records normalize to templateId: null', async () => {
165
+ const { normalizeStoredRecord } = await import('@/lib/server/storage-normalization')
166
+ const legacy: Record<string, unknown> = {
167
+ id: 'mi_legacy_1',
168
+ title: 'Legacy',
169
+ goal: 'from before templates',
170
+ successCriteria: [],
171
+ rootSessionId: 'sess_legacy',
172
+ agentIds: [],
173
+ status: 'draft',
174
+ budget: {},
175
+ usage: {},
176
+ milestones: [],
177
+ reportConnectorIds: [],
178
+ createdAt: Date.now(),
179
+ updatedAt: Date.now(),
180
+ }
181
+ const { value } = normalizeStoredRecord('agent_missions', legacy, loadItem)
182
+ const normalized = value as { templateId: unknown }
183
+ assert.equal(normalized.templateId, null)
184
+ })
185
+
186
+ it('normalization preserves a valid templateId', async () => {
187
+ const { normalizeStoredRecord } = await import('@/lib/server/storage-normalization')
188
+ const record: Record<string, unknown> = {
189
+ id: 'mi_tpl_1',
190
+ title: 'From template',
191
+ goal: 'x',
192
+ successCriteria: [],
193
+ rootSessionId: 'sess_tpl',
194
+ agentIds: [],
195
+ status: 'draft',
196
+ budget: {},
197
+ usage: {},
198
+ milestones: [],
199
+ reportConnectorIds: [],
200
+ createdAt: Date.now(),
201
+ updatedAt: Date.now(),
202
+ templateId: 'daily-news-digest',
203
+ }
204
+ const { value } = normalizeStoredRecord('agent_missions', record, loadItem)
205
+ const normalized = value as { templateId: unknown }
206
+ assert.equal(normalized.templateId, 'daily-news-digest')
207
+ })
208
+ })
@@ -0,0 +1,186 @@
1
+ import type {
2
+ MissionBudget,
3
+ MissionReportSchedule,
4
+ MissionTemplate,
5
+ MissionTemplateCategory,
6
+ } from '@/types'
7
+ import { DEFAULT_MISSION_WARN_FRACTIONS } from '@/types'
8
+
9
+ const HOUR = 3600
10
+ const DAY = 86_400
11
+
12
+ function budget(overrides: Partial<MissionBudget>): MissionBudget {
13
+ return {
14
+ maxUsd: null,
15
+ maxTokens: null,
16
+ maxToolCalls: null,
17
+ maxWallclockSec: null,
18
+ maxTurns: null,
19
+ warnAtFractions: DEFAULT_MISSION_WARN_FRACTIONS,
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ function report(intervalSec: number, format: MissionReportSchedule['format'] = 'markdown'): MissionReportSchedule {
25
+ return { intervalSec, format, enabled: true, lastReportAt: null }
26
+ }
27
+
28
+ export const BUILT_IN_MISSION_TEMPLATES: MissionTemplate[] = [
29
+ {
30
+ id: 'daily-news-digest',
31
+ name: 'Daily News Digest',
32
+ description:
33
+ 'Scan news sources, pick the 5 most relevant stories for your interests, and write a short digest once a day.',
34
+ icon: '📰',
35
+ category: 'research',
36
+ tags: ['daily', 'news', 'summary'],
37
+ setupNote:
38
+ 'Edit the goal to list your interests and sources before starting (e.g., "AI infrastructure, open-source agents, Bloomberg / Hacker News").',
39
+ defaults: {
40
+ title: 'Daily News Digest',
41
+ goal:
42
+ 'Every day, scan the latest news from my sources of interest, pick the 5 most relevant stories, and write a short markdown digest with title, 2-sentence summary, and link for each.',
43
+ successCriteria: [
44
+ 'Exactly 5 stories per digest',
45
+ 'Each story has a title, summary, and source link',
46
+ 'Digest is less than 500 words',
47
+ ],
48
+ budget: budget({ maxUsd: 1, maxTokens: 40_000, maxTurns: 80, maxWallclockSec: 2 * HOUR }),
49
+ reportSchedule: report(DAY),
50
+ },
51
+ },
52
+ {
53
+ id: 'inbox-triage',
54
+ name: 'Inbox Triage',
55
+ description:
56
+ 'Classify new emails, draft replies to routine threads, and flag anything that needs your attention.',
57
+ icon: '📬',
58
+ category: 'communication',
59
+ tags: ['email', 'triage', 'automation'],
60
+ setupNote:
61
+ 'Connect your email connector and confirm send/reply permissions before starting this mission.',
62
+ defaults: {
63
+ title: 'Inbox Triage',
64
+ goal:
65
+ 'Every hour, pull new emails, classify each as routine / needs-reply / urgent, draft replies to routine threads for my approval, and surface urgent items to me immediately.',
66
+ successCriteria: [
67
+ 'Every new email is classified',
68
+ 'Routine replies are drafted, not sent without approval',
69
+ 'Urgent items are flagged within the same hour they arrive',
70
+ ],
71
+ budget: budget({ maxUsd: 3, maxTokens: 120_000, maxTurns: 200, maxWallclockSec: 12 * HOUR }),
72
+ reportSchedule: report(6 * HOUR),
73
+ },
74
+ },
75
+ {
76
+ id: 'competitor-watch',
77
+ name: 'Competitor Watch',
78
+ description:
79
+ 'Track competitor websites, blogs, and releases; flag anything new and write a weekly summary.',
80
+ icon: '🔭',
81
+ category: 'monitoring',
82
+ tags: ['competitive', 'weekly', 'monitoring'],
83
+ setupNote:
84
+ 'List the competitors and specific URLs to watch in the goal field before starting.',
85
+ defaults: {
86
+ title: 'Competitor Watch',
87
+ goal:
88
+ 'Check the listed competitors every 6 hours for product releases, pricing changes, blog posts, and notable social activity. Write a short summary of new signals and compile a weekly roll-up.',
89
+ successCriteria: [
90
+ 'All listed competitors are checked every cycle',
91
+ 'New signals are captured with source links and timestamps',
92
+ 'A weekly roll-up is produced on Monday mornings',
93
+ ],
94
+ budget: budget({ maxUsd: 5, maxTokens: 200_000, maxTurns: 300, maxWallclockSec: 7 * DAY }),
95
+ reportSchedule: report(DAY),
96
+ },
97
+ },
98
+ {
99
+ id: 'weekly-research-report',
100
+ name: 'Weekly Research Report',
101
+ description:
102
+ 'Pick a research topic each Monday, dig into it across the week, and deliver a polished report by Friday.',
103
+ icon: '🧠',
104
+ category: 'research',
105
+ tags: ['weekly', 'research', 'report'],
106
+ setupNote:
107
+ 'Set the topic in the goal (or let the agent pick from a rotating list).',
108
+ defaults: {
109
+ title: 'Weekly Research Report',
110
+ goal:
111
+ 'Produce a 1000-2000 word research report on the assigned topic. Gather at least 8 sources, compare viewpoints, surface open questions, and deliver a polished markdown document by end of week.',
112
+ successCriteria: [
113
+ 'Report is between 1000 and 2000 words',
114
+ 'At least 8 distinct sources are cited with links',
115
+ 'Conclusion explicitly lists open questions or areas for follow-up',
116
+ ],
117
+ budget: budget({ maxUsd: 4, maxTokens: 250_000, maxTurns: 250, maxWallclockSec: 7 * DAY }),
118
+ reportSchedule: report(2 * DAY),
119
+ },
120
+ },
121
+ {
122
+ id: 'social-listener',
123
+ name: 'Social Listener',
124
+ description:
125
+ 'Watch configured channels for mentions of your brand, keywords, or topics and surface notable threads.',
126
+ icon: '👂',
127
+ category: 'monitoring',
128
+ tags: ['social', 'listening', 'realtime'],
129
+ setupNote:
130
+ 'Connect Discord or Slack (or both) and list the keywords to watch for in the goal.',
131
+ defaults: {
132
+ title: 'Social Listener',
133
+ goal:
134
+ 'Watch the connected channels for the configured keywords. When a match appears, capture the message, author, timestamp, and a 1-sentence context note. Summarize daily.',
135
+ successCriteria: [
136
+ 'Every keyword match is captured with context',
137
+ 'No duplicate alerts for the same message',
138
+ 'A daily recap is produced listing the top 10 mentions',
139
+ ],
140
+ budget: budget({ maxUsd: 2, maxTokens: 100_000, maxTurns: 400, maxWallclockSec: 7 * DAY }),
141
+ reportSchedule: report(DAY),
142
+ },
143
+ },
144
+ {
145
+ id: 'customer-support-triage',
146
+ name: 'Customer Support Triage',
147
+ description:
148
+ 'Classify incoming support tickets, draft first responses, and route complex issues to a human.',
149
+ icon: '🛟',
150
+ category: 'support',
151
+ tags: ['support', 'triage', 'drafts'],
152
+ setupNote:
153
+ 'Connect your helpdesk or email connector and confirm draft-only permissions before starting.',
154
+ defaults: {
155
+ title: 'Customer Support Triage',
156
+ goal:
157
+ 'For each new support ticket, classify priority and category, draft a first response for human review, and flag tickets that require engineering or account-level escalation.',
158
+ successCriteria: [
159
+ 'Every ticket receives a draft within one hour',
160
+ 'Priority and category are labeled consistently',
161
+ 'Escalations are clearly flagged with a reason',
162
+ ],
163
+ budget: budget({ maxUsd: 3, maxTokens: 150_000, maxTurns: 300, maxWallclockSec: 3 * DAY }),
164
+ reportSchedule: report(12 * HOUR),
165
+ },
166
+ },
167
+ ]
168
+
169
+ const TEMPLATE_INDEX: Map<string, MissionTemplate> = new Map(
170
+ BUILT_IN_MISSION_TEMPLATES.map((template) => [template.id, template]),
171
+ )
172
+
173
+ export function listMissionTemplates(): MissionTemplate[] {
174
+ return BUILT_IN_MISSION_TEMPLATES.slice()
175
+ }
176
+
177
+ export function getMissionTemplate(id: string | null | undefined): MissionTemplate | null {
178
+ if (!id || typeof id !== 'string') return null
179
+ return TEMPLATE_INDEX.get(id.trim()) ?? null
180
+ }
181
+
182
+ export function listMissionTemplateCategories(): MissionTemplateCategory[] {
183
+ const seen = new Set<MissionTemplateCategory>()
184
+ for (const template of BUILT_IN_MISSION_TEMPLATES) seen.add(template.category)
185
+ return Array.from(seen)
186
+ }
@@ -458,6 +458,12 @@ function normalizeStoredAgentMissionRecord(value: unknown): unknown {
458
458
  if (mission.endedAt === undefined) mission.endedAt = null
459
459
  if (mission.endReason === undefined) mission.endReason = null
460
460
 
461
+ if (typeof mission.templateId === 'string' && mission.templateId.trim()) {
462
+ mission.templateId = mission.templateId.trim().slice(0, 64)
463
+ } else {
464
+ mission.templateId = null
465
+ }
466
+
461
467
  return mission
462
468
  }
463
469
 
@@ -109,7 +109,34 @@ export interface Mission {
109
109
  startedAt?: number | null
110
110
  endedAt?: number | null
111
111
  endReason?: string | null
112
+ templateId?: string | null
112
113
  }
113
114
 
114
115
  export const DEFAULT_MISSION_WARN_FRACTIONS = [0.5, 0.8, 0.95]
115
116
  export const MISSION_MILESTONE_TAIL_CAP = 200
117
+
118
+ export type MissionTemplateCategory =
119
+ | 'research'
120
+ | 'communication'
121
+ | 'monitoring'
122
+ | 'productivity'
123
+ | 'support'
124
+
125
+ export interface MissionTemplateDefaults {
126
+ title: string
127
+ goal: string
128
+ successCriteria: string[]
129
+ budget: MissionBudget
130
+ reportSchedule: MissionReportSchedule | null
131
+ }
132
+
133
+ export interface MissionTemplate {
134
+ id: string
135
+ name: string
136
+ description: string
137
+ icon: string
138
+ category: MissionTemplateCategory
139
+ tags: string[]
140
+ setupNote?: string | null
141
+ defaults: MissionTemplateDefaults
142
+ }