@swarmclawai/swarmclaw 0.4.0 → 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 (209) hide show
  1. package/README.md +21 -4
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +13 -0
  4. package/package.json +3 -1
  5. package/src/app/api/agents/[id]/route.ts +39 -22
  6. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  7. package/src/app/api/agents/route.ts +3 -2
  8. package/src/app/api/agents/trash/route.ts +44 -0
  9. package/src/app/api/clawhub/install/route.ts +2 -2
  10. package/src/app/api/connectors/[id]/route.ts +17 -7
  11. package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
  12. package/src/app/api/connectors/route.ts +6 -3
  13. package/src/app/api/credentials/[id]/route.ts +2 -1
  14. package/src/app/api/credentials/route.ts +2 -2
  15. package/src/app/api/documents/route.ts +2 -2
  16. package/src/app/api/files/serve/route.ts +8 -0
  17. package/src/app/api/knowledge/[id]/route.ts +5 -4
  18. package/src/app/api/knowledge/upload/route.ts +2 -2
  19. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  20. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  21. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  22. package/src/app/api/mcp-servers/route.ts +2 -2
  23. package/src/app/api/memory/[id]/route.ts +9 -8
  24. package/src/app/api/memory/route.ts +2 -2
  25. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  26. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  27. package/src/app/api/openclaw/approvals/route.ts +46 -0
  28. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  29. package/src/app/api/openclaw/cron/route.ts +52 -0
  30. package/src/app/api/openclaw/directory/route.ts +27 -0
  31. package/src/app/api/openclaw/discover/route.ts +62 -0
  32. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  33. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  34. package/src/app/api/openclaw/gateway/route.ts +72 -0
  35. package/src/app/api/openclaw/history/route.ts +109 -0
  36. package/src/app/api/openclaw/media/route.ts +53 -0
  37. package/src/app/api/openclaw/models/route.ts +12 -0
  38. package/src/app/api/openclaw/permissions/route.ts +39 -0
  39. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  40. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  41. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  42. package/src/app/api/openclaw/skills/route.ts +82 -0
  43. package/src/app/api/openclaw/sync/route.ts +31 -0
  44. package/src/app/api/orchestrator/run/route.ts +2 -2
  45. package/src/app/api/projects/[id]/route.ts +55 -0
  46. package/src/app/api/projects/route.ts +27 -0
  47. package/src/app/api/providers/[id]/models/route.ts +2 -1
  48. package/src/app/api/providers/[id]/route.ts +13 -15
  49. package/src/app/api/providers/route.ts +2 -2
  50. package/src/app/api/schedules/[id]/route.ts +16 -18
  51. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  52. package/src/app/api/schedules/route.ts +2 -2
  53. package/src/app/api/secrets/[id]/route.ts +16 -17
  54. package/src/app/api/secrets/route.ts +2 -2
  55. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  56. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  57. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  58. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  59. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  60. package/src/app/api/sessions/[id]/messages/route.ts +20 -2
  61. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  62. package/src/app/api/sessions/[id]/route.ts +14 -4
  63. package/src/app/api/sessions/route.ts +8 -4
  64. package/src/app/api/skills/[id]/route.ts +23 -21
  65. package/src/app/api/skills/import/route.ts +2 -2
  66. package/src/app/api/skills/route.ts +2 -2
  67. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  68. package/src/app/api/tasks/[id]/route.ts +6 -5
  69. package/src/app/api/tasks/route.ts +2 -2
  70. package/src/app/api/tts/stream/route.ts +48 -0
  71. package/src/app/api/upload/route.ts +2 -2
  72. package/src/app/api/uploads/[filename]/route.ts +4 -1
  73. package/src/app/api/webhooks/[id]/route.ts +29 -31
  74. package/src/app/api/webhooks/route.ts +2 -2
  75. package/src/app/globals.css +14 -0
  76. package/src/app/layout.tsx +5 -20
  77. package/src/app/page.tsx +3 -24
  78. package/src/cli/index.js +60 -0
  79. package/src/cli/index.ts +1 -1
  80. package/src/cli/spec.js +42 -0
  81. package/src/components/agents/agent-avatar.tsx +45 -0
  82. package/src/components/agents/agent-card.tsx +19 -5
  83. package/src/components/agents/agent-chat-list.tsx +31 -24
  84. package/src/components/agents/agent-files-editor.tsx +185 -0
  85. package/src/components/agents/agent-list.tsx +84 -3
  86. package/src/components/agents/agent-sheet.tsx +147 -14
  87. package/src/components/agents/cron-job-form.tsx +137 -0
  88. package/src/components/agents/exec-config-panel.tsx +147 -0
  89. package/src/components/agents/inspector-panel.tsx +310 -0
  90. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  91. package/src/components/agents/permission-preset-selector.tsx +79 -0
  92. package/src/components/agents/personality-builder.tsx +111 -0
  93. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  94. package/src/components/agents/skill-install-dialog.tsx +102 -0
  95. package/src/components/agents/trash-list.tsx +109 -0
  96. package/src/components/chat/chat-area.tsx +41 -6
  97. package/src/components/chat/chat-header.tsx +305 -29
  98. package/src/components/chat/chat-preview-panel.tsx +113 -0
  99. package/src/components/chat/exec-approval-card.tsx +89 -0
  100. package/src/components/chat/message-bubble.tsx +218 -36
  101. package/src/components/chat/message-list.tsx +135 -31
  102. package/src/components/chat/streaming-bubble.tsx +59 -10
  103. package/src/components/chat/suggestions-bar.tsx +74 -0
  104. package/src/components/chat/thinking-indicator.tsx +20 -6
  105. package/src/components/chat/tool-call-bubble.tsx +98 -19
  106. package/src/components/chat/tool-request-banner.tsx +20 -2
  107. package/src/components/chat/trace-block.tsx +103 -0
  108. package/src/components/chat/voice-overlay.tsx +80 -0
  109. package/src/components/connectors/connector-list.tsx +6 -2
  110. package/src/components/connectors/connector-sheet.tsx +31 -7
  111. package/src/components/layout/app-layout.tsx +47 -25
  112. package/src/components/projects/project-list.tsx +123 -0
  113. package/src/components/projects/project-sheet.tsx +135 -0
  114. package/src/components/schedules/schedule-list.tsx +3 -1
  115. package/src/components/sessions/new-session-sheet.tsx +6 -6
  116. package/src/components/sessions/session-card.tsx +1 -1
  117. package/src/components/sessions/session-list.tsx +7 -7
  118. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  119. package/src/components/shared/avatar.tsx +13 -2
  120. package/src/components/shared/connector-platform-icon.tsx +4 -0
  121. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  122. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  123. package/src/components/shared/settings/section-web-search.tsx +56 -0
  124. package/src/components/shared/settings/settings-page.tsx +74 -0
  125. package/src/components/skills/skill-list.tsx +2 -1
  126. package/src/components/tasks/task-board.tsx +1 -1
  127. package/src/components/tasks/task-list.tsx +5 -2
  128. package/src/components/tasks/task-sheet.tsx +12 -12
  129. package/src/hooks/use-continuous-speech.ts +181 -0
  130. package/src/hooks/use-openclaw-gateway.ts +63 -0
  131. package/src/hooks/use-view-router.ts +52 -0
  132. package/src/hooks/use-voice-conversation.ts +80 -0
  133. package/src/lib/id.ts +6 -0
  134. package/src/lib/notification-sounds.ts +58 -0
  135. package/src/lib/personality-parser.ts +97 -0
  136. package/src/lib/projects.ts +13 -0
  137. package/src/lib/provider-sets.ts +5 -0
  138. package/src/lib/providers/anthropic.ts +14 -1
  139. package/src/lib/providers/index.ts +6 -0
  140. package/src/lib/providers/ollama.ts +9 -1
  141. package/src/lib/providers/openai.ts +9 -1
  142. package/src/lib/providers/openclaw.ts +28 -2
  143. package/src/lib/runtime-loop.ts +2 -2
  144. package/src/lib/server/api-routes.test.ts +5 -6
  145. package/src/lib/server/build-llm.ts +17 -4
  146. package/src/lib/server/chat-execution.ts +82 -6
  147. package/src/lib/server/collection-helpers.ts +54 -0
  148. package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
  149. package/src/lib/server/connectors/bluebubbles.ts +360 -0
  150. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  151. package/src/lib/server/connectors/googlechat.ts +51 -8
  152. package/src/lib/server/connectors/manager.ts +424 -13
  153. package/src/lib/server/connectors/media.ts +2 -2
  154. package/src/lib/server/connectors/openclaw.ts +65 -0
  155. package/src/lib/server/connectors/pairing.test.ts +99 -0
  156. package/src/lib/server/connectors/pairing.ts +256 -0
  157. package/src/lib/server/connectors/signal.ts +1 -0
  158. package/src/lib/server/connectors/teams.ts +5 -5
  159. package/src/lib/server/connectors/types.ts +10 -0
  160. package/src/lib/server/daemon-state.ts +11 -0
  161. package/src/lib/server/execution-log.ts +3 -3
  162. package/src/lib/server/heartbeat-service.ts +1 -1
  163. package/src/lib/server/knowledge-db.test.ts +2 -33
  164. package/src/lib/server/main-agent-loop.ts +8 -9
  165. package/src/lib/server/main-session.ts +21 -0
  166. package/src/lib/server/memory-db.ts +6 -6
  167. package/src/lib/server/openclaw-approvals.ts +105 -0
  168. package/src/lib/server/openclaw-config-sync.ts +107 -0
  169. package/src/lib/server/openclaw-exec-config.ts +52 -0
  170. package/src/lib/server/openclaw-gateway.ts +291 -0
  171. package/src/lib/server/openclaw-history-merge.ts +36 -0
  172. package/src/lib/server/openclaw-models.ts +56 -0
  173. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  174. package/src/lib/server/openclaw-sync.ts +497 -0
  175. package/src/lib/server/orchestrator-lg.ts +30 -9
  176. package/src/lib/server/orchestrator.ts +4 -4
  177. package/src/lib/server/process-manager.ts +2 -2
  178. package/src/lib/server/queue.ts +24 -11
  179. package/src/lib/server/scheduler.ts +2 -2
  180. package/src/lib/server/session-mailbox.ts +2 -2
  181. package/src/lib/server/session-run-manager.ts +2 -2
  182. package/src/lib/server/session-tools/connector.ts +53 -6
  183. package/src/lib/server/session-tools/crud.ts +3 -3
  184. package/src/lib/server/session-tools/delegate.ts +22 -6
  185. package/src/lib/server/session-tools/file.ts +192 -19
  186. package/src/lib/server/session-tools/index.ts +4 -2
  187. package/src/lib/server/session-tools/memory.ts +2 -2
  188. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  189. package/src/lib/server/session-tools/sandbox.ts +33 -0
  190. package/src/lib/server/session-tools/search-providers.ts +277 -0
  191. package/src/lib/server/session-tools/session-info.ts +2 -2
  192. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  193. package/src/lib/server/session-tools/shell.ts +1 -1
  194. package/src/lib/server/session-tools/web.ts +53 -72
  195. package/src/lib/server/storage.ts +74 -11
  196. package/src/lib/server/stream-agent-chat.ts +53 -4
  197. package/src/lib/server/suggestions.ts +20 -0
  198. package/src/lib/server/task-result.test.ts +44 -0
  199. package/src/lib/server/task-result.ts +14 -0
  200. package/src/lib/server/ws-hub.ts +14 -0
  201. package/src/lib/tool-definitions.ts +5 -3
  202. package/src/lib/tts-stream.ts +130 -0
  203. package/src/lib/view-routes.ts +28 -0
  204. package/src/proxy.ts +3 -0
  205. package/src/stores/use-app-store.ts +80 -1
  206. package/src/stores/use-approval-store.ts +78 -0
  207. package/src/stores/use-chat-store.ts +162 -6
  208. package/src/types/index.ts +154 -3
  209. package/tsconfig.json +13 -4
