@robbiesrobotics/alice-agents 1.4.3 → 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/README.md +21 -0
- package/bin/alice-install.mjs +27 -2
- package/lib/doctor.mjs +81 -19
- package/lib/installer.mjs +69 -3
- package/lib/manifest.mjs +1 -0
- package/lib/mission-control.mjs +169 -0
- package/lib/prompter.mjs +16 -0
- package/package.json +2 -1
- package/templates/mission-control-bridge/index.ts +674 -0
- package/templates/mission-control-bridge/openclaw.plugin.json +8 -0
- package/templates/mission-control-bridge/package.json +11 -0
package/README.md
CHANGED
|
@@ -18,6 +18,11 @@ That's it. The installer detects your runtime (NemoClaw or OpenClaw) and sets ev
|
|
|
18
18
|
|
|
19
19
|
**Starter** includes 10 agents. **Pro** unlocks 18 more — [sign up at getalice.av3.ai](https://getalice.av3.ai/signup?plan=pro)
|
|
20
20
|
|
|
21
|
+
**Mission Control Cloud** is available as a Pro add-on. If enabled during install, the package now:
|
|
22
|
+
- installs the `mission-control-bridge` plugin into your OpenClaw home
|
|
23
|
+
- writes a portable local Mission Control config at `~/.openclaw/.alice-mission-control.json`
|
|
24
|
+
- enables the bridge in `openclaw.json` so your runtime can forward live telemetry to Mission Control
|
|
25
|
+
|
|
21
26
|
An orchestrator (A.L.I.C.E., also addressable as Alice or Olivia) backed by specialist agents across every domain:
|
|
22
27
|
|
|
23
28
|
| Agent | Domain | Emoji | Tier |
|
|
@@ -77,6 +82,9 @@ npx @robbiesrobotics/alice-agents
|
|
|
77
82
|
# Non-interactive with defaults (detected model if available, otherwise Sonnet; Starter tier)
|
|
78
83
|
npx @robbiesrobotics/alice-agents --yes
|
|
79
84
|
|
|
85
|
+
# Non-interactive Pro install with Mission Control Cloud enabled
|
|
86
|
+
npx @robbiesrobotics/alice-agents --cloud --cloud-token YOUR_TOKEN
|
|
87
|
+
|
|
80
88
|
# Show help
|
|
81
89
|
npx @robbiesrobotics/alice-agents --help
|
|
82
90
|
```
|
|
@@ -87,6 +95,19 @@ npx @robbiesrobotics/alice-agents --help
|
|
|
87
95
|
- **Merge** — Adds A.L.I.C.E. agents alongside your existing agents
|
|
88
96
|
- **Upgrade** — Updates product files (SOUL.md, AGENTS.md, etc.) without touching user customizations
|
|
89
97
|
|
|
98
|
+
### Mission Control Cloud
|
|
99
|
+
|
|
100
|
+
If you're a Pro user with the cloud add-on, the installer can configure your local runtime for Mission Control in the same pass.
|
|
101
|
+
|
|
102
|
+
- Interactive install: choose `Pro`, validate your license, then enable the Mission Control Cloud add-on when prompted
|
|
103
|
+
- Non-interactive install: pass `--cloud`
|
|
104
|
+
- Optional flags:
|
|
105
|
+
- `--cloud-token <token>` — access or ingest token for authenticated telemetry
|
|
106
|
+
- `--cloud-dashboard-url <url>` — defaults to `https://alice.av3.ai`
|
|
107
|
+
- `--cloud-ingest-url <url>` — defaults to `<dashboard-url>/api/v1/ingest`
|
|
108
|
+
|
|
109
|
+
The cloud config is stored in `~/.openclaw/.alice-mission-control.json`, and the bundled bridge plugin reads from that file so the setup remains portable across macOS, Linux, and Windows.
|
|
110
|
+
|
|
90
111
|
## Upgrade
|
|
91
112
|
|
|
92
113
|
Re-run the installer and choose "Upgrade":
|
package/bin/alice-install.mjs
CHANGED
|
@@ -9,6 +9,12 @@ import { runSkillsManager } from '../lib/skills.mjs';
|
|
|
9
9
|
const args = process.argv.slice(2);
|
|
10
10
|
const flags = new Set(args);
|
|
11
11
|
|
|
12
|
+
function getFlagValue(name) {
|
|
13
|
+
const idx = args.indexOf(name);
|
|
14
|
+
if (idx === -1) return undefined;
|
|
15
|
+
return args[idx + 1];
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
if (flags.has('--version') || flags.has('-v')) {
|
|
13
19
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url)));
|
|
14
20
|
console.log(pkg.version);
|
|
@@ -26,6 +32,7 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
26
32
|
npx @robbiesrobotics/alice-agents --uninstall Remove A.L.I.C.E. agents from config
|
|
27
33
|
npx @robbiesrobotics/alice-agents --doctor Run diagnostics on your A.L.I.C.E. install
|
|
28
34
|
npx @robbiesrobotics/alice-agents --skills Manage skills (install, remove, browse)
|
|
35
|
+
npx @robbiesrobotics/alice-agents --cloud Enable Mission Control Cloud during install
|
|
29
36
|
npx @robbiesrobotics/alice-agents --version Show version
|
|
30
37
|
npx @robbiesrobotics/alice-agents --help Show this help
|
|
31
38
|
|
|
@@ -34,6 +41,11 @@ if (flags.has('--help') || flags.has('-h')) {
|
|
|
34
41
|
--update Non-interactive upgrade (alias for --yes with upgrade mode)
|
|
35
42
|
--uninstall Remove A.L.I.C.E. agents (preserves non-ALICE agents)
|
|
36
43
|
--doctor Run diagnostics and check install health
|
|
44
|
+
--cloud Enable Mission Control Cloud setup during install
|
|
45
|
+
--no-cloud Skip Mission Control Cloud setup during install
|
|
46
|
+
--cloud-token <token> Mission Control ingest/access token
|
|
47
|
+
--cloud-dashboard-url <url> Mission Control dashboard URL
|
|
48
|
+
--cloud-ingest-url <url> Mission Control ingest endpoint
|
|
37
49
|
--version Print package version
|
|
38
50
|
`);
|
|
39
51
|
process.exit(0);
|
|
@@ -45,7 +57,14 @@ if (flags.has('--doctor')) {
|
|
|
45
57
|
process.exit(1);
|
|
46
58
|
});
|
|
47
59
|
} else if (flags.has('--update')) {
|
|
48
|
-
runInstall({
|
|
60
|
+
runInstall({
|
|
61
|
+
yes: true,
|
|
62
|
+
modeOverride: 'upgrade',
|
|
63
|
+
cloud: flags.has('--cloud') ? true : flags.has('--no-cloud') ? false : undefined,
|
|
64
|
+
cloudToken: getFlagValue('--cloud-token'),
|
|
65
|
+
cloudDashboardUrl: getFlagValue('--cloud-dashboard-url'),
|
|
66
|
+
cloudIngestUrl: getFlagValue('--cloud-ingest-url'),
|
|
67
|
+
}).catch((err) => {
|
|
49
68
|
console.error(' ❌ Update failed:', err.message);
|
|
50
69
|
process.exit(1);
|
|
51
70
|
});
|
|
@@ -60,7 +79,13 @@ if (flags.has('--doctor')) {
|
|
|
60
79
|
process.exit(1);
|
|
61
80
|
});
|
|
62
81
|
} else {
|
|
63
|
-
runInstall({
|
|
82
|
+
runInstall({
|
|
83
|
+
yes: flags.has('--yes'),
|
|
84
|
+
cloud: flags.has('--cloud') ? true : flags.has('--no-cloud') ? false : undefined,
|
|
85
|
+
cloudToken: getFlagValue('--cloud-token'),
|
|
86
|
+
cloudDashboardUrl: getFlagValue('--cloud-dashboard-url'),
|
|
87
|
+
cloudIngestUrl: getFlagValue('--cloud-ingest-url'),
|
|
88
|
+
}).catch((err) => {
|
|
64
89
|
console.error(' ❌ Install failed:', err.message);
|
|
65
90
|
process.exit(1);
|
|
66
91
|
});
|
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/installer.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { homedir } from 'node:os';
|
|
|
6
6
|
import { configExists, mergeConfig, removeAliceAgents, detectAvailableModels } from './config-merger.mjs';
|
|
7
7
|
import { scaffoldAll } from './workspace-scaffolder.mjs';
|
|
8
8
|
import { readManifest, writeManifest } from './manifest.mjs';
|
|
9
|
+
import { configureMissionControlCloud, getDefaultMissionControlSettings } from './mission-control.mjs';
|
|
9
10
|
import {
|
|
10
11
|
promptInstallMode,
|
|
11
12
|
promptUserInfo,
|
|
@@ -13,6 +14,8 @@ import {
|
|
|
13
14
|
promptCustomModel,
|
|
14
15
|
promptTier,
|
|
15
16
|
promptLicenseKey,
|
|
17
|
+
promptCloudAddon,
|
|
18
|
+
promptMissionControlToken,
|
|
16
19
|
confirm,
|
|
17
20
|
choose,
|
|
18
21
|
input,
|
|
@@ -440,6 +443,10 @@ function printBanner() {
|
|
|
440
443
|
}
|
|
441
444
|
|
|
442
445
|
function printSummary(mode, tier, agents, preset, userInfo, detectedModels) {
|
|
446
|
+
return printSummaryWithOptions(mode, tier, agents, preset, userInfo, detectedModels, null);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function printSummaryWithOptions(mode, tier, agents, preset, userInfo, detectedModels, missionControl) {
|
|
443
450
|
const modelLabel =
|
|
444
451
|
preset === 'detected'
|
|
445
452
|
? `${detectedModels?.primary || 'your configured model'} ${dim('(detected)')}`
|
|
@@ -454,6 +461,13 @@ function printSummary(mode, tier, agents, preset, userInfo, detectedModels) {
|
|
|
454
461
|
`${dim('Model:')} ${green(modelLabel)}`,
|
|
455
462
|
`${dim('User:')} ${green(userInfo.name)}`,
|
|
456
463
|
`${dim('Timezone:')} ${green(userInfo.timezone)}`,
|
|
464
|
+
`${dim('Mission Control:')} ${green(missionControl?.enabled ? 'cloud enabled' : 'local only')}`,
|
|
465
|
+
...(missionControl?.enabled
|
|
466
|
+
? [
|
|
467
|
+
`${dim('Dashboard:')} ${green(missionControl.dashboardUrl)}`,
|
|
468
|
+
`${dim('Ingest:')} ${green(missionControl.ingestUrl)}`,
|
|
469
|
+
]
|
|
470
|
+
: []),
|
|
457
471
|
'',
|
|
458
472
|
`${dim('Agents:')}`,
|
|
459
473
|
...agents.map(a => ` ${icons.bullet} ${green(a.emoji)} ${bold(a.name.padEnd(10))} ${dim('─')} ${a.domain}`),
|
|
@@ -465,6 +479,7 @@ function printSummary(mode, tier, agents, preset, userInfo, detectedModels) {
|
|
|
465
479
|
|
|
466
480
|
export async function runInstall(options = {}) {
|
|
467
481
|
const auto = options.yes || false;
|
|
482
|
+
const manifest = readManifest();
|
|
468
483
|
|
|
469
484
|
// Check health flag first (before banner)
|
|
470
485
|
if (process.argv.includes('--health')) {
|
|
@@ -534,14 +549,12 @@ export async function runInstall(options = {}) {
|
|
|
534
549
|
if (options.modeOverride) {
|
|
535
550
|
mode = options.modeOverride;
|
|
536
551
|
} else if (auto) {
|
|
537
|
-
const manifest = readManifest();
|
|
538
552
|
mode = manifest ? 'upgrade' : 'fresh';
|
|
539
553
|
} else {
|
|
540
554
|
mode = await promptInstallMode();
|
|
541
555
|
}
|
|
542
556
|
|
|
543
557
|
if (mode === 'upgrade') {
|
|
544
|
-
const manifest = readManifest();
|
|
545
558
|
if (!manifest) {
|
|
546
559
|
console.log(` ${icons.warn} ${yellow('No previous install found. Switching to fresh install.')}\n`);
|
|
547
560
|
mode = 'fresh';
|
|
@@ -644,10 +657,43 @@ export async function runInstall(options = {}) {
|
|
|
644
657
|
}
|
|
645
658
|
}
|
|
646
659
|
|
|
660
|
+
let missionControl = null;
|
|
661
|
+
const existingMissionControl = manifest?.missionControl;
|
|
662
|
+
if (tier === 'pro') {
|
|
663
|
+
const cloudFlag = options.cloud === true ? true : options.cloud === false ? false : null;
|
|
664
|
+
const enableCloud = auto
|
|
665
|
+
? cloudFlag ?? existingMissionControl?.enabled ?? false
|
|
666
|
+
: cloudFlag ?? await promptCloudAddon();
|
|
667
|
+
|
|
668
|
+
if (enableCloud) {
|
|
669
|
+
const defaults = getDefaultMissionControlSettings();
|
|
670
|
+
const dashboardUrl =
|
|
671
|
+
String(options.cloudDashboardUrl || existingMissionControl?.dashboardUrl || defaults.dashboardUrl).trim();
|
|
672
|
+
const ingestUrl =
|
|
673
|
+
String(options.cloudIngestUrl || existingMissionControl?.ingestUrl || `${dashboardUrl}/api/v1/ingest`).trim();
|
|
674
|
+
const ingestToken =
|
|
675
|
+
auto
|
|
676
|
+
? String(options.cloudToken || '').trim()
|
|
677
|
+
: String(options.cloudToken || await promptMissionControlToken()).trim();
|
|
678
|
+
const sourceNode =
|
|
679
|
+
String(options.cloudSourceNode || existingMissionControl?.sourceNode || defaults.sourceNode).trim();
|
|
680
|
+
|
|
681
|
+
missionControl = {
|
|
682
|
+
enabled: true,
|
|
683
|
+
provider: 'cloud',
|
|
684
|
+
dashboardUrl,
|
|
685
|
+
ingestUrl,
|
|
686
|
+
sourceNode,
|
|
687
|
+
hasIngestToken: !!ingestToken,
|
|
688
|
+
ingestToken,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
647
693
|
const agents = allAgents;
|
|
648
694
|
|
|
649
695
|
// 6. Confirmation
|
|
650
|
-
|
|
696
|
+
printSummaryWithOptions(mode, tier, agents, preset, userInfo, detectedModels, missionControl);
|
|
651
697
|
|
|
652
698
|
if (!auto) {
|
|
653
699
|
const ok = await confirm(' Proceed with installation?');
|
|
@@ -676,6 +722,16 @@ export async function runInstall(options = {}) {
|
|
|
676
722
|
console.log(` ${icons.warn} ${yellow(warning)}`);
|
|
677
723
|
}
|
|
678
724
|
|
|
725
|
+
if (missionControl?.enabled) {
|
|
726
|
+
const missionControlResult = configureMissionControlCloud(missionControl);
|
|
727
|
+
printStepDone('Mission Control cloud', missionControlResult.summary.dashboardUrl);
|
|
728
|
+
if (!missionControlResult.summary.hasIngestToken) {
|
|
729
|
+
printStepSkip('Cloud access token not set', 'bridge installed; add token later to enable authenticated ingest');
|
|
730
|
+
}
|
|
731
|
+
} else {
|
|
732
|
+
printStepSkip('Mission Control cloud', 'not enabled for this install');
|
|
733
|
+
}
|
|
734
|
+
|
|
679
735
|
// Scaffold workspaces
|
|
680
736
|
const results = scaffoldAll(agents, userInfo);
|
|
681
737
|
let newWorkspaces = 0;
|
|
@@ -706,6 +762,16 @@ export async function runInstall(options = {}) {
|
|
|
706
762
|
userName: userInfo.name,
|
|
707
763
|
userTimezone: userInfo.timezone,
|
|
708
764
|
modelPreset: effectivePreset,
|
|
765
|
+
missionControl: missionControl
|
|
766
|
+
? {
|
|
767
|
+
enabled: true,
|
|
768
|
+
provider: 'cloud',
|
|
769
|
+
dashboardUrl: missionControl.dashboardUrl,
|
|
770
|
+
ingestUrl: missionControl.ingestUrl,
|
|
771
|
+
sourceNode: missionControl.sourceNode,
|
|
772
|
+
bridgeInstalled: true,
|
|
773
|
+
}
|
|
774
|
+
: existing?.missionControl || null,
|
|
709
775
|
});
|
|
710
776
|
printStepDone('Manifest written');
|
|
711
777
|
|
package/lib/manifest.mjs
CHANGED
|
@@ -27,6 +27,7 @@ export function writeManifest(data) {
|
|
|
27
27
|
userName: data.userName,
|
|
28
28
|
userTimezone: data.userTimezone,
|
|
29
29
|
modelPreset: data.modelPreset,
|
|
30
|
+
missionControl: data.missionControl || null,
|
|
30
31
|
};
|
|
31
32
|
writeFileSync(getManifestPath(), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
32
33
|
return manifest;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir, hostname } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || join(homedir(), '.openclaw');
|
|
8
|
+
const CONFIG_PATH = join(OPENCLAW_HOME, 'openclaw.json');
|
|
9
|
+
const MC_CONFIG_PATH = join(OPENCLAW_HOME, '.alice-mission-control.json');
|
|
10
|
+
const BRIDGE_ID = 'mission-control-bridge';
|
|
11
|
+
const DEFAULT_DASHBOARD_URL = 'https://alice.av3.ai';
|
|
12
|
+
const DEFAULT_ADMIN_URL = 'https://admin.av3.ai';
|
|
13
|
+
const DEFAULT_INGEST_URL = `${DEFAULT_DASHBOARD_URL}/api/v1/ingest`;
|
|
14
|
+
const TEMPLATE_DIR = join(__dirname, '..', 'templates', 'mission-control-bridge');
|
|
15
|
+
|
|
16
|
+
function normalizeUrl(url, fallback) {
|
|
17
|
+
const value = String(url || fallback || '').trim();
|
|
18
|
+
if (!value) return '';
|
|
19
|
+
return value.replace(/\/+$/, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function atomicWriteJSON(targetPath, data) {
|
|
23
|
+
const tmpPath = `${targetPath}.tmp`;
|
|
24
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
25
|
+
renameSync(tmpPath, targetPath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readJsonFile(path) {
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getMissionControlConfigPath() {
|
|
37
|
+
return MC_CONFIG_PATH;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readMissionControlConfig() {
|
|
41
|
+
return readJsonFile(MC_CONFIG_PATH);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildMissionControlSettings(input = {}) {
|
|
45
|
+
const dashboardUrl = normalizeUrl(input.dashboardUrl, DEFAULT_DASHBOARD_URL);
|
|
46
|
+
const adminUrl = normalizeUrl(input.adminUrl, DEFAULT_ADMIN_URL);
|
|
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`);
|
|
53
|
+
const sourceNode = String(input.sourceNode || hostname() || 'openclaw-local').trim();
|
|
54
|
+
const ingestToken = String(input.ingestToken || '').trim();
|
|
55
|
+
const workerToken = String(input.workerToken || ingestToken || '').trim();
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
enabled: input.enabled !== false,
|
|
59
|
+
provider: 'cloud',
|
|
60
|
+
dashboardUrl,
|
|
61
|
+
adminUrl,
|
|
62
|
+
ingestUrl,
|
|
63
|
+
runtimeBaseUrl,
|
|
64
|
+
adminHeartbeatUrl,
|
|
65
|
+
commandsUrl,
|
|
66
|
+
nodeRegisterUrl,
|
|
67
|
+
nodeHeartbeatUrl,
|
|
68
|
+
sourceNode,
|
|
69
|
+
...(ingestToken ? { ingestToken } : {}),
|
|
70
|
+
...(workerToken ? { workerToken } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function writeMissionControlConfig(input = {}) {
|
|
75
|
+
mkdirSync(OPENCLAW_HOME, { recursive: true });
|
|
76
|
+
|
|
77
|
+
const existing = readMissionControlConfig();
|
|
78
|
+
const settings = buildMissionControlSettings({
|
|
79
|
+
...existing?.cloud,
|
|
80
|
+
...input,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const config = {
|
|
84
|
+
version: 1,
|
|
85
|
+
updatedAt: new Date().toISOString(),
|
|
86
|
+
cloud: settings,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
atomicWriteJSON(MC_CONFIG_PATH, config);
|
|
90
|
+
return { path: MC_CONFIG_PATH, config };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function ensureBridgeFiles(targetPath) {
|
|
94
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
95
|
+
cpSync(TEMPLATE_DIR, targetPath, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function installMissionControlBridge() {
|
|
99
|
+
const sourcePath = join(OPENCLAW_HOME, 'plugins', BRIDGE_ID);
|
|
100
|
+
const installPath = join(OPENCLAW_HOME, 'extensions', BRIDGE_ID);
|
|
101
|
+
|
|
102
|
+
ensureBridgeFiles(sourcePath);
|
|
103
|
+
ensureBridgeFiles(installPath);
|
|
104
|
+
|
|
105
|
+
return { sourcePath, installPath };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function enableMissionControlBridge() {
|
|
109
|
+
const config = readJsonFile(CONFIG_PATH);
|
|
110
|
+
if (!config) {
|
|
111
|
+
throw new Error('OpenClaw config not found. Run openclaw configure first.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const existingInstall = config?.plugins?.installs?.[BRIDGE_ID] || {};
|
|
115
|
+
const { sourcePath, installPath } = installMissionControlBridge();
|
|
116
|
+
const pkg = readJsonFile(join(TEMPLATE_DIR, 'package.json')) || {};
|
|
117
|
+
|
|
118
|
+
config.plugins = config.plugins || {};
|
|
119
|
+
config.plugins.entries = config.plugins.entries || {};
|
|
120
|
+
config.plugins.installs = config.plugins.installs || {};
|
|
121
|
+
|
|
122
|
+
config.plugins.entries[BRIDGE_ID] = {
|
|
123
|
+
...(config.plugins.entries[BRIDGE_ID] || {}),
|
|
124
|
+
enabled: true,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
config.plugins.installs[BRIDGE_ID] = {
|
|
128
|
+
source: 'path',
|
|
129
|
+
sourcePath,
|
|
130
|
+
installPath,
|
|
131
|
+
version: pkg.version || existingInstall.version || '1.0.0',
|
|
132
|
+
installedAt: existingInstall.installedAt || new Date().toISOString(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
atomicWriteJSON(CONFIG_PATH, config);
|
|
136
|
+
return { configPath: CONFIG_PATH, sourcePath, installPath };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function configureMissionControlCloud(input = {}) {
|
|
140
|
+
const configResult = writeMissionControlConfig(input);
|
|
141
|
+
const bridgeResult = enableMissionControlBridge();
|
|
142
|
+
const settings = configResult.config.cloud;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
configPath: configResult.path,
|
|
146
|
+
bridgeSourcePath: bridgeResult.sourcePath,
|
|
147
|
+
bridgeInstallPath: bridgeResult.installPath,
|
|
148
|
+
summary: {
|
|
149
|
+
enabled: settings.enabled,
|
|
150
|
+
provider: settings.provider,
|
|
151
|
+
dashboardUrl: settings.dashboardUrl,
|
|
152
|
+
adminUrl: settings.adminUrl,
|
|
153
|
+
ingestUrl: settings.ingestUrl,
|
|
154
|
+
runtimeBaseUrl: settings.runtimeBaseUrl,
|
|
155
|
+
adminHeartbeatUrl: settings.adminHeartbeatUrl,
|
|
156
|
+
sourceNode: settings.sourceNode,
|
|
157
|
+
hasIngestToken: !!settings.ingestToken,
|
|
158
|
+
hasWorkerToken: !!settings.workerToken,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function getDefaultMissionControlSettings() {
|
|
164
|
+
return buildMissionControlSettings();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function hasMissionControlBridgeInstalled() {
|
|
168
|
+
return existsSync(join(OPENCLAW_HOME, 'extensions', BRIDGE_ID));
|
|
169
|
+
}
|
package/lib/prompter.mjs
CHANGED
|
@@ -125,3 +125,19 @@ export async function promptLicenseKey() {
|
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
127
|
}
|
|
128
|
+
|
|
129
|
+
export async function promptCloudAddon() {
|
|
130
|
+
console.log('');
|
|
131
|
+
console.log(' Mission Control Cloud add-on');
|
|
132
|
+
console.log(' Hosted dashboard, telemetry sync, and cloud memory for Pro users.');
|
|
133
|
+
console.log('');
|
|
134
|
+
return confirm(' Enable the Mission Control Cloud add-on on this machine?', true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function promptMissionControlToken() {
|
|
138
|
+
console.log('');
|
|
139
|
+
console.log(' Enter your Mission Control cloud access token if you have one.');
|
|
140
|
+
console.log(' Press Enter to skip for now — you can add it later without reinstalling.');
|
|
141
|
+
console.log('');
|
|
142
|
+
return input(' Mission Control access token', '');
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robbiesrobotics/alice-agents",
|
|
3
|
-
"version": "1.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"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"tools/",
|
|
24
24
|
"snapshots/",
|
|
25
25
|
"templates/agents-starter.json",
|
|
26
|
+
"templates/mission-control-bridge/",
|
|
26
27
|
"templates/skills/",
|
|
27
28
|
"templates/workspaces/",
|
|
28
29
|
"SELF-HEALING-SPEC.md",
|
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/diagnostics-otel";
|
|
5
|
+
import {
|
|
6
|
+
emptyPluginConfigSchema,
|
|
7
|
+
onDiagnosticEvent,
|
|
8
|
+
} from "openclaw/plugin-sdk/diagnostics-otel";
|
|
9
|
+
import type { DiagnosticEventPayload } from "openclaw/plugin-sdk/diagnostics-otel";
|
|
10
|
+
|
|
11
|
+
const OPENCLAW_HOME = process.env.OPENCLAW_HOME ?? join(homedir(), ".openclaw");
|
|
12
|
+
const MC_CONFIG_PATH = join(OPENCLAW_HOME, ".alice-mission-control.json");
|
|
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;
|
|
22
|
+
|
|
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 {
|
|
49
|
+
try {
|
|
50
|
+
if (!existsSync(MC_CONFIG_PATH)) return {};
|
|
51
|
+
return JSON.parse(readFileSync(MC_CONFIG_PATH, "utf8")) as JsonRecord;
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getCloudConfig(): JsonRecord {
|
|
58
|
+
const fileConfig = readMissionControlConfig();
|
|
59
|
+
return typeof fileConfig.cloud === "object" && fileConfig.cloud
|
|
60
|
+
? (fileConfig.cloud as JsonRecord)
|
|
61
|
+
: {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getString(value: unknown, fallback = ""): string {
|
|
65
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeUrl(value: string): string {
|
|
69
|
+
return value.replace(/\/+$/, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const cloudConfig = getCloudConfig();
|
|
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
|
+
);
|
|
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
|
+
);
|
|
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);
|
|
113
|
+
|
|
114
|
+
function now(): string {
|
|
115
|
+
return new Date().toISOString();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sessionKeyToAgentId(sessionKey?: string): string {
|
|
119
|
+
if (!sessionKey) return "unknown";
|
|
120
|
+
const parts = sessionKey.split(":");
|
|
121
|
+
return parts[1] ?? parts[0] ?? "unknown";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let seq = 0;
|
|
125
|
+
function nextId(prefix: string): string {
|
|
126
|
+
return `${prefix}-${Date.now()}-${++seq}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
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 {
|
|
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
|
+
},
|
|
214
|
+
};
|
|
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)}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
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),
|
|
252
|
+
signal: AbortSignal.timeout(8000),
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
logger.warn(`[mc-bridge] command poll failed ${res.status}: ${await res.text().catch(() => "")}`);
|
|
256
|
+
return [];
|
|
257
|
+
}
|
|
258
|
+
const data = (await res.json()) as { commands?: RuntimeCommand[] };
|
|
259
|
+
return Array.isArray(data.commands) ? data.commands : [];
|
|
260
|
+
} catch (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);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function handleModelUsage(evt: Extract<DiagnosticEventPayload, { type: "model.usage" }>) {
|
|
436
|
+
const agentId = sessionKeyToAgentId(evt.sessionKey);
|
|
437
|
+
const totalTokens = evt.usage.total ?? (evt.usage.input ?? 0) + (evt.usage.output ?? 0);
|
|
438
|
+
|
|
439
|
+
const event = {
|
|
440
|
+
event_id: nextId("mc-bridge"),
|
|
441
|
+
event_type: "agent.session.completed",
|
|
442
|
+
event_version: "1.0",
|
|
443
|
+
source_system: "openclaw",
|
|
444
|
+
source_node: SOURCE_NODE,
|
|
445
|
+
occurred_at: now(),
|
|
446
|
+
actor_id: agentId,
|
|
447
|
+
actor_type: "agent",
|
|
448
|
+
correlation_id: evt.sessionId ?? null,
|
|
449
|
+
payload: {
|
|
450
|
+
session_id: evt.sessionId ?? evt.sessionKey ?? nextId("sess"),
|
|
451
|
+
agent_id: agentId,
|
|
452
|
+
model: evt.model ?? "unknown",
|
|
453
|
+
channel: evt.channel ?? "unknown",
|
|
454
|
+
total_tokens: totalTokens,
|
|
455
|
+
input_tokens: evt.usage.input ?? 0,
|
|
456
|
+
output_tokens: evt.usage.output ?? 0,
|
|
457
|
+
cache_read_tokens: evt.usage.cacheRead ?? 0,
|
|
458
|
+
cache_write_tokens: evt.usage.cacheWrite ?? 0,
|
|
459
|
+
cost_usd: evt.costUsd ?? 0,
|
|
460
|
+
duration_ms: evt.durationMs ?? 0,
|
|
461
|
+
context_limit: evt.context?.limit ?? 0,
|
|
462
|
+
context_used: evt.context?.used ?? 0,
|
|
463
|
+
status: "completed",
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
void postToIngest([event]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function handleMessageProcessed(evt: Extract<DiagnosticEventPayload, { type: "message.processed" }>) {
|
|
471
|
+
if (!evt.sessionKey && !evt.sessionId) return;
|
|
472
|
+
const agentId = sessionKeyToAgentId(evt.sessionKey);
|
|
473
|
+
const eventType =
|
|
474
|
+
evt.outcome === "completed"
|
|
475
|
+
? "agent.session.completed"
|
|
476
|
+
: evt.outcome === "error"
|
|
477
|
+
? "agent.session.completed"
|
|
478
|
+
: null;
|
|
479
|
+
|
|
480
|
+
if (!eventType) return;
|
|
481
|
+
|
|
482
|
+
const event = {
|
|
483
|
+
event_id: nextId("mc-bridge"),
|
|
484
|
+
event_type: eventType,
|
|
485
|
+
event_version: "1.0",
|
|
486
|
+
source_system: "openclaw",
|
|
487
|
+
source_node: SOURCE_NODE,
|
|
488
|
+
occurred_at: now(),
|
|
489
|
+
actor_id: agentId,
|
|
490
|
+
actor_type: "agent",
|
|
491
|
+
correlation_id: evt.sessionId ?? null,
|
|
492
|
+
payload: {
|
|
493
|
+
session_id: evt.sessionId ?? evt.sessionKey ?? nextId("sess"),
|
|
494
|
+
agent_id: agentId,
|
|
495
|
+
channel: evt.channel ?? "unknown",
|
|
496
|
+
status: evt.outcome === "error" ? "failed" : "completed",
|
|
497
|
+
duration_ms: evt.durationMs ?? 0,
|
|
498
|
+
...(evt.error ? { error: evt.error } : {}),
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
void postToIngest([event]);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function handleSessionState(evt: Extract<DiagnosticEventPayload, { type: "session.state" }>) {
|
|
506
|
+
if (!evt.sessionKey && !evt.sessionId) return;
|
|
507
|
+
const agentId = sessionKeyToAgentId(evt.sessionKey);
|
|
508
|
+
const stateToEventType: Record<string, string> = {
|
|
509
|
+
processing: "agent.session.started",
|
|
510
|
+
idle: "agent.session.completed",
|
|
511
|
+
};
|
|
512
|
+
const eventType = stateToEventType[evt.state];
|
|
513
|
+
if (!eventType) return;
|
|
514
|
+
|
|
515
|
+
const event = {
|
|
516
|
+
event_id: nextId("mc-bridge"),
|
|
517
|
+
event_type: eventType,
|
|
518
|
+
event_version: "1.0",
|
|
519
|
+
source_system: "openclaw",
|
|
520
|
+
source_node: SOURCE_NODE,
|
|
521
|
+
occurred_at: now(),
|
|
522
|
+
actor_id: agentId,
|
|
523
|
+
actor_type: "agent",
|
|
524
|
+
correlation_id: evt.sessionId ?? null,
|
|
525
|
+
payload: {
|
|
526
|
+
session_id: evt.sessionId ?? evt.sessionKey ?? nextId("sess"),
|
|
527
|
+
agent_id: agentId,
|
|
528
|
+
state: evt.state,
|
|
529
|
+
prev_state: evt.prevState ?? null,
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
void postToIngest([event]);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const plugin = {
|
|
537
|
+
id: "mission-control-bridge",
|
|
538
|
+
name: "Mission Control Bridge",
|
|
539
|
+
description: "Forwards OpenClaw diagnostic events to A.L.I.C.E. Mission Control and executes hosted runtime commands",
|
|
540
|
+
configSchema: emptyPluginConfigSchema(),
|
|
541
|
+
|
|
542
|
+
register(api: OpenClawPluginApi) {
|
|
543
|
+
api.registerService({
|
|
544
|
+
id: "mission-control-bridge",
|
|
545
|
+
|
|
546
|
+
async start(ctx) {
|
|
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;
|
|
552
|
+
|
|
553
|
+
const unsubscribe = onDiagnosticEvent((evt: DiagnosticEventPayload) => {
|
|
554
|
+
try {
|
|
555
|
+
switch (evt.type) {
|
|
556
|
+
case "model.usage":
|
|
557
|
+
handleModelUsage(evt);
|
|
558
|
+
break;
|
|
559
|
+
case "message.processed":
|
|
560
|
+
handleMessageProcessed(evt);
|
|
561
|
+
break;
|
|
562
|
+
case "session.state":
|
|
563
|
+
handleSessionState(evt);
|
|
564
|
+
break;
|
|
565
|
+
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
ctx.logger.warn(`[mc-bridge] event handler error (${evt.type}): ${String(err)}`);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
|
|
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
|
+
};
|
|
584
|
+
|
|
585
|
+
await registerNode(ctx.logger);
|
|
586
|
+
await heartbeatNode(ctx.logger);
|
|
587
|
+
await postToIngest([
|
|
588
|
+
{
|
|
589
|
+
event_id: nextId("mc-bridge"),
|
|
590
|
+
event_type: "node.registered",
|
|
591
|
+
event_version: "1.0",
|
|
592
|
+
source_system: "openclaw",
|
|
593
|
+
source_node: SOURCE_NODE,
|
|
594
|
+
occurred_at: now(),
|
|
595
|
+
payload: {
|
|
596
|
+
node_name: SOURCE_NODE,
|
|
597
|
+
node_id: NODE_ID,
|
|
598
|
+
install_id: INSTALL_ID,
|
|
599
|
+
platform: process.platform,
|
|
600
|
+
node_version: process.version,
|
|
601
|
+
gateway_url: GATEWAY_URL,
|
|
602
|
+
},
|
|
603
|
+
},
|
|
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
|
+
};
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
async stop() {
|
|
655
|
+
const self = this as {
|
|
656
|
+
_unsub?: () => void;
|
|
657
|
+
_heartbeatTimer?: ReturnType<typeof setInterval>;
|
|
658
|
+
_pollTimer?: ReturnType<typeof setInterval>;
|
|
659
|
+
_stop?: () => void;
|
|
660
|
+
};
|
|
661
|
+
self._unsub?.();
|
|
662
|
+
self._stop?.();
|
|
663
|
+
if (self._heartbeatTimer) clearInterval(self._heartbeatTimer);
|
|
664
|
+
if (self._pollTimer) clearInterval(self._pollTimer);
|
|
665
|
+
self._unsub = undefined;
|
|
666
|
+
self._heartbeatTimer = undefined;
|
|
667
|
+
self._pollTimer = undefined;
|
|
668
|
+
self._stop = undefined;
|
|
669
|
+
},
|
|
670
|
+
});
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
export default plugin;
|