@swarmclawai/swarmclaw 1.9.8 → 1.9.9

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,15 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.9.9 Highlights
403
+
404
+ Schedule revision timeline release: schedule edits, lifecycle changes, and run evidence now stay inspectable from UI, API, and CLI surfaces.
405
+
406
+ - **Schedule history ledger.** Schedules now carry a bounded revision history for create, update, archive, restore, skipped, failed, and run-started events.
407
+ - **History console.** The Schedule Console adds a searchable History tab with revision badges, actor labels, and before/after change summaries.
408
+ - **API and CLI access.** `GET /api/schedules/:id/history` and `swarmclaw schedules history <id>` expose the same timeline for scripts and operator audits.
409
+ - **Runtime evidence.** Manual runs and scheduler-fired runs append history entries, while storage normalization caps old entries and keeps legacy schedules compatible.
410
+
402
411
  ### v1.9.8 Highlights
403
412
 
404
413
  Bundled release-readiness release: a single operator report that combines eval gates, operations blockers, approvals, and runtime readiness.
@@ -703,6 +712,62 @@ Older releases: https://swarmclaw.ai/docs/release-notes
703
712
  - npm package: https://www.npmjs.com/package/@swarmclawai/swarmclaw
704
713
  - Historical release notes: https://swarmclaw.ai/docs/release-notes
705
714
 
715
+
716
+ ## FAQ
717
+
718
+ ### General
719
+
720
+ **What is SwarmClaw?**
721
+ 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.
722
+
723
+ **How does SwarmClaw differ from LangChain or CrewAI?**
724
+ 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.
725
+
726
+ **Is SwarmClaw production-ready?**
727
+ 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.
728
+
729
+ ### Setup & Configuration
730
+
731
+ **How do I install SwarmClaw?**
732
+ 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.
733
+
734
+ **Which LLM providers are supported?**
735
+ 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.
736
+
737
+ **Can I use local models?**
738
+ Yes. SwarmClaw integrates with Ollama and other local LLM backends for fully offline agent operation.
739
+
740
+ ### Agent Development
741
+
742
+ **What is an agent swarm?**
743
+ 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.
744
+
745
+ **What are MCP tools?**
746
+ 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.
747
+
748
+ **How does agent memory work?**
749
+ 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.
750
+
751
+ ### Deployment
752
+
753
+ **How do I deploy SwarmClaw?**
754
+ 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.
755
+
756
+ **Can I run SwarmClaw in the cloud?**
757
+ Yes. SwarmClaw runs on any Linux server with Node.js 18+. Popular options include AWS EC2, DigitalOcean, Hetzner, and self-hosted on home servers.
758
+
759
+ ### Troubleshooting
760
+
761
+ **Agent is not responding. What should I check?**
762
+ 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.
763
+
764
+ **How do I update SwarmClaw?**
765
+ 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.
766
+
767
+ **Where can I get help?**
768
+ - Documentation: https://swarmclaw.ai/docs
769
+ - Discord community: https://discord.gg/sbEavS8cPV
770
+ - GitHub Issues: https://github.com/swarmclawai/swarmclaw/issues
706
771
  ## Security Notes
707
772
 
708
773
  - 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.9",
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-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
+ })
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'),
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'] },
@@ -14,10 +14,10 @@ import { api } from '@/lib/app/api-client'
14
14
  import { archiveSchedule, purgeSchedule, restoreSchedule, runSchedule, updateSchedule } from '@/lib/schedules/schedules'
15
15
  import { cronToHuman } from '@/lib/schedules/cron-human'
16
16
  import { timeAgo, timeUntil } from '@/lib/time-format'
17
- import type { BoardTask, ProtocolRun, Schedule, ScheduleStatus } from '@/types'
17
+ import type { BoardTask, ProtocolRun, Schedule, ScheduleHistoryEntry, ScheduleStatus } from '@/types'
18
18
  import { toast } from 'sonner'
19
19
 
20
- type ScheduleScope = 'live' | 'archived' | 'runs'
20
+ type ScheduleScope = 'live' | 'archived' | 'runs' | 'history'
21
21
  type ScheduleFilterStatus = 'all' | ScheduleStatus
22
22
  type ScheduleRunStatusFilter = 'all' | Extract<BoardTask['status'], 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'>
23
23
  type ScheduleCadenceFilter = 'all' | Schedule['scheduleType']
@@ -35,6 +35,12 @@ type ScheduleConsoleRunRow = {
35
35
  scheduleId: string | null
36
36
  scheduleName: string
37
37
  }
