From 2a64faf4cfee168baba502d9fae6d184e3ebace0 Mon Sep 17 00:00:00 2001 From: Jonathan Borgwing Date: Mon, 20 Jul 2026 17:08:18 -0400 Subject: [PATCH] feat: add native CNVS integration --- Cargo.lock | 1 + INSTALL.md | 13 + README.md | 13 +- adapters/README.md | 23 +- adapters/cnvs/README.md | 40 ++ apps/microbridge-ui/src/lib/bus.ts | 17 + apps/microbridge-ui/src/surfaces/Settings.tsx | 14 +- .../src/surfaces/surfaces.test.tsx | 23 +- crates/mb-adapters/src/claude.rs | 29 +- crates/mb-adapters/src/codex.rs | 33 +- crates/mb-adapters/src/lib.rs | 17 +- crates/mb-protocol/src/lib.rs | 1 + crates/microbridged/Cargo.toml | 1 + crates/microbridged/src/app_match.rs | 5 + crates/microbridged/src/cnvs.rs | 632 ++++++++++++++++++ crates/microbridged/src/lib.rs | 1 + crates/microbridged/src/main.rs | 12 +- crates/microbridged/src/state.rs | 219 +++++- docs/adapters.md | 10 +- docs/architecture.md | 13 +- 20 files changed, 1062 insertions(+), 55 deletions(-) create mode 100644 adapters/cnvs/README.md create mode 100644 crates/microbridged/src/cnvs.rs diff --git a/Cargo.lock b/Cargo.lock index 8538a3d..fc79eb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -672,6 +672,7 @@ dependencies = [ "objc2", "objc2-app-kit", "objc2-foundation", + "percent-encoding", "reqwest", "serde", "serde_json", diff --git a/INSTALL.md b/INSTALL.md index 98862a9..628ea16 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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**. diff --git a/README.md b/README.md index d3d7668..9b10643 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, 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 @@ -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. @@ -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 ``` diff --git a/adapters/README.md b/adapters/README.md index b8c2848..05c4ba4 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -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. diff --git a/adapters/cnvs/README.md b/adapters/cnvs/README.md new file mode 100644 index 0000000..d69af26 --- /dev/null +++ b/adapters/cnvs/README.md @@ -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::`. 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. diff --git a/apps/microbridge-ui/src/lib/bus.ts b/apps/microbridge-ui/src/lib/bus.ts index 761b684..cafa6dc 100644 --- a/apps/microbridge-ui/src/lib/bus.ts +++ b/apps/microbridge-ui/src/lib/bus.ts @@ -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 }, @@ -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", diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx index 6d4b2ba..88f7a8f 100644 --- a/apps/microbridge-ui/src/surfaces/Settings.tsx +++ b/apps/microbridge-ui/src/surfaces/Settings.tsx @@ -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", @@ -580,13 +580,15 @@ export function Settings({

Adapters

- 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.

- 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. diff --git a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx index dd6dc3f..f329694 100644 --- a/apps/microbridge-ui/src/surfaces/surfaces.test.tsx +++ b/apps/microbridge-ui/src/surfaces/surfaces.test.tsx @@ -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 }, @@ -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", @@ -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"); diff --git a/crates/mb-adapters/src/claude.rs b/crates/mb-adapters/src/claude.rs index b000eb6..23779db 100644 --- a/crates/mb-adapters/src/claude.rs +++ b/crates/mb-adapters/src/claude.rs @@ -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 { @@ -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() @@ -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 { +fn parse_claude_session(path: &std::path::Path) -> Option { let text = std::fs::read_to_string(path).ok()?; let mut id: Option = None; let mut title: Option = None; @@ -154,12 +155,18 @@ fn parse_claude_session(path: &std::path::Path) -> Option { 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, + }), }) } @@ -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. diff --git a/crates/mb-adapters/src/codex.rs b/crates/mb-adapters/src/codex.rs index 85633c0..ae5daab 100644 --- a/crates/mb-adapters/src/codex.rs +++ b/crates/mb-adapters/src/codex.rs @@ -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 { @@ -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() @@ -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 { +fn parse_codex_session(path: &std::path::Path) -> Option { let text = std::fs::read_to_string(path).ok()?; let mut id: Option = None; let mut cwd: Option = None; @@ -160,12 +161,18 @@ fn parse_codex_session(path: &std::path::Path) -> Option { 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, + }), }) } @@ -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); @@ -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"); } @@ -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"); } diff --git a/crates/mb-adapters/src/lib.rs b/crates/mb-adapters/src/lib.rs index 3133e6d..cf0c214 100644 --- a/crates/mb-adapters/src/lib.rs +++ b/crates/mb-adapters/src/lib.rs @@ -18,8 +18,23 @@ pub use codex::spawn_codex_adapter; /// Events emitted by in-process adapters toward the daemon bus. #[derive(Debug, Clone)] pub enum AdapterEvent { - Upsert(SessionStatus), + Upsert(ObservedSession), Remove(String), } +/// Runtime identity used to reconcile a hosted terminal with the underlying +/// journal watcher. The host owns display/focus while it is present; the raw +/// session becomes visible again when the host claim disappears. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SessionContext { + pub runtime: String, + pub cwd: String, +} + +#[derive(Debug, Clone)] +pub struct ObservedSession { + pub session: SessionStatus, + pub context: Option, +} + pub type AdapterTx = mpsc::UnboundedSender; diff --git a/crates/mb-protocol/src/lib.rs b/crates/mb-protocol/src/lib.rs index 8127e8b..99b561e 100644 --- a/crates/mb-protocol/src/lib.rs +++ b/crates/mb-protocol/src/lib.rs @@ -286,6 +286,7 @@ pub fn default_adapter_preferences() -> BTreeMap { BTreeMap::from([ ("codex".into(), AdapterPreference { enabled: true }), ("claude".into(), AdapterPreference { enabled: true }), + ("cnvs".into(), AdapterPreference { enabled: true }), ("cursor".into(), AdapterPreference { enabled: false }), ("t3code".into(), AdapterPreference { enabled: false }), ("factory".into(), AdapterPreference { enabled: false }), diff --git a/crates/microbridged/Cargo.toml b/crates/microbridged/Cargo.toml index 0e52f91..75259ab 100644 --- a/crates/microbridged/Cargo.toml +++ b/crates/microbridged/Cargo.toml @@ -27,6 +27,7 @@ tracing-subscriber = { workspace = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } url = "2" time = { version = "0.3", features = ["formatting", "parsing"] } +percent-encoding = "2" [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.6" diff --git a/crates/microbridged/src/app_match.rs b/crates/microbridged/src/app_match.rs index d5e4f76..3593b66 100644 --- a/crates/microbridged/src/app_match.rs +++ b/crates/microbridged/src/app_match.rs @@ -36,6 +36,9 @@ pub fn app_family(name: &str) -> String { if lower == "synara" || lower.starts_with("synara ") { return "synara".into(); } + if lower == "cnvs" || lower.starts_with("cnvs ") { + return "cnvs".into(); + } if is_codex(&lower) { return "codex".into(); } @@ -56,6 +59,7 @@ fn canonical_family(name: &str) -> Option<&'static str> { "Synara" => Some("synara"), "Conductor" => Some("conductor"), "Factory" => Some("factory"), + "CNVS" => Some("cnvs"), "Codex CLI" => Some("codex"), "Claude Code" => Some("claude_code"), "Claude Desktop" => Some("claude_desktop"), @@ -114,6 +118,7 @@ mod tests { assert!(!same_app("Cursor", "T3 Code")); assert!(same_app("Conductor", "Conductor")); assert!(same_app("Factory", "Factory")); + assert!(same_app("CNVS", "CNVS")); } #[test] diff --git a/crates/microbridged/src/cnvs.rs b/crates/microbridged/src/cnvs.rs new file mode 100644 index 0000000..3078328 --- /dev/null +++ b/crates/microbridged/src/cnvs.rs @@ -0,0 +1,632 @@ +//! Native CNVS integration through the authenticated loopback control API. +//! +//! CNVS owns the canvas and terminal identity, so Microbridge treats +//! `canvas id + node id` as the stable session target. The token is read from +//! CNVS's endpoint descriptor for each scan/action and is never persisted. + +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use mb_adapters::SessionContext; +use mb_protocol::{ + Action, AdapterCapabilities, AdapterConnectionState, AgentState, ServerMessage, SessionStatus, +}; +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS}; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use tokio::sync::{mpsc, Mutex}; +use tracing::{debug, warn}; + +use crate::state::DaemonState; + +pub const CNVS_OWNER: u64 = u64::MAX - 3; +const ACTIVE_REFRESH: Duration = Duration::from_secs(2); +const IDLE_REFRESH: Duration = Duration::from_secs(10); +const OFFLINE_REFRESH: Duration = Duration::from_secs(5); +const SESSION_ID_ENCODE_SET: &AsciiSet = &CONTROLS.add(b'%').add(b':'); + +pub fn capabilities() -> AdapterCapabilities { + AdapterCapabilities { + lifecycle_observation: true, + interrupt: true, + focus_open: true, + ..AdapterCapabilities::default() + } +} + +pub fn spawn( + shared: Arc>, + mut action_rx: mpsc::UnboundedReceiver, +) { + tokio::spawn(async move { + let client = match Client::builder() + .connect_timeout(Duration::from_secs(1)) + .timeout(Duration::from_secs(3)) + .build() + { + Ok(client) => client, + Err(error) => { + warn!(%error, "CNVS HTTP client could not start"); + return; + } + }; + let mut refresh_after = Duration::ZERO; + + loop { + tokio::select! { + action = action_rx.recv() => { + let Some(ServerMessage::Action { session_id, action }) = action else { + return; + }; + if let Err(error) = perform_action(&client, &session_id, action).await { + warn!(%error, ?action, session_id, "CNVS action failed"); + } + } + _ = tokio::time::sleep(refresh_after) => { + let enabled = shared.lock().await.adapter_enabled("cnvs"); + if !enabled { + shared.lock().await.replace_hosted_sessions(CNVS_OWNER, Vec::new()); + refresh_after = OFFLINE_REFRESH; + continue; + } + + match discover(&client).await { + Ok(discovery) => { + let count = discovery.sessions.len(); + let active = discovery.active; + let failures = discovery.failed_canvases; + let mut state = shared.lock().await; + state.replace_hosted_sessions(CNVS_OWNER, discovery.sessions); + state.set_adapter_runtime( + "cnvs", + if failures == 0 { + AdapterConnectionState::Connected + } else { + AdapterConnectionState::Limited + }, + capabilities(), + cnvs_diagnostic(count, failures), + ); + refresh_after = if active { ACTIVE_REFRESH } else { IDLE_REFRESH }; + } + Err(error) => { + debug!(%error, "CNVS control unavailable"); + let mut state = shared.lock().await; + state.replace_hosted_sessions(CNVS_OWNER, Vec::new()); + state.set_adapter_runtime( + "cnvs", + AdapterConnectionState::NeedsSetup, + capabilities(), + "CNVS is enabled and will connect automatically when CNVS is running.", + ); + refresh_after = OFFLINE_REFRESH; + } + } + } + } + } + }); +} + +struct Discovery { + sessions: Vec<(SessionStatus, SessionContext)>, + active: bool, + failed_canvases: usize, +} + +async fn discover(client: &Client) -> Result { + let endpoint = Endpoint::load()?; + let root = get_state(client, &endpoint, None).await?; + let mut sessions = Vec::new(); + let mut active = false; + let mut failed_canvases = 0; + + for canvas in root.state.canvases { + let snapshot = match get_state(client, &endpoint, Some(&canvas.id)).await { + Ok(snapshot) => snapshot, + Err(error) => { + failed_canvases += 1; + debug!(canvas_id = canvas.id, %error, "CNVS canvas state unavailable"); + continue; + } + }; + for node in snapshot.state.nodes { + let Some(hosted) = hosted_session(&canvas, node) else { + continue; + }; + active |= matches!( + hosted.0.state, + AgentState::Thinking | AgentState::Working | AgentState::AwaitingApproval + ); + sessions.push(hosted); + } + } + + Ok(Discovery { + sessions, + active, + failed_canvases, + }) +} + +fn hosted_session(canvas: &CanvasSummary, node: Node) -> Option<(SessionStatus, SessionContext)> { + if node.kind != "terminal" { + return None; + } + let runtime = node.agent_id?.trim().to_ascii_lowercase(); + if runtime.is_empty() { + return None; + } + let cwd = node + .cwd + .filter(|cwd| !cwd.trim().is_empty()) + .unwrap_or_else(|| canvas.project_directory.clone()); + if cwd.trim().is_empty() { + return None; + } + let state = map_status(&node.status); + let agent = display_agent(&runtime); + let terminal = nonempty(&node.title).unwrap_or(agent); + let title = truncate_title(&format!("{} · {terminal} · {agent}", canvas.name), 72); + + Some(( + SessionStatus { + id: format!( + "cnvs:{}:{}", + encode_id_component(&canvas.id), + encode_id_component(&node.id) + ), + app: "CNVS".into(), + title, + state, + updated_at_ms: now_ms(), + }, + SessionContext { runtime, cwd }, + )) +} + +async fn perform_action(client: &Client, session_id: &str, action: Action) -> Result<(), String> { + let (canvas_id, node_id) = parse_session_id(session_id)?; + let action_name = match action { + Action::OpenFocusedThread => "focus", + Action::Interrupt => "stop_agent", + _ => return Err(format!("CNVS does not advertise {action:?}.")), + }; + let endpoint = Endpoint::load()?; + let snapshot = get_state(client, &endpoint, Some(&canvas_id)).await?; + let target = exact_node_target(&snapshot.state, &node_id)?; + let response = client + .post(endpoint.url("/action")) + .header("X-CNVS-Token", &endpoint.token) + .header("X-CNVS-Canvas-ID", &canvas_id) + .json(&ActionRequest { + action: action_name, + target: &target, + }) + .send() + .await + .map_err(|error| format!("could not reach CNVS control: {error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("CNVS rejected the action ({status}).")); + } + let response = response + .json::() + .await + .map_err(|error| format!("CNVS returned an invalid action response: {error}"))?; + if response.ok { + if response + .outcomes + .iter() + .filter_map(|outcome| outcome.node_id.as_deref()) + .any(|returned| returned != node_id) + { + return Err("CNVS focused a different terminal than Microbridge requested.".into()); + } + if action == Action::OpenFocusedThread { + if let Err(error) = activate_cnvs() { + warn!(%error, "CNVS focus succeeded but the app could not be activated"); + } + } + Ok(()) + } else { + Err(response + .status + .unwrap_or_else(|| "CNVS did not accept the action.".into())) + } +} + +#[cfg(target_os = "macos")] +fn activate_cnvs() -> Result<(), String> { + let status = std::process::Command::new("open") + .args(["-a", "CNVS"]) + .status() + .map_err(|error| format!("could not activate CNVS: {error}"))?; + status + .success() + .then_some(()) + .ok_or_else(|| "macOS could not activate CNVS.".into()) +} + +#[cfg(not(target_os = "macos"))] +fn activate_cnvs() -> Result<(), String> { + Err("CNVS focus is currently available on macOS only.".into()) +} + +async fn get_state( + client: &Client, + endpoint: &Endpoint, + canvas_id: Option<&str>, +) -> Result { + let mut request = client + .get(endpoint.url("/state")) + .header("X-CNVS-Token", &endpoint.token); + if let Some(canvas_id) = canvas_id { + request = request.header("X-CNVS-Canvas-ID", canvas_id); + } + let response = request + .send() + .await + .map_err(|error| format!("could not reach CNVS control: {error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("CNVS state request failed ({status}).")); + } + response + .json::() + .await + .map_err(|error| format!("CNVS returned an invalid state response: {error}")) +} + +#[derive(Debug, Deserialize)] +struct Endpoint { + host: String, + port: u16, + token: String, +} + +impl Endpoint { + fn load() -> Result { + let path = endpoint_path(); + let bytes = std::fs::read(&path) + .map_err(|_| format!("CNVS endpoint is not available at {}.", path.display()))?; + let endpoint: Self = serde_json::from_slice(&bytes) + .map_err(|error| format!("CNVS endpoint descriptor is invalid: {error}"))?; + endpoint.validate()?; + Ok(endpoint) + } + + fn validate(&self) -> Result<(), String> { + let loopback = self.host == "localhost" + || self + .host + .parse::() + .is_ok_and(|address| address.is_loopback()); + if !loopback { + return Err("CNVS control refused a non-loopback endpoint.".into()); + } + if self.port == 0 || self.token.trim().is_empty() { + return Err("CNVS endpoint descriptor is incomplete.".into()); + } + Ok(()) + } + + fn url(&self, path: &str) -> String { + let host = if self.host.contains(':') { + format!("[{}]", self.host) + } else { + self.host.clone() + }; + format!("http://{host}:{}{path}", self.port) + } +} + +fn endpoint_path() -> PathBuf { + if let Ok(path) = std::env::var("CNVS_CONTROL_ENDPOINT_PATH") { + return PathBuf::from(path); + } + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("CNVS") + .join("control-endpoint.json") +} + +#[derive(Debug, Deserialize)] +struct StateEnvelope { + #[serde(default)] + state: CnvsState, +} + +#[derive(Debug, Default, Deserialize)] +struct CnvsState { + #[serde(default)] + canvases: Vec, + #[serde(default)] + nodes: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CanvasSummary { + id: String, + name: String, + #[serde(default)] + project_directory: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Node { + id: String, + kind: String, + #[serde(default)] + title: String, + #[serde(default)] + status: String, + #[serde(default, rename = "agentID")] + agent_id: Option, + #[serde(default)] + cwd: Option, +} + +#[derive(Serialize)] +struct ActionRequest<'a> { + action: &'a str, + target: &'a str, +} + +#[derive(Deserialize)] +struct ControlResponse { + #[serde(default)] + ok: bool, + #[serde(default)] + status: Option, + #[serde(default)] + outcomes: Vec, +} + +#[derive(Deserialize)] +struct ControlOutcome { + #[serde(default, rename = "nodeID")] + node_id: Option, +} + +fn parse_session_id(session_id: &str) -> Result<(String, String), String> { + let (canvas, node) = session_id + .strip_prefix("cnvs:") + .and_then(|target| target.split_once(':')) + .filter(|(canvas, node)| !canvas.is_empty() && !node.is_empty()) + .ok_or_else(|| "CNVS session target is invalid.".to_string())?; + if node.contains(':') { + return Err("CNVS session target is ambiguous; encode component delimiters.".into()); + } + Ok((decode_id_component(canvas)?, decode_id_component(node)?)) +} + +fn encode_id_component(value: &str) -> String { + utf8_percent_encode(value, SESSION_ID_ENCODE_SET).to_string() +} + +fn decode_id_component(value: &str) -> Result { + percent_decode_str(value) + .decode_utf8() + .map(String::from) + .map_err(|_| "CNVS session target contains invalid UTF-8.".into()) +} + +fn cnvs_diagnostic(terminals: usize, failed_canvases: usize) -> String { + let mut diagnostic = format!( + "CNVS control is connected across {} canvas terminal{}.", + terminals, + if terminals == 1 { "" } else { "s" } + ); + if failed_canvases > 0 { + diagnostic.push_str(&format!( + " {} canvas{} could not be refreshed.", + failed_canvases, + if failed_canvases == 1 { "" } else { "es" } + )); + } + diagnostic +} + +fn exact_node_target(state: &CnvsState, node_id: &str) -> Result { + let node = state + .nodes + .iter() + .find(|node| node.id == node_id) + .ok_or_else(|| "The CNVS terminal no longer exists.".to_string())?; + let title = nonempty(&node.title) + .ok_or_else(|| "The CNVS terminal has no supported focus target.".to_string())?; + let matches = state + .nodes + .iter() + .filter(|candidate| candidate.title.trim() == title) + .count(); + if matches != 1 { + return Err("The CNVS terminal name is ambiguous within its canvas.".into()); + } + Ok(title.to_string()) +} + +fn map_status(status: &str) -> AgentState { + match status.trim().to_ascii_lowercase().as_str() { + "thinking" => AgentState::Thinking, + "working" | "running" => AgentState::Working, + "waiting" | "awaiting_approval" | "needs_input" => AgentState::AwaitingApproval, + "done" | "completed" => AgentState::Done, + "error" | "failed" => AgentState::Error, + _ => AgentState::Idle, + } +} + +fn display_agent(runtime: &str) -> &str { + match runtime { + "codex" => "Codex", + "claude" => "Claude", + "cursor" => "Cursor", + "droid" | "factory" => "Factory", + "opencode" => "OpenCode", + _ => runtime, + } +} + +fn nonempty(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +fn truncate_title(value: &str, max_chars: usize) -> String { + let mut chars = value.chars(); + let prefix = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{}…", prefix.trim_end()) + } else { + prefix + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_stable_hosted_session_identity() { + let canvas = CanvasSummary { + id: "canvas-1".into(), + name: "Payments".into(), + project_directory: "/Users/me/dev/payments".into(), + }; + let node = Node { + id: "node-9".into(), + kind: "terminal".into(), + title: "Checkout repair".into(), + status: "working".into(), + agent_id: Some("codex".into()), + cwd: Some("/Users/me/dev/payments/api".into()), + }; + let (session, context) = hosted_session(&canvas, node).unwrap(); + assert_eq!(session.id, "cnvs:canvas-1:node-9"); + assert_eq!(session.app, "CNVS"); + assert_eq!(session.state, AgentState::Working); + assert!(session.title.contains("Payments")); + assert_eq!(context.runtime, "codex"); + assert_eq!(context.cwd, "/Users/me/dev/payments/api"); + } + + #[test] + fn decodes_cnvs_agent_id_spelling() { + let node: Node = serde_json::from_str( + r#"{"id":"node-1","kind":"terminal","title":"Odin","status":"working","agentID":"codex","cwd":"/tmp/project"}"#, + ) + .unwrap(); + assert_eq!(node.agent_id.as_deref(), Some("codex")); + } + + #[test] + fn ignores_non_agent_terminals_and_maps_waiting() { + let canvas = CanvasSummary { + id: "c".into(), + name: "Canvas".into(), + project_directory: "/tmp/project".into(), + }; + let shell = Node { + id: "shell".into(), + kind: "terminal".into(), + title: "Shell".into(), + status: "idle".into(), + agent_id: None, + cwd: None, + }; + assert!(hosted_session(&canvas, shell).is_none()); + assert_eq!(map_status("waiting"), AgentState::AwaitingApproval); + } + + #[test] + fn parses_exact_canvas_and_node_target() { + assert_eq!( + parse_session_id("cnvs:canvas-id:node-id").unwrap(), + ("canvas-id".to_string(), "node-id".to_string()) + ); + assert!(parse_session_id("codex:thread").is_err()); + } + + #[test] + fn session_identity_round_trips_colons_without_changing_uuid_ids() { + assert_eq!(encode_id_component("plain-uuid"), "plain-uuid"); + let id = format!( + "cnvs:{}:{}", + encode_id_component("remote:canvas"), + encode_id_component("node:one") + ); + assert_eq!( + parse_session_id(&id).unwrap(), + ("remote:canvas".to_string(), "node:one".to_string()) + ); + assert!(parse_session_id("cnvs:raw:canvas:node").is_err()); + } + + #[test] + fn resolves_node_id_to_an_unambiguous_supported_target() { + let state = CnvsState { + nodes: vec![ + Node { + id: "node-1".into(), + kind: "terminal".into(), + title: "Odin".into(), + status: "idle".into(), + agent_id: Some("codex".into()), + cwd: Some("/tmp/project".into()), + }, + Node { + id: "node-2".into(), + kind: "terminal".into(), + title: "Thor".into(), + status: "idle".into(), + agent_id: Some("claude".into()), + cwd: Some("/tmp/project".into()), + }, + ], + ..CnvsState::default() + }; + assert_eq!(exact_node_target(&state, "node-1").unwrap(), "Odin"); + } + + #[test] + fn refuses_an_ambiguous_cnvs_target() { + let node = |id: &str| Node { + id: id.into(), + kind: "terminal".into(), + title: "Odin".into(), + status: "idle".into(), + agent_id: Some("codex".into()), + cwd: Some("/tmp/project".into()), + }; + let state = CnvsState { + nodes: vec![node("node-1"), node("node-2")], + ..CnvsState::default() + }; + assert!(exact_node_target(&state, "node-1").is_err()); + } + + #[test] + fn refuses_remote_control_endpoints() { + let endpoint = Endpoint { + host: "example.com".into(), + port: 1234, + token: "secret".into(), + }; + assert!(endpoint.validate().is_err()); + } +} diff --git a/crates/microbridged/src/lib.rs b/crates/microbridged/src/lib.rs index 9c42ba5..86f47ca 100644 --- a/crates/microbridged/src/lib.rs +++ b/crates/microbridged/src/lib.rs @@ -1,6 +1,7 @@ //! microbridged library — status bus, focus policy, key source, socket server. pub mod app_match; +pub mod cnvs; pub mod config; pub mod factory; pub mod frontmost; diff --git a/crates/microbridged/src/main.rs b/crates/microbridged/src/main.rs index 7166d93..3641287 100644 --- a/crates/microbridged/src/main.rs +++ b/crates/microbridged/src/main.rs @@ -8,6 +8,7 @@ use std::sync::Arc; use mb_adapters::{spawn_claude_adapter, spawn_codex_adapter, AdapterEvent}; use mb_device::open_default_device_with_claim; +use microbridged::cnvs::{self, CNVS_OWNER}; use microbridged::config::load_config; use microbridged::factory::{self, FACTORY_OWNER}; use microbridged::frontmost::spawn_frontmost_watcher; @@ -36,6 +37,7 @@ async fn main() -> std::io::Result<()> { let (t3_action_tx, t3_action_rx) = mpsc::unbounded_channel(); let (factory_action_tx, factory_action_rx) = mpsc::unbounded_channel(); + let (cnvs_action_tx, cnvs_action_rx) = mpsc::unbounded_channel(); let mut daemon_state = DaemonState::new(device, config); daemon_state.install_internal_adapter(T3_OWNER, "t3code", t3code::capabilities(), t3_action_tx); daemon_state.install_internal_adapter( @@ -44,9 +46,11 @@ async fn main() -> std::io::Result<()> { factory::capabilities(), factory_action_tx, ); + daemon_state.install_internal_adapter(CNVS_OWNER, "cnvs", cnvs::capabilities(), cnvs_action_tx); let shared = Arc::new(Mutex::new(daemon_state)); t3code::spawn(Arc::clone(&shared), t3_action_rx); factory::spawn(Arc::clone(&shared), factory_action_rx); + cnvs::spawn(Arc::clone(&shared), cnvs_action_rx); // Hardware notifications are non-blocking. This small bounded drain also // expires lease-backed IDE hook sessions without introducing network polling. @@ -71,13 +75,13 @@ async fn main() -> std::io::Result<()> { let mut state = bus.lock().await; match event { // conn_id 0 = in-process owner - AdapterEvent::Upsert(session) => { - let adapter_id = session.id.split(':').next().unwrap_or_default(); + AdapterEvent::Upsert(observed) => { + let adapter_id = observed.session.id.split(':').next().unwrap_or_default(); if state.adapter_enabled(adapter_id) { - state.upsert_session(session, 0); + state.upsert_observed_session(observed, 0); } } - AdapterEvent::Remove(id) => state.remove_session(&id), + AdapterEvent::Remove(id) => state.remove_observed_session(&id), } } }); diff --git a/crates/microbridged/src/state.rs b/crates/microbridged/src/state.rs index 9c22c34..fb05c34 100644 --- a/crates/microbridged/src/state.rs +++ b/crates/microbridged/src/state.rs @@ -1,10 +1,11 @@ //! Shared daemon state: registry, config, device, subscribers. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use mb_adapters::{ObservedSession, SessionContext}; use mb_device::{parse_rgb_hex, Device, LedFrame}; use mb_protocol::{ Action, AdapterCapabilities, AdapterConnectionState, AdapterKind, AdapterStatus, AgentKeyLed, @@ -40,6 +41,11 @@ pub struct DaemonState { pub adapters: BTreeMap, /// One-shot hook sessions survive their short socket connection until this deadline. leased_sessions: HashMap, + /// Journal observations remain cached while a native host such as CNVS + /// claims the same runtime + working directory. This makes the host card + /// authoritative without losing the raw session when the host closes. + observed_sessions: HashMap, + hosted_claims: HashMap>, last_agent_key_press: [Option; AGENT_KEY_COUNT], last_leds: LedFrame, } @@ -58,6 +64,8 @@ impl DaemonState { adapter_capabilities: HashMap::new(), adapters, leased_sessions: HashMap::new(), + observed_sessions: HashMap::new(), + hosted_claims: HashMap::new(), last_agent_key_press: [None; AGENT_KEY_COUNT], last_leds: LedFrame::default(), } @@ -101,6 +109,119 @@ impl DaemonState { self.after_bus_change(prev_focus); } + pub fn upsert_observed_session(&mut self, mut observed: ObservedSession, owner: u64) { + if let Some(context) = observed.context.as_mut() { + context.cwd = normalize_cwd(&context.cwd); + } + let id = observed.session.id.clone(); + self.observed_sessions.insert(id.clone(), observed.clone()); + if self.observation_is_hosted(&observed) { + if self.registry.sessions.contains_key(&id) { + self.remove_session(&id); + } + } else if self.registry.sessions.get(&id) != Some(&observed.session) + || self.registry.owner_of(&id) != Some(owner) + { + self.upsert_session(observed.session, owner); + } + } + + pub fn remove_observed_session(&mut self, id: &str) { + self.observed_sessions.remove(id); + self.remove_session(id); + } + + /// Replace one native host's full terminal snapshot atomically. CNVS node + /// ids are the user-facing sessions; matching raw Codex/Claude journals + /// stay cached and return if the host terminal disappears. + pub fn replace_hosted_sessions( + &mut self, + owner: u64, + sessions: Vec<(SessionStatus, SessionContext)>, + ) { + let claims = sessions + .iter() + .map(|(_, context)| SessionContext { + runtime: context.runtime.clone(), + cwd: normalize_cwd(&context.cwd), + }) + .collect::>(); + self.hosted_claims.insert(owner, claims); + + let next_ids = sessions + .iter() + .map(|(session, _)| session.id.clone()) + .collect::>(); + let removed = self + .registry + .owners + .iter() + .filter_map(|(id, session_owner)| { + (*session_owner == owner && !next_ids.contains(id)).then_some(id.clone()) + }) + .collect::>(); + for id in removed { + self.remove_session(&id); + } + + for (mut session, _) in sessions { + if let Some(existing) = self.registry.sessions.get(&session.id) { + if existing.app == session.app + && existing.title == session.title + && existing.state == session.state + { + session.updated_at_ms = existing.updated_at_ms; + } + } + if self.registry.sessions.get(&session.id) != Some(&session) + || self.registry.owner_of(&session.id) != Some(owner) + { + self.upsert_session(session, owner); + } + } + self.reconcile_observed_sessions(); + } + + fn observation_is_hosted(&self, observed: &ObservedSession) -> bool { + let Some(context) = observed.context.as_ref() else { + return false; + }; + if !matches!(context.runtime.as_str(), "codex" | "claude") { + return false; + } + // A stable host attribution from another app must never be hidden just + // because that app happens to use the same runtime and working tree. + if !matches!( + observed.session.app.as_str(), + "Codex CLI" | "Claude Code" | "Claude Agent SDK" | "CNVS" + ) { + return false; + } + let normalized = SessionContext { + runtime: context.runtime.clone(), + cwd: normalize_cwd(&context.cwd), + }; + self.hosted_claims + .values() + .any(|claims| claims.contains(&normalized)) + } + + fn reconcile_observed_sessions(&mut self) { + let observations = self.observed_sessions.values().cloned().collect::>(); + for observed in observations { + let id = observed.session.id.clone(); + if self.observation_is_hosted(&observed) { + if self.registry.sessions.contains_key(&id) { + self.remove_session(&id); + } + } else if self.registry.sessions.get(&id) != Some(&observed.session) + || self.registry.owner_of(&id) != Some(0) + { + self.upsert_session(observed.session, 0); + } + } + } + pub fn remove_session(&mut self, session_id: &str) { let prev_focus = self.registry.focused.clone(); self.registry.remove(session_id, &self.config); @@ -404,6 +525,8 @@ impl DaemonState { self.registry.remove(&id, &self.config); self.broadcast_ui(BusEvent::SessionRemoved { session_id: id }); } + self.hosted_claims.remove(&owner); + self.reconcile_observed_sessions(); self.after_bus_change(previous); } @@ -663,6 +786,15 @@ fn now_ms() -> u64 { .as_millis() as u64 } +fn normalize_cwd(cwd: &str) -> String { + let trimmed = cwd.trim_end_matches('/'); + if trimmed.is_empty() { + "/".into() + } else { + trimmed.into() + } +} + fn setup_diagnostic(adapter_id: &str) -> String { match adapter_id { "cursor" => { @@ -671,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(), + "cnvs" => { + "Open CNVS; Microbridge connects to its authenticated local control API automatically." + .into() + } _ => "Waiting for the adapter to connect.".into(), } } @@ -680,6 +816,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(), + "cnvs" => "CNVS is enabled and will reconnect automatically when the app is running.".into(), _ => "Adapter is offline.".into(), } } @@ -736,6 +873,7 @@ fn initial_adapter_statuses(config: &DaemonConfig) -> BTreeMap Option { #[cfg(test)] mod tests { use super::*; + use mb_adapters::{ObservedSession, SessionContext}; + + const CNVS_OWNER_FOR_TEST: u64 = u64::MAX - 3; use mb_device::MockDevice; fn session(id: &str, state: AgentState) -> SessionStatus { @@ -780,6 +921,82 @@ mod tests { DaemonState::new(Box::::default(), DaemonConfig::default()) } + #[test] + fn hosted_terminal_replaces_and_then_restores_raw_journal() { + let mut state = state(); + let context = SessionContext { + runtime: "codex".into(), + cwd: "/Users/me/dev/project/".into(), + }; + let raw = SessionStatus { + id: "codex:thread-1".into(), + app: "Codex CLI".into(), + title: "Repair checkout".into(), + state: AgentState::Working, + updated_at_ms: 1, + }; + state.upsert_observed_session( + ObservedSession { + session: raw.clone(), + context: Some(context.clone()), + }, + 0, + ); + assert!(state.registry.sessions.contains_key(&raw.id)); + + let hosted = SessionStatus { + id: "cnvs:canvas-1:node-1".into(), + app: "CNVS".into(), + title: "Project · Repair checkout · Codex".into(), + state: AgentState::Working, + updated_at_ms: 2, + }; + state.replace_hosted_sessions(CNVS_OWNER_FOR_TEST, vec![(hosted.clone(), context)]); + assert!(!state.registry.sessions.contains_key(&raw.id)); + assert!(state.registry.sessions.contains_key(&hosted.id)); + + state.replace_hosted_sessions(CNVS_OWNER_FOR_TEST, Vec::new()); + assert!(state.registry.sessions.contains_key(&raw.id)); + assert!(!state.registry.sessions.contains_key(&hosted.id)); + } + + #[test] + fn hosted_terminal_does_not_hide_a_different_attributed_host() { + let mut state = state(); + let context = SessionContext { + runtime: "codex".into(), + cwd: "/Users/me/dev/project".into(), + }; + let synara = SessionStatus { + id: "codex:synara-thread".into(), + app: "Synara".into(), + title: "Independent Synara thread".into(), + state: AgentState::Working, + updated_at_ms: 1, + }; + state.upsert_observed_session( + ObservedSession { + session: synara.clone(), + context: Some(context.clone()), + }, + 0, + ); + state.replace_hosted_sessions( + CNVS_OWNER_FOR_TEST, + vec![( + SessionStatus { + id: "cnvs:canvas-1:node-1".into(), + app: "CNVS".into(), + title: "Project · Odin · Codex".into(), + state: AgentState::Idle, + updated_at_ms: 2, + }, + context, + )], + ); + assert!(state.registry.sessions.contains_key(&synara.id)); + } + #[test] fn native_adapters_honor_disabled_config_on_restart() { let mut config = DaemonConfig::default(); diff --git a/docs/adapters.md b/docs/adapters.md index 787478d..c033507 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -23,9 +23,9 @@ of dependency-free Node). Prefer, in order: -1. **Official hooks/APIs** — e.g. Cursor/Factory hooks, Factory JSON-RPC, and T3 Code's - authenticated paired HTTP GET/POST orchestration contract. Stable and - supported. +1. **Official hooks/APIs** — e.g. CNVS's authenticated loopback control API, + Cursor/Factory hooks, Factory JSON-RPC, and T3 Code's authenticated paired + HTTP GET/POST orchestration contract. Stable and supported. 2. **Session files** — many runtimes journal to disk (e.g. `~/.codex/sessions`). Watch with FSEvents/inotify, not polling. 3. **Logs** — fragile; document exactly which version you tested. @@ -55,4 +55,6 @@ community adapter that proves stable and broadly used can graduate to in-process. T3 Code is daemon-owned because its paired credential and action routing must share Microbridge's consent boundary. Cursor and Factory remain host-managed so each host owns hook execution and Microbridge owns only the -entries it installs. Synara and Conductor reuse the built-in journal watchers. +entries it installs. CNVS is daemon-owned because its canvas/node identity and +short-lived local token must remain inside the same routing boundary. Synara +and Conductor reuse the built-in journal watchers. diff --git a/docs/architecture.md b/docs/architecture.md index fc79652..7b3efc5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ | Component | Runs as | Language | Required? | |---|---|---|---| | `microbridged` | resident daemon (launchd agent) | Rust | yes | -| First-party watchers (Codex CLI, Claude Code, Synara/Conductor attribution) | in-process modules of the daemon | Rust | bundled | +| First-party integrations (Codex CLI, Claude Code, CNVS, Synara/Conductor attribution) | in-process modules of the daemon | Rust | bundled | | Community adapters | separate processes on the socket | any | optional | | Menu bar app | primary UI (tray + settings + focus HUD) | Tauri 2 + React (`apps/microbridge-ui`) | yes (default install) | @@ -21,9 +21,9 @@ line; regressions are release blockers. | Metric | Budget | How | |---|---|---| -| Idle CPU | near-idle with bounded wakeups | local session and frontmost-app watchers are event-driven; enabled hardware input is drained on a 16 ms tick, and an enabled paired T3 adapter refreshes at 900 ms with exponential backoff. | +| Idle CPU | near-idle with bounded wakeups | local session and frontmost-app watchers are event-driven; enabled hardware input is drained on a 16 ms tick, CNVS refreshes its local snapshot every 2 seconds while agents are active and every 10 seconds while idle, and an enabled paired T3 adapter refreshes at 900 ms with exponential backoff. | | Idle RSS (daemon) | < 15 MB, target single-digit | single static Rust binary, no runtime | -| Network | explicit only | no telemetry; update checks are opt-in, T3 traffic requires an enabled paired environment, and Factory invokes the signed-in Droid CLI only for a requested control | +| Network | explicit only | no telemetry; CNVS uses only its token-authenticated loopback endpoint, update checks are opt-in, T3 traffic requires an enabled paired environment, and Factory invokes the signed-in Droid CLI only for a requested control | | Device traffic | bytes per state *transition* | LED frames written only when resolved state changes; a 32–64 byte HID report each | | Disk | config file + log (rotated) | logs at `info` are transition-only | @@ -33,6 +33,10 @@ The paired T3 adapter uses a bounded refresh with exponential backoff because the supported HTTP contract is snapshot-based. Cursor and Factory use one-shot managed hooks and leave no resident helper process. Factory starts Droid JSON-RPC only for an interrupt or reasoning-effort action and exits afterward. +CNVS exposes snapshot state, so its in-process integration uses an adaptive +local-only refresh and publishes only changed sessions. It rereads CNVS's +endpoint descriptor for each scan or action, never persists its token, and +rejects endpoints that are not loopback addresses. ## Focus model — "one owner, no fighting" @@ -65,7 +69,8 @@ owner and only when that owner advertised the required capability. isolate mutually hostile processes already running as the same user. - Actions route only through advertised host contracts. Most are JSON commands to adapters; Factory actions start the user-installed `droid` CLI in its - documented JSON-RPC mode for that single request. + documented JSON-RPC mode for that single request. CNVS actions use its + authenticated loopback API and include the exact canvas and terminal node. - Hardware access is best-effort reverse engineering of the Micro's HID protocol; the device layer is isolated in `mb-device` so a firmware change cannot ripple past one crate.