@openclaw-cloud/agent-controller 0.1.8 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CLAUDE.md +9 -8
  2. package/__tests__/api.test.ts +60 -0
  3. package/__tests__/board-handler.test.ts +2 -0
  4. package/__tests__/connection.test.ts +25 -4
  5. package/__tests__/heartbeat.test.ts +22 -29
  6. package/bin/agent-controller.js +6 -0
  7. package/dist/api.d.ts +2 -0
  8. package/dist/api.js +12 -0
  9. package/dist/api.js.map +1 -1
  10. package/dist/commands/install.d.ts +1 -0
  11. package/dist/commands/install.js +45 -0
  12. package/dist/commands/install.js.map +1 -0
  13. package/dist/commands/uninstall.d.ts +1 -0
  14. package/dist/commands/uninstall.js +22 -0
  15. package/dist/commands/uninstall.js.map +1 -0
  16. package/dist/connection.d.ts +2 -0
  17. package/dist/connection.js +3 -3
  18. package/dist/connection.js.map +1 -1
  19. package/dist/heartbeat.d.ts +2 -2
  20. package/dist/heartbeat.js +4 -29
  21. package/dist/heartbeat.js.map +1 -1
  22. package/dist/index.js +28 -41
  23. package/dist/index.js.map +1 -1
  24. package/dist/platform/linux.d.ts +8 -0
  25. package/dist/platform/linux.js +96 -0
  26. package/dist/platform/linux.js.map +1 -0
  27. package/dist/platform/macos.d.ts +8 -0
  28. package/dist/platform/macos.js +109 -0
  29. package/dist/platform/macos.js.map +1 -0
  30. package/dist/platform/windows.d.ts +8 -0
  31. package/dist/platform/windows.js +77 -0
  32. package/dist/platform/windows.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/api.ts +14 -0
  35. package/src/commands/install.ts +50 -0
  36. package/src/commands/uninstall.ts +19 -0
  37. package/src/connection.ts +5 -3
  38. package/src/heartbeat.ts +6 -30
  39. package/src/index.ts +29 -42
  40. package/src/platform/linux.ts +108 -0
  41. package/src/platform/macos.ts +122 -0
  42. package/src/platform/windows.ts +92 -0
