@swarmclawai/swarmclaw 1.5.48 → 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.
package/README.md CHANGED
@@ -396,6 +396,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
396
396
 
397
397
  ## Releases
398
398
 
399
+ ### v1.5.49 Highlights
400
+
401
+ - **Autonomous Missions**: a new first-class concept for long-running, goal-driven agent work. Hand your agent team a goal on Friday, come back Monday to see what they shipped. Each mission carries a title, a natural-language objective, bulleted success criteria, hard budgets (USD, tokens, turns, wallclock), periodic markdown reports, and a full milestone timeline. Missions drive any session through the existing heartbeat pipeline, so delegation to Claude Code, Codex, OpenCode, Cursor, Droid, Goose, Qwen, or native SwarmClaw agents all work without changes.
402
+ - **Budget enforcement in the run pipeline**: `enqueueSessionRun` now consults the mission's budget before every autonomous turn. When any cap is hit the mission transitions to `budget_exhausted`, the queue drains, and a final report fires. Warn thresholds (default 50% / 80% / 95% of each cap) emit `budget_warn` milestones exactly once each.
403
+ - **Scheduler tick from heartbeat**: `runMissionScheduler()` fires every heartbeat tick, independent of the active-hours window, so wallclock budgets and periodic reports still fire overnight. Report cadence is configurable per mission; reports land as in-app notifications today and ship as Slack/Discord/audio in a follow-up.
404
+ - **`/missions` dashboard**: new page with a live mission list, status pills, four-axis budget gauges, a scrollable milestone timeline, a reports drawer, and start / pause / cancel / mark-complete / generate-report-now controls.
405
+ - **CLI commands**: `swarmclaw missions list|get|create|update|delete|control|reports|report-now|events`. Create a mission, start it, and watch the timeline from the terminal or CI.
406
+ - **New storage collections**: `agent_missions`, `mission_reports`, and `agent_mission_events`. The legacy deprecated `missions` table is left untouched so nothing in existing installs is disturbed.
407
+
399
408
  ### v1.5.48 Highlights
400
409
 
