diff --git a/INSTALL.md b/INSTALL.md index 628ea16..0c7cd0f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -102,7 +102,7 @@ right-click the menu bar icon → **Check for Updates…**, or turn on *Settings Updates → check automatically at launch* (off by default). The app downloads the signed update, verifies it, and relaunches. Update checks are the only app-originated network call. The daemon also contacts a T3 Code environment -only after you explicitly enable that adapter and exchange a one-time pairing +only after you explicitly enable that integration and exchange a one-time pairing link; Microbridge has no telemetry or cloud relay. Homebrew installs are managed by brew instead: the app detects the brew @@ -111,7 +111,7 @@ so the formula version and the on-disk app never drift apart. ### Cursor integration -Cursor support ships inside Microbridge. Open **Settings → Adapters** and click +Cursor support ships inside Microbridge. Open **Settings → Integrations** and click **Enable Cursor**; Microbridge installs its bundled lifecycle integration into Cursor's supported local-plugin directory after that explicit consent. Reload Cursor once if it is already open. **Remove** disables the adapter and removes @@ -119,7 +119,7 @@ only Microbridge's local integration. No Marketplace download is required. ### Factory integration -Factory support ships inside Microbridge. Open **Settings → Adapters** and +Factory support ships inside Microbridge. Open **Settings → Integrations** and click **Enable Factory**. Microbridge copies its signed `microbridgectl` helper to `~/.microbridge/integrations/factory/` and merges only its own entries into Factory's supported `~/.factory/hooks.json`; existing hooks are preserved. @@ -140,8 +140,18 @@ contract. Microbridge leaves approval, new-session, and reasoning-effort controls disabled until CNVS exposes stable targets for them. Synara and Conductor do not need an installer: their Codex/Claude sessions are -named by the built-in journal watchers. T3 Code controls require the one-time -pairing flow shown in **Settings → Adapters**. +named by the built-in journal watchers. ChatGPT, Claude Desktop, Codex CLI, and +Claude Code are distinguished automatically by those same watchers. T3 Code +controls require the one-time pairing flow shown in **Settings → Integrations**. + +### OpenCode integration + +OpenCode support ships inside Microbridge. Open **Settings → Integrations** and +click **Enable OpenCode**. Microbridge installs its dependency-free global plugin +at `~/.config/opencode/plugins/microbridge.mjs`. Restart OpenCode if it is already +running. The plugin publishes local lifecycle state and routes Interrupt to the +exact OpenCode session; it does not read or send prompts, transcripts, source +code, or tool arguments. **Remove** deletes only the Microbridge-owned file. **Note:** Homebrew installs **prebuilt** release binaries (not a from-source Tauri build). The formula checksums are refreshed by CI after each `v*` tag. @@ -160,6 +170,7 @@ Tauri build). The formula checksums are refreshed by CI after each `v*` tag. | `~/.cursor/plugins/local/microbridge` | Bundled Cursor lifecycle integration (only after consent) | | `~/.factory/hooks.json` | Existing Factory hooks plus Microbridge-owned lifecycle entries (only after consent) | | `~/.microbridge/integrations/factory/microbridgectl` | Signed Factory hook helper (only after consent) | +| `~/.config/opencode/plugins/microbridge.mjs` | Bundled OpenCode lifecycle and interrupt integration (only after consent) | | `~/Library/LaunchAgents/ai.microbridge.ui.plist` | Login item (only if you enable launch at login) | ## Launch at login diff --git a/PRIVACY.md b/PRIVACY.md index 2afaaa8..5639ff2 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -22,16 +22,21 @@ no unconfigured network traffic. - No unconfigured network traffic; update checks and T3 access require opt-in - No uploading of session text or source code -## Adapters +## Integrations -First-party adapters watch local session stores. The bundled Cursor integration +First-party integrations watch local session stores. The bundled Cursor integration sends metadata-only lifecycle events over the local socket and never sends prompt, response, transcript, or tool argument content. The T3 Code adapter talks only to the exact environment the user pairs, using scoped orchestration access. +The optional OpenCode plugin sends only the local session identifier, title, +state, and update time over Microbridge's mode-`0600` Unix socket. It does not +send prompts, responses, transcripts, source code, tool arguments, or files. +Interrupt is executed inside OpenCode against the exact local session ID. + The one-time T3 pairing token is exchanged immediately, never logged, and not -stored. Removing the adapter deletes its Keychain credential. +stored. Removing the integration deletes its Keychain credential. ## Hardware diff --git a/README.md b/README.md index 9b10643..d5ac773 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ **An open-source control plane for the Codex Micro — one macropad, every coding agent.** -Microbridge is a tiny local daemon that bridges AI coding agents — Codex CLI, Claude Code, CNVS, Cursor, T3 Code, Synara, Conductor, Factory, and anything else with an adapter — to the [Work Louder Codex Micro](https://worklouder.cc/). Per-key RGB mirrors live agent state; keys route only the actions each adapter explicitly advertises, so unsupported controls never report false success. No vendor desktop app is required for Microbridge itself. +Microbridge is a tiny local daemon that bridges AI coding agents — ChatGPT, Claude Desktop, Codex CLI, Claude Code, CNVS, Cursor, T3 Code, Synara, Conductor, Factory, OpenCode, and anything else with an integration — to the [Work Louder Codex Micro](https://worklouder.cc/). Per-key RGB mirrors live agent state; keys route only the actions each integration explicitly advertises, so unsupported controls never report false success. No vendor desktop app is required for Microbridge itself. -> **Status: early public alpha (`v0.3.x`).** Menu bar UI, local daemon, in-process Codex/Claude host attribution, native CNVS control, and signed macOS packages are shipping. Cursor and Factory lifecycle reception and paired T3 Code control are opt-in and capability-gated. **HID protocol (VID/PID, framing, `v.oai.thstatus`) is implemented from ChatGPT’s Work Louder kit**; hardware control stays off until enabled in Device settings (or `MICROBRIDGE_HID_CLAIM=1` is set for diagnostics) while physical validation is completed. See [docs/device-hid.md](docs/device-hid.md). +> **Status: early public alpha (`v0.3.x`).** Menu bar UI, local daemon, in-process ChatGPT/Codex and Claude Desktop/Claude Code attribution, native CNVS control, and signed macOS packages are shipping. Cursor, Factory, OpenCode, and paired T3 Code control are opt-in and capability-gated. **HID protocol (VID/PID, framing, `v.oai.thstatus`) is implemented from ChatGPT’s Work Louder kit**; hardware control stays off until enabled in Device settings (or `MICROBRIDGE_HID_CLAIM=1` is set for diagnostics) while physical validation is completed. See [docs/device-hid.md](docs/device-hid.md). ## Screenshots @@ -34,19 +34,19 @@ Design spec: [docs/design/README.md](docs/design/README.md). The Micro's best feature — bidirectional Agent Keys — currently works through exactly one vendor's desktop app. Most of us run agents in more than one place. Microbridge turns the deck into a shared, neutral surface: -- **Adapters publish state.** Each agent session reports `thinking`, `working`, `awaiting_approval`, … as transitions happen. -- **The focus policy decides.** Exactly one session owns the deck at a time; approval requests can preempt. Adapters never touch the device, so two apps can never fight over your keys. +- **Integrations publish state.** Each agent session reports `thinking`, `working`, `awaiting_approval`, … as transitions happen. +- **The focus policy decides.** Exactly one session owns the deck at a time; approval requests can preempt. Integrations never touch the device, so two apps can never fight over your keys. - **The device layer renders.** State becomes LEDs; key presses become routed actions. ## Design principles 1. **Invisible footprint.** Local watchers are event-driven; device input, CNVS's local snapshot API, and an explicitly paired T3 connection use bounded polling and backoff. Idle CPU and RSS remain part of the [footprint budget](docs/architecture.md#footprint-budget). -2. **Local-first and explicit network access.** There is no telemetry or Microbridge cloud relay. The app checks for updates only when requested or enabled, CNVS traffic is restricted to its authenticated loopback endpoint, and the daemon contacts a T3 environment only after the user enables the adapter and supplies a one-time pairing link. Factory controls invoke the user-installed `droid` CLI only when a hardware action is requested. -3. **Rust core, any-language adapters.** The always-resident part is a single static Rust binary. First-party adapters compile into it (in-process, ~zero overhead). Community adapters are separate processes speaking [newline-delimited JSON](docs/protocol.md) — write one in whatever you like. -4. **The menu bar app is the product UI.** Configure keys, lighting, and adapters there. The daemon keeps the hardware alive underneath; `microbridgectl` is a support/debug escape hatch. +2. **Local-first and explicit network access.** There is no telemetry or Microbridge cloud relay. The app checks for updates only when requested or enabled, CNVS traffic is restricted to its authenticated loopback endpoint, and the daemon contacts a T3 environment only after the user enables the integration and supplies a one-time pairing link. Factory controls invoke the user-installed `droid` CLI only when a hardware action is requested. +3. **Rust core, any-language integrations.** The always-resident part is a single static Rust binary. First-party integrations compile into it (in-process, ~zero overhead). Optional integrations use official host hooks/APIs or speak [newline-delimited JSON](docs/protocol.md). +4. **The menu bar app is the product UI.** Configure keys, lighting, and integrations there. The daemon keeps the hardware alive underneath; `microbridgectl` is a support/debug escape hatch. Cursor support is included in the Microbridge app and repository. Enable it -once in **Settings → Adapters**; Microbridge installs its bundled lifecycle +once in **Settings → Integrations**; Microbridge installs its bundled lifecycle integration into Cursor's supported local-plugin directory. There is no separate Marketplace download or second product to maintain. @@ -57,6 +57,11 @@ active model advertises. Synara and Conductor sessions are attributed through the built-in Codex/Claude watchers, so they need no pairing code or extra background adapter. +OpenCode support is bundled too. Enabling it installs one dependency-free +global plugin through OpenCode's official plugin directory. Lifecycle events +stay in the OpenCode process, and Interrupt uses the documented session API; +there is no polling or additional helper process. + CNVS support is native and automatic: Microbridge reads CNVS's authenticated loopback control API, represents each agent terminal by its exact canvas and node identifiers, and routes focus or interrupt back to that target. No pairing @@ -66,8 +71,8 @@ code, private database access, or CNVS modification is required. ``` ┌───────────┐ ┌─────────────┐ ┌──────────────────┐ -│ Codex CLI │ │ Claude Code │ │ community adapters │ -│ (in-proc) │ │ (in-proc) │ │ (any language) │ +│ ChatGPT / │ │ Claude app /│ │ optional integrations │ +│ Codex CLI │ │ Claude Code │ │ OpenCode, Cursor, … │ └─────┬─────┘ └─────┬───────┘ └────────┬─────────┘ │ in-process │ NDJSON over unix socket ▼ ▼ ▼ @@ -144,7 +149,7 @@ Requires stable Rust (see `rust-toolchain.toml`) and, for Node adapters / UI, No ## Contributing -Adapter PRs are explicitly welcome — that is the point of the project. Start with [docs/adapters.md](docs/adapters.md) and [CONTRIBUTING.md](CONTRIBUTING.md). +Integration PRs are explicitly welcome — that is the point of the project. Start with [docs/adapters.md](docs/adapters.md) and [CONTRIBUTING.md](CONTRIBUTING.md). ## Relationship to Work Louder / OpenAI @@ -156,7 +161,7 @@ Microbridge only exists because the Codex Micro is *open* — and that was a cho OpenAI is a for-profit company, and it would have been easy to lock the Micro to a single first-party app: a closed protocol, an exclusive USB claim, no way for anyone else to light a key. They did the opposite — they ship the device kit in the open, keep the HID interface **non-exclusive** so third-party software can coexist with the official experience instead of fighting it, and keep giving users a choice (Codex CLI is open source; the models are reachable over documented APIs). None of that was required of them. **Thank you.** -Thanks too to **Work Louder** for designing a genuinely hackable macropad, and to **everyone who writes an adapter, files an issue, or plugs in a device and tells us what really happens** — adapters are the point of this project. +Thanks too to **Work Louder** for designing a genuinely hackable macropad, and to **everyone who writes an integration, files an issue, or plugs in a device and tells us what really happens** — integrations are the point of this project. Full notes: [ACKNOWLEDGMENTS.md](ACKNOWLEDGMENTS.md). diff --git a/adapters/README.md b/adapters/README.md index 05c4ba4..ed1871d 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -1,12 +1,14 @@ -# Adapter catalog +# Integration catalog -Adapter documentation lives here, one folder per runtime. +Integration documentation lives here, one folder per runtime. The local socket +protocol still calls an out-of-process connector an `adapter`; that is an +implementation term, not a separate product category. Read [docs/adapters.md](../docs/adapters.md) for the contract and the review checklist, and [docs/protocol.md](../docs/protocol.md) for the wire format. ## Built-in integrations -| Adapter | Status | Language | +| Integration | Status | Language | |---|---|---| | [`t3code`](t3code/) | daemon-owned paired HTTP | Rust | | [`factory`](factory/) | bundled official hooks + JSON-RPC controls | Rust helper | @@ -17,11 +19,12 @@ checklist, and [docs/protocol.md](../docs/protocol.md) for the wire format. These integrations are daemon-owned or reuse watchers compiled into the daemon. See [docs/architecture.md](../docs/architecture.md). -## Managed and out-of-process adapters +## Managed and out-of-process integrations -| Adapter | Status | Language | +| Integration | Status | Language | |---|---|---| | [`cursor`](cursor/) | bundled managed hooks | Node | +| [`opencode`](opencode/) | bundled global plugin + session interrupt | Node (no deps) | | [`reference-echo`](reference-echo/) | community adapter example | Node (no deps) | Out-of-process adapters may be written in any language and communicate over the diff --git a/adapters/cursor/README.md b/adapters/cursor/README.md index 2b5a8b6..5c017d2 100644 --- a/adapters/cursor/README.md +++ b/adapters/cursor/README.md @@ -7,7 +7,7 @@ replacement sessions. ## Install and consent -1. Open **Microbridge Settings → Adapters**. +1. Open **Microbridge Settings → Integrations**. 2. Click **Enable Cursor**. Microbridge installs its bundled integration into Cursor's supported local-plugin directory after this explicit consent. 3. Reload Cursor once if it is already open. **Remove** disables the adapter diff --git a/adapters/factory/README.md b/adapters/factory/README.md index 07677e0..1165601 100644 --- a/adapters/factory/README.md +++ b/adapters/factory/README.md @@ -1,6 +1,6 @@ # Factory integration -Enable Factory in **Microbridge Settings → Adapters**. The app transactionally +Enable Factory in **Microbridge Settings → Integrations**. The app transactionally merges Microbridge-owned commands into Factory's official user hooks at `~/.factory/hooks.json` and installs a signed helper at `~/.microbridge/integrations/factory/microbridgectl`. Existing hooks are diff --git a/adapters/opencode/README.md b/adapters/opencode/README.md new file mode 100644 index 0000000..5b91c33 --- /dev/null +++ b/adapters/opencode/README.md @@ -0,0 +1,24 @@ +# OpenCode integration + +Enable OpenCode in **Microbridge Settings → Integrations**. Microbridge installs +one dependency-free global plugin at +`~/.config/opencode/plugins/microbridge.mjs`. OpenCode loads global plugins for +both its CLI and desktop surfaces. + +The plugin uses OpenCode's documented plugin events and SDK client. It opens a +local Unix-socket connection to `microbridged`; there is no helper process, +polling loop, telemetry, or external network request. + +## Supported behavior + +- Lifecycle: session create/update/status/idle/error/delete, tool activity, and + permission-waiting state. +- Interrupt: OpenCode's documented `client.session.abort()` method for the exact + session owning the Agent Key. + +OpenCode does not currently expose a stable exact-session focus operation or a +model-aware per-session reasoning-effort mutation through this plugin contract, +so Microbridge does not advertise those controls. + +**Remove** deletes only the Microbridge-owned plugin. Restart OpenCode after +installing, repairing, or removing the integration if it was already running. diff --git a/adapters/opencode/microbridge.mjs b/adapters/opencode/microbridge.mjs new file mode 100644 index 0000000..13e7928 --- /dev/null +++ b/adapters/opencode/microbridge.mjs @@ -0,0 +1,229 @@ +// MICROBRIDGE_OPENCODE_PLUGIN +// Installed by Microbridge into ~/.config/opencode/plugins/microbridge.mjs. + +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +const ADAPTER_ID = "opencode"; +const ADAPTER_VERSION = "__MICROBRIDGE_VERSION__"; +const MAX_PENDING_MESSAGES = 128; +const MIN_RECONNECT_DELAY_MS = 1_000; +const MAX_RECONNECT_DELAY_MS = 30_000; + +const capabilities = { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: true, + new_session: false, + focus_open: false, + reasoning_effort: false, +}; + +export function sessionIdFromEvent(event) { + const properties = event?.properties ?? {}; + return ( + properties.sessionID ?? + properties.sessionId ?? + properties.info?.id ?? + properties.session?.id ?? + null + ); +} + +export function stateFromEvent(event) { + const type = event?.type ?? ""; + if (type === "permission.asked") return "awaiting_approval"; + if (type === "session.error") return "error"; + if (type === "session.idle") return "done"; + if (type === "session.created" || type === "session.updated") return "idle"; + if (type !== "session.status") return null; + + const status = event?.properties?.status; + const value = typeof status === "string" ? status : status?.type; + switch (value) { + case "busy": + case "active": + case "working": + return "working"; + case "retry": + case "thinking": + return "thinking"; + case "error": + return "error"; + case "idle": + return "done"; + default: + return null; + } +} + +export function titleFromInfo(info, directory) { + const title = info?.title?.trim(); + if (title) return title.slice(0, 72); + const candidate = info?.directory || directory; + const basename = candidate ? path.basename(candidate) : ""; + return basename || "OpenCode session"; +} + +function createBridge({ client, directory }) { + const socketPath = + process.env.MICROBRIDGE_SOCKET || + path.join(os.homedir(), ".microbridge", "microbridged.sock"); + const metadata = new Map(); + const latestStates = new Map(); + const pending = []; + let socket = null; + let reconnectTimer = null; + let reconnectDelayMs = MIN_RECONNECT_DELAY_MS; + let disposed = false; + let input = ""; + + const send = (message) => { + const line = `${JSON.stringify(message)}\n`; + if (socket?.writable) { + socket.write(line); + return; + } + pending.push(line); + if (pending.length > MAX_PENDING_MESSAGES) pending.shift(); + }; + + const publish = async (sessionID, state, suppliedInfo) => { + if (!sessionID || !state) return; + if (suppliedInfo) metadata.set(sessionID, suppliedInfo); + let info = metadata.get(sessionID); + if (!info) { + try { + const response = await client.session.get({ path: { id: sessionID } }); + info = response?.data ?? response; + if (info) metadata.set(sessionID, info); + } catch { + // Lifecycle truth is still useful when a title lookup races deletion. + } + } + latestStates.set(sessionID, state); + send({ + type: "status", + session: { + id: `${ADAPTER_ID}:${sessionID}`, + app: "OpenCode", + title: titleFromInfo(info, directory), + state, + updated_at_ms: Date.now(), + }, + }); + }; + + const handleAction = async (message) => { + if (message?.type !== "action" || message.action !== "interrupt") return; + const prefix = `${ADAPTER_ID}:`; + if (!message.session_id?.startsWith(prefix)) return; + const sessionID = message.session_id.slice(prefix.length); + if (!sessionID) return; + try { + await client.session.abort({ path: { id: sessionID } }); + } catch { + // The daemon keeps capability routing honest; OpenCode owns user-facing errors. + } + }; + + const scheduleReconnect = () => { + if (disposed || reconnectTimer) return; + const delay = reconnectDelayMs; + reconnectDelayMs = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, delay); + }; + + const connect = () => { + if (disposed || socket) return; + const next = net.createConnection({ path: socketPath }); + socket = next; + next.setEncoding("utf8"); + next.on("connect", () => { + reconnectDelayMs = MIN_RECONNECT_DELAY_MS; + next.write( + `${JSON.stringify({ + type: "hello", + adapter: ADAPTER_ID, + protocol_version: 0, + adapter_version: ADAPTER_VERSION, + capabilities, + })}\n`, + ); + for (const line of pending.splice(0)) next.write(line); + for (const [sessionID, state] of latestStates) { + void publish(sessionID, state, metadata.get(sessionID)); + } + }); + next.on("data", (chunk) => { + input += chunk; + while (input.includes("\n")) { + const newline = input.indexOf("\n"); + const line = input.slice(0, newline).trim(); + input = input.slice(newline + 1); + if (!line) continue; + try { + void handleAction(JSON.parse(line)); + } catch { + // Ignore malformed daemon lines and retain the connection. + } + } + }); + next.on("error", () => {}); + next.on("close", () => { + if (socket === next) socket = null; + input = ""; + scheduleReconnect(); + }); + }; + + connect(); + + return { + async event(event) { + const sessionID = sessionIdFromEvent(event); + if (event?.type === "session.deleted") { + if (sessionID) { + metadata.delete(sessionID); + latestStates.delete(sessionID); + send({ type: "bye", session_id: `${ADAPTER_ID}:${sessionID}` }); + } + return; + } + const info = event?.properties?.info ?? event?.properties?.session; + const state = stateFromEvent(event); + if (sessionID && (state || info)) { + await publish(sessionID, state ?? latestStates.get(sessionID) ?? "idle", info); + } + }, + async activity(sessionID, state) { + await publish(sessionID, state); + }, + dispose() { + disposed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + reconnectTimer = null; + socket?.destroy(); + socket = null; + }, + }; +} + +export const Microbridge = async ({ client, directory }) => { + const bridge = createBridge({ client, directory }); + return { + event: async ({ event }) => bridge.event(event), + "chat.message": async ({ sessionID }) => bridge.activity(sessionID, "thinking"), + "permission.ask": async (input, output) => { + if (output.status === "ask") await bridge.activity(input.sessionID, "awaiting_approval"); + }, + "tool.execute.before": async ({ sessionID }) => bridge.activity(sessionID, "working"), + "tool.execute.after": async ({ sessionID }) => bridge.activity(sessionID, "working"), + dispose: async () => bridge.dispose(), + }; +}; diff --git a/adapters/opencode/microbridge.test.mjs b/adapters/opencode/microbridge.test.mjs new file mode 100644 index 0000000..d7cd925 --- /dev/null +++ b/adapters/opencode/microbridge.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sessionIdFromEvent, stateFromEvent, titleFromInfo } from "./microbridge.mjs"; + +test("maps official OpenCode lifecycle events", () => { + assert.equal( + sessionIdFromEvent({ type: "session.status", properties: { sessionID: "ses_1" } }), + "ses_1", + ); + assert.equal( + sessionIdFromEvent({ type: "session.updated", properties: { info: { id: "ses_2" } } }), + "ses_2", + ); + assert.equal( + stateFromEvent({ type: "session.status", properties: { status: { type: "busy" } } }), + "working", + ); + assert.equal(stateFromEvent({ type: "permission.asked", properties: {} }), "awaiting_approval"); + assert.equal(stateFromEvent({ type: "session.idle", properties: {} }), "done"); + assert.equal(stateFromEvent({ type: "session.error", properties: {} }), "error"); +}); + +test("uses session title and falls back to the workspace name", () => { + assert.equal(titleFromInfo({ title: "Ship OpenCode support" }, "/tmp/repo"), "Ship OpenCode support"); + assert.equal(titleFromInfo({}, "/Users/me/dev/microbridge"), "microbridge"); +}); diff --git a/adapters/t3code/README.md b/adapters/t3code/README.md index 1060f08..c1ece3e 100644 --- a/adapters/t3code/README.md +++ b/adapters/t3code/README.md @@ -5,7 +5,7 @@ its host contract and replaces the former idle Node scaffold. ## Pairing -1. Enable T3 Code in **Microbridge Settings → Adapters**. +1. Enable T3 Code in **Microbridge Settings → Integrations**. 2. In T3 Code, open **Settings → Connections**, enable **Network access**, then create a one-time link under **Authorized clients**. 3. Paste the link into Microbridge. The one-time token is exchanged immediately. diff --git a/adapters/t3code/index.mjs b/adapters/t3code/index.mjs index 41f602e..dfbea68 100755 --- a/adapters/t3code/index.mjs +++ b/adapters/t3code/index.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node console.error( - "The T3 Code adapter is daemon-owned in Microbridge v0.2.1. Enable and pair it in Microbridge Settings → Adapters.", + "The T3 Code integration is daemon-owned in Microbridge v0.2.1. Enable and pair it in Microbridge Settings → Integrations.", ); process.exitCode = 1; diff --git a/apps/microbridge-ui/README.md b/apps/microbridge-ui/README.md index 14e7b2e..7f4cd53 100644 --- a/apps/microbridge-ui/README.md +++ b/apps/microbridge-ui/README.md @@ -7,7 +7,7 @@ actions (approve / reject / interrupt) stay on the physical Codex Micro. |---|---| | Menu bar icon | Template tray icon; click toggles the popover | | Popover | Connection, focus card, device echo, threads, Settings / Pause LEDs / Quit | -| Settings | Keys (device twin) · Agent Keys · Adapters · Device | +| Settings | Keys (device twin) · Agent Keys · Integrations · Device · Updates | | Focus HUD | ~2.5s toast when deck focus changes | MagicPath mockups remain the visual go-to (`vendor/magicpath/`). diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs index cee115e..4b3e544 100644 --- a/apps/microbridge-ui/src-tauri/src/lib.rs +++ b/apps/microbridge-ui/src-tauri/src/lib.rs @@ -553,6 +553,156 @@ mod factory_integration_tests { } } +const OPENCODE_PLUGIN_MARKER: &str = "MICROBRIDGE_OPENCODE_PLUGIN"; +const OPENCODE_PLUGIN_NAME: &str = "microbridge.mjs"; +static OPENCODE_PLUGIN_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn validate_opencode_plugin(path: &Path) -> Result { + let source = + fs::read_to_string(path).map_err(|error| format!("read {}: {error}", path.display()))?; + if !source.contains(OPENCODE_PLUGIN_MARKER) || !source.contains("export const Microbridge") { + return Err(format!( + "{} is not the Microbridge OpenCode integration", + path.display() + )); + } + Ok(source) +} + +fn opencode_plugin_source(app: &AppHandle) -> Result { + let bundled = app + .path() + .resource_dir() + .map_err(|error| error.to_string())? + .join("opencode-plugin") + .join(OPENCODE_PLUGIN_NAME); + if validate_opencode_plugin(&bundled).is_ok() { + return Ok(bundled); + } + let repository = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../adapters/opencode") + .join(OPENCODE_PLUGIN_NAME); + validate_opencode_plugin(&repository)?; + Ok(repository) +} + +fn opencode_plugin_destination() -> Result { + let home = std::env::var_os("HOME").ok_or_else(|| "HOME is unavailable".to_string())?; + Ok(PathBuf::from(home) + .join(".config/opencode/plugins") + .join(OPENCODE_PLUGIN_NAME)) +} + +fn install_opencode_integration(app: &AppHandle) -> Result { + let source = opencode_plugin_source(app)?; + let destination = opencode_plugin_destination()?; + install_opencode_integration_at( + &source, + &destination, + &app.package_info().version.to_string(), + )?; + Ok(destination) +} + +fn install_opencode_integration_at( + source: &Path, + destination: &Path, + version: &str, +) -> Result<(), String> { + let _operation = OPENCODE_PLUGIN_LOCK + .lock() + .map_err(|_| "OpenCode integration installer lock is unavailable".to_string())?; + let template = validate_opencode_plugin(source)?; + if destination.is_file() { + validate_opencode_plugin(destination).map_err(|_| { + format!( + "Preserving unowned OpenCode plugin at {}. Move it aside before enabling Microbridge.", + destination.display() + ) + })?; + } + let parent = destination + .parent() + .ok_or_else(|| "OpenCode plugin destination has no parent".to_string())?; + fs::create_dir_all(parent).map_err(|error| format!("create {}: {error}", parent.display()))?; + let rendered = template.replace("__MICROBRIDGE_VERSION__", version); + let staging = destination.with_extension("mjs.microbridge.tmp"); + fs::write(&staging, rendered) + .map_err(|error| format!("write {}: {error}", staging.display()))?; + validate_opencode_plugin(&staging)?; + fs::rename(&staging, destination) + .map_err(|error| format!("install OpenCode integration: {error}")) +} + +fn remove_opencode_integration() -> Result { + let destination = opencode_plugin_destination()?; + remove_opencode_integration_at(&destination) +} + +fn remove_opencode_integration_at(destination: &Path) -> Result { + let _operation = OPENCODE_PLUGIN_LOCK + .lock() + .map_err(|_| "OpenCode integration installer lock is unavailable".to_string())?; + if !destination.exists() { + return Ok(false); + } + validate_opencode_plugin(destination).map_err(|_| { + format!( + "Preserving unowned OpenCode plugin at {}.", + destination.display() + ) + })?; + fs::remove_file(destination) + .map_err(|error| format!("remove {}: {error}", destination.display()))?; + Ok(true) +} + +fn sync_opencode_integration(app: &AppHandle, synced: &AtomicBool, enabled: bool) { + if !enabled { + synced.store(false, Ordering::Relaxed); + return; + } + if !synced.swap(true, Ordering::Relaxed) && install_opencode_integration(app).is_err() { + synced.store(false, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod opencode_integration_tests { + use super::*; + + #[test] + fn installs_updates_and_removes_only_owned_opencode_plugin() { + let root = + std::env::temp_dir().join(format!("microbridge-opencode-test-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).unwrap(); + let source = root.join("source.mjs"); + let destination = root.join("plugins/microbridge.mjs"); + fs::write( + &source, + "// MICROBRIDGE_OPENCODE_PLUGIN\nconst v='__MICROBRIDGE_VERSION__';\nexport const Microbridge = async () => ({})\n", + ) + .unwrap(); + + install_opencode_integration_at(&source, &destination, "0.4.0").unwrap(); + let installed = fs::read_to_string(&destination).unwrap(); + assert!(installed.contains("0.4.0")); + assert!(!installed.contains("__MICROBRIDGE_VERSION__")); + assert!(remove_opencode_integration_at(&destination).unwrap()); + assert!(!destination.exists()); + + fs::write(&destination, "export const SomeoneElse = {}\n").unwrap(); + assert!(install_opencode_integration_at(&source, &destination, "0.4.1").is_err()); + assert!(remove_opencode_integration_at(&destination).is_err()); + assert_eq!( + fs::read_to_string(&destination).unwrap(), + "export const SomeoneElse = {}\n" + ); + let _ = fs::remove_dir_all(&root); + } +} + fn physical_tray_rect(rect: &tauri::Rect, scale: f64) -> (f64, f64, f64, f64) { let (x, y) = match rect.position { Position::Physical(p) => (p.x as f64, p.y as f64), @@ -809,6 +959,30 @@ async fn set_adapter_enabled( } } } + if adapter_id == "opencode" && enabled { + match install_opencode_integration(&app) { + Ok(path) => { + return Ok(format!( + "{message} OpenCode integration installed at {}. Restart OpenCode once if it is already running.", + path.display() + )); + } + Err(error) => { + if !was_enabled { + let _ = state + .bus + .adapter_operation(ClientMessage::SetAdapterEnabled { + adapter_id: adapter_id.clone(), + enabled: false, + }) + .await; + } + return Err(format!( + "OpenCode was not enabled because its bundled integration could not be installed: {error}" + )); + } + } + } Ok(message) } @@ -844,6 +1018,7 @@ async fn forget_adapter( let removed = match adapter_id.as_str() { "cursor" => remove_cursor_integration()?, "factory" => remove_factory_integration()?, + "opencode" => remove_opencode_integration()?, _ => false, }; let operation = state @@ -860,6 +1035,8 @@ async fn forget_adapter( let _ = install_cursor_integration(&app); } else if adapter_id == "factory" { let _ = install_factory_integration(); + } else if adapter_id == "opencode" { + let _ = install_opencode_integration(&app); } } if was_enabled { @@ -886,6 +1063,11 @@ async fn forget_adapter( "{message} The Microbridge-owned Factory hooks and helper were removed." )); } + if adapter_id == "opencode" && removed { + return Ok(format!( + "{message} The Microbridge-owned OpenCode integration was removed. Restart OpenCode if it is running." + )); + } Ok(message) } @@ -1107,6 +1289,8 @@ pub fn run() { let cursor_sync_loop = Arc::clone(&cursor_integration_synced); let factory_integration_synced = Arc::new(AtomicBool::new(false)); let factory_sync_loop = Arc::clone(&factory_integration_synced); + let opencode_integration_synced = Arc::new(AtomicBool::new(false)); + let opencode_sync_loop = Arc::clone(&opencode_integration_synced); tauri::async_runtime::spawn(async move { let mut last_focus: Option = None; @@ -1128,6 +1312,17 @@ pub fn run() { .map(|preference| preference.enabled) .unwrap_or(false); sync_factory_integration(&factory_sync_loop, factory_enabled); + let opencode_enabled = s + .config + .adapters + .get("opencode") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_opencode_integration( + &handle, + &opencode_sync_loop, + opencode_enabled, + ); last_focus = s.focused_session_id.clone(); saw_snapshot = true; *snap_for_loop.lock().await = Some(s.clone()); @@ -1159,6 +1354,16 @@ pub fn run() { .map(|preference| preference.enabled) .unwrap_or(false); sync_factory_integration(&factory_sync_loop, factory_enabled); + let opencode_enabled = config + .adapters + .get("opencode") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_opencode_integration( + &handle, + &opencode_sync_loop, + opencode_enabled, + ); } let focus_changed = matches!(&event, BusEvent::FocusChanged { .. }); let mut guard = snap_for_loop.lock().await; @@ -1190,6 +1395,16 @@ pub fn run() { .map(|preference| preference.enabled) .unwrap_or(false); sync_factory_integration(&factory_sync_loop, factory_enabled); + let opencode_enabled = config + .adapters + .get("opencode") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_opencode_integration( + &handle, + &opencode_sync_loop, + opencode_enabled, + ); let mut guard = snap_for_loop.lock().await; if let Some(s) = guard.as_mut() { s.config = config; diff --git a/apps/microbridge-ui/src-tauri/tauri.conf.json b/apps/microbridge-ui/src-tauri/tauri.conf.json index 7708db0..97f6831 100644 --- a/apps/microbridge-ui/src-tauri/tauri.conf.json +++ b/apps/microbridge-ui/src-tauri/tauri.conf.json @@ -78,7 +78,8 @@ "../../../adapters/cursor/.cursor-plugin/plugin.json": "cursor-plugin/.cursor-plugin/plugin.json", "../../../adapters/cursor/hooks/hooks.json": "cursor-plugin/hooks/hooks.json", "../../../adapters/cursor/hooks/event.mjs": "cursor-plugin/hooks/event.mjs", - "../../../adapters/cursor/hooks/microbridge-event.mjs": "cursor-plugin/hooks/microbridge-event.mjs" + "../../../adapters/cursor/hooks/microbridge-event.mjs": "cursor-plugin/hooks/microbridge-event.mjs", + "../../../adapters/opencode/microbridge.mjs": "opencode-plugin/microbridge.mjs" }, "icon": [ "icons/32x32.png", diff --git a/apps/microbridge-ui/src/App.tsx b/apps/microbridge-ui/src/App.tsx index 01e7a03..9014c59 100644 --- a/apps/microbridge-ui/src/App.tsx +++ b/apps/microbridge-ui/src/App.tsx @@ -21,7 +21,7 @@ type SettingsTab = | "general" | "keys" | "agent" - | "adapters" + | "integrations" | "device" | "updates"; diff --git a/apps/microbridge-ui/src/lib/bus.ts b/apps/microbridge-ui/src/lib/bus.ts index cafa6dc..6ba3de0 100644 --- a/apps/microbridge-ui/src/lib/bus.ts +++ b/apps/microbridge-ui/src/lib/bus.ts @@ -12,7 +12,7 @@ const DEMO: Snapshot = { sessions: [ { id: "s1", - app: "Codex", + app: "ChatGPT", title: "microbridge — HID reconnect on wake", state: "working", updated_at_ms: Date.now() - 12 * 60000, @@ -62,6 +62,7 @@ const DEMO: Snapshot = { cursor: { enabled: false }, t3code: { enabled: false }, factory: { enabled: false }, + opencode: { enabled: false }, }, hardware_control_enabled: false, brightness: 80, @@ -101,6 +102,38 @@ const DEMO: Snapshot = { }, diagnostic: "Built-in lifecycle watcher is active.", }, + { + id: "claude_desktop", + display_name: "Claude Desktop", + kind: "native", + state: "connected", + capabilities: { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Built-in Claude Desktop session attribution is ready.", + }, + { + id: "chatgpt", + display_name: "ChatGPT", + kind: "native", + state: "connected", + capabilities: { + lifecycle_observation: true, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Built-in ChatGPT Codex-session attribution is ready.", + }, { id: "cnvs", display_name: "CNVS", @@ -197,6 +230,22 @@ const DEMO: Snapshot = { }, diagnostic: "Disabled until you explicitly enable this integration.", }, + { + id: "opencode", + display_name: "OpenCode", + kind: "community", + state: "disabled", + capabilities: { + lifecycle_observation: false, + approval_acceptance: false, + approval_rejection: false, + interrupt: false, + new_session: false, + focus_open: false, + reasoning_effort: false, + }, + diagnostic: "Disabled until you explicitly enable this integration.", + }, ], }; @@ -266,7 +315,7 @@ export async function setConfig(config: DaemonConfig): Promise { export async function setAdapterEnabled(adapterId: string, enabled: boolean): Promise { const message = await invokeTauri("set_adapter_enabled", { adapterId, enabled }); - if (message === null) throw new Error("Adapter controls require the Microbridge app."); + if (message === null) throw new Error("Integration controls require the Microbridge app."); return message; } @@ -278,7 +327,7 @@ export async function pairAdapter(adapterId: string, pairingUrl: string): Promis export async function forgetAdapter(adapterId: string): Promise { const message = await invokeTauri("forget_adapter", { adapterId }); - if (message === null) throw new Error("Adapter controls require the Microbridge app."); + if (message === null) throw new Error("Integration controls require the Microbridge app."); return message; } diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx index 88f7a8f..41868ca 100644 --- a/apps/microbridge-ui/src/surfaces/Settings.tsx +++ b/apps/microbridge-ui/src/surfaces/Settings.tsx @@ -46,6 +46,30 @@ const CAPABILITIES: { id: keyof AdapterCapabilities; label: string }[] = [ { id: "reasoning_effort", label: "Effort" }, ]; +const INTEGRATION_ORDER = [ + "chatgpt", + "claude_desktop", + "claude", + "codex", + "cnvs", + "synara", + "conductor", + "cursor", + "t3code", + "factory", + "opencode", +]; + +const INTEGRATION_STATE_LABELS = { + disabled: "Not connected", + needs_setup: "Waiting", + connecting: "Connecting", + connected: "Connected", + limited: "Limited", + incompatible: "Incompatible", + error: "Error", +} as const; + const KEY_SOURCES: { id: DaemonConfig["key_source"]; label: string; @@ -54,7 +78,7 @@ const KEY_SOURCES: { { id: "focused_app", label: "Focused app", - hint: "Owning app — newest threads (Claude, Codex, CNVS, Cursor, Synara, T3, Conductor, Factory)", + hint: "Owning app — newest threads (ChatGPT, Claude, Codex, CNVS, Cursor, Synara, T3, Conductor, Factory, OpenCode)", }, { id: "most_recent", @@ -78,7 +102,7 @@ const KEY_SOURCES: { }, ]; -type Tab = "general" | "keys" | "agent" | "adapters" | "device" | "updates"; +type Tab = "general" | "keys" | "agent" | "integrations" | "device" | "updates"; export function Settings({ snapshot, @@ -155,7 +179,7 @@ export function Settings({ { id: "general", label: "General" }, { id: "keys", label: "Keys" }, { id: "agent", label: "Agent Keys" }, - { id: "adapters", label: "Adapters" }, + { id: "integrations", label: "Integrations" }, { id: "device", label: "Device" }, { id: "updates", label: "Updates" }, ]; @@ -251,7 +275,7 @@ export function Settings({ > Click a control on the twin to inspect it. Agent Keys show the live thread; commands route only when hardware control is enabled - and the focused adapter advertises the action. + and the focused integration advertises the action.

)} - {tab === "adapters" && ( + {tab === "integrations" && (
-

Adapters

+

Integrations

- CNVS connects automatically through its authenticated local control - API and targets exact canvas terminals. Cursor and Factory ship - inside Microbridge and install locally with one click. Synara and - Conductor are identified through the built-in Codex and Claude - journal watchers. State and capabilities below are live. + Every supported app is listed here. Green integrations are ready; + everything else shows exactly what it needs. ChatGPT, Claude + Desktop, Synara, Conductor, and CNVS are built in. Cursor, Factory, + T3 Code, and OpenCode connect only after you choose to enable them.

CNVS-hosted Codex and Claude terminals replace matching raw journal - cards while CNVS owns them. T3-hosted threads are identified automatically. For controls, enable + cards while CNVS owns them. OpenCode uses its official global plugin + API for lifecycle and interrupt. T3-hosted threads are identified automatically. For controls, enable Network access in T3 Code Settings → Connections, create a link under Authorized clients, then paste it below. Factory hooks are merged without replacing your existing hooks. @@ -598,8 +622,27 @@ export function Settings({ {adapterMessage}

)} -
    - {snapshot.adapters.map((adapter) => ( + {[ + { + label: "Connected", + connected: true, + integrations: snapshot.adapters.filter((item) => item.state === "connected"), + }, + { + label: "Not connected", + connected: false, + integrations: snapshot.adapters.filter((item) => item.state !== "connected"), + }, + ].map((group) => ( +
    +
    + + {group.label} · {group.integrations.length} +
    +
      + {[...group.integrations] + .sort((left, right) => INTEGRATION_ORDER.indexOf(left.id) - INTEGRATION_ORDER.indexOf(right.id)) + .map((adapter) => (
    • {adapter.display_name} - - {adapter.kind} -
      - {adapter.state.replace("_", " ")} + {INTEGRATION_STATE_LABELS[adapter.state]}
      @@ -694,12 +731,14 @@ export function Settings({ ? "Enable Cursor" : adapter.id === "factory" ? "Enable Factory" + : adapter.id === "opencode" + ? "Enable OpenCode" : "Enable integration"} )} {adapter.kind === "community" && cfg.adapters[adapter.id]?.enabled && ( <> - {(adapter.id === "cursor" || adapter.id === "factory") && ( + {(adapter.id === "cursor" || adapter.id === "factory" || adapter.id === "opencode") && (
    • ))} -
    +
+
+ ))} )} @@ -748,7 +789,7 @@ export function Settings({

Updates

Update checks run only when requested or explicitly enabled. A - paired T3 Code adapter contacts only its approved environment. + paired T3 Code integration contacts only its approved environment.

{ expect(html).toContain("Reset to Codex Defaults"); }); - it("renders daemon adapter state and capability limits", () => { + it("renders unified integration state and capability limits", () => { const html = renderToStaticMarkup( , ); - expect(html).toContain("limited"); + expect(html).toContain("Limited"); expect(html).toContain("Lifecycle is connected"); expect(html).toContain("Live state"); - expect(html).toContain("CNVS connects automatically"); + expect(html).toContain("Integrations"); + expect(html).toContain("Connected · 1"); + expect(html).toContain("Not connected · 1"); expect(html).toContain("Connected across 3 exact canvas terminal targets"); expect(html).toContain("✓ Open"); expect(html).toContain("Interrupt"); - expect(html).toContain("Cursor and Factory ship"); + expect(html).toContain("OpenCode uses its official global plugin"); expect(html).toContain("Repair bundled integration"); expect(html).not.toContain("Install managed plugin"); expect(html).not.toContain("scaffold only"); diff --git a/crates/mb-adapters/src/codex.rs b/crates/mb-adapters/src/codex.rs index ae5daab..4bb02fc 100644 --- a/crates/mb-adapters/src/codex.rs +++ b/crates/mb-adapters/src/codex.rs @@ -198,8 +198,8 @@ fn codex_app_label(originator: Option<&str>, cwd: Option<&str>) -> String { ("synara", "Synara"), ("conductor", "Conductor"), ("factory", "Factory"), - ("codex desktop", "Codex Desktop"), - ("codex_work_desktop", "Codex Desktop"), + ("codex desktop", "ChatGPT"), + ("codex_work_desktop", "ChatGPT"), ]; if let Some(originator) = originator { @@ -302,14 +302,8 @@ mod tests { assert_eq!(codex_app_label(Some("t3code_desktop"), None), "T3 Code"); assert_eq!(codex_app_label(Some("factory_desktop"), None), "Factory"); // The desktop app writes a display-cased originator. - assert_eq!( - codex_app_label(Some("Codex Desktop"), None), - "Codex Desktop" - ); - assert_eq!( - codex_app_label(Some("codex_work_desktop"), None), - "Codex Desktop" - ); + assert_eq!(codex_app_label(Some("Codex Desktop"), None), "ChatGPT"); + assert_eq!(codex_app_label(Some("codex_work_desktop"), None), "ChatGPT"); // Unknown host, no locating cwd → historical default rather than a // guess. diff --git a/crates/mb-protocol/src/lib.rs b/crates/mb-protocol/src/lib.rs index 99b561e..742d5a0 100644 --- a/crates/mb-protocol/src/lib.rs +++ b/crates/mb-protocol/src/lib.rs @@ -290,6 +290,7 @@ pub fn default_adapter_preferences() -> BTreeMap { ("cursor".into(), AdapterPreference { enabled: false }), ("t3code".into(), AdapterPreference { enabled: false }), ("factory".into(), AdapterPreference { enabled: false }), + ("opencode".into(), AdapterPreference { enabled: false }), ]) } @@ -321,7 +322,8 @@ pub struct DaemonConfig { pub lighting_preset: LightingPreset, #[serde(default)] pub state_colors: StateColors, - /// Per-host consent. Cursor, T3 Code, and Factory are disabled until explicitly enabled. + /// Per-host consent. Cursor, T3 Code, Factory, and OpenCode are disabled + /// until explicitly enabled. #[serde(default = "default_adapter_preferences")] pub adapters: BTreeMap, /// Claim and poll the physical HID interface. Off unless explicitly enabled. diff --git a/crates/microbridged/src/app_match.rs b/crates/microbridged/src/app_match.rs index 3593b66..4b99e9f 100644 --- a/crates/microbridged/src/app_match.rs +++ b/crates/microbridged/src/app_match.rs @@ -39,6 +39,12 @@ pub fn app_family(name: &str) -> String { if lower == "cnvs" || lower.starts_with("cnvs ") { return "cnvs".into(); } + if lower == "opencode" || lower.starts_with("opencode ") { + return "opencode".into(); + } + if is_chatgpt(&lower) { + return "chatgpt".into(); + } if is_codex(&lower) { return "codex".into(); } @@ -60,7 +66,9 @@ fn canonical_family(name: &str) -> Option<&'static str> { "Conductor" => Some("conductor"), "Factory" => Some("factory"), "CNVS" => Some("cnvs"), + "OpenCode" => Some("opencode"), "Codex CLI" => Some("codex"), + "ChatGPT" | "Codex Desktop" => Some("chatgpt"), "Claude Code" => Some("claude_code"), "Claude Desktop" => Some("claude_desktop"), _ => None, @@ -85,8 +93,14 @@ fn is_t3(lower: &str) -> bool { || lower.starts_with("t3 chat") } +fn is_chatgpt(lower: &str) -> bool { + matches!(lower, "chatgpt" | "codex app" | "codex desktop") + || lower.starts_with("chatgpt ") + || lower.starts_with("codex desktop") +} + fn is_codex(lower: &str) -> bool { - matches!(lower, "codex" | "codex cli" | "codex app" | "chatgpt") || lower.starts_with("codex ") + matches!(lower, "codex" | "codex cli") || lower.starts_with("codex cli") } /// Frontmost often reports bare `"Claude"` while sessions are `"Claude Code"`. @@ -119,12 +133,15 @@ mod tests { assert!(same_app("Conductor", "Conductor")); assert!(same_app("Factory", "Factory")); assert!(same_app("CNVS", "CNVS")); + assert!(same_app("OpenCode", "OpenCode (Dev)")); } #[test] fn codex_cli_matches_codex_frontmost() { assert!(same_app("Codex", "Codex CLI")); assert!(same_app("Codex CLI", "Codex CLI")); + assert!(!same_app("ChatGPT", "Codex CLI")); + assert!(same_app("Codex Desktop", "ChatGPT")); } #[test] diff --git a/crates/microbridged/src/state.rs b/crates/microbridged/src/state.rs index fb05c34..9e97120 100644 --- a/crates/microbridged/src/state.rs +++ b/crates/microbridged/src/state.rs @@ -803,6 +803,10 @@ fn setup_diagnostic(adapter_id: &str) -> String { } "t3code" => "Paste a one-time pairing link from T3 Code Settings → Connections.".into(), "factory" => "Enable the bundled Factory lifecycle hooks to connect Droid sessions.".into(), + "opencode" => { + "The bundled OpenCode integration is installed. Restart OpenCode once if it is already running." + .into() + } "cnvs" => { "Open CNVS; Microbridge connects to its authenticated local control API automatically." .into() @@ -816,6 +820,7 @@ fn reconnect_diagnostic(adapter_id: &str) -> String { "cursor" => "Cursor is enabled, but no lifecycle event has arrived yet. Reload Cursor if it was already open.".into(), "t3code" => "T3 Code is enabled, but its paired connection is offline.".into(), "factory" => "Factory is enabled, but no Droid lifecycle event has arrived yet.".into(), + "opencode" => "OpenCode is enabled, but its integration has not connected yet. Restart OpenCode if it was already running.".into(), "cnvs" => "CNVS is enabled and will reconnect automatically when the app is running.".into(), _ => "Adapter is offline.".into(), } @@ -870,13 +875,19 @@ fn initial_adapter_statuses(config: &DaemonConfig) -> BTreeMap