@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,97 @@
1
+ import type { PersonalityDraft } from '@/types'
2
+
3
+ // --- IDENTITY.md ---
4
+
5
+ export function parseIdentityMd(content: string): PersonalityDraft['identity'] {
6
+ const result: PersonalityDraft['identity'] = {}
7
+ const lines = content.split('\n')
8
+ for (const line of lines) {
9
+ const match = line.match(/^\s*-\s*(.+?):\s*(.+)$/)
10
+ if (!match) continue
11
+ const key = match[1].trim().toLowerCase()
12
+ const value = match[2].trim()
13
+ if (key === 'name') result.name = value
14
+ else if (key === 'creature' || key === 'species' || key === 'type') result.creature = value
15
+ else if (key === 'vibe' || key === 'personality') result.vibe = value
16
+ else if (key === 'emoji' || key === 'icon') result.emoji = value
17
+ }
18
+ return result
19
+ }
20
+
21
+ export function serializeIdentityMd(draft: PersonalityDraft['identity']): string {
22
+ const lines: string[] = ['# Identity', '']
23
+ if (draft.name) lines.push(`- Name: ${draft.name}`)
24
+ if (draft.creature) lines.push(`- Creature: ${draft.creature}`)
25
+ if (draft.vibe) lines.push(`- Vibe: ${draft.vibe}`)
26
+ if (draft.emoji) lines.push(`- Emoji: ${draft.emoji}`)
27
+ return lines.join('\n') + '\n'
28
+ }
29
+
30
+ // --- USER.md ---
31
+
32
+ export function parseUserMd(content: string): PersonalityDraft['user'] {
33
+ const result: PersonalityDraft['user'] = {}
34
+ const contextIdx = content.indexOf('## Context')
35
+ const headerPart = contextIdx >= 0 ? content.slice(0, contextIdx) : content
36
+ const contextPart = contextIdx >= 0 ? content.slice(contextIdx + '## Context'.length).trim() : ''
37
+
38
+ const lines = headerPart.split('\n')
39
+ for (const line of lines) {
40
+ const match = line.match(/^\s*-\s*(.+?):\s*(.+)$/)
41
+ if (!match) continue
42
+ const key = match[1].trim().toLowerCase()
43
+ const value = match[2].trim()
44
+ if (key === 'name') result.name = value
45
+ else if (key === 'call them' || key === 'nickname') result.callThem = value
46
+ else if (key === 'pronouns') result.pronouns = value
47
+ else if (key === 'timezone') result.timezone = value
48
+ else if (key === 'notes') result.notes = value
49
+ }
50
+
51
+ if (contextPart) result.context = contextPart
52
+
53
+ return result
54
+ }
55
+
56
+ export function serializeUserMd(draft: PersonalityDraft['user']): string {
57
+ const lines: string[] = ['# User', '']
58
+ if (draft.name) lines.push(`- Name: ${draft.name}`)
59
+ if (draft.callThem) lines.push(`- Call them: ${draft.callThem}`)
60
+ if (draft.pronouns) lines.push(`- Pronouns: ${draft.pronouns}`)
61
+ if (draft.timezone) lines.push(`- Timezone: ${draft.timezone}`)
62
+ if (draft.notes) lines.push(`- Notes: ${draft.notes}`)
63
+ if (draft.context) {
64
+ lines.push('', '## Context', '', draft.context)
65
+ }
66
+ return lines.join('\n') + '\n'
67
+ }
68
+
69
+ // --- SOUL.md ---
70
+
71
+ export function parseSoulMd(content: string): PersonalityDraft['soul'] {
72
+ const result: PersonalityDraft['soul'] = {}
73
+ const sections = content.split(/^##\s+/m)
74
+
75
+ for (const section of sections) {
76
+ const nlIdx = section.indexOf('\n')
77
+ if (nlIdx < 0) continue
78
+ const heading = section.slice(0, nlIdx).trim().toLowerCase()
79
+ const body = section.slice(nlIdx + 1).trim()
80
+
81
+ if (heading.startsWith('core truth')) result.coreTruths = body
82
+ else if (heading.startsWith('boundar')) result.boundaries = body
83
+ else if (heading.startsWith('vibe')) result.vibe = body
84
+ else if (heading.startsWith('continuity')) result.continuity = body
85
+ }
86
+
87
+ return result
88
+ }
89
+
90
+ export function serializeSoulMd(draft: PersonalityDraft['soul']): string {
91
+ const lines: string[] = ['# Soul', '']
92
+ if (draft.coreTruths) lines.push('## Core Truths', '', draft.coreTruths, '')
93
+ if (draft.boundaries) lines.push('## Boundaries', '', draft.boundaries, '')
94
+ if (draft.vibe) lines.push('## Vibe', '', draft.vibe, '')
95
+ if (draft.continuity) lines.push('## Continuity', '', draft.continuity, '')
96
+ return lines.join('\n')
97
+ }
@@ -65,6 +65,7 @@ function tryLoadIdentityFile(filePath: string): DeviceIdentity | null {
65
65
  function loadOrCreateDeviceIdentity(): DeviceIdentity {
66
66
  // 0. Check shared device token for cross-synced identity
67
67
  try {
68
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
68
69
  const { getSharedDeviceToken } = require('../server/openclaw-sync')
69
70
  const sharedToken = getSharedDeviceToken()
70
71
  if (sharedToken) {
@@ -344,8 +345,22 @@ export function streamOpenClawChat({ session, message, imagePath, apiKey, write,
344
345
  for (const p of payloads) {
345
346
  const text = typeof p.text === 'string' ? p.text.trimEnd() : ''
346
347
  if (text) {
347
- fullResponse += text
348
- write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
348
+ // Detect [[trace]], [[tool]], [[tool-result]], [[meta]] prefixes
349
+ const traceMatch = text.match(/^\[\[(thinking|tool|tool-result|trace|meta)\]\]/)
350
+ if (traceMatch) {
351
+ const traceType = traceMatch[1]
352
+ const traceContent = text.slice(traceMatch[0].length)
353
+ if (traceType === 'meta') {
354
+ write(`data: ${JSON.stringify({ t: 'md', text: traceContent })}\n\n`)
355
+ } else {
356
+ // Include as text (client-side will parse trace markers)
357
+ fullResponse += text
358
+ write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
359
+ }
360
+ } else {
361
+ fullResponse += text
362
+ write(`data: ${JSON.stringify({ t: 'd', text })}\n\n`)
363
+ }
349
364
  }
350
365
  }
351
366
  if (!fullResponse && msg.payload?.summary) {
@@ -11,5 +11,5 @@ export const DEFAULT_ONGOING_LOOP_MAX_RUNTIME_MINUTES = 60
11
11
 
12
12
  // Tool/process timeouts
13
13
  export const DEFAULT_SHELL_COMMAND_TIMEOUT_SEC = 30
14
- export const DEFAULT_CLAUDE_CODE_TIMEOUT_SEC = 120
15
- export const DEFAULT_CLI_PROCESS_TIMEOUT_SEC = 300
14
+ export const DEFAULT_CLAUDE_CODE_TIMEOUT_SEC = 1800
15
+ export const DEFAULT_CLI_PROCESS_TIMEOUT_SEC = 1800
@@ -343,13 +343,27 @@ function resolveApiKeyForSession(session: SessionWithCredentials, provider: Prov
343
343
 
344
344
  function classifyHeartbeatResponse(text: string, ackMaxChars: number): 'suppress' | 'strip' | 'keep' {
345
345
  const trimmed = text.trim()
346
- if (trimmed === 'HEARTBEAT_OK') return 'suppress'
347
- const stripped = trimmed.replace(/HEARTBEAT_OK/gi, '').trim()
346
+ if (trimmed === 'HEARTBEAT_OK' || trimmed === 'NO_MESSAGE') return 'suppress'
347
+ const stripped = trimmed.replace(/HEARTBEAT_OK/gi, '').replace(/NO_MESSAGE/gi, '').trim()
348
348
  if (!stripped) return 'suppress'
349
349
  if (stripped.length <= ackMaxChars) return 'suppress'
350
350
  return stripped.length < trimmed.length ? 'strip' : 'keep'
351
351
  }
352
352
 
353
+ function estimateConversationTone(text: string): string {
354
+ const t = text || ''
355
+ // Technical: code blocks, function signatures, technical terms
356
+ if (/```/.test(t) || /\b(function|const|let|var|import|export|class|interface|async|await|return)\b/.test(t)) return 'technical'
357
+ if (/\b(error|bug|debug|stack trace|exception|null|undefined|TypeError)\b/i.test(t)) return 'technical'
358
+ // Empathetic: emotional/supportive language
359
+ if (/\b(understand|feel|sorry|empathize|appreciate|grateful|tough|difficult|challenging)\b/i.test(t)) return 'empathetic'
360
+ // Formal: academic/business language
361
+ if (/\b(furthermore|regarding|consequently|therefore|henceforth|pursuant|accordingly|notwithstanding)\b/i.test(t)) return 'formal'
362
+ // Casual: contractions, exclamations, informal language
363
+ if (/\b(gonna|wanna|gotta|yeah|hey|awesome|cool|lol|btw|tbh)\b/i.test(t) || /!{2,}/.test(t)) return 'casual'
364
+ return 'neutral'
365
+ }
366
+
353
367
  const AUTO_MEMORY_MIN_INTERVAL_MS = 45 * 60 * 1000
354
368
 
355
369
  function normalizeMemoryText(value: string): string {
@@ -816,6 +830,26 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
816
830
  const finalText = (fullResponse || '').trim() || (!internal && errorMessage ? `Error: ${errorMessage}` : '')
817
831
  const textForPersistence = stripMainLoopMetaForPersistence(finalText, internal)
818
832
 
833
+ // Emit status SSE event from [MAIN_LOOP_META] if present
834
+ if (internal && finalText) {
835
+ const metaMatch = finalText.match(/\[MAIN_LOOP_META\]\s*(\{[^\n]*\})/i)
836
+ if (metaMatch) {
837
+ try {
838
+ const meta = JSON.parse(metaMatch[1])
839
+ const statusPayload: Record<string, string | undefined> = {}
840
+ if (meta.goal) statusPayload.goal = String(meta.goal)
841
+ if (meta.status) statusPayload.status = String(meta.status)
842
+ if (meta.summary) statusPayload.summary = String(meta.summary)
843
+ if (meta.next_action) statusPayload.nextAction = String(meta.next_action)
844
+ if (Object.keys(statusPayload).length > 0) {
845
+ emit({ t: 'status', text: JSON.stringify(statusPayload) })
846
+ }
847
+ } catch {
848
+ // ignore malformed meta JSON
849
+ }
850
+ }
851
+ }
852
+
819
853
  // HEARTBEAT_OK suppression
820
854
  const heartbeatConfig = input.heartbeatConfig
821
855
  let heartbeatClassification: 'suppress' | 'strip' | 'keep' | null = null
@@ -875,6 +909,14 @@ export async function executeSessionChatTurn(input: ExecuteChatTurnInput): Promi
875
909
  })
876
910
  changed = true
877
911
 
912
+ // Conversation tone detection
913
+ if (!internal) {
914
+ const tone = estimateConversationTone(persistedText)
915
+ if (tone !== current.conversationTone) {
916
+ current.conversationTone = tone
917
+ }
918
+ }
919
+
878
920
  // Target routing for non-suppressed heartbeat alerts
879
921
  if (isHeartbeatRun && heartbeatConfig?.target && heartbeatConfig.target !== 'none' && heartbeatConfig.showAlerts !== false) {
880
922
  try {
@@ -1,6 +1,8 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { test } from 'node:test'
3
3
  import bluebubbles from './bluebubbles.ts'
4
+ import type { Connector } from '@/types'
5
+ import type { InboundMessage } from './types'
4
6
 
5
7
  type FetchCall = {
6
8
  url: string
@@ -10,7 +12,7 @@ type FetchCall = {
10
12
  type MockResponse = {
11
13
  ok: boolean
12
14
  status: number
13
- json: () => Promise<any>
15
+ json: () => Promise<unknown>
14
16
  text: () => Promise<string>
15
17
  }
16
18
 
@@ -37,6 +39,7 @@ function textResponse(status: number, text: string): MockResponse {
37
39
  const originalFetch = globalThis.fetch
38
40
 
39
41
  test.afterEach(() => {
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
43
  ;(globalThis as any).fetch = originalFetch
41
44
  })
42
45
 
@@ -47,14 +50,15 @@ test('bluebubbles connector processes inbound webhook payloads and sends replies
47
50
  jsonResponse(200, { data: { guid: 'msg-1' } }), // send reply
48
51
  ]
49
52
 
53
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
54
  ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
51
55
  calls.push({ url: String(url), init })
52
56
  const next = queue.shift()
53
57
  assert.ok(next, 'unexpected fetch call')
54
- return next as any
58
+ return next
55
59
  }
56
60
 
57
- const received: any[] = []
61
+ const received: InboundMessage[] = []
58
62
  const connector = {
59
63
  id: 'bb-1',
60
64
  name: 'BlueBubbles Test',
@@ -68,7 +72,7 @@ test('bluebubbles connector processes inbound webhook payloads and sends replies
68
72
  status: 'running',
69
73
  createdAt: Date.now(),
70
74
  updatedAt: Date.now(),
71
- } as any
75
+ } as unknown as Connector
72
76
 
73
77
  const instance = await bluebubbles.start(connector, 'pw-test', async (msg) => {
74
78
  received.push(msg)
@@ -76,8 +80,10 @@ test('bluebubbles connector processes inbound webhook payloads and sends replies
76
80
  })
77
81
 
78
82
  try {
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
84
  const handler = (globalThis as any).__swarmclaw_bluebubbles_handler_bb_1__
80
85
  assert.equal(typeof handler, 'undefined', 'sanity: wrong handler key should be undefined')
86
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
87
  const validHandler = (globalThis as any)[`__swarmclaw_bluebubbles_handler_${connector.id}__`]
82
88
  assert.equal(typeof validHandler, 'function')
83
89
 
@@ -116,11 +122,12 @@ test('bluebubbles connector supports array-wrapped webhook payload and NO_MESSAG
116
122
  jsonResponse(200, { ok: true }), // ping
117
123
  ]
118
124
 
125
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
119
126
  ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
120
127
  calls.push({ url: String(url), init })
121
128
  const next = queue.shift()
122
129
  assert.ok(next, 'unexpected fetch call')
123
- return next as any
130
+ return next
124
131
  }
125
132
 
126
133
  const connector = {
@@ -136,11 +143,12 @@ test('bluebubbles connector supports array-wrapped webhook payload and NO_MESSAG
136
143
  status: 'running',
137
144
  createdAt: Date.now(),
138
145
  updatedAt: Date.now(),
139
- } as any
146
+ } as unknown as Connector
140
147
 
141
148
  const instance = await bluebubbles.start(connector, 'pw-test', async () => 'NO_MESSAGE')
142
149
 
143
150
  try {
151
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
144
152
  const handler = (globalThis as any)[`__swarmclaw_bluebubbles_handler_${connector.id}__`]
145
153
  assert.equal(typeof handler, 'function')
146
154
 
@@ -171,11 +179,12 @@ test('bluebubbles sendMessage posts to message/text endpoint', async () => {
171
179
  textResponse(200, ''), // send
172
180
  ]
173
181
 
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
183
  ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
175
184
  calls.push({ url: String(url), init })
176
185
  const next = queue.shift()
177
186
  assert.ok(next, 'unexpected fetch call')
178
- return next as any
187
+ return next
179
188
  }
180
189
 
181
190
  const connector = {
@@ -191,7 +200,7 @@ test('bluebubbles sendMessage posts to message/text endpoint', async () => {
191
200
  status: 'running',
192
201
  createdAt: Date.now(),
193
202
  updatedAt: Date.now(),
194
- } as any
203
+ } as unknown as Connector
195
204
 
196
205
  const instance = await bluebubbles.start(connector, 'pw-test', async () => 'ok')
197
206
 
@@ -274,6 +274,7 @@ const bluebubbles: PlatformConnector = {
274
274
  }
275
275
 
276
276
  const handlerKey = `__swarmclaw_bluebubbles_handler_${connector.id}__`
277
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
277
278
  ;(globalThis as any)[handlerKey] = processWebhookEvent
278
279
 
279
280
  const pingUrl = resolveRequestUrl(serverUrl, '/api/v1/ping', password)
@@ -299,6 +300,7 @@ const bluebubbles: PlatformConnector = {
299
300
  },
300
301
  async stop() {
301
302
  stopped = true
303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
302
304
  delete (globalThis as any)[handlerKey]
303
305
  console.log(`[bluebubbles] Connector stopped`)
304
306
  },
@@ -341,8 +343,9 @@ async function sendBlueBubblesText(params: {
341
343
  }
342
344
 
343
345
  try {
344
- const body = await res.json() as any
345
- const id = body?.data?.guid || body?.guid || body?.data?.id || body?.id
346
+ const body = await res.json() as Record<string, unknown>
347
+ const data = body?.data && typeof body.data === 'object' ? body.data as Record<string, unknown> : null
348
+ const id = data?.guid || body?.guid || data?.id || body?.id
346
349
  return { messageId: typeof id === 'string' ? id : undefined }
347
350
  } catch (err) {
348
351
  // BlueBubbles may return empty body on success in some setups.
@@ -7,7 +7,7 @@ const googlechat: PlatformConnector = {
7
7
  const { google } = await import(/* webpackIgnore: true */ pkg)
8
8
 
9
9
  // Parse service account credentials from botToken
10
- let credentials: any
10
+ let credentials: Record<string, unknown>
11
11
  try {
12
12
  credentials = JSON.parse(botToken)
13
13
  } catch {
@@ -41,28 +41,30 @@ const googlechat: PlatformConnector = {
41
41
  return txt.replace(/<users\/[^>]+>/g, '').trim()
42
42
  }
43
43
 
44
- async function processWebhookEvent(event: any): Promise<Record<string, unknown>> {
44
+ async function processWebhookEvent(event: Record<string, unknown>): Promise<Record<string, unknown>> {
45
45
  if (stopped) throw new Error('Connector is stopped')
46
46
 
47
- const msg = event?.message
47
+ const msg = event?.message as Record<string, unknown> | undefined
48
48
  if (!msg) return {}
49
49
 
50
- const spaceName: string = msg?.space?.name || event?.space?.name || ''
50
+ const msgSpace = msg?.space as Record<string, unknown> | undefined
51
+ const eventSpace = event?.space as Record<string, unknown> | undefined
52
+ const spaceName: string = (msgSpace?.name as string) || (eventSpace?.name as string) || ''
51
53
  if (allowedSpaces && !allowedSpaces.some((s) => spaceName.includes(s))) {
52
54
  return {}
53
55
  }
54
56
 
55
- const rawText = msg?.argumentText || msg?.text || ''
57
+ const rawText = (msg?.argumentText as string) || (msg?.text as string) || ''
56
58
  const text = cleanInboundText(rawText)
57
59
  if (!text) return {}
58
60
 
59
- const sender = msg?.sender || event?.user || {}
60
- const senderName = sender?.displayName || sender?.name || 'Google Chat User'
61
- const senderId = sender?.name || ''
61
+ const sender = (msg?.sender || event?.user || {}) as Record<string, unknown>
62
+ const senderName = (sender?.displayName as string) || (sender?.name as string) || 'Google Chat User'
63
+ const senderId = (sender?.name as string) || ''
62
64
  const inbound: InboundMessage = {
63
65
  platform: 'googlechat',
64
- channelId: spaceName || (msg?.thread?.name || 'space:unknown'),
65
- channelName: msg?.space?.displayName || spaceName || 'Google Chat',
66
+ channelId: spaceName || ((msg?.thread as Record<string, unknown>)?.name as string) || 'space:unknown',
67
+ channelName: (msgSpace?.displayName as string) || spaceName || 'Google Chat',
66
68
  senderId,
67
69
  senderName,
68
70
  text,
@@ -73,6 +75,7 @@ const googlechat: PlatformConnector = {
73
75
  return { text: response }
74
76
  }
75
77
 
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
79
  ;(globalThis as any)[handlerKey] = processWebhookEvent
77
80
 
78
81
  return {
@@ -95,6 +98,7 @@ const googlechat: PlatformConnector = {
95
98
  },
96
99
  async stop() {
97
100
  stopped = true
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
98
102
  delete (globalThis as any)[handlerKey]
99
103
  console.log(`[googlechat] Bot disconnected`)
100
104
  },
@@ -33,18 +33,25 @@ export function isNoMessage(text: string): boolean {
33
33
  * Stored on globalThis to survive HMR reloads in dev mode —
34
34
  * prevents duplicate sockets fighting for the same WhatsApp session. */
35
35
  const globalKey = '__swarmclaw_running_connectors__' as const
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ const g = globalThis as any
36
38
  const running: Map<string, ConnectorInstance> =
37
- (globalThis as any)[globalKey] ?? ((globalThis as any)[globalKey] = new Map<string, ConnectorInstance>())
39
+ g[globalKey] ?? (g[globalKey] = new Map<string, ConnectorInstance>())
38
40
 
39
41
  /** Most recent inbound channel per connector (used for proactive replies/default outbound target) */
40
42
  const lastInboundKey = '__swarmclaw_connector_last_inbound__' as const
41
43
  const lastInboundChannelByConnector: Map<string, string> =
42
- (globalThis as any)[lastInboundKey] ?? ((globalThis as any)[lastInboundKey] = new Map<string, string>())
44
+ g[lastInboundKey] ?? (g[lastInboundKey] = new Map<string, string>())
45
+
46
+ /** Last inbound message timestamp per connector (for presence indicators) */
47
+ const lastInboundTimeKey = '__swarmclaw_connector_last_inbound_time__' as const
48
+ const lastInboundTimeByConnector: Map<string, number> =
49
+ g[lastInboundTimeKey] ?? (g[lastInboundTimeKey] = new Map<string, number>())
43
50
 
44
51
  /** Per-connector lock to prevent concurrent start/stop operations */
45
52
  const lockKey = '__swarmclaw_connector_locks__' as const
46
53
  const locks: Map<string, Promise<void>> =
47
- (globalThis as any)[lockKey] ?? ((globalThis as any)[lockKey] = new Map<string, Promise<void>>())
54
+ g[lockKey] ?? (g[lockKey] = new Map<string, Promise<void>>())
48
55
 
49
56
  /** Get platform implementation lazily */
50
57
  export async function getPlatform(platform: string) {
@@ -117,14 +124,16 @@ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
117
124
  }
118
125
  }
119
126
 
120
- function pushSessionMessage(session: any, role: 'user' | 'assistant', text: string): void {
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ function pushSessionMessage(session: Record<string, any>, role: 'user' | 'assistant', text: string): void {
121
129
  if (!text.trim()) return
122
130
  if (!Array.isArray(session.messages)) session.messages = []
123
131
  session.messages.push({ role, text: text.trim(), time: Date.now() })
124
132
  session.lastActiveAt = Date.now()
125
133
  }
126
134
 
127
- function persistSession(session: any): void {
135
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
136
+ function persistSession(session: Record<string, any>): void {
128
137
  const sessions = loadSessions()
129
138
  sessions[session.id] = session
130
139
  saveSessions(sessions)
@@ -278,7 +287,8 @@ function enforceInboundAccessPolicy(connector: Connector, msg: InboundMessage):
278
287
  async function handleConnectorCommand(params: {
279
288
  command: ParsedConnectorCommand
280
289
  connector: Connector
281
- session: any
290
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
291
+ session: Record<string, any>
282
292
  msg: InboundMessage
283
293
  agentName: string
284
294
  }): Promise<string> {
@@ -303,8 +313,8 @@ async function handleConnectorCommand(params: {
303
313
 
304
314
  if (command.name === 'status') {
305
315
  const all = Array.isArray(session.messages) ? session.messages : []
306
- const userCount = all.filter((m: any) => m?.role === 'user').length
307
- const assistantCount = all.filter((m: any) => m?.role === 'assistant').length
316
+ const userCount = all.filter((m: { role?: string }) => m?.role === 'user').length
317
+ const assistantCount = all.filter((m: { role?: string }) => m?.role === 'assistant').length
308
318
  const toolsCount = Array.isArray(session.tools) ? session.tools.length : 0
309
319
  const statusText = [
310
320
  `Status for ${connector.platform} / ${connector.name}:`,
@@ -399,6 +409,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
399
409
  if (msg?.channelId) {
400
410
  lastInboundChannelByConnector.set(connector.id, msg.channelId)
401
411
  }
412
+ lastInboundTimeByConnector.set(connector.id, Date.now())
402
413
 
403
414
  const agents = loadAgents()
404
415
  const effectiveAgentId = msg.agentIdOverride || connector.agentId
@@ -408,6 +419,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
408
419
  // Log connector trigger
409
420
  const triggerSessionKey = `connector:${connector.id}:${msg.channelId}`
410
421
  const allSessions = loadSessions()
422
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
411
423
  const existingSession = Object.values(allSessions).find((s: any) => s.name === triggerSessionKey)
412
424
  if (existingSession) {
413
425
  logExecution(existingSession.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
@@ -437,6 +449,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
437
449
  // Find or create a session keyed by platform + channel
438
450
  const sessionKey = `connector:${connector.id}:${msg.channelId}`
439
451
  const sessions = loadSessions()
452
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
440
453
  let session = Object.values(sessions).find((s: any) => s.name === sessionKey)
441
454
  if (!session) {
442
455
  const id = genId()
@@ -586,9 +599,10 @@ The test: would a thoughtful friend feel compelled to type something back? If no
586
599
  // Use finalResponse for connectors — strips intermediate planning/tool-use text
587
600
  fullText = result.finalResponse
588
601
  console.log(`[connector] streamAgentChat returned ${result.fullText.length} chars total, ${fullText.length} chars final`)
589
- } catch (err: any) {
590
- console.error(`[connector] streamAgentChat error:`, err.message || err)
591
- return `[Error] ${err.message}`
602
+ } catch (err: unknown) {
603
+ const message = err instanceof Error ? err.message : String(err)
604
+ console.error(`[connector] streamAgentChat error:`, message)
605
+ return `[Error] ${message}`
592
606
  }
593
607
  } else {
594
608
  // Use the provider directly
@@ -726,10 +740,10 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
726
740
  notify('connectors')
727
741
 
728
742
  console.log(`[connector] Started ${connector.platform} connector: ${connector.name}`)
729
- } catch (err: any) {
743
+ } catch (err: unknown) {
730
744
  connector.status = 'error'
731
745
  connector.isEnabled = false
732
- connector.lastError = err.message
746
+ connector.lastError = err instanceof Error ? err.message : String(err)
733
747
  connector.updatedAt = Date.now()
734
748
  connectors[connectorId] = connector
735
749
  saveConnectors(connectors)
@@ -818,8 +832,8 @@ export async function autoStartConnectors(): Promise<void> {
818
832
  try {
819
833
  console.log(`[connector] Auto-starting ${connector.platform} connector: ${connector.name}`)
820
834
  await startConnector(connector.id)
821
- } catch (err: any) {
822
- console.error(`[connector] Failed to auto-start ${connector.name}:`, err.message)
835
+ } catch (err: unknown) {
836
+ console.error(`[connector] Failed to auto-start ${connector.name}:`, err instanceof Error ? err.message : err)
823
837
  }
824
838
  }
825
839
  }
@@ -878,6 +892,14 @@ export function getConnectorRecentChannelId(connectorId: string): string | null
878
892
  return lastInboundChannelByConnector.get(connectorId) || null
879
893
  }
880
894
 
895
+ /** Get presence info for a connector */
896
+ export function getConnectorPresence(connectorId: string): { lastMessageAt: number | null; channelId: string | null } {
897
+ return {
898
+ lastMessageAt: lastInboundTimeByConnector.get(connectorId) ?? null,
899
+ channelId: lastInboundChannelByConnector.get(connectorId) ?? null,
900
+ }
901
+ }
902
+
881
903
  /** Get a running connector instance (internal use for rich messaging). */
882
904
  export function getRunningInstance(connectorId: string): ConnectorInstance | undefined {
883
905
  return running.get(connectorId)
@@ -830,6 +830,7 @@ const openclaw: PlatformConnector = {
830
830
  // Cross-sync device token for provider identity resolution
831
831
  if (normalized) {
832
832
  try {
833
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
833
834
  const { setSharedDeviceToken } = require('../openclaw-sync')
834
835
  setSharedDeviceToken(normalized)
835
836
  } catch { /* openclaw-sync not available */ }
@@ -12,6 +12,7 @@ import {
12
12
  getConnectorStatus,
13
13
  } from './connectors/manager'
14
14
  import { startHeartbeatService, stopHeartbeatService, getHeartbeatServiceStatus } from './heartbeat-service'
15
+ import { hasOpenClawAgents, ensureGatewayConnected, disconnectGateway, getGateway } from './openclaw-gateway'
15
16
 
16
17
  const QUEUE_CHECK_INTERVAL = 30_000 // 30 seconds
17
18
  const BROWSER_SWEEP_INTERVAL = 60_000 // 60 seconds
@@ -179,6 +180,16 @@ function startQueueProcessor() {
179
180
  await processNext()
180
181
  ds.lastProcessedAt = Date.now()
181
182
  }
183
+ // OpenClaw gateway lifecycle: lazy connect when openclaw agents exist, disconnect when none remain
184
+ try {
185
+ if (hasOpenClawAgents()) {
186
+ if (!getGateway()?.connected) {
187
+ await ensureGatewayConnected()
188
+ }
189
+ } else if (getGateway()?.connected) {
190
+ disconnectGateway()
191
+ }
192
+ } catch { /* gateway errors are non-fatal */ }
182
193
  }, QUEUE_CHECK_INTERVAL)
183
194
  }
184
195
 
@@ -4,14 +4,13 @@ import type { GoalContract, MessageToolEvent } from '@/types'
4
4
  import { loadSessions, saveSessions, loadAgents, saveAgents, loadTasks, saveTasks } from './storage'
5
5
  import { log } from './logger'
6
6
  import { getMemoryDb } from './memory-db'
7
+ import { isProtectedMainSession } from './main-session'
7
8
  import {
8
9
  mergeGoalContracts,
9
10
  parseGoalContractFromText,
10
11
  parseMainLoopPlan,
11
12
  parseMainLoopReview,
12
13
  } from './autonomy-contract'
13
-
14
- const MAIN_SESSION_NAME = '__main__'
15
14
  const MAX_PENDING_EVENTS = 40
16
15
  const MAX_TIMELINE_EVENTS = 80
17
16
  const EVENT_TTL_MS = 7 * 24 * 60 * 60 * 1000
@@ -669,7 +668,7 @@ function buildFollowupPrompt(state: MainLoopState, opts?: { hasMemoryTool?: bool
669
668
  }
670
669
 
671
670
  export function isMainSession(session: any): boolean {
672
- return session?.name === MAIN_SESSION_NAME
671
+ return isProtectedMainSession(session)
673
672
  }
674
673
 
675
674
  export function buildMainLoopHeartbeatPrompt(session: any, fallbackPrompt: string): string {
@@ -0,0 +1,21 @@
1
+ const MAIN_SESSION_NAME = '__main__'
2
+
3
+ export function isProtectedMainSession(session: any): boolean {
4
+ if (!session || typeof session !== 'object') return false
5
+ if (session.mainSession === true) return true
6
+
7
+ const name = typeof session.name === 'string' ? session.name.trim() : ''
8
+ if (name === MAIN_SESSION_NAME) return true
9
+
10
+ const id = typeof session.id === 'string' ? session.id.trim() : ''
11
+ if (id.startsWith('main-')) return true
12
+
13
+ return false
14
+ }
15
+
16
+ export function ensureMainSessionFlag(session: any): void {
17
+ if (!session || typeof session !== 'object') return
18
+ if (isProtectedMainSession(session)) {
19
+ session.mainSession = true
20
+ }
21
+ }