import { createHash } from "node:crypto"; import { copyFileSync, existsSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { AssistantMessageEventStream } from "@earendil-works/pi-ai"; import type { AgentModeOption, InteractionUpdate } from "@cursor/sdk"; import type { CursorPiToolBridgeDiagnosticEvent } from "./pi-tool-bridge-diagnostics.js"; import { serializeCursorPiToolBridgeDiagnostic } from "./pi-tool-bridge-diagnostics.js"; import type { CursorPiBridgeToolRequest } from "./pi-tool-bridge-types.js"; import type { CursorLiveQueuedEvent } from "./live-run-coordinator.js"; import { loadCursorSdkUserConfig, type CursorSdkConfig } from "./config.js"; import { asRecord } from "./record-utils.js"; import { getCursorSessionFile } from "./session-scope.js"; import { ARTIFACTS, CURSOR_SDK_EVENT_DEBUG_LOG_PREFIX, SESSION_MANIFEST, SESSION_PI_SESSION_SNAPSHOT, } from "./sdk-event-debug-constants.js"; import { allocateCursorSdkEventDebugTurn, resetCursorSdkEventDebugSessionStateForTests, slugSessionKey, updateCursorSdkEventDebugSessionManifest, type CursorSdkEventDebugTurnAllocation, } from "./sdk-event-debug-session.js"; export { CURSOR_SDK_EVENT_DEBUG_INTERNAL_ENV_NAMES, CURSOR_SDK_EVENT_DEBUG_INTERNAL_RUN_DIR_ENV, CURSOR_SDK_EVENT_DEBUG_INTERNAL_SESSION_DIR_ENV, CURSOR_SDK_EVENT_DEBUG_LOG_PREFIX, CURSOR_SDK_EVENT_DEBUG_RUN_DIR_ENV, CURSOR_SDK_EVENT_DEBUG_SESSION_DIR_ENV, resolveCursorSdkEventDebugBaseDir, } from "./sdk-event-debug-constants.js"; const MAX_CURSOR_SDK_EVENT_DEBUG_JSONL_BYTES = 2 * 1024 * 1024; const COUNT_BUCKET_NAMES = [ "onDelta", "onStep", "stream", "piStream", "provider", "liveRun", "bridge", "bridgeRaw", "displayDecisions", "coordinator", "drain", "timeline", ] as const; type CountBucketName = (typeof COUNT_BUCKET_NAMES)[number]; export type CursorSdkDisplayDecisionAction = | "skip-duplicate" | "skip-incomplete-successful-run" | "queue_replay" | "emit_trace" | "ignore-bridge"; export interface CursorSdkDisplayDecisionRecord { action: CursorSdkDisplayDecisionAction; disposition?: string; toolName: string; identity?: string; source?: "started" | "fallback" | "transcript" | "delta" | "step"; transcript?: string; traceText?: string; replayToolId?: string; reason?: string; } export interface CursorSdkEventDebugSinkOptions { cwd: string; modelId: string; provider: string; env?: Record; config?: CursorSdkConfig; } export interface CursorSdkEventDebugSendMeta { mode: string; reason: string; resetAgent: boolean; bootstrap: boolean; promptText: string; imageCount: number; useNativeToolReplay: boolean; bridgeEnabled: boolean; nativeReplayId: string; promptInputTokens: number; agentMode: AgentModeOption; } export interface CursorSdkEventDebugRunMeta { runId: string; requestId?: string; agentId: string; status: string; } interface CursorSdkRunLike { id: string; requestId?: string; agentId?: string; status?: string; stream?: () => AsyncIterable; wait?: () => Promise; supports?: (operation: never) => boolean; unsupportedReason?: (operation: never) => string | undefined; conversation?: () => Promise; } function eventType(value: unknown): string { const record = asRecord(value); if (typeof record?.type === "string") return record.type; if (typeof record?.event === "string") return record.event; if (typeof record?.kind === "string") return record.kind; return "unknown"; } function resolveCursorSdkEventDebugStderrEnabled(config: CursorSdkConfig = loadCursorSdkUserConfig()): boolean { return config.debug?.sdkEvents?.stderr === true; } function isNodeErrorWithCode(error: unknown, code: string): boolean { return asRecord(error)?.code === code; } function snapshotCursorSdkEventDebugRecord(record: unknown): unknown { try { return structuredClone(record); } catch { try { return JSON.parse(JSON.stringify(record)); } catch { return record; } } } function serializeCursorSdkEventDebugRecord(record: unknown): string { try { const seen = new WeakSet(); return JSON.stringify(snapshotCursorSdkEventDebugRecord(record), (_key, value: unknown) => { if (typeof value === "bigint") return value.toString(); if (value && typeof value === "object") { if (seen.has(value)) return "[Circular]"; seen.add(value); } return value; }) ?? JSON.stringify({ type: "artifact_serialization_error" }); } catch { return JSON.stringify({ type: "artifact_serialization_error" }); } } export function resolveCursorSdkEventDebugEnabled(config: CursorSdkConfig = loadCursorSdkUserConfig()): boolean { return config.debug?.sdkEvents?.enabled === true; } export interface CursorSdkEventDebugRecorder { recordLiveRunEvent(event: CursorLiveQueuedEvent): void; recordBridgeDiagnostic(event: CursorPiToolBridgeDiagnosticEvent): void; recordBridgeRaw(payload: { kind: "queued" | "resolved" | "rejected"; request: CursorPiBridgeToolRequest; result?: unknown; error?: unknown; rejectionKind?: string; }): void; recordDisplayDecision(decision: CursorSdkDisplayDecisionRecord): void; recordCoordinatorEvent(phase: string, payload: unknown): void; recordDrainEvent(phase: string, payload: unknown): void; recordFinalPartial(partial: unknown): void; finalize(): Promise; } export const DISCARDED_INCOMPLETE_TOOL_CALL_REASON = "no-completion-at-run-end"; export type DiscardedIncompleteStartedToolCallReason = | typeof DISCARDED_INCOMPLETE_TOOL_CALL_REASON | "abort" | "sdk-failure" | "run-drain"; export function hashCursorSdkCallId(callId: string): string { return createHash("sha256").update(callId).digest("hex").slice(0, 8); } export interface DiscardedIncompleteStartedToolCallRecord { event: "discarded-incomplete-started-tool-call"; toolName: string; callIdHash: string; reason: DiscardedIncompleteStartedToolCallReason; } export function serializeDiscardedIncompleteStartedToolCall(record: { toolName: string; callId: string; reason?: DiscardedIncompleteStartedToolCallReason; }): DiscardedIncompleteStartedToolCallRecord { return { event: "discarded-incomplete-started-tool-call", toolName: record.toolName, callIdHash: hashCursorSdkCallId(record.callId), reason: record.reason ?? DISCARDED_INCOMPLETE_TOOL_CALL_REASON, }; } export function recordDiscardedIncompleteStartedToolCall( recorder: CursorSdkEventDebugRecorder | undefined, config: CursorSdkConfig, record: { toolName: string; callId: string; reason?: DiscardedIncompleteStartedToolCallReason }, ): void { if (!recorder && !resolveCursorSdkEventDebugEnabled(config)) return; try { const payload = serializeDiscardedIncompleteStartedToolCall(record); recorder?.recordCoordinatorEvent("discarded-incomplete-started-tool-call", payload); if (resolveCursorSdkEventDebugStderrEnabled(config) && resolveCursorSdkEventDebugEnabled(config)) { process.stderr.write(`${CURSOR_SDK_EVENT_DEBUG_LOG_PREFIX} ${JSON.stringify(payload)}\n`); } } catch { // Debug logging must never affect provider execution. } } export function attachCursorSdkEventDebugPiStreamTap( stream: AssistantMessageEventStream, sinkRef: { current?: CursorSdkEventDebugSink }, ): void { if (!resolveCursorSdkEventDebugEnabled()) return; const originalPush = stream.push.bind(stream); stream.push = (event) => { try { sinkRef.current?.recordPiStreamEvent(event); } catch { // Debug capture must never block the underlying stream. } return originalPush(event); }; } export class CursorSdkEventDebugSink { readonly artifactDir: string; readonly sessionDir?: string; readonly turn?: number; readonly sessionKey?: string; readonly pinnedRun: boolean; private readonly config: CursorSdkConfig; private readonly startedAt = Date.now(); private readonly buckets: Record> = {}; private errorCount = 0; private metadata: Record; private readonly jsonlBuffers = new Map(); private readonly jsonlBufferBytes = new Map(); private readonly truncatedJsonlFiles = new Set(); private finalized = false; private finalizationPromise: Promise | undefined; private waitResultRecorded = false; private streamCapturePromise: Promise | undefined; private readonly streamCaptureErrors: unknown[] = []; static maybeCreate(options: CursorSdkEventDebugSinkOptions): CursorSdkEventDebugSink | undefined { const env = options.env ?? process.env; const config = options.config ?? loadCursorSdkUserConfig(); if (!resolveCursorSdkEventDebugEnabled(config)) return undefined; const allocation = allocateCursorSdkEventDebugTurn(options.cwd, env, config); return new CursorSdkEventDebugSink(allocation, options, config); } private constructor( allocation: CursorSdkEventDebugTurnAllocation, options: CursorSdkEventDebugSinkOptions, config: CursorSdkConfig, ) { this.artifactDir = allocation.artifactDir; this.sessionDir = allocation.sessionDir; this.turn = allocation.turn; this.sessionKey = allocation.sessionKey; this.pinnedRun = allocation.pinnedRun; this.config = config; this.metadata = { capturedAt: new Date().toISOString(), modelId: options.modelId, provider: options.provider, cwd: options.cwd, sessionDir: allocation.sessionDir, sessionKey: allocation.sessionKey, sessionFile: getCursorSessionFile(), turn: allocation.turn, pinnedRun: allocation.pinnedRun, artifacts: ARTIFACTS, warnings: [ "Raw artifact files may contain local paths, project text, tool args/results, or secrets from the workspace. Do not commit or share them.", ], }; this.clearKnownArtifactFiles(); writeFileSync(join(this.artifactDir, ARTIFACTS.metadata), `${JSON.stringify(this.metadata, null, 2)}\n`); } recordProviderMeta(meta: Record): void { this.metadata = { ...this.metadata, providerMeta: meta, }; writeFileSync(join(this.artifactDir, ARTIFACTS.metadata), `${JSON.stringify(this.metadata, null, 2)}\n`); } recordSendMeta(meta: CursorSdkEventDebugSendMeta): void { this.metadata = { ...this.metadata, send: meta, }; writeFileSync(join(this.artifactDir, ARTIFACTS.metadata), `${JSON.stringify(this.metadata, null, 2)}\n`); } recordSendPayload(payload: unknown): void { writeFileSync(join(this.artifactDir, ARTIFACTS.sendPayload), `${JSON.stringify(payload, null, 2)}\n`); } recordContextSnapshot(context: unknown): void { writeFileSync(join(this.artifactDir, ARTIFACTS.contextSnapshot), `${JSON.stringify(context, null, 2)}\n`); } recordRunMeta(meta: CursorSdkEventDebugRunMeta): void { this.metadata = { ...this.metadata, run: meta, }; writeFileSync(join(this.artifactDir, ARTIFACTS.metadata), `${JSON.stringify(this.metadata, null, 2)}\n`); } recordOnDelta(update: InteractionUpdate): void { this.appendJsonl(ARTIFACTS.onDelta, "update", update, "onDelta"); } recordOnStep(step: unknown): void { this.appendJsonl(ARTIFACTS.onStep, "step", step, "onStep"); } recordStreamEvent(event: unknown): void { this.appendJsonl(ARTIFACTS.streamEvents, "event", event, "stream"); } recordPiStreamEvent(event: unknown): void { this.appendJsonl(ARTIFACTS.piStreamEvents, "event", event, "piStream"); } recordProviderEvent(phase: string, payload: unknown): void { this.appendPhased(ARTIFACTS.providerEvents, "provider", phase, payload); } recordLiveRunEvent(event: CursorLiveQueuedEvent): void { this.appendJsonl(ARTIFACTS.liveRunEvents, "event", event, "liveRun"); } recordBridgeDiagnostic(event: CursorPiToolBridgeDiagnosticEvent): void { const serialized = serializeCursorPiToolBridgeDiagnostic(event); this.appendJsonl(ARTIFACTS.bridgeEvents, "event", serialized, "bridge", String(serialized.event)); } recordBridgeRaw(payload: { kind: "queued" | "resolved" | "rejected"; request: CursorPiBridgeToolRequest; result?: unknown; error?: unknown; rejectionKind?: string; }): void { this.appendJsonl(ARTIFACTS.bridgeRaw, "bridgeRaw", payload, "bridgeRaw", payload.kind); } recordDisplayDecision(decision: CursorSdkDisplayDecisionRecord): void { this.appendJsonl(ARTIFACTS.displayDecisions, "decision", decision, "displayDecisions", decision.action); } recordCoordinatorEvent(phase: string, payload: unknown): void { this.appendPhased(ARTIFACTS.coordinatorEvents, "coordinator", phase, payload); } recordDrainEvent(phase: string, payload: unknown): void { this.appendPhased(ARTIFACTS.drainEvents, "drain", phase, payload); } recordFinalPartial(partial: unknown): void { writeFileSync(join(this.artifactDir, ARTIFACTS.finalPartial), `${JSON.stringify(partial, null, 2)}\n`); this.recordTimeline("finalPartial", "snapshot", partial); } recordError(label: string, error: unknown): void { this.errorCount += 1; const payload = { label, message: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, value: error, }; this.appendJsonl(ARTIFACTS.errors, "error", payload, undefined, label); } attachRunStream(run: unknown): void { const sdkRun = run as CursorSdkRunLike; if (typeof sdkRun.stream !== "function") { this.recordProviderEvent("run_stream_unavailable", { runId: sdkRun.id, requestId: sdkRun.requestId }); return; } this.streamCapturePromise = (async () => { try { for await (const event of sdkRun.stream!()) { this.recordStreamEvent(event); } } catch (error) { this.streamCaptureErrors.push(error); this.recordError("run_stream", error); } })(); } async captureRunArtifacts(run: unknown): Promise { const sdkRun = run as CursorSdkRunLike & { supports?: (operation: string) => boolean; unsupportedReason?: (operation: string) => string | undefined; }; if (this.streamCapturePromise) { await this.streamCapturePromise.catch(() => undefined); } if (typeof sdkRun.conversation === "function" && sdkRun.supports?.("conversation")) { try { const conversation = await sdkRun.conversation(); writeFileSync(join(this.artifactDir, ARTIFACTS.conversation), `${JSON.stringify(conversation, null, 2)}\n`); this.recordProviderEvent("conversation_captured", { supported: true }); } catch (error) { this.recordError("conversation", error); } } else { writeFileSync( join(this.artifactDir, ARTIFACTS.conversation), `${JSON.stringify( { skipped: true, reason: sdkRun.unsupportedReason?.("conversation") ?? "conversation unsupported", }, null, 2, )}\n`, ); } } recordWaitResult(result: unknown): void { if (this.waitResultRecorded) return; this.waitResultRecorded = true; writeFileSync(join(this.artifactDir, ARTIFACTS.waitResult), `${JSON.stringify(result, null, 2)}\n`); } private capturePiSessionSnapshot(): { copied: boolean; sessionFile?: string; reason?: string } { const sessionFile = getCursorSessionFile(); if (!sessionFile) { return { copied: false, reason: "session file unknown" }; } if (!existsSync(sessionFile)) { return { copied: false, sessionFile, reason: "session file not found at debug finalization" }; } try { copyFileSync(sessionFile, join(this.artifactDir, ARTIFACTS.piSessionSnapshot)); if (this.sessionDir) { copyFileSync(sessionFile, join(this.sessionDir, SESSION_PI_SESSION_SNAPSHOT)); } this.recordTimeline("piSession", "snapshot", { sessionFile, artifact: ARTIFACTS.piSessionSnapshot }); return { copied: true, sessionFile }; } catch (error) { if (isNodeErrorWithCode(error, "ENOENT")) { return { copied: false, sessionFile, reason: "session file not found at debug finalization" }; } this.recordError("pi_session_snapshot", error); return { copied: false, sessionFile, reason: error instanceof Error ? error.message : String(error), }; } } private updateSessionManifest(summary: Record): void { if (this.pinnedRun || !this.sessionDir || this.turn === undefined) return; updateCursorSdkEventDebugSessionManifest(this.sessionDir, this.artifactDir, summary); } private clearKnownArtifactFiles(): void { for (const fileName of Object.values(ARTIFACTS)) { try { unlinkSync(join(this.artifactDir, fileName)); } catch { // Ignore missing prior artifacts when reusing a pinned run directory. } } } async finalize(): Promise { this.finalizationPromise ??= this.finalizeOnce(); await this.finalizationPromise; } private async finalizeOnce(): Promise { if (this.finalized) return; if (this.streamCapturePromise) { await this.streamCapturePromise.catch(() => undefined); } const piSessionSnapshot = this.capturePiSessionSnapshot(); const summary = { artifactDir: this.artifactDir, sessionDir: this.sessionDir, sessionKey: this.sessionKey, sessionFile: getCursorSessionFile(), turn: this.turn, elapsedMs: Date.now() - this.startedAt, counts: this.snapshotCounts(), piSessionSnapshot, artifacts: Object.fromEntries( Object.entries(ARTIFACTS).map(([key, name]) => [key, join(this.artifactDir, name)]), ), waitResultRecorded: this.waitResultRecorded, streamCaptureErrors: this.streamCaptureErrors.map((error) => error instanceof Error ? error.message : String(error), ), jsonlByteLimit: MAX_CURSOR_SDK_EVENT_DEBUG_JSONL_BYTES, truncatedJsonlFiles: [...this.truncatedJsonlFiles].sort(), }; this.flushJsonlBuffers(); writeFileSync(join(this.artifactDir, ARTIFACTS.summary), `${JSON.stringify(summary, null, 2)}\n`); this.updateSessionManifest(summary); if (resolveCursorSdkEventDebugStderrEnabled(this.config)) { process.stderr.write(`${CURSOR_SDK_EVENT_DEBUG_LOG_PREFIX} ${JSON.stringify(summary)}\n`); } this.finalized = true; } private bucket(name: CountBucketName): Record { return (this.buckets[name] ??= {}); } private increment(name: CountBucketName, key: string): void { const bucket = this.bucket(name); bucket[key] = (bucket[key] ?? 0) + 1; } private snapshotCounts(): Record | number> { return { ...Object.fromEntries(COUNT_BUCKET_NAMES.map((name) => [name, { ...this.bucket(name) }])), errors: this.errorCount, }; } private appendPhased(fileName: string, layer: CountBucketName, phase: string, payload: unknown): void { const elapsedMs = Date.now() - this.startedAt; this.bufferJsonl(fileName, { ts: new Date().toISOString(), elapsedMs, turn: this.turn, phase, payload }); this.increment(layer, phase); this.recordTimeline(layer, phase, payload); } private recordTimeline(layer: string, kind: string, payload: unknown): void { const elapsedMs = Date.now() - this.startedAt; const record = { ts: new Date().toISOString(), elapsedMs, turn: this.turn, layer, kind, payload, }; this.bufferJsonl(ARTIFACTS.timeline, record); this.increment("timeline", `${layer}:${kind}`); } private appendJsonl( fileName: string, recordKey: string, value: unknown, bucket: CountBucketName | undefined, countKey?: string, ): void { const elapsedMs = Date.now() - this.startedAt; const record = { ts: new Date().toISOString(), elapsedMs, turn: this.turn, [recordKey]: value, }; this.bufferJsonl(fileName, record); const type = countKey ?? eventType(value); if (bucket) this.increment(bucket, type); const layer = fileName.replace(/\.jsonl$/, ""); this.recordTimeline(layer, type, value); } private bufferJsonl(fileName: string, record: unknown): void { if (this.finalized || this.truncatedJsonlFiles.has(fileName)) return; const line = `${serializeCursorSdkEventDebugRecord(record)}\n`; const marker = `${JSON.stringify({ type: "artifact_truncated", limitBytes: MAX_CURSOR_SDK_EVENT_DEBUG_JSONL_BYTES })}\n`; const usedBytes = this.jsonlBufferBytes.get(fileName) ?? 0; const lineBytes = Buffer.byteLength(line); const markerBytes = Buffer.byteLength(marker); const records = this.jsonlBuffers.get(fileName) ?? []; if (usedBytes + lineBytes > MAX_CURSOR_SDK_EVENT_DEBUG_JSONL_BYTES - markerBytes) { records.push(marker); this.jsonlBuffers.set(fileName, records); this.jsonlBufferBytes.set(fileName, usedBytes + markerBytes); this.truncatedJsonlFiles.add(fileName); return; } records.push(line); this.jsonlBuffers.set(fileName, records); this.jsonlBufferBytes.set(fileName, usedBytes + lineBytes); } private flushJsonlBuffers(): void { for (const [fileName, records] of this.jsonlBuffers) { writeFileSync(join(this.artifactDir, fileName), records.join("")); } this.jsonlBuffers.clear(); this.jsonlBufferBytes.clear(); } } export const __testUtils = { ARTIFACTS, SESSION_MANIFEST, MAX_CURSOR_SDK_EVENT_DEBUG_JSONL_BYTES, slugSessionKey, resetSessionDebugState: resetCursorSdkEventDebugSessionStateForTests, };