@@ -0,0 +1,105 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import net from 'node:net'
4
+ import { resolveOpenClawWorkspace } from './openclaw-sync'
5
+
6
+ const APPROVAL_TIMEOUT_MS = 30_000
7
+
8
+ interface ApprovalRequest {
9
+ toolName: string
10
+ args: Record<string, unknown>
11
+ socketPath?: string
12
+ }
13
+
14
+ interface ApprovalResponse {
15
+ approved: boolean
16
+ reason?: string
17
+ }
18
+
19
+ function resolveSocketPath(): string | null {
20
+ try {
21
+ const workspace = resolveOpenClawWorkspace()
22
+ const socketPath = path.join(workspace, 'exec-approvals.sock')
23
+ if (fs.existsSync(socketPath)) return socketPath
24
+ } catch { /* workspace not found */ }
25
+ return null
26
+ }
27
+
28
+ function resolveApprovalToken(): string | null {
29
+ try {
30
+ const workspace = resolveOpenClawWorkspace()
31
+ const tokenPath = path.join(workspace, 'exec-approvals.json')
32
+ if (!fs.existsSync(tokenPath)) return null
33
+ const raw = JSON.parse(fs.readFileSync(tokenPath, 'utf8'))
34
+ return typeof raw?.token === 'string' ? raw.token : null
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Forward a tool approval request to OpenClaw's exec-approvals Unix socket.
42
+ * Returns the approval decision, or null if the socket is unavailable.
43
+ */
44
+ export async function forwardApprovalToOpenClaw(request: ApprovalRequest): Promise<ApprovalResponse | null> {
45
+ const socketPath = request.socketPath || resolveSocketPath()
46
+ if (!socketPath) return null
47
+
48
+ const token = resolveApprovalToken()
49
+
50
+ return new Promise<ApprovalResponse | null>((resolve) => {
51
+ const socket = net.createConnection({ path: socketPath }, () => {
52
+ const payload = JSON.stringify({
53
+ type: 'approval_request',
54
+ toolName: request.toolName,
55
+ args: request.args,
56
+ token,
57
+ timestamp: Date.now(),
58
+ })
59
+ socket.write(payload + '\n')
60
+ })
61
+
62
+ let data = ''
63
+ const timer = setTimeout(() => {
64
+ socket.destroy()
65
+ resolve(null) // Timeout — fall through to SwarmClaw UI
66
+ }, APPROVAL_TIMEOUT_MS)
67
+
68
+ socket.on('data', (chunk) => {
69
+ data += chunk.toString()
70
+ // Try to parse complete JSON response
71
+ try {
72
+ const response = JSON.parse(data.trim())
73
+ clearTimeout(timer)
74
+ socket.destroy()
75
+ resolve({
76
+ approved: response.approved === true,
77
+ reason: typeof response.reason === 'string' ? response.reason : undefined,
78
+ })
79
+ } catch {
80
+ // Incomplete data, wait for more
81
+ }
82
+ })
83
+
84
+ socket.on('error', () => {
85
+ clearTimeout(timer)
86
+ resolve(null) // Socket error — fall through
87
+ })
88
+
89
+ socket.on('close', () => {
90
+ clearTimeout(timer)
91
+ // If we haven't resolved yet, try to parse what we have
92
+ if (data.trim()) {
93
+ try {
94
+ const response = JSON.parse(data.trim())
95
+ resolve({
96
+ approved: response.approved === true,
97
+ reason: typeof response.reason === 'string' ? response.reason : undefined,
98
+ })
99
+ return
100
+ } catch { /* fall through */ }
101
+ }
102
+ resolve(null)
103
+ })
104
+ })
105
+ }
@@ -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
+ }