@swarmclawai/swarmclaw 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/package.json +2 -1
  4. package/public/screenshots/agents.png +0 -0
  5. package/public/screenshots/dashboard.png +0 -0
  6. package/public/screenshots/providers.png +0 -0
  7. package/public/screenshots/tasks.png +0 -0
  8. package/src/app/api/activity/route.ts +30 -0
  9. package/src/app/api/agents/[id]/route.ts +3 -1
  10. package/src/app/api/agents/route.ts +2 -1
  11. package/src/app/api/connectors/[id]/route.ts +4 -1
  12. package/src/app/api/notifications/[id]/route.ts +27 -0
  13. package/src/app/api/notifications/route.ts +64 -0
  14. package/src/app/api/openclaw/approvals/route.ts +20 -0
  15. package/src/app/api/search/route.ts +105 -0
  16. package/src/app/api/tasks/[id]/route.ts +45 -1
  17. package/src/app/api/tasks/route.ts +7 -1
  18. package/src/app/api/usage/route.ts +118 -21
  19. package/src/app/api/webhooks/[id]/route.ts +62 -22
  20. package/src/cli/index.js +25 -0
  21. package/src/cli/spec.js +22 -0
  22. package/src/components/activity/activity-feed.tsx +91 -0
  23. package/src/components/chat/exec-approval-card.tsx +6 -3
  24. package/src/components/layout/app-layout.tsx +67 -7
  25. package/src/components/layout/mobile-header.tsx +2 -0
  26. package/src/components/schedules/schedule-card.tsx +2 -1
  27. package/src/components/shared/notification-center.tsx +185 -0
  28. package/src/components/shared/search-dialog.tsx +287 -0
  29. package/src/components/tasks/task-board.tsx +40 -2
  30. package/src/components/tasks/task-card.tsx +40 -2
  31. package/src/components/tasks/task-sheet.tsx +147 -1
  32. package/src/components/usage/metrics-dashboard.tsx +356 -0
  33. package/src/hooks/use-page-active.ts +21 -0
  34. package/src/hooks/use-ws.ts +13 -1
  35. package/src/lib/cron-human.ts +114 -0
  36. package/src/lib/fetch-dedup.ts +20 -0
  37. package/src/lib/optimistic.ts +25 -0
  38. package/src/lib/server/connectors/manager.ts +18 -0
  39. package/src/lib/server/create-notification.ts +30 -0
  40. package/src/lib/server/daemon-state.ts +213 -20
  41. package/src/lib/server/queue.ts +16 -0
  42. package/src/lib/server/storage.ts +61 -0
  43. package/src/lib/view-routes.ts +1 -0
  44. package/src/lib/ws-client.ts +2 -1
  45. package/src/proxy.ts +79 -2
  46. package/src/stores/use-app-store.ts +105 -1
  47. package/src/stores/use-approval-store.ts +21 -7
  48. package/src/types/index.ts +53 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SwarmClaw Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -20,6 +20,7 @@ Inspired by [OpenClaw](https://github.com/openclaw).
20
20
  - Always use the access key authentication (generated on first run)
21
21
  - Never expose port 3456 without a reverse proxy + TLS
22
22
  - Review agent system prompts before giving them shell or browser tools
23
+ - Repeated failed access key attempts are rate-limited to slow brute-force attacks
23
24
 
24
25
  ## Features
25
26
 
@@ -40,6 +41,8 @@ Inspired by [OpenClaw](https://github.com/openclaw).
40
41
  - **Session Run Queue** — Per-session queued runs with followup/steer/collect modes, collect coalescing for bursty inputs, and run-state APIs
41
42
  - **Chat Iteration Workflow** — Edit-and-resend user turns, fork a new session from any message, bookmark key messages, use contextual follow-up suggestion chips, and auto-continue after tool access grants
42
43
  - **Live Chat Telemetry** — Thinking/tool/responding stream phases, live main-loop status badges, connector activity presence, tone indicator, and optional sound notifications
44
+ - **Global Search Palette** — `Cmd/Ctrl+K` search across agents, tasks, sessions, schedules, webhooks, and skills from anywhere in the app
45
+ - **Notification Center** — Real-time in-app notifications for task/schedule/daemon events with unread tracking and quick actions
43
46
  - **Preview-Rich Chat UI** — Side preview panel for tool outputs (image/browser/html/code), inline code/PDF previews for attachments, and image lightbox support
44
47
  - **Voice Settings** — Per-instance ElevenLabs API key + voice ID for TTS replies, plus configurable speech recognition language for chat input
45
48
  - **Chat Connectors** — Bridge agents to Discord, Slack, Telegram, WhatsApp, BlueBubbles (iMessage), Signal, Microsoft Teams, Google Chat, Matrix, and OpenClaw with media-aware inbound handling
@@ -48,6 +51,7 @@ Inspired by [OpenClaw](https://github.com/openclaw).
48
51
  - **Context Management** — Auto-compaction of conversation history when approaching context limits, with manual `context_status` and `context_summarize` tools for agents
49
52
  - **Memory** — Per-agent and per-session memory with hybrid FTS5 + vector embeddings search, relevance-based memory recall injected into runs, and periodic auto-journaling for durable execution context
50
53
  - **Cost Tracking** — Per-message token counting and cost estimation displayed in the chat header
54
+ - **Provider Health Metrics** — Usage dashboard surfaces provider request volume, success rates, models used, and last-used timestamps
51
55
  - **Model Failover** — Automatic key rotation on rate limits and auth errors with configurable fallback credentials
52
56
  - **Plugin System** — Extend agent behavior with JS plugins (hooks: beforeAgentStart, afterAgentComplete, beforeToolExec, afterToolExec, onMessage)
53
57
  - **Secrets Vault** — Encrypted storage for API keys and service tokens
@@ -81,7 +85,7 @@ curl -fsSL https://raw.githubusercontent.com/swarmclawai/swarmclaw/main/install.
81
85
  ```
82
86
 
83
87
  The installer resolves the latest stable release tag and installs that version by default.
84
- To pin a version: `SWARMCLAW_VERSION=v0.5.1 curl ... | bash`
88
+ To pin a version: `SWARMCLAW_VERSION=v0.5.3 curl ... | bash`
85
89
 
86
90
  Or run locally from the repo (friendly for non-technical users):
87
91
 
@@ -600,4 +604,4 @@ swarmclaw memory maintenance
600
604
 
601
605
  ## License
602
606
 
603
- MIT
607
+ [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -84,6 +84,7 @@
84
84
  "react-dom": "19.2.3",
85
85
  "react-icons": "^5.5.0",
86
86
  "react-markdown": "^10.1.0",
87
+ "recharts": "^3.7.0",
87
88
  "rehype-highlight": "^7.0.2",
88
89
  "remark-gfm": "^4.0.1",
89
90
  "sonner": "^2.0.7",
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,30 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { loadActivity } from '@/lib/server/storage'
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export async function GET(req: Request) {
6
+ const { searchParams } = new URL(req.url)
7
+ const entityType = searchParams.get('entityType')
8
+ const entityId = searchParams.get('entityId')
9
+ const action = searchParams.get('action')
10
+ const since = searchParams.get('since')
11
+ const limit = Math.min(200, Math.max(1, Number(searchParams.get('limit')) || 50))
12
+
13
+ const all = loadActivity()
14
+ let entries = Object.values(all) as Array<Record<string, unknown>>
15
+
16
+ if (entityType) entries = entries.filter((e) => e.entityType === entityType)
17
+ if (entityId) entries = entries.filter((e) => e.entityId === entityId)
18
+ if (action) entries = entries.filter((e) => e.action === action)
19
+ if (since) {
20
+ const sinceMs = Number(since)
21
+ if (Number.isFinite(sinceMs)) {
22
+ entries = entries.filter((e) => typeof e.timestamp === 'number' && e.timestamp >= sinceMs)
23
+ }
24
+ }
25
+
26
+ entries.sort((a, b) => (b.timestamp as number) - (a.timestamp as number))
27
+ entries = entries.slice(0, limit)
28
+
29
+ return NextResponse.json(entries)
30
+ }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadAgents, saveAgents, loadSessions, saveSessions } from '@/lib/server/storage'
2
+ import { loadAgents, saveAgents, loadSessions, saveSessions, logActivity } from '@/lib/server/storage'
3
3
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
4
4
  import { mutateItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
5
5
 
@@ -22,6 +22,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
22
22
  return agent
23
23
  })
24
24
  if (!result) return notFound()
25
+ logActivity({ entityType: 'agent', entityId: id, action: 'updated', actor: 'user', summary: `Agent updated: "${result.name}"` })
25
26
  return NextResponse.json(result)
26
27
  }
27
28
 
@@ -33,6 +34,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
33
34
  return agent
34
35
  })
35
36
  if (!result) return notFound()
37
+ logActivity({ entityType: 'agent', entityId: id, action: 'deleted', actor: 'user', summary: `Agent trashed: "${result.name}"` })
36
38
 
37
39
  // Detach sessions from the trashed agent
38
40
  const sessions = loadSessions()
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
- import { loadAgents, saveAgents } from '@/lib/server/storage'
3
+ import { loadAgents, saveAgents, logActivity } from '@/lib/server/storage'
4
4
  import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
5
5
  import { notify } from '@/lib/server/ws-hub'
6
6
  export const dynamic = 'force-dynamic'
@@ -33,6 +33,7 @@ export async function POST(req: Request) {
33
33
  updatedAt: now,
34
34
  }
35
35
  saveAgents(agents)
36
+ logActivity({ entityType: 'agent', entityId: id, action: 'created', actor: 'user', summary: `Agent created: "${agents[id].name}"` })
36
37
  notify('agents')
37
38
  return NextResponse.json(agents[id])
38
39
  }
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadConnectors, saveConnectors } from '@/lib/server/storage'
2
+ import { loadConnectors, saveConnectors, logActivity } from '@/lib/server/storage'
3
3
  import { notify } from '@/lib/server/ws-hub'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
 
@@ -39,10 +39,13 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
39
39
  const manager = await import('@/lib/server/connectors/manager')
40
40
  if (body.action === 'start') {
41
41
  await manager.startConnector(id)
42
+ logActivity({ entityType: 'connector', entityId: id, action: 'started', actor: 'user', summary: `Connector started: "${connector.name}"` })
42
43
  } else if (body.action === 'stop') {
43
44
  await manager.stopConnector(id)
45
+ logActivity({ entityType: 'connector', entityId: id, action: 'stopped', actor: 'user', summary: `Connector stopped: "${connector.name}"` })
44
46
  } else {
45
47
  await manager.repairConnector(id)
48
+ logActivity({ entityType: 'connector', entityId: id, action: 'started', actor: 'user', summary: `Connector repaired: "${connector.name}"` })
46
49
  }
47
50
  } catch (err: unknown) {
48
51
  // Re-read to get the error state saved by startConnector
@@ -0,0 +1,27 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { loadNotifications, markNotificationRead, deleteNotification } from '@/lib/server/storage'
3
+ import { notify } from '@/lib/server/ws-hub'
4
+
5
+ export async function PUT(_req: Request, { params }: { params: Promise<{ id: string }> }) {
6
+ const { id } = await params
7
+ const all = loadNotifications()
8
+ if (!all[id]) {
9
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
10
+ }
11
+
12
+ markNotificationRead(id)
13
+ notify('notifications')
14
+ return NextResponse.json({ ok: true })
15
+ }
16
+
17
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
18
+ const { id } = await params
19
+ const all = loadNotifications()
20
+ if (!all[id]) {
21
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
22
+ }
23
+
24
+ deleteNotification(id)
25
+ notify('notifications')
26
+ return NextResponse.json({ ok: true })
27
+ }
@@ -0,0 +1,64 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { genId } from '@/lib/id'
3
+ import { loadNotifications, saveNotification, deleteNotification } from '@/lib/server/storage'
4
+ import { notify } from '@/lib/server/ws-hub'
5
+ import type { AppNotification } from '@/types'
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export async function GET(req: Request) {
9
+ const { searchParams } = new URL(req.url)
10
+ const unreadOnly = searchParams.get('unreadOnly') === 'true'
11
+ const limit = Math.min(200, Math.max(1, Number(searchParams.get('limit')) || 100))
12
+
13
+ const all = loadNotifications()
14
+ let entries = Object.values(all) as AppNotification[]
15
+
16
+ if (unreadOnly) {
17
+ entries = entries.filter((e) => !e.read)
18
+ }
19
+
20
+ entries.sort((a, b) => b.createdAt - a.createdAt)
21
+ entries = entries.slice(0, limit)
22
+
23
+ return NextResponse.json(entries)
24
+ }
25
+
26
+ export async function POST(req: Request) {
27
+ const body = (await req.json()) as Record<string, unknown>
28
+ const id = genId()
29
+ const notification: AppNotification = {
30
+ id,
31
+ type: (['info', 'success', 'warning', 'error'].includes(body.type as string) ? body.type : 'info') as AppNotification['type'],
32
+ title: typeof body.title === 'string' ? body.title : 'Notification',
33
+ message: typeof body.message === 'string' ? body.message : undefined,
34
+ entityType: typeof body.entityType === 'string' ? body.entityType : undefined,
35
+ entityId: typeof body.entityId === 'string' ? body.entityId : undefined,
36
+ read: false,
37
+ createdAt: Date.now(),
38
+ }
39
+
40
+ saveNotification(id, notification)
41
+ notify('notifications')
42
+ return NextResponse.json(notification, { status: 201 })
43
+ }
44
+
45
+ export async function DELETE(req: Request) {
46
+ const { searchParams } = new URL(req.url)
47
+ const specificId = searchParams.get('id')
48
+
49
+ if (specificId) {
50
+ deleteNotification(specificId)
51
+ } else {
52
+ // Clear all read notifications
53
+ const all = loadNotifications()
54
+ for (const raw of Object.values(all)) {
55
+ const entry = raw as AppNotification
56
+ if (entry.read) {
57
+ deleteNotification(entry.id)
58
+ }
59
+ }
60
+ }
61
+
62
+ notify('notifications')
63
+ return NextResponse.json({ ok: true })
64
+ }
@@ -17,6 +17,19 @@ export async function GET() {
17
17
  }
18
18
  }
19
19
 
20
+ /* ── Conflict-detection: track recently resolved approval IDs in-process ── */
21
+ const resolvedKey = '__swarmclaw_resolved_approvals__'
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ const resolved: Map<string, number> = (globalThis as any)[resolvedKey] ?? ((globalThis as any)[resolvedKey] = new Map<string, number>())
24
+ const RESOLVED_TTL_MS = 5 * 60 * 1000
25
+
26
+ function pruneResolved() {
27
+ const cutoff = Date.now() - RESOLVED_TTL_MS
28
+ for (const [k, ts] of resolved) {
29
+ if (ts < cutoff) resolved.delete(k)
30
+ }
31
+ }
32
+
20
33
  /** POST { id, decision } — resolve an execution approval */
21
34
  export async function POST(req: Request) {
22
35
  const body = await req.json()
@@ -31,6 +44,12 @@ export async function POST(req: Request) {
31
44
  return NextResponse.json({ error: 'Invalid decision' }, { status: 400 })
32
45
  }
33
46
 
47
+ // Conflict detection — prevent duplicate resolution
48
+ pruneResolved()
49
+ if (resolved.has(id)) {
50
+ return NextResponse.json({ error: 'Already resolved' }, { status: 409 })
51
+ }
52
+
34
53
  const gw = await ensureGatewayConnected()
35
54
  if (!gw) {
36
55
  return NextResponse.json({ error: 'OpenClaw gateway not connected' }, { status: 503 })
@@ -38,6 +57,7 @@ export async function POST(req: Request) {
38
57
 
39
58
  try {
40
59
  await gw.rpc('exec.approvals.resolve', { id, decision })
60
+ resolved.set(id, Date.now())
41
61
  return NextResponse.json({ ok: true })
42
62
  } catch (err: unknown) {
43
63
  const message = err instanceof Error ? err.message : String(err)
@@ -0,0 +1,105 @@
1
+ import { NextResponse } from 'next/server'
2
+ import {
3
+ loadTasks,
4
+ loadAgents,
5
+ loadSessions,
6
+ loadSchedules,
7
+ loadWebhooks,
8
+ loadSkills,
9
+ } from '@/lib/server/storage'
10
+
11
+ interface SearchResult {
12
+ type: 'task' | 'agent' | 'session' | 'schedule' | 'webhook' | 'skill'
13
+ id: string
14
+ title: string
15
+ description?: string
16
+ status?: string
17
+ }
18
+
19
+ const MAX_RESULTS = 20
20
+
21
+ function matches(haystack: string | undefined | null, needle: string): boolean {
22
+ if (!haystack) return false
23
+ return haystack.toLowerCase().includes(needle)
24
+ }
25
+
26
+ function searchCollection(
27
+ collection: Record<string, Record<string, unknown>>,
28
+ type: SearchResult['type'],
29
+ needle: string,
30
+ titleKey: string,
31
+ descKey: string,
32
+ statusKey?: string,
33
+ ): SearchResult[] {
34
+ const results: SearchResult[] = []
35
+ for (const [id, item] of Object.entries(collection)) {
36
+ const title = item[titleKey] as string | undefined
37
+ const desc = item[descKey] as string | undefined
38
+ const idStr = typeof item.id === 'string' ? item.id : id
39
+ if (matches(title, needle) || matches(desc, needle) || matches(idStr, needle)) {
40
+ results.push({
41
+ type,
42
+ id,
43
+ title: title || idStr || id,
44
+ description: desc ? desc.slice(0, 120) : undefined,
45
+ status: statusKey ? (item[statusKey] as string | undefined) : undefined,
46
+ })
47
+ }
48
+ }
49
+ return results
50
+ }
51
+
52
+ export async function GET(req: Request) {
53
+ const { searchParams } = new URL(req.url)
54
+ const q = (searchParams.get('q') || '').trim().toLowerCase()
55
+
56
+ if (q.length < 2) {
57
+ return NextResponse.json({ results: [] })
58
+ }
59
+
60
+ const tasks = loadTasks() as Record<string, Record<string, unknown>>
61
+ const agents = loadAgents() as Record<string, Record<string, unknown>>
62
+ const sessions = loadSessions() as Record<string, Record<string, unknown>>
63
+ const schedules = loadSchedules() as Record<string, Record<string, unknown>>
64
+ const webhooks = loadWebhooks() as Record<string, Record<string, unknown>>
65
+ const skills = loadSkills() as Record<string, Record<string, unknown>>
66
+
67
+ const buckets: SearchResult[][] = [
68
+ searchCollection(agents, 'agent', q, 'name', 'description'),
69
+ searchCollection(tasks, 'task', q, 'title', 'description', 'status'),
70
+ searchCollection(sessions, 'session', q, 'name', 'cwd'),
71
+ searchCollection(schedules, 'schedule', q, 'name', 'taskPrompt', 'status'),
72
+ searchCollection(webhooks, 'webhook', q, 'name', 'source'),
73
+ searchCollection(skills, 'skill', q, 'name', 'description'),
74
+ ]
75
+
76
+ // Proportional allocation across types
77
+ const totalRaw = buckets.reduce((s, b) => s + b.length, 0)
78
+ if (totalRaw === 0) {
79
+ return NextResponse.json({ results: [] })
80
+ }
81
+
82
+ const results: SearchResult[] = []
83
+ if (totalRaw <= MAX_RESULTS) {
84
+ for (const bucket of buckets) results.push(...bucket)
85
+ } else {
86
+ // Give each bucket a fair share, round-robin leftover slots
87
+ const perBucket = Math.floor(MAX_RESULTS / buckets.length)
88
+ let remaining = MAX_RESULTS
89
+ for (const bucket of buckets) {
90
+ const take = Math.min(bucket.length, perBucket)
91
+ results.push(...bucket.slice(0, take))
92
+ remaining -= take
93
+ }
94
+ // Fill remaining slots from buckets that had more
95
+ for (const bucket of buckets) {
96
+ if (remaining <= 0) break
97
+ const alreadyTaken = Math.min(bucket.length, perBucket)
98
+ const extra = bucket.slice(alreadyTaken, alreadyTaken + remaining)
99
+ results.push(...extra)
100
+ remaining -= extra.length
101
+ }
102
+ }
103
+
104
+ return NextResponse.json({ results })
105
+ }
@@ -1,12 +1,13 @@
1
1
  import { genId } from '@/lib/id'
2
2
  import { NextResponse } from 'next/server'
3
- import { loadTasks, saveTasks } from '@/lib/server/storage'
3
+ import { loadTasks, saveTasks, logActivity } from '@/lib/server/storage'
4
4
  import { notFound } from '@/lib/server/collection-helpers'
5
5
  import { disableSessionHeartbeat, enqueueTask, validateCompletedTasksQueue } from '@/lib/server/queue'
6
6
  import { ensureTaskCompletionReport } from '@/lib/server/task-reports'
7
7
  import { formatValidationFailure, validateTaskCompletion } from '@/lib/server/task-validation'
8
8
  import { pushMainLoopEventToMainSessions } from '@/lib/server/main-agent-loop'
9
9
  import { notify } from '@/lib/server/ws-hub'
10
+ import { createNotification } from '@/lib/server/create-notification'
10
11
 
11
12
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
12
13
  // Keep completed queue integrity even if daemon is not running.
@@ -65,6 +66,7 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
65
66
  }
66
67
 
67
68
  saveTasks(tasks)
69
+ logActivity({ entityType: 'task', entityId: id, action: 'updated', actor: 'user', summary: `Task updated: "${tasks[id].title}" (${prevStatus} → ${tasks[id].status})` })
68
70
  if (prevStatus !== tasks[id].status) {
69
71
  pushMainLoopEventToMainSessions({
70
72
  type: 'task_status_changed',
@@ -75,6 +77,47 @@ export async function PUT(req: Request, { params }: { params: Promise<{ id: stri
75
77
  // If task is manually transitioned to a terminal status, disable session heartbeat.
76
78
  if (prevStatus !== tasks[id].status && (tasks[id].status === 'completed' || tasks[id].status === 'failed')) {
77
79
  disableSessionHeartbeat(tasks[id].sessionId)
80
+ createNotification({
81
+ type: tasks[id].status === 'completed' ? 'success' : 'error',
82
+ title: `Task ${tasks[id].status}: "${tasks[id].title}"`,
83
+ message: tasks[id].status === 'failed' ? tasks[id].error?.slice(0, 200) : undefined,
84
+ entityType: 'task',
85
+ entityId: id,
86
+ })
87
+ }
88
+
89
+ // Dependency check: cannot queue a task if any blocker is incomplete
90
+ if (tasks[id].status === 'queued') {
91
+ const blockers = Array.isArray(tasks[id].blockedBy) ? tasks[id].blockedBy : []
92
+ const incompleteBlocker = blockers.find((bid: string) => tasks[bid] && tasks[bid].status !== 'completed')
93
+ if (incompleteBlocker) {
94
+ // Revert status change and reject
95
+ tasks[id].status = prevStatus
96
+ tasks[id].updatedAt = Date.now()
97
+ saveTasks(tasks)
98
+ return NextResponse.json(
99
+ { error: 'Cannot queue: blocked by incomplete tasks', blockedBy: incompleteBlocker },
100
+ { status: 409 },
101
+ )
102
+ }
103
+ }
104
+
105
+ // When a task is completed, auto-unblock dependent tasks
106
+ if (tasks[id].status === 'completed') {
107
+ const blockedIds = Array.isArray(tasks[id].blocks) ? tasks[id].blocks as string[] : []
108
+ for (const blockedId of blockedIds) {
109
+ const blocked = tasks[blockedId]
110
+ if (!blocked) continue
111
+ const deps = Array.isArray(blocked.blockedBy) ? blocked.blockedBy as string[] : []
112
+ const allDone = deps.every((depId: string) => tasks[depId]?.status === 'completed')
113
+ if (allDone && (blocked.status === 'backlog' || blocked.status === 'todo')) {
114
+ blocked.status = 'queued'
115
+ blocked.queuedAt = Date.now()
116
+ blocked.updatedAt = Date.now()
117
+ saveTasks(tasks)
118
+ enqueueTask(blockedId)
119
+ }
120
+ }
78
121
  }
79
122
 
80
123
  // If status changed to 'queued', enqueue it
@@ -96,6 +139,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
96
139
  tasks[id].archivedAt = Date.now()
97
140
  tasks[id].updatedAt = Date.now()
98
141
  saveTasks(tasks)
142
+ logActivity({ entityType: 'task', entityId: id, action: 'deleted', actor: 'user', summary: `Task archived: "${tasks[id].title}"` })
99
143
  pushMainLoopEventToMainSessions({
100
144
  type: 'task_archived',
101
145
  text: `Task archived: "${tasks[id].title}" (${id}).`,
@@ -1,6 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { genId } from '@/lib/id'
3
- import { loadTasks, saveTasks, loadSettings } from '@/lib/server/storage'
3
+ import { loadTasks, saveTasks, loadSettings, logActivity } from '@/lib/server/storage'
4
4
  import { enqueueTask, validateCompletedTasksQueue } from '@/lib/server/queue'
5
5
  import { ensureTaskCompletionReport } from '@/lib/server/task-reports'
6
6
  import { formatValidationFailure, validateTaskCompletion } from '@/lib/server/task-validation'
@@ -88,6 +88,11 @@ export async function POST(req: Request) {
88
88
  retryScheduledAt: null,
89
89
  deadLetteredAt: null,
90
90
  checkpoint: null,
91
+ blockedBy: Array.isArray(body.blockedBy) ? body.blockedBy.filter((s: unknown) => typeof s === 'string') : [],
92
+ blocks: Array.isArray(body.blocks) ? body.blocks.filter((s: unknown) => typeof s === 'string') : [],
93
+ tags: Array.isArray(body.tags) ? body.tags.filter((s: unknown) => typeof s === 'string') : [],
94
+ dueAt: typeof body.dueAt === 'number' ? body.dueAt : null,
95
+ customFields: body.customFields && typeof body.customFields === 'object' ? body.customFields : undefined,
91
96
  }
92
97
 
93
98
  if (tasks[id].status === 'completed') {
@@ -106,6 +111,7 @@ export async function POST(req: Request) {
106
111
  }
107
112
 
108
113
  saveTasks(tasks)
114
+ logActivity({ entityType: 'task', entityId: id, action: 'created', actor: 'user', summary: `Task created: "${tasks[id].title}"` })
109
115
  pushMainLoopEventToMainSessions({
110
116
  type: 'task_created',
111
117
  text: `Task created: "${tasks[id].title}" (${id}) with status ${tasks[id].status}.`,