@swarmclawai/swarmclaw 0.6.8 → 0.7.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 (166) hide show
  1. package/README.md +70 -45
  2. package/next.config.ts +31 -6
  3. package/package.json +3 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +1 -0
  5. package/src/app/api/agents/route.ts +18 -5
  6. package/src/app/api/approvals/route.ts +22 -0
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
  9. package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
  10. package/src/app/api/memory/route.ts +36 -5
  11. package/src/app/api/notifications/route.ts +3 -0
  12. package/src/app/api/plugins/install/route.ts +57 -5
  13. package/src/app/api/plugins/marketplace/route.ts +73 -22
  14. package/src/app/api/plugins/route.ts +61 -1
  15. package/src/app/api/plugins/ui/route.ts +34 -0
  16. package/src/app/api/settings/route.ts +62 -0
  17. package/src/app/api/setup/doctor/route.ts +22 -5
  18. package/src/app/api/tasks/[id]/approve/route.ts +4 -3
  19. package/src/app/api/tasks/[id]/route.ts +11 -3
  20. package/src/app/api/tasks/route.ts +8 -2
  21. package/src/app/globals.css +27 -0
  22. package/src/app/page.tsx +10 -5
  23. package/src/cli/index.js +13 -0
  24. package/src/components/activity/activity-feed.tsx +9 -2
  25. package/src/components/agents/agent-avatar.tsx +5 -1
  26. package/src/components/agents/agent-card.tsx +55 -9
  27. package/src/components/agents/agent-sheet.tsx +86 -29
  28. package/src/components/agents/inspector-panel.tsx +1 -1
  29. package/src/components/auth/access-key-gate.tsx +63 -54
  30. package/src/components/auth/user-picker.tsx +37 -32
  31. package/src/components/chat/chat-area.tsx +11 -0
  32. package/src/components/chat/chat-header.tsx +69 -25
  33. package/src/components/chat/chat-tool-toggles.tsx +2 -2
  34. package/src/components/chat/code-block.tsx +3 -1
  35. package/src/components/chat/exec-approval-card.tsx +8 -1
  36. package/src/components/chat/message-bubble.tsx +164 -4
  37. package/src/components/chat/message-list.tsx +30 -4
  38. package/src/components/chat/session-approval-card.tsx +80 -0
  39. package/src/components/chat/streaming-bubble.tsx +6 -5
  40. package/src/components/chat/thinking-indicator.tsx +48 -12
  41. package/src/components/chat/tool-request-banner.tsx +39 -20
  42. package/src/components/chatrooms/chatroom-list.tsx +11 -4
  43. package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
  44. package/src/components/connectors/connector-list.tsx +33 -11
  45. package/src/components/connectors/connector-sheet.tsx +29 -6
  46. package/src/components/home/home-view.tsx +20 -14
  47. package/src/components/input/chat-input.tsx +22 -1
  48. package/src/components/knowledge/knowledge-list.tsx +17 -18
  49. package/src/components/knowledge/knowledge-sheet.tsx +9 -5
  50. package/src/components/layout/app-layout.tsx +73 -21
  51. package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
  52. package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
  53. package/src/components/memory/memory-list.tsx +20 -13
  54. package/src/components/plugins/plugin-list.tsx +213 -59
  55. package/src/components/plugins/plugin-sheet.tsx +119 -24
  56. package/src/components/projects/project-list.tsx +17 -9
  57. package/src/components/providers/provider-list.tsx +21 -6
  58. package/src/components/providers/provider-sheet.tsx +42 -25
  59. package/src/components/runs/run-list.tsx +17 -13
  60. package/src/components/schedules/schedule-card.tsx +10 -3
  61. package/src/components/schedules/schedule-list.tsx +2 -2
  62. package/src/components/schedules/schedule-sheet.tsx +19 -7
  63. package/src/components/secrets/secret-sheet.tsx +7 -2
  64. package/src/components/secrets/secrets-list.tsx +18 -5
  65. package/src/components/sessions/new-session-sheet.tsx +183 -376
  66. package/src/components/sessions/session-card.tsx +10 -2
  67. package/src/components/settings/gateway-connection-panel.tsx +9 -8
  68. package/src/components/shared/command-palette.tsx +13 -5
  69. package/src/components/shared/empty-state.tsx +20 -8
  70. package/src/components/shared/notification-center.tsx +134 -86
  71. package/src/components/shared/profile-sheet.tsx +4 -0
  72. package/src/components/shared/settings/plugin-manager.tsx +360 -135
  73. package/src/components/shared/settings/section-capability-policy.tsx +3 -3
  74. package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
  75. package/src/components/skills/clawhub-browser.tsx +1 -0
  76. package/src/components/skills/skill-list.tsx +31 -12
  77. package/src/components/skills/skill-sheet.tsx +20 -7
  78. package/src/components/tasks/approvals-panel.tsx +170 -66
  79. package/src/components/tasks/task-board.tsx +20 -12
  80. package/src/components/tasks/task-card.tsx +21 -7
  81. package/src/components/tasks/task-column.tsx +4 -3
  82. package/src/components/tasks/task-list.tsx +1 -1
  83. package/src/components/tasks/task-sheet.tsx +130 -1
  84. package/src/components/ui/dialog.tsx +1 -0
  85. package/src/components/ui/sheet.tsx +1 -0
  86. package/src/components/usage/metrics-dashboard.tsx +66 -64
  87. package/src/components/wallets/wallet-panel.tsx +65 -41
  88. package/src/components/wallets/wallet-section.tsx +9 -3
  89. package/src/components/webhooks/webhook-list.tsx +21 -12
  90. package/src/components/webhooks/webhook-sheet.tsx +13 -3
  91. package/src/lib/approval-display.test.ts +45 -0
  92. package/src/lib/approval-display.ts +62 -0
  93. package/src/lib/clipboard.ts +38 -0
  94. package/src/lib/memory.ts +8 -0
  95. package/src/lib/providers/claude-cli.ts +5 -3
  96. package/src/lib/providers/index.ts +67 -21
  97. package/src/lib/runtime-loop.ts +3 -2
  98. package/src/lib/server/approvals.ts +150 -0
  99. package/src/lib/server/chat-execution.ts +223 -62
  100. package/src/lib/server/clawhub-client.ts +82 -6
  101. package/src/lib/server/connectors/manager.ts +27 -1
  102. package/src/lib/server/cost.test.ts +73 -0
  103. package/src/lib/server/cost.ts +165 -34
  104. package/src/lib/server/daemon-state.ts +42 -0
  105. package/src/lib/server/data-dir.ts +18 -1
  106. package/src/lib/server/integrity-monitor.ts +208 -0
  107. package/src/lib/server/llm-response-cache.test.ts +102 -0
  108. package/src/lib/server/llm-response-cache.ts +227 -0
  109. package/src/lib/server/main-agent-loop.ts +1 -1
  110. package/src/lib/server/main-session.ts +6 -3
  111. package/src/lib/server/mcp-conformance.test.ts +18 -0
  112. package/src/lib/server/mcp-conformance.ts +233 -0
  113. package/src/lib/server/memory-db.ts +180 -17
  114. package/src/lib/server/memory-retrieval.test.ts +56 -0
  115. package/src/lib/server/orchestrator-lg.ts +4 -1
  116. package/src/lib/server/orchestrator.ts +4 -3
  117. package/src/lib/server/plugins.ts +650 -142
  118. package/src/lib/server/process-manager.ts +18 -0
  119. package/src/lib/server/queue.ts +253 -11
  120. package/src/lib/server/runtime-settings.ts +9 -0
  121. package/src/lib/server/session-run-manager.test.ts +23 -0
  122. package/src/lib/server/session-run-manager.ts +11 -1
  123. package/src/lib/server/session-tools/canvas.ts +85 -50
  124. package/src/lib/server/session-tools/chatroom.ts +130 -127
  125. package/src/lib/server/session-tools/connector.ts +233 -454
  126. package/src/lib/server/session-tools/context-mgmt.ts +87 -105
  127. package/src/lib/server/session-tools/crud.ts +84 -7
  128. package/src/lib/server/session-tools/delegate.ts +351 -752
  129. package/src/lib/server/session-tools/discovery.ts +198 -0
  130. package/src/lib/server/session-tools/edit_file.ts +82 -0
  131. package/src/lib/server/session-tools/file-send.test.ts +39 -0
  132. package/src/lib/server/session-tools/file.ts +257 -425
  133. package/src/lib/server/session-tools/git.ts +87 -47
  134. package/src/lib/server/session-tools/http.ts +85 -33
  135. package/src/lib/server/session-tools/index.ts +205 -160
  136. package/src/lib/server/session-tools/memory.ts +152 -265
  137. package/src/lib/server/session-tools/monitor.ts +126 -0
  138. package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
  139. package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
  140. package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
  141. package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
  142. package/src/lib/server/session-tools/platform.ts +86 -0
  143. package/src/lib/server/session-tools/plugin-creator.ts +239 -0
  144. package/src/lib/server/session-tools/sample-ui.ts +97 -0
  145. package/src/lib/server/session-tools/sandbox.ts +175 -148
  146. package/src/lib/server/session-tools/schedule.ts +66 -31
  147. package/src/lib/server/session-tools/session-info.ts +104 -410
  148. package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
  149. package/src/lib/server/session-tools/shell.ts +171 -143
  150. package/src/lib/server/session-tools/subagent.ts +77 -77
  151. package/src/lib/server/session-tools/wallet.ts +182 -106
  152. package/src/lib/server/session-tools/web.ts +179 -349
  153. package/src/lib/server/storage.ts +24 -0
  154. package/src/lib/server/stream-agent-chat.ts +301 -244
  155. package/src/lib/server/task-quality-gate.test.ts +44 -0
  156. package/src/lib/server/task-quality-gate.ts +67 -0
  157. package/src/lib/server/task-validation.test.ts +78 -0
  158. package/src/lib/server/task-validation.ts +67 -2
  159. package/src/lib/server/tool-aliases.ts +68 -0
  160. package/src/lib/server/tool-capability-policy.ts +23 -5
  161. package/src/lib/tasks.ts +7 -1
  162. package/src/lib/tool-definitions.ts +23 -23
  163. package/src/lib/validation/schemas.ts +12 -0
  164. package/src/lib/view-routes.ts +2 -24
  165. package/src/stores/use-app-store.ts +23 -1
  166. package/src/types/index.ts +121 -7
