Tooling & Integration

IDE Extensions for an Internal Agent: Keep the Plugins Thin

David Guzenburg/ / 10 min read

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.

VS CodeJetBrainsextensionsarchitecture

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 pluginBelongs in the daemon
Reading selection, cursor, open filePrompt construction
Extracting AST or PSI structureModel calls and retries
Rendering UI and diffsContext assembly and indexing
Applying edits transactionallyPolicy: what may be changed
Progress and cancellationCredentials 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;
}
The version check is not optional

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 CodeJetBrains
Structural accessText and LSP; AST via your own parserPSI — a full resolved model
ThreadingAsync by default; UI thread separateExplicit read/write actions on EDT
Applying editsWorkspaceEditWriteCommandAction
Staleness guarddocument.versionmodificationStamp
UI surfaceWebview — arbitrary HTMLSwing 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

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.

Takeaway

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.

Keep reading
Tooling & Integration

Local and Hosted Models: Deciding on Data Flow, Not Benchmarks

What actually leaves your machine under each arrangement, why the consumer-versus-business tier distinction matters more than local versus hosted, and how to enforce a hard boundary.

Codex vs Claude

Layered Architecture or Functional Single Surface?

Layering earns its cost when boundaries change independently, need separate tests, or belong to different owners. A single surface is better when the.

Codex vs Claude

Local Machine or Managed Container: The Difference That Actually Survives

Claude Code runs locally and Codex runs in the cloud is the first thing every comparison says, and it stopped being true. What each product treats as home, and what home costs you.

Context Architecture

Encoding Architectural Constraints and Module Boundaries for Agents

Why naming your architecture pattern doesn't work, how to state dependency directions as checkable rules, and why every prohibition needs an escape hatch.

← Static Analysis in the Loop: Linters as Feedback, Not Just Gates  ·  Building a Local MCP Server: Exposing What the Filesystem Cannot Answer →

All tooling & integration articles  ·  Every article