Files
vscode-cowriting-plugin/src/liveTurn.ts
T

134 lines
5.6 KiB
TypeScript

/**
* 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<EditTurnResult> {
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(
`<instruction>\n${instruction}\n</instruction>\n<text>\n${selectedText}\n</text>`,
);
// 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);
}
}