@@ -0,0 +1,73 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import type { Agent } from '@/types'
4
+ import { checkAgentBudgetLimits, getAgentSpendWindows } from './cost.ts'
5
+
6
+ function buildNowTs(): number {
7
+ const d = new Date()
8
+ d.setFullYear(2026, 2, 15)
9
+ d.setHours(12, 0, 0, 0)
10
+ return d.getTime()
11
+ }
12
+
13
+ test('getAgentSpendWindows aggregates hourly/daily/monthly windows', () => {
14
+ const now = buildNowTs()
15
+ const previousMonth = new Date(2026, 1, 20, 12, 0, 0, 0).getTime()
16
+
17
+ const sessions = {
18
+ s1: { agentId: 'agent-a' },
19
+ s2: { agentId: 'agent-b' },
20
+ }
21
+ const usage = {
22
+ s1: [
23
+ { timestamp: now - 20 * 60_000, estimatedCost: 1.25 }, // within hour
24
+ { timestamp: now - 3 * 60 * 60_000, estimatedCost: 0.5 }, // today
25
+ { timestamp: now - 26 * 60 * 60_000, estimatedCost: 2.0 }, // yesterday
26
+ { timestamp: previousMonth, estimatedCost: 4.0 }, // previous month
27
+ ],
28
+ s2: [
29
+ { timestamp: now - 5 * 60_000, estimatedCost: 99 }, // different agent
30
+ ],
31
+ }
32
+
33
+ const spend = getAgentSpendWindows('agent-a', now, { sessions, usage })
34
+ assert.equal(spend.hourly, 1.25)
35
+ assert.equal(spend.daily, 1.75)
36
+ assert.equal(spend.monthly, 3.75)
37
+ })
38
+
39
+ test('checkAgentBudgetLimits reports exceeded and warning windows', () => {
40
+ const now = buildNowTs()
41
+ const sessions = { s1: { agentId: 'agent-a' } }
42
+ const usage = {
43
+ s1: [
44
+ { timestamp: now - 15 * 60_000, estimatedCost: 1.25 }, // hourly over
45
+ { timestamp: now - 4 * 60 * 60_000, estimatedCost: 0.5 }, // daily near
46
+ { timestamp: now - 26 * 60 * 60_000, estimatedCost: 2.0 }, // monthly near
47
+ ],
48
+ }
49
+ const agent = {
50
+ id: 'agent-a',
51
+ name: 'Agent A',
52
+ hourlyBudget: 1.0,
53
+ dailyBudget: 2.0,
54
+ monthlyBudget: 4.0,
55
+ } as Agent
56
+
57
+ const result = checkAgentBudgetLimits(agent, now, { sessions, usage })
58
+ assert.equal(result.ok, false)
59
+ assert.deepEqual(result.exceeded.map((x) => x.window), ['hourly'])
60
+ assert.deepEqual(result.warnings.map((x) => x.window), ['daily', 'monthly'])
61
+ })
62
+
63
+ test('checkAgentBudgetLimits is ok when no caps are configured', () => {
64
+ const now = buildNowTs()
65
+ const sessions = { s1: { agentId: 'agent-a' } }
66
+ const usage = { s1: [{ timestamp: now - 10 * 60_000, estimatedCost: 10 }] }
67
+ const agent = { id: 'agent-a', name: 'Agent A' } as Agent
68
+
69
+ const result = checkAgentBudgetLimits(agent, now, { sessions, usage })
70
+ assert.equal(result.ok, true)
71
+ assert.equal(result.exceeded.length, 0)
72
+ assert.equal(result.warnings.length, 0)
73
+ })
@@ -1,3 +1,6 @@
1
+ import type { Agent, UsageRecord } from '@/types'
2
+ import { loadSessions, loadUsage } from './storage'
3
+
1
4
  // Model cost table: [inputCostPer1M, outputCostPer1M] in USD
