@swarmclawai/swarmclaw 1.2.5 → 1.2.8

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 (115) hide show
  1. package/README.md +24 -17
  2. package/next.config.ts +1 -0
  3. package/package.json +3 -2
  4. package/scripts/easy-setup.mjs +1 -1
  5. package/scripts/postinstall.mjs +1 -1
  6. package/skills/swarmclaw.md +115 -0
  7. package/skills/tools/browser.md +131 -0
  8. package/skills/tools/execute.md +98 -0
  9. package/skills/tools/files.md +98 -0
  10. package/skills/tools/memory.md +104 -0
  11. package/skills/tools/platform.md +144 -0
  12. package/skills/tools/skills.md +83 -0
  13. package/src/app/api/chats/[id]/messages/route.ts +23 -19
  14. package/src/app/api/chats/messages-route.test.ts +105 -51
  15. package/src/app/api/mcp-servers/[id]/test/route.ts +3 -2
  16. package/src/app/api/openclaw/deploy/route.ts +2 -0
  17. package/src/app/api/setup/check-provider/route.ts +10 -2
  18. package/src/app/api/setup/doctor/route.ts +4 -4
  19. package/src/components/agents/agent-chat-list.tsx +23 -1
  20. package/src/components/agents/inspector-panel.tsx +165 -48
  21. package/src/components/chat/chat-area.tsx +38 -9
  22. package/src/components/chat/message-list.tsx +33 -19
  23. package/src/components/gateways/gateway-sheet.tsx +5 -2
  24. package/src/lib/agent-execute-defaults.test.ts +24 -0
  25. package/src/lib/agent-execute-defaults.ts +62 -0
  26. package/src/lib/chat/queued-message-queue.test.ts +134 -1
  27. package/src/lib/chat/queued-message-queue.ts +77 -2
  28. package/src/lib/providers/index.test.ts +108 -0
  29. package/src/lib/providers/index.ts +38 -15
  30. package/src/lib/server/agents/agent-service.ts +5 -0
  31. package/src/lib/server/builtin-extensions.ts +1 -0
  32. package/src/lib/server/chat-execution/chat-execution-advanced.test.ts +1 -1
  33. package/src/lib/server/chat-execution/chat-execution-tool-events.test.ts +1 -0
  34. package/src/lib/server/chat-execution/chat-execution-utils.ts +2 -2
  35. package/src/lib/server/chat-execution/chat-turn-preparation.ts +79 -42
  36. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +4 -0
  37. package/src/lib/server/chat-execution/continuation-evaluator.ts +8 -0
  38. package/src/lib/server/chat-execution/memory-mutation-tools.ts +1 -1
  39. package/src/lib/server/chat-execution/message-classifier.ts +11 -1
  40. package/src/lib/server/chat-execution/prompt-builder.test.ts +28 -0
  41. package/src/lib/server/chat-execution/prompt-builder.ts +14 -1
  42. package/src/lib/server/chat-execution/prompt-mode.test.ts +24 -0
  43. package/src/lib/server/chat-execution/prompt-mode.ts +5 -1
  44. package/src/lib/server/chat-execution/stream-agent-chat.test.ts +6 -4
  45. package/src/lib/server/chat-execution/stream-agent-chat.ts +45 -16
  46. package/src/lib/server/chatrooms/chatroom-routing.test.ts +4 -0
  47. package/src/lib/server/connectors/discord.ts +2 -2
  48. package/src/lib/server/connectors/matrix.ts +3 -2
  49. package/src/lib/server/connectors/signal.ts +5 -4
  50. package/src/lib/server/connectors/slack.ts +10 -9
  51. package/src/lib/server/connectors/teams.ts +3 -2
  52. package/src/lib/server/connectors/telegram.ts +4 -4
  53. package/src/lib/server/connectors/whatsapp.ts +2 -2
  54. package/src/lib/server/daemon/controller.ts +7 -0
  55. package/src/lib/server/gateways/gateway-profile-service.ts +19 -1
  56. package/src/lib/server/messages/message-repository.test.ts +70 -0
  57. package/src/lib/server/messages/message-repository.ts +11 -6
  58. package/src/lib/server/openclaw/deploy.ts +32 -2
  59. package/src/lib/server/plugins-advanced.test.ts +1 -2
  60. package/src/lib/server/provider-health.ts +1 -1
  61. package/src/lib/server/runtime/process-manager.ts +13 -9
  62. package/src/lib/server/runtime/session-run-manager/queries.ts +15 -0
  63. package/src/lib/server/runtime/session-run-manager.test.ts +58 -0
  64. package/src/lib/server/sandbox/session-runtime.test.ts +18 -1
  65. package/src/lib/server/sandbox/session-runtime.ts +40 -28
  66. package/src/lib/server/session-tools/autonomy-tools.test.ts +7 -9
  67. package/src/lib/server/session-tools/context.ts +1 -1
  68. package/src/lib/server/session-tools/credential-env.ts +109 -0
  69. package/src/lib/server/session-tools/crud.ts +3 -3
  70. package/src/lib/server/session-tools/edit_file.ts +3 -2
  71. package/src/lib/server/session-tools/execute.test.ts +58 -0
  72. package/src/lib/server/session-tools/execute.ts +334 -0
  73. package/src/lib/server/session-tools/files-tool.ts +635 -0
  74. package/src/lib/server/session-tools/index.ts +14 -4
  75. package/src/lib/server/session-tools/memory-tool.ts +242 -0
  76. package/src/lib/server/session-tools/memory.ts +1 -1
  77. package/src/lib/server/session-tools/openclaw-nodes.ts +3 -2
  78. package/src/lib/server/session-tools/openclaw-workspace.ts +3 -2
  79. package/src/lib/server/session-tools/platform-tool.ts +617 -0
  80. package/src/lib/server/session-tools/session-info.ts +3 -2
  81. package/src/lib/server/session-tools/session-tools-wiring.test.ts +3 -4
  82. package/src/lib/server/session-tools/shell.ts +7 -122
  83. package/src/lib/server/session-tools/skills-tool.ts +396 -0
  84. package/src/lib/server/session-tools/web.ts +2 -2
  85. package/src/lib/server/storage-normalization.ts +2 -0
  86. package/src/lib/server/tool-aliases.ts +2 -1
  87. package/src/lib/server/tool-capability-policy-advanced.test.ts +9 -2
  88. package/src/lib/server/tool-capability-policy.test.ts +2 -1
  89. package/src/lib/server/tool-capability-policy.ts +60 -33
  90. package/src/lib/server/tool-planning.ts +11 -0
  91. package/src/lib/setup-defaults.ts +5 -0
  92. package/src/lib/tool-definitions.ts +1 -0
  93. package/src/lib/validation/schemas.test.ts +16 -0
  94. package/src/lib/validation/schemas.ts +16 -0
  95. package/src/stores/use-chat-store.test.ts +231 -0
  96. package/src/stores/use-chat-store.ts +62 -13
  97. package/src/types/agent.ts +348 -0
  98. package/src/types/app-settings.ts +175 -0
  99. package/src/types/approval.ts +27 -0
  100. package/src/types/connector.ts +187 -0
  101. package/src/types/extension.ts +386 -0
  102. package/src/types/index.ts +16 -3555
  103. package/src/types/message.ts +57 -0
  104. package/src/types/misc.ts +739 -0
  105. package/src/types/mission.ts +185 -0
  106. package/src/types/protocol.ts +422 -0
  107. package/src/types/provider.ts +52 -0
  108. package/src/types/run.ts +183 -0
  109. package/src/types/schedule.ts +59 -0
  110. package/src/types/session.ts +265 -0
  111. package/src/types/skill.ts +157 -0
  112. package/src/types/task.ts +140 -0
  113. package/src/types/working-state.ts +211 -0
  114. package/src/views/settings/section-heartbeat.tsx +2 -2
  115. package/src/lib/server/session-tools/sandbox.ts +0 -281
