@swarmclawai/swarmclaw 1.9.3 → 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.
package/README.md CHANGED
@@ -399,19 +399,29 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.9.4 Highlights
403
+
404
+ Bundled runtime-environment release: gateway execution visibility, task context handoff, and operator triage in one release cycle.
405
+
406
+ - **OpenClaw environments.** Gateway topology now calls `environments.list`, stores available environment counts, exposes `/api/gateways/:id/environments`, and adds CLI commands for list/status checks.
407
+ - **Provider dashboard visibility.** The Providers screen now shows fleet-wide and per-gateway execution environment availability alongside nodes, sessions, presence, and pairings.
408
+ - **Task context packets.** Prepared task workspaces now write `context.json` with task, preview, runtime, blocker, tag, and upstream-result context for external workers.
409
+ - **Runtime env handoff.** Workspaces now include `.env.swarmclaw` plus SwarmClaw, portable task/workspace, and `AGENT_HOME` env hints without embedding secrets.
410
+ - **Operations Pulse triage.** Gateway actions now surface zero-available-environment states as high-priority operator work.
411
+
402
412
  ### v1.9.3 Highlights
403
413
 
404
- Bundled extension-orchestration release: Paperclip-style managed plugin resources, Hermes-style gateway/setup declarations, and safer local folder access in one release cycle.
414
+ Bundled extension-orchestration release: managed plugin resources, gateway/setup declarations, and safer local folder access in one release cycle.
405
415
 
406
- - **Managed extension resources.** Extensions can now declare provisionable agents, schedules/routines, local folders, gateway platforms, and setup checks through `managedResources` or Paperclip-compatible top-level aliases.
416
+ - **Managed extension resources.** Extensions can now declare provisionable agents, schedules/routines, local folders, gateway platforms, and setup checks through `managedResources` or top-level manifest aliases.
407
417
  - **Deterministic reconciliation.** `/api/extensions/managed-resources` can preview and reconcile extension-owned agents and routines with stable IDs and `managedByExtension` markers.
408
418
  - **Trusted local folders.** Extension-declared local folders support root-bounded inspection and recursive listing with traversal and symlink-escape protection.
409
419
  - **Operator UI.** The Extensions screen now shows managed-resource badges and a Managed tab with totals plus per-extension reconcile controls.
410
- - **Extension authoring spec.** `extension_creator` now documents managed resources, gateway declarations, setup checks, and Paperclip-compatible manifest aliases.
420
+ - **Extension authoring spec.** `extension_creator` now documents managed resources, gateway declarations, setup checks, and manifest aliases.
411
421
 
412
422
  ### v1.9.2 Highlights
413
423
 
414
- Bundled competitor-parity release: Hermes-style reasoning hygiene, deterministic delegation routing, Mission Control task workflow polish, OpenClaw export hardening, and Paperclip-style timeout hygiene.
424
+ Bundled runtime-polish release: reasoning hygiene, deterministic delegation routing, task workflow polish, OpenClaw export hardening, and timeout hygiene.
415
425
 
416
426
  - **Stateful reasoning tag scrubber.** String-streamed `<think>`, `<thinking>`, `<reasoning>`, `<thought>`, and `<REASONING_SCRATCHPAD>` blocks are removed across split deltas and routed into SwarmClaw's thinking stream instead of leaking into visible answers.
417
427
  - **Deterministic delegation profiles.** `manage_tasks` now accepts explicit `workType` and `requiredCapabilities` routing hints, returns a stable `routeKey`, and can auto-assign unowned work without a classifier call when the profile is explicit.
@@ -421,7 +431,7 @@ Bundled competitor-parity release: Hermes-style reasoning hygiene, deterministic
421
431
 
422
432
  ### v1.9.1 Highlights
423
433
 
424
- Task execution workspace release: the first Paperclip-style work-control slice for task-scoped workspaces, preview handoffs, and liveness evidence.
434
+ Task execution workspace release: task-scoped workspaces, preview handoffs, and liveness evidence.
425
435
 
426
436
  - **Task-scoped execution workspaces.** Tasks can now provision a deterministic workspace under the SwarmClaw workspace root, preserving source cwd and project context while creating a task-local README for artifacts and handoffs.
427
437
  - **Preview and runtime metadata.** Tasks can carry preview links and runtime services, and the task board surfaces those links directly on task cards and sheets.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.9.3",
