@openagents-org/agent-connector 0.1.0 → 0.1.2

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.2",
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,234 @@ 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 spawnEnv = { ...env };
485
+ if (IS_WINDOWS) {
486
+ const npmBin = path.join(process.env.APPDATA || '', 'npm');
487
+ if (npmBin && !(spawnEnv.PATH || '').includes(npmBin)) {
488
+ spawnEnv.PATH = npmBin + ';' + (spawnEnv.PATH || process.env.PATH || '');
489
+ }
490
+ }
491
+
492
+ // On Windows, .cmd shims need shell:true but that breaks argument
493
+ // quoting. Use execFileSync-style approach: resolve the .cmd to its
494
+ // target and run directly, or use spawn without shell and full path.
495
+ let spawnBinary = binary;
496
+ let spawnArgs = args;
497
+ const spawnOpts = {
498
+ stdio: ['ignore', 'pipe', 'pipe'],
499
+ env: spawnEnv,
500
+ timeout: 600000,
501
+ };
502
+
503
+ if (IS_WINDOWS) {
504
+ // Find the actual .cmd shim and invoke via cmd.exe /C with proper quoting
505
+ spawnBinary = process.env.COMSPEC || 'cmd.exe';
506
+ // Wrap argument containing spaces in double quotes for cmd.exe
507
+ const quotedArgs = args.map((a) => a.includes(' ') ? `"${a}"` : a);
508
+ spawnArgs = ['/C', binary, ...quotedArgs];
509
+ }
510
+
511
+ const proc = spawn(spawnBinary, spawnArgs, spawnOpts);
512
+ let stdout = '';
513
+ let stderr = '';
514
+
515
+ if (proc.stdout) proc.stdout.on('data', (d) => { stdout += d; });
516
+ if (proc.stderr) proc.stderr.on('data', (d) => { stderr += d; });
517
+
518
+ proc.on('error', (err) => reject(err));
519
+ proc.on('exit', (code) => {
520
+ if (code !== 0) {
521
+ reject(new Error(`CLI exited ${code}: ${stderr.slice(0, 300)}`));
522
+ return;
523
+ }
524
+
525
+ stdout = stdout.trim();
526
+ if (!stdout) { resolve(''); return; }
527
+
528
+ // Parse JSON output — find first '{'
529
+ const jsonStart = stdout.indexOf('{');
530
+ if (jsonStart < 0) { resolve(stdout); return; }
531
+
532
+ try {
533
+ const data = JSON.parse(stdout.slice(jsonStart));
534
+ const payloads = data.payloads || [];
535
+ if (payloads.length > 0) {
536
+ const texts = payloads.filter((p) => p.text).map((p) => p.text);
537
+ resolve(texts.join('\n\n'));
538
+ } else {
539
+ resolve(stdout.slice(0, jsonStart).trim() || '');
540
+ }
541
+ } catch {
542
+ resolve(stdout);
543
+ }
544
+ });
545
+ });
546
+ }
547
+
548
+ _resolveAgentBinary(agentCfg) {
549
+ const entry = this.registry.getEntry(agentCfg.type);
550
+ let binary = (entry && entry.install && entry.install.binary);
551
+ if (!binary) {
552
+ const knownBinaries = {
553
+ openclaw: 'openclaw', claude: 'claude', codex: 'codex',
554
+ aider: 'aider', goose: 'goose', gemini: 'gemini',
555
+ };
556
+ binary = knownBinaries[agentCfg.type];
557
+ }
558
+ return binary || null;
559
+ }
560
+
561
+ // ---------------------------------------------------------------------------
562
+ // Internal — spawn loop (local-only agents)
563
+ // ---------------------------------------------------------------------------
564
+
336
565
  _spawnAgent(cmd, opts) {
337
566
  const [binary, ...args] = cmd;
338
567
  const spawnOpts = {
@@ -341,8 +570,14 @@ class Daemon {
341
570
  cwd: opts.cwd,
342
571
  };
343
572
 
344
- if (IS_WINDOWS && binary.toLowerCase().endsWith('.cmd')) {
573
+ if (IS_WINDOWS) {
574
+ // On Windows, always use shell so .cmd/.ps1 shims on PATH are found
345
575
  spawnOpts.shell = true;
576
+ // Ensure npm global bin is on PATH
577
+ const npmBin = path.join(process.env.APPDATA || '', 'npm');
578
+ if (npmBin && !(process.env.PATH || '').includes(npmBin)) {
579
+ spawnOpts.env = { ...spawnOpts.env, PATH: npmBin + ';' + (spawnOpts.env.PATH || process.env.PATH || '') };
580
+ }
346
581
  }
347
582
 
348
583
  const proc = spawn(binary, args, spawnOpts);
@@ -358,19 +593,29 @@ class Daemon {
358
593
  }
359
594
 
360
595
  _getLaunchCommand(agentCfg) {
361
- const entry = this.registry.getEntry(agentCfg.type);
362
- if (!entry || !entry.install || !entry.install.binary) return null;
596
+ const binary = this._resolveAgentBinary(agentCfg);
597
+ if (!binary) return null;
363
598
 
364
- const binary = entry.install.binary;
599
+ const entry = this.registry.getEntry(agentCfg.type);
365
600
  const args = [];
366
601
 
367
602
  // Add launch args from registry
368
- if (entry.launch && entry.launch.args) {
603
+ if (entry && entry.launch && entry.launch.args) {
369
604
  for (const arg of entry.launch.args) {
370
605
  args.push(arg.replace(/\{agent_name\}/g, agentCfg.name));
371
606
  }
372
607
  }
373
608
 
609
+ // Built-in launch profiles for local-only agents
610
+ if (!args.length) {
611
+ const type = agentCfg.type || '';
612
+ if (type === 'claude') {
613
+ args.push('--print');
614
+ } else if (type === 'codex') {
615
+ args.push('--quiet');
616
+ }
617
+ }
618
+
374
619
  return [binary, ...args];
375
620
  }
376
621
 
@@ -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);