@@ -4,6 +4,7 @@ import path from 'path'
4
4
  import { DATA_DIR } from '../data-dir'
5
5
  import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
6
6
  import { resolveConnectorIngressReply } from './ingress-delivery'
7
+ import { errorMessage } from '@/lib/shared-utils'
7
8
 
8
9
  const TAG = 'matrix'
9
10
 
@@ -54,8 +55,8 @@ const matrix: PlatformConnector = {
54
55
  const reply = await resolveConnectorIngressReply(onMessage, inbound)
55
56
  if (!reply) return
56
57
  await client.sendText(roomId, reply.visibleText)
57
- } catch (err: any) {
58
- log.error(TAG, 'Error handling message:', err.message)
58
+ } catch (err: unknown) {
59
+ log.error(TAG, 'Error handling message:', errorMessage(err))
59
60
  try {
60
61
  await client.sendText(roomId, 'Sorry, I encountered an error processing your message.')
61
62
  } catch { /* ignore */ }
@@ -4,6 +4,7 @@ import type { ChildProcess } from 'child_process'
4
4
  import type { Connector } from '@/types'
5
5
  import type { PlatformConnector, ConnectorInstance, InboundMessage, ConnectorIngressResult } from './types'
6
6
  import { resolveConnectorIngressReply } from './ingress-delivery'
7
+ import { errorMessage } from '@/lib/shared-utils'
7
8
 
8
9
  const TAG = 'signal'
9
10
 
@@ -107,8 +108,8 @@ const signal: PlatformConnector = {
107
108
  `${cliPath} -u ${phoneNumber} send -m ${JSON.stringify(text)} ${channelId}`,
108
109
  { timeout: 15_000 },
109
110
  )
110
- } catch (err: any) {
111
- throw new Error(`Signal send failed: ${err.message}`)
111
+ } catch (err: unknown) {
112
+ throw new Error(`Signal send failed: ${errorMessage(err)}`)
112
113
  }
