diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..de8b98a --- /dev/null +++ b/.codex/environments/environment.toml @@ -0,0 +1,11 @@ +# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY +version = 1 +name = "microbridge" + +[setup] +script = "" + +[[actions]] +name = "Run" +icon = "run" +command = "./script/build_and_run.sh" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e722d7..646bad6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,10 +137,12 @@ jobs: npm ci # Bundle the daemon inside direct-download apps so the device twin # always has a real local source of truth without a separate install. - cargo build --manifest-path ../../Cargo.toml --release -p microbridged --target ${{ matrix.target }} + cargo build --manifest-path ../../Cargo.toml --release -p microbridged -p microbridgectl --target ${{ matrix.target }} mkdir -p src-tauri/binaries cp "../../target/${{ matrix.target }}/release/microbridged" \ "src-tauri/binaries/microbridged-${{ matrix.target }}" + cp "../../target/${{ matrix.target }}/release/microbridgectl" \ + "src-tauri/binaries/microbridgectl-${{ matrix.target }}" # createUpdaterArtifacts (tauri.conf.json) requires the updater signing # key. Forks without the secret still build — just without in-app # updater artifacts (they can use the DMG / Homebrew). diff --git a/Cargo.lock b/Cargo.lock index c66bf92..8538a3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -616,7 +616,7 @@ dependencies = [ [[package]] name = "mb-adapters" -version = "0.2.4" +version = "0.3.0" dependencies = [ "mb-protocol", "notify", @@ -628,7 +628,7 @@ dependencies = [ [[package]] name = "mb-device" -version = "0.2.4" +version = "0.3.0" dependencies = [ "hidapi", "mb-protocol", @@ -639,7 +639,7 @@ dependencies = [ [[package]] name = "mb-protocol" -version = "0.2.4" +version = "0.3.0" dependencies = [ "serde", "serde_json", @@ -653,7 +653,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "microbridgectl" -version = "0.2.4" +version = "0.3.0" dependencies = [ "mb-device", "mb-protocol", @@ -663,7 +663,7 @@ dependencies = [ [[package]] name = "microbridged" -version = "0.2.4" +version = "0.3.0" dependencies = [ "keyring", "mb-adapters", diff --git a/Cargo.toml b/Cargo.toml index d570e3e..2c0282c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.2.4" +version = "0.3.0" edition = "2021" license = "MIT" repository = "https://github.com/DevVig/microbridge" @@ -17,7 +17,7 @@ repository = "https://github.com/DevVig/microbridge" [workspace.dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "fs", "signal"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "fs", "signal", "process"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } toml = "0.8" diff --git a/INSTALL.md b/INSTALL.md index 848f922..98862a9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -117,6 +117,19 @@ Cursor's supported local-plugin directory after that explicit consent. Reload Cursor once if it is already open. **Remove** disables the adapter and removes only Microbridge's local integration. No Marketplace download is required. +### Factory integration + +Factory support ships inside Microbridge. Open **Settings → Adapters** 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. +**Remove** deletes the Microbridge-owned hook entries and helper. Droid must be +installed and signed in for interrupt and reasoning-effort controls. + +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**. + **Note:** Homebrew installs **prebuilt** release binaries (not a from-source Tauri build). The formula checksums are refreshed by CI after each `v*` tag. @@ -132,6 +145,8 @@ Tauri build). The formula checksums are refreshed by CI after each `v*` tag. | `~/.microbridge/config.toml` | Key source, lighting, appearance | | `~/.microbridge/daemon.log` | launchd / service logs | | `~/.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) | | `~/Library/LaunchAgents/ai.microbridge.ui.plist` | Login item (only if you enable launch at login) | ## Launch at login diff --git a/README.md b/README.md index 68d046c..d3d7668 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, 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, 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.2.x`).** Menu bar UI, local daemon, in-process Codex/Claude watchers, and signed macOS packages are shipping. Cursor 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, 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 @@ -41,7 +41,7 @@ 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 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. +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. 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. @@ -50,6 +50,13 @@ once in **Settings → Adapters**; Microbridge installs its bundled lifecycle integration into Cursor's supported local-plugin directory. There is no separate Marketplace download or second product to maintain. +Factory support is also bundled. Enabling it merges Microbridge-owned entries +into Factory's official user hooks without replacing existing hooks; the knob +uses Factory's public JSON-RPC session settings and only cycles levels the +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. + ## Architecture ``` diff --git a/adapters/README.md b/adapters/README.md index 60e5c1c..b8c2848 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -7,8 +7,11 @@ checklist, and [docs/protocol.md](../docs/protocol.md) for the wire format. | Adapter | Status | Language | |---|---|---| | [`reference-echo`](reference-echo/) | working example | Node (no deps) | -| [`cursor`](cursor/) | scaffold — awaiting session source | Node | -| [`t3code`](t3code/) | scaffold — awaiting session source | Node | +| [`cursor`](cursor/) | bundled managed hooks | Node | +| [`t3code`](t3code/) | daemon-owned paired HTTP | Rust | +| [`factory`](factory/) | bundled official hooks + JSON-RPC controls | Rust helper | +| [`synara`](synara/) | built-in host attribution | Rust | +| [`conductor`](conductor/) | built-in host attribution | Rust | -First-party adapters (Codex CLI, Claude Code) are compiled into the daemon +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). diff --git a/adapters/conductor/README.md b/adapters/conductor/README.md new file mode 100644 index 0000000..e6764e3 --- /dev/null +++ b/adapters/conductor/README.md @@ -0,0 +1,11 @@ +# Conductor integration + +Conductor needs no pairing code for lifecycle detection. Codex and Claude +sessions running under `~/conductor/workspaces` are attributed to **Conductor** +by Microbridge's existing journal watchers, with no new poller or network call. + +Conductor's public API exposes session creation, status, cancellation, and deep +links, but does not expose a reliable mapping from an underlying Codex/Claude +journal id to the Conductor session id or an active-session effort update. +Microbridge therefore advertises lifecycle only instead of guessing with local +database reads or keyboard automation. diff --git a/adapters/factory/README.md b/adapters/factory/README.md new file mode 100644 index 0000000..07677e0 --- /dev/null +++ b/adapters/factory/README.md @@ -0,0 +1,21 @@ +# Factory integration + +Enable Factory in **Microbridge Settings → Adapters**. 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 +preserved; **Remove** deletes only Microbridge-owned entries and the helper. + +## Supported behavior + +- Lifecycle: `SessionStart`, `UserPromptSubmit`, `Notification`, `PreToolUse`, + `PostToolUse`, `Stop`, and `SessionEnd`. +- Interrupt: Factory's public `droid.interrupt_session` JSON-RPC method. +- Knob: Factory's public `droid.update_session_settings` method. Microbridge + reads the active session's documented settings and cycles only the reasoning + levels advertised by the installed Droid model catalogue. + +There is no resident Factory bridge process and no polling. Hooks run on state +transitions; control starts `droid` only for the requested hardware action. +Microbridge finds Droid in Factory.app, `~/.local/bin`, or common Homebrew +locations; custom installations can set `FACTORY_DROID_PATH` for the daemon. diff --git a/adapters/synara/README.md b/adapters/synara/README.md new file mode 100644 index 0000000..7f38849 --- /dev/null +++ b/adapters/synara/README.md @@ -0,0 +1,10 @@ +# Synara integration + +Synara needs no separate adapter process or pairing code. Its Codex and Claude +Agent SDK sessions use the standard agent journals, and Microbridge's built-in +watchers attribute them to **Synara** from the journal's official host metadata +or Synara worktree path. + +Lifecycle LEDs and Agent Key assignment work automatically. Commands remain +limited to capabilities exposed by the underlying Codex or Claude contract; +Microbridge does not inspect Synara private state or synthesize keystrokes. diff --git a/adapters/t3code/README.md b/adapters/t3code/README.md index 83d7c02..1060f08 100644 --- a/adapters/t3code/README.md +++ b/adapters/t3code/README.md @@ -6,7 +6,8 @@ its host contract and replaces the former idle Node scaffold. ## Pairing 1. Enable T3 Code in **Microbridge Settings → Adapters**. -2. In T3 Code, open **Settings → Connections** and create a one-time pairing link. +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. 4. The resulting bearer credential is stored in macOS Keychain under `ai.microbridge.t3code` and is removed by **Remove**. @@ -16,14 +17,18 @@ Microbridge uses T3 Code's authenticated public endpoints: - `GET /api/orchestration/shell` for lifecycle snapshots. - `GET /api/orchestration/threads/:threadId` for pending approval identity. - `POST /api/orchestration/dispatch` for approval and interrupt commands. +- `t3code://threads/:environmentId/:threadId` to open the owning thread. It never reads T3 Code databases, bootstrap credentials, or desktop internals. The compatibility suite is pinned to T3 server `0.0.28` and upstream contract commit `ebe8afb1df357423a0e036b388af3e739d640205`. Other server versions are reported as **Incompatible** until Microbridge verifies and ships their contract. -The adapter reports **Limited** when the paired HTTP contract does not advertise -focus/open or provider option descriptors for reasoning-effort adjustment. +The adapter opens threads with T3 Code's semantic deep link. Reasoning effort +stays disabled because the current paired HTTP snapshot includes the selected +value but not the provider/model option descriptors needed to choose the next +valid level safely. Microbridge does not fake this with key presses or private +desktop state. The compatibility target is the contract present in `pingdotgg/t3code` as of July 18, 2026. Authentication failures return to **Needs setup**; transport diff --git a/apps/microbridge-ui/package-lock.json b/apps/microbridge-ui/package-lock.json index 11c67fc..f3db517 100644 --- a/apps/microbridge-ui/package-lock.json +++ b/apps/microbridge-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "microbridge-ui", - "version": "0.2.4", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "microbridge-ui", - "version": "0.2.4", + "version": "0.3.0", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", diff --git a/apps/microbridge-ui/package.json b/apps/microbridge-ui/package.json index af9d3c5..b02ae9d 100644 --- a/apps/microbridge-ui/package.json +++ b/apps/microbridge-ui/package.json @@ -1,7 +1,7 @@ { "name": "microbridge-ui", "private": true, - "version": "0.2.4", + "version": "0.3.0", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/microbridge-ui/src-tauri/Cargo.lock b/apps/microbridge-ui/src-tauri/Cargo.lock index 0ca22a7..7d3af1c 100644 --- a/apps/microbridge-ui/src-tauri/Cargo.lock +++ b/apps/microbridge-ui/src-tauri/Cargo.lock @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "mb-protocol" -version = "0.2.4" +version = "0.3.0" dependencies = [ "serde", ] @@ -1853,7 +1853,7 @@ dependencies = [ [[package]] name = "microbridge-ui" -version = "0.2.4" +version = "0.3.0" dependencies = [ "mb-protocol", "serde", diff --git a/apps/microbridge-ui/src-tauri/Cargo.toml b/apps/microbridge-ui/src-tauri/Cargo.toml index e6127f8..a81d819 100644 --- a/apps/microbridge-ui/src-tauri/Cargo.toml +++ b/apps/microbridge-ui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "microbridge-ui" -version = "0.2.4" +version = "0.3.0" description = "Microbridge menu bar app (primary UI)" authors = ["Microbridge contributors"] edition = "2021" diff --git a/apps/microbridge-ui/src-tauri/src/lib.rs b/apps/microbridge-ui/src-tauri/src/lib.rs index 98ac11e..cee115e 100644 --- a/apps/microbridge-ui/src-tauri/src/lib.rs +++ b/apps/microbridge-ui/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod bus; use std::fs::{self, OpenOptions}; +use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixStream as StdUnixStream; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -312,6 +313,246 @@ fn sync_cursor_integration(app: &AppHandle, synced: &AtomicBool, enabled: bool) } } +const FACTORY_HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "Notification", + "PreToolUse", + "PostToolUse", + "Stop", + "SessionEnd", +]; +static FACTORY_HOOK_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn factory_hooks_path() -> Result { + let home = std::env::var_os("HOME").ok_or_else(|| "HOME is unavailable".to_string())?; + Ok(PathBuf::from(home).join(".factory/hooks.json")) +} + +fn factory_bridge_destination() -> Result { + let home = std::env::var_os("HOME").ok_or_else(|| "HOME is unavailable".to_string())?; + Ok(PathBuf::from(home).join(".microbridge/integrations/factory/microbridgectl")) +} + +fn microbridgectl_source() -> Result { + let mut candidates = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join("microbridgectl")); + } + } + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + candidates.push(manifest.join("../../../target/debug/microbridgectl")); + candidates.push(manifest.join("../../../target/release/microbridgectl")); + if let Some(home) = std::env::var_os("HOME") { + candidates.push(PathBuf::from(home).join(".local/bin/microbridgectl")); + } + candidates.extend([ + PathBuf::from("/opt/homebrew/bin/microbridgectl"), + PathBuf::from("/usr/local/bin/microbridgectl"), + ]); + candidates + .into_iter() + .find(|path| path.is_file()) + .ok_or_else(|| { + "The bundled microbridgectl helper is missing. Reinstall Microbridge.".into() + }) +} + +fn factory_hook_command(binary: &Path) -> String { + let escaped = binary.to_string_lossy().replace('\'', "'\\''"); + format!("'{escaped}' factory-event") +} + +fn merge_factory_hooks( + root: &mut serde_json::Value, + command: &str, + install: bool, +) -> Result<(), String> { + if !root.is_object() { + *root = serde_json::json!({}); + } + let hooks = root + .as_object_mut() + .expect("object initialized") + .entry("hooks") + .or_insert_with(|| serde_json::json!({})); + if !hooks.is_object() { + return Err( + "Factory hooks.json has a non-object `hooks` value; preserving it unchanged.".into(), + ); + } + let hooks = hooks.as_object_mut().expect("validated object"); + for event in FACTORY_HOOK_EVENTS { + let groups = hooks + .entry((*event).to_string()) + .or_insert_with(|| serde_json::json!([])); + let groups = groups.as_array_mut().ok_or_else(|| { + format!("Factory {event} hooks are not an array; preserving them unchanged.") + })?; + for group in groups.iter_mut() { + let Some(commands) = group + .get_mut("hooks") + .and_then(|value| value.as_array_mut()) + else { + continue; + }; + commands.retain(|hook| { + hook.get("command") + .and_then(|value| value.as_str()) + .is_none_or(|existing| existing != command) + }); + } + groups.retain(|group| { + group + .get("hooks") + .and_then(|value| value.as_array()) + .is_none_or(|commands| !commands.is_empty()) + }); + if install { + groups.push(serde_json::json!({ + "matcher": "*", + "hooks": [{ + "type": "command", + "command": command, + "timeout": 10 + }] + })); + } + } + Ok(()) +} + +fn write_json_atomic(path: &Path, value: &serde_json::Value) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "hooks path has no parent".to_string())?; + fs::create_dir_all(parent).map_err(|error| format!("create {}: {error}", parent.display()))?; + let staging = path.with_extension("json.microbridge.tmp"); + let body = serde_json::to_vec_pretty(value).map_err(|error| error.to_string())?; + fs::write(&staging, body).map_err(|error| format!("write {}: {error}", staging.display()))?; + fs::rename(&staging, path).map_err(|error| format!("replace {}: {error}", path.display())) +} + +fn install_factory_integration() -> Result { + let _operation = FACTORY_HOOK_LOCK + .lock() + .map_err(|_| "Factory integration installer lock is unavailable".to_string())?; + let source = microbridgectl_source()?; + let destination = factory_bridge_destination()?; + let hooks_path = factory_hooks_path()?; + let mut hooks = if hooks_path.is_file() { + let body = fs::read_to_string(&hooks_path) + .map_err(|error| format!("read {}: {error}", hooks_path.display()))?; + serde_json::from_str(&body) + .map_err(|error| format!("parse {}: {error}", hooks_path.display()))? + } else { + serde_json::json!({}) + }; + merge_factory_hooks(&mut hooks, &factory_hook_command(&destination), true)?; + let parent = destination + .parent() + .ok_or_else(|| "Factory helper has no parent".to_string())?; + fs::create_dir_all(parent).map_err(|error| format!("create {}: {error}", parent.display()))?; + let staging = destination.with_extension("installing"); + fs::copy(&source, &staging).map_err(|error| format!("copy Factory helper: {error}"))?; + fs::set_permissions(&staging, fs::Permissions::from_mode(0o755)) + .map_err(|error| format!("make Factory helper executable: {error}"))?; + let backup = destination.with_extension("backup"); + if backup.exists() { + fs::remove_file(&backup) + .map_err(|error| format!("remove stale {}: {error}", backup.display()))?; + } + if destination.exists() { + fs::rename(&destination, &backup) + .map_err(|error| format!("prepare Factory helper update: {error}"))?; + } + if let Err(error) = fs::rename(&staging, &destination) { + if backup.exists() { + let _ = fs::rename(&backup, &destination); + } + return Err(format!("install Factory helper: {error}")); + } + if let Err(error) = write_json_atomic(&hooks_path, &hooks) { + let _ = fs::remove_file(&destination); + if backup.exists() { + let _ = fs::rename(&backup, &destination); + } + return Err(error); + } + if backup.exists() { + fs::remove_file(&backup).map_err(|error| format!("remove old Factory helper: {error}"))?; + } + Ok(hooks_path) +} + +fn remove_factory_integration() -> Result { + let _operation = FACTORY_HOOK_LOCK + .lock() + .map_err(|_| "Factory integration installer lock is unavailable".to_string())?; + let destination = factory_bridge_destination()?; + let hooks_path = factory_hooks_path()?; + let mut changed = false; + if hooks_path.is_file() { + let body = fs::read_to_string(&hooks_path) + .map_err(|error| format!("read {}: {error}", hooks_path.display()))?; + let mut hooks: serde_json::Value = serde_json::from_str(&body) + .map_err(|error| format!("parse {}: {error}", hooks_path.display()))?; + let before = hooks.clone(); + merge_factory_hooks(&mut hooks, &factory_hook_command(&destination), false)?; + if hooks != before { + write_json_atomic(&hooks_path, &hooks)?; + changed = true; + } + } + if destination.is_file() { + fs::remove_file(&destination) + .map_err(|error| format!("remove {}: {error}", destination.display()))?; + changed = true; + } + Ok(changed) +} + +fn sync_factory_integration(synced: &AtomicBool, enabled: bool) { + if !enabled { + synced.store(false, Ordering::Relaxed); + return; + } + if !synced.swap(true, Ordering::Relaxed) && install_factory_integration().is_err() { + synced.store(false, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod factory_integration_tests { + use super::*; + + #[test] + fn merges_and_removes_only_microbridge_factory_hooks() { + let command = "'/tmp/microbridgectl' factory-event"; + let mut value = serde_json::json!({ + "hooks": { + "Stop": [{"matcher":"*","hooks":[{"type":"command","command":"keep-me"}]}] + } + }); + merge_factory_hooks(&mut value, command, true).unwrap(); + for event in FACTORY_HOOK_EVENTS { + assert!(value["hooks"][event] + .as_array() + .unwrap() + .iter() + .any(|group| group["hooks"] + .as_array() + .unwrap() + .iter() + .any(|hook| hook["command"] == command))); + } + merge_factory_hooks(&mut value, command, false).unwrap(); + assert_eq!(value["hooks"]["Stop"][0]["hooks"][0]["command"], "keep-me"); + assert!(!value.to_string().contains(command)); + } +} + 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), @@ -544,6 +785,30 @@ async fn set_adapter_enabled( } } } + if adapter_id == "factory" && enabled { + match install_factory_integration() { + Ok(path) => { + return Ok(format!( + "{message} Factory lifecycle hooks installed in {}. New and active Droid sessions will appear automatically.", + 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!( + "Factory was not enabled because its hooks could not be installed: {error}" + )); + } + } + } Ok(message) } @@ -576,10 +841,10 @@ async fn forget_adapter( .and_then(|snapshot| snapshot.config.adapters.get(&adapter_id)) .map(|preference| preference.enabled) .unwrap_or(false); - let removed = if adapter_id == "cursor" { - remove_cursor_integration()? - } else { - false + let removed = match adapter_id.as_str() { + "cursor" => remove_cursor_integration()?, + "factory" => remove_factory_integration()?, + _ => false, }; let operation = state .bus @@ -591,7 +856,11 @@ async fn forget_adapter( Ok(message) => message, Err(error) => { if removed { - let _ = install_cursor_integration(&app); + if adapter_id == "cursor" { + let _ = install_cursor_integration(&app); + } else if adapter_id == "factory" { + let _ = install_factory_integration(); + } } if was_enabled { let _ = state @@ -612,6 +881,11 @@ async fn forget_adapter( "{message} The bundled Cursor integration was removed. Reload Cursor once if it is already open." )); } + if adapter_id == "factory" && removed { + return Ok(format!( + "{message} The Microbridge-owned Factory hooks and helper were removed." + )); + } Ok(message) } @@ -831,6 +1105,8 @@ pub fn run() { let handle = app.handle().clone(); let cursor_integration_synced = Arc::new(AtomicBool::new(false)); 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); tauri::async_runtime::spawn(async move { let mut last_focus: Option = None; @@ -845,6 +1121,13 @@ pub fn run() { .map(|preference| preference.enabled) .unwrap_or(false); sync_cursor_integration(&handle, &cursor_sync_loop, cursor_enabled); + let factory_enabled = s + .config + .adapters + .get("factory") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_factory_integration(&factory_sync_loop, factory_enabled); last_focus = s.focused_session_id.clone(); saw_snapshot = true; *snap_for_loop.lock().await = Some(s.clone()); @@ -870,6 +1153,12 @@ pub fn run() { .map(|preference| preference.enabled) .unwrap_or(false); sync_cursor_integration(&handle, &cursor_sync_loop, cursor_enabled); + let factory_enabled = config + .adapters + .get("factory") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_factory_integration(&factory_sync_loop, factory_enabled); } let focus_changed = matches!(&event, BusEvent::FocusChanged { .. }); let mut guard = snap_for_loop.lock().await; @@ -895,6 +1184,12 @@ pub fn run() { .map(|preference| preference.enabled) .unwrap_or(false); sync_cursor_integration(&handle, &cursor_sync_loop, cursor_enabled); + let factory_enabled = config + .adapters + .get("factory") + .map(|preference| preference.enabled) + .unwrap_or(false); + sync_factory_integration(&factory_sync_loop, factory_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 df4b9dd..66e1830 100644 --- a/apps/microbridge-ui/src-tauri/tauri.conf.json +++ b/apps/microbridge-ui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Microbridge", - "version": "0.2.4", + "version": "0.3.0", "identifier": "ai.microbridge.ui", "build": { "beforeDevCommand": "npm run dev", @@ -72,7 +72,7 @@ "bundle": { "active": true, "targets": "all", - "externalBin": ["binaries/microbridged"], + "externalBin": ["binaries/microbridged", "binaries/microbridgectl"], "createUpdaterArtifacts": true, "resources": { "../../../adapters/cursor/.cursor-plugin/plugin.json": "cursor-plugin/.cursor-plugin/plugin.json", diff --git a/apps/microbridge-ui/src/lib/bus.ts b/apps/microbridge-ui/src/lib/bus.ts index da77aca..761b684 100644 --- a/apps/microbridge-ui/src/lib/bus.ts +++ b/apps/microbridge-ui/src/lib/bus.ts @@ -26,8 +26,8 @@ const DEMO: Snapshot = { }, { id: "s3", - app: "Cursor", - title: "synara — onboarding empty states", + app: "Conductor", + title: "workspace — onboarding empty states", state: "thinking", updated_at_ms: Date.now() - 60000, }, @@ -60,6 +60,7 @@ const DEMO: Snapshot = { claude: { enabled: true }, cursor: { enabled: false }, t3code: { enabled: false }, + factory: { enabled: false }, }, hardware_control_enabled: false, brightness: 80, @@ -99,6 +100,38 @@ const DEMO: Snapshot = { }, diagnostic: "Built-in lifecycle watcher is active.", }, + { + id: "synara", + display_name: "Synara", + 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 host attribution is active for Codex and Claude sessions.", + }, + { + id: "conductor", + display_name: "Conductor", + 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 host attribution is active for Conductor workspaces.", + }, { id: "cursor", display_name: "Cursor", @@ -131,6 +164,22 @@ const DEMO: Snapshot = { }, diagnostic: "Disabled until you explicitly enable this integration.", }, + { + id: "factory", + display_name: "Factory", + 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.", + }, ], }; diff --git a/apps/microbridge-ui/src/surfaces/Settings.tsx b/apps/microbridge-ui/src/surfaces/Settings.tsx index 6ff88f9..6d4b2ba 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 IDE — newest threads (Claude, Codex, Cursor, Synara, T3)", + hint: "Owning app — newest threads (Claude, Codex, Cursor, Synara, T3, Conductor, Factory)", }, { id: "most_recent", @@ -580,13 +580,16 @@ export function Settings({

Adapters

- Cursor ships inside Microbridge and installs locally with one - click. Community describes ownership, not readiness. State and - capabilities below are live. + 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 Codex threads are identified automatically. Enable and pair - the T3 Code card only when you also want T3's supported thread controls. + 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.

{adapterMessage && (

@@ -685,12 +688,16 @@ export function Settings({ void runAdapterOperation(adapter.id, () => setAdapterEnabled(adapter.id, true)) } > - {adapter.id === "cursor" ? "Enable Cursor" : "Enable integration"} + {adapter.id === "cursor" + ? "Enable Cursor" + : adapter.id === "factory" + ? "Enable Factory" + : "Enable integration"} )} {adapter.kind === "community" && cfg.adapters[adapter.id]?.enabled && ( <> - {adapter.id === "cursor" && ( + {(adapter.id === "cursor" || adapter.id === "factory") && (