@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.
- package/README.md +70 -45
- package/next.config.ts +31 -6
- package/package.json +3 -2
- package/src/app/api/agents/[id]/thread/route.ts +1 -0
- package/src/app/api/agents/route.ts +18 -5
- package/src/app/api/approvals/route.ts +22 -0
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/conformance/route.ts +26 -0
- package/src/app/api/mcp-servers/[id]/invoke/route.ts +81 -0
- package/src/app/api/memory/route.ts +36 -5
- package/src/app/api/notifications/route.ts +3 -0
- package/src/app/api/plugins/install/route.ts +57 -5
- package/src/app/api/plugins/marketplace/route.ts +73 -22
- package/src/app/api/plugins/route.ts +61 -1
- package/src/app/api/plugins/ui/route.ts +34 -0
- package/src/app/api/settings/route.ts +62 -0
- package/src/app/api/setup/doctor/route.ts +22 -5
- package/src/app/api/tasks/[id]/approve/route.ts +4 -3
- package/src/app/api/tasks/[id]/route.ts +11 -3
- package/src/app/api/tasks/route.ts +8 -2
- package/src/app/globals.css +27 -0
- package/src/app/page.tsx +10 -5
- package/src/cli/index.js +13 -0
- package/src/components/activity/activity-feed.tsx +9 -2
- package/src/components/agents/agent-avatar.tsx +5 -1
- package/src/components/agents/agent-card.tsx +55 -9
- package/src/components/agents/agent-sheet.tsx +86 -29
- package/src/components/agents/inspector-panel.tsx +1 -1
- package/src/components/auth/access-key-gate.tsx +63 -54
- package/src/components/auth/user-picker.tsx +37 -32
- package/src/components/chat/chat-area.tsx +11 -0
- package/src/components/chat/chat-header.tsx +69 -25
- package/src/components/chat/chat-tool-toggles.tsx +2 -2
- package/src/components/chat/code-block.tsx +3 -1
- package/src/components/chat/exec-approval-card.tsx +8 -1
- package/src/components/chat/message-bubble.tsx +164 -4
- package/src/components/chat/message-list.tsx +30 -4
- package/src/components/chat/session-approval-card.tsx +80 -0
- package/src/components/chat/streaming-bubble.tsx +6 -5
- package/src/components/chat/thinking-indicator.tsx +48 -12
- package/src/components/chat/tool-request-banner.tsx +39 -20
- package/src/components/chatrooms/chatroom-list.tsx +11 -4
- package/src/components/chatrooms/chatroom-sheet.tsx +7 -2
- package/src/components/connectors/connector-list.tsx +33 -11
- package/src/components/connectors/connector-sheet.tsx +29 -6
- package/src/components/home/home-view.tsx +20 -14
- package/src/components/input/chat-input.tsx +22 -1
- package/src/components/knowledge/knowledge-list.tsx +17 -18
- package/src/components/knowledge/knowledge-sheet.tsx +9 -5
- package/src/components/layout/app-layout.tsx +73 -21
- package/src/components/mcp-servers/mcp-server-list.tsx +352 -50
- package/src/components/mcp-servers/mcp-server-sheet.tsx +25 -9
- package/src/components/memory/memory-list.tsx +20 -13
- package/src/components/plugins/plugin-list.tsx +213 -59
- package/src/components/plugins/plugin-sheet.tsx +119 -24
- package/src/components/projects/project-list.tsx +17 -9
- package/src/components/providers/provider-list.tsx +21 -6
- package/src/components/providers/provider-sheet.tsx +42 -25
- package/src/components/runs/run-list.tsx +17 -13
- package/src/components/schedules/schedule-card.tsx +10 -3
- package/src/components/schedules/schedule-list.tsx +2 -2
- package/src/components/schedules/schedule-sheet.tsx +19 -7
- package/src/components/secrets/secret-sheet.tsx +7 -2
- package/src/components/secrets/secrets-list.tsx +18 -5
- package/src/components/sessions/new-session-sheet.tsx +183 -376
- package/src/components/sessions/session-card.tsx +10 -2
- package/src/components/settings/gateway-connection-panel.tsx +9 -8
- package/src/components/shared/command-palette.tsx +13 -5
- package/src/components/shared/empty-state.tsx +20 -8
- package/src/components/shared/notification-center.tsx +134 -86
- package/src/components/shared/profile-sheet.tsx +4 -0
- package/src/components/shared/settings/plugin-manager.tsx +360 -135
- package/src/components/shared/settings/section-capability-policy.tsx +3 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +144 -0
- package/src/components/skills/clawhub-browser.tsx +1 -0
- package/src/components/skills/skill-list.tsx +31 -12
- package/src/components/skills/skill-sheet.tsx +20 -7
- package/src/components/tasks/approvals-panel.tsx +170 -66
- package/src/components/tasks/task-board.tsx +20 -12
- package/src/components/tasks/task-card.tsx +21 -7
- package/src/components/tasks/task-column.tsx +4 -3
- package/src/components/tasks/task-list.tsx +1 -1
- package/src/components/tasks/task-sheet.tsx +130 -1
- package/src/components/ui/dialog.tsx +1 -0
- package/src/components/ui/sheet.tsx +1 -0
- package/src/components/usage/metrics-dashboard.tsx +66 -64
- package/src/components/wallets/wallet-panel.tsx +65 -41
- package/src/components/wallets/wallet-section.tsx +9 -3
- package/src/components/webhooks/webhook-list.tsx +21 -12
- package/src/components/webhooks/webhook-sheet.tsx +13 -3
- package/src/lib/approval-display.test.ts +45 -0
- package/src/lib/approval-display.ts +62 -0
- package/src/lib/clipboard.ts +38 -0
- package/src/lib/memory.ts +8 -0
- package/src/lib/providers/claude-cli.ts +5 -3
- package/src/lib/providers/index.ts +67 -21
- package/src/lib/runtime-loop.ts +3 -2
- package/src/lib/server/approvals.ts +150 -0
- package/src/lib/server/chat-execution.ts +223 -62
- package/src/lib/server/clawhub-client.ts +82 -6
- package/src/lib/server/connectors/manager.ts +27 -1
- package/src/lib/server/cost.test.ts +73 -0
- package/src/lib/server/cost.ts +165 -34
- package/src/lib/server/daemon-state.ts +42 -0
- package/src/lib/server/data-dir.ts +18 -1
- package/src/lib/server/integrity-monitor.ts +208 -0
- package/src/lib/server/llm-response-cache.test.ts +102 -0
- package/src/lib/server/llm-response-cache.ts +227 -0
- package/src/lib/server/main-agent-loop.ts +1 -1
- package/src/lib/server/main-session.ts +6 -3
- package/src/lib/server/mcp-conformance.test.ts +18 -0
- package/src/lib/server/mcp-conformance.ts +233 -0
- package/src/lib/server/memory-db.ts +180 -17
- package/src/lib/server/memory-retrieval.test.ts +56 -0
- package/src/lib/server/orchestrator-lg.ts +4 -1
- package/src/lib/server/orchestrator.ts +4 -3
- package/src/lib/server/plugins.ts +650 -142
- package/src/lib/server/process-manager.ts +18 -0
- package/src/lib/server/queue.ts +253 -11
- package/src/lib/server/runtime-settings.ts +9 -0
- package/src/lib/server/session-run-manager.test.ts +23 -0
- package/src/lib/server/session-run-manager.ts +11 -1
- package/src/lib/server/session-tools/canvas.ts +85 -50
- package/src/lib/server/session-tools/chatroom.ts +130 -127
- package/src/lib/server/session-tools/connector.ts +233 -454
- package/src/lib/server/session-tools/context-mgmt.ts +87 -105
- package/src/lib/server/session-tools/crud.ts +84 -7
- package/src/lib/server/session-tools/delegate.ts +351 -752
- package/src/lib/server/session-tools/discovery.ts +198 -0
- package/src/lib/server/session-tools/edit_file.ts +82 -0
- package/src/lib/server/session-tools/file-send.test.ts +39 -0
- package/src/lib/server/session-tools/file.ts +257 -425
- package/src/lib/server/session-tools/git.ts +87 -47
- package/src/lib/server/session-tools/http.ts +85 -33
- package/src/lib/server/session-tools/index.ts +205 -160
- package/src/lib/server/session-tools/memory.ts +152 -265
- package/src/lib/server/session-tools/monitor.ts +126 -0
- package/src/lib/server/session-tools/normalize-tool-args.test.ts +61 -0
- package/src/lib/server/session-tools/normalize-tool-args.ts +48 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +82 -99
- package/src/lib/server/session-tools/openclaw-workspace.ts +103 -93
- package/src/lib/server/session-tools/platform.ts +86 -0
- package/src/lib/server/session-tools/plugin-creator.ts +239 -0
- package/src/lib/server/session-tools/sample-ui.ts +97 -0
- package/src/lib/server/session-tools/sandbox.ts +175 -148
- package/src/lib/server/session-tools/schedule.ts +66 -31
- package/src/lib/server/session-tools/session-info.ts +104 -410
- package/src/lib/server/session-tools/shell-normalize.test.ts +43 -0
- package/src/lib/server/session-tools/shell.ts +171 -143
- package/src/lib/server/session-tools/subagent.ts +77 -77
- package/src/lib/server/session-tools/wallet.ts +182 -106
- package/src/lib/server/session-tools/web.ts +179 -349
- package/src/lib/server/storage.ts +24 -0
- package/src/lib/server/stream-agent-chat.ts +301 -244
- package/src/lib/server/task-quality-gate.test.ts +44 -0
- package/src/lib/server/task-quality-gate.ts +67 -0
- package/src/lib/server/task-validation.test.ts +78 -0
- package/src/lib/server/task-validation.ts +67 -2
- package/src/lib/server/tool-aliases.ts +68 -0
- package/src/lib/server/tool-capability-policy.ts +23 -5
- package/src/lib/tasks.ts +7 -1
- package/src/lib/tool-definitions.ts +23 -23
- package/src/lib/validation/schemas.ts +12 -0
- package/src/lib/view-routes.ts +2 -24
- package/src/stores/use-app-store.ts +23 -1
- 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
|
+
})
|
package/src/lib/server/cost.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
68
|
+
export interface AgentSpendWindows {
|
|
69
|
+
hourly: number
|
|
70
|
+
daily: number
|
|
71
|
+
monthly: number
|
|
72
|
+
}
|
|
37
73
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const sessions = loadSessions()
|
|
44
|
-
|
|
45
|
-
const agentSessionIds =
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
52
|
-
const
|
|
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
|
|
58
|
-
if (!Array.isArray(
|
|
59
|
-
for (const record of
|
|
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
|
-
|
|
62
|
-
if (
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|