@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.
- package/CHANGELOG.md +19 -0
- package/README.md +29 -9
- package/bin/cli.cjs +71 -5
- package/package.json +8 -3
- package/server/lib/claude-bridge.ts +14 -1
- package/server/lib/cli-debug-view.ts +31 -4
- package/server/lib/cursor-bridge.ts +726 -0
- package/server/lib/cursor-engine-sessions.ts +162 -0
- package/server/lib/cursor-model-catalog.ts +288 -0
- package/server/lib/cursor-run-ledger.ts +300 -0
- package/server/lib/hallucination-filter.ts +1 -1
- package/server/lib/model-router.ts +26 -1
- package/server/lib/provider-terminal-error.ts +1 -1
- package/server/lib/query-job-coordinator.ts +1 -0
- package/server/lib/query-job-runtime.ts +8 -2
- package/server/lib/query-job-store.ts +11 -3
- package/server/lib/query-job-types.ts +2 -1
- package/server/lib/speaker-trainer.ts +1 -1
- package/server/lib/token-audit.ts +11 -1
- package/server/models/silero_vad.onnx +0 -0
- package/server/routes/cli-debug.ts +8 -0
- package/server/routes/health.ts +56 -4
- package/server/routes/openai-compat.ts +28 -4
- package/shared/model-preference.ts +50 -2
|
@@ -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
|
+
}
|