Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ Factory's supported `~/.factory/hooks.json`; existing hooks are preserved.
**Remove** deletes the Microbridge-owned hook entries and helper. Droid must be
installed and signed in for interrupt and reasoning-effort controls.

### CNVS integration

CNVS support ships inside the daemon and is enabled by default. Start CNVS and
Microbridge connects automatically to CNVS's authenticated loopback control
API. Every running agent terminal is identified by its exact canvas and node,
so an Agent Key can focus the correct workspace and terminal or interrupt that
specific agent. There is no pairing code, plugin installation, or CNVS file
modification.

CNVS currently exposes lifecycle, focus, and interrupt controls through this
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**.
Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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 — 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.

> **Status: early public alpha (`v0.3.x`).** Menu bar UI, local daemon, in-process Codex/Claude host attribution, 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 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).

## Screenshots

Expand Down Expand Up @@ -40,8 +40,8 @@ The Micro's best feature — bidirectional Agent Keys — currently works throug

## Design principles

1. **Invisible footprint.** Local watchers are event-driven; device input 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, 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.
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.

Expand All @@ -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.

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
code, private database access, or CNVS modification is required.

## Architecture

```
Expand Down
23 changes: 17 additions & 6 deletions adapters/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
# Community adapters
# Adapter catalog

Out-of-process adapters live here, one folder per runtime, any language.
Adapter documentation lives here, one folder per runtime.
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 |
|---|---|---|
| [`reference-echo`](reference-echo/) | working example | Node (no deps) |
| [`cursor`](cursor/) | bundled managed hooks | Node |
| [`t3code`](t3code/) | daemon-owned paired HTTP | Rust |
| [`factory`](factory/) | bundled official hooks + JSON-RPC controls | Rust helper |
| [`cnvs`](cnvs/) | native authenticated loopback control | Rust |
| [`synara`](synara/) | built-in host attribution | Rust |
| [`conductor`](conductor/) | built-in host attribution | Rust |

First-party watchers (Codex CLI, Claude Code, including their embedding hosts) are compiled into the daemon
(`crates/mb-adapters`) — see [docs/architecture.md](../docs/architecture.md).
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

| Adapter | Status | Language |
|---|---|---|
| [`cursor`](cursor/) | bundled managed hooks | Node |
| [`reference-echo`](reference-echo/) | community adapter example | Node (no deps) |

Out-of-process adapters may be written in any language and communicate over the
documented local socket.
40 changes: 40 additions & 0 deletions adapters/cnvs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# CNVS integration

CNVS support is compiled into `microbridged` and enabled by default. It uses
CNVS's authenticated local control API; it does not scrape CNVS internals,
read its private database, modify a workspace, or install a plugin.

## Setup

There is no pairing code. Start CNVS and Microbridge. CNVS publishes a
short-lived loopback endpoint descriptor, and Microbridge connects
automatically while both apps are running.

Each session has the stable identity `cnvs:<canvas-id>:<node-id>`. This lets an
Agent Key route to the exact CNVS workspace and terminal even when different
canvases are running different harnesses such as Codex or Claude Code.

## Capabilities

- Lifecycle: active CNVS agent terminals and their working, waiting, done, or
error state.
- Open/focus: focuses the exact canvas and terminal node.
- Interrupt: stops the agent running in that exact terminal.

CNVS does not currently expose stable controls for approval/rejection, starting
a new agent, or reasoning effort through this contract, so Microbridge does not
advertise those actions.

## Footprint and privacy

CNVS's state contract is snapshot-based. Microbridge refreshes the local API
every 2 seconds while an agent is active and every 10 seconds while idle, then
emits only state transitions. It accepts only loopback endpoints, rereads the
descriptor for every scan or action, and never logs or persists the token.

When a CNVS terminal maps exactly to a raw Codex or Claude journal by runtime
and working directory, the native CNVS session replaces that raw card while
CNVS owns it. The journal observation remains cached and returns if CNVS exits.
Cursor's current managed lifecycle contract does not include enough stable
workspace identity for equivalent exact reconciliation, so a Cursor-hosted
terminal may still appear twice if both integrations report it.
17 changes: 17 additions & 0 deletions apps/microbridge-ui/src/lib/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const DEMO: Snapshot = {
adapters: {
codex: { enabled: true },
claude: { enabled: true },
cnvs: { enabled: true },
cursor: { enabled: false },
t3code: { enabled: false },
factory: { enabled: false },
Expand Down Expand Up @@ -100,6 +101,22 @@ const DEMO: Snapshot = {
},
diagnostic: "Built-in lifecycle watcher is active.",
},
{
id: "cnvs",
display_name: "CNVS",
kind: "native",
state: "connected",
capabilities: {
lifecycle_observation: true,
approval_acceptance: false,
approval_rejection: false,
interrupt: true,
new_session: false,
focus_open: true,
reasoning_effort: false,
},
diagnostic: "CNVS control is connected across exact canvas terminal targets.",
},
{
id: "synara",
display_name: "Synara",
Expand Down
14 changes: 8 additions & 6 deletions apps/microbridge-ui/src/surfaces/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const KEY_SOURCES: {
{
id: "focused_app",
label: "Focused app",
hint: "Owning app — newest threads (Claude, Codex, Cursor, Synara, T3, Conductor, Factory)",
hint: "Owning app — newest threads (Claude, Codex, CNVS, Cursor, Synara, T3, Conductor, Factory)",
},
{
id: "most_recent",
Expand Down Expand Up @@ -580,13 +580,15 @@ export function Settings({
<section>
<h1 className="text-[18px] font-semibold">Adapters</h1>
<p className="mt-1 text-[12.5px]" style={{ color: t.textSecondary }}>
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.
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.
</p>
<p className="mt-2 text-[11px]" style={{ color: t.textMuted }}>
T3-hosted threads are identified automatically. For controls, enable
CNVS-hosted Codex and Claude terminals replace matching raw journal
cards while CNVS owns them. 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.
Expand Down
23 changes: 22 additions & 1 deletion apps/microbridge-ui/src/surfaces/surfaces.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function snapshot(sessions: SessionStatus[] = []): Snapshot {
adapters: {
codex: { enabled: true },
claude: { enabled: true },
cnvs: { enabled: true },
cursor: { enabled: true },
t3code: { enabled: false },
factory: { enabled: false },
Expand All @@ -43,6 +44,22 @@ function snapshot(sessions: SessionStatus[] = []): Snapshot {
frontmost_app: null,
},
adapters: [
{
id: "cnvs",
display_name: "CNVS",
kind: "native",
state: "connected",
capabilities: {
lifecycle_observation: true,
approval_acceptance: false,
approval_rejection: false,
interrupt: true,
new_session: false,
focus_open: true,
reasoning_effort: false,
},
diagnostic: "Connected across 3 exact canvas terminal targets.",
},
{
id: "cursor",
display_name: "Cursor",
Expand Down Expand Up @@ -98,7 +115,11 @@ describe("Settings", () => {
expect(html).toContain("limited");
expect(html).toContain("Lifecycle is connected");
expect(html).toContain("Live state");
expect(html).toContain("Cursor and Factory ship inside Microbridge");
expect(html).toContain("CNVS connects automatically");
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("Repair bundled integration");
expect(html).not.toContain("Install managed plugin");
expect(html).not.toContain("scaffold only");
Expand Down
29 changes: 18 additions & 11 deletions crates/mb-adapters/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use tracing::debug;
use crate::hosts::host_from_cwd;
use crate::title::{clean_title, looks_like_boilerplate, project_label_from_path};
use crate::watch::{path_components_contain, watch_dir};
use crate::{AdapterEvent, AdapterTx};
use crate::{AdapterEvent, AdapterTx, ObservedSession, SessionContext};

#[derive(Clone, PartialEq, Eq)]
struct Fingerprint {
Expand Down Expand Up @@ -55,7 +55,8 @@ pub fn spawn_claude_adapter(tx: AdapterTx) {
}
return;
}
if let Some(session) = parse_claude_session(&path) {
if let Some(observed) = parse_claude_session(&path) {
let session = &observed.session;
path_ids_cb
.lock()
.unwrap()
Expand All @@ -72,13 +73,13 @@ pub fn spawn_claude_adapter(tx: AdapterTx) {
map.insert(session.id.clone(), fp);
drop(map);
debug!(id = %session.id, ?session.state, title = %session.title, "claude session");
let _ = tx.send(AdapterEvent::Upsert(session));
let _ = tx.send(AdapterEvent::Upsert(observed));
}
});
}
}

fn parse_claude_session(path: &std::path::Path) -> Option<SessionStatus> {
fn parse_claude_session(path: &std::path::Path) -> Option<ObservedSession> {
let text = std::fs::read_to_string(path).ok()?;
let mut id: Option<String> = None;
let mut title: Option<String> = None;
Expand Down Expand Up @@ -154,12 +155,18 @@ fn parse_claude_session(path: &std::path::Path) -> Option<SessionStatus> {

let updated_at_ms = file_mtime_ms(path).unwrap_or_else(now_ms);

Some(SessionStatus {
id: format!("claude:{id_raw}"),
app: claude_app_label(entrypoint.as_deref(), cwd.as_deref()),
title,
state,
updated_at_ms,
Some(ObservedSession {
session: SessionStatus {
id: format!("claude:{id_raw}"),
app: claude_app_label(entrypoint.as_deref(), cwd.as_deref()),
title,
state,
updated_at_ms,
},
context: cwd.map(|cwd| SessionContext {
runtime: "claude".into(),
cwd,
}),
})
}

Expand Down Expand Up @@ -256,7 +263,7 @@ mod tests {
r#"{{"type":"user","sessionId":"s1","message":{{"role":"user","content":"Wire the menu bar tray icon"}}}}"#
)
.unwrap();
let session = parse_claude_session(&path).unwrap();
let session = parse_claude_session(&path).unwrap().session;
assert_eq!(session.id, "claude:s1");
assert_eq!(session.title, "Wire the menu bar tray icon");
// No entrypoint in the journal → historical default.
Expand Down
33 changes: 20 additions & 13 deletions crates/mb-adapters/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use tracing::debug;
use crate::hosts::host_from_cwd;
use crate::title::{clean_title, cwd_basename, looks_like_boilerplate};
use crate::watch::{path_components_contain, watch_dir};
use crate::{AdapterEvent, AdapterTx};
use crate::{AdapterEvent, AdapterTx, ObservedSession, SessionContext};

#[derive(Clone, PartialEq, Eq)]
struct Fingerprint {
Expand Down Expand Up @@ -47,7 +47,8 @@ pub fn spawn_codex_adapter(tx: AdapterTx) {
}
return;
}
if let Some(session) = parse_codex_session(&path) {
if let Some(observed) = parse_codex_session(&path) {
let session = &observed.session;
path_ids_cb
.lock()
.unwrap()
Expand All @@ -64,12 +65,12 @@ pub fn spawn_codex_adapter(tx: AdapterTx) {
map.insert(session.id.clone(), fp);
drop(map);
debug!(id = %session.id, ?session.state, title = %session.title, "codex session");
let _ = tx.send(AdapterEvent::Upsert(session));
let _ = tx.send(AdapterEvent::Upsert(observed));
}
});
}

fn parse_codex_session(path: &std::path::Path) -> Option<SessionStatus> {
fn parse_codex_session(path: &std::path::Path) -> Option<ObservedSession> {
let text = std::fs::read_to_string(path).ok()?;
let mut id: Option<String> = None;
let mut cwd: Option<String> = None;
Expand Down Expand Up @@ -160,12 +161,18 @@ fn parse_codex_session(path: &std::path::Path) -> Option<SessionStatus> {

let updated_at_ms = file_mtime_ms(path).unwrap_or_else(now_ms);

Some(SessionStatus {
id: format!("codex:{id_raw}"),
app: codex_app_label(originator.as_deref(), cwd.as_deref()),
title,
state,
updated_at_ms,
Some(ObservedSession {
session: SessionStatus {
id: format!("codex:{id_raw}"),
app: codex_app_label(originator.as_deref(), cwd.as_deref()),
title,
state,
updated_at_ms,
},
context: cwd.map(|cwd| SessionContext {
runtime: "codex".into(),
cwd,
}),
})
}

Expand Down Expand Up @@ -262,7 +269,7 @@ mod tests {
r#"{{"type":"event_msg","payload":{{"type":"task_started","turn_id":"t1"}}}}"#
)
.unwrap();
let session = parse_codex_session(&path).unwrap();
let session = parse_codex_session(&path).unwrap().session;
assert_eq!(session.id, "codex:abc-123");
assert_eq!(session.title, "Build the AIhero menu bar pet");
assert_eq!(session.state, AgentState::Working);
Expand All @@ -279,7 +286,7 @@ mod tests {
r#"{{"type":"session_meta","payload":{{"id":"t3-1","cwd":"/Users/me/dev/repo","originator":"t3code_desktop"}}}}"#
)
.unwrap();
let session = parse_codex_session(&path).unwrap();
let session = parse_codex_session(&path).unwrap().session;
assert_eq!(session.id, "codex:t3-1");
assert_eq!(session.app, "T3 Code");
}
Expand Down Expand Up @@ -358,7 +365,7 @@ mod tests {
r#"{{"type":"session_meta","payload":{{"id":"top-1","cwd":"/Users/me/dev/repo","originator":"synara_desktop","source":"vscode"}}}}"#
)
.unwrap();
let session = parse_codex_session(&path).unwrap();
let session = parse_codex_session(&path).unwrap().session;
assert_eq!(session.id, "codex:top-1");
assert_eq!(session.app, "Synara");
}
Expand Down
Loading
Loading