/** * LiveTurn — the minimal machine-edit ingress (spec §6.2): one @cline/sdk * Agent turn on the built-in `claude-code` provider, which rides the * operator's local Claude Code Pro/Max login — the extension holds NO * credentials of any kind (INV-8). vscode-free; the editSelection command * (extension.ts) feeds the result into the applyAgentEdit seam (INV-9). * * Like src/cline.ts: @cline/sdk is ESM-only, loaded via dynamic import(), * never bundled (esbuild keeps it external). */ import type { TurnProgressSnapshot } from "./turnProgress"; import { createTurnProgressState, reduceTurnProgress } from "./turnProgress"; export interface EditTurnResult { replacement: string; model: string; /** the SDK run id — recorded as provenance sessionId. */ sessionId: string; } /** * Options for runEditTurn. Both new fields are purely additive observability / * control over the existing turn (INV-44): `onProgress` streams reduced progress * snapshots out; `signal` cancels the turn in. Neither touches the result path, * and neither pulls `vscode` into this module (INV-43 — `AbortSignal` is a web * standard, the progress snapshot is a domain type). */ export interface RunEditTurnOptions { modelId?: string; onProgress?: (snapshot: TurnProgressSnapshot) => void; signal?: AbortSignal; } const SYSTEM_PROMPT = [ "You are a precise text editor embedded in VS Code.", "You will be given a piece of text and an instruction.", "Respond with ONLY the edited replacement text — no explanation, no preamble,", "no markdown code fences. Preserve the original's leading/trailing whitespace", "unless the instruction says otherwise.", ].join(" "); /** * Strip a wrapping markdown code fence if the model added one anyway. * If `selectedText` itself was fence-wrapped (i.e. the user's selection was a * fenced block), the model mirroring that format is correct — return * `outputText` unchanged so the fence is preserved. */ export function extractReplacement(outputText: string, selectedText: string): string { const fencePattern = /^\s*```[^\n]*\n[\s\S]*?\n?```\s*$/; if (fencePattern.test(selectedText)) return outputText; const m = outputText.match(/^\s*```[^\n]*\n([\s\S]*?)\n?```\s*$/); return m ? m[1] : outputText; } export async function runEditTurn( instruction: string, selectedText: string, opts?: RunEditTurnOptions, ): Promise { const sdk = await import("@cline/sdk"); const modelId = opts?.modelId ?? "sonnet"; const agent = new sdk.Agent({ providerId: "claude-code", modelId, systemPrompt: SYSTEM_PROMPT, // The claude-code provider spawns the bundled `claude` CLI. On macOS, when // that process is launched from inside VS Code it performs IDE auto-connect, // which shells out to `osascript` (`tell application …`) to identify/activate // the editor — raising the macOS "control other applications" (Apple Events) // permission prompt (#59). We only consume the turn's text result and never // use the IDE connection, so we disable it: `CLAUDE_CODE_AUTO_CONNECT_IDE=0` // hard-returns out of the auto-connect path in the binary, and // `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` skips the extension-install deep link. // Seeding with the full `process.env` keeps the default inheritance intact // (login under HOME, proxy/CA vars) — we only add the two switches. The // provider forwards `options.env` into the spawned child (verified end to end // through @cline/sdk → ai-sdk-provider-claude-code → @anthropic-ai/claude-agent-sdk). options: { env: { ...process.env, CLAUDE_CODE_AUTO_CONNECT_IDE: "0", CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL: "1", }, }, }); // Stream reduced progress snapshots out (INV-44 additive) and wire cancellation // in via the AbortSignal (INV-47). agent.subscribe returns its unsubscribe fn. let state = createTurnProgressState(); const unsubscribe = opts?.onProgress ? agent.subscribe((event) => { const next = reduceTurnProgress(state, event); state = next.state; // Observability must never affect the result (INV-44): a throwing relay // is swallowed, not allowed to propagate into the SDK and fail the turn. if (next.snapshot) { try { opts.onProgress!(next.snapshot); } catch { /* progress is best-effort */ } } }) : undefined; const onAbort = () => agent.abort(); opts?.signal?.addEventListener("abort", onAbort); try { // A signal already aborted before the turn starts can't be honored by // agent.abort() (the SDK's AbortController isn't created until run()), so // short-circuit to the same aborted outcome the call site reflects (INV-47). if (opts?.signal?.aborted) { throw new Error("claude-code turn aborted: cancelled before start"); } const result = await agent.run( `\n${instruction}\n\n\n${selectedText}\n`, ); // The SDK's AgentRunResult.status union is "completed" | "aborted" | "failed" // (@cline/shared agent.d.ts) — "completed" is the success status. An aborted // turn (user cancel) falls into this throw; the call site reflects "cancelled". if (result.status !== "completed") { throw new Error( `claude-code turn ${result.status}: ${result.error?.message ?? "unknown error"} ` + "(is Claude Code installed and signed in?)", ); } return { replacement: extractReplacement(result.outputText, selectedText), model: modelId, sessionId: result.runId }; } finally { unsubscribe?.(); opts?.signal?.removeEventListener("abort", onAbort); } }