@openagents-org/agent-launcher 0.2.130 → 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.130",
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,14 +209,16 @@
209
209
  "order": 4,
210
210
  "builtin": true,
211
211
  "install": {
212
- "binary": "agent",
213
- "verify": "agent --version 2>/dev/null | head -1",
214
- "verify_win": "agent --version 2>nul",
212
+ "binary": "cursor-agent",
213
+ "binary_aliases": [
214
+ "agent"
215
+ ],
216
+ "verify": "cursor-agent --version 2>/dev/null | head -1",
217
+ "verify_win": "cursor-agent --version 2>nul",
215
218
  "requires": [],
216
219
  "macos": "curl https://cursor.com/install -fsSL | bash",
217
220
  "linux": "curl https://cursor.com/install -fsSL | bash",
218
- "windows": "irm 'https://cursor.com/install?win32=true' | iex",
219
- "npm": "npm install -g @cursor/cli"
221
+ "windows": "\"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe\" -NoProfile -ExecutionPolicy Bypass -Command \"Invoke-RestMethod -UseBasicParsing 'https://cursor.com/install?win32=true' | Invoke-Expression\""
220
222
  },
221
223
  "adapter": {
222
224
  "module": "openagents.adapters.cursor",
@@ -53,6 +53,11 @@ class BaseAdapter {
53
53
  // Per-channel task tracking for parallel execution
54
54
  this._channelBusy = new Set();
55
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;
56
61
  // Wall-clock timestamp of adapter init, used by the `status` control
57
62
  // action to report uptime back to the channel. Reset on reinstantiation
58
63
  // (e.g. after a `restart` IPC bounce) so uptime tracks "time since last
@@ -210,6 +215,8 @@ class BaseAdapter {
210
215
  async _onControlAction(action, payload) {
211
216
  if (action === 'status') {
212
217
  await this._postStatusReport(payload);
218
+ } else if (action === 'routines') {
219
+ await this._postRoutinesReport(payload);
213
220
  }
214
221
  }
215
222
 
@@ -262,6 +269,74 @@ class BaseAdapter {
262
269
  }
263
270
  }
264
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
+
265
340
  _hasActiveWork() {
266
341
  return this._channelBusy.size > 0;
267
342
  }
@@ -306,7 +381,7 @@ class BaseAdapter {
306
381
 
307
382
  while (this._running) {
308
383
  pollCount++;
309
- let messages, rawCursor;
384
+ let messages, rawCursor, composingActive = false;
310
385
  try {
311
386
  const result = await this.client.pollPending(
312
387
  this.workspaceId, this.agentName, this.token,
@@ -314,8 +389,9 @@ class BaseAdapter {
314
389
  );
315
390
  messages = result.messages;
316
391
  rawCursor = result.cursor;
392
+ composingActive = !!result.composing;
317
393
  if (pollCount <= 3 || pollCount % 20 === 0) {
318
- this._log(`Poll #${pollCount}: ${messages.length} messages, cursor=${rawCursor || 'none'}`);
394
+ this._log(`Poll #${pollCount}: ${messages.length} messages, cursor=${rawCursor || 'none'}${composingActive ? ' composing' : ''}`);
319
395
  }
320
396
  } catch (e) {
321
397
  this._log(`Poll #${pollCount} failed: ${e.message} \nStack: ${e.stack}`);
@@ -384,11 +460,26 @@ class BaseAdapter {
384
460
  }
385
461
  }
386
462
 
387
- // Adaptive polling: 2s active, up to 15s idle.
388
- // Each connected agent runs this loop, so faster rates multiply across
389
- // every workspace member — keep this conservative and tune separately
390
- // with a load-impact analysis on workspace-endpoint.
391
- const delay = incoming.length > 0 ? 2000 : Math.min(2000 + idleCount * 1000, 15000);
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
+ }
392
483
  await this._sleep(delay);
393
484
  }
394
485
  }
@@ -636,6 +727,25 @@ class BaseAdapter {
636
727
  _sleep(ms) {
637
728
  return new Promise((resolve) => setTimeout(resolve, ms));
638
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
+ }
639
749
  }
640
750
 
641
751
  // ------------------------------------------------------------------
@@ -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}`);
@@ -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
@@ -171,40 +171,59 @@ class CursorAdapter extends BaseAdapter {
171
171
  _findCursorBinary() {
172
172
  const home = os.homedir();
173
173
  const ext = IS_WINDOWS ? '.cmd' : '';
174
+ const names = ['cursor-agent', 'agent'];
174
175
 
175
176
  // Tier 0: Isolated runtime prefix
176
- const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'cursor', 'node_modules', '.bin', `agent${ext}`);
177
- if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
177
+ for (const name of names) {
178
+ const runtimeCandidate = path.join(home, '.openagents', 'runtimes', 'cursor', 'node_modules', '.bin', `${name}${ext}`);
179
+ if (fs.existsSync(runtimeCandidate)) return runtimeCandidate;
180
+ }
178
181
 
179
182
  // Tier 0b: Legacy portable install
180
- const portableCandidate = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `agent${ext}`);
181
- if (fs.existsSync(portableCandidate)) return portableCandidate;
183
+ for (const name of names) {
184
+ const portableCandidate = path.join(home, '.openagents', 'nodejs', 'node_modules', '.bin', `${name}${ext}`);
185
+ if (fs.existsSync(portableCandidate)) return portableCandidate;
186
+ }
182
187
 
183
188
  // Tier 1: PATH search
184
189
  try {
185
190
  if (IS_WINDOWS) {
186
- const r = execSync('where agent.cmd 2>nul || where agent.exe 2>nul || where agent 2>nul', {
191
+ const r = execSync('where cursor-agent.cmd 2>nul || where cursor-agent.exe 2>nul || where cursor-agent 2>nul || where agent.cmd 2>nul || where agent.exe 2>nul || where agent 2>nul', {
187
192
  encoding: 'utf-8', timeout: 5000,
188
193
  });
189
194
  return r.split(/\r?\n/)[0].trim();
190
195
  } else {
191
- return execSync('which agent', { encoding: 'utf-8', timeout: 5000 }).trim();
196
+ return execSync('which cursor-agent || which agent', { encoding: 'utf-8', timeout: 5000 }).trim();
192
197
  }
193
198
  } catch {}
194
199
 
195
200
  // Tier 2: Next to current Node.js interpreter (npm global)
196
201
  const nodeBinDir = path.dirname(process.execPath);
197
- const nearNode = path.join(nodeBinDir, `agent${ext}`);
198
- if (fs.existsSync(nearNode)) return nearNode;
202
+ for (const name of names) {
203
+ const nearNode = path.join(nodeBinDir, `${name}${ext}`);
204
+ if (fs.existsSync(nearNode)) return nearNode;
205
+ }
199
206
 
200
207
  // Tier 3: Common install locations
201
208
  const candidates = IS_WINDOWS ? [
209
+ path.join(home, '.cursor', 'bin', 'cursor-agent.cmd'),
210
+ path.join(home, '.cursor', 'bin', 'cursor-agent.exe'),
211
+ path.join(home, '.cursor', 'bin', 'cursor-agent'),
212
+ path.join(home, '.cursor', 'bin', 'agent.cmd'),
213
+ path.join(home, '.cursor', 'bin', 'agent.exe'),
214
+ path.join(home, '.cursor', 'bin', 'agent'),
215
+ path.join(process.env.APPDATA || '', 'npm', 'cursor-agent.cmd'),
202
216
  path.join(process.env.APPDATA || '', 'npm', 'agent.cmd'),
203
217
  ] : [
218
+ path.join(home, '.local', 'bin', 'cursor-agent'),
204
219
  path.join(home, '.local', 'bin', 'agent'),
220
+ path.join(home, '.npm-global', 'bin', 'cursor-agent'),
205
221
  path.join(home, '.npm-global', 'bin', 'agent'),
222
+ path.join(home, '.cursor', 'bin', 'cursor-agent'),
206
223
  path.join(home, '.cursor', 'bin', 'agent'),
224
+ '/opt/homebrew/bin/cursor-agent',
207
225
  '/opt/homebrew/bin/agent',
226
+ '/usr/local/bin/cursor-agent',
208
227
  '/usr/local/bin/agent',
209
228
  ];
210
229
  for (const c of candidates) {
@@ -11,6 +11,51 @@
11
11
 
12
12
  'use strict';
13
13
 
14
+ /**
15
+ * Strong directive forcing agents to use the workspace browser when the
16
+ * workspace has Browser Fabric enabled. Emitted high in the system prompt
17
+ * so it wins against any earlier guidance that suggests local browsing
18
+ * tools.
19
+ *
20
+ * Returns '' when the toggle is off; the caller can unconditionally
21
+ * concatenate the result.
22
+ *
23
+ * Mirrors `build_browser_directive` in
24
+ * sdk/src/openagents/adapters/workspace_prompt.py.
25
+ */
26
+ function buildBrowserDirective(browserEnabled) {
27
+ if (!browserEnabled) return '';
28
+ return (
29
+ '\n## Browser Use (MANDATORY)\n' +
30
+ 'This workspace has the **shared Browser Fabric session** enabled. ' +
31
+ 'All web browsing MUST go through it so the user can watch the ' +
32
+ 'session live in their right-side panel and so cookies / state ' +
33
+ 'persist across agents.\n\n' +
34
+ '**Use ONLY these tools for any web browsing:**\n' +
35
+ '- `mcp__openagents-workspace__workspace_browser_open`\n' +
36
+ '- `mcp__openagents-workspace__workspace_browser_navigate`\n' +
37
+ '- `mcp__openagents-workspace__workspace_browser_click`\n' +
38
+ '- `mcp__openagents-workspace__workspace_browser_type`\n' +
39
+ '- `mcp__openagents-workspace__workspace_browser_snapshot`\n' +
40
+ '- `mcp__openagents-workspace__workspace_browser_screenshot`\n' +
41
+ '- `mcp__openagents-workspace__workspace_browser_list_tabs`\n' +
42
+ '- `mcp__openagents-workspace__workspace_browser_close`\n' +
43
+ '\n' +
44
+ 'If you don\'t have these MCP tools, use `Bash` + `curl` against ' +
45
+ '`/v1/browser/tabs` (documented below in Shared Browser).\n\n' +
46
+ '**FORBIDDEN — do NOT call any of these:**\n' +
47
+ '- `mcp__browsermcp__*` (any local Browser MCP extension tool)\n' +
48
+ '- `mcp__playwright__*`, `mcp__puppeteer__*`, `mcp__chrome-devtools__*`, or any other local-browser MCP\n' +
49
+ '- `WebFetch`, `WebSearch`, `web_fetch`, `web_search`, or any built-in network/browser tool\n' +
50
+ '\n' +
51
+ 'If a local browser tool errors with "extension isn\'t connected" or ' +
52
+ '"connect your browser", do NOT ask the user to connect anything — ' +
53
+ 'the local extension is irrelevant here. Immediately switch to the ' +
54
+ 'workspace browser tools above. The Browser Fabric session is already ' +
55
+ 'running on the backend.\n'
56
+ );
57
+ }
58
+
14
59
  /**
15
60
  * Build the identity section common to all adapters.
16
61
  */
@@ -294,25 +339,28 @@ function buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channel
294
339
  if (!isPlan) {
295
340
  sections.push(
296
341
  '\n### Routines (Recurring Tasks)\n\n' +
297
- 'Create a recurring routine that fires on a schedule and posts a message ' +
298
- 'to the channel, waking you to do the work. Great for daily standups, ' +
299
- 'periodic reviews, scheduled checks.\n\n' +
300
- '**Schedule:** Specify `hour` (0-23 UTC), `minute` (0-59), and optional ' +
301
- '`days` array (0=Mon, 6=Sun). Omit `days` for every day.\n\n' +
302
- '**Create a routine:**\n' +
342
+ 'Create a recurring routine that fires on a schedule. Each routine gets ' +
343
+ '**its own dedicated thread** (`routine:<id>`) so different routines never ' +
344
+ 'interfere, and the full context is preserved.\n\n' +
345
+ '**`context` is required** — provide a thorough description of what the ' +
346
+ 'routine should do, any background info, and relevant details from the ' +
347
+ 'current conversation. This context is posted at the start of the routine\'s ' +
348
+ 'thread every time it fires, so you have full background.\n\n' +
349
+ '**Two schedule modes:**\n' +
350
+ '- **Daily**: `hour` (0-23 UTC) + `minute` (0-59), optional `days` ' +
351
+ 'array (0=Mon, 6=Sun). Omit `days` for every day.\n' +
352
+ '- **Interval**: `interval_minutes` (1-1440). Fires every N minutes. ' +
353
+ 'Mutually exclusive with `hour`/`minute`.\n\n' +
354
+ '**Create a daily routine:**\n' +
303
355
  `\`${curl} -s -X POST -H "${h}" -H "Content-Type: application/json" ` +
304
356
  `${baseUrl}/v1/routines -d '{"name":"Daily PR Review","message":"Review open PRs",` +
357
+ `"context":"Review all open pull requests on the main repo. Check for merge conflicts, ` +
358
+ `CI failures, and stale PRs older than 3 days. Post a summary to the workspace.",` +
305
359
  `"hour":8,"minute":0,` +
306
- `"network":"${workspaceId}","channel":"${channelName}",` +
307
- `"source":"openagents:${agentName}"}'\`\n\n` +
308
- '**Create a weekday-only routine (Mon-Fri):**\n' +
309
- `\`${curl} -s -X POST -H "${h}" -H "Content-Type: application/json" ` +
310
- `${baseUrl}/v1/routines -d '{"name":"Morning Standup","message":"Post standup summary",` +
311
- `"hour":9,"minute":0,"days":[0,1,2,3,4],` +
312
- `"network":"${workspaceId}","channel":"${channelName}",` +
360
+ `"network":"${workspaceId}",` +
313
361
  `"source":"openagents:${agentName}"}'\`\n\n` +
314
362
  '**List active routines:**\n' +
315
- `\`${curl} -s -H "${h}" "${baseUrl}/v1/routines?network=${workspaceId}&channel=${channelName}"\`\n\n` +
363
+ `\`${curl} -s -H "${h}" "${baseUrl}/v1/routines?network=${workspaceId}"\`\n\n` +
316
364
  '**Cancel a routine:**\n' +
317
365
  `\`${curl} -s -X DELETE -H "${h}" ${baseUrl}/v1/routines/ROUTINE_ID\`\n`
318
366
  );
