@openagents-org/agent-launcher 0.2.128 → 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.
- package/package.json +1 -1
- package/registry.json +18 -23
- package/src/adapters/base.js +111 -2
- package/src/adapters/claude.js +1 -1
- package/src/adapters/cursor.js +599 -9
- package/src/adapters/utils.js +34 -10
- package/src/adapters/workspace-prompt.js +199 -66
- package/src/mcp-server.js +8 -1
- package/src/workspace-client.js +27 -0
package/src/adapters/cursor.js
CHANGED
|
@@ -1,22 +1,612 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cursor adapter
|
|
2
|
+
* Cursor CLI adapter for OpenAgents workspace.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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;
|
package/src/adapters/utils.js
CHANGED
|
@@ -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 (
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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');
|