IDE Extensions for an Internal Agent: Keep the Plugins Thin
A refactor takes seconds. In that time the user can type, switch files or undo — and an edit computed against a stale document silently corrupts their work.
The mistake is writing the agent twice
An internal agent that has to work in both VS Code and a JetBrains IDE presents an obvious problem: one ecosystem is TypeScript, the other is Kotlin. The naive plan is to implement the agent in both, and it fails predictably — two codebases drift, prompts diverge, and a bug fixed in one takes a fortnight to reach the other.
The arrangement that works puts everything interesting in a local daemon and leaves the plugins thin. Each plugin does what only it can do: read the editor state, present UI, apply edits through its own IDE's transaction model. Nothing else.
| Belongs in the plugin | Belongs in the daemon |
|---|---|
| Reading selection, cursor, open file | Prompt construction |
| Extracting AST or PSI structure | Model calls and retries |
| Rendering UI and diffs | Context assembly and indexing |
| Applying edits transactionally | Policy: what may be changed |
| Progress and cancellation | Credentials and audit logging |
The credentials row is the one that decides the architecture on its own. A plugin holding an API key ships that key to every developer machine in a package they can unzip. A daemon holding it can be provisioned, rotated and audited centrally.
The VS Code side
Extensions run in a separate extension host process, so blocking work does not freeze the editor — but it does block other extensions in the same host, which is reason enough to keep the plugin thin.
import * as vscode from "vscode";
export function activate(ctx: vscode.ExtensionContext) {
ctx.subscriptions.push(
vscode.commands.registerCommand("internalAI.refactorSelection", async () => {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty) {
vscode.window.showWarningMessage("Select some code first.");
return;
}
// Capture identity NOW. The user can switch tabs while we wait.
const doc = editor.document;
const selection = editor.selection;
const versionAtRequest = doc.version;
const code = doc.getText(selection);
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification,
title: "Refactoring…", cancellable: true },
async (_progress, token) => {
const result = await callDaemon("/v1/refactor",
{ code, language: doc.languageId, path: doc.fileName }, token);
if (token.isCancellationRequested) return;
// Refuse to apply against a document that changed underneath us.
if (doc.version !== versionAtRequest) {
vscode.window.showWarningMessage(
"File changed while refactoring. Nothing was applied.");
return;
}
const edit = new vscode.WorkspaceEdit();
edit.replace(doc.uri, selection, result);
await vscode.workspace.applyEdit(edit);
});
}));
}
async function callDaemon(path: string, body: unknown,
token: vscode.CancellationToken): Promise<string> {
const controller = new AbortController();
token.onCancellationRequested(() => controller.abort());
const res = await fetch(`http://127.0.0.1:9090${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${readDaemonToken()}`,
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) throw new Error(`daemon ${res.status}: ${await res.text()}`);
return (await res.json()).result;
}
A refactor takes seconds. In that time the user can type, switch files, or
undo. Applying an edit computed against a stale document silently corrupts
their work, and it is the single most common defect in extensions like this.
Capture document.version before the call and refuse to apply if it
moved.
The JetBrains side
IntelliJ's threading model is stricter than VS Code's and unforgiving of shortcuts. Two rules govern everything: the model may only be modified inside a write action on the event dispatch thread, and long work must not happen on that thread.
class RefactorAction : AnAction() {
override fun update(e: AnActionEvent) {
// Grey the menu item out when there is nothing to act on.
val editor = e.getData(CommonDataKeys.EDITOR)
e.presentation.isEnabled =
editor?.selectionModel?.hasSelection() == true
}
override fun actionPerformed(e: AnActionEvent) {
val project = e.project ?: return
val editor = e.getData(CommonDataKeys.EDITOR) ?: return
val document = editor.document
val caret = editor.caretModel.primaryCaret
val selected = caret.selectedText
if (selected.isNullOrBlank()) return
val start = caret.selectionStart
val end = caret.selectionEnd
val stampAtRequest = document.modificationStamp
// Background thread with a progress indicator — never a raw Thread.
ProgressManager.getInstance().run(
object : Task.Backgroundable(project, "Refactoring…", true) {
override fun run(indicator: ProgressIndicator) {
val result = try {
callDaemon(selected, indicator)
} catch (ex: Exception) {
showError(project, ex.message)
return
}
if (indicator.isCanceled) return
// Back to the EDT, inside a write command, and only if
// the document has not moved underneath us.
ApplicationManager.getApplication().invokeLater {
if (document.modificationStamp != stampAtRequest) {
showError(project, "File changed. Nothing applied.")
return@invokeLater
}
WriteCommandAction.runWriteCommandAction(project) {
document.replaceString(start, end, result)
}
}
}
})
}
}
Three differences from the naive version are worth naming, because each is a real bug rather than a style preference.
A Task.Backgroundable, not a raw thread. The
platform's progress mechanism gives cancellation, a progress bar and correct
behaviour when the project closes mid-request. A bare
Thread { ... }.start() gives none of those, and will happily try
to write into a disposed project.
invokeLater before the write action. The write
command must be issued on the EDT. Calling it from a background thread works
inconsistently across platform versions, which is worse than failing.
An update() override. Without it the menu item
is always enabled, including when there is no editor, and clicking it does
nothing. Cheap to add, and it is the difference between a plugin that feels
native and one that feels bolted on.
Where the two IDEs genuinely differ
| VS Code | JetBrains | |
|---|---|---|
| Structural access | Text and LSP; AST via your own parser | PSI — a full resolved model |
| Threading | Async by default; UI thread separate | Explicit read/write actions on EDT |
| Applying edits | WorkspaceEdit | WriteCommandAction |
| Staleness guard | document.version | modificationStamp |
| UI surface | Webview — arbitrary HTML | Swing components |
The first row is the substantive advantage on the JetBrains side. PSI gives resolved types, references and inheritance without you building an index, which means a JetBrains plugin can send the daemon far richer structural context than a VS Code plugin can for the same effort.
The last row is the one that shapes the product. A shared HTML UI is not possible; Swing is not a web view. Either accept two UI implementations, or design the feature so the UI is minimal — a command, a progress bar, a diff — and let each platform render it natively. The second is almost always the better trade.
Version the daemon protocol from day one
Plugins update on the developer's schedule; the daemon updates on yours. You will have a fleet running four different plugin versions against one daemon, and unversioned endpoints turn that into a support problem.
GET /v1/capabilities
→ {
"daemon_version": "2.4.0",
"protocol": 3,
"min_plugin_protocol": 2,
"features": ["refactor", "explain", "index"]
}
The plugin checks this at startup and says something useful when it is too old, rather than failing on the first request with a parse error. Feature discovery in the same call lets you ship a daemon capability before the plugin that uses it, which is what makes independent release cycles workable.
Distribution, briefly
- JetBrains: a private plugin repository serving an
updatePlugins.xml, added to the IDE's repository list. Push that setting through whatever manages developer machines. - VS Code: package with
vsce packageand serve the.vsix, or run an internal registry. Onboarding scripts can install it withcode --install-extension. - The daemon: the awkward one. It is a background process
with a lifecycle, so ship it through the same channel as the rest of your
developer environment and supervise it with the platform's own mechanism
—
launchd,systemd, whatever you already use.
Have the plugin detect a missing daemon and say so precisely, with the command to start it. The alternative is a connection-refused error that generates a support ticket every time.
Put prompts, model calls, policy and credentials in one local daemon, and
keep both plugins thin. Capture the document version before every request and
refuse to apply against a changed buffer. On JetBrains use
Task.Backgroundable and invokeLater rather than a raw
thread. Version the protocol from the first release, because plugins and the
daemon will never be in step.