@@ -352,7 +400,7 @@ function buildGuardrails() {
352
400
  * Build the system prompt for Claude adapter (MCP-based).
353
401
  * Claude gets identity + collaboration instructions but NOT API skills.
354
402
  */
355
- function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = 'execute' }) {
403
+ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = 'execute', browserEnabled = false }) {
356
404
  const parts = [];
357
405
  parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode));
358
406
  parts.push(
@@ -362,6 +410,7 @@ function buildClaudeSystemPrompt({ agentName, workspaceId, channelName, mode = '
362
410
  'Use workspace_create_timer to set a reminder that wakes you up later.\n' +
363
411
  'Use workspace_create_routine to set up recurring scheduled tasks (e.g. daily reviews).\n'
364
412
  );
413
+ parts.push(buildBrowserDirective(browserEnabled));
365
414
  parts.push(buildCollaborationPrompt());
366
415
  parts.push(buildA2UIPrompt());
367
416
 
@@ -457,9 +506,10 @@ function buildA2UIPrompt() {
457
506
  /**
458
507
  * Build the full system prompt for OpenClaw/non-MCP agents.
459
508
  */
460
- function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules }) {
509
+ function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false }) {
461
510
  const parts = [];
462
511
  parts.push(buildWorkspaceIdentity(agentName, workspaceId, channelName, mode));
512
+ parts.push(buildBrowserDirective(browserEnabled));
463
513
  parts.push(buildCollaborationPrompt());
464
514
  parts.push(buildA2UIPrompt());
465
515
  parts.push(buildModePrompt(mode));
@@ -473,12 +523,13 @@ function buildOpenclawSystemPrompt({ agentName, workspaceId, channelName, endpoi
473
523
  /**
474
524
  * Build a SKILL.md file for OpenClaw's skill auto-discovery.
475
525
  */
476
- function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules }) {
526
+ function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules, browserEnabled = false }) {
477
527
  const body = buildApiSkillsPrompt({
478
528
  endpoint, workspaceId, token, agentName, channelName, disabledModules, mode: 'execute',
479
529
  });
480
530
 
481
531
  const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, 'execute');
