@swarmclawai/swarmclaw 0.4.5 → 0.5.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 (113) hide show
  1. package/README.md +8 -2
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +5 -0
  4. package/package.json +2 -1
  5. package/src/app/api/agents/[id]/route.ts +23 -5
  6. package/src/app/api/agents/trash/route.ts +44 -0
  7. package/src/app/api/connectors/[id]/route.ts +7 -4
  8. package/src/app/api/connectors/[id]/webhook/route.ts +6 -2
  9. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  10. package/src/app/api/openclaw/approvals/route.ts +46 -0
  11. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  12. package/src/app/api/openclaw/cron/route.ts +52 -0
  13. package/src/app/api/openclaw/directory/route.ts +4 -3
  14. package/src/app/api/openclaw/discover/route.ts +3 -2
  15. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  16. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  17. package/src/app/api/openclaw/gateway/route.ts +72 -0
  18. package/src/app/api/openclaw/history/route.ts +109 -0
  19. package/src/app/api/openclaw/media/route.ts +53 -0
  20. package/src/app/api/openclaw/models/route.ts +12 -0
  21. package/src/app/api/openclaw/permissions/route.ts +39 -0
  22. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  23. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  24. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  25. package/src/app/api/openclaw/skills/route.ts +82 -0
  26. package/src/app/api/openclaw/sync/route.ts +3 -2
  27. package/src/app/api/projects/[id]/route.ts +1 -1
  28. package/src/app/api/projects/route.ts +1 -1
  29. package/src/app/api/secrets/[id]/route.ts +1 -1
  30. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  31. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  32. package/src/app/api/sessions/[id]/messages/route.ts +18 -1
  33. package/src/app/api/sessions/[id]/route.ts +12 -3
  34. package/src/app/api/sessions/route.ts +6 -2
  35. package/src/app/globals.css +14 -0
  36. package/src/app/layout.tsx +5 -20
  37. package/src/cli/index.js +33 -1
  38. package/src/cli/spec.js +40 -0
  39. package/src/components/agents/agent-avatar.tsx +45 -0
  40. package/src/components/agents/agent-card.tsx +19 -5
  41. package/src/components/agents/agent-chat-list.tsx +31 -24
  42. package/src/components/agents/agent-files-editor.tsx +185 -0
  43. package/src/components/agents/agent-list.tsx +82 -3
  44. package/src/components/agents/agent-sheet.tsx +31 -0
  45. package/src/components/agents/cron-job-form.tsx +137 -0
  46. package/src/components/agents/exec-config-panel.tsx +147 -0
  47. package/src/components/agents/inspector-panel.tsx +310 -0
  48. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  49. package/src/components/agents/permission-preset-selector.tsx +79 -0
  50. package/src/components/agents/personality-builder.tsx +111 -0
  51. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  52. package/src/components/agents/skill-install-dialog.tsx +102 -0
  53. package/src/components/agents/trash-list.tsx +109 -0
  54. package/src/components/chat/chat-area.tsx +14 -2
  55. package/src/components/chat/chat-header.tsx +168 -4
  56. package/src/components/chat/chat-preview-panel.tsx +113 -0
  57. package/src/components/chat/exec-approval-card.tsx +89 -0
  58. package/src/components/chat/message-bubble.tsx +218 -36
  59. package/src/components/chat/message-list.tsx +135 -31
  60. package/src/components/chat/streaming-bubble.tsx +59 -10
  61. package/src/components/chat/suggestions-bar.tsx +74 -0
  62. package/src/components/chat/thinking-indicator.tsx +20 -6
  63. package/src/components/chat/tool-call-bubble.tsx +89 -16
  64. package/src/components/chat/tool-request-banner.tsx +20 -2
  65. package/src/components/chat/trace-block.tsx +103 -0
  66. package/src/components/projects/project-list.tsx +1 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  68. package/src/components/shared/avatar.tsx +13 -2
  69. package/src/components/shared/settings/settings-page.tsx +1 -0
  70. package/src/components/tasks/task-board.tsx +1 -1
  71. package/src/components/tasks/task-sheet.tsx +12 -12
  72. package/src/hooks/use-continuous-speech.ts +42 -5
  73. package/src/hooks/use-openclaw-gateway.ts +63 -0
  74. package/src/lib/notification-sounds.ts +58 -0
  75. package/src/lib/personality-parser.ts +97 -0
  76. package/src/lib/providers/openclaw.ts +17 -2
  77. package/src/lib/runtime-loop.ts +2 -2
  78. package/src/lib/server/chat-execution.ts +44 -2
  79. package/src/lib/server/connectors/bluebubbles.test.ts +17 -8
  80. package/src/lib/server/connectors/bluebubbles.ts +5 -2
  81. package/src/lib/server/connectors/googlechat.ts +14 -10
  82. package/src/lib/server/connectors/manager.ts +37 -15
  83. package/src/lib/server/connectors/openclaw.ts +1 -0
  84. package/src/lib/server/daemon-state.ts +11 -0
  85. package/src/lib/server/main-agent-loop.ts +2 -3
  86. package/src/lib/server/main-session.ts +21 -0
  87. package/src/lib/server/openclaw-config-sync.ts +107 -0
  88. package/src/lib/server/openclaw-exec-config.ts +52 -0
  89. package/src/lib/server/openclaw-gateway.ts +291 -0
  90. package/src/lib/server/openclaw-history-merge.ts +36 -0
  91. package/src/lib/server/openclaw-models.ts +56 -0
  92. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  93. package/src/lib/server/openclaw-sync.ts +11 -10
  94. package/src/lib/server/queue.ts +2 -1
  95. package/src/lib/server/session-tools/connector.ts +4 -4
  96. package/src/lib/server/session-tools/delegate.ts +19 -3
  97. package/src/lib/server/session-tools/file.ts +20 -20
  98. package/src/lib/server/session-tools/index.ts +2 -2
  99. package/src/lib/server/session-tools/openclaw-nodes.ts +6 -6
  100. package/src/lib/server/session-tools/sandbox.ts +2 -2
  101. package/src/lib/server/session-tools/search-providers.ts +13 -6
  102. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  103. package/src/lib/server/session-tools/shell.ts +1 -1
  104. package/src/lib/server/session-tools/web.ts +8 -8
  105. package/src/lib/server/storage.ts +62 -11
  106. package/src/lib/server/stream-agent-chat.ts +24 -4
  107. package/src/lib/server/suggestions.ts +20 -0
  108. package/src/lib/server/ws-hub.ts +14 -0
  109. package/src/stores/use-app-store.ts +53 -1
  110. package/src/stores/use-approval-store.ts +78 -0
  111. package/src/stores/use-chat-store.ts +153 -5
  112. package/src/types/index.ts +127 -1
  113. package/tsconfig.json +13 -4