2
5
  const MODEL_COSTS: Record<string, [number, number]> = {
3
6
  // Anthropic
@@ -11,7 +14,7 @@ const MODEL_COSTS: Record<string, [number, number]> = {
11
14
  'gpt-4.1': [2, 8],
12
15
  'gpt-4.1-mini': [0.4, 1.6],
13
16
  'gpt-4.1-nano': [0.1, 0.4],
14
- 'o3': [10, 40],
17
+ o3: [10, 40],
15
18
  'o3-mini': [1.1, 4.4],
16
19
  'o4-mini': [1.1, 4.4],
17
20
  // OpenAI embeddings
@@ -19,6 +22,38 @@ const MODEL_COSTS: Record<string, [number, number]> = {
19
22
  'text-embedding-3-large': [0.13, 0],
20
23
  }
21
24
 
25
+ const ONE_HOUR_MS = 60 * 60 * 1000
26
+ const WARNING_RATIO = 0.8
27
+
28
+ type GenericRecord = Record<string, unknown>
29
+ type SessionsMap = Record<string, GenericRecord>
30
+ type UsageMap = Record<string, unknown>
31
+
32
+ function parsePositiveBudget(value: unknown): number | null {
33
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null
34
+ return value
35
+ }
36
+
37
+ function toDateBoundaries(now: number) {
38
+ const d = new Date(now)
39
+ const dayStart = new Date(d)
40
+ dayStart.setHours(0, 0, 0, 0)
41
+ const monthStart = new Date(d.getFullYear(), d.getMonth(), 1)
42
+ return {
43
+ hourStartTs: now - ONE_HOUR_MS,
44
+ dayStartTs: dayStart.getTime(),
45
+ monthStartTs: monthStart.getTime(),
46
+ }
47
+ }
48
+
49
+ function getAgentSessionIds(agentId: string, sessions: SessionsMap): Set<string> {
50
+ const ids = new Set<string>()
51
+ for (const [sid, session] of Object.entries(sessions)) {
52
+ if (session?.agentId === agentId) ids.add(sid)
53
+ }
54
+ return ids
55
+ }
56
+
22
57
  export function estimateCost(model: string, inputTokens: number, outputTokens: number): number {
23
58
  const costs = MODEL_COSTS[model]
24
59
  if (!costs) return 0
@@ -30,41 +65,141 @@ export function getModelCosts(): Record<string, [number, number]> {
30
65
  return { ...MODEL_COSTS }
31
66
  }
32
67
 
33
- // --- Agent Monthly Budget ---
34
-
35
- import { loadUsage, loadSessions } from './storage'
36
- import type { Agent, UsageRecord } from '@/types'
68
+ export interface AgentSpendWindows {
69
+ hourly: number
70
+ daily: number
71
+ monthly: number
72
+ }
37
73
 
38
- /**
39
- * Sum the estimated cost for an agent in the current calendar month.
40
- * Usage records are keyed by sessionId; we resolve agentId through sessions.
41
- */
42
- export function getAgentMonthlySpend(agentId: string): number {
43
- const sessions = loadSessions()
44
- // Build a set of sessionIds linked to this agent
45
- const agentSessionIds = new Set<string>()
46
- for (const [sid, session] of Object.entries(sessions)) {
47
- if (session?.agentId === agentId) agentSessionIds.add(sid)
74
+ export function getAgentSpendWindows(
75
+ agentId: string,
76
+ now = Date.now(),
77
+ opts?: { sessions?: SessionsMap; usage?: UsageMap },
78
+ ): AgentSpendWindows {
79
+ const sessions = opts?.sessions ?? (loadSessions() as SessionsMap)
80
+ const usage = opts?.usage ?? (loadUsage() as UsageMap)
81
+ const agentSessionIds = getAgentSessionIds(agentId, sessions)
82
+ if (agentSessionIds.size === 0) {
83
+ return { hourly: 0, daily: 0, monthly: 0 }
48
84
  }
49
- if (agentSessionIds.size === 0) return 0
50
85
 
51
- const now = new Date()
52
- const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).getTime()
86
+ const { hourStartTs, dayStartTs, monthStartTs } = toDateBoundaries(now)
87
+ const spend: AgentSpendWindows = { hourly: 0, daily: 0, monthly: 0 }
53
88
 
54
- const usage = loadUsage()
55
- let total = 0
56
89
  for (const sid of agentSessionIds) {
57
- const records = usage[sid]
58
- if (!Array.isArray(records)) continue
59
- for (const record of records) {
90
+ const raw = usage[sid]
91
+ if (!Array.isArray(raw)) continue
92
+ for (const record of raw) {
60
93
  const r = record as UsageRecord
61
- if (typeof r.timestamp !== 'number' || r.timestamp < monthStart) continue
62
- if (typeof r.estimatedCost === 'number' && Number.isFinite(r.estimatedCost) && r.estimatedCost > 0) {
63
- total += r.estimatedCost
64
- }
94
+ const ts = typeof r?.timestamp === 'number' ? r.timestamp : 0
95
+ if (ts <= 0) continue
96
+ const cost = typeof r?.estimatedCost === 'number' ? r.estimatedCost : 0
97
+ if (!Number.isFinite(cost) || cost <= 0) continue
98
+
99
+ if (ts >= monthStartTs) spend.monthly += cost
100
+ if (ts >= dayStartTs) spend.daily += cost
101
+ if (ts >= hourStartTs) spend.hourly += cost
65
102
  }
66
103
  }
67
- return total
104
+
105
+ return spend
106
+ }
107
+
108
+ export function getAgentMonthlySpend(
109
+ agentId: string,
110
+ now = Date.now(),
111
+ opts?: { sessions?: SessionsMap; usage?: UsageMap },
112
+ ): number {
113
+ return getAgentSpendWindows(agentId, now, opts).monthly
114
+ }
115
+
116
+ export function getAgentDailySpend(
117
+ agentId: string,
118
+ now = Date.now(),
119
+ opts?: { sessions?: SessionsMap; usage?: UsageMap },
120
+ ): number {
121
+ return getAgentSpendWindows(agentId, now, opts).daily
122
+ }
123
+
124
+ export function getAgentHourlySpend(
125
+ agentId: string,
126
+ now = Date.now(),
127
+ opts?: { sessions?: SessionsMap; usage?: UsageMap },
128
+ ): number {
129
+ return getAgentSpendWindows(agentId, now, opts).hourly
130
+ }
131
+
132
+ export type AgentBudgetWindow = 'hourly' | 'daily' | 'monthly'
133
+
134
+ export interface AgentBudgetStatus {
135
+ window: AgentBudgetWindow
136
+ spend: number
137
+ budget: number
138
+ ratio: number
139
+ message: string
140
+ }
141
+
142
+ export interface AgentBudgetCheckSummary {
143
+ ok: boolean
144
+ spend: AgentSpendWindows
145
+ exceeded: AgentBudgetStatus[]
146
+ warnings: AgentBudgetStatus[]
147
+ }
148
+
149
+ function budgetWindowLabel(window: AgentBudgetWindow): string {
150
+ if (window === 'hourly') return 'hourly'
151
+ if (window === 'daily') return 'daily'
152
+ return 'monthly'
153
+ }
154
+
155
+ function buildBudgetStatus(
156
+ agentName: string,
157
+ window: AgentBudgetWindow,
158
+ spend: number,
159
+ budget: number,
160
+ exceeded: boolean,
161
+ ): AgentBudgetStatus {
162
+ const ratio = budget > 0 ? spend / budget : 0
163
+ const label = budgetWindowLabel(window)
164
+ const message = exceeded
165
+ ? `Agent "${agentName}" has reached its ${label} budget: $${spend.toFixed(4)} spent of $${budget.toFixed(2)} cap.`
166
+ : `Agent "${agentName}" is nearing its ${label} budget: $${spend.toFixed(4)} of $${budget.toFixed(2)} (${Math.round(ratio * 100)}%).`
167
+ return { window, spend, budget, ratio, message }
168
+ }
169
+
170
+ export function checkAgentBudgetLimits(
171
+ agent: Agent,
172
+ now = Date.now(),
173
+ opts?: { sessions?: SessionsMap; usage?: UsageMap },
174
+ ): AgentBudgetCheckSummary {
175
+ const budgets: Partial<Record<AgentBudgetWindow, number>> = {
176
+ hourly: parsePositiveBudget(agent.hourlyBudget) ?? undefined,
177
+ daily: parsePositiveBudget(agent.dailyBudget) ?? undefined,
178
+ monthly: parsePositiveBudget(agent.monthlyBudget) ?? undefined,
179
+ }
180
+ const spend = getAgentSpendWindows(agent.id, now, opts)
181
+ const exceeded: AgentBudgetStatus[] = []
182
+ const warnings: AgentBudgetStatus[] = []
183
+
184
+ for (const window of ['hourly', 'daily', 'monthly'] as const) {
185
+ const budget = budgets[window]
186
+ if (!budget) continue
187
+ const windowSpend = spend[window]
188
+ if (windowSpend >= budget) {
189
+ exceeded.push(buildBudgetStatus(agent.name, window, windowSpend, budget, true))
190
+ continue
191
+ }
192
+ if (windowSpend >= budget * WARNING_RATIO) {
193
+ warnings.push(buildBudgetStatus(agent.name, window, windowSpend, budget, false))
194
+ }
195
+ }
196
+
197
+ return {
198
+ ok: exceeded.length === 0,
199
+ spend,
200
+ exceeded,
201
+ warnings,
202
+ }
68
203
  }
69
204
 
70
205
  export interface BudgetCheckResult {
@@ -75,14 +210,10 @@ export interface BudgetCheckResult {
75
210
  }
76
211
 
77
212
  /**
78
- * Check whether an agent is within its monthly budget.
79
- * Returns ok: true if no budget is set or spend is under the cap.
213
+ * Backwards-compatible monthly-only budget check.
80
214
  */
81
215
  export function checkBudget(agent: Agent): BudgetCheckResult {
82
- const budget = typeof agent.monthlyBudget === 'number' && Number.isFinite(agent.monthlyBudget) && agent.monthlyBudget > 0
83
- ? agent.monthlyBudget
84
- : 0
85
-
216
+ const budget = parsePositiveBudget(agent.monthlyBudget) ?? 0
86
217
  if (budget <= 0) {
87
218
  return { ok: true, spend: 0, budget: 0 }
88
219
  }
@@ -17,9 +17,11 @@ import { hasOpenClawAgents, ensureGatewayConnected, disconnectGateway, getGatewa
17
17
  import { enqueueSessionRun } from './session-run-manager'
18
18
  import { WORKSPACE_DIR } from './data-dir'
19
19
  import { genId } from '@/lib/id'
20
+ import path from 'node:path'
20
21
  import type { WebhookRetryEntry } from '@/types'
21
22
  import { createNotification } from '@/lib/server/create-notification'
22
23
  import { pingProvider, OPENAI_COMPATIBLE_DEFAULTS } from '@/lib/server/provider-health'
24
+ import { runIntegrityMonitor } from '@/lib/server/integrity-monitor'
23
25
 
24
26
  const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
25
27
  const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
@@ -88,6 +90,8 @@ const ds: {
88
90
  openclawDownAgentIds: Set<string>
89
91
  /** Per-agent auto-repair state for OpenClaw gateways. */
90
92
  openclawRepairState: Map<string, { attempts: number; lastAttemptAt: number; cooldownUntil: number }>
93
+ lastIntegrityCheckAt: number | null
94
+ lastIntegrityDriftCount: number
91
95
  manualStopRequested: boolean
92
96
  running: boolean
93
97
  lastProcessedAt: number | null
@@ -103,6 +107,8 @@ const ds: {
103
107
  connectorRestartState: new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>(),
104
108
  openclawDownAgentIds: new Set<string>(),
105
109
  openclawRepairState: new Map<string, { attempts: number; lastAttemptAt: number; cooldownUntil: number }>(),
110
+ lastIntegrityCheckAt: null,
111
+ lastIntegrityDriftCount: 0,
106
112
  manualStopRequested: false,
107
113
  running: false,
108
114
  lastProcessedAt: null,
@@ -113,6 +119,8 @@ if (!ds.staleSessionIds) ds.staleSessionIds = new Set<string>()
113
119
  if (!ds.connectorRestartState) ds.connectorRestartState = new Map<string, { lastAttemptAt: number; failCount: number; wakeAttempts: number }>()
114
120
  if (!ds.openclawDownAgentIds) ds.openclawDownAgentIds = new Set<string>()
115
121
  if (!ds.openclawRepairState) ds.openclawRepairState = new Map<string, { attempts: number; lastAttemptAt: number; cooldownUntil: number }>()
122
+ if (ds.lastIntegrityCheckAt === undefined) ds.lastIntegrityCheckAt = null
123
+ if (ds.lastIntegrityDriftCount === undefined) ds.lastIntegrityDriftCount = 0
116
124
  // Migrate from old issueLastAlertAt map if present (HMR across code versions)
117
125
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
118
126
  if ((ds as any).issueLastAlertAt) delete (ds as any).issueLastAlertAt
@@ -731,6 +739,35 @@ async function runHealthChecks() {
731
739
  console.error('[daemon] OpenClaw gateway health check failed:', err instanceof Error ? err.message : String(err))
732
740
  }
733
741
 
742
+ // Integrity drift monitoring for identity/config/plugin files.
743
+ try {
744
+ const integrity = runIntegrityMonitor(loadSettings())
745
+ ds.lastIntegrityCheckAt = integrity.checkedAt
746
+ ds.lastIntegrityDriftCount = integrity.drifts.length
747
+ if (integrity.drifts.length > 0) {
748
+ for (const drift of integrity.drifts) {
749
+ const rel = path.relative(process.cwd(), drift.filePath)
750
+ const shortPath = rel && !rel.startsWith('..') ? rel : drift.filePath
751
+ const action = drift.type === 'created'
752
+ ? 'created'
753
+ : drift.type === 'deleted'
754
+ ? 'deleted'
755
+ : 'modified'
756
+ createNotification({
757
+ type: drift.type === 'deleted' ? 'error' : 'warning',
758
+ title: `Integrity drift detected (${drift.kind})`,
759
+ message: `${shortPath} was ${action}.`,
760
+ dedupKey: `integrity:${drift.id}:${drift.nextHash || 'missing'}`,
761
+ entityType: 'session',
762
+ entityId: drift.id,
763
+ })
764
+ }
765
+ await sendHealthAlert(`Integrity monitor detected ${integrity.drifts.length} file drift event(s).`)
766
+ }
767
+ } catch (err: unknown) {
768
+ console.error('[daemon] Integrity monitor check failed:', err instanceof Error ? err.message : String(err))
769
+ }
770
+
734
771
  // Process webhook retry queue
735
772
  try {
736
773
  await processWebhookRetries()
@@ -892,6 +929,11 @@ export function getDaemonStatus() {
892
929
  staleSessions: ds.staleSessionIds.size,
893
930
  connectorsInBackoff: ds.connectorRestartState.size,
894
931
  checkIntervalSec: Math.trunc(HEALTH_CHECK_INTERVAL / 1000),
932
+ integrity: {
933
+ enabled: loadSettings().integrityMonitorEnabled !== false,
934
+ lastCheckedAt: ds.lastIntegrityCheckAt,
935
+ lastDriftCount: ds.lastIntegrityDriftCount,
936
+ },
895
937
  },
896
938
  webhookRetry: {
897
939
  pendingRetries,
@@ -1,4 +1,21 @@
1
1
  import path from 'path'
2
+ import os from 'os'
3
+ import fs from 'fs'
2
4
 
3
5
  export const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'data')
4
- export const WORKSPACE_DIR = path.join(DATA_DIR, 'workspace')
6
+
7
+ // Workspace lives outside the project directory to avoid triggering Next.js HMR
8
+ // when agents create/modify files. Falls back to data/workspace for Docker/CI.
9
+ function resolveWorkspaceDir(): string {
10
+ if (process.env.WORKSPACE_DIR) return process.env.WORKSPACE_DIR
11
+ const external = path.join(os.homedir(), '.swarmclaw', 'workspace')
12
+ try {
13
+ fs.mkdirSync(external, { recursive: true })
14
+ return external
15
+ } catch {
16
+ // If we can't create the external dir (permissions, etc.), fall back to in-project
17
+ return path.join(DATA_DIR, 'workspace')
18
+ }
19
+ }
20
+
21
+ export const WORKSPACE_DIR = resolveWorkspaceDir()
@@ -0,0 +1,208 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { DATA_DIR } from './data-dir'
5
+ import { resolveOpenClawWorkspace } from './openclaw-sync'
6
+ import { loadIntegrityBaselines, saveIntegrityBaselines } from './storage'
7
+
8
+ export interface IntegrityBaselineEntry {
9
+ id: string
10
+ filePath: string
11
+ kind: 'identity' | 'config' | 'plugin'
12
+ present: boolean
13
+ hash: string | null
14
+ size: number | null
15
+ mtimeMs: number | null
16
+ updatedAt: number
17
+ }
18
+
19
+ export interface IntegrityDrift {
20
+ id: string
21
+ filePath: string
22
+ kind: IntegrityBaselineEntry['kind']
23
+ type: 'created' | 'modified' | 'deleted'
24
+ previousHash: string | null
25
+ nextHash: string | null
26
+ checkedAt: number
27
+ }
28
+
29
+ export interface IntegrityMonitorResult {
30
+ enabled: boolean
31
+ checkedAt: number
32
+ checkedFiles: number
33
+ drifts: IntegrityDrift[]
34
+ }
35
+
36
+ interface WatchTarget {
37
+ id: string
38
+ filePath: string
39
+ kind: IntegrityBaselineEntry['kind']
40
+ }
41
+
42
+ function parseBool(value: unknown, fallback: boolean): boolean {
43
+ if (typeof value === 'boolean') return value
44
+ if (typeof value === 'string') {
45
+ const normalized = value.trim().toLowerCase()
46
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
47
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
48
+ }
49
+ return fallback
50
+ }
51
+
52
+ function fileHash(filePath: string): string {
53
+ const hasher = crypto.createHash('sha256')
54
+ const content = fs.readFileSync(filePath)
55
+ hasher.update(content)
56
+ return hasher.digest('hex')
57
+ }
58
+
59
+ function safeStat(filePath: string): fs.Stats | null {
60
+ try {
61
+ return fs.statSync(filePath)
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ function toId(filePath: string): string {
68
+ return crypto.createHash('sha1').update(path.resolve(filePath)).digest('hex')
69
+ }
70
+
71
+ function pushIfExists(targets: WatchTarget[], filePath: string, kind: WatchTarget['kind']): void {
72
+ if (!fs.existsSync(filePath)) return
73
+ targets.push({
74
+ id: toId(filePath),
75
+ filePath: path.resolve(filePath),
76
+ kind,
77
+ })
78
+ }
79
+
80
+ function collectWatchTargets(): WatchTarget[] {
81
+ const targets: WatchTarget[] = []
82
+ const cwd = process.cwd()
83
+
84
+ // Core workspace identity/config files.
85
+ pushIfExists(targets, path.join(cwd, 'AGENTS.md'), 'identity')
86
+ pushIfExists(targets, path.join(cwd, 'SOUL.md'), 'identity')
87
+ pushIfExists(targets, path.join(cwd, 'IDENTITY.md'), 'identity')
88
+ pushIfExists(targets, path.join(cwd, '.env.local'), 'config')
89
+
90
+ // Repo-level AGENTS.md (one level above app dir when present).
91
+ pushIfExists(targets, path.resolve(cwd, '..', 'AGENTS.md'), 'identity')
92
+
93
+ // Plugin files + plugin config.
94
+ pushIfExists(targets, path.join(DATA_DIR, 'plugins.json'), 'config')
95
+ const pluginDir = path.join(DATA_DIR, 'plugins')
96
+ if (fs.existsSync(pluginDir)) {
97
+ for (const entry of fs.readdirSync(pluginDir)) {
98
+ if (!entry.endsWith('.js') && !entry.endsWith('.mjs') && !entry.endsWith('.cjs')) continue
99
+ pushIfExists(targets, path.join(pluginDir, entry), 'plugin')
100
+ }
101
+ }
102
+
103
+ // OpenClaw agent identity files.
104
+ try {
105
+ const workspace = resolveOpenClawWorkspace()
106
+ const agentsDir = path.join(workspace, 'agents')
107
+ if (fs.existsSync(agentsDir)) {
108
+ for (const agentDirName of fs.readdirSync(agentsDir)) {
109
+ const dirPath = path.join(agentsDir, agentDirName)
110
+ if (!safeStat(dirPath)?.isDirectory()) continue
111
+ pushIfExists(targets, path.join(dirPath, 'SOUL.md'), 'identity')
112
+ pushIfExists(targets, path.join(dirPath, 'IDENTITY.md'), 'identity')
113
+ pushIfExists(targets, path.join(dirPath, 'TOOLS.md'), 'identity')
114
+ pushIfExists(targets, path.join(dirPath, 'AGENTS.md'), 'identity')
115
+ }
116
+ }
117
+ } catch {
118
+ // OpenClaw workspace is optional.
119
+ }
120
+
121
+ // Deduplicate path collisions.
122
+ const seen = new Set<string>()
123
+ return targets.filter((target) => {
124
+ if (seen.has(target.id)) return false
125
+ seen.add(target.id)
126
+ return true
127
+ })
128
+ }
129
+
130
+ function toBaseline(target: WatchTarget, checkedAt: number): IntegrityBaselineEntry {
131
+ const stat = safeStat(target.filePath)
132
+ const present = !!stat && stat.isFile()
133
+ return {
134
+ id: target.id,
135
+ filePath: target.filePath,
136
+ kind: target.kind,
137
+ present,
138
+ hash: present ? fileHash(target.filePath) : null,
139
+ size: present ? stat!.size : null,
140
+ mtimeMs: present ? Math.trunc(stat!.mtimeMs) : null,
141
+ updatedAt: checkedAt,
142
+ }
143
+ }
144
+
145
+ export function runIntegrityMonitor(settings?: Record<string, unknown> | null): IntegrityMonitorResult {
146
+ const enabled = parseBool(settings?.integrityMonitorEnabled, true)
147
+ const checkedAt = Date.now()
148
+ if (!enabled) {
149
+ return {
150
+ enabled: false,
151
+ checkedAt,
152
+ checkedFiles: 0,
153
+ drifts: [],
154
+ }
155
+ }
156
+
157
+ const targets = collectWatchTargets()
158
+ const stored = loadIntegrityBaselines() as Record<string, IntegrityBaselineEntry>
159
+ const nextBaselines: Record<string, IntegrityBaselineEntry> = { ...stored }
160
+ const drifts: IntegrityDrift[] = []
161
+ let dirty = false
162
+
163
+ for (const target of targets) {
164
+ const previous = stored[target.id]
165
+ const current = toBaseline(target, checkedAt)
166
+
167
+ if (!previous) {
168
+ nextBaselines[target.id] = current
169
+ dirty = true
170
+ continue
171
+ }
172
+
173
+ const changed = (
174
+ previous.present !== current.present
175
+ || previous.hash !== current.hash
176
+ || previous.filePath !== current.filePath
177
+ || previous.kind !== current.kind
178
+ )
179
+
180
+ if (changed) {
181
+ let type: IntegrityDrift['type'] = 'modified'
182
+ if (!previous.present && current.present) type = 'created'
183
+ else if (previous.present && !current.present) type = 'deleted'
184
+ drifts.push({
185
+ id: current.id,
186
+ filePath: current.filePath,
187
+ kind: current.kind,
188
+ type,
189
+ previousHash: previous.hash || null,
190
+ nextHash: current.hash || null,
191
+ checkedAt,
192
+ })
193
+ nextBaselines[target.id] = current
194
+ dirty = true
195
+ }
196
+ }
197
+
198
+ if (dirty) {
199
+ saveIntegrityBaselines(nextBaselines)
200
+ }
201
+
202
+ return {
203
+ enabled: true,
204
+ checkedAt,
205
+ checkedFiles: targets.length,
206
+ drifts,
207
+ }
208
+ }