532
+ const directive = buildBrowserDirective(browserEnabled);
482
533
  const collab = buildCollaborationPrompt();
483
534
 
484
535
  const frontmatter = (
@@ -495,18 +546,19 @@ function buildOpenclawSkillMd({ endpoint, workspaceId, token, agentName, channel
495
546
  '---\n\n'
496
547
  );
497
548
 
498
- return frontmatter + identity + '\n' + collab + '\n' + body + '\n' + buildGuardrails();
549
+ return frontmatter + identity + directive + '\n' + collab + '\n' + body + '\n' + buildGuardrails();
499
550
  }
500
551
 
501
552
  /**
502
553
  * Build system prompt for OpenCode adapter.
503
554
  */
504
- function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules }) {
555
+ function buildOpenCodeSystemPrompt({ agentName, workspaceId, channelName, endpoint, token, mode = 'execute', disabledModules, browserEnabled = false }) {
505
556
  const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, mode);
557
+ const directive = buildBrowserDirective(browserEnabled);
506
558
  const collab = buildCollaborationPrompt();
507
559
  const modePrompt = buildModePrompt(mode);
508
560
  const api = buildApiSkillsPrompt({ endpoint, workspaceId, token, agentName, channelName, disabledModules, mode });
509
- return identity + '\n' + collab + '\n' + modePrompt + '\n' + api + '\n' + buildGuardrails();
561
+ return identity + directive + '\n' + collab + '\n' + modePrompt + '\n' + api + '\n' + buildGuardrails();
510
562
  }
511
563
 
512
564
  /**
@@ -540,7 +592,7 @@ function buildOpenCodeSkillMd({ endpoint, workspaceId, token, agentName, channel
540
592
  * of spawning an MCP server. Claude Code discovers the skill via its
541
593
  * .claude/skills/ directory and uses Bash + curl to call workspace APIs.
542
594
  */
