@openagents-org/agent-connector 0.1.0 → 0.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openagents-org/agent-connector",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Agent management CLI and library for OpenAgents — install, configure, and run AI coding agents",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -63,17 +63,17 @@ function table(rows, headers) {
63
63
  // ---------------------------------------------------------------------------
64
64
 
65
65
  async function cmdUp(connector, flags) {
66
- const pid = connector.getDaemonPid();
67
- if (pid) {
68
- print(`Daemon already running (PID ${pid})`);
69
- return;
70
- }
71
-
72
66
  if (flags.foreground) {
73
- // Run in foreground (used by daemonize child)
67
+ // Run in foreground (used by daemonize child) — skip PID check
68
+ // because the parent already wrote our PID to the file.
74
69
  const daemon = connector.createDaemon();
75
70
  await daemon.start();
76
71
  } else {
72
+ const pid = connector.getDaemonPid();
73
+ if (pid) {
74
+ print(`Daemon already running (PID ${pid})`);
75
+ return;
76
+ }
77
77
  // Daemonize
78
78
  const foregroundArgs = [process.argv[1], 'up', '--foreground'];
79
79
  if (flags.config) foregroundArgs.push('--config', flags.config);
package/src/daemon.js CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { spawn, execSync } = require('child_process');
5
+ const { spawn, execSync, execFileSync } = require('child_process');
6
6
  const os = require('os');
7
+ const { WorkspaceClient } = require('./workspace-client');
7
8
 
8
9
  const IS_WINDOWS = process.platform === 'win32';
9
10
 
@@ -145,41 +146,28 @@ class Daemon {
145
146
  * The parent process prints info and exits; the child runs `start()`.
146
147
  * @param {string[]} foregroundArgs - CLI args for the foreground child process
147
148
  */
148
- static daemonize(configDir, foregroundArgs) {
149
+ static daemonize(configDir, foregroundArgs, execPath) {
149
150
  const logFile = path.join(configDir, 'daemon.log');
150
151
  const pidFile = path.join(configDir, 'daemon.pid');
152
+ const bin = execPath || process.execPath;
151
153
 
152
154
  fs.mkdirSync(configDir, { recursive: true });
153
155
  const logFd = fs.openSync(logFile, 'a');
154
156
 
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
- }
157
+ const opts = {
158
+ detached: true,
159
+ stdio: ['ignore', logFd, logFd],
160
+ env: { ...process.env },
161
+ };
162
+ if (IS_WINDOWS) opts.windowsHide = true;
163
+
164
+ const proc = spawn(bin, foregroundArgs, opts);
165
+ proc.unref();
166
+ fs.writeFileSync(pidFile, String(proc.pid), 'utf-8');
167
+ fs.closeSync(logFd);
168
+ console.log(`Daemon started (PID ${proc.pid})`);
169
+ console.log(`Logs: ${logFile}`);
170
+ console.log('Stop: agent-connector down');
183
171
  }
184
172
 
185
173
  /**
@@ -195,7 +183,7 @@ class Daemon {
195
183
 
196
184
  try {
197
185
  if (IS_WINDOWS) {
198
- execSync(`taskkill /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
186
+ execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore', timeout: 5000 });
199
187
  } else {
200
188
  process.kill(pid, 'SIGTERM');
201
189
  }
@@ -209,8 +197,8 @@ class Daemon {
209
197
  return true;
210
198
  }
211
199
  // 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,
200
+ execSync(IS_WINDOWS ? 'ping -n 2 127.0.0.1 >nul' : 'sleep 0.5', {
201
+ stdio: 'ignore', timeout: 5000,
214
202
  });
215
203
  }
216
204
 
@@ -257,7 +245,19 @@ class Daemon {
257
245
  };
258
246
  this._processes[name] = info;
259
247
 
260
- this._spawnLoop(name, agentCfg, info);
248
+ // Workspace-connected agents use the adapter loop (poll + CLI per message).
249
+ // Local-only agents use the spawn loop (long-running child process).
250
+ const network = agentCfg.network
251
+ ? this.config.getNetworks().find(
252
+ (n) => n.slug === agentCfg.network || n.id === agentCfg.network
253
+ )
254
+ : null;
255
+
256
+ if (network) {
257
+ this._adapterLoop(name, agentCfg, info, network);
258
+ } else {
259
+ this._spawnLoop(name, agentCfg, info);
260
+ }
261
261
  }
262
262
 
263
263
  async _spawnLoop(name, agentCfg, info) {
@@ -277,6 +277,7 @@ class Daemon {
277
277
  info.state = 'starting';
278
278
  this._writeStatus();
279
279
 
280
+ this._log(`${name} launching: ${cmd.join(' ')}`);
280
281
  const proc = this._spawnAgent(cmd, { env, cwd });
281
282
  info.proc = proc;
282
283
  info.state = 'running';
@@ -333,6 +334,220 @@ class Daemon {
333
334
  this._writeStatus();
334
335
  }
335
336
 
337
+ // ---------------------------------------------------------------------------
338
+ // Internal — adapter loop (workspace-connected agents)
339
+ // ---------------------------------------------------------------------------
340
+
341
+ async _adapterLoop(name, agentCfg, info, network) {
342
+ const wsClient = new WorkspaceClient(network.endpoint || 'https://workspace-endpoint.openagents.org');
343
+ const binary = this._resolveAgentBinary(agentCfg);
344
+ const env = this._buildAgentEnv(agentCfg);
345
+ let cursor = null;
346
+ const processedIds = new Set();
347
+
348
+ // Skip existing events on startup
349
+ try {
350
+ while (true) {
351
+ const { cursor: newCursor } = await wsClient.pollPending(
352
+ network.id, name, network.token, { after: cursor, limit: 200 }
353
+ );
354
+ if (!newCursor || newCursor === cursor) break;
355
+ cursor = newCursor;
356
+ }
357
+ this._log(`${name} skipped existing events, cursor=${cursor}`);
358
+ } catch (e) {
359
+ this._log(`${name} skip-events failed: ${e.message}`);
360
+ }
361
+
362
+ // Heartbeat interval
363
+ const heartbeatInterval = setInterval(async () => {
364
+ if (this._stoppedAgents.has(name) || this._shuttingDown) return;
365
+ try {
366
+ await wsClient.heartbeat(network.id, name, network.token);
367
+ } catch (e) {
368
+ this._log(`${name} heartbeat failed: ${e.message}`);
369
+ }
370
+ }, 30000);
371
+
372
+ // Send initial heartbeat
373
+ try { await wsClient.heartbeat(network.id, name, network.token); } catch {}
374
+
375
+ info.state = 'running';
376
+ info.startedAt = new Date().toISOString();
377
+ this._writeStatus();
378
+ this._log(`${name} adapter online → ${network.slug}${binary ? ` (binary: ${binary})` : ''}`);
379
+
380
+ let idleCount = 0;
381
+
382
+ while (!this._shuttingDown && !this._stoppedAgents.has(name)) {
383
+ try {
384
+ const { messages, cursor: newCursor } = await wsClient.pollPending(
385
+ network.id, name, network.token, { after: cursor }
386
+ );
387
+ if (newCursor) cursor = newCursor;
388
+
389
+ // Filter already-processed messages
390
+ const incoming = messages.filter((m) => {
391
+ const id = m.messageId;
392
+ if (!id || processedIds.has(id)) return false;
393
+ if (m.messageType === 'status') return false;
394
+ return true;
395
+ });
396
+
397
+ if (incoming.length > 0) {
398
+ idleCount = 0;
399
+ for (const msg of incoming) {
400
+ if (msg.messageId) processedIds.add(msg.messageId);
401
+ await this._handleAdapterMessage(name, agentCfg, msg, network, wsClient, binary, env);
402
+ }
403
+ // Cap dedup set
404
+ if (processedIds.size > 2000) {
405
+ const arr = [...processedIds];
406
+ processedIds.clear();
407
+ for (const id of arr.slice(-1000)) processedIds.add(id);
408
+ }
409
+ } else {
410
+ idleCount++;
411
+ }
412
+
413
+ // Adaptive polling: 2s active, up to 15s idle
414
+ const delay = incoming.length > 0 ? 2000 : Math.min(2000 + idleCount * 1000, 15000);
415
+ await this._sleep(delay);
416
+ } catch (e) {
417
+ if (this._stoppedAgents.has(name) || this._shuttingDown) break;
418
+ info.lastError = (e.message || String(e)).slice(0, 200);
419
+ this._log(`${name} poll error: ${info.lastError}`);
420
+ await this._sleep(5000);
421
+ }
422
+ }
423
+
424
+ clearInterval(heartbeatInterval);
425
+
426
+ // Disconnect from workspace
427
+ try { await wsClient.disconnect(network.id, name, network.token); } catch {}
428
+
429
+ info.state = 'stopped';
430
+ this._writeStatus();
431
+ this._log(`${name} adapter stopped`);
432
+ }
433
+
434
+ async _handleAdapterMessage(name, agentCfg, msg, network, wsClient, binary, env) {
435
+ const content = (msg.content || '').trim();
436
+ if (!content) return;
437
+
438
+ const channel = msg.sessionId || 'general';
439
+ const sender = msg.senderName || msg.senderType || 'user';
440
+ this._log(`${name} message from ${sender} in ${channel}: ${content.slice(0, 80)}`);
441
+
442
+ // Send "thinking..." status
443
+ try {
444
+ await wsClient.sendMessage(network.id, channel, network.token, 'thinking...', {
445
+ senderType: 'agent', senderName: name, messageType: 'status',
446
+ });
447
+ } catch {}
448
+
449
+ try {
450
+ let response;
451
+ if (binary) {
452
+ response = await this._runCliAgent(binary, content, channel, agentCfg, network, env);
453
+ } else {
454
+ response = `Agent type '${agentCfg.type}' has no CLI binary — cannot process message.`;
455
+ }
456
+
457
+ if (response) {
458
+ await wsClient.sendMessage(network.id, channel, network.token, response, {
459
+ senderType: 'agent', senderName: name,
460
+ });
461
+ this._log(`${name} responded in ${channel}: ${response.slice(0, 80)}...`);
462
+ }
463
+ } catch (e) {
464
+ const errMsg = `Error: ${(e.message || String(e)).slice(0, 200)}`;
465
+ this._log(`${name} error handling message: ${errMsg}`);
466
+ try {
467
+ await wsClient.sendMessage(network.id, channel, network.token, errMsg, {
468
+ senderType: 'agent', senderName: name,
469
+ });
470
+ } catch {}
471
+ }
472
+ }
473
+
474
+ _runCliAgent(binary, message, channel, agentCfg, network, env) {
475
+ return new Promise((resolve, reject) => {
476
+ const sessionKey = `openagents-${network.id.slice(0, 8)}-${channel.slice(-8)}`;
477
+ const agentId = agentCfg.openclaw_agent_id || 'main';
478
+
479
+ const args = ['agent', '--local', '--agent', agentId,
480
+ '--session-id', sessionKey, '--message', message, '--json'];
481
+
482
+ this._log(`${agentCfg.name} CLI: ${binary} ${args.slice(0, 5).join(' ')} ...`);
483
+
484
+ const spawnOpts = {
485
+ stdio: ['ignore', 'pipe', 'pipe'],
486
+ env,
487
+ timeout: 600000,
488
+ };
489
+ if (IS_WINDOWS) {
490
+ spawnOpts.shell = true;
491
+ const npmBin = path.join(process.env.APPDATA || '', 'npm');
492
+ if (npmBin && !(env.PATH || '').includes(npmBin)) {
493
+ spawnOpts.env = { ...env, PATH: npmBin + ';' + (env.PATH || process.env.PATH || '') };
494
+ }
495
+ }
496
+
497
+ const proc = spawn(binary, args, spawnOpts);
498
+ let stdout = '';
499
+ let stderr = '';
500
+
501
+ if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d; });
502
+ if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; });
503
+
504
+ proc.on('error', (err) => reject(err));
505
+ proc.on('exit', (code) => {
506
+ if (code !== 0) {
507
+ reject(new Error(`CLI exited ${code}: ${stderr.slice(0, 300)}`));
508
+ return;
509
+ }
510
+
511
+ stdout = stdout.trim();
512
+ if (!stdout) { resolve(''); return; }
513
+
514
+ // Parse JSON output — find first '{'
515
+ const jsonStart = stdout.indexOf('{');
516
+ if (jsonStart < 0) { resolve(stdout); return; }
517
+
518
+ try {
519
+ const data = JSON.parse(stdout.slice(jsonStart));
520
+ const payloads = data.payloads || [];
521
+ if (payloads.length > 0) {
522
+ const texts = payloads.filter((p) => p.text).map((p) => p.text);
523
+ resolve(texts.join('\n\n'));
524
+ } else {
525
+ resolve(stdout.slice(0, jsonStart).trim() || '');
526
+ }
527
+ } catch {
528
+ resolve(stdout);
529
+ }
530
+ });
531
+ });
532
+ }
533
+
534
+ _resolveAgentBinary(agentCfg) {
535
+ const entry = this.registry.getEntry(agentCfg.type);
536
+ let binary = (entry && entry.install && entry.install.binary);
537
+ if (!binary) {
538
+ const knownBinaries = {
539
+ openclaw: 'openclaw', claude: 'claude', codex: 'codex',
540
+ aider: 'aider', goose: 'goose', gemini: 'gemini',
541
+ };
542
+ binary = knownBinaries[agentCfg.type];
543
+ }
544
+ return binary || null;
545
+ }
546
+
547
+ // ---------------------------------------------------------------------------
548
+ // Internal — spawn loop (local-only agents)
549
+ // ---------------------------------------------------------------------------
550
+
336
551
  _spawnAgent(cmd, opts) {
337
552
  const [binary, ...args] = cmd;
338
553
  const spawnOpts = {
@@ -341,8 +556,14 @@ class Daemon {
341
556
  cwd: opts.cwd,
342
557
  };
343
558
 
344
- if (IS_WINDOWS && binary.toLowerCase().endsWith('.cmd')) {
559
+ if (IS_WINDOWS) {
560
+ // On Windows, always use shell so .cmd/.ps1 shims on PATH are found
345
561
  spawnOpts.shell = true;
562
+ // Ensure npm global bin is on PATH
563
+ const npmBin = path.join(process.env.APPDATA || '', 'npm');
564
+ if (npmBin && !(process.env.PATH || '').includes(npmBin)) {
565
+ spawnOpts.env = { ...spawnOpts.env, PATH: npmBin + ';' + (spawnOpts.env.PATH || process.env.PATH || '') };
566
+ }
346
567
  }
347
568
 
348
569
  const proc = spawn(binary, args, spawnOpts);
@@ -358,19 +579,29 @@ class Daemon {
358
579
  }
359
580
 
360
581
  _getLaunchCommand(agentCfg) {
361
- const entry = this.registry.getEntry(agentCfg.type);
362
- if (!entry || !entry.install || !entry.install.binary) return null;
582
+ const binary = this._resolveAgentBinary(agentCfg);
583
+ if (!binary) return null;
363
584
 
364
- const binary = entry.install.binary;
585
+ const entry = this.registry.getEntry(agentCfg.type);
365
586
  const args = [];
366
587
 
367
588
  // Add launch args from registry
368
- if (entry.launch && entry.launch.args) {
589
+ if (entry && entry.launch && entry.launch.args) {
369
590
  for (const arg of entry.launch.args) {
370
591
  args.push(arg.replace(/\{agent_name\}/g, agentCfg.name));
371
592
  }
372
593
  }
373
594
 
595
+ // Built-in launch profiles for local-only agents
596
+ if (!args.length) {
597
+ const type = agentCfg.type || '';
598
+ if (type === 'claude') {
599
+ args.push('--print');
600
+ } else if (type === 'codex') {
601
+ args.push('--quiet');
602
+ }
603
+ }
604
+
374
605
  return [binary, ...args];
375
606
  }
376
607
 
@@ -3,7 +3,7 @@
3
3
  const https = require('https');
4
4
  const http = require('http');
5
5
 
6
- const DEFAULT_ENDPOINT = 'https://endpoint.openagents.org';
6
+ const DEFAULT_ENDPOINT = 'https://workspace-endpoint.openagents.org';
7
7
 
8
8
  /**
9
9
  * HTTP client for workspace API operations.
@@ -143,6 +143,79 @@ class WorkspaceClient {
143
143
  }, token);
144
144
  }
145
145
 
146
+ /**
147
+ * Poll for pending messages targeted at an agent via GET /v1/events.
148
+ * Returns { messages, cursor } where cursor is the last event ID.
149
+ */
150
+ async pollPending(workspaceId, agentName, token, { after, limit = 50 } = {}) {
151
+ const params = new URLSearchParams({
152
+ network: workspaceId,
153
+ type: 'workspace.message',
154
+ limit: String(limit),
155
+ });
156
+ if (after) params.set('after', after);
157
+
158
+ const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
159
+ const result = data.data || data;
160
+ const events = (result && result.events) || [];
161
+
162
+ let cursor = null;
163
+ if (events.length > 0) {
164
+ cursor = events[events.length - 1].id || null;
165
+ }
166
+
167
+ // Filter for messages targeted at this agent
168
+ const messages = [];
169
+ for (const e of events) {
170
+ const source = e.source || '';
171
+ const meta = e.metadata || {};
172
+ const targetAgents = meta.target_agents || [];
173
+
174
+ // Skip own messages
175
+ if (source === `openagents:${agentName}`) continue;
176
+
177
+ if (source.startsWith('human:')) {
178
+ // Human messages: pick up if targeted at this agent or broadcast
179
+ if (!targetAgents.length || targetAgents.includes(agentName)) {
180
+ messages.push(this._eventToMessage(e));
181
+ }
182
+ } else if (source.startsWith('openagents:')) {
183
+ // Agent messages: only pick up if explicitly mentioned
184
+ if (targetAgents.includes(agentName)) {
185
+ messages.push(this._eventToMessage(e));
186
+ }
187
+ }
188
+ }
189
+
190
+ return { messages, cursor };
191
+ }
192
+
193
+ /**
194
+ * Convert an ONM event to a message-compatible object.
195
+ */
196
+ _eventToMessage(event) {
197
+ const source = event.source || '';
198
+ const isHuman = source.startsWith('human:');
199
+ const senderName = source.replace('openagents:', '').replace('human:', '');
200
+ const payload = event.payload || {};
201
+ const target = event.target || '';
202
+ const ts = event.timestamp;
203
+
204
+ const msg = {
205
+ messageId: event.id || '',
206
+ sessionId: target.startsWith('channel/') ? target.replace('channel/', '') : target,
207
+ senderType: isHuman ? 'human' : 'agent',
208
+ senderName,
209
+ content: (payload.content || ''),
210
+ messageType: payload.message_type || 'chat',
211
+ metadata: event.metadata || {},
212
+ };
213
+ if (ts) {
214
+ msg.createdAt = new Date(ts).toISOString();
215
+ }
216
+ return msg;
217
+ }
218
+
146
219
  // -- Internal --
147
220
 
148
221
  _wsHeaders(token) {
@@ -152,6 +225,40 @@ class WorkspaceClient {
152
225
  };
153
226
  }
154
227
 
228
+ _get(urlPath, headers = {}) {
229
+ const fullUrl = this.endpoint + urlPath;
230
+
231
+ return new Promise((resolve, reject) => {
232
+ const parsedUrl = new URL(fullUrl);
233
+ const transport = parsedUrl.protocol === 'https:' ? https : http;
234
+
235
+ const req = transport.request(fullUrl, {
236
+ method: 'GET',
237
+ headers,
238
+ timeout: 15000,
239
+ }, (res) => {
240
+ let data = '';
241
+ res.on('data', (chunk) => { data += chunk; });
242
+ res.on('end', () => {
243
+ try {
244
+ const parsed = JSON.parse(data);
245
+ if (res.statusCode >= 400) {
246
+ reject(new Error(parsed.message || `HTTP ${res.statusCode}`));
247
+ } else {
248
+ resolve(parsed);
249
+ }
250
+ } catch {
251
+ reject(new Error(`Invalid response: ${data.slice(0, 200)}`));
252
+ }
253
+ });
254
+ });
255
+
256
+ req.on('error', reject);
257
+ req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out')); });
258
+ req.end();
259
+ });
260
+ }
261
+
155
262
  _post(urlPath, body, headers = {}) {
156
263
  if (!headers['Content-Type']) headers['Content-Type'] = 'application/json';
157
264
  const jsonBody = JSON.stringify(body);