@swarmclawai/swarmclaw 0.7.3 → 0.7.5

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 (152) 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 +4 -87
  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/agent-thread-session.test.ts +85 -0
  88. package/src/lib/server/agent-thread-session.ts +123 -0
  89. package/src/lib/server/approvals-auto-approve.test.ts +59 -0
  90. package/src/lib/server/build-llm.test.ts +13 -5
  91. package/src/lib/server/chat-execution-tool-events.test.ts +87 -2
  92. package/src/lib/server/chat-execution.ts +159 -71
  93. package/src/lib/server/chatroom-helpers.test.ts +7 -0
  94. package/src/lib/server/chatroom-helpers.ts +99 -6
  95. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  96. package/src/lib/server/connectors/manager.ts +89 -61
  97. package/src/lib/server/connectors/slack.ts +1 -1
  98. package/src/lib/server/daemon-state.ts +3 -2
  99. package/src/lib/server/data-dir.test.ts +56 -0
  100. package/src/lib/server/data-dir.ts +15 -9
  101. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  102. package/src/lib/server/eval/agent-regression.ts +1742 -0
  103. package/src/lib/server/eval/runner.ts +11 -1
  104. package/src/lib/server/eval/store.ts +2 -1
  105. package/src/lib/server/heartbeat-service.ts +23 -8
  106. package/src/lib/server/heartbeat-wake.ts +6 -2
  107. package/src/lib/server/main-agent-loop.ts +13 -6
  108. package/src/lib/server/openclaw-exec-config.ts +4 -2
  109. package/src/lib/server/openclaw-gateway.ts +123 -36
  110. package/src/lib/server/orchestrator-lg.ts +1 -2
  111. package/src/lib/server/orchestrator.ts +3 -2
  112. package/src/lib/server/plugins.test.ts +9 -1
  113. package/src/lib/server/plugins.ts +12 -2
  114. package/src/lib/server/provider-model-discovery.ts +481 -0
  115. package/src/lib/server/queue.ts +1 -1
  116. package/src/lib/server/runtime-settings.test.ts +119 -0
  117. package/src/lib/server/runtime-settings.ts +12 -92
  118. package/src/lib/server/schedule-normalization.ts +187 -0
  119. package/src/lib/server/session-tools/autonomy-tools.test.ts +23 -0
  120. package/src/lib/server/session-tools/crud.ts +27 -3
  121. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  122. package/src/lib/server/session-tools/discovery.ts +18 -8
  123. package/src/lib/server/session-tools/file-normalize.test.ts +5 -0
  124. package/src/lib/server/session-tools/file.ts +8 -2
  125. package/src/lib/server/session-tools/http.ts +9 -3
  126. package/src/lib/server/session-tools/index.ts +31 -1
  127. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  128. package/src/lib/server/session-tools/monitor.ts +14 -7
  129. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  130. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  131. package/src/lib/server/session-tools/platform.ts +1 -1
  132. package/src/lib/server/session-tools/plugin-creator.ts +9 -2
  133. package/src/lib/server/session-tools/sandbox.ts +51 -92
  134. package/src/lib/server/session-tools/session-info.ts +22 -1
  135. package/src/lib/server/session-tools/session-tools-wiring.test.ts +23 -0
  136. package/src/lib/server/session-tools/shell.ts +2 -2
  137. package/src/lib/server/session-tools/subagent.ts +3 -1
  138. package/src/lib/server/session-tools/web.ts +73 -30
  139. package/src/lib/server/storage.ts +29 -3
  140. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  141. package/src/lib/server/stream-agent-chat.ts +139 -4
  142. package/src/lib/server/structured-extract.ts +1 -1
  143. package/src/lib/server/task-mention.ts +0 -1
  144. package/src/lib/server/tool-aliases.ts +37 -6
  145. package/src/lib/server/tool-capability-policy.ts +1 -1
  146. package/src/lib/setup-defaults.ts +352 -11
  147. package/src/lib/tool-definitions.ts +3 -4
  148. package/src/lib/validation/schemas.ts +55 -1
  149. package/src/stores/use-app-store.ts +43 -1
  150. package/src/stores/use-chatroom-store.ts +153 -26
  151. package/src/types/index.ts +189 -6
  152. package/src/app/api/chats/[id]/main-loop/route.ts +0 -13