3
+ "version": "1.9.4",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -0,0 +1,16 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ import { notFound } from '@/lib/server/collection-helpers'
4
+ import { getOpenClawGatewayEnvironmentStatus } from '@/lib/server/gateways/gateway-topology'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export async function GET(
9
+ _req: Request,
10
+ { params }: { params: Promise<{ id: string; environmentId: string }> },
11
+ ) {
12
+ const { id, environmentId } = await params
13
+ const snapshot = await getOpenClawGatewayEnvironmentStatus(id, decodeURIComponent(environmentId))
14
+ if (!snapshot) return notFound()
15
+ return NextResponse.json(snapshot, { status: snapshot.errors.length > 0 ? 502 : 200 })
16
+ }
@@ -0,0 +1,13 @@
1
+ import { NextResponse } from 'next/server'
2
+
3
+ import { notFound } from '@/lib/server/collection-helpers'
4
+ import { listOpenClawGatewayEnvironments } from '@/lib/server/gateways/gateway-topology'
5
+
6
+ export const dynamic = 'force-dynamic'
7
+
8
+ export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
9
+ const { id } = await params
10
+ const snapshot = await listOpenClawGatewayEnvironments(id)
11
+ if (!snapshot) return notFound()
12
+ return NextResponse.json(snapshot)
13
+ }
@@ -18,6 +18,36 @@ test('gateway topology route returns 404 for unknown profiles', () => {
18
18
  assert.equal(output.body.error, 'Not found')
19
19
  })
20
20
 
