@swarmclawai/swarmclaw 1.9.2 → 1.9.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 (32) hide show
  1. package/README.md +23 -3
  2. package/electron-dist/main.js +218 -0
  3. package/package.json +2 -2
  4. package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
  5. package/src/app/api/extensions/managed-resources/route.ts +116 -0
  6. package/src/app/api/gateways/[id]/environments/[environmentId]/route.ts +16 -0
  7. package/src/app/api/gateways/[id]/environments/route.ts +13 -0
  8. package/src/app/api/gateways/topology-route.test.ts +30 -0
  9. package/src/app/api/tasks/task-workspace-route.test.ts +4 -0
  10. package/src/cli/index.js +4 -0
  11. package/src/cli/spec.js +4 -0
  12. package/src/components/providers/provider-list.tsx +34 -1
  13. package/src/components/tasks/task-sheet.tsx +50 -0
  14. package/src/features/gateways/queries.ts +3 -0
  15. package/src/lib/server/extension-managed-resources.test.ts +159 -0
  16. package/src/lib/server/extension-managed-resources.ts +905 -0
  17. package/src/lib/server/extensions.ts +113 -2
  18. package/src/lib/server/gateways/gateway-profile-service.ts +2 -0
  19. package/src/lib/server/gateways/gateway-topology.test.ts +59 -3
  20. package/src/lib/server/gateways/gateway-topology.ts +129 -3
  21. package/src/lib/server/operations/operation-pulse.test.ts +29 -0
  22. package/src/lib/server/operations/operation-pulse.ts +9 -0
  23. package/src/lib/server/session-tools/extension-creator.ts +50 -0
  24. package/src/lib/server/tasks/task-execution-workspace.test.ts +14 -0
  25. package/src/lib/server/tasks/task-execution-workspace.ts +133 -6
  26. package/src/types/agent.ts +2 -0
  27. package/src/types/app-settings.ts +8 -0
  28. package/src/types/extension.ts +132 -0
  29. package/src/types/misc.ts +31 -0
  30. package/src/types/schedule.ts +3 -0
  31. package/src/types/task.ts +30 -0
  32. package/src/views/settings/extension-manager.tsx +157 -1