@@ -1,3 +1,5 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
1
3
  import { genId } from '@/lib/id'
2
4
  import type { EvalScenario, EvalRun, EvalSuiteResult } from './types'
3
5
  import { getScenario, EVAL_SCENARIOS } from './scenarios'
@@ -5,8 +7,15 @@ import { scoreCriteria } from './scorer'
5
7
  import { saveEvalRun } from './store'
6
8
  import { loadSessions, saveSessions, loadAgents, loadCredentials, decryptKey } from '../storage'
7
9
  import { executeSessionChatTurn } from '../chat-execution'
10
+ import { WORKSPACE_DIR } from '../data-dir'
8
11
  import type { Session } from '@/types'
9
12
 
13
+ export function resolveEvalSessionCwd(runId: string): string {
14
+ const dir = path.join(WORKSPACE_DIR, 'evals', runId)
15
+ fs.mkdirSync(dir, { recursive: true })
16
+ return dir
17
+ }
18
+
10
19
  export async function runEvalScenario(scenarioId: string, agentId: string): Promise<EvalRun> {
11
20
  const scenario = getScenario(scenarioId)
12
21
  if (!scenario) throw new Error(`Unknown eval scenario: ${scenarioId}`)
@@ -18,6 +27,7 @@ export async function runEvalScenario(scenarioId: string, agentId: string): Prom
18
27
  const runId = genId()
19
28
  const sessionId = `eval-${runId}`
20
29
  const now = Date.now()
30
+ const sessionCwd = resolveEvalSessionCwd(runId)
21
31
 
22
32
  const run: EvalRun = {
23
33
  id: runId,
@@ -36,7 +46,7 @@ export async function runEvalScenario(scenarioId: string, agentId: string): Prom
36
46
  const evalSession: Session = {
37
47
  id: sessionId,
38
48
  name: `Eval: ${scenario.name}`,
39
- cwd: process.cwd(),
49
+ cwd: sessionCwd,
40
50
  user: 'eval-runner',
41
51
  provider: (agent.provider as Session['provider']) ?? 'anthropic',
42
52
  model: (agent.model as string) ?? '',
@@ -1,8 +1,9 @@
1
1
  import Database from 'better-sqlite3'
2
2
  import path from 'path'
3
3
  import type { EvalRun } from './types'
4
+ import { DATA_DIR } from '../data-dir'
4
5
 
5
- const DB_PATH = path.join(process.cwd(), 'data', 'eval-runs.db')
6
+ const DB_PATH = path.join(DATA_DIR, 'eval-runs.db')
6
7
 
7
8
  let db: Database.Database | null = null
8
9
 
@@ -1,11 +1,18 @@
1
1
  import fs from 'fs'
2
2
  import path from 'path'
3
+ import {
4
+ DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
5
+ DEFAULT_HEARTBEAT_INTERVAL_SEC,
6
+ DEFAULT_HEARTBEAT_SHOW_ALERTS,
7
+ DEFAULT_HEARTBEAT_SHOW_OK,
8
+ } from '@/lib/heartbeat-defaults'
3
9
  import { loadAgents, loadSessions, loadSettings } from './storage'
4
10
  import { enqueueSessionRun, getSessionRunState } from './session-run-manager'
5
11
  import { log } from './logger'
6
12
  import { WORKSPACE_DIR } from './data-dir'
7
13
  import { drainSystemEvents } from './system-events'
8
14
  import { buildIdentityContinuityContext } from './identity-continuity'
15
+ import { ensureAgentThreadSession } from './agent-thread-session'
9
16
 
10
17
  const HEARTBEAT_TICK_MS = 5_000
11
18
 
@@ -275,7 +282,7 @@ function resolveNum(obj: Record<string, any>, key: string, current: number): num
275
282
 
276
283
  function heartbeatConfigForSession(session: any, settings: Record<string, any>, agents: Record<string, any>): HeartbeatConfig {
277
284
  // Global defaults — 30 min interval (was 120s)
278
- let intervalSec = resolveInterval(settings, 1800)
285
+ let intervalSec = resolveInterval(settings, DEFAULT_HEARTBEAT_INTERVAL_SEC)
279
286
  const globalPrompt = (typeof settings.heartbeatPrompt === 'string' && settings.heartbeatPrompt.trim())
280
287
  ? settings.heartbeatPrompt.trim()
281
288
  : DEFAULT_HEARTBEAT_PROMPT
@@ -283,9 +290,9 @@ function heartbeatConfigForSession(session: any, settings: Record<string, any>,
283
290
  let enabled = intervalSec > 0
284
291
  let prompt = globalPrompt
285
292
  let model: string | null = resolveStr(settings, 'heartbeatModel', null)
286
- let ackMaxChars = resolveNum(settings, 'heartbeatAckMaxChars', 300)
287
- let showOk = resolveBool(settings, 'heartbeatShowOk', false)
288
- let showAlerts = resolveBool(settings, 'heartbeatShowAlerts', true)
293
+ let ackMaxChars = resolveNum(settings, 'heartbeatAckMaxChars', DEFAULT_HEARTBEAT_ACK_MAX_CHARS)
294
+ let showOk = resolveBool(settings, 'heartbeatShowOk', DEFAULT_HEARTBEAT_SHOW_OK)
295
+ let showAlerts = resolveBool(settings, 'heartbeatShowAlerts', DEFAULT_HEARTBEAT_SHOW_ALERTS)
289
296
  let target: string | null = resolveStr(settings, 'heartbeatTarget', null)
290
297
 
291
298
  // Agent layer overrides
@@ -352,8 +359,12 @@ async function tickHeartbeats() {
352
359
  return
353
360
  }
354
361
 
355
- const sessions = loadSessions()
356
362
  const agents = loadAgents()
363
+ for (const agent of Object.values(agents) as any[]) {
364
+ if (!agent?.id || agent.heartbeatEnabled !== true) continue
365
+ ensureAgentThreadSession(String(agent.id))
366
+ }
367
+ const sessions = loadSessions()
357
368
  const hasScopedAgents = Object.values(agents).some((a: any) => a?.heartbeatEnabled === true)
358
369
 
359
370
  // Prune tracked sessions that no longer exist or have heartbeat disabled
@@ -371,7 +382,6 @@ async function tickHeartbeats() {
371
382
 
372
383
  for (const session of Object.values(sessions) as any[]) {
373
384
  if (!session?.id) continue
374
- if (!Array.isArray(session.plugins) || session.plugins.length === 0) continue
375
385
  if (session.sessionType && session.sessionType !== 'human') continue
376
386
 
377
387
  // Check if this session or its agent has explicit heartbeat opt-in
@@ -396,8 +406,13 @@ async function tickHeartbeats() {
396
406
  : Math.max(cfg.intervalSec * 2, 180)
397
407
  const userIdleThresholdSec = resolveHeartbeatUserIdleSec(settings, defaultIdleSec)
398
408
  const lastUserAt = lastUserMessageAt(session)
399
- if (lastUserAt <= 0) continue
400
- const idleMs = now - lastUserAt
409
+ const baselineAt = lastUserAt > 0
410
+ ? lastUserAt
411
+ : explicitOptIn
412
+ ? (typeof session.lastActiveAt === 'number' ? session.lastActiveAt : (typeof session.createdAt === 'number' ? session.createdAt : 0))
413
+ : 0
414
+ if (baselineAt <= 0) continue
415
+ const idleMs = now - baselineAt
401
416
  if (idleMs < userIdleThresholdSec * 1000) continue
402
417
 
403
418
  const last = state.lastBySession.get(session.id) || 0
@@ -3,6 +3,7 @@
3
3
  * Requests are debounced with a 250ms coalesce window to batch rapid-fire events.
4
4
  */
5
5
 
6
+ import { ensureAgentThreadSession } from './agent-thread-session'
6
7
  import { loadSessions, loadAgents, loadSettings } from './storage'
7
8
  import { enqueueSessionRun } from './session-run-manager'
8
9
  import { log } from './logger'
@@ -29,7 +30,6 @@ function flushWakes(): void {
29
30
  const wakes = new Map(state.pending)
30
31
  state.pending.clear()
31
32
 
32
- const sessions = loadSessions()
33
33
  const agents = loadAgents()
34
34
  const settings = loadSettings()
35
35
 
@@ -39,6 +39,7 @@ function flushWakes(): void {
39
39
 
40
40
  // If only agentId provided, find the agent's most recently active session
41
41
  if (!sessionId && wake.agentId) {
42
+ const sessions = loadSessions()
42
43
  let bestSession: { id: string; lastActiveAt: number } | null = null
43
44
  for (const s of Object.values(sessions) as Array<Record<string, unknown>>) {
44
45
  if (s.agentId !== wake.agentId) continue
@@ -48,11 +49,14 @@ function flushWakes(): void {
48
49
  }
49
50
  }
50
51
  sessionId = bestSession?.id
52
+ if (!sessionId) {
53
+ sessionId = ensureAgentThreadSession(wake.agentId)?.id
54
+ }
51
55
  }
52
56
 
53
57
  if (!sessionId) continue
54
58
 
55
- const session = sessions[sessionId] as Record<string, unknown> | undefined
59
+ const session = loadSessions()[sessionId] as Record<string, unknown> | undefined
56
60
  if (!session) continue
57
61
 
58
62
  const agentId = (session.agentId || wake.agentId) as string | undefined
@@ -66,11 +66,13 @@ export interface HandleMainLoopRunResultInput {
66
66
  estimatedCost?: number
67
67
  }
68
68
 
69
- export function isMainSession(_session: unknown): boolean {
69
+ export function isMainSession(session: unknown): boolean {
70
+ void session
70
71
  return false
71
72
  }
72
73
 
73
- export function buildMainLoopHeartbeatPrompt(_session: unknown, fallbackPrompt: string): string {
74
+ export function buildMainLoopHeartbeatPrompt(session: unknown, fallbackPrompt: string): string {
75
+ void session
74
76
  return fallbackPrompt
75
77
  }
76
78
 
@@ -82,18 +84,23 @@ export function stripMainLoopMetaForPersistence(text: string): string {
82
84
  .trim()
83
85
  }
84
86
 
85
- export function getMainLoopStateForSession(_sessionId: string): MainLoopState | null {
87
+ export function getMainLoopStateForSession(sessionId: string): MainLoopState | null {
88
+ void sessionId
86
89
  return null
87
90
  }
88
91
 
89
- export function setMainLoopStateForSession(_sessionId: string, _patch: Partial<MainLoopState>): MainLoopState | null {
92
+ export function setMainLoopStateForSession(sessionId: string, patch: Partial<MainLoopState>): MainLoopState | null {
93
+ void sessionId
94
+ void patch
90
95
  return null
91
96
  }
92
97
 
93
- export function pushMainLoopEventToMainSessions(_input: PushMainLoopEventInput): number {
98
+ export function pushMainLoopEventToMainSessions(input: PushMainLoopEventInput): number {
99
+ void input
94
100
  return 0
95
101
  }
96
102
 
97
- export function handleMainLoopRunResult(_input: HandleMainLoopRunResultInput): MainLoopFollowupRequest | null {
103
+ export function handleMainLoopRunResult(input: HandleMainLoopRunResultInput): MainLoopFollowupRequest | null {
104
+ void input
98
105
  return null
99
106
  }
@@ -8,7 +8,8 @@ const DEFAULT_CONFIG: ExecApprovalConfig = {
8
8
  }
9
9
 
10
10
  /** Fetch the gateway's global exec approval config. */
11
- export async function getExecConfig(_agentId?: string): Promise<ExecApprovalSnapshot> {
11
+ export async function getExecConfig(agentId?: string): Promise<ExecApprovalSnapshot> {
12
+ void agentId
12
13
  const gw = await ensureGatewayConnected()
13
14
  if (!gw) throw new Error('Gateway not connected')
14
15
 
@@ -21,10 +22,11 @@ export async function getExecConfig(_agentId?: string): Promise<ExecApprovalSnap
21
22
 
22
23
  /** Save exec approval config with hash-based conflict retry (up to 3 attempts) */
23
24
  export async function setExecConfig(
24
- _agentId: string,
25
+ agentId: string,
25
26
  config: ExecApprovalConfig,
26
27
  baseHash: string,
27
28
  ): Promise<{ ok: boolean; hash: string }> {
29
+ void agentId
28
30
  const gw = await ensureGatewayConnected()
29
31
  if (!gw) throw new Error('Gateway not connected')
30
32
 
@@ -3,6 +3,7 @@ import { randomUUID } from 'crypto'
3
3
  import { wsConnect, buildOpenClawConnectParams } from '../providers/openclaw'
4
4
  import { loadAgents, loadCredentials, decryptKey } from './storage'
5
5
  import { notify, notifyWithPayload } from './ws-hub'
6
+ import { getGatewayProfile, getGatewayProfiles, resolvePrimaryAgentRoute } from './agent-runtime-config'
6
7
 
7
8
  // --- Types ---
8
9
 
@@ -19,19 +20,22 @@ type EventHandler = (payload: unknown) => void
19
20
  const GK = '__swarmclaw_ocgateway__' as const
20
21
 
21
22
  interface GatewayState {
22
- instance: OpenClawGateway | null
23
+ instances: Map<string, OpenClawGateway>
24
+ activeKey: string | null
23
25
  }
24
26
 
25
27
  function getState(): GatewayState {
26
28
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
27
29
  const g = globalThis as any
28
- if (!g[GK]) g[GK] = { instance: null }
30
+ if (!g[GK]) g[GK] = { instances: new Map<string, OpenClawGateway>(), activeKey: null }
29
31
  return g[GK] as GatewayState
30
32
  }
31
33
 
32
34
  // --- Helper: resolve gateway config from first OpenClaw agent ---
33
35
 
34
36
  interface GatewayConfig {
37
+ key: string
38
+ profileId?: string | null
35
39
  wsUrl: string
36
40
  token: string | undefined
37
41
  }
@@ -43,27 +47,79 @@ function normalizeWsUrl(raw: string): string {
43
47
  return url.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
44
48
  }
45
49
 
46
- export function resolveGatewayConfig(): GatewayConfig | null {
47
- const agents = loadAgents({ includeTrashed: true })
50
+ function resolveTokenForCredential(credentialId?: string | null): string | undefined {
51
+ const id = typeof credentialId === 'string' && credentialId.trim() ? credentialId.trim() : ''
52
+ if (!id) return undefined
48
53
  const creds = loadCredentials()
54
+ const cred = creds[id]
55
+ if (!cred?.encryptedKey) return undefined
56
+ try {
57
+ return decryptKey(cred.encryptedKey)
58
+ } catch {
59
+ return undefined
60
+ }
61
+ }
62
+
63
+ export function resolveGatewayConfig(target?: {
64
+ profileId?: string | null
65
+ agentId?: string | null
66
+ }): GatewayConfig | null {
67
+ const profileId = typeof target?.profileId === 'string' ? target.profileId.trim() : ''
68
+ if (profileId) {
69
+ const profile = getGatewayProfile(profileId)
70
+ if (!profile) return null
71
+ return {
72
+ key: `profile:${profile.id}`,
73
+ profileId: profile.id,
74
+ wsUrl: profile.wsUrl ? normalizeWsUrl(profile.wsUrl) : normalizeWsUrl(profile.endpoint),
75
+ token: resolveTokenForCredential(profile.credentialId),
76
+ }
77
+ }
78
+
79
+ const agentId = typeof target?.agentId === 'string' ? target.agentId.trim() : ''
80
+ if (agentId) {
81
+ const agents = loadAgents({ includeTrashed: true })
82
+ const agent = agents[agentId]
83
+ const route = resolvePrimaryAgentRoute(agent)
84
+ if (route?.provider === 'openclaw') {
85
+ return {
86
+ key: route.gatewayProfileId ? `profile:${route.gatewayProfileId}` : `agent:${agentId}`,
87
+ profileId: route.gatewayProfileId ?? null,
88
+ wsUrl: normalizeWsUrl(route.apiEndpoint || 'ws://127.0.0.1:18789'),
89
+ token: resolveTokenForCredential(route.credentialId),
90
+ }
91
+ }
92
+ }
93
+
94
+ const gatewayProfiles = getGatewayProfiles('openclaw')
95
+ if (gatewayProfiles[0]) {
96
+ const profile = gatewayProfiles[0]
97
+ return {
98
+ key: `profile:${profile.id}`,
99
+ profileId: profile.id,
100
+ wsUrl: profile.wsUrl ? normalizeWsUrl(profile.wsUrl) : normalizeWsUrl(profile.endpoint),
101
+ token: resolveTokenForCredential(profile.credentialId),
102
+ }
103
+ }
104
+
105
+ const agents = loadAgents({ includeTrashed: true })
49
106
  for (const agent of Object.values(agents)) {
50
107
  if (agent?.provider !== 'openclaw') continue
51
108
  const wsUrl = agent.apiEndpoint
52
109
  ? normalizeWsUrl(agent.apiEndpoint)
53
110
  : '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
- }
111
+ return {
112
+ key: `agent:${agent.id}`,
113
+ profileId: agent.gatewayProfileId ?? null,
114
+ wsUrl,
115
+ token: resolveTokenForCredential(agent.credentialId),
60
116
  }
61
- return { wsUrl, token }
62
117
  }
63
118
  return null
64
119
  }
65
120
 
66
121
  export function hasOpenClawAgents(): boolean {
122
+ if (getGatewayProfiles('openclaw').length > 0) return true
67
123
  const agents = loadAgents({ includeTrashed: true })
68
124
  return Object.values(agents).some((a) => a?.provider === 'openclaw' && !a.trashedAt)
69
125
  }
@@ -253,47 +309,78 @@ export class OpenClawGateway {
253
309
 
254
310
  // --- Singleton access ---
255
311
 
256
- export function getGateway(): OpenClawGateway | null {
257
- return getState().instance
312
+ export function getGateway(profileId?: string | null): OpenClawGateway | null {
313
+ const state = getState()
314
+ const key = typeof profileId === 'string' && profileId.trim() ? `profile:${profileId.trim()}` : null
315
+ if (key) {
316
+ return state.instances.get(key) || null
317
+ }
318
+ if (state.activeKey) {
319
+ return state.instances.get(state.activeKey) || null
320
+ }
321
+ for (const instance of state.instances.values()) {
322
+ if (instance.connected) return instance
323
+ }
324
+ return null
258
325
  }
259
326
 
260
- export async function ensureGatewayConnected(): Promise<OpenClawGateway | null> {
327
+ export async function ensureGatewayConnected(target?: {
328
+ profileId?: string | null
329
+ agentId?: string | null
330
+ }): Promise<OpenClawGateway | null> {
261
331
  const state = getState()
262
- if (state.instance?.connected) return state.instance
263
-
264
- const config = resolveGatewayConfig()
332
+ const config = resolveGatewayConfig(target)
265
333
  if (!config) return null
266
-
267
- if (!state.instance) {
268
- state.instance = new OpenClawGateway()
334
+ const existing = state.instances.get(config.key)
335
+ if (existing?.connected) {
336
+ state.activeKey = config.key
337
+ return existing
269
338
  }
270
339
 
271
- const ok = await state.instance.connect(config.wsUrl, config.token)
272
- return ok ? state.instance : null
340
+ const instance = existing || new OpenClawGateway()
341
+ state.instances.set(config.key, instance)
342
+ const ok = await instance.connect(config.wsUrl, config.token)
343
+ if (ok) {
344
+ state.activeKey = config.key
345
+ return instance
346
+ }
347
+ return null
273
348
  }
274
349
 
275
- export function disconnectGateway() {
350
+ export function disconnectGateway(profileId?: string | null) {
276
351
  const state = getState()
277
- if (state.instance) {
278
- state.instance.disconnect()
279
- state.instance = null
352
+ const key = typeof profileId === 'string' && profileId.trim() ? `profile:${profileId.trim()}` : null
353
+ if (key) {
354
+ const instance = state.instances.get(key)
355
+ if (instance) {
356
+ instance.disconnect()
357
+ state.instances.delete(key)
358
+ if (state.activeKey === key) state.activeKey = null
359
+ }
360
+ return
361
+ }
362
+ for (const [instanceKey, instance] of state.instances.entries()) {
363
+ instance.disconnect()
364
+ state.instances.delete(instanceKey)
280
365
  }
366
+ state.activeKey = null
281
367
  }
282
368
 
283
369
  /** Manual connect with explicit URL/token (used by gateway connection panel) */
284
- export async function manualConnect(url?: string, token?: string): Promise<boolean> {
370
+ export async function manualConnect(url?: string, token?: string, profileId?: string | null): Promise<boolean> {
285
371
  const state = getState()
286
- if (state.instance?.connected) {
287
- state.instance.disconnect()
372
+ const config = resolveGatewayConfig({ profileId: profileId || null })
373
+ const key = profileId ? `profile:${profileId}` : '__manual__'
374
+ const instance = state.instances.get(key) || new OpenClawGateway()
375
+ if (instance.connected) {
376
+ instance.disconnect()
288
377
  }
289
-
290
- const config = resolveGatewayConfig()
378
+ state.instances.set(key, instance)
291
379
  const wsUrl = url ? normalizeWsUrl(url) : config?.wsUrl ?? 'ws://127.0.0.1:18789'
292
380
  const resolvedToken = token ?? config?.token
293
-
294
- if (!state.instance) {
295
- state.instance = new OpenClawGateway()
381
+ const ok = await instance.connect(wsUrl, resolvedToken)
382
+ if (ok) {
383
+ state.activeKey = key
296
384
  }
297
-
298
- return state.instance.connect(wsUrl, resolvedToken)
385
+ return ok
299
386
  }
@@ -612,8 +612,7 @@ export async function executeLangGraphOrchestrator(
612
612
  const completeMatch = finalResult.match(/ORCHESTRATION_COMPLETE:\s*([\s\S]+)/)
613
613
  const summary = completeMatch ? completeMatch[1].trim() : finalResult
614
614
 
615
- // Append meta so the main-loop knows we are done
616
- return `${summary}\n\n[MAIN_LOOP_META] {"status":"ok", "follow_up":false, "summary":${JSON.stringify(summary.slice(0, 300))}, "mission_task_id":${JSON.stringify(taskId || null)}}`
615
+ return summary
617
616
  }
618
617
 
619
618
  /**
@@ -86,6 +86,7 @@ async function executeOrchestratorLegacy(
86
86
  sessionId: string,
87
87
  taskId?: string,
88
88
  ): Promise<string> {
89
+ void taskId
89
90
  const allAgents = loadAgents()
90
91
  const sessions = loadSessions()
91
92
  const session = sessions[sessionId]
@@ -241,7 +242,7 @@ async function executeOrchestratorLegacy(
241
242
 
242
243
  if (cmd.done) {
243
244
  result = cmd.summary || fullText
244
- return `${result}\n\n[MAIN_LOOP_META] {"status":"ok", "follow_up":false, "summary":${JSON.stringify(result.slice(0, 300))}, "mission_task_id":${JSON.stringify(taskId || null)}}`
245
+ return result
245
246
  }
246
247
  }
247
248
 
@@ -256,7 +257,7 @@ async function executeOrchestratorLegacy(
256
257
  result = `Loop stopped after reaching max turns (${maxTurns}).`
257
258
  }
258
259
 
259
- return `${result}\n\n[MAIN_LOOP_META] {"status":"ok", "follow_up":false, "summary":${JSON.stringify(result.slice(0, 300))}, "mission_task_id":${JSON.stringify(taskId || null)}}`
260
+ return result
260
261
  }
261
262
 
262
263
  async function executeSubTask(
@@ -3,7 +3,7 @@ import { describe, it } from 'node:test'
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { getPluginManager, normalizeMarketplacePluginUrl, sanitizePluginFilename } from './plugins'
6
- import { canonicalizePluginId, expandPluginIds } from './tool-aliases'
6
+ import { canonicalizePluginId, expandPluginIds, pluginIdMatches } from './tool-aliases'
7
7
  import { DATA_DIR } from './data-dir'
8
8
 
9
9
  let testPluginSeq = 0
@@ -33,6 +33,14 @@ describe('plugin id canonicalization', () => {
33
33
  assert.equal(expanded.includes('ask_human'), true)
34
34
  assert.equal(expanded.includes('human_loop'), true)
35
35
  })
36
+
37
+ it('does not expand a specific platform tool back into manage_platform', () => {
38
+ const expanded = expandPluginIds(['manage_schedules'])
39
+ assert.equal(expanded.includes('manage_schedules'), true)
40
+ assert.equal(expanded.includes('manage_platform'), false)
41
+ assert.equal(pluginIdMatches(['manage_platform'], 'manage_schedules'), true)
42
+ assert.equal(pluginIdMatches(['manage_schedules'], 'manage_platform'), false)
43
+ })
36
44
  })
37
45
 
38
46
  describe('plugin install helpers', () => {
@@ -510,12 +510,22 @@ class PluginManager {
510
510
  if (this.watcher) return
511
511
  try {
512
512
  this.ensurePluginDirs()
513
- this.watcher = fs.watch(PLUGINS_DIR, (_eventType, filename) => {
513
+ const watcher = fs.watch(PLUGINS_DIR, (_eventType, filename) => {
514
514
  if (!filename || (!filename.endsWith('.js') && !filename.endsWith('.mjs'))) return
515
515
  this.loaded = false
516
516
  notify('plugins')
517
517
  })
518
- this.watcher.unref?.()
518
+ watcher.on('error', (err: unknown) => {
519
+ log.warn('plugins', 'Plugin watcher disabled after runtime watch failure', {
520
+ error: err instanceof Error ? err.message : String(err),
521
+ })
522
+ if (this.watcher === watcher) {
523
+ try { watcher.close() } catch { /* ignore */ }
524
+ this.watcher = null
525
+ }
526
+ })
527
+ watcher.unref?.()
528
+ this.watcher = watcher
519
529
  } catch (err: unknown) {
520
530
  log.warn('plugins', 'Failed to watch plugins directory', {
521
531
  error: err instanceof Error ? err.message : String(err),