113
114
  }
114
115
  },
@@ -179,8 +180,8 @@ export async function handleSignalEvent(
179
180
  { timeout: 15_000 },
180
181
  )
181
182
  }
182
- } catch (err: any) {
183
- log.error(TAG, 'Error handling message:', err.message)
183
+ } catch (err: unknown) {
184
+ log.error(TAG, 'Error handling message:', errorMessage(err))
184
185
  }
185
186
  }
186
187
 
@@ -145,11 +145,12 @@ const slack: PlatformConnector = {
145
145
  }
146
146
  botUserId = auth.user_id as string
147
147
  log.info(TAG, `Authenticated as @${auth.user} in workspace "${auth.team}"`)
148
- } catch (err: any) {
149
- const hint = err.code === 'slack_webapi_platform_error'
148
+ } catch (err: unknown) {
149
+ const hint = (err instanceof Error && 'code' in err) ? (err as { code: string }).code : undefined
150
+ const suffix = hint === 'slack_webapi_platform_error'
150
151
  ? '. Check that your Bot Token (xoxb-...) is correct and the app is installed to the workspace.'
151
152
  : ''
152
- throw new Error(`Slack auth failed: ${err.message}${hint}`)
153
+ throw new Error(`Slack auth failed: ${errorMessage(err)}${suffix}`)
153
154
  }
154
155
 
155
156
  const app = new App({
@@ -215,8 +216,8 @@ const slack: PlatformConnector = {
215
216
  media.push(stored)
216
217
  continue
217
218
  }
218
- } catch (err: any) {
219
- log.warn(TAG, `Media download failed (${f?.name || 'file'}):`, err?.message || String(err))
219
+ } catch (err: unknown) {
220
+ log.warn(TAG, `Media download failed (${f?.name || 'file'}):`, errorMessage(err))
220
221
  }
221
222
  }
222
223
  media.push({
@@ -264,8 +265,8 @@ const slack: PlatformConnector = {
264
265
  return sent.ts || undefined
265
266
  },
266
267
  })
267
- } catch (err: any) {
268
- log.error(TAG, 'Error handling message:', err.message)
268
+ } catch (err: unknown) {
269
+ log.error(TAG, 'Error handling message:', errorMessage(err))
269
270
  try {
270
271
  await say('Sorry, I encountered an error processing your message.')
271
272
  } catch { /* ignore */ }
@@ -322,8 +323,8 @@ const slack: PlatformConnector = {
322
323
  return sent.ts || undefined
323
324
  },
324
325
  })
325
- } catch (err: any) {
326
- log.error(TAG, 'Error handling mention:', err.message)
326
+ } catch (err: unknown) {
327
+ log.error(TAG, 'Error handling mention:', errorMessage(err))
327
328
  }
328
329
  })
329
330
 
@@ -1,6 +1,7 @@
1
1
  import { log } from '@/lib/server/logger'
2
2
  import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
3
3
  import { resolveConnectorIngressReply } from './ingress-delivery'
4
+ import { errorMessage } from '@/lib/shared-utils'
4
5
 
5
6
  const TAG = 'teams'
6
7
 
@@ -51,8 +52,8 @@ const teams: PlatformConnector = {
51
52
  const reply = await resolveConnectorIngressReply(onMessage, inbound)
52
53
  if (!reply) return
53
54
  await context.sendActivity(reply.visibleText)
54
- } catch (err: any) {
55
- log.error(TAG, 'Error handling message:', err.message)
55
+ } catch (err: unknown) {
56
+ log.error(TAG, 'Error handling message:', errorMessage(err))
56
57
  try {
57
58
  await context.sendActivity('Sorry, I encountered an error processing your message.')
58
59
  } catch { /* ignore */ }
@@ -127,8 +127,8 @@ const telegram: PlatformConnector = {
127
127
  mimeType: m.mimeType,
128
128
  })
129
129
  if (stored) media.push(stored)
130
- } catch (err: any) {
131
- log.warn(TAG, `Failed to fetch media ${m.fileId}:`, err?.message || String(err))
130
+ } catch (err: unknown) {
131
+ log.warn(TAG, `Failed to fetch media ${m.fileId}:`, errorMessage(err))
132
132
  media.push({
133
133
  type: m.type,
134
134
  fileName: m.fileName,
@@ -177,8 +177,8 @@ const telegram: PlatformConnector = {
177
177
  return String(sent.message_id)
178
178
  },
179
179
  })
180
- } catch (err: any) {
181
- log.error(TAG, 'Error handling message:', err.message)
180
+ } catch (err: unknown) {
181
+ log.error(TAG, 'Error handling message:', errorMessage(err))
182
182
  try {
183
183
  await ctx.reply('Sorry, I encountered an error processing your message.')
184
184
  } catch { /* ignore */ }
@@ -714,8 +714,8 @@ const whatsapp: PlatformConnector = {
714
714
  fileName: mediaCandidate.payload?.fileName || undefined,
715
715
  })
