@openagents-org/agent-launcher 0.2.151 → 0.2.153

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/src/mcp-server.js CHANGED
@@ -100,9 +100,66 @@ function buildToolDefs(disabledModules) {
100
100
  );
101
101
  }
102
102
 
103
+ // -- Search module --
104
+ if (!disabledModules.has('search')) {
105
+ tools.push(
106
+ {
107
+ name: 'workspace_image_search',
108
+ description:
109
+ 'Search the web for images. Returns image URLs you can show in chat directly by embedding ' +
110
+ 'markdown — ![title](image_url) — in your reply, or persist with workspace_image_save.',
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ query: { type: 'string', description: 'Image search query' },
115
+ count: { type: 'number', description: 'Number of results (default 10, max 20)' },
116
+ },
117
+ required: ['query'],
118
+ },
119
+ },
120
+ {
121
+ name: 'workspace_image_save',
122
+ description:
123
+ 'Download an image (or any file) URL into workspace storage. By default it is also posted ' +
124
+ 'into the current channel as an inline image attachment (set post_to_channel=false to only save).',
125
+ inputSchema: {
126
+ type: 'object',
127
+ properties: {
128
+ url: { type: 'string', description: 'Direct image/file URL (e.g. image_url from workspace_image_search)' },
129
+ filename: { type: 'string', description: 'Filename to store as (derived from URL if omitted)' },
130
+ caption: { type: 'string', description: 'Message text to accompany the posted image' },
131
+ post_to_channel: { type: 'boolean', description: 'Post into the chat as an attachment (default true)' },
132
+ },
133
+ required: ['url'],
134
+ },
135
+ },
136
+ );
137
+ }
138
+
103
139
  // -- Browser module --