543
- function buildClaudeSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules }) {
595
+ function buildClaudeSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules, browserEnabled = false }) {
544
596
  const api = buildApiSkillsPrompt({
545
597
  endpoint, workspaceId, token, agentName,
546
598
  channelName: channelName || 'general',
@@ -549,6 +601,7 @@ function buildClaudeSkillMd({ endpoint, workspaceId, token, agentName, channelNa
549
601
  });
550
602
 
551
603
  const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, 'execute');
604
+ const directive = buildBrowserDirective(browserEnabled);
552
605
  const collab = buildCollaborationPrompt();
553
606
 
554
607
  const frontmatter =
@@ -561,7 +614,7 @@ function buildClaudeSkillMd({ endpoint, workspaceId, token, agentName, channelNa
561
614
  ' or collaborating with other agents via @mentions.\n' +
562
615
  '---\n\n';
563
616
 
564
- return frontmatter + identity + '\n' + collab + '\n' + api + '\n' + buildGuardrails();
617
+ return frontmatter + identity + directive + '\n' + collab + '\n' + api + '\n' + buildGuardrails();
565
618
  }
566
619
 
567
620
  /**
@@ -570,7 +623,7 @@ function buildClaudeSkillMd({ endpoint, workspaceId, token, agentName, channelNa
570
623
  * Written to .cursor/skills/openagents-workspace.md before each CLI spawn.
571
624
  * Cursor discovers skills from the .cursor/skills/ directory automatically.
572
625
  */
573
- function buildCursorSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules }) {
626
+ function buildCursorSkillMd({ endpoint, workspaceId, token, agentName, channelName, disabledModules, browserEnabled = false }) {
574
627
  const api = buildApiSkillsPrompt({
575
628
  endpoint, workspaceId, token, agentName,
576
629
  channelName: channelName || 'general',
@@ -579,6 +632,7 @@ function buildCursorSkillMd({ endpoint, workspaceId, token, agentName, channelNa
579
632
  });
580
633
 
581
634
  const identity = buildWorkspaceIdentity(agentName, workspaceId, channelName, 'execute');
635
+ const directive = buildBrowserDirective(browserEnabled);
582
636
  const collab = buildCollaborationPrompt();
583
637
 
584
638
  const frontmatter =
@@ -591,11 +645,12 @@ function buildCursorSkillMd({ endpoint, workspaceId, token, agentName, channelNa
591
645
  ' or collaborating with other agents via @mentions.\n' +
592
646
  '---\n\n';
593
647
 
594
- return frontmatter + identity + '\n' + collab + '\n' + api + '\n' + buildGuardrails();
648
+ return frontmatter + identity + directive + '\n' + collab + '\n' + api + '\n' + buildGuardrails();
595
649
  }
596
650
 
597
651
  module.exports = {
598
652
  buildWorkspaceIdentity,
653
+ buildBrowserDirective,
599
654
  buildCollaborationPrompt,
600
655
  buildModePrompt,
601
656
  buildGuardrails,
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ const https = require('https');
4
+
5
+ const DEFAULT_BASE_URL = 'https://api.github.com';
6
+ const USER_AGENT = '@openagents-org/agent-launcher';
7
+
8
+ /**
9
+ * Thin GitHub REST API client used by the launcher's GitHub integration.
10
+ *
11
+ * Auth is via a personal access token (classic or fine-grained) or GitHub
12
+ * App installation token. Each call accepts an explicit token so the caller
13
+ * can resolve it from the launcher's credentials store at request time.
14
+ */
15
+ class GitHubClient {
16
+ constructor(opts = {}) {
17
+ this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/$/, '');
18
+ }
19
+
20
+ /**
21
+ * Verify a token and return identity / rate-limit info.
22
+ * Mirrors `GET /user` + `GET /rate_limit`.
23
+ */
24
+ async probe(token) {
25
+ if (!token) throw new Error('Missing GitHub token');
26
+ const user = await this._get('/user', token);
27
+ const rate = await this._get('/rate_limit', token).catch(() => null);
28
+ return {
29
+ ok: true,
30
+ login: user.login,
31
+ name: user.name,
32
+ avatarUrl: user.avatar_url,
33
+ scopes: user.__scopes || [],
34
+ rate: rate ? rate.rate : null,
35
+ };
36
+ }
37
+
38
+ async getRepo(owner, name, token) {
39
+ return this._get(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`, token);
40
+ }
41
+
42
+ /**
43
+ * @param {string} owner
44
+ * @param {string} name
45
+ * @param {{ state?: 'open'|'closed'|'all', perPage?: number, page?: number }} opts
46
+ * @param {string} token
47
+ */
48
+ async listIssues(owner, name, opts = {}, token) {
49
+ const state = opts.state || 'open';
50
+ const perPage = opts.perPage || 20;
51
+ const page = opts.page || 1;
52
+ // GitHub's /issues endpoint returns PRs too — filter to issue-only.
53
+ const items = await this._get(
54
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues?state=${state}&per_page=${perPage}&page=${page}`,
55
+ token,
56
+ );
57
+ return items.filter((i) => !i.pull_request);
58
+ }
59
+
60
+ async listPullRequests(owner, name, opts = {}, token) {
61
+ const state = opts.state || 'open';
62
+ const perPage = opts.perPage || 20;
63
+ const page = opts.page || 1;
64
+ return this._get(
65
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls?state=${state}&per_page=${perPage}&page=${page}`,
66
+ token,
67
+ );
68
+ }
69
+
70
+ async createIssueComment(owner, name, issueNumber, body, token) {
71
+ return this._post(
72
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${issueNumber}/comments`,
73
+ { body },
74
+ token,
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Parse a GitHub-ish input into { owner, name }.
80
+ * Accepts: "owner/name", "https://github.com/owner/name", "git@github.com:owner/name.git".
81
+ */
82
+ static parseRepo(input) {
83
+ if (!input) return null;
84
+ const v = String(input).trim();
85
+ if (!v) return null;
86
+ // SSH form: git@github.com:owner/name(.git)
87
+ const ssh = v.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/);
88
+ if (ssh) return { owner: ssh[1], name: ssh[2] };
89
+ // URL form
90
+ try {
91
+ const u = new URL(v);
92
+ if (/github\.com$/i.test(u.hostname)) {
93
+ const parts = u.pathname.replace(/^\//, '').replace(/\.git$/, '').split('/');
94
+ if (parts.length >= 2 && parts[0] && parts[1]) {
95
+ return { owner: parts[0], name: parts[1] };
96
+ }
97
+ }
98
+ } catch {
99
+ // not a URL — fall through
100
+ }
101
+ // Plain "owner/name"
102
+ const plain = v.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
103
+ if (plain) return { owner: plain[1], name: plain[2] };
104
+ return null;
105
+ }
106
+
107
+ _headers(token, extra = {}) {
108
+ return {
109
+ Accept: 'application/vnd.github+json',
110
+ 'X-GitHub-Api-Version': '2022-11-28',
111
+ 'User-Agent': USER_AGENT,
112
+ Authorization: `Bearer ${token}`,
113
+ ...extra,
114
+ };
115
+ }
116
+
117
+ _get(urlPath, token, timeout = 20000) {
118
+ return this._request('GET', urlPath, null, token, timeout);
119
+ }
120
+
121
+ _post(urlPath, body, token, timeout = 30000) {
122
+ return this._request('POST', urlPath, body, token, timeout);
123
+ }
124
+
125
+ _request(method, urlPath, body, token, timeout) {
126
+ const fullUrl = this.baseUrl + urlPath;
127
+ const jsonBody = body == null ? null : JSON.stringify(body);
128
+ const headers = this._headers(token);
129
+ if (jsonBody != null) headers['Content-Type'] = 'application/json';
130
+
131
+ return new Promise((resolve, reject) => {
132
+ const req = https.request(fullUrl, { method, headers, timeout }, (res) => {
133
+ let data = '';
134
+ res.on('data', (chunk) => { data += chunk; });
135
+ res.on('end', () => {
136
+ const status = res.statusCode || 0;
137
+ let parsed = null;
138
+ try { parsed = data ? JSON.parse(data) : null; } catch { parsed = null; }
139
+ if (status >= 400) {
140
+ const msg = (parsed && (parsed.message || parsed.error)) || `HTTP ${status}`;
141
+ const err = new Error(`GitHub ${method} ${urlPath} failed: ${msg}`);
142
+ err.status = status;
143
+ err.body = parsed;
144
+ return reject(err);
145
+ }
146
+ // Attach OAuth scopes for /user probe convenience.
147
+ if (parsed && method === 'GET' && urlPath === '/user') {
148
+ const scopes = res.headers['x-oauth-scopes'];
149
+ if (typeof scopes === 'string') {
150
+ parsed.__scopes = scopes.split(',').map((s) => s.trim()).filter(Boolean);
151
+ }
152
+ }
153
+ resolve(parsed);
154
+ });
155
+ });
156
+
157
+ req.on('error', reject);
158
+ req.on('timeout', () => { req.destroy(); reject(new Error('GitHub request timed out')); });
159
+ if (jsonBody != null) req.write(jsonBody);
160
+ req.end();
161
+ });
162
+ }
163
+ }
164
+
165
+ module.exports = { GitHubClient };
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ const { Registry } = require('./registry');
6
6
  const { Installer } = require('./installer');
7
7
  const { Daemon } = require('./daemon');
8
8
  const { WorkspaceClient } = require('./workspace-client');
9
+ const { GitHubClient } = require('./github-client');
9
10
 
10
11
  /**
11
12
  * Main entry point for the agent-connector library.
@@ -274,4 +275,4 @@ class AgentConnector {
274
275
  const adapters = require('./adapters');
275
276
 
276
277
  const paths = require('./paths');
277
- module.exports = { AgentConnector, Daemon, WorkspaceClient, adapters, paths };
278
+ module.exports = { AgentConnector, Daemon, WorkspaceClient, GitHubClient, adapters, paths };
package/src/installer.js CHANGED
@@ -433,8 +433,15 @@ class Installer {
433
433
  if (proc.stdout) proc.stdout.setEncoding('utf-8');
434
434
  if (proc.stderr) proc.stderr.setEncoding('utf-8');
435
435
 
436
- if (proc.stdout) proc.stdout.on('data', (d) => { if (onData) onData(d); });
437
- if (proc.stderr) proc.stderr.on('data', (d) => { if (onData) onData(d); });
436
+ let outputTail = '';
437
+ const captureOutput = (d) => {
438
+ const text = String(d);
439
+ outputTail = (outputTail + text).slice(-4000);
440
+ if (onData) onData(text);
441
+ };
442
+
443
+ if (proc.stdout) proc.stdout.on('data', captureOutput);
444
+ if (proc.stderr) proc.stderr.on('data', captureOutput);
438
445
 
439
446
  proc.on('error', (err) => reject(err));
440
447
  proc.on('close', (code) => {
@@ -443,7 +450,21 @@ class Installer {
443
450
  if (onData) onData(`\nDone! ${agentType} is now installed.\n`);
444
451
  resolve({ success: true, command: cmd });
445
452
  } else {
446
- const msg = `Install failed with exit code ${code}`;
453
+ // A partial install can leave a placeholder stub at
454
+ // bin/<binary>.exe and npm-generated cmd-shims under
455
+ // node_modules/.bin/<name>{,.cmd,.ps1} that point to it. On
456
+ // Windows, anything that later tries to run the binary (a health
457
+ // check, a shell open) triggers a "this app can't run on your PC"
458
+ // dialog because the stub isn't a real PE executable. Sweep them
459
+ // up so the next install (or uninstall+reinstall) starts clean.
460
+ try {
461
+ this._cleanStaleShims(agentType);
462
+ this._cleanStubBinary(agentType);
463
+ } catch {}
464
+ const tail = outputTail.trim();
465
+ const msg = tail
466
+ ? `Install failed with exit code ${code}\nCommand: ${cmd}\n\n${tail}`
467
+ : `Install failed with exit code ${code}\nCommand: ${cmd}`;
447
468
  if (onData) onData(`\n${msg}\n`);
448
469
  reject(new Error(msg));
449
470
  }
@@ -451,6 +472,35 @@ class Installer {
451
472
  });
452
473
  }
453
474
 
475
+ /**
476
+ * Remove the placeholder bin/<binary>.exe (and the npm package directory
477
+ * if it's effectively empty) left over from a failed postinstall.
478
+ */
479
+ _cleanStubBinary(agentType) {
480
+ const entry = this.registry.getEntry(agentType);
481
+ const install = entry && entry.install;
482
+ if (!install) return;
483
+ const binary = install.binary || agentType;
484
+ const npmPkg = install.npm_package
485
+ || (() => {
486
+ const cmd = this._getInstallCommand(install);
487
+ if (!cmd || !cmd.includes('npm install')) return null;
488
+ const m = cmd.match(/npm install\s+(?:-g\s+)?(@?[\w-]+(?:\/[\w-]+)?)(?:@\S*)?$/);
489
+ return m ? m[1] : null;
490
+ })();
491
+ if (!npmPkg) return;
492
+
493
+ const prefix = getRuntimePrefix(agentType);
494
+ const pkgDir = path.join(prefix, 'node_modules', npmPkg);
495
+ const stubDir = path.join(pkgDir, 'bin');
496
+ for (const ext of ['', '.exe', '.cmd', '.ps1']) {
497
+ try {
498
+ const f = path.join(stubDir, binary + ext);
499
+ if (fs.existsSync(f) && fs.statSync(f).size < 4096) fs.unlinkSync(f);
500
+ } catch {}
501
+ }
502
+ }
503
+
454
504
  /**
455
505
  * Uninstall an agent runtime.
456
506
  * @returns {Promise<{success: boolean, output: string}>}
@@ -566,7 +616,7 @@ class Installer {
566
616
  */
567
617
  _getInstallCommand(installCfg) {
568
618
  const plat = Installer.platform();
569
- return installCfg[plat] || installCfg.command || null;
619
+ return installCfg[plat] || installCfg.command || installCfg.npm || null;
570
620
  }
571
621
 
572
622
  /**
@@ -608,7 +658,14 @@ class Installer {
608
658
  _whichBinary(agentType) {
609
659
  const entry = this.registry.getEntry(agentType);
610
660
  const binary = entry && entry.install ? entry.install.binary : agentType;
611
- return whichBinary(binary);
661
+ const aliases = entry && entry.install && Array.isArray(entry.install.binary_aliases)
662
+ ? entry.install.binary_aliases
663
+ : [];
664
+ for (const candidate of [binary, ...aliases]) {
665
+ const found = whichBinary(candidate);
666
+ if (found) return found;
667
+ }
668
+ return null;
612
669
  }
613
670
 
614
671
  // -- Markers --
@@ -680,28 +737,58 @@ class Installer {
680
737
  const extraDirs = [];
681
738
  try { extraDirs.push(path.dirname(process.execPath)); } catch {}
682
739
 
683
- // Check for bundled Node.js in ~/.openagents/nodejs/
740
+ // Whether a usable `node` is already reachable WITHOUT the bundled
741
+ // standalone node.exe. We must avoid shadowing a working system node with
742
+ // the launcher's bundled exe: that file is an unsigned standalone
743
+ // node.exe and on some Windows machines Defender/SmartScreen blocks
744
+ // CreateProcess on it, which surfaces as `cmd: 拒绝访问` ("Access
745
+ // Denied") when npm runs `node install.cjs` for packages like
746
+ // @anthropic-ai/claude-code, plus a "this app can't run on your PC"
747
+ // dialog.
748
+ const bundledDir = path.join(this.configDir, 'nodejs');
749
+ const hasSystemNode = this._hasSystemNode(bundledDir);
750
+
684
751
  if (process.platform === 'win32') {
685
- try {
686
- const bundledDir = path.join(this.configDir, 'nodejs');
687
- if (fs.existsSync(bundledDir)) {
688
- // Node.js may be directly in nodejs/ (launcher symlink style) or in nodejs/node-v*/
689
- if (fs.existsSync(path.join(bundledDir, 'node.exe'))) {
690
- extraDirs.push(bundledDir);
691
- }
692
- const entries = fs.readdirSync(bundledDir).filter(e => e.startsWith('node-'));
693
- if (entries.length > 0) {
694
- extraDirs.push(path.join(bundledDir, entries[0]));
695
- }
696
- }
697
- } catch {}
698
752
  const appData = env.APPDATA || '';
699
753
  if (appData) extraDirs.push(path.join(appData, 'npm'));
700
754
  extraDirs.push(env.ProgramFiles ? path.join(env.ProgramFiles, 'nodejs') : 'C:\\Program Files\\nodejs');
701
755
  extraDirs.push(env.SystemRoot ? path.join(env.SystemRoot, 'System32') : 'C:\\Windows\\System32');
702
756
  extraDirs.push(env.ProgramFiles ? path.join(env.ProgramFiles, 'Git', 'cmd') : 'C:\\Program Files\\Git\\cmd');
757
+
758
+ // Bundled node — fallback only. Skip if system node works, otherwise
759
+ // verify the bundled exe can actually launch before exposing it.
760
+ if (!hasSystemNode) {
761
+ try {
762
+ if (fs.existsSync(bundledDir)) {
763
+ const directExe = path.join(bundledDir, 'node.exe');
764
+ if (fs.existsSync(directExe) && this._canExecute(directExe)) {
765
+ extraDirs.unshift(bundledDir);
766
+ }
767
+ for (const entry of fs.readdirSync(bundledDir).filter(e => e.startsWith('node-'))) {
768
+ const nested = path.join(bundledDir, entry);
769
+ if (fs.existsSync(path.join(nested, 'node.exe')) && this._canExecute(path.join(nested, 'node.exe'))) {
770
+ extraDirs.unshift(nested);
771
+ }
772
+ }
773
+ }
774
+ } catch {}
775
+ }
703
776
  } else {
704
777
  extraDirs.push('/usr/local/bin', '/opt/homebrew/bin');
778
+ if (!hasSystemNode) {
779
+ try {
780
+ const candidates = [
781
+ path.join(bundledDir, 'bin', 'node'),
782
+ path.join(bundledDir, 'node'),
783
+ ];
784
+ for (const bin of candidates) {
785
+ if (fs.existsSync(bin) && this._canExecute(bin)) {
786
+ extraDirs.unshift(path.dirname(bin));
787
+ break;
788
+ }
789
+ }
790
+ } catch {}
791
+ }
705
792
  }
706
793
  for (const d of extraDirs) {
707
794
  if (d && !(env.PATH || '').includes(d)) {
@@ -711,6 +798,51 @@ class Installer {
711
798
  return env;
712
799
  }
713
800
 
801
+ /**
802
+ * True if `node` exists outside the bundled launcher directory. Uses raw
803
+ * process.env.PATH (no enhancement) so we don't accidentally detect the
804
+ * bundled node and conclude that "system node exists".
805
+ */
806
+ _hasSystemNode(bundledDir) {
807
+ try {
808
+ const { execFileSync } = require('child_process');
809
+ const cmd = process.platform === 'win32' ? 'where' : 'which';
810
+ const args = process.platform === 'win32' ? ['node'] : ['node'];
811
+ const out = execFileSync(cmd, args, {
812
+ encoding: 'utf-8',
813
+ stdio: ['ignore', 'pipe', 'ignore'],
814
+ timeout: 5000,
815
+ windowsHide: true,
816
+ env: { ...process.env },
817
+ });
818
+ const prefix = (bundledDir || '').toLowerCase();
819
+ for (const line of out.split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
820
+ if (!prefix || !line.toLowerCase().startsWith(prefix)) return true;
821
+ }
822
+ return false;
823
+ } catch {
824
+ return false;
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Smoke-test a node binary by running `--version`. Returns false on any
830
+ * failure (missing exe, blocked by AV, arch mismatch, signature policy).
831
+ */
832
+ _canExecute(binaryPath) {
833
+ try {
834
+ const { spawnSync } = require('child_process');
835
+ const r = spawnSync(binaryPath, ['--version'], {
836
+ timeout: 5000,
837
+ windowsHide: true,
838
+ stdio: ['ignore', 'pipe', 'pipe'],
839
+ });
840
+ return r.status === 0 && !r.error;
841
+ } catch {
842
+ return false;
843
+ }
844
+ }
845
+
714
846
  /**
715
847
  * Check if Node.js/npm is available on the system.
716
848
  */
package/src/mcp-server.js CHANGED
@@ -301,21 +301,26 @@ function buildToolDefs(disabledModules) {
301
301
  },
302
302
  {
303
303
  name: 'workspace_create_routine',
304
- description: 'Create a recurring scheduled routine that posts a message on a repeating schedule.',
304
+ description: 'Create a recurring scheduled routine. Each routine gets its own dedicated thread with full context. Two schedule modes: daily (hour+minute, optional days) or interval (every N minutes).',
305
305
  inputSchema: {
306
306
  type: 'object',
307
307
  properties: {
308
308
  name: { type: 'string', description: 'Human-readable label for the routine (e.g. "Daily PR Review")' },
309
- message: { type: 'string', description: 'Message to post each time the routine fires' },
310
- hour: { type: 'integer', description: 'Hour in UTC (0-23)' },
311
- minute: { type: 'integer', description: 'Minute (0-59)' },
309
+ message: { type: 'string', description: 'Short trigger message posted each time the routine fires' },
310
+ context: { type: 'string', description: 'Comprehensive context for the routine: what it should do, background info, goals, relevant details from the current conversation. This is posted at the start of the routine\'s dedicated thread so you have full background every time it fires. Be thorough — this is your only memory of why this routine exists.' },
311
+ hour: { type: 'integer', description: 'Daily mode: hour in UTC (0-23). Omit if using interval_minutes.' },
312
+ minute: { type: 'integer', description: 'Daily mode: minute (0-59). Omit if using interval_minutes.' },
312
313
  days: {
313
314
  type: 'array',
314
315
  items: { type: 'integer' },
315
- description: 'Days of week to fire (0=Mon, 6=Sun). Omit for every day.',
316
+ description: 'Daily mode: days of week to fire (0=Mon, 6=Sun). Omit for every day. Not allowed in interval mode.',
317
+ },
318
+ interval_minutes: {
319
+ type: 'integer',
320
+ description: 'Interval mode: fire every N minutes (1-1440). Mutually exclusive with hour/minute.',
316
321
  },
317
322
  },
318
- required: ['name', 'message', 'hour', 'minute'],
323
+ required: ['name', 'message', 'context'],
319
324
  },
320
325
  },
321
326
  {
@@ -760,25 +765,41 @@ class McpServer {
760
765
  {
761
766
  name: args.name,
762
767
  message: args.message,
768
+ context: args.context,
763
769
  hour: args.hour,
764
770
  minute: args.minute,
765
771
  days: args.days,
772
+ interval_minutes: args.interval_minutes,
766
773
  source: `openagents:${this.agentName}`,
767
774
  },
768
775
  );
769
- const daysStr = args.days ? `days [${args.days.join(',')}]` : 'every day';
770
- return text(`Routine created: "${args.name}" at ${String(args.hour).padStart(2,'0')}:${String(args.minute).padStart(2,'0')} UTC, ${daysStr} (id: ${result.id})`);
776
+ let scheduleStr;
777
+ if (args.interval_minutes != null) {
778
+ scheduleStr = `every ${args.interval_minutes} min`;
779
+ } else {
780
+ const daysStr = args.days ? `days [${args.days.join(',')}]` : 'every day';
781
+ scheduleStr = `at ${String(args.hour).padStart(2,'0')}:${String(args.minute).padStart(2,'0')} UTC, ${daysStr}`;
782
+ }
783
+ const channel = (result && result.channel_name) || `routine:${result.id}`;
784
+ return text(
785
+ `Routine created: "${args.name}" ${scheduleStr} in dedicated thread \`${channel}\` (id: ${result.id})`,
786
+ );
771
787
  }
772
788
 
773
789
  case 'workspace_list_routines': {
774
790
  const data = await this.ws.listRoutines(this.workspaceId, this.channelName, this.token);
775
791
  const routines = (data && data.routines) || [];
776
792
  if (!routines.length) return text('No active routines.');
777
- const lines = routines.map((r) =>
778
- `- ${r.id}: "${r.name}" at ${String(r.schedule_hour).padStart(2,'0')}:${String(r.schedule_minute).padStart(2,'0')} UTC` +
779
- (r.schedule_days ? ` [days: ${r.schedule_days.join(',')}]` : ' (daily)') +
780
- ` — next: ${r.next_fires_at} (by ${r.created_by})`
781
- );
793
+ const lines = routines.map((r) => {
794
+ let when;
795
+ if (r.schedule_interval_minutes != null) {
796
+ when = `every ${r.schedule_interval_minutes} min`;
797
+ } else {
798
+ when = `at ${String(r.schedule_hour).padStart(2,'0')}:${String(r.schedule_minute).padStart(2,'0')} UTC` +
799
+ (r.schedule_days ? ` [days: ${r.schedule_days.join(',')}]` : ' (daily)');
800
+ }
801
+ return `- ${r.id}: "${r.name}" ${when} — next: ${r.next_fires_at} (by ${r.created_by})`;
802
+ });
782
803
  return text(lines.join('\n'));
783
804
  }
784
805
 
package/src/paths.js CHANGED
@@ -16,12 +16,25 @@ const IS_WINDOWS = process.platform === 'win32';
16
16
  const IS_MACOS = process.platform === 'darwin';
17
17
  const SEP = IS_WINDOWS ? ';' : ':';
18
18
  const HOME = process.env.HOME || process.env.USERPROFILE || '';
19
+ const PATH_LOOKUP_CACHE_TTL_MS = 30 * 1000;
20
+
21
+ let extraBinDirsCache = { value: null, at: 0, path: '' };
22
+ const whichBinaryCache = new Map();
19
23
 
20
24
  /**
21
25
  * Get all extra binary directories that should be checked beyond process.env.PATH.
22
26
  * Returns deduplicated list of existing directories.
23
27
  */
24
28
  function getExtraBinDirs() {
29
+ const currentPATH = process.env.PATH || '';
30
+ if (
31
+ extraBinDirsCache.value &&
32
+ extraBinDirsCache.path === currentPATH &&
33
+ Date.now() - extraBinDirsCache.at < PATH_LOOKUP_CACHE_TTL_MS
34
+ ) {
35
+ return [...extraBinDirsCache.value];
36
+ }
37
+
25
38
  const dirs = [];
26
39
 
27
40
  if (IS_WINDOWS) {
@@ -62,8 +75,7 @@ function getExtraBinDirs() {
62
75
 
63
76
  // Filter to existing directories only, deduplicate
64
77
  const seen = new Set();
65
- const currentPATH = process.env.PATH || '';
66
- return dirs.filter(d => {
78
+ const value = dirs.filter(d => {
67
79
  if (!d || seen.has(d)) return false;
68
80
  // Skip if already in PATH (case-insensitive on Windows)
69
81
  if (IS_WINDOWS ? currentPATH.toLowerCase().includes(d.toLowerCase()) : currentPATH.includes(d)) return false;
@@ -74,6 +86,8 @@ function getExtraBinDirs() {
74
86
  return false;
75
87
  }
76
88
  });
89
+ extraBinDirsCache = { value, at: Date.now(), path: currentPATH };
90
+ return [...value];
77
91
  }
78
92
 
79
93
  /**
@@ -114,6 +128,13 @@ function getEnhancedEnv(baseEnv) {
114
128
  */
115
129
  function whichBinary(name) {
116
130
  if (!name) return null;
131
+ const currentPATH = process.env.PATH || '';
132
+ const cacheKey = `${name}\0${currentPATH}`;
133
+ const cached = whichBinaryCache.get(cacheKey);
134
+ if (cached && Date.now() - cached.at < PATH_LOOKUP_CACHE_TTL_MS) {
135
+ return cached.value;
136
+ }
137
+
117
138
  const cmd = IS_WINDOWS ? `where ${name}` : `which ${name}`;
118
139
  try {
119
140
  const result = execSync(cmd, {
@@ -121,9 +142,13 @@ function whichBinary(name) {
121
142
  stdio: ['pipe', 'pipe', 'pipe'],
122
143
  env: { ...process.env, PATH: getEnhancedPATH() },
123
144
  timeout: 5000,
145
+ windowsHide: true,
124
146
  }).trim();
125
- return result.split(/\r?\n/)[0] || null;
147
+ const value = result.split(/\r?\n/)[0] || null;
148
+ whichBinaryCache.set(cacheKey, { value, at: Date.now() });
149
+ return value;
126
150
  } catch {
151
+ whichBinaryCache.set(cacheKey, { value: null, at: Date.now() });
127
152
  return null;
128
153
  }
129
154
  }
@@ -154,6 +179,9 @@ function _addWindowsPaths(dirs) {
154
179
  // Portable Node.js installed by OpenAgents Launcher
155
180
  _push(dirs, path.join(HOME, '.openagents', 'nodejs'));
156
181
 
182
+ // Cursor CLI native installer
183
+ _push(dirs, path.join(HOME, '.cursor', 'bin'));
184
+
157
185
  // Node.js install
158
186
  _push(dirs, path.join(programFiles, 'nodejs'));
159
187
 
package/src/registry.js CHANGED
@@ -85,7 +85,7 @@ class Registry {
85
85
 
86
86
  /**
87
87
  * Merge bundled env_config/resolve_env/install into catalog entries.
88
- * Remote/cached entries may lack these fields.
88
+ * Remote/cached entries may lack these fields or carry stale install data.
89
89
  */
90
90
  _mergeBundled(catalog) {
91
91
  const bundled = this._loadBundled();
@@ -94,7 +94,7 @@ class Registry {
94
94
  if (b) {
95
95
  if ((!entry.env_config || entry.env_config.length === 0) && b.env_config && b.env_config.length > 0) entry.env_config = b.env_config;
96
96
  if (!entry.resolve_env && b.resolve_env) entry.resolve_env = b.resolve_env;
97
- if (!entry.install && b.install) entry.install = b.install;
97
+ if (b.install) entry.install = { ...b.install };
98
98
  if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
99
99
  if (!entry.launch && b.launch) entry.launch = b.launch;
100
100
  // Always take featured/order/support from bundled (source of truth for ordering)
@@ -136,11 +136,11 @@ class Registry {
136
136
  const catalog = this.getCatalogSync();
137
137
  const entry = catalog.find((e) => e.name === agentType) || null;
138
138
  if (!entry) return null;
139
- // Merge missing fields from bundled registry
139
+ // Merge bundled registry fields into cached/remote entries.
140
140
  const bundled = this._loadBundled();
141
141
  const b = bundled.find((e) => e.name === agentType);
142
142
  if (b) {
143
- if (!entry.install && b.install) entry.install = b.install;
143
+ if (b.install) entry.install = { ...b.install };
144
144
  if (!entry.check_ready && b.check_ready) entry.check_ready = b.check_ready;
145
145
  if (!entry.launch && b.launch) entry.launch = b.launch;
146
146
  if ((!entry.env_config || !entry.env_config.length) && b.env_config?.length) entry.env_config = b.env_config;
package/src/tui.js CHANGED
@@ -120,25 +120,10 @@ function describeHealth(health) {
120
120
  }
121
121
 
122
122
  function loadCatalog(connector) {
123
- const { execSync } = require('child_process');
124
123
  const entries = connector.registry.getCatalogSync();
125
124
  return entries.map(e => {
126
125
  let installed = false;
127
-
128
- // If a verify command exists, use it for accurate detection
129
- const verifyCmd = IS_WINDOWS ? (e.install && e.install.verify_win) : (e.install && e.install.verify);
130
- if (verifyCmd) {
131
- try { execSync(verifyCmd, { stdio: 'ignore', timeout: 5000 }); installed = true; } catch {}
132
- } else {
133
- try { const { whichBinary } = require('./paths'); installed = !!whichBinary((e.install && e.install.binary) || e.name); } catch {}
134
- }
135
-
136
- if (!installed) {
137
- try {
138
- const f = path.join(connector._configDir, 'installed_agents.json');
139
- if (fs.existsSync(f)) installed = !!JSON.parse(fs.readFileSync(f, 'utf-8'))[e.name];
140
- } catch {}
141
- }
126
+ try { installed = !!connector.installer.getInstallInfo(e.name).installed; } catch {}
142
127
  return {
143
128
  name: e.name,
144
129
  label: e.label || e.name,
@@ -271,7 +271,7 @@ class WorkspaceClient {
271
271
  * Poll for pending messages targeted at an agent via GET /v1/events.
272
272
  * Returns { messages, cursor } where cursor is the last event ID.
273
273
  */
274
- async pollPending(workspaceId, agentName, token, { after, limit = 50 } = {}) {
274
+ async pollPending(workspaceId, agentName, token, { after, limit = 500 } = {}) {
275
275
  const params = new URLSearchParams({
276
276
  network: workspaceId,
277
277
  type: 'workspace.message.posted',
@@ -333,7 +333,28 @@ class WorkspaceClient {
333
333
  }
334
334
  }
335
335
 
336
- return { messages, cursor };
336
+ const composing = !!(result && result.composing);
337
+ return { messages, cursor, composing };
338
+ }
339
+
340
+ /**
341
+ * Get the workspace's top-level metadata via GET /v1/workspaces/{id}.
342
+ *
343
+ * Returns the full data block from the backend — adapters care about
344
+ * `browserEnabled` today, but more keys may be surfaced over time. On
345
+ * error, returns null so callers can fall back to defaults rather than
346
+ * crashing.
347
+ */
348
+ async getWorkspaceMetadata(workspaceId, token) {
349
+ try {
350
+ const data = await this._get(
351
+ `/v1/workspaces/${workspaceId}`,
352
+ this._wsHeaders(token),
353
+ );
354
+ return data.data || data || {};
355
+ } catch {
356
+ return null;
357
+ }
337
358
  }
338
359
 
339
360
  /**
@@ -606,17 +627,22 @@ class WorkspaceClient {
606
627
 
607
628
  // ── Routines ──
608
629
 
609
- async createRoutine(workspaceId, channelName, token, { name, message, hour, minute, days, source } = {}) {
630
+ async createRoutine(workspaceId, channelName, token, { name, message, context, hour, minute, days, interval_minutes, source } = {}) {
610
631
  const body = {
611
632
  name,
612
633
  message,
613
- hour,
614
- minute,
634
+ context: context || '',
615
635
  network: workspaceId,
616
636
  channel: channelName,
617
637
  source: source || 'openagents:unknown',
618
638
  };
619
- if (days) body.days = days;
639
+ if (interval_minutes != null) {
640
+ body.interval_minutes = interval_minutes;
641
+ } else {
642
+ body.hour = hour;
643
+ body.minute = minute;
644
+ if (days) body.days = days;
645
+ }
620
646
  const data = await this._post('/v1/routines', body, this._wsHeaders(token));
621
647
  return data.data || data;
622
648
  }