@openagents-org/agent-launcher 0.2.69 → 0.2.72

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-launcher",
3
- "version": "0.2.69",
3
+ "version": "0.2.72",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -313,6 +313,17 @@ class BaseAdapter {
313
313
  } catch {}
314
314
  }
315
315
 
316
+ async sendThinking(channel, content) {
317
+ try {
318
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
319
+ senderType: 'agent',
320
+ senderName: this.agentName,
321
+ messageType: 'thinking',
322
+ metadata: { agent_mode: this._mode },
323
+ });
324
+ } catch {}
325
+ }
326
+
316
327
  async sendResponse(channel, content) {
317
328
  await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
318
329
  senderType: 'agent',
@@ -192,7 +192,7 @@ class ClaudeAdapter extends BaseAdapter {
192
192
  return null;
193
193
  }
194
194
 
195
- _buildClaudeCmd(prompt, channelName) {
195
+ _buildClaudeCmd(prompt, channelName, { skipResume = false } = {}) {
196
196
  const claudeBin = this._findClaudeBinary();
197
197
  if (!claudeBin) {
198
198
  throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
@@ -252,9 +252,9 @@ class ClaudeAdapter extends BaseAdapter {
252
252
  cmd.push('--allowedTools', ...allowed);
253
253
  cmd.push('--disallowedTools', 'AskUserQuestion');
254
254
 
255
- // Resume existing conversation
255
+ // Resume existing conversation (skipped on retry after stale session)
256
256
  const sessionId = this._channelSessions[channelName];
257
- if (sessionId) {
257
+ if (sessionId && !skipResume) {
258
258
  cmd.push('--resume', sessionId);
259
259
  }
260
260
 
@@ -389,20 +389,25 @@ class ClaudeAdapter extends BaseAdapter {
389
389
 
390
390
  let mcpConfigFile = null;
391
391
  let cmd;
392
- try {
393
- const result = this._buildClaudeCmd(content, msgChannel);
394
- cmd = result.cmd;
395
- mcpConfigFile = result.mcpConfigFile;
396
- } catch (e) {
397
- await this.sendError(msgChannel, e.message);
398
- return;
399
- }
400
392
 
401
393
  // Clean env
402
394
  const cleanEnv = { ...(this.agentEnv || process.env) };
403
395
  delete cleanEnv.CLAUDECODE;
404
396
  delete cleanEnv.CLAUDE_CODE_SESSION;
405
397
 
398
+ // Run up to 2 attempts: first with session resume, then fresh if stale session detected
399
+ let _shouldRetry = false;
400
+ for (let attempt = 0; attempt < 2; attempt++) {
401
+ if (mcpConfigFile) { try { fs.unlinkSync(mcpConfigFile); } catch {} mcpConfigFile = null; }
402
+ try {
403
+ const built = this._buildClaudeCmd(content, msgChannel, { skipResume: attempt > 0 });
404
+ cmd = built.cmd;
405
+ mcpConfigFile = built.mcpConfigFile;
406
+ } catch (e) {
407
+ await this.sendError(msgChannel, e.message);
408
+ return;
409
+ }
410
+
406
411
  try {
407
412
  // Always resolve shim/symlink to node + JS entry point.
408
413
  // On Windows: .cmd shims need cmd.exe which creates visible windows.
@@ -428,13 +433,14 @@ class ClaudeAdapter extends BaseAdapter {
428
433
  let postedThinking = false;
429
434
  let stderrBuf = '';
430
435
  let lineBuffer = '';
436
+ let _pendingLines = Promise.resolve(); // chain of in-flight processLine calls
431
437
 
432
438
  // Capture stderr for diagnostics
433
439
  if (proc.stderr) {
434
440
  proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
435
441
  }
436
442
 
437
- await new Promise((resolve, reject) => {
443
+ _shouldRetry = await new Promise((resolve, reject) => {
438
444
  let consecutiveTimeouts = 0;
439
445
  let lastDataTime = Date.now();
440
446
  let timeoutTimer = null;
@@ -484,6 +490,8 @@ class ClaudeAdapter extends BaseAdapter {
484
490
  }
485
491
  lastResponseText.push(block.text.trim());
486
492
  postedThinking = true;
493
+ // Stream text in real-time as "thinking" (same as Python adapter)
494
+ try { await this.sendThinking(msgChannel, block.text.trim()); } catch {}
487
495
  } else if (block.type === 'tool_use') {
488
496
  hasToolUseSinceLastText = true;
489
497
  postedThinking = false;
@@ -530,6 +538,9 @@ class ClaudeAdapter extends BaseAdapter {
530
538
  proc.on('exit', async (code) => {
531
539
  if (timeoutTimer) clearInterval(timeoutTimer);
532
540
 
541
+ // Wait for all in-flight processLine calls to complete
542
+ try { await _pendingLines; } catch {}
543
+
533
544
  // Process remaining buffer
534
545
  const lines = lineBuffer.split('\n');
535
546
  for (const line of lines) {
@@ -550,10 +561,19 @@ class ClaudeAdapter extends BaseAdapter {
550
561
  if (fullResponse) {
551
562
  try { await this.sendResponse(msgChannel, fullResponse); } catch {}
552
563
  }
553
- } else if (!postedThinking) {
554
- try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
564
+ resolve(false); // done, no retry
565
+ } else if (code !== 0 && this._channelSessions[msgChannel]) {
566
+ // No output + error exit + had a resume session → stale session, signal retry
567
+ this._log(`Stale session detected for ${msgChannel}, clearing and retrying without resume`);
568
+ delete this._channelSessions[msgChannel];
569
+ this._saveSessions();
570
+ resolve(true); // retry=true
571
+ } else {
572
+ if (!postedThinking) {
573
+ try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
574
+ }
575
+ resolve(false);
555
576
  }
556
- resolve();
557
577
  });
558
578
 
559
579
  proc.on('error', (err) => {
@@ -561,26 +581,29 @@ class ClaudeAdapter extends BaseAdapter {
561
581
  reject(err);
562
582
  });
563
583
 
564
- // Process lines as they arrive
584
+ // Process lines as they arrive (chained to preserve order)
565
585
  proc.stdout.on('data', (chunk) => {
566
586
  lineBuffer += chunk.toString('utf-8');
567
587
  resetTimeout();
568
588
  const lines = lineBuffer.split('\n');
569
589
  lineBuffer = lines.pop(); // keep incomplete line
570
590
  for (const line of lines) {
571
- processLine(line).catch(() => {});
591
+ _pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
572
592
  }
573
593
  });
574
594
  });
575
595
  } catch (e) {
576
596
  this._log(`Error handling message: ${e.message}`);
577
597
  await this.sendError(msgChannel, `Error processing message: ${e.message}`);
578
- } finally {
579
- if (mcpConfigFile) {
580
- try { fs.unlinkSync(mcpConfigFile); } catch {}
581
- }
582
- delete this._channelProcesses[msgChannel];
598
+ break; // no retry on spawn error
599
+ }
600
+ if (!_shouldRetry) break; // exit loop if no retry needed
601
+ } // end for attempt
602
+
603
+ if (mcpConfigFile) {
604
+ try { fs.unlinkSync(mcpConfigFile); } catch {}
583
605
  }
606
+ delete this._channelProcesses[msgChannel];
584
607
  }
585
608
  }
586
609
 
package/src/daemon.js CHANGED
@@ -408,6 +408,7 @@ class Daemon {
408
408
  openclawAgentId: agentCfg.openclaw_agent_id || 'main',
409
409
  disabledModules: new Set(),
410
410
  agentEnv: this._buildAgentEnv(agentCfg),
411
+ workingDir: agentCfg.path || undefined,
411
412
  });
412
413
  } catch (e) {
413
414
  this._log(`${name} failed to create ${agentType} adapter: ${e.message}`);
package/src/installer.js CHANGED
@@ -196,7 +196,7 @@ class Installer {
196
196
  // Use bundled node/npm if system npm not available
197
197
  if (cmd.startsWith('npm install')) {
198
198
  const prefixDir = path.join(os.homedir(), '.openagents', 'nodejs');
199
- const args = cmd.replace('npm install', 'install --ignore-scripts --no-save').replace(' -g ', ` --prefix "${prefixDir}" `);
199
+ const args = cmd.replace('npm install', 'install --ignore-scripts --no-save --install-strategy=nested').replace(' -g ', ` --prefix "${prefixDir}" `);
200
200
  cmd = this._resolveNpmCommand(args);
201
201
  }
202
202
 
@@ -232,7 +232,8 @@ class Installer {
232
232
  let cmd = rawCmd;
233
233
  if (rawCmd.startsWith('npm install')) {
234
234
  const prefixDir2 = path.join(os.homedir(), '.openagents', 'nodejs');
235
- const args = rawCmd.replace('npm install', 'install --loglevel=verbose --ignore-scripts --no-save').replace(' -g ', ` --prefix "${prefixDir2}" `);
235
+ // --install-strategy=nested prevents npm from pruning existing packages in the prefix
236
+ const args = rawCmd.replace('npm install', 'install --loglevel=verbose --ignore-scripts --no-save --install-strategy=nested').replace(' -g ', ` --prefix "${prefixDir2}" `);
236
237
  cmd = this._resolveNpmCommand(args);
237
238
  } else if (rawCmd.startsWith('pip install') || rawCmd.startsWith('pipx install')) {
238
239
  cmd = rawCmd; // pip commands stay as-is
package/src/mcp-server.js CHANGED
@@ -13,8 +13,13 @@
13
13
  */
14
14
 
15
15
  const readline = require('readline');
16
+ const { execSync, spawn: spawnChild } = require('child_process');
17
+ const net = require('net');
16
18
  const { WorkspaceClient } = require('./workspace-client');
17
19
 
20
+ // Active tunnels: port → { proc, url }
21
+ const _activeTunnels = {};
22
+
18
23
  // ── Tool definitions ────────────────────────────────────────────────────────
19
24
 
20
25
  function buildToolDefs(disabledModules) {
@@ -185,6 +190,42 @@ function buildToolDefs(disabledModules) {
185
190
  );
186
191
  }
187
192
 
193
+ // -- Tunnel module --
194
+ if (!disabledModules.has('tunnel')) {
195
+ tools.push(
196
+ {
197
+ name: 'tunnel_expose',
198
+ description:
199
+ 'Expose a local port as a public URL via Cloudflare tunnel. ' +
200
+ 'Use this to let workspace users preview a local dev server ' +
201
+ '(e.g. React, Next.js, Flask on localhost). Returns the public URL.',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ port: { type: 'integer', description: 'Local port to expose (e.g. 3000)' },
206
+ },
207
+ required: ['port'],
208
+ },
209
+ },
210
+ {
211
+ name: 'tunnel_close',
212
+ description: 'Close a tunnel that was previously opened with tunnel_expose.',
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ port: { type: 'integer', description: 'Port of the tunnel to close' },
217
+ },
218
+ required: ['port'],
219
+ },
220
+ },
221
+ {
222
+ name: 'tunnel_list',
223
+ description: 'List all active tunnels.',
224
+ inputSchema: { type: 'object', properties: {} },
225
+ },
226
+ );
227
+ }
228
+
188
229
  return tools;
189
230
  }
190
231
 
@@ -196,6 +237,10 @@ const MIME_MAP = {
196
237
  '.html': 'text/html', '.css': 'text/css', '.xml': 'application/xml',
197
238
  '.yaml': 'application/yaml', '.yml': 'application/yaml',
198
239
  '.csv': 'text/csv', '.log': 'text/plain', '.sh': 'text/x-shellscript',
240
+ '.toml': 'application/toml', '.rs': 'text/x-rust', '.go': 'text/x-go',
241
+ '.java': 'text/x-java', '.rb': 'text/x-ruby', '.c': 'text/x-c',
242
+ '.cpp': 'text/x-c++', '.h': 'text/x-c', '.tsx': 'text/typescript',
243
+ '.jsx': 'text/javascript', '.sql': 'application/sql',
199
244
  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
200
245
  '.gif': 'image/gif', '.svg': 'image/svg+xml', '.pdf': 'application/pdf',
201
246
  };
@@ -421,6 +466,100 @@ class McpServer {
421
466
  return text(`Browser tab closed: ${args.tab_id}`);
422
467
  }
423
468
 
469
+ // ── Tunnel ──
470
+
471
+ case 'tunnel_expose': {
472
+ const port = args.port;
473
+ if (!port) throw new Error('port is required');
474
+
475
+ if (_activeTunnels[port]) {
476
+ return text(`Tunnel already open for port ${port}: ${_activeTunnels[port].url}`);
477
+ }
478
+
479
+ // Check cloudflared is available
480
+ let cfBin;
481
+ try {
482
+ cfBin = execSync('which cloudflared 2>/dev/null || echo ""', { encoding: 'utf-8' }).trim();
483
+ } catch {}
484
+ if (!cfBin) {
485
+ return text(
486
+ 'Error: cloudflared is not installed. Install it:\n' +
487
+ ' macOS: brew install cloudflared\n' +
488
+ ' Linux: curl -fsSL https://github.com/cloudflare/cloudflared/' +
489
+ 'releases/latest/download/cloudflared-linux-amd64 ' +
490
+ '-o /usr/local/bin/cloudflared && chmod +x /usr/local/bin/cloudflared'
491
+ );
492
+ }
493
+
494
+ // Pre-flight: check port is listening
495
+ const portOpen = await new Promise((resolve) => {
496
+ const sock = new net.Socket();
497
+ sock.setTimeout(2000);
498
+ sock.on('connect', () => { sock.destroy(); resolve(true); });
499
+ sock.on('error', () => resolve(false));
500
+ sock.on('timeout', () => { sock.destroy(); resolve(false); });
501
+ sock.connect(port, 'localhost');
502
+ });
503
+ if (!portOpen) {
504
+ return text(`Error: nothing is listening on localhost:${port}. Start a server on that port first.`);
505
+ }
506
+
507
+ // Start cloudflared
508
+ const proc = spawnChild('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
509
+ stdio: ['ignore', 'pipe', 'pipe'],
510
+ detached: true,
511
+ });
512
+
513
+ // Wait for URL from stderr (cloudflared logs the URL there)
514
+ const url = await new Promise((resolve, reject) => {
515
+ const urlRe = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
516
+ let buf = '';
517
+ const timeout = setTimeout(() => {
518
+ proc.kill();
519
+ reject(new Error('cloudflared did not produce a URL within 20 seconds'));
520
+ }, 20000);
521
+
522
+ const onData = (chunk) => {
523
+ buf += chunk.toString();
524
+ const match = buf.match(urlRe);
525
+ if (match) {
526
+ clearTimeout(timeout);
527
+ proc.stderr.removeListener('data', onData);
528
+ resolve(match[0]);
529
+ }
530
+ };
531
+ proc.stderr.on('data', onData);
532
+ proc.on('exit', (code) => {
533
+ clearTimeout(timeout);
534
+ reject(new Error(`cloudflared exited with code ${code}: ${buf.slice(0, 200)}`));
535
+ });
536
+ });
537
+
538
+ _activeTunnels[port] = { proc, url };
539
+ proc.unref(); // don't block MCP server exit
540
+ return text(`Tunnel open: localhost:${port} → ${url}`);
541
+ }
542
+
543
+ case 'tunnel_close': {
544
+ const port = args.port;
545
+ const tunnel = _activeTunnels[port];
546
+ if (!tunnel) return text(`No tunnel open for port ${port}`);
547
+ try { tunnel.proc.kill(); } catch {}
548
+ delete _activeTunnels[port];
549
+ return text(`Tunnel closed for port ${port}`);
550
+ }
551
+
552
+ case 'tunnel_list': {
553
+ const ports = Object.keys(_activeTunnels);
554
+ if (!ports.length) return text('No active tunnels.');
555
+ const lines = ports.map((p) => {
556
+ const t = _activeTunnels[p];
557
+ const running = t.proc && t.proc.exitCode === null;
558
+ return `- localhost:${p} → ${t.url} (${running ? 'running' : 'stopped'})`;
559
+ });
560
+ return text(lines.join('\n'));
561
+ }
562
+
424
563
  default:
425
564
  throw new Error(`Unknown tool: ${name}`);
426
565
  }