@buildify-cli/cursor-agent 0.1.1 → 0.2.1

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/dist/runner.js DELETED
@@ -1,714 +0,0 @@
1
- import { Agent, Cursor, CursorAgentError } from "@cursor/sdk";
2
- import EventSource from "eventsource";
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import { AGENT_CLIENT_ID_HEADER, AGENT_SESSION_ID_HEADER, } from "./types.js";
6
- const APP_NAME = "buildify-cursor-agent";
7
- const DEFAULT_MODEL = "composer-2.5";
8
- const TASK_POLL_INTERVAL_MS = 5_000;
9
- const HEARTBEAT_FAILURES_BEFORE_RECONNECT = 2;
10
- const REPORT_RETRY_COUNT = 3;
11
- const LOG_LEVEL_WEIGHT = {
12
- error: 0,
13
- warn: 1,
14
- info: 2,
15
- debug: 3,
16
- };
17
- export class AgentRunner {
18
- config;
19
- sessionId = null;
20
- heartbeatIntervalSec = 15;
21
- heartbeatTimer = null;
22
- pollTimer = null;
23
- eventSource = null;
24
- reconnectTimer = null;
25
- stopped = false;
26
- connected = false;
27
- reconnecting = false;
28
- pulling = false;
29
- reconnectAttempts = 0;
30
- heartbeatFailures = 0;
31
- activeTasks = new Map();
32
- availableModels = [];
33
- constructor(config) {
34
- this.config = config;
35
- }
36
- async start() {
37
- this.setupSignalHandlers();
38
- await this.connectWithRetry();
39
- this.startHeartbeat();
40
- this.openSse();
41
- this.startTaskPoller();
42
- this.info(`connected clientId=${this.config.clientId} session=${this.sessionId} maxConcurrent=${this.config.maxConcurrent}`);
43
- }
44
- async stop() {
45
- if (this.stopped) {
46
- return;
47
- }
48
- this.stopped = true;
49
- this.clearTimers();
50
- this.closeSse();
51
- for (const task of this.activeTasks.values()) {
52
- task.cancelRequested = true;
53
- await this.cancelRun(task).catch((error) => this.warn("cancel during stop failed", error));
54
- }
55
- if (this.sessionId) {
56
- await this.disconnect();
57
- }
58
- this.connected = false;
59
- this.info("stopped");
60
- }
61
- getStatus() {
62
- return {
63
- connected: this.connected,
64
- sessionId: this.sessionId,
65
- runningCount: this.activeTasks.size,
66
- maxConcurrent: this.config.maxConcurrent,
67
- clientId: this.config.clientId,
68
- server: this.config.server,
69
- activeTaskIds: [...this.activeTasks.keys()],
70
- };
71
- }
72
- setupSignalHandlers() {
73
- const shutdown = () => {
74
- void this.stop().finally(() => process.exit(0));
75
- };
76
- process.once("SIGINT", shutdown);
77
- process.once("SIGTERM", shutdown);
78
- }
79
- clearTimers() {
80
- if (this.heartbeatTimer) {
81
- clearInterval(this.heartbeatTimer);
82
- this.heartbeatTimer = null;
83
- }
84
- if (this.pollTimer) {
85
- clearInterval(this.pollTimer);
86
- this.pollTimer = null;
87
- }
88
- if (this.reconnectTimer) {
89
- clearTimeout(this.reconnectTimer);
90
- this.reconnectTimer = null;
91
- }
92
- }
93
- clearConnectionTimers() {
94
- if (this.heartbeatTimer) {
95
- clearInterval(this.heartbeatTimer);
96
- this.heartbeatTimer = null;
97
- }
98
- if (this.pollTimer) {
99
- clearInterval(this.pollTimer);
100
- this.pollTimer = null;
101
- }
102
- }
103
- baseUrl() {
104
- return this.config.server.replace(/\/$/, "");
105
- }
106
- sessionHeaders() {
107
- if (!this.sessionId) {
108
- throw new Error("Not connected");
109
- }
110
- return {
111
- [AGENT_SESSION_ID_HEADER]: this.sessionId,
112
- };
113
- }
114
- async connectWithRetry() {
115
- while (!this.stopped) {
116
- try {
117
- await this.connect();
118
- this.reconnectAttempts = 0;
119
- this.heartbeatFailures = 0;
120
- return;
121
- }
122
- catch (error) {
123
- const delay = this.nextReconnectDelay();
124
- this.warn(`connect failed, retrying in ${delay}ms`, error);
125
- await sleep(delay);
126
- }
127
- }
128
- }
129
- async connect() {
130
- const headers = {
131
- "Content-Type": "application/json",
132
- [AGENT_CLIENT_ID_HEADER]: this.config.clientId,
133
- };
134
- if (this.config.token) {
135
- headers.Authorization = `Bearer ${this.config.token}`;
136
- }
137
- const response = await this.fetchWithTimeout(`${this.baseUrl()}/connect`, {
138
- method: "POST",
139
- headers,
140
- body: JSON.stringify({
141
- capabilities: {
142
- maxConcurrent: this.config.maxConcurrent,
143
- models: this.availableModels,
144
- },
145
- }),
146
- });
147
- if (!response.ok) {
148
- const body = await response.text();
149
- throw new Error(`Connect failed (${response.status}): ${body}`);
150
- }
151
- const data = (await response.json());
152
- this.sessionId =
153
- response.headers.get(AGENT_SESSION_ID_HEADER) ?? data.sessionId ?? null;
154
- this.heartbeatIntervalSec = data.heartbeatIntervalSec ?? 15;
155
- this.connected = true;
156
- }
157
- async disconnect() {
158
- const sessionId = this.sessionId;
159
- if (!sessionId) {
160
- return;
161
- }
162
- try {
163
- await this.fetchWithTimeout(`${this.baseUrl()}/disconnect`, {
164
- method: "DELETE",
165
- headers: this.sessionHeaders(),
166
- });
167
- }
168
- catch (error) {
169
- this.warn("disconnect failed", error);
170
- }
171
- finally {
172
- this.sessionId = null;
173
- this.connected = false;
174
- }
175
- }
176
- startHeartbeat() {
177
- void this.sendHeartbeat();
178
- this.heartbeatTimer = setInterval(() => {
179
- void this.sendHeartbeat();
180
- }, this.heartbeatIntervalSec * 1000);
181
- }
182
- async sendHeartbeat() {
183
- if (this.stopped || !this.sessionId) {
184
- return;
185
- }
186
- try {
187
- const response = await this.fetchWithTimeout(`${this.baseUrl()}/heartbeat`, {
188
- method: "POST",
189
- headers: {
190
- ...this.sessionHeaders(),
191
- "Content-Type": "application/json",
192
- },
193
- body: JSON.stringify({
194
- runningCount: this.activeTasks.size,
195
- models: this.availableModels,
196
- }),
197
- });
198
- if (response.status === 404) {
199
- this.warn("session expired, reconnecting");
200
- this.scheduleReconnect();
201
- return;
202
- }
203
- if (!response.ok && response.status !== 204) {
204
- throw new Error(`Heartbeat failed (${response.status}): ${await response.text()}`);
205
- }
206
- this.heartbeatFailures = 0;
207
- }
208
- catch (error) {
209
- this.heartbeatFailures++;
210
- this.warn(`heartbeat failed (${this.heartbeatFailures})`, error);
211
- if (this.heartbeatFailures >= HEARTBEAT_FAILURES_BEFORE_RECONNECT) {
212
- this.scheduleReconnect();
213
- }
214
- }
215
- }
216
- openSse() {
217
- if (this.stopped || !this.sessionId) {
218
- return;
219
- }
220
- this.closeSse();
221
- const source = new EventSource(`${this.baseUrl()}/events`, {
222
- headers: this.sessionHeaders(),
223
- });
224
- this.eventSource = source;
225
- source.onopen = () => {
226
- this.debug("SSE connected");
227
- };
228
- source.addEventListener("newTask", () => {
229
- void this.tryPullAndRunTasks();
230
- });
231
- source.addEventListener("cancelled", (event) => {
232
- void this.handleCancelledEvent(event.data);
233
- });
234
- source.onerror = () => {
235
- if (this.stopped) {
236
- return;
237
- }
238
- this.warn("SSE disconnected, reconnecting");
239
- this.closeSse();
240
- this.scheduleReconnect();
241
- };
242
- }
243
- closeSse() {
244
- if (this.eventSource) {
245
- this.eventSource.close();
246
- this.eventSource = null;
247
- }
248
- }
249
- startTaskPoller() {
250
- void this.tryPullAndRunTasks();
251
- this.pollTimer = setInterval(() => {
252
- void this.tryPullAndRunTasks();
253
- }, TASK_POLL_INTERVAL_MS);
254
- }
255
- scheduleReconnect() {
256
- if (this.stopped || this.reconnecting || this.reconnectTimer) {
257
- return;
258
- }
259
- const delay = this.nextReconnectDelay();
260
- this.reconnectTimer = setTimeout(() => {
261
- this.reconnectTimer = null;
262
- void this.reconnect();
263
- }, delay);
264
- this.debug(`scheduled reconnect in ${delay}ms`);
265
- }
266
- async reconnect() {
267
- if (this.stopped || this.reconnecting) {
268
- return;
269
- }
270
- this.reconnecting = true;
271
- this.connected = false;
272
- this.clearConnectionTimers();
273
- this.closeSse();
274
- this.sessionId = null;
275
- try {
276
- await this.connectWithRetry();
277
- if (this.stopped) {
278
- return;
279
- }
280
- this.startHeartbeat();
281
- this.openSse();
282
- this.startTaskPoller();
283
- this.info("reconnected");
284
- }
285
- finally {
286
- this.reconnecting = false;
287
- }
288
- }
289
- nextReconnectDelay() {
290
- const min = Math.max(250, this.config.reconnectMinDelayMs);
291
- const max = Math.max(min, this.config.reconnectMaxDelayMs);
292
- const exponential = Math.min(max, min * 2 ** this.reconnectAttempts);
293
- this.reconnectAttempts++;
294
- const jitter = Math.floor(exponential * 0.2 * Math.random());
295
- return Math.min(max, exponential + jitter);
296
- }
297
- async tryPullAndRunTasks() {
298
- if (this.stopped || this.pulling || !this.sessionId || this.reconnecting) {
299
- return;
300
- }
301
- this.pulling = true;
302
- try {
303
- while (!this.stopped && this.connected && this.activeTasks.size < this.config.maxConcurrent) {
304
- const task = await this.pullNextTask();
305
- if (!task) {
306
- break;
307
- }
308
- await this.reportTaskReceived(task);
309
- void this.executeTask(task).finally(() => {
310
- void this.tryPullAndRunTasks();
311
- });
312
- }
313
- }
314
- catch (error) {
315
- this.warn("task polling failed", error);
316
- this.scheduleReconnect();
317
- }
318
- finally {
319
- this.pulling = false;
320
- }
321
- }
322
- async pullNextTask() {
323
- if (!this.sessionId) {
324
- return null;
325
- }
326
- const response = await this.fetchWithTimeout(`${this.baseUrl()}/tasks/next`, {
327
- method: "GET",
328
- headers: this.sessionHeaders(),
329
- });
330
- if (response.status === 204) {
331
- return null;
332
- }
333
- if (response.status === 404) {
334
- this.scheduleReconnect();
335
- return null;
336
- }
337
- if (!response.ok) {
338
- const body = await response.text();
339
- throw new Error(`Pull task failed (${response.status}): ${body}`);
340
- }
341
- return (await response.json());
342
- }
343
- async executeTask(task) {
344
- const active = {
345
- task,
346
- startedAt: Date.now(),
347
- cancelRequested: false,
348
- };
349
- this.activeTasks.set(task.taskId, active);
350
- try {
351
- const cwd = this.ensureCwd(task.cwd);
352
- const model = task.model?.trim() || DEFAULT_MODEL;
353
- const cursorApiKey = task.cursorApiKey?.trim();
354
- this.info(`task started taskId=${task.taskId} cwd=${cwd} model=${model}`);
355
- if (!cursorApiKey) {
356
- throw new Error("Task is missing cursorApiKey");
357
- }
358
- await this.refreshAvailableModels(cursorApiKey);
359
- void this.sendHeartbeat();
360
- const agentOptions = {
361
- apiKey: cursorApiKey,
362
- model: { id: model },
363
- name: `Buildify ${task.taskId}`,
364
- local: {
365
- cwd,
366
- settingSources: [],
367
- },
368
- };
369
- if (task.mcpServers && Object.keys(task.mcpServers).length > 0) {
370
- agentOptions.mcpServers = task.mcpServers;
371
- }
372
- const agent = await Agent.create(agentOptions);
373
- active.agent = agent;
374
- if (active.cancelRequested) {
375
- await this.reportCancelled(active, "cancelled before run started");
376
- return;
377
- }
378
- const run = await agent.send(task.prompt, {
379
- model: { id: model },
380
- idempotencyKey: task.taskId,
381
- });
382
- active.run = run;
383
- this.info(`task run created taskId=${task.taskId} agentId=${run.agentId} runId=${run.id}`);
384
- await this.reportTaskEvent(task.taskId, {
385
- eventType: "progress",
386
- runId: run.id,
387
- agentId: run.agentId,
388
- phase: "started",
389
- summary: "Cursor run started",
390
- });
391
- if (active.cancelRequested) {
392
- await this.cancelRun(active);
393
- }
394
- const streamPromise = this.consumeRunStream(active).catch((error) => {
395
- this.warn(`stream failed taskId=${task.taskId}`, error);
396
- });
397
- const result = await run.wait();
398
- await streamPromise;
399
- const durationMs = Date.now() - active.startedAt;
400
- if (active.cancelRequested || result.status === "cancelled") {
401
- await this.reportTaskEvent(task.taskId, {
402
- eventType: "cancelled",
403
- runId: run.id,
404
- agentId: run.agentId,
405
- durationMs,
406
- summary: "Cursor run cancelled",
407
- });
408
- this.info(`task cancelled taskId=${task.taskId} durationMs=${durationMs}`);
409
- return;
410
- }
411
- if (result.status === "finished") {
412
- await this.reportTaskEvent(task.taskId, {
413
- eventType: "completed",
414
- result: result.result ?? undefined,
415
- runId: run.id,
416
- agentId: run.agentId,
417
- durationMs: result.durationMs ?? durationMs,
418
- });
419
- this.info(`task completed taskId=${task.taskId} durationMs=${durationMs}`);
420
- return;
421
- }
422
- await this.reportTaskEvent(task.taskId, {
423
- eventType: "failed",
424
- runId: run.id,
425
- agentId: run.agentId,
426
- errorCode: result.status,
427
- errorMessage: `Cursor run finished with status: ${result.status}`,
428
- durationMs: result.durationMs ?? durationMs,
429
- });
430
- this.warn(`task failed taskId=${task.taskId} status=${result.status}`);
431
- }
432
- catch (error) {
433
- await this.reportExecutionError(active, error);
434
- }
435
- finally {
436
- await this.disposeAgent(active);
437
- this.activeTasks.delete(task.taskId);
438
- void this.sendHeartbeat();
439
- }
440
- }
441
- async consumeRunStream(active) {
442
- const run = active.run;
443
- if (!run || !run.supports("stream")) {
444
- return;
445
- }
446
- let toolCallCount = 0;
447
- for await (const message of run.stream()) {
448
- if (active.cancelRequested) {
449
- continue;
450
- }
451
- const line = this.formatSdkMessage(message);
452
- if (line && (this.config.streamLogs || this.shouldLog("debug"))) {
453
- this.info(`task stream taskId=${active.task.taskId} ${line}`);
454
- }
455
- if (message.type === "tool_call") {
456
- toolCallCount++;
457
- if (message.status === "completed" || message.status === "error") {
458
- await this.reportTaskEvent(active.task.taskId, {
459
- eventType: "progress",
460
- runId: message.run_id,
461
- agentId: message.agent_id,
462
- phase: `tool:${message.status}`,
463
- summary: message.name,
464
- toolCallCount,
465
- });
466
- }
467
- }
468
- else if (message.type === "status") {
469
- await this.reportTaskEvent(active.task.taskId, {
470
- eventType: "progress",
471
- runId: message.run_id,
472
- agentId: message.agent_id,
473
- phase: message.status.toLowerCase(),
474
- summary: message.message,
475
- toolCallCount,
476
- });
477
- }
478
- }
479
- }
480
- async handleCancelledEvent(data) {
481
- const payload = this.parseCancelEvent(data);
482
- const taskId = payload.taskId;
483
- if (!taskId) {
484
- this.warn(`cancelled SSE missing taskId data=${data}`);
485
- return;
486
- }
487
- const active = this.activeTasks.get(taskId);
488
- if (!active) {
489
- this.debug(`cancelled SSE ignored for inactive taskId=${taskId}`);
490
- return;
491
- }
492
- active.cancelRequested = true;
493
- this.info(`cancel requested taskId=${taskId}`);
494
- await this.cancelRun(active);
495
- }
496
- async cancelRun(active) {
497
- const run = active.run;
498
- if (!run) {
499
- return;
500
- }
501
- if (!run.supports("cancel")) {
502
- this.warn(`run does not support cancel taskId=${active.task.taskId} reason=${run.unsupportedReason("cancel") ?? "unknown"}`);
503
- return;
504
- }
505
- await run.cancel();
506
- }
507
- async reportCancelled(active, summary) {
508
- await this.reportTaskEvent(active.task.taskId, {
509
- eventType: "cancelled",
510
- runId: active.run?.id,
511
- agentId: active.run?.agentId ?? active.agent?.agentId,
512
- durationMs: Date.now() - active.startedAt,
513
- summary,
514
- });
515
- }
516
- async reportExecutionError(active, error) {
517
- const durationMs = Date.now() - active.startedAt;
518
- const run = active.run;
519
- if (active.cancelRequested) {
520
- await this.reportCancelled(active, "Cursor run cancellation requested");
521
- return;
522
- }
523
- if (error instanceof CursorAgentError) {
524
- await this.reportTaskEvent(active.task.taskId, {
525
- eventType: "failed",
526
- runId: run?.id,
527
- agentId: run?.agentId ?? active.agent?.agentId,
528
- errorCode: "CURSOR_AGENT_ERROR",
529
- errorMessage: error.message,
530
- durationMs,
531
- });
532
- }
533
- else {
534
- await this.reportTaskEvent(active.task.taskId, {
535
- eventType: "failed",
536
- runId: run?.id,
537
- agentId: run?.agentId ?? active.agent?.agentId,
538
- errorCode: "INTERNAL_ERROR",
539
- errorMessage: error instanceof Error ? error.message : String(error),
540
- durationMs,
541
- });
542
- }
543
- this.error(`task error taskId=${active.task.taskId}`, error);
544
- }
545
- async refreshAvailableModels(apiKey) {
546
- try {
547
- const models = await Cursor.models.list({ apiKey });
548
- const next = models
549
- .map((model) => this.modelId(model))
550
- .filter((id) => Boolean(id))
551
- .filter((id, index, all) => all.indexOf(id) === index);
552
- if (next.length > 0) {
553
- this.availableModels = next;
554
- this.debug(`refreshed ${next.length} Cursor models`);
555
- }
556
- }
557
- catch (error) {
558
- this.warn("refresh Cursor models failed", error);
559
- }
560
- }
561
- modelId(model) {
562
- const id = model.id?.trim();
563
- return id || null;
564
- }
565
- async disposeAgent(active) {
566
- if (!active.agent) {
567
- return;
568
- }
569
- try {
570
- await active.agent[Symbol.asyncDispose]();
571
- }
572
- catch {
573
- active.agent.close();
574
- }
575
- }
576
- ensureCwd(taskCwd) {
577
- const raw = taskCwd?.trim() || this.config.defaultCwd;
578
- const resolved = path.resolve(raw);
579
- if (!fs.existsSync(resolved)) {
580
- fs.mkdirSync(resolved, { recursive: true });
581
- this.info(`created working directory cwd=${resolved}`);
582
- }
583
- else if (!fs.statSync(resolved).isDirectory()) {
584
- throw new Error(`Working directory path is not a directory: ${resolved}`);
585
- }
586
- return resolved;
587
- }
588
- async reportTaskReceived(task) {
589
- await this.reportTaskEvent(task.taskId, {
590
- eventType: "received",
591
- summary: "Task received by buildify-cursor-agent",
592
- });
593
- this.info(`task received taskId=${task.taskId}`);
594
- }
595
- async reportTaskEvent(taskId, payload) {
596
- for (let attempt = 1; attempt <= REPORT_RETRY_COUNT; attempt++) {
597
- if (!this.sessionId) {
598
- await sleep(this.nextShortRetryDelay(attempt));
599
- continue;
600
- }
601
- try {
602
- const response = await this.fetchWithTimeout(`${this.baseUrl()}/tasks/${taskId}/events`, {
603
- method: "POST",
604
- headers: {
605
- ...this.sessionHeaders(),
606
- "Content-Type": "application/json",
607
- },
608
- body: JSON.stringify(payload),
609
- });
610
- if (response.ok || response.status === 204) {
611
- return;
612
- }
613
- if (response.status === 404) {
614
- this.scheduleReconnect();
615
- }
616
- const body = await response.text();
617
- throw new Error(`Report task event failed (${response.status}): ${body}`);
618
- }
619
- catch (error) {
620
- if (attempt === REPORT_RETRY_COUNT) {
621
- throw error;
622
- }
623
- this.warn(`report event retry ${attempt}/${REPORT_RETRY_COUNT} taskId=${taskId}`, error);
624
- await sleep(this.nextShortRetryDelay(attempt));
625
- }
626
- }
627
- }
628
- async fetchWithTimeout(input, init) {
629
- const controller = new AbortController();
630
- const timer = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
631
- try {
632
- return await fetch(input, {
633
- ...init,
634
- signal: controller.signal,
635
- });
636
- }
637
- finally {
638
- clearTimeout(timer);
639
- }
640
- }
641
- nextShortRetryDelay(attempt) {
642
- return Math.min(5_000, 500 * 2 ** (attempt - 1));
643
- }
644
- parseCancelEvent(data) {
645
- try {
646
- return JSON.parse(data);
647
- }
648
- catch {
649
- return {};
650
- }
651
- }
652
- formatSdkMessage(message) {
653
- switch (message.type) {
654
- case "system":
655
- return `system agentId=${message.agent_id} runId=${message.run_id}`;
656
- case "status":
657
- return `status=${message.status}${message.message ? ` message=${message.message}` : ""}`;
658
- case "assistant": {
659
- const text = message.message.content
660
- .filter((block) => block.type === "text")
661
- .map((block) => block.text)
662
- .join("");
663
- return text ? `assistant=${truncate(text)}` : null;
664
- }
665
- case "thinking":
666
- return `thinking=${truncate(message.text)}`;
667
- case "tool_call":
668
- return `tool=${message.name} status=${message.status}`;
669
- case "task":
670
- return `task status=${message.status ?? ""} text=${truncate(message.text ?? "")}`;
671
- default:
672
- return `event=${message.type}`;
673
- }
674
- }
675
- shouldLog(level) {
676
- return LOG_LEVEL_WEIGHT[this.config.logLevel] >= LOG_LEVEL_WEIGHT[level];
677
- }
678
- log(method, message, error) {
679
- if (!this.shouldLog(method)) {
680
- return;
681
- }
682
- const line = `[${APP_NAME}] ${message}`;
683
- if (error === undefined) {
684
- console[method](line);
685
- }
686
- else {
687
- console[method](line, error);
688
- }
689
- }
690
- error(message, error) {
691
- this.log("error", message, error);
692
- }
693
- warn(message, error) {
694
- this.log("warn", message, error);
695
- }
696
- info(message, error) {
697
- this.log("info", message, error);
698
- }
699
- debug(message, error) {
700
- this.log("debug", message, error);
701
- }
702
- }
703
- export async function runAgent(config) {
704
- const runner = new AgentRunner(config);
705
- await runner.start();
706
- return runner;
707
- }
708
- function sleep(ms) {
709
- return new Promise((resolve) => setTimeout(resolve, ms));
710
- }
711
- function truncate(value, max = 500) {
712
- return value.length <= max ? value : `${value.slice(0, max)}...`;
713
- }
714
- //# sourceMappingURL=runner.js.map