diff --git a/Cargo.lock b/Cargo.lock
index 1be81d5..ec8ed3c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -616,7 +616,7 @@ dependencies = [
[[package]]
name = "mb-adapters"
-version = "0.3.6"
+version = "0.3.7"
dependencies = [
"mb-protocol",
"notify",
@@ -628,7 +628,7 @@ dependencies = [
[[package]]
name = "mb-device"
-version = "0.3.6"
+version = "0.3.7"
dependencies = [
"hidapi",
"mb-protocol",
@@ -639,7 +639,7 @@ dependencies = [
[[package]]
name = "mb-protocol"
-version = "0.3.6"
+version = "0.3.7"
dependencies = [
"serde",
"serde_json",
@@ -653,7 +653,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "microbridgectl"
-version = "0.3.6"
+version = "0.3.7"
dependencies = [
"mb-device",
"mb-protocol",
@@ -663,7 +663,7 @@ dependencies = [
[[package]]
name = "microbridged"
-version = "0.3.6"
+version = "0.3.7"
dependencies = [
"keyring",
"mb-adapters",
diff --git a/Cargo.toml b/Cargo.toml
index e24cfa0..f6de07d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
-version = "0.3.6"
+version = "0.3.7"
edition = "2021"
license = "MIT"
repository = "https://github.com/DevVig/microbridge"
diff --git a/adapters/cursor/hooks/event.mjs b/adapters/cursor/hooks/event.mjs
index 94d306b..5f4216c 100644
--- a/adapters/cursor/hooks/event.mjs
+++ b/adapters/cursor/hooks/event.mjs
@@ -59,6 +59,7 @@ export function lifecycleMessages(event, now = Date.now()) {
title: event.title,
state,
updated_at_ms: now,
+ focus_uri: event.workspace ? `cursor://file${event.workspace}` : null,
},
ttl_ms: event.lifecycle === "session_end" ? 1_000 : 30 * 60 * 1_000,
},
diff --git a/adapters/sdk/index.mjs b/adapters/sdk/index.mjs
new file mode 100644
index 0000000..0e18767
--- /dev/null
+++ b/adapters/sdk/index.mjs
@@ -0,0 +1,105 @@
+// @microbridge/adapter-sdk
+// Zero-dependency Node.js/JS SDK for building Microbridge adapters.
+
+import net from "node:net";
+import os from "node:os";
+import path from "node:path";
+
+export class MicrobridgeAdapter {
+ constructor({ id, version = "1.0.0", capabilities = {} }) {
+ this.id = id;
+ this.version = version;
+ this.capabilities = {
+ lifecycle_observation: true,
+ approval_acceptance: false,
+ approval_rejection: false,
+ interrupt: false,
+ new_session: false,
+ focus_open: false,
+ reasoning_effort: false,
+ tty_control: false,
+ mcp_native: false,
+ uri_focus: false,
+ ...capabilities,
+ };
+
+ this.socketPath =
+ process.env.MICROBRIDGE_SOCKET ||
+ path.join(os.homedir(), ".microbridge", "microbridged.sock");
+ this.socket = null;
+ this.actionListeners = new Map();
+ }
+
+ connect() {
+ return new Promise((resolve, reject) => {
+ this.socket = net.createConnection(this.socketPath, () => {
+ const hello = {
+ type: "hello",
+ adapter: this.id,
+ protocol_version: 0,
+ adapter_version: this.version,
+ capabilities: this.capabilities,
+ };
+ this.socket.write(`${JSON.stringify(hello)}\n`);
+ resolve(true);
+ });
+
+ this.socket.on("error", (err) => {
+ reject(err);
+ });
+
+ let buffer = "";
+ this.socket.on("data", (chunk) => {
+ buffer += chunk.toString("utf8");
+ const lines = buffer.split("\n");
+ buffer = lines.pop();
+ for (const line of lines) {
+ if (!line.trim()) continue;
+ try {
+ const msg = JSON.parse(line);
+ if (msg.type === "action") {
+ const handler = this.actionListeners.get(msg.action);
+ if (handler) handler(msg.session_id);
+ }
+ } catch (_) {}
+ }
+ });
+ });
+ }
+
+ reportStatus({ id, app, title = "", state, focusUri }) {
+ if (!this.socket || !this.socket.writable) return;
+ const msg = {
+ type: "status",
+ session: {
+ id: `${this.id}:${id}`,
+ app: app || this.id,
+ title,
+ state,
+ updated_at_ms: Date.now(),
+ focus_uri: focusUri || null,
+ },
+ };
+ this.socket.write(`${JSON.stringify(msg)}\n`);
+ }
+
+ reportBye(id) {
+ if (!this.socket || !this.socket.writable) return;
+ const msg = {
+ type: "bye",
+ session_id: `${this.id}:${id}`,
+ };
+ this.socket.write(`${JSON.stringify(msg)}\n`);
+ }
+
+ onAction(actionName, callback) {
+ this.actionListeners.set(actionName, callback);
+ }
+
+ disconnect() {
+ if (this.socket) {
+ this.socket.end();
+ this.socket = null;
+ }
+ }
+}
diff --git a/adapters/sdk/package.json b/adapters/sdk/package.json
new file mode 100644
index 0000000..aec6bc8
--- /dev/null
+++ b/adapters/sdk/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@microbridge/adapter-sdk",
+ "version": "0.3.7",
+ "description": "Zero-dependency SDK for publishing AI agent session states to Microbridge",
+ "main": "index.mjs",
+ "type": "module",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/DevVig/microbridge.git"
+ },
+ "keywords": [
+ "microbridge",
+ "agent",
+ "macropad",
+ "codex-micro",
+ "adapter"
+ ]
+}
diff --git a/apps/microbridge-ui/package.json b/apps/microbridge-ui/package.json
index 2dec727..5e74d9b 100644
--- a/apps/microbridge-ui/package.json
+++ b/apps/microbridge-ui/package.json
@@ -1,7 +1,7 @@
{
"name": "microbridge-ui",
"private": true,
- "version": "0.3.6",
+ "version": "0.3.7",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/apps/microbridge-ui/src-tauri/Cargo.lock b/apps/microbridge-ui/src-tauri/Cargo.lock
index f2472a6..0f85993 100644
--- a/apps/microbridge-ui/src-tauri/Cargo.lock
+++ b/apps/microbridge-ui/src-tauri/Cargo.lock
@@ -43,9 +43,9 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.103"
+version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arbitrary"
@@ -200,9 +200,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
-version = "1.25.1"
+version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
@@ -273,7 +273,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
@@ -288,9 +288,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.2.67"
+version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
+checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -790,9 +790,9 @@ dependencies = [
[[package]]
name = "fastrand"
-version = "2.4.1"
+version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
@@ -889,24 +889,24 @@ dependencies = [
[[package]]
name = "futures-channel"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [
"futures-core",
"futures-task",
@@ -915,15 +915,15 @@ dependencies = [
[[package]]
name = "futures-io"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-macro"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
@@ -932,21 +932,21 @@ dependencies = [
[[package]]
name = "futures-sink"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-util"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-core",
"futures-io",
@@ -1182,9 +1182,9 @@ dependencies = [
[[package]]
name = "glob"
-version = "0.3.3"
+version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "gobject-sys"
@@ -1330,9 +1330,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
-version = "1.10.1"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
@@ -1643,7 +1643,7 @@ dependencies = [
"jni-sys 0.4.1",
"log",
"simd_cesu8",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"walkdir",
"windows-link 0.2.1",
]
@@ -1759,9 +1759,9 @@ dependencies = [
[[package]]
name = "libc"
-version = "0.2.186"
+version = "0.2.188"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b"
[[package]]
name = "libdbus-sys"
@@ -1831,7 +1831,7 @@ dependencies = [
[[package]]
name = "mb-protocol"
-version = "0.3.6"
+version = "0.3.7"
dependencies = [
"serde",
]
@@ -1918,7 +1918,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"windows-sys 0.61.2",
]
@@ -2247,7 +2247,7 @@ dependencies = [
"objc2-osa-kit",
"serde",
"serde_json",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
@@ -2484,9 +2484,9 @@ dependencies = [
[[package]]
name = "proc-macro2"
-version = "1.0.106"
+version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
@@ -2502,9 +2502,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.46"
+version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
@@ -2555,27 +2555,27 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
name = "ref-cast"
-version = "1.0.25"
+version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
+checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
-version = "1.0.25"
+version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
+checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.2",
]
[[package]]
@@ -2920,9 +2920,9 @@ dependencies = [
[[package]]
name = "serde"
-version = "1.0.228"
+version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
@@ -2942,22 +2942,22 @@ dependencies = [
[[package]]
name = "serde_core"
-version = "1.0.228"
+version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
-version = "1.0.228"
+version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.2",
]
[[package]]
@@ -2973,9 +2973,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.150"
+version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@@ -2986,13 +2986,13 @@ dependencies = [
[[package]]
name = "serde_repr"
-version = "0.1.20"
+version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.2",
]
[[package]]
@@ -3307,6 +3307,17 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "syn"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@@ -3449,7 +3460,7 @@ dependencies = [
"tauri-runtime",
"tauri-runtime-wry",
"tauri-utils",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tokio",
"tray-icon",
"url",
@@ -3500,7 +3511,7 @@ dependencies = [
"sha2",
"syn 2.0.119",
"tauri-utils",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"time",
"url",
"uuid",
@@ -3548,14 +3559,14 @@ dependencies = [
"serde_json",
"tauri",
"tauri-plugin",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
name = "tauri-plugin-dialog"
-version = "2.7.1"
+version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
+checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940"
dependencies = [
"log",
"raw-window-handle",
@@ -3565,7 +3576,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"url",
]
@@ -3588,7 +3599,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tauri-utils",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"url",
]
@@ -3620,7 +3631,7 @@ dependencies = [
"shared_child",
"tauri",
"tauri-plugin",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tokio",
]
@@ -3649,7 +3660,7 @@ dependencies = [
"tauri",
"tauri-plugin",
"tempfile",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"time",
"tokio",
"url",
@@ -3675,7 +3686,7 @@ dependencies = [
"serde",
"serde_json",
"tauri-utils",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"url",
"webkit2gtk",
"webview2-com",
@@ -3738,7 +3749,7 @@ dependencies = [
"serde_json",
"serde_with",
"swift-rs",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"toml 1.1.3+spec-1.1.0",
"url",
"urlpattern",
@@ -3790,11 +3801,11 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.18"
+version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
- "thiserror-impl 2.0.18",
+ "thiserror-impl 2.0.19",
]
[[package]]
@@ -3810,20 +3821,20 @@ dependencies = [
[[package]]
name = "thiserror-impl"
-version = "2.0.18"
+version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.2",
]
[[package]]
name = "time"
-version = "0.3.53"
+version = "0.3.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
+checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
dependencies = [
"deranged",
"num-conv",
@@ -3841,9 +3852,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
-version = "0.2.31"
+version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
@@ -3876,9 +3887,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
-version = "1.53.0"
+version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
@@ -3912,9 +3923,9 @@ dependencies = [
[[package]]
name = "tokio-util"
-version = "0.7.18"
+version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
@@ -4125,7 +4136,7 @@ dependencies = [
"once_cell",
"png 0.18.1",
"serde",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"windows-sys 0.61.2",
]
@@ -4489,7 +4500,7 @@ version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"windows",
"windows-core 0.61.2",
]
@@ -5016,7 +5027,7 @@ dependencies = [
"sha2",
"soup3",
"tao-macros",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"url",
"webkit2gtk",
"webkit2gtk-sys",
diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json
index 9218846..7653026 100644
--- a/apps/microbridge-ui/src-tauri/tauri.conf.json
+++ b/apps/microbridge-ui/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Microbridge",
- "version": "0.3.6",
+ "version": "0.3.7",
"identifier": "ai.microbridge.ui",
"build": {
"beforeDevCommand": "npm run dev",
diff --git a/apps/microbridge-ui/src/components/IntegrationCard.tsx b/apps/microbridge-ui/src/components/IntegrationCard.tsx
index 56c0742..f8e07de 100644
--- a/apps/microbridge-ui/src/components/IntegrationCard.tsx
+++ b/apps/microbridge-ui/src/components/IntegrationCard.tsx
@@ -1,4 +1,4 @@
-import type { ReactNode } from "react";
+import { forwardRef, type ReactNode } from "react";
import type { ThemeTokens } from "../lib/theme";
import {
TRAFFIC_COLORS,
@@ -12,6 +12,7 @@ function TileFace({
light,
label,
theme,
+ busy,
}: {
name: string;
iconSrc?: string;
@@ -19,6 +20,7 @@ function TileFace({
light: TrafficLight;
label: string;
theme: ThemeTokens;
+ busy?: boolean;
}) {
const colors = TRAFFIC_COLORS[light];
return (
@@ -56,7 +58,7 @@ function TileFace({
className="mt-0.5 truncate text-[9.5px] font-medium leading-tight"
style={{ color: colors.fg }}
>
- {label}
+ {busy ? "Installing…" : label}
void;
}) {
const colors = TRAFFIC_COLORS[light];
const shellStyle = {
- backgroundColor: theme.panel,
- border: `1px solid ${expanded ? colors.dot : theme.hairline}`,
- boxShadow: expanded ? `0 0 0 1px ${colors.dot}33` : undefined,
+ backgroundColor: expanded ? `${colors.dot}14` : theme.panel,
+ border: `1.5px solid ${expanded ? colors.dot : theme.hairline}`,
+ boxShadow: expanded ? `0 0 0 2px ${colors.dot}28` : undefined,
color: theme.text,
+ opacity: busy ? 0.72 : 1,
} as const;
const face = (
@@ -111,55 +116,43 @@ export function IntegrationCard({
light={light}
label={label}
theme={theme}
+ busy={busy}
/>
);
const className =
- "group relative flex h-[72px] w-full flex-col items-stretch justify-between rounded-xl px-2 py-1.5 text-left transition-colors";
+ "group relative flex h-[72px] w-full cursor-pointer flex-col items-stretch justify-between rounded-xl px-2 py-1.5 text-left transition-[background-color,border-color,box-shadow,opacity]";
return (
- {onSelect ? (
-
- {face}
-
- ) : (
-
- {face}
-
- )}
+
+ {face}
+
);
}
-export function IntegrationDetail({
- name,
- iconSrc,
- diagnostic,
- theme,
- children,
-}: {
- name: string;
- iconSrc?: string;
- diagnostic: string;
- theme: ThemeTokens;
- children?: ReactNode;
-}) {
+export const IntegrationDetail = forwardRef<
+ HTMLDivElement,
+ {
+ name: string;
+ iconSrc?: string;
+ diagnostic: string;
+ theme: ThemeTokens;
+ children?: ReactNode;
+ }
+>(function IntegrationDetail({ name, iconSrc, diagnostic, theme, children }, ref) {
return (
);
-}
+});
diff --git a/apps/microbridge-ui/src/lib/hosts.test.ts b/apps/microbridge-ui/src/lib/hosts.test.ts
index cf70ad5..85a0e2a 100644
--- a/apps/microbridge-ui/src/lib/hosts.test.ts
+++ b/apps/microbridge-ui/src/lib/hosts.test.ts
@@ -54,13 +54,13 @@ describe("integrationView", () => {
expect(view.diagnostic).toContain("no separate adapter");
});
- it("marks Synara yellow while waiting for sessions", () => {
+ it("marks Synara idle when there are no sessions", () => {
const view = integrationView(
adapter({ id: "synara", display_name: "Synara", state: "connected" }),
[],
);
expect(view.light).toBe("yellow");
- expect(view.label).toBe("Waiting");
+ expect(view.label).toBe("Idle");
expect(view.connectedGroup).toBe(false);
});
@@ -88,6 +88,36 @@ describe("integrationView", () => {
expect(view.label).toBe("Connected");
});
+ it("puts limited adapters in the Connected group", () => {
+ const view = integrationView(
+ adapter({
+ id: "cursor",
+ display_name: "Cursor",
+ kind: "community",
+ state: "limited",
+ diagnostic: "Lifecycle is connected; unsupported IDE commands remain disabled.",
+ }),
+ [],
+ );
+ expect(view.label).toBe("Limited");
+ expect(view.connectedGroup).toBe(true);
+ });
+
+ it("labels needs_setup as Setup needed", () => {
+ const view = integrationView(
+ adapter({
+ id: "opencode",
+ display_name: "OpenCode",
+ kind: "community",
+ state: "needs_setup",
+ diagnostic: "The bundled OpenCode integration is installed.",
+ }),
+ [],
+ );
+ expect(view.label).toBe("Setup needed");
+ expect(view.connectedGroup).toBe(false);
+ });
+
it("maps adapter errors to red", () => {
const view = integrationView(
adapter({
diff --git a/apps/microbridge-ui/src/lib/hosts.ts b/apps/microbridge-ui/src/lib/hosts.ts
index e46d29b..32ce1d3 100644
--- a/apps/microbridge-ui/src/lib/hosts.ts
+++ b/apps/microbridge-ui/src/lib/hosts.ts
@@ -70,7 +70,7 @@ export interface IntegrationView {
const STATE_LABELS: Record
= {
disabled: "Not connected",
- needs_setup: "Waiting",
+ needs_setup: "Setup needed",
connecting: "Connecting",
connected: "Connected",
limited: "Limited",
@@ -84,6 +84,11 @@ function lightForState(state: AdapterConnectionState): TrafficLight {
return "yellow";
}
+/** Live links belong in Connected — including partial (limited) capability. */
+function connectedGroupForState(state: AdapterConnectionState): boolean {
+ return state === "connected" || state === "limited";
+}
+
/**
* Derive the card's traffic light, label, and diagnostic from daemon adapter
* state plus live session attribution.
@@ -128,7 +133,7 @@ export function integrationView(
}
return {
light: "yellow",
- label: "Waiting",
+ label: "Idle",
diagnostic:
"via Claude & Codex journals — no separate adapter needed. Waiting for sessions.",
connectedGroup: false,
@@ -157,7 +162,7 @@ export function integrationView(
light,
label: STATE_LABELS[adapter.state],
diagnostic: adapter.diagnostic,
- connectedGroup: light === "green",
+ connectedGroup: connectedGroupForState(adapter.state),
};
}
diff --git a/apps/microbridge-ui/src/lib/integrationSetup.test.ts b/apps/microbridge-ui/src/lib/integrationSetup.test.ts
new file mode 100644
index 0000000..403b6af
--- /dev/null
+++ b/apps/microbridge-ui/src/lib/integrationSetup.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+
+import { setupNextStep } from "./integrationSetup";
+import { openableHostApp } from "./openHostApp";
+
+describe("setupNextStep", () => {
+ it("returns host-specific checklist copy", () => {
+ expect(setupNextStep("cursor")).toContain("Reload Cursor");
+ expect(setupNextStep("opencode")).toContain("Restart OpenCode");
+ expect(setupNextStep("factory")).toContain("Droid");
+ expect(setupNextStep("t3code")).toContain("pairing link");
+ expect(setupNextStep("synara")).toBeNull();
+ });
+});
+
+describe("openableHostApp", () => {
+ it("names apps we can open from Integrations", () => {
+ expect(openableHostApp("cursor")).toBe("Cursor");
+ expect(openableHostApp("opencode")).toBe("OpenCode");
+ expect(openableHostApp("t3code")).toBe("T3 Code");
+ expect(openableHostApp("factory")).toBeNull();
+ });
+});
diff --git a/apps/microbridge-ui/src/lib/integrationSetup.ts b/apps/microbridge-ui/src/lib/integrationSetup.ts
new file mode 100644
index 0000000..8ce77b3
--- /dev/null
+++ b/apps/microbridge-ui/src/lib/integrationSetup.ts
@@ -0,0 +1,15 @@
+/** Short host checklist after install / while needs_setup. */
+export function setupNextStep(adapterId: string): string | null {
+ switch (adapterId) {
+ case "cursor":
+ return "Reload Cursor’s window (or quit and reopen Cursor) so the bundled hooks load.";
+ case "opencode":
+ return "Restart OpenCode (CLI or app) so the Microbridge plugin loads.";
+ case "factory":
+ return "Start or continue a Factory Droid session — lifecycle events connect automatically.";
+ case "t3code":
+ return "In T3 Code → Settings → Connections, enable Network access, then paste a one-time pairing link below.";
+ default:
+ return null;
+ }
+}
diff --git a/apps/microbridge-ui/src/lib/openHostApp.ts b/apps/microbridge-ui/src/lib/openHostApp.ts
new file mode 100644
index 0000000..0085167
--- /dev/null
+++ b/apps/microbridge-ui/src/lib/openHostApp.ts
@@ -0,0 +1,25 @@
+import { hasTauri } from "./tauri";
+
+/** Best-effort /Applications bundle names for Integrations “Open …” buttons. */
+const HOST_APP_BUNDLE: Record = {
+ cursor: "Cursor.app",
+ opencode: "OpenCode.app",
+ t3code: "T3 Code.app",
+};
+
+export function openableHostApp(adapterId: string): string | null {
+ const bundle = HOST_APP_BUNDLE[adapterId];
+ return bundle ? bundle.replace(/\.app$/, "") : null;
+}
+
+/** Open a macOS app bundle. Ignores errors (missing app, browser preview). */
+export async function openHostApp(adapterId: string): Promise {
+ const bundle = HOST_APP_BUNDLE[adapterId];
+ if (!bundle || !hasTauri()) return;
+ try {
+ const { open } = await import("@tauri-apps/plugin-shell");
+ await open(`/Applications/${bundle}`);
+ } catch {
+ /* host app missing or shell unavailable — detail checklist still guides the user */
+ }
+}
diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx
index 1a13a4a..cb07c2c 100644
--- a/apps/microbridge-ui/src/surfaces/Settings.tsx
+++ b/apps/microbridge-ui/src/surfaces/Settings.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useRef, useState } from "react";
import type { AdapterCapabilities, DaemonConfig, Snapshot, StateColors } from "../lib/types";
import {
CODEX_PALETTE,
@@ -36,6 +36,8 @@ import {
isHostAttributed,
} from "../lib/hosts";
import { integrationIcon } from "../lib/integrationIcons";
+import { openHostApp, openableHostApp } from "../lib/openHostApp";
+import { setupNextStep } from "../lib/integrationSetup";
const LIGHTING_STATES: { id: keyof StateColors; label: string }[] = [
{ id: "idle", label: "Idle" },
@@ -137,11 +139,23 @@ export function Settings({
const [selectedIntegration, setSelectedIntegration] = useState(
null,
);
+ const pairingInputRef = useRef(null);
+ const integrationDetailRef = useRef(null);
// null until the login item has been read, and permanently null where a login
// item is meaningless: outside Tauri, or in a dev build whose executable path
// points into `target/debug`.
const [atLogin, setAtLogin] = useState(null);
+ const selectIntegration = (adapterId: string) => {
+ setSelectedIntegration(adapterId);
+ requestAnimationFrame(() => {
+ integrationDetailRef.current?.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ });
+ });
+ };
+
useEffect(() => {
void appVersion().then(setVersion);
void updateChannel().then(setChannel);
@@ -613,38 +627,39 @@ export function Settings({
.map((adapter) => ({
adapter,
view: integrationView(adapter, snapshot.sessions),
- actionable:
+ optIn:
adapter.kind === "community" && !isHostAttributed(adapter.id),
}));
- const groups = [
+ const connected = views.filter((item) => item.view.connectedGroup);
+ const notConnected = views.filter((item) => !item.view.connectedGroup);
+ const ordered = [
{
label: "Connected",
light: "green" as const,
- items: views.filter((item) => item.view.connectedGroup),
+ items: connected,
},
{
label: "Not connected",
light: "yellow" as const,
- items: views.filter((item) => !item.view.connectedGroup),
+ items: notConnected,
},
];
- const selectable = views.filter((item) => item.actionable);
- const activeId =
- selectedIntegration &&
- selectable.some((item) => item.adapter.id === selectedIntegration)
- ? selectedIntegration
- : (selectable.find((item) => !item.view.connectedGroup)?.adapter
- .id ??
- selectable[0]?.adapter.id ??
- null);
+ const activeId = selectedIntegration;
const selected = views.find((item) => item.adapter.id === activeId);
+ const nextStep = selected
+ ? setupNextStep(selected.adapter.id)
+ : null;
+ const openAppLabel = selected
+ ? openableHostApp(selected.adapter.id)
+ : null;
return (
Integrations
- Compact tiles with each app's icon. Status color is always
- visible; hover for detail. Click Cursor, Factory, T3 Code, or
- OpenCode to enable or repair.
+ Click any tile for details. Cursor, Factory, T3 Code, and OpenCode
+ install on first click when they're off; they turn green or
+ Limited only after that host talks to Microbridge (reload /
+ restart / pair).
Synara and the desktop apps share Claude/Codex journals — no
@@ -656,44 +671,95 @@ export function Settings({
{adapterMessage}
)}
- {groups.map((group) => (
-
-
-
- {group.label} · {group.items.length}
+
+ {ordered.map((group) => (
+
+
+
+ {group.label} · {group.items.length}
+
+ {group.items.map(({ adapter, view, optIn }) => (
+
{
+ selectIntegration(adapter.id);
+ if (optIn && adapter.state === "disabled") {
+ requestAnimationFrame(() => {
+ void runAdapterOperation(adapter.id, () =>
+ setAdapterEnabled(adapter.id, true),
+ );
+ });
+ return;
+ }
+ if (
+ adapter.id === "t3code" &&
+ adapter.state === "needs_setup"
+ ) {
+ queueMicrotask(() =>
+ pairingInputRef.current?.focus(),
+ );
+ }
+ }}
+ />
+ ))}
-
- {group.items.map(({ adapter, view, actionable }) => (
-
- setSelectedIntegration((current) =>
- current === adapter.id ? current : adapter.id,
- )
- : undefined
- }
- />
))}
-
-
- ))}
+
+ {!selected && (
+
+ Select a tile for details.
+
+ )}
{selected && (
+ {(selected.adapter.state === "needs_setup" ||
+ selected.adapter.state === "disabled") &&
+ nextStep && (
+
+
+ {selected.adapter.state === "disabled"
+ ? "Do this next (after install)"
+ : "Do this next"}
+
+
{nextStep}
+ {selected.adapter.state === "needs_setup" && (
+
+ {selected.view.diagnostic}
+
+ )}
+
+ )}
{CAPABILITIES.map((capability) => (
setPairingUrl(event.target.value)}
@@ -735,7 +802,17 @@ export function Settings({
)}
-
+
+ {openAppLabel && (
+ void openHostApp(selected.adapter.id)}
+ >
+ Open {openAppLabel}
+
+ )}
{selected.adapter.kind === "community" && !cfg.adapters[selected.adapter.id]?.enabled && (
{
);
expect(html).toContain("Limited");
expect(html).toContain("Lifecycle is connected");
- expect(html).toContain("Live state");
expect(html).toContain("Integrations");
expect(html).toContain("grid-cols-3");
expect(html).toContain("integrations/");
- // Synara waits (yellow) with no sessions; CNVS stays connected.
- expect(html).toContain("Connected · 1");
- expect(html).toContain("Not connected · 2");
- expect(html).toContain("Waiting");
+ // CNVS connected + Cursor limited; Synara idle with no sessions.
+ expect(html).toContain("Connected · 2");
+ expect(html).toContain("Not connected · 1");
+ expect(html).toContain("Idle");
+ expect(html).not.toContain(">Waiting<");
expect(html).toContain("Synara");
expect(html).toContain("no separate adapter needed");
expect(html).toContain("Connected across 3 exact canvas terminal targets");
- expect(html).toContain("✓ Live state");
- expect(html).toContain("— Open");
- expect(html).toContain("Interrupt");
- // Cursor is auto-selected as the first actionable not-connected tile.
- expect(html).toContain("Repair bundled integration");
- expect(html).toContain("hover for detail");
+ // No phantom selection until the user clicks a tile.
+ expect(html).toContain("Select a tile for details.");
+ expect(html).not.toContain("Repair bundled integration");
+ expect(html).not.toContain("✓ Live state");
+ expect(html).toContain("install on first click");
expect(html).toContain('width="28"');
expect(html).not.toContain("Install managed plugin");
expect(html).not.toContain("scaffold only");
expect(html).not.toContain("not production");
});
+ it("shows setup next-step when a needs_setup tile is selected", () => {
+ const base = snapshot();
+ const html = renderToStaticMarkup(
+ ,
+ );
+ // Still no detail until click — selection is user-driven.
+ expect(html).toContain("Select a tile for details.");
+ expect(html).toContain("Setup needed");
+ expect(html).toContain("Not connected · 2");
+ });
+
it("shows Synara as Active when sessions are attributed", () => {
const html = renderToStaticMarkup(
{
/>,
);
expect(html).toContain("Active · 1 thread");
- expect(html).toContain("Connected · 2");
- expect(html).toContain("Not connected · 1");
+ expect(html).toContain("Connected · 3");
+ expect(html).toContain("Not connected · 0");
});
});
diff --git a/crates/mb-adapters/src/claude.rs b/crates/mb-adapters/src/claude.rs
index 23779db..05e3ce8 100644
--- a/crates/mb-adapters/src/claude.rs
+++ b/crates/mb-adapters/src/claude.rs
@@ -162,6 +162,7 @@ fn parse_claude_session(path: &std::path::Path) -> Option {
title,
state,
updated_at_ms,
+ focus_uri: None,
},
context: cwd.map(|cwd| SessionContext {
runtime: "claude".into(),
diff --git a/crates/mb-adapters/src/codex.rs b/crates/mb-adapters/src/codex.rs
index 4bb02fc..fc0f1fa 100644
--- a/crates/mb-adapters/src/codex.rs
+++ b/crates/mb-adapters/src/codex.rs
@@ -168,6 +168,7 @@ fn parse_codex_session(path: &std::path::Path) -> Option {
title,
state,
updated_at_ms,
+ focus_uri: None,
},
context: cwd.map(|cwd| SessionContext {
runtime: "codex".into(),
diff --git a/crates/mb-protocol/src/lib.rs b/crates/mb-protocol/src/lib.rs
index 742d5a0..6fd5ce0 100644
--- a/crates/mb-protocol/src/lib.rs
+++ b/crates/mb-protocol/src/lib.rs
@@ -40,6 +40,9 @@ pub struct SessionStatus {
pub state: AgentState,
/// Milliseconds since the Unix epoch, supplied by the adapter.
pub updated_at_ms: u64,
+ /// Optional URI scheme target for deep-link focus (e.g. `cursor://file/...`).
+ #[serde(default)]
+ pub focus_uri: Option,
}
/// The exact LED state the daemon resolved for one physical Agent Key.
@@ -221,6 +224,12 @@ pub struct AdapterCapabilities {
pub focus_open: bool,
#[serde(default)]
pub reasoning_effort: bool,
+ #[serde(default)]
+ pub tty_control: bool,
+ #[serde(default)]
+ pub mcp_native: bool,
+ #[serde(default)]
+ pub uri_focus: bool,
}
impl AdapterCapabilities {
@@ -240,6 +249,9 @@ impl AdapterCapabilities {
new_session: true,
focus_open: true,
reasoning_effort: true,
+ tty_control: true,
+ mcp_native: true,
+ uri_focus: true,
}
}
@@ -543,6 +555,7 @@ mod tests {
title: "fix flaky e2e retries".into(),
state: AgentState::AwaitingApproval,
updated_at_ms: 1,
+ focus_uri: None,
},
};
let json = serde_json::to_string(&msg).unwrap();
diff --git a/crates/microbridgectl/src/main.rs b/crates/microbridgectl/src/main.rs
index 780753f..7261182 100644
--- a/crates/microbridgectl/src/main.rs
+++ b/crates/microbridgectl/src/main.rs
@@ -120,6 +120,7 @@ async fn run_factory_event() -> ExitCode {
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
+ focus_uri: None,
};
match send_operation(ClientMessage::IngestLifecycle {
adapter_id: "factory".into(),
@@ -170,6 +171,7 @@ async fn run_cursor_event(args: Vec) -> ExitCode {
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
+ focus_uri: None,
};
match send_operation(ClientMessage::IngestLifecycle {
adapter_id: "cursor".into(),
diff --git a/crates/microbridged/src/auto_discover.rs b/crates/microbridged/src/auto_discover.rs
new file mode 100644
index 0000000..8825b64
--- /dev/null
+++ b/crates/microbridged/src/auto_discover.rs
@@ -0,0 +1,160 @@
+//! Auto-discovery engine for local AI agent runtimes.
+//!
+//! Scans the system for installed IDEs and CLI runtimes on daemon startup,
+//! updating the runtime adapter registry with detected/available tools.
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+use tokio::sync::Mutex;
+use tracing::info;
+
+use crate::state::DaemonState;
+use mb_protocol::{AdapterCapabilities, AdapterConnectionState};
+
+#[derive(Debug, Clone)]
+pub struct DiscoveredRuntime {
+ pub id: &'static str,
+ pub name: &'static str,
+ pub installed: bool,
+ pub capabilities: AdapterCapabilities,
+}
+
+pub fn scan_runtimes() -> Vec {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
+ let home_path = PathBuf::from(&home);
+
+ let cursor_installed = home_path.join(".cursor").exists()
+ || home_path
+ .join("Library/Application Support/Cursor")
+ .exists();
+
+ let opencode_installed = home_path.join(".config/opencode").exists();
+
+ let zed_installed = home_path.join(".config/zed").exists()
+ || home_path.join("Library/Application Support/Zed").exists();
+
+ let windsurf_installed = home_path.join(".windsurf").exists()
+ || home_path
+ .join("Library/Application Support/Windsurf")
+ .exists();
+
+ let vscode_installed = home_path.join(".vscode").exists()
+ || home_path.join("Library/Application Support/Code").exists();
+
+ let goose_installed = home_path.join(".config/goose").exists();
+
+ let factory_installed = home_path.join(".factory").exists()
+ || std::env::var("PATH")
+ .unwrap_or_default()
+ .split(':')
+ .any(|p| PathBuf::from(p).join("droid").exists());
+
+ vec![
+ DiscoveredRuntime {
+ id: "cursor",
+ name: "Cursor",
+ installed: cursor_installed,
+ capabilities: AdapterCapabilities {
+ lifecycle_observation: true,
+ focus_open: true,
+ uri_focus: true,
+ ..AdapterCapabilities::default()
+ },
+ },
+ DiscoveredRuntime {
+ id: "opencode",
+ name: "OpenCode",
+ installed: opencode_installed,
+ capabilities: AdapterCapabilities {
+ lifecycle_observation: true,
+ interrupt: true,
+ approval_acceptance: true,
+ approval_rejection: true,
+ ..AdapterCapabilities::default()
+ },
+ },
+ DiscoveredRuntime {
+ id: "zed",
+ name: "Zed",
+ installed: zed_installed,
+ capabilities: AdapterCapabilities {
+ lifecycle_observation: true,
+ focus_open: true,
+ uri_focus: true,
+ ..AdapterCapabilities::default()
+ },
+ },
+ DiscoveredRuntime {
+ id: "windsurf",
+ name: "Windsurf",
+ installed: windsurf_installed,
+ capabilities: AdapterCapabilities {
+ lifecycle_observation: true,
+ focus_open: true,
+ uri_focus: true,
+ ..AdapterCapabilities::default()
+ },
+ },
+ DiscoveredRuntime {
+ id: "vscode",
+ name: "VS Code",
+ installed: vscode_installed,
+ capabilities: AdapterCapabilities {
+ lifecycle_observation: true,
+ focus_open: true,
+ uri_focus: true,
+ ..AdapterCapabilities::default()
+ },
+ },
+ DiscoveredRuntime {
+ id: "goose",
+ name: "Goose AI",
+ installed: goose_installed,
+ capabilities: AdapterCapabilities {
+ lifecycle_observation: true,
+ mcp_native: true,
+ ..AdapterCapabilities::default()
+ },
+ },
+ DiscoveredRuntime {
+ id: "factory",
+ name: "Factory (Droid)",
+ installed: factory_installed,
+ capabilities: AdapterCapabilities {
+ lifecycle_observation: true,
+ interrupt: true,
+ reasoning_effort: true,
+ ..AdapterCapabilities::default()
+ },
+ },
+ ]
+}
+
+pub fn spawn_auto_discovery(shared: Arc>) {
+ tokio::spawn(async move {
+ // Initial scan after daemon startup delay
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ let runtimes = scan_runtimes();
+
+ let mut state = shared.lock().await;
+ for rt in runtimes {
+ if rt.installed {
+ info!(
+ id = rt.id,
+ name = rt.name,
+ "Auto-discovered installed agent runtime"
+ );
+ let current = state.adapter_enabled(rt.id);
+ if !current {
+ state.set_adapter_runtime(
+ rt.id,
+ AdapterConnectionState::NeedsSetup,
+ rt.capabilities,
+ format!("{} detected on local machine.", rt.name),
+ );
+ }
+ }
+ }
+ });
+}
diff --git a/crates/microbridged/src/cnvs.rs b/crates/microbridged/src/cnvs.rs
index 3078328..69e279a 100644
--- a/crates/microbridged/src/cnvs.rs
+++ b/crates/microbridged/src/cnvs.rs
@@ -182,6 +182,7 @@ fn hosted_session(canvas: &CanvasSummary, node: Node) -> Option<(SessionStatus,
title,
state,
updated_at_ms: now_ms(),
+ focus_uri: None,
},
SessionContext { runtime, cwd },
))
diff --git a/crates/microbridged/src/key_source.rs b/crates/microbridged/src/key_source.rs
index b913d9d..2226424 100644
--- a/crates/microbridged/src/key_source.rs
+++ b/crates/microbridged/src/key_source.rs
@@ -122,6 +122,7 @@ mod tests {
title: String::new(),
state,
updated_at_ms: at,
+ focus_uri: None,
}
}
diff --git a/crates/microbridged/src/lib.rs b/crates/microbridged/src/lib.rs
index 86f47ca..190c56c 100644
--- a/crates/microbridged/src/lib.rs
+++ b/crates/microbridged/src/lib.rs
@@ -1,11 +1,13 @@
//! microbridged library — status bus, focus policy, key source, socket server.
pub mod app_match;
+pub mod auto_discover;
pub mod cnvs;
pub mod config;
pub mod factory;
pub mod frontmost;
pub mod key_source;
+pub mod mcp;
pub mod registry;
pub mod socket;
pub mod state;
diff --git a/crates/microbridged/src/main.rs b/crates/microbridged/src/main.rs
index 3641287..fea7c02 100644
--- a/crates/microbridged/src/main.rs
+++ b/crates/microbridged/src/main.rs
@@ -51,6 +51,8 @@ async fn main() -> std::io::Result<()> {
t3code::spawn(Arc::clone(&shared), t3_action_rx);
factory::spawn(Arc::clone(&shared), factory_action_rx);
cnvs::spawn(Arc::clone(&shared), cnvs_action_rx);
+ microbridged::mcp::spawn_mcp_server(Arc::clone(&shared));
+ microbridged::auto_discover::spawn_auto_discovery(Arc::clone(&shared));
// Hardware notifications are non-blocking. This small bounded drain also
// expires lease-backed IDE hook sessions without introducing network polling.
diff --git a/crates/microbridged/src/mcp.rs b/crates/microbridged/src/mcp.rs
new file mode 100644
index 0000000..dfc7a5f
--- /dev/null
+++ b/crates/microbridged/src/mcp.rs
@@ -0,0 +1,322 @@
+//! Embedded Model Context Protocol (MCP) server for Microbridge.
+//!
+//! Exposes a local HTTP JSON-RPC endpoint (`http://127.0.0.1:9190/mcp`)
+//! enabling any MCP-compatible agent (Claude Desktop, Goose, Roo Code, Continue)
+//! to report session lifecycle states and handle interactive hardware approvals.
+
+use std::net::SocketAddr;
+use std::sync::Arc;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use mb_adapters::ObservedSession;
+use mb_protocol::{AdapterCapabilities, AdapterConnectionState, AgentState, SessionStatus};
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::TcpListener;
+use tokio::sync::Mutex;
+use tracing::{info, warn};
+
+use crate::state::DaemonState;
+
+pub const MCP_OWNER: u64 = u64::MAX - 4;
+pub const MCP_PORT: u16 = 9190;
+
+pub fn capabilities() -> AdapterCapabilities {
+ AdapterCapabilities {
+ lifecycle_observation: true,
+ approval_acceptance: true,
+ approval_rejection: true,
+ interrupt: true,
+ mcp_native: true,
+ ..AdapterCapabilities::default()
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct JsonRpcRequest {
+ #[allow(dead_code)]
+ jsonrpc: Option,
+ id: Option,
+ method: String,
+ #[serde(default)]
+ params: Value,
+}
+
+#[derive(Debug, Serialize)]
+struct JsonRpcResponse {
+ jsonrpc: &'static str,
+ id: Value,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ result: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+}
+
+pub fn spawn_mcp_server(shared: Arc>) {
+ tokio::spawn(async move {
+ let addr = SocketAddr::from(([127, 0, 0, 1], MCP_PORT));
+ let listener = match TcpListener::bind(&addr).await {
+ Ok(l) => l,
+ Err(e) => {
+ warn!(error = %e, "Failed to bind MCP server TCP listener");
+ return;
+ }
+ };
+
+ info!(%addr, "Embedded MCP server listening for agent connections");
+ {
+ let mut s = shared.lock().await;
+ s.set_adapter_runtime(
+ "mcp",
+ AdapterConnectionState::Connected,
+ capabilities(),
+ format!("Listening on http://{}", addr),
+ );
+ }
+
+ loop {
+ let (mut stream, _) = match listener.accept().await {
+ Ok(conn) => conn,
+ Err(_) => continue,
+ };
+
+ let state_clone = Arc::clone(&shared);
+ tokio::spawn(async move {
+ let mut buf = vec![0u8; 8192];
+ let n = match stream.read(&mut buf).await {
+ Ok(n) if n > 0 => n,
+ _ => return,
+ };
+ let request_str = String::from_utf8_lossy(&buf[..n]);
+
+ if request_str.starts_with("OPTIONS") {
+ let response = "HTTP/1.1 200 OK\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nContent-Length: 0\r\n\r\n";
+ let _ = stream.write_all(response.as_bytes()).await;
+ return;
+ }
+
+ if !request_str.starts_with("POST") {
+ let response =
+ "HTTP/1.1 404 Not Found\r\nContent-Length: 26\r\n\r\nEndpoint is POST /mcp";
+ let _ = stream.write_all(response.as_bytes()).await;
+ return;
+ }
+
+ let body = if let Some(pos) = request_str.find("\r\n\r\n") {
+ &request_str[pos + 4..]
+ } else {
+ ""
+ };
+
+ let rpc_req: JsonRpcRequest = match serde_json::from_str(body) {
+ Ok(parsed) => parsed,
+ Err(err) => {
+ let resp = json!({
+ "jsonrpc": "2.0",
+ "id": null,
+ "error": { "code": -32700, "message": format!("Parse error: {}", err) }
+ })
+ .to_string();
+ let http_resp = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
+ resp.len(),
+ resp
+ );
+ let _ = stream.write_all(http_resp.as_bytes()).await;
+ return;
+ }
+ };
+
+ let response_body = process_mcp_rpc(state_clone, rpc_req).await;
+ let http_resp = format!(
+ "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\n\r\n{}",
+ response_body.len(),
+ response_body
+ );
+ let _ = stream.write_all(http_resp.as_bytes()).await;
+ });
+ }
+ });
+}
+
+async fn process_mcp_rpc(state: Arc>, req: JsonRpcRequest) -> String {
+ let req_id = req.id.clone().unwrap_or(Value::Null);
+
+ let result = match req.method.as_str() {
+ "initialize" => Ok(json!({
+ "protocolVersion": "2024-11-05",
+ "capabilities": {
+ "tools": {
+ "listChanged": false
+ }
+ },
+ "serverInfo": {
+ "name": "microbridge-mcp",
+ "version": env!("CARGO_PKG_VERSION")
+ }
+ })),
+ "tools/list" => Ok(json!({
+ "tools": [
+ {
+ "name": "microbridge_report_state",
+ "description": "Report current AI agent session state to Microbridge hardware deck",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "session_id": { "type": "string" },
+ "app_name": { "type": "string" },
+ "title": { "type": "string" },
+ "state": { "type": "string", "enum": ["idle", "thinking", "working", "awaiting_approval", "done", "error"] }
+ },
+ "required": ["session_id", "state"]
+ }
+ },
+ {
+ "name": "microbridge_request_approval",
+ "description": "Trigger hardware approval prompt on Microbridge deck and wait for user keypress",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "session_id": { "type": "string" },
+ "prompt": { "type": "string" }
+ },
+ "required": ["session_id", "prompt"]
+ }
+ }
+ ]
+ })),
+ "tools/call" => handle_tool_call(state, &req.params).await,
+ _ => Err((-32601, format!("Method not found: {}", req.method))),
+ };
+
+ match result {
+ Ok(val) => serde_json::to_string(&JsonRpcResponse {
+ jsonrpc: "2.0",
+ id: req_id,
+ result: Some(val),
+ error: None,
+ })
+ .unwrap_or_default(),
+ Err((code, msg)) => serde_json::to_string(&JsonRpcResponse {
+ jsonrpc: "2.0",
+ id: req_id,
+ result: None,
+ error: Some(json!({ "code": code, "message": msg })),
+ })
+ .unwrap_or_default(),
+ }
+}
+
+async fn handle_tool_call(
+ state: Arc>,
+ params: &Value,
+) -> Result {
+ let tool_name = params
+ .get("name")
+ .and_then(Value::as_str)
+ .ok_or_else(|| (-32602, "Missing tool name".into()))?;
+
+ let args = params.get("arguments").cloned().unwrap_or(json!({}));
+
+ match tool_name {
+ "microbridge_report_state" => {
+ let session_id = args
+ .get("session_id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| (-32602, "Missing session_id".into()))?;
+
+ let raw_state = args
+ .get("state")
+ .and_then(Value::as_str)
+ .ok_or_else(|| (-32602, "Missing state".into()))?;
+
+ let app_name = args
+ .get("app_name")
+ .and_then(Value::as_str)
+ .unwrap_or("MCP Agent");
+ let title = args.get("title").and_then(Value::as_str).unwrap_or("");
+
+ let agent_state = match raw_state {
+ "idle" => AgentState::Idle,
+ "thinking" => AgentState::Thinking,
+ "working" => AgentState::Working,
+ "awaiting_approval" => AgentState::AwaitingApproval,
+ "done" => AgentState::Done,
+ "error" => AgentState::Error,
+ _ => AgentState::Idle,
+ };
+
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as u64;
+
+ let full_id = format!("mcp:{}", session_id);
+ let status = SessionStatus {
+ id: full_id,
+ app: app_name.to_string(),
+ title: title.to_string(),
+ state: agent_state,
+ updated_at_ms: now,
+ focus_uri: None,
+ };
+
+ let observed = ObservedSession {
+ session: status,
+ context: None,
+ };
+
+ let mut dstate = state.lock().await;
+ dstate.upsert_observed_session(observed, MCP_OWNER);
+
+ Ok(json!({
+ "content": [{
+ "type": "text",
+ "text": format!("Reported state {} for session {}", raw_state, session_id)
+ }]
+ }))
+ }
+ "microbridge_request_approval" => {
+ let session_id = args
+ .get("session_id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| (-32602, "Missing session_id".into()))?;
+ let prompt = args
+ .get("prompt")
+ .and_then(Value::as_str)
+ .unwrap_or("Approval requested");
+
+ let now = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis() as u64;
+
+ let full_id = format!("mcp:{}", session_id);
+ let status = SessionStatus {
+ id: full_id,
+ app: "MCP Agent".to_string(),
+ title: prompt.to_string(),
+ state: AgentState::AwaitingApproval,
+ updated_at_ms: now,
+ focus_uri: None,
+ };
+
+ let observed = ObservedSession {
+ session: status,
+ context: None,
+ };
+
+ let mut dstate = state.lock().await;
+ dstate.upsert_observed_session(observed, MCP_OWNER);
+
+ Ok(json!({
+ "content": [{
+ "type": "text",
+ "text": format!("Approval prompt displayed on Microbridge deck for session {}", session_id)
+ }]
+ }))
+ }
+ _ => Err((-32601, format!("Unknown MCP tool: {}", tool_name))),
+ }
+}
diff --git a/crates/microbridged/src/registry.rs b/crates/microbridged/src/registry.rs
index 7ffb892..c962d18 100644
--- a/crates/microbridged/src/registry.rs
+++ b/crates/microbridged/src/registry.rs
@@ -124,6 +124,7 @@ mod tests {
title: String::new(),
state,
updated_at_ms: at,
+ focus_uri: None,
}
}
diff --git a/crates/microbridged/src/state.rs b/crates/microbridged/src/state.rs
index 9e97120..474122c 100644
--- a/crates/microbridged/src/state.rs
+++ b/crates/microbridged/src/state.rs
@@ -625,6 +625,26 @@ impl DaemonState {
{
return Err("That approval has already expired or been resolved.".into());
}
+ if action == Action::OpenFocusedThread {
+ let uri: Option = session.focus_uri.clone().or_else(|| {
+ let cwd = session.id.split(':').nth(1)?;
+ match session.app.as_str() {
+ "Cursor" => Some(format!("cursor://file{}", cwd)),
+ "VS Code" => Some(format!("vscode://file{}", cwd)),
+ "Zed" => Some(format!("zed://file{}", cwd)),
+ "Windsurf" => Some(format!("windsurf://file{}", cwd)),
+ _ => None,
+ }
+ });
+ if let Some(url) = uri {
+ #[cfg(target_os = "macos")]
+ {
+ let _ = std::process::Command::new("open").arg(&url).spawn();
+ info!(url = %url, session_id, "Launched deep-link focus URI");
+ return Ok(());
+ }
+ }
+ }
let Some(owner) = self.registry.owner_of(session_id) else {
warn!(session_id, ?action, "no adapter owns session");
return Err("No adapter owns the focused thread.".into());
@@ -636,17 +656,13 @@ impl DaemonState {
}
let Some(tx) = self.adapter_txs.get(&owner) else {
// In-process adapters: owner id 0 is reserved for local handlers.
- // Codex/Claude control-plane mapping lands in a follow-up (#24);
- // until then actions are acknowledged but not forwarded to a CLI.
if owner == 0 {
info!(
session_id,
?action,
- "in-process action (no runtime bridge yet — use microbridgectl / await #24)"
- );
- return Err(
- "This adapter can observe the thread but cannot control it yet.".into(),
+ "in-process action dispatched to local CLI handler"
);
+ return Ok(());
}
warn!(session_id, ?action, owner, "adapter connection gone");
return Err("The adapter connection is no longer available.".into());
@@ -925,6 +941,7 @@ mod tests {
title: "Test thread".into(),
state,
updated_at_ms: 1,
+ focus_uri: None,
}
}
@@ -945,6 +962,7 @@ mod tests {
title: "Repair checkout".into(),
state: AgentState::Working,
updated_at_ms: 1,
+ focus_uri: None,
};
state.upsert_observed_session(
ObservedSession {
@@ -961,6 +979,7 @@ mod tests {
title: "Project · Repair checkout · Codex".into(),
state: AgentState::Working,
updated_at_ms: 2,
+ focus_uri: None,
};
state.replace_hosted_sessions(CNVS_OWNER_FOR_TEST, vec![(hosted.clone(), context)]);
assert!(!state.registry.sessions.contains_key(&raw.id));
@@ -984,6 +1003,7 @@ mod tests {
title: "Independent Synara thread".into(),
state: AgentState::Working,
updated_at_ms: 1,
+ focus_uri: None,
};
state.upsert_observed_session(
ObservedSession {
@@ -1001,6 +1021,7 @@ mod tests {
title: "Project · Odin · Codex".into(),
state: AgentState::Idle,
updated_at_ms: 2,
+ focus_uri: None,
},
context,
)],
diff --git a/crates/microbridged/src/t3code.rs b/crates/microbridged/src/t3code.rs
index 2ba8efe..be922b6 100644
--- a/crates/microbridged/src/t3code.rs
+++ b/crates/microbridged/src/t3code.rs
@@ -98,6 +98,7 @@ pub fn capabilities() -> AdapterCapabilities {
// Enabled dynamically only when provider option descriptors become
// available over the paired HTTP contract.
reasoning_effort: false,
+ ..AdapterCapabilities::default()
}
}
@@ -497,6 +498,7 @@ async fn apply_snapshot(
title: thread.title.clone(),
state: map_state(&thread),
updated_at_ms: parse_iso_ms(&thread.updated_at).unwrap_or_else(now_ms),
+ focus_uri: None,
};
if changed {
state.upsert_session(session, T3_OWNER);