@swarmclawai/swarmclaw 0.7.3 → 0.7.4

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 (147) hide show
  1. package/README.md +47 -40
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +17 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +3 -1
  12. package/src/app/api/agents/route.ts +23 -1
  13. package/src/app/api/auth/route.ts +1 -1
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +16 -5
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/route.ts +12 -0
  19. package/src/app/api/chats/heartbeat/route.ts +2 -1
  20. package/src/app/api/chats/route.ts +7 -1
  21. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  22. package/src/app/api/external-agents/[id]/route.ts +31 -0
  23. package/src/app/api/external-agents/register/route.ts +3 -0
  24. package/src/app/api/external-agents/route.ts +66 -0
  25. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  26. package/src/app/api/gateways/[id]/route.ts +79 -0
  27. package/src/app/api/gateways/route.ts +57 -0
  28. package/src/app/api/openclaw/gateway/route.ts +10 -7
  29. package/src/app/api/openclaw/skills/route.ts +1 -1
  30. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  31. package/src/app/api/schedules/[id]/route.ts +38 -9
  32. package/src/app/api/schedules/route.ts +51 -28
  33. package/src/app/api/settings/route.ts +6 -10
  34. package/src/app/api/setup/doctor/route.ts +6 -4
  35. package/src/app/api/tasks/[id]/route.ts +2 -1
  36. package/src/app/api/tasks/bulk/route.ts +2 -2
  37. package/src/app/page.tsx +126 -15
  38. package/src/cli/binary.test.js +142 -0
  39. package/src/cli/index.js +34 -11
  40. package/src/cli/index.test.js +195 -0
  41. package/src/cli/index.ts +20 -4
  42. package/src/cli/server-cmd.test.js +59 -0
  43. package/src/cli/spec.js +20 -2
  44. package/src/components/agents/agent-sheet.tsx +249 -7
  45. package/src/components/agents/inspector-panel.tsx +3 -2
  46. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  47. package/src/components/auth/setup-wizard.tsx +970 -275
  48. package/src/components/chat/chat-area.tsx +41 -14
  49. package/src/components/chat/chat-card.tsx +2 -1
  50. package/src/components/chat/chat-header.tsx +8 -13
  51. package/src/components/chat/chat-list.tsx +58 -20
  52. package/src/components/chat/message-list.tsx +142 -18
  53. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  54. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  55. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  56. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +157 -86
  59. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  60. package/src/components/gateways/gateway-sheet.tsx +567 -0
  61. package/src/components/input/chat-input.tsx +135 -86
  62. package/src/components/layout/app-layout.tsx +2 -0
  63. package/src/components/memory/memory-browser.tsx +71 -6
  64. package/src/components/memory/memory-card.tsx +18 -0
  65. package/src/components/memory/memory-detail.tsx +58 -31
  66. package/src/components/memory/memory-sheet.tsx +32 -4
  67. package/src/components/projects/project-detail.tsx +7 -2
  68. package/src/components/providers/provider-list.tsx +158 -2
  69. package/src/components/providers/provider-sheet.tsx +81 -70
  70. package/src/components/shared/bottom-sheet.tsx +31 -15
  71. package/src/components/shared/confirm-dialog.tsx +45 -30
  72. package/src/components/shared/model-combobox.tsx +90 -8
  73. package/src/components/shared/settings/section-heartbeat.tsx +11 -6
  74. package/src/components/shared/settings/section-orchestrator.tsx +3 -0
  75. package/src/components/shared/settings/settings-page.tsx +5 -3
  76. package/src/components/tasks/approvals-panel.tsx +7 -1
  77. package/src/components/ui/dialog.tsx +2 -2
  78. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  79. package/src/lib/heartbeat-defaults.ts +48 -0
  80. package/src/lib/memory-presentation.ts +59 -0
  81. package/src/lib/provider-model-discovery-client.ts +29 -0
  82. package/src/lib/providers/index.ts +12 -5
  83. package/src/lib/runtime-loop.ts +105 -3
  84. package/src/lib/safe-storage.ts +6 -1
  85. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  86. package/src/lib/server/agent-runtime-config.ts +277 -0
  87. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  88. package/src/lib/server/build-llm.test.ts +13 -5
  89. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  90. package/src/lib/server/chat-execution.ts +159 -71
  91. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  92. package/src/lib/server/chatroom-helpers.ts +99 -6
  93. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  94. package/src/lib/server/connectors/manager.ts +89 -61
  95. package/src/lib/server/connectors/slack.ts +1 -1
  96. package/src/lib/server/daemon-state.ts +3 -2
  97. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  98. package/src/lib/server/eval/agent-regression.ts +1742 -0
  99. package/src/lib/server/eval/runner.ts +11 -1
  100. package/src/lib/server/eval/store.ts +2 -1
  101. package/src/lib/server/heartbeat-service.ts +10 -4
  102. package/src/lib/server/main-agent-loop.ts +13 -6
  103. package/src/lib/server/openclaw-exec-config.ts +4 -2
  104. package/src/lib/server/openclaw-gateway.ts +123 -36
  105. package/src/lib/server/orchestrator-lg.ts +1 -2
  106. package/src/lib/server/orchestrator.ts +3 -2
  107. package/src/lib/server/plugins.test.ts +9 -1
  108. package/src/lib/server/plugins.ts +12 -2
  109. package/src/lib/server/provider-model-discovery.ts +481 -0
  110. package/src/lib/server/queue.ts +1 -1
  111. package/src/lib/server/runtime-settings.test.ts +119 -0
  112. package/src/lib/server/runtime-settings.ts +12 -92
  113. package/src/lib/server/schedule-normalization.ts +187 -0
  114. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  115. package/src/lib/server/session-tools/crud.ts +27 -3
  116. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  117. package/src/lib/server/session-tools/discovery.ts +18 -8
  118. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  119. package/src/lib/server/session-tools/file.ts +8 -2
  120. package/src/lib/server/session-tools/http.ts +9 -3
  121. package/src/lib/server/session-tools/index.ts +31 -1
  122. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  123. package/src/lib/server/session-tools/monitor.ts +14 -7
  124. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  125. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  126. package/src/lib/server/session-tools/platform.ts +1 -1
  127. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  128. package/src/lib/server/session-tools/sandbox.ts +51 -92
  129. package/src/lib/server/session-tools/session-info.ts +22 -1
  130. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  131. package/src/lib/server/session-tools/shell.ts +2 -2
  132. package/src/lib/server/session-tools/subagent.ts +3 -1
  133. package/src/lib/server/session-tools/web.ts +73 -30
  134. package/src/lib/server/storage.ts +29 -3
  135. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  136. package/src/lib/server/stream-agent-chat.ts +139 -4
  137. package/src/lib/server/structured-extract.ts +1 -1
  138. package/src/lib/server/task-mention.ts +0 -1
  139. package/src/lib/server/tool-aliases.ts +37 -6
  140. package/src/lib/server/tool-capability-policy.ts +1 -1
  141. package/src/lib/setup-defaults.ts +352 -11
  142. package/src/lib/tool-definitions.ts +3 -4
  143. package/src/lib/validation/schemas.ts +55 -1
  144. package/src/stores/use-app-store.ts +43 -1
  145. package/src/stores/use-chatroom-store.ts +153 -26
  146. package/src/types/index.ts +189 -6
  147. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
