@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 +81 -19
- package/lib/mission-control.mjs +19 -0
- package/package.json +1 -1
- package/templates/mission-control-bridge/index.ts +473 -32
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 =
|
|
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
|
-
|
|
211
|
-
|
|
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.
|
|
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 {
|
package/lib/mission-control.mjs
CHANGED
|
@@ -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
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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():
|
|
57
|
+
function getCloudConfig(): JsonRecord {
|
|
22
58
|
const fileConfig = readMissionControlConfig();
|
|
23
|
-
return typeof fileConfig.cloud === "object" && fileConfig.cloud
|
|
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
|
|
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
|
|
47
|
-
return
|
|
125
|
+
function nextId(prefix: string): string {
|
|
126
|
+
return `${prefix}-${Date.now()}-${++seq}`;
|
|
48
127
|
}
|
|
49
128
|
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 ??
|
|
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:
|
|
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 ??
|
|
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:
|
|
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 ??
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
},
|