@openagents-org/agent-launcher 0.2.128 → 0.2.131

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.128",
3
+ "version": "0.2.131",
4
4
  "description": "OpenAgents Launcher — install, configure, and run AI coding agents from your terminal",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/registry.json CHANGED
@@ -209,49 +209,46 @@
209
209
  "order": 4,
210
210
  "builtin": true,
211
211
  "install": {
212
- "binary": "cursor",
213
- "verify": "cursor --version 2>/dev/null | grep -qi cursor",
214
- "verify_win": "cursor --version 2>nul | findstr /i cursor",
215
- "requires": [
216
- null
212
+ "binary": "cursor-agent",
213
+ "binary_aliases": [
214
+ "agent"
217
215
  ],
216
+ "verify": "cursor-agent --version 2>/dev/null | head -1",
217
+ "verify_win": "cursor-agent --version 2>nul",
218
+ "requires": [],
218
219
  "macos": "curl https://cursor.com/install -fsSL | bash",
219
220
  "linux": "curl https://cursor.com/install -fsSL | bash",
220
- "windows": "echo 'Download Cursor from https://cursor.com/downloads'"
221
+ "windows": "\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -Command \"Invoke-RestMethod -UseBasicParsing 'https://cursor.com/install?win32=true' | Invoke-Expression\""
221
222
  },
222
223
  "adapter": {
223
224
  "module": "openagents.adapters.cursor",
224
225
  "class": "CursorAdapter"
225
226
  },
226
227
  "launch": {
227
- "args": [
228
- null
229
- ]
228
+ "args": []
230
229
  },
231
230
  "env_config": [
232
231
  {
233
- "name": "OPENAI_API_KEY",
234
- "description": "API key for LLM inference",
235
- "required": true,
232
+ "name": "CURSOR_API_KEY",
233
+ "description": "Cursor API key for CLI authentication",
234
+ "required": false,
236
235
  "password": true
236
+ },
237
+ {
238
+ "name": "CURSOR_MODEL",
239
+ "description": "Model to use (e.g. claude-sonnet-4-6, gpt-4o)",
240
+ "required": false
237
241
  }
238
242
  ],
239
243
  "check_ready": {
240
- "env_vars": [
241
- "OPENAI_API_KEY"
242
- ],
243
- "saved_env_key": "OPENAI_API_KEY",
244
- "not_ready_message": "No API key \u2014 press e to configure"
244
+ "binary": "agent",
245
+ "not_ready_message": "Cursor CLI not found \u2014 install with: curl https://cursor.com/install -fsSL | bash"
245
246
  },
246
247
  "resolve_env": {
247
248
  "rules": [
248
249
  {
249
250
  "from": "LLM_API_KEY",
250
- "to": "OPENAI_API_KEY"
251
- },
252
- {
253
- "from": "LLM_BASE_URL",
254
- "to": "OPENAI_BASE_URL"
251
+ "to": "CURSOR_API_KEY"
255
252
  },
256
253
  {
257
254
  "from": "LLM_MODEL",
@@ -42,6 +42,7 @@ class BaseAdapter {
42
42
  this.workingDir = workingDir || undefined;
43
43
  this.client = new WorkspaceClient(this.endpoint);
44
44
  this._lastEventId = null;
45
+ this._lastToolResultId = null;
45
46
  this._running = false;
46
47
  this._sessionId = null; // issued by server on /v1/join; used to prove liveness
47
48
  this._processedIds = new Set();
@@ -52,6 +53,11 @@ class BaseAdapter {
52
53
  // Per-channel task tracking for parallel execution
53
54
  this._channelBusy = new Set();
54
55
  this._channelQueues = {};
56
+ // Cached workspace.browser_enabled. Populated lazily on first read so we
57
+ // don't pay an HTTP roundtrip per message — adapters that toggle the
58
+ // workspace flag must reconnect/restart to pick up the change (matches
59
+ // the Python adapter behavior in workspace_prompt.py).
60
+ this._browserEnabledCache = null;
55
61
  // Wall-clock timestamp of adapter init, used by the `status` control
56
62
  // action to report uptime back to the channel. Reset on reinstantiation
57
63
  // (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
@@ -209,6 +215,8 @@ class BaseAdapter {
209
215
  async _onControlAction(action, payload) {
210
216
  if (action === 'status') {
211
217
  await this._postStatusReport(payload);
218
+ } else if (action === 'routines') {
219
+ await this._postRoutinesReport(payload);
212
220
  }
213
221
  }
214
222
 
@@ -261,6 +269,74 @@ class BaseAdapter {
261
269
  }
262
270
  }
263
271
 
272
+ /**
273
+ * Post a markdown table of the agent's active routines back to the
274
+ * requesting channel. Used by the `/routines` slash command. Each agent
275
+ * reports only routines it owns (created_by === openagents:<agentName>)
276
+ * so the user sees a clear "my routines" view per agent, mirroring how
277
+ * /status reports per-agent uptime.
278
+ */
279
+ async _postRoutinesReport(payload) {
280
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
281
+ if (!channel) return;
282
+
283
+ let routines = [];
284
+ try {
285
+ const data = await this.client.listRoutines(this.workspaceId, channel, this.token);
286
+ // Accept both the canonical `openagents:<name>` source and the bare
287
+ // `<name>` form. Agents that follow the workspace prompt verbatim
288
+ // produce the prefixed form, but some agents send the bare name when
289
+ // they construct the POST body themselves.
290
+ const prefixed = `openagents:${this.agentName}`;
291
+ routines = ((data && data.routines) || []).filter(
292
+ (r) => r.created_by === prefixed || r.created_by === this.agentName,
293
+ );
294
+ } catch (e) {
295
+ this._log(`Routines: failed to list: ${e && e.message ? e.message : e}`);
296
+ try {
297
+ await this.client.sendMessage(
298
+ this.workspaceId, channel, this.token,
299
+ `**Routines for \`${this.agentName}\`**\n\n_Failed to fetch routines._`,
300
+ { senderType: 'agent', senderName: this.agentName, messageType: 'chat', sessionId: this._sessionId },
301
+ );
302
+ } catch {}
303
+ return;
304
+ }
305
+
306
+ let content;
307
+ if (!routines.length) {
308
+ content = `**Routines for \`${this.agentName}\`**\n\n_No active routines._`;
309
+ } else {
310
+ const rows = routines.map((r) => {
311
+ const schedule = (r.schedule_interval_minutes != null)
312
+ ? `every ${r.schedule_interval_minutes} min`
313
+ : `${String(r.schedule_hour ?? 0).padStart(2, '0')}:${String(r.schedule_minute ?? 0).padStart(2, '0')} UTC` +
314
+ (r.schedule_days ? ` (days [${r.schedule_days.join(',')}])` : ' daily');
315
+ const next = r.next_fires_at || '—';
316
+ const name = String(r.name || '').replace(/\|/g, '\\|');
317
+ const id = String(r.id || '').slice(0, 8);
318
+ return `| \`${id}\` | ${name} | ${schedule} | ${next} |`;
319
+ });
320
+ content =
321
+ `**Routines for \`${this.agentName}\`** (${routines.length})\n\n` +
322
+ '| ID | Name | Schedule | Next fires |\n' +
323
+ '|---|---|---|---|\n' +
324
+ rows.join('\n');
325
+ }
326
+
327
+ try {
328
+ await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
329
+ senderType: 'agent',
330
+ senderName: this.agentName,
331
+ messageType: 'chat',
332
+ metadata: { agent_mode: this._mode },
333
+ sessionId: this._sessionId,
334
+ });
335
+ } catch (e) {
336
+ this._log(`Routines: failed to post: ${e && e.message ? e.message : e}`);
337
+ }
338
+ }
339
+
264
340
  _hasActiveWork() {
265
341
  return this._channelBusy.size > 0;
266
342
  }
@@ -305,7 +381,7 @@ class BaseAdapter {
305
381
 
306
382
  while (this._running) {
307
383
  pollCount++;
308
- let messages, rawCursor;
384
+ let messages, rawCursor, composingActive = false;
309
385
  try {
310
386
  const result = await this.client.pollPending(
311
387
  this.workspaceId, this.agentName, this.token,
@@ -313,8 +389,9 @@ class BaseAdapter {
313
389
  );
314
390
  messages = result.messages;
315
391
  rawCursor = result.cursor;
392
+ composingActive = !!result.composing;
316
393
  if (pollCount <= 3 || pollCount % 20 === 0) {
317
- this._log(`Poll #${pollCount}: ${messages.length} messages, cursor=${rawCursor || 'none'}`);
394
+ this._log(`Poll #${pollCount}: ${messages.length} messages, cursor=${rawCursor || 'none'}${composingActive ? ' composing' : ''}`);
318
395
  }
319
396
  } catch (e) {
320
397
  this._log(`Poll #${pollCount} failed: ${e.message} \nStack: ${e.stack}`);
@@ -358,11 +435,51 @@ class BaseAdapter {
358
435
  idleCount++;
359
436
  }
360
437
 
361
- // Adaptive polling: 2s active, up to 15s idle.
362
- // Each connected agent runs this loop, so faster rates multiply across
363
- // every workspace member — keep this conservative and tune separately
364
- // with a load-impact analysis on workspace-endpoint.
365
- const delay = incoming.length > 0 ? 2000 : Math.min(2000 + idleCount * 1000, 15000);
438
+ // Sidecar poll: A2UI tool_result events. These are the user's response
439
+ // to a UI spec this agent (or any agent in the network) emitted. We
440
+ // surface each one as a synthetic user message so the LLM sees it as
441
+ // the next turn and can react. Failures here don't break the main
442
+ // message poll.
443
+ try {
444
+ const toolResult = await this.client.pollToolResults(
445
+ this.workspaceId, this.token,
446
+ { after: this._lastToolResultId }
447
+ );
448
+ if (toolResult.cursor) this._lastToolResultId = toolResult.cursor;
449
+ for (const event of toolResult.events || []) {
450
+ const msgId = event.id;
451
+ if (msgId && this._processedIds.has(msgId)) continue;
452
+ if (msgId) this._processedIds.add(msgId);
453
+ const synth = synthesizeToolResultMessage(event);
454
+ if (synth) await this._dispatchMessage(synth);
455
+ }
456
+ } catch (e) {
457
+ // Non-fatal — log once per poll if it fails
458
+ if (pollCount <= 3 || pollCount % 20 === 0) {
459
+ this._log(`tool_result poll #${pollCount} failed: ${e.message}`);
460
+ }
461
+ }
462
+
463
+ // Adaptive polling with warm plateau:
464
+ // Active (messages incoming): 2s
465
+ // Warm (≤5 min since last msg): 5s
466
+ // Cooldown (5-7 min): 5s → 15s (ramp 1s per idle poll)
467
+ // Cold (>7 min): 15s
468
+ // The warm plateau keeps the agent responsive during typical user
469
+ // think-time between messages without hammering the backend.
470
+ const WARM_INTERVAL = 5000;
471
+ const WARM_POLLS = 60; // 60 × 5s = 5 minutes warm plateau
472
+ let delay;
473
+ if (incoming.length > 0) {
474
+ delay = 2000;
475
+ } else if (composingActive) {
476
+ delay = 2000;
477
+ idleCount = Math.min(idleCount, WARM_POLLS);
478
+ } else if (idleCount <= WARM_POLLS) {
479
+ delay = WARM_INTERVAL;
480
+ } else {
481
+ delay = Math.min(WARM_INTERVAL + (idleCount - WARM_POLLS) * 1000, 15000);
482
+ }
366
483
  await this._sleep(delay);
367
484
  }
368
485
  }
@@ -421,6 +538,9 @@ class BaseAdapter {
421
538
  const queue = this._channelQueues[channel];
422
539
  if (!queue || queue.length === 0) break;
423
540
  const nextMsg = queue.shift();
541
+ if (nextMsg._queueId) {
542
+ try { await this.sendStatus(channel, 'processing queued message', { queue_id: nextMsg._queueId, queue_status: 'processed' }); } catch {}
543
+ }
424
544
  try {
425
545
  await this._handleMessage(nextMsg);
426
546
  } catch (e) {
@@ -473,8 +593,15 @@ class BaseAdapter {
473
593
  }
474
594
 
475
595
  async sendThinking(channel, content) {
596
+ // Strip ```a2ui blocks if they leak into Claude's intermediate thinking
597
+ // trace — the real spec gets emitted via sendResponse with proper
598
+ // payload.spec extraction, so showing the raw block here is just noise
599
+ // (and a duplicate). If stripping leaves the thinking message empty,
600
+ // skip it entirely.
601
+ const { cleanContent } = extractA2UISpec(content);
602
+ if (!cleanContent || !cleanContent.trim()) return;
476
603
  try {
477
- await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
604
+ await this.client.sendMessage(this.workspaceId, channel, this.token, cleanContent, {
478
605
  senderType: 'agent',
479
606
  senderName: this.agentName,
480
607
  messageType: 'thinking',
@@ -487,11 +614,14 @@ class BaseAdapter {
487
614
  }
488
615
 
489
616
  async sendResponse(channel, content) {
617
+ const { cleanContent, spec, specToolCallId } = extractA2UISpec(content);
490
618
  try {
491
- await this.client.sendMessage(this.workspaceId, channel, this.token, content, {
619
+ await this.client.sendMessage(this.workspaceId, channel, this.token, cleanContent, {
492
620
  senderType: 'agent',
493
621
  senderName: this.agentName,
494
622
  sessionId: this._sessionId,
623
+ spec,
624
+ specToolCallId,
495
625
  });
496
626
  } catch (e) {
497
627
  if (e instanceof SessionRevokedError) {
@@ -597,6 +727,95 @@ class BaseAdapter {
597
727
  _sleep(ms) {
598
728
  return new Promise((resolve) => setTimeout(resolve, ms));
599
729
  }
730
+
731
+ /**
732
+ * Return whether the workspace has the Browser Fabric viewer toggle on.
733
+ * Cached for the lifetime of the adapter — restart to pick up a flip.
734
+ * Falls back to false on error so the prompt builders don't accidentally
735
+ * inject the strong directive against an older backend that can't route
736
+ * to Browser Fabric.
737
+ */
738
+ async getBrowserEnabled() {
739
+ if (this._browserEnabledCache === null) {
740
+ try {
741
+ const meta = await this.client.getWorkspaceMetadata(this.workspaceId, this.token);
742
+ this._browserEnabledCache = !!(meta && meta.browserEnabled);
743
+ } catch (e) {
744
+ this._browserEnabledCache = false;
745
+ }
746
+ }
747
+ return this._browserEnabledCache;
748
+ }
749
+ }
750
+
751
+ // ------------------------------------------------------------------
752
+ // A2UI helpers
753
+ // ------------------------------------------------------------------
754
+
755
+ /**
756
+ * Pull the first ```a2ui ... ``` fenced block out of LLM-produced content.
757
+ * Returns the content with the block stripped, the parsed spec, and a
758
+ * tool-call id derived from `spec.tool_call_id` (if present) or a new one.
759
+ * If no block is present or parsing fails, returns the content unchanged
760
+ * with null spec — the message still goes out as plain markdown.
761
+ */
762
+ /**
763
+ * Convert a workspace.tool_result event into a synthetic user-message
764
+ * shape that the agent's _handleMessage can dispatch. The LLM sees this
765
+ * as the next user turn — the content is a short, machine-readable line
766
+ * the LLM can parse without ambiguity. The original spec it emitted is
767
+ * already in the LLM's conversation history; the tool_call_id lets the
768
+ * LLM correlate this back.
769
+ */
770
+ function synthesizeToolResultMessage(event) {
771
+ if (!event || !event.payload) return null;
772
+ const p = event.payload;
773
+ const actionId = p.action_id || '';
774
+ const toolCallId = p.tool_call_id || '';
775
+ let valueStr = '';
776
+ if (p.value !== undefined && p.value !== null) {
777
+ try { valueStr = JSON.stringify(p.value); } catch (_) { valueStr = String(p.value); }
778
+ }
779
+ const lines = [
780
+ '[ui_action]',
781
+ `action=${actionId}`,
782
+ toolCallId ? `tool_call_id=${toolCallId}` : null,
783
+ valueStr ? `value=${valueStr}` : null,
784
+ ].filter(Boolean);
785
+ const content = lines.join(' ');
786
+ const target = event.target || '';
787
+ return {
788
+ messageId: event.id || '',
789
+ sessionId: target.startsWith('channel/') ? target.replace('channel/', '') : target,
790
+ senderType: 'human',
791
+ senderName: 'user',
792
+ content,
793
+ mentions: [],
794
+ messageType: 'chat',
795
+ metadata: event.metadata || {},
796
+ };
797
+ }
798
+
799
+ function extractA2UISpec(content) {
800
+ if (!content || typeof content !== 'string') {
801
+ return { cleanContent: content, spec: null, specToolCallId: null };
802
+ }
803
+ const match = content.match(/```a2ui\s*\n([\s\S]*?)\n```/);
804
+ if (!match) return { cleanContent: content, spec: null, specToolCallId: null };
805
+
806
+ let spec;
807
+ try {
808
+ spec = JSON.parse(match[1]);
809
+ } catch (_) {
810
+ return { cleanContent: content, spec: null, specToolCallId: null };
811
+ }
812
+
813
+ const specToolCallId = (spec && spec.tool_call_id) || `tc_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
814
+ if (spec && spec.tool_call_id) delete spec.tool_call_id;
815
+
816
+ const cleanContent = content.replace(match[0], '').trim();
817
+ return { cleanContent, spec, specToolCallId };
600
818
  }
601
819
 
602
820
  module.exports = BaseAdapter;
821
+ module.exports.extractA2UISpec = extractA2UISpec;
@@ -358,7 +358,7 @@ class ClaudeAdapter extends BaseAdapter {
358
358
  return null;
359
359
  }
360
360
 
361
- _buildClaudeCmd(prompt, channelName, { skipResume = false } = {}) {
361
+ _buildClaudeCmd(prompt, channelName, { skipResume = false, browserEnabled = false } = {}) {
362
362
  const claudeBin = this._findClaudeBinary();
363
363
  if (!claudeBin) {
364
364
  throw new Error('claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash');
@@ -369,6 +369,7 @@ class ClaudeAdapter extends BaseAdapter {
369
369
  workspaceId: this.workspaceId,
370
370
  channelName,
371
371
  mode: this._mode,
372
+ browserEnabled,
372
373
  });
373
374
 
374
375
  // In skills mode, replace MCP tool references with curl-based instructions
@@ -432,6 +433,7 @@ class ClaudeAdapter extends BaseAdapter {
432
433
  agentName: this.agentName,
433
434
  channelName,
434
435
  disabledModules: this.disabledModules,
436
+ browserEnabled: this._browserEnabledCache === true,
435
437
  });
436
438
  fs.writeFileSync(skillFile, skillContent, 'utf-8');
437
439
  this._log(`Wrote workspace skill to ${skillFile}`);
@@ -569,7 +571,7 @@ class ClaudeAdapter extends BaseAdapter {
569
571
  let content = (msg.content || '').trim();
570
572
  const attachments = msg.attachments || [];
571
573
 
572
- const attText = formatAttachmentsForPrompt(attachments);
574
+ const attText = formatAttachmentsForPrompt(attachments, this.toolMode);
573
575
  if (attText) {
574
576
  content = content ? content + attText : attText.trim();
575
577
  }
@@ -648,7 +650,11 @@ class ClaudeAdapter extends BaseAdapter {
648
650
  }
649
651
 
650
652
  try {
651
- const built = this._buildClaudeCmd(effectiveContent, msgChannel, { skipResume: attempt > 0 });
653
+ const browserEnabled = await this.getBrowserEnabled();
654
+ const built = this._buildClaudeCmd(effectiveContent, msgChannel, {
655
+ skipResume: attempt > 0,
656
+ browserEnabled,
657
+ });
652
658
  cmd = built.cmd;
653
659
  mcpConfigFile = built.mcpConfigFile;
654
660
  } catch (e) {
@@ -853,6 +859,27 @@ class ClaudeAdapter extends BaseAdapter {
853
859
  }
854
860
  }
855
861
 
862
+ // In plan mode, read the plan file and include it in the response
863
+ if (this._mode === 'plan') {
864
+ try {
865
+ const planDir = path.join(this.workingDir || process.cwd(), '.claude', 'plans');
866
+ if (fs.existsSync(planDir)) {
867
+ const planFiles = fs.readdirSync(planDir)
868
+ .filter((f) => f.endsWith('.md'))
869
+ .map((f) => ({ name: f, mtime: fs.statSync(path.join(planDir, f)).mtimeMs }))
870
+ .sort((a, b) => b.mtime - a.mtime);
871
+ if (planFiles.length > 0) {
872
+ const planContent = fs.readFileSync(path.join(planDir, planFiles[0].name), 'utf-8').trim();
873
+ if (planContent) {
874
+ lastResponseText.push('\n\n---\n\n**Plan:**\n\n' + planContent);
875
+ }
876
+ }
877
+ }
878
+ } catch (e) {
879
+ this._log(`Failed to read plan file: ${e.message}`);
880
+ }
881
+ }
882
+
856
883
  if (lastResponseText.length > 0) {
857
884
  const fullResponse = lastResponseText.join('\n').trim();
858
885
  // "Prompt is too long" means the resumed session's context