package/src/index.ts CHANGED
@@ -22,14 +22,12 @@ export function main(): void {
22
22
  const token = requireEnv('AGENT_TOKEN');
23
23
  const agentId = requireEnv('AGENT_ID');
24
24
  const backendUrl = requireEnv('BACKEND_INTERNAL_URL');
25
- const centrifugoInternalUrl = process.env.CENTRIFUGO_INTERNAL_URL || '';
26
- const centrifugoApiKey = process.env.CENTRIFUGO_API_KEY || '';
27
-
28
25
  if (DEBUG) console.log('[debug] Debug mode enabled');
29
26
  console.log(`Starting agent-controller for agent: ${agentId}`);
30
27
  console.log(`Backend URL: ${backendUrl} (JWT mode)`);
31
28
 
32
- const { client } = createConnection({ url, token, agentId, backendUrl });
29
+ const api = createAgentApi(backendUrl, token);
30
+ const { client } = createConnection({ url, token, agentId, backendUrl, api });
33
31
 
34
32
  // Chat provider via OpenClaw Gateway WS
35
33
  const gatewayWsUrl = process.env.OPENCLAW_GATEWAY_WS || 'ws://localhost:18789';
@@ -46,50 +44,39 @@ export function main(): void {
46
44
  console.warn('OPENCLAW_GATEWAY_TOKEN not set, chat provider disabled');
47
45
  }
48
46
 
49
- // Board handler (optional — requires BACKEND_URL and AGENT_TOKEN)
50
- let boardHandler: BoardHandler | null = null;
51
- const apiBaseUrl = process.env.BACKEND_URL || backendUrl;
52
- if (apiBaseUrl && token) {
53
- const api = createAgentApi(apiBaseUrl, token);
54
- boardHandler = new BoardHandler(api);
47
+ // Board handler
48
+ const boardHandler = new BoardHandler(api);
55
49
 
56
- // Initialize board handler after connection is established
57
- boardHandler.initialize().then((workspaceId) => {
58
- if (!workspaceId) {
59
- console.log('No board found for this agent, board handler dormant');
60
- return;
61
- }
50
+ // Initialize board handler after connection is established
51
+ boardHandler.initialize().then((workspaceId) => {
52
+ if (!workspaceId) {
53
+ console.log('No board found for this agent, board handler dormant');
54
+ return;
55
+ }
62
56
 
63
- const boardChannel = `board:${workspaceId}`;
64
- const boardSub = client.newSubscription(boardChannel);
57
+ const boardChannel = `board:${workspaceId}`;
58
+ const boardSub = client.newSubscription(boardChannel);
65
59
 
66
- boardSub.on('publication', (ctx) => {
67
- const event = ctx.data as BoardEvent;
68
- if (!event || !event.event) return;
69
- boardHandler!.onBoardEvent(event).catch((err) => {
70
- console.error('Board event handler error:', err instanceof Error ? err.message : err);
71
- });
60
+ boardSub.on('publication', (ctx) => {
61
+ const event = ctx.data as BoardEvent;
62
+ if (!event || !event.event) return;
63
+ boardHandler.onBoardEvent(event).catch((err) => {
64
+ console.error('Board event handler error:', err instanceof Error ? err.message : err);
72
65
  });
73
-
74
- boardSub.subscribe();
75
- console.log(`Subscribed to board channel: ${boardChannel}`);
76
- }).catch((err) => {
77
- console.error('Board handler init error:', err instanceof Error ? err.message : err);
78
66
  });
79
- }
80
67
 
81
- let heartbeatTimer: NodeJS.Timeout | undefined;
82
- if (centrifugoInternalUrl && centrifugoApiKey) {
83
- console.log(`Heartbeat via Centrifugo internal API: ${centrifugoInternalUrl}`);
84
- heartbeatTimer = startHeartbeat({
85
- centrifugoInternalUrl,
86
- centrifugoApiKey,
87
- agentId,
88
- ...(boardHandler ? { getBoardStatus: () => boardHandler!.getBoardStatus() } : {}),
89
- });
90
- } else {
91
- console.warn('CENTRIFUGO_INTERNAL_URL or CENTRIFUGO_API_KEY not set, heartbeat disabled');
92
- }
68
+ boardSub.subscribe();
69
+ console.log(`Subscribed to board channel: ${boardChannel}`);
70
+ }).catch((err) => {
71
+ console.error('Board handler init error:', err instanceof Error ? err.message : err);
72
+ });
73
+
74
+ console.log('Heartbeat via backend API');
75
+ const heartbeatTimer = startHeartbeat({
76
+ api,
77
+ agentId,
78
+ getBoardStatus: () => boardHandler.getBoardStatus(),
79
+ });
93
80
 
94
81
  const shutdown = () => {
95
82
  console.log('Shutting down...');
@@ -0,0 +1,108 @@
1
+ import { exec } from 'node:child_process';
2
+ import { writeFileSync, mkdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export interface PlatformConfig {
7
+ centrifugoUrl: string;
8
+ agentToken: string;
9
+ agentId: string;
10
+ backendInternalUrl: string;
11
+ }
12
+
13
+ function execAsync(cmd: string): Promise<string> {
14
+ return new Promise((resolve, reject) => {
15
+ exec(cmd, (err, stdout) => {
16
+ if (err) reject(err);
17
+ else resolve(stdout.toString().trim());
18
+ });
19
+ });
20
+ }
21
+
22
+ async function which(bin: string): Promise<string> {
23
+ try {
24
+ return await execAsync(`which "${bin}"`);
25
+ } catch {
26
+ return '';
27
+ }
28
+ }
29
+
30
+ export async function installLinux(config: PlatformConfig): Promise<void> {
31
+ const home = homedir();
32
+ const systemdDir = join(home, '.config', 'systemd', 'user');
33
+ const servicePath = join(systemdDir, 'agent-controller.service');
34
+
35
+ const nodePath = process.execPath;
36
+ let agentControllerPath = await which('agent-controller');
37
+ if (!agentControllerPath) {
38
+ agentControllerPath = join(nodePath, '..', 'agent-controller');
39
+ }
40
+
41
+ mkdirSync(systemdDir, { recursive: true });
42
+
43
+ const service = `[Unit]
44
+ Description=OpenClaw Agent Controller
45
+ After=network.target
46
+
47
+ [Service]
48
+ Type=simple
49
+ Restart=always
50
+ RestartSec=10
51
+ Environment=CENTRIFUGO_URL=${config.centrifugoUrl}
52
+ Environment=AGENT_TOKEN=${config.agentToken}
53
+ Environment=AGENT_ID=${config.agentId}
54
+ Environment=BACKEND_INTERNAL_URL=${config.backendInternalUrl}
55
+ ExecStart="${nodePath}" "${agentControllerPath}"
56
+
57
+ [Install]
58
+ WantedBy=default.target
59
+ `;
60
+
61
+ writeFileSync(servicePath, service, 'utf8');
62
+ console.log(`Written: ${servicePath}`);
63
+
64
+ try {
65
+ await execAsync('systemctl --user daemon-reload');
66
+ console.log('systemd daemon reloaded.');
67
+ await execAsync('systemctl --user enable --now agent-controller');
68
+ console.log('Service enabled and started.');
69
+ } catch (err) {
70
+ console.warn('systemctl command failed:', err instanceof Error ? err.message : err);
71
+ }
72
+
73
+ console.log('');
74
+ console.log('Agent Controller installed as a systemd user service.');
75
+ console.log('');
76
+ console.log('Useful commands:');
77
+ console.log(' Start: systemctl --user start agent-controller');
78
+ console.log(' Stop: systemctl --user stop agent-controller');
79
+ console.log(' Status: systemctl --user status agent-controller');
80
+ console.log(' Logs: journalctl --user -u agent-controller -f');
81
+ console.log(' Uninstall: agent-controller uninstall');
82
+ }
83
+
84
+ export async function uninstallLinux(): Promise<void> {
85
+ try {
86
+ await execAsync('systemctl --user disable --now agent-controller');
87
+ console.log('Service disabled and stopped.');
88
+ } catch (err) {
89
+ console.warn('systemctl disable failed (may not be enabled):', err instanceof Error ? err.message : err);
90
+ }
91
+
92
+ const servicePath = join(homedir(), '.config', 'systemd', 'user', 'agent-controller.service');
93
+ const { unlinkSync, existsSync } = await import('node:fs');
94
+ if (existsSync(servicePath)) {
95
+ unlinkSync(servicePath);
96
+ console.log(`Removed: ${servicePath}`);
97
+ } else {
98
+ console.log(`Service file not found: ${servicePath}`);
99
+ }
100
+
101
+ try {
102
+ await execAsync('systemctl --user daemon-reload');
103
+ } catch {
104
+ // best-effort
105
+ }
106
+
107
+ console.log('Agent Controller uninstalled.');
108
+ }
@@ -0,0 +1,122 @@
1
+ import { exec } from 'node:child_process';
2
+ import { writeFileSync, mkdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export interface PlatformConfig {
7
+ centrifugoUrl: string;
8
+ agentToken: string;
9
+ agentId: string;
10
+ backendInternalUrl: string;
11
+ }
12
+
13
+ function execAsync(cmd: string): Promise<string> {
14
+ return new Promise((resolve, reject) => {
15
+ exec(cmd, (err, stdout) => {
16
+ if (err) reject(err);
17
+ else resolve(stdout.toString().trim());
18
+ });
19
+ });
20
+ }
21
+
22
+ async function which(bin: string): Promise<string> {
23
+ try {
24
+ return await execAsync(`which "${bin}"`);
25
+ } catch {
26
+ return '';
27
+ }
28
+ }
29
+
30
+ export async function installMacOS(config: PlatformConfig): Promise<void> {
31
+ const home = homedir();
32
+ const launchAgentsDir = join(home, 'Library', 'LaunchAgents');
33
+ const plistPath = join(launchAgentsDir, 'com.openclaw.agent-controller.plist');
34
+ const logDir = join(home, 'Library', 'Logs');
35
+ const logFile = join(logDir, 'agent-controller.log');
36
+ const errFile = join(logDir, 'agent-controller.err');
37
+
38
+ const nodePath = process.execPath;
39
+ let agentControllerPath = await which('agent-controller');
40
+ if (!agentControllerPath) {
41
+ // Fallback: derive from node path
42
+ agentControllerPath = join(nodePath, '..', 'agent-controller');
43
+ }
44
+
45
+ mkdirSync(launchAgentsDir, { recursive: true });
46
+
47
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
48
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
49
+ <plist version="1.0">
50
+ <dict>
51
+ <key>Label</key>
52
+ <string>com.openclaw.agent-controller</string>
53
+ <key>ProgramArguments</key>
54
+ <array>
55
+ <string>${nodePath}</string>
56
+ <string>${agentControllerPath}</string>
57
+ </array>
58
+ <key>EnvironmentVariables</key>
59
+ <dict>
60
+ <key>CENTRIFUGO_URL</key>
61
+ <string>${config.centrifugoUrl}</string>
62
+ <key>AGENT_TOKEN</key>
63
+ <string>${config.agentToken}</string>
64
+ <key>AGENT_ID</key>
65
+ <string>${config.agentId}</string>
66
+ <key>BACKEND_INTERNAL_URL</key>
67
+ <string>${config.backendInternalUrl}</string>
68
+ </dict>
69
+ <key>RunAtLoad</key>
70
+ <true/>
71
+ <key>KeepAlive</key>
72
+ <true/>
73
+ <key>StandardOutPath</key>
74
+ <string>${logFile}</string>
75
+ <key>StandardErrorPath</key>
76
+ <string>${errFile}</string>
77
+ </dict>
78
+ </plist>
79
+ `;
80
+
81
+ writeFileSync(plistPath, plist, 'utf8');
82
+ console.log(`Written: ${plistPath}`);
83
+
84
+ try {
85
+ await execAsync(`launchctl load -w "${plistPath}"`);
86
+ console.log('Service loaded via launchctl.');
87
+ } catch (err) {
88
+ console.warn('launchctl load failed (may already be loaded):', err instanceof Error ? err.message : err);
89
+ }
90
+
91
+ console.log('');
92
+ console.log('Agent Controller installed as a macOS LaunchAgent.');
93
+ console.log('');
94
+ console.log('Useful commands:');
95
+ console.log(` Start: launchctl load -w "${plistPath}"`);
96
+ console.log(` Stop: launchctl unload "${plistPath}"`);
97
+ console.log(` Logs: tail -f "${logFile}"`);
98
+ console.log(` Errors: tail -f "${errFile}"`);
99
+ console.log(` Uninstall: agent-controller uninstall`);
100
+ }
101
+
102
+ export async function uninstallMacOS(): Promise<void> {
103
+ const home = homedir();
104
+ const plistPath = join(home, 'Library', 'LaunchAgents', 'com.openclaw.agent-controller.plist');
105
+
106
+ try {
107
+ await execAsync(`launchctl unload "${plistPath}"`);
108
+ console.log('Service unloaded via launchctl.');
109
+ } catch (err) {
110
+ console.warn('launchctl unload failed (may not be loaded):', err instanceof Error ? err.message : err);
111
+ }
112
+
113
+ const { unlinkSync, existsSync } = await import('node:fs');
114
+ if (existsSync(plistPath)) {
115
+ unlinkSync(plistPath);
116
+ console.log(`Removed: ${plistPath}`);
117
+ } else {
118
+ console.log(`Plist not found: ${plistPath}`);
119
+ }
120
+
121
+ console.log('Agent Controller uninstalled.');
122
+ }
@@ -0,0 +1,92 @@
1
+ import { exec } from 'node:child_process';
2
+ import { writeFileSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export interface PlatformConfig {
6
+ centrifugoUrl: string;
7
+ agentToken: string;
8
+ agentId: string;
9
+ backendInternalUrl: string;
10
+ }
11
+
12
+ function execAsync(cmd: string): Promise<string> {
13
+ return new Promise((resolve, reject) => {
14
+ exec(cmd, (err, stdout) => {
15
+ if (err) reject(err);
16
+ else resolve(stdout.toString().trim());
17
+ });
18
+ });
19
+ }
20
+
21
+ async function which(bin: string): Promise<string> {
22
+ try {
23
+ return await execAsync(`where "${bin}"`);
24
+ } catch {
25
+ return '';
26
+ }
27
+ }
28
+
29
+ export async function installWindows(config: PlatformConfig): Promise<void> {
30
+ const appData = process.env['APPDATA'] ?? join(process.env['USERPROFILE'] ?? 'C:\\Users\\Default', 'AppData', 'Roaming');
31
+ const openclawDir = join(appData, 'openclaw');
32
+ const wrapperPath = join(openclawDir, 'agent-controller.cmd');
33
+
34
+ const nodePath = process.execPath;
35
+ let agentControllerPath = await which('agent-controller');
36
+ if (!agentControllerPath) {
37
+ agentControllerPath = join(nodePath, '..', 'agent-controller');
38
+ }
39
+
40
+ mkdirSync(openclawDir, { recursive: true });
41
+
42
+ const wrapper = `@echo off
43
+ set CENTRIFUGO_URL=${config.centrifugoUrl}
44
+ set AGENT_TOKEN=${config.agentToken}
45
+ set AGENT_ID=${config.agentId}
46
+ set BACKEND_INTERNAL_URL=${config.backendInternalUrl}
47
+ "${nodePath}" "${agentControllerPath}"
48
+ `;
49
+
50
+ writeFileSync(wrapperPath, wrapper, 'utf8');
51
+ console.log(`Written: ${wrapperPath}`);
52
+
53
+ try {
54
+ await execAsync(
55
+ `schtasks /create /tn "OpenClaw Agent Controller" /tr "${wrapperPath}" /sc ONLOGON /rl HIGHEST /f`
56
+ );
57
+ console.log('Scheduled task created.');
58
+ } catch (err) {
59
+ console.warn('schtasks create failed:', err instanceof Error ? err.message : err);
60
+ }
61
+
62
+ console.log('');
63
+ console.log('Agent Controller installed as a Windows scheduled task (ONLOGON).');
64
+ console.log('');
65
+ console.log('Useful commands:');
66
+ console.log(' Start: schtasks /run /tn "OpenClaw Agent Controller"');
67
+ console.log(' Stop: schtasks /end /tn "OpenClaw Agent Controller"');
68
+ console.log(' Uninstall: agent-controller uninstall');
69
+ console.log(` Wrapper: ${wrapperPath}`);
70
+ }
71
+
72
+ export async function uninstallWindows(): Promise<void> {
73
+ try {
74
+ await execAsync('schtasks /delete /tn "OpenClaw Agent Controller" /f');
75
+ console.log('Scheduled task deleted.');
76
+ } catch (err) {
77
+ console.warn('schtasks delete failed (may not exist):', err instanceof Error ? err.message : err);
78
+ }
79
+
80
+ const appData = process.env['APPDATA'] ?? join(process.env['USERPROFILE'] ?? 'C:\\Users\\Default', 'AppData', 'Roaming');
81
+ const wrapperPath = join(appData, 'openclaw', 'agent-controller.cmd');
82
+
83
+ const { unlinkSync, existsSync } = await import('node:fs');
84
+ if (existsSync(wrapperPath)) {
85
+ unlinkSync(wrapperPath);
86
+ console.log(`Removed: ${wrapperPath}`);
87
+ } else {
88
+ console.log(`Wrapper not found: ${wrapperPath}`);
89
+ }
90
+
91
+ console.log('Agent Controller uninstalled.');
92
+ }