@swarmclawai/swarmclaw 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +81 -22
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +36 -7
  5. package/src/app/api/agents/route.ts +12 -1
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  9. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  10. package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
  11. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  12. package/src/app/api/chats/[id]/route.ts +18 -0
  13. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  14. package/src/app/api/chats/route.ts +16 -0
  15. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  16. package/src/app/api/connectors/doctor/route.ts +13 -0
  17. package/src/app/api/files/open/route.ts +16 -14
  18. package/src/app/api/memory/maintenance/route.ts +11 -1
  19. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  20. package/src/app/api/openclaw/skills/route.ts +11 -3
  21. package/src/app/api/plugins/dependencies/route.ts +24 -0
  22. package/src/app/api/plugins/install/route.ts +15 -92
  23. package/src/app/api/plugins/route.ts +3 -26
  24. package/src/app/api/plugins/settings/route.ts +17 -12
  25. package/src/app/api/plugins/ui/route.ts +1 -0
  26. package/src/app/api/settings/route.ts +49 -7
  27. package/src/app/api/tasks/[id]/route.ts +15 -6
  28. package/src/app/api/tasks/bulk/route.ts +2 -2
  29. package/src/app/api/tasks/route.ts +9 -4
  30. package/src/app/api/webhooks/[id]/route.ts +8 -1
  31. package/src/app/page.tsx +9 -2
  32. package/src/cli/index.js +4 -0
  33. package/src/cli/index.ts +3 -10
  34. package/src/components/agents/agent-card.tsx +15 -12
  35. package/src/components/agents/agent-chat-list.tsx +101 -1
  36. package/src/components/agents/agent-list.tsx +46 -9
  37. package/src/components/agents/agent-sheet.tsx +207 -16
  38. package/src/components/agents/inspector-panel.tsx +108 -48
  39. package/src/components/auth/access-key-gate.tsx +36 -97
  40. package/src/components/chat/chat-area.tsx +29 -13
  41. package/src/components/chat/chat-card.tsx +4 -20
  42. package/src/components/chat/chat-header.tsx +255 -353
  43. package/src/components/chat/chat-list.tsx +7 -9
  44. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  45. package/src/components/chat/message-list.tsx +3 -1
  46. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  47. package/src/components/connectors/connector-list.tsx +265 -127
  48. package/src/components/connectors/connector-sheet.tsx +217 -0
  49. package/src/components/home/home-view.tsx +128 -4
  50. package/src/components/layout/app-layout.tsx +383 -194
  51. package/src/components/layout/mobile-header.tsx +26 -8
  52. package/src/components/plugins/plugin-list.tsx +15 -3
  53. package/src/components/plugins/plugin-sheet.tsx +118 -9
  54. package/src/components/projects/project-detail.tsx +183 -0
  55. package/src/components/shared/agent-picker-list.tsx +2 -2
  56. package/src/components/shared/command-palette.tsx +111 -24
  57. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  58. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  59. package/src/components/shared/settings/section-heartbeat.tsx +77 -0
  60. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  61. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  62. package/src/components/shared/settings/section-secrets.tsx +6 -6
  63. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  64. package/src/components/shared/settings/section-voice.tsx +5 -1
  65. package/src/components/shared/settings/section-web-search.tsx +10 -2
  66. package/src/components/shared/settings/settings-page.tsx +245 -46
  67. package/src/components/tasks/approvals-panel.tsx +205 -18
  68. package/src/components/tasks/task-board.tsx +242 -46
  69. package/src/components/usage/metrics-dashboard.tsx +74 -1
  70. package/src/components/wallets/wallet-panel.tsx +17 -5
  71. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  72. package/src/lib/auth.ts +17 -0
  73. package/src/lib/chat-streaming-state.test.ts +108 -0
  74. package/src/lib/chat-streaming-state.ts +108 -0
  75. package/src/lib/openclaw-agent-id.test.ts +14 -0
  76. package/src/lib/openclaw-agent-id.ts +31 -0
  77. package/src/lib/server/agent-assignment.test.ts +112 -0
  78. package/src/lib/server/agent-assignment.ts +169 -0
  79. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  80. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  81. package/src/lib/server/approvals.ts +483 -75
  82. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  83. package/src/lib/server/browser-state.test.ts +118 -0
  84. package/src/lib/server/browser-state.ts +123 -0
  85. package/src/lib/server/build-llm.test.ts +36 -0
  86. package/src/lib/server/build-llm.ts +11 -4
  87. package/src/lib/server/builtin-plugins.ts +34 -0
  88. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  89. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  90. package/src/lib/server/chat-execution.ts +250 -61
  91. package/src/lib/server/chatroom-health.test.ts +26 -0
  92. package/src/lib/server/chatroom-health.ts +2 -3
  93. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  94. package/src/lib/server/chatroom-helpers.ts +45 -5
  95. package/src/lib/server/connectors/discord.ts +175 -11
  96. package/src/lib/server/connectors/doctor.test.ts +80 -0
  97. package/src/lib/server/connectors/doctor.ts +116 -0
  98. package/src/lib/server/connectors/manager.ts +946 -110
  99. package/src/lib/server/connectors/policy.test.ts +222 -0
  100. package/src/lib/server/connectors/policy.ts +452 -0
  101. package/src/lib/server/connectors/slack.ts +188 -9
  102. package/src/lib/server/connectors/telegram.ts +65 -15
  103. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  104. package/src/lib/server/connectors/thread-context.ts +72 -0
  105. package/src/lib/server/connectors/types.ts +41 -11
  106. package/src/lib/server/daemon-state.ts +59 -1
  107. package/src/lib/server/data-dir.ts +13 -0
  108. package/src/lib/server/delegation-jobs.test.ts +140 -0
  109. package/src/lib/server/delegation-jobs.ts +248 -0
  110. package/src/lib/server/document-utils.test.ts +47 -0
  111. package/src/lib/server/document-utils.ts +397 -0
  112. package/src/lib/server/heartbeat-service.ts +13 -39
  113. package/src/lib/server/heartbeat-source.test.ts +22 -0
  114. package/src/lib/server/heartbeat-source.ts +7 -0
  115. package/src/lib/server/identity-continuity.test.ts +77 -0
  116. package/src/lib/server/identity-continuity.ts +127 -0
  117. package/src/lib/server/mailbox-utils.ts +347 -0
  118. package/src/lib/server/main-agent-loop.ts +27 -967
  119. package/src/lib/server/memory-db.ts +4 -6
  120. package/src/lib/server/memory-tiers.ts +40 -0
  121. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  122. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  123. package/src/lib/server/openclaw-exec-config.ts +5 -6
  124. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  125. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  126. package/src/lib/server/openclaw-sync.ts +3 -2
  127. package/src/lib/server/orchestrator-lg.ts +17 -6
  128. package/src/lib/server/orchestrator.ts +2 -2
  129. package/src/lib/server/playwright-proxy.mjs +27 -3
  130. package/src/lib/server/plugins.test.ts +207 -0
  131. package/src/lib/server/plugins.ts +822 -69
  132. package/src/lib/server/provider-health.ts +33 -3
  133. package/src/lib/server/queue.ts +3 -20
  134. package/src/lib/server/scheduler.ts +2 -0
  135. package/src/lib/server/session-archive-memory.test.ts +85 -0
  136. package/src/lib/server/session-archive-memory.ts +230 -0
  137. package/src/lib/server/session-mailbox.ts +8 -18
  138. package/src/lib/server/session-reset-policy.test.ts +99 -0
  139. package/src/lib/server/session-reset-policy.ts +311 -0
  140. package/src/lib/server/session-run-manager.ts +33 -80
  141. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  142. package/src/lib/server/session-tools/calendar.ts +2 -12
  143. package/src/lib/server/session-tools/connector.ts +109 -8
  144. package/src/lib/server/session-tools/context.ts +14 -2
  145. package/src/lib/server/session-tools/crawl.ts +447 -0
  146. package/src/lib/server/session-tools/crud.ts +70 -32
  147. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  148. package/src/lib/server/session-tools/delegate.ts +406 -20
  149. package/src/lib/server/session-tools/discovery.ts +22 -4
  150. package/src/lib/server/session-tools/document.ts +283 -0
  151. package/src/lib/server/session-tools/email.ts +1 -3
  152. package/src/lib/server/session-tools/extract.ts +137 -0
  153. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  154. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  155. package/src/lib/server/session-tools/file.ts +237 -24
  156. package/src/lib/server/session-tools/human-loop.ts +227 -0
  157. package/src/lib/server/session-tools/image-gen.ts +1 -3
  158. package/src/lib/server/session-tools/index.ts +56 -1
  159. package/src/lib/server/session-tools/mailbox.ts +276 -0
  160. package/src/lib/server/session-tools/memory.ts +35 -3
  161. package/src/lib/server/session-tools/monitor.ts +150 -7
  162. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  163. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  164. package/src/lib/server/session-tools/platform.ts +142 -4
  165. package/src/lib/server/session-tools/plugin-creator.ts +86 -23
  166. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  167. package/src/lib/server/session-tools/replicate.ts +1 -3
  168. package/src/lib/server/session-tools/schedule.ts +20 -10
  169. package/src/lib/server/session-tools/session-info.ts +36 -3
  170. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  171. package/src/lib/server/session-tools/subagent.ts +193 -27
  172. package/src/lib/server/session-tools/table.ts +587 -0
  173. package/src/lib/server/session-tools/wallet.ts +13 -10
  174. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  175. package/src/lib/server/session-tools/web.ts +896 -100
  176. package/src/lib/server/storage.ts +226 -7
  177. package/src/lib/server/stream-agent-chat.ts +46 -21
  178. package/src/lib/server/structured-extract.test.ts +72 -0
  179. package/src/lib/server/structured-extract.ts +373 -0
  180. package/src/lib/server/task-mention.test.ts +16 -2
  181. package/src/lib/server/task-mention.ts +61 -10
  182. package/src/lib/server/tool-aliases.ts +44 -7
  183. package/src/lib/server/tool-capability-policy.ts +6 -0
  184. package/src/lib/server/tool-retry.ts +2 -0
  185. package/src/lib/server/watch-jobs.test.ts +173 -0
  186. package/src/lib/server/watch-jobs.ts +532 -0
  187. package/src/lib/server/ws-hub.ts +5 -3
  188. package/src/lib/validation/schemas.test.ts +26 -0
  189. package/src/lib/validation/schemas.ts +7 -0
  190. package/src/lib/ws-client.ts +14 -12
  191. package/src/proxy.ts +5 -5
  192. package/src/stores/use-app-store.ts +0 -6
  193. package/src/stores/use-chat-store.ts +31 -2
  194. package/src/types/index.ts +287 -44
  195. package/src/components/chat/new-chat-sheet.tsx +0 -253
  196. package/src/lib/server/main-session.ts +0 -17
  197. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -0,0 +1,169 @@
1
+ import type { Agent } from '@/types'
2
+ import { resolveAgentReference, resolveTaskAgentFromDescription } from './task-mention'
3
+
4
+ export const MANAGED_AGENT_REFERENCE_KEYS = [
5
+ 'agentId',
6
+ 'agent_id',
7
+ 'assignedAgentId',
8
+ 'assigned_agent_id',
9
+ 'assignedToAgentId',
10
+ 'assigned_to_agent_id',
11
+ 'assigneeId',
12
+ 'assignee_id',
13
+ 'assignedAgent',
14
+ 'assigned_agent',
15
+ 'assignedTo',
16
+ 'assigned_to',
17
+ 'assignee',
18
+ 'agent',
19
+ 'owner',
20
+ ] as const
21
+
22
+ type AssignmentSource = 'explicit' | 'description' | 'fallback' | 'none'
23
+
24
+ export interface ManagedAgentAssignmentResolution {
25
+ agentId: string | null
26
+ explicitReference: string | null
27
+ unresolvedReference: string | null
28
+ source: AssignmentSource
29
+ hadExplicitInput: boolean
30
+ }
31
+
32
+ function firstNonEmptyString(
33
+ parsed: Record<string, unknown>,
34
+ keys: readonly string[],
35
+ ): string | null {
36
+ for (const key of keys) {
37
+ const raw = parsed[key]
38
+ if (typeof raw !== 'string') continue
39
+ const trimmed = raw.trim()
40
+ if (trimmed) return trimmed
41
+ }
42
+ return null
43
+ }
44
+
45
+ export function hasManagedAgentAssignmentInput(
46
+ parsed: Record<string, unknown>,
47
+ keys: readonly string[] = MANAGED_AGENT_REFERENCE_KEYS,
48
+ ): boolean {
49
+ return keys.some((key) => Object.prototype.hasOwnProperty.call(parsed, key))
50
+ }
51
+
52
+ export function resolveManagedAgentAssignment(
53
+ parsed: Record<string, unknown>,
54
+ agents: Record<string, Agent>,
55
+ fallbackAgentId?: string | null,
56
+ opts?: {
57
+ allowDescription?: boolean
58
+ keys?: readonly string[]
59
+ },
60
+ ): ManagedAgentAssignmentResolution {
61
+ const keys = opts?.keys ?? MANAGED_AGENT_REFERENCE_KEYS
62
+ const explicitReference = firstNonEmptyString(parsed, keys)
63
+ const hadExplicitInput = hasManagedAgentAssignmentInput(parsed, keys)
64
+ if (explicitReference) {
65
+ const resolved = resolveAgentReference(explicitReference, agents)
66
+ return {
67
+ agentId: resolved,
68
+ explicitReference,
69
+ unresolvedReference: resolved ? null : explicitReference,
70
+ source: 'explicit',
71
+ hadExplicitInput,
72
+ }
73
+ }
74
+
75
+ if (opts?.allowDescription !== false) {
76
+ const description = typeof parsed.description === 'string' ? parsed.description.trim() : ''
77
+ if (description) {
78
+ const resolvedFromDescription = resolveTaskAgentFromDescription(description, '', agents).trim()
79
+ if (resolvedFromDescription) {
80
+ return {
81
+ agentId: resolvedFromDescription,
82
+ explicitReference: null,
83
+ unresolvedReference: null,
84
+ source: 'description',
85
+ hadExplicitInput,
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ const fallback = typeof fallbackAgentId === 'string' ? fallbackAgentId.trim() : ''
92
+ if (fallback) {
93
+ return {
94
+ agentId: fallback,
95
+ explicitReference: null,
96
+ unresolvedReference: null,
97
+ source: 'fallback',
98
+ hadExplicitInput,
99
+ }
100
+ }
101
+
102
+ return {
103
+ agentId: null,
104
+ explicitReference: null,
105
+ unresolvedReference: null,
106
+ source: 'none',
107
+ hadExplicitInput,
108
+ }
109
+ }
110
+
111
+ export function resolveDelegatorAgentId(
112
+ parsed: Record<string, unknown>,
113
+ agents: Record<string, Agent>,
114
+ fallbackAgentId?: string | null,
115
+ ): string | null {
116
+ const explicitDelegator = typeof parsed.delegatedByAgentId === 'string'
117
+ ? parsed.delegatedByAgentId.trim()
118
+ : ''
119
+ if (explicitDelegator) {
120
+ return resolveAgentReference(explicitDelegator, agents) || explicitDelegator
121
+ }
122
+ const fallback = typeof fallbackAgentId === 'string' ? fallbackAgentId.trim() : ''
123
+ return fallback || null
124
+ }
125
+
126
+ export function isDelegationTaskPayload(parsed: Record<string, unknown>): boolean {
127
+ const sourceType = typeof parsed.sourceType === 'string' ? parsed.sourceType.trim().toLowerCase() : ''
128
+ if (sourceType === 'delegation') return true
129
+ if (typeof parsed.delegatedFromTaskId === 'string' && parsed.delegatedFromTaskId.trim()) return true
130
+ if (typeof parsed.delegatedByAgentId === 'string' && parsed.delegatedByAgentId.trim()) return true
131
+ return false
132
+ }
133
+
134
+ export function validateManagedAgentAssignment(params: {
135
+ resourceLabel: string
136
+ agents: Record<string, Agent>
137
+ assignScope: 'self' | 'all'
138
+ currentAgentId?: string | null
139
+ targetAgentId?: string | null
140
+ unresolvedReference?: string | null
141
+ isDelegation?: boolean
142
+ delegatorAgentId?: string | null
143
+ }): string | null {
144
+ const currentAgentId = typeof params.currentAgentId === 'string' ? params.currentAgentId.trim() : ''
145
+ const targetAgentId = typeof params.targetAgentId === 'string' ? params.targetAgentId.trim() : ''
146
+ const unresolvedReference = typeof params.unresolvedReference === 'string' ? params.unresolvedReference.trim() : ''
147
+ const delegatorAgentId = typeof params.delegatorAgentId === 'string' ? params.delegatorAgentId.trim() : ''
148
+
149
+ if (unresolvedReference) {
150
+ return `Error: Unknown agent "${unresolvedReference}". Use an existing agent ID or exact agent name.`
151
+ }
152
+
153
+ if (targetAgentId && !params.agents[targetAgentId]) {
154
+ return `Error: Unknown agent "${targetAgentId}". Use an existing agent ID or exact agent name.`
155
+ }
156
+
157
+ if (params.assignScope === 'self' && currentAgentId && targetAgentId && targetAgentId !== currentAgentId) {
158
+ return `Error: You can only assign ${params.resourceLabel} to yourself ("${currentAgentId}"). To assign to other agents, ask a user to enable "Assign to Other Agents" in your agent settings.`
159
+ }
160
+
161
+ if (params.isDelegation && targetAgentId) {
162
+ const comparisonId = delegatorAgentId || currentAgentId
163
+ if (comparisonId && targetAgentId === comparisonId) {
164
+ return 'Error: Delegation target must be a different agent ID. Create a normal self-task instead of delegating to yourself.'
165
+ }
166
+ }
167
+
168
+ return null
169
+ }
@@ -0,0 +1,253 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-approval-connector-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: tempDir,
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ describe('approval connector reminders', () => {
36
+ it('resolves a due approval to the session connector target and records one-shot delivery state', () => {
37
+ const output = runWithTempDataDir(`
38
+ const storageMod = await import('./src/lib/server/storage.ts')
39
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
40
+ const storage = storageMod.default || storageMod
41
+ const approvals = approvalsMod.default || approvalsMod
42
+
43
+ const now = Date.now()
44
+ storage.saveSettings({
45
+ approvalConnectorNotifyEnabled: true,
46
+ approvalConnectorNotifyDelaySec: 60,
47
+ })
48
+ storage.saveAgents({
49
+ agent_1: {
50
+ id: 'agent_1',
51
+ name: 'Molly',
52
+ description: 'Test agent',
53
+ systemPrompt: 'You are Molly.',
54
+ provider: 'openai',
55
+ model: 'gpt-test',
56
+ plugins: [],
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ },
60
+ })
61
+ storage.saveSessions({
62
+ session_1: {
63
+ id: 'session_1',
64
+ name: 'Connector session',
65
+ cwd: process.cwd(),
66
+ user: 'tester',
67
+ provider: 'openai',
68
+ model: 'gpt-test',
69
+ credentialId: null,
70
+ apiEndpoint: null,
71
+ claudeSessionId: null,
72
+ codexThreadId: null,
73
+ opencodeSessionId: null,
74
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
75
+ messages: [
76
+ {
77
+ role: 'user',
78
+ text: 'Please ask me before spending money.',
79
+ time: now - 1_000,
80
+ source: { connectorId: 'conn-1', channelId: 'chat-42', threadId: 'topic-7' },
81
+ },
82
+ ],
83
+ createdAt: now - 120_000,
84
+ lastActiveAt: now - 1_000,
85
+ sessionType: 'human',
86
+ agentId: 'agent_1',
87
+ plugins: [],
88
+ connectorContext: {
89
+ connectorId: 'conn-1',
90
+ platform: 'telegram',
91
+ channelId: 'chat-42',
92
+ threadId: 'topic-7',
93
+ lastInboundAt: now - 1_000,
94
+ },
95
+ },
96
+ })
97
+
98
+ const approval = approvals.requestApproval({
99
+ category: 'human_loop',
100
+ title: 'Approve plugin install',
101
+ description: 'Need permission to install a plugin.',
102
+ data: {},
103
+ sessionId: 'session_1',
104
+ agentId: 'agent_1',
105
+ })
106
+
107
+ const dueAt = approval.createdAt + 61_000
108
+ const reminders = approvals.listPendingApprovalsNeedingConnectorNotification({
109
+ now: dueAt,
110
+ runningConnectors: [
111
+ { id: 'conn-1', agentId: 'agent_1', supportsSend: true, configuredTargets: [], recentChannelId: 'chat-42' },
112
+ ],
113
+ })
114
+
115
+ approvals.markApprovalConnectorNotificationSent(approval.id, {
116
+ at: dueAt,
117
+ connectorId: 'conn-1',
118
+ channelId: 'chat-42',
119
+ threadId: 'topic-7',
120
+ messageId: 'msg-9',
121
+ })
122
+
123
+ const afterSend = approvals.listPendingApprovalsNeedingConnectorNotification({
124
+ now: dueAt + 1_000,
125
+ runningConnectors: [
126
+ { id: 'conn-1', agentId: 'agent_1', supportsSend: true, configuredTargets: [], recentChannelId: 'chat-42' },
127
+ ],
128
+ })
129
+
130
+ const storedApproval = storage.loadApprovals()[approval.id]
131
+ console.log(JSON.stringify({
132
+ reminderCount: reminders.length,
133
+ reminder: reminders[0],
134
+ afterSendCount: afterSend.length,
135
+ storedApproval,
136
+ }))
137
+ `)
138
+
139
+ assert.equal(output.reminderCount, 1)
140
+ assert.equal(output.reminder.connectorId, 'conn-1')
141
+ assert.equal(output.reminder.channelId, 'chat-42')
142
+ assert.equal(output.reminder.threadId, 'topic-7')
143
+ assert.match(output.reminder.text, /Molly is waiting for your approval/i)
144
+ assert.equal(output.afterSendCount, 0)
145
+ assert.equal(output.storedApproval.connectorNotification.sentAt > 0, true)
146
+ assert.equal(output.storedApproval.connectorNotification.messageId, 'msg-9')
147
+ })
148
+
149
+ it('falls back to a running owned connector and respects retry cooldowns after failed sends', () => {
150
+ const output = runWithTempDataDir(`
151
+ const storageMod = await import('./src/lib/server/storage.ts')
152
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
153
+ const storage = storageMod.default || storageMod
154
+ const approvals = approvalsMod.default || approvalsMod
155
+
156
+ const now = Date.now()
157
+ storage.saveSettings({
158
+ approvalConnectorNotifyEnabled: true,
159
+ approvalConnectorNotifyDelaySec: 30,
160
+ })
161
+ storage.saveAgents({
162
+ agent_2: {
163
+ id: 'agent_2',
164
+ name: 'Writer',
165
+ description: 'Test agent',
166
+ systemPrompt: 'You are Writer.',
167
+ provider: 'openai',
168
+ model: 'gpt-test',
169
+ plugins: [],
170
+ createdAt: now,
171
+ updatedAt: now,
172
+ },
173
+ })
174
+ storage.saveSessions({
175
+ session_plain: {
176
+ id: 'session_plain',
177
+ name: 'Non-connector session',
178
+ cwd: process.cwd(),
179
+ user: 'tester',
180
+ provider: 'openai',
181
+ model: 'gpt-test',
182
+ credentialId: null,
183
+ apiEndpoint: null,
184
+ claudeSessionId: null,
185
+ codexThreadId: null,
186
+ opencodeSessionId: null,
187
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
188
+ messages: [],
189
+ createdAt: now - 60_000,
190
+ lastActiveAt: now - 1_000,
191
+ sessionType: 'human',
192
+ agentId: 'agent_2',
193
+ plugins: [],
194
+ },
195
+ })
196
+
197
+ const approval = approvals.requestApproval({
198
+ category: 'task_tool',
199
+ title: 'Approve outbound outreach',
200
+ description: 'Need your approval before sending a message.',
201
+ data: {},
202
+ sessionId: 'session_plain',
203
+ agentId: 'agent_2',
204
+ })
205
+
206
+ const dueAt = approval.createdAt + 31_000
207
+ const first = approvals.listPendingApprovalsNeedingConnectorNotification({
208
+ now: dueAt,
209
+ runningConnectors: [
210
+ { id: 'conn-fallback', agentId: 'agent_2', supportsSend: true, configuredTargets: [], recentChannelId: 'dm-88' },
211
+ ],
212
+ })
213
+
214
+ approvals.markApprovalConnectorNotificationAttempt(approval.id, {
215
+ at: dueAt,
216
+ connectorId: 'conn-fallback',
217
+ channelId: 'dm-88',
218
+ lastError: 'connector temporarily unavailable',
219
+ })
220
+
221
+ const withinCooldown = approvals.listPendingApprovalsNeedingConnectorNotification({
222
+ now: dueAt + 5_000,
223
+ runningConnectors: [
224
+ { id: 'conn-fallback', agentId: 'agent_2', supportsSend: true, configuredTargets: [], recentChannelId: 'dm-88' },
225
+ ],
226
+ })
227
+
228
+ const afterCooldown = approvals.listPendingApprovalsNeedingConnectorNotification({
229
+ now: dueAt + (10 * 60_000) + 1_000,
230
+ runningConnectors: [
231
+ { id: 'conn-fallback', agentId: 'agent_2', supportsSend: true, configuredTargets: [], recentChannelId: 'dm-88' },
232
+ ],
233
+ })
234
+
235
+ const storedApproval = storage.loadApprovals()[approval.id]
236
+ console.log(JSON.stringify({
237
+ firstCount: first.length,
238
+ fallbackConnectorId: first[0]?.connectorId || null,
239
+ fallbackChannelId: first[0]?.channelId || null,
240
+ withinCooldownCount: withinCooldown.length,
241
+ afterCooldownCount: afterCooldown.length,
242
+ storedApproval,
243
+ }))
244
+ `)
245
+
246
+ assert.equal(output.firstCount, 1)
247
+ assert.equal(output.fallbackConnectorId, 'conn-fallback')
248
+ assert.equal(output.fallbackChannelId, 'dm-88')
249
+ assert.equal(output.withinCooldownCount, 0)
250
+ assert.equal(output.afterCooldownCount, 1)
251
+ assert.equal(output.storedApproval.connectorNotification.lastError, 'connector temporarily unavailable')
252
+ })
253
+ })
@@ -0,0 +1,205 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-approval-auto-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: tempDir,
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ describe('approval auto-approve', () => {
36
+ it('auto-approves tool access and plugin scaffolds when configured', () => {
37
+ const output = runWithTempDataDir(`
38
+ const storageMod = await import('./src/lib/server/storage.ts')
39
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
40
+ const dataDirMod = await import('./src/lib/server/data-dir.ts')
41
+ const storage = storageMod.default || storageMod
42
+ const approvals = approvalsMod.default || approvalsMod
43
+ const dataDir = dataDirMod.DATA_DIR || dataDirMod.default?.DATA_DIR || dataDirMod['module.exports']?.DATA_DIR
44
+
45
+ storage.saveSettings({
46
+ approvalAutoApproveCategories: ['tool_access', 'plugin_scaffold'],
47
+ })
48
+
49
+ const now = Date.now()
50
+ storage.saveSessions({
51
+ session_auto: {
52
+ id: 'session_auto',
53
+ name: 'Auto Approval Test',
54
+ cwd: process.cwd(),
55
+ user: 'tester',
56
+ provider: 'openai',
57
+ model: 'gpt-test',
58
+ credentialId: null,
59
+ apiEndpoint: null,
60
+ claudeSessionId: null,
61
+ codexThreadId: null,
62
+ opencodeSessionId: null,
63
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
64
+ messages: [],
65
+ createdAt: now,
66
+ lastActiveAt: now,
67
+ sessionType: 'human',
68
+ agentId: 'default',
69
+ plugins: [],
70
+ },
71
+ })
72
+
73
+ const toolApproval = await approvals.requestApprovalMaybeAutoApprove({
74
+ category: 'tool_access',
75
+ title: 'Enable Plugin: shell',
76
+ data: { toolId: 'shell', pluginId: 'shell' },
77
+ sessionId: 'session_auto',
78
+ agentId: 'default',
79
+ })
80
+
81
+ const pluginApproval = await approvals.requestApprovalMaybeAutoApprove({
82
+ category: 'plugin_scaffold',
83
+ title: 'Scaffold Plugin: auto-test.js',
84
+ data: {
85
+ filename: 'auto-test.js',
86
+ code: 'module.exports = { name: \"AutoTestPlugin\" }',
87
+ createdByAgentId: 'default',
88
+ },
89
+ sessionId: 'session_auto',
90
+ agentId: 'default',
91
+ })
92
+
93
+ const sessions = storage.loadSessions()
94
+ const pluginsDir = await import('node:path').then((path) => path.join(dataDir, 'plugins'))
95
+ const pluginPath = await import('node:path').then((path) => path.join(pluginsDir, 'auto-test.js'))
96
+
97
+ console.log(JSON.stringify({
98
+ categories: approvals.listAutoApprovableApprovalCategories(),
99
+ toolApprovalStatus: toolApproval.status,
100
+ pluginApprovalStatus: pluginApproval.status,
101
+ sessionPlugins: sessions.session_auto.plugins,
102
+ pluginExists: (await import('node:fs')).existsSync(pluginPath),
103
+ }))
104
+ `)
105
+
106
+ assert.equal(output.toolApprovalStatus, 'approved')
107
+ assert.equal(output.pluginApprovalStatus, 'approved')
108
+ assert.equal(Array.isArray(output.categories), true)
109
+ assert.equal(output.categories.includes('wallet_transfer'), true)
110
+ assert.equal(output.sessionPlugins.includes('shell'), true)
111
+ assert.equal(output.pluginExists, true)
112
+ })
113
+
114
+ it('can disable approvals platform-wide for fully autonomous execution', () => {
115
+ const output = runWithTempDataDir(`
116
+ const storageMod = await import('./src/lib/server/storage.ts')
117
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
118
+ const storage = storageMod.default || storageMod
119
+ const approvals = approvalsMod.default || approvalsMod
120
+
121
+ storage.saveSettings({
122
+ approvalsEnabled: false,
123
+ })
124
+
125
+ const approval = await approvals.requestApprovalMaybeAutoApprove({
126
+ category: 'human_loop',
127
+ title: 'Need an answer',
128
+ description: 'Should be auto-approved because approvals are disabled platform-wide.',
129
+ data: { question: 'Proceed?' },
130
+ agentId: 'default',
131
+ sessionId: null,
132
+ })
133
+
134
+ const stored = storage.loadApprovals()[approval.id]
135
+ console.log(JSON.stringify({
136
+ approvalStatus: approval.status,
137
+ storedStatus: stored?.status || null,
138
+ }))
139
+ `)
140
+
141
+ assert.equal(output.approvalStatus, 'approved')
142
+ assert.equal(output.storedStatus, 'approved')
143
+ })
144
+
145
+ it('adds a pending approval request message to the chat session when approvals are enabled', () => {
146
+ const output = runWithTempDataDir(`
147
+ const storageMod = await import('./src/lib/server/storage.ts')
148
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
149
+ const storage = storageMod.default || storageMod
150
+ const approvals = approvalsMod.default || approvalsMod
151
+
152
+ const now = Date.now()
153
+ storage.saveSettings({
154
+ approvalsEnabled: true,
155
+ approvalAutoApproveCategories: [],
156
+ })
157
+ storage.saveSessions({
158
+ session_chat: {
159
+ id: 'session_chat',
160
+ name: 'Approval Chat Test',
161
+ cwd: process.cwd(),
162
+ user: 'tester',
163
+ provider: 'openai',
164
+ model: 'gpt-test',
165
+ credentialId: null,
166
+ apiEndpoint: null,
167
+ claudeSessionId: null,
168
+ codexThreadId: null,
169
+ opencodeSessionId: null,
170
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
171
+ messages: [],
172
+ createdAt: now,
173
+ lastActiveAt: now,
174
+ sessionType: 'human',
175
+ agentId: 'default',
176
+ plugins: [],
177
+ },
178
+ })
179
+
180
+ const approval = await approvals.requestApprovalMaybeAutoApprove({
181
+ category: 'tool_access',
182
+ title: 'Enable Plugin: shell',
183
+ description: 'Need shell access for a task.',
184
+ data: { toolId: 'shell', pluginId: 'shell' },
185
+ sessionId: 'session_chat',
186
+ agentId: 'default',
187
+ })
188
+
189
+ const session = storage.loadSessions().session_chat
190
+ const lastMessage = session.messages.at(-1)
191
+ console.log(JSON.stringify({
192
+ approvalStatus: approval.status,
193
+ messageCount: session.messages.length,
194
+ lastMessage,
195
+ }))
196
+ `)
197
+
198
+ assert.equal(output.approvalStatus, 'pending')
199
+ assert.equal(output.messageCount, 1)
200
+ assert.equal(output.lastMessage.role, 'assistant')
201
+ assert.equal(output.lastMessage.kind, 'system')
202
+ assert.match(output.lastMessage.text, /\"type\":\"plugin_request\"/)
203
+ assert.match(output.lastMessage.text, /\"pluginId\":\"shell\"/)
204
+ })
205
+ })