@openagents-org/agent-launcher 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,666 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawn, execSync, execFileSync } = require('child_process');
6
+ const os = require('os');
7
+ const { WorkspaceClient } = require('./workspace-client');
8
+ const { getEnhancedEnv, whichBinary, IS_WINDOWS } = require('./paths');
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
+ // Watch config file for hot-reload
66
+ this._watchConfig();
67
+
68
+ this._writeStatus();
69
+ this._cachedAgentNames = new Set(agents.map(a => a.name));
70
+ this._log(`Daemon started with ${agents.length} agent(s)`);
71
+
72
+ // Block until shutdown
73
+ await new Promise((resolve) => {
74
+ this._shutdownResolve = resolve;
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Gracefully stop all agents and exit.
80
+ */
81
+ async stop() {
82
+ if (this._shuttingDown) return;
83
+ this._shuttingDown = true;
84
+ this._log('Shutting down...');
85
+
86
+ if (this._statusInterval) clearInterval(this._statusInterval);
87
+ if (this._cmdInterval) clearInterval(this._cmdInterval);
88
+ if (this._configWatcher) { try { this._configWatcher.close(); } catch {} }
89
+
90
+ // Kill all child processes
91
+ const kills = Object.keys(this._processes).map((name) =>
92
+ this._killAgent(name, 5000)
93
+ );
94
+ await Promise.all(kills);
95
+
96
+ this._writeStatus();
97
+ this._cleanupPid();
98
+ this._log('Daemon stopped');
99
+
100
+ if (this._shutdownResolve) this._shutdownResolve();
101
+ }
102
+
103
+ /**
104
+ * Stop a single agent by name.
105
+ */
106
+ async stopAgent(agentName) {
107
+ this._stoppedAgents.add(agentName);
108
+ await this._killAgent(agentName, 5000);
109
+ this._writeStatus();
110
+ }
111
+
112
+ /**
113
+ * Restart a single agent by name.
114
+ */
115
+ async restartAgent(agentName) {
116
+ await this.stopAgent(agentName);
117
+ this._stoppedAgents.delete(agentName);
118
+
119
+ const agent = this.config.getAgent(agentName);
120
+ if (agent) {
121
+ this._launchAgent(agent);
122
+ this._writeStatus();
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Get current status of all agents.
128
+ */
129
+ getStatus() {
130
+ const result = {};
131
+ for (const [name, info] of Object.entries(this._processes)) {
132
+ result[name] = {
133
+ state: info.state,
134
+ type: info.type || 'unknown',
135
+ network: info.network || '(local)',
136
+ restarts: info.restarts,
137
+ started_at: info.startedAt || null,
138
+ last_error: info.lastError || null,
139
+ };
140
+ }
141
+ return result;
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Daemonize — launch as background process
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Launch the daemon as a background process.
150
+ * The parent process prints info and exits; the child runs `start()`.
151
+ * @param {string[]} foregroundArgs - CLI args for the foreground child process
152
+ */
153
+ static daemonize(configDir, foregroundArgs, execPath) {
154
+ const logFile = path.join(configDir, 'daemon.log');
155
+ const pidFile = path.join(configDir, 'daemon.pid');
156
+ const bin = execPath || process.execPath;
157
+
158
+ fs.mkdirSync(configDir, { recursive: true });
159
+ const logFd = fs.openSync(logFile, 'a');
160
+
161
+ const opts = {
162
+ detached: true,
163
+ stdio: ['ignore', logFd, logFd],
164
+ env: { ...process.env },
165
+ };
166
+ if (IS_WINDOWS) opts.windowsHide = true;
167
+
168
+ const proc = spawn(bin, foregroundArgs, opts);
169
+ proc.unref();
170
+ fs.writeFileSync(pidFile, String(proc.pid), 'utf-8');
171
+ fs.closeSync(logFd);
172
+ console.log(`Daemon started (PID ${proc.pid})`);
173
+ console.log(`Logs: ${logFile}`);
174
+ console.log('Stop: agent-connector down');
175
+ }
176
+
177
+ /**
178
+ * Stop a running daemon by reading PID file and sending signal.
179
+ * @returns {boolean} true if stopped
180
+ */
181
+ static stopDaemon(configDir) {
182
+ const pidFile = path.join(configDir, 'daemon.pid');
183
+ const statusFile = path.join(configDir, 'daemon.status.json');
184
+
185
+ const pid = Daemon._readPid(pidFile);
186
+ if (!pid) return false;
187
+
188
+ try {
189
+ if (IS_WINDOWS) {
190
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
191
+ } else {
192
+ process.kill(pid, 'SIGTERM');
193
+ }
194
+ } catch {}
195
+
196
+ // Wait for process to die
197
+ for (let i = 0; i < 20; i++) {
198
+ if (!Daemon._isAlive(pid)) {
199
+ try { fs.unlinkSync(pidFile); } catch {}
200
+ try { fs.unlinkSync(statusFile); } catch {}
201
+ return true;
202
+ }
203
+ // Busy-wait 500ms (sync, used only in CLI stop command)
204
+ execSync(IS_WINDOWS ? 'ping -n 2 127.0.0.1 >nul' : 'sleep 0.5', {
205
+ stdio: 'ignore', timeout: 5000,
206
+ });
207
+ }
208
+
209
+ // Force kill
210
+ try {
211
+ if (IS_WINDOWS) {
212
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
213
+ } else {
214
+ process.kill(pid, 'SIGKILL');
215
+ }
216
+ } catch {}
217
+
218
+ try { fs.unlinkSync(pidFile); } catch {}
219
+ try { fs.unlinkSync(statusFile); } catch {}
220
+ return true;
221
+ }
222
+
223
+ /**
224
+ * Read daemon PID, returning null if not running.
225
+ */
226
+ static readDaemonPid(configDir) {
227
+ return Daemon._readPid(path.join(configDir, 'daemon.pid'));
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Internal — agent launch
232
+ // ---------------------------------------------------------------------------
233
+
234
+ _launchAgent(agentCfg) {
235
+ const name = agentCfg.name;
236
+ const type = agentCfg.type || 'openclaw';
237
+
238
+ this._stoppedAgents.delete(name);
239
+
240
+ const info = {
241
+ type,
242
+ network: agentCfg.network || '(local)',
243
+ state: 'starting',
244
+ restarts: 0,
245
+ startedAt: null,
246
+ lastError: null,
247
+ proc: null,
248
+ _backoff: 2,
249
+ };
250
+ this._processes[name] = info;
251
+
252
+ // Workspace-connected agents use the adapter loop (poll + CLI per message).
253
+ // Local-only agents use the spawn loop (long-running child process).
254
+ const network = agentCfg.network
255
+ ? this.config.getNetworks().find(
256
+ (n) => n.slug === agentCfg.network || n.id === agentCfg.network
257
+ )
258
+ : null;
259
+
260
+ if (network) {
261
+ this._adapterLoop(name, agentCfg, info, network);
262
+ } else {
263
+ this._spawnLoop(name, agentCfg, info);
264
+ }
265
+ }
266
+
267
+ async _spawnLoop(name, agentCfg, info) {
268
+ const cmd = this._getLaunchCommand(agentCfg);
269
+ if (!cmd) {
270
+ info.state = 'running';
271
+ info.startedAt = new Date().toISOString();
272
+ this._log(`${name} registered (no launch command for ${agentCfg.type})`);
273
+ return;
274
+ }
275
+
276
+ const env = this._buildAgentEnv(agentCfg);
277
+ const cwd = agentCfg.path || undefined;
278
+
279
+ while (!this._shuttingDown && !this._stoppedAgents.has(name)) {
280
+ try {
281
+ info.state = 'starting';
282
+ this._writeStatus();
283
+
284
+ this._log(`${name} launching: ${cmd.join(' ')}`);
285
+ const proc = this._spawnAgent(cmd, { env, cwd });
286
+ info.proc = proc;
287
+ info.state = 'running';
288
+ info.startedAt = new Date().toISOString();
289
+ this._writeStatus();
290
+ this._log(`${name} running (PID ${proc.pid})`);
291
+
292
+ // Stream output to log
293
+ if (proc.stdout) {
294
+ proc.stdout.on('data', (chunk) => {
295
+ const lines = chunk.toString().split('\n').filter(Boolean);
296
+ for (const line of lines) {
297
+ this._log(`[${name}] ${line}`);
298
+ }
299
+ });
300
+ }
301
+
302
+ const exitCode = await new Promise((resolve) => {
303
+ proc.on('exit', (code) => resolve(code));
304
+ proc.on('error', (err) => {
305
+ this._log(`${name} spawn error: ${err.message}`);
306
+ resolve(1);
307
+ });
308
+ });
309
+
310
+ info.proc = null;
311
+
312
+ if (this._stoppedAgents.has(name)) {
313
+ this._log(`${name} was stopped, not restarting`);
314
+ break;
315
+ }
316
+
317
+ if (exitCode === 0) {
318
+ this._log(`${name} exited cleanly`);
319
+ break;
320
+ }
321
+
322
+ throw new Error(`Process exited with code ${exitCode}`);
323
+ } catch (err) {
324
+ if (this._stoppedAgents.has(name) || this._shuttingDown) break;
325
+
326
+ info.restarts++;
327
+ info.state = 'error';
328
+ info.lastError = (err.message || String(err)).slice(0, 200);
329
+ this._writeStatus();
330
+ this._log(`${name} crashed: ${info.lastError}, restarting in ${info._backoff}s (attempt ${info.restarts})`);
331
+
332
+ await this._sleep(info._backoff * 1000);
333
+ info._backoff = Math.min(info._backoff * 2, 60);
334
+ }
335
+ }
336
+
337
+ info.state = 'stopped';
338
+ this._writeStatus();
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Internal — adapter loop (workspace-connected agents)
343
+ // ---------------------------------------------------------------------------
344
+
345
+ async _adapterLoop(name, agentCfg, info, network) {
346
+ const { createAdapter } = require('./adapters');
347
+ const agentType = agentCfg.type || 'openclaw';
348
+ const endpoint = network.endpoint || 'https://workspace-endpoint.openagents.org';
349
+
350
+ let adapter;
351
+ try {
352
+ adapter = createAdapter(agentType, {
353
+ workspaceId: network.id,
354
+ channelName: 'general',
355
+ token: network.token,
356
+ agentName: name,
357
+ endpoint,
358
+ openclawAgentId: agentCfg.openclaw_agent_id || 'main',
359
+ disabledModules: new Set(),
360
+ });
361
+ } catch (e) {
362
+ this._log(`${name} failed to create ${agentType} adapter: ${e.message}`);
363
+ info.state = 'error';
364
+ info.lastError = e.message;
365
+ this._writeStatus();
366
+ return;
367
+ }
368
+
369
+ // Store adapter reference for stop
370
+ this._adapters = this._adapters || {};
371
+ this._adapters[name] = adapter;
372
+
373
+ info.state = 'running';
374
+ info.startedAt = new Date().toISOString();
375
+ this._writeStatus();
376
+ this._log(`${name} adapter online → ${network.slug} (type: ${agentType})`);
377
+
378
+ try {
379
+ // Run adapter poll loop — stops when adapter.stop() is called
380
+ // or when the daemon shuts down
381
+ const checkStop = setInterval(() => {
382
+ if (this._shuttingDown || this._stoppedAgents.has(name)) {
383
+ adapter.stop();
384
+ clearInterval(checkStop);
385
+ }
386
+ }, 1000);
387
+
388
+ await adapter.run();
389
+ clearInterval(checkStop);
390
+ } catch (e) {
391
+ info.lastError = (e.message || String(e)).slice(0, 200);
392
+ this._log(`${name} adapter error: ${info.lastError}`);
393
+ }
394
+
395
+ delete this._adapters[name];
396
+ info.state = 'stopped';
397
+ this._writeStatus();
398
+ this._log(`${name} adapter stopped`);
399
+ }
400
+
401
+ // NOTE: Adapter-specific message handling (openclaw, claude, codex)
402
+ // has been moved to src/adapters/. The daemon delegates via createAdapter().
403
+
404
+ _resolveAgentBinary(agentCfg) {
405
+ const entry = this.registry.getEntry(agentCfg.type);
406
+ let binary = (entry && entry.install && entry.install.binary);
407
+ if (!binary) {
408
+ const knownBinaries = {
409
+ openclaw: 'openclaw', claude: 'claude', codex: 'codex',
410
+ aider: 'aider', goose: 'goose', gemini: 'gemini',
411
+ };
412
+ binary = knownBinaries[agentCfg.type];
413
+ }
414
+ return binary || null;
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Internal — spawn loop (local-only agents)
419
+ // ---------------------------------------------------------------------------
420
+
421
+ _spawnAgent(cmd, opts) {
422
+ const [binary, ...args] = cmd;
423
+ const spawnOpts = {
424
+ stdio: ['ignore', 'pipe', 'pipe'],
425
+ env: getEnhancedEnv(opts.env),
426
+ cwd: opts.cwd,
427
+ };
428
+
429
+ if (IS_WINDOWS) {
430
+ // On Windows, always use shell so .cmd/.ps1 shims on PATH are found
431
+ // Use cmd /c with chcp 65001 to force UTF-8 output (fixes GBK garbled text)
432
+ spawnOpts.shell = true;
433
+ }
434
+
435
+ const proc = spawn(binary, args, spawnOpts);
436
+
437
+ // Force UTF-8 decoding on stdout/stderr
438
+ if (proc.stdout) proc.stdout.setEncoding('utf-8');
439
+ if (proc.stderr) proc.stderr.setEncoding('utf-8');
440
+
441
+ // Merge stderr into stdout handler
442
+ if (proc.stderr) {
443
+ proc.stderr.on('data', (chunk) => {
444
+ if (proc.stdout) proc.stdout.emit('data', chunk);
445
+ });
446
+ }
447
+
448
+ return proc;
449
+ }
450
+
451
+ _getLaunchCommand(agentCfg) {
452
+ const binary = this._resolveAgentBinary(agentCfg);
453
+ if (!binary) return null;
454
+
455
+ const entry = this.registry.getEntry(agentCfg.type);
456
+ const args = [];
457
+
458
+ // Add launch args from registry
459
+ if (entry && entry.launch && entry.launch.args) {
460
+ for (const arg of entry.launch.args) {
461
+ args.push(arg.replace(/\{agent_name\}/g, agentCfg.name));
462
+ }
463
+ }
464
+
465
+ // Built-in launch profiles for local-only agents
466
+ if (!args.length) {
467
+ const type = agentCfg.type || '';
468
+ if (type === 'claude') {
469
+ args.push('--print');
470
+ } else if (type === 'codex') {
471
+ args.push('--quiet');
472
+ }
473
+ }
474
+
475
+ return [binary, ...args];
476
+ }
477
+
478
+ _buildAgentEnv(agentCfg) {
479
+ const type = agentCfg.type || 'openclaw';
480
+ const saved = this.envManager.load(type);
481
+ const resolved = this.envManager.resolve(type, saved, this.registry);
482
+ const merged = { ...saved, ...resolved, ...(agentCfg.env || {}) };
483
+ return { ...process.env, ...merged };
484
+ }
485
+
486
+ // ---------------------------------------------------------------------------
487
+ // Internal — agent kill
488
+ // ---------------------------------------------------------------------------
489
+
490
+ async _killAgent(name, timeoutMs) {
491
+ const info = this._processes[name];
492
+ if (!info || !info.proc) {
493
+ if (info) info.state = 'stopped';
494
+ return;
495
+ }
496
+
497
+ const proc = info.proc;
498
+ info.proc = null;
499
+
500
+ // Try graceful termination
501
+ try {
502
+ if (IS_WINDOWS) {
503
+ execSync(`taskkill /PID ${proc.pid}`, { stdio: 'ignore', timeout: 5000 });
504
+ } else {
505
+ proc.kill('SIGTERM');
506
+ }
507
+ } catch {}
508
+
509
+ // Wait for exit
510
+ const died = await Promise.race([
511
+ new Promise((resolve) => proc.on('exit', () => resolve(true))),
512
+ this._sleep(timeoutMs).then(() => false),
513
+ ]);
514
+
515
+ if (!died) {
516
+ try {
517
+ if (IS_WINDOWS) {
518
+ execSync(`taskkill /F /PID ${proc.pid}`, { stdio: 'ignore', timeout: 5000 });
519
+ } else {
520
+ proc.kill('SIGKILL');
521
+ }
522
+ } catch {}
523
+ }
524
+
525
+ info.state = 'stopped';
526
+ }
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // Internal — status, commands, PID
530
+ // ---------------------------------------------------------------------------
531
+
532
+ _writeStatus() {
533
+ try {
534
+ const status = { agents: this.getStatus(), pid: process.pid };
535
+ fs.writeFileSync(this.config.statusFile, JSON.stringify(status, null, 2), 'utf-8');
536
+ } catch {}
537
+ }
538
+
539
+ _processCommands() {
540
+ const cmdFile = this.config.cmdFile;
541
+ try {
542
+ if (!fs.existsSync(cmdFile)) return;
543
+ const raw = fs.readFileSync(cmdFile, 'utf-8').trim();
544
+ fs.unlinkSync(cmdFile);
545
+ if (!raw) return;
546
+
547
+ for (const line of raw.split('\n')) {
548
+ const cmd = line.trim();
549
+ if (cmd.startsWith('stop:')) {
550
+ const agentName = cmd.slice(5).trim();
551
+ this.stopAgent(agentName);
552
+ } else if (cmd.startsWith('restart:')) {
553
+ const agentName = cmd.slice(8).trim();
554
+ this.restartAgent(agentName);
555
+ } else if (cmd === 'reload') {
556
+ this._reload();
557
+ }
558
+ }
559
+ } catch {}
560
+ }
561
+
562
+ _watchConfig() {
563
+ try {
564
+ let debounce = null;
565
+ this._configWatcher = fs.watch(this.config.configFile, () => {
566
+ if (debounce) clearTimeout(debounce);
567
+ debounce = setTimeout(() => this._reload(), 1000);
568
+ });
569
+ this._configWatcher.on('error', () => {});
570
+ } catch {}
571
+ }
572
+
573
+ _reload() {
574
+ this._log('Reloading config...');
575
+ const oldNames = this._cachedAgentNames || new Set();
576
+ // Re-read config from disk
577
+ const newAgents = this.config.getAgents();
578
+ const newNames = new Set(newAgents.map(a => a.name));
579
+
580
+ // Stop removed agents
581
+ for (const name of oldNames) {
582
+ if (!newNames.has(name)) {
583
+ this.stopAgent(name);
584
+ this._log(`Reload: stopped removed agent '${name}'`);
585
+ }
586
+ }
587
+
588
+ // Start new agents
589
+ for (const agent of newAgents) {
590
+ if (!oldNames.has(agent.name)) {
591
+ this._launchAgent(agent);
592
+ this._log(`Reload: started new agent '${agent.name}'`);
593
+ }
594
+ }
595
+
596
+ this._cachedAgentNames = newNames;
597
+ this._writeStatus();
598
+ }
599
+
600
+ _writePid() {
601
+ try {
602
+ fs.writeFileSync(this.config.pidFile, String(process.pid), 'utf-8');
603
+ } catch {}
604
+ }
605
+
606
+ _cleanupPid() {
607
+ try { fs.unlinkSync(this.config.pidFile); } catch {}
608
+ try { fs.unlinkSync(this.config.statusFile); } catch {}
609
+ }
610
+
611
+ _log(msg) {
612
+ const ts = new Date().toISOString();
613
+ const line = `${ts} INFO daemon: ${msg}`;
614
+ try {
615
+ fs.appendFileSync(this.config.logFile, line + '\n', 'utf-8');
616
+ this._maybeRotateLog();
617
+ } catch {}
618
+ if (!this._shuttingDown) {
619
+ console.log(line);
620
+ }
621
+ }
622
+
623
+ _maybeRotateLog() {
624
+ // Rotate at 10MB, keep 1 backup
625
+ const MAX_SIZE = 10 * 1024 * 1024;
626
+ try {
627
+ const stat = fs.statSync(this.config.logFile);
628
+ if (stat.size > MAX_SIZE) {
629
+ const backup = this.config.logFile + '.1';
630
+ try { fs.unlinkSync(backup); } catch {}
631
+ fs.renameSync(this.config.logFile, backup);
632
+ }
633
+ } catch {}
634
+ }
635
+
636
+ _sleep(ms) {
637
+ return new Promise((resolve) => setTimeout(resolve, ms));
638
+ }
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // Static helpers
642
+ // ---------------------------------------------------------------------------
643
+
644
+ static _readPid(pidFile) {
645
+ try {
646
+ if (!fs.existsSync(pidFile)) return null;
647
+ const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
648
+ return isNaN(pid) ? null : pid;
649
+ } catch {
650
+ return null;
651
+ }
652
+ }
653
+
654
+ static _isAlive(pid) {
655
+ try {
656
+ process.kill(pid, 0);
657
+ return true;
658
+ } catch (e) {
659
+ // EPERM = process exists but cross-session on Windows
660
+ if (e.code === 'EPERM') return true;
661
+ return false;
662
+ }
663
+ }
664
+ }
665
+
666
+ module.exports = { Daemon };