@gotcos/glasses-server 6.16.0 → 6.16.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.
@@ -0,0 +1,726 @@
1
+ // Cursor Agent CLI bridge — ask or full agent (`--force` + `--sandbox disabled`).
2
+ // Fail-closed: never fall through to Claude.
3
+
4
+ import { spawn } from 'node:child_process'
5
+ import { logTokenAudit } from './token-audit.js'
6
+ import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
7
+ import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
8
+ import {
9
+ getHistory,
10
+ addExchange,
11
+ reconcileExchangeByJobIdentity,
12
+ setExchangeAttachments,
13
+ removeExchange,
14
+ formatHistoryForPrompt,
15
+ getOrCreateSession,
16
+ isNewSession,
17
+ markSessionNotified,
18
+ getSessionRaw,
19
+ replaceLastExchangeWithSummary,
20
+ type PromptReference,
21
+ } from './conversation.js'
22
+ import { notifySessionStart, notifyExchange } from './telegram-notify.js'
23
+ import {
24
+ clearCursorEngineSession,
25
+ getCursorEngineSession,
26
+ saveCursorEngineSession,
27
+ } from './cursor-engine-sessions.js'
28
+ import {
29
+ CURSOR_COMPOSER_MODEL,
30
+ normalizeCursorExecutionMode,
31
+ type CursorExecutionMode,
32
+ type CursorModelPreference,
33
+ } from '../../shared/model-preference.js'
34
+ import {
35
+ getCursorModelCatalog,
36
+ resolveAgentBinary,
37
+ resolveCursorModelOption,
38
+ } from './cursor-model-catalog.js'
39
+ import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
40
+ import {
41
+ classifyCursorError,
42
+ finishCursorRun,
43
+ getCursorExecutionCwd,
44
+ isCursorPersistenceEnabled,
45
+ startCursorRun,
46
+ updateCursorRun,
47
+ type CursorRunStatus,
48
+ } from './cursor-run-ledger.js'
49
+ import {
50
+ collectRunOutputImagesBounded,
51
+ createRunOutputImagePublisher,
52
+ type RunOutputImageCollectionStats,
53
+ } from './run-output-images.js'
54
+ import {
55
+ MAX_ATTACHMENTS_PER_PROMPT,
56
+ type MediaAttachmentRef,
57
+ } from '../../shared/media-attachment.js'
58
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
59
+
60
+ const INACTIVITY_MS = 180_000
61
+ const WALL_MAX_MS = 900_000
62
+ const HEARTBEAT_INTERVAL_MS = 6_000
63
+
64
+ type Phase = 'context' | 'thinking' | 'generating'
65
+
66
+ const PHASE_LABELS: Record<Phase, string> = {
67
+ context: 'Loading context...',
68
+ thinking: 'Reasoning...',
69
+ generating: 'Cursor drafting...',
70
+ }
71
+
72
+ export function buildCursorAgentArgs(input: {
73
+ workspace: string
74
+ modelId: string
75
+ executionMode?: CursorExecutionMode
76
+ resumeSessionId?: string
77
+ }): string[] {
78
+ const mode = normalizeCursorExecutionMode(input.executionMode)
79
+ const args = [
80
+ '-p',
81
+ '--model', input.modelId,
82
+ '--output-format', 'stream-json',
83
+ '--stream-partial-output',
84
+ '--trust',
85
+ '--workspace', input.workspace,
86
+ ]
87
+ if (mode === 'ask') {
88
+ args.splice(1, 0, '--mode', 'ask')
89
+ } else {
90
+ // Headless glasses need non-interactive tool approval + no sandbox hang.
91
+ args.push('--force', '--sandbox', 'disabled', '--approve-mcps')
92
+ }
93
+ if (input.resumeSessionId) {
94
+ args.push('--resume', input.resumeSessionId)
95
+ }
96
+ // Prompt is written to stdin AFTER durable provider ownership is confirmed.
97
+ return args
98
+ }
99
+
100
+ /** Map Cursor stream-json tool_call events to HUD status / activity lines. */
101
+ export function extractCursorToolActivity(event: any): {
102
+ status?: string
103
+ activity?: { kind: 'input' | 'output'; text: string }
104
+ isWrite: boolean
105
+ } {
106
+ if (event?.type !== 'tool_call') return { isWrite: false }
107
+ const toolCall = event?.tool_call && typeof event.tool_call === 'object' ? event.tool_call : null
108
+ if (!toolCall) return { isWrite: false }
109
+ const subtype = String(event?.subtype ?? '')
110
+
111
+ if (toolCall.editToolCall) {
112
+ const path = String(toolCall.editToolCall?.args?.path || toolCall.editToolCall?.result?.success?.path || '')
113
+ const base = path.split('/').pop() || 'file'
114
+ const writing = subtype === 'started' || subtype === 'completed'
115
+ return {
116
+ isWrite: writing,
117
+ status: writing ? 'Cursor editing files…' : undefined,
118
+ activity: {
119
+ kind: subtype === 'completed' ? 'output' : 'input',
120
+ text: `CURSOR · edit ${base}`,
121
+ },
122
+ }
123
+ }
124
+
125
+ const shell = toolCall.shellToolCall || toolCall.bashToolCall || toolCall.terminalToolCall
126
+ if (shell) {
127
+ const cmd = String(shell?.args?.command || shell?.args?.cmd || 'command').slice(0, 80)
128
+ return {
129
+ isWrite: true,
130
+ status: 'Cursor editing files…',
131
+ activity: {
132
+ kind: subtype === 'completed' ? 'output' : 'input',
133
+ text: `CURSOR · shell ${cmd}`,
134
+ },
135
+ }
136
+ }
137
+
138
+ const keys = Object.keys(toolCall).filter(k => k.endsWith('ToolCall') || k === 'name')
139
+ const name = keys[0]?.replace(/ToolCall$/, '') || 'tool'
140
+ return {
141
+ isWrite: false,
142
+ activity: {
143
+ kind: subtype === 'completed' ? 'output' : 'input',
144
+ text: `CURSOR · ${name}`,
145
+ },
146
+ }
147
+ }
148
+
149
+ function buildCursorPrompt(systemPrompt: string, fullQuery: string): string {
150
+ return [
151
+ 'SYSTEM INSTRUCTIONS',
152
+ systemPrompt,
153
+ '',
154
+ 'USER REQUEST',
155
+ fullQuery,
156
+ ].join('\n')
157
+ }
158
+
159
+ /**
160
+ * Extract assistant delta text only when `timestamp_ms` is present.
161
+ * Thinking events and the final assistant flush (no timestamp_ms) are skipped.
162
+ */
163
+ export function extractCursorResponseText(event: any): string {
164
+ const type = String(event?.type ?? '').toLowerCase()
165
+ if (type !== 'assistant') return ''
166
+ if (typeof event?.timestamp_ms !== 'number') return ''
167
+ if (event?.model_call_id != null && event.model_call_id !== '') return ''
168
+
169
+ const content = event?.message?.content
170
+ if (Array.isArray(content)) {
171
+ let text = ''
172
+ for (const block of content) {
173
+ if (typeof block === 'string') text += block
174
+ else if (typeof block?.text === 'string') text += block.text
175
+ }
176
+ return text
177
+ }
178
+ if (typeof event?.text === 'string') return event.text
179
+ return ''
180
+ }
181
+
182
+ export function extractCursorSessionId(event: any): string | undefined {
183
+ const candidate = event?.session_id ?? event?.sessionId
184
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined
185
+ }
186
+
187
+ function safeCursorUserError(message: string): string {
188
+ const code = classifyCursorError(message)
189
+ if (code === 'cursor.cli_unavailable') return 'Cursor CLI unavailable. Check server Settings.'
190
+ if (code === 'cursor.auth_error') return 'Cursor auth failed. Run agent login on the Mac.'
191
+ if (code === 'cursor.timeout') return 'Cursor timed out. Retry or start a new chat.'
192
+ if (code === 'cursor.permission_denied') return 'Cursor permission failed.'
193
+ return `Cursor failed (${code}). Retry or check CLI Debug.`
194
+ }
195
+
196
+ export async function callCursorStreaming(
197
+ query: string,
198
+ sessionId: string | undefined,
199
+ callbacks: StreamCallbacks,
200
+ model: CursorModelPreference = CURSOR_COMPOSER_MODEL,
201
+ images?: ModelImageInput[],
202
+ reference?: PromptReference,
203
+ globalMsgNum?: number,
204
+ options?: CallOptions,
205
+ ): Promise<string> {
206
+ const sid = getOrCreateSession(sessionId)
207
+ const history = getHistory(sid)
208
+ const session = getSessionRaw(sid)
209
+ const contextBreaks = session?.contextBreaks ?? []
210
+ const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
211
+ const handoffPrompt = options?.handoffContext?.promptBlock ? `\n\n${options.handoffContext.promptBlock}` : ''
212
+ const contextPrompt = `${historyPrompt}${handoffPrompt}`
213
+ const persistentCursorSession = isCursorPersistenceEnabled()
214
+ const cursorCwd = getCursorExecutionCwd()
215
+ const executionMode = normalizeCursorExecutionMode(options?.cursorExecutionMode)
216
+
217
+ await getCursorModelCatalog()
218
+ const resolvedOption = resolveCursorModelOption(model)
219
+ const agentBinary = resolveAgentBinary()
220
+ if (!agentBinary || !resolvedOption?.id) {
221
+ const msg = !agentBinary
222
+ ? 'cursor-bridge: Cursor agent binary not found on PATH or ~/.local/bin/agent.'
223
+ : `cursor-bridge: Cursor model slot ${model} is not resolved.`
224
+ await callbacks.onError(safeCursorUserError(msg))
225
+ return sid
226
+ }
227
+
228
+ const engineSession = persistentCursorSession
229
+ ? getCursorEngineSession({ cosSessionId: sid, model, cwd: cursorCwd, executionMode })
230
+ : null
231
+
232
+ // Inbound phone photos are not yet wired into Cursor CLI args (Phase 1.5).
233
+ // Outbound run-scoped images still use the Release C publisher so traffic
234
+ // frames and other agent visuals can attach to the assistant message.
235
+ const imageInputs: ModelImageInput[] = images ?? []
236
+ const imagePaths: string[] = imageInputs.map(i => i.path)
237
+ const outputImageBudget = Math.max(0, MAX_ATTACHMENTS_PER_PROMPT - imageInputs.length)
238
+ const startTime = Date.now()
239
+ const run = startCursorRun({
240
+ turnId: options?.turnId,
241
+ clientJobId: options?.clientJobId,
242
+ cosSessionId: sid,
243
+ model,
244
+ cwd: cursorCwd,
245
+ resumed: !!engineSession,
246
+ cursorChatId: engineSession?.cursorSessionId,
247
+ expiresAt: engineSession?.expiresAt,
248
+ cliModel: resolvedOption.id,
249
+ query,
250
+ messageEra: options?.messageEra,
251
+ globalMsgNum: options?.globalMsgNum ?? globalMsgNum,
252
+ })
253
+ let outputImagePublisher: ReturnType<typeof createRunOutputImagePublisher> | null = null
254
+ if (!options?.lightweight && outputImageBudget > 0) {
255
+ try {
256
+ outputImagePublisher = createRunOutputImagePublisher({
257
+ sessionId: sid,
258
+ globalMsgNum,
259
+ runId: run.runId,
260
+ maxImages: outputImageBudget,
261
+ })
262
+ } catch (err) {
263
+ console.error('[cursor-bridge] output image publisher unavailable:', err)
264
+ }
265
+ }
266
+
267
+ let cursorChatId: string | undefined = engineSession?.cursorSessionId
268
+ callbacks.onStart?.(model, sid, undefined, {
269
+ cursorRunId: run.runId,
270
+ cursorChatId,
271
+ })
272
+
273
+ let phase: Phase = 'context'
274
+ let systemPrompt: string
275
+ try {
276
+ if (options?.lightweight) {
277
+ systemPrompt = buildLightweightSystemPrompt(query, contextPrompt)
278
+ } else {
279
+ callbacks.onToolStatus?.('Loading context...')
280
+ systemPrompt = await buildSystemPrompt(contextPrompt)
281
+ }
282
+ if (executionMode === 'agent') {
283
+ systemPrompt = `${systemPrompt}\n\nCURSOR AGENT MODE: You run in the user's selected local workspace via Cursor Agent. Prefer surgical edits. File and shell tools are allowed. Announce destructive operations briefly in your reply.`
284
+ }
285
+ if (outputImagePublisher) {
286
+ systemPrompt = `${systemPrompt}\n\n${outputImagePublisher.promptInstructions}`
287
+ }
288
+ } catch (err: any) {
289
+ outputImagePublisher?.cleanup()
290
+ finishCursorRun(run.runId, {
291
+ status: 'failed',
292
+ startedAtMs: startTime,
293
+ error: `cursor-bridge: context build failed — ${err?.message ?? 'unknown error'}`,
294
+ exitCode: null,
295
+ })
296
+ cleanupModelImageInputs(imageInputs)
297
+ throw err
298
+ }
299
+
300
+ phase = 'thinking'
301
+ callbacks.onToolStatus?.('Reasoning...')
302
+
303
+ const isFirstQuery = isNewSession(sid)
304
+ const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
305
+ const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
306
+ const jobGeneration = options?.jobGeneration ?? options?.generation
307
+ const durableIdentity = options?.clientJobId && Number.isSafeInteger(jobGeneration) && jobGeneration! > 0
308
+ ? { clientJobId: options.clientJobId, generation: jobGeneration! } : undefined
309
+ const inboundAttachments = imageInputs.length > 0 ? imageInputs.map(i => i.attachment) : undefined
310
+ const pendingUserExchange = durableIdentity
311
+ ? reconcileExchangeByJobIdentity(
312
+ sid, durableIdentity, 'user', historyQuery, globalMsgNum, inboundAttachments,
313
+ ).exchange
314
+ : addExchange(
315
+ sid, 'user', historyQuery, globalMsgNum, inboundAttachments, durableIdentity,
316
+ )
317
+
318
+ let fullQuery = query
319
+ if (imagePaths.length > 0) {
320
+ fullQuery = `${query || 'Describe what you see.'}\n\n(Note: glasses photo attachments are not wired for Cursor ask-mode yet.)`
321
+ }
322
+
323
+ const prompt = buildCursorPrompt(systemPrompt, fullQuery)
324
+ const args = buildCursorAgentArgs({
325
+ workspace: cursorCwd,
326
+ modelId: resolvedOption.id,
327
+ executionMode,
328
+ resumeSessionId: engineSession?.cursorSessionId,
329
+ })
330
+
331
+ const env = { ...process.env }
332
+ delete env.CLAUDECODE
333
+ if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
334
+
335
+ const proc = spawn(agentBinary, args, {
336
+ stdio: ['pipe', 'pipe', 'pipe'],
337
+ env,
338
+ cwd: cursorCwd,
339
+ })
340
+
341
+ let fullText = ''
342
+ let stderr = ''
343
+ let buffer = ''
344
+ let finalized = false
345
+ let terminalTextError: string | null = null
346
+ let resultText: string | null = null
347
+ let sawCursorWrite = false
348
+
349
+ function cleanupImages() {
350
+ cleanupModelImageInputs(imageInputs)
351
+ }
352
+
353
+ function cleanup() {
354
+ clearInterval(heartbeat)
355
+ clearTimeout(inactivityTimer)
356
+ clearTimeout(wallTimer)
357
+ options?.abortSignal?.removeEventListener('abort', handleAbort)
358
+ }
359
+
360
+ function clearEngineSessionBestEffort(reason: string) {
361
+ if (!engineSession && !cursorChatId) return
362
+ try {
363
+ clearCursorEngineSession(sid, model)
364
+ } catch (error) {
365
+ console.error(`[cursor-bridge] engine session clear failed (${reason}):`, error)
366
+ }
367
+ }
368
+
369
+ function saveEngineSessionBestEffort() {
370
+ if (!persistentCursorSession || !cursorChatId) return
371
+ try {
372
+ const saved = saveCursorEngineSession({
373
+ cosSessionId: sid,
374
+ model,
375
+ cursorSessionId: cursorChatId,
376
+ cwd: cursorCwd,
377
+ executionMode,
378
+ })
379
+ updateCursorRun(run.runId, { cursorChatId, expiresAt: saved.expiresAt })
380
+ } catch (error) {
381
+ console.error('[cursor-bridge] engine session save failed:', error)
382
+ }
383
+ }
384
+
385
+ function finishRunBestEffort(input: Parameters<typeof finishCursorRun>[1]) {
386
+ try {
387
+ finishCursorRun(run.runId, input)
388
+ } catch (error) {
389
+ console.error('[cursor-bridge] run ledger finalization failed:', error)
390
+ }
391
+ }
392
+
393
+ function emitText(text: string) {
394
+ if (!text) return
395
+ phase = 'generating'
396
+ fullText += text
397
+ callbacks.onChunk(text)
398
+ }
399
+
400
+ async function finalize(text: string) {
401
+ if (finalized) return
402
+ const responseAuthenticationError = terminalProviderAuthFailure('cursor', text)
403
+ const authenticationError = responseAuthenticationError
404
+ ?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('cursor', stderr) : null)
405
+ if (authenticationError) {
406
+ await finalizeError(authenticationError, 0)
407
+ return
408
+ }
409
+ if (!text.trim()) {
410
+ await finalizeError('cursor-bridge: Cursor completed without a response.', 0)
411
+ return
412
+ }
413
+ finalized = true
414
+ cleanup()
415
+ cleanupImages()
416
+
417
+ try {
418
+ const answerOwned = await callbacks.onAnswerReady?.(text)
419
+ if (answerOwned === false) {
420
+ removeExchange(sid, pendingUserExchange)
421
+ clearEngineSessionBestEffort('answer_ownership_lost')
422
+ finishRunBestEffort({
423
+ status: 'failed',
424
+ startedAtMs: startTime,
425
+ error: 'cursor-bridge: durable answer ownership was lost.',
426
+ exitCode: null,
427
+ })
428
+ return
429
+ }
430
+ } catch (error) {
431
+ console.error('[cursor-bridge] durable answer barrier failed:', error)
432
+ removeExchange(sid, pendingUserExchange)
433
+ clearEngineSessionBestEffort('answer_barrier')
434
+ finishRunBestEffort({
435
+ status: 'failed',
436
+ startedAtMs: startTime,
437
+ error: 'cursor-bridge: durable answer persistence failed.',
438
+ exitCode: null,
439
+ })
440
+ try {
441
+ await callbacks.onError('cursor-bridge: durable answer persistence failed.')
442
+ } catch (callbackError) {
443
+ console.error('[cursor-bridge] durable barrier error callback failed:', callbackError)
444
+ }
445
+ return
446
+ }
447
+
448
+ const assistantExchange = durableIdentity
449
+ ? reconcileExchangeByJobIdentity(sid, durableIdentity, 'assistant', text, globalMsgNum).exchange
450
+ : addExchange(sid, 'assistant', text, globalMsgNum, undefined, durableIdentity)
451
+ if (imagePaths.length > 0) {
452
+ replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
453
+ }
454
+
455
+ let outputAttachments: MediaAttachmentRef[] = []
456
+ let outputImageStats: RunOutputImageCollectionStats | undefined
457
+ if (outputImagePublisher) {
458
+ callbacks.onToolStatus?.('Preparing images...')
459
+ const preparingHeartbeat = setInterval(() => {
460
+ callbacks.onToolStatus?.('Preparing images...')
461
+ }, HEARTBEAT_INTERVAL_MS)
462
+ preparingHeartbeat.unref?.()
463
+ try {
464
+ outputAttachments = await collectRunOutputImagesBounded(outputImagePublisher, {
465
+ signal: options?.abortSignal,
466
+ })
467
+ } catch (err) {
468
+ console.error('[cursor-bridge] output image collection failed:', err)
469
+ } finally {
470
+ clearInterval(preparingHeartbeat)
471
+ outputImageStats = outputImagePublisher.stats
472
+ outputImagePublisher.cleanup()
473
+ }
474
+ if (outputAttachments.length > 0) {
475
+ setExchangeAttachments(sid, assistantExchange, outputAttachments)
476
+ }
477
+ if (outputImageStats && outputImageStats.rejected > 0) {
478
+ callbacks.onToolStatus?.(outputImageStats.attached > 0
479
+ ? 'Some images could not be attached'
480
+ : 'Image attachment unavailable')
481
+ }
482
+ }
483
+
484
+ const totalMs = Date.now() - startTime
485
+ logTokenAudit({
486
+ source: options?.lightweight ? 'g2-voice' : 'g2-query',
487
+ model,
488
+ inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
489
+ outputChars: text.length,
490
+ durationMs: totalMs,
491
+ caller: options?.lightweight ? 'voice_query' : 'full_query',
492
+ turnId: options?.turnId,
493
+ runId: run.runId,
494
+ sessionId: sid,
495
+ clientJobId: options?.clientJobId,
496
+ usageKind: 'estimated',
497
+ })
498
+ saveEngineSessionBestEffort()
499
+
500
+ finishRunBestEffort({
501
+ status: 'completed',
502
+ startedAtMs: startTime,
503
+ output: text,
504
+ exitCode: 0,
505
+ })
506
+ try {
507
+ const terminalOwned = await callbacks.onDone(text, model, undefined, {
508
+ cursorRunId: run.runId,
509
+ cursorChatId,
510
+ turnId: options?.turnId,
511
+ clientJobId: options?.clientJobId,
512
+ ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
513
+ ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
514
+ })
515
+ if (terminalOwned === false) return
516
+ } catch (error) {
517
+ console.error('[cursor-bridge] terminal completion callback failed:', error)
518
+ }
519
+
520
+ if (isFirstQuery) {
521
+ notifySessionStart(sid, query)
522
+ markSessionNotified(sid)
523
+ }
524
+ notifyExchange(sid, query, text)
525
+ }
526
+
527
+ async function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CursorRunStatus, 'running'> = 'failed') {
528
+ if (finalized) return
529
+ finalized = true
530
+ cleanup()
531
+ cleanupImages()
532
+ outputImagePublisher?.cleanup()
533
+ removeExchange(sid, pendingUserExchange)
534
+ clearEngineSessionBestEffort('provider_error')
535
+ finishRunBestEffort({
536
+ status,
537
+ startedAtMs: startTime,
538
+ error: msg,
539
+ exitCode,
540
+ })
541
+ try {
542
+ await callbacks.onError(safeCursorUserError(msg))
543
+ } catch (error) {
544
+ console.error('[cursor-bridge] terminal error callback failed:', error)
545
+ }
546
+ }
547
+
548
+ function handleAbort() {
549
+ if (finalized) return
550
+ proc.kill('SIGTERM')
551
+ finalizeError('cursor-bridge: client disconnected before Cursor completed.', null, 'client_disconnected')
552
+ }
553
+
554
+ const heartbeat = setInterval(() => {
555
+ if (finalized) return
556
+ callbacks.onToolStatus?.(PHASE_LABELS[phase] ?? 'Processing...')
557
+ }, HEARTBEAT_INTERVAL_MS)
558
+
559
+ let inactivityTimer = setTimeout(() => {
560
+ proc.kill('SIGTERM')
561
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
562
+ finalizeError(`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Cursor process killed.`)
563
+ }, INACTIVITY_MS)
564
+
565
+ function resetInactivity() {
566
+ clearTimeout(inactivityTimer)
567
+ inactivityTimer = setTimeout(() => {
568
+ proc.kill('SIGTERM')
569
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
570
+ finalizeError(`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Cursor process killed.`)
571
+ }, INACTIVITY_MS)
572
+ }
573
+
574
+ const wallTimer = setTimeout(() => {
575
+ proc.kill('SIGTERM')
576
+ if (fullText || resultText) {
577
+ void finalize(resultText || fullText)
578
+ } else {
579
+ finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Cursor process killed.`)
580
+ }
581
+ }, WALL_MAX_MS)
582
+
583
+ function handleEvent(event: any) {
584
+ const nextSessionId = extractCursorSessionId(event)
585
+ if (nextSessionId && nextSessionId !== cursorChatId) {
586
+ cursorChatId = nextSessionId
587
+ updateCursorRun(run.runId, { cursorChatId })
588
+ }
589
+
590
+ if (event?.type === 'system' && event?.subtype === 'init') {
591
+ callbacks.onToolStatus?.(
592
+ executionMode === 'agent' ? 'Starting Cursor Agent...' : 'Starting Cursor Ask...',
593
+ )
594
+ }
595
+
596
+ const toolActivity = extractCursorToolActivity(event)
597
+ if (toolActivity.isWrite) sawCursorWrite = true
598
+ if (toolActivity.status) callbacks.onToolStatus?.(toolActivity.status)
599
+ else if (sawCursorWrite && event?.type === 'assistant') {
600
+ // Keep write flag visible over generic drafting status.
601
+ callbacks.onToolStatus?.('Cursor editing files…')
602
+ }
603
+ if (toolActivity.activity) callbacks.onActivityLine?.(toolActivity.activity)
604
+
605
+ const text = extractCursorResponseText(event)
606
+ if (text) {
607
+ const authenticationError = terminalProviderAuthFailure('cursor', text)
608
+ if (authenticationError) terminalTextError = authenticationError
609
+ else emitText(text)
610
+ }
611
+
612
+ if (event?.type === 'result') {
613
+ if (event?.subtype === 'success' && event?.is_error !== true) {
614
+ const result = typeof event?.result === 'string' ? event.result : fullText
615
+ resultText = result
616
+ void finalize(result || fullText)
617
+ } else {
618
+ const raw = typeof event?.result === 'string' ? event.result
619
+ : typeof event?.error === 'string' ? event.error
620
+ : 'unknown error'
621
+ const authenticationError = terminalProviderAuthFailure('cursor', raw)
622
+ finalizeError(authenticationError ?? `cursor-bridge: ${raw}`)
623
+ }
624
+ }
625
+ }
626
+
627
+ proc.stdout.on('data', (chunk: Buffer) => {
628
+ resetInactivity()
629
+ buffer += chunk.toString()
630
+ const lines = buffer.split('\n')
631
+ buffer = lines.pop() ?? ''
632
+
633
+ for (const line of lines) {
634
+ const trimmed = line.trim()
635
+ if (!trimmed) continue
636
+ try {
637
+ handleEvent(JSON.parse(trimmed))
638
+ } catch {
639
+ terminalTextError ??= terminalProviderAuthFailure('cursor', trimmed)
640
+ }
641
+ }
642
+ })
643
+
644
+ proc.stderr.on('data', (chunk: Buffer) => {
645
+ resetInactivity()
646
+ stderr += chunk.toString()
647
+ })
648
+
649
+ proc.on('close', (code) => {
650
+ if (buffer.trim()) {
651
+ try {
652
+ handleEvent(JSON.parse(buffer.trim()))
653
+ } catch {
654
+ terminalTextError ??= terminalProviderAuthFailure('cursor', buffer.trim())
655
+ }
656
+ }
657
+ if (finalized) return
658
+ if (resultText || fullText) {
659
+ void finalize(resultText || fullText)
660
+ return
661
+ }
662
+ const authenticationError = terminalTextError
663
+ ?? terminalProviderAuthFailure('cursor', stderr, buffer)
664
+ if (authenticationError) {
665
+ finalizeError(authenticationError, code)
666
+ return
667
+ }
668
+ if (code !== 0) {
669
+ finalizeError(`cursor-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
670
+ } else {
671
+ finalizeError('cursor-bridge: Cursor completed without a response.')
672
+ }
673
+ })
674
+
675
+ proc.on('error', (err) => {
676
+ finalizeError(`cursor-bridge: ${err.message}`)
677
+ })
678
+ proc.stdin.on('error', (err) => {
679
+ finalizeError(`cursor-bridge: stdin failed — ${err.message}`)
680
+ })
681
+
682
+ if (options?.abortSignal) {
683
+ if (options.abortSignal.aborted) {
684
+ handleAbort()
685
+ return sid
686
+ }
687
+ options.abortSignal.addEventListener('abort', handleAbort, { once: true })
688
+ }
689
+
690
+ try {
691
+ const providerOwned = await callbacks.onProviderProcess?.({
692
+ provider: 'cursor',
693
+ runId: run.runId,
694
+ pid: proc.pid,
695
+ clientJobId: options?.clientJobId,
696
+ generation: options?.jobGeneration ?? options?.generation,
697
+ })
698
+ if (providerOwned === false) {
699
+ proc.kill('SIGTERM')
700
+ finalized = true
701
+ cleanup()
702
+ cleanupImages()
703
+ outputImagePublisher?.cleanup()
704
+ removeExchange(sid, pendingUserExchange)
705
+ clearEngineSessionBestEffort('provider_ownership_lost')
706
+ finishRunBestEffort({
707
+ status: 'failed',
708
+ startedAtMs: startTime,
709
+ error: 'cursor-bridge: durable provider ownership was lost.',
710
+ exitCode: null,
711
+ })
712
+ return sid
713
+ }
714
+ if (finalized) return sid
715
+ proc.stdin.write(prompt)
716
+ proc.stdin.end()
717
+ } catch (err) {
718
+ const message = err instanceof Error ? err.message : String(err)
719
+ if (!finalized) {
720
+ proc.kill('SIGTERM')
721
+ await finalizeError(`cursor-bridge: provider start failed — ${message}`)
722
+ }
723
+ }
724
+
725
+ return sid
726
+ }