diff --git a/.github/workflows/jetbrains.yml b/.github/workflows/jetbrains.yml
new file mode 100644
index 0000000..cdf5109
--- /dev/null
+++ b/.github/workflows/jetbrains.yml
@@ -0,0 +1,150 @@
+name: JetBrains Plugin
+
+# Checks: Kotlin unit tests, the plugin verifier, and the editor-independent
+# conformance suite against IntelliJ IDEA (Java fixture) through the real MCP
+# server, on every push and pull request that touches the plugin.
+#
+# Release: bump pluginVersion in editors/jetbrains/gradle.properties, add an
+# editors/jetbrains/CHANGELOG.md entry, merge, then push a tag
+# `jetbrains-v`. This checks the tag, version and changelog agree,
+# runs the checks, builds and signs the plugin once, creates the GitHub
+# Release (notes from CHANGELOG.md, plugin zip attached) and publishes that
+# zip to the JetBrains Marketplace ("eap" channel for pre-releases).
+
+on:
+ push:
+ branches: [ main, 'feat/**' ]
+ tags: [ 'jetbrains-v*' ]
+ paths: [ 'editors/jetbrains/**', 'editors/conformance/**', 'editors/vscode/src/**', 'editors/vscode/scripts/**', 'schema/**', '.github/workflows/jetbrains.yml' ]
+ pull_request:
+ paths: [ 'editors/jetbrains/**', 'editors/conformance/**', 'editors/vscode/src/**', 'editors/vscode/scripts/**', 'schema/**', '.github/workflows/jetbrains.yml' ]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+defaults:
+ run:
+ working-directory: editors/jetbrains
+ shell: bash
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ outputs:
+ version: ${{ steps.meta.outputs.version }}
+ prerelease: ${{ steps.meta.outputs.prerelease }}
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+
+ - name: Check version, tag and changelog
+ id: meta
+ run: |
+ version=$(sed -n 's/^pluginVersion=//p' gradle.properties)
+ prerelease=false
+ # Only a release needs its tag and changelog entry; other runs just test.
+ if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
+ if [[ "$GITHUB_REF_NAME" != "jetbrains-v$version" ]]; then
+ echo "::error::Tag $GITHUB_REF_NAME does not match gradle.properties pluginVersion $version (expected jetbrains-v$version)"
+ exit 1
+ fi
+ export CHANGELOG="$PWD/CHANGELOG.md"
+ node ../vscode/scripts/release-notes.js "$version" > /dev/null
+ prerelease=$(node ../vscode/scripts/release-notes.js "$version" --prerelease)
+ fi
+ {
+ echo "version=$version"
+ echo "prerelease=$prerelease"
+ } >> "$GITHUB_OUTPUT"
+
+ test:
+ needs: check
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ ubuntu-latest, macos-latest, windows-latest ]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 21
+
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+
+ - uses: gradle/actions/setup-gradle@v5
+
+ # The plugin bundles the MCP server; the conformance suite runs it too.
+ - name: Build the MCP server
+ working-directory: editors/vscode
+ run: (npm ci || (npm cache clean --force && npm ci)) && npm run compile
+
+ - name: Unit tests and build
+ run: ./gradlew --console=plain test buildPlugin
+
+ - name: Plugin verifier
+ if: runner.os == 'Linux'
+ run: ./gradlew --console=plain verifyPlugin
+
+ - name: Conformance (IntelliJ IDEA + Java)
+ if: runner.os == 'Linux'
+ working-directory: editors/conformance
+ run: npm ci && xvfb-run -a npm run test:jetbrains
+
+ release:
+ needs: [ check, test ]
+ if: github.ref_type == 'tag'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v7
+
+ - uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: 21
+
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 22
+
+ - uses: gradle/actions/setup-gradle@v5
+
+ - name: Build the MCP server
+ working-directory: editors/vscode
+ run: npm ci
+
+ - name: Build and sign
+ run: ./gradlew --console=plain signPlugin
+ env:
+ CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
+ PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
+ PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
+
+ - name: Create GitHub Release
+ run: |
+ CHANGELOG="$PWD/CHANGELOG.md" node ../vscode/scripts/release-notes.js "${{ needs.check.outputs.version }}" > release-notes.md
+ gh release create "$GITHUB_REF_NAME" build/distributions/*-signed.zip \
+ --repo "$GITHUB_REPOSITORY" --verify-tag \
+ --title "Blackbox for JetBrains IDEs ${{ needs.check.outputs.version }}" \
+ --notes-file release-notes.md \
+ ${{ needs.check.outputs.prerelease == 'true' && '--prerelease' || '' }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+
+ - name: Publish to JetBrains Marketplace
+ run: ./gradlew --console=plain publishPlugin ${{ needs.check.outputs.prerelease == 'true' && '-PpublishChannel=eap' || '' }}
+ env:
+ CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
+ PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
+ PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
+ PUBLISH_TOKEN: ${{ secrets.JETBRAINS_PUBLISH_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 90cdfe3..efc5ca2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -150,6 +150,7 @@ Thumbs.db
# Compiled output (monorepo-wide)
editors/vscode/out/
+editors/vscode/dist-mcp/
# Vite logs files
vite.config.js.timestamp-*
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 540126f..11e1f79 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -50,6 +50,14 @@ Each VS Code extension version is a tag `vscode-v`, a [GitHub Release](
To try the pipeline on a branch without releasing anything, run the workflow by hand from the Actions tab: it builds, tests and packages only.
+### JetBrains plugin
+
+Same flow with tag `jetbrains-v`: bump `pluginVersion` in `editors/jetbrains/gradle.properties`, rename `## Unreleased` in `editors/jetbrains/CHANGELOG.md` (check it with `CHANGELOG=editors/jetbrains/CHANGELOG.md node editors/vscode/scripts/release-notes.js `), merge, then push the tag. The **JetBrains Plugin** workflow runs the unit tests, the plugin verifier and the conformance suite, signs the plugin, creates the GitHub Release and publishes to the JetBrains Marketplace (pre-releases to the `eap` channel).
+
+It needs these repository secrets: `JETBRAINS_PUBLISH_TOKEN` (a Marketplace token), and `JETBRAINS_CERTIFICATE_CHAIN`, `JETBRAINS_PRIVATE_KEY`, `JETBRAINS_PRIVATE_KEY_PASSWORD` for [plugin signing](https://plugins.jetbrains.com/docs/intellij/plugin-signing.html). The Marketplace accepts automated uploads only after the first version was uploaded by hand on [plugins.jetbrains.com](https://plugins.jetbrains.com/plugin/add).
+
+The plugin bundles the MCP server from `editors/vscode`, so a server change reaches JetBrains users with the next plugin release.
+
## Reporting Issues
- Use the GitHub issue tracker
diff --git a/README.md b/README.md
index 705c1c2..c946f4a 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ This project contains implementations for the following editors:
| IDE | Status | Directory |
|-----|--------|-----------|
| **VS Code** | Active | [`editors/vscode/`](editors/vscode/) |
-| **JetBrains** | Planned | [`editors/jetbrains/`](editors/jetbrains/) |
+| **JetBrains** (PhpStorm, IntelliJ IDEA, GoLand, …) | Working, not yet published ([build from this repo](editors/jetbrains/README.md)) | [`editors/jetbrains/`](editors/jetbrains/) |
| **Neovim** | Working, not yet published ([install from this repo](editors/neovim/README.md)) | [`editors/neovim/`](editors/neovim/) |
diff --git a/docs/architecture.md b/docs/architecture.md
index 9684047..f4f44dc 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -91,11 +91,16 @@ Layers on the MCP side, each depending only on the ones below:
## Neovim Implementation
`editors/neovim/` is the same window side in Lua on top of nvim-dap. It writes the same registry entry (`app: "Neovim"`; `pid` and `appPid` are both the Neovim process, so an AI tool in `:terminal` is matched to it), listens on the same kind of socket, and answers the same requests. The MCP server needs no changes. Pause state, output and waits port `debugEvents.ts` onto `dap.listeners`. nvim-dap has no function or disabled breakpoints, so the plugin keeps those itself.
+## JetBrains Implementation
+`editors/jetbrains/` is the window side in Kotlin on the IntelliJ debugger platform (XDebugger), so one plugin works in every IntelliJ-based IDE; language-specific parts (method and exception breakpoints) are optional extensions, e.g. for Java. An IDE serves all its projects from one process, so **each open project is a window**: id `-`, `pid` and `appPid` both the IDE's. The MCP server then treats the IDE process as the *editor* an AI tool was started from, not a window; the IDE's terminal sets `BLACKBOX_WINDOW` to pick the project. Run configurations are started by name in the debugger. Hit conditions and logpoints, which the platform doesn't have for every language, are applied by the plugin when such a breakpoint pauses. The plugin also ships a bundled MCP server (`editors/vscode/scripts/bundle-mcp.js`) and installs the launcher with the same rules as the VS Code extension.
+
+On Windows, Node can't connect to Java's Unix domain sockets (it only knows named pipes there), so the plugin listens on TCP loopback: the registry entry's socket is `tcp://127.0.0.1:/`, and every request carries the token. The registry folder is private, so only this user can read it.
+
## Extension Guide: Adding New IDEs
A new editor only needs the **window side**; the MCP server is shared.
1. **Namespace:** Create a new directory under `editors//`.
-2. **Window protocol:** Per window, listen on `/.sock` (a randomly named pipe on Windows) in the private per-user folder (`/tmp/blackbox-/`, mode 0700, checked for ownership), and write `/.json` with the `RegistryEntry` fields from `editors/vscode/src/ipc/protocol.ts` (`app`, `appPid`, `protocol: 2`, …). Answer newline-delimited JSON requests `{id, tool, args}` with `{id, result}` or `{id, error}`.
+2. **Window protocol:** Per window, listen on `/.sock` (a randomly named pipe on Windows, or `tcp://127.0.0.1:/` where named pipes aren't available) in the private per-user folder (`/tmp/blackbox-/`, mode 0700, checked for ownership), and write `/.json` with the `RegistryEntry` fields from `editors/vscode/src/ipc/protocol.ts` (`app`, `appPid`, `protocol: 2`, …). Answer newline-delimited JSON requests `{id, tool, args}` with `{id, result}` or `{id, error}`.
3. **Compliance:** Implement every tool in `schema/tools.json` except `ide_*` (answered by the MCP server), plus the internal `window_status`.
4. **Conformance:** Add a harness to `editors/conformance/harness/` that starts the editor, and run `editors/conformance/test` against it. It drives the editor through the real MCP server.
5. **Automation:** Register a CI workflow in `.github/workflows/.yml`.
diff --git a/editors/conformance/fixtures/java/App.java b/editors/conformance/fixtures/java/App.java
new file mode 100644
index 0000000..0ef64c5
--- /dev/null
+++ b/editors/conformance/fixtures/java/App.java
@@ -0,0 +1,19 @@
+// Fixture for the Blackbox conformance suite (editors/conformance/test).
+// The tests refer to these line numbers; keep them in step with app.go.
+
+public class App {
+ static int add(int a, int b) {
+ return a + b;
+ }
+
+ public static void main(String[] args) {
+ int total = 0;
+ for (int i = 1; i <= 2; i++) {
+ // Same lines as app.go: breakpoint on 14, logpoint on 15.
+
+ total = add(total, i); // line 14: breakpoint
+ System.out.println("total " + total); // line 15: logpoint
+ }
+ System.out.println("done " + total);
+ }
+}
diff --git a/editors/conformance/harness/jetbrains.js b/editors/conformance/harness/jetbrains.js
new file mode 100644
index 0000000..285f8a5
--- /dev/null
+++ b/editors/conformance/harness/jetbrains.js
@@ -0,0 +1,134 @@
+// Starts an IntelliJ-platform IDE (IntelliJ IDEA Community, through Gradle's
+// runIdeForConformance task) with the Blackbox plugin, on a copy of the Java
+// fixture, for the conformance suite.
+//
+// The fixture is a prebuilt jar run by a "JAR Application" configuration, so
+// the IDE needs no JDK set up and no build: javac and java come from
+// JAVA_HOME (or PATH). Linux CI runs this under xvfb-run.
+
+const { spawn, spawnSync, execFileSync } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const PLUGIN = path.resolve(__dirname, '../../jetbrains');
+const FIXTURE = path.resolve(__dirname, '../fixtures/java');
+const isWindows = process.platform === 'win32';
+// The first start builds the plugin and may download the IDE.
+const START_TIMEOUT_MS = 10 * 60_000;
+
+function javaHome() {
+ if (process.env.JAVA_HOME) { return process.env.JAVA_HOME; }
+ const { stderr } = spawnSync('java', ['-XshowSettings:properties', '-version'], { encoding: 'utf8' });
+ const match = /java\.home = (.+)/.exec(stderr || '');
+ if (!match) { throw new Error('set JAVA_HOME to a JDK: the fixture is built with javac and jar'); }
+ return match[1].trim();
+}
+
+const xml = s => s.replace(/&/g, '&').replace(/"/g, '"').replace(/
+
+
+
+
+
+
+
+
+`;
+}
+
+// A project around the fixture: one module with the source, the compiled jar,
+// and the run configurations the tests start by name.
+function prepareWorkspace(dir) {
+ const workspace = path.join(dir, 'fixture');
+ fs.mkdirSync(path.join(workspace, '.idea', 'runConfigurations'), { recursive: true });
+ fs.copyFileSync(path.join(FIXTURE, 'App.java'), path.join(workspace, 'App.java'));
+
+ const jdk = javaHome();
+ const bin = name => path.join(jdk, 'bin', isWindows ? `${name}.exe` : name);
+ const classes = path.join(dir, 'classes');
+ execFileSync(bin('javac'), ['-g', '--release', '17', '-d', classes, path.join(workspace, 'App.java')]);
+ execFileSync(bin('jar'), ['--create', '--file', path.join(workspace, 'app.jar'), '--main-class', 'App', '-C', classes, '.']);
+
+ const idea = path.join(workspace, '.idea');
+ fs.writeFileSync(path.join(idea, 'modules.xml'), `
+
+
+
+
+
+
+
+`);
+ fs.writeFileSync(path.join(workspace, 'fixture.iml'), `
+
+
+
+
+
+
+
+
+
+`);
+ const configs = path.join(idea, 'runConfigurations');
+ fs.writeFileSync(path.join(configs, 'Fixture.xml'), `\n` +
+ runConfiguration('Fixture', '$PROJECT_DIR$/app.jar', jdk));
+ // The IDE refuses it (no such JRE), to check that the error is reported.
+ fs.writeFileSync(path.join(configs, 'Fixture_broken.xml'),
+ runConfiguration('Fixture (broken)', '$PROJECT_DIR$/app.jar', path.join(dir, 'no-such-jre')));
+ return fs.realpathSync(workspace);
+}
+
+async function startJetBrains({ ipcDir }) {
+ const workspace = prepareWorkspace(ipcDir);
+ // Where the plugin installs its MCP launcher (not the user's ~/.blackbox).
+ const home = path.join(ipcDir, 'home');
+ const gradlew = path.join(PLUGIN, isWindows ? 'gradlew.bat' : 'gradlew');
+ const gradle = spawn(gradlew, ['--console=plain', 'runIdeForConformance', `-PblackboxProject=${workspace}`], {
+ cwd: PLUGIN,
+ env: { ...process.env, BLACKBOX_IPC_DIR: ipcDir, BLACKBOX_HOME: home },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let log = '';
+ gradle.stdout.on('data', chunk => { log += chunk; });
+ gradle.stderr.on('data', chunk => { log += chunk; });
+
+ // Ready once the project's registry entry exists.
+ const started = Date.now();
+ let entry;
+ while (!entry && Date.now() - started < START_TIMEOUT_MS && gradle.exitCode === null) {
+ const file = fs.readdirSync(ipcDir).find(f => f.endsWith('.json'));
+ if (file) {
+ try { entry = JSON.parse(fs.readFileSync(path.join(ipcDir, file), 'utf8')); } catch { /* being written */ }
+ }
+ if (!entry) { await new Promise(r => setTimeout(r, 500)); }
+ }
+ if (!entry) {
+ gradle.kill();
+ throw new Error(`the IDE did not register within ${START_TIMEOUT_MS / 1000}s.\n${log.slice(-5000)}`);
+ }
+
+ return {
+ name: entry.app.name,
+ workspace,
+ file: path.join(workspace, 'App.java'),
+ functionName: 'App.add',
+ // The plugin ships the MCP server and installs ~/.blackbox/blackbox-mcp.js.
+ home,
+ // Its run configurations can't stall the way a debug adapter can.
+ skip: ['stalled start'],
+ stop: () => new Promise(resolve => {
+ if (gradle.exitCode !== null || gradle.signalCode !== null) { return resolve(); }
+ gradle.once('exit', resolve);
+ // Ends the IDE (Gradle's task then finishes); Gradle itself as a fallback.
+ try { process.kill(entry.pid, 'SIGTERM'); } catch { gradle.kill(); }
+ setTimeout(() => gradle.kill(), 30_000).unref();
+ }),
+ stderr: () => log.slice(-20000),
+ };
+}
+
+module.exports = { startJetBrains };
diff --git a/editors/conformance/harness/nvim.js b/editors/conformance/harness/nvim.js
index 3b6f8bb..c4738f6 100644
--- a/editors/conformance/harness/nvim.js
+++ b/editors/conformance/harness/nvim.js
@@ -9,6 +9,7 @@ const os = require('os');
const path = require('path');
const PLUGIN = path.resolve(__dirname, '../../neovim');
+const GO_FIXTURE = fs.realpathSync(path.resolve(__dirname, '../fixtures/go'));
const NVIM_DAP = process.env.NVIM_DAP_PATH || path.join(os.homedir(), '.local/share/nvim/lazy/nvim-dap');
function initScript(workspace) {
@@ -30,7 +31,7 @@ require('blackbox').setup()
`;
}
-async function startNvim({ ipcDir, workspace }) {
+async function startNvim({ ipcDir, workspace = GO_FIXTURE }) {
const init = path.join(ipcDir, 'init.lua');
fs.writeFileSync(init, initScript(workspace));
const nvim = spawn('nvim', ['--headless', '-u', init], {
@@ -52,6 +53,10 @@ async function startNvim({ ipcDir, workspace }) {
}
return {
name: 'Neovim',
+ workspace,
+ file: path.join(workspace, 'app.go'),
+ functionName: 'main.add',
+ skip: [],
// Resolves at once if Neovim already exited (e.g. crashed during a test).
stop: () => new Promise(resolve => {
if (nvim.exitCode !== null || nvim.signalCode !== null) { return resolve(); }
diff --git a/editors/conformance/package.json b/editors/conformance/package.json
index f3f3449..79a2f68 100644
--- a/editors/conformance/package.json
+++ b/editors/conformance/package.json
@@ -3,7 +3,8 @@
"private": true,
"description": "Editor-independent checks that an editor's Blackbox window side implements the tool contract, driven through the real MCP server.",
"scripts": {
- "test:nvim": "mocha --ui tdd --timeout 120000 \"test/**/*.test.js\""
+ "test:nvim": "mocha --ui tdd --timeout 120000 \"test/**/*.test.js\"",
+ "test:jetbrains": "BLACKBOX_EDITOR=jetbrains mocha --ui tdd --timeout 120000 \"test/**/*.test.js\""
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
diff --git a/editors/conformance/test/loop.test.js b/editors/conformance/test/loop.test.js
index 437c3ea..bd599c0 100644
--- a/editors/conformance/test/loop.test.js
+++ b/editors/conformance/test/loop.test.js
@@ -1,7 +1,7 @@
// Conformance: the AI debugging loop against an editor's window side, through
-// the real Blackbox MCP server, on the Go fixture (delve). Mirrors
-// editors/vscode/src/test/suite/loop.test.ts.
+// the real Blackbox MCP server. Mirrors editors/vscode/src/test/suite/loop.test.ts.
//
+// BLACKBOX_EDITOR nvim (Go fixture, delve; default) or jetbrains (Java fixture, IntelliJ IDEA)
// BLACKBOX_SERVER MCP server to use (default: the VS Code build's out/mcp/server.js)
const assert = require('assert');
@@ -10,18 +10,26 @@ const os = require('os');
const path = require('path');
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
const { StdioClientTransport } = require('@modelcontextprotocol/sdk/client/stdio.js');
-const { startNvim } = require('../harness/nvim');
-const WORKSPACE = fs.realpathSync(path.resolve(__dirname, '../fixtures/go'));
-const FIXTURE = path.join(WORKSPACE, 'app.go');
+const HARNESSES = {
+ nvim: { start: () => require('../harness/nvim').startNvim, setupMs: 30_000 },
+ jetbrains: { start: () => require('../harness/jetbrains').startJetBrains, setupMs: 11 * 60_000 },
+};
+const EDITOR = process.env.BLACKBOX_EDITOR || 'nvim';
+const HARNESS = HARNESSES[EDITOR];
+if (!HARNESS) { throw new Error(`unknown BLACKBOX_EDITOR "${EDITOR}"; one of ${Object.keys(HARNESSES).join(', ')}`); }
+
+// Both fixtures have the same lines.
const BREAKPOINT_LINE = 14;
const LOGPOINT_LINE = 15;
const SERVER = process.env.BLACKBOX_SERVER || path.resolve(__dirname, '../../vscode/out/mcp/server.js');
-suite('Debug loop (Go fixture, Neovim)', () => {
+suite(`Debug loop (${EDITOR})`, () => {
let ipcDir;
let editor;
let client;
+ let WORKSPACE;
+ let FIXTURE;
async function call(tool, args = {}) {
const result = await client.callTool({ name: tool, arguments: args });
@@ -32,10 +40,12 @@ suite('Debug loop (Go fixture, Neovim)', () => {
const callJson = async (tool, args) => JSON.parse(await call(tool, args));
suiteSetup(async function () {
- this.timeout(30_000);
+ this.timeout(HARNESS.setupMs);
ipcDir = fs.realpathSync(fs.mkdtempSync(path.join(process.platform === 'win32' ? os.tmpdir() : '/tmp', 'bbx-conf-')));
fs.chmodSync(ipcDir, 0o700);
- editor = await startNvim({ ipcDir, workspace: WORKSPACE });
+ editor = await HARNESS.start()({ ipcDir });
+ WORKSPACE = editor.workspace;
+ FIXTURE = editor.file;
client = new Client({ name: 'conformance', version: '0' });
await client.connect(new StdioClientTransport({
command: process.execPath,
@@ -64,6 +74,23 @@ suite('Debug loop (Go fixture, Neovim)', () => {
assert.deepStrictEqual(window.folders, [WORKSPACE]);
});
+ test('the MCP launcher the editor installed serves its windows', async function () {
+ if (!editor.home) { this.skip(); } // Editors that rely on the VS Code extension's.
+ const launcher = new Client({ name: 'conformance-launcher', version: '0' });
+ await launcher.connect(new StdioClientTransport({
+ command: process.execPath,
+ args: [path.join(editor.home, 'blackbox-mcp.js')],
+ cwd: WORKSPACE,
+ env: { ...process.env, BLACKBOX_IPC_DIR: ipcDir, BLACKBOX_HOME: editor.home },
+ }));
+ try {
+ const result = await launcher.callTool({ name: 'ide_list_windows', arguments: {} });
+ assert.strictEqual(JSON.parse(result.content[0].text)[0].app.name, editor.name);
+ } finally {
+ await launcher.close();
+ }
+ });
+
test('start by name, wait, step, read logpoints, stop', async () => {
await call('debug_set_breakpoint', { breakpoints: [
{ file: FIXTURE, line: BREAKPOINT_LINE },
@@ -74,7 +101,7 @@ suite('Debug loop (Go fixture, Neovim)', () => {
const hit = await callJson('debug_wait_for_stop', { timeoutMs: 60_000 });
assert.strictEqual(hit.state, 'stopped', JSON.stringify(hit));
assert.strictEqual(hit.line, BREAKPOINT_LINE, JSON.stringify(hit));
- assert.ok(hit.file.endsWith('app.go'));
+ assert.ok(hit.file.endsWith(path.basename(FIXTURE)), hit.file);
const stepped = await callJson('debug_step_over');
assert.strictEqual(stepped.state, 'stopped', JSON.stringify(stepped));
@@ -125,9 +152,11 @@ suite('Debug loop (Go fixture, Neovim)', () => {
assert.match(await call('debug_toggle_breakpoints', { enabled: true, breakpoints: [{ file: FIXTURE, line: BREAKPOINT_LINE }] }), /Enabled 1/);
assert.strictEqual((await callJson('debug_list_breakpoints'))[0].hitCondition, '2', 'toggling keeps the hit condition');
- assert.match(await call('debug_set_function_breakpoint', { name: 'main.add' }), /ok: function main.add/);
- assert.ok((await callJson('debug_list_breakpoints')).some(b => b.type === 'function' && b.name === 'main.add'));
- assert.match(await call('debug_remove_breakpoint', { functions: ['main.add'] }), /removed function main.add/);
+ const fn = editor.functionName;
+ assert.match(await call('debug_set_function_breakpoint', { name: fn }), new RegExp(`ok: function ${fn}`));
+ const withFunction = await callJson('debug_list_breakpoints');
+ assert.ok(withFunction.some(b => b.type === 'function' && b.name.endsWith(fn)), JSON.stringify(withFunction));
+ assert.match(await call('debug_remove_breakpoint', { functions: [fn] }), new RegExp(`removed function ${fn}`));
const reached = await callJson('debug_run_to_line', { file: FIXTURE, line: LOGPOINT_LINE, timeoutMs: 20_000 });
assert.strictEqual(reached.reachedTarget, true, JSON.stringify(reached));
@@ -159,7 +188,8 @@ suite('Debug loop (Go fixture, Neovim)', () => {
assert.strictEqual(after.state, 'terminated', JSON.stringify(after));
});
- test('a start that never comes up fails with an explanation instead of hanging', async () => {
+ test('a start that never comes up fails with an explanation instead of hanging', async function () {
+ if (editor.skip.includes('stalled start')) { this.skip(); }
const started = Date.now();
await assert.rejects(call('debug_start', { configName: 'Fixture (stalled)' }), /has not started after 20s/);
assert.ok(Date.now() - started < 30_000, 'answers within the stall limit');
diff --git a/editors/jetbrains/.gitignore b/editors/jetbrains/.gitignore
new file mode 100644
index 0000000..8e8851b
--- /dev/null
+++ b/editors/jetbrains/.gitignore
@@ -0,0 +1,4 @@
+.gradle/
+.kotlin/
+build/
+.intellijPlatform/
diff --git a/editors/jetbrains/CHANGELOG.md b/editors/jetbrains/CHANGELOG.md
new file mode 100644
index 0000000..5a09df7
--- /dev/null
+++ b/editors/jetbrains/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog
+
+All notable changes to the Blackbox plugin for JetBrains IDEs. Each version is also a [GitHub Release](https://github.com/xcode-studio/blackbox/releases) (tag `jetbrains-v`) with these notes and the plugin zip.
+
+Releases are published by pushing a `jetbrains-v` tag; CI refuses to release a version without an entry here. Changes collect under `## Unreleased`; at release time that heading becomes `## (Pre-release) — ` (leave out `(Pre-release)` for a stable release).
+
+## Unreleased
+
+- First version: all Blackbox tools in every IntelliJ-based IDE (2024.3 or later), on the IDE's debugger platform. Each open project is a Blackbox window; AI tools started in the IDE's terminal use their project.
+- Ships the MCP server and installs `~/.blackbox/blackbox-mcp.js`, so it works without the VS Code extension (Node.js is still needed). **Tools | Blackbox: Copy MCP Config** copies the MCP client entry.
+- Java: method breakpoints by name, and caught/uncaught exception filters.
diff --git a/editors/jetbrains/README.md b/editors/jetbrains/README.md
index d1fcb73..25f9652 100644
--- a/editors/jetbrains/README.md
+++ b/editors/jetbrains/README.md
@@ -1,18 +1,50 @@
-# Blackbox — JetBrains Plugin
+# Blackbox for JetBrains IDEs
-> **Status**: Planned
+> **Status:** working, not yet published. Build it from this repository (below).
-JetBrains IDE plugin (IntelliJ, PhpStorm, WebStorm, PyCharm, GoLand) implementing the Blackbox tool contract.
+Lets AI tools (Claude Code, Cursor, any MCP client) drive the IDE's debugger: set breakpoints, start run configurations by name, wait for pauses, step, inspect and change variables, read logpoint output. It is built on the IDE's own debugger platform, so it works in every IntelliJ-based IDE (PhpStorm, IntelliJ IDEA, GoLand, PyCharm, WebStorm, …) with whatever that IDE debugs.
-## Architecture
+It speaks the same protocol as the [VS Code extension](../vscode/) and [blackbox.nvim](../neovim/), so one Blackbox MCP server sees JetBrains projects next to VS Code, Cursor, Antigravity and Neovim windows.
-- **Language**: Kotlin
-- **Build**: Gradle + IntelliJ Platform Plugin
-- **Debug APIs**: `XDebuggerManager`, `XBreakpointManager`, `XDebugSession`
-- **Editor APIs**: `FileEditorManager`, `PsiFile`
-- **Workspace APIs**: `ProjectFileIndex`, `InspectionManager`
-- **MCP Transport**: JetBrains native MCP support or custom stdio bridge
+## Requirements
+- A JetBrains IDE 2024.3 or later
+- [Node.js](https://nodejs.org/) 18 or later, to run the MCP server (the plugin ships the server itself)
-## Tool Contract
+## Install
+```sh
+(cd ../vscode && npm ci) # the plugin bundles the MCP server from there
+./gradlew buildPlugin
+```
+Then in the IDE: **Settings | Plugins | ⚙ | Install Plugin from Disk…** and pick `build/distributions/blackbox-jetbrains-.zip`.
-All tools must match the schemas defined in [`/schema/tools.json`](../../schema/tools.json).
+## Connect your AI tool
+Run **Tools | Blackbox: Copy MCP Config** and add the copied entry under `mcpServers` in your MCP client's config (for Claude Code: `~/.claude.json`), then reconnect it. The entry is:
+```json
+{ "blackbox": { "command": "/path/to/node", "args": ["/Users//.blackbox/blackbox-mcp.js"] } }
+```
+The plugin installs that launcher (unless a newer one from another editor is there already), so the same entry works with every editor that has Blackbox.
+
+## How a session finds this IDE
+Each open project is its own window for Blackbox.
+- **`claude` (or another AI tool) started in the IDE's terminal:** it uses that project automatically (`BLACKBOX_WINDOW` is set there).
+- **Elsewhere:** the project whose folder contains the AI tool's folder. If several windows match, the AI calls `ide_list_windows` and `ide_select_window`, where projects show with the IDE's name, e.g. `"app": "PhpStorm"`.
+
+## Launch configurations
+`debug_start {configName}` starts the **run configuration** of that name in the debugger, as **Run | Debug '…'** does (before-launch tasks such as a build included). `debug_get_launch_configs` lists them. Creating a session from `type`/`request` arguments, as in VS Code, isn't possible here: make a run configuration instead.
+
+For PHP, create the run configuration you use in PhpStorm (e.g. a *PHP Web Page* or *PHP Remote Debug* configuration) and start it by name.
+
+## How it differs from VS Code
+- **Logpoints and hit conditions** are applied by Blackbox: such a breakpoint pauses the program, Blackbox logs the message or counts the hit, and resumes it at once. The IDE's own UI may flicker for a moment. They show as normal breakpoints in the IDE.
+- **Function breakpoints** use the language's method breakpoints, where the IDE has them (Java and other JVM languages: `"App.add"` or `"com.example.App.add"`).
+- **Exception filters** are the IDE's exception breakpoints, so they stay set for later sessions (Java: `java-caught`, `java-uncaught`).
+- **Threads** are listed only while the program is paused; thread ids stay the same for a session.
+- **Program output** is whatever the run configuration's process prints, plus logpoint messages.
+
+## Development
+- **Unit tests:** `./gradlew test`
+- **Run an IDE with the plugin:** `./gradlew runIde`
+- **Conformance:** the same checks as the other editors, through the real MCP server, against a Java fixture in IntelliJ IDEA Community (downloaded by Gradle; javac and jar from `JAVA_HOME`):
+ ```sh
+ (cd ../vscode && npm ci && npm run compile) && (cd ../conformance && npm ci && npm run test:jetbrains)
+ ```
diff --git a/editors/jetbrains/build.gradle.kts b/editors/jetbrains/build.gradle.kts
new file mode 100644
index 0000000..f0865ad
--- /dev/null
+++ b/editors/jetbrains/build.gradle.kts
@@ -0,0 +1,117 @@
+import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
+import org.jetbrains.intellij.platform.gradle.TestFrameworkType
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.dsl.KotlinVersion
+
+plugins {
+ id("org.jetbrains.kotlin.jvm") version "2.4.20"
+ id("org.jetbrains.intellij.platform") version "2.19.0"
+}
+
+group = "dev.blackbox"
+version = providers.gradleProperty("pluginVersion").get()
+
+repositories {
+ mavenCentral()
+ intellijPlatform { defaultRepositories() }
+}
+
+kotlin {
+ jvmToolchain(21)
+ compilerOptions {
+ jvmTarget = JvmTarget.JVM_21
+ // The Kotlin runtime bundled with 2024.3.
+ apiVersion = KotlinVersion.KOTLIN_2_0
+ languageVersion = KotlinVersion.KOTLIN_2_0
+ }
+}
+
+dependencies {
+ intellijPlatform {
+ intellijIdeaCommunity(providers.gradleProperty("platformVersion"))
+ bundledPlugins("com.intellij.java", "org.jetbrains.plugins.terminal")
+ testFramework(TestFrameworkType.Platform)
+ }
+ testImplementation("junit:junit:4.13.2")
+}
+
+// The MCP server ships inside the plugin (see McpInstall), bundled by
+// editors/vscode/scripts/bundle-mcp.js. Needs `npm ci` in editors/vscode.
+val vscodeDir = layout.projectDirectory.dir("../vscode")
+val bundleMcpServer by tasks.registering(Exec::class) {
+ description = "Bundles the Blackbox MCP server from editors/vscode."
+ workingDir = vscodeDir.asFile
+ commandLine(if (System.getProperty("os.name").startsWith("Windows")) "node.exe" else "node", "scripts/bundle-mcp.js")
+ inputs.dir(vscodeDir.dir("src"))
+ inputs.file(vscodeDir.file("package.json"))
+ inputs.file(layout.projectDirectory.file("../../schema/tools.json"))
+ outputs.dir(vscodeDir.dir("dist-mcp"))
+ val esbuild = vscodeDir.dir("node_modules/esbuild").asFile
+ doFirst {
+ check(esbuild.exists()) { "Run `npm ci` in editors/vscode first: the plugin bundles its MCP server." }
+ }
+}
+
+// The plugin's own version, read at run time (ProjectBridge.PLUGIN_VERSION).
+val pluginVersionFile by tasks.registering {
+ val version = providers.gradleProperty("pluginVersion")
+ val out = layout.buildDirectory.file("generated/version/blackbox-plugin-version.txt")
+ inputs.property("version", version)
+ outputs.file(out)
+ doLast { out.get().asFile.writeText(version.get()) }
+}
+
+tasks.processResources {
+ from(bundleMcpServer) { into("blackbox-mcp") }
+ from(pluginVersionFile)
+}
+
+intellijPlatform {
+ pluginConfiguration {
+ version = providers.gradleProperty("pluginVersion")
+ ideaVersion {
+ sinceBuild = providers.gradleProperty("sinceBuild")
+ untilBuild = provider { null }
+ }
+ }
+ // The oldest supported IDE, and the latest PhpStorm (no Java plugin there).
+ pluginVerification {
+ ides {
+ create(IntelliJPlatformType.IntellijIdeaCommunity, providers.gradleProperty("platformVersion"))
+ create(IntelliJPlatformType.PhpStorm, providers.gradleProperty("verifyPhpStormVersion"))
+ }
+ }
+ // Release only (.github/workflows/jetbrains.yml), from repository secrets.
+ signing {
+ certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN")
+ privateKey = providers.environmentVariable("PRIVATE_KEY")
+ password = providers.environmentVariable("PRIVATE_KEY_PASSWORD")
+ }
+ publishing {
+ token = providers.environmentVariable("PUBLISH_TOKEN")
+ // Pre-releases go to the "eap" channel.
+ channels = providers.gradleProperty("publishChannel").map { listOf(it) }.orElse(listOf("default"))
+ }
+}
+
+// An IDE for the conformance suite (editors/conformance): opens the project
+// given with -PblackboxProject=, without first-run dialogs.
+val runIdeForConformance by intellijPlatformTesting.runIde.registering {
+ task {
+ jvmArgumentProviders += CommandLineArgumentProvider {
+ listOf(
+ "-Didea.trust.all.projects=true",
+ "-Djb.privacy.policy.text=",
+ "-Djb.consents.confirmation.enabled=false",
+ "-Dide.show.tips.on.startup.default.value=false",
+ "-Didea.initially.ask.config=never",
+ "-Dide.mac.message.dialogs.as.sheets=false",
+ "-Didea.suppress.statistics.report=true",
+ "-Dnosplash=true",
+ )
+ }
+ argumentProviders += CommandLineArgumentProvider {
+ listOfNotNull(providers.gradleProperty("blackboxProject").orNull)
+ }
+ }
+}
diff --git a/editors/jetbrains/gradle.properties b/editors/jetbrains/gradle.properties
new file mode 100644
index 0000000..278e67f
--- /dev/null
+++ b/editors/jetbrains/gradle.properties
@@ -0,0 +1,10 @@
+pluginVersion=0.1.0
+# The oldest IDE line supported (2024.3) is also the one compiled against.
+platformVersion=2024.3.6
+sinceBuild=243
+# Checked by verifyPlugin as well.
+verifyPhpStormVersion=2026.2.3
+
+kotlin.stdlib.default.dependency=false
+org.gradle.configuration-cache=true
+org.gradle.caching=true
diff --git a/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar b/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..5097068
Binary files /dev/null and b/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties b/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..9b7cb4a
--- /dev/null
+++ b/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/editors/jetbrains/gradlew b/editors/jetbrains/gradlew
new file mode 100755
index 0000000..249efbb
--- /dev/null
+++ b/editors/jetbrains/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/editors/jetbrains/gradlew.bat b/editors/jetbrains/gradlew.bat
new file mode 100644
index 0000000..03754c4
--- /dev/null
+++ b/editors/jetbrains/gradlew.bat
@@ -0,0 +1,112 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+@rem Catch executions from older scripts and ensure they exit cleanly.
+@rem This can be removed once we can be reasonably confident that few people
+@rem will be migrating directly to this new wrapper.
+goto afterSafetyNet
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
+goto exitWithErrorLevel
+:afterSafetyNet
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+1>&2 echo.
+1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+1>&2 echo.
+1>&2 echo Please set the JAVA_HOME variable in your environment to match the
+1>&2 echo location of your Java installation.
+
+"%COMSPEC%" /c exit 1
+goto exitWithErrorLevel
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
+
+@rem This label must not be changed. We rely on old scripts being able to jump to this point.
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/editors/jetbrains/settings.gradle.kts b/editors/jetbrains/settings.gradle.kts
new file mode 100644
index 0000000..20801f3
--- /dev/null
+++ b/editors/jetbrains/settings.gradle.kts
@@ -0,0 +1,5 @@
+rootProject.name = "blackbox-jetbrains"
+
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/Async.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/Async.kt
new file mode 100644
index 0000000..3d80b35
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/Async.kt
@@ -0,0 +1,147 @@
+// Suspend wrappers over XDebugger's callback APIs. Debuggers answer on their
+// own threads, sometimes in several batches, and sometimes never (a value
+// that can't be computed), so every call has a timeout.
+
+package dev.blackbox.debug
+
+import com.intellij.openapi.util.NlsSafe
+import com.intellij.ui.SimpleColoredText
+import com.intellij.xdebugger.XDebuggerUtil
+import com.intellij.xdebugger.XSourcePosition
+import com.intellij.xdebugger.evaluation.EvaluationMode
+import com.intellij.xdebugger.evaluation.XDebuggerEvaluator
+import com.intellij.xdebugger.frame.XCompositeNode
+import com.intellij.xdebugger.frame.XDebuggerTreeNodeHyperlink
+import com.intellij.xdebugger.frame.XExecutionStack
+import com.intellij.xdebugger.frame.XFullValueEvaluator
+import com.intellij.xdebugger.frame.XStackFrame
+import com.intellij.xdebugger.frame.XSuspendContext
+import com.intellij.xdebugger.frame.XValue
+import com.intellij.xdebugger.frame.XValueChildrenList
+import com.intellij.xdebugger.frame.XValueContainer
+import com.intellij.xdebugger.frame.XValueModifier
+import com.intellij.xdebugger.frame.XValueNode
+import com.intellij.xdebugger.frame.XValuePlace
+import com.intellij.xdebugger.frame.presentation.XValuePresentation
+import com.intellij.xdebugger.impl.ui.tree.nodes.XValuePresentationUtil
+import dev.blackbox.ipc.ToolError
+import kotlinx.coroutines.CancellableContinuation
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withTimeoutOrNull
+import javax.swing.Icon
+import kotlin.coroutines.resume
+
+const val REQUEST_TIMEOUT_MS = 30_000L
+
+// The IDE shows this while a value is still being computed.
+private const val COLLECTING = "Collecting data"
+
+private suspend fun callback(what: String, block: (CancellableContinuation) -> Unit): T =
+ withTimeoutOrNull(REQUEST_TIMEOUT_MS) { suspendCancellableCoroutine(block) }
+ ?: throw ToolError("the debugger did not answer $what within ${REQUEST_TIMEOUT_MS / 1000}s")
+
+private fun CancellableContinuation.complete(value: T) {
+ if (isActive) resume(value)
+}
+
+private fun CancellableContinuation<*>.fail(message: String) {
+ if (isActive) resumeWith(Result.failure(ToolError(message)))
+}
+
+suspend fun XSuspendContext.stacks(): List = callback("threads") { cont ->
+ val all = mutableListOf()
+ computeExecutionStacks(object : XSuspendContext.XExecutionStackContainer {
+ override fun addExecutionStack(stacks: List, last: Boolean) {
+ all += stacks
+ if (last) cont.complete(all.toList())
+ }
+
+ override fun errorOccurred(errorMessage: String) = cont.fail(errorMessage)
+ override fun isObsolete() = !cont.isActive
+ })
+}
+
+suspend fun XExecutionStack.frames(limit: Int): List = callback("stackTrace") { cont ->
+ val all = mutableListOf()
+ computeStackFrames(0, object : XExecutionStack.XStackFrameContainer {
+ override fun addStackFrames(frames: List, last: Boolean) {
+ all += frames
+ if (last || all.size >= limit) cont.complete(all.take(limit))
+ }
+
+ override fun errorOccurred(errorMessage: String) = cont.fail(errorMessage)
+ override fun isObsolete() = !cont.isActive
+ })
+}
+
+// A container's children: values first, then groups (e.g. "Static"),
+// which hold more values.
+class Children(val values: List>, val groups: List>)
+
+suspend fun XValueContainer.children(): Children = callback("variables") { cont ->
+ val values = mutableListOf>()
+ val groups = mutableListOf>()
+ computeChildren(object : XCompositeNode {
+ override fun addChildren(children: XValueChildrenList, last: Boolean) {
+ for (group in children.topGroups) groups += group.name to group
+ for (i in 0 until children.size()) values += children.getName(i) to children.getValue(i)
+ for (group in children.bottomGroups) groups += group.name to group
+ if (last) cont.complete(Children(values.toList(), groups.toList()))
+ }
+
+ @Suppress("OVERRIDE_DEPRECATION")
+ override fun tooManyChildren(remaining: Int) = cont.complete(Children(values.toList(), groups.toList()))
+ override fun tooManyChildren(remaining: Int, addNextChildren: Runnable) = tooManyChildren(remaining)
+ override fun setAlreadySorted(alreadySorted: Boolean) {}
+ override fun setErrorMessage(errorMessage: String) = cont.fail(errorMessage)
+ override fun setErrorMessage(errorMessage: String, link: XDebuggerTreeNodeHyperlink?) = cont.fail(errorMessage)
+ override fun setMessage(
+ message: String, icon: Icon?, attributes: com.intellij.ui.SimpleTextAttributes,
+ link: XDebuggerTreeNodeHyperlink?,
+ ) {}
+ override fun isObsolete() = !cont.isActive
+ })
+}
+
+data class Shown(val value: String, val type: String?, val hasChildren: Boolean)
+
+// What the Variables view would show for a value.
+suspend fun XValue.shown(): Shown = callback("the value") { cont ->
+ computePresentation(object : XValueNode {
+ override fun setPresentation(icon: Icon?, type: @NlsSafe String?, value: @NlsSafe String, hasChildren: Boolean) {
+ if (!value.startsWith(COLLECTING)) cont.complete(Shown(value, type?.ifEmpty { null }, hasChildren))
+ }
+
+ override fun setPresentation(icon: Icon?, presentation: XValuePresentation, hasChildren: Boolean) {
+ val text = XValuePresentationUtil.computeValueText(presentation)
+ if (!text.startsWith(COLLECTING)) cont.complete(Shown(text, presentation.type?.ifEmpty { null }, hasChildren))
+ }
+
+ override fun setFullValueEvaluator(fullValueEvaluator: XFullValueEvaluator) {}
+ override fun isObsolete() = !cont.isActive
+ }, XValuePlace.TREE)
+}
+
+suspend fun XDebuggerEvaluator.evaluated(expression: String, position: XSourcePosition?): XValue =
+ callback("the evaluation") { cont ->
+ val expr = XDebuggerUtil.getInstance().createExpression(expression, null, null, EvaluationMode.EXPRESSION)
+ evaluate(expr, object : XDebuggerEvaluator.XEvaluationCallback {
+ override fun evaluated(result: XValue) = cont.complete(result)
+ override fun errorOccurred(errorMessage: String) = cont.fail(errorMessage)
+ }, position)
+ }
+
+suspend fun XValueModifier.changeTo(value: String) = callback("the change") { cont ->
+ val expr = XDebuggerUtil.getInstance().createExpression(value, null, null, EvaluationMode.EXPRESSION)
+ setValue(expr, object : XValueModifier.XModificationCallback {
+ override fun valueModified() = cont.complete(Unit)
+ override fun errorOccurred(errorMessage: String) = cont.fail(errorMessage)
+ })
+}
+
+// The frame's name as the Frames view shows it, e.g. "add:7, App".
+fun XStackFrame.title(): String {
+ val text = SimpleColoredText()
+ customizePresentation(text)
+ return text.toString()
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/BreakpointExtras.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/BreakpointExtras.kt
new file mode 100644
index 0000000..1e013fb
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/BreakpointExtras.kt
@@ -0,0 +1,116 @@
+// Hit conditions and log messages of line breakpoints. The IDE's debugger
+// platform has neither in a form every language supports, so Blackbox keeps
+// them and applies them itself when a breakpoint pauses (DebugEvents).
+//
+// They are kept per breakpoint object, so they follow the breakpoint when
+// edits move it, and saved in the workspace file by file and line.
+
+package dev.blackbox.debug
+
+import com.intellij.openapi.components.PersistentStateComponent
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.State
+import com.intellij.openapi.components.Storage
+import com.intellij.openapi.components.StoragePathMacros
+import com.intellij.openapi.components.service
+import com.intellij.openapi.project.Project
+import com.intellij.xdebugger.XDebuggerManager
+import com.intellij.xdebugger.breakpoints.XBreakpoint
+import com.intellij.xdebugger.breakpoints.XLineBreakpoint
+import java.util.WeakHashMap
+
+data class Extra(val hitCondition: String?, val logMessage: String?) {
+ val isEmpty get() = hitCondition == null && logMessage == null
+}
+
+@Service(Service.Level.PROJECT)
+@State(name = "BlackboxBreakpoints", storages = [Storage(StoragePathMacros.WORKSPACE_FILE)])
+class BreakpointExtras(private val project: Project) : PersistentStateComponent {
+ class SavedEntry {
+ var url: String = ""
+ var line: Int = 0
+ var hitCondition: String? = null
+ var logMessage: String? = null
+ }
+
+ class Saved {
+ var entries: MutableList = mutableListOf()
+ }
+
+ private val extras = WeakHashMap, Extra>()
+ private var unresolved: List = emptyList()
+
+ @Synchronized
+ fun get(breakpoint: XBreakpoint<*>): Extra? {
+ resolveSaved()
+ return extras[breakpoint]
+ }
+
+ @Synchronized
+ fun set(breakpoint: XLineBreakpoint<*>, extra: Extra) {
+ resolveSaved()
+ if (extra.isEmpty) extras.remove(breakpoint) else extras[breakpoint] = extra
+ }
+
+ @Synchronized
+ fun remove(breakpoint: XBreakpoint<*>) {
+ extras.remove(breakpoint)
+ }
+
+ // Matches entries loaded from the workspace file to breakpoints, once the
+ // IDE has loaded those.
+ private fun resolveSaved() {
+ if (unresolved.isEmpty()) return
+ val manager = XDebuggerManager.getInstance(project).breakpointManager
+ val lines = manager.allBreakpoints.filterIsInstance>()
+ for (entry in unresolved) {
+ lines.firstOrNull { it.fileUrl == entry.url && it.line == entry.line }?.let {
+ extras[it] = Extra(entry.hitCondition, entry.logMessage)
+ }
+ }
+ unresolved = emptyList()
+ }
+
+ @Synchronized
+ override fun getState(): Saved = Saved().also { saved ->
+ resolveSaved()
+ saved.entries = extras.entries.mapTo(mutableListOf()) { (bp, extra) ->
+ SavedEntry().apply {
+ url = bp.fileUrl
+ line = bp.line
+ hitCondition = extra.hitCondition
+ logMessage = extra.logMessage
+ }
+ }
+ }
+
+ @Synchronized
+ override fun loadState(state: Saved) {
+ unresolved = state.entries.toList()
+ }
+
+ companion object {
+ fun of(project: Project): BreakpointExtras = project.service()
+ }
+}
+
+// Hit conditions as the tool contract describes them: "5" (the 5th hit),
+// ">= 10", "> 3", "< 3", "<= 3", "== 5", "% 3" (every 3rd).
+object HitCondition {
+ private val PATTERN = Regex("""^\s*(>=|<=|==|>|<|%)?\s*(\d+)\s*$""")
+
+ fun isValid(condition: String): Boolean = PATTERN.matches(condition)
+
+ fun matches(condition: String, hits: Int): Boolean {
+ val match = PATTERN.matchEntire(condition) ?: return true
+ val n = match.groupValues[2].toInt()
+ return when (match.groupValues[1]) {
+ ">=" -> hits >= n
+ "<=" -> hits <= n
+ ">" -> hits > n
+ "<" -> hits < n
+ "%" -> n > 0 && hits % n == 0
+ else -> hits == n
+ }
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/DebugEvents.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/DebugEvents.kt
new file mode 100644
index 0000000..44bc96a
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/DebugEvents.kt
@@ -0,0 +1,294 @@
+// Follows the project's debug sessions so tools can wait for the debugger to
+// pause and read program output (port of editors/vscode/src/tools/impl/
+// debugEvents.ts and blackbox.nvim's events.lua).
+//
+// It also applies Blackbox's hit conditions and logpoints (BreakpointExtras):
+// such a breakpoint pauses the program, and here it is counted or its
+// message logged, and the program resumed, before anyone sees the pause.
+
+package dev.blackbox.debug
+
+import com.intellij.execution.process.ProcessEvent
+import com.intellij.execution.process.ProcessListener
+import com.intellij.execution.process.ProcessOutputTypes
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.application.readAction
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.service
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Key
+import com.intellij.xdebugger.XDebugProcess
+import com.intellij.xdebugger.XDebugSession
+import com.intellij.xdebugger.XDebugSessionListener
+import com.intellij.xdebugger.XDebuggerManager
+import com.intellij.xdebugger.XDebuggerManagerListener
+import com.intellij.xdebugger.XSourcePosition
+import com.intellij.xdebugger.breakpoints.XBreakpoint
+import com.intellij.xdebugger.breakpoints.XBreakpointListener
+import com.intellij.xdebugger.breakpoints.XLineBreakpoint
+import dev.blackbox.lang.LanguageSupport
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicLong
+
+data class Stop(val reason: String, val threadId: Int?, val description: String? = null)
+
+sealed interface WaitResult {
+ data class Stopped(val session: XDebugSession, val stop: Stop) : WaitResult
+ data object Timeout : WaitResult
+ data object Terminated : WaitResult
+}
+
+class OutputEntry(val seq: Long, val session: String, val category: String, val text: String)
+
+@Service(Service.Level.PROJECT)
+class DebugEvents(private val project: Project, private val scope: CoroutineScope) : Disposable {
+ val handles = Handles()
+
+ private class Pause(val stop: Stop, val order: Long)
+
+ private class Tracked(val session: XDebugSession) {
+ @Volatile var pause: Pause? = null
+ @Volatile var lastStop: Stop? = null
+ // What Blackbox last asked this session to do ("step", "pause", "goto"),
+ // to name the next pause; null for a pause the program ran into.
+ @Volatile var action: String? = null
+ val hits = ConcurrentHashMap, Int>()
+ }
+
+ private val tracked = ConcurrentHashMap()
+ private val waiters = ConcurrentHashMap.newKeySet>()
+ private val stopCounter = AtomicLong()
+
+ private val output = ArrayDeque()
+ private var outputSeq = 0L
+
+ init {
+ val connection = project.messageBus.connect(this)
+ connection.subscribe(XDebuggerManager.TOPIC, object : XDebuggerManagerListener {
+ // The session doesn't know its process yet at this point.
+ override fun processStarted(process: XDebugProcess) = track(process.session, process)
+ override fun processStopped(process: XDebugProcess) = ended(process.session)
+ })
+ connection.subscribe(XBreakpointListener.TOPIC, object : XBreakpointListener> {
+ override fun breakpointRemoved(breakpoint: XBreakpoint<*>) = BreakpointExtras.of(project).remove(breakpoint)
+ })
+ XDebuggerManager.getInstance(project).debugSessions.forEach { track(it, it.debugProcess) }
+ }
+
+ private fun track(session: XDebugSession, process: XDebugProcess) {
+ if (tracked.putIfAbsent(session, Tracked(session)) != null) return
+ session.addSessionListener(object : XDebugSessionListener {
+ override fun sessionPaused() {
+ scope.launch { onPaused(session) }
+ }
+
+ override fun sessionResumed() = resumed(session)
+ override fun sessionStopped() = ended(session)
+ }, this)
+ process.processHandler?.addProcessListener(object : ProcessListener {
+ override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
+ val category = when (outputType) {
+ ProcessOutputTypes.STDOUT -> "stdout"
+ ProcessOutputTypes.STDERR -> "stderr"
+ else -> "console"
+ }
+ addOutput(session, category, event.text)
+ }
+ }, this)
+ }
+
+ // The next pause of this session comes from Blackbox's own request.
+ fun expect(session: XDebugSession, action: String) {
+ tracked[session]?.action = action
+ }
+
+ private suspend fun onPaused(session: XDebugSession) {
+ val t = tracked[session] ?: return
+ val action = t.action.also { t.action = null }
+ val position = session.currentPosition
+ val breakpoint = position?.let { lineBreakpointAt(it) }
+ val extra = breakpoint?.let { BreakpointExtras.of(project).get(it) }
+
+ if (action != null && extra?.logMessage != null) {
+ // A step that lands on a logpoint logs it, as debug adapters do.
+ addOutput(session, "console", render(session, extra.logMessage) + "\n")
+ } else if (action == null && breakpoint != null && extra != null) {
+ val hits = t.hits.merge(breakpoint, 1, Int::plus)!!
+ if (extra.hitCondition != null && !HitCondition.matches(extra.hitCondition, hits)) {
+ return resume(session)
+ }
+ if (extra.logMessage != null) {
+ addOutput(session, "console", render(session, extra.logMessage) + "\n")
+ return resume(session)
+ }
+ }
+
+ val stack = session.suspendContext?.activeExecutionStack
+ val threadId = stack?.let { handles.threadIds(session, listOf(it)).first() }
+ val exception = activeNonLineBreakpoint(session)
+ val reason = action ?: when {
+ exception != null -> "exception"
+ breakpoint != null -> "breakpoint"
+ else -> "pause"
+ }
+ val stop = Stop(reason, threadId, exception?.let { describeBreakpoint(it) })
+ t.lastStop = stop
+ t.pause = Pause(stop, stopCounter.incrementAndGet())
+ notify(WaitResult.Stopped(session, stop))
+ }
+
+ // The exception (or other non-line) breakpoint the session paused on. The
+ // platform only offers this on its session implementation, so it is
+ // looked up by name and left out if that ever changes.
+ private fun activeNonLineBreakpoint(session: XDebugSession): XBreakpoint<*>? = runCatching {
+ session.javaClass.getMethod("getActiveNonLineBreakpoint").invoke(session) as XBreakpoint<*>?
+ }.getOrNull()
+
+ private fun describeBreakpoint(breakpoint: XBreakpoint<*>): String? = runCatching {
+ @Suppress("UNCHECKED_CAST")
+ (breakpoint.type as com.intellij.xdebugger.breakpoints.XBreakpointType, *>).getDisplayText(breakpoint)
+ }.getOrNull()
+
+ private suspend fun lineBreakpointAt(position: XSourcePosition): XLineBreakpoint<*>? = readAction {
+ XDebuggerManager.getInstance(project).breakpointManager.allBreakpoints
+ .filterIsInstance>()
+ .firstOrNull { it.isEnabled && it.line == position.line && it.fileUrl == position.file.url }
+ }
+
+ // A logpoint message with its {expression} parts evaluated in the paused frame.
+ private suspend fun render(session: XDebugSession, template: String): String {
+ val frame = session.currentStackFrame
+ val evaluator = frame?.evaluator ?: return template
+ return LOG_EXPRESSION.replace(template) { "\u0000${it.groupValues[1]}\u0000" }
+ .split('\u0000')
+ .mapIndexed { i, part ->
+ if (i % 2 == 0) part else try {
+ evaluator.evaluated(part, frame.sourcePosition).shown().value.removeSurrounding("\"")
+ } catch (e: Exception) {
+ ""
+ }
+ }
+ .joinToString("")
+ }
+
+ private suspend fun resume(session: XDebugSession) {
+ resumed(session)
+ withContext(Dispatchers.EDT) { if (!session.isStopped) session.resume() }
+ }
+
+ private fun resumed(session: XDebugSession) {
+ tracked[session]?.pause = null
+ handles.resumed(session)
+ }
+
+ private fun ended(session: XDebugSession) {
+ if (tracked.remove(session) == null) return
+ handles.ended(session)
+ if (!active()) notify(WaitResult.Terminated)
+ }
+
+ private fun notify(result: WaitResult) {
+ for (waiter in waiters.toList()) {
+ if (waiters.remove(waiter)) waiter.complete(result)
+ }
+ }
+
+ fun liveSessions(): List = tracked.keys.filter { !it.isStopped }
+
+ // A session runs, or the IDE listens for one (PHP): a pause may still come.
+ private fun active(): Boolean = liveSessions().isNotEmpty() || LanguageSupport.anyListening(project)
+
+ fun isPaused(session: XDebugSession): Boolean = tracked[session]?.pause != null && !session.isStopped
+
+ class Current(val session: XDebugSession, val stop: Stop)
+
+ // The most recent pause still in effect, across sessions.
+ fun currentStop(): Current? = tracked.values
+ .filter { !it.session.isStopped }
+ .mapNotNull { t -> t.pause?.let { t to it } }
+ .maxByOrNull { it.second.order }
+ ?.let { Current(it.first.session, it.second.stop) }
+
+ // The most recent pause in effect in this session, else the last one seen.
+ fun lastStop(session: XDebugSession): Stop? = tracked[session]?.let { it.pause?.stop ?: it.lastStop }
+
+ // Starts listening now, before a request is sent, so a fast pause is not
+ // missed; await() then waits for it.
+ inner class Waiter internal constructor() {
+ private val result = CompletableDeferred()
+
+ init {
+ if (!active()) result.complete(WaitResult.Terminated) else waiters += result
+ }
+
+ suspend fun await(timeoutMs: Long): WaitResult =
+ withTimeoutOrNull(timeoutMs) { result.await() } ?: WaitResult.Timeout.also { waiters -= result }
+ }
+
+ fun arm(): Waiter = Waiter()
+
+ // Returns the current pause at once, unless `next` asks for a new one.
+ suspend fun waitForStop(timeoutMs: Long, next: Boolean): WaitResult {
+ if (!next) currentStop()?.let { return WaitResult.Stopped(it.session, it.stop) }
+ return arm().await(timeoutMs)
+ }
+
+ // The session tools act on: the focused one if it is paused, otherwise
+ // the most recently paused one, otherwise the focused one.
+ fun findSession(): XDebugSession? {
+ val current = XDebuggerManager.getInstance(project).currentSession?.takeIf { !it.isStopped }
+ if (current != null && isPaused(current)) return current
+ return currentStop()?.session ?: current ?: liveSessions().firstOrNull()
+ }
+
+ private fun addOutput(session: XDebugSession, category: String, text: String) = synchronized(output) {
+ output.addLast(OutputEntry(++outputSeq, session.sessionName, category, text))
+ if (output.size > MAX_OUTPUT) output.removeFirst()
+ }
+
+ class OutputPage(val entries: List, val nextSince: Long, val more: Boolean)
+
+ fun readOutput(since: Long, category: String?, match: String?, limit: Int?): OutputPage = synchronized(output) {
+ val needle = match?.lowercase()
+ val matching = output.filter {
+ it.seq > since && (category == null || it.category == category) &&
+ (needle == null || it.text.lowercase().contains(needle))
+ }
+ val max = maxOf(1, limit ?: DEFAULT_OUTPUT_LIMIT)
+ val entries = mutableListOf()
+ var budget = MAX_OUTPUT_CHARS
+ for (e in matching) {
+ val text = truncate(e.text, MAX_ENTRY_CHARS)
+ if (entries.size >= max || (entries.isNotEmpty() && text.length > budget)) break
+ entries += OutputEntry(e.seq, e.session, e.category, text)
+ budget -= text.length
+ }
+ val more = entries.size < matching.size
+ OutputPage(entries, if (more) entries.last().seq else maxOf(since, outputSeq), more)
+ }
+
+ private fun truncate(text: String, max: Int) =
+ if (text.length <= max) text else "${text.take(max)}… (${text.length - max} more characters)"
+
+ override fun dispose() {
+ notify(WaitResult.Terminated)
+ }
+
+ companion object {
+ private const val MAX_OUTPUT = 1000
+ private const val DEFAULT_OUTPUT_LIMIT = 200
+ // Some debuggers log a lot; keep responses small.
+ private const val MAX_ENTRY_CHARS = 2000
+ private const val MAX_OUTPUT_CHARS = 50_000
+ private val LOG_EXPRESSION = Regex("""\{([^{}]+)\}""")
+
+ fun of(project: Project): DebugEvents = project.service()
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/Handles.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/Handles.kt
new file mode 100644
index 0000000..6a97344
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/debug/Handles.kt
@@ -0,0 +1,54 @@
+// Numeric ids for threads, frames and values, as DAP gives them: the AI
+// refers back to them in later calls. Thread ids stay stable for a session
+// (by thread name); frame and value ids are valid until the program resumes.
+
+package dev.blackbox.debug
+
+import com.intellij.xdebugger.XDebugSession
+import com.intellij.xdebugger.frame.XExecutionStack
+import com.intellij.xdebugger.frame.XStackFrame
+import com.intellij.xdebugger.frame.XValueContainer
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.atomic.AtomicInteger
+
+class Handles {
+ private val nextId = AtomicInteger(1)
+ private val threadIds = ConcurrentHashMap>()
+ private val frames = ConcurrentHashMap>()
+ private val values = ConcurrentHashMap>()
+
+ // Debuggers create new stack objects on every pause; the thread's name is
+ // what stays the same. Same-named threads are told apart by position.
+ fun threadIds(session: XDebugSession, stacks: List): List {
+ val ids = threadIds.computeIfAbsent(session) { ConcurrentHashMap() }
+ val seen = mutableMapOf()
+ return stacks.map { stack ->
+ val n = seen.merge(stack.displayName, 1, Int::plus)!!
+ val key = if (n == 1) stack.displayName else "${stack.displayName}#$n"
+ ids.computeIfAbsent(key) { nextId.getAndIncrement() }
+ }
+ }
+
+ fun frameId(session: XDebugSession, frame: XStackFrame): Int {
+ frames.entries.firstOrNull { it.value.second === frame }?.let { return it.key }
+ return nextId.getAndIncrement().also { frames[it] = session to frame }
+ }
+
+ fun frame(id: Int): Pair? = frames[id]
+
+ fun valueId(session: XDebugSession, value: XValueContainer): Int =
+ nextId.getAndIncrement().also { values[it] = session to value }
+
+ fun value(id: Int): Pair? = values[id]
+
+ // The program moved on: its frames and values are gone.
+ fun resumed(session: XDebugSession) {
+ frames.values.removeIf { it.first === session }
+ values.values.removeIf { it.first === session }
+ }
+
+ fun ended(session: XDebugSession) {
+ resumed(session)
+ threadIds.remove(session)
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/BridgeServer.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/BridgeServer.kt
new file mode 100644
index 0000000..bf8b7c2
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/BridgeServer.kt
@@ -0,0 +1,246 @@
+// One project window's side of the Blackbox bridge (mirrors IPCServer in
+// editors/vscode/src/ipc/server.ts): a socket the MCP server connects to,
+// plus a registry entry describing the window. Requests are newline-
+// delimited JSON: {id, tool, args} -> {id, result} | {id, error}.
+
+package dev.blackbox.ipc
+
+import com.google.gson.JsonObject
+import com.google.gson.JsonParseException
+import com.intellij.openapi.diagnostic.logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.net.StandardProtocolFamily
+import java.net.UnixDomainSocketAddress
+import java.nio.ByteBuffer
+import java.nio.channels.ServerSocketChannel
+import java.nio.channels.SocketChannel
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.attribute.BasicFileAttributes
+import java.util.concurrent.ConcurrentHashMap
+
+typealias ToolHandler = suspend (args: JsonObject) -> String
+
+// Thrown by handlers for errors the AI should see as-is.
+class ToolError(message: String) : Exception(message)
+
+class BridgeServer(
+ private val scope: CoroutineScope,
+ private val id: String,
+ folders: List,
+ app: AppInfo,
+ extensionVersion: String,
+ private val handlers: Map,
+ private val dir: Path = Protocol.ipcDir(),
+ private val useTcp: Boolean = Protocol.isWindows,
+ private val healthCheckMs: Long = HEALTH_CHECK_MS,
+) {
+ private val pid = ProcessHandle.current().pid()
+ private val now = System.currentTimeMillis()
+ private val token = if (useTcp) Protocol.randomHex(32) else null
+
+ @Volatile
+ var entry = RegistryEntry(
+ id = id,
+ pid = pid,
+ socket = if (useTcp) "" else Protocol.socketPath(dir, id).toString(),
+ folders = folders,
+ startedAt = now,
+ focusedAt = now,
+ app = app,
+ // An IDE is one process for all its project windows.
+ appPid = pid,
+ extensionVersion = extensionVersion,
+ )
+ private set
+
+ private var channel: ServerSocketChannel? = null
+ private var socketKey: Any? = null // file identity of our socket, to notice it being replaced
+ private val connections = ConcurrentHashMap.newKeySet()
+ private var acceptJob: Job? = null
+ private var healthJob: Job? = null
+ private val registryLock = Mutex()
+
+ @Volatile
+ private var disposed = false
+
+ fun start() {
+ Protocol.ensurePrivateDir(dir)
+ listen()
+ writeRegistry()
+ healthJob = scope.launch(Dispatchers.IO) {
+ while (isActive) {
+ delay(healthCheckMs)
+ checkHealth()
+ }
+ }
+ }
+
+ private fun listen() {
+ val server: ServerSocketChannel
+ if (useTcp) {
+ server = ServerSocketChannel.open(StandardProtocolFamily.INET)
+ server.bind(InetSocketAddress(InetAddress.getLoopbackAddress(), 0))
+ val port = (server.localAddress as InetSocketAddress).port
+ entry = entry.copy(socket = "tcp://127.0.0.1:$port/$token")
+ } else {
+ val path = Path.of(entry.socket)
+ Files.deleteIfExists(path) // Leftover of a previous IDE with this id.
+ server = ServerSocketChannel.open(StandardProtocolFamily.UNIX)
+ server.bind(UnixDomainSocketAddress.of(path))
+ socketKey = fileKey(path)
+ }
+ channel = server
+ acceptJob = scope.launch(Dispatchers.IO) {
+ while (isActive) {
+ val client = try {
+ server.accept()
+ } catch (_: Exception) {
+ break // Closed.
+ }
+ connections += client
+ launch { serve(client) }
+ }
+ }
+ }
+
+ // Reads and writes the channel directly: the java.nio.channels.Channels
+ // stream adapters would make a write wait for a blocked read.
+ private suspend fun serve(client: SocketChannel) {
+ val writeLock = Mutex()
+ val buffer = ByteBuffer.allocate(64 * 1024)
+ val pending = java.io.ByteArrayOutputStream()
+ try {
+ while (client.read(buffer.clear()) >= 0) {
+ buffer.flip()
+ while (buffer.hasRemaining()) {
+ val b = buffer.get()
+ if (b == NEWLINE) {
+ val line = pending.toString(Charsets.UTF_8)
+ pending.reset()
+ scope.launch { handle(line, client, writeLock) }
+ } else {
+ pending.write(b.toInt())
+ }
+ }
+ }
+ } catch (_: Exception) {
+ // Client went away.
+ } finally {
+ connections -= client
+ }
+ }
+
+ private fun sameToken(given: String?, expected: String) =
+ given != null && java.security.MessageDigest.isEqual(given.toByteArray(), expected.toByteArray())
+
+ private suspend fun handle(line: String, client: SocketChannel, writeLock: Mutex) {
+ val request = try {
+ Protocol.gson.fromJson(line, IpcRequest::class.java) ?: return
+ } catch (_: JsonParseException) {
+ return
+ }
+ val response = when {
+ token != null && !sameToken(request.token, token) -> IpcResponse(request.id, error = "invalid token")
+ else -> {
+ val handler = handlers[request.tool]
+ if (handler == null) {
+ IpcResponse(request.id, error = "unknown tool: ${request.tool}")
+ } else {
+ try {
+ IpcResponse(request.id, result = handler(request.args ?: JsonObject()))
+ } catch (e: ToolError) {
+ IpcResponse(request.id, error = e.message)
+ } catch (e: kotlinx.coroutines.CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ LOG.warn("blackbox: ${request.tool} failed", e)
+ IpcResponse(request.id, error = e.message ?: e.toString())
+ }
+ }
+ }
+ }
+ writeLock.withLock {
+ try {
+ val data = ByteBuffer.wrap((Protocol.gson.toJson(response) + "\n").toByteArray(Charsets.UTF_8))
+ while (data.hasRemaining()) client.write(data)
+ } catch (_: Exception) {
+ // The client hung up (e.g. timed out).
+ }
+ }
+ }
+
+ private fun writeRegistry() {
+ if (disposed) return
+ try {
+ Protocol.writeAtomic(Protocol.registryPath(dir, id), Protocol.gson.toJson(entry))
+ } catch (e: Exception) {
+ LOG.warn("blackbox: could not write the registry entry", e)
+ }
+ }
+
+ suspend fun setFolders(folders: List) = registryLock.withLock {
+ if (folders == entry.folders) return@withLock
+ entry = entry.copy(folders = folders)
+ writeRegistry()
+ }
+
+ suspend fun markFocused() = registryLock.withLock {
+ entry = entry.copy(focusedAt = System.currentTimeMillis())
+ writeRegistry()
+ }
+
+ // The file's identity (inode); Windows has none, so its creation time.
+ private fun fileKey(path: Path): Any? = runCatching {
+ Files.readAttributes(path, BasicFileAttributes::class.java).let { it.fileKey() ?: it.creationTime() }
+ }.getOrNull()
+
+ private fun ownsSocket(): Boolean = useTcp || (socketKey != null && fileKey(Path.of(entry.socket)) == socketKey)
+
+ // Recreates the socket or registry file if something removed them.
+ private suspend fun checkHealth() = registryLock.withLock {
+ if (disposed) return@withLock
+ if (!Files.exists(Protocol.registryPath(dir, id))) writeRegistry()
+ if (ownsSocket()) return@withLock
+ closeChannel()
+ try {
+ listen()
+ } catch (e: Exception) {
+ LOG.warn("blackbox: could not restart the bridge", e)
+ }
+ }
+
+ // Stops listening and drops open connections, so the address is free at once.
+ private fun closeChannel() {
+ acceptJob?.cancel()
+ runCatching { channel?.close() }
+ channel = null
+ connections.forEach { runCatching { it.close() } }
+ connections.clear()
+ }
+
+ fun dispose() {
+ if (disposed) return
+ disposed = true
+ healthJob?.cancel()
+ val owned = ownsSocket()
+ closeChannel()
+ if (!useTcp && owned) runCatching { Files.deleteIfExists(Path.of(entry.socket)) }
+ runCatching { Files.deleteIfExists(Protocol.registryPath(dir, id)) }
+ }
+
+ companion object {
+ const val HEALTH_CHECK_MS = 5000L
+ private const val NEWLINE = '\n'.code.toByte()
+ private val LOG = logger()
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/ProjectBridge.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/ProjectBridge.kt
new file mode 100644
index 0000000..659ee5b
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/ProjectBridge.kt
@@ -0,0 +1,134 @@
+// One Blackbox "window" per open project: its bridge server, folders and
+// focus. Several projects share one IDE process, so the window id is the
+// IDE's pid plus a hash of the project's location.
+
+package dev.blackbox.ipc
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.ApplicationInfo
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.application.ApplicationNamesInfo
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.service
+import com.intellij.openapi.diagnostic.logger
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.project.guessProjectDir
+import com.intellij.openapi.roots.ModuleRootEvent
+import com.intellij.openapi.roots.ModuleRootListener
+import com.intellij.openapi.roots.ProjectRootManager
+import com.intellij.openapi.startup.ProjectActivity
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.openapi.wm.WindowManager
+import dev.blackbox.debug.DebugEvents
+import dev.blackbox.launcher.McpInstall
+import dev.blackbox.tools.Catalog
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.awt.event.WindowAdapter
+import java.awt.event.WindowEvent
+import java.nio.file.Path
+import java.security.MessageDigest
+import java.util.concurrent.atomic.AtomicBoolean
+
+@Service(Service.Level.PROJECT)
+class ProjectBridge(private val project: Project, private val scope: CoroutineScope) : Disposable {
+ val id: String = "${ProcessHandle.current().pid()}-${shortHash(project.basePath ?: project.locationHash)}"
+
+ @Volatile
+ private var server: BridgeServer? = null
+
+ @Volatile
+ private var focused = true
+
+ fun folders(): List = roots(project).map { real(it.path) }.distinct()
+
+ fun isFocused() = focused
+
+ suspend fun start() {
+ if (server != null) return
+ DebugEvents.of(project) // Follows sessions from now on.
+ val bridge = BridgeServer(
+ scope = scope,
+ id = id,
+ folders = folders(),
+ app = appInfo(),
+ extensionVersion = PLUGIN_VERSION,
+ handlers = Catalog.handlers(project),
+ )
+ try {
+ bridge.start()
+ } catch (e: Exception) {
+ LOG.warn("blackbox: could not start the MCP bridge for ${project.name}", e)
+ return
+ }
+ server = bridge
+
+ project.messageBus.connect(this).subscribe(ModuleRootListener.TOPIC, object : ModuleRootListener {
+ override fun rootsChanged(event: ModuleRootEvent) {
+ scope.launch { bridge.setFolders(folders()) }
+ }
+ })
+ withContext(Dispatchers.EDT) {
+ val frame = WindowManager.getInstance().getFrame(project) ?: return@withContext
+ focused = frame.isActive
+ frame.addWindowListener(object : WindowAdapter() {
+ override fun windowActivated(e: WindowEvent) {
+ focused = true
+ scope.launch { bridge.markFocused() }
+ }
+
+ override fun windowDeactivated(e: WindowEvent) {
+ focused = false
+ }
+ })
+ }
+ }
+
+ override fun dispose() {
+ server?.dispose()
+ server = null
+ }
+
+ companion object {
+ private val LOG = logger()
+
+ val PLUGIN_VERSION: String = ProjectBridge::class.java.getResourceAsStream("/blackbox-plugin-version.txt")
+ ?.use { it.readBytes().toString(Charsets.UTF_8).trim() } ?: "dev"
+
+ fun of(project: Project): ProjectBridge = project.service()
+
+ // Where the project's files are: its content roots, else its folder.
+ fun roots(project: Project): List =
+ ProjectRootManager.getInstance(project).contentRoots.filter { it.isInLocalFileSystem }.ifEmpty {
+ listOfNotNull(project.guessProjectDir())
+ }
+
+ private fun real(path: String) = runCatching { Path.of(path).toRealPath().toString() }.getOrDefault(path)
+
+ private fun appInfo() = AppInfo(
+ name = ApplicationNamesInfo.getInstance().fullProductName,
+ scheme = "jetbrains",
+ version = ApplicationInfo.getInstance().fullVersion,
+ )
+
+ private fun shortHash(text: String): String =
+ MessageDigest.getInstance("SHA-1").digest(text.toByteArray()).take(4).joinToString("") { "%02x".format(it) }
+ }
+}
+
+class BridgeStartup : ProjectActivity {
+ override suspend fun execute(project: Project) {
+ if (launcherChecked.compareAndSet(false, true) && !ApplicationManager.getApplication().isUnitTestMode) {
+ withContext(Dispatchers.IO) { McpInstall.install(ProjectBridge.PLUGIN_VERSION) }
+ }
+ ProjectBridge.of(project).start()
+ }
+
+ private companion object {
+ // Once per IDE run, by the first project opened.
+ val launcherChecked = AtomicBoolean(false)
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/Protocol.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/Protocol.kt
new file mode 100644
index 0000000..a994ccf
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/Protocol.kt
@@ -0,0 +1,120 @@
+// The Blackbox window protocol, shared with the VS Code extension
+// (editors/vscode/src/ipc/protocol.ts) and blackbox.nvim. Each editor window
+// listens on its own socket in a private per-user folder and describes
+// itself in a registry file next to it; the MCP server reads the registry to
+// find windows.
+//
+// /.sock the socket (Unix domain socket)
+// /.json the registry entry
+//
+// On Windows, Node can't connect to Java's AF_UNIX sockets (libuv only does
+// named pipes there), so windows listen on TCP loopback instead: the entry's
+// socket is "tcp://127.0.0.1:/" and every request carries the
+// token (see parseTcpSocket in protocol.ts).
+
+package dev.blackbox.ipc
+
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+import java.nio.file.attribute.PosixFilePermissions
+import java.security.SecureRandom
+
+object Protocol {
+ // Bumped when the registry entry or internal tools change shape.
+ const val VERSION = 2
+
+ // Internal tool the MCP server uses to describe windows.
+ const val STATUS_TOOL = "window_status"
+
+ val isWindows: Boolean = System.getProperty("os.name").startsWith("Windows")
+
+ val gson: Gson = GsonBuilder().disableHtmlEscaping().create()
+
+ // Same location rule as ipcDir() in protocol.ts: a fixed per-user folder,
+ // because editors and MCP servers often see different TMPDIRs.
+ fun ipcDir(env: Map = System.getenv()): Path {
+ env["BLACKBOX_IPC_DIR"]?.takeIf { it.isNotEmpty() }?.let { return Path.of(it) }
+ if (isWindows) {
+ return Path.of(env["TEMP"] ?: System.getProperty("java.io.tmpdir"), "blackbox")
+ }
+ return Path.of("/tmp/blackbox-${uid()}")
+ }
+
+ fun socketPath(dir: Path, id: String): Path = dir.resolve("$id.sock")
+
+ fun registryPath(dir: Path, id: String): Path = dir.resolve("$id.json")
+
+ fun uid(): Int = com.sun.security.auth.module.UnixSystem().uid.toInt()
+
+ // Error message unless dir is a real directory owned by this user and
+ // closed to everyone else (mirrors assertPrivateDir); null when it is fine.
+ fun privateDirProblem(dir: Path): String? {
+ if (isWindows) return null // %TEMP% is already per-user.
+ if (!Files.isDirectory(dir, LinkOption.NOFOLLOW_LINKS)) return "$dir is not a directory"
+ val owner = Files.getAttribute(dir, "unix:uid", LinkOption.NOFOLLOW_LINKS) as Int
+ if (owner != uid()) return "$dir is owned by another user"
+ val mode = Files.getAttribute(dir, "unix:mode", LinkOption.NOFOLLOW_LINKS) as Int
+ if (mode and "077".toInt(8) != 0) {
+ return "$dir is accessible to other users (mode ${Integer.toOctalString(mode and "777".toInt(8))})"
+ }
+ return null
+ }
+
+ // Creates the folder, or tightens one this user already owns; throws if
+ // another user could still reach it (mirrors ensurePrivateDir).
+ fun ensurePrivateDir(dir: Path) {
+ if (isWindows) {
+ Files.createDirectories(dir)
+ return
+ }
+ val private = PosixFilePermissions.fromString("rwx------")
+ if (!Files.exists(dir, LinkOption.NOFOLLOW_LINKS)) {
+ runCatching { Files.createDirectory(dir, PosixFilePermissions.asFileAttribute(private)) }
+ }
+ if (Files.isDirectory(dir, LinkOption.NOFOLLOW_LINKS) &&
+ Files.getAttribute(dir, "unix:uid", LinkOption.NOFOLLOW_LINKS) as Int == uid()
+ ) {
+ Files.setPosixFilePermissions(dir, private)
+ }
+ privateDirProblem(dir)?.let { throw IllegalStateException(it) }
+ }
+
+ // Writes then renames, so readers never see a half-written file.
+ fun writeAtomic(path: Path, content: String) {
+ val tmp = path.resolveSibling("${path.fileName}.${ProcessHandle.current().pid()}.tmp")
+ Files.writeString(tmp, content)
+ if (!isWindows) Files.setPosixFilePermissions(tmp, PosixFilePermissions.fromString("rw-------"))
+ Files.move(tmp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE)
+ }
+
+ fun randomHex(bytes: Int): String {
+ val data = ByteArray(bytes)
+ SecureRandom().nextBytes(data)
+ return data.joinToString("") { "%02x".format(it) }
+ }
+}
+
+// The editor a window belongs to.
+data class AppInfo(val name: String, val scheme: String, val version: String)
+
+// Field names and types match RegistryEntry in protocol.ts.
+data class RegistryEntry(
+ val id: String,
+ val pid: Long,
+ var socket: String,
+ var folders: List,
+ val startedAt: Long,
+ var focusedAt: Long,
+ val app: AppInfo,
+ val appPid: Long,
+ val extensionVersion: String,
+ val protocol: Int = Protocol.VERSION,
+)
+
+data class IpcRequest(val id: String?, val tool: String?, val args: com.google.gson.JsonObject?, val token: String?)
+
+data class IpcResponse(val id: String?, val result: String? = null, val error: String? = null)
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/TerminalWindowEnv.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/TerminalWindowEnv.kt
new file mode 100644
index 0000000..36a7c16
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/ipc/TerminalWindowEnv.kt
@@ -0,0 +1,21 @@
+// AI tools started in the IDE's terminal (claude, ...) inherit
+// BLACKBOX_WINDOW and so use this project's window (see BridgeSession in
+// editors/vscode/src/mcp/session.ts). Registered only when the terminal
+// plugin is present (terminal.xml).
+
+package dev.blackbox.ipc
+
+import com.intellij.openapi.project.Project
+import org.jetbrains.plugins.terminal.LocalTerminalCustomizer
+
+class TerminalWindowEnv : LocalTerminalCustomizer() {
+ override fun customizeCommandAndEnvironment(
+ project: Project,
+ workingDirectory: String?,
+ command: Array,
+ envs: MutableMap,
+ ): Array {
+ envs["BLACKBOX_WINDOW"] = ProjectBridge.of(project).id
+ return command
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/JavaSupport.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/JavaSupport.kt
new file mode 100644
index 0000000..2ffa901
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/JavaSupport.kt
@@ -0,0 +1,106 @@
+// Java (and other JVM languages debugged by the Java debugger): function
+// breakpoints are method breakpoints, exception filters are the "Any
+// exception" breakpoint's caught/uncaught switches.
+
+package dev.blackbox.lang
+
+import com.intellij.debugger.ui.breakpoints.JavaExceptionBreakpointType
+import com.intellij.debugger.ui.breakpoints.JavaMethodBreakpointType
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.project.Project
+import com.intellij.psi.JavaPsiFacade
+import com.intellij.openapi.project.ProjectLocator
+import com.intellij.psi.PsiClass
+import com.intellij.psi.PsiManager
+import com.intellij.psi.PsiMethod
+import com.intellij.psi.util.PsiTreeUtil
+import com.intellij.psi.search.GlobalSearchScope
+import com.intellij.psi.search.PsiShortNamesCache
+import com.intellij.xdebugger.XDebuggerManager
+import com.intellij.xdebugger.XDebuggerUtil
+import com.intellij.xdebugger.breakpoints.XBreakpoint
+import com.intellij.xdebugger.breakpoints.XLineBreakpointType
+import org.jetbrains.java.debugger.breakpoints.properties.JavaExceptionBreakpointProperties
+import org.jetbrains.java.debugger.breakpoints.properties.JavaMethodBreakpointProperties
+
+class JavaSupport : LanguageSupport {
+ override fun addFunctionBreakpoint(project: Project, name: String): XBreakpoint<*>? {
+ val className = name.substringBeforeLast('.', "")
+ val methodName = name.substringAfterLast('.')
+ if (className.isEmpty()) return null // Java methods need their class: "App.add".
+ val method = findClasses(project, className)
+ .firstNotNullOfOrNull { it.findMethodsByName(methodName, false).firstOrNull() } ?: return null
+ val cls = method.containingClass ?: return null
+ val file = method.containingFile?.virtualFile ?: return null
+ val document = FileDocumentManager.getInstance().getDocument(file) ?: return null
+ val line = document.getLineNumber((method.nameIdentifier ?: method).textOffset)
+
+ val type = XDebuggerUtil.getInstance().findBreakpointType(JavaMethodBreakpointType::class.java)
+ @Suppress("UNCHECKED_CAST")
+ return XDebuggerManager.getInstance(project).breakpointManager.addLineBreakpoint(
+ type as XLineBreakpointType,
+ file.url,
+ line,
+ JavaMethodBreakpointProperties(cls.qualifiedName ?: className, methodName),
+ )
+ }
+
+ private fun findClasses(project: Project, name: String): List {
+ val scope = GlobalSearchScope.allScope(project)
+ JavaPsiFacade.getInstance(project).findClass(name, scope)?.let { return listOf(it) }
+ // A short name ("App") or a tail of the qualified one.
+ return PsiShortNamesCache.getInstance(project).getClassesByName(name.substringAfterLast('.'), scope)
+ .filter { cls -> cls.qualifiedName?.let { LanguageSupport.nameMatches(it, name) } == true }
+ }
+
+ override fun functionName(breakpoint: XBreakpoint<*>): String? {
+ val properties = breakpoint.properties as? JavaMethodBreakpointProperties ?: return null
+ if (breakpoint.type !is JavaMethodBreakpointType) return null
+ val className = properties.myClassPattern
+ val methodName = properties.myMethodName
+ if (className != null && methodName != null) return "$className.$methodName"
+ // The Java debugger refills these from the source in the background;
+ // until then, read them from the method on the breakpoint's line.
+ val method = methodAt(breakpoint) ?: return "$className.$methodName"
+ return "${method.containingClass?.qualifiedName}.${method.name}"
+ }
+
+ private fun methodAt(breakpoint: XBreakpoint<*>): PsiMethod? {
+ val position = breakpoint.sourcePosition ?: return null
+ val project = ProjectLocator.getInstance().guessProjectForFile(position.file) ?: return null
+ val document = FileDocumentManager.getInstance().getDocument(position.file) ?: return null
+ val psi = PsiManager.getInstance(project).findFile(position.file) ?: return null
+ if (position.line >= document.lineCount) return null
+ val start = document.getLineStartOffset(position.line)
+ val text = document.charsSequence
+ var offset = start
+ while (offset < document.getLineEndOffset(position.line) && text[offset].isWhitespace()) offset++
+ return PsiTreeUtil.getParentOfType(psi.findElementAt(offset), PsiMethod::class.java, false)
+ }
+
+ private fun anyException(project: Project): XBreakpoint? {
+ val type = XDebuggerUtil.getInstance().findBreakpointType(JavaExceptionBreakpointType::class.java)
+ return XDebuggerManager.getInstance(project).breakpointManager.getDefaultBreakpoints(type).firstOrNull()
+ }
+
+ override fun exceptionFilters(project: Project): List {
+ val any = anyException(project) ?: return emptyList()
+ val on = any.isEnabled
+ return listOf(
+ ExceptionFilter(CAUGHT, "Caught exceptions (Java)", on && any.properties.NOTIFY_CAUGHT),
+ ExceptionFilter(UNCAUGHT, "Uncaught exceptions (Java)", on && any.properties.NOTIFY_UNCAUGHT),
+ )
+ }
+
+ override fun setExceptionFilters(project: Project, filters: Collection) {
+ val any = anyException(project) ?: return
+ any.properties.NOTIFY_CAUGHT = CAUGHT in filters
+ any.properties.NOTIFY_UNCAUGHT = UNCAUGHT in filters
+ any.isEnabled = CAUGHT in filters || UNCAUGHT in filters
+ }
+
+ companion object {
+ const val CAUGHT = "java-caught"
+ const val UNCAUGHT = "java-uncaught"
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/LanguageSupport.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/LanguageSupport.kt
new file mode 100644
index 0000000..a980216
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/LanguageSupport.kt
@@ -0,0 +1,57 @@
+// What differs between languages: function breakpoints and exception
+// breakpoints are language-specific breakpoint types in JetBrains IDEs.
+// Implementations are registered only when their language plugin is present
+// (see java.xml), so one Blackbox plugin works in every IDE.
+
+package dev.blackbox.lang
+
+import com.intellij.openapi.extensions.ExtensionPointName
+import com.intellij.openapi.project.Project
+import com.intellij.xdebugger.breakpoints.XBreakpoint
+
+data class ExceptionFilter(val filter: String, val label: String, val enabled: Boolean)
+
+// A way to debug that waits for the program to connect instead of starting
+// it, e.g. PhpStorm's "Listen for PHP Debug Connections" (VS Code's "Listen
+// for Xdebug"). It shows as a launch configuration named `name`.
+interface DebugListener {
+ val name: String
+ val hint: String // What to do once it listens, for the AI.
+ fun isListening(project: Project): Boolean
+ fun setListening(project: Project, on: Boolean) // Called on the EDT.
+}
+
+interface LanguageSupport {
+ // Adds a breakpoint on the named function, or returns null if this
+ // language has no such function. Called on the EDT inside a write action.
+ fun addFunctionBreakpoint(project: Project, name: String): XBreakpoint<*>?
+
+ // The function name of a function breakpoint of this language, else null.
+ fun functionName(breakpoint: XBreakpoint<*>): String?
+
+ fun exceptionFilters(project: Project): List
+
+ // Called on the EDT.
+ fun setExceptionFilters(project: Project, filters: Collection)
+
+ fun listeners(): List = emptyList()
+
+ companion object {
+ val EP = ExtensionPointName("dev.blackbox.languageSupport")
+
+ fun all(): List = EP.extensionList
+
+ fun functionName(breakpoint: XBreakpoint<*>): String? = all().firstNotNullOfOrNull { it.functionName(breakpoint) }
+
+ fun listeners(): List = all().flatMap { it.listeners() }
+
+ // Whether a program may still connect, so waits must not end yet.
+ fun anyListening(project: Project): Boolean = listeners().any { it.isListening(project) }
+
+ // A function breakpoint on `name`: the full name, or its tail after a
+ // package or namespace ("App.add" matches "com.example.App.add",
+ // "Foo::bar" matches "App\\Foo::bar").
+ fun nameMatches(actual: String, wanted: String) =
+ actual == wanted || actual.endsWith(".$wanted") || actual.endsWith("\\$wanted")
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/PhpSupport.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/PhpSupport.kt
new file mode 100644
index 0000000..80a9199
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/lang/PhpSupport.kt
@@ -0,0 +1,113 @@
+// PHP in PhpStorm (and IntelliJ IDEA Ultimate with the PHP plugin): method
+// breakpoints, Xdebug exception breakpoints, and listening for incoming
+// Xdebug connections.
+//
+// Uses the PHP debugger's classes by name: they are only in IDEs with the
+// PHP plugin (registered through blackbox-php.xml), and recent PhpStorm
+// releases are built for a newer JVM than this plugin compiles against.
+
+package dev.blackbox.lang
+
+import com.intellij.openapi.project.Project
+import com.intellij.xdebugger.XDebuggerManager
+import com.intellij.xdebugger.breakpoints.XBreakpoint
+import com.intellij.xdebugger.breakpoints.XBreakpointProperties
+import com.intellij.xdebugger.breakpoints.XBreakpointType
+
+class PhpSupport : LanguageSupport {
+ @Suppress("UNCHECKED_CAST")
+ private fun type(className: String): XBreakpointType>, XBreakpointProperties<*>>? =
+ XBreakpointType.EXTENSION_POINT_NAME.extensionList.firstOrNull { it.javaClass.name == className }
+ as XBreakpointType>, XBreakpointProperties<*>>?
+
+ private fun call(target: Any, method: String, vararg args: Any?): Any? =
+ target.javaClass.methods.first { it.name == method && it.parameterCount == args.size }.invoke(target, *args)
+
+ // "Class::method", "Namespace\\Class::method", "Class->method" or "function".
+ override fun addFunctionBreakpoint(project: Project, name: String): XBreakpoint<*>? {
+ val type = type(METHOD_TYPE) ?: return null
+ val separator = listOf("::", "->").firstOrNull { it in name }
+ val className = separator?.let { name.substringBefore(it) }.orEmpty()
+ val methodName = separator?.let { name.substringAfter(it) } ?: name
+ if (methodName.isEmpty() || !methodName.all { it.isLetterOrDigit() || it == '_' }) return null
+ val properties = type.createProperties() ?: return null
+ call(properties, "setClassName", className)
+ call(properties, "setMethodName", methodName)
+ return XDebuggerManager.getInstance(project).breakpointManager.addBreakpoint(type, properties)
+ }
+
+ override fun functionName(breakpoint: XBreakpoint<*>): String? {
+ if (breakpoint.type.javaClass.name != METHOD_TYPE) return null
+ val properties = breakpoint.properties ?: return null
+ val className = call(properties, "getClassName") as String?
+ val methodName = call(properties, "getMethodName") as String? ?: return null
+ return if (className.isNullOrEmpty()) methodName else "$className::$methodName"
+ }
+
+ private fun exceptionBreakpoints(project: Project): Map> {
+ val type = type(EXCEPTION_TYPE) ?: return emptyMap()
+ return XDebuggerManager.getInstance(project).breakpointManager.getBreakpoints(type)
+ .mapNotNull { bp -> (bp.properties?.let { call(it, "getExceptionName") } as String?)?.let { it to bp } }
+ .toMap()
+ }
+
+ // The same filters as VS Code's PHP Debug extension; Xdebug matches them
+ // by name ("*" is everything).
+ override fun exceptionFilters(project: Project): List {
+ if (type(EXCEPTION_TYPE) == null) return emptyList()
+ val set = exceptionBreakpoints(project)
+ return FILTERS.map { (filter, label) -> ExceptionFilter(filter, label, set[filter]?.isEnabled == true) }
+ }
+
+ override fun setExceptionFilters(project: Project, filters: Collection) {
+ val type = type(EXCEPTION_TYPE) ?: return
+ val manager = XDebuggerManager.getInstance(project).breakpointManager
+ val existing = exceptionBreakpoints(project).filterKeys { it in FILTERS }
+ for ((name, bp) in existing) if (name !in filters) manager.removeBreakpoint(bp)
+ for (name in filters.filter { it in FILTERS }) {
+ val bp = existing[name]
+ if (bp != null) {
+ bp.isEnabled = true
+ continue
+ }
+ val properties = type.createProperties() ?: continue
+ call(properties, "setExceptionName", name)
+ manager.addBreakpoint(type, properties)
+ }
+ }
+
+ override fun listeners(): List = listOf(XdebugListener)
+
+ private object XdebugListener : DebugListener {
+ override val name = "Listen for PHP Debug Connections"
+ override val hint = "PhpStorm is listening for Xdebug. Trigger the code (e.g. a request with ?XDEBUG_TRIGGER=1, " +
+ "or a CLI script with XDEBUG_SESSION=1), then call debug_wait_for_stop. The first connection from a new " +
+ "server may show a dialog in PhpStorm to map its paths."
+
+ private fun accepter(project: Project): Any? = runCatching {
+ Class.forName(ACCEPTER, true, PhpSupport::class.java.classLoader)
+ .getMethod("getInstance", Project::class.java).invoke(null, project)
+ }.getOrNull()
+
+ override fun isListening(project: Project): Boolean =
+ accepter(project)?.let { it.javaClass.getMethod("isStarted").invoke(it) as Boolean } == true
+
+ override fun setListening(project: Project, on: Boolean) {
+ val accepter = accepter(project) ?: return
+ if (isListening(project) != on) accepter.javaClass.getMethod("doSwitch").invoke(accepter)
+ }
+ }
+
+ private companion object {
+ const val METHOD_TYPE = "com.jetbrains.php.debug.common.PhpMethodBreakpointType"
+ const val EXCEPTION_TYPE = "com.jetbrains.php.debug.common.PhpExceptionBreakpointType"
+ const val ACCEPTER = "com.jetbrains.php.debug.listener.PhpDebugExternalConnectionsAccepter"
+ val FILTERS = linkedMapOf(
+ "Notice" to "Notices",
+ "Warning" to "Warnings",
+ "Error" to "Errors",
+ "Exception" to "Exceptions",
+ "*" to "Everything",
+ )
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/launcher/CopyMcpConfigAction.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/launcher/CopyMcpConfigAction.kt
new file mode 100644
index 0000000..e934920
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/launcher/CopyMcpConfigAction.kt
@@ -0,0 +1,37 @@
+// Tools | Blackbox: Copy MCP Config — the entry to add under "mcpServers"
+// in an MCP client's config (same as the VS Code command).
+
+package dev.blackbox.launcher
+
+import com.intellij.notification.NotificationGroupManager
+import com.intellij.notification.NotificationType
+import com.intellij.openapi.actionSystem.ActionUpdateThread
+import com.intellij.openapi.actionSystem.AnAction
+import com.intellij.openapi.actionSystem.AnActionEvent
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.ide.CopyPasteManager
+import com.intellij.openapi.project.DumbAware
+import java.awt.datatransfer.StringSelection
+
+class CopyMcpConfigAction : AnAction(), DumbAware {
+ override fun getActionUpdateThread() = ActionUpdateThread.BGT
+
+ override fun actionPerformed(e: AnActionEvent) {
+ val project = e.project
+ // Finding node runs the login shell; keep it off the UI thread.
+ ApplicationManager.getApplication().executeOnPooledThread {
+ val config = mapOf("blackbox" to mapOf("command" to McpInstall.findNode(), "args" to listOf(McpInstall.launcher().toString())))
+ val text = com.google.gson.GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create().toJson(config)
+ ApplicationManager.getApplication().invokeLater {
+ CopyPasteManager.getInstance().setContents(StringSelection(text))
+ NotificationGroupManager.getInstance().getNotificationGroup("Blackbox")
+ .createNotification(
+ "Blackbox MCP server configuration copied",
+ "Add it under \"mcpServers\" in your MCP client's config (for Claude Code: ~/.claude.json), then reconnect it.",
+ NotificationType.INFORMATION,
+ )
+ .notify(project)
+ }
+ }
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/launcher/McpInstall.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/launcher/McpInstall.kt
new file mode 100644
index 0000000..4d13ea8
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/launcher/McpInstall.kt
@@ -0,0 +1,136 @@
+// Installs the MCP server that ships inside the plugin, the way the VS Code
+// extension does (editors/vscode/src/launcher/blackboxMcp.ts), so MCP
+// clients use the same entry point whichever editors are installed:
+//
+// { "command": "node", "args": ["/.blackbox/blackbox-mcp.js"] }
+//
+// The server is copied out of the plugin to ~/.blackbox/jetbrains//,
+// recorded in ~/.blackbox/installs/, and the launcher replaced only by a
+// newer version (or when the install that wrote it is gone).
+
+package dev.blackbox.launcher
+
+import com.intellij.openapi.application.ApplicationNamesInfo
+import com.intellij.openapi.diagnostic.logger
+import dev.blackbox.ipc.Protocol
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.attribute.PosixFilePermissions
+import java.security.MessageDigest
+
+object McpInstall {
+ const val LAUNCHER_NAME = "blackbox-mcp.js"
+ private const val RESOURCES = "/blackbox-mcp"
+ private val LOG = logger()
+
+ fun home(env: Map = System.getenv()): Path =
+ env["BLACKBOX_HOME"]?.takeIf { it.isNotEmpty() }?.let { Path.of(it) }
+ ?: Path.of(System.getProperty("user.home"), ".blackbox")
+
+ fun launcher(home: Path = home()): Path = home.resolve(LAUNCHER_NAME)
+
+ // Version of the bundled server (the VS Code extension's), or null when
+ // the plugin was built without it.
+ fun bundledVersion(): String? = resource("version.txt")?.toString(Charsets.UTF_8)?.trim()
+
+ private fun resource(name: String): ByteArray? = McpInstall::class.java.getResourceAsStream("$RESOURCES/$name")?.use { it.readBytes() }
+
+ @Synchronized
+ fun install(pluginVersion: String, home: Path = home()) {
+ val version = bundledVersion() ?: return LOG.warn("blackbox: this build has no bundled MCP server")
+ try {
+ val dir = home.resolve("jetbrains").resolve("$version-$pluginVersion")
+ val server = dir.resolve("mcp").resolve("server.js")
+ if (!Files.exists(server)) {
+ Files.createDirectories(server.parent)
+ writeAtomic(dir.resolve("tools.json"), resource("tools.json")!!)
+ writeAtomic(server, resource("mcp/server.js")!!)
+ }
+ record(home, version, server.toString(), ApplicationNamesInfo.getInstance().fullProductName)
+ } catch (e: Exception) {
+ LOG.warn("blackbox: could not install the MCP launcher", e)
+ }
+ }
+
+ // Port of recordInstall(): one record file per install, records of
+ // installs that are gone removed, the launcher replaced only by a newer one.
+ fun record(home: Path, version: String, server: String, app: String) {
+ val installs = home.resolve("installs")
+ Files.createDirectories(installs)
+ if (!Protocol.isWindows) Files.setPosixFilePermissions(home, PosixFilePermissions.fromString("rwx------"))
+
+ val key = if (Protocol.isWindows) server.lowercase() else server
+ val id = MessageDigest.getInstance("SHA-1").digest(key.toByteArray()).joinToString("") { "%02x".format(it) }.take(16)
+ val record = mapOf("version" to version, "server" to server, "app" to app, "recordedAt" to System.currentTimeMillis())
+ writeAtomic(installs.resolve("$id.json"), (Protocol.gson.toJson(record) + "\n").toByteArray())
+ Files.list(installs).use { files ->
+ files.filter { it.toString().endsWith(".json") }.forEach { file ->
+ val recorded = runCatching { Protocol.gson.fromJson(Files.readString(file), Map::class.java)["server"] as? String }.getOrNull()
+ if (recorded != null && !Files.exists(Path.of(recorded))) runCatching { Files.delete(file) }
+ }
+ }
+
+ val launcher = launcher(home)
+ val current = if (Files.exists(launcher)) Files.readString(launcher) else null
+ if (shouldReplaceLauncher(current, version)) {
+ writeAtomic(launcher, "${launcherStamp(version, server)}\n${resource("launcher.js")!!.toString(Charsets.UTF_8)}".toByteArray())
+ }
+ }
+
+ private val STAMP = Regex("""^// blackbox-mcp launcher v(\S+) from (.+)$""")
+
+ fun launcherStamp(version: String, server: String) = "// blackbox-mcp launcher v$version from $server"
+
+ fun readStamp(content: String?): Pair? =
+ content?.lineSequence()?.firstOrNull()?.let { STAMP.matchEntire(it) }?.let { it.groupValues[1] to it.groupValues[2] }
+
+ // Replace the installed launcher only with a newer one, or when the
+ // install that wrote it is gone. Never downgrade a live one.
+ fun shouldReplaceLauncher(current: String?, version: String, exists: (String) -> Boolean = { Files.exists(Path.of(it)) }): Boolean {
+ val (stampVersion, stampServer) = readStamp(current) ?: return true
+ return compareVersions(version, stampVersion) > 0 || !exists(stampServer)
+ }
+
+ // Compares "1.2.3" style versions numerically; a pre-release suffix
+ // ("1.2.3-beta") sorts before the plain version.
+ fun compareVersions(a: String, b: String): Int {
+ fun parse(v: String): Pair, String?> {
+ val core = v.substringBefore('-')
+ val pre = if ('-' in v) v.substringAfter('-') else null
+ return core.split('.').map { it.toIntOrNull() ?: 0 } to pre
+ }
+ val (x, xPre) = parse(a)
+ val (y, yPre) = parse(b)
+ for (i in 0 until maxOf(x.size, y.size)) {
+ val diff = x.getOrElse(i) { 0 } - y.getOrElse(i) { 0 }
+ if (diff != 0) return diff
+ }
+ return when {
+ xPre == yPre -> 0
+ xPre == null -> 1
+ yPre == null -> -1
+ else -> xPre.compareTo(yPre).coerceIn(-1, 1)
+ }
+ }
+
+ private fun writeAtomic(file: Path, content: ByteArray) {
+ val tmp = file.resolveSibling("${file.fileName}.${ProcessHandle.current().pid()}.${System.nanoTime()}.tmp")
+ Files.write(tmp, content)
+ if (!Protocol.isWindows) Files.setPosixFilePermissions(tmp, PosixFilePermissions.fromString("rw-------"))
+ Files.move(tmp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING, java.nio.file.StandardCopyOption.ATOMIC_MOVE)
+ }
+
+ // Absolute path of the user's node, for MCP configs: apps started from the
+ // Dock often lack nvm & co. on their PATH. Falls back to plain "node".
+ fun findNode(): String = try {
+ val command = if (Protocol.isWindows) listOf("where", "node")
+ else listOf(System.getenv("SHELL") ?: "/bin/sh", "-lc", "command -v node")
+ val process = ProcessBuilder(command).redirectErrorStream(false).start()
+ process.outputStream.close()
+ val finished = process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS)
+ if (!finished) process.destroyForcibly()
+ process.inputStream.bufferedReader().readLines().map { it.trim() }.firstOrNull { Path.of(it).isAbsolute } ?: "node"
+ } catch (_: Exception) {
+ "node"
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Breakpoints.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Breakpoints.kt
new file mode 100644
index 0000000..43bd2ef
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Breakpoints.kt
@@ -0,0 +1,243 @@
+// Breakpoint tools on the IDE's breakpoint manager. Line breakpoints use the
+// line's own breakpoint type (Java, PHP, Go, ...), conditions are native,
+// hit conditions and log messages are Blackbox's (BreakpointExtras).
+
+package dev.blackbox.tools
+
+import com.google.gson.JsonObject
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.application.WriteAction
+import com.intellij.openapi.application.readAction
+import com.intellij.openapi.application.smartReadAction
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.xdebugger.XDebuggerManager
+import com.intellij.xdebugger.XDebuggerUtil
+import com.intellij.xdebugger.breakpoints.SuspendPolicy
+import com.intellij.xdebugger.breakpoints.XBreakpoint
+import com.intellij.xdebugger.breakpoints.XBreakpointProperties
+import com.intellij.xdebugger.breakpoints.XLineBreakpoint
+import com.intellij.xdebugger.breakpoints.XLineBreakpointType
+import com.intellij.xdebugger.evaluation.EvaluationMode
+import dev.blackbox.debug.BreakpointExtras
+import dev.blackbox.debug.Extra
+import dev.blackbox.debug.HitCondition
+import dev.blackbox.lang.LanguageSupport
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class Breakpoints(private val project: Project) {
+ private val manager get() = XDebuggerManager.getInstance(project).breakpointManager
+ private val extras get() = BreakpointExtras.of(project)
+
+ private class Spec(val file: String?, val line: Int?, val condition: String?, val hitCondition: String?, val logMessage: String?) {
+ fun describe() = "$file:$line" +
+ (condition?.let { " (if: $it)" } ?: "") +
+ (hitCondition?.let { " (hits: $it)" } ?: "") +
+ (logMessage?.let { " (log: $it)" } ?: "")
+ }
+
+ private fun spec(o: JsonObject) =
+ Spec(o.str("file"), o.int("line"), o.str("condition"), o.str("hitCondition"), o.str("logMessage"))
+
+ private suspend fun write(block: () -> T): T = withContext(Dispatchers.EDT) { WriteAction.compute(block) }
+
+ // Source breakpoints: line breakpoints that aren't function breakpoints.
+ private fun sourceBreakpoints(): List> = manager.allBreakpoints
+ .filterIsInstance>()
+ .filter { LanguageSupport.functionName(it) == null }
+
+ private fun functionBreakpoints(): List>> = manager.allBreakpoints
+ .mapNotNull { bp -> LanguageSupport.functionName(bp)?.let { it to bp } }
+
+ private fun at(file: String, line: Int): List> =
+ sourceBreakpoints().filter { it.line == line - 1 && sameFile(it.sourcePosition?.file?.path ?: urlPath(it.fileUrl), file) }
+
+ private fun urlPath(url: String) = url.removePrefix("file://")
+
+ // The line breakpoint type for this line (the one the gutter would use)
+ // and the line to use: the same, or the next line that can hold one, as
+ // debug adapters move breakpoints off blank lines and comments.
+ private suspend fun placement(file: VirtualFile, line0: Int): Pair, Int>? = readAction {
+ val types = XDebuggerUtil.getInstance().lineBreakpointTypes.sortedByDescending { it.priority }
+ (line0..line0 + MAX_LINE_SHIFT).firstNotNullOfOrNull { candidate ->
+ types.firstOrNull { !isFunctionType(it) && it.canPutAt(file, candidate, project) }
+ ?.let { it to candidate }
+ }
+ }
+
+ // Method breakpoint types also fit some lines; they're not line breakpoints.
+ private fun isFunctionType(type: XLineBreakpointType<*>) =
+ type.id.contains("method", ignoreCase = true)
+
+ private fun add(type: XLineBreakpointType<*>, file: VirtualFile, line0: Int, spec: Spec): XLineBreakpoint<*> {
+ @Suppress("UNCHECKED_CAST")
+ val t = type as XLineBreakpointType>
+ val bp = manager.addLineBreakpoint(t, file.url, line0, t.createBreakpointProperties(file, line0))
+ spec.condition?.let {
+ bp.conditionExpression = XDebuggerUtil.getInstance().createExpression(it, null, null, EvaluationMode.EXPRESSION)
+ }
+ // Blackbox counts hits and logs messages itself, so the program must pause there.
+ if (spec.hitCondition != null || spec.logMessage != null) bp.suspendPolicy = SuspendPolicy.ALL
+ extras.set(bp, Extra(spec.hitCondition, spec.logMessage))
+ return bp
+ }
+
+ suspend fun set(args: JsonObject): String {
+ val specs = args.objects("breakpoints")?.map(::spec) ?: listOf(spec(args))
+ val results = specs.map { spec ->
+ val file = spec.file
+ val line = spec.line
+ when {
+ file == null || line == null || line < 1 -> "skip: invalid — file and line (>= 1) required"
+ spec.hitCondition != null && !HitCondition.isValid(spec.hitCondition) ->
+ "skip: ${spec.describe()} — hitCondition must look like \"5\", \">= 10\" or \"% 3\""
+ else -> setOne(file, line, spec)
+ }
+ }
+ return results.joinToString("\n")
+ }
+
+ private suspend fun setOne(file: String, line: Int, spec: Spec): String {
+ val vfile = findFile(file) ?: return "skip: no such file $file"
+ val (type, line0) = placement(vfile, line - 1)
+ ?: return "skip: ${spec.describe()} — no line from $line to ${line + MAX_LINE_SHIFT} can hold a breakpoint"
+ write {
+ at(file, line).forEach(manager::removeBreakpoint) // Replaces one already there.
+ if (line0 != line - 1) at(file, line0 + 1).forEach(manager::removeBreakpoint)
+ add(type, vfile, line0, spec)
+ }
+ val moved = if (line0 != line - 1) " (moved to line ${line0 + 1}, the next line that can hold a breakpoint)" else ""
+ return "ok: ${spec.describe()}$moved"
+ }
+
+ suspend fun setFunction(args: JsonObject): String {
+ val name = args.str("name")?.takeIf { it.isNotEmpty() } ?: fail("function name is required")
+ val supports = LanguageSupport.all()
+ if (supports.isEmpty()) fail("function breakpoints aren't supported in this IDE; set a breakpoint on the function's first line instead")
+ val condition = args.str("condition")
+ val hitCondition = args.str("hitCondition")
+ smartReadAction(project) {} // Finding the function needs the indexes.
+ val bp = write {
+ functionBreakpoints().filter { LanguageSupport.nameMatches(it.first, name) }.forEach { manager.removeBreakpoint(it.second) }
+ supports.firstNotNullOfOrNull { it.addFunctionBreakpoint(project, name) }?.also { bp ->
+ condition?.let {
+ bp.conditionExpression = XDebuggerUtil.getInstance().createExpression(it, null, null, EvaluationMode.EXPRESSION)
+ }
+ }
+ } ?: fail("no function named \"$name\" found in this project (use the qualified form, e.g. \"App.add\")")
+ if (hitCondition != null && bp is XLineBreakpoint<*>) extras.set(bp, Extra(hitCondition, null))
+ return "ok: function $name" +
+ (condition?.let { " (if: $it)" } ?: "") +
+ (hitCondition?.let { " (hits: $it)" } ?: "")
+ }
+
+ suspend fun remove(args: JsonObject): String {
+ val specs = args.objects("breakpoints")
+ ?: args.str("file")?.let { listOf(obj("file" to it, "line" to args.int("line"))) }
+ ?: emptyList()
+ val results = mutableListOf()
+ write {
+ for (spec in specs) {
+ val file = spec.str("file") ?: continue
+ val line = spec.int("line") ?: continue
+ val found = at(file, line)
+ found.forEach(manager::removeBreakpoint)
+ results += if (found.isNotEmpty()) "ok: removed $file:$line" else "skip: no breakpoint at $file:$line"
+ }
+ for (name in args.strings("functions") ?: emptyList()) {
+ val found = functionBreakpoints().filter { LanguageSupport.nameMatches(it.first, name) }
+ found.forEach { manager.removeBreakpoint(it.second) }
+ results += if (found.isNotEmpty()) "ok: removed function $name" else "skip: no function breakpoint on $name"
+ }
+ }
+ return results.joinToString("\n").ifEmpty { "Nothing to remove: pass file+line, breakpoints or functions" }
+ }
+
+ suspend fun removeAll(): String {
+ val count = write {
+ val all = manager.allBreakpoints.filter { !manager.isDefaultBreakpoint(it) }
+ all.forEach(manager::removeBreakpoint)
+ all.size
+ }
+ return if (count == 0) "No breakpoints to remove" else "Removed all $count breakpoint(s)"
+ }
+
+ suspend fun toggle(args: JsonObject): String {
+ val enabled = args.bool("enabled") ?: fail("\"enabled\" (true or false) is required")
+ val wantedSources = args.objects("breakpoints")
+ val wantedFunctions = args.strings("functions")
+ val all = wantedSources == null && wantedFunctions == null
+ var changed = 0
+ var already = 0
+ write {
+ val sources = sourceBreakpoints().filter { bp ->
+ all || wantedSources.orEmpty().any { w ->
+ w.int("line") == bp.line + 1 && sameFile(bp.sourcePosition?.file?.path ?: urlPath(bp.fileUrl), w.str("file"))
+ }
+ }
+ val functions = functionBreakpoints()
+ .filter { (name, _) -> all || wantedFunctions.orEmpty().any { LanguageSupport.nameMatches(name, it) } }
+ .map { it.second }
+ for (bp in sources + functions) {
+ if (bp.isEnabled == enabled) already++ else {
+ bp.isEnabled = enabled
+ changed++
+ }
+ }
+ }
+ val verb = if (enabled) "Enabled" else "Disabled"
+ return "$verb $changed breakpoint(s)" +
+ (if (already > 0) "; $already already ${verb.lowercase()}" else "") +
+ (if (changed + already == 0) ". No matching breakpoints." else "")
+ }
+
+ suspend fun list(): String = readAction {
+ val sources = sourceBreakpoints().map { bp ->
+ val extra = extras.get(bp)
+ obj(
+ "type" to "source",
+ "file" to (bp.sourcePosition?.file?.path ?: urlPath(bp.fileUrl)),
+ "line" to bp.line + 1,
+ "enabled" to bp.isEnabled,
+ "condition" to bp.conditionExpression?.expression?.ifEmpty { null },
+ "hitCondition" to extra?.hitCondition,
+ "logMessage" to extra?.logMessage,
+ )
+ }.sortedWith(compareBy({ it.str("file") }, { it.int("line") }))
+ val functions = functionBreakpoints().map { (name, bp) ->
+ obj(
+ "type" to "function",
+ "name" to name,
+ "enabled" to bp.isEnabled,
+ "condition" to bp.conditionExpression?.expression?.ifEmpty { null },
+ "hitCondition" to extras.get(bp)?.hitCondition,
+ )
+ }
+ json(sources + functions)
+ }
+
+ fun count(): Int = manager.allBreakpoints.count { !manager.isDefaultBreakpoint(it) }
+
+ suspend fun setExceptionFilters(args: JsonObject, sessionName: String?): String {
+ val supports = LanguageSupport.all()
+ val available = readAction { supports.flatMap { it.exceptionFilters(project) } }
+ val filters = args.strings("filters")
+ ?: return json(obj(
+ "session" to sessionName,
+ "available" to available.map { obj("filter" to it.filter, "label" to it.label, "default" to it.enabled) },
+ ))
+ val known = available.map { it.filter }.toSet()
+ val unknown = filters.filter { it !in known }
+ if (unknown.isNotEmpty()) {
+ fail("unknown exception filter(s): ${unknown.joinToString(", ")}. Available: ${known.joinToString(", ").ifEmpty { "(none in this IDE)" }}")
+ }
+ write { supports.forEach { it.setExceptionFilters(project, filters) } }
+ return "Exception breakpoints: ${filters.joinToString(", ").ifEmpty { "(none)" }}. " +
+ "They are the IDE's exception breakpoints, so they stay set for later debug sessions."
+ }
+
+ companion object {
+ private const val MAX_LINE_SHIFT = 5
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Catalog.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Catalog.kt
new file mode 100644
index 0000000..1d185ce
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Catalog.kt
@@ -0,0 +1,66 @@
+// Every tool this plugin serves, by name (mirrors tools/catalog.ts in the
+// VS Code extension). Names and arguments follow schema/tools.json; the
+// ide_* tools are answered by the MCP server itself.
+
+package dev.blackbox.tools
+
+import com.intellij.openapi.project.Project
+import dev.blackbox.ipc.Protocol
+import dev.blackbox.ipc.ToolHandler
+
+object Catalog {
+ fun handlers(project: Project): Map {
+ val debug = Debug(project)
+ val bp = Breakpoints(project)
+ val sessions = Sessions(project, debug)
+ val exec = Execution(debug)
+ val inspect = Inspect(debug)
+ val editor = Editor(project, debug, bp)
+
+ return mapOf(
+ // Breakpoints
+ "debug_set_breakpoint" to { a -> bp.set(a) },
+ "debug_set_function_breakpoint" to { a -> bp.setFunction(a) },
+ "debug_remove_breakpoint" to { a -> bp.remove(a) },
+ "debug_remove_all_breakpoints" to { _ -> bp.removeAll() },
+ "debug_toggle_breakpoints" to { a -> bp.toggle(a) },
+ "debug_list_breakpoints" to { _ -> bp.list() },
+ "debug_set_exception_breakpoints" to { a -> bp.setExceptionFilters(a, debug.findSession()?.sessionName) },
+
+ // Session control
+ "debug_get_launch_configs" to { _ -> sessions.launchConfigs() },
+ "debug_start" to { a -> sessions.start(a) },
+ "debug_stop" to { _ -> sessions.stop() },
+ "debug_restart" to { _ -> sessions.restart() },
+
+ // Execution
+ "debug_continue" to { a -> exec.continue_(a) },
+ "debug_pause" to { a -> exec.pause(a) },
+ "debug_step_over" to { a -> exec.stepOver(a) },
+ "debug_step_into" to { a -> exec.stepInto(a) },
+ "debug_step_out" to { a -> exec.stepOut(a) },
+ "debug_run_to_line" to { a -> exec.runToLine(a) },
+ "debug_wait_for_stop" to { a -> exec.waitForStop(a) },
+
+ // Inspection
+ "debug_list_threads" to { _ -> inspect.listThreads() },
+ "debug_get_stack_trace" to { a -> inspect.stackTrace(a) },
+ "debug_get_variables" to { a -> inspect.variables(a) },
+ "debug_evaluate" to { a -> inspect.evaluate(a) },
+ "debug_inspect" to { a -> inspect.inspect(a) },
+ "debug_set_variable" to { a -> inspect.setVariable(a) },
+ "debug_watch" to { a -> inspect.watch(a) },
+ "debug_get_source_context" to { a -> inspect.sourceContext(a) },
+ "debug_get_output" to { a -> inspect.output(a) },
+
+ // Editor and workspace
+ "editor_open_file" to { a -> editor.openFile(a) },
+ "editor_get_open_files" to { _ -> editor.openFiles() },
+ "workspace_find_file" to { a -> editor.findFile(a) },
+ "workspace_get_diagnostics" to { a -> editor.diagnostics(a) },
+
+ // Internal: describes this window to the MCP server.
+ Protocol.STATUS_TOOL to { _ -> editor.windowStatus() },
+ )
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Common.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Common.kt
new file mode 100644
index 0000000..e647df1
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Common.kt
@@ -0,0 +1,157 @@
+// Helpers shared by the tools (mirrors the helper section of
+// editors/vscode/src/tools/impl/debug.ts and blackbox.nvim's common.lua).
+
+package dev.blackbox.tools
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonNull
+import com.google.gson.JsonObject
+import com.google.gson.JsonPrimitive
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.LocalFileSystem
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.xdebugger.XDebugSession
+import com.intellij.xdebugger.frame.XExecutionStack
+import com.intellij.xdebugger.frame.XStackFrame
+import dev.blackbox.debug.DebugEvents
+import dev.blackbox.debug.WaitResult
+import dev.blackbox.debug.frames
+import dev.blackbox.debug.stacks
+import dev.blackbox.debug.title
+import dev.blackbox.ipc.Protocol
+import dev.blackbox.ipc.ToolError
+import java.nio.file.Files
+import java.nio.file.Path
+
+const val STEP_WAIT_MS = 10_000L
+const val PAUSE_WAIT_MS = 5_000L
+const val DEFAULT_STOP_WAIT_MS = 30_000L
+const val STOP_FRAMES = 5
+
+fun fail(message: String): Nothing = throw ToolError(message)
+
+// Arguments, as the MCP server forwards them (already checked against the schema).
+fun JsonObject.str(name: String): String? = get(name)?.takeIf { it.isJsonPrimitive }?.asString
+fun JsonObject.int(name: String): Int? = get(name)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asInt
+fun JsonObject.long(name: String): Long? = get(name)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isNumber }?.asLong
+fun JsonObject.bool(name: String): Boolean? = get(name)?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean }?.asBoolean
+fun JsonObject.objects(name: String): List? =
+ get(name)?.takeIf { it.isJsonArray }?.asJsonArray?.filter { it.isJsonObject }?.map { it.asJsonObject }
+fun JsonObject.strings(name: String): List? =
+ get(name)?.takeIf { it.isJsonArray }?.asJsonArray?.filter { it.isJsonPrimitive }?.map { it.asString }
+
+// JSON results, built the way the VS Code extension returns them.
+fun obj(vararg fields: Pair): JsonObject = JsonObject().apply {
+ for ((name, value) in fields) if (value != null) add(name, toJson(value))
+}
+
+fun toJson(value: Any?): JsonElement = when (value) {
+ null -> JsonNull.INSTANCE
+ is JsonElement -> value
+ is String -> JsonPrimitive(value)
+ is Number -> JsonPrimitive(value)
+ is Boolean -> JsonPrimitive(value)
+ is Map<*, *> -> JsonObject().apply { value.forEach { (k, v) -> if (v != null) add(k.toString(), toJson(v)) } }
+ is Iterable<*> -> JsonArray().apply { value.forEach { add(toJson(it)) } }
+ else -> JsonPrimitive(value.toString())
+}
+
+fun json(value: Any?): String = Protocol.gson.toJson(toJson(value))
+
+fun findFile(path: String): VirtualFile? = LocalFileSystem.getInstance().refreshAndFindFileByPath(path)
+
+// Same file, seeing through symlinks (e.g. WordPress wp/ -> wordpress-develop/src/).
+fun sameFile(a: String?, b: String?): Boolean {
+ if (a == null || b == null) return false
+ if (a == b) return true
+ fun real(p: String) = runCatching { Path.of(p).toRealPath().toString() }.getOrDefault(Path.of(p).normalize().toString())
+ return real(a) == real(b)
+}
+
+fun fileExists(path: String) = Files.isRegularFile(Path.of(path))
+
+class Debug(val project: Project) {
+ val events = DebugEvents.of(project)
+ val handles = events.handles
+
+ fun findSession(): XDebugSession? = events.findSession()
+
+ fun activeSession(): XDebugSession = findSession() ?: fail("no active debug session")
+
+ // The debugger's threads while paused (debuggers only list them then).
+ suspend fun threads(session: XDebugSession): List> {
+ val context = session.suspendContext ?: return emptyList()
+ val stacks = context.stacks()
+ return handles.threadIds(session, stacks).zip(stacks)
+ }
+
+ // The thread a command acts on: the one asked for, the one the debugger
+ // has active (where it paused, or picked in the Frames view), then the
+ // first thread.
+ suspend fun resolveThread(session: XDebugSession, threadId: Int?): Pair {
+ val context = session.suspendContext ?: fail("the program is running — is the debugger stopped at a breakpoint?")
+ if (threadId != null) {
+ return threads(session).firstOrNull { it.first == threadId }
+ ?: fail("no thread $threadId — call debug_list_threads for the current ones")
+ }
+ val active = context.activeExecutionStack
+ if (active != null) return handles.threadIds(session, listOf(active)).first() to active
+ return threads(session).firstOrNull() ?: fail("no threads — is the debugger stopped at a breakpoint?")
+ }
+
+ class Frame(val id: Int, val name: String, val file: String, val line: Int, val frame: XStackFrame) {
+ fun view() = obj("id" to id, "name" to name, "file" to file, "line" to line)
+ }
+
+ fun frameOf(session: XDebugSession, frame: XStackFrame): Frame {
+ val position = frame.sourcePosition
+ return Frame(
+ handles.frameId(session, frame),
+ frame.title(),
+ position?.file?.path ?: "(unknown)",
+ (position?.line ?: -1) + 1,
+ frame,
+ )
+ }
+
+ suspend fun stackFrames(session: XDebugSession, stack: XExecutionStack, levels: Int): List =
+ stack.frames(levels).map { frameOf(session, it) }
+
+ // The frame to inspect: the one asked for, else the top of the chosen thread.
+ suspend fun frameFor(session: XDebugSession, args: JsonObject): XStackFrame {
+ args.int("frameId")?.let { id ->
+ val (owner, frame) = handles.frame(id) ?: fail("no stack frame $id — frames change when the program moves; call debug_get_stack_trace again")
+ if (owner !== session) fail("stack frame $id belongs to another debug session")
+ return frame
+ }
+ val (_, stack) = resolveThread(session, args.int("threadId"))
+ return stack.topFrame ?: stack.frames(1).firstOrNull()
+ ?: fail("no stack frames — is the debugger stopped at a breakpoint?")
+ }
+
+ suspend fun describeStop(session: XDebugSession, stop: dev.blackbox.debug.Stop): JsonObject {
+ val frames = runCatching {
+ val (_, stack) = resolveThread(session, stop.threadId)
+ stackFrames(session, stack, STOP_FRAMES)
+ }.getOrDefault(emptyList())
+ val top = frames.firstOrNull()
+ return obj(
+ "state" to "stopped",
+ "session" to session.sessionName,
+ "reason" to stop.reason,
+ "description" to stop.description,
+ "threadId" to stop.threadId,
+ "file" to top?.file,
+ "line" to top?.line,
+ "function" to top?.name,
+ "frames" to frames.map { it.view() },
+ )
+ }
+
+ suspend fun describeWait(result: WaitResult): JsonObject = when (result) {
+ is WaitResult.Stopped -> describeStop(result.session, result.stop)
+ WaitResult.Timeout -> obj("state" to "running", "hint" to "Not paused yet. Call debug_wait_for_stop to keep waiting.")
+ WaitResult.Terminated -> obj("state" to "terminated")
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Editor.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Editor.kt
new file mode 100644
index 0000000..857b261
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Editor.kt
@@ -0,0 +1,122 @@
+// Editor and workspace tools, plus window_status (mirrors
+// editors/vscode/src/tools/impl/editor.ts, workspace.ts and window.ts).
+
+package dev.blackbox.tools
+
+import com.google.gson.JsonObject
+import com.intellij.codeInsight.daemon.impl.DaemonCodeAnalyzerEx
+import com.intellij.lang.annotation.HighlightSeverity
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.application.readAction
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.fileEditor.OpenFileDescriptor
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.roots.ProjectFileIndex
+import com.intellij.openapi.vfs.VfsUtilCore
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.openapi.vfs.VirtualFileVisitor
+import dev.blackbox.ipc.ProjectBridge
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.nio.file.FileSystems
+import java.nio.file.Path
+
+class Editor(private val project: Project, private val debug: Debug, private val breakpoints: Breakpoints) {
+ suspend fun openFile(args: JsonObject): String {
+ val file = args.str("file")?.takeIf { it.isNotEmpty() } ?: fail("file path is required")
+ val line = args.int("line")
+ val vfile = findFile(file) ?: fail("Error opening file: no such file $file")
+ withContext(Dispatchers.EDT) {
+ OpenFileDescriptor(project, vfile, ((line ?: 1) - 1).coerceAtLeast(0), 0).navigate(true)
+ }
+ return "Opened $file" + (line?.let { ":$it" } ?: "")
+ }
+
+ suspend fun openFiles(): String = withContext(Dispatchers.EDT) {
+ val editors = FileEditorManager.getInstance(project)
+ val selected = editors.selectedFiles.toSet()
+ val documents = FileDocumentManager.getInstance()
+ json(editors.openFiles.filter { it.isInLocalFileSystem }.map {
+ obj("file" to it.path, "active" to (it in selected), "dirty" to documents.isFileModified(it))
+ })
+ }
+
+ suspend fun findFile(args: JsonObject): String {
+ val glob = args.str("pattern")?.takeIf { it.isNotEmpty() } ?: fail("glob pattern is required")
+ val limit = args.int("maxResults") ?: 20
+ val matcher = FileSystems.getDefault().getPathMatcher("glob:$glob")
+ val found = mutableListOf()
+ readAction {
+ val index = ProjectFileIndex.getInstance(project)
+ for (root in ProjectBridge.roots(project)) {
+ if (found.size >= limit) break
+ VfsUtilCore.visitChildrenRecursively(root, object : VirtualFileVisitor() {
+ override fun visitFile(file: VirtualFile): Boolean {
+ if (found.size >= limit) return false
+ if (file.isDirectory) return file.name != "node_modules" && file.name != ".git" && !index.isExcluded(file)
+ val relative = VfsUtilCore.getRelativePath(file, root) ?: return true
+ // "**/x" also matches "x" at the root, as in VS Code.
+ if (matcher.matches(Path.of(relative)) || matcher.matches(Path.of("_", relative))) {
+ if (file.path !in found) found += file.path
+ }
+ return true
+ }
+ })
+ }
+ }
+ return json(found)
+ }
+
+ suspend fun diagnostics(args: JsonObject): String {
+ val min = when (args.str("severity")) {
+ "error" -> HighlightSeverity.ERROR
+ "warning" -> HighlightSeverity.WARNING
+ else -> HighlightSeverity.WEAK_WARNING
+ }
+ val files = args.str("file")?.let { path -> listOfNotNull(findFile(path)) }
+ ?: withContext(Dispatchers.EDT) { FileEditorManager.getInstance(project).openFiles.toList() }
+ val result = readAction {
+ files.mapNotNull { file ->
+ val document = FileDocumentManager.getInstance().getDocument(file) ?: return@mapNotNull null
+ val issues = mutableListOf()
+ DaemonCodeAnalyzerEx.processHighlights(document, project, min, 0, document.textLength) { info ->
+ issues += obj(
+ "line" to document.getLineNumber(info.startOffset) + 1,
+ "severity" to when {
+ info.severity >= HighlightSeverity.ERROR -> "error"
+ info.severity >= HighlightSeverity.WARNING -> "warning"
+ else -> "info"
+ },
+ "message" to info.description,
+ "source" to info.inspectionToolId,
+ )
+ true
+ }
+ issues.filter { it.has("message") }.takeIf { it.isNotEmpty() }?.let { obj("file" to file.path, "issues" to it) }
+ }
+ }
+ return if (result.isEmpty()) "No diagnostics found" else json(result)
+ }
+
+ suspend fun windowStatus(): String {
+ val session = debug.findSession()
+ val stop = debug.events.currentStop()
+ val debugView = session?.let {
+ val position = if (stop != null) it.currentPosition else null
+ obj(
+ "name" to it.sessionName,
+ "type" to (it.runProfile as? com.intellij.execution.configurations.RunConfiguration)?.type?.id.orEmpty(),
+ "state" to if (stop != null) "stopped" else "running",
+ "stoppedAt" to position?.let { p -> obj("file" to p.file.path, "line" to p.line + 1) },
+ )
+ }
+ val bridge = ProjectBridge.of(project)
+ return json(obj(
+ "folders" to bridge.folders(),
+ "focused" to bridge.isFocused(),
+ "breakpoints" to readAction { breakpoints.count() },
+ "debug" to (debugView ?: com.google.gson.JsonNull.INSTANCE),
+ ))
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Execution.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Execution.kt
new file mode 100644
index 0000000..8b1539d
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Execution.kt
@@ -0,0 +1,69 @@
+// Continue, pause, step, run-to-line and waiting for the debugger to pause.
+// XDebugger steps the thread selected in the Frames view, so a requested
+// thread is selected first.
+
+package dev.blackbox.tools
+
+import com.google.gson.JsonObject
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.application.readAction
+import com.intellij.xdebugger.XDebugSession
+import com.intellij.xdebugger.XDebuggerUtil
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class Execution(private val debug: Debug) {
+ private val events get() = debug.events
+
+ // Makes the requested thread the one the debugger acts on.
+ private suspend fun select(session: XDebugSession, threadId: Int?) {
+ if (threadId == null) return
+ val (_, stack) = debug.resolveThread(session, threadId)
+ val top = stack.topFrame ?: return
+ withContext(Dispatchers.EDT) { session.setCurrentStackFrame(stack, top) }
+ }
+
+ // Sends a resuming command and reports where the debugger pauses next.
+ private suspend fun runAndReport(args: JsonObject, action: String, waitMs: Long, command: (XDebugSession) -> Unit): String {
+ val session = debug.activeSession()
+ if (action != "pause" && !events.isPaused(session)) fail("the program is running — call debug_pause or debug_wait_for_stop first")
+ select(session, args.int("threadId"))
+ val waiter = events.arm() // Before the command, so a fast pause is not missed.
+ events.expect(session, action)
+ withContext(Dispatchers.EDT) { command(session) }
+ return json(debug.describeWait(waiter.await(waitMs)))
+ }
+
+ suspend fun continue_(args: JsonObject): String {
+ val session = debug.activeSession()
+ select(session, args.int("threadId"))
+ withContext(Dispatchers.EDT) { session.resume() }
+ return "Resumed execution. Call debug_wait_for_stop to wait for the next pause."
+ }
+
+ suspend fun pause(args: JsonObject) = runAndReport(args, "pause", PAUSE_WAIT_MS) { it.pause() }
+ suspend fun stepOver(args: JsonObject) = runAndReport(args, "step", STEP_WAIT_MS) { it.stepOver(false) }
+ suspend fun stepInto(args: JsonObject) = runAndReport(args, "step", STEP_WAIT_MS) { it.stepInto() }
+ suspend fun stepOut(args: JsonObject) = runAndReport(args, "step", STEP_WAIT_MS) { it.stepOut() }
+
+ suspend fun waitForStop(args: JsonObject): String =
+ json(debug.describeWait(events.waitForStop(args.long("timeoutMs") ?: DEFAULT_STOP_WAIT_MS, args.bool("next") == true)))
+
+ suspend fun runToLine(args: JsonObject): String {
+ val file = args.str("file")
+ val line = args.int("line")
+ if (file == null || line == null || line < 1) fail("file and line (>= 1) are required")
+ val vfile = findFile(file) ?: fail("no such file $file")
+ val position = readAction { XDebuggerUtil.getInstance().createPosition(vfile, line - 1) } ?: fail("no line $line in $file")
+ val session = debug.activeSession()
+ if (!events.isPaused(session)) fail("the program is running — call debug_pause or debug_wait_for_stop first")
+ select(session, args.int("threadId"))
+ val waiter = events.arm()
+ events.expect(session, "goto")
+ withContext(Dispatchers.EDT) { session.runToPosition(position, false) }
+ val result = debug.describeWait(waiter.await(args.long("timeoutMs") ?: DEFAULT_STOP_WAIT_MS))
+ val reached = result.str("state") == "stopped" && result.int("line") == line && sameFile(result.str("file"), file)
+ result.addProperty("reachedTarget", reached)
+ return json(result)
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Inspect.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Inspect.kt
new file mode 100644
index 0000000..c8083b0
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Inspect.kt
@@ -0,0 +1,220 @@
+// Threads, stack, variables, evaluation, watches, source context and output.
+
+package dev.blackbox.tools
+
+import com.google.gson.JsonObject
+import com.intellij.openapi.application.readAction
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.xdebugger.XDebugSession
+import com.intellij.xdebugger.frame.XStackFrame
+import com.intellij.xdebugger.frame.XValue
+import com.intellij.xdebugger.frame.XValueContainer
+import dev.blackbox.debug.changeTo
+import dev.blackbox.debug.children
+import dev.blackbox.debug.evaluated
+import dev.blackbox.debug.shown
+import java.nio.file.Files
+import java.nio.file.Path
+
+class Inspect(private val debug: Debug) {
+ private val handles get() = debug.handles
+ private val watches = mutableListOf() // kept across steps and sessions
+
+ suspend fun listThreads(): String {
+ val session = debug.activeSession()
+ val paused = debug.events.isPaused(session)
+ val threads = debug.threads(session).map { (id, stack) ->
+ obj("id" to id, "name" to stack.displayName, "stopped" to (if (paused) true else null))
+ }
+ return json(obj(
+ "session" to session.sessionName,
+ "threads" to threads,
+ "note" to (if (!paused) "The program is running; this debugger lists threads only while paused." else null),
+ ))
+ }
+
+ suspend fun stackTrace(args: JsonObject): String {
+ val session = debug.activeSession()
+ val (_, stack) = debug.resolveThread(session, args.int("threadId"))
+ val levels = minOf(args.int("levels") ?: 20, 200)
+ return json(debug.stackFrames(session, stack, levels).map { it.view() })
+ }
+
+ private suspend fun view(session: XDebugSession, name: String, value: XValue, expandable: Boolean): JsonObject {
+ val shown = try {
+ value.shown()
+ } catch (e: Exception) {
+ return obj("name" to name, "value" to "")
+ }
+ return obj(
+ "name" to name,
+ "value" to shown.value,
+ "type" to shown.type,
+ "expandable" to (if (expandable) shown.hasChildren else null),
+ "variablesReference" to (if (expandable && shown.hasChildren) handles.valueId(session, value) else null),
+ )
+ }
+
+ suspend fun variables(args: JsonObject): String {
+ val session = debug.activeSession()
+ args.int("variablesReference")?.let { ref ->
+ val container = container(session, ref)
+ return json(container.children().values.map { (name, value) -> view(session, name, value, true) })
+ }
+ val frame = debug.frameFor(session, args)
+ val needle = args.str("filter")?.lowercase()
+ fun wanted(name: String) = needle == null || name.lowercase().contains(needle)
+ // Scopes, as DAP has them: the frame's own values, then each group
+ // the debugger adds (e.g. "Static", "Globals").
+ val children = frame.children()
+ val result = linkedMapOf()
+ result["Locals"] = children.values.filter { wanted(it.first) }.map { (n, v) -> view(session, n, v, false) }
+ for ((groupName, group) in children.groups) {
+ val values = runCatching { group.children().values }.getOrDefault(emptyList())
+ result[groupName] = values.filter { wanted(it.first) }.map { (n, v) -> view(session, n, v, false) }
+ }
+ return json(result)
+ }
+
+ private fun container(session: XDebugSession, ref: Int): XValueContainer {
+ val (owner, value) = handles.value(ref)
+ ?: fail("no variablesReference $ref — references change when the program moves; read the variables again")
+ if (owner !== session) fail("variablesReference $ref belongs to another debug session")
+ return value
+ }
+
+ private suspend fun evaluate(session: XDebugSession, frame: XStackFrame, expression: String): XValue {
+ val evaluator = frame.evaluator ?: fail("this debugger can't evaluate expressions here")
+ return evaluator.evaluated(expression, frame.sourcePosition)
+ }
+
+ suspend fun evaluate(args: JsonObject): String {
+ val expression = args.str("expression")?.takeIf { it.isNotEmpty() } ?: fail("expression is required")
+ val session = debug.activeSession()
+ val value = evaluate(session, debug.frameFor(session, args), expression)
+ val shown = value.shown()
+ return json(obj(
+ "expression" to expression,
+ "result" to shown.value,
+ "type" to shown.type,
+ "variablesReference" to (if (shown.hasChildren) handles.valueId(session, value) else null),
+ ))
+ }
+
+ private suspend fun expand(container: XValueContainer, depth: Int, maxDepth: Int, maxItems: Int): Any {
+ if (depth >= maxDepth) return "..."
+ val items = container.children().values
+ val result = linkedMapOf()
+ for ((name, value) in items.take(maxItems)) {
+ val shown = runCatching { value.shown() }.getOrNull()
+ result[name] = when {
+ shown == null -> ""
+ shown.hasChildren -> expand(value, depth + 1, maxDepth, maxItems)
+ else -> shown.value
+ }
+ }
+ if (items.size > maxItems) result["..."] = "(${items.size - maxItems} more items)"
+ return result
+ }
+
+ suspend fun inspect(args: JsonObject): String {
+ val variable = args.str("variable")?.takeIf { it.isNotEmpty() } ?: fail("variable expression is required")
+ val session = debug.activeSession()
+ val maxDepth = minOf(args.int("depth") ?: 2, 5)
+ val maxItems = minOf(args.int("maxItems") ?: 50, 200)
+ val value = evaluate(session, debug.frameFor(session, args), variable)
+ val shown = value.shown()
+ val expanded = if (shown.hasChildren) expand(value, 0, maxDepth, maxItems) else shown.value
+ return json(obj("variable" to variable, "type" to shown.type, "value" to expanded))
+ }
+
+ suspend fun setVariable(args: JsonObject): String {
+ val name = args.str("name")
+ val newValue = args.get("value")?.let { if (it.isJsonPrimitive) it.asString else it.toString() }
+ if (name == null || newValue == null) fail("\"name\" and \"value\" are required")
+ val session = debug.activeSession()
+ val container: XValueContainer = args.int("variablesReference")?.let { container(session, it) }
+ ?: debug.frameFor(session, args)
+ val children = container.children()
+ val target = children.values.firstOrNull { it.first == name }?.second
+ ?: children.groups.firstNotNullOfOrNull { (_, g) ->
+ runCatching { g.children().values.firstOrNull { it.first == name }?.second }.getOrNull()
+ }
+ ?: fail("no variable named \"$name\" in the current frame; pass variablesReference for a nested one")
+ target.shown() // Some debuggers (Java) offer a modifier only once the value is computed.
+ val modifier = target.modifier ?: fail("the debugger can't change \"$name\"")
+ modifier.changeTo(newValue)
+ // Read it back: the old XValue may still show the old value.
+ val updated = container.children().values.firstOrNull { it.first == name }?.second ?: target
+ val shown = updated.shown()
+ return json(obj("name" to name, "value" to shown.value, "type" to shown.type))
+ }
+
+ suspend fun watch(args: JsonObject): String {
+ val expressions = args.strings("expressions").orEmpty()
+ when (args.str("action")) {
+ "add" -> {
+ if (expressions.isEmpty()) fail("expressions array required for add")
+ synchronized(watches) { expressions.forEach { if (it !in watches) watches += it } }
+ return "Watching ${expressions.size} expression(s). Total watches: ${watches.size}\n${watches.joinToString(", ")}"
+ }
+ "remove" -> {
+ if (expressions.isEmpty()) fail("expressions array required for remove")
+ synchronized(watches) { watches.removeAll(expressions.toSet()) }
+ return "Removed ${expressions.size}. Remaining watches: ${watches.size}"
+ }
+ "clear" -> {
+ val count = synchronized(watches) { watches.size.also { watches.clear() } }
+ return "Cleared all $count watch expression(s)"
+ }
+ "list" -> {
+ val list = synchronized(watches) { watches.toList() }
+ if (list.isEmpty()) return "No watch expressions set. Use action=\"add\" first."
+ val session = debug.findSession()
+ ?: return "Watch expressions (${list.size}): ${list.joinToString(", ")}\n(No active debug session — values not available)"
+ val frame = debug.frameFor(session, args)
+ val result = linkedMapOf()
+ for (e in list) {
+ result[e] = try {
+ val value = evaluate(session, frame, e)
+ val shown = value.shown()
+ if (shown.hasChildren) expand(value, 0, 1, 20) else shown.value
+ } catch (ex: Exception) {
+ ""
+ }
+ }
+ return json(result)
+ }
+ }
+ fail("action must be add, remove, list, or clear")
+ }
+
+ // Includes unsaved edits when the file is open.
+ private suspend fun readLines(path: String): List {
+ findFile(path)?.let { file ->
+ readAction { FileDocumentManager.getInstance().getDocument(file)?.text }?.let { return it.lines() }
+ }
+ return Files.readAllLines(Path.of(path))
+ }
+
+ suspend fun sourceContext(args: JsonObject): String {
+ val session = debug.activeSession()
+ val radius = (args.int("lines") ?: 5).coerceIn(0, 50)
+ val frame = debug.frameOf(session, debug.frameFor(session, args))
+ if (frame.line < 1) fail("no source for this stack frame")
+ val lines = readLines(frame.file)
+ val result = ((frame.line - radius).coerceAtLeast(1)..(frame.line + radius).coerceAtMost(lines.size)).map { n ->
+ obj("line" to n, "text" to lines[n - 1], "current" to (if (n == frame.line) true else null))
+ }
+ return json(obj("file" to frame.file, "line" to frame.line, "function" to frame.name, "lines" to result))
+ }
+
+ fun output(args: JsonObject): String {
+ val page = debug.events.readOutput(args.long("since") ?: 0, args.str("category"), args.str("match"), args.int("limit"))
+ return json(obj(
+ "entries" to page.entries.map { obj("seq" to it.seq, "session" to it.session, "category" to it.category, "text" to it.text) },
+ "nextSince" to page.nextSince,
+ "more" to page.more,
+ ))
+ }
+}
diff --git a/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Sessions.kt b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Sessions.kt
new file mode 100644
index 0000000..a8953fa
--- /dev/null
+++ b/editors/jetbrains/src/main/kotlin/dev/blackbox/tools/Sessions.kt
@@ -0,0 +1,173 @@
+// Run configurations and debug session control.
+
+package dev.blackbox.tools
+
+import com.google.gson.JsonObject
+import com.intellij.execution.ExecutionException
+import com.intellij.execution.ExecutionListener
+import com.intellij.execution.ExecutionManager
+import com.intellij.execution.RunManager
+import com.intellij.execution.RunnerAndConfigurationSettings
+import com.intellij.execution.configurations.RuntimeConfigurationError
+import com.intellij.execution.configurations.RuntimeConfigurationException
+import com.intellij.execution.executors.DefaultDebugExecutor
+import com.intellij.execution.runners.ExecutionEnvironment
+import com.intellij.execution.runners.ExecutionEnvironmentBuilder
+import com.intellij.execution.runners.ExecutionUtil
+import com.intellij.openapi.application.EDT
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Disposer
+import com.intellij.xdebugger.XDebugProcess
+import com.intellij.xdebugger.XDebugSession
+import com.intellij.xdebugger.XDebuggerManager
+import com.intellij.xdebugger.XDebuggerManagerListener
+import dev.blackbox.lang.DebugListener
+import dev.blackbox.lang.LanguageSupport
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+
+class Sessions(private val project: Project, private val debug: Debug) {
+ private val runManager get() = RunManager.getInstance(project)
+
+ private fun runConfigs() = runManager.allSettings.filter { !it.isTemplate }
+
+ // Run configurations, then ways to wait for a program to connect (PHP).
+ fun launchConfigs(): String {
+ val configs = runConfigs().map { obj("name" to it.name, "type" to it.type.displayName, "request" to "launch") } +
+ LanguageSupport.listeners().map { obj("name" to it.name, "type" to "listener", "request" to "attach") }
+ if (configs.isEmpty()) return "No run configurations found (Run | Edit Configurations…)"
+ return json(listOf(obj("folder" to (project.basePath ?: project.name), "configurations" to configs)))
+ }
+
+ private fun find(name: String): RunnerAndConfigurationSettings {
+ runConfigs().firstOrNull { it.name == name }?.let { return it }
+ val names = (runConfigs().map { it.name } + LanguageSupport.listeners().map { it.name }).joinToString(", ").ifEmpty { "(none)" }
+ fail("no launch configuration named \"$name\". Available: $names")
+ }
+
+ private suspend fun listen(listener: DebugListener): String {
+ withContext(Dispatchers.EDT) { listener.setListening(project, true) }
+ if (!listener.isListening(project)) fail("the IDE did not start listening; check its Debug settings (e.g. the Xdebug port)")
+ listenersStarted += listener
+ return "${listener.name}: on. ${listener.hint}"
+ }
+
+ // Listeners Blackbox turned on, turned off again by debug_stop.
+ private val listenersStarted = mutableSetOf()
+
+ private sealed interface Outcome {
+ data object Started : Outcome
+ data object Slow : Outcome
+ data object Stalled : Outcome
+ data class Failed(val message: String) : Outcome
+ }
+
+ suspend fun start(args: JsonObject): String {
+ val name = args.str("configName")?.takeIf { it.isNotEmpty() }
+ ?: fail("JetBrains IDEs start run configurations by name: pass \"configName\" (see debug_get_launch_configs)")
+ LanguageSupport.listeners().firstOrNull { it.name == name && runConfigs().none { c -> c.name == name } }
+ ?.let { return listen(it) }
+ val settings = find(name)
+ val executor = DefaultDebugExecutor.getDebugExecutorInstance()
+ try {
+ settings.checkSettings(executor)
+ } catch (e: RuntimeConfigurationError) {
+ fail("the IDE refused to launch \"$name\": ${e.localizedMessage}")
+ } catch (_: RuntimeConfigurationException) {
+ // A warning; the IDE runs it anyway.
+ }
+
+ val outcome = CompletableDeferred()
+ var scheduled = false
+ val disposable = Disposer.newDisposable("blackbox start")
+ try {
+ val connection = project.messageBus.connect(disposable)
+ fun ours(env: ExecutionEnvironment) = env.runnerAndConfigurationSettings == settings
+ connection.subscribe(ExecutionManager.EXECUTION_TOPIC, object : ExecutionListener {
+ override fun processStartScheduled(executorId: String, env: ExecutionEnvironment) {
+ if (ours(env)) scheduled = true
+ }
+
+ override fun processNotStarted(executorId: String, env: ExecutionEnvironment, cause: Throwable?) {
+ if (ours(env)) outcome.complete(Outcome.Failed(cause?.message ?: "see the IDE's Run/Debug tool window"))
+ }
+
+ override fun processTerminated(executorId: String, env: ExecutionEnvironment, handler: com.intellij.execution.process.ProcessHandler, exitCode: Int) {
+ if (ours(env)) outcome.complete(Outcome.Failed("the program exited with code $exitCode before the debugger attached"))
+ }
+ })
+ connection.subscribe(XDebuggerManager.TOPIC, object : XDebuggerManagerListener {
+ override fun processStarted(process: XDebugProcess) {
+ if (process.session.runProfile == settings.configuration) outcome.complete(Outcome.Started)
+ }
+ })
+
+ withContext(Dispatchers.EDT) {
+ val builder = ExecutionEnvironmentBuilder.createOrNull(executor, settings)
+ ?: fail("\"$name\" (${settings.type.displayName}) can't be debugged in this IDE")
+ try {
+ builder.buildAndExecute()
+ } catch (e: ExecutionException) {
+ outcome.complete(Outcome.Failed(e.message ?: e.toString()))
+ }
+ }
+
+ // Before-launch tasks (a build) can take a while; a start that was
+ // never even scheduled is stuck, e.g. on a dialog.
+ val result = withTimeoutOrNull(START_PROMPT_MS) { outcome.await() }
+ ?: if (!scheduled) Outcome.Stalled
+ else withTimeoutOrNull(LAUNCH_MS - START_PROMPT_MS) { outcome.await() } ?: Outcome.Slow
+
+ return when (result) {
+ Outcome.Started -> "Debug session \"$name\" started from its launch configuration"
+ Outcome.Slow -> "Debug session \"$name\" started from its launch configuration " +
+ "(still building or launching; call debug_wait_for_stop)"
+ Outcome.Stalled -> fail(
+ "the debugger has not started after ${START_PROMPT_MS / 1000}s. Check the IDE for a dialog or prompt; " +
+ "if it starts later, call debug_wait_for_stop rather than starting again.",
+ )
+ is Outcome.Failed -> fail("the IDE refused to launch \"$name\": ${result.message}")
+ }
+ } finally {
+ Disposer.dispose(disposable)
+ }
+ }
+
+ suspend fun stop(): String {
+ val stopped = stopListeners()
+ val session = debug.findSession()
+ ?: return stopped.ifEmpty { null }?.let { "Stopped: ${it.joinToString(", ")}" } ?: "No active debug session"
+ withContext(Dispatchers.EDT) { session.stop() }
+ waitStopped(session, 5_000)
+ return "Debug session \"${session.sessionName}\" stopped" + stopped.joinToString("") { "; stopped $it" }
+ }
+
+ private suspend fun stopListeners(): List {
+ val on = listenersStarted.filter { it.isListening(project) }
+ listenersStarted.clear()
+ if (on.isNotEmpty()) withContext(Dispatchers.EDT) { on.forEach { it.setListening(project, false) } }
+ return on.map { it.name }
+ }
+
+ private suspend fun waitStopped(session: XDebugSession, timeoutMs: Long) {
+ withTimeoutOrNull(timeoutMs) {
+ while (!session.isStopped) delay(50)
+ }
+ }
+
+ suspend fun restart(): String {
+ val session = debug.findSession() ?: fail("no active debug session")
+ val descriptor = session.runContentDescriptor
+ withContext(Dispatchers.EDT) { ExecutionUtil.restart(descriptor) }
+ return "Debug session \"${session.sessionName}\" restarted"
+ }
+
+ companion object {
+ private const val START_PROMPT_MS = 20_000L
+ // Below the MCP server's 60s limit for debug_start, so the note arrives.
+ private const val LAUNCH_MS = 45_000L
+ }
+}
diff --git a/editors/jetbrains/src/main/resources/META-INF/blackbox-java.xml b/editors/jetbrains/src/main/resources/META-INF/blackbox-java.xml
new file mode 100644
index 0000000..17cacc0
--- /dev/null
+++ b/editors/jetbrains/src/main/resources/META-INF/blackbox-java.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/editors/jetbrains/src/main/resources/META-INF/blackbox-php.xml b/editors/jetbrains/src/main/resources/META-INF/blackbox-php.xml
new file mode 100644
index 0000000..ad945c5
--- /dev/null
+++ b/editors/jetbrains/src/main/resources/META-INF/blackbox-php.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/editors/jetbrains/src/main/resources/META-INF/blackbox-terminal.xml b/editors/jetbrains/src/main/resources/META-INF/blackbox-terminal.xml
new file mode 100644
index 0000000..206cf48
--- /dev/null
+++ b/editors/jetbrains/src/main/resources/META-INF/blackbox-terminal.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/editors/jetbrains/src/main/resources/META-INF/plugin.xml b/editors/jetbrains/src/main/resources/META-INF/plugin.xml
new file mode 100644
index 0000000..794d272
--- /dev/null
+++ b/editors/jetbrains/src/main/resources/META-INF/plugin.xml
@@ -0,0 +1,35 @@
+
+ dev.blackbox.jetbrains
+ Blackbox
+ xcode-studio
+ Lets AI tools (Claude Code, Cursor, any MCP client) drive the IDE's debugger through the
+ Blackbox MCP server: set breakpoints, start run
+ configurations by name, wait for pauses, step, inspect and change variables, and read logpoint output.
+ Works in every IntelliJ-based IDE (PhpStorm, IntelliJ IDEA, GoLand, PyCharm, WebStorm, ...), with the same
+ tools as the Blackbox extensions for VS Code and Neovim.
+ ]]>
+
+ com.intellij.modules.platform
+ com.intellij.modules.xdebugger
+ com.intellij.java
+ com.jetbrains.php
+ org.jetbrains.plugins.terminal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/editors/jetbrains/src/test/kotlin/dev/blackbox/BridgeServerTest.kt b/editors/jetbrains/src/test/kotlin/dev/blackbox/BridgeServerTest.kt
new file mode 100644
index 0000000..99f5a61
--- /dev/null
+++ b/editors/jetbrains/src/test/kotlin/dev/blackbox/BridgeServerTest.kt
@@ -0,0 +1,157 @@
+package dev.blackbox
+
+import com.google.gson.JsonParser
+import dev.blackbox.ipc.AppInfo
+import dev.blackbox.ipc.BridgeServer
+import dev.blackbox.ipc.Protocol
+import dev.blackbox.ipc.ToolError
+import dev.blackbox.ipc.ToolHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeFalse
+import org.junit.Before
+import org.junit.Test
+import java.net.InetSocketAddress
+import java.net.StandardProtocolFamily
+import java.net.UnixDomainSocketAddress
+import java.nio.ByteBuffer
+import java.nio.channels.SocketChannel
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.attribute.PosixFilePermissions
+
+class BridgeServerTest {
+ private lateinit var dir: Path
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val servers = mutableListOf()
+
+ private val handlers: Map = mapOf(
+ "echo" to { args -> args.get("text")?.asString ?: "" },
+ "slow" to { _ -> delay(300); "late" },
+ "refuse" to { _ -> throw ToolError("no active debug session") },
+ )
+
+ @Before
+ fun setUp() {
+ // Short: macOS limits socket paths to 104 bytes.
+ dir = Files.createTempDirectory(Path.of(if (Protocol.isWindows) System.getProperty("java.io.tmpdir") else "/tmp"), "bbx-")
+ }
+
+ @After
+ fun tearDown() {
+ servers.forEach { it.dispose() }
+ scope.cancel()
+ dir.toFile().deleteRecursively()
+ }
+
+ private fun start(useTcp: Boolean = false): BridgeServer =
+ BridgeServer(scope, "4242-ab", listOf("/work"), AppInfo("IntelliJ IDEA", "jetbrains", "2024.3"), "0.1.0", handlers, dir, useTcp)
+ .also { it.start(); servers += it }
+
+ private fun connect(socket: String): SocketChannel =
+ if (socket.startsWith("tcp://")) {
+ val port = socket.removePrefix("tcp://127.0.0.1:").substringBefore('/').toInt()
+ SocketChannel.open(InetSocketAddress("127.0.0.1", port))
+ } else {
+ SocketChannel.open(StandardProtocolFamily.UNIX).apply { connect(UnixDomainSocketAddress.of(socket)) }
+ }
+
+ // Sends the lines on one connection and reads as many response lines.
+ private fun exchange(socket: String, vararg lines: String): List = connect(socket).use { channel ->
+ channel.write(ByteBuffer.wrap(lines.joinToString("") { "$it\n" }.toByteArray()))
+ val buffer = ByteBuffer.allocate(64 * 1024)
+ val text = StringBuilder()
+ while (text.count { it == '\n' } < lines.size && channel.read(buffer) >= 0) {
+ buffer.flip()
+ text.append(Charsets.UTF_8.decode(buffer))
+ buffer.clear()
+ }
+ text.lines().filter { it.isNotEmpty() }
+ }
+
+ private fun field(response: String, name: String) =
+ JsonParser.parseString(response).asJsonObject.get(name)?.takeIf { !it.isJsonNull }?.asString
+
+ @Test
+ fun `writes a registry entry the MCP server can read`() {
+ val server = start()
+ val entry = JsonParser.parseString(Files.readString(dir.resolve("4242-ab.json"))).asJsonObject
+ assertEquals("4242-ab", entry["id"].asString)
+ assertEquals(ProcessHandle.current().pid(), entry["pid"].asLong)
+ assertEquals(entry["pid"].asLong, entry["appPid"].asLong)
+ assertEquals(server.entry.socket, entry["socket"].asString)
+ assertEquals(listOf("/work"), entry["folders"].asJsonArray.map { it.asString })
+ assertEquals("IntelliJ IDEA", entry["app"].asJsonObject["name"].asString)
+ assertEquals("jetbrains", entry["app"].asJsonObject["scheme"].asString)
+ assertEquals(Protocol.VERSION, entry["protocol"].asInt)
+ assertTrue(entry.has("startedAt") && entry.has("focusedAt") && entry.has("extensionVersion"))
+ }
+
+ @Test
+ fun `answers requests, several per connection, in any order`() {
+ val server = start()
+ val responses = exchange(
+ server.entry.socket,
+ """{"id":"1","tool":"slow","args":{}}""",
+ """{"id":"2","tool":"echo","args":{"text":"hi"}}""",
+ )
+ assertEquals(listOf("2", "1"), responses.map { field(it, "id") })
+ assertEquals("hi", field(responses[0], "result"))
+ assertEquals("late", field(responses[1], "result"))
+ }
+
+ @Test
+ fun `reports unknown tools and tool errors`() {
+ val server = start()
+ val (unknown, refused) = exchange(
+ server.entry.socket,
+ """{"id":"1","tool":"nope","args":{}}""",
+ """{"id":"2","tool":"refuse","args":{}}""",
+ ).sortedBy { field(it, "id") }
+ assertEquals("unknown tool: nope", field(unknown, "error"))
+ assertEquals("no active debug session", field(refused, "error"))
+ assertNull(field(refused, "result"))
+ }
+
+ @Test
+ fun `over TCP, answers only requests with the token`() {
+ val server = start(useTcp = true)
+ val socket = server.entry.socket
+ assertTrue(socket, Regex("""tcp://127\.0\.0\.1:\d+/[0-9a-f]{64}""").matches(socket))
+ val token = socket.substringAfterLast('/')
+ val (wrong, right) = exchange(
+ socket,
+ """{"id":"1","tool":"echo","args":{"text":"x"},"token":"bad"}""",
+ """{"id":"2","tool":"echo","args":{"text":"x"},"token":"$token"}""",
+ ).sortedBy { field(it, "id") }
+ assertEquals("invalid token", field(wrong, "error"))
+ assertEquals("x", field(right, "result"))
+ }
+
+ @Test
+ fun `cleans up its socket and registry entry`() {
+ val server = start()
+ server.dispose()
+ assertFalse(Files.exists(dir.resolve("4242-ab.json")))
+ assertFalse(Files.exists(Path.of(server.entry.socket)))
+ }
+
+ @Test
+ fun `refuses a folder other users can reach`() {
+ assumeFalse(Protocol.isWindows)
+ val open = Files.createDirectory(dir.resolve("open"), PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-xr-x")))
+ Files.setPosixFilePermissions(open, PosixFilePermissions.fromString("rwxr-xr-x"))
+ assertTrue(Protocol.privateDirProblem(open)!!.contains("accessible to other users"))
+ Protocol.ensurePrivateDir(open) // Tightens a folder this user owns.
+ assertNull(Protocol.privateDirProblem(open))
+ assertEquals("rwx------", PosixFilePermissions.toString(Files.getPosixFilePermissions(open)))
+ }
+}
diff --git a/editors/jetbrains/src/test/kotlin/dev/blackbox/HitConditionTest.kt b/editors/jetbrains/src/test/kotlin/dev/blackbox/HitConditionTest.kt
new file mode 100644
index 0000000..da927e2
--- /dev/null
+++ b/editors/jetbrains/src/test/kotlin/dev/blackbox/HitConditionTest.kt
@@ -0,0 +1,28 @@
+package dev.blackbox
+
+import dev.blackbox.debug.HitCondition
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class HitConditionTest {
+ private fun hits(condition: String) = (1..10).filter { HitCondition.matches(condition, it) }
+
+ @Test
+ fun `follows the tool contract's examples`() {
+ assertEquals(listOf(5), hits("5"))
+ assertEquals(listOf(8, 9, 10), hits(">= 8"))
+ assertEquals(listOf(3, 6, 9), hits("% 3"))
+ assertEquals(listOf(1, 2), hits("<3"))
+ assertEquals(listOf(4), hits("== 4"))
+ }
+
+ @Test
+ fun `knows what it can't apply`() {
+ assertTrue(HitCondition.isValid(" >= 10 "))
+ assertFalse(HitCondition.isValid("i > 3"))
+ assertFalse(HitCondition.isValid(""))
+ assertEquals(emptyList(), hits("% 0"))
+ }
+}
diff --git a/editors/jetbrains/src/test/kotlin/dev/blackbox/McpInstallTest.kt b/editors/jetbrains/src/test/kotlin/dev/blackbox/McpInstallTest.kt
new file mode 100644
index 0000000..9544e2e
--- /dev/null
+++ b/editors/jetbrains/src/test/kotlin/dev/blackbox/McpInstallTest.kt
@@ -0,0 +1,35 @@
+package dev.blackbox
+
+import dev.blackbox.launcher.McpInstall
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+// Same cases as editors/vscode/src/test/unit/launcher.test.ts.
+class McpInstallTest {
+ @Test
+ fun `compares versions numerically, pre-releases first`() {
+ assertTrue(McpInstall.compareVersions("0.10.0", "0.9.9") > 0)
+ assertEquals(0, McpInstall.compareVersions("1.2", "1.2.0"))
+ assertTrue(McpInstall.compareVersions("1.0.0-beta", "1.0.0") < 0)
+ assertTrue(McpInstall.compareVersions("1.0.0", "1.0.0-beta") > 0)
+ }
+
+ @Test
+ fun `replaces the launcher only with a newer one, or when its install is gone`() {
+ val stamp = McpInstall.launcherStamp("0.5.1", "/ext/server.js") + "\n// code"
+ val alive = { _: String -> true }
+ assertTrue(McpInstall.shouldReplaceLauncher(null, "0.5.0", alive))
+ assertTrue(McpInstall.shouldReplaceLauncher("// no stamp", "0.5.0", alive))
+ assertFalse(McpInstall.shouldReplaceLauncher(stamp, "0.5.1", alive))
+ assertFalse(McpInstall.shouldReplaceLauncher(stamp, "0.5.0", alive))
+ assertTrue(McpInstall.shouldReplaceLauncher(stamp, "0.6.0", alive))
+ assertTrue(McpInstall.shouldReplaceLauncher(stamp, "0.5.0") { false })
+ }
+
+ @Test
+ fun `reads the stamp the VS Code extension writes`() {
+ assertEquals("0.5.1" to "/a b/server.js", McpInstall.readStamp("// blackbox-mcp launcher v0.5.1 from /a b/server.js\nrest"))
+ }
+}
diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore
index 5c91b3f..68973e0 100644
--- a/editors/vscode/.vscodeignore
+++ b/editors/vscode/.vscodeignore
@@ -11,4 +11,4 @@ tsconfig.json
.gitignore
node_modules/.bin/
package-lock.json
-ext.sh
\ No newline at end of file
+ext.shdist-mcp/
diff --git a/editors/vscode/CHANGELOG.md b/editors/vscode/CHANGELOG.md
index 65a873c..07f7290 100644
--- a/editors/vscode/CHANGELOG.md
+++ b/editors/vscode/CHANGELOG.md
@@ -4,6 +4,11 @@ All notable changes to the Blackbox VS Code extension. Each version is also a [G
Releases are published by pushing a `vscode-v` tag; CI refuses to release a version without an entry here. Changes collect under `## Unreleased`; at release time that heading becomes `## (Pre-release) — ` (leave out `(Pre-release)` for a stable release).
+## Unreleased
+
+- **JetBrains IDEs:** the MCP server reaches windows that listen on TCP loopback with a token (`tcp://127.0.0.1:/`), as the JetBrains plugin does on Windows.
+- An AI tool started from an editor that serves several windows from one process (a JetBrains IDE) is matched to that editor, not to an arbitrary one of its windows.
+
## 0.5.1 — 2026-09-30
- **Launcher can't be downgraded:** `~/.blackbox/blackbox-mcp.js` now records which version wrote it. An editor with an older Blackbox leaves a newer launcher alone; a rollback replaces it once the newer install is gone.
diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json
index f36277c..554f1ec 100644
--- a/editors/vscode/package-lock.json
+++ b/editors/vscode/package-lock.json
@@ -18,6 +18,7 @@
"@types/node": "^20.0.0",
"@types/vscode": "^1.99.0",
"@vscode/test-electron": "^3.1.0",
+ "esbuild": "^0.28.2",
"eslint": "^9.0.0",
"mocha": "^10.0.0",
"typescript": "^5.5.0"
@@ -26,6 +27,448 @@
"vscode": "^1.99.0"
}
},
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -1049,6 +1492,48 @@
"node": ">= 0.4"
}
},
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
diff --git a/editors/vscode/package.json b/editors/vscode/package.json
index 909ccdb..5101cd4 100644
--- a/editors/vscode/package.json
+++ b/editors/vscode/package.json
@@ -851,6 +851,7 @@
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -p ./ && node scripts/copy-schema.js",
+ "bundle:mcp": "node scripts/bundle-mcp.js",
"watch": "tsc -watch -p ./",
"lint": "eslint src --ext ts",
"test": "node ./out/test/runTest.js",
@@ -863,6 +864,7 @@
"@types/node": "^20.0.0",
"@types/vscode": "^1.99.0",
"@vscode/test-electron": "^3.1.0",
+ "esbuild": "^0.28.2",
"eslint": "^9.0.0",
"mocha": "^10.0.0",
"typescript": "^5.5.0"
diff --git a/editors/vscode/scripts/bundle-mcp.js b/editors/vscode/scripts/bundle-mcp.js
new file mode 100644
index 0000000..3fd15ba
--- /dev/null
+++ b/editors/vscode/scripts/bundle-mcp.js
@@ -0,0 +1,21 @@
+// Bundles the MCP server into one file, for editors that ship it without
+// node_modules (the JetBrains plugin):
+//
+// dist-mcp/mcp/server.js the server, with its dependencies
+// dist-mcp/tools.json the tool contract it serves (read from ../tools.json)
+// dist-mcp/launcher.js ~/.blackbox/blackbox-mcp.js (Node built-ins only)
+// dist-mcp/version.txt the server's version, for install records
+
+const esbuild = require('esbuild');
+const fs = require('fs');
+const path = require('path');
+
+const root = path.resolve(__dirname, '..');
+const out = path.join(root, 'dist-mcp');
+fs.rmSync(out, { recursive: true, force: true });
+
+const common = { bundle: true, platform: 'node', target: 'node18', format: 'cjs', logLevel: 'warning' };
+esbuild.buildSync({ ...common, entryPoints: [path.join(root, 'src/mcp/server.ts')], outfile: path.join(out, 'mcp/server.js') });
+esbuild.buildSync({ ...common, entryPoints: [path.join(root, 'src/launcher/blackboxMcp.ts')], outfile: path.join(out, 'launcher.js') });
+fs.copyFileSync(path.resolve(root, '../../schema/tools.json'), path.join(out, 'tools.json'));
+fs.writeFileSync(path.join(out, 'version.txt'), require(path.join(root, 'package.json')).version);
diff --git a/editors/vscode/scripts/release-notes.js b/editors/vscode/scripts/release-notes.js
index 34aa94d..b11982e 100644
--- a/editors/vscode/scripts/release-notes.js
+++ b/editors/vscode/scripts/release-notes.js
@@ -3,6 +3,8 @@
// node scripts/release-notes.js 0.5.0 -> the notes (markdown)
// node scripts/release-notes.js 0.5.0 --prerelease -> "true" or "false"
//
+// CHANGELOG= reads another editor's changelog (default: this one).
+//
// Exits with an error if the version has no entry or the entry is empty, so
// nothing is released without release notes. Headings look like:
// ## 0.5.0 (Pre-release) — 2026-10-01
@@ -16,7 +18,7 @@ if (!version) {
process.exit(2);
}
-const changelog = fs.readFileSync(path.resolve(__dirname, '../CHANGELOG.md'), 'utf8');
+const changelog = fs.readFileSync(process.env.CHANGELOG || path.resolve(__dirname, '../CHANGELOG.md'), 'utf8');
const sections = changelog.split(/^## /m).slice(1);
const section = sections.find(s => s.split(/[\s(—]/)[0] === version);
if (!section) {
diff --git a/editors/vscode/src/ipc/client.ts b/editors/vscode/src/ipc/client.ts
index 1627f27..d3cfb61 100644
--- a/editors/vscode/src/ipc/client.ts
+++ b/editors/vscode/src/ipc/client.ts
@@ -3,7 +3,7 @@
// (mcp/session.ts).
import * as net from 'net';
-import { IPCRequest, IPCResponse } from './protocol';
+import { IPCRequest, IPCResponse, isTcpSocket, parseTcpSocket } from './protocol';
const RETRY_DELAYS_MS = [250, 500, 750];
// Only errors where the request never reached VS Code, so a retry cannot
@@ -47,9 +47,14 @@ export async function withRetry(fn: () => Promise): Promise {
}
export const callExtension: Send = (socket, { tool, args, timeoutMs }) => {
- const req: IPCRequest = { id: String(++requestId), tool, args };
+ const tcp = parseTcpSocket(socket);
+ if (isTcpSocket(socket) && !tcp) {
+ return Promise.reject(new IPCError(`Unsupported window address ${socket.replace(/\/[0-9a-f]+$/, '/…')}; update Blackbox.`));
+ }
+ const req: IPCRequest = { id: String(++requestId), tool, args, ...(tcp && { token: tcp.token }) };
return new Promise((resolve, reject) => {
- const client = net.createConnection(socket, () => {
+ const target: net.NetConnectOpts = tcp ? { host: tcp.host, port: tcp.port } : { path: socket };
+ const client = net.createConnection(target, () => {
client.write(JSON.stringify(req) + '\n');
});
const timer = setTimeout(() => {
diff --git a/editors/vscode/src/ipc/protocol.ts b/editors/vscode/src/ipc/protocol.ts
index 138ee44..258ad9d 100644
--- a/editors/vscode/src/ipc/protocol.ts
+++ b/editors/vscode/src/ipc/protocol.ts
@@ -24,6 +24,7 @@ export interface IPCRequest {
id: string;
tool: string;
args: Record;
+ token?: string; // TCP sockets only, see parseTcpSocket.
}
export interface IPCResponse {
@@ -121,6 +122,22 @@ export function socketPath(id: string): string {
return path.join(ipcDir(), `${id}.sock`);
}
+// Windows where Node can't reach the editor's Unix socket (JetBrains IDEs on
+// Windows: Java has no named pipes) listen on TCP loopback instead. Their
+// registry entry's socket is "tcp://127.0.0.1:/", and every
+// request carries the token; the registry folder is private, so only this
+// user can read it.
+const TCP_SOCKET = /^tcp:\/\/(127\.0\.0\.1|localhost):(\d+)\/([0-9a-f]+)$/;
+
+export function isTcpSocket(socket: string): boolean {
+ return socket.startsWith('tcp://');
+}
+
+export function parseTcpSocket(socket: string): { host: string; port: number; token: string } | undefined {
+ const match = TCP_SOCKET.exec(socket);
+ return match ? { host: match[1], port: Number(match[2]), token: match[3] } : undefined;
+}
+
export function registryPath(id: string): string {
return path.join(ipcDir(), `${id}.json`);
}
diff --git a/editors/vscode/src/ipc/registry.ts b/editors/vscode/src/ipc/registry.ts
index d531541..d4dcc38 100644
--- a/editors/vscode/src/ipc/registry.ts
+++ b/editors/vscode/src/ipc/registry.ts
@@ -4,13 +4,13 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
-import { RegistryEntry, UNKNOWN_APP, ipcDir, isPrivateDir } from './protocol';
+import { RegistryEntry, UNKNOWN_APP, ipcDir, isPrivateDir, isTcpSocket } from './protocol';
// A window's socket disappears with it. Checking it too catches entries
-// whose pid was reused by another process. Named pipes (Windows) aren't
-// files, so there only the pid is checked.
+// whose pid was reused by another process. Named pipes (Windows) and TCP
+// sockets aren't files, so there only the pid is checked.
function socketExists(socket: string): boolean {
- return process.platform === 'win32' || fs.existsSync(socket);
+ return process.platform === 'win32' || isTcpSocket(socket) || fs.existsSync(socket);
}
function isAlive(pid: number): boolean {
diff --git a/editors/vscode/src/mcp/launch.ts b/editors/vscode/src/mcp/launch.ts
index f04a27f..fca3f55 100644
--- a/editors/vscode/src/mcp/launch.ts
+++ b/editors/vscode/src/mcp/launch.ts
@@ -51,9 +51,11 @@ export function detectLaunch(windows: readonly RegistryEntry[], ancestors: reado
if (fromTerminal) {
return { window: fromTerminal };
}
- const fromChatPanel = windows.find(w => ancestors.includes(w.pid));
- if (fromChatPanel) {
- return { window: fromChatPanel };
+ // Only a process of its own identifies a window: JetBrains IDEs serve
+ // every project window from one process, so there it names the editor.
+ const fromChatPanel = windows.filter(w => ancestors.includes(w.pid));
+ if (fromChatPanel.length === 1) {
+ return { window: fromChatPanel[0] };
}
const editor = windows.find(w => w.appPid > 0 && ancestors.includes(w.appPid));
return editor ? { appPid: editor.appPid } : {};
diff --git a/editors/vscode/src/test/unit/ipc.test.ts b/editors/vscode/src/test/unit/ipc.test.ts
index 52314e3..6396ae1 100644
--- a/editors/vscode/src/test/unit/ipc.test.ts
+++ b/editors/vscode/src/test/unit/ipc.test.ts
@@ -3,6 +3,7 @@
import * as assert from 'assert';
import * as fs from 'fs';
+import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import { ChildProcess, spawn } from 'child_process';
@@ -124,6 +125,33 @@ suite('IPC bridge', () => {
assert.strictEqual(await who(wsA), 'A');
});
+ test('reaches a TCP window (JetBrains on Windows) with its token', async () => {
+ const seen: unknown[] = [];
+ const server = net.createServer(socket => {
+ socket.on('data', chunk => {
+ const req = JSON.parse(chunk.toString().trim());
+ seen.push(req.token);
+ socket.end(JSON.stringify({ id: req.id, result: `hello ${req.tool}` }) + '\n');
+ });
+ });
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ try {
+ const port = (server.address() as net.AddressInfo).port;
+ const resp = await callExtension(`tcp://127.0.0.1:${port}/abc123`, { tool: 'whoami', args: {}, timeoutMs: 2000 });
+ assert.strictEqual(resp.result, 'hello whoami');
+ assert.deepStrictEqual(seen, ['abc123']);
+ await assert.rejects(callExtension('tcp://example.com:80/abc', { tool: 'whoami', args: {}, timeoutMs: 2000 }), /Unsupported window address/);
+ } finally {
+ server.close();
+ }
+ });
+
+ test('keeps TCP windows in the registry: their socket is not a file', async () => {
+ const tcp = makeEntry('T', [wsA], { pid: windowOwner.pid, socket: 'tcp://127.0.0.1:1/ab' });
+ fs.writeFileSync(path.join(ipcDir, 'T.json'), JSON.stringify(tcp));
+ assert.ok(listWindows().some(w => w.id === 'T'));
+ });
+
test('returns handler errors as errors', async () => {
await open('A', wsA);
const { resp } = await sessionAt(wsA).call({ tool: 'boom', args: {}, timeoutMs: 2000 });
diff --git a/editors/vscode/src/test/unit/launch.test.ts b/editors/vscode/src/test/unit/launch.test.ts
index 3e8bc65..d319256 100644
--- a/editors/vscode/src/test/unit/launch.test.ts
+++ b/editors/vscode/src/test/unit/launch.test.ts
@@ -29,6 +29,16 @@ suite('detectLaunch', () => {
assert.deepStrictEqual(detectLaunch(WINDOWS, [], { BLACKBOX_WINDOW: '999' }), {});
});
+ test('several windows in one process (JetBrains projects): the process names only the editor', () => {
+ const ide = 6000;
+ const projects = [
+ makeEntry('6000-aa', ['/sites/a'], { pid: ide, appPid: ide }),
+ makeEntry('6000-bb', ['/sites/b'], { pid: ide, appPid: ide }),
+ ];
+ assert.deepStrictEqual(detectLaunch(projects, [7001, ide], {}), { appPid: ide });
+ assert.strictEqual(detectLaunch(projects, [7001, ide], { BLACKBOX_WINDOW: '6000-bb' }).window?.id, '6000-bb');
+ });
+
test('external terminal: no link', () => {
assert.deepStrictEqual(detectLaunch(WINDOWS, [7001, 43533], {}), {});
});