38
+ type ScheduleHistoryRow = {
39
+ id: string
40
+ schedule: Schedule
41
+ entry: ScheduleHistoryEntry
42
+ searchText: string
43
+ }
38
44
 
39
45
  const STATUS_STYLES: Record<string, string> = {
40
46
  active: 'bg-emerald-500/12 text-emerald-400 border-emerald-500/20',
@@ -139,6 +145,38 @@ function protocolRunPreview(run: ProtocolRun): string {
139
145
  return (goal || run.title || 'Structured session run').slice(0, 180)
140
146
  }
141
147
 
148
+ function historyActionLabel(action: ScheduleHistoryEntry['action']): string {
149
+ switch (action) {
150
+ case 'created':
151
+ return 'Created'
152
+ case 'updated':
153
+ return 'Updated'
154
+ case 'archived':
155
+ return 'Archived'
156
+ case 'restored':
157
+ return 'Restored'
158
+ case 'run_started':
159
+ return 'Run started'
160
+ case 'skipped':
161
+ return 'Skipped'
162
+ case 'failed':
163
+ return 'Failed'
164
+ default:
165
+ return action
166
+ }
167
+ }
168
+
169
+ function historyActionBadge(action: ScheduleHistoryEntry['action']): string {
170
+ if (action === 'created' || action === 'restored' || action === 'run_started') return badgeClass('completed')
171
+ if (action === 'failed') return badgeClass('failed')
172
+ if (action === 'skipped' || action === 'archived') return badgeClass('paused')
173
+ return badgeClass('running')
174
+ }
175
+
176
+ function formatHistoryValue(value: string | null | undefined): string {
177
+ return value == null || value === '' ? 'empty' : value
178
+ }
179
+
142
180
  function ActionButton(
143
181
  props: ButtonHTMLAttributes<HTMLButtonElement> & { tone?: 'default' | 'danger' },
144
182
  ) {
@@ -337,6 +375,42 @@ export function ScheduleConsole() {
337
375
  .sort((a, b) => b.updatedAt - a.updatedAt)
338
376
  }, [agentFilter, agents, cadenceFilter, projectScopedProtocolRuns, projectScopedRuns, runStatusFilter, schedules, search])
339
377
 
378
+ const filteredHistory = useMemo<ScheduleHistoryRow[]>(() => {
379
+ const q = search.trim().toLowerCase()
380
+ return projectScopedSchedules
381
+ .filter((schedule) => {
382
+ if (statusFilter !== 'all' && schedule.status !== statusFilter) return false
383
+ if (cadenceFilter !== 'all' && schedule.scheduleType !== cadenceFilter) return false
384
+ if (agentFilter !== 'all' && schedule.agentId !== agentFilter) return false
385
+ return true
386
+ })
387
+ .flatMap((schedule) => {
388
+ const agentName = agents[schedule.agentId]?.name || ''
389
+ const history = Array.isArray(schedule.history) ? schedule.history : []
390
+ return history.map((entry) => {
391
+ const changeText = Array.isArray(entry.changes)
392
+ ? entry.changes.map((change) => [change.label, change.before, change.after].filter(Boolean).join(' ')).join(' ')
393
+ : ''
394
+ return {
395
+ id: `${schedule.id}:${entry.id}`,
396
+ schedule,
397
+ entry,
398
+ searchText: [
399
+ schedule.name,
400
+ schedule.taskPrompt,
401
+ agentName,
402
+ entry.summary,
403
+ entry.actor,
404
+ historyActionLabel(entry.action),
405
+ changeText,
406
+ ].filter(Boolean).join(' ').toLowerCase(),
407
+ }
408
+ })
409
+ })
410
+ .filter((row) => !q || row.searchText.includes(q))
411
+ .sort((a, b) => b.entry.at - a.entry.at || b.entry.revision - a.entry.revision)
412
+ }, [agentFilter, agents, cadenceFilter, projectScopedSchedules, search, statusFilter])
413
+
340
414
  const handleArchive = async (scheduleId: string) => {
341
415
  setBusyId(scheduleId)
342
416
  try {
@@ -432,7 +506,11 @@ export function ScheduleConsole() {
432
506
  setSortBy('nextRunAt')
433
507
  }
434
508
 
435
- const scopeCount = scope === 'runs' ? filteredRuns.length : filteredSchedules.length
509
+ const scopeCount = scope === 'runs'
510
+ ? filteredRuns.length
511
+ : scope === 'history'
512
+ ? filteredHistory.length
513
+ : filteredSchedules.length
436
514
 
437
515
  if (!loaded) {
438
516
  return <PageLoader label="Loading schedules..." />
@@ -464,6 +542,7 @@ export function ScheduleConsole() {
464
542
  <FilterPill label="Live" active={scope === 'live'} onClick={() => setScope('live')} />
465
543
  <FilterPill label="Archived" active={scope === 'archived'} onClick={() => setScope('archived')} />
466
544
  <FilterPill label="Runs" active={scope === 'runs'} onClick={() => setScope('runs')} />
545
+ <FilterPill label="History" active={scope === 'history'} onClick={() => setScope('history')} />
467
546
  </div>
468
547
  </div>
469
548
  <div className="w-full lg:max-w-[360px]">
@@ -472,7 +551,11 @@ export function ScheduleConsole() {
472
551
  value={search}
473
552
  onChange={(e) => setSearch(e.target.value)}
474
553
  onClear={() => setSearch('')}
475
- placeholder={scope === 'runs' ? 'Search runs, schedules, or agents...' : 'Search schedules, agents, or recipients...'}
554
+ placeholder={scope === 'runs'
555
+ ? 'Search runs, schedules, or agents...'
556
+ : scope === 'history'
557
+ ? 'Search history, schedules, or changes...'
558
+ : 'Search schedules, agents, or recipients...'}
476
559
  />
477
560
  </div>
478
561
  </div>
@@ -544,7 +627,7 @@ export function ScheduleConsole() {
544
627
  </select>
545
628
  </label>
546
629
 
547
- {scope !== 'runs' ? (
630
+ {scope !== 'runs' && scope !== 'history' ? (
548
631
  <label className="text-[12px] text-text-3/70">
549
632
  <span className="block mb-1.5 font-600 uppercase tracking-[0.08em] text-[10px]">Delivery</span>
550
633
  <select
@@ -562,16 +645,26 @@ export function ScheduleConsole() {
562
645
 
563
646
  <label className="text-[12px] text-text-3/70">
564
647
  <span className="block mb-1.5 font-600 uppercase tracking-[0.08em] text-[10px]">Sort</span>
565
- <select
566
- value={sortBy}
567
- onChange={(e) => setSortBy(e.target.value as ScheduleSortBy)}
568
- className="w-full px-3 py-2.5 rounded-[12px] border border-white/[0.06] bg-surface text-text-2"
569
- >
570
- <option value="nextRunAt">Next run</option>
571
- <option value="lastRunAt">Last run</option>
572
- <option value="updatedAt">Recently updated</option>
573
- <option value="name">Name</option>
574
- </select>
648
+ {scope === 'history' ? (
649
+ <select
650
+ value="history"
651
+ disabled
652
+ className="w-full px-3 py-2.5 rounded-[12px] border border-white/[0.06] bg-surface text-text-3"
653
+ >
654
+ <option value="history">Newest changes</option>
655
+ </select>
656
+ ) : (
657
+ <select
658
+ value={sortBy}
659
+ onChange={(e) => setSortBy(e.target.value as ScheduleSortBy)}
660
+ className="w-full px-3 py-2.5 rounded-[12px] border border-white/[0.06] bg-surface text-text-2"
661
+ >
662
+ <option value="nextRunAt">Next run</option>
663
+ <option value="lastRunAt">Last run</option>
664
+ <option value="updatedAt">Recently updated</option>
665
+ <option value="name">Name</option>
666
+ </select>
667
+ )}
575
668
  </label>
576
669
 
577
670
  <div className="flex items-end">
@@ -628,6 +721,71 @@ export function ScheduleConsole() {
628
721
  )
629
722
  })}
630
723
  </div>
724
+ ) : scope === 'history' ? (
725
+ <div className="divide-y divide-white/[0.05]">
726
+ {filteredHistory.length === 0 ? (
727
+ <div className="px-5 py-10 text-center text-text-3/60">No schedule history matches the current filters.</div>
728
+ ) : filteredHistory.map((row) => {
729
+ const { schedule, entry } = row
730
+ const agent = agents[schedule.agentId]
731
+ const changes = Array.isArray(entry.changes) ? entry.changes.slice(0, 4) : []
732
+ const remainingChanges = Math.max(0, (entry.changes?.length || 0) - changes.length)
733
+ return (
734
+ <div key={row.id} className="px-5 py-4 hover:bg-white/[0.02] transition-colors">
735
+ <div className="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
736
+ <div className="min-w-0">
737
+ <div className="flex flex-wrap items-center gap-2 mb-1.5">
738
+ <span className={`px-2 py-0.5 rounded-[8px] border text-[10px] font-700 uppercase tracking-[0.08em] ${historyActionBadge(entry.action)}`}>
739
+ {historyActionLabel(entry.action)}
740
+ </span>
741
+ <span className="text-[11px] text-text-3/60 uppercase tracking-[0.08em]">{schedule.scheduleType}</span>
742
+ <span className="text-[11px] text-text-3/40 uppercase tracking-[0.08em]">rev {entry.revision}</span>
743
+ </div>
744
+ <div className="text-[15px] font-600 text-text-2">{schedule.name}</div>
745
+ <div className="text-[13px] text-text-3 mt-1 line-clamp-2">{entry.summary}</div>
746
+ {changes.length > 0 && (
747
+ <div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
748
+ {changes.map((change) => (
749
+ <div key={`${entry.id}:${change.field}`} className="rounded-[10px] border border-white/[0.06] bg-white/[0.025] px-3 py-2">
750
+ <div className="text-[10px] uppercase tracking-[0.08em] text-text-3/50 font-700">{change.label}</div>
751
+ <div className="mt-1 text-[12px] text-text-2 break-words">
752
+ <span className="text-text-3">{formatHistoryValue(change.before)}</span>
753
+ <span className="mx-1.5 text-text-3/40">-&gt;</span>
754
+ <span>{formatHistoryValue(change.after)}</span>
755
+ </div>
756
+ </div>
757
+ ))}
758
+ {remainingChanges > 0 && (
759
+ <div className="rounded-[10px] border border-white/[0.06] bg-white/[0.025] px-3 py-2 text-[12px] text-text-3">
760
+ {remainingChanges} more change{remainingChanges === 1 ? '' : 's'}
761
+ </div>
762
+ )}
763
+ </div>
764
+ )}
765
+ <div className="flex flex-wrap items-center gap-2 mt-3">
766
+ {agent && (
767
+ <div className="inline-flex items-center gap-2 rounded-[10px] bg-white/[0.03] px-2.5 py-1.5 text-[12px] text-text-2">
768
+ <AgentAvatar
769
+ seed={agent.avatarSeed}
770
+ avatarUrl={agent.avatarUrl}
771
+ name={agent.name}
772
+ size={16}
773
+ />
774
+ <span>{agent.name}</span>
775
+ </div>
776
+ )}
777
+ <span className="text-[12px] text-text-3/60">{timeAgo(entry.at, now)}</span>
778
+ <span className="text-[12px] text-text-3/50">Actor: {entry.actor}</span>
779
+ </div>
780
+ </div>
781
+ <div className="flex flex-wrap items-center gap-2 shrink-0">
782
+ <ActionButton onClick={() => openSchedule(schedule.id)}>Open Schedule</ActionButton>
783
+ </div>
784
+ </div>
785
+ </div>
786
+ )
787
+ })}
788
+ </div>
631
789
  ) : (
632
790
  <div className="divide-y divide-white/[0.05]">
633
791
  {filteredSchedules.length === 0 ? (
@@ -1,5 +1,5 @@
1
1
  import { api } from '@/lib/app/api-client'
2
- import type { Schedule } from '@/types'
2
+ import type { Schedule, ScheduleHistoryEntry } from '@/types'
3
3
 
4
4
  export interface ScheduleArchiveResponse {
5
5
  ok: boolean
@@ -19,6 +19,12 @@ export interface SchedulePurgeResponse {
19
19
  purgedIds: string[]
20
20
  }
21
21
 
22
+ export interface ScheduleHistoryResponse {
23
+ scheduleId: string
24
+ revision: number
25
+ history: ScheduleHistoryEntry[]
26
+ }
27
+
22
28
  export const fetchSchedules = (includeArchived = false) =>
23
29
  api<Record<string, Schedule>>('GET', `/schedules${includeArchived ? '?includeArchived=true' : ''}`)
24
30
 
@@ -42,3 +48,6 @@ export const purgeSchedule = (id: string) =>
42
48
 
43
49
  export const runSchedule = (id: string) =>
44
50
  api<{ ok: boolean; queued?: boolean; reason?: string; taskId?: string; runNumber?: number }>('POST', `/schedules/${id}/run`)
51
+
52
+ export const fetchScheduleHistory = (id: string) =>
53
+ api<ScheduleHistoryResponse>('GET', `/schedules/${id}/history`)