@openagents-org/agent-connector 0.1.0

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/src/daemon.js ADDED
@@ -0,0 +1,541 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn, execSync } = require('child_process');
6
+ const os = require('os');
7
+
8
+ const IS_WINDOWS = process.platform === 'win32';
9
+
10
+ /**
11
+ * Agent process lifecycle manager.
12
+ *
13
+ * Spawns agent subprocesses, monitors them with auto-restart + backoff,
14
+ * writes status to disk, processes commands from daemon.cmd, and handles
15
+ * graceful shutdown.
16
+ *
17
+ * Compatible with the Python SDK's daemon — reads the same config files,
18
+ * writes the same status format, and supports the same command protocol.
19
+ */
20
+ class Daemon {
21
+ constructor(config, envManager, registry) {
22
+ this.config = config;
23
+ this.envManager = envManager;
24
+ this.registry = registry;
25
+
26
+ // State
27
+ this._processes = {}; // agentName → { proc, state, restarts, startedAt, lastError }
28
+ this._stoppedAgents = new Set();
29
+ this._shuttingDown = false;
30
+ this._statusInterval = null;
31
+ this._cmdInterval = null;
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Public API
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Start all configured agents and block until shutdown.
40
+ * Call this from the foreground daemon process.
41
+ */
42
+ async start() {
43
+ const agents = this.config.getAgents();
44
+ for (const agent of agents) {
45
+ this._launchAgent(agent);
46
+ }
47
+
48
+ // Install signal handlers
49
+ const shutdown = () => this.stop();
50
+ process.on('SIGTERM', shutdown);
51
+ process.on('SIGINT', shutdown);
52
+ if (!IS_WINDOWS && process.on) {
53
+ try { process.on('SIGHUP', () => this._reload()); } catch {}
54
+ }
55
+
56
+ // Write PID file
57
+ this._writePid();
58
+
59
+ // Periodic status + command check
60
+ this._statusInterval = setInterval(() => {
61
+ this._writeStatus();
62
+ this._processCommands();
63
+ }, 5000);
64
+
65
+ this._writeStatus();
66
+ this._log(`Daemon started with ${agents.length} agent(s)`);
67
+
68
+ // Block until shutdown
69
+ await new Promise((resolve) => {
70
+ this._shutdownResolve = resolve;
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Gracefully stop all agents and exit.
76
+ */
77
+ async stop() {
78
+ if (this._shuttingDown) return;
79
+ this._shuttingDown = true;
80
+ this._log('Shutting down...');
81
+
82
+ if (this._statusInterval) clearInterval(this._statusInterval);
83
+ if (this._cmdInterval) clearInterval(this._cmdInterval);
84
+
85
+ // Kill all child processes
86
+ const kills = Object.keys(this._processes).map((name) =>
87
+ this._killAgent(name, 5000)
88
+ );
89
+ await Promise.all(kills);
90
+
91
+ this._writeStatus();
92
+ this._cleanupPid();
93
+ this._log('Daemon stopped');
94
+
95
+ if (this._shutdownResolve) this._shutdownResolve();
96
+ }
97
+
98
+ /**
99
+ * Stop a single agent by name.
100
+ */
101
+ async stopAgent(agentName) {
102
+ this._stoppedAgents.add(agentName);
103
+ await this._killAgent(agentName, 5000);
104
+ this._writeStatus();
105
+ }
106
+
107
+ /**
108
+ * Restart a single agent by name.
109
+ */
110
+ async restartAgent(agentName) {
111
+ await this.stopAgent(agentName);
112
+ this._stoppedAgents.delete(agentName);
113
+
114
+ const agent = this.config.getAgent(agentName);
115
+ if (agent) {
116
+ this._launchAgent(agent);
117
+ this._writeStatus();
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Get current status of all agents.
123
+ */
124
+ getStatus() {
125
+ const result = {};
126
+ for (const [name, info] of Object.entries(this._processes)) {
127
+ result[name] = {
128
+ state: info.state,
129
+ type: info.type || 'unknown',
130
+ network: info.network || '(local)',
131
+ restarts: info.restarts,
132
+ started_at: info.startedAt || null,
133
+ last_error: info.lastError || null,
134
+ };
135
+ }
136
+ return result;
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Daemonize — launch as background process
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Launch the daemon as a background process.
145
+ * The parent process prints info and exits; the child runs `start()`.
146
+ * @param {string[]} foregroundArgs - CLI args for the foreground child process
147
+ */
148
+ static daemonize(configDir, foregroundArgs) {
149
+ const logFile = path.join(configDir, 'daemon.log');
150
+ const pidFile = path.join(configDir, 'daemon.pid');
151
+
152
+ fs.mkdirSync(configDir, { recursive: true });
153
+ const logFd = fs.openSync(logFile, 'a');
154
+
155
+ if (IS_WINDOWS) {
156
+ // Windows: re-launch as detached with CREATE_NO_WINDOW
157
+ const proc = spawn(process.execPath, foregroundArgs, {
158
+ detached: true,
159
+ stdio: ['ignore', logFd, logFd],
160
+ windowsHide: true,
161
+ env: { ...process.env },
162
+ });
163
+ proc.unref();
164
+ fs.writeFileSync(pidFile, String(proc.pid), 'utf-8');
165
+ fs.closeSync(logFd);
166
+ console.log(`Daemon started (PID ${proc.pid})`);
167
+ console.log(`Logs: ${logFile}`);
168
+ console.log('Stop: agent-connector down');
169
+ } else {
170
+ // Unix: spawn detached child
171
+ const proc = spawn(process.execPath, foregroundArgs, {
172
+ detached: true,
173
+ stdio: ['ignore', logFd, logFd],
174
+ env: { ...process.env },
175
+ });
176
+ proc.unref();
177
+ fs.writeFileSync(pidFile, String(proc.pid), 'utf-8');
178
+ fs.closeSync(logFd);
179
+ console.log(`Daemon started (PID ${proc.pid})`);
180
+ console.log(`Logs: ${logFile}`);
181
+ console.log('Stop: agent-connector down');
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Stop a running daemon by reading PID file and sending signal.
187
+ * @returns {boolean} true if stopped
188
+ */
189
+ static stopDaemon(configDir) {
190
+ const pidFile = path.join(configDir, 'daemon.pid');
191
+ const statusFile = path.join(configDir, 'daemon.status.json');
192
+
193
+ const pid = Daemon._readPid(pidFile);
194
+ if (!pid) return false;
195
+
196
+ try {
197
+ if (IS_WINDOWS) {
198
+ execSync(`taskkill /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
199
+ } else {
200
+ process.kill(pid, 'SIGTERM');
201
+ }
202
+ } catch {}
203
+
204
+ // Wait for process to die
205
+ for (let i = 0; i < 20; i++) {
206
+ if (!Daemon._isAlive(pid)) {
207
+ try { fs.unlinkSync(pidFile); } catch {}
208
+ try { fs.unlinkSync(statusFile); } catch {}
209
+ return true;
210
+ }
211
+ // Busy-wait 500ms (sync, used only in CLI stop command)
212
+ execSync(IS_WINDOWS ? 'timeout /t 1 /nobreak >nul' : 'sleep 0.5', {
213
+ stdio: 'ignore', timeout: 2000,
214
+ });
215
+ }
216
+
217
+ // Force kill
218
+ try {
219
+ if (IS_WINDOWS) {
220
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
221
+ } else {
222
+ process.kill(pid, 'SIGKILL');
223
+ }
224
+ } catch {}
225
+
226
+ try { fs.unlinkSync(pidFile); } catch {}
227
+ try { fs.unlinkSync(statusFile); } catch {}
228
+ return true;
229
+ }
230
+
231
+ /**
232
+ * Read daemon PID, returning null if not running.
233
+ */
234
+ static readDaemonPid(configDir) {
235
+ return Daemon._readPid(path.join(configDir, 'daemon.pid'));
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Internal — agent launch
240
+ // ---------------------------------------------------------------------------
241
+
242
+ _launchAgent(agentCfg) {
243
+ const name = agentCfg.name;
244
+ const type = agentCfg.type || 'openclaw';
245
+
246
+ this._stoppedAgents.delete(name);
247
+
248
+ const info = {
249
+ type,
250
+ network: agentCfg.network || '(local)',
251
+ state: 'starting',
252
+ restarts: 0,
253
+ startedAt: null,
254
+ lastError: null,
255
+ proc: null,
256
+ _backoff: 2,
257
+ };
258
+ this._processes[name] = info;
259
+
260
+ this._spawnLoop(name, agentCfg, info);
261
+ }
262
+
263
+ async _spawnLoop(name, agentCfg, info) {
264
+ const cmd = this._getLaunchCommand(agentCfg);
265
+ if (!cmd) {
266
+ info.state = 'running';
267
+ info.startedAt = new Date().toISOString();
268
+ this._log(`${name} registered (no launch command for ${agentCfg.type})`);
269
+ return;
270
+ }
271
+
272
+ const env = this._buildAgentEnv(agentCfg);
273
+ const cwd = agentCfg.path || undefined;
274
+
275
+ while (!this._shuttingDown && !this._stoppedAgents.has(name)) {
276
+ try {
277
+ info.state = 'starting';
278
+ this._writeStatus();
279
+
280
+ const proc = this._spawnAgent(cmd, { env, cwd });
281
+ info.proc = proc;
282
+ info.state = 'running';
283
+ info.startedAt = new Date().toISOString();
284
+ this._writeStatus();
285
+ this._log(`${name} running (PID ${proc.pid})`);
286
+
287
+ // Stream output to log
288
+ if (proc.stdout) {
289
+ proc.stdout.on('data', (chunk) => {
290
+ const lines = chunk.toString().split('\n').filter(Boolean);
291
+ for (const line of lines) {
292
+ this._log(`[${name}] ${line}`);
293
+ }
294
+ });
295
+ }
296
+
297
+ const exitCode = await new Promise((resolve) => {
298
+ proc.on('exit', (code) => resolve(code));
299
+ proc.on('error', (err) => {
300
+ this._log(`${name} spawn error: ${err.message}`);
301
+ resolve(1);
302
+ });
303
+ });
304
+
305
+ info.proc = null;
306
+
307
+ if (this._stoppedAgents.has(name)) {
308
+ this._log(`${name} was stopped, not restarting`);
309
+ break;
310
+ }
311
+
312
+ if (exitCode === 0) {
313
+ this._log(`${name} exited cleanly`);
314
+ break;
315
+ }
316
+
317
+ throw new Error(`Process exited with code ${exitCode}`);
318
+ } catch (err) {
319
+ if (this._stoppedAgents.has(name) || this._shuttingDown) break;
320
+
321
+ info.restarts++;
322
+ info.state = 'error';
323
+ info.lastError = (err.message || String(err)).slice(0, 200);
324
+ this._writeStatus();
325
+ this._log(`${name} crashed: ${info.lastError}, restarting in ${info._backoff}s (attempt ${info.restarts})`);
326
+
327
+ await this._sleep(info._backoff * 1000);
328
+ info._backoff = Math.min(info._backoff * 2, 60);
329
+ }
330
+ }
331
+
332
+ info.state = 'stopped';
333
+ this._writeStatus();
334
+ }
335
+
336
+ _spawnAgent(cmd, opts) {
337
+ const [binary, ...args] = cmd;
338
+ const spawnOpts = {
339
+ stdio: ['ignore', 'pipe', 'pipe'],
340
+ env: opts.env,
341
+ cwd: opts.cwd,
342
+ };
343
+
344
+ if (IS_WINDOWS && binary.toLowerCase().endsWith('.cmd')) {
345
+ spawnOpts.shell = true;
346
+ }
347
+
348
+ const proc = spawn(binary, args, spawnOpts);
349
+
350
+ // Merge stderr into stdout handler
351
+ if (proc.stderr) {
352
+ proc.stderr.on('data', (chunk) => {
353
+ if (proc.stdout) proc.stdout.emit('data', chunk);
354
+ });
355
+ }
356
+
357
+ return proc;
358
+ }
359
+
360
+ _getLaunchCommand(agentCfg) {
361
+ const entry = this.registry.getEntry(agentCfg.type);
362
+ if (!entry || !entry.install || !entry.install.binary) return null;
363
+
364
+ const binary = entry.install.binary;
365
+ const args = [];
366
+
367
+ // Add launch args from registry
368
+ if (entry.launch && entry.launch.args) {
369
+ for (const arg of entry.launch.args) {
370
+ args.push(arg.replace(/\{agent_name\}/g, agentCfg.name));
371
+ }
372
+ }
373
+
374
+ return [binary, ...args];
375
+ }
376
+
377
+ _buildAgentEnv(agentCfg) {
378
+ const type = agentCfg.type || 'openclaw';
379
+ const saved = this.envManager.load(type);
380
+ const resolved = this.envManager.resolve(type, saved, this.registry);
381
+ const merged = { ...saved, ...resolved, ...(agentCfg.env || {}) };
382
+ return { ...process.env, ...merged };
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Internal — agent kill
387
+ // ---------------------------------------------------------------------------
388
+
389
+ async _killAgent(name, timeoutMs) {
390
+ const info = this._processes[name];
391
+ if (!info || !info.proc) {
392
+ if (info) info.state = 'stopped';
393
+ return;
394
+ }
395
+
396
+ const proc = info.proc;
397
+ info.proc = null;
398
+
399
+ // Try graceful termination
400
+ try {
401
+ if (IS_WINDOWS) {
402
+ execSync(`taskkill /PID ${proc.pid}`, { stdio: 'ignore', timeout: 5000 });
403
+ } else {
404
+ proc.kill('SIGTERM');
405
+ }
406
+ } catch {}
407
+
408
+ // Wait for exit
409
+ const died = await Promise.race([
410
+ new Promise((resolve) => proc.on('exit', () => resolve(true))),
411
+ this._sleep(timeoutMs).then(() => false),
412
+ ]);
413
+
414
+ if (!died) {
415
+ try {
416
+ if (IS_WINDOWS) {
417
+ execSync(`taskkill /F /PID ${proc.pid}`, { stdio: 'ignore', timeout: 5000 });
418
+ } else {
419
+ proc.kill('SIGKILL');
420
+ }
421
+ } catch {}
422
+ }
423
+
424
+ info.state = 'stopped';
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // Internal — status, commands, PID
429
+ // ---------------------------------------------------------------------------
430
+
431
+ _writeStatus() {
432
+ try {
433
+ const status = { agents: this.getStatus(), pid: process.pid };
434
+ fs.writeFileSync(this.config.statusFile, JSON.stringify(status, null, 2), 'utf-8');
435
+ } catch {}
436
+ }
437
+
438
+ _processCommands() {
439
+ const cmdFile = this.config.cmdFile;
440
+ try {
441
+ if (!fs.existsSync(cmdFile)) return;
442
+ const raw = fs.readFileSync(cmdFile, 'utf-8').trim();
443
+ fs.unlinkSync(cmdFile);
444
+ if (!raw) return;
445
+
446
+ for (const line of raw.split('\n')) {
447
+ const cmd = line.trim();
448
+ if (cmd.startsWith('stop:')) {
449
+ const agentName = cmd.slice(5).trim();
450
+ this.stopAgent(agentName);
451
+ } else if (cmd.startsWith('restart:')) {
452
+ const agentName = cmd.slice(8).trim();
453
+ this.restartAgent(agentName);
454
+ } else if (cmd === 'reload') {
455
+ this._reload();
456
+ }
457
+ }
458
+ } catch {}
459
+ }
460
+
461
+ _reload() {
462
+ this._log('Reloading config...');
463
+ const oldAgents = new Map(this.config.getAgents().map((a) => [a.name, a]));
464
+ // Re-read config from disk (Config reads fresh on each call)
465
+ const newAgents = new Map(this.config.getAgents().map((a) => [a.name, a]));
466
+
467
+ // Stop removed agents
468
+ for (const name of oldAgents.keys()) {
469
+ if (!newAgents.has(name)) {
470
+ this.stopAgent(name);
471
+ this._log(`Reload: stopped removed agent '${name}'`);
472
+ }
473
+ }
474
+
475
+ // Start new agents
476
+ for (const [name, agent] of newAgents) {
477
+ if (!oldAgents.has(name)) {
478
+ this._launchAgent(agent);
479
+ this._log(`Reload: started new agent '${name}'`);
480
+ }
481
+ }
482
+
483
+ this._writeStatus();
484
+ }
485
+
486
+ _writePid() {
487
+ try {
488
+ fs.writeFileSync(this.config.pidFile, String(process.pid), 'utf-8');
489
+ } catch {}
490
+ }
491
+
492
+ _cleanupPid() {
493
+ try { fs.unlinkSync(this.config.pidFile); } catch {}
494
+ try { fs.unlinkSync(this.config.statusFile); } catch {}
495
+ }
496
+
497
+ _log(msg) {
498
+ const ts = new Date().toISOString();
499
+ const line = `${ts} INFO daemon: ${msg}`;
500
+ try {
501
+ fs.appendFileSync(this.config.logFile, line + '\n', 'utf-8');
502
+ } catch {}
503
+ if (!this._shuttingDown) {
504
+ // Also log to console when running in foreground
505
+ console.log(line);
506
+ }
507
+ }
508
+
509
+ _sleep(ms) {
510
+ return new Promise((resolve) => setTimeout(resolve, ms));
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Static helpers
515
+ // ---------------------------------------------------------------------------
516
+
517
+ static _readPid(pidFile) {
518
+ try {
519
+ if (!fs.existsSync(pidFile)) return null;
520
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
521
+ if (isNaN(pid)) return null;
522
+ if (Daemon._isAlive(pid)) return pid;
523
+ // Stale
524
+ try { fs.unlinkSync(pidFile); } catch {}
525
+ return null;
526
+ } catch {
527
+ return null;
528
+ }
529
+ }
530
+
531
+ static _isAlive(pid) {
532
+ try {
533
+ process.kill(pid, 0);
534
+ return true;
535
+ } catch {
536
+ return false;
537
+ }
538
+ }
539
+ }
540
+
541
+ module.exports = { Daemon };
package/src/env.js ADDED
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /**
7
+ * Manages ~/.openagents/env/<type>.env files and resolve_env rules.
8
+ *
9
+ * Env files use key=value format (one per line, # for comments).
10
+ * resolve_env maps generic LLM_* vars to provider-specific vars
11
+ * (e.g. LLM_API_KEY → OPENAI_API_KEY or ANTHROPIC_API_KEY).
12
+ */
13
+ class EnvManager {
14
+ constructor(configDir) {
15
+ this.envDir = path.join(configDir, 'env');
16
+ }
17
+
18
+ /**
19
+ * Load env vars from ~/.openagents/env/<agentType>.env
20
+ */
21
+ load(agentType) {
22
+ const envFile = path.join(this.envDir, `${agentType}.env`);
23
+ const env = {};
24
+ try {
25
+ if (!fs.existsSync(envFile)) return env;
26
+ const lines = fs.readFileSync(envFile, 'utf-8').split('\n');
27
+ for (const line of lines) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
30
+ const idx = trimmed.indexOf('=');
31
+ const key = trimmed.slice(0, idx).trim();
32
+ const val = trimmed.slice(idx + 1).trim();
33
+ if (key) env[key] = val;
34
+ }
35
+ } catch {}
36
+ return env;
37
+ }
38
+
39
+ /**
40
+ * Save env vars to ~/.openagents/env/<agentType>.env
41
+ * Merges with existing values (new values override).
42
+ */
43
+ save(agentType, env) {
44
+ fs.mkdirSync(this.envDir, { recursive: true });
45
+ const envFile = path.join(this.envDir, `${agentType}.env`);
46
+ const existing = this.load(agentType);
47
+ const merged = { ...existing, ...env };
48
+ const lines = Object.entries(merged)
49
+ .filter(([, v]) => v !== null && v !== undefined && v !== '')
50
+ .map(([k, v]) => `${k}=${v}`);
51
+ fs.writeFileSync(envFile, lines.join('\n') + '\n', 'utf-8');
52
+ }
53
+
54
+ /**
55
+ * Delete env file for an agent type.
56
+ */
57
+ delete(agentType) {
58
+ const envFile = path.join(this.envDir, `${agentType}.env`);
59
+ try { fs.unlinkSync(envFile); } catch {}
60
+ }
61
+
62
+ /**
63
+ * Apply resolve_env rules to map generic vars to provider-specific vars.
64
+ *
65
+ * Rules format (from YAML plugin definition):
66
+ * { from: 'LLM_API_KEY', to: 'OPENAI_API_KEY', unless_base_url_contains: 'anthropic' }
67
+ * { from: 'LLM_API_KEY', to: 'ANTHROPIC_API_KEY', if_base_url_contains: 'anthropic' }
68
+ * { from: 'LLM_BASE_URL', to: 'OPENAI_BASE_URL' }
69
+ *
70
+ * @param {object} saved - The saved env vars (from the env file)
71
+ * @param {object[]} rules - The resolve_env rules from the registry
72
+ * @returns {object} - The resolved env vars (provider-specific)
73
+ */
74
+ resolve(agentType, saved, registry) {
75
+ const rules = registry ? registry.getResolveRules(agentType) : [];
76
+ if (!rules || rules.length === 0) return saved;
77
+
78
+ const resolved = {};
79
+ const baseUrl = (saved.LLM_BASE_URL || '').toLowerCase();
80
+
81
+ for (const rule of rules) {
82
+ const src = rule.from || '';
83
+ const dst = rule.to || '';
84
+ const srcVal = saved[src];
85
+ if (!srcVal || !dst) continue;
86
+
87
+ // Conditional rules based on base URL
88
+ if (rule.if_base_url_contains) {
89
+ if (!baseUrl.includes(rule.if_base_url_contains.toLowerCase())) continue;
90
+ }
91
+ if (rule.unless_base_url_contains) {
92
+ if (baseUrl.includes(rule.unless_base_url_contains.toLowerCase())) continue;
93
+ }
94
+
95
+ resolved[dst] = srcVal;
96
+ }
97
+
98
+ return resolved;
99
+ }
100
+
101
+ /**
102
+ * Get the full effective env for an agent: saved + resolved.
103
+ */
104
+ getEffective(agentType, registry) {
105
+ const saved = this.load(agentType);
106
+ const resolved = this.resolve(agentType, saved, registry);
107
+ return { ...saved, ...resolved };
108
+ }
109
+ }
110
+
111
+ module.exports = { EnvManager };