@robbiesrobotics/alice-agents 1.4.4 → 1.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/doctor.mjs CHANGED
@@ -25,6 +25,50 @@ const STARTER_AGENTS = [
25
25
  'felix', 'daphne', 'rowan', 'darius', 'sophie',
26
26
  ];
27
27
 
28
+ function normalizeProviderId(provider) {
29
+ if (!provider) return null;
30
+ if (provider === 'openai-codex') return 'openai';
31
+ return provider;
32
+ }
33
+
34
+ function getConfigAgents(config) {
35
+ if (Array.isArray(config?.agents?.list)) return config.agents.list;
36
+ if (Array.isArray(config?.agents)) return config.agents;
37
+ return [];
38
+ }
39
+
40
+ function detectConfiguredModel(config) {
41
+ if (!config || config === 'invalid') return { ok: false, label: null, inherited: false };
42
+
43
+ const defaults = config?.agents?.defaults?.model || {};
44
+ const primary = defaults.primary || config?.model || config?.default_model || null;
45
+ if (primary) {
46
+ return { ok: true, label: primary, inherited: true };
47
+ }
48
+
49
+ const providerKeys = Object.keys(config?.models?.providers || {});
50
+ if (providerKeys.length > 0) {
51
+ return { ok: true, label: providerKeys[0], inherited: false };
52
+ }
53
+
54
+ const profile = Object.values(config?.auth?.profiles || {}).find((entry) => entry?.provider);
55
+ if (profile?.provider) {
56
+ return { ok: true, label: normalizeProviderId(profile.provider), inherited: false };
57
+ }
58
+
59
+ if (config.models && Object.keys(config.models).length > 0) {
60
+ return { ok: true, label: Object.keys(config.models)[0], inherited: false };
61
+ }
62
+ if (config.providers && Object.keys(config.providers).length > 0) {
63
+ return { ok: true, label: Object.keys(config.providers)[0], inherited: false };
64
+ }
65
+ if (config.llm && Object.keys(config.llm).length > 0) {
66
+ return { ok: true, label: Object.keys(config.llm)[0], inherited: false };
67
+ }
68
+
69
+ return { ok: false, label: null, inherited: false };
70
+ }
71
+
28
72
  function check(label, ok, hint) {
29
73
  const icon = ok ? icons.ok : icons.fail;
30
74
  console.log(` ${icon} ${ok ? green(label) : red(label)}`);
@@ -158,7 +202,7 @@ export async function runDoctor() {
158
202
  }
159
203
 
160
204
  // 3. A.L.I.C.E. agents in config
161
- const configAgents = Array.isArray(config.agents) ? config.agents : [];
205
+ const configAgents = getConfigAgents(config);
162
206
  const agentsInConfig = configAgents
163
207
  .filter((a) => a && STARTER_AGENTS.includes(a.id))
164
208
  .map((a) => a.id);
@@ -207,26 +251,13 @@ export async function runDoctor() {
207
251
  }
208
252
 
209
253
  // 5. At least one model/provider configured
210
- let modelOk = false;
211
- let modelLabel = null;
212
-
213
- if (config.default_model) {
214
- modelOk = true;
215
- modelLabel = config.default_model;
216
- } else if (config.models && Object.keys(config.models).length > 0) {
217
- modelOk = true;
218
- modelLabel = Object.keys(config.models)[0];
219
- } else if (config.providers && Object.keys(config.providers).length > 0) {
220
- modelOk = true;
221
- modelLabel = Object.keys(config.providers)[0];
222
- } else if (config.llm && Object.keys(config.llm).length > 0) {
223
- modelOk = true;
224
- modelLabel = Object.keys(config.llm)[0];
225
- }
254
+ const modelState = detectConfiguredModel(config);
255
+ const modelOk = modelState.ok;
256
+ const modelLabel = modelState.label;
226
257
 
227
258
  check(
228
259
  modelOk
229
- ? `Model/provider configured: ${modelLabel}`
260
+ ? `Model/provider configured: ${modelLabel}${modelState.inherited ? ' (shared default)' : ''}`
230
261
  : 'No model/provider configured',
231
262
  modelOk,
232
263
  'Run: openclaw configure to set up a model provider'
@@ -291,7 +322,38 @@ export async function runDoctor() {
291
322
  check('License: Starter tier (no license required)', true);
292
323
  }
293
324
 
294
- // 8. Skills disk check
325
+ // 8. Mission Control cloud config
326
+ const missionControlConfigPath = join(OPENCLAW_DIR, '.alice-mission-control.json');
327
+ if (existsSync(missionControlConfigPath)) {
328
+ try {
329
+ const missionControlConfig = JSON.parse(readFileSync(missionControlConfigPath, 'utf8'));
330
+ const cloud = missionControlConfig?.cloud || {};
331
+ const hasDashboardUrl = typeof cloud.dashboardUrl === 'string' && cloud.dashboardUrl.length > 0;
332
+ const hasIngestUrl = typeof cloud.ingestUrl === 'string' && cloud.ingestUrl.length > 0;
333
+ const hasIngestToken = typeof cloud.ingestToken === 'string' && cloud.ingestToken.length > 0;
334
+ const cloudOk = hasDashboardUrl && hasIngestUrl && hasIngestToken;
335
+
336
+ check(
337
+ cloudOk
338
+ ? `Mission Control cloud configured (${cloud.dashboardUrl})`
339
+ : 'Mission Control cloud config incomplete',
340
+ cloudOk,
341
+ 'Run: npx @robbiesrobotics/alice-agents --cloud to repair cloud settings'
342
+ );
343
+ allOk = allOk && cloudOk;
344
+ } catch {
345
+ check(
346
+ 'Mission Control cloud config invalid',
347
+ false,
348
+ 'Repair ~/.openclaw/.alice-mission-control.json or rerun the installer with --cloud'
349
+ );
350
+ allOk = false;
351
+ }
352
+ } else {
353
+ console.log(` ${dim('–')} ${dim('Mission Control cloud not configured (optional)')}`);
354
+ }
355
+
356
+ // 9. Skills disk check
295
357
  const skillsManifestPath = join(OPENCLAW_DIR, '.alice-manifest.json');
296
358
  const skillsManifestData = (() => {
297
359
  try {
@@ -9,6 +9,7 @@ const CONFIG_PATH = join(OPENCLAW_HOME, 'openclaw.json');
9
9
  const MC_CONFIG_PATH = join(OPENCLAW_HOME, '.alice-mission-control.json');
10
10
  const BRIDGE_ID = 'mission-control-bridge';
11
11
  const DEFAULT_DASHBOARD_URL = 'https://alice.av3.ai';
12
+ const DEFAULT_ADMIN_URL = 'https://admin.av3.ai';
12
13
  const DEFAULT_INGEST_URL = `${DEFAULT_DASHBOARD_URL}/api/v1/ingest`;
13
14
  const TEMPLATE_DIR = join(__dirname, '..', 'templates', 'mission-control-bridge');
14
15
 
@@ -42,17 +43,31 @@ export function readMissionControlConfig() {
42
43
 
43
44
  export function buildMissionControlSettings(input = {}) {
44
45
  const dashboardUrl = normalizeUrl(input.dashboardUrl, DEFAULT_DASHBOARD_URL);
46
+ const adminUrl = normalizeUrl(input.adminUrl, DEFAULT_ADMIN_URL);
45
47
  const ingestUrl = normalizeUrl(input.ingestUrl, `${dashboardUrl}/api/v1/ingest`);
48
+ const runtimeBaseUrl = normalizeUrl(input.runtimeBaseUrl, `${dashboardUrl}/api/v1/runtime`);
49
+ const adminHeartbeatUrl = normalizeUrl(input.adminHeartbeatUrl, `${adminUrl}/api/admin/v1/node-heartbeat`);
50
+ const commandsUrl = normalizeUrl(input.commandsUrl, `${runtimeBaseUrl}/commands`);
51
+ const nodeRegisterUrl = normalizeUrl(input.nodeRegisterUrl, `${runtimeBaseUrl}/nodes/register`);
52
+ const nodeHeartbeatUrl = normalizeUrl(input.nodeHeartbeatUrl, `${runtimeBaseUrl}/nodes/heartbeat`);
46
53
  const sourceNode = String(input.sourceNode || hostname() || 'openclaw-local').trim();
47
54
  const ingestToken = String(input.ingestToken || '').trim();
55
+ const workerToken = String(input.workerToken || ingestToken || '').trim();
48
56
 
49
57
  return {
50
58
  enabled: input.enabled !== false,
51
59
  provider: 'cloud',
52
60
  dashboardUrl,
61
+ adminUrl,
53
62
  ingestUrl,
63
+ runtimeBaseUrl,
64
+ adminHeartbeatUrl,
65
+ commandsUrl,
66
+ nodeRegisterUrl,
67
+ nodeHeartbeatUrl,
54
68
  sourceNode,
55
69
  ...(ingestToken ? { ingestToken } : {}),
70
+ ...(workerToken ? { workerToken } : {}),
56
71
  };
57
72
  }
58
73
 
@@ -134,9 +149,13 @@ export function configureMissionControlCloud(input = {}) {
134
149
  enabled: settings.enabled,
135
150
  provider: settings.provider,
136
151
  dashboardUrl: settings.dashboardUrl,
152
+ adminUrl: settings.adminUrl,
137
153
  ingestUrl: settings.ingestUrl,
154
+ runtimeBaseUrl: settings.runtimeBaseUrl,
155
+ adminHeartbeatUrl: settings.adminHeartbeatUrl,
138
156
  sourceNode: settings.sourceNode,
139
157
  hasIngestToken: !!settings.ingestToken,
158
+ hasWorkerToken: !!settings.workerToken,
140
159
  },
141
160
  };
142
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robbiesrobotics/alice-agents",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "description": "A.L.I.C.E. — 28 AI agents for OpenClaw. One conversation, one team.",
5
5
  "bin": {
6
6
  "alice-agents": "bin/alice-install.mjs"
@@ -2,35 +2,114 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diagnostics-otel";
5
- import { emptyPluginConfigSchema, onDiagnosticEvent } from "openclaw/plugin-sdk/diagnostics-otel";
5
+ import {
6
+ emptyPluginConfigSchema,
7
+ onDiagnosticEvent,
8
+ } from "openclaw/plugin-sdk/diagnostics-otel";
6
9
  import type { DiagnosticEventPayload } from "openclaw/plugin-sdk/diagnostics-otel";
7
10
 
8
11
  const OPENCLAW_HOME = process.env.OPENCLAW_HOME ?? join(homedir(), ".openclaw");
9
12
  const MC_CONFIG_PATH = join(OPENCLAW_HOME, ".alice-mission-control.json");
10
- const DEFAULT_INGEST_URL = "https://alice.av3.ai/api/v1/ingest";
13
+ const DEFAULT_DASHBOARD_URL = "https://alice.av3.ai";
14
+ const DEFAULT_ADMIN_URL = "https://admin.av3.ai";
15
+ const DEFAULT_INGEST_URL = `${DEFAULT_DASHBOARD_URL}/api/v1/ingest`;
16
+ const DEFAULT_RUNTIME_BASE_URL = `${DEFAULT_DASHBOARD_URL}/api/v1/runtime`;
17
+ const DEFAULT_GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL ?? "http://127.0.0.1:18789";
18
+ const DEFAULT_GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN ?? process.env.MC_GATEWAY_TOKEN ?? "";
19
+ const DEFAULT_CHAT_USER = process.env.MC_CHAT_USER ?? "mission-control-worker";
20
+ const HEARTBEAT_INTERVAL_MS = 15000;
21
+ const COMMAND_POLL_INTERVAL_MS = 5000;
11
22
 
12
- function readMissionControlConfig(): Record<string, unknown> {
23
+ type JsonRecord = Record<string, unknown>;
24
+
25
+ interface RuntimeNodeRegistrationPayload {
26
+ nodeId: string;
27
+ installId: string;
28
+ nodeName: string;
29
+ sourceNode: string;
30
+ connectionMode: "relay";
31
+ gatewayUrl: string;
32
+ runtimeVersion: string;
33
+ platform: string;
34
+ capabilities: JsonRecord;
35
+ metadata: JsonRecord;
36
+ }
37
+
38
+ interface RuntimeCommand {
39
+ id: string;
40
+ nodeId: string;
41
+ type: string;
42
+ status: string;
43
+ threadId: string | null;
44
+ sessionId: string | null;
45
+ payload: JsonRecord;
46
+ }
47
+
48
+ function readMissionControlConfig(): JsonRecord {
13
49
  try {
14
50
  if (!existsSync(MC_CONFIG_PATH)) return {};
15
- return JSON.parse(readFileSync(MC_CONFIG_PATH, "utf8"));
51
+ return JSON.parse(readFileSync(MC_CONFIG_PATH, "utf8")) as JsonRecord;
16
52
  } catch {
17
53
  return {};
18
54
  }
19
55
  }
20
56
 
21
- function getCloudConfig(): Record<string, unknown> {
57
+ function getCloudConfig(): JsonRecord {
22
58
  const fileConfig = readMissionControlConfig();
23
- return typeof fileConfig.cloud === "object" && fileConfig.cloud ? fileConfig.cloud as Record<string, unknown> : {};
59
+ return typeof fileConfig.cloud === "object" && fileConfig.cloud
60
+ ? (fileConfig.cloud as JsonRecord)
61
+ : {};
24
62
  }
25
63
 
26
64
  function getString(value: unknown, fallback = ""): string {
27
65
  return typeof value === "string" && value.trim() ? value.trim() : fallback;
28
66
  }
29
67
 
68
+ function normalizeUrl(value: string): string {
69
+ return value.replace(/\/+$/, "");
70
+ }
71
+
30
72
  const cloudConfig = getCloudConfig();
31
- const INGEST_URL = getString(process.env.MC_INGEST_URL, getString(cloudConfig.ingestUrl, DEFAULT_INGEST_URL));
73
+ const DASHBOARD_URL = normalizeUrl(
74
+ getString(process.env.MC_DASHBOARD_URL, getString(cloudConfig.dashboardUrl, DEFAULT_DASHBOARD_URL)),
75
+ );
76
+ const ADMIN_URL = normalizeUrl(
77
+ getString(process.env.MC_ADMIN_URL, getString(cloudConfig.adminUrl, DEFAULT_ADMIN_URL)),
78
+ );
79
+ const INGEST_URL = normalizeUrl(
80
+ getString(process.env.MC_INGEST_URL, getString(cloudConfig.ingestUrl, DEFAULT_INGEST_URL)),
81
+ );
82
+ const RUNTIME_BASE_URL = normalizeUrl(
83
+ getString(process.env.MC_RUNTIME_BASE_URL, getString(cloudConfig.runtimeBaseUrl, DEFAULT_RUNTIME_BASE_URL)),
84
+ );
85
+ const NODE_REGISTER_URL = normalizeUrl(
86
+ getString(process.env.MC_NODE_REGISTER_URL, getString(cloudConfig.nodeRegisterUrl, `${RUNTIME_BASE_URL}/nodes/register`)),
87
+ );
88
+ const NODE_HEARTBEAT_URL = normalizeUrl(
89
+ getString(
90
+ process.env.MC_NODE_HEARTBEAT_URL,
91
+ getString(cloudConfig.nodeHeartbeatUrl, `${RUNTIME_BASE_URL}/nodes/heartbeat`),
92
+ ),
93
+ );
94
+ const ADMIN_HEARTBEAT_URL = normalizeUrl(
95
+ getString(
96
+ process.env.MC_ADMIN_HEARTBEAT_URL,
97
+ getString(cloudConfig.adminHeartbeatUrl, `${ADMIN_URL}/api/admin/v1/node-heartbeat`),
98
+ ),
99
+ );
100
+ const COMMANDS_URL = normalizeUrl(
101
+ getString(process.env.MC_COMMANDS_URL, getString(cloudConfig.commandsUrl, `${RUNTIME_BASE_URL}/commands`)),
102
+ );
32
103
  const INGEST_TOKEN = getString(process.env.MC_INGEST_TOKEN, getString(cloudConfig.ingestToken));
104
+ const WORKER_TOKEN = getString(
105
+ process.env.MC_RUNTIME_WORKER_TOKEN,
106
+ getString(cloudConfig.workerToken, INGEST_TOKEN),
107
+ );
33
108
  const SOURCE_NODE = getString(process.env.MC_SOURCE_NODE, getString(cloudConfig.sourceNode, "openclaw-local"));
109
+ const INSTALL_ID = getString(process.env.MC_INSTALL_ID, SOURCE_NODE);
110
+ const NODE_ID = getString(process.env.MC_NODE_ID, SOURCE_NODE);
111
+ const GATEWAY_URL = normalizeUrl(getString(process.env.MC_GATEWAY_URL, DEFAULT_GATEWAY_URL));
112
+ const GATEWAY_TOKEN = getString(process.env.MC_GATEWAY_TOKEN, DEFAULT_GATEWAY_TOKEN);
34
113
 
35
114
  function now(): string {
36
115
  return new Date().toISOString();
@@ -43,30 +122,313 @@ function sessionKeyToAgentId(sessionKey?: string): string {
43
122
  }
44
123
 
45
124
  let seq = 0;
46
- function nextEventId(): string {
47
- return `mc-bridge-${Date.now()}-${++seq}`;
125
+ function nextId(prefix: string): string {
126
+ return `${prefix}-${Date.now()}-${++seq}`;
48
127
  }
49
128
 
50
- async function postToIngest(events: object[]): Promise<void> {
51
- const headers: Record<string, string> = {
129
+ function authHeaders(token: string): Record<string, string> {
130
+ return token ? { Authorization: `Bearer ${token}` } : {};
131
+ }
132
+
133
+ function headers(token = ""): Record<string, string> {
134
+ return {
52
135
  "Content-Type": "application/json",
136
+ ...authHeaders(token),
137
+ };
138
+ }
139
+
140
+ async function postJson(url: string, token: string, body: object): Promise<Response> {
141
+ return fetch(url, {
142
+ method: "POST",
143
+ headers: headers(token),
144
+ body: JSON.stringify(body),
145
+ signal: AbortSignal.timeout(8000),
146
+ });
147
+ }
148
+
149
+ async function postToIngest(events: object[]): Promise<void> {
150
+ try {
151
+ const res = await postJson(INGEST_URL, INGEST_TOKEN, events);
152
+ if (!res.ok) {
153
+ console.warn(`[mc-bridge] ingest HTTP ${res.status} — ${await res.text().catch(() => "")}`);
154
+ }
155
+ } catch (err) {
156
+ console.warn(`[mc-bridge] ingest post failed: ${String(err)}`);
157
+ }
158
+ }
159
+
160
+ function buildNodeRegistrationPayload(): RuntimeNodeRegistrationPayload {
161
+ return {
162
+ nodeId: NODE_ID,
163
+ installId: INSTALL_ID,
164
+ nodeName: SOURCE_NODE,
165
+ sourceNode: SOURCE_NODE,
166
+ connectionMode: "relay",
167
+ gatewayUrl: GATEWAY_URL,
168
+ runtimeVersion: process.version,
169
+ platform: process.platform,
170
+ capabilities: {
171
+ telemetry: true,
172
+ commands: true,
173
+ directGateway: true,
174
+ commandTypes: ["agent.message.send"],
175
+ },
176
+ metadata: {
177
+ dashboardUrl: DASHBOARD_URL,
178
+ pluginId: "mission-control-bridge",
179
+ },
180
+ };
181
+ }
182
+
183
+ async function registerNode(logger: { info(message: string): void; warn(message: string): void }): Promise<void> {
184
+ if (!WORKER_TOKEN) {
185
+ logger.warn("[mc-bridge] worker token missing; node registration skipped");
186
+ return;
187
+ }
188
+ try {
189
+ const res = await postJson(NODE_REGISTER_URL, WORKER_TOKEN, buildNodeRegistrationPayload());
190
+ if (!res.ok) {
191
+ logger.warn(`[mc-bridge] register failed ${res.status}: ${await res.text().catch(() => "")}`);
192
+ }
193
+ } catch (err) {
194
+ logger.warn(`[mc-bridge] register failed: ${String(err)}`);
195
+ }
196
+ }
197
+
198
+ async function heartbeatNode(logger: { warn(message: string): void }): Promise<void> {
199
+ if (!WORKER_TOKEN) return;
200
+ const payload = {
201
+ nodeId: NODE_ID,
202
+ status: "online",
203
+ runtimeVersion: process.version,
204
+ platform: process.platform,
205
+ capabilities: {
206
+ telemetry: true,
207
+ commands: true,
208
+ directGateway: true,
209
+ commandTypes: ["agent.message.send"],
210
+ },
211
+ metadata: {
212
+ dashboardUrl: DASHBOARD_URL,
213
+ },
53
214
  };
54
- if (INGEST_TOKEN) {
55
- headers["Authorization"] = `Bearer ${INGEST_TOKEN}`;
215
+ try {
216
+ const res = await postJson(NODE_HEARTBEAT_URL, WORKER_TOKEN, payload);
217
+ if (!res.ok) {
218
+ logger.warn(`[mc-bridge] heartbeat failed ${res.status}: ${await res.text().catch(() => "")}`);
219
+ }
220
+ } catch (err) {
221
+ logger.warn(`[mc-bridge] heartbeat failed: ${String(err)}`);
56
222
  }
57
223
 
58
224
  try {
59
- const res = await fetch(INGEST_URL, {
60
- method: "POST",
61
- headers,
62
- body: JSON.stringify(events),
225
+ const res = await postJson(ADMIN_HEARTBEAT_URL, WORKER_TOKEN, {
226
+ instanceId: NODE_ID,
227
+ nodeName: SOURCE_NODE,
228
+ tailscaleIp: getString(process.env.TAILSCALE_IP),
229
+ deployedVersion: getString(process.env.MC_DEPLOYED_VERSION),
230
+ runtimeVersion: process.version,
231
+ status: "healthy",
232
+ publicUrl: DASHBOARD_URL,
233
+ region: getString(process.env.MC_REGION),
234
+ backupStatus: "unknown",
235
+ memorySyncStatus: "healthy",
236
+ });
237
+ if (!res.ok) {
238
+ logger.warn(`[mc-bridge] admin heartbeat failed ${res.status}: ${await res.text().catch(() => "")}`);
239
+ }
240
+ } catch (err) {
241
+ logger.warn(`[mc-bridge] admin heartbeat failed: ${String(err)}`);
242
+ }
243
+ }
244
+
245
+ async function listQueuedCommands(logger: { warn(message: string): void }): Promise<RuntimeCommand[]> {
246
+ if (!WORKER_TOKEN) return [];
247
+ const url = `${COMMANDS_URL}?nodeId=${encodeURIComponent(NODE_ID)}&limit=5`;
248
+ try {
249
+ const res = await fetch(url, {
250
+ method: "GET",
251
+ headers: authHeaders(WORKER_TOKEN),
63
252
  signal: AbortSignal.timeout(8000),
64
253
  });
65
254
  if (!res.ok) {
66
- console.warn(`[mc-bridge] ingest HTTP ${res.status} ${await res.text().catch(() => "")}`);
255
+ logger.warn(`[mc-bridge] command poll failed ${res.status}: ${await res.text().catch(() => "")}`);
256
+ return [];
67
257
  }
258
+ const data = (await res.json()) as { commands?: RuntimeCommand[] };
259
+ return Array.isArray(data.commands) ? data.commands : [];
68
260
  } catch (err) {
69
- console.warn(`[mc-bridge] ingest post failed: ${String(err)}`);
261
+ logger.warn(`[mc-bridge] command poll failed: ${String(err)}`);
262
+ return [];
263
+ }
264
+ }
265
+
266
+ async function leaseCommand(commandId: string, logger: { warn(message: string): void }): Promise<boolean> {
267
+ try {
268
+ const res = await postJson(`${COMMANDS_URL}/${commandId}/lease`, WORKER_TOKEN, {
269
+ nodeId: NODE_ID,
270
+ leaseOwner: NODE_ID,
271
+ });
272
+ if (res.ok) return true;
273
+ if (res.status !== 409) {
274
+ logger.warn(`[mc-bridge] command lease failed ${res.status}: ${await res.text().catch(() => "")}`);
275
+ }
276
+ return false;
277
+ } catch (err) {
278
+ logger.warn(`[mc-bridge] command lease failed: ${String(err)}`);
279
+ return false;
280
+ }
281
+ }
282
+
283
+ async function emitCommandEvent(
284
+ commandId: string,
285
+ eventType: string,
286
+ payload: JsonRecord,
287
+ logger: { warn(message: string): void },
288
+ ): Promise<void> {
289
+ try {
290
+ const res = await postJson(`${COMMANDS_URL}/${commandId}/events`, WORKER_TOKEN, {
291
+ nodeId: NODE_ID,
292
+ eventType,
293
+ payload,
294
+ });
295
+ if (!res.ok) {
296
+ logger.warn(`[mc-bridge] command event failed ${res.status}: ${await res.text().catch(() => "")}`);
297
+ }
298
+ } catch (err) {
299
+ logger.warn(`[mc-bridge] command event failed: ${String(err)}`);
300
+ }
301
+ }
302
+
303
+ async function completeCommand(
304
+ commandId: string,
305
+ result: JsonRecord,
306
+ logger: { warn(message: string): void },
307
+ ): Promise<void> {
308
+ try {
309
+ const res = await postJson(`${COMMANDS_URL}/${commandId}/complete`, WORKER_TOKEN, {
310
+ nodeId: NODE_ID,
311
+ result,
312
+ });
313
+ if (!res.ok) {
314
+ logger.warn(`[mc-bridge] command completion failed ${res.status}: ${await res.text().catch(() => "")}`);
315
+ }
316
+ } catch (err) {
317
+ logger.warn(`[mc-bridge] command completion failed: ${String(err)}`);
318
+ }
319
+ }
320
+
321
+ async function failCommand(
322
+ commandId: string,
323
+ error: string,
324
+ logger: { warn(message: string): void },
325
+ ): Promise<void> {
326
+ try {
327
+ const res = await postJson(`${COMMANDS_URL}/${commandId}/fail`, WORKER_TOKEN, {
328
+ nodeId: NODE_ID,
329
+ error,
330
+ });
331
+ if (!res.ok) {
332
+ logger.warn(`[mc-bridge] command failure update failed ${res.status}: ${await res.text().catch(() => "")}`);
333
+ }
334
+ } catch (err) {
335
+ logger.warn(`[mc-bridge] command failure update failed: ${String(err)}`);
336
+ }
337
+ }
338
+
339
+ async function executeAgentMessage(command: RuntimeCommand): Promise<JsonRecord> {
340
+ const payload = command.payload ?? {};
341
+ const message = getString(payload.message);
342
+ const agentId = getString(payload.agentId, "olivia");
343
+ const chatUser = getString(payload.user, DEFAULT_CHAT_USER);
344
+
345
+ if (!message) {
346
+ throw new Error("agent.message.send requires payload.message");
347
+ }
348
+
349
+ const gatewayHeaders: Record<string, string> = {
350
+ "Content-Type": "application/json",
351
+ };
352
+ if (GATEWAY_TOKEN) {
353
+ gatewayHeaders.Authorization = `Bearer ${GATEWAY_TOKEN}`;
354
+ }
355
+
356
+ const res = await fetch(`${GATEWAY_URL}/v1/responses`, {
357
+ method: "POST",
358
+ headers: gatewayHeaders,
359
+ body: JSON.stringify({
360
+ model: `openclaw:${agentId}`,
361
+ input: message,
362
+ user: chatUser,
363
+ stream: false,
364
+ }),
365
+ signal: AbortSignal.timeout(30000),
366
+ });
367
+
368
+ if (!res.ok) {
369
+ throw new Error(`gateway ${res.status}: ${await res.text().catch(() => "")}`);
370
+ }
371
+
372
+ const data = (await res.json()) as JsonRecord;
373
+ const outputText = extractOutputText(data);
374
+
375
+ return {
376
+ agentId,
377
+ user: chatUser,
378
+ responseId: getString(data.id),
379
+ outputText,
380
+ raw: data,
381
+ };
382
+ }
383
+
384
+ function extractOutputText(data: JsonRecord): string {
385
+ const output = Array.isArray(data.output) ? data.output : [];
386
+ const parts: string[] = [];
387
+
388
+ for (const item of output) {
389
+ if (!item || typeof item !== "object") continue;
390
+ const content = Array.isArray((item as JsonRecord).content) ? ((item as JsonRecord).content as unknown[]) : [];
391
+ for (const block of content) {
392
+ if (!block || typeof block !== "object") continue;
393
+ const record = block as JsonRecord;
394
+ const text =
395
+ getString(record.text) ||
396
+ getString(record.value) ||
397
+ getString(typeof record.output_text === "string" ? record.output_text : "");
398
+ if (text) parts.push(text);
399
+ }
400
+ }
401
+
402
+ return parts.join("\n\n").trim();
403
+ }
404
+
405
+ async function executeCommand(
406
+ command: RuntimeCommand,
407
+ logger: { info(message: string): void; warn(message: string): void },
408
+ ): Promise<void> {
409
+ await emitCommandEvent(command.id, "command.started", {
410
+ commandType: command.type,
411
+ startedAt: now(),
412
+ }, logger);
413
+
414
+ try {
415
+ switch (command.type) {
416
+ case "agent.message.send": {
417
+ const result = await executeAgentMessage(command);
418
+ await emitCommandEvent(command.id, "agent.message.completed", result, logger);
419
+ await completeCommand(command.id, result, logger);
420
+ break;
421
+ }
422
+ default:
423
+ throw new Error(`unsupported command type: ${command.type}`);
424
+ }
425
+ } catch (err) {
426
+ const message = err instanceof Error ? err.message : String(err);
427
+ await emitCommandEvent(command.id, "command.failed", {
428
+ error: message,
429
+ failedAt: now(),
430
+ }, logger);
431
+ await failCommand(command.id, message, logger);
70
432
  }
71
433
  }
72
434
 
@@ -75,7 +437,7 @@ function handleModelUsage(evt: Extract<DiagnosticEventPayload, { type: "model.us
75
437
  const totalTokens = evt.usage.total ?? (evt.usage.input ?? 0) + (evt.usage.output ?? 0);
76
438
 
77
439
  const event = {
78
- event_id: nextEventId(),
440
+ event_id: nextId("mc-bridge"),
79
441
  event_type: "agent.session.completed",
80
442
  event_version: "1.0",
81
443
  source_system: "openclaw",
@@ -85,7 +447,7 @@ function handleModelUsage(evt: Extract<DiagnosticEventPayload, { type: "model.us
85
447
  actor_type: "agent",
86
448
  correlation_id: evt.sessionId ?? null,
87
449
  payload: {
88
- session_id: evt.sessionId ?? evt.sessionKey ?? nextEventId(),
450
+ session_id: evt.sessionId ?? evt.sessionKey ?? nextId("sess"),
89
451
  agent_id: agentId,
90
452
  model: evt.model ?? "unknown",
91
453
  channel: evt.channel ?? "unknown",
@@ -102,7 +464,7 @@ function handleModelUsage(evt: Extract<DiagnosticEventPayload, { type: "model.us
102
464
  },
103
465
  };
104
466
 
105
- postToIngest([event]);
467
+ void postToIngest([event]);
106
468
  }
107
469
 
108
470
  function handleMessageProcessed(evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>) {
@@ -118,7 +480,7 @@ function handleMessageProcessed(evt: Extract<DiagnosticEventPayload, { type: "me
118
480
  if (!eventType) return;
119
481
 
120
482
  const event = {
121
- event_id: nextEventId(),
483
+ event_id: nextId("mc-bridge"),
122
484
  event_type: eventType,
123
485
  event_version: "1.0",
124
486
  source_system: "openclaw",
@@ -128,7 +490,7 @@ function handleMessageProcessed(evt: Extract<DiagnosticEventPayload, { type: "me
128
490
  actor_type: "agent",
129
491
  correlation_id: evt.sessionId ?? null,
130
492
  payload: {
131
- session_id: evt.sessionId ?? evt.sessionKey ?? nextEventId(),
493
+ session_id: evt.sessionId ?? evt.sessionKey ?? nextId("sess"),
132
494
  agent_id: agentId,
133
495
  channel: evt.channel ?? "unknown",
134
496
  status: evt.outcome === "error" ? "failed" : "completed",
@@ -137,7 +499,7 @@ function handleMessageProcessed(evt: Extract<DiagnosticEventPayload, { type: "me
137
499
  },
138
500
  };
139
501
 
140
- postToIngest([event]);
502
+ void postToIngest([event]);
141
503
  }
142
504
 
143
505
  function handleSessionState(evt: Extract<DiagnosticEventPayload, { type: "session.state" }>) {
@@ -151,7 +513,7 @@ function handleSessionState(evt: Extract<DiagnosticEventPayload, { type: "sessio
151
513
  if (!eventType) return;
152
514
 
153
515
  const event = {
154
- event_id: nextEventId(),
516
+ event_id: nextId("mc-bridge"),
155
517
  event_type: eventType,
156
518
  event_version: "1.0",
157
519
  source_system: "openclaw",
@@ -161,20 +523,20 @@ function handleSessionState(evt: Extract<DiagnosticEventPayload, { type: "sessio
161
523
  actor_type: "agent",
162
524
  correlation_id: evt.sessionId ?? null,
163
525
  payload: {
164
- session_id: evt.sessionId ?? evt.sessionKey ?? nextEventId(),
526
+ session_id: evt.sessionId ?? evt.sessionKey ?? nextId("sess"),
165
527
  agent_id: agentId,
166
528
  state: evt.state,
167
529
  prev_state: evt.prevState ?? null,
168
530
  },
169
531
  };
170
532
 
171
- postToIngest([event]);
533
+ void postToIngest([event]);
172
534
  }
173
535
 
174
536
  const plugin = {
175
537
  id: "mission-control-bridge",
176
538
  name: "Mission Control Bridge",
177
- description: "Forwards OpenClaw diagnostic events to the A.L.I.C.E. Mission Control ingest endpoint",
539
+ description: "Forwards OpenClaw diagnostic events to A.L.I.C.E. Mission Control and executes hosted runtime commands",
178
540
  configSchema: emptyPluginConfigSchema(),
179
541
 
180
542
  register(api: OpenClawPluginApi) {
@@ -183,6 +545,10 @@ const plugin = {
183
545
 
184
546
  async start(ctx) {
185
547
  ctx.logger.info(`[mc-bridge] starting — ingest URL: ${INGEST_URL}`);
548
+ ctx.logger.info(`[mc-bridge] runtime worker — commands URL: ${COMMANDS_URL}`);
549
+
550
+ let pollInFlight = false;
551
+ let stopped = false;
186
552
 
187
553
  const unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
188
554
  try {
@@ -202,11 +568,25 @@ const plugin = {
202
568
  }
203
569
  });
204
570
 
205
- (this as { _unsub?: () => void })._unsub = unsubscribe;
571
+ const pollCommands = async () => {
572
+ if (stopped || pollInFlight || !WORKER_TOKEN) return;
573
+ pollInFlight = true;
574
+ try {
575
+ const commands = await listQueuedCommands(ctx.logger);
576
+ for (const command of commands) {
577
+ if (!(await leaseCommand(command.id, ctx.logger))) continue;
578
+ await executeCommand(command, ctx.logger);
579
+ }
580
+ } finally {
581
+ pollInFlight = false;
582
+ }
583
+ };
206
584
 
585
+ await registerNode(ctx.logger);
586
+ await heartbeatNode(ctx.logger);
207
587
  await postToIngest([
208
588
  {
209
- event_id: nextEventId(),
589
+ event_id: nextId("mc-bridge"),
210
590
  event_type: "node.registered",
211
591
  event_version: "1.0",
212
592
  source_system: "openclaw",
@@ -214,17 +594,78 @@ const plugin = {
214
594
  occurred_at: now(),
215
595
  payload: {
216
596
  node_name: SOURCE_NODE,
597
+ node_id: NODE_ID,
598
+ install_id: INSTALL_ID,
217
599
  platform: process.platform,
218
600
  node_version: process.version,
601
+ gateway_url: GATEWAY_URL,
219
602
  },
220
603
  },
221
604
  ]);
605
+
606
+ const heartbeatTimer = setInterval(() => {
607
+ void heartbeatNode(ctx.logger);
608
+ }, HEARTBEAT_INTERVAL_MS);
609
+
610
+ const pollTimer = setInterval(() => {
611
+ void pollCommands();
612
+ }, COMMAND_POLL_INTERVAL_MS);
613
+
614
+ await pollCommands();
615
+
616
+ (
617
+ this as {
618
+ _unsub?: () => void;
619
+ _heartbeatTimer?: ReturnType<typeof setInterval>;
620
+ _pollTimer?: ReturnType<typeof setInterval>;
621
+ _stop?: () => void;
622
+ }
623
+ )._unsub = unsubscribe;
624
+ (
625
+ this as {
626
+ _unsub?: () => void;
627
+ _heartbeatTimer?: ReturnType<typeof setInterval>;
628
+ _pollTimer?: ReturnType<typeof setInterval>;
629
+ _stop?: () => void;
630
+ }
631
+ )._heartbeatTimer = heartbeatTimer;
632
+ (
633
+ this as {
634
+ _unsub?: () => void;
635
+ _heartbeatTimer?: ReturnType<typeof setInterval>;
636
+ _pollTimer?: ReturnType<typeof setInterval>;
637
+ _stop?: () => void;
638
+ }
639
+ )._pollTimer = pollTimer;
640
+ (
641
+ this as {
642
+ _unsub?: () => void;
643
+ _heartbeatTimer?: ReturnType<typeof setInterval>;
644
+ _pollTimer?: ReturnType<typeof setInterval>;
645
+ _stop?: () => void;
646
+ }
647
+ )._stop = () => {
648
+ stopped = true;
649
+ clearInterval(heartbeatTimer);
650
+ clearInterval(pollTimer);
651
+ };
222
652
  },
223
653
 
224
654
  async stop() {
225
- const self = this as { _unsub?: () => void };
655
+ const self = this as {
656
+ _unsub?: () => void;
657
+ _heartbeatTimer?: ReturnType<typeof setInterval>;
658
+ _pollTimer?: ReturnType<typeof setInterval>;
659
+ _stop?: () => void;
660
+ };
226
661
  self._unsub?.();
662
+ self._stop?.();
663
+ if (self._heartbeatTimer) clearInterval(self._heartbeatTimer);
664
+ if (self._pollTimer) clearInterval(self._pollTimer);
227
665
  self._unsub = undefined;
666
+ self._heartbeatTimer = undefined;
667
+ self._pollTimer = undefined;
668
+ self._stop = undefined;
228
669
  },
229
670
  });
230
671
  },