@swarmclawai/swarmclaw 1.2.9 → 1.3.0

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 (38) hide show
  1. package/README.md +9 -0
  2. package/package.json +2 -2
  3. package/src/app/api/activity/route.ts +9 -23
  4. package/src/app/api/agents/route.ts +17 -1
  5. package/src/app/api/approvals/route.ts +13 -5
  6. package/src/app/api/credentials/[id]/route.ts +2 -0
  7. package/src/app/api/credentials/route.ts +4 -1
  8. package/src/app/api/goals/[id]/route.ts +28 -0
  9. package/src/app/api/goals/route.ts +33 -0
  10. package/src/app/api/protocols/templates/[id]/route.ts +2 -1
  11. package/src/app/api/protocols/templates/route.ts +2 -1
  12. package/src/app/api/settings/route.ts +13 -0
  13. package/src/app/home/page.tsx +3 -0
  14. package/src/cli/index.js +11 -0
  15. package/src/cli/spec.js +10 -0
  16. package/src/lib/server/activity/activity-log.ts +16 -1
  17. package/src/lib/server/agents/agent-service.ts +24 -11
  18. package/src/lib/server/approval-match.ts +14 -0
  19. package/src/lib/server/approvals/approval-hooks.ts +81 -0
  20. package/src/lib/server/approvals.ts +11 -0
  21. package/src/lib/server/connectors/swarmdock-tasks.ts +8 -0
  22. package/src/lib/server/connectors/swarmdock.ts +30 -0
  23. package/src/lib/server/execution-brief.ts +18 -0
  24. package/src/lib/server/goals/goal-repository.ts +19 -0
  25. package/src/lib/server/goals/goal-service.ts +143 -0
  26. package/src/lib/server/storage-normalization.ts +5 -0
  27. package/src/lib/server/storage.ts +57 -0
  28. package/src/lib/server/usage/cost-rollup.ts +124 -0
  29. package/src/lib/server/usage/usage-repository.ts +6 -0
  30. package/src/lib/validation/schemas.ts +3 -30
  31. package/src/lib/validation/server-schemas.ts +35 -0
  32. package/src/types/agent.ts +10 -0
  33. package/src/types/app-settings.ts +6 -0
  34. package/src/types/approval.ts +3 -0
  35. package/src/types/goal.ts +30 -0
  36. package/src/types/index.ts +1 -0
  37. package/src/types/misc.ts +2 -2
  38. package/src/types/task.ts +2 -0
package/README.md CHANGED
@@ -204,6 +204,15 @@ Read the full setup guide in [`SWARMDOCK.md`](./SWARMDOCK.md), browse the public
204
204
 
205
205
  ## Release Notes
206
206
 
207
+ ### v1.3.0 Highlights
208
+
209
+ - **SwarmDock SDK v0.2.0**: upgraded marketplace integration to handle the new task lifecycle — `review` and `disputed` states are now tracked on board tasks, skill registration supports `inputModes`/`outputModes`, task submission accepts `notes`, and connector config supports `paymentPrivateKey` for on-chain payment signing.
210
+ - **Comprehensive audit logging**: activity log now covers approval decisions, settings changes, budget modifications, and credential operations, with SQL-indexed paginated queries replacing the in-memory full-collection scan.
211
+ - **Push-based cost rollups**: agent spend fields (`spentHourlyCents`, `spentDailyCents`, `spentMonthlyCents`) update atomically on every usage event, with automatic budget warning/exceeded activity entries and window reset detection — replacing the pull-based full-scan approach.
212
+ - **Goal hierarchy**: new goals system with organization → team → project → agent → task levels, parent-child chains, and automatic injection of the "why chain" into agent execution briefs. Full CRUD API and CLI support.
213
+ - **Extended approval workflows**: new `agent_create`, `budget_change`, and `delegation_enable` approval categories with configurable policies in settings. When enabled, agent creation returns a pending approval instead of creating the agent directly.
214
+ - **Shared validation schemas**: Zod schemas in `src/lib/validation/schemas.ts` are now safe for client-side import (server-only DAG validation moved to `server-schemas.ts`), enabling form-level pre-validation.
215
+
207
216
  ### v1.2.9 Highlights
208
217
 
209
218
  - **SwarmDock marketplace connector**: SwarmClaw agents can now register on SwarmDock, auto-bid on matching work, receive assignments as board tasks, and submit results back through the connector runtime.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.2.9",