716
716
  media.push(saved)
717
- } catch (err: any) {
718
- log.error(TAG, `Failed to decode media: ${err?.message || String(err)}`)
717
+ } catch (err: unknown) {
718
+ log.error(TAG, `Failed to decode media: ${errorMessage(err)}`)
719
719
  media.push({
720
720
  type: mediaCandidate.kind,
721
721
  fileName: mediaCandidate.payload?.fileName || undefined,
@@ -25,6 +25,7 @@ import type {
25
25
  } from '@/lib/server/daemon/types'
26
26
  import { DATA_DIR } from '@/lib/server/data-dir'
27
27
  import { loadEstopState } from '@/lib/server/runtime/estop'
28
+ import { getDaemonStatus } from '@/lib/server/runtime/daemon-state/core'
28
29
  import { daemonAutostartEnvEnabled } from '@/lib/server/runtime/daemon-policy'
29
30
  import {
30
31
  releaseRuntimeLock,
@@ -344,6 +345,12 @@ export async function ensureDaemonProcessRunning(
344
345
  source: string,
345
346
  opts?: { manualStart?: boolean },
346
347
  ): Promise<boolean> {
348
+ // In dev mode, the daemon may already be running in-process (same Next.js server)
349
+ // without a daemon-admin.json file. Check in-process state first to avoid spawning
350
+ // a subprocess that fails to acquire the already-held lease.
351
+ const inProcessStatus = getDaemonStatus()
352
+ if (inProcessStatus.running) return false
353
+
347
354
  const manualStart = opts?.manualStart === true
348
355
  const record = loadDaemonStatusRecord()
349
356
  if (loadEstopState().level !== 'none') return false
@@ -4,6 +4,7 @@ import { genId } from '@/lib/id'
4
4
  import { normalizeOpenClawEndpoint } from '@/lib/openclaw/openclaw-endpoint'
5
5
  import { listAgents, saveAgentMany } from '@/lib/server/agents/agent-repository'
6
6
  import { getGatewayProfiles } from '@/lib/server/agents/agent-runtime-config'
7
+ import { deleteCredentialRecord } from '@/lib/server/credentials/credential-service'
7
8
  import {
8
9
  loadGatewayProfile,
9
10
  loadGatewayProfiles,
@@ -161,7 +162,9 @@ export function updateGatewayProfile(id: string, input: Record<string, unknown>)
161
162
 
162
163
  export function deleteGatewayProfileAndDetachAgents(id: string): boolean {
163
164
  const gateways = loadGatewayProfiles()
164
- if (!gateways[id]) return false
165
+ const deleted = gateways[id]
166
+ if (!deleted) return false
167
+ const orphanCredentialId = deleted.credentialId || null
165
168
  delete gateways[id]
166
169
  saveGatewayProfiles(gateways)
167
170
 
@@ -195,6 +198,21 @@ export function deleteGatewayProfileAndDetachAgents(id: string): boolean {
195
198
  }
196
199
 
197
200
  if (changed.length > 0) saveAgentMany(changed)
201
+
202
+ // Clean up orphaned credential if no other gateway or agent references it
203
+ if (orphanCredentialId) {
204
+ const stillReferencedByGateway = Object.values(gateways).some(
205
+ (gw) => gw && gw.credentialId === orphanCredentialId,
206
+ )
207
+ const stillReferencedByAgent = !stillReferencedByGateway && Object.values(agents).some(
208
+ (a) => a.credentialId === orphanCredentialId
209
+ || (Array.isArray(a.fallbackCredentialIds) && a.fallbackCredentialIds.includes(orphanCredentialId)),
210
+ )
211
+ if (!stillReferencedByGateway && !stillReferencedByAgent) {
212
+ deleteCredentialRecord(orphanCredentialId)
213
+ }
214
+ }
215
+
198
216
  notify('gateways')
199
217
  return true
200
218
  }
@@ -0,0 +1,70 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { runWithTempDataDir } from '@/lib/server/test-utils/run-with-temp-data-dir'
4
+
5
+ test('appendMessage notifies both generic and per-session message topics', () => {
6
+ const output = runWithTempDataDir<{
7
+ genericTopics: string[]
8
+ sessionTopics: string[]
9
+ }>(`
10
+ const { WebSocket } = await import('ws')
11
+ const storageMod = await import('@/lib/server/storage')
12
+ const repoMod = await import('@/lib/server/messages/message-repository')
13
+ const storage = storageMod.default || storageMod
14
+ const repo = repoMod.default || repoMod
15
+
16
+ storage.saveSessions({
17
+ 'sess-notify': {
18
+ id: 'sess-notify',
19
+ name: 'Notify Session',
20
+ cwd: process.env.WORKSPACE_DIR,
21
+ user: 'tester',
22
+ provider: 'openai',
23
+ model: 'gpt-5',
24
+ claudeSessionId: null,
25
+ codexThreadId: null,
26
+ opencodeSessionId: null,
27
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
28
+ messages: [],
29
+ createdAt: Date.now(),
30
+ lastActiveAt: Date.now(),
31
+ },
32
+ })
33
+
34
+ const genericPayloads = []
35
+ const sessionPayloads = []
36
+ globalThis.__swarmclaw_ws__ = {
37
+ wss: null,
38
+ clients: new Set([
39
+ {
40
+ ws: {
41
+ readyState: WebSocket.OPEN,
42
+ send(payload) { genericPayloads.push(JSON.parse(payload)) },
43
+ },
44
+ topics: new Set(['messages']),
45
+ },
46
+ {
47
+ ws: {
48
+ readyState: WebSocket.OPEN,
49
+ send(payload) { sessionPayloads.push(JSON.parse(payload)) },
50
+ },
51
+ topics: new Set(['messages:sess-notify']),
52
+ },
53
+ ]),
54
+ }
55
+
56
+ repo.appendMessage('sess-notify', {
57
+ role: 'user',
58
+ text: 'hello',
59
+ time: 1,
60
+ })
61
+
62
+ console.log(JSON.stringify({
63
+ genericTopics: genericPayloads.map((payload) => payload.topic),
64
+ sessionTopics: sessionPayloads.map((payload) => payload.topic),
65
+ }))
66
+ `, { prefix: 'swarmclaw-message-repo-notify-' })
67
+
68
+ assert.deepEqual(output.genericTopics, ['messages'])
69
+ assert.deepEqual(output.sessionTopics, ['messages:sess-notify'])
70
+ })
@@ -107,6 +107,11 @@ function syncSessionMeta(sessionId: string): void {
107
107
  })
108
108
  }
109
109
 
110
+ function notifyMessageTopics(sessionId: string, action: string): void {
111
+ notify('messages', action, sessionId)
112
+ notify(`messages:${sessionId}`, action)
113
+ }
114
+
110
115
  // ---------------------------------------------------------------------------
111
116
  // Lazy migration — copies blob messages → table on first access
112
117
  // ---------------------------------------------------------------------------
@@ -229,7 +234,7 @@ export function appendMessage(sessionId: string, message: Message): number {
229
234
  const seq = nextSeq(sessionId)
230
235
  stmts().insert.run(sessionId, seq, JSON.stringify(message))
231
236
  syncSessionMeta(sessionId)
232
- notify('messages', 'append', sessionId)
237
+ notifyMessageTopics(sessionId, 'append')
233
238
  return seq
234
239
  }, { sessionId })
235
240
  }
@@ -247,7 +252,7 @@ export function appendMessages(sessionId: string, messages: Message[]): void {
247
252
  }
248
253
  })
