@openagents-org/agent-launcher 0.2.127 → 0.2.130

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.
@@ -1,22 +1,612 @@
1
1
  /**
2
- * Cursor adapter AI-powered code editor agent mode.
2
+ * Cursor CLI adapter for OpenAgents workspace.
3
3
  *
4
- * Uses direct LLM API mode (OpenAI-compatible chat completions).
5
- * Port of Python: sdk/src/openagents/adapters/cursor.py
4
+ * Bridges the Cursor Agent CLI to an OpenAgents workspace via:
5
+ * - Polling loop for incoming messages
6
+ * - Cursor CLI subprocess (stream-json) for task execution
7
+ * - SKILL.md file for workspace tool access
6
8
  */
7
9
 
8
10
  'use strict';
9
11
 
10
- const LlmDirectAdapter = require('./llm-direct');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const path = require('path');
15
+ const { execSync, spawn } = require('child_process');
11
16
 
12
- class CursorAdapter extends LlmDirectAdapter {
17
+ const BaseAdapter = require('./base');
18
+ const { formatAttachmentsForPrompt, SESSION_DEFAULT_RE, generateSessionTitle } = require('./utils');
19
+ const { buildCursorSkillMd } = require('./workspace-prompt');
20
+
21
+ const IS_WINDOWS = process.platform === 'win32';
22
+
23
+ class CursorAdapter extends BaseAdapter {
13
24
  constructor(opts) {
14
- super({
15
- ...opts,
16
- adapterLabel: 'Cursor',
17
- modelEnvVar: 'CURSOR_MODEL',
25
+ super(opts);
26
+ this.disabledModules = opts.disabledModules || new Set();
27
+ this._channelSessions = {};
28
+ this._channelProcesses = {};
29
+ this._stoppingChannels = new Set();
30
+ this._sessionsFile = path.join(
31
+ os.homedir(), '.openagents', 'sessions',
32
+ `${this.workspaceId}_${this.agentName}_cursor.json`
33
+ );
34
+ this._loadSessions();
35
+ }
36
+
37
+ _loadSessions() {
38
+ try {
39
+ if (fs.existsSync(this._sessionsFile)) {
40
+ const data = JSON.parse(fs.readFileSync(this._sessionsFile, 'utf-8'));
41
+ if (data && typeof data === 'object') {
42
+ Object.assign(this._channelSessions, data);
43
+ this._log(`Loaded ${Object.keys(data).length} session(s)`);
44
+ }
45
+ }
46
+ } catch {
47
+ this._log('Could not load sessions file, starting fresh');
48
+ }
49
+ }
50
+
51
+ _saveSessions() {
52
+ try {
53
+ const dir = path.dirname(this._sessionsFile);
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ fs.writeFileSync(this._sessionsFile, JSON.stringify(this._channelSessions));
56
+ } catch {}
57
+ }
58
+
59
+ async _onControlAction(action, payload) {
60
+ if (action === 'stop') {
61
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
62
+ if (channel && this._channelProcesses[channel]) {
63
+ this._stoppingChannels.add(channel);
64
+ await this._stopProcess(this._channelProcesses[channel]);
65
+ delete this._channelProcesses[channel];
66
+ delete this._channelQueues[channel];
67
+ try { await this.sendResponse(channel, 'Execution stopped.'); } catch {}
68
+ } else {
69
+ await this._stopAllProcesses('Execution stopped.');
70
+ }
71
+ } else if (action === 'restart') {
72
+ const channel = (payload && typeof payload === 'object') ? payload.channel : null;
73
+ if (channel) {
74
+ if (this._channelProcesses[channel]) {
75
+ this._stoppingChannels.add(channel);
76
+ await this._stopProcess(this._channelProcesses[channel]);
77
+ delete this._channelProcesses[channel];
78
+ delete this._channelQueues[channel];
79
+ }
80
+ delete this._channelSessions[channel];
81
+ this._saveSessions();
82
+ try { await this.sendResponse(channel, 'Session cleared. Send a new message to start fresh.'); } catch {}
83
+ }
84
+ }
85
+ }
86
+
87
+ stop() {
88
+ this._stopAllProcesses(
89
+ 'Task interrupted — daemon restarting. Send another message to continue.'
90
+ ).catch(() => {});
91
+ super.stop();
92
+ }
93
+
94
+ async _stopProcess(proc) {
95
+ if (!proc || proc.exitCode !== null) return;
96
+ try {
97
+ if (IS_WINDOWS) {
98
+ try { proc.kill('SIGINT'); } catch {}
99
+ const exited = await new Promise((resolve) => {
100
+ if (proc.exitCode !== null) { resolve(true); return; }
101
+ const timeout = setTimeout(() => resolve(false), 1500);
102
+ proc.once('exit', () => { clearTimeout(timeout); resolve(true); });
103
+ });
104
+ if (!exited) {
105
+ try { execSync(`taskkill /F /T /PID ${proc.pid}`, { timeout: 5000 }); } catch {}
106
+ }
107
+ } else {
108
+ try { process.kill(-proc.pid, 'SIGTERM'); } catch {
109
+ proc.kill('SIGTERM');
110
+ }
111
+ await new Promise((resolve) => {
112
+ let done = false;
113
+ const finish = () => { if (done) return; done = true; resolve(); };
114
+ const timeout = setTimeout(() => {
115
+ try { process.kill(-proc.pid, 'SIGKILL'); } catch {
116
+ proc.kill('SIGKILL');
117
+ }
118
+ const reapTimeout = setTimeout(finish, 1000);
119
+ proc.once('exit', () => { clearTimeout(reapTimeout); finish(); });
120
+ }, 1500);
121
+ proc.once('exit', () => { clearTimeout(timeout); finish(); });
122
+ });
123
+ }
124
+ } catch {}
125
+ }
126
+
127
+ async _buildChannelRecap(channelName, currentMessage) {
128
+ const messages = await this.client.getRecentMessages(
129
+ this.workspaceId, channelName, this.token, 30
130
+ );
131
+ if (!messages || messages.length === 0) return null;
132
+
133
+ const lines = [];
134
+ for (const m of messages) {
135
+ const mt = m.messageType || 'chat';
136
+ if (mt === 'status' || mt === 'thinking' || mt === 'loading') continue;
137
+ const text = (m.content || '').trim();
138
+ if (!text) continue;
139
+ if (text === currentMessage) continue;
140
+ const who = m.senderType === 'human'
141
+ ? (m.senderName || 'user')
142
+ : (m.senderName || 'agent');
143
+ const truncated = text.length > 800 ? text.slice(0, 800) + '…' : text;
144
+ lines.push(`[${who}] ${truncated}`);
145
+ }
146
+ if (lines.length === 0) return null;
147
+
148
+ const tail = lines.slice(-15).join('\n');
149
+ return (
150
+ 'You previously worked in this channel but your prior session is no ' +
151
+ 'longer available, so here is the recent conversation for context:\n\n' +
152
+ tail
153
+ );
154
+ }
155
+
156
+ async _stopAllProcesses(completionMessage = 'Execution stopped.') {
157
+ const entries = Object.entries(this._channelProcesses);
158
+ if (!entries.length) return;
159
+ this._log(`Stopping ${entries.length} running process(es)...`);
160
+ for (const [channel, proc] of entries) {
161
+ this._stoppingChannels.add(channel);
162
+ await this._stopProcess(proc);
163
+ delete this._channelProcesses[channel];
164
+ delete this._channelQueues[channel];
165
+ try { await this.sendResponse(channel, completionMessage); } catch {}
166
+ }
167
+ }
168
+
169
+ // ── Binary resolution ──
170
+
171
+ _findCursorBinary() {
172
+ const home = os.homedir();
173
+ const ext = IS_WINDOWS ? '.cmd' : '';
174
+
175
+ // 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;
178
+
179
+ // 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;
182
+
183
+ // Tier 1: PATH search
184
+ try {
185
+ if (IS_WINDOWS) {
186
+ const r = execSync('where agent.cmd 2>nul || where agent.exe 2>nul || where agent 2>nul', {
187
+ encoding: 'utf-8', timeout: 5000,
188
+ });
189
+ return r.split(/\r?\n/)[0].trim();
190
+ } else {
191
+ return execSync('which agent', { encoding: 'utf-8', timeout: 5000 }).trim();
192
+ }
193
+ } catch {}
194
+
195
+ // Tier 2: Next to current Node.js interpreter (npm global)
196
+ const nodeBinDir = path.dirname(process.execPath);
197
+ const nearNode = path.join(nodeBinDir, `agent${ext}`);
198
+ if (fs.existsSync(nearNode)) return nearNode;
199
+
200
+ // Tier 3: Common install locations
201
+ const candidates = IS_WINDOWS ? [
202
+ path.join(process.env.APPDATA || '', 'npm', 'agent.cmd'),
203
+ ] : [
204
+ path.join(home, '.local', 'bin', 'agent'),
205
+ path.join(home, '.npm-global', 'bin', 'agent'),
206
+ path.join(home, '.cursor', 'bin', 'agent'),
207
+ '/opt/homebrew/bin/agent',
208
+ '/usr/local/bin/agent',
209
+ ];
210
+ for (const c of candidates) {
211
+ if (fs.existsSync(c)) return c;
212
+ }
213
+
214
+ return null;
215
+ }
216
+
217
+ _findNodeBin() {
218
+ const home = os.homedir();
219
+ const candidates = IS_WINDOWS
220
+ ? [path.join(home, '.openagents', 'nodejs', 'node.exe')]
221
+ : [path.join(home, '.openagents', 'nodejs', 'node'),
222
+ path.join(home, '.openagents', 'nodejs', 'bin', 'node')];
223
+ for (const c of candidates) {
224
+ if (fs.existsSync(c)) return c;
225
+ }
226
+ return 'node';
227
+ }
228
+
229
+ _resolveToNodeCmd(binPath) {
230
+ const nodeBin = this._findNodeBin();
231
+ if (IS_WINDOWS && binPath.toLowerCase().endsWith('.cmd')) {
232
+ const cmdDir = path.dirname(path.resolve(binPath));
233
+ const cmdContent = fs.readFileSync(binPath, 'utf-8');
234
+ const jsMatch = cmdContent.match(/%dp0%\\([^\s"*?]+\.m?js)/i);
235
+ if (jsMatch) {
236
+ return [nodeBin, path.resolve(cmdDir, jsMatch[1])];
237
+ }
238
+ } else {
239
+ try {
240
+ let target = binPath;
241
+ if (fs.lstatSync(binPath).isSymbolicLink()) {
242
+ target = path.resolve(path.dirname(binPath), fs.readlinkSync(binPath));
243
+ }
244
+ if (target.endsWith('.js') || target.endsWith('.mjs')) {
245
+ return [nodeBin, target];
246
+ }
247
+ } catch {}
248
+ }
249
+ return null;
250
+ }
251
+
252
+ // ── Skill file writing ──
253
+
254
+ _writeSkillFile(channelName) {
255
+ const workDir = this.workingDir || process.cwd();
256
+ const skillDir = path.join(workDir, '.cursor', 'skills');
257
+ fs.mkdirSync(skillDir, { recursive: true });
258
+ const skillFile = path.join(skillDir, 'openagents-workspace.md');
259
+
260
+ const skillContent = buildCursorSkillMd({
261
+ endpoint: this.endpoint,
262
+ workspaceId: this.workspaceId,
263
+ token: this.token,
264
+ agentName: this.agentName,
265
+ channelName,
266
+ disabledModules: this.disabledModules,
18
267
  });
268
+ fs.writeFileSync(skillFile, skillContent, 'utf-8');
269
+ this._log(`Wrote workspace skill to ${skillFile}`);
270
+ }
271
+
272
+ // ── Command building ──
273
+
274
+ _buildCursorCmd(prompt, channelName, { skipResume = false } = {}) {
275
+ const agentBin = this._findCursorBinary();
276
+ if (!agentBin) {
277
+ throw new Error('Cursor CLI not found. Install with: curl https://cursor.com/install -fsSL | bash');
278
+ }
279
+
280
+ const cmd = [agentBin, '-p', prompt, '--output-format', 'stream-json', '--trust', '--force'];
281
+
282
+ // Model selection
283
+ const model = (this.agentEnv || process.env).CURSOR_MODEL;
284
+ if (model) {
285
+ cmd.push('--model', model);
286
+ }
287
+
288
+ // Working directory
289
+ if (this.workingDir) {
290
+ cmd.push('--workspace', this.workingDir);
291
+ }
292
+
293
+ // Resume existing conversation
294
+ const sessionId = this._channelSessions[channelName];
295
+ if (sessionId && !skipResume) {
296
+ cmd.push('--resume', sessionId);
297
+ }
298
+
299
+ return cmd;
19
300
  }
301
+
302
+ // ── Message handling ──
303
+
304
+ async _handleMessage(msg) {
305
+ let content = (msg.content || '').trim();
306
+ const attachments = msg.attachments || [];
307
+
308
+ const attText = formatAttachmentsForPrompt(attachments);
309
+ if (attText) {
310
+ content = content ? content + attText : attText.trim();
311
+ }
312
+
313
+ if (!content) return;
314
+
315
+ const msgChannel = msg.sessionId || this.channelName;
316
+ this._stoppingChannels.delete(msgChannel);
317
+ const sender = msg.senderName || msg.senderType || 'user';
318
+ this._log(`Processing message from ${sender} in ${msgChannel}: ${content.slice(0, 80)}...`);
319
+
320
+ // Auto-title on first encounter
321
+ if (!this._titledSessions.has(msgChannel)) {
322
+ this._titledSessions.add(msgChannel);
323
+ try {
324
+ const info = await this.client.getSession(this.workspaceId, msgChannel, this.token);
325
+ const resumeFrom = info.resumeFrom;
326
+ if (resumeFrom && !this._channelSessions[msgChannel]) {
327
+ const sourceSession = this._channelSessions[resumeFrom];
328
+ if (sourceSession) {
329
+ this._channelSessions[msgChannel] = sourceSession;
330
+ this._saveSessions();
331
+ this._log(`Resuming channel ${msgChannel} from ${resumeFrom}`);
332
+ }
333
+ }
334
+ const title = generateSessionTitle(content);
335
+ if (title && !info.titleManuallySet && SESSION_DEFAULT_RE.test(info.title || '')) {
336
+ await this.client.updateSession(
337
+ this.workspaceId, msgChannel, this.token,
338
+ { title, autoTitle: true }
339
+ );
340
+ }
341
+ } catch {}
342
+ }
343
+
344
+ await this.sendStatus(msgChannel, 'thinking...');
345
+
346
+ // Write workspace skill file before each spawn
347
+ try {
348
+ this._writeSkillFile(msgChannel);
349
+ } catch (e) {
350
+ this._log(`Warning: could not write skill file: ${e.message}`);
351
+ }
352
+
353
+ let cmd;
354
+ let _shouldRetry = false;
355
+ let effectiveContent = content;
356
+
357
+ for (let attempt = 0; attempt < 2; attempt++) {
358
+ if (attempt > 0) {
359
+ try {
360
+ const recap = await this._buildChannelRecap(msgChannel, content);
361
+ if (recap) effectiveContent = `${recap}\n\n---\n\n${content}`;
362
+ } catch {}
363
+ }
364
+
365
+ try {
366
+ cmd = this._buildCursorCmd(effectiveContent, msgChannel, { skipResume: attempt > 0 });
367
+ } catch (e) {
368
+ await this.sendError(msgChannel, e.message);
369
+ return;
370
+ }
371
+
372
+ try {
373
+ const resolved = this._resolveToNodeCmd(cmd[0]);
374
+ if (resolved) {
375
+ cmd = [resolved[0], resolved[1], ...cmd.slice(1)];
376
+ } else if (IS_WINDOWS && cmd[0].toLowerCase().endsWith('.cmd')) {
377
+ cmd = ['cmd.exe', '/c', ...cmd];
378
+ }
379
+
380
+ const cleanEnv = { ...(this.agentEnv || process.env) };
381
+
382
+ const proc = spawn(cmd[0], cmd.slice(1), {
383
+ stdio: ['ignore', 'pipe', 'pipe'],
384
+ env: cleanEnv,
385
+ cwd: this.workingDir,
386
+ detached: !IS_WINDOWS,
387
+ windowsHide: true,
388
+ });
389
+ this._channelProcesses[msgChannel] = proc;
390
+
391
+ const lastResponseText = [];
392
+ let hasToolUseSinceLastText = false;
393
+ let postedThinking = false;
394
+ let everPostedAnything = false;
395
+ let stderrBuf = '';
396
+ let lineBuffer = '';
397
+ let _pendingLines = Promise.resolve();
398
+
399
+ if (proc.stderr) {
400
+ proc.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf-8'); });
401
+ }
402
+
403
+ _shouldRetry = await new Promise((resolve, reject) => {
404
+ let consecutiveTimeouts = 0;
405
+ let lastDataTime = Date.now();
406
+ let timeoutTimer = null;
407
+
408
+ const resetTimeout = () => {
409
+ consecutiveTimeouts = 0;
410
+ lastDataTime = Date.now();
411
+ };
412
+
413
+ const startTimeoutMonitor = () => {
414
+ timeoutTimer = setInterval(async () => {
415
+ const elapsed = Date.now() - lastDataTime;
416
+ if (elapsed >= 15000) {
417
+ consecutiveTimeouts++;
418
+ lastDataTime = Date.now();
419
+ if (consecutiveTimeouts === 2) {
420
+ try { await this.sendStatus(msgChannel, 'Compacting conversation...'); } catch {}
421
+ }
422
+ if (consecutiveTimeouts >= 20) {
423
+ this._log(`Process idle for ${consecutiveTimeouts * 15}s, killing...`);
424
+ await this._stopProcess(proc);
425
+ }
426
+ }
427
+ }, 15000);
428
+ };
429
+ startTimeoutMonitor();
430
+
431
+ const processLine = async (line) => {
432
+ line = line.trim();
433
+ if (!line) return;
434
+ resetTimeout();
435
+
436
+ let event;
437
+ try { event = JSON.parse(line); } catch { return; }
438
+
439
+ const eventType = event.type;
440
+
441
+ if (eventType === 'assistant') {
442
+ const blocks = (event.message || {}).content || [];
443
+ for (const block of blocks) {
444
+ if (block.type === 'text' && block.text && block.text.trim()) {
445
+ if (hasToolUseSinceLastText) {
446
+ lastResponseText.length = 0;
447
+ hasToolUseSinceLastText = false;
448
+ }
449
+ lastResponseText.push(block.text.trim());
450
+ postedThinking = true;
451
+ everPostedAnything = true;
452
+ try { await this.sendThinking(msgChannel, block.text.trim()); } catch {}
453
+ }
454
+ }
455
+ } else if (eventType === 'tool_call') {
456
+ const subtype = event.subtype || '';
457
+ if (subtype === 'started') {
458
+ hasToolUseSinceLastText = true;
459
+ postedThinking = false;
460
+ lastResponseText.length = 0;
461
+ const tc = event.tool_call || {};
462
+ const toolName = _extractToolName(tc);
463
+ const toolDetail = _extractToolDetail(tc);
464
+ const label = toolDetail ? `${toolName} › ${toolDetail}` : toolName;
465
+ await this.sendStatus(msgChannel, label);
466
+ everPostedAnything = true;
467
+ }
468
+ } else if (eventType === 'result') {
469
+ const sessionId = event.session_id;
470
+ if (sessionId) {
471
+ this._channelSessions[msgChannel] = sessionId;
472
+ this._saveSessions();
473
+ }
474
+ if (event.is_error) {
475
+ this._log(`Cursor error: ${String(event.result || '').slice(0, 200)}`);
476
+ }
477
+ } else if (eventType === 'system') {
478
+ const sessionId = event.session_id;
479
+ if (sessionId && !this._channelSessions[msgChannel]) {
480
+ this._channelSessions[msgChannel] = sessionId;
481
+ this._saveSessions();
482
+ }
483
+ }
484
+ };
485
+
486
+ proc.on('exit', async (code) => {
487
+ if (timeoutTimer) clearInterval(timeoutTimer);
488
+
489
+ try { await _pendingLines; } catch {}
490
+
491
+ const lines = lineBuffer.split('\n');
492
+ for (const line of lines) {
493
+ try { await processLine(line); } catch {}
494
+ }
495
+
496
+ delete this._channelProcesses[msgChannel];
497
+
498
+ const stoppedByUser = this._stoppingChannels.has(msgChannel);
499
+
500
+ if (stoppedByUser) {
501
+ try { await this.cleanupTodos(msgChannel); } catch {}
502
+ } else if (!msg._todoNudge) {
503
+ try {
504
+ const remaining = await this.getRemainingTodos(msgChannel);
505
+ if (remaining.length > 0) {
506
+ const items = remaining.map((t) => `- ${t.content}`).join('\n');
507
+ const nudge = `You have ${remaining.length} remaining task(s) from your plan:\n${items}\n\nPlease continue working on them.`;
508
+ if (!this._channelQueues[msgChannel]) this._channelQueues[msgChannel] = [];
509
+ this._channelQueues[msgChannel].push({
510
+ content: nudge,
511
+ senderType: 'system',
512
+ senderName: 'system:todos',
513
+ sessionId: msgChannel,
514
+ messageType: 'chat',
515
+ _todoNudge: true,
516
+ });
517
+ }
518
+ } catch {}
519
+ } else {
520
+ try { await this.cleanupTodos(msgChannel); } catch {}
521
+ }
522
+
523
+ if (stoppedByUser) {
524
+ this._stoppingChannels.delete(msgChannel);
525
+ resolve(false);
526
+ return;
527
+ }
528
+
529
+ this._log(`CLI exited: code=${code}, lastResponseText=${lastResponseText.length} items, everPosted=${everPostedAnything}, hasSession=${!!this._channelSessions[msgChannel]}`);
530
+ if (code !== 0 && stderrBuf.trim()) {
531
+ this._log(`stderr: ${stderrBuf.trim().slice(0, 500)}`);
532
+ }
533
+
534
+ if (lastResponseText.length > 0) {
535
+ const fullResponse = lastResponseText.join('\n').trim();
536
+ if (/prompt is too long/i.test(fullResponse) && this._channelSessions[msgChannel]) {
537
+ this._log(`Prompt too long with resumed session for ${msgChannel}, clearing and retrying`);
538
+ delete this._channelSessions[msgChannel];
539
+ this._saveSessions();
540
+ resolve(true);
541
+ } else if (fullResponse) {
542
+ try { await this.sendResponse(msgChannel, fullResponse); } catch {}
543
+ resolve(false);
544
+ } else {
545
+ resolve(false);
546
+ }
547
+ } else if (this._channelSessions[msgChannel] && !everPostedAnything) {
548
+ this._log(`Stale session detected for ${msgChannel}, clearing and retrying without resume`);
549
+ delete this._channelSessions[msgChannel];
550
+ this._saveSessions();
551
+ resolve(true);
552
+ } else {
553
+ if (!everPostedAnything) {
554
+ try { await this.sendResponse(msgChannel, 'No response generated. Please try again.'); } catch {}
555
+ }
556
+ resolve(false);
557
+ }
558
+ });
559
+
560
+ proc.on('error', (err) => {
561
+ if (timeoutTimer) clearInterval(timeoutTimer);
562
+ reject(err);
563
+ });
564
+
565
+ proc.stdout.on('data', (chunk) => {
566
+ lineBuffer += chunk.toString('utf-8');
567
+ resetTimeout();
568
+ const lines = lineBuffer.split('\n');
569
+ lineBuffer = lines.pop();
570
+ for (const line of lines) {
571
+ _pendingLines = _pendingLines.then(() => processLine(line)).catch(() => {});
572
+ }
573
+ });
574
+ });
575
+ } catch (e) {
576
+ this._log(`Error handling message: ${e.message}`);
577
+ await this.sendError(msgChannel, `Error processing message: ${e.message}`);
578
+ break;
579
+ }
580
+ if (!_shouldRetry) break;
581
+ } // end for attempt
582
+
583
+ delete this._channelProcesses[msgChannel];
584
+ }
585
+ }
586
+
587
+ /**
588
+ * Extract human-readable tool name from a Cursor stream-json tool_call object.
589
+ * Cursor uses nested keys like readToolCall, writeToolCall, etc.
590
+ */
591
+ function _extractToolName(tc) {
592
+ if (tc.readToolCall) return 'Read';
593
+ if (tc.writeToolCall) return 'Write';
594
+ if (tc.editToolCall) return 'Edit';
595
+ if (tc.bashToolCall || tc.terminalToolCall) return 'Bash';
596
+ if (tc.globToolCall) return 'Glob';
597
+ if (tc.grepToolCall) return 'Grep';
598
+ if (tc.function) return tc.function.name || 'tool';
599
+ const keys = Object.keys(tc).filter((k) => k.endsWith('ToolCall'));
600
+ if (keys.length > 0) return keys[0].replace('ToolCall', '');
601
+ return 'tool';
602
+ }
603
+
604
+ function _extractToolDetail(tc) {
605
+ const call = tc.readToolCall || tc.writeToolCall || tc.editToolCall
606
+ || tc.bashToolCall || tc.terminalToolCall || tc.globToolCall || tc.grepToolCall;
607
+ if (!call || !call.args) return '';
608
+ const args = call.args;
609
+ return args.command || args.path || args.file_path || args.pattern || args.query || '';
20
610
  }
21
611
 
22
612
  module.exports = CursorAdapter;
@@ -52,8 +52,11 @@ function generateSessionTitle(message, maxWords = 6) {
52
52
 
53
53
  /**
54
54
  * Format attachment metadata into text to append to an agent prompt.
55
+ * @param {Array} attachments
56
+ * @param {'mcp'|'skills'} [toolMode='mcp']
57
+ * @param {boolean} [isWindows]
55
58
  */
56
- function formatAttachmentsForPrompt(attachments) {
59
+ function formatAttachmentsForPrompt(attachments, toolMode = 'mcp', isWindows = process.platform === 'win32') {
57
60
  if (!attachments || attachments.length === 0) return null;
58
61
 
59
62
  const lines = ['\n[Attached files]'];
@@ -61,16 +64,37 @@ function formatAttachmentsForPrompt(attachments) {
61
64
  const filename = att.filename || 'unknown';
62
65
  const fileId = att.fileId || '';
63
66
  const contentType = att.contentType || '';
64
- if (contentType.startsWith('image/')) {
65
- lines.push(
66
- `- Image: ${filename} (file_id: ${fileId}) ` +
67
- 'use workspace_read_file to view this image'
68
- );
67
+ if (toolMode === 'skills') {
68
+ const url = att.url || `{WORKSPACE_API}/v1/files/${fileId}`;
69
+ const curl = isWindows ? 'curl.exe' : 'curl';
70
+ const tmpDir = isWindows ? '$env:TEMP' : '/tmp';
71
+ if (contentType.startsWith('image/')) {
72
+ lines.push(
73
+ `- Image: ${filename} (file_id: ${fileId}) — ` +
74
+ `download with curl, then use your Read tool on the local file to view it:\n` +
75
+ ` Step 1: ${curl} -s -H "X-Workspace-Token: $TOKEN" "${url}" -o ${tmpDir}/${filename}\n` +
76
+ ` Step 2: Use the Read tool on ${tmpDir}/${filename} to see the image`
77
+ );
78
+ } else {
79
+ lines.push(
80
+ `- File: ${filename} (file_id: ${fileId}, type: ${contentType}) — ` +
81
+ `download with curl, then use your Read tool on the local file:\n` +
82
+ ` Step 1: ${curl} -s -H "X-Workspace-Token: $TOKEN" "${url}" -o ${tmpDir}/${filename}\n` +
83
+ ` Step 2: Use the Read tool on ${tmpDir}/${filename} to read the file`
84
+ );
85
+ }
69
86
  } else {
70
- lines.push(
71
- `- File: ${filename} (file_id: ${fileId}, type: ${contentType}) — ` +
72
- 'use workspace_read_file to read this file'
73
- );
87
+ if (contentType.startsWith('image/')) {
88
+ lines.push(
89
+ `- Image: ${filename} (file_id: ${fileId}) — ` +
90
+ 'use workspace_read_file to view this image'
91
+ );
92
+ } else {
93
+ lines.push(
94
+ `- File: ${filename} (file_id: ${fileId}, type: ${contentType}) — ` +
95
+ 'use workspace_read_file to read this file'
96
+ );
97
+ }
74
98
  }
75
99
  }
76
100
  return lines.join('\n');