104
140
  if (!disabledModules.has('browser')) {
105
141
  tools.push(
142
+ {
143
+ name: 'workspace_fetch_url',
144
+ description:
145
+ 'Read a web page as text WITHOUT opening a shared browser tab. Preferred way to read any URL: ' +
146
+ 'tries a fast static fetch first, and automatically renders JavaScript-heavy pages (Notion, SPAs) ' +
147
+ 'in a temporary browser session. Only open a shared browser tab (workspace_browser_open) when you ' +
148
+ 'need to interact with the page (click/type/login) or the result says AUTH_REQUIRED.',
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ url: { type: 'string', description: 'URL to fetch (http/https)' },
153
+ mode: {
154
+ type: 'string',
155
+ enum: ['auto', 'static', 'render'],
156
+ description: 'auto (default): static first, browser render if needed. static: never use a browser. render: force browser rendering.',
157
+ },
158
+ max_chars: { type: 'number', description: 'Max characters of content to return (default 20000)' },
159
+ },
160
+ required: ['url'],
161
+ },
162
+ },
106
163
  {
107
164
  name: 'workspace_browser_open',
108
165
  description:
@@ -629,6 +686,61 @@ class McpServer {
629
686
 
630
687
  // ── Browser ──
631
688
 
689
+ case 'workspace_fetch_url': {
690
+ try {
691
+ const result = await this.ws.fetchUrl(this.workspaceId, this.token, args.url, {
692
+ mode: args.mode || 'auto',
693
+ maxChars: args.max_chars,
694
+ source: `openagents:${this.agentName}`,
695
+ });
696
+ const header = [
697
+ `URL: ${result.url}`,
698
+ result.title ? `Title: ${result.title}` : null,
699
+ `Source: ${result.content_source}${result.truncated ? ' (truncated)' : ''}`,
700
+ ].filter(Boolean).join('\n');
701
+ return text(`${header}\n\n${result.content || '(empty page)'}`);
702
+ } catch (e) {
703
+ const detail = e.data || {};
704
+ const parts = [`Fetch failed: ${e.message}`];
705
+ if (detail.error_code) parts.push(`Code: ${detail.error_code}`);
706
+ if (detail.hint) parts.push(`Hint: ${detail.hint}`);
707
+ return text(parts.join('\n'));
708
+ }
709
+ }
710
+
711
+ case 'workspace_image_search': {
712
+ const data = await this.ws.searchImages(this.workspaceId, this.token, args.query, {
713
+ count: args.count || 10,
714
+ });
715
+ const results = (data && data.results) || [];
716
+ if (!results.length) return text(`No image results for "${args.query}".`);
717
+ const lines = results.map((r, i) => {
718
+ const dims = r.width && r.height ? ` (${r.width}x${r.height})` : '';
719
+ return `${i + 1}. ${r.title || 'untitled'}${dims}\n` +
720
+ ` image_url: ${r.image_url}\n` +
721
+ ` source: ${r.page_url || r.source || 'unknown'}`;
722
+ });
723
+ return text(
724
+ `Found ${results.length} images for "${args.query}":\n\n${lines.join('\n')}\n\n` +
725
+ 'To show one in chat, embed it in your reply as markdown: ![title](image_url). ' +
726
+ 'To keep a copy in the workspace (and post it as an attachment), call workspace_image_save with the image_url.'
727
+ );
728
+ }
729
+
730
+ case 'workspace_image_save': {
731
+ const result = await this.ws.uploadFileFromUrl(this.workspaceId, this.token, args.url, {
732
+ filename: args.filename,
733
+ channelName: this.channelName,
734
+ source: `openagents:${this.agentName}`,
735
+ postToChannel: args.post_to_channel !== false,
736
+ caption: args.caption,
737
+ });
738
+ const posted = result.posted_to_channel
739
+ ? ' and posted to the channel'
740
+ : '';
741
+ return text(`Saved ${result.filename} (${result.content_type}, ${result.size} bytes, id: ${result.id})${posted}.`);
742
+ }
743
+
632
744
  case 'workspace_browser_open': {
633
745
  const opts = {
634
746
  url: args.url || 'about:blank',
package/src/paths.js CHANGED
@@ -517,12 +517,69 @@ function aiderBinDirs() {
517
517
  return dirs;
518
518
  }
519
519
 
520
+ /** Executable suffixes to try for a bare binary name, per platform. */
521
+ const BIN_EXTS = IS_WINDOWS ? ['.cmd', '.exe', '.bat', ''] : [''];
522
+
523
+ /**
524
+ * Last-resort binary lookup: walk the directories we already know agent CLIs
525
+ * land in and check the filesystem directly, instead of asking the shell.
526
+ *
527
+ * `where`/`which` is not enough on Windows. The Cursor, Amp and Hermes
528
+ * installers edit the *registry* PATH, which an already-running process never
529
+ * inherits — so a perfectly good install is invisible to a PATH lookup until
530
+ * the machine (or at least the app) is restarted. Every adapter grew its own
531
+ * tiered search to cope; the launcher had none, so it alone reported "can't
532
+ * find it" and then opened a terminal running a bare command that could only
533
+ * fail with "'cursor-agent' is not recognized".
534
+ *
535
+ * Deliberately reuses getExtraBinDirs() rather than a second hardcoded list:
536
+ * that list already IS the curated set, and a copy of it is exactly how the
537
+ * launcher and the adapters drifted apart in the first place.
538
+ *
539
+ * Runs only after a PATH lookup has already missed, so it can add resolutions
540
+ * but never change one that already works.
541
+ *
542
+ * @param {string|string[]} names binary name(s), e.g. ['cursor-agent','agent']
543
+ * @param {string} [agentType] adds that agent's isolated runtime bin dir
544
+ * @returns {string|null} absolute path, or null
545
+ */
546
+ function resolveBinaryInKnownDirs(names, agentType) {
547
+ const list = (Array.isArray(names) ? names : [names]).filter(Boolean);
548
+ if (!list.length) return null;
549
+
550
+ const dirs = [];
551
+ const push = (d) => { if (d && !dirs.includes(d)) dirs.push(d); };
552
+
553
+ // The agent's own isolated runtime first — it is the copy this launcher
554
+ // installed, and should win over anything the user happens to have globally.
555
+ if (agentType) push(path.join(getRuntimePrefix(agentType), 'node_modules', '.bin'));
556
+ push(path.join(HOME, '.openagents', 'nodejs', 'node_modules', '.bin'));
557
+ try { push(path.dirname(process.execPath)); } catch {}
558
+ for (const d of getExtraBinDirs()) push(d);
559
+
560
+ for (const dir of dirs) {
561
+ for (const name of list) {
562
+ for (const ext of BIN_EXTS) {
563
+ const candidate = path.join(dir, `${name}${ext}`);
564
+ try {
565
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
566
+ } catch {
567
+ /* unreadable path — keep looking */
568
+ }
569
+ }
570
+ }
571
+ }
572
+ return null;
573
+ }
574
+
575
+
520
576
  module.exports = {
521
577
  getExtraBinDirs,
522
578
  getEnhancedPATH,
523
579
  getEnhancedEnv,
524
580
  whichBinary,
525
581
  whereBinary,
582
+ resolveBinaryInKnownDirs,
526
583
  clearBinaryLookupCache,
527
584
  getRuntimePrefix,
528
585
  getCorePrefix,
@@ -192,26 +192,29 @@ class WorkspaceClient {
192
192
  }
193
193
 
194
194
  /**
195
- * Fetch the most recent N messages in a channel, returned oldest-to-newest.
196
- * Used by adapters to rebuild context for a fresh Claude Code session
197
- * when --resume of the previous session fails (the channel's chat history
198
- * is the only thing that survives a session-storage rotation).
195
+ * Fetch N messages in a channel, returned oldest-to-newest. `sort` picks
196
+ * which end of the channel the window comes from: 'desc' (default) takes
197
+ * the most recent N, 'asc' takes the channel's opening N. Used by adapters
198
+ * to rebuild context for a fresh Claude Code session when --resume of the
199
+ * previous session fails (the channel's chat history is the only thing
200
+ * that survives a session-storage rotation).
199
201
  */
200
- async getRecentMessages(workspaceId, channelName, token, limit = 30) {
202
+ async getRecentMessages(workspaceId, channelName, token, limit = 30, { sort = 'desc' } = {}) {
201
203
  try {
202
204
  const params = new URLSearchParams({
203
205
  network: workspaceId,
204
206
  channel: channelName,
205
207
  type: 'workspace.message',
206
- sort: 'desc',
208
+ sort,
207
209
  limit: String(limit),
208
210
  });
209
211
  const data = await this._get(`/v1/events?${params}`, this._wsHeaders(token));
210
212
  const result = data.data || data;
211
213
  const events = (result && result.events) || [];
212
- // Server returned newest-first; reverse so the caller can present them
213
- // in chronological order without further fiddling.
214
- return events.slice().reverse().map((e) => this._eventToMessage(e));
214
+ // A desc window arrives newest-first; reverse so the caller always gets
215
+ // chronological order. An asc window is already chronological.
216
+ const ordered = sort === 'asc' ? events.slice() : events.slice().reverse();
217
+ return ordered.map((e) => this._eventToMessage(e));
215
218
  } catch {
216
219
  return [];
217
220
  }
@@ -471,6 +474,31 @@ class WorkspaceClient {
471
474
  return data.data || data;
472
475
  }
473
476
 
477
+ /**
478
+ * Search the web for images via POST /v1/search/images.
479
+ */
480
+ async searchImages(workspaceId, token, query, { count = 10 } = {}) {
481
+ const body = { query, network: workspaceId, count };
482
+ const data = await this._post('/v1/search/images', body, this._wsHeaders(token), 30000);
483
+ return data.data || data;
484
+ }
485
+
486
+ /**
487
+ * Download a URL into workspace storage via POST /v1/files/from_url.
488
+ * With postToChannel, the file is also posted into the chat as an
489
+ * inline attachment.
490
+ */
491
+ async uploadFileFromUrl(workspaceId, token, url, {
492
+ filename, channelName, source = 'human:user', postToChannel = false, caption,
493
+ } = {}) {
494
+ const body = { url, network: workspaceId, source, post_to_channel: postToChannel };
495
+ if (filename) body.filename = filename;
496
+ if (channelName) body.channel_name = channelName;
497
+ if (caption) body.caption = caption;
498
+ const data = await this._post('/v1/files/from_url', body, this._wsHeaders(token), 90000);
499
+ return data.data || data;
500
+ }
501
+
474
502
  /**
475
503
  * List files via GET /v1/files.
476
504
  */
@@ -583,6 +611,19 @@ class WorkspaceClient {
583
611
  return data.data || data;
584
612
  }
585
613
 
614
+ /**
615
+ * Server-side fetch chain via POST /v1/fetch — reads a page WITHOUT
616
+ * holding a shared browser tab. Static HTTP first; JS-heavy pages are
617
+ * rendered in an ephemeral browser session that is closed immediately.
618
+ */
619
+ async fetchUrl(workspaceId, token, url, { mode = 'auto', maxChars, source } = {}) {
620
+ const body = { url, network: workspaceId, mode };
621
+ if (maxChars) body.max_chars = maxChars;
622
+ if (source) body.source = source;
623
+ const data = await this._post('/v1/fetch', body, this._wsHeaders(token), 90000);
624
+ return data.data || data;
625
+ }
626
+
586
627
  /**
587
628
  * List persistent browser contexts via GET /v1/browser/contexts.
588
629
  */
@@ -699,18 +740,18 @@ class WorkspaceClient {
699
740
 
700
741
  // ── Knowledge Base ──
701
742
 
702
- async listKnowledge(workspaceId, token, { limit = 100 } = {}) {
743
+ async listKnowledge(workspaceId, token, { limit = 100, timeout } = {}) {
703
744
  const params = new URLSearchParams({
704
745
  network: workspaceId,
705
746
  limit: String(limit),
706
747
  });
707
- const data = await this._get(`/v1/knowledge?${params}`, this._wsHeaders(token));
748
+ const data = await this._get(`/v1/knowledge?${params}`, this._wsHeaders(token), timeout);
708
749
  return data.data || data;
709
750
  }
710
751
 
711
- async getKnowledge(workspaceId, token, entryId) {
752
+ async getKnowledge(workspaceId, token, entryId, { timeout } = {}) {
712
753
  const params = new URLSearchParams({ network: workspaceId });
713
- const data = await this._get(`/v1/knowledge/${entryId}?${params}`, this._wsHeaders(token));
754
+ const data = await this._get(`/v1/knowledge/${entryId}?${params}`, this._wsHeaders(token), timeout);
714
755
  return data.data || data;
715
756
  }
716
757
 
@@ -878,7 +919,11 @@ class WorkspaceClient {
878
919
  if (typeof msg === 'string' && msg.toLowerCase().includes('session_revoked')) {
879
920
  reject(new SessionRevokedError(msg));
880
921
  } else {
881
- reject(new Error(msg));
922
+ const err = new Error(msg);
923
+ // Preserve structured error details (error_code, hint,
924
+ // quota occupancy, ...) so tool handlers can surface them.
925
+ if (parsed.data && typeof parsed.data === 'object') err.data = parsed.data;
926
+ reject(err);
882
927
  }
883
928
  } else {
884
929
  resolve(parsed);