249
254
  syncSessionMeta(sessionId)
250
- notify('messages', 'append', sessionId)
255
+ notifyMessageTopics(sessionId, 'append')
251
256
  }, { sessionId, count: messages.length })
252
257
  }
253
258
 
@@ -256,7 +261,7 @@ export function replaceMessageAt(sessionId: string, seq: number, message: Messag
256
261
  perf.measureSync('message-repo', 'replaceMessageAt', () => {
257
262
  stmts().update.run(JSON.stringify(message), sessionId, seq)
258
263
  syncSessionMeta(sessionId)
259
- notify('messages', 'update', sessionId)
264
+ notifyMessageTopics(sessionId, 'update')
260
265
  }, { sessionId, seq })
261
266
  }
262
267
 
@@ -265,7 +270,7 @@ export function truncateAfter(sessionId: string, seq: number): void {
265
270
  perf.measureSync('message-repo', 'truncateAfter', () => {
266
271
  stmts().deleteAfter.run(sessionId, seq)
267
272
  syncSessionMeta(sessionId)
268
- notify('messages', 'truncate', sessionId)
273
+ notifyMessageTopics(sessionId, 'truncate')
269
274
  }, { sessionId, seq })
270
275
  }
271
276
 
@@ -274,7 +279,7 @@ export function clearMessages(sessionId: string): void {
274
279
  perf.measureSync('message-repo', 'clearMessages', () => {
275
280
  stmts().deleteAll.run(sessionId)
276
281
  syncSessionMeta(sessionId)
277
- notify('messages', 'clear', sessionId)
282
+ notifyMessageTopics(sessionId, 'clear')
278
283
  }, { sessionId })
279
284
  }
280
285
 
@@ -289,7 +294,7 @@ export function replaceAllMessages(sessionId: string, messages: Message[]): void
289
294
  }
290
295
  })
291
296
  syncSessionMeta(sessionId)
292
- notify('messages', 'replace', sessionId)
297
+ notifyMessageTopics(sessionId, 'replace')
293
298
  }, { sessionId, count: messages.length })
