@swarmclawai/swarmclaw 1.9.8 → 1.9.10

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,6 +399,24 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.9.10 Highlights
403
+
404
+ Task handoff release: operators can package task state, readiness, workspace context, dependencies, outputs, and resume handles into a shareable packet before continuing work.
405
+
406
+ - **Task handoff packets.** `GET /api/tasks/:id/handoff` returns a structured packet with owner, liveness, workspace, runtime links, dependencies, quality checks, outputs, run summary, and recommended actions.
407
+ - **Workspace snapshots.** `POST /api/tasks/:id/handoff` prepares a workspace when needed and writes `handoff.md` plus `handoff.json` beside the task context files.
408
+ - **Board-level triage.** `GET /api/tasks/handoffs` lists readiness packets with ready, needs-attention, and blocked counts so operators can scan handoff risk across the board.
409
+ - **CLI and UI access.** `swarmclaw tasks handoff`, `swarmclaw tasks handoff-save`, and `swarmclaw tasks handoffs` expose the workflow for scripts, while the task sheet can copy, open, or save packets.
410
+
411
+ ### v1.9.9 Highlights
412
+
413
+ Schedule revision timeline release: schedule edits, lifecycle changes, and run evidence now stay inspectable from UI, API, and CLI surfaces.
414
+
415
+ - **Schedule history ledger.** Schedules now carry a bounded revision history for create, update, archive, restore, skipped, failed, and run-started events.
416
+ - **History console.** The Schedule Console adds a searchable History tab with revision badges, actor labels, and before/after change summaries.
417
+ - **API and CLI access.** `GET /api/schedules/:id/history` and `swarmclaw schedules history <id>` expose the same timeline for scripts and operator audits.
418
+ - **Runtime evidence.** Manual runs and scheduler-fired runs append history entries, while storage normalization caps old entries and keeps legacy schedules compatible.
419
+
402
420
  ### v1.9.8 Highlights
403
421
 
404
422
  Bundled release-readiness release: a single operator report that combines eval gates, operations blockers, approvals, and runtime readiness.
@@ -703,6 +721,62 @@ Older releases: https://swarmclaw.ai/docs/release-notes
703
721
  - npm package: https://www.npmjs.com/package/@swarmclawai/swarmclaw
704
722
  - Historical release notes: https://swarmclaw.ai/docs/release-notes
705
723
 