3
+ "version": "1.3.0",
4
4
  "description": "Self-hosted AI runtime for OpenClaw, delegation, autonomy, runtime skills, crypto wallets, and chat platform connectors.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -87,7 +87,7 @@
87
87
  "@multiavatar/multiavatar": "^1.0.7",
88
88
  "@playwright/mcp": "^0.0.68",
89
89
  "@slack/bolt": "^4.6.0",
90
- "@swarmdock/sdk": "^0.1.0",
90
+ "@swarmdock/sdk": "^0.2.0",
91
91
  "@tailwindcss/postcss": "^4",
92
92
  "@tanstack/react-query": "^5.91.0",
93
93
  "@types/better-sqlite3": "^7.6.13",
@@ -1,32 +1,18 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { loadActivity } from '@/lib/server/storage'
2
+ import { queryActivity } from '@/lib/server/activity/activity-log'
3
3
  export const dynamic = 'force-dynamic'
4
4
 
5
5
  export async function GET(req: Request) {
6
6
  const { searchParams } = new URL(req.url)
7
- const entityType = searchParams.get('entityType')
8
- const entityId = searchParams.get('entityId')
9
- const actor = searchParams.get('actor')
10
- const action = searchParams.get('action')
11
- const since = searchParams.get('since')
7
+ const entityType = searchParams.get('entityType') ?? undefined
8
+ const entityId = searchParams.get('entityId') ?? undefined
9
+ const actor = searchParams.get('actor') ?? undefined
10
+ const action = searchParams.get('action') ?? undefined
11
+ const sinceRaw = searchParams.get('since')
12
+ const since = sinceRaw ? Number(sinceRaw) : undefined
12
13
  const limit = Math.min(200, Math.max(1, Number(searchParams.get('limit')) || 50))
14
+ const offset = Math.max(0, Number(searchParams.get('offset')) || 0)
13
15
 
14
- const all = loadActivity()
15
- let entries = Object.values(all) as unknown as Array<Record<string, unknown>>
16
-
17
- if (entityType) entries = entries.filter((e) => e.entityType === entityType)
18
- if (entityId) entries = entries.filter((e) => e.entityId === entityId)
19
- if (actor) entries = entries.filter((e) => e.actor === actor)
20
- if (action) entries = entries.filter((e) => e.action === action)
21
- if (since) {
22
- const sinceMs = Number(since)
23
- if (Number.isFinite(sinceMs)) {
24
- entries = entries.filter((e) => typeof e.timestamp === 'number' && e.timestamp >= sinceMs)
25
- }
26
- }
27
-
28
- entries.sort((a, b) => (b.timestamp as number) - (a.timestamp as number))
29
- entries = entries.slice(0, limit)
30
-
16
+ const entries = queryActivity({ entityType, entityId, actor, action, since, limit, offset })
31
17
  return NextResponse.json(entries)
32
18
  }
@@ -5,6 +5,8 @@ import { AgentCreateSchema, formatZodError } from '@/lib/validation/schemas'
5
5
  import { ensureDaemonProcessRunning } from '@/lib/server/daemon/controller'
6
6
  import { z } from 'zod'
7
7
  import { safeParseBody } from '@/lib/server/safe-parse-body'
8
+ import { loadSettings } from '@/lib/server/storage'
9
+ import { requestApproval } from '@/lib/server/approvals'
8
10
  export const dynamic = 'force-dynamic'
9
11
 
10
12
 
@@ -36,6 +38,20 @@ export async function POST(req: Request) {
36
38
  if (!parsed.success) {
37
39
  return NextResponse.json(formatZodError(parsed.error as z.ZodError), { status: 400 })
38
40
  }
39
- const agent = createAgent({ body: parsed.data as unknown as Record<string, unknown>, rawRecord })
41
+ const body = parsed.data as unknown as Record<string, unknown>
42
+
43
+ // Check approval policy — if enabled, create an approval request instead of the agent
44
+ const settings = loadSettings()
45
+ if (settings.approvalPolicies?.requireApprovalForAgentCreate) {
46
+ const approval = requestApproval({
47
+ category: 'agent_create',
48
+ title: `Create agent: ${body.name}`,
49
+ description: `Request to create agent "${body.name}" with provider ${body.provider}`,
50
+ data: { pendingAgentConfig: body, agentName: String(body.name || ''), provider: String(body.provider || '') },
51
+ })
52
+ return NextResponse.json({ pendingApproval: true, approvalId: approval.id }, { status: 202 })
53
+ }
54
+
55
+ const agent = createAgent({ body, rawRecord })
40
56
  return NextResponse.json(agent)
41
57
  }