294
299
  }
295
300
 
@@ -12,6 +12,9 @@ import {
12
12
  type ProcessStatus,
13
13
  } from '@/lib/server/runtime/process-manager'
14
14
  import { normalizeOpenClawEndpoint, deriveOpenClawWsUrl } from '@/lib/openclaw/openclaw-endpoint'
15
+ import { createCredentialRecord } from '@/lib/server/credentials/credential-service'
16
+ import { createGatewayProfile } from '@/lib/server/gateways/gateway-profile-service'
17
+ import { loadGatewayProfiles } from '@/lib/server/gateways/gateway-profile-repository'
15
18
  import { probeOpenClawHealth, type OpenClawHealthResult } from './health'
16
19
  import { DATA_DIR } from '../data-dir'
17
20
 
@@ -1046,7 +1049,7 @@ export async function startOpenClawLocalDeploy(input?: {
1046
1049
  port?: number
1047
1050
  token?: string | null
1048
1051
  makePrimary?: boolean
1049
- }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string }> {
1052
+ }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string; gatewayProfileId: string | null }> {
1050
1053
  const state = getRuntimeState()
1051
1054
  const port = sanitizeLocalPort(input?.port, DEFAULT_LOCAL_PORT)
1052
1055
  const requestedLocalId = typeof input?.localId === 'string' && input.localId.trim()
@@ -1145,11 +1148,38 @@ export async function startOpenClawLocalDeploy(input?: {
1145
1148
 
1146
1149
  await waitForLocalRuntime(result.processId)
1147
1150
 
1151
+ // Auto-provision gateway profile so agents can connect immediately
1152
+ const existingGateways = loadGatewayProfiles()
1153
+ const existingProfile = Object.values(existingGateways).find(
1154
+ (gw) => gw && gw.endpoint === endpoint,
1155
+ )
1156
+ let gatewayProfileId: string | null = null
1157
+
1158
+ if (existingProfile) {
1159
+ gatewayProfileId = existingProfile.id
1160
+ } else {
1161
+ const deployName = current.name || 'Local OpenClaw'
1162
+ const cred = createCredentialRecord({
1163
+ provider: 'openclaw',
1164
+ name: `${deployName} token`,
1165
+ apiKey: token,
1166
+ })
1167
+ const profile = createGatewayProfile({
1168
+ name: deployName,
1169
+ endpoint,
1170
+ credentialId: cred.id,
1171
+ isDefault: Object.keys(existingGateways).length === 0,
1172
+ deployment: { method: 'local', port, localId },
1173
+ })
1174
+ gatewayProfileId = profile.id
1175
+ }
1176
+
1148
1177
  const local = getOpenClawLocalDeployStatus(localId)
1149
1178
  return {
1150
1179
  local,
1151
1180
  locals: getOpenClawLocalDeployStatuses(),
1152
1181
  token,
1182
+ gatewayProfileId,
1153
1183
  }
1154
1184
  }
1155
1185
 
@@ -1190,7 +1220,7 @@ export async function restartOpenClawLocalDeploy(input?: {
1190
1220
  port?: number
1191
1221
  token?: string | null
1192
1222
  makePrimary?: boolean
1193
- }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string }> {
1223
+ }): Promise<{ local: OpenClawLocalDeployStatus; locals: OpenClawLocalDeployStatus[]; token: string; gatewayProfileId: string | null }> {
1194
1224
  const state = getRuntimeState()
1195
1225
  const current = findLocalRuntimeState(state, {
1196
1226
  localId: input?.localId ?? null,
@@ -340,8 +340,7 @@ describe('complex expansion scenarios', () => {
340
340
  it('sandbox aliases expand', () => {
341
341
  const result = expandExtensionIds(['sandbox'])
342
342
  assert.ok(result.includes('sandbox'))
343
- assert.ok(result.includes('sandbox_exec'))
344
- assert.ok(result.includes('sandbox_list_runtimes'))
343
+ assert.ok(result.includes('execute'))
345
344
  })
346
345
 
347
346
  it('files expands to include read_file, write_file, etc.', () => {
@@ -42,7 +42,7 @@ function commandExists(binary: string): boolean {
42
42
  pruneTTLCache(cliCheckCache, CLI_CHECK_TTL_MS, CLI_CHECK_CACHE_MAX)
43
43
  const probe = isWindows
44
44
  ? spawnSync('where', [binary], { timeout: 2000, stdio: 'pipe' })
45
- : spawnSync('/bin/zsh', ['-lc', `command -v ${binary} >/dev/null 2>&1`], { timeout: 2000 })
45
+ : spawnSync(process.env.SHELL || '/bin/bash', ['-lc', `command -v ${binary} >/dev/null 2>&1`], { timeout: 2000 })
46
46
  const ok = (probe.status ?? 1) === 0
47
47
  cliCheckCache.set(binary, { at: now, ok })
48
48
  return ok
@@ -1,5 +1,5 @@
1
1
  import { genId } from '@/lib/id'
2
- import { hmrSingleton, sleep } from '@/lib/shared-utils'
2
+ import { hmrSingleton, sleep, errorMessage } from '@/lib/shared-utils'
3
3
  import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
4
4
  import { detectDocker } from '@/lib/server/sandbox/docker-detect'
5
5
  import { log } from '@/lib/server/logger'
@@ -169,7 +169,8 @@ export function buildDockerExecArgs(params: {
169
169
 
170
170
  export function getShellCommand(command: string, processId?: string, sandbox?: SandboxOptions): { shell: string; args: string[]; containerName?: string } {
171
171
  if (!sandbox) {
172
- return { shell: '/bin/zsh', args: ['-lc', command] }
172
+ const shell = process.env.SHELL || '/bin/bash'
173
+ return { shell, args: ['-lc', command] }
173
174
  }
174
175
 
175
176
  if (sandbox.kind === 'persistent') {
@@ -247,9 +248,12 @@ export async function startManagedProcess(opts: StartProcessOptions): Promise<St
247
248
  state.records.set(id, record)
248
249
 
249
250
  const { shell, args } = getShellCommand(opts.command, id, opts.sandbox)
250
- // Strip SwarmClaw-specific env vars so agent child processes don't inherit
251
- // our port binding or internal keys (e.g. npm dev servers defaulting to :3456)
252
- const stripKeys = new Set(['PORT', 'ACCESS_KEY', 'NEXTAUTH_SECRET'])
251
+ // Strip env vars that should not leak into child processes:
252
+ // - PORT, ACCESS_KEY, NEXTAUTH_SECRET: SwarmClaw-specific bindings
253
+ // - npm_config_prefix: injected by npm when running scripts; conflicts with nvm
254
+ // in login shell subprocesses (nvm refuses to initialize when this is set,
255
+ // breaking node PATH resolution for all nvm users)
256
+ const stripKeys = new Set(['PORT', 'ACCESS_KEY', 'NEXTAUTH_SECRET', 'npm_config_prefix'])
253
257
  const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => !stripKeys.has(k))) as NodeJS.ProcessEnv
254
258
  const child = spawn(shell, args, {
255
259
  cwd: opts.sandbox ? undefined : opts.cwd,
@@ -405,8 +409,8 @@ export function writeManagedProcessStdin(processId: string, data: string, eof?:
405
409
  if (data) child.stdin.write(data)
406
410
  if (eof) child.stdin.end()
407
411
  return { ok: true }
408
- } catch (err: any) {
409
- return { ok: false, error: err.message || String(err) }
412
+ } catch (err: unknown) {
413
+ return { ok: false, error: errorMessage(err) || String(err) }
410
414
  }
411
415
  }
412
416
 
@@ -418,8 +422,8 @@ export function killManagedProcess(processId: string, signal: NodeJS.Signals = '
418
422
  rec.status = 'killed'
419
423
  child.kill(signal)
420
424
  return { ok: true }
421
- } catch (err: any) {
422
- return { ok: false, error: err.message || String(err) }
425
+ } catch (err: unknown) {
426
+ return { ok: false, error: errorMessage(err) || String(err) }
423
427
  }
424
428
  }
425
429
 
@@ -49,12 +49,27 @@ function toQueuedTurn(entry: SessionRunQueueEntry, index: number): SessionQueued
49
49
  }
50
50
  }
51
51
 
52
+ function toActiveTurn(entry: SessionRunQueueEntry): SessionQueuedTurn {
53
+ return {
54
+ ...toQueuedTurn(entry, 0),
55
+ position: 0,
56
+ }
57
+ }
58
+
59
+ function visibleActiveTurnForSession(sessionId: string): SessionQueuedTurn | null {
60
+ const running = Array.from(state.runningByExecution.values())
61
+ .find((entry) => entry.run.sessionId === sessionId && entry.run.status === 'running')
62
+ if (!running || running.run.internal === true) return null
63
+ return toActiveTurn(running)
64
+ }
65
+
52
66
  export function getSessionQueueSnapshot(sessionId: string): SessionQueueSnapshot {
53
67
  const execution = getSessionExecutionState(sessionId)
54
68
  const visibleQueued = visibleQueuedEntriesForSession(sessionId)
55
69
  return {
56
70
  sessionId,
57
71
  activeRunId: execution.runningRunId || null,
72
+ activeTurn: visibleActiveTurnForSession(sessionId),
58
73
  queueLength: visibleQueued.length,
59
74
  items: visibleQueued.map((entry, index) => toQueuedTurn(entry, index)),
60
75
  }
@@ -521,6 +521,37 @@ describe('session-run-manager', () => {
521
521
  })
522
522
  })
523
523
 
524
+ it('exposes the active user-visible turn separately from queued follow-ups', () => {
525
+ const running = enqueue({
526
+ sessionId: 'sess-active-turn',
527
+ message: 'running now',
528
+ source: 'chat',
529
+ })
530
+ const queued = enqueue({
531
+ sessionId: 'sess-active-turn',
532
+ message: 'queued next',
533
+ source: 'chat',
534
+ })
535
+
536
+ const snapshot = mgr.getSessionQueueSnapshot('sess-active-turn')
537
+ assert.equal(snapshot.activeRunId, running.runId)
538
+ assert.deepEqual(snapshot.activeTurn, {
539
+ runId: running.runId,
540
+ sessionId: 'sess-active-turn',
541
+ missionId: null,
542
+ text: 'running now',
543
+ queuedAt: snapshot.activeTurn?.queuedAt,
544
+ position: 0,
545
+ imagePath: undefined,
546
+ imageUrl: undefined,
547
+ attachedFiles: undefined,
548
+ replyToId: undefined,
549
+ source: 'chat',
550
+ })
551
+ assert.deepEqual(snapshot.items.map((item) => item.runId), [queued.runId])
552
+ assert.equal(snapshot.queueLength, 1)
553
+ })
554
+
524
555
  it('hides internal queued runs from the user-visible queue snapshot', () => {
525
556
  enqueue({ sessionId: 'sess-visible-queue', message: 'running' })
526
557
  enqueue({
@@ -538,9 +569,36 @@ describe('session-run-manager', () => {
538
569
 
539
570
  const snapshot = mgr.getSessionQueueSnapshot('sess-visible-queue')
540
571
  assert.equal(snapshot.queueLength, 1)
572
+ assert.equal(snapshot.activeTurn?.runId, snapshot.activeRunId)
541
573
  assert.deepEqual(snapshot.items.map((item) => item.runId), [visible.runId])
542
574
  })
543
575
 
576
+ it('hides internal active runs from the active-turn snapshot field', () => {
577
+ const { entry, promise } = makeManualQueuedEntry({
578
+ sessionId: 'sess-hidden-active',
579
+ runId: 'run-hidden',
580
+ message: 'heartbeat hidden',
581
+ internal: true,
582
+ source: 'heartbeat',
583
+ })
584
+ entry.run.status = 'running'
585
+ entry.run.startedAt = Date.now()
586
+ const state = getRuntimeState()
587
+ state.runningByExecution.set(entry.executionKey, entry as unknown)
588
+ state.runs.set(entry.run.id, entry.run)
589
+ state.promises.set(entry.run.id, promise)
590
+ pendingPromises.push(promise.catch(() => {}))
591
+
592
+ try {
593
+ const snapshot = mgr.getSessionQueueSnapshot('sess-hidden-active')
594
+ assert.equal(snapshot.activeRunId, 'run-hidden')
595
+ assert.equal(snapshot.activeTurn, null)
596
+ assert.equal(snapshot.queueLength, 0)
597
+ } finally {
598
+ entry.resolve(undefined)
599
+ }
600
+ })
601
+
544
602
  it('reports heartbeat vs non-heartbeat queued runs', () => {
545
603
  enqueue({ sessionId: 'sess-hb-state', message: 'occupier' })
546
604
  enqueue({
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import test from 'node:test'
3
- import { resolveSandboxRuntimeStatus, resolveSandboxWorkdir } from '@/lib/server/sandbox/session-runtime'
3
+ import { resolveSandboxRuntimeStatus, resolveSandboxSessionContext, resolveSandboxWorkdir } from '@/lib/server/sandbox/session-runtime'
4
4
  import type { Session } from '@/types'
5
5
 
6
6
  test('resolveSandboxRuntimeStatus defaults enabled sandboxes to all sessions', () => {
@@ -49,6 +49,23 @@ test('resolveSandboxRuntimeStatus sandboxes child sessions in non-main mode', ()
49
49
  assert.equal(status.scopeKey, 'agent:agent-1')
50
50
  })
51
51
 
52
+ test('resolveSandboxSessionContext resolves browser-compatible workspace context without starting containers', () => {
53
+ const context = resolveSandboxSessionContext({
54
+ config: { enabled: true, scope: 'agent', workdir: '/workspace' },
55
+ session: {
56
+ id: 'child-session',
57
+ agentId: 'agent-1',
58
+ parentSessionId: 'main-session',
59
+ } as Session,
60
+ workspaceDir: '/tmp/project',
61
+ })
62
+
63
+ assert.ok(context)
64
+ assert.equal(context?.scopeKey, 'agent:agent-1')
65
+ assert.equal(context?.workspaceDir, '/tmp/project')
66
+ assert.equal(context?.containerWorkdir, '/workspace')
67
+ })
68
+
52
69
  test('resolveSandboxWorkdir maps nested host paths into the container workspace', () => {
53
70
  const resolved = resolveSandboxWorkdir({
54
71
  workspaceDir: '/tmp/project',