@@ -0,0 +1,87 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-chatroom-session-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: path.join(tempDir, 'data'),
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ describe('chatroom synthetic session persistence', () => {
36
+ it('reuses stored synthetic sessions and preserves delegate resume state', () => {
37
+ const output = runWithTempDataDir(`
38
+ const helpersMod = await import('./src/lib/server/chatroom-helpers.ts')
39
+ const helpers = helpersMod.default || helpersMod
40
+ const storageMod = await import('./src/lib/server/storage.ts')
41
+ const storage = storageMod.default || storageMod
42
+ const now = Date.now()
43
+ const agent = {
44
+ id: 'default',
45
+ name: 'Molly',
46
+ description: '',
47
+ systemPrompt: '',
48
+ provider: 'openai',
49
+ model: 'gpt-4o',
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ plugins: ['delegate'],
53
+ }
54
+
55
+ const first = helpers.ensureSyntheticSession(agent, 'room-1')
56
+ helpers.appendSyntheticSessionMessage(first.id, 'user', 'first prompt')
57
+
58
+ const sessions = storage.loadSessions()
59
+ sessions[first.id].delegateResumeIds = {
60
+ claudeCode: null,
61
+ codex: 'resume-123',
62
+ opencode: null,
63
+ gemini: null,
64
+ }
65
+ storage.saveSessions(sessions)
66
+
67
+ const second = helpers.ensureSyntheticSession({ ...agent, model: 'gpt-4.1' }, 'room-1')
68
+ console.log(JSON.stringify({
69
+ sessionId: second.id,
70
+ cwd: second.cwd,
71
+ model: second.model,
72
+ messageCount: second.messages.length,
73
+ firstMessage: second.messages[0]?.text || '',
74
+ delegateResumeIds: second.delegateResumeIds,
75
+ plugins: second.plugins || [],
76
+ }))
77
+ `)
78
+
79
+ assert.equal(output.sessionId, 'chatroom-room-1-default')
80
+ assert.match(String(output.cwd), /chatrooms[\/\\]room-1$/)
81
+ assert.equal(output.model, 'gpt-4.1')
82
+ assert.equal(output.messageCount, 1)
83
+ assert.equal(output.firstMessage, 'first prompt')
84
+ assert.equal(output.delegateResumeIds?.codex, 'resume-123')
85
+ assert.deepEqual(output.plugins, ['delegate'])
86
+ })
87
+ })
@@ -20,7 +20,7 @@ import {
20
20
  parseMentions,
21
21
  compactChatroomMessages,
22
22
  buildChatroomSystemPrompt,
23
- buildSyntheticSession,
23
+ ensureSyntheticSession,
24
24
  buildAgentSystemPromptForChatroom,
25
25
  buildHistoryForAgent,
26
26
  resolveApiKey as resolveApiKeyHelper,
@@ -31,7 +31,7 @@ import { markProviderFailure, markProviderSuccess } from '../provider-health'
31
31
  import { syncSessionArchiveMemory } from '../session-archive-memory'
32
32
  import { buildIdentityContinuityContext } from '../identity-continuity'
33
33
  import { getProvider } from '@/lib/providers'
34
- import type { Connector, MessageSource, Chatroom, ChatroomMessage, Session } from '@/types'
34
+ import type { Agent, Connector, MessageSource, Chatroom, ChatroomMessage, Session } from '@/types'
35
35
  import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
36
36
  import {
37
37
  addAllowedSender,
@@ -258,30 +258,41 @@ export function isNoMessage(text: string): boolean {
258
258
  * Stored on globalThis to survive HMR reloads in dev mode —
259
259
  * prevents duplicate sockets fighting for the same WhatsApp session. */
260
260
  const globalKey = '__swarmclaw_running_connectors__' as const
261
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
- const g = globalThis as any
261
+ const g = globalThis as typeof globalThis & Record<string, unknown>
262
+
263
+ function getOrInitGlobalValue<T>(key: string, factory: () => T): T {
264
+ const existing = g[key]
265
+ if (existing !== undefined) return existing as T
266
+ const created = factory()
267
+ g[key] = created
268
+ return created
269
+ }
270
+
271
+ type ConnectorSession = Session
272
+ type ConnectorAgent = Agent
273
+
263
274
  const running: Map<string, ConnectorInstance> =
264
- g[globalKey] ?? (g[globalKey] = new Map<string, ConnectorInstance>())
275
+ getOrInitGlobalValue(globalKey, () => new Map<string, ConnectorInstance>())
265
276
 
266
277
  /** Most recent inbound channel per connector (used for proactive replies/default outbound target) */
267
278
  const lastInboundKey = '__swarmclaw_connector_last_inbound__' as const
268
279
  const lastInboundChannelByConnector: Map<string, string> =
269
- g[lastInboundKey] ?? (g[lastInboundKey] = new Map<string, string>())
280
+ getOrInitGlobalValue(lastInboundKey, () => new Map<string, string>())
270
281
 
271
282
  /** Last inbound message timestamp per connector (for presence indicators) */
272
283
  const lastInboundTimeKey = '__swarmclaw_connector_last_inbound_time__' as const
273
284
  const lastInboundTimeByConnector: Map<string, number> =
274
- g[lastInboundTimeKey] ?? (g[lastInboundTimeKey] = new Map<string, number>())
285
+ getOrInitGlobalValue(lastInboundTimeKey, () => new Map<string, number>())
275
286
 
276
287
  /** Per-connector lock to prevent concurrent start/stop operations */
277
288
  const lockKey = '__swarmclaw_connector_locks__' as const
278
289
  const locks: Map<string, Promise<void>> =
279
- g[lockKey] ?? (g[lockKey] = new Map<string, Promise<void>>())
290
+ getOrInitGlobalValue(lockKey, () => new Map<string, Promise<void>>())
280
291
 
281
292
  /** Generation counter per connector — used to detect stale lifecycle events after restart */
282
293
  const genCounterKey = '__swarmclaw_connector_gen__' as const
283
294
  const generationCounter: Map<string, number> =
284
- g[genCounterKey] ?? (g[genCounterKey] = new Map<string, number>())
295
+ getOrInitGlobalValue(genCounterKey, () => new Map<string, number>())
285
296
 
286
297
  type ScheduledConnectorFollowup = {
287
298
  id: string
@@ -294,11 +305,11 @@ type ScheduledConnectorFollowup = {
294
305
 
295
306
  const followupKey = '__swarmclaw_connector_followups__' as const
296
307
  const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
297
- g[followupKey] ?? (g[followupKey] = new Map<string, ScheduledConnectorFollowup>())
308
+ getOrInitGlobalValue(followupKey, () => new Map<string, ScheduledConnectorFollowup>())
298
309
 
299
310
  const inboundDedupeKey = '__swarmclaw_connector_inbound_dedupe__' as const
300
311
  const recentInboundByKey: Map<string, number> =
301
- g[inboundDedupeKey] ?? (g[inboundDedupeKey] = new Map<string, number>())
312
+ getOrInitGlobalValue(inboundDedupeKey, () => new Map<string, number>())
302
313
 
303
314
  type DebouncedInboundEntry = {
304
315
  connector: Connector
@@ -308,11 +319,11 @@ type DebouncedInboundEntry = {
308
319
 
309
320
  const inboundDebounceKey = '__swarmclaw_connector_inbound_debounce__' as const
310
321
  const pendingInboundDebounce: Map<string, DebouncedInboundEntry> =
311
- g[inboundDebounceKey] ?? (g[inboundDebounceKey] = new Map<string, DebouncedInboundEntry>())
322
+ getOrInitGlobalValue(inboundDebounceKey, () => new Map<string, DebouncedInboundEntry>())
312
323
 
313
324
  const followupDedupeKey = '__swarmclaw_connector_followup_dedupe__' as const
314
325
  const scheduledFollowupByDedupe: Map<string, { id: string; sendAt: number }> =
315
- g[followupDedupeKey] ?? (g[followupDedupeKey] = new Map<string, { id: string; sendAt: number }>())
326
+ getOrInitGlobalValue(followupDedupeKey, () => new Map<string, { id: string; sendAt: number }>())
316
327
 
317
328
  /** Reconnect state per connector — tracks backoff and retry attempts for crash recovery */
318
329
  export interface ConnectorReconnectState {
@@ -325,7 +336,7 @@ export interface ConnectorReconnectState {
325
336
 
326
337
  const reconnectStateKey = '__swarmclaw_connector_reconnect_state__' as const
327
338
  const reconnectState: Map<string, ConnectorReconnectState> =
328
- g[reconnectStateKey] ?? (g[reconnectStateKey] = new Map<string, ConnectorReconnectState>())
339
+ getOrInitGlobalValue(reconnectStateKey, () => new Map<string, ConnectorReconnectState>())
329
340
 
330
341
  const RECONNECT_INITIAL_BACKOFF_MS = 1_000
331
342
  const RECONNECT_MAX_BACKOFF_MS = 5 * 60 * 1_000
@@ -371,11 +382,10 @@ function rememberRecentInbound(key: string, now = Date.now(), ttlMs = 120_000):
371
382
  return true
372
383
  }
373
384
 
374
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
375
- function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): Record<string, any> | null {
385
+ function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): ConnectorSession | null {
376
386
  if (connector.chatroomId) return null
377
387
  const effectiveAgentId = msg.agentIdOverride || connector.agentId
378
- const sessions = Object.values(loadSessions()) as Array<Record<string, any>>
388
+ const sessions = Object.values(loadSessions() as Record<string, ConnectorSession>)
379
389
  const candidates = sessions.filter((session) =>
380
390
  session?.agentId === effectiveAgentId
381
391
  && session?.connectorContext?.connectorId === connector.id
@@ -431,7 +441,7 @@ function startConnectorTypingLoop(connector: Connector, msg: InboundMessage): ((
431
441
  type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
432
442
  const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
433
443
  const routeMessageHandlerRef: { current: RouteMessageHandler } =
434
- g[routeHandlerKey] ?? (g[routeHandlerKey] = { current: async () => '[Error] Connector router unavailable.' })
444
+ getOrInitGlobalValue(routeHandlerKey, () => ({ current: async () => '[Error] Connector router unavailable.' }))
435
445
 
436
446
  async function flushDebouncedInbound(key: string): Promise<void> {
437
447
  const entry = pendingInboundDebounce.get(key)
@@ -625,15 +635,13 @@ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
625
635
  }
626
636
  }
627
637
 
628
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
629
- function persistSessionRecord(session: Record<string, any>): void {
638
+ function persistSessionRecord(session: ConnectorSession): void {
630
639
  const sessions = loadSessions()
631
640
  sessions[session.id] = session
632
641
  saveSessions(sessions)
633
642
  }
634
643
 
635
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
636
- function updateSessionConnectorContext(session: Record<string, any>, connector: Connector, msg: InboundMessage, sessionKey: string): void {
644
+ function updateSessionConnectorContext(session: ConnectorSession, connector: Connector, msg: InboundMessage, sessionKey: string): void {
637
645
  const policy = resolveConnectorSessionPolicy(connector, msg, session)
638
646
  session.connectorContext = {
639
647
  ...(session.connectorContext || {}),
@@ -665,7 +673,7 @@ function updateSessionConnectorContext(session: Record<string, any>, connector:
665
673
  }
666
674
  }
667
675
 
668
- function describeSessionControls(session: Record<string, any>, connector: Connector, msg: InboundMessage): string {
676
+ function describeSessionControls(session: ConnectorSession, connector: Connector, msg: InboundMessage): string {
669
677
  const policy = resolveConnectorSessionPolicy(connector, msg, session)
670
678
  const context = session.connectorContext || {}
671
679
  const sessionAgeSec = Math.max(0, Math.round((Date.now() - (session.createdAt || Date.now())) / 1000))
@@ -699,7 +707,7 @@ function normalizeSessionSettingKey(raw: string): string {
699
707
  return raw.trim().toLowerCase().replace(/[_-]+/g, '')
700
708
  }
701
709
 
702
- function applySessionSetting(session: Record<string, any>, keyRaw: string, valueRaw: string, msg: InboundMessage): string {
710
+ function applySessionSetting(session: ConnectorSession, keyRaw: string, valueRaw: string, msg: InboundMessage): string {
703
711
  const key = normalizeSessionSettingKey(keyRaw)
704
712
  const value = valueRaw.trim()
705
713
  const asInt = () => {
@@ -709,32 +717,38 @@ function applySessionSetting(session: Record<string, any>, keyRaw: string, value
709
717
  }
710
718
  return parsed
711
719
  }
720
+ const asEnum = <T extends string>(allowed: readonly T[], label: string): T | null => {
721
+ if (!value) return null
722
+ const normalized = value.toLowerCase()
723
+ if ((allowed as readonly string[]).includes(normalized)) return normalized as T
724
+ throw new Error(`Invalid ${label}. Use one of: ${allowed.join(', ')}.`)
725
+ }
712
726
 
713
727
  switch (key) {
714
728
  case 'think':
715
729
  case 'thinkinglevel':
716
- session.connectorThinkLevel = value || null
730
+ session.connectorThinkLevel = asEnum(['minimal', 'low', 'medium', 'high'] as const, '/think level')
717
731
  return `Connector thinking level set to ${session.connectorThinkLevel || 'inherit'}.`
718
732
  case 'reply':
719
733
  case 'replymode':
720
- session.connectorReplyMode = value || null
734
+ session.connectorReplyMode = asEnum(['off', 'first', 'all'] as const, 'reply mode')
721
735
  return `Reply mode set to ${session.connectorReplyMode || 'inherit'}.`
722
736
  case 'scope':
723
737
  case 'sessionscope':
724
- session.connectorSessionScope = value || null
738
+ session.connectorSessionScope = asEnum(['main', 'channel', 'peer', 'channel-peer', 'thread'] as const, 'session scope')
725
739
  return `Session scope set to ${session.connectorSessionScope || 'inherit'}.`
726
740
  case 'thread':
727
741
  case 'threadbinding':
728
- session.connectorThreadBinding = value || null
742
+ session.connectorThreadBinding = asEnum(['off', 'prefer', 'strict'] as const, 'thread binding')
729
743
  if (!value) {
730
744
  session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
731
- } else if (value === 'strict' && msg.threadId) {
745
+ } else if (session.connectorThreadBinding === 'strict' && msg.threadId) {
732
746
  session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
733
747
  }
734
748
  return `Thread binding set to ${session.connectorThreadBinding || 'inherit'}.`
735
749
  case 'group':
736
750
  case 'grouppolicy':
737
- session.connectorGroupPolicy = value || null
751
+ session.connectorGroupPolicy = asEnum(['open', 'mention', 'reply-or-mention', 'disabled'] as const, 'group policy')
738
752
  return `Group policy set to ${session.connectorGroupPolicy || 'inherit'}.`
739
753
  case 'idle':
740
754
  case 'idletimeout':
@@ -775,10 +789,15 @@ function applySessionSetting(session: Record<string, any>, keyRaw: string, value
775
789
  case 'model':
776
790
  session.model = value
777
791
  return `Model set to ${session.model}.`
778
- case 'provider':
779
- session.provider = value
780
- session.apiEndpoint = getProvider(value)?.defaultEndpoint || session.apiEndpoint || null
792
+ case 'provider': {
793
+ const provider = getProvider(value)
794
+ if (!provider) {
795
+ throw new Error(`Unknown provider "${value}".`)
796
+ }
797
+ session.provider = provider.id as Session['provider']
798
+ session.apiEndpoint = provider.defaultEndpoint || session.apiEndpoint || null
781
799
  return `Provider set to ${session.provider}.`
800
+ }
782
801
  default:
783
802
  throw new Error(`Unknown session setting "${keyRaw}".`)
784
803
  }
@@ -787,7 +806,7 @@ function applySessionSetting(session: Record<string, any>, keyRaw: string, value
787
806
  function evaluateGroupPolicy(params: {
788
807
  connector: Connector
789
808
  msg: InboundMessage
790
- session?: Record<string, any> | null
809
+ session?: ConnectorSession | null
791
810
  aliases: string[]
792
811
  }): { allowed: boolean; reason: string } {
793
812
  const { connector, msg, session, aliases } = params
@@ -804,11 +823,11 @@ function evaluateGroupPolicy(params: {
804
823
  return { allowed, reason: allowed ? (mentioned ? 'mentioned' : 'reply') : 'reply_or_mention_required' }
805
824
  }
806
825
 
807
- function applyConnectorRuntimeDefaults(session: Record<string, any>, defaults: {
808
- provider: string
826
+ function applyConnectorRuntimeDefaults(session: ConnectorSession, defaults: {
827
+ provider: Session['provider']
809
828
  model: string
810
829
  apiEndpoint: string | null
811
- thinkingLevel: string | null
830
+ thinkingLevel: Session['connectorThinkLevel']
812
831
  }): void {
813
832
  session.provider = defaults.provider
814
833
  session.model = defaults.model
@@ -819,12 +838,12 @@ function applyConnectorRuntimeDefaults(session: Record<string, any>, defaults: {
819
838
  function resolveDirectSession(params: {
820
839
  connector: Connector
821
840
  msg: InboundMessage
822
- agent: Record<string, any>
823
- }): { session: Record<string, any>; sessionKey: string; wasCreated: boolean; staleReason?: string | null; clearedMessages?: number } {
841
+ agent: ConnectorAgent
842
+ }): { session: ConnectorSession; sessionKey: string; wasCreated: boolean; staleReason?: string | null; clearedMessages?: number } {
824
843
  const { connector, msg, agent } = params
825
844
  const policySeed = resolveConnectorSessionPolicy(connector, msg)
826
845
  const providerInfo = policySeed.providerOverride ? getProvider(policySeed.providerOverride) : null
827
- const defaultProvider = policySeed.providerOverride || (agent.provider === 'claude-cli' ? 'anthropic' : agent.provider)
846
+ const defaultProvider: Session['provider'] = providerInfo?.id || (agent.provider === 'claude-cli' ? 'anthropic' : agent.provider)
828
847
  const defaultModel = policySeed.modelOverride || agent.model
829
848
  const defaultApiEndpoint = agent.apiEndpoint || providerInfo?.defaultEndpoint || null
830
849
  const runtimeDefaults = {
@@ -840,7 +859,7 @@ function resolveDirectSession(params: {
840
859
  policy: policySeed,
841
860
  })
842
861
  const sessions = loadSessions()
843
- let session = Object.values(sessions).find((item: any) => item?.name === sessionKey) as Record<string, any> | undefined
862
+ let session = Object.values(sessions as Record<string, ConnectorSession>).find((item) => item?.name === sessionKey)
844
863
  let wasCreated = false
845
864
  if (!session) {
846
865
  const id = genId()
@@ -892,8 +911,8 @@ function resolveDirectSession(params: {
892
911
  const staleness = getConnectorSessionStaleness(session, policy)
893
912
  let clearedMessages = 0
894
913
  if (staleness.stale) {
895
- try { syncSessionArchiveMemory(session as any, { agent }) } catch { /* archive sync is best-effort */ }
896
- clearedMessages = resetConnectorSessionRuntime(session as any, staleness.reason || 'session_refresh')
914
+ try { syncSessionArchiveMemory(session, { agent }) } catch { /* archive sync is best-effort */ }
915
+ clearedMessages = resetConnectorSessionRuntime(session, staleness.reason || 'session_refresh')
897
916
  applyConnectorRuntimeDefaults(session, {
898
917
  ...runtimeDefaults,
899
918
  thinkingLevel: policySeed.thinkingLevel || session.connectorThinkLevel || null,
@@ -911,16 +930,14 @@ function resolveDirectSession(params: {
911
930
  }
912
931
  }
913
932
 
914
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
915
- function pushSessionMessage(session: Record<string, any>, role: 'user' | 'assistant', text: string): void {
933
+ function pushSessionMessage(session: ConnectorSession, role: 'user' | 'assistant', text: string): void {
916
934
  if (!text.trim()) return
917
935
  if (!Array.isArray(session.messages)) session.messages = []
918
936
  session.messages.push({ role, text: text.trim(), time: Date.now() })
919
937
  session.lastActiveAt = Date.now()
920
938
  }
921
939
 
922
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
923
- function persistSession(session: Record<string, any>): void {
940
+ function persistSession(session: ConnectorSession): void {
924
941
  const sessions = loadSessions()
925
942
  sessions[session.id] = session
926
943
  saveSessions(sessions)
@@ -1074,8 +1091,7 @@ function enforceInboundAccessPolicy(connector: Connector, msg: InboundMessage):
1074
1091
  async function handleConnectorCommand(params: {
1075
1092
  command: ParsedConnectorCommand
1076
1093
  connector: Connector
1077
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1078
- session: Record<string, any>
1094
+ session: ConnectorSession
1079
1095
  msg: InboundMessage
1080
1096
  agentName: string
1081
1097
  }): Promise<string> {
@@ -1128,12 +1144,13 @@ async function handleConnectorCommand(params: {
1128
1144
  }
1129
1145
 
1130
1146
  if (command.name === 'new' || command.name === 'reset') {
1131
- try { syncSessionArchiveMemory(session as any, { agent: loadAgents()[session.agentId] }) } catch { /* best effort */ }
1132
- const cleared = resetConnectorSessionRuntime(session as any, 'manual_reset')
1147
+ const agent = session.agentId ? (loadAgents() as Record<string, ConnectorAgent>)[session.agentId] : undefined
1148
+ try { syncSessionArchiveMemory(session, { agent }) } catch { /* best effort */ }
1149
+ const cleared = resetConnectorSessionRuntime(session, 'manual_reset')
1133
1150
  const policy = resolveConnectorSessionPolicy(connector, msg, session)
1134
1151
  const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
1135
1152
  applyConnectorRuntimeDefaults(session, {
1136
- provider: policy.providerOverride || session.provider,
1153
+ provider: providerInfo?.id || session.provider,
1137
1154
  model: policy.modelOverride || session.model,
1138
1155
  apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
1139
1156
  thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
@@ -1173,7 +1190,7 @@ async function handleConnectorCommand(params: {
1173
1190
 
1174
1191
  if (command.name === 'think') {
1175
1192
  const requested = command.args.trim().toLowerCase()
1176
- const allowed = new Set(['minimal', 'low', 'medium', 'high'])
1193
+ const allowed = new Set(['minimal', 'low', 'medium', 'high'] as const)
1177
1194
  if (!requested) {
1178
1195
  const policy = resolveConnectorSessionPolicy(connector, msg, session)
1179
1196
  const current = typeof policy.thinkingLevel === 'string' && allowed.has(policy.thinkingLevel)
@@ -1185,7 +1202,12 @@ async function handleConnectorCommand(params: {
1185
1202
  persistSession(session)
1186
1203
  return text
1187
1204
  }
1188
- if (!allowed.has(requested)) {
1205
+ if (
1206
+ requested !== 'minimal'
1207
+ && requested !== 'low'
1208
+ && requested !== 'medium'
1209
+ && requested !== 'high'
1210
+ ) {
1189
1211
  const text = 'Invalid /think level. Use one of: minimal, low, medium, high.'
1190
1212
  pushSessionMessage(session, 'user', inboundText)
1191
1213
  pushSessionMessage(session, 'assistant', text)
@@ -1222,12 +1244,13 @@ async function handleConnectorCommand(params: {
1222
1244
  return text
1223
1245
  }
1224
1246
  if (parts[0].toLowerCase() === 'reset') {
1225
- try { syncSessionArchiveMemory(session as any, { agent: loadAgents()[session.agentId] }) } catch { /* best effort */ }
1226
- const cleared = resetConnectorSessionRuntime(session as any, 'manual_reset')
1247
+ const agent = session.agentId ? (loadAgents() as Record<string, ConnectorAgent>)[session.agentId] : undefined
1248
+ try { syncSessionArchiveMemory(session, { agent }) } catch { /* best effort */ }
1249
+ const cleared = resetConnectorSessionRuntime(session, 'manual_reset')
1227
1250
  const policy = resolveConnectorSessionPolicy(connector, msg, session)
1228
1251
  const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
1229
1252
  applyConnectorRuntimeDefaults(session, {
1230
- provider: policy.providerOverride || session.provider,
1253
+ provider: providerInfo?.id || session.provider,
1231
1254
  model: policy.modelOverride || session.model,
1232
1255
  apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
1233
1256
  thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
@@ -1402,7 +1425,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
1402
1425
  continue
1403
1426
  }
1404
1427
 
1405
- const syntheticSession = buildSyntheticSession(agent, chatroomId)
1428
+ const syntheticSession = ensureSyntheticSession(agent, chatroomId)
1406
1429
  const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
1407
1430
  const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
1408
1431
  const fullSystemPrompt = [agentSystemPrompt, chatroomContext, threadContextBlock].filter(Boolean).join('\n\n')
@@ -2255,12 +2278,17 @@ export async function recordConnectorOutboundDelivery(params: {
2255
2278
  for (let i = history.length - 1; i >= 0; i -= 1) {
2256
2279
  const entry = history[i]
2257
2280
  if (entry?.role !== 'assistant') continue
2258
- const source = entry?.source || {}
2281
+ const source: Partial<MessageSource> = entry?.source || {}
2259
2282
  if (source.connectorId !== connector.id) continue
2260
2283
  if (source.channelId !== params.inbound.channelId) continue
2261
2284
  if (!source.messageId && params.messageId) {
2262
2285
  entry.source = {
2263
- ...source,
2286
+ platform: source.platform || connector.platform,
2287
+ connectorId: source.connectorId || connector.id,
2288
+ connectorName: source.connectorName || connector.name,
2289
+ channelId: source.channelId || params.inbound.channelId,
2290
+ senderId: source.senderId,
2291
+ senderName: source.senderName,
2264
2292
  messageId: params.messageId,
2265
2293
  replyToMessageId: source.replyToMessageId || params.inbound.messageId,
2266
2294
  threadId: source.threadId || params.inbound.threadId,
@@ -2456,7 +2484,7 @@ export async function sendConnectorMessage(params: {
2456
2484
  for (let i = history.length - 1; i >= 0; i -= 1) {
2457
2485
  const entry = history[i]
2458
2486
  if (entry?.role !== 'assistant') continue
2459
- const source = entry?.source || {}
2487
+ const source: Partial<MessageSource> = entry?.source || {}
2460
2488
  if (source.connectorId !== connectorId) continue
2461
2489
  if (source.channelId !== channelId) continue
2462
2490
  if (!source.messageId && result?.messageId) {
@@ -284,7 +284,7 @@ const slack: PlatformConnector = {
284
284
  })
285
285
 
286
286
  // Handle @mentions
287
- app.event('app_mention', async ({ event, say, client }) => {
287
+ app.event('app_mention', async ({ event, client }) => {
288
288
  if (allowedChannels && !allowedChannels.includes(event.channel)) return
289
289
 
290
290
  let senderName = event.user || 'unknown'
@@ -16,6 +16,7 @@ import { startHeartbeatService, stopHeartbeatService, getHeartbeatServiceStatus
16
16
  import { hasOpenClawAgents, ensureGatewayConnected, disconnectGateway, getGateway } from './openclaw-gateway'
17
17
  import { enqueueSessionRun } from './session-run-manager'
18
18
  import { WORKSPACE_DIR } from './data-dir'
19
+ import { DEFAULT_HEARTBEAT_INTERVAL_SEC } from '@/lib/heartbeat-defaults'
19
20
  import { genId } from '@/lib/id'
20
21
  import path from 'node:path'
21
22
  import type { WebhookRetryEntry } from '@/types'
@@ -57,7 +58,7 @@ function daemonAutostartEnvEnabled(): boolean {
57
58
  return parseBoolish(process.env.SWARMCLAW_DAEMON_AUTOSTART, true)
58
59
  }
59
60
 
60
- function parseHeartbeatIntervalSec(value: unknown, fallback = 120): number {
61
+ function parseHeartbeatIntervalSec(value: unknown, fallback = DEFAULT_HEARTBEAT_INTERVAL_SEC): number {
61
62
  const parsed = typeof value === 'number'
62
63
  ? value
63
64
  : typeof value === 'string'
@@ -735,7 +736,7 @@ async function runHealthChecks() {
735
736
 
736
737
  const sessionId = session.id
737
738
  const sessionLabel = String(session.name || sessionId)
738
- const intervalSec = parseHeartbeatIntervalSec(session.heartbeatIntervalSec, 120)
739
+ const intervalSec = parseHeartbeatIntervalSec(session.heartbeatIntervalSec, DEFAULT_HEARTBEAT_INTERVAL_SEC)
739
740
  if (intervalSec <= 0) continue
740
741
  const staleAfter = Math.max(intervalSec * STALE_MULTIPLIER * 1000, STALE_MIN_MS)
741
742
  const lastActive = typeof session.lastActiveAt === 'number' ? session.lastActiveAt : 0
@@ -0,0 +1,47 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { AGENT_REGRESSION_SCENARIOS, resolveRegressionApprovalSettings, scoreAssertions } from './agent-regression'
4
+
5
+ describe('agent regression helpers', () => {
6
+ it('maps approval modes onto deterministic platform settings', () => {
7
+ assert.deepEqual(resolveRegressionApprovalSettings('manual'), {
8
+ approvalsEnabled: true,
9
+ approvalAutoApproveCategories: [],
10
+ })
11
+ assert.deepEqual(resolveRegressionApprovalSettings('auto'), {
12
+ approvalsEnabled: true,
13
+ approvalAutoApproveCategories: ['tool_access'],
14
+ })
15
+ assert.deepEqual(resolveRegressionApprovalSettings('off'), {
16
+ approvalsEnabled: false,
17
+ approvalAutoApproveCategories: [],
18
+ })
19
+ })
20
+
21
+ it('scores scenarios from assertion weights instead of prose', () => {
22
+ const scored = scoreAssertions([
23
+ { name: 'artifact exists', passed: true, weight: 2 },
24
+ { name: 'exact token preserved', passed: false, weight: 3 },
25
+ { name: 'delegate used', passed: true },
26
+ ])
27
+
28
+ assert.deepEqual(scored, {
29
+ score: 3,
30
+ maxScore: 6,
31
+ status: 'failed',
32
+ })
33
+ })
34
+
35
+ it('includes the extended signup, secrets, email, and human-verification scenarios', () => {
36
+ const ids = AGENT_REGRESSION_SCENARIOS.map((scenario) => scenario.id)
37
+ assert.deepEqual(ids, [
38
+ 'approval-resume',
39
+ 'delegate-literal-artifact',
40
+ 'schedule-script',
41
+ 'open-ended-iteration',
42
+ 'mock-signup-secret-email',
43
+ 'human-verified-signup',
44
+ 'research-build-deploy',
45
+ ])
46
+ })
47
+ })