21
+ test('gateway environments route returns 404 for unknown profiles', () => {
22
+ const output = runWithTempDataDir<{ status: number; body: { error: string } }>(`
23
+ const routeMod = await import('./src/app/api/gateways/[id]/environments/route')
24
+ const route = routeMod.default || routeMod
25
+ const response = await route.GET(
26
+ new Request('http://local/api/gateways/missing/environments'),
27
+ { params: Promise.resolve({ id: 'missing' }) },
28
+ )
29
+ console.log(JSON.stringify({ status: response.status, body: await response.json() }))
30
+ `, { prefix: 'swarmclaw-gateway-environments-route-test-' })
31
+
32
+ assert.equal(output.status, 404)
33
+ assert.equal(output.body.error, 'Not found')
34
+ })
35
+
36
+ test('gateway environment status route returns 404 for unknown profiles', () => {
37
+ const output = runWithTempDataDir<{ status: number; body: { error: string } }>(`
38
+ const routeMod = await import('./src/app/api/gateways/[id]/environments/[environmentId]/route')
39
+ const route = routeMod.default || routeMod
40
+ const response = await route.GET(
41
+ new Request('http://local/api/gateways/missing/environments/gateway'),
42
+ { params: Promise.resolve({ id: 'missing', environmentId: 'gateway' }) },
43
+ )
44
+ console.log(JSON.stringify({ status: response.status, body: await response.json() }))
45
+ `, { prefix: 'swarmclaw-gateway-environment-status-route-test-' })
46
+
47
+ assert.equal(output.status, 404)
48
+ assert.equal(output.body.error, 'Not found')
49
+ })
50
+
21
51
  test('gateway fleet route reports empty totals when no OpenClaw profiles exist', () => {
22
52
  const output = runWithTempDataDir<{
23
53
  status: number
@@ -81,9 +81,13 @@ test('PUT /api/tasks/:id provisions an execution workspace and preview links', a
81
81
  assert.equal(response.status, 200)
82
82
  const body = await response.json() as BoardTask
83
83
  assert.equal(body.executionWorkspace?.sourceCwd, '/source/repo')
84
+ assert.equal(body.executionWorkspace?.context?.taskId, 'task-route-workspace')
85
+ assert.equal(body.executionWorkspace?.envHints?.some((hint) => hint.key === 'WORKSPACE_CWD'), true)
84
86
  assert.equal(body.previewLinks?.[0]?.url, 'http://127.0.0.1:3456')
85
87
  assert.equal(body.runtimeServices?.[0]?.name, 'Next dev')
86
88
  assert.equal(fs.existsSync(body.executionWorkspace?.path || ''), true)
89
+ assert.equal(fs.existsSync(body.executionWorkspace?.contextPath || ''), true)
90
+ assert.equal(fs.existsSync(body.executionWorkspace?.envPath || ''), true)
87
91
  })
88
92
 
89
93
  test('GET /api/tasks returns computed blocked liveness without persisting a task patch', async () => {
package/src/cli/index.js CHANGED
@@ -273,6 +273,8 @@ const COMMAND_GROUPS = [
273
273
  cmd('delete', 'DELETE', '/gateways/:id', 'Delete a gateway profile'),
274
274
  cmd('health', 'GET', '/gateways/:id/health', 'Run a gateway health check'),
275
275
  cmd('topology', 'GET', '/gateways/:id/topology', 'Refresh and return one gateway topology snapshot'),
276
+ cmd('environments', 'GET', '/gateways/:id/environments', 'List OpenClaw gateway execution environments'),
277
+ cmd('environment-status', 'GET', '/gateways/:id/environments/:environmentId', 'Get one OpenClaw gateway execution environment status'),
276
278
  cmd('fleet', 'GET', '/gateways/fleet', 'Refresh and return fleet-wide gateway topology'),
277
279
  ],
278
280
  },
package/src/cli/spec.js CHANGED
@@ -221,6 +221,8 @@ const COMMAND_GROUPS = {
221
221
  delete: { description: 'Delete a gateway profile', method: 'DELETE', path: '/gateways/:id', params: ['id'] },
222
222
  health: { description: 'Run a gateway health check', method: 'GET', path: '/gateways/:id/health', params: ['id'] },
223
223
  topology: { description: 'Refresh and return one gateway topology snapshot', method: 'GET', path: '/gateways/:id/topology', params: ['id'] },
224
+ environments: { description: 'List OpenClaw gateway execution environments', method: 'GET', path: '/gateways/:id/environments', params: ['id'] },
225
+ 'environment-status': { description: 'Get one OpenClaw gateway execution environment status', method: 'GET', path: '/gateways/:id/environments/:environmentId', params: ['id', 'environmentId'] },
224
226
  fleet: { description: 'Refresh and return fleet-wide gateway topology', method: 'GET', path: '/gateways/fleet' },
225
227
  },
226
228
  },
@@ -475,10 +475,14 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
475
475
  <div className="text-[12px] font-800 text-text">Gateway fleet topology</div>
476
476
  <div className="mt-1 text-[11px] text-text-3/70">
477
477
  {gatewayFleetTopology.totals.connectedGatewayCount}/{gatewayFleetTopology.totals.gatewayCount} gateways connected ·{' '}
478
- {gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected
478
+ {gatewayFleetTopology.totals.connectedNodeCount}/{gatewayFleetTopology.totals.nodeCount} nodes connected ·{' '}
479
+ {gatewayFleetTopology.totals.availableEnvironmentCount || 0}/{gatewayFleetTopology.totals.environmentCount || 0} environments available
479
480
  </div>
480
481
  </div>
481
482
  <div className="flex flex-wrap gap-2 text-[11px] text-text-3/70">
483
+ <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
484
+ {gatewayFleetTopology.totals.environmentCount || 0} environments
485
+ </span>
482
486
  <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2.5 py-1">
483
487
  {gatewayFleetTopology.totals.sessionCount || 0} sessions
484
488
  </span>
@@ -536,6 +540,7 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
536
540
  const topologyErrors = topology?.errors || []
537
541
  const pendingPairings = (stats?.pendingNodePairings || 0) + (stats?.pendingDevicePairings || 0)
538
542
  const topologyErrorCount = topologyErrors.length || stats?.lastTopologyErrorCount || 0
543
+ const environments = topology?.environments || []
539
544
  return (
540
545
  <div
541
546
  key={gateway.id}
@@ -602,6 +607,12 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
602
607
  {stats?.connectedNodeCount ?? 0}/{stats?.nodeCount ?? 0} nodes · {stats?.pairedDeviceCount ?? 0} devices
603
608
  </div>
604
609
  </div>
610
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
611
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Environments</div>
612
+ <div className="mt-1 text-text-2">
613
+ {stats?.availableEnvironmentCount ?? 0}/{stats?.environmentCount ?? 0} available
614
+ </div>
615
+ </div>
605
616
  <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
606
617
  <div className="uppercase tracking-[0.08em] text-text-3/50">Sessions</div>
607
618
  <div className="mt-1 text-text-2">
@@ -616,6 +627,28 @@ export function ProviderList({ inSidebar }: { inSidebar?: boolean }) {
616
627
  </div>
617
628
  </div>
618
629
  )}
630
+ {!inSidebar && environments.length > 0 && (
631
+ <div className="mt-3 flex flex-wrap gap-1.5">
632
+ {environments.slice(0, 4).map((environment) => (
633
+ <span
634
+ key={`${gateway.id}-${environment.id}`}
635
+ className={`rounded-full border px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] ${
636
+ environment.status === 'available'
637
+ ? 'border-emerald-400/20 bg-emerald-400/[0.06] text-emerald-300'
638
+ : 'border-white/[0.06] bg-white/[0.03] text-text-3/70'
639
+ }`}
640
+ title={environment.capabilities?.join(', ') || environment.id}
641
+ >
642
+ {environment.label || environment.id}
643
+ </span>
644
+ ))}
645
+ {environments.length > 4 && (
646
+ <span className="rounded-full border border-white/[0.06] bg-white/[0.03] px-2 py-0.5 text-[10px] font-700 uppercase tracking-[0.08em] text-text-3/70">
647
+ +{environments.length - 4}
648
+ </span>
649
+ )}
650
+ </div>
651
+ )}
619
652
  {!inSidebar && (pendingPairings > 0 || topologyErrorCount > 0) && (
620
653
  <div className={`mt-3 rounded-[10px] border px-3 py-2 text-[11px] leading-relaxed ${
621
654
  topologyErrorCount > 0
@@ -462,6 +462,31 @@ export function TaskSheet() {
462
462
  {editing.executionWorkspace?.path && (
463
463
  <code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
464
464
  )}
465
+ {(editing.executionWorkspace?.contextPath || editing.executionWorkspace?.envPath) && (
466
+ <div className="grid grid-cols-1 gap-2 text-[11px] text-text-3/70">
467
+ {editing.executionWorkspace.contextPath && (
468
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
469
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Context</div>
470
+ <code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.contextPath}</code>
471
+ </div>
472
+ )}
473
+ {editing.executionWorkspace.envPath && (
474
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
475
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Env</div>
476
+ <code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.envPath}</code>
477
+ </div>
478
+ )}
479
+ </div>
480
+ )}
481
+ {editing.executionWorkspace?.envHints?.length ? (
482
+ <div className="flex flex-wrap gap-1.5">
483
+ {editing.executionWorkspace.envHints.slice(0, 8).map((hint) => (
484
+ <InfoChip key={hint.key} tone="neutral" title={hint.value}>
485
+ <span className="max-w-[220px] truncate">{hint.key}</span>
486
+ </InfoChip>
487
+ ))}
488
+ </div>
489
+ ) : null}
465
490
  {previewLinks.length > 0 && (
466
491
  <div className="flex flex-wrap gap-2">
467
492
  {previewLinks.map((link) => (
@@ -942,6 +967,31 @@ export function TaskSheet() {
942
967
  {editing.executionWorkspace?.path && (
943
968
  <code className="block text-[12px] text-text-3 font-mono break-all">{editing.executionWorkspace.path}</code>
944
969
  )}
970
+ {(editing.executionWorkspace?.contextPath || editing.executionWorkspace?.envPath) && (
971
+ <div className="grid grid-cols-1 gap-2 text-[11px] text-text-3/70">
972
+ {editing.executionWorkspace.contextPath && (
973
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
974
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Context</div>
975
+ <code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.contextPath}</code>
976
+ </div>
977
+ )}
978
+ {editing.executionWorkspace.envPath && (
979
+ <div className="rounded-[10px] border border-white/[0.05] bg-white/[0.02] px-3 py-2">
980
+ <div className="uppercase tracking-[0.08em] text-text-3/50">Env</div>
981
+ <code className="mt-1 block break-all text-text-2">{editing.executionWorkspace.envPath}</code>
982
+ </div>
983
+ )}
984
+ </div>
985
+ )}
986
+ {editing.executionWorkspace?.envHints?.length ? (
987
+ <div className="flex flex-wrap gap-1.5">
988
+ {editing.executionWorkspace.envHints.slice(0, 8).map((hint) => (
989
+ <InfoChip key={hint.key} tone="neutral" title={hint.value}>
990
+ <span className="max-w-[220px] truncate">{hint.key}</span>
991
+ </InfoChip>
992
+ ))}
993
+ </div>
994
+ ) : null}
945
995
  {previewLinks.length > 0 && (
946
996
  <div className="flex flex-wrap gap-2">
947
997
  {previewLinks.map((link) => (
@@ -3,6 +3,7 @@ import { api } from '@/lib/app/api-client'
3
3
  import { credentialQueryKeys } from '@/features/credentials/queries'
4
4
  import type {
5
5
  GatewayProfile,
6
+ OpenClawEnvironmentSummary,
6
7
  OpenClawDevicePairRequest,
7
8
  OpenClawGatewayFleetTopology,
8
9
  OpenClawGatewayPresenceEntry,
@@ -60,6 +61,7 @@ export interface RefreshGatewayTopologyResult {
60
61
  pairedDevices: OpenClawPairedDevice[]
61
62
  sessions: OpenClawGatewaySession[]
62
63
  presence: OpenClawGatewayPresenceEntry[]
64
+ environments: OpenClawEnvironmentSummary[]
63
65
  errors: OpenClawGatewayRpcError[]
64
66
  topology: OpenClawGatewayTopology
65
67
  }
@@ -190,6 +192,7 @@ export function useRefreshGatewayTopologyMutation() {
190
192
  pairedDevices: topology.pairedDevices,
191
193
  sessions: topology.sessions,
192
194
  presence: topology.presence,
195
+ environments: topology.environments,
193
196
  errors: topology.errors,
194
197
  topology,
195
198
  }
@@ -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',
@@ -66,10 +66,24 @@ describe('task execution workspaces', () => {
66
66
  assert.match(patch.executionWorkspace.path, /task-alpha-launch-qa-preview/)
67
67
  assert.equal(fs.existsSync(patch.executionWorkspace.path), true)
68
68
  assert.equal(fs.existsSync(patch.executionWorkspace.readmePath || ''), true)
69
+ assert.equal(fs.existsSync(patch.executionWorkspace.contextPath || ''), true)
70
+ assert.equal(fs.existsSync(patch.executionWorkspace.envPath || ''), true)
69
71
  assert.equal(patch.executionWorkspace.sourceCwd, '/repo/source')
72
+ assert.equal(patch.executionWorkspace.context?.taskId, 'task-alpha')
73
+ assert.equal(patch.executionWorkspace.context?.workspacePath, patch.executionWorkspace.path)
74
+ assert.equal(patch.executionWorkspace.envHints?.some((hint) => hint.key === 'WORKSPACE_CWD'), true)
75
+ assert.equal(patch.executionWorkspace.envHints?.some((hint) => hint.key === 'KANBAN_TASK_ID'), true)
70
76
  assert.equal(patch.executionWorkspace.previewLinks[0]?.label, 'Local preview')
71
77
  assert.equal(patch.previewLinks[0]?.url, 'http://127.0.0.1:3456')
72
78
  assert.equal(patch.runtimeServices[0]?.status, 'planned')
79
+
80
+ const context = JSON.parse(fs.readFileSync(patch.executionWorkspace.contextPath || '', 'utf8'))
81
+ assert.equal(context.taskId, 'task-alpha')
82
+ assert.equal(context.previewLinks[0]?.url, 'http://127.0.0.1:3456')
83
+ const envFile = fs.readFileSync(patch.executionWorkspace.envPath || '', 'utf8')
84
+ assert.equal(envFile.includes('SWARMCLAW_TASK_ID="task-alpha"'), true)
85
+ assert.equal(envFile.includes('WORKSPACE_SOURCE="/repo/source"'), true)
86
+ assert.equal(envFile.includes('KANBAN_WORKSPACE='), true)
73
87
  })
74
88
 
75
89
  it('deduplicates preview URLs and computes blocked, stale, and retrying liveness', () => {
@@ -7,6 +7,8 @@ import type {
7
7
  TaskExecutionWorkspace,
8
8
  TaskLivenessSnapshot,
9
9
  TaskPreviewLink,
10
+ TaskRuntimeContextPacket,
11
+ TaskRuntimeEnvHint,
10
12
  TaskRuntimeService,
11
13
  } from '@/types'
12
14
 
@@ -182,11 +184,106 @@ function writeWorkspaceReadme(task: BoardTask, workspacePath: string, now: numbe
182
184
  ]
183
185
  if (task.projectId) lines.push(`Project ID: ${task.projectId}`)
184
186
  if (task.cwd) lines.push(`Source cwd: ${task.cwd}`)
185
- lines.push('', 'Use this directory for task-local notes, generated artifacts, and preview handoff files.')
187
+ lines.push(
188
+ '',
189
+ 'Runtime context: ./context.json',
190
+ 'Environment hints: ./.env.swarmclaw',
191
+ '',
192
+ 'Use this directory for task-local notes, generated artifacts, and preview handoff files.',
193
+ )
186
194
  fs.writeFileSync(readmePath, `${lines.join('\n')}\n`, 'utf8')
187
195
  return readmePath
188
196
  }
189
197
 
198
+ function addEnvHint(out: TaskRuntimeEnvHint[], key: string, value: unknown, description?: string) {
199
+ if (typeof value !== 'string' || !value) return
200
+ out.push({ key, value, ...(description ? { description } : {}) })
201
+ }
202
+
203
+ function buildRuntimeEnvHints(params: {
204
+ task: BoardTask
205
+ workspacePath: string
206
+ sourceCwd?: string | null
207
+ mode: TaskExecutionWorkspace['mode']
208
+ contextPath: string
209
+ envPath: string
210
+ }): TaskRuntimeEnvHint[] {
211
+ const { task, workspacePath, sourceCwd, mode, contextPath, envPath } = params
212
+ const hints: TaskRuntimeEnvHint[] = []
213
+ const workspaceId = taskWorkspaceSlug(task)
214
+ addEnvHint(hints, 'SWARMCLAW_TASK_ID', task.id, 'SwarmClaw task id')
215
+ addEnvHint(hints, 'SWARMCLAW_TASK_TITLE', task.title || 'Task', 'SwarmClaw task title')
216
+ addEnvHint(hints, 'SWARMCLAW_TASK_STATUS', task.status, 'SwarmClaw task status')
217
+ addEnvHint(hints, 'SWARMCLAW_TASK_AGENT_ID', task.agentId, 'Assigned SwarmClaw agent id')
218
+ addEnvHint(hints, 'SWARMCLAW_WORKSPACE_ID', workspaceId, 'Stable task workspace id')
219
+ addEnvHint(hints, 'SWARMCLAW_WORKSPACE_CWD', workspacePath, 'Task workspace directory')
220
+ addEnvHint(hints, 'SWARMCLAW_WORKSPACE_MODE', mode, 'Task workspace mode')
221
+ addEnvHint(hints, 'SWARMCLAW_WORKSPACE_CONTEXT', contextPath, 'Runtime context packet path')
222
+ addEnvHint(hints, 'SWARMCLAW_WORKSPACE_ENV', envPath, 'Reusable runtime env file')
223
+ addEnvHint(hints, 'SWARMCLAW_PROJECT_ID', task.projectId || '', 'SwarmClaw project id')
224
+ addEnvHint(hints, 'SWARMCLAW_SOURCE_CWD', sourceCwd || '', 'Original source directory')
225
+ addEnvHint(hints, 'AGENT_HOME', workspacePath, 'Agent-local home directory')
226
+ addEnvHint(hints, 'TASK_ID', task.id, 'Portable task id')
227
+ addEnvHint(hints, 'TASK_TITLE', task.title || 'Task', 'Portable task title')
228
+ addEnvHint(hints, 'WORKSPACE_ID', workspaceId, 'Portable workspace id')
229
+ addEnvHint(hints, 'WORKSPACE_CWD', workspacePath, 'Portable workspace cwd')
230
+ addEnvHint(hints, 'WORKSPACE_SOURCE', sourceCwd || workspacePath, 'Portable source path')
231
+ addEnvHint(
232
+ hints,
233
+ 'WORKSPACE_STRATEGY',
234
+ mode === 'project' ? 'project-task-workspace' : 'task-workspace',
235
+ 'Portable workspace strategy',
236
+ )
237
+ addEnvHint(hints, 'KANBAN_TASK_ID', task.id, 'Portable board task id')
238
+ addEnvHint(hints, 'KANBAN_WORKSPACE', workspacePath, 'Portable board workspace path')
239
+ return hints
240
+ }
241
+
242
+ function envLine(hint: TaskRuntimeEnvHint): string {
243
+ return `${hint.key}=${JSON.stringify(hint.value)}`
244
+ }
245
+
246
+ function writeWorkspaceEnv(envPath: string, hints: TaskRuntimeEnvHint[]) {
247
+ const lines = [
248
+ '# Generated by SwarmClaw. Contains task context only, not secrets.',
249
+ ...hints.map(envLine),
250
+ ]
251
+ fs.writeFileSync(envPath, `${lines.join('\n')}\n`, 'utf8')
252
+ }
253
+
254
+ function buildTaskRuntimeContext(params: {
255
+ task: BoardTask
256
+ executionWorkspace: Omit<TaskExecutionWorkspace, 'context'>
257
+ previewLinks: TaskPreviewLink[]
258
+ runtimeServices: TaskRuntimeService[]
259
+ generatedAt: number
260
+ }): TaskRuntimeContextPacket {
261
+ const { task, executionWorkspace, previewLinks, runtimeServices, generatedAt } = params
262
+ return {
263
+ taskId: task.id,
264
+ title: task.title || 'Task',
265
+ description: task.description || undefined,
266
+ status: task.status,
267
+ agentId: task.agentId,
268
+ projectId: executionWorkspace.projectId || null,
269
+ workspacePath: executionWorkspace.path,
270
+ sourceCwd: executionWorkspace.sourceCwd || null,
271
+ mode: executionWorkspace.mode,
272
+ preparedAt: executionWorkspace.preparedAt,
273
+ generatedAt,
274
+ previewLinks,
275
+ runtimeServices,
276
+ blockedBy: task.blockedBy,
277
+ blocks: task.blocks,
278
+ tags: task.tags,
279
+ upstreamResults: task.upstreamResults,
280
+ }
281
+ }
282
+
283
+ function writeWorkspaceContext(contextPath: string, context: TaskRuntimeContextPacket) {
284
+ fs.writeFileSync(contextPath, `${JSON.stringify(context, null, 2)}\n`, 'utf8')
285
+ }
286
+
190
287
  export function computeTaskLiveness(
191
288
  task: BoardTask,
192
289
  tasks: Record<string, BoardTask> = {},
@@ -284,6 +381,8 @@ export function prepareTaskExecutionWorkspace(
284
381
  const existing = task.executionWorkspace || null
285
382
  const workspacePath = existing?.path || path.join(taskWorkspaceRoot(task, workspaceRoot), taskWorkspaceSlug(task))
286
383
  fs.mkdirSync(workspacePath, { recursive: true })
384
+ const contextPath = path.join(workspacePath, 'context.json')
385
+ const envPath = path.join(workspacePath, '.env.swarmclaw')
287
386
  const readmePath = writeWorkspaceReadme(task, workspacePath, now)
288
387
  const previewLinks = normalizeTaskPreviewLinks(
289
388
  task.previewLinks || existing?.previewLinks,
@@ -295,17 +394,45 @@ export function prepareTaskExecutionWorkspace(
295
394
  options.runtimeServices,
296
395
  now,
297
396
  )
298
- const executionWorkspace: TaskExecutionWorkspace = {
397
+ const mode: TaskExecutionWorkspace['mode'] = task.projectId ? 'project' : 'task'
398
+ const sourceCwd = task.cwd || existing?.sourceCwd || null
399
+ const projectId = task.projectId || existing?.projectId || null
400
+ const preparedAt = existing?.preparedAt || now
401
+ const envHints = buildRuntimeEnvHints({
402
+ task,
403
+ workspacePath,
404
+ sourceCwd,
405
+ mode,
406
+ contextPath,
407
+ envPath,
408
+ })
409
+ const executionWorkspaceBase: Omit<TaskExecutionWorkspace, 'context'> = {
299
410
  path: workspacePath,
300
- mode: task.projectId ? 'project' : 'task',
301
- sourceCwd: task.cwd || existing?.sourceCwd || null,
302
- projectId: task.projectId || existing?.projectId || null,
303
- preparedAt: existing?.preparedAt || now,
411
+ mode,
412
+ sourceCwd,
413
+ projectId,
414
+ preparedAt,
304
415
  preparedBy: options.actor || existing?.preparedBy || null,
305
416
  readmePath,
417
+ contextPath,
418
+ envPath,
419
+ envHints,
306
420
  previewLinks,
307
421
  runtimeServices,
308
422
  }
423
+ const context = buildTaskRuntimeContext({
424
+ task,
425
+ executionWorkspace: executionWorkspaceBase,
426
+ previewLinks,
427
+ runtimeServices,
428
+ generatedAt: now,
429
+ })
430
+ writeWorkspaceContext(contextPath, context)
431
+ writeWorkspaceEnv(envPath, envHints)
432
+ const executionWorkspace: TaskExecutionWorkspace = {
433
+ ...executionWorkspaceBase,
434
+ context,
435
+ }
309
436
  const taskForLiveness = {
310
437
  ...task,
311
438
  executionWorkspace,
package/src/types/misc.ts CHANGED
@@ -672,6 +672,8 @@ export interface OpenClawGatewayStats {
672
672
  externalRuntimeCount?: number
673
673
  sessionCount?: number
674
674
  presenceCount?: number
675
+ environmentCount?: number
676
+ availableEnvironmentCount?: number
675
677
  lastTopologyCheckedAt?: number
676
678
  lastTopologyErrorCount?: number
677
679
  lastTopologyError?: string | null
@@ -803,7 +805,19 @@ export interface OpenClawGatewayPresenceEntry {
803
805
  updatedAt?: number | null
804
806
  }
805
807
 
808
+ export type OpenClawEnvironmentStatus = 'available' | 'unavailable' | 'starting' | 'stopping' | 'error'
809
+
810
+ export interface OpenClawEnvironmentSummary {
811
+ id: string
812
+ type: string
813
+ label?: string | null
814
+ status: OpenClawEnvironmentStatus
815
+ capabilities?: string[]
816
+ }
817
+
806
818
  export interface OpenClawGatewayTopologyStats extends OpenClawGatewayStats {
819
+ environmentCount: number
820
+ availableEnvironmentCount: number
807
821
  pendingPairingCount: number
808
822
  hasErrors: boolean
809
823
  }
@@ -819,6 +833,23 @@ export interface OpenClawGatewayTopology {
819
833
  pairedDevices: OpenClawPairedDevice[]
820
834
  sessions: OpenClawGatewaySession[]
821
835
  presence: OpenClawGatewayPresenceEntry[]
836
+ environments: OpenClawEnvironmentSummary[]
837
+ errors: OpenClawGatewayRpcError[]
838
+ }
839
+
840
+ export interface OpenClawGatewayEnvironmentList {
841
+ profile: GatewayProfile
842
+ connected: boolean
843
+ refreshedAt: number
844
+ environments: OpenClawEnvironmentSummary[]
845
+ errors: OpenClawGatewayRpcError[]
846
+ }
847
+
848
+ export interface OpenClawGatewayEnvironmentStatusSnapshot {
849
+ profile: GatewayProfile
850
+ connected: boolean
851
+ refreshedAt: number
852
+ environment: OpenClawEnvironmentSummary | null
822
853
  errors: OpenClawGatewayRpcError[]
823
854
  }
824
855
 
package/src/types/task.ts CHANGED
@@ -43,6 +43,32 @@ export interface TaskRuntimeService {
43
43
  updatedAt: number
44
44
  }
45
45
 
46
+ export interface TaskRuntimeEnvHint {
47
+ key: string
48
+ value: string
49
+ description?: string
50
+ }
51
+
52
+ export interface TaskRuntimeContextPacket {
53
+ taskId: string
54
+ title: string
55
+ description?: string
56
+ status: BoardTaskStatus
57
+ agentId: string
58
+ projectId?: string | null
59
+ workspacePath: string
60
+ sourceCwd?: string | null
61
+ mode: TaskExecutionWorkspaceMode
62
+ preparedAt: number
63
+ generatedAt: number
64
+ previewLinks: TaskPreviewLink[]
65
+ runtimeServices: TaskRuntimeService[]
66
+ blockedBy?: string[]
67
+ blocks?: string[]
68
+ tags?: string[]
69
+ upstreamResults?: BoardTask['upstreamResults']
70
+ }
71
+
46
72
  export interface TaskExecutionWorkspace {
47
73
  path: string
48
74
  mode: TaskExecutionWorkspaceMode
@@ -51,6 +77,10 @@ export interface TaskExecutionWorkspace {
51
77
  preparedAt: number
52
78
  preparedBy?: string | null
53
79
  readmePath?: string | null
80
+ contextPath?: string | null
81
+ envPath?: string | null
82
+ envHints?: TaskRuntimeEnvHint[]
83
+ context?: TaskRuntimeContextPacket
54
84
  previewLinks: TaskPreviewLink[]
55
85
  runtimeServices: TaskRuntimeService[]
56
86
  }