Files
vscode-cowriting-plugin/src/liveTurn.ts
T
BenStullsBets a482082529 fix(#59): disable claude-code IDE auto-connect to stop spurious macOS Apple-Events prompt
The claude-code provider spawns the bundled `claude` CLI via
@cline/sdk → ai-sdk-provider-claude-code → @anthropic-ai/claude-agent-sdk.
On macOS, when that subprocess is launched from inside the VS Code extension
host 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 / Automation) permission prompt.

There is no AppleScript/osascript anywhere in the JS dependency tree (the only
match is the build-only `vite`); the Apple Event is sent by the spawned `claude`
binary, which contains 26 osascript calls. Every one is user-action-gated
(clipboard image, screenshot, terminal-setup, Claude-Desktop deep link, browser
detection) except the IDE auto-connect path, which is the only one that fires
without user action when launched inside an IDE — exactly the reported symptom
(appears on use, harmless when declined, since we never use the connection).

Fix: spawn the agent with the macOS IDE integration disabled via the provider's
`options.env` passthrough — `CLAUDE_CODE_AUTO_CONNECT_IDE=0` hard-returns out of
the binary's auto-connect gate (`mK("0") === true`), and
`CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1` skips the extension-install deep link.
`options.env` is seeded with the full `process.env` so the default environment
inheritance is unchanged (login under HOME, proxy/CA vars preserved) — only the
two switches are added. The env flows end to end into the spawned child
(verified through the provider's getBaseProcessEnv merge and the agent SDK's
spawn). runEditTurn is the sole agent-spawn site, so this covers every flow.

Unit test asserts the agent is constructed with the two switches set and the
rest of the environment preserved. 250 unit + typecheck + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 13:11:13 -07:00

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);
}
}