724
+
725
+ ## FAQ
726
+
727
+ ### General
728
+
729
+ **What is SwarmClaw?**
730
+ SwarmClaw is an open-source, self-hosted AI agent runtime and multi-agent framework. It lets you run autonomous AI agents, agent swarms, and orchestrators with durable memory, MCP tools, skills, delegation, schedules, and support for 23+ LLM providers — serving as a practical alternative to Claude Code and LangChain for self-hosted workflows.
731
+
732
+ **How does SwarmClaw differ from LangChain or CrewAI?**
733
+ SwarmClaw is a self-hosted runtime rather than a code library. It provides a persistent dashboard, durable agent memory, real-time org chart visualization, and built-in multi-agent orchestration with delegation — all running on your own infrastructure. LangChain and CrewAI are code frameworks you embed in your applications; SwarmClaw is the platform your agents run on.
734
+
735
+ **Is SwarmClaw production-ready?**
736
+ Yes. SwarmClaw is used in production by teams running autonomous agent swarms. It includes security features like approval-gated actions, TLS support, and access key management.
737
+
738
+ ### Setup & Configuration
739
+
740
+ **How do I install SwarmClaw?**
741
+ Install via npm: `npm install -g @swarmclawai/swarmclaw`, then run `swarmclaw init` to create your configuration. Docker deployment is also available via the provided Docker Compose setup.
742
+
743
+ **Which LLM providers are supported?**
744
+ SwarmClaw supports 23+ providers including OpenAI, Anthropic (Claude), Google Gemini, OpenRouter, Ollama (local), DeepSeek, Groq, Together AI, and more. Configure your API keys in the `.env` file or via the dashboard.
745
+
746
+ **Can I use local models?**
747
+ Yes. SwarmClaw integrates with Ollama and other local LLM backends for fully offline agent operation.
748
+
749
+ ### Agent Development
750
+
751
+ **What is an agent swarm?**
752
+ An agent swarm is a group of AI agents working together under an orchestrator. Each agent has its own role, tools, and memory. The orchestrator delegates tasks, agents execute in parallel, and results are aggregated — mimicking a real organizational structure.
753
+
754
+ **What are MCP tools?**
755
+ MCP (Model Context Protocol) tools let agents interact with external systems — databases, APIs, file systems, browsers, and more. SwarmClaw provides a marketplace of pre-built MCP tools at SwarmDock.
756
+
757
+ **How does agent memory work?**
758
+ SwarmClaw provides durable agent memory that persists across sessions. Each agent maintains its own conversation history, learned skills, and context — enabling long-running autonomous workflows.
759
+
760
+ ### Deployment
761
+
762
+ **How do I deploy SwarmClaw?**
763
+ Options include: local npm installation, Docker Compose for containerized deployment, or cloud VPS with reverse proxy and TLS. See the [deployment docs](https://swarmclaw.ai/docs/deployment) for detailed guides.
764
+
765
+ **Can I run SwarmClaw in the cloud?**
766
+ Yes. SwarmClaw runs on any Linux server with Node.js 18+. Popular options include AWS EC2, DigitalOcean, Hetzner, and self-hosted on home servers.
767
+
768
+ ### Troubleshooting
769
+
770
+ **Agent is not responding. What should I check?**
771
+ Verify your LLM API key is valid, check the agent's configuration in the dashboard, and review the agent chat logs for error messages. Common issues include rate limiting and invalid model names.
772
+
773
+ **How do I update SwarmClaw?**
774
+ Run `npm update -g @swarmclawai/swarmclaw` or pull the latest Docker image. Check the [release notes](https://swarmclaw.ai/docs/release-notes) for breaking changes.
775
+
776
+ **Where can I get help?**
777
+ - Documentation: https://swarmclaw.ai/docs
778
+ - Discord community: https://discord.gg/sbEavS8cPV
779
+ - GitHub Issues: https://github.com/swarmclawai/swarmclaw/issues
706
780
  ## Security Notes
707
781
 
708
782
  - First run creates an access key; keep it private.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.8",
3
+ "version": "1.9.10",
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",
@@ -87,7 +87,7 @@
87
87
  "test:cli": "node --test src/cli/*.test.js bin/*.test.js scripts/electron-after-pack.test.mjs scripts/ensure-sandbox-browser-image.test.mjs scripts/postinstall.test.mjs scripts/run-next-build.test.mjs scripts/run-next-typegen.test.mjs",
88
88
  "test:setup": "tsx --test src/app/api/setup/check-provider/route.test.ts src/lib/server/provider-model-discovery.test.ts src/components/auth/setup-wizard/utils.test.ts src/components/auth/setup-wizard/types.test.ts src/hooks/setup-done-detection.test.ts src/lib/setup-defaults.test.ts src/lib/server/storage-auth.test.ts src/lib/server/storage-auth-docker.test.ts",
89
89
  "test:openclaw": "tsx --test src/lib/openclaw/openclaw-agent-id.test.ts src/lib/openclaw/openclaw-endpoint.test.ts src/lib/server/agents/agent-runtime-config.test.ts src/lib/server/build-llm.test.ts src/lib/server/connectors/connector-routing.test.ts src/lib/server/connectors/openclaw.test.ts src/lib/server/connectors/swarmdock.test.ts src/lib/server/gateway/protocol.test.ts src/lib/server/gateways/gateway-topology.test.ts src/lib/server/llm-response-cache.test.ts src/lib/server/mcp-conformance.test.ts src/lib/server/openclaw/agent-resolver.test.ts src/lib/server/openclaw/deploy.test.ts src/lib/server/openclaw/skills-normalize.test.ts src/lib/server/session-tools/openclaw-nodes.test.ts src/lib/server/session-tools/swarmdock.test.ts src/lib/server/tasks/task-quality-gate.test.ts src/lib/server/tasks/task-validation.test.ts src/lib/server/tool-capability-policy.test.ts src/lib/providers/openai.test.ts src/lib/providers/openclaw-exports.test.ts src/app/api/gateways/topology-route.test.ts src/app/api/openclaw/dashboard-url/route.test.ts",
90
- "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/quality/release-readiness.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/tts/route.test.ts",
90
+ "test:runtime": "tsx --test src/lib/a2a/agent-card.test.ts src/lib/strip-internal-metadata.test.ts src/lib/provider-sets.test.ts src/lib/providers/opencode-cli.test.ts src/lib/providers/cli-provider-metadata.test.ts src/lib/providers/cli-utils.test.ts src/lib/providers/generic-cli.test.ts src/lib/server/agents/delegation-advisory.test.ts src/lib/server/cli-provider-readiness.test.ts src/lib/server/provider-health.test.ts src/lib/server/mcp-gateway-runtime.test.ts src/lib/server/mcp-connection-pool.test.ts src/lib/server/knowledge-sources.test.ts src/lib/server/extension-managed-resources.test.ts src/lib/server/eval/baseline.test.ts src/lib/server/eval/environment-plan.test.ts src/lib/server/chat-execution/chat-execution-grounding.test.ts src/lib/server/chat-execution/chat-turn-preparation.test.ts src/lib/server/chat-execution/iteration-timers.test.ts src/lib/server/chat-execution/post-stream-finalization.test.ts src/lib/server/chat-execution/reasoning-tag-scrubber.test.ts src/lib/server/chats/clear-undo-snapshots.test.ts src/lib/server/connectors/email.test.ts src/lib/server/protocols/protocol-service.test.ts src/lib/server/runtime/run-ledger.test.ts src/lib/server/runtime/queue-retry-policy.test.ts src/lib/server/runs/run-brief.test.ts src/lib/server/operations/operation-pulse.test.ts src/lib/server/schedules/schedule-history.test.ts src/lib/quality/release-readiness.test.ts src/lib/server/artifacts/artifact-resolver.test.ts src/lib/server/observability/otel-config.test.ts src/lib/server/safe-parse-body.test.ts src/lib/server/missions/mission-templates.test.ts src/lib/server/sharing/share-link-repository.test.ts src/lib/server/sharing/share-resolver.test.ts src/lib/server/tasks/task-execution-workspace.test.ts src/lib/server/tasks/task-handoff.test.ts src/lib/server/tasks/task-service.test.ts src/lib/server/session-tools/execute.test.ts src/lib/server/session-tools/manage-tasks.test.ts src/lib/app/view-constants.test.ts src/lib/quality/quality-summary.test.ts src/app/api/approvals/route.test.ts src/app/api/agents/agents-route.test.ts src/app/api/tasks/tasks-route.test.ts src/app/api/tasks/task-workspace-route.test.ts src/app/api/chats/chat-route.test.ts src/app/api/chats/clear-route.test.ts src/app/api/chats/compact-route.test.ts src/app/api/chats/context-status-route.test.ts src/app/api/connectors/connector-doctor-route.test.ts src/app/api/extensions/managed-resources/route.test.ts src/app/api/healthz/route.test.ts src/app/api/logs/route.test.ts src/app/api/portability/export/route.test.ts src/app/api/portability/import/route.test.ts src/app/api/providers/[id]/route.test.ts src/app/api/schedules/schedule-history-route.test.ts src/app/api/tts/route.test.ts",
91
91
  "test:builder": "tsx --test src/features/protocols/builder/utils/nodes-to-template.test.ts src/features/protocols/builder/utils/template-to-nodes.test.ts src/features/protocols/builder/validators/dag-validator.test.ts",
92
92
  "test:e2e": "node --import tsx scripts/browser-e2e-smoke.ts",
93
93
  "test:mcp:conformance": "node --import tsx ./scripts/mcp-conformance-check.ts",
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { notFound } from '@/lib/server/collection-helpers'
3
+ import { loadSchedule } from '@/lib/server/schedules/schedule-repository'
4
+ import { normalizeScheduleHistory } from '@/lib/server/schedules/schedule-history'
5
+
6
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
7
+ const { id } = await params
8
+ const schedule = loadSchedule(id)
9
+ if (!schedule) return notFound()
10
+ return NextResponse.json({
11
+ scheduleId: schedule.id,
12
+ revision: schedule.revision || 0,
13
+ history: normalizeScheduleHistory(schedule.history),
14
+ })
15
+ }
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import test, { afterEach } from 'node:test'
3
3
 
4
4
  import { DELETE as deleteScheduleRoute, PUT as updateSchedule } from './route'
5
+ import { GET as getScheduleHistory } from './history/route'
5
6
  import { loadAgents, loadSchedules, saveAgents, saveSchedules } from '@/lib/server/storage'
6
7
 
7
8
  const originalAgents = loadAgents()
@@ -84,7 +85,7 @@ test('PUT /api/schedules/[id] pauses equivalent reminder schedules together', as
84
85
  assert.equal(schedules.two.status, 'paused')
85
86
  })