@@ -2,11 +2,22 @@ import { NextResponse } from 'next/server'
2
2
  import { listPendingApprovals, submitDecision } from '@/lib/server/approvals'
3
3
  import { loadApprovals } from '@/lib/server/storage'
4
4
  import { errorMessage } from '@/lib/shared-utils'
5
+ import type { ApprovalCategory } from '@/types'
5
6
 
6
7
  export const dynamic = 'force-dynamic'
7
8
 
8
- export async function GET() {
9
- return NextResponse.json(listPendingApprovals('human_loop'))
9
+ const ALLOWED_CATEGORIES: ApprovalCategory[] = [
10
+ 'human_loop', 'tool_access', 'extension_scaffold', 'extension_install',
11
+ 'task_tool', 'connector_sender', 'agent_create', 'budget_change', 'delegation_enable',
12
+ ]
13
+
14
+ export async function GET(req: Request) {
15
+ const { searchParams } = new URL(req.url)
16
+ const categoryParam = searchParams.get('category') as ApprovalCategory | null
17
+ const category = categoryParam && ALLOWED_CATEGORIES.includes(categoryParam)
18
+ ? categoryParam
19
+ : undefined
20
+ return NextResponse.json(listPendingApprovals(category))
10
21
  }
11
22
 
12
23
  export async function POST(req: Request) {
@@ -20,9 +31,6 @@ export async function POST(req: Request) {
20
31
  if (!approval) {
21
32
  return NextResponse.json({ error: 'approval not found' }, { status: 404 })
22
33
  }
23
- if (approval.category !== 'human_loop') {
24
- return NextResponse.json({ error: 'only human-loop approvals are supported here' }, { status: 400 })
25
- }
26
34
  await submitDecision(id, approved)
27
35
  return NextResponse.json({ ok: true })
28
36
  } catch (err: unknown) {
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { deleteCredentialRecord } from '@/lib/server/credentials/credential-service'
3
3
  import { notFound } from '@/lib/server/collection-helpers'
4
4
  import { log } from '@/lib/server/logger'
5
+ import { logActivity } from '@/lib/server/activity/activity-log'
5
6
 
6
7
  const TAG = 'api-credentials'
7
8
 
@@ -11,5 +12,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
11
12
  return notFound()
12
13
  }
13
14
  log.info(TAG, `deleted ${credId}`)
15
+ logActivity({ entityType: 'credential', entityId: credId, action: 'deleted', actor: 'user', summary: `Credential deleted: ${credId}` })
14
16
  return new NextResponse('OK')
15
17
  }
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { createCredentialRecord, listCredentialSummaries } from '@/lib/server/credentials/credential-service'
3
3
  import { safeParseBody } from '@/lib/server/safe-parse-body'
4
+ import { logActivity } from '@/lib/server/activity/activity-log'
4
5
  export const dynamic = 'force-dynamic'
5
6
 
6
7
 
@@ -16,7 +17,9 @@ export async function POST(req: Request) {
16
17
  return NextResponse.json({ error: 'provider and apiKey are required' }, { status: 400 })
17
18
  }
18
19
  try {
19
- return NextResponse.json(createCredentialRecord({ provider, name, apiKey }))
20
+ const result = createCredentialRecord({ provider, name, apiKey })
21
+ logActivity({ entityType: 'credential', entityId: result.id, action: 'created', actor: 'user', summary: `Credential created: "${name}" (${provider})` })
22
+ return NextResponse.json(result)
20
23
  } catch (err: unknown) {
21
24
  return NextResponse.json(
22
25
  { error: err instanceof Error ? err.message : 'Failed to create credential' },
@@ -0,0 +1,28 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
3
+ import { getGoalById, updateGoal, deleteGoal, getGoalChain } from '@/lib/server/goals/goal-service'
4
+ import { notFound } from '@/lib/server/collection-helpers'
5
+ export const dynamic = 'force-dynamic'
6
+
7
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
8
+ const { id } = await params
9
+ const goal = getGoalById(id)
10
+ if (!goal) return notFound()
11
+ const chain = getGoalChain(id)
12
+ return NextResponse.json({ ...goal, chain })
13
+ }
14
+
15
+ export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
16
+ const { id } = await params
17
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
18
+ if (error) return error
19
+ const updated = updateGoal(id, body)
20
+ if (!updated) return notFound()
21
+ return NextResponse.json(updated)
22
+ }
23
+
24
+ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
25
+ const { id } = await params
26
+ if (!deleteGoal(id)) return notFound()
27
+ return new NextResponse('OK')
28
+ }
@@ -0,0 +1,33 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
4
+ import { getAllGoals, createGoal } from '@/lib/server/goals/goal-service'
5
+ import { formatZodError } from '@/lib/validation/schemas'
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ const GoalCreateSchema = z.object({
9
+ title: z.string().min(1, 'Goal title is required'),
10
+ description: z.string().optional().default(''),
11
+ level: z.enum(['organization', 'team', 'project', 'agent', 'task']),
12
+ parentGoalId: z.string().nullable().optional().default(null),
13
+ projectId: z.string().nullable().optional().default(null),
14
+ agentId: z.string().nullable().optional().default(null),
15
+ taskId: z.string().nullable().optional().default(null),
16
+ objective: z.string().min(1, 'Objective is required'),
17
+ constraints: z.array(z.string()).optional().default([]),
18
+ successMetric: z.string().nullable().optional().default(null),
19
+ budgetUsd: z.number().positive().nullable().optional().default(null),
20
+ deadlineAt: z.number().nullable().optional().default(null),
21
+ })
22
+
23
+ export async function GET() {
24
+ return NextResponse.json(getAllGoals())
25
+ }
26
+
27
+ export async function POST(req: Request) {
28
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
29
+ if (error) return error
30
+ const parsed = GoalCreateSchema.safeParse(body)
31
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
32
+ return NextResponse.json(createGoal(parsed.data))
33
+ }
@@ -1,7 +1,8 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { notFound } from '@/lib/server/collection-helpers'
4
- import { formatZodError, ProtocolTemplateUpsertSchema } from '@/lib/validation/schemas'
4
+ import { formatZodError } from '@/lib/validation/schemas'
5
+ import { ProtocolTemplateUpsertSchema } from '@/lib/validation/server-schemas'
5
6
  import {
6
7
  deleteProtocolTemplateById,
7
8
  loadProtocolTemplateById,
@@ -1,6 +1,7 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
- import { formatZodError, ProtocolTemplateUpsertSchema } from '@/lib/validation/schemas'
3
+ import { formatZodError } from '@/lib/validation/schemas'
4
+ import { ProtocolTemplateUpsertSchema } from '@/lib/validation/server-schemas'
4
5
  import {
5
6
  createProtocolTemplate,
6
7
  listProtocolTemplates,
@@ -6,6 +6,7 @@ import { loadPublicSettings, loadSettings, saveSettings } from '@/lib/server/sto
6
6
  import { normalizeRuntimeSettingFields } from '@/lib/runtime/runtime-loop'
7
7
  import { normalizeSupervisorSettings } from '@/lib/autonomy/supervisor-settings'
8
8
  import { ensureDaemonProcessRunning } from '@/lib/server/daemon/controller'
9
+ import { logActivity } from '@/lib/server/activity/activity-log'
9
10
  export const dynamic = 'force-dynamic'
10
11
 
11
12
 
@@ -164,6 +165,18 @@ export async function PUT(req: Request) {
164
165
 
165
166
  saveSettings(settings)
166
167
 
168
+ const changedKeys = Object.keys(sanitizedBody).filter((k) => !SECRET_SETTING_KEYS.includes(k as typeof SECRET_SETTING_KEYS[number]))
169
+ if (changedKeys.length > 0) {
170
+ logActivity({
171
+ entityType: 'settings',
172
+ entityId: 'app',
173
+ action: 'configured',
174
+ actor: 'user',
175
+ summary: `Settings updated: ${changedKeys.join(', ')}`,
176
+ detail: { changedKeys },
177
+ })
178
+ }
179
+
167
180
  if ('daemonAutostartEnabled' in sanitizedBody && settings.daemonAutostartEnabled) {
168
181
  void ensureDaemonProcessRunning('api/settings:put:daemon-autostart', { manualStart: true }).catch(() => {
169
182
  // The daemon launcher may not be available during early bootstrap.
@@ -42,6 +42,9 @@ const ACTIVITY_ICONS: Record<ActivityEntry['action'], string> = {
42
42
  incident: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4m0 4h.01',
43
43
  running: 'M12 2v4m0 12v4m10-10h-4M6 12H2',
44
44
  claimed: 'M9 12l2 2 4-4m6 2a10 10 0 1 1-20 0 10 10 0 0 1 20 0z',
45
+ configured: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z',
46
+ budget_exceeded: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20m0 6v4m0 4h.01',
47
+ budget_warning: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4m0 4h.01',
45
48
  }
46
49
 
47
50
  const ACTIVITY_COLORS: Record<string, string> = {
package/src/cli/index.js CHANGED
@@ -757,6 +757,17 @@ const COMMAND_GROUPS = [
757
757
  cmd('delete', 'DELETE', '/wallets/:id', 'Delete a wallet'),
758
758
  ],
759
759
  },
760
+ {
761
+ name: 'goals',
762
+ description: 'Manage goal hierarchy',
763
+ commands: [
764
+ cmd('list', 'GET', '/goals', 'List goals'),
765
+ cmd('get', 'GET', '/goals/:id', 'Get goal by id'),
766
+ cmd('create', 'POST', '/goals', 'Create a goal', { expectsJsonBody: true }),
767
+ cmd('update', 'PATCH', '/goals/:id', 'Update a goal', { expectsJsonBody: true }),
768
+ cmd('delete', 'DELETE', '/goals/:id', 'Delete a goal'),
769
+ ],
770
+ },
760
771
  ]
761
772
 
762
773
  const GROUP_MAP = new Map(COMMAND_GROUPS.map((group) => [group.name, group]))
package/src/cli/spec.js CHANGED
@@ -535,6 +535,16 @@ const COMMAND_GROUPS = {
535
535
  delete: { description: 'Delete a wallet', method: 'DELETE', path: '/wallets/:id', params: ['id'] },
536
536
  },
537
537
  },
538
+ goals: {
539
+ description: 'Manage goal hierarchy',
540
+ commands: {
541
+ list: { description: 'List goals', method: 'GET', path: '/goals' },
542
+ get: { description: 'Get goal by id', method: 'GET', path: '/goals/:id', params: ['id'] },
543
+ create: { description: 'Create a goal', method: 'POST', path: '/goals' },
544
+ update: { description: 'Update a goal', method: 'PATCH', path: '/goals/:id', params: ['id'], body: true },
545
+ delete: { description: 'Delete a goal', method: 'DELETE', path: '/goals/:id', params: ['id'] },
546
+ },
547
+ },
538
548
  }
539
549
 
540
550
  const GROUP_NAMES = Object.keys(COMMAND_GROUPS)
@@ -1,4 +1,4 @@
1
- import { loadActivity as loadStoredActivity, logActivity as writeActivityLog } from '@/lib/server/storage'
1
+ import { loadActivity as loadStoredActivity, logActivity as writeActivityLog, queryActivity as queryStoredActivity } from '@/lib/server/storage'
2
2
  import { perf } from '@/lib/server/runtime/perf'
3
3
 
4
4
  export function loadActivity() {
@@ -19,3 +19,18 @@ export function logActivity(entry: {
19
19
  action: entry.action,
20
20
  })
21
21
  }
22
+
23
+ /** Paginated activity query — uses SQL WHERE + LIMIT/OFFSET for efficiency. */
24
+ export function queryActivity(filters: {
25
+ entityType?: string
26
+ entityId?: string
27
+ actor?: string
28
+ action?: string
29
+ since?: number
30
+ limit?: number
31
+ offset?: number
32
+ }): unknown[] {
33
+ return perf.measureSync('repository', 'activity.query', () => queryStoredActivity(filters), {
34
+ entityType: filters.entityType ?? 'all',
35
+ })
36
+ }
@@ -66,19 +66,22 @@ function detachAgentSessions(agentId: string): number {
66
66
 
67
67
  export function listAgentsForApi(): Record<string, Agent> {
68
68
  const agents = loadAgents()
69
- const sessions = listSessions()
70
- const usage = loadUsage()
71
- const now = Date.now()
72
69
  for (const agent of Object.values(agents)) {
73
- if (
74
- (typeof agent.monthlyBudget === 'number' && agent.monthlyBudget > 0)
70
+ const hasBudget = (typeof agent.monthlyBudget === 'number' && agent.monthlyBudget > 0)
75
71
  || (typeof agent.dailyBudget === 'number' && agent.dailyBudget > 0)
76
72
  || (typeof agent.hourlyBudget === 'number' && agent.hourlyBudget > 0)
77
- ) {
78
- const spend = getAgentSpendWindows(agent.id, now, {
79
- sessions,
80
- usage,
81
- })
73
+ if (!hasBudget) continue
74
+
75
+ // Use persisted spend fields when available (push-based rollup)
76
+ if (typeof agent.lastSpendRollupAt === 'number' && agent.lastSpendRollupAt > 0) {
77
+ if (typeof agent.monthlyBudget === 'number' && agent.monthlyBudget > 0) agent.monthlySpend = (agent.spentMonthlyCents ?? 0) / 100
78
+ if (typeof agent.dailyBudget === 'number' && agent.dailyBudget > 0) agent.dailySpend = (agent.spentDailyCents ?? 0) / 100
79
+ if (typeof agent.hourlyBudget === 'number' && agent.hourlyBudget > 0) agent.hourlySpend = (agent.spentHourlyCents ?? 0) / 100
80
+ } else {
81
+ // Fallback: full scan for agents that predate the rollup system
82
+ const sessions = listSessions()
83
+ const usage = loadUsage()
84
+ const spend = getAgentSpendWindows(agent.id, Date.now(), { sessions, usage })
82
85
  if (typeof agent.monthlyBudget === 'number' && agent.monthlyBudget > 0) agent.monthlySpend = spend.monthly
83
86
  if (typeof agent.dailyBudget === 'number' && agent.dailyBudget > 0) agent.dailySpend = spend.daily
84
87
  if (typeof agent.hourlyBudget === 'number' && agent.hourlyBudget > 0) agent.hourlySpend = spend.hourly
@@ -301,7 +304,17 @@ export function updateAgent(agentId: string, body: Record<string, unknown>): Age
301
304
  updateThreadShortcutSession(agentId, updated)
302
305
  }
303
306
 
304
- logActivity({ entityType: 'agent', entityId: agentId, action: 'updated', actor: 'user', summary: `Agent updated: "${updated.name}"` })
307
+ const budgetFields = ['monthlyBudget', 'dailyBudget', 'hourlyBudget', 'budgetAction'] as const
308
+ const budgetChanges: Record<string, unknown> = {}
309
+ for (const key of budgetFields) {
310
+ if (key in body) budgetChanges[key] = body[key]
311
+ }
312
+ const detail: Record<string, unknown> = {}
313
+ if (Object.keys(budgetChanges).length > 0) detail.budgetChanges = budgetChanges
314
+ logActivity({ entityType: 'agent', entityId: agentId, action: 'updated', actor: 'user', summary: `Agent updated: "${updated.name}"`, detail: Object.keys(detail).length > 0 ? detail : undefined })
315
+ if (Object.keys(budgetChanges).length > 0) {
316
+ logActivity({ entityType: 'budget', entityId: agentId, action: 'configured', actor: 'user', summary: `Budget updated for agent "${updated.name}"`, detail: budgetChanges })
317
+ }
305
318
  return updated
306
319
  }
307
320
 
@@ -71,6 +71,20 @@ export function buildApprovalComparablePayload(
71
71
  toolName: trimString(data.toolName),
72
72
  args: canonicalizeValue(data.args),
73
73
  }
74
+ case 'agent_create':
75
+ return {
76
+ agentName: trimString(data.agentName),
77
+ provider: trimString(data.provider),
78
+ }
79
+ case 'budget_change':
80
+ return {
81
+ agentId: trimString(data.agentId),
82
+ budgetChanges: canonicalizeValue(data.budgetChanges),
83
+ }
84
+ case 'delegation_enable':
85
+ return {
86
+ agentId: trimString(data.agentId),
87
+ }
74
88
  default:
75
89
  return canonicalizeValue(data) as Record<string, unknown>
76
90
  }
@@ -0,0 +1,81 @@
1
+ import type { ApprovalRequest } from '@/types'
2
+ import { logActivity } from '@/lib/server/activity/activity-log'
3
+ import { log } from '@/lib/server/logger'
4
+
5
+ const TAG = 'approval-hooks'
6
+
7
+ type ApprovalHookHandler = (request: ApprovalRequest) => void
8
+
9
+ const approvalHandlers: Partial<Record<string, ApprovalHookHandler>> = {
10
+ agent_create: onAgentCreateDecided,
11
+ budget_change: onBudgetChangeDecided,
12
+ delegation_enable: onDelegationEnableDecided,
13
+ }
14
+
15
+ /**
16
+ * Dispatch lifecycle hooks when an approval decision is made.
17
+ * Called from submitDecision() after the approval is persisted.
18
+ */
19
+ export function onApprovalDecision(request: ApprovalRequest): void {
20
+ const handler = approvalHandlers[request.category]
21
+ if (!handler) return
22
+ try {
23
+ handler(request)
24
+ } catch (err) {
25
+ log.error(TAG, `Error in approval hook for ${request.category}: ${err}`)
26
+ }
27
+ }
28
+
29
+ function onAgentCreateDecided(request: ApprovalRequest): void {
30
+ if (request.status !== 'approved') return
31
+ const pendingConfig = request.data.pendingAgentConfig as Record<string, unknown> | undefined
32
+ if (!pendingConfig) return
33
+
34
+ // Dynamically import to avoid circular dependency
35
+ import('@/lib/server/agents/agent-service').then(({ createAgent }) => {
36
+ const agent = createAgent({ body: pendingConfig })
37
+ logActivity({
38
+ entityType: 'agent',
39
+ entityId: agent.id,
40
+ action: 'created',
41
+ actor: 'system',
42
+ summary: `Agent "${agent.name}" created after approval ${request.id}`,
43
+ detail: { approvalId: request.id },
44
+ })
45
+ }).catch((err) => {
46
+ log.error(TAG, `Failed to create agent after approval: ${err}`)
47
+ })
48
+ }
49
+
50
+ function onBudgetChangeDecided(request: ApprovalRequest): void {
51
+ if (request.status !== 'approved') return
52
+ const agentId = request.data.agentId as string | undefined
53
+ const budgetChanges = request.data.budgetChanges as Record<string, unknown> | undefined
54
+ if (!agentId || !budgetChanges) return
55
+
56
+ import('@/lib/server/agents/agent-service').then(({ updateAgent }) => {
57
+ updateAgent(agentId, budgetChanges)
58
+ logActivity({
59
+ entityType: 'budget',
60
+ entityId: agentId,
61
+ action: 'configured',
62
+ actor: 'system',
63
+ summary: `Budget updated for agent after approval ${request.id}`,
64
+ detail: { approvalId: request.id, budgetChanges },
65
+ })
66
+ }).catch((err) => {
67
+ log.error(TAG, `Failed to apply budget change after approval: ${err}`)
68
+ })
69
+ }
70
+
71
+ function onDelegationEnableDecided(request: ApprovalRequest): void {
72
+ if (request.status !== 'approved') return
73
+ const agentId = request.data.agentId as string | undefined
74
+ if (!agentId) return
75
+
76
+ import('@/lib/server/agents/agent-service').then(({ updateAgent }) => {
77
+ updateAgent(agentId, { delegationEnabled: true })
78
+ }).catch((err) => {
79
+ log.error(TAG, `Failed to enable delegation after approval: ${err}`)
80
+ })
81
+ }
@@ -5,6 +5,8 @@ import { notify } from './ws-hub'
5
5
  import { dispatchWake } from '@/lib/server/runtime/wake-dispatcher'
6
6
  import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
7
7
  import { enqueueSessionRun } from '@/lib/server/runtime/session-run-manager'
8
+ import { logActivity } from '@/lib/server/activity/activity-log'
9
+ import { onApprovalDecision } from '@/lib/server/approvals/approval-hooks'
8
10
 
9
11
  function trimToString(value: unknown): string {
10
12
  return typeof value === 'string' ? value.trim() : ''
@@ -131,6 +133,15 @@ export async function submitDecision(id: string, approved: boolean): Promise<App
131
133
  if (request.status === (approved ? 'approved' : 'rejected')) return request
132
134
  if (request.status !== 'pending') return request
133
135
  const updated = await persistApprovalDecision(request, approved)
136
+ logActivity({
137
+ entityType: 'approval',
138
+ entityId: id,
139
+ action: approved ? 'approved' : 'rejected',
140
+ actor: 'user',
141
+ summary: `Approval ${approved ? 'approved' : 'rejected'}: ${request.title}`,
142
+ detail: { category: request.category, agentId: request.agentId, sessionId: request.sessionId },
143
+ })
144
+ onApprovalDecision(updated)
134
145
  wakeForApprovalDecision(updated, approved)
135
146
  return updated
136
147
  }
@@ -100,6 +100,14 @@ export async function updateBoardTaskFromEvent(
100
100
  boardTask.status = 'failed'
101
101
  boardTask.checkoutRunId = null
102
102
  break
103
+ case 'task.review':
104
+ // Work submitted, awaiting requester review on SwarmDock
105
+ if (boardTask.externalSource) boardTask.externalSource.state = 'review'
106
+ break
107
+ case 'task.disputed':
108
+ // Task disputed on SwarmDock
109
+ if (boardTask.externalSource) boardTask.externalSource.state = 'disputed'
110
+ break
103
111
  }
104
112
 
105
113
  boardTask.updatedAt = now
@@ -35,6 +35,7 @@ interface SwarmDockConfig {
35
35
  skills: string
36
36
  autoDiscover: boolean
37
37
  maxBudget: string
38
+ paymentPrivateKey?: string
38
39
  }
39
40
 
40
41
  function parseConfig(connector: Connector): SwarmDockConfig {
@@ -46,6 +47,7 @@ function parseConfig(connector: Connector): SwarmDockConfig {
46
47
  skills: c.skills || '',
47
48
  autoDiscover: c.autoDiscover === 'true',
48
49
  maxBudget: c.maxBudget || '0',
50
+ paymentPrivateKey: c.paymentPrivateKey || undefined,
49
51
  }
50
52
  }
51
53
 
@@ -73,11 +75,13 @@ export async function submitSwarmdockTaskResult(
73
75
  client: { tasks: { submit: (taskId: string, input: TaskSubmitInput) => Promise<unknown> } },
74
76
  swarmdockTaskId: string,
75
77
  text: string,
78
+ notes?: string,
76
79
  ): Promise<void> {
77
80
  const payload: TaskSubmitInput = {
78
81
  artifacts: [{ type: 'text/markdown', content: text }],
79
82
  files: [],
80
83
  }
84
+ if (notes) payload.notes = notes
81
85
  await client.tasks.submit(swarmdockTaskId, payload)
82
86
  }
83
87
 
@@ -106,6 +110,9 @@ const swarmdock: PlatformConnector = {
106
110
  const client = new SwarmDockClient({
107
111
  baseUrl: config.apiUrl,
108
112
  privateKey,
113
+ ...(config.paymentPrivateKey?.startsWith('0x')
114
+ ? { paymentPrivateKey: config.paymentPrivateKey as `0x${string}` }
115
+ : {}),
109
116
  })
110
117
 
111
118
  // Register agent on SwarmDock (Ed25519 challenge-response)
@@ -119,6 +126,8 @@ const swarmdock: PlatformConnector = {
119
126
  description: `${skillId} capability`,
120
127
  category: skillId,
121
128
  basePrice: '1000000', // $1.00 default
129
+ inputModes: ['text'],
130
+ outputModes: ['text'],
122
131
  }))
123
132
 
124
133
  log.info(TAG, `Registering agent "${connector.name}" on SwarmDock at ${config.apiUrl}`)
@@ -195,6 +204,27 @@ const swarmdock: PlatformConnector = {
195
204
  break
196
205
  }
197
206
 
207
+ case 'task.review': {
208
+ const taskId = (event.data as Record<string, string>).taskId
209
+ if (taskId) await updateBoardTaskFromEvent(taskId, 'task.review')
210
+ break
211
+ }
212
+
213
+ case 'task.disputed': {
214
+ const taskId = (event.data as Record<string, string>).taskId
215
+ if (taskId) {
216
+ await updateBoardTaskFromEvent(taskId, 'task.disputed')
217
+ logActivity({
218
+ entityType: 'connector',
219
+ entityId: connectorId,
220
+ action: 'incident',
221
+ actor: 'system',
222
+ summary: `SwarmDock task ${taskId} disputed`,
223
+ })
224
+ }
225
+ break
226
+ }
227
+
198
228
  case 'payment.released': {
199
229
  const data = event.data as Record<string, string>
200
230
  logActivity({