@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/README.md +86 -0
- package/bin/agent-connector.js +4 -0
- package/package.json +42 -0
- package/registry.json +457 -0
- package/src/adapters/base.js +327 -0
- package/src/adapters/claude.js +420 -0
- package/src/adapters/codex.js +260 -0
- package/src/adapters/index.js +39 -0
- package/src/adapters/openclaw.js +264 -0
- package/src/adapters/utils.js +83 -0
- package/src/adapters/workspace-prompt.js +293 -0
- package/src/autostart.js +178 -0
- package/src/cli.js +556 -0
- package/src/config.js +322 -0
- package/src/daemon.js +666 -0
- package/src/env.js +111 -0
- package/src/index.js +205 -0
- package/src/installer.js +588 -0
- package/src/paths.js +276 -0
- package/src/registry.js +197 -0
- package/src/tui.js +540 -0
- package/src/utils.js +93 -0
- package/src/workspace-client.js +338 -0
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 };
|