86
87
 
87
- test('DELETE /api/schedules/[id] deletes equivalent reminder schedules together', async () => {
88
+ test('DELETE /api/schedules/[id] archives equivalent reminder schedules together', async () => {
88
89
  seedAgent('schedule-route-agent-delete')
89
90
  const now = Date.now()
90
91
  saveSchedules({
@@ -123,6 +124,54 @@ test('DELETE /api/schedules/[id] deletes equivalent reminder schedules together'
123
124
 
124
125
  assert.equal(response.status, 200)
125
126
  const payload = await response.json() as Record<string, unknown>
126
- assert.deepEqual(new Set(payload.deletedIds as string[]), new Set(['one', 'two']))
127
- assert.deepEqual(loadSchedules(), {})
127
+ assert.deepEqual(new Set(payload.archivedIds as string[]), new Set(['one', 'two']))
128
+ const schedules = loadSchedules()
129
+ assert.equal(schedules.one.status, 'archived')
130
+ assert.equal(schedules.two.status, 'archived')
131
+ assert.equal(schedules.one.history?.[0]?.action, 'archived')
132
+ })
133
+
134
+ test('GET /api/schedules/[id]/history returns revision history', async () => {
135
+ const now = Date.now()
136
+ saveSchedules({
137
+ one: {
138
+ id: 'one',
139
+ name: 'History Schedule',
140
+ agentId: 'schedule-route-agent-history',
141
+ taskPrompt: 'Report changes',
142
+ scheduleType: 'interval',
143
+ intervalMs: 86_400_000,
144
+ status: 'active',
145
+ revision: 2,
146
+ history: [{
147
+ id: 'history-2',
148
+ at: now,
149
+ actor: 'user',
150
+ action: 'updated',
151
+ revision: 2,
152
+ summary: 'Schedule updated: "History Schedule"',
153
+ changes: [{
154
+ field: 'status',
155
+ label: 'Status',
156
+ before: 'paused',
157
+ after: 'active',
158
+ }],
159
+ }],
160
+ createdAt: now,
161
+ updatedAt: now,
162
+ },
163
+ })
164
+
165
+ const response = await getScheduleHistory(
166
+ new Request('http://local/api/schedules/one/history', { method: 'GET' }),
167
+ routeParams('one'),
168
+ )
169
+
170
+ assert.equal(response.status, 200)
171
+ const payload = await response.json() as Record<string, unknown>
172
+ assert.equal(payload.scheduleId, 'one')
173
+ assert.equal(payload.revision, 2)
174
+ const history = payload.history as Array<Record<string, unknown>>
175
+ assert.equal(history.length, 1)
176
+ assert.equal(history[0].action, 'updated')
128
177
  })
@@ -0,0 +1,60 @@
1
+ import assert from 'node:assert/strict'
2
+ import test, { afterEach } from 'node:test'
3
+
4
+ import { GET as getScheduleHistory } from './[id]/history/route'
5
+ import { loadSchedules, saveSchedules } from '@/lib/server/storage'
6
+
7
+ const originalSchedules = loadSchedules()
8
+
9
+ function routeParams(id: string) {
10
+ return { params: Promise.resolve({ id }) }
11
+ }
12
+
13
+ afterEach(() => {
14
+ saveSchedules(originalSchedules)
15
+ })
16
+
17
+ test('GET /api/schedules/[id]/history returns normalized revision history', async () => {
18
+ const now = Date.now()
19
+ saveSchedules({
20
+ one: {
21
+ id: 'one',
22
+ name: 'History Schedule',
23
+ agentId: 'schedule-route-agent-history',
24
+ taskPrompt: 'Report changes',
25
+ scheduleType: 'interval',
26
+ intervalMs: 86_400_000,
27
+ status: 'active',
28
+ revision: 2,
29
+ history: [{
30
+ id: 'history-2',
31
+ at: now,
32
+ actor: 'user',
33
+ action: 'updated',
34
+ revision: 2,
35
+ summary: 'Schedule updated: "History Schedule"',
36
+ changes: [{
37
+ field: 'status',
38
+ label: 'Status',
39
+ before: 'paused',
40
+ after: 'active',
41
+ }],
42
+ }],
43
+ createdAt: now,
44
+ updatedAt: now,
45
+ },
46
+ })
47
+
48
+ const response = await getScheduleHistory(
49
+ new Request('http://local/api/schedules/one/history', { method: 'GET' }),
50
+ routeParams('one'),
51
+ )
52
+
53
+ assert.equal(response.status, 200)
54
+ const payload = await response.json() as Record<string, unknown>
55
+ assert.equal(payload.scheduleId, 'one')
56
+ assert.equal(payload.revision, 2)
57
+ const history = payload.history as Array<Record<string, unknown>>
58
+ assert.equal(history.length, 1)
59
+ assert.equal(history[0].action, 'updated')
60
+ })
@@ -0,0 +1,73 @@
1
+ import { NextResponse } from 'next/server'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { buildTaskHandoffPacket, formatTaskHandoffMarkdown } from '@/lib/server/tasks/task-handoff'
5
+ import { prepareTaskExecutionWorkspace } from '@/lib/server/tasks/task-execution-workspace'
6
+ import { loadTasks, saveTask } from '@/lib/server/tasks/task-repository'
7
+
8
+ export const dynamic = 'force-dynamic'
9
+
10
+ export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
11
+ const { id } = await params
12
+ const tasks = loadTasks()
13
+ const task = tasks[id]
14
+ if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 })
15
+
16
+ const packet = buildTaskHandoffPacket(task, tasks)
17
+ const { searchParams } = new URL(req.url)
18
+ if (searchParams.get('format') === 'markdown') {
19
+ return new Response(formatTaskHandoffMarkdown(packet), {
20
+ headers: {
21
+ 'content-type': 'text/markdown; charset=utf-8',
22
+ },
23
+ })
24
+ }
25
+
26
+ return NextResponse.json(packet)
27
+ }
28
+
29
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
30
+ const { id } = await params
31
+ const tasks = loadTasks()
32
+ const task = tasks[id]
33
+ if (!task) return NextResponse.json({ error: 'Task not found' }, { status: 404 })
34
+
35
+ let body: Record<string, unknown> = {}
36
+ try {
37
+ body = await req.json() as Record<string, unknown>
38
+ } catch {
39
+ body = {}
40
+ }
41
+
42
+ if (!task.executionWorkspace || body.prepareWorkspace !== false) {
43
+ Object.assign(task, prepareTaskExecutionWorkspace(task, {
44
+ now: Date.now(),
45
+ actor: 'user',
46
+ tasks,
47
+ }))
48
+ task.updatedAt = Date.now()
49
+ tasks[id] = task
50
+ saveTask(id, task)
51
+ }
52
+
53
+ const workspacePath = task.executionWorkspace?.path
54
+ if (!workspacePath) {
55
+ return NextResponse.json({ error: 'Task workspace is not available' }, { status: 409 })
56
+ }
57
+
58
+ fs.mkdirSync(workspacePath, { recursive: true })
59
+ const packet = buildTaskHandoffPacket(task, tasks)
60
+ const markdown = formatTaskHandoffMarkdown(packet)
61
+ const markdownPath = path.join(workspacePath, 'handoff.md')
62
+ const jsonPath = path.join(workspacePath, 'handoff.json')
63
+ fs.writeFileSync(markdownPath, markdown, 'utf8')
64
+ fs.writeFileSync(jsonPath, `${JSON.stringify(packet, null, 2)}\n`, 'utf8')
65
+
66
+ return NextResponse.json({
67
+ packet,
68
+ files: {
69
+ markdownPath,
70
+ jsonPath,
71
+ },
72
+ })
73
+ }
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { buildTaskHandoffPacket } from '@/lib/server/tasks/task-handoff'
3
+ import { loadTasks } from '@/lib/server/tasks/task-repository'
4
+ import type { TaskHandoffReadinessStatus } from '@/types'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ const READINESS_STATUSES: TaskHandoffReadinessStatus[] = ['ready', 'needs_attention', 'blocked']
9
+
10
+ function normalizeLimit(value: string | null): number {
11
+ const parsed = value ? Number.parseInt(value, 10) : 50
12
+ if (!Number.isFinite(parsed)) return 50
13
+ return Math.max(1, Math.min(200, Math.trunc(parsed)))
14
+ }
15
+
16
+ export async function GET(req: Request) {
17
+ const { searchParams } = new URL(req.url)
18
+ const status = searchParams.get('status') as TaskHandoffReadinessStatus | null
19
+ const includeArchived = searchParams.get('includeArchived') === 'true'
20
+ const limit = normalizeLimit(searchParams.get('limit'))
21
+ const now = Date.now()
22
+ const tasks = loadTasks()
23
+ const packets = Object.values(tasks)
24
+ .filter((task) => includeArchived || task.status !== 'archived')
25
+ .map((task) => buildTaskHandoffPacket(task, tasks, { now, runBrief: null }))
26
+ .sort((left, right) => {
27
+ const statusRank: Record<TaskHandoffReadinessStatus, number> = {
28
+ blocked: 0,
29
+ needs_attention: 1,
30
+ ready: 2,
31
+ }
32
+ return statusRank[left.readiness.status] - statusRank[right.readiness.status] || right.updatedAt - left.updatedAt
33
+ })
34
+
35
+ const filtered = status && READINESS_STATUSES.includes(status)
36
+ ? packets.filter((packet) => packet.readiness.status === status)
37
+ : packets
38
+ const counts: Record<TaskHandoffReadinessStatus, number> = {
39
+ ready: 0,
40
+ needs_attention: 0,
41
+ blocked: 0,
42
+ }
43
+ for (const packet of packets) counts[packet.readiness.status] += 1
44
+
45
+ return NextResponse.json({
46
+ generatedAt: now,
47
+ counts,
48
+ items: filtered.slice(0, limit),
49
+ })
50
+ }
@@ -15,6 +15,9 @@ const originalEnv = {
15
15
 
16
16
  let tempDir = ''
17
17
  let putTask: typeof import('./[id]/route')['PUT']
18
+ let getTaskHandoff: typeof import('./[id]/handoff/route')['GET']
19
+ let postTaskHandoff: typeof import('./[id]/handoff/route')['POST']
20
+ let getTaskHandoffs: typeof import('./handoffs/route')['GET']
18
21
  let getTasks: typeof import('./route')['GET']
19
22
  let storage: typeof import('@/lib/server/storage')
20
23
 
@@ -46,6 +49,10 @@ before(async () => {
46
49
  process.env.SWARMCLAW_DAEMON_AUTOSTART = '0'
47
50
  storage = await import('@/lib/server/storage')
48
51
  putTask = (await import('./[id]/route')).PUT
52
+ const handoffRoute = await import('./[id]/handoff/route')
53
+ getTaskHandoff = handoffRoute.GET
54
+ postTaskHandoff = handoffRoute.POST
55
+ getTaskHandoffs = (await import('./handoffs/route')).GET
49
56
  getTasks = (await import('./route')).GET
50
57
  })
51
58
 
@@ -114,3 +121,94 @@ test('GET /api/tasks returns computed blocked liveness without persisting a task
114
121
  assert.equal(body['task-blocked']?.liveness?.state, 'blocked')
115
122
  assert.deepEqual(body['task-blocked']?.liveness?.blockerTaskIds, ['dep-route'])
116
123
  })
124
+
125
+ test('GET /api/tasks/:id/handoff returns readiness and markdown packets', async () => {
126
+ seedTask('task-handoff', {
127
+ title: 'Handoff Route Task',
128
+ description: 'Prepare a packet.',
129
+ blockedBy: ['dep-handoff'],
130
+ qualityGate: {
131
+ enabled: true,
132
+ minResultChars: 50,
133
+ minEvidenceItems: 1,
134
+ },
135
+ })
136
+ const tasks = storage.loadTasks()
137
+ tasks['dep-handoff'] = {
138
+ id: 'dep-handoff',
139
+ title: 'Dependency',
140
+ description: '',
141
+ status: 'running',
142
+ agentId: 'agent-1',
143
+ createdAt: Date.now(),
144
+ updatedAt: Date.now(),
145
+ } as BoardTask
146
+ storage.saveTasks(tasks)
147
+
148
+ const jsonResponse = await getTaskHandoff(
149
+ new Request('http://local/api/tasks/task-handoff/handoff'),
150
+ routeParams('task-handoff'),
151
+ )
152
+ assert.equal(jsonResponse.status, 200)
153
+ const packet = await jsonResponse.json()
154
+ assert.equal(packet.taskId, 'task-handoff')
155
+ assert.equal(packet.readiness.status, 'blocked')
156
+ assert.equal(packet.dependencies.blockedBy[0]?.id, 'dep-handoff')
157
+
158
+ const markdownResponse = await getTaskHandoff(
159
+ new Request('http://local/api/tasks/task-handoff/handoff?format=markdown'),
160
+ routeParams('task-handoff'),
161
+ )
162
+ assert.equal(markdownResponse.status, 200)
163
+ assert.match(markdownResponse.headers.get('content-type') || '', /text\/markdown/)
164
+ const markdown = await markdownResponse.text()
165
+ assert.match(markdown, /# Task Handoff: Handoff Route Task/)
166
+ assert.match(markdown, /Readiness: blocked/)
167
+ })
168
+
169
+ test('POST /api/tasks/:id/handoff saves markdown and JSON snapshots into the workspace', async () => {
170
+ seedTask('task-handoff-save', {
171
+ title: 'Saved Handoff Task',
172
+ cwd: '/source/repo',
173
+ result: 'Ready for the next operator.',
174
+ })
175
+
176
+ const response = await postTaskHandoff(
177
+ new Request('http://local/api/tasks/task-handoff-save/handoff', {
178
+ method: 'POST',
179
+ headers: { 'content-type': 'application/json' },
180
+ body: JSON.stringify({ prepareWorkspace: true }),
181
+ }),
182
+ routeParams('task-handoff-save'),
183
+ )
184
+
185
+ assert.equal(response.status, 200)
186
+ const body = await response.json()
187
+ assert.equal(body.packet.taskId, 'task-handoff-save')
188
+ assert.equal(fs.existsSync(body.files.markdownPath), true)
189
+ assert.equal(fs.existsSync(body.files.jsonPath), true)
190
+ assert.match(fs.readFileSync(body.files.markdownPath, 'utf8'), /# Task Handoff: Saved Handoff Task/)
191
+ })
192
+
193
+ test('GET /api/tasks/handoffs lists board-level readiness packets with counts', async () => {
194
+ seedTask('task-ready', {
195
+ title: 'Ready Task',
196
+ executionWorkspace: {
197
+ path: '/tmp/ready',
198
+ mode: 'task',
199
+ preparedAt: Date.now(),
200
+ previewLinks: [],
201
+ runtimeServices: [],
202
+ },
203
+ })
204
+ seedTask('task-needs-attention', {
205
+ title: 'Needs Workspace',
206
+ })
207
+
208
+ const response = await getTaskHandoffs(new Request('http://local/api/tasks/handoffs?status=needs_attention&limit=10'))
209
+ assert.equal(response.status, 200)
210
+ const body = await response.json()
211
+ assert.equal(body.counts.ready >= 1, true)
212
+ assert.equal(body.counts.needs_attention >= 1, true)
213
+ assert.equal(body.items.every((packet: { readiness: { status: string } }) => packet.readiness.status === 'needs_attention'), true)
214
+ })
package/src/cli/index.js CHANGED
@@ -579,6 +579,7 @@ const COMMAND_GROUPS = [
579
579
  commands: [
580
580
  cmd('list', 'GET', '/schedules', 'List schedules'),
581
581
  cmd('get', 'GET', '/schedules/:id', 'Get schedule by id'),
582
+ cmd('history', 'GET', '/schedules/:id/history', 'Get schedule revision history'),
582
583
  cmd('create', 'POST', '/schedules', 'Create schedule', { expectsJsonBody: true }),
583
584
  cmd('update', 'PUT', '/schedules/:id', 'Update schedule', { expectsJsonBody: true }),
584
585
  cmd('delete', 'DELETE', '/schedules/:id', 'Delete schedule'),
@@ -734,6 +735,9 @@ const COMMAND_GROUPS = [
734
735
  commands: [
735
736
  cmd('list', 'GET', '/tasks', 'List tasks'),
736
737
  cmd('get', 'GET', '/tasks/:id', 'Get task'),
738
+ cmd('handoff', 'GET', '/tasks/:id/handoff', 'Get task handoff packet'),
739
+ cmd('handoff-save', 'POST', '/tasks/:id/handoff', 'Save task handoff packet into the task workspace', { expectsJsonBody: true }),
740
+ cmd('handoffs', 'GET', '/tasks/handoffs', 'List task handoff readiness packets'),
737
741
  cmd('create', 'POST', '/tasks', 'Create task', { expectsJsonBody: true }),
738
742
  cmd('bulk', 'POST', '/tasks/bulk', 'Bulk update tasks (status/agent/project)', { expectsJsonBody: true }),
739
743
  cmd('update', 'PUT', '/tasks/:id', 'Update task', { expectsJsonBody: true }),
@@ -163,6 +163,38 @@ test('runCli sends authenticated request and emits compact JSON when --json is s
163
163
  assert.equal(stderr.toString(), '')
164
164
  })
165
165
 
166
+ test('tasks handoff command can request markdown packets', async () => {
167
+ const stdout = makeWritable()
168
+ const stderr = makeWritable()
169
+ const calls = []
170
+
171
+ const fetchImpl = async (url, init) => {
172
+ calls.push({ url: String(url), init })
173
+ return new Response('# Task Handoff\n', {
174
+ status: 200,
175
+ headers: { 'content-type': 'text/markdown; charset=utf-8' },
176
+ })
177
+ }
178
+
179
+ const exitCode = await runCli(
180
+ ['tasks', 'handoff', 'task-1', '--query', 'format=markdown'],
181
+ {
182
+ fetchImpl,
183
+ stdout,
184
+ stderr,
185
+ env: {},
186
+ cwd: process.cwd(),
187
+ }
188
+ )
189
+
190
+ assert.equal(exitCode, 0)
191
+ assert.equal(calls.length, 1)
192
+ assert.match(calls[0].url, /\/api\/tasks\/task-1\/handoff\?format=markdown$/)
193
+ assert.equal(calls[0].init.method, 'GET')
194
+ assert.equal(stdout.toString(), '# Task Handoff\n')
195
+ assert.equal(stderr.toString(), '')
196
+ })
197
+
166
198
  test('openclaw deploy bundle command merges action with provided JSON body', async () => {
167
199
  const stdout = makeWritable()
168
200
  const stderr = makeWritable()
package/src/cli/index.ts CHANGED
@@ -608,6 +608,14 @@ export function buildProgram(): Command {
608
608
  await runWithHandler(this as Command, (ctx) => resolveByIdFromCollection(ctx, '/schedules', id))
609
609
  })
610
610
 
611
+ schedules
612
+ .command('history')
613
+ .description('Get schedule revision history')
614
+ .argument('<id>', 'Schedule id')
615
+ .action(async function (id: string) {
616
+ await runWithHandler(this as Command, (ctx) => apiRequest(ctx, 'GET', `/schedules/${encodeURIComponent(id)}/history`))
617
+ })
618
+
611
619
  schedules
612
620
  .command('create')
613
621
  .description('Create schedule')
package/src/cli/spec.js CHANGED
@@ -420,6 +420,7 @@ const COMMAND_GROUPS = {
420
420
  list: { description: 'List schedules', method: 'GET', path: '/schedules' },
421
421
  create: { description: 'Create schedule', method: 'POST', path: '/schedules' },
422
422
  get: { description: 'Get schedule by id (from list)', virtualGet: true, collectionPath: '/schedules', params: ['id'] },
423
+ history: { description: 'Get schedule revision history', method: 'GET', path: '/schedules/:id/history', params: ['id'] },
423
424
  update: { description: 'Update schedule', method: 'PUT', path: '/schedules/:id', params: ['id'] },
424
425
  delete: { description: 'Delete schedule', method: 'DELETE', path: '/schedules/:id', params: ['id'] },
425
426
  run: { description: 'Trigger schedule immediately', method: 'POST', path: '/schedules/:id/run', params: ['id'] },
@@ -528,6 +529,9 @@ const COMMAND_GROUPS = {
528
529
  commands: {
529
530
  list: { description: 'List tasks', method: 'GET', path: '/tasks' },
530
531
  get: { description: 'Get task by id', method: 'GET', path: '/tasks/:id', params: ['id'] },
532
+ handoff: { description: 'Get task handoff packet', method: 'GET', path: '/tasks/:id/handoff', params: ['id'] },
533
+ 'handoff-save': { description: 'Save task handoff packet into the task workspace', method: 'POST', path: '/tasks/:id/handoff', params: ['id'] },
534
+ handoffs: { description: 'List task handoff readiness packets', method: 'GET', path: '/tasks/handoffs' },
531
535
  create: { description: 'Create task', method: 'POST', path: '/tasks' },
532
536
  bulk: { description: 'Bulk update tasks (status/agent/project)', method: 'POST', path: '/tasks/bulk' },
533
537
  update: { description: 'Update task', method: 'PUT', path: '/tasks/:id', params: ['id'] },