401
410
  - **SwarmDock MCP preset now points at the hosted endpoint**: *MCP Servers → Quick Setup → SwarmDock* is pre-filled with `streamable-http` transport pointed at `https://swarmdock-api.onrender.com/mcp` and a ready-to-edit `Authorization: Bearer <key>` header template. Users no longer need to run `npx swarmdock-mcp` locally — the SwarmDock team hosts the MCP server in-process on the existing API service. First-time setup (browser keygen + agent registration) lives at [swarmdock.ai/mcp/connect](https://www.swarmdock.ai/mcp/connect).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.48",
3
+ "version": "1.5.49",
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,57 @@
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 { getMission } from '@/lib/server/missions/mission-repository'
7
+ import {
8
+ cancelMission,
9
+ completeMission,
10
+ failMission,
11
+ pauseMission,
12
+ startMission,
13
+ } from '@/lib/server/missions/mission-service'
14
+
15
+ export const dynamic = 'force-dynamic'
16
+
17
+ const ControlSchema = z.object({
18
+ action: z.enum(['start', 'pause', 'resume', 'cancel', 'complete', 'fail']),
19
+ reason: z.string().max(1000).optional(),
20
+ }).strict()
21
+
22
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
23
+ const { id } = await params
24
+ const mission = getMission(id)
25
+ if (!mission) return notFound()
26
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
27
+ if (error) return error
28
+ const parsed = ControlSchema.safeParse(body)
29
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
30
+
31
+ switch (parsed.data.action) {
32
+ case 'start':
33
+ case 'resume': {
34
+ const updated = startMission(id)
35
+ return NextResponse.json(updated)
36
+ }
37
+ case 'pause': {
38
+ const updated = pauseMission(id, parsed.data.reason)
39
+ return NextResponse.json(updated)
40
+ }
41
+ case 'cancel': {
42
+ const updated = cancelMission(id, parsed.data.reason)
43
+ return NextResponse.json(updated)
44
+ }
45
+ case 'complete': {
46
+ const updated = completeMission(id, parsed.data.reason)
47
+ return NextResponse.json(updated)
48
+ }
49
+ case 'fail': {
50
+ if (!parsed.data.reason) {
51
+ return NextResponse.json({ error: 'reason is required for fail action' }, { status: 400 })
52
+ }
53
+ const updated = failMission(id, parsed.data.reason)
54
+ return NextResponse.json(updated)
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notFound } from '@/lib/server/collection-helpers'
3
+ import { getMission, listMissionEvents } from '@/lib/server/missions/mission-repository'
4
+
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
8
+ const { id } = await params
9
+ const mission = getMission(id)
10
+ if (!mission) return notFound()
11
+ const url = new URL(req.url)
12
+ const sinceRaw = url.searchParams.get('sinceAt')
13
+ const untilRaw = url.searchParams.get('untilAt')
14
+ const sinceAt = sinceRaw ? Number.parseInt(sinceRaw, 10) : undefined
15
+ const untilAt = untilRaw ? Number.parseInt(untilRaw, 10) : undefined
16
+ const events = listMissionEvents(id, {
17
+ sinceAt: Number.isFinite(sinceAt) ? sinceAt : undefined,
18
+ untilAt: Number.isFinite(untilAt) ? untilAt : undefined,
19
+ })
20
+ return NextResponse.json(events)
21
+ }
@@ -0,0 +1,33 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notFound } from '@/lib/server/collection-helpers'
3
+ import {
4
+ getMission,
5
+ listMissionReports,
6
+ saveMissionReport,
7
+ } from '@/lib/server/missions/mission-repository'
8
+ import { buildMissionReport } from '@/lib/server/missions/mission-report-builder'
9
+
10
+ export const dynamic = 'force-dynamic'
11
+
12
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
13
+ const { id } = await params
14
+ const mission = getMission(id)
15
+ if (!mission) return notFound()
16
+ const url = new URL(req.url)
17
+ const limitRaw = url.searchParams.get('limit')
18
+ const limit = limitRaw ? Math.min(100, Math.max(1, Number.parseInt(limitRaw, 10) || 20)) : 20
19
+ return NextResponse.json(listMissionReports(id, limit))
20
+ }
21
+
22
+ export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
23
+ const { id } = await params
24
+ const mission = getMission(id)
25
+ if (!mission) return notFound()
26
+ const from = mission.reportSchedule?.lastReportAt
27
+ ?? mission.usage.startedAt
28
+ ?? mission.createdAt
29
+ const to = Date.now()
30
+ const { report } = buildMissionReport(mission, { from, to }, { windowSource: 'on_demand' })
31
+ saveMissionReport(report)
32
+ return NextResponse.json(report)
33
+ }
@@ -0,0 +1,82 @@
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 {
7
+ getMission,
8
+ patchMission,
9
+ removeMission,
10
+ } from '@/lib/server/missions/mission-repository'
11
+ import { patchSession } from '@/lib/server/sessions/session-repository'
12
+
13
+ export const dynamic = 'force-dynamic'
14
+
15
+ const MissionUpdateSchema = z.object({
16
+ title: z.string().min(1).max(200).optional(),
17
+ goal: z.string().min(1).max(4000).optional(),
18
+ successCriteria: z.array(z.string().min(1)).max(32).optional(),
19
+ agentIds: z.array(z.string().min(1)).max(32).optional(),
20
+ budget: z.object({
21
+ maxUsd: z.number().positive().nullable().optional(),
22
+ maxTokens: z.number().positive().int().nullable().optional(),
23
+ maxToolCalls: z.number().positive().int().nullable().optional(),
24
+ maxWallclockSec: z.number().positive().int().nullable().optional(),
25
+ maxTurns: z.number().positive().int().nullable().optional(),
26
+ warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
27
+ }).partial().optional(),
28
+ reportSchedule: z.object({
29
+ intervalSec: z.number().int().min(30),
30
+ format: z.enum(['markdown', 'slack', 'discord', 'email', 'audio']),
31
+ enabled: z.boolean().default(true),
32
+ lastReportAt: z.number().nullable().optional(),
33
+ }).strict().nullable().optional(),
34
+ reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
35
+ }).strict()
36
+
37
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
38
+ const { id } = await params
39
+ const mission = getMission(id)
40
+ if (!mission) return notFound()
41
+ return NextResponse.json(mission)
42
+ }
43
+
44
+ export async function PUT(req: Request, { params }: { params: Promise<{ id: string }> }) {
45
+ const { id } = await params
46
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
47
+ if (error) return error
48
+ const parsed = MissionUpdateSchema.safeParse(body)
49
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
50
+ const updated = patchMission(id, (current) => {
51
+ if (!current) return null
52
+ return {
53
+ ...current,
54
+ ...(parsed.data.title != null ? { title: parsed.data.title } : {}),
55
+ ...(parsed.data.goal != null ? { goal: parsed.data.goal } : {}),
56
+ ...(parsed.data.successCriteria != null ? { successCriteria: parsed.data.successCriteria } : {}),
57
+ ...(parsed.data.agentIds != null ? { agentIds: parsed.data.agentIds } : {}),
58
+ ...(parsed.data.budget != null ? { budget: { ...current.budget, ...parsed.data.budget } } : {}),
59
+ ...(parsed.data.reportSchedule !== undefined ? { reportSchedule: parsed.data.reportSchedule } : {}),
60
+ ...(parsed.data.reportConnectorIds != null ? { reportConnectorIds: parsed.data.reportConnectorIds } : {}),
61
+ }
62
+ })
63
+ if (!updated) return notFound()
64
+ return NextResponse.json(updated)
65
+ }
66
+
67
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
68
+ const { id } = await params
69
+ const mission = getMission(id)
70
+ if (!mission) return notFound()
71
+ removeMission(id)
72
+ try {
73
+ patchSession(mission.rootSessionId, (current) => {
74
+ if (!current) return null
75
+ if (current.missionId !== id) return current
76
+ return { ...current, missionId: null }
77
+ })
78
+ } catch {
79
+ // Session may already be gone.
80
+ }
81
+ return new NextResponse('OK')
82
+ }
@@ -0,0 +1,170 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { afterEach } from 'node:test'
3
+
4
+ // Disable daemon autostart during tests
5
+ process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
6
+
7
+ import { GET as listMissionsRoute, POST as createMissionRoute } from './route'
8
+ import { GET as getMissionRoute, PUT as updateMissionRoute, DELETE as deleteMissionRoute } from './[id]/route'
9
+ import { POST as controlMissionRoute } from './[id]/control/route'
10
+ import { GET as listReportsRoute, POST as forceReportRoute } from './[id]/reports/route'
11
+ import {
12
+ loadAgentMissions,
13
+ saveAgentMissions,
14
+ loadMissionReports,
15
+ saveMissionReports,
16
+ loadAgentMissionEvents,
17
+ saveAgentMissionEvents,
18
+ } from '@/lib/server/storage'
19
+
20
+ const originalMissions = loadAgentMissions()
21
+ const originalReports = loadMissionReports()
22
+ const originalEvents = loadAgentMissionEvents()
23
+
24
+ afterEach(() => {
25
+ saveAgentMissions(originalMissions)
26
+ saveMissionReports(originalReports)
27
+ saveAgentMissionEvents(originalEvents)
28
+ })
29
+
30
+ function routeParams(id: string) {
31
+ return { params: Promise.resolve({ id }) }
32
+ }
33
+
34
+ function jsonRequest(url: string, body: unknown, method = 'POST'): Request {
35
+ return new Request(url, {
36
+ method,
37
+ headers: { 'content-type': 'application/json' },
38
+ body: JSON.stringify(body),
39
+ })
40
+ }
41
+
42
+ test('POST /api/missions creates a mission and GET lists it', async () => {
43
+ const req = jsonRequest('http://local/api/missions', {
44
+ title: 'Route smoke',
45
+ goal: 'Demonstrate the API',
46
+ rootSessionId: 'route_smoke_session_1',
47
+ budget: { maxTurns: 5, maxWallclockSec: 120 },
48
+ })
49
+ const createRes = await createMissionRoute(req)
50
+ assert.equal(createRes.status, 200)
51
+ const created = await createRes.json()
52
+ assert.equal(created.status, 'draft')
53
+ assert.ok(created.id)
54
+
55
+ const listRes = await listMissionsRoute()
56
+ const items = await listRes.json()
57
+ const found = items.find((m: { id: string }) => m.id === created.id)
58
+ assert.ok(found, 'created mission should appear in list')
59
+ })
60
+
61
+ test('POST /api/missions rejects a body missing rootSessionId', async () => {
62
+ const req = jsonRequest('http://local/api/missions', {
63
+ title: 'Broken',
64
+ goal: 'No session',
65
+ })
66
+ const res = await createMissionRoute(req)
67
+ assert.equal(res.status, 400)
68
+ })
69
+
70
+ test('GET /api/missions/:id returns 404 for unknown id', async () => {
71
+ const res = await getMissionRoute(
72
+ new Request('http://local/api/missions/does-not-exist'),
73
+ routeParams('does-not-exist'),
74
+ )
75
+ assert.equal(res.status, 404)
76
+ })
77
+
78
+ test('PUT /api/missions/:id patches allowed fields only', async () => {
79
+ const created = await createMissionRoute(
80
+ jsonRequest('http://local/api/missions', {
81
+ title: 'Update target',
82
+ goal: 'before',
83
+ rootSessionId: 'route_update_session',
84
+ }),
85
+ ).then((r) => r.json())
86
+
87
+ const res = await updateMissionRoute(
88
+ jsonRequest(`http://local/api/missions/${created.id}`, {
89
+ goal: 'after',
90
+ budget: { maxTurns: 99 },
91
+ }, 'PUT'),
92
+ routeParams(created.id),
93
+ )
94
+ assert.equal(res.status, 200)
95
+ const body = await res.json()
96
+ assert.equal(body.goal, 'after')
97
+ assert.equal(body.budget.maxTurns, 99)
98
+ assert.equal(body.title, 'Update target', 'unchanged fields preserved')
99
+ })
100
+
101
+ test('POST /api/missions/:id/control transitions draft to running', async () => {
102
+ const created = await createMissionRoute(
103
+ jsonRequest('http://local/api/missions', {
104
+ title: 'Control target',
105
+ goal: 'run',
106
+ rootSessionId: 'route_control_session',
107
+ }),
108
+ ).then((r) => r.json())
109
+
110
+ const res = await controlMissionRoute(
111
+ jsonRequest(`http://local/api/missions/${created.id}/control`, { action: 'start' }),
112
+ routeParams(created.id),
113
+ )
114
+ assert.equal(res.status, 200)
115
+ const body = await res.json()
116
+ assert.equal(body.status, 'running')
117
+ })
118
+
119
+ test('POST /api/missions/:id/reports force-generates a report', async () => {
120
+ const created = await createMissionRoute(
121
+ jsonRequest('http://local/api/missions', {
122
+ title: 'Report target',
123
+ goal: 'produce reports',
124
+ rootSessionId: 'route_report_session',
125
+ }),
126
+ ).then((r) => r.json())
127
+ await controlMissionRoute(
128
+ jsonRequest(`http://local/api/missions/${created.id}/control`, { action: 'start' }),
129
+ routeParams(created.id),
130
+ )
131
+
132
+ const genRes = await forceReportRoute(
133
+ new Request(`http://local/api/missions/${created.id}/reports`, { method: 'POST' }),
134
+ routeParams(created.id),
135
+ )
136
+ assert.equal(genRes.status, 200)
137
+ const report = await genRes.json()
138
+ assert.equal(report.format, 'markdown')
139
+ assert.ok(report.body.includes('Report target'))
140
+
141
+ const listRes = await listReportsRoute(
142
+ new Request(`http://local/api/missions/${created.id}/reports`),
143
+ routeParams(created.id),
144
+ )
145
+ assert.equal(listRes.status, 200)
146
+ const reports = await listRes.json()
147
+ assert.ok(reports.find((r: { id: string }) => r.id === report.id))
148
+ })
149
+
150
+ test('DELETE /api/missions/:id removes the mission', async () => {
151
+ const created = await createMissionRoute(
152
+ jsonRequest('http://local/api/missions', {
153
+ title: 'Delete target',
154
+ goal: 'bye',
155
+ rootSessionId: 'route_delete_session',
156
+ }),
157
+ ).then((r) => r.json())
158
+
159
+ const res = await deleteMissionRoute(
160
+ new Request(`http://local/api/missions/${created.id}`, { method: 'DELETE' }),
161
+ routeParams(created.id),
162
+ )
163
+ assert.equal(res.status, 200)
164
+
165
+ const getRes = await getMissionRoute(
166
+ new Request(`http://local/api/missions/${created.id}`),
167
+ routeParams(created.id),
168
+ )
169
+ assert.equal(getRes.status, 404)
170
+ })
@@ -0,0 +1,58 @@
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 { listMissions } from '@/lib/server/missions/mission-repository'
6
+ import { createMission } 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 MissionBudgetSchema = 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
+ }).strict()
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 MissionCreateSchema = z.object({
28
+ title: z.string().min(1, 'Title is required').max(200),
29
+ goal: z.string().min(1, 'Goal is required').max(4000),
30
+ successCriteria: z.array(z.string().min(1)).max(32).optional(),
31
+ rootSessionId: z.string().min(1, 'rootSessionId is required'),
32
+ agentIds: z.array(z.string().min(1)).max(32).optional(),
33
+ budget: MissionBudgetSchema.optional(),
34
+ reportSchedule: ReportScheduleSchema.nullable().optional(),
35
+ reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
36
+ })
37
+
38
+ export async function GET() {
39
+ return NextResponse.json(listMissions())
40
+ }
41
+
42
+ export async function POST(req: Request) {
43
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
44
+ if (error) return error
45
+ const parsed = MissionCreateSchema.safeParse(body)
46
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
47
+ const mission = createMission(parsed.data)
48
+ // Wire the session back-reference so enqueue.ts budget hook picks it up fast.
49
+ try {
50
+ patchSession(mission.rootSessionId, (current) => {
51
+ if (!current) return null
52
+ return { ...current, missionId: mission.id }
53
+ })
54
+ } catch {
55
+ // Session may not exist yet, budget hook falls back to the service map.
56
+ }
57
+ return NextResponse.json(mission)
58
+ }