@@ -11,6 +11,7 @@ import type {
11
11
  ExtensionUIDefinition,
12
12
  ExtensionProviderDefinition,
13
13
  ExtensionConnectorDefinition,
14
+ ExtensionManagedResources,
14
15
  Session,
15
16
  ExtensionPackageManager,
16
17
  ExtensionDependencyInstallStatus,
@@ -467,11 +468,60 @@ function coerceTools(rawTools: unknown): ExtensionToolDef[] {
467
468
  return []
468
469
  }
469
470
 
471
+ function coerceManagedResources(raw: Record<string, unknown>): ExtensionManagedResources | undefined {
472
+ const explicit = isRecord(raw.managedResources)
473
+ ? raw.managedResources as Record<string, unknown>
474
+ : {}
475
+ const agents = Array.isArray(explicit.agents)
476
+ ? explicit.agents
477
+ : Array.isArray(raw.agents)
478
+ ? raw.agents
479
+ : undefined
480
+ const schedules = Array.isArray(explicit.schedules)
481
+ ? explicit.schedules
482
+ : Array.isArray(raw.schedules)
483
+ ? raw.schedules
484
+ : undefined
485
+ const routines = Array.isArray(explicit.routines)
486
+ ? explicit.routines
487
+ : Array.isArray(raw.routines)
488
+ ? raw.routines
489
+ : undefined
490
+ const localFolders = Array.isArray(explicit.localFolders)
491
+ ? explicit.localFolders
492
+ : Array.isArray(raw.localFolders)
493
+ ? raw.localFolders
494
+ : undefined
495
+ const gatewayPlatforms = Array.isArray(explicit.gatewayPlatforms)
496
+ ? explicit.gatewayPlatforms
497
+ : Array.isArray(raw.gatewayPlatforms)
498
+ ? raw.gatewayPlatforms
499
+ : undefined
500
+ const setupChecks = Array.isArray(explicit.setupChecks)
501
+ ? explicit.setupChecks
502
+ : Array.isArray(raw.setupChecks)
503
+ ? raw.setupChecks
504
+ : undefined
505
+
506
+ const managedResources: ExtensionManagedResources = {
507
+ agents: agents as ExtensionManagedResources['agents'],
508
+ schedules: schedules as ExtensionManagedResources['schedules'],
509
+ routines: routines as ExtensionManagedResources['routines'],
510
+ localFolders: localFolders as ExtensionManagedResources['localFolders'],
511
+ gatewayPlatforms: gatewayPlatforms as ExtensionManagedResources['gatewayPlatforms'],
512
+ setupChecks: setupChecks as ExtensionManagedResources['setupChecks'],
513
+ }
514
+
515
+ return Object.values(managedResources).some((value) => Array.isArray(value) && value.length > 0)
516
+ ? managedResources
517
+ : undefined
518
+ }
519
+
470
520
  function normalizeExtension(mod: unknown): Extension | null {
471
521
  const modObj = mod as Record<string, unknown>
472
522
  const raw: Record<string, unknown> = (modObj?.default as Record<string, unknown>) || modObj
473
523
 
474
- if (raw.name && (raw.hooks || raw.tools || raw.ui || raw.providers || raw.connectors)) {
524
+ if (raw.name && (raw.hooks || raw.tools || raw.ui || raw.providers || raw.connectors || raw.managedResources || raw.agents || raw.schedules || raw.routines || raw.localFolders || raw.gatewayPlatforms || raw.setupChecks)) {
475
525
  const hooks = isRecord(raw.hooks) ? (raw.hooks as ExtensionHooks) : {}
476
526
  return {
477
527
  name: raw.name as string,
@@ -484,6 +534,7 @@ function normalizeExtension(mod: unknown): Extension | null {
484
534
  ui: isRecord(raw.ui) ? (raw.ui as ExtensionUIDefinition) : undefined,
485
535
  providers: Array.isArray(raw.providers) ? (raw.providers as ExtensionProviderDefinition[]) : undefined,
486
536
  connectors: Array.isArray(raw.connectors) ? (raw.connectors as ExtensionConnectorDefinition[]) : undefined,
537
+ managedResources: coerceManagedResources(raw),
487
538
  } as Extension
488
539
  }
489
540
 
@@ -639,6 +690,7 @@ interface LoadedExtension {
639
690
  ui?: ExtensionUIDefinition
640
691
  providers?: ExtensionProviderDefinition[]
641
692
  connectors?: ExtensionConnectorDefinition[]
693
+ managedResources?: ExtensionManagedResources
642
694
  isBuiltin?: boolean
643
695
  }
644
696
 
@@ -1017,6 +1069,7 @@ class ExtensionManager {
1017
1069
  ui: p.ui,
1018
1070
  providers: p.providers,
1019
1071
  connectors: p.connectors,
1072
+ managedResources: p.managedResources || coerceManagedResources(p as unknown as Record<string, unknown>),
1020
1073
  isBuiltin: true
1021
1074
  })
1022
1075
  this.markExtensionSuccess(id)
@@ -1064,6 +1117,7 @@ class ExtensionManager {
1064
1117
  ui: ext.ui,
1065
1118
  providers: ext.providers,
1066
1119
  connectors: ext.connectors,
1120
+ managedResources: ext.managedResources,
1067
1121
  })
1068
1122
  this.markExtensionSuccess(file)
1069
1123
  } catch (err: unknown) {
@@ -1145,6 +1199,55 @@ class ExtensionManager {
1145
1199
  return allUI
1146
1200
  }
1147
1201
 
1202
+ getManagedResourceExtensions(): Array<{
1203
+ extensionId: string
1204
+ extensionName: string
1205
+ enabled: boolean
1206
+ isBuiltin: boolean
1207
+ source?: ExtensionMeta['source']
1208
+ managedResources: ExtensionManagedResources
1209
+ }> {
1210
+ this.load()
1211
+ const result: Array<{
1212
+ extensionId: string
1213
+ extensionName: string
1214
+ enabled: boolean
1215
+ isBuiltin: boolean
1216
+ source?: ExtensionMeta['source']
1217
+ managedResources: ExtensionManagedResources
1218
+ }> = []
1219
+
1220
+ for (const [id, entry] of this.extensions.entries()) {
1221
+ const managedResources = entry.managedResources
1222
+ if (!managedResources) continue
1223
+ if (!Object.values(managedResources).some((value) => Array.isArray(value) && value.length > 0)) continue
1224
+ result.push({
1225
+ extensionId: id,
1226
+ extensionName: entry.meta.name,
1227
+ enabled: entry.meta.enabled,
1228
+ isBuiltin: entry.isBuiltin === true,
1229
+ source: entry.meta.source,
1230
+ managedResources,
1231
+ })
1232
+ }
1233
+
1234
+ return result
1235
+ }
1236
+
1237
+ getManagedResources(extensionId: string): ExtensionManagedResources | null {
1238
+ this.load()
1239
+ const candidateIds = expandExtensionIds([extensionId])
1240
+ for (const id of candidateIds) {
1241
+ const loaded = this.extensions.get(id)
1242
+ if (loaded?.managedResources) return loaded.managedResources
1243
+ const builtin = this.builtins.get(id)
1244
+ if (builtin) {
1245
+ return builtin.managedResources || coerceManagedResources(builtin as unknown as Record<string, unknown>) || null
1246
+ }
1247
+ }
1248
+ return null
1249
+ }
1250
+
1148
1251
  listExtensionIds(): string[] {
1149
1252
  this.load()
1150
1253
  return Array.from(this.extensions.keys())
@@ -1842,11 +1945,14 @@ class ExtensionManager {
1842
1945
  const failures = this.readFailureState()
1843
1946
  const metas: ExtensionMeta[] = []
1844
1947
 
1845
- const describeCapabilities = (loaded?: LoadedExtension, fallback?: Extension): Pick<ExtensionMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields'> => {
1948
+ const describeCapabilities = (loaded?: LoadedExtension, fallback?: Extension): Pick<ExtensionMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields' | 'managedAgentCount' | 'managedScheduleCount' | 'localFolderCount' | 'gatewayPlatformCount' | 'setupCheckCount'> => {
1846
1949
  const tools = loaded?.tools || fallback?.tools || []
1847
1950
  const hooks = loaded?.hooks || fallback?.hooks || {}
1848
1951
  const providers = loaded?.providers || fallback?.providers || []
1849
1952
  const connectors = loaded?.connectors || fallback?.connectors || []
1953
+ const managedResources = loaded?.managedResources
1954
+ || fallback?.managedResources
1955
+ || (fallback ? coerceManagedResources(fallback as unknown as Record<string, unknown>) : undefined)
1850
1956
  const hasUi = !!(loaded?.ui || fallback?.ui)
1851
1957
  const settingsFields = loaded?.ui?.settingsFields || fallback?.ui?.settingsFields
1852
1958
  return {
@@ -1855,6 +1961,11 @@ class ExtensionManager {
1855
1961
  hasUI: hasUi,
1856
1962
  providerCount: Array.isArray(providers) ? providers.length : 0,
1857
1963
  connectorCount: Array.isArray(connectors) ? connectors.length : 0,
1964
+ managedAgentCount: managedResources?.agents?.length || 0,
1965
+ managedScheduleCount: (managedResources?.schedules?.length || 0) + (managedResources?.routines?.length || 0),
1966
+ localFolderCount: managedResources?.localFolders?.length || 0,
1967
+ gatewayPlatformCount: managedResources?.gatewayPlatforms?.length || 0,
1968
+ setupCheckCount: managedResources?.setupChecks?.length || 0,
1858
1969
  settingsFields: settingsFields?.length ? settingsFields : undefined,
1859
1970
  }
1860
1971
  }
@@ -70,6 +70,8 @@ function normalizeStats(value: unknown): OpenClawGatewayStats | null {
70
70
  externalRuntimeCount: normalizeNullableNumber(stats.externalRuntimeCount) ?? undefined,
71
71
  sessionCount: normalizeNullableNumber(stats.sessionCount) ?? undefined,
72
72
  presenceCount: normalizeNullableNumber(stats.presenceCount) ?? undefined,
73
+ environmentCount: normalizeNullableNumber(stats.environmentCount) ?? undefined,
74
+ availableEnvironmentCount: normalizeNullableNumber(stats.availableEnvironmentCount) ?? undefined,
73
75
  lastTopologyCheckedAt: normalizeNullableNumber(stats.lastTopologyCheckedAt) ?? undefined,
74
76
  lastTopologyErrorCount: normalizeNullableNumber(stats.lastTopologyErrorCount) ?? undefined,
75
77
  lastTopologyError: normalizeText(stats.lastTopologyError),
@@ -1,7 +1,11 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { describe, it } from 'node:test'
3
3
 
4
- import { buildOpenClawGatewayTopology, getOpenClawGatewayFleetTopology } from './gateway-topology'
4
+ import {
5
+ buildOpenClawGatewayTopology,
6
+ getOpenClawGatewayEnvironmentStatus,
7
+ getOpenClawGatewayFleetTopology,
8
+ } from './gateway-topology'
5
9
  import type { GatewayProfile, OpenClawGatewayStats } from '@/types'
6
10
 
7
11
  const now = 1_800_000_000_000
@@ -60,6 +64,25 @@ describe('OpenClaw gateway topology', () => {
60
64
  },
61
65
  'sessions.list': { sessions: [{ sessionId: 'session_1', title: 'Release room' }] },
62
66
  'system-presence': { presence: [{ deviceId: 'phone_1', mode: 'active' }] },
67
+ 'environments.list': {
68
+ environments: [
69
+ {
70
+ id: 'gateway',
71
+ type: 'local',
72
+ label: 'Gateway local',
73
+ status: 'available',
74
+ capabilities: ['agent.run', 'sessions', 'tools', 'workspace'],
75
+ },
76
+ {
77
+ id: 'node:node_1',
78
+ type: 'node',
79
+ label: 'Mac Studio',
80
+ status: 'available',
81
+ capabilities: ['shell.exec'],
82
+ },
83
+ { id: 'node:node_2', type: 'node', label: 'Builder', status: 'unavailable' },
84
+ ],
85
+ },
63
86
  }) as never,
64
87
  persistStats: (id, input) => {
65
88
  assert.equal(id, 'gateway_1')
@@ -77,6 +100,10 @@ describe('OpenClaw gateway topology', () => {
77
100
  assert.equal(topology.stats.pairedDeviceCount, 1)
78
101
  assert.equal(topology.stats.sessionCount, 1)
79
102
  assert.equal(topology.stats.presenceCount, 1)
103
+ assert.equal(topology.stats.environmentCount, 3)
104
+ assert.equal(topology.stats.availableEnvironmentCount, 2)
105
+ assert.equal(topology.environments[0]?.id, 'gateway')
106
+ assert.equal(topology.environments[1]?.capabilities?.[0], 'shell.exec')
80
107
  assert.equal(topology.stats.pendingPairingCount, 2)
81
108
  assert.equal(topology.stats.hasErrors, false)
82
109
  assert.equal(topology.stats.lastTopologyCheckedAt, now)
@@ -92,15 +119,17 @@ describe('OpenClaw gateway topology', () => {
92
119
  'device.pair.list': { pending: [], paired: [] },
93
120
  'sessions.list': new Error('sessions unavailable'),
94
121
  'system-presence': new Error('presence unavailable'),
122
+ 'environments.list': new Error('environments unavailable'),
95
123
  }) as never,
96
124
  persistStats: (id, input) => ({ ...profile({ id }), stats: input.stats as OpenClawGatewayStats }),
97
125
  })
98
126
 
99
127
  assert.equal(topology.nodes.length, 1)
100
128
  assert.equal(topology.sessions.length, 0)
101
- assert.deepEqual(topology.errors.map((error) => error.method), ['sessions.list', 'system-presence'])
129
+ assert.equal(topology.environments.length, 2)
130
+ assert.deepEqual(topology.errors.map((error) => error.method), ['sessions.list', 'system-presence', 'environments.list'])
102
131
  assert.equal(topology.stats.hasErrors, true)
103
- assert.equal(topology.stats.lastTopologyErrorCount, 2)
132
+ assert.equal(topology.stats.lastTopologyErrorCount, 3)
104
133
  assert.equal(topology.stats.lastTopologyError, 'sessions unavailable')
105
134
  })
106
135
 
@@ -113,6 +142,7 @@ describe('OpenClaw gateway topology', () => {
113
142
  assert.equal(topology.connected, false)
114
143
  assert.equal(topology.stats.hasErrors, true)
115
144
  assert.equal(topology.stats.nodeCount, 0)
145
+ assert.equal(topology.stats.environmentCount, 0)
116
146
  assert.equal(topology.errors[0]?.method, 'gateway.connect')
117
147
  })
118
148
 
@@ -132,6 +162,11 @@ describe('OpenClaw gateway topology', () => {
132
162
  'device.pair.list': { pending: [], paired: [] },
133
163
  'sessions.list': { sessions: target?.profileId === 'gateway_a' ? [{ id: 'session_a' }] : [] },
134
164
  'system-presence': { presence: [] },
165
+ 'environments.list': {
166
+ environments: target?.profileId === 'gateway_a'
167
+ ? [{ id: 'gateway', type: 'local', status: 'available' }]
168
+ : [{ id: 'gateway', type: 'local', status: 'available' }, { id: 'node:node_b', type: 'node', status: 'unavailable' }],
169
+ },
135
170
  }) as never,
136
171
  persistStats: (id, input) => ({
137
172
  ...(id === 'gateway_a' ? first : second),
@@ -144,8 +179,29 @@ describe('OpenClaw gateway topology', () => {
144
179
  assert.equal(fleet.totals.connectedGatewayCount, 2)
145
180
  assert.equal(fleet.totals.nodeCount, 2)
146
181
  assert.equal(fleet.totals.connectedNodeCount, 1)
182
+ assert.equal(fleet.totals.environmentCount, 3)
183
+ assert.equal(fleet.totals.availableEnvironmentCount, 2)
147
184
  assert.equal(fleet.totals.pendingNodePairings, 1)
148
185
  assert.equal(fleet.totals.sessionCount, 1)
149
186
  assert.equal(fleet.gateways.length, 2)
150
187
  })
188
+
189
+ it('returns direct environment status through the gateway protocol', async () => {
190
+ const calls: Array<{ method: string; params?: Record<string, unknown> }> = []
191
+ const status = await getOpenClawGatewayEnvironmentStatus('gateway_1', 'node:node_1', {
192
+ now: () => now,
193
+ getGatewayProfile: () => profile(),
194
+ ensureGatewayConnected: async () => ({
195
+ connected: true,
196
+ rpc: async (method: string, params?: Record<string, unknown>) => {
197
+ calls.push({ method, params })
198
+ return { id: 'node:node_1', type: 'node', label: 'Mac Studio', status: 'available', capabilities: ['shell.exec'] }
199
+ },
200
+ }) as never,
201
+ })
202
+
203
+ assert.equal(status?.environment?.id, 'node:node_1')
204
+ assert.equal(status?.environment?.status, 'available')
205
+ assert.deepEqual(calls, [{ method: 'environments.status', params: { environmentId: 'node:node_1' } }])
206
+ })
151
207
  })
@@ -6,8 +6,11 @@ import {
6
6
  } from './gateway-profile-service'
7
7
  import { ensureGatewayConnected } from '@/lib/server/openclaw/gateway'
8
8
  import type {
9
+ OpenClawEnvironmentSummary,
9
10
  GatewayProfile,
10
11
  OpenClawDevicePairRequest,
12
+ OpenClawGatewayEnvironmentList,
13
+ OpenClawGatewayEnvironmentStatusSnapshot,
11
14
  OpenClawGatewayFleetTopology,
12
15
  OpenClawGatewayPresenceEntry,
13
16
  OpenClawGatewayRpcError,
@@ -26,6 +29,7 @@ type GatewayRpcClient = {
26
29
 
27
30
  interface GatewayTopologyDeps {
28
31
  ensureGatewayConnected?: typeof ensureGatewayConnected
32
+ getGatewayProfile?: typeof getGatewayProfileById
29
33
  listGatewayProfiles?: typeof listOpenClawGatewayProfiles
30
34
  now?: () => number
31
35
  persistStats?: typeof updateGatewayProfile
@@ -176,14 +180,72 @@ function normalizePresence(value: unknown): OpenClawGatewayPresenceEntry | null
176
180
  }
177
181
  }
178
182
 
183
+ function uniqueStrings(...items: Array<readonly string[] | undefined>): string[] {
184
+ const values = new Set<string>()
185
+ for (const item of items) {
186
+ for (const value of item || []) {
187
+ const trimmed = asString(value)
188
+ if (trimmed) values.add(trimmed)
189
+ }
190
+ }
191
+ return [...values].sort((left, right) => left.localeCompare(right))
192
+ }
193
+
194
+ function normalizeEnvironmentStatus(value: unknown): OpenClawEnvironmentSummary['status'] {
195
+ return value === 'available'
196
+ || value === 'starting'
197
+ || value === 'stopping'
198
+ || value === 'error'
199
+ ? value
200
+ : 'unavailable'
201
+ }
202
+
203
+ function normalizeEnvironment(value: unknown): OpenClawEnvironmentSummary | null {
204
+ const record = asObject(value)
205
+ const id = asString(record?.id) || asString(record?.environmentId)
206
+ if (!record || !id) return null
207
+ const capabilities = Array.isArray(record.capabilities)
208
+ ? uniqueStrings(record.capabilities.map(asString).filter(Boolean) as string[])
209
+ : undefined
210
+ return {
211
+ id,
212
+ type: asString(record.type) || 'local',
213
+ label: asString(record.label) || asString(record.name),
214
+ status: normalizeEnvironmentStatus(record.status),
215
+ capabilities: capabilities && capabilities.length > 0 ? capabilities : undefined,
216
+ }
217
+ }
218
+
219
+ function deriveEnvironments(profile: GatewayProfile, nodes: OpenClawNode[], connected: boolean): OpenClawEnvironmentSummary[] {
220
+ const gatewayEnvironment: OpenClawEnvironmentSummary = {
221
+ id: 'gateway',
222
+ type: 'local',
223
+ label: profile.name || 'Gateway local',
224
+ status: connected ? 'available' : 'unavailable',
225
+ capabilities: ['agent.run', 'sessions', 'tools', 'workspace'],
226
+ }
227
+ const nodeEnvironments = nodes.map((node) => {
228
+ const capabilities = uniqueStrings(node.caps, node.commands)
229
+ return {
230
+ id: `node:${node.nodeId}`,
231
+ type: 'node',
232
+ label: node.displayName || node.nodeId,
233
+ status: node.connected === true ? 'available' : 'unavailable',
234
+ capabilities: capabilities.length > 0 ? capabilities : undefined,
235
+ } satisfies OpenClawEnvironmentSummary
236
+ })
237
+ return [gatewayEnvironment, ...nodeEnvironments]
238
+ }
239
+
179
240
  async function safeRpc<T>(
180
241
  gateway: GatewayRpcClient,
181
242
  method: string,
182
243
  errors: OpenClawGatewayRpcError[],
183
244
  normalize: (value: unknown) => T,
245
+ params: Record<string, unknown> = {},
184
246
  ): Promise<T> {
185
247
  try {
186
- return normalize(await gateway.rpc(method, {}))
248
+ return normalize(await gateway.rpc(method, params))
187
249
  } catch (err: unknown) {
188
250
  errors.push({ method, message: errorMessage(err) })
189
251
  return normalize(null)
@@ -197,6 +259,7 @@ function topologyStats(params: {
197
259
  pairedDevices: OpenClawPairedDevice[]
198
260
  sessions: OpenClawGatewaySession[]
199
261
  presence: OpenClawGatewayPresenceEntry[]
262
+ environments: OpenClawEnvironmentSummary[]
200
263
  errors: OpenClawGatewayRpcError[]
201
264
  refreshedAt: number
202
265
  }): OpenClawGatewayTopologyStats {
@@ -208,6 +271,8 @@ function topologyStats(params: {
208
271
  pendingDevicePairings: params.devicePairings.length,
209
272
  sessionCount: params.sessions.length,
210
273
  presenceCount: params.presence.length,
274
+ environmentCount: params.environments.length,
275
+ availableEnvironmentCount: params.environments.filter((environment) => environment.status === 'available').length,
211
276
  pendingPairingCount: params.nodePairings.length + params.devicePairings.length,
212
277
  hasErrors: params.errors.length > 0,
213
278
  lastTopologyCheckedAt: params.refreshedAt,
@@ -235,6 +300,7 @@ export async function buildOpenClawGatewayTopology(
235
300
  pairedDevices: [],
236
301
  sessions: [],
237
302
  presence: [],
303
+ environments: [],
238
304
  errors,
239
305
  refreshedAt,
240
306
  })
@@ -249,11 +315,12 @@ export async function buildOpenClawGatewayTopology(
249
315
  pairedDevices: [],
250
316
  sessions: [],
251
317
  presence: [],
318
+ environments: [],
252
319
  errors,
253
320
  }
254
321
  }
255
322
 
256
- const [nodes, nodePairingsRaw, devicePairingsRaw, sessions, presence] = await Promise.all([
323
+ const [nodes, nodePairingsRaw, devicePairingsRaw, sessions, presence, rpcEnvironments] = await Promise.all([
257
324
  safeRpc(gateway, 'node.list', errors, (value) =>
258
325
  extractArray(value, ['nodes']).map(normalizeNode).filter(Boolean) as OpenClawNode[],
259
326
  ),
@@ -265,6 +332,9 @@ export async function buildOpenClawGatewayTopology(
265
332
  safeRpc(gateway, 'system-presence', errors, (value) =>
266
333
  extractArray(value, ['presence']).map(normalizePresence).filter(Boolean) as OpenClawGatewayPresenceEntry[],
267
334
  ),
335
+ safeRpc(gateway, 'environments.list', errors, (value) =>
336
+ extractArray(value, ['environments']).map(normalizeEnvironment).filter(Boolean) as OpenClawEnvironmentSummary[],
337
+ ),
268
338
  ])
269
339
 
270
340
  const devicePairingsRecord = asObject(devicePairingsRaw) || {}
@@ -277,6 +347,9 @@ export async function buildOpenClawGatewayTopology(
277
347
  const pairedDevices = extractArray(devicePairingsRecord.paired)
278
348
  .map(normalizePairedDevice)
279
349
  .filter(Boolean) as OpenClawPairedDevice[]
350
+ const environments = rpcEnvironments.length > 0
351
+ ? rpcEnvironments
352
+ : deriveEnvironments(profile, nodes, gateway.connected)
280
353
  const stats = topologyStats({
281
354
  nodes,
282
355
  nodePairings,
@@ -284,6 +357,7 @@ export async function buildOpenClawGatewayTopology(
284
357
  pairedDevices,
285
358
  sessions,
286
359
  presence,
360
+ environments,
287
361
  errors,
288
362
  refreshedAt,
289
363
  })
@@ -301,6 +375,54 @@ export async function buildOpenClawGatewayTopology(
301
375
  pairedDevices,
302
376
  sessions,
303
377
  presence,
378
+ environments,
379
+ errors,
380
+ }
381
+ }
382
+
383
+ export async function listOpenClawGatewayEnvironments(
384
+ id: string,
385
+ deps: GatewayTopologyDeps = {},
386
+ ): Promise<OpenClawGatewayEnvironmentList | null> {
387
+ const topology = await getOpenClawGatewayTopology(id, deps)
388
+ if (!topology) return null
389
+ return {
390
+ profile: topology.profile,
391
+ connected: topology.connected,
392
+ refreshedAt: topology.refreshedAt,
393
+ environments: topology.environments,
394
+ errors: topology.errors,
395
+ }
396
+ }
397
+
398
+ export async function getOpenClawGatewayEnvironmentStatus(
399
+ id: string,
400
+ environmentId: string,
401
+ deps: GatewayTopologyDeps = {},
402
+ ): Promise<OpenClawGatewayEnvironmentStatusSnapshot | null> {
403
+ const profile = (deps.getGatewayProfile ?? getGatewayProfileById)(id)
404
+ if (!profile) return null
405
+ const now = deps.now ?? (() => Date.now())
406
+ const refreshedAt = now()
407
+ const ensureConnected = deps.ensureGatewayConnected ?? ensureGatewayConnected
408
+ const errors: OpenClawGatewayRpcError[] = []
409
+ const gateway = await ensureConnected({ profileId: profile.id }) as GatewayRpcClient | null
410
+ if (!gateway) {
411
+ errors.push({ method: 'gateway.connect', message: 'OpenClaw gateway not connected' })
412
+ return { profile, connected: false, refreshedAt, environment: null, errors }
413
+ }
414
+ const environment = await safeRpc(
415
+ gateway,
416
+ 'environments.status',
417
+ errors,
418
+ (value) => normalizeEnvironment(value),
419
+ { environmentId },
420
+ )
421
+ return {
422
+ profile,
423
+ connected: gateway.connected,
424
+ refreshedAt,
425
+ environment,
304
426
  errors,
305
427
  }
306
428
  }
@@ -311,6 +433,8 @@ function emptyTotals(generatedAt: number): OpenClawGatewayFleetTopology['totals'
311
433
  connectedGatewayCount: 0,
312
434
  nodeCount: 0,
313
435
  connectedNodeCount: 0,
436
+ environmentCount: 0,
437
+ availableEnvironmentCount: 0,
314
438
  pendingNodePairings: 0,
315
439
  pairedDeviceCount: 0,
316
440
  pendingDevicePairings: 0,
@@ -328,7 +452,7 @@ export async function getOpenClawGatewayTopology(
328
452
  id: string,
329
453
  deps: GatewayTopologyDeps = {},
330
454
  ): Promise<OpenClawGatewayTopology | null> {
331
- const profile = getGatewayProfileById(id)
455
+ const profile = (deps.getGatewayProfile ?? getGatewayProfileById)(id)
332
456
  if (!profile) return null
333
457
  return buildOpenClawGatewayTopology(profile, deps)
334
458
  }
@@ -355,6 +479,8 @@ export async function getOpenClawGatewayFleetTopology(
355
479
  acc.pendingDevicePairings = (acc.pendingDevicePairings || 0) + (topology.stats.pendingDevicePairings || 0)
356
480
  acc.sessionCount = (acc.sessionCount || 0) + (topology.stats.sessionCount || 0)
357
481
  acc.presenceCount = (acc.presenceCount || 0) + (topology.stats.presenceCount || 0)
482
+ acc.environmentCount = (acc.environmentCount || 0) + (topology.stats.environmentCount || 0)
483
+ acc.availableEnvironmentCount = (acc.availableEnvironmentCount || 0) + (topology.stats.availableEnvironmentCount || 0)
358
484
  acc.pendingPairingCount += topology.stats.pendingPairingCount
359
485
  acc.hasErrors = acc.hasErrors || topology.stats.hasErrors
360
486
  acc.lastTopologyErrorCount = (acc.lastTopologyErrorCount || 0) + (topology.stats.lastTopologyErrorCount || 0)
@@ -161,4 +161,33 @@ describe('operation pulse', () => {
161
161
  assert.ok((pulse.actions[0]?.summary || '').includes('pending OpenClaw pairing'))
162
162
  assert.equal(pulse.actions[0]?.href, '/providers')
163
163
  })
164
+
165
+ it('raises gateway attention when no execution environments are available', () => {
166
+ const pulse = buildOperationPulse({
167
+ range: '24h',
168
+ now,
169
+ missions: [],
170
+ runs: [],
171
+ approvals: [],
172
+ connectors: [],
173
+ gateways: [
174
+ gateway({
175
+ status: 'healthy',
176
+ stats: {
177
+ nodeCount: 1,
178
+ connectedNodeCount: 1,
179
+ environmentCount: 2,
180
+ availableEnvironmentCount: 0,
181
+ lastTopologyCheckedAt: now - 1000,
182
+ },
183
+ }),
184
+ ],
185
+ })
186
+
187
+ assert.equal(pulse.kpis.gatewayAttention, 1)
188
+ assert.equal(pulse.actions[0]?.kind, 'gateway')
189
+ assert.equal(pulse.actions[0]?.severity, 'high')
190
+ assert.ok((pulse.actions[0]?.summary || '').includes('no available OpenClaw execution environments'))
191
+ assert.equal(pulse.actions[0]?.evidence.includes('0/2 environments'), true)
192
+ })
164
193
  })
@@ -87,6 +87,7 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
87
87
  const evidence = [
88
88
  `status:${gateway.status}`,
89
89
  `${gateway.stats?.connectedNodeCount || 0}/${gateway.stats?.nodeCount || 0} nodes`,
90
+ `${gateway.stats?.availableEnvironmentCount || 0}/${gateway.stats?.environmentCount || 0} environments`,
90
91
  ]
91
92
 
92
93
  if (gateway.status === 'offline') {
@@ -113,6 +114,14 @@ function gatewayAttentionReason(gateway: GatewayProfile, now: number): {
113
114
  }
114
115
  }
115
116
 
117
+ if ((gateway.stats?.environmentCount || 0) > 0 && (gateway.stats?.availableEnvironmentCount || 0) === 0) {
118
+ return {
119
+ severity: 'high',
120
+ summary: `${gateway.name} has no available OpenClaw execution environments.`,
121
+ evidence,
122
+ }
123
+ }
124
+
116
125
  if (pendingPairings > 0) {
117
126
  return {
118
127
  severity: 'medium',
@@ -138,6 +138,54 @@ module.exports = {
138
138
  }
139
139
  ],
140
140
 
141
+ // --- Managed Resources (Paperclip-compatible) ---
142
+ managedResources: {
143
+ agents: [
144
+ {
145
+ agentKey: 'researcher',
146
+ displayName: 'Managed Researcher',
147
+ description: 'Reusable agent this extension can provision.',
148
+ systemPrompt: 'Research carefully and cite durable evidence.',
149
+ provider: 'openai',
150
+ model: 'gpt-4o-mini',
151
+ capabilities: ['research', 'analysis'],
152
+ extensions: ['web', 'memory']
153
+ }
154
+ ],
155
+ schedules: [
156
+ {
157
+ scheduleKey: 'daily-digest',
158
+ displayName: 'Daily Digest',
159
+ agentRef: { resourceKind: 'agent', resourceKey: 'researcher' },
160
+ taskPrompt: 'Prepare the daily digest.',
161
+ scheduleType: 'cron',
162
+ cron: '0 9 * * *',
163
+ timezone: 'UTC',
164
+ status: 'paused'
165
+ }
166
+ ],
167
+ localFolders: [
168
+ {
169
+ folderKey: 'workspace',
170
+ displayName: 'Workspace Folder',
171
+ access: 'readWrite',
172
+ requiredDirectories: ['inputs', 'outputs']
173
+ }
174
+ ],
175
+ gatewayPlatforms: [
176
+ {
177
+ platformKey: 'openai-compatible-api',
178
+ displayName: 'OpenAI-compatible API',
179
+ transport: 'http',
180
+ endpoint: 'http://127.0.0.1:8642/v1',
181
+ authMode: 'bearer'
182
+ }
183
+ ],
184
+ setupChecks: [
185
+ { checkKey: 'api-key', displayName: 'API key configured', kind: 'env', target: 'OPENAI_API_KEY', required: true }
186
+ ]
187
+ },
188
+
141
189
  // --- Real OpenClaw Format (register API) ---
142
190
  register(api) {
143
191
  api.registerHook('agent:start', (ctx) => {
@@ -162,6 +210,8 @@ Key rules:
162
210
  - If your extension needs npm/pnpm/yarn/bun packages, include a packageJson object during scaffold or call install_dependencies later.
163
211
  - Dependency installs are run by the extension manager inside a per-extension workspace using the selected package manager with scripts disabled.
164
212
  - Extension settings are declared through ui.settingsFields and stored per extension ID
213
+ - Managed resources let an extension declare provisionable agents, schedules/routines, trusted local folders, gateway platforms, and setup checks. Operators reconcile them through Extensions > Managed Resources or /api/extensions/managed-resources.
214
+ - Paperclip-compatible top-level agents, routines, and localFolders are also accepted; SwarmClaw reconciles routines as schedules when they include schedule timing.
165
215
  - Keep extensions focused: one clear purpose per extension
166
216
  `
167
217
  }