@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/README.md +86 -0
- package/bin/agent-connector.js +4 -0
- package/package.json +31 -0
- package/registry.json +457 -0
- package/src/cli.js +526 -0
- package/src/config.js +299 -0
- package/src/daemon.js +541 -0
- package/src/env.js +111 -0
- package/src/index.js +198 -0
- package/src/installer.js +228 -0
- package/src/registry.js +188 -0
- package/src/utils.js +93 -0
- package/src/workspace-client.js +194 -0
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 };
|