@@ -0,0 +1,107 @@
1
+ import { ensureGatewayConnected } from './openclaw-gateway'
2
+
3
+ export interface ConfigIssue {
4
+ id: string
5
+ severity: 'warning' | 'error'
6
+ title: string
7
+ description: string
8
+ repairAction?: string
9
+ }
10
+
11
+ /** Fetch gateway config and detect common issues */
12
+ export async function detectConfigIssues(): Promise<ConfigIssue[]> {
13
+ const gw = await ensureGatewayConnected()
14
+ if (!gw) return [{ id: 'no-connection', severity: 'error', title: 'Not Connected', description: 'Gateway is not connected.' }]
15
+
16
+ let config: Record<string, unknown>
17
+ try {
18
+ config = (await gw.rpc('config.get')) as Record<string, unknown> ?? {}
19
+ } catch {
20
+ return [{ id: 'config-fetch-failed', severity: 'error', title: 'Config Fetch Failed', description: 'Could not retrieve gateway configuration.' }]
21
+ }
22
+
23
+ const issues: ConfigIssue[] = []
24
+
25
+ // Check sandbox env allowlist
26
+ const agentsDefaults = config.agents as Record<string, unknown> | undefined
27
+ const sandbox = (agentsDefaults?.defaults as Record<string, unknown>)?.sandbox as Record<string, unknown> | undefined
28
+ const docker = sandbox?.docker as Record<string, unknown> | undefined
29
+ const envArr = docker?.env as string[] | undefined
30
+ if (!envArr || envArr.length === 0) {
31
+ issues.push({
32
+ id: 'empty-sandbox-env',
33
+ severity: 'warning',
34
+ title: 'Empty Sandbox Env',
35
+ description: 'No environment variables are allowed in the sandbox. Agents may lack API access.',
36
+ repairAction: 'sandbox-env-defaults',
37
+ })
38
+ }
39
+
40
+ // Check model defaults
41
+ const models = config.models as Record<string, unknown> | undefined
42
+ const defaultModel = models?.default as string | undefined
43
+ if (!defaultModel) {
44
+ issues.push({
45
+ id: 'no-default-model',
46
+ severity: 'warning',
47
+ title: 'No Default Model',
48
+ description: 'No default model is configured. Agents will need explicit model assignment.',
49
+ repairAction: 'set-default-model',
50
+ })
51
+ }
52
+
53
+ // Check reload mode
54
+ const reloadMode = config.reloadMode as string | undefined
55
+ if (reloadMode === 'full') {
56
+ issues.push({
57
+ id: 'full-reload-mode',
58
+ severity: 'warning',
59
+ title: 'Full Reload Mode',
60
+ description: 'Gateway is in full reload mode. This restarts all agents on config change, which may disrupt running sessions.',
61
+ })
62
+ }
63
+
64
+ return issues
65
+ }
66
+
67
+ /** Attempt to repair a specific config issue with hash-based retry */
68
+ export async function repairConfigIssue(issueId: string): Promise<{ ok: boolean; error?: string }> {
69
+ const gw = await ensureGatewayConnected()
70
+ if (!gw) return { ok: false, error: 'Gateway not connected' }
71
+
72
+ const MAX_RETRIES = 3
73
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
74
+ try {
75
+ const config = (await gw.rpc('config.get')) as Record<string, unknown> & { _hash?: string } ?? {}
76
+ const baseHash = config._hash as string | undefined
77
+
78
+ switch (issueId) {
79
+ case 'sandbox-env-defaults': {
80
+ // Set common env vars as defaults
81
+ const defaultEnvVars = ['${OPENAI_API_KEY}', '${ANTHROPIC_API_KEY}']
82
+ await gw.rpc('config.set', {
83
+ path: 'agents.defaults.sandbox.docker.env',
84
+ value: defaultEnvVars,
85
+ ...(baseHash ? { baseHash } : {}),
86
+ })
87
+ return { ok: true }
88
+ }
89
+ case 'set-default-model': {
90
+ await gw.rpc('config.set', {
91
+ path: 'models.default',
92
+ value: 'claude-sonnet-4-20250514',
93
+ ...(baseHash ? { baseHash } : {}),
94
+ })
95
+ return { ok: true }
96
+ }
97
+ default:
98
+ return { ok: false, error: `Unknown issue: ${issueId}` }
99
+ }
100
+ } catch (err: unknown) {
101
+ const msg = err instanceof Error ? err.message : String(err)
102
+ if (msg.includes('conflict') && attempt < MAX_RETRIES - 1) continue
103
+ return { ok: false, error: msg }
104
+ }
105
+ }
106
+ return { ok: false, error: 'Max retries exceeded' }
107
+ }
@@ -0,0 +1,52 @@
1
+ import type { ExecApprovalConfig, ExecApprovalSnapshot } from '@/types'
2
+ import { ensureGatewayConnected } from './openclaw-gateway'
3
+
4
+ const DEFAULT_CONFIG: ExecApprovalConfig = {
5
+ security: 'deny',
6
+ askMode: 'off',
7
+ patterns: [],
8
+ }
9
+
10
+ /** Fetch exec approval config from gateway for a given agent */
11
+ export async function getExecConfig(agentId: string): Promise<ExecApprovalSnapshot> {
12
+ const gw = await ensureGatewayConnected()
13
+ if (!gw) throw new Error('Gateway not connected')
14
+
15
+ const result = await gw.rpc('exec.approvals.get', { agentId }) as ExecApprovalSnapshot | undefined
16
+ if (!result) {
17
+ return { path: '', exists: false, hash: '', file: { ...DEFAULT_CONFIG } }
18
+ }
19
+ return result
20
+ }
21
+
22
+ /** Save exec approval config with hash-based conflict retry (up to 3 attempts) */
23
+ export async function setExecConfig(
24
+ agentId: string,
25
+ config: ExecApprovalConfig,
26
+ baseHash: string,
27
+ ): Promise<{ ok: boolean; hash: string }> {
28
+ const gw = await ensureGatewayConnected()
29
+ if (!gw) throw new Error('Gateway not connected')
30
+
31
+ let currentHash = baseHash
32
+ for (let attempt = 0; attempt < 3; attempt++) {
33
+ try {
34
+ const result = await gw.rpc('exec.approvals.set', {
35
+ agentId,
36
+ file: config,
37
+ baseHash: currentHash,
38
+ }) as { hash?: string } | undefined
39
+ return { ok: true, hash: result?.hash ?? '' }
40
+ } catch (err: unknown) {
41
+ const msg = err instanceof Error ? err.message : String(err)
42
+ if (msg.includes('conflict') && attempt < 2) {
43
+ // Re-fetch to get fresh hash
44
+ const fresh = await getExecConfig(agentId)
45
+ currentHash = fresh.hash
46
+ continue
47
+ }
48
+ throw err
49
+ }
50
+ }
51
+ throw new Error('Failed after 3 conflict retries')
52
+ }
@@ -0,0 +1,291 @@
1
+ import { WebSocket } from 'ws'
2
+ import { randomUUID } from 'crypto'
3
+ import { wsConnect, buildOpenClawConnectParams } from '../providers/openclaw'
4
+ import { loadAgents, loadCredentials, decryptKey } from './storage'
5
+ import { notify, notifyWithPayload } from './ws-hub'
6
+
7
+ // --- Types ---
8
+
9
+ interface PendingRpc {
10
+ resolve: (value: unknown) => void
11
+ reject: (err: Error) => void
12
+ timer: ReturnType<typeof setTimeout>
13
+ }
14
+
15
+ type EventHandler = (payload: unknown) => void
16
+
17
+ // --- Singleton (HMR-safe) ---
18
+
19
+ const GK = '__swarmclaw_ocgateway__' as const
20
+
21
+ interface GatewayState {
22
+ instance: OpenClawGateway | null
23
+ }
24
+
25
+ function getState(): GatewayState {
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
+ const g = globalThis as any
28
+ if (!g[GK]) g[GK] = { instance: null }
29
+ return g[GK] as GatewayState
30
+ }
31
+
32
+ // --- Helper: resolve gateway config from first OpenClaw agent ---
33
+
34
+ interface GatewayConfig {
35
+ wsUrl: string
36
+ token: string | undefined
37
+ }
38
+
39
+ function normalizeWsUrl(raw: string): string {
40
+ let url = raw.replace(/\/+$/, '')
41
+ if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
42
+ url = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
43
+ return url.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
44
+ }
45
+
46
+ export function resolveGatewayConfig(): GatewayConfig | null {
47
+ const agents = loadAgents({ includeTrashed: true })
48
+ const creds = loadCredentials()
49
+ for (const agent of Object.values(agents)) {
50
+ if (agent?.provider !== 'openclaw') continue
51
+ const wsUrl = agent.apiEndpoint
52
+ ? normalizeWsUrl(agent.apiEndpoint)
53
+ : 'ws://127.0.0.1:18789'
54
+ let token: string | undefined
55
+ if (agent.credentialId) {
56
+ const cred = creds[agent.credentialId]
57
+ if (cred?.encryptedKey) {
58
+ try { token = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
59
+ }
60
+ }
61
+ return { wsUrl, token }
62
+ }
63
+ return null
64
+ }
65
+
66
+ export function hasOpenClawAgents(): boolean {
67
+ const agents = loadAgents({ includeTrashed: true })
68
+ return Object.values(agents).some((a) => a?.provider === 'openclaw' && !a.trashedAt)
69
+ }
70
+
71
+ // --- Gateway Client ---
72
+
73
+ export class OpenClawGateway {
74
+ private ws: WebSocket | null = null
75
+ private pending = new Map<string, PendingRpc>()
76
+ private eventListeners = new Map<string, Set<EventHandler>>()
77
+ private _connected = false
78
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
79
+ private reconnectDelay = 800
80
+ private shouldReconnect = false
81
+ private wsUrl = ''
82
+ private token: string | undefined
83
+
84
+ get connected(): boolean { return this._connected }
85
+
86
+ async connect(wsUrl: string, token: string | undefined): Promise<boolean> {
87
+ this.wsUrl = wsUrl
88
+ this.token = token
89
+ this.shouldReconnect = true
90
+ return this.doConnect()
91
+ }
92
+
93
+ private async doConnect(): Promise<boolean> {
94
+ if (this._connected && this.ws?.readyState === WebSocket.OPEN) return true
95
+
96
+ try {
97
+ const result = await wsConnect(this.wsUrl, this.token, true, 15_000)
98
+ if (!result.ok || !result.ws) {
99
+ console.error('[openclaw-gateway] Connect failed:', result.message)
100
+ this.scheduleReconnect()
101
+ return false
102
+ }
103
+
104
+ this.ws = result.ws
105
+ this._connected = true
106
+ this.reconnectDelay = 800
107
+ console.log('[openclaw-gateway] Connected to gateway')
108
+
109
+ this.ws.on('message', (data) => {
110
+ try {
111
+ const msg = JSON.parse(data.toString())
112
+ this.handleMessage(msg)
113
+ } catch { /* ignore malformed */ }
114
+ })
115
+
116
+ this.ws.on('close', () => {
117
+ this._connected = false
118
+ this.ws = null
119
+ this.rejectAllPending('Gateway connection closed')
120
+ if (this.shouldReconnect) this.scheduleReconnect()
121
+ })
122
+
123
+ this.ws.on('error', () => {
124
+ // onclose fires after this
125
+ })
126
+
127
+ return true
128
+ } catch (err: unknown) {
129
+ console.error('[openclaw-gateway] Connect error:', err instanceof Error ? err.message : String(err))
130
+ this.scheduleReconnect()
131
+ return false
132
+ }
133
+ }
134
+
135
+ disconnect() {
136
+ this.shouldReconnect = false
137
+ if (this.reconnectTimer) {
138
+ clearTimeout(this.reconnectTimer)
139
+ this.reconnectTimer = null
140
+ }
141
+ this.rejectAllPending('Disconnecting')
142
+ if (this.ws) {
143
+ try { this.ws.close() } catch { /* ignore */ }
144
+ this.ws = null
145
+ }
146
+ this._connected = false
147
+ console.log('[openclaw-gateway] Disconnected')
148
+ }
149
+
150
+ private scheduleReconnect() {
151
+ if (this.reconnectTimer || !this.shouldReconnect) return
152
+ this.reconnectTimer = setTimeout(() => {
153
+ this.reconnectTimer = null
154
+ if (!this.shouldReconnect) return
155
+ this.doConnect().catch(() => {})
156
+ }, this.reconnectDelay)
157
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 15_000)
158
+ }
159
+
160
+ private rejectAllPending(reason: string) {
161
+ for (const [id, p] of this.pending) {
162
+ clearTimeout(p.timer)
163
+ p.reject(new Error(reason))
164
+ }
165
+ this.pending.clear()
166
+ }
167
+
168
+ // --- RPC ---
169
+
170
+ rpc(method: string, params?: unknown, timeoutMs = 30_000): Promise<unknown> {
171
+ return new Promise((resolve, reject) => {
172
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
173
+ return reject(new Error('Gateway not connected'))
174
+ }
175
+ const id = randomUUID()
176
+ const timer = setTimeout(() => {
177
+ this.pending.delete(id)
178
+ reject(new Error(`RPC ${method} timed out`))
179
+ }, timeoutMs)
180
+
181
+ this.pending.set(id, { resolve, reject, timer })
182
+ this.ws.send(JSON.stringify({ type: 'req', id, method, params }))
183
+ })
184
+ }
185
+
186
+ // --- Events ---
187
+
188
+ on(event: string, handler: EventHandler) {
189
+ let set = this.eventListeners.get(event)
190
+ if (!set) {
191
+ set = new Set()
192
+ this.eventListeners.set(event, set)
193
+ }
194
+ set.add(handler)
195
+ }
196
+
197
+ off(event: string, handler: EventHandler) {
198
+ const set = this.eventListeners.get(event)
199
+ if (!set) return
200
+ set.delete(handler)
201
+ if (set.size === 0) this.eventListeners.delete(event)
202
+ }
203
+
204
+ private handleMessage(msg: Record<string, unknown>) {
205
+ // RPC response
206
+ if (msg.type === 'res' && typeof msg.id === 'string') {
207
+ const p = this.pending.get(msg.id)
208
+ if (p) {
209
+ this.pending.delete(msg.id)
210
+ clearTimeout(p.timer)
211
+ if (msg.ok) {
212
+ p.resolve(msg.payload)
213
+ } else {
214
+ const errMsg = (msg.error as Record<string, unknown>)?.message
215
+ p.reject(new Error(typeof errMsg === 'string' ? errMsg : 'RPC failed'))
216
+ }
217
+ }
218
+ return
219
+ }
220
+
221
+ // Event dispatch
222
+ if (msg.type === 'event' || msg.event) {
223
+ const eventName = (msg.event || msg.type) as string
224
+ const payload = msg.payload ?? msg.data ?? msg
225
+
226
+ // Dispatch to registered listeners
227
+ const handlers = this.eventListeners.get(eventName)
228
+ if (handlers) {
229
+ for (const h of handlers) {
230
+ try { h(payload) } catch { /* ignore handler errors */ }
231
+ }
232
+ }
233
+
234
+ // Push to browser clients via ws-hub
235
+ if (eventName.startsWith('exec.approval')) {
236
+ notifyWithPayload('openclaw:approvals', { event: eventName, payload })
237
+ } else if (eventName.startsWith('agent')) {
238
+ notify('openclaw:agents')
239
+ } else if (eventName.startsWith('skill')) {
240
+ notify('openclaw:skills')
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ // --- Singleton access ---
247
+
248
+ export function getGateway(): OpenClawGateway | null {
249
+ return getState().instance
250
+ }
251
+
252
+ export async function ensureGatewayConnected(): Promise<OpenClawGateway | null> {
253
+ const state = getState()
254
+ if (state.instance?.connected) return state.instance
255
+
256
+ const config = resolveGatewayConfig()
257
+ if (!config) return null
258
+
259
+ if (!state.instance) {
260
+ state.instance = new OpenClawGateway()
261
+ }
262
+
263
+ const ok = await state.instance.connect(config.wsUrl, config.token)
264
+ return ok ? state.instance : null
265
+ }
266
+
267
+ export function disconnectGateway() {
268
+ const state = getState()
269
+ if (state.instance) {
270
+ state.instance.disconnect()
271
+ state.instance = null
272
+ }
273
+ }
274
+
275
+ /** Manual connect with explicit URL/token (used by gateway connection panel) */
276
+ export async function manualConnect(url?: string, token?: string): Promise<boolean> {
277
+ const state = getState()
278
+ if (state.instance?.connected) {
279
+ state.instance.disconnect()
280
+ }
281
+
282
+ const config = resolveGatewayConfig()
283
+ const wsUrl = url ? normalizeWsUrl(url) : config?.wsUrl ?? 'ws://127.0.0.1:18789'
284
+ const resolvedToken = token ?? config?.token
285
+
286
+ if (!state.instance) {
287
+ state.instance = new OpenClawGateway()
288
+ }
289
+
290
+ return state.instance.connect(wsUrl, resolvedToken)
291
+ }
@@ -0,0 +1,36 @@
1
+ import type { Message, GatewaySessionPreview } from '@/types'
2
+
3
+ /** Merge gateway history messages into local messages, deduplicating by timestamp */
4
+ export function mergeHistoryMessages(
5
+ localMessages: Message[],
6
+ preview: GatewaySessionPreview,
7
+ ): Message[] {
8
+ const localTimestamps = new Set(localMessages.map((m) => m.time))
9
+
10
+ const newMessages: Message[] = []
11
+ for (const gm of preview.messages) {
12
+ // Skip if we already have a message at this timestamp
13
+ if (localTimestamps.has(gm.ts)) continue
14
+
15
+ const role = gm.role === 'user' ? 'user' as const : 'assistant' as const
16
+ newMessages.push({
17
+ role,
18
+ text: gm.content,
19
+ time: gm.ts,
20
+ kind: 'chat',
21
+ })
22
+ }
23
+
24
+ if (newMessages.length === 0) return localMessages
25
+
26
+ // Merge and sort by timestamp
27
+ const merged = [...localMessages, ...newMessages]
28
+ merged.sort((a, b) => a.time - b.time)
29
+
30
+ return merged
31
+ }
32
+
33
+ /** Validate a session key matches expected format */
34
+ export function isValidSessionKey(key: string): boolean {
35
+ return typeof key === 'string' && key.length > 0 && key.length < 256
36
+ }
@@ -0,0 +1,56 @@
1
+ import { ensureGatewayConnected } from './openclaw-gateway'
2
+
3
+ interface ModelPolicy {
4
+ defaultModel?: string
5
+ allowedModels?: string[]
6
+ fetchedAt: number
7
+ }
8
+
9
+ let cachedPolicy: ModelPolicy | null = null
10
+ const CACHE_TTL = 60_000 // 60 seconds
11
+
12
+ export async function fetchGatewayModelPolicy(): Promise<ModelPolicy | null> {
13
+ if (cachedPolicy && Date.now() - cachedPolicy.fetchedAt < CACHE_TTL) {
14
+ return cachedPolicy
15
+ }
16
+
17
+ const gw = await ensureGatewayConnected()
18
+ if (!gw) return cachedPolicy ?? null
19
+
20
+ try {
21
+ const result = await gw.rpc('config.get') as Record<string, unknown> | undefined
22
+ if (!result) return null
23
+
24
+ const agentDefaults = (result.agents as Record<string, unknown>)?.defaults as Record<string, unknown> | undefined
25
+ const defaultModel = typeof agentDefaults?.model === 'string' ? agentDefaults.model : undefined
26
+ const rawModels = agentDefaults?.models
27
+
28
+ let allowedModels: string[] | undefined
29
+ if (Array.isArray(rawModels)) {
30
+ allowedModels = rawModels.filter((m): m is string => typeof m === 'string')
31
+ }
32
+
33
+ cachedPolicy = {
34
+ defaultModel,
35
+ allowedModels,
36
+ fetchedAt: Date.now(),
37
+ }
38
+ return cachedPolicy
39
+ } catch {
40
+ return cachedPolicy ?? null
41
+ }
42
+ }
43
+
44
+ export function buildAllowedModelKeys(policy: ModelPolicy | null): string[] | null {
45
+ if (!policy) return null
46
+ const models = new Set<string>()
47
+ if (policy.defaultModel) models.add(policy.defaultModel)
48
+ if (policy.allowedModels) {
49
+ for (const m of policy.allowedModels) models.add(m)
50
+ }
51
+ return models.size > 0 ? Array.from(models) : null
52
+ }
53
+
54
+ export function invalidateModelPolicyCache() {
55
+ cachedPolicy = null
56
+ }
@@ -0,0 +1,64 @@
1
+ import type { ExecApprovalConfig, PermissionPreset } from '@/types'
2
+ import { ensureGatewayConnected } from './openclaw-gateway'
3
+ import { setExecConfig, getExecConfig } from './openclaw-exec-config'
4
+
5
+ export interface PresetConfig {
6
+ security: ExecApprovalConfig['security']
7
+ askMode: ExecApprovalConfig['askMode']
8
+ toolGroups: string[]
9
+ }
10
+
11
+ export const PRESET_CONFIGS: Record<PermissionPreset, PresetConfig> = {
12
+ conservative: {
13
+ security: 'deny',
14
+ askMode: 'off',
15
+ toolGroups: [],
16
+ },
17
+ collaborative: {
18
+ security: 'allowlist',
19
+ askMode: 'on-miss',
20
+ toolGroups: ['group:web', 'group:fs'],
21
+ },
22
+ autonomous: {
23
+ security: 'full',
24
+ askMode: 'off',
25
+ toolGroups: ['group:runtime', 'group:web', 'group:fs'],
26
+ },
27
+ }
28
+
29
+ /** Derive which preset matches the current config, or 'custom' if none match */
30
+ export function resolvePresetFromConfig(config: ExecApprovalConfig): PermissionPreset | 'custom' {
31
+ for (const [preset, pc] of Object.entries(PRESET_CONFIGS) as [PermissionPreset, PresetConfig][]) {
32
+ if (config.security === pc.security && config.askMode === pc.askMode) {
33
+ return preset
34
+ }
35
+ }
36
+ return 'custom'
37
+ }
38
+
39
+ /** Apply a permission preset to an agent via gateway RPC */
40
+ export async function applyPreset(agentId: string, preset: PermissionPreset): Promise<void> {
41
+ const pc = PRESET_CONFIGS[preset]
42
+ if (!pc) throw new Error(`Unknown preset: ${preset}`)
43
+
44
+ const gw = await ensureGatewayConnected()
45
+ if (!gw) throw new Error('Gateway not connected')
46
+
47
+ // Update exec approval config
48
+ const snap = await getExecConfig(agentId)
49
+ await setExecConfig(agentId, {
50
+ security: pc.security,
51
+ askMode: pc.askMode,
52
+ patterns: pc.security === 'allowlist' ? snap.file.patterns : [],
53
+ }, snap.hash)
54
+
55
+ // Sync tool groups if gateway supports it
56
+ try {
57
+ await gw.rpc('config.set', {
58
+ key: `agents.${agentId}.toolGroups`,
59
+ value: pc.toolGroups,
60
+ })
61
+ } catch {
62
+ // Not all gateways support tool group config — ignore
63
+ }
64
+ }
@@ -252,12 +252,12 @@ export function pushSchedulesToOpenClaw(): { written: number } {
252
252
  const cronDir = path.join(config.workspacePath, 'cron')
253
253
  ensureDir(cronDir)
254
254
 
255
- const schedules = loadSchedules()
255
+ const schedules = loadSchedules() as Record<string, Schedule>
256
256
  const cronSchedules = Object.values(schedules).filter(
257
- (s: any) => s.scheduleType === 'cron' && s.status === 'active',
257
+ (s) => s.scheduleType === 'cron' && s.status === 'active',
258
258
  )
259
259
 
260
- const jobs = cronSchedules.map((s: any) => ({
260
+ const jobs = cronSchedules.map((s) => ({
261
261
  name: s.name,
262
262
  cron: s.cron,
263
263
  agentId: s.agentId,
@@ -277,8 +277,8 @@ export function pullSchedulesFromOpenClaw(): { imported: number } {
277
277
  const raw = JSON.parse(fs.readFileSync(jobsPath, 'utf8'))
278
278
  if (!Array.isArray(raw)) return { imported: 0 }
279
279
 
280
- const schedules = loadSchedules()
281
- const existingNames = new Set(Object.values(schedules).map((s: any) => `${s.name}|${s.cron}`))
280
+ const schedules = loadSchedules() as Record<string, Schedule>
281
+ const existingNames = new Set(Object.values(schedules).map((s) => `${s.name}|${s.cron}`))
282
282
  let imported = 0
283
283
 
284
284
  for (const job of raw) {
@@ -307,11 +307,12 @@ export function pullSchedulesFromOpenClaw(): { imported: number } {
307
307
 
308
308
  // --- Secret/Credential Sync (Feature 7) ---
309
309
 
310
- export function pullCredentialsFromOpenClaw(): { imported: number } {
310
+ export async function pullCredentialsFromOpenClaw(): Promise<{ imported: number }> {
311
311
  const config = loadSyncConfig()
312
312
  const modelsPath = path.join(config.workspacePath, 'agents', 'main', 'agent', 'models.json')
313
313
  if (!fs.existsSync(modelsPath)) return { imported: 0 }
314
314
 
315
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
316
  let raw: any
316
317
  try {
317
318
  raw = JSON.parse(fs.readFileSync(modelsPath, 'utf8'))
@@ -319,9 +320,9 @@ export function pullCredentialsFromOpenClaw(): { imported: number } {
319
320
  return { imported: 0 }
320
321
  }
321
322
 
322
- const { loadCredentials: loadCreds, saveCredentials } = require('./storage')
323
+ const { loadCredentials: loadCreds, saveCredentials } = await import('./storage')
323
324
  const creds = loadCreds()
324
- const existingProviders = new Set(Object.values(creds).map((c: any) => c.provider))
325
+ const existingProviders = new Set(Object.values(creds).map((c: Record<string, unknown>) => c.provider))
325
326
  let imported = 0
326
327
 
327
328
  // Extract API keys from models.json entries
@@ -352,7 +353,7 @@ export function pushCredentialsToOpenClaw(): { written: boolean } {
352
353
  const creds = loadCredentials()
353
354
 
354
355
  const profiles: Record<string, string> = {}
355
- for (const cred of Object.values(creds) as any[]) {
356
+ for (const cred of Object.values(creds) as Array<Record<string, string>>) {
356
357
  if (!cred.encryptedKey || !cred.provider) continue
357
358
  try {
358
359
  profiles[cred.provider] = decryptKey(cred.encryptedKey)
@@ -483,7 +484,7 @@ export async function runSync(params: {
483
484
  results.push({ type, action: 'pull', result: pullSchedulesFromOpenClaw() })
484
485
  break
485
486
  case 'credentials':
486
- results.push({ type, action: 'pull', result: pullCredentialsFromOpenClaw() })
487
+ results.push({ type, action: 'pull', result: await pullCredentialsFromOpenClaw() })
487
488
  break
488
489
  case 'plugins':
489
490
  results.push({ type, action: 'pull', result: syncPluginsFromOpenClaw() })
@@ -9,6 +9,7 @@ import { pushMainLoopEventToMainSessions } from './main-agent-loop'
9
9
  import { executeSessionChatTurn } from './chat-execution'
10
10
  import { extractTaskResult, formatResultBody } from './task-result'
11
11
  import { getCheckpointSaver } from './langgraph-checkpoint'
12
+ import { isProtectedMainSession } from './main-session'
12
13
  import type { Agent, BoardTask, Message } from '@/types'
13
14
 
14
15
  let processing = false
@@ -82,7 +83,7 @@ function pushQueueUnique(queue: string[], id: string): void {
82
83
  }
83
84
 
84
85
  function isMainSession(session: SessionLike | null | undefined): boolean {
85
- return session?.name === '__main__'
86
+ return isProtectedMainSession(session)
86
87
  }
87
88
 
88
89
  function resolveTaskOwnerUser(task: ScheduleTaskMeta, sessions